aigroup-workflow 2.2.0 → 2.2.1

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.
@@ -1,8 +1,9 @@
1
1
  # 工作流 Phase 心智模型
2
2
 
3
3
  > 8 个 phase 是**完整路径上限**,不是强制路径——主会话按任务复杂度和风险**裁剪**。
4
- > 状态真相源:`.orchestration/<session>/<worker>/status.md`。
5
- > **完结契约**:worker 转 `completed` 前必须调用 `session.cjs complete` 回写 handoff 标准四节(`Summary` / `Files Changed` / `Validation` / `Follow-ups`),否则视作"未真正完成"。`append` 仅记录过程(设计草稿/需求笔记),不替代 `complete`。
4
+ > 状态真相源:`.orchestration/<session>/<worker>/status.json`(`state` 字段)。
5
+ > **完结契约**:worker 转 `completed` 前必须调用 `session.cjs complete` 回写 handoff 标准字段(`summary` / `filesChanged` / `validation` / `followUps`,写入 `finalizedAt` 时间戳),否则视作"未真正完成"。`append` 仅追加过程记录(设计草稿/需求笔记 `handoff.notes[]`),不替代 `complete`。
6
+ > 产物全部以 JSON 为唯一格式,shape 由 `schemas/orchestration/*.schema.json` 约束;用 `session.cjs validate <session>` 校验。
6
7
 
7
8
  ## 完整路径
8
9
 
@@ -23,12 +24,12 @@
23
24
  |---|-------|-----------|--------|---------------------------|---------|
24
25
  | 1 | 需求收集 | `brainstorming`(前段) | 主会话 | `architect/requirements.md` | 需求文档包含目标 / 用户场景 / 成功标准 |
25
26
  | 2 | 需求验证 | `brainstorming`(中段:challenge) | 主会话 | 在 requirements.md 追加验证结论 | 无歧义、无矛盾、用户确认 |
26
- | 3 | 方案设计 | `brainstorming`(终段:spec 锁定) | `architect` | `architect/handoff.md`(ADR 格式) | 至少 2 个候选方案 + 推荐理由 |
27
- | 4 | 任务拆解 | `writing-plans` | `planner` | `planner/handoff.md` | 3–7 个阶段,每个含 agent / 验证命令 |
27
+ | 3 | 方案设计 | `brainstorming`(终段:spec 锁定) | `architect` | `architect/handoff.json`(ADR 格式) | 至少 2 个候选方案 + 推荐理由 |
28
+ | 4 | 任务拆解 | `writing-plans` | `planner` | `planner/handoff.json` | 3–7 个阶段,每个含 agent / 验证命令 |
28
29
  | 4→5 桥 | 隔离工作区 | `using-git-worktrees` | 主会话执行 git | session `README.md` 记录 worktree 路径 | worktree 创建、依赖装好、测试基线通过 |
29
- | 5 | 实施开发 | subagent 派遣(推荐)/ `executing-plans` | `tdd-guide`(TDD 路径)/ 语言专项 reviewer | `<agent>/handoff.md` 或直接改代码 | 改动文件清单 + 验证证据(typecheck / test) |
30
+ | 5 | 实施开发 | subagent 派遣(推荐)/ `executing-plans` | `tdd-guide`(TDD 路径)/ 语言专项 reviewer | `<agent>/handoff.json` 或直接改代码 | 改动文件清单 + 验证证据(typecheck / test) |
30
31
  | 6a | 审查发起 | `requesting-code-review` | 主会话向 `code-reviewer` 派遣(敏感场景加 `security-reviewer`、关键路径加 `e2e-runner`、按栈加语言专项 reviewer) | `code-reviewer/request.md` | 审查范围 / 验收点 / 关注项清单完整 |
31
- | 6b | 审查反馈处理 | `receiving-code-review` | 主会话逐条决议 | `code-reviewer/handoff.md`(含决议) | 每条反馈有"采纳/反驳/记录"决议且证据闭环(Stage 1 规格 Stage 2 代码质量 ✓) |
32
+ | 6b | 审查反馈处理 | `receiving-code-review` | 主会话逐条决议 | `code-reviewer/handoff.json`(含决议) | 每条反馈有"采纳/反驳/记录"决议且证据闭环(`handoff.stages.spec``handoff.stages.quality` ✓) |
32
33
  | 7 | 文档更新 | (无强制 skill) | `doc-updater` 或主会话直接改 | 直接改 `docs/`;session `README.md` 留笔记 | docs/ARCHITECTURE / docs/PROJECT_CONTEXT / API 文档已同步 |
33
34
  | 8 | 分支收尾 | `finishing-a-development-branch` | 主会话 | session `README.md` 总结 | 集成 / PR / 归档 |
34
35
 
@@ -69,7 +70,7 @@
69
70
  | Discard 确认 | phase 8 选项 4 | 删除分支 + worktree 不可恢复 | 必须用户键入 `discard` 字面量;其他输入一律视为放弃 |
70
71
  | 审查反馈分歧 | phase 6b(`receiving-code-review` 中) | reviewer 与实施 agent 对同一问题给出冲突结论时 | 暂停,把分歧摘要呈给用户决议 |
71
72
 
72
- **自动决策**(不打断用户):phase 间流转、handoff 文件生成、`status.md` 推进、worktree 路径分配、`session.cjs` 状态写入、phase 内的子 agent 派遣。
73
+ **自动决策**(不打断用户):phase 间流转、handoff 文件生成、`status.json` 推进、worktree 路径分配、`session.cjs` 状态写入、phase 内的子 agent 派遣。
73
74
 
74
75
  ## Session 存在条件
75
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aigroup-workflow",
3
- "version": "2.2.0",
3
+ "version": "2.2.1",
4
4
  "description": "AI 团队协作框架 — 通过角色派遣、工作流管道和 Harness 传感器驱动 AI 协作开发",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,8 +5,14 @@ const path = require('path');
5
5
  const { createReport, ROOT, relPosix, dirExists } = require('../lib/runner.cjs');
6
6
 
7
7
  const COORD_ROOT = path.join(ROOT, '.orchestration');
8
- const STAGE1_RE = /(stage.?1|阶段.?1|规格符合)/i;
9
- const STAGE2_RE = /(stage.?2|阶段.?2|代码质量)/i;
8
+
9
+ function readJsonSafe(filePath) {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
12
+ } catch (_error) {
13
+ return null;
14
+ }
15
+ }
10
16
 
11
17
  function run() {
12
18
  const report = createReport();
@@ -22,37 +28,36 @@ function run() {
22
28
 
23
29
  for (const worker of workers) {
24
30
  const workerDir = path.join(sessionDir, worker.name);
25
- const statusFile = path.join(workerDir, 'status.md');
26
-
27
- // 轻量 worker:只要求 handoff + status,跳过 task.md
28
- let lightweight = false;
29
- try {
30
- const raw = fs.readFileSync(statusFile, 'utf8');
31
- lightweight = /^\s*-\s*Lightweight:\s*true/m.test(raw);
32
- } catch (_error) { /* fall through */ }
31
+ const statusFile = path.join(workerDir, 'status.json');
32
+ const handoffFile = path.join(workerDir, 'handoff.json');
33
+ const taskFile = path.join(workerDir, 'task.json');
33
34
 
34
- const required = lightweight
35
- ? ['handoff.md', 'status.md']
36
- : ['task.md', 'handoff.md', 'status.md'];
35
+ // JSON 化后所有 worker 三件套统一:task + handoff + status 都必须存在。
36
+ // 轻量与否通过 task.lightweight / status.lightweight 字段区分,不再靠文件缺失。
37
+ const required = [
38
+ ['task.json', taskFile],
39
+ ['handoff.json', handoffFile],
40
+ ['status.json', statusFile]
41
+ ];
37
42
 
38
- for (const file of required) {
39
- const full = path.join(workerDir, file);
43
+ for (const [name, full] of required) {
40
44
  if (!fs.existsSync(full)) {
41
45
  report.fail(
42
- `${relPosix(workerDir)}/ 缺失 ${file}${lightweight ? '(轻量 worker)' : ''}`,
43
- '用 node scripts/orchestration/session.cjs add-worker 创建(轻量加 --lightweight)'
46
+ `${relPosix(workerDir)}/ 缺失 ${name}`,
47
+ '用 `node scripts/orchestration/session.cjs add-worker` 创建(轻量加 --lightweight)'
44
48
  );
45
49
  }
46
50
  }
47
51
 
52
+ // code-reviewer 强制双阶段产物
48
53
  if (worker.name === 'code-reviewer') {
49
- const handoff = path.join(workerDir, 'handoff.md');
50
- if (fs.existsSync(handoff)) {
51
- const content = fs.readFileSync(handoff, 'utf8');
52
- if (!STAGE1_RE.test(content) && !STAGE2_RE.test(content)) {
54
+ const handoff = readJsonSafe(handoffFile);
55
+ if (handoff && handoff.finalizedAt) {
56
+ const stages = handoff.stages || {};
57
+ if (!stages.spec || !stages.quality) {
53
58
  report.fail(
54
- `${relPosix(handoff)} 缺少阶段标识`,
55
- '审查报告必须含 Stage 1 (规格符合性) Stage 2 (代码质量)'
59
+ `${relPosix(handoffFile)} 缺少双阶段产物`,
60
+ '审查报告必须填 handoff.stages.spec (Stage 1 规格符合性) + handoff.stages.quality (Stage 2 代码质量);用 `--stage-spec`/`--stage-quality` 传给 complete 命令'
56
61
  );
57
62
  }
58
63
  }
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { createReport, ROOT, relPosix, dirExists } = require('../lib/runner.cjs');
6
6
 
7
- // 真相源:.orchestration/<session>/<worker>/status.md
7
+ // 真相源:.orchestration/<session>/<worker>/status.json (state 字段)
8
8
  // 在 Stop 事件中:若任一 worker state=running,说明主会话要停但 worker 没走到终态。
9
9
  // 终态:completed | failed | blocked (完成、失败、主动阻塞都允许 Stop)
10
10
 
@@ -12,9 +12,8 @@ const TERMINAL_STATES = new Set(['completed', 'failed', 'blocked']);
12
12
 
13
13
  function readState(statusFile) {
14
14
  try {
15
- const content = fs.readFileSync(statusFile, 'utf8');
16
- const match = content.match(/^\s*-\s*State:\s*(\S+)/m);
17
- return match ? match[1].toLowerCase() : null;
15
+ const data = JSON.parse(fs.readFileSync(statusFile, 'utf8'));
16
+ return typeof data.state === 'string' ? data.state.toLowerCase() : null;
18
17
  } catch (_error) {
19
18
  return null;
20
19
  }
@@ -35,7 +34,7 @@ function run() {
35
34
  .filter(entry => entry.isDirectory());
36
35
 
37
36
  for (const worker of workers) {
38
- const statusFile = path.join(sessionDir, worker.name, 'status.md');
37
+ const statusFile = path.join(sessionDir, worker.name, 'status.json');
39
38
  const state = readState(statusFile);
40
39
  if (state === 'running') {
41
40
  stuck.push(relPosix(statusFile));
@@ -2,9 +2,17 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { validateAgainstSchema, loadSchema } = require('./validate.cjs');
5
6
 
6
7
  const ROOT = path.resolve(__dirname, '..', '..', '..');
7
8
  const COORDINATION_ROOT = path.join(ROOT, '.orchestration');
9
+ const SCHEMAS_ROOT = path.join(ROOT, 'schemas', 'orchestration');
10
+ const SCHEMA_VERSION = '1.0';
11
+
12
+ const TERMINAL_STATES = new Set(['completed', 'failed', 'blocked']);
13
+ const VALID_STATES = ['not_started', 'running', 'blocked', 'completed', 'failed'];
14
+
15
+ // ─── Path helpers ───
8
16
 
9
17
  function toPosix(p) {
10
18
  return p.split(path.sep).join('/');
@@ -28,8 +36,7 @@ function timestamp() {
28
36
  }
29
37
 
30
38
  function sessionDir(sessionName) {
31
- const slug = slugify(sessionName, 'session');
32
- return path.join(COORDINATION_ROOT, slug);
39
+ return path.join(COORDINATION_ROOT, slugify(sessionName, 'session'));
33
40
  }
34
41
 
35
42
  function workerDir(sessionName, workerName) {
@@ -40,79 +47,142 @@ function workerArtifacts(sessionName, workerName) {
40
47
  const dir = workerDir(sessionName, workerName);
41
48
  return {
42
49
  dir,
43
- task: path.join(dir, 'task.md'),
44
- handoff: path.join(dir, 'handoff.md'),
45
- status: path.join(dir, 'status.md')
50
+ task: path.join(dir, 'task.json'),
51
+ handoff: path.join(dir, 'handoff.json'),
52
+ status: path.join(dir, 'status.json')
46
53
  };
47
54
  }
48
55
 
56
+ function sessionMetaPath(sessionName) {
57
+ return path.join(sessionDir(sessionName), 'session.json');
58
+ }
59
+
60
+ function sessionPlanPath(sessionName) {
61
+ return path.join(sessionDir(sessionName), 'plan.json');
62
+ }
63
+
64
+ // ─── IO ───
65
+
49
66
  function ensureDir(dirPath) {
50
67
  fs.mkdirSync(dirPath, { recursive: true });
51
68
  }
52
69
 
53
- function writeFile(filePath, content) {
70
+ function readJson(filePath) {
71
+ if (!fs.existsSync(filePath)) return null;
72
+ try {
73
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
74
+ } catch (error) {
75
+ throw new Error(`failed to parse ${relPosix(filePath)}: ${error.message}`);
76
+ }
77
+ }
78
+
79
+ function writeJson(filePath, data) {
54
80
  ensureDir(path.dirname(filePath));
55
- fs.writeFileSync(filePath, content.endsWith('\n') ? content : content + '\n', 'utf8');
81
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
56
82
  }
57
83
 
58
- function buildTaskFile({ sessionName, workerName, agent, objective, context = [], deliverables = [] }) {
59
- const ctx = context.length > 0 ? context.map(line => `- ${line}`).join('\n') : '- _无_';
60
- const deliv = deliverables.length > 0 ? deliverables.map(line => `- ${line}`).join('\n') : '- 见 handoff.md 模板';
61
- const artifacts = workerArtifacts(sessionName, workerName);
62
- return [
63
- `# Worker Task: ${workerName}`,
64
- '',
65
- `- Session: \`${sessionName}\``,
66
- `- Agent: \`${agent}\``,
67
- `- Created: ${timestamp()}`,
68
- `- Handoff: \`${relPosix(artifacts.handoff)}\``,
69
- `- Status: \`${relPosix(artifacts.status)}\``,
70
- '',
71
- '## Objective',
72
- objective.trim(),
73
- '',
74
- '## Context',
75
- ctx,
76
- '',
77
- '## Deliverables',
78
- deliv,
79
- '',
80
- '## Completion Rules',
81
- '- 不再向下派遣 subagent,结果写进最终响应',
82
- '- 主会话负责把响应抄写进 `handoff.md`',
83
- '- 主会话负责更新 `status.md`'
84
- ].join('\n');
85
- }
86
-
87
- function buildHandoffFile({ workerName }) {
88
- return [
89
- `# Handoff: ${workerName}`,
90
- '',
91
- '## Summary',
92
- '- Pending',
93
- '',
94
- '## Files Changed',
95
- '- Pending',
96
- '',
97
- '## Validation',
98
- '- Pending',
99
- '',
100
- '## Follow-ups',
101
- '- Pending'
102
- ].join('\n');
103
- }
104
-
105
- function buildStatusFile({ workerName, state = 'not_started', lightweight = false }) {
106
- const lines = [
107
- `# Status: ${workerName}`,
108
- '',
109
- `- State: ${state}`,
110
- `- Updated: ${timestamp()}`
111
- ];
112
- if (lightweight) lines.push('- Lightweight: true');
113
- return lines.join('\n');
84
+ // ─── Session-level ───
85
+
86
+ function readSessionMeta(sessionName) {
87
+ return readJson(sessionMetaPath(sessionName));
88
+ }
89
+
90
+ function writeSessionMeta(sessionName, meta) {
91
+ writeJson(sessionMetaPath(sessionName), meta);
114
92
  }
115
93
 
94
+ function initSession(sessionName) {
95
+ const slug = slugify(sessionName, 'session');
96
+ const dir = sessionDir(sessionName);
97
+ ensureDir(dir);
98
+ const metaPath = sessionMetaPath(sessionName);
99
+ if (!fs.existsSync(metaPath)) {
100
+ const meta = {
101
+ schemaVersion: SCHEMA_VERSION,
102
+ name: slug,
103
+ label: sessionName,
104
+ createdAt: timestamp(),
105
+ workers: []
106
+ };
107
+ writeJson(metaPath, meta);
108
+ }
109
+ return dir;
110
+ }
111
+
112
+ function registerWorker(sessionName, workerName) {
113
+ const slug = slugify(workerName, 'worker');
114
+ const meta = readSessionMeta(sessionName) || {
115
+ schemaVersion: SCHEMA_VERSION,
116
+ name: slugify(sessionName, 'session'),
117
+ label: sessionName,
118
+ createdAt: timestamp(),
119
+ workers: []
120
+ };
121
+ if (!Array.isArray(meta.workers)) meta.workers = [];
122
+ if (!meta.workers.includes(slug)) meta.workers.push(slug);
123
+ meta.updatedAt = timestamp();
124
+ writeSessionMeta(sessionName, meta);
125
+ }
126
+
127
+ function touchSession(sessionName) {
128
+ const meta = readSessionMeta(sessionName);
129
+ if (!meta) return;
130
+ meta.updatedAt = timestamp();
131
+ writeSessionMeta(sessionName, meta);
132
+ }
133
+
134
+ // ─── Worker artifacts: builders ───
135
+
136
+ function buildTask({ sessionName, workerName, agent, objective, context = [], deliverables = [], dependsOn = [], lightweight = false }) {
137
+ return {
138
+ schemaVersion: SCHEMA_VERSION,
139
+ session: slugify(sessionName, 'session'),
140
+ worker: slugify(workerName, 'worker'),
141
+ agent,
142
+ createdAt: timestamp(),
143
+ objective: objective.trim(),
144
+ context: context.filter(Boolean),
145
+ deliverables: deliverables.filter(Boolean),
146
+ dependsOn: dependsOn.filter(Boolean),
147
+ lightweight: Boolean(lightweight),
148
+ completionRules: [
149
+ '不再向下派遣 subagent,结果写进最终响应',
150
+ '主会话负责把响应写进 handoff.json',
151
+ '主会话负责更新 status.json'
152
+ ]
153
+ };
154
+ }
155
+
156
+ function buildHandoff({ sessionName, workerName }) {
157
+ return {
158
+ schemaVersion: SCHEMA_VERSION,
159
+ session: slugify(sessionName, 'session'),
160
+ worker: slugify(workerName, 'worker'),
161
+ summary: null,
162
+ filesChanged: [],
163
+ validation: null,
164
+ followUps: [],
165
+ notes: [],
166
+ finalizedAt: null
167
+ };
168
+ }
169
+
170
+ function buildStatus({ sessionName, workerName, state = 'not_started', lightweight = false, details = null }) {
171
+ const at = timestamp();
172
+ return {
173
+ schemaVersion: SCHEMA_VERSION,
174
+ session: slugify(sessionName, 'session'),
175
+ worker: slugify(workerName, 'worker'),
176
+ state,
177
+ updatedAt: at,
178
+ lightweight: Boolean(lightweight),
179
+ details: details || null,
180
+ history: [{ state, at, details: details || null }]
181
+ };
182
+ }
183
+
184
+ // ─── Worker operations ───
185
+
116
186
  function createWorker(spec) {
117
187
  const { sessionName, workerName, lightweight = false } = spec;
118
188
  if (!sessionName) throw new Error('sessionName is required');
@@ -122,94 +192,251 @@ function createWorker(spec) {
122
192
 
123
193
  const artifacts = workerArtifacts(sessionName, workerName);
124
194
  ensureDir(artifacts.dir);
125
- if (!lightweight) {
126
- writeFile(artifacts.task, buildTaskFile(spec));
127
- }
128
- writeFile(artifacts.handoff, buildHandoffFile({ workerName }));
129
- writeFile(artifacts.status, buildStatusFile({ workerName, lightweight }));
195
+ // task.json 总是写入(统一 shape);轻量 worker 通过 task.lightweight=true 区分,
196
+ // 而非通过文件缺失。hook 与下游消费者按字段判断,不靠 fs.exists。
197
+ writeJson(artifacts.task, buildTask(spec));
198
+ writeJson(artifacts.handoff, buildHandoff({ sessionName, workerName }));
199
+ writeJson(artifacts.status, buildStatus({ sessionName, workerName, lightweight }));
200
+ registerWorker(sessionName, workerName);
130
201
  return { ...artifacts, lightweight };
131
202
  }
132
203
 
133
- function isLightweight(statusFile) {
134
- try {
135
- const raw = fs.readFileSync(statusFile, 'utf8');
136
- return /^\s*-\s*Lightweight:\s*true/m.test(raw);
137
- } catch (_error) {
138
- return false;
139
- }
204
+ function isLightweight(sessionName, workerName) {
205
+ const status = readJson(workerArtifacts(sessionName, workerName).status);
206
+ return Boolean(status && status.lightweight);
140
207
  }
141
208
 
142
209
  function updateStatus(sessionName, workerName, state, details = '') {
210
+ if (!VALID_STATES.includes(state)) {
211
+ throw new Error(`invalid state '${state}'. Valid: ${VALID_STATES.join(' | ')}`);
212
+ }
143
213
  const artifacts = workerArtifacts(sessionName, workerName);
144
- const lightweight = isLightweight(artifacts.status);
145
- const body = [
146
- `# Status: ${workerName}`,
147
- '',
148
- `- State: ${state}`,
149
- `- Updated: ${timestamp()}`
150
- ];
151
- if (lightweight) body.push('- Lightweight: true');
152
- if (details) body.push('', details.trim());
153
- writeFile(artifacts.status, body.join('\n'));
214
+ const existing = readJson(artifacts.status) || buildStatus({ sessionName, workerName });
215
+ const at = timestamp();
216
+ const entry = { state, at, details: details ? String(details).trim() : null };
217
+ existing.state = state;
218
+ existing.updatedAt = at;
219
+ existing.details = entry.details;
220
+ if (!Array.isArray(existing.history)) existing.history = [];
221
+ existing.history.push(entry);
222
+ writeJson(artifacts.status, existing);
223
+ touchSession(sessionName);
154
224
  return artifacts.status;
155
225
  }
156
226
 
157
227
  function appendHandoff(sessionName, workerName, sectionTitle, content) {
228
+ if (!sectionTitle) throw new Error('section title required');
229
+ if (!content) throw new Error('content required');
158
230
  const artifacts = workerArtifacts(sessionName, workerName);
159
- const existing = fs.existsSync(artifacts.handoff)
160
- ? fs.readFileSync(artifacts.handoff, 'utf8')
161
- : buildHandoffFile({ workerName });
162
- const block = `\n## ${sectionTitle} (${timestamp()})\n${content.trim()}\n`;
163
- writeFile(artifacts.handoff, existing.replace(/\s+$/, '') + block);
231
+ const existing = readJson(artifacts.handoff) || buildHandoff({ sessionName, workerName });
232
+ if (!Array.isArray(existing.notes)) existing.notes = [];
233
+ existing.notes.push({
234
+ section: String(sectionTitle).trim(),
235
+ content: String(content).trim(),
236
+ addedAt: timestamp()
237
+ });
238
+ writeJson(artifacts.handoff, existing);
239
+ touchSession(sessionName);
164
240
  return artifacts.handoff;
165
241
  }
166
242
 
167
- const STANDARD_SECTIONS = ['Summary', 'Files Changed', 'Validation', 'Follow-ups'];
243
+ /**
244
+ * Finalize handoff. fields keys: summary, filesChanged, validation, followUps, stages.
245
+ * - summary: string
246
+ * - filesChanged: string[] | {path, action?, note?}[]
247
+ * - validation: string
248
+ * - followUps: string[]
249
+ * - stages: { spec?: string, quality?: string } (code-reviewer only)
250
+ */
251
+ function completeHandoff(sessionName, workerName, fields = {}) {
252
+ const artifacts = workerArtifacts(sessionName, workerName);
253
+ const existing = readJson(artifacts.handoff) || buildHandoff({ sessionName, workerName });
254
+
255
+ if (fields.summary !== undefined) existing.summary = String(fields.summary).trim() || null;
256
+ if (fields.validation !== undefined) existing.validation = String(fields.validation).trim() || null;
257
+ if (fields.filesChanged !== undefined) existing.filesChanged = normalizeFilesChanged(fields.filesChanged);
258
+ if (fields.followUps !== undefined) existing.followUps = normalizeFollowUps(fields.followUps);
259
+ if (fields.stages !== undefined) {
260
+ const stages = {};
261
+ if (fields.stages.spec) stages.spec = String(fields.stages.spec).trim();
262
+ if (fields.stages.quality) stages.quality = String(fields.stages.quality).trim();
263
+ if (Object.keys(stages).length > 0) existing.stages = stages;
264
+ }
168
265
 
169
- function replaceStandardSection(body, title, content) {
170
- const trimmed = (content || '').trim() || '- _未提供_';
171
- const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
172
- const re = new RegExp(`(^|\\n)## ${escaped}\\n[\\s\\S]*?(?=\\n## |$)`);
173
- const replacement = `$1## ${title}\n${trimmed}\n`;
174
- if (re.test(body)) return body.replace(re, replacement);
175
- return body.replace(/\s+$/, '') + `\n\n## ${title}\n${trimmed}\n`;
266
+ existing.finalizedAt = timestamp();
267
+ writeJson(artifacts.handoff, existing);
268
+ touchSession(sessionName);
269
+ return artifacts.handoff;
176
270
  }
177
271
 
178
- function completeHandoff(sessionName, workerName, fields = {}) {
179
- const artifacts = workerArtifacts(sessionName, workerName);
180
- let body = fs.existsSync(artifacts.handoff)
181
- ? fs.readFileSync(artifacts.handoff, 'utf8')
182
- : buildHandoffFile({ workerName });
183
- const map = {
184
- Summary: fields.summary,
185
- 'Files Changed': fields.files,
186
- Validation: fields.validation,
187
- 'Follow-ups': fields.followUps
272
+ function normalizeFilesChanged(input) {
273
+ if (Array.isArray(input)) return input;
274
+ if (typeof input !== 'string') return [];
275
+ // Accept newline- or comma-separated string for CLI ergonomics
276
+ return input
277
+ .split(/[\n,]/)
278
+ .map(s => s.trim())
279
+ .filter(Boolean);
280
+ }
281
+
282
+ function normalizeFollowUps(input) {
283
+ if (Array.isArray(input)) return input;
284
+ if (typeof input !== 'string') return [];
285
+ return input
286
+ .split(/\n/)
287
+ .map(s => s.replace(/^[-*]\s*/, '').trim())
288
+ .filter(Boolean);
289
+ }
290
+
291
+ // ─── Plan aggregation ───
292
+
293
+ function listWorkers(sessionName) {
294
+ const dir = sessionDir(sessionName);
295
+ if (!fs.existsSync(dir)) return [];
296
+ return fs.readdirSync(dir, { withFileTypes: true })
297
+ .filter(e => e.isDirectory())
298
+ .map(e => e.name)
299
+ .sort();
300
+ }
301
+
302
+ function buildPlan(sessionName) {
303
+ const workers = listWorkers(sessionName).map(name => {
304
+ const artifacts = workerArtifacts(sessionName, name);
305
+ const task = readJson(artifacts.task);
306
+ const status = readJson(artifacts.status);
307
+ const handoff = readJson(artifacts.handoff);
308
+ return {
309
+ name,
310
+ agent: (task && task.agent) || (handoff && handoff.agent) || 'unknown',
311
+ state: (status && status.state) || 'not_started',
312
+ lightweight: Boolean(status && status.lightweight),
313
+ dependsOn: (task && Array.isArray(task.dependsOn)) ? task.dependsOn : [],
314
+ objective: (task && task.objective) || undefined,
315
+ finalizedAt: (handoff && handoff.finalizedAt) || null
316
+ };
317
+ });
318
+
319
+ const completedSet = new Set(workers.filter(w => w.state === 'completed').map(w => w.name));
320
+ const buckets = { ready: [], running: [], blocked: [], completed: [], failed: [], waiting: [] };
321
+ for (const w of workers) {
322
+ if (w.state === 'running') buckets.running.push(w.name);
323
+ else if (w.state === 'blocked') buckets.blocked.push(w.name);
324
+ else if (w.state === 'completed') buckets.completed.push(w.name);
325
+ else if (w.state === 'failed') buckets.failed.push(w.name);
326
+ else {
327
+ const allDepsDone = w.dependsOn.every(d => completedSet.has(d));
328
+ if (allDepsDone) buckets.ready.push(w.name);
329
+ else buckets.waiting.push(w.name);
330
+ }
331
+ }
332
+
333
+ return {
334
+ schemaVersion: SCHEMA_VERSION,
335
+ session: slugify(sessionName, 'session'),
336
+ generatedAt: timestamp(),
337
+ workers,
338
+ buckets
188
339
  };
189
- for (const title of STANDARD_SECTIONS) {
190
- if (map[title] !== undefined && map[title] !== null) {
191
- body = replaceStandardSection(body, title, map[title]);
340
+ }
341
+
342
+ function refreshPlan(sessionName) {
343
+ const plan = buildPlan(sessionName);
344
+ writeJson(sessionPlanPath(sessionName), plan);
345
+ return sessionPlanPath(sessionName);
346
+ }
347
+
348
+ // ─── Validation ───
349
+
350
+ const SCHEMA_FILES = {
351
+ session: 'session.schema.json',
352
+ plan: 'plan.schema.json',
353
+ task: 'task.schema.json',
354
+ handoff: 'handoff.schema.json',
355
+ status: 'status.schema.json'
356
+ };
357
+
358
+ function getSchema(kind) {
359
+ const file = SCHEMA_FILES[kind];
360
+ if (!file) throw new Error(`unknown schema kind: ${kind}`);
361
+ return loadSchema(path.join(SCHEMAS_ROOT, file));
362
+ }
363
+
364
+ function validateArtifact(kind, data) {
365
+ const schema = getSchema(kind);
366
+ return validateAgainstSchema(data, schema);
367
+ }
368
+
369
+ function validateSession(sessionName) {
370
+ const errors = [];
371
+ const meta = readJson(sessionMetaPath(sessionName));
372
+ if (!meta) {
373
+ errors.push({ file: relPosix(sessionMetaPath(sessionName)), problems: ['file missing'] });
374
+ } else {
375
+ const r = validateArtifact('session', meta);
376
+ if (!r.valid) errors.push({ file: relPosix(sessionMetaPath(sessionName)), problems: r.errors });
377
+ }
378
+
379
+ const planFile = sessionPlanPath(sessionName);
380
+ if (fs.existsSync(planFile)) {
381
+ const plan = readJson(planFile);
382
+ const r = validateArtifact('plan', plan);
383
+ if (!r.valid) errors.push({ file: relPosix(planFile), problems: r.errors });
384
+ }
385
+
386
+ for (const worker of listWorkers(sessionName)) {
387
+ const a = workerArtifacts(sessionName, worker);
388
+ for (const kind of ['task', 'handoff', 'status']) {
389
+ const file = a[kind];
390
+ const data = readJson(file);
391
+ if (!data) {
392
+ errors.push({ file: relPosix(file), problems: ['file missing or unreadable'] });
393
+ continue;
394
+ }
395
+ const r = validateArtifact(kind, data);
396
+ if (!r.valid) errors.push({ file: relPosix(file), problems: r.errors });
192
397
  }
193
398
  }
194
- const stamp = `\n<!-- finalized: ${timestamp()} -->\n`;
195
- body = body.replace(/\n<!-- finalized: [^>]+ -->\n?/g, '');
196
- writeFile(artifacts.handoff, body.replace(/\s+$/, '') + stamp);
197
- return artifacts.handoff;
399
+
400
+ return errors;
198
401
  }
199
402
 
200
403
  module.exports = {
404
+ // constants
201
405
  COORDINATION_ROOT,
202
406
  ROOT,
407
+ SCHEMAS_ROOT,
408
+ SCHEMA_VERSION,
409
+ TERMINAL_STATES,
410
+ VALID_STATES,
411
+ // path helpers
203
412
  sessionDir,
204
413
  workerDir,
205
414
  workerArtifacts,
415
+ sessionMetaPath,
416
+ sessionPlanPath,
417
+ // session ops
418
+ initSession,
419
+ readSessionMeta,
420
+ writeSessionMeta,
421
+ registerWorker,
422
+ // worker ops
206
423
  createWorker,
424
+ isLightweight,
207
425
  updateStatus,
208
426
  appendHandoff,
209
427
  completeHandoff,
210
- isLightweight,
428
+ // plan
429
+ listWorkers,
430
+ buildPlan,
431
+ refreshPlan,
432
+ // validation
433
+ validateArtifact,
434
+ validateSession,
435
+ // utils
211
436
  slugify,
212
437
  timestamp,
213
438
  relPosix,
214
- toPosix
439
+ toPosix,
440
+ readJson,
441
+ writeJson
215
442
  };
@@ -0,0 +1,145 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Minimal JSON Schema (Draft-07 subset) validator — zero deps.
5
+ *
6
+ * Supported keywords:
7
+ * type, required, enum, const, pattern, format(date-time), additionalProperties,
8
+ * properties, items, minLength, minItems, uniqueItems, default(passthrough), oneOf
9
+ *
10
+ * Not supported (out of scope for our schemas): $ref resolution across files, allOf,
11
+ * anyOf, not, dependencies, conditionals (if/then/else). If you add those to a schema,
12
+ * extend this file.
13
+ */
14
+
15
+ const fs = require('fs');
16
+
17
+ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
18
+
19
+ function loadSchema(filePath) {
20
+ const raw = fs.readFileSync(filePath, 'utf8');
21
+ return JSON.parse(raw);
22
+ }
23
+
24
+ function jsTypeOf(value) {
25
+ if (value === null) return 'null';
26
+ if (Array.isArray(value)) return 'array';
27
+ if (Number.isInteger(value)) return 'integer';
28
+ return typeof value; // string, number, boolean, object, undefined
29
+ }
30
+
31
+ function matchesType(value, type) {
32
+ if (Array.isArray(type)) return type.some(t => matchesType(value, t));
33
+ const actual = jsTypeOf(value);
34
+ if (type === 'integer') return actual === 'integer';
35
+ if (type === 'number') return actual === 'integer' || actual === 'number';
36
+ return actual === type;
37
+ }
38
+
39
+ function validate(value, schema, pathPrefix = '$') {
40
+ const errors = [];
41
+
42
+ if (schema === true) return errors;
43
+ if (schema === false) {
44
+ errors.push(`${pathPrefix}: schema explicitly forbids this value`);
45
+ return errors;
46
+ }
47
+ if (!schema || typeof schema !== 'object') return errors;
48
+
49
+ // type
50
+ if (schema.type !== undefined && !matchesType(value, schema.type)) {
51
+ errors.push(`${pathPrefix}: expected type ${JSON.stringify(schema.type)}, got ${jsTypeOf(value)}`);
52
+ return errors; // bail — downstream checks meaningless
53
+ }
54
+
55
+ // const
56
+ if (schema.const !== undefined && value !== schema.const) {
57
+ errors.push(`${pathPrefix}: expected const ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`);
58
+ }
59
+
60
+ // enum
61
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
62
+ errors.push(`${pathPrefix}: value not in enum ${JSON.stringify(schema.enum)}`);
63
+ }
64
+
65
+ // string-specific
66
+ if (typeof value === 'string') {
67
+ if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
68
+ errors.push(`${pathPrefix}: shorter than minLength ${schema.minLength}`);
69
+ }
70
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
71
+ errors.push(`${pathPrefix}: does not match pattern /${schema.pattern}/`);
72
+ }
73
+ if (schema.format === 'date-time' && !ISO_DATETIME_RE.test(value)) {
74
+ errors.push(`${pathPrefix}: not a valid ISO-8601 date-time`);
75
+ }
76
+ }
77
+
78
+ // array-specific
79
+ if (Array.isArray(value)) {
80
+ if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
81
+ errors.push(`${pathPrefix}: array shorter than minItems ${schema.minItems}`);
82
+ }
83
+ if (schema.uniqueItems === true) {
84
+ const seen = new Set();
85
+ for (const item of value) {
86
+ const key = JSON.stringify(item);
87
+ if (seen.has(key)) {
88
+ errors.push(`${pathPrefix}: array has duplicate items`);
89
+ break;
90
+ }
91
+ seen.add(key);
92
+ }
93
+ }
94
+ if (schema.items) {
95
+ for (let i = 0; i < value.length; i += 1) {
96
+ errors.push(...validate(value[i], schema.items, `${pathPrefix}[${i}]`));
97
+ }
98
+ }
99
+ }
100
+
101
+ // object-specific
102
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
103
+ if (Array.isArray(schema.required)) {
104
+ for (const key of schema.required) {
105
+ if (!(key in value)) errors.push(`${pathPrefix}.${key}: required field missing`);
106
+ }
107
+ }
108
+ if (schema.properties) {
109
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
110
+ if (key in value) {
111
+ errors.push(...validate(value[key], propSchema, `${pathPrefix}.${key}`));
112
+ }
113
+ }
114
+ }
115
+ if (schema.additionalProperties === false && schema.properties) {
116
+ const allowed = new Set(Object.keys(schema.properties));
117
+ for (const key of Object.keys(value)) {
118
+ if (!allowed.has(key)) errors.push(`${pathPrefix}.${key}: additionalProperties not allowed`);
119
+ }
120
+ }
121
+ }
122
+
123
+ // oneOf
124
+ if (Array.isArray(schema.oneOf)) {
125
+ const matches = schema.oneOf.filter(sub => validate(value, sub, pathPrefix).length === 0);
126
+ if (matches.length === 0) {
127
+ errors.push(`${pathPrefix}: matches none of oneOf branches`);
128
+ } else if (matches.length > 1) {
129
+ errors.push(`${pathPrefix}: matches ${matches.length} oneOf branches (must match exactly 1)`);
130
+ }
131
+ }
132
+
133
+ return errors;
134
+ }
135
+
136
+ function validateAgainstSchema(data, schema) {
137
+ const errors = validate(data, schema);
138
+ return { valid: errors.length === 0, errors };
139
+ }
140
+
141
+ module.exports = {
142
+ loadSchema,
143
+ validateAgainstSchema,
144
+ validate
145
+ };
@@ -8,39 +8,52 @@ const {
8
8
  ROOT,
9
9
  sessionDir,
10
10
  workerArtifacts,
11
+ initSession,
11
12
  createWorker,
12
13
  updateStatus,
13
14
  appendHandoff,
14
15
  completeHandoff,
16
+ refreshPlan,
17
+ buildPlan,
18
+ validateSession,
19
+ readJson,
15
20
  slugify,
16
21
  relPosix
17
22
  } = require('./lib/orchestrator.cjs');
18
23
 
19
- const COMMANDS = ['init', 'add-worker', 'status', 'set-status', 'append', 'complete', 'list', 'help'];
24
+ const COMMANDS = ['init', 'add-worker', 'status', 'set-status', 'append', 'complete', 'plan', 'validate', 'list', 'help'];
20
25
 
21
26
  function usage() {
22
27
  console.log([
23
28
  'Usage:',
24
- ' node scripts/orchestration/session.js init <session-name>',
29
+ ' node scripts/orchestration/session.cjs init <session>',
25
30
  ' node scripts/orchestration/session.cjs add-worker <session> <worker> --agent <name> --objective <text>',
26
- ' [--context <line>]... [--deliverable <line>]... [--lightweight]',
27
- ' node scripts/orchestration/session.js set-status <session> <worker> <state> [--details <text>]',
28
- ' node scripts/orchestration/session.js append <session> <worker> <section> --content <text>',
31
+ ' [--context <line>]... [--deliverable <line>]...',
32
+ ' [--depends-on <worker>]... [--lightweight]',
33
+ ' node scripts/orchestration/session.cjs set-status <session> <worker> <state> [--details <text>]',
34
+ ' node scripts/orchestration/session.cjs append <session> <worker> <section> --content <text>',
29
35
  ' node scripts/orchestration/session.cjs complete <session> <worker>',
30
- ' [--summary <md>] [--files <md>]',
31
- ' [--validation <md>] [--follow-ups <md>]',
32
- ' node scripts/orchestration/session.js status <session>',
33
- ' node scripts/orchestration/session.js list',
36
+ ' [--summary <text>] [--validation <text>]',
37
+ ' [--files <newline-or-comma-separated>]',
38
+ ' [--follow-ups <newline-separated>]',
39
+ ' [--stage-spec <text>] [--stage-quality <text>]',
40
+ ' node scripts/orchestration/session.cjs status <session>',
41
+ ' node scripts/orchestration/session.cjs plan <session> # refresh and print plan.json',
42
+ ' node scripts/orchestration/session.cjs validate <session> # validate all artifacts against schemas',
43
+ ' node scripts/orchestration/session.cjs list',
34
44
  '',
35
45
  'States: not_started | running | blocked | completed | failed',
36
- `Coordination root: ${relPosix(COORDINATION_ROOT)}/`
46
+ `Coordination root: ${relPosix(COORDINATION_ROOT)}/`,
47
+ 'Schemas: schemas/orchestration/'
37
48
  ].join('\n'));
38
49
  }
39
50
 
40
51
  const BOOLEAN_FLAGS = new Set(['lightweight']);
52
+ const REPEATABLE_FLAGS = new Set(['context', 'deliverable', 'depends-on']);
41
53
 
42
54
  function parseFlags(args) {
43
- const flags = { context: [], deliverable: [] };
55
+ const flags = {};
56
+ for (const k of REPEATABLE_FLAGS) flags[k] = [];
44
57
  const positional = [];
45
58
  for (let i = 0; i < args.length; i += 1) {
46
59
  const arg = args[i];
@@ -51,7 +64,7 @@ function parseFlags(args) {
51
64
  continue;
52
65
  }
53
66
  const value = args[i + 1];
54
- if (key === 'context' || key === 'deliverable') {
67
+ if (REPEATABLE_FLAGS.has(key)) {
55
68
  flags[key].push(value);
56
69
  } else {
57
70
  flags[key] = value;
@@ -65,17 +78,8 @@ function parseFlags(args) {
65
78
  }
66
79
 
67
80
  function cmdInit(name) {
68
- if (!name) throw new Error('session-name is required');
69
- const dir = sessionDir(name);
70
- fs.mkdirSync(dir, { recursive: true });
71
- const meta = path.join(dir, 'session.json');
72
- if (!fs.existsSync(meta)) {
73
- fs.writeFileSync(meta, JSON.stringify({
74
- name: slugify(name, 'session'),
75
- label: name,
76
- createdAt: new Date().toISOString()
77
- }, null, 2) + '\n', 'utf8');
78
- }
81
+ if (!name) throw new Error('session name is required');
82
+ const dir = initSession(name);
79
83
  console.log(relPosix(dir));
80
84
  }
81
85
 
@@ -88,8 +92,9 @@ function cmdAddWorker(session, worker, flags) {
88
92
  workerName: worker,
89
93
  agent: flags.agent,
90
94
  objective: flags.objective,
91
- context: flags.context,
92
- deliverables: flags.deliverable,
95
+ context: flags.context || [],
96
+ deliverables: flags.deliverable || [],
97
+ dependsOn: flags['depends-on'] || [],
93
98
  lightweight: Boolean(flags.lightweight)
94
99
  });
95
100
  const out = {
@@ -116,37 +121,70 @@ function cmdAppend(session, worker, section, flags) {
116
121
 
117
122
  function cmdComplete(session, worker, flags) {
118
123
  if (!session || !worker) throw new Error('session and worker names are required');
119
- const fields = {
120
- summary: flags.summary,
121
- files: flags.files,
122
- validation: flags.validation,
123
- followUps: flags['follow-ups']
124
- };
125
- const provided = Object.values(fields).some(v => v !== undefined && v !== null);
126
- if (!provided) {
127
- throw new Error('at least one of --summary | --files | --validation | --follow-ups is required');
124
+ const fields = {};
125
+ if (flags.summary !== undefined) fields.summary = flags.summary;
126
+ if (flags.validation !== undefined) fields.validation = flags.validation;
127
+ if (flags.files !== undefined) fields.filesChanged = flags.files;
128
+ if (flags['follow-ups'] !== undefined) fields.followUps = flags['follow-ups'];
129
+ const stages = {};
130
+ if (flags['stage-spec'] !== undefined) stages.spec = flags['stage-spec'];
131
+ if (flags['stage-quality'] !== undefined) stages.quality = flags['stage-quality'];
132
+ if (Object.keys(stages).length > 0) fields.stages = stages;
133
+
134
+ if (Object.keys(fields).length === 0) {
135
+ throw new Error('at least one of --summary | --validation | --files | --follow-ups | --stage-spec | --stage-quality is required');
128
136
  }
129
137
  const file = completeHandoff(session, worker, fields);
130
138
  console.log(relPosix(file));
131
139
  }
132
140
 
133
141
  function cmdStatus(session) {
134
- if (!session) throw new Error('session-name is required');
142
+ if (!session) throw new Error('session name is required');
135
143
  const dir = sessionDir(session);
136
144
  if (!fs.existsSync(dir)) {
137
145
  console.log(`(no such session: ${session})`);
138
146
  return;
139
147
  }
140
- const workers = fs.readdirSync(dir, { withFileTypes: true })
141
- .filter(entry => entry.isDirectory())
142
- .map(entry => entry.name);
143
- const out = workers.map(worker => {
144
- const statusPath = workerArtifacts(session, worker).status;
145
- const content = fs.existsSync(statusPath) ? fs.readFileSync(statusPath, 'utf8') : '(missing)';
146
- const stateLine = content.split('\n').find(line => line.startsWith('- State:'));
147
- return `${worker}\t${stateLine ? stateLine.replace('- State:', '').trim() : '?'}`;
148
+ const plan = buildPlan(session);
149
+ if (plan.workers.length === 0) {
150
+ console.log('(no workers)');
151
+ return;
152
+ }
153
+ const lines = plan.workers.map(w => {
154
+ const lw = w.lightweight ? ' [lightweight]' : '';
155
+ const dep = w.dependsOn.length > 0 ? ` ⟵ ${w.dependsOn.join(',')}` : '';
156
+ return `${w.name}\t${w.state}${lw}${dep}`;
148
157
  });
149
- console.log(out.join('\n') || '(no workers)');
158
+ console.log(lines.join('\n'));
159
+ console.log('');
160
+ console.log(`ready: ${plan.buckets.ready.join(', ') || '∅'}`);
161
+ console.log(`running: ${plan.buckets.running.join(', ') || '∅'}`);
162
+ console.log(`blocked: ${plan.buckets.blocked.join(', ') || '∅'}`);
163
+ console.log(`waiting: ${plan.buckets.waiting.join(', ') || '∅'}`);
164
+ console.log(`completed: ${plan.buckets.completed.join(', ') || '∅'}`);
165
+ if (plan.buckets.failed.length > 0) console.log(`failed: ${plan.buckets.failed.join(', ')}`);
166
+ }
167
+
168
+ function cmdPlan(session) {
169
+ if (!session) throw new Error('session name is required');
170
+ const file = refreshPlan(session);
171
+ console.log(relPosix(file));
172
+ console.log(JSON.stringify(readJson(file), null, 2));
173
+ }
174
+
175
+ function cmdValidate(session) {
176
+ if (!session) throw new Error('session name is required');
177
+ const errors = validateSession(session);
178
+ if (errors.length === 0) {
179
+ console.log(`✔ session '${slugify(session, 'session')}' artifacts conform to schemas/orchestration/*.schema.json`);
180
+ return;
181
+ }
182
+ console.error(`✖ ${errors.length} file(s) failed validation:`);
183
+ for (const e of errors) {
184
+ console.error(` ${e.file}`);
185
+ for (const p of e.problems) console.error(` - ${p}`);
186
+ }
187
+ process.exit(1);
150
188
  }
151
189
 
152
190
  function cmdList() {
@@ -187,6 +225,12 @@ function main() {
187
225
  case 'status':
188
226
  cmdStatus(positional[0]);
189
227
  break;
228
+ case 'plan':
229
+ cmdPlan(positional[0]);
230
+ break;
231
+ case 'validate':
232
+ cmdValidate(positional[0]);
233
+ break;
190
234
  case 'list':
191
235
  cmdList();
192
236
  break;