diff --git a/BUILD-LOG.md b/BUILD-LOG.md index cafcc8c0..dc1b07c0 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -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. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 32c1f01e..beeca5a1 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -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. diff --git a/docs/plans/CURRENT.md b/docs/plans/CURRENT.md index 2018c46f..315e07ce 100644 --- a/docs/plans/CURRENT.md +++ b/docs/plans/CURRENT.md @@ -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, diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md index f00f28c3..392ae00a 100644 --- a/docs/plans/DEFERRED.md +++ b/docs/plans/DEFERRED.md @@ -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). diff --git a/docs/plans/reviews/2026-09-12_ledger-review-request.md b/docs/plans/reviews/2026-09-12_ledger-review-request.md new file mode 100644 index 00000000..0f970533 --- /dev/null +++ b/docs/plans/reviews/2026-09-12_ledger-review-request.md @@ -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. diff --git a/docs/plans/reviews/2026-09-12_ledger-verdict.md b/docs/plans/reviews/2026-09-12_ledger-verdict.md new file mode 100644 index 00000000..d79519b1 --- /dev/null +++ b/docs/plans/reviews/2026-09-12_ledger-verdict.md @@ -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: "; 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. diff --git a/packages/ledger/README.md b/packages/ledger/README.md new file mode 100644 index 00000000..a998a76c --- /dev/null +++ b/packages/ledger/README.md @@ -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//sessions/*.jsonl` + where `` 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. diff --git a/packages/ledger/src/cli.mjs b/packages/ledger/src/cli.mjs new file mode 100644 index 00000000..66cd1417 --- /dev/null +++ b/packages/ledger/src/cli.mjs @@ -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; + }); +} diff --git a/packages/ledger/src/ledger.mjs b/packages/ledger/src/ledger.mjs new file mode 100644 index 00000000..dc8a3a69 --- /dev/null +++ b/packages/ledger/src/ledger.mjs @@ -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'); +} diff --git a/packages/ledger/tests/gitea-helper.test.mjs b/packages/ledger/tests/gitea-helper.test.mjs new file mode 100644 index 00000000..088181fd --- /dev/null +++ b/packages/ledger/tests/gitea-helper.test.mjs @@ -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}`)); + }); +} diff --git a/packages/ledger/tests/ledger.test.mjs b/packages/ledger/tests/ledger.test.mjs new file mode 100644 index 00000000..7b4e4d33 --- /dev/null +++ b/packages/ledger/tests/ledger.test.mjs @@ -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', 'fixture@example.invalid']); 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'); +}); diff --git a/scripts/gitea-api.sh b/scripts/gitea-api.sh index 128a39cd..6d02c75e 100755 --- a/scripts/gitea-api.sh +++ b/scripts/gitea-api.sh @@ -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)"