mandrel 2.9.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 +16 -4
- 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 +24 -2
- 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/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/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 +1 -1
- 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/complexity-gate.js +113 -2
- 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 +57 -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/code-review.js +8 -3
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +230 -79
- 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/templates/decomposer-prompts.js +13 -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/single-story-confirm-merge.js +65 -5
- package/.agents/scripts/stories-wave-tick.js +1 -1
- 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 -432
- package/.agents/workflows/helpers/plan-reference.md +211 -0
- package/.agents/workflows/plan.md +107 -304
- package/docs/CHANGELOG.md +27 -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,7 @@ 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 { applyPlannerDowngrade, LITE_ROUTE_LABEL } from '../complexity-gate.js';
|
|
47
48
|
import {
|
|
48
49
|
appendCriticSkip,
|
|
49
50
|
readPlanMetrics,
|
|
@@ -261,6 +262,70 @@ async function renderRunScopedPlanMetricsLine({
|
|
|
261
262
|
}
|
|
262
263
|
}
|
|
263
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Resolve the plan's **effective** complexity route for persist
|
|
267
|
+
* (Story #4707).
|
|
268
|
+
*
|
|
269
|
+
* The deterministic verdict rides in on the captured plan-context envelope's
|
|
270
|
+
* `complexityRoute`. Layered on top is the audited planner downgrade: a
|
|
271
|
+
* `full` verdict downgrades to `lite` **only** when the operator/planner
|
|
272
|
+
* passed `--route-downgrade-reason` with a non-empty reason
|
|
273
|
+
* (`applyPlannerDowngrade` — absent a recorded reason the deterministic
|
|
274
|
+
* verdict stands, and the gate itself still fails toward `full`).
|
|
275
|
+
*
|
|
276
|
+
* The resolved route decides whether the created Stories carry the
|
|
277
|
+
* {@link LITE_ROUTE_LABEL} marker and a `route` block on their
|
|
278
|
+
* `story-plan-state` checkpoint — the persisted, ledgered record `/deliver`
|
|
279
|
+
* reads. A full route persists **no** marker and no checkpoint block:
|
|
280
|
+
* absence is the conservative default.
|
|
281
|
+
*
|
|
282
|
+
* Module-private: reachable end to end through {@link runPlanPersist}
|
|
283
|
+
* (whose result reports the resolved route), so there is no test-only
|
|
284
|
+
* export to leave production-dead.
|
|
285
|
+
*
|
|
286
|
+
* @param {{ planContextEnvelope?: object|null, routeDowngradeReason?: string|null }} args
|
|
287
|
+
* @returns {{ route: 'lite'|'full', reasons: string[], downgraded: { from: 'full', reason: string }|null }|null}
|
|
288
|
+
* `null` when no envelope carried a verdict (nothing to persist).
|
|
289
|
+
*/
|
|
290
|
+
function resolveEffectiveRoute({
|
|
291
|
+
planContextEnvelope = null,
|
|
292
|
+
routeDowngradeReason = null,
|
|
293
|
+
} = {}) {
|
|
294
|
+
const verdict = planContextEnvelope?.complexityRoute ?? null;
|
|
295
|
+
if (!verdict || typeof verdict !== 'object') {
|
|
296
|
+
if (
|
|
297
|
+
typeof routeDowngradeReason === 'string' &&
|
|
298
|
+
routeDowngradeReason.trim() !== ''
|
|
299
|
+
) {
|
|
300
|
+
Logger.warn(
|
|
301
|
+
'[plan-persist] --route-downgrade-reason was passed but no captured ' +
|
|
302
|
+
'plan-context envelope carries a complexityRoute verdict — there ' +
|
|
303
|
+
'is no full verdict to downgrade, so the plan persists as full ' +
|
|
304
|
+
'(no route marker).',
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const effective = applyPlannerDowngrade(verdict, {
|
|
310
|
+
reason: routeDowngradeReason,
|
|
311
|
+
});
|
|
312
|
+
if (
|
|
313
|
+
typeof routeDowngradeReason === 'string' &&
|
|
314
|
+
routeDowngradeReason.trim() !== '' &&
|
|
315
|
+
effective.downgraded == null
|
|
316
|
+
) {
|
|
317
|
+
Logger.warn(
|
|
318
|
+
'[plan-persist] --route-downgrade-reason had no effect: the envelope ' +
|
|
319
|
+
`verdict is already "${verdict.route}" — nothing to downgrade.`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
route: effective.route === 'lite' ? 'lite' : 'full',
|
|
324
|
+
reasons: Array.isArray(effective.reasons) ? effective.reasons : [],
|
|
325
|
+
downgraded: effective.downgraded ?? null,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
264
329
|
/**
|
|
265
330
|
* Age after which an abandoned `temp/plan-*` directory is reaped. A plan run
|
|
266
331
|
* that is still being authored is minutes-to-hours old; a week is far past
|
|
@@ -328,6 +393,7 @@ export async function reapStalePlanDirs({
|
|
|
328
393
|
* stories: Array<object>,
|
|
329
394
|
* techSpecContent?: string|null,
|
|
330
395
|
* planAcceptance?: string[]|null,
|
|
396
|
+
* planContextEnvelope?: object|null,
|
|
331
397
|
* },
|
|
332
398
|
* config?: object,
|
|
333
399
|
* settings?: object,
|
|
@@ -343,6 +409,7 @@ export async function reapStalePlanDirs({
|
|
|
343
409
|
* sourceTicketIds?: number[],
|
|
344
410
|
* sourceTicketOrigin?: 'flag'|'envelope'|'none',
|
|
345
411
|
* closeSuperseded?: boolean,
|
|
412
|
+
* routeDowngradeReason?: string|null,
|
|
346
413
|
* },
|
|
347
414
|
* }} input
|
|
348
415
|
*/
|
|
@@ -357,6 +424,7 @@ export async function runPlanPersist({
|
|
|
357
424
|
stories: rawStories = null,
|
|
358
425
|
techSpecContent = null,
|
|
359
426
|
planAcceptance = null,
|
|
427
|
+
planContextEnvelope = null,
|
|
360
428
|
} = artifacts ?? {};
|
|
361
429
|
const {
|
|
362
430
|
forceReview = false,
|
|
@@ -370,6 +438,7 @@ export async function runPlanPersist({
|
|
|
370
438
|
sourceTicketIds = [],
|
|
371
439
|
sourceTicketOrigin = 'none',
|
|
372
440
|
closeSuperseded = true,
|
|
441
|
+
routeDowngradeReason = null,
|
|
373
442
|
} = opts;
|
|
374
443
|
|
|
375
444
|
// Boundary for the plan-metrics summary below: everything this invocation
|
|
@@ -444,10 +513,29 @@ export async function runPlanPersist({
|
|
|
444
513
|
sourceTicketIds,
|
|
445
514
|
});
|
|
446
515
|
|
|
447
|
-
|
|
516
|
+
// Effective complexity route (Story #4707): envelope verdict, plus the
|
|
517
|
+
// audited planner downgrade when --route-downgrade-reason was recorded.
|
|
518
|
+
// Lite persists the `route::lite` marker + a checkpoint block; full
|
|
519
|
+
// persists nothing.
|
|
520
|
+
const route = resolveEffectiveRoute({
|
|
521
|
+
planContextEnvelope,
|
|
522
|
+
routeDowngradeReason,
|
|
523
|
+
});
|
|
524
|
+
const isLiteRoute = route?.route === 'lite';
|
|
525
|
+
if (isLiteRoute) {
|
|
526
|
+
Logger.info(
|
|
527
|
+
`[plan-persist] ceremony-lite route: created Stories carry the ` +
|
|
528
|
+
`${LITE_ROUTE_LABEL} marker` +
|
|
529
|
+
(route.downgraded
|
|
530
|
+
? ` (planner downgrade, recorded reason: ${route.downgraded.reason})`
|
|
531
|
+
: ' (deterministic gate verdict)'),
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const { created, planRunLabel } = await createStoryIssues({
|
|
448
536
|
provider,
|
|
449
537
|
stories,
|
|
450
|
-
opts: { dryRun },
|
|
538
|
+
opts: { dryRun, routeLabel: isLiteRoute ? LITE_ROUTE_LABEL : null },
|
|
451
539
|
});
|
|
452
540
|
|
|
453
541
|
const primary = created[0];
|
|
@@ -498,6 +586,10 @@ export async function runPlanPersist({
|
|
|
498
586
|
id: createdStory.id,
|
|
499
587
|
})),
|
|
500
588
|
},
|
|
589
|
+
// Ledger the lite route — including any planner downgrade and its
|
|
590
|
+
// recorded reason — on plan state (Story #4707). A full route writes
|
|
591
|
+
// no block: absence of the marker is the full path.
|
|
592
|
+
...(isLiteRoute ? { route } : {}),
|
|
501
593
|
});
|
|
502
594
|
}
|
|
503
595
|
await upsertStructuredComment(
|
|
@@ -551,10 +643,18 @@ export async function runPlanPersist({
|
|
|
551
643
|
Logger.info(
|
|
552
644
|
`[plan-persist] Deliver with: /deliver ${created.map((s2) => s2.id).join(' ')}`,
|
|
553
645
|
);
|
|
646
|
+
// Metadata only — a GitHub filter for the cohort this run authored, never
|
|
647
|
+
// a delivery-resolution input (/deliver stays ids-only, Story #4540).
|
|
648
|
+
Logger.info(
|
|
649
|
+
`[plan-persist] Cohort grouping label: ${planRunLabel} — filter with ` +
|
|
650
|
+
`label:${planRunLabel}`,
|
|
651
|
+
);
|
|
554
652
|
|
|
555
653
|
return {
|
|
556
654
|
stories: created,
|
|
557
655
|
primaryStoryId: primary.id,
|
|
656
|
+
planRunLabel,
|
|
657
|
+
route,
|
|
558
658
|
forceReview,
|
|
559
659
|
reachability,
|
|
560
660
|
freshness,
|