@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.
- package/CHANGELOG.md +274 -258
- package/LICENSE +21 -21
- package/README.md +92 -92
- package/bin/cli.mjs +682 -682
- package/lib/active.mjs +199 -199
- package/lib/caps.mjs +46 -46
- package/lib/config.mjs +41 -41
- package/lib/delta.mjs +227 -227
- package/lib/frontmatter.mjs +59 -59
- package/lib/glob.mjs +29 -29
- package/lib/lenses.mjs +48 -48
- package/lib/manifest.mjs +99 -99
- package/lib/settings-merge.mjs +63 -63
- package/lib/skills.mjs +148 -148
- package/lib/validate.mjs +198 -198
- package/package.json +42 -42
- package/payload/adapters/agents-md.md +8 -8
- package/payload/adapters/claude/agents/sdlc-architect.md +12 -12
- package/payload/adapters/claude/agents/sdlc-builder.md +14 -14
- package/payload/adapters/claude/agents/sdlc-contractor.md +13 -13
- package/payload/adapters/claude/agents/sdlc-evaluator.md +13 -13
- package/payload/adapters/claude/agents/sdlc-learner.md +16 -16
- package/payload/adapters/claude/agents/sdlc-ops.md +11 -11
- package/payload/adapters/claude/agents/sdlc-quality.md +13 -13
- package/payload/adapters/claude/agents/sdlc-security.md +12 -12
- package/payload/adapters/claude/commands/sdlc/converge.md +5 -5
- package/payload/adapters/claude/commands/sdlc/init.md +4 -4
- package/payload/adapters/claude/commands/sdlc/next.md +4 -4
- package/payload/adapters/claude/commands/sdlc/observe.md +4 -4
- package/payload/adapters/claude/commands/sdlc/steer.md +4 -4
- package/payload/adapters/claude/skills/contract-writing/SKILL.md +26 -26
- package/payload/adapters/claude/skills/delta-spec-format/SKILL.md +36 -36
- package/payload/adapters/claude/skills/sdlc-conventions/SKILL.md +30 -30
- package/payload/adapters/cline.md +8 -8
- package/payload/adapters/copilot.md +8 -8
- package/payload/adapters/cursor.mdc +7 -7
- package/payload/adapters/gemini.md +8 -8
- package/payload/adapters/windsurf.md +4 -4
- package/payload/hooks/_shared.mjs +138 -138
- package/payload/hooks/guard-writes.mjs +87 -87
- package/payload/hooks/inject-context.mjs +57 -57
- package/payload/hooks/journal.mjs +66 -66
- package/payload/hooks/session-summary.mjs +52 -52
- package/payload/hooks/validate-artifact.mjs +84 -84
- package/payload/playbook/README.md +32 -32
- package/payload/playbook/context.md +26 -26
- package/payload/playbook/contract.md +29 -29
- package/payload/playbook/converge.md +19 -19
- package/payload/playbook/design.md +28 -28
- package/payload/playbook/init.md +22 -22
- package/payload/playbook/lenses.md +64 -64
- package/payload/playbook/new.md +41 -33
- package/payload/playbook/next.md +24 -24
- package/payload/playbook/observe.md +20 -20
- package/payload/playbook/principles.md +28 -28
- package/payload/playbook/review.md +31 -31
- package/payload/playbook/routing.md +19 -19
- package/payload/playbook/rules-card.md +17 -16
- package/payload/playbook/ship.md +35 -35
- package/payload/playbook/steer.md +21 -21
- package/payload/playbook/verify.md +42 -42
- package/payload/templates/change-deep.md +29 -29
- package/payload/templates/change-standard.md +28 -28
- package/payload/templates/change-vibe.md +19 -19
- package/payload/templates/config.yaml +8 -8
- package/payload/templates/constitution.md +14 -14
- package/payload/templates/contract-evals.md +9 -9
- package/payload/templates/contract-tests.md +9 -9
- package/payload/templates/harness.md +34 -34
- package/payload/templates/spec.md +14 -14
- package/payload/templates/steering.md +9 -9
- 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
|
+
}
|