mandrel 2.9.0 → 2.10.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 (57) hide show
  1. package/.agents/agents/.markdownlint.json +4 -0
  2. package/.agents/agents/acceptance-critic.md +30 -5
  3. package/.agents/agents/auditor.md +36 -19
  4. package/.agents/agents/plan-critic.md +31 -5
  5. package/.agents/agents/story-worker.md +91 -100
  6. package/.agents/docs/configuration.md +16 -4
  7. package/.agents/docs/execution-reference.md +13 -0
  8. package/.agents/docs/workflows.md +1 -1
  9. package/.agents/instructions.md +131 -265
  10. package/.agents/rules/git-conventions.md +47 -83
  11. package/.agents/rules/orchestration-error-handling.md +28 -0
  12. package/.agents/schemas/agentrc.schema.json +24 -2
  13. package/.agents/schemas/validation-evidence.schema.json +3 -1
  14. package/.agents/scripts/acceptance-eval.js +1 -1
  15. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  16. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  17. package/.agents/scripts/deliver-recover.js +23 -6
  18. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  19. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  20. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  21. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  22. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  23. package/.agents/scripts/lib/config-settings-schema.js +1 -1
  24. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  25. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  26. package/.agents/scripts/lib/orchestration/complexity-gate.js +113 -2
  27. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  28. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  29. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  30. package/.agents/scripts/lib/orchestration/plan-context.js +57 -0
  31. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  32. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  33. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  34. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
  35. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
  36. package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
  37. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  38. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  40. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  41. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  42. package/.agents/scripts/lib/templates/decomposer-prompts.js +13 -6
  43. package/.agents/scripts/lib/test-env.js +65 -0
  44. package/.agents/scripts/plan-context.js +66 -9
  45. package/.agents/scripts/plan-critics.js +115 -3
  46. package/.agents/scripts/plan-persist.js +11 -1
  47. package/.agents/scripts/plan-run-epilogue.js +1 -1
  48. package/.agents/scripts/single-story-confirm-merge.js +65 -5
  49. package/.agents/scripts/stories-wave-tick.js +1 -1
  50. package/.agents/workflows/deliver.md +86 -230
  51. package/.agents/workflows/helpers/deliver-reference.md +167 -0
  52. package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
  53. package/.agents/workflows/helpers/deliver-story.md +114 -432
  54. package/.agents/workflows/helpers/plan-reference.md +211 -0
  55. package/.agents/workflows/plan.md +107 -304
  56. package/docs/CHANGELOG.md +27 -0
  57. package/package.json +1 -1
@@ -702,7 +702,7 @@ const SOURCE_CODE_EXTENSIONS = Object.freeze([
702
702
  const TEST_FILE_RE = new RegExp(String.raw`\.(test|spec)\.[cm]?[jt]sx?$`);
703
703
  // biome-ignore lint/complexity/useRegexLiterals: constructor form keeps the MI walker able to score this module.
704
704
  const TEST_DIR_RE = new RegExp(
705
- String.raw`(^|/)(tests?|__tests__|spec|e2e|__mocks__)(/|$)`,
705
+ '(^|/)(tests?|__tests__|spec|e2e|__mocks__)(/|$)',
706
706
  );
707
707
  // biome-ignore lint/complexity/useRegexLiterals: constructor form keeps the MI walker able to score this module.
708
708
  const CODE_EXT_RE = new RegExp(String.raw`\.[cm]?[jt]sx?$`);
@@ -52,6 +52,8 @@
52
52
  */
53
53
 
54
54
  import { execFileSync } from 'node:child_process';
55
+ import { mkdtempSync } from 'node:fs';
56
+ import os from 'node:os';
55
57
  import path from 'node:path';
56
58
 
57
59
  /**
@@ -114,6 +116,81 @@ export function _clearMainCheckoutRootCache() {
114
116
  _mainCheckoutRootCache.clear();
115
117
  }
116
118
 
119
+ /**
120
+ * Environment variable naming an absolute per-process scratch tempRoot that
121
+ * every stream writer must land in during a test run (Story #4696).
122
+ *
123
+ * The shared test bootstrap (`lib/test-env.js`) sets this to a fresh
124
+ * `os.tmpdir()` directory before spawning the test runner, so any test that
125
+ * reaches a writer (`signals-writer`, the lifecycle `LedgerWriter`, etc.)
126
+ * *without* explicitly injecting an absolute tempRoot still resolves under
127
+ * scratch instead of the repo's real `temp/` tree. This is the single
128
+ * injection seam: because every path helper funnels a relative root through
129
+ * `anchorTempRoot`, one redirect here covers all writers regardless of how
130
+ * each one resolved its root.
131
+ */
132
+ export const TEST_TEMP_ROOT_ENV = 'MANDREL_TEST_TEMP_ROOT';
133
+
134
+ /**
135
+ * Resolve the absolute scratch tempRoot override, or `null` when none is
136
+ * configured. Only an **absolute** value is honoured — a relative override
137
+ * would re-anchor against the repo tree and defeat the isolation, so it is
138
+ * ignored (treated as unset). Module-internal: the behaviour is exercised
139
+ * through `anchorTempRoot`, so it is deliberately not exported (keeps the
140
+ * public seam to `anchorTempRoot` + `TEST_TEMP_ROOT_ENV`).
141
+ *
142
+ * @param {NodeJS.ProcessEnv} [env=process.env]
143
+ * @returns {string|null}
144
+ */
145
+ function testScratchTempRoot(env = process.env) {
146
+ const override = env?.[TEST_TEMP_ROOT_ENV];
147
+ return typeof override === 'string' &&
148
+ override.length > 0 &&
149
+ path.isAbsolute(override)
150
+ ? override
151
+ : null;
152
+ }
153
+
154
+ /**
155
+ * Environment variable that opts a test-context process back into the real
156
+ * `temp/` tree (Story #4711). The `anchorTempRoot` test-context fallback
157
+ * refuses to anchor a relative root into the repo's telemetry tree when the
158
+ * process is a node:test context; a test that genuinely needs the real tree
159
+ * sets this to `'1'` on its own spawn — an explicit, greppable opt-out
160
+ * instead of a silent bypass.
161
+ */
162
+ export const TEST_ALLOW_REAL_TEMP_ENV = 'MANDREL_TEST_ALLOW_REAL_TEMP';
163
+
164
+ /**
165
+ * Per-process memo for the lazily-created test-context scratch dir, so every
166
+ * relative-root resolution in one test process converges on a single scratch
167
+ * tree (mirrors the `_mainCheckoutRootCache` pattern above).
168
+ */
169
+ let _testContextScratchDir = null;
170
+
171
+ /**
172
+ * Test-only: clear the test-context scratch memo so a suite can exercise the
173
+ * lazy-arming branch repeatedly in one process.
174
+ */
175
+ export function _clearTestContextScratchCache() {
176
+ _testContextScratchDir = null;
177
+ }
178
+
179
+ /**
180
+ * Is this process a node:test context? True when the env carries
181
+ * `NODE_TEST_CONTEXT` (set by the node:test runner on every spawned test
182
+ * child, regardless of how the runner itself was launched) or when the
183
+ * process was started with the `--test` flag (a direct `node --test <file>`
184
+ * runner process, or in-process isolation modes).
185
+ *
186
+ * @param {NodeJS.ProcessEnv} env
187
+ * @param {string[]} execArgv
188
+ * @returns {boolean}
189
+ */
190
+ function inNodeTestContext(env, execArgv) {
191
+ return Boolean(env?.NODE_TEST_CONTEXT) || execArgv.includes('--test');
192
+ }
193
+
117
194
  /**
118
195
  * Anchor a resolved `tempRoot` to the main checkout root when it is a
119
196
  * relative path (Story #3900). Absolute roots are returned verbatim; a
@@ -123,11 +200,54 @@ export function _clearMainCheckoutRootCache() {
123
200
  * root is returned unchanged so behaviour degrades to the prior
124
201
  * cwd-relative semantics rather than throwing.
125
202
  *
203
+ * Test isolation (Story #4696): when the scratch override
204
+ * (`MANDREL_TEST_TEMP_ROOT`) is set, a relative root is joined onto the
205
+ * scratch dir instead of the main checkout root, so a writer that reaches
206
+ * the default (or any relative) root under the test bootstrap lands in
207
+ * scratch and never pollutes the repo's real `temp/` telemetry tree. An
208
+ * absolute root injected by a well-behaved test still bypasses the redirect
209
+ * verbatim.
210
+ *
211
+ * Process-level arming (Story #4711): the wrapper-armed override above only
212
+ * covers processes spawned by `run-tests.js` — a direct `node --test <file>`
213
+ * run used to bypass it and append fixture records to the real tree. When no
214
+ * override is armed but the process *is* a node:test context (see
215
+ * `inNodeTestContext`), a per-process scratch dir is created lazily and the
216
+ * relative root anchors there instead. The scratch dir is memoized and — for
217
+ * the real `process.env` — written back to `MANDREL_TEST_TEMP_ROOT` so child
218
+ * processes the test spawns inherit the same scratch tree. Escape hatch: a
219
+ * test that genuinely needs the real tree sets
220
+ * `MANDREL_TEST_ALLOW_REAL_TEMP=1` (`TEST_ALLOW_REAL_TEMP_ENV`) to restore
221
+ * main-checkout anchoring; the `check-test-temp-hygiene` guard remains the
222
+ * backstop either way.
223
+ *
126
224
  * @param {string} tempRoot
225
+ * @param {NodeJS.ProcessEnv} [env=process.env]
226
+ * @param {{ mkdtemp?: typeof mkdtempSync, execArgv?: string[] }} [deps]
227
+ * Injectable for tests.
127
228
  * @returns {string}
128
229
  */
129
- export function anchorTempRoot(tempRoot) {
230
+ export function anchorTempRoot(tempRoot, env = process.env, deps = {}) {
130
231
  if (path.isAbsolute(tempRoot)) return tempRoot;
232
+ const scratch = testScratchTempRoot(env);
233
+ if (scratch) return path.join(scratch, tempRoot);
234
+ const execArgv = deps.execArgv ?? process.execArgv;
235
+ if (
236
+ inNodeTestContext(env, execArgv) &&
237
+ env?.[TEST_ALLOW_REAL_TEMP_ENV] !== '1'
238
+ ) {
239
+ if (_testContextScratchDir === null) {
240
+ const mkdtemp = deps.mkdtemp ?? mkdtempSync;
241
+ _testContextScratchDir = mkdtemp(
242
+ path.join(os.tmpdir(), 'mandrel-test-temp-'),
243
+ );
244
+ if (env === process.env) {
245
+ // Children spawned by this test process inherit the same scratch.
246
+ process.env[TEST_TEMP_ROOT_ENV] = _testContextScratchDir;
247
+ }
248
+ }
249
+ return path.join(_testContextScratchDir, tempRoot);
250
+ }
131
251
  const root = mainCheckoutRoot();
132
252
  return root ? path.join(root, tempRoot) : tempRoot;
133
253
  }
@@ -160,10 +160,19 @@ const SIGNALS_SCHEMA = {
160
160
  * behind-the-base PR up to date before giving the branch up as unwinnable,
161
161
  * rather than waiting out the budget behind a base it could have merged.
162
162
  * All keys default in the consumer when omitted (30s / 300s / 3600s / 3).
163
+ *
164
+ * `mode` selects the close-time merge posture (Story #4698). Default `sync`
165
+ * keeps the in-close foreground wait unchanged. `async` caps the per-invocation
166
+ * wait to a short probe window (~60s: catches instant merges and instantly-red
167
+ * required checks) and then returns the resumable `pending` terminal, so a
168
+ * slow-CI consumer no longer burns ~5 minutes of the host tool slot on a merge
169
+ * that lands after the wait would have expired anyway — the worker launches the
170
+ * `pending` envelope's `nextCommand` in the background instead.
163
171
  */
164
172
  const MERGE_WATCH_SCHEMA = {
165
173
  type: 'object',
166
174
  properties: {
175
+ mode: { type: 'string', enum: ['sync', 'async'] },
167
176
  intervalSeconds: { type: 'integer', minimum: 1 },
168
177
  maxWaitSeconds: { type: 'integer', minimum: 1 },
169
178
  maxBudgetSeconds: { type: 'integer', minimum: 1 },
@@ -280,6 +289,26 @@ const ACCEPTANCE_EVAL_SCHEMA = {
280
289
  additionalProperties: false,
281
290
  };
282
291
 
292
+ /**
293
+ * `delivery.review` — close-scope review tuning (Story #4699). `lensDiffFloor`
294
+ * is the changed-line floor below which the Story-scope local-lens pass skips
295
+ * lens materialization for a diff with zero sensitive-path hits (default 40
296
+ * via `lib/audit-suite/lens-diff-floor.js`; `0` disables the skip). The
297
+ * maker-blind code-review pass and all hard gates are untouched by the floor.
298
+ */
299
+ const REVIEW_SCHEMA = {
300
+ type: 'object',
301
+ properties: {
302
+ lensDiffFloor: {
303
+ type: 'integer',
304
+ minimum: 0,
305
+ description:
306
+ 'Changed-line floor for the close-scope lens walk (Story #4699). A diff strictly below this many changed lines (additions + deletions) with zero sensitive-path hits skips lens materialization and records the skip in the findings-yield ledger. Default 40; 0 disables the skip. Hard gates and the maker-blind code-review pass are unaffected.',
307
+ },
308
+ },
309
+ additionalProperties: false,
310
+ };
311
+
283
312
  /**
284
313
  * `delivery.feedbackLoop` — opt-out toggles consumed by the Epic finalize
285
314
  * listener's auto-file graduators (`lib/feedback-loop/*-graduator.js`, read
@@ -335,6 +364,7 @@ export const DELIVERY_SCHEMA = {
335
364
  quality: QUALITY_SCHEMA,
336
365
  mergeWatch: MERGE_WATCH_SCHEMA,
337
366
  codeReview: CODE_REVIEW_SCHEMA,
367
+ review: REVIEW_SCHEMA,
338
368
  refactorStage: REFACTOR_STAGE_SCHEMA,
339
369
  acceptanceEval: ACCEPTANCE_EVAL_SCHEMA,
340
370
  feedbackLoop: FEEDBACK_LOOP_SCHEMA,
@@ -299,7 +299,7 @@ const PLANNING_SCHEMA = {
299
299
  type: 'integer',
300
300
  minimum: 0,
301
301
  description:
302
- 'Seed prose word ceiling for the lite path. A seed above this many words is not trivial and takes the full path. Default 60.',
302
+ 'Seed prose word ceiling for the lite path. A seed above this many words is not trivial and takes the full path. Default 150.',
303
303
  },
304
304
  maxArtifacts: {
305
305
  type: 'integer',
@@ -0,0 +1,217 @@
1
+ /**
2
+ * metrics-ledger.js — the narrow shared metrics-ledger boundary (Story #4712).
3
+ *
4
+ * Owns the one append-tail implementation for the plan-metrics ledger:
5
+ * path resolution, serialization, lazy directory creation, size-capped
6
+ * rotation, and the append itself. Every ledger writer — the plan-domain
7
+ * appenders in `../orchestration/plan-metrics.js` and the close-domain
8
+ * findings-yield entry point below — routes through
9
+ * {@link appendLedgerRecord}, so the open→append→rotate→close tail exists
10
+ * exactly once and cannot drift between call sites.
11
+ *
12
+ * The findings-yield entry point (Story #4699) lives here rather than in
13
+ * the plan-domain module so the story-close review spine
14
+ * (`../orchestration/story-close/phases/review-core.js`) depends on this
15
+ * narrow shared ledger module instead of plan-domain internals.
16
+ *
17
+ * Wire contract (unchanged by the #4712 re-home): record shapes, file
18
+ * locations (`temp/run-<id>/plan-metrics.json` / the standalone stream),
19
+ * and the rotation threshold are exactly what `plan-metrics.js` shipped —
20
+ * readers (`readPlanMetrics` / `summarizePlanMetrics`) stay plan-side and
21
+ * key kinded records on `kind`, never on absent fields.
22
+ *
23
+ * Robustness contract (mirrors `signals-writer.js`):
24
+ * - **No buffering.** Each append opens, writes one line, closes.
25
+ * - **Rotation.** When an append would push the ledger past
26
+ * `maxBytes`, the current file is renamed to `<name>.1` (replacing any
27
+ * prior rollover) and the append starts a fresh ledger.
28
+ * - **Best-effort at the entry points.** {@link appendLedgerRecord}
29
+ * itself throws on fs failure; each public appender catches, warns via
30
+ * `Logger`, and returns `false` so metric capture can never fail the
31
+ * wrapped plan phase or Story close.
32
+ */
33
+
34
+ import fs from 'node:fs/promises';
35
+ import path from 'node:path';
36
+
37
+ import {
38
+ anchorTempRoot,
39
+ runArtifactPath,
40
+ tempRootFrom,
41
+ } from '../config/temp-paths.js';
42
+ import { Logger } from '../Logger.js';
43
+
44
+ export const PLAN_METRICS_BASENAME = 'plan-metrics.json';
45
+ export const PLAN_METRICS_SCHEMA_VERSION = 1;
46
+
47
+ /**
48
+ * Record kind for a per-close, per-lens findings-yield entry (Story #4699).
49
+ * One record per Story close whose review ran (or floor-skipped) at least
50
+ * one local lens:
51
+ *
52
+ * ```json
53
+ * { "v": 1, "kind": "findings-yield", "cli": "story-close-review",
54
+ * "storyId": 4699, "epicId": null,
55
+ * "lenses": [{ "lens": "audit-clean-code", "findings": 0,
56
+ * "skippedByFloor": false }],
57
+ * "diffFloor": { "skip": false, "reason": "at-or-above-floor",
58
+ * "floor": 40, "changedLineCount": 120 },
59
+ * "at": "..." }
60
+ * ```
61
+ *
62
+ * The ledger only records — no roster behavior changes ride on it. Its
63
+ * purpose is evidentiary: a lens that stays at zero findings across N
64
+ * closes becomes droppable on measurement instead of assumption.
65
+ */
66
+ const PLAN_METRICS_KIND_FINDINGS_YIELD = 'findings-yield';
67
+
68
+ /**
69
+ * Rotation threshold. At ~200 bytes per record this is ~5000 invocations —
70
+ * far beyond any real plan run, so rotation only fires on pathological
71
+ * accumulation.
72
+ */
73
+ export const MAX_LEDGER_BYTES = 1024 * 1024;
74
+
75
+ /**
76
+ * Resolve the ledger path for an Epic (or the standalone stream when
77
+ * `epicId` is `null` — the `story-plan.js` / Epic-less healthcheck case).
78
+ *
79
+ * @param {number|null} epicId
80
+ * @param {object} [config] Resolved config (threads `project.paths.tempRoot`).
81
+ * @returns {string}
82
+ */
83
+ export function planMetricsPath(epicId, config) {
84
+ if (epicId === null || epicId === undefined) {
85
+ return path.join(
86
+ anchorTempRoot(tempRootFrom(config)),
87
+ 'standalone',
88
+ PLAN_METRICS_BASENAME,
89
+ );
90
+ }
91
+ return runArtifactPath(epicId, PLAN_METRICS_BASENAME, config);
92
+ }
93
+
94
+ /**
95
+ * Rotate the ledger when appending `incomingBytes` would exceed
96
+ * `maxBytes`. Single-generation rollover: `plan-metrics.json` →
97
+ * `plan-metrics.json.1` (any prior `.1` is replaced).
98
+ *
99
+ * @param {string} filePath
100
+ * @param {number} incomingBytes
101
+ * @param {number} maxBytes
102
+ * @returns {Promise<boolean>} true when a rotation happened.
103
+ */
104
+ async function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
105
+ let size = 0;
106
+ try {
107
+ size = (await fs.stat(filePath)).size;
108
+ } catch {
109
+ return false; // No existing ledger — nothing to rotate.
110
+ }
111
+ if (size + incomingBytes <= maxBytes) return false;
112
+ await fs.rename(filePath, `${filePath}.1`);
113
+ return true;
114
+ }
115
+
116
+ /**
117
+ * The single ledger append tail (Story #4712): serialize the record to one
118
+ * newline-terminated JSON line, create the ledger directory lazily, rotate
119
+ * when the byte cap would be exceeded, and append.
120
+ *
121
+ * Throws on any fs failure — the best-effort posture (warn + `false`,
122
+ * never throw) belongs to the public appenders that wrap this, because
123
+ * each labels its own failure mode.
124
+ *
125
+ * @param {object} record Fully-built ledger record (already validated).
126
+ * @param {{
127
+ * epicId?: number|null,
128
+ * config?: object,
129
+ * maxBytes?: number,
130
+ * }} [opts] `maxBytes` is a test seam for the rotation threshold.
131
+ * @returns {Promise<void>}
132
+ */
133
+ export async function appendLedgerRecord(record, opts = {}) {
134
+ const filePath = planMetricsPath(opts.epicId ?? null, opts.config);
135
+ const line = `${JSON.stringify(record)}\n`;
136
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
137
+ await rotateIfNeeded(
138
+ filePath,
139
+ Buffer.byteLength(line),
140
+ opts.maxBytes ?? MAX_LEDGER_BYTES,
141
+ );
142
+ await fs.appendFile(filePath, line, 'utf8');
143
+ }
144
+
145
+ /**
146
+ * Append one findings-yield record (Story #4699). Called once per Story
147
+ * close by the review spine when the local-lens pass matched (or
148
+ * floor-skipped) at least one lens. Best-effort: returns `false` (after a
149
+ * `Logger.warn`) instead of throwing on any failure, so a ledger failure
150
+ * can never fail the close.
151
+ *
152
+ * @param {{
153
+ * storyId: number,
154
+ * lenses: Array<{ lens: string, findings?: number, skippedByFloor?: boolean }>,
155
+ * cli?: string,
156
+ * epicId?: number|null,
157
+ * diffFloor?: object|null,
158
+ * }} entry
159
+ * @param {object} [config]
160
+ * @param {{ maxBytes?: number }} [opts] Test seam for the rotation threshold.
161
+ * @returns {Promise<boolean>} true when the line was written.
162
+ */
163
+ export async function appendFindingsYield(entry, config, opts = {}) {
164
+ try {
165
+ if (!entry || typeof entry !== 'object') {
166
+ throw new TypeError('appendFindingsYield requires an entry object');
167
+ }
168
+ const storyId = Number(entry.storyId);
169
+ if (!Number.isInteger(storyId) || storyId <= 0) {
170
+ throw new TypeError(
171
+ 'appendFindingsYield requires a positive integer entry.storyId',
172
+ );
173
+ }
174
+ if (!Array.isArray(entry.lenses) || entry.lenses.length === 0) {
175
+ throw new TypeError(
176
+ 'appendFindingsYield requires a non-empty entry.lenses array',
177
+ );
178
+ }
179
+ const epicId = entry.epicId ?? null;
180
+ const record = {
181
+ v: PLAN_METRICS_SCHEMA_VERSION,
182
+ kind: PLAN_METRICS_KIND_FINDINGS_YIELD,
183
+ cli:
184
+ typeof entry.cli === 'string' && entry.cli.length > 0
185
+ ? entry.cli
186
+ : 'story-close-review',
187
+ storyId,
188
+ epicId,
189
+ lenses: entry.lenses
190
+ .filter((l) => l && typeof l.lens === 'string' && l.lens.length > 0)
191
+ .map((l) => ({
192
+ lens: l.lens,
193
+ findings:
194
+ typeof l.findings === 'number' && Number.isFinite(l.findings)
195
+ ? l.findings
196
+ : 0,
197
+ skippedByFloor: l.skippedByFloor === true,
198
+ })),
199
+ diffFloor:
200
+ entry.diffFloor && typeof entry.diffFloor === 'object'
201
+ ? entry.diffFloor
202
+ : null,
203
+ at: new Date().toISOString(),
204
+ };
205
+ await appendLedgerRecord(record, {
206
+ epicId,
207
+ config,
208
+ maxBytes: opts.maxBytes,
209
+ });
210
+ return true;
211
+ } catch (err) {
212
+ Logger.warn(
213
+ `[plan-metrics] findings-yield append failed (non-fatal): ${err?.message ?? err}`,
214
+ );
215
+ return false;
216
+ }
217
+ }
@@ -68,6 +68,13 @@ export const RUNTIME_FRICTION_CATEGORIES = Object.freeze({
68
68
  CLOSE_FAILED: 'close-failed',
69
69
  /** A bounded merge wait expired with the PR still in flight. */
70
70
  MERGE_WAIT_EXHAUSTED: 'merge-wait-exhausted',
71
+ /**
72
+ * A review/lens tool failed to *execute* (binary missing, unparseable
73
+ * output, materialization crash) — an operational degradation, not a code
74
+ * finding. Story #4699 routes these here so findings severity tiers
75
+ * reflect code findings only.
76
+ */
77
+ TOOL_DEGRADED: 'tool-degraded',
71
78
  });
72
79
 
73
80
  /** Cap on free-form reason text copied into a signal's `details`. */
@@ -38,13 +38,31 @@
38
38
  * `planning.complexityGate` in `.agentrc.json`:
39
39
  *
40
40
  * - `enabled` (default `true`) — `false` forces every seed to `full`.
41
- * - `maxSeedWords` (default `60`) — seed prose word ceiling for `lite`.
41
+ * - `maxSeedWords` (default `150`) — seed prose word ceiling for `lite`.
42
42
  * - `maxArtifacts` (default `1`) — enumerated-artifact ceiling for `lite`.
43
43
  *
44
44
  * Resolution clamps every field toward the conservative default: a malformed or
45
45
  * negative ceiling falls back to the framework default rather than widening the
46
46
  * lite path.
47
47
  *
48
+ * ## Planner downgrade + the persisted route marker (Story #4707)
49
+ *
50
+ * Seed word count is a poor complexity proxy: a well-written 70-word trivial
51
+ * seed is no less trivial than a terse 40-word one, which is why the ceiling
52
+ * sits at 150 rather than 60. Two adjacent surfaces live here with the gate so
53
+ * the whole lite-routing contract has one home:
54
+ *
55
+ * - {@link applyPlannerDowngrade} — the planner may downgrade a `full`
56
+ * verdict to `lite` **only** with a recorded reason. The deterministic
57
+ * gate itself is unchanged (it still fails toward `full`); the downgrade
58
+ * is an auditable model judgment layered on top, never a silent gate
59
+ * change. Absent a non-empty reason the deterministic verdict stands.
60
+ * - {@link resolveStoryDispatchMode} — the deliver-side reader of the
61
+ * persisted {@link LITE_ROUTE_LABEL} marker. A lite-routed Story executes
62
+ * inline in the deliver session (no story-worker or acceptance-critic
63
+ * sub-agent boots); everything else dispatches as before. Model-side
64
+ * fan-out only — never a deterministic close gate.
65
+ *
48
66
  * @typedef {'lite'|'full'} ComplexityRoute
49
67
  */
50
68
 
@@ -55,10 +73,21 @@
55
73
  */
56
74
  const DEFAULT_COMPLEXITY_GATE = Object.freeze({
57
75
  enabled: true,
58
- maxSeedWords: 60,
76
+ maxSeedWords: 150,
59
77
  maxArtifacts: 1,
60
78
  });
61
79
 
80
+ /**
81
+ * The persisted route marker for a lite-routed Story (Story #4707).
82
+ *
83
+ * Applied by plan-persist at create time and read by `/deliver` (via the
84
+ * resolver envelope's `stories[].labels`) through
85
+ * {@link resolveStoryDispatchMode}. A full-routed Story carries no marker —
86
+ * absence is the conservative default, so an unlabelled Story always takes
87
+ * the sub-agent dispatch path.
88
+ */
89
+ export const LITE_ROUTE_LABEL = 'route::lite';
90
+
62
91
  /**
63
92
  * The non-negotiables the ceremony-lite path preserves. This is the
64
93
  * contract behind Story #4683 AC-2: collapsing ceremony never means dropping
@@ -205,3 +234,85 @@ export function buildComplexityRouteSignal({ seedText = '', config } = {}) {
205
234
  `trivial single-artifact scope (${wordCount} words ≤ ${threshold.maxSeedWords}, ${artifactCount} enumerated artifact(s) ≤ ${threshold.maxArtifacts}) — collapsed ceremony-lite path; non-negotiables preserved`,
206
235
  );
207
236
  }
237
+
238
+ /**
239
+ * Apply an auditable planner downgrade to a `full` complexity verdict
240
+ * (Story #4707).
241
+ *
242
+ * The deterministic gate is conservative by construction, and seed word count
243
+ * is a poor complexity proxy — so the planner is allowed to judge a `full`
244
+ * verdict down to `lite`, but **only** with a recorded reason. The contract:
245
+ *
246
+ * - No non-empty reason → the deterministic verdict stands, unchanged. A
247
+ * downgrade without a reason is indistinguishable from a silent gate
248
+ * change, which is exactly what this path must never be.
249
+ * - A signal that is not a `full` verdict (already `lite`, or absent) is
250
+ * returned unchanged — there is nothing to downgrade.
251
+ * - Otherwise the returned signal routes `lite`, appends the reason to
252
+ * `reasons`, and carries a frozen `downgraded: { from: 'full', reason }`
253
+ * record so the judgment is ledgerable on plan state (plan-persist writes
254
+ * it into every created Story's `story-plan-state` checkpoint).
255
+ *
256
+ * Pure and total: never mutates `signal`, never throws on malformed input.
257
+ * The gate itself ({@link buildComplexityRouteSignal}) is untouched — it
258
+ * still fails toward `full` on any doubt.
259
+ *
260
+ * @param {ReturnType<typeof buildComplexityRouteSignal>|null|undefined} signal
261
+ * @param {{ reason?: unknown }} [args]
262
+ * @returns {object|null|undefined} The (possibly downgraded) signal.
263
+ */
264
+ export function applyPlannerDowngrade(signal, { reason } = {}) {
265
+ if (!signal || typeof signal !== 'object' || signal.route !== 'full') {
266
+ return signal;
267
+ }
268
+ const recorded = typeof reason === 'string' ? reason.trim() : '';
269
+ if (recorded === '') return signal;
270
+ return {
271
+ ...signal,
272
+ route: 'lite',
273
+ reasons: [
274
+ ...(Array.isArray(signal.reasons) ? signal.reasons : []),
275
+ `planner downgrade full → lite (recorded reason): ${recorded}`,
276
+ ],
277
+ downgraded: Object.freeze({ from: 'full', reason: recorded }),
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Decide how `/deliver` executes a Story from its persisted route marker
283
+ * (Story #4707).
284
+ *
285
+ * Reads the labels the resolver envelope already carries. A Story labelled
286
+ * {@link LITE_ROUTE_LABEL} executes **inline** in the deliver session — no
287
+ * story-worker sub-agent boot and no fresh acceptance-critic sub-agent
288
+ * dispatch (sub-agent boots are the dominant deliver-phase token cost at
289
+ * trivial scope). Every other Story — including one with missing or
290
+ * malformed labels — dispatches as a sub-agent: absence of the marker is the
291
+ * conservative default, mirroring the gate's fail-toward-`full` posture.
292
+ *
293
+ * Inline execution removes model-side fan-out only. Every deterministic
294
+ * `single-story-close.js` gate (validation, security baseline, PR-to-`main`)
295
+ * runs unchanged regardless of mode — see {@link LITE_PATH_INVARIANTS}.
296
+ *
297
+ * @param {{ labels?: unknown }} [args]
298
+ * @returns {{ mode: 'inline'|'subagent', reasons: string[] }}
299
+ */
300
+ export function resolveStoryDispatchMode({ labels } = {}) {
301
+ const list = Array.isArray(labels)
302
+ ? labels.filter((l) => typeof l === 'string')
303
+ : [];
304
+ if (list.includes(LITE_ROUTE_LABEL)) {
305
+ return {
306
+ mode: 'inline',
307
+ reasons: [
308
+ `Story carries the ${LITE_ROUTE_LABEL} route marker — execute deliver-story inline; no story-worker or acceptance-critic sub-agent dispatch (close gates unchanged)`,
309
+ ],
310
+ };
311
+ }
312
+ return {
313
+ mode: 'subagent',
314
+ reasons: [
315
+ `no ${LITE_ROUTE_LABEL} route marker — standard sub-agent dispatch`,
316
+ ],
317
+ };
318
+ }