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