@bongos/core 1.20.2 → 1.20.4

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.
Files changed (35) hide show
  1. package/.bongos-core.json +53 -38
  2. package/README.md +9 -18
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +3 -0
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0073-secrets-scan-exclude-uri-detector.md +1 -1
  9. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  10. package/docs/api/openapi.json +70 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +32 -18
  13. package/docs/copy-registry.json +153 -27
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +2 -1
  16. package/docs/page-readings.json +112 -72
  17. package/modules/hall-ui/public/settings-specialities.js +169 -5
  18. package/modules/hall-ui/public/settings.css +52 -1
  19. package/modules/hall-ui/public/settings.states.json +2 -1
  20. package/modules/hall-ui/records/panel-family.md +2 -0
  21. package/modules/specialities/routes/specialities.js +27 -0
  22. package/modules/specialities/skills.js +109 -13
  23. package/modules/specialities/specialities.js +4 -2
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/release-notes.json +12 -0
  27. package/scripts/gds/control-manifest.js +9 -0
  28. package/scripts/gds/doc-control-claims.js +168 -0
  29. package/scripts/gds/fitness.js +1 -0
  30. package/scripts/hall-preview/server.js +4 -0
  31. package/src/bongos/module-scope-map.js +1 -1
  32. package/src/module-api.js +1 -1
  33. package/tests/doc_control_claims.mjs +133 -0
  34. package/tests/speciality_skills.mjs +76 -1
  35. package/tests/speciality_walkthrough_ui.mjs +218 -0
@@ -202,7 +202,9 @@ select.voices__select { min-width: 140px; }
202
202
  /* The adopted contract, shown in full before "Use this" — another builder's prose
203
203
  reaching your sessions is the one thing here that must never be folded away. */
204
204
  .speciality-contract { flex-basis: 100%; margin-top: 0.5rem; font-size: 0.85rem; }
205
- .speciality-contract > summary { cursor: pointer; opacity: 0.85; }
205
+ /* 24px tall: the panel was never revealed until task 1004073, so the kit had
206
+ never measured this toggle; it came back 21px. */
207
+ .speciality-contract > summary { cursor: pointer; opacity: 0.85; min-height: 24px; padding-block: 2px; box-sizing: border-box; }
206
208
  .speciality-contract__body {
207
209
  white-space: pre-wrap;
208
210
  margin: 0.4rem 0 0;
@@ -212,6 +214,55 @@ select.voices__select { min-width: 140px; }
212
214
  font-family: inherit;
213
215
  opacity: 0.92;
214
216
  }
217
+ /* The skills walkthrough (task 1004073, ADR 0310 §3): one offered skill at a
218
+ time, three answers as a definition list, then a decision. It sits inside the
219
+ card like the train form, so it inherits the card's ground in both modes. */
220
+ .skill-walk {
221
+ flex-basis: 100%;
222
+ margin-top: 0.75rem;
223
+ padding-top: 0.75rem;
224
+ border-top: 1px solid var(--rule);
225
+ display: flex;
226
+ flex-direction: column;
227
+ gap: 0.6rem;
228
+ }
229
+ /* The measure caps the reading lines, never the section: a capped flex-basis
230
+ lets the section fit beside the card's actions instead of wrapping under them. */
231
+ .skill-walk > * { max-width: var(--measure); }
232
+ .skill-walk__progress { margin: 0; font-size: 0.8rem; color: var(--ink-soft); }
233
+ .skill-walk__name { margin: 0; font-family: var(--font-mono); font-size: 1rem; color: var(--ink); }
234
+ .skill-walk__facts { margin: 0; display: grid; gap: 0.2rem 0; }
235
+ .skill-walk__facts dt { font-size: 0.8rem; color: var(--ink-soft); margin-top: 0.4rem; }
236
+ .skill-walk__facts dt:first-child { margin-top: 0; }
237
+ .skill-walk__facts dd { margin: 0; color: var(--ink); line-height: 1.5; }
238
+ .skill-walk__note { font-size: 0.8rem; }
239
+ /* "Leave it off" and "Turn it on" are the panel family's plain pill at EQUAL
240
+ weight: the default is off, so the page must not lean on the reader toward on.
241
+ Only "Save my choices" takes the accent. A decision already made reads through
242
+ aria-pressed when the person steps Back to it. */
243
+ .skill-walk__actions { flex-wrap: wrap; }
244
+ .skill-walk__actions .pbtn[aria-pressed="true"] { border-color: var(--ink-soft); }
245
+ .skill-walk__recap { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; }
246
+ .skill-walk__recap li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.4rem; }
247
+ .skill-walk__recap .prow__hint { flex-basis: 100%; margin: 0; }
248
+ .skill-walk__recap strong { font-family: var(--font-mono); font-weight: 600; }
249
+ /* "Finish later" is a way out, not a choice about a skill, so it reads as a quiet
250
+ link below the decision rather than a third button beside it. */
251
+ .skill-walk__later {
252
+ align-self: flex-start;
253
+ background: none;
254
+ border: 0;
255
+ padding: 0 0.25rem;
256
+ margin-inline-start: -0.25rem;
257
+ min-height: 24px;
258
+ font: inherit;
259
+ font-size: 0.8rem;
260
+ color: var(--ink-soft);
261
+ text-decoration: underline;
262
+ text-underline-offset: 3px;
263
+ cursor: pointer;
264
+ }
265
+ .skill-walk__later:hover { color: var(--ink); }
215
266
 
216
267
  /* ---- Software update (task 1004296) -------------------------------------- */
217
268
  /* A version is read digit by digit and compared against another, so it takes the mono
@@ -9,7 +9,8 @@
9
9
  "states": {
10
10
  "account": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#settings-h1", "#display-name-input", "#craft-chips"] } },
11
11
  "access": { "auth": true, "url": "/settings#access", "actions": [["wait", 900]], "expect": { "visible": ["#cli-reissue-btn", "#sessions-list", "#artkey-card"] } },
12
- "preferences": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900]], "expect": { "visible": ["#wander-rows", "#render-rows"] } },
12
+ "preferences": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900]], "expect": { "visible": ["#wander-rows", "#render-rows", "#specialities-rows"] } },
13
+ "skills-walkthrough": { "auth": true, "url": "/settings#preferences", "actions": [["wait", 900], ["click", "#specialities-rows button[data-act=\"adopt\"]"], ["wait", 700]], "expect": { "visible": [".skill-walk", ".skill-walk__facts", "button[data-walk=\"on\"]", "button[data-walk=\"off\"]"] } },
13
14
  "sound": { "auth": true, "url": "/settings#sound", "actions": [["wait", 900]], "expect": { "visible": ["#voices-table", "#sound-rows", "#event-sound-rows"] } },
14
15
  "update": { "auth": true, "url": "/settings#software-update", "actions": [["wait", 1200]], "expect": { "visible": ["#software-update-h", "#su-body", "#su-update", ".su-versions"] } }
15
16
  },
@@ -26,3 +26,5 @@ Also fixed here, as the board task handed forward: the two sub-24px links on Hom
26
26
  - **"Up to date" is said only from an answer.** A failed registry read, an unnamed running version and unread release notes each render as what they are; unread notes are never drawn as a version with no changes.
27
27
  - **The notes are the soft ink, underlined, on the platform.** There every entry links to its task, and a list of accent-coloured sentences read as a warning. Elsewhere the ids belong to another project's ledger, so the entries are plain text.
28
28
  - **Kit:** `settings.states.json` `update`, ALL CLEAN at 1440/390/320 × dark/light against the harness with the fixture `software-update.json` (a live reading for 1.19.1033). Tests: `tests/software_update.mjs`.
29
+
30
+ **Settings: adopting a speciality walks through its skills (task 1004073, ADR 0310 §3)** — the owner ruled an adopted speciality's skills start OFF "and you should walk through what comes with each". So adopting one that offers skills opens `.skill-walk` inside its card: one skill per step (what it does, when to reach for it, what it costs, what it needs, who can run it), "Leave it off" / "Turn it on" at EQUAL weight (plain `.pbtn`, no accent — the default is off, so the page must not lean toward on), Back, then a recap restating each skill in a line, then ONE `PUT` of the whole set. "Finish later" saves nothing. Focus moves to each new step heading. Two things found on the way: the specialities panel shipped `hidden` and nothing ever revealed it, so adoption was unreachable from the hall; and its contract `<summary>` measured 21px once visible (now 24). **Kit:** `settings.states.json` `skills-walkthrough` + `preferences`, ALL CLEAN at 1440/390/320 × dark/light against the harness, with fixtures `specialities*.json` built from the real skills on disk and canned answers for adopt and save. Tests: `tests/speciality_walkthrough_ui.mjs` (runs the real page script).
@@ -401,6 +401,33 @@ module.exports = function buildSpecialitiesRouter() {
401
401
  res.json({ adoption });
402
402
  }));
403
403
 
404
+ // GET /specialities/:id/skills — what the adoption WALKTHROUGH shows (task
405
+ // 1004073, ADR 0310 §3): each skill the speciality offers, explained for the
406
+ // person deciding (what it does, when to reach for it, what it costs, what it
407
+ // needs), plus the caller's own enabled set and whether they hold it at all.
408
+ // Same visibility as GET /specialities/:id — 404 for a row you cannot see. An
409
+ // offered skill this instance no longer has comes back `installed: false`, so
410
+ // the walkthrough can say so instead of offering something that would 404.
411
+ // Read-only; rank: any builder who may see the speciality.
412
+ router.get('/specialities/:id/skills', api.requireBuilder, asyncHandler('GET /specialities/:id/skills', async (req, res) => {
413
+ const id = parseId(req, res, { code: 'bad_id' });
414
+ if (id === null) return;
415
+ const s = await db.getSpeciality(id);
416
+ if (!s || !(await visibleTo(s, req.builder))) return res.fail('not_found', 404);
417
+ const installed = installedSkills();
418
+ const offered = Array.isArray(s.skills) ? s.skills : [];
419
+ const skills = offered.map((name) => (installed.has(name)
420
+ ? { ...skillsLib.explainSkill({ name, ...installed.get(name) }), installed: true }
421
+ : { name, installed: false }));
422
+ const mine = (await db.listAdoptions(req.builder.id)).find((a) => String(a.id) === String(id) && a.active);
423
+ res.json({
424
+ speciality: { id: s.id, name: s.name, discipline: s.discipline },
425
+ adopted: !!mine,
426
+ enabled: mine && Array.isArray(mine.enabled_skills) ? mine.enabled_skills : [],
427
+ skills,
428
+ });
429
+ }));
430
+
404
431
  // PUT /specialities/:id/skills — the caller's ENABLED set for a speciality they
405
432
  // hold (task 1004072, ADR 0310 §2). Replaces the set whole: the walkthrough
406
433
  // (task 1004073) sends the decisions it collected, so a partial patch would
@@ -35,29 +35,125 @@ function listDirs(dir) {
35
35
  } catch { return []; }
36
36
  }
37
37
 
38
- // The description line from a SKILL.md's frontmatter, for the adoption
39
- // walkthrough (task 1004073) to show. Flat `description: ...` only, the grammar
40
- // every SKILL.md here uses; anything else reads as no description.
38
+ // The top-level keys of a SKILL.md's frontmatter, as strings (a flow list
39
+ // `[a, b]` as an array). Enough YAML for the grammar SKILL.md files use: a flat
40
+ // `key: value`, a quoted value, and a folded or literal block (`>-`, `>`, `|`,
41
+ // `|-`) whose indented lines follow. Part 1 read only the flat form, and almost
42
+ // every skill here folds its description, so each read as `>-` (task 1004073).
43
+ // Anything it cannot read is simply absent.
44
+ function parseFrontmatter(text) {
45
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text || ''));
46
+ if (!fm) return {};
47
+ const lines = fm[1].split(/\r?\n/);
48
+ const out = {};
49
+ for (let i = 0; i < lines.length; i++) {
50
+ const m = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(lines[i]);
51
+ if (!m) continue;
52
+ const [, key, rest] = m;
53
+ const block = /^([>|])([+-]?)\s*$/.exec(rest);
54
+ if (block) {
55
+ const body = [];
56
+ while (i + 1 < lines.length && (/^\s+\S/.test(lines[i + 1]) || lines[i + 1].trim() === '')) body.push(lines[++i].trim());
57
+ out[key] = block[1] === '>'
58
+ ? body.join('\n').split(/\n{2,}/).map((p) => p.split('\n').join(' ')).join('\n').trim()
59
+ : body.join('\n').trim();
60
+ continue;
61
+ }
62
+ const list = /^\[(.*)\]$/.exec(rest.trim());
63
+ if (list) { out[key] = list[1].split(',').map((s) => s.trim().replace(/^(['"])(.*)\1$/, '$2')).filter(Boolean); continue; }
64
+ out[key] = rest.trim().replace(/^(['"])([\s\S]*)\1$/, '$2');
65
+ }
66
+ return out;
67
+ }
68
+
69
+ function readFrontmatter(skillMd) {
70
+ try { return parseFrontmatter(fs.readFileSync(skillMd, 'utf8')); } catch { return {}; }
71
+ }
72
+
73
+ // The description a SKILL.md gives, for the adoption walkthrough (task 1004073).
41
74
  function readDescription(skillMd) {
42
- try {
43
- const text = fs.readFileSync(skillMd, 'utf8');
44
- const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
45
- if (!fm) return '';
46
- const line = fm[1].split(/\r?\n/).find((l) => /^description:\s*/.test(l));
47
- if (!line) return '';
48
- return line.replace(/^description:\s*/, '').replace(/^(['"])([\s\S]*)\1$/, '$2').trim();
49
- } catch { return ''; }
75
+ const d = readFrontmatter(skillMd).description;
76
+ return typeof d === 'string' ? d : '';
50
77
  }
51
78
 
79
+ // The optional PEOPLE-FACING fields (task 1004073). `description` is written to
80
+ // be MATCHED by a model, so a skill's author may also say, for the person
81
+ // deciding whether to turn it on: what it does (`plain`), when to reach for it
82
+ // (`reach-for`) and what it costs (`cost`). Claude Code ignores keys it does not
83
+ // know, as it already does `requires:`.
84
+ const PEOPLE_KEYS = { plain: 'plain', 'reach-for': 'reachFor', cost: 'cost' };
85
+
52
86
  function collect(out, dir, source) {
53
87
  for (const name of listDirs(dir)) {
54
88
  if (!SKILL_NAME_RE.test(name) || out.has(name)) continue;
55
89
  const md = path.join(dir, name, 'SKILL.md');
56
90
  if (!fs.existsSync(md)) continue;
57
- out.set(name, { name, description: readDescription(md), source });
91
+ const fm = readFrontmatter(md);
92
+ const entry = { name, description: typeof fm.description === 'string' ? fm.description : '', source };
93
+ for (const [key, field] of Object.entries(PEOPLE_KEYS)) if (typeof fm[key] === 'string' && fm[key]) entry[field] = fm[key];
94
+ if (Array.isArray(fm.requires) && fm.requires.length) entry.requires = fm.requires;
95
+ out.set(name, entry);
58
96
  }
59
97
  }
60
98
 
99
+ // ONE SKILL, AS A PERSON DECIDING ABOUT IT READS IT (task 1004073; ADR 0310 §3:
100
+ // "what it does, when its author reaches for it, what it costs"). Pure: takes an
101
+ // installedSkills() entry and returns the three answers plus what the skill
102
+ // needs. An author's own people-facing field always wins; without one each
103
+ // answer is derived from the model-facing description, and `derived` says so,
104
+ // so the walkthrough can be honest that the words were not written for them.
105
+ //
106
+ // The rank note is kept apart on purpose: enabling a skill GRANTS NOTHING
107
+ // (ADR 0310 §1), so a Metic-only skill stays Metic-only after you turn it on,
108
+ // and the person should be told that before they choose it, not after.
109
+ function explainSkill(entry) {
110
+ const e = entry || {};
111
+ const desc = String(e.description || '').replace(/\s+/g, ' ').trim();
112
+ const cut = desc.search(/\bTriggers?:/i);
113
+ let does = (cut >= 0 ? desc.slice(0, cut) : desc).trim();
114
+ const triggers = cut >= 0 ? desc.slice(cut).replace(/^Triggers?:\s*/i, '') : '';
115
+
116
+ const rankMatch = /\b(Metic\+|Archon)[- ]only\b\.?/i.exec(does);
117
+ const rank = rankMatch ? (/archon/i.test(rankMatch[1]) ? 'archon' : 'metic') : null;
118
+ if (rankMatch) does = does.replace(rankMatch[0], '').replace(/\s{2,}/g, ' ').trim();
119
+
120
+ const quoted = [...triggers.matchAll(/"([^"]+)"/g)].map((m) => m[1].trim());
121
+ const commands = quoted.filter((q) => q.startsWith('/'));
122
+ const phrases = quoted.filter((q) => !q.startsWith('/'));
123
+ const otherwise = /\bor\s+(?!")([^"]+?)\.?$/i.exec(triggers);
124
+
125
+ let reachFor = e.reachFor || '';
126
+ if (!reachFor && (phrases.length || otherwise)) {
127
+ const said = phrases.slice(0, 3).map((p) => `“${p}”`).join(', ');
128
+ reachFor = [said && `When you would say something like ${said}`, otherwise && otherwise[1].trim()]
129
+ .filter(Boolean).join(' — or ');
130
+ reachFor = reachFor.charAt(0).toUpperCase() + reachFor.slice(1);
131
+ if (!/[.!?]$/.test(reachFor)) reachFor += '.';
132
+ }
133
+
134
+ let cost = e.cost || '';
135
+ if (!cost) {
136
+ const readOnly = /\bread-only\b|\bchanges nothing\b/i.test(desc);
137
+ const paid = /\bpaid\b/i.test(desc);
138
+ if (readOnly && paid) cost = 'Read-only as it normally runs, but its author flags a paid option — check before you use that one. Otherwise it uses your session like any other request.';
139
+ else if (readOnly) cost = 'Read-only: it looks things up and changes nothing. It uses your session like any other request.';
140
+ else if (paid) cost = 'Has a paid step — its author flags one in the description. Otherwise it uses your session like any other request.';
141
+ else cost = 'Its author has not written down what it costs. Running it uses your session like any other request.';
142
+ }
143
+
144
+ return {
145
+ name: e.name,
146
+ source: e.source || null,
147
+ does: e.plain || does,
148
+ reachFor,
149
+ cost,
150
+ commands,
151
+ needs: Array.isArray(e.requires) ? e.requires : [],
152
+ rank,
153
+ derived: { does: !e.plain, reachFor: !e.reachFor, cost: !e.cost },
154
+ };
155
+ }
156
+
61
157
  /**
62
158
  * The skills this instance has: Map name -> { name, description, source }.
63
159
  * First root wins on a duplicate name (core, then instance, then modules), the
@@ -91,4 +187,4 @@ function installedSkillsCached(opts, now = Date.now()) {
91
187
  }
92
188
  function _resetCache() { cached = null; }
93
189
 
94
- module.exports = { SKILL_NAME_RE, installedSkills, installedSkillsCached, readDescription, _resetCache };
190
+ module.exports = { SKILL_NAME_RE, installedSkills, installedSkillsCached, readDescription, parseFrontmatter, explainSkill, _resetCache };
@@ -18,8 +18,10 @@
18
18
  // OFFERS skills (`skills`), each adopter's ENABLED set
19
19
  // starts empty (`enabled_skills`), and only a skill this
20
20
  // instance has may be named (skills.js). The walkthrough
21
- // that fills the enabled set is task 1004073; the session
22
- // line naming it is task 1004074. Naming a skill grants
21
+ // that fills the enabled set was built in task 1004073
22
+ // (GET /specialities/:id/skills + the hall's settings
23
+ // panel); the session line naming it is task 1004074.
24
+ // Naming a skill grants
23
25
  // nothing.
24
26
  // The genuinely new part is the knowledge bundle, which is why the migration adds
25
27
  // tables for it and this file carries the rules over them.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.2",
3
+ "version": "1.20.4",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.2",
9
+ "version": "1.20.4",
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.20.2",
3
+ "version": "1.20.4",
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",
@@ -8052,5 +8052,17 @@
8052
8052
  "id": "1003631",
8053
8053
  "text": "Closed a small consistency gap in the hall's needs-attention inbox: one link is now escaped like all the others. No visible change."
8054
8054
  }
8055
+ ],
8056
+ "1.20.3": [
8057
+ {
8058
+ "id": "1004073",
8059
+ "text": "Adopting a speciality now walks you through its skills one at a time: what each does, when you'd use it, and what it costs, and you choose on or off for each before seeing the next. Nothing is switched on unless you choose it."
8060
+ }
8061
+ ],
8062
+ "1.20.4": [
8063
+ {
8064
+ "id": "1003418",
8065
+ "text": "The project's automatic checks now catch any document that describes a safety check which no longer exists. On its first run it found the README still telling people their code was scanned for leaked passwords by a tool that w"
8066
+ }
8055
8067
  ]
8056
8068
  }
@@ -141,6 +141,15 @@ const CONTROLS = [
141
141
  wiredIn: { file: 'scripts/gds/ship-flow.js', needle: 'ship-honesty' },
142
142
  alsoExists: ['tests/ship_cannot_lie.mjs'],
143
143
  },
144
+ {
145
+ // Task 1003418 (audit A14). The manifest's own blind spot: it covers the rows
146
+ // someone listed. This guard derives claims from the docs instead, so a
147
+ // described-but-absent control reds the gate with no row to add.
148
+ label: 'guard: a doc cannot describe a control that does not exist (A14)',
149
+ file: 'scripts/gds/doc-control-claims.js',
150
+ wiredIn: { file: 'scripts/gds/fitness.js', needle: 'doc-control-claims' },
151
+ alsoExists: ['tests/doc_control_claims.mjs'],
152
+ },
144
153
  ];
145
154
 
146
155
  // Pure evaluation — exported for the test. deps.exists/deps.read override fs.
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/doc-control-claims.js — prose cannot describe a control that does not
3
+ // exist (task 1003418, audit ref A14 of the 2026-08-29 security audit, goal 1000084).
4
+ //
5
+ // WHY. control-manifest.js exists because ADR 0022 named three controls that had
6
+ // silently stopped existing, and it works — for the 14 controls someone remembered
7
+ // to list. Every phantom-control finding since (A6, A13, B31, B32, the row-8 patrol
8
+ // that read green for months while never running) was the same failure one row
9
+ // outside that list. A hand list only catches what a person already thought of, so
10
+ // the manifest cannot be the whole answer. This check reads the claims FROM THE DOCS:
11
+ // a doc sentence that says an enforcement artifact does something is a claim that
12
+ // the artifact exists, and a claim whose artifact is gone reds the gate by
13
+ // construction, with nobody having to add a row. The README's secrets section was
14
+ // the proof on first run: it still sent readers to a TruffleHog workflow and a
15
+ // pre-commit hook that had not existed since the core extraction — the ADR 0022
16
+ // finding itself, one file over.
17
+ //
18
+ // WHAT COUNTS AS A CLAIM, deliberately narrow:
19
+ // · a backticked path to an ENFORCEMENT artifact — a hook, a workflow, a script
20
+ // under scripts/gds or a hooks dir (CONTROL_PATH_RE);
21
+ // · in a sentence that uses control vocabulary — enforce, gate, guard, block,
22
+ // refuse, scan, patrol, audit, fail the build (CONTROL_WORDS);
23
+ // · and does NOT speak of it in the past — removed, retired, replaced, used to,
24
+ // no longer (HISTORY_WORDS). An ADR recording what was deleted is history,
25
+ // which is not a claim; the same path in the present tense is.
26
+ // Superseded / deprecated / rejected / withdrawn ADRs are skipped whole, and so are
27
+ // the dated records (session logs, audits, limitations archives), which describe a
28
+ // moment rather than the tree.
29
+ //
30
+ // WHAT IT DOES NOT PROVE: that an existing artifact is WIRED. That stays the
31
+ // manifest's job (a file present but unhooked). The two halves are complementary:
32
+ // this one scales to every doc and asks only "is it there"; the manifest asks
33
+ // "is it connected" for the controls that matter most.
34
+ //
35
+ // ALLOWLIST. A true claim this scan cannot tell from history (an accepted ADR whose
36
+ // design was later retired by a newer ADR) is listed in ALLOW with the reason. It
37
+ // is shrink-only in practice: an entry that no longer matches anything is itself a
38
+ // violation, so a fixed doc cannot leave a dead exception behind to hide the next.
39
+ //
40
+ // Its own file for the size-ratchet reason (the doc-cli-guard.js precedent);
41
+ // fitness.js requires it in CHECKS, and control-manifest.js lists it, so this guard
42
+ // cannot silently stop existing either.
43
+
44
+ 'use strict';
45
+
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+ const { trackedMarkdown } = require('./doc-cli-guard.js');
49
+
50
+ const ROOT = path.resolve(__dirname, '..', '..');
51
+ const NAME = 'a doc cannot describe an enforcement control that does not exist (task 1003418)';
52
+
53
+ // Two shapes: a script or workflow with its extension, OR a git hook by its bare
54
+ // name. Hooks under .husky/, .githooks/ and scripts/hooks/ carry no extension by
55
+ // convention, and `.husky/pre-commit` is exactly the artifact ADR 0022 lost. The
56
+ // names are git's whole documented set (githooks(5)), not a curated few.
57
+ const GIT_HOOK_NAMES = [
58
+ 'applypatch-msg', 'pre-applypatch', 'post-applypatch', 'pre-commit', 'pre-merge-commit',
59
+ 'prepare-commit-msg', 'commit-msg', 'post-commit', 'pre-rebase', 'post-checkout', 'post-merge',
60
+ 'pre-push', 'pre-receive', 'update', 'proc-receive', 'post-receive', 'post-update',
61
+ 'reference-transaction', 'push-to-checkout', 'pre-auto-gc', 'post-rewrite', 'sendemail-validate',
62
+ 'fsmonitor-watchman', 'p4-changelist', 'p4-prepare-changelist', 'p4-post-changelist',
63
+ 'p4-pre-submit', 'post-index-change',
64
+ ];
65
+ const CONTROL_PATH_RE = new RegExp(
66
+ '`((?:\\.claude/hooks|\\.github/workflows|scripts/gds|scripts/hooks|\\.githooks|\\.husky)/[A-Za-z0-9_.\\-/]+?\\.(?:js|mjs|cjs|sh|yml|yaml|ps1)'
67
+ + `|(?:\\.husky|\\.githooks|scripts/hooks)/(?:${GIT_HOOK_NAMES.join('|')}))\``,
68
+ 'g',
69
+ );
70
+ const CONTROL_WORDS = /\b(enforc\w*|gates?|gated|gating|guards?|guarded|blocks?|blocked|refuses?|refused|hard[- ]?fails?|fails? (?:the )?(?:build|ci|merge|gate)|compensating control|pre-commit|pre-push|scans?|scanned|patrol\w*|audits?)\b/i;
71
+ const HISTORY_WORDS = /\b(removed|deleted|retired|superseded|no longer|used to|never existed|did not exist|never survived|dropped|replaced|gone|historical|legacy|formerly|renamed)\b/i;
72
+ const INACTIVE_STATUS_RE = /^\s*\**\s*status\s*\**\s*:?\s*\**\s*:?\s*\**\s*(superseded|deprecated|rejected|withdrawn)/im;
73
+ const SKIP_PREFIXES = ['docs/session-logs/', 'docs/audits/', 'limitations/'];
74
+
75
+ // `${doc}::${artifact}` -> why this present-tense mention is not a phantom control.
76
+ const ALLOW = new Map([
77
+ ['docs/adr/0025-structured-criterion-task-link.md::scripts/gds/seed-v3-tasks.js',
78
+ 'describes the grep a session HAD to do before this ADR; a seed script, not a control'],
79
+ ['docs/adr/0042-builder-self-deploy-ci-auto-merge.md::.github/workflows/grade-gate.yml',
80
+ 'the CI grader gate this ADR designed; it was armed in the grader-off variant its own Status line records, and grading runs server-side (scripts/gds/ci-grade.js)'],
81
+ ['docs/adr/0111-instance-hosting-provisioning-module.md::scripts/gds/box.js',
82
+ 'the dev-box control-plane runner, retired with dev boxes by ADR 0346 (dev-box-guard.js keeps it out)'],
83
+ ['docs/adr/0150-box-first-boot-bringup-vendored-instances.md::scripts/gds/box.js',
84
+ 'the dev-box control-plane runner, retired with dev boxes by ADR 0346 (dev-box-guard.js keeps it out)'],
85
+ ]);
86
+
87
+ // Sentence-ish units: prose sentences, blank-line paragraphs, and list/table rows.
88
+ function sentencesOf(src) {
89
+ return String(src).split(/(?<=[.!?])\s+|\n\s*\n|\n(?=\s*(?:[-*|]|\d+\.)\s)/);
90
+ }
91
+
92
+ // Pure over its deps: docs is a list of repo-relative .md paths.
93
+ function findPhantomClaims(docs, { read, exists }) {
94
+ const claims = [];
95
+ for (const doc of docs) {
96
+ if (SKIP_PREFIXES.some((p) => doc.startsWith(p))) continue;
97
+ const src = read(doc);
98
+ if (src == null) continue;
99
+ if (INACTIVE_STATUS_RE.test(src.slice(0, 2000))) continue;
100
+ const seen = new Set();
101
+ for (const s of sentencesOf(src)) {
102
+ // Judge the WORDING with every code span blanked: a path's own name must not
103
+ // decide the tense (`gone.yml` reads as "gone", `guard.js` as "guard").
104
+ const prose = s.replace(/`[^`]*`/g, ' ');
105
+ for (const m of s.matchAll(CONTROL_PATH_RE)) {
106
+ const artifact = m[1];
107
+ if (seen.has(artifact) || exists(artifact)) continue;
108
+ if (!CONTROL_WORDS.test(prose) || HISTORY_WORDS.test(prose)) continue;
109
+ seen.add(artifact);
110
+ claims.push({ doc, artifact, sentence: s.replace(/\s+/g, ' ').trim().slice(0, 160) });
111
+ }
112
+ }
113
+ }
114
+ return claims;
115
+ }
116
+
117
+ function evaluate(docs, deps, allow = ALLOW) {
118
+ const claims = findPhantomClaims(docs, deps);
119
+ const hit = new Set();
120
+ const violations = [];
121
+ for (const c of claims) {
122
+ const key = `${c.doc}::${c.artifact}`;
123
+ if (allow.has(key)) { hit.add(key); continue; }
124
+ violations.push(`${c.doc}: names \`${c.artifact}\` as a live control, and it does not exist — "${c.sentence}"`);
125
+ }
126
+ for (const key of allow.keys()) {
127
+ if (!hit.has(key)) violations.push(`stale allowance: ${key} no longer matches a claim — delete it from ALLOW in scripts/gds/doc-control-claims.js`);
128
+ }
129
+ return { violations, claims };
130
+ }
131
+
132
+ const readRel = (rel) => { try { return fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch { return null; } };
133
+ const existsRel = (rel) => fs.existsSync(path.join(ROOT, rel));
134
+
135
+ function checkDocControlClaims({ files = null } = {}) {
136
+ const docs = files || trackedMarkdown();
137
+ // A lint that reports on nothing is worse than one that fails (docs-entropy).
138
+ if (!docs.length) {
139
+ return { name: NAME, ok: false, hardFail: true, warnings: [],
140
+ violations: ['scan defect — enumerated 0 tracked markdown files; a broken enumeration, not a clean result.'],
141
+ note: 'static scan of enforcement artifacts named in docs.' };
142
+ }
143
+ const { violations, claims } = evaluate(docs, { read: readRel, exists: existsRel });
144
+ return {
145
+ name: NAME,
146
+ ok: violations.length === 0,
147
+ hardFail: violations.length > 0,
148
+ violations,
149
+ warnings: [],
150
+ note: violations.length
151
+ ? 'restore the control, correct the doc to name what really enforces it, or (for an accepted ADR a newer one retired) add an ALLOW entry saying which'
152
+ : `${docs.length} tracked .md file(s) scanned; every enforcement artifact named in a present-tense control sentence exists (${claims.length} allowlisted, each with its reason).`,
153
+ };
154
+ }
155
+
156
+ function main() {
157
+ const r = checkDocControlClaims();
158
+ for (const v of r.violations) console.error(`✗ ${v}`);
159
+ console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
160
+ process.exit(r.hardFail ? 1 : 0);
161
+ }
162
+
163
+ if (require.main === module) main();
164
+
165
+ module.exports = {
166
+ checkDocControlClaims, findPhantomClaims, evaluate, sentencesOf,
167
+ ALLOW, GIT_HOOK_NAMES, CONTROL_PATH_RE, CONTROL_WORDS, HISTORY_WORDS, INACTIVE_STATUS_RE,
168
+ };
@@ -1342,6 +1342,7 @@ const CHECKS = [
1342
1342
  checkGovernmentRenameIdentity, checkPrelaunchVocabulary, // 19c task 1003120 (`X`→`X` rename arrow) + 19d task 1003152 ("stealth" is the product's word) — sharing a line: this file is AT the 1,500 budget (Check 23/24 precedent below; task 1003825 buys it back)
1343
1343
  checkWriteRoutesValidated, // Check 18 — BV1 R12 / task 1999: every body-reading write route validates (ADR 0118)
1344
1344
  require('./fitness-ratchets.js').checkQualityRatchets, // Check 20 — task 1003129: quality budgets only tighten (mechanics + knip CI step live in fitness-ratchets.js)
1345
+ require('./doc-control-claims.js').checkDocControlClaims, // Check 37 — task 1003418 / audit A14: a doc cannot describe an enforcement control that does not exist (derived from the docs, not a hand list)
1345
1346
  require('./doc-cli-guard.js').checkDocNamesRealCli, // Check 22 — task 1003165 / G01: a doc that names a CLI invocation must match the real CLI
1346
1347
  require('./skill-preflight.js').checkOpsSkillsDeclareMachine, // Check 23 — task 1003167 / G03: an ops skill declares the machine it needs
1347
1348
  // Check 24 — task 1003166 / G02: no ops script hardcodes a version id. It landed
@@ -160,6 +160,10 @@ const CANNED_WRITES = [
160
160
  // sent-back lines the artboard draws render too.
161
161
  ['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/approve$/, 'copy-desk__pages__approve.post'],
162
162
  ['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/send-back$/, 'copy-desk__pages__send-back.post'],
163
+ // a speciality's adopt and the walkthrough's save (task 1004073), so adopting
164
+ // opens the skills walkthrough and its recap can save in the harness.
165
+ ['POST', /^specialities\/\d+\/adopt$/, 'specialities__9__adopt.post'],
166
+ ['PUT', /^specialities\/\d+\/skills$/, 'specialities__9__skills.put'],
163
167
  ];
164
168
 
165
169
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
@@ -435,7 +435,7 @@ const MODULE_GLOBS = {
435
435
  'scripts/gds/skill-lint.js', 'scripts/gds/run-routine.js',
436
436
  'scripts/gds/adr-namespace.js', 'scripts/gds/baseline-staleness.js',
437
437
  'scripts/gds/category-advisory-guard.js', 'scripts/gds/client-baseurl-guard.js',
438
- 'scripts/gds/doc-cli-guard.js', 'scripts/gds/doc-comment-coverage.js',
438
+ 'scripts/gds/doc-cli-guard.js', 'scripts/gds/doc-control-claims.js', 'scripts/gds/doc-comment-coverage.js',
439
439
  'scripts/gds/exec-path-guard.js', 'scripts/gds/fitness-lib.js',
440
440
  'scripts/gds/fitness-checks-error-envelope.js', 'scripts/gds/fitness-checks-identity.js',
441
441
  'scripts/gds/fitness-checks-packaging.js', 'scripts/gds/fitness-checks-route-pins.js',
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.2'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.4'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');