@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.
Files changed (86) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +330 -0
  3. package/_Sprintpilot/.secrets-allowlist +26 -0
  4. package/_Sprintpilot/Sprintpilot.md +216 -0
  5. package/_Sprintpilot/lib/runtime/args.js +77 -0
  6. package/_Sprintpilot/lib/runtime/git.js +24 -0
  7. package/_Sprintpilot/lib/runtime/http.js +96 -0
  8. package/_Sprintpilot/lib/runtime/log.js +30 -0
  9. package/_Sprintpilot/lib/runtime/secrets.js +151 -0
  10. package/_Sprintpilot/lib/runtime/spawn.js +68 -0
  11. package/_Sprintpilot/lib/runtime/text.js +26 -0
  12. package/_Sprintpilot/lib/runtime/yaml-lite.js +160 -0
  13. package/_Sprintpilot/manifest.yaml +26 -0
  14. package/_Sprintpilot/modules/autopilot/config.yaml +20 -0
  15. package/_Sprintpilot/modules/git/branching-and-pr-strategy.md +101 -0
  16. package/_Sprintpilot/modules/git/config.yaml +83 -0
  17. package/_Sprintpilot/modules/git/templates/commit-patch.txt +1 -0
  18. package/_Sprintpilot/modules/git/templates/commit-story.txt +1 -0
  19. package/_Sprintpilot/modules/git/templates/pr-body.md +20 -0
  20. package/_Sprintpilot/modules/ma/config.yaml +9 -0
  21. package/_Sprintpilot/scripts/create-pr.js +284 -0
  22. package/_Sprintpilot/scripts/detect-platform.js +64 -0
  23. package/_Sprintpilot/scripts/health-check.js +98 -0
  24. package/_Sprintpilot/scripts/lint-changed.js +249 -0
  25. package/_Sprintpilot/scripts/lock.js +195 -0
  26. package/_Sprintpilot/scripts/sanitize-branch.js +107 -0
  27. package/_Sprintpilot/scripts/stage-and-commit.js +190 -0
  28. package/_Sprintpilot/scripts/sync-status.js +141 -0
  29. package/_Sprintpilot/skills/sprint-autopilot-off/SKILL.md +6 -0
  30. package/_Sprintpilot/skills/sprint-autopilot-off/workflow.md +154 -0
  31. package/_Sprintpilot/skills/sprint-autopilot-on/SKILL.md +6 -0
  32. package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +1119 -0
  33. package/_Sprintpilot/skills/sprintpilot-assess/SKILL.md +6 -0
  34. package/_Sprintpilot/skills/sprintpilot-assess/agents/debt-classifier.md +64 -0
  35. package/_Sprintpilot/skills/sprintpilot-assess/agents/dependency-auditor.md +57 -0
  36. package/_Sprintpilot/skills/sprintpilot-assess/agents/migration-analyzer.md +62 -0
  37. package/_Sprintpilot/skills/sprintpilot-assess/workflow.md +114 -0
  38. package/_Sprintpilot/skills/sprintpilot-code-review/SKILL.md +6 -0
  39. package/_Sprintpilot/skills/sprintpilot-code-review/agents/acceptance-auditor.md +51 -0
  40. package/_Sprintpilot/skills/sprintpilot-code-review/agents/blind-hunter.md +39 -0
  41. package/_Sprintpilot/skills/sprintpilot-code-review/agents/edge-case-hunter.md +46 -0
  42. package/_Sprintpilot/skills/sprintpilot-code-review/workflow.md +111 -0
  43. package/_Sprintpilot/skills/sprintpilot-codebase-map/SKILL.md +6 -0
  44. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/architecture-mapper.md +129 -0
  45. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/concerns-hunter.md +135 -0
  46. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/integration-mapper.md +138 -0
  47. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/quality-assessor.md +143 -0
  48. package/_Sprintpilot/skills/sprintpilot-codebase-map/agents/stack-analyzer.md +133 -0
  49. package/_Sprintpilot/skills/sprintpilot-codebase-map/workflow.md +120 -0
  50. package/_Sprintpilot/skills/sprintpilot-migrate/SKILL.md +6 -0
  51. package/_Sprintpilot/skills/sprintpilot-migrate/agents/dependency-analyzer.md +51 -0
  52. package/_Sprintpilot/skills/sprintpilot-migrate/agents/risk-assessor.md +55 -0
  53. package/_Sprintpilot/skills/sprintpilot-migrate/agents/stack-mapper.md +49 -0
  54. package/_Sprintpilot/skills/sprintpilot-migrate/agents/test-parity-analyzer.md +49 -0
  55. package/_Sprintpilot/skills/sprintpilot-migrate/resources/coexistence-patterns.md +59 -0
  56. package/_Sprintpilot/skills/sprintpilot-migrate/resources/strategies.md +43 -0
  57. package/_Sprintpilot/skills/sprintpilot-migrate/templates/component-card.md +11 -0
  58. package/_Sprintpilot/skills/sprintpilot-migrate/templates/migration-epics.md +35 -0
  59. package/_Sprintpilot/skills/sprintpilot-migrate/templates/migration-plan.md +66 -0
  60. package/_Sprintpilot/skills/sprintpilot-migrate/workflow.md +235 -0
  61. package/_Sprintpilot/skills/sprintpilot-party-mode/SKILL.md +6 -0
  62. package/_Sprintpilot/skills/sprintpilot-party-mode/workflow.md +138 -0
  63. package/_Sprintpilot/skills/sprintpilot-research/SKILL.md +6 -0
  64. package/_Sprintpilot/skills/sprintpilot-research/workflow.md +128 -0
  65. package/_Sprintpilot/skills/sprintpilot-reverse-architect/SKILL.md +6 -0
  66. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/component-mapper.md +53 -0
  67. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/data-flow-tracer.md +54 -0
  68. package/_Sprintpilot/skills/sprintpilot-reverse-architect/agents/pattern-extractor.md +67 -0
  69. package/_Sprintpilot/skills/sprintpilot-reverse-architect/workflow.md +119 -0
  70. package/_Sprintpilot/skills/sprintpilot-update/SKILL.md +6 -0
  71. package/_Sprintpilot/skills/sprintpilot-update/workflow.md +46 -0
  72. package/_Sprintpilot/templates/agent-rules.md +43 -0
  73. package/bin/sprintpilot.js +95 -0
  74. package/lib/commands/check-update.js +54 -0
  75. package/lib/commands/install.js +876 -0
  76. package/lib/commands/uninstall.js +218 -0
  77. package/lib/core/bmad-config.js +113 -0
  78. package/lib/core/file-ops.js +90 -0
  79. package/lib/core/gitignore.js +54 -0
  80. package/lib/core/markers.js +126 -0
  81. package/lib/core/tool-registry.js +73 -0
  82. package/lib/core/update-check.js +39 -0
  83. package/lib/core/v1-detect.js +86 -0
  84. package/lib/prompts.js +82 -0
  85. package/lib/substitute.js +39 -0
  86. 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.