@bongos/core 1.19.877 → 1.19.878

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.877",
6
- "core_contract": "1.19.877",
7
- "source_commit": "7cc46fa08c1a00e2194ab40bddea2dce575e2d38",
5
+ "core_version": "1.19.878",
6
+ "core_contract": "1.19.878",
7
+ "source_commit": "2b2c4d57dea2fc815d5edf5a1fd9ddb076f49be9",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-19T10:46:37.399Z",
9
+ "built_at": "2026-09-19T11:02:46.880Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 501,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2316,
14
+ "functional_verbatim": 2319,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2843,
20
- "tree_sha256": "f1b43ea830cc3e9e55d49cdfc2da4528c9388b558fca838b1e54002de9e00f9d",
19
+ "file_count": 2846,
20
+ "tree_sha256": "5cf258930712618621a4d89ca402aef67b76d4b0212318336edb63c4a4a0502b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2602,7 +2602,7 @@
2602
2602
  {
2603
2603
  "path": "docs/module-api-changelog.md",
2604
2604
  "mode": "0000644",
2605
- "sha256": "d7e2217a982356c43047a8f416106e885a22862d1bc125ea59545fc995f9b318"
2605
+ "sha256": "2c1b16d4be5696489456b2c49d7fbef8b09d846d722860bd019968e20a88db02"
2606
2606
  },
2607
2607
  {
2608
2608
  "path": "docs/modules-contract.md",
@@ -2707,7 +2707,7 @@
2707
2707
  {
2708
2708
  "path": "docs/recipes/autobongos-windows-host.md",
2709
2709
  "mode": "0000644",
2710
- "sha256": "94b0b2442708fec6f62fe98b41dc5131c114833b66c487a0056974265cc13db6"
2710
+ "sha256": "72f38de74c8cbfd1d44de916d19572dc705b1039a4270d53e3a9065a50d8faf7"
2711
2711
  },
2712
2712
  {
2713
2713
  "path": "docs/recipes/bongos-cli-release.md",
@@ -3799,6 +3799,11 @@
3799
3799
  "mode": "0000644",
3800
3800
  "sha256": "722b9f3ddbd59fda3e92ae6c6fb0400eb3eadc343f0a3f84369d69bea72b7d65"
3801
3801
  },
3802
+ {
3803
+ "path": "modules/autonomy/digest.js",
3804
+ "mode": "0000644",
3805
+ "sha256": "cfe22a887e5a69cf314a318f7b410c81f91cfba65f7643605e9f3ac1ebef8022"
3806
+ },
3802
3807
  {
3803
3808
  "path": "modules/autonomy/fence.js",
3804
3809
  "mode": "0000644",
@@ -7967,12 +7972,12 @@
7967
7972
  {
7968
7973
  "path": "package-lock.json",
7969
7974
  "mode": "0000644",
7970
- "sha256": "4e8f5415cb5201d24ca7114d11e21d15b7042e7c6a846fed8cf10e35971305ad"
7975
+ "sha256": "da57fd75ea411bf8a7ecad98041b7d38f6ce568bca460243b24b67bd2ddd60ba"
7971
7976
  },
7972
7977
  {
7973
7978
  "path": "package.json",
7974
7979
  "mode": "0000644",
7975
- "sha256": "c616356037d3d48c3d6a49a28c89b8e7e38e13be5c9ea8a38899f51efc55a4b9"
7980
+ "sha256": "ef72c5d5fd50006bbb62080ebf77339025a5979363d10c655e5db6f1b507850c"
7976
7981
  },
7977
7982
  {
7978
7983
  "path": "public-docs/index.html",
@@ -8129,6 +8134,11 @@
8129
8134
  "mode": "0000644",
8130
8135
  "sha256": "891e795c68d43d11958a2e68af0cfb1fcd5a1f3308c821f86ff37512326ef72d"
8131
8136
  },
8137
+ {
8138
+ "path": "scripts/gds/autobongos-digest.js",
8139
+ "mode": "0000644",
8140
+ "sha256": "b406f73bbc0f577a2a58ec99597fb92c7edc0d1628c0ca7729cd25b0a1270045"
8141
+ },
8132
8142
  {
8133
8143
  "path": "scripts/gds/autobongos-loop.js",
8134
8144
  "mode": "0000644",
@@ -8137,7 +8147,7 @@
8137
8147
  {
8138
8148
  "path": "scripts/gds/autobongos-run.js",
8139
8149
  "mode": "0000644",
8140
- "sha256": "da0047f65186ea6d049799b62aaf60acc2ec1a3d4fe15875dadbdc493512d205"
8150
+ "sha256": "24f0be946550a89f717278a418ebb68330fad3c7cdd45d3b79e85315564c7425"
8141
8151
  },
8142
8152
  {
8143
8153
  "path": "scripts/gds/autobongos-service.cmd",
@@ -8147,7 +8157,7 @@
8147
8157
  {
8148
8158
  "path": "scripts/gds/autobongos-service.ps1",
8149
8159
  "mode": "0000644",
8150
- "sha256": "60af163d90c3f30d6723678eb64d6ed259be5575230ee43adaebce9537a4fe9a"
8160
+ "sha256": "3f3e44969809b3c22c9cf338af68695af6e835b861ac056d38408a4be1d202a7"
8151
8161
  },
8152
8162
  {
8153
8163
  "path": "scripts/gds/autobongos-verify.js",
@@ -8917,7 +8927,7 @@
8917
8927
  {
8918
8928
  "path": "scripts/gds/provision-core-upgrade.js",
8919
8929
  "mode": "0000644",
8920
- "sha256": "fef10e02d3b1d605c1b30fd553b1cf8069159d8c2cbf6b0e4b48ec32359562a9"
8930
+ "sha256": "5fad0beb0a3d4b322918a99765cb922fcce1de4196c19cb08a0d3bc91c3d149e"
8921
8931
  },
8922
8932
  {
8923
8933
  "path": "scripts/gds/provision-disconnect.js",
@@ -9972,7 +9982,7 @@
9972
9982
  {
9973
9983
  "path": "src/module-api.js",
9974
9984
  "mode": "0000644",
9975
- "sha256": "7dbc21b0402be4d9acc429c0cd1f9fc5ba33ea4f7ab0650d89dd078d7ffb82bb"
9985
+ "sha256": "f38e0d4d6eeb707065f278ed386fec6a66d502553ac99d1e683b7216c0bb6a99"
9976
9986
  },
9977
9987
  {
9978
9988
  "path": "src/module-loader/catalog.js",
@@ -10399,6 +10409,11 @@
10399
10409
  "mode": "0000644",
10400
10410
  "sha256": "1b5b6e5010c45a77015a1d0d63d49cb3286eca91a83f45cb94d145af01aab7dc"
10401
10411
  },
10412
+ {
10413
+ "path": "tests/autonomy_digest.mjs",
10414
+ "mode": "0000644",
10415
+ "sha256": "633ede3096916c1d5571def211fabac9ae934f48723d182c54f2fe27c2ead105"
10416
+ },
10402
10417
  {
10403
10418
  "path": "tests/autonomy_gate.mjs",
10404
10419
  "mode": "0000644",
@@ -10882,7 +10897,7 @@
10882
10897
  {
10883
10898
  "path": "tests/core_upgrade_runner.mjs",
10884
10899
  "mode": "0000644",
10885
- "sha256": "5f9ae4144ab85ee197f417003a71df1d78398ca11b6c8bd3dc96de9b4cd5e1fb"
10900
+ "sha256": "a8fc77b7a32e4377b4b327bd485cbc17428421ef3fc871770fdaeee3e02e48f4"
10886
10901
  },
10887
10902
  {
10888
10903
  "path": "tests/cosmos_token_contrast.mjs",
@@ -2227,6 +2227,8 @@ is load-bearing: the script throws rather than guess if it is missing, and
2227
2227
  1.19.876 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2228
2228
  landed since 1.19.875 with no explicit bump. run 35420916304. (task 1002620)
2229
2229
  1.19.877 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2230
- landed since 1.19.876 with no explicit bump. run 35438291185. (task 1002620)
2230
+ landed since 1.19.876 with no explicit bump. run 35438492966. (task 1002620)
2231
+ 1.19.878 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2232
+ landed since 1.19.877 with no explicit bump. run 35439004814. (task 1002620)
2231
2233
  ---------------------------------------------------------------------------
2232
2234
  ```
@@ -101,7 +101,32 @@ A runner can be **Allowed to work** and **Not checking in** at the same time:
101
101
  that is a machine that is off, unplugged or wedged, and it is the state worth
102
102
  noticing.
103
103
 
104
- The full log lives on the box at `%LOCALAPPDATA%\cloudbongos\autobongos.log`.
104
+ ### Reading the night
105
+
106
+ The log answers "what happened" only if you are willing to read a log, and the
107
+ whole premise is that you are not at the keyboard. One command gives you the
108
+ night in five answers instead:
109
+
110
+ ```powershell
111
+ powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 digest
112
+ ```
113
+
114
+ or from any checkout: `node scripts/gds/autobongos-digest.js` (add `--since 24h`
115
+ for a rolling window, `--all` for everything, `--json` for the structured shape).
116
+
117
+ It answers: **what shipped**, **what is on hold and why**, **what each worker
118
+ decided that its task description did not determine**, **what it cost**, and
119
+ **where both limit windows stand**.
120
+
121
+ The third one is the one to actually read. A worker will not reliably stop and
122
+ ask — given an under-specified task it makes the call and writes it into the
123
+ file, which is what a good engineer does. So the decisions happen whether or not
124
+ anyone sees them, and every line in that section is a sentence the task
125
+ description should have contained. The task with the most entries is the thinnest
126
+ spec you wrote, and the digest names it.
127
+
128
+ The full log still lives on the box at `%LOCALAPPDATA%\cloudbongos\autobongos.log`
129
+ if you want the raw rows.
105
130
 
106
131
  ## 5. Stopping it
107
132
 
@@ -0,0 +1,344 @@
1
+ 'use strict';
2
+
3
+ // modules/autonomy/digest.js — the night, readable afterwards (task 1003906,
4
+ // ADR 0307). Pure: rows in, a digest out. No I/O, no clock of its own.
5
+ //
6
+ // THE OWNER IS NOT AT THE KEYBOARD. That is the whole premise, and it means the
7
+ // run log — an append-only JSONL nobody will ever open — is not an answer. One
8
+ // page has to answer five questions, and it has to answer them without anyone
9
+ // reading a log: what shipped, what is on hold and why, what each worker decided
10
+ // that its task description did not determine, what it cost, and where the two
11
+ // limit windows stand.
12
+ //
13
+ // THE THIRD QUESTION IS THE ONE THAT COMPOUNDS, and it is worth saying why it is
14
+ // here at all. A worker will NOT reliably stop to ask: tested on an ambiguous
15
+ // task, it made four genuine judgement calls and wrote them straight into the
16
+ // file, which is what a competent engineer does with an under-specified brief.
17
+ // So the decisions happen either way. What the digest changes is whether anyone
18
+ // finds out. And every entry is also a sentence the task description SHOULD have
19
+ // contained — which makes this list a spec-quality signal, not just an audit
20
+ // trail: the goals whose decisions pile up are the goals whose specs are thin,
21
+ // and fixing those is what lowers tomorrow's halt count.
22
+ //
23
+ // WHAT COUNTS AS SHIPPED IS THE LEDGER'S ANSWER, never the worker's. A `worked`
24
+ // row with `verified: false` is a task that ran and did not ship, and putting it
25
+ // under "shipped" because a session ended would reintroduce the exact confusion
26
+ // task 1003903 exists to end.
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const isRow = (r) => r && typeof r === 'object' && typeof r.event === 'string';
33
+
34
+ // num — a number, or null for "not a number I can use".
35
+ //
36
+ // THE GUARD ON null IS LOAD-BEARING, and it is not defensive noise:
37
+ // `Number(null)` is 0 and 0 is finite, so the obvious one-liner turns every
38
+ // absent cost into a free worker and every absent duration into an instant one.
39
+ // The same trap cost a bug in modules/autonomy/cadence.js's fingerprint, where an
40
+ // unread claimable count became the literal string "null" for the same reason.
41
+ // Empty string behaves identically. Both are spelled out rather than trusted to
42
+ // Number().
43
+ const num = (v) => {
44
+ if (v === null || v === undefined || v === '') return null;
45
+ const n = Number(v);
46
+ return Number.isFinite(n) ? n : null;
47
+ };
48
+
49
+ function parseAt(row) {
50
+ const t = Date.parse(row && row.at);
51
+ return Number.isFinite(t) ? t : null;
52
+ }
53
+
54
+ // humanDuration — minutes and hours, the way a person says them.
55
+ function humanDuration(s) {
56
+ const n = num(s);
57
+ if (n === null || n < 0) return null;
58
+ if (n < 90) return `${Math.round(n)}s`;
59
+ if (n < 5400) return `${Math.round(n / 60)}m`;
60
+ return `${(n / 3600).toFixed(1)}h`;
61
+ }
62
+
63
+ function usd(n) {
64
+ const v = num(n);
65
+ return v === null ? null : `$${v.toFixed(2)}`;
66
+ }
67
+
68
+ // windowsFromGauge — question five, from the gauge file's own shape.
69
+ //
70
+ // Reports what it can SEE and says so when it cannot. A digest that silently
71
+ // omits a window reads as "nothing to report", and the one morning that matters
72
+ // is the morning the gauge stopped being written.
73
+ // STALENESS IS GAUGE.JS'S RULE, NOT A SECOND ONE. modules/autonomy/gauge.js holds
74
+ // a window UNKNOWN when its `observed_at` is older than staleAfterS (30 minutes
75
+ // by default), and the runner HOLDS on unknown. A digest that applied a laxer
76
+ // test would print "41% used, resets in 2h" about a window the runner had already
77
+ // decided it could not vouch for — two surfaces disagreeing about one file, with
78
+ // the reader believing the one that is wrong. Same threshold, same conclusion.
79
+ // (Grader finding, task 1003906.)
80
+ function windowsFromGauge(gauge, nowEpochS, { staleAfterS = null } = {}) {
81
+ const limit = staleAfterS === null ? require('./gauge.js').DEFAULT_STALE_AFTER_S : staleAfterS;
82
+ const writtenAt = gauge && typeof gauge === 'object' ? num(gauge.written_at) : null;
83
+ const one = (key, label) => {
84
+ const w = gauge && typeof gauge === 'object' ? gauge[key] : null;
85
+ const used = w ? num(w.used_percentage) : null;
86
+ const resets = w ? num(w.resets_at) : null;
87
+ // Per-window observed_at, falling back to the file's written_at — exactly the
88
+ // shape gauge.js reads, because one window can go unreported while the other
89
+ // is carried forward.
90
+ const observedAt = w ? (num(w.observed_at) ?? writtenAt) : null;
91
+ if (used === null) return { key, label, known: false, detail: 'not reported' };
92
+
93
+ const ageS = observedAt !== null && Number.isFinite(nowEpochS) ? nowEpochS - observedAt : null;
94
+ if (ageS !== null && ageS > limit) {
95
+ return {
96
+ key, label, known: false, stale: true, usedPct: Math.round(used), resetsAt: resets, ageS,
97
+ detail: `last read ${Math.round(used)}% used ${humanDuration(ageS)} ago — older than the runner trusts, so it is holding`,
98
+ };
99
+ }
100
+ const ahead = resets !== null && Number.isFinite(nowEpochS) ? resets - nowEpochS : null;
101
+
102
+ // A RESET TIME IN THE PAST MEANS THE READING IS STALE, not that the window is
103
+ // about to turn over. The percentage was true of a window that has since
104
+ // rolled, so printing it as a live figure is worse than printing nothing —
105
+ // "41% used, resets in 0s" invites a decision about headroom that no longer
106
+ // exists. Say the reading is old and let the reader go look.
107
+ if (ahead !== null && ahead <= 0) {
108
+ return {
109
+ key, label, known: false, stale: true, usedPct: Math.round(used), resetsAt: resets, resetsInS: 0,
110
+ detail: `last read ${Math.round(used)}% used, but that window has since reset — the gauge is stale`,
111
+ };
112
+ }
113
+ return {
114
+ key, label, known: true, usedPct: Math.round(used),
115
+ resetsAt: resets, resetsInS: ahead,
116
+ detail: `${Math.round(used)}% used${ahead !== null ? `, resets in ${humanDuration(ahead)}` : ''}`,
117
+ };
118
+ };
119
+ return [one('five_hour', 'Five-hour window'), one('seven_day', 'Seven-day window')];
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // The digest
124
+ // ---------------------------------------------------------------------------
125
+
126
+ // buildDigest — every question answered from the rows, or explicitly unanswered.
127
+ //
128
+ // `rows` is the run log for the period, oldest first. `gauge` is the raw gauge
129
+ // file (the same shape modules/autonomy/gauge.js reads). Unrecognised rows are
130
+ // COUNTED rather than dropped: a digest that silently ignores what it does not
131
+ // understand is how a new failure mode stays invisible for a week.
132
+ function buildDigest(rows = [], { gauge = null, nowEpochS = null, sinceMs = null, untilMs = null, staleAfterS = null } = {}) {
133
+ const all = (Array.isArray(rows) ? rows : []).filter(isRow);
134
+ const worked = all.filter((r) => r.event === 'worked');
135
+
136
+ // 1. WHAT SHIPPED — the ledger's answer.
137
+ const shipped = worked.filter((r) => r.verified === true).map((r) => ({
138
+ taskId: String(r.task_id ?? ''), title: r.title || null, goalId: r.goal_id ?? null,
139
+ durationS: num(r.duration_s), costUsd: num(r.cost_usd),
140
+ }));
141
+
142
+ // 2. WHAT IS ON HOLD, AND WHY. Four different shapes of "did not ship", kept
143
+ // apart because the owner's next action differs for each.
144
+ const ranButDidNotShip = worked.filter((r) => r.verified !== true).map((r) => ({
145
+ taskId: String(r.task_id ?? ''), title: r.title || null,
146
+ shape: r.ledger_shape || 'unknown',
147
+ // THE LEDGER'S REASON FIRST, and the worker's only as a fallback. `row.reason`
148
+ // is verdict.reason — the WORKER's account of itself — and quoting that in the
149
+ // "did not ship" section is the precise inversion this whole goal exists to
150
+ // undo: a worker that stopped dead needing a decision reports success. The
151
+ // ledger's explanation ("the grade passed and the land never finished") is the
152
+ // one a person can act on. Older rows predate ledger_reason, so the fallback
153
+ // stays rather than leaving them blank.
154
+ reason: r.ledger_reason || r.reason || null,
155
+ remedy: r.ledger_remedy || null,
156
+ workerSaid: r.reason || null,
157
+ blockerId: r.blocker_id ?? null,
158
+ parked: !!r.task_parked,
159
+ claimReleased: !!r.claim_released,
160
+ }));
161
+
162
+ const fenced = all.filter((r) => r.event === 'fenced').map((r) => ({
163
+ code: r.code || 'unknown', reason: r.reason || null, taskId: r.task_id ? String(r.task_id) : null,
164
+ }));
165
+
166
+ // An UNWORKABLE QUEUE is not breakage, and the digest must not present it as
167
+ // such: the machine is healthy and there is nothing it may work on.
168
+ const unworkable = [];
169
+ for (const r of all) {
170
+ if (r.event !== 'nothing_claimable') continue;
171
+ for (const sk of (Array.isArray(r.skipped) ? r.skipped : [])) {
172
+ unworkable.push({ taskId: String(sk.id ?? ''), goalId: sk.goal_id ?? null, reasons: sk.reasons || [] });
173
+ }
174
+ }
175
+
176
+ const held = all.filter((r) => r.event === 'hold').map((r) => ({ reason: r.reason || null, sleepUntil: r.sleep_until ?? null }));
177
+
178
+ // 3. WHAT EACH WORKER DECIDED that its spec did not determine — grouped by task,
179
+ // because the SHAPE of this list is the signal. One task with six decisions is a
180
+ // thin spec; six tasks with one each is a normal night.
181
+ const decisions = [];
182
+ for (const r of worked) {
183
+ const list = Array.isArray(r.undetermined_decisions) ? r.undetermined_decisions : [];
184
+ if (!list.length) continue;
185
+ decisions.push({
186
+ taskId: String(r.task_id ?? ''), title: r.title || null, goalId: r.goal_id ?? null,
187
+ shipped: r.verified === true,
188
+ decisions: list.map((d) => String(d)),
189
+ });
190
+ }
191
+ const decisionCount = decisions.reduce((n, d) => n + d.decisions.length, 0);
192
+ // The thinnest spec of the night, named. A list is browsable; a name is
193
+ // actionable, and this one is the sentence the description should have carried.
194
+ const thinnestSpec = decisions.slice().sort((a, b) => b.decisions.length - a.decisions.length)[0] || null;
195
+
196
+ // 4. WHAT IT COST. Nulls are EXCLUDED and counted, never coerced to zero — a
197
+ // worker whose cost was not reported must not make the night look cheaper.
198
+ const costs = worked.map((r) => num(r.cost_usd));
199
+ const knownCosts = costs.filter((c) => c !== null);
200
+ const cost = {
201
+ totalUsd: knownCosts.reduce((a, b) => a + b, 0),
202
+ reported: knownCosts.length,
203
+ unreported: costs.length - knownCosts.length,
204
+ workerRuns: worked.length,
205
+ totalDurationS: worked.reduce((a, r) => a + (num(r.duration_s) || 0), 0),
206
+ };
207
+
208
+ // 5. THE TWO LIMIT WINDOWS.
209
+ const windows = windowsFromGauge(gauge, nowEpochS, { staleAfterS });
210
+
211
+ // How the runner spent the night, so "nothing shipped" can be told apart from
212
+ // "nothing ran".
213
+ const KNOWN = new Set(['worked', 'fenced', 'hold', 'nothing_claimable', 'dry_run',
214
+ 'pick_failed', 'worktree_failed', 'claim_failed', 'verify_halt', 'cadence', 'boot_reconcile']);
215
+ const byEvent = {};
216
+ let unrecognised = 0;
217
+ for (const r of all) {
218
+ byEvent[r.event] = (byEvent[r.event] || 0) + 1;
219
+ if (!KNOWN.has(r.event)) unrecognised += 1;
220
+ }
221
+
222
+ const cadenceRows = all.filter((r) => r.event === 'cadence' && r.mode);
223
+ const endedProbing = cadenceRows.length ? cadenceRows[cadenceRows.length - 1].mode === 'probing' : false;
224
+
225
+ // reduce, not Math.min(...times): `--all` on a long-lived log can hold more
226
+ // timestamps than the engine will accept as call arguments, and a reporting CLI
227
+ // that throws on a big log fails exactly when the log is most worth reading.
228
+ const times = all.map(parseAt).filter((t) => t !== null);
229
+ const minAt = times.length ? times.reduce((a, b) => (b < a ? b : a)) : null;
230
+ const maxAt = times.length ? times.reduce((a, b) => (b > a ? b : a)) : null;
231
+ return {
232
+ period: {
233
+ fromMs: sinceMs ?? minAt,
234
+ toMs: untilMs ?? maxAt,
235
+ rows: all.length,
236
+ },
237
+ shipped,
238
+ onHold: { ranButDidNotShip, fenced, unworkable, held },
239
+ decisions, decisionCount, thinnestSpec,
240
+ cost,
241
+ windows,
242
+ runner: { byEvent, unrecognised, endedProbing, bootReconciles: byEvent.boot_reconcile || 0 },
243
+ };
244
+ }
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // Rendering
248
+ // ---------------------------------------------------------------------------
249
+
250
+ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
251
+
252
+ // renderDigest — the five questions, in the order they are asked, as plain text a
253
+ // person reads over coffee. Deliberately not a table: the widths are unknowable
254
+ // and a wrapped table is worse than prose.
255
+ //
256
+ // EVERY SECTION PRINTS, even when empty. A digest whose quiet sections vanish
257
+ // teaches its reader that absence means "fine", and then the one morning a
258
+ // section is missing because the DATA is missing reads exactly the same.
259
+ function renderDigest(d, { title = 'Autobongos — overnight' } = {}) {
260
+ const L = [];
261
+ const when = d.period.fromMs && d.period.toMs
262
+ ? `${new Date(d.period.fromMs).toISOString().slice(0, 16).replace('T', ' ')} → ${new Date(d.period.toMs).toISOString().slice(11, 16)} UTC`
263
+ : 'no runs recorded';
264
+ L.push(title, '='.repeat(title.length), when, '');
265
+
266
+ // 1
267
+ L.push(`SHIPPED — ${plural(d.shipped.length, 'task', 'tasks')}`);
268
+ if (!d.shipped.length) L.push(' nothing shipped.');
269
+ for (const s of d.shipped) {
270
+ L.push(` ${s.taskId} ${s.title || '(untitled)'}`);
271
+ const bits = [humanDuration(s.durationS), usd(s.costUsd)].filter(Boolean);
272
+ if (bits.length) L.push(` ${bits.join(' · ')}`);
273
+ }
274
+ L.push('');
275
+
276
+ // 2
277
+ const holdTotal = d.onHold.ranButDidNotShip.length + d.onHold.fenced.length + d.onHold.unworkable.length + d.onHold.held.length;
278
+ L.push(`ON HOLD — ${plural(holdTotal, 'thing', 'things')}`);
279
+ if (!holdTotal) L.push(' nothing is waiting on you.');
280
+ for (const r of d.onHold.ranButDidNotShip) {
281
+ L.push(` ${r.taskId} ran but did not ship — ${r.shape}`);
282
+ if (r.reason) L.push(` ${String(r.reason).slice(0, 160)}`);
283
+ if (r.remedy) L.push(` next: ${String(r.remedy).slice(0, 160)}`);
284
+ // Printed only when it ADDS something. A worker that agreed with the ledger
285
+ // tells the reader nothing twice; one that claimed success against a ledger
286
+ // that says otherwise is the single most useful line on the page.
287
+ if (r.workerSaid && r.workerSaid !== r.reason) L.push(` the worker said: ${String(r.workerSaid).slice(0, 160)}`);
288
+ if (r.blockerId) L.push(` blocker ${r.blockerId}${r.parked ? ' (task parked, out of the queue)' : ''}`);
289
+ }
290
+ // Fenced refusals are collapsed by code: twenty identical lines say nothing the
291
+ // first one did not.
292
+ const byCode = {};
293
+ for (const f of d.onHold.fenced) (byCode[f.code] = byCode[f.code] || []).push(f);
294
+ for (const [code, list] of Object.entries(byCode)) {
295
+ L.push(` fence: ${code} ×${list.length}`);
296
+ if (list[0].reason) L.push(` ${String(list[0].reason).slice(0, 160)}`);
297
+ }
298
+ if (d.onHold.unworkable.length) {
299
+ L.push(` queue: ${plural(d.onHold.unworkable.length, 'task', 'tasks')} in the allowlist, every one halted`);
300
+ L.push(' (this is not a runner failure — it has nothing it may work on)');
301
+ for (const u of d.onHold.unworkable.slice(0, 5)) L.push(` ${u.taskId}: ${(u.reasons || []).join(', ') || 'halted'}`);
302
+ }
303
+ if (d.onHold.held.length) L.push(` capacity: held ${plural(d.onHold.held.length, 'time', 'times')} — ${d.onHold.held[0].reason || 'no headroom'}`);
304
+ L.push('');
305
+
306
+ // 3 — the one that compounds.
307
+ L.push(`DECIDED FOR YOU — ${plural(d.decisionCount, 'decision', 'decisions')} across ${plural(d.decisions.length, 'task', 'tasks')}`);
308
+ if (!d.decisionCount) {
309
+ L.push(' no worker had to fill a gap in its spec.');
310
+ } else {
311
+ L.push(' Each line is a sentence the task description should have contained.');
312
+ for (const t of d.decisions) {
313
+ L.push(` ${t.taskId} ${t.title || '(untitled)'}${t.shipped ? '' : ' [did not ship]'}`);
314
+ for (const one of t.decisions) L.push(` · ${one}`);
315
+ }
316
+ if (d.thinnestSpec && d.thinnestSpec.decisions.length > 1) {
317
+ L.push('');
318
+ L.push(` Thinnest spec: ${d.thinnestSpec.taskId} (${d.thinnestSpec.decisions.length} decisions).`);
319
+ L.push(' Tightening that description is what lowers tomorrow’s halts.');
320
+ }
321
+ }
322
+ L.push('');
323
+
324
+ // 4
325
+ L.push('COST');
326
+ L.push(` ${usd(d.cost.totalUsd)} across ${plural(d.cost.workerRuns, 'worker run', 'worker runs')}` +
327
+ (d.cost.unreported ? ` (${d.cost.unreported} did not report a cost)` : ''));
328
+ if (d.cost.totalDurationS) L.push(` ${humanDuration(d.cost.totalDurationS)} of worker time`);
329
+ L.push('');
330
+
331
+ // 5
332
+ L.push('LIMITS');
333
+ for (const w of d.windows) L.push(` ${w.label.padEnd(18)} ${w.detail}`);
334
+ L.push('');
335
+
336
+ // The footer answers "did it run at all", which none of the five questions do.
337
+ const events = Object.entries(d.runner.byEvent).sort((a, b) => b[1] - a[1]).map(([k, n]) => `${k} ${n}`).join(' · ');
338
+ L.push(`RUNNER ${events || 'no rows'}`);
339
+ if (d.runner.endedProbing) L.push(' Ended the night PROBING — it hit repeated failures and slowed itself down.');
340
+ if (d.runner.unrecognised) L.push(` ${plural(d.runner.unrecognised, 'row', 'rows')} of a kind this digest does not know about.`);
341
+ return L.join('\n');
342
+ }
343
+
344
+ module.exports = { buildDigest, renderDigest, windowsFromGauge, humanDuration };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.877",
3
+ "version": "1.19.878",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.877",
9
+ "version": "1.19.878",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.877",
3
+ "version": "1.19.878",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // scripts/gds/autobongos-digest.js — read the night without opening a log
5
+ // (task 1003906, ADR 0307).
6
+ //
7
+ // node scripts/gds/autobongos-digest.js # since midnight, local
8
+ // node scripts/gds/autobongos-digest.js --since 24h # a rolling window
9
+ // node scripts/gds/autobongos-digest.js --json # the structured shape
10
+ // node scripts/gds/autobongos-digest.js --all # the whole log
11
+ //
12
+ // The decision logic is modules/autonomy/digest.js and has no I/O; this half
13
+ // finds the two files it reads and prints the result. Exit 0 always, including on
14
+ // an empty night: a digest is a report, and a non-zero exit would make a cron
15
+ // wrapper treat "nothing happened" as a failure.
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+
20
+ const digest = require('../../modules/autonomy/digest.js');
21
+
22
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
23
+
24
+ function arg(flag, argv) {
25
+ const i = argv.indexOf(flag);
26
+ return i >= 0 ? argv[i + 1] : null;
27
+ }
28
+
29
+ function logPath() {
30
+ if (process.env.AUTOBONGOS_LOG_FILE) return process.env.AUTOBONGOS_LOG_FILE;
31
+ try { return require('../../src/instance-config.js').configPath('autobongos-runs.jsonl'); }
32
+ catch (_) { return path.join(REPO_ROOT, 'autobongos-runs.jsonl'); }
33
+ }
34
+
35
+ function gaugePath() {
36
+ if (process.env.AUTOBONGOS_GAUGE_FILE) return process.env.AUTOBONGOS_GAUGE_FILE;
37
+ try { return require('../../src/instance-config.js').configPath('autobongos-gauge.json'); }
38
+ catch (_) { return path.join(REPO_ROOT, 'autobongos-gauge.json'); }
39
+ }
40
+
41
+ // readRows — an append-only JSONL, and a single unparseable line must not cost
42
+ // the whole night. A JSONL written by a process that can be killed mid-write has
43
+ // a torn last line more often than never.
44
+ function readRows(file) {
45
+ let text;
46
+ try { text = fs.readFileSync(file, 'utf8'); }
47
+ catch (_) { return { rows: [], missing: true, skipped: 0 }; }
48
+ const rows = [];
49
+ let skipped = 0;
50
+ for (const line of text.split('\n')) {
51
+ const t = line.trim();
52
+ if (!t) continue;
53
+ try { rows.push(JSON.parse(t)); } catch (_) { skipped += 1; }
54
+ }
55
+ return { rows, missing: false, skipped };
56
+ }
57
+
58
+ function readGaugeFile(file) {
59
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
60
+ catch (_) { return null; }
61
+ }
62
+
63
+ // sinceMs — `--since 24h|90m|3d`, or midnight LOCAL by default.
64
+ //
65
+ // Local rather than UTC because the question is "what happened last night" and
66
+ // night is a local idea; a UTC midnight cut would split an owner's evening in
67
+ // half depending on where they live.
68
+ function sinceMs(spec, now = new Date()) {
69
+ if (!spec) {
70
+ const d = new Date(now);
71
+ d.setHours(0, 0, 0, 0);
72
+ return d.getTime();
73
+ }
74
+ const m = /^(\d+(?:\.\d+)?)\s*([hmd])$/i.exec(String(spec).trim());
75
+ if (!m) return null;
76
+ const mult = { h: 3600e3, m: 60e3, d: 86400e3 }[m[2].toLowerCase()];
77
+ return now.getTime() - Number(m[1]) * mult;
78
+ }
79
+
80
+ function main(argv) {
81
+ const all = argv.includes('--all');
82
+ const asJson = argv.includes('--json');
83
+ const now = new Date();
84
+ const from = all ? null : sinceMs(arg('--since', argv), now);
85
+ if (!all && from === null) {
86
+ console.error('autobongos-digest: --since wants a span like 24h, 90m or 3d.');
87
+ return 2;
88
+ }
89
+
90
+ const lf = logPath();
91
+ const { rows, missing, skipped } = readRows(lf);
92
+ const inWindow = all ? rows : rows.filter((r) => {
93
+ const t = Date.parse(r && r.at);
94
+ return Number.isFinite(t) ? t >= from : true; // an undated row is kept, not silently dropped
95
+ });
96
+
97
+ const d = digest.buildDigest(inWindow, {
98
+ gauge: readGaugeFile(gaugePath()),
99
+ nowEpochS: Math.floor(now.getTime() / 1000),
100
+ sinceMs: all ? null : from,
101
+ untilMs: now.getTime(),
102
+ });
103
+
104
+ if (asJson) { console.log(JSON.stringify(d, null, 2)); return 0; }
105
+
106
+ console.log('');
107
+ console.log(digest.renderDigest(d));
108
+ // Both of these are about the DIGEST's own reliability, so they sit outside the
109
+ // five questions rather than being folded into them.
110
+ if (missing) {
111
+ console.log('');
112
+ console.log(` No run log at ${lf} — the runner has not written one here yet.`);
113
+ }
114
+ if (skipped) console.log(` (${skipped} unreadable line(s) in the log were skipped.)`);
115
+ console.log('');
116
+ return 0;
117
+ }
118
+
119
+ if (require.main === module) {
120
+ process.exit(main(process.argv.slice(2)));
121
+ }
122
+
123
+ module.exports = { main, sinceMs, readRows, logPath, gaugePath };
@@ -381,6 +381,13 @@ async function iteration(opts, deps = {}) {
381
381
  // What the LEDGER says, kept separate from what the worker said, so the run
382
382
  // log can be read afterwards without having to trust either one alone.
383
383
  ledger_status: ledger.status, ledger_shape: ledger.shape, verified: !!ledger.verified,
384
+ // The LEDGER's own explanation, kept beside the worker's. `reason` above is
385
+ // verdict.reason — what the WORKER said happened — and it was the only reason
386
+ // in the row, so anything reading the log afterwards was quoting the thing
387
+ // being checked rather than the check. The morning digest did exactly that
388
+ // until the grader caught it (task 1003906).
389
+ ledger_reason: ledger.reason || null,
390
+ ledger_remedy: ledger.remedy || null,
384
391
  artifact_on_main: ledger.artifact && ledger.artifact.checked ? !!ledger.artifact.onMain : null,
385
392
  claim_released: !!ledger.released,
386
393
  blocker_id: ledger.blocker && ledger.blocker.blockerId ? ledger.blocker.blockerId : null,
@@ -3,6 +3,7 @@
3
3
  #
4
4
  # powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 install
5
5
  # powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 status
6
+ # powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 digest
6
7
  # powershell -ExecutionPolicy Bypass -File scripts\gds\autobongos-service.ps1 start|stop|uninstall
7
8
  #
8
9
  # WHY TASK SCHEDULER AND NOT A REAL SERVICE. Windows services speak a protocol
@@ -29,7 +30,7 @@
29
30
  [CmdletBinding()]
30
31
  param(
31
32
  [Parameter(Position = 0)]
32
- [ValidateSet('install', 'uninstall', 'start', 'stop', 'status')]
33
+ [ValidateSet('install', 'uninstall', 'start', 'stop', 'status', 'digest')]
33
34
  [string]$Action = 'status',
34
35
 
35
36
  # The checkout to run from. Defaults to the repo this script lives in, so the
@@ -118,6 +119,11 @@ switch ($Action) {
118
119
  Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
119
120
  Write-Host "removed '$TaskName'. The log at $LogFile is left in place."
120
121
  }
122
+ 'digest' {
123
+ # The night in one page (task 1003906). On the box for convenience; the same
124
+ # five answers are what the Gate page and a mailed digest would carry.
125
+ & node (Join-Path $RepoRoot 'scripts\gds\autobongos-digest.js') @args
126
+ }
121
127
  'start' { Start-ScheduledTask -TaskName $TaskName; Write-Host "started '$TaskName'" }
122
128
  'stop' {
123
129
  Stop-ScheduledTask -TaskName $TaskName
@@ -187,6 +187,25 @@ async function coreUpgradeInstance(inst, deps, intent) {
187
187
  const healthUrl = `http://127.0.0.1:${inst.port}/healthz`;
188
188
  const versionUrl = `http://127.0.0.1:${inst.port}/version`;
189
189
  const sudoP = privileged ? 'sudo ' : '';
190
+ // WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES (task 1004053).
191
+ //
192
+ // `scripts/gds/upgrade.js` ships in the CORE checkout. A provisioned instance root holds
193
+ // no scripts/ directory at all — its core sits under node_modules/@bongos/core — so
194
+ // running the CLI with cwd = the instance root resolved nothing, and EVERY standalone
195
+ // upgrade died before it began:
196
+ //
197
+ // Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
198
+ //
199
+ // It worked only where instanceDir HAPPENED to be this checkout — the control-plane self
200
+ // row and a co-tenant — which is why it survived to production: the shapes that are
201
+ // upgraded by hand are the shapes the relative path fits. Every ACTIVE tenant is
202
+ // standalone, so the deploy door could not move a single one of them.
203
+ //
204
+ // upgrade.js's own header states the contract this restores: "Runs from the consumer
205
+ // instance root (default) or `--instance <dir>`. This script ships IN the core … against
206
+ // `--instance <consumer>`." So: run it where it exists, and NAME the target.
207
+ const runDir = REPO_ROOT;
208
+ const targetArg = instanceDir === REPO_ROOT ? '' : `--instance ${instanceDir}`;
190
209
  const cli = path.posix.join('scripts', 'gds', 'upgrade.js');
191
210
  const requestedBy = intent.requested_by && /^api:archon:(\d+)$/.test(intent.requested_by)
192
211
  ? intent.requested_by.replace(/^api:archon:/, '')
@@ -195,6 +214,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
195
214
  `${sudoP}node ${cli}`,
196
215
  '--registry',
197
216
  `--to ${to}`,
217
+ targetArg,
198
218
  `--service ${inst.slug}`,
199
219
  `--health-url ${healthUrl}`,
200
220
  `--version-url ${versionUrl}`,
@@ -204,13 +224,13 @@ async function coreUpgradeInstance(inst, deps, intent) {
204
224
  ].filter(Boolean).join(' ');
205
225
 
206
226
  if (!apply) {
207
- log(` ✓ would run: ${cmd} (dry-run — nothing ran)`);
227
+ log(` ✓ would run (in ${runDir}): ${cmd} (dry-run — nothing ran)`);
208
228
  log(` ✓ would then read ${versionUrl} on the box and require it to say ${to}`);
209
229
  return { ok: true, served: null };
210
230
  }
211
231
  try {
212
- log(` [upgrade] ${cmd}`);
213
- boxExec(cmd, { cwd: instanceDir });
232
+ log(` [upgrade] (in ${runDir}) ${cmd}`);
233
+ boxExec(cmd, { cwd: runDir });
214
234
  } catch (e) {
215
235
  // upgrade.js rolls itself back on a failed health check or an unchanged served
216
236
  // version, so by the time this throws the instance is back on the core it had.
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.877'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.878'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,302 @@
1
+ // tests/autonomy_digest.mjs — the night, readable afterwards (task 1003906).
2
+ //
3
+ // The done-when is "a night produces one digest answering all five questions
4
+ // without anyone opening a log". So the spine of this file is one test that
5
+ // feeds a realistic night through and asserts all five answers are present and
6
+ // correct — because five separately-passing sections can still add up to a page
7
+ // that does not answer the question.
8
+
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const { buildDigest, renderDigest, windowsFromGauge, humanDuration } = require('../modules/autonomy/digest.js');
15
+ const cli = require('../scripts/gds/autobongos-digest.js');
16
+
17
+ const T0 = Date.parse('2026-09-19T02:00:00.000Z');
18
+ const at = (m) => new Date(T0 + m * 60000).toISOString();
19
+ const NOW_S = Math.floor((T0 + 8 * 3600e3) / 1000);
20
+
21
+ const night = () => [
22
+ { at: at(0), event: 'boot_reconcile', ok: true, leftover: 0 },
23
+ { at: at(1), event: 'worked', task_id: 111, title: 'A shipped thing', goal_id: 1000119,
24
+ duration_s: 2820, cost_usd: 3.41, verified: true, ledger_shape: 'shipped',
25
+ undetermined_decisions: ['Cached for 60s rather than 5m'] },
26
+ { at: at(50), event: 'worked', task_id: 222, title: 'A stranded thing', goal_id: 1000119,
27
+ duration_s: 5400, cost_usd: 6.8, verified: false, ledger_shape: 'unlanded',
28
+ reason: 'the grade passed and the land never finished', blocker_id: '1000161',
29
+ undetermined_decisions: ['Sorted inactive last', 'Kept the id tie-break', 'Chose ascending'] },
30
+ { at: at(120), event: 'fenced', code: 'grader_unavailable', reason: 'the grader is bypassed' },
31
+ { at: at(121), event: 'fenced', code: 'grader_unavailable', reason: 'the grader is bypassed' },
32
+ { at: at(122), event: 'cadence', mode: 'probing', class: 'transient', consecutive_failures: 3, wait_s: 60 },
33
+ { at: at(200), event: 'nothing_claimable', goals: [1000119],
34
+ skipped: [{ id: 333, goal_id: 1000119, reasons: ['needs_migration'] }] },
35
+ { at: at(201), event: 'hold', reason: 'seven-day pace: 78% used' },
36
+ ];
37
+ const gauge = {
38
+ five_hour: { used_percentage: 41, resets_at: NOW_S + 3600 },
39
+ seven_day: { used_percentage: 78, resets_at: NOW_S + 240000 },
40
+ };
41
+
42
+ // ── the done-when, in one test ─────────────────────────────────────────────────
43
+
44
+ test('one night produces one page answering all five questions', () => {
45
+ const text = renderDigest(buildDigest(night(), { gauge, nowEpochS: NOW_S }));
46
+
47
+ // 1. what shipped — and ONLY what the ledger agreed shipped.
48
+ assert.match(text, /SHIPPED — 1 task/);
49
+ assert.match(text, /111\s+A shipped thing/);
50
+ assert.doesNotMatch(text.split('ON HOLD')[0], /222/, 'a task that ran but did not ship is not "shipped"');
51
+
52
+ // 2. what is on hold, and why.
53
+ assert.match(text, /ON HOLD/);
54
+ assert.match(text, /222\s+ran but did not ship — unlanded/);
55
+ assert.match(text, /blocker 1000161/);
56
+ assert.match(text, /fence: grader_unavailable ×2/);
57
+ assert.match(text, /every one halted/);
58
+ assert.match(text, /capacity: held 1 time/);
59
+
60
+ // 3. what each worker decided that its spec did not determine.
61
+ assert.match(text, /DECIDED FOR YOU — 4 decisions across 2 tasks/);
62
+ assert.match(text, /Cached for 60s rather than 5m/);
63
+ assert.match(text, /sentence the task description should have contained/);
64
+ assert.match(text, /Thinnest spec: 222/);
65
+
66
+ // 4. what it cost.
67
+ assert.match(text, /COST/);
68
+ assert.match(text, /\$10\.21 across 2 worker runs/);
69
+
70
+ // 5. where both limit windows stand.
71
+ assert.match(text, /Five-hour window\s+41% used, resets in 60m/);
72
+ assert.match(text, /Seven-day window\s+78% used/);
73
+ });
74
+
75
+ test('every section prints even when the night was empty', () => {
76
+ // A digest whose quiet sections vanish teaches its reader that absence means
77
+ // "fine" — and then the morning a section is missing because the DATA is
78
+ // missing reads exactly the same.
79
+ const text = renderDigest(buildDigest([], { gauge, nowEpochS: NOW_S }));
80
+ for (const heading of ['SHIPPED', 'ON HOLD', 'DECIDED FOR YOU', 'COST', 'LIMITS', 'RUNNER']) {
81
+ assert.ok(text.includes(heading), `${heading} must print on an empty night`);
82
+ }
83
+ assert.match(text, /nothing shipped/);
84
+ assert.match(text, /nothing is waiting on you/);
85
+ assert.match(text, /no worker had to fill a gap/);
86
+ });
87
+
88
+ // ── the answers, individually ──────────────────────────────────────────────────
89
+
90
+ test('shipped is the LEDGER’s answer, never the worker’s', () => {
91
+ const d = buildDigest([
92
+ { at: at(0), event: 'worked', task_id: 1, verified: true, ledger_shape: 'shipped' },
93
+ { at: at(1), event: 'worked', task_id: 2, verified: false, ledger_shape: 'still_held' },
94
+ { at: at(2), event: 'worked', task_id: 3, outcome: 'claims_shipped', ledger_shape: 'shipped_no_artifact', verified: false },
95
+ ], { nowEpochS: NOW_S });
96
+ assert.deepEqual(d.shipped.map((s) => s.taskId), ['1']);
97
+ assert.deepEqual(d.onHold.ranButDidNotShip.map((r) => r.shape), ['still_held', 'shipped_no_artifact']);
98
+ });
99
+
100
+ test('an unworkable queue is reported as a queue problem, not as breakage', () => {
101
+ const d = buildDigest([{ at: at(0), event: 'nothing_claimable', skipped: [{ id: 9, reasons: ['needs_migration'] }] }], { nowEpochS: NOW_S });
102
+ assert.equal(d.onHold.unworkable.length, 1);
103
+ const text = renderDigest(d);
104
+ assert.match(text, /this is not a runner failure/);
105
+ });
106
+
107
+ test('the decisions list groups by task and names the thinnest spec', () => {
108
+ // The SHAPE is the signal: one task with six decisions is a thin spec, six
109
+ // tasks with one each is a normal night.
110
+ const d = buildDigest([
111
+ { at: at(0), event: 'worked', task_id: 1, verified: true, undetermined_decisions: ['a'] },
112
+ { at: at(1), event: 'worked', task_id: 2, verified: true, undetermined_decisions: ['b', 'c', 'd'] },
113
+ { at: at(2), event: 'worked', task_id: 3, verified: true, undetermined_decisions: [] },
114
+ ], { nowEpochS: NOW_S });
115
+ assert.equal(d.decisionCount, 4);
116
+ assert.equal(d.decisions.length, 2, 'a task with no decisions is not listed');
117
+ assert.equal(d.thinnestSpec.taskId, '2');
118
+ });
119
+
120
+ test('a decision from a task that did NOT ship is still surfaced', () => {
121
+ // These are the ones that matter most: the judgement was made, the work is not
122
+ // in, and the call is sitting in a branch nobody has read.
123
+ const text = renderDigest(buildDigest([
124
+ { at: at(0), event: 'worked', task_id: 7, title: 'T', verified: false, ledger_shape: 'unlanded', undetermined_decisions: ['picked a default'] },
125
+ ], { nowEpochS: NOW_S }));
126
+ assert.match(text, /picked a default/);
127
+ assert.match(text, /\[did not ship\]/);
128
+ });
129
+
130
+ test('an unreported cost is excluded and counted, never coerced to zero', () => {
131
+ const d = buildDigest([
132
+ { at: at(0), event: 'worked', task_id: 1, verified: true, cost_usd: 2.5 },
133
+ { at: at(1), event: 'worked', task_id: 2, verified: true, cost_usd: null },
134
+ { at: at(2), event: 'worked', task_id: 3, verified: true },
135
+ ], { nowEpochS: NOW_S });
136
+ assert.equal(d.cost.totalUsd, 2.5, 'a worker whose cost was not reported must not make the night look cheaper');
137
+ assert.equal(d.cost.reported, 1);
138
+ assert.equal(d.cost.unreported, 2);
139
+ assert.match(renderDigest(d), /2 did not report a cost/);
140
+ });
141
+
142
+ // ── the limit windows ──────────────────────────────────────────────────────────
143
+
144
+ test('a window whose reset has PASSED is reported as stale, not as headroom', () => {
145
+ // "41% used, resets in 0s" invites a decision about headroom that no longer
146
+ // exists: the percentage was true of a window that has since rolled.
147
+ const [five] = windowsFromGauge({ five_hour: { used_percentage: 41, resets_at: NOW_S - 60 } }, NOW_S);
148
+ assert.equal(five.known, false);
149
+ assert.equal(five.stale, true);
150
+ assert.match(five.detail, /has since reset/);
151
+ });
152
+
153
+ test('a window the gauge never reported says so rather than reading as zero', () => {
154
+ const [five, seven] = windowsFromGauge({ seven_day: { used_percentage: 12, resets_at: NOW_S + 999 } }, NOW_S);
155
+ assert.equal(five.known, false);
156
+ assert.equal(five.detail, 'not reported');
157
+ assert.equal(seven.known, true);
158
+ for (const g of [null, undefined, {}, 'nope']) {
159
+ assert.deepEqual(windowsFromGauge(g, NOW_S).map((w) => w.known), [false, false], JSON.stringify(g));
160
+ }
161
+ });
162
+
163
+ // ── the runner footer ──────────────────────────────────────────────────────────
164
+
165
+ test('a night that ended probing says so — "nothing shipped" has two causes', () => {
166
+ const text = renderDigest(buildDigest([
167
+ { at: at(0), event: 'cadence', mode: 'full', class: 'quiet' },
168
+ { at: at(1), event: 'cadence', mode: 'probing', class: 'transient' },
169
+ ], { nowEpochS: NOW_S }));
170
+ assert.match(text, /Ended the night PROBING/);
171
+ });
172
+
173
+ test('an event this digest does not know about is counted, never dropped', () => {
174
+ // A digest that silently ignores what it does not understand is how a new
175
+ // failure mode stays invisible for a week.
176
+ const d = buildDigest([{ at: at(0), event: 'something_new_entirely' }], { nowEpochS: NOW_S });
177
+ assert.equal(d.runner.unrecognised, 1);
178
+ assert.equal(d.runner.byEvent.something_new_entirely, 1);
179
+ assert.match(renderDigest(d), /a kind this digest does not know about/);
180
+ });
181
+
182
+ test('malformed rows never take the night down', () => {
183
+ const d = buildDigest([null, undefined, 42, 'nope', {}, { event: 'worked', verified: true }], { nowEpochS: NOW_S });
184
+ assert.equal(d.shipped.length, 1);
185
+ assert.doesNotThrow(() => renderDigest(d));
186
+ });
187
+
188
+ // ── the CLI half ───────────────────────────────────────────────────────────────
189
+
190
+ test('--since accepts a span and defaults to local midnight', () => {
191
+ const now = new Date('2026-09-19T14:30:00');
192
+ assert.equal(cli.sinceMs('24h', now), now.getTime() - 86400e3);
193
+ assert.equal(cli.sinceMs('90m', now), now.getTime() - 5400e3);
194
+ assert.equal(cli.sinceMs('3d', now), now.getTime() - 3 * 86400e3);
195
+ // Local midnight, because "last night" is a local idea — a UTC cut would split
196
+ // an owner's evening in half depending on where they live.
197
+ const midnight = new Date(now); midnight.setHours(0, 0, 0, 0);
198
+ assert.equal(cli.sinceMs(null, now), midnight.getTime());
199
+ assert.equal(cli.sinceMs('soon', now), null, 'an unparseable span must refuse, not guess');
200
+ });
201
+
202
+ test('a torn last line costs one row, not the night', async () => {
203
+ // A JSONL written by a process that can be killed mid-write has a torn last
204
+ // line more often than never.
205
+ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path');
206
+ const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ab-digest-')), 'runs.jsonl');
207
+ fs.writeFileSync(f, `${JSON.stringify({ at: at(0), event: 'worked', task_id: 1, verified: true })}\n{"at":"x","event":"wor`);
208
+ const { rows, skipped, missing } = cli.readRows(f);
209
+ assert.equal(missing, false);
210
+ assert.equal(rows.length, 1);
211
+ assert.equal(skipped, 1);
212
+ });
213
+
214
+ test('a missing log is reported, not mistaken for a quiet night', async () => {
215
+ const { rows, missing } = cli.readRows('/definitely/not/here/runs.jsonl');
216
+ assert.deepEqual(rows, []);
217
+ assert.equal(missing, true);
218
+ });
219
+
220
+ test('humanDuration reads like a person wrote it', () => {
221
+ assert.equal(humanDuration(45), '45s');
222
+ assert.equal(humanDuration(2820), '47m');
223
+ assert.equal(humanDuration(10080), '2.8h');
224
+ assert.equal(humanDuration(null), null);
225
+ });
226
+
227
+ // ── the grader's three findings (task 1003906) ─────────────────────────────────
228
+
229
+ test('the "did not ship" reason is the LEDGER’s, not the worker’s', () => {
230
+ // THE INVERSION THIS GOAL EXISTS TO UNDO. `row.reason` is verdict.reason — the
231
+ // WORKER's account of itself — and quoting it here means the digest reports the
232
+ // thing being checked rather than the check. A worker that stopped dead needing
233
+ // a decision reports success; the ledger says what actually happened.
234
+ const d = buildDigest([{
235
+ at: at(0), event: 'worked', task_id: 9, title: 'T', verified: false, ledger_shape: 'unlanded',
236
+ reason: 'I finished the work and shipped it',
237
+ ledger_reason: 'the grade passed and the land never finished',
238
+ ledger_remedy: '/merge-mode is the documented manual fallback',
239
+ }], { nowEpochS: NOW_S });
240
+
241
+ assert.equal(d.onHold.ranButDidNotShip[0].reason, 'the grade passed and the land never finished');
242
+ assert.equal(d.onHold.ranButDidNotShip[0].workerSaid, 'I finished the work and shipped it');
243
+
244
+ const text = renderDigest(d);
245
+ assert.match(text, /the grade passed and the land never finished/);
246
+ assert.match(text, /next: \/merge-mode/);
247
+ // The disagreement is the single most useful line on the page.
248
+ assert.match(text, /the worker said: I finished the work and shipped it/);
249
+ });
250
+
251
+ test('the worker’s account is printed only when it disagrees', () => {
252
+ // A worker that agreed with the ledger tells the reader nothing twice.
253
+ const same = 'the grade parked this';
254
+ const text = renderDigest(buildDigest([{
255
+ at: at(0), event: 'worked', task_id: 9, verified: false, ledger_shape: 'ungraded', reason: same, ledger_reason: same,
256
+ }], { nowEpochS: NOW_S }));
257
+ assert.equal((text.match(/the grade parked this/g) || []).length, 1);
258
+ assert.doesNotMatch(text, /the worker said:/);
259
+ });
260
+
261
+ test('a row from before ledger_reason existed still shows something', () => {
262
+ const d = buildDigest([{ at: at(0), event: 'worked', task_id: 9, verified: false, ledger_shape: 'unlanded', reason: 'older row' }], { nowEpochS: NOW_S });
263
+ assert.equal(d.onHold.ranButDidNotShip[0].reason, 'older row');
264
+ });
265
+
266
+ test('the digest calls a gauge stale on the SAME rule the runner holds on', () => {
267
+ // A digest that applied a laxer test would print live headroom about a window
268
+ // the runner had already decided it could not vouch for — two surfaces
269
+ // disagreeing about one file, with the reader believing the wrong one.
270
+ const gaugeMod = require('../modules/autonomy/gauge.js');
271
+ const tooOld = gaugeMod.DEFAULT_STALE_AFTER_S + 60;
272
+
273
+ const [fresh] = windowsFromGauge(
274
+ { five_hour: { used_percentage: 41, resets_at: NOW_S + 7200, observed_at: NOW_S - 60 } }, NOW_S);
275
+ assert.equal(fresh.known, true, 'a recent reading is usable');
276
+
277
+ const [stale] = windowsFromGauge(
278
+ { five_hour: { used_percentage: 41, resets_at: NOW_S + 7200, observed_at: NOW_S - tooOld } }, NOW_S);
279
+ assert.equal(stale.known, false);
280
+ assert.equal(stale.stale, true);
281
+ assert.match(stale.detail, /older than the runner trusts/);
282
+ assert.doesNotMatch(stale.detail, /resets in/, 'never offer headroom from a reading the runner rejected');
283
+ });
284
+
285
+ test('a window with no observed_at falls back to the file’s written_at', () => {
286
+ // One window can go unreported while the other is carried forward — the exact
287
+ // shape gauge.js reads.
288
+ const gaugeMod = require('../modules/autonomy/gauge.js');
289
+ const old = NOW_S - gaugeMod.DEFAULT_STALE_AFTER_S - 60;
290
+ const [w] = windowsFromGauge({ written_at: old, five_hour: { used_percentage: 41, resets_at: NOW_S + 7200 } }, NOW_S);
291
+ assert.equal(w.stale, true);
292
+ });
293
+
294
+ test('the period bounds survive a log too long to spread as arguments', () => {
295
+ // `Math.min(...times)` throws past the engine's argument cap, and a reporting
296
+ // CLI that dies on a big log fails exactly when the log is worth reading.
297
+ const many = Array.from({ length: 200000 }, (_, i) => ({ at: at(i % 1000), event: 'cadence', mode: 'full' }));
298
+ let d;
299
+ assert.doesNotThrow(() => { d = buildDigest(many, { nowEpochS: NOW_S }); });
300
+ assert.equal(d.period.rows, 200000);
301
+ assert.equal(d.period.fromMs, T0);
302
+ });
@@ -28,6 +28,8 @@
28
28
 
29
29
  import { strict as assert } from 'node:assert';
30
30
  import { createRequire } from 'node:module';
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
31
33
 
32
34
  const require = createRequire(import.meta.url);
33
35
  const R = require('../scripts/gds/provision-core-upgrade.js');
@@ -46,13 +48,19 @@ const t = async (name, fn) => {
46
48
  // instant. `apply:true` because the interesting behaviour is the applied path.
47
49
  function makeDeps({ shape = 'control-plane', served = '1.19.760', available = ['1.19.758', '1.19.760', '1.19.762'], installed = '1.19.760', queries = [] } = {}) {
48
50
  const ran = [];
49
- const exec = (cmd) => {
51
+ // task 1004053: the CWD is half of what makes a relative CLI path resolve, and the
52
+ // bug that shipped was invisible to every assertion that read only the command
53
+ // string. Captured alongside, so a test can check the pair.
54
+ const ranWith = [];
55
+ const exec = (cmd, opts) => {
50
56
  ran.push(cmd);
57
+ ranWith.push({ cmd, cwd: opts && opts.cwd });
51
58
  if (/\/version/.test(cmd)) return { ok: true, stdout: served == null ? '' : JSON.stringify({ coreVersion: served }) };
52
59
  return { ok: true, stdout: '' };
53
60
  };
54
61
  return {
55
62
  ran,
63
+ ranWith,
56
64
  queries,
57
65
  db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
58
66
  provisioning: { recordEvent: async () => {}, clearErrorNote: async () => {} },
@@ -245,6 +253,40 @@ await t('drives upgrade.js with the registry, the health gate, the version read-
245
253
  assert.ok(cmd.includes('--commit-pin'));
246
254
  });
247
255
 
256
+ await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that broke every standalone tenant (task 1004053)', async () => {
257
+ // The whole failure was a path that resolved nowhere:
258
+ // Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
259
+ // No assertion about FLAGS could have caught it, so this one resolves the composed
260
+ // cwd + relative CLI against the real filesystem, for every shape the door admits.
261
+ for (const shape of ['control-plane', 'standalone', 'co-tenant']) {
262
+ const d = makeDeps({ served: '1.19.762' });
263
+ await R.coreUpgradeInstance(inst({ hosting_shape: shape, slug: 'hermeslines-marketing', port: 3011 }), d, intent());
264
+ const call = d.ranWith.find((c) => /upgrade\.js/.test(c.cmd));
265
+ assert.ok(call, `${shape}: it ran the upgrade CLI`);
266
+ const rel = (call.cmd.match(/node (\S*scripts\/gds\/upgrade\.js)/) || [])[1];
267
+ assert.ok(rel, `${shape}: the command names the CLI`);
268
+ assert.ok(fs.existsSync(path.join(call.cwd, rel)),
269
+ `${shape}: ${path.join(call.cwd, rel)} must exist — this is the assertion the original bug would have failed`);
270
+ }
271
+ });
272
+
273
+ await t('a standalone tenant is NAMED with --instance, because the CLI no longer runs inside it', async () => {
274
+ const d = makeDeps({ served: '1.19.762' });
275
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'hermeslines-marketing', port: 3011 }), d, intent());
276
+ const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
277
+ assert.match(cmd, /--instance \S*hermeslines-marketing/, 'the target is named explicitly');
278
+ assert.ok(cmd.includes('--service hermeslines-marketing'), 'and its own unit is still the one restarted');
279
+ });
280
+
281
+ await t('the control-plane row carries NO --instance — its target IS the checkout the CLI runs in', async () => {
282
+ // Naming it would be harmless but wrong, and the shapes that already worked must not
283
+ // change behaviour on the way to fixing the one that never did.
284
+ const d = makeDeps({ served: '1.19.762' });
285
+ await R.coreUpgradeInstance(inst(), d, intent());
286
+ const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
287
+ assert.ok(!cmd.includes('--instance'), 'unchanged for the platform itself');
288
+ });
289
+
248
290
  await t('the health URL and the version URL are LOOPBACK — the apex is behind basic_auth and a CDN (ADR 0293 D6)', async () => {
249
291
  const d = makeDeps();
250
292
  await R.coreUpgradeInstance(inst(), d, intent());