mandrel 2.16.0 → 2.18.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 (69) hide show
  1. package/.agents/docs/agentrc-reference.json +10 -0
  2. package/.agents/docs/configuration.md +9 -0
  3. package/.agents/docs/quality-gates.md +137 -0
  4. package/.agents/schemas/agentrc.schema.json +48 -0
  5. package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
  6. package/.agents/schemas/baselines/crap.schema.json +4 -0
  7. package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
  8. package/.agents/scripts/acceptance-eval.js +52 -12
  9. package/.agents/scripts/audit-to-stories.js +92 -25
  10. package/.agents/scripts/boot-sweep.js +67 -8
  11. package/.agents/scripts/check-baseline-drift.js +138 -0
  12. package/.agents/scripts/coverage-capture.js +74 -25
  13. package/.agents/scripts/deliver-recover.js +45 -18
  14. package/.agents/scripts/drain-pending-cleanup.js +67 -23
  15. package/.agents/scripts/generate-lens-checklists.js +81 -30
  16. package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +88 -17
  17. package/.agents/scripts/lib/baselines/drift-detector.js +351 -0
  18. package/.agents/scripts/lib/baselines/envelope.js +7 -0
  19. package/.agents/scripts/lib/baselines/kernel.js +31 -0
  20. package/.agents/scripts/lib/baselines/kinds/crap.js +76 -0
  21. package/.agents/scripts/lib/baselines/reader.js +12 -1
  22. package/.agents/scripts/lib/baselines/refresh-service.js +7 -1
  23. package/.agents/scripts/lib/baselines/writer.js +10 -0
  24. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +23 -8
  25. package/.agents/scripts/lib/cli-utils.js +48 -13
  26. package/.agents/scripts/lib/close-validation/projections/advisories.js +184 -0
  27. package/.agents/scripts/lib/close-validation/projections/crap.js +303 -0
  28. package/.agents/scripts/lib/close-validation/runner.js +68 -0
  29. package/.agents/scripts/lib/config/gates/crap.schema.js +7 -0
  30. package/.agents/scripts/lib/config/quality.js +40 -0
  31. package/.agents/scripts/lib/config/temp-paths.js +27 -0
  32. package/.agents/scripts/lib/config-settings-schema-delivery.js +69 -0
  33. package/.agents/scripts/lib/coverage-utils.js +92 -9
  34. package/.agents/scripts/lib/crap-engine.js +113 -23
  35. package/.agents/scripts/lib/crap-utils.js +159 -93
  36. package/.agents/scripts/lib/dynamic-workflow/audit-orchestrator.js +97 -10
  37. package/.agents/scripts/lib/dynamic-workflow/degraded-coverage.js +81 -0
  38. package/.agents/scripts/lib/git-branch-lifecycle.js +15 -8
  39. package/.agents/scripts/lib/observability/terse-result.js +7 -3
  40. package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +35 -0
  41. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +13 -0
  42. package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +16 -1
  43. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +19 -41
  44. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +122 -0
  45. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +9 -5
  46. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +15 -1
  47. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +31 -1
  48. package/.agents/scripts/lib/orchestration/story-deliver-terminal-schema.js +166 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +21 -50
  50. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +26 -12
  51. package/.agents/scripts/lib/single-story-sweep.js +11 -0
  52. package/.agents/scripts/lib/stdio-flush.js +71 -0
  53. package/.agents/scripts/lib/temp-retention.js +559 -0
  54. package/.agents/scripts/lib/transpile.js +133 -6
  55. package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +47 -101
  56. package/.agents/scripts/lib/workers/crap-worker.js +49 -76
  57. package/.agents/scripts/lib/worktree/lifecycle/reap.js +81 -8
  58. package/.agents/scripts/nav-registry-diff.js +30 -8
  59. package/.agents/scripts/plan-run-epilogue.js +27 -11
  60. package/.agents/scripts/resolve-doc-tiers.js +18 -8
  61. package/.agents/scripts/single-story-close.js +9 -92
  62. package/.agents/scripts/single-story-init.js +1 -1
  63. package/.agents/scripts/sync-branch-from-base.js +6 -1
  64. package/.agents/scripts/update-crap-baseline.js +13 -0
  65. package/README.md +14 -6
  66. package/docs/CHANGELOG.md +36 -0
  67. package/lib/cli/version-helpers.js +7 -0
  68. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +15 -8
  69. package/package.json +5 -1
@@ -1,6 +1,7 @@
1
1
  import nodeFs from 'node:fs';
2
2
  import path from 'node:path';
3
3
 
4
+ import { orchestrationLogDir } from '../config/temp-paths.js';
4
5
  import { Logger } from '../Logger.js';
5
6
 
6
7
  /**
@@ -69,8 +70,10 @@ function detailBlock(label, result) {
69
70
  * on; serialized compactly onto the single summary line.
70
71
  * @param {string|number} [args.scope] Disambiguating suffix for the log name
71
72
  * (typically the Story id) so concurrent deliveries don't clobber one file.
72
- * @param {string} [args.logDir] Directory for the detail log. Defaults to
73
- * `<cwd>/temp/orchestration`.
73
+ * @param {string} [args.logDir] Directory for the detail log. Defaults to the
74
+ * configured `<tempRoot>/orchestration` (Story #4794 — was a hardcoded
75
+ * `<cwd>/temp/orchestration`, which ignored `project.paths.tempRoot`).
76
+ * @param {object} [args.config] Resolved config bag, for the default `logDir`.
74
77
  * @param {typeof nodeFs} [args.fs] Filesystem seam (tests).
75
78
  * @param {{ info: (m: string) => void }} [args.log] Logger seam (tests).
76
79
  * @param {NodeJS.ProcessEnv} [args.env] Environment seam (tests).
@@ -82,6 +85,7 @@ export function emitTerseResult({
82
85
  summary = {},
83
86
  scope,
84
87
  logDir,
88
+ config,
85
89
  fs = nodeFs,
86
90
  log = Logger,
87
91
  env = process.env,
@@ -94,7 +98,7 @@ export function emitTerseResult({
94
98
  return { logPath: null, inline: true };
95
99
  }
96
100
 
97
- const dir = logDir ?? path.join(process.cwd(), 'temp', 'orchestration');
101
+ const dir = logDir ?? orchestrationLogDir(config);
98
102
  const name = `${slugify(label)}${scope ? `-${scope}` : ''}.log`;
99
103
 
100
104
  try {
@@ -12,6 +12,7 @@
12
12
  import { readBaseFromGit } from '../../../baselines/git-base.js';
13
13
  import { getKindModule } from '../../../baselines/kernel.js';
14
14
  import { resolveScope } from '../../../baselines/scope.js';
15
+ import { Logger } from '../../../Logger.js';
15
16
  import { DEFAULT_BASELINE_PATHS } from './parse-args.js';
16
17
 
17
18
  function baselineRelativePath(kind, gateBlock) {
@@ -67,6 +68,29 @@ export async function evaluateCompare({ kind, gateBlock, scope, cwd }) {
67
68
  return { baseRef: scope.ref, baseRead: true, basePayload, kindModule };
68
69
  }
69
70
 
71
+ /**
72
+ * Is the base baseline comparable to the head baseline (Story #4775)?
73
+ *
74
+ * A kind can change its SCORING SEMANTICS — how it derives a row's metric —
75
+ * without moving `kernelVersion`. Across that boundary the same row can carry
76
+ * a different score for reasons that have nothing to do with the branch's
77
+ * changes, so a head-vs-base diff manufactures phantom regressions (and can
78
+ * hide real ones behind them).
79
+ *
80
+ * The head-side stamp is already a fail-closed gate: a stale HEAD baseline
81
+ * never reaches this point. What reaches here is the opposite and legitimate
82
+ * case — a branch that DOES carry a re-derived baseline, compared against a
83
+ * base that predates the change. The only honest verdict is "no comparison";
84
+ * floors still run, so a genuine ceiling breach is still caught, and once the
85
+ * refreshed baseline is the base the ratchet returns to full strength on the
86
+ * very next run without anything to remember to reset.
87
+ */
88
+ function baseIsComparable(headBaseline, basePayload) {
89
+ const head = headBaseline?.scoringSemantics ?? null;
90
+ const base = basePayload?.scoringSemantics ?? null;
91
+ return head === base;
92
+ }
93
+
70
94
  export function runCompareStage(headBaseline, cmp) {
71
95
  const empty = {
72
96
  regressions: [],
@@ -75,6 +99,17 @@ export function runCompareStage(headBaseline, cmp) {
75
99
  additions: [],
76
100
  };
77
101
  if (!cmp.baseRead || !cmp.basePayload || !cmp.kindModule) return empty;
102
+ if (!baseIsComparable(headBaseline, cmp.basePayload)) {
103
+ Logger.warn(
104
+ `[${cmp.kindModule.name}] ⚠ base baseline was scored under different ` +
105
+ `semantics (base=${cmp.basePayload.scoringSemantics ?? '<unstamped>'} ` +
106
+ `head=${headBaseline?.scoringSemantics ?? '<unstamped>'}); its rows are ` +
107
+ 'not comparable, so the head-vs-base compare is skipped for this run. ' +
108
+ 'Floors still enforced. The ratchet resumes once the re-derived ' +
109
+ 'baseline is the base.',
110
+ );
111
+ return empty;
112
+ }
78
113
  try {
79
114
  const baseRows = Array.isArray(cmp.basePayload.rows)
80
115
  ? cmp.basePayload.rows
@@ -13,6 +13,7 @@ import {
13
13
  } from '../../../baselines/env-overrides.js';
14
14
  import { readRangeSubjectsTouchingFile } from '../../../baselines/git-base.js';
15
15
  import {
16
+ checkBaselineSemantics,
16
17
  checkKernelVersion,
17
18
  getKindModule,
18
19
  } from '../../../baselines/kernel.js';
@@ -247,6 +248,18 @@ export async function evaluateKind({
247
248
  const headLoad = loadHeadBaseline(kind, cwd, configPath);
248
249
  if (headLoad.schemaError) return { kind, schemaError: headLoad.schemaError };
249
250
  const baseline = headLoad.baseline;
251
+ // Story #4775 — scoring-semantics gate. A baseline whose rows were produced
252
+ // by superseded scoring semantics is structurally valid but semantically
253
+ // incomparable, so schema validation alone would wave it through. Fail
254
+ // closed on the `semantics` tag rather than compare across the boundary;
255
+ // the message names the exact re-baseline command.
256
+ const semanticsError = checkBaselineSemantics(kind, baseline);
257
+ if (semanticsError) {
258
+ return {
259
+ kind,
260
+ schemaError: { tag: 'semantics', message: semanticsError },
261
+ };
262
+ }
250
263
  const floorRollup = rollupExcludingIgnored({
251
264
  kind,
252
265
  baseline,
@@ -152,9 +152,24 @@ export function removeWorktree(worktreePath, cwd) {
152
152
  };
153
153
  }
154
154
 
155
+ /**
156
+ * Prune the clone's stale remote-tracking refs and report which ones went.
157
+ *
158
+ * The fetch MUST NOT be `--quiet` (Story #4772). `--quiet` still prunes, but
159
+ * suppresses the `- [deleted] (none) -> <remote>/<ref>` progress lines that
160
+ * are the *only* record of what was dropped — `parsePruneFn` then reports an
161
+ * empty list for work that really happened, and `computeExitCode` reads the
162
+ * run as "nothing to do" (exit 2). The output is captured, not shown, so
163
+ * `--quiet` bought nothing to begin with.
164
+ *
165
+ * @param {string} cwd
166
+ * @param {string} remoteName
167
+ * @param {(output: string, remoteName: string) => string[]} parsePruneFn
168
+ * @returns {{ ok: boolean, pruned: string[], stderr?: string }}
169
+ */
155
170
  /* node:coverage ignore next */
156
171
  export function pruneRemoteTracking(cwd, remoteName, parsePruneFn) {
157
- const res = gitSpawn(cwd, 'fetch', '--prune', '--quiet', remoteName);
172
+ const res = gitSpawn(cwd, 'fetch', '--prune', remoteName);
158
173
  if (res.status !== 0) return { ok: false, pruned: [], stderr: res.stderr };
159
174
  return { ok: true, pruned: parsePruneFn(res.stderr, remoteName) };
160
175
  }
@@ -37,13 +37,12 @@
37
37
  * @module lib/orchestration/plan-persist/run-plan-persist
38
38
  */
39
39
 
40
- import { readdir, rm, stat } from 'node:fs/promises';
40
+ import { rm } from 'node:fs/promises';
41
41
  import path from 'node:path';
42
-
43
- import { anchorTempRoot, tempRootFrom } from '../../config/temp-paths.js';
44
42
  import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
45
43
  import { gitSpawn } from '../../git-utils.js';
46
44
  import { Logger } from '../../Logger.js';
45
+ import { sweepTempRetention } from '../../temp-retention.js';
47
46
  import {
48
47
  deriveStoryShape,
49
48
  LITE_ROUTE_LABEL,
@@ -374,13 +373,6 @@ function resolveEffectiveRoute({
374
373
  };
375
374
  }
376
375
 
377
- /**
378
- * Age after which an abandoned `temp/plan-*` directory is reaped. A plan run
379
- * that is still being authored is minutes-to-hours old; a week is far past
380
- * any live run and comfortably past an operator returning to a paused one.
381
- */
382
- const STALE_PLAN_DIR_MS = 7 * 24 * 60 * 60 * 1000;
383
-
384
376
  /**
385
377
  * Reap abandoned `plan-*` directories under the temp root (Story #4541).
386
378
  *
@@ -389,9 +381,16 @@ const STALE_PLAN_DIR_MS = 7 * 24 * 60 * 60 * 1000;
389
381
  * `--dry-run` left its directory behind forever. This sweeps the stragglers
390
382
  * on each persist.
391
383
  *
384
+ * Story #4794 folded the age-floored reap into the shared temp-retention
385
+ * engine — `planDirs` is one of its declared classes, so the plan path and
386
+ * the delivery path now converge on one classifier and one staleness floor
387
+ * (`delivery.tempRetention.staleDays`, still 7 days by default) instead of
388
+ * this module owning a private constant. Behaviour is unchanged: only
389
+ * `plan-*` directories are considered, the age test is the directory's own
390
+ * mtime, and the current run's `planDir` is excluded.
391
+ *
392
392
  * Best-effort throughout: this is hygiene, never a reason to fail a run that
393
- * has already created Stories. The current run's own `planDir` is always
394
- * excluded — its cleanup is the caller's decision.
393
+ * has already created Stories.
395
394
  *
396
395
  * @param {{ config?: object, keepDir?: string|null, now?: number }} args
397
396
  * @returns {Promise<{ reaped: string[] }>}
@@ -401,35 +400,14 @@ export async function reapStalePlanDirs({
401
400
  keepDir = null,
402
401
  now = Date.now(),
403
402
  } = {}) {
404
- const reaped = [];
405
- const tempRoot = anchorTempRoot(tempRootFrom(config));
406
- let entries;
407
- try {
408
- entries = await readdir(tempRoot, { withFileTypes: true });
409
- } catch {
410
- return { reaped }; // No temp root yet — nothing to reap.
411
- }
412
- const keep = keepDir ? path.resolve(keepDir) : null;
413
- for (const entry of entries) {
414
- if (!entry.isDirectory() || !entry.name.startsWith('plan-')) continue;
415
- const dir = path.resolve(tempRoot, entry.name);
416
- if (keep !== null && dir === keep) continue;
417
- try {
418
- const { mtimeMs } = await stat(dir);
419
- if (now - mtimeMs < STALE_PLAN_DIR_MS) continue;
420
- await rm(dir, { recursive: true, force: true });
421
- reaped.push(dir);
422
- } catch {
423
- // A racing writer or a permission error: leave it for the next run.
424
- }
425
- }
426
- if (reaped.length > 0) {
427
- Logger.info(
428
- `[plan-persist] reaped ${reaped.length} abandoned plan director(ies) ` +
429
- `older than 7d under ${tempRoot}.`,
430
- );
431
- }
432
- return { reaped };
403
+ const result = await sweepTempRetention({
404
+ config,
405
+ only: ['planDirs'],
406
+ excludePaths: keepDir ? [keepDir] : [],
407
+ now,
408
+ label: 'plan-persist',
409
+ });
410
+ return { reaped: result.purged.map((entry) => entry.path) };
433
411
  }
434
412
 
435
413
  /**
@@ -0,0 +1,122 @@
1
+ /**
2
+ * single-story-close/failed-terminal.js — the `failed` terminal a close
3
+ * emits when a phase crashes, and the gate reconstruction it carries.
4
+ *
5
+ * Split out of `single-story-close.js` so the CLI entry stays an entry: it
6
+ * parses args, dispatches the runner, and maps a terminal onto an exit code.
7
+ * The reasoning about which gates had run by the time a phase died belongs
8
+ * with the envelope it feeds, not in the file that owns process lifetime.
9
+ *
10
+ * The runner deliberately throws rather than returning a failure (a red gate
11
+ * must not look like a return value), so without this the most common
12
+ * non-happy ending — a failing close-validation gate — would emit **no
13
+ * envelope at all**, exiting 1 with only a stderr line while the workflow
14
+ * docs promise the agent a `failed` envelope naming the phase.
15
+ */
16
+
17
+ import { Logger } from '../../Logger.js';
18
+ import {
19
+ buildTerminalEnvelope,
20
+ NEXT_COMMANDS,
21
+ } from '../story-deliver-terminal.js';
22
+
23
+ /**
24
+ * The close pipeline's phase order, as `setPhase` walks it. Only used to
25
+ * decide whether a gate had already run when a later phase died.
26
+ */
27
+ const PHASE_ORDER = Object.freeze([
28
+ 'init',
29
+ 'wrong-tree-guard',
30
+ 'close-validation',
31
+ 'base-sync',
32
+ 'push',
33
+ 'pull-request',
34
+ 'code-review',
35
+ 'auto-merge',
36
+ 'confirm-merge',
37
+ 'post-land',
38
+ 'done',
39
+ ]);
40
+
41
+ /** Each reported gate and the pipeline phase that decides it. */
42
+ const GATE_PHASES = Object.freeze([
43
+ ['validation', 'close-validation'],
44
+ ['baseSync', 'base-sync'],
45
+ ['codeReview', 'code-review'],
46
+ ]);
47
+
48
+ /**
49
+ * Report every gate's outcome for a run that died at `phase`.
50
+ *
51
+ * The schema's contract: "A gate the run skipped … reports `skipped` rather
52
+ * than being omitted, so a missing gate is never mistaken for a passing one."
53
+ * The previous shape named only the gate that died and omitted the rest
54
+ * entirely — exactly the ambiguity the contract forbids.
55
+ *
56
+ * Reconstructed from the phase order, which is sound because the pipeline is
57
+ * strictly sequential: reaching phase N means every gate before it completed.
58
+ * A gate whose phase the run never reached is `skipped`; one the operator
59
+ * turned off via `--skip-validation` / `--skip-sync` is `skipped` too (it did
60
+ * not pass — it never ran).
61
+ *
62
+ * @param {string} phase The phase the run died in.
63
+ * @param {{ skipValidation?: boolean, skipSync?: boolean }} args Parsed CLI args.
64
+ * @returns {Record<string, 'passed'|'failed'|'skipped'>}
65
+ */
66
+ export function gatesForFailedPhase(phase, args = {}) {
67
+ const skipped = { validation: args.skipValidation, baseSync: args.skipSync };
68
+ const failedAt = PHASE_ORDER.indexOf(phase);
69
+ const gates = {};
70
+ for (const [gate, gatePhase] of GATE_PHASES) {
71
+ const at = PHASE_ORDER.indexOf(gatePhase);
72
+ if (gatePhase === phase) gates[gate] = 'failed';
73
+ else if (failedAt < 0 || at > failedAt) gates[gate] = 'skipped';
74
+ else gates[gate] = skipped[gate] ? 'skipped' : 'passed';
75
+ }
76
+ return gates;
77
+ }
78
+
79
+ /**
80
+ * Build the `failed` terminal for a phase that crashed. Every close
81
+ * invocation emits exactly one envelope; this is the path that keeps that
82
+ * true when a phase dies.
83
+ *
84
+ * `err.closePhase` is tagged by the runner's phase tracker.
85
+ *
86
+ * **Never throws.** This runs on the path that already has one failure in
87
+ * hand, so a second failure here must not REPLACE the first: an
88
+ * envelope-build error surfacing as the run's cause sends the operator to
89
+ * diagnose the wrong thing entirely — a close whose PR had already merged
90
+ * once reported a schema `ENOENT` as its fatal error, because the worktree
91
+ * holding the script had been reaped mid-run. On failure this returns null
92
+ * and the caller rethrows the original.
93
+ *
94
+ * @param {unknown} err
95
+ * @param {{ storyId?: string|number, skipValidation?: boolean, skipSync?: boolean }} args
96
+ * Parsed CLI args — the story id the envelope reports on, plus the skip
97
+ * flags `gatesForFailedPhase` needs.
98
+ * @returns {object|null} A validated envelope, or null when even the story id
99
+ * is unknown (a usage error — there is nothing to report an envelope about)
100
+ * or the envelope itself could not be assembled.
101
+ */
102
+ export function failedTerminalFor(err, args = {}) {
103
+ const phase = err?.closePhase ?? 'init';
104
+ const storyId = Number(args.storyId);
105
+ if (!Number.isInteger(storyId) || storyId <= 0) return null;
106
+ try {
107
+ return buildTerminalEnvelope({
108
+ storyId,
109
+ status: 'failed',
110
+ phase,
111
+ gates: gatesForFailedPhase(phase, args),
112
+ failure: { reason: String(err?.message ?? err) },
113
+ nextCommand: NEXT_COMMANDS.recover(storyId),
114
+ elapsedSeconds: 0,
115
+ });
116
+ } catch (buildErr) {
117
+ Logger.error(
118
+ `[single-story-close] ⚠️ Could not assemble the failed terminal envelope: ${buildErr?.message ?? buildErr}. Reporting the original failure instead.`,
119
+ );
120
+ return null;
121
+ }
122
+ }
@@ -59,6 +59,7 @@
59
59
  import nodeFs from 'node:fs';
60
60
  import path from 'node:path';
61
61
 
62
+ import { orchestrationLogDir } from '../../config/temp-paths.js';
62
63
  import { Logger, resolveLevel } from '../../Logger.js';
63
64
 
64
65
  /**
@@ -210,26 +211,29 @@ function createArtifactWriter(fs, logPath, handle) {
210
211
  *
211
212
  * @param {{
212
213
  * storyId: number|null,
213
- * cwd?: string,
214
214
  * logDir?: string,
215
215
  * fs?: typeof nodeFs,
216
216
  * logger?: { info: (m: string) => void },
217
217
  * level?: string,
218
- * }} [args] `logDir` defaults to `<cwd>/temp/orchestration`; `level` defaults
219
- * to the live Logger level so `AGENT_LOG_LEVEL=verbose` restores streaming.
218
+ * config?: object,
219
+ * }} [args] `logDir` defaults to the configured `<tempRoot>/orchestration`
220
+ * (Story #4794 — was a hardcoded `<cwd>/temp/orchestration`, which ignored
221
+ * `project.paths.tempRoot` and hid the artifact from the retention purge);
222
+ * `level` defaults to the live Logger level so `AGENT_LOG_LEVEL=verbose`
223
+ * restores streaming.
220
224
  * @returns {GateLogSink}
221
225
  */
222
226
  export function createGateLogSink({
223
227
  storyId = null,
224
- cwd = process.cwd(),
225
228
  logDir,
226
229
  fs = nodeFs,
227
230
  logger = Logger,
228
231
  level,
232
+ config,
229
233
  } = {}) {
230
234
  const emit = (line) => logger.info?.(line);
231
235
  const verbose = (level ?? resolveLevel()) === 'verbose';
232
- const dir = logDir ?? path.join(cwd, 'temp', 'orchestration');
236
+ const dir = logDir ?? orchestrationLogDir(config);
233
237
 
234
238
  let writer = null;
235
239
  let logPath = null;
@@ -33,6 +33,13 @@
33
33
  * needs the evidence in front of them. `AGENT_LOG_LEVEL=verbose` restores
34
34
  * live streaming.
35
35
  *
36
+ * Projection advisories (Story #4776). `baseBranch`, `storyBranch` and the
37
+ * resolved `config` are forwarded to `runCloseValidation` so its projection
38
+ * phase can run. They surface, after the gates pass, which committed
39
+ * baseline rows the post-merge tree would breach and the exact
40
+ * `*:update` + `baseline-refresh:` remedy — advisory only, so the close
41
+ * verdict is unchanged.
42
+ *
36
43
  * `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
37
44
  * are accepted as injected dependencies so the parent CLI's cache-busted
38
45
  * bindings win in tests that mock the upstream module URLs.
@@ -136,7 +143,7 @@ export async function runCloseValidationPhase({
136
143
  );
137
144
  // Story #4736 — one sink for both `log` seams (gate construction and gate
138
145
  // execution), so nothing in the chain can route around the artifact.
139
- const gateLog = createGateLogSink({ storyId, cwd });
146
+ const gateLog = createGateLogSink({ storyId, config });
140
147
  let validation;
141
148
  try {
142
149
  validation = await runCloseValidation({
@@ -154,6 +161,13 @@ export async function runCloseValidationPhase({
154
161
  // epicId; the standalone flag routes the cache to
155
162
  // temp/standalone/stories/story-<id>/validation-evidence.json.
156
163
  standalone: true,
164
+ // Story #4776 — the branch pair and resolved config the advisory
165
+ // projections need. Without them the runner skips the projection
166
+ // phase entirely, which is the correct behaviour for resume/legacy
167
+ // callers that have no story branch to diff.
168
+ baseBranch,
169
+ storyBranch,
170
+ config,
157
171
  });
158
172
  } finally {
159
173
  // Story #4766 — gate lines are buffered to an async stream so the drain
@@ -39,6 +39,7 @@ import {
39
39
  RUNTIME_FRICTION_CATEGORIES,
40
40
  } from '../../../observability/runtime-friction.js';
41
41
  import { acquireLockWithWait as defaultAcquireLockWithWait } from '../../../single-story-sweep/sweep-lock.js';
42
+ import { purgeStoryTempArtifacts as defaultPurgeStoryTempArtifacts } from '../../../temp-retention.js';
42
43
  import {
43
44
  executeFastForward as defaultExecuteFastForward,
44
45
  planFastForward as defaultPlanFastForward,
@@ -224,6 +225,22 @@ async function stepBaseFastForward({
224
225
  };
225
226
  }
226
227
 
228
+ /**
229
+ * Purge this Story's spent temp artifacts now that its merge is confirmed
230
+ * (Story #4794).
231
+ *
232
+ * The engine already emits its own one-line summary and returns a disabled
233
+ * policy as `skipped` with no errors, so this step needs no branching of its
234
+ * own: errors degrade it, everything else — including a deliberate
235
+ * config-disabled no-op — is a success. Reporting a disabled purge as a failed
236
+ * step would train readers to ignore the field.
237
+ */
238
+ async function stepTempPurge({ storyId, config, purgeStoryTempArtifactsFn }) {
239
+ const result = await purgeStoryTempArtifactsFn({ storyId, config });
240
+ const errors = result?.errors ?? [];
241
+ return { ok: errors.length === 0, detail: errors.join('; ') || null };
242
+ }
243
+
227
244
  /**
228
245
  * Run the whole post-land tail. Never throws.
229
246
  *
@@ -262,7 +279,8 @@ async function stepBaseFastForward({
262
279
  * @param {Function} [args.planFastForwardFn] Test seam.
263
280
  * @param {Function} [args.executeFastForwardFn] Test seam.
264
281
  * @param {Function} [args.acquireLockWithWaitFn] Test seam.
265
- * @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, details: Record<string, string|null> }>}
282
+ * @param {Function} [args.purgeStoryTempArtifactsFn] Test seam.
283
+ * @returns {Promise<{ followUps: boolean, statusResync: boolean, refCleanup: boolean, baseFastForward: boolean, tempPurge: boolean, details: Record<string, string|null> }>}
266
284
  */
267
285
  export async function runPostLandTail({
268
286
  storyId,
@@ -280,6 +298,7 @@ export async function runPostLandTail({
280
298
  planFastForwardFn = defaultPlanFastForward,
281
299
  executeFastForwardFn = defaultExecuteFastForward,
282
300
  acquireLockWithWaitFn = defaultAcquireLockWithWait,
301
+ purgeStoryTempArtifactsFn = defaultPurgeStoryTempArtifacts,
283
302
  }) {
284
303
  progress?.('POST-LAND', `🧾 Running land tail for Story #${storyId}...`);
285
304
 
@@ -373,16 +392,27 @@ export async function runPostLandTail({
373
392
  if (lock.acquired) lock.release();
374
393
  }
375
394
 
395
+ // Story #4794 — the merge is confirmed, so this Story's gate transcripts and
396
+ // validation evidence are spent. Runs LAST so a purge can never race a step
397
+ // that still reads them, and outside the checkout lock because it touches
398
+ // only the temp tree. Its `signals.ndjson` survives by construction.
399
+ const tempPurge = await step(
400
+ () => stepTempPurge({ storyId, config, purgeStoryTempArtifactsFn }),
401
+ { name: 'temp purge', progress },
402
+ );
403
+
376
404
  const tail = {
377
405
  followUps: followUps.ok,
378
406
  statusResync: statusResync.ok,
379
407
  refCleanup: refCleanup.ok,
380
408
  baseFastForward: baseFastForward.ok,
409
+ tempPurge: tempPurge.ok,
381
410
  details: {
382
411
  followUps: followUps.detail,
383
412
  statusResync: statusResync.detail,
384
413
  refCleanup: refCleanup.detail,
385
414
  baseFastForward: baseFastForward.detail,
415
+ tempPurge: tempPurge.detail,
386
416
  },
387
417
  };
388
418
  const degraded = Object.entries(tail)