mandrel 2.8.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 (73) 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 +39 -1
  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 +44 -1
  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/audit-to-stories.js +51 -0
  17. package/.agents/scripts/check-test-temp-hygiene.js +438 -0
  18. package/.agents/scripts/deliver-recover.js +23 -6
  19. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  20. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
  21. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
  23. package/.agents/scripts/lib/config/temp-paths.js +121 -1
  24. package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
  25. package/.agents/scripts/lib/config-settings-schema.js +32 -0
  26. package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
  27. package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
  28. package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
  29. package/.agents/scripts/lib/observability/terse-result.js +114 -0
  30. package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
  31. package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
  32. package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
  34. package/.agents/scripts/lib/orchestration/plan-context.js +60 -0
  35. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
  36. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
  37. package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
  38. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
  39. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
  40. package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
  41. package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
  42. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
  43. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
  44. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
  45. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
  46. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
  47. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
  48. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
  49. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
  50. package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
  51. package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
  52. package/.agents/scripts/lib/story-body/story-body.js +18 -2
  53. package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
  54. package/.agents/scripts/lib/test-env.js +65 -0
  55. package/.agents/scripts/plan-context.js +66 -9
  56. package/.agents/scripts/plan-critics.js +115 -3
  57. package/.agents/scripts/plan-persist.js +11 -1
  58. package/.agents/scripts/plan-run-epilogue.js +1 -1
  59. package/.agents/scripts/providers/github/issues.js +54 -7
  60. package/.agents/scripts/providers/github/search-budget.js +124 -0
  61. package/.agents/scripts/providers/github/search-query.js +71 -0
  62. package/.agents/scripts/single-story-confirm-merge.js +79 -10
  63. package/.agents/scripts/single-story-init.js +19 -3
  64. package/.agents/scripts/stories-wave-tick.js +1 -1
  65. package/.agents/scripts/sync-branch-from-base.js +9 -3
  66. package/.agents/workflows/deliver.md +86 -230
  67. package/.agents/workflows/helpers/deliver-reference.md +167 -0
  68. package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
  69. package/.agents/workflows/helpers/deliver-story.md +114 -422
  70. package/.agents/workflows/helpers/plan-reference.md +211 -0
  71. package/.agents/workflows/plan.md +107 -279
  72. package/docs/CHANGELOG.md +47 -0
  73. package/package.json +1 -1
@@ -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`. */
@@ -0,0 +1,114 @@
1
+ import nodeFs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { Logger } from '../Logger.js';
5
+
6
+ /**
7
+ * Terse hot-path result emission (Story #4685).
8
+ *
9
+ * The orchestration CLIs an agent invokes on every delivery turn
10
+ * (`single-story-init`, `single-story-close`, `single-story-confirm-merge`,
11
+ * `sync-branch-from-base`, the close `emit-blocked` path) historically dumped
12
+ * their whole result object to stdout as pretty-printed JSON:
13
+ *
14
+ * --- STORY CLOSE RESULT ---
15
+ * { ... every field, 2-space indented ... }
16
+ * --- END RESULT ---
17
+ *
18
+ * That blob stays resident for the rest of the session and is re-read as
19
+ * cache every subsequent turn, yet the agent acts on only a handful of its
20
+ * fields (the machine contract is the separate terminal envelope). This helper
21
+ * routes the full detail to a temp log the agent can read on demand and emits
22
+ * a single structured summary line in its place.
23
+ *
24
+ * The escape hatch `MANDREL_RESULT_DETAIL=inline` restores the old inline
25
+ * pretty dump for interactive debugging.
26
+ */
27
+
28
+ /** Env var that restores the legacy inline pretty dump when set to `inline`. */
29
+ const RESULT_DETAIL_ENV = 'MANDREL_RESULT_DETAIL';
30
+
31
+ /**
32
+ * Turn a human label (`STORY CLOSE RESULT`) into a filesystem-safe log
33
+ * basename fragment (`story-close-result`).
34
+ *
35
+ * @param {string} label
36
+ * @returns {string}
37
+ */
38
+ function slugify(label) {
39
+ return (
40
+ String(label)
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9]+/g, '-')
43
+ .replace(/^-+|-+$/g, '') || 'result'
44
+ );
45
+ }
46
+
47
+ /**
48
+ * The full detail block, byte-compatible with the legacy dump the hot-path
49
+ * scripts used to write to stdout — same markers, same pretty JSON — so a log
50
+ * a human opens reads exactly as the old inline dump did.
51
+ *
52
+ * @param {string} label
53
+ * @param {unknown} result
54
+ * @returns {string}
55
+ */
56
+ function detailBlock(label, result) {
57
+ return `--- ${label} ---\n${JSON.stringify(result, null, 2)}\n--- END RESULT ---`;
58
+ }
59
+
60
+ /**
61
+ * Route a verbose result object off the agent's turn-resident stdout: write the
62
+ * full pretty detail to a temp log and emit a single-line structured summary in
63
+ * its place.
64
+ *
65
+ * @param {object} args
66
+ * @param {string} args.label Human label for the result (e.g. `STORY CLOSE RESULT`).
67
+ * @param {unknown} args.result The full result object; pretty-printed to the log.
68
+ * @param {Record<string, unknown>} [args.summary] The few fields the agent acts
69
+ * on; serialized compactly onto the single summary line.
70
+ * @param {string|number} [args.scope] Disambiguating suffix for the log name
71
+ * (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`.
74
+ * @param {typeof nodeFs} [args.fs] Filesystem seam (tests).
75
+ * @param {{ info: (m: string) => void }} [args.log] Logger seam (tests).
76
+ * @param {NodeJS.ProcessEnv} [args.env] Environment seam (tests).
77
+ * @returns {{ logPath: string|null, inline: boolean, error?: string }}
78
+ */
79
+ export function emitTerseResult({
80
+ label,
81
+ result,
82
+ summary = {},
83
+ scope,
84
+ logDir,
85
+ fs = nodeFs,
86
+ log = Logger,
87
+ env = process.env,
88
+ } = {}) {
89
+ const body = detailBlock(label, result);
90
+
91
+ // Escape hatch: restore the full inline pretty dump for interactive debugging.
92
+ if (String(env[RESULT_DETAIL_ENV] ?? '').toLowerCase() === 'inline') {
93
+ log.info?.(`\n${body}\n`);
94
+ return { logPath: null, inline: true };
95
+ }
96
+
97
+ const dir = logDir ?? path.join(process.cwd(), 'temp', 'orchestration');
98
+ const name = `${slugify(label)}${scope ? `-${scope}` : ''}.log`;
99
+
100
+ try {
101
+ fs.mkdirSync(dir, { recursive: true });
102
+ const logPath = path.join(dir, name);
103
+ fs.writeFileSync(logPath, `${body}\n`);
104
+ log.info?.(
105
+ `${label} · ${JSON.stringify(summary)} · full detail → ${logPath}`,
106
+ );
107
+ return { logPath, inline: false };
108
+ } catch (err) {
109
+ // Never lose detail: if the log write fails, fall back to the inline dump
110
+ // so the result is still recoverable from the transcript.
111
+ log.info?.(`\n${body}\n`);
112
+ return { logPath: null, inline: true, error: err?.message ?? String(err) };
113
+ }
114
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * lib/orchestration/complexity-gate.js — plan-time ceremony-lite routing gate.
3
+ *
4
+ * A **deterministic, conservative** complexity gate that routes a planning seed
5
+ * onto either the full two-session plan/deliver ceremony (`full`) or a collapsed
6
+ * ceremony-lite path (`lite`). It exists because the full ceremony imposes a
7
+ * large fixed cost premium on genuinely trivial single-artifact scopes with no
8
+ * measured quality gain (Story #4683): the bench cohort spent ~52 turns on a
9
+ * hello-world scope a bare control delivered in ~6, and no path existed to opt
10
+ * trivial scopes out.
11
+ *
12
+ * ## What "lite" changes and — critically — what it never changes
13
+ *
14
+ * The lite route collapses the **advisory ceremony** only: the plan/deliver
15
+ * session split, the fresh-context critic ceremony, and the Tech-Spec authoring
16
+ * that a one-artifact scope does not earn. It **never** relaxes a non-negotiable.
17
+ * {@link LITE_PATH_INVARIANTS} is the machine-readable contract that the lite
18
+ * path still produces a Story ticket, still lands via a PR to `main`, still runs
19
+ * every repo quality gate, and still honours `rules/security-baseline.md`. Those
20
+ * gates run in `single-story-close.js` regardless of route; the gate cannot and
21
+ * does not switch them off. Every `lite` decision carries this frozen object on
22
+ * its `preserves` field so a downstream reader can assert the invariants held.
23
+ *
24
+ * ## Conservative by construction — full on any doubt
25
+ *
26
+ * The gate is total and pure: seed text + resolved config in, decision out. It
27
+ * routes `lite` **only** when every trivial-scope signal agrees; every other
28
+ * case — an empty/unreadable seed, a seed above the word ceiling, a seed
29
+ * enumerating more than one candidate artifact, or the gate disabled by config —
30
+ * falls to `full`. Being wrong toward `full` costs a session; being wrong toward
31
+ * `lite` would skip ceremony a real capability slice needs, so the tie always
32
+ * breaks to `full`.
33
+ *
34
+ * ## Threshold + operator override
35
+ *
36
+ * {@link DEFAULT_COMPLEXITY_GATE} is the single source of truth for the
37
+ * threshold. Operators tune it (or disable the gate entirely) via
38
+ * `planning.complexityGate` in `.agentrc.json`:
39
+ *
40
+ * - `enabled` (default `true`) — `false` forces every seed to `full`.
41
+ * - `maxSeedWords` (default `150`) — seed prose word ceiling for `lite`.
42
+ * - `maxArtifacts` (default `1`) — enumerated-artifact ceiling for `lite`.
43
+ *
44
+ * Resolution clamps every field toward the conservative default: a malformed or
45
+ * negative ceiling falls back to the framework default rather than widening the
46
+ * lite path.
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
+ *
66
+ * @typedef {'lite'|'full'} ComplexityRoute
67
+ */
68
+
69
+ /**
70
+ * Framework defaults for the plan-time complexity gate. The threshold SSOT —
71
+ * the config schema mirror and the configuration reference both cite these
72
+ * numbers rather than restating divergent ones.
73
+ */
74
+ const DEFAULT_COMPLEXITY_GATE = Object.freeze({
75
+ enabled: true,
76
+ maxSeedWords: 150,
77
+ maxArtifacts: 1,
78
+ });
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
+
91
+ /**
92
+ * The non-negotiables the ceremony-lite path preserves. This is the
93
+ * contract behind Story #4683 AC-2: collapsing ceremony never means dropping
94
+ * the Story ticket, the PR-to-`main` landing, the repo quality gates, or the
95
+ * security baseline. Attached verbatim to every `lite` decision's `preserves`
96
+ * field; a downstream consumer (or contract test) asserts against it.
97
+ */
98
+ const LITE_PATH_INVARIANTS = Object.freeze({
99
+ storyTicket: true,
100
+ prToMain: true,
101
+ repoGates: true,
102
+ securityBaseline: true,
103
+ });
104
+
105
+ /**
106
+ * Coerce a candidate ceiling into a non-negative integer, falling back to the
107
+ * framework default for anything malformed. Non-numbers, non-finite values, and
108
+ * negatives all fall back — a stray `-1` or `NaN` must never widen the lite path
109
+ * (the gate fails conservative, toward `full`).
110
+ *
111
+ * @param {unknown} value
112
+ * @param {number} fallback
113
+ * @returns {number}
114
+ */
115
+ function normalizeCeiling(value, fallback) {
116
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
117
+ return fallback;
118
+ }
119
+ return Math.floor(value);
120
+ }
121
+
122
+ /**
123
+ * Resolve the effective complexity-gate config, shallow-overlaying an operator
124
+ * `planning.complexityGate` block onto {@link DEFAULT_COMPLEXITY_GATE}. Accepts
125
+ * the full resolved config, the bare `planning` bag, or the bare
126
+ * `complexityGate` bag, mirroring the tolerant unwrap the other routing
127
+ * accessors use. Module-private: exposed only through the resolved `threshold`
128
+ * on {@link buildComplexityRouteSignal}'s output, so there is no test-only
129
+ * export to leave production-dead.
130
+ *
131
+ * @param {object | null | undefined} config
132
+ * @returns {{ enabled: boolean, maxSeedWords: number, maxArtifacts: number }}
133
+ */
134
+ function resolveComplexityGate(config) {
135
+ const raw =
136
+ config?.planning?.complexityGate ?? config?.complexityGate ?? config ?? {};
137
+ const bag = raw && typeof raw === 'object' ? raw : {};
138
+ return {
139
+ enabled:
140
+ typeof bag.enabled === 'boolean'
141
+ ? bag.enabled
142
+ : DEFAULT_COMPLEXITY_GATE.enabled,
143
+ maxSeedWords: normalizeCeiling(
144
+ bag.maxSeedWords,
145
+ DEFAULT_COMPLEXITY_GATE.maxSeedWords,
146
+ ),
147
+ maxArtifacts: normalizeCeiling(
148
+ bag.maxArtifacts,
149
+ DEFAULT_COMPLEXITY_GATE.maxArtifacts,
150
+ ),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Count top-level enumerated items (`- `, `* `, `1. `) in a free-form seed —
156
+ * the same shape the scope-triage and delivery-shape signals read as candidate
157
+ * capabilities. Each enumerated line is one predicted artifact; a seed with two
158
+ * or more is a multi-capability scope that must take the full path.
159
+ *
160
+ * @param {string} text
161
+ * @returns {number}
162
+ */
163
+ function countSeedArtifacts(text) {
164
+ if (typeof text !== 'string' || text.length === 0) return 0;
165
+ return text
166
+ .split(/\r?\n/)
167
+ .filter((line) => /^\s*(?:[-*]|\d+\.)\s+\S/.test(line)).length;
168
+ }
169
+
170
+ /**
171
+ * Build the advisory complexity-route signal for a planning seed. Deterministic,
172
+ * total, and conservative (see the module header): every trivial-scope signal
173
+ * must agree for a `lite` decision; everything else routes `full`.
174
+ *
175
+ * The result is folded into the `/plan` context envelope as `complexityRoute`,
176
+ * so the workflow reads one field instead of re-deriving the decision. Every
177
+ * `lite` decision carries {@link LITE_PATH_INVARIANTS} on `preserves`.
178
+ *
179
+ * @param {{ seedText?: string, config?: object }} [args]
180
+ * @returns {{
181
+ * route: ComplexityRoute,
182
+ * reasons: string[],
183
+ * threshold: { enabled: boolean, maxSeedWords: number, maxArtifacts: number },
184
+ * preserves: typeof LITE_PATH_INVARIANTS,
185
+ * advisory: true,
186
+ * }}
187
+ */
188
+ export function buildComplexityRouteSignal({ seedText = '', config } = {}) {
189
+ const threshold = resolveComplexityGate(config);
190
+ const advisory = /** @type {const} */ (true);
191
+ const preserves = LITE_PATH_INVARIANTS;
192
+ const decide = (route, reason) => ({
193
+ route,
194
+ reasons: [reason],
195
+ threshold,
196
+ preserves,
197
+ advisory,
198
+ });
199
+
200
+ if (!threshold.enabled) {
201
+ return decide(
202
+ 'full',
203
+ 'complexity gate disabled (planning.complexityGate.enabled=false) — full plan/deliver ceremony',
204
+ );
205
+ }
206
+
207
+ const text = typeof seedText === 'string' ? seedText : '';
208
+ const trimmed = text.trim();
209
+ if (trimmed.length === 0) {
210
+ return decide(
211
+ 'full',
212
+ 'empty seed — triviality cannot be judged; conservative full path',
213
+ );
214
+ }
215
+
216
+ const artifactCount = countSeedArtifacts(text);
217
+ if (artifactCount > threshold.maxArtifacts) {
218
+ return decide(
219
+ 'full',
220
+ `seed enumerates ${artifactCount} candidate artifacts (> maxArtifacts ${threshold.maxArtifacts}) — multi-capability scope takes the full path`,
221
+ );
222
+ }
223
+
224
+ const wordCount = trimmed.split(/\s+/).filter(Boolean).length;
225
+ if (wordCount > threshold.maxSeedWords) {
226
+ return decide(
227
+ 'full',
228
+ `seed is ${wordCount} words (> maxSeedWords ${threshold.maxSeedWords}) — not a trivial scope; full path`,
229
+ );
230
+ }
231
+
232
+ return decide(
233
+ 'lite',
234
+ `trivial single-artifact scope (${wordCount} words ≤ ${threshold.maxSeedWords}, ${artifactCount} enumerated artifact(s) ≤ ${threshold.maxArtifacts}) — collapsed ceremony-lite path; non-negotiables preserved`,
235
+ );
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
+ }