@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 +21 -0
- package/README.md +26 -2
- package/bin/sdlc.mjs +11 -0
- package/lib/cli.mjs +610 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +42 -4
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
|
-
#
|
|
1
|
+
# @patwoodman/dsh-sdlc-flow-cli — the `sdlc` executable
|
|
2
2
|
|
|
3
|
-
|
|
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
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patwoodman/dsh-sdlc-flow-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|