mandrel 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/docs/configuration.md +26 -0
- package/.agents/schemas/agentrc.schema.json +21 -0
- package/.agents/scripts/audit-to-stories.js +51 -0
- package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
- 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/terse-result.js +114 -0
- package/.agents/scripts/lib/orchestration/complexity-gate.js +207 -0
- package/.agents/scripts/lib/orchestration/plan-context.js +3 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
- 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-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 +16 -0
- 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 +14 -5
- package/.agents/scripts/single-story-init.js +19 -3
- package/.agents/scripts/sync-branch-from-base.js +9 -3
- package/.agents/workflows/helpers/deliver-story.md +10 -0
- package/.agents/workflows/plan.md +27 -2
- package/docs/CHANGELOG.md +20 -0
- package/package.json +1 -1
|
@@ -102,6 +102,10 @@ top-level keys are validation errors.
|
|
|
102
102
|
| `codebaseSnapshot.include` | No | `array<string>` | — | — |
|
|
103
103
|
| `codebaseSnapshot.exclude` | No | `array<string>` | — | — |
|
|
104
104
|
| `codebaseSnapshot.recentCommitWindow` | No | `integer` | — | — |
|
|
105
|
+
| `complexityGate` | No | `object` | — | Plan-time ceremony-lite complexity gate. Routes trivial single-artifact seeds onto a collapsed plan/deliver path; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables. |
|
|
106
|
+
| `complexityGate.enabled` | No | `boolean` | — | Master switch. When false, every seed takes the full plan/deliver ceremony. Default true. |
|
|
107
|
+
| `complexityGate.maxSeedWords` | No | `integer` | — | Seed prose word ceiling for the lite path. A seed above this many words is not trivial and takes the full path. Default 60. |
|
|
108
|
+
| `complexityGate.maxArtifacts` | No | `integer` | — | Enumerated-artifact ceiling for the lite path. A seed enumerating more than this many candidate artifacts is multi-capability and takes the full path. Default 1. |
|
|
105
109
|
| `failOnSharedEditors` | No | `boolean` | — | When true, upgrade shared-editor conflict findings to hard errors (default false — advisory soft findings only). |
|
|
106
110
|
| `requireExplicitCrossStoryDeps` | No | `boolean` | — | When true, upgrade implicit cross-Story dependency findings to hard errors (default false — advisory soft findings only). |
|
|
107
111
|
| `failOnRegistryConflicts` | No | `boolean` | — | When true, upgrade cross-cutting registry conflict findings to hard errors (default false). |
|
|
@@ -344,6 +348,28 @@ scans `.agents/scripts/**`, `src/**`, `lib/**`, `app/**`, `packages/**` and
|
|
|
344
348
|
`exclude` drops `node_modules`, build dirs, and test files. Override `include`
|
|
345
349
|
only when the project's source layout differs.
|
|
346
350
|
|
|
351
|
+
- **`complexityGate`.** Plan-time ceremony-lite routing (Story #4683). The full
|
|
352
|
+
two-session plan/deliver ceremony buys measurable quality on capability-sized
|
|
353
|
+
work but imposes a large fixed cost premium on genuinely trivial
|
|
354
|
+
single-artifact scopes with no measured quality gain. The gate reads the
|
|
355
|
+
planning seed and emits a `complexityRoute` signal on the `/plan` context
|
|
356
|
+
envelope: `lite` collapses the plan/deliver session split and skips the
|
|
357
|
+
fresh-critic / Tech-Spec ceremony a one-artifact scope does not earn; `full`
|
|
358
|
+
keeps the whole ceremony. It is **deterministic and conservative** — `lite`
|
|
359
|
+
only when every trivial-scope signal agrees (seed ≤ `maxSeedWords` words **and**
|
|
360
|
+
≤ `maxArtifacts` enumerated items), and `full` on any doubt (empty seed, over
|
|
361
|
+
the ceiling, multi-capability enumeration, or the gate disabled). The lite path
|
|
362
|
+
**never** relaxes a non-negotiable: it still produces a Story ticket, still
|
|
363
|
+
lands via a PR to `main`, still runs every repo quality gate, and still honours
|
|
364
|
+
`rules/security-baseline.md` — those gates run in `single-story-close.js`
|
|
365
|
+
regardless of route. **Threshold + override:** `enabled` (default `true`;
|
|
366
|
+
`false` forces every seed to `full`), `maxSeedWords` (default `60`), and
|
|
367
|
+
`maxArtifacts` (default `1`). The defaults are the single source of truth on
|
|
368
|
+
`DEFAULT_COMPLEXITY_GATE` in
|
|
369
|
+
[`lib/orchestration/complexity-gate.js`](../scripts/lib/orchestration/complexity-gate.js);
|
|
370
|
+
a malformed or negative ceiling falls back to the default rather than widening
|
|
371
|
+
the lite path.
|
|
372
|
+
|
|
347
373
|
### `delivery`
|
|
348
374
|
|
|
349
375
|
- **`docsFreshness.paths`.** Files refreshed during the post-PR-merge release
|
|
@@ -327,6 +327,27 @@
|
|
|
327
327
|
"codebaseSnapshot": {
|
|
328
328
|
"$ref": "#/$defs/codebaseSnapshot"
|
|
329
329
|
},
|
|
330
|
+
"complexityGate": {
|
|
331
|
+
"type": "object",
|
|
332
|
+
"description": "Plan-time ceremony-lite complexity gate. Routes trivial single-artifact seeds onto a collapsed plan/deliver path; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables.",
|
|
333
|
+
"properties": {
|
|
334
|
+
"enabled": {
|
|
335
|
+
"type": "boolean",
|
|
336
|
+
"description": "Master switch. When false, every seed takes the full plan/deliver ceremony. Default true."
|
|
337
|
+
},
|
|
338
|
+
"maxSeedWords": {
|
|
339
|
+
"type": "integer",
|
|
340
|
+
"minimum": 0,
|
|
341
|
+
"description": "Seed prose word ceiling for the lite path. A seed above this many words is not trivial and takes the full path. Default 60."
|
|
342
|
+
},
|
|
343
|
+
"maxArtifacts": {
|
|
344
|
+
"type": "integer",
|
|
345
|
+
"minimum": 0,
|
|
346
|
+
"description": "Enumerated-artifact ceiling for the lite path. A seed enumerating more than this many candidate artifacts is multi-capability and takes the full path. Default 1."
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
"additionalProperties": false
|
|
350
|
+
},
|
|
330
351
|
"failOnSharedEditors": {
|
|
331
352
|
"type": "boolean",
|
|
332
353
|
"description": "When true, upgrade shared-editor conflict findings to hard errors (default false — advisory soft findings only)."
|
|
@@ -31,6 +31,7 @@ import fs from 'node:fs';
|
|
|
31
31
|
import { glob } from 'node:fs/promises';
|
|
32
32
|
import path from 'node:path';
|
|
33
33
|
import process from 'node:process';
|
|
34
|
+
import { pathToFileURL } from 'node:url';
|
|
34
35
|
import { parseArgs } from 'node:util';
|
|
35
36
|
import { buildStoryBody } from './lib/audit-to-stories/build-story-body.js';
|
|
36
37
|
import { classifyGroupsAgainstGitHub } from './lib/audit-to-stories/dedupe-against-github.js';
|
|
@@ -85,12 +86,30 @@ function tallyBySeverity(findings) {
|
|
|
85
86
|
return t;
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Test-only seam: when `AUDIT_TO_STORIES_PROVIDER_FIXTURE` names a module, load
|
|
91
|
+
* its default export as the dedup provider (ports) instead of the live GitHub
|
|
92
|
+
* provider. This lets the soft-fail contract be exercised end-to-end through
|
|
93
|
+
* the real `--scan` CLI with a search port that fails for a subset of groups
|
|
94
|
+
* (Story #4678, AC-8), with no network. Returns null when the env var is unset.
|
|
95
|
+
*
|
|
96
|
+
* @returns {Promise<object|null>}
|
|
97
|
+
*/
|
|
98
|
+
async function loadFixtureProvider() {
|
|
99
|
+
const fixturePath = process.env.AUDIT_TO_STORIES_PROVIDER_FIXTURE;
|
|
100
|
+
if (!fixturePath) return null;
|
|
101
|
+
const mod = await import(pathToFileURL(fixturePath).href);
|
|
102
|
+
return mod.default ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
88
105
|
async function loadProvider({ createProviderImpl, resolveConfigImpl } = {}) {
|
|
89
106
|
// The provider is optional — when missing, the dedupe step emits a
|
|
90
107
|
// create-only classification and the workflow operator is informed. The
|
|
91
108
|
// `createProviderImpl` / `resolveConfigImpl` seams let a contract test drive
|
|
92
109
|
// this exact adapter (fingerprint + semantic-candidate ports) with an
|
|
93
110
|
// in-memory issue store instead of the live GitHub provider.
|
|
111
|
+
const fixture = await loadFixtureProvider();
|
|
112
|
+
if (fixture) return fixture;
|
|
94
113
|
try {
|
|
95
114
|
const resolveConfig =
|
|
96
115
|
resolveConfigImpl ??
|
|
@@ -180,6 +199,31 @@ function dedupSkippedWarning(reason) {
|
|
|
180
199
|
);
|
|
181
200
|
}
|
|
182
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Render the loud, operator-visible warning emitted when Phase 6 dedup ran but
|
|
204
|
+
* one or more groups' lookups could not complete (an HTTP 422, or a rate limit
|
|
205
|
+
* still exhausted after the endpoint budget's cooldown). Those groups degrade
|
|
206
|
+
* to `create` rather than aborting the whole scan (Story #4678); this warning
|
|
207
|
+
* names each affected group so the operator knows exactly which to check by
|
|
208
|
+
* hand. Mirrors the `dedupSkippedWarning` shape so a partially-checked plan
|
|
209
|
+
* reads as clearly as a wholly-unchecked one.
|
|
210
|
+
*
|
|
211
|
+
* Pure: returns the message string so `buildPlan` owns the single `Logger.warn`
|
|
212
|
+
* write site (stderr) and the text stays unit-testable.
|
|
213
|
+
*
|
|
214
|
+
* @param {Array<{ group: string, reason: string }>} entries
|
|
215
|
+
* @returns {string}
|
|
216
|
+
*/
|
|
217
|
+
function dedupDegradedWarning(entries) {
|
|
218
|
+
const lines = (entries ?? []).map((e) => ` - ${e.group}: ${e.reason}`);
|
|
219
|
+
return (
|
|
220
|
+
`dedup degraded for ${lines.length} group(s): their GitHub lookup could ` +
|
|
221
|
+
'not complete, so they are classified "create" WITHOUT a dedup check. A ' +
|
|
222
|
+
'run that creates Stories from this plan may open duplicates of these ' +
|
|
223
|
+
`groups — verify each by hand before opening:\n${lines.join('\n')}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
183
227
|
async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
|
|
184
228
|
const reportPaths = await collectReportPaths(pattern ?? DEFAULT_GLOB);
|
|
185
229
|
if (reportPaths.length === 0) {
|
|
@@ -227,6 +271,12 @@ async function buildPlan({ glob: pattern, severity, useProvider, ledger }) {
|
|
|
227
271
|
classifications = result.classifications;
|
|
228
272
|
summary = result.summary;
|
|
229
273
|
dedupApplied = true;
|
|
274
|
+
// A partially-checked plan is a useful result — warn loudly (stderr, so
|
|
275
|
+
// the --scan JSON on stdout stays clean) naming the groups that degraded
|
|
276
|
+
// to create because their lookup could not complete (Story #4678).
|
|
277
|
+
if (summary.dedupDegraded?.count > 0) {
|
|
278
|
+
Logger.warn(dedupDegradedWarning(summary.dedupDegraded.groups));
|
|
279
|
+
}
|
|
230
280
|
} else {
|
|
231
281
|
// The provider could not resolve a searchIssues port — the dedup gate
|
|
232
282
|
// is silently a no-op without this. Surface it loudly (stderr, so the
|
|
@@ -488,6 +538,7 @@ export const __testing = {
|
|
|
488
538
|
buildPlan,
|
|
489
539
|
loadProvider,
|
|
490
540
|
dedupSkippedWarning,
|
|
541
|
+
dedupDegradedWarning,
|
|
491
542
|
buildAndGateStories,
|
|
492
543
|
runAuto,
|
|
493
544
|
resolveSeverityFloor,
|
|
@@ -36,6 +36,92 @@ import { toCanonicalFinding } from './finding-adapter.js';
|
|
|
36
36
|
* @property {string[]} matchedFingerprints — full sha1 list that triggered the match.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Render a short, operator-legible reason from a dedup-lookup failure. Pure —
|
|
41
|
+
* no imports, no I/O — so the module stays pure orchestration (Story #4678).
|
|
42
|
+
* @param {unknown} err
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
function describeDegradeReason(err) {
|
|
46
|
+
const status = err?.status;
|
|
47
|
+
const message = err?.message ?? String(err);
|
|
48
|
+
if (status === 422 || /\b422\b/.test(message)) {
|
|
49
|
+
return 'search query rejected (HTTP 422)';
|
|
50
|
+
}
|
|
51
|
+
if (/rate limit/i.test(message)) {
|
|
52
|
+
return 'rate limit still exhausted after cooldown';
|
|
53
|
+
}
|
|
54
|
+
return `dedup lookup failed: ${message}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Stable operator-facing label for a group in a degrade report.
|
|
59
|
+
* @param {object} group
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function groupLabel(group) {
|
|
63
|
+
return group?.groupKey ?? group?.title ?? '(unlabelled group)';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Route every finding in one group and fold the per-finding decisions up to a
|
|
68
|
+
* group action. Extracted so the top-level loop can wrap it in one try/catch:
|
|
69
|
+
* a search failure that survives the endpoint budget (an HTTP 422, or a rate
|
|
70
|
+
* limit still exhausted after the cooldown) throws out of here and is caught
|
|
71
|
+
* once per group rather than aborting the whole scan (Story #4678).
|
|
72
|
+
*
|
|
73
|
+
* @param {object} group
|
|
74
|
+
* @param {object} routing — `{ searchIssues, semanticPort, routeOptions }`.
|
|
75
|
+
* @returns {Promise<{ action: string, matchedIssues: Array, matchedFingerprints: string[] }>}
|
|
76
|
+
*/
|
|
77
|
+
async function classifyOneGroup(
|
|
78
|
+
group,
|
|
79
|
+
{ searchIssues, semanticPort, routeOptions },
|
|
80
|
+
) {
|
|
81
|
+
const findings = group.findings ?? [];
|
|
82
|
+
const matchedIssues = [];
|
|
83
|
+
const matchedFingerprints = [];
|
|
84
|
+
let sawOpen = false;
|
|
85
|
+
let sawClosed = false;
|
|
86
|
+
|
|
87
|
+
for (const finding of findings) {
|
|
88
|
+
const sha = finding?.fingerprint?.full;
|
|
89
|
+
if (typeof sha !== 'string' || sha.length !== 40) continue;
|
|
90
|
+
|
|
91
|
+
const canonical = toCanonicalFinding(finding);
|
|
92
|
+
const { decision, matchedIssue, fingerprint } = await routeFinding(
|
|
93
|
+
canonical,
|
|
94
|
+
semanticPort
|
|
95
|
+
? { searchIssues, searchCandidates: () => semanticPort(canonical) }
|
|
96
|
+
: { searchIssues },
|
|
97
|
+
routeOptions,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
if (decision === 'new') continue;
|
|
101
|
+
|
|
102
|
+
if (matchedIssue) {
|
|
103
|
+
matchedIssues.push({
|
|
104
|
+
number: matchedIssue.number,
|
|
105
|
+
state: matchedIssue.state,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
if (!matchedFingerprints.includes(fingerprint)) {
|
|
109
|
+
matchedFingerprints.push(fingerprint);
|
|
110
|
+
}
|
|
111
|
+
if (decision === 'update-existing' || decision === 'duplicate') {
|
|
112
|
+
sawOpen = true;
|
|
113
|
+
} else if (decision === 'regression-of-closed') {
|
|
114
|
+
sawClosed = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let action = 'create';
|
|
119
|
+
if (sawOpen) action = 'skip-open';
|
|
120
|
+
else if (sawClosed) action = 'skip-reoccurring';
|
|
121
|
+
|
|
122
|
+
return { action, matchedIssues, matchedFingerprints };
|
|
123
|
+
}
|
|
124
|
+
|
|
39
125
|
/**
|
|
40
126
|
* @param {object} params
|
|
41
127
|
* @param {Array<object>} params.groups — output of `groupFindings`.
|
|
@@ -44,12 +130,18 @@ import { toCanonicalFinding } from './finding-adapter.js';
|
|
|
44
130
|
* Optional meaning-first candidate search (production: `semantic-issue-search.js`).
|
|
45
131
|
* When supplied, routing runs the Stage-1 semantic pass and opts into
|
|
46
132
|
* location-based semantic-key confirmation.
|
|
47
|
-
* @
|
|
133
|
+
* @param {(entry: { group: object, reason: string }) => void} [params.onDegraded]
|
|
134
|
+
* Optional sink notified once per group whose dedup lookup could not complete
|
|
135
|
+
* (Story #4678). The group is then classified `create` — a soft-fail, never
|
|
136
|
+
* fatal. Pure orchestration: this module performs no network I/O and swallows
|
|
137
|
+
* no failure silently.
|
|
138
|
+
* @returns {Promise<{ classifications: GroupClassification[], summary: { create: number, skipOpen: number, skipReoccurring: number, dedupDegraded: { count: number, groups: Array<{ group: string, reason: string }> } } }>}
|
|
48
139
|
*/
|
|
49
140
|
export async function classifyGroupsAgainstGitHub({
|
|
50
141
|
groups,
|
|
51
142
|
provider,
|
|
52
143
|
searchCandidates,
|
|
144
|
+
onDegraded,
|
|
53
145
|
}) {
|
|
54
146
|
if (!Array.isArray(groups)) {
|
|
55
147
|
throw new Error('classifyGroupsAgainstGitHub: groups must be an array');
|
|
@@ -67,67 +159,40 @@ export async function classifyGroupsAgainstGitHub({
|
|
|
67
159
|
const searchIssues = (sha) => provider.findIssuesByFingerprint(sha);
|
|
68
160
|
const semanticPort =
|
|
69
161
|
typeof searchCandidates === 'function' ? searchCandidates : undefined;
|
|
70
|
-
const
|
|
162
|
+
const routing = {
|
|
163
|
+
searchIssues,
|
|
164
|
+
semanticPort,
|
|
165
|
+
routeOptions: { semanticKeyConfirm: Boolean(semanticPort) },
|
|
166
|
+
};
|
|
71
167
|
|
|
72
168
|
const classifications = [];
|
|
73
|
-
const summary = {
|
|
169
|
+
const summary = {
|
|
170
|
+
create: 0,
|
|
171
|
+
skipOpen: 0,
|
|
172
|
+
skipReoccurring: 0,
|
|
173
|
+
dedupDegraded: { count: 0, groups: [] },
|
|
174
|
+
};
|
|
74
175
|
|
|
75
176
|
for (const group of groups) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const { decision, matchedIssue, fingerprint } = await routeFinding(
|
|
89
|
-
canonical,
|
|
90
|
-
semanticPort
|
|
91
|
-
? { searchIssues, searchCandidates: () => semanticPort(canonical) }
|
|
92
|
-
: { searchIssues },
|
|
93
|
-
routeOptions,
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
if (decision === 'new') continue;
|
|
97
|
-
|
|
98
|
-
if (matchedIssue) {
|
|
99
|
-
matchedIssues.push({
|
|
100
|
-
number: matchedIssue.number,
|
|
101
|
-
state: matchedIssue.state,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
if (!matchedFingerprints.includes(fingerprint)) {
|
|
105
|
-
matchedFingerprints.push(fingerprint);
|
|
106
|
-
}
|
|
107
|
-
if (decision === 'update-existing' || decision === 'duplicate') {
|
|
108
|
-
sawOpen = true;
|
|
109
|
-
} else if (decision === 'regression-of-closed') {
|
|
110
|
-
sawClosed = true;
|
|
111
|
-
}
|
|
177
|
+
let result;
|
|
178
|
+
try {
|
|
179
|
+
result = await classifyOneGroup(group, routing);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// A dedup lookup that cannot complete degrades this group to `create`
|
|
182
|
+
// with a recorded reason — never aborts the whole scan.
|
|
183
|
+
const reason = describeDegradeReason(err);
|
|
184
|
+
const entry = { group: groupLabel(group), reason };
|
|
185
|
+
summary.dedupDegraded.count += 1;
|
|
186
|
+
summary.dedupDegraded.groups.push(entry);
|
|
187
|
+
if (typeof onDegraded === 'function') onDegraded({ group, reason });
|
|
188
|
+
result = { action: 'create', matchedIssues: [], matchedFingerprints: [] };
|
|
112
189
|
}
|
|
113
190
|
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
summary.skipOpen += 1;
|
|
118
|
-
} else if (sawClosed) {
|
|
119
|
-
action = 'skip-reoccurring';
|
|
120
|
-
summary.skipReoccurring += 1;
|
|
121
|
-
} else {
|
|
122
|
-
summary.create += 1;
|
|
123
|
-
}
|
|
191
|
+
if (result.action === 'skip-open') summary.skipOpen += 1;
|
|
192
|
+
else if (result.action === 'skip-reoccurring') summary.skipReoccurring += 1;
|
|
193
|
+
else summary.create += 1;
|
|
124
194
|
|
|
125
|
-
classifications.push({
|
|
126
|
-
group,
|
|
127
|
-
action,
|
|
128
|
-
matchedIssues,
|
|
129
|
-
matchedFingerprints,
|
|
130
|
-
});
|
|
195
|
+
classifications.push({ group, ...result });
|
|
131
196
|
}
|
|
132
197
|
|
|
133
198
|
return { classifications, summary };
|
|
@@ -278,6 +278,38 @@ const PLANNING_SCHEMA = {
|
|
|
278
278
|
properties: {
|
|
279
279
|
riskHeuristics: LIST_OR_EXTENDER_OF_STRINGS,
|
|
280
280
|
codebaseSnapshot: CODEBASE_SNAPSHOT_SCHEMA,
|
|
281
|
+
// Story #4683 — plan-time ceremony-lite complexity gate. Routes a trivial
|
|
282
|
+
// single-artifact seed onto a collapsed plan/deliver path while a
|
|
283
|
+
// multi-capability seed keeps the full ceremony. Deterministic and
|
|
284
|
+
// conservative (full on any doubt); the lite path never relaxes a
|
|
285
|
+
// non-negotiable (Story ticket, PR-to-main, repo gates, security baseline).
|
|
286
|
+
// Defaults live on DEFAULT_COMPLEXITY_GATE in
|
|
287
|
+
// `lib/orchestration/complexity-gate.js`.
|
|
288
|
+
complexityGate: {
|
|
289
|
+
type: 'object',
|
|
290
|
+
description:
|
|
291
|
+
'Plan-time ceremony-lite complexity gate. Routes trivial single-artifact seeds onto a collapsed plan/deliver path; conservative (full on any doubt). Never relaxes the Story-ticket / PR-to-main / repo-gates / security-baseline non-negotiables.',
|
|
292
|
+
properties: {
|
|
293
|
+
enabled: {
|
|
294
|
+
type: 'boolean',
|
|
295
|
+
description:
|
|
296
|
+
'Master switch. When false, every seed takes the full plan/deliver ceremony. Default true.',
|
|
297
|
+
},
|
|
298
|
+
maxSeedWords: {
|
|
299
|
+
type: 'integer',
|
|
300
|
+
minimum: 0,
|
|
301
|
+
description:
|
|
302
|
+
'Seed prose word ceiling for the lite path. A seed above this many words is not trivial and takes the full path. Default 60.',
|
|
303
|
+
},
|
|
304
|
+
maxArtifacts: {
|
|
305
|
+
type: 'integer',
|
|
306
|
+
minimum: 0,
|
|
307
|
+
description:
|
|
308
|
+
'Enumerated-artifact ceiling for the lite path. A seed enumerating more than this many candidate artifacts is multi-capability and takes the full path. Default 1.',
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
additionalProperties: false,
|
|
312
|
+
},
|
|
281
313
|
// Cross-Story conflict-finding severity gates. Off by default so
|
|
282
314
|
// existing repos keep advisory-only behaviour; flipping either to
|
|
283
315
|
// `true` upgrades the matching finding class to `'hard'`, which routes
|
|
@@ -23,6 +23,13 @@
|
|
|
23
23
|
|
|
24
24
|
const DEFAULT_LIMIT = 25;
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Character budget for the query {@link buildQuery} emits. Default 200 leaves
|
|
28
|
+
* headroom under GitHub Search's 256-char limit for the `repo:` / `type:`
|
|
29
|
+
* qualifiers `searchIssues` appends (Story #4678).
|
|
30
|
+
*/
|
|
31
|
+
const DEFAULT_QUERY_BUDGET = 200;
|
|
32
|
+
|
|
26
33
|
/**
|
|
27
34
|
* Normalise a free-text string for token comparison: lowercased, trimmed,
|
|
28
35
|
* punctuation collapsed to spaces.
|
|
@@ -68,18 +75,49 @@ function jaccard(a, b) {
|
|
|
68
75
|
return union === 0 ? 0 : intersection / union;
|
|
69
76
|
}
|
|
70
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The trailing path segment of a slash-or-backslash-delimited path. A deep
|
|
80
|
+
* `primaryFile` like `src/very/deep/module.js` contributes only `module.js`
|
|
81
|
+
* to the query — the basename carries the discriminating signal while the
|
|
82
|
+
* mangled path spends the character budget for nothing (Story #4678).
|
|
83
|
+
* @param {unknown} value
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function basename(value) {
|
|
87
|
+
const str = String(value ?? '');
|
|
88
|
+
const cut = str.split(/[\\/]/).filter(Boolean);
|
|
89
|
+
return cut.length > 0 ? cut[cut.length - 1] : '';
|
|
90
|
+
}
|
|
91
|
+
|
|
71
92
|
/**
|
|
72
93
|
* Build the search query text for a finding. The title carries the strongest
|
|
73
|
-
* signal; area and primaryFile sharpen it. This is the text
|
|
74
|
-
* (production) full-text search port and the local relevance scorer
|
|
94
|
+
* signal; area and the primaryFile **basename** sharpen it. This is the text
|
|
95
|
+
* both the (production) full-text search port and the local relevance scorer
|
|
96
|
+
* key on. The result is filled highest-signal-first (title, then area, then
|
|
97
|
+
* basename) up to `budget` characters on a whole-token boundary so a long title
|
|
98
|
+
* over a deep path never blows GitHub Search's length limit (Story #4678).
|
|
75
99
|
* @param {object} finding
|
|
100
|
+
* @param {object} [options]
|
|
101
|
+
* @param {number} [options.budget] — max query length in characters.
|
|
76
102
|
* @returns {string}
|
|
77
103
|
*/
|
|
78
|
-
export function buildQuery(finding) {
|
|
79
|
-
|
|
104
|
+
export function buildQuery(finding, { budget = DEFAULT_QUERY_BUDGET } = {}) {
|
|
105
|
+
const tokens = [finding?.title, finding?.area, basename(finding?.primaryFile)]
|
|
80
106
|
.map((v) => normaliseText(v))
|
|
81
107
|
.filter((v) => v.length > 0)
|
|
82
|
-
.join(' ')
|
|
108
|
+
.join(' ')
|
|
109
|
+
.split(' ')
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
|
|
112
|
+
const kept = [];
|
|
113
|
+
let length = 0;
|
|
114
|
+
for (const token of tokens) {
|
|
115
|
+
const cost = kept.length === 0 ? token.length : token.length + 1;
|
|
116
|
+
if (length + cost > budget) break;
|
|
117
|
+
kept.push(token);
|
|
118
|
+
length += cost;
|
|
119
|
+
}
|
|
120
|
+
return kept.join(' ');
|
|
83
121
|
}
|
|
84
122
|
|
|
85
123
|
/**
|
|
@@ -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
|
+
}
|