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.
- package/.agents/docs/configuration.md +2 -2
- package/.agents/schemas/agentrc.schema.json +1 -1
- package/.agents/schemas/dispatch-manifest.json +1 -1
- package/.agents/schemas/validation-evidence.schema.json +2 -1
- package/.agents/scripts/audit-to-stories.js +43 -1
- package/.agents/scripts/epic-deliver-prepare.js +31 -0
- package/.agents/scripts/evidence-gate.js +48 -12
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +141 -34
- package/.agents/scripts/lib/cli-args.js +6 -0
- package/.agents/scripts/lib/close-validation/runner.js +25 -8
- package/.agents/scripts/lib/config/temp-paths.js +1 -1
- package/.agents/scripts/lib/config/worktree-isolation.js +18 -3
- package/.agents/scripts/lib/config-resolver.js +4 -1
- package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
- package/.agents/scripts/lib/git-branch-lifecycle.js +90 -0
- package/.agents/scripts/lib/orchestration/auto-merge-cwd.js +128 -0
- package/.agents/scripts/lib/orchestration/column-sync.js +88 -9
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +20 -2
- package/.agents/scripts/lib/orchestration/project-meta-cache.js +238 -0
- package/.agents/scripts/lib/orchestration/reassert-status-column.js +3 -1
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +25 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +80 -14
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +74 -25
- package/.agents/scripts/lib/orchestration/story-close/phases/locked-pipeline.js +10 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +48 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +148 -4
- package/.agents/scripts/lib/orchestration/ticketing/transition.js +8 -1
- package/.agents/scripts/lib/story-body/story-body.js +76 -7
- package/.agents/scripts/lib/story-init/branch-initializer.js +29 -43
- package/.agents/scripts/lib/story-init/hierarchy-tracer.js +25 -4
- package/.agents/scripts/lib/story-init/task-graph-builder.js +22 -12
- package/.agents/scripts/lib/templates/decomposer-prompts.js +23 -0
- package/.agents/scripts/lib/validation-evidence.js +63 -25
- package/.agents/scripts/lib/worktree/node-modules-strategy.js +239 -31
- package/.agents/scripts/resync-status-column.js +5 -0
- package/.agents/scripts/run-coverage.js +85 -45
- package/.agents/scripts/single-story-init.js +22 -29
- package/.agents/scripts/story-init.js +38 -63
- package/.agents/scripts/story-phase.js +46 -4
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/workflows/helpers/acceptance-self-eval.md +27 -0
- package/.agents/workflows/helpers/deliver-epic.md +19 -2
- package/.agents/workflows/helpers/epic-deliver-story.md +50 -14
- package/.agents/workflows/helpers/single-story-deliver.md +12 -0
- package/docs/CHANGELOG.md +33 -0
- 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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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, {
|
|
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
|
|
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 = {
|
|
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
|
|
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
|
|
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, {
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
161
|
-
*
|
|
344
|
+
* - `{ status: 'failed', reason }` — missing/incomplete install detected;
|
|
345
|
+
* callers should retry the install.
|
|
162
346
|
*
|
|
163
|
-
*
|
|
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,
|
|
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
|
|
177
|
-
if (
|
|
178
|
-
return { status: '
|
|
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 {
|
|
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
|
-
/**
|
|
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 :
|
|
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`);
|