@ikunin/sprintpilot 2.0.10 → 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 (42) 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/stack-snapshot.js +128 -0
  35. package/_Sprintpilot/scripts/state-shard.js +8 -1
  36. package/_Sprintpilot/scripts/summarize-timings.js +30 -12
  37. package/_Sprintpilot/scripts/with-retry.js +17 -5
  38. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
  39. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
  40. package/lib/core/update-check.js +11 -1
  41. package/package.json +1 -1
  42. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +0 -1388
@@ -14,6 +14,32 @@ git:
14
14
  # story commit. Set on nano profile by default.
15
15
  granularity: story
16
16
 
17
+ # Reuse a user-created branch instead of auto-creating per-story or
18
+ # per-epic branches. When `true`, autopilot detects the current branch
19
+ # on boot (must NOT be base_branch) and commits every story directly
20
+ # onto it. Per-story / per-epic branch creation is suppressed; one PR
21
+ # is opened at sprint-end. Useful for feature-branch workflows where
22
+ # the user already has the branch they want to work on.
23
+ reuse_user_branch: false
24
+
25
+ # Merge strategy.
26
+ # stacked — every story branch lives until sprint completes,
27
+ # PRs are stacked (default; existing behavior).
28
+ # land_as_you_go — merge each story's PR immediately after STORY_DONE
29
+ # to avoid PR pile-up. `land_when` controls when.
30
+ merge_strategy: stacked
31
+
32
+ # When to merge under merge_strategy: land_as_you_go.
33
+ # no_wait — merge synchronously after STORY_DONE, no CI wait.
34
+ # ci_pass — wait for `gh pr checks` (or platform equivalent)
35
+ # to report all checks green, then merge.
36
+ # ci_and_review — also wait for an `approved` PR review.
37
+ land_when: ci_pass
38
+
39
+ # Max minutes to wait for CI / review under land_as_you_go. After this
40
+ # the orchestrator halts and prompts the user.
41
+ land_wait_minutes: 30
42
+
17
43
  # Branch naming
18
44
  branch_prefix: "story/" # prefix for story branches (e.g., story/1-2-user-auth)
19
45
  max_branch_length: 60 # truncate + 6-char hash if longer
@@ -119,11 +119,10 @@ function parentProcessName() {
119
119
  try {
120
120
  const pid = process.ppid;
121
121
  if (process.platform === 'win32') {
122
- const res = spawnSync(
123
- 'tasklist',
124
- ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'],
125
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
126
- );
122
+ const res = spawnSync('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
123
+ encoding: 'utf8',
124
+ stdio: ['ignore', 'pipe', 'ignore'],
125
+ });
127
126
  if (res.status !== 0) return null;
128
127
  return parseTasklistOutput(res.stdout || '');
129
128
  }
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+
3
+ // auto-merge-bmad-docs.js — automatically merge BMad documentation
4
+ // updates (decision log, retrospectives, story files) from per-story
5
+ // branches into the base, without running the full per-story PR flow.
6
+ //
7
+ // Use case: after `bmad-create-story` or `bmad-retrospective` produces
8
+ // artifacts that don't affect product code, fast-merge them so the next
9
+ // story can build on the latest sprint state without waiting on review.
10
+ //
11
+ // Scope: only touches files under `_bmad-output/` and recognized
12
+ // SAFE paths. Refuses to merge a branch that has product-code changes.
13
+
14
+ const { execFileSync } = require('node:child_process');
15
+ const path = require('node:path');
16
+
17
+ const { parseArgs } = require('../lib/runtime/args');
18
+ const log = require('../lib/runtime/log');
19
+
20
+ const SAFE_PATHS = ['_bmad-output/', '_bmad/', 'docs/sprint/'];
21
+
22
+ function help() {
23
+ log.out(
24
+ [
25
+ 'Usage: auto-merge-bmad-docs.js --branch <name> [--base <name>]',
26
+ ' [--project-root <path>] [--check-only]',
27
+ '',
28
+ 'Refuses to merge if the branch touches any path outside SAFE_PATHS:',
29
+ ` ${SAFE_PATHS.join(', ')}`,
30
+ ].join('\n'),
31
+ );
32
+ }
33
+
34
+ function git(projectRoot, args) {
35
+ return execFileSync('git', ['-C', projectRoot, ...args], { encoding: 'utf8' }).trim();
36
+ }
37
+
38
+ function changedFiles(projectRoot, branch, base) {
39
+ return git(projectRoot, ['diff', '--name-only', `${base}...${branch}`])
40
+ .split(/\n/)
41
+ .filter(Boolean);
42
+ }
43
+
44
+ function classifyChanges(files) {
45
+ const unsafe = files.filter((f) => !SAFE_PATHS.some((prefix) => f.startsWith(prefix)));
46
+ return { safe: unsafe.length === 0, unsafe };
47
+ }
48
+
49
+ function main(argv) {
50
+ const { opts } = parseArgs(argv, { booleanFlags: ['help', 'check-only'] });
51
+ if (opts.help) {
52
+ help();
53
+ return 0;
54
+ }
55
+ if (!opts.branch) {
56
+ log.error('--branch required');
57
+ return 2;
58
+ }
59
+ const projectRoot = path.resolve(opts['project-root'] || process.cwd());
60
+ const base = opts.base || 'main';
61
+
62
+ let files;
63
+ try {
64
+ files = changedFiles(projectRoot, opts.branch, base);
65
+ } catch (e) {
66
+ log.error(`git diff failed: ${e.message}`);
67
+ return 1;
68
+ }
69
+
70
+ const classification = classifyChanges(files);
71
+ const result = {
72
+ branch: opts.branch,
73
+ base,
74
+ files,
75
+ ...classification,
76
+ };
77
+
78
+ if (opts['check-only']) {
79
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
80
+ return result.safe ? 0 : 1;
81
+ }
82
+
83
+ if (!result.safe) {
84
+ log.error(
85
+ `refusing to auto-merge: branch touches ${result.unsafe.length} non-doc file(s): ${result.unsafe.slice(0, 5).join(', ')}${result.unsafe.length > 5 ? '...' : ''}`,
86
+ );
87
+ return 1;
88
+ }
89
+
90
+ // Safe — perform the merge.
91
+ try {
92
+ git(projectRoot, ['switch', base]);
93
+ git(projectRoot, [
94
+ 'merge',
95
+ '--no-ff',
96
+ '-m',
97
+ `Auto-merge BMad docs from ${opts.branch}`,
98
+ opts.branch,
99
+ ]);
100
+ } catch (e) {
101
+ log.error(`merge failed: ${e.message}`);
102
+ return 1;
103
+ }
104
+ process.stdout.write(`${JSON.stringify({ ...result, merged: true }, null, 2)}\n`);
105
+ return 0;
106
+ }
107
+
108
+ if (require.main === module) {
109
+ process.exit(main(process.argv.slice(2)));
110
+ }
111
+
112
+ module.exports = { main, classifyChanges, SAFE_PATHS };
@@ -51,7 +51,10 @@ function help() {
51
51
 
52
52
  function parseLayer(raw) {
53
53
  if (!raw) return { ok: false, error: '--layer is required' };
54
- const keys = String(raw).split(',').map((s) => s.trim()).filter(Boolean);
54
+ const keys = String(raw)
55
+ .split(',')
56
+ .map((s) => s.trim())
57
+ .filter(Boolean);
55
58
  for (const k of keys) {
56
59
  if (!STORY_RE.test(k)) {
57
60
  return { ok: false, error: `invalid story key '${k}': must match ${STORY_RE}` };
@@ -133,11 +136,10 @@ function createWorktree({ projectRoot, worktree, branch, baseBranch }) {
133
136
  stderr: first.stderr || '',
134
137
  };
135
138
  }
136
- const second = spawnSync(
137
- 'git',
138
- ['-C', projectRoot, 'worktree', 'add', worktree, branch],
139
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
140
- );
139
+ const second = spawnSync('git', ['-C', projectRoot, 'worktree', 'add', worktree, branch], {
140
+ encoding: 'utf8',
141
+ stdio: ['ignore', 'pipe', 'pipe'],
142
+ });
141
143
  return {
142
144
  created: second.status === 0,
143
145
  retried: true,
@@ -260,13 +262,15 @@ function main() {
260
262
  log.error(layer.error);
261
263
  process.exit(1);
262
264
  }
263
- const maxParallel = opts['max-parallel'] !== undefined ? Number.parseInt(String(opts['max-parallel']), 10) : 2;
265
+ const maxParallel =
266
+ opts['max-parallel'] !== undefined ? Number.parseInt(String(opts['max-parallel']), 10) : 2;
264
267
  if (Number.isNaN(maxParallel) || maxParallel < 1) {
265
268
  log.error(`invalid --max-parallel '${opts['max-parallel']}': must be a positive integer`);
266
269
  process.exit(1);
267
270
  }
268
271
  const projectRoot = opts['project-root'] || process.cwd();
269
- const branchPrefix = opts['branch-prefix'] !== undefined ? String(opts['branch-prefix']) : 'story/';
272
+ const branchPrefix =
273
+ opts['branch-prefix'] !== undefined ? String(opts['branch-prefix']) : 'story/';
270
274
  const baseBranch = opts['base-branch'] !== undefined ? String(opts['base-branch']) : 'main';
271
275
  const dryRun = opts['dry-run'] === true;
272
276
 
@@ -137,7 +137,11 @@ function validateEnvelope(envelope, { projectRoot, epic }) {
137
137
  return { valid: false, errors };
138
138
  }
139
139
  if (envelope.version !== 1) {
140
- push({ code: 'schema', field: 'version', message: `expected version === 1, got ${JSON.stringify(envelope.version)}` });
140
+ push({
141
+ code: 'schema',
142
+ field: 'version',
143
+ message: `expected version === 1, got ${JSON.stringify(envelope.version)}`,
144
+ });
141
145
  }
142
146
  if (typeof envelope.epic !== 'string' || envelope.epic !== String(epic)) {
143
147
  push({
@@ -149,10 +153,23 @@ function validateEnvelope(envelope, { projectRoot, epic }) {
149
153
  const deps = envelope.dependencies;
150
154
  const rationale = envelope.rationale;
151
155
  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, ...] }' });
156
+ push({
157
+ code: 'schema',
158
+ field: 'dependencies',
159
+ message: 'must be an object of { storyKey: [depKey, ...] }',
160
+ });
153
161
  }
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" }' });
162
+ if (
163
+ rationale === undefined ||
164
+ rationale === null ||
165
+ typeof rationale !== 'object' ||
166
+ Array.isArray(rationale)
167
+ ) {
168
+ push({
169
+ code: 'schema',
170
+ field: 'rationale',
171
+ message: 'must be an object of { storyKey: "string" }',
172
+ });
156
173
  }
157
174
  // Stop here on root-level shape failures — the per-key checks below assume valid containers.
158
175
  if (errors.length > 0) return { valid: false, errors };
@@ -163,15 +180,27 @@ function validateEnvelope(envelope, { projectRoot, epic }) {
163
180
  for (const key of Object.keys(deps)) {
164
181
  const arr = deps[key];
165
182
  if (!Array.isArray(arr)) {
166
- push({ code: 'schema', field: `dependencies.${key}`, message: 'must be an array of story keys' });
183
+ push({
184
+ code: 'schema',
185
+ field: `dependencies.${key}`,
186
+ message: 'must be an array of story keys',
187
+ });
167
188
  continue;
168
189
  }
169
190
  if (!validKeys.has(key)) {
170
- push({ code: 'unknown-key', key, message: `story "${key}" not present in sprint-status.yaml for epic ${epic}` });
191
+ push({
192
+ code: 'unknown-key',
193
+ key,
194
+ message: `story "${key}" not present in sprint-status.yaml for epic ${epic}`,
195
+ });
171
196
  }
172
197
  for (const dep of arr) {
173
198
  if (typeof dep !== 'string') {
174
- push({ code: 'schema', field: `dependencies.${key}[]`, message: `dep entries must be strings, got ${JSON.stringify(dep)}` });
199
+ push({
200
+ code: 'schema',
201
+ field: `dependencies.${key}[]`,
202
+ message: `dep entries must be strings, got ${JSON.stringify(dep)}`,
203
+ });
175
204
  continue;
176
205
  }
177
206
  if (dep === key) {
@@ -189,13 +218,21 @@ function validateEnvelope(envelope, { projectRoot, epic }) {
189
218
  continue;
190
219
  }
191
220
  if (!validKeys.has(dep)) {
192
- push({ code: 'unknown-key', key: dep, message: `dependency "${dep}" of "${key}" not in sprint-status.yaml` });
221
+ push({
222
+ code: 'unknown-key',
223
+ key: dep,
224
+ message: `dependency "${dep}" of "${key}" not in sprint-status.yaml`,
225
+ });
193
226
  }
194
227
  }
195
228
  // Rationale required for every declared key.
196
229
  const r = rationale[key];
197
230
  if (typeof r !== 'string' || r.trim() === '') {
198
- push({ code: 'schema', field: `rationale.${key}`, message: 'rationale required for every key in dependencies (non-empty string)' });
231
+ push({
232
+ code: 'schema',
233
+ field: `rationale.${key}`,
234
+ message: 'rationale required for every key in dependencies (non-empty string)',
235
+ });
199
236
  }
200
237
  }
201
238
 
@@ -218,7 +255,11 @@ function validateEnvelope(envelope, { projectRoot, epic }) {
218
255
  }
219
256
  const { cycle } = topoLayers(allKeys, edges);
220
257
  if (cycle.length > 0) {
221
- push({ code: 'cycle', nodes: cycle.slice().sort(), message: `cyclic dependency among: ${cycle.slice().sort().join(', ')}` });
258
+ push({
259
+ code: 'cycle',
260
+ nodes: cycle.slice().sort(),
261
+ message: `cyclic dependency among: ${cycle.slice().sort().join(', ')}`,
262
+ });
222
263
  }
223
264
  }
224
265
 
@@ -255,10 +296,16 @@ function mergeDoc(envelope, existing) {
255
296
  };
256
297
  }
257
298
  // 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
- : {};
299
+ const overrides =
300
+ existing && existing.doc && Array.isArray(existing.doc.overrides) ? existing.doc.overrides : [];
301
+ const epics =
302
+ existing &&
303
+ existing.doc &&
304
+ existing.doc.epics &&
305
+ typeof existing.doc.epics === 'object' &&
306
+ !Array.isArray(existing.doc.epics)
307
+ ? existing.doc.epics
308
+ : {};
262
309
  return { version: 1, stories, overrides, epics };
263
310
  }
264
311
 
@@ -342,16 +389,17 @@ function renderYaml(doc, hash) {
342
389
  }
343
390
  const first = ovKeys[0];
344
391
  const firstVal = ov[first];
345
- if (Array.isArray(firstVal) || (typeof firstVal !== 'object' || firstVal === null)) {
392
+ if (Array.isArray(firstVal) || typeof firstVal !== 'object' || firstVal === null) {
346
393
  lines.push(` - ${first}: ${inlineScalar(firstVal)}`);
347
394
  } else {
348
395
  lines.push(` - ${first}:`);
349
- for (const sk of Object.keys(firstVal)) lines.push(` ${sk}: ${inlineScalar(firstVal[sk])}`);
396
+ for (const sk of Object.keys(firstVal))
397
+ lines.push(` ${sk}: ${inlineScalar(firstVal[sk])}`);
350
398
  }
351
399
  for (let i = 1; i < ovKeys.length; i++) {
352
400
  const k = ovKeys[i];
353
401
  const v = ov[k];
354
- if (Array.isArray(v) || (typeof v !== 'object' || v === null)) {
402
+ if (Array.isArray(v) || typeof v !== 'object' || v === null) {
355
403
  lines.push(` ${k}: ${inlineScalar(v)}`);
356
404
  } else {
357
405
  lines.push(` ${k}:`);
@@ -465,7 +513,10 @@ async function runDryRun(projectRoot, epic) {
465
513
  envelope = JSON.parse(stdin);
466
514
  } catch (e) {
467
515
  process.stdout.write(
468
- JSON.stringify({ valid: false, errors: [{ code: 'schema', field: 'root', message: `invalid JSON: ${e.message}` }] }) + '\n',
516
+ JSON.stringify({
517
+ valid: false,
518
+ errors: [{ code: 'schema', field: 'root', message: `invalid JSON: ${e.message}` }],
519
+ }) + '\n',
469
520
  );
470
521
  return 1;
471
522
  }
@@ -477,7 +528,9 @@ async function runDryRun(projectRoot, epic) {
477
528
  const existing = readExisting(projectRoot);
478
529
  const merged = mergeDoc(envelope, existing);
479
530
  const diff = diffCounts(existing.doc, merged);
480
- process.stdout.write(JSON.stringify({ valid: true, errors: [], merged_doc: merged, diff }) + '\n');
531
+ process.stdout.write(
532
+ JSON.stringify({ valid: true, errors: [], merged_doc: merged, diff }) + '\n',
533
+ );
481
534
  return 0;
482
535
  }
483
536
 
@@ -502,7 +555,10 @@ async function runWrite(projectRoot, epic, { force }) {
502
555
  envelope = JSON.parse(stdin);
503
556
  } catch (e) {
504
557
  process.stdout.write(
505
- JSON.stringify({ valid: false, errors: [{ code: 'schema', field: 'root', message: `invalid JSON: ${e.message}` }] }) + '\n',
558
+ JSON.stringify({
559
+ valid: false,
560
+ errors: [{ code: 'schema', field: 'root', message: `invalid JSON: ${e.message}` }],
561
+ }) + '\n',
506
562
  );
507
563
  return 1;
508
564
  }
@@ -569,7 +625,8 @@ async function main() {
569
625
  try {
570
626
  if (command === 'scaffold-prompt') process.exit(await runScaffoldPrompt(projectRoot, epic));
571
627
  if (command === 'dry-run') process.exit(await runDryRun(projectRoot, epic));
572
- if (command === 'write') process.exit(await runWrite(projectRoot, epic, { force: opts.force === true }));
628
+ if (command === 'write')
629
+ process.exit(await runWrite(projectRoot, epic, { force: opts.force === true }));
573
630
  } catch (e) {
574
631
  log.error(`unexpected error: ${e.stack || e.message}`);
575
632
  process.exit(1);
@@ -249,9 +249,10 @@ function main() {
249
249
  const headerRe = /^(#{2,})\s+(tasks|subtasks)(\s*\/\s*(tasks|subtasks))?\s*$/i;
250
250
  for (let i = 0; i < lines.length; i++) {
251
251
  if (headerRe.test(lines[i])) {
252
- const injection = entries.length === 0
253
- ? ['', '- [ ] Implement story per Acceptance Criteria', '']
254
- : ['', ...entries.map((e) => `- [ ] ${e}`), ''];
252
+ const injection =
253
+ entries.length === 0
254
+ ? ['', '- [ ] Implement story per Acceptance Criteria', '']
255
+ : ['', ...entries.map((e) => `- [ ] ${e}`), ''];
255
256
  lines.splice(i + 1, 0, ...injection);
256
257
  break;
257
258
  }
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+
3
+ // land-this-pr.js — produce the argv sequence that lands the active PR
4
+ // from a stack snapshot. Land = merge into base + delete the local
5
+ // branch + rebase the rest of the stack.
6
+ //
7
+ // Reads a stack snapshot produced by stack-snapshot.js (--snapshot <path>),
8
+ // outputs an ordered list of git commands. Does NOT execute them — the
9
+ // orchestrator CLI runs each step through its retry/error pipeline.
10
+ //
11
+ // merge_strategy honors the active profile's squash_on_merge.
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const { parseArgs } = require('../lib/runtime/args');
17
+ const log = require('../lib/runtime/log');
18
+
19
+ function help() {
20
+ log.out(
21
+ [
22
+ 'Usage: land-this-pr.js --snapshot <path> [--squash] [--base <name>]',
23
+ ' [--output <path>]',
24
+ '',
25
+ 'Reads a stack snapshot, outputs an argv-step plan to land the active PR.',
26
+ ].join('\n'),
27
+ );
28
+ }
29
+
30
+ function buildPlan(snapshot, opts) {
31
+ if (!snapshot || !snapshot.active_pr) {
32
+ return { steps: [], skipped: true, reason: 'no active_pr in snapshot' };
33
+ }
34
+ const base = opts.base || snapshot.base_branch || 'main';
35
+ const branch = snapshot.active_pr.branch;
36
+ const squash = !!opts.squash;
37
+
38
+ const steps = [];
39
+ steps.push({ args: ['git', 'fetch', 'origin'], description: 'sync remote' });
40
+ steps.push({ args: ['git', 'switch', base], description: `switch to ${base}` });
41
+ steps.push({
42
+ args: ['git', 'merge', '--ff-only', `origin/${base}`],
43
+ description: 'ff base to remote',
44
+ });
45
+ if (squash) {
46
+ steps.push({ args: ['git', 'merge', '--squash', branch], description: 'squash-merge' });
47
+ steps.push({
48
+ args: ['git', 'commit', '-m', `feat(${snapshot.active_pr.story_key}): land`],
49
+ description: 'squash commit',
50
+ });
51
+ } else {
52
+ steps.push({
53
+ args: ['git', 'merge', '--no-ff', '-m', `Merge ${branch}`, branch],
54
+ description: 'non-ff merge',
55
+ });
56
+ }
57
+ steps.push({
58
+ args: ['git', 'push', 'origin', base],
59
+ description: `push ${base}`,
60
+ retry: { attempts: 4, backoff_ms: [2000, 4000, 8000, 16000], on: 'network' },
61
+ });
62
+ steps.push({
63
+ args: ['git', 'branch', '-d', branch],
64
+ description: `delete local ${branch}`,
65
+ });
66
+
67
+ // Rebase the rest of the stack onto the new base.
68
+ const rest = (snapshot.branches || []).filter((b) => b.name !== branch && b.status !== 'done');
69
+ for (const b of rest) {
70
+ steps.push({
71
+ args: ['git', 'rebase', base, b.name],
72
+ description: `rebase ${b.name} onto ${base}`,
73
+ });
74
+ }
75
+
76
+ return { steps, skipped: false, branch, base, rebased: rest.map((b) => b.name) };
77
+ }
78
+
79
+ function main(argv) {
80
+ const { opts } = parseArgs(argv, { booleanFlags: ['help', 'squash'] });
81
+ if (opts.help) {
82
+ help();
83
+ return 0;
84
+ }
85
+ if (!opts.snapshot) {
86
+ log.error('--snapshot <path> required');
87
+ return 2;
88
+ }
89
+ let snap;
90
+ try {
91
+ snap = JSON.parse(fs.readFileSync(path.resolve(opts.snapshot), 'utf8'));
92
+ } catch (e) {
93
+ log.error(`snapshot read failed: ${e.message}`);
94
+ return 1;
95
+ }
96
+ const plan = buildPlan(snap, opts);
97
+ const text = `${JSON.stringify(plan, null, 2)}\n`;
98
+ if (opts.output) {
99
+ fs.writeFileSync(path.resolve(opts.output), text, 'utf8');
100
+ } else {
101
+ process.stdout.write(text);
102
+ }
103
+ return 0;
104
+ }
105
+
106
+ if (require.main === module) {
107
+ process.exit(main(process.argv.slice(2)));
108
+ }
109
+
110
+ module.exports = { main, buildPlan };
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+
3
+ // lint-test-pitfalls.js — scan test files for common LLM-authored mistakes
4
+ // that make tests pass locally but fail under different conditions or hide
5
+ // real bugs.
6
+ //
7
+ // Run as part of post-green-gates.js after the GREEN phase. Reports issues
8
+ // per file; exits 0 if no issues, 1 if any "block" issue found.
9
+ //
10
+ // Detected pitfalls:
11
+ // - it.only / describe.only / xit / xdescribe — focused/disabled tests
12
+ // - expect(true).toBe(true) and equivalent tautologies
13
+ // - Promise without await (potential unhandled rejection in test)
14
+ // - process.exit() inside a test (kills the runner)
15
+ // - Hard-coded paths to /tmp / C:\ — not portable
16
+ //
17
+ // Pure-ish: takes a list of files via argv, reads via fs, prints JSON.
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const { parseArgs } = require('../lib/runtime/args');
23
+ const log = require('../lib/runtime/log');
24
+
25
+ const PITFALLS = [
26
+ {
27
+ id: 'focused_or_skipped',
28
+ severity: 'block',
29
+ re: /\b(?:it|describe)\.only\b|\bxit\b|\bxdescribe\b/g,
30
+ message: 'focused (.only) or skipped (xit/xdescribe) tests',
31
+ },
32
+ {
33
+ id: 'tautological_expect',
34
+ severity: 'block',
35
+ re: /expect\(\s*(true|false|1|0|"")\s*\)\.toBe\(\s*\1\s*\)/g,
36
+ message: 'tautological expect (e.g. expect(true).toBe(true))',
37
+ },
38
+ {
39
+ id: 'process_exit_in_test',
40
+ severity: 'block',
41
+ re: /\bprocess\.exit\(/g,
42
+ message: 'process.exit() inside test source — kills the runner',
43
+ },
44
+ {
45
+ id: 'hardcoded_absolute_path',
46
+ severity: 'warn',
47
+ // Match /tmp/... or C:\... at start of a string literal
48
+ re: /["'](\/tmp\/|[A-Za-z]:\\)/g,
49
+ message: 'hard-coded absolute path — use os.tmpdir() / path.join()',
50
+ },
51
+ {
52
+ id: 'missing_await_on_promise',
53
+ severity: 'warn',
54
+ re: /^\s*(?:fetch|axios|page|request)\s*\(/gm,
55
+ message: 'looks like a promise call without await — verify intent',
56
+ },
57
+ ];
58
+
59
+ function help() {
60
+ log.out(
61
+ [
62
+ 'Usage: lint-test-pitfalls.js [--json] <files...>',
63
+ ' --json Emit structured JSON (default: human-readable)',
64
+ ].join('\n'),
65
+ );
66
+ }
67
+
68
+ function scanFile(filePath) {
69
+ let text;
70
+ try {
71
+ text = fs.readFileSync(filePath, 'utf8');
72
+ } catch (e) {
73
+ return { file: filePath, error: e.message, issues: [] };
74
+ }
75
+ const issues = [];
76
+ for (const p of PITFALLS) {
77
+ p.re.lastIndex = 0;
78
+ let m;
79
+ while ((m = p.re.exec(text))) {
80
+ const line = text.slice(0, m.index).split('\n').length;
81
+ issues.push({
82
+ id: p.id,
83
+ severity: p.severity,
84
+ line,
85
+ message: p.message,
86
+ match: m[0],
87
+ });
88
+ }
89
+ }
90
+ return { file: filePath, issues };
91
+ }
92
+
93
+ function main(argv) {
94
+ const { opts, positional } = parseArgs(argv, { booleanFlags: ['json', 'help'] });
95
+ if (opts.help) {
96
+ help();
97
+ return 0;
98
+ }
99
+ if (positional.length === 0) {
100
+ help();
101
+ return 2;
102
+ }
103
+
104
+ const reports = positional.map((f) => scanFile(path.resolve(f)));
105
+ let blockCount = 0;
106
+ let warnCount = 0;
107
+ for (const r of reports) {
108
+ for (const i of r.issues) {
109
+ if (i.severity === 'block') blockCount += 1;
110
+ else if (i.severity === 'warn') warnCount += 1;
111
+ }
112
+ }
113
+
114
+ if (opts.json) {
115
+ process.stdout.write(`${JSON.stringify({ reports, blockCount, warnCount }, null, 2)}\n`);
116
+ } else {
117
+ for (const r of reports) {
118
+ if (r.issues.length === 0) continue;
119
+ log.out(`${r.file}:`);
120
+ for (const i of r.issues) {
121
+ log.out(` L${i.line} [${i.severity}] ${i.message}: ${i.match}`);
122
+ }
123
+ }
124
+ log.out(`\n${blockCount} blocking, ${warnCount} warning`);
125
+ }
126
+ return blockCount > 0 ? 1 : 0;
127
+ }
128
+
129
+ if (require.main === module) {
130
+ process.exit(main(process.argv.slice(2)));
131
+ }
132
+
133
+ module.exports = { main, scanFile, PITFALLS };
@@ -83,7 +83,7 @@ function leadingIndent(line) {
83
83
  // {key, value} or null. Handles trailing `# comment`.
84
84
  function parseKV(content) {
85
85
  const m = content.match(
86
- /^["']?([A-Za-z0-9][A-Za-z0-9_.\-]*)["']?\s*:\s*(\S[^#]*?)?(?:\s*#.*)?\s*$/,
86
+ /^["']?([A-Za-z0-9][A-Za-z0-9_.-]*)["']?\s*:\s*(\S[^#]*?)?(?:\s*#.*)?\s*$/,
87
87
  );
88
88
  if (!m) return null;
89
89
  return { key: m[1], value: m[2] ? stripQuotes(m[2].trim()) : '' };