mandrel 2.7.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 (95) hide show
  1. package/.agents/README.md +104 -330
  2. package/.agents/agents/auditor.md +135 -0
  3. package/.agents/agents/plan-critic.md +80 -0
  4. package/.agents/audit-checklists/dependencies.md +7 -0
  5. package/.agents/audit-checklists/documentation.md +1 -0
  6. package/.agents/docs/SDLC.md +69 -162
  7. package/.agents/docs/configuration.md +159 -499
  8. package/.agents/docs/quality-gates.md +59 -180
  9. package/.agents/instructions.md +170 -295
  10. package/.agents/rules/changelog-style.md +8 -66
  11. package/.agents/rules/ci-remediation.md +65 -124
  12. package/.agents/rules/gherkin-standards.md +10 -31
  13. package/.agents/rules/git-conventions-reference.md +28 -61
  14. package/.agents/rules/git-conventions.md +1 -1
  15. package/.agents/rules/orchestration-error-handling.md +5 -15
  16. package/.agents/rules/security-baseline.md +7 -13
  17. package/.agents/rules/shell-conventions.md +4 -13
  18. package/.agents/rules/test-seams.md +2 -2
  19. package/.agents/rules/testing-standards.md +7 -17
  20. package/.agents/schemas/agentrc.schema.json +21 -0
  21. package/.agents/scripts/audit-to-stories.js +51 -0
  22. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +120 -55
  23. package/.agents/scripts/lib/config-settings-schema.js +32 -0
  24. package/.agents/scripts/lib/findings/semantic-issue-search.js +43 -5
  25. package/.agents/scripts/lib/observability/terse-result.js +114 -0
  26. package/.agents/scripts/lib/orchestration/complexity-gate.js +207 -0
  27. package/.agents/scripts/lib/orchestration/plan-context.js +3 -0
  28. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +221 -8
  29. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +55 -14
  30. package/.agents/scripts/lib/orchestration/story-close/emit-blocked.js +9 -3
  31. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +4 -1
  32. package/.agents/scripts/lib/orchestration/task-body-validator.js +13 -40
  33. package/.agents/scripts/lib/story-body/body-format-lints.js +215 -0
  34. package/.agents/scripts/lib/story-body/story-body.js +18 -2
  35. package/.agents/scripts/lib/templates/decomposer-prompts.js +16 -0
  36. package/.agents/scripts/providers/github/issues.js +54 -7
  37. package/.agents/scripts/providers/github/search-budget.js +124 -0
  38. package/.agents/scripts/providers/github/search-query.js +71 -0
  39. package/.agents/scripts/single-story-confirm-merge.js +14 -5
  40. package/.agents/scripts/single-story-init.js +19 -3
  41. package/.agents/scripts/sync-branch-from-base.js +9 -3
  42. package/.agents/skills/core/api-and-interface-design/SKILL.md +23 -297
  43. package/.agents/skills/core/api-and-interface-design/reference.md +76 -0
  44. package/.agents/skills/core/browser-testing-with-devtools/SKILL.md +20 -327
  45. package/.agents/skills/core/browser-testing-with-devtools/reference.md +74 -0
  46. package/.agents/skills/core/code-review-and-quality/reference.md +16 -398
  47. package/.agents/skills/core/debugging-and-error-recovery/reference.md +14 -281
  48. package/.agents/skills/core/documentation-and-adrs/reference.md +42 -338
  49. package/.agents/skills/core/git-workflow-and-versioning/SKILL.md +28 -326
  50. package/.agents/skills/core/idea-refinement/SKILL.md +67 -193
  51. package/.agents/skills/core/security-and-hardening/SKILL.md +15 -31
  52. package/.agents/skills/core/security-and-hardening/reference.md +15 -273
  53. package/.agents/skills/skills.index.json +5 -5
  54. package/.agents/skills/stack/qa/gherkin-authoring/SKILL.md +16 -222
  55. package/.agents/skills/stack/qa/gherkin-authoring/reference.md +157 -0
  56. package/.agents/skills/stack/qa/playwright/SKILL.md +0 -29
  57. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +19 -23
  58. package/.agents/skills/stack/qa/qa-harness/SKILL.md +35 -53
  59. package/.agents/skills/stack/qa/vitest/SKILL.md +0 -29
  60. package/.agents/workflows/audit-accessibility.md +34 -108
  61. package/.agents/workflows/audit-architecture.md +35 -123
  62. package/.agents/workflows/audit-clean-code.md +19 -78
  63. package/.agents/workflows/audit-data-model.md +32 -100
  64. package/.agents/workflows/audit-dependencies.md +47 -111
  65. package/.agents/workflows/audit-devops.md +16 -83
  66. package/.agents/workflows/audit-documentation.md +46 -93
  67. package/.agents/workflows/audit-navigability.md +26 -80
  68. package/.agents/workflows/audit-performance.md +40 -106
  69. package/.agents/workflows/audit-privacy.md +17 -80
  70. package/.agents/workflows/audit-quality.md +35 -99
  71. package/.agents/workflows/audit-security.md +20 -78
  72. package/.agents/workflows/audit-seo.md +20 -98
  73. package/.agents/workflows/audit-sre.md +20 -88
  74. package/.agents/workflows/audit-to-stories.md +1 -8
  75. package/.agents/workflows/audit-ux-ui.md +17 -80
  76. package/.agents/workflows/deliver.md +54 -9
  77. package/.agents/workflows/git-cleanup.md +50 -275
  78. package/.agents/workflows/helpers/audit-lens-core.md +230 -0
  79. package/.agents/workflows/helpers/code-review.md +11 -23
  80. package/.agents/workflows/helpers/deliver-story-reference.md +114 -17
  81. package/.agents/workflows/helpers/deliver-story.md +36 -186
  82. package/.agents/workflows/helpers/qa-core.md +174 -0
  83. package/.agents/workflows/helpers/qa-run-scenario-reference.md +35 -0
  84. package/.agents/workflows/helpers/qa-run-scenario.md +11 -25
  85. package/.agents/workflows/helpers/worktree-lifecycle.md +6 -67
  86. package/.agents/workflows/mandrel-update.md +7 -13
  87. package/.agents/workflows/plan.md +44 -17
  88. package/.agents/workflows/qa-assist.md +140 -269
  89. package/.agents/workflows/qa-explore.md +125 -316
  90. package/.agents/workflows/qa-run.md +180 -380
  91. package/docs/CHANGELOG.md +30 -0
  92. package/package.json +1 -1
  93. package/.agents/workflows/helpers/audit-dual-path.md +0 -59
  94. package/.agents/workflows/helpers/audit-self-check.md +0 -70
  95. package/.agents/workflows/helpers/audit-severity-scale.md +0 -19
@@ -52,6 +52,10 @@
52
52
  * at a time.
53
53
  */
54
54
 
55
+ import {
56
+ suggestPathEntryFix,
57
+ suggestVerifyFix,
58
+ } from '../story-body/body-format-lints.js';
55
59
  import {
56
60
  parse as parseStoryBody,
57
61
  StoryBodyParseError,
@@ -73,44 +77,6 @@ export const VERIFY_TIER_VALUES = Object.freeze([
73
77
  'validate',
74
78
  ]);
75
79
 
76
- const PATH_LIKE_RE = /[/.][\w@\-./*]+|\*\*?\/?\*?\.\w+|[a-z][\w-]*\/[\w-./*]+/i;
77
- const VAGUE_VERBS = [
78
- 'clean up',
79
- 'refactor',
80
- 'improve',
81
- 'polish',
82
- 'tighten',
83
- 'tidy',
84
- 'simplify',
85
- ];
86
-
87
- /**
88
- * @param {string} bullet
89
- * @returns {boolean}
90
- */
91
- function bulletNamesPath(bullet) {
92
- const colonIdx = bullet.indexOf(':');
93
- if (colonIdx <= 0) return PATH_LIKE_RE.test(bullet);
94
- const head = bullet.slice(0, colonIdx);
95
- // The conventional shape is "<path>: <verb> <object>" — head is the path.
96
- return PATH_LIKE_RE.test(head) || PATH_LIKE_RE.test(bullet);
97
- }
98
-
99
- /**
100
- * @param {string} bullet
101
- * @returns {string|null} reason if the bullet uses a vague verb without a named target, else null.
102
- */
103
- function vagueVerbWithoutTarget(bullet) {
104
- const lower = bullet.toLowerCase();
105
- for (const verb of VAGUE_VERBS) {
106
- if (!lower.includes(verb)) continue;
107
- if (!bulletNamesPath(bullet)) {
108
- return verb;
109
- }
110
- }
111
- return null;
112
- }
113
-
114
80
  /**
115
81
  * Predicate: should the validator skip this ticket entirely? Skip when:
116
82
  * - it is not a Story (only `type: 'story'` tickets are validated here),
@@ -297,8 +263,13 @@ function collectChangesErrors(prefix, rawChanges) {
297
263
  }
298
264
  for (const entry of changes) {
299
265
  if (typeof entry === 'string') {
266
+ const fix = suggestPathEntryFix(entry);
267
+ const fixIt =
268
+ fix === null
269
+ ? ''
270
+ : ` Suggested fix: ${fix} (adjust the assumption to creates|deletes if this is a new file or a removal).`;
300
271
  errors.push(
301
- `${prefix}: body.changes entry must be a { path, assumption } object; plain string bullets are no longer accepted: "${entry}".`,
272
+ `${prefix}: body.changes entry must be a { path, assumption } object; plain string bullets are no longer accepted: "${entry}".${fixIt}`,
302
273
  );
303
274
  continue;
304
275
  }
@@ -385,8 +356,10 @@ function collectVerifyErrors(prefix, rawVerify) {
385
356
  continue;
386
357
  }
387
358
  if (!VERIFY_TIER_RE.test(v)) {
359
+ const fix = suggestVerifyFix(v);
360
+ const fixIt = fix === null ? '' : ` Suggested fix: "${fix}".`;
388
361
  errors.push(
389
- `${prefix}: body.verify entry must end with a tier in parentheses — one of (${VERIFY_TIER_VALUES.join('|')}). Got: "${v}".`,
362
+ `${prefix}: body.verify entry must end with a tier in parentheses — one of (${VERIFY_TIER_VALUES.join('|')}). Got: "${v}".${fixIt}`,
390
363
  );
391
364
  }
392
365
  }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * body-format-lints.js — SSOT for the deterministic body-format lints that can
3
+ * REJECT an authored Story body at persist time, plus the mechanical auto-fix
4
+ * inference a failing dry-run surfaces (Story #4684).
5
+ *
6
+ * The problem this closes: deterministic format rules (structured `## Changes`
7
+ * bullet shape, verify-tier suffixes) used to be discovered only as persist
8
+ * dry-run failures — every miss cost a full re-author round-trip at
9
+ * resident-context prices. This module is the single home for two remedies:
10
+ *
11
+ * 1. `BODY_FORMAT_LINTS` — the enumerated rejecting lints, each carrying a
12
+ * concrete good/bad example. `templates/decomposer-prompts.js` renders
13
+ * them into the story-author system prompt so the first draft is
14
+ * lint-clean by construction; a test enumerates the registry against the
15
+ * rendered prompt (Story #4684 AC-1).
16
+ * 2. `suggestPathEntryFix` / `suggestVerifyFix` — the mechanical rewrites.
17
+ * `story-body.js` (`parsePathEntry`) and `task-body-validator.js`
18
+ * (`collectChangesErrors` / `collectVerifyErrors`) call them so a failing
19
+ * lint emits the corrected form ready to paste rather than a bare reject
20
+ * (AC-2).
21
+ *
22
+ * Import hygiene: this module imports only the cycle-free
23
+ * `file-assumption-enum.js` leaf. It must NOT import `story-body.js` or
24
+ * `task-body-validator.js` — both import this module, and either back-edge
25
+ * would introduce a cycle (see the note in `file-assumption-enum.js`).
26
+ */
27
+
28
+ import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
29
+
30
+ /**
31
+ * Canonical testing-tier vocabulary an inferred verify suffix may name. Kept in
32
+ * sync with `task-body-validator.js`'s `VERIFY_TIER_VALUES` by a test rather
33
+ * than an import (importing that module here would create a cycle).
34
+ *
35
+ * @type {readonly ['unit','contract','e2e','validate']}
36
+ */
37
+ const INFERABLE_VERIFY_TIERS = Object.freeze([
38
+ 'unit',
39
+ 'contract',
40
+ 'e2e',
41
+ 'validate',
42
+ ]);
43
+
44
+ /**
45
+ * The default assumption a mechanical `## Changes` auto-fix proposes. Most
46
+ * bare-path bullets an author drops are in-place edits, so `refactors-existing`
47
+ * is the safe default — the fix-it text tells the author to switch it to
48
+ * `creates` / `deletes` when the edit is net-new or a removal.
49
+ */
50
+ const DEFAULT_SUGGESTED_ASSUMPTION = 'refactors-existing';
51
+
52
+ // A token that looks like a file path / glob / module id: it carries a `/` or a
53
+ // `.`-separated segment. Deliberately loose — the suggestion is best-effort, and
54
+ // a false positive only produces an unhelpful (still-valid) fix-it string.
55
+ const PATH_LIKE_RE = /[\w@*-]*[/.][\w@./*-]+/;
56
+
57
+ /**
58
+ * Infer the testing tier a bare `verify[]` command implies, when it is
59
+ * unambiguous. Returns `null` when no confident inference is possible (the
60
+ * author must then choose the tier themselves — the lint still fires, just
61
+ * without a fix-it).
62
+ *
63
+ * @param {unknown} command
64
+ * @returns {'unit'|'contract'|'e2e'|'validate'|null}
65
+ */
66
+ function inferVerifyTier(command) {
67
+ if (typeof command !== 'string') return null;
68
+ const c = command.toLowerCase();
69
+ if (/\bvalidate\b/.test(c)) return 'validate';
70
+ if (/playwright|\.spec\.|\be2e\b/.test(c)) return 'e2e';
71
+ if (/\.test\.|node --test|node:test|\bvitest\b|\bjest\b/.test(c)) {
72
+ return 'unit';
73
+ }
74
+ if (/\bcontract\b/.test(c)) return 'contract';
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Propose the corrected form of a `verify[]` entry that is missing its tier
80
+ * suffix, when the tier is inferable from the command. Returns `null` when the
81
+ * entry is a `manual:` escape, is empty, or the tier cannot be inferred.
82
+ *
83
+ * @param {unknown} entry
84
+ * @returns {string|null} e.g. `"npm run validate (validate)"`.
85
+ */
86
+ export function suggestVerifyFix(entry) {
87
+ if (typeof entry !== 'string') return null;
88
+ const trimmed = entry.trim();
89
+ if (trimmed === '' || trimmed.startsWith('manual:')) return null;
90
+ const tier = inferVerifyTier(trimmed);
91
+ if (tier === null) return null;
92
+ // Drop any trailing (…) — a wrong/partial tier suffix — before appending the
93
+ // inferred one, so `npm test (smoke)` becomes `npm test (unit)` not a double.
94
+ const base = trimmed.replace(/\s*\([^)]*\)\s*$/, '').trim();
95
+ if (base === '') return null;
96
+ return `${base} (${tier})`;
97
+ }
98
+
99
+ /**
100
+ * Propose the canonical `{ path, assumption }` object form for a `## Changes` /
101
+ * `## References` bullet an author wrote as a bare path string (or a humanized
102
+ * bullet with a bad assumption). Returns `null` when no path-shaped token can
103
+ * be salvaged.
104
+ *
105
+ * The returned string is inline-JSON that round-trips cleanly back through the
106
+ * story-body parser, so it is paste-ready.
107
+ *
108
+ * @param {unknown} raw
109
+ * @returns {string|null} e.g. `{"path":"src/app.js","assumption":"refactors-existing"}`.
110
+ */
111
+ export function suggestPathEntryFix(raw) {
112
+ if (typeof raw !== 'string') return null;
113
+ // Strip a leading markdown bullet marker.
114
+ let s = raw
115
+ .trim()
116
+ .replace(/^[-*]\s+/, '')
117
+ .trim();
118
+ // Take the segment before any humanized "— assumption" tail.
119
+ s = s.split('—')[0].trim();
120
+ // Peel surrounding backticks / quotes.
121
+ s = s
122
+ .replace(/^[`'"]+/, '')
123
+ .replace(/[`'"]+$/, '')
124
+ .trim();
125
+ if (s === '' || !PATH_LIKE_RE.test(s)) return null;
126
+ return JSON.stringify({ path: s, assumption: DEFAULT_SUGGESTED_ASSUMPTION });
127
+ }
128
+
129
+ /**
130
+ * A single deterministic body-format lint the persist path enforces.
131
+ *
132
+ * @typedef {object} BodyFormatLint
133
+ * @property {string} id Stable identifier (also the prompt anchor).
134
+ * @property {string} summary One-line statement of the requirement.
135
+ * @property {string} badExample A form the lint rejects.
136
+ * @property {string} goodExample The lint-clean form to author instead.
137
+ * @property {boolean} autoFixable Whether a failing dry-run emits a fix-it.
138
+ */
139
+
140
+ /**
141
+ * The enumerated deterministic lints that can reject an authored Story body at
142
+ * persist time. Each carries a concrete example so the story-author prompt can
143
+ * state the requirement example-first (Story #4684 AC-1). The two `autoFixable`
144
+ * lints are the mechanical rewrites whose dry-run failure carries the corrected
145
+ * form (AC-2).
146
+ *
147
+ * @type {ReadonlyArray<BodyFormatLint>}
148
+ */
149
+ export const BODY_FORMAT_LINTS = Object.freeze([
150
+ {
151
+ id: 'body-is-string',
152
+ summary:
153
+ 'The Story `body` MUST be the serialized markdown string produced by `serialize()`, never a JSON object.',
154
+ badExample: '"body": { "goal": "…" }',
155
+ goodExample: '"body": "## Goal\\n…"',
156
+ autoFixable: false,
157
+ },
158
+ {
159
+ id: 'goal-non-empty',
160
+ summary: 'The body MUST open with a non-empty `## Goal` sentence.',
161
+ badExample: '## Goal\n\n## Spec',
162
+ goodExample:
163
+ '## Goal\nExchange short-lived JWTs so sessions survive a restart.',
164
+ autoFixable: false,
165
+ },
166
+ {
167
+ id: 'changes-path-entry-shape',
168
+ summary:
169
+ 'Every `## Changes` / `## References` bullet MUST be a `{ path, assumption }` object (assumption ∈ ' +
170
+ `${FILE_ASSUMPTION_VALUES.join(' | ')}); plain path strings are rejected.`,
171
+ badExample: '- src/app.js',
172
+ goodExample: '- {"path": "src/app.js", "assumption": "refactors-existing"}',
173
+ autoFixable: true,
174
+ },
175
+ {
176
+ id: 'changes-non-empty',
177
+ summary: 'A Story MUST declare at least one `## Changes` bullet.',
178
+ badExample: '## Changes\n\n## Acceptance',
179
+ goodExample: '- {"path": "src/app.js", "assumption": "creates"}',
180
+ autoFixable: false,
181
+ },
182
+ {
183
+ id: 'verify-tier-suffix',
184
+ summary:
185
+ 'Every `verify[]` entry MUST end with a tier in parentheses — one of ' +
186
+ `(${INFERABLE_VERIFY_TIERS.join(' | ')}) — or be a \`manual:<reason>\` escape.`,
187
+ badExample: 'npm test -- src/app.test.js',
188
+ goodExample: 'npm test -- src/app.test.js (unit)',
189
+ autoFixable: true,
190
+ },
191
+ {
192
+ id: 'verify-non-empty',
193
+ summary:
194
+ 'A Story MUST list at least one `verify[]` entry (use `manual:<reason>` only when truly unverifiable in isolation).',
195
+ badExample: '"verify": []',
196
+ goodExample: '"verify": ["npm run validate (validate)"]',
197
+ autoFixable: false,
198
+ },
199
+ {
200
+ id: 'verify-manual-reason',
201
+ summary:
202
+ 'A `manual:` verify entry MUST carry a reason after the colon; a bare `manual:` is rejected.',
203
+ badExample: 'manual:',
204
+ goodExample: 'manual: copy-only edit an auditor eyeballs',
205
+ autoFixable: false,
206
+ },
207
+ {
208
+ id: 'acceptance-non-empty',
209
+ summary:
210
+ 'A Story MUST list at least one observable `acceptance[]` criterion.',
211
+ badExample: '"acceptance": []',
212
+ goodExample: '"acceptance": ["`npm run build` exits 0"]',
213
+ autoFixable: false,
214
+ },
215
+ ]);
@@ -48,6 +48,7 @@ import {
48
48
  authoredMarkerLine,
49
49
  } from '../framework-version.js';
50
50
  import { FILE_ASSUMPTION_VALUES } from '../orchestration/file-assumption-enum.js';
51
+ import { suggestPathEntryFix } from './body-format-lints.js';
51
52
 
52
53
  // ---------------------------------------------------------------------------
53
54
  // Public types (JSDoc only — no runtime schema file)
@@ -229,7 +230,7 @@ function parsePathEntry(raw, warnings) {
229
230
  }
230
231
  // Recognized the humanized shape but the fields are invalid: fail closed.
231
232
  throw new StoryBodyParseError(
232
- `changes/references entry is a humanized bullet but not a valid PathEntry: ${str.slice(0, 120)}`,
233
+ `changes/references entry is a humanized bullet but not a valid PathEntry: ${str.slice(0, 120)}${pathEntryFixIt(str)}`,
233
234
  { field: 'changes', raw: str },
234
235
  );
235
236
  }
@@ -261,11 +262,26 @@ function parsePathEntry(raw, warnings) {
261
262
  }
262
263
 
263
264
  throw new StoryBodyParseError(
264
- `changes/references entry must be a { path, assumption } object; plain string bullets are no longer accepted: ${str.slice(0, 120)}`,
265
+ `changes/references entry must be a { path, assumption } object; plain string bullets are no longer accepted: ${str.slice(0, 120)}${pathEntryFixIt(str)}`,
265
266
  { field: 'changes', raw: str },
266
267
  );
267
268
  }
268
269
 
270
+ /**
271
+ * Build the ` Suggested fix: …` suffix for a rejected changes/references
272
+ * bullet, when a `{ path, assumption }` object can be salvaged from it. Returns
273
+ * an empty string when nothing is inferable, so callers can append it
274
+ * unconditionally (Story #4684 — mechanical auto-fix in the failure output).
275
+ *
276
+ * @param {string} raw The rejected bullet text.
277
+ * @returns {string}
278
+ */
279
+ function pathEntryFixIt(raw) {
280
+ const suggestion = suggestPathEntryFix(raw);
281
+ if (suggestion === null) return '';
282
+ return ` — Suggested fix: ${suggestion} (adjust the assumption to creates|deletes if this is a new file or a removal).`;
283
+ }
284
+
269
285
  /**
270
286
  * Extract the `blocked by #N` lines from the footer block (text after
271
287
  * the last `---` separator). Returns an array of "#N" strings.
@@ -5,6 +5,7 @@ import {
5
5
  DELIVERABLE_GRANULARITY_GUIDANCE,
6
6
  resolveCapacityCeilings,
7
7
  } from '../orchestration/ticket-validator-sizing.js';
8
+ import { BODY_FORMAT_LINTS } from '../story-body/body-format-lints.js';
8
9
 
9
10
  /**
10
11
  * Sole source of truth for the prompt's `maxTickets` cap is the resolved
@@ -63,6 +64,15 @@ function render2TierPrompt({ maxTickets }) {
63
64
  advisoryCaveat,
64
65
  newFileContract,
65
66
  } = AUTHORING_ALTITUDE_GUIDANCE;
67
+ // The deterministic body-format lints (structured `## Changes` bullet shape,
68
+ // verify-tier suffixes, …) rendered example-first from their single source
69
+ // (`lib/story-body/body-format-lints.js`) so an authored draft is lint-clean
70
+ // by construction rather than discovered as a persist dry-run failure and
71
+ // re-authored at resident-context prices (Story #4684).
72
+ const bodyFormatLintChecklist = BODY_FORMAT_LINTS.map(
73
+ (lint) =>
74
+ `- **${lint.id}** — ${lint.summary} Example: \`${lint.goodExample}\``,
75
+ ).join('\n');
66
76
  return `You are an expert Senior Project Manager and Orchestrator.
67
77
  Your job is to turn a plan seed / Tech Spec into a Story ticket array for an AI Agent to execute.
68
78
 
@@ -152,6 +162,12 @@ The serialized \`body\` string renders these markdown sections (in order):
152
162
  - **Bodies record decisions, never questions to the operator.** Never persist an open question ("Flag if…", "TBD", "confirm with the operator") into a Story body — the executing sub-agent is non-interactive and cannot answer it. Resolve the unknown before authoring, or restate it as a declarative Key Assumption the agent can act on.
153
163
  - **non_goals** (OPTIONAL, in body string as the \`## Non-Goals\` section): A short list of capabilities or changes this Story explicitly does NOT deliver — an advisory negative-scope bound that fences the executing agent away from adjacent work. It is **advisory and NON-GATING**: the validator does not require, count, or reject on it, and an absent or empty section renders nothing. Use the EXACT single-word hyphenated heading spelling \`## Non-Goals\` (a space-separated heading like \`## Out of Scope\` is NOT recognized by the parser and will be dropped). Reach for it when a Story's negative boundary is non-obvious from its \`acceptance[]\` alone; omit it otherwise.
154
164
 
165
+ #### DETERMINISTIC BODY-FORMAT LINTS — author lint-clean by construction:
166
+
167
+ Persist enforces the deterministic body-format rules below and **rejects** an authored body that violates any of them. Author every Story to satisfy all of them on the FIRST draft — each rule is stated example-first so there is nothing to discover by trial-and-error. The two auto-fixable rules (\`changes-path-entry-shape\`, \`verify-tier-suffix\`) also emit the corrected form in the dry-run failure output, but authoring them right up front avoids the round-trip entirely.
168
+
169
+ ${bodyFormatLintChecklist}
170
+
155
171
  #### AUTHORING ALTITUDE — BINDING ACCEPTANCE vs ADVISORY CHANGES:
156
172
 
157
173
  ${authoringAltitude}
@@ -15,16 +15,38 @@
15
15
  * @see Story #2462 — Split GitHubProvider god class into seven composed gateways.
16
16
  */
17
17
 
18
+ import { GhRateLimitError } from '../../lib/gh-exec.js';
18
19
  import { Logger } from '../../lib/Logger.js';
19
20
  import { concurrentMap } from '../../lib/util/concurrent-map.js';
20
21
  import { isNotFoundError } from './branch-protection.js';
21
- import { withTransientRetry } from './errors.js';
22
+ import { classifyGithubError, withTransientRetry } from './errors.js';
22
23
  import { issueToEpic } from './mappers.js';
23
24
  import {
24
25
  defaultRetryWarn,
25
26
  paginateRest,
26
27
  parseApiJson,
27
28
  } from './request-helpers.js';
29
+ import {
30
+ searchBudget as defaultSearchBudget,
31
+ parseRateLimitResetMs,
32
+ } from './search-budget.js';
33
+ import { composeBoundedQuery } from './search-query.js';
34
+
35
+ /**
36
+ * Retry classifier for the `searchIssues` call site. A rate-limit error is
37
+ * non-transient **here** — the shared search budget (not `withTransientRetry`)
38
+ * owns the wait, so the call must fail fast rather than burn its retry budget
39
+ * re-issuing into an empty window. Every other error keeps the global
40
+ * classification, so a 5xx/network blip still retries. `classifyGithubError`
41
+ * itself is unchanged: other endpoints legitimately retry on rate-limit.
42
+ *
43
+ * @param {unknown} err
44
+ * @returns {string}
45
+ */
46
+ function classifySearchRetry(err) {
47
+ if (err instanceof GhRateLimitError) return 'rate-limited';
48
+ return classifyGithubError(err);
49
+ }
28
50
 
29
51
  /**
30
52
  * Concurrency budget for the `getSubTickets` fan-out — preserved from
@@ -51,11 +73,15 @@ export class IssuesGateway {
51
73
  * },
52
74
  * }} deps
53
75
  */
54
- constructor({ gh, owner, repo, hooks = {} } = {}) {
76
+ constructor({ gh, owner, repo, hooks = {}, searchBudget } = {}) {
55
77
  this._gh = gh;
56
78
  this.owner = owner;
57
79
  this.repo = repo;
58
80
  this._hooks = hooks;
81
+ // The `/search/issues` fan-out budget. Defaults to the process-wide
82
+ // singleton so every gateway instance shares one 30/min window; injectable
83
+ // so unit tests drive it deterministically (Story #4678).
84
+ this._searchBudget = searchBudget ?? defaultSearchBudget;
59
85
  }
60
86
 
61
87
  /**
@@ -132,14 +158,35 @@ export class IssuesGateway {
132
158
  // Constrain the search to this repo and to issues (not PRs). The
133
159
  // fingerprint sha is the free-text term; GitHub matches it against the
134
160
  // issue body where the `<!-- audit-fingerprints: ... -->` footer lives.
161
+ // The composed `q` is bounded to GitHub Search's 256-char limit here — the
162
+ // only place that knows both the free text and the qualifiers it appends —
163
+ // truncating the free-text portion on a whole-token boundary (Story #4678).
135
164
  const qualifiers = [`repo:${scopeOwner}/${scopeRepo}`, 'type:issue'];
136
- const q = `${query.trim()} ${qualifiers.join(' ')}`;
165
+ const q = composeBoundedQuery(query, qualifiers);
137
166
  const params = new URLSearchParams({ q, per_page: '100' });
138
167
  const endpoint = `/search/issues?${params}`;
139
- const result = await withTransientRetry(
140
- () => this._gh.api({ method: 'GET', endpoint }),
141
- { label: `searchIssues ${query}`, onRetry: defaultRetryWarn },
142
- );
168
+ // Await the shared 30/min budget before every call so the whole scan's
169
+ // fan-out is throttled at the endpoint, not per caller (Story #4678).
170
+ await this._searchBudget.take();
171
+ let result;
172
+ try {
173
+ result = await withTransientRetry(
174
+ () => this._gh.api({ method: 'GET', endpoint }),
175
+ {
176
+ label: `searchIssues ${query}`,
177
+ onRetry: defaultRetryWarn,
178
+ classify: classifySearchRetry,
179
+ },
180
+ );
181
+ } catch (err) {
182
+ // A rate limit means the window is empty: drain the budget until the
183
+ // reported reset so the *next* call pauses once, rather than every call
184
+ // retrying independently into the exhausted window.
185
+ if (err instanceof GhRateLimitError) {
186
+ this._searchBudget.noteRateLimited(parseRateLimitResetMs(err));
187
+ }
188
+ throw err;
189
+ }
143
190
  const json = parseApiJson(result);
144
191
  const items = Array.isArray(json?.items) ? json.items : [];
145
192
  return items.map((item) => ({
@@ -0,0 +1,124 @@
1
+ /**
2
+ * GitHub Provider — `/search/issues` fan-out budget (Story #4678).
3
+ *
4
+ * The 30-requests-per-minute cap is a property of GitHub's `/search/issues`
5
+ * endpoint, not of any one caller. A single audit-to-stories scan issues ~2
6
+ * search calls per finding; a 22-group scan therefore blows well past the cap
7
+ * with no throttle of any kind, and every exhausted call then spends its whole
8
+ * transient-retry budget re-issuing a request against an already-empty window.
9
+ *
10
+ * This module owns the throttle at the endpoint seam: a pure, injectable token
11
+ * bucket that `IssuesGateway#searchIssues` awaits before every call, so every
12
+ * caller (audit-to-stories dedup, `lib/duplicate-search.js`, the tickets
13
+ * gateway's `_searchIssues`) inherits one shared budget for free.
14
+ *
15
+ * `now` and `sleep` are injected so unit tests drive the bucket deterministically
16
+ * without wall-clock time.
17
+ */
18
+
19
+ /**
20
+ * Default budget: 30 tokens per 60s window, matching GitHub's authenticated
21
+ * Search API cap. When a rate limit is reported with no readable reset, the
22
+ * bucket pauses for one full window before the next `take()` resolves.
23
+ */
24
+ const SEARCH_BUDGET_DEFAULTS = Object.freeze({
25
+ capacity: 30,
26
+ windowMs: 60_000,
27
+ cooldownMs: 60_000,
28
+ });
29
+
30
+ /**
31
+ * Create a token-bucket search budget.
32
+ *
33
+ * `take()` resolves once a token is available, consuming it; it awaits an
34
+ * accruing token (and any active rate-limit cooldown) rather than failing.
35
+ * `noteRateLimited(resetAtMs)` drains the bucket and blocks every subsequent
36
+ * `take()` until the reported reset (or a fixed cooldown when no reset is
37
+ * readable), so the whole batch pauses **once** instead of each call retrying
38
+ * independently into the empty window.
39
+ *
40
+ * @param {object} [opts]
41
+ * @param {number} [opts.capacity] — max tokens (and burst size).
42
+ * @param {number} [opts.windowMs] — window over which `capacity` tokens accrue.
43
+ * @param {number} [opts.cooldownMs] — pause applied when a rate limit reports
44
+ * no readable reset time.
45
+ * @param {() => number} [opts.now] — millisecond clock (injected for tests).
46
+ * @param {(ms: number) => Promise<void>} [opts.sleep] — delay primitive.
47
+ * @returns {{ take: () => Promise<void>, noteRateLimited: (resetAtMs?: number) => void }}
48
+ */
49
+ export function createSearchBudget({
50
+ capacity = SEARCH_BUDGET_DEFAULTS.capacity,
51
+ windowMs = SEARCH_BUDGET_DEFAULTS.windowMs,
52
+ cooldownMs = SEARCH_BUDGET_DEFAULTS.cooldownMs,
53
+ now = () => Date.now(),
54
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
55
+ } = {}) {
56
+ const refillPerMs = capacity / windowMs;
57
+ let tokens = capacity;
58
+ let lastRefillAt = now();
59
+ let blockedUntil = 0;
60
+
61
+ function refill() {
62
+ const at = now();
63
+ const elapsed = at - lastRefillAt;
64
+ if (elapsed > 0) {
65
+ tokens = Math.min(capacity, tokens + elapsed * refillPerMs);
66
+ lastRefillAt = at;
67
+ }
68
+ }
69
+
70
+ async function take() {
71
+ for (;;) {
72
+ const at = now();
73
+ if (blockedUntil > at) {
74
+ await sleep(blockedUntil - at);
75
+ continue;
76
+ }
77
+ refill();
78
+ if (tokens >= 1) {
79
+ tokens -= 1;
80
+ return;
81
+ }
82
+ // Sleep just long enough for one token to accrue, then re-check.
83
+ const waitMs = Math.max(1, Math.ceil((1 - tokens) / refillPerMs));
84
+ await sleep(waitMs);
85
+ }
86
+ }
87
+
88
+ function noteRateLimited(resetAtMs) {
89
+ const at = now();
90
+ tokens = 0;
91
+ lastRefillAt = at;
92
+ const until =
93
+ typeof resetAtMs === 'number' && resetAtMs > at
94
+ ? resetAtMs
95
+ : at + cooldownMs;
96
+ if (until > blockedUntil) blockedUntil = until;
97
+ }
98
+
99
+ return { take, noteRateLimited };
100
+ }
101
+
102
+ /**
103
+ * Process-wide singleton shared by every `searchIssues` caller. Per-process,
104
+ * matching the one-scan-per-checkout model — there is deliberately no
105
+ * cross-process budget.
106
+ */
107
+ export const searchBudget = createSearchBudget();
108
+
109
+ /**
110
+ * Best-effort extract of a rate-limit reset time (epoch ms) from a thrown
111
+ * error's stderr. GitHub surfaces the reset as an `x-ratelimit-reset` epoch
112
+ * (seconds) header; `gh` echoes response headers onto stderr on failure.
113
+ * Returns `undefined` when no reset is readable so the bucket falls back to a
114
+ * fixed cooldown. Pure — no I/O.
115
+ *
116
+ * @param {unknown} err
117
+ * @returns {number|undefined} reset time in epoch milliseconds, or undefined.
118
+ */
119
+ export function parseRateLimitResetMs(err) {
120
+ const haystack = [err?.stderr, err?.message].filter(Boolean).join('\n');
121
+ const match = haystack.match(/x-ratelimit-reset:\s*(\d{10})/i);
122
+ if (!match) return undefined;
123
+ return Number.parseInt(match[1], 10) * 1000;
124
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * GitHub Provider — composed `/search/issues` query bound (Story #4678).
3
+ *
4
+ * `IssuesGateway#searchIssues` is the only place that knows the *composed* `q`
5
+ * — the caller's free text plus the `repo:<owner>/<repo> type:issue` qualifiers
6
+ * it appends. GitHub Search rejects a query over 256 characters with HTTP 422,
7
+ * which is neither transient nor caught, so an over-long title over a deep path
8
+ * would abort the whole scan. This module owns the defensive guard: it truncates
9
+ * the free-text portion on a whole-token boundary until the composed `q` fits.
10
+ *
11
+ * Pure and unit-testable — no I/O.
12
+ */
13
+
14
+ /**
15
+ * GitHub Search's documented maximum query length, in characters. Module-private
16
+ * — `composeBoundedQuery` is the only supported way to apply it, so the bound
17
+ * cannot drift between call sites.
18
+ */
19
+ const GITHUB_SEARCH_MAX_QUERY = 256;
20
+
21
+ /**
22
+ * Compose a `/search/issues` query from free text plus fixed qualifiers,
23
+ * truncating the free-text portion on a whole-token boundary so the whole
24
+ * composed string is at most `max` characters. Qualifiers are never dropped —
25
+ * they are the load-bearing scope (`repo:` / `type:`) — so when even the
26
+ * qualifiers alone exceed the budget the qualifier string is returned as-is.
27
+ *
28
+ * @param {string} freeText — the caller's free-text search term(s).
29
+ * @param {string[]} qualifiers — fixed qualifier tokens (e.g. `repo:o/r`).
30
+ * @param {number} [max] — character ceiling for the composed query.
31
+ * @returns {string} the composed query, at most `max` characters.
32
+ */
33
+ export function composeBoundedQuery(
34
+ freeText,
35
+ qualifiers,
36
+ max = GITHUB_SEARCH_MAX_QUERY,
37
+ ) {
38
+ const quals = qualifiers.join(' ');
39
+ const free = String(freeText ?? '').trim();
40
+ const full = `${free} ${quals}`.trim();
41
+ if (full.length <= max) return full;
42
+
43
+ // Reserve space for the qualifiers (and the joining space) and fit as many
44
+ // leading free-text tokens as the remaining budget allows.
45
+ const reserve = quals.length + (quals.length > 0 ? 1 : 0);
46
+ const budget = max - reserve;
47
+ const bounded = fitTokens(free.split(/\s+/).filter(Boolean), budget);
48
+ return bounded.length > 0 ? `${bounded} ${quals}`.trim() : quals;
49
+ }
50
+
51
+ /**
52
+ * Join as many leading `tokens` as fit within `budget` characters, on a
53
+ * whole-token boundary (space-separated). Returns '' when the budget cannot fit
54
+ * even the first token. Module-private — exercised through
55
+ * {@link composeBoundedQuery}, the only caller.
56
+ *
57
+ * @param {string[]} tokens
58
+ * @param {number} budget
59
+ * @returns {string}
60
+ */
61
+ function fitTokens(tokens, budget) {
62
+ const kept = [];
63
+ let length = 0;
64
+ for (const token of tokens) {
65
+ const cost = kept.length === 0 ? token.length : token.length + 1;
66
+ if (length + cost > budget) break;
67
+ kept.push(token);
68
+ length += cost;
69
+ }
70
+ return kept.join(' ');
71
+ }