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.
- package/docs/workflow-pipeline.md +8 -7
- package/package.json +1 -1
- package/scripts/hooks/checks/orchestration-artifacts.cjs +28 -23
- package/scripts/hooks/checks/workflow-state.cjs +4 -5
- package/scripts/orchestration/lib/orchestrator.cjs +344 -117
- package/scripts/orchestration/lib/validate.cjs +145 -0
- package/scripts/orchestration/session.cjs +88 -44
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# 工作流 Phase 心智模型
|
|
2
2
|
|
|
3
3
|
> 8 个 phase 是**完整路径上限**,不是强制路径——主会话按任务复杂度和风险**裁剪**。
|
|
4
|
-
> 状态真相源:`.orchestration/<session>/<worker>/status.
|
|
5
|
-
> **完结契约**:worker 转 `completed` 前必须调用 `session.cjs complete` 回写 handoff
|
|
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.
|
|
27
|
-
| 4 | 任务拆解 | `writing-plans` | `planner` | `planner/handoff.
|
|
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.
|
|
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.
|
|
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.
|
|
73
|
+
**自动决策**(不打断用户):phase 间流转、handoff 文件生成、`status.json` 推进、worktree 路径分配、`session.cjs` 状态写入、phase 内的子 agent 派遣。
|
|
73
74
|
|
|
74
75
|
## Session 存在条件
|
|
75
76
|
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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.
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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)}/ 缺失 ${
|
|
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 =
|
|
50
|
-
if (
|
|
51
|
-
const
|
|
52
|
-
if (!
|
|
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(
|
|
55
|
-
'
|
|
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.
|
|
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
|
|
16
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
44
|
-
handoff: path.join(dir, 'handoff.
|
|
45
|
-
status: path.join(dir, 'status.
|
|
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
|
|
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,
|
|
81
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
56
82
|
}
|
|
57
83
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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(
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
];
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 =
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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.
|
|
29
|
+
' node scripts/orchestration/session.cjs init <session>',
|
|
25
30
|
' node scripts/orchestration/session.cjs add-worker <session> <worker> --agent <name> --objective <text>',
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
' node scripts/orchestration/session.
|
|
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
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
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 = {
|
|
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
|
|
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
|
|
69
|
-
const dir =
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
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
|
|
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
|
|
141
|
-
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
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(
|
|
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;
|