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
@@ -20,10 +20,23 @@
20
20
  * to catch a distorted shape without a dedicated sub-agent.
21
21
  * - **Pre-mortem**: dispatch when the ticket count is at least half
22
22
  * of `maxTickets`, OR any configured `planning.riskHeuristics` phrase
23
- * matches the plan text (case-insensitive substring). Story #4542 removed
24
- * its third condition the authored risk verdict's overall level — along
25
- * with the verdict itself; both surviving conditions read the plan's own
26
- * observable text and shape rather than a self-assessment.
23
+ * matches the plan text (case-insensitive substring), OR the
24
+ * **external-dependency probe** (Story #4700) finds an out-of-repo marker
25
+ * in the plan text. Story #4542 removed the authored-risk-verdict condition
26
+ * along with the verdict itself; every surviving condition reads the plan's
27
+ * own observable text and shape rather than a self-assessment.
28
+ *
29
+ * The external-dependency probe (Story #4700) is what gives the default N=1
30
+ * path a cheap viability check: on that path the size condition is unreachable
31
+ * (`count*2 >= maxTickets` never holds at one ticket) and a repo whose resolved
32
+ * `planning.riskHeuristics` is empty has no phrase to match, so a plan-time
33
+ * discoverable blocker — a scoped package the plan names that no manifest
34
+ * declares, a cross-repo reference, an external service prerequisite — reached
35
+ * delivery unquestioned (the swarm-os #757 shape). The probe is deliberately
36
+ * **conservative**: it matches only explicit markers (npm scoped-package specs,
37
+ * `github.com/<owner>/<repo>` URLs, prerequisite-keyword-anchored endpoints),
38
+ * never NLP guesswork, so a plan with no such marker dispatches exactly as it
39
+ * did before.
27
40
  *
28
41
  * Under-firing risk (design PR6 note): the persist validators are
29
42
  * unchanged hard gates and G2's cohort re-measures plan quality; every
@@ -107,8 +120,150 @@ export function evaluateConsolidationDispatch({ draftStories, specText }) {
107
120
  }
108
121
 
109
122
  /**
110
- * Decide the pre-mortem dispatch: size ½ budget, or a risk-heuristic
111
- * phrase match.
123
+ * Explicit npm scoped-package marker: `@scope/name`. Requires the leading `@`
124
+ * and an interior `/`, so bare GitHub handles (`@dsj1984`) and the
125
+ * `@[USERNAME]` operator-handle placeholder never match.
126
+ */
127
+ const SCOPED_PACKAGE_MARKER = /@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*/gi;
128
+
129
+ /** Explicit cross-repo marker: a `github.com/<owner>/<repo>` URL. */
130
+ const GITHUB_REPO_MARKER =
131
+ /github\.com\/([a-z0-9][a-z0-9._-]*)\/([a-z0-9][a-z0-9._-]*)/gi;
132
+
133
+ /**
134
+ * Explicit external-service prerequisite marker: a prerequisite keyword
135
+ * followed, within the same clause, by an http(s) endpoint. The keyword gate
136
+ * is what keeps casual documentation links from matching — only an endpoint
137
+ * named as a precondition counts.
138
+ */
139
+ const SERVICE_PREREQ_MARKER =
140
+ /\b(?:requires?|required|prerequisite|provision(?:ed|ing)?|depends?\s+on|credentials?\s+for)\b[^.\n]*?\bhttps?:\/\/([a-z0-9][a-z0-9.-]*)/gi;
141
+
142
+ /** Order-preserving de-duplication. */
143
+ function uniquePreserveOrder(values) {
144
+ return [...new Set(values)];
145
+ }
146
+
147
+ /** Quote each item for an evidence reason string. */
148
+ function quoteList(values) {
149
+ return values.map((v) => `"${v}"`).join(', ');
150
+ }
151
+
152
+ /**
153
+ * Scoped packages named in the plan that no repo manifest declares.
154
+ *
155
+ * @param {string} planText
156
+ * @param {string[]} knownPackages - Package specifiers the repo's own
157
+ * manifests declare (own name + dependency maps + workspace package names).
158
+ * @returns {string[]}
159
+ */
160
+ function matchExternalScopedPackages(planText, knownPackages) {
161
+ const known = new Set(
162
+ knownPackages
163
+ .filter((n) => typeof n === 'string')
164
+ .map((n) => n.trim().toLowerCase()),
165
+ );
166
+ const matches = [];
167
+ for (const m of planText.matchAll(SCOPED_PACKAGE_MARKER)) {
168
+ if (!known.has(m[0].toLowerCase())) matches.push(m[0]);
169
+ }
170
+ return uniquePreserveOrder(matches);
171
+ }
172
+
173
+ /**
174
+ * `github.com/<owner>/<repo>` references outside the configured repo. When the
175
+ * owner is unknown (no `github.owner` configured) the arm stays silent rather
176
+ * than flag every URL as foreign.
177
+ *
178
+ * @param {string} planText
179
+ * @param {{ owner?: string|null, repo?: string|null }|null} ownerRepo
180
+ * @returns {string[]}
181
+ */
182
+ function matchCrossRepoRefs(planText, ownerRepo) {
183
+ const owner =
184
+ typeof ownerRepo?.owner === 'string'
185
+ ? ownerRepo.owner.trim().toLowerCase()
186
+ : '';
187
+ if (!owner) return [];
188
+ const repo =
189
+ typeof ownerRepo?.repo === 'string'
190
+ ? ownerRepo.repo.trim().toLowerCase()
191
+ : '';
192
+ const matches = [];
193
+ for (const m of planText.matchAll(GITHUB_REPO_MARKER)) {
194
+ const internal =
195
+ m[1].toLowerCase() === owner && (!repo || m[2].toLowerCase() === repo);
196
+ if (!internal) matches.push(`${m[1]}/${m[2]}`);
197
+ }
198
+ return uniquePreserveOrder(matches);
199
+ }
200
+
201
+ /**
202
+ * Endpoints named as prerequisites in the plan text.
203
+ *
204
+ * @param {string} planText
205
+ * @returns {string[]}
206
+ */
207
+ function matchExternalServicePrereqs(planText) {
208
+ const matches = [];
209
+ for (const m of planText.matchAll(SERVICE_PREREQ_MARKER)) {
210
+ matches.push(m[1]);
211
+ }
212
+ return uniquePreserveOrder(matches);
213
+ }
214
+
215
+ /**
216
+ * The external-dependency probe (Story #4700): a conservative, marker-only
217
+ * scan of the draft plan text for artifacts outside the current repo that the
218
+ * plan depends on. A match is the pre-mortem's third dispatch condition; a
219
+ * no-match plan behaves exactly as it did before this probe existed.
220
+ *
221
+ * @param {object} input
222
+ * @param {string} [input.planText] - Concatenated plan text (tech spec +
223
+ * serialized tickets).
224
+ * @param {string[]} [input.knownPackages] - Package specifiers the repo's own
225
+ * manifests declare, used to tell an external scoped package from a local one.
226
+ * @param {{ owner?: string|null, repo?: string|null }|null} [input.ownerRepo] -
227
+ * The configured `github.owner`/`github.repo` a cross-repo reference is
228
+ * measured against.
229
+ * @returns {{ matched: boolean, reasons: string[] }}
230
+ */
231
+ export function evaluateExternalDependencyProbe({
232
+ planText = '',
233
+ knownPackages = [],
234
+ ownerRepo = null,
235
+ }) {
236
+ const text = String(planText);
237
+ const packages = matchExternalScopedPackages(text, knownPackages);
238
+ const crossRepo = matchCrossRepoRefs(text, ownerRepo);
239
+ const services = matchExternalServicePrereqs(text);
240
+
241
+ const reasons = [];
242
+ if (packages.length > 0) {
243
+ reasons.push(
244
+ `External-dependency probe: scoped package(s) named in the plan but absent from the repo's own manifests: ${quoteList(packages)}.`,
245
+ );
246
+ }
247
+ if (crossRepo.length > 0) {
248
+ const scope = ownerRepo.repo
249
+ ? `${ownerRepo.owner}/${ownerRepo.repo}`
250
+ : ownerRepo.owner;
251
+ reasons.push(
252
+ `External-dependency probe: cross-repo reference(s) outside ${scope}: ${quoteList(crossRepo)}.`,
253
+ );
254
+ }
255
+ if (services.length > 0) {
256
+ reasons.push(
257
+ `External-dependency probe: external service prerequisite endpoint(s): ${quoteList(services)}.`,
258
+ );
259
+ }
260
+
261
+ return { matched: reasons.length > 0, reasons };
262
+ }
263
+
264
+ /**
265
+ * Decide the pre-mortem dispatch: size ≥ ½ budget, a risk-heuristic phrase
266
+ * match, or an external-dependency probe match (Story #4700).
112
267
  *
113
268
  * @param {object} input
114
269
  * @param {number} input.ticketCount - Draft ticket count (0 in the
@@ -117,8 +272,15 @@ export function evaluateConsolidationDispatch({ draftStories, specText }) {
117
272
  * (`getLimits(config).maxTickets`).
118
273
  * @param {string[]} [input.riskHeuristics] - `planning.riskHeuristics`
119
274
  * phrases from the resolved config.
120
- * @param {string} [input.planText] - Concatenated plan text the heuristics
121
- * match against (tech spec + serialized tickets).
275
+ * @param {string} [input.planText] - Concatenated plan text the heuristics and
276
+ * the external-dependency probe match against (tech spec + serialized
277
+ * tickets).
278
+ * @param {string[]} [input.knownPackages] - Package specifiers the repo's own
279
+ * manifests declare (own name + dependency maps + workspace package names),
280
+ * passed to the external-dependency probe.
281
+ * @param {{ owner?: string|null, repo?: string|null }|null} [input.ownerRepo] -
282
+ * The configured `github.owner`/`github.repo`, passed to the
283
+ * external-dependency probe's cross-repo arm.
122
284
  * @returns {CriticDispatchDecision}
123
285
  */
124
286
  export function evaluatePremortemDispatch({
@@ -126,6 +288,8 @@ export function evaluatePremortemDispatch({
126
288
  maxTickets,
127
289
  riskHeuristics = [],
128
290
  planText = '',
291
+ knownPackages = [],
292
+ ownerRepo = null,
129
293
  }) {
130
294
  if (!Number.isInteger(maxTickets) || maxTickets <= 0) {
131
295
  throw new TypeError(
@@ -154,6 +318,15 @@ export function evaluatePremortemDispatch({
154
318
  );
155
319
  }
156
320
 
321
+ const externalDeps = evaluateExternalDependencyProbe({
322
+ planText,
323
+ knownPackages,
324
+ ownerRepo,
325
+ });
326
+ if (externalDeps.matched) {
327
+ reasons.push(...externalDeps.reasons);
328
+ }
329
+
157
330
  if (reasons.length > 0) {
158
331
  return { critic: 'pre-mortem', dispatch: true, reasons };
159
332
  }
@@ -162,7 +335,7 @@ export function evaluatePremortemDispatch({
162
335
  critic: 'pre-mortem',
163
336
  dispatch: false,
164
337
  reasons: [
165
- `Ticket count ${count} is under half the budget (maxTickets ${maxTickets}) and no planning.riskHeuristics phrase matches the plan text.`,
338
+ `Ticket count ${count} is under half the budget (maxTickets ${maxTickets}), no planning.riskHeuristics phrase matches the plan text, and the external-dependency probe found no out-of-repo markers.`,
166
339
  ],
167
340
  };
168
341
  }
@@ -42,6 +42,20 @@ function resolveRiskHeuristics(config = {}) {
42
42
  return [];
43
43
  }
44
44
 
45
+ /**
46
+ * Resolve the `{ owner, repo }` the external-dependency probe's cross-repo arm
47
+ * (Story #4700) measures references against, from the canonical `github` block.
48
+ *
49
+ * @param {object} config
50
+ * @returns {{ owner: string|null, repo: string|null }}
51
+ */
52
+ function resolveOwnerRepo(config = {}) {
53
+ return {
54
+ owner: config.github?.owner ?? null,
55
+ repo: config.github?.repo ?? null,
56
+ };
57
+ }
58
+
45
59
  /**
46
60
  * Evaluate the consolidation + pre-mortem critic dispatch conditions over
47
61
  * the authored planning artifacts (#4474 PR6 conditions, unchanged):
@@ -50,8 +64,12 @@ function resolveRiskHeuristics(config = {}) {
50
64
  * single-delivery shape authors no draft tickets); otherwise the
51
65
  * deterministic precondition + size/divergence conditions.
52
66
  * - Pre-mortem: ticket count at least half `maxTickets`, OR any
53
- * `planning.riskHeuristics` phrase matching the plan text. Story #4542
54
- * retired its authored-risk-level condition with the verdict itself.
67
+ * `planning.riskHeuristics` phrase matching the plan text, OR the
68
+ * external-dependency probe (Story #4700) matching an out-of-repo marker
69
+ * — a scoped package absent from `knownPackages`, a cross-repo
70
+ * `github.com/<owner>/<repo>` reference, or a named external service
71
+ * prerequisite. Story #4542 retired its authored-risk-level condition with
72
+ * the verdict itself.
55
73
  * - Text hygiene (Story #4599, advisory-only): deterministic body lints
56
74
  * (dangling-citation / open-question / slicing-mass) over the draft
57
75
  * stories. It has no `dispatch` semantics and spawns nothing — its
@@ -62,7 +80,13 @@ function resolveRiskHeuristics(config = {}) {
62
80
  * techSpecContent: string,
63
81
  * tickets?: Array<object>|null,
64
82
  * config?: object,
83
+ * knownPackages?: string[],
65
84
  * }} args
85
+ * @param {string[]} [args.knownPackages] - Package specifiers the repo's own
86
+ * manifests declare, forwarded to the pre-mortem external-dependency probe
87
+ * (Story #4700). The caller (the `plan-critics.js` CLI) owns the file I/O
88
+ * that gathers them; this module stays pure. Empty when unresolved, which
89
+ * only widens what the probe treats as external.
66
90
  * @returns {{
67
91
  * consolidation: { critic: string, dispatch: boolean, reasons: string[] },
68
92
  * premortem: { critic: string, dispatch: boolean, reasons: string[] },
@@ -73,6 +97,7 @@ export function evaluatePlanCritics({
73
97
  techSpecContent,
74
98
  tickets = null,
75
99
  config = {},
100
+ knownPackages = [],
76
101
  }) {
77
102
  const ticketList = Array.isArray(tickets) ? tickets : null;
78
103
  const consolidation =
@@ -97,6 +122,8 @@ export function evaluatePlanCritics({
97
122
  techSpecContent ?? '',
98
123
  ticketList ? JSON.stringify(ticketList) : '',
99
124
  ].join('\n'),
125
+ knownPackages,
126
+ ownerRepo: resolveOwnerRepo(config),
100
127
  });
101
128
 
102
129
  const textHygiene = {
@@ -39,16 +39,15 @@
39
39
  * Robustness contract (mirrors `lib/observability/signals-writer.js`):
40
40
  * - **Best-effort writes.** A failed append is a missing metric, not a
41
41
  * failed plan phase — fs errors are swallowed after a `Logger.warn`.
42
- * - **No buffering.** Each append opens, writes one line, closes.
42
+ * - **No buffering / rotation.** The append tail (open → append one line
43
+ * rotate at the byte cap → close) is owned by the shared
44
+ * `lib/observability/metrics-ledger.js` module (Story #4712); every
45
+ * appender here routes through it. The close-domain findings-yield
46
+ * entry point (Story #4699) lives there too, so the story-close review
47
+ * spine never imports this plan-domain module.
43
48
  * - **Malformed-line tolerance on read.** The reader skips unparseable
44
49
  * lines (counting them) instead of throwing, so a torn write can never
45
50
  * wedge the analyzer.
46
- * - **Rotation.** When an append would push the ledger past
47
- * `MAX_LEDGER_BYTES`, the current file is renamed to
48
- * `plan-metrics.json.1` (replacing any prior rollover) and the append
49
- * starts a fresh ledger, so a long-lived Epic cannot grow the file
50
- * unboundedly. Readers only consume the active generation — the
51
- * rollover exists for manual archaeology.
52
51
  *
53
52
  * `plan-metrics.json` is intentionally NOT in
54
53
  * `lib/plan-phase-cleanup.js#PHASE_TEMP_BASENAMES`: the ledger must survive
@@ -57,69 +56,17 @@
57
56
  */
58
57
 
59
58
  import fs from 'node:fs/promises';
60
- import path from 'node:path';
61
59
 
62
- import {
63
- anchorTempRoot,
64
- runArtifactPath,
65
- tempRootFrom,
66
- } from '../config/temp-paths.js';
67
60
  import { Logger } from '../Logger.js';
68
-
69
- export const PLAN_METRICS_BASENAME = 'plan-metrics.json';
70
- export const PLAN_METRICS_SCHEMA_VERSION = 1;
61
+ import {
62
+ appendLedgerRecord,
63
+ PLAN_METRICS_SCHEMA_VERSION,
64
+ planMetricsPath,
65
+ } from '../observability/metrics-ledger.js';
71
66
 
72
67
  /** Record kind for a logged critic skip decision (Epic #4474 PR6). */
73
68
  export const PLAN_METRICS_KIND_CRITIC_SKIP = 'critic-skip';
74
69
 
75
- /**
76
- * Rotation threshold. At ~200 bytes per record this is ~5000 invocations —
77
- * far beyond any real plan run, so rotation only fires on pathological
78
- * accumulation.
79
- */
80
- export const MAX_LEDGER_BYTES = 1024 * 1024;
81
-
82
- /**
83
- * Resolve the ledger path for an Epic (or the standalone stream when
84
- * `epicId` is `null` — the `story-plan.js` / Epic-less healthcheck case).
85
- *
86
- * @param {number|null} epicId
87
- * @param {object} [config] Resolved config (threads `project.paths.tempRoot`).
88
- * @returns {string}
89
- */
90
- export function planMetricsPath(epicId, config) {
91
- if (epicId === null || epicId === undefined) {
92
- return path.join(
93
- anchorTempRoot(tempRootFrom(config)),
94
- 'standalone',
95
- PLAN_METRICS_BASENAME,
96
- );
97
- }
98
- return runArtifactPath(epicId, PLAN_METRICS_BASENAME, config);
99
- }
100
-
101
- /**
102
- * Rotate the ledger when appending `incomingBytes` would exceed
103
- * `maxBytes`. Single-generation rollover: `plan-metrics.json` →
104
- * `plan-metrics.json.1` (any prior `.1` is replaced).
105
- *
106
- * @param {string} filePath
107
- * @param {number} incomingBytes
108
- * @param {number} [maxBytes]
109
- * @returns {Promise<boolean>} true when a rotation happened.
110
- */
111
- async function rotateIfNeeded(filePath, incomingBytes, maxBytes) {
112
- let size = 0;
113
- try {
114
- size = (await fs.stat(filePath)).size;
115
- } catch {
116
- return false; // No existing ledger — nothing to rotate.
117
- }
118
- if (size + incomingBytes <= maxBytes) return false;
119
- await fs.rename(filePath, `${filePath}.1`);
120
- return true;
121
- }
122
-
123
70
  /**
124
71
  * Append one invocation record to the ledger. Best-effort: returns `false`
125
72
  * (after a `Logger.warn`) instead of throwing on any fs failure, so metric
@@ -165,15 +112,11 @@ export async function appendPlanMetric(entry, config, opts = {}) {
165
112
  ) || 0,
166
113
  ok: entry.ok === true,
167
114
  };
168
- const filePath = planMetricsPath(epicId, config);
169
- const line = `${JSON.stringify(record)}\n`;
170
- await fs.mkdir(path.dirname(filePath), { recursive: true });
171
- await rotateIfNeeded(
172
- filePath,
173
- Buffer.byteLength(line),
174
- opts.maxBytes ?? MAX_LEDGER_BYTES,
175
- );
176
- await fs.appendFile(filePath, line, 'utf8');
115
+ await appendLedgerRecord(record, {
116
+ epicId,
117
+ config,
118
+ maxBytes: opts.maxBytes,
119
+ });
177
120
  return true;
178
121
  } catch (err) {
179
122
  Logger.warn(
@@ -198,9 +141,10 @@ export async function appendPlanMetric(entry, config, opts = {}) {
198
141
  * epicId?: number|null,
199
142
  * }} entry
200
143
  * @param {object} [config]
144
+ * @param {{ maxBytes?: number }} [opts] Test seam for the rotation threshold.
201
145
  * @returns {Promise<boolean>} true when the line was written.
202
146
  */
203
- export async function appendCriticSkip(entry, config) {
147
+ export async function appendCriticSkip(entry, config, opts = {}) {
204
148
  try {
205
149
  if (!entry || typeof entry !== 'object') {
206
150
  throw new TypeError('appendCriticSkip requires an entry object');
@@ -223,11 +167,11 @@ export async function appendCriticSkip(entry, config) {
223
167
  epicId,
224
168
  at: new Date().toISOString(),
225
169
  };
226
- const filePath = planMetricsPath(epicId, config);
227
- const line = `${JSON.stringify(record)}\n`;
228
- await fs.mkdir(path.dirname(filePath), { recursive: true });
229
- await rotateIfNeeded(filePath, Buffer.byteLength(line), MAX_LEDGER_BYTES);
230
- await fs.appendFile(filePath, line, 'utf8');
170
+ await appendLedgerRecord(record, {
171
+ epicId,
172
+ config,
173
+ maxBytes: opts.maxBytes,
174
+ });
231
175
  return true;
232
176
  } catch (err) {
233
177
  Logger.warn(
@@ -348,14 +292,13 @@ export async function readPlanMetrics(epicId, config) {
348
292
  */
349
293
  /**
350
294
  * Timestamp a ledger record is ordered by: `startedAt` for invocation
351
- * records, `at` for critic-skip records.
295
+ * records, `at` for kinded records (critic-skip, findings-yield).
352
296
  *
353
297
  * @param {object} entry
354
298
  * @returns {string|null}
355
299
  */
356
300
  function recordTimestamp(entry) {
357
- const stamp =
358
- entry?.kind === PLAN_METRICS_KIND_CRITIC_SKIP ? entry.at : entry.startedAt;
301
+ const stamp = typeof entry?.kind === 'string' ? entry.at : entry?.startedAt;
359
302
  return typeof stamp === 'string' ? stamp : null;
360
303
  }
361
304
 
@@ -390,6 +333,12 @@ export function summarizePlanMetrics(ledger, opts = {}) {
390
333
  }
391
334
  continue;
392
335
  }
336
+ if (typeof e.kind === 'string') {
337
+ // Any other kinded record (e.g. `findings-yield`, Story #4699) is not
338
+ // an invocation — readers key on `kind`, never on absent fields, so it
339
+ // must not inflate the invocation/failure tallies.
340
+ continue;
341
+ }
393
342
  invocationEntries.push(e);
394
343
  byCli[e.cli] = (byCli[e.cli] ?? 0) + 1;
395
344
  if (typeof e.mode === 'string') byMode[e.mode] = (byMode[e.mode] ?? 0) + 1;
@@ -44,6 +44,7 @@ import { anchorTempRoot, tempRootFrom } from '../../config/temp-paths.js';
44
44
  import { getLimits, PROJECT_ROOT } from '../../config-resolver.js';
45
45
  import { gitSpawn } from '../../git-utils.js';
46
46
  import { Logger } from '../../Logger.js';
47
+ import { applyPlannerDowngrade, LITE_ROUTE_LABEL } from '../complexity-gate.js';
47
48
  import {
48
49
  appendCriticSkip,
49
50
  readPlanMetrics,
@@ -261,6 +262,70 @@ async function renderRunScopedPlanMetricsLine({
261
262
  }
262
263
  }
263
264
 
265
+ /**
266
+ * Resolve the plan's **effective** complexity route for persist
267
+ * (Story #4707).
268
+ *
269
+ * The deterministic verdict rides in on the captured plan-context envelope's
270
+ * `complexityRoute`. Layered on top is the audited planner downgrade: a
271
+ * `full` verdict downgrades to `lite` **only** when the operator/planner
272
+ * passed `--route-downgrade-reason` with a non-empty reason
273
+ * (`applyPlannerDowngrade` — absent a recorded reason the deterministic
274
+ * verdict stands, and the gate itself still fails toward `full`).
275
+ *
276
+ * The resolved route decides whether the created Stories carry the
277
+ * {@link LITE_ROUTE_LABEL} marker and a `route` block on their
278
+ * `story-plan-state` checkpoint — the persisted, ledgered record `/deliver`
279
+ * reads. A full route persists **no** marker and no checkpoint block:
280
+ * absence is the conservative default.
281
+ *
282
+ * Module-private: reachable end to end through {@link runPlanPersist}
283
+ * (whose result reports the resolved route), so there is no test-only
284
+ * export to leave production-dead.
285
+ *
286
+ * @param {{ planContextEnvelope?: object|null, routeDowngradeReason?: string|null }} args
287
+ * @returns {{ route: 'lite'|'full', reasons: string[], downgraded: { from: 'full', reason: string }|null }|null}
288
+ * `null` when no envelope carried a verdict (nothing to persist).
289
+ */
290
+ function resolveEffectiveRoute({
291
+ planContextEnvelope = null,
292
+ routeDowngradeReason = null,
293
+ } = {}) {
294
+ const verdict = planContextEnvelope?.complexityRoute ?? null;
295
+ if (!verdict || typeof verdict !== 'object') {
296
+ if (
297
+ typeof routeDowngradeReason === 'string' &&
298
+ routeDowngradeReason.trim() !== ''
299
+ ) {
300
+ Logger.warn(
301
+ '[plan-persist] --route-downgrade-reason was passed but no captured ' +
302
+ 'plan-context envelope carries a complexityRoute verdict — there ' +
303
+ 'is no full verdict to downgrade, so the plan persists as full ' +
304
+ '(no route marker).',
305
+ );
306
+ }
307
+ return null;
308
+ }
309
+ const effective = applyPlannerDowngrade(verdict, {
310
+ reason: routeDowngradeReason,
311
+ });
312
+ if (
313
+ typeof routeDowngradeReason === 'string' &&
314
+ routeDowngradeReason.trim() !== '' &&
315
+ effective.downgraded == null
316
+ ) {
317
+ Logger.warn(
318
+ '[plan-persist] --route-downgrade-reason had no effect: the envelope ' +
319
+ `verdict is already "${verdict.route}" — nothing to downgrade.`,
320
+ );
321
+ }
322
+ return {
323
+ route: effective.route === 'lite' ? 'lite' : 'full',
324
+ reasons: Array.isArray(effective.reasons) ? effective.reasons : [],
325
+ downgraded: effective.downgraded ?? null,
326
+ };
327
+ }
328
+
264
329
  /**
265
330
  * Age after which an abandoned `temp/plan-*` directory is reaped. A plan run
266
331
  * that is still being authored is minutes-to-hours old; a week is far past
@@ -328,6 +393,7 @@ export async function reapStalePlanDirs({
328
393
  * stories: Array<object>,
329
394
  * techSpecContent?: string|null,
330
395
  * planAcceptance?: string[]|null,
396
+ * planContextEnvelope?: object|null,
331
397
  * },
332
398
  * config?: object,
333
399
  * settings?: object,
@@ -343,6 +409,7 @@ export async function reapStalePlanDirs({
343
409
  * sourceTicketIds?: number[],
344
410
  * sourceTicketOrigin?: 'flag'|'envelope'|'none',
345
411
  * closeSuperseded?: boolean,
412
+ * routeDowngradeReason?: string|null,
346
413
  * },
347
414
  * }} input
348
415
  */
@@ -357,6 +424,7 @@ export async function runPlanPersist({
357
424
  stories: rawStories = null,
358
425
  techSpecContent = null,
359
426
  planAcceptance = null,
427
+ planContextEnvelope = null,
360
428
  } = artifacts ?? {};
361
429
  const {
362
430
  forceReview = false,
@@ -370,6 +438,7 @@ export async function runPlanPersist({
370
438
  sourceTicketIds = [],
371
439
  sourceTicketOrigin = 'none',
372
440
  closeSuperseded = true,
441
+ routeDowngradeReason = null,
373
442
  } = opts;
374
443
 
375
444
  // Boundary for the plan-metrics summary below: everything this invocation
@@ -444,10 +513,29 @@ export async function runPlanPersist({
444
513
  sourceTicketIds,
445
514
  });
446
515
 
447
- const { created } = await createStoryIssues({
516
+ // Effective complexity route (Story #4707): envelope verdict, plus the
517
+ // audited planner downgrade when --route-downgrade-reason was recorded.
518
+ // Lite persists the `route::lite` marker + a checkpoint block; full
519
+ // persists nothing.
520
+ const route = resolveEffectiveRoute({
521
+ planContextEnvelope,
522
+ routeDowngradeReason,
523
+ });
524
+ const isLiteRoute = route?.route === 'lite';
525
+ if (isLiteRoute) {
526
+ Logger.info(
527
+ `[plan-persist] ceremony-lite route: created Stories carry the ` +
528
+ `${LITE_ROUTE_LABEL} marker` +
529
+ (route.downgraded
530
+ ? ` (planner downgrade, recorded reason: ${route.downgraded.reason})`
531
+ : ' (deterministic gate verdict)'),
532
+ );
533
+ }
534
+
535
+ const { created, planRunLabel } = await createStoryIssues({
448
536
  provider,
449
537
  stories,
450
- opts: { dryRun },
538
+ opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
451
539
  });
452
540
 
453
541
  const primary = created[0];
@@ -498,6 +586,10 @@ export async function runPlanPersist({
498
586
  id: createdStory.id,
499
587
  })),
500
588
  },
589
+ // Ledger the lite route — including any planner downgrade and its
590
+ // recorded reason — on plan state (Story #4707). A full route writes
591
+ // no block: absence of the marker is the full path.
592
+ ...(isLiteRoute ? { route } : {}),
501
593
  });
502
594
  }
503
595
  await upsertStructuredComment(
@@ -551,10 +643,18 @@ export async function runPlanPersist({
551
643
  Logger.info(
552
644
  `[plan-persist] Deliver with: /deliver ${created.map((s2) => s2.id).join(' ')}`,
553
645
  );
646
+ // Metadata only — a GitHub filter for the cohort this run authored, never
647
+ // a delivery-resolution input (/deliver stays ids-only, Story #4540).
648
+ Logger.info(
649
+ `[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
650
+ `label:${planRunLabel}`,
651
+ );
554
652
 
555
653
  return {
556
654
  stories: created,
557
655
  primaryStoryId: primary.id,
656
+ planRunLabel,
657
+ route,
558
658
  forceReview,
559
659
  reachability,
560
660
  freshness,