mandrel 1.76.0 → 1.77.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 (46) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/schemas/agentrc.schema.json +1 -1
  3. package/.agents/schemas/dispatch-manifest.json +1 -1
  4. package/.agents/schemas/validation-evidence.schema.json +2 -1
  5. package/.agents/scripts/audit-to-stories.js +43 -1
  6. package/.agents/scripts/epic-deliver-prepare.js +31 -0
  7. package/.agents/scripts/evidence-gate.js +48 -12
  8. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
  9. package/.agents/scripts/lib/cli-args.js +6 -0
  10. package/.agents/scripts/lib/close-validation/runner.js +25 -8
  11. package/.agents/scripts/lib/config/temp-paths.js +1 -1
  12. package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
  13. package/.agents/scripts/lib/config-resolver.js +4 -1
  14. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  15. package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
  16. package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
  17. package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
  18. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
  19. package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
  20. package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
  21. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
  22. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
  23. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
  24. package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
  25. package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
  26. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
  27. package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
  28. package/.agents/scripts/lib/story-body/story-body.js +76 -7
  29. package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
  30. package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
  31. package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
  32. package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
  33. package/.agents/scripts/lib/validation-evidence.js +63 -25
  34. package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
  35. package/.agents/scripts/resync-status-column.js +5 -0
  36. package/.agents/scripts/run-coverage.js +85 -45
  37. package/.agents/scripts/single-story-init.js +22 -29
  38. package/.agents/scripts/story-init.js +38 -63
  39. package/.agents/scripts/story-phase.js +46 -4
  40. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  41. package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
  42. package/.agents/workflows/helpers/deliver-epic.md +19 -2
  43. package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
  44. package/.agents/workflows/helpers/single-story-deliver.md +12 -0
  45. package/docs/CHANGELOG.md +33 -0
  46. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { LIMITS_DEFAULTS } from '../config/limits.js';
2
2
  import {
3
+ AUTHORING_ALTITUDE_GUIDANCE,
3
4
  DEFAULT_TASK_SIZING,
4
5
  DELIVERABLE_GRANULARITY_GUIDANCE,
5
6
  } from '../orchestration/ticket-validator-sizing.js';
@@ -56,6 +57,15 @@ function render2TierPrompt({ maxTickets, maxTokenBudget }) {
56
57
  // cannot drift (Story #3777).
57
58
  const { definition: granularityDefinition, singleConsumerRule } =
58
59
  DELIVERABLE_GRANULARITY_GUIDANCE;
60
+ // The binding-vs-advisory authoring altitude + the New-File Contract are
61
+ // sourced from the single AUTHORING_ALTITUDE_GUIDANCE constant
62
+ // (ticket-validator-sizing.js) so the prompt and the authoring SKILL cannot
63
+ // drift (Story #4272).
64
+ const {
65
+ altitude: authoringAltitude,
66
+ advisoryCaveat,
67
+ newFileContract,
68
+ } = AUTHORING_ALTITUDE_GUIDANCE;
59
69
  return `You are an expert Senior Project Manager and Orchestrator.
60
70
  Your job is to take a Product Requirements Document (PRD) and a Technical Specification and decompose them into a flat list of Story tickets for an AI Agent to execute.
61
71
 
@@ -114,6 +124,10 @@ The serialized \`body\` string renders these markdown sections (in order):
114
124
  - {"path": "<read-only dependency path>", "assumption": "exists"}
115
125
  - ...
116
126
 
127
+ ## Non-Goals
128
+ - <a capability or change this Story explicitly does NOT deliver>
129
+ - ...
130
+
117
131
  #### STORY BODY RULES:
118
132
 
119
133
  - **goal** (in body string): One sentence stating WHY this story exists within the Epic.
@@ -122,6 +136,15 @@ The serialized \`body\` string renders these markdown sections (in order):
122
136
  - **verify** (top-level array on the ticket object): Each entry MUST name a testing tier in parentheses, drawn from \`unit\` / \`contract\` / \`e2e\` / \`validate\`. Example: \`npm run test -- src/x.test.ts (unit)\`, \`npm run validate (validate)\`. Stories with zero verify entries SHOULD fail validation; if a story is genuinely unverifiable in isolation (e.g., a copy edit auditor will eyeball), the literal entry \`manual:<reason>\` is allowed so the absence is intentional, not lazy. Manual entries without a reason are rejected.
123
137
  - **reason to exist** (REQUIRED, encoded as the \`reason_to_exist\` field of the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): One sentence stating the single coherent reason this Story exists, distinct from its broader \`## Goal\` prose. Every Story MUST carry a non-empty \`reason_to_exist\`; it is the machine-checkable form of the cohesion rule (**one Story = one coherent change with one reason to exist**) and the \`epic-plan-consolidate\` critic flags any Story whose body carries no non-empty reason to exist. Encode it as \`<!-- meta: {"reason_to_exist": "..."} -->\`.
124
138
  - **estimated_test_files** (optional, encoded in the \`<!-- meta: {...} -->\` comment appended to the serialized body string — NOT a top-level ticket field): Integer estimate of how many test files this Story creates or modifies. Omit when the number is not estimable. Informational only — it does not gate the decompose.
139
+ - **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.
140
+
141
+ #### AUTHORING ALTITUDE — BINDING ACCEPTANCE vs ADVISORY CHANGES:
142
+
143
+ ${authoringAltitude}
144
+
145
+ ${newFileContract}
146
+
147
+ ${advisoryCaveat}
125
148
 
126
149
  #### STORY SIZING — COHESION FIRST (the numeric ceiling is only a backstop):
127
150
 
@@ -103,25 +103,34 @@ function requirePositiveInt(value, label) {
103
103
  * - `scopeId === epicId` → `<tempRoot>/epic-<epicId>/validation-evidence.json`
104
104
  * - `scopeId !== epicId` → treated as a Story id → `<tempRoot>/epic-<epicId>/story-<scopeId>/validation-evidence.json`
105
105
  *
106
- * `epicId` is required. The legacy flat
107
- * `temp/validation-evidence-<scopeId>.json` layout is no longer supported —
108
- * callers must thread the Epic id through (Epic #1030 follow-up to Story
109
- * #1054). The synthetic config bag passed to `epicTempDir` keeps the
110
- * resolver from doing a disk-bound `.agentrc.json` lookup; bare callers can
111
- * pass `tempDir` via `opts` to override the default `'temp'`.
106
+ * **Standalone keyspace (Story #4250).** When `opts.standalone === true`,
107
+ * the Story has no parent Epic, so the evidence file is anchored on the
108
+ * Story id alone at
109
+ * `<tempRoot>/standalone/stories/story-<scopeId>/validation-evidence.json`
110
+ * (the `storyTempDir(null, sid)` layout from Story #2874). In this mode
111
+ * `epicId` is ignored — callers MUST NOT feed a `0`/`null` epicId into the
112
+ * Epic-keyed branch (the historical bug this keyspace replaces). Outside
113
+ * standalone mode `epicId` remains required.
114
+ *
115
+ * The legacy flat `temp/validation-evidence-<scopeId>.json` layout is no
116
+ * longer supported — Epic-scoped callers must thread the Epic id through
117
+ * (Epic #1030 follow-up to Story #1054). The synthetic config bag passed to
118
+ * `epicTempDir` / `storyTempDir` keeps the resolver from doing a disk-bound
119
+ * `.agentrc.json` lookup; bare callers can pass `tempDir` via `opts` to
120
+ * override the default `'temp'`.
112
121
  *
113
122
  * @param {number|string} scopeId
114
- * @param {{ cwd?: string, tempDir?: string, epicId: number|string }} opts
123
+ * @param {{ cwd?: string, tempDir?: string, epicId?: number|string|null, standalone?: boolean }} opts
115
124
  * @returns {string}
116
125
  */
117
126
  export function evidencePath(scopeId, opts = {}) {
118
- if (opts.epicId == null) {
127
+ const standalone = opts.standalone === true;
128
+ if (!standalone && opts.epicId == null) {
119
129
  throw new Error(
120
- '[validation-evidence] evidencePath requires opts.epicId (Epic-scoped path resolution).',
130
+ '[validation-evidence] evidencePath requires opts.epicId (Epic-scoped path resolution) unless opts.standalone is set.',
121
131
  );
122
132
  }
123
133
  const { cwd, tempDir } = resolveOpts(opts);
124
- const epicId = requirePositiveInt(opts.epicId, 'epicId');
125
134
  const scope = requirePositiveInt(scopeId, 'scopeId');
126
135
  // Bind the temp tree to the explicit `cwd` (Story #3900): pre-absolutise
127
136
  // the tempRoot under `cwd` and pass it through the canonical
@@ -134,10 +143,18 @@ export function evidencePath(scopeId, opts = {}) {
134
143
  ? tempDir
135
144
  : path.join(cwd, tempDir);
136
145
  const configBag = { project: { paths: { tempRoot: absTempRoot } } };
137
- const dir =
138
- scope === epicId
139
- ? epicTempDir(epicId, configBag)
140
- : storyTempDir(epicId, scope, configBag);
146
+ let dir;
147
+ if (standalone) {
148
+ // Story #4250 — storyId-anchored standalone keyspace. `null` is the
149
+ // standalone-story sentinel `storyTempDir` accepts (Story #2874).
150
+ dir = storyTempDir(null, scope, configBag);
151
+ } else {
152
+ const epicId = requirePositiveInt(opts.epicId, 'epicId');
153
+ dir =
154
+ scope === epicId
155
+ ? epicTempDir(epicId, configBag)
156
+ : storyTempDir(epicId, scope, configBag);
157
+ }
141
158
  return path.join(dir, EVIDENCE_FILENAME);
142
159
  }
143
160
 
@@ -172,15 +189,21 @@ function emptyDoc(scopeId) {
172
189
  * cross-scopeId cases — callers don't have to branch on those failure
173
190
  * modes; they manifest as `shouldSkip()` returning `skip: false`.
174
191
  *
175
- * `opts.epicId` is required so the per-Epic-tree path can be resolved.
192
+ * `opts.epicId` is required so the per-Epic-tree path can be resolved,
193
+ * unless `opts.standalone === true` (Story #4250) routes to the
194
+ * storyId-anchored standalone keyspace.
176
195
  *
177
196
  * @param {number|string} scopeId
178
- * @param {{ cwd?: string, tempDir?: string, epicId: number|string, fs?: object }} opts
197
+ * @param {{ cwd?: string, tempDir?: string, epicId?: number|string|null, standalone?: boolean, fs?: object }} opts
179
198
  * @returns {{ storyId: number, schemaVersion: number, records: object[] }}
180
199
  */
181
200
  export function loadEvidence(scopeId, opts = {}) {
182
201
  const resolved = resolveOpts(opts);
183
- const file = evidencePath(scopeId, { ...resolved, epicId: opts.epicId });
202
+ const file = evidencePath(scopeId, {
203
+ ...resolved,
204
+ epicId: opts.epicId,
205
+ standalone: opts.standalone,
206
+ });
184
207
  if (!resolved.fs.existsSync(file)) return emptyDoc(scopeId);
185
208
  let parsed;
186
209
  try {
@@ -200,7 +223,9 @@ export function loadEvidence(scopeId, opts = {}) {
200
223
  * Validates the resulting document against the schema before writing — a
201
224
  * malformed write throws so the bug surfaces immediately.
202
225
  *
203
- * `opts.epicId` is required so the per-Epic-tree path can be resolved.
226
+ * `opts.epicId` is required so the per-Epic-tree path can be resolved,
227
+ * unless `opts.standalone === true` (Story #4250) routes to the
228
+ * storyId-anchored standalone keyspace.
204
229
  *
205
230
  * @param {{
206
231
  * storyId: number|string,
@@ -210,7 +235,7 @@ export function loadEvidence(scopeId, opts = {}) {
210
235
  * exitCode?: number,
211
236
  * durationMs?: number|null,
212
237
  * }} input
213
- * @param {{ cwd?: string, tempDir?: string, epicId: number|string, fs?: object, now?: Function }} opts
238
+ * @param {{ cwd?: string, tempDir?: string, epicId?: number|string|null, standalone?: boolean, fs?: object, now?: Function }} opts
214
239
  * @returns {object} The persisted record.
215
240
  */
216
241
  export function recordPass(
@@ -231,7 +256,11 @@ export function recordPass(
231
256
  );
232
257
  }
233
258
  const resolved = resolveOpts(opts);
234
- const evidenceOpts = { ...resolved, epicId: opts.epicId };
259
+ const evidenceOpts = {
260
+ ...resolved,
261
+ epicId: opts.epicId,
262
+ standalone: opts.standalone,
263
+ };
235
264
  const doc = loadEvidence(storyId, evidenceOpts);
236
265
  const record = {
237
266
  gateName,
@@ -268,10 +297,13 @@ export function recordPass(
268
297
  * with a machine-readable `reason` so callers can log why the skip didn't
269
298
  * fire.
270
299
  *
271
- * `opts.epicId` is required so the per-Epic-tree path can be resolved.
300
+ * `opts.epicId` is required so the per-Epic-tree path can be resolved,
301
+ * unless `opts.standalone === true` (Story #4250) routes to the
302
+ * storyId-anchored standalone keyspace. `opts` is forwarded verbatim to
303
+ * `loadEvidence`, so `standalone` flows through unchanged.
272
304
  *
273
305
  * @param {{ storyId: number|string, gateName: string, currentSha: string, configHash: string }} input
274
- * @param {{ cwd?: string, tempDir?: string, epicId: number|string, fs?: object }} opts
306
+ * @param {{ cwd?: string, tempDir?: string, epicId?: number|string|null, standalone?: boolean, fs?: object }} opts
275
307
  * @returns {{ skip: boolean, reason: string, record?: object }}
276
308
  */
277
309
  export function shouldSkip(
@@ -308,15 +340,21 @@ export function shouldSkip(
308
340
  * start of each Story so a re-run always starts clean. Idempotent —
309
341
  * absent file is not an error.
310
342
  *
311
- * `opts.epicId` is required so the per-Epic-tree path can be resolved.
343
+ * `opts.epicId` is required so the per-Epic-tree path can be resolved,
344
+ * unless `opts.standalone === true` (Story #4250) routes to the
345
+ * storyId-anchored standalone keyspace.
312
346
  *
313
347
  * @param {number|string} scopeId
314
- * @param {{ cwd?: string, tempDir?: string, epicId: number|string, fs?: object }} opts
348
+ * @param {{ cwd?: string, tempDir?: string, epicId?: number|string|null, standalone?: boolean, fs?: object }} opts
315
349
  * @returns {{ cleared: boolean, path: string }}
316
350
  */
317
351
  export function forceClear(scopeId, opts = {}) {
318
352
  const resolved = resolveOpts(opts);
319
- const file = evidencePath(scopeId, { ...resolved, epicId: opts.epicId });
353
+ const file = evidencePath(scopeId, {
354
+ ...resolved,
355
+ epicId: opts.epicId,
356
+ standalone: opts.standalone,
357
+ });
320
358
  if (!resolved.fs.existsSync(file)) return { cleared: false, path: file };
321
359
  resolved.fs.unlinkSync(file);
322
360
  return { cleared: true, path: file };
@@ -7,6 +7,15 @@
7
7
  *
8
8
  * - `per-worktree` — run the project's package-manager install inside the
9
9
  * worktree (lock-file aware).
10
+ * - `clone` — copy-on-write (reflink/clonefile) clone of the donor's
11
+ * `node_modules` into the worktree (`cp -c` on darwin,
12
+ * `cp --reflink=auto` on linux). Effectively free in time
13
+ * and disk on APFS / reflink-capable filesystems. Falls
14
+ * back to `per-worktree` cleanly on any failure
15
+ * (unsupported filesystem, cross-volume clone, Windows).
16
+ * When the worktree's lockfile byte-matches the donor's
17
+ * and the donor's install markers are present, the
18
+ * per-tree install is skipped.
10
19
  * - `symlink` — symlink (or junction on Windows) the worktree's
11
20
  * `node_modules` to a donor worktree's copy. Refuses on
12
21
  * Windows unless `allowSymlinkOnWindows=true`.
@@ -14,10 +23,11 @@
14
23
  * shared content-addressable store.
15
24
  *
16
25
  * The context passed to each helper carries the minimum state the strategy
17
- * needs: config, platform, logger, and repoRoot (for `symlink`).
26
+ * needs: config, platform, logger, and repoRoot (for `symlink` / `clone`).
18
27
  */
19
28
 
20
29
  import { spawnSync } from 'node:child_process';
30
+ import crypto from 'node:crypto';
21
31
  import fs from 'node:fs';
22
32
  import path from 'node:path';
23
33
  import { detectPackageManager } from '../detect-package-manager.js';
@@ -28,6 +38,21 @@ function sleepSync(ms) {
28
38
  Atomics.wait(signal, 0, 0, ms);
29
39
  }
30
40
 
41
+ /**
42
+ * Resolve the copy-on-write clone donor directory. Defaults to the repo root
43
+ * (the main checkout, which is primed by the host's own install) unless the
44
+ * operator pinned a `primeFromPath`.
45
+ *
46
+ * @param {{ config: object, repoRoot: string }} ctx
47
+ * @returns {string} Absolute donor path.
48
+ */
49
+ function resolveCloneDonor(ctx) {
50
+ const primeFromPath = ctx.config?.primeFromPath;
51
+ return primeFromPath
52
+ ? path.resolve(ctx.repoRoot, primeFromPath)
53
+ : path.resolve(ctx.repoRoot);
54
+ }
55
+
31
56
  /**
32
57
  * Apply the configured `nodeModulesStrategy` after a fresh worktree is added.
33
58
  * Called only during creation.
@@ -43,6 +68,11 @@ export function applyNodeModulesStrategy(ctx, wtPath) {
43
68
  case 'pnpm-store':
44
69
  return;
45
70
 
71
+ case 'clone': {
72
+ cloneNodeModules(ctx, wtPath);
73
+ return;
74
+ }
75
+
46
76
  case 'symlink': {
47
77
  const primeFromPath = ctx.config.primeFromPath;
48
78
  if (!primeFromPath) {
@@ -89,17 +119,106 @@ export function applyNodeModulesStrategy(ctx, wtPath) {
89
119
  default:
90
120
  throw new Error(
91
121
  `WorktreeManager: unknown nodeModulesStrategy '${strategy}'. ` +
92
- 'Expected per-worktree | symlink | pnpm-store.',
122
+ 'Expected per-worktree | clone | symlink | pnpm-store.',
93
123
  );
94
124
  }
95
125
  }
96
126
 
127
+ /**
128
+ * Copy-on-write clone the donor's `node_modules` into the worktree. On a
129
+ * reflink-capable filesystem (APFS on darwin, btrfs/XFS/etc. on linux) the
130
+ * clone is effectively free in both time and disk; the subsequent
131
+ * lockfile-match probe in `installDependencies` then skips the per-tree
132
+ * install entirely.
133
+ *
134
+ * Non-fatal by design: any failure (unsupported filesystem, cross-volume
135
+ * clone, Windows, missing donor) falls back to `per-worktree` cleanly — the
136
+ * worktree is simply left without a cloned `node_modules`, and the regular
137
+ * per-tree install runs. Failure NEVER throws; the clone is a fast-path
138
+ * optimization, not a correctness requirement.
139
+ *
140
+ * @param {{ config: object, platform: NodeJS.Platform, logger: object, repoRoot: string }} ctx
141
+ * @param {string} wtPath Absolute worktree path.
142
+ * @param {{ spawnFn?: typeof spawnSync, fsLike?: typeof fs }} [io] Injectable for tests.
143
+ * @returns {{ cloned: boolean, reason?: string }}
144
+ */
145
+ export function cloneNodeModules(ctx, wtPath, io = {}) {
146
+ const spawnFn = io.spawnFn ?? spawnSync;
147
+ const fsLike = io.fsLike ?? fs;
148
+ // Key off the real host OS, not the test-injection `ctx.platform`, for the
149
+ // capability decision: Windows has no `cp -c`/`--reflink` equivalent here.
150
+ if (process.platform === 'win32') {
151
+ ctx.logger.info(
152
+ 'worktree.node_modules strategy=clone fallback=per-worktree reason=windows-unsupported',
153
+ );
154
+ return { cloned: false, reason: 'windows-unsupported' };
155
+ }
156
+
157
+ const donor = resolveCloneDonor(ctx);
158
+ const donorNodeModules = path.join(donor, 'node_modules');
159
+ if (!fsLike.existsSync(donorNodeModules)) {
160
+ ctx.logger.info(
161
+ `worktree.node_modules strategy=clone fallback=per-worktree reason=donor-node-modules-missing donor=${donorNodeModules}`,
162
+ );
163
+ return { cloned: false, reason: 'donor-node-modules-missing' };
164
+ }
165
+
166
+ const target = path.join(wtPath, 'node_modules');
167
+ if (fsLike.existsSync(target)) {
168
+ // A worktree that already carries node_modules must not be clobbered by a
169
+ // clone-on-top (cp would nest into the existing dir). Treat as already
170
+ // populated and let the install-skip probe decide freshness.
171
+ ctx.logger.info(
172
+ `worktree.node_modules strategy=clone skip reason=target-exists target=${target}`,
173
+ );
174
+ return { cloned: false, reason: 'target-exists' };
175
+ }
176
+
177
+ // darwin: `cp -c` requests a clonefile (APFS). linux: `cp --reflink=auto`
178
+ // requests a reflink, silently degrading to a full copy on non-reflink
179
+ // filesystems — which we deliberately reject below so a slow full-tree copy
180
+ // never masquerades as a free clone.
181
+ const cloneArgs =
182
+ process.platform === 'darwin'
183
+ ? ['-c', '-R', donorNodeModules, target]
184
+ : ['--reflink=always', '-R', donorNodeModules, target];
185
+ const result = spawnFn('cp', cloneArgs, {
186
+ cwd: wtPath,
187
+ stdio: 'pipe',
188
+ encoding: 'utf-8',
189
+ timeout: 120_000,
190
+ });
191
+ if (result.status !== 0) {
192
+ // Clean up a partial copy so the per-worktree fallback install starts from
193
+ // a known-empty target.
194
+ try {
195
+ fsLike.rmSync(target, { recursive: true, force: true });
196
+ } catch {
197
+ // best-effort
198
+ }
199
+ ctx.logger.warn(
200
+ `worktree.node_modules strategy=clone fallback=per-worktree reason=clone-command-failed ` +
201
+ `(${describeAttemptFailure(result, 120_000)}) stderr=${(result.stderr ?? '').slice(0, 300)}`,
202
+ );
203
+ return { cloned: false, reason: 'clone-command-failed' };
204
+ }
205
+
206
+ ctx.logger.info(
207
+ `worktree.node_modules strategy=clone target=${target} source=${donorNodeModules}`,
208
+ );
209
+ return { cloned: true };
210
+ }
211
+
97
212
  /**
98
213
  * Pure: pick the package-manager command + args for a given strategy and
99
214
  * worktree path. Returns `null` when the strategy is `symlink` (handled
100
215
  * elsewhere) or the worktree has no `package.json`.
101
216
  *
102
- * @param {string} strategy One of `per-worktree | pnpm-store | symlink`.
217
+ * `clone` shares `per-worktree`'s PM-detection: when the install is *not*
218
+ * skipped (lockfile mismatch), the worktree still installs with the project's
219
+ * detected package manager.
220
+ *
221
+ * @param {string} strategy One of `per-worktree | clone | pnpm-store | symlink`.
103
222
  * @param {string} wtPath Absolute worktree path.
104
223
  * @param {{ existsSync: (p: string) => boolean }} [fsLike] Injectable for tests.
105
224
  * @returns {{ cmd: string, args: string[] } | null}
@@ -112,6 +231,7 @@ export function selectInstallCommand(strategy, wtPath, fsLike = fs) {
112
231
  return { cmd: 'pnpm', args: ['install', '--frozen-lockfile'] };
113
232
  }
114
233
  // Shared lockfile probe (Story #4048 B3 — one implementation per concept).
234
+ // `per-worktree` and `clone` both fall through to PM detection here.
115
235
  const pm = detectPackageManager(wtPath, (p) => fsLike.existsSync(p)) ?? 'npm';
116
236
  if (pm === 'pnpm') {
117
237
  return { cmd: 'pnpm', args: ['install', '--frozen-lockfile'] };
@@ -138,14 +258,78 @@ const INSTALL_MARKERS = [
138
258
 
139
259
  const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'];
140
260
 
141
- function safeMtimeMs(fsLike, p) {
261
+ /**
262
+ * Pure: locate the first present lockfile in `dir` and return its byte-exact
263
+ * SHA-256 hash, or `null` when no lockfile exists / cannot be read.
264
+ *
265
+ * The hash is the load-bearing freshness key for the install-skip decision —
266
+ * never an mtime, which clone (clonefile/reflink) does not preserve reliably
267
+ * and which is trivially perturbed by a no-op `git checkout`.
268
+ *
269
+ * @param {string} dir Absolute directory containing the lockfile.
270
+ * @param {{ existsSync: Function, readFileSync: Function }} [fsLike]
271
+ * @returns {string | null}
272
+ */
273
+ export function lockfileHash(dir, fsLike = fs) {
274
+ const lockfile = LOCKFILES.map((l) => path.join(dir, l)).find((p) =>
275
+ fsLike.existsSync(p),
276
+ );
277
+ if (!lockfile) return null;
142
278
  try {
143
- return fsLike.statSync(p).mtimeMs;
279
+ const bytes = fsLike.readFileSync(lockfile);
280
+ return crypto.createHash('sha256').update(bytes).digest('hex');
144
281
  } catch {
145
282
  return null;
146
283
  }
147
284
  }
148
285
 
286
+ /**
287
+ * Pure: the single shared "is the per-tree install skippable?" predicate
288
+ * (generalized from `probeReusedInstall`). The install is safe to skip iff:
289
+ *
290
+ * 1. the worktree carries a completed-install marker
291
+ * (`node_modules/<INSTALL_MARKERS>`), AND
292
+ * 2. the worktree's lockfile byte-exactly matches the donor's lockfile.
293
+ *
294
+ * The lockfile match is keyed on a byte-exact SHA-256 hash, NEVER mtime: a
295
+ * reflink/clonefile clone does not preserve mtime relationships, so an mtime
296
+ * comparison would either mis-skip a real change or force a redundant install
297
+ * on every clone. When the donor is omitted (reuse path, no clone donor), the
298
+ * worktree's own lockfile is hashed against itself — i.e. the lockfile-match
299
+ * leg is vacuously satisfied and only the marker presence gates the skip.
300
+ *
301
+ * @param {object} opts
302
+ * @param {string} opts.wtPath Absolute worktree path.
303
+ * @param {string} [opts.donorPath] Absolute donor path (the clone source).
304
+ * @param {{ existsSync: Function, readFileSync: Function }} [opts.fsLike]
305
+ * @returns {{ skippable: boolean, reason: string }}
306
+ */
307
+ export function isInstallSkippable({ wtPath, donorPath, fsLike = fs }) {
308
+ const nmPath = path.join(wtPath, 'node_modules');
309
+ if (!fsLike.existsSync(nmPath)) {
310
+ return { skippable: false, reason: 'node-modules-missing' };
311
+ }
312
+ const marker = INSTALL_MARKERS.map((m) => path.join(nmPath, m)).find((p) =>
313
+ fsLike.existsSync(p),
314
+ );
315
+ if (!marker) {
316
+ return { skippable: false, reason: 'install-incomplete' };
317
+ }
318
+ const wtHash = lockfileHash(wtPath, fsLike);
319
+ if (wtHash === null) {
320
+ // No lockfile in the worktree — nothing to pin freshness against. The
321
+ // marker alone is the signal (matches a lockfile-less project).
322
+ return { skippable: true, reason: 'marker-present-no-lockfile' };
323
+ }
324
+ if (donorPath) {
325
+ const donorHash = lockfileHash(donorPath, fsLike);
326
+ if (donorHash !== null && donorHash !== wtHash) {
327
+ return { skippable: false, reason: 'lockfile-mismatch' };
328
+ }
329
+ }
330
+ return { skippable: true, reason: 'lockfile-match' };
331
+ }
332
+
149
333
  /**
150
334
  * Pure: probe whether a **reused** worktree already carries a completed,
151
335
  * up-to-date install. Worktree reuse must not blindly report
@@ -157,12 +341,17 @@ function safeMtimeMs(fsLike, p) {
157
341
  * - `{ status: 'skipped', reason: 'worktree-reused' }` — a completed
158
342
  * install was detected (or the strategy never installs per-tree);
159
343
  * safe to skip.
160
- * - `{ status: 'failed', reason }` — missing/incomplete/stale install
161
- * detected; callers should retry the install.
344
+ * - `{ status: 'failed', reason }` — missing/incomplete install detected;
345
+ * callers should retry the install.
162
346
  *
163
- * @param {string} strategy One of `per-worktree | pnpm-store | symlink`.
347
+ * Thin wrapper over the shared `isInstallSkippable` predicate (Story #4249
348
+ * one freshness predicate, keyed on a byte-exact lockfile hash, never mtime).
349
+ * The reuse path has no separate donor, so freshness reduces to marker
350
+ * presence (the worktree's own lockfile is its own baseline).
351
+ *
352
+ * @param {string} strategy One of `per-worktree | clone | pnpm-store | symlink`.
164
353
  * @param {string} wtPath Absolute worktree path.
165
- * @param {{ existsSync: Function, statSync: Function }} [fsLike] Injectable for tests.
354
+ * @param {{ existsSync: Function, readFileSync: Function }} [fsLike] Injectable for tests.
166
355
  * @returns {{ status: 'skipped' | 'failed', reason: string }}
167
356
  */
168
357
  export function probeReusedInstall(strategy, wtPath, fsLike = fs) {
@@ -173,34 +362,33 @@ export function probeReusedInstall(strategy, wtPath, fsLike = fs) {
173
362
  if (!fsLike.existsSync(path.join(wtPath, 'package.json'))) {
174
363
  return { status: 'skipped', reason: 'no-package-json' };
175
364
  }
176
- const nmPath = path.join(wtPath, 'node_modules');
177
- if (!fsLike.existsSync(nmPath)) {
178
- return { status: 'failed', reason: 'reuse-node-modules-missing' };
179
- }
180
- const marker = INSTALL_MARKERS.map((m) => path.join(nmPath, m)).find((p) =>
181
- fsLike.existsSync(p),
182
- );
183
- if (!marker) {
184
- return { status: 'failed', reason: 'reuse-install-incomplete' };
185
- }
186
- const markerMtime = safeMtimeMs(fsLike, marker);
187
- const lockfile = LOCKFILES.map((l) => path.join(wtPath, l)).find((p) =>
188
- fsLike.existsSync(p),
189
- );
190
- if (lockfile && markerMtime !== null) {
191
- const lockMtime = safeMtimeMs(fsLike, lockfile);
192
- if (lockMtime !== null && lockMtime > markerMtime) {
193
- return { status: 'failed', reason: 'reuse-node-modules-stale' };
194
- }
365
+ const probe = isInstallSkippable({ wtPath, fsLike });
366
+ if (probe.skippable) {
367
+ return { status: 'skipped', reason: 'worktree-reused' };
195
368
  }
196
- return { status: 'skipped', reason: 'worktree-reused' };
369
+ return {
370
+ status: 'failed',
371
+ reason:
372
+ probe.reason === 'node-modules-missing'
373
+ ? 'reuse-node-modules-missing'
374
+ : 'reuse-install-incomplete',
375
+ };
197
376
  }
198
377
 
199
- /** Pure: retry policy keyed off the chosen command. pnpm gets 3× + 5min. */
378
+ /**
379
+ * Pure: retry policy keyed off the chosen command. pnpm gets 3× + 5min.
380
+ *
381
+ * Story #4249: npm/yarn now get a real `maxAttempts` of 2 (was 1). The
382
+ * formerly hardcoded `npm ci` retry inside `runStoryInitPrepare` was deleted
383
+ * in the same change; this in-`ensure` retry budget is what compensates so
384
+ * npm install reliability does not regress — a transient first-attempt
385
+ * failure now retries here, with the correct (PM-detected) command, instead
386
+ * of an unconditional `npm ci` re-run after init.
387
+ */
200
388
  export function installRetryPolicy(cmd) {
201
389
  const isPnpm = cmd === 'pnpm';
202
390
  return {
203
- maxAttempts: isPnpm ? 3 : 1,
391
+ maxAttempts: isPnpm ? 3 : 2,
204
392
  timeoutMs: isPnpm ? 300_000 : 120_000,
205
393
  backoffMs: [0, 2_000, 5_000],
206
394
  };
@@ -387,6 +575,26 @@ export function installDependencies(ctx, wtPath) {
387
575
  if (selection === null) {
388
576
  return { status: 'skipped', reason: 'no-package-json' };
389
577
  }
578
+ // `clone` reflink-clones the donor's node_modules in `applyNodeModulesStrategy`.
579
+ // When the cloned tree carries a completed-install marker AND the worktree's
580
+ // lockfile byte-matches the donor's, the per-tree install is pure waste —
581
+ // skip it. The freshness predicate is the single shared `isInstallSkippable`
582
+ // (byte-exact lockfile hash, never mtime). On a lockfile mismatch (donor
583
+ // drifted, or no clone happened) this returns false and the install below
584
+ // runs normally.
585
+ if (strategy === 'clone') {
586
+ const donor = resolveCloneDonor(ctx);
587
+ const probe = isInstallSkippable({ wtPath, donorPath: donor });
588
+ if (probe.skippable) {
589
+ ctx.logger.info(
590
+ `worktree.install strategy=clone skip reason=${probe.reason} path=${wtPath}`,
591
+ );
592
+ return { status: 'skipped', reason: `clone-${probe.reason}` };
593
+ }
594
+ ctx.logger.info(
595
+ `worktree.install strategy=clone install reason=${probe.reason} path=${wtPath}`,
596
+ );
597
+ }
390
598
  // Prime the pnpm content-addressable store once per machine before the
391
599
  // worktree's own install runs. No-op for non-pnpm-store strategies. Prime
392
600
  // failures are surfaced as warnings but do not short-circuit the install —
@@ -127,6 +127,7 @@ export function buildReassertOptions({
127
127
  logger,
128
128
  pollAttempts,
129
129
  pollDelayMs,
130
+ config,
130
131
  }) {
131
132
  const opts = {
132
133
  provider,
@@ -135,6 +136,9 @@ export function buildReassertOptions({
135
136
  };
136
137
  if (pollAttempts !== undefined) opts.pollAttempts = pollAttempts;
137
138
  if (pollDelayMs !== undefined) opts.pollDelayMs = pollDelayMs;
139
+ // Story #4252 — forward the resolved config so ColumnSync's on-disk
140
+ // board-metadata cache lands under the project's configured tempRoot.
141
+ if (config !== undefined) opts.config = config;
138
142
  return opts;
139
143
  }
140
144
 
@@ -168,6 +172,7 @@ export async function main(argv = process.argv.slice(2)) {
168
172
  logger: Logger,
169
173
  pollAttempts,
170
174
  pollDelayMs,
175
+ config: effectiveConfig,
171
176
  }),
172
177
  );
173
178
  process.stdout.write(`${JSON.stringify({ ticketId, ...result })}\n`);