@ikunin/sprintpilot 1.0.4 → 2.0.4
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/_Sprintpilot/Sprintpilot.md +14 -1
- package/_Sprintpilot/manifest.yaml +1 -1
- package/_Sprintpilot/modules/autopilot/config.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/_base.yaml +45 -0
- package/_Sprintpilot/modules/autopilot/profiles/large.yaml +22 -0
- package/_Sprintpilot/modules/autopilot/profiles/legacy.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/medium.yaml +5 -0
- package/_Sprintpilot/modules/autopilot/profiles/nano.yaml +35 -0
- package/_Sprintpilot/modules/autopilot/profiles/small.yaml +5 -0
- package/_Sprintpilot/modules/git/config.yaml +8 -0
- package/_Sprintpilot/modules/ma/config.yaml +42 -0
- package/_Sprintpilot/scripts/agent-adapter.js +247 -0
- package/_Sprintpilot/scripts/cached-read.js +238 -0
- package/_Sprintpilot/scripts/check-prereqs.js +139 -0
- package/_Sprintpilot/scripts/dispatch-layer.js +192 -0
- package/_Sprintpilot/scripts/git-portable.js +219 -0
- package/_Sprintpilot/scripts/infer-dependencies.js +594 -0
- package/_Sprintpilot/scripts/inject-tasks-section.js +279 -0
- package/_Sprintpilot/scripts/list-remaining-stories.js +295 -0
- package/_Sprintpilot/scripts/log-timing.js +360 -0
- package/_Sprintpilot/scripts/mark-done-stories-tasks.js +254 -0
- package/_Sprintpilot/scripts/merge-shards.js +339 -0
- package/_Sprintpilot/scripts/preflight-merge.js +235 -0
- package/_Sprintpilot/scripts/resolve-dag.js +559 -0
- package/_Sprintpilot/scripts/resolve-profile.js +355 -0
- package/_Sprintpilot/scripts/state-shard.js +602 -0
- package/_Sprintpilot/scripts/submodule-lock.js +130 -0
- package/_Sprintpilot/scripts/summarize-timings.js +362 -0
- package/_Sprintpilot/scripts/sync-status.js +13 -0
- package/_Sprintpilot/scripts/with-retry.js +145 -0
- package/_Sprintpilot/skills/sprint-autopilot-on/workflow.md +673 -540
- package/_Sprintpilot/skills/sprintpilot-update/workflow.md +2 -1
- package/_Sprintpilot/templates/epic-retrospective.md +24 -0
- package/_Sprintpilot/templates/sprint-report.txt +60 -0
- package/bin/sprintpilot.js +4 -0
- package/lib/commands/install.js +157 -1
- package/package.json +1 -1
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// resolve-profile.js — resolve the effective Sprintpilot profile config.
|
|
4
|
+
//
|
|
5
|
+
// Reads:
|
|
6
|
+
// 1. _Sprintpilot/modules/autopilot/config.yaml → complexity_profile key
|
|
7
|
+
// (missing key → defaults to "medium" with a one-time stderr notice)
|
|
8
|
+
// 2. _Sprintpilot/modules/autopilot/profiles/_base.yaml → base defaults
|
|
9
|
+
// (skipped when the profile has version_pinned set — i.e. legacy)
|
|
10
|
+
// 3. _Sprintpilot/modules/autopilot/profiles/<profile>.yaml → overlay
|
|
11
|
+
// 4. _Sprintpilot/modules/{autopilot,git,ma}/config.yaml → user overrides
|
|
12
|
+
//
|
|
13
|
+
// Deep-merge semantics:
|
|
14
|
+
// - leaf values: user override > profile overlay > base > undefined
|
|
15
|
+
// - null user-override is "explicit unset" → fall back to profile default
|
|
16
|
+
// - arrays are replaced wholesale (not concatenated)
|
|
17
|
+
|
|
18
|
+
const fs = require('node:fs');
|
|
19
|
+
const path = require('node:path');
|
|
20
|
+
|
|
21
|
+
const { parseArgs } = require('../lib/runtime/args');
|
|
22
|
+
const log = require('../lib/runtime/log');
|
|
23
|
+
|
|
24
|
+
const VALID_PROFILES = ['nano', 'small', 'medium', 'large', 'legacy'];
|
|
25
|
+
const DEFAULT_PROFILE = 'medium';
|
|
26
|
+
|
|
27
|
+
// Narrow YAML parser for the profile YAML shape.
|
|
28
|
+
//
|
|
29
|
+
// We deliberately avoid requiring js-yaml here because this script runs
|
|
30
|
+
// from the user's project after `sprintpilot install` copies it into
|
|
31
|
+
// `_Sprintpilot/scripts/` — at that point js-yaml isn't resolvable
|
|
32
|
+
// because the user's project doesn't depend on @ikunin/sprintpilot.
|
|
33
|
+
//
|
|
34
|
+
// Supported shape (all that profile YAMLs need):
|
|
35
|
+
// - top-level scalar keys: `name: nano`, `version_pinned: null`
|
|
36
|
+
// - nested objects up to 3 levels deep
|
|
37
|
+
// - scalar values: string (bare / single-quoted / double-quoted),
|
|
38
|
+
// integer, boolean (true/false), null
|
|
39
|
+
// - comments after `#` (whole-line or trailing)
|
|
40
|
+
// - blank lines
|
|
41
|
+
//
|
|
42
|
+
// NOT supported (and not needed here): arrays, anchors, flow style,
|
|
43
|
+
// multi-line strings, timestamps.
|
|
44
|
+
function parseYaml(text) {
|
|
45
|
+
const lines = text.split(/\r?\n/);
|
|
46
|
+
const root = {};
|
|
47
|
+
const stack = [{ indent: -1, obj: root }];
|
|
48
|
+
|
|
49
|
+
for (const rawLine of lines) {
|
|
50
|
+
// Strip comments (naively — comment `#` inside a quoted string is not
|
|
51
|
+
// handled, but profile YAMLs never have `#` in string values).
|
|
52
|
+
const hashIdx = rawLine.indexOf('#');
|
|
53
|
+
const line = hashIdx === -1 ? rawLine : rawLine.slice(0, hashIdx);
|
|
54
|
+
if (!line.trim()) continue;
|
|
55
|
+
|
|
56
|
+
const indent = line.match(/^( *)/)[1].length;
|
|
57
|
+
const content = line.slice(indent).trimEnd();
|
|
58
|
+
const colon = content.indexOf(':');
|
|
59
|
+
if (colon === -1) continue; // malformed line — skip gracefully
|
|
60
|
+
|
|
61
|
+
const key = content.slice(0, colon).trim();
|
|
62
|
+
const rest = content.slice(colon + 1).trim();
|
|
63
|
+
|
|
64
|
+
// Pop stack until top is a strict parent of this indent level.
|
|
65
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
66
|
+
stack.pop();
|
|
67
|
+
}
|
|
68
|
+
const parent = stack[stack.length - 1].obj;
|
|
69
|
+
|
|
70
|
+
if (rest === '') {
|
|
71
|
+
// New nested object.
|
|
72
|
+
const child = {};
|
|
73
|
+
parent[key] = child;
|
|
74
|
+
stack.push({ indent, obj: child });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
parent[key] = parseScalar(rest);
|
|
79
|
+
}
|
|
80
|
+
return root;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseScalar(raw) {
|
|
84
|
+
// Quoted strings
|
|
85
|
+
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
|
|
86
|
+
return raw.slice(1, -1);
|
|
87
|
+
}
|
|
88
|
+
if (raw === 'null' || raw === '~' || raw === '') return null;
|
|
89
|
+
if (raw === 'true') return true;
|
|
90
|
+
if (raw === 'false') return false;
|
|
91
|
+
if (/^-?\d+$/.test(raw)) return Number.parseInt(raw, 10);
|
|
92
|
+
if (/^-?\d+\.\d+$/.test(raw)) return Number.parseFloat(raw);
|
|
93
|
+
return raw;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function dumpYaml(obj, indent = 0) {
|
|
97
|
+
const pad = ' '.repeat(indent);
|
|
98
|
+
const lines = [];
|
|
99
|
+
for (const key of Object.keys(obj)) {
|
|
100
|
+
const val = obj[key];
|
|
101
|
+
if (val === null) {
|
|
102
|
+
lines.push(`${pad}${key}: null`);
|
|
103
|
+
} else if (typeof val === 'object' && !Array.isArray(val)) {
|
|
104
|
+
lines.push(`${pad}${key}:`);
|
|
105
|
+
const inner = dumpYaml(val, indent + 1);
|
|
106
|
+
if (inner) lines.push(inner);
|
|
107
|
+
} else if (typeof val === 'boolean' || typeof val === 'number') {
|
|
108
|
+
lines.push(`${pad}${key}: ${val}`);
|
|
109
|
+
} else {
|
|
110
|
+
// string — quote only if ambiguous
|
|
111
|
+
const s = String(val);
|
|
112
|
+
const needsQuote = /^(true|false|null|~)$/i.test(s) || /^-?\d/.test(s) || /[:#]/.test(s);
|
|
113
|
+
lines.push(`${pad}${key}: ${needsQuote ? JSON.stringify(s) : s}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const yaml = { load: parseYaml, dump: (obj) => `${dumpYaml(obj)}\n` };
|
|
120
|
+
|
|
121
|
+
function help() {
|
|
122
|
+
log.out(
|
|
123
|
+
[
|
|
124
|
+
'Usage:',
|
|
125
|
+
' resolve-profile.js print [--project-root <path>] [--profile <name>]',
|
|
126
|
+
' resolve-profile.js get <dotted.key> [--project-root <path>] [--profile <name>]',
|
|
127
|
+
' resolve-profile.js validate [--project-root <path>] [--profile <name>]',
|
|
128
|
+
'',
|
|
129
|
+
'Profiles: nano, small, medium, large, legacy',
|
|
130
|
+
].join('\n'),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function readYamlFile(filePath) {
|
|
135
|
+
if (!fs.existsSync(filePath)) return null;
|
|
136
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
137
|
+
try {
|
|
138
|
+
return yaml.load(raw);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
log.warn(`failed to parse ${filePath}: ${e.message}`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Deep-merge `source` into `target`. Rules:
|
|
146
|
+
// - plain objects: merge key-by-key, recursive
|
|
147
|
+
// - arrays: replace wholesale
|
|
148
|
+
// - other leaves: source wins
|
|
149
|
+
// Special rule at caller level: `null` in source is treated as "unset";
|
|
150
|
+
// the caller decides whether to propagate that or skip the merge.
|
|
151
|
+
function deepMerge(target, source) {
|
|
152
|
+
if (!isPlainObject(source)) return source;
|
|
153
|
+
const out = isPlainObject(target) ? { ...target } : {};
|
|
154
|
+
for (const key of Object.keys(source)) {
|
|
155
|
+
const sv = source[key];
|
|
156
|
+
const tv = out[key];
|
|
157
|
+
if (isPlainObject(sv) && isPlainObject(tv)) {
|
|
158
|
+
out[key] = deepMerge(tv, sv);
|
|
159
|
+
} else {
|
|
160
|
+
out[key] = sv;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isPlainObject(v) {
|
|
167
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Read complexity_profile from the autopilot/config.yaml via regex so the
|
|
171
|
+
// file's `{{variable}}` placeholders aren't interpreted as YAML tokens.
|
|
172
|
+
// Returns { profile, source } where source is:
|
|
173
|
+
// 'config' — key was present and valid
|
|
174
|
+
// 'missing-default' — key absent, fell back to DEFAULT_PROFILE
|
|
175
|
+
// 'invalid' — key present but not in VALID_PROFILES
|
|
176
|
+
function readConfiguredProfile(projectRoot) {
|
|
177
|
+
const cfgPath = path.join(projectRoot, '_Sprintpilot', 'modules', 'autopilot', 'config.yaml');
|
|
178
|
+
if (!fs.existsSync(cfgPath)) {
|
|
179
|
+
return { profile: DEFAULT_PROFILE, source: 'missing-default' };
|
|
180
|
+
}
|
|
181
|
+
const raw = fs.readFileSync(cfgPath, 'utf8');
|
|
182
|
+
// `complexity_profile: <value>` at the top level (two-space indent under
|
|
183
|
+
// `autopilot:` or flat). Accept either; the canonical v2 shape is under
|
|
184
|
+
// autopilot but a flat install could theoretically write it at root.
|
|
185
|
+
const m = raw.match(/^[ \t]*complexity_profile:[ \t]*["']?([a-zA-Z_-]+)["']?[ \t]*(?:#.*)?$/m);
|
|
186
|
+
if (!m) return { profile: DEFAULT_PROFILE, source: 'missing-default' };
|
|
187
|
+
const value = m[1];
|
|
188
|
+
if (!VALID_PROFILES.includes(value)) {
|
|
189
|
+
return { profile: DEFAULT_PROFILE, source: 'invalid', raw: value };
|
|
190
|
+
}
|
|
191
|
+
return { profile: value, source: 'config' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolveProfile(projectRoot, explicitProfile) {
|
|
195
|
+
const profilesDir = path.join(projectRoot, '_Sprintpilot', 'modules', 'autopilot', 'profiles');
|
|
196
|
+
|
|
197
|
+
let profile;
|
|
198
|
+
let source;
|
|
199
|
+
if (explicitProfile) {
|
|
200
|
+
if (!VALID_PROFILES.includes(explicitProfile)) {
|
|
201
|
+
log.error(`unknown profile '${explicitProfile}'. Valid: ${VALID_PROFILES.join(', ')}`);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
profile = explicitProfile;
|
|
205
|
+
source = 'flag';
|
|
206
|
+
} else {
|
|
207
|
+
const read = readConfiguredProfile(projectRoot);
|
|
208
|
+
profile = read.profile;
|
|
209
|
+
source = read.source;
|
|
210
|
+
if (read.source === 'missing-default') {
|
|
211
|
+
// One-time stderr notice — caller may silence by redirecting stderr.
|
|
212
|
+
log.info(
|
|
213
|
+
`complexity_profile not set in autopilot/config.yaml; defaulting to '${DEFAULT_PROFILE}' (matches v1.0.5 behavior).`,
|
|
214
|
+
);
|
|
215
|
+
} else if (read.source === 'invalid') {
|
|
216
|
+
log.warn(
|
|
217
|
+
`complexity_profile '${read.raw}' is not recognized; defaulting to '${DEFAULT_PROFILE}'. Valid: ${VALID_PROFILES.join(', ')}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Load the named profile. It MUST exist — a missing file is a ship
|
|
223
|
+
// error, not a user error, so fail loudly.
|
|
224
|
+
const profileFile = path.join(profilesDir, `${profile}.yaml`);
|
|
225
|
+
const profileDoc = readYamlFile(profileFile);
|
|
226
|
+
if (profileDoc == null) {
|
|
227
|
+
log.error(`profile file missing or unreadable: ${profileFile}`);
|
|
228
|
+
process.exit(2);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Legacy pins everything — skip the _base overlay.
|
|
232
|
+
const baseDoc = profileDoc.version_pinned
|
|
233
|
+
? {}
|
|
234
|
+
: readYamlFile(path.join(profilesDir, '_base.yaml')) || {};
|
|
235
|
+
|
|
236
|
+
// Merge: start with base, overlay profile. Strip the `name` +
|
|
237
|
+
// `version_pinned` fields before overlaying the user config so they
|
|
238
|
+
// don't masquerade as config keys.
|
|
239
|
+
const stripMeta = (doc) => {
|
|
240
|
+
const { name: _n, version_pinned: _v, ...rest } = doc || {};
|
|
241
|
+
return rest;
|
|
242
|
+
};
|
|
243
|
+
let resolved = deepMerge(stripMeta(baseDoc), stripMeta(profileDoc));
|
|
244
|
+
|
|
245
|
+
// Overlay user config — autopilot/git/ma module YAMLs.
|
|
246
|
+
for (const moduleName of ['autopilot', 'git', 'ma']) {
|
|
247
|
+
const moduleCfg = path.join(projectRoot, '_Sprintpilot', 'modules', moduleName, 'config.yaml');
|
|
248
|
+
const doc = readYamlFile(moduleCfg);
|
|
249
|
+
if (!doc || !isPlainObject(doc)) continue;
|
|
250
|
+
// User config files are flat: top-level keys are the module's own
|
|
251
|
+
// knobs. Wrap them under the module name for the merge.
|
|
252
|
+
const wrapped = { [moduleName]: doc[moduleName] || doc };
|
|
253
|
+
resolved = deepMerge(resolved, wrapped);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
profile,
|
|
258
|
+
source,
|
|
259
|
+
resolved,
|
|
260
|
+
version_pinned: profileDoc.version_pinned || null,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function getByDottedKey(obj, key) {
|
|
265
|
+
const parts = key.split('.');
|
|
266
|
+
let cur = obj;
|
|
267
|
+
for (const p of parts) {
|
|
268
|
+
if (!isPlainObject(cur) || !(p in cur)) return undefined;
|
|
269
|
+
cur = cur[p];
|
|
270
|
+
}
|
|
271
|
+
return cur;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function main() {
|
|
275
|
+
const argv = process.argv.slice(2);
|
|
276
|
+
const { opts, positional } = parseArgs(argv);
|
|
277
|
+
|
|
278
|
+
if (opts.help || positional.length === 0) {
|
|
279
|
+
help();
|
|
280
|
+
process.exit(opts.help ? 0 : 1);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const command = positional[0];
|
|
284
|
+
const projectRoot = opts['project-root'] || process.cwd();
|
|
285
|
+
const explicitProfile = opts.profile;
|
|
286
|
+
|
|
287
|
+
if (command === 'print') {
|
|
288
|
+
const { resolved } = resolveProfile(projectRoot, explicitProfile);
|
|
289
|
+
process.stdout.write(yaml.dump(resolved));
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (command === 'get') {
|
|
294
|
+
const key = positional[1];
|
|
295
|
+
if (!key) {
|
|
296
|
+
log.error('get requires a dotted key argument');
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
const { resolved } = resolveProfile(projectRoot, explicitProfile);
|
|
300
|
+
const value = getByDottedKey(resolved, key);
|
|
301
|
+
if (value === undefined) {
|
|
302
|
+
log.error(`key not found: ${key}`);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
process.stdout.write(
|
|
306
|
+
typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
|
307
|
+
? `${value}\n`
|
|
308
|
+
: `${JSON.stringify(value)}\n`,
|
|
309
|
+
);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (command === 'validate') {
|
|
314
|
+
// Every profile YAML must exist, parse, and declare a name.
|
|
315
|
+
const profilesDir = path.join(projectRoot, '_Sprintpilot', 'modules', 'autopilot', 'profiles');
|
|
316
|
+
const errors = [];
|
|
317
|
+
for (const p of ['_base', ...VALID_PROFILES]) {
|
|
318
|
+
const file = path.join(profilesDir, `${p}.yaml`);
|
|
319
|
+
const doc = readYamlFile(file);
|
|
320
|
+
if (doc == null) {
|
|
321
|
+
errors.push(`missing or unparseable: ${file}`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (typeof doc.name !== 'string' || doc.name !== p) {
|
|
325
|
+
errors.push(`${file}: expected name: '${p}'`);
|
|
326
|
+
}
|
|
327
|
+
if (p === 'legacy' && !doc.version_pinned) {
|
|
328
|
+
errors.push(`${file}: legacy profile must set version_pinned`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (errors.length) {
|
|
332
|
+
for (const e of errors) log.error(e);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
log.out('OK');
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
log.error(`unknown command: ${command}`);
|
|
340
|
+
help();
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
module.exports = {
|
|
345
|
+
VALID_PROFILES,
|
|
346
|
+
DEFAULT_PROFILE,
|
|
347
|
+
resolveProfile,
|
|
348
|
+
readConfiguredProfile,
|
|
349
|
+
getByDottedKey,
|
|
350
|
+
deepMerge,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
if (require.main === module) {
|
|
354
|
+
main();
|
|
355
|
+
}
|