@ikunin/sprintpilot 1.0.5 → 2.0.4

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 (34) hide show
  1. package/_Sprintpilot/Sprintpilot.md +14 -1
  2. package/_Sprintpilot/manifest.yaml +1 -1
  3. package/_Sprintpilot/modules/autopilot/config.yaml +22 -0
  4. package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +45 -0
  5. package/_Sprintpilot/modules/autopilot/profiles/large.yaml +22 -0
  6. package/_Sprintpilot/modules/autopilot/profiles/legacy.yaml +35 -0
  7. package/_Sprintpilot/modules/autopilot/profiles/medium.yaml +5 -0
  8. package/_Sprintpilot/modules/autopilot/profiles/nano.yaml +35 -0
  9. package/_Sprintpilot/modules/autopilot/profiles/small.yaml +5 -0
  10. package/_Sprintpilot/modules/git/config.yaml +8 -0
  11. package/_Sprintpilot/modules/ma/config.yaml +42 -0
  12. package/_Sprintpilot/scripts/agent-adapter.js +247 -0
  13. package/_Sprintpilot/scripts/cached-read.js +238 -0
  14. package/_Sprintpilot/scripts/check-prereqs.js +139 -0
  15. package/_Sprintpilot/scripts/dispatch-layer.js +192 -0
  16. package/_Sprintpilot/scripts/git-portable.js +219 -0
  17. package/_Sprintpilot/scripts/infer-dependencies.js +594 -0
  18. package/_Sprintpilot/scripts/inject-tasks-section.js +279 -0
  19. package/_Sprintpilot/scripts/list-remaining-stories.js +295 -0
  20. package/_Sprintpilot/scripts/log-timing.js +360 -0
  21. package/_Sprintpilot/scripts/mark-done-stories-tasks.js +254 -0
  22. package/_Sprintpilot/scripts/merge-shards.js +339 -0
  23. package/_Sprintpilot/scripts/preflight-merge.js +235 -0
  24. package/_Sprintpilot/scripts/resolve-dag.js +559 -0
  25. package/_Sprintpilot/scripts/resolve-profile.js +355 -0
  26. package/_Sprintpilot/scripts/state-shard.js +602 -0
  27. package/_Sprintpilot/scripts/submodule-lock.js +130 -0
  28. package/_Sprintpilot/scripts/summarize-timings.js +362 -0
  29. package/_Sprintpilot/scripts/sync-status.js +13 -0
  30. package/_Sprintpilot/scripts/with-retry.js +145 -0
  31. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +572 -42
  32. package/bin/sprintpilot.js +4 -0
  33. package/lib/commands/install.js +157 -1
  34. package/package.json +1 -1
@@ -0,0 +1,594 @@
1
+ #!/usr/bin/env node
2
+
3
+ // infer-dependencies.js — validates an LLM-produced inter-story dependency
4
+ // envelope and writes _Sprintpilot/sprints/dependencies.yaml for resolve-dag.
5
+ //
6
+ // Sprintpilot scripts NEVER call LLMs (architecture rule). The autopilot
7
+ // session does the inference inline in a workflow.md action, then pipes the
8
+ // resulting JSON envelope into this script via stdin. The script:
9
+ // 1. Validates the envelope (schema, unknown keys, self-deps, cross-epic
10
+ // edges, missing rationales, cycles).
11
+ // 2. Merges with any existing dependencies.yaml — preserving the user's
12
+ // `overrides:` and `epics:` blocks verbatim if present.
13
+ // 3. Writes the file with an `# AUTO-INFERRED` marker header so future
14
+ // runs (and humans) can distinguish auto vs hand-authored content.
15
+ //
16
+ // Usage:
17
+ // infer-dependencies.js scaffold-prompt --epic <id> [--project-root <path>]
18
+ // infer-dependencies.js dry-run --epic <id> [--project-root <path>]
19
+ // infer-dependencies.js write --epic <id> [--project-root <path>] [--force]
20
+ //
21
+ // Subcommands:
22
+ // scaffold-prompt — emits the literal LLM prompt with file paths
23
+ // interpolated. Stdout-only; the workflow reads this
24
+ // and feeds it into the in-conversation reasoning step.
25
+ // Exit 0 always.
26
+ // dry-run — accepts LLM JSON via stdin, validates, returns
27
+ // `{valid, errors, merged_doc, diff}` envelope on
28
+ // stdout. Exit 0 if valid; 1 otherwise.
29
+ // write — accepts LLM JSON via stdin, validates, writes the
30
+ // dependencies.yaml file. Exit 0 on success, 1 on
31
+ // validation failure, 2 if a hand-authored file
32
+ // (no marker) exists and --force is not set.
33
+ //
34
+ // LLM JSON envelope:
35
+ // { "version": 1, "epic": "1",
36
+ // "dependencies": { "<key>": ["<dep-key>", ...], ... },
37
+ // "rationale": { "<key>": "1-sentence justification", ... } }
38
+ //
39
+ // Stories with no inbound deps are absent from `dependencies` (distinguishes
40
+ // "no deps" from "LLM forgot"). Rationale is required for every key in
41
+ // `dependencies` so reviewers can spot hallucinated edges.
42
+
43
+ const crypto = require('node:crypto');
44
+ const fs = require('node:fs');
45
+ const path = require('node:path');
46
+
47
+ const { parseArgs } = require('../lib/runtime/args');
48
+ const log = require('../lib/runtime/log');
49
+
50
+ const dagMod = require('./resolve-dag.js');
51
+ const timing = require('./log-timing.js');
52
+
53
+ function emitTimingEvent(projectRoot, phase, meta) {
54
+ try {
55
+ if (!timing.isEnabled(projectRoot)) return;
56
+ timing.appendLine(projectRoot, 'sprint', timing.buildEntry('once', 'sprint', phase, meta));
57
+ } catch {
58
+ /* ignore — timing is best-effort */
59
+ }
60
+ }
61
+ const {
62
+ readStoriesFromStatus,
63
+ parseEpicFromKey,
64
+ parseDependenciesYaml,
65
+ topoLayers,
66
+ dependenciesPath,
67
+ sprintStatusPath,
68
+ } = dagMod;
69
+
70
+ const AUTO_MARKER = '# AUTO-INFERRED — regenerate via infer-dependencies.js';
71
+ const VALID_COMMANDS = ['scaffold-prompt', 'dry-run', 'write'];
72
+
73
+ function help() {
74
+ log.out(
75
+ [
76
+ 'Usage:',
77
+ ' infer-dependencies.js scaffold-prompt --epic <id> [--project-root <path>]',
78
+ ' infer-dependencies.js dry-run --epic <id> [--project-root <path>]',
79
+ ' infer-dependencies.js write --epic <id> [--project-root <path>] [--force]',
80
+ '',
81
+ 'Validates an LLM-produced dependency envelope (read from stdin) and',
82
+ 'writes _Sprintpilot/sprints/dependencies.yaml. The autopilot session',
83
+ 'is the LLM caller — this script never calls a model itself.',
84
+ ].join('\n'),
85
+ );
86
+ }
87
+
88
+ // ---------------------------------------------------------------
89
+ // Prompt scaffolding (stdout for the workflow to feed back to LLM)
90
+ // ---------------------------------------------------------------
91
+
92
+ function scaffoldPrompt(projectRoot, epic) {
93
+ const ssFile = sprintStatusPath(projectRoot);
94
+ const epicsFile = path.join(projectRoot, '_bmad-output', 'planning-artifacts', 'epics.md');
95
+ const archFile = path.join(projectRoot, '_bmad-output', 'planning-artifacts', 'architecture.md');
96
+ const depsFile = dependenciesPath(projectRoot);
97
+ const lines = [
98
+ `You are inferring inter-story execution dependencies for epic ${epic}. Your output controls which stories Sprintpilot runs concurrently vs sequentially. Wrong dependencies just over-serialize the sprint (slower, not broken). Wrong independence claims cause merge conflicts in worktrees.`,
99
+ '',
100
+ 'READ in order, then output the JSON envelope below:',
101
+ `1. ${ssFile} — the authoritative list of story keys for epic ${epic}. Use ONLY these keys.`,
102
+ `2. ${epicsFile} — story descriptions and Acceptance Criteria.`,
103
+ `3. ${archFile} — component map.`,
104
+ `4. ${depsFile} if present — existing user overrides (you must NOT modify these; the script preserves them).`,
105
+ '',
106
+ 'RULES — only emit a dependency edge when ONE of the following is concretely true from the read documents:',
107
+ "- Story B's Acceptance Criteria explicitly reference an artifact (table, endpoint, component, file) that story A creates.",
108
+ "- Both stories modify the same file path (mentioned in either story's tasks/AC) — emit B → A so they serialize.",
109
+ '- Story B\'s description begins with words like "Extend", "Add to", "Build on", or names story A.',
110
+ '- Architecture.md groups the modules involved and the dependency direction is explicit.',
111
+ '',
112
+ 'DO NOT emit a dependency for:',
113
+ '- General "comes later in the sprint" ordering preferences.',
114
+ '- Vague thematic similarity ("both touch the user feature").',
115
+ '- Test-only or doc-only relationships.',
116
+ `- Different epics — only edges within epic ${epic} are valid.`,
117
+ '',
118
+ 'OUTPUT — exactly one JSON object, no prose, no fences:',
119
+ '',
120
+ ` { "version": 1, "epic": "${epic}", "dependencies": { "<key>": ["<dep-key>", ...], ... }, "rationale": { "<key>": "1 sentence quoting the AC/file/architecture line that justifies it", ... } }`,
121
+ '',
122
+ 'Stories with no dependencies: omit them entirely from `dependencies`. Provide `rationale` for every key you DO list.',
123
+ ];
124
+ return lines.join('\n');
125
+ }
126
+
127
+ // ---------------------------------------------------------------
128
+ // Validation
129
+ // ---------------------------------------------------------------
130
+
131
+ function validateEnvelope(envelope, { projectRoot, epic }) {
132
+ const errors = [];
133
+ const push = (e) => errors.push(e);
134
+
135
+ if (envelope === null || typeof envelope !== 'object' || Array.isArray(envelope)) {
136
+ push({ code: 'schema', field: 'root', message: 'envelope must be a JSON object' });
137
+ return { valid: false, errors };
138
+ }
139
+ if (envelope.version !== 1) {
140
+ push({ code: 'schema', field: 'version', message: `expected version === 1, got ${JSON.stringify(envelope.version)}` });
141
+ }
142
+ if (typeof envelope.epic !== 'string' || envelope.epic !== String(epic)) {
143
+ push({
144
+ code: 'schema',
145
+ field: 'epic',
146
+ message: `expected epic === "${epic}", got ${JSON.stringify(envelope.epic)}`,
147
+ });
148
+ }
149
+ const deps = envelope.dependencies;
150
+ const rationale = envelope.rationale;
151
+ if (deps === undefined || deps === null || typeof deps !== 'object' || Array.isArray(deps)) {
152
+ push({ code: 'schema', field: 'dependencies', message: 'must be an object of { storyKey: [depKey, ...] }' });
153
+ }
154
+ if (rationale === undefined || rationale === null || typeof rationale !== 'object' || Array.isArray(rationale)) {
155
+ push({ code: 'schema', field: 'rationale', message: 'must be an object of { storyKey: "string" }' });
156
+ }
157
+ // Stop here on root-level shape failures — the per-key checks below assume valid containers.
158
+ if (errors.length > 0) return { valid: false, errors };
159
+
160
+ const { byKey } = readStoriesFromStatus(projectRoot, String(epic));
161
+ const validKeys = new Set(Object.keys(byKey));
162
+
163
+ for (const key of Object.keys(deps)) {
164
+ const arr = deps[key];
165
+ if (!Array.isArray(arr)) {
166
+ push({ code: 'schema', field: `dependencies.${key}`, message: 'must be an array of story keys' });
167
+ continue;
168
+ }
169
+ if (!validKeys.has(key)) {
170
+ push({ code: 'unknown-key', key, message: `story "${key}" not present in sprint-status.yaml for epic ${epic}` });
171
+ }
172
+ for (const dep of arr) {
173
+ if (typeof dep !== 'string') {
174
+ push({ code: 'schema', field: `dependencies.${key}[]`, message: `dep entries must be strings, got ${JSON.stringify(dep)}` });
175
+ continue;
176
+ }
177
+ if (dep === key) {
178
+ push({ code: 'self-dep', key, message: `story "${key}" cannot depend on itself` });
179
+ continue;
180
+ }
181
+ const depEpic = parseEpicFromKey(dep);
182
+ if (depEpic !== null && depEpic !== String(epic)) {
183
+ push({
184
+ code: 'cross-epic-dep',
185
+ from: key,
186
+ to: dep,
187
+ message: `cross-epic edge "${key}" → "${dep}" (epic ${depEpic} ≠ ${epic}) — declare via overrides[*].epics.<id>.independent instead`,
188
+ });
189
+ continue;
190
+ }
191
+ if (!validKeys.has(dep)) {
192
+ push({ code: 'unknown-key', key: dep, message: `dependency "${dep}" of "${key}" not in sprint-status.yaml` });
193
+ }
194
+ }
195
+ // Rationale required for every declared key.
196
+ const r = rationale[key];
197
+ if (typeof r !== 'string' || r.trim() === '') {
198
+ push({ code: 'schema', field: `rationale.${key}`, message: 'rationale required for every key in dependencies (non-empty string)' });
199
+ }
200
+ }
201
+
202
+ for (const k of Object.keys(rationale)) {
203
+ if (!(k in deps)) {
204
+ push({
205
+ code: 'schema',
206
+ field: `rationale.${k}`,
207
+ message: `rationale supplied for "${k}" but it has no entry in dependencies (rationale is for declared edges only)`,
208
+ });
209
+ }
210
+ }
211
+
212
+ // Cycle detection — only meaningful if the graph is otherwise well-formed.
213
+ if (errors.length === 0) {
214
+ const allKeys = Object.keys(byKey);
215
+ const edges = [];
216
+ for (const key of Object.keys(deps)) {
217
+ for (const dep of deps[key]) edges.push([dep, key]);
218
+ }
219
+ const { cycle } = topoLayers(allKeys, edges);
220
+ if (cycle.length > 0) {
221
+ push({ code: 'cycle', nodes: cycle.slice().sort(), message: `cyclic dependency among: ${cycle.slice().sort().join(', ')}` });
222
+ }
223
+ }
224
+
225
+ return { valid: errors.length === 0, errors };
226
+ }
227
+
228
+ // ---------------------------------------------------------------
229
+ // Existing-file detection + merge
230
+ // ---------------------------------------------------------------
231
+
232
+ function readExisting(projectRoot) {
233
+ const file = dependenciesPath(projectRoot);
234
+ if (!fs.existsSync(file)) return { exists: false, autoMarker: false, doc: null, raw: null };
235
+ const raw = fs.readFileSync(file, 'utf8');
236
+ const firstNonEmpty = raw.split(/\r?\n/).find((l) => l.trim().length > 0) ?? '';
237
+ const autoMarker = firstNonEmpty.trim() === AUTO_MARKER;
238
+ let doc = null;
239
+ try {
240
+ doc = parseDependenciesYaml(raw);
241
+ } catch {
242
+ doc = null;
243
+ }
244
+ return { exists: true, autoMarker, doc, raw };
245
+ }
246
+
247
+ function mergeDoc(envelope, existing) {
248
+ // stories: regenerated entirely from the LLM envelope.
249
+ const stories = {};
250
+ const sortedKeys = Object.keys(envelope.dependencies).sort();
251
+ for (const k of sortedKeys) {
252
+ stories[k] = {
253
+ depends_on: envelope.dependencies[k].slice().sort(),
254
+ rationale: envelope.rationale[k],
255
+ };
256
+ }
257
+ // overrides + epics: preserved from existing if present, else empty defaults.
258
+ const overrides = existing && existing.doc && Array.isArray(existing.doc.overrides) ? existing.doc.overrides : [];
259
+ const epics = existing && existing.doc && existing.doc.epics && typeof existing.doc.epics === 'object' && !Array.isArray(existing.doc.epics)
260
+ ? existing.doc.epics
261
+ : {};
262
+ return { version: 1, stories, overrides, epics };
263
+ }
264
+
265
+ // ---------------------------------------------------------------
266
+ // Hash + serialization
267
+ // ---------------------------------------------------------------
268
+
269
+ // Content hash covers the structural fields (deps + overrides + epics).
270
+ // Rationale text changes do NOT change the hash — they're for human review.
271
+ function contentHash(doc) {
272
+ const stripped = {
273
+ stories: {},
274
+ overrides: doc.overrides ?? [],
275
+ epics: doc.epics ?? {},
276
+ };
277
+ for (const k of Object.keys(doc.stories ?? {}).sort()) {
278
+ stripped.stories[k] = { depends_on: (doc.stories[k].depends_on ?? []).slice().sort() };
279
+ }
280
+ return crypto.createHash('sha256').update(JSON.stringify(stripped)).digest('hex').slice(0, 12);
281
+ }
282
+
283
+ // Serialize a value for inline YAML emission. Strings get JSON quoting when
284
+ // they contain reserved characters; arrays use JSON flow form (matches the
285
+ // shape parseDependenciesYaml accepts).
286
+ function inlineScalar(v) {
287
+ if (v === null || v === undefined) return 'null';
288
+ if (typeof v === 'boolean' || typeof v === 'number') return String(v);
289
+ if (Array.isArray(v)) return JSON.stringify(v);
290
+ if (typeof v === 'object') return JSON.stringify(v);
291
+ const s = String(v);
292
+ const needsQuote =
293
+ s === '' ||
294
+ /[:#\n\r"'\\]/.test(s) ||
295
+ /^[\s\-?&*!|>%@`]/.test(s) ||
296
+ /^(true|false|null|~|yes|no|on|off)$/i.test(s) ||
297
+ /^-?\d/.test(s);
298
+ return needsQuote ? JSON.stringify(s) : s;
299
+ }
300
+
301
+ // Small dedicated serializer for dependencies.yaml. Produces nested block
302
+ // YAML that parseDependenciesYaml round-trips. Top-level keys: version,
303
+ // stories, overrides, epics.
304
+ function renderYaml(doc, hash) {
305
+ const lines = [
306
+ AUTO_MARKER,
307
+ '# DO NOT hand-edit `stories:` directly — it is regenerated on the next',
308
+ '# planning cycle. To pin a relationship, add to `overrides:` instead.',
309
+ `# Hash: ${hash}`,
310
+ '',
311
+ `version: ${doc.version}`,
312
+ ];
313
+
314
+ // stories: block-form with sorted keys
315
+ const storyKeys = Object.keys(doc.stories ?? {}).sort();
316
+ if (storyKeys.length === 0) {
317
+ lines.push('stories: {}');
318
+ } else {
319
+ lines.push('stories:');
320
+ for (const k of storyKeys) {
321
+ const entry = doc.stories[k];
322
+ lines.push(` ${k}:`);
323
+ lines.push(` depends_on: ${JSON.stringify((entry.depends_on ?? []).slice().sort())}`);
324
+ if (entry.rationale !== undefined) {
325
+ lines.push(` rationale: ${inlineScalar(entry.rationale)}`);
326
+ }
327
+ }
328
+ }
329
+
330
+ // overrides: preserved verbatim from existing doc; emit as block-form list
331
+ // of mappings (each entry is an object with epic/force_independent/etc).
332
+ const overrides = Array.isArray(doc.overrides) ? doc.overrides : [];
333
+ if (overrides.length === 0) {
334
+ lines.push('overrides: []');
335
+ } else {
336
+ lines.push('overrides:');
337
+ for (const ov of overrides) {
338
+ const ovKeys = Object.keys(ov ?? {});
339
+ if (ovKeys.length === 0) {
340
+ lines.push(' - {}');
341
+ continue;
342
+ }
343
+ const first = ovKeys[0];
344
+ const firstVal = ov[first];
345
+ if (Array.isArray(firstVal) || (typeof firstVal !== 'object' || firstVal === null)) {
346
+ lines.push(` - ${first}: ${inlineScalar(firstVal)}`);
347
+ } else {
348
+ lines.push(` - ${first}:`);
349
+ for (const sk of Object.keys(firstVal)) lines.push(` ${sk}: ${inlineScalar(firstVal[sk])}`);
350
+ }
351
+ for (let i = 1; i < ovKeys.length; i++) {
352
+ const k = ovKeys[i];
353
+ const v = ov[k];
354
+ if (Array.isArray(v) || (typeof v !== 'object' || v === null)) {
355
+ lines.push(` ${k}: ${inlineScalar(v)}`);
356
+ } else {
357
+ lines.push(` ${k}:`);
358
+ for (const sk of Object.keys(v)) lines.push(` ${sk}: ${inlineScalar(v[sk])}`);
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ // epics: block-form mapping (each id maps to { independent: bool })
365
+ const epicIds = Object.keys(doc.epics ?? {});
366
+ if (epicIds.length === 0) {
367
+ lines.push('epics: {}');
368
+ } else {
369
+ lines.push('epics:');
370
+ for (const id of epicIds.sort()) {
371
+ const e = doc.epics[id];
372
+ if (!e || typeof e !== 'object' || Array.isArray(e)) {
373
+ lines.push(` ${id}: ${inlineScalar(e)}`);
374
+ continue;
375
+ }
376
+ lines.push(` ${id}:`);
377
+ for (const sk of Object.keys(e)) lines.push(` ${sk}: ${inlineScalar(e[sk])}`);
378
+ }
379
+ }
380
+
381
+ return lines.join('\n') + '\n';
382
+ }
383
+
384
+ // ---------------------------------------------------------------
385
+ // I/O helpers
386
+ // ---------------------------------------------------------------
387
+
388
+ function readStdin() {
389
+ return new Promise((resolve, reject) => {
390
+ let buf = '';
391
+ process.stdin.setEncoding('utf8');
392
+ process.stdin.on('data', (c) => {
393
+ buf += c;
394
+ });
395
+ process.stdin.on('end', () => resolve(buf));
396
+ process.stdin.on('error', reject);
397
+ });
398
+ }
399
+
400
+ function atomicWrite(file, body) {
401
+ const dir = path.dirname(file);
402
+ fs.mkdirSync(dir, { recursive: true });
403
+ const tmp = path.join(
404
+ dir,
405
+ `.${path.basename(file)}.tmp.${process.pid}.${process.hrtime.bigint().toString(36)}`,
406
+ );
407
+ const fd = fs.openSync(tmp, 'w', 0o644);
408
+ try {
409
+ fs.writeFileSync(fd, body);
410
+ try {
411
+ fs.fsyncSync(fd);
412
+ } catch {
413
+ /* fsync unsupported on some filesystems */
414
+ }
415
+ } finally {
416
+ fs.closeSync(fd);
417
+ }
418
+ fs.renameSync(tmp, file);
419
+ // Skip directory fsync on Windows: fs.openSync(<dir>, 'r') throws there
420
+ // and we have no portable Windows equivalent. NTFS rename is atomic;
421
+ // it's just not flushed to disk on power loss the way POSIX fsync would.
422
+ if (process.platform !== 'win32') {
423
+ try {
424
+ const dfd = fs.openSync(dir, 'r');
425
+ try {
426
+ fs.fsyncSync(dfd);
427
+ } finally {
428
+ fs.closeSync(dfd);
429
+ }
430
+ } catch {
431
+ /* directory fsync unsupported on some filesystems */
432
+ }
433
+ }
434
+ }
435
+
436
+ // ---------------------------------------------------------------
437
+ // Subcommands
438
+ // ---------------------------------------------------------------
439
+
440
+ function diffCounts(prev, next) {
441
+ const prevEdges = new Set();
442
+ for (const k of Object.keys(prev?.stories ?? {})) {
443
+ for (const d of prev.stories[k].depends_on ?? []) prevEdges.add(`${d}→${k}`);
444
+ }
445
+ const nextEdges = new Set();
446
+ for (const k of Object.keys(next?.stories ?? {})) {
447
+ for (const d of next.stories[k].depends_on ?? []) nextEdges.add(`${d}→${k}`);
448
+ }
449
+ let added = 0;
450
+ let removed = 0;
451
+ for (const e of nextEdges) if (!prevEdges.has(e)) added++;
452
+ for (const e of prevEdges) if (!nextEdges.has(e)) removed++;
453
+ return { added, removed };
454
+ }
455
+
456
+ async function runScaffoldPrompt(projectRoot, epic) {
457
+ process.stdout.write(scaffoldPrompt(projectRoot, epic) + '\n');
458
+ return 0;
459
+ }
460
+
461
+ async function runDryRun(projectRoot, epic) {
462
+ const stdin = await readStdin();
463
+ let envelope;
464
+ try {
465
+ envelope = JSON.parse(stdin);
466
+ } catch (e) {
467
+ process.stdout.write(
468
+ JSON.stringify({ valid: false, errors: [{ code: 'schema', field: 'root', message: `invalid JSON: ${e.message}` }] }) + '\n',
469
+ );
470
+ return 1;
471
+ }
472
+ const result = validateEnvelope(envelope, { projectRoot, epic });
473
+ if (!result.valid) {
474
+ process.stdout.write(JSON.stringify({ valid: false, errors: result.errors }) + '\n');
475
+ return 1;
476
+ }
477
+ const existing = readExisting(projectRoot);
478
+ const merged = mergeDoc(envelope, existing);
479
+ const diff = diffCounts(existing.doc, merged);
480
+ process.stdout.write(JSON.stringify({ valid: true, errors: [], merged_doc: merged, diff }) + '\n');
481
+ return 0;
482
+ }
483
+
484
+ async function runWrite(projectRoot, epic, { force }) {
485
+ const existing = readExisting(projectRoot);
486
+ if (existing.exists && !existing.autoMarker && !force) {
487
+ process.stdout.write(
488
+ JSON.stringify({
489
+ wrote: false,
490
+ reason: 'existing-hand-authored',
491
+ message:
492
+ 'Existing dependencies.yaml was hand-authored (no AUTO-INFERRED marker). Re-run with --force to overwrite, or delete it first.',
493
+ file: dependenciesPath(projectRoot),
494
+ }) + '\n',
495
+ );
496
+ return 2;
497
+ }
498
+
499
+ const stdin = await readStdin();
500
+ let envelope;
501
+ try {
502
+ envelope = JSON.parse(stdin);
503
+ } catch (e) {
504
+ process.stdout.write(
505
+ JSON.stringify({ valid: false, errors: [{ code: 'schema', field: 'root', message: `invalid JSON: ${e.message}` }] }) + '\n',
506
+ );
507
+ return 1;
508
+ }
509
+ const result = validateEnvelope(envelope, { projectRoot, epic });
510
+ if (!result.valid) {
511
+ process.stdout.write(JSON.stringify({ valid: false, errors: result.errors }) + '\n');
512
+ return 1;
513
+ }
514
+
515
+ const merged = mergeDoc(envelope, existing);
516
+ const hash = contentHash(merged);
517
+ const body = renderYaml(merged, hash);
518
+ const file = dependenciesPath(projectRoot);
519
+ atomicWrite(file, body);
520
+
521
+ const diff = diffCounts(existing.doc, merged);
522
+ const overridesPreserved = (existing.doc?.overrides?.length ?? 0) > 0;
523
+ const edgesInferred = Object.values(envelope.dependencies).reduce((n, arr) => n + arr.length, 0);
524
+
525
+ emitTimingEvent(projectRoot, 'planning.infer-dependencies', {
526
+ epic: String(epic),
527
+ edges_inferred: edgesInferred,
528
+ edges_added: diff.added,
529
+ edges_removed: diff.removed,
530
+ hash,
531
+ });
532
+
533
+ process.stdout.write(
534
+ JSON.stringify({
535
+ wrote: true,
536
+ file,
537
+ edges_inferred: edgesInferred,
538
+ edges_added: diff.added,
539
+ edges_removed: diff.removed,
540
+ user_overrides_preserved: overridesPreserved,
541
+ hash,
542
+ }) + '\n',
543
+ );
544
+ return 0;
545
+ }
546
+
547
+ // ---------------------------------------------------------------
548
+ // CLI
549
+ // ---------------------------------------------------------------
550
+
551
+ async function main() {
552
+ const { opts, positional } = parseArgs(process.argv.slice(2), { booleanFlags: ['force'] });
553
+ if (opts.help || positional.length === 0) {
554
+ help();
555
+ process.exit(opts.help ? 0 : 1);
556
+ }
557
+ const command = positional[0];
558
+ if (!VALID_COMMANDS.includes(command)) {
559
+ log.error(`unknown command '${command}'. Valid: ${VALID_COMMANDS.join(', ')}`);
560
+ process.exit(1);
561
+ }
562
+ const projectRoot = opts['project-root'] || process.cwd();
563
+ const epic = opts.epic !== undefined ? String(opts.epic) : null;
564
+ if (!epic) {
565
+ log.error(`${command} requires --epic`);
566
+ process.exit(1);
567
+ }
568
+
569
+ try {
570
+ if (command === 'scaffold-prompt') process.exit(await runScaffoldPrompt(projectRoot, epic));
571
+ if (command === 'dry-run') process.exit(await runDryRun(projectRoot, epic));
572
+ if (command === 'write') process.exit(await runWrite(projectRoot, epic, { force: opts.force === true }));
573
+ } catch (e) {
574
+ log.error(`unexpected error: ${e.stack || e.message}`);
575
+ process.exit(1);
576
+ }
577
+ }
578
+
579
+ module.exports = {
580
+ AUTO_MARKER,
581
+ VALID_COMMANDS,
582
+ scaffoldPrompt,
583
+ validateEnvelope,
584
+ readExisting,
585
+ mergeDoc,
586
+ contentHash,
587
+ renderYaml,
588
+ inlineScalar,
589
+ diffCounts,
590
+ };
591
+
592
+ if (require.main === module) {
593
+ main();
594
+ }