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