@agentic-coding-framework/orchestrator-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +199 -0
- package/dist/dispatch.d.ts +192 -0
- package/dist/dispatch.js +729 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +47 -0
- package/dist/rules.d.ts +65 -0
- package/dist/rules.js +366 -0
- package/dist/state.d.ts +81 -0
- package/dist/state.js +160 -0
- package/package.json +41 -0
package/dist/dispatch.js
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* dispatch.ts — Orchestrator State Machine + Prompt Builder + Handoff Parser
|
|
4
|
+
*
|
|
5
|
+
* The only file with logic. Combines state.ts (read/write) and rules.ts (lookup)
|
|
6
|
+
* to drive the micro-waterfall pipeline. All decisions are deterministic —
|
|
7
|
+
* zero LLM tokens.
|
|
8
|
+
*
|
|
9
|
+
* Main entry point: dispatch(projectRoot)
|
|
10
|
+
*/
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.dispatch = dispatch;
|
|
13
|
+
exports.buildPrompt = buildPrompt;
|
|
14
|
+
exports.parseHandoff = parseHandoff;
|
|
15
|
+
exports.applyHandoff = applyHandoff;
|
|
16
|
+
exports.runPostCheck = runPostCheck;
|
|
17
|
+
exports.approveReview = approveReview;
|
|
18
|
+
exports.rejectReview = rejectReview;
|
|
19
|
+
exports.startStory = startStory;
|
|
20
|
+
exports.detectFramework = detectFramework;
|
|
21
|
+
exports.queryProjectStatus = queryProjectStatus;
|
|
22
|
+
exports.listProjects = listProjects;
|
|
23
|
+
exports.startCustom = startCustom;
|
|
24
|
+
const fs_1 = require("fs");
|
|
25
|
+
const path_1 = require("path");
|
|
26
|
+
const state_1 = require("./state");
|
|
27
|
+
const rules_1 = require("./rules");
|
|
28
|
+
// ─── Main Dispatch Function ──────────────────────────────────────────────────
|
|
29
|
+
/**
|
|
30
|
+
* Core dispatch logic — direct translation of Protocol's dispatch(project).
|
|
31
|
+
*
|
|
32
|
+
* Reads STATE.json, applies the step rules table, and returns a DispatchResult
|
|
33
|
+
* telling the caller what to do next. The caller is responsible for actually
|
|
34
|
+
* invoking the executor (e.g., spawning a Claude Code session).
|
|
35
|
+
*
|
|
36
|
+
* This function updates STATE.json as a side effect (marks running, advances
|
|
37
|
+
* steps, etc.).
|
|
38
|
+
*/
|
|
39
|
+
function dispatch(projectRoot) {
|
|
40
|
+
const state = (0, state_1.readState)(projectRoot);
|
|
41
|
+
// ── Story complete ──
|
|
42
|
+
if (state.step === "done") {
|
|
43
|
+
return {
|
|
44
|
+
type: "done",
|
|
45
|
+
story: state.story ?? "(no story)",
|
|
46
|
+
summary: `Story ${state.story} completed.`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const rule = (0, rules_1.getRule)(state.step);
|
|
50
|
+
// ── Timeout check ──
|
|
51
|
+
if (state.status === "running") {
|
|
52
|
+
if ((0, state_1.isTimedOut)(state)) {
|
|
53
|
+
state.status = "timeout";
|
|
54
|
+
state.completed_at = new Date().toISOString();
|
|
55
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
56
|
+
const elapsed = elapsedMinutes(state.dispatched_at);
|
|
57
|
+
return {
|
|
58
|
+
type: "timeout",
|
|
59
|
+
step: state.step,
|
|
60
|
+
elapsed_min: elapsed,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
// Still running
|
|
64
|
+
return {
|
|
65
|
+
type: "already_running",
|
|
66
|
+
step: state.step,
|
|
67
|
+
elapsed_min: elapsedMinutes(state.dispatched_at),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// ── Requires human (review checkpoint) ──
|
|
71
|
+
if (rule.requires_human && state.status !== "pass") {
|
|
72
|
+
if (state.status !== "needs_human") {
|
|
73
|
+
state.status = "needs_human";
|
|
74
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
type: "needs_human",
|
|
78
|
+
step: state.step,
|
|
79
|
+
message: formatReviewRequest(state),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ── Success → advance to next step ──
|
|
83
|
+
if (state.status === "pass") {
|
|
84
|
+
state.step = rule.next_on_pass;
|
|
85
|
+
state.attempt = 1;
|
|
86
|
+
state.status = "pending";
|
|
87
|
+
state.reason = null;
|
|
88
|
+
state.human_note = null;
|
|
89
|
+
state.tests = null;
|
|
90
|
+
state.failing_tests = [];
|
|
91
|
+
state.lint_pass = null;
|
|
92
|
+
state.files_changed = [];
|
|
93
|
+
// Check if we just reached "done"
|
|
94
|
+
if (state.step === "done") {
|
|
95
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
96
|
+
return {
|
|
97
|
+
type: "done",
|
|
98
|
+
story: state.story ?? "(no story)",
|
|
99
|
+
summary: `Story ${state.story} completed. All steps passed.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// Recurse: the new step might also require human
|
|
103
|
+
const newRule = (0, rules_1.getRule)(state.step);
|
|
104
|
+
if (newRule.requires_human) {
|
|
105
|
+
state.status = "needs_human";
|
|
106
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
107
|
+
return {
|
|
108
|
+
type: "needs_human",
|
|
109
|
+
step: state.step,
|
|
110
|
+
message: formatReviewRequest(state),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Update max_attempts and timeout from new step's rule
|
|
114
|
+
state.max_attempts = newRule.max_attempts;
|
|
115
|
+
state.timeout_min = newRule.timeout_min;
|
|
116
|
+
}
|
|
117
|
+
// ── Failure → reason-based routing or retry ──
|
|
118
|
+
if (state.status === "failing") {
|
|
119
|
+
if ((0, state_1.isMaxedOut)(state)) {
|
|
120
|
+
state.status = "needs_human";
|
|
121
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
122
|
+
return {
|
|
123
|
+
type: "blocked",
|
|
124
|
+
step: state.step,
|
|
125
|
+
reason: `Max attempts (${state.max_attempts}) exhausted at step "${state.step}". ` +
|
|
126
|
+
(state.reason ? `Last reason: ${state.reason}` : "No specific reason."),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const target = (0, rules_1.getFailTarget)(state.step, state.reason);
|
|
130
|
+
if (target !== state.step) {
|
|
131
|
+
// Route to different step
|
|
132
|
+
state.step = target;
|
|
133
|
+
state.attempt = 1;
|
|
134
|
+
const targetRule = (0, rules_1.getRule)(target);
|
|
135
|
+
state.max_attempts = targetRule.max_attempts;
|
|
136
|
+
state.timeout_min = targetRule.timeout_min;
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// Retry same step
|
|
140
|
+
state.attempt++;
|
|
141
|
+
}
|
|
142
|
+
state.status = "pending";
|
|
143
|
+
}
|
|
144
|
+
// ── Dispatch executor ──
|
|
145
|
+
const currentRule = (0, rules_1.getRule)(state.step);
|
|
146
|
+
const prompt = buildPrompt(state, currentRule);
|
|
147
|
+
const running = (0, state_1.markRunning)(state);
|
|
148
|
+
(0, state_1.writeState)(projectRoot, running);
|
|
149
|
+
// Detect framework adoption level so caller knows the context richness
|
|
150
|
+
const framework = detectFramework(projectRoot);
|
|
151
|
+
return {
|
|
152
|
+
type: "dispatched",
|
|
153
|
+
step: running.step,
|
|
154
|
+
attempt: running.attempt,
|
|
155
|
+
prompt,
|
|
156
|
+
fw_lv: framework.level,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
// ─── Prompt Builder ──────────────────────────────────────────────────────────
|
|
160
|
+
/**
|
|
161
|
+
* Build the dispatch prompt from the template.
|
|
162
|
+
* Pure template filling — zero LLM reasoning.
|
|
163
|
+
*/
|
|
164
|
+
function buildPrompt(state, rule) {
|
|
165
|
+
const storyId = state.story ?? "BOOTSTRAP";
|
|
166
|
+
const reads = (0, rules_1.resolvePaths)(rule.claude_reads, storyId);
|
|
167
|
+
const lines = [];
|
|
168
|
+
// Header
|
|
169
|
+
lines.push(`You are executing step "${rule.display_name}" for ${storyId}.`);
|
|
170
|
+
if (state.attempt > 1) {
|
|
171
|
+
lines.push(`(Attempt ${state.attempt} of ${state.max_attempts})`);
|
|
172
|
+
}
|
|
173
|
+
lines.push("");
|
|
174
|
+
// Files to read
|
|
175
|
+
if (reads.length > 0) {
|
|
176
|
+
lines.push("Please read the following files in order:");
|
|
177
|
+
for (const file of reads) {
|
|
178
|
+
lines.push(`- ${file}`);
|
|
179
|
+
}
|
|
180
|
+
lines.push("");
|
|
181
|
+
}
|
|
182
|
+
// Human note
|
|
183
|
+
if (state.human_note) {
|
|
184
|
+
lines.push("=== Human Instruction ===");
|
|
185
|
+
lines.push(state.human_note);
|
|
186
|
+
lines.push("==========================");
|
|
187
|
+
lines.push("");
|
|
188
|
+
}
|
|
189
|
+
// Previous failure context
|
|
190
|
+
if (state.attempt > 1 && state.failing_tests.length > 0) {
|
|
191
|
+
lines.push("Previous attempt had these failing tests:");
|
|
192
|
+
for (const t of state.failing_tests) {
|
|
193
|
+
lines.push(`- ${t}`);
|
|
194
|
+
}
|
|
195
|
+
lines.push("");
|
|
196
|
+
}
|
|
197
|
+
// Inject test results for update-memory step (replaces STATE.json reading)
|
|
198
|
+
if (state.step === "update-memory" && state.tests) {
|
|
199
|
+
lines.push("Test results from this Story:");
|
|
200
|
+
lines.push(`- Pass: ${state.tests.pass}, Fail: ${state.tests.fail}, Skip: ${state.tests.skip}`);
|
|
201
|
+
if (state.files_changed.length > 0) {
|
|
202
|
+
lines.push(`- Files changed: ${state.files_changed.join(", ")}`);
|
|
203
|
+
}
|
|
204
|
+
lines.push("");
|
|
205
|
+
}
|
|
206
|
+
// Step instruction
|
|
207
|
+
lines.push(rule.step_instruction);
|
|
208
|
+
lines.push("");
|
|
209
|
+
// Output rules (always appended)
|
|
210
|
+
lines.push("Output rules:");
|
|
211
|
+
lines.push("- Only modify affected files and paragraphs, don't rewrite unrelated content");
|
|
212
|
+
lines.push("- After completion, update .ai/HANDOFF.md:");
|
|
213
|
+
lines.push(" - YAML front matter: fill in story, step, attempt, status, reason, files_changed, tests values");
|
|
214
|
+
lines.push(" - Markdown body: record what was done, what's unresolved, what next session should note");
|
|
215
|
+
lines.push("- If requirements unclear, fill reason field with needs_clarification");
|
|
216
|
+
lines.push("- If Constitution violation found, fill reason field with constitution_violation");
|
|
217
|
+
lines.push("- If touching Non-Goals scope, fill reason field with scope_warning");
|
|
218
|
+
return lines.join("\n");
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Parse HANDOFF.md — prioritize YAML front matter, fallback to grep.
|
|
222
|
+
* Direct translation of Protocol's hook pseudocode.
|
|
223
|
+
*/
|
|
224
|
+
function parseHandoff(projectRoot) {
|
|
225
|
+
const handoffPath = (0, path_1.join)(projectRoot, ".ai", "HANDOFF.md");
|
|
226
|
+
if (!(0, fs_1.existsSync)(handoffPath))
|
|
227
|
+
return null;
|
|
228
|
+
const content = (0, fs_1.readFileSync)(handoffPath, "utf-8");
|
|
229
|
+
const lines = content.split("\n");
|
|
230
|
+
// Check for YAML front matter
|
|
231
|
+
if (lines[0]?.trim() === "---") {
|
|
232
|
+
return parseYamlFrontMatter(content);
|
|
233
|
+
}
|
|
234
|
+
// Fallback: grep markdown body for reason keywords
|
|
235
|
+
return parseFallback(content);
|
|
236
|
+
}
|
|
237
|
+
/** Parse YAML front matter format (hybrid HANDOFF.md) */
|
|
238
|
+
function parseYamlFrontMatter(content) {
|
|
239
|
+
const parts = content.split("---");
|
|
240
|
+
// parts[0] = "" (before first ---), parts[1] = YAML, parts[2+] = body
|
|
241
|
+
const yamlBlock = parts[1] ?? "";
|
|
242
|
+
const body = parts.slice(2).join("---").trim();
|
|
243
|
+
const yaml = parseSimpleYaml(yamlBlock);
|
|
244
|
+
return {
|
|
245
|
+
story: yaml["story"] ?? null,
|
|
246
|
+
step: yaml["step"] ?? null,
|
|
247
|
+
attempt: yaml["attempt"] ? parseInt(yaml["attempt"], 10) : null,
|
|
248
|
+
status: yaml["status"] ?? null,
|
|
249
|
+
reason: (yaml["reason"] || null),
|
|
250
|
+
files_changed: parseYamlList(yaml["files_changed"]),
|
|
251
|
+
tests_pass: yaml["tests_pass"] ? parseInt(yaml["tests_pass"], 10) : null,
|
|
252
|
+
tests_fail: yaml["tests_fail"] ? parseInt(yaml["tests_fail"], 10) : null,
|
|
253
|
+
tests_skip: yaml["tests_skip"] ? parseInt(yaml["tests_skip"], 10) : null,
|
|
254
|
+
body,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/** Fallback parser: grep for reason keywords in markdown body */
|
|
258
|
+
function parseFallback(content) {
|
|
259
|
+
const reasonMap = {
|
|
260
|
+
"NEEDS CLARIFICATION": "needs_clarification",
|
|
261
|
+
"CONSTITUTION VIOLATION": "constitution_violation",
|
|
262
|
+
"SCOPE WARNING": "scope_warning",
|
|
263
|
+
};
|
|
264
|
+
let reason = null;
|
|
265
|
+
for (const [keyword, code] of Object.entries(reasonMap)) {
|
|
266
|
+
if (content.includes(keyword)) {
|
|
267
|
+
reason = code;
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
story: null,
|
|
273
|
+
step: null,
|
|
274
|
+
attempt: null,
|
|
275
|
+
status: reason ? "failing" : "pass",
|
|
276
|
+
reason,
|
|
277
|
+
files_changed: [],
|
|
278
|
+
tests_pass: null,
|
|
279
|
+
tests_fail: null,
|
|
280
|
+
tests_skip: null,
|
|
281
|
+
body: content,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Minimal YAML parser for flat key: value pairs.
|
|
286
|
+
* Handles simple scalars and inline lists. NOT a full YAML parser —
|
|
287
|
+
* just enough for HANDOFF.md front matter.
|
|
288
|
+
*/
|
|
289
|
+
function parseSimpleYaml(block) {
|
|
290
|
+
const result = {};
|
|
291
|
+
let currentKey = null;
|
|
292
|
+
let listValues = [];
|
|
293
|
+
for (const line of block.split("\n")) {
|
|
294
|
+
const trimmed = line.trim();
|
|
295
|
+
if (!trimmed)
|
|
296
|
+
continue;
|
|
297
|
+
// List item (indented "- value")
|
|
298
|
+
if (trimmed.startsWith("- ") && currentKey) {
|
|
299
|
+
listValues.push(trimmed.slice(2).trim());
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
// Flush previous list
|
|
303
|
+
if (currentKey && listValues.length > 0) {
|
|
304
|
+
result[currentKey] = JSON.stringify(listValues);
|
|
305
|
+
listValues = [];
|
|
306
|
+
}
|
|
307
|
+
// Key: value pair
|
|
308
|
+
const colonIdx = trimmed.indexOf(":");
|
|
309
|
+
if (colonIdx > 0) {
|
|
310
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
311
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
312
|
+
currentKey = key;
|
|
313
|
+
if (value) {
|
|
314
|
+
// "null" → empty
|
|
315
|
+
result[key] = value === "null" ? "" : value;
|
|
316
|
+
currentKey = null; // not expecting list items
|
|
317
|
+
}
|
|
318
|
+
// else: value on next lines (list)
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
// Flush trailing list
|
|
322
|
+
if (currentKey && listValues.length > 0) {
|
|
323
|
+
result[currentKey] = JSON.stringify(listValues);
|
|
324
|
+
}
|
|
325
|
+
return result;
|
|
326
|
+
}
|
|
327
|
+
/** Parse a YAML list value (either JSON-encoded string[] or empty) */
|
|
328
|
+
function parseYamlList(value) {
|
|
329
|
+
if (!value)
|
|
330
|
+
return [];
|
|
331
|
+
try {
|
|
332
|
+
const parsed = JSON.parse(value);
|
|
333
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return value ? [value] : [];
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
// ─── Post-Hook: Apply HANDOFF Results to STATE ───────────────────────────────
|
|
340
|
+
/**
|
|
341
|
+
* After executor exits, read HANDOFF.md and update STATE.json accordingly.
|
|
342
|
+
* This is the TypeScript equivalent of the Protocol's post-execution hook.
|
|
343
|
+
*
|
|
344
|
+
* Call this after the executor process exits, before the next dispatch().
|
|
345
|
+
*/
|
|
346
|
+
function applyHandoff(projectRoot) {
|
|
347
|
+
const state = (0, state_1.readState)(projectRoot);
|
|
348
|
+
const handoff = parseHandoff(projectRoot);
|
|
349
|
+
if (!handoff) {
|
|
350
|
+
// No HANDOFF.md — executor might have crashed
|
|
351
|
+
state.status = "failing";
|
|
352
|
+
state.reason = null;
|
|
353
|
+
state.completed_at = new Date().toISOString();
|
|
354
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
355
|
+
return state;
|
|
356
|
+
}
|
|
357
|
+
// Apply structured fields from HANDOFF
|
|
358
|
+
if (handoff.status) {
|
|
359
|
+
state.status = handoff.status;
|
|
360
|
+
}
|
|
361
|
+
else {
|
|
362
|
+
// Infer: if tests_fail > 0, it's failing
|
|
363
|
+
state.status =
|
|
364
|
+
handoff.tests_fail && handoff.tests_fail > 0 ? "failing" : "pass";
|
|
365
|
+
}
|
|
366
|
+
state.reason = handoff.reason;
|
|
367
|
+
state.completed_at = new Date().toISOString();
|
|
368
|
+
if (handoff.files_changed.length > 0) {
|
|
369
|
+
state.files_changed = handoff.files_changed;
|
|
370
|
+
}
|
|
371
|
+
if (handoff.tests_pass !== null ||
|
|
372
|
+
handoff.tests_fail !== null ||
|
|
373
|
+
handoff.tests_skip !== null) {
|
|
374
|
+
state.tests = {
|
|
375
|
+
pass: handoff.tests_pass ?? 0,
|
|
376
|
+
fail: handoff.tests_fail ?? 0,
|
|
377
|
+
skip: handoff.tests_skip ?? 0,
|
|
378
|
+
};
|
|
379
|
+
state.failing_tests = []; // could extract from body if needed
|
|
380
|
+
}
|
|
381
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
382
|
+
return state;
|
|
383
|
+
}
|
|
384
|
+
// ─── Post-Check Runner ───────────────────────────────────────────────────────
|
|
385
|
+
/**
|
|
386
|
+
* Run the post_check command for the current step.
|
|
387
|
+
* Returns true if check passed (or no check defined), false if failed.
|
|
388
|
+
*
|
|
389
|
+
* This is a synchronous shell execution — zero LLM tokens.
|
|
390
|
+
*/
|
|
391
|
+
function runPostCheck(projectRoot, execSync) {
|
|
392
|
+
const state = (0, state_1.readState)(projectRoot);
|
|
393
|
+
const rule = (0, rules_1.getRule)(state.step);
|
|
394
|
+
if (!rule.post_check)
|
|
395
|
+
return true;
|
|
396
|
+
try {
|
|
397
|
+
execSync(rule.post_check, {
|
|
398
|
+
cwd: projectRoot,
|
|
399
|
+
stdio: "pipe",
|
|
400
|
+
timeout: 60_000,
|
|
401
|
+
});
|
|
402
|
+
state.lint_pass = true;
|
|
403
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
state.lint_pass = false;
|
|
408
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
// ─── Human Review Approval ───────────────────────────────────────────────────
|
|
413
|
+
/**
|
|
414
|
+
* Mark the review step as approved by human.
|
|
415
|
+
* Optionally attach a human note (modification requests, clarifications).
|
|
416
|
+
*/
|
|
417
|
+
function approveReview(projectRoot, humanNote) {
|
|
418
|
+
const state = (0, state_1.readState)(projectRoot);
|
|
419
|
+
if (state.step !== "review") {
|
|
420
|
+
throw new Error(`Cannot approve review: current step is "${state.step}", not "review"`);
|
|
421
|
+
}
|
|
422
|
+
state.status = "pass";
|
|
423
|
+
state.human_note = humanNote ?? null;
|
|
424
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Reject the review with a reason and optional note.
|
|
428
|
+
*/
|
|
429
|
+
function rejectReview(projectRoot, reason, humanNote) {
|
|
430
|
+
const state = (0, state_1.readState)(projectRoot);
|
|
431
|
+
if (state.step !== "review") {
|
|
432
|
+
throw new Error(`Cannot reject review: current step is "${state.step}", not "review"`);
|
|
433
|
+
}
|
|
434
|
+
state.status = "failing";
|
|
435
|
+
state.reason = reason;
|
|
436
|
+
state.human_note = humanNote ?? null;
|
|
437
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
438
|
+
}
|
|
439
|
+
// ─── Auto-Init Helper ────────────────────────────────────────────────────────
|
|
440
|
+
/**
|
|
441
|
+
* Ensure STATE.json exists. If not, auto-initialize it.
|
|
442
|
+
* This allows startStory() and startCustom() to work on ANY project,
|
|
443
|
+
* even if the Agentic Coding Framework hasn't been set up yet.
|
|
444
|
+
*
|
|
445
|
+
* Project name is inferred from: package.json name → go.mod module →
|
|
446
|
+
* directory name (in that order).
|
|
447
|
+
*/
|
|
448
|
+
function ensureState(projectRoot) {
|
|
449
|
+
const path = (0, path_1.join)(projectRoot, ".ai", "STATE.json");
|
|
450
|
+
if ((0, fs_1.existsSync)(path)) {
|
|
451
|
+
return (0, state_1.readState)(projectRoot);
|
|
452
|
+
}
|
|
453
|
+
// Infer project name
|
|
454
|
+
let projectName = projectRoot.split("/").filter(Boolean).pop() ?? "project";
|
|
455
|
+
const pkgPath = (0, path_1.join)(projectRoot, "package.json");
|
|
456
|
+
if ((0, fs_1.existsSync)(pkgPath)) {
|
|
457
|
+
try {
|
|
458
|
+
const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, "utf-8"));
|
|
459
|
+
if (pkg.name)
|
|
460
|
+
projectName = pkg.name;
|
|
461
|
+
}
|
|
462
|
+
catch { /* ignore */ }
|
|
463
|
+
}
|
|
464
|
+
const goModPath = (0, path_1.join)(projectRoot, "go.mod");
|
|
465
|
+
if ((0, fs_1.existsSync)(goModPath)) {
|
|
466
|
+
try {
|
|
467
|
+
const goMod = (0, fs_1.readFileSync)(goModPath, "utf-8");
|
|
468
|
+
const moduleLine = goMod.split("\n").find((l) => l.startsWith("module "));
|
|
469
|
+
if (moduleLine) {
|
|
470
|
+
const parts = moduleLine.replace("module ", "").trim().split("/");
|
|
471
|
+
projectName = parts[parts.length - 1] ?? projectName;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
catch { /* ignore */ }
|
|
475
|
+
}
|
|
476
|
+
const { state } = (0, state_1.initState)(projectRoot, projectName);
|
|
477
|
+
return state;
|
|
478
|
+
}
|
|
479
|
+
// ─── Start New Story ─────────────────────────────────────────────────────────
|
|
480
|
+
/**
|
|
481
|
+
* Begin a new User Story. Resets state to bdd step with attempt 1.
|
|
482
|
+
* Auto-initializes STATE.json if the project hasn't adopted the framework yet.
|
|
483
|
+
*/
|
|
484
|
+
function startStory(projectRoot, storyId) {
|
|
485
|
+
const state = ensureState(projectRoot);
|
|
486
|
+
const rule = (0, rules_1.getRule)("bdd");
|
|
487
|
+
state.story = storyId;
|
|
488
|
+
state.step = "bdd";
|
|
489
|
+
state.attempt = 1;
|
|
490
|
+
state.max_attempts = rule.max_attempts;
|
|
491
|
+
state.status = "pending";
|
|
492
|
+
state.reason = null;
|
|
493
|
+
state.dispatched_at = null;
|
|
494
|
+
state.completed_at = null;
|
|
495
|
+
state.timeout_min = rule.timeout_min;
|
|
496
|
+
state.tests = null;
|
|
497
|
+
state.failing_tests = [];
|
|
498
|
+
state.lint_pass = null;
|
|
499
|
+
state.files_changed = [];
|
|
500
|
+
state.blocked_by = [];
|
|
501
|
+
state.human_note = null;
|
|
502
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
503
|
+
return state;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Detect whether a project uses the Agentic Coding Framework.
|
|
507
|
+
* OpenClaw calls this when the user asks "is this project using the framework?"
|
|
508
|
+
*/
|
|
509
|
+
function detectFramework(projectRoot) {
|
|
510
|
+
const check = (p) => (0, fs_1.existsSync)((0, path_1.join)(projectRoot, p));
|
|
511
|
+
const has_state = check(".ai/STATE.json");
|
|
512
|
+
const has_memory = check("PROJECT_MEMORY.md");
|
|
513
|
+
const has_context = check("PROJECT_CONTEXT.md");
|
|
514
|
+
const has_constitution = check("docs/constitution.md");
|
|
515
|
+
const has_sdd = check("docs/sdd.md");
|
|
516
|
+
const has_handoff = check(".ai/HANDOFF.md");
|
|
517
|
+
const has_history = check(".ai/history.md");
|
|
518
|
+
const core = [has_state, has_memory, has_context, has_constitution, has_sdd];
|
|
519
|
+
const coreCount = core.filter(Boolean).length;
|
|
520
|
+
let level = 0;
|
|
521
|
+
if (coreCount === core.length)
|
|
522
|
+
level = 2;
|
|
523
|
+
else if (coreCount > 0)
|
|
524
|
+
level = 1;
|
|
525
|
+
return {
|
|
526
|
+
has_state, has_memory, has_context, has_constitution,
|
|
527
|
+
has_sdd, has_handoff, has_history, level,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Get comprehensive project status for OpenClaw to summarize to the user.
|
|
532
|
+
* Works for ANY project — with or without the Agentic Coding Framework.
|
|
533
|
+
*
|
|
534
|
+
* - Framework project (has STATE.json): returns full state + memory summary
|
|
535
|
+
* - Non-framework project: returns framework detection + whatever files exist
|
|
536
|
+
*/
|
|
537
|
+
function queryProjectStatus(projectRoot) {
|
|
538
|
+
const framework = detectFramework(projectRoot);
|
|
539
|
+
// Read MEMORY summary if it exists
|
|
540
|
+
let memory_summary = null;
|
|
541
|
+
const memoryPath = (0, path_1.join)(projectRoot, "PROJECT_MEMORY.md");
|
|
542
|
+
if ((0, fs_1.existsSync)(memoryPath)) {
|
|
543
|
+
const content = (0, fs_1.readFileSync)(memoryPath, "utf-8");
|
|
544
|
+
const nextMatch = content.match(/## NEXT[\s\S]*?(?=## |$)/);
|
|
545
|
+
if (nextMatch) {
|
|
546
|
+
memory_summary = nextMatch[0].trim().slice(0, 500);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// If no STATE.json, return a minimal status with framework detection
|
|
550
|
+
if (!framework.has_state) {
|
|
551
|
+
// Try to infer project name from package.json or directory name
|
|
552
|
+
let project = "(not initialized)";
|
|
553
|
+
const pkgPath = (0, path_1.join)(projectRoot, "package.json");
|
|
554
|
+
if ((0, fs_1.existsSync)(pkgPath)) {
|
|
555
|
+
try {
|
|
556
|
+
const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, "utf-8"));
|
|
557
|
+
project = pkg.name ?? project;
|
|
558
|
+
}
|
|
559
|
+
catch { /* ignore */ }
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
project,
|
|
563
|
+
task_type: "unknown",
|
|
564
|
+
story: null,
|
|
565
|
+
step: "none",
|
|
566
|
+
status: "not_initialized",
|
|
567
|
+
attempt: 0,
|
|
568
|
+
max_attempts: 0,
|
|
569
|
+
reason: null,
|
|
570
|
+
tests: null,
|
|
571
|
+
lint_pass: null,
|
|
572
|
+
files_changed: [],
|
|
573
|
+
blocked_by: [],
|
|
574
|
+
human_note: null,
|
|
575
|
+
memory_summary,
|
|
576
|
+
has_framework: framework,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
// Framework project: read full state
|
|
580
|
+
const state = (0, state_1.readState)(projectRoot);
|
|
581
|
+
return {
|
|
582
|
+
project: state.project,
|
|
583
|
+
task_type: state.task_type,
|
|
584
|
+
story: state.story,
|
|
585
|
+
step: state.step,
|
|
586
|
+
status: state.status,
|
|
587
|
+
attempt: state.attempt,
|
|
588
|
+
max_attempts: state.max_attempts,
|
|
589
|
+
reason: state.reason,
|
|
590
|
+
tests: state.tests,
|
|
591
|
+
lint_pass: state.lint_pass,
|
|
592
|
+
files_changed: state.files_changed,
|
|
593
|
+
blocked_by: state.blocked_by,
|
|
594
|
+
human_note: state.human_note,
|
|
595
|
+
memory_summary,
|
|
596
|
+
has_framework: framework,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Scan a workspace directory for all projects — both framework and non-framework.
|
|
601
|
+
* OpenClaw calls this when the user asks "list my projects" or "switch project".
|
|
602
|
+
*
|
|
603
|
+
* A directory is considered a "project" if it contains any of:
|
|
604
|
+
* - .ai/STATE.json (framework project)
|
|
605
|
+
* - package.json (Node.js project)
|
|
606
|
+
* - go.mod (Go project)
|
|
607
|
+
* - Cargo.toml (Rust project)
|
|
608
|
+
* - pyproject.toml or setup.py (Python project)
|
|
609
|
+
* - .git/ (any git repo)
|
|
610
|
+
*/
|
|
611
|
+
function listProjects(workspaceRoot) {
|
|
612
|
+
const { readdirSync, statSync } = require("fs");
|
|
613
|
+
const results = [];
|
|
614
|
+
const PROJECT_MARKERS = [
|
|
615
|
+
".ai/STATE.json",
|
|
616
|
+
"package.json",
|
|
617
|
+
"go.mod",
|
|
618
|
+
"Cargo.toml",
|
|
619
|
+
"pyproject.toml",
|
|
620
|
+
"setup.py",
|
|
621
|
+
".git",
|
|
622
|
+
];
|
|
623
|
+
let entries;
|
|
624
|
+
try {
|
|
625
|
+
entries = readdirSync(workspaceRoot);
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return results;
|
|
629
|
+
}
|
|
630
|
+
for (const entry of entries) {
|
|
631
|
+
// Skip hidden directories (except .git check is internal)
|
|
632
|
+
if (entry.startsWith("."))
|
|
633
|
+
continue;
|
|
634
|
+
const dir = (0, path_1.join)(workspaceRoot, entry);
|
|
635
|
+
try {
|
|
636
|
+
if (!statSync(dir).isDirectory())
|
|
637
|
+
continue;
|
|
638
|
+
// Check if this directory is a project
|
|
639
|
+
const isProject = PROJECT_MARKERS.some((marker) => (0, fs_1.existsSync)((0, path_1.join)(dir, marker)));
|
|
640
|
+
if (!isProject)
|
|
641
|
+
continue;
|
|
642
|
+
const stateFile = (0, path_1.join)(dir, ".ai", "STATE.json");
|
|
643
|
+
const hasFramework = (0, fs_1.existsSync)(stateFile);
|
|
644
|
+
if (hasFramework) {
|
|
645
|
+
// Framework project: read state
|
|
646
|
+
const state = JSON.parse((0, fs_1.readFileSync)(stateFile, "utf-8"));
|
|
647
|
+
results.push({
|
|
648
|
+
name: state.project,
|
|
649
|
+
dir: entry,
|
|
650
|
+
step: state.step,
|
|
651
|
+
status: state.status,
|
|
652
|
+
story: state.story,
|
|
653
|
+
has_framework: true,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
// Non-framework project: infer name from package.json or dir name
|
|
658
|
+
let name = entry;
|
|
659
|
+
const pkgPath = (0, path_1.join)(dir, "package.json");
|
|
660
|
+
if ((0, fs_1.existsSync)(pkgPath)) {
|
|
661
|
+
try {
|
|
662
|
+
const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, "utf-8"));
|
|
663
|
+
name = pkg.name ?? entry;
|
|
664
|
+
}
|
|
665
|
+
catch { /* ignore */ }
|
|
666
|
+
}
|
|
667
|
+
results.push({
|
|
668
|
+
name,
|
|
669
|
+
dir: entry,
|
|
670
|
+
step: "none",
|
|
671
|
+
status: "not_initialized",
|
|
672
|
+
story: null,
|
|
673
|
+
has_framework: false,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
// skip unreadable directories
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return results;
|
|
682
|
+
}
|
|
683
|
+
// ─── Start Custom Task ──────────────────────────────────────────────────────
|
|
684
|
+
/**
|
|
685
|
+
* Begin a custom (ad-hoc) task. The orchestrator forwards the instruction
|
|
686
|
+
* to Claude Code with full project context, without going through the
|
|
687
|
+
* micro-waterfall pipeline.
|
|
688
|
+
*
|
|
689
|
+
* Pipeline: custom → update-memory → done
|
|
690
|
+
* Auto-initializes STATE.json if the project hasn't adopted the framework yet.
|
|
691
|
+
*
|
|
692
|
+
* Use cases: refactoring, code review, bug fix, DevOps, documentation,
|
|
693
|
+
* testing, migration, performance optimization, security, cleanup, etc.
|
|
694
|
+
*/
|
|
695
|
+
function startCustom(projectRoot, instruction, label) {
|
|
696
|
+
const state = ensureState(projectRoot);
|
|
697
|
+
const rule = (0, rules_1.getRule)("custom");
|
|
698
|
+
state.story = label ?? `CUSTOM-${Date.now()}`;
|
|
699
|
+
state.step = "custom";
|
|
700
|
+
state.attempt = 1;
|
|
701
|
+
state.max_attempts = rule.max_attempts;
|
|
702
|
+
state.status = "pending";
|
|
703
|
+
state.reason = null;
|
|
704
|
+
state.dispatched_at = null;
|
|
705
|
+
state.completed_at = null;
|
|
706
|
+
state.timeout_min = rule.timeout_min;
|
|
707
|
+
state.tests = null;
|
|
708
|
+
state.failing_tests = [];
|
|
709
|
+
state.lint_pass = null;
|
|
710
|
+
state.files_changed = [];
|
|
711
|
+
state.blocked_by = [];
|
|
712
|
+
state.human_note = instruction;
|
|
713
|
+
state.task_type = "custom";
|
|
714
|
+
(0, state_1.writeState)(projectRoot, state);
|
|
715
|
+
return state;
|
|
716
|
+
}
|
|
717
|
+
// ─── Internal Helpers ────────────────────────────────────────────────────────
|
|
718
|
+
function elapsedMinutes(isoTimestamp) {
|
|
719
|
+
return Math.round((Date.now() - new Date(isoTimestamp).getTime()) / 60_000);
|
|
720
|
+
}
|
|
721
|
+
function formatReviewRequest(state) {
|
|
722
|
+
const storyId = state.story ?? "(no story)";
|
|
723
|
+
return (`Story ${storyId} is ready for review.\n` +
|
|
724
|
+
`Please check:\n` +
|
|
725
|
+
`- docs/bdd/${storyId}.md (BDD scenarios)\n` +
|
|
726
|
+
`- docs/deltas/${storyId}.md (Delta Spec)\n` +
|
|
727
|
+
`- docs/api/openapi.yaml (contract changes)\n\n` +
|
|
728
|
+
`Reply "approved" to continue, or provide feedback.`);
|
|
729
|
+
}
|