@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.
@@ -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
+ }