mandrel 1.57.0 → 1.59.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 (131) hide show
  1. package/.agents/README.md +89 -87
  2. package/.agents/docs/SDLC.md +11 -7
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/audit-rules.json +20 -0
  5. package/.agents/scripts/acceptance-eval.js +20 -3
  6. package/.agents/scripts/assert-branch.js +1 -3
  7. package/.agents/scripts/bootstrap.js +1 -1
  8. package/.agents/scripts/check-arch-cycles.js +360 -0
  9. package/.agents/scripts/coverage-capture.js +24 -3
  10. package/.agents/scripts/epic-deliver-preflight.js +5 -3
  11. package/.agents/scripts/epic-deliver-prepare.js +12 -4
  12. package/.agents/scripts/epic-execute-record-wave.js +1 -1
  13. package/.agents/scripts/evidence-gate.js +1 -1
  14. package/.agents/scripts/git-rebase-and-resolve.js +1 -1
  15. package/.agents/scripts/hierarchy-gate.js +34 -14
  16. package/.agents/scripts/lib/baselines/kinds/coverage.js +33 -149
  17. package/.agents/scripts/lib/baselines/kinds/duplication.js +27 -116
  18. package/.agents/scripts/lib/baselines/kinds/kind-factory.js +192 -0
  19. package/.agents/scripts/lib/baselines/kinds/lighthouse.js +34 -133
  20. package/.agents/scripts/lib/baselines/kinds/maintainability.js +31 -124
  21. package/.agents/scripts/lib/baselines/kinds/mutation.js +25 -111
  22. package/.agents/scripts/lib/baselines/maintainability-baseline-io.js +59 -0
  23. package/.agents/scripts/lib/baselines/maintainability-baseline-save.js +37 -0
  24. package/.agents/scripts/lib/baselines/writer.js +1 -1
  25. package/.agents/scripts/lib/close-validation/commands.js +188 -0
  26. package/.agents/scripts/lib/close-validation/gates.js +235 -0
  27. package/.agents/scripts/lib/close-validation/process.js +101 -0
  28. package/.agents/scripts/lib/close-validation/projections/maintainability.js +1 -1
  29. package/.agents/scripts/lib/close-validation/runner.js +325 -0
  30. package/.agents/scripts/lib/close-validation/telemetry.js +70 -0
  31. package/.agents/scripts/lib/config/quality.js +6 -6
  32. package/.agents/scripts/lib/config-resolver.js +2 -5
  33. package/.agents/scripts/lib/coverage-capture.js +147 -4
  34. package/.agents/scripts/lib/cpu-pool.js +14 -0
  35. package/.agents/scripts/lib/crap-utils.js +6 -11
  36. package/.agents/scripts/lib/dynamic-workflow/documentation-report-contract.js +87 -0
  37. package/.agents/scripts/lib/git-utils.js +24 -22
  38. package/.agents/scripts/lib/maintainability-engine.js +1 -1
  39. package/.agents/scripts/lib/maintainability-utils.js +4 -187
  40. package/.agents/scripts/lib/observability/perf-report-readers.js +32 -23
  41. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +80 -6
  42. package/.agents/scripts/lib/orchestration/code-review.js +90 -77
  43. package/.agents/scripts/lib/orchestration/dispatch-pipeline.js +5 -12
  44. package/.agents/scripts/lib/orchestration/epic-deliver-lease-guard.js +14 -14
  45. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +2 -2
  46. package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +184 -49
  47. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/drain.js +1 -1
  48. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +26 -2
  49. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +26 -6
  50. package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +7 -20
  51. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/composition.js +1 -2
  52. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter/signals.js +0 -6
  53. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +103 -0
  54. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +22 -64
  55. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +38 -76
  56. package/.agents/scripts/lib/orchestration/epic-runner/story-run-progress-writer.js +2 -2
  57. package/.agents/scripts/lib/orchestration/epic-runner/sub-agent-return.js +4 -16
  58. package/.agents/scripts/lib/orchestration/file-assumptions.js +4 -3
  59. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +144 -0
  60. package/.agents/scripts/lib/orchestration/lifecycle/emit-story-heartbeat.js +2 -2
  61. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +7 -7
  62. package/.agents/scripts/lib/orchestration/post-merge/phases/notification.js +3 -3
  63. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +7 -7
  64. package/.agents/scripts/lib/orchestration/preflight-cache.js +35 -12
  65. package/.agents/scripts/lib/orchestration/review-providers/codex.js +5 -60
  66. package/.agents/scripts/lib/orchestration/review-providers/native.js +7 -6
  67. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +105 -0
  68. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +7 -59
  69. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +2 -4
  70. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +241 -0
  71. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +1 -1
  72. package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +16 -3
  73. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -4
  74. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +32 -35
  75. package/.agents/scripts/lib/orchestration/skill-capsule-loader.js +1 -2
  76. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +451 -503
  77. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/pre-merge-attribution.js +8 -2
  78. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +47 -2
  79. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +2 -2
  80. package/.agents/scripts/lib/orchestration/story-close/format-autofix.js +358 -54
  81. package/.agents/scripts/lib/orchestration/story-close/phases/close.js +1 -1
  82. package/.agents/scripts/lib/orchestration/story-close/phases/gates.js +3 -2
  83. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +30 -3
  84. package/.agents/scripts/lib/orchestration/story-close/post-merge-close.js +5 -18
  85. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +3 -3
  86. package/.agents/scripts/lib/orchestration/story-close-recovery.js +33 -16
  87. package/.agents/scripts/lib/orchestration/story-reachability.js +47 -0
  88. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +2 -33
  89. package/.agents/scripts/lib/orchestration/ticketing/bulk.js +42 -64
  90. package/.agents/scripts/lib/orchestration/ticketing/reads.js +9 -0
  91. package/.agents/scripts/lib/orchestration/ticketing/state.js +50 -436
  92. package/.agents/scripts/lib/orchestration/ticketing/transition.js +471 -0
  93. package/.agents/scripts/lib/orchestration/ticketing.js +0 -1
  94. package/.agents/scripts/lib/orchestration/wave-record-notifications.js +1 -1
  95. package/.agents/scripts/lib/orchestration/wave-record-projection.js +1 -7
  96. package/.agents/scripts/lib/project-root.js +17 -0
  97. package/.agents/scripts/lib/story-adjacency.js +76 -0
  98. package/.agents/scripts/lib/story-lifecycle.js +1 -1
  99. package/.agents/scripts/lib/transpile.js +93 -0
  100. package/.agents/scripts/lib/wave-runner/tick.js +4 -153
  101. package/.agents/scripts/lib/workers/crap-worker.js +1 -1
  102. package/.agents/scripts/lib/workers/maintainability-report-worker.js +1 -1
  103. package/.agents/scripts/lib/worktree/lifecycle/creation.js +20 -2
  104. package/.agents/scripts/lib/worktree/lifecycle/force-drain.js +90 -0
  105. package/.agents/scripts/lib/worktree/lifecycle/reap.js +26 -8
  106. package/.agents/scripts/lib/worktree/node-modules-strategy.js +74 -0
  107. package/.agents/scripts/providers/github/tickets.js +110 -6
  108. package/.agents/scripts/run-lint.js +9 -0
  109. package/.agents/scripts/run-tests.js +24 -4
  110. package/.agents/scripts/stories-wave-tick.js +8 -5
  111. package/.agents/scripts/story-init.js +149 -10
  112. package/.agents/scripts/sync-branch-from-base.js +1 -1
  113. package/.agents/skills/stack/qa/lighthouse-baseline/SKILL.md +1 -1
  114. package/.agents/workflows/audit-documentation.md +226 -0
  115. package/.agents/workflows/epic-deliver.md +16 -23
  116. package/.agents/workflows/epic-plan.md +1 -1
  117. package/.agents/workflows/helpers/epic-deliver-story.md +17 -28
  118. package/.agents/workflows/helpers/single-story-deliver.md +2 -1
  119. package/.agents/workflows/onboard.md +4 -3
  120. package/.agents/workflows/story-deliver.md +1 -1
  121. package/README.md +21 -8
  122. package/lib/cli/init.js +336 -0
  123. package/package.json +2 -1
  124. package/.agents/scripts/lib/auto-refresh-baselines.js +0 -308
  125. package/.agents/scripts/lib/close-validation.js +0 -897
  126. package/.agents/scripts/lib/orchestration/cascade-grouping.js +0 -275
  127. package/.agents/scripts/lib/orchestration/epic-runner/progress-reporter.js +0 -69
  128. package/.agents/scripts/lib/orchestration/story-close/format-autofix-scoped.js +0 -221
  129. package/.agents/scripts/lib/orchestration/story-close/format-autofix-shared.js +0 -123
  130. package/.agents/scripts/lib/task-utils.js +0 -26
  131. package/.agents/scripts/story-deliver-prepare.js +0 -267
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import { parseBlockedBy, parseBlocks } from '../../lib/dependency-parser.js';
25
+ import { Logger } from '../../lib/Logger.js';
25
26
  import { addIssueToBoard } from './board-add.js';
26
27
  import { createInlineTicketCache } from './cache.js';
27
28
  import { withTransientRetry } from './errors.js';
@@ -32,6 +33,14 @@ import {
32
33
  parseApiJson,
33
34
  } from './request-helpers.js';
34
35
 
36
+ /**
37
+ * GitHub Search API hard ceiling is 1000 results per query; at
38
+ * `per_page=100` that is 10 pages. An Epic never has anywhere near 1000
39
+ * children, so hitting this cap means the query is degenerate — we stop
40
+ * rather than throw (the regex post-filter keeps results correct).
41
+ */
42
+ const SEARCH_PAGE_CAP = 10;
43
+
35
44
  /**
36
45
  * Compose the final markdown body for a created ticket. Under the 3-tier
37
46
  * hierarchy (Epic → Feature → Story), `body` is always a string supplied
@@ -100,6 +109,14 @@ export class TicketGateway {
100
109
  this.repo = repo;
101
110
  this._hooks = hooks;
102
111
  this._cache = cache ?? createInlineTicketCache();
112
+ /**
113
+ * Per-instance memo of `getTickets(epicId, filters)` results (Story
114
+ * #3988). The planning-state-manager fetches the same child list twice
115
+ * per planning pass; without this memo each fetch re-pays the full
116
+ * search/list round-trip. Invalidated on every write surface.
117
+ * @type {Map<string, object[]>}
118
+ */
119
+ this._listCache = new Map();
103
120
  }
104
121
 
105
122
  /**
@@ -142,29 +159,113 @@ export class TicketGateway {
142
159
  }
143
160
 
144
161
  /**
145
- * @field-manifest /repos/{owner}/{repo}/issues?state=...&labels=...:
146
- * number, body, labels, state, pull_request
162
+ * Run one Search API query (`/search/issues`) to completion, returning
163
+ * the raw issue items. Search responses are `{ total_count, items }`
164
+ * envelopes rather than bare arrays, so this paginates manually instead
165
+ * of going through `paginateRest`.
166
+ */
167
+ async _searchIssues(query) {
168
+ const items = [];
169
+ for (let page = 1; page <= SEARCH_PAGE_CAP; page++) {
170
+ const params = new URLSearchParams({
171
+ q: query,
172
+ per_page: '100',
173
+ page: String(page),
174
+ });
175
+ const result = await withTransientRetry(
176
+ () =>
177
+ this._gh.api({
178
+ method: 'GET',
179
+ endpoint: `/search/issues?${params}`,
180
+ }),
181
+ { label: `searchIssues page ${page}`, onRetry: defaultRetryWarn },
182
+ );
183
+ const parsed = parseApiJson(result);
184
+ const batch = Array.isArray(parsed?.items) ? parsed.items : [];
185
+ items.push(...batch);
186
+ if (batch.length < 100) break;
187
+ }
188
+ return items;
189
+ }
190
+
191
+ /**
192
+ * Server-side narrowed child lookup (Story #3988): two Search API
193
+ * queries — `"Epic: #N" in:body` and `"parent: #N" in:body` — deduped
194
+ * by issue number. Replaces the repo-wide `state=all` pagination that
195
+ * cost ~1 spawn per 100 repo issues and hard-failed past the
196
+ * `paginateRest` page cap. Search tokenization can over-match (e.g.
197
+ * `#10` vs `#100`), so callers MUST keep the word-boundary regex
198
+ * post-filter.
199
+ */
200
+ async _searchEpicChildren(epicId, filters) {
201
+ const qualifiers = [`repo:${this.owner}/${this.repo}`, 'is:issue'];
202
+ const state = filters.state ?? 'all';
203
+ if (state === 'open' || state === 'closed') {
204
+ qualifiers.push(`state:${state}`);
205
+ }
206
+ if (filters.label) qualifiers.push(`label:"${filters.label}"`);
207
+ const base = qualifiers.join(' ');
208
+
209
+ const [epicRefs, parentRefs] = await Promise.all([
210
+ this._searchIssues(`${base} "Epic: #${epicId}" in:body`),
211
+ this._searchIssues(`${base} "parent: #${epicId}" in:body`),
212
+ ]);
213
+
214
+ const byNumber = new Map();
215
+ for (const issue of [...epicRefs, ...parentRefs]) {
216
+ if (!byNumber.has(issue.number)) byNumber.set(issue.number, issue);
217
+ }
218
+ return Array.from(byNumber.values());
219
+ }
220
+
221
+ /**
222
+ * Repo-wide listing fallback — the pre-#3988 shape. Only used when the
223
+ * Search API path fails (search outage, search-specific rate limit).
147
224
  */
148
225
  /* node:coverage ignore next */
149
- async getTickets(epicId, filters = {}) {
226
+ async _listAllIssues(filters) {
150
227
  const params = new URLSearchParams({ state: filters.state ?? 'all' });
151
228
  if (filters.label) params.set('labels', filters.label);
152
-
153
229
  const endpoint = `/repos/${this.owner}/${this.repo}/issues?${params}`;
154
- const issues = await paginateRest(this._gh, endpoint);
230
+ return paginateRest(this._gh, endpoint);
231
+ }
232
+
233
+ /**
234
+ * @field-manifest /search/issues?q=...: number, id, node_id, title,
235
+ * body, labels, state, pull_request
236
+ * @field-manifest /repos/{owner}/{repo}/issues?state=...&labels=...:
237
+ * number, body, labels, state, pull_request
238
+ */
239
+ async getTickets(epicId, filters = {}) {
240
+ const memoKey = `${epicId}|${filters.state ?? 'all'}|${filters.label ?? ''}`;
241
+ if (this._listCache.has(memoKey)) return this._listCache.get(memoKey);
242
+
243
+ let issues;
244
+ try {
245
+ issues = await this._searchEpicChildren(epicId, filters);
246
+ } catch (err) {
247
+ const msg = typeof err?.message === 'string' ? err.message : String(err);
248
+ Logger.warn(
249
+ `[TicketGateway] search-based getTickets(#${epicId}) failed (${msg}); ` +
250
+ 'falling back to repo-wide issue listing',
251
+ );
252
+ issues = await this._listAllIssues(filters);
253
+ }
155
254
 
156
255
  // Word-boundary regex prevents #1 matching #10, #100, etc.
157
256
  const epicRefRe = new RegExp(
158
257
  `(?:Epic:\\s*#${epicId}|parent:\\s*#${epicId})(?:\\s|$|[,.)\\]])`,
159
258
  );
160
259
 
161
- return issues
260
+ const tickets = issues
162
261
  .filter((issue) => {
163
262
  if (issue.pull_request) return false;
164
263
  const body = issue.body ?? '';
165
264
  return epicRefRe.test(body);
166
265
  })
167
266
  .map(issueToListItem);
267
+ this._listCache.set(memoKey, tickets);
268
+ return tickets;
168
269
  }
169
270
 
170
271
  /* node:coverage ignore next */
@@ -187,6 +288,7 @@ export class TicketGateway {
187
288
 
188
289
  invalidateTicket(ticketId) {
189
290
  this._cache.invalidate(ticketId);
291
+ this._listCache.clear();
190
292
  }
191
293
 
192
294
  // ---------------------------------------------------------------------------
@@ -226,6 +328,7 @@ export class TicketGateway {
226
328
  },
227
329
  });
228
330
  const issue = parseApiJson(result);
331
+ this._listCache.clear();
229
332
 
230
333
  let subIssueLinked = false;
231
334
  let subIssueError = null;
@@ -288,6 +391,7 @@ export class TicketGateway {
288
391
  body: { title, body, labels },
289
392
  });
290
393
  const issue = parseApiJson(result);
394
+ this._listCache.clear();
291
395
 
292
396
  const boardAdd = await addIssueToBoard({
293
397
  nodeId: issue.node_id,
@@ -69,6 +69,15 @@ const tasks = [
69
69
  cmd: 'node',
70
70
  args: ['.agents/scripts/lint-label-vocabulary.js'],
71
71
  },
72
+ {
73
+ // Architecture cycle ratchet (Story #3991). Detects directed import
74
+ // cycles under `.agents/scripts/` and fails on any cycle not in the
75
+ // committed allowlist (`baselines/arch-cycles.json`). Mirrors the
76
+ // ratchet-down semantics of `check-dead-exports.js`.
77
+ name: 'arch-cycles',
78
+ cmd: 'node',
79
+ args: ['.agents/scripts/check-arch-cycles.js'],
80
+ },
72
81
  ];
73
82
 
74
83
  function runTask({ name, cmd, args }) {
@@ -84,11 +84,31 @@ export const TEST_RUNNER_FLAGS = Object.freeze([
84
84
  * quoting). Budgeting the targets to 8 000 chars keeps every spawn's full
85
85
  * command line at roughly a quarter of the ceiling — ample headroom for the
86
86
  * exe path and any pass-through `--test-name-pattern` args — while keeping
87
- * the chunk count (and thus the extra `node` start-ups) low. POSIX limits are
88
- * far higher, so this only ever splits work that would otherwise fail on
89
- * Windows.
87
+ * the chunk count (and thus the extra `node` start-ups) low.
88
+ *
89
+ * `MAX_TARGET_CHARS` is the **Windows** budget. On POSIX hosts `ARG_MAX` is
90
+ * far higher (~256 KB on macOS, ~1 MB on Linux), so applying the Windows
91
+ * budget there needlessly serializes the quick tier into several sequential
92
+ * `node --test` spawns — each chunk pays a fresh runner start-up, and cores
93
+ * idle at every chunk's tail. `POSIX_MAX_TARGET_CHARS` (100 000) lets the
94
+ * whole quick-tier target list (~33 000 chars today) collapse into a single
95
+ * spawn on POSIX while staying far below `ARG_MAX`.
96
+ * `resolveMaxTargetChars` picks the budget per platform; the Windows
97
+ * semantics of `MAX_TARGET_CHARS` are unchanged.
90
98
  */
91
99
  export const MAX_TARGET_CHARS = 8000;
100
+ export const POSIX_MAX_TARGET_CHARS = 100_000;
101
+
102
+ /**
103
+ * Resolve the per-spawn target-character budget for the host platform.
104
+ * The `platform` parameter is injected in tests.
105
+ *
106
+ * @param {NodeJS.Platform} [platform]
107
+ * @returns {number}
108
+ */
109
+ export function resolveMaxTargetChars(platform = process.platform) {
110
+ return platform === 'win32' ? MAX_TARGET_CHARS : POSIX_MAX_TARGET_CHARS;
111
+ }
92
112
 
93
113
  /**
94
114
  * Partition an ordered list of test-file targets into chunks whose joined
@@ -144,7 +164,7 @@ export function runTestSuite({
144
164
  spawn = spawnSync,
145
165
  cleanup = cleanupRepoTestTempArtifacts,
146
166
  listTargets = listTestFilesForTier,
147
- maxTargetChars = MAX_TARGET_CHARS,
167
+ maxTargetChars = resolveMaxTargetChars(),
148
168
  } = {}) {
149
169
  const { tier, rest } = parseTierArgv(argv);
150
170
  const targets = listTargets(tier, cwd);
@@ -43,6 +43,7 @@ import { runAsCli } from './lib/cli-utils.js';
43
43
  import { getRunners, resolveConfig } from './lib/config-resolver.js';
44
44
  import { assignLayers, detectCycle } from './lib/Graph.js';
45
45
  import { Logger } from './lib/Logger.js';
46
+ import { buildStoryAdjacency } from './lib/story-adjacency.js';
46
47
 
47
48
  const HELP = `Usage: node .agents/scripts/stories-wave-tick.js --dag '<json>' | --dag-file <path> [--concurrency <n>]
48
49
 
@@ -132,15 +133,17 @@ export function parseDag(raw) {
132
133
  * Build an adjacency map from parsed DAG nodes.
133
134
  * Returns Map<id, id[]> where each id maps to its dependencies.
134
135
  *
136
+ * Delegates to the shared story-level builder
137
+ * (`lib/story-adjacency.js#buildStoryAdjacency`) with `dropForeign: false`
138
+ * to preserve the operator-DAG contract: a `dependsOn` id absent from the
139
+ * input set still deepens the dependent's layer (assignLayers treats the
140
+ * unknown id as a root).
141
+ *
135
142
  * @param {Array<{id: number, dependsOn: number[]}>} nodes
136
143
  * @returns {Map<number, number[]>}
137
144
  */
138
145
  export function buildAdjacency(nodes) {
139
- const adjacency = new Map();
140
- for (const node of nodes) {
141
- adjacency.set(node.id, [...node.dependsOn]);
142
- }
143
- return adjacency;
146
+ return buildStoryAdjacency(nodes, { dropForeign: false });
144
147
  }
145
148
 
146
149
  /**
@@ -38,8 +38,13 @@ import {
38
38
  } from './lib/config-resolver.js';
39
39
  import { parseBlockedBy } from './lib/dependency-parser.js';
40
40
  import { getEpicBranch, getStoryBranch } from './lib/git-utils.js';
41
+ import { runInstallCommand } from './lib/install-cmd-parser.js';
41
42
  import { Logger } from './lib/Logger.js';
42
43
  import { setActiveStoryEnv } from './lib/observability/active-story-env.js';
44
+ import {
45
+ defaultStoryPhases,
46
+ upsertStoryRunProgress,
47
+ } from './lib/orchestration/epic-runner/story-run-progress-writer.js';
43
48
  import { upsertStructuredComment } from './lib/orchestration/ticketing.js';
44
49
  import { createProvider } from './lib/provider-factory.js';
45
50
  import { validateBlockers } from './lib/story-init/blocker-validator.js';
@@ -330,11 +335,6 @@ export async function runStoryInit({
330
335
  hierarchy: hierarchyMode,
331
336
  });
332
337
 
333
- emitStoryInitResult(result, {
334
- storyId,
335
- dryRun,
336
- });
337
-
338
338
  if (!dryRun) {
339
339
  await postStoryInitComment({
340
340
  provider,
@@ -342,11 +342,153 @@ export async function runStoryInit({
342
342
  result,
343
343
  logger: stageLogger,
344
344
  });
345
+
346
+ // Story #4017 — the formerly standalone prepare CLI (which
347
+ // re-read the story-init structured comment seconds after this process
348
+ // wrote it) is inlined here: apply the install tri-state and render the
349
+ // initial Story-phase snapshot in-process, off the result we already
350
+ // hold.
351
+ result.prepare = await runStoryInitPrepare({
352
+ provider,
353
+ storyId,
354
+ result,
355
+ notify: _notifyFn,
356
+ logger: stageLogger,
357
+ });
345
358
  }
346
359
 
360
+ emitStoryInitResult(result, {
361
+ storyId,
362
+ dryRun,
363
+ });
364
+
347
365
  return { success: true, result };
348
366
  }
349
367
 
368
+ const VALID_INSTALLED_STATES = new Set(['true', 'false', 'skipped']);
369
+
370
+ /**
371
+ * Apply the dependenciesInstalled tri-state to derive the next install
372
+ * action. Pure helper — exposes the Step 0.5 truth table as data so tests
373
+ * can pin each branch without spinning up a child process.
374
+ *
375
+ * @param {'true' | 'false' | 'skipped'} dependenciesInstalled
376
+ * @param {{ skipInstall?: boolean }} [options]
377
+ * @returns {'skip' | 'install'}
378
+ */
379
+ export function deriveInstallAction(dependenciesInstalled, options = {}) {
380
+ if (!VALID_INSTALLED_STATES.has(dependenciesInstalled)) {
381
+ throw new RangeError(
382
+ `deriveInstallAction: dependenciesInstalled "${dependenciesInstalled}" must be one of: ${[...VALID_INSTALLED_STATES].join(', ')}`,
383
+ );
384
+ }
385
+ if (options.skipInstall) return 'skip';
386
+ return dependenciesInstalled === 'false' ? 'install' : 'skip';
387
+ }
388
+
389
+ /**
390
+ * Resolve the install command to run when `dependenciesInstalled === 'false'`.
391
+ * `project.commands` does not currently carry a dedicated install key,
392
+ * so this defaults to `npm ci`. Operators can override per-invocation via
393
+ * the `installCmd` option.
394
+ *
395
+ * @param {{ override?: string }} [options]
396
+ * @returns {string}
397
+ */
398
+ export function resolveInstallCommand(options = {}) {
399
+ const trimmed = options.override?.trim();
400
+ if (trimmed) {
401
+ return trimmed;
402
+ }
403
+ return 'npm ci';
404
+ }
405
+
406
+ /**
407
+ * Post-init prepare step (Story #4017 — formerly a standalone prepare
408
+ * CLI, now consuming the in-process init result
409
+ * instead of re-reading the `story-init` structured comment):
410
+ *
411
+ * 1. Apply the `dependenciesInstalled` tri-state truth table — `'false'`
412
+ * (install attempted and failed) retries the install command in the
413
+ * worktree; `'true'` / `'skipped'` proceed.
414
+ * 2. Render the initial Story-phase snapshot with every phase pinned to
415
+ * `pending` and `phase: 'init'` via `upsertStoryRunProgress`
416
+ * (render-only since Story #3909 — no comment is posted). The
417
+ * `renderedBody` markdown is relayed to chat by the delivery
418
+ * workflows so operators see the initial progress block before the
419
+ * first commit lands.
420
+ *
421
+ * Install failure throws (init exits non-zero); a snapshot-render failure
422
+ * is non-fatal observability loss and only warns.
423
+ *
424
+ * @param {{
425
+ * provider: object,
426
+ * storyId: number,
427
+ * result: { workCwd?: string, dependenciesInstalled?: string, storyBranch?: string },
428
+ * notify?: Function | null,
429
+ * runInstall?: (cmd: string, cwd: string) => { status: number, stderr?: string },
430
+ * skipInstall?: boolean,
431
+ * installCmd?: string,
432
+ * logger?: object,
433
+ * }} args
434
+ * @returns {Promise<{
435
+ * installAction: 'skip' | 'install',
436
+ * installCmd: string | null,
437
+ * installResult: { status: number, stderr?: string } | null,
438
+ * snapshot: object | null,
439
+ * renderedBody: string | null,
440
+ * }>}
441
+ */
442
+ export async function runStoryInitPrepare({
443
+ provider,
444
+ storyId,
445
+ result,
446
+ notify: notifyFn = null,
447
+ runInstall = runInstallCommand,
448
+ skipInstall = false,
449
+ installCmd: installCmdOverride,
450
+ logger = stageLogger,
451
+ }) {
452
+ const dependenciesInstalled = String(
453
+ result?.dependenciesInstalled ?? 'skipped',
454
+ );
455
+ const installAction = deriveInstallAction(dependenciesInstalled, {
456
+ skipInstall,
457
+ });
458
+ let installCmd = null;
459
+ let installResult = null;
460
+ if (installAction === 'install') {
461
+ installCmd = resolveInstallCommand({ override: installCmdOverride });
462
+ installResult = runInstall(installCmd, result.workCwd);
463
+ if (installResult.status !== 0) {
464
+ throw new Error(
465
+ `runStoryInitPrepare: install command \`${installCmd}\` failed with status ${installResult.status}: ${installResult.stderr ?? ''}`,
466
+ );
467
+ }
468
+ }
469
+
470
+ let snapshot = null;
471
+ let renderedBody = null;
472
+ try {
473
+ const { body, payload } = await upsertStoryRunProgress({
474
+ provider,
475
+ storyId,
476
+ branch: result?.storyBranch ?? `story-${storyId}`,
477
+ phase: 'init',
478
+ phases: defaultStoryPhases(),
479
+ notify: notifyFn,
480
+ });
481
+ snapshot = payload;
482
+ renderedBody = body;
483
+ } catch (err) {
484
+ logger?.warn?.(
485
+ `[story-init] ⚠️ Failed to render initial story-run-progress snapshot: ${err?.message ?? err}`,
486
+ );
487
+ }
488
+
489
+ return { installAction, installCmd, installResult, snapshot, renderedBody };
490
+ }
491
+
350
492
  function buildStoryInitResult({
351
493
  storyId,
352
494
  epicId,
@@ -458,11 +600,8 @@ export function renderStoryInitCommentBody(result) {
458
600
  worktreeCreated: result.worktreeCreated,
459
601
  dependenciesInstalled: result.dependenciesInstalled,
460
602
  installStatus: result.installStatus,
461
- // Embed the canonical task list so `story-deliver-prepare.js` can seed the
462
- // initial `story-run-progress` snapshot without re-fetching the task graph.
463
- // Without this field, the prepare CLI silently seeded an empty snapshot,
464
- // breaking every subsequent phase-writer call (it asserts the
465
- // task id is present in the snapshot).
603
+ // Embed the canonical task list so downstream snapshot consumers can
604
+ // seed phase-writer calls without re-fetching the task graph.
466
605
  tasks: Array.isArray(result.tasks)
467
606
  ? result.tasks.map((t) => ({ id: t.id, title: t.title }))
468
607
  : [],
@@ -31,10 +31,10 @@
31
31
  import path from 'node:path';
32
32
  import { parseArgs } from 'node:util';
33
33
  import { runAsCli } from './lib/cli-utils.js';
34
- import { PROJECT_ROOT } from './lib/config-resolver.js';
35
34
  import { syncBranchFromBase } from './lib/git/sync-from-base.js';
36
35
  import { gitSpawn, gitSync } from './lib/git-utils.js';
37
36
  import { Logger } from './lib/Logger.js';
37
+ import { PROJECT_ROOT } from './lib/project-root.js';
38
38
 
39
39
  const progress = Logger.createProgress('sync-branch-from-base', {
40
40
  stderr: true,
@@ -58,7 +58,7 @@ The five pieces every baseline of this shape carries:
58
58
  3. **`<name>:check` npm script** — runs the measurement, compares against
59
59
  `baselines/<name>.json` with a tolerance, exits non-zero on regression.
60
60
  Wired into the close-validation gate chain (see `buildDefaultGates` in
61
- `lib/close-validation.js`) and into the PR gate.
61
+ `lib/close-validation/gates.js`) and into the PR gate.
62
62
  4. **`--self-test` flag** — every check script accepts `--self-test`,
63
63
  which runs the comparator against synthetic inputs (a known-good and a
64
64
  known-regression fixture) and asserts the gate's verdict matches. CI