@bongos/core 1.19.870 → 1.19.872

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.
Files changed (102) hide show
  1. package/.bongos-core.json +164 -94
  2. package/.claude/skills/merge-mode/SKILL.md +1 -1
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +4 -0
  7. package/clients/bongos-client/index.cjs +4 -0
  8. package/clients/bongos-client/index.d.ts +5 -0
  9. package/clients/bongos-client/index.mjs +4 -0
  10. package/docs/adr/0307-autobongos-a-deterministic-supervisor-and-agentic-workers.md +10 -0
  11. package/docs/api/openapi.json +61 -3
  12. package/docs/api-reference.md +7 -1
  13. package/docs/architecture.md +7 -7
  14. package/docs/branding-contract.md +1 -1
  15. package/docs/copy-inventory.md +190 -189
  16. package/docs/copy-registry.json +264 -255
  17. package/docs/design/hall-direction-v2.md +2 -2
  18. package/docs/module-api-changelog.md +4 -0
  19. package/docs/onboarding/diagrams/README.md +4 -4
  20. package/docs/onboarding/drachmae-and-karma.md +2 -2
  21. package/docs/onboarding/primer.md +1 -1
  22. package/docs/recipes/README.md +1 -1
  23. package/docs/recipes/bongos-cli-release.md +4 -4
  24. package/docs/recipes/core-release-pipeline.md +3 -3
  25. package/docs/recipes/instance-deploy.md +2 -2
  26. package/docs/recipes/ops-gotchas.md +2 -2
  27. package/docs/recipes/search-index-ingestion.md +2 -2
  28. package/docs/recipes/upgrading-the-core.md +1 -1
  29. package/modules/autonomy/gauge.js +218 -0
  30. package/modules/dev-box/app/README.md +1 -1
  31. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  32. package/modules/hall-ui/public/blockers.js +138 -34
  33. package/modules/hall-ui/public/builders.js +2 -1
  34. package/modules/hall-ui/public/collab.html +2 -2
  35. package/modules/hall-ui/public/collab.js +6 -3
  36. package/modules/hall-ui/public/dom-utils.js +46 -5
  37. package/modules/hall-ui/public/goals-page.js +7 -3
  38. package/modules/hall-ui/public/goals-render.js +4 -3
  39. package/modules/hall-ui/public/hall-render.js +1 -1
  40. package/modules/hall-ui/public/people.js +3 -3
  41. package/modules/hall-ui/public/profile-charts.js +5 -2
  42. package/modules/hall-ui/public/profile.html +3 -3
  43. package/modules/hall-ui/public/profile.js +6 -6
  44. package/modules/hall-ui/public/settings-specialities.js +2 -2
  45. package/modules/hall-ui/public/settings.html +4 -4
  46. package/modules/hall-ui/public/settings.js +2 -2
  47. package/modules/hall-ui/public/shell.js +70 -0
  48. package/modules/hall-ui/public/style.css +31 -0
  49. package/modules/hall-ui/public/work.js +12 -14
  50. package/modules/provisioning/module.json +3 -3
  51. package/modules/public-landing/public/terms.html +1 -1
  52. package/package-lock.json +2 -2
  53. package/package.json +1 -1
  54. package/scripts/gds/autobongos-loop.js +170 -0
  55. package/scripts/gds/autobongos-run.js +317 -0
  56. package/scripts/gds/autobongos-verify.js +377 -0
  57. package/scripts/gds/autonomy-gauge-statusline.js +124 -0
  58. package/scripts/gds/autonomy-gauge.js +84 -0
  59. package/scripts/gds/run-unit-tests.js +120 -2
  60. package/scripts/gds/sequence.js +3 -0
  61. package/scripts/gds/start.js +16 -10
  62. package/src/bongos/core-update.js +147 -0
  63. package/src/bongos/routes/core-update.js +49 -0
  64. package/src/bongos/routes.js +2 -0
  65. package/src/module-api.js +1 -1
  66. package/tests/account_privacy_flags.mjs +3 -1
  67. package/tests/autobongos_loop.mjs +446 -0
  68. package/tests/autobongos_verify.mjs +325 -0
  69. package/tests/autonomy_gauge.mjs +344 -0
  70. package/tests/blockers_hall.mjs +20 -6
  71. package/tests/blockers_kit_pager.mjs +260 -0
  72. package/tests/blockers_page_ui.mjs +5 -4
  73. package/tests/box_ship_permissions.mjs +30 -0
  74. package/tests/core_update_banner.mjs +308 -0
  75. package/tests/eviction_policy.mjs +22 -1
  76. package/tests/git_guard.mjs +68 -0
  77. package/tests/goal_criterion_confirm_ui.mjs +7 -0
  78. package/tests/goal_invite_consent.mjs +7 -0
  79. package/tests/goal_sort_staleness.mjs +6 -2
  80. package/tests/goal_subtitle.mjs +48 -4
  81. package/tests/hall_board_world.mjs +4 -1
  82. package/tests/hall_builders_page_pass.mjs +7 -0
  83. package/tests/hall_landing_boot.mjs +7 -0
  84. package/tests/hall_profile_world.mjs +20 -2
  85. package/tests/hall_record_world.mjs +7 -0
  86. package/tests/hall_roster_world.mjs +2 -1
  87. package/tests/hall_studio_world.mjs +7 -0
  88. package/tests/hook_shell_tools.mjs +23 -1
  89. package/tests/idea_detail_page.mjs +7 -0
  90. package/tests/idea_spark_hall.mjs +7 -0
  91. package/tests/integration_parked_ratchet.mjs +79 -0
  92. package/tests/module_contributions.mjs +134 -8
  93. package/tests/people_directory_clip.mjs +7 -0
  94. package/tests/profile_activity.mjs +7 -0
  95. package/tests/profile_ui_cross_project.mjs +9 -1
  96. package/tests/provision_disconnect.mjs +7 -0
  97. package/tests/provision_restart.mjs +7 -0
  98. package/tests/provision_settings_apply.mjs +7 -0
  99. package/tests/sessions_kit_pager.mjs +7 -0
  100. package/tests/watch_applications_queue.mjs +7 -0
  101. package/tests/watch_roster_self_row.mjs +7 -0
  102. package/tests/watch_sealed_floor.mjs +7 -0
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // scripts/gds/autobongos-verify.js — read completion back from the LEDGER, never
5
+ // from the session that claims it (task 1003903; ADR 0307 §4).
6
+ //
7
+ // THE CONFUSION THIS EXISTS TO END. A session exiting is not a task shipping,
8
+ // and this project's worst incidents are all that one confusion wearing
9
+ // different clothes: task 1002483 sat at `completed` for three days carrying 651
10
+ // lines of tested work on a branch nobody had pushed; task 1002459 sat at
11
+ // `confirmed` for four days with no artifact on any branch at all; a ship once
12
+ // "timed out" after its merge had already landed. Every one of them was found by
13
+ // accident, because a strand emits no signal of its own.
14
+ //
15
+ // The headless envelope makes it worse rather than better. A worker that stopped
16
+ // dead needing a decision returns `subtype: "success"` — structurally identical
17
+ // to one that finished the job. So the worker's `shipped` is a CLAIM, which is
18
+ // why autobongos-loop.js names it `claims_shipped`, and this file is the half
19
+ // that turns a claim into a fact or into a filed blocker.
20
+ //
21
+ // THE VOCABULARY IS BORROWED, NOT INVENTED. strand-watch.js (task 1003163)
22
+ // already named the three mid-ship shapes — `unprovable`, `unlanded`,
23
+ // `ungraded` — and /strand-fix routes on those names. Calling the same condition
24
+ // something else here would give one situation two names and split the remedies,
25
+ // so classifyStrand() is called directly. What changes is the THRESHOLD: the
26
+ // board sweep waits an hour (a slow CI queue is not a strand), while here the
27
+ // session that was supposed to finish the job has already exited, so waiting
28
+ // cannot change the verdict. The thresholds are zeroed deliberately, and this
29
+ // file is strictly stricter than the board check — never quieter.
30
+ //
31
+ // Usage (normally called by autobongos-run.js, but runnable by hand):
32
+ // node scripts/gds/autobongos-verify.js 1003902 # verify, report, change nothing
33
+ // node scripts/gds/autobongos-verify.js 1003902 --act # also release + file the blocker
34
+ // node scripts/gds/autobongos-verify.js 1003902 --json
35
+ //
36
+ // Exit codes: 0 verified shipped · 1 not shipped (a shape is named) · 2 undetermined.
37
+
38
+ const path = require('node:path');
39
+ const { execFile } = require('node:child_process');
40
+
41
+ const { classifyStrand, WATCHED } = require('./strand-watch.js');
42
+
43
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
44
+
45
+ // Statuses a claim can still be sitting in when the worker is already gone.
46
+ // `active` is the live one; the others are listed because a future rename must
47
+ // fail loudly here rather than silently reclassify a held claim as untouched.
48
+ const HELD_STATUSES = new Set(['active', 'claimed', 'in_progress']);
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // The pure half
52
+ // ---------------------------------------------------------------------------
53
+
54
+ // classifyLedger — what the ledger says happened, in one word.
55
+ //
56
+ // `artifact` is the result of probeArtifact() or null for "not probed". The
57
+ // distinction is load-bearing: `checked: false` means we could not look (no
58
+ // network, a detached checkout), and NOT LOOKING IS NOT EVIDENCE OF ABSENCE. A
59
+ // failed probe must never manufacture a missing artifact.
60
+ //
61
+ // Every shape carries what to DO, because the caller must not re-derive it:
62
+ // releaseClaim — the task is still held by a session that no longer exists.
63
+ // fileBlocker — a human has to look at this.
64
+ // linkBlocker — link the blocker to the task, which parks it at `blocked` and
65
+ // takes it out of claimable_tasks. Only meaningful once the task
66
+ // is back at `ready` (LINK_BLOCKABLE_STATUSES in
67
+ // modules/ideas/blockers.js is exactly ['ready']), which is why
68
+ // the caller releases BEFORE it links.
69
+ // halt — the run stops here rather than picking another task.
70
+ function classifyLedger({ task, artifact = null, nowMs = Date.now() } = {}) {
71
+ const none = { releaseClaim: false, fileBlocker: false, linkBlocker: false, halt: false };
72
+
73
+ if (!task) {
74
+ // Cannot read the ledger => cannot verify anything => touch nothing. Releasing
75
+ // a claim on a guess is how work gets stranded, and this is the one branch
76
+ // where the honest move is to stop and let a person look.
77
+ return {
78
+ ...none, verified: false, shape: 'undetermined', status: null, halt: true,
79
+ reason: 'the task could not be read back from Bongos, so nothing about it is known',
80
+ remedy: 'check the instance is reachable, then re-run the verify by hand',
81
+ };
82
+ }
83
+
84
+ const status = String(task.status || '');
85
+
86
+ if (status === 'shipped') {
87
+ if (artifact && artifact.checked && !artifact.onMain) {
88
+ // The flip happened with nothing behind it — tasks 1002459 and 1002792,
89
+ // both credited, neither carrying a line of code on any branch. The task is
90
+ // NOT released and NOT linked: it is shipped, credits are paid, and parking
91
+ // it would help nobody. The blocker is the whole point — a person has to
92
+ // decide whether the work was codeless (legitimate: a DB-only change, work
93
+ // on another checkout) or simply never done.
94
+ return {
95
+ ...none, verified: false, shape: 'shipped_no_artifact', status, fileBlocker: true,
96
+ reason: 'the ledger says shipped but no commit on main names this task',
97
+ remedy: 'confirm the work was codeless, or redo it — do not try to deploy what is not there',
98
+ };
99
+ }
100
+ return {
101
+ ...none, verified: true, shape: 'shipped', status,
102
+ reason: artifact && artifact.checked
103
+ ? `shipped, and main carries ${artifact.sha ? artifact.sha.slice(0, 8) : 'a commit'} naming it`
104
+ : 'shipped',
105
+ remedy: null,
106
+ };
107
+ }
108
+
109
+ if (status === 'abandoned') {
110
+ // Terminal, but not a delivery. An unattended worker deciding to cut a task
111
+ // is a judgement call it was never given, so it stops the run.
112
+ return {
113
+ ...none, verified: false, shape: 'abandoned', status, fileBlocker: true, halt: true,
114
+ reason: 'the task was abandoned, not shipped — a cut is a decision a worker does not get to make',
115
+ remedy: 'decide whether the cut was right; re-open the task if it was not',
116
+ };
117
+ }
118
+
119
+ if (WATCHED.includes(status)) {
120
+ // The three shapes strand-watch already names. Thresholds zeroed: the board
121
+ // sweep is allowed to wait, this is not (see the header).
122
+ const strand = classifyStrand(task, { nowMs, confirmedStaleMs: -1, completedStaleMs: -1 });
123
+ // classifyStrand fails QUIET on an unparseable updated_at — right for a line
124
+ // injected into every session, wrong here, where quiet means the loop calls a
125
+ // strand a success. The status alone decides the shape when the date will not
126
+ // parse; `unprovable` is the only shape that needs a field to be sure of.
127
+ const shape = strand ? strand.shape : (status === 'confirmed' ? 'unlanded' : 'ungraded');
128
+ const remedy = strand ? strand.remedy : (status === 'confirmed'
129
+ ? '/merge-mode is the documented manual fallback'
130
+ : '/grade-recover diagnoses which of the three shapes parked it');
131
+ return {
132
+ ...none, verified: false, shape, status, fileBlocker: true,
133
+ reason: {
134
+ unprovable: 'the reconciler probed this, could not prove a land, and gave up — it will never self-heal',
135
+ unlanded: 'the grade passed and the land never finished',
136
+ ungraded: 'the grade parked this; it is not waiting on a land',
137
+ }[shape],
138
+ strandReason: strand ? strand.reason : null,
139
+ remedy,
140
+ };
141
+ }
142
+
143
+ if (HELD_STATUSES.has(status)) {
144
+ // The worker is gone and the claim is not. This is the one shape that
145
+ // actually needs the claim released — and the only one where the link can
146
+ // then park the task, because release returns it to `ready`.
147
+ return {
148
+ ...none, verified: false, shape: 'still_held', status,
149
+ releaseClaim: true, fileBlocker: true, linkBlocker: true,
150
+ reason: `the worker exited with the task still at '${status}' — the claim outlived the session holding it`,
151
+ remedy: 'read the worker output in the run log; the tree may hold committed work',
152
+ };
153
+ }
154
+
155
+ if (status === 'blocked') {
156
+ // Already out of the pool with a blocker on it. Filing a second one would
157
+ // just be noise, and the loop will skip it on the next pick either way.
158
+ return {
159
+ ...none, verified: false, shape: 'blocked', status,
160
+ reason: 'the task is blocked — it is already out of the claimable pool',
161
+ remedy: 'resolve the blocker that is on it',
162
+ };
163
+ }
164
+
165
+ // `ready`, `backlog`, or anything a future migration adds. The worker either
166
+ // released the claim itself or never took it. Still not shipped, so it still
167
+ // gets a blocker: a task a worker could not finish will be picked straight back
168
+ // up next iteration otherwise, and fail the same way, for the whole window.
169
+ return {
170
+ ...none, verified: false, shape: 'not_started', status, fileBlocker: true, linkBlocker: true,
171
+ reason: `the worker returned with the task back at '${status}' — no work was recorded against it`,
172
+ remedy: 'read the worker output in the run log before re-queueing it',
173
+ };
174
+ }
175
+
176
+ // blockerFor — the row a shape becomes. Pure, so the wording is testable.
177
+ //
178
+ // `source_ref` is the DEDUPE KEY: POST /blockers has ON CONFLICT (source,
179
+ // source_ref) DO NOTHING, so a second failure of the SAME shape on the SAME task
180
+ // is skipped rather than piling up a column of identical blockers. The shape is
181
+ // part of the key on purpose — a task that fails a DIFFERENT way is a different
182
+ // fact and deserves to be said out loud.
183
+ function blockerFor(v, { taskId, taskTitle = null, worker = null } = {}) {
184
+ const lines = [
185
+ `Autobongos verified task ${taskId} against the ledger after its worker exited, and it is not shipped.`,
186
+ '',
187
+ `**Ledger status:** \`${v.status || 'unreadable'}\` · **shape:** \`${v.shape}\``,
188
+ `**What that means:** ${v.reason}`,
189
+ ];
190
+ if (v.strandReason) lines.push(`**Reconciler recorded:** \`${v.strandReason}\``);
191
+ if (worker && worker.outcome) {
192
+ lines.push('', `**What the worker reported:** \`${worker.outcome}\`${worker.reason ? ` — ${String(worker.reason).slice(0, 400)}` : ''}`);
193
+ }
194
+ if (v.remedy) lines.push('', `**Next:** ${v.remedy}`);
195
+ lines.push('', 'Filed by `scripts/gds/autobongos-verify.js`. The supervisor reads completion from Bongos, never from the session — a session exiting is not a task shipping (ADR 0307 §4).');
196
+
197
+ return {
198
+ title: `Autobongos: task ${taskId} came back ${v.shape}${taskTitle ? ` — ${String(taskTitle).slice(0, 120)}` : ''}`.slice(0, 200),
199
+ body_md: lines.join('\n'),
200
+ source: 'autobongos',
201
+ source_ref: `task-${taskId}-${v.shape}`,
202
+ };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // The I/O half
207
+ // ---------------------------------------------------------------------------
208
+
209
+ const runGit = (args, cwd) => new Promise((resolve) => {
210
+ execFile('git', args, { cwd, maxBuffer: 4 * 1024 * 1024 },
211
+ (err, stdout, stderr) => resolve({ ok: !err, stdout: stdout || '', stderr: stderr || '' }));
212
+ });
213
+
214
+ // probeArtifact — is there ANY trace of this task on main?
215
+ //
216
+ // Deliberately weak and deliberately cheap. It answers "is there a trace", not
217
+ // "which commit is it": a body mention counts, because the question being asked
218
+ // is the 1002459 question — was anything at all produced. The fixed-string grep
219
+ // is against `(task N)`, the convention every ship commit follows.
220
+ //
221
+ // A probe that could not run returns `checked: false`, which classifyLedger
222
+ // treats as no evidence either way.
223
+ async function probeArtifact(taskId, { git = runGit, cwd = REPO_ROOT } = {}) {
224
+ const id = String(taskId);
225
+ if (!/^\d{1,19}$/.test(id)) return { checked: false, reason: 'task id is not a plain number' };
226
+
227
+ const fetched = await git(['fetch', '-q', 'origin', 'main'], cwd);
228
+ if (!fetched.ok) {
229
+ return { checked: false, reason: `git fetch failed: ${(fetched.stderr || '').trim().slice(-200)}` };
230
+ }
231
+ const log = await git(['log', 'FETCH_HEAD', '--max-count=5', '--format=%H %s', '-F', `--grep=(task ${id})`], cwd);
232
+ if (!log.ok) return { checked: false, reason: `git log failed: ${(log.stderr || '').trim().slice(-200)}` };
233
+
234
+ const first = log.stdout.split('\n').find((l) => l.trim());
235
+ if (!first) return { checked: true, onMain: false, sha: null, subject: null };
236
+ const sp = first.indexOf(' ');
237
+ return { checked: true, onMain: true, sha: first.slice(0, sp < 0 ? first.length : sp), subject: sp < 0 ? null : first.slice(sp + 1) };
238
+ }
239
+
240
+ // fetchTask returns the row or null, and says WHY on a null. The reason is the
241
+ // only thing a person reading the run log afterwards has to go on — "undetermined"
242
+ // alone cannot tell an expired session from an unreachable instance.
243
+ async function fetchTask(api, taskId) {
244
+ try {
245
+ const r = await api.tasks.getTasksId({ id: Number(taskId) });
246
+ if (!r || !r.ok) return { task: null, error: `GET /tasks/${taskId} → ${r ? r.status : 'no response'}` };
247
+ const task = r.data && r.data.task;
248
+ return task ? { task } : { task: null, error: `GET /tasks/${taskId} returned no task` };
249
+ } catch (e) { return { task: null, error: e && e.message ? e.message : String(e) }; }
250
+ }
251
+
252
+ // fileBlocker — create the row, then link it to the task.
253
+ //
254
+ // The two steps are separate calls and the SECOND one is what actually takes the
255
+ // task out of the pool: POST /blockers only creates a row, and claimable_tasks
256
+ // excludes a task through `task_blockers`, not through the blockers table. A
257
+ // blocker filed and never linked is a note nobody reads.
258
+ //
259
+ // The link is rank-gated (metic+, `blocker.review`), while filing is open to any
260
+ // builder. A runner below that rank still gets the blocker written down; the
261
+ // result says the link did not happen rather than pretending it did.
262
+ async function fileBlocker(api, row, { taskId, link }) {
263
+ let created = null;
264
+ try {
265
+ const r = await api.blockers.postBlockers({ body: row });
266
+ if (!r || !r.ok) return { filed: false, reason: `POST /blockers → ${r ? r.status : 'no response'}`, linked: false };
267
+ if (r.data && r.data.skipped) return { filed: false, duplicate: true, reason: 'this task already carries a blocker of this shape', linked: false };
268
+ created = r.data && r.data.blocker;
269
+ } catch (e) { return { filed: false, reason: e && e.message ? e.message : String(e), linked: false }; }
270
+
271
+ if (!created || !created.id) return { filed: false, reason: 'POST /blockers returned no blocker', linked: false };
272
+ if (!link) return { filed: true, blockerId: created.id, linked: false };
273
+
274
+ try {
275
+ const r = await api.blockers.postBlockersIdLink({ id: Number(created.id), body: { task_id: Number(taskId) } });
276
+ // `blocked` is the server saying whether the task actually MOVED. It answers
277
+ // false when the task was not at `ready` — a dead link loop went unnoticed for
278
+ // six weeks on exactly that (task 1002559), so it is reported, not discarded.
279
+ return { filed: true, blockerId: created.id, linked: !!(r && r.ok), parked: !!(r && r.ok && r.data && r.data.blocked) };
280
+ } catch (e) { return { filed: true, blockerId: created.id, linked: false, reason: e && e.message ? e.message : String(e) }; }
281
+ }
282
+
283
+ // verifyShip — the whole step, in the order the order matters.
284
+ //
285
+ // RELEASE BEFORE LINK, always. linkBlockerToTask() parks the task only when it is
286
+ // at `ready`; releasing is what puts it there. Link first and the row is written,
287
+ // the task is not parked, and the release then hands it back to the pool — the
288
+ // loop picks the same doomed task up next iteration and burns the window on it.
289
+ async function verifyShip({ taskId, taskTitle = null, worker = null }, deps = {}) {
290
+ const api = deps.api || await (require('./cli-lib').cliClient)();
291
+ const probe = deps.probeArtifact || probeArtifact;
292
+ const release = deps.release || null;
293
+ const file = deps.fileBlocker || fileBlocker;
294
+ const act = deps.act !== false;
295
+
296
+ let task = deps.task;
297
+ let readError = null;
298
+ if (task === undefined) ({ task, error: readError } = await fetchTask(api, taskId));
299
+
300
+ // The artifact probe only earns its network round-trip when the ledger claims
301
+ // success. Every other shape is already not-shipped; what is on main cannot
302
+ // change that.
303
+ let artifact = null;
304
+ if (task && String(task.status) === 'shipped') {
305
+ artifact = await probe(taskId, { cwd: deps.cwd || REPO_ROOT });
306
+ }
307
+
308
+ const v = classifyLedger({ task, artifact, nowMs: deps.nowMs || Date.now() });
309
+ const out = { ...v, task_id: String(taskId), artifact, released: false, blocker: null };
310
+ if (readError) out.read_error = readError;
311
+ if (!act) return out;
312
+
313
+ if (v.releaseClaim) {
314
+ // A caller that forgot to hand over a release function must not read as "no
315
+ // release was needed". Silently skipping the one write that unlocks a task is
316
+ // precisely the class of failure this whole step exists to catch.
317
+ if (!release) out.release_error = 'no release function was given to verifyShip — the claim is still held';
318
+ else {
319
+ const r = await release(taskId, `autobongos: ${v.shape} — ${String(v.reason || '').slice(0, 180)}`);
320
+ out.released = !!(r && r.ok);
321
+ if (!out.released) out.release_error = (r && (r.stderr || r.stdout) ? String(r.stderr || r.stdout).slice(-300) : 'release failed');
322
+ }
323
+ }
324
+
325
+ if (v.fileBlocker) {
326
+ out.blocker = await file(api, blockerFor(v, { taskId, taskTitle: taskTitle || (task && task.title), worker }), {
327
+ taskId,
328
+ // Linking is only worth attempting where it can park the task. A shipped or
329
+ // mid-ship task is not claimable anyway, so a link there writes a row that
330
+ // changes nothing and muddies the blocker board.
331
+ link: v.linkBlocker && (out.released || String(v.status) === 'ready'),
332
+ });
333
+ }
334
+
335
+ return out;
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // CLI
340
+ // ---------------------------------------------------------------------------
341
+
342
+ async function main(argv) {
343
+ const taskId = argv.find((a) => /^\d+$/.test(a));
344
+ if (!taskId) {
345
+ console.error('usage: node scripts/gds/autobongos-verify.js <task-id> [--act] [--json]');
346
+ return 2;
347
+ }
348
+ const act = argv.includes('--act');
349
+ const release = async (id, reason) => new Promise((resolve) => {
350
+ execFile(process.execPath, [path.join(REPO_ROOT, 'scripts', 'gds', 'release.js'), String(id), '--reason', reason],
351
+ { cwd: REPO_ROOT }, (err, stdout, stderr) => resolve({ ok: !err, stdout: stdout || '', stderr: stderr || '' }));
352
+ });
353
+
354
+ const out = await verifyShip({ taskId }, { act, release });
355
+ if (argv.includes('--json')) { console.log(JSON.stringify(out, null, 2)); }
356
+ else {
357
+ console.log('');
358
+ console.log(` task ${taskId} ledger says: ${out.status || 'unreadable'} → ${out.shape}`);
359
+ console.log(` ${out.reason}`);
360
+ if (out.remedy) console.log(` next: ${out.remedy}`);
361
+ if (out.artifact && out.artifact.checked === false) console.log(` (artifact not probed: ${out.artifact.reason})`);
362
+ if (out.released) console.log(' claim released');
363
+ if (out.blocker) console.log(` blocker: ${out.blocker.filed ? `#${out.blocker.blockerId}${out.blocker.parked ? ' (task parked at blocked)' : out.blocker.linked ? ' (linked)' : ' (not linked)'}` : `not filed — ${out.blocker.reason}`}`);
364
+ if (!act && (out.releaseClaim || out.fileBlocker)) console.log(' (read-only — pass --act to release and file)');
365
+ console.log('');
366
+ }
367
+ return out.shape === 'undetermined' ? 2 : (out.verified ? 0 : 1);
368
+ }
369
+
370
+ if (require.main === module) {
371
+ main(process.argv.slice(2)).then((c) => process.exit(c)).catch((e) => {
372
+ console.error(`autobongos-verify: ${e && e.message ? e.message : e}`);
373
+ process.exit(2);
374
+ });
375
+ }
376
+
377
+ module.exports = { classifyLedger, blockerFor, probeArtifact, fileBlocker, verifyShip };
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // scripts/gds/autonomy-gauge-statusline.js — the WRITER half of the Autobongos
5
+ // usage gauge (task 1003901; ADR 0307 §2).
6
+ //
7
+ // Wire this as Claude Code's `statusLine.command`. On every render Claude Code
8
+ // pipes the status JSON in on stdin; for a subscriber, after the session's first
9
+ // API response, that JSON carries the only structured read of remaining capacity
10
+ // there is:
11
+ //
12
+ // rate_limits.five_hour.{ used_percentage, resets_at }
13
+ // rate_limits.seven_day.{ used_percentage, resets_at }
14
+ //
15
+ // It is absent before that first response, which is exactly why this has to
16
+ // persist it: the supervisor needs the number BETWEEN sessions, when nothing is
17
+ // running to ask. So this script's real output is the file; the line it prints
18
+ // is a courtesy.
19
+ //
20
+ // TWO HARD RULES, both because this runs inside somebody's prompt:
21
+ //
22
+ // 1. IT NEVER THROWS AND NEVER EXITS NON-ZERO. A statusline that crashes breaks
23
+ // the prompt of every session on the machine, including the owner's. Every
24
+ // failure path prints something harmless and exits 0.
25
+ // 2. IT NEVER DESTROYS A READING IT DID NOT REPLACE. This is per-WINDOW, not
26
+ // per-file (grader finding, task 1003901). A render with no rate_limits at
27
+ // all leaves the file alone — but so does a render that reports only
28
+ // five_hour: the previously-known seven_day is CARRIED FORWARD rather than
29
+ // dropped, because the seven-day number is what the weekly pacing depends on
30
+ // and it is the half most likely to be transiently absent. Rebuilding the
31
+ // object from scratch each render silently lost it.
32
+ //
33
+ // Carrying a window forward means it must age on its own, so each window
34
+ // records its OWN `observed_at` and the reader applies staleness per window.
35
+ // Refreshing a single file-level written_at while keeping old data is exactly
36
+ // how "unknown" would become "headroom" — the one mistake the reader exists
37
+ // to prevent.
38
+
39
+ const fs = require('node:fs');
40
+ const path = require('node:path');
41
+
42
+ function main(raw) {
43
+ let input = null;
44
+ try { input = JSON.parse(raw); } catch (_) { return 'bongos: (no status json)'; }
45
+ if (!input || typeof input !== 'object') return 'bongos: (no status json)';
46
+
47
+ const rl = input.rate_limits;
48
+ const five = rl && rl.five_hour;
49
+ const seven = rl && rl.seven_day;
50
+
51
+ const pct = (w) => (w && Number.isFinite(Number(w.used_percentage)) ? Number(w.used_percentage) : null);
52
+ const fivePct = pct(five);
53
+ const sevenPct = pct(seven);
54
+
55
+ // Rule 2: nothing to record → say so, touch nothing.
56
+ if (fivePct === null && sevenPct === null) return 'bongos: limits not reported yet';
57
+
58
+ const now = Math.floor(Date.now() / 1000);
59
+
60
+ // Same redirect the reader honours (autonomy-gauge.js). Without it the two
61
+ // halves disagree about where the file lives, and a test that thinks it
62
+ // redirected the writer silently writes into the real config dir instead —
63
+ // which is how that was found.
64
+ let target;
65
+ try {
66
+ target = process.env.AUTOBONGOS_GAUGE_FILE
67
+ || require('../../src/instance-config.js').configPath('autobongos-gauge.json');
68
+ } catch (_) {
69
+ return `bongos: 5h ${fivePct ?? '–'}% · 7d ${sevenPct ?? '–'}% (not persisted)`;
70
+ }
71
+
72
+ // Rule 2: start from what is already on disk, so a window this render did not
73
+ // report survives instead of being overwritten with nothing.
74
+ let prior = {};
75
+ try { const p = JSON.parse(fs.readFileSync(target, 'utf8')); if (p && typeof p === 'object') prior = p; } catch (_) {}
76
+
77
+ const gauge = { written_at: now, source: 'statusline' };
78
+ const carry = (key) => {
79
+ const old = prior[key];
80
+ if (old && Number.isFinite(Number(old.used_percentage)) && Number.isFinite(Number(old.resets_at))) {
81
+ // Keep its ORIGINAL observed_at so it ages out on its own; a carried
82
+ // window that inherited `now` would read as fresh evidence forever.
83
+ gauge[key] = { ...old, observed_at: Number.isFinite(Number(old.observed_at)) ? Number(old.observed_at) : Number(prior.written_at) || now };
84
+ }
85
+ };
86
+ const put = (key, w, p) => {
87
+ const resets = w && Number(w.resets_at);
88
+ if (p !== null && Number.isFinite(resets)) gauge[key] = { used_percentage: p, resets_at: resets, observed_at: now };
89
+ else carry(key);
90
+ };
91
+ put('five_hour', five, fivePct);
92
+ put('seven_day', seven, sevenPct);
93
+
94
+ try {
95
+ fs.mkdirSync(path.dirname(target), { recursive: true });
96
+ // Atomic: the supervisor may read this at any instant, and a half-written
97
+ // file parses as "no gauge file", which is a hold. Correct, but needlessly
98
+ // pessimistic when the real answer was one rename away.
99
+ const tmp = `${target}.tmp-${process.pid}-${require('node:crypto').randomBytes(6).toString('hex')}`;
100
+ fs.writeFileSync(tmp, `${JSON.stringify(gauge, null, 2)}\n`);
101
+ fs.renameSync(tmp, target);
102
+ } catch (_) {
103
+ return `bongos: 5h ${fivePct ?? '–'}% · 7d ${sevenPct ?? '–'}% (not persisted)`;
104
+ }
105
+
106
+ const bits = [];
107
+ if (fivePct !== null) bits.push(`5h ${Math.round(fivePct)}%`);
108
+ if (sevenPct !== null) bits.push(`7d ${Math.round(sevenPct)}%`);
109
+ return `bongos: ${bits.join(' · ')}`;
110
+ }
111
+
112
+ let buf = '';
113
+ process.stdin.setEncoding('utf8');
114
+ process.stdin.on('data', (d) => { buf += d; });
115
+ process.stdin.on('end', () => {
116
+ let line;
117
+ try { line = main(buf); } catch (_) { line = 'bongos: gauge error'; }
118
+ process.stdout.write(`${line}\n`);
119
+ process.exit(0); // rule 1, unconditionally
120
+ });
121
+ // A statusline invoked with no stdin must still return promptly.
122
+ setTimeout(() => { try { process.stdout.write('bongos: (no input)\n'); } catch (_) {} process.exit(0); }, 2000).unref();
123
+
124
+ module.exports = { main };
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // scripts/gds/autonomy-gauge.js — the READER half of the Autobongos usage gauge
5
+ // (task 1003901; ADR 0307 §2). The supervisor calls this between tasks to ask
6
+ // one question: may I start another task right now, and if not, until when?
7
+ //
8
+ // node scripts/gds/autonomy-gauge.js # human-readable
9
+ // node scripts/gds/autonomy-gauge.js --json # machine-readable
10
+ // node scripts/gds/autonomy-gauge.js --sleep # block until capacity, then exit 0
11
+ //
12
+ // Exit codes: 0 = go 1 = hold (sleepUntil printed) 2 = unknown gauge
13
+ //
14
+ // The DECISION is not here — it is the pure `decideCapacity` in
15
+ // modules/autonomy/gauge.js, which is unit-tested with no clock and no disk.
16
+ // This file is the I/O half only: find the file, read it, print the verdict.
17
+ // Same split autonomy-gate.js draws between `decide` and `gatherSignals`.
18
+
19
+ const fs = require('node:fs');
20
+ const { decideCapacity } = require('../../modules/autonomy/gauge.js');
21
+
22
+ function gaugePath() {
23
+ if (process.env.AUTOBONGOS_GAUGE_FILE) return process.env.AUTOBONGOS_GAUGE_FILE;
24
+ try { return require('../../src/instance-config.js').configPath('autobongos-gauge.json'); }
25
+ catch (_) { return null; }
26
+ }
27
+
28
+ function loadGauge(p) {
29
+ if (!p) return null;
30
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return null; }
31
+ }
32
+
33
+ function readConfig() {
34
+ // Operator overrides ride on env so the runner can be retuned without a deploy.
35
+ const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : undefined);
36
+ const c = {};
37
+ if (process.env.AUTOBONGOS_TIMEZONE) c.timezone = process.env.AUTOBONGOS_TIMEZONE;
38
+ if (num(process.env.AUTOBONGOS_STALE_AFTER_S) !== undefined) c.staleAfterS = num(process.env.AUTOBONGOS_STALE_AFTER_S);
39
+ if (num(process.env.AUTOBONGOS_WEEKLY_SLACK) !== undefined) c.weeklySlack = num(process.env.AUTOBONGOS_WEEKLY_SLACK);
40
+ const rw = num(process.env.AUTOBONGOS_RESERVE_WORKING);
41
+ const ro = num(process.env.AUTOBONGOS_RESERVE_OVERNIGHT);
42
+ if (rw !== undefined || ro !== undefined) c.reserve = { ...(rw !== undefined && { working: rw }), ...(ro !== undefined && { overnight: ro }) };
43
+ const hs = num(process.env.AUTOBONGOS_HOURS_START);
44
+ const he = num(process.env.AUTOBONGOS_HOURS_END);
45
+ if (hs !== undefined || he !== undefined) c.ownerHours = { ...(hs !== undefined && { start: hs }), ...(he !== undefined && { end: he }) };
46
+ return c;
47
+ }
48
+
49
+ function evaluate(nowEpochS = Math.floor(Date.now() / 1000)) {
50
+ const p = gaugePath();
51
+ return { path: p, decision: decideCapacity({ gauge: loadGauge(p), now: nowEpochS, config: readConfig() }) };
52
+ }
53
+
54
+ async function main(argv) {
55
+ const json = argv.includes('--json');
56
+ const sleep = argv.includes('--sleep');
57
+
58
+ for (;;) {
59
+ const { path: p, decision: d } = evaluate();
60
+ if (json) console.log(JSON.stringify({ ...d, gauge_path: p }, null, 2));
61
+ else {
62
+ const when = d.sleepUntil ? ` → wake ${new Date(d.sleepUntil * 1000).toISOString()}` : '';
63
+ console.log(`${d.go ? 'GO' : d.unknown ? 'UNKNOWN' : 'HOLD'}: ${d.reason}${when}`);
64
+ }
65
+ if (d.go) return 0;
66
+ if (!sleep) return d.unknown ? 2 : 1;
67
+
68
+ // --sleep: wait exactly as long as the decision says, then re-read. An
69
+ // UNKNOWN gauge has no reset to aim at, so poll gently rather than spin.
70
+ const now = Math.floor(Date.now() / 1000);
71
+ const waitS = d.sleepUntil ? Math.max(1, d.sleepUntil - now) : 60;
72
+ if (!json) console.log(` sleeping ${waitS}s…`);
73
+ await new Promise((r) => setTimeout(r, waitS * 1000));
74
+ }
75
+ }
76
+
77
+ if (require.main === module) {
78
+ main(process.argv.slice(2)).then((c) => process.exit(c)).catch((e) => {
79
+ console.error(`autonomy-gauge: ${e && e.message ? e.message : e}`);
80
+ process.exit(2);
81
+ });
82
+ }
83
+
84
+ module.exports = { evaluate, gaugePath, loadGauge, readConfig };