@bongos/core 1.19.772 → 1.19.774

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 (97) hide show
  1. package/.bongos-core.json +204 -79
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
  8. package/docs/adr/0177-goal-membership-kind.md +26 -2
  9. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
  10. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
  11. package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
  12. package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
  13. package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
  14. package/docs/adr/README.md +3 -0
  15. package/docs/api/openapi.json +102 -11
  16. package/docs/api-reference.md +5 -4
  17. package/docs/copy-inventory.md +205 -190
  18. package/docs/copy-registry.json +435 -269
  19. package/docs/file-map.md +1 -0
  20. package/docs/module-api-changelog.md +4 -0
  21. package/docs/modules-contract.md +1 -1
  22. package/modules/builder-settings/render-prefs.js +281 -33
  23. package/modules/builder-settings/routes/settings.js +20 -12
  24. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  25. package/modules/dev-box/module.json +4 -0
  26. package/modules/hall-ui/public/settings-render.js +138 -0
  27. package/modules/hall-ui/public/settings.html +16 -9
  28. package/modules/hall-ui/public/settings.js +3 -70
  29. package/modules/lifecycle/db-tasks.js +7 -1
  30. package/modules/lifecycle/dead-deps.js +10 -2
  31. package/modules/lifecycle/ship-card.js +49 -13
  32. package/modules/lifecycle/task-list-limits.js +44 -0
  33. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  34. package/modules/platform-identity/platform-identity.js +8 -19
  35. package/modules/platform-identity/project-catalog.js +24 -0
  36. package/modules/platform-identity/routes/sso.js +11 -14
  37. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  38. package/modules/provisioning/capacity-gate.js +54 -0
  39. package/modules/provisioning/catalog-bridge.js +2 -0
  40. package/modules/provisioning/disconnect.js +35 -0
  41. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  42. package/modules/provisioning/module.json +1 -1
  43. package/modules/provisioning/provisioning.js +3 -3
  44. package/modules/provisioning/routes/disconnect.js +63 -0
  45. package/modules/provisioning/routes/provisioning.js +6 -6
  46. package/modules/public-landing/public/projects.html +116 -11
  47. package/modules/public-landing/public/projects.probes.json +3 -0
  48. package/modules/ui-design/kit/serve.js +3 -2
  49. package/modules/ui-design/module.json +1 -0
  50. package/package-lock.json +2 -2
  51. package/package.json +1 -1
  52. package/scripts/gds/address-park-sweep.sh +78 -0
  53. package/scripts/gds/audit-deps.js +38 -4
  54. package/scripts/gds/cli-lib.js +37 -0
  55. package/scripts/gds/fitness-migration-contraction.js +50 -0
  56. package/scripts/gds/fitness.js +18 -0
  57. package/scripts/gds/land-watch.js +8 -2
  58. package/scripts/gds/provision-config.js +4 -4
  59. package/scripts/gds/provision-disconnect.js +90 -0
  60. package/scripts/gds/provision-teardown.js +108 -0
  61. package/scripts/gds/provision.js +6 -65
  62. package/scripts/gds/seam-wiring-guard.js +273 -0
  63. package/scripts/gds/ship-finish.js +17 -10
  64. package/scripts/gds/ship-flow.js +28 -6
  65. package/scripts/gds/ship-resume.js +32 -4
  66. package/scripts/gds/start.js +13 -7
  67. package/src/bongos/auth-admission.js +7 -8
  68. package/src/bongos/auth-config.js +3 -2
  69. package/src/bongos/goal-scope-check.js +23 -8
  70. package/src/bongos/module-scope-map.js +40 -0
  71. package/src/module-api.js +1 -1
  72. package/src/module-loader/manifest-schema.js +79 -1
  73. package/tests/catalog_only_client.mjs +8 -5
  74. package/tests/fitness.mjs +22 -0
  75. package/tests/goal_scope_check.mjs +63 -1
  76. package/tests/hall_settings_world.mjs +57 -1
  77. package/tests/hub_disconnect_ui.mjs +105 -0
  78. package/tests/land_watch.mjs +44 -0
  79. package/tests/membership_kind_lockstep.mjs +86 -27
  80. package/tests/module-scope-map.mjs +39 -0
  81. package/tests/module_exports_unique_keys.mjs +70 -0
  82. package/tests/module_manifest.mjs +85 -0
  83. package/tests/project_catalog_forget.mjs +83 -0
  84. package/tests/projects_hub.mjs +10 -4
  85. package/tests/projects_hub_pre_uat.mjs +5 -3
  86. package/tests/provision_disconnect.mjs +231 -0
  87. package/tests/provision_teardown_address.mjs +230 -0
  88. package/tests/provisioning_capacity_gate.mjs +192 -0
  89. package/tests/provisioning_disconnect.mjs +231 -0
  90. package/tests/provisioning_restart.mjs +5 -5
  91. package/tests/public_landing_projects.mjs +4 -3
  92. package/tests/render_prefs.mjs +152 -0
  93. package/tests/seam_wiring_guard.mjs +234 -0
  94. package/tests/ship_card.mjs +67 -1
  95. package/tests/ship_resume.mjs +86 -12
  96. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
  97. package/tests/task_list_paging.mjs +178 -0
@@ -0,0 +1,273 @@
1
+ // scripts/gds/seam-wiring-guard.js — Check 33: a declared contributes.<seam> that
2
+ // nothing consumes (task 1003819; successor to abandoned task 1002464).
3
+ //
4
+ // THE GAP THIS CLOSES. A module declares what it contributes in module.json.
5
+ // src/module-seams.js `verifyPortsSatisfied` checks the OPPOSITE direction — every
6
+ // `consumes` port has exactly one provider among the enabled modules — and
7
+ // src/module-loader/manifest-schema.js `validateManifest` checks the SHAPE of
8
+ // `contributes` (a closed key set, so a typo'd KEY errors). Neither asks whether a
9
+ // declared entry is actually WIRED. So a module can declare `contributes.pollers:
10
+ // ["ghost"]` with no modules/<key>/pollers/ghost.js and boot stays silent: the
11
+ // loader's startModulePollers treats a missing poller file as declarative-only and
12
+ // skips it, hallWidgetScripts skips a uiSection with no hall-widget.js, and
13
+ // moduleWebSurfaces skips a surface whose dir was never shipped. Every one of those
14
+ // skips is correct behaviour for a REAL declarative-only entry, and indistinguishable
15
+ // from a typo or a rename that left the manifest behind.
16
+ //
17
+ // WHAT "CONSUMED" MEANS. Each seam has one consumer in the tree, and each consumer
18
+ // looks for one artifact at a fixed path. SEAM_CONSUMERS below records that pairing —
19
+ // seam → the artifact its consumer resolves — so this guard asks the same question
20
+ // the consumer asks rather than inventing a second convention that could drift from
21
+ // it. A seam with NO consumer anywhere carries `consumer: null`, and every
22
+ // declaration of it is reported: that is the strongest form of the defect.
23
+ //
24
+ // WHY A CI GATE AND NOT A BOOT REFUSAL (ADR 0289). `verifyEnabledPorts` throws at
25
+ // boot because an unprovided port otherwise becomes a runtime throw on a request
26
+ // path, in a process that reported healthy — refusing early is strictly safer. For
27
+ // every seam BUT `routes`, an unconsumed contribution is not that shape: the consumer
28
+ // skips it gracefully, nothing crashes later, and the instance runs correctly minus
29
+ // one feature. Adding a production start-up failure for a manifest-hygiene defect
30
+ // would trade a real outage risk for a cosmetic one.
31
+ //
32
+ // `routes` is the exception and it argues the SAME way: mountModuleRoutes `require`s
33
+ // the factory with no try/catch, so a missing route file already takes the boot down
34
+ // today. There the guard is not a new gate at all — it moves an existing crash from
35
+ // production start-up to the build, which is strictly earlier and strictly cheaper.
36
+ // So CI covers both cases: the seams that fail silently get a voice, and the one that
37
+ // fails loudly gets to fail before it reaches a server.
38
+ //
39
+ // Exported for scripts/gds/fitness.js (checkSeamWiring) and for the unit suite, which
40
+ // drives scanSeamWiring() over synthetic manifests with an injected `exists` so every
41
+ // case is hermetic — the scanScopeMapSource / scanDeterministicBody precedent, where
42
+ // the pure scanner is the tested surface.
43
+
44
+ 'use strict';
45
+
46
+ const fs = require('node:fs');
47
+ const path = require('node:path');
48
+ // The SAME enumerator + labeller validateManifest uses for the exemption cross-check,
49
+ // imported rather than re-declared: two copies would drift, and the drift would land
50
+ // on whether an exemption is honoured (task 1003819).
51
+ const { declaredEntries, entryLabel } = require('../../src/module-loader/manifest-schema.js');
52
+
53
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
54
+
55
+ // Is `child` inside `parent`? Both are repo-relative-or-absolute; normalised before
56
+ // comparing so a '..' segment cannot pass by spelling.
57
+ function within(parent, child) {
58
+ const a = path.resolve(REPO_ROOT, parent);
59
+ const b = path.resolve(REPO_ROOT, child);
60
+ const r = path.relative(a, b);
61
+ return r !== '' && !r.startsWith('..') && !path.isAbsolute(r);
62
+ }
63
+
64
+ // A repo-relative, forward-slashed path for an error message.
65
+ function rel(p) {
66
+ if (!p) return '(no artifact path)';
67
+ const r = path.relative(REPO_ROOT, path.isAbsolute(p) ? p : path.join(REPO_ROOT, p));
68
+ return r.split(path.sep).join('/');
69
+ }
70
+
71
+ // seam → how its consumer resolves a declared entry to an on-disk artifact.
72
+ // consumer the function that actually reads it, named so a reader can go check
73
+ // kind 'file' | 'dir' | 'registry' — what must exist
74
+ // artifact (moduleDir, entry) → path, or null when the seam is per-MODULE rather
75
+ // than per-entry (uiSections ships ONE widget however many it names)
76
+ // alternates optional extra paths that also count as wired
77
+ const SEAM_CONSUMERS = {
78
+ routes: {
79
+ consumer: 'src/module-loader/loader.js mountModuleRoutes',
80
+ kind: 'file',
81
+ artifact: (dir, entry) => path.join(dir, 'routes', `${entry}.js`),
82
+ },
83
+ pollers: {
84
+ consumer: 'src/module-loader/loader.js startModulePollers',
85
+ kind: 'file',
86
+ artifact: (dir, entry) => path.join(dir, 'pollers', `${entry}.js`),
87
+ },
88
+ rooms: {
89
+ // Allowed by the manifest schema since the doorway, but nothing in core reads
90
+ // contributes.rooms — the realtime seam was never wired to the loader. No module
91
+ // declares it today; if one ever does, this guard says so rather than letting the
92
+ // declaration read as though it did something.
93
+ consumer: null,
94
+ kind: 'file',
95
+ artifact: (dir, entry) => path.join(dir, 'rooms', `${entry}.js`),
96
+ },
97
+ uiSections: {
98
+ // Per-MODULE, not per-entry: hallWidgetScripts looks for one hall-widget.js
99
+ // however many sections the module names.
100
+ consumer: 'src/module-loader/loader.js hallWidgetScripts',
101
+ kind: 'file',
102
+ artifact: (dir) => path.join(dir, 'public', 'hall-widget.js'),
103
+ },
104
+ skills: {
105
+ // moduleSkillSources resolves a declared name against the module's OWN tree or
106
+ // its vendor/ subdir. A name that exists only in the core .claude/skills/ is
107
+ // skipped by it (`if (!src) continue`), so it is unconsumed BY THE MANIFEST —
108
+ // a legitimate declarative-only case, and what ui-design's exemption records.
109
+ //
110
+ // The artifact is the SKILL.md, not the directory: moduleSkillSources tests
111
+ // `fs.existsSync(path.join(p, 'SKILL.md'))`, so a skills/<name>/ dir without one
112
+ // is NOT a skill to it. Probing the bare directory would call such an entry wired
113
+ // while the real consumer skipped it — this guard's own defect, on the seam with
114
+ // the most declared entries.
115
+ consumer: 'scripts/gds/claude-materialize.js moduleSkillSources',
116
+ kind: 'file',
117
+ artifact: (dir, entry) => path.join(dir, 'skills', entry, 'SKILL.md'),
118
+ alternates: (dir, entry) => [path.join(dir, 'skills', 'vendor', entry, 'SKILL.md')],
119
+ },
120
+ disciplines: {
121
+ consumer: 'scripts/gds/discipline-modes.json (read by src/modules.js + claim.js)',
122
+ kind: 'registry',
123
+ artifact: () => null,
124
+ },
125
+ webSurfaces: {
126
+ consumer: 'src/module-loader/loader.js moduleWebSurfaces',
127
+ kind: 'dir',
128
+ // `entry` is the surface OBJECT here, not a string.
129
+ artifact: (dir, entry) => (entry && typeof entry.dir === 'string' ? path.join(dir, entry.dir) : null),
130
+ },
131
+ migrations: {
132
+ consumer: 'scripts/migrate.sh (the module-migration runner)',
133
+ kind: 'dir',
134
+ artifact: (dir) => path.join(dir, 'migrations'),
135
+ },
136
+ };
137
+
138
+ // Default existence probe — `kind` decides file-vs-directory. A relative path is
139
+ // resolved against the repo root so a manifest's `dir` may be either.
140
+ function fsExists(p, kind) {
141
+ try {
142
+ const st = fs.statSync(path.isAbsolute(p) ? p : path.join(REPO_ROOT, p));
143
+ return kind === 'dir' ? st.isDirectory() : st.isFile();
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ // THE SCANNER. Pure over its inputs (the only I/O is the injected `exists`), so the
150
+ // unit suite drives every case with synthetic manifests and no tmp dirs.
151
+ //
152
+ // modules [{ key, dir, manifest }] — the loader's `discovered().loaded` shape
153
+ // exists (path, kind) => boolean — defaults to the real filesystem
154
+ // registryKeys Set of valid `disciplines` entries, or null to skip that seam
155
+ //
156
+ // Returns { ok, findings[], wired[], declarative[] }. `wired` and `declarative` are
157
+ // populated on the SUCCESS path too, deliberately: a guard that reports nothing when
158
+ // it passes leaves no evidence it ran, so the caller prints the counts either way.
159
+ function scanSeamWiring(modules = [], { exists = fsExists, registryKeys = null } = {}) {
160
+ const findings = [];
161
+ const wired = [];
162
+ const declarative = [];
163
+
164
+ for (const { key, dir, manifest } of modules) {
165
+ const contributes = (manifest && manifest.contributes) || {};
166
+ const exempt = (manifest && manifest.declarativeSeams) || {};
167
+ const isExempt = (seam, label) => Array.isArray(exempt[seam]) && exempt[seam].includes(label);
168
+
169
+ for (const [seam, value] of Object.entries(contributes)) {
170
+ const spec = SEAM_CONSUMERS[seam];
171
+ if (!spec) continue; // an unknown seam key is validateManifest's error, not ours
172
+
173
+ for (const entry of declaredEntries(seam, value)) {
174
+ const label = entryLabel(seam, entry);
175
+
176
+ if (spec.consumer === null) {
177
+ findings.push(`module "${key}" declares contributes.${seam}: "${label}", but NOTHING in the tree consumes the ${seam} seam at all — the declaration does nothing`);
178
+ continue;
179
+ }
180
+
181
+ // A 'registry' seam is satisfied by membership in a roster, not by a file.
182
+ if (spec.kind === 'registry') {
183
+ if (registryKeys && !registryKeys.has(label)) {
184
+ findings.push(`module "${key}" declares contributes.${seam}: "${label}", but ${spec.consumer} has no such entry`);
185
+ } else {
186
+ wired.push({ key, seam, entry: label, via: spec.consumer });
187
+ }
188
+ continue;
189
+ }
190
+
191
+ const primary = spec.artifact(dir, entry);
192
+ const candidates = [primary, ...(spec.alternates ? spec.alternates(dir, entry) : [])].filter(Boolean);
193
+ // An entry carrying path separators or '..' would aim the probe outside
194
+ // modules/<key>/. It is only an existence test over repo-committed content, so
195
+ // the blast radius is an on/off answer — but a declaration that reaches out of
196
+ // its own module is a finding in its own right, not something to resolve.
197
+ const escapes = candidates.find((p) => !within(dir, p));
198
+ if (escapes) {
199
+ findings.push(`module "${key}" declares contributes.${seam}: "${label}", which resolves to ${rel(escapes)} — outside the module's own directory. A contributes entry names something the module ships, so it may not contain a path separator or ".."`);
200
+ continue;
201
+ }
202
+ const found = candidates.some((p) => exists(p, spec.kind));
203
+
204
+ if (found) {
205
+ // A STALE exemption is the same rot in the other direction: the artifact
206
+ // exists now, so the entry IS wired and the exemption reads as though a
207
+ // real gap were sanctioned. Fail it rather than let the list quietly lie.
208
+ if (isExempt(seam, label)) {
209
+ findings.push(`module "${key}" exempts ${seam} "${label}" as declarative-only, but ${rel(primary)} exists and ${spec.consumer} wires it — drop the stale declarativeSeams.${seam} entry`);
210
+ } else {
211
+ wired.push({ key, seam, entry: label, via: spec.consumer });
212
+ }
213
+ continue;
214
+ }
215
+
216
+ if (isExempt(seam, label)) {
217
+ declarative.push({ key, seam, entry: label });
218
+ continue;
219
+ }
220
+
221
+ findings.push(`module "${key}" declares contributes.${seam}: "${label}", but ${rel(primary)} does not exist — ${spec.consumer} silently skips it. Ship the artifact, drop the declaration, or record it in declarativeSeams.${seam} if it is declarative-only on purpose`);
222
+ }
223
+ }
224
+ }
225
+
226
+ return { ok: findings.length === 0, findings, wired, declarative };
227
+ }
228
+
229
+ // The roster `contributes.disciplines` is checked against. Returns null when the file
230
+ // is unreadable, which makes the disciplines seam UNCHECKED rather than failed — a
231
+ // missing registry is its own check's problem (fitness.js role packs), not this one's.
232
+ function disciplineKeys() {
233
+ try {
234
+ const raw = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'scripts', 'gds', 'discipline-modes.json'), 'utf8'));
235
+ return new Set(Object.keys(raw.modes || {}));
236
+ } catch {
237
+ return null;
238
+ }
239
+ }
240
+
241
+ // The fitness-check wrapper. Discovers every module.json through the loader — the same
242
+ // discovery the running server uses, so the guard cannot check a different module set
243
+ // than the one that boots.
244
+ function checkSeamWiring() {
245
+ const name = 'declared module seams are consumed (task 1003819)';
246
+ let modules = [];
247
+ try {
248
+ modules = require('../../src/module-loader/loader.js').discoverModules().loaded;
249
+ } catch (e) {
250
+ return {
251
+ name,
252
+ ok: true,
253
+ hardFail: false,
254
+ violations: [],
255
+ warnings: [`module discovery failed: ${e && e.message ? e.message : e}`],
256
+ note: 'module discovery unavailable — skipped.',
257
+ };
258
+ }
259
+
260
+ const { ok, findings, wired, declarative } = scanSeamWiring(modules, { registryKeys: disciplineKeys() });
261
+ const seams = [...new Set(wired.map((w) => w.seam))].sort();
262
+ return {
263
+ name,
264
+ ok,
265
+ hardFail: !ok,
266
+ violations: findings,
267
+ warnings: [],
268
+ // Printed on the PASS path too — the wired set is the evidence the guard ran.
269
+ note: `${modules.length} module(s): ${wired.length} declared seam entr(ies) WIRED across ${seams.length} seam(s) (${seams.join(', ') || 'none'}), ${declarative.length} recorded declarative-only. A declared contributes.<seam> whose consumer's artifact is absent is a silent no-op (ADR 0289).`,
270
+ };
271
+ }
272
+
273
+ module.exports = { checkSeamWiring, scanSeamWiring, SEAM_CONSUMERS, declaredEntries, entryLabel, disciplineKeys };
@@ -10,7 +10,7 @@
10
10
 
11
11
  const fs = require('node:fs');
12
12
  const os = require('node:os');
13
- const { cliClient, printOnboardingNudge, rewardPolicy } = require('./cli-lib');
13
+ const { cliClient, printOnboardingNudge, rewardPolicy, cardFlagsFromMe } = require('./cli-lib');
14
14
  const { closeoutPlan } = require('../../modules/lifecycle/ship-preflight');
15
15
  const { syncMemoryOnShip } = require('./ship-memory-sync.js');
16
16
  const { uploadSessionOnShip } = require('./ship-session-upload.js');
@@ -264,17 +264,18 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
264
264
  commit = (v && v.commit) ? String(v.commit).slice(0, 7) : (gitOk('git rev-parse --short HEAD') || null);
265
265
  }
266
266
 
267
- // Running drachmae total + the builder's render-widgets knob (task 1279),
268
- // both from one /me fetch (best-effort).
267
+ // Running drachmae total + the builder's card-richness level (task 1279's
268
+ // knob, a ladder since task 1002518), both from one /me fetch (best-effort).
269
269
  let total = null;
270
- let widgetsEnabled = true;
270
+ let cardFlags = cardFlagsFromMe(null);
271
271
  try {
272
272
  const me = noteServerReward(await (await cliClient()).me.getMe());
273
273
  if (me.ok && me.data) {
274
274
  if (me.data.builder) total = me.data.builder.total_credits;
275
- if (me.data.render) widgetsEnabled = me.data.render.widgets_enabled !== false;
275
+ cardFlags = cardFlagsFromMe(me);
276
276
  }
277
- } catch (_) { /* total + knob are optional; default widgets on */ }
277
+ } catch (_) { /* total + knob are optional; default to the standard rung */ }
278
+ const widgetsEnabled = cardFlags.showWidget;
278
279
 
279
280
  // Token count + estimated cost from the session usage. Split the tokens into
280
281
  // 'new' (fresh input + output + first-time cache writes) vs 'cached' re-reads
@@ -328,9 +329,12 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
328
329
  // Print the monospace card + write the branded HTML for the in-feed renderer
329
330
  // (shared emitCard, task 1275). The skill reads the printed path and passes
330
331
  // the contents to the visualization tool — the agent authors nothing, so the
331
- // card is identical every ship. task 1279: when the builder has widgets off,
332
- // emitCard prints only the monospace card (no HTML written or relayed).
333
- shipCard.emitCard(model, taskId, { widgetsEnabled });
332
+ // card is identical every ship. task 1002518: the resolved richness level
333
+ // reaches emitCard as flags — at the plain rung no HTML is written or
334
+ // relayed, at the minimal rung not even the monospace card is printed (the
335
+ // guaranteed [otb-confirm] line always is), and at the rich rung the
336
+ // widget's summary arrives already open.
337
+ shipCard.emitCard(model, taskId, { flags: cardFlags });
334
338
 
335
339
  // task 1687: in CI deploy mode this card is 'confirmed — landing' and the real
336
340
  // confirmed->shipped land happens minutes later, server-side — so the SHIPPED
@@ -338,7 +342,10 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
338
342
  // land-watch hook renders the shipped card on a later prompt once it lands.
339
343
  // Best-effort: a breadcrumb must never fail a ship (mirrors the card itself).
340
344
  if (postGradeStatus === 'confirmed') {
341
- try { require('./land-watch').writeBreadcrumb(taskId, { widgetsEnabled, cardInputs }); }
345
+ // `cardFlags` carries the resolved rung so the later-rendered shipped card
346
+ // matches the one this session printed; `widgetsEnabled` stays beside it
347
+ // so a breadcrumb written by an older CLI still reads correctly.
348
+ try { require('./land-watch').writeBreadcrumb(taskId, { widgetsEnabled, cardFlags, cardInputs }); }
342
349
  catch (_) { /* breadcrumb is best-effort */ }
343
350
  }
344
351
  } catch (_) {
@@ -8,7 +8,7 @@
8
8
 
9
9
  'use strict';
10
10
 
11
- const { cliClient, requireSession, arg, detectWorktreeName } = require('./cli-lib');
11
+ const { cliClient, requireSession, arg, detectWorktreeName, cardFlagsFromMe } = require('./cli-lib');
12
12
  const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal } = require('./ship-honesty.js');
13
13
  const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
14
14
  const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
@@ -342,10 +342,10 @@ async function shipMain() {
342
342
  removed,
343
343
  sandboxUrl: sandboxGate.url,
344
344
  });
345
- // task 1279: honour the builder's render-widgets knob (from the /me
346
- // fetched at the top of shipMain; default ON if absent/older server).
347
- const widgetsEnabled = me?.data?.render?.widgets_enabled !== false;
348
- shipCard.emitCard(model, taskId, { widgetsEnabled });
345
+ // task 1279 / 1002518: honour the builder's card-richness level (from the
346
+ // /me fetched at the top of shipMain; the standard rung if absent, and
347
+ // the boolean's old meaning against an older server).
348
+ shipCard.emitCard(model, taskId, { flags: cardFlagsFromMe(me) });
349
349
  } catch (_) { /* the review pause stands with or without the card */ }
350
350
  // #1061 polish: a sandbox review pause is a DELIBERATE, expected
351
351
  // checkpoint — not a failed ship. printReviewBlock already showed the URL
@@ -865,7 +865,16 @@ async function regradeMain() {
865
865
  // nothing to read, so there is no verdict to dodge. Confirming is additionally
866
866
  // permission-gated server-side on task.confirm.grade_bypass, so this widens
867
867
  // no authority — it only stops the ONE shape that had no exit at all.
868
- if (shouldConfirmWithoutGrade({ committedFileCount: committedFiles.length, args })) {
868
+ // task 1003944: the porcelain is MEASURED here and handed to the decision, so a
869
+ // re-grade weighs the same evidence a first ship does. Without it the guard saw
870
+ // only the flag and the (empty) committed diff, and an UNTRACKED deliverable —
871
+ // invisible to preflight's trackedDirty — confirmed straight through to shipped.
872
+ const emptyShip = shouldConfirmWithoutGrade({
873
+ committedFileCount: committedFiles.length,
874
+ porcelain: gitOk('git status --porcelain'),
875
+ args,
876
+ });
877
+ if (emptyShip.confirm) {
869
878
  console.log('');
870
879
  console.log(`Nothing to grade (the committed diff vs ${graderBaseline || 'baseline'} is empty) and this ship`);
871
880
  console.log('declares itself artifact-free — manual-confirming via POST /tasks/:id/confirm.');
@@ -906,6 +915,19 @@ async function regradeMain() {
906
915
  });
907
916
  return;
908
917
  }
918
+ // task 1003944: the two refusals are DIFFERENT advice. 'uncommitted-work' means
919
+ // the deliverable exists and was never committed — offering --allow-empty there
920
+ // would be telling the builder to assert the opposite of what the tree shows,
921
+ // which is how task 1363 shipped nothing and called it done. Only the genuinely
922
+ // clean tree gets pointed at the flag.
923
+ if (emptyShip.reason === 'uncommitted-work') {
924
+ console.error('Nothing to grade: nothing is committed on this branch, but your working tree has uncommitted changes:');
925
+ for (const f of emptyShip.uncommitted) console.error(` - ${f}`);
926
+ console.error('');
927
+ console.error('Your work was never committed — confirming now would mark the task "shipped" with NO artifact on main (the ledger would lie).');
928
+ console.error('Stage + commit your work (e.g. `git add -A && git commit`), then re-run --regrade.');
929
+ process.exit(1);
930
+ }
909
931
  console.error(`Nothing to grade: the committed diff vs ${graderBaseline || 'baseline'} is empty.`);
910
932
  console.error('The grader reads committed history, not the working tree — commit your fixes on this branch, then re-run --regrade.');
911
933
  console.error('If this task is DELIBERATELY artifact-free (a DB-only close, an ops verification), say so:');
@@ -9,6 +9,7 @@
9
9
  'use strict';
10
10
 
11
11
  const { cliClient } = require('./cli-lib');
12
+ const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
12
13
  const { preflight } = require('./ship-preflight-steps.js');
13
14
  const { playShipBell } = require('./ship-io.js');
14
15
  const { strandNextStep } = require('./ship-land.js');
@@ -121,10 +122,37 @@ function evaluateResumeAction({ task, lastClaim, builder }) {
121
122
  // A bare re-run (neither flag) keeps dead-ending, deliberately: the overwhelmingly
122
123
  // common cause of an empty diff is uncommitted work, and silently confirming that
123
124
  // would ship nothing while reporting success.
124
- function shouldConfirmWithoutGrade({ committedFileCount, args = [] }) {
125
- const emptyDiff = Number(committedFileCount) === 0;
126
- const declaredNoArtifact = args.includes('--allow-empty') || args.includes('--skip-grade');
127
- return emptyDiff && declaredNoArtifact;
125
+ //
126
+ // task 1003944 — THE FIRST CUT ASKED THE WRONG TWO QUESTIONS, and both answers let
127
+ // the task-1363 phantom ship back in on this path:
128
+ //
129
+ // (a) It never looked at the WORKING TREE. The only tree check a re-grade runs is
130
+ // preflight(), which uses trackedDirty and — by its own comment — deliberately
131
+ // ignores UNTRACKED files. So a deliverable that was never `git add`ed is
132
+ // invisible to both, and `--allow-empty` confirmed it, awarded credits, and
133
+ // (via finishShip's hardcoded dbOnly) landed the row at SHIPPED with HEAD
134
+ // never moved. That is task 1363 exactly: the ledger lying, CLAUDE.md §8.
135
+ // classifyEmptyShip already states the rule this violated — a dirty tree
136
+ // refuses EVEN UNDER --allow-empty, because the flag asserts there is no
137
+ // artifact and the tree proves there is one.
138
+ //
139
+ // (b) It accepted `--skip-grade` as the declaration. That flag declares nothing
140
+ // about artifacts — ship.js's usage block documents it as a grader-outage
141
+ // override, "NOT a routine flag" — and task 1365 had already REMOVED it from
142
+ // the first-ship empty-diff decision for precisely that reason. Task 1003146's
143
+ // recovery is unaffected: `--allow-empty` is the flag both refusal messages
144
+ // prescribe, and it still works.
145
+ //
146
+ // So this no longer carries a second, weaker copy of the rule: it hands the
147
+ // measured tree to classifyEmptyShip — the SAME function the first ship calls — and
148
+ // returns its verdict, so the two paths cannot drift apart again. `porcelain` is
149
+ // `git status --porcelain` output, passed in by the caller to keep this pure.
150
+ // `reason` is classifyEmptyShip's ('uncommitted-work' | 'nothing-to-ship' |
151
+ // 'allow-empty'), plus 'has-diff' when there is real committed work to grade.
152
+ function shouldConfirmWithoutGrade({ committedFileCount, porcelain = '', args = [] }) {
153
+ if (Number(committedFileCount) !== 0) return { confirm: false, reason: 'has-diff', uncommitted: [] };
154
+ const verdict = classifyEmptyShip(porcelain, { allowEmpty: args.includes('--allow-empty') });
155
+ return { confirm: !verdict.refuse, reason: verdict.reason, uncommitted: verdict.uncommitted || [] };
128
156
  }
129
157
 
130
158
  // task 941: resume a `confirmed`-but-unshipped task — the merge/deploy leg only
@@ -67,7 +67,7 @@ const { checkTree: _checkTree } = require('./tree-preflight.js');
67
67
  if (_fault) { console.error(_fault); process.exit(1); }
68
68
  }
69
69
 
70
- const { cliClient, requireSession, fmtUsd, printOnboardingNudge, printCostWarning, printNeedsNudge, hallBase, API_BASE, rewardPolicy, costPlusOnlyNote, isDispatcherIncompatError } = require('./cli-lib');
70
+ const { cliClient, requireSession, fmtUsd, printOnboardingNudge, printCostWarning, printNeedsNudge, hallBase, API_BASE, rewardPolicy, costPlusOnlyNote, isDispatcherIncompatError, cardFlagsFromMe } = require('./cli-lib');
71
71
  const { contextPack, formatContextPackCompact } = require('./context-pack');
72
72
  const { depsFinding } = require('./preflight');
73
73
 
@@ -782,12 +782,18 @@ async function main() {
782
782
  }
783
783
 
784
784
  // ---------- widget (HTML for the app renderer) ----------
785
- // task 1279: the per-builder "render widgets" knob. When a builder turns
786
- // widgets off, even an explicit --widget request degrades to the markdown card
787
- // below — so no HTML is emitted, read, or re-relayed (a real token saving, not
788
- // just a visual change). Default ON; /me carries the resolved boolean. Headless
789
- // /cron callers don't pass --widget at all (no renderer), so they're unaffected.
790
- const widgetsPref = me.ok ? (me.data?.render?.widgets_enabled !== false) : true;
785
+ // task 1279 / 1002518: the per-builder card-richness level. Below the widget
786
+ // rung, even an explicit --widget request degrades to the markdown card below
787
+ // — so no HTML is emitted, read, or re-relayed (a real token saving, not just
788
+ // a visual change). /me carries the resolved level; an older instance carries
789
+ // only the boolean and cardFlagsFromMe reads either. Headless/cron callers
790
+ // don't pass --widget at all (no renderer), so they're unaffected.
791
+ //
792
+ // The `minimal` rung lands on the markdown card here rather than on nothing:
793
+ // the lifecycle card has a guaranteed one-line confirmation to fall back to,
794
+ // and this card — the list of what you can claim — has no such floor. Dropping
795
+ // it would leave a session opening with no answer to the question it asked.
796
+ const widgetsPref = me.ok ? cardFlagsFromMe(me).showWidget : true;
791
797
  if (widget && widgetsPref) {
792
798
  const myClaims = me.ok ? (me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : [])) : [];
793
799
  // Private warnings (budget / onboarding next-stage) → stderr, so stdout
@@ -46,12 +46,13 @@ function membershipKind({ githubLogin } = {}) {
46
46
  // Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
47
47
  // github_id builds on project <client_id>, coarse membership <membership_kind>".
48
48
  //
49
- // BOTH SPELLINGS GO ON THE WIRE FOR THE WINDOW (task 1003090). The receiver is the
50
- // HUB, a different instance on its own upgrade schedule: send only the new field and
51
- // an un-upgraded hub silently defaults every membership to non-owner (it would drop
52
- // the owner flag); send only the legacy field and an upgraded hub keeps translating
53
- // forever. Sending both is what makes the two ends independent — the removal task
54
- // drops `role` once the hub is past the window. Runs on every
49
+ // ONE SPELLING ON THE WIRE: `membership_kind` (task 1003090 renamed it; task 1003092
50
+ // closed the cross-version window). During the window this sender wrote BOTH
51
+ // spellings, because the receiver is the HUB — a different instance on its own
52
+ // upgrade schedule — and new-field-only against an un-upgraded hub would have
53
+ // silently dropped the owner flag. The hub has been past that release since core
54
+ // 1.19.241 and no longer accepts the legacy field (its schema is strict), so
55
+ // sending it again would turn every check-in into a 400. Runs on every
55
56
  // FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
56
57
  // project still appears in the builder's My Projects with no hub browser session.
57
58
  // Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
@@ -72,8 +73,6 @@ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, ava
72
73
  display_name: displayName ?? null,
73
74
  avatar_url: avatarUrl ?? null,
74
75
  membership_kind: kind,
75
- // LEGACY (cross-version window): 'member' → 'builder' for an un-upgraded hub.
76
- role: kind === 'member' ? 'builder' : kind,
77
76
  }),
78
77
  });
79
78
  } catch (err) {
@@ -135,8 +135,9 @@ function idpConfigured() {
135
135
  //
136
136
  // 'member', not 'builder' (task 1003090, ADR 0177 §"the fifth use"): ADR 0174 reserves
137
137
  // `role` — and the craft name 'Builder' — for Artist / Ideator / Builder, so the coarse
138
- // project membership says what it means. The legacy spelling still goes on the wire for
139
- // the cross-version window; see reportMembershipCheckIn.
138
+ // project membership says what it means. It is the only spelling on the wire — the
139
+ // cross-version window that carried the legacy one closed under task 1003092; see
140
+ // reportMembershipCheckIn.
140
141
 
141
142
  module.exports = {
142
143
  SCOPE,
@@ -11,8 +11,11 @@
11
11
  //
12
12
  // 1. SCOPE-SUBSET — every `touches[]` element must be CONTAINED WITHIN the
13
13
  // goal's territory (the union of its modules' globs, resolved via
14
- // module-scope-map G01). This bounds *everyone*, regardless of rank: a goal
15
- // member can only author work inside the goal's modules.
14
+ // module-scope-map G01) or in SHARED_GLOBS, the small set of roots no module
15
+ // can honestly own that every task legitimately writes (`tests/` — see the
16
+ // SHARED territory block in module-scope-map.js, task 1003655 / ADR 0288).
17
+ // This bounds *everyone*, regardless of rank: a goal member can only author
18
+ // work inside the goal's modules, plus that shared floor.
16
19
  //
17
20
  // 2. PROTECTED-PATH — a sub-Metic member (xenos/thetes) may not author a task
18
21
  // touching the permission/ship-grade/deploy/migration core, even when that
@@ -37,7 +40,7 @@
37
40
  'use strict';
38
41
 
39
42
  const { matchOne } = require('./path-match');
40
- const { globsForModules } = require('./module-scope-map');
43
+ const { globsForModules, SHARED_GLOBS } = require('./module-scope-map');
41
44
  const { checkPermissionPaths } = require('./permission-path-check');
42
45
 
43
46
  function clean(arr) {
@@ -54,13 +57,25 @@ function isCovered(touch, goalGlobs) {
54
57
  return matchOne(touch, goalGlobs || []) !== null;
55
58
  }
56
59
 
57
- // Wall 1 — scope subset. Pure: { ok, out_of_scope[], goal_globs[] }.
58
- // `ok` is true iff every cleaned touch is covered by the goal's resolved globs.
59
- // An empty/unknown module set resolves to zero globs → nothing is covered.
60
+ // Wall 1 — scope subset. Pure: { ok, out_of_scope[], goal_globs[], shared_globs[] }.
61
+ // `ok` is true iff every cleaned touch is covered by the goal's resolved globs OR
62
+ // by the shared floor. An empty/unknown module set resolves to zero goal globs.
63
+ //
64
+ // `goal_globs` stays the MODULE-resolved set only — the shared floor is reported
65
+ // separately and never folded in. check()'s unknown_scope guard tests
66
+ // `goal_globs.length === 0`, so mixing the always-present shared globs into it
67
+ // would make a goal scoped to nothing but garbage resolve to a non-empty
68
+ // territory and slip past that fail-closed edge.
60
69
  function scopeSubset(goalScopeModules, touches) {
61
70
  const goalGlobs = globsForModules(clean(goalScopeModules));
62
- const outOfScope = clean(touches).filter((p) => !isCovered(p, goalGlobs));
63
- return { ok: outOfScope.length === 0, out_of_scope: outOfScope, goal_globs: goalGlobs };
71
+ const outOfScope = clean(touches)
72
+ .filter((p) => !isCovered(p, goalGlobs) && !isCovered(p, SHARED_GLOBS));
73
+ return {
74
+ ok: outOfScope.length === 0,
75
+ out_of_scope: outOfScope,
76
+ goal_globs: goalGlobs,
77
+ shared_globs: [...SHARED_GLOBS],
78
+ };
64
79
  }
65
80
 
66
81
  // Wall 2 — the protected-path delegate. Thin wrapper over checkPermissionPaths
@@ -388,6 +388,44 @@ const MODULE_GLOBS = {
388
388
  ],
389
389
  };
390
390
 
391
+ // ---------------------------------------------------------------------------
392
+ // SHARED territory — the globs EVERY goal's wall implicitly includes (task 1003655).
393
+ //
394
+ // MODULE_GLOBS answers "who OWNS this path". Some paths have no honest owner: a
395
+ // shared root that every module writes into and none ships. `tests/` is the one
396
+ // that mattered — 751 flat suites covering all ~24 modules, owned by no key — so
397
+ // scopeSubset() denied EVERY touch under it. Since every real task adds a test,
398
+ // the create gate (POST /goals/:id/tasks) refused any task that honestly declared
399
+ // its own test file, whichever goal it was filed under. The wall only looked
400
+ // passable because callers under-declared their touches[] (idea 1000816).
401
+ //
402
+ // The two fixes we did NOT take, and why:
403
+ // • Give `tests/` to a key (devsecops, methodology). That is a FALSE ownership
404
+ // claim — an ideas-module test is the ideas module's work, not devsecops' —
405
+ // and it would hand one goal's members the whole suite while denying it to
406
+ // everyone else, which is worse than the gap.
407
+ // • Enumerate per-module test paths. The suites are flat and named by feature,
408
+ // not by module, and the glob format has no wildcards to carve with; the list
409
+ // would rot on the next test file.
410
+ //
411
+ // So `tests/` is SHARED, not owned: inside every goal's wall, outside every
412
+ // module's territory. That is sound because a test file is not a shipping
413
+ // surface — nothing imports it, so its blast radius on another module is zero —
414
+ // and the other two walls are untouched: claim collisions are still caught by
415
+ // touchesOverlap, and the protected-path wall (below) runs independently.
416
+ //
417
+ // FAIL-CLOSED, like `protected`: the exported list is DERIVED, not trusted. A
418
+ // shared glob is dropped if it overlaps PROTECTED_GLOBS, because a protected path
419
+ // in here would widen every goal's wall into the trust core in one line. Adding
420
+ // an entry is a deliberate design decision (ADR 0288) — the bar is "no module can
421
+ // honestly own it AND every task legitimately writes it", which today is `tests/`
422
+ // alone. Module-owned suites (modules/<key>/tests/) are already covered by their
423
+ // module's glob and do NOT belong here.
424
+ const SHARED_GLOBS_RAW = ['tests/'];
425
+ const SHARED_GLOBS = Object.freeze(
426
+ SHARED_GLOBS_RAW.filter((g) => !touchesOverlap([g], PROTECTED_GLOBS)),
427
+ );
428
+
391
429
  // A glob set is protected iff it overlaps the imported PROTECTED_GLOBS — the one
392
430
  // source of truth (permission-path-check.js). Prefix-aware both directions
393
431
  // (path-match.touchesOverlap), so e.g. a module owning 'src/bongos/routes/inbox.js'
@@ -451,6 +489,8 @@ function scopeIncludesProtected(keys) {
451
489
  module.exports = {
452
490
  MODULE_GLOBS,
453
491
  MODULE_SCOPE_MAP,
492
+ SHARED_GLOBS,
493
+ SHARED_GLOBS_RAW,
454
494
  isProtected,
455
495
  moduleKeys,
456
496
  isModuleKey,