@bongos/core 1.19.909 → 1.19.911

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.
@@ -52,6 +52,10 @@ const { completenessScore, weakestSectionPayload, scoreFieldValues } = require('
52
52
  // The trusted tier, spelled once (task 1003197 (D)) — routing.js owns METIC_PLUS
53
53
  // and already names 'criterion-proposal' in NEVER_ROUTE_KINDS.
54
54
  const { mayProposeCriterion, METIC_PLUS } = require('../routing');
55
+ // "Gravity" — the landing verdict read BEFORE the write (task 1004083, BV2.R02).
56
+ // Read-only and never-throwing; it calls the same matrix the capture calls so the
57
+ // composer can state the outcome instead of implying one. See ../landing-preview.js.
58
+ const { previewLanding } = require('../landing-preview');
55
59
  // task 1003208: structured logging (pino via the doorway) — was console.*.
56
60
  const log = api.logger('ideas');
57
61
  const { validateOrRespond, LIMITS, parseId } = api;
@@ -514,6 +518,49 @@ module.exports = function buildInboxRouter() {
514
518
  const { score, fields } = scoreFieldValues(req.body.field_values, templateKey ? { templateKey } : undefined);
515
519
  return res.json({ score, fields, template_key: templateKey || null });
516
520
  }, { errorCode: 'score_failed' }));
521
+ // POST /inbox/preview-landing — GRAVITY, MADE VISIBLE (task 1004083, BV2.R02).
522
+ //
523
+ // What pressing "File it" would do, without doing it. The composer draws one
524
+ // line from this so a filer can see which goal will catch the thought, change
525
+ // it, or deliberately let it float — the criterion's done-when, read literally.
526
+ //
527
+ // A DRY RUN IN THE SHAPE OF /inbox/score DIRECTLY ABOVE: same POST-with-a-body
528
+ // reason (the draft is the input, not an id), same "a half-formed draft is a
529
+ // legal state" contract. It differs in one way worth naming, because the file
530
+ // header above claims /score is pure: this one READS (the goal row, the
531
+ // building version). It still WRITES NOTHING — see ../landing-preview.js, which
532
+ // calls `currentBuildingVersionId` and deliberately never `ensureMaintenanceGoal`,
533
+ // so previewing a homeless bug cannot mint a goal as a side effect of typing.
534
+ //
535
+ // THE RANK AND THE VECTOR ARE NOT READ FROM THE BODY, exactly as POST /inbox
536
+ // refuses to read them: the rank comes off the authenticated builder and the
537
+ // vector is hardcoded to 'api'. A preview is only worth having if it is the
538
+ // same question the capture will be asked — one that answered a rank the filer
539
+ // does not hold would teach them a landing they cannot have.
540
+ //
541
+ // NEVER A 4xx FOR A DRAFT. Only the input SHAPE is validated; an unknown kind,
542
+ // an absent title, a goal that vanished all come back as `outcome: null`
543
+ // ("nothing to say yet"), because a composer redrawing on every keystroke must
544
+ // degrade to silence rather than to a red error under a half-typed thought.
545
+ //
546
+ // rank: any-builder — it answers only about the caller's own prospective
547
+ // filing, and filing itself is open to every rank.
548
+ router.post('/inbox/preview-landing', api.requireBuilder, api.asyncHandler('POST /inbox/preview-landing', async (req, res) => {
549
+ if (validateOrRespond(req, res, {
550
+ goal_id: { type: 'integer', min: 1 },
551
+ kind: { type: 'string', maxLength: 64 },
552
+ grade: { type: 'string', maxLength: 16 },
553
+ })) return;
554
+ const body = req.body || {};
555
+ const verdict = await previewLanding({
556
+ goalId: body.goal_id ?? null,
557
+ kind: body.kind ?? null,
558
+ grade: body.grade ?? null,
559
+ filerRank: req.builder?.rank ?? null,
560
+ vector: 'api',
561
+ });
562
+ return res.json(verdict);
563
+ }, { errorCode: 'preview_landing_failed' }));
517
564
  // GET /inbox/sparks — THE SPARK QUEUE (task 1002921 / BV1.R145, goal 1000062).
518
565
  //
519
566
  // Big Idea Sparks awaiting development: the ideator craft's pickup surface,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.909",
3
+ "version": "1.19.911",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.909",
9
+ "version": "1.19.911",
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.909",
3
+ "version": "1.19.911",
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",
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // api-alias-caller-scan.js — the detector behind the lowercase `/api/gds` CALLER ratchet
4
+ // (task 1004064, from idea 1001030; the sibling of gds-literal-scan.js).
5
+ //
6
+ // WHY THIS EXISTS. Task 1003707 (criterion C3e of goal 1000073) moved 665 in-repo callers off the
7
+ // permanent `/api/gds` alias onto the canonical `/api/bongos`. The ratchet that already guards the
8
+ // rename, `legacy_gds_literals`, is CASE-SENSITIVE on the shouting acronym — deliberately, because
9
+ // `GDS` is the word a newcomer trips over. So that metric read 1135 before the sweep and 1135
10
+ // after it, and it was right to: the alias is spelled in lowercase.
11
+ //
12
+ // The consequence is the gap this file closes. The lowercase spelling is invisible to CI in BOTH
13
+ // directions: the 665-caller sweep earned no credit, and — the half that matters — a caller written
14
+ // against `/api/gds` tomorrow moves no number and reds no build. 1003707's own session log says it
15
+ // plainly: "the 665 will quietly grow back". `tests/api_alias_exceptions.mjs` pins the callers that
16
+ // were deliberately LEFT behind and why, but it polices the exceptions, not new offenders. That is
17
+ // a ratchet's job.
18
+ //
19
+ // WHY A COUNT AND NOT ZERO. Every remaining occurrence has a reason, and most of them are reasons
20
+ // that will never expire — the OAuth callbacks are registered in GitHub's and Discord's app
21
+ // settings, the uptime poller is the one production caller proving the alias still answers, and
22
+ // tests are what keep it exercised in CI. A zero-rule would demand deleting things that must not be
23
+ // deleted. So this holds the line the same way its sibling does: the number may fall, never rise.
24
+ //
25
+ // WHAT IS NEVER COUNTED, and why each one. The exclusion list is not invented here — it is
26
+ // 1003707's own worked-out list, transcribed from docs/session-logs/2026-09-16-task-1003707-api-gds-callers.md
27
+ // so that a future move has to argue with a stated reason rather than with a leftover:
28
+ //
29
+ // 1. THE ALIAS CONTRACT (path-excluded). api-prefix.js and api-path-404.js ARE the alias; they
30
+ // cannot name it less often than they do.
31
+ // 2. THE PINNED OAUTH CALLBACKS (path-excluded). routes/auth.js, provisioning.js and the scripts
32
+ // + skills that register the same callback for a new instance. These paths live in the GitHub
33
+ // and Discord app settings; a repo-only change silently breaks sign-in.
34
+ // 3. THE LIVE PROOF (path-excluded). uptime-poller.js and smoke-gds.js exercise the alias on
35
+ // purpose — if nothing calls it, its eventual breakage is found by a user rather than by us.
36
+ // Note fitness-ratchets.js belongs here too: it is the GATE, and its builder-facing hint has
37
+ // to spell the alias out to tell anyone what they tripped over.
38
+ // 4. THE RENAME GUARDRAILS (path-excluded). rename-history-check.js, rename-ledger-prose.js,
39
+ // gds-literal-scan.js, codemod-rename-src-gds.js, fitness-ratchets.js and THIS FILE name the
40
+ // old path because that is what they are for. Same principle as GDS_RULE_FILES next door: a
41
+ // gate that cannot state its own rule cannot explain itself.
42
+ // 5. tests/ (path-excluded). Tests are what keeps the alias exercised in CI. Moving them off it
43
+ // would retire the alias by accident.
44
+ // 6. docs/ (path-excluded) — a DELIBERATE SCOPE CALL, not an oversight. It is the largest
45
+ // remaining bucket (~320) and it is where rename-history-check's per-file floors live, so a
46
+ // docs pass has to separate the record of what we used to call it from instructions that
47
+ // still teach the old spelling. 1004064's own brief says to treat docs as out of scope and
48
+ // file it rather than widen the task; filed as the follow-up named in that task's session log.
49
+ // 7. MIGRATIONS + THE FROZEN RECORD (path-excluded). Same rule as the sibling detector: an
50
+ // applied migration renamed is an applied migration re-run, and a rewritten ADR destroys the
51
+ // evidence that the words changed.
52
+ // 8. A LINE THAT IS ABOUT THE ALIAS (line-excluded). Any line that ALSO names `/api/bongos`, or
53
+ // carries the words alias / legacy / permanent / forever, is commentary on the alias rather
54
+ // than a caller of it. This is the same rule 1003707's codemod used to decide what to leave,
55
+ // so the ratchet and the sweep agree by construction.
56
+ // 9. CAPTURED FIXTURES (path-excluded). scripts/hall-preview/fixtures/*.json are snapshots of
57
+ // real API responses; rewriting one would make the fixture misreport what the server returned.
58
+ //
59
+ // Usage:
60
+ // node scripts/gds/api-alias-caller-scan.js # count + the top offending files
61
+ // node scripts/gds/api-alias-caller-scan.js --files # every file with its count
62
+ // node scripts/gds/api-alias-caller-scan.js --json
63
+
64
+ const fs = require('node:fs');
65
+ const path = require('node:path');
66
+ const { execFileSync } = require('node:child_process');
67
+
68
+ const ROOT = path.resolve(__dirname, '..', '..');
69
+
70
+ // The alias, lowercase — the whole point of this detector, since its sibling is case-sensitive on
71
+ // the shouting form and therefore blind to exactly this spelling.
72
+ const ALIAS = '/api/gds';
73
+ const ALIAS_RE = /\/api\/gds/g;
74
+
75
+ // The canonical prefix. A line naming BOTH is commentary on the alias, not a caller of it.
76
+ const CANONICAL = '/api/bongos';
77
+
78
+ // Path exclusions, matched against the repo-relative path (forward slashes), at any depth.
79
+ // Every entry maps to a numbered reason in the header above — keep them in step.
80
+ const ALIAS_EXCLUDED_PATHS = [
81
+ // 1. the alias contract
82
+ /^src\/bongos\/api-prefix\.js$/,
83
+ /^src\/bongos\/api-path-404\.js$/,
84
+ // 2. the pinned OAuth callbacks + everything that registers them for a new instance
85
+ /^src\/bongos\/routes\/auth\.js$/,
86
+ /^modules\/provisioning\/provisioning\.js$/,
87
+ /^scripts\/gds\/(onboard|provision-net|dev|login)\.js$/,
88
+ /^modules\/lifecycle\/kickoff-checklist\.js$/,
89
+ /^modules\/public-landing\/public\/projects\.html$/,
90
+ /^\.claude\/skills\/new-project\/SKILL\.md$/,
91
+ // 3. the live proof that the alias still answers
92
+ /^src\/bongos\/uptime-poller\.js$/,
93
+ /^scripts\/gds\/smoke-gds\.js$/,
94
+ // 4. the rename guardrails — they name the old path on purpose
95
+ /^scripts\/gds\/(rename-history-check|rename-ledger-prose|gds-literal-scan|codemod-rename-src-gds|api-alias-caller-scan|fitness-ratchets)\.js$/,
96
+ /^config\/(fitness-baselines|rename-history-baseline)\.json$/,
97
+ // 5. tests keep the alias exercised in CI — the repo's own two spellings, `tests/` at the root
98
+ // and a module's `tests/` or `test/` dir. Deliberately NOT a blanket `\.mjs$`: three .mjs
99
+ // files are not tests (clients/bongos-client/index.mjs and its example, scripts/status-probe.mjs)
100
+ // and the client is exactly the kind of caller this ratchet exists to police.
101
+ /(^|\/)tests?\//,
102
+ // 6. docs are a separate pass (scope call, see the header)
103
+ /(^|\/)docs\//,
104
+ // 7. migrations + the frozen record
105
+ /(^|\/)migrations\//,
106
+ /(^|\/)limitations\//,
107
+ // 9. captured API-response fixtures
108
+ /(^|\/)fixtures\//,
109
+ ];
110
+
111
+ // Vendored / generated / third-party trees — the same exclusion the sibling detector applies.
112
+ const ALIAS_EXCLUDED_TREES = /(^|\/)(node_modules|public-docs)\/|\.min\.js$|(^|\/)vendor\//;
113
+
114
+ // A line that is ABOUT the alias rather than a caller of it. Rule 8 above.
115
+ const ALIAS_COMMENTARY_RE = /\balias\b|\blegacy\b|\bpermanent\b|\bforever\b/i;
116
+
117
+ function isExcludedPath(relPath) {
118
+ const p = relPath.replace(/\\/g, '/');
119
+ if (ALIAS_EXCLUDED_TREES.test(p)) return true;
120
+ return ALIAS_EXCLUDED_PATHS.some((re) => re.test(p));
121
+ }
122
+
123
+ // isCountedLine — a line naming the alias that is a CALLER rather than commentary. Exported so the
124
+ // test and any future sweep share ONE decision (the gds-literal-scan precedent: a gate whose
125
+ // detector drifts from its sweep is worse than no gate).
126
+ function isCountedLine(line) {
127
+ if (!line.includes(ALIAS)) return false;
128
+ if (line.includes(CANONICAL)) return false;
129
+ if (ALIAS_COMMENTARY_RE.test(line)) return false;
130
+ return true;
131
+ }
132
+
133
+ // countInText — alias occurrences on counted lines only. A line can legitimately carry more than
134
+ // one (two fetches, a usage banner), so occurrences are counted rather than lines: that is what
135
+ // makes the number fall when a caller is actually moved.
136
+ function countInText(text) {
137
+ let n = 0;
138
+ for (const line of String(text).split(/\r?\n/)) {
139
+ if (!isCountedLine(line)) continue;
140
+ n += (line.match(ALIAS_RE) || []).length;
141
+ }
142
+ return n;
143
+ }
144
+
145
+ function trackedFiles() {
146
+ const out = execFileSync('git', ['ls-files'], { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
147
+ return out.split('\n').map((s) => s.trim()).filter(Boolean);
148
+ }
149
+
150
+ // scanAliasCallers — { total, byFile, scanned } across every tracked, countable file.
151
+ //
152
+ // `scanned` is the NON-VACUITY channel, and it is the whole reason this function returns it. A
153
+ // static scan breaks by finding nothing, and a green run looks identical to a working one: if
154
+ // `git ls-files` returns nothing, or every path is excluded, this reports total 0 and a caller
155
+ // that reads only `total` cannot tell a clean tree from a broken scanner. The ratchet and the test
156
+ // both assert `scanned > 0`, so an enumeration that collapses fails LOUDLY instead of passing.
157
+ //
158
+ // `files` and `readFile` are injectable for tests, so the real aggregation — exclusions, the
159
+ // commentary rule, per-file totals — is exercised without writing into the repo. The unit lane runs
160
+ // test files in parallel; a test that edited a tracked file would break whichever suite happened to
161
+ // be scanning the tree at that moment.
162
+ function scanAliasCallers({ files = null, readFile = null } = {}) {
163
+ const read = readFile || ((relPath) => fs.readFileSync(path.join(ROOT, relPath), 'utf8'));
164
+ const byFile = {};
165
+ let total = 0;
166
+ let scanned = 0;
167
+ for (const relPath of files || trackedFiles()) {
168
+ if (isExcludedPath(relPath)) continue;
169
+ let text;
170
+ try {
171
+ text = read(relPath);
172
+ } catch {
173
+ continue;
174
+ }
175
+ if (text.includes(String.fromCharCode(0))) continue; // binary (a NUL byte)
176
+ scanned += 1;
177
+ const n = countInText(text);
178
+ if (n) {
179
+ byFile[relPath] = n;
180
+ total += n;
181
+ }
182
+ }
183
+ return { total, byFile, scanned };
184
+ }
185
+
186
+ function main() {
187
+ const { total, byFile, scanned } = scanAliasCallers();
188
+ const ranked = Object.entries(byFile).sort((a, b) => b[1] - a[1]);
189
+ if (process.argv.includes('--json')) {
190
+ process.stdout.write(`${JSON.stringify({ total, byFile, scanned }, null, 2)}\n`);
191
+ return;
192
+ }
193
+ const show = process.argv.includes('--files') ? ranked : ranked.slice(0, 20);
194
+ console.log(`in-repo /api/gds callers: ${total} across ${ranked.length} file(s) (${scanned} file(s) scanned)`);
195
+ console.log('(the alias contract, the pinned OAuth callbacks, the uptime poller + smoke, the rename guardrails,');
196
+ console.log(' tests/, docs/, migrations/ and captured fixtures are excluded by construction; so is any line that');
197
+ console.log(' also names /api/bongos or carries the words alias / legacy / permanent / forever)');
198
+ console.log('');
199
+ for (const [f, n] of show) console.log(` ${String(n).padStart(5)} ${f}`);
200
+ if (show.length < ranked.length) console.log(` … and ${ranked.length - show.length} more file(s) — pass --files for all`);
201
+ }
202
+
203
+ if (require.main === module) main();
204
+
205
+ // Only what a consumer actually calls. The constants above stay module-private on purpose: an
206
+ // export nobody imports reads as dead code to knip and costs a ratchet point for nothing, and the
207
+ // rules are meant to be read here rather than re-implemented by a caller.
208
+ module.exports = {
209
+ isExcludedPath,
210
+ isCountedLine,
211
+ countInText,
212
+ scanAliasCallers,
213
+ };
@@ -347,6 +347,15 @@ const METRIC_HINTS = {
347
347
  + 'Write Bongos instead. Run `node scripts/gds/gds-literal-scan.js` to see every counted file. '
348
348
  + 'Version ids (GDS-V##), docs/adr, docs/session-logs, docs/audits, limitations and migrations '
349
349
  + 'are excluded by construction — if your literal is history, it belongs in one of those.',
350
+ legacy_api_gds_callers:
351
+ ' — a NEW in-repo caller was written against the permanent `/api/gds` alias (task 1004064, '
352
+ + 'idea 1001030). Ask for `/api/bongos` instead: the alias exists for callers we CANNOT update '
353
+ + '(shipped Dev Box binaries, live dev boxes), not for code in this repo. Run '
354
+ + '`node scripts/gds/api-alias-caller-scan.js --files` to see every counted line. The alias '
355
+ + 'contract, the pinned OAuth callbacks, the uptime poller + smoke, the rename guardrails, '
356
+ + 'tests/, docs/, migrations/ and captured fixtures are excluded by construction — and so is '
357
+ + 'any line that also names /api/bongos or carries the words alias / legacy / permanent / forever, '
358
+ + 'so if your line is ABOUT the alias rather than a call to it, say so on the line.',
350
359
  };
351
360
 
352
361
  function compareToBaselines(metrics, baselines) {
@@ -379,6 +388,27 @@ function gdsMetrics() {
379
388
  return { legacy_gds_literals: scanGdsLiterals().total };
380
389
  }
381
390
 
391
+ // legacy_api_gds_callers — the LOWERCASE half of the same rename (task 1004064, idea 1001030).
392
+ // `legacy_gds_literals` above is case-sensitive on the shouting acronym, so it read 1135 both
393
+ // before and after task 1003707 moved 665 in-repo callers off `/api/gds` — correct for what it
394
+ // measures, and exactly why a caller written against the alias tomorrow moves no number and reds
395
+ // no build. This counts that spelling instead. Detector + the reason for every exclusion:
396
+ // scripts/gds/api-alias-caller-scan.js.
397
+ //
398
+ // It FAILS CLOSED on a collapsed enumeration. A static scan breaks by finding nothing, and a
399
+ // green run looks identical to a working one — so if the scanner scanned no files at all, the
400
+ // metric is OMITTED with a reason on stderr rather than reported as a flattering 0 that would
401
+ // invite someone to ratchet the baseline down to it.
402
+ function apiAliasCallerMetrics() {
403
+ const { scanAliasCallers } = require('./api-alias-caller-scan.js');
404
+ const { total, scanned } = scanAliasCallers();
405
+ if (!scanned) {
406
+ process.stderr.write('fitness-ratchets: legacy_api_gds_callers NOT MEASURED — the caller scan enumerated ZERO files, which is a scan defect, not a clean tree\n');
407
+ return {};
408
+ }
409
+ return { legacy_api_gds_callers: total };
410
+ }
411
+
382
412
  // skill_listing_chars — the resident cost of the skill listing (every name +
383
413
  // description, both skill homes). Ratcheted rather than merely warned about
384
414
  // because this number has now been trimmed three times (tasks 1003548, 1003587,
@@ -411,6 +441,7 @@ function computeMetrics({ withDocs = true } = {}) {
411
441
  require_cycle_count: countCycles(requireGraph(contents)),
412
442
  console_call_count: consoleCallCount(contents),
413
443
  ...gdsMetrics(),
444
+ ...apiAliasCallerMetrics(),
414
445
  ...skillListingMetrics(),
415
446
  ...(withDocs ? docMetrics() : {}),
416
447
  };
@@ -225,7 +225,7 @@ function checkGeneratedArtifactsFresh() {
225
225
  try {
226
226
  execFileSync(process.execPath, [path.join(__dirname, 'gen-file-map.js'), '--check'], { cwd: REPO_ROOT, stdio: 'pipe' });
227
227
  } catch {
228
- violations.push('docs/file-map.md generated sections (.claude/skills/, .claude/scheduled-tasks/) are STALE or a skill/routine has no note in docs/file-map.notes.json — run `node scripts/gds/gen-file-map.js` and add any missing note, then commit (ADR 0066).');
228
+ violations.push('docs/file-map.md generated sections (.claude/skills/, .claude/scheduled-tasks/) are STALE, a skill/routine has no note in docs/file-map.notes.json, or a directory under modules/ has no file-map entry at all — run `node scripts/gds/gen-file-map.js --check` for the exact list, then fix and commit (ADR 0066; module coverage: task 1004066).');
229
229
  }
230
230
  try {
231
231
  // Also structurally validates the spec (exit 1 on invalid OpenAPI 3.1) beyond the drift check (exit 2). Task 1918.
@@ -16,6 +16,16 @@
16
16
  // • every skill/routine dir on disk MUST have a note → a new skill can't land undescribed;
17
17
  // • every note MUST map to a dir that exists → the map can't carry ghosts;
18
18
  // • the rendered block MUST match what's committed → no stale tree.
19
+ // MODULE COVERAGE (task 1004066). The same honor system that let a skill land unmapped let a whole
20
+ // MODULE land unmapped: `modules/agents`, `modules/government` and `modules/specialities` were all
21
+ // on disk with nothing in the map, and the two that got fixed before this got fixed BY HAND — which
22
+ // is the point, since hand-patching a drifting map does not stop the drift. The modules/ block is
23
+ // not generated (each entry is a paragraph of prose no directory walk could write, and generating
24
+ // the fenced repo tree is the expensive option this task ruled out), so what is enforced is the
25
+ // cheap half: a set comparison asserting every directory under modules/ is documented SOMEWHERE in
26
+ // the map — either a `modules/<key>/…` path reference or a `<key>/` entry inside the modules block.
27
+ // A new module now fails --check until someone writes its line.
28
+ //
19
29
  // scripts/gds/ is deliberately NOT generated: it is a curated highlight reel of ~20 of ~70 files
20
30
  // (generating it would bloat it with low-signal entries), and it sits mid-tree inside the big
21
31
  // fenced repo tree where HTML-comment markers can't live cleanly. It stays hand-curated.
@@ -51,6 +61,13 @@ const SECTIONS = [
51
61
  { id: 'scheduled-tasks', dir: '.claude/scheduled-tasks', notesKey: 'scheduled-tasks' },
52
62
  ];
53
63
 
64
+ // modules/ is NOT generated (task 1004066): each entry is a paragraph of prose no directory walk
65
+ // could write, and generating it would force the whole fenced repo tree through a generator —
66
+ // explicitly out of scope. What IS enforceable is COVERAGE: every module on disk must be documented
67
+ // somewhere in the map. That set comparison is the cheap half of the same contract the skills
68
+ // section already carries, and it is what stops a whole vertical slice landing unmapped.
69
+ const MODULES_DIR = 'modules';
70
+
54
71
  const begin = (id) => `<!-- BEGIN GENERATED FILE-MAP ${id} (scripts/gds/gen-file-map.js — do not hand-edit; notes live in docs/file-map.notes.json) -->`;
55
72
  const end = (id) => `<!-- END GENERATED FILE-MAP ${id} -->`;
56
73
 
@@ -107,6 +124,72 @@ function buildModel() {
107
124
  return SECTIONS.map((s) => buildSection(s, notes));
108
125
  }
109
126
 
127
+ // ── module coverage (task 1004066) ────────────────────────────────────────────
128
+ // A module counts as documented when the map either references its PATH (`modules/<key>/…`
129
+ // anywhere) or carries a `<key>/` entry line inside the `modules/` tree block. Both forms are in
130
+ // use today, so the check accepts both rather than inventing a third the map would fail on.
131
+
132
+ // Both matchers CAPTURE the whole path segment rather than testing for a given key. That is what
133
+ // makes the prefix hazard structural instead of something a lookahead has to remember: a mention of
134
+ // `modules/agents-extra` yields the key `agents-extra`, so it can never vouch for `agents`. It also
135
+ // means the map is scanned ONCE per run instead of twice per module.
136
+ const PATH_REF_RE = /modules\/([A-Za-z0-9_.-]+)/g;
137
+ const ENTRY_RE = /[├└]──\s+([A-Za-z0-9_.-]+)\//g;
138
+
139
+ // The slice of the fenced repo tree between the `├── modules/` header and the next sibling at the
140
+ // same depth. Scoping the entry search here matters: `.claude/skills/` lists dirs in the same
141
+ // `├── name/` shape, so an unscoped match would let a SKILL named after a module vouch for it.
142
+ function modulesBlock(mapText) {
143
+ const lines = String(mapText).split(/\r?\n/);
144
+ const start = lines.findIndex((l) => new RegExp(`^[├└]── ${MODULES_DIR}/`).test(l));
145
+ if (start === -1) return '';
146
+ let stop = lines.length;
147
+ for (let i = start + 1; i < lines.length; i += 1) {
148
+ if (/^[├└]── /.test(lines[i])) { stop = i; break; }
149
+ }
150
+ return lines.slice(start, stop).join('\n');
151
+ }
152
+
153
+ // Every module key the map documents: any `modules/<key>` path reference anywhere, plus any
154
+ // `<key>/` entry line inside the modules block. Both forms are in use today, so both count.
155
+ function documentedModuleKeys(mapText, block) {
156
+ const keys = new Set();
157
+ for (const m of String(mapText).matchAll(PATH_REF_RE)) keys.add(m[1]);
158
+ for (const m of String(block).matchAll(ENTRY_RE)) keys.add(m[1]);
159
+ return keys;
160
+ }
161
+
162
+ // The single-key form — the readable one to assert against.
163
+ function isModuleDocumented(mapText, block, key) {
164
+ return documentedModuleKeys(mapText, block).has(key);
165
+ }
166
+
167
+ // Every immediate subdirectory of modules/, or [] when the tree carries no modules/ at all (a
168
+ // consumer instance that enabled none — nothing to document).
169
+ function listModules() {
170
+ return listSubdirs(path.join(REPO_ROOT, MODULES_DIR));
171
+ }
172
+
173
+ // The set comparison. Returns { onDisk, undocumented, defects } — `defects` is the non-vacuity
174
+ // channel: this check breaks by comparing two empty sets and reporting nothing, so a state that
175
+ // makes the comparison meaningless (modules on disk but no readable map, or no modules/ block to
176
+ // read entries out of) is reported as a SCAN DEFECT rather than passing quietly.
177
+ function moduleCoverage(mapText) {
178
+ const onDisk = listModules();
179
+ const defects = [];
180
+ if (!onDisk.length) return { onDisk, undocumented: [], defects };
181
+ const text = typeof mapText === 'string' ? mapText : (fs.existsSync(MAP_PATH) ? fs.readFileSync(MAP_PATH, 'utf8') : '');
182
+ if (!text.trim()) {
183
+ defects.push(`${onDisk.length} module(s) on disk but docs/file-map.md is missing or empty — the module-coverage check cannot run`);
184
+ return { onDisk, undocumented: [], defects };
185
+ }
186
+ const block = modulesBlock(text);
187
+ if (!block) defects.push('docs/file-map.md has no `modules/` block in its repo tree — module entries cannot be read (path references are still checked)');
188
+ const documented = documentedModuleKeys(text, block);
189
+ const undocumented = onDisk.filter((key) => !documented.has(key));
190
+ return { onDisk, undocumented, defects };
191
+ }
192
+
110
193
  // Render one section's fenced tree block (the bytes that live between its markers). Arrows are
111
194
  // column-aligned to the longest label, matching the hand-formatted look of the rest of the map.
112
195
  function renderSection(model) {
@@ -144,6 +227,7 @@ function computeWrites() {
144
227
  const stale = model.flatMap((m) => m.stale);
145
228
  const writes = [];
146
229
  let markersMissing = [];
230
+ let mapText = '';
147
231
  if (fs.existsSync(MAP_PATH)) {
148
232
  let cur = fs.readFileSync(MAP_PATH, 'utf8');
149
233
  let next = cur;
@@ -152,8 +236,10 @@ function computeWrites() {
152
236
  next = injectSection(next, m);
153
237
  }
154
238
  if (!sameContent(next, cur)) writes.push({ full: MAP_PATH, content: next, rel: 'docs/file-map.md' });
239
+ mapText = next; // coverage reads the POST-generation text, so a regen never changes the verdict
155
240
  }
156
- return { model, writes, missing, stale, markersMissing };
241
+ const modules = moduleCoverage(mapText);
242
+ return { model, writes, missing, stale, markersMissing, modules };
157
243
  }
158
244
 
159
245
  function main() {
@@ -162,7 +248,7 @@ function main() {
162
248
  process.stdout.write(JSON.stringify(buildModel(), null, 2) + '\n');
163
249
  return;
164
250
  }
165
- const { writes, missing, stale, markersMissing } = computeWrites();
251
+ const { writes, missing, stale, markersMissing, modules } = computeWrites();
166
252
 
167
253
  // A missing note (dir without a note) or a stale note (note without a dir) is a hard failure in
168
254
  // BOTH modes: in --check it fails CI; in write mode we still report it (and render a visible
@@ -171,6 +257,8 @@ function main() {
171
257
  if (missing.length) problems.push(`notes MISSING for: ${missing.join(', ')} — add a one-line note to docs/file-map.notes.json`);
172
258
  if (stale.length) problems.push(`notes are STALE (no such dir) — remove from docs/file-map.notes.json: ${stale.join(', ')}`);
173
259
  if (markersMissing.length) problems.push(`docs/file-map.md is missing generated markers for: ${markersMissing.join(', ')}`);
260
+ if (modules.undocumented.length) problems.push(`modules with NO docs/file-map.md entry: ${modules.undocumented.map((k) => `${MODULES_DIR}/${k}`).join(', ')} — add an entry to the modules/ block of docs/file-map.md (task 1004066)`);
261
+ for (const d of modules.defects) problems.push(`module-coverage SCAN DEFECT — ${d}`);
174
262
 
175
263
  if (checkOnly) {
176
264
  if (writes.length || problems.length) {
@@ -197,6 +285,12 @@ if (require.main === module) main();
197
285
  module.exports = {
198
286
  materializedNames,
199
287
  SECTIONS,
288
+ MODULES_DIR,
289
+ listModules,
290
+ modulesBlock,
291
+ documentedModuleKeys,
292
+ isModuleDocumented,
293
+ moduleCoverage,
200
294
  begin,
201
295
  end,
202
296
  buildModel,
@@ -27,7 +27,7 @@ const STEPS = [
27
27
  { key: 'fitness', label: 'fitness.js (architecture + budgets + freshness gate)', argv: ['scripts/gds/fitness.js'], fix: 'read the ✗ lines above; each names its fix' },
28
28
  { key: 'repo-map', label: 'nested CLAUDE.md symbol blocks fresh', argv: ['scripts/gds/gen-repo-map.js', '--check-block'], fix: 'node scripts/gds/gen-repo-map.js' },
29
29
  { key: 'session-index', label: 'CLAUDE.md §13 session snippet fresh', argv: ['scripts/gds/gen-session-index.js', '--check-block'], fix: 'node scripts/gds/gen-session-index.js' },
30
- { key: 'file-map', label: 'docs/file-map.md skills/routines sections fresh', argv: ['scripts/gds/gen-file-map.js', '--check'], fix: 'node scripts/gds/gen-file-map.js (+ a note in docs/file-map.notes.json for a new skill)' },
30
+ { key: 'file-map', label: 'docs/file-map.md skills/routines sections fresh + every module documented', argv: ['scripts/gds/gen-file-map.js', '--check'], fix: 'node scripts/gds/gen-file-map.js (+ a note in docs/file-map.notes.json for a new skill, or a modules/ entry in docs/file-map.md for a new module)' },
31
31
  { key: 'api-docs', label: 'docs/api/openapi.json + api-reference fresh', argv: ['scripts/gds/gen-api-docs.js', '--check'], fix: 'node scripts/gds/gen-api-docs.js' },
32
32
  { key: 'copy-registry', label: 'docs/copy-registry.json fresh', argv: ['scripts/gds/copy-inventory.js', '--check'], fix: 'node scripts/gds/copy-inventory.js', optional: true },
33
33
  { key: 'linkify', label: 'no bare #NNN task refs in committed markdown', argv: ['scripts/gds/linkify-refs.js', '--check'], fix: 'node scripts/gds/linkify-refs.js', optional: true },
@@ -231,7 +231,20 @@ app.use((req, res, next) => {
231
231
  return res.end(png);
232
232
  }
233
233
 
234
- if (req.method !== 'GET') {
234
+ // READ-SHAPED POSTS. A handful of routes are POSTs only because the INPUT is a
235
+ // draft rather than an id — they write nothing. Serving them the 501 below made
236
+ // their surfaces permanently un-demoable here: the page renders its degraded
237
+ // state forever, which is the "a page that only ever renders its empty state
238
+ // cannot be redesigned by looking" trap modules/hall-ui/CLAUDE.md names. They
239
+ // fall through to the ordinary fixture lookup instead.
240
+ //
241
+ // AN ALLOWLIST, and it stays short. Anything absent is still refused, so a real
242
+ // write cannot reach the harness by growing a comment — adding a member means
243
+ // asserting that route writes nothing, which is checkable at its handler.
244
+ const READ_SHAPED_POSTS = new Set([
245
+ 'inbox/preview-landing', // the landing verdict for a draft (task 1004083)
246
+ ]);
247
+ if (req.method !== 'GET' && !READ_SHAPED_POSTS.has(apiPath)) {
235
248
  return res.status(501).json({ error: { code: 'preview_readonly', message: 'the preview harness serves fixtures; writes are not wired' } });
236
249
  }
237
250
  // Your own account wins over the checked-in snapshot; a me-scoped read that
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.909'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.911'; // 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');