@warnyin/sdlc 0.6.0 → 0.7.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/bin/cli.mjs CHANGED
@@ -1,641 +1,668 @@
1
- #!/usr/bin/env node
2
- // @warnyin/sdlc CLI — OpenSpec-style installer + lifecycle mechanics.
3
- // Zero-dependency, Node >= 20, cross-platform. Exported functions are pure
4
- // where possible so tests can exercise them; `main()` is guarded.
5
-
6
- import fs from 'node:fs';
7
- import path from 'node:path';
8
- import crypto from 'node:crypto';
9
- import process from 'node:process';
10
- import { spawnSync } from 'node:child_process';
11
- import { fileURLToPath } from 'node:url';
12
- import { parseFrontmatter } from '../lib/frontmatter.mjs';
13
- import { parseDelta, mergeDelta } from '../lib/delta.mjs';
14
- import { parseConfig } from '../lib/config.mjs';
15
- import { mergeHookSettings } from '../lib/settings-merge.mjs';
16
- import { buildReport, renderReport } from '../lib/observe.mjs';
17
- import { parseManifest, renderManifest, computeStale, containedIn, hasSymlinkSegment, PRUNE_BLAST_CAP } from '../lib/manifest.mjs';
18
- import { validateAll, formatIssues, listChangeDirs } from '../lib/validate.mjs';
19
- import {
20
- readChangeJournal, liveJournalPath, sealedJournalPath, serializeJournal, appendEvent,
21
- isSafeChangeId,
22
- } from '../lib/journal.mjs';
23
- import { detectTools, toolName } from './detect.mjs';
24
- import { colorEnabled, createStyle, symbolsFor, summarizeInstall, startHints } from './ui.mjs';
25
- import { multiSelect } from './multiselect.mjs';
26
-
27
- const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
- const PAYLOAD = path.join(PKG_ROOT, 'payload');
29
- const MARKER = '<!-- sdlc:start -->';
30
-
31
- export const TOOLS = Object.freeze([
32
- 'claude', 'cursor', 'windsurf', 'copilot', 'cline', 'gemini', 'agents-md',
33
- ]);
34
-
35
- const TEXT_EXT = new Set(['.md', '.mdc', '.mjs', '.json', '.yaml', '.yml', '.txt']);
36
-
37
- // ---------- small pure helpers ----------
38
-
39
- export function normalizeEol(content) {
40
- return content.replace(/\r\n/g, '\n');
41
- }
42
-
43
- export function toPosix(p) {
44
- return p.split(path.sep).join('/');
45
- }
46
-
47
- export function sha256(content) {
48
- return crypto.createHash('sha256').update(content).digest('hex');
49
- }
50
-
51
- export function parseArgs(argv) {
52
- const args = { _: [], tool: null, toolProvided: false, strict: false, force: false, json: false, help: false, version: false };
53
- for (let i = 0; i < argv.length; i++) {
54
- const a = argv[i];
55
- if (a === '--tool' || a === '--tools') {
56
- args.toolProvided = true;
57
- args.tool = (argv[++i] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
58
- }
59
- else if (a === '--strict') args.strict = true;
60
- else if (a === '--force') args.force = true;
61
- else if (a === '--json') args.json = true;
62
- else if (a === '--help' || a === '-h') args.help = true;
63
- else if (a === '--version' || a === '-v') args.version = true;
64
- else if (a.startsWith('--')) console.warn(`unknown flag ${a} (ignored)`);
65
- else args._.push(a);
66
- }
67
- return args;
68
- }
69
-
70
- // ---------- payload-owned file installation (ownership semantics) ----------
71
- // install mode: missing → write · byte-equal claim · different → user's, keep.
72
- // update mode: additionally, disk == old manifest hash (ours, unmodified)refresh.
73
-
74
- function writeFileNormalized(dest, content) {
75
- const ext = path.extname(dest);
76
- const out = TEXT_EXT.has(ext) ? normalizeEol(content) : content;
77
- fs.mkdirSync(path.dirname(dest), { recursive: true });
78
- fs.writeFileSync(dest, out);
79
- return out;
80
- }
81
-
82
- function tally(ctx, outcome) {
83
- if (ctx.stats) ctx.stats[outcome] = (ctx.stats[outcome] ?? 0) + 1;
84
- return outcome;
85
- }
86
-
87
- export function installFile(projectRoot, destRel, content, ctx) {
88
- const relPosix = toPosix(destRel);
89
- const dest = path.join(projectRoot, destRel);
90
- const next = normalizeEol(content);
91
- const nextHash = sha256(next);
92
-
93
- if (!fs.existsSync(dest)) {
94
- writeFileNormalized(dest, next);
95
- ctx.manifest.set(relPosix, nextHash);
96
- return tally(ctx, 'written');
97
- }
98
- const current = normalizeEol(fs.readFileSync(dest, 'utf8'));
99
- const currentHash = sha256(current);
100
- if (currentHash === nextHash) {
101
- ctx.manifest.set(relPosix, nextHash);
102
- return tally(ctx, 'current');
103
- }
104
- if (ctx.mode === 'update' && ctx.oldManifest?.get(relPosix) === currentHash) {
105
- writeFileNormalized(dest, next);
106
- ctx.manifest.set(relPosix, nextHash);
107
- return tally(ctx, 'updated');
108
- }
109
- // Keeping the content must not forget the ownership. Dropping the entry made
110
- // the next run see a file we had never installed, which permanently disarmed
111
- // update's refresh branch (disk hash === old manifest hash) the file froze
112
- // at its old payload version and every later run relabelled it user-modified.
113
- // The recorded hash stays the one we last wrote, so prune's "disk must match
114
- // the manifest" guard still refuses to touch a file the user really did edit.
115
- const owned = ctx.oldManifest?.get(relPosix);
116
- if (owned) ctx.manifest.set(relPosix, owned);
117
- // Matching the recorded hash proves nobody touched it — `install` simply does
118
- // not refresh. Calling that "user-modified" sent people hunting for an edit
119
- // they never made.
120
- ctx.warnings.push(owned === currentHash
121
- ? `kept (ours, older version — run \`npx @warnyin/sdlc update\` to refresh): ${relPosix}`
122
- : `kept (user-modified): ${relPosix}`);
123
- return tally(ctx, 'kept');
124
- }
125
-
126
- function copyTree(srcDir, destDirRel, projectRoot, ctx) {
127
- if (!fs.existsSync(srcDir)) return;
128
- for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
129
- const src = path.join(srcDir, entry.name);
130
- const destRel = path.join(destDirRel, entry.name);
131
- if (entry.isDirectory()) copyTree(src, destRel, projectRoot, ctx);
132
- else installFile(projectRoot, destRel, fs.readFileSync(src, 'utf8'), ctx);
133
- }
134
- }
135
-
136
- function payloadText(rel) {
137
- return normalizeEol(fs.readFileSync(path.join(PAYLOAD, rel), 'utf8'));
138
- }
139
-
140
- // ---------- adapters ----------
141
-
142
- function renderAdapter(templateRel) {
143
- return payloadText(templateRel).replace('{{RULES_CARD}}', payloadText('playbook/rules-card.md').trim());
144
- }
145
-
146
- // Marker adapters live inside user-owned files: append once, never rewrite.
147
- function appendWithMarker(projectRoot, destRel, content) {
148
- const dest = path.join(projectRoot, destRel);
149
- if (fs.existsSync(dest)) {
150
- const current = fs.readFileSync(dest, 'utf8');
151
- if (current.includes(MARKER)) return 'present';
152
- const sep = current.endsWith('\n') ? '\n' : '\n\n';
153
- fs.writeFileSync(dest, current + sep + normalizeEol(content));
154
- return 'appended';
155
- }
156
- writeFileNormalized(dest, content);
157
- return 'written';
158
- }
159
-
160
- export function installClaudeHooks(projectRoot) {
161
- const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
162
- let current = {};
163
- if (fs.existsSync(settingsPath)) {
164
- try {
165
- current = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
166
- } catch {
167
- throw new Error('.claude/settings.json is not valid JSON — fix it, then re-run init');
168
- }
169
- }
170
- const merged = mergeHookSettings(current);
171
- fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
172
- fs.writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
173
- }
174
-
175
- function installToolAdapters(projectRoot, tools, ctx) {
176
- if (tools.includes('claude')) {
177
- copyTree(path.join(PAYLOAD, 'adapters/claude/commands'), path.join('.claude', 'commands'), projectRoot, ctx);
178
- copyTree(path.join(PAYLOAD, 'adapters/claude/skills'), path.join('.claude', 'skills'), projectRoot, ctx);
179
- copyTree(path.join(PAYLOAD, 'adapters/claude/agents'), path.join('.claude', 'agents'), projectRoot, ctx);
180
- installClaudeHooks(projectRoot);
181
- }
182
- if (tools.includes('cursor')) {
183
- installFile(projectRoot, path.join('.cursor', 'rules', 'sdlc.mdc'), renderAdapter('adapters/cursor.mdc'), ctx);
184
- }
185
- if (tools.includes('windsurf')) {
186
- installFile(projectRoot, path.join('.windsurf', 'rules', 'sdlc.md'), renderAdapter('adapters/windsurf.md'), ctx);
187
- }
188
- if (tools.includes('copilot')) {
189
- appendWithMarker(projectRoot, path.join('.github', 'copilot-instructions.md'), renderAdapter('adapters/copilot.md'));
190
- }
191
- if (tools.includes('cline')) {
192
- appendWithMarker(projectRoot, '.clinerules', renderAdapter('adapters/cline.md'));
193
- }
194
- if (tools.includes('gemini')) {
195
- appendWithMarker(projectRoot, 'GEMINI.md', renderAdapter('adapters/gemini.md'));
196
- }
197
- if (tools.includes('agents-md')) {
198
- appendWithMarker(projectRoot, 'AGENTS.md', renderAdapter('adapters/agents-md.md'));
199
- }
200
- }
201
-
202
- // ---------- scaffold ----------
203
-
204
- function scaffoldSdlc(projectRoot, tools, ctx) {
205
- const sdlcRoot = path.join(projectRoot, 'sdlc');
206
- for (const dir of ['context/steering', 'specs', 'changes/archive', 'evals', '.state']) {
207
- fs.mkdirSync(path.join(sdlcRoot, dir), { recursive: true });
208
- }
209
-
210
- // git does not track empty directories, so changes/archive/ vanishes for
211
- // anyone who clones before the first change ships. Not manifested: an empty
212
- // marker is nothing for prune to reclaim or for the installer to warn about.
213
- const gitkeep = path.join(sdlcRoot, 'changes', 'archive', '.gitkeep');
214
- if (!fs.existsSync(gitkeep)) fs.writeFileSync(gitkeep, '');
215
-
216
- // Seeds are user-owned from birth: created once, never manifested/overwritten.
217
- const seed = (rel, templateName, transform = (s) => s) => {
218
- const dest = path.join(sdlcRoot, rel);
219
- if (fs.existsSync(dest)) return;
220
- writeFileNormalized(dest, transform(payloadText(`templates/${templateName}`)));
221
- };
222
- seed('config.yaml', 'config.yaml', (s) => s.replace('tools: []', `tools: [${tools.join(', ')}]`));
223
- seed('context/constitution.md', 'constitution.md');
224
- seed('harness.md', 'harness.md');
225
-
226
- // Payload-owned trees (manifested, refreshed by `update`).
227
- copyTree(path.join(PAYLOAD, 'playbook'), path.join('sdlc', '.playbook'), projectRoot, ctx);
228
- copyTree(path.join(PAYLOAD, 'templates'), path.join('sdlc', '.playbook', 'templates'), projectRoot, ctx);
229
- copyTree(path.join(PAYLOAD, 'hooks'), path.join('sdlc', '.hooks'), projectRoot, ctx);
230
- copyTree(path.join(PKG_ROOT, 'lib'), path.join('sdlc', '.hooks', 'lib'), projectRoot, ctx);
231
- }
232
-
233
- export function writeManifestFile(projectRoot, manifest) {
234
- const statePath = path.join(projectRoot, 'sdlc', '.state');
235
- fs.mkdirSync(statePath, { recursive: true });
236
- fs.writeFileSync(path.join(statePath, 'manifest'), renderManifest(manifest));
237
- }
238
-
239
- export function readManifestFile(projectRoot) {
240
- const p = path.join(projectRoot, 'sdlc', '.state', 'manifest');
241
- return fs.existsSync(p) ? parseManifest(fs.readFileSync(p, 'utf8')) : new Map();
242
- }
243
-
244
- export function ensureGitignore(projectRoot) {
245
- const giPath = path.join(projectRoot, '.gitignore');
246
- const entry = 'sdlc/.state/';
247
- let current = fs.existsSync(giPath) ? fs.readFileSync(giPath, 'utf8') : '';
248
- if (current.split(/\r?\n/).some((l) => l.trim() === entry)) return;
249
- if (current && !current.endsWith('\n')) current += '\n';
250
- fs.writeFileSync(giPath, current + entry + '\n');
251
- }
252
-
253
- // ---------- init ----------
254
-
255
- // `all` / `none` are reserved words, never combinable with a list — mixing
256
- // them would leave the caller guessing which one won.
257
- export function resolveToolList(picked) {
258
- const reserved = picked.filter((t) => t === 'all' || t === 'none');
259
- if (reserved.length && picked.length > 1) {
260
- throw new Error(`"${reserved[0]}" cannot be combined with other tools`);
261
- }
262
- if (picked[0] === 'all') return [...TOOLS];
263
- if (picked[0] === 'none') return [];
264
- if (!picked.length) throw new Error(`--tool requires a value: all, none, or any of ${TOOLS.join(', ')}`);
265
- const bad = picked.filter((t) => !TOOLS.includes(t));
266
- if (bad.length) throw new Error(`unknown tool(s): ${bad.join(', ')} — valid: ${TOOLS.join(', ')}, all, none`);
267
- return [...new Set(picked)];
268
- }
269
-
270
- export async function resolveTools(args, {
271
- interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
272
- projectRoot = process.cwd(),
273
- style = createStyle(false),
274
- } = {}) {
275
- if (args.toolProvided) return resolveToolList(args.tool ?? []);
276
- if (!interactive) return ['claude'];
277
-
278
- const detected = detectTools(projectRoot);
279
- if (detected.length) {
280
- console.log(style.dim(`Detected in this project: ${detected.map(toolName).join(', ')} (pre-selected)`));
281
- }
282
- const choices = TOOLS.map((tool) => ({
283
- value: tool,
284
- name: toolName(tool),
285
- note: detected.includes(tool) ? 'detected' : '',
286
- // First-time setup with nothing detected still needs a sane default.
287
- preSelected: detected.length ? detected.includes(tool) : tool === 'claude',
288
- }));
289
- const picked = await multiSelect({ choices, style, symbols: symbolsFor() });
290
- if (picked === null) throw new Error('cancelled nothing was installed');
291
- return picked;
292
- }
293
-
294
- function printInitSummary(tools, ctx, style, symbols, { configExisted }) {
295
- const s = summarizeInstall(ctx.manifest.keys(), tools);
296
- const stats = ctx.stats;
297
- const line = (text) => console.log(` ${text}`);
298
-
299
- console.log('');
300
- console.log(` ${style.green(symbols.tick)} ${style.bold('SDLC Setup Complete')}`);
301
- console.log('');
302
- line(`Tools: ${tools.length ? tools.map(toolName).join(', ') : style.dim('none (framework only)')}`);
303
- if (s.commands || s.skills || s.agents) {
304
- line(`${s.commands} commands, ${s.skills} skills and ${s.agents} agents in .claude/`);
305
- }
306
- for (const a of s.adapters.filter((a) => a.tool !== 'claude')) {
307
- line(`Rules for ${toolName(a.tool)}: ${a.path}`);
308
- }
309
- line(`${s.hooks} hooks in sdlc/.hooks/`);
310
- line(`Playbook: sdlc/.playbook/ (${s.playbook} stages + ${s.templates} templates)`);
311
- line(`Config: sdlc/config.yaml${configExisted ? ' (kept)' : ''}`);
312
- line(style.dim(`Files: ${stats.written} written · ${stats.current} unchanged · ${stats.updated} refreshed · ${stats.kept} kept (yours)`));
313
- console.log('');
314
- console.log(` ${style.bold('Getting started:')}`);
315
- startHints(tools).forEach((hint, i) => line(` ${i + 1}. ${hint}`));
316
- console.log('');
317
- }
318
-
319
- export async function cmdInit(projectRoot, args) {
320
- const style = createStyle(colorEnabled());
321
- const symbols = symbolsFor();
322
- const tools = await resolveTools(args, { projectRoot, style });
323
- const configExisted = fs.existsSync(path.join(projectRoot, 'sdlc', 'config.yaml'));
324
- const ctx = {
325
- mode: 'install',
326
- manifest: new Map(),
327
- oldManifest: readManifestFile(projectRoot),
328
- warnings: [],
329
- stats: { written: 0, current: 0, updated: 0, kept: 0 },
330
- };
331
- scaffoldSdlc(projectRoot, tools, ctx);
332
- installToolAdapters(projectRoot, tools, ctx);
333
- writeManifestFile(projectRoot, ctx.manifest);
334
- ensureGitignore(projectRoot);
335
- for (const w of ctx.warnings) console.warn(` ${style.yellow(symbols.warn)} ${w}`);
336
- printInitSummary(tools, ctx, style, symbols, { configExisted });
337
- return { tools };
338
- }
339
-
340
- // ---------- update + prune ----------
341
-
342
- export function cmdUpdate(projectRoot, args) {
343
- const sdlcRoot = path.join(projectRoot, 'sdlc');
344
- requireSdlc(sdlcRoot);
345
- const configRaw = fs.readFileSync(path.join(sdlcRoot, 'config.yaml'), 'utf8');
346
- const config = parseConfig(configRaw);
347
- // An explicit `tools: []` (from `init --tool none`) is a decision, not a gap:
348
- // only a config that never declared the key at all falls back to claude.
349
- const tools = args.toolProvided
350
- ? resolveToolList(args.tool ?? [])
351
- : (/^tools:/m.test(configRaw) ? config.tools : ['claude']);
352
-
353
- // Persist an explicit --tool override so declared and installed state never
354
- // diverge (otherwise pruning tool-specific files leaves config.yaml stale).
355
- if (args.toolProvided) {
356
- const configPath = path.join(sdlcRoot, 'config.yaml');
357
- const raw = fs.readFileSync(configPath, 'utf8');
358
- fs.writeFileSync(configPath, raw.replace(/^tools:.*$/m, `tools: [${tools.join(', ')}]`));
359
- }
360
-
361
- const oldManifest = readManifestFile(projectRoot);
362
- const ctx = { mode: 'update', manifest: new Map(), oldManifest, warnings: [] };
363
- scaffoldSdlc(projectRoot, tools, ctx);
364
- installToolAdapters(projectRoot, tools, ctx);
365
- // `update` is how an existing project acquires hooks that journal under .state/, and
366
- // that whole design rests on the entry being there. Re-assert it: a project whose
367
- // .gitignore never had it, or lost it, would otherwise start reporting telemetry as
368
- // untracked noise the same symptom in a subtler form.
369
- ensureGitignore(projectRoot);
370
-
371
- // Prune: old-manifest entries no longer in the payload, guarded six ways.
372
- const { stale, rejected, overCap } = computeStale(oldManifest, new Set(ctx.manifest.keys()));
373
- for (const r of rejected) ctx.warnings.push(`prune rejected: ${r.path} (${r.reason})`);
374
- let pruned = 0;
375
- if (overCap && !args.force) {
376
- ctx.warnings.push(`prune skipped: ${stale.length} stale files exceed the blast cap (${PRUNE_BLAST_CAP}) — re-run with --force after reviewing`);
377
- } else {
378
- const realRoot = fs.realpathSync.native(projectRoot);
379
- const nominalRoot = path.resolve(projectRoot);
380
- for (const { path: relPath, hash } of stale) {
381
- const abs = path.join(projectRoot, relPath);
382
- if (!fs.existsSync(abs)) continue;
383
- if (hasSymlinkSegment(nominalRoot, abs)) {
384
- ctx.warnings.push(`prune rejected: ${relPath} (symlink in path)`);
385
- continue;
386
- }
387
- const diskHash = sha256(normalizeEol(fs.readFileSync(abs, 'utf8')));
388
- if (diskHash !== hash) { ctx.warnings.push(`prune kept (modified): ${relPath}`); continue; }
389
- const realAbs = fs.realpathSync.native(abs);
390
- if (!containedIn(realRoot, realAbs)) { ctx.warnings.push(`prune rejected: ${relPath} (escapes project)`); continue; }
391
- fs.rmSync(abs);
392
- pruned++;
393
- let dir = path.dirname(abs);
394
- while (containedIn(realRoot, dir)) {
395
- try { fs.rmdirSync(dir); } catch { break; }
396
- dir = path.dirname(dir);
397
- }
398
- }
399
- }
400
-
401
- writeManifestFile(projectRoot, ctx.manifest);
402
- for (const w of ctx.warnings) console.warn(` ${w}`);
403
- console.log(`updated for: ${tools.join(', ')} · payload files: ${ctx.manifest.size} · pruned: ${pruned}`);
404
- return { pruned, warnings: ctx.warnings };
405
- }
406
-
407
- // ---------- status ----------
408
-
409
- export function readChanges(sdlcRoot) {
410
- return listChangeDirs(sdlcRoot).map((dir) => {
411
- const changePath = path.join(dir, 'change.md');
412
- const id = path.basename(dir);
413
- if (!fs.existsSync(changePath)) return { id, tier: '?', status: '?', title: '(missing change.md)' };
414
- const raw = fs.readFileSync(changePath, 'utf8');
415
- const { data, body } = parseFrontmatter(raw);
416
- const title = body.match(/^# Change:\s*(.+)$/m)?.[1] ?? '';
417
- return { id, tier: data.tier ?? '?', status: data.status ?? '?', title };
418
- });
419
- }
420
-
421
- export function cmdStatus(projectRoot, { json = false } = {}) {
422
- const sdlcRoot = path.join(projectRoot, 'sdlc');
423
- requireSdlc(sdlcRoot);
424
- const changes = readChanges(sdlcRoot);
425
- const archiveDir = path.join(sdlcRoot, 'changes', 'archive');
426
- const archived = fs.existsSync(archiveDir)
427
- ? fs.readdirSync(archiveDir, { withFileTypes: true }).filter((d) => d.isDirectory()).length
428
- : 0;
429
- if (json) {
430
- console.log(JSON.stringify({ changes, archived }, null, 2));
431
- } else if (!changes.length) {
432
- console.log(`No active changes (${archived} archived). Start one with /sdlc:new or /sdlc:auto.`);
433
- } else {
434
- for (const c of changes) console.log(`${c.id} [${c.tier}/${c.status}] ${c.title}`);
435
- console.log(`${changes.length} active · ${archived} archived`);
436
- }
437
- return { changes, archived };
438
- }
439
-
440
- // ---------- observe ----------
441
-
442
- export function cmdObserve(projectRoot, { json = false } = {}) {
443
- const sdlcRoot = path.join(projectRoot, 'sdlc');
444
- requireSdlc(sdlcRoot);
445
- const report = buildReport(sdlcRoot);
446
- console.log(json ? JSON.stringify(report, null, 2) : renderReport(report));
447
- return report;
448
- }
449
-
450
- // ---------- archive (= mechanical part of ship) ----------
451
-
452
- // The CLI's own events go to the same out-of-tree stream the hooks append to, so the
453
- // ship event does not become the one write that dirties the tree.
454
- export function appendJournal(sdlcRoot, changeId, event) {
455
- const target = liveJournalPath(sdlcRoot, changeId);
456
- if (!target) return;
457
- appendEvent(target, { ts: new Date().toISOString(), ...event });
458
- }
459
-
460
- export function cmdArchive(projectRoot, changeId, { strict = true } = {}) {
461
- const sdlcRoot = path.join(projectRoot, 'sdlc');
462
- requireSdlc(sdlcRoot);
463
- if (!changeId) throw new Error('usage: warnyin-sdlc archive <change-id>');
464
- // Refuse before anything is read or written. An id like `a/../b` resolves to a real
465
- // folder, so without this it would ship merging specs and moving the folder — and
466
- // only then fail on the journal paths that do gate the id, reporting a completed
467
- // ship as an error.
468
- if (!isSafeChangeId(changeId)) {
469
- throw new Error(`"${changeId}" is not a valid change id — one path segment, no separators`);
470
- }
471
- const changeDir = path.join(sdlcRoot, 'changes', changeId);
472
- if (!fs.existsSync(changeDir)) throw new Error(`change "${changeId}" not found`);
473
-
474
- // Atomicity: the archive destination must be checked BEFORE any write —
475
- // otherwise a same-day id collision would mutate specs and stamp the change
476
- // while reporting failure.
477
- const date = new Date().toISOString().slice(0, 10);
478
- const destDir = path.join(sdlcRoot, 'changes', 'archive', `${date}-${changeId}`);
479
- // `init` scaffolds changes/archive/, but git does not track empty directories:
480
- // it is absent for anyone who cloned before the first change shipped. Prepare
481
- // it here, with the other destination checks, so a bad archive path fails
482
- // while the specs are still untouched instead of ENOENT-ing at the rename.
483
- fs.mkdirSync(path.dirname(destDir), { recursive: true });
484
- if (fs.existsSync(destDir)) {
485
- throw new Error(`archive target already exists: ${toPosix(path.relative(projectRoot, destDir))} — nothing was merged`);
486
- }
487
-
488
- const issues = validateAll(sdlcRoot, { strict, changeId });
489
- const errors = issues.filter((i) => i.level === 'error');
490
- if (errors.length) {
491
- console.error(formatIssues(errors));
492
- throw new Error(`validate --strict failed with ${errors.length} error(s)not archiving`);
493
- }
494
-
495
- const changeText = fs.readFileSync(path.join(changeDir, 'change.md'), 'utf8');
496
- const { deltas, errors: parseErrors } = parseDelta(changeText);
497
- if (parseErrors.length) throw new Error(`delta parse errors: ${parseErrors.join('; ')}`);
498
-
499
- // Phase 1: compute every merge before writing anything (all-or-nothing).
500
- const merged = [];
501
- const driftWarnings = [];
502
- for (const d of deltas) {
503
- const specPath = path.join(sdlcRoot, 'specs', d.capability, 'spec.md');
504
- const specText = fs.existsSync(specPath) ? fs.readFileSync(specPath, 'utf8') : null;
505
- const result = mergeDelta(specText, d.ops, d.capability);
506
- if (!result.ok) throw new Error(`spec merge failed for "${d.capability}": ${result.errors.join('; ')}`);
507
- driftWarnings.push(...result.warnings);
508
- merged.push({ specPath, content: result.content, capability: d.capability });
509
- }
510
-
511
- // A MODIFIED body replaces the requirement wholesale, so it can carry away a
512
- // scenario the spec still promised. That is allowed — but it is said out loud
513
- // here, while the change folder is still readable, not discovered in a diff
514
- // after the folder moved under changes/archive/.
515
- for (const w of driftWarnings) console.error(`⚠ ${w}`);
516
-
517
- // Phase 2: write specs, promote evals, stamp status, move to archive.
518
- for (const m of merged) writeFileNormalized(m.specPath, m.content);
519
-
520
- const evalsSrc = path.join(changeDir, 'contract', 'evals.md');
521
- if (fs.existsSync(evalsSrc)) {
522
- for (const m of merged) {
523
- const rubricDest = path.join(sdlcRoot, 'evals', m.capability, 'rubric.md');
524
- if (!fs.existsSync(rubricDest)) {
525
- writeFileNormalized(rubricDest, fs.readFileSync(evalsSrc, 'utf8'));
526
- }
527
- }
528
- }
529
-
530
- const stamped = changeText.replace(/^status:\s*.*$/m, 'status: shipped');
531
- writeFileNormalized(path.join(changeDir, 'change.md'), stamped);
532
- appendJournal(sdlcRoot, changeId, { event: 'ship', change: changeId, specs: merged.map((m) => m.capability) });
533
-
534
- // Telemetry stays out of the tree for the whole life of the change and becomes
535
- // tracked exactly once here, in the ship commit so no session can dirty it and no
536
- // appended tail can conflict.
537
- //
538
- // Read before the move, write after it. For an open change `sealedJournalPath` and
539
- // `legacyJournalPath` are the SAME file, so sealing first would leave the merged
540
- // union sitting at the legacy path if the rename then failed (EPERM/EBUSY on Windows
541
- // is the realistic way); the retry would merge that union with the still-present live
542
- // stream and double every event. Reading first and writing into `destDir` means a
543
- // failed rename has consumed nothing.
544
- const sealed = readChangeJournal(sdlcRoot, changeId);
545
-
546
- fs.renameSync(changeDir, destDir);
547
-
548
- // Past the point of no return: specs are merged and the folder has moved. Nothing
549
- // below may throw, or a completed ship reports as a failure and the human retries
550
- // into "change not found".
551
- try {
552
- // Empty only if the id was never journalled at all — the ship event above normally
553
- // guarantees at least one entry. An empty file would be worse than none.
554
- if (sealed.length) {
555
- writeFileNormalized(sealedJournalPath(destDir), serializeJournal(sealed));
556
- }
557
- fs.rmSync(liveJournalPath(sdlcRoot, changeId), { force: true });
558
- } catch (err) {
559
- console.error(`⚠ shipped, but the journal was not fully sealed: ${err.message}`);
560
- }
561
-
562
- console.log(`shipped: ${changeId}`);
563
- for (const m of merged) console.log(` spec merged: specs/${m.capability}/spec.md`);
564
- if (driftWarnings.length) {
565
- console.log(` ${driftWarnings.length} scenario warning(s) above re-read the spec diff before pushing`);
566
- }
567
- console.log(` archived: changes/archive/${date}-${changeId}/`);
568
- return { archived: `${date}-${changeId}`, specs: merged.map((m) => m.capability), warnings: driftWarnings };
569
- }
570
-
571
- // ---------- shared ----------
572
-
573
- function requireSdlc(sdlcRoot) {
574
- if (!fs.existsSync(sdlcRoot)) {
575
- throw new Error('No sdlc/ directory hererun `npx @warnyin/sdlc init` first.');
576
- }
577
- }
578
-
579
- function runValidate(projectRoot, args) {
580
- const validator = path.join(PKG_ROOT, 'scripts', 'validate.mjs');
581
- const spawnArgs = [validator, ...(args._.slice(1)), ...(args.strict ? ['--strict'] : []), '--root', projectRoot];
582
- const res = spawnSync(process.execPath, spawnArgs, { stdio: 'inherit' });
583
- process.exitCode = res.status ?? 0;
584
- }
585
-
586
- // Read from our own package.json: an npx install leaves nothing readable in the
587
- // target project, and a report whose version is `unknown` cannot be triaged.
588
- function pkgVersion() {
589
- return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
590
- }
591
-
592
- const HELP = `@warnyin/sdlcspec-driven AI-SDLC framework
593
-
594
- usage: warnyin-sdlc <command> [options]
595
-
596
- init [--tool all|none|a,b] scaffold sdlc/ + adapters + hooks (interactive picker when omitted)
597
- update [--tool ...] [--force] refresh payload-owned files, prune stale ones (guarded)
598
- validate [id] [--strict] structural validation (caps, delta grammar, gates)
599
- status [--json] list active changes and their stage
600
- observe [--json] tokens/cost per change, residency, steering hits, drift flags
601
- archive <id> merge delta specs into living specs and archive the change
602
- version | --version | -v print the installed framework version
603
- help this text
604
- `;
605
-
606
- export async function main(argv = process.argv.slice(2), projectRoot = process.cwd()) {
607
- const args = parseArgs(argv);
608
- const cmd = args._[0];
609
- try {
610
- // before the help branch: `--version` carries no command, and `!cmd` would
611
- // otherwise print help instead of the version.
612
- if (args.version || cmd === 'version') { console.log(pkgVersion()); return; }
613
- if (args.help || !cmd || cmd === 'help') { console.log(HELP); return; }
614
- if (cmd === 'init') await cmdInit(projectRoot, args);
615
- else if (cmd === 'update') cmdUpdate(projectRoot, args);
616
- else if (cmd === 'validate') runValidate(projectRoot, args);
617
- else if (cmd === 'status') cmdStatus(projectRoot, { json: args.json });
618
- else if (cmd === 'observe') cmdObserve(projectRoot, { json: args.json });
619
- else if (cmd === 'archive') cmdArchive(projectRoot, args._[1]);
620
- else { console.error(`unknown command: ${cmd}`); console.log(HELP); process.exitCode = 2; }
621
- } catch (err) {
622
- console.error(String(err.message ?? err));
623
- process.exitCode = 1;
624
- }
625
- }
626
-
627
- // npx invokes the bin via a node_modules/.bin symlink, so argv[1] must be
628
- // realpath-resolved before comparing with import.meta.url (which the ESM
629
- // loader already resolves) otherwise main() silently never runs.
630
- function isEntrypoint() {
631
- if (!process.argv[1]) return false;
632
- try {
633
- return fs.realpathSync.native(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url);
634
- } catch {
635
- return false;
636
- }
637
- }
638
-
639
- if (isEntrypoint()) {
640
- main();
641
- }
1
+ #!/usr/bin/env node
2
+ // @warnyin/sdlc CLI — OpenSpec-style installer + lifecycle mechanics.
3
+ // Zero-dependency, Node >= 20, cross-platform. Exported functions are pure
4
+ // where possible so tests can exercise them; `main()` is guarded.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import crypto from 'node:crypto';
9
+ import process from 'node:process';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { parseFrontmatter } from '../lib/frontmatter.mjs';
13
+ import { parseDelta, mergeDelta } from '../lib/delta.mjs';
14
+ import { parseConfig } from '../lib/config.mjs';
15
+ import { mergeHookSettings } from '../lib/settings-merge.mjs';
16
+ import { buildReport, renderReport } from '../lib/observe.mjs';
17
+ import { parseManifest, renderManifest, computeStale, containedIn, hasSymlinkSegment, PRUNE_BLAST_CAP } from '../lib/manifest.mjs';
18
+ import { validateAll, formatIssues, listChangeDirs } from '../lib/validate.mjs';
19
+ import {
20
+ readChangeJournal, liveJournalPath, sealedJournalPath, serializeJournal, appendEvent,
21
+ isSafeChangeId,
22
+ } from '../lib/journal.mjs';
23
+ import { resolveActive, clearPointersFor } from '../lib/active.mjs';
24
+ import { detectTools, toolName } from './detect.mjs';
25
+ import { colorEnabled, createStyle, symbolsFor, summarizeInstall, startHints } from './ui.mjs';
26
+ import { multiSelect } from './multiselect.mjs';
27
+
28
+ const PKG_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const PAYLOAD = path.join(PKG_ROOT, 'payload');
30
+ const MARKER = '<!-- sdlc:start -->';
31
+
32
+ export const TOOLS = Object.freeze([
33
+ 'claude', 'cursor', 'windsurf', 'copilot', 'cline', 'gemini', 'agents-md',
34
+ ]);
35
+
36
+ const TEXT_EXT = new Set(['.md', '.mdc', '.mjs', '.json', '.yaml', '.yml', '.txt']);
37
+
38
+ // ---------- small pure helpers ----------
39
+
40
+ export function normalizeEol(content) {
41
+ return content.replace(/\r\n/g, '\n');
42
+ }
43
+
44
+ export function toPosix(p) {
45
+ return p.split(path.sep).join('/');
46
+ }
47
+
48
+ export function sha256(content) {
49
+ return crypto.createHash('sha256').update(content).digest('hex');
50
+ }
51
+
52
+ export function parseArgs(argv) {
53
+ const args = { _: [], tool: null, toolProvided: false, strict: false, force: false, json: false, help: false, version: false };
54
+ for (let i = 0; i < argv.length; i++) {
55
+ const a = argv[i];
56
+ if (a === '--tool' || a === '--tools') {
57
+ args.toolProvided = true;
58
+ args.tool = (argv[++i] ?? '').split(',').map((s) => s.trim()).filter(Boolean);
59
+ }
60
+ else if (a === '--strict') args.strict = true;
61
+ else if (a === '--force') args.force = true;
62
+ else if (a === '--json') args.json = true;
63
+ else if (a === '--help' || a === '-h') args.help = true;
64
+ else if (a === '--version' || a === '-v') args.version = true;
65
+ else if (a.startsWith('--')) console.warn(`unknown flag ${a} (ignored)`);
66
+ else args._.push(a);
67
+ }
68
+ return args;
69
+ }
70
+
71
+ // ---------- payload-owned file installation (ownership semantics) ----------
72
+ // install mode: missing write · byte-equal claim · different user's, keep.
73
+ // update mode: additionally, disk == old manifest hash (ours, unmodified) → refresh.
74
+
75
+ function writeFileNormalized(dest, content) {
76
+ const ext = path.extname(dest);
77
+ const out = TEXT_EXT.has(ext) ? normalizeEol(content) : content;
78
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
79
+ fs.writeFileSync(dest, out);
80
+ return out;
81
+ }
82
+
83
+ function tally(ctx, outcome) {
84
+ if (ctx.stats) ctx.stats[outcome] = (ctx.stats[outcome] ?? 0) + 1;
85
+ return outcome;
86
+ }
87
+
88
+ export function installFile(projectRoot, destRel, content, ctx) {
89
+ const relPosix = toPosix(destRel);
90
+ const dest = path.join(projectRoot, destRel);
91
+ const next = normalizeEol(content);
92
+ const nextHash = sha256(next);
93
+
94
+ if (!fs.existsSync(dest)) {
95
+ writeFileNormalized(dest, next);
96
+ ctx.manifest.set(relPosix, nextHash);
97
+ return tally(ctx, 'written');
98
+ }
99
+ const current = normalizeEol(fs.readFileSync(dest, 'utf8'));
100
+ const currentHash = sha256(current);
101
+ if (currentHash === nextHash) {
102
+ ctx.manifest.set(relPosix, nextHash);
103
+ return tally(ctx, 'current');
104
+ }
105
+ if (ctx.mode === 'update' && ctx.oldManifest?.get(relPosix) === currentHash) {
106
+ writeFileNormalized(dest, next);
107
+ ctx.manifest.set(relPosix, nextHash);
108
+ return tally(ctx, 'updated');
109
+ }
110
+ // Keeping the content must not forget the ownership. Dropping the entry made
111
+ // the next run see a file we had never installed, which permanently disarmed
112
+ // update's refresh branch (disk hash === old manifest hash) the file froze
113
+ // at its old payload version and every later run relabelled it user-modified.
114
+ // The recorded hash stays the one we last wrote, so prune's "disk must match
115
+ // the manifest" guard still refuses to touch a file the user really did edit.
116
+ const owned = ctx.oldManifest?.get(relPosix);
117
+ if (owned) ctx.manifest.set(relPosix, owned);
118
+ // Matching the recorded hash proves nobody touched it `install` simply does
119
+ // not refresh. Calling that "user-modified" sent people hunting for an edit
120
+ // they never made.
121
+ ctx.warnings.push(owned === currentHash
122
+ ? `kept (ours, older version — run \`npx @warnyin/sdlc update\` to refresh): ${relPosix}`
123
+ : `kept (user-modified): ${relPosix}`);
124
+ return tally(ctx, 'kept');
125
+ }
126
+
127
+ function copyTree(srcDir, destDirRel, projectRoot, ctx) {
128
+ if (!fs.existsSync(srcDir)) return;
129
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
130
+ const src = path.join(srcDir, entry.name);
131
+ const destRel = path.join(destDirRel, entry.name);
132
+ if (entry.isDirectory()) copyTree(src, destRel, projectRoot, ctx);
133
+ else installFile(projectRoot, destRel, fs.readFileSync(src, 'utf8'), ctx);
134
+ }
135
+ }
136
+
137
+ function payloadText(rel) {
138
+ return normalizeEol(fs.readFileSync(path.join(PAYLOAD, rel), 'utf8'));
139
+ }
140
+
141
+ // ---------- adapters ----------
142
+
143
+ function renderAdapter(templateRel) {
144
+ return payloadText(templateRel).replace('{{RULES_CARD}}', payloadText('playbook/rules-card.md').trim());
145
+ }
146
+
147
+ // Marker adapters live inside user-owned files: append once, never rewrite.
148
+ function appendWithMarker(projectRoot, destRel, content) {
149
+ const dest = path.join(projectRoot, destRel);
150
+ if (fs.existsSync(dest)) {
151
+ const current = fs.readFileSync(dest, 'utf8');
152
+ if (current.includes(MARKER)) return 'present';
153
+ const sep = current.endsWith('\n') ? '\n' : '\n\n';
154
+ fs.writeFileSync(dest, current + sep + normalizeEol(content));
155
+ return 'appended';
156
+ }
157
+ writeFileNormalized(dest, content);
158
+ return 'written';
159
+ }
160
+
161
+ export function installClaudeHooks(projectRoot) {
162
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
163
+ let current = {};
164
+ if (fs.existsSync(settingsPath)) {
165
+ try {
166
+ current = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
167
+ } catch {
168
+ throw new Error('.claude/settings.json is not valid JSON — fix it, then re-run init');
169
+ }
170
+ }
171
+ const merged = mergeHookSettings(current);
172
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
173
+ fs.writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n');
174
+ }
175
+
176
+ function installToolAdapters(projectRoot, tools, ctx) {
177
+ if (tools.includes('claude')) {
178
+ copyTree(path.join(PAYLOAD, 'adapters/claude/commands'), path.join('.claude', 'commands'), projectRoot, ctx);
179
+ copyTree(path.join(PAYLOAD, 'adapters/claude/skills'), path.join('.claude', 'skills'), projectRoot, ctx);
180
+ copyTree(path.join(PAYLOAD, 'adapters/claude/agents'), path.join('.claude', 'agents'), projectRoot, ctx);
181
+ installClaudeHooks(projectRoot);
182
+ }
183
+ if (tools.includes('cursor')) {
184
+ installFile(projectRoot, path.join('.cursor', 'rules', 'sdlc.mdc'), renderAdapter('adapters/cursor.mdc'), ctx);
185
+ }
186
+ if (tools.includes('windsurf')) {
187
+ installFile(projectRoot, path.join('.windsurf', 'rules', 'sdlc.md'), renderAdapter('adapters/windsurf.md'), ctx);
188
+ }
189
+ if (tools.includes('copilot')) {
190
+ appendWithMarker(projectRoot, path.join('.github', 'copilot-instructions.md'), renderAdapter('adapters/copilot.md'));
191
+ }
192
+ if (tools.includes('cline')) {
193
+ appendWithMarker(projectRoot, '.clinerules', renderAdapter('adapters/cline.md'));
194
+ }
195
+ if (tools.includes('gemini')) {
196
+ appendWithMarker(projectRoot, 'GEMINI.md', renderAdapter('adapters/gemini.md'));
197
+ }
198
+ if (tools.includes('agents-md')) {
199
+ appendWithMarker(projectRoot, 'AGENTS.md', renderAdapter('adapters/agents-md.md'));
200
+ }
201
+ }
202
+
203
+ // ---------- scaffold ----------
204
+
205
+ function scaffoldSdlc(projectRoot, tools, ctx) {
206
+ const sdlcRoot = path.join(projectRoot, 'sdlc');
207
+ for (const dir of ['context/steering', 'specs', 'changes/archive', 'evals', '.state']) {
208
+ fs.mkdirSync(path.join(sdlcRoot, dir), { recursive: true });
209
+ }
210
+
211
+ // git does not track empty directories, so changes/archive/ vanishes for
212
+ // anyone who clones before the first change ships. Not manifested: an empty
213
+ // marker is nothing for prune to reclaim or for the installer to warn about.
214
+ const gitkeep = path.join(sdlcRoot, 'changes', 'archive', '.gitkeep');
215
+ if (!fs.existsSync(gitkeep)) fs.writeFileSync(gitkeep, '');
216
+
217
+ // Seeds are user-owned from birth: created once, never manifested/overwritten.
218
+ const seed = (rel, templateName, transform = (s) => s) => {
219
+ const dest = path.join(sdlcRoot, rel);
220
+ if (fs.existsSync(dest)) return;
221
+ writeFileNormalized(dest, transform(payloadText(`templates/${templateName}`)));
222
+ };
223
+ seed('config.yaml', 'config.yaml', (s) => s.replace('tools: []', `tools: [${tools.join(', ')}]`));
224
+ seed('context/constitution.md', 'constitution.md');
225
+ seed('harness.md', 'harness.md');
226
+
227
+ // Payload-owned trees (manifested, refreshed by `update`).
228
+ copyTree(path.join(PAYLOAD, 'playbook'), path.join('sdlc', '.playbook'), projectRoot, ctx);
229
+ copyTree(path.join(PAYLOAD, 'templates'), path.join('sdlc', '.playbook', 'templates'), projectRoot, ctx);
230
+ copyTree(path.join(PAYLOAD, 'hooks'), path.join('sdlc', '.hooks'), projectRoot, ctx);
231
+ copyTree(path.join(PKG_ROOT, 'lib'), path.join('sdlc', '.hooks', 'lib'), projectRoot, ctx);
232
+ }
233
+
234
+ export function writeManifestFile(projectRoot, manifest) {
235
+ const statePath = path.join(projectRoot, 'sdlc', '.state');
236
+ fs.mkdirSync(statePath, { recursive: true });
237
+ fs.writeFileSync(path.join(statePath, 'manifest'), renderManifest(manifest));
238
+ }
239
+
240
+ export function readManifestFile(projectRoot) {
241
+ const p = path.join(projectRoot, 'sdlc', '.state', 'manifest');
242
+ return fs.existsSync(p) ? parseManifest(fs.readFileSync(p, 'utf8')) : new Map();
243
+ }
244
+
245
+ export function ensureGitignore(projectRoot) {
246
+ const giPath = path.join(projectRoot, '.gitignore');
247
+ const entry = 'sdlc/.state/';
248
+ let current = fs.existsSync(giPath) ? fs.readFileSync(giPath, 'utf8') : '';
249
+ if (current.split(/\r?\n/).some((l) => l.trim() === entry)) return;
250
+ if (current && !current.endsWith('\n')) current += '\n';
251
+ fs.writeFileSync(giPath, current + entry + '\n');
252
+ }
253
+
254
+ // ---------- init ----------
255
+
256
+ // `all` / `none` are reserved words, never combinable with a list — mixing
257
+ // them would leave the caller guessing which one won.
258
+ export function resolveToolList(picked) {
259
+ const reserved = picked.filter((t) => t === 'all' || t === 'none');
260
+ if (reserved.length && picked.length > 1) {
261
+ throw new Error(`"${reserved[0]}" cannot be combined with other tools`);
262
+ }
263
+ if (picked[0] === 'all') return [...TOOLS];
264
+ if (picked[0] === 'none') return [];
265
+ if (!picked.length) throw new Error(`--tool requires a value: all, none, or any of ${TOOLS.join(', ')}`);
266
+ const bad = picked.filter((t) => !TOOLS.includes(t));
267
+ if (bad.length) throw new Error(`unknown tool(s): ${bad.join(', ')} — valid: ${TOOLS.join(', ')}, all, none`);
268
+ return [...new Set(picked)];
269
+ }
270
+
271
+ export async function resolveTools(args, {
272
+ interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
273
+ projectRoot = process.cwd(),
274
+ style = createStyle(false),
275
+ } = {}) {
276
+ if (args.toolProvided) return resolveToolList(args.tool ?? []);
277
+ if (!interactive) return ['claude'];
278
+
279
+ const detected = detectTools(projectRoot);
280
+ if (detected.length) {
281
+ console.log(style.dim(`Detected in this project: ${detected.map(toolName).join(', ')} (pre-selected)`));
282
+ }
283
+ const choices = TOOLS.map((tool) => ({
284
+ value: tool,
285
+ name: toolName(tool),
286
+ note: detected.includes(tool) ? 'detected' : '',
287
+ // First-time setup with nothing detected still needs a sane default.
288
+ preSelected: detected.length ? detected.includes(tool) : tool === 'claude',
289
+ }));
290
+ const picked = await multiSelect({ choices, style, symbols: symbolsFor() });
291
+ if (picked === null) throw new Error('cancelled — nothing was installed');
292
+ return picked;
293
+ }
294
+
295
+ function printInitSummary(tools, ctx, style, symbols, { configExisted }) {
296
+ const s = summarizeInstall(ctx.manifest.keys(), tools);
297
+ const stats = ctx.stats;
298
+ const line = (text) => console.log(` ${text}`);
299
+
300
+ console.log('');
301
+ console.log(` ${style.green(symbols.tick)} ${style.bold('SDLC Setup Complete')}`);
302
+ console.log('');
303
+ line(`Tools: ${tools.length ? tools.map(toolName).join(', ') : style.dim('none (framework only)')}`);
304
+ if (s.commands || s.skills || s.agents) {
305
+ line(`${s.commands} commands, ${s.skills} skills and ${s.agents} agents in .claude/`);
306
+ }
307
+ for (const a of s.adapters.filter((a) => a.tool !== 'claude')) {
308
+ line(`Rules for ${toolName(a.tool)}: ${a.path}`);
309
+ }
310
+ line(`${s.hooks} hooks in sdlc/.hooks/`);
311
+ line(`Playbook: sdlc/.playbook/ (${s.playbook} stages + ${s.templates} templates)`);
312
+ line(`Config: sdlc/config.yaml${configExisted ? ' (kept)' : ''}`);
313
+ line(style.dim(`Files: ${stats.written} written · ${stats.current} unchanged · ${stats.updated} refreshed · ${stats.kept} kept (yours)`));
314
+ console.log('');
315
+ console.log(` ${style.bold('Getting started:')}`);
316
+ startHints(tools).forEach((hint, i) => line(` ${i + 1}. ${hint}`));
317
+ console.log('');
318
+ }
319
+
320
+ export async function cmdInit(projectRoot, args) {
321
+ const style = createStyle(colorEnabled());
322
+ const symbols = symbolsFor();
323
+ const tools = await resolveTools(args, { projectRoot, style });
324
+ const configExisted = fs.existsSync(path.join(projectRoot, 'sdlc', 'config.yaml'));
325
+ const ctx = {
326
+ mode: 'install',
327
+ manifest: new Map(),
328
+ oldManifest: readManifestFile(projectRoot),
329
+ warnings: [],
330
+ stats: { written: 0, current: 0, updated: 0, kept: 0 },
331
+ };
332
+ scaffoldSdlc(projectRoot, tools, ctx);
333
+ installToolAdapters(projectRoot, tools, ctx);
334
+ writeManifestFile(projectRoot, ctx.manifest);
335
+ ensureGitignore(projectRoot);
336
+ for (const w of ctx.warnings) console.warn(` ${style.yellow(symbols.warn)} ${w}`);
337
+ printInitSummary(tools, ctx, style, symbols, { configExisted });
338
+ return { tools };
339
+ }
340
+
341
+ // ---------- update + prune ----------
342
+
343
+ export function cmdUpdate(projectRoot, args) {
344
+ const sdlcRoot = path.join(projectRoot, 'sdlc');
345
+ requireSdlc(sdlcRoot);
346
+ const configRaw = fs.readFileSync(path.join(sdlcRoot, 'config.yaml'), 'utf8');
347
+ const config = parseConfig(configRaw);
348
+ // An explicit `tools: []` (from `init --tool none`) is a decision, not a gap:
349
+ // only a config that never declared the key at all falls back to claude.
350
+ const tools = args.toolProvided
351
+ ? resolveToolList(args.tool ?? [])
352
+ : (/^tools:/m.test(configRaw) ? config.tools : ['claude']);
353
+
354
+ // Persist an explicit --tool override so declared and installed state never
355
+ // diverge (otherwise pruning tool-specific files leaves config.yaml stale).
356
+ if (args.toolProvided) {
357
+ const configPath = path.join(sdlcRoot, 'config.yaml');
358
+ const raw = fs.readFileSync(configPath, 'utf8');
359
+ fs.writeFileSync(configPath, raw.replace(/^tools:.*$/m, `tools: [${tools.join(', ')}]`));
360
+ }
361
+
362
+ const oldManifest = readManifestFile(projectRoot);
363
+ const ctx = { mode: 'update', manifest: new Map(), oldManifest, warnings: [] };
364
+ scaffoldSdlc(projectRoot, tools, ctx);
365
+ installToolAdapters(projectRoot, tools, ctx);
366
+ // `update` is how an existing project acquires hooks that journal under .state/, and
367
+ // that whole design rests on the entry being there. Re-assert it: a project whose
368
+ // .gitignore never had it, or lost it, would otherwise start reporting telemetry as
369
+ // untracked noise — the same symptom in a subtler form.
370
+ ensureGitignore(projectRoot);
371
+
372
+ // Prune: old-manifest entries no longer in the payload, guarded six ways.
373
+ const { stale, rejected, overCap } = computeStale(oldManifest, new Set(ctx.manifest.keys()));
374
+ for (const r of rejected) ctx.warnings.push(`prune rejected: ${r.path} (${r.reason})`);
375
+ let pruned = 0;
376
+ if (overCap && !args.force) {
377
+ ctx.warnings.push(`prune skipped: ${stale.length} stale files exceed the blast cap (${PRUNE_BLAST_CAP}) — re-run with --force after reviewing`);
378
+ } else {
379
+ const realRoot = fs.realpathSync.native(projectRoot);
380
+ const nominalRoot = path.resolve(projectRoot);
381
+ for (const { path: relPath, hash } of stale) {
382
+ const abs = path.join(projectRoot, relPath);
383
+ if (!fs.existsSync(abs)) continue;
384
+ if (hasSymlinkSegment(nominalRoot, abs)) {
385
+ ctx.warnings.push(`prune rejected: ${relPath} (symlink in path)`);
386
+ continue;
387
+ }
388
+ const diskHash = sha256(normalizeEol(fs.readFileSync(abs, 'utf8')));
389
+ if (diskHash !== hash) { ctx.warnings.push(`prune kept (modified): ${relPath}`); continue; }
390
+ const realAbs = fs.realpathSync.native(abs);
391
+ if (!containedIn(realRoot, realAbs)) { ctx.warnings.push(`prune rejected: ${relPath} (escapes project)`); continue; }
392
+ fs.rmSync(abs);
393
+ pruned++;
394
+ let dir = path.dirname(abs);
395
+ while (containedIn(realRoot, dir)) {
396
+ try { fs.rmdirSync(dir); } catch { break; }
397
+ dir = path.dirname(dir);
398
+ }
399
+ }
400
+ }
401
+
402
+ writeManifestFile(projectRoot, ctx.manifest);
403
+ for (const w of ctx.warnings) console.warn(` ${w}`);
404
+ console.log(`updated for: ${tools.join(', ')} · payload files: ${ctx.manifest.size} · pruned: ${pruned}`);
405
+ return { pruned, warnings: ctx.warnings };
406
+ }
407
+
408
+ // ---------- status ----------
409
+
410
+ export function readChanges(sdlcRoot) {
411
+ return listChangeDirs(sdlcRoot).map((dir) => {
412
+ const changePath = path.join(dir, 'change.md');
413
+ const id = path.basename(dir);
414
+ if (!fs.existsSync(changePath)) return { id, tier: '?', status: '?', title: '(missing change.md)' };
415
+ const raw = fs.readFileSync(changePath, 'utf8');
416
+ const { data, body } = parseFrontmatter(raw);
417
+ const title = body.match(/^# Change:\s*(.+)$/m)?.[1] ?? '';
418
+ return { id, tier: data.tier ?? '?', status: data.status ?? '?', title };
419
+ });
420
+ }
421
+
422
+ export function cmdStatus(projectRoot, { json = false } = {}) {
423
+ const sdlcRoot = path.join(projectRoot, 'sdlc');
424
+ requireSdlc(sdlcRoot);
425
+ const changes = readChanges(sdlcRoot);
426
+ const archiveDir = path.join(sdlcRoot, 'changes', 'archive');
427
+ const archived = fs.existsSync(archiveDir)
428
+ ? fs.readdirSync(archiveDir, { withFileTypes: true }).filter((d) => d.isDirectory()).length
429
+ : 0;
430
+
431
+ const resolved = resolveActive(sdlcRoot, { sessionId: process.env.CLAUDE_CODE_SESSION_ID });
432
+ const current = resolved && (resolved.source === 'session' || resolved.source === 'project')
433
+ && changes.some((c) => c.id === resolved.change)
434
+ ? { id: resolved.change, source: resolved.source }
435
+ : null;
436
+
437
+ const orderedChanges = current
438
+ ? [changes.find((c) => c.id === current.id), ...changes.filter((c) => c.id !== current.id)]
439
+ : changes;
440
+
441
+ // JSON is a machine contract: `current` names the id, so the list keeps its order.
442
+ if (json) {
443
+ console.log(JSON.stringify({ changes, archived, current }, null, 2));
444
+ } else if (!changes.length) {
445
+ console.log(`No active changes (${archived} archived). Start one with /sdlc:new or /sdlc:auto.`);
446
+ } else {
447
+ for (const c of orderedChanges) {
448
+ let marker = '';
449
+ if (current && c.id === current.id) {
450
+ marker = current.source === 'session' ? ' ← this session' : ' ← last set for project';
451
+ } else if (current?.source === 'session') {
452
+ // Only a pointer this session wrote can say what is NOT this session's; the
453
+ // project pointer is someone's last choice, so claiming the rest would be a guess.
454
+ marker = ' (not this session)';
455
+ }
456
+ console.log(`${c.id} [${c.tier}/${c.status}] ${c.title}${marker}`);
457
+ }
458
+ console.log(`${changes.length} active · ${archived} archived`);
459
+ }
460
+ return { changes, archived, current };
461
+ }
462
+
463
+ // ---------- observe ----------
464
+
465
+ export function cmdObserve(projectRoot, { json = false } = {}) {
466
+ const sdlcRoot = path.join(projectRoot, 'sdlc');
467
+ requireSdlc(sdlcRoot);
468
+ const report = buildReport(sdlcRoot);
469
+ console.log(json ? JSON.stringify(report, null, 2) : renderReport(report));
470
+ return report;
471
+ }
472
+
473
+ // ---------- archive (= mechanical part of ship) ----------
474
+
475
+ // The CLI's own events go to the same out-of-tree stream the hooks append to, so the
476
+ // ship event does not become the one write that dirties the tree.
477
+ export function appendJournal(sdlcRoot, changeId, event) {
478
+ const target = liveJournalPath(sdlcRoot, changeId);
479
+ if (!target) return;
480
+ appendEvent(target, { ts: new Date().toISOString(), ...event });
481
+ }
482
+
483
+ export function cmdArchive(projectRoot, changeId, { strict = true } = {}) {
484
+ const sdlcRoot = path.join(projectRoot, 'sdlc');
485
+ requireSdlc(sdlcRoot);
486
+ if (!changeId) throw new Error('usage: warnyin-sdlc archive <change-id>');
487
+ // Refuse before anything is read or written. An id like `a/../b` resolves to a real
488
+ // folder, so without this it would ship — merging specs and moving the folder — and
489
+ // only then fail on the journal paths that do gate the id, reporting a completed
490
+ // ship as an error.
491
+ if (!isSafeChangeId(changeId)) {
492
+ throw new Error(`"${changeId}" is not a valid change id one path segment, no separators`);
493
+ }
494
+ const changeDir = path.join(sdlcRoot, 'changes', changeId);
495
+ if (!fs.existsSync(changeDir)) throw new Error(`change "${changeId}" not found`);
496
+
497
+ // Atomicity: the archive destination must be checked BEFORE any write —
498
+ // otherwise a same-day id collision would mutate specs and stamp the change
499
+ // while reporting failure.
500
+ const date = new Date().toISOString().slice(0, 10);
501
+ const destDir = path.join(sdlcRoot, 'changes', 'archive', `${date}-${changeId}`);
502
+ // `init` scaffolds changes/archive/, but git does not track empty directories:
503
+ // it is absent for anyone who cloned before the first change shipped. Prepare
504
+ // it here, with the other destination checks, so a bad archive path fails
505
+ // while the specs are still untouched instead of ENOENT-ing at the rename.
506
+ fs.mkdirSync(path.dirname(destDir), { recursive: true });
507
+ if (fs.existsSync(destDir)) {
508
+ throw new Error(`archive target already exists: ${toPosix(path.relative(projectRoot, destDir))} nothing was merged`);
509
+ }
510
+
511
+ const issues = validateAll(sdlcRoot, { strict, changeId });
512
+ const errors = issues.filter((i) => i.level === 'error');
513
+ if (errors.length) {
514
+ console.error(formatIssues(errors));
515
+ throw new Error(`validate --strict failed with ${errors.length} error(s) — not archiving`);
516
+ }
517
+
518
+ const changeText = fs.readFileSync(path.join(changeDir, 'change.md'), 'utf8');
519
+ const { deltas, errors: parseErrors } = parseDelta(changeText);
520
+ if (parseErrors.length) throw new Error(`delta parse errors: ${parseErrors.join('; ')}`);
521
+
522
+ // Phase 1: compute every merge before writing anything (all-or-nothing).
523
+ const merged = [];
524
+ const driftWarnings = [];
525
+ for (const d of deltas) {
526
+ const specPath = path.join(sdlcRoot, 'specs', d.capability, 'spec.md');
527
+ const specText = fs.existsSync(specPath) ? fs.readFileSync(specPath, 'utf8') : null;
528
+ const result = mergeDelta(specText, d.ops, d.capability);
529
+ if (!result.ok) throw new Error(`spec merge failed for "${d.capability}": ${result.errors.join('; ')}`);
530
+ driftWarnings.push(...result.warnings);
531
+ merged.push({ specPath, content: result.content, capability: d.capability });
532
+ }
533
+
534
+ // A MODIFIED body replaces the requirement wholesale, so it can carry away a
535
+ // scenario the spec still promised. That is allowedbut it is said out loud
536
+ // here, while the change folder is still readable, not discovered in a diff
537
+ // after the folder moved under changes/archive/.
538
+ for (const w of driftWarnings) console.error(`⚠ ${w}`);
539
+
540
+ // Phase 2: write specs, promote evals, stamp status, move to archive.
541
+ for (const m of merged) writeFileNormalized(m.specPath, m.content);
542
+
543
+ const evalsSrc = path.join(changeDir, 'contract', 'evals.md');
544
+ if (fs.existsSync(evalsSrc)) {
545
+ for (const m of merged) {
546
+ const rubricDest = path.join(sdlcRoot, 'evals', m.capability, 'rubric.md');
547
+ if (!fs.existsSync(rubricDest)) {
548
+ writeFileNormalized(rubricDest, fs.readFileSync(evalsSrc, 'utf8'));
549
+ }
550
+ }
551
+ }
552
+
553
+ const stamped = changeText.replace(/^status:\s*.*$/m, 'status: shipped');
554
+ writeFileNormalized(path.join(changeDir, 'change.md'), stamped);
555
+ appendJournal(sdlcRoot, changeId, { event: 'ship', change: changeId, specs: merged.map((m) => m.capability) });
556
+
557
+ // Telemetry stays out of the tree for the whole life of the change and becomes
558
+ // tracked exactly once — here, in the ship commit — so no session can dirty it and no
559
+ // appended tail can conflict.
560
+ //
561
+ // Read before the move, write after it. For an open change `sealedJournalPath` and
562
+ // `legacyJournalPath` are the SAME file, so sealing first would leave the merged
563
+ // union sitting at the legacy path if the rename then failed (EPERM/EBUSY on Windows
564
+ // is the realistic way); the retry would merge that union with the still-present live
565
+ // stream and double every event. Reading first and writing into `destDir` means a
566
+ // failed rename has consumed nothing.
567
+ const sealed = readChangeJournal(sdlcRoot, changeId);
568
+
569
+ fs.renameSync(changeDir, destDir);
570
+
571
+ // Past the point of no return: specs are merged and the folder has moved. Nothing
572
+ // below may throw, or a completed ship reports as a failure and the human retries
573
+ // into "change not found".
574
+ try {
575
+ // Empty only if the id was never journalled at all the ship event above normally
576
+ // guarantees at least one entry. An empty file would be worse than none.
577
+ if (sealed.length) {
578
+ writeFileNormalized(sealedJournalPath(destDir), serializeJournal(sealed));
579
+ }
580
+ fs.rmSync(liveJournalPath(sdlcRoot, changeId), { force: true });
581
+ } catch (err) {
582
+ console.error(`⚠ shipped, but the journal was not fully sealed: ${err.message}`);
583
+ }
584
+
585
+ // The pointers name a folder that has just moved. Release them so no session's focus or
586
+ // telemetry keeps following a change that shipped. Never throws.
587
+ clearPointersFor(sdlcRoot, changeId);
588
+
589
+ console.log(`shipped: ${changeId}`);
590
+ for (const m of merged) console.log(` spec merged: specs/${m.capability}/spec.md`);
591
+ if (driftWarnings.length) {
592
+ console.log(` ⚠ ${driftWarnings.length} scenario warning(s) above re-read the spec diff before pushing`);
593
+ }
594
+ console.log(` archived: changes/archive/${date}-${changeId}/`);
595
+ return { archived: `${date}-${changeId}`, specs: merged.map((m) => m.capability), warnings: driftWarnings };
596
+ }
597
+
598
+ // ---------- shared ----------
599
+
600
+ function requireSdlc(sdlcRoot) {
601
+ if (!fs.existsSync(sdlcRoot)) {
602
+ throw new Error('No sdlc/ directory here run `npx @warnyin/sdlc init` first.');
603
+ }
604
+ }
605
+
606
+ function runValidate(projectRoot, args) {
607
+ const validator = path.join(PKG_ROOT, 'scripts', 'validate.mjs');
608
+ const spawnArgs = [validator, ...(args._.slice(1)), ...(args.strict ? ['--strict'] : []), '--root', projectRoot];
609
+ const res = spawnSync(process.execPath, spawnArgs, { stdio: 'inherit' });
610
+ process.exitCode = res.status ?? 0;
611
+ }
612
+
613
+ // Read from our own package.json: an npx install leaves nothing readable in the
614
+ // target project, and a report whose version is `unknown` cannot be triaged.
615
+ function pkgVersion() {
616
+ return JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version;
617
+ }
618
+
619
+ const HELP = `@warnyin/sdlc spec-driven AI-SDLC framework
620
+
621
+ usage: warnyin-sdlc <command> [options]
622
+
623
+ init [--tool all|none|a,b] scaffold sdlc/ + adapters + hooks (interactive picker when omitted)
624
+ update [--tool ...] [--force] refresh payload-owned files, prune stale ones (guarded)
625
+ validate [id] [--strict] structural validation (caps, delta grammar, gates)
626
+ status [--json] list active changes and their stage
627
+ observe [--json] tokens/cost per change, residency, steering hits, drift flags
628
+ archive <id> merge delta specs into living specs and archive the change
629
+ version | --version | -v print the installed framework version
630
+ help this text
631
+ `;
632
+
633
+ export async function main(argv = process.argv.slice(2), projectRoot = process.cwd()) {
634
+ const args = parseArgs(argv);
635
+ const cmd = args._[0];
636
+ try {
637
+ // before the help branch: `--version` carries no command, and `!cmd` would
638
+ // otherwise print help instead of the version.
639
+ if (args.version || cmd === 'version') { console.log(pkgVersion()); return; }
640
+ if (args.help || !cmd || cmd === 'help') { console.log(HELP); return; }
641
+ if (cmd === 'init') await cmdInit(projectRoot, args);
642
+ else if (cmd === 'update') cmdUpdate(projectRoot, args);
643
+ else if (cmd === 'validate') runValidate(projectRoot, args);
644
+ else if (cmd === 'status') cmdStatus(projectRoot, { json: args.json });
645
+ else if (cmd === 'observe') cmdObserve(projectRoot, { json: args.json });
646
+ else if (cmd === 'archive') cmdArchive(projectRoot, args._[1]);
647
+ else { console.error(`unknown command: ${cmd}`); console.log(HELP); process.exitCode = 2; }
648
+ } catch (err) {
649
+ console.error(String(err.message ?? err));
650
+ process.exitCode = 1;
651
+ }
652
+ }
653
+
654
+ // npx invokes the bin via a node_modules/.bin symlink, so argv[1] must be
655
+ // realpath-resolved before comparing with import.meta.url (which the ESM
656
+ // loader already resolves) — otherwise main() silently never runs.
657
+ function isEntrypoint() {
658
+ if (!process.argv[1]) return false;
659
+ try {
660
+ return fs.realpathSync.native(path.resolve(process.argv[1])) === fileURLToPath(import.meta.url);
661
+ } catch {
662
+ return false;
663
+ }
664
+ }
665
+
666
+ if (isEntrypoint()) {
667
+ main();
668
+ }