@bongos/core 1.19.823 → 1.19.825

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.823",
6
- "core_contract": "1.19.823",
7
- "source_commit": "2dbaf2b0a9d49393d3f3daf6f501a09087b8a156",
5
+ "core_version": "1.19.825",
6
+ "core_contract": "1.19.825",
7
+ "source_commit": "cb729764661db914505acde16e83bf6db16e47fb",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T11:55:48.898Z",
9
+ "built_at": "2026-09-17T13:27:19.562Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 490,
12
+ "docs_redacted": 491,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2230,
14
+ "functional_verbatim": 2234,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2746,
20
- "tree_sha256": "6b21d5cba0536e95777730af1de9c226f7d38a89997a4cb586847a39748e7f37",
19
+ "file_count": 2751,
20
+ "tree_sha256": "d080ea1d5501cd02f6af8cde89d25b58442a1abf7d282379bbf683df5b00ee33",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -1979,10 +1979,15 @@
1979
1979
  "mode": "0000644",
1980
1980
  "sha256": "8fcac086f276e57b59ba635cdf34600bf78bfb69d80177d43065dfc6776b0611"
1981
1981
  },
1982
+ {
1983
+ "path": "docs/adr/0299-the-nod-queue-stays-scoped-to-routed-filings.md",
1984
+ "mode": "0000644",
1985
+ "sha256": "f52bf2568d02d5cf8589dc394dc76cbf0ea3a7ba193275f300e320c2ab64a51c"
1986
+ },
1982
1987
  {
1983
1988
  "path": "docs/adr/README.md",
1984
1989
  "mode": "0000644",
1985
- "sha256": "14c4e2e04bba4a0438375364a88e41eca44983ab0fb2c3ecd13bf0190f0ad151"
1990
+ "sha256": "0b4e0cd78a8bd6ae3d41c0d010634dfdc29ecd4dca1f8b49127791d1b4cd7450"
1986
1991
  },
1987
1992
  {
1988
1993
  "path": "docs/api-reference.md",
@@ -2537,7 +2542,7 @@
2537
2542
  {
2538
2543
  "path": "docs/file-map.md",
2539
2544
  "mode": "0000644",
2540
- "sha256": "5560da3dad24d04ba192d4cbcab9f7e669ebd6ead35bde8d92a222a763289a36"
2545
+ "sha256": "4cacfe54b3e4f80aa1cd7a4b190bf673da867f99d5101cc1c7870ee13021c008"
2541
2546
  },
2542
2547
  {
2543
2548
  "path": "docs/handoff-template.md",
@@ -2547,7 +2552,7 @@
2547
2552
  {
2548
2553
  "path": "docs/module-api-changelog.md",
2549
2554
  "mode": "0000644",
2550
- "sha256": "5236a1550b3f87d767e2f59479b3f2ef8859621f0c57bbd7a054b8d056c1a2ef"
2555
+ "sha256": "bc4fb1f646f00a5ac5870be4468a5bcb94c1bc19d6c69eea48c54d63f5d0eb70"
2551
2556
  },
2552
2557
  {
2553
2558
  "path": "docs/modules-contract.md",
@@ -5422,7 +5427,7 @@
5422
5427
  {
5423
5428
  "path": "modules/ideas/inbox.js",
5424
5429
  "mode": "0000644",
5425
- "sha256": "36481a27a5921e3eb930268bf96bccd178259d3f7cce67b19b0dc4246f7d9bf1"
5430
+ "sha256": "683e2234893c9814c2ecec490099c140e273e764e993707bb1cc4c5c9d1baed3"
5426
5431
  },
5427
5432
  {
5428
5433
  "path": "modules/ideas/migrations/ideas_001_grades.sql",
@@ -7812,12 +7817,12 @@
7812
7817
  {
7813
7818
  "path": "package-lock.json",
7814
7819
  "mode": "0000644",
7815
- "sha256": "ade8e48efd7ae7eaeb9f51472f731940ca866a1f0a8917de7dadab245428908b"
7820
+ "sha256": "2a7a81e9c97e32cc83b22d74eef060a6829d4e6847dfabaa9a851ebff576b151"
7816
7821
  },
7817
7822
  {
7818
7823
  "path": "package.json",
7819
7824
  "mode": "0000644",
7820
- "sha256": "bd706beddc637ac4587a99436be805e327d7392306525124874ea39092fa6149"
7825
+ "sha256": "67e42f1c6c167a0857846b43f877395adc67ce48ae72c31cc4709240906e6432"
7821
7826
  },
7822
7827
  {
7823
7828
  "path": "public-docs/index.html",
@@ -8092,7 +8097,7 @@
8092
8097
  {
8093
8098
  "path": "scripts/gds/category-advisory-guard.js",
8094
8099
  "mode": "0000644",
8095
- "sha256": "a71ac076299b9e37bf8e0a6ff8761f8ba3059271b2596b6ee8941fc24533357d"
8100
+ "sha256": "19fd98214001bb83ed69789653519d820ece8d94fea4451503ecf85ff4285f32"
8096
8101
  },
8097
8102
  {
8098
8103
  "path": "scripts/gds/cf-tunnel.js",
@@ -8357,7 +8362,7 @@
8357
8362
  {
8358
8363
  "path": "scripts/gds/fitness.js",
8359
8364
  "mode": "0000644",
8360
- "sha256": "8725f7a8dcdd6445e8d2379e24c3985624b1889cc67be055cce8365b312457d2"
8365
+ "sha256": "aa1a26c0b2871cd6e49b619254ce2b11d477780da8f4b037c38cb16b0ccd68fc"
8361
8366
  },
8362
8367
  {
8363
8368
  "path": "scripts/gds/gate-review.js",
@@ -8869,6 +8874,11 @@
8869
8874
  "mode": "0000644",
8870
8875
  "sha256": "c82513a9333724c77ba3a99667726861c292d576ea493f2144e6051dd78c47b6"
8871
8876
  },
8877
+ {
8878
+ "path": "scripts/gds/scope-map-guard.js",
8879
+ "mode": "0000644",
8880
+ "sha256": "75f0791698a36e48ec232d2285e6f80dac6d64c2a23a23a26cd54c1c05883c3b"
8881
+ },
8872
8882
  {
8873
8883
  "path": "scripts/gds/seam-wiring-guard.js",
8874
8884
  "mode": "0000644",
@@ -9239,6 +9249,11 @@
9239
9249
  "mode": "0000644",
9240
9250
  "sha256": "2a9959bd2e45046dadcda9982998a5b7163cef00b5d719ff299314daa5409770"
9241
9251
  },
9252
+ {
9253
+ "path": "scripts/gds/stranded-rows.js",
9254
+ "mode": "0000644",
9255
+ "sha256": "573e608a90b5822f9bf77f203d54cf8a80ef9e96bdc51b22e5dec1226da3ee98"
9256
+ },
9242
9257
  {
9243
9258
  "path": "scripts/gds/stray-unit-sweep.sh",
9244
9259
  "mode": "0000644",
@@ -9537,7 +9552,7 @@
9537
9552
  {
9538
9553
  "path": "src/bongos/module-scope-map.js",
9539
9554
  "mode": "0000644",
9540
- "sha256": "8d7b749edca694b4c9d8a563a4334c7fe3fc7be64c520c3d14c3b63febff132a"
9555
+ "sha256": "7f393fc515fe751d5296ce0a574e0d3e40695c0346933a0d8b9c30ed6ae39a1b"
9541
9556
  },
9542
9557
  {
9543
9558
  "path": "src/bongos/module-submissions.js",
@@ -9727,7 +9742,7 @@
9727
9742
  {
9728
9743
  "path": "src/module-api.js",
9729
9744
  "mode": "0000644",
9730
- "sha256": "2ac366c05863ab86d0bdb5b3df3bf6f605e4edb0182a977c83c781cf503bf5b7"
9745
+ "sha256": "8f04e8b51652109c0e95424fbc9801ed170b2bc899f91793ac827152e653c07a"
9731
9746
  },
9732
9747
  {
9733
9748
  "path": "src/module-loader/catalog.js",
@@ -10372,7 +10387,7 @@
10372
10387
  {
10373
10388
  "path": "tests/category_advisory_guard.mjs",
10374
10389
  "mode": "0000644",
10375
- "sha256": "8fd7b066c264ddaeccaa2dfa9a3c1fe6204a943933a1161c6a235f45b40ab121"
10390
+ "sha256": "0fdb3c4e34fadc0f6697eb3ee3083d6739273e4df3c5693b7c3693cf0dfb1ef1"
10376
10391
  },
10377
10392
  {
10378
10393
  "path": "tests/cf_ruleset_preflight.mjs",
@@ -12879,6 +12894,11 @@
12879
12894
  "mode": "0000644",
12880
12895
  "sha256": "8d516fe0fb398d8322e014773901449b6909439ab1567c90d8354b49f211cdbf"
12881
12896
  },
12897
+ {
12898
+ "path": "tests/scope_map_guard.mjs",
12899
+ "mode": "0000644",
12900
+ "sha256": "278e334a945d151c225d849b51464a3dffc8f0bb0dcb1d39b72d12bae57efd4f"
12901
+ },
12882
12902
  {
12883
12903
  "path": "tests/scouting_owner_access_and_reach.mjs",
12884
12904
  "mode": "0000644",
@@ -13389,6 +13409,11 @@
13389
13409
  "mode": "0000644",
13390
13410
  "sha256": "bba2bde5b7a4a25ccddff58fb2fde0ef84ef67224e63819232221a461234a30f"
13391
13411
  },
13412
+ {
13413
+ "path": "tests/stranded_rows.mjs",
13414
+ "mode": "0000644",
13415
+ "sha256": "63070400896c81e90e53e6b9480beb55eafee854d7d54971e201cee70056d487"
13416
+ },
13392
13417
  {
13393
13418
  "path": "tests/streak.mjs",
13394
13419
  "mode": "0000644",
@@ -0,0 +1,65 @@
1
+ # ADR 0299 — The nod queue stays scoped to routed filings; the backlog walk is the widening
2
+
3
+ **Status:** accepted · **Date:** 2026-09-17 · **Task:** [task 1003748](https://cloudbongos.com/builders#/task/1003748) (goal 1000089)
4
+
5
+ ## Context
6
+
7
+ `listRoutedAwaitingNod` (`modules/ideas/inbox.js`) feeds the "Reported work awaiting a
8
+ nod" slice of the Home attention card, and it filters `WHERE l.origin = 'route'`. On
9
+ 2026-09-08 that read returned 11 rows while 232 tasks sat at `backlog`. The task asked
10
+ whether a queue that omits ~95% of the un-nodded backlog is honestly named, or should
11
+ grow a second slice for un-routed rows. It noted two things that bound any widening:
12
+ dep-gated rows wait on a trigger (migration 163), not a person, so they must not be
13
+ shown as needing a nod; and [task 1003746](https://cloudbongos.com/builders#/task/1003746)
14
+ (`/backlog-review`) might make the question moot, so decide after that exists.
15
+
16
+ It exists now. Live numbers on 2026-09-17, from `node scripts/gds/backlog-review.js`:
17
+
18
+ | Bucket | Rows |
19
+ |---|---|
20
+ | waiting on a dependency (auto-promotes on ship) | 109 |
21
+ | spikes (backlog by convention, migration 020) | 2 |
22
+ | stranded (dep abandoned — trigger can never fire) | 1 |
23
+ | awaiting a nod (only a human gates it) | 3 |
24
+ | **total backlog** | **115** |
25
+
26
+ `GET /inbox/awaiting-nod` returned `count: 0`. `GET /inbox/rotting` returns 200 (the
27
+ route-shadowing bug the task mentioned no longer reproduces), with no rows past the
28
+ 30-day timer.
29
+
30
+ ## Decision
31
+
32
+ **Do not widen the nod queue or the card.** `origin='route'` stays the predicate.
33
+
34
+ 1. **The premise that ~220 rows had no path to a human is gone.** Of the rows the card
35
+ omits, 111 are correctly *not* a person's decision (109 dep-gated, 2 spikes). The
36
+ remaining 4 (3 nod + 1 stranded) are exactly what `/backlog-review` walks, goal by
37
+ goal, with the dependency predicate the task said any widening would need
38
+ (`include=deps` → per-edge `satisfied`). The widening the task asked for already
39
+ shipped — as a cadence skill, not a card slice.
40
+ 2. **The name is honest.** The card says *"Reported work awaiting a nod"* — reported
41
+ is the routed population. It never claimed to be the whole backlog.
42
+ 3. **A second slice would duplicate a partition.** The four-bucket split lives in
43
+ `backlog-review.js`; a card slice would need a second copy of the
44
+ stranded/dep-gated/spike rules in SQL, free to disagree with the walk. The rot
45
+ card already carries the same kind of warning about its verb list duplicated there.
46
+ 4. **The card is a push surface for filers.** A routed filing is someone waiting on an
47
+ answer; a planning-seeded backlog row is not. Mixing them dilutes the one signal
48
+ the card exists to carry (task 1003066, owner interview Q3).
49
+
50
+ ## Revisit when
51
+
52
+ - The *awaiting a nod* bucket of `/backlog-review` (not dep-gated, not spike, not routed)
53
+ grows past what one daily walk clears — then a Home count linking to the walk earns
54
+ its place. Show a **count and link**, not rows, and derive it from the same partition
55
+ the walk uses.
56
+ - `/backlog-review` stops being run — then the rot timer is again the only pull, and
57
+ this decision's first reason no longer holds.
58
+
59
+ ## Rejected
60
+
61
+ - A second "un-routed backlog" slice on the card (duplicates the partition; dilutes the
62
+ filer signal).
63
+ - Renaming the queue (the current copy already says *reported*).
64
+ - Dropping the origin filter and relying on `has_unshipped_dep` (would pull planning rows
65
+ and spikes into a surface built for filers).
@@ -390,3 +390,4 @@ This keeps the decision history honest and traceable.
390
390
  | 0296 | [**A role speciality is data over three existing surfaces, not a fourth mechanism** ([task 1003855](https://cloudbongos.com/builders#/task/1003855), goal 1000095). A craft says HOW you work; nothing said what you work ON. A speciality is a named bundle of knowledge, skills and an interaction contract sitting under a craft — an ideator with an Economics speciality. **Decision: it is DATA, not an ADR 0083 code module** — a subject-matter expert authors one, and requiring them to ship a package puts the mechanism out of reach of exactly the person it exists for. It layers over three surfaces that already exist (discipline-modes.json, the `behavior_prefs.interaction` contract path, the manifest contribution seam); only the knowledge bundle is new. The contract **rides the existing GET /me -> Conductor injection path rather than adding a second one**, and is CRAFT-SCOPED: the hook injects only the contract matching the claim in hand (which needed `t.discipline` on the active-claims read), and with no claim there is no craft and **nothing is injected** — silence beats guessing which expertise applies. Training is two-fold per the owner's 2026-09-17 ruling: documents they supplied, plus learnings captured from sessions worked in it (pointing at existing `learnings` rows, never a second corpus). The corpus is **inventoried, never inlined** — a document may run to 100k chars — so `speciality_documents` joins the recall ingester, because otherwise the contract's "search it with /recall" is a lie whose failure is silent. **`sellable` is recorded with no storefront behind it**: ADR 0107 defers the marketplace and explicitly rejected building one, so the flag captures the owner's decision when they make it and grants nothing — a deliberate partial implementation, recorded rather than silently made. Index scoping errs toward UNDER-sharing: only a public speciality's documents are corpus-wide, because `project` names a set the index cannot express.](0296-a-role-speciality-is-data-over-three-existing-surfaces.md) | roles / modules |
391
391
  | 0297 | [**A task’s creator may retire it, and that is the ownership axis’s first route** ([task 1002443](https://cloudbongos.com/builders#/task/1002443), goal 1000095 — owner request 2026-07-25: *"the creator of that task should be able to abandon that task directly inside of the UI"*). Attribution itself shipped earlier and elsewhere (`core_227`, [task 1003278](https://cloudbongos.com/builders#/task/1003278)); what had not moved is that **retiring a task was a rank power and nothing else** — `task.abandon` floors at `metic` (ADR 0152), so a sub-Metic builder could file a task and then could not withdraw their own proposal. The model already had the vocabulary and had never used it: ADR 0151 §3 defines an **ownership axis** where a `scope:'own'` key authorizes nothing until a handler supplies the row's owner, R100 made `requirePermission` refuse such a key outright so `.own` can never degrade into `.any`, and it recorded that **zero call sites passed one**. This is that axis’s first real consumer. **Decision: two authorities, only one rank-shaped** — `task.abandon` (metic, any task) OR new `task.abandon.own` (xenos, a task you created, while it is still backlog/ready/blocked). Route becomes `requireBuilder` + an in-handler decision (the `POST /goals/:id/members` shape), pin `metic+archon`→`any-builder` so it now catches an accidental **tightening** as loudly as a gate removal. **The two facts resolve in two places on purpose:** the RANK fact is request-scoped and resolves before the transaction (uncached, ADR 0016); the OWNER fact is a column that can change under us, so `created_by` joins `abandonTask`’s existing `FOR UPDATE` SELECT and a new optional `authorize` callback decides under that lock. Reading `created_by` in the route would be a TOCTOU window; resolving permissions inside the transaction would hold a row lock across a second pool acquire. The decision is a PURE module (`abandon-authz.js`, the `goal-route-authz.js` precedent) so every branch is pinned DB-free and the hall hint and server verdict provably read one rule. **A creator’s window stops at the grader** — completed/confirmed stay cross-owner, because past the grader a retire is a ledger correction, not an author changing their mind; nothing is subtracted from `task.abandon`, pinned by a test. `counterpart: ['task.abandon']` is declared not derived, verb `act` not `manage` — `manage` would silently dominate every future narrower `task.*.own` key (the verb-blind escalation task 1003195 closed). **A null creator belongs to nobody and that is load-bearing:** ~1,357 pre-`core_227` tasks plus seed/restock/discord vectors are unattributed, so the owner comparison is the government resolver’s own — injected, never re-implemented, since a duplicated ownership comparison is how one quietly widens — and one slip would open every historical task to a xenos grant at once. Fail-closed throughout: a missing `government` port, a throwing resolver, and anything but an explicit `{ok:true}` all deny, and `authorize` runs BEFORE the idempotent already-abandoned return and the shipped/active-claim guards so an unauthorized caller cannot probe a row through a 200. No guard removed. Does NOT widen edit/promote/dependency (still Metic), does not touch `task.merge`, and does not backfill attribution — the control simply does not appear on tasks predating `core_227`.](0297-a-tasks-creator-may-retire-it.md) | authority / lifecycle / ownership |
392
392
  | 0298 | [**The grader gets a reader lens: advisory, scoped to copy-bearing diffs, and it waits on a written voice** ([task 1003937](https://cloudbongos.com/builders#/task/1003937), goal 1000095 — Working area 6; owner decision 2026-09-17). Every artist review the cascade files opened with *“REVIEW THE GRADER … coach it”*, and it could not be followed: the v4 panel runs narc, quality, hacker and efficiency — completion, code quality, security, runtime cost — and not one of them reads a sentence for whether it is true, clear, in the project’s voice or worth a person’s attention. Four consecutive reviews hit the same wall and all four graded ships deservedly PASSED on the axes that were measured; the gate is not lenient, it is not pointed at this. `grader-prompt.js` already injects the craft’s written responsibility, but only when `discipline === ‘artist’`, so the unjudged case is copy shipped by someone whose craft is not the copy. **Decision: a fifth ADVISORY `reader` worker, on three settled terms.** It runs only where `modules/lifecycle/cascade.js`’s existing `hasCopyOrVisualWork` says the ship is copy-bearing — the same predicate the after-the-fact review uses, so the two can never disagree about coverage, and every other ship costs exactly what it costs today. It records `issues[]` and a `copy_score` and can never verdict `fail` or drag the panel to `concerns`, because [ADR 0241](0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md) put the artist’s judgement on the DEPLOY and off the ship path; gating would need that ADR amended first. And it is NOT BUILT until the instance writes a voice guide — today the whole written standard is one dash rule in `DESIGN.md`, a tagline, a registry of which 1,860 strings exist, and one responsibility sentence, so a lens built now would record taste and call it a finding. Until the reader exists, STEP 1 asks the artist to WRITE DOWN what the grade could not see rather than to coach a lens that is not listening — followable today, and the corpus both the guide and the lens get written from. Nothing in the rubric, the active panel or any existing worker changes.](0298-the-reader-lens-is-advisory-and-waits-on-a-voice-guide.md) | grading / the artist’s loop / voice |
393
+ | 0299 | [**The nod queue stays scoped to routed filings; the backlog walk is the widening** ([task 1003748](https://cloudbongos.com/builders#/task/1003748), goal 1000089). `listRoutedAwaitingNod` filters `origin='route'`, and on 2026-09-08 showed 11 of 232 backlog rows. Re-measured after `/backlog-review` (task 1003746) shipped: 115 backlog rows = 109 dep-gated + 2 spikes (not a person's decision) + 1 stranded + 3 awaiting a nod, all four walked by `/backlog-review`. **Decision: do not widen the card.** The omitted population already has a walkable review with the dependency predicate any widening needed; the copy says *reported* work, so the name is honest; a card slice would be a second copy of the walk's four-bucket partition. Revisit if the un-routed nod bucket outgrows a daily walk (then a count + link, derived from the same partition) or if the walk stops being run.](0299-the-nod-queue-stays-scoped-to-routed-filings.md) | ideas / backlog / attention |
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
 
@@ -2119,6 +2119,10 @@ is load-bearing: the script throws rather than guess if it is missing, and
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
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 35218228051. (task 1002620)
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)
2125
+ 1.19.825 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2126
+ landed since 1.19.824 with no explicit bump. run 35227106979. (task 1002620)
2123
2127
  ---------------------------------------------------------------------------
2124
2128
  ```
@@ -891,7 +891,9 @@ async function sourceIdeaForTask(taskId) {
891
891
  //
892
892
  // Scoped by the lineage row (origin='route'), not by status alone — a task can be
893
893
  // at backlog for a dozen unrelated reasons, and this queue is specifically "a
894
- // person reported this and the system is waiting on a human". It reads the FILER
894
+ // person reported this and the system is waiting on a human". Widening it to the
895
+ // rest of the backlog was weighed and declined (ADR 0299): /backlog-review walks
896
+ // that population with the dependency split this read lacks. It reads the FILER
895
897
  // too (captured_by), because Q3's copy calls the pending state "awaiting a nod"
896
898
  // and shows kind + who filed it.
897
899
  //
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.823",
3
+ "version": "1.19.825",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.823",
9
+ "version": "1.19.825",
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.823",
3
+ "version": "1.19.825",
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)}\`. `
@@ -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
 
@@ -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 };
@@ -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.823'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.825'; // 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,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
+ });