@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 +44 -19
- package/docs/adr/0299-the-nod-queue-stays-scoped-to-routed-filings.md +65 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +5 -1
- package/modules/ideas/inbox.js +3 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/category-advisory-guard.js +13 -1
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/scope-map-guard.js +120 -0
- package/scripts/gds/stranded-rows.js +200 -0
- package/src/bongos/module-scope-map.js +30 -0
- package/src/module-api.js +1 -1
- package/tests/category_advisory_guard.mjs +29 -0
- package/tests/scope_map_guard.mjs +82 -0
- package/tests/stranded_rows.mjs +162 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
9
|
+
"built_at": "2026-09-17T13:27:19.562Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 491,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2234,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
7820
|
+
"sha256": "2a7a81e9c97e32cc83b22d74eef060a6829d4e6847dfabaa9a851ebff576b151"
|
|
7816
7821
|
},
|
|
7817
7822
|
{
|
|
7818
7823
|
"path": "package.json",
|
|
7819
7824
|
"mode": "0000644",
|
|
7820
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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).
|
package/docs/adr/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
```
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -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".
|
|
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.
|
|
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.
|
|
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.
|
|
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)}\`. `
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -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.
|
|
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
|
+
});
|