@ikunin/sprintpilot 1.0.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/LICENSE +201 -0
- package/README.md +330 -0
- package/_Sprintpilot/.secrets-allowlist +26 -0
- package/_Sprintpilot/Sprintpilot.md +216 -0
- package/_Sprintpilot/lib/runtime/args.js +77 -0
- package/_Sprintpilot/lib/runtime/git.js +24 -0
- package/_Sprintpilot/lib/runtime/http.js +96 -0
- package/_Sprintpilot/lib/runtime/log.js +30 -0
- package/_Sprintpilot/lib/runtime/secrets.js +151 -0
- package/_Sprintpilot/lib/runtime/spawn.js +68 -0
- package/_Sprintpilot/lib/runtime/text.js +26 -0
- package/_Sprintpilot/lib/runtime/yaml-lite.js +160 -0
- package/_Sprintpilot/manifest.yaml +26 -0
- package/_Sprintpilot/modules/autopilot/config.yaml +20 -0
- package/_Sprintpilot/modules/git/branching-and-pr-strategy.md +101 -0
- package/_Sprintpilot/modules/git/config.yaml +83 -0
- package/_Sprintpilot/modules/git/templates/commit-patch.txt +1 -0
- package/_Sprintpilot/modules/git/templates/commit-story.txt +1 -0
- package/_Sprintpilot/modules/git/templates/pr-body.md +20 -0
- package/_Sprintpilot/modules/ma/config.yaml +9 -0
- package/_Sprintpilot/scripts/create-pr.js +284 -0
- package/_Sprintpilot/scripts/detect-platform.js +64 -0
- package/_Sprintpilot/scripts/health-check.js +98 -0
- package/_Sprintpilot/scripts/lint-changed.js +249 -0
- package/_Sprintpilot/scripts/lock.js +195 -0
- package/_Sprintpilot/scripts/sanitize-branch.js +107 -0
- package/_Sprintpilot/scripts/stage-and-commit.js +190 -0
- package/_Sprintpilot/scripts/sync-status.js +141 -0
- package/_Sprintpilot/skills/sprint-autopilot-off/SKILL.md +6 -0
- package/_Sprintpilot/skills/sprint-autopilot-off/workflow.md +154 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +6 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +1119 -0
- package/_Sprintpilot/skills/sprintpilot-assess/SKILL.md +6 -0
- package/_Sprintpilot/skills/sprintpilot-assess/agents/debt-classifier.md +64 -0
- package/_Sprintpilot/skills/sprintpilot-assess/agents/dependency-auditor.md +57 -0
- package/_Sprintpilot/skills/sprintpilot-assess/agents/migration-analyzer.md +62 -0
- package/_Sprintpilot/skills/sprintpilot-assess/workflow.md +114 -0
- package/_Sprintpilot/skills/sprintpilot-code-review/SKILL.md +6 -0
- package/_Sprintpilot/skills/sprintpilot-code-review/agents/acceptance-auditor.md +51 -0
- package/_Sprintpilot/skills/sprintpilot-code-review/agents/blind-hunter.md +39 -0
- package/_Sprintpilot/skills/sprintpilot-code-review/agents/edge-case-hunter.md +46 -0
- package/_Sprintpilot/skills/sprintpilot-code-review/workflow.md +111 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/SKILL.md +6 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +129 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +135 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +138 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +143 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +133 -0
- package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +120 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/SKILL.md +6 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/agents/dependency-analyzer.md +51 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/agents/risk-assessor.md +55 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/agents/stack-mapper.md +49 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/agents/test-parity-analyzer.md +49 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/resources/coexistence-patterns.md +59 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/resources/strategies.md +43 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/templates/component-card.md +11 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/templates/migration-epics.md +35 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/templates/migration-plan.md +66 -0
- package/_Sprintpilot/skills/sprintpilot-migrate/workflow.md +235 -0
- package/_Sprintpilot/skills/sprintpilot-party-mode/SKILL.md +6 -0
- package/_Sprintpilot/skills/sprintpilot-party-mode/workflow.md +138 -0
- package/_Sprintpilot/skills/sprintpilot-research/SKILL.md +6 -0
- package/_Sprintpilot/skills/sprintpilot-research/workflow.md +128 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/SKILL.md +6 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/component-mapper.md +53 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/data-flow-tracer.md +54 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/pattern-extractor.md +67 -0
- package/_Sprintpilot/skills/sprintpilot-reverse-architect/workflow.md +119 -0
- package/_Sprintpilot/skills/sprintpilot-update/SKILL.md +6 -0
- package/_Sprintpilot/skills/sprintpilot-update/workflow.md +46 -0
- package/_Sprintpilot/templates/agent-rules.md +43 -0
- package/bin/sprintpilot.js +95 -0
- package/lib/commands/check-update.js +54 -0
- package/lib/commands/install.js +876 -0
- package/lib/commands/uninstall.js +218 -0
- package/lib/core/bmad-config.js +113 -0
- package/lib/core/file-ops.js +90 -0
- package/lib/core/gitignore.js +54 -0
- package/lib/core/markers.js +126 -0
- package/lib/core/tool-registry.js +73 -0
- package/lib/core/update-check.js +39 -0
- package/lib/core/v1-detect.js +86 -0
- package/lib/prompts.js +82 -0
- package/lib/substitute.js +39 -0
- package/package.json +49 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
9
|
+
const log = require('../lib/runtime/log');
|
|
10
|
+
|
|
11
|
+
const ACTIONS = ['check', 'acquire', 'release', 'status'];
|
|
12
|
+
const CLOCK_SKEW_TOLERANCE_SECONDS = 60;
|
|
13
|
+
|
|
14
|
+
function help() {
|
|
15
|
+
log.out('Usage: lock.js <check|acquire|release|status> [--file path] [--stale-minutes n]');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeSessionId() {
|
|
19
|
+
try {
|
|
20
|
+
return crypto.randomUUID();
|
|
21
|
+
} catch {
|
|
22
|
+
return `session-${Date.now()}-${Math.floor(Math.random() * 1e9)}`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readLockInfo(lockFile, staleSeconds) {
|
|
27
|
+
let stat;
|
|
28
|
+
try {
|
|
29
|
+
stat = fs.lstatSync(lockFile);
|
|
30
|
+
} catch {
|
|
31
|
+
return { state: 'FREE' };
|
|
32
|
+
}
|
|
33
|
+
// A non-regular-file lock (directory, symlink, device, etc.) is unsafe to
|
|
34
|
+
// read/overwrite. Treat as LOCKED so the operator can investigate rather
|
|
35
|
+
// than silently stomp it.
|
|
36
|
+
// Identifiers returned from this function are included in the CLI's
|
|
37
|
+
// stdout as part of a `STATE:ID:AGE` contract — callers split on `:`, so
|
|
38
|
+
// IDs must not contain colons, spaces, or parentheses. Use stable,
|
|
39
|
+
// parser-safe slugs for the diagnostic cases.
|
|
40
|
+
if (!stat.isFile()) {
|
|
41
|
+
return { state: 'LOCKED', id: 'non-file-lock-path', ageMin: 0, corrupt: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let raw;
|
|
45
|
+
try {
|
|
46
|
+
raw = fs.readFileSync(lockFile, 'utf8');
|
|
47
|
+
} catch {
|
|
48
|
+
return { state: 'LOCKED', id: 'unreadable-lock', ageMin: 0, corrupt: true };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
|
|
52
|
+
const firstLine = lines[0];
|
|
53
|
+
// A corrupted or unparseable first line is not automatically stale.
|
|
54
|
+
// Treating garbage as epoch-0 lets anyone wipe a live lock by writing junk.
|
|
55
|
+
if (!firstLine || !/^\d+$/.test(firstLine)) {
|
|
56
|
+
return { state: 'LOCKED', id: 'corrupt-lock', ageMin: 0, corrupt: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const lockTime = parseInt(firstLine, 10);
|
|
60
|
+
const lockId = lines[lines.length - 1] || 'unknown';
|
|
61
|
+
const now = Math.floor(Date.now() / 1000);
|
|
62
|
+
const age = now - lockTime;
|
|
63
|
+
|
|
64
|
+
// Future-dated lockTime (clock skew, DST, manual mtime): treat as STALE so
|
|
65
|
+
// the lock doesn't become permanent. Tolerate a small positive skew window.
|
|
66
|
+
if (age < -CLOCK_SKEW_TOLERANCE_SECONDS) {
|
|
67
|
+
return { state: 'STALE', id: lockId, ageMin: 0, skew: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const ageMin = Math.floor(Math.max(age, 0) / 60);
|
|
71
|
+
if (age < staleSeconds) return { state: 'LOCKED', id: lockId, ageMin };
|
|
72
|
+
return { state: 'STALE', id: lockId, ageMin };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Atomic exclusive-create write — the lockfile's existence IS the lock.
|
|
76
|
+
// Two racing acquirers cannot both win: the second sees EEXIST.
|
|
77
|
+
function writeLockExclusive(lockFile, id) {
|
|
78
|
+
const dir = path.dirname(lockFile);
|
|
79
|
+
if (dir && dir !== '.' && !fs.existsSync(dir)) {
|
|
80
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
83
|
+
const content = `${ts}\n${id}\n`;
|
|
84
|
+
// 'wx' => O_CREAT | O_EXCL: fails with EEXIST if file already exists.
|
|
85
|
+
const fd = fs.openSync(lockFile, 'wx', 0o644);
|
|
86
|
+
let wrote = false;
|
|
87
|
+
try {
|
|
88
|
+
fs.writeSync(fd, content, 0, 'utf8');
|
|
89
|
+
wrote = true;
|
|
90
|
+
} finally {
|
|
91
|
+
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
92
|
+
if (!wrote) {
|
|
93
|
+
// writeSync failed (ENOSPC, EIO): leaving an empty lockfile behind
|
|
94
|
+
// would look "corrupt" to the next acquirer and permanently wedge
|
|
95
|
+
// the autopilot. Unlink so the next try can re-create cleanly.
|
|
96
|
+
try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function main() {
|
|
102
|
+
const { opts, positional } = parseArgs(process.argv.slice(2));
|
|
103
|
+
|
|
104
|
+
if (opts.help) {
|
|
105
|
+
help();
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const action = positional.find((p) => ACTIONS.includes(p));
|
|
110
|
+
const lockFile = opts.file || '.autopilot.lock';
|
|
111
|
+
const staleMinutes = parseInt(opts['stale-minutes'] || '30', 10);
|
|
112
|
+
const staleSeconds = staleMinutes * 60;
|
|
113
|
+
|
|
114
|
+
if (!action) {
|
|
115
|
+
log.error('action required (check|acquire|release|status)');
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (action === 'check') {
|
|
120
|
+
const info = readLockInfo(lockFile, staleSeconds);
|
|
121
|
+
if (info.state === 'FREE') log.out('FREE');
|
|
122
|
+
else log.out(`${info.state}:${info.id}:${info.ageMin}m`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (action === 'acquire') {
|
|
127
|
+
const id = makeSessionId();
|
|
128
|
+
// First try the fast exclusive-create path.
|
|
129
|
+
try {
|
|
130
|
+
writeLockExclusive(lockFile, id);
|
|
131
|
+
log.out(`ACQUIRED:${id}`);
|
|
132
|
+
return;
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (e.code !== 'EEXIST') {
|
|
135
|
+
log.error(`failed to acquire lock: ${e.message}`);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// EEXIST: inspect the current lock. Only STALE may be taken over, and
|
|
141
|
+
// the takeover is still racy (two processes could unlink+recreate) —
|
|
142
|
+
// mitigate by re-doing an exclusive create after unlink. On race, one
|
|
143
|
+
// of them gets EEXIST.
|
|
144
|
+
const info = readLockInfo(lockFile, staleSeconds);
|
|
145
|
+
if (info.state === 'STALE') {
|
|
146
|
+
try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
|
|
147
|
+
try {
|
|
148
|
+
writeLockExclusive(lockFile, id);
|
|
149
|
+
log.out(`ACQUIRED_STALE:${id}`);
|
|
150
|
+
return;
|
|
151
|
+
} catch (e) {
|
|
152
|
+
// Another acquirer either won the stale takeover race, OR released
|
|
153
|
+
// their lock in the meantime. Re-read and handle both cases.
|
|
154
|
+
const fresh = readLockInfo(lockFile, staleSeconds);
|
|
155
|
+
if (fresh.state === 'FREE') {
|
|
156
|
+
// Released between our unlink and our re-create. Try once more.
|
|
157
|
+
try {
|
|
158
|
+
writeLockExclusive(lockFile, id);
|
|
159
|
+
log.out(`ACQUIRED:${id}`);
|
|
160
|
+
return;
|
|
161
|
+
} catch {
|
|
162
|
+
const afterRetry = readLockInfo(lockFile, staleSeconds);
|
|
163
|
+
log.out(`LOCKED:${afterRetry.id || 'unknown'}:${afterRetry.ageMin || 0}m`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
log.out(`LOCKED:${fresh.id || 'unknown'}:${fresh.ageMin || 0}m`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// LOCKED (or corrupt lock — surface the state rather than silently evicting it).
|
|
173
|
+
log.out(`LOCKED:${info.id}:${info.ageMin}m`);
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (action === 'release') {
|
|
178
|
+
if (fs.existsSync(lockFile)) {
|
|
179
|
+
try { fs.unlinkSync(lockFile); } catch { /* ignore */ }
|
|
180
|
+
log.out('RELEASED');
|
|
181
|
+
} else {
|
|
182
|
+
log.out('NO_LOCK');
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (action === 'status') {
|
|
188
|
+
const info = readLockInfo(lockFile, staleSeconds);
|
|
189
|
+
if (info.state === 'FREE') log.out('Lock: free (no active session)');
|
|
190
|
+
else if (info.state === 'LOCKED') log.out(`Lock: ACTIVE — session ${info.id}, age ${info.ageMin}m`);
|
|
191
|
+
else log.out(`Lock: STALE — session ${info.id}, age ${info.ageMin}m (will auto-remove)`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
main();
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
|
|
6
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
7
|
+
const { tryGit } = require('../lib/runtime/git');
|
|
8
|
+
const log = require('../lib/runtime/log');
|
|
9
|
+
|
|
10
|
+
function help() {
|
|
11
|
+
log.out('Usage: sanitize-branch.js <story-key> [--prefix story/] [--max-length 60]');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Minimum length for truncation to produce a valid result: we need at least
|
|
15
|
+
// 1 char of name + '-' + 6-char hash = 8 chars.
|
|
16
|
+
const MIN_MAX_LENGTH = 8;
|
|
17
|
+
|
|
18
|
+
function sanitize(storyKey, maxLength) {
|
|
19
|
+
let name = storyKey.toLowerCase();
|
|
20
|
+
// Strip invalid git ref chars + control chars.
|
|
21
|
+
name = name
|
|
22
|
+
.replace(/[~^:?*[\\@{}"'!#$%+;=,<>|`\]]/g, '')
|
|
23
|
+
.replace(/[\x00-\x1f]/g, '')
|
|
24
|
+
// Path separators and path-traversal sequences — git treats `..` as
|
|
25
|
+
// invalid and `/` creates ref namespaces, so a story key like
|
|
26
|
+
// `../../etc/passwd` otherwise lands as a directory-traversing ref.
|
|
27
|
+
.replace(/\.\.+/g, '-')
|
|
28
|
+
.replace(/\//g, '-')
|
|
29
|
+
.replace(/\s+/g, '-')
|
|
30
|
+
.replace(/[&()]/g, '-')
|
|
31
|
+
.replace(/-{2,}/g, '-')
|
|
32
|
+
.replace(/^[-.]+/, '')
|
|
33
|
+
.replace(/[-.]+$/, '');
|
|
34
|
+
|
|
35
|
+
if (!name) return null;
|
|
36
|
+
|
|
37
|
+
if (name.length > maxLength) {
|
|
38
|
+
const hash = crypto.createHash('sha256').update(name).digest('hex').slice(0, 6);
|
|
39
|
+
const truncLen = maxLength - 7; // -6 for hash, -1 for separator
|
|
40
|
+
name = `${name.slice(0, truncLen)}-${hash}`;
|
|
41
|
+
}
|
|
42
|
+
return name;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function branchExists(fullName) {
|
|
46
|
+
const r = await tryGit(['rev-parse', '--verify', fullName]);
|
|
47
|
+
return r.exitCode === 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function validateRefFormat(fullName) {
|
|
51
|
+
const r = await tryGit(['check-ref-format', '--branch', fullName]);
|
|
52
|
+
return r.exitCode === 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function main() {
|
|
56
|
+
const { opts, positional } = parseArgs(process.argv.slice(2));
|
|
57
|
+
if (opts.help) { help(); process.exit(0); }
|
|
58
|
+
const storyKey = positional[0];
|
|
59
|
+
const prefix = opts.prefix ?? 'story/';
|
|
60
|
+
const maxLength = parseInt(opts['max-length'] || '60', 10);
|
|
61
|
+
|
|
62
|
+
if (!storyKey) {
|
|
63
|
+
log.error('story key required');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!Number.isFinite(maxLength) || maxLength < MIN_MAX_LENGTH) {
|
|
68
|
+
log.error(`--max-length must be at least ${MIN_MAX_LENGTH} (got ${maxLength})`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let name = sanitize(storyKey, maxLength);
|
|
73
|
+
if (!name) {
|
|
74
|
+
log.error(`story key '${storyKey}' produced empty branch name after sanitization`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let fullName = `${prefix}${name}`;
|
|
79
|
+
if (await branchExists(fullName)) {
|
|
80
|
+
const maxAttempts = 100;
|
|
81
|
+
let counter = 2;
|
|
82
|
+
while (counter <= maxAttempts) {
|
|
83
|
+
if (!(await branchExists(`${prefix}${name}-${counter}`))) {
|
|
84
|
+
name = `${name}-${counter}`;
|
|
85
|
+
fullName = `${prefix}${name}`;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
counter++;
|
|
89
|
+
}
|
|
90
|
+
if (counter > maxAttempts) {
|
|
91
|
+
log.error(`branch collision limit (${maxAttempts}) exceeded for '${name}'`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!(await validateRefFormat(fullName))) {
|
|
97
|
+
log.error(`could not produce valid branch name from '${storyKey}'`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
log.out(name);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
main().catch((e) => {
|
|
105
|
+
log.error(e.message || String(e));
|
|
106
|
+
process.exit(1);
|
|
107
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
8
|
+
const { tryGit, tryGitStdout, gitStdout } = require('../lib/runtime/git');
|
|
9
|
+
const {
|
|
10
|
+
parseAllowlist,
|
|
11
|
+
isAllowlisted,
|
|
12
|
+
scanLinesForSecrets,
|
|
13
|
+
isBinaryFile,
|
|
14
|
+
} = require('../lib/runtime/secrets');
|
|
15
|
+
const log = require('../lib/runtime/log');
|
|
16
|
+
|
|
17
|
+
function help() {
|
|
18
|
+
log.out("Usage: stage-and-commit.js --message 'msg' [--allowlist path] [--max-size-mb 1] [--file-list path] [--dry-run]");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function splitOut(out) {
|
|
22
|
+
return (out || '').split(/\r?\n/).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function dedupeSorted(arr) {
|
|
26
|
+
return Array.from(new Set(arr)).sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function collectChanges() {
|
|
30
|
+
const modified = await tryGitStdout(['diff', '--name-only', 'HEAD']);
|
|
31
|
+
const untracked = await tryGitStdout(['ls-files', '--others', '--exclude-standard']);
|
|
32
|
+
const deleted = splitOut(await tryGitStdout(['diff', '--name-only', '--diff-filter=D', 'HEAD']));
|
|
33
|
+
// `git diff --name-only HEAD` includes deletions — remove them from the
|
|
34
|
+
// add-side list so we don't `git add` a path that no longer exists and
|
|
35
|
+
// emit a spurious warning; the dedicated `git rm` loop handles them.
|
|
36
|
+
const deletedSet = new Set(deleted);
|
|
37
|
+
const all = dedupeSorted([...splitOut(modified), ...splitOut(untracked)])
|
|
38
|
+
.filter((f) => !deletedSet.has(f));
|
|
39
|
+
return { all, deleted };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseFileListMarkdown(filePath) {
|
|
43
|
+
if (!filePath || !fs.existsSync(filePath)) return [];
|
|
44
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
45
|
+
const results = [];
|
|
46
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
47
|
+
const m = line.match(/^\s*[-*]\s+(.+?)\s*$/);
|
|
48
|
+
if (m) results.push(m[1]);
|
|
49
|
+
}
|
|
50
|
+
return results;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function main() {
|
|
54
|
+
const { opts } = parseArgs(process.argv.slice(2), { booleanFlags: ['dry-run'] });
|
|
55
|
+
if (opts.help) { help(); process.exit(0); }
|
|
56
|
+
|
|
57
|
+
const message = opts.message ?? opts.m;
|
|
58
|
+
const allowlist = opts.allowlist;
|
|
59
|
+
const maxSizeMb = parseFloat(opts['max-size-mb'] || '1');
|
|
60
|
+
const fileList = opts['file-list'];
|
|
61
|
+
const dryRun = !!opts['dry-run'];
|
|
62
|
+
|
|
63
|
+
if (!message) {
|
|
64
|
+
log.error('--message required');
|
|
65
|
+
process.exit(2);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { all, deleted } = await collectChanges();
|
|
69
|
+
|
|
70
|
+
if (all.length === 0 && deleted.length === 0) {
|
|
71
|
+
log.err('Nothing to commit');
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const warnings = [];
|
|
76
|
+
const maxSizeBytes = Math.round(maxSizeMb * 1024 * 1024);
|
|
77
|
+
const allowPatterns = parseAllowlist(allowlist);
|
|
78
|
+
// Cap secret-scan reads to keep memory bounded on accidentally-staged
|
|
79
|
+
// multi-megabyte logs / generated artifacts. The scan is a warning-only
|
|
80
|
+
// heuristic anyway — refusing to scan a huge file is preferable to OOM.
|
|
81
|
+
const MAX_SCAN_BYTES = 2 * 1024 * 1024;
|
|
82
|
+
|
|
83
|
+
for (const file of all) {
|
|
84
|
+
try {
|
|
85
|
+
// lstat (not stat) so symlinks are visible and skippable. A symlink
|
|
86
|
+
// pointing outside the repo (e.g. /etc/shadow) would otherwise be
|
|
87
|
+
// opened and its contents included in warning output.
|
|
88
|
+
const lstat = fs.lstatSync(file);
|
|
89
|
+
if (lstat.isSymbolicLink()) continue;
|
|
90
|
+
if (!lstat.isFile()) continue;
|
|
91
|
+
|
|
92
|
+
// Detect once — binary classification is the same whether we're
|
|
93
|
+
// scanning for secrets or emitting the binary-file warning.
|
|
94
|
+
const isBinary = isBinaryFile(file);
|
|
95
|
+
|
|
96
|
+
if (!isAllowlisted(file, allowPatterns)) {
|
|
97
|
+
if (lstat.size > MAX_SCAN_BYTES) {
|
|
98
|
+
warnings.push(`secret scan skipped for ${file} (size ${Math.floor(lstat.size / 1024)} KB > ${MAX_SCAN_BYTES / 1024} KB limit)`);
|
|
99
|
+
} else if (!isBinary) {
|
|
100
|
+
try {
|
|
101
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
102
|
+
const hits = scanLinesForSecrets(raw, 3);
|
|
103
|
+
if (hits.length > 0) {
|
|
104
|
+
const shown = hits.map((h) => `${file}:${h.line}:${h.text}`).join('\n');
|
|
105
|
+
warnings.push(`possible secret in ${file}:\n${shown}\n`);
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// read error — skip
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (lstat.size > maxSizeBytes) {
|
|
114
|
+
const sizeMb = Math.floor(lstat.size / (1024 * 1024));
|
|
115
|
+
warnings.push(`large file ${file} (${sizeMb}MB > ${maxSizeMb}MB limit)`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (isBinary) {
|
|
119
|
+
warnings.push(`binary file detected: ${file} (will be staged but verify it's intended)`);
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
// missing / permission — ignore
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (fs.existsSync('.gitignore')) {
|
|
127
|
+
// Exact line match — substring tests were fooled by the entry appearing
|
|
128
|
+
// inside a comment (e.g. "# .autopilot.lock is auto-created").
|
|
129
|
+
const entries = fs.readFileSync('.gitignore', 'utf8')
|
|
130
|
+
.split(/\r?\n/)
|
|
131
|
+
.map((l) => l.trim())
|
|
132
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
133
|
+
if (!entries.includes('.autopilot.lock')) {
|
|
134
|
+
warnings.push(".gitignore missing entry '.autopilot.lock' — run installer to fix");
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
warnings.push('no .gitignore found — addon artifacts may be committed');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (fileList) {
|
|
141
|
+
const expected = parseFileListMarkdown(fileList);
|
|
142
|
+
if (expected.length > 0) {
|
|
143
|
+
for (const file of all) {
|
|
144
|
+
if (!expected.includes(file)) {
|
|
145
|
+
warnings.push(`unexpected file not in story File List: ${file}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const w of warnings) log.err(`WARN: ${w}`);
|
|
152
|
+
|
|
153
|
+
if (dryRun) {
|
|
154
|
+
log.out('DRY RUN — would stage and commit:');
|
|
155
|
+
for (const f of all) log.out(f);
|
|
156
|
+
if (deleted.length > 0) log.out(`Deleted: ${deleted.join(' ')}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Stage adds
|
|
161
|
+
for (const file of all) {
|
|
162
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) continue;
|
|
163
|
+
const r = await tryGit(['add', '--', file]);
|
|
164
|
+
if (r.exitCode !== 0) {
|
|
165
|
+
log.err(`WARN: could not add '${file}': ${(r.stderr || '').trim()}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Stage deletions
|
|
170
|
+
for (const file of deleted) {
|
|
171
|
+
const r = await tryGit(['rm', '--quiet', '--', file]);
|
|
172
|
+
if (r.exitCode !== 0) {
|
|
173
|
+
log.err(`WARN: could not remove '${file}' from index (may not be tracked)`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const commit = await tryGit(['commit', '-m', message]);
|
|
178
|
+
if (commit.exitCode !== 0) {
|
|
179
|
+
log.error(`commit failed: ${(commit.stderr || commit.stdout || '').trim()}`);
|
|
180
|
+
process.exit(2);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const sha = await gitStdout(['rev-parse', 'HEAD']);
|
|
184
|
+
log.out(sha);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
main().catch((e) => {
|
|
188
|
+
log.error(e.message || String(e));
|
|
189
|
+
process.exit(2);
|
|
190
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
|
|
8
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
9
|
+
const {
|
|
10
|
+
yamlSafe,
|
|
11
|
+
hasStoryBlock,
|
|
12
|
+
replaceStoryBlock,
|
|
13
|
+
appendStoryBlock,
|
|
14
|
+
} = require('../lib/runtime/yaml-lite');
|
|
15
|
+
const log = require('../lib/runtime/log');
|
|
16
|
+
|
|
17
|
+
function help() {
|
|
18
|
+
log.out('Usage: sync-status.js --story <key> --git-status-file <path> [git fields...]');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function atomicWrite(targetPath, content) {
|
|
22
|
+
const dir = path.dirname(targetPath);
|
|
23
|
+
if (dir && dir !== '.' && !fs.existsSync(dir)) {
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
// Random suffix avoids collision between concurrent writers in the same ms.
|
|
27
|
+
const suffix = crypto.randomBytes(4).toString('hex');
|
|
28
|
+
const tmp = `${targetPath}.${process.pid}.${Date.now()}.${suffix}.tmp`;
|
|
29
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
30
|
+
try {
|
|
31
|
+
fs.renameSync(tmp, targetPath);
|
|
32
|
+
} catch (e) {
|
|
33
|
+
if (e.code === 'EXDEV') {
|
|
34
|
+
// Cross-device rename (rare — bind mounts or the target path crossing
|
|
35
|
+
// a mount boundary between its parent dir and the file itself). Since
|
|
36
|
+
// we already have the full content in memory, skip the risky
|
|
37
|
+
// copyFile-then-unlink dance (which isn't atomic — O_TRUNC can leave
|
|
38
|
+
// the target truncated on failure) and just write the content
|
|
39
|
+
// directly at the target.
|
|
40
|
+
try {
|
|
41
|
+
fs.writeFileSync(targetPath, content, 'utf8');
|
|
42
|
+
} finally {
|
|
43
|
+
try { fs.unlinkSync(tmp); } catch { /* best effort */ }
|
|
44
|
+
}
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
// Any other error: clean up tmp so we don't leak cruft.
|
|
48
|
+
try { fs.unlinkSync(tmp); } catch { /* best effort */ }
|
|
49
|
+
throw e;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildBlock(story, fields) {
|
|
54
|
+
const lines = [` ${story}:`];
|
|
55
|
+
for (const { key, value, raw } of fields) {
|
|
56
|
+
if (value === undefined || value === null || value === '') continue;
|
|
57
|
+
if (raw) {
|
|
58
|
+
lines.push(` ${key}: ${value}`);
|
|
59
|
+
} else {
|
|
60
|
+
lines.push(` ${key}: ${yamlSafe(value)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildHeader(baseBranch, platform) {
|
|
67
|
+
return [
|
|
68
|
+
'# Sprintpilot — Git Status',
|
|
69
|
+
'# Tracks git metadata per story. Do not edit manually.',
|
|
70
|
+
'git_integration:',
|
|
71
|
+
' enabled: true',
|
|
72
|
+
` base_branch: ${baseBranch || 'main'}`,
|
|
73
|
+
` platform: ${platform || ''}`,
|
|
74
|
+
'',
|
|
75
|
+
'stories:',
|
|
76
|
+
].join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function main() {
|
|
80
|
+
const { opts } = parseArgs(process.argv.slice(2));
|
|
81
|
+
if (opts.help) { help(); process.exit(0); }
|
|
82
|
+
|
|
83
|
+
const story = opts.story;
|
|
84
|
+
const statusFile = opts['git-status-file'];
|
|
85
|
+
|
|
86
|
+
if (!story || !statusFile) {
|
|
87
|
+
log.error('--story and --git-status-file required');
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const branch = opts.branch;
|
|
92
|
+
const worktree = opts.worktree;
|
|
93
|
+
const storyCommit = opts.commit;
|
|
94
|
+
const patchCommits = opts['patch-commits'];
|
|
95
|
+
const pushStatus = opts['push-status'] || 'pending';
|
|
96
|
+
const mergeStatus = opts['merge-status'];
|
|
97
|
+
const prUrl = opts['pr-url'];
|
|
98
|
+
const lintResult = opts['lint-result'];
|
|
99
|
+
const platform = opts.platform;
|
|
100
|
+
const baseBranch = opts['base-branch'] || 'main';
|
|
101
|
+
// worktree-cleaned is a *tri-state*: unprovided means "don't touch prior
|
|
102
|
+
// value", so we must NOT emit the field when the flag is absent. The
|
|
103
|
+
// previous logic defaulted to 'false' and overwrote a prior 'true' every
|
|
104
|
+
// call.
|
|
105
|
+
const hasWorktreeCleaned = Object.prototype.hasOwnProperty.call(opts, 'worktree-cleaned');
|
|
106
|
+
let worktreeCleaned;
|
|
107
|
+
if (hasWorktreeCleaned) {
|
|
108
|
+
const v = opts['worktree-cleaned'];
|
|
109
|
+
// Accept 'true'/'false' strings (any case) and boolean true.
|
|
110
|
+
worktreeCleaned = (v === true || String(v).toLowerCase() === 'true') ? 'true' : 'false';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const fields = [
|
|
114
|
+
{ key: 'branch', value: branch },
|
|
115
|
+
{ key: 'worktree', value: worktree },
|
|
116
|
+
{ key: 'story_commit', value: storyCommit },
|
|
117
|
+
{ key: 'patch_commits', value: patchCommits ? `[${patchCommits}]` : undefined, raw: true },
|
|
118
|
+
{ key: 'lint_result', value: lintResult },
|
|
119
|
+
{ key: 'push_status', value: pushStatus },
|
|
120
|
+
{ key: 'merge_status', value: mergeStatus },
|
|
121
|
+
{ key: 'pr_url', value: prUrl },
|
|
122
|
+
{ key: 'worktree_cleaned', value: worktreeCleaned, raw: true },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const block = buildBlock(story, fields);
|
|
126
|
+
const existing = fs.existsSync(statusFile) ? fs.readFileSync(statusFile, 'utf8') : '';
|
|
127
|
+
|
|
128
|
+
let updated;
|
|
129
|
+
if (!existing) {
|
|
130
|
+
updated = `${buildHeader(baseBranch, platform)}\n${block}\n`;
|
|
131
|
+
} else if (hasStoryBlock(existing, story)) {
|
|
132
|
+
updated = replaceStoryBlock(existing, story, block);
|
|
133
|
+
} else {
|
|
134
|
+
updated = appendStoryBlock(existing, block);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
atomicWrite(statusFile, updated);
|
|
138
|
+
log.out(`OK:${story}:push=${pushStatus}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
main();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sprint-autopilot-off
|
|
3
|
+
description: 'Disengage autonomous story execution mode with git status report. Reports sprint position, completed work, git branch/PR status, and next steps. Releases the autopilot lock file. Use when user says "/sprint-autopilot-off" or "stop autopilot" or "pause autopilot".'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Follow the instructions in ./workflow.md.
|