@calltelemetry/openclaw-linear 0.7.0 → 0.8.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +719 -539
  3. package/index.ts +40 -1
  4. package/openclaw.plugin.json +4 -4
  5. package/package.json +2 -1
  6. package/prompts.yaml +19 -5
  7. package/src/__test__/fixtures/linear-responses.ts +75 -0
  8. package/src/__test__/fixtures/webhook-payloads.ts +113 -0
  9. package/src/__test__/helpers.ts +133 -0
  10. package/src/agent/agent.test.ts +143 -0
  11. package/src/api/linear-api.test.ts +586 -0
  12. package/src/api/linear-api.ts +50 -11
  13. package/src/gateway/dispatch-methods.test.ts +409 -0
  14. package/src/gateway/dispatch-methods.ts +243 -0
  15. package/src/infra/cli.ts +273 -30
  16. package/src/infra/codex-worktree.ts +83 -0
  17. package/src/infra/commands.test.ts +276 -0
  18. package/src/infra/commands.ts +156 -0
  19. package/src/infra/doctor.test.ts +19 -0
  20. package/src/infra/doctor.ts +28 -23
  21. package/src/infra/file-lock.test.ts +61 -0
  22. package/src/infra/file-lock.ts +49 -0
  23. package/src/infra/multi-repo.test.ts +163 -0
  24. package/src/infra/multi-repo.ts +114 -0
  25. package/src/infra/notify.test.ts +155 -16
  26. package/src/infra/notify.ts +137 -26
  27. package/src/infra/observability.test.ts +85 -0
  28. package/src/infra/observability.ts +48 -0
  29. package/src/infra/resilience.test.ts +94 -0
  30. package/src/infra/resilience.ts +101 -0
  31. package/src/pipeline/artifacts.test.ts +26 -3
  32. package/src/pipeline/artifacts.ts +38 -2
  33. package/src/pipeline/dag-dispatch.test.ts +553 -0
  34. package/src/pipeline/dag-dispatch.ts +390 -0
  35. package/src/pipeline/dispatch-service.ts +48 -1
  36. package/src/pipeline/dispatch-state.ts +3 -42
  37. package/src/pipeline/e2e-dispatch.test.ts +584 -0
  38. package/src/pipeline/e2e-planning.test.ts +455 -0
  39. package/src/pipeline/pipeline.test.ts +69 -0
  40. package/src/pipeline/pipeline.ts +132 -29
  41. package/src/pipeline/planner.test.ts +1 -1
  42. package/src/pipeline/planner.ts +18 -31
  43. package/src/pipeline/planning-state.ts +2 -40
  44. package/src/pipeline/tier-assess.test.ts +264 -0
  45. package/src/pipeline/webhook.ts +134 -36
  46. package/src/tools/cli-shared.test.ts +155 -0
  47. package/src/tools/code-tool.test.ts +210 -0
  48. package/src/tools/dispatch-history-tool.test.ts +315 -0
  49. package/src/tools/dispatch-history-tool.ts +201 -0
  50. package/src/tools/orchestration-tools.test.ts +158 -0
@@ -34,6 +34,7 @@ import {
34
34
  getActiveDispatch,
35
35
  } from "./dispatch-state.js";
36
36
  import { type NotifyFn } from "../infra/notify.js";
37
+ import { onProjectIssueCompleted, onProjectIssueStuck } from "./dag-dispatch.js";
37
38
  import {
38
39
  saveWorkerOutput,
39
40
  saveAuditVerdict,
@@ -45,6 +46,7 @@ import {
45
46
  resolveOrchestratorWorkspace,
46
47
  } from "./artifacts.js";
47
48
  import { resolveWatchdogConfig } from "../agent/watchdog.js";
49
+ import { emitDiagnostic } from "../infra/observability.js";
48
50
 
49
51
  // ---------------------------------------------------------------------------
50
52
  // Prompt loading
@@ -58,25 +60,40 @@ interface PromptTemplates {
58
60
 
59
61
  const DEFAULT_PROMPTS: PromptTemplates = {
60
62
  worker: {
61
- system: "You are implementing a Linear issue. Post an implementation summary as a Linear comment when done. DO NOT mark the issue as Done.",
62
- task: "Implement issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}",
63
+ system: "You are a coding worker implementing a Linear issue. Your ONLY job is to write code and return a text summary. Do NOT attempt to update, close, comment on, or modify the Linear issue. Do NOT mark the issue as Done.",
64
+ task: "Implement issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}\n\nImplement the solution, run tests, commit your work, and return a text summary.",
63
65
  },
64
66
  audit: {
65
67
  system: "You are an independent auditor. The Linear issue body is the SOURCE OF TRUTH. Worker comments are secondary evidence.",
66
68
  task: 'Audit issue {{identifier}}: {{title}}\n\nIssue body:\n{{description}}\n\nWorktree: {{worktreePath}}\n\nReturn JSON verdict: {"pass": true/false, "criteria": [...], "gaps": [...], "testResults": "..."}',
67
69
  },
68
70
  rework: {
69
- addendum: "PREVIOUS AUDIT FAILED (attempt {{attempt}}). Gaps:\n{{gaps}}\n\nAddress these specific issues.",
71
+ addendum: "PREVIOUS AUDIT FAILED (attempt {{attempt}}). Gaps:\n{{gaps}}\n\nAddress these specific issues. Preserve correct code from prior attempts.",
70
72
  },
71
73
  };
72
74
 
73
- let _cachedPrompts: PromptTemplates | null = null;
75
+ let _cachedGlobalPrompts: PromptTemplates | null = null;
76
+ const _projectPromptCache = new Map<string, PromptTemplates>();
74
77
 
75
- export function loadPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
76
- if (_cachedPrompts) return _cachedPrompts;
78
+ /**
79
+ * Merge two prompt layers. Overlay replaces individual fields per section
80
+ * (shallow section-level merge, not deep).
81
+ */
82
+ function mergePromptLayers(base: PromptTemplates, overlay: Partial<PromptTemplates>): PromptTemplates {
83
+ return {
84
+ worker: { ...base.worker, ...overlay.worker },
85
+ audit: { ...base.audit, ...overlay.audit },
86
+ rework: { ...base.rework, ...overlay.rework },
87
+ };
88
+ }
77
89
 
90
+ /**
91
+ * Load and parse the raw prompts YAML file (global promptsPath or sidecar).
92
+ * Returns the parsed object, or null if no file found.
93
+ * Shared by both pipeline and planner prompt loaders.
94
+ */
95
+ export function loadRawPromptYaml(pluginConfig?: Record<string, unknown>): Record<string, any> | null {
78
96
  try {
79
- // Try custom path first
80
97
  const customPath = pluginConfig?.promptsPath as string | undefined;
81
98
  let raw: string;
82
99
 
@@ -86,27 +103,66 @@ export function loadPrompts(pluginConfig?: Record<string, unknown>): PromptTempl
86
103
  : customPath;
87
104
  raw = readFileSync(resolved, "utf-8");
88
105
  } else {
89
- // Load from plugin directory (sidecar file)
90
106
  const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
91
107
  raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
92
108
  }
93
109
 
94
- const parsed = parseYaml(raw) as Partial<PromptTemplates>;
95
- _cachedPrompts = {
96
- worker: { ...DEFAULT_PROMPTS.worker, ...parsed.worker },
97
- audit: { ...DEFAULT_PROMPTS.audit, ...parsed.audit },
98
- rework: { ...DEFAULT_PROMPTS.rework, ...parsed.rework },
99
- };
110
+ return parseYaml(raw) as Record<string, any>;
100
111
  } catch {
101
- _cachedPrompts = DEFAULT_PROMPTS;
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Load global prompts (layers 1+2: hardcoded defaults + global promptsPath override).
118
+ * Cached after first load.
119
+ */
120
+ function loadGlobalPrompts(pluginConfig?: Record<string, unknown>): PromptTemplates {
121
+ if (_cachedGlobalPrompts) return _cachedGlobalPrompts;
122
+
123
+ const parsed = loadRawPromptYaml(pluginConfig);
124
+ if (parsed) {
125
+ _cachedGlobalPrompts = mergePromptLayers(DEFAULT_PROMPTS, parsed as Partial<PromptTemplates>);
126
+ } else {
127
+ _cachedGlobalPrompts = DEFAULT_PROMPTS;
102
128
  }
103
129
 
104
- return _cachedPrompts;
130
+ return _cachedGlobalPrompts;
131
+ }
132
+
133
+ /**
134
+ * Load prompts with three-layer merge:
135
+ * 1. Built-in defaults (hardcoded DEFAULT_PROMPTS)
136
+ * 2. Global override (promptsPath or sidecar prompts.yaml)
137
+ * 3. Per-project override ({worktreePath}/.claw/prompts.yaml) — optional
138
+ */
139
+ export function loadPrompts(pluginConfig?: Record<string, unknown>, worktreePath?: string): PromptTemplates {
140
+ const global = loadGlobalPrompts(pluginConfig);
141
+
142
+ if (!worktreePath) return global;
143
+
144
+ // Check per-project cache
145
+ const cached = _projectPromptCache.get(worktreePath);
146
+ if (cached) return cached;
147
+
148
+ // Try loading per-project prompts
149
+ try {
150
+ const projectPromptsPath = join(worktreePath, ".claw", "prompts.yaml");
151
+ const raw = readFileSync(projectPromptsPath, "utf-8");
152
+ const parsed = parseYaml(raw) as Partial<PromptTemplates>;
153
+ const merged = mergePromptLayers(global, parsed);
154
+ _projectPromptCache.set(worktreePath, merged);
155
+ return merged;
156
+ } catch {
157
+ // No per-project override — use global
158
+ return global;
159
+ }
105
160
  }
106
161
 
107
162
  /** Clear prompt cache (for testing or after config change) */
108
163
  export function clearPromptCache(): void {
109
- _cachedPrompts = null;
164
+ _cachedGlobalPrompts = null;
165
+ _projectPromptCache.clear();
110
166
  }
111
167
 
112
168
  function renderTemplate(template: string, vars: Record<string, string>): string {
@@ -137,7 +193,7 @@ export function buildWorkerTask(
137
193
  worktreePath: string,
138
194
  opts?: { attempt?: number; gaps?: string[]; pluginConfig?: Record<string, unknown> },
139
195
  ): { system: string; task: string } {
140
- const prompts = loadPrompts(opts?.pluginConfig);
196
+ const prompts = loadPrompts(opts?.pluginConfig, worktreePath);
141
197
  const vars: Record<string, string> = {
142
198
  identifier: issue.identifier,
143
199
  title: issue.title,
@@ -145,7 +201,7 @@ export function buildWorkerTask(
145
201
  worktreePath,
146
202
  tier: "",
147
203
  attempt: String(opts?.attempt ?? 0),
148
- gaps: opts?.gaps?.join("\n- ") ?? "",
204
+ gaps: opts?.gaps?.length ? "- " + opts.gaps.join("\n- ") : "",
149
205
  };
150
206
 
151
207
  let task = renderTemplate(prompts.worker.task, vars);
@@ -167,7 +223,7 @@ export function buildAuditTask(
167
223
  worktreePath: string,
168
224
  pluginConfig?: Record<string, unknown>,
169
225
  ): { system: string; task: string } {
170
- const prompts = loadPrompts(pluginConfig);
226
+ const prompts = loadPrompts(pluginConfig, worktreePath);
171
227
  const vars: Record<string, string> = {
172
228
  identifier: issue.identifier,
173
229
  title: issue.title,
@@ -274,6 +330,7 @@ export async function triggerAudit(
274
330
  }
275
331
 
276
332
  api.logger.info(`${TAG} worker completed, triggering audit (attempt ${dispatch.attempt})`);
333
+ emitDiagnostic(api, { event: "phase_transition", identifier: dispatch.issueIdentifier, from: "working", to: "auditing", attempt: dispatch.attempt });
277
334
 
278
335
  // Update .claw/ manifest
279
336
  try { updateManifest(dispatch.worktreePath, { status: "auditing", attempts: dispatch.attempt }); } catch {}
@@ -288,7 +345,12 @@ export async function triggerAudit(
288
345
  };
289
346
 
290
347
  // Build audit prompt from YAML templates
291
- const auditPrompt = buildAuditTask(issue, dispatch.worktreePath, pluginConfig);
348
+ // For multi-repo dispatches, render worktreePath as a list of repo→path mappings
349
+ const effectiveAuditPath = dispatch.worktrees
350
+ ? dispatch.worktrees.map(w => `${w.repoName}: ${w.path}`).join("\n")
351
+ : dispatch.worktreePath;
352
+
353
+ const auditPrompt = buildAuditTask(issue, effectiveAuditPath, pluginConfig);
292
354
 
293
355
  // Set Linear label
294
356
  await linearApi.emitActivity(dispatch.agentSessionId ?? "", {
@@ -400,6 +462,11 @@ export async function processVerdict(
400
462
  const verdict = parseVerdict(auditOutput);
401
463
  if (!verdict) {
402
464
  api.logger.warn(`${TAG} could not parse audit verdict from output (${auditOutput.length} chars)`);
465
+ // Post comment so user knows what happened
466
+ await linearApi.createComment(
467
+ dispatch.issueId,
468
+ `## Audit Inconclusive\n\nThe auditor's response couldn't be parsed as a verdict. **Retrying automatically** — this usually resolves itself.\n\n**If it keeps happening:** \`openclaw openclaw-linear prompts validate\`\n\n**Status:** Retrying audit now. No action needed.`,
469
+ ).catch((err) => api.logger.error(`${TAG} failed to post inconclusive comment: ${err}`));
403
470
  // Treat unparseable verdict as failure
404
471
  await handleAuditFail(hookCtx, dispatch, {
405
472
  pass: false,
@@ -464,7 +531,14 @@ async function handleAuditPass(
464
531
  if (summary) {
465
532
  writeSummary(dispatch.worktreePath, summary);
466
533
  const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
467
- writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir);
534
+ writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir, {
535
+ title: dispatch.issueTitle ?? dispatch.issueIdentifier,
536
+ tier: dispatch.tier,
537
+ status: "done",
538
+ project: dispatch.project,
539
+ attempts: dispatch.attempt + 1,
540
+ model: dispatch.model,
541
+ });
468
542
  api.logger.info(`${TAG} .claw/ summary and memory written`);
469
543
  }
470
544
  } catch (err) {
@@ -476,10 +550,11 @@ async function handleAuditPass(
476
550
  const summaryExcerpt = summary ? `\n\n**Summary:**\n${summary.slice(0, 2000)}` : "";
477
551
  await linearApi.createComment(
478
552
  dispatch.issueId,
479
- `## Audit Passed\n\n**Criteria verified:**\n${criteriaList}\n\n**Tests:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Attempt ${dispatch.attempt + 1} audit passed. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
553
+ `## Done\n\nThis issue has been implemented and verified.\n\n**What was checked:**\n${criteriaList}\n\n**Test results:** ${verdict.testResults || "N/A"}${summaryExcerpt}\n\n---\n*Completed on attempt ${dispatch.attempt + 1}.*\n\n**Next steps:**\n- Review the code: \`cd ${dispatch.worktreePath}\`\n- View artifacts: \`ls ${dispatch.worktreePath}/.claw/\`\n- Create a PR from the worktree branch if one wasn't opened automatically`,
480
554
  ).catch((err) => api.logger.error(`${TAG} failed to post audit pass comment: ${err}`));
481
555
 
482
556
  api.logger.info(`${TAG} audit PASSED — dispatch completed (attempt ${dispatch.attempt})`);
557
+ emitDiagnostic(api, { event: "verdict_processed", identifier: dispatch.issueIdentifier, phase: "done", attempt: dispatch.attempt });
483
558
 
484
559
  await notify("audit_pass", {
485
560
  identifier: dispatch.issueIdentifier,
@@ -489,6 +564,12 @@ async function handleAuditPass(
489
564
  verdict: { pass: true, gaps: [] },
490
565
  });
491
566
 
567
+ // DAG cascade: if this issue belongs to a project dispatch, check for newly unblocked issues
568
+ if (dispatch.project) {
569
+ void onProjectIssueCompleted(hookCtx, dispatch.project, dispatch.issueIdentifier)
570
+ .catch((err) => api.logger.error(`${TAG} DAG cascade error: ${err}`));
571
+ }
572
+
492
573
  clearActiveSession(dispatch.issueId);
493
574
  }
494
575
 
@@ -531,17 +612,25 @@ async function handleAuditFail(
531
612
  if (summary) {
532
613
  writeSummary(dispatch.worktreePath, summary);
533
614
  const wsDir = resolveOrchestratorWorkspace(api, pluginConfig);
534
- writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir);
615
+ writeDispatchMemory(dispatch.issueIdentifier, summary, wsDir, {
616
+ title: dispatch.issueTitle ?? dispatch.issueIdentifier,
617
+ tier: dispatch.tier,
618
+ status: "stuck",
619
+ project: dispatch.project,
620
+ attempts: nextAttempt,
621
+ model: dispatch.model,
622
+ });
535
623
  }
536
624
  } catch {}
537
625
 
538
626
  const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
539
627
  await linearApi.createComment(
540
628
  dispatch.issueId,
541
- `## Audit Failed — Escalating\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}**\n\n**Gaps:**\n${gapsList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Max rework attempts reached. Needs human review. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
629
+ `## Needs Your Help\n\nAll ${nextAttempt} attempts failed. The agent couldn't resolve these issues on its own.\n\n**What went wrong:**\n${gapsList}\n\n**Test results:** ${verdict.testResults || "N/A"}\n\n---\n\n**What you can do:**\n1. **Clarify requirements** — update the issue body with more detail, then re-assign to try again\n2. **Fix it manually** — the agent's work is in the worktree: \`cd ${dispatch.worktreePath}\`\n3. **Force retry** — \`/dispatch retry ${dispatch.issueIdentifier}\`\n4. **View logs** — worker output: \`.claw/worker-*.md\`, audit verdicts: \`.claw/audit-*.json\``,
542
630
  ).catch((err) => api.logger.error(`${TAG} failed to post escalation comment: ${err}`));
543
631
 
544
632
  api.logger.warn(`${TAG} audit FAILED ${nextAttempt}x — escalating to human`);
633
+ emitDiagnostic(api, { event: "verdict_processed", identifier: dispatch.issueIdentifier, phase: "stuck", attempt: nextAttempt });
545
634
 
546
635
  await notify("escalation", {
547
636
  identifier: dispatch.issueIdentifier,
@@ -552,6 +641,12 @@ async function handleAuditFail(
552
641
  verdict: { pass: false, gaps: verdict.gaps },
553
642
  });
554
643
 
644
+ // DAG cascade: mark this issue as stuck in the project dispatch
645
+ if (dispatch.project) {
646
+ void onProjectIssueStuck(hookCtx, dispatch.project, dispatch.issueIdentifier)
647
+ .catch((err) => api.logger.error(`${TAG} DAG stuck cascade error: ${err}`));
648
+ }
649
+
555
650
  return;
556
651
  }
557
652
 
@@ -575,10 +670,11 @@ async function handleAuditFail(
575
670
  const gapsList = verdict.gaps.map((g) => `- ${g}`).join("\n");
576
671
  await linearApi.createComment(
577
672
  dispatch.issueId,
578
- `## Audit FailedRework\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}**\n\n**Gaps:**\n${gapsList}\n\n**Tests:** ${verdict.testResults || "N/A"}\n\n---\n*Reworking: addressing gaps above.*`,
673
+ `## Needs More Work\n\nThe audit found gaps. **Retrying now** the worker gets the feedback below as context.\n\n**Attempt ${nextAttempt} of ${maxAttempts + 1}** — ${maxAttempts + 1 - nextAttempt > 0 ? `${maxAttempts + 1 - nextAttempt} more ${maxAttempts + 1 - nextAttempt === 1 ? "retry" : "retries"} if this fails too` : "this is the last attempt"}.\n\n**What needs fixing:**\n${gapsList}\n\n**Test results:** ${verdict.testResults || "N/A"}\n\n**Status:** Worker is restarting with the gaps above as context. No action needed unless all retries fail.`,
579
674
  ).catch((err) => api.logger.error(`${TAG} failed to post rework comment: ${err}`));
580
675
 
581
676
  api.logger.info(`${TAG} audit FAILED — rework attempt ${nextAttempt}/${maxAttempts + 1}`);
677
+ emitDiagnostic(api, { event: "phase_transition", identifier: dispatch.issueIdentifier, from: "auditing", to: "working", attempt: nextAttempt });
582
678
 
583
679
  await notify("audit_fail", {
584
680
  identifier: dispatch.issueIdentifier,
@@ -643,7 +739,12 @@ export async function spawnWorker(
643
739
  };
644
740
 
645
741
  // Build worker prompt from YAML templates
646
- const workerPrompt = buildWorkerTask(issue, dispatch.worktreePath, {
742
+ // For multi-repo dispatches, render worktreePath as a list of repo→path mappings
743
+ const effectiveWorkerPath = dispatch.worktrees
744
+ ? dispatch.worktrees.map(w => `${w.repoName}: ${w.path}`).join("\n")
745
+ : dispatch.worktreePath;
746
+
747
+ const workerPrompt = buildWorkerTask(issue, effectiveWorkerPath, {
647
748
  attempt: dispatch.attempt,
648
749
  gaps: opts?.gaps,
649
750
  pluginConfig,
@@ -698,6 +799,7 @@ export async function spawnWorker(
698
799
  const thresholdSec = Math.round(wdConfig.inactivityMs / 1000);
699
800
 
700
801
  api.logger.warn(`${TAG} worker killed by inactivity watchdog 2x — escalating to stuck`);
802
+ emitDiagnostic(api, { event: "watchdog_kill", identifier: dispatch.issueIdentifier, attempt: dispatch.attempt });
701
803
 
702
804
  try {
703
805
  appendLog(dispatch.worktreePath, {
@@ -724,8 +826,9 @@ export async function spawnWorker(
724
826
 
725
827
  await linearApi.createComment(
726
828
  dispatch.issueId,
727
- `## Watchdog Kill\n\nAgent killed by inactivity watchdog (no I/O for ${thresholdSec}s). ` +
728
- `Automatic retry also failed.\n\n---\n*Needs human review. Artifacts: \`${dispatch.worktreePath}/.claw/\`*`,
829
+ `## Agent Timed Out\n\nThe agent stopped responding for over ${thresholdSec}s. It was automatically restarted, but the retry also timed out.\n\n` +
830
+ `**What you can do:**\n1. **Try again** re-assign this issue or \`/dispatch retry ${dispatch.issueIdentifier}\`\n2. **Break it down** — if it keeps timing out, split into smaller issues\n3. **Increase timeout** — set \`inactivitySec\` higher in your agent profile\n\n` +
831
+ `**Logs:** \`${dispatch.worktreePath}/.claw/log.jsonl\` (look for \`"phase": "watchdog"\`)\n\n**Current status:** Stuck — waiting for you.`,
729
832
  ).catch(() => {});
730
833
 
731
834
  await hookCtx.notify("watchdog_kill", {
@@ -251,7 +251,7 @@ describe("handlePlannerTurn", () => {
251
251
 
252
252
  await handlePlannerTurn(ctx, session, {
253
253
  issueId: "issue-1",
254
- commentBody: "abandon",
254
+ commentBody: "abandon planning",
255
255
  commentorName: "Tester",
256
256
  });
257
257
 
@@ -6,11 +6,8 @@
6
6
  * - handlePlannerTurn: processes each user comment during planning
7
7
  * - runPlanAudit: validates the plan before finalizing
8
8
  */
9
- import { readFileSync } from "node:fs";
10
- import { join, dirname } from "node:path";
11
- import { fileURLToPath } from "node:url";
12
- import { parse as parseYaml } from "yaml";
13
9
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
10
+ import { loadRawPromptYaml } from "./pipeline.js";
14
11
  import type { LinearAgentApi } from "../api/linear-api.js";
15
12
  import { runAgent } from "../agent/agent.js";
16
13
  import {
@@ -56,30 +53,15 @@ function loadPlannerPrompts(pluginConfig?: Record<string, unknown>): PlannerProm
56
53
  welcome: "Entering planning mode for **{{projectName}}**. What are the main feature areas?",
57
54
  };
58
55
 
59
- try {
60
- const customPath = pluginConfig?.promptsPath as string | undefined;
61
- let raw: string;
62
-
63
- if (customPath) {
64
- const resolved = customPath.startsWith("~")
65
- ? customPath.replace("~", process.env.HOME ?? "")
66
- : customPath;
67
- raw = readFileSync(resolved, "utf-8");
68
- } else {
69
- const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
70
- raw = readFileSync(join(pluginRoot, "prompts.yaml"), "utf-8");
71
- }
72
-
73
- const parsed = parseYaml(raw) as any;
74
- if (parsed?.planner) {
75
- return {
76
- system: parsed.planner.system ?? defaults.system,
77
- interview: parsed.planner.interview ?? defaults.interview,
78
- audit_prompt: parsed.planner.audit_prompt ?? defaults.audit_prompt,
79
- welcome: parsed.planner.welcome ?? defaults.welcome,
80
- };
81
- }
82
- } catch { /* use defaults */ }
56
+ const parsed = loadRawPromptYaml(pluginConfig);
57
+ if (parsed?.planner) {
58
+ return {
59
+ system: parsed.planner.system ?? defaults.system,
60
+ interview: parsed.planner.interview ?? defaults.interview,
61
+ audit_prompt: parsed.planner.audit_prompt ?? defaults.audit_prompt,
62
+ welcome: parsed.planner.welcome ?? defaults.welcome,
63
+ };
64
+ }
83
65
 
84
66
  return defaults;
85
67
  }
@@ -142,20 +124,21 @@ export async function initiatePlanningSession(
142
124
  // Interview turn
143
125
  // ---------------------------------------------------------------------------
144
126
 
145
- const FINALIZE_PATTERN = /\b(finalize\s+plan|finalize|done\s+planning|approve\s+plan|plan\s+looks\s+good)\b/i;
146
- const ABANDON_PATTERN = /\b(abandon|cancel\s+planning|stop\s+planning|exit\s+planning)\b/i;
127
+ const FINALIZE_PATTERN = /\b(finalize\s+(the\s+)?plan\b|done\s+planning\b(?!\s+\w)|approve\s+(the\s+)?plan\b|plan\s+looks\s+good\b|ready\s+to\s+finalize\b|let'?s\s+finalize\b)/i;
128
+ const ABANDON_PATTERN = /\b(abandon\s+plan(ning)?|cancel\s+plan(ning)?|stop\s+planning|exit\s+planning|quit\s+planning)\b/i;
147
129
 
148
130
  export async function handlePlannerTurn(
149
131
  ctx: PlannerContext,
150
132
  session: PlanningSession,
151
133
  input: { issueId: string; commentBody: string; commentorName: string },
134
+ opts?: { onApproved?: (projectId: string) => void },
152
135
  ): Promise<void> {
153
136
  const { api, linearApi, pluginConfig } = ctx;
154
137
  const configPath = pluginConfig?.planningStatePath as string | undefined;
155
138
 
156
139
  // Detect finalization intent
157
140
  if (FINALIZE_PATTERN.test(input.commentBody)) {
158
- await runPlanAudit(ctx, session);
141
+ await runPlanAudit(ctx, session, { onApproved: opts?.onApproved });
159
142
  return;
160
143
  }
161
144
 
@@ -234,6 +217,7 @@ export async function handlePlannerTurn(
234
217
  export async function runPlanAudit(
235
218
  ctx: PlannerContext,
236
219
  session: PlanningSession,
220
+ opts?: { onApproved?: (projectId: string) => void },
237
221
  ): Promise<void> {
238
222
  const { api, linearApi, pluginConfig } = ctx;
239
223
  const configPath = pluginConfig?.planningStatePath as string | undefined;
@@ -262,6 +246,9 @@ export async function runPlanAudit(
262
246
 
263
247
  await endPlanningSession(session.projectId, "approved", configPath);
264
248
  api.logger.info(`Planning: session approved for ${session.projectName}`);
249
+
250
+ // Trigger DAG-based dispatch if callback provided
251
+ opts?.onApproved?.(session.projectId);
265
252
  } else {
266
253
  // Post problems and keep planning
267
254
  const problemsList = result.problems.map((p) => `- ${p}`).join("\n");
@@ -48,48 +48,10 @@ function resolveStatePath(configPath?: string): string {
48
48
  }
49
49
 
50
50
  // ---------------------------------------------------------------------------
51
- // File locking
51
+ // File locking (shared utility)
52
52
  // ---------------------------------------------------------------------------
53
53
 
54
- const LOCK_STALE_MS = 30_000;
55
- const LOCK_RETRY_MS = 50;
56
- const LOCK_TIMEOUT_MS = 10_000;
57
-
58
- function lockPath(statePath: string): string {
59
- return statePath + ".lock";
60
- }
61
-
62
- async function acquireLock(statePath: string): Promise<void> {
63
- const lock = lockPath(statePath);
64
- const deadline = Date.now() + LOCK_TIMEOUT_MS;
65
-
66
- while (Date.now() < deadline) {
67
- try {
68
- await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
69
- return;
70
- } catch (err: any) {
71
- if (err.code !== "EEXIST") throw err;
72
-
73
- try {
74
- const content = await fs.readFile(lock, "utf-8");
75
- const lockTime = Number(content);
76
- if (Date.now() - lockTime > LOCK_STALE_MS) {
77
- try { await fs.unlink(lock); } catch { /* race */ }
78
- continue;
79
- }
80
- } catch { /* lock disappeared — retry */ }
81
-
82
- await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
83
- }
84
- }
85
-
86
- try { await fs.unlink(lockPath(statePath)); } catch { /* ignore */ }
87
- await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
88
- }
89
-
90
- async function releaseLock(statePath: string): Promise<void> {
91
- try { await fs.unlink(lockPath(statePath)); } catch { /* already removed */ }
92
- }
54
+ import { acquireLock, releaseLock } from "../infra/file-lock.js";
93
55
 
94
56
  // ---------------------------------------------------------------------------
95
57
  // Read / Write