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.
- package/.agents/docs/configuration.md +35 -33
- package/.agents/rules/orchestration-error-handling.md +9 -1
- package/.agents/schemas/agentrc.schema.json +13 -8
- package/.agents/scripts/acceptance-eval.js +9 -5
- package/.agents/scripts/lib/audit-suite/audit-rules-reader.js +48 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -26
- package/.agents/scripts/lib/baselines/env-overrides.js +33 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/preview-gates.js +5 -0
- package/.agents/scripts/lib/config/gates/maintainability.schema.js +10 -1
- package/.agents/scripts/lib/config/quality.js +13 -0
- package/.agents/scripts/lib/config-settings-schema.js +12 -16
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +45 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +97 -4
- package/.agents/scripts/lib/orchestration/check-baselines/phases/parse-args.js +7 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +561 -184
- package/.agents/scripts/lib/orchestration/plan-context.js +69 -10
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +117 -60
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +21 -15
- package/.agents/scripts/lib/orchestration/resolve-stories.js +28 -7
- package/.agents/scripts/lib/orchestration/review-depth.js +9 -4
- package/.agents/scripts/lib/orchestration/single-story-close/gate-log.js +186 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +21 -3
- package/.agents/scripts/lib/orchestration/spec-budget.js +78 -0
- package/.agents/scripts/lib/orchestration/story-body-gate.js +72 -0
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +6 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +18 -62
- package/.agents/scripts/plan-context.js +23 -5
- package/.agents/scripts/resolve-stories.js +2 -0
- package/.agents/workflows/deliver.md +28 -28
- package/.agents/workflows/helpers/acceptance-self-eval.md +16 -5
- package/.agents/workflows/helpers/deliver-digest.md +126 -0
- package/.agents/workflows/helpers/deliver-reference.md +30 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +38 -12
- package/.agents/workflows/helpers/deliver-story.md +34 -37
- package/.agents/workflows/helpers/plan-reference.md +79 -44
- package/.agents/workflows/plan.md +11 -10
- package/docs/CHANGELOG.md +31 -0
- package/lib/cli/registry.js +31 -14
- package/lib/migrations/index.js +2 -0
- package/lib/migrations/steps/2.11.0-retire-max-seed-words.js +92 -0
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* single-story-close/gate-log.js — bounded gate output for the close path
|
|
3
|
+
* (Story #4736).
|
|
4
|
+
*
|
|
5
|
+
* ## Why
|
|
6
|
+
*
|
|
7
|
+
* `runCloseValidation` streams every child gate's stdout/stderr line through
|
|
8
|
+
* an injected `log`, and the close phase used to hand it `Logger.info` — whose
|
|
9
|
+
* default sink is `console.log`. A single successful close therefore wrote the
|
|
10
|
+
* whole of `npm test`, the linter, and the baseline checks onto the invoking
|
|
11
|
+
* agent's stdout: ~50KB, over the host's inline tool-result ceiling. The caller
|
|
12
|
+
* got a truncated preview, had to open the persisted file anyway, and re-ran
|
|
13
|
+
* close for a clean envelope — burning the run's most expensive stretch to
|
|
14
|
+
* re-derive output it already had.
|
|
15
|
+
*
|
|
16
|
+
* Story #4708 set the contract this restores compliance with (see
|
|
17
|
+
* `rules/orchestration-error-handling.md` § Output Contract): compact digest
|
|
18
|
+
* plus an on-disk artifact path, ≤ ~2KB on the **default success path**.
|
|
19
|
+
*
|
|
20
|
+
* ## The shape
|
|
21
|
+
*
|
|
22
|
+
* A sink captures every gate line to a log under the gitignored temp tree and
|
|
23
|
+
* emits nothing inline. What happens next depends on the outcome, because the
|
|
24
|
+
* two outcomes want opposite things:
|
|
25
|
+
*
|
|
26
|
+
* - **success** — the caller wants the verdict, not the evidence.
|
|
27
|
+
* {@link GateLogSink#digest} is one line: the pass count and the log path.
|
|
28
|
+
* - **failure** — the evidence IS the point, and making the caller open a
|
|
29
|
+
* file to see why a gate went red just moves the cost.
|
|
30
|
+
* {@link GateLogSink#replay} puts the captured tail back inline.
|
|
31
|
+
*
|
|
32
|
+
* `AGENT_LOG_LEVEL=verbose` opts back into live inline streaming (the
|
|
33
|
+
* "existing log-level control"): the capture still happens, so the artifact is
|
|
34
|
+
* written either way.
|
|
35
|
+
*
|
|
36
|
+
* The sink never throws. A log directory that cannot be written degrades to
|
|
37
|
+
* inline streaming — losing the size bound is strictly better than losing the
|
|
38
|
+
* gate output that says why a close failed.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import nodeFs from 'node:fs';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
|
|
44
|
+
import { Logger, resolveLevel } from '../../Logger.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How many trailing captured lines {@link GateLogSink#replay} puts back
|
|
48
|
+
* inline. A failed gate's actionable evidence — the assertion, the stack, the
|
|
49
|
+
* summary counts — sits at the end of its output; the head is startup noise.
|
|
50
|
+
* The full text is always in the artifact regardless.
|
|
51
|
+
*/
|
|
52
|
+
export const REPLAY_TAIL_LINES = 200;
|
|
53
|
+
|
|
54
|
+
/** Basename of the per-Story gate log inside the temp directory. */
|
|
55
|
+
function logNameFor(storyId) {
|
|
56
|
+
return `close-gates-${storyId ?? 'unknown'}.log`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A capturing sink for close-validation gate output.
|
|
61
|
+
*
|
|
62
|
+
* Not exported as a constructor — {@link createGateLogSink} owns the
|
|
63
|
+
* degradation decision, so every instance in the wild has already resolved
|
|
64
|
+
* whether it has a writable artifact.
|
|
65
|
+
*/
|
|
66
|
+
class GateLogSink {
|
|
67
|
+
/**
|
|
68
|
+
* @param {{ logPath: string|null, streamInline: boolean, write: (line: string) => void, emit: (line: string) => void }} args
|
|
69
|
+
*/
|
|
70
|
+
constructor({ logPath, streamInline, write, emit }) {
|
|
71
|
+
/** Absolute path of the artifact, or `null` when capture is unavailable. */
|
|
72
|
+
this.logPath = logPath;
|
|
73
|
+
/** Whether lines are ALSO echoed inline as they arrive. */
|
|
74
|
+
this.streamInline = streamInline;
|
|
75
|
+
/** Number of lines captured so far. */
|
|
76
|
+
this.lineCount = 0;
|
|
77
|
+
this._write = write;
|
|
78
|
+
this._emit = emit;
|
|
79
|
+
this._tail = [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The `log` callable handed to `runCloseValidation` / `buildDefaultGates`.
|
|
84
|
+
* Bound, because it is passed by reference into the gate machinery.
|
|
85
|
+
*
|
|
86
|
+
* @type {(message: string) => void}
|
|
87
|
+
*/
|
|
88
|
+
get log() {
|
|
89
|
+
return (message) => {
|
|
90
|
+
const line = String(message ?? '');
|
|
91
|
+
this.lineCount += 1;
|
|
92
|
+
this._tail.push(line);
|
|
93
|
+
if (this._tail.length > REPLAY_TAIL_LINES) this._tail.shift();
|
|
94
|
+
this._write(line);
|
|
95
|
+
if (this.streamInline) this._emit(line);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The success-path digest: one line, no gate output. Names the artifact so
|
|
101
|
+
* the caller can open it on demand rather than carrying it all session.
|
|
102
|
+
*
|
|
103
|
+
* @returns {string}
|
|
104
|
+
*/
|
|
105
|
+
digest() {
|
|
106
|
+
const where = this.logPath
|
|
107
|
+
? `full gate output → ${this.logPath}`
|
|
108
|
+
: 'full gate output was streamed inline (no artifact could be written)';
|
|
109
|
+
return `${this.lineCount} line(s) of gate output captured; ${where}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Put the captured tail back inline — the failure path, where the evidence
|
|
114
|
+
* is what the caller came for. A no-op when the lines were already streamed
|
|
115
|
+
* inline (verbose, or degraded capture), so nothing is ever printed twice.
|
|
116
|
+
*
|
|
117
|
+
* @returns {number} Lines replayed.
|
|
118
|
+
*/
|
|
119
|
+
replay() {
|
|
120
|
+
if (this.streamInline || this._tail.length === 0) return 0;
|
|
121
|
+
const dropped = this.lineCount - this._tail.length;
|
|
122
|
+
if (dropped > 0) {
|
|
123
|
+
this._emit(
|
|
124
|
+
`[close-validation] … ${dropped} earlier line(s) omitted; full output → ${this.logPath}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
for (const line of this._tail) this._emit(line);
|
|
128
|
+
return this._tail.length;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build the gate-output sink for one close run.
|
|
134
|
+
*
|
|
135
|
+
* @param {{
|
|
136
|
+
* storyId: number|null,
|
|
137
|
+
* cwd?: string,
|
|
138
|
+
* logDir?: string,
|
|
139
|
+
* fs?: typeof nodeFs,
|
|
140
|
+
* logger?: { info: (m: string) => void },
|
|
141
|
+
* level?: string,
|
|
142
|
+
* }} [args] `logDir` defaults to `<cwd>/temp/orchestration`; `level` defaults
|
|
143
|
+
* to the live Logger level so `AGENT_LOG_LEVEL=verbose` restores streaming.
|
|
144
|
+
* @returns {GateLogSink}
|
|
145
|
+
*/
|
|
146
|
+
export function createGateLogSink({
|
|
147
|
+
storyId = null,
|
|
148
|
+
cwd = process.cwd(),
|
|
149
|
+
logDir,
|
|
150
|
+
fs = nodeFs,
|
|
151
|
+
logger = Logger,
|
|
152
|
+
level,
|
|
153
|
+
} = {}) {
|
|
154
|
+
const emit = (line) => logger.info?.(line);
|
|
155
|
+
const verbose = (level ?? resolveLevel()) === 'verbose';
|
|
156
|
+
const dir = logDir ?? path.join(cwd, 'temp', 'orchestration');
|
|
157
|
+
|
|
158
|
+
let handle = null;
|
|
159
|
+
let logPath = null;
|
|
160
|
+
try {
|
|
161
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
162
|
+
logPath = path.join(dir, logNameFor(storyId));
|
|
163
|
+
// Truncate: each close run owns its artifact outright, so a re-run never
|
|
164
|
+
// hands the reader a file interleaving two runs' gates.
|
|
165
|
+
handle = fs.openSync(logPath, 'w');
|
|
166
|
+
} catch {
|
|
167
|
+
// No artifact — fall back to inline streaming rather than dropping the
|
|
168
|
+
// gate output on the floor.
|
|
169
|
+
return new GateLogSink({
|
|
170
|
+
logPath: null,
|
|
171
|
+
streamInline: true,
|
|
172
|
+
write: () => {},
|
|
173
|
+
emit,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const write = (line) => {
|
|
178
|
+
try {
|
|
179
|
+
fs.writeSync(handle, `${line}\n`);
|
|
180
|
+
} catch {
|
|
181
|
+
/* best-effort: a mid-run write failure must not abort the close */
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
return new GateLogSink({ logPath, streamInline: verbose, write, emit });
|
|
186
|
+
}
|
|
@@ -24,6 +24,15 @@
|
|
|
24
24
|
* the same, with `baseBranch` as the diff anchor and the Story worktree as
|
|
25
25
|
* the commit target.
|
|
26
26
|
*
|
|
27
|
+
* Bounded gate output (Story #4736). Every gate line goes to the run's
|
|
28
|
+
* `gate-log.js` sink — an artifact under the gitignored temp tree — instead
|
|
29
|
+
* of straight to the agent's stdout, where a passing `npm test` alone once
|
|
30
|
+
* pushed a successful close past the host's inline tool-result ceiling. A
|
|
31
|
+
* clean run reports one digest line naming the artifact; a failing gate
|
|
32
|
+
* replays its captured tail inline, because that is exactly when the caller
|
|
33
|
+
* needs the evidence in front of them. `AGENT_LOG_LEVEL=verbose` restores
|
|
34
|
+
* live streaming.
|
|
35
|
+
*
|
|
27
36
|
* `runCloseValidation`, `buildDefaultGates`, and `runScopedFormatAutofix`
|
|
28
37
|
* are accepted as injected dependencies so the parent CLI's cache-busted
|
|
29
38
|
* bindings win in tests that mock the upstream module URLs.
|
|
@@ -33,6 +42,7 @@ import { buildDefaultGates as defaultBuildDefaultGates } from '../../../close-va
|
|
|
33
42
|
import { runCloseValidation as defaultRunCloseValidation } from '../../../close-validation/runner.js';
|
|
34
43
|
import { Logger } from '../../../Logger.js';
|
|
35
44
|
import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../story-close/format-autofix.js';
|
|
45
|
+
import { createGateLogSink as defaultCreateGateLogSink } from '../gate-log.js';
|
|
36
46
|
|
|
37
47
|
/**
|
|
38
48
|
* Run the close-validation gate chain. Throws on first gate failure.
|
|
@@ -60,6 +70,7 @@ import { runScopedFormatAutofix as defaultRunScopedFormatAutofix } from '../../s
|
|
|
60
70
|
* runCloseValidation?: typeof defaultRunCloseValidation,
|
|
61
71
|
* buildDefaultGates?: typeof defaultBuildDefaultGates,
|
|
62
72
|
* runScopedFormatAutofix?: typeof defaultRunScopedFormatAutofix,
|
|
73
|
+
* createGateLogSink?: typeof defaultCreateGateLogSink,
|
|
63
74
|
* }} args
|
|
64
75
|
*/
|
|
65
76
|
export async function runCloseValidationPhase({
|
|
@@ -73,6 +84,7 @@ export async function runCloseValidationPhase({
|
|
|
73
84
|
runCloseValidation = defaultRunCloseValidation,
|
|
74
85
|
buildDefaultGates = defaultBuildDefaultGates,
|
|
75
86
|
runScopedFormatAutofix = defaultRunScopedFormatAutofix,
|
|
87
|
+
createGateLogSink = defaultCreateGateLogSink,
|
|
76
88
|
}) {
|
|
77
89
|
// Story #4250 — format-autofix self-heal before the check-only gates.
|
|
78
90
|
// Mirrors the Epic path (story-close/phases/gates.js): the formatter is
|
|
@@ -122,6 +134,9 @@ export async function runCloseValidationPhase({
|
|
|
122
134
|
'VALIDATE',
|
|
123
135
|
`Running close-validation gates against baseline ${baseBranch}${worktreePath ? ` in ${worktreePath}` : ''}...`,
|
|
124
136
|
);
|
|
137
|
+
// Story #4736 — one sink for both `log` seams (gate construction and gate
|
|
138
|
+
// execution), so nothing in the chain can route around the artifact.
|
|
139
|
+
const gateLog = createGateLogSink({ storyId, cwd });
|
|
125
140
|
const validation = await runCloseValidation({
|
|
126
141
|
cwd,
|
|
127
142
|
worktreePath,
|
|
@@ -129,9 +144,9 @@ export async function runCloseValidationPhase({
|
|
|
129
144
|
config,
|
|
130
145
|
baseBranch,
|
|
131
146
|
cwd: worktreePath || cwd,
|
|
132
|
-
log:
|
|
147
|
+
log: gateLog.log,
|
|
133
148
|
}),
|
|
134
|
-
log:
|
|
149
|
+
log: gateLog.log,
|
|
135
150
|
storyId,
|
|
136
151
|
// Story #4250 — standalone storyId-anchored evidence keyspace. No
|
|
137
152
|
// epicId; the standalone flag routes the cache to
|
|
@@ -141,10 +156,13 @@ export async function runCloseValidationPhase({
|
|
|
141
156
|
if (!validation.ok) {
|
|
142
157
|
const [first] = validation.failed;
|
|
143
158
|
const { gate, status, cwd: gateCwd } = first;
|
|
159
|
+
// The evidence is the point on this path: replay the captured tail inline
|
|
160
|
+
// rather than making the caller open a file to learn why close stopped.
|
|
161
|
+
gateLog.replay();
|
|
144
162
|
throw new Error(
|
|
145
163
|
`[single-story-close] Gate failed: ${gate.name} (exit ${status})${gateCwd ? ` in ${gateCwd}` : ''}.` +
|
|
146
164
|
(gate.hint ? ` ${gate.hint}` : ''),
|
|
147
165
|
);
|
|
148
166
|
}
|
|
149
|
-
progress('VALIDATE',
|
|
167
|
+
progress('VALIDATE', `✅ All gates passed. ${gateLog.digest()}`);
|
|
150
168
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/spec-budget.js — the soft `## Spec` word-budget pass
|
|
3
|
+
* (Story #4723), extracted from `ticket-validator.js` so the advisory
|
|
4
|
+
* length nudge lives beside neither the hard validators nor their error
|
|
5
|
+
* channel: everything here is `'soft'` by construction and can never fail
|
|
6
|
+
* a persist.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parse as parseStoryBody } from '../story-body/story-body.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Soft advisory word budget for a Story's inline `## Spec` (Story #4723).
|
|
13
|
+
* ~250 words is the #4707 contract-level-prose target: interfaces,
|
|
14
|
+
* invariants, and load-bearing constraints — not route-by-route behavior
|
|
15
|
+
* narration. Distinct from the hard ~1500-token fail-closed ceiling in
|
|
16
|
+
* `spec-spill.js`: this budget only warns; it never fails the persist.
|
|
17
|
+
*/
|
|
18
|
+
export const SPEC_SOFT_WORD_BUDGET = 250;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a Story's Spec prose across both authoring shapes: the canonical
|
|
22
|
+
* serialized string body (parsed; `## Spec` text block) and the
|
|
23
|
+
* pre-serialize structured object body (`body.spec`). Returns `''` when
|
|
24
|
+
* absent — or when a string body does not parse: this pass is advisory, so
|
|
25
|
+
* an unreadable body contributes no finding here and is left to the hard
|
|
26
|
+
* parse gate (`assertStoryBodiesParse`) to reject.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} story
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function resolveSpecText(story) {
|
|
32
|
+
const body = story?.body;
|
|
33
|
+
if (typeof body === 'string' && body.trim().length > 0) {
|
|
34
|
+
let spec;
|
|
35
|
+
try {
|
|
36
|
+
spec = parseStoryBody(body).body.spec;
|
|
37
|
+
} catch {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
return typeof spec === 'string' ? spec : '';
|
|
41
|
+
}
|
|
42
|
+
if (body !== null && typeof body === 'object') {
|
|
43
|
+
return typeof body?.spec === 'string' ? body.spec : '';
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Advisory `## Spec` length pass (Story #4723). Emits one `'soft'` finding
|
|
50
|
+
* per Story whose Spec prose exceeds {@link SPEC_SOFT_WORD_BUDGET} words,
|
|
51
|
+
* nudging the author toward contract-level prose (#4707). Soft only — the
|
|
52
|
+
* findings never reach the validator's `errors[]` channel, so an
|
|
53
|
+
* over-budget Spec never fails the persist.
|
|
54
|
+
*
|
|
55
|
+
* @param {{ stories: object[] }} opts
|
|
56
|
+
* @returns {object[]} Zero or more `spec-word-budget` findings.
|
|
57
|
+
*/
|
|
58
|
+
export function computeSpecBudgetFindings({ stories }) {
|
|
59
|
+
const findings = [];
|
|
60
|
+
for (const story of stories ?? []) {
|
|
61
|
+
const words = resolveSpecText(story).split(/\s+/).filter(Boolean).length;
|
|
62
|
+
if (words <= SPEC_SOFT_WORD_BUDGET) continue;
|
|
63
|
+
findings.push({
|
|
64
|
+
kind: 'spec-word-budget',
|
|
65
|
+
severity: 'soft',
|
|
66
|
+
ticketSlug: story.slug ?? '<unknown>',
|
|
67
|
+
words,
|
|
68
|
+
budget: SPEC_SOFT_WORD_BUDGET,
|
|
69
|
+
message:
|
|
70
|
+
`Story "${story.slug ?? '<unknown>'}" ## Spec is ~${words} words ` +
|
|
71
|
+
`(soft budget ${SPEC_SOFT_WORD_BUDGET}). Prefer contract-level prose ` +
|
|
72
|
+
'(interfaces, invariants, load-bearing constraints with their why) ' +
|
|
73
|
+
'over per-file behavior narration — advisory only; the persist ' +
|
|
74
|
+
'proceeds.',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return findings;
|
|
78
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/orchestration/story-body-gate.js — the Story-body parse gate
|
|
3
|
+
* (Story #4541), extracted from `ticket-validator.js`: the one place a
|
|
4
|
+
* serialized Story body is parsed with parse failures translated into the
|
|
5
|
+
* validator's operator-legible `ValidationError` shape. Both the gate that
|
|
6
|
+
* refuses a plan up front (`assertStoryBodiesParse`) and the per-call
|
|
7
|
+
* translating parser (`parseStoryBodyOrThrow`) the downstream validators
|
|
8
|
+
* lean on live here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { ValidationError } from '../errors/index.js';
|
|
12
|
+
import {
|
|
13
|
+
parse as parseStoryBody,
|
|
14
|
+
StoryBodyParseError,
|
|
15
|
+
} from '../story-body/story-body.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a Story's serialized markdown body, translating a
|
|
19
|
+
* `StoryBodyParseError` into a `ValidationError` that names the offending
|
|
20
|
+
* **section** and **entry** (Story #4541).
|
|
21
|
+
*
|
|
22
|
+
* `StoryBodyParseError` already carries `field` (the section the parser was
|
|
23
|
+
* reading) and `raw` (the entry text that failed); this lifts both into an
|
|
24
|
+
* operator-legible message and a structured `violation` payload so an
|
|
25
|
+
* authoring loop can point at the exact bullet instead of re-deriving it
|
|
26
|
+
* from a downstream freshness miss.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} story Story whose `body` is a non-empty markdown string.
|
|
29
|
+
* @returns {object} The structured body.
|
|
30
|
+
* @throws {ValidationError} `code: 'story-body-unparseable'`.
|
|
31
|
+
*/
|
|
32
|
+
export function parseStoryBodyOrThrow(story) {
|
|
33
|
+
try {
|
|
34
|
+
return parseStoryBody(story.body).body;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (!(err instanceof StoryBodyParseError)) throw err;
|
|
37
|
+
const slug = story.slug ?? '<unknown>';
|
|
38
|
+
const section = err.field ?? 'body';
|
|
39
|
+
const entry = err.raw ?? null;
|
|
40
|
+
const entryLine = entry === null ? '' : `\n entry: ${entry}`;
|
|
41
|
+
const violation = { slug, section, entry, reason: err.message };
|
|
42
|
+
const error = new ValidationError(
|
|
43
|
+
`Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
|
|
44
|
+
`the ## ${section} section could not be read: ${err.message}` +
|
|
45
|
+
`${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
|
|
46
|
+
'not a stale path reference.',
|
|
47
|
+
{ violations: [violation] },
|
|
48
|
+
);
|
|
49
|
+
error.code = 'story-body-unparseable';
|
|
50
|
+
error.violations = [violation];
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Refuse the plan when any Story's serialized body cannot be parsed, before
|
|
57
|
+
* either git-probe gate runs (Story #4541). Ordering matters: the freshness
|
|
58
|
+
* gate consults `body.changes` for its net-new whitelist, so an unparseable
|
|
59
|
+
* body used to reach the operator as a freshness miss naming declared paths.
|
|
60
|
+
*
|
|
61
|
+
* @param {{ tickets: object[] }} opts
|
|
62
|
+
* @throws {ValidationError} `code: 'story-body-unparseable'` on the first
|
|
63
|
+
* offending Story.
|
|
64
|
+
*/
|
|
65
|
+
export function assertStoryBodiesParse({ tickets }) {
|
|
66
|
+
for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
|
|
67
|
+
if (typeof story.body !== 'string' || story.body.trim().length === 0) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
parseStoryBodyOrThrow(story);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -829,6 +829,12 @@ export function renderHardConflictError(finding) {
|
|
|
829
829
|
if (finding.kind === 'missing-bdd-scaffold') {
|
|
830
830
|
return `Missing BDD scaffold: Story "${finding.consumer.storySlug}" verifies against "${finding.path}" (created by Story "${finding.producer.storySlug}") via body.${finding.consumer.sourceField}, but "${finding.consumer.storySlug}" has no depends_on path to "${finding.producer.storySlug}" — the .feature file is scaffolded in the same wave (or later), so verification runs before the file exists. Add depends_on: ["${finding.producer.storySlug}"] to the consumer Story so the scaffold lands in an earlier wave.`;
|
|
831
831
|
}
|
|
832
|
+
// Findings from other passes (sizing, spec-word-budget) carry their own
|
|
833
|
+
// message — render it rather than a shape-blind generic line, so the soft
|
|
834
|
+
// surface (`surfaceSoftConflictFindings`) stays legible for every kind.
|
|
835
|
+
if (typeof finding.message === 'string' && finding.message.length > 0) {
|
|
836
|
+
return finding.message;
|
|
837
|
+
}
|
|
832
838
|
return `Conflict finding ${finding.kind} on path "${finding.path ?? '<unknown>'}".`;
|
|
833
839
|
}
|
|
834
840
|
|
|
@@ -3,11 +3,12 @@ import { detectCycle } from '../Graph.js';
|
|
|
3
3
|
import { gitSpawn } from '../git-utils.js';
|
|
4
4
|
|
|
5
5
|
import { Logger } from '../Logger.js';
|
|
6
|
-
import {
|
|
7
|
-
parse as parseStoryBody,
|
|
8
|
-
StoryBodyParseError,
|
|
9
|
-
} from '../story-body/story-body.js';
|
|
10
6
|
import { validateStoryFileAssumptions } from './file-assumptions.js';
|
|
7
|
+
import { computeSpecBudgetFindings } from './spec-budget.js';
|
|
8
|
+
import {
|
|
9
|
+
assertStoryBodiesParse,
|
|
10
|
+
parseStoryBodyOrThrow,
|
|
11
|
+
} from './story-body-gate.js';
|
|
11
12
|
import {
|
|
12
13
|
computeConflictFindings,
|
|
13
14
|
renderHardConflictError,
|
|
@@ -46,63 +47,6 @@ function collectPathsFromText(text, paths) {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
/**
|
|
50
|
-
* Parse a Story's serialized markdown body, translating a
|
|
51
|
-
* `StoryBodyParseError` into a `ValidationError` that names the offending
|
|
52
|
-
* **section** and **entry** (Story #4541).
|
|
53
|
-
*
|
|
54
|
-
* `StoryBodyParseError` already carries `field` (the section the parser was
|
|
55
|
-
* reading) and `raw` (the entry text that failed); this lifts both into an
|
|
56
|
-
* operator-legible message and a structured `violation` payload so an
|
|
57
|
-
* authoring loop can point at the exact bullet instead of re-deriving it
|
|
58
|
-
* from a downstream freshness miss.
|
|
59
|
-
*
|
|
60
|
-
* @param {object} story Story whose `body` is a non-empty markdown string.
|
|
61
|
-
* @returns {object} The structured body.
|
|
62
|
-
* @throws {ValidationError} `code: 'story-body-unparseable'`.
|
|
63
|
-
*/
|
|
64
|
-
function parseStoryBodyOrThrow(story) {
|
|
65
|
-
try {
|
|
66
|
-
return parseStoryBody(story.body).body;
|
|
67
|
-
} catch (err) {
|
|
68
|
-
if (!(err instanceof StoryBodyParseError)) throw err;
|
|
69
|
-
const slug = story.slug ?? '<unknown>';
|
|
70
|
-
const section = err.field ?? 'body';
|
|
71
|
-
const entry = err.raw ?? null;
|
|
72
|
-
const entryLine = entry === null ? '' : `\n entry: ${entry}`;
|
|
73
|
-
const violation = { slug, section, entry, reason: err.message };
|
|
74
|
-
const error = new ValidationError(
|
|
75
|
-
`Cross-Validation Failed: Story "${slug}" has an unparseable body — ` +
|
|
76
|
-
`the ## ${section} section could not be read: ${err.message}` +
|
|
77
|
-
`${entryLine}\n\nFix the offending entry; this is a malformed body, ` +
|
|
78
|
-
'not a stale path reference.',
|
|
79
|
-
{ violations: [violation] },
|
|
80
|
-
);
|
|
81
|
-
error.code = 'story-body-unparseable';
|
|
82
|
-
error.violations = [violation];
|
|
83
|
-
throw error;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Refuse the plan when any Story's serialized body cannot be parsed, before
|
|
89
|
-
* either git-probe gate runs (Story #4541). Ordering matters: the freshness
|
|
90
|
-
* gate consults `body.changes` for its net-new whitelist, so an unparseable
|
|
91
|
-
* body used to reach the operator as a freshness miss naming declared paths.
|
|
92
|
-
*
|
|
93
|
-
* @param {{ tickets: object[] }} opts
|
|
94
|
-
* @throws {ValidationError} `code: 'story-body-unparseable'` on the first
|
|
95
|
-
* offending Story.
|
|
96
|
-
*/
|
|
97
|
-
function assertStoryBodiesParse({ tickets }) {
|
|
98
|
-
for (const story of (tickets ?? []).filter((t) => t.type === 'story')) {
|
|
99
|
-
if (typeof story.body !== 'string' || story.body.trim().length === 0) {
|
|
100
|
-
continue;
|
|
101
|
-
}
|
|
102
|
-
parseStoryBodyOrThrow(story);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
50
|
/**
|
|
107
51
|
* Resolve every acceptance line a Story declares, across both authoring
|
|
108
52
|
* shapes (Story #4541).
|
|
@@ -721,7 +665,19 @@ export function validateAndNormalizeTickets(tickets, opts = {}) {
|
|
|
721
665
|
stories,
|
|
722
666
|
policy: opts.conflictPolicy,
|
|
723
667
|
});
|
|
724
|
-
|
|
668
|
+
// Advisory `## Spec` word-budget pass (Story #4723) — soft findings only,
|
|
669
|
+
// surfaced as warnings here and via the persist soft-finding channel;
|
|
670
|
+
// never promoted to `errors[]`, so an over-budget Spec cannot fail the
|
|
671
|
+
// persist. Runs after `assertStoryBodiesParse`, so string bodies parse.
|
|
672
|
+
const specBudgetFindings = computeSpecBudgetFindings({ stories });
|
|
673
|
+
for (const finding of specBudgetFindings) {
|
|
674
|
+
Logger.warn(`[ticket-validator] spec-word-budget: ${finding.message}`);
|
|
675
|
+
}
|
|
676
|
+
const findings = [
|
|
677
|
+
...sizingFindings,
|
|
678
|
+
...conflictFindings,
|
|
679
|
+
...specBudgetFindings,
|
|
680
|
+
];
|
|
725
681
|
const CONFLICT_KINDS = new Set([
|
|
726
682
|
'shared-editor',
|
|
727
683
|
'implicit-cross-story-dep',
|
|
@@ -124,7 +124,7 @@ export async function emitPlanContext({
|
|
|
124
124
|
// later turn. When it is captured to disk anyway, stdout carries a
|
|
125
125
|
// compact digest naming the artifact instead of the payload itself.
|
|
126
126
|
await writeEnvelopeFile(outPath, json);
|
|
127
|
-
await writeStoriesTemplateFile(outPath);
|
|
127
|
+
await writeStoriesTemplateFile(outPath, envelope);
|
|
128
128
|
const resolved = path.resolve(outPath);
|
|
129
129
|
const digest = {
|
|
130
130
|
digest: 'plan-context',
|
|
@@ -137,7 +137,16 @@ export async function emitPlanContext({
|
|
|
137
137
|
bytes: Buffer.byteLength(json, 'utf8'),
|
|
138
138
|
sourceTickets: (envelope.sourceTickets ?? []).map((t) => t.id),
|
|
139
139
|
duplicates: (envelope.duplicates ?? []).length,
|
|
140
|
-
|
|
140
|
+
// Advisory only (Story #4722): signals, no route — the planner owns
|
|
141
|
+
// the trivial-vs-standard verdict and persist validates it by shape.
|
|
142
|
+
complexitySignals: envelope.complexitySignals
|
|
143
|
+
? {
|
|
144
|
+
artifactCount: envelope.complexitySignals.artifactCount,
|
|
145
|
+
riskHeuristicHits: envelope.complexitySignals.riskHeuristicHits,
|
|
146
|
+
sensitivePathClasses:
|
|
147
|
+
envelope.complexitySignals.sensitivePathClasses,
|
|
148
|
+
}
|
|
149
|
+
: null,
|
|
141
150
|
};
|
|
142
151
|
stdout.write(`${JSON.stringify(digest)}\n`);
|
|
143
152
|
} else {
|
|
@@ -177,18 +186,27 @@ async function writeEnvelopeFile(outPath, json) {
|
|
|
177
186
|
* requires reading `story-body.js` source. Written whenever `--out` is
|
|
178
187
|
* passed, and throwing on failure for the same reason the envelope write
|
|
179
188
|
* does: a silently missing template re-opens the format-discovery loop it
|
|
180
|
-
* exists to close.
|
|
189
|
+
* exists to close. The envelope's advisory `complexitySignals` are threaded
|
|
190
|
+
* through so the skeleton's `changes[]` arrive pre-resolved to
|
|
191
|
+
* creates-vs-refactors against the repo snapshot (Story #4723).
|
|
181
192
|
*
|
|
182
193
|
* @param {string} outPath The envelope `--out` path; the template lands in
|
|
183
194
|
* the same directory as {@link STORIES_TEMPLATE_FILENAME}.
|
|
195
|
+
* @param {object} [envelope] The emitted plan-context envelope.
|
|
184
196
|
*/
|
|
185
|
-
async function writeStoriesTemplateFile(outPath) {
|
|
197
|
+
async function writeStoriesTemplateFile(outPath, envelope = {}) {
|
|
186
198
|
const resolved = path.resolve(
|
|
187
199
|
path.dirname(path.resolve(outPath)),
|
|
188
200
|
STORIES_TEMPLATE_FILENAME,
|
|
189
201
|
);
|
|
190
202
|
try {
|
|
191
|
-
await writeFile(
|
|
203
|
+
await writeFile(
|
|
204
|
+
resolved,
|
|
205
|
+
renderStoriesTemplate({
|
|
206
|
+
complexitySignals: envelope?.complexitySignals ?? null,
|
|
207
|
+
}),
|
|
208
|
+
'utf8',
|
|
209
|
+
);
|
|
192
210
|
} catch (err) {
|
|
193
211
|
throw new Error(
|
|
194
212
|
`[plan-context] cannot write stories template to ${resolved}: ${err.message}`,
|
|
@@ -209,6 +209,7 @@ async function main() {
|
|
|
209
209
|
stories,
|
|
210
210
|
nativeEdges,
|
|
211
211
|
warn: (m) => Logger.warn(m),
|
|
212
|
+
config,
|
|
212
213
|
});
|
|
213
214
|
const foreignDone = await resolveForeignDone({
|
|
214
215
|
provider,
|
|
@@ -220,6 +221,7 @@ async function main() {
|
|
|
220
221
|
nativeEdges,
|
|
221
222
|
foreignDone,
|
|
222
223
|
warn: () => {},
|
|
224
|
+
config,
|
|
223
225
|
});
|
|
224
226
|
|
|
225
227
|
process.stdout.write(
|