@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.
- package/README.md +245 -10
- package/_Sprintpilot/Sprintpilot.md +1 -1
- package/_Sprintpilot/bin/autopilot.js +581 -0
- package/_Sprintpilot/lib/orchestrator/action-ledger.js +148 -0
- package/_Sprintpilot/lib/orchestrator/adapt.js +502 -0
- package/_Sprintpilot/lib/orchestrator/decision-log.js +224 -0
- package/_Sprintpilot/lib/orchestrator/divergence.js +201 -0
- package/_Sprintpilot/lib/orchestrator/git-plan.js +259 -0
- package/_Sprintpilot/lib/orchestrator/impact-classifier.js +108 -0
- package/_Sprintpilot/lib/orchestrator/land.js +155 -0
- package/_Sprintpilot/lib/orchestrator/parallel-batch.js +99 -0
- package/_Sprintpilot/lib/orchestrator/profile-rules.js +167 -0
- package/_Sprintpilot/lib/orchestrator/report.js +95 -0
- package/_Sprintpilot/lib/orchestrator/state-machine.js +402 -0
- package/_Sprintpilot/lib/orchestrator/state-store.js +260 -0
- package/_Sprintpilot/lib/orchestrator/user-command-applier.js +157 -0
- package/_Sprintpilot/lib/orchestrator/user-commands.js +115 -0
- package/_Sprintpilot/lib/orchestrator/verify.js +397 -0
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/git/config.yaml +26 -0
- package/_Sprintpilot/scripts/agent-adapter.js +4 -5
- package/_Sprintpilot/scripts/auto-merge-bmad-docs.js +112 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +12 -8
- package/_Sprintpilot/scripts/infer-dependencies.js +78 -21
- package/_Sprintpilot/scripts/inject-tasks-section.js +4 -3
- package/_Sprintpilot/scripts/land-this-pr.js +110 -0
- package/_Sprintpilot/scripts/lint-test-pitfalls.js +133 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +1 -1
- package/_Sprintpilot/scripts/log-timing.js +12 -3
- package/_Sprintpilot/scripts/merge-shards.js +32 -12
- package/_Sprintpilot/scripts/post-green-gates.js +187 -0
- package/_Sprintpilot/scripts/preflight-merge.js +2 -1
- package/_Sprintpilot/scripts/resolve-dag.js +3 -1
- package/_Sprintpilot/scripts/scan.js +109 -13
- package/_Sprintpilot/scripts/stack-snapshot.js +128 -0
- package/_Sprintpilot/scripts/state-shard.js +8 -1
- package/_Sprintpilot/scripts/summarize-timings.js +30 -12
- package/_Sprintpilot/scripts/with-retry.js +17 -5
- package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +23 -1
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.orchestrator.md +148 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +9 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +10 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +2 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/component-mapper.md +7 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/data-flow-tracer.md +7 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/pattern-extractor.md +7 -0
- package/lib/core/update-check.js +11 -1
- package/package.json +1 -1
- 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
|
-
'
|
|
124
|
-
['
|
|
125
|
-
|
|
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)
|
|
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
|
-
'
|
|
138
|
-
['
|
|
139
|
-
|
|
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 =
|
|
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 =
|
|
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({
|
|
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({
|
|
156
|
+
push({
|
|
157
|
+
code: 'schema',
|
|
158
|
+
field: 'dependencies',
|
|
159
|
+
message: 'must be an object of { storyKey: [depKey, ...] }',
|
|
160
|
+
});
|
|
153
161
|
}
|
|
154
|
-
if (
|
|
155
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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({
|
|
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 =
|
|
259
|
-
|
|
260
|
-
|
|
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) ||
|
|
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))
|
|
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) ||
|
|
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({
|
|
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(
|
|
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({
|
|
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')
|
|
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 =
|
|
253
|
-
|
|
254
|
-
|
|
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_
|
|
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()) : '' };
|