@ikunin/sprintpilot 2.0.9 → 2.1.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 (52) hide show
  1. package/README.md +245 -10
  2. package/_Sprintpilot/Sprintpilot.md +1 -1
  3. package/_Sprintpilot/bin/autopilot.js +581 -0
  4. package/_Sprintpilot/lib/orchestrator/action-ledger.js +148 -0
  5. package/_Sprintpilot/lib/orchestrator/adapt.js +502 -0
  6. package/_Sprintpilot/lib/orchestrator/decision-log.js +224 -0
  7. package/_Sprintpilot/lib/orchestrator/divergence.js +201 -0
  8. package/_Sprintpilot/lib/orchestrator/git-plan.js +259 -0
  9. package/_Sprintpilot/lib/orchestrator/impact-classifier.js +108 -0
  10. package/_Sprintpilot/lib/orchestrator/land.js +155 -0
  11. package/_Sprintpilot/lib/orchestrator/parallel-batch.js +99 -0
  12. package/_Sprintpilot/lib/orchestrator/profile-rules.js +167 -0
  13. package/_Sprintpilot/lib/orchestrator/report.js +95 -0
  14. package/_Sprintpilot/lib/orchestrator/state-machine.js +402 -0
  15. package/_Sprintpilot/lib/orchestrator/state-store.js +260 -0
  16. package/_Sprintpilot/lib/orchestrator/user-command-applier.js +157 -0
  17. package/_Sprintpilot/lib/orchestrator/user-commands.js +115 -0
  18. package/_Sprintpilot/lib/orchestrator/verify.js +397 -0
  19. package/_Sprintpilot/manifest.yaml +1 -1
  20. package/_Sprintpilot/modules/git/config.yaml +26 -0
  21. package/_Sprintpilot/scripts/agent-adapter.js +4 -5
  22. package/_Sprintpilot/scripts/auto-merge-bmad-docs.js +112 -0
  23. package/_Sprintpilot/scripts/dispatch-layer.js +12 -8
  24. package/_Sprintpilot/scripts/infer-dependencies.js +78 -21
  25. package/_Sprintpilot/scripts/inject-tasks-section.js +4 -3
  26. package/_Sprintpilot/scripts/land-this-pr.js +110 -0
  27. package/_Sprintpilot/scripts/lint-test-pitfalls.js +133 -0
  28. package/_Sprintpilot/scripts/list-remaining-stories.js +1 -1
  29. package/_Sprintpilot/scripts/log-timing.js +12 -3
  30. package/_Sprintpilot/scripts/merge-shards.js +32 -12
  31. package/_Sprintpilot/scripts/post-green-gates.js +187 -0
  32. package/_Sprintpilot/scripts/preflight-merge.js +2 -1
  33. package/_Sprintpilot/scripts/resolve-dag.js +3 -1
  34. package/_Sprintpilot/scripts/scan.js +109 -13
  35. package/_Sprintpilot/scripts/stack-snapshot.js +128 -0
  36. package/_Sprintpilot/scripts/state-shard.js +8 -1
  37. package/_Sprintpilot/scripts/summarize-timings.js +30 -12
  38. package/_Sprintpilot/scripts/with-retry.js +17 -5
  39. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
  40. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
  41. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +9 -0
  42. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +9 -0
  43. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +9 -0
  44. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +9 -0
  45. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +10 -0
  46. package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +2 -0
  47. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/component-mapper.md +7 -0
  48. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/data-flow-tracer.md +7 -0
  49. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/pattern-extractor.md +7 -0
  50. package/lib/core/update-check.js +11 -1
  51. package/package.json +1 -1
  52. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +0 -1388
@@ -103,7 +103,10 @@ function validatePhase(s) {
103
103
 
104
104
  function validateAction(a) {
105
105
  if (!VALID_ACTIONS.includes(a)) {
106
- return { ok: false, error: `invalid action '${a}': must be one of ${VALID_ACTIONS.join(', ')}` };
106
+ return {
107
+ ok: false,
108
+ error: `invalid action '${a}': must be one of ${VALID_ACTIONS.join(', ')}`,
109
+ };
107
110
  }
108
111
  return { ok: true, value: a };
109
112
  }
@@ -430,10 +433,16 @@ function main() {
430
433
  const r = markPhase(projectRoot, story.value, phase.value, meta.value);
431
434
  // Emit a brief JSON line so callers can log the duration if useful.
432
435
  // Stdout is intentionally separate from the per-story shard.
433
- process.stdout.write(`${JSON.stringify({ marked: phase.value, prev_phase: r.prev_phase, duration_ms: r.duration_ms })}\n`);
436
+ process.stdout.write(
437
+ `${JSON.stringify({ marked: phase.value, prev_phase: r.prev_phase, duration_ms: r.duration_ms })}\n`,
438
+ );
434
439
  return;
435
440
  }
436
- appendLine(projectRoot, story.value, buildEntry(action.value, story.value, phase.value, meta.value));
441
+ appendLine(
442
+ projectRoot,
443
+ story.value,
444
+ buildEntry(action.value, story.value, phase.value, meta.value),
445
+ );
437
446
  } catch (e) {
438
447
  log.error(`timing write failed: ${e.message}`);
439
448
  process.exit(1);
@@ -115,7 +115,9 @@ function acquireMergeLock(projectRoot) {
115
115
  try {
116
116
  const st = fs.statSync(file);
117
117
  if (Date.now() - st.mtimeMs > STALE_LOCK_AGE_MS) {
118
- log.warn(`merge-shards: removing stale lock ${file} (older than ${STALE_LOCK_AGE_MS}ms)`);
118
+ log.warn(
119
+ `merge-shards: removing stale lock ${file} (older than ${STALE_LOCK_AGE_MS}ms)`,
120
+ );
119
121
  fs.unlinkSync(file);
120
122
  continue;
121
123
  }
@@ -201,11 +203,7 @@ function shardUnchanged(file, snapshot) {
201
203
  if (!snapshot) return false;
202
204
  try {
203
205
  const st = fs.statSync(file);
204
- return (
205
- st.mtimeMs === snapshot.mtime &&
206
- st.size === snapshot.size &&
207
- st.ino === snapshot.ino
208
- );
206
+ return st.mtimeMs === snapshot.mtime && st.size === snapshot.size && st.ino === snapshot.ino;
209
207
  } catch {
210
208
  return false;
211
209
  }
@@ -421,7 +419,11 @@ function composeStateYaml(stateMerge) {
421
419
  if (stateMerge.corrupt.length + stateMerge.invalid.length > 0) {
422
420
  doc.shard_problems = [
423
421
  ...stateMerge.corrupt.map((c) => ({ story: c.story, kind: 'parse-error', detail: c.error })),
424
- ...stateMerge.invalid.map((c) => ({ story: c.story, kind: 'invalid-shape', detail: c.reason })),
422
+ ...stateMerge.invalid.map((c) => ({
423
+ story: c.story,
424
+ kind: 'invalid-shape',
425
+ detail: c.reason,
426
+ })),
425
427
  ];
426
428
  }
427
429
  return `${yamlDump(doc)}\n`;
@@ -435,8 +437,16 @@ function composeDecisionYaml(decisionMerge) {
435
437
  };
436
438
  if (decisionMerge.corrupt.length + decisionMerge.invalid.length > 0) {
437
439
  doc.shard_problems = [
438
- ...decisionMerge.corrupt.map((c) => ({ story: c.story, kind: 'parse-error', detail: c.error })),
439
- ...decisionMerge.invalid.map((c) => ({ story: c.story, kind: 'invalid-shape', detail: c.reason })),
440
+ ...decisionMerge.corrupt.map((c) => ({
441
+ story: c.story,
442
+ kind: 'parse-error',
443
+ detail: c.error,
444
+ })),
445
+ ...decisionMerge.invalid.map((c) => ({
446
+ story: c.story,
447
+ kind: 'invalid-shape',
448
+ detail: c.reason,
449
+ })),
440
450
  ];
441
451
  }
442
452
  return `${yamlDump(doc)}\n`;
@@ -460,7 +470,13 @@ function merge(projectRoot, { layerId, archive, dryRun } = {}) {
460
470
  archivedCorrupt.push({ kind: 'state', story: c.story, ...arch });
461
471
  }
462
472
  for (const c of decisions.corrupt.concat(decisions.invalid)) {
463
- const arch = archiveCorrupt(projectRoot, 'decision-log', c.story, c.file, c.error || c.reason);
473
+ const arch = archiveCorrupt(
474
+ projectRoot,
475
+ 'decision-log',
476
+ c.story,
477
+ c.file,
478
+ c.error || c.reason,
479
+ );
464
480
  archivedCorrupt.push({ kind: 'decision-log', story: c.story, ...arch });
465
481
  }
466
482
  }
@@ -468,8 +484,12 @@ function merge(projectRoot, { layerId, archive, dryRun } = {}) {
468
484
  const stateBody = composeStateYaml(state);
469
485
  const decisionBody = composeDecisionYaml(decisions);
470
486
 
471
- const stateWrite = writeAuthoritative(projectRoot, 'autopilot-state.yaml', stateBody, { dryRun });
472
- const decisionWrite = writeAuthoritative(projectRoot, 'decision-log.yaml', decisionBody, { dryRun });
487
+ const stateWrite = writeAuthoritative(projectRoot, 'autopilot-state.yaml', stateBody, {
488
+ dryRun,
489
+ });
490
+ const decisionWrite = writeAuthoritative(projectRoot, 'decision-log.yaml', decisionBody, {
491
+ dryRun,
492
+ });
473
493
 
474
494
  let archiveDir = null;
475
495
  let archiveSkipped = [];
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+
3
+ // post-green-gates.js — composed post-GREEN quality pipeline.
4
+ //
5
+ // Called by the orchestrator after a `bmad-dev-story` GREEN phase
6
+ // completes verify. Runs three gates in order; first failing gate causes
7
+ // non-zero exit with a structured JSON report.
8
+ //
9
+ // Gates:
10
+ // 1. lint-changed.js — biome/eslint on changed files only
11
+ // 2. lint-test-pitfalls.js — LLM-test pitfalls (only on test files)
12
+ // 3. scan.js (ci-parity) — search for obvious CI-only failure modes
13
+ // (envs hard-coded to local-only assumptions)
14
+ //
15
+ // Usage:
16
+ // post-green-gates.js [--json] [--changed-files <path>] [--project-root <path>]
17
+ // --changed-files: path to a newline-delimited list of changed files
18
+ // (default: derive from `git diff --name-only HEAD`)
19
+ //
20
+ // Each gate runs in a child process via execFileSync. Argv-only — no shell.
21
+
22
+ const { execFileSync, spawnSync } = require('node:child_process');
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+
26
+ const { parseArgs } = require('../lib/runtime/args');
27
+ const log = require('../lib/runtime/log');
28
+
29
+ function help() {
30
+ log.out(
31
+ [
32
+ 'Usage: post-green-gates.js [--json] [--changed-files <path>] [--project-root <path>]',
33
+ '',
34
+ 'Runs in order:',
35
+ ' 1. lint-changed.js — formatter + linter on changed files',
36
+ ' 2. lint-test-pitfalls.js — LLM-test pitfall scan (test files only)',
37
+ ' 3. scan.js (ci-parity) — CI-only failure mode scan',
38
+ ].join('\n'),
39
+ );
40
+ }
41
+
42
+ function listChangedFiles(projectRoot, override) {
43
+ if (override) {
44
+ return fs
45
+ .readFileSync(override, 'utf8')
46
+ .split(/\r?\n/)
47
+ .map((s) => s.trim())
48
+ .filter((s) => s.length > 0);
49
+ }
50
+ try {
51
+ const out = execFileSync('git', ['diff', '--name-only', 'HEAD'], {
52
+ cwd: projectRoot,
53
+ encoding: 'utf8',
54
+ });
55
+ return out
56
+ .split(/\r?\n/)
57
+ .map((s) => s.trim())
58
+ .filter(Boolean);
59
+ } catch (_e) {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ function runGate(name, command, args, projectRoot) {
65
+ const r = spawnSync(command, args, { cwd: projectRoot, encoding: 'utf8' });
66
+ return {
67
+ gate: name,
68
+ ok: r.status === 0,
69
+ exit_code: r.status === null ? -1 : r.status,
70
+ stdout: r.stdout || '',
71
+ stderr: r.stderr || '',
72
+ };
73
+ }
74
+
75
+ function isTestFile(p) {
76
+ return /\.test\.(ts|tsx|js|jsx|mts)$/.test(p) || /\.spec\.(ts|tsx|js|jsx|mts)$/.test(p);
77
+ }
78
+
79
+ function isJsTsFile(p) {
80
+ return /\.(ts|tsx|js|jsx|mts|cjs)$/.test(p);
81
+ }
82
+
83
+ function main(argv) {
84
+ const { opts } = parseArgs(argv, { booleanFlags: ['json', 'help'] });
85
+ if (opts.help) {
86
+ help();
87
+ return 0;
88
+ }
89
+ const projectRoot = path.resolve(opts['project-root'] || process.cwd());
90
+ const changed = listChangedFiles(projectRoot, opts['changed-files']);
91
+ const jsTs = changed.filter(isJsTsFile);
92
+ const testFiles = changed.filter(isTestFile);
93
+
94
+ const gates = [];
95
+
96
+ // Gate 1: lint-changed.
97
+ const lintChangedPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'lint-changed.js');
98
+ if (fs.existsSync(lintChangedPath) && jsTs.length > 0) {
99
+ gates.push(
100
+ runGate(
101
+ 'lint-changed',
102
+ 'node',
103
+ [lintChangedPath, '--project-root', projectRoot],
104
+ projectRoot,
105
+ ),
106
+ );
107
+ } else {
108
+ gates.push({
109
+ gate: 'lint-changed',
110
+ ok: true,
111
+ exit_code: 0,
112
+ stdout: 'skipped (no JS/TS changes or script missing)',
113
+ stderr: '',
114
+ });
115
+ }
116
+
117
+ // Gate 2: lint-test-pitfalls — only run on test files.
118
+ const pitfallsPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'lint-test-pitfalls.js');
119
+ if (fs.existsSync(pitfallsPath) && testFiles.length > 0) {
120
+ gates.push(runGate('lint-test-pitfalls', 'node', [pitfallsPath, ...testFiles], projectRoot));
121
+ } else {
122
+ gates.push({
123
+ gate: 'lint-test-pitfalls',
124
+ ok: true,
125
+ exit_code: 0,
126
+ stdout: 'skipped (no test files in change set)',
127
+ stderr: '',
128
+ });
129
+ }
130
+
131
+ // Gate 3: ci-parity via scan.js. Pattern set is intentionally conservative
132
+ // — flag obvious CI-only-fail patterns: `if (!process.env.CI)` skips,
133
+ // hardcoded localhost ports, `xit`/`xdescribe`. scan.js does the search;
134
+ // we treat its non-zero exit as a block.
135
+ const scanPath = path.join(projectRoot, '_Sprintpilot', 'scripts', 'scan.js');
136
+ if (fs.existsSync(scanPath) && jsTs.length > 0) {
137
+ gates.push(
138
+ runGate(
139
+ 'ci-parity',
140
+ 'node',
141
+ [
142
+ scanPath,
143
+ '--pattern',
144
+ 'process.env.CI',
145
+ '--pattern',
146
+ '(localhost|127\\.0\\.0\\.1):\\d{4,5}',
147
+ '--paths',
148
+ ...jsTs,
149
+ ],
150
+ projectRoot,
151
+ ),
152
+ );
153
+ } else {
154
+ gates.push({
155
+ gate: 'ci-parity',
156
+ ok: true,
157
+ exit_code: 0,
158
+ stdout: 'skipped (no JS/TS changes or script missing)',
159
+ stderr: '',
160
+ });
161
+ }
162
+
163
+ const firstFail = gates.find((g) => !g.ok);
164
+ const overallOk = !firstFail;
165
+
166
+ if (opts.json) {
167
+ process.stdout.write(
168
+ `${JSON.stringify({ ok: overallOk, gates, first_fail: firstFail?.gate || null }, null, 2)}\n`,
169
+ );
170
+ } else {
171
+ for (const g of gates) {
172
+ log.out(`[${g.ok ? '✓' : '✗'}] ${g.gate}: exit=${g.exit_code}`);
173
+ if (!g.ok) {
174
+ if (g.stdout) log.out(g.stdout);
175
+ if (g.stderr) log.err(g.stderr);
176
+ }
177
+ }
178
+ log.out(overallOk ? 'all gates passed' : `failed gate: ${firstFail?.gate}`);
179
+ }
180
+ return overallOk ? 0 : 1;
181
+ }
182
+
183
+ if (require.main === module) {
184
+ process.exit(main(process.argv.slice(2)));
185
+ }
186
+
187
+ module.exports = { main, listChangedFiles, runGate, isTestFile, isJsTsFile };
@@ -196,7 +196,8 @@ function main() {
196
196
  process.exit(1);
197
197
  }
198
198
  const projectRoot = opts['project-root'] || process.cwd();
199
- const branchPrefix = opts['branch-prefix'] !== undefined ? String(opts['branch-prefix']) : 'story/';
199
+ const branchPrefix =
200
+ opts['branch-prefix'] !== undefined ? String(opts['branch-prefix']) : 'story/';
200
201
  const timeout = opts['lock-timeout-sec']
201
202
  ? Number.parseInt(String(opts['lock-timeout-sec']), 10)
202
203
  : DEFAULT_LOCK_TIMEOUT_SEC;
@@ -568,7 +568,9 @@ function main() {
568
568
  }
569
569
 
570
570
  if (command === 'graph') {
571
- process.stdout.write(`${JSON.stringify({ nodes: dag.nodes, edges: dag.edges, epic: dag.epic })}\n`);
571
+ process.stdout.write(
572
+ `${JSON.stringify({ nodes: dag.nodes, edges: dag.edges, epic: dag.epic })}\n`,
573
+ );
572
574
  return;
573
575
  }
574
576
  if (command === 'layers') {
@@ -15,6 +15,10 @@
15
15
  * Flags: --include, --exclude, --root
16
16
  * extensions Extension frequency histogram, descending.
17
17
  * Flags: --exclude, --root, --limit (default 20)
18
+ *
19
+ * Ignore files: .gitignore and .aiexclude at the project root are parsed and
20
+ * applied as additional excludes by default. Pass --no-respect-ignore-files
21
+ * to disable. Negation patterns (`!pattern`) are logged to stderr and skipped.
18
22
  */
19
23
 
20
24
  const fs = require('fs');
@@ -41,9 +45,74 @@ const DEFAULT_EXCLUDES = [
41
45
  '.worktrees',
42
46
  ];
43
47
 
48
+ const IGNORE_FILES = ['.gitignore', '.aiexclude'];
49
+
50
+ // Translate one .gitignore / .aiexclude line into zero or more scan.js exclude
51
+ // patterns. Blank lines and `#` comments → []. Negation (`!`) is unsupported
52
+ // and returns []; the caller reports a stderr note. Trailing `/` marks a
53
+ // directory; we expand to both `dir` and `dir/**` so descendant files are also
54
+ // excluded. Leading `/` anchors to the ignore file's directory; we strip it
55
+ // and rely on scan.js's path-anchored exclude semantics for patterns that
56
+ // contain a slash.
57
+ function parseIgnorePattern(line) {
58
+ let p = line.trim();
59
+ if (!p || p.startsWith('#')) return { patterns: [], negation: false };
60
+ if (p.startsWith('!')) return { patterns: [], negation: true };
61
+ // Unescape leading `\#` and `\!` (gitignore literal escapes).
62
+ if (p.startsWith('\\#') || p.startsWith('\\!')) p = p.slice(1);
63
+
64
+ const isDir = p.endsWith('/');
65
+ if (isDir) p = p.slice(0, -1);
66
+
67
+ const anchored = p.startsWith('/');
68
+ const body = anchored ? p.slice(1) : p;
69
+ if (!body) return { patterns: [], negation: false };
70
+ // compilePatterns handles a leading '/' by anchoring the pattern to the
71
+ // root, so we keep it intact for anchored patterns.
72
+ const prefix = anchored ? '/' : '';
73
+
74
+ const patterns = [];
75
+ if (isDir) {
76
+ patterns.push(`${prefix}${body}`);
77
+ patterns.push(`${prefix}${body}/**`);
78
+ } else {
79
+ patterns.push(`${prefix}${body}`);
80
+ // A non-anchored pattern that has no slash matches files at any depth as
81
+ // a basename, which scan.js's matcher already does. If the same name is
82
+ // also a directory anywhere in the tree, exclude its descendants too.
83
+ if (!anchored && !body.includes('/')) patterns.push(`**/${body}/**`);
84
+ }
85
+ return { patterns, negation: false };
86
+ }
87
+
88
+ function loadIgnoreFilePatterns(root) {
89
+ const out = [];
90
+ let negationCount = 0;
91
+ for (const name of IGNORE_FILES) {
92
+ const full = path.join(root, name);
93
+ let content;
94
+ try {
95
+ content = fs.readFileSync(full, 'utf8');
96
+ } catch {
97
+ continue;
98
+ }
99
+ for (const raw of content.split(/\r?\n/)) {
100
+ const { patterns, negation } = parseIgnorePattern(raw);
101
+ if (negation) negationCount++;
102
+ for (const p of patterns) out.push(p);
103
+ }
104
+ }
105
+ if (negationCount > 0) {
106
+ log.error(
107
+ `scan.js: ignored ${negationCount} negation pattern(s) from .gitignore/.aiexclude (not supported)`,
108
+ );
109
+ }
110
+ return out;
111
+ }
112
+
44
113
  function help() {
45
114
  log.out(
46
- 'Usage: scan.js <files|largest|loc|extensions> [--include <globs>] [--exclude <globs>] [--root <path>] [--limit <N>] [--count]',
115
+ 'Usage: scan.js <files|largest|loc|extensions> [--include <globs>] [--exclude <globs>] [--root <path>] [--limit <N>] [--count] [--no-respect-ignore-files]',
47
116
  );
48
117
  }
49
118
 
@@ -183,12 +252,22 @@ function toPosix(p) {
183
252
  // pathAnchored = true if the pattern contains a path separator; such patterns
184
253
  // only match the full relative path. Basename-only patterns (no '/') match
185
254
  // both the full path and the basename, so "*.ts" works at any depth.
255
+ // A leading '/' anchors the pattern to the root (relative paths have no
256
+ // leading slash, so we strip it but keep pathAnchored=true).
186
257
  function compilePatterns(patterns) {
187
- return patterns.map((p) => ({
188
- raw: p,
189
- re: globToRegex(p),
190
- pathAnchored: p.includes('/'),
191
- }));
258
+ return patterns.map((p) => {
259
+ let raw = p;
260
+ let leadingSlash = false;
261
+ if (raw.startsWith('/')) {
262
+ raw = raw.slice(1);
263
+ leadingSlash = true;
264
+ }
265
+ return {
266
+ raw,
267
+ re: globToRegex(raw),
268
+ pathAnchored: leadingSlash || raw.includes('/'),
269
+ };
270
+ });
192
271
  }
193
272
 
194
273
  function matchesAny(relPath, compiled) {
@@ -335,8 +414,8 @@ function resolveRoot(opts) {
335
414
  return root;
336
415
  }
337
416
 
338
- function buildExcludes(extra) {
339
- const list = [...DEFAULT_EXCLUDES, ...extra];
417
+ function buildExcludes(extra, ignoreFromFiles) {
418
+ const list = [...DEFAULT_EXCLUDES, ...extra, ...ignoreFromFiles];
340
419
  // Patterns: match the basename of a directory OR any path containing it.
341
420
  const patterns = [];
342
421
  const basenames = new Set();
@@ -352,10 +431,18 @@ function buildExcludes(extra) {
352
431
  return { compiled: compilePatterns(patterns), basenames };
353
432
  }
354
433
 
434
+ function ignoreFilePatternsFor(root, opts) {
435
+ if (opts['no-respect-ignore-files'] === true) return [];
436
+ return loadIgnoreFilePatterns(root);
437
+ }
438
+
355
439
  function cmdFiles(opts) {
356
440
  const root = resolveRoot(opts);
357
441
  const includes = compilePatterns(splitList(opts.include));
358
- const { compiled: excludes, basenames } = buildExcludes(splitList(opts.exclude));
442
+ const { compiled: excludes, basenames } = buildExcludes(
443
+ splitList(opts.exclude),
444
+ ignoreFilePatternsFor(root, opts),
445
+ );
359
446
  const limit = opts.limit ? Number(opts.limit) : 0;
360
447
  const count = opts.count === true || opts.count === 'true';
361
448
 
@@ -378,7 +465,10 @@ function cmdFiles(opts) {
378
465
  function cmdLargest(opts) {
379
466
  const root = resolveRoot(opts);
380
467
  const includes = compilePatterns(splitList(opts.include));
381
- const { compiled: excludes, basenames } = buildExcludes(splitList(opts.exclude));
468
+ const { compiled: excludes, basenames } = buildExcludes(
469
+ splitList(opts.exclude),
470
+ ignoreFilePatternsFor(root, opts),
471
+ );
382
472
  const limit = opts.limit ? Number(opts.limit) : 10;
383
473
 
384
474
  const heap = []; // simple array; N is small so O(files * log N) is fine
@@ -396,7 +486,10 @@ function cmdLargest(opts) {
396
486
  function cmdLoc(opts) {
397
487
  const root = resolveRoot(opts);
398
488
  const includes = compilePatterns(splitList(opts.include));
399
- const { compiled: excludes, basenames } = buildExcludes(splitList(opts.exclude));
489
+ const { compiled: excludes, basenames } = buildExcludes(
490
+ splitList(opts.exclude),
491
+ ignoreFilePatternsFor(root, opts),
492
+ );
400
493
 
401
494
  let total = 0;
402
495
  let fileCount = 0;
@@ -409,7 +502,10 @@ function cmdLoc(opts) {
409
502
 
410
503
  function cmdExtensions(opts) {
411
504
  const root = resolveRoot(opts);
412
- const { compiled: excludes, basenames } = buildExcludes(splitList(opts.exclude));
505
+ const { compiled: excludes, basenames } = buildExcludes(
506
+ splitList(opts.exclude),
507
+ ignoreFilePatternsFor(root, opts),
508
+ );
413
509
  const limit = opts.limit ? Number(opts.limit) : 20;
414
510
 
415
511
  const counts = new Map();
@@ -427,7 +523,7 @@ function cmdExtensions(opts) {
427
523
 
428
524
  function main() {
429
525
  const { opts, positional } = parseArgs(process.argv.slice(2), {
430
- booleanFlags: ['count'],
526
+ booleanFlags: ['count', 'no-respect-ignore-files'],
431
527
  });
432
528
  if (opts.help || positional.length === 0) {
433
529
  help();
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+
3
+ // stack-snapshot.js — capture a snapshot of the current per-story branch
4
+ // stack so `land-this-pr.js` can land the active PR without losing the
5
+ // rest of the in-flight stack.
6
+ //
7
+ // Output: JSON to stdout (or --output <path>). Shape:
8
+ // {
9
+ // base_branch: string,
10
+ // ts: ISO,
11
+ // branches: [{ name, head, story_key, status, parent? }],
12
+ // active_pr: { branch, number?, story_key } | null,
13
+ // }
14
+ //
15
+ // Argv-only. Uses `git -C <projectRoot>` for every git call.
16
+
17
+ const { execFileSync } = require('node:child_process');
18
+ const fs = require('node:fs');
19
+ const path = require('node:path');
20
+
21
+ const { parseArgs } = require('../lib/runtime/args');
22
+ const log = require('../lib/runtime/log');
23
+
24
+ function help() {
25
+ log.out(
26
+ [
27
+ 'Usage: stack-snapshot.js [--project-root <path>] [--base-branch <name>]',
28
+ ' [--output <file>] [--active-branch <name>]',
29
+ ' [--story-key <key>] [--pr-number <n>]',
30
+ ].join('\n'),
31
+ );
32
+ }
33
+
34
+ function git(projectRoot, args) {
35
+ try {
36
+ return execFileSync('git', ['-C', projectRoot, ...args], { encoding: 'utf8' }).trim();
37
+ } catch (_e) {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ function listLocalBranches(projectRoot, prefix) {
43
+ const raw = git(projectRoot, [
44
+ 'for-each-ref',
45
+ '--format=%(refname:short)\t%(objectname)',
46
+ `refs/heads/${prefix}`,
47
+ ]);
48
+ if (!raw) return [];
49
+ return raw
50
+ .split(/\n/)
51
+ .filter(Boolean)
52
+ .map((line) => {
53
+ const [name, head] = line.split('\t');
54
+ return { name, head };
55
+ });
56
+ }
57
+
58
+ function readSprintStatus(projectRoot) {
59
+ const p = path.join(
60
+ projectRoot,
61
+ '_bmad-output',
62
+ 'implementation-artifacts',
63
+ 'sprint-status.yaml',
64
+ );
65
+ if (!fs.existsSync(p)) return null;
66
+ try {
67
+ return fs.readFileSync(p, 'utf8');
68
+ } catch (_e) {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ function statusForStory(sprintStatusText, storyKey) {
74
+ if (!sprintStatusText) return 'unknown';
75
+ // Narrow regex — accept either "story_key: status" or block form.
76
+ const re = new RegExp(`^\\s*${storyKey}:\\s*(\\w+)`, 'm');
77
+ const m = sprintStatusText.match(re);
78
+ return m ? m[1] : 'unknown';
79
+ }
80
+
81
+ function snapshot(opts) {
82
+ const projectRoot = path.resolve(opts['project-root'] || process.cwd());
83
+ const baseBranch = opts['base-branch'] || 'main';
84
+ const branches = listLocalBranches(projectRoot, 'story/').map((b) => ({
85
+ ...b,
86
+ story_key: b.name.slice('story/'.length),
87
+ }));
88
+ const sprintStatus = readSprintStatus(projectRoot);
89
+ for (const b of branches) {
90
+ b.status = statusForStory(sprintStatus, b.story_key);
91
+ }
92
+ const active =
93
+ opts['active-branch'] && opts['story-key']
94
+ ? {
95
+ branch: opts['active-branch'],
96
+ story_key: opts['story-key'],
97
+ number: opts['pr-number'] ? Number(opts['pr-number']) : undefined,
98
+ }
99
+ : null;
100
+ return {
101
+ base_branch: baseBranch,
102
+ ts: new Date().toISOString(),
103
+ branches,
104
+ active_pr: active,
105
+ };
106
+ }
107
+
108
+ function main(argv) {
109
+ const { opts } = parseArgs(argv, { booleanFlags: ['help'] });
110
+ if (opts.help) {
111
+ help();
112
+ return 0;
113
+ }
114
+ const snap = snapshot(opts);
115
+ const out = `${JSON.stringify(snap, null, 2)}\n`;
116
+ if (opts.output) {
117
+ fs.writeFileSync(path.resolve(opts.output), out, 'utf8');
118
+ } else {
119
+ process.stdout.write(out);
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ if (require.main === module) {
125
+ process.exit(main(process.argv.slice(2)));
126
+ }
127
+
128
+ module.exports = { main, snapshot, statusForStory };
@@ -575,7 +575,14 @@ function deepAssign(target, source) {
575
575
  for (const k of Object.keys(source)) {
576
576
  const sv = source[k];
577
577
  const tv = out[k];
578
- if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
578
+ if (
579
+ sv &&
580
+ typeof sv === 'object' &&
581
+ !Array.isArray(sv) &&
582
+ tv &&
583
+ typeof tv === 'object' &&
584
+ !Array.isArray(tv)
585
+ ) {
579
586
  out[k] = deepAssign(tv, sv);
580
587
  } else {
581
588
  out[k] = sv;