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.
- package/.agents/agents/.markdownlint.json +4 -0
- package/.agents/agents/acceptance-critic.md +30 -5
- package/.agents/agents/auditor.md +36 -19
- package/.agents/agents/plan-critic.md +31 -5
- package/.agents/agents/story-worker.md +91 -100
- package/.agents/docs/configuration.md +39 -1
- package/.agents/docs/execution-reference.md +13 -0
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +131 -265
- package/.agents/rules/git-conventions.md +47 -83
- package/.agents/rules/orchestration-error-handling.md +28 -0
- package/.agents/schemas/agentrc.schema.json +44 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/acceptance-eval.js +1 -1
- package/.agents/scripts/apply-quality-bootstrap.js +1 -1
- package/.agents/scripts/audit-to-stories.js +51 -0
- package/.agents/scripts/check-test-temp-hygiene.js +438 -0
- package/.agents/scripts/deliver-recover.js +23 -6
- package/.agents/scripts/lib/audit-suite/index.js +5 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +179 -0
- package/.agents/scripts/lib/audit-suite/selector.js +1 -1
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
- package/.agents/scripts/lib/config/temp-paths.js +121 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +30 -0
- package/.agents/scripts/lib/config-settings-schema.js +32 -0
- package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
- package/.agents/scripts/lib/observability/metrics-ledger.js +217 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +7 -0
- package/.agents/scripts/lib/observability/terse-result.js +114 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +318 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +137 -10
- package/.agents/scripts/lib/orchestration/merge-block-class.js +36 -15
- package/.agents/scripts/lib/orchestration/merge-poll.js +213 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +60 -0
- package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +182 -9
- package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +29 -2
- package/.agents/scripts/lib/orchestration/plan-metrics.js +31 -82
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +102 -2
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +215 -14
- package/.agents/scripts/lib/orchestration/resolve-stories.js +7 -0
- package/.agents/scripts/lib/orchestration/review-providers/native.js +34 -16
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
- package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
- package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +89 -1
- package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +73 -0
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
- package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
- package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
- package/.agents/scripts/lib/story-body/story-body.js +18 -2
- package/.agents/scripts/lib/templates/decomposer-prompts.js +29 -6
- package/.agents/scripts/lib/test-env.js +65 -0
- package/.agents/scripts/plan-context.js +66 -9
- package/.agents/scripts/plan-critics.js +115 -3
- package/.agents/scripts/plan-persist.js +11 -1
- package/.agents/scripts/plan-run-epilogue.js +1 -1
- package/.agents/scripts/providers/github/issues.js +54 -7
- package/.agents/scripts/providers/github/search-budget.js +124 -0
- package/.agents/scripts/providers/github/search-query.js +71 -0
- package/.agents/scripts/single-story-confirm-merge.js +79 -10
- package/.agents/scripts/single-story-init.js +19 -3
- package/.agents/scripts/stories-wave-tick.js +1 -1
- package/.agents/scripts/sync-branch-from-base.js +9 -3
- package/.agents/workflows/deliver.md +86 -230
- package/.agents/workflows/helpers/deliver-reference.md +167 -0
- package/.agents/workflows/helpers/deliver-story-reference.md +203 -0
- package/.agents/workflows/helpers/deliver-story.md +114 -422
- package/.agents/workflows/helpers/plan-reference.md +211 -0
- package/.agents/workflows/plan.md +107 -279
- package/docs/CHANGELOG.md +47 -0
- 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
|
+
}
|