@bongos/core 1.19.893 → 1.19.894

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.893",
6
- "core_contract": "1.19.893",
7
- "source_commit": "99f7250a5cc9e39e16d1869281d1fd2660a466ad",
5
+ "core_version": "1.19.894",
6
+ "core_contract": "1.19.894",
7
+ "source_commit": "a62eeb34dfae8175f0ff3586f38460f044b6bc2b",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-19T22:26:15.995Z",
9
+ "built_at": "2026-09-19T22:40:59.707Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 498,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2272,
14
+ "functional_verbatim": 2274,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2796,
20
- "tree_sha256": "1ffd95b16bdf5a9986639f6ea5452817996a270f6274f1195d3330fc915fe900",
19
+ "file_count": 2798,
20
+ "tree_sha256": "22cc76ae6f3b3fe04849fa744d2d7b43b859f159332e23bee866c374a82b15a7",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -242,7 +242,7 @@
242
242
  {
243
243
  "path": ".claude/skills/status/SKILL.md",
244
244
  "mode": "0000644",
245
- "sha256": "38cad84f41dbebc255a379a102db5c27d9fa5f0173dd08ea0ca0ac19365a47c7"
245
+ "sha256": "2613bd488e5ec83681effe72b40d7b9c9152bfae401c9ca484c6b84f6d349373"
246
246
  },
247
247
  {
248
248
  "path": ".claude/skills/strand-fix/SKILL.md",
@@ -2612,7 +2612,7 @@
2612
2612
  {
2613
2613
  "path": "docs/module-api-changelog.md",
2614
2614
  "mode": "0000644",
2615
- "sha256": "adada9ba37f56cd06bbb497540a86710002658b4160a9c0e31f5a3ae36fb1ddb"
2615
+ "sha256": "6fb5bea1a5ba196287323307cc7272e869defbaa91d4f5ef522a92436064847f"
2616
2616
  },
2617
2617
  {
2618
2618
  "path": "docs/modules-contract.md",
@@ -2682,7 +2682,7 @@
2682
2682
  {
2683
2683
  "path": "docs/onboarding/slash-commands.md",
2684
2684
  "mode": "0000644",
2685
- "sha256": "748df9bec7434093e32f70915f151b9cc6ac67beebbe226b997d81eba327de31"
2685
+ "sha256": "92ce9c4de763330c4ac877a1ba79d0b11d88797c711b3517adebe8604bdf59ea"
2686
2686
  },
2687
2687
  {
2688
2688
  "path": "docs/packs/artist.md",
@@ -5409,6 +5409,11 @@
5409
5409
  "mode": "0000644",
5410
5410
  "sha256": "107dec0deb790b7439da27761235631ab79a29a6cfcea58db9a57460e04df1bd"
5411
5411
  },
5412
+ {
5413
+ "path": "modules/lifecycle/criterion-ref.js",
5414
+ "mode": "0000644",
5415
+ "sha256": "c79a34e577df14c4c54d426e60789c5e30bc2e81fd4005a283f21936b9f0aba7"
5416
+ },
5412
5417
  {
5413
5418
  "path": "modules/lifecycle/criterion-suggest.js",
5414
5419
  "mode": "0000644",
@@ -5432,7 +5437,7 @@
5432
5437
  {
5433
5438
  "path": "modules/lifecycle/db-deps-criteria.js",
5434
5439
  "mode": "0000644",
5435
- "sha256": "76ffab3052ce0b48d0b78451a2e34d5cd3c5300410f0856b4a6667cbe708ba2b"
5440
+ "sha256": "e921d48860af27f836252fa7e44e1bdae25be5b940c25e9272f1cf368805d132"
5436
5441
  },
5437
5442
  {
5438
5443
  "path": "modules/lifecycle/db-executor.js",
@@ -7712,12 +7717,12 @@
7712
7717
  {
7713
7718
  "path": "package-lock.json",
7714
7719
  "mode": "0000644",
7715
- "sha256": "4439b27f55c654d0758f704b1225fb25509949f048e9292b5c54af9c047c59a2"
7720
+ "sha256": "5a7c7abe37561006b741b013bd299d254bac0267a5da69e05c15bd5df7f804ba"
7716
7721
  },
7717
7722
  {
7718
7723
  "path": "package.json",
7719
7724
  "mode": "0000644",
7720
- "sha256": "3c2e6c91f071304b4464dfa845786eb5037631d1dff4cdad6c9560201dfc9bb6"
7725
+ "sha256": "78a3e6e4967ac1ffcd9fef0be950247551d3c739763d30cfc98aad8dd0dd5168"
7721
7726
  },
7722
7727
  {
7723
7728
  "path": "public-docs/index.html",
@@ -8022,7 +8027,7 @@
8022
8027
  {
8023
8028
  "path": "scripts/gds/build-cli-package.js",
8024
8029
  "mode": "0000644",
8025
- "sha256": "ca0566c509fb1ea81e7c874e2676bbcb965803509c0289c577bc8f15791e6f5c"
8030
+ "sha256": "fcb5bdfe3341b177018a080c2cd7295bad3b1772696e61bef6154ec1e5767300"
8026
8031
  },
8027
8032
  {
8028
8033
  "path": "scripts/gds/bump-version.js",
@@ -9212,7 +9217,7 @@
9212
9217
  {
9213
9218
  "path": "scripts/gds/status.js",
9214
9219
  "mode": "0000644",
9215
- "sha256": "191b49e9ccbcfc7b855d834535ad5e9de0970d6e91126c7a0591e7790d196a89"
9220
+ "sha256": "cdf231a4804965b5cd4ca6218893573319d318b51356bf87c2cddeeccd523dd2"
9216
9221
  },
9217
9222
  {
9218
9223
  "path": "scripts/gds/strand-watch.js",
@@ -9722,7 +9727,7 @@
9722
9727
  {
9723
9728
  "path": "src/module-api.js",
9724
9729
  "mode": "0000644",
9725
- "sha256": "f5e01142842ee5a6d8c2cb8870eb8bd41e9de63d4e3621a07a69fc2c095597c7"
9730
+ "sha256": "61c6a9f0360af4f9f936786eacffe6b231db6f0f0339b7ba206511136823d2ee"
9726
9731
  },
9727
9732
  {
9728
9733
  "path": "src/module-loader/catalog.js",
@@ -10719,6 +10724,11 @@
10719
10724
  "mode": "0000644",
10720
10725
  "sha256": "80ce711abf62a78dc4f36b513b852a5396173a2ff50e5deab2460531d17911e4"
10721
10726
  },
10727
+ {
10728
+ "path": "tests/criterion_ref_status.mjs",
10729
+ "mode": "0000644",
10730
+ "sha256": "a441876b05922aeebb5fa122a679a836ccf322e8d96e8a28acf0c7c076283741"
10731
+ },
10722
10732
  {
10723
10733
  "path": "tests/criterion_suggest.mjs",
10724
10734
  "mode": "0000644",
@@ -13167,7 +13177,7 @@
13167
13177
  {
13168
13178
  "path": "tests/session-cards.mjs",
13169
13179
  "mode": "0000644",
13170
- "sha256": "9035468dcf1b6ef0bc0aaf5a665196a0eab321563ab84aa1133b1446ff1ce9cd"
13180
+ "sha256": "62dbeb24b93c9680ee2cf4427ca64da2532588cdfb75deaa329c1cfc9c122085"
13171
13181
  },
13172
13182
  {
13173
13183
  "path": "tests/session_506_regression.mjs",
@@ -12,19 +12,19 @@ Rolls up a version's done-when criteria → gating tasks → live statuses → w
12
12
 
13
13
  ## How to render it
14
14
 
15
- Delivery is deterministic (task 1288, same methodology as `/builder-start`). The **card-delivery hook** (`.claude/hooks/session-cards.js`) runs `status.js` for you on a `/status` (or "what's left for C8" / "status of GDS-V4") prompt — pulling any version + `Cn` out of the prompt — and injects the **finished card + a one-step directive** into your context, a block beginning `[otb card — the status card below was pre-rendered …]`.
15
+ Delivery is deterministic (task 1288, same methodology as `/builder-start`). The **card-delivery hook** (`.claude/hooks/session-cards.js`) runs `status.js` for you on a `/status` (or "what's left for C8" / "status of GDS-V4") prompt — pulling any version + criterion ref (a slug or a positional `Cn`) out of the prompt — and injects the **finished card + a one-step directive** into your context, a block beginning `[otb card — the status card below was pre-rendered …]`.
16
16
 
17
17
  1. **Normal path — follow the injected directive.** If that `[otb card …]` block is present, do exactly what it says: `show_widget` with the fenced HTML if you have `mcp__visualize` (call `mcp__visualize__read_me` once first, silently), otherwise output the fenced markdown **verbatim**. Render **once** — don't run `status.js`, don't lead with your own headline, don't rebuild the table, don't add a recommendation or recap. The card now owns the headline, the remaining-task list, and the unattributed-tasks note (this replaces the old freeform "interpret + relay" summary).
18
18
 
19
19
  2. **Fallback — no directive present** (hook disabled/failed, or an older checkout). Run it yourself:
20
- - **If you have `show_widget`**: `node scripts/gds/status.js --widget [--version ID] [Cn]` → render the HTML verbatim with `title: "status_card"` (call `read_me` once first).
21
- - **No widget renderer**: `node scripts/gds/status.js [--version ID] [Cn]` → print its stdout verbatim.
22
- - `Cn` is positional (C8 = the 8th criterion); `--version` defaults to the live internal version; `--json` is the machine interface; `--hook` is the envelope the hook consumes — don't render those for the user unless asked.
20
+ - **If you have `show_widget`**: `node scripts/gds/status.js --widget [--version ID] [--criterion REF]` → render the HTML verbatim with `title: "status_card"` (call `read_me` once first).
21
+ - **No widget renderer**: `node scripts/gds/status.js [--version ID] [--criterion REF]` → print its stdout verbatim.
22
+ - A criterion is named by its **slug** (`/status wa6-kernel-and-packs`) or, as an affordance, by position (`/status C8`); `--criterion REF` is the exact form for either. `--version` defaults to the live internal version; `--json` is the machine interface; `--hook` is the envelope the hook consumes — don't render those for the user unless asked.
23
23
 
24
24
  ## Constraints
25
25
 
26
26
  - **Read-only.** This skill never claims, ships, or mutates anything. If the user then wants to act on a remaining task, hand off to `/builder-claim N`.
27
- - **`Cn` is positional**, computed live from the criteria ordering — so it stays correct even if criteria are added or reordered. Don't hardcode a Cn→slug mapping.
27
+ - **The SLUG is the criterion's identity; `Cn` is a position, and positions move** (task 1004069). `Cn` is `ROW_NUMBER()` over a reorderable `sort_order`, recomputed on every read: adding `wa6-noise-limited` on 2026-09-19 moved `<redacted>` from C11 to C13, and everything still quoting C11 silently began naming a different criterion. The write path (`resolveCriterionIds`) **refuses** a positional ref on a multi-goal version for that reason. So: **quote the slug** in anything durable — a note, a task description, a handoff, a card you expect someone to read tomorrow. Repeat a `Cn` back only alongside the slug it resolved to; `status.js` prints that line for you whenever a drill-in came in positionally.
28
28
  - If `--version X` returns no criteria, say so plainly (that version may not have done-when criteria seeded) rather than implying 0% done.
29
29
 
30
30
  ## Files this skill touches
@@ -2260,5 +2260,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2260
2260
  landed since 1.19.891 with no explicit bump. run 35471773244. (task 1002620)
2261
2261
  1.19.893 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2262
2262
  landed since 1.19.892 with no explicit bump. run 35473299957. (task 1002620)
2263
+ 1.19.894 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2264
+ landed since 1.19.893 with no explicit bump. run 35473999345. (task 1002620)
2263
2265
  ---------------------------------------------------------------------------
2264
2266
  ```
@@ -32,7 +32,7 @@ Every one of these talks to the build system. They're grouped by when you'd reac
32
32
  | `/builder-release` | Cancel a claim — the task returns to `ready`, no credits |
33
33
  | `/builder-stage` | Preview your work-in-progress on your own sandbox before shipping |
34
34
  | `/builder-end` | Close out the session safely — resolve the claim, seal the log |
35
- | `/status` | Version / criterion progress — "what's left for C8?" |
35
+ | `/status` | Version / criterion progress — "what's left for `wa6-kernel-and-packs`?" (a criterion's slug is its name; `C8` is its current position and moves) |
36
36
  | `/recall` | Search the docs + project knowledge before re-solving something |
37
37
 
38
38
  ### Your account and your machine
@@ -0,0 +1,107 @@
1
+ // modules/lifecycle/criterion-ref.js — the ONE vocabulary for naming a done-when
2
+ // criterion, shared by the write path and the read path (task 1004069).
3
+ //
4
+ // WHY THIS FILE EXISTS. `parseCriterionRef` lived inside db-deps-criteria.js,
5
+ // which requires ../../src/module-api and therefore a database pool. The read
6
+ // side — scripts/gds/status.js, which is public, unauthenticated, and shipped in
7
+ // the @cloudbongos/cli package with no server present — could not require it, so
8
+ // it grew its own parser: `Number(ref.slice(1))`, positional only. The system
9
+ // ended up PRINTING an identifier its own write path refuses as unsafe, because
10
+ // the two sides never shared a definition of what a ref is. Carving the pure
11
+ // half out is what stops that from happening again; this file requires nothing,
12
+ // touches no I/O, and both sides import it.
13
+ //
14
+ // THE VOCABULARY. A ref is one of three things:
15
+ // - a positional "Cn" token → { kind: 'cnum', n } C8 = the 8th criterion by
16
+ // (sort_order, criterion_id) WITHIN one version.
17
+ // - bare digits → { kind: 'id', id } done_when_criteria.id.
18
+ // - anything else → { kind: 'slug', slug } the criterion_id slug.
19
+ //
20
+ // WHICH ONE IS THE IDENTITY. The slug. `Cn` is a presentation ordinal computed
21
+ // live (ROW_NUMBER over a user-reorderable sort_order), so inserting a criterion
22
+ // renumbers every criterion after it, version-wide, with no record: creating
23
+ // `wa6-noise-limited` on 2026-09-19 moved `wa10-execution-integrity` from C11 to
24
+ // C13 while every doc, card and click handler quoting C11 kept pointing at a row
25
+ // that had moved. `resolveCriterionIds` already REFUSES a positional ref on any
26
+ // version holding more than one goal, for the same reason (a positional ref
27
+ // mislinked all 15 tasks of goal 1000071 — learning 1000188). So `Cn` is an
28
+ // affordance for a human reading a numbered list, never the thing to quote or
29
+ // store. Do not "fix" the instability by persisting cnum as a column: that is a
30
+ // second source of truth over a reorderable ordering, and it drifts the first
31
+ // time someone reorders.
32
+
33
+ 'use strict';
34
+
35
+ // PURE: classify a single criterion ref. Returns { kind:'cnum', n } |
36
+ // { kind:'id', id } | { kind:'slug', slug }.
37
+ // A leading C/c + digits is positional; bare digits are a numeric id; anything
38
+ // else is treated as a criterion_id slug. Anchored on purpose — prose like
39
+ // "see C8 above" is a slug, never a silent position match.
40
+
41
+ function parseCriterionRef(raw) {
42
+ const ref = String(raw == null ? '' : raw).trim();
43
+ const cMatch = /^C(\d+)$/i.exec(ref);
44
+ if (cMatch) return { kind: 'cnum', n: Number(cMatch[1]) };
45
+ if (/^\d+$/.test(ref)) return { kind: 'id', id: Number(ref) };
46
+ return { kind: 'slug', slug: ref };
47
+ }
48
+
49
+ // A version id as this project writes them: an uppercase prefix (possibly none)
50
+ // followed by V + digits — V1, PMS-V2, GDS-V4, BONGOS-V2, CB-V1.
51
+ //
52
+ // Used ONLY to tell a bare CLI token apart from a criterion ref, because
53
+ // `status.js BONGOS-V2` and `status.js wa6-kernel-and-packs` are both legal and
54
+ // positional. Deliberately case-SENSITIVE: every version id in the versions
55
+ // table is uppercase and every criterion_id slug is lowercase kebab, so casing
56
+ // is the one signal that does not require a round trip — and a lowercase
57
+ // `bongos-v2` was never a working version id anyway (the progress endpoint
58
+ // path-matches the id verbatim). A caller that does not want to be guessed at
59
+ // passes the exact form: `--version ID` and `--criterion REF` skip this entirely.
60
+ const VERSION_ID_RE = /^(?:[A-Z][A-Z0-9]*-)*V\d+(?:\.\d+)?$/;
61
+
62
+ function looksLikeVersionId(token) {
63
+ return VERSION_ID_RE.test(String(token == null ? '' : token).trim());
64
+ }
65
+
66
+ // Resolve a ref against criteria rows already fetched for ONE version. Rows need
67
+ // `cnum`, `criterion_id`, and (for the numeric form) `id` — the shape
68
+ // GET /versions/:id/progress returns.
69
+ //
70
+ // Version scope is the CALLER's job and is not optional: `Cn` numbers
71
+ // independently per version (BONGOS-V1 C8 is `money-one-unit`, BONGOS-V2 C8 is
72
+ // `wa6-kernel-and-packs`), so resolving a bare `Cn` without a version in hand
73
+ // answers a different question than the one asked. Passing rows from a single
74
+ // version's payload is what makes that structurally impossible here.
75
+ //
76
+ // Returns { criterion, kind } on a hit, or { criterion: null, kind } on a miss.
77
+ // Slug matching is case-insensitive so a slug pasted from a title still lands.
78
+ function resolveCriterionIn(criteria, ref) {
79
+ const rows = Array.isArray(criteria) ? criteria : [];
80
+ const p = parseCriterionRef(ref);
81
+ let criterion = null;
82
+ if (p.kind === 'cnum') {
83
+ criterion = rows.find((c) => Number(c.cnum) === p.n) || null;
84
+ } else if (p.kind === 'id') {
85
+ criterion = rows.find((c) => String(c.id) === String(p.id)) || null;
86
+ } else {
87
+ const want = p.slug.toLowerCase();
88
+ criterion = rows.find((c) => String(c.criterion_id || '').toLowerCase() === want) || null;
89
+ }
90
+ return { criterion, kind: p.kind };
91
+ }
92
+
93
+ // How a criterion is NAMED in output: the slug leads, the position trails in
94
+ // parentheses as the affordance it is. Never "C8" alone — that is the form that
95
+ // goes stale between the printing and the reading.
96
+ function criterionLabel(c) {
97
+ if (!c) return '';
98
+ const slug = String(c.criterion_id || '');
99
+ return c.cnum == null ? slug : `${slug} (C${c.cnum})`;
100
+ }
101
+
102
+ module.exports = {
103
+ parseCriterionRef,
104
+ looksLikeVersionId,
105
+ resolveCriterionIn,
106
+ criterionLabel,
107
+ };
@@ -278,17 +278,17 @@ async function removeDependency(fromKind, fromId, toKind, toId) {
278
278
  // ---------------------------------------------------------------------------
279
279
 
280
280
  // PURE: classify a single criterion ref. Returns { kind:'cnum', n } |
281
- // { kind:'id', id } | { kind:'slug', slug }. Exported for unit tests (no DB).
282
- // A leading C/c + digits is positional; bare digits are a numeric id; anything
283
- // else is treated as a criterion_id slug.
284
-
285
- function parseCriterionRef(raw) {
286
- const ref = String(raw == null ? '' : raw).trim();
287
- const cMatch = /^C(\d+)$/i.exec(ref);
288
- if (cMatch) return { kind: 'cnum', n: Number(cMatch[1]) };
289
- if (/^\d+$/.test(ref)) return { kind: 'id', id: Number(ref) };
290
- return { kind: 'slug', slug: ref };
291
- }
281
+ // { kind:'id', id } | { kind:'slug', slug }. Re-exported below for unit tests.
282
+ //
283
+ // The DEFINITION lives in ./criterion-ref.js, which requires nothing at all —
284
+ // because the READ side needs the same vocabulary and cannot pay for a pool.
285
+ // scripts/gds/status.js is public, unauthenticated and shipped inside the
286
+ // server-free @cloudbongos/cli, so while this classifier sat behind
287
+ // ../../src/module-api it was unreachable from there and the CLI grew its own
288
+ // positional-only parser (`Number(ref.slice(1))`). The system ended up PRINTING
289
+ // and REQUIRING the very ref shape resolveCriterionIds below refuses as unsafe
290
+ // (task 1004069). One vocabulary, two importers, no second definition.
291
+ const { parseCriterionRef } = require('./criterion-ref.js');
292
292
 
293
293
  // Resolve an array of refs to done_when_criteria.id values within a version.
294
294
  // Returns { ids: [unique numeric ids, first-appearance order], unresolved: [refs],
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.893",
3
+ "version": "1.19.894",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.893",
9
+ "version": "1.19.894",
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.893",
3
+ "version": "1.19.894",
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",
@@ -85,6 +85,11 @@ const FILES = [
85
85
  // Module files the above touch. Each imports nothing from the kernel at load time.
86
86
  'modules/lifecycle/task-classifier.js',
87
87
  'modules/lifecycle/task-visuals.js',
88
+ // The criterion-ref vocabulary status.js resolves a `Cn` or a slug through
89
+ // (task 1004069). It is in modules/ because the WRITE path uses the same
90
+ // definition; it requires nothing, which is the whole reason it could be
91
+ // carved out of db-deps-criteria.js (that file needs a pool) and shipped here.
92
+ 'modules/lifecycle/criterion-ref.js',
88
93
  'modules/economy/reward-policy.js',
89
94
 
90
95
  // The only core files in the package: config/branding readers and one constant table.
@@ -8,11 +8,26 @@
8
8
  // GET /api/bongos/versions/:id/progress.
9
9
  //
10
10
  // Usage:
11
- // node scripts/gds/status.js # all criteria, default version
12
- // node scripts/gds/status.js C8 # drill into one criterion
13
- // node scripts/gds/status.js --version V1 # another version
11
+ // node scripts/gds/status.js # all criteria, default version
12
+ // node scripts/gds/status.js wa6-kernel-and-packs # drill in by SLUG (the identity)
13
+ // node scripts/gds/status.js C8 # …or by position (an affordance)
14
+ // node scripts/gds/status.js --criterion C8 # exact form, no guessing
15
+ // node scripts/gds/status.js --version V1 # another version
14
16
  // node scripts/gds/status.js --version GDS-V3 C8
15
- // node scripts/gds/status.js --json # machine-readable
17
+ // node scripts/gds/status.js --json # machine-readable
18
+ //
19
+ // WHICH NAME IS THE IDENTITY (task 1004069). The SLUG. `Cn` is a presentation
20
+ // ordinal recomputed live from a reorderable sort_order, so inserting one
21
+ // criterion renumbers every criterion after it, version-wide, silently — and
22
+ // the write path (`resolveCriterionIds`) already REFUSES a positional ref on any
23
+ // multi-goal version for exactly that reason. This CLI used to print `Cn` as
24
+ // each row's identity and accept nothing else, so the one documented way to
25
+ // drill in was the one form the rest of the system calls unsafe. Now the slug
26
+ // leads in every output mode and BOTH forms resolve on input; a positional ref
27
+ // answers with the slug it landed on, so the reader can quote the stable name.
28
+ // The ref vocabulary itself is ../../modules/lifecycle/criterion-ref.js — one
29
+ // definition, shared with the write path, which is what stops the two drifting
30
+ // apart again.
16
31
  //
17
32
  // Read-only and UNAUTHENTICATED (the endpoint is public, same posture as
18
33
  // /versions/:id/done-when), so it works before /builder-setup too.
@@ -22,6 +37,11 @@
22
37
  const { API_BASE, cliClient, assertInstanceMatch, cliExit } = require('./cli-lib');
23
38
  const { resolveEnv } = require('../../src/instance-config');
24
39
  const { resolveDefaultVersion } = require('./version-select.js');
40
+ const {
41
+ looksLikeVersionId,
42
+ resolveCriterionIn,
43
+ criterionLabel,
44
+ } = require('../../modules/lifecycle/criterion-ref.js');
25
45
 
26
46
  // The active internal version is RESOLVED, never named here. Override with
27
47
  // <PREFIX>_STATUS_DEFAULT_VERSION or --version.
@@ -53,20 +73,32 @@ function parseArgs(argv) {
53
73
  if (a === '--version' || a === '-v') {
54
74
  version = args[++i];
55
75
  if (version == null) return { error: '--version requires a value' };
76
+ } else if (a === '--criterion' || a === '-c') {
77
+ criterion = args[++i];
78
+ if (criterion == null) return { error: '--criterion requires a value' };
56
79
  } else if (a === '--json') json = true;
57
80
  else if (a === '--widget') widget = true;
58
81
  else if (a === '--hook') hook = true;
59
82
  else if (a === '--help' || a === '-h') return { help: true };
60
- else if (/^[Cc]\d+$/.test(a)) criterion = a.toUpperCase();
61
83
  else if (a.startsWith('-')) return { error: `unknown flag: ${a}` };
62
- else if (!version) version = a; // bare token → version id
63
- else if (!criterion) criterion = a.toUpperCase();
84
+ // A bare token is a VERSION only if it is shaped like one (V1, GDS-V4,
85
+ // BONGOS-V2); everything else is a criterion ref, so a slug can be typed
86
+ // where only `Cn` used to fit. Shape, not order: `status.js
87
+ // wa6-kernel-and-packs` used to be swallowed as a version id and 404 the
88
+ // progress fetch. `--version` / `--criterion` are the exact forms that skip
89
+ // this test entirely, for a caller who will not be guessed at.
90
+ else if (!version && looksLikeVersionId(a)) version = a;
91
+ else if (!criterion) criterion = a;
92
+ else if (!version) version = a;
64
93
  else return { error: `unexpected argument: ${a}` };
65
94
  }
66
95
  // No `versionExplicit` companion flag: `version !== null` already IS that
67
96
  // fact, and resolveVersion returns `defaulted`/`source` for anyone who needs
68
97
  // to act on it. A second field carrying the same truth is one that can drift.
69
- return { version: version || null, criterion, json, widget, hook };
98
+ // `criterion` is kept VERBATIM — never uppercased, which is what made a slug
99
+ // unrepresentable here. Case-folding belongs to the resolver, which matches
100
+ // slugs case-insensitively and `Cn` case-insensitively too.
101
+ return { version: version || null, criterion: criterion || null, json, widget, hook };
70
102
  }
71
103
 
72
104
  // Resolve which version this invocation is about. Precedence: an explicit
@@ -95,6 +127,18 @@ function defaultedVersionNote(r) {
95
127
  return `(no version given — showing ${r.version}, ${why}. Pass --version to pick another.)`;
96
128
  }
97
129
 
130
+ // One line naming the STABLE name a positional ref landed on, so whatever the
131
+ // reader quotes next survives the next criterion anyone inserts. `Cn` is
132
+ // recomputed from sort_order on every read: creating `wa6-noise-limited` moved
133
+ // `wa10-execution-integrity` from C11 to C13, and every note, doc and click
134
+ // handler still saying C11 now points at a different criterion. Empty when the
135
+ // user already named the slug — they are holding the durable form.
136
+ function positionalRefNote(c, kind) {
137
+ if (!c || kind !== 'cnum') return '';
138
+ return `(C${c.cnum} is a position, recomputed whenever a criterion is added or reordered.`
139
+ + ` The stable name is \`${c.criterion_id}\` — quote that.)`;
140
+ }
141
+
98
142
  async function fetchProgress(version) {
99
143
  let res;
100
144
  try {
@@ -145,6 +189,8 @@ function bar(shipped, total, width = 10) {
145
189
  return '█'.repeat(filled) + '░'.repeat(width - filled);
146
190
  }
147
191
 
192
+ // The POSITION column only. It is deliberately narrow and deliberately not the
193
+ // row's name: the criterion_id beside it is what a reader should carry away.
148
194
  function clabel(c) {
149
195
  return `C${c.cnum}`.padEnd(3);
150
196
  }
@@ -160,6 +206,13 @@ function clabel(c) {
160
206
  // or the ship card's `.cc-*`.
161
207
  const sesc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
162
208
 
209
+ // A value going inside a single-quoted JS string inside an HTML attribute needs
210
+ // BOTH escapes, in that order. Task ids are digits so the existing handlers get
211
+ // away with `sesc` alone; a criterion_id is free text from the DB, and an
212
+ // apostrophe in one would close the sendPrompt argument early and break every
213
+ // row's click. Escape for JS first, then for the attribute.
214
+ const jsq = (s) => sesc(String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/'/g, "\\'"));
215
+
163
216
  const STATUS_WIDGET_STYLE = `<style>
164
217
  .st-sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
165
218
  .st-card{background:var(--color-background-primary);border:0.5px solid var(--color-border-tertiary);border-radius:var(--border-radius-lg);padding:1rem 1.25rem;font-family:var(--font-sans);}
@@ -207,9 +260,9 @@ function renderStatusWidget(data, criterion) {
207
260
  : `<div class="st-crit">No tasks linked to this criterion yet.</div>`;
208
261
  const claimHint = c.tasks.some((t) => t.status === 'ready') ? ' · click a ready task to claim it' : '';
209
262
  return `${STATUS_WIDGET_STYLE}
210
- <h2 class="st-sr">${sesc(data.version_id)} criterion C${c.cnum} ${c.satisfied ? 'is satisfied' : 'is not yet satisfied'}: ${c.counts.shipped} of ${c.counts.total} gating tasks shipped.</h2>
263
+ <h2 class="st-sr">${sesc(data.version_id)} criterion ${sesc(criterionLabel(c))} ${c.satisfied ? 'is satisfied' : 'is not yet satisfied'}: ${c.counts.shipped} of ${c.counts.total} gating tasks shipped.</h2>
211
264
  <div class="st-card">
212
- <div class="st-head"><span class="st-title"><i class="ti ti-checklist" aria-hidden="true"></i>${sesc(data.version_id)} · C${c.cnum}</span><span class="st-meta">${c.satisfied ? '<span class="st-ok">satisfied</span>' : 'not satisfied'} · ${c.counts.shipped}/${c.counts.total} shipped</span></div>
265
+ <div class="st-head"><span class="st-title"><i class="ti ti-checklist" aria-hidden="true"></i>${sesc(data.version_id)} · ${sesc(criterionLabel(c))}</span><span class="st-meta">${c.satisfied ? '<span class="st-ok">satisfied</span>' : 'not satisfied'} · ${c.counts.shipped}/${c.counts.total} shipped</span></div>
213
266
  <div class="st-crit">${sesc(c.criterion_md.replace(/\*\*/g, '').slice(0, 160))}</div>
214
267
  ${body}
215
268
  <div class="st-foot">remaining: ${sesc(remainingSummary(c.remaining))}${claimHint}</div>
@@ -219,8 +272,13 @@ function renderStatusWidget(data, criterion) {
219
272
  // Version → Goals → Criteria (ADR 0086 §2 / BV1.R58). Render each criterion
220
273
  // row under its goal's header. Fall back to a flat list if the rollup carries
221
274
  // no goals (older payload).
275
+ // The click sends the SLUG, never the position. A rendered card outlives the
276
+ // ordering it was rendered from: every handler here used to carry `/status
277
+ // C11`, so one criterion inserted anywhere above turned every drill-in on a
278
+ // card already in someone's transcript into a different criterion, silently.
279
+ // The slug survives an insert, a reorder, and a card read tomorrow.
222
280
  const critRow = (c) =>
223
- `<tr class="st-tr" role="button" tabindex="0" title="Drill into C${c.cnum}: ${sesc(c.criterion_id)}" onclick="sendPrompt('/status C${c.cnum}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();sendPrompt('/status C${c.cnum}')}"><td class="st-td st-ok st-nowrap">${c.satisfied ? '✓' : ''}</td><td class="st-td st-id">C${c.cnum}</td><td class="st-td st-ellip" title="${sesc(c.criterion_id)}">${sesc(c.criterion_id)}</td><td class="st-td st-nowrap">${widgetBar(c.counts.shipped, c.counts.total)}${c.counts.shipped}/${c.counts.total}</td><td class="st-td st-ellip st-sub">${c.remaining.length ? sesc(remainingSummary(c.remaining)) : '—'}</td></tr>`;
281
+ `<tr class="st-tr" role="button" tabindex="0" title="Drill into ${sesc(c.criterion_id)} (C${c.cnum})" onclick="sendPrompt('/status ${jsq(c.criterion_id)}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();sendPrompt('/status ${jsq(c.criterion_id)}')}"><td class="st-td st-ok st-nowrap">${c.satisfied ? '✓' : ''}</td><td class="st-td st-ellip" title="${sesc(c.criterion_id)}">${sesc(c.criterion_id)}</td><td class="st-td st-id">C${c.cnum}</td><td class="st-td st-nowrap">${widgetBar(c.counts.shipped, c.counts.total)}${c.counts.shipped}/${c.counts.total}</td><td class="st-td st-ellip st-sub">${c.remaining.length ? sesc(remainingSummary(c.remaining)) : '—'}</td></tr>`;
224
282
  const goals = (data.goals && data.goals.length) ? data.goals : null;
225
283
  const rows = goals
226
284
  ? goals.map((g) => {
@@ -237,7 +295,7 @@ function renderStatusWidget(data, criterion) {
237
295
  <h2 class="st-sr">${sesc(data.version_id)} status: ${satisfied} of ${data.criteria.length} done-when criteria satisfied across ${goals ? data.goals_total : 0} goal(s). Click a criterion to drill in.</h2>
238
296
  <div class="st-card">
239
297
  <div class="st-head"><span class="st-title"><i class="ti ti-checklist" aria-hidden="true"></i>${sesc(data.version_id)} status</span><span class="st-meta">${satisfied}/${data.criteria.length} criteria${goalMeta}</span></div>
240
- <table class="st-table"><colgroup><col style="width:24px;"><col style="width:44px;"><col><col style="width:96px;"><col style="width:120px;"></colgroup><thead><tr class="st-thead"><th class="st-th"></th><th class="st-th">#</th><th class="st-th">Criterion</th><th class="st-th">Shipped</th><th class="st-th">Remaining</th></tr></thead><tbody>${rows}</tbody></table>
298
+ <table class="st-table"><colgroup><col style="width:24px;"><col><col style="width:44px;"><col style="width:96px;"><col style="width:120px;"></colgroup><thead><tr class="st-thead"><th class="st-th"></th><th class="st-th">Criterion</th><th class="st-th">Pos</th><th class="st-th">Shipped</th><th class="st-th">Remaining</th></tr></thead><tbody>${rows}</tbody></table>
241
299
  ${foot}
242
300
  </div>`;
243
301
  }
@@ -248,7 +306,7 @@ function renderStatusMarkdown(data, criterion) {
248
306
  const lines = [];
249
307
  if (criterion) {
250
308
  const c = criterion;
251
- lines.push(`**/status · ${data.version_id} · C${c.cnum}** — ${c.satisfied ? 'satisfied ✓' : 'not satisfied'}`);
309
+ lines.push(`**/status · ${data.version_id} · ${criterionLabel(c)}** — ${c.satisfied ? 'satisfied ✓' : 'not satisfied'}`);
252
310
  lines.push(`_${c.criterion_md.replace(/\*\*/g, '').slice(0, 160)}_`);
253
311
  lines.push('');
254
312
  if (c.tasks.length) {
@@ -272,8 +330,10 @@ function renderStatusMarkdown(data, criterion) {
272
330
  lines.push('');
273
331
  // Version → Goals → Criteria (ADR 0086 §2): a subheading per goal, its criteria
274
332
  // beneath. Falls back to a single flat table if the payload carries no goals.
275
- const critRow = (c) => `| ${c.satisfied ? '✓' : ''} | C${c.cnum} | ${c.criterion_id} | ${bar(c.counts.shipped, c.counts.total)} ${c.counts.shipped}/${c.counts.total} | ${c.remaining.length ? remainingSummary(c.remaining) : '—'} |`;
276
- const tableHeader = () => { lines.push('| | # | Criterion | Shipped | Remaining |'); lines.push('|---|---|---|---|---|'); };
333
+ // Slug first, position second — the column a reader copies out of a card is
334
+ // the one that still means the same thing next week.
335
+ const critRow = (c) => `| ${c.satisfied ? '✓' : ''} | ${c.criterion_id} | C${c.cnum} | ${bar(c.counts.shipped, c.counts.total)} ${c.counts.shipped}/${c.counts.total} | ${c.remaining.length ? remainingSummary(c.remaining) : '—'} |`;
336
+ const tableHeader = () => { lines.push('| | Criterion | Pos | Shipped | Remaining |'); lines.push('|---|---|---|---|---|'); };
277
337
  if (goals) {
278
338
  for (const g of goals) {
279
339
  lines.push(`**🎯 ${g.title}** — ${g.status === 'achieved' ? 'achieved ✓' : g.status}${g.criteria.length ? '' : ' · no criteria yet'}`);
@@ -289,7 +349,10 @@ function renderStatusMarkdown(data, criterion) {
289
349
  lines.push(`_${data.unattributed_tasks} task(s) not linked to any criterion._`);
290
350
  }
291
351
  lines.push('');
292
- lines.push('→ Drill into one: `/status C8`');
352
+ // The hint quotes a REAL slug off this payload rather than a frozen `C8`,
353
+ // which is both an example of the stable form and immune to going stale.
354
+ const example = data.criteria.length ? data.criteria[0].criterion_id : 'wa6-kernel-and-packs';
355
+ lines.push(`→ Drill into one: \`/status ${example}\``);
293
356
  return lines.join('\n');
294
357
  }
295
358
 
@@ -313,7 +376,7 @@ function printAll(data) {
313
376
  // Version → Goals → Criteria (ADR 0086 §2): a heading per goal, criteria beneath.
314
377
  const printCrit = (c) => {
315
378
  const flag = c.satisfied ? '✓' : ' ';
316
- const line = `${flag} ${clabel(c)} ${c.criterion_id.padEnd(34)} ${bar(c.counts.shipped, c.counts.total)} `
379
+ const line = `${flag} ${c.criterion_id.padEnd(34)} ${clabel(c)} ${bar(c.counts.shipped, c.counts.total)} `
317
380
  + `${c.counts.shipped}/${c.counts.total} shipped`;
318
381
  const rem = c.remaining.length ? ` remaining: ${remainingSummary(c.remaining)}` : '';
319
382
  console.log(line + rem);
@@ -328,13 +391,13 @@ function printAll(data) {
328
391
  for (const c of data.criteria) printCrit(c);
329
392
  }
330
393
  if (data.unattributed_tasks > 0) {
331
- console.log(`\n${data.unattributed_tasks} task(s) in ${data.version_id} not linked to any criterion (run /status <Cn> to drill in).`);
394
+ console.log(`\n${data.unattributed_tasks} task(s) in ${data.version_id} not linked to any criterion (run /status <criterion> to drill in).`);
332
395
  }
333
396
  console.log('');
334
397
  }
335
398
 
336
399
  function printOne(data, c) {
337
- console.log(`\n${data.version_id} · C${c.cnum} ${c.criterion_id} [${c.satisfied ? 'satisfied ✓' : 'not satisfied'}]`);
400
+ console.log(`\n${data.version_id} · ${c.criterion_id} (C${c.cnum}) [${c.satisfied ? 'satisfied ✓' : 'not satisfied'}]`);
338
401
  console.log(`"${c.criterion_md.replace(/\*\*/g, '').slice(0, 120)}"\n`);
339
402
  if (c.tasks.length === 0) {
340
403
  console.log(' (no tasks linked — this criterion has no structured gating tasks yet)\n');
@@ -352,11 +415,11 @@ async function main() {
352
415
  const opts = parseArgs(process.argv);
353
416
  if (opts.error) {
354
417
  console.error(`status: ${opts.error}`);
355
- console.error('Usage: node scripts/gds/status.js [--version ID] [Cn] [--json]');
418
+ console.error('Usage: node scripts/gds/status.js [--version ID] [<criterion-slug>|Cn] [--json]');
356
419
  process.exit(2);
357
420
  }
358
421
  if (opts.help) {
359
- console.log('Usage: node scripts/gds/status.js [--version ID] [Cn] [--json]');
422
+ console.log('Usage: node scripts/gds/status.js [--version ID] [<criterion-slug>|Cn] [--json]');
360
423
  process.exit(0);
361
424
  }
362
425
  // Fail closed if this repo's configured instance != the signed-in session's (task
@@ -398,16 +461,31 @@ async function main() {
398
461
  const data = await fetchProgress(picked.version);
399
462
 
400
463
  // Resolve the requested criterion (if any) once — every output mode needs it.
464
+ // Slug, positional `Cn`, or numeric id, through the SAME vocabulary the write
465
+ // path uses. The rows come from ONE version's payload, which is what makes the
466
+ // version scoping structural rather than remembered: a bare `Cn` can only ever
467
+ // rank inside the version we just fetched, and the versions number
468
+ // independently (BONGOS-V1 C8 ≠ BONGOS-V2 C8).
401
469
  let c = null;
470
+ let refKind = null;
402
471
  if (opts.criterion) {
403
- const n = Number(opts.criterion.slice(1));
404
- c = data.criteria.find((x) => x.cnum === n);
472
+ const hit = resolveCriterionIn(data.criteria, opts.criterion);
473
+ c = hit.criterion;
474
+ refKind = hit.kind;
405
475
  if (!c) {
406
476
  // Name the version, not just the criterion: on a DEFAULTED version this is
407
477
  // the reader's only clue that C13 was looked up somewhere they didn't pick.
408
- console.error(`status: ${picked.version} has no criterion ${opts.criterion} (it has C1–C${data.criteria.length}).`);
478
+ console.error(`status: ${picked.version} has no criterion "${opts.criterion}".`);
479
+ if (refKind === 'cnum') {
480
+ console.error(` It has C1–C${data.criteria.length}. Positions shift when a criterion is added — prefer the slug.`);
481
+ } else {
482
+ // A miss on a slug is usually a typo or the wrong version, and the cure
483
+ // is the list of legal names, not a count.
484
+ const names = data.criteria.map((x) => x.criterion_id);
485
+ console.error(` Known criteria: ${names.join(', ')}`);
486
+ }
409
487
  if (picked.defaulted) console.error(` ${defaultedVersionNote(picked)}`);
410
- process.exit(3);
488
+ return cliExit(3);
411
489
  }
412
490
  }
413
491
 
@@ -433,8 +511,9 @@ async function main() {
433
511
  // the version was resolved rather than chosen (--json/--widget/--hook are
434
512
  // machine interfaces and stay byte-identical).
435
513
  if (c) printOne(data, c); else printAll(data);
436
- const note = defaultedVersionNote(picked);
437
- if (note) console.log(` ${note}\n`);
514
+ const notes = [positionalRefNote(c, refKind), defaultedVersionNote(picked)].filter(Boolean);
515
+ for (const note of notes) console.log(` ${note}`);
516
+ if (notes.length) console.log('');
438
517
  }
439
518
 
440
519
  if (require.main === module) {
@@ -444,4 +523,4 @@ if (require.main === module) {
444
523
  });
445
524
  }
446
525
 
447
- module.exports = { parseArgs, resolveVersion, defaultedVersionNote, renderStatusWidget, renderStatusMarkdown };
526
+ module.exports = { parseArgs, resolveVersion, defaultedVersionNote, positionalRefNote, renderStatusWidget, renderStatusMarkdown };
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.893'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.894'; // 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');
@@ -0,0 +1,202 @@
1
+ // tests/criterion_ref_status.mjs
2
+ //
3
+ // The READ side of the criterion-ref vocabulary (task 1004069): what
4
+ // scripts/gds/status.js accepts on input and what it calls a criterion in
5
+ // output. The write half is tests/criterion_ref_parse.mjs.
6
+ //
7
+ // WHY THESE CASES. `Cn` is ROW_NUMBER() over a reorderable sort_order, so it is
8
+ // a presentation ordinal, not a name — inserting a criterion renumbers every one
9
+ // after it, version-wide, silently. `resolveCriterionIds` has refused a
10
+ // positional ref on a multi-goal version since task 1003108, while status.js
11
+ // PRINTED that same form as each row's identity and accepted nothing else. So
12
+ // the assertions below are about the two halves agreeing: a slug must resolve, a
13
+ // position must still resolve but say what it landed on, and nothing the
14
+ // renderers emit may carry a bare position where a reader will copy it.
15
+ //
16
+ // Run: node tests/criterion_ref_status.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const status = require('../scripts/gds/status.js');
23
+ const refs = require('../modules/lifecycle/criterion-ref.js');
24
+
25
+ let passed = 0;
26
+ let failed = 0;
27
+ function test(name, fn) {
28
+ try { fn(); passed++; console.log(` ok ${name}`); }
29
+ catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
30
+ }
31
+
32
+ // A miniature version payload in the shape GET /versions/:id/progress returns.
33
+ const crit = (cnum, criterion_id, id) => ({
34
+ cnum,
35
+ id,
36
+ criterion_id,
37
+ criterion_md: `**${criterion_id}** — prose`,
38
+ satisfied: false,
39
+ goal_id: '1000095',
40
+ tasks: [],
41
+ counts: { shipped: 0, total: 0 },
42
+ remaining: [],
43
+ });
44
+ const DATA = {
45
+ version_id: 'BONGOS-V2',
46
+ criteria: [
47
+ crit(1, 'wa10-security-enforced', '1000364'),
48
+ crit(8, 'wa6-kernel-and-packs', '1000369'),
49
+ crit(11, 'wa6-noise-limited', '1000380'),
50
+ ],
51
+ goals: [],
52
+ goals_achieved: 0,
53
+ goals_total: 0,
54
+ unattributed_tasks: 0,
55
+ };
56
+
57
+ // ---------- input: a bare token is a version or a criterion by SHAPE ----------
58
+
59
+ test('a bare SLUG is a criterion, not a version id', () => {
60
+ // The whole defect in one case: this used to set version='wa6-kernel-and-packs'
61
+ // and 404 the progress fetch, because the first bare token was always a version.
62
+ const o = status.parseArgs(['node', 'status.js', 'wa6-kernel-and-packs']);
63
+ assert.equal(o.criterion, 'wa6-kernel-and-packs');
64
+ assert.equal(o.version, null);
65
+ });
66
+
67
+ test('a bare version-shaped token is still a version', () => {
68
+ for (const v of ['V1', 'PMS-V2', 'GDS-V4', 'BONGOS-V2', 'CB-V1']) {
69
+ const o = status.parseArgs(['node', 'status.js', v]);
70
+ assert.equal(o.version, v, `${v} should parse as a version`);
71
+ assert.equal(o.criterion, null, `${v} should not parse as a criterion`);
72
+ }
73
+ });
74
+
75
+ test('a bare Cn is a criterion even though it is neither lowercase nor kebab', () => {
76
+ assert.equal(status.parseArgs(['node', 'status.js', 'C8']).criterion, 'C8');
77
+ assert.equal(status.parseArgs(['node', 'status.js', 'C8']).version, null);
78
+ });
79
+
80
+ test('version and criterion together, in either order', () => {
81
+ const a = status.parseArgs(['node', 'status.js', 'BONGOS-V2', 'wa6-noise-limited']);
82
+ assert.equal(a.version, 'BONGOS-V2');
83
+ assert.equal(a.criterion, 'wa6-noise-limited');
84
+ const b = status.parseArgs(['node', 'status.js', 'wa6-noise-limited', 'BONGOS-V2']);
85
+ assert.equal(b.version, 'BONGOS-V2');
86
+ assert.equal(b.criterion, 'wa6-noise-limited');
87
+ });
88
+
89
+ test('--criterion is the EXACT form and skips the shape test entirely', () => {
90
+ // A criterion whose slug happens to be version-shaped is unreachable by the
91
+ // bare-token path by construction; the flag is how a caller refuses to be guessed at.
92
+ const o = status.parseArgs(['node', 'status.js', '--criterion', 'V2', '--version', 'BONGOS-V2']);
93
+ assert.equal(o.criterion, 'V2');
94
+ assert.equal(o.version, 'BONGOS-V2');
95
+ assert.deepEqual(status.parseArgs(['node', 'status.js', '--criterion']), { error: '--criterion requires a value' });
96
+ });
97
+
98
+ test('a criterion ref is NOT uppercased on the way through', () => {
99
+ // The old parser did `a.toUpperCase()`, which is what made a slug
100
+ // unrepresentable: 'WA6-KERNEL-AND-PACKS' matches no row.
101
+ assert.equal(status.parseArgs(['node', 'status.js', 'wa6-kernel-and-packs']).criterion, 'wa6-kernel-and-packs');
102
+ });
103
+
104
+ // ---------- resolution: both forms land, only one is stable ----------
105
+
106
+ test('a slug resolves, case-insensitively', () => {
107
+ assert.equal(refs.resolveCriterionIn(DATA.criteria, 'wa6-kernel-and-packs').criterion.cnum, 8);
108
+ assert.equal(refs.resolveCriterionIn(DATA.criteria, 'WA6-Kernel-And-Packs').criterion.cnum, 8);
109
+ });
110
+
111
+ test('a positional Cn resolves BY POSITION, not by the number in the slug', () => {
112
+ const hit = refs.resolveCriterionIn(DATA.criteria, 'C11');
113
+ assert.equal(hit.kind, 'cnum');
114
+ assert.equal(hit.criterion.criterion_id, 'wa6-noise-limited');
115
+ });
116
+
117
+ test('a numeric done_when id resolves', () => {
118
+ assert.equal(refs.resolveCriterionIn(DATA.criteria, '1000369').criterion.criterion_id, 'wa6-kernel-and-packs');
119
+ });
120
+
121
+ test('a miss is a miss, and carries the kind so the caller can say the right thing', () => {
122
+ assert.equal(refs.resolveCriterionIn(DATA.criteria, 'C99').criterion, null);
123
+ assert.equal(refs.resolveCriterionIn(DATA.criteria, 'C99').kind, 'cnum');
124
+ assert.equal(refs.resolveCriterionIn(DATA.criteria, 'no-such-thing').criterion, null);
125
+ assert.equal(refs.resolveCriterionIn(DATA.criteria, 'no-such-thing').kind, 'slug');
126
+ });
127
+
128
+ test('resolution is scoped to the rows handed in — it cannot reach another version', () => {
129
+ // BONGOS-V1 and BONGOS-V2 both have a C8 and they are different criteria. The
130
+ // scoping is structural: there is no argument by which a caller could ask this
131
+ // function about a version it was not given the rows for.
132
+ assert.equal(refs.resolveCriterionIn([], 'C8').criterion, null);
133
+ });
134
+
135
+ // ---------- the note: a position must announce what it landed on ----------
136
+
137
+ test('a positional drill-in names the stable slug', () => {
138
+ const note = status.positionalRefNote(DATA.criteria[1], 'cnum');
139
+ assert.match(note, /wa6-kernel-and-packs/);
140
+ assert.match(note, /position/i);
141
+ });
142
+
143
+ test('a slug drill-in emits NO note — the reader already holds the durable form', () => {
144
+ assert.equal(status.positionalRefNote(DATA.criteria[1], 'slug'), '');
145
+ assert.equal(status.positionalRefNote(null, 'cnum'), '');
146
+ });
147
+
148
+ // ---------- output: the slug is the identity everywhere ----------
149
+
150
+ test('criterionLabel leads with the slug and trails the position', () => {
151
+ assert.equal(refs.criterionLabel(DATA.criteria[1]), 'wa6-kernel-and-packs (C8)');
152
+ assert.equal(refs.criterionLabel({ criterion_id: 'x' }), 'x');
153
+ assert.equal(refs.criterionLabel(null), '');
154
+ });
155
+
156
+ test('every widget drill-in click sends the SLUG, never a position', () => {
157
+ // The case that made this worth a task: a rendered card outlives the ordering
158
+ // it was rendered from, so a handler carrying `/status C11` starts naming a
159
+ // different criterion the moment anyone inserts one above it.
160
+ const html = status.renderStatusWidget(DATA, null);
161
+ for (const c of DATA.criteria) {
162
+ assert.ok(html.includes(`sendPrompt('/status ${c.criterion_id}')`),
163
+ `expected a slug click handler for ${c.criterion_id}`);
164
+ }
165
+ assert.equal(/sendPrompt\('\/status C\d+'\)/.test(html), false,
166
+ 'a positional click handler survived in the widget');
167
+ });
168
+
169
+ test('the markdown card never suggests a positional drill-in', () => {
170
+ const md = status.renderStatusMarkdown(DATA, null);
171
+ assert.equal(/`\/status C\d+`/.test(md), false, 'the drill-in hint quotes a position');
172
+ assert.match(md, /`\/status wa10-security-enforced`/);
173
+ });
174
+
175
+ test('the markdown table puts the criterion before the position', () => {
176
+ const md = status.renderStatusMarkdown(DATA, null);
177
+ assert.match(md, /\| \| Criterion \| Pos \|/);
178
+ // …and every row follows the header it declares.
179
+ assert.match(md, /\|\s*\|\s*wa6-kernel-and-packs\s*\|\s*C8\s*\|/);
180
+ });
181
+
182
+ test('a criterion card headlines the slug', () => {
183
+ const md = status.renderStatusMarkdown(DATA, DATA.criteria[1]);
184
+ assert.match(md, /BONGOS-V2 · wa6-kernel-and-packs \(C8\)/);
185
+ const html = status.renderStatusWidget(DATA, DATA.criteria[1]);
186
+ assert.match(html, /wa6-kernel-and-packs \(C8\)/);
187
+ });
188
+
189
+ // ---------- the escaping the slug column newly needs ----------
190
+
191
+ test('a slug carrying an apostrophe cannot break the click handler', () => {
192
+ // Task ids are digits, so the existing handlers get away with HTML-escaping
193
+ // alone; a criterion_id is free text from the DB and goes inside a
194
+ // single-quoted JS string inside an HTML attribute.
195
+ const odd = { ...crit(2, "o'hara-rule", '1000999') };
196
+ const html = status.renderStatusWidget({ ...DATA, criteria: [odd] }, null);
197
+ assert.ok(html.includes("sendPrompt('/status o\\'hara-rule')"),
198
+ 'the apostrophe was not escaped for the JS string');
199
+ });
200
+
201
+ console.log(`\ncriterion ref — read side (task 1004069): ${passed} passed, ${failed} failed`);
202
+ process.exitCode = failed > 0 ? 1 : 0;
@@ -78,12 +78,45 @@ test('status args extract an explicit version', () => {
78
78
  assert.deepEqual(statusCard.args("what's left in PMS-V1"), ['--hook', '--version', 'PMS-V1']);
79
79
  });
80
80
 
81
- test('status args extract a criterion token', () => {
82
- assert.deepEqual(statusCard.args("what's left for C8"), ['--hook', 'C8']);
81
+ // Both criterion forms ride the EXACT flag (task 1004069) — status.js then never
82
+ // has to guess whether a bare token was a version id or a criterion ref.
83
+ test('status args extract a positional criterion token', () => {
84
+ assert.deepEqual(statusCard.args("what's left for C8"), ['--hook', '--criterion', 'C8']);
83
85
  });
84
86
 
85
87
  test('status args extract version AND criterion together', () => {
86
- assert.deepEqual(statusCard.args('status of GDS-V4 C2'), ['--hook', '--version', 'GDS-V4', 'C2']);
88
+ assert.deepEqual(statusCard.args('status of GDS-V4 C2'), ['--hook', '--version', 'GDS-V4', '--criterion', 'C2']);
89
+ });
90
+
91
+ // A card's own click handler sends the SLUG, so the hook that feeds the card has
92
+ // to recognise the form its own output produces — otherwise every drill-in click
93
+ // on a rendered card falls back to the whole-version overview.
94
+ test('status args extract a criterion SLUG after the slash command', () => {
95
+ assert.deepEqual(statusCard.args('/status wa6-kernel-and-packs'),
96
+ ['--hook', '--criterion', 'wa6-kernel-and-packs']);
97
+ assert.deepEqual(statusCard.args('please run /status `wa11-legible`'),
98
+ ['--hook', '--criterion', 'wa11-legible']);
99
+ });
100
+
101
+ test('a slug after the word "criterion" is picked up too', () => {
102
+ assert.deepEqual(statusCard.args('how far along is criterion wa6-noise-limited'),
103
+ ['--hook', '--criterion', 'wa6-noise-limited']);
104
+ });
105
+
106
+ test('the SLUG wins over a positional token in the same prompt', () => {
107
+ // The slug cannot have gone stale; C11 may name a different criterion than it
108
+ // did when whatever the builder is quoting was written.
109
+ assert.deepEqual(statusCard.args('/status wa6-noise-limited (was C11)'),
110
+ ['--hook', '--criterion', 'wa6-noise-limited']);
111
+ });
112
+
113
+ test('hyphenated ENGLISH in a status prompt is not read as a slug', () => {
114
+ // The pattern only fires where a prompt actually POINTS at a criterion. A
115
+ // loose one would 404 the drill-in on ordinary prose — and a status card
116
+ // answering about nothing is worse than one answering about the version.
117
+ assert.deepEqual(statusCard.args("what's left for the half-finished work"), ['--hook']);
118
+ assert.deepEqual(statusCard.args('what tasks remain on the module-economy side of things'), ['--hook']);
119
+ assert.equal(hook.criterionSlugIn('status of the multi-goal version'), null);
87
120
  });
88
121
 
89
122
  // ---------- buildAdditionalContext: widgets ON ----------