@zigrivers/scaffold 3.13.0 → 3.14.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 (73) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/commands/adopt.d.ts.map +1 -1
  3. package/dist/cli/commands/adopt.js +8 -7
  4. package/dist/cli/commands/adopt.js.map +1 -1
  5. package/dist/cli/commands/adopt.serialization.test.js +8 -0
  6. package/dist/cli/commands/adopt.serialization.test.js.map +1 -1
  7. package/dist/cli/commands/adopt.test.js +8 -0
  8. package/dist/cli/commands/adopt.test.js.map +1 -1
  9. package/dist/cli/commands/build.d.ts.map +1 -1
  10. package/dist/cli/commands/build.js +191 -180
  11. package/dist/cli/commands/build.js.map +1 -1
  12. package/dist/cli/commands/complete.d.ts.map +1 -1
  13. package/dist/cli/commands/complete.js +16 -12
  14. package/dist/cli/commands/complete.js.map +1 -1
  15. package/dist/cli/commands/complete.test.js +14 -5
  16. package/dist/cli/commands/complete.test.js.map +1 -1
  17. package/dist/cli/commands/init.d.ts.map +1 -1
  18. package/dist/cli/commands/init.js +43 -49
  19. package/dist/cli/commands/init.js.map +1 -1
  20. package/dist/cli/commands/init.test.js +33 -27
  21. package/dist/cli/commands/init.test.js.map +1 -1
  22. package/dist/cli/commands/reset.d.ts.map +1 -1
  23. package/dist/cli/commands/reset.js +44 -40
  24. package/dist/cli/commands/reset.js.map +1 -1
  25. package/dist/cli/commands/reset.test.js +42 -20
  26. package/dist/cli/commands/reset.test.js.map +1 -1
  27. package/dist/cli/commands/rework.d.ts.map +1 -1
  28. package/dist/cli/commands/rework.js +16 -12
  29. package/dist/cli/commands/rework.js.map +1 -1
  30. package/dist/cli/commands/rework.test.js +12 -3
  31. package/dist/cli/commands/rework.test.js.map +1 -1
  32. package/dist/cli/commands/run.d.ts.map +1 -1
  33. package/dist/cli/commands/run.js +318 -298
  34. package/dist/cli/commands/run.js.map +1 -1
  35. package/dist/cli/commands/run.test.js +92 -120
  36. package/dist/cli/commands/run.test.js.map +1 -1
  37. package/dist/cli/commands/skip.d.ts.map +1 -1
  38. package/dist/cli/commands/skip.js +19 -15
  39. package/dist/cli/commands/skip.js.map +1 -1
  40. package/dist/cli/commands/skip.test.js +22 -11
  41. package/dist/cli/commands/skip.test.js.map +1 -1
  42. package/dist/cli/commands/update.d.ts.map +1 -1
  43. package/dist/cli/commands/update.js +3 -1
  44. package/dist/cli/commands/update.js.map +1 -1
  45. package/dist/cli/commands/update.test.js +8 -4
  46. package/dist/cli/commands/update.test.js.map +1 -1
  47. package/dist/cli/commands/version.d.ts.map +1 -1
  48. package/dist/cli/commands/version.js +3 -1
  49. package/dist/cli/commands/version.js.map +1 -1
  50. package/dist/cli/commands/version.test.js +9 -5
  51. package/dist/cli/commands/version.test.js.map +1 -1
  52. package/dist/cli/index.d.ts.map +1 -1
  53. package/dist/cli/index.js +2 -0
  54. package/dist/cli/index.js.map +1 -1
  55. package/dist/cli/output/interactive.d.ts +1 -0
  56. package/dist/cli/output/interactive.d.ts.map +1 -1
  57. package/dist/cli/output/interactive.js +5 -0
  58. package/dist/cli/output/interactive.js.map +1 -1
  59. package/dist/cli/shutdown.d.ts +51 -0
  60. package/dist/cli/shutdown.d.ts.map +1 -0
  61. package/dist/cli/shutdown.js +199 -0
  62. package/dist/cli/shutdown.js.map +1 -0
  63. package/dist/cli/shutdown.test.d.ts +2 -0
  64. package/dist/cli/shutdown.test.d.ts.map +1 -0
  65. package/dist/cli/shutdown.test.js +316 -0
  66. package/dist/cli/shutdown.test.js.map +1 -0
  67. package/dist/e2e/init.test.js +5 -4
  68. package/dist/e2e/init.test.js.map +1 -1
  69. package/dist/state/lock-manager.d.ts +1 -0
  70. package/dist/state/lock-manager.d.ts.map +1 -1
  71. package/dist/state/lock-manager.js +1 -1
  72. package/dist/state/lock-manager.js.map +1 -1
  73. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
3
  import { StateManager } from '../../state/state-manager.js';
4
- import { acquireLock, releaseLock } from '../../state/lock-manager.js';
4
+ import { acquireLock, getLockPath, releaseLock } from '../../state/lock-manager.js';
5
5
  import { analyzeCrash } from '../../state/completion.js';
6
6
  import { AssemblyEngine } from '../../core/assembly/engine.js';
7
7
  import { getPackageKnowledgeDir } from '../../utils/fs.js';
@@ -18,6 +18,7 @@ import { createOutputContext } from '../../cli/output/context.js';
18
18
  import { displayErrors } from '../../cli/output/error-display.js';
19
19
  import { resolveOutputMode } from '../../cli/middleware/output-mode.js';
20
20
  import { findClosestMatch } from '../../utils/levenshtein.js';
21
+ import { shutdown } from '../shutdown.js';
21
22
  const runCommand = {
22
23
  command: 'run <step>',
23
24
  describe: 'Run a pipeline step',
@@ -51,7 +52,8 @@ const runCommand = {
51
52
  if (!projectRoot) {
52
53
  process.stderr.write('✗ error [PROJECT_NOT_INITIALIZED]: No .scaffold/ directory found\n' +
53
54
  ' Fix: Run `scaffold init` to initialize a project\n');
54
- process.exit(1);
55
+ process.exitCode = 1;
56
+ return;
55
57
  }
56
58
  const outputMode = resolveOutputMode(argv);
57
59
  const output = createOutputContext(outputMode);
@@ -61,9 +63,10 @@ const runCommand = {
61
63
  const context = loadPipelineContext(projectRoot, { includeTools: true });
62
64
  if (!context.config) {
63
65
  displayErrors(context.configErrors, context.configWarnings, output);
64
- process.exit(1);
66
+ process.exitCode = 1;
65
67
  return;
66
68
  }
69
+ const config = context.config;
67
70
  const pipeline = resolvePipeline(context, { output });
68
71
  const metaPrompt = context.metaPrompts.get(step);
69
72
  if (!metaPrompt) {
@@ -76,7 +79,8 @@ const runCommand = {
76
79
  exitCode: 1,
77
80
  recovery: `Available steps: ${candidates.join(', ')}`,
78
81
  });
79
- process.exit(1);
82
+ process.exitCode = 1;
83
+ return;
80
84
  }
81
85
  // -----------------------------------------------------------------------
82
86
  // Step 3: Acquire lock
@@ -92,7 +96,8 @@ const runCommand = {
92
96
  });
93
97
  }
94
98
  output.warn({ code: 'LOCK_HELD', message: 'Another scaffold process is running. Use --force to override.' });
95
- process.exit(3);
99
+ process.exitCode = 3;
100
+ return;
96
101
  }
97
102
  // --force: proceed without lock
98
103
  lockAcquired = false;
@@ -101,337 +106,352 @@ const runCommand = {
101
106
  output.warn(lockResult.warning);
102
107
  }
103
108
  // -----------------------------------------------------------------------
104
- // Step 4: Load and validate state
109
+ // Lock-protected body wrapped in withResource when lock is acquired
105
110
  // -----------------------------------------------------------------------
106
- const stateManager = new StateManager(projectRoot, pipeline.computeEligible);
107
- let state = stateManager.loadState();
108
- // Crash recovery: in_progress is non-null from a previous run
109
- if (state.in_progress !== null) {
110
- const crashAction = analyzeCrash(state, projectRoot);
111
- if (crashAction.action === 'auto_complete') {
112
- const lastDepth = (state.steps[state.in_progress.step]?.depth ?? 3);
113
- stateManager.markCompleted(state.in_progress.step, [], 'scaffold-crash-recovery', lastDepth);
114
- stateManager.clearInProgress();
115
- }
116
- else if (crashAction.action === 'ask_user') {
117
- if (outputMode === 'auto' || outputMode === 'json') {
118
- const crashedStep = state.in_progress.step;
119
- output.warn({
120
- code: 'CRASH_RECOVERY_NEEDED',
121
- message: `Previous run of '${crashedStep}' may be incomplete. ` +
122
- 'Some artifacts present, some missing. ' +
123
- 'Please manually verify and use --force to continue.',
124
- });
125
- if (lockAcquired)
126
- releaseLock(projectRoot);
127
- process.exit(4);
111
+ const lockProtectedBody = async () => {
112
+ // -----------------------------------------------------------------------
113
+ // Step 4: Load and validate state
114
+ // -----------------------------------------------------------------------
115
+ const stateManager = new StateManager(projectRoot, pipeline.computeEligible);
116
+ let state = stateManager.loadState();
117
+ // Crash recovery: in_progress is non-null from a previous run
118
+ if (state.in_progress !== null) {
119
+ const crashAction = analyzeCrash(state, projectRoot);
120
+ if (crashAction.action === 'auto_complete') {
121
+ const lastDepth = (state.steps[state.in_progress.step]?.depth ?? 3);
122
+ stateManager.markCompleted(state.in_progress.step, [], 'scaffold-crash-recovery', lastDepth);
123
+ stateManager.clearInProgress();
128
124
  }
129
- else {
130
- // Interactive: prompt user
131
- const shouldComplete = await output.confirm(`Previous run of '${state.in_progress.step}' appears partially complete. Mark as completed?`, false);
132
- if (shouldComplete) {
133
- const lastDepth = (state.steps[state.in_progress.step]?.depth ?? 3);
134
- stateManager.markCompleted(state.in_progress.step, [], 'scaffold-crash-recovery', lastDepth);
135
- stateManager.clearInProgress();
125
+ else if (crashAction.action === 'ask_user') {
126
+ if (outputMode === 'auto' || outputMode === 'json') {
127
+ const crashedStep = state.in_progress.step;
128
+ output.warn({
129
+ code: 'CRASH_RECOVERY_NEEDED',
130
+ message: `Previous run of '${crashedStep}' may be incomplete. ` +
131
+ 'Some artifacts present, some missing. ' +
132
+ 'Please manually verify and use --force to continue.',
133
+ });
134
+ process.exitCode = 4;
135
+ return;
136
136
  }
137
137
  else {
138
- stateManager.clearInProgress();
138
+ // Interactive: prompt user
139
+ const shouldComplete = await shutdown.withPrompt(() => output.confirm(`Previous run of '${state.in_progress.step}' appears partially complete. Mark as completed?`, false));
140
+ if (shouldComplete) {
141
+ const lastDepth = (state.steps[state.in_progress.step]?.depth ?? 3);
142
+ stateManager.markCompleted(state.in_progress.step, [], 'scaffold-crash-recovery', lastDepth);
143
+ stateManager.clearInProgress();
144
+ }
145
+ else {
146
+ stateManager.clearInProgress();
147
+ }
139
148
  }
140
149
  }
150
+ else {
151
+ // recommend_rerun: just clear
152
+ stateManager.clearInProgress();
153
+ }
154
+ // Reload state after recovery
155
+ state = stateManager.loadState();
141
156
  }
142
- else {
143
- // recommend_rerun: just clear
144
- stateManager.clearInProgress();
157
+ // -----------------------------------------------------------------------
158
+ // Step 5: Check dependencies
159
+ // -----------------------------------------------------------------------
160
+ const { graph } = pipeline;
161
+ const cycles = detectCycles(graph);
162
+ if (cycles.length > 0) {
163
+ displayErrors(cycles, [], output);
164
+ process.exitCode = 1;
165
+ return;
145
166
  }
146
- // Reload state after recovery
147
- state = stateManager.loadState();
148
- }
149
- // -----------------------------------------------------------------------
150
- // Step 5: Check dependencies
151
- // -----------------------------------------------------------------------
152
- const { graph } = pipeline;
153
- const cycles = detectCycles(graph);
154
- if (cycles.length > 0) {
155
- displayErrors(cycles, [], output);
156
- if (lockAcquired)
157
- releaseLock(projectRoot);
158
- process.exit(1);
159
- }
160
- // Tools (category: 'tool') are not in the dependency graph — skip dep checking
161
- const isTool = metaPrompt.frontmatter.category === 'tool';
162
- const stepNode = isTool ? undefined : graph.nodes.get(step);
163
- const deps = isTool
164
- ? (pipeline.overlay.dependencies[step] ?? [])
165
- : (stepNode?.dependencies ?? []);
166
- if (!isTool) {
167
- const unmetDeps = deps.filter(dep => {
168
- // Overlay-disabled deps are treated as satisfied (matches eligibility.ts)
169
- const depNode = graph?.nodes.get(dep);
170
- if (depNode && !depNode.enabled)
171
- return false;
172
- const depStatus = state.steps[dep]?.status;
173
- return depStatus !== 'completed' && depStatus !== 'skipped';
174
- });
175
- if (unmetDeps.length > 0) {
176
- output.error({
177
- code: 'DEP_UNMET',
178
- message: `Step '${step}' has unmet dependencies: ${unmetDeps.join(', ')}`,
179
- exitCode: 2,
180
- recovery: `Complete these steps first: ${unmetDeps.join(', ')}`,
167
+ // Tools (category: 'tool') are not in the dependency graph — skip dep checking
168
+ const isTool = metaPrompt.frontmatter.category === 'tool';
169
+ const stepNode = isTool ? undefined : graph.nodes.get(step);
170
+ const deps = isTool
171
+ ? (pipeline.overlay.dependencies[step] ?? [])
172
+ : (stepNode?.dependencies ?? []);
173
+ if (!isTool) {
174
+ const unmetDeps = deps.filter(dep => {
175
+ // Overlay-disabled deps are treated as satisfied (matches eligibility.ts)
176
+ const depNode = graph?.nodes.get(dep);
177
+ if (depNode && !depNode.enabled)
178
+ return false;
179
+ const depStatus = state.steps[dep]?.status;
180
+ return depStatus !== 'completed' && depStatus !== 'skipped';
181
181
  });
182
- if (lockAcquired)
183
- releaseLock(projectRoot);
184
- process.exit(2);
185
- }
186
- }
187
- // -----------------------------------------------------------------------
188
- // Step 6: Check update mode and depth downgrade
189
- // -----------------------------------------------------------------------
190
- const cliDepth = argv.depth !== undefined ? argv.depth : undefined;
191
- const { depth, provenance } = resolveDepth(step, context.config, pipeline.preset, cliDepth);
192
- const updateModeResult = detectUpdateMode({ step, state, currentDepth: depth, projectRoot });
193
- if (updateModeResult.isUpdateMode) {
194
- if (!argv.force) {
195
- if (outputMode === 'interactive') {
196
- const proceed = await output.confirm(`Step '${step}' is already completed. Re-run in update mode?`, true);
197
- if (!proceed) {
198
- if (lockAcquired)
199
- releaseLock(projectRoot);
200
- process.exit(4);
201
- }
202
- }
203
- else {
204
- output.info(`Re-running step '${step}' in update mode (auto)`);
182
+ if (unmetDeps.length > 0) {
183
+ output.error({
184
+ code: 'DEP_UNMET',
185
+ message: `Step '${step}' has unmet dependencies: ${unmetDeps.join(', ')}`,
186
+ exitCode: 2,
187
+ recovery: `Complete these steps first: ${unmetDeps.join(', ')}`,
188
+ });
189
+ process.exitCode = 2;
190
+ return;
205
191
  }
206
192
  }
207
- // Check for depth downgrade
208
- const hasDowngrade = updateModeResult.warnings.some(w => w.code === 'ASM_DEPTH_DOWNGRADE');
209
- if (hasDowngrade && !argv.force) {
210
- if (outputMode === 'interactive') {
211
- const proceedWithDowngrade = await output.confirm('Depth downgrade detected. Continue?', false);
212
- if (!proceedWithDowngrade) {
213
- if (lockAcquired)
214
- releaseLock(projectRoot);
215
- process.exit(4);
193
+ // -----------------------------------------------------------------------
194
+ // Step 6: Check update mode and depth downgrade
195
+ // -----------------------------------------------------------------------
196
+ const cliDepth = argv.depth !== undefined ? argv.depth : undefined;
197
+ const { depth, provenance } = resolveDepth(step, config, pipeline.preset, cliDepth);
198
+ const updateModeResult = detectUpdateMode({ step, state, currentDepth: depth, projectRoot });
199
+ if (updateModeResult.isUpdateMode) {
200
+ if (!argv.force) {
201
+ if (outputMode === 'interactive') {
202
+ const proceed = await shutdown.withPrompt(() => output.confirm(`Step '${step}' is already completed. Re-run in update mode?`, true));
203
+ if (!proceed) {
204
+ process.exitCode = 4;
205
+ return;
206
+ }
207
+ }
208
+ else {
209
+ output.info(`Re-running step '${step}' in update mode (auto)`);
216
210
  }
217
211
  }
218
- else {
219
- for (const w of updateModeResult.warnings) {
220
- output.warn(w);
212
+ // Check for depth downgrade
213
+ const hasDowngrade = updateModeResult.warnings.some(w => w.code === 'ASM_DEPTH_DOWNGRADE');
214
+ if (hasDowngrade && !argv.force) {
215
+ if (outputMode === 'interactive') {
216
+ const proceedWithDowngrade = await shutdown.withPrompt(() => output.confirm('Depth downgrade detected. Continue?', false));
217
+ if (!proceedWithDowngrade) {
218
+ process.exitCode = 4;
219
+ return;
220
+ }
221
+ }
222
+ else {
223
+ for (const w of updateModeResult.warnings) {
224
+ output.warn(w);
225
+ }
221
226
  }
222
227
  }
223
228
  }
224
- }
225
- // Check methodology change
226
- const methodologyChangeResult = detectMethodologyChange({ state, config: context.config });
227
- for (const w of methodologyChangeResult.warnings) {
228
- output.warn(w);
229
- }
230
- // -----------------------------------------------------------------------
231
- // Step 7: Set step to in_progress (skip for stateless steps)
232
- // -----------------------------------------------------------------------
233
- const isStateless = metaPrompt.frontmatter.stateless === true;
234
- if (!isStateless) {
235
- stateManager.setInProgress(step, 'scaffold-run');
236
- }
237
- try {
238
- // Reload state after setInProgress
239
- state = stateManager.loadState();
240
- // -----------------------------------------------------------------------
241
- // Step 8: Load assembly components
242
- // -----------------------------------------------------------------------
243
- const { instructions } = loadInstructions(projectRoot, step, argv.instructions);
244
- const kbIndex = buildIndexWithOverrides(projectRoot, getPackageKnowledgeDir(projectRoot));
245
- const { entries: knowledgeEntries, warnings: kbWarnings } = loadEntries(kbIndex, pipeline.overlay.knowledge[step] ?? metaPrompt.frontmatter.knowledgeBase ?? []);
246
- for (const w of kbWarnings) {
229
+ // Check methodology change
230
+ const methodologyChangeResult = detectMethodologyChange({ state, config });
231
+ for (const w of methodologyChangeResult.warnings) {
247
232
  output.warn(w);
248
233
  }
249
- // Gather artifacts from completed dependency steps
250
- const artifacts = [];
251
- const gatheredPaths = new Set();
252
- for (const dep of deps) {
253
- const depEntry = state.steps[dep];
254
- if (depEntry?.status === 'completed' && depEntry.produces) {
255
- for (const relPath of depEntry.produces) {
256
- const fullPath = path.resolve(projectRoot, relPath);
257
- if (fs.existsSync(fullPath)) {
258
- try {
259
- const content = fs.readFileSync(fullPath, 'utf8');
260
- artifacts.push({ stepName: dep, filePath: relPath, content });
261
- gatheredPaths.add(relPath);
234
+ // -----------------------------------------------------------------------
235
+ // Step 7: Set step to in_progress (skip for stateless steps)
236
+ // -----------------------------------------------------------------------
237
+ const isStateless = metaPrompt.frontmatter.stateless === true;
238
+ try {
239
+ await shutdown.withContext(() => {
240
+ try {
241
+ const ctxState = stateManager.loadState();
242
+ return ctxState.in_progress !== null
243
+ ? 'Cancelled. Step progress cleared.'
244
+ : 'Cancelled.';
245
+ }
246
+ catch {
247
+ return 'Cancelled.';
248
+ }
249
+ }, async () => {
250
+ // Note: Do NOT wrap in withResource('in-progress') — clearing in_progress
251
+ // on Ctrl+C would break crash recovery. The existing analyzeCrash() logic
252
+ // handles interrupted steps correctly on the next run.
253
+ if (!isStateless) {
254
+ stateManager.setInProgress(step, 'scaffold-run');
255
+ }
256
+ // Reload state after setInProgress
257
+ state = stateManager.loadState();
258
+ // -----------------------------------------------------------------------
259
+ // Step 8: Load assembly components
260
+ // -----------------------------------------------------------------------
261
+ const { instructions } = loadInstructions(projectRoot, step, argv.instructions);
262
+ const kbIndex = buildIndexWithOverrides(projectRoot, getPackageKnowledgeDir(projectRoot));
263
+ const { entries: knowledgeEntries, warnings: kbWarnings } = loadEntries(kbIndex, pipeline.overlay.knowledge[step] ?? metaPrompt.frontmatter.knowledgeBase ?? []);
264
+ for (const w of kbWarnings) {
265
+ output.warn(w);
266
+ }
267
+ // Gather artifacts from completed dependency steps
268
+ const artifacts = [];
269
+ const gatheredPaths = new Set();
270
+ for (const dep of deps) {
271
+ const depEntry = state.steps[dep];
272
+ if (depEntry?.status === 'completed' && depEntry.produces) {
273
+ for (const relPath of depEntry.produces) {
274
+ const fullPath = path.resolve(projectRoot, relPath);
275
+ if (fs.existsSync(fullPath)) {
276
+ try {
277
+ const content = fs.readFileSync(fullPath, 'utf8');
278
+ artifacts.push({ stepName: dep, filePath: relPath, content });
279
+ gatheredPaths.add(relPath);
280
+ }
281
+ catch (err) {
282
+ output.warn({
283
+ code: 'ARTIFACT_READ_ERROR',
284
+ message: `Could not read artifact '${relPath}' from step '${dep}': ${err.message}`,
285
+ });
286
+ }
287
+ }
262
288
  }
263
- catch (err) {
264
- output.warn({
265
- code: 'ARTIFACT_READ_ERROR',
266
- message: `Could not read artifact '${relPath}' from step '${dep}': ${err.message}`,
267
- });
289
+ }
290
+ }
291
+ // Gather artifacts from reads (optional cross-cutting references)
292
+ // Note: graph defaults missing steps to enabled:true, which may not reflect
293
+ // custom config overrides. This is a pre-existing graph builder limitation.
294
+ const reads = pipeline.overlay.reads[step] ?? metaPrompt.frontmatter.reads ?? [];
295
+ for (const readStep of reads) {
296
+ // Check dependency graph for enablement (overlay-disabled steps)
297
+ const readNode = graph?.nodes.get(readStep);
298
+ if (readNode && !readNode.enabled)
299
+ continue;
300
+ // Check state — silently skip if not completed (reads are optional)
301
+ const readEntry = state.steps[readStep];
302
+ if (readEntry?.status !== 'completed' || !readEntry.produces)
303
+ continue;
304
+ for (const relPath of readEntry.produces) {
305
+ // Deduplicate: skip paths already gathered from deps
306
+ if (gatheredPaths.has(relPath))
307
+ continue;
308
+ const fullPath = path.resolve(projectRoot, relPath);
309
+ if (fs.existsSync(fullPath)) {
310
+ try {
311
+ const content = fs.readFileSync(fullPath, 'utf8');
312
+ artifacts.push({ stepName: readStep, filePath: relPath, content });
313
+ gatheredPaths.add(relPath);
314
+ }
315
+ catch (err) {
316
+ output.warn({
317
+ code: 'ARTIFACT_READ_ERROR',
318
+ message: `Could not read artifact '${relPath}' from step` +
319
+ ` '${readStep}': ${err.message}`,
320
+ });
321
+ }
268
322
  }
269
323
  }
270
324
  }
271
- }
272
- }
273
- // Gather artifacts from reads (optional cross-cutting references)
274
- // Note: graph defaults missing steps to enabled:true, which may not reflect
275
- // custom config overrides. This is a pre-existing graph builder limitation.
276
- const reads = pipeline.overlay.reads[step] ?? metaPrompt.frontmatter.reads ?? [];
277
- for (const readStep of reads) {
278
- // Check dependency graph for enablement (overlay-disabled steps)
279
- const readNode = graph?.nodes.get(readStep);
280
- if (readNode && !readNode.enabled)
281
- continue;
282
- // Check state — silently skip if not completed (reads are optional)
283
- const readEntry = state.steps[readStep];
284
- if (readEntry?.status !== 'completed' || !readEntry.produces)
285
- continue;
286
- for (const relPath of readEntry.produces) {
287
- // Deduplicate: skip paths already gathered from deps
288
- if (gatheredPaths.has(relPath))
289
- continue;
290
- const fullPath = path.resolve(projectRoot, relPath);
291
- if (fs.existsSync(fullPath)) {
325
+ // Read decisions log
326
+ const decisionsPath = path.join(projectRoot, '.scaffold', 'decisions.jsonl');
327
+ let decisions = '';
328
+ if (fs.existsSync(decisionsPath)) {
292
329
  try {
293
- const content = fs.readFileSync(fullPath, 'utf8');
294
- artifacts.push({ stepName: readStep, filePath: relPath, content });
295
- gatheredPaths.add(relPath);
330
+ decisions = fs.readFileSync(decisionsPath, 'utf8');
296
331
  }
297
332
  catch (err) {
298
333
  output.warn({
299
- code: 'ARTIFACT_READ_ERROR',
300
- message: `Could not read artifact '${relPath}' from step '${readStep}': ${err.message}`,
334
+ code: 'DECISIONS_READ_ERROR',
335
+ message: `Could not read decisions log: ${err.message}`,
301
336
  });
302
337
  }
303
338
  }
304
- }
305
- }
306
- // Read decisions log
307
- const decisionsPath = path.join(projectRoot, '.scaffold', 'decisions.jsonl');
308
- let decisions = '';
309
- if (fs.existsSync(decisionsPath)) {
310
- try {
311
- decisions = fs.readFileSync(decisionsPath, 'utf8');
312
- }
313
- catch (err) {
314
- output.warn({
315
- code: 'DECISIONS_READ_ERROR',
316
- message: `Could not read decisions log: ${err.message}`,
339
+ // -----------------------------------------------------------------------
340
+ // Step 9: Assemble prompt
341
+ // -----------------------------------------------------------------------
342
+ const engine = new AssemblyEngine();
343
+ const assemblyResult = engine.assemble(step, {
344
+ config,
345
+ state,
346
+ metaPrompt,
347
+ knowledgeEntries,
348
+ instructions,
349
+ arguments: argv.instructions,
350
+ depth,
351
+ depthProvenance: provenance,
352
+ updateMode: updateModeResult.isUpdateMode,
353
+ existingArtifact: updateModeResult.existingArtifact,
354
+ artifacts,
355
+ decisions,
317
356
  });
318
- }
319
- }
320
- // -----------------------------------------------------------------------
321
- // Step 9: Assemble prompt
322
- // -----------------------------------------------------------------------
323
- const engine = new AssemblyEngine();
324
- const assemblyResult = engine.assemble(step, {
325
- config: context.config,
326
- state,
327
- metaPrompt,
328
- knowledgeEntries,
329
- instructions,
330
- arguments: argv.instructions,
331
- depth,
332
- depthProvenance: provenance,
333
- updateMode: updateModeResult.isUpdateMode,
334
- existingArtifact: updateModeResult.existingArtifact,
335
- artifacts,
336
- decisions,
337
- });
338
- if (!assemblyResult.success) {
339
- displayErrors(assemblyResult.errors, assemblyResult.warnings, output);
340
- if (lockAcquired)
341
- releaseLock(projectRoot);
342
- process.exit(5);
343
- }
344
- // -----------------------------------------------------------------------
345
- // Step 10: Wait for completion (interactive) or exit (auto/json)
346
- // -----------------------------------------------------------------------
347
- if (outputMode === 'auto' || outputMode === 'json') {
348
- // In auto/json mode: output the structured result and exit 0
349
- // For stateful steps, step stays in_progress for crash recovery awareness
350
- if (outputMode === 'json') {
351
- if (isStateless) {
352
- output.result({
353
- step,
354
- status: 'stateless',
355
- depth,
356
- depth_source: provenance,
357
- prompt: assemblyResult.prompt.text,
358
- });
357
+ if (!assemblyResult.success) {
358
+ displayErrors(assemblyResult.errors, assemblyResult.warnings, output);
359
+ process.exitCode = 5;
360
+ return;
359
361
  }
360
- else {
361
- // Reload state for next eligible
362
- const stateForEligible = stateManager.loadState();
363
- const nextSteps = pipeline.computeEligible(stateForEligible.steps);
364
- output.result({
365
- step,
366
- status: 'in_progress',
367
- depth,
368
- depth_source: provenance,
369
- nextEligible: nextSteps,
370
- prompt: assemblyResult.prompt.text,
371
- });
362
+ // -----------------------------------------------------------------------
363
+ // Step 10: Wait for completion (interactive) or exit (auto/json)
364
+ // -----------------------------------------------------------------------
365
+ if (outputMode === 'auto' || outputMode === 'json') {
366
+ // In auto/json mode: output the structured result and exit 0
367
+ // For stateful steps, step stays in_progress for crash recovery awareness
368
+ if (outputMode === 'json') {
369
+ if (isStateless) {
370
+ output.result({
371
+ step,
372
+ status: 'stateless',
373
+ depth,
374
+ depth_source: provenance,
375
+ prompt: assemblyResult.prompt.text,
376
+ });
377
+ }
378
+ else {
379
+ // Reload state for next eligible
380
+ const stateForEligible = stateManager.loadState();
381
+ const nextSteps = pipeline.computeEligible(stateForEligible.steps);
382
+ output.result({
383
+ step,
384
+ status: 'in_progress',
385
+ depth,
386
+ depth_source: provenance,
387
+ nextEligible: nextSteps,
388
+ prompt: assemblyResult.prompt.text,
389
+ });
390
+ }
391
+ }
392
+ else {
393
+ // auto mode: write prompt to stdout for AI consumption
394
+ process.stdout.write(assemblyResult.prompt.text);
395
+ }
396
+ return;
372
397
  }
373
- }
374
- else {
375
- // auto mode: write prompt to stdout for AI consumption
398
+ // Write assembled prompt to stdout (raw, for AI consumption in interactive mode)
376
399
  process.stdout.write(assemblyResult.prompt.text);
377
- }
378
- if (lockAcquired)
379
- releaseLock(projectRoot);
380
- process.exit(0);
400
+ // Interactive mode: prompt user for completion
401
+ if (isStateless) {
402
+ // Stateless steps don't track completion — just exit
403
+ if (outputMode === 'interactive') {
404
+ output.info(`Stateless step '${step}' executed. Available for re-use anytime.`);
405
+ }
406
+ return;
407
+ }
408
+ const isComplete = await shutdown.withPrompt(() => output.confirm(`Step '${step}' complete?`, true));
409
+ if (!isComplete) {
410
+ const shouldSkip = await shutdown.withPrompt(() => output.confirm('Mark as skipped instead?', false));
411
+ if (shouldSkip) {
412
+ stateManager.markSkipped(step, 'user-cancelled', 'scaffold-run');
413
+ }
414
+ else {
415
+ stateManager.clearInProgress();
416
+ }
417
+ process.exitCode = 4;
418
+ return;
419
+ }
420
+ // -----------------------------------------------------------------------
421
+ // Step 11: Mark completed
422
+ // -----------------------------------------------------------------------
423
+ stateManager.markCompleted(step, metaPrompt.frontmatter.outputs ?? [], 'scaffold-run', depth);
424
+ // -----------------------------------------------------------------------
425
+ // Step 12: Show next eligible steps
426
+ // -----------------------------------------------------------------------
427
+ const finalState = stateManager.loadState();
428
+ const nextSteps = pipeline.computeEligible(finalState.steps);
429
+ if (outputMode === 'interactive') {
430
+ if (nextSteps.length > 0) {
431
+ output.info(`Next eligible: ${nextSteps.join(', ')}`);
432
+ }
433
+ else {
434
+ output.info('No more eligible steps.');
435
+ }
436
+ }
437
+ });
381
438
  }
382
- // Write assembled prompt to stdout (raw, for AI consumption in interactive mode)
383
- process.stdout.write(assemblyResult.prompt.text);
384
- // Interactive mode: prompt user for completion
385
- if (isStateless) {
386
- // Stateless steps don't track completion — just release lock and exit
387
- if (lockAcquired)
388
- releaseLock(projectRoot);
389
- if (outputMode === 'interactive') {
390
- output.info(`Stateless step '${step}' executed. Available for re-use anytime.`);
391
- }
392
- process.exit(0);
439
+ catch (err) {
440
+ const message = err instanceof Error ? err.message : String(err);
441
+ output.error({ code: 'RUN_UNEXPECTED_ERROR', message, exitCode: 1 });
442
+ process.exitCode = 1;
393
443
  return;
394
444
  }
395
- const isComplete = await output.confirm(`Step '${step}' complete?`, true);
396
- if (!isComplete) {
397
- const shouldSkip = await output.confirm('Mark as skipped instead?', false);
398
- if (shouldSkip) {
399
- stateManager.markSkipped(step, 'user-cancelled', 'scaffold-run');
400
- }
401
- else {
402
- stateManager.clearInProgress();
403
- }
404
- if (lockAcquired)
405
- releaseLock(projectRoot);
406
- process.exit(4);
407
- }
408
- // -----------------------------------------------------------------------
409
- // Step 11: Mark completed
410
- // -----------------------------------------------------------------------
411
- stateManager.markCompleted(step, metaPrompt.frontmatter.outputs ?? [], 'scaffold-run', depth);
412
- if (lockAcquired)
445
+ };
446
+ if (lockAcquired) {
447
+ shutdown.registerLockOwnership(getLockPath(projectRoot));
448
+ await shutdown.withResource('lock', () => {
413
449
  releaseLock(projectRoot);
414
- // -----------------------------------------------------------------------
415
- // Step 12: Show next eligible steps
416
- // -----------------------------------------------------------------------
417
- const finalState = stateManager.loadState();
418
- const nextSteps = pipeline.computeEligible(finalState.steps);
419
- if (outputMode === 'interactive') {
420
- if (nextSteps.length > 0) {
421
- output.info(`Next eligible: ${nextSteps.join(', ')}`);
422
- }
423
- else {
424
- output.info('No more eligible steps.');
425
- }
426
- }
427
- process.exit(0);
450
+ shutdown.releaseLockOwnership();
451
+ }, lockProtectedBody);
428
452
  }
429
- catch (err) {
430
- if (lockAcquired)
431
- releaseLock(projectRoot);
432
- const message = err instanceof Error ? err.message : String(err);
433
- output.error({ code: 'RUN_UNEXPECTED_ERROR', message, exitCode: 1 });
434
- process.exit(1);
453
+ else {
454
+ await lockProtectedBody();
435
455
  }
436
456
  },
437
457
  };