@bongos/core 1.19.689 → 1.19.691

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.689",
6
- "core_contract": "1.19.689",
7
- "source_commit": "636af32c2114ecf4e8b2fe03d2146106f8123776",
5
+ "core_version": "1.19.691",
6
+ "core_contract": "1.19.691",
7
+ "source_commit": "d6f9d26e0e431f817085c4f3e93cba48e534c2e7",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-12T23:51:28.908Z",
9
+ "built_at": "2026-09-13T00:51:33.922Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 477,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2125,
14
+ "functional_verbatim": 2126,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2626,
20
- "tree_sha256": "480885e76086019b4dc2565be23ed784e8181134410c41afc8ef25da3374d72e",
19
+ "file_count": 2627,
20
+ "tree_sha256": "c8d122583fff727fc928395a6e21c2ec230528b5163e89020d1b5506c39376be",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "837ec3c7ddeb36761a1b67b765e28fa117b85f1e8e58795798024e1cd432d9c1"
2805
+ "sha256": "d15899aa7ccc7cf1c4746843d0bc37487fcfaf1dabcbb6c9cac6ea8ccac49865"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -7782,12 +7782,12 @@
7782
7782
  {
7783
7783
  "path": "package-lock.json",
7784
7784
  "mode": "0000644",
7785
- "sha256": "973f657a0c063434dec755b53ee29e1b8348c9f1f3f7e8762d937d79aead985e"
7785
+ "sha256": "8ef95c8bad02f83fa9d831818ab483bc1fbf18fbaf3392f5d03ab82dea4a3a7b"
7786
7786
  },
7787
7787
  {
7788
7788
  "path": "package.json",
7789
7789
  "mode": "0000644",
7790
- "sha256": "defc3109442ff69bb93889c12a876641ec174585697a93f12722e31a548c4848"
7790
+ "sha256": "88f4f66604d980452d57d66d581170a45fd7e07464cf3699e390b34773105504"
7791
7791
  },
7792
7792
  {
7793
7793
  "path": "public-docs/index.html",
@@ -8077,7 +8077,7 @@
8077
8077
  {
8078
8078
  "path": "scripts/gds/conductor-dispatch.js",
8079
8079
  "mode": "0000644",
8080
- "sha256": "6f25380a8b715dad1ce9dd17c0f0a2d7fa7573db6e2434029f0ba1d3c1cdc730"
8080
+ "sha256": "b0e5767d0a30e57130169b41f1dd0add6cfc54237bca6d731219d5aff5d38168"
8081
8081
  },
8082
8082
  {
8083
8083
  "path": "scripts/gds/context-pack.js",
@@ -8922,7 +8922,7 @@
8922
8922
  {
8923
8923
  "path": "scripts/gds/ship-flow.js",
8924
8924
  "mode": "0000644",
8925
- "sha256": "8bf28a3d4d15e83a1e94416ec2519d39bc2a3faa71303a4f2d22d429b6afaa95"
8925
+ "sha256": "7f6c46f56fcd54c01b5dd0bd6fecd712962c87ed693a44bea1746cf4c6aacebb"
8926
8926
  },
8927
8927
  {
8928
8928
  "path": "scripts/gds/ship-git.js",
@@ -8977,7 +8977,7 @@
8977
8977
  {
8978
8978
  "path": "scripts/gds/ship-resume.js",
8979
8979
  "mode": "0000644",
8980
- "sha256": "4d56098b0b7df003e7efece23b8d1bca55dd272774117cd75074a1c7609939ec"
8980
+ "sha256": "20b91c52c75fae6355165941d1467081424614d46063f87137870ef291d442b9"
8981
8981
  },
8982
8982
  {
8983
8983
  "path": "scripts/gds/ship-session-upload.js",
@@ -9002,7 +9002,7 @@
9002
9002
  {
9003
9003
  "path": "scripts/gds/ship.js",
9004
9004
  "mode": "0000644",
9005
- "sha256": "6fd0329b8ad7d45fbb93deda02142c2b766c9e01b669d2d94eeafcb1caa225b0"
9005
+ "sha256": "a64455fdf9771e4ccb04b49d42b9bb0cb0fe45bc6cb36d4e5f3abe0c31fe99ff"
9006
9006
  },
9007
9007
  {
9008
9008
  "path": "scripts/gds/skill-lint.js",
@@ -9289,6 +9289,11 @@
9289
9289
  "mode": "0000644",
9290
9290
  "sha256": "7290d5d15b4c8283ebb17e28b17739b9dc5de4da3e916e9491248ba8acc24619"
9291
9291
  },
9292
+ {
9293
+ "path": "src/bongos/claim-attribution.js",
9294
+ "mode": "0000644",
9295
+ "sha256": "1c3c7bc5d3c72de526334866a966a2fc7c5c8a486e19d65fefd50431f982d77b"
9296
+ },
9292
9297
  {
9293
9298
  "path": "src/bongos/db-kernel.js",
9294
9299
  "mode": "0000644",
@@ -9547,7 +9552,7 @@
9547
9552
  {
9548
9553
  "path": "src/module-api.js",
9549
9554
  "mode": "0000644",
9550
- "sha256": "95a7e92acae45c56f040be85ecb6a2b65a6c240290af7f383b18672da4764ec8"
9555
+ "sha256": "97d34718e5b331dc33b78b070ed6760c8113f383faf0a90cd8076f1ac5047fbb"
9551
9556
  },
9552
9557
  {
9553
9558
  "path": "src/module-loader/catalog.js",
@@ -10207,7 +10212,7 @@
10207
10212
  {
10208
10213
  "path": "tests/conductor.mjs",
10209
10214
  "mode": "0000644",
10210
- "sha256": "e2c338b765a7fccedd1730bb56e9338e022c41a40ed60406a94c1bb5b7b1599a"
10215
+ "sha256": "e0d75747731cf45280dca2d36260a8b7799923dc1a2ffeb775cd37a0eb1f837c"
10211
10216
  },
10212
10217
  {
10213
10218
  "path": "tests/confirm_attribution.mjs",
@@ -12672,7 +12677,7 @@
12672
12677
  {
12673
12678
  "path": "tests/ship_resume.mjs",
12674
12679
  "mode": "0000644",
12675
- "sha256": "2b2ef91bda46ab9a764709ab30b97de6e184a3ab2b86c4b7cb8f1c168fc0a5b7"
12680
+ "sha256": "e5305fa140de31d7e646e328b4a3956f845f62b335bf14285a91cc38a20f8e0f"
12676
12681
  },
12677
12682
  {
12678
12683
  "path": "tests/ship_smoke_gate.mjs",
@@ -1837,5 +1837,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1837
1837
  landed since 1.19.687 with no explicit bump. run 34725902871. (task 1002620)
1838
1838
  1.19.689 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1839
1839
  landed since 1.19.688 with no explicit bump. run 34726470555. (task 1002620)
1840
+ 1.19.690 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1841
+ landed since 1.19.689 with no explicit bump. run 34727464797. (task 1002620)
1842
+ 1.19.691 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1843
+ landed since 1.19.690 with no explicit bump. run 34729018766. (task 1002620)
1840
1844
  ---------------------------------------------------------------------------
1841
1845
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.689",
3
+ "version": "1.19.691",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.689",
9
+ "version": "1.19.691",
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.689",
3
+ "version": "1.19.691",
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",
@@ -65,6 +65,9 @@ const { isSensitive } = require('../../src/bongos/sensitive-surfaces');
65
65
  // re-hand-rolled this coverage logic — the exact drift this PR fixed for the
66
66
  // sensitive-surface set. Route through the one matcher instead.)
67
67
  const { matchOne } = require('../../src/bongos/path-match');
68
+ // The worktree→claim rule, shared with .claude/hooks/conductor.js — the third
69
+ // member of this file's "one definition, both conductors" set (task 1003837).
70
+ const { claimForWorktree } = require('../../src/bongos/claim-attribution');
68
71
 
69
72
  const GRADER_DIFF_CHAR_CAP = 250_000;
70
73
  const GRADER_TIMEOUT_MS = 240_000;
@@ -209,10 +212,11 @@ function diffStatsSince(base, untracked) {
209
212
  //
210
213
  // Claims from before the column existed carry a null worktree_name and are
211
214
  // simply not matched here — the heuristic still covers them.
212
- function claimForWorktree(claims, worktreeName) {
213
- if (!Array.isArray(claims) || !worktreeName) return null;
214
- return claims.find((c) => c && c.worktree_name && c.worktree_name === worktreeName) || null;
215
- }
215
+ //
216
+ // The rule itself moved to src/bongos/claim-attribution.js in task 1003837, so
217
+ // the conductor HOOK can answer the same question the same way without pulling
218
+ // this module (and cli-lib, and the grader) into a per-prompt hook. Re-exported
219
+ // below, so every existing importer and test is unchanged.
216
220
 
217
221
  // Pick the active claim whose touches[] best overlap the changed files — the
218
222
  // claim the current diff most plausibly belongs to. With multiple (possibly
@@ -296,6 +300,13 @@ async function resolveTaskContext(changedFiles) {
296
300
  // one answered is reported to the reader rather than hidden.
297
301
  const attribution = attributeClaim(claims, changedFiles, path.basename(process.cwd()));
298
302
  const c = attribution.claim;
303
+ // attributeClaim's documented { claim: null, signal: 'none' } return —
304
+ // not dead defensive code, and not a crash path: the throw is caught by
305
+ // this function's own catch below, which is where the no-claim context
306
+ // is synthesized. Unreachable only because the caller happens to check
307
+ // claims.length first; it states the seam's contract rather than relying
308
+ // on that. (Task 1003834's grade panel read it as a dead branch and
309
+ // filed it as a nit; kept, with this sentence, per task 1003837.)
299
310
  if (!c) throw new Error('no claim attributed');
300
311
  // Pull the full description for richer context (best-effort). Coerce
301
312
  // task_id to an integer before interpolating it into the URL path —
@@ -27,7 +27,7 @@ const { apiErrorLine, normalizeApiError, isUnknownFieldRejection } = require('./
27
27
  const { pushVia } = require('./ship-deploy-target.js');
28
28
 
29
29
  const { diffstatFor, finishShip } = require('./ship-finish.js');
30
- const { evaluateRegradeEligibility, evaluateResumeAction, resumeMerge } = require('./ship-resume.js');
30
+ const { evaluateRegradeEligibility, evaluateResumeAction, resumeMerge, shouldConfirmWithoutGrade } = require('./ship-resume.js');
31
31
  const { noteServerReward } = require('./ship-state.js');
32
32
 
33
33
  // ---------- Main flow ----------
@@ -850,8 +850,66 @@ async function regradeMain() {
850
850
  const graderBaseline = computeGraderBaseline(claimLike);
851
851
  const committedFiles = computeChangedFiles(graderBaseline);
852
852
  if (committedFiles.length === 0) {
853
+ // task 1003147: a DELIBERATELY artifact-free task used to dead-end here.
854
+ // shipMain routes every 'completed' task to this re-grade BEFORE reading any
855
+ // flag (evaluateResumeAction takes none), so --allow-empty / --skip-grade never
856
+ // reached the manual-confirm path that a no-artifact task actually needs — and
857
+ // that path is unreachable from 'completed' any other way. Hit for real on task
858
+ // 1003146: a kind=verify task landed at 'completed' with no grade row, and
859
+ // --allow-empty, --skip-grade and --verified all bailed on this same line.
860
+ //
861
+ // BOTH conditions are required, and the diff emptiness is MEASURED, not taken
862
+ // on the builder's word. A flag alone must not reach confirm: a task with a
863
+ // real diff whose grade failed would then bypass the grader by passing
864
+ // --allow-empty, which is a quality hole, not a fix. Here the grader has
865
+ // nothing to read, so there is no verdict to dodge. Confirming is additionally
866
+ // permission-gated server-side on task.confirm.grade_bypass, so this widens
867
+ // no authority — it only stops the ONE shape that had no exit at all.
868
+ if (shouldConfirmWithoutGrade({ committedFileCount: committedFiles.length, args })) {
869
+ console.log('');
870
+ console.log(`Nothing to grade (the committed diff vs ${graderBaseline || 'baseline'} is empty) and this ship`);
871
+ console.log('declares itself artifact-free — manual-confirming via POST /tasks/:id/confirm.');
872
+ // A kind='verify' task cannot reach here without --verified: the gate above
873
+ // exits first, so the server's verify_attestation_required can't fire below.
874
+ const confirm = await (await cliClient()).tasks.postTasksIdConfirm({ id: taskId, body: { verified } });
875
+ if (!confirm.ok) {
876
+ const errObj = confirm.data && confirm.data.error;
877
+ const errCode = typeof errObj === 'string' ? errObj : (errObj && errObj.code);
878
+ if (confirm.status === 403 && errCode === 'permission_forbidden') {
879
+ const details = (errObj && typeof errObj === 'object' && errObj.details) || {};
880
+ const floor = details.floors && details.floors['task.confirm.grade_bypass'];
881
+ console.error('');
882
+ console.error(`✖ Confirming without a passing grade is permission-gated: task.confirm.grade_bypass${floor ? ` (floor: ${floor})` : ''}.`);
883
+ console.error(' This is an authorization wall, not a verdict on your work.');
884
+ console.error(` Ask a builder whose role holds it to POST /api/gds/tasks/${taskId}/confirm.`);
885
+ process.exit(1);
886
+ }
887
+ console.error(`manual-confirm failed: ${apiErrorLine(confirm.data, confirm.status)}`);
888
+ process.exit(1);
889
+ }
890
+ const confirmedStatus = confirm.data.taskStatus;
891
+ printCreditsAndAchievements({
892
+ postGradeStatus: confirmedStatus,
893
+ credits: confirm.data.creditsAwarded ?? 0,
894
+ creditsRaw: null,
895
+ creditsMultiplier: null,
896
+ ideaBonusInfo: confirm.data.ideaBonus ?? null,
897
+ justUnlocked: confirm.data.just_unlocked || [],
898
+ });
899
+ // The same confirmed→shipped tail a normal ship runs, so a no-artifact resume
900
+ // lands identically instead of stopping one step short.
901
+ await finishShip({
902
+ taskId, summary, advanceToMerge: confirmedStatus === 'confirmed', skipMerge, dbOnly: true,
903
+ postGradeStatus: confirmedStatus,
904
+ doneLine: 'Done — confirmed with no artifact (grader had nothing to read).',
905
+ card: { title: task.title, valueSummary: summary, notes: '' },
906
+ });
907
+ return;
908
+ }
853
909
  console.error(`Nothing to grade: the committed diff vs ${graderBaseline || 'baseline'} is empty.`);
854
910
  console.error('The grader reads committed history, not the working tree — commit your fixes on this branch, then re-run --regrade.');
911
+ console.error('If this task is DELIBERATELY artifact-free (a DB-only close, an ops verification), say so:');
912
+ console.error(` node scripts/gds/ship.js ${taskId} --allow-empty`);
855
913
  process.exit(1);
856
914
  }
857
915
 
@@ -106,6 +106,27 @@ function evaluateResumeAction({ task, lastClaim, builder }) {
106
106
  return { action: 'error', reason: `Task #${task.id} is '${status}'. Nothing to resume.` };
107
107
  }
108
108
 
109
+ // task 1003147: may a re-grade that has NOTHING to grade manual-confirm instead of
110
+ // dead-ending? Pure + exported so the truth table is unit-tested with no git or
111
+ // network, the same contract evaluateResumeAction above carries.
112
+ //
113
+ // Both halves are required, and they are different KINDS of evidence. The empty
114
+ // diff is MEASURED (the caller passes what `git diff --name-only` actually
115
+ // returned); the flag is the builder's DECLARATION that artifact-free is intended.
116
+ // A declaration alone must never be enough — a task with a real diff whose grade
117
+ // failed would otherwise bypass the grader by passing --allow-empty, turning a
118
+ // deadlock fix into a quality hole. When the diff is genuinely empty the grader has
119
+ // nothing to read, so there is no verdict being dodged.
120
+ //
121
+ // A bare re-run (neither flag) keeps dead-ending, deliberately: the overwhelmingly
122
+ // common cause of an empty diff is uncommitted work, and silently confirming that
123
+ // would ship nothing while reporting success.
124
+ function shouldConfirmWithoutGrade({ committedFileCount, args = [] }) {
125
+ const emptyDiff = Number(committedFileCount) === 0;
126
+ const declaredNoArtifact = args.includes('--allow-empty') || args.includes('--skip-grade');
127
+ return emptyDiff && declaredNoArtifact;
128
+ }
129
+
109
130
  // task 941: resume a `confirmed`-but-unshipped task — the merge/deploy leg only
110
131
  // (it already passed the grade + earned credits; this is the leg that stalls when
111
132
  // a box can't reach the server publish, a poll window expires, etc.). Mirrors the
@@ -148,4 +169,5 @@ module.exports = {
148
169
  evaluateRegradeEligibility,
149
170
  evaluateResumeAction,
150
171
  resumeMerge,
172
+ shouldConfirmWithoutGrade,
151
173
  };
@@ -152,7 +152,7 @@ const {
152
152
  formatGradeOutcome,
153
153
  gradeAndRecord,
154
154
  } = require('./ship-grade-steps.js');
155
- const { evaluateRegradeEligibility, evaluateResumeAction } = require('./ship-resume.js');
155
+ const { evaluateRegradeEligibility, evaluateResumeAction, shouldConfirmWithoutGrade } = require('./ship-resume.js');
156
156
  const { regradeMain, shipMain } = require('./ship-flow.js');
157
157
  const { fatalRecoveryLines, smokeGateMessages } = require('./ship-honesty.js');
158
158
  const { chooseBroadcastTargets } = require('../../modules/discord/ship-broadcast');
@@ -213,6 +213,10 @@ module.exports = {
213
213
  // re-run — exported so the truth table (completed→grade, confirmed→merge,
214
214
  // ownership, terminal states) is unit-tested with no git/network.
215
215
  evaluateResumeAction,
216
+ // task 1003147: whether a re-grade with NOTHING to grade may manual-confirm
217
+ // instead of dead-ending — exported so the truth table (empty diff + declared
218
+ // no-artifact → confirm; a real diff, or no declaration → never) is unit-tested.
219
+ shouldConfirmWithoutGrade,
216
220
  // #747: pre-ship auto-linkify of the ship's own changed docs. The path
217
221
  // predicate is pure + exported so the regression test can assert which
218
222
  // changed files fall in the linkifier's domain without git/fs/API.
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+ // src/bongos/claim-attribution.js — which of a builder's open claims does THIS
3
+ // working tree belong to? (task 1003837)
4
+ //
5
+ // WHY. Two things answer that question — `scripts/gds/conductor-dispatch.js`,
6
+ // which briefs a reviewer on the claim a diff belongs to, and
7
+ // `.claude/hooks/conductor.js`, which measures "changed files outside your
8
+ // claim's declared touches[]" on every prompt. They must not answer it
9
+ // differently, and the hook cannot simply require the dispatcher: the dispatcher
10
+ // pulls in cli-lib, the grader and the autonomy gate at module scope, and the
11
+ // hook's cost posture is explicit that it runs on EVERY prompt and must never
12
+ // crash one. So the rule lives here, in a dependency-free core module both can
13
+ // resolve — the same shape `sensitive-surfaces.js` and `path-match.js` already
14
+ // take for this exact pair, after the Phase 4 dogfood flagged a hand-copied
15
+ // pattern set as a drift risk.
16
+ //
17
+ // The worktree is a FACT, not a guess. `claim.js` records the worktree it was
18
+ // run in, and the project default is one claim per worktree (task 1642), so a
19
+ // name match attributes the tree with certainty. That is worth keeping separate
20
+ // from the dispatcher's touches[]-overlap heuristic, which is a guess and is
21
+ // labelled as one.
22
+
23
+ const path = require('node:path');
24
+
25
+ // The claim bound to `worktreeName`, or null. Exact match: the worktree name is
26
+ // a recorded identifier, not a pattern, so a near-miss is a different tree.
27
+ function claimForWorktree(claims, worktreeName) {
28
+ if (!Array.isArray(claims) || !worktreeName) return null;
29
+ return claims.find((c) => c && c.worktree_name && c.worktree_name === worktreeName) || null;
30
+ }
31
+
32
+ // The worktree name for a directory — its basename, which is what `claim.js`
33
+ // stores. Returns null for an empty/garbage input so callers get "unknown"
34
+ // rather than a name that cannot match anything.
35
+ //
36
+ // Backslashes are normalized BEFORE the basename, then `path.posix` does the
37
+ // split, so the answer does not depend on which platform is asking. Plain
38
+ // `path.basename` is the platform's: on POSIX it splits only on `/`, so a
39
+ // Windows path arrives back whole — `C:\…\task-1003837` would BE the worktree
40
+ // name, matching nothing. This repo keeps finding that same defect (task
41
+ // 1003821 was two CI-invisible instances of it), and `scripts/gds/worktree.js`
42
+ // already normalizes backslashes first for exactly this reason.
43
+ function worktreeNameFor(dir) {
44
+ const d = String(dir || '').trim().replace(/\\/g, '/').replace(/\/+$/, '');
45
+ if (!d) return null;
46
+ return path.posix.basename(d) || null;
47
+ }
48
+
49
+ module.exports = { claimForWorktree, worktreeNameFor };
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.689'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.691'; // 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');
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { strict as assert } from 'node:assert';
12
12
  import { createRequire } from 'node:module';
13
+ import { readFileSync } from 'node:fs';
13
14
  import { requireHostModule } from './helpers.mjs';
14
15
 
15
16
  const require = createRequire(import.meta.url);
@@ -129,11 +130,15 @@ test('wandering: lowest priority — emitted after methodology/status advisories
129
130
  assert.equal(a[a.length - 1].id, 'wandering', 'wandering is emitted last');
130
131
  });
131
132
 
133
+ // The nudge says "your claim's", singular, so it is measured against the claim
134
+ // bound to THIS worktree (task 1003837). These three carry a worktree_name and a
135
+ // matching `worktree` for that reason; the four below pin what changed.
132
136
  test('outside-touches fires for files outside the claim touches[]', () => {
133
137
  const a = conductor.evaluateTriggers({
134
138
  changedFiles: ['src/bongos/grader.js', 'server.js'],
135
139
  prompt: 'edits',
136
- activeClaims: [{ task_id: 1, title: 't', touches: ['src/bongos/grader.js'] }],
140
+ activeClaims: [{ task_id: 1, title: 't', touches: ['src/bongos/grader.js'], worktree_name: 'task-1' }],
141
+ worktree: 'task-1',
137
142
  });
138
143
  const outside = a.find((x) => x.id === 'outside-touches');
139
144
  assert.ok(outside, 'expected outside-touches advisory');
@@ -144,7 +149,8 @@ test('outside-touches does NOT fire when all changes are covered', () => {
144
149
  const a = conductor.evaluateTriggers({
145
150
  changedFiles: ['src/bongos/grader.js', 'migrations/053_x.sql'],
146
151
  prompt: 'edits',
147
- activeClaims: [{ task_id: 1, title: 't', touches: ['src/bongos/grader.js', 'migrations/'] }],
152
+ activeClaims: [{ task_id: 1, title: 't', touches: ['src/bongos/grader.js', 'migrations/'], worktree_name: 'task-1' }],
153
+ worktree: 'task-1',
148
154
  });
149
155
  assert.ok(!ids(a).includes('outside-touches'));
150
156
  });
@@ -153,7 +159,71 @@ test('outside-touches does NOT fire when the claim has empty touches[] (fresh ta
153
159
  const a = conductor.evaluateTriggers({
154
160
  changedFiles: ['src/bongos/grader.js'],
155
161
  prompt: 'edits',
156
- activeClaims: [{ task_id: 1, title: 't', touches: [] }],
162
+ activeClaims: [{ task_id: 1, title: 't', touches: [], worktree_name: 'task-1' }],
163
+ worktree: 'task-1',
164
+ });
165
+ assert.ok(!ids(a).includes('outside-touches'));
166
+ });
167
+
168
+ // THE BUG (task 1003837). The pool measured the line partly against somebody
169
+ // else's declared paths. The shape is the one observed on 2026-09-10: the claim
170
+ // you are in declares three exact files, a concurrent claim declares four broad
171
+ // directories, and those directories cover the very files the nudge should have
172
+ // named. Under the pool this fired with ONE file; it must name both.
173
+ test('outside-touches ignores a CONCURRENT claim\'s touches[] — the under-report', () => {
174
+ const a = conductor.evaluateTriggers({
175
+ changedFiles: ['modules/hall-ui/public/studio.js', 'scripts/gds/ship.js', 'docs/adr/0280-x.md'],
176
+ prompt: 'edits',
177
+ activeClaims: [
178
+ { task_id: 1, title: 'mine', touches: ['modules/hall-ui/public/studio.js'], worktree_name: 'task-1' },
179
+ { task_id: 2, title: 'theirs', touches: ['scripts/gds/', 'src/bongos/', 'modules/', 'clients/'], worktree_name: 'task-2' },
180
+ ],
181
+ worktree: 'task-1',
182
+ });
183
+ const outside = a.find((x) => x.id === 'outside-touches');
184
+ assert.ok(outside, 'expected outside-touches advisory');
185
+ // studio.js is covered by MY claim; the other two are not covered by anything
186
+ // of mine. Under the pool, `modules/` and `scripts/gds/` swallowed them and
187
+ // the line read "1 changed file(s)".
188
+ assert.match(outside.text, /^2 changed file\(s\)/, 'both uncovered files must be counted, not one');
189
+ });
190
+
191
+ test('outside-touches counts only what MY claim fails to cover', () => {
192
+ const a = conductor.evaluateTriggers({
193
+ changedFiles: ['modules/hall-ui/public/studio.js', 'scripts/gds/ship.js', 'docs/adr/0280-x.md'],
194
+ prompt: 'edits',
195
+ activeClaims: [
196
+ { task_id: 1, title: 'mine', touches: ['modules/hall-ui/public/studio.js'], worktree_name: 'task-1' },
197
+ { task_id: 2, title: 'theirs', touches: ['scripts/gds/', 'modules/'], worktree_name: 'task-2' },
198
+ ],
199
+ worktree: 'task-1',
200
+ });
201
+ const outside = a.find((x) => x.id === 'outside-touches');
202
+ assert.match(outside.text, /scripts\/gds\/ship\.js/, "the other claim's directory must not cover my diff");
203
+ assert.match(outside.text, /docs\/adr\/0280-x\.md/);
204
+ assert.doesNotMatch(outside.text, /studio\.js/, 'my own declared file is still covered');
205
+ });
206
+
207
+ // The owner's call, recorded in the task: an unbound tree is SILENT rather than
208
+ // falling back to the pool. There is no "your claim" to measure against, and a
209
+ // number derived from another claim's touches[] is the defect, not a degraded
210
+ // version of it. Trigger 1 still covers having no claim at all.
211
+ test('outside-touches is SILENT when no claim is bound to this worktree', () => {
212
+ const claims = [{ task_id: 2, title: 'theirs', touches: ['scripts/gds/'], worktree_name: 'task-2' }];
213
+ for (const worktree of ['cloud-bongos', null, undefined, '']) {
214
+ const a = conductor.evaluateTriggers({
215
+ changedFiles: ['server.js', 'scripts/gds/ship.js'], prompt: 'edits', activeClaims: claims, worktree,
216
+ });
217
+ assert.ok(!ids(a).includes('outside-touches'), `unbound tree (${String(worktree)}) must not borrow another claim's touches[]`);
218
+ }
219
+ });
220
+
221
+ test('outside-touches is SILENT for a claim predating the worktree_name column', () => {
222
+ const a = conductor.evaluateTriggers({
223
+ changedFiles: ['server.js'],
224
+ prompt: 'edits',
225
+ activeClaims: [{ task_id: 1, title: 'old', touches: ['src/bongos/'], worktree_name: null }],
226
+ worktree: 'task-1',
157
227
  });
158
228
  assert.ok(!ids(a).includes('outside-touches'));
159
229
  });
@@ -478,6 +548,36 @@ test('attribution: claimForWorktree ignores claims with no recorded worktree', (
478
548
  assert.equal(dispatch.claimForWorktree(null, 'task-x'), null);
479
549
  });
480
550
 
551
+ // ONE DEFINITION, BOTH CONDUCTORS (task 1003837). The dispatcher and the hook
552
+ // answer "which claim is this tree?" and must not answer it differently — the
553
+ // drift this file's own header warns about, after the Phase 4 dogfood found a
554
+ // hand-copied pattern set. The dispatcher re-exports the shared rule, so identity
555
+ // is the assertion: a second copy would pass a behavioural test and fail this one.
556
+ test('attribution: the hook and the dispatcher share ONE claimForWorktree', () => {
557
+ const shared = require('../src/bongos/claim-attribution.js');
558
+ assert.equal(dispatch.claimForWorktree, shared.claimForWorktree, 'the dispatcher must re-export the shared rule, not re-derive it');
559
+ // …and the hook resolves that same module rather than inlining a find().
560
+ const hookSrc = readFileSync(new URL('../.claude/hooks/conductor.js', import.meta.url), 'utf8');
561
+ assert.match(hookSrc, /requireCore\('src\/bongos\/claim-attribution'\)/, 'the hook resolves the shared module');
562
+ assert.doesNotMatch(hookSrc, /flatMap\(\(c\) => \(Array\.isArray\(c\.touches\)/, 'the touches POOL must not come back');
563
+ });
564
+
565
+ // PLATFORM-INDEPENDENT on purpose. These assertions must hold identically on the
566
+ // Windows box a builder runs on and the POSIX box CI runs on — `path.basename`
567
+ // splits only on `/` under POSIX, so a Windows path would come back whole and
568
+ // match no claim, and this test would red on CI while passing locally.
569
+ test('attribution: worktreeNameFor is the basename claim.js records, on either platform', () => {
570
+ const { worktreeNameFor } = require('../src/bongos/claim-attribution.js');
571
+ assert.equal(worktreeNameFor('/repo/.claude/worktrees/task-1003837'), 'task-1003837');
572
+ assert.equal(worktreeNameFor('C:\\repo\\.claude\\worktrees\\task-1003837'), 'task-1003837', 'a Windows path must resolve the same way under POSIX');
573
+ assert.equal(worktreeNameFor('C:/repo/.claude/worktrees/task-1003837'), 'task-1003837');
574
+ assert.equal(worktreeNameFor('/repo/.claude/worktrees/task-1003837/'), 'task-1003837', 'a trailing separator must not erase the name');
575
+ assert.equal(worktreeNameFor('C:\\repo\\worktrees\\task-1003837\\'), 'task-1003837');
576
+ assert.equal(worktreeNameFor(null), null);
577
+ assert.equal(worktreeNameFor(''), null);
578
+ assert.equal(worktreeNameFor(' '), null);
579
+ });
580
+
481
581
  test('isCovered now delegates to the canonical matcher (still correct)', () => {
482
582
  // Regression guard for the dedup: isCovered is a thin wrapper over
483
583
  // path-match.matchOne (relocated from the deleted touches-scan in task 879),
@@ -17,9 +17,11 @@
17
17
  import { strict as assert } from 'node:assert';
18
18
  import { createRequire } from 'node:module';
19
19
 
20
+ import { shipCliSource } from './helpers.mjs';
21
+
20
22
  const require = createRequire(import.meta.url);
21
23
  const ship = require('../scripts/gds/ship.js');
22
- const { evaluateResumeAction } = ship;
24
+ const { evaluateResumeAction, shouldConfirmWithoutGrade } = ship;
23
25
 
24
26
  let passed = 0;
25
27
  let failed = 0;
@@ -124,6 +126,57 @@ test('completed + owner with low rank still resumes (ownership, not rank)', () =
124
126
  assert.equal(a.via, 'owner');
125
127
  });
126
128
 
129
+ // ---- task 1003147: the artifact-free resume that used to deadlock ----------
130
+ // HIT FOR REAL on task 1003146. A kind=verify task landed at 'completed' with no
131
+ // grade row; every documented escape (--allow-empty, --skip-grade, --verified) was
132
+ // swallowed by the same "Nothing to grade" bail, because shipMain routes ANY
133
+ // 'completed' task into the re-grade before reading a flag. The manual-confirm
134
+ // path a no-artifact task needs is unreachable from 'completed' any other way.
135
+
136
+ test('empty diff + --allow-empty → confirm instead of bailing (the 1003146 shape)', () => {
137
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146', '--allow-empty'] }), true);
138
+ });
139
+
140
+ test('empty diff + --skip-grade → confirm (the other documented no-artifact flag)', () => {
141
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146', '--skip-grade'] }), true);
142
+ });
143
+
144
+ test('a REAL diff + --allow-empty still grades — the flag is not a grader bypass', () => {
145
+ // The hole this guard exists to avoid: if a declaration alone were enough, a task
146
+ // whose grade FAILED could dodge the verdict by re-running with --allow-empty.
147
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 3, args: ['1003146', '--allow-empty'] }), false);
148
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 1, args: ['1003146', '--skip-grade'] }), false);
149
+ });
150
+
151
+ test('empty diff with NO flag keeps dead-ending — uncommitted work must not "ship"', () => {
152
+ // The overwhelmingly common cause of an empty diff is forgetting to commit.
153
+ // Confirming that silently would report success having shipped nothing.
154
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0, args: ['1003146'] }), false);
155
+ });
156
+
157
+ test('the empty-diff guard is actually WIRED to the decision, not just exporting it', () => {
158
+ // A pure truth table proves the decision, not that anything calls it. Without
159
+ // this, the guard could revert to its unconditional `process.exit(1)` and every
160
+ // case above would still pass — the deadlock back, the tests still green.
161
+ const src = shipCliSource();
162
+ // Match the CALL, not the declaration. shipCliSource() spans the whole ship
163
+ // family including ship-resume.js, where the function is DEFINED as
164
+ // `shouldConfirmWithoutGrade({ committedFileCount, args })` — a looser pattern
165
+ // matches that signature and passes even with the call site deleted. Verified:
166
+ // with the guard stubbed to `if (false)`, the loose form stayed green.
167
+ // `committedFiles.length` exists only at the call site.
168
+ assert.match(src, /shouldConfirmWithoutGrade\(\{\s*committedFileCount:\s*committedFiles\.length/,
169
+ 'the re-grade empty-diff guard must CALL shouldConfirmWithoutGrade, not merely export it');
170
+ assert.match(src, /postTasksIdConfirm\(\{ id: taskId, body: \{ verified \} \}\)/,
171
+ 'and reaching it must land on the manual-confirm route a no-artifact task needs');
172
+ });
173
+
174
+ test('shouldConfirmWithoutGrade: defaults and odd inputs never throw', () => {
175
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: 0 }), false, 'no args → no declaration');
176
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: '0', args: ['--allow-empty'] }), true, 'a numeric string still counts as empty');
177
+ assert.equal(shouldConfirmWithoutGrade({ committedFileCount: undefined, args: ['--allow-empty'] }), false, 'an unknown count is not an empty one');
178
+ });
179
+
127
180
  console.log('');
128
181
  console.log(`ship_resume: ${passed} passed, ${failed} failed`);
129
182
  process.exit(failed === 0 ? 0 : 1);