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.
Files changed (27) hide show
  1. package/.agents/docs/configuration.md +26 -0
  2. package/.agents/schemas/agentrc.schema.json +21 -0
  3. package/.agents/scripts/audit-to-stories.js +51 -0
  4. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
  5. package/.agents/scripts/lib/config-settings-schema.js +32 -0
  6. package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
  7. package/.agents/scripts/lib/observability/terse-result.js +114 -0
  8. package/.agents/scripts/lib/orchestration/complexity-gate.js +207 -0
  9. package/.agents/scripts/lib/orchestration/plan-context.js +3 -0
  10. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
  11. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
  12. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
  13. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
  14. package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
  15. package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
  16. package/.agents/scripts/lib/story-body/story-body.js +18 -2
  17. package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -0
  18. package/.agents/scripts/providers/github/issues.js +54 -7
  19. package/.agents/scripts/providers/github/search-budget.js +124 -0
  20. package/.agents/scripts/providers/github/search-query.js +71 -0
  21. package/.agents/scripts/single-story-confirm-merge.js +14 -5
  22. package/.agents/scripts/single-story-init.js +19 -3
  23. package/.agents/scripts/sync-branch-from-base.js +9 -3
  24. package/.agents/workflows/helpers/deliver-story.md +10 -0
  25. package/.agents/workflows/plan.md +27 -2
  26. package/docs/CHANGELOG.md +20 -0
  27. 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
- * @returns {Promise<{ classifications: GroupClassification[], summary: { create: number, skipOpen: number, skipReoccurring: number } }>}
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 routeOptions = { semanticKeyConfirm: Boolean(semanticPort) };
162
+ const routing = {
163
+ searchIssues,
164
+ semanticPort,
165
+ routeOptions: { semanticKeyConfirm: Boolean(semanticPort) },
166
+ };
71
167
 
72
168
  const classifications = [];
73
- const summary = { create: 0, skipOpen: 0, skipReoccurring: 0 };
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
- const findings = group.findings ?? [];
77
-
78
- const matchedIssues = [];
79
- const matchedFingerprints = [];
80
- let sawOpen = false;
81
- let sawClosed = false;
82
-
83
- for (const finding of findings) {
84
- const sha = finding?.fingerprint?.full;
85
- if (typeof sha !== 'string' || sha.length !== 40) continue;
86
-
87
- const canonical = toCanonicalFinding(finding);
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
- let action = 'create';
115
- if (sawOpen) {
116
- action = 'skip-open';
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 both the
74
- * (production) full-text search port and the local relevance scorer key on.
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
- return [finding?.title, finding?.area, finding?.primaryFile]
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
+ }