@bongos/core 1.19.645 → 1.19.647

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.
@@ -254,6 +254,117 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
254
254
  return [...base, ...extra];
255
255
  }
256
256
 
257
+ // ---------------------------------------------------------------------------
258
+ // PURE: builder-requested WIDENING (task 1003087 / idea 1000793)
259
+ // ---------------------------------------------------------------------------
260
+
261
+ // A builder sometimes needs a directory their task scope did not pull — to run
262
+ // the full suite, to read a caller, to regenerate a TREE-DEPENDENT artifact. The
263
+ // obvious move, `git sparse-checkout add <path>`, does not survive: the */10
264
+ // source-fetch cron re-applies the SERVER-computed set and deletes the addition,
265
+ // including WHILE A COMMAND IS RUNNING (a live suite died mid-run with
266
+ // MODULE_NOT_FOUND that read exactly like a code bug; a box-committed
267
+ // gen-repo-map regen off a narrow tree reds CI's freshness gate — learning
268
+ // 1000192, which stranded PR #254).
269
+ //
270
+ // So the widening is recorded SERVER-side and unioned in HERE, which is what
271
+ // makes it tick-surviving: the cron asks this endpoint for the set on every tick,
272
+ // so it gets the union too. Nothing in the instance-owned fetch script changes —
273
+ // which matters, because the neutral core does not ship it (ADR 0150).
274
+
275
+ // Bounds. Small on purpose: this is an escape hatch for a handful of dirs, not a
276
+ // second scope system. Over the cap the extras are dropped, not an error — a
277
+ // truncated widen still fetches, and failing the source pull over it would be a
278
+ // far worse trade than materializing less.
279
+ const MAX_WIDEN_PATHS = 24;
280
+ const MAX_WIDEN_PATH_LEN = 200;
281
+
282
+ // withinAny(p, roots) — is p one of roots, or underneath one? Cone mode
283
+ // materializes a directory and everything below it, so "already covered" and
284
+ // "inside the allowed surface" are the same ancestor walk.
285
+ function withinAny(p, roots) {
286
+ let q = p;
287
+ for (;;) {
288
+ if (roots.includes(q)) return true;
289
+ const cut = q.lastIndexOf('/');
290
+ if (cut < 0) return false;
291
+ q = q.slice(0, cut);
292
+ }
293
+ }
294
+
295
+ // normalizeWidenPath — repo-relative POSIX directory, or null when it is not one.
296
+ //
297
+ // This is a path that reaches `git sparse-checkout set` on a box, so it is
298
+ // validated as INPUT rather than trusted: absolute paths, Windows drive letters,
299
+ // '..' traversal and empty segments are all refused rather than normalized away,
300
+ // because "cleaned up into something that still works" is how a traversal becomes
301
+ // a shrug. Backslashes are folded to '/' first so a Windows-typed path is judged
302
+ // on what it means, not on its separator.
303
+ function normalizeWidenPath(raw) {
304
+ if (typeof raw !== 'string') return null;
305
+ // BACKSLASH by char code, not as a literal: this file is edited through
306
+ // tooling that has silently collapsed a doubled backslash before, and a
307
+ // separator fold that quietly stops folding is invisible until a Windows
308
+ // path slips through as one long segment.
309
+ const BACKSLASH = String.fromCharCode(92);
310
+ let p = raw.trim().split(BACKSLASH).join('/');
311
+ while (p.startsWith('./')) p = p.slice(2);
312
+ p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
313
+ if (!p || p === '.' || p.length > MAX_WIDEN_PATH_LEN) return null;
314
+ if (p.startsWith('/') || /^[A-Za-z]:/.test(p)) return null;
315
+ const segs = p.split('/');
316
+ if (!segs.every((s) => s && s !== '.' && s !== '..' && /^[A-Za-z0-9._-]+$/.test(s))) return null;
317
+ return p;
318
+ }
319
+
320
+ // admissibleWidenPaths — the widen requests this builder's RANK actually permits,
321
+ // normalized, deduped, capped and sorted.
322
+ //
323
+ // THIS IS THE SECURITY BOUNDARY OF THE FEATURE, so it is stated plainly: widening
324
+ // changes what is MATERIALIZED on a box, and rank decides how much of the repo a
325
+ // box may hold at all (ADR 0031 section 9.2 — 'full' for Metic+, the 'starter'
326
+ // surface below that). Honoring an arbitrary widen would hand a Xenos the whole
327
+ // tree through a convenience flag, which is a rank escalation dressed as
328
+ // ergonomics. So:
329
+ // - a builder whose RANK scope is 'full' may widen anywhere (they are already
330
+ // entitled to the entire repo; the widen only decides what gets pulled);
331
+ // - anyone else may widen only INSIDE the starter surface. That is not a null
332
+ // grant: a task-scoped box is BASE union its modules, which is NARROWER than
333
+ // starter, so this is exactly the room to climb back to what their rank
334
+ // already allowed — and no further.
335
+ // The authoring wall is untouched either way. What a builder may CHANGE is still
336
+ // goal-scope-check against the module globs, never what happens to be on disk
337
+ // (ADR 0148).
338
+ function admissibleWidenPaths(widenPaths, rank) {
339
+ const unrestricted = boxScopeForRank(rank) === 'full';
340
+ const out = [];
341
+ const seen = new Set();
342
+ for (const raw of Array.isArray(widenPaths) ? widenPaths : []) {
343
+ const p = normalizeWidenPath(raw);
344
+ if (!p || seen.has(p)) continue;
345
+ if (!unrestricted && !withinAny(p, STARTER_SPARSE_PATHS)) continue;
346
+ seen.add(p);
347
+ out.push(p);
348
+ if (out.length >= MAX_WIDEN_PATHS) break;
349
+ }
350
+ return out.sort();
351
+ }
352
+
353
+ // withWidenPaths — union admitted widen dirs into a computed sparse set, skipping
354
+ // any the set already covers (cone mode pulls a dir and everything under it, so
355
+ // adding 'scripts/gds' when 'scripts' is present is noise in the git command and
356
+ // noise in the box's scope readout). A null set is the 'full' clone — there is
357
+ // nothing to widen into, so it passes through untouched.
358
+ function withWidenPaths(sparsePaths, admitted) {
359
+ if (!Array.isArray(sparsePaths) || !admitted || admitted.length === 0) return sparsePaths;
360
+ const out = [...sparsePaths];
361
+ for (const dir of admitted) {
362
+ if (withinAny(dir, out)) continue;
363
+ out.push(dir);
364
+ }
365
+ return out;
366
+ }
367
+
257
368
  // ---------------------------------------------------------------------------
258
369
  // PURE: the per-fetch access decision (consumed by GET /box/source-access)
259
370
  // ---------------------------------------------------------------------------
@@ -295,7 +406,7 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
295
406
  // { allowed:false, reason:'BUILDER_INACTIVE' }
296
407
  // { allowed:false, reason:'INSUFFICIENT_RANK', required, actual }
297
408
  // { allowed:true, scope, spec }
298
- function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap } = {}) {
409
+ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap, widenPaths } = {}) {
299
410
  if (status && status !== 'active') {
300
411
  return { allowed: false, reason: 'BUILDER_INACTIVE' };
301
412
  }
@@ -307,23 +418,32 @@ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, sco
307
418
  actual: rank ?? null,
308
419
  };
309
420
  }
421
+ // The builder's own widening, narrowed to what their rank already reaches
422
+ // (task 1003087). Computed once here so every sparse branch below unions the
423
+ // SAME set — a widen that applied to one scope and not another would be a
424
+ // surprise that only surfaces when a claim changes.
425
+ const widen = admissibleWidenPaths(widenPaths, rank);
426
+
310
427
  // Claim-driven task scope (present → the box asks for exactly its work's code).
311
428
  if (Array.isArray(scopeKeys)) {
312
429
  if (scopeKeys.length > 0) {
313
- const sparsePaths = sparsePathsForScopeKeys(scopeKeys, moduleScopeMap);
430
+ const sparsePaths = withWidenPaths(sparsePathsForScopeKeys(scopeKeys, moduleScopeMap), widen);
314
431
  return { allowed: true, scope: 'task', spec: { scope: 'task', mode: 'sparse', sparsePaths } };
315
432
  }
316
433
  // Empty. A builder mid-claim whose goals declare no wall gets the rank scope,
317
434
  // NOT a stripped box — the planning gap is not theirs to pay for.
318
435
  if (!hasActiveClaims) {
319
- const sparsePaths = sparsePathsForScopeKeys([], moduleScopeMap);
436
+ const sparsePaths = withWidenPaths(sparsePathsForScopeKeys([], moduleScopeMap), widen);
320
437
  return { allowed: true, scope: 'base', spec: { scope: 'base', mode: 'sparse', sparsePaths } };
321
438
  }
322
439
  }
323
440
  // No claim signal (no lifecycle port on this instance), or claims with an
324
441
  // undeclared goal wall: the pre-ADR-0148 rank fallback.
325
442
  const scope = boxScopeForRank(rank);
326
- return { allowed: true, scope, spec: cloneSpecForScope(scope) };
443
+ const spec = cloneSpecForScope(scope);
444
+ // 'full' carries sparsePaths null (the whole repo) — withWidenPaths passes it
445
+ // through, because there is no sparse set to widen INTO.
446
+ return { allowed: true, scope, spec: { ...spec, sparsePaths: withWidenPaths(spec.sparsePaths, widen) } };
327
447
  }
328
448
 
329
449
  // scopeMetadata — PURE. The credential-FREE projection of a source-access
@@ -470,6 +590,10 @@ module.exports = {
470
590
  boxCanHoldSource,
471
591
  cloneSpecForScope,
472
592
  sparsePathsForScopeKeys, // ADR 0148 — BASE ∪ the claims' module dirs (cone-mode)
593
+ MAX_WIDEN_PATHS, // task 1003087 — the builder-widening cap
594
+ normalizeWidenPath, // task 1003087 — repo-relative dir, or null
595
+ admissibleWidenPaths, // task 1003087 — the widen requests this RANK permits
596
+ withWidenPaths, // task 1003087 — union a widen set into a sparse set
473
597
  scopeMetadata, // BV1.R100 — credential-free projection for GET /box/scope
474
598
  // pure — decisions
475
599
  decideSourceAccess,
@@ -397,6 +397,26 @@ function decideEnsureAction({ boxState, rank, status, blocked = false, floor = D
397
397
  // DB helpers (async). Each takes the pg pool (or a client mid-transaction).
398
398
  // ---------------------------------------------------------------------------
399
399
 
400
+ // setBoxWidenPaths — replace a box row's builder-requested widen set (task
401
+ // 1003087). Whole-set replace rather than add/remove SQL: the set is tiny and
402
+ // capped, the caller has already normalized and rank-filtered it, and a
403
+ // read-modify-write in one statement keeps two concurrent widens from
404
+ // interleaving into a half-applied list.
405
+ //
406
+ // Returns the stored array, or null when the builder has no box row — the
407
+ // caller turns that into a 404 rather than silently creating one, because a
408
+ // widen with no box to apply it to is a request that has not happened yet.
409
+ async function setBoxWidenPaths(db, builderId, paths) {
410
+ const { rows } = await db.query(
411
+ `UPDATE builder_boxes
412
+ SET widen_paths = $2::text[], updated_at = now()
413
+ WHERE builder_id = $1
414
+ RETURNING widen_paths`,
415
+ [builderId, Array.isArray(paths) ? paths : []]
416
+ );
417
+ return rows[0] ? rows[0].widen_paths : null;
418
+ }
419
+
400
420
  async function getBoxByBuilderId(db, builderId) {
401
421
  const { rows } = await db.query(
402
422
  `SELECT * FROM builder_boxes WHERE builder_id = $1`,
@@ -891,6 +911,7 @@ module.exports = {
891
911
  decideEnsureAction,
892
912
  // db
893
913
  getBoxByBuilderId,
914
+ setBoxWidenPaths,
894
915
  ensureBoxRow,
895
916
  listBoxes,
896
917
  recordEvent,
@@ -480,8 +480,16 @@ module.exports = function buildBoxRouter() {
480
480
  hasActiveClaims = undefined;
481
481
  }
482
482
  }
483
- const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
483
+ // Read the box BEFORE deciding: its widen_paths are an INPUT to the sparse
484
+ // set now (task 1003087). A builder-requested directory is unioned in here,
485
+ // which is what makes it survive the */10 cron — the cron re-asks THIS
486
+ // endpoint every tick, so the union comes back with it instead of being
487
+ // deleted by the next `git sparse-checkout set`.
484
488
  const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
489
+ const decision = boxAccess.decideSourceAccess({
490
+ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap,
491
+ widenPaths: box ? box.widen_paths : null,
492
+ });
485
493
  const auditDeny = (reason, extra) =>
486
494
  box && boxAccess.recordSourceEvent(pool, {
487
495
  box, builderId: req.builder.id, event: 'source_deny',
@@ -598,7 +606,13 @@ module.exports = function buildBoxRouter() {
598
606
  hasActiveClaims = undefined;
599
607
  }
600
608
  }
601
- const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
609
+ // Same widen input as the real fetch this readout exists precisely so it
610
+ // cannot drift from what the box will actually hold.
611
+ const scopeBox = await boxes.getBoxByBuilderId(pool, req.builder.id);
612
+ const decision = boxAccess.decideSourceAccess({
613
+ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap,
614
+ widenPaths: scopeBox ? scopeBox.widen_paths : null,
615
+ });
602
616
  if (!decision.allowed) {
603
617
  return res.fail('SOURCE_ACCESS_REVOKED', 403, {
604
618
  reason: decision.reason,
@@ -753,6 +767,98 @@ module.exports = function buildBoxRouter() {
753
767
  }
754
768
  });
755
769
 
770
+ // GET /box/widen — the caller's own recorded widen set (task 1003087).
771
+ // rank: any authenticated builder, own resource. Reports what is STORED plus
772
+ // what is currently ADMITTED, because those differ whenever a request sits
773
+ // outside the builder's rank scope — showing only the stored list would let a
774
+ // Xenos believe a widen took effect that the fetch quietly drops.
775
+ router.get('/box/widen', auth.requireBuilder, async (req, res) => {
776
+ try {
777
+ const builder = await gdsDb.getBuilderById(req.builder.id);
778
+ const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
779
+ if (!box) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
780
+ const stored = Array.isArray(box.widen_paths) ? box.widen_paths : [];
781
+ const admitted = boxAccess.admissibleWidenPaths(stored, builder ? builder.rank : null);
782
+ res.json({
783
+ widen_paths: stored,
784
+ admitted,
785
+ ignored: stored.filter((p) => !admitted.includes(boxAccess.normalizeWidenPath(p))),
786
+ max: boxAccess.MAX_WIDEN_PATHS,
787
+ });
788
+ } catch (err) {
789
+ log.error('[gds] GET /box/widen', err);
790
+ res.fail('widen_read_failed', { status: 500, message: 'internal error' });
791
+ }
792
+ });
793
+
794
+ // POST /box/widen — record extra sparse-checkout directories that must SURVIVE
795
+ // the */10 source-fetch (task 1003087, idea 1000793). rank: any authenticated
796
+ // builder, OWN box (builder_id-scoped in SQL, so no id from the body can point
797
+ // this at someone else's box).
798
+ //
799
+ // Deliberately NOT allowBoxScope. The on-box crons read source-access with a
800
+ // box-scoped session (ADR 0053); this is a WRITE that changes what the box
801
+ // pulls forever after, so it takes the builder's own session — the same posture
802
+ // that keeps a stolen box token from re-scoping the checkout it was stolen from.
803
+ //
804
+ // add/remove are applied to the stored set and the result is re-normalized, so
805
+ // the endpoint is idempotent: widening twice is not an error, and neither is
806
+ // removing something that was never there.
807
+ //
808
+ // A path outside the caller's rank scope is ACCEPTED into storage but reported
809
+ // in `ignored` rather than refused. That is the honest shape: rank can change,
810
+ // and a builder promoted to Metic should find the widen they asked for as a
811
+ // Xenos simply start working, instead of having been silently discarded months
812
+ // earlier. What it can never do is take effect early — admissibleWidenPaths is
813
+ // re-evaluated against the LIVE rank on every single fetch.
814
+ router.post('/box/widen', auth.requireBuilder, async (req, res) => {
815
+ if (validateOrRespond(req, res, {
816
+ add: { type: 'array' },
817
+ remove: { type: 'array' },
818
+ clear: { type: 'boolean' },
819
+ })) return;
820
+ try {
821
+ const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
822
+ if (!box) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
823
+ const builder = await gdsDb.getBuilderById(req.builder.id);
824
+
825
+ const current = Array.isArray(box.widen_paths) ? box.widen_paths : [];
826
+ const next = new Set(req.body && req.body.clear === true ? [] : current);
827
+
828
+ const rejected = [];
829
+ for (const raw of Array.isArray(req.body && req.body.add) ? req.body.add : []) {
830
+ const p = boxAccess.normalizeWidenPath(raw);
831
+ if (!p) { rejected.push(String(raw).slice(0, 120)); continue; }
832
+ next.add(p);
833
+ }
834
+ for (const raw of Array.isArray(req.body && req.body.remove) ? req.body.remove : []) {
835
+ const p = boxAccess.normalizeWidenPath(raw);
836
+ if (p) next.delete(p);
837
+ }
838
+ if (rejected.length) {
839
+ return res.fail('bad_widen_path', 400, {
840
+ rejected,
841
+ message: 'a widen path must be a repo-relative directory: no absolute paths, no "..", no drive letters',
842
+ });
843
+ }
844
+
845
+ const stored = [...next].sort().slice(0, boxAccess.MAX_WIDEN_PATHS);
846
+ const saved = await boxes.setBoxWidenPaths(pool, req.builder.id, stored);
847
+ if (saved === null) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
848
+ const admitted = boxAccess.admissibleWidenPaths(saved, builder ? builder.rank : null);
849
+ res.json({
850
+ ok: true,
851
+ widen_paths: saved,
852
+ admitted,
853
+ ignored: saved.filter((p) => !admitted.includes(p)),
854
+ max: boxAccess.MAX_WIDEN_PATHS,
855
+ });
856
+ } catch (err) {
857
+ log.error('[gds] POST /box/widen', err);
858
+ res.fail('widen_failed', { status: 500, message: 'internal error' });
859
+ }
860
+ });
861
+
756
862
  // GET /box/authorized-keys — the box pulls its builder's authorized_keys.
757
863
  // rank: any authenticated builder (own resource) — but the ALLOW/DENY is decided
758
864
  // INSIDE from the caller's LIVE rank+status (boxAccess.decideSourceAccess), NOT a
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.645",
3
+ "version": "1.19.647",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.645",
9
+ "version": "1.19.647",
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.645",
3
+ "version": "1.19.647",
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",
@@ -15,6 +15,18 @@
15
15
  //
16
16
  // Usage: node scripts/gds/box-sync.js # re-fetch now
17
17
  // node scripts/gds/box-sync.js --dry-run # print what it WOULD run
18
+ // node scripts/gds/box-sync.js --widen <dir> # keep <dir> across ticks
19
+ // node scripts/gds/box-sync.js --unwiden <dir> # stop keeping it
20
+ // node scripts/gds/box-sync.js --widen-list # what is kept, and what is ignored
21
+ //
22
+ // WHY --widen EXISTS (task 1003087 / idea 1000793). `git sparse-checkout add`
23
+ // does not survive: the */10 cron re-applies the SERVER-computed set and deletes
24
+ // the addition, sometimes MID-COMMAND — a live test suite died with
25
+ // MODULE_NOT_FOUND that read exactly like a code bug. --widen records the
26
+ // directory server-side instead, so every fetch (the cron included) returns it
27
+ // in the set and it stops being deleted. It is capped, and it can never widen
28
+ // past what your RANK already reaches — a request outside it is stored but
29
+ // reported as ignored rather than silently applied.
18
30
 
19
31
  const fs = require('node:fs');
20
32
  const path = require('node:path');
@@ -36,7 +48,78 @@ function resolveFetchScript(opts = {}) {
36
48
  try { return fs.existsSync(p) ? p : null; } catch { return null; }
37
49
  }
38
50
 
51
+ // valueAfter(argv, flag) — the argument following a flag, or null. Kept tiny and
52
+ // pure so the arg handling is testable without spawning anything.
53
+ function valueAfter(argv, flag) {
54
+ const i = argv.indexOf(flag);
55
+ if (i < 0) return null;
56
+ const v = argv[i + 1];
57
+ return typeof v === 'string' && v && !v.startsWith('--') ? v : null;
58
+ }
59
+
60
+ // formatWidenReport — PURE. The lines to print for a /box/widen body. Reports
61
+ // IGNORED entries loudly: a widen the server accepted into storage but will not
62
+ // apply (because it is outside your rank scope) looks identical to a working one
63
+ // unless it is said out loud, and a silently-inert widen is exactly the class of
64
+ // bug this whole task is about.
65
+ function formatWidenReport(body) {
66
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
67
+ return ['widen: could not read your widen set.'];
68
+ }
69
+ const kept = Array.isArray(body.widen_paths) ? body.widen_paths : [];
70
+ const ignored = Array.isArray(body.ignored) ? body.ignored : [];
71
+ const out = [];
72
+ out.push(kept.length
73
+ ? `widen: keeping ${kept.length} path(s) across ticks: ${kept.join(', ')}`
74
+ : 'widen: nothing kept — the fetch set is whatever your claim scope says.');
75
+ if (ignored.length) {
76
+ out.push(`widen: IGNORED (outside your rank scope, stored but not applied): ${ignored.join(', ')}`);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // The --widen family. Talks to the control plane, so it works off a box too —
82
+ // unlike the fetch itself, the widen set is server state you can inspect and
83
+ // edit from anywhere.
84
+ async function runWiden(argv) {
85
+ const { apiCall } = require('./cli-lib');
86
+ const add = valueAfter(argv, '--widen');
87
+ const remove = valueAfter(argv, '--unwiden');
88
+ const clear = argv.includes('--widen-clear');
89
+ const listOnly = !add && !remove && !clear;
90
+
91
+ const r = listOnly
92
+ ? await apiCall('GET', '/api/gds/box/widen')
93
+ : await apiCall('POST', '/api/gds/box/widen', {
94
+ ...(add ? { add: [add] } : {}),
95
+ ...(remove ? { remove: [remove] } : {}),
96
+ ...(clear ? { clear: true } : {}),
97
+ });
98
+
99
+ if (!r || (r.status !== 200 && r.status !== 201)) {
100
+ const code = r && r.data && r.data.error ? r.data.error : `HTTP ${r ? r.status : '?'}`;
101
+ if (code === 'no_box') {
102
+ console.error('widen: you have no dev box — there is nothing to widen. Provision one first.');
103
+ } else if (code === 'bad_widen_path') {
104
+ const bad = r.data && r.data.rejected ? r.data.rejected.join(', ') : '';
105
+ console.error(`widen: refused ${bad} — give a repo-relative DIRECTORY (no leading /, no "..").`);
106
+ } else {
107
+ console.error(`widen: failed (${code}).`);
108
+ }
109
+ return 1;
110
+ }
111
+ for (const line of formatWidenReport(r.data)) console.log(line);
112
+ if (!listOnly) console.log('widen: run `box-sync` (no flags) to apply it to the checkout now.');
113
+ return 0;
114
+ }
115
+
39
116
  function main(argv = process.argv.slice(2)) {
117
+ // The widen family is server state, not a fetch — handled before the on-a-box
118
+ // check, so it works from a laptop too.
119
+ if (argv.some((a) => a === '--widen' || a === '--unwiden' || a === '--widen-list' || a === '--widen-clear')) {
120
+ runWiden(argv).then((code) => process.exit(code), () => process.exit(1));
121
+ return;
122
+ }
40
123
  const dryRun = argv.includes('--dry-run') || argv.includes('-n');
41
124
  const script = resolveFetchScript();
42
125
  if (!script) {
@@ -130,4 +213,4 @@ async function reportScope() {
130
213
 
131
214
  if (require.main === module) main();
132
215
 
133
- module.exports = { resolveFetchScript, FETCH_SCRIPT, formatScopeReport };
216
+ module.exports = { resolveFetchScript, FETCH_SCRIPT, formatScopeReport, formatWidenReport, valueAfter };
@@ -431,7 +431,20 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
431
431
  landedNow[name] = { module: mod.key, source: `.claude/skills/${name}`, coreShipped: true };
432
432
  }
433
433
  }
434
- const withdrawable = Object.keys(landedPreviously).filter((n) => SKILL_NAME_RE.test(n) && !landedNow[n]);
434
+ // A name the CORE ships now is the core's, whatever an older manifest said — a core
435
+ // skill can take over a name a module once owned, and step 1 copies it in on this very
436
+ // run. Without this guard 1c deleted it again immediately (task 1003818, a regression
437
+ // in 1003632's own first cut). materializeModuleSkillsIntoCore states the same rule for
438
+ // the checkout side via its `tracked(name)` check; this is the instance-side twin.
439
+ //
440
+ // The `!skipSkills.has(name)` half is load-bearing, not belt-and-braces: when a DISABLED
441
+ // module declares a core-shipped name, step 1 SKIPS the copy (that is the task 2103b
442
+ // exclusion), so the instance's dir really is stale and must still be withdrawn. Keeping
443
+ // it on the strength of "the core ships this name" would resurrect exactly the bug 1c
444
+ // exists to fix. Deliberately asked of the CORE source tree rather than the roster, so a
445
+ // name no enabled module declares any more is still recognised as legitimately sourced.
446
+ const coreShipsNow = (name) => fs.existsSync(path.join(skillsSrc, name)) && !skipSkills.has(name);
447
+ const withdrawable = Object.keys(landedPreviously).filter((n) => SKILL_NAME_RE.test(n) && !landedNow[n] && !coreShipsNow(n));
435
448
  if (!overwrite) {
436
449
  // adopt mode never clobbers what the owner already has; withdrawing is a clobber.
437
450
  if (withdrawable.length) summary.notes.push(`${withdrawable.length} skill(s) of a now-disabled module were left in place (adopt mode does not withdraw): ${withdrawable.sort().join(', ')}.`);
@@ -9,7 +9,7 @@
9
9
  'use strict';
10
10
 
11
11
  const { cliClient, requireSession, arg, detectWorktreeName } = require('./cli-lib');
12
- const { resolveProse, markShipProgress, smokeGateMessages } = require('./ship-honesty.js');
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');
15
15
  const { matchOne } = require('../../src/bongos/path-match');
@@ -684,6 +684,16 @@ async function regradeMain() {
684
684
  console.error('usage: node scripts/gds/ship.js <task-id> --regrade [--summary-file <path|-> | --summary "..."] [--response-file <path|-> | --response "..."] [--no-merge] [--force] [--verified] [--base <ref>]');
685
685
  process.exit(2);
686
686
  }
687
+ // task 1003702: refuse --notes rather than swallowing it. The reasoning — why
688
+ // a refusal and not a persist — is in ship-honesty.js above regradeNotesRefusal
689
+ // and is not repeated here. What is load-bearing at THIS call site is the
690
+ // POSITION: before the preflight and before any grade spend, because a refusal
691
+ // that first costs a 4-worker panel run is barely better than the silence it
692
+ // replaces. tests/ship_cannot_lie.mjs pins that order.
693
+ if (proseFlagPresent(args, 'notes')) {
694
+ for (const line of regradeNotesRefusal(taskId)) console.error(line);
695
+ process.exit(2);
696
+ }
687
697
  const regradeProse = resolveProse(args, ['summary', 'response']);
688
698
  const summaryArg = regradeProse.summary;
689
699
  // task 1002662: the builder's response to the prior round's findings — how
@@ -753,6 +763,17 @@ async function regradeMain() {
753
763
  // re-grade is the FIRST time this task actually ships. Fall back to the
754
764
  // task's stored value_summary so the merge commit + Discord #ship-news post
755
765
  // carry meaningful text even when --summary is omitted.
766
+ //
767
+ // AND IT NEVER REACHES THE TASK ROW — task 1003702's acceptance 3 asked this
768
+ // question; the answer is a real, separate defect, filed as task 1003817.
769
+ // `summary` from here feeds the grader prompt, the merge commit message and
770
+ // the PR title/body, and NOTHING writes it back to tasks.value_summary. All
771
+ // three candidate write paths were read and ruled out: publish-branch accepts
772
+ // value_summary but uses it only for PR text; PATCH /tasks/:id does not accept
773
+ // the field at all; the claim-resolve route is the sole writer and a re-grade
774
+ // has no claim (which is why claimLike is synthesized above). Fixing it needs
775
+ // a server route change, so it is deliberately NOT folded into this CLI fix.
776
+ // tests/ship_cannot_lie.mjs pins both halves of that so the answer cannot rot.
756
777
  const summary = summaryArg || task.value_summary || '';
757
778
 
758
779
  // The grader helpers want a claim-like object for touches[] (preflight's
@@ -848,6 +869,11 @@ async function regradeMain() {
848
869
  card: {
849
870
  title: task.title,
850
871
  valueSummary: summary,
872
+ // Empty by design, not by omission (task 1003702). A re-grade takes no
873
+ // --notes at all now — it is refused above — so there are none from THIS
874
+ // round to show. The first ship's notes are the durable record and live on
875
+ // the resolved claim; re-printing them here would suggest this round
876
+ // produced them.
851
877
  notes: '',
852
878
  files: committedFiles,
853
879
  baseline: graderBaseline,
@@ -143,8 +143,61 @@ function smokeGateMessages(taskId) {
143
143
  };
144
144
  }
145
145
 
146
+ // ---- --regrade must not swallow --notes (task 1003702) ----------------------
147
+ //
148
+ // THE THIRD WAY TO LOSE THE BUILDER'S PROSE, and the reason this lives in the
149
+ // prose-loss file rather than beside regradeMain. `--regrade` resolves only
150
+ // `summary` and `response`, so `--notes` parsed as an unknown flag, produced no
151
+ // error, and vanished. A builder answering a "no evidence in the notes" blocker
152
+ // passed it again in --notes, saw the ship run, and got the identical finding
153
+ // back marked unanswered — with nothing anywhere saying why.
154
+ //
155
+ // AND THE FIX IS A REFUSAL, NOT A PERSIST, because the premise the bug was filed
156
+ // under is wrong in the builder's favour: the panel NEVER reads ship notes, on a
157
+ // first ship or a re-grade. modules/grading/grader-prompt.js buildPrompt() puts
158
+ // the task row, the VALUE SUMMARY, the changed files, the diffstat and the diff
159
+ // in the prompt and nothing else, and the workers get the same bundle. So
160
+ // storing the notes would have fixed the silence and none of the substance: the
161
+ // builder would pass --notes, watch it save, and STILL get "unanswered" — a
162
+ // worse failure, because now it looks like it worked.
163
+ //
164
+ // The two channels that do reach the panel are named in the refusal: --response
165
+ // (fenced into the prior-round block, task 1002662) and the DIFF itself, which
166
+ // is why the workaround that actually worked on task 1003698 was committing the
167
+ // evidence as a file. Nothing is written and nothing is lost: the first ship's
168
+ // notes stay the durable ledger record, which is the one thing --notes on a
169
+ // re-grade could legitimately have meant.
170
+ function proseFlagPresent(args, name) {
171
+ const list = Array.isArray(args) ? args : [];
172
+ return list.some((a) => a === `--${name}` || String(a).startsWith(`--${name}=`)
173
+ || a === `--${name}-file` || String(a).startsWith(`--${name}-file=`));
174
+ }
175
+
176
+ // Pure so the refusal's WORDING is testable — a message that fails to name the
177
+ // alternative is the same dead end with an error code on it.
178
+ function regradeNotesRefusal(taskId) {
179
+ return [
180
+ '--regrade does not take --notes, and your notes have NOT been saved or graded.',
181
+ '',
182
+ 'The grader never reads ship notes — not on a re-grade, and not on the first ship.',
183
+ 'The panel is given the task, your VALUE SUMMARY, the changed files and the diff.',
184
+ 'So notes cannot answer a finding, and silently keeping them would only look like it had.',
185
+ '',
186
+ 'Put the evidence where the panel can actually see it:',
187
+ ` --response "..." answers the prior round's findings; it rides to the panel fenced.`,
188
+ ` --response-file <path> the safe form for prose (a shell eats backticks in an inline value).`,
189
+ ' the DIFF commit the proof — a test, a dry-run log, a session-log file.',
190
+ ' The panel reads the committed diff and can Read every changed file.',
191
+ '',
192
+ ` node scripts/gds/ship.js ${taskId} --regrade --response-file notes.md`,
193
+ '',
194
+ "The first ship's notes stay the durable record; nothing you wrote earlier was lost.",
195
+ ];
196
+ }
197
+
146
198
  module.exports = {
147
199
  resolveProse, markShipProgress, fatalRecoveryLines, smokeGateMessages,
200
+ proseFlagPresent, regradeNotesRefusal,
148
201
  _resetForTest: () => { shipProgress = { stage: 'preflight', taskId: null }; },
149
202
  _peekForTest: () => shipProgress,
150
203
  };
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.645'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.647'; // 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');