@bongos/core 1.19.822 → 1.19.824

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.822",
6
- "core_contract": "1.19.822",
7
- "source_commit": "5a9c009cff17c2a500ff23f5d0de66a1b7f00477",
5
+ "core_version": "1.19.824",
6
+ "core_contract": "1.19.824",
7
+ "source_commit": "95554434b3e9a6349986ebd50fb547858e7c0fbd",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T11:05:47.254Z",
9
+ "built_at": "2026-09-17T12:15:41.626Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 490,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2228,
14
+ "functional_verbatim": 2234,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2744,
20
- "tree_sha256": "acbc13ab96c308bd0c0aa200c9dd1f6af1013ed6771c70730a797f7a1fb4a697",
19
+ "file_count": 2750,
20
+ "tree_sha256": "5eaa31427b8c53ddb2d3aa0a10a2e851968649d578993ab3c4412fc279e1014a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2537,7 +2537,7 @@
2537
2537
  {
2538
2538
  "path": "docs/file-map.md",
2539
2539
  "mode": "0000644",
2540
- "sha256": "5560da3dad24d04ba192d4cbcab9f7e669ebd6ead35bde8d92a222a763289a36"
2540
+ "sha256": "4cacfe54b3e4f80aa1cd7a4b190bf673da867f99d5101cc1c7870ee13021c008"
2541
2541
  },
2542
2542
  {
2543
2543
  "path": "docs/handoff-template.md",
@@ -2547,7 +2547,7 @@
2547
2547
  {
2548
2548
  "path": "docs/module-api-changelog.md",
2549
2549
  "mode": "0000644",
2550
- "sha256": "9182a3b88e6092a231f59db4220628cb8579244c8289350406f07187c959e644"
2550
+ "sha256": "a921ec8fff3a0bb3664431ff5f567e3660cb26dbc55a24012edcc7a1c52de4bd"
2551
2551
  },
2552
2552
  {
2553
2553
  "path": "docs/modules-contract.md",
@@ -7812,12 +7812,12 @@
7812
7812
  {
7813
7813
  "path": "package-lock.json",
7814
7814
  "mode": "0000644",
7815
- "sha256": "d13165aac1aea3612a3c5af1b5b14267acdde35f2a8d99482310c7a749f9761c"
7815
+ "sha256": "aa5cbab9d550e88355a4d19269ef77f0b414394f53d0a745dba09417158d42ae"
7816
7816
  },
7817
7817
  {
7818
7818
  "path": "package.json",
7819
7819
  "mode": "0000644",
7820
- "sha256": "0f9f0baab592f70e890e2a39e61dcf21c5170841f97b8c82cb16703c2a959756"
7820
+ "sha256": "50285f0725d1e9c6da37f0b47e3a64ad5f2084a81539eb2902ff9f75b7635ce0"
7821
7821
  },
7822
7822
  {
7823
7823
  "path": "public-docs/index.html",
@@ -8092,7 +8092,7 @@
8092
8092
  {
8093
8093
  "path": "scripts/gds/category-advisory-guard.js",
8094
8094
  "mode": "0000644",
8095
- "sha256": "a71ac076299b9e37bf8e0a6ff8761f8ba3059271b2596b6ee8941fc24533357d"
8095
+ "sha256": "19fd98214001bb83ed69789653519d820ece8d94fea4451503ecf85ff4285f32"
8096
8096
  },
8097
8097
  {
8098
8098
  "path": "scripts/gds/cf-tunnel.js",
@@ -8212,7 +8212,7 @@
8212
8212
  {
8213
8213
  "path": "scripts/gds/deploy-divergence.js",
8214
8214
  "mode": "0000644",
8215
- "sha256": "4b77b2c878cace855ad979c66a7bbb0318199d10b1e0f114f79732db2b6cc203"
8215
+ "sha256": "39879d5d26a325529620ab4fd180f133643d0e4c2a5a9ef92f8ee347ad0ba0d7"
8216
8216
  },
8217
8217
  {
8218
8218
  "path": "scripts/gds/design-sync.js",
@@ -8302,7 +8302,7 @@
8302
8302
  {
8303
8303
  "path": "scripts/gds/fetch-sound-prefs.js",
8304
8304
  "mode": "0000644",
8305
- "sha256": "0043a1af935e5dff7d807525eb6426310a8e6517af03f0506a86cb3296d38f74"
8305
+ "sha256": "f2239bdd653ceda6f2595d454e618bff5273f46eb63d3cb6d8040190ee0125e9"
8306
8306
  },
8307
8307
  {
8308
8308
  "path": "scripts/gds/figma-design-sync.js",
@@ -8357,7 +8357,7 @@
8357
8357
  {
8358
8358
  "path": "scripts/gds/fitness.js",
8359
8359
  "mode": "0000644",
8360
- "sha256": "8725f7a8dcdd6445e8d2379e24c3985624b1889cc67be055cce8365b312457d2"
8360
+ "sha256": "aa1a26c0b2871cd6e49b619254ce2b11d477780da8f4b037c38cb16b0ccd68fc"
8361
8361
  },
8362
8362
  {
8363
8363
  "path": "scripts/gds/gate-review.js",
@@ -8689,6 +8689,11 @@
8689
8689
  "mode": "0000644",
8690
8690
  "sha256": "9898b6f8e60b9f5ee4aeb9a023d1b68bb5bfbdf589eed10923a8e492716340ed"
8691
8691
  },
8692
+ {
8693
+ "path": "scripts/gds/probe-cache.js",
8694
+ "mode": "0000644",
8695
+ "sha256": "34d891e8ff58f0da957d300021210dbc34448fe676873b6b77e13ad3b7179d2e"
8696
+ },
8692
8697
  {
8693
8698
  "path": "scripts/gds/prove-strict-versioning.js",
8694
8699
  "mode": "0000644",
@@ -8864,6 +8869,11 @@
8864
8869
  "mode": "0000644",
8865
8870
  "sha256": "c82513a9333724c77ba3a99667726861c292d576ea493f2144e6051dd78c47b6"
8866
8871
  },
8872
+ {
8873
+ "path": "scripts/gds/scope-map-guard.js",
8874
+ "mode": "0000644",
8875
+ "sha256": "75f0791698a36e48ec232d2285e6f80dac6d64c2a23a23a26cd54c1c05883c3b"
8876
+ },
8867
8877
  {
8868
8878
  "path": "scripts/gds/seam-wiring-guard.js",
8869
8879
  "mode": "0000644",
@@ -9232,7 +9242,12 @@
9232
9242
  {
9233
9243
  "path": "scripts/gds/strand-watch.js",
9234
9244
  "mode": "0000644",
9235
- "sha256": "c7f9c997a6892e6ca083a03789b24524912632731ea550df6ee974f1ef05267e"
9245
+ "sha256": "2a9959bd2e45046dadcda9982998a5b7163cef00b5d719ff299314daa5409770"
9246
+ },
9247
+ {
9248
+ "path": "scripts/gds/stranded-rows.js",
9249
+ "mode": "0000644",
9250
+ "sha256": "573e608a90b5822f9bf77f203d54cf8a80ef9e96bdc51b22e5dec1226da3ee98"
9236
9251
  },
9237
9252
  {
9238
9253
  "path": "scripts/gds/stray-unit-sweep.sh",
@@ -9532,7 +9547,7 @@
9532
9547
  {
9533
9548
  "path": "src/bongos/module-scope-map.js",
9534
9549
  "mode": "0000644",
9535
- "sha256": "8d7b749edca694b4c9d8a563a4334c7fe3fc7be64c520c3d14c3b63febff132a"
9550
+ "sha256": "7f393fc515fe751d5296ce0a574e0d3e40695c0346933a0d8b9c30ed6ae39a1b"
9536
9551
  },
9537
9552
  {
9538
9553
  "path": "src/bongos/module-submissions.js",
@@ -9722,7 +9737,7 @@
9722
9737
  {
9723
9738
  "path": "src/module-api.js",
9724
9739
  "mode": "0000644",
9725
- "sha256": "782160b1ae7c1483c92337a589f92d8099a8bbee37005ea744c78bd4fcb5803c"
9740
+ "sha256": "7bd07a13ff08f341820d14bfa056a4fff9715b0a63fbfb7f392e4d04b5336a15"
9726
9741
  },
9727
9742
  {
9728
9743
  "path": "src/module-loader/catalog.js",
@@ -10367,7 +10382,7 @@
10367
10382
  {
10368
10383
  "path": "tests/category_advisory_guard.mjs",
10369
10384
  "mode": "0000644",
10370
- "sha256": "8fd7b066c264ddaeccaa2dfa9a3c1fe6204a943933a1161c6a235f45b40ab121"
10385
+ "sha256": "0fdb3c4e34fadc0f6697eb3ee3083d6739273e4df3c5693b7c3693cf0dfb1ef1"
10371
10386
  },
10372
10387
  {
10373
10388
  "path": "tests/cf_ruleset_preflight.mjs",
@@ -12344,6 +12359,11 @@
12344
12359
  "mode": "0000644",
12345
12360
  "sha256": "3c85d28ecc55e375988f3e55aee993c8c84220889678262d8207146c2772f3f5"
12346
12361
  },
12362
+ {
12363
+ "path": "tests/probe_cache.mjs",
12364
+ "mode": "0000644",
12365
+ "sha256": "26c734a1ab1996e1aff6197a9e0b19a448b28c772e0e75b00fee2433535c1193"
12366
+ },
12347
12367
  {
12348
12368
  "path": "tests/profile_activity.mjs",
12349
12369
  "mode": "0000644",
@@ -12869,6 +12889,11 @@
12869
12889
  "mode": "0000644",
12870
12890
  "sha256": "8d516fe0fb398d8322e014773901449b6909439ab1567c90d8354b49f211cdbf"
12871
12891
  },
12892
+ {
12893
+ "path": "tests/scope_map_guard.mjs",
12894
+ "mode": "0000644",
12895
+ "sha256": "278e334a945d151c225d849b51464a3dffc8f0bb0dcb1d39b72d12bae57efd4f"
12896
+ },
12872
12897
  {
12873
12898
  "path": "tests/scouting_owner_access_and_reach.mjs",
12874
12899
  "mode": "0000644",
@@ -13379,6 +13404,11 @@
13379
13404
  "mode": "0000644",
13380
13405
  "sha256": "bba2bde5b7a4a25ccddff58fb2fde0ef84ef67224e63819232221a461234a30f"
13381
13406
  },
13407
+ {
13408
+ "path": "tests/stranded_rows.mjs",
13409
+ "mode": "0000644",
13410
+ "sha256": "63070400896c81e90e53e6b9480beb55eafee854d7d54971e201cee70056d487"
13411
+ },
13382
13412
  {
13383
13413
  "path": "tests/streak.mjs",
13384
13414
  "mode": "0000644",
package/docs/file-map.md CHANGED
@@ -464,7 +464,8 @@ tests/
464
464
  ├── repo-simplify-sweep/SKILL.md ← weekly repo-cleanliness janitor — runs repo-metrics, files dead-code findings to idea_inbox, and auto-fixes only the narrow safe class (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
465
465
  ├── security-review-weekly/SKILL.md ← weekly security-state review — stale vulnerability_reports, ageing security-tagged tasks, recent high/critical dependency alerts; one Archon broadcast when anything is stale, silent otherwise (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
466
466
  ├── skill-usage-review/SKILL.md ← nightly skill-usage feedback loop — aggregates 24h of skill invocations into friction findings filed as `[skill-friction]` idea_inbox rows (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
467
- └── stale-claim-release/SKILL.md ← hourly sweep releasing claims whose session_pulses heartbeat has been silent for GDS_STALE_CLAIM_HOURS, returning the task to ready and pinging the builder (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
467
+ ├── stale-claim-release/SKILL.md ← hourly sweep releasing claims whose session_pulses heartbeat has been silent for GDS_STALE_CLAIM_HOURS, returning the task to ready and pinging the builder (restored in task 1003365; unscheduled in core — only redteam-weekly has a firing workflow; config/scheduled-routines.json records the intended cadence)
468
+ └── stranded-rows/SKILL.md ← flags an open task whose work ALREADY LANDED on main (task 1003775): a row still in ready/backlog while a commit claims it with a line ending in `(task N)`. Narrower than "mentions N" on purpose — idea 1000807 measured that a mention-scan returns ~44 open rows that merely cite an id. Measured clean at 878 claims vs 434 open rows, and still catches the historical case (task 1002200, whose claim lives in the MERGE commit body). NO cadence declared: nothing schedules it yet, so it is run by hand — wiring a scheduler needs the control plane, and declaring a cadence with no cron firing it fails fitness Check 25 (task 1003209, ADR 0188). Prints ids + fixed vocabulary only, never builder-authored text.
468
469
  ```
469
470
  <!-- END GENERATED FILE-MAP .claude/scheduled-tasks/ -->
470
471
 
@@ -2118,5 +2118,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2118
2118
  landed since 1.19.820 with no explicit bump. run 35212866365. (task 1002620)
2119
2119
  1.19.822 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2120
2120
  landed since 1.19.821 with no explicit bump. run 35213847140. (task 1002620)
2121
+ 1.19.823 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2122
+ landed since 1.19.822 with no explicit bump. run 35218585239. (task 1002620)
2123
+ 1.19.824 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2124
+ landed since 1.19.823 with no explicit bump. run 35220051893. (task 1002620)
2121
2125
  ---------------------------------------------------------------------------
2122
2126
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.822",
3
+ "version": "1.19.824",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.822",
9
+ "version": "1.19.824",
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.822",
3
+ "version": "1.19.824",
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",
@@ -69,6 +69,18 @@ const FEATURE_ANCHORS = [
69
69
  // Word-bounded on purpose: `subcategory_id` in some unrelated module is not this
70
70
  // feature, and a guard that cries wolf gets suppressed rather than obeyed. The
71
71
  // table name needs no boundary — nothing else is called that.
72
+ // A quoted path to a scripts/gds/*.js file is a FILENAME, not a category read
73
+ // (task 1003949). One of these guards is itself called category-advisory-guard.js,
74
+ // so the moment any decision surface lists the enforcement tooling by path — which
75
+ // module-scope-map.js must, to own it — this check fired on its own name.
76
+ //
77
+ // Blanked rather than exempted by file, and kept as narrow as it can be: only the
78
+ // quoted path is removed, so the rest of the line is still scanned. A genuine
79
+ // category read cannot hide inside a string that ends in .js.
80
+ function withoutScriptPaths(line) {
81
+ return String(line).replace(/(['\"`])[^'\"`]*\.js\1/g, '""');
82
+ }
83
+
72
84
  const CATEGORY_RE = /\bcategor(y|ies|ised|ized|isation|ization)\b|\bcategory_id\b|work_categories/i;
73
85
 
74
86
  // Strip // line comments and /* */ blocks so a citation of a rule is legal and only
@@ -147,7 +159,7 @@ function scanGates(files, read) {
147
159
  const code = stripComments(src);
148
160
  const hits = code.split('\n')
149
161
  .map((line, i) => ({ line: line.trim(), n: i + 1 }))
150
- .filter(({ line }) => CATEGORY_RE.test(line));
162
+ .filter(({ line }) => CATEGORY_RE.test(withoutScriptPaths(line)));
151
163
  for (const { line, n } of hits) {
152
164
  violations.push(
153
165
  `${rel}:${n} reads a work CATEGORY inside a decision surface — \`${line.slice(0, 100)}\`. `
@@ -321,7 +321,11 @@ async function main() {
321
321
  if (hasFlag('--line')) {
322
322
  const ms = Number(arg('--timeout-ms')) || DEFAULT_TIMEOUT_MS;
323
323
  try {
324
- const one = line(await check({ timeoutMs: ms }));
324
+ // Shared across concurrent session starts (task 1003689): four sessions opened
325
+ // seconds apart asked this same question four times and all four timed out, which
326
+ // emits NO line — and silence from a truth-teller reads as "nothing to report".
327
+ const { cached } = require('./probe-cache.js');
328
+ const { value: one } = await cached('deploy-divergence-line', async () => line(await check({ timeoutMs: ms })));
325
329
  if (one) console.log(one);
326
330
  } catch { /* offline, no git, unreadable module-api — all silence */ }
327
331
  process.exitCode = 0;
@@ -36,10 +36,16 @@ function writeAtomic(targetPath, contents) {
36
36
  fs.renameSync(tmp, targetPath);
37
37
  }
38
38
 
39
- async function main() {
39
+ // The network half, on its own so probe-cache can own it: cached() records a value
40
+ // only when its producer RESOLVES, so a failed fetch leaves nothing cached and the
41
+ // next session retries instead of inheriting a minute of "already done".
42
+ //
43
+ // Returns { soundData, eventData } or null. Null is an ordinary outcome — offline, a
44
+ // timeout, or both endpoints refusing — and the caller writes nothing, which leaves
45
+ // the prefs file exactly as the last good run left it (fail-open).
46
+ async function fetchPrefs() {
40
47
  const sess = readSessionToken();
41
- if (!sess) return; // not signed in — player fails open
42
-
48
+ if (!sess) return null;
43
49
  const ctrl = new AbortController();
44
50
  const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
45
51
  let soundData = null;
@@ -57,15 +63,44 @@ async function main() {
57
63
  signal: ctrl.signal,
58
64
  }),
59
65
  ]);
60
- if (!soundRes.ok && !eventRes.ok) return;
66
+ if (!soundRes.ok && !eventRes.ok) return null;
61
67
  if (soundRes.ok) soundData = await soundRes.json();
62
68
  if (eventRes.ok) eventData = await eventRes.json();
63
69
  } catch (_) {
64
- return; // network/timeout — no-op
70
+ return null; // network/timeout — no-op
65
71
  } finally {
66
72
  clearTimeout(timer);
67
73
  }
68
74
 
75
+ return { soundData, eventData };
76
+ }
77
+
78
+ async function main() {
79
+ const sess = readSessionToken();
80
+ if (!sess) return; // not signed in — player fails open
81
+
82
+ // SHARED ACROSS CONCURRENT SESSION STARTS (task 1003689). This is the third of the
83
+ // three SessionStart hooks that call the server, and when four sessions open within
84
+ // seconds they contend and every hook in all four hits its timeout. Unlike the
85
+ // truth-tellers, a lost run here is invisible rather than merely silent: the prefs
86
+ // file simply keeps its previous contents.
87
+ //
88
+ // Which is also why skipping the write on a cache hit is correct — the file already
89
+ // holds what the previous run fetched, and these prefs change when a builder edits
90
+ // them in Settings, not between two sessions opened three seconds apart.
91
+ // The cache must be marked fresh only by a SUCCESSFUL fetch. The first cut cached
92
+ // Date.now() BEFORE fetching, so a failed or timed-out fetch still recorded 'done'
93
+ // and blocked every retry for the whole TTL - turning a transient network error into
94
+ // a minute of silently stale prefs. cached() writes its record only when the producer
95
+ // RESOLVES, so the work belongs inside it, the way strand-watch and deploy-divergence
96
+ // already do it.
97
+ const { cached } = require('./probe-cache.js');
98
+ const { value: fetched, from } = await cached('sound-prefs', async () => fetchPrefs());
99
+ if (from !== 'fresh') return; // another session already fetched and wrote the file
100
+ if (!fetched) return; // not signed in, offline, or both requests failed
101
+ const { soundData, eventData } = fetched;
102
+
103
+
69
104
  // effective_map is { <sound>: <bool> } for every known sound (defaults
70
105
  // already applied server-side); volume is the master 0-100 (#788). The local
71
106
  // file shape is { sounds: {...}, volume: N, event_sounds: {...} } — play.js
@@ -1234,6 +1234,7 @@ const CHECKS = [
1234
1234
  // one (ADR 0289). Rationale + the seam→consumer table live in that file.
1235
1235
  require('./seam-wiring-guard.js').checkSeamWiring,
1236
1236
  require('./home-isolation-guard.js').checkHomeIsolation, // Check 34 — task 1003763: a test that sets HOME for a child without USERPROFILE isolates NOTHING on win32, so it silently stops testing what its comment says. Three tasks fixed this one site at a time. Rationale in that file.
1237
+ require('./scope-map-guard.js').checkDevsecopsOwnsItsChecks, // Check 35 — task 1003949: a check file no MODULE_SCOPE_MAP key owns cannot be authorized by the goal scoped to own it, and the list drifts once per new check. Rationale in that file.
1237
1238
  require('./adr-namespace.js').checkAdrNumbering, require('./exec-path-guard.js').checkExecPathsStatic, // Check 32 — task 1003371 / B50: what the server EXECUTES, out of whose tree — the blind spot all three 2026-08-29 CRITICALs shared. Rationale + reviewed exemptions live in that file. Sharing Check 31's line (the 19c/19d + 23/24 precedent above): fitness.js is AT the 1,500 ratchet and a new check cannot afford a line until task 1003825 buys it back.
1238
1239
  ];
1239
1240
 
Binary file
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/scope-map-guard.js — the enforcement tooling owns itself (task 1003949).
4
+ //
5
+ // THE DRIFT THIS STOPS. MODULE_SCOPE_MAP's `devsecops` key enumerates the
6
+ // enforcement tooling by exact path, and the house style since fitness.js hit its
7
+ // line ratchet is that every new check gets its OWN file beside it. Those two facts
8
+ // disagree once per new check: the file appears, nobody adds it to the map, and
9
+ // scopeSubset() resolves it to no module territory — so a goal scoped to devsecops
10
+ // is refused out_of_scope for touching the very tooling it exists to own. That is
11
+ // the same class as tasks 1002690 / 1002736 / 1003124 / 1003655, and it WIDENS with
12
+ // every check added.
13
+ //
14
+ // WHY A GUARD RATHER THAN A LONGER LIST. The list was appended to this time too —
15
+ // but a list that needs appending is the defect, not the fix. Note the evidence:
16
+ // the task that filed this named fourteen unowned files, and fitness.js requires more than that, and the number moves
17
+ // with every check added — which is why this reads the file instead of citing a count. The list was already stale again by the time
18
+ // somebody wrote it down.
19
+ //
20
+ // WHY NOT A PREFIX OR A DIRECTORY MOVE. The scope-map format has no wildcards, so
21
+ // owning the family by prefix means moving the guards into scripts/gds/fitness/ —
22
+ // and those filenames are named in workflows, skills, ADRs, session logs, the file
23
+ // map and the docs tree (40+ files). That is a real refactor with a wide blast
24
+ // radius, and it is deliberately NOT bundled here; this makes the drift impossible
25
+ // first, which is what the goal wall actually needs.
26
+ //
27
+ // WHY THE LIST STAYS EXPLICIT. MODULE_SCOPE_MAP feeds an AUTHORIZATION decision.
28
+ // Deriving it at runtime from a static scan of another file would mean what a goal
29
+ // may touch depends on a regex over source text — a worse property than a list a
30
+ // human can read. So the list stays literal and CI keeps it honest.
31
+
32
+ const fs = require('node:fs');
33
+ const path = require('node:path');
34
+
35
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
36
+ const FITNESS = path.join('scripts', 'gds', 'fitness.js');
37
+
38
+ // Every `require('./x.js')` in fitness.js — the files it actually runs, which is
39
+ // the definition of "the enforcement tooling" that matters here.
40
+ //
41
+ // PURE, and exported, so the extraction is testable without the real file: a
42
+ // scanner that quietly matches nothing looks exactly like a clean result, and this
43
+ // one's whole job is to produce a non-empty set.
44
+ // THE EXTENSION IS OPTIONAL, and requiring it was a real gap (grade round 1). Node
45
+ // resolves `require('./run-routine')` to run-routine.js, and fitness.js writes FOUR of
46
+ // its sibling requires that way — gen-api-docs, migration-namespace, run-routine,
47
+ // skill-lint. A pattern anchored on `.js` found none of them, so four check files
48
+ // stayed unowned while this guard reported the set clean.
49
+ //
50
+ // That is the failure shape this file exists to end, committed by the guard itself: a
51
+ // scan that matches less than it should reports a healthy repo, and healthy is exactly
52
+ // what a broken scan looks like.
53
+ function siblingRequires(source) {
54
+ const out = new Set();
55
+ for (const m of String(source || '').matchAll(/require\(['"]\.\/([A-Za-z0-9._-]+?)(\.js)?['"]\)/g)) {
56
+ out.add(`scripts/gds/${m[1]}.js`);
57
+ }
58
+ return [...out].sort();
59
+ }
60
+
61
+ // PURE — which of those files no module key claims. A DIRECTORY entry owns everything
62
+ // beneath it, which is why `dirs` is a parameter rather than something the caller
63
+ // pre-filters: forgetting it would report every file under an owned directory as
64
+ // unowned.
65
+ function unownedBy(siblings, ownedPaths, dirs = []) {
66
+ const owned = new Set(ownedPaths);
67
+ return siblings.filter((p) => !owned.has(p) && !dirs.some((d) => p.startsWith(d)));
68
+ }
69
+
70
+ function checkDevsecopsOwnsItsChecks() {
71
+ const name = 'the enforcement tooling owns itself (task 1003949)';
72
+ const src = fs.readFileSync(path.join(REPO_ROOT, FITNESS), 'utf8');
73
+ const siblings = siblingRequires(src);
74
+
75
+ // A scanner that finds nothing passes for free. fitness.js is BUILT out of
76
+ // sibling requires, so an empty set means the pattern broke, not that the tooling
77
+ // became self-contained — this check must not be able to pass vacuously.
78
+ if (siblings.length === 0) {
79
+ return {
80
+ name,
81
+ ok: false,
82
+ hardFail: true,
83
+ violations: [`${FITNESS}: found NO sibling require() calls. That is a broken scan, not a `
84
+ + 'clean result — the extraction pattern in scope-map-guard.js needs looking at.'],
85
+ warnings: [],
86
+ note: 'the scan produced an empty set, which cannot be right.',
87
+ };
88
+ }
89
+
90
+ const { MODULE_SCOPE_MAP } = require('../../src/bongos/module-scope-map.js');
91
+ // Each value is { globs: [...], protected?: bool } — not a bare array.
92
+ const globsOf = (v) => (Array.isArray(v) ? v : (v && v.globs) || []);
93
+ const allOwned = [];
94
+ for (const v of Object.values(MODULE_SCOPE_MAP)) allOwned.push(...globsOf(v));
95
+ // Any key may own a check file — what matters is that SOME key does, so a goal can
96
+ // be scoped to it. A directory entry owns everything beneath it.
97
+ const dirs = allOwned.filter((p) => p.endsWith('/'));
98
+ const owned = new Set(allOwned);
99
+ // Through the exported helper rather than a second inline copy of the same filter:
100
+ // two spellings of one rule is how the enumerated list this guard polices drifted in
101
+ // the first place.
102
+ const unowned = unownedBy(siblings, allOwned, dirs);
103
+
104
+ const violations = unowned.map((p) => `${p} is required by ${FITNESS} but no MODULE_SCOPE_MAP key `
105
+ + 'owns it — scopeSubset() resolves it to no territory, so a goal scoped to devsecops is denied '
106
+ + 'out_of_scope for touching the tooling it exists to own. Add it to the devsecops key.');
107
+
108
+ return {
109
+ name,
110
+ ok: unowned.length === 0,
111
+ hardFail: unowned.length > 0,
112
+ violations,
113
+ warnings: [],
114
+ // Printed on the PASS path too: the count is the evidence the scan ran at all.
115
+ note: `${siblings.length} file(s) required by ${FITNESS}, all owned by a MODULE_SCOPE_MAP key. `
116
+ + 'The house style gives each new check its own file, so this list drifts once per check '
117
+ + 'unless CI says otherwise (task 1003949).',
118
+ };
119
+ }
120
+ module.exports = { checkDevsecopsOwnsItsChecks, siblingRequires, unownedBy, FITNESS };
@@ -245,7 +245,14 @@ async function main() {
245
245
  // deploy-divergence's identical comment).
246
246
  if (hasFlag('--line')) {
247
247
  try {
248
- const one = line(await check(opts));
248
+ // SHARED ACROSS CONCURRENT SESSION STARTS (task 1003689). Four sessions opened
249
+ // within three seconds — one worktree per claim, the normal pattern here — used
250
+ // to make this same call four times and ALL of them timed out, which produces no
251
+ // line at all. Silence from a truth-teller is indistinguishable from 'nothing to
252
+ // report', so the brief was lost without a symptom. The first session to ask now
253
+ // answers for the rest.
254
+ const { cached } = require('./probe-cache.js');
255
+ const { value: one } = await cached('strand-watch-line', async () => line(await check(opts)));
249
256
  if (one) console.log(one);
250
257
  } catch { /* no session, offline, unreadable — all silence */ }
251
258
  process.exitCode = 0;
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/stranded-rows.js — a task whose work already landed, still sitting in
3
+ // `ready`/`backlog` (task 1003775).
4
+ //
5
+ // THE FAILURE. Three tasks closed in one 2026-08-20 session needed no work at all:
6
+ // the fix was already in main. That was 3 of 8 tasks in one sweep, and every one was
7
+ // found by a human reading code. The cost is not the wasted claim — it is that the
8
+ // next builder claims it, reads the code, finds nothing to do, and has to prove a
9
+ // negative before releasing it.
10
+ //
11
+ // SCOPE — ONE OF THE TWO FAILURE MODES, DELIBERATELY. Idea 1000807 named two:
12
+ //
13
+ // 1. STRANDED ROW — the work landed under the task's OWN id, but the row never
14
+ // moved off `ready`. Mechanical. This file.
15
+ // 2. SILENT SUPERSESSION — a LATER task solved the same problem under a different
16
+ // id. The idea's author tried commit-message scanning for this and reports it
17
+ // does NOT work: a broad scan and a narrowed one (826 commits) both returned
18
+ // ~44 open tasks that merely MENTION an id — seeded-task commits, session logs,
19
+ // ADRs citing planned work. Not attempted here. It needs a human or LLM read and
20
+ // belongs in a review cadence, not new machinery.
21
+ //
22
+ // THE SIGNAL, and why it is this one. A commit says which task it DID by ending a
23
+ // line with `(task N)`. That is narrower than "mentions N", which is the distinction
24
+ // the idea's investigation failed on: "1002792, 1002200, 1002571" in a session-log
25
+ // body is a comma list, not a line ending in `(task N)`.
26
+ //
27
+ // It reads the WHOLE message, not just the subject, and that detail is load-bearing.
28
+ // The historical case the idea names — task 1002200 — landed on a cloud-agent branch
29
+ // whose own commit subject never named it; the `(task 1002200)` line lives in the
30
+ // MERGE commit's body, where GitHub echoes the branch's subject. A subject-only scan
31
+ // misses the one case this file exists for.
32
+ //
33
+ // MEASURED on this repo before shipping: 877 ids are named as an authoring subject
34
+ // across 4,784 commits, and ZERO of them are open `ready`/`backlog` rows (434 of
35
+ // those). So today's board is clean and the signal is not merely quiet — the same
36
+ // rule catches 1002200 when its row is treated as open.
37
+ //
38
+ // HOUSE RULES (the truth-teller family: strand-watch.js, stale-worktrees.js).
39
+ // Fail OPEN — a probe never blocks a session. Silent when healthy. Prints IDS AND
40
+ // FIXED VOCABULARY ONLY: never a commit subject or a task title, both of which are
41
+ // builder-authored text.
42
+ //
43
+ // Usage:
44
+ // node scripts/gds/stranded-rows.js # human report; exit 1 if any found
45
+ // node scripts/gds/stranded-rows.js --json # { stranded: [{ id, status, sha }] }
46
+ // node scripts/gds/stranded-rows.js --line # ONE line, or nothing; always exit 0
47
+
48
+ 'use strict';
49
+
50
+ const { execFileSync } = require('node:child_process');
51
+
52
+ const MAIN_REF = 'origin/main';
53
+ // A record separator no commit message can contain: the word is wrapped in U+0001
54
+ // (START OF HEADING), a C0 control character git will not carry in a message body.
55
+ //
56
+ // Built from an explicit escape rather than written as a literal, because the control
57
+ // bytes are INVISIBLE in a source listing - the first reviewer of this file read it as
58
+ // `const SEP = 'COMMIT'` and correctly flagged that a body containing the word COMMIT
59
+ // would split a record in half. It would; that is exactly why the sentinels are there,
60
+ // and now you can see them. A test pins a body carrying the bare word.
61
+ const SOH = String.fromCharCode(1);
62
+ const SEP = `${SOH}COMMIT${SOH}`;
63
+
64
+ // The authoring convention: a line whose END is `(task N)`. Anchored at end-of-line on
65
+ // purpose — that is what distinguishes "this commit did task N" from "this commit
66
+ // mentioned N", and the second is the noise that made the supersession half undoable.
67
+ const AUTHORED_RE = /\(task (\d{6,7})\)\s*$/;
68
+
69
+ // PURE — every task id a commit message claims to have DONE. Exported so the rule is
70
+ // testable without a repo: a scanner that quietly stops matching reports a clean board,
71
+ // which is indistinguishable from a healthy one.
72
+ function authoredIds(message) {
73
+ const out = new Set();
74
+ for (const line of String(message || '').split('\n')) {
75
+ const m = AUTHORED_RE.exec(line.trim());
76
+ if (m) out.add(m[1]);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // PURE — id -> first sha that claimed it, over a whole `git log` payload.
82
+ function scanLog(raw) {
83
+ const byId = new Map();
84
+ for (const block of String(raw || '').split(SEP)) {
85
+ if (!block.trim()) continue;
86
+ const nl = block.indexOf('\n');
87
+ const sha = (nl === -1 ? block : block.slice(0, nl)).trim();
88
+ const body = nl === -1 ? '' : block.slice(nl + 1);
89
+ for (const id of authoredIds(body)) if (!byId.has(id)) byId.set(id, sha);
90
+ }
91
+ return byId;
92
+ }
93
+
94
+ // PURE — the stranded set. `openRows` are { id, status }; `landed` is scanLog's map.
95
+ // Kept separate from the IO so the decision is unit-testable with no git and no API.
96
+ function strandedRows(openRows, landed) {
97
+ const out = [];
98
+ for (const row of openRows || []) {
99
+ const id = String(row && row.id);
100
+ if (!landed.has(id)) continue;
101
+ out.push({ id, status: String(row.status || ''), sha: landed.get(id).slice(0, 8) });
102
+ }
103
+ return out.sort((a, b) => Number(a.id) - Number(b.id));
104
+ }
105
+
106
+ function readLog(cwd) {
107
+ return execFileSync('git', ['log', MAIN_REF, `--pretty=${SEP}%H%n%B`], {
108
+ cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
109
+ });
110
+ }
111
+
112
+ // Both statuses that mean "nobody has started this". A row already `active` or
113
+ // `completed` is someone's live work, not a strand.
114
+ const OPEN_STATUSES = ['ready', 'backlog'];
115
+
116
+ // A hard stop so a paging bug cannot loop forever. Reaching it means the board grew
117
+ // past what one scan should walk, which is a fact worth SAYING rather than silently
118
+ // truncating — a detector that quietly stops early reports a clean board.
119
+ const MAX_PAGES = 50;
120
+
121
+ // PURE-ish — the paging walk, with `getPage` injected. Extracted and exported because
122
+ // it was the only untested IO in this file, and off-by-one paging is exactly the kind
123
+ // of logic that silently returns a short list (and a short list here means 'nothing is
124
+ // stranded').
125
+ async function pageOpenRows(getPage, { statuses = OPEN_STATUSES, pageSize = 200, maxPages = MAX_PAGES } = {}) {
126
+ const rows = [];
127
+ for (const status of statuses) {
128
+ let offset = 0;
129
+ let page = 0;
130
+ for (; page < maxPages; page += 1) {
131
+ const r = await getPage({ status, limit: pageSize, offset });
132
+ if (!r || !r.ok) throw new Error(`could not list ${status} tasks (${r && r.status})`);
133
+ const got = (r.data && r.data.tasks) || [];
134
+ for (const t of got) rows.push({ id: t.id, status: t.status });
135
+ // A SHORT page is the end of the table. Compared against the size the route
136
+ // actually honoured, never the size we asked for — the route clamps.
137
+ if (got.length < (r.limit || pageSize)) break;
138
+ offset += got.length;
139
+ }
140
+ if (page >= maxPages) {
141
+ throw new Error(`more than ${maxPages} pages of ${status} tasks — refusing to report a partial board as clean`);
142
+ }
143
+ }
144
+ return rows;
145
+ }
146
+
147
+ async function readOpenRows() {
148
+ const { cliClient } = require('./cli-lib.js');
149
+ const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
150
+ const api = await cliClient();
151
+ return pageOpenRows(async ({ status, limit, offset }) => {
152
+ const q = buildTaskPageQuery({ status, limit, offset });
153
+ const r = await api.tasks.getTasks({ query: q });
154
+ // Carry the limit the route HONOURED back to the walker, so its short-page test
155
+ // compares against the clamped size rather than the requested one.
156
+ return r && { ok: r.ok, status: r.status, data: r.data, limit: q.limit };
157
+ }, { pageSize: TASK_LIST_MAX_LIMIT });
158
+ }
159
+
160
+ async function scan(cwd) {
161
+ const landed = scanLog(readLog(cwd));
162
+ // The scan must not pass by finding nothing: this repo's own history is built out of
163
+ // these lines, so an empty map means the pattern broke.
164
+ if (landed.size === 0) throw new Error('no commit claims to have done any task — the pattern has drifted');
165
+ return { stranded: strandedRows(await readOpenRows(), landed), scanned: landed.size };
166
+ }
167
+
168
+ async function main() {
169
+ const argv = process.argv.slice(2);
170
+ const line = argv.includes('--line');
171
+ let r;
172
+ try {
173
+ r = await scan(process.cwd());
174
+ } catch (e) {
175
+ if (line) process.exit(0); // fail OPEN — a probe never blocks a session
176
+ console.error(`stranded-rows: undetermined — ${e.message || e}`);
177
+ process.exit(2);
178
+ }
179
+
180
+ if (argv.includes('--json')) { console.log(JSON.stringify(r, null, 2)); return; }
181
+ if (line) {
182
+ if (r.stranded.length) {
183
+ const ids = r.stranded.slice(0, 6).map((s) => s.id).join(', ');
184
+ const more = r.stranded.length > 6 ? ` +${r.stranded.length - 6} more` : '';
185
+ console.log(`Stranded rows: ${r.stranded.length} open task(s) whose work already landed on main (${ids}${more}). `
186
+ + 'Claiming one means reading the code to prove there is nothing to do. Run node scripts/gds/stranded-rows.js.');
187
+ }
188
+ return;
189
+ }
190
+ console.log(` Stranded rows: ${r.stranded.length} (of ${r.scanned} task id(s) named as an authoring subject on main)`);
191
+ for (const s of r.stranded) console.log(` task ${s.id} [${s.status}] landed in ${s.sha}`);
192
+ if (r.stranded.length) {
193
+ console.log(' Each of these has a commit on main claiming to have done it. Verify, then ship or abandon the row.');
194
+ process.exit(1);
195
+ }
196
+ }
197
+
198
+ if (require.main === module) main();
199
+
200
+ module.exports = { authoredIds, scanLog, strandedRows, pageOpenRows, AUTHORED_RE, SEP, OPEN_STATUSES, MAX_PAGES };
@@ -338,6 +338,36 @@ const MODULE_GLOBS = {
338
338
  'scripts/gds/fitness.js', 'scripts/gds/docs-entropy.js',
339
339
  'scripts/gds/redteam-patrol.js', 'scripts/gds/audit-gate.js',
340
340
  'scripts/gds/fitness-ratchets.js', 'scripts/gds/control-manifest.js',
341
+ // Every file fitness.js actually requires (task 1003949). Since fitness.js hit
342
+ // its line ratchet, the house style is that each new check lands in its OWN file
343
+ // beside it — and none of them was listed here, so scopeSubset() resolved them to
344
+ // no territory and a goal scoped to devsecops was denied out_of_scope for touching
345
+ // the very tooling it owns (the 1002690 / 1002736 / 1003124 / 1003655 class).
346
+ //
347
+ // This list is kept honest by scripts/gds/scope-map-guard.js rather than by
348
+ // remembering: it derives the same set from fitness.js's own requires and fails
349
+ // CI when one is missing. That guard exists because appending is not a fix — the
350
+ // task that filed this named FOURTEEN unowned files and there were twenty-two.
351
+ 'scripts/gds/scope-map-guard.js',
352
+ // Landed from task 1003763 while this branch was in flight, and the guard below
353
+ // caught it unowned on the merge - which is the drift it exists to stop.
354
+ 'scripts/gds/home-isolation-guard.js',
355
+ // Required WITHOUT the .js extension by fitness.js, which is how they stayed
356
+ // invisible to the first cut of scope-map-guard (grade round 1): Node resolves
357
+ // require('./skill-lint') fine, but a pattern anchored on '.js' matched none of them.
358
+ 'scripts/gds/gen-api-docs.js', 'scripts/gds/migration-namespace.js',
359
+ 'scripts/gds/skill-lint.js', 'scripts/gds/run-routine.js',
360
+ 'scripts/gds/adr-namespace.js', 'scripts/gds/baseline-staleness.js',
361
+ 'scripts/gds/category-advisory-guard.js', 'scripts/gds/client-baseurl-guard.js',
362
+ 'scripts/gds/doc-cli-guard.js', 'scripts/gds/doc-comment-coverage.js',
363
+ 'scripts/gds/exec-path-guard.js', 'scripts/gds/fitness-lib.js',
364
+ 'scripts/gds/fitness-checks-error-envelope.js', 'scripts/gds/fitness-checks-identity.js',
365
+ 'scripts/gds/fitness-checks-packaging.js', 'scripts/gds/fitness-checks-route-pins.js',
366
+ 'scripts/gds/fitness-checks-vocabulary.js', 'scripts/gds/fitness-checks-write-validation.js',
367
+ 'scripts/gds/fitness-migration-contraction.js', 'scripts/gds/role-pack-guard.js',
368
+ 'scripts/gds/route-shadow-guard.js', 'scripts/gds/routine-schedule.js',
369
+ 'scripts/gds/seam-wiring-guard.js', 'scripts/gds/skill-preflight.js',
370
+ 'scripts/gds/test-path-guard.js', 'scripts/gds/version-literal-guard.js',
341
371
  ],
342
372
  // --- the METHODOLOGY surfaces: the instruction core an agent session actually
343
373
  // reads — the always-loaded root CLAUDE.md, the docs library it indexes, the
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.822'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.824'; // 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');
@@ -131,5 +131,34 @@ test('the live tree passes, and the gate list is not empty', () => {
131
131
  assert.equal(r.hardFail, false);
132
132
  });
133
133
 
134
+ // --- a FILENAME is not a category read (task 1003949) ----------------------
135
+ //
136
+ // One of the enforcement files is itself called category-advisory-guard.js, so the
137
+ // moment a decision surface lists the tooling BY PATH — which module-scope-map.js
138
+ // has to, in order to own it — this guard fired on its own name. Quoted paths
139
+ // ending in .js are blanked before the match. The point of these two tests is that
140
+ // the blanking is narrow: it must not become a way to hide a real read.
141
+
142
+ test('a quoted path to a file whose NAME contains "category" is not a violation', () => {
143
+ const { violations } = scanGates(['src/bongos/module-scope-map.js'], from({
144
+ 'src/bongos/module-scope-map.js':
145
+ 'const MAP = {\n'
146
+ + " devsecops: { globs: ['scripts/gds/category-advisory-guard.js', 'scripts/gds/fitness.js'] },\n"
147
+ + '};\n',
148
+ }));
149
+ assert.deepEqual(violations, [], 'owning the tooling by path is not branching on a category');
150
+ });
151
+
152
+ test('a real category read on the SAME LINE as a path is still refused', () => {
153
+ // Only the quoted path is blanked, not the line — so a read cannot ride along
154
+ // beside one.
155
+ const { violations } = scanGates(['modules/lifecycle/claim-feed.js'], from({
156
+ 'modules/lifecycle/claim-feed.js':
157
+ "const guard = require('./category-advisory-guard.js'); if (t.category_id === 3) return false;\n",
158
+ }));
159
+ assert.equal(violations.length, 1, 'the category_id read must still be caught');
160
+ assert.match(violations[0], /ADVISORY/);
161
+ });
162
+
134
163
  console.log(`\n${passed} passed, ${failed} failed`);
135
164
  process.exit(failed === 0 ? 0 : 1);
@@ -0,0 +1,200 @@
1
+ // tests/probe_cache.mjs — one answer, shared by the sessions that start together
2
+ // (task 1003689).
3
+ //
4
+ // THE MEASUREMENT THIS EXISTS FOR. SessionStart runs six hooks, three of which call the
5
+ // server. Four sessions opened within three seconds — one worktree per claim, the normal
6
+ // pattern here — and every hook in all four hit its 10-15s timeout, while the sessions
7
+ // immediately before and after ran clean at ~5s on identical config.
8
+ //
9
+ // A timed-out SessionStart hook emits NO LINE, so the truth-teller briefs vanish. Silence
10
+ // is indistinguishable from "nothing to report", which is the one thing these probes are
11
+ // built not to be.
12
+ //
13
+ // The cache must therefore fail toward DOING THE WORK, never toward silence: every error
14
+ // path below returns the producer's answer rather than nothing.
15
+
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import { execFileSync } from 'node:child_process';
19
+ import fs from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { createRequire } from 'node:module';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const pc = require('../scripts/gds/probe-cache.js');
27
+
28
+ // fileURLToPath, never URL.pathname (fitness check 30 / task 1002889): a %20 or a
29
+ // Windows drive slash makes require() throw at load, and CI's spaceless checkout
30
+ // cannot see it.
31
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const fresh = () => `k-${Math.random().toString(36).slice(2)}`;
33
+
34
+ test('the second asker does not repeat the call', async () => {
35
+ const k = fresh();
36
+ let calls = 0;
37
+ const produce = async () => { calls += 1; return `answer-${calls}`; };
38
+ const a = await pc.cached(k, produce, { ttlMs: 5000 });
39
+ const b = await pc.cached(k, produce, { ttlMs: 5000 });
40
+ assert.equal(calls, 1, 'the whole point: four sessions, one question');
41
+ assert.equal(a.from, 'fresh');
42
+ assert.equal(b.from, 'cache');
43
+ assert.equal(b.value, a.value);
44
+ });
45
+
46
+ test('an expired answer is asked again', async () => {
47
+ const k = fresh();
48
+ let calls = 0;
49
+ const produce = async () => { calls += 1; return calls; };
50
+ await pc.cached(k, produce, { ttlMs: 1 });
51
+ await new Promise((r) => setTimeout(r, 10));
52
+ const b = await pc.cached(k, produce, { ttlMs: 1 });
53
+ assert.equal(calls, 2, 'a cache that never expires is a lie, not a cache');
54
+ assert.equal(b.from, 'fresh');
55
+ });
56
+
57
+ test('a record from the FUTURE is treated as stale, not trusted forever', () => {
58
+ // A clock that jumped would otherwise pin a stale answer permanently.
59
+ assert.equal(pc.isFresh({ at: 5_000 }, 1_000, 4_000), false);
60
+ assert.equal(pc.isFresh({ at: 3_500 }, 1_000, 4_000), true);
61
+ assert.equal(pc.isFresh(null, 1_000, 4_000), false);
62
+ assert.equal(pc.isFresh({}, 1_000, 4_000), false);
63
+ });
64
+
65
+ test('two checkouts of the same instance do not read each other\'s answers', async () => {
66
+ const k = fresh();
67
+ const a = await pc.cached(k, async () => 'from-A', { ttlMs: 5000, scope: '/repo/a' });
68
+ const b = await pc.cached(k, async () => 'from-B', { ttlMs: 5000, scope: '/repo/b' });
69
+ assert.equal(a.value, 'from-A');
70
+ assert.equal(b.value, 'from-B', 'the scope is part of the key — a worktree must not inherit another\'s brief');
71
+ });
72
+
73
+ test('a producer that throws is NOT cached, and the error reaches the caller', async () => {
74
+ const k = fresh();
75
+ await assert.rejects(() => pc.cached(k, async () => { throw new Error('probe failed'); }, { ttlMs: 5000 }));
76
+ // and the next asker gets a real attempt, not a cached failure
77
+ const r = await pc.cached(k, async () => 'recovered', { ttlMs: 5000 });
78
+ assert.equal(r.value, 'recovered');
79
+ assert.equal(r.from, 'fresh', 'a failure must not be remembered as an answer');
80
+ });
81
+
82
+ // ---- the real scenario: four processes at once -----------------------------
83
+ //
84
+ // The unit cases above all run in ONE process, where a lock is trivially uncontended.
85
+ // The measured failure is four SEPARATE processes starting within three seconds on a
86
+ // cold cache — and the first cut of this cache did NOT fix it: with nothing cached and
87
+ // the lock held, every waiter fell through and made the call anyway, so all four still
88
+ // ran. This is the case that caught it.
89
+ test('four concurrent PROCESSES on a cold cache produce ONE call, and all four get the answer', async () => {
90
+ const counter = path.join(os.tmpdir(), `bongos-probe-test-${Date.now()}.txt`);
91
+ try { fs.rmSync(counter, { force: true }); } catch { /* not there */ }
92
+ execFileSync(process.execPath, [path.join(REPO_ROOT, 'scripts', 'gds', 'probe-cache.js'), '--clear'], { encoding: 'utf8' });
93
+
94
+ const child = `
95
+ const pc = require(${JSON.stringify(path.join(REPO_ROOT, 'scripts', 'gds', 'probe-cache.js'))});
96
+ const fs = require('fs');
97
+ (async () => {
98
+ const r = await pc.cached('probe-cache-concurrency-test', async () => {
99
+ fs.appendFileSync(${JSON.stringify(counter)}, 'ran\\n');
100
+ await new Promise((res) => setTimeout(res, 400));
101
+ return 'the-answer';
102
+ }, { ttlMs: 30000 });
103
+ process.stdout.write(r.value);
104
+ })();
105
+ `;
106
+
107
+ const { spawn } = await import('node:child_process');
108
+ const results = await Promise.all([0, 1, 2, 3].map(() => new Promise((resolve) => {
109
+ const p = spawn(process.execPath, ['-e', child]);
110
+ let out = '';
111
+ p.stdout.on('data', (d) => { out += d; });
112
+ p.on('close', () => resolve(out.trim()));
113
+ })));
114
+
115
+ const ran = fs.existsSync(counter) ? fs.readFileSync(counter, 'utf8').trim().split('\n').filter(Boolean).length : 0;
116
+ try { fs.rmSync(counter, { force: true }); } catch { /* best effort */ }
117
+
118
+ assert.equal(ran, 1, `the probe must run ONCE across four concurrent starts; it ran ${ran}`);
119
+ assert.deepEqual(results, ['the-answer', 'the-answer', 'the-answer', 'the-answer'],
120
+ 'and every session must still get its brief — trading a timeout for a silence would be no fix at all');
121
+ });
122
+
123
+ // ---- FOUR DIFFERENT WORKTREES (grade round 1) ------------------------------
124
+ //
125
+ // The test above spawns four processes from ONE directory, and it passed while the
126
+ // cache was keyed on process.cwd() — which meant the fix did nothing for the case it
127
+ // was built for. The measured failure is four sessions in FOUR DIFFERENT WORKTREES,
128
+ // one per claim, so four cwds hashed to four keys and nothing was ever shared.
129
+ //
130
+ // A test that does not model the scenario proves the wrong thing. This one runs each
131
+ // child from a different directory of the SAME checkout.
132
+ test('four processes in DIFFERENT worktrees of one checkout still share the answer', async () => {
133
+ const counter = path.join(os.tmpdir(), `bongos-probe-wt-${Date.now()}.txt`);
134
+ try { fs.rmSync(counter, { force: true }); } catch { /* not there */ }
135
+ execFileSync(process.execPath, [path.join(REPO_ROOT, 'scripts', 'gds', 'probe-cache.js'), '--clear'], { encoding: 'utf8' });
136
+
137
+ // Four real directories inside this checkout — different cwds, one git common dir,
138
+ // which is exactly the shape of four claim worktrees.
139
+ const cwds = [REPO_ROOT, path.join(REPO_ROOT, 'scripts'), path.join(REPO_ROOT, 'tests'), path.join(REPO_ROOT, 'docs')]
140
+ .filter((d) => fs.existsSync(d));
141
+ assert.equal(cwds.length, 4, 'need four real directories in this checkout to model four worktrees');
142
+
143
+ const child = `
144
+ const pc = require(${JSON.stringify(path.join(REPO_ROOT, 'scripts', 'gds', 'probe-cache.js'))});
145
+ const fs = require('fs');
146
+ (async () => {
147
+ const r = await pc.cached('probe-cache-worktree-test', async () => {
148
+ fs.appendFileSync(${JSON.stringify(counter)}, 'ran\\n');
149
+ await new Promise((res) => setTimeout(res, 400));
150
+ return 'shared';
151
+ }, { ttlMs: 30000 });
152
+ process.stdout.write(r.value);
153
+ })();
154
+ `;
155
+
156
+ const { spawn } = await import('node:child_process');
157
+ const results = await Promise.all(cwds.map((cwd) => new Promise((resolve) => {
158
+ const p = spawn(process.execPath, ['-e', child], { cwd });
159
+ let out = '';
160
+ p.stdout.on('data', (d) => { out += d; });
161
+ p.on('close', () => resolve(out.trim()));
162
+ })));
163
+
164
+ const ran = fs.existsSync(counter) ? fs.readFileSync(counter, 'utf8').trim().split('\n').filter(Boolean).length : 0;
165
+ try { fs.rmSync(counter, { force: true }); } catch { /* best effort */ }
166
+
167
+ assert.equal(ran, 1, `four DIFFERENT working directories must still share one answer; the probe ran ${ran} times`);
168
+ assert.deepEqual(results, ['shared', 'shared', 'shared', 'shared']);
169
+ });
170
+
171
+ test('the scope is the CHECKOUT, not the directory', () => {
172
+ // Every worktree of one checkout reports the same git common dir; two unrelated
173
+ // checkouts report different ones. That is the distinction that matters, because the
174
+ // probes ask one server about one repo.
175
+ const scope = pc.defaultScope();
176
+ assert.ok(scope && typeof scope === 'string');
177
+ assert.ok(/\.git$|\.git[\\/]/.test(scope) || scope === process.cwd(),
178
+ `expected a git common dir (or the documented cwd fallback), got ${scope}`);
179
+ });
180
+
181
+ test('clear() with no argument actually empties the cache', async () => {
182
+ // It used to take a scope and delete only when that was exactly null — so calling it
183
+ // with its own default deleted nothing while reporting success.
184
+ const k = fresh();
185
+ let calls = 0;
186
+ await pc.cached(k, async () => { calls += 1; return calls; }, { ttlMs: 60_000 });
187
+ pc.clear();
188
+ await pc.cached(k, async () => { calls += 1; return calls; }, { ttlMs: 60_000 });
189
+ assert.equal(calls, 2, 'after clear(), the next asker must do the work again');
190
+ });
191
+
192
+ test('the three SessionStart hooks that call the server all go through the cache', () => {
193
+ // The wiring is the deliverable; the cache alone changes nothing. These are the three
194
+ // that make network calls — the other three SessionStart hooks are local git.
195
+ for (const rel of ['scripts/gds/strand-watch.js', 'scripts/gds/deploy-divergence.js', 'scripts/gds/fetch-sound-prefs.js']) {
196
+ const src = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8');
197
+ assert.match(src, /require\('\.\/probe-cache\.js'\)/, `${rel} must share its answer with concurrent sessions`);
198
+ assert.match(src, /cached\(/, `${rel} must actually call cached(), not merely import it`);
199
+ }
200
+ });
@@ -0,0 +1,82 @@
1
+ // tests/scope_map_guard.mjs — the enforcement tooling owns itself (task 1003949).
2
+ //
3
+ // MODULE_SCOPE_MAP's devsecops key enumerates the enforcement tooling by exact path,
4
+ // and the house style since fitness.js hit its line ratchet is that every new check
5
+ // lands in its OWN file beside it. Those two facts disagree once per new check: the
6
+ // file appears, nobody adds it to the map, and scopeSubset() resolves it to no module
7
+ // territory — so a goal scoped to devsecops is denied out_of_scope for touching the
8
+ // very tooling it owns.
9
+ //
10
+ // The size of the drift is the argument for a guard rather than a longer list: the
11
+ // task that filed this named FOURTEEN unowned files, and there were twenty-two.
12
+ //
13
+ // Run: node tests/scope_map_guard.mjs
14
+
15
+ import { test } from 'node:test';
16
+ import assert from 'node:assert/strict';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const { checkDevsecopsOwnsItsChecks, siblingRequires } = require('../scripts/gds/scope-map-guard.js');
21
+
22
+ test('every file fitness.js requires is owned by some MODULE_SCOPE_MAP key', () => {
23
+ const r = checkDevsecopsOwnsItsChecks();
24
+ assert.equal(r.ok, true, r.violations.join('\n'));
25
+ assert.equal(r.hardFail, false);
26
+ });
27
+
28
+ test('siblingRequires extracts the real require shape, deduped and sorted', () => {
29
+ const src = [
30
+ "const a = require('./role-pack-guard.js').checkRolePacks,",
31
+ " b = require('./doc-cli-guard.js').checkDocNamesRealCli,",
32
+ " c = require('./role-pack-guard.js').other, // the same file twice",
33
+ "const d = require('node:path');",
34
+ "const e = require('../../src/module-api');",
35
+ ].join('\n');
36
+ assert.deepEqual(siblingRequires(src), [
37
+ 'scripts/gds/doc-cli-guard.js',
38
+ 'scripts/gds/role-pack-guard.js',
39
+ ], 'only ./sibling.js requires, deduped — not node: builtins and not cross-tree requires');
40
+ });
41
+
42
+ test('an EXTENSION-LESS require is extracted — the gap that shipped green', () => {
43
+ // Node resolves require('./run-routine') to run-routine.js, and fitness.js writes FOUR
44
+ // of its sibling requires that way. A pattern anchored on .js found none of them, so
45
+ // four check files stayed unowned while the guard reported the set clean — this guard
46
+ // committing the exact failure it exists to end.
47
+ assert.deepEqual(siblingRequires("require('./run-routine'); require('./skill-lint')"), [
48
+ 'scripts/gds/run-routine.js',
49
+ 'scripts/gds/skill-lint.js',
50
+ ]);
51
+ });
52
+
53
+ test('both spellings of ONE file collapse to a single entry', () => {
54
+ assert.deepEqual(siblingRequires("require('./a'); require('./a.js')"), ['scripts/gds/a.js']);
55
+ });
56
+
57
+ test('a hyphenated and a dotted filename both survive extraction', () => {
58
+ // fitness-checks-write-validation.js and the like: a character class that stopped at
59
+ // the first hyphen would silently shorten the set.
60
+ assert.deepEqual(
61
+ siblingRequires("require('./fitness-checks-write-validation.js'); require('./a.b.js')"),
62
+ ['scripts/gds/a.b.js', 'scripts/gds/fitness-checks-write-validation.js'],
63
+ );
64
+ });
65
+
66
+ test('an EMPTY scan is a hard failure, not a clean result', () => {
67
+ // This check's only output is "nothing is unowned", which an empty input satisfies
68
+ // for free. fitness.js is built out of sibling requires, so an empty set means the
69
+ // pattern broke — the failure mode that would otherwise look like success.
70
+ assert.deepEqual(siblingRequires(''), []);
71
+ assert.deepEqual(siblingRequires(null), []);
72
+ // and the check itself says so rather than passing: exercised by pointing the
73
+ // extraction at source with no sibling requires in it.
74
+ assert.equal(siblingRequires('const x = require("node:fs");').length, 0);
75
+ });
76
+
77
+ test('the PASS note carries the count, so the check cannot look green while doing nothing', () => {
78
+ const r = checkDevsecopsOwnsItsChecks();
79
+ assert.match(r.note, /\d+ file\(s\) required by/);
80
+ const n = Number(r.note.match(/^(\d+) file/)[1]);
81
+ assert.ok(n > 14, `expected more than the fourteen the filing task listed, saw ${n}`);
82
+ });
@@ -0,0 +1,162 @@
1
+ // tests/stranded_rows.mjs — a task whose work already landed, still sitting in
2
+ // ready/backlog (task 1003775).
3
+ //
4
+ // Three tasks closed in one 2026-08-20 session needed no work at all: the fix was
5
+ // already in main. Every one was found by a human reading code.
6
+ //
7
+ // This detector's whole risk is looking healthy. It prints nothing when the board is
8
+ // clean, and the board IS clean today — so "no output" is exactly what a completely
9
+ // broken scan produces too. These tests therefore lean on the rule itself, and on the
10
+ // historical case the idea names.
11
+
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const { authoredIds, scanLog, strandedRows, pageOpenRows, SEP } = require('../scripts/gds/stranded-rows.js');
18
+
19
+ test('a subject naming the task it did IS an authoring claim', () => {
20
+ assert.deepEqual([...authoredIds('fix(docs): self-healing /docs/openapi.json (task 1002200)')], ['1002200']);
21
+ });
22
+
23
+ test('a MENTION is not an authoring claim — the distinction the whole scope rests on', () => {
24
+ // This is the exact shape that made the supersession half undoable: the idea's
25
+ // author scanned 826 commits and got ~44 open tasks that merely mentioned an id.
26
+ assert.deepEqual([...authoredIds('1002792, 1002200, 1002571, 1002790, 1002795), per CLAUDE.md §12.')], []);
27
+ assert.deepEqual([...authoredIds('supersedes task 1002200 and should be revisited')], []);
28
+ assert.deepEqual([...authoredIds('see (task 1002200) for background')], [],
29
+ 'mid-line is a citation; only end-of-line is the authoring convention');
30
+ });
31
+
32
+ test('the claim is read from ANYWHERE in the message, not just the subject', () => {
33
+ // Task 1002200 landed on a cloud-agent branch whose own subject never named it. The
34
+ // `(task N)` line lives in the MERGE commit's body, where GitHub echoes the branch's
35
+ // subject. A subject-only scan misses the one case this detector exists for.
36
+ const mergeBody = [
37
+ // The owner handle is a placeholder on purpose: a fixture that hardcodes the
38
+ // founder's identity fails audit-authorship.sh (ADR 0016's no-hardcoded-Archon
39
+ // rule), and nothing here depends on WHOSE fork the branch came from.
40
+ 'Merge pull request #5 from an-owner/claude/api-docs-bongos-16a682',
41
+ '',
42
+ 'fix(docs): self-healing /docs/openapi.json — serve committed spec, else generate in-process (task 1002200)',
43
+ ].join('\n');
44
+ assert.deepEqual([...authoredIds(mergeBody)], ['1002200']);
45
+ });
46
+
47
+ test('scanLog keeps the FIRST sha that claimed an id', () => {
48
+ const raw = [
49
+ `${SEP}aaaaaaaa1111\nfix: later touch (task 1000001)`,
50
+ `${SEP}bbbbbbbb2222\nfix: the original (task 1000001)`,
51
+ ].join('\n');
52
+ const m = scanLog(raw);
53
+ assert.equal(m.get('1000001'), 'aaaaaaaa1111', 'git log is newest-first, so the first seen is the most recent claim');
54
+ });
55
+
56
+ test('a row whose work landed is stranded; one whose has not is left alone', () => {
57
+ const landed = new Map([['1000001', 'aaaaaaaa1111']]);
58
+ const rows = [{ id: '1000001', status: 'ready' }, { id: '1000002', status: 'backlog' }];
59
+ const out = strandedRows(rows, landed);
60
+ assert.equal(out.length, 1);
61
+ assert.deepEqual(out[0], { id: '1000001', status: 'ready', sha: 'aaaaaaaa' });
62
+ });
63
+
64
+ test('the report carries NO builder-authored text — ids and fixed vocabulary only', () => {
65
+ // A truth-teller printing a commit subject or a task title would put arbitrary
66
+ // builder prose into a session's context. The house rule for this family is ids and
67
+ // fixed vocabulary; `strandedRows` returns exactly three machine fields.
68
+ const out = strandedRows([{ id: '1000001', status: 'ready', title: 'DO NOT LEAK ME' }],
69
+ new Map([['1000001', 'aaaaaaaa1111']]));
70
+ assert.deepEqual(Object.keys(out[0]).sort(), ['id', 'sha', 'status']);
71
+ assert.ok(!JSON.stringify(out).includes('DO NOT LEAK'));
72
+ });
73
+
74
+ test('an empty scan is a BROKEN scan — it must not read as a clean board', () => {
75
+ // The detector is silent when healthy, so a pattern that stops matching looks
76
+ // exactly like good news. scanLog over real-shaped input must be non-empty, and the
77
+ // IO wrapper throws on an empty map (fail-open at the --line boundary, loud
78
+ // otherwise).
79
+ assert.equal(scanLog('').size, 0, 'nothing in, nothing out — which is why the caller checks');
80
+ assert.ok(scanLog(`${SEP}aaaaaaaa1111\nfix: something (task 1000001)`).size > 0);
81
+ });
82
+
83
+ // ---- the record separator (grade round 1) ---------------------------------
84
+ //
85
+ // The separator is the word COMMIT wrapped in U+0001. Those bytes are INVISIBLE in a
86
+ // source listing, so the first reviewer read it as a bare 'COMMIT' and flagged — quite
87
+ // reasonably — that a commit body containing that word would split a record in half.
88
+ // It would. This pins that it does not.
89
+
90
+ test('a commit body containing the bare word COMMIT does not split a record', () => {
91
+ const raw = `${SEP}aaaaaaaa1111\nfix COMMIT-hook validation (task 1000001)`;
92
+ const m = scanLog(raw);
93
+ assert.equal(m.size, 1, 'one commit in, one record out');
94
+ assert.equal(m.get('1000001'), 'aaaaaaaa1111');
95
+ });
96
+
97
+ test('the separator really is control-wrapped, not the bare word', () => {
98
+ assert.equal(SEP.charCodeAt(0), 1);
99
+ assert.equal(SEP.charCodeAt(SEP.length - 1), 1);
100
+ assert.notEqual(SEP, 'COMMIT');
101
+ });
102
+
103
+ // ---- the paging walk (grade round 1) --------------------------------------
104
+ //
105
+ // This was the only untested IO in the file, and off-by-one paging is exactly the kind
106
+ // of logic that silently returns a SHORT list — which here means 'nothing is stranded',
107
+ // the most comfortable wrong answer this tool can give.
108
+
109
+ const page = (tasks, limit) => ({ ok: true, data: { tasks }, limit });
110
+ const row = (id) => ({ id: String(id), status: 'ready' });
111
+
112
+ test('a full page is followed by another request; a short page ends it', async () => {
113
+ const asked = [];
114
+ const rows = await pageOpenRows(async ({ status, limit, offset }) => {
115
+ asked.push(`${status}@${offset}`);
116
+ if (status !== 'ready') return page([], limit);
117
+ return offset === 0 ? page([row(1), row(2)], 2) : page([row(3)], 2);
118
+ }, { statuses: ['ready', 'backlog'], pageSize: 2 });
119
+ assert.deepEqual(rows.map((r) => r.id), ['1', '2', '3']);
120
+ assert.deepEqual(asked, ['ready@0', 'ready@2', 'backlog@0'], 'a full page must be followed by another request');
121
+ });
122
+
123
+ test('the offset advances by what was RECEIVED, not by what was asked for', async () => {
124
+ // The route clamps `limit`. Advancing by the requested size would skip rows.
125
+ const asked = [];
126
+ await pageOpenRows(async ({ limit, offset }) => {
127
+ asked.push(offset);
128
+ return asked.length === 1 ? page([row(1)], 1) : page([], 1);
129
+ }, { statuses: ['ready'], pageSize: 500 });
130
+ assert.deepEqual(asked, [0, 1], 'asked for 500, got 1, so the next offset is 1');
131
+ });
132
+
133
+ test('a failed page THROWS — a partial board must never read as clean', async () => {
134
+ await assert.rejects(
135
+ () => pageOpenRows(async () => ({ ok: false, status: 500 }), { statuses: ['ready'] }),
136
+ /could not list ready tasks/,
137
+ );
138
+ });
139
+
140
+ test('hitting the page cap THROWS rather than silently truncating', async () => {
141
+ // A detector that quietly stops early reports a clean board, which is the one
142
+ // outcome this tool must never fake.
143
+ await assert.rejects(
144
+ () => pageOpenRows(async ({ limit }) => page([row(1)], 1), { statuses: ['ready'], pageSize: 1, maxPages: 3 }),
145
+ /refusing to report a partial board as clean/,
146
+ );
147
+ });
148
+
149
+ test('both open statuses are walked — a backlog strand counts too', async () => {
150
+ const seen = [];
151
+ await pageOpenRows(async ({ status, limit }) => { seen.push(status); return page([], limit); });
152
+ assert.deepEqual(seen, ['ready', 'backlog']);
153
+ });
154
+
155
+ test('the live repo actually produces claims — the rule has not drifted', () => {
156
+ const { execFileSync } = require('node:child_process');
157
+ const raw = execFileSync('git', ['log', 'origin/main', `--pretty=${SEP}%H%n%B`, '-400'],
158
+ { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
159
+ const m = scanLog(raw);
160
+ assert.ok(m.size > 20,
161
+ `only ${m.size} authoring claims in the last 400 commits — the convention or the pattern has moved, and this detector is now inert`);
162
+ });