@patwoodman/dsh-sdlc-flow-cli 0.0.0-stage → 0.4.0-beta.13

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SDLC Orchestrator contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,27 @@
1
- # Temporary Holding Version
1
+ # @patwoodman/dsh-sdlc-flow-cli — the `sdlc` executable
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A deterministic control plane over `.sdlc/` that makes **no model calls**:
4
+ `init`, `status`, `tasks`, `claim`, `report`, `verify`, `decide`, `debug`
5
+ (including `debug compare` / `debug trace`) and `doctor`.
6
+
7
+ Useful on its own for inspecting and driving an orchestrated project from a
8
+ terminal or CI, and it is the surface `npm run doctor` reports on.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ npm install -g @patwoodman/dsh-sdlc-flow-cli
14
+ sdlc status
15
+ ```
16
+
17
+ The package ships its `src/` as-is (plain ESM, zero dependencies) — `bin/sdlc.mjs`
18
+ is the entry point and `main` points at `src/cli.mjs`. There is no build step.
19
+
20
+ ## Requirements
21
+
22
+ - Node.js ≥ 22.5 (`node:sqlite`)
23
+ - `@deepseek-ai/dsh` `>=0.2.0-rc.2 <0.3.0-0` for the profiles it drives
24
+
25
+ ## License
26
+
27
+ MIT
package/bin/sdlc.mjs ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * packages/sdlc-cli/bin/sdlc.mjs — the `sdlc` executable (spec v3 C.2/F/I).
4
+ *
5
+ * Deterministic control plane over `.sdlc/`: no model call, no network.
6
+ */
7
+
8
+ import { main } from '../lib/cli.mjs';
9
+
10
+ const code = await main(process.argv.slice(2));
11
+ process.exit(code ?? 0);
package/lib/cli.mjs ADDED
@@ -0,0 +1,610 @@
1
+ /**
2
+ * packages/sdlc-cli/src/cli.mjs — the deterministic control plane (spec v3 C.2, F, I.2–I.4).
3
+ *
4
+ * The `sdlc` CLI reads on-disk state and never calls a model. It is the
5
+ * automation surface for headless deployments precisely because slash commands
6
+ * do not exist in one-shot runs (E3).
7
+ *
8
+ * Deployment modes (C.2):
9
+ * interactive lead `dsh --profile sdlc-web` slash commands, approvals, long-lived
10
+ * headless one-shot `dsh --profile sdlc-worker "…"` no approvals (fail-closed)
11
+ * deterministic CLI `sdlc status|tasks|…` read/ops only
12
+ */
13
+
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, mkdtempSync, rmSync, unlinkSync } from 'node:fs';
15
+ import { tmpdir } from 'node:os';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { Runtime } from '@patwoodman/dsh-sdlc-flow-core/runtime';
19
+ import { initProject, describeProject } from '@patwoodman/dsh-sdlc-flow-core/project';
20
+ import { loadConfig, validateConfig, deepMerge, DEFAULT_CONFIG, dumpConfigYaml } from '@patwoodman/dsh-sdlc-flow-core/config';
21
+ import { readLock, isStale } from '@patwoodman/dsh-sdlc-flow-core/instance-lock';
22
+ import { runVerification } from '@patwoodman/dsh-sdlc-flow-core/gates';
23
+ import { compareRuns, renderSideBySide, tracePathFor } from '@patwoodman/dsh-sdlc-flow-core/debug';
24
+ import { exportMetrics } from '@patwoodman/dsh-sdlc-flow-core/metrics';
25
+ import { parseYaml } from '@patwoodman/dsh-sdlc-flow-core/yaml-lite';
26
+
27
+ export const CLI_NAME = 'sdlc';
28
+
29
+ export function projectRootOf(argv = process.argv) {
30
+ const idx = argv.indexOf('--root');
31
+ return resolve(idx >= 0 ? argv[idx + 1] : (process.env.SDLC_PROJECT_ROOT ?? process.cwd()));
32
+ }
33
+
34
+ // ── argument helpers ───────────────────────────────────────────────────────
35
+
36
+ function parseArgs(argv) {
37
+ const args = { _: [], flags: {} };
38
+ for (let i = 0; i < argv.length; i += 1) {
39
+ const a = argv[i];
40
+ if (a.startsWith('--')) {
41
+ const eq = a.indexOf('=');
42
+ if (eq > 0) args.flags[a.slice(2, eq)] = a.slice(eq + 1);
43
+ else if (argv[i + 1] && !argv[i + 1].startsWith('-')) { args.flags[a.slice(2)] = argv[i + 1]; i += 1; }
44
+ else args.flags[a.slice(2)] = true;
45
+ } else args._.push(a);
46
+ }
47
+ return args;
48
+ }
49
+
50
+ export function jsonOut(value) {
51
+ process.stdout.write(JSON.stringify(value, null, 2) + '\n');
52
+ }
53
+
54
+ // ── commands ───────────────────────────────────────────────────────────────
55
+
56
+ export function cmdInit(root, args) {
57
+ const result = initProject({
58
+ projectRoot: root,
59
+ force: Boolean(args.flags.force),
60
+ reconfigure: Boolean(args.flags.reconfigure),
61
+ });
62
+ if (args.flags.json) { jsonOut(result); return result.ok ? 0 : 1; }
63
+ process.stdout.write(`${describeProject(result)}\n`);
64
+ return result.ok ? 0 : 1;
65
+ }
66
+
67
+ function openRuntime(root, { useLock = true } = {}) {
68
+ const { config } = loadConfig(root);
69
+ const runtime = new Runtime({ projectRoot: root, config, useLock });
70
+ runtime.open();
71
+ return { runtime, config };
72
+ }
73
+
74
+ export function cmdStatus(root, args, { allowNoDb = true } = {}) {
75
+ const dbPath = join(root, '.sdlc', 'sdlc.db');
76
+ if (!existsSync(dbPath)) {
77
+ const report = { ok: false, error: 'NO_PROJECT', detail: 'run `sdlc init` first', root };
78
+ if (args.flags.json) jsonOut(report); else process.stdout.write(`${report.detail}\n`);
79
+ return allowNoDb ? 0 : 1;
80
+ }
81
+ const { runtime } = openRuntime(root, { useLock: false });
82
+ try {
83
+ const projects = runtime.db.prepare('SELECT * FROM projects ORDER BY created_at').all();
84
+ const taskRows = runtime.listTasks();
85
+ const byState = {};
86
+ for (const t of taskRows) byState[t.state] = (byState[t.state] ?? 0) + 1;
87
+ const runs = runtime.listWorkerRuns();
88
+ const events = runtime.getEvents({ limit: 10 }).reverse();
89
+ const report = {
90
+ ok: true,
91
+ dbPath,
92
+ projects: projects.map((p) => ({ id: p.id, name: p.name, state: p.state, blocked_state: p.blocked_state })),
93
+ tasks: { total: taskRows.length, byState },
94
+ workerRuns: { total: runs.length, orphaned: runs.filter((r) => r.orphaned).length },
95
+ spend: runtime.spentTotals(),
96
+ recentEvents: events.map((e) => ({ ts: e.ts, actor: e.actor, action: e.action, task_id: e.task_id })),
97
+ };
98
+ // observability.metricsExport (audit #5): `--metrics` renders the same
99
+ // snapshot to .sdlc/metrics.prom when the flag is enabled in config.
100
+ if (args.flags.metrics) {
101
+ const exported = exportMetrics(runtime, { projectRoot: root });
102
+ report.metrics = exported;
103
+ }
104
+ if (args.flags.json) jsonOut(report);
105
+ else {
106
+ process.stdout.write(renderStatus(report));
107
+ if (report.metrics) {
108
+ process.stdout.write(report.metrics.enabled
109
+ ? ` metrics : exported → ${report.metrics.path}\n`
110
+ : ' metrics : disabled (observability.metricsExport: false)\n');
111
+ }
112
+ }
113
+ return 0;
114
+ } finally { runtime.close({ releaseLock: false }); }
115
+ }
116
+
117
+ function renderStatus(r) {
118
+ const lines = [`SDLC status — ${r.dbPath}`, ''];
119
+ if (r.projects.length === 0) lines.push(' (no project initialised — run `sdlc init` then `/sdlc run`)');
120
+ for (const p of r.projects) lines.push(` project ${p.id} state=${p.state}${p.blocked_state ? ` (blocked from ${p.blocked_state})` : ''}`);
121
+ lines.push('', ` tasks : ${r.tasks.total}`);
122
+ for (const [state, n] of Object.entries(r.tasks.byState).sort()) lines.push(` ${state.padEnd(12)} ${n}`);
123
+ lines.push(` worker runs: ${r.workerRuns.total}${r.workerRuns.orphaned ? ` (${r.workerRuns.orphaned} orphaned)` : ''}`);
124
+ lines.push(` spend : $${Number(r.spend.costUsd).toFixed(4)} (estimate) · ${r.spend.tokens} tokens · ${Math.round(r.spend.durationMs / 1000)}s`);
125
+ if (r.recentEvents.length) {
126
+ lines.push('', ' recent events:');
127
+ for (const e of r.recentEvents) lines.push(` ${e.ts ?? ''} ${String(e.actor).padEnd(14)} ${e.action ?? ''} ${e.task_id ?? ''}`);
128
+ }
129
+ return lines.join('\n') + '\n';
130
+ }
131
+
132
+ export function cmdTasks(root, args) {
133
+ const { runtime } = openRuntime(root, { useLock: false });
134
+ try {
135
+ const tasks = runtime.listTasks(args.flags.state ? { state: args.flags.state } : {});
136
+ if (args.flags.json) { jsonOut(tasks); return 0; }
137
+ if (!tasks.length) { process.stdout.write('no tasks\n'); return 0; }
138
+ process.stdout.write(`${'id'.padEnd(16)}${'state'.padEnd(13)}${'role'.padEnd(26)}risk calls\n`);
139
+ for (const t of tasks) {
140
+ process.stdout.write(`${String(t.id).padEnd(16)}${String(t.state).padEnd(13)}${String(t.role ?? '-').padEnd(26)}${String(t.risk_class ?? '-').padEnd(6)}${t.attempt ?? 0}\n`);
141
+ }
142
+ return 0;
143
+ } finally { runtime.close({ releaseLock: false }); }
144
+ }
145
+
146
+ export function cmdExplain(root, args) {
147
+ const [taskId] = args._;
148
+ if (!taskId) { process.stderr.write('usage: sdlc explain <task-id> [--model|--gates|--events]\n'); return 2; }
149
+ const { runtime } = openRuntime(root, { useLock: false });
150
+ try {
151
+ const task = runtime.getTask(taskId);
152
+ if (!task) { process.stderr.write(`no such task: ${taskId}\n`); return 1; }
153
+ const detail = {
154
+ task,
155
+ events: runtime.getEvents({ taskId }),
156
+ workerRuns: runtime.listWorkerRuns({ taskId }),
157
+ modelRuns: runtime.db.prepare('SELECT * FROM model_runs WHERE task_id = ?').all(taskId),
158
+ routing: task.model_tier ? runtime.route({ attempt: task.attempt ?? 0, task_class: 'normal', task_type: task.role }) : null,
159
+ };
160
+ if (args.flags.json) { jsonOut(detail); return 0; }
161
+ process.stdout.write(`task ${task.id} — ${task.state}\n`);
162
+ process.stdout.write(` role=${task.role} risk=${task.risk_class} complexity=${task.complexity} attempt=${task.attempt}\n`);
163
+ process.stdout.write(` allowed_paths: ${task.allowed_paths.join(', ') || '(none)'}\n`);
164
+ process.stdout.write(` verification : ${task.verification.join(', ') || '(none)'}\n`);
165
+ if (args.flags.model && detail.routing) process.stdout.write(` routed : ${detail.routing.tier}/${detail.routing.model ?? '-'} (${detail.routing.reason})\n`);
166
+ if (args.flags.events) {
167
+ process.stdout.write(' events:\n');
168
+ for (const e of detail.events) process.stdout.write(` ${e.ts} ${e.actor} ${e.action}\n`);
169
+ }
170
+ if (args.flags.gates) {
171
+ process.stdout.write(' gate results:\n');
172
+ for (const row of runtime.db.prepare('SELECT gate, status, log_path FROM test_results WHERE task_id = ?').all(taskId)) {
173
+ process.stdout.write(` ${String(row.gate).padEnd(12)} ${row.status} ${row.log_path ?? ''}\n`);
174
+ }
175
+ }
176
+ return 0;
177
+ } finally { runtime.close({ releaseLock: false }); }
178
+ }
179
+
180
+ export function cmdConfig(root, args) {
181
+ const [sub = 'show'] = args._;
182
+ const path = join(root, '.sdlc', 'config.yml');
183
+ if (sub === 'validate') {
184
+ const { config } = loadConfig(root, { configPath: path });
185
+ const result = validateConfig(config, { projectRoot: root });
186
+ if (args.flags.json) { jsonOut(result); return result.ok ? 0 : 1; }
187
+ if (result.ok) process.stdout.write('config: ok\n');
188
+ else { process.stdout.write('config: FAILED\n'); for (const e of result.errors) process.stdout.write(` error: ${e}\n`); }
189
+ for (const w of result.warnings) process.stdout.write(` warning: ${w}\n`);
190
+ return result.ok ? 0 : 1;
191
+ }
192
+ if (sub === 'show') {
193
+ const { config } = loadConfig(root, { configPath: path });
194
+ process.stdout.write(dumpConfigYaml(config));
195
+ return 0;
196
+ }
197
+ if (sub === 'set') {
198
+ const [key, value] = args._.slice(1);
199
+ if (!key || value === undefined) { process.stderr.write('usage: sdlc config set <dotted.key[index]> <value>\n'); return 2; }
200
+ const base = existsSync(path) ? (parseYaml(readFileSync(path, 'utf8')) ?? {}) : {};
201
+ const merged = deepMerge(base, setPath({}, key, coerce(value)));
202
+ writeFileSync(path, dumpConfigYaml(deepMerge(DEFAULT_CONFIG, merged)));
203
+ process.stdout.write(`config set ${key} = ${value}\n`);
204
+ return 0;
205
+ }
206
+ if (sub === 'get') {
207
+ const [key] = args._.slice(1);
208
+ const { config } = loadConfig(root, { configPath: path });
209
+ jsonOut(getPath(config, key));
210
+ return 0;
211
+ }
212
+ process.stderr.write(`unknown config subcommand: ${sub}\n`);
213
+ return 2;
214
+ }
215
+
216
+ function coerce(v) {
217
+ if (v === 'true') return true;
218
+ if (v === 'false') return false;
219
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
220
+ return v;
221
+ }
222
+
223
+ function tokenizeKey(key) {
224
+ const parts = [];
225
+ for (const seg of String(key).split('.')) {
226
+ const m = seg.match(/^([^[\]]+)((\[[^\]]*\])*)$/);
227
+ parts.push(m[1]);
228
+ for (const idx of seg.matchAll(/\[([^\]]*)\]/g)) parts.push(idx[1] === '' ? '[]' : (Number.isNaN(Number(idx[1])) ? idx[1] : Number(idx[1])));
229
+ }
230
+ return parts;
231
+ }
232
+
233
+ /**
234
+ * Set a dotted/indexed key. Container kind is decided by the NEXT segment
235
+ * (a numeric index or `[]` means array); an existing container of the right
236
+ * kind is REUSED, never replaced.
237
+ *
238
+ * The reuse rule matters in practice: spec v3's own Quick Start runs
239
+ * `sdlc config set tiers.cheap[0].provider …` and then
240
+ * `sdlc config set tiers.cheap[0].model …`. Replacing the container on the
241
+ * second call silently erased the provider.
242
+ */
243
+ export function setPath(obj, key, value) {
244
+ const parts = tokenizeKey(key);
245
+ let cursor = obj;
246
+ for (let i = 0; i < parts.length; i += 1) {
247
+ const part = parts[i];
248
+ const isLast = i === parts.length - 1;
249
+ const next = parts[i + 1];
250
+ if (isLast) {
251
+ if (part === '[]') {
252
+ if (!Array.isArray(cursor)) throw new Error(`${key}: "[]" can only append to an array`);
253
+ cursor.push(value);
254
+ } else if (typeof part === 'number') {
255
+ // Backfill with explicit nulls: a SPARSE array serializes badly through
256
+ // YAML (holes render as blank lines and disappear on re-read), so
257
+ // `config set tiers.cheap[1].provider x` must produce [null, {…}].
258
+ for (let k = cursor.length; k < part; k += 1) cursor[k] = null;
259
+ cursor[part] = value;
260
+ } else {
261
+ cursor[part] = value;
262
+ }
263
+ break;
264
+ }
265
+ const wantArray = typeof next === 'number' || next === '[]';
266
+ const current = cursor[part];
267
+ const wrongKind = wantArray ? !Array.isArray(current) : (typeof current !== 'object' || current === null || Array.isArray(current));
268
+ if (current === undefined || current === null || wrongKind) {
269
+ if (typeof part === 'number') for (let k = cursor.length; k < part; k += 1) cursor[k] = null;
270
+ cursor[part] = wantArray ? [] : {};
271
+ }
272
+ cursor = cursor[part];
273
+ }
274
+ return obj;
275
+ }
276
+
277
+ export function getPath(obj, key) {
278
+ let cursor = obj;
279
+ const parts = tokenizeKey(key);
280
+ for (let i = 0; i < parts.length; i += 1) {
281
+ if (cursor == null) return null;
282
+ const part = parts[i];
283
+ if (part === '[]') {
284
+ // `[]` addresses the ARRAY at the preceding path.
285
+ if (i === parts.length - 1) break;
286
+ cursor = Array.isArray(cursor) ? cursor[0] : null;
287
+ continue;
288
+ }
289
+ cursor = cursor[part];
290
+ }
291
+ return cursor ?? null;
292
+ }
293
+
294
+ export function cmdLock(root, args) {
295
+ const [sub = 'status'] = args._;
296
+ const lockPath = join(root, '.sdlc', 'sdlc.lock');
297
+ if (sub === 'status') {
298
+ const lock = existsSync(lockPath) ? readLock(lockPath) : null;
299
+ const report = {
300
+ path: lockPath,
301
+ held: Boolean(lock),
302
+ holder: lock,
303
+ stale: lock ? isStale(lock) : null,
304
+ };
305
+ if (args.flags.json) jsonOut(report);
306
+ else process.stdout.write(lock
307
+ ? `lock held by pid ${lock.pid} on ${lock.host} since ${lock.startedAt}${report.stale ? ' (STALE)' : ''}\n`
308
+ : 'no lock\n');
309
+ return 0;
310
+ }
311
+ if (sub === 'release') {
312
+ if (!existsSync(lockPath)) { process.stdout.write('no lock to release\n'); return 0; }
313
+ const lock = readLock(lockPath);
314
+ if (lock && !isStale(lock) && !args.flags.force) {
315
+ process.stderr.write(`lock is live (pid ${lock.pid}); use --force to break it\n`);
316
+ return 1;
317
+ }
318
+ unlinkSync(lockPath);
319
+ process.stdout.write('lock released\n');
320
+ return 0;
321
+ }
322
+ process.stderr.write(`unknown lock subcommand: ${sub}\n`);
323
+ return 2;
324
+ }
325
+
326
+ export function cmdResume(root, args) {
327
+ const { runtime } = openRuntime(root);
328
+ try {
329
+ const tasks = runtime.listTasks();
330
+ const runs = runtime.listWorkerRuns();
331
+ const transitions = [];
332
+ for (const task of tasks) {
333
+ if (!['ASSIGNED', 'IN_PROGRESS', 'VERIFYING'].includes(task.state)) continue;
334
+ const run = runs.find((r) => r.task_id === task.id && !r.finished_at);
335
+ const alive = run?.pid ? isPidAlive(run.pid) : false;
336
+ if (!alive) transitions.push({ taskId: task.id, to: 'FAILED', actor: 'recovery', evidence: 'ORPHAN_SALVAGE' });
337
+ else transitions.push({ taskId: task.id, to: task.state, actor: 'recovery', evidence: 'kept-alive' });
338
+ }
339
+ const orphaned = transitions.filter((t) => t.evidence === 'ORPHAN_SALVAGE');
340
+ for (const t of orphaned) {
341
+ runtime.state.transition(t.taskId, t.to, 'recovery', { hardFailure: true });
342
+ }
343
+ const report = { reconciled: transitions.length, transitions };
344
+ if (args.flags.json) jsonOut(report); else process.stdout.write(`resume: reconciled ${transitions.length} task(s)\n`);
345
+ return 0;
346
+ } finally { runtime.close(); }
347
+ }
348
+
349
+ function isPidAlive(pid) {
350
+ try { process.kill(pid, 0); return true; } catch { return false; }
351
+ }
352
+
353
+ export function cmdStop(root, args) {
354
+ const { runtime } = openRuntime(root);
355
+ try {
356
+ const stopped = [];
357
+ for (const task of runtime.listTasks()) {
358
+ if (['BACKLOG', 'READY', 'ASSIGNED', 'IN_PROGRESS', 'VERIFYING'].includes(task.state)) {
359
+ const res = runtime.state.transition(task.id, 'CANCELLED', 'human', { workerStopped: true });
360
+ if (res.ok) stopped.push(task.id);
361
+ }
362
+ }
363
+ runtime.state.record({ actor: 'human', action: 'kill-switch', payload: { stopped } });
364
+ const report = { stopped, reason: args.flags.force ? 'force' : 'requested' };
365
+ if (args.flags.json) jsonOut(report); else process.stdout.write(`stop: cancelled ${stopped.length} task(s)\n`);
366
+ return 0;
367
+ } finally { runtime.close(); }
368
+ }
369
+
370
+ export function cmdDb(root, args) {
371
+ const [sub = 'export-events'] = args._;
372
+ if (sub === 'export-events') {
373
+ const { runtime } = openRuntime(root, { useLock: false });
374
+ try {
375
+ const events = runtime.getEvents({ limit: 100000 });
376
+ if (args.flags.json) jsonOut(events);
377
+ else process.stdout.write(events.map((e) => JSON.stringify(e)).join('\n') + '\n');
378
+ return 0;
379
+ } finally { runtime.close({ releaseLock: false }); }
380
+ }
381
+ if (sub === 'integrity') {
382
+ const { runtime } = openRuntime(root, { useLock: false });
383
+ try {
384
+ const status = runtime.assertIntegrity();
385
+ if (args.flags.json) jsonOut({ ok: true, quickCheck: status });
386
+ else process.stdout.write(`integrity: ${status}\n`);
387
+ return 0;
388
+ } finally { runtime.close({ releaseLock: false }); }
389
+ }
390
+ process.stderr.write(`unknown db subcommand: ${sub}\n`);
391
+ return 2;
392
+ }
393
+
394
+ export function cmdVerify(root, args) {
395
+ const { config } = loadConfig(root);
396
+ const taskId = args.flags.task ?? args._[0] ?? 'MANUAL';
397
+ const gates = args.flags.gates ? String(args.flags.gates).split(',') : undefined;
398
+ const result = runVerification({ cwd: root, gates, config, taskId });
399
+ if (args.flags.json) jsonOut(result);
400
+ else {
401
+ process.stdout.write(`verification: ${result.overall}\n`);
402
+ for (const [gate, r] of Object.entries(result.gates)) process.stdout.write(` ${gate.padEnd(12)} ${r.status}\n`);
403
+ }
404
+ return result.overall === 'pass' ? 0 : 1;
405
+ }
406
+
407
+ export function cmdDoctor(root, args) {
408
+ const checks = [];
409
+ const push = (id, ok, detail) => checks.push({ id, ok, detail });
410
+ const nodeMajor = Number(process.versions.node.split('.')[0]);
411
+ const nodeMinor = Number(process.versions.node.split('.')[1]);
412
+ push('node>=22.5', nodeMajor > 22 || (nodeMajor === 22 && nodeMinor >= 5), process.version);
413
+ const dsh = spawnSync('dsh', ['--version'], { encoding: 'utf8' });
414
+ push('dsh', dsh.status === 0, (dsh.stdout || dsh.stderr || 'not found').trim());
415
+ const pnpm = spawnSync('pnpm', ['--version'], { encoding: 'utf8' });
416
+ push('pnpm', pnpm.status === 0, (pnpm.stdout || 'not found').trim());
417
+ const { config } = loadConfig(root);
418
+ const v = validateConfig(config, { projectRoot: root });
419
+ push('config', v.ok, `${v.errors.length} error(s), ${v.warnings.length} warning(s)`);
420
+ const report = { errors: checks.filter((c) => !c.ok).length, warnings: v.warnings.length, checks };
421
+ if (args.flags.json) jsonOut(report);
422
+ else for (const c of checks) process.stdout.write(` ${c.ok ? 'ok ' : 'ERR '} ${c.id.padEnd(14)} ${c.detail}\n`);
423
+ return report.errors ? 1 : 0;
424
+ }
425
+
426
+ /** `sdlc selftest --mock`: self-contained end-to-end smoke test in a temp dir. */
427
+ export function cmdSelftest(root, args) {
428
+ if (args.flags.live) {
429
+ process.stderr.write('selftest --live requires a funded provider key and is intentionally not run by CI\n');
430
+ return 1;
431
+ }
432
+ const dir = mkdtempSync(join(tmpdir(), 'sdlc-selftest-'));
433
+ try {
434
+ const results = [];
435
+ const step = (name, fn) => {
436
+ try { const detail = fn(); results.push({ name, ok: true, detail: detail ?? '' }); }
437
+ catch (err) { results.push({ name, ok: false, detail: err.message }); }
438
+ };
439
+ step('init', () => {
440
+ mkdirSync(join(dir, 'src'), { recursive: true });
441
+ writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'selftest', devDependencies: {} }));
442
+ writeFileSync(join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n');
443
+ const res = initProject({ projectRoot: dir, createBranch: false });
444
+ if (!res.ok) throw new Error(res.message);
445
+ return res.stack;
446
+ });
447
+ step('schema', () => {
448
+ const { runtime } = openRuntime(dir, { useLock: true });
449
+ runtime.close();
450
+ return 'sdlc.db initialised';
451
+ });
452
+ step('state-machine', () => {
453
+ const { runtime } = openRuntime(dir, { useLock: false });
454
+ try {
455
+ runtime.createProject({ id: 'P1', name: 'selftest' });
456
+ runtime.addTask({ id: 'T-001', project_id: 'P1', title: 'smoke', role: 'backend-engineer', allowed_paths: ['src/**'], verification: ['unit'] });
457
+ runtime.transition('T-001', 'READY', 'scheduler', { depStates: {} });
458
+ runtime.transition('T-001', 'ASSIGNED', 'scheduler', { available: true });
459
+ runtime.transition('T-001', 'IN_PROGRESS', 'worker-mgr', {});
460
+ runtime.transition('T-001', 'VERIFYING', 'worker-mgr', {});
461
+ runtime.transition('T-001', 'ACCEPTED', 'verifier', { gatesPass: true });
462
+ runtime.transition('T-001', 'MERGING', 'integration-mgr', { mergeSlotFree: true });
463
+ runtime.transition('T-001', 'DONE', 'integration-mgr', { merged: true });
464
+ return `task reached ${runtime.getTask('T-001').state}`;
465
+ } finally { runtime.close({ releaseLock: false }); }
466
+ });
467
+ step('append-only', () => {
468
+ const { runtime } = openRuntime(dir, { useLock: false });
469
+ try { runtime.state.assertAppendOnly(); return 'events rejects DELETE'; }
470
+ finally { runtime.close({ releaseLock: false }); }
471
+ });
472
+ const failed = results.filter((r) => !r.ok);
473
+ if (args.flags.json) jsonOut({ ok: failed.length === 0, results });
474
+ else {
475
+ for (const r of results) process.stdout.write(` ${r.ok ? 'ok ' : 'FAIL'} ${r.name.padEnd(16)} ${r.detail}\n`);
476
+ }
477
+ return failed.length ? 1 : 0;
478
+ } finally { rmSync(dir, { recursive: true, force: true }); }
479
+ }
480
+
481
+ export function cmdLogs(root, args) {
482
+ const taskId = args.flags.task;
483
+ const dir = taskId ? join(root, '.sdlc', 'verification', taskId) : join(root, '.sdlc', 'verification');
484
+ if (!existsSync(dir)) { process.stdout.write('no logs\n'); return 0; }
485
+ const files = readdirSync(dir, { recursive: true }).map(String).sort();
486
+ if (args.flags.json) { jsonOut({ dir, files }); return 0; }
487
+ for (const f of files) process.stdout.write(`${join(dir, f)}\n`);
488
+ return 0;
489
+ }
490
+
491
+ /**
492
+ * `sdlc debug …` — the debug plugin option: side-by-side run anatomy for
493
+ * review and improvement. Read-only over `.sdlc/`; never calls a model.
494
+ *
495
+ * sdlc debug compare <task-id> <run-a> <run-b> [--label-a X] [--label-b Y] [--out SIDEBYSIDE.md] [--json]
496
+ * sdlc debug trace <task-id> [--json]
497
+ */
498
+ export function cmdDebug(root, args) {
499
+ const [sub = 'compare', ...rest] = args._;
500
+ const dbPath = join(root, '.sdlc', 'sdlc.db');
501
+ if (!existsSync(dbPath)) {
502
+ const report = { ok: false, error: 'NO_PROJECT', detail: 'run `sdlc init` first', root };
503
+ if (args.flags.json) jsonOut(report); else process.stdout.write(`${report.detail}\n`);
504
+ return 1;
505
+ }
506
+ if (sub === 'trace') {
507
+ const [taskId] = rest;
508
+ if (!taskId) { process.stderr.write('usage: sdlc debug trace <task-id> [--json]\n'); return 2; }
509
+ const { runtime } = openRuntime(root, { useLock: false });
510
+ try {
511
+ const path = tracePathFor(runtime, taskId);
512
+ if (args.flags.json) { jsonOut({ taskId, steps: path.length, path }); return 0; }
513
+ process.stdout.write(`trace ${taskId} — ${path.length} step(s)\n`);
514
+ for (const step of path) {
515
+ process.stdout.write(` ${step.ts ?? '?'} ${String(step.actor).padEnd(14)} ${step.action ?? ''}${step.to ? ` → ${step.to}` : ''}\n`);
516
+ }
517
+ return 0;
518
+ } finally { runtime.close({ releaseLock: false }); }
519
+ }
520
+ if (sub === 'compare') {
521
+ const [taskId, runA, runB] = rest;
522
+ if (!taskId || !runA || !runB) {
523
+ process.stderr.write('usage: sdlc debug compare <task-id> <run-a> <run-b> [--label-a X] [--label-b Y] [--out FILE] [--json]\n');
524
+ return 2;
525
+ }
526
+ const { runtime } = openRuntime(root, { useLock: false });
527
+ try {
528
+ const report = compareRuns(runtime, taskId, runA, runB, {
529
+ labelA: args.flags['label-a'] ? String(args.flags['label-a']) : 'A',
530
+ labelB: args.flags['label-b'] ? String(args.flags['label-b']) : 'B',
531
+ });
532
+ const outPath = args.flags.out ? resolve(String(args.flags.out)) : null;
533
+ if (args.flags.json && !outPath) { jsonOut(report); return 0; }
534
+ const markdown = renderSideBySide(report);
535
+ if (outPath) {
536
+ mkdirSync(dirname(outPath), { recursive: true });
537
+ writeFileSync(outPath, markdown);
538
+ if (args.flags.json) jsonOut({ ...report, outPath });
539
+ else process.stdout.write(`debug compare: ${taskId} ${runA} vs ${runB} → ${outPath}\n`);
540
+ } else {
541
+ process.stdout.write(markdown);
542
+ }
543
+ const missing = [!report.runs.A.available, !report.runs.B.available].filter(Boolean).length;
544
+ return missing ? 1 : 0;
545
+ } finally { runtime.close({ releaseLock: false }); }
546
+ }
547
+ process.stderr.write(`unknown debug subcommand: ${sub} (compare|trace)\n`);
548
+ return 2;
549
+ }
550
+
551
+ export const COMMANDS = {
552
+ init: cmdInit,
553
+ status: cmdStatus,
554
+ tasks: cmdTasks,
555
+ explain: cmdExplain,
556
+ config: cmdConfig,
557
+ lock: cmdLock,
558
+ resume: cmdResume,
559
+ stop: cmdStop,
560
+ db: cmdDb,
561
+ verify: cmdVerify,
562
+ doctor: cmdDoctor,
563
+ selftest: cmdSelftest,
564
+ logs: cmdLogs,
565
+ debug: cmdDebug,
566
+ };
567
+
568
+ export function usage() {
569
+ return `sdlc — deterministic control plane over .sdlc/ (no model calls)
570
+
571
+ sdlc init [--force|--reconfigure] create .sdlc/, integration branch, gate commands
572
+ sdlc status [--json] [--metrics] project + task + spend summary
573
+ sdlc tasks [--state S] [--json] task list
574
+ sdlc explain <id> [--model|--gates|--events] [--json]
575
+ sdlc debug compare <task> <runA> <runB> [--label-a X] [--label-b Y] [--out FILE] [--json]
576
+ sdlc debug trace <task> [--json] side-by-side time/tokens/tracepaths for review
577
+ sdlc config show|validate|get <key>|set <key> <value>
578
+ sdlc lock status|release [--force]
579
+ sdlc resume [--json] reconciliation (B.12)
580
+ sdlc stop [--force] kill switch
581
+ sdlc db export-events|integrity
582
+ sdlc verify [--task ID] [--gates a,b]
583
+ sdlc logs [--task ID]
584
+ sdlc doctor [--json]
585
+ sdlc selftest [--mock|--live]
586
+
587
+ Modes (C.2): slash commands and approvals exist ONLY in the interactive lead
588
+ (\`dsh --profile sdlc-web\`). A headless deployment is a guest of the state
589
+ machine: it can run work but cannot unblock itself (fail-closed).
590
+ `;
591
+ }
592
+
593
+ export async function main(argv = process.argv.slice(2)) {
594
+ const args = parseArgs(argv);
595
+ const [command] = args._;
596
+ if (!command || command === 'help' || args.flags.help) {
597
+ process.stdout.write(usage());
598
+ return 0;
599
+ }
600
+ const handler = COMMANDS[command];
601
+ if (!handler) {
602
+ process.stderr.write(`sdlc: unknown command "${command}"\n\n${usage()}`);
603
+ return 2;
604
+ }
605
+ const root = projectRootOf(argv);
606
+ // Subcommands see the positionals AFTER the command itself.
607
+ return handler(root, { ...args, _: args._.slice(1) });
608
+ }
609
+
610
+ export default main;
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "SDLC CLI",
4
+ "description": "Deterministic sdlc control plane: init, status, tasks, debug compare."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "SDLC CLI",
4
+ "description": "Deterministic sdlc control plane: init, status, tasks, debug compare."
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@patwoodman/dsh-sdlc-flow-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0-beta.13",
4
+ "type": "module",
5
+ "description": "The `sdlc` executable: a deterministic control plane over .sdlc/ (no model calls).",
6
+ "bin": {
7
+ "sdlc": "./bin/sdlc.mjs"
8
+ },
9
+ "main": "lib/cli.mjs",
10
+ "files": [
11
+ "lib",
12
+ "bin",
13
+ "locale/*.json",
14
+ "LICENSE",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "build": "node ../../scripts/build.mjs"
19
+ },
20
+ "peerDependencies": {
21
+ "@deepseek-ai/dsh": ">=0.2.0-rc.2 <0.3.0-0"
22
+ },
23
+ "exports": {
24
+ ".": "./lib/cli.mjs",
25
+ "./package.json": "./package.json",
26
+ "./locale/*.json": "./locale/*.json"
27
+ },
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "directory": "packages/sdlc-cli",
32
+ "url": "git+https://github.com/pwoodman/dsh-sdlc.git"
33
+ },
34
+ "engines": {
35
+ "node": ">=22.5.0"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "homepage": "https://github.com/pwoodman/dsh-sdlc",
41
+ "bugs": {
42
+ "url": "https://github.com/pwoodman/dsh-sdlc/issues"
43
+ }
44
+ }