mandrel 2.19.0 → 2.21.0

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 (37) hide show
  1. package/.agents/agents/story-worker.md +10 -0
  2. package/.agents/docs/agentrc-reference.json +0 -21
  3. package/.agents/docs/configuration.md +11 -14
  4. package/.agents/docs/execution-reference.md +8 -5
  5. package/.agents/schemas/agentrc.schema.json +0 -31
  6. package/.agents/scripts/check-test-temp-hygiene.js +153 -14
  7. package/.agents/scripts/deliver-light.js +72 -8
  8. package/.agents/scripts/lib/baselines/kinds/maintainability.js +3 -4
  9. package/.agents/scripts/lib/bdd-scenario-scanner.js +3 -2
  10. package/.agents/scripts/lib/config/explain.js +0 -8
  11. package/.agents/scripts/lib/config/temp-paths.js +74 -1
  12. package/.agents/scripts/lib/config-settings-schema.js +8 -24
  13. package/.agents/scripts/lib/orchestration/complexity-gate.js +68 -6
  14. package/.agents/scripts/lib/orchestration/deliver-recover.js +253 -6
  15. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -2
  16. package/.agents/scripts/lib/orchestration/light-suitability.js +194 -11
  17. package/.agents/scripts/lib/orchestration/plan-context.js +13 -14
  18. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +12 -66
  19. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +11 -1
  20. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +1 -1
  21. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +117 -4
  22. package/.agents/scripts/lib/temp-retention.js +23 -8
  23. package/.agents/scripts/lib/test-env.js +15 -3
  24. package/.agents/scripts/lib/test-temp.js +311 -0
  25. package/.agents/scripts/single-story-confirm-merge.js +1 -1
  26. package/.agents/workflows/helpers/deliver-light.md +45 -5
  27. package/.agents/workflows/helpers/deliver-story-reference.md +33 -0
  28. package/.agents/workflows/helpers/deliver-story.md +6 -3
  29. package/.agents/workflows/helpers/plan-reference.md +19 -4
  30. package/.agents/workflows/plan.md +7 -6
  31. package/docs/CHANGELOG.md +16 -0
  32. package/lib/migrations/index.js +2 -0
  33. package/lib/migrations/steps/2.20.0-retire-codebase-snapshot.js +113 -0
  34. package/package.json +1 -1
  35. package/.agents/scripts/lib/codebase-snapshot.js +0 -513
  36. package/.agents/scripts/lib/orchestration/planning/spec-authoring-grounding.js +0 -147
  37. package/.agents/scripts/lib/orchestration/spec-freshness.js +0 -129
@@ -56,6 +56,8 @@ import { mkdtempSync } from 'node:fs';
56
56
  import os from 'node:os';
57
57
  import path from 'node:path';
58
58
 
59
+ import { reapOnExit } from '../test-temp.js';
60
+
59
61
  /**
60
62
  * Cache the resolved main-checkout root per spawn cwd so the
61
63
  * `git rev-parse` shell-out runs at most once per distinct working
@@ -223,7 +225,7 @@ function inNodeTestContext(env, execArgv) {
223
225
  *
224
226
  * @param {string} tempRoot
225
227
  * @param {NodeJS.ProcessEnv} [env=process.env]
226
- * @param {{ mkdtemp?: typeof mkdtempSync, execArgv?: string[] }} [deps]
228
+ * @param {{ mkdtemp?: typeof mkdtempSync, execArgv?: string[], onExit?: (fn: () => void) => void }} [deps]
227
229
  * Injectable for tests.
228
230
  * @returns {string}
229
231
  */
@@ -238,9 +240,18 @@ export function anchorTempRoot(tempRoot, env = process.env, deps = {}) {
238
240
  ) {
239
241
  if (_testContextScratchDir === null) {
240
242
  const mkdtemp = deps.mkdtemp ?? mkdtempSync;
243
+ // test-temp-allow: published to children below, so it must live
244
+ // outside the per-process suite root that this process reaps.
241
245
  _testContextScratchDir = mkdtemp(
242
246
  path.join(os.tmpdir(), 'mandrel-test-temp-'),
243
247
  );
248
+ // Creator-only reaping (Story #4808): a process that read the root
249
+ // from the env returned at `scratch` above and never reaches here,
250
+ // so it can never remove a root its parent is still writing to.
251
+ reapOnExit(
252
+ _testContextScratchDir,
253
+ deps.onExit ? { onExit: deps.onExit } : {},
254
+ );
244
255
  if (env === process.env) {
245
256
  // Children spawned by this test process inherit the same scratch.
246
257
  process.env[TEST_TEMP_ROOT_ENV] = _testContextScratchDir;
@@ -301,6 +312,68 @@ export function orchestrationLogDir(config) {
301
312
  return path.join(anchorTempRoot(tempRootFrom(config)), ORCHESTRATION_DIRNAME);
302
313
  }
303
314
 
315
+ /**
316
+ * Basename of one Story's close gate log (Story #4816 lifted it here from
317
+ * `single-story-close/gate-log.js`).
318
+ *
319
+ * The writer that appends this file and the reader that uses its **freshness**
320
+ * to tell a live close from a dead one (`deliver-recover.js`) sit in different
321
+ * subtrees, and the reader importing the writer is the wrong edge to draw for
322
+ * a filename. Both take it from the module that already owns every other
323
+ * tempRoot path instead.
324
+ *
325
+ * `null` is the sink's no-Story sentinel and keeps its `unknown` spelling.
326
+ *
327
+ * @param {number|null} sid
328
+ * @returns {string}
329
+ */
330
+ function closeGateLogName(sid) {
331
+ return `close-gates-${sid ?? 'unknown'}.log`;
332
+ }
333
+
334
+ /**
335
+ * `<tempRoot>/orchestration/close-gates-<sid>.log`.
336
+ *
337
+ * @param {number|null} sid
338
+ * @param {object} [config]
339
+ * @returns {string}
340
+ */
341
+ export function closeGateLogPath(sid, config) {
342
+ return path.join(orchestrationLogDir(config), closeGateLogName(sid));
343
+ }
344
+
345
+ /**
346
+ * Basename of the persisted terminal envelope for one Story (Story #4816).
347
+ *
348
+ * @param {number} sid
349
+ * @returns {string}
350
+ */
351
+ function storyTerminalEnvelopeName(sid) {
352
+ return `story-deliver-terminal-${storyId(sid)}.json`;
353
+ }
354
+
355
+ /**
356
+ * `<tempRoot>/orchestration/story-deliver-terminal-<sid>.json` — the on-disk
357
+ * copy of the one terminal envelope a Story's close-and-land emits (Story
358
+ * #4816).
359
+ *
360
+ * Deliberately a sibling of the gate log rather than a per-Story temp dir
361
+ * entry: the envelope is a run artifact of the same close that writes
362
+ * `close-gates-<sid>.log`, and `deliver-recover.js` reads the pair together to
363
+ * tell a finished close from a live one. Sharing `orchestrationLogDir` also
364
+ * means it inherits main-checkout anchoring for free — the close runs inside
365
+ * `.worktrees/story-<sid>/` while the `/deliver` host reads from the main
366
+ * checkout, and an un-anchored path would put the envelope somewhere the
367
+ * router never looks.
368
+ *
369
+ * @param {number} sid
370
+ * @param {object} [config]
371
+ * @returns {string}
372
+ */
373
+ export function storyTerminalEnvelopePath(sid, config) {
374
+ return path.join(orchestrationLogDir(config), storyTerminalEnvelopeName(sid));
375
+ }
376
+
304
377
  const runId = (id) => {
305
378
  if (!Number.isInteger(id) || id <= 0) {
306
379
  throw new Error(`[temp-paths] runId must be a positive integer; got ${id}`);
@@ -249,35 +249,19 @@ const GITHUB_SCHEMA = {
249
249
  // rejected as an additional property, so a resurrected key fails loudly rather
250
250
  // than silently doing nothing.
251
251
 
252
- /**
253
- * Story #2634 `planning.codebaseSnapshot` controls the structural
254
- * view of the consumer repo threaded into `/plan` Phase 7 spec
255
- * authoring. Absent / partial entries resolve to defaults inside
256
- * `lib/codebase-snapshot.js#resolveSnapshotConfig` the schema only
257
- * enforces shape (correct enum value, well-formed glob arrays).
258
- */
259
- const CODEBASE_SNAPSHOT_SCHEMA = {
260
- type: 'object',
261
- properties: {
262
- tier: { type: 'string', enum: ['skinny', 'medium'] },
263
- include: {
264
- type: 'array',
265
- items: { type: 'string', minLength: 1 },
266
- },
267
- exclude: {
268
- type: 'array',
269
- items: { type: 'string', minLength: 1 },
270
- },
271
- recentCommitWindow: { type: 'integer', minimum: 1 },
272
- },
273
- additionalProperties: false,
274
- };
252
+ // Story #4811: the `planning` block's structural-snapshot key was retired
253
+ // along with the snapshot itself. The pre-computed view it configured grounded
254
+ // nothing its default include globs missed the standard monorepo layout, and
255
+ // its knobs only re-filtered the same matched set. Spec authoring is grounded
256
+ // by the author's own targeted repo retrieval plus the Phase 8
257
+ // `validateStoryFileAssumptions` gate, neither of which is configurable here.
258
+ // `planning` carries `additionalProperties: false`, so a resurrected key fails
259
+ // loudly; the 2.20.0 retirement migration strips it on upgrade.
275
260
 
276
261
  const PLANNING_SCHEMA = {
277
262
  type: 'object',
278
263
  properties: {
279
264
  riskHeuristics: LIST_OR_EXTENDER_OF_STRINGS,
280
- codebaseSnapshot: CODEBASE_SNAPSHOT_SCHEMA,
281
265
  // Story #4722 (superseding #4683's word-count gate) — shape-derived
282
266
  // ceremony-lite routing. Complexity routes on the objective shape of the
283
267
  // authored work (changes[] count, acceptance count, creates-vs-refactors
@@ -259,23 +259,65 @@ function spansMigrationAndConsumers(paths) {
259
259
  return migrations.length > 0 && migrations.length < paths.length;
260
260
  }
261
261
 
262
+ /**
263
+ * Stable machine-readable identifiers for every reason a shape routes `full` —
264
+ * the `code` field on a {@link deriveStoryShape} decision (Story #4815).
265
+ *
266
+ * The prose in `reasons[]` is written for a human reading a gate envelope and
267
+ * is free to be re-worded; a caller that must **branch** on *which* rule
268
+ * objected reads this code instead. That distinction is load-bearing for the
269
+ * light path's operator override
270
+ * ({@link module:lib/orchestration/light-suitability.OVERRIDABLE_SHAPE_CODES}),
271
+ * which may waive a size *prediction* but never a risk rule: keying that
272
+ * decision off reason text would make a copy-edit a security change.
273
+ *
274
+ * Split three ways, and the grouping is the contract:
275
+ *
276
+ * - **Ceiling rules** — `change-kinds`, `magnitude`, `uncertainty`,
277
+ * `deployable-span`. Coarse predictions about size, enforced for real
278
+ * against ground truth by the diff backstop.
279
+ * - **Absolute rules** — `migration-span`, `sensitive-path`. Risk, not size.
280
+ * - **Unknown-footprint rejections** — `no-changes`, `unreadable-changes`,
281
+ * `glob-footprint`, `no-acceptance`, `classification-unavailable`,
282
+ * `unparseable-body`. Nothing was judged, so there is nothing to waive.
283
+ *
284
+ * A `lite` route carries `code: null`.
285
+ */
286
+ export const SHAPE_CODES = Object.freeze({
287
+ CHANGE_KINDS: 'change-kinds',
288
+ MAGNITUDE: 'magnitude',
289
+ UNCERTAINTY: 'uncertainty',
290
+ DEPLOYABLE_SPAN: 'deployable-span',
291
+ MIGRATION_SPAN: 'migration-span',
292
+ SENSITIVE_PATH: 'sensitive-path',
293
+ NO_CHANGES: 'no-changes',
294
+ UNREADABLE_CHANGES: 'unreadable-changes',
295
+ GLOB_FOOTPRINT: 'glob-footprint',
296
+ NO_ACCEPTANCE: 'no-acceptance',
297
+ CLASSIFICATION_UNAVAILABLE: 'classification-unavailable',
298
+ UNPARSEABLE_BODY: 'unparseable-body',
299
+ });
300
+
262
301
  /**
263
302
  * Ordered effort/risk rules, evaluated in order; the first hit is the recorded
264
303
  * reason for a `full` route. Every rule names an effort, risk, or uncertainty
265
304
  * property of the work — none counts artifacts.
266
305
  *
267
306
  * @type {ReadonlyArray<{
307
+ * code: string,
268
308
  * when: (shape: object, ceilings: typeof STORY_SHAPE_CEILINGS) => boolean,
269
309
  * reason: (shape: object, ceilings: typeof STORY_SHAPE_CEILINGS) => string,
270
310
  * }>}
271
311
  */
272
312
  const EFFORT_RULES = Object.freeze([
273
313
  {
314
+ code: SHAPE_CODES.CHANGE_KINDS,
274
315
  when: (s, c) => s.kindCount > c.maxChangeKinds,
275
316
  reason: (s, c) =>
276
317
  `${s.kindCount} distinct change kinds (${s.changeKinds.join(', ')}) > maxChangeKinds ${c.maxChangeKinds} — an explicit multi-capability enumeration, not one capability; full route`,
277
318
  },
278
319
  {
320
+ code: SHAPE_CODES.MAGNITUDE,
279
321
  when: (s, c) =>
280
322
  MAGNITUDE_SCALE.indexOf(s.magnitude) >
281
323
  MAGNITUDE_SCALE.indexOf(c.maxMagnitude),
@@ -283,6 +325,7 @@ const EFFORT_RULES = Object.freeze([
283
325
  `declared magnitude "${s.magnitude}" > maxMagnitude "${c.maxMagnitude}" — a substantial rewrite is effort a single inline pass should not absorb, however few files it touches; full route`,
284
326
  },
285
327
  {
328
+ code: SHAPE_CODES.UNCERTAINTY,
286
329
  when: (s, c) =>
287
330
  UNCERTAINTY_SCALE.indexOf(s.uncertainty) >
288
331
  UNCERTAINTY_SCALE.indexOf(c.maxUncertainty),
@@ -290,16 +333,19 @@ const EFFORT_RULES = Object.freeze([
290
333
  `the shape is not determined by the request (uncertainty "${s.uncertainty}") — the design decisions /plan exists to resolve are still open; full route`,
291
334
  },
292
335
  {
336
+ code: SHAPE_CODES.DEPLOYABLE_SPAN,
293
337
  when: (s, c) => s.deployables.length > c.maxDeployables,
294
338
  reason: (s, c) =>
295
339
  `footprint spans ${s.deployables.length} deployables (${s.deployables.join(', ')}) > maxDeployables ${c.maxDeployables} — clearly-epic scope; full route`,
296
340
  },
297
341
  {
342
+ code: SHAPE_CODES.MIGRATION_SPAN,
298
343
  when: (s) => s.migrationSpan,
299
344
  reason: () =>
300
345
  'footprint pairs a migration with its consumers — clearly-epic scope; full route',
301
346
  },
302
347
  {
348
+ code: SHAPE_CODES.SENSITIVE_PATH,
303
349
  when: (s) => s.sensitiveClasses.length > 0,
304
350
  reason: (s) =>
305
351
  `footprint intersects sensitive-path class(es) ${s.sensitiveClasses.join(', ')} — sensitivity wins over a small shape; full route (fresh acceptance critic retained)`,
@@ -307,15 +353,18 @@ const EFFORT_RULES = Object.freeze([
307
353
  ]);
308
354
 
309
355
  /**
310
- * First effort/risk rule the shape violates, or `null` when it clears them all.
356
+ * First effort/risk rule the shape violates as a `{ code, reason }` pair, or
357
+ * `null` when it clears them all.
311
358
  *
312
359
  * @param {object} shape
313
360
  * @param {typeof STORY_SHAPE_CEILINGS} ceilings
314
- * @returns {string|null}
361
+ * @returns {{ code: string, reason: string }|null}
315
362
  */
316
363
  function firstEffortViolation(shape, ceilings) {
317
364
  for (const rule of EFFORT_RULES) {
318
- if (rule.when(shape, ceilings)) return rule.reason(shape, ceilings);
365
+ if (rule.when(shape, ceilings)) {
366
+ return { code: rule.code, reason: rule.reason(shape, ceilings) };
367
+ }
319
368
  }
320
369
  return null;
321
370
  }
@@ -654,10 +703,13 @@ function buildEffortShape({
654
703
  * @returns {{
655
704
  * route: ComplexityRoute,
656
705
  * reasons: string[],
706
+ * code: string|null,
657
707
  * shape: ReturnType<typeof buildEffortShape>|null,
658
708
  * ceilings: typeof STORY_SHAPE_CEILINGS,
659
709
  * preserves: typeof LITE_PATH_INVARIANTS,
660
- * }}
710
+ * }} `code` is the stable {@link SHAPE_CODES} identifier for the rule that
711
+ * rejected the shape (`null` on `lite`) — the field a caller branches on,
712
+ * since `reasons[]` is human prose and free to be re-worded.
661
713
  */
662
714
  export function deriveStoryShape({
663
715
  changes,
@@ -670,9 +722,10 @@ export function deriveStoryShape({
670
722
  } = {}) {
671
723
  const ceilings = STORY_SHAPE_CEILINGS;
672
724
  const preserves = LITE_PATH_INVARIANTS;
673
- const decide = (route, reason, shape = null) => ({
725
+ const decide = (route, code, reason, shape = null) => ({
674
726
  route,
675
727
  reasons: [reason],
728
+ code,
676
729
  shape,
677
730
  ceilings,
678
731
  preserves,
@@ -681,6 +734,7 @@ export function deriveStoryShape({
681
734
  if (!Array.isArray(changes) || changes.length === 0) {
682
735
  return decide(
683
736
  'full',
737
+ SHAPE_CODES.NO_CHANGES,
684
738
  'no changes[] declared — the footprint is unknown, so the work cannot be judged trivial; conservative full route',
685
739
  );
686
740
  }
@@ -691,6 +745,7 @@ export function deriveStoryShape({
691
745
  } catch (err) {
692
746
  return decide(
693
747
  'full',
748
+ SHAPE_CODES.UNREADABLE_CHANGES,
694
749
  `changes[] could not be read (${err?.message ?? err}) — unknown footprint; conservative full route`,
695
750
  );
696
751
  }
@@ -714,6 +769,7 @@ export function deriveStoryShape({
714
769
  if (entries.some((e) => e.isGlob)) {
715
770
  return decide(
716
771
  'full',
772
+ SHAPE_CODES.GLOB_FOOTPRINT,
717
773
  'changes[] contains a glob path — unknown footprint width; conservative full route',
718
774
  shape,
719
775
  );
@@ -721,13 +777,16 @@ export function deriveStoryShape({
721
777
  if (shape.acceptanceCount === 0) {
722
778
  return decide(
723
779
  'full',
780
+ SHAPE_CODES.NO_ACCEPTANCE,
724
781
  'no acceptance criteria — the contract cannot be judged trivial; conservative full route',
725
782
  shape,
726
783
  );
727
784
  }
728
785
 
729
786
  const violation = firstEffortViolation(shape, ceilings);
730
- if (violation !== null) return decide('full', violation, shape);
787
+ if (violation !== null) {
788
+ return decide('full', violation.code, violation.reason, shape);
789
+ }
731
790
 
732
791
  if (level !== 'low') {
733
792
  // `deriveChangeLevel` degraded to its null fail-safe (unreadable
@@ -735,6 +794,7 @@ export function deriveStoryShape({
735
794
  // non-sensitive, and a classification failure must never buy lite.
736
795
  return decide(
737
796
  'full',
797
+ SHAPE_CODES.CLASSIFICATION_UNAVAILABLE,
738
798
  'sensitive-path classification unavailable — cannot verify the footprint is non-sensitive; conservative full route',
739
799
  shape,
740
800
  );
@@ -742,6 +802,7 @@ export function deriveStoryShape({
742
802
 
743
803
  return decide(
744
804
  'lite',
805
+ null,
745
806
  `trivial shape: ${shape.kindCount} change kind(s) (${shape.changeKinds.join(', ')}) ≤ ${ceilings.maxChangeKinds} across ${shape.siteCount} site(s), magnitude ${shape.magnitude} ≤ ${ceilings.maxMagnitude}, shape ${shape.uncertainty}, no epic-scope span, no sensitive-path class — inline-eligible; non-negotiables preserved`,
746
807
  shape,
747
808
  );
@@ -771,6 +832,7 @@ function deriveStoryRouteFromBody(body, opts = {}) {
771
832
  reasons: [
772
833
  `Story body is unparseable (${err?.message ?? err}) — shape unknown; conservative full route`,
773
834
  ],
835
+ code: SHAPE_CODES.UNPARSEABLE_BODY,
774
836
  shape: null,
775
837
  ceilings: STORY_SHAPE_CEILINGS,
776
838
  preserves: LITE_PATH_INVARIANTS,