ledger: count issue-tagged commits and repo seat messages (#1506)

This commit is contained in:
2026-09-12 12:04:15 -05:00
parent db0d784bd8
commit cd0aa5fbb3
12 changed files with 677 additions and 7 deletions
+45
View File
@@ -2152,3 +2152,48 @@ one scan; the seat's attempt to agent-send back to `control-board` was
refused, correctly, since the board has no pane. Fix: every board message
ends with a fixed trailer saying to answer in the seat's own session.
## 2026-09-12 — Ledger piece 3 intake (#1506)
Before: no numeric report ties issue-tagged refactor commits to repo seat user
messages. Darkwing owns packages/ledger and fixtures; independent review precedes
commit/push to refactor. Brief and Gate D remain on the control-board plan page.
Single-request constraint: Gitea caps issue lists at 50. Query issues updated
since the start date, reject a full page as potentially incomplete, and show
unknown metadata for commit-referenced issues absent from that response.
The live date-range query returned 19 issues; the unfiltered query hit the cap.
No pagination, credential inspection, fleet reads, or unrelated source changes.
## 2026-09-12 — Ledger implementation ready for review (#1506)
After: packages/ledger provides the read-only CLI, two tables, JSON and explicit
unknown values. Fixtures pass 20/20; combined ledger, board, seat and registry
Node suites pass 202/202. Syntax and diff whitespace checks pass. No root CI
workflow exists, and no live/task/release suite or deployment is claimed.
Live run for September 6 through 12 succeeded after fixing the existing
gitea-api.sh cleanup trap: a no-body GET used to return 1 even on HTTP 200.
The one-line fix has offline GET 200, POST 201 and GET 403 regressions.
LEDGER-1506-R1 sent to Filbert on default socket via agent-send.sh, exit 0,
transport dispatched with application acceptance unknown. Exact candidates and
pre-publication totals are in reviews/2026-09-12_ledger-review-request.md.
Waiting for his verdict before publication; he owns the reply wake. No timer.
Ledger verification addendum #1506: clean HEAD-plus-candidate Node suite 202/202.
First clean copy omitted tools/tmux, so the board executable-presence test failed;
restoring that tracked dependency fixed the test setup, with no source change.
Concurrent control-board planning commit db0d784b included the CURRENT intake
line while adding DEFERRED.md. Reconciled and preserved it; candidate source
hashes unchanged. Gitea progress comment 26061 records review and test status.
## 2026-09-12 — Ledger independent approval and publication gate (#1506)
Filbert APPROVED LEDGER-1506-R1, independently ran 20/20 offline fixtures,
verified all six pinned SHA-256 hashes, and reproduced the GET cleanup defect.
Darkwing reverified those hashes and the shared worktree before integration.
Review: docs/plans/reviews/2026-09-12_ledger-verdict.md. Helper hardening is
recorded in DEFERRED.md, not silently added to the reviewed patch. Correction
to the review provenance wording: db0d784b also includes the CURRENT intake
line, not only DEFERRED.md; its diff on the six candidate paths is still empty.
Publication is authorized to refactor only. Gate D and #1506 closure await
Jason writing a sentence with a measured number into CURRENT.md. No WebUI,
fleet, live deployment, role changes or unrelated files are part of this commit.
+3
View File
@@ -225,3 +225,6 @@ are never rewritten or removed; corrections are new entries.
- 2026-09-12 — darkwing (Claude Code, coordinator) — Control board: model per row (#1503) on Jason's ask: readSession tracks model/provider from the latest model_change entry or assistant turn; page shows the model under the agent name and a Model detail row. Live check on a scratch board: all 42 real rows named a model. Board 91/91. Committed and pushed to refactor.
- 2026-09-12 — darkwing (Claude Code, coordinator) — Control board piece 2, reply-from-board (#1505, brief by Jason): `POST /api/reply` runs `tools/tmux/agent-send.sh` for one registered, live seat and returns the exit code and streams; the page has a reply box in registered live rows' detail with delivered/failed receipts. Live check on a scratch tmux session through the real agent-send.sh: delivered, then a failure receipt with the tool's stderr. Board 98/98. Gate C first exchange ("pizza?") delivered and read back within one scan; the seat's send-back to control-board was refused as expected, so every board message now ends with a fixed trailer (Jason's ruling via the professor session). Committed and pushed to refactor.
- 2026-09-12 — darkwing (Claude Code, coordinator) — Gate C pass for reply-from-board (#1505) verified against Jason's log commit 4f830680 and filbert's session log; CURRENT.md moved to waiting for the ledger brief; #1505 closed with a comment. Committed and pushed to refactor.
- 2026-09-12 — darkwing, native Pi, /mnt/storage/src/mosaic-stack — Accepted piece 3 ledger, opened #1506 via Gitea HTTP 201; implementing packages/ledger and tests from the approved brief, independent review then authorized refactor commit/push. Gate D remains Jason-owned.
- 2026-09-12 — filbert (native Pi, /mnt/storage/src/mosaic-stack) — LEDGER-1506-R1 independent review: all six pinned SHA-256 hashes match; baseline 889d8750 proven ancestor of HEAD db0d784b with empty diff on candidate paths; node --test 20/20 pass offline on fixtures (node v26.8.1); helper EXIT-trap defect reproduced in isolation and the if-form fix verified; verdict APPROVED with six non-blocking observations at docs/plans/reviews/2026-09-12_ledger-verdict.md. Read-only: no source edits, no git mutations, no real API or private-session reads.
- 2026-09-12 — darkwing, native Pi — #1506 ledger implemented, 20/20 fixtures and 202/202 combined Node regressions including clean candidate copy; Filbert exact-candidate APPROVED, six hashes reverified. Integrating under authorized refactor commit/push; CURRENT names Jason-owned Gate D, issue stays open pending his sentence.
+15 -6
View File
@@ -50,12 +50,21 @@ Gate C passed 2026-09-12 on 867619dc (logged by Jason at 4f830680):
answered in its own session with no send attempt, and the row showed the
answer on the next scan. #1505 is closed.
Next action: Darkwing is building piece 3, the ledger, under #1506. Jason approved it on 2026-09-12 and
the brief is on the plan page, section "Piece 3: ledger (numbers for the
rails)": `packages/ledger`, read-only CLI over the local git log, Gitea
issues and the repo seats' session logs, with Gate D (Jason writes one
sentence with one number from it into this file). Open the Gitea issue
first. Then the WebUI on Dewey's Console design absorbs the board as its
Next action: Jason runs Gate D for piece 3, the ledger, under #1506:
`node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12`.
Choose one number to move next week and write that sentence with the number
into this file. Do not infer acceptance from the implementation or tests.
`packages/ledger` is implemented: read-only local refactor subjects, one
Gitea issue request, repo seats' user messages, two tables and JSON. Ledger
fixtures 20/20; ledger, board, seat and registry suites 202/202, also checked
in a clean candidate copy. Filbert independently APPROVED all six pinned
source/test/doc files in `reviews/2026-09-12_ledger-verdict.md`. The helper's
no-body GET cleanup exit defect is fixed and regression-tested. Counting
rules, one-page refusal and unknown metadata limits are in the package README.
Jason authorized commit/push to refactor; #1506 stays open for Gate D.
The brief remains in the plan page under "Piece 3: ledger (numbers for the rails)".
After Gate D, the WebUI on Dewey's Console design absorbs the board as its
first screen. Close #1503 when Jason says the page answers "who
is waiting on me" without him opening a terminal. Out of scope until he
asks: auth or provider registry, roster schema changes, hooks/plugins, comms,
+7
View File
@@ -46,6 +46,13 @@ at every gate. Started 2026-09-12 during the control board MVP.
registry increment 3 (headless identity-env leak), new seat directories.
(plan page, 2026-09-12 parking note)
- **Gitea helper response-file hardening.** Predictable `/tmp/gitea-api-response.$$`
has no explicit 0600 mode; use `mktemp` with cleanup in a separately reviewed
pass. Credential validation and later reread also have a race window. Other
non-blocking ledger review notes remain in
`reviews/2026-09-12_ledger-verdict.md`. No change beyond the reviewed GET
cleanup fix in this piece. Filbert, LEDGER-1506-R1, 2026-09-12, #1506.
## Queue (Jason's order)
1. Ledger (piece 3, in progress, Gate D).
@@ -0,0 +1,43 @@
# Ledger review request, #1506
Author Darkwing requests independent read-only review by Filbert of piece 3.
Baseline 889d8750 on refactor. Approved brief is Piece 3 on
`docs/plans/2026-09-12_control-board-mvp.md`. No author self-approval.
Review these exact candidates and run `node --test packages/ledger/tests/`.
Use only fixtures, no real Gitea/session reads needed. Do not edit source or
unrelated work. Write the verdict, tested hashes and findings to
`docs/plans/reviews/2026-09-12_ledger-verdict.md`, then notify darkwing.
SHA-256:
```text
601db1de6d4838785f334dd93dd191d444276f306e016b3a4137659eaae76561 packages/ledger/README.md
e33fc5be5cdeb69f6f443a44c5b02a7bc346ac74dd69f984538e424a8d1b5de9 packages/ledger/src/cli.mjs
fbd95e45c5753cb44e39dfead0c6e0d2a5f07859dd9411f1dd659dde98af2ffc packages/ledger/src/ledger.mjs
c836ecd64068cccf7f515ab699c6a218e5ea1618c39e7e88be21bfa74d96414e packages/ledger/tests/gitea-helper.test.mjs
8e838a5333cbe6aafda5d72b810eb2840c4439973841d9c2bf5b55bc660455a3 packages/ledger/tests/ledger.test.mjs
033e6d5fdd4626fb0ca1c981b6d85eba961a4618a303faf2bb7b30ae024ed9a6 scripts/gitea-api.sh
```
Author fixtures 20/20. Node syntax, shell syntax and diff whitespace pass.
No root Gitea/GitHub workflow directory exists, so no CI run is claimed.
Direct refactor publication follows the approved brief; no PR/trunk merge or
live deployment. Gate D stays with Jason.
Important choices to assess: UTC inclusive dates, in-range follow-ups, unique
commit total versus per-issue links, zero/unknown denominator handling, and
one Gitea call. Gitea capped an unfiltered request at 50; updated-since returned
19 issues for this week. Full 50-row pages fail, missing row metadata is unknown.
Session counts are literal entries, without copied-log dedup or authorship claims.
Blocking helper defect discovered by real use: GET returned HTTP 200 with valid
JSON but status 1 because the EXIT cleanup's final conditional failed when there
was no body file. One-line `if` correction preserves successful GET status; fake
Node/curl tests cover GET 200, POST 201 and GET 403 without reading credentials.
Live pre-publication measurement: 8 issues closed, 2.7 median hours open,
40 tagged commits, 1.9 follow-ups per issue, 18.9 human messages per closed issue.
These are changing source counts, not the final post-publication measurement.
Archived numeric references can point to unrelated same-number current issues;
the README explicitly disclaims remapping, matching the literal-subject brief.
@@ -0,0 +1,114 @@
# Ledger review verdict, LEDGER-1506-R1
Reviewer: Filbert (independent; author is Darkwing, no self-approval).
Request: `docs/plans/reviews/2026-09-12_ledger-review-request.md`.
Brief: Piece 3, `docs/plans/2026-09-12_control-board-mvp.md` (approved by
Jason 2026-09-12). Review date: 2026-09-12.
## Verdict: APPROVED
Non-blocking observations are listed below; none contradicts the brief, the
boundaries, or the fail-closed rules. Gate D remains Jason's. This verdict
authorizes the transition to publication per the approved brief; it grants no
push, merge, or deployment authority.
## Candidate identity
Baseline `889d8750` verified on `refactor`: it is an ancestor of current HEAD
`db0d784b`; the single intervening commit (`db0d784b`) touches only
`docs/plans/DEFERRED.md` and produces an empty diff on all six candidate
paths. The reviewed worktree files are therefore byte-identical to the
baseline candidate.
Recomputed SHA-256 (all six match the pinned values):
```text
601db1de6d4838785f334dd93dd191d444276f306e016b3a4137659eaae76561 packages/ledger/README.md
e33fc5be5cdeb69f6f443a44c5b02a7bc346ac74dd69f984538e424a8d1b5de9 packages/ledger/src/cli.mjs
fbd95e45c5753cb44e39dfead0c6e0d2a5f07859dd9411f1dd659dde98af2ffc packages/ledger/src/ledger.mjs
c836ecd64068cccf7f515ab699c6a218e5ea1618c39e7e88be21bfa74d96414e packages/ledger/tests/gitea-helper.test.mjs
8e838a5333cbe6aafda5d72b810eb2840c4439973841d9c2bf5b55bc660455a3 packages/ledger/tests/ledger.test.mjs
033e6d5fdd4626fb0ca1c981b6d85eba961a4618a303faf2bb7b30ae024ed9a6 scripts/gitea-api.sh
```
## Independent test evidence
- `node --test packages/ledger/tests/` on node v26.8.1: **20 tests, 20 pass,
0 fail** (duration ~1.4 s). Offline: fixtures are temp git repos, temp JSONL
logs, and stubbed `gitea-api.sh` on PATH; the helper regression test points
`MOSAIC_GITEA_CREDENTIAL_FILE` at a nonexistent file and stubs `node` before
any credential read. No real API call, no private-session read, no
credential access occurred during review.
- `node --check` on all four `.mjs` files: OK. `bash -n scripts/gitea-api.sh`:
OK. No trailing whitespace in any candidate file.
- Helper defect reproduced mechanically: a `cleanup() { [ -n "$X" ] && rm …; }`
EXIT trap under `set -euo pipefail` turns a successful run into exit 1;
the candidate's `if` form exits 0. Verified with an isolated script outside
the repository. The suite's GET 200 → exit 0, POST 201 → exit 0, and
GET 403 → exit 1 cases directly regression-cover the fix.
## Brief conformance
- CLI shape matches: `--since` required; `--until` defaults to today UTC;
`--json`; `--no-issues`; unknown/duplicate arguments refused (exit 1).
Sources are consulted in the brief's order: local `git log refactor`
(subjects only, range self-filtered — correct, since `git --since` prunes at
out-of-order dates), then the single Gitea call, then repo-seat session
logs.
- Table 1 columns, Table 2 board/agent/human classification by first-line
preamble, and the totals line all match the brief. Issue seats come from
`#N` in in-range user text.
- The "unknown, never a guess" rule holds everywhere it applies: missing issue
metadata, `--no-issues` aggregates, zero-denominator ratios with nonzero
human counts, negative durations, and malformed/partial session evidence
(refuses with file and line, never echoing content).
- Read-only boundaries hold: no filesystem writes; PATH mutation restored in a
`finally`; no credential reads in ledger code; API error bodies and stderr
are never echoed (tested); control characters are stripped from printed
titles; symlinked source directories are refused and symlinked `.jsonl`
files are skipped; no fleet paths; no network beyond the one Gitea call.
The five flagged choices are each implemented as documented in the README and
internally consistent, each with a covering test:
1. UTC dates inclusive at both endpoints (tested at 00:00:00.000 and
23:59:59.999, next-day exclusive).
2. Follow-ups counted in range only (`max(commits 1, 0)`), explicitly not a
lifetime count.
3. A commit naming several issues counts once in total commits and once per
linked row (tested with `both #1 #2 #2`).
4. Zero/unknown denominators: empty report → 0 totals; human messages with
zero closed issues → `unknown`; `--no-issues``unknown` closed-count,
median, and ratio.
5. One Gitea call: updated-since, first page, limit 50; a full 50-row page
refuses (exit 2) rather than silently undercounting; a commit-linked issue
the query did not return still gets a row with `unknown` metadata.
## Non-blocking observations
1. `scripts/gitea-api.sh` writes the API response body to
`/tmp/gitea-api-response.$$` — predictable name, not `mktemp`, no 0600.
On a multi-user host this could expose or race a private repo's issue
content. Single-user POC host; the POST body file already uses
`mktemp` + `chmod 600`. Suggest `mktemp` here in a later pass.
2. `REPO_PATH` in `gitea-api.sh` is computed but never used; the header usage
example also references `repos/mosaicstack/stack-v2` while the canonical
repo is `mosaicstack/stack`. Cosmetic.
3. The credential file is validated once for `BASE` and read a second time in
`gen_curl_cfg` (TOCTOU window). Acceptable on this host.
4. A literal `|` in an issue title shifts text-table columns (JSON output is
unaffected; no structured-parse contract exists for the text table).
5. `readSessions` refuses a partially written final JSONL line. This is
documented in the README as intended fail-closed behavior; note it can
refuse while a seat is mid-write.
6. The `directories()` helper's specific message "Session source must be a
real directory" is always wrapped by the catch into "Cannot read ledger
directory: <path>"; the specific string is unreachable. The path is still
reported, so diagnosis is unimpaired. Cosmetic.
## Scope statement
Read-only review: hashes, `git rev-parse`/`git log`/`git diff` provenance
checks (no mutations), the test suite, and the six files. No source edits, no
git mutations, no real API or private-session reads. This verdict and the
SESSIONS.md registration are the only files written.
+80
View File
@@ -0,0 +1,80 @@
# Ledger
Read-only counts from local `refactor` commit subjects, one Gitea issue-list
request through `scripts/gitea-api.sh`, and repo seats' Pi session logs.
No board changes, data-root writes, fleet reads, transcript output, or scheduler.
```sh
node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12
node packages/ledger/src/cli.mjs --since 2026-09-06 --until 2026-09-12 --json
node packages/ledger/src/cli.mjs --since 2026-09-06 --no-issues
node --test packages/ledger/tests/
```
Dates include both endpoints in UTC. Omitted `--until` means today in UTC.
The checkout containing this package supplies the sources, not the shell's cwd.
No install, build, service restart, or configuration change is needed.
## Counting rules
- Git uses committer timestamps and every commit reachable from local `refactor`,
including merges. It reads subjects only. A repeated `#N` in one subject counts
once. A commit naming two issues counts for both rows but once in total commits.
Untagged commits do not count. Issue numbers are literal references to
`mosaicstack/stack`, with no attempt to remap archived repositories' numbers.
This can associate historical references with unrelated same-number issues.
- Table 1 includes issues with a tagged commit or a `closed_at` in range.
Opened is `created_at`; hours open is `closed_at - created_at`, rounded to one
decimal, or `open` if not closed. It is not age as of `--until`. Reopen history
is unavailable from the issue-list response. Median hours open uses only
issues closed in range and rounds after computing the median.
- Follow-ups are `max(commits in range - 1, 0)` per issue, not a lifetime count
and not a quality assessment. Follow-ups per issue divides their sum by all
Table 1 rows, including close-only rows.
- Table 2 counts user-message entries in `.pi/state/<seat>/sessions/*.jsonl`
where `<seat>` is a real directory in `agents/`. All matching files count;
duplicated entries in copied logs are not deduplicated. No transcript content
leaves the parser. Assistant messages and logs outside repo seats do not count.
Symlink source directories are refused and symlink files are not followed.
- The first text line alone classifies a message. A bracketed addressing
preamble whose source session is `control-board` is board; any other valid
addressing preamble is agent; otherwise human. This is a format count, not
proof of who typed the message. Text blocks are joined with newlines.
The entry timestamp is used, falling back to the message timestamp.
- Seats with no in-range user messages are omitted. Issue seats come from `#N`
mentions anywhere in in-range user text, including quoted text.
- Human messages per closed issue divides Table 2's human sum by issues closed
in range. A zero denominator with human messages is `unknown`; a truly empty
report has zero totals. JSON keeps numeric values as numbers; text displays
ratios and durations with one decimal. Titles truncate to 48 characters in
text only. Missing evidence is the literal string `unknown`.
## One Gitea call and missing evidence
The client requests issues updated since the start date, all states, first page,
limit 50. This includes issues closed in range, even if later updated. Gitea caps
responses at 50; a full page fails rather than silently reporting partial totals.
Use a narrower range or `--no-issues`, not hidden pagination. A commit-linked
issue not returned by the updated-since query still has a row, with unknown
metadata. This is the cost of the brief's one-call boundary.
Exit 0 means a report was computed. Exit 1 means bad arguments or unreadable git
or session evidence. Malformed JSONL, including a partially written last line,
refuses the report; rerun after the seat finishes writing. Exit 2 means issue
credentials, API, payload, or completeness failure. The CLI never prints API
error bodies or reads authentication files itself. `--no-issues` makes no API
call, keeps commit-derived rows, and shows unknown issue metadata, closed counts,
median duration, and human-per-closed ratio. It cannot invent close-only rows.
For fixtures, a fake `gitea-api.sh` can be placed first on PATH. Otherwise the
repository scripts directory is appended to PATH for the issue request.
Tests use only temporary repositories, logs, and fake API tools, with no real
credentials or network. The helper regression stubs Node before any credential
read and checks successful GET, successful POST, and failed HTTP status.
## Acceptance
Gate D is Jason's: run the requested week, choose a number to move next week,
and write the sentence and number into `docs/plans/CURRENT.md`. Automated tests
and publication do not pass that gate. Remove the package to stop using it;
there is no persistent ledger state to migrate or restore.
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dateRange, readCommits, readIssues, readSessions, summarize, formatTable, SourceError } from './ledger.mjs';
const usage = 'Usage: node packages/ledger/src/cli.mjs --since YYYY-MM-DD [--until YYYY-MM-DD] [--json] [--no-issues]';
export async function main(args, root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')) {
let since, until, json = false, noIssues = false;
const seen = new Set();
for (let i = 0; i < args.length; i++) {
const flag = args[i];
if (seen.has(flag)) throw new SourceError(`Duplicate option: ${flag}`);
seen.add(flag);
if (flag === '--help') { console.log(usage); return; }
if (flag === '--json') json = true;
else if (flag === '--no-issues') noIssues = true;
else if (flag === '--since' || flag === '--until') {
const value = args[++i];
if (!value || value.startsWith('--')) throw new SourceError(`${flag} requires a date`);
if (flag === '--since') since = value; else until = value;
} else throw new SourceError('Unknown option; ' + usage);
}
if (!since) throw new SourceError(usage);
const range = dateRange(since, until);
const commits = readCommits(root, range);
// Fixture tools may be placed first on PATH. The repository client is the
// default without requiring installation or reading auth material here.
const priorPath = process.env.PATH;
process.env.PATH = `${priorPath ?? ''}${path.delimiter}${path.join(root, 'scripts')}`;
let issues;
try { issues = noIssues ? null : readIssues(root, range); }
finally { if (priorPath === undefined) delete process.env.PATH; else process.env.PATH = priorPath; }
const sessions = await readSessions(root, range);
const report = summarize(range, commits, issues, sessions);
console.log(json ? JSON.stringify(report, null, 2) : formatTable(report));
return report;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2)).catch(error => {
console.error(error instanceof SourceError ? error.message : 'Ledger failed: cannot read source evidence');
process.exitCode = error.exitCode ?? 1;
});
}
+181
View File
@@ -0,0 +1,181 @@
import { execFileSync } from 'node:child_process';
import { createReadStream } from 'node:fs';
import { readdir, lstat } from 'node:fs/promises';
import { createInterface } from 'node:readline';
import path from 'node:path';
const DAY = 86400000;
export const UNKNOWN = 'unknown';
export class SourceError extends Error {
constructor(message, exitCode = 1) { super(message); this.exitCode = exitCode; }
}
export function dateRange(since, until = new Date().toISOString().slice(0, 10)) {
const parse = value => {
const ms = Date.parse(`${value}T00:00:00Z`);
if (!/^\d{4}-\d{2}-\d{2}$/.test(value ?? '') || !Number.isFinite(ms) ||
new Date(ms).toISOString().slice(0, 10) !== value) {
throw new SourceError('Dates must be valid YYYY-MM-DD values');
}
return ms;
};
const start = parse(since), end = parse(until) + DAY;
if (end <= start) throw new SourceError('--until must not precede --since');
return { since, until, start, end };
}
const inRange = (value, range) => {
const ms = typeof value === 'number' ? value : Date.parse(value);
return Number.isFinite(ms) && ms >= range.start && ms < range.end;
};
export const issueNumbers = text => [...new Set(
[...text.matchAll(/(?:^|[^\w])#([1-9]\d*)\b/g)].map(m => Number(m[1]))
)].filter(Number.isSafeInteger);
export function readCommits(root, range) {
let output;
try {
// Filter ourselves: git --since can prune history at an out-of-order date.
output = execFileSync('git', ['log', 'refactor', '--format=%H%x00%ct%x00%s'],
{ cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
} catch { throw new SourceError('Cannot read local refactor git log'); }
return output.split('\n').filter(Boolean).flatMap(line => {
const [hash, seconds, subject] = line.split('\0');
const issues = issueNumbers(subject ?? '');
return inRange(Number(seconds) * 1000, range) && issues.length ? [{ hash, issues }] : [];
});
}
export function readIssues(root, range, tool = 'gitea-api.sh') {
let output;
try {
// Updated-since includes closes in range. No until filter: later updates must
// not hide a close in range. One page only, as required by the brief.
output = execFileSync(tool, ['GET', `repos/mosaicstack/stack/issues?state=all&type=issues&since=${encodeURIComponent(new Date(range.start).toISOString())}&limit=50&page=1`],
{ cwd: root, encoding: 'utf8', timeout: 60000, maxBuffer: 16 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
} catch (error) {
// Never echo an arbitrary API response or stderr that might contain secrets.
const reason = error.status === 3 ? 'credentials missing, unreadable, or invalid' :
error.code === 'ENOENT' ? 'gitea-api.sh unavailable' : 'credential or Gitea request failure';
throw new SourceError(`Issues unavailable: ${reason}; use --no-issues for unknown issue metrics`, 2);
}
let issues;
try { issues = JSON.parse(output); } catch { throw new SourceError('Issues unavailable: invalid Gitea JSON', 2); }
if (!Array.isArray(issues) || issues.some(i => !i || !Number.isSafeInteger(i.number) || i.number < 1 ||
typeof i.title !== 'string' || !Number.isFinite(Date.parse(i.created_at)) ||
!(i.closed_at === null || Number.isFinite(Date.parse(i.closed_at))))) {
throw new SourceError('Issues unavailable: invalid Gitea issue records', 2);
}
if (issues.length >= 50) throw new SourceError('Issues unavailable: full 50-row page may be incomplete; one-call limit forbids pagination. Narrow --since or use --no-issues', 2);
if (new Set(issues.map(i => i.number)).size !== issues.length) throw new SourceError('Issues unavailable: duplicate issue numbers', 2);
return issues.filter(i => !i.pull_request);
}
export function messageText(content) {
if (typeof content === 'string') return content;
if (Array.isArray(content)) return content.filter(c => c?.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
return '';
}
export function messageKind(text) {
const firstLine = text.split(/\r?\n/, 1)[0];
const match = firstLine.match(/^\[([^\s:\[\]]+):([^\s\[\]]+) -> ([^\s:\[\]]+):([^\s\[\]]+)(?: class=[a-z-]+)?\](?:\s|$)/);
return !match ? 'human' : match[2] === 'control-board' ? 'board' : 'agent';
}
async function directories(dir, optional = false) {
try {
if (!(await lstat(dir)).isDirectory()) throw new SourceError('Session source must be a real directory');
return await readdir(dir, { withFileTypes: true });
} catch (error) {
if (optional && error.code === 'ENOENT') return [];
throw new SourceError(`Cannot read ledger directory: ${dir}`);
}
}
export async function readSessions(root, range) {
const rows = [];
const mentions = new Map();
// No symlink traversal, no fleet paths, no transcript content in the report.
const agents = (await directories(path.join(root, 'agents'))).filter(e => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name));
const state = path.join(root, '.pi', 'state');
// Check every source ancestor, not only the leaf directory.
if (!(await directories(path.join(root, '.pi'), true)).length) return { rows, mentions };
await directories(state, true);
for (const agent of agents) {
const seatRoot = path.join(state, agent.name);
await directories(seatRoot, true);
const dir = path.join(seatRoot, 'sessions');
const files = (await directories(dir, true)).filter(e => e.isFile() && e.name.endsWith('.jsonl'));
const row = { seat: agent.name, board: 0, agent: 0, human: 0 };
for (const file of files) {
const input = createReadStream(path.join(dir, file.name));
const lines = createInterface({ input, crlfDelay: Infinity });
let lineNumber = 0;
try {
for await (const line of lines) {
lineNumber++;
if (!line.trim()) continue;
let entry;
try { entry = JSON.parse(line); }
catch { throw new SourceError(`Malformed session JSON: ${agent.name}/${file.name}:${lineNumber}`); }
if (entry?.type !== 'message' || entry.message?.role !== 'user') continue;
const stamp = entry.timestamp ?? entry.message.timestamp;
if (!Number.isFinite(typeof stamp === 'number' ? stamp : Date.parse(stamp))) {
throw new SourceError(`Invalid user-message timestamp: ${agent.name}/${file.name}:${lineNumber}`);
}
if (!inRange(stamp, range)) continue;
const text = messageText(entry.message.content);
row[messageKind(text)]++;
for (const number of issueNumbers(text)) {
if (!mentions.has(number)) mentions.set(number, new Set());
mentions.get(number).add(agent.name);
}
}
} finally { lines.close(); input.destroy(); }
}
if (row.board + row.agent + row.human) rows.push(row);
}
return { rows, mentions };
}
const round = value => Math.round(value * 10) / 10;
function duration(issue) {
if (!issue) return UNKNOWN;
if (!issue.closed_at) return 'open';
const hours = (Date.parse(issue.closed_at) - Date.parse(issue.created_at)) / 3600000;
return hours >= 0 ? hours : UNKNOWN;
}
export function summarize(range, commits, issues, sessions) {
const byNumber = new Map((issues ?? []).map(i => [i.number, i]));
const counts = new Map();
for (const commit of commits) for (const n of commit.issues) counts.set(n, (counts.get(n) ?? 0) + 1);
const closed = (issues ?? []).filter(i => i.closed_at && inRange(i.closed_at, range));
const touched = new Set([...counts.keys(), ...closed.map(i => i.number)]);
const rows = [...touched].sort((a, b) => a - b).map(number => {
const issue = byNumber.get(number), count = counts.get(number) ?? 0;
const hours = duration(issue);
return { issue: number, title: issue?.title ?? UNKNOWN, opened: issue?.created_at ?? UNKNOWN,
hoursOpen: typeof hours === 'number' ? round(hours) : hours, commits: count,
followUps: Math.max(0, count - 1), seats: [...(sessions.mentions.get(number) ?? [])].sort() };
});
const hours = closed.map(duration).sort((a, b) => a - b);
const middle = Math.floor(hours.length / 2);
const median = hours.includes(UNKNOWN) ? UNKNOWN : hours.length ?
round(hours.length % 2 ? hours[middle] : (hours[middle - 1] + hours[middle]) / 2) : 0;
const human = sessions.rows.reduce((sum, r) => sum + r.human, 0);
return { since: range.since, until: range.until, timezone: 'UTC', issues: rows, seats: sessions.rows,
totals: { issuesClosed: issues === null ? UNKNOWN : closed.length,
medianHoursOpen: issues === null ? UNKNOWN : median, commits: commits.length,
followUpsPerIssue: rows.length ? round(rows.reduce((sum, r) => sum + r.followUps, 0) / rows.length) : 0,
humanMessagesPerClosedIssue: issues === null ? UNKNOWN : closed.length ? round(human / closed.length) : human ? UNKNOWN : 0 } };
}
const clean = value => String(value).replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
const decimal = value => typeof value === 'number' ? value.toFixed(1) : value;
export function totalsLine(t) {
return `Totals: issues closed ${t.issuesClosed} | median hours open ${decimal(t.medianHoursOpen)} | commits ${t.commits} | follow-ups per issue ${decimal(t.followUpsPerIssue)} | human messages per closed issue ${decimal(t.humanMessagesPerClosedIssue)}`;
}
export function formatTable(report) {
return [`Ledger ${report.since} through ${report.until} UTC`,
'Issue | Title | Opened | Hours open | Commits | Follow-ups | Seats',
...report.issues.map(r => [`#${r.issue}`, clean(r.title).slice(0, 48), r.opened,
decimal(r.hoursOpen), r.commits, r.followUps, r.seats.join(', ')].join(' | ')),
'', 'Seat | Board | Agent | Human',
...report.seats.map(r => [clean(r.seat), r.board, r.agent, r.human].join(' | ')),
'', totalsLine(report.totals)].join('\n');
}
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const helper = fileURLToPath(new URL('../../../scripts/gitea-api.sh', import.meta.url));
for (const [method, status, expected] of [['GET', '200', 0], ['POST', '201', 0], ['GET', '403', 1]]) {
test(`real helper ${method} HTTP ${status} preserves exit ${expected} without credentials`, t => {
const dir = mkdtempSync(path.join(os.tmpdir(), 'ledger-helper-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
function tool(name, content) { const p = path.join(dir, name); writeFileSync(p, '#!/usr/bin/env bash\n' + content); chmodSync(p, 0o755); }
// Stub node before the helper can read credential material. No auth file exists.
tool('node', 'printf %s https://git.mosaicstack.dev\n');
tool('git', 'printf %s https://git.mosaicstack.dev/mosaicstack/stack.git\n');
tool('curl', 'while (($#)); do if [[ "$1" == -o ]]; then shift; out="$1"; fi; shift; done\nprintf "[]" > "$out"\nprintf %s "$FAKE_HTTP"\n');
const r = spawnSync('bash', [helper, method, 'repos/mosaicstack/stack/issues', ...(method === 'POST' ? ['{}'] : [])], { encoding: 'utf8', env: { ...process.env, PATH: `${dir}:${process.env.PATH}`, MOSAIC_GITEA_CREDENTIAL_FILE: `${dir}/nonexistent`, FAKE_HTTP: status } });
assert.equal(r.status, expected, r.stderr); assert.equal(r.stdout, '[]'); assert.match(r.stderr, new RegExp(`HTTP ${status}`));
});
}
+123
View File
@@ -0,0 +1,123 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, symlinkSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync, spawnSync } from 'node:child_process';
import { dateRange, messageKind, issueNumbers, totalsLine, summarize } from '../src/ledger.mjs';
const source = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../src');
const range = dateRange('2026-09-06', '2026-09-12');
const fixtureIssues = [
{ number: 1, title: 'First issue', created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-07T12:00:00Z' },
{ number: 2, title: 'Second issue', created_at: '2026-09-06T00:00:00Z', closed_at: null },
];
function fixture(t) {
const root = mkdtempSync(path.join(os.tmpdir(), 'ledger-test-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const put = (name, data) => { const p = path.join(root, name); mkdirSync(path.dirname(p), { recursive: true }); writeFileSync(p, data); return p; };
const git = (args, date = '2026-09-07T00:00:00Z') => execFileSync('git', args, { cwd: root, env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, stdio: 'pipe' });
git(['init', '-b', 'refactor']); git(['config', 'user.email', '[email protected]']); git(['config', 'user.name', 'Fixture']);
const commit = (subject, date, body) => git(['-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', subject, ...(body ? ['-m', body] : [])], date);
commit('first #1'); commit('first #2'); commit('follow-up #2'); commit('untagged', undefined, 'body only #3');
mkdirSync(path.join(root, 'agents/alice'), { recursive: true });
mkdirSync(path.join(root, 'agents/bob'), { recursive: true });
cpSync(source, path.join(root, 'packages/ledger/src'), { recursive: true });
const api = put('bin/gitea-api.sh', '#!/usr/bin/env node\nconst fs=require("fs"); fs.appendFileSync(process.env.CALLS,JSON.stringify(process.argv.slice(2))+"\\n"); if(process.env.API_FAIL){console.error("secret must not escape");process.exit(Number(process.env.API_FAIL));} process.stdout.write(fs.readFileSync(process.env.ISSUES,"utf8"));\n');
execFileSync('chmod', ['+x', api]);
put('issues.json', JSON.stringify(fixtureIssues)); put('calls.jsonl', '');
const entry = (text, timestamp = '2026-09-08T12:00:00Z') => ({ type: 'message', timestamp, message: { role: 'user', content: [{ type: 'text', text }] } });
const logs = [entry('[host:control-board -> host:alice] do #1'), entry('[host:bob -> host:alice] review #2'), entry('build #2'), entry('old #1', '2026-09-05T23:59:59Z'), { type: 'message', timestamp: '2026-09-08T00:00:00Z', message: { role: 'assistant', content: 'not a user #1' } }];
put('.pi/state/alice/sessions/one.jsonl', logs.map(x => JSON.stringify(x)).join('\n') + '\n');
const run = (args = [], env = {}) => spawnSync(process.execPath, [path.join(root, 'packages/ledger/src/cli.mjs'), '--since', '2026-09-06', '--until', '2026-09-12', ...args], { cwd: root, encoding: 'utf8', env: { ...process.env, PATH: `${path.join(root, 'bin')}:${process.env.PATH}`, ISSUES: path.join(root, 'issues.json'), CALLS: path.join(root, 'calls.jsonl'), ...env } });
return { root, put, commit, run, entry, logs };
}
test('fixture git subjects only, follow-ups and three session kinds', t => {
const f = fixture(t), result = f.run(['--json']);
assert.equal(result.status, 0, result.stderr);
const r = JSON.parse(result.stdout);
assert.deepEqual(r.issues.map(x => [x.issue, x.commits, x.followUps, x.hoursOpen, x.seats]), [[1, 1, 0, 36, ['alice']], [2, 2, 1, 'open', ['alice']]]);
assert.deepEqual(r.seats, [{ seat: 'alice', board: 1, agent: 1, human: 1 }]);
assert.deepEqual(r.totals, { issuesClosed: 1, medianHoursOpen: 36, commits: 3, followUpsPerIssue: 0.5, humanMessagesPerClosedIssue: 1 });
const calls = readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(calls.length, 1); assert.equal(calls[0][0], 'GET');
assert.match(calls[0][1], /^repos\/mosaicstack\/stack\/issues\?state=all&type=issues&since=2026-09-06T00%3A00%3A00.000Z&limit=50&page=1$/);
});
test('text and JSON carry same numbers, open and truncated title', t => {
const f = fixture(t); f.put('issues.json', JSON.stringify([{ ...fixtureIssues[0], title: 'x'.repeat(100) }, fixtureIssues[1]]));
const text = f.run(), json = f.run(['--json']);
assert.equal(text.status, 0, text.stderr); const r = JSON.parse(json.stdout);
assert.ok(text.stdout.includes(totalsLine(r.totals)));
assert.match(text.stdout, /#1 \| x{48} \| .* \| 36.0 \| 1 \| 0 \| alice/);
assert.match(text.stdout, /#2 \| Second issue \| .* \| open \| 2 \| 1 \| alice/);
assert.match(text.stdout, /alice \| 1 \| 1 \| 1/);
});
test('missing credentials exit 2, no-issues never calls API and shows unknown', t => {
const f = fixture(t); const bad = f.run([], { API_FAIL: '3' });
assert.equal(bad.status, 2); assert.match(bad.stderr, /credentials missing, unreadable, or invalid/); assert.doesNotMatch(bad.stderr, /secret must/); assert.equal(bad.stdout, '');
f.put('calls.jsonl', ''); const skip = f.run(['--no-issues'], { API_FAIL: '3' });
assert.equal(skip.status, 0, skip.stderr); assert.match(skip.stdout, /#1 \| unknown \| unknown \| unknown/);
assert.match(skip.stdout, /issues closed unknown/); assert.equal(readFileSync(path.join(f.root, 'calls.jsonl'), 'utf8'), '');
});
test('empty range gives no rows and zero totals', t => {
const f = fixture(t);
f.put('issues.json', '[]');
const result = spawnSync(process.execPath, [path.join(f.root, 'packages/ledger/src/cli.mjs'), '--since', '2027-01-01', '--until', '2027-01-02', '--json'], { encoding: 'utf8', env: { ...process.env, PATH: `${f.root}/bin:${process.env.PATH}`, ISSUES: `${f.root}/issues.json`, CALLS: `${f.root}/calls.jsonl` } });
assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
assert.deepEqual(r.issues, []); assert.deepEqual(r.seats, []); assert.ok(Object.values(r.totals).every(n => n === 0));
});
test('inclusive UTC dates, first-line preamble only, role and seat boundaries', t => {
const f = fixture(t);
f.put('.pi/state/alice/sessions/one.jsonl', [f.entry('start #1', '2026-09-06T00:00:00Z'), f.entry('end #2', '2026-09-12T23:59:59.999Z'), f.entry('outside', '2026-09-13T00:00:00Z'), f.entry('human\n[host:control-board -> host:alice] quoted')].map(JSON.stringify).join('\n'));
f.put('.pi/state/not-a-seat/sessions/one.jsonl', JSON.stringify(f.entry('ignored')));
f.commit('at end #2', '2026-09-12T23:59:59Z'); f.commit('outside #2', '2026-09-13T00:00:00Z');
const result = f.run(['--json']); assert.equal(result.status, 0, result.stderr); const r = JSON.parse(result.stdout);
assert.equal(r.seats[0].human, 3); assert.equal(r.totals.commits, 4);
});
test('close-only issue included, even median, missing metadata stays unknown', t => {
const f = fixture(t);
f.put('issues.json', JSON.stringify([fixtureIssues[0], { number: 3, title: 'close only', created_at: '2026-09-06T00:00:00Z', closed_at: '2026-09-08T12:00:00Z' }]));
const r = JSON.parse(f.run(['--json']).stdout);
assert.equal(r.issues[1].hoursOpen, 'unknown'); assert.equal(r.issues[2].commits, 0); assert.equal(r.totals.medianHoursOpen, 48); assert.equal(r.totals.issuesClosed, 2);
});
test('unique commits but per-issue links count multiple tags once each', t => {
const f = fixture(t); f.commit('both #1 #2 #2'); const r = JSON.parse(f.run(['--json']).stdout);
assert.equal(r.totals.commits, 4); assert.deepEqual(r.issues.map(x => x.commits), [2, 3]);
});
test('page cap refuses rather than silently undercounting', t => {
const f = fixture(t); f.put('issues.json', JSON.stringify(Array.from({ length: 50 }, (_, i) => ({ ...fixtureIssues[0], number: i + 1 }))));
const result = f.run(); assert.equal(result.status, 2); assert.match(result.stderr, /full 50-row page/); assert.equal(result.stdout, '');
});
for (const payload of ['not JSON', '{}', '[{"number":1}]']) test(`bad API payload ${payload} refuses`, t => {
const f = fixture(t); f.put('issues.json', payload); const r = f.run(); assert.equal(r.status, 2); assert.equal(r.stdout, '');
});
test('partial or malformed session log refuses with location, not content', t => {
const f = fixture(t); f.put('.pi/state/alice/sessions/bad.jsonl', '{sensitive'); const r = f.run();
assert.equal(r.status, 1); assert.match(r.stderr, /Malformed session JSON: alice\/bad.jsonl:1/); assert.doesNotMatch(r.stderr, /sensitive/);
});
test('no sessions is an empty table; symlink source refuses', t => {
const f = fixture(t); rmSync(path.join(f.root, '.pi'), { recursive: true });
assert.deepEqual(JSON.parse(f.run(['--json']).stdout).seats, []);
symlinkSync(path.join(f.root, 'agents'), path.join(f.root, '.pi'));
const r = f.run(); assert.equal(r.status, 1); assert.match(r.stderr, /Cannot read ledger directory/);
});
test('reads only refactor even when another branch is checked out', t => {
const f = fixture(t); execFileSync('git', ['checkout', '-b', 'other'], { cwd: f.root, stdio: 'pipe' }); f.commit('other #1');
assert.equal(JSON.parse(f.run(['--json']).stdout).totals.commits, 3);
});
test('invalid dates, reverse dates and duplicate options refuse', t => {
assert.throws(() => dateRange('2026-02-30')); assert.throws(() => dateRange('2026-09-12', '2026-09-06'));
const f = fixture(t); assert.equal(f.run(['--since', '2026-09-01']).status, 1);
});
test('preamble parsing and issue number boundaries', () => {
assert.equal(messageKind('[h:control-board -> h:seat] hi'), 'board');
assert.equal(messageKind('[h:seat -> h:seat class=actionable] hi'), 'agent');
assert.equal(messageKind('human\n[h:control-board -> h:seat] hi'), 'human');
assert.equal(messageKind(' [h:seat -> h:seat] quoted'), 'human');
assert.deepEqual(issueNumbers('fix #1 #2 #2 abc#3 #0 #4x'), [1, 2]);
});
test('no closed issues with human messages means undefined ratio, not invented zero', () => {
const r = summarize(range, [], [], { rows: [{ seat: 'a', human: 1, board: 0, agent: 0 }], mentions: new Map() });
assert.equal(r.totals.humanMessagesPerClosedIssue, 'unknown');
});
+1 -1
View File
@@ -54,7 +54,7 @@ process.stdout.write("header = \"Content-Type: application/json\"\n");
}
BODY_FILE=""
cleanup() { [ -n "$BODY_FILE" ] && rm -f "$BODY_FILE"; }
cleanup() { if [ -n "$BODY_FILE" ]; then rm -f "$BODY_FILE"; fi; }
trap cleanup EXIT
if [ -n "$BODY" ]; then
BODY_FILE="$(mktemp)"