mandrel 2.10.0 → 2.12.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 (42) hide show
  1. package/.agents/docs/configuration.md +35 -33
  2. package/.agents/rules/orchestration-error-handling.md +9 -1
  3. package/.agents/schemas/agentrc.schema.json +13 -8
  4. package/.agents/scripts/acceptance-eval.js +9 -5
  5. package/.agents/scripts/lib/audit-suite/audit-rules-reader.js +48 -0
  6. package/.agents/scripts/lib/audit-suite/selector.js +1 -26
  7. package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
  8. package/.agents/scripts/lib/baselines/git-base.js +0 -0
  9. package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
  10. package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
  11. package/.agents/scripts/lib/config/quality.js +13 -0
  12. package/.agents/scripts/lib/config-settings-schema.js +12 -16
  13. package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
  14. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
  15. package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
  16. package/.agents/scripts/lib/orchestration/complexity-gate.js +561 -184
  17. package/.agents/scripts/lib/orchestration/plan-context.js +69 -10
  18. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +117 -60
  19. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +21 -15
  20. package/.agents/scripts/lib/orchestration/resolve-stories.js +28 -7
  21. package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
  22. package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +186 -0
  23. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +21 -3
  24. package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
  25. package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
  26. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
  27. package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
  28. package/.agents/scripts/plan-context.js +23 -5
  29. package/.agents/scripts/resolve-stories.js +2 -0
  30. package/.agents/workflows/deliver.md +28 -28
  31. package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
  32. package/.agents/workflows/helpers/deliver-digest.md +126 -0
  33. package/.agents/workflows/helpers/deliver-reference.md +30 -5
  34. package/.agents/workflows/helpers/deliver-story-reference.md +38 -12
  35. package/.agents/workflows/helpers/deliver-story.md +34 -37
  36. package/.agents/workflows/helpers/plan-reference.md +79 -44
  37. package/.agents/workflows/plan.md +11 -10
  38. package/docs/CHANGELOG.md +31 -0
  39. package/lib/cli/registry.js +31 -14
  40. package/lib/migrations/index.js +2 -0
  41. package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
  42. package/package.json +1 -1
@@ -102,10 +102,9 @@ top-level keys are validation errors.
102
102
  | `codebaseSnapshot.include` | No | `array<string>` | — | — |
103
103
  | `codebaseSnapshot.exclude` | No | `array<string>` | — | — |
104
104
  | `codebaseSnapshot.recentCommitWindow` | No | `integer` | — | — |
105
- | `complexityGate` | No | `object` | — | Plan-time ceremony-lite complexity gate. Routes trivial single-artifact seeds onto a collapsed plan/deliver path; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables. |
106
- | `complexityGate.enabled` | No | `boolean` | — | Master switch. When false, every seed takes the full plan/deliver ceremony. Default true. |
107
- | `complexityGate.maxSeedWords` | No | `integer` | — | Seed prose word ceiling for the lite path. A seed above this many words is not trivial and takes the full path. Default 150. |
108
- | `complexityGate.maxArtifacts` | No | `integer` | — | Enumerated-artifact ceiling for the lite path. A seed enumerating more than this many candidate artifacts is multi-capability and takes the full path. Default 1. |
105
+ | `complexityGate` | No | `object` | — | Shape-derived ceremony-lite complexity routing. A lite claim is validated against the authored Story shape at persist and re-derived from the Story body at dispatch; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables. |
106
+ | `complexityGate.enabled` | No | `boolean` | — | Master switch. When false, lite routing is disabled everywhere: persist refuses lite claims and dispatch always takes the sub-agent path. Default true. |
107
+ | `complexityGate.maxArtifacts` | No | `integer` | — | Enumerated-artifact threshold reported by the plan-context complexity signals. An input signal for the planner verdict carries no routing authority. Default 1. |
109
108
  | `failOnSharedEditors` | No | `boolean` | — | When true, upgrade shared-editor conflict findings to hard errors (default false — advisory soft findings only). |
110
109
  | `requireExplicitCrossStoryDeps` | No | `boolean` | — | When true, upgrade implicit cross-Story dependency findings to hard errors (default false — advisory soft findings only). |
111
110
  | `failOnRegistryConflicts` | No | `boolean` | — | When true, upgrade cross-cutting registry conflict findings to hard errors (default false). |
@@ -189,6 +188,7 @@ top-level keys are validation errors.
189
188
  | `quality.gates.maintainability.floors` | No | `object<map>` | — | — |
190
189
  | `quality.gates.maintainability.components` | No | `object<map>` | — | — |
191
190
  | `quality.gates.maintainability.targetDirs` | No | `string[]` or `{ append?, prepend? }` | — | Directories whose JS sources the maintainability gate scores. Mandrel ships a `src/`-centric default; projects whose executable code lives elsewhere (e.g. this repo's `.agents/scripts/` plus `tests/`) override here. The framework default is intentionally not auto-discovered, so an override is the explicit, auditable signal. |
191
+ | `quality.gates.maintainability.refreshTag` | No | `string` | — | — |
192
192
  | `quality.gates.maintainability.refreshTimeoutMs` | No | `integer` | — | Bounded timeout (ms) for `npm run maintainability:update` spawned by the baseline-attribution refresh path. Mirrors `coverage.timeoutMs`: a SIGKILL fired at the budget boundary maps to exit 124 so the close orchestrator can flip the Story to `agent::blocked`. Default 60000 (Story #2165). |
193
193
  | `quality.gates.maintainability.ignoreGlobs` | No | `array<string>` | — | Minimatch glob patterns matched against the canonicalised repo-relative path of each discovered file. Files matching any pattern are excluded from MI discovery before scoring. Orthogonal to `components` (grouping) — a file excluded here never appears in any component bucket. Absent or empty preserves the existing IGNORED_DIRS-only behaviour (Story #3217). |
194
194
  | `quality.gates.mutation` | No | `object` | — | Nested configuration block. |
@@ -351,36 +351,38 @@ scans `.agents/scripts/**`, `src/**`, `lib/**`, `app/**`, `packages/**` and
351
351
  `exclude` drops `node_modules`, build dirs, and test files. Override `include`
352
352
  only when the project's source layout differs.
353
353
 
354
- - **`complexityGate`.** Plan-time ceremony-lite routing (Story #4683). The full
355
- two-session plan/deliver ceremony buys measurable quality on capability-sized
356
- work but imposes a large fixed cost premium on genuinely trivial
357
- single-artifact scopes with no measured quality gain. The gate reads the
358
- planning seed and emits a `complexityRoute` signal on the `/plan` context
359
- envelope: `lite` collapses the plan/deliver session split and skips the
360
- fresh-critic / Tech-Spec ceremony a one-artifact scope does not earn; `full`
361
- keeps the whole ceremony. It is **deterministic and conservative** `lite`
362
- only when every trivial-scope signal agrees (seed ≤ `maxSeedWords` words **and**
363
- `maxArtifacts` enumerated items), and `full` on any doubt (empty seed, over
364
- the ceiling, multi-capability enumeration, or the gate disabled). The lite path
365
- **never** relaxes a non-negotiable: it still produces a Story ticket, still
366
- lands via a PR to `main`, still runs every repo quality gate, and still honours
367
- `rules/security-baseline.md` those gates run in `single-story-close.js`
368
- regardless of route. **Threshold + override:** `enabled` (default `true`;
369
- `false` forces every seed to `full`), `maxSeedWords` (default `150` —
370
- raised from 60 by Story #4707: seed word count is a poor complexity proxy,
371
- and the old ceiling punished a well-written 70-word trivial seed with the
372
- full two-session ceremony), and
373
- `maxArtifacts` (default `1`). The defaults are the single source of truth on
374
- `DEFAULT_COMPLEXITY_GATE` in
354
+ - **`complexityGate`.** Shape-derived ceremony-lite routing (Story #4722,
355
+ superseding the word-count gate of Stories #4683/#4707). The full ceremony
356
+ buys measurable quality on capability-sized work but imposes a large fixed
357
+ cost premium on genuinely trivial scopes and seed word count is the wrong
358
+ proxy in both directions (a detailed prompt can describe trivial work, a
359
+ terse one complex work), so `maxSeedWords` was **removed** in the hard
360
+ cutover (a config still setting it is rejected as an additional property).
361
+ Routing is now staged on the objective shape of the work: `/plan`'s context
362
+ envelope emits advisory `complexitySignals` (enumerated-artifact count,
363
+ risk-heuristic hits, repo state of predicted paths, sensitive-path classes)
364
+ with **no routing authority**; the planner authors the trivial-vs-standard
365
+ verdict via `plan-persist.js --route-downgrade-reason "<why>"` (recorded on
366
+ every created Story's `story-plan-state` checkpoint); persist validates a
367
+ lite claim against each authored Story's own shape (`changes[]` count,
368
+ acceptance count, creates-vs-refactors mix, sensitive-path classes the
369
+ framework constants `STORY_SHAPE_CEILINGS`) and **fails closed to `full`**
370
+ when the shape exceeds the ceilings; and `/deliver` re-derives the route
371
+ from the fetched Story body via the same shape function at dispatch. The
372
+ `route::lite` label is a human-visible hint only — a lost label cannot
373
+ misroute delivery. A lite-shaped Story executes inline (no story-worker or
374
+ acceptance-critic sub-agent fan-out); sensitivity always wins — a footprint
375
+ intersecting a sensitive-path class routes `full` and keeps its fresh
376
+ critic. The lite path **never** relaxes a non-negotiable: it still produces
377
+ a Story ticket, still lands via a PR to `main`, still runs every repo
378
+ quality gate, and still honours `rules/security-baseline.md` — those gates
379
+ run in `single-story-close.js` regardless of route. **Knobs:** `enabled`
380
+ (default `true`; `false` disables lite routing everywhere) and
381
+ `maxArtifacts` (default `1` — a signal threshold, not a router). Defaults
382
+ live on `DEFAULT_COMPLEXITY_GATE` in
375
383
  [`lib/orchestration/complexity-gate.js`](../scripts/lib/orchestration/complexity-gate.js);
376
- a malformed or negative ceiling falls back to the default rather than widening
377
- the lite path. **Planner downgrade + route marker (Story #4707):** the
378
- planner may downgrade a `full` verdict to `lite` only via
379
- `plan-persist.js --route-downgrade-reason "<why>"` — the reason is recorded
380
- on every created Story's `story-plan-state` checkpoint, so the judgment is
381
- auditable and never a silent gate change. A lite-routed plan's Stories carry
382
- the runtime-derived `route::lite` label, which `/deliver` reads to execute
383
- the Story inline (no sub-agent fan-out) with every close gate unchanged.
384
+ a malformed or negative value falls back to the default rather than
385
+ widening the lite path.
384
386
 
385
387
  ### `delivery`
386
388
 
@@ -46,8 +46,16 @@ success output is a per-turn tax on the whole session — not a one-time cost.
46
46
  their drivers — emit them single-line (`JSON.stringify(x)`), never
47
47
  pretty-printed (`null, 2` only adds resident bytes). Pretty output is
48
48
  reserved for explicit opt-in flags (`--pretty`).
49
+ - **Streamed child output counts too (Story #4736).** A script that pipes a
50
+ child process's stdout/stderr through to the caller is emitting that output
51
+ as its own. `single-story-close.js` streamed every close-validation gate —
52
+ the whole of `npm test` included — and blew the budget by ~25× on a *passing*
53
+ close. Capture it to an artifact instead
54
+ ([`single-story-close/gate-log.js`](../scripts/lib/orchestration/single-story-close/gate-log.js)),
55
+ emit the digest, and **replay the tail inline on failure** — the bound is a
56
+ success-path bound, and a red gate's evidence belongs in front of the caller.
49
57
  - **Escape hatch.** `MANDREL_RESULT_DETAIL=inline` restores inline full
50
58
  detail for interactive debugging; scripts using `emitTerseResult` honor it
51
- automatically.
59
+ automatically. `AGENT_LOG_LEVEL=verbose` restores live gate streaming.
52
60
  - **stdout purity is unchanged.** Scripts whose stdout is a machine contract
53
61
  (Story #2278) keep logs on stderr; the digest is the *only* stdout line.
@@ -329,21 +329,16 @@
329
329
  },
330
330
  "complexityGate": {
331
331
  "type": "object",
332
- "description": "Plan-time ceremony-lite complexity gate. Routes trivial single-artifact seeds onto a collapsed plan/deliver path; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables.",
332
+ "description": "Shape-derived ceremony-lite complexity routing. A lite claim is validated against the authored Story shape at persist and re-derived from the Story body at dispatch; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables.",
333
333
  "properties": {
334
334
  "enabled": {
335
335
  "type": "boolean",
336
- "description": "Master switch. When false, every seed takes the full plan/deliver ceremony. Default true."
337
- },
338
- "maxSeedWords": {
339
- "type": "integer",
340
- "minimum": 0,
341
- "description": "Seed prose word ceiling for the lite path. A seed above this many words is not trivial and takes the full path. Default 150."
336
+ "description": "Master switch. When false, lite routing is disabled everywhere: persist refuses lite claims and dispatch always takes the sub-agent path. Default true."
342
337
  },
343
338
  "maxArtifacts": {
344
339
  "type": "integer",
345
340
  "minimum": 0,
346
- "description": "Enumerated-artifact ceiling for the lite path. A seed enumerating more than this many candidate artifacts is multi-capability and takes the full path. Default 1."
341
+ "description": "Enumerated-artifact threshold reported by the plan-context complexity signals. An input signal for the planner verdict carries no routing authority. Default 1."
347
342
  }
348
343
  },
349
344
  "additionalProperties": false
@@ -844,6 +839,16 @@
844
839
  "$ref": "#/$defs/listOrExtenderOfStrings",
845
840
  "description": "Directories whose JS sources the maintainability gate scores. Mandrel ships a `src/`-centric default; projects whose executable code lives elsewhere (e.g. this repo's `.agents/scripts/` plus `tests/`) override here. The framework default is intentionally not auto-discovered, so an override is the explicit, auditable signal."
846
841
  },
842
+ "refreshTag": {
843
+ "allOf": [
844
+ {
845
+ "$ref": "#/$defs/safeString"
846
+ },
847
+ {
848
+ "minLength": 1
849
+ }
850
+ ]
851
+ },
847
852
  "refreshTimeoutMs": {
848
853
  "type": "integer",
849
854
  "minimum": 1,
@@ -3,12 +3,16 @@
3
3
  /**
4
4
  * acceptance-eval.js — bounded per-Story acceptance self-eval gate (Story #3819).
5
5
  *
6
- * The Story-implementation phase runs an independent (fresh-context)
7
- * critic pass that scores the caller-injected change set against each
8
- * inline `acceptance[]` item, emitting one verdict file per round
6
+ * The Story-implementation phase runs ONE verdict-owner per acceptance
7
+ * cluster (Story #4723) the fresh-context critic when
8
+ * `ceremony-routing.js` sensitivity-routes the cluster fresh, the
9
+ * contract-identical inline self-eval otherwise — which scores the
10
+ * caller-injected change set against each inline `acceptance[]` item and
11
+ * emits one verdict file per round
9
12
  * (`.agents/schemas/acceptance-eval-verdict.schema.json`). This CLI is the
10
- * deterministic substrate that turns that verdict into the loop's next
11
- * action:
13
+ * deterministic SCORER of that single authored verdict it validates and
14
+ * decides, it never re-scores the criteria as an independent additional
15
+ * pass — turning the verdict into the loop's next action:
12
16
  *
13
17
  * 1. Validate the verdict file against the verdict JSON Schema (a
14
18
  * malformed verdict is a hard error — the loop refuses to guess).
@@ -0,0 +1,48 @@
1
+ /**
2
+ * lib/audit-suite/audit-rules-reader.js — the one synchronous reader of the
3
+ * `audit-rules.json` manifest, memoized for the process lifetime.
4
+ *
5
+ * The manifest is shipped framework configuration resolved from one fixed
6
+ * path per process, and the shape-derivation path reads it once per Story at
7
+ * resolve AND persist — an un-memoized read is pure repeated I/O (measured
8
+ * 221 µs/op raw vs 5.5 µs seamed on the run adhoc-4722-4723 audit). Only a
9
+ * successful parse is cached: a read failure stays a per-call throw so a
10
+ * caller can observe a manifest that becomes readable later. Tests never
11
+ * reach this read — they inject fixture rules through the callers'
12
+ * `injectedRules` seam.
13
+ */
14
+
15
+ import { readFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
18
+
19
+ /** Process-lifetime memo of the parsed manifest (successful parses only). */
20
+ let auditRulesCache = null;
21
+
22
+ /**
23
+ * Read and parse the `audit-rules.json` manifest synchronously from the
24
+ * project's configured `schemasRoot`. Shared by the synchronous, ticket-free
25
+ * readers (`resolveLensTier`, `selectLocalLenses`,
26
+ * `selectSensitivePathClasses`) so the path resolution and read-failure
27
+ * handling live in one place rather than being duplicated per reader.
28
+ *
29
+ * @returns {{ audits?: Record<string, object> }} Parsed manifest.
30
+ * @throws {Error} When the manifest cannot be read or parsed.
31
+ */
32
+ export function readAuditRulesSync() {
33
+ if (auditRulesCache !== null) return auditRulesCache;
34
+ const config = resolveConfig();
35
+ const rulesPath = path.join(
36
+ PROJECT_ROOT,
37
+ getPaths(config).schemasRoot,
38
+ 'audit-rules.json',
39
+ );
40
+ try {
41
+ auditRulesCache = JSON.parse(readFileSync(rulesPath, 'utf8'));
42
+ return auditRulesCache;
43
+ } catch (err) {
44
+ throw new Error(
45
+ `audit-suite: failed to read audit-rules from ${rulesPath}: ${err.message}`,
46
+ );
47
+ }
48
+ }
@@ -26,6 +26,7 @@ import { getPaths, PROJECT_ROOT, resolveConfig } from '../config-resolver.js';
26
26
  import { softFailOrThrow } from '../degraded-mode.js';
27
27
  import { gitSpawn } from '../git-utils.js';
28
28
  import { withTimeout } from '../util/with-timeout.js';
29
+ import { readAuditRulesSync } from './audit-rules-reader.js';
29
30
 
30
31
  const DEFAULT_GIT_TIMEOUT_MS = 30000;
31
32
 
@@ -101,32 +102,6 @@ export const LENS_TIERS = Object.freeze(['local', 'cumulative', 'global']);
101
102
  * manifest cannot be read, or the registered entry carries a scope outside
102
103
  * {@link LENS_TIERS}.
103
104
  */
104
- /**
105
- * Read and parse the `audit-rules.json` manifest synchronously from the
106
- * project's configured `schemasRoot`. Shared by the synchronous, ticket-free
107
- * readers ({@link resolveLensTier}, {@link selectLocalLenses}) so the path
108
- * resolution and read-failure handling live in one place rather than being
109
- * duplicated per reader.
110
- *
111
- * @returns {{ audits?: Record<string, object> }} Parsed manifest.
112
- * @throws {Error} When the manifest cannot be read or parsed.
113
- */
114
- function readAuditRulesSync() {
115
- const config = resolveConfig();
116
- const rulesPath = path.join(
117
- PROJECT_ROOT,
118
- getPaths(config).schemasRoot,
119
- 'audit-rules.json',
120
- );
121
- try {
122
- return JSON.parse(readFileSync(rulesPath, 'utf8'));
123
- } catch (err) {
124
- throw new Error(
125
- `audit-suite: failed to read audit-rules from ${rulesPath}: ${err.message}`,
126
- );
127
- }
128
- }
129
-
130
105
  export function resolveLensTier(lens) {
131
106
  const rulesData = readAuditRulesSync();
132
107
 
@@ -117,6 +117,39 @@ export function resolveBundleSizeEnvOverrides(env) {
117
117
  return { acknowledged, overrides };
118
118
  }
119
119
 
120
+ /**
121
+ * Pure helper: resolve the one-shot maintainability refresh/acknowledge flag
122
+ * (Story #4731). This is the env-parity sibling of
123
+ * `resolveBundleSizeEnvOverrides`: `MAINTAINABILITY_REFRESH=1` (or `true`,
124
+ * case-insensitive) tells `check-baselines --gate maintainability` to demote
125
+ * this run's head-vs-base maintainability regressions to `unchanged` for this
126
+ * invocation only. Floors still apply — an acknowledged run can still fail on
127
+ * an absolute floor breach (e.g. a row below `min` 70); only the
128
+ * ratchet-vs-base regression comparison is suspended.
129
+ *
130
+ * Unlike bundle-size, maintainability also has a **commit-tagged** trigger
131
+ * (a `baseline-refresh:`-tagged commit in the compared range that touches the
132
+ * maintainability baseline file) resolved in the evaluate phase — this env
133
+ * flag is the manual override the two share by shape. Neither is persisted:
134
+ * the next run without the flag / tag re-enforces the ratchet at full
135
+ * strength automatically.
136
+ *
137
+ * Accepted truthy values: `1`, `true` (case-insensitive). Anything else
138
+ * (including unset/empty) resolves to `acknowledged: false`.
139
+ *
140
+ * @param {NodeJS.ProcessEnv} env
141
+ * @returns {{ acknowledged: boolean, overrides: string[] }}
142
+ */
143
+ export function resolveMaintainabilityRefreshOverrides(env) {
144
+ const raw = env?.MAINTAINABILITY_REFRESH;
145
+ const acknowledged =
146
+ typeof raw === 'string' && /^(1|true)$/i.test(raw.trim());
147
+ const overrides = acknowledged
148
+ ? [`acknowledged=true (MAINTAINABILITY_REFRESH=${raw})`]
149
+ : [];
150
+ return { acknowledged, overrides };
151
+ }
152
+
120
153
  /**
121
154
  * Pure helper: resolve the effective MI tolerance by layering precedence:
122
155
  * 1. `CRAP_TOLERANCE` env-var (CI override — the baseline-refresh-
@@ -263,6 +263,11 @@ export async function runCrapPreview({
263
263
  const requireCoverage = crap.requireCoverage !== false;
264
264
  const coveragePath = crap.coveragePath ?? 'coverage/coverage-final.json';
265
265
  const coverage = loadCoverage(path.resolve(cwd, coveragePath));
266
+ // Story #4731 (AC-3) — feed the CRAP regression compare the *configured*
267
+ // crap tolerance (env override → `gates.crap.tolerance` → framework default)
268
+ // so `compareCrap` demotes positive deltas at or under tolerance rather than
269
+ // failing on any positive delta; over-tolerance deltas still fail. This keeps
270
+ // the pre-commit/pre-push preview aligned with the authoritative gate.
266
271
  const { newMethodCeiling, tolerance } = resolveCrapEnvOverrides(
267
272
  crap,
268
273
  process.env,
@@ -1,12 +1,21 @@
1
1
  /* node:coverage ignore file -- AJV schema declaration (data-as-code) */
2
2
 
3
- import { GATE_BASE, LIST_OR_EXTENDER_OF_STRINGS } from './shared.js';
3
+ import {
4
+ GATE_BASE,
5
+ LIST_OR_EXTENDER_OF_STRINGS,
6
+ SAFE_STRING,
7
+ } from './shared.js';
4
8
 
5
9
  export const MAINTAINABILITY_GATE = {
6
10
  type: 'object',
7
11
  properties: {
8
12
  ...GATE_BASE,
9
13
  targetDirs: LIST_OR_EXTENDER_OF_STRINGS,
14
+ // Story #4731 — commit-subject substring that acknowledges a deliberate
15
+ // maintainability baseline refresh in the compared range. Mirrors the CRAP
16
+ // gate's `refreshTag`; a range commit carrying it that touches the baseline
17
+ // file demotes head-vs-base regressions (floors still enforced).
18
+ refreshTag: { ...SAFE_STRING, minLength: 1 },
10
19
  // Story #2165 — bounded timeout for `npm run maintainability:update`
11
20
  // spawned by the baseline-attribution refresh path. Mirrors
12
21
  // `coverage.timeoutMs` (Story #2142).
@@ -110,6 +110,13 @@ export const MAINTAINABILITY_GATE_DEFAULTS = Object.freeze({
110
110
  tolerance: DEFAULT_MI_TOLERANCE,
111
111
  floors: DEFAULT_MI_FLOORS,
112
112
  targetDirs: Object.freeze([]),
113
+ // Story #4731 — the commit-subject substring that acknowledges a deliberate
114
+ // maintainability baseline refresh in the compared range. Mirrors the CRAP
115
+ // gate's `refreshTag`; the evaluate phase demotes head-vs-base regressions
116
+ // (floors still enforced) when a range commit carrying this tag touches the
117
+ // baseline file. Kept identical to the CRAP default so one refresh commit
118
+ // can acknowledge both gates.
119
+ refreshTag: 'baseline-refresh:',
113
120
  // Story #2165 — bounded timeout (ms) for `npm run maintainability:update`
114
121
  // spawned by the baseline-attribution refresh path. Defaults to 60 s.
115
122
  refreshTimeoutMs: 60_000,
@@ -154,6 +161,7 @@ const MI_GATE_KEYS = new Set([
154
161
  'tolerance',
155
162
  'floors',
156
163
  'targetDirs',
164
+ 'refreshTag',
157
165
  'refreshTimeoutMs',
158
166
  'ignoreGlobs',
159
167
  ]);
@@ -268,6 +276,7 @@ function resolveMaintainabilityQuality(userBlock, gateScoping) {
268
276
  if (userBlock == null || typeof userBlock !== 'object') {
269
277
  return {
270
278
  targetDirs: [...defaults.targetDirs],
279
+ refreshTag: defaults.refreshTag,
271
280
  refreshTimeoutMs: defaults.refreshTimeoutMs,
272
281
  ignoreGlobs: [...defaults.ignoreGlobs],
273
282
  defaultScope: scoping.defaultScope,
@@ -277,6 +286,10 @@ function resolveMaintainabilityQuality(userBlock, gateScoping) {
277
286
  warnUnknownKeys(userBlock, MI_GATE_KEYS, 'quality.gates.maintainability');
278
287
  const out = {
279
288
  targetDirs: resolveListValue(defaults.targetDirs, userBlock.targetDirs),
289
+ refreshTag:
290
+ typeof userBlock.refreshTag === 'string' && userBlock.refreshTag.length
291
+ ? userBlock.refreshTag
292
+ : defaults.refreshTag,
280
293
  refreshTimeoutMs: resolvePositiveIntegerMs(
281
294
  userBlock.refreshTimeoutMs,
282
295
  defaults.refreshTimeoutMs,
@@ -278,34 +278,30 @@ const PLANNING_SCHEMA = {
278
278
  properties: {
279
279
  riskHeuristics: LIST_OR_EXTENDER_OF_STRINGS,
280
280
  codebaseSnapshot: CODEBASE_SNAPSHOT_SCHEMA,
281
- // Story #4683 plan-time ceremony-lite complexity gate. Routes a trivial
282
- // single-artifact seed onto a collapsed plan/deliver path while a
283
- // multi-capability seed keeps the full ceremony. Deterministic and
284
- // conservative (full on any doubt); the lite path never relaxes a
285
- // non-negotiable (Story ticket, PR-to-main, repo gates, security baseline).
286
- // Defaults live on DEFAULT_COMPLEXITY_GATE in
287
- // `lib/orchestration/complexity-gate.js`.
281
+ // Story #4722 (superseding #4683's word-count gate) shape-derived
282
+ // ceremony-lite routing. Complexity routes on the objective shape of the
283
+ // authored work (changes[] count, acceptance count, creates-vs-refactors
284
+ // mix, sensitive-path classes), never on seed word count: `maxSeedWords`
285
+ // was removed in the hard cutover and is rejected as an additional
286
+ // property. The lite path never relaxes a non-negotiable (Story ticket,
287
+ // PR-to-main, repo gates, security baseline). Defaults live on
288
+ // DEFAULT_COMPLEXITY_GATE in `lib/orchestration/complexity-gate.js`;
289
+ // shape ceilings are the framework constants STORY_SHAPE_CEILINGS.
288
290
  complexityGate: {
289
291
  type: 'object',
290
292
  description:
291
- 'Plan-time ceremony-lite complexity gate. Routes trivial single-artifact seeds onto a collapsed plan/deliver path; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables.',
293
+ 'Shape-derived ceremony-lite complexity routing. A lite claim is validated against the authored Story shape at persist and re-derived from the Story body at dispatch; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables.',
292
294
  properties: {
293
295
  enabled: {
294
296
  type: 'boolean',
295
297
  description:
296
- 'Master switch. When false, every seed takes the full plan/deliver ceremony. Default true.',
297
- },
298
- maxSeedWords: {
299
- type: 'integer',
300
- minimum: 0,
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 150.',
298
+ 'Master switch. When false, lite routing is disabled everywhere: persist refuses lite claims and dispatch always takes the sub-agent path. Default true.',
303
299
  },
304
300
  maxArtifacts: {
305
301
  type: 'integer',
306
302
  minimum: 0,
307
303
  description:
308
- 'Enumerated-artifact ceiling for the lite path. A seed enumerating more than this many candidate artifacts is multi-capability and takes the full path. Default 1.',
304
+ 'Enumerated-artifact threshold reported by the plan-context complexity signals. An input signal for the planner verdict carries no routing authority. Default 1.',
309
305
  },
310
306
  },
311
307
  additionalProperties: false,
@@ -35,6 +35,19 @@
35
35
  * sub-agent. This module takes the cluster index as an INPUT and returns a
36
36
  * decision for that one cluster; it has no way to add or remove clusters.
37
37
  *
38
+ * ## One verdict-owner per cluster (Story #4723)
39
+ *
40
+ * The resolved decision names the cluster's **single verdict owner** via
41
+ * `verdictOwner`: `'fresh-critic'` when the mode is `fresh`,
42
+ * `'inline-self-eval'` when the mode is `inline`. Exactly one pass authors
43
+ * the cluster's verdict — the fresh maker-blind critic OR the
44
+ * contract-identical inline self-eval, never both, and never an additional
45
+ * pre-pass self-assessment before the owner runs. `acceptance-eval.js` is
46
+ * the deterministic SCORER of that one authored verdict (schema validation,
47
+ * round cap, proceed/redraft/block) — it is not a third pass over the
48
+ * criteria. This removes a redundant pass only; it never removes a
49
+ * cluster's verdict (the M4-B floor above holds).
50
+ *
38
51
  * ## Tier rules (per cluster, `standard` profile)
39
52
  *
40
53
  * - `high` level → `fresh` (a sensitive path was touched — a
@@ -63,10 +76,23 @@
63
76
  * `undefined` / malformed inputs degrade to `fresh` + `full` ceremony.
64
77
  *
65
78
  * @typedef {'fresh'|'inline'} CeremonyMode
79
+ * @typedef {'fresh-critic'|'inline-self-eval'} VerdictOwner
66
80
  * @typedef {import('./review-depth.js').ChangeLevel} ChangeLevel
67
81
  * @typedef {'minimal'|'standard'|'strict'} CeremonyProfile
68
82
  */
69
83
 
84
+ /**
85
+ * Map a resolved ceremony mode to the cluster's single verdict owner
86
+ * (Story #4723). Total: any non-`fresh` value maps to the inline
87
+ * self-eval owner, mirroring how the mode itself degrades.
88
+ *
89
+ * @param {CeremonyMode} mode
90
+ * @returns {VerdictOwner}
91
+ */
92
+ export function verdictOwnerForMode(mode) {
93
+ return mode === 'fresh' ? 'fresh-critic' : 'inline-self-eval';
94
+ }
95
+
70
96
  /** @type {readonly CeremonyProfile[]} */
71
97
  export const CEREMONY_PROFILES = Object.freeze([
72
98
  'minimal',
@@ -133,9 +159,28 @@ export function sampledFresh(clusterIndex, rate) {
133
159
  * reason: string,
134
160
  * sampled: boolean,
135
161
  * profile: CeremonyProfile,
162
+ * verdictOwner: VerdictOwner,
136
163
  * }}
137
164
  */
138
165
  export function resolveCeremonyForRisk(input = {}) {
166
+ const decision = resolveCeremonyDecision(input);
167
+ return { ...decision, verdictOwner: verdictOwnerForMode(decision.mode) };
168
+ }
169
+
170
+ /**
171
+ * Internal mode/reason resolution — the tier rules and sampling floor.
172
+ * `resolveCeremonyForRisk` decorates the result with the single
173
+ * `verdictOwner` derived from the mode (Story #4723).
174
+ *
175
+ * @param {Parameters<typeof resolveCeremonyForRisk>[0]} [input]
176
+ * @returns {{
177
+ * mode: CeremonyMode,
178
+ * reason: string,
179
+ * sampled: boolean,
180
+ * profile: CeremonyProfile,
181
+ * }}
182
+ */
183
+ function resolveCeremonyDecision(input = {}) {
139
184
  const derivedLevel =
140
185
  input && typeof input === 'object' ? input.derivedLevel : undefined;
141
186
  const clusterIndex =