@opencode_weave/weave 0.6.4 → 0.7.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 (30) hide show
  1. package/dist/config/schema.d.ts +9 -0
  2. package/dist/features/analytics/adherence.d.ts +10 -0
  3. package/dist/features/analytics/format-metrics.d.ts +10 -0
  4. package/dist/features/analytics/generate-metrics-report.d.ts +17 -0
  5. package/dist/features/analytics/git-diff.d.ts +7 -0
  6. package/dist/features/analytics/index.d.ts +13 -6
  7. package/dist/features/analytics/plan-parser.d.ts +7 -0
  8. package/dist/features/analytics/plan-token-aggregator.d.ts +11 -0
  9. package/dist/features/analytics/session-tracker.d.ts +20 -0
  10. package/dist/features/analytics/storage.d.ts +13 -1
  11. package/dist/features/analytics/token-report.d.ts +14 -0
  12. package/dist/features/analytics/types.d.ts +89 -1
  13. package/dist/features/builtin-commands/templates/metrics.d.ts +1 -0
  14. package/dist/features/builtin-commands/templates/run-workflow.d.ts +1 -0
  15. package/dist/features/builtin-commands/types.d.ts +1 -1
  16. package/dist/features/workflow/commands.d.ts +17 -0
  17. package/dist/features/workflow/completion.d.ts +31 -0
  18. package/dist/features/workflow/constants.d.ts +12 -0
  19. package/dist/features/workflow/context.d.ts +16 -0
  20. package/dist/features/workflow/discovery.d.ts +19 -0
  21. package/dist/features/workflow/engine.d.ts +49 -0
  22. package/dist/features/workflow/hook.d.ts +47 -0
  23. package/dist/features/workflow/index.d.ts +15 -0
  24. package/dist/features/workflow/schema.d.ts +118 -0
  25. package/dist/features/workflow/storage.d.ts +51 -0
  26. package/dist/features/workflow/types.d.ts +142 -0
  27. package/dist/hooks/create-hooks.d.ts +6 -0
  28. package/dist/index.js +2206 -428
  29. package/dist/plugin/types.d.ts +1 -1
  30. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
- import { join as join10 } from "path";
2
+ import { join as join13 } from "path";
3
3
 
4
4
  // src/config/loader.ts
5
- import { existsSync, readFileSync } from "node:fs";
5
+ import { existsSync as existsSync2, readFileSync } from "node:fs";
6
6
  import { join as join2 } from "node:path";
7
- import { homedir } from "node:os";
7
+ import { homedir as homedir2 } from "node:os";
8
8
  import { parse } from "jsonc-parser";
9
9
 
10
10
  // src/config/schema.ts
@@ -77,7 +77,11 @@ var CustomAgentConfigSchema = z.object({
77
77
  });
78
78
  var CustomAgentsConfigSchema = z.record(z.string(), CustomAgentConfigSchema);
79
79
  var AnalyticsConfigSchema = z.object({
80
- enabled: z.boolean().optional()
80
+ enabled: z.boolean().optional(),
81
+ use_fingerprint: z.boolean().optional()
82
+ });
83
+ var WorkflowConfigSchema = z.object({
84
+ disabled_workflows: z.array(z.string()).optional()
81
85
  });
82
86
  var WeaveConfigSchema = z.object({
83
87
  $schema: z.string().optional(),
@@ -91,7 +95,8 @@ var WeaveConfigSchema = z.object({
91
95
  background: BackgroundConfigSchema.optional(),
92
96
  analytics: AnalyticsConfigSchema.optional(),
93
97
  tmux: TmuxConfigSchema.optional(),
94
- experimental: ExperimentalConfigSchema.optional()
98
+ experimental: ExperimentalConfigSchema.optional(),
99
+ workflows: WorkflowConfigSchema.optional()
95
100
  });
96
101
 
97
102
  // src/config/merge.ts
@@ -134,7 +139,20 @@ function mergeConfigs(user, project) {
134
139
  import * as fs from "fs";
135
140
  import * as path from "path";
136
141
  import * as os from "os";
137
- var LOG_FILE = path.join(os.tmpdir(), "weave-opencode.log");
142
+ function getLogDir() {
143
+ const home = os.homedir();
144
+ return path.join(home, ".opencode", "logs");
145
+ }
146
+ function resolveLogFile() {
147
+ const dir = getLogDir();
148
+ try {
149
+ if (!fs.existsSync(dir)) {
150
+ fs.mkdirSync(dir, { recursive: true });
151
+ }
152
+ } catch {}
153
+ return path.join(dir, "weave-opencode.log");
154
+ }
155
+ var LOG_FILE = resolveLogFile();
138
156
  function log(message, data) {
139
157
  try {
140
158
  const timestamp = new Date().toISOString();
@@ -170,15 +188,15 @@ function readJsoncFile(filePath) {
170
188
  }
171
189
  function detectConfigFile(basePath) {
172
190
  const jsoncPath = basePath + ".jsonc";
173
- if (existsSync(jsoncPath))
191
+ if (existsSync2(jsoncPath))
174
192
  return jsoncPath;
175
193
  const jsonPath = basePath + ".json";
176
- if (existsSync(jsonPath))
194
+ if (existsSync2(jsonPath))
177
195
  return jsonPath;
178
196
  return null;
179
197
  }
180
198
  function loadWeaveConfig(directory, _ctx, _homeDir) {
181
- const userBasePath = join2(_homeDir ?? homedir(), ".config", "opencode", "weave-opencode");
199
+ const userBasePath = join2(_homeDir ?? homedir2(), ".config", "opencode", "weave-opencode");
182
200
  const projectBasePath = join2(directory, ".opencode", "weave-opencode");
183
201
  const userConfigPath = detectConfigFile(userBasePath);
184
202
  const projectConfigPath = detectConfigFile(projectBasePath);
@@ -267,6 +285,38 @@ For each unchecked \`- [ ]\` task in the plan:
267
285
  - Do NOT stop until all checkboxes are checked or you are explicitly told to stop
268
286
  - After all tasks are complete, report a final summary`;
269
287
 
288
+ // src/features/builtin-commands/templates/metrics.ts
289
+ var METRICS_TEMPLATE = `You are being activated by the /metrics command to present Weave analytics data to the user.
290
+
291
+ ## Your Mission
292
+ Present the injected metrics data in a clear, readable format. The data has already been loaded and formatted by the command hook — simply relay it to the user.
293
+
294
+ ## Instructions
295
+
296
+ 1. **Read the injected context below** — it contains pre-formatted metrics markdown
297
+ 2. **Present it to the user** as-is — do NOT re-fetch or recalculate anything
298
+ 3. **Answer follow-up questions** about the data if the user asks
299
+ 4. If the data indicates analytics is disabled or no data exists, relay that message directly`;
300
+
301
+ // src/features/builtin-commands/templates/run-workflow.ts
302
+ var RUN_WORKFLOW_TEMPLATE = `You are being activated by the /run-workflow command to execute a multi-step workflow.
303
+
304
+ ## Your Mission
305
+ The workflow engine will inject context below with:
306
+ - The workflow definition to use
307
+ - The user's goal for this workflow instance
308
+ - The current step and its prompt
309
+ - Context from any previously completed steps
310
+
311
+ Follow the injected step prompt. When the step is complete, the workflow engine will
312
+ automatically advance you to the next step.
313
+
314
+ ## Rules
315
+ - Focus on the current step's task only
316
+ - Signal completion clearly (the workflow engine detects it)
317
+ - Do NOT skip ahead to future steps
318
+ - If you need user input, ask for it and wait`;
319
+
270
320
  // src/features/builtin-commands/commands.ts
271
321
  var BUILTIN_COMMANDS = {
272
322
  "start-work": {
@@ -279,6 +329,36 @@ ${START_WORK_TEMPLATE}
279
329
  <session-context>Session ID: $SESSION_ID Timestamp: $TIMESTAMP</session-context>
280
330
  <user-request>$ARGUMENTS</user-request>`,
281
331
  argumentHint: "[plan-name]"
332
+ },
333
+ "token-report": {
334
+ name: "token-report",
335
+ description: "Show token usage and cost report across sessions",
336
+ agent: "loom",
337
+ template: `<command-instruction>
338
+ Display the token usage report that has been injected below. Present it clearly to the user.
339
+ </command-instruction>
340
+ <token-report>$ARGUMENTS</token-report>`
341
+ },
342
+ metrics: {
343
+ name: "metrics",
344
+ description: "Show Weave analytics and plan metrics reports",
345
+ agent: "loom",
346
+ template: `<command-instruction>
347
+ ${METRICS_TEMPLATE}
348
+ </command-instruction>
349
+ <metrics-data>$ARGUMENTS</metrics-data>`,
350
+ argumentHint: "[plan-name|all]"
351
+ },
352
+ "run-workflow": {
353
+ name: "run-workflow",
354
+ description: "Run a multi-step workflow",
355
+ agent: "loom",
356
+ template: `<command-instruction>
357
+ ${RUN_WORKFLOW_TEMPLATE}
358
+ </command-instruction>
359
+ <session-context>Session ID: $SESSION_ID Timestamp: $TIMESTAMP</session-context>
360
+ <user-request>$ARGUMENTS</user-request>`,
361
+ argumentHint: '<workflow-name> ["goal"]'
282
362
  }
283
363
  };
284
364
  // src/managers/config-handler.ts
@@ -608,7 +688,12 @@ FORMAT RULES:
608
688
  - Max 5 visible todos at any time
609
689
  - in_progress = yellow highlight — use for ACTIVE work only
610
690
  - Prefix delegations with agent name
611
- - After all work done: mark everything completed (sidebar hides)
691
+
692
+ BEFORE FINISHING (MANDATORY):
693
+ - ALWAYS issue a final todowrite before your last response
694
+ - Mark ALL in_progress items → "completed" (or "cancelled")
695
+ - Never leave in_progress items when done
696
+ - This is NON-NEGOTIABLE — skipping it breaks the UI
612
697
  </SidebarTodos>`;
613
698
  }
614
699
  function buildDelegationSection(disabled) {
@@ -899,6 +984,12 @@ FORMAT RULES:
899
984
  - Summary todo always present during execution
900
985
  - Max 5 visible todos (1 summary + 1 in_progress + 2-3 pending)
901
986
  - in_progress = yellow highlight — use for CURRENT task only
987
+
988
+ BEFORE FINISHING (MANDATORY):
989
+ - ALWAYS issue a final todowrite before your last response
990
+ - Mark ALL in_progress items → "completed" (or "cancelled")
991
+ - Never leave in_progress items when done
992
+ - This is NON-NEGOTIABLE — skipping it breaks the UI
902
993
  </SidebarTodos>`;
903
994
  }
904
995
  function buildTapestryPlanExecutionSection(disabled = new Set) {
@@ -1768,18 +1859,18 @@ function createBuiltinAgents(options = {}) {
1768
1859
  }
1769
1860
 
1770
1861
  // src/agents/prompt-loader.ts
1771
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
1772
- import { resolve, isAbsolute, normalize } from "path";
1862
+ import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
1863
+ import { resolve, isAbsolute, normalize, sep } from "path";
1773
1864
  function loadPromptFile(promptFilePath, basePath) {
1774
1865
  if (isAbsolute(promptFilePath)) {
1775
1866
  return null;
1776
1867
  }
1777
1868
  const base = resolve(basePath ?? process.cwd());
1778
1869
  const resolvedPath = normalize(resolve(base, promptFilePath));
1779
- if (!resolvedPath.startsWith(base + "/") && resolvedPath !== base) {
1870
+ if (!resolvedPath.startsWith(base + sep) && resolvedPath !== base) {
1780
1871
  return null;
1781
1872
  }
1782
- if (!existsSync2(resolvedPath)) {
1873
+ if (!existsSync3(resolvedPath)) {
1783
1874
  return null;
1784
1875
  }
1785
1876
  return readFileSync2(resolvedPath, "utf-8").trim();
@@ -2359,7 +2450,7 @@ var WORK_STATE_FILE = "state.json";
2359
2450
  var WORK_STATE_PATH = `${WEAVE_DIR}/${WORK_STATE_FILE}`;
2360
2451
  var PLANS_DIR = `${WEAVE_DIR}/plans`;
2361
2452
  // src/features/work-state/storage.ts
2362
- import { existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync, unlinkSync, mkdirSync, readdirSync as readdirSync2, statSync } from "fs";
2453
+ import { existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync, unlinkSync, mkdirSync as mkdirSync2, readdirSync as readdirSync2, statSync } from "fs";
2363
2454
  import { join as join6, basename } from "path";
2364
2455
  import { execSync } from "child_process";
2365
2456
  var UNCHECKED_RE = /^[-*]\s*\[\s*\]/gm;
@@ -2367,7 +2458,7 @@ var CHECKED_RE = /^[-*]\s*\[[xX]\]/gm;
2367
2458
  function readWorkState(directory) {
2368
2459
  const filePath = join6(directory, WEAVE_DIR, WORK_STATE_FILE);
2369
2460
  try {
2370
- if (!existsSync6(filePath))
2461
+ if (!existsSync7(filePath))
2371
2462
  return null;
2372
2463
  const raw = readFileSync5(filePath, "utf-8");
2373
2464
  const parsed = JSON.parse(raw);
@@ -2386,8 +2477,8 @@ function readWorkState(directory) {
2386
2477
  function writeWorkState(directory, state) {
2387
2478
  try {
2388
2479
  const dir = join6(directory, WEAVE_DIR);
2389
- if (!existsSync6(dir)) {
2390
- mkdirSync(dir, { recursive: true });
2480
+ if (!existsSync7(dir)) {
2481
+ mkdirSync2(dir, { recursive: true });
2391
2482
  }
2392
2483
  writeFileSync(join6(dir, WORK_STATE_FILE), JSON.stringify(state, null, 2), "utf-8");
2393
2484
  return true;
@@ -2398,7 +2489,7 @@ function writeWorkState(directory, state) {
2398
2489
  function clearWorkState(directory) {
2399
2490
  const filePath = join6(directory, WEAVE_DIR, WORK_STATE_FILE);
2400
2491
  try {
2401
- if (existsSync6(filePath)) {
2492
+ if (existsSync7(filePath)) {
2402
2493
  unlinkSync(filePath);
2403
2494
  }
2404
2495
  return true;
@@ -2442,7 +2533,7 @@ function getHeadSha(directory) {
2442
2533
  function findPlans(directory) {
2443
2534
  const plansDir = join6(directory, PLANS_DIR);
2444
2535
  try {
2445
- if (!existsSync6(plansDir))
2536
+ if (!existsSync7(plansDir))
2446
2537
  return [];
2447
2538
  const files = readdirSync2(plansDir).filter((f) => f.endsWith(".md")).map((f) => {
2448
2539
  const fullPath = join6(plansDir, f);
@@ -2455,7 +2546,7 @@ function findPlans(directory) {
2455
2546
  }
2456
2547
  }
2457
2548
  function getPlanProgress(planPath) {
2458
- if (!existsSync6(planPath)) {
2549
+ if (!existsSync7(planPath)) {
2459
2550
  return { total: 0, completed: 0, isComplete: true };
2460
2551
  }
2461
2552
  try {
@@ -2491,14 +2582,14 @@ function resumeWork(directory) {
2491
2582
  return writeWorkState(directory, state);
2492
2583
  }
2493
2584
  // src/features/work-state/validation.ts
2494
- import { readFileSync as readFileSync6, existsSync as existsSync7 } from "fs";
2495
- import { resolve as resolve3, sep } from "path";
2585
+ import { readFileSync as readFileSync6, existsSync as existsSync8 } from "fs";
2586
+ import { resolve as resolve3, sep as sep2 } from "path";
2496
2587
  function validatePlan(planPath, projectDir) {
2497
2588
  const errors = [];
2498
2589
  const warnings = [];
2499
2590
  const resolvedPlanPath = resolve3(planPath);
2500
2591
  const allowedDir = resolve3(projectDir, PLANS_DIR);
2501
- if (!resolvedPlanPath.startsWith(allowedDir + sep) && resolvedPlanPath !== allowedDir) {
2592
+ if (!resolvedPlanPath.startsWith(allowedDir + sep2) && resolvedPlanPath !== allowedDir) {
2502
2593
  errors.push({
2503
2594
  severity: "error",
2504
2595
  category: "structure",
@@ -2506,7 +2597,7 @@ function validatePlan(planPath, projectDir) {
2506
2597
  });
2507
2598
  return { valid: false, errors, warnings };
2508
2599
  }
2509
- if (!existsSync7(resolvedPlanPath)) {
2600
+ if (!existsSync8(resolvedPlanPath)) {
2510
2601
  errors.push({
2511
2602
  severity: "error",
2512
2603
  category: "structure",
@@ -2678,7 +2769,7 @@ function validateFileReferences(content, projectDir, warnings) {
2678
2769
  }
2679
2770
  const resolvedProject = resolve3(projectDir);
2680
2771
  const absolutePath = resolve3(projectDir, filePath);
2681
- if (!absolutePath.startsWith(resolvedProject + sep) && absolutePath !== resolvedProject) {
2772
+ if (!absolutePath.startsWith(resolvedProject + sep2) && absolutePath !== resolvedProject) {
2682
2773
  warnings.push({
2683
2774
  severity: "warning",
2684
2775
  category: "file-references",
@@ -2686,7 +2777,7 @@ function validateFileReferences(content, projectDir, warnings) {
2686
2777
  });
2687
2778
  continue;
2688
2779
  }
2689
- if (!existsSync7(absolutePath)) {
2780
+ if (!existsSync8(absolutePath)) {
2690
2781
  warnings.push({
2691
2782
  severity: "warning",
2692
2783
  category: "file-references",
@@ -2770,370 +2861,1867 @@ function validateVerificationSection(content, errors) {
2770
2861
  });
2771
2862
  }
2772
2863
  }
2773
- // src/hooks/start-work-hook.ts
2774
- function handleStartWork(input) {
2775
- const { promptText, sessionId, directory } = input;
2776
- if (!promptText.includes("<session-context>")) {
2777
- return { contextInjection: null, switchAgent: null };
2864
+ // src/features/workflow/constants.ts
2865
+ var WORKFLOWS_STATE_DIR = ".weave/workflows";
2866
+ var INSTANCE_STATE_FILE = "state.json";
2867
+ var ACTIVE_INSTANCE_FILE = "active-instance.json";
2868
+ var WORKFLOWS_DIR_PROJECT = ".opencode/workflows";
2869
+ var WORKFLOWS_DIR_USER = "workflows";
2870
+ // src/features/workflow/storage.ts
2871
+ import { existsSync as existsSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync3 } from "fs";
2872
+ import { join as join7 } from "path";
2873
+ import { randomBytes } from "node:crypto";
2874
+ function generateInstanceId() {
2875
+ return `wf_${randomBytes(4).toString("hex")}`;
2876
+ }
2877
+ function generateSlug(goal) {
2878
+ return goal.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 50);
2879
+ }
2880
+ function createWorkflowInstance(definition, definitionPath, goal, sessionId) {
2881
+ const instanceId = generateInstanceId();
2882
+ const slug = generateSlug(goal);
2883
+ const firstStepId = definition.steps[0].id;
2884
+ const steps = {};
2885
+ for (const step of definition.steps) {
2886
+ steps[step.id] = {
2887
+ id: step.id,
2888
+ status: step.id === firstStepId ? "active" : "pending",
2889
+ ...step.id === firstStepId ? { started_at: new Date().toISOString() } : {}
2890
+ };
2778
2891
  }
2779
- const explicitPlanName = extractPlanName(promptText);
2780
- const existingState = readWorkState(directory);
2781
- const allPlans = findPlans(directory);
2782
- if (explicitPlanName) {
2783
- return handleExplicitPlan(explicitPlanName, allPlans, sessionId, directory);
2892
+ return {
2893
+ instance_id: instanceId,
2894
+ definition_id: definition.name,
2895
+ definition_name: definition.name,
2896
+ definition_path: definitionPath,
2897
+ goal,
2898
+ slug,
2899
+ status: "running",
2900
+ started_at: new Date().toISOString(),
2901
+ session_ids: [sessionId],
2902
+ current_step_id: firstStepId,
2903
+ steps,
2904
+ artifacts: {}
2905
+ };
2906
+ }
2907
+ function readWorkflowInstance(directory, instanceId) {
2908
+ const filePath = join7(directory, WORKFLOWS_STATE_DIR, instanceId, INSTANCE_STATE_FILE);
2909
+ try {
2910
+ if (!existsSync9(filePath))
2911
+ return null;
2912
+ const raw = readFileSync7(filePath, "utf-8");
2913
+ const parsed = JSON.parse(raw);
2914
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
2915
+ return null;
2916
+ if (typeof parsed.instance_id !== "string")
2917
+ return null;
2918
+ return parsed;
2919
+ } catch {
2920
+ return null;
2784
2921
  }
2785
- if (existingState) {
2786
- const progress = getPlanProgress(existingState.active_plan);
2787
- if (!progress.isComplete) {
2788
- const validation = validatePlan(existingState.active_plan, directory);
2789
- if (!validation.valid) {
2790
- clearWorkState(directory);
2791
- return {
2792
- switchAgent: "tapestry",
2793
- contextInjection: `## Plan Validation Failed
2794
- The active plan "${existingState.plan_name}" has structural issues. Work state has been cleared.
2795
-
2796
- ${formatValidationResults(validation)}
2922
+ }
2923
+ function writeWorkflowInstance(directory, instance) {
2924
+ try {
2925
+ const dir = join7(directory, WORKFLOWS_STATE_DIR, instance.instance_id);
2926
+ if (!existsSync9(dir)) {
2927
+ mkdirSync3(dir, { recursive: true });
2928
+ }
2929
+ writeFileSync2(join7(dir, INSTANCE_STATE_FILE), JSON.stringify(instance, null, 2), "utf-8");
2930
+ return true;
2931
+ } catch {
2932
+ return false;
2933
+ }
2934
+ }
2935
+ function readActiveInstance(directory) {
2936
+ const filePath = join7(directory, WORKFLOWS_STATE_DIR, ACTIVE_INSTANCE_FILE);
2937
+ try {
2938
+ if (!existsSync9(filePath))
2939
+ return null;
2940
+ const raw = readFileSync7(filePath, "utf-8");
2941
+ const parsed = JSON.parse(raw);
2942
+ if (!parsed || typeof parsed !== "object" || typeof parsed.instance_id !== "string")
2943
+ return null;
2944
+ return parsed;
2945
+ } catch {
2946
+ return null;
2947
+ }
2948
+ }
2949
+ function setActiveInstance(directory, instanceId) {
2950
+ try {
2951
+ const dir = join7(directory, WORKFLOWS_STATE_DIR);
2952
+ if (!existsSync9(dir)) {
2953
+ mkdirSync3(dir, { recursive: true });
2954
+ }
2955
+ const pointer = { instance_id: instanceId };
2956
+ writeFileSync2(join7(dir, ACTIVE_INSTANCE_FILE), JSON.stringify(pointer, null, 2), "utf-8");
2957
+ return true;
2958
+ } catch {
2959
+ return false;
2960
+ }
2961
+ }
2962
+ function clearActiveInstance(directory) {
2963
+ const filePath = join7(directory, WORKFLOWS_STATE_DIR, ACTIVE_INSTANCE_FILE);
2964
+ try {
2965
+ if (existsSync9(filePath)) {
2966
+ unlinkSync2(filePath);
2967
+ }
2968
+ return true;
2969
+ } catch {
2970
+ return false;
2971
+ }
2972
+ }
2973
+ function getActiveWorkflowInstance(directory) {
2974
+ const pointer = readActiveInstance(directory);
2975
+ if (!pointer)
2976
+ return null;
2977
+ return readWorkflowInstance(directory, pointer.instance_id);
2978
+ }
2979
+ // src/features/workflow/discovery.ts
2980
+ import * as fs5 from "fs";
2981
+ import * as path5 from "path";
2982
+ import * as os3 from "os";
2983
+ import { parse as parseJsonc } from "jsonc-parser";
2984
+
2985
+ // src/features/workflow/schema.ts
2986
+ import { z as z2 } from "zod";
2987
+ var CompletionConfigSchema = z2.object({
2988
+ method: z2.enum(["user_confirm", "plan_created", "plan_complete", "review_verdict", "agent_signal"]),
2989
+ plan_name: z2.string().optional(),
2990
+ keywords: z2.array(z2.string()).optional()
2991
+ });
2992
+ var ArtifactRefSchema = z2.object({
2993
+ name: z2.string(),
2994
+ description: z2.string().optional()
2995
+ });
2996
+ var StepArtifactsSchema = z2.object({
2997
+ inputs: z2.array(ArtifactRefSchema).optional(),
2998
+ outputs: z2.array(ArtifactRefSchema).optional()
2999
+ });
3000
+ var WorkflowStepSchema = z2.object({
3001
+ id: z2.string().regex(/^[a-z][a-z0-9-]*$/, "Step ID must be lowercase alphanumeric with hyphens"),
3002
+ name: z2.string(),
3003
+ type: z2.enum(["interactive", "autonomous", "gate"]),
3004
+ agent: z2.string(),
3005
+ prompt: z2.string(),
3006
+ completion: CompletionConfigSchema,
3007
+ artifacts: StepArtifactsSchema.optional(),
3008
+ on_reject: z2.enum(["pause", "fail"]).optional()
3009
+ });
3010
+ var WorkflowDefinitionSchema = z2.object({
3011
+ name: z2.string().regex(/^[a-z][a-z0-9-]*$/, "Workflow name must be lowercase alphanumeric with hyphens"),
3012
+ description: z2.string().optional(),
3013
+ version: z2.number().int().positive(),
3014
+ steps: z2.array(WorkflowStepSchema).min(1, "Workflow must have at least one step")
3015
+ });
2797
3016
 
2798
- Tell the user to fix the plan file and run /start-work again.`
2799
- };
3017
+ // src/features/workflow/discovery.ts
3018
+ function loadWorkflowDefinition(filePath) {
3019
+ let raw;
3020
+ try {
3021
+ raw = fs5.readFileSync(filePath, "utf-8");
3022
+ } catch (err) {
3023
+ log("Failed to read workflow definition file", { filePath, error: String(err) });
3024
+ return null;
3025
+ }
3026
+ let parsed;
3027
+ try {
3028
+ parsed = parseJsonc(raw);
3029
+ } catch (err) {
3030
+ log("Failed to parse workflow definition JSONC", { filePath, error: String(err) });
3031
+ return null;
3032
+ }
3033
+ const result = WorkflowDefinitionSchema.safeParse(parsed);
3034
+ if (!result.success) {
3035
+ log("Workflow definition failed validation", {
3036
+ filePath,
3037
+ errors: result.error.issues.map((i) => i.message)
3038
+ });
3039
+ return null;
3040
+ }
3041
+ return result.data;
3042
+ }
3043
+ function scanWorkflowDirectory(directory, scope) {
3044
+ if (!fs5.existsSync(directory))
3045
+ return [];
3046
+ let entries;
3047
+ try {
3048
+ entries = fs5.readdirSync(directory, { withFileTypes: true });
3049
+ } catch (err) {
3050
+ log("Failed to read workflows directory", { directory, error: String(err) });
3051
+ return [];
3052
+ }
3053
+ const workflows = [];
3054
+ for (const entry of entries) {
3055
+ if (!entry.isFile())
3056
+ continue;
3057
+ if (!entry.name.endsWith(".jsonc") && !entry.name.endsWith(".json"))
3058
+ continue;
3059
+ const filePath = path5.join(directory, entry.name);
3060
+ const definition = loadWorkflowDefinition(filePath);
3061
+ if (definition) {
3062
+ workflows.push({ definition, path: filePath, scope });
3063
+ }
3064
+ }
3065
+ return workflows;
3066
+ }
3067
+ function discoverWorkflows(directory) {
3068
+ const projectDir = path5.join(directory, WORKFLOWS_DIR_PROJECT);
3069
+ const userDir = path5.join(os3.homedir(), ".config", "opencode", WORKFLOWS_DIR_USER);
3070
+ const userWorkflows = scanWorkflowDirectory(userDir, "user");
3071
+ const projectWorkflows = scanWorkflowDirectory(projectDir, "project");
3072
+ const byName = new Map;
3073
+ for (const wf of userWorkflows) {
3074
+ byName.set(wf.definition.name, wf);
3075
+ }
3076
+ for (const wf of projectWorkflows) {
3077
+ byName.set(wf.definition.name, wf);
3078
+ }
3079
+ return Array.from(byName.values());
3080
+ }
3081
+ // src/features/workflow/context.ts
3082
+ function resolveTemplate(template, instance, definition) {
3083
+ return template.replace(/\{\{(\w+)\.(\w+)\}\}/g, (_match, namespace, key) => {
3084
+ switch (namespace) {
3085
+ case "instance": {
3086
+ const instanceRecord = instance;
3087
+ const value = instanceRecord[key];
3088
+ return typeof value === "string" ? value : _match;
2800
3089
  }
2801
- appendSessionId(directory, sessionId);
2802
- resumeWork(directory);
2803
- const resumeContext = buildResumeContext(existingState.active_plan, existingState.plan_name, progress, existingState.start_sha);
2804
- if (validation.warnings.length > 0) {
2805
- return {
2806
- switchAgent: "tapestry",
2807
- contextInjection: `${resumeContext}
2808
-
2809
- ### Validation Warnings
2810
- ${formatValidationResults(validation)}`
2811
- };
3090
+ case "artifacts": {
3091
+ const value = instance.artifacts[key];
3092
+ return value ?? "(not yet available)";
3093
+ }
3094
+ case "step": {
3095
+ const currentStep = definition.steps.find((s) => s.id === instance.current_step_id);
3096
+ if (!currentStep)
3097
+ return _match;
3098
+ const stepRecord = currentStep;
3099
+ const value = stepRecord[key];
3100
+ return typeof value === "string" ? value : _match;
2812
3101
  }
3102
+ default:
3103
+ return _match;
3104
+ }
3105
+ });
3106
+ }
3107
+ function buildContextHeader(instance, definition) {
3108
+ const currentStepIndex = definition.steps.findIndex((s) => s.id === instance.current_step_id);
3109
+ const currentStepDef = definition.steps[currentStepIndex];
3110
+ const stepLabel = currentStepDef ? `step ${currentStepIndex + 1} of ${definition.steps.length}: ${currentStepDef.name}` : `step ${currentStepIndex + 1} of ${definition.steps.length}`;
3111
+ const lines = [];
3112
+ lines.push("## Workflow Context");
3113
+ lines.push(`**Goal**: "${instance.goal}"`);
3114
+ lines.push(`**Workflow**: ${definition.name} (${stepLabel})`);
3115
+ lines.push("");
3116
+ const completedSteps = definition.steps.filter((s) => {
3117
+ const state = instance.steps[s.id];
3118
+ return state && state.status === "completed";
3119
+ });
3120
+ if (completedSteps.length > 0) {
3121
+ lines.push("### Completed Steps");
3122
+ for (const stepDef of completedSteps) {
3123
+ const state = instance.steps[stepDef.id];
3124
+ const summary = state.summary ? ` → "${truncateSummary(state.summary)}"` : "";
3125
+ lines.push(`- [✓] **${stepDef.name}**${summary}`);
3126
+ }
3127
+ lines.push("");
3128
+ }
3129
+ const artifactEntries = Object.entries(instance.artifacts);
3130
+ if (artifactEntries.length > 0) {
3131
+ lines.push("### Accumulated Artifacts");
3132
+ for (const [name, value] of artifactEntries) {
3133
+ lines.push(`- **${name}**: "${truncateSummary(value)}"`);
3134
+ }
3135
+ lines.push("");
3136
+ }
3137
+ return lines.join(`
3138
+ `);
3139
+ }
3140
+ function composeStepPrompt(stepDef, instance, definition) {
3141
+ const contextHeader = buildContextHeader(instance, definition);
3142
+ const resolvedPrompt = resolveTemplate(stepDef.prompt, instance, definition);
3143
+ return `${contextHeader}---
3144
+
3145
+ ## Your Task
3146
+ ${resolvedPrompt}`;
3147
+ }
3148
+ function truncateSummary(text) {
3149
+ const maxLength = 200;
3150
+ if (text.length <= maxLength)
3151
+ return text;
3152
+ return text.slice(0, maxLength - 3) + "...";
3153
+ }
3154
+ // src/features/workflow/completion.ts
3155
+ import { existsSync as existsSync11 } from "fs";
3156
+ import { join as join9 } from "path";
3157
+ var DEFAULT_CONFIRM_KEYWORDS = ["confirmed", "approved", "continue", "done", "let's proceed", "looks good", "lgtm"];
3158
+ var VERDICT_APPROVE_RE = /\[\s*APPROVE\s*\]/i;
3159
+ var VERDICT_REJECT_RE = /\[\s*REJECT\s*\]/i;
3160
+ var AGENT_SIGNAL_MARKER = "<!-- workflow:step-complete -->";
3161
+ function checkStepCompletion(method, context) {
3162
+ switch (method) {
3163
+ case "user_confirm":
3164
+ return checkUserConfirm(context);
3165
+ case "plan_created":
3166
+ return checkPlanCreated(context);
3167
+ case "plan_complete":
3168
+ return checkPlanComplete(context);
3169
+ case "review_verdict":
3170
+ return checkReviewVerdict(context);
3171
+ case "agent_signal":
3172
+ return checkAgentSignal(context);
3173
+ default:
3174
+ return { complete: false, reason: `Unknown completion method: ${method}` };
3175
+ }
3176
+ }
3177
+ function checkUserConfirm(context) {
3178
+ const { lastUserMessage, config } = context;
3179
+ if (!lastUserMessage)
3180
+ return { complete: false };
3181
+ const keywords = config.keywords ?? DEFAULT_CONFIRM_KEYWORDS;
3182
+ const lowerMessage = lastUserMessage.toLowerCase().trim();
3183
+ for (const keyword of keywords) {
3184
+ if (lowerMessage.includes(keyword.toLowerCase())) {
2813
3185
  return {
2814
- switchAgent: "tapestry",
2815
- contextInjection: resumeContext
3186
+ complete: true,
3187
+ summary: `User confirmed: "${lastUserMessage.slice(0, 100)}"`
2816
3188
  };
2817
3189
  }
2818
3190
  }
2819
- return handlePlanDiscovery(allPlans, sessionId, directory);
3191
+ return { complete: false };
2820
3192
  }
2821
- function extractPlanName(promptText) {
2822
- const match = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i);
2823
- if (!match)
2824
- return null;
2825
- const cleaned = match[1].trim();
2826
- return cleaned || null;
2827
- }
2828
- function handleExplicitPlan(requestedName, allPlans, sessionId, directory) {
2829
- const matched = findPlanByName(allPlans, requestedName);
2830
- if (!matched) {
2831
- const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete);
2832
- const listing = incompletePlans.length > 0 ? incompletePlans.map((p) => ` - ${getPlanName(p)}`).join(`
2833
- `) : " (none)";
2834
- return {
2835
- switchAgent: "tapestry",
2836
- contextInjection: `## Plan Not Found
2837
- No plan matching "${requestedName}" was found.
2838
-
2839
- Available incomplete plans:
2840
- ${listing}
2841
-
2842
- Tell the user which plans are available and ask them to specify one.`
2843
- };
3193
+ function checkPlanCreated(context) {
3194
+ const { config, directory } = context;
3195
+ const planName = config.plan_name;
3196
+ if (!planName) {
3197
+ return { complete: false, reason: "plan_created requires plan_name in completion config" };
2844
3198
  }
2845
- const progress = getPlanProgress(matched);
2846
- if (progress.isComplete) {
3199
+ const plans = findPlans(directory);
3200
+ const matchingPlan = plans.find((p) => p.includes(planName));
3201
+ if (matchingPlan) {
2847
3202
  return {
2848
- switchAgent: "tapestry",
2849
- contextInjection: `## Plan Already Complete
2850
- The plan "${getPlanName(matched)}" has all ${progress.total} tasks completed.
2851
- Tell the user this plan is already done and suggest creating a new one with Pattern.`
3203
+ complete: true,
3204
+ artifacts: { plan_path: matchingPlan },
3205
+ summary: `Plan created at ${matchingPlan}`
2852
3206
  };
2853
3207
  }
2854
- const validation = validatePlan(matched, directory);
2855
- if (!validation.valid) {
3208
+ const directPath = join9(directory, ".weave", "plans", `${planName}.md`);
3209
+ if (existsSync11(directPath)) {
2856
3210
  return {
2857
- switchAgent: "tapestry",
2858
- contextInjection: `## Plan Validation Failed
2859
- The plan "${getPlanName(matched)}" has structural issues that must be fixed before execution can begin.
2860
-
2861
- ${formatValidationResults(validation)}
2862
-
2863
- Tell the user to fix these issues in the plan file and try again.`
3211
+ complete: true,
3212
+ artifacts: { plan_path: directPath },
3213
+ summary: `Plan created at ${directPath}`
2864
3214
  };
2865
3215
  }
2866
- clearWorkState(directory);
2867
- const state = createWorkState(matched, sessionId, "tapestry", directory);
2868
- writeWorkState(directory, state);
2869
- const freshContext = buildFreshContext(matched, getPlanName(matched), progress, state.start_sha);
2870
- if (validation.warnings.length > 0) {
3216
+ return { complete: false };
3217
+ }
3218
+ function checkPlanComplete(context) {
3219
+ const { config, directory } = context;
3220
+ const planName = config.plan_name;
3221
+ if (!planName) {
3222
+ return { complete: false, reason: "plan_complete requires plan_name in completion config" };
3223
+ }
3224
+ const planPath = join9(directory, ".weave", "plans", `${planName}.md`);
3225
+ if (!existsSync11(planPath)) {
3226
+ return { complete: false, reason: `Plan file not found: ${planPath}` };
3227
+ }
3228
+ const progress = getPlanProgress(planPath);
3229
+ if (progress.isComplete) {
2871
3230
  return {
2872
- switchAgent: "tapestry",
2873
- contextInjection: `${freshContext}
2874
-
2875
- ### Validation Warnings
2876
- ${formatValidationResults(validation)}`
3231
+ complete: true,
3232
+ summary: `Plan completed: ${progress.completed}/${progress.total} tasks done`
2877
3233
  };
2878
3234
  }
2879
3235
  return {
2880
- switchAgent: "tapestry",
2881
- contextInjection: freshContext
3236
+ complete: false,
3237
+ reason: `Plan in progress: ${progress.completed}/${progress.total} tasks done`
2882
3238
  };
2883
3239
  }
2884
- function handlePlanDiscovery(allPlans, sessionId, directory) {
2885
- if (allPlans.length === 0) {
3240
+ function checkReviewVerdict(context) {
3241
+ const { lastAssistantMessage } = context;
3242
+ if (!lastAssistantMessage)
3243
+ return { complete: false };
3244
+ if (VERDICT_APPROVE_RE.test(lastAssistantMessage)) {
2886
3245
  return {
2887
- switchAgent: "tapestry",
2888
- contextInjection: "## No Plans Found\nNo plan files found at `.weave/plans/`.\nTell the user to switch to Pattern agent to create a work plan first."
3246
+ complete: true,
3247
+ verdict: "approve",
3248
+ summary: "Review verdict: APPROVED"
2889
3249
  };
2890
3250
  }
2891
- const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete);
2892
- if (incompletePlans.length === 0) {
3251
+ if (VERDICT_REJECT_RE.test(lastAssistantMessage)) {
2893
3252
  return {
2894
- switchAgent: "tapestry",
2895
- contextInjection: `## All Plans Complete
2896
- All existing plans have been completed.
2897
- Tell the user to switch to Pattern agent to create a new plan.`
3253
+ complete: true,
3254
+ verdict: "reject",
3255
+ summary: "Review verdict: REJECTED"
2898
3256
  };
2899
3257
  }
2900
- if (incompletePlans.length === 1) {
2901
- const plan = incompletePlans[0];
2902
- const progress = getPlanProgress(plan);
2903
- const validation = validatePlan(plan, directory);
2904
- if (!validation.valid) {
2905
- return {
2906
- switchAgent: "tapestry",
2907
- contextInjection: `## Plan Validation Failed
2908
- The plan "${getPlanName(plan)}" has structural issues that must be fixed before execution can begin.
2909
-
2910
- ${formatValidationResults(validation)}
2911
-
2912
- Tell the user to fix these issues in the plan file and try again.`
2913
- };
2914
- }
2915
- const state = createWorkState(plan, sessionId, "tapestry", directory);
2916
- writeWorkState(directory, state);
2917
- const freshContext = buildFreshContext(plan, getPlanName(plan), progress, state.start_sha);
2918
- if (validation.warnings.length > 0) {
2919
- return {
2920
- switchAgent: "tapestry",
2921
- contextInjection: `${freshContext}
2922
-
2923
- ### Validation Warnings
2924
- ${formatValidationResults(validation)}`
3258
+ return { complete: false };
3259
+ }
3260
+ function checkAgentSignal(context) {
3261
+ const { lastAssistantMessage } = context;
3262
+ if (!lastAssistantMessage)
3263
+ return { complete: false };
3264
+ if (lastAssistantMessage.includes(AGENT_SIGNAL_MARKER)) {
3265
+ return {
3266
+ complete: true,
3267
+ summary: "Agent signaled completion"
3268
+ };
3269
+ }
3270
+ return { complete: false };
3271
+ }
3272
+ // src/features/workflow/engine.ts
3273
+ function startWorkflow(input) {
3274
+ const { definition, definitionPath, goal, sessionId, directory } = input;
3275
+ const instance = createWorkflowInstance(definition, definitionPath, goal, sessionId);
3276
+ writeWorkflowInstance(directory, instance);
3277
+ setActiveInstance(directory, instance.instance_id);
3278
+ const firstStepDef = definition.steps[0];
3279
+ const prompt = composeStepPrompt(firstStepDef, instance, definition);
3280
+ return {
3281
+ type: "inject_prompt",
3282
+ prompt,
3283
+ agent: firstStepDef.agent
3284
+ };
3285
+ }
3286
+ function checkAndAdvance(input) {
3287
+ const { directory, context } = input;
3288
+ const instance = getActiveWorkflowInstance(directory);
3289
+ if (!instance)
3290
+ return { type: "none" };
3291
+ if (instance.status !== "running")
3292
+ return { type: "none" };
3293
+ const definition = loadWorkflowDefinition(instance.definition_path);
3294
+ if (!definition)
3295
+ return { type: "none", reason: "Failed to load workflow definition" };
3296
+ const currentStepDef = definition.steps.find((s) => s.id === instance.current_step_id);
3297
+ if (!currentStepDef)
3298
+ return { type: "none", reason: "Current step not found in definition" };
3299
+ const stepState = instance.steps[instance.current_step_id];
3300
+ if (!stepState || stepState.status !== "active")
3301
+ return { type: "none" };
3302
+ const completionResult = checkStepCompletion(currentStepDef.completion.method, context);
3303
+ if (!completionResult.complete) {
3304
+ if (currentStepDef.type === "interactive")
3305
+ return { type: "none" };
3306
+ return { type: "none" };
3307
+ }
3308
+ if (currentStepDef.type === "gate" && completionResult.verdict === "reject") {
3309
+ return handleGateReject(directory, instance, currentStepDef, completionResult);
3310
+ }
3311
+ return advanceToNextStep(directory, instance, definition, completionResult);
3312
+ }
3313
+ function handleGateReject(directory, instance, currentStepDef, completionResult) {
3314
+ const stepState = instance.steps[currentStepDef.id];
3315
+ stepState.status = "completed";
3316
+ stepState.completed_at = new Date().toISOString();
3317
+ stepState.verdict = "reject";
3318
+ stepState.summary = completionResult.summary;
3319
+ const action = currentStepDef.on_reject ?? "pause";
3320
+ if (action === "fail") {
3321
+ instance.status = "failed";
3322
+ instance.ended_at = new Date().toISOString();
3323
+ clearActiveInstance(directory);
3324
+ } else {
3325
+ instance.status = "paused";
3326
+ instance.pause_reason = "Gate step rejected";
3327
+ }
3328
+ writeWorkflowInstance(directory, instance);
3329
+ return {
3330
+ type: "pause",
3331
+ reason: `Gate step "${currentStepDef.id}" rejected${action === "fail" ? " — workflow failed" : " — workflow paused"}`
3332
+ };
3333
+ }
3334
+ function advanceToNextStep(directory, instance, definition, completionResult) {
3335
+ const currentStepDef = definition.steps.find((s) => s.id === instance.current_step_id);
3336
+ const currentIndex = definition.steps.indexOf(currentStepDef);
3337
+ const stepState = instance.steps[instance.current_step_id];
3338
+ stepState.status = "completed";
3339
+ stepState.completed_at = new Date().toISOString();
3340
+ stepState.summary = completionResult.summary;
3341
+ if (completionResult.verdict)
3342
+ stepState.verdict = completionResult.verdict;
3343
+ if (completionResult.artifacts) {
3344
+ stepState.artifacts = completionResult.artifacts;
3345
+ Object.assign(instance.artifacts, completionResult.artifacts);
3346
+ }
3347
+ if (currentIndex >= definition.steps.length - 1) {
3348
+ instance.status = "completed";
3349
+ instance.ended_at = new Date().toISOString();
3350
+ clearActiveInstance(directory);
3351
+ writeWorkflowInstance(directory, instance);
3352
+ return { type: "complete", reason: "Workflow completed — all steps done" };
3353
+ }
3354
+ const nextStepDef = definition.steps[currentIndex + 1];
3355
+ instance.current_step_id = nextStepDef.id;
3356
+ instance.steps[nextStepDef.id].status = "active";
3357
+ instance.steps[nextStepDef.id].started_at = new Date().toISOString();
3358
+ writeWorkflowInstance(directory, instance);
3359
+ const prompt = composeStepPrompt(nextStepDef, instance, definition);
3360
+ return {
3361
+ type: "inject_prompt",
3362
+ prompt,
3363
+ agent: nextStepDef.agent
3364
+ };
3365
+ }
3366
+ function pauseWorkflow(directory, reason) {
3367
+ const instance = getActiveWorkflowInstance(directory);
3368
+ if (!instance || instance.status !== "running")
3369
+ return false;
3370
+ instance.status = "paused";
3371
+ instance.pause_reason = reason ?? "Paused by user";
3372
+ return writeWorkflowInstance(directory, instance);
3373
+ }
3374
+ function resumeWorkflow(directory) {
3375
+ const instance = getActiveWorkflowInstance(directory);
3376
+ if (!instance || instance.status !== "paused")
3377
+ return { type: "none", reason: "No paused workflow to resume" };
3378
+ const definition = loadWorkflowDefinition(instance.definition_path);
3379
+ if (!definition)
3380
+ return { type: "none", reason: "Failed to load workflow definition" };
3381
+ instance.status = "running";
3382
+ instance.pause_reason = undefined;
3383
+ const currentStepState = instance.steps[instance.current_step_id];
3384
+ if (currentStepState.status !== "active") {
3385
+ currentStepState.status = "active";
3386
+ currentStepState.started_at = new Date().toISOString();
3387
+ }
3388
+ writeWorkflowInstance(directory, instance);
3389
+ const currentStepDef = definition.steps.find((s) => s.id === instance.current_step_id);
3390
+ if (!currentStepDef)
3391
+ return { type: "none", reason: "Current step not found in definition" };
3392
+ const prompt = composeStepPrompt(currentStepDef, instance, definition);
3393
+ return {
3394
+ type: "inject_prompt",
3395
+ prompt,
3396
+ agent: currentStepDef.agent
3397
+ };
3398
+ }
3399
+ function skipStep(directory) {
3400
+ const instance = getActiveWorkflowInstance(directory);
3401
+ if (!instance)
3402
+ return { type: "none", reason: "No active workflow" };
3403
+ const definition = loadWorkflowDefinition(instance.definition_path);
3404
+ if (!definition)
3405
+ return { type: "none", reason: "Failed to load workflow definition" };
3406
+ return advanceToNextStep(directory, instance, definition, {
3407
+ complete: true,
3408
+ summary: "Step skipped by user"
3409
+ });
3410
+ }
3411
+ function abortWorkflow(directory) {
3412
+ const instance = getActiveWorkflowInstance(directory);
3413
+ if (!instance)
3414
+ return false;
3415
+ instance.status = "cancelled";
3416
+ instance.ended_at = new Date().toISOString();
3417
+ clearActiveInstance(directory);
3418
+ return writeWorkflowInstance(directory, instance);
3419
+ }
3420
+ // src/features/workflow/hook.ts
3421
+ var WORKFLOW_CONTINUATION_MARKER = "<!-- weave:workflow-continuation -->";
3422
+ function parseWorkflowArgs(args) {
3423
+ const trimmed = args.trim();
3424
+ if (!trimmed)
3425
+ return { workflowName: null, goal: null };
3426
+ const quotedMatch = trimmed.match(/^(\S+)\s+"([^"]+)"$/);
3427
+ if (quotedMatch) {
3428
+ return { workflowName: quotedMatch[1], goal: quotedMatch[2] };
3429
+ }
3430
+ const singleQuotedMatch = trimmed.match(/^(\S+)\s+'([^']+)'$/);
3431
+ if (singleQuotedMatch) {
3432
+ return { workflowName: singleQuotedMatch[1], goal: singleQuotedMatch[2] };
3433
+ }
3434
+ const parts = trimmed.split(/\s+/);
3435
+ if (parts.length === 1) {
3436
+ return { workflowName: parts[0], goal: null };
3437
+ }
3438
+ return { workflowName: parts[0], goal: parts.slice(1).join(" ") };
3439
+ }
3440
+ function handleRunWorkflow(input) {
3441
+ const { promptText, sessionId, directory } = input;
3442
+ if (!promptText.includes("<session-context>")) {
3443
+ return { contextInjection: null, switchAgent: null };
3444
+ }
3445
+ const args = extractArguments(promptText);
3446
+ const { workflowName, goal } = parseWorkflowArgs(args);
3447
+ const workStateWarning = checkWorkStatePlanActive(directory);
3448
+ const activeInstance = getActiveWorkflowInstance(directory);
3449
+ if (!workflowName && !activeInstance) {
3450
+ const result = listAvailableWorkflows(directory);
3451
+ return prependWarning(result, workStateWarning);
3452
+ }
3453
+ if (!workflowName && activeInstance) {
3454
+ const result = resumeActiveWorkflow(directory);
3455
+ return prependWarning(result, workStateWarning);
3456
+ }
3457
+ if (workflowName && !goal && activeInstance && activeInstance.definition_id === workflowName) {
3458
+ const result = resumeActiveWorkflow(directory);
3459
+ return prependWarning(result, workStateWarning);
3460
+ }
3461
+ if (workflowName && goal) {
3462
+ if (activeInstance) {
3463
+ return {
3464
+ contextInjection: `## Workflow Already Active
3465
+ There is already an active workflow: "${activeInstance.definition_name}" (${activeInstance.instance_id}).
3466
+ Goal: "${activeInstance.goal}"
3467
+
3468
+ To start a new workflow, first abort the current one with \`/workflow abort\` or let it complete.`,
3469
+ switchAgent: null
3470
+ };
3471
+ }
3472
+ const result = startNewWorkflow(workflowName, goal, sessionId, directory);
3473
+ return prependWarning(result, workStateWarning);
3474
+ }
3475
+ if (workflowName && !goal) {
3476
+ if (activeInstance) {
3477
+ return {
3478
+ contextInjection: `## Workflow Already Active
3479
+ There is already an active workflow: "${activeInstance.definition_name}" (${activeInstance.instance_id}).
3480
+ Goal: "${activeInstance.goal}"
3481
+
3482
+ Did you mean to resume the active workflow? Run \`/run-workflow\` without arguments to resume.`,
3483
+ switchAgent: null
2925
3484
  };
2926
3485
  }
2927
3486
  return {
2928
- switchAgent: "tapestry",
2929
- contextInjection: freshContext
3487
+ contextInjection: `## Goal Required
3488
+ To start the "${workflowName}" workflow, provide a goal:
3489
+ \`/run-workflow ${workflowName} "your goal here"\``,
3490
+ switchAgent: null
2930
3491
  };
2931
3492
  }
2932
- const listing = incompletePlans.map((p) => {
2933
- const progress = getPlanProgress(p);
2934
- return ` - **${getPlanName(p)}** (${progress.completed}/${progress.total} tasks done)`;
2935
- }).join(`
3493
+ return { contextInjection: null, switchAgent: null };
3494
+ }
3495
+ function checkWorkflowContinuation(input) {
3496
+ const { directory, lastAssistantMessage, lastUserMessage } = input;
3497
+ const instance = getActiveWorkflowInstance(directory);
3498
+ if (!instance)
3499
+ return { continuationPrompt: null, switchAgent: null };
3500
+ if (instance.status !== "running")
3501
+ return { continuationPrompt: null, switchAgent: null };
3502
+ const definition = loadWorkflowDefinition(instance.definition_path);
3503
+ if (!definition)
3504
+ return { continuationPrompt: null, switchAgent: null };
3505
+ const currentStepDef = definition.steps.find((s) => s.id === instance.current_step_id);
3506
+ if (!currentStepDef)
3507
+ return { continuationPrompt: null, switchAgent: null };
3508
+ const completionContext = {
3509
+ directory,
3510
+ config: currentStepDef.completion,
3511
+ artifacts: instance.artifacts,
3512
+ lastAssistantMessage,
3513
+ lastUserMessage
3514
+ };
3515
+ const action = checkAndAdvance({ directory, context: completionContext });
3516
+ switch (action.type) {
3517
+ case "inject_prompt":
3518
+ return {
3519
+ continuationPrompt: `${WORKFLOW_CONTINUATION_MARKER}
3520
+ ${action.prompt}`,
3521
+ switchAgent: action.agent ?? null
3522
+ };
3523
+ case "complete":
3524
+ return {
3525
+ continuationPrompt: `${WORKFLOW_CONTINUATION_MARKER}
3526
+ ## Workflow Complete
3527
+ ${action.reason ?? "All steps have been completed."}
3528
+
3529
+ Summarize what was accomplished across all workflow steps.`,
3530
+ switchAgent: null
3531
+ };
3532
+ case "pause":
3533
+ return {
3534
+ continuationPrompt: `${WORKFLOW_CONTINUATION_MARKER}
3535
+ ## Workflow Paused
3536
+ ${action.reason ?? "The workflow has been paused."}
3537
+
3538
+ Inform the user about the pause and what to do next.`,
3539
+ switchAgent: null
3540
+ };
3541
+ case "none":
3542
+ default:
3543
+ return { continuationPrompt: null, switchAgent: null };
3544
+ }
3545
+ }
3546
+ function checkWorkStatePlanActive(directory) {
3547
+ const state = readWorkState(directory);
3548
+ if (!state)
3549
+ return null;
3550
+ const progress = getPlanProgress(state.active_plan);
3551
+ if (progress.isComplete)
3552
+ return null;
3553
+ const status = state.paused ? "paused" : "running";
3554
+ const planName = state.plan_name ?? state.active_plan;
3555
+ return `## Active Plan Detected
3556
+
3557
+ There is currently an active plan being executed: "${planName}"
3558
+ Status: ${status} • Progress: ${progress.completed}/${progress.total} tasks complete
3559
+
3560
+ Starting a workflow will take priority over the active plan — plan continuation will be suspended while the workflow runs.
3561
+
3562
+ **Options:**
3563
+ - **Proceed anyway** — the plan will be paused and can be resumed with \`/start-work\` after the workflow completes
3564
+ - **Abort the plan first** — abandon the current plan, then start the workflow
3565
+ - **Cancel** — don't start the workflow, continue with the plan`;
3566
+ }
3567
+ function prependWarning(result, warning) {
3568
+ if (!warning)
3569
+ return result;
3570
+ if (!result.contextInjection) {
3571
+ return { ...result, contextInjection: warning };
3572
+ }
3573
+ return { ...result, contextInjection: `${warning}
3574
+
3575
+ ---
3576
+
3577
+ ${result.contextInjection}` };
3578
+ }
3579
+ function extractArguments(promptText) {
3580
+ const match = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i);
3581
+ if (!match)
3582
+ return "";
3583
+ return match[1].trim();
3584
+ }
3585
+ function listAvailableWorkflows(directory) {
3586
+ const workflows = discoverWorkflows(directory);
3587
+ if (workflows.length === 0) {
3588
+ return {
3589
+ contextInjection: "## No Workflows Available\nNo workflow definitions found.\n\nWorkflow definitions should be placed in `.opencode/workflows/` (project) or `~/.config/opencode/workflows/` (user).",
3590
+ switchAgent: null
3591
+ };
3592
+ }
3593
+ const listing = workflows.map((w) => ` - **${w.definition.name}**: ${w.definition.description ?? "(no description)"} (${w.scope})`).join(`
2936
3594
  `);
2937
3595
  return {
2938
- switchAgent: "tapestry",
2939
- contextInjection: `## Multiple Plans Found
2940
- There are ${incompletePlans.length} incomplete plans:
3596
+ contextInjection: `## Available Workflows
2941
3597
  ${listing}
2942
3598
 
2943
- Ask the user which plan to work on. They can run \`/start-work [plan-name]\` to select one.`
3599
+ To start a workflow, run:
3600
+ \`/run-workflow <name> "your goal"\``,
3601
+ switchAgent: null
2944
3602
  };
2945
3603
  }
2946
- function findPlanByName(plans, requestedName) {
2947
- const lower = requestedName.toLowerCase();
2948
- const exact = plans.find((p) => getPlanName(p).toLowerCase() === lower);
2949
- if (exact)
2950
- return exact;
2951
- const partial = plans.find((p) => getPlanName(p).toLowerCase().includes(lower));
2952
- return partial || null;
2953
- }
2954
- function formatValidationResults(result) {
2955
- const lines = [];
2956
- if (result.errors.length > 0) {
2957
- lines.push("**Errors (blocking):**");
2958
- for (const err of result.errors) {
2959
- lines.push(`- [${err.category}] ${err.message}`);
3604
+ function resumeActiveWorkflow(directory) {
3605
+ const action = resumeWorkflow(directory);
3606
+ if (action.type === "none") {
3607
+ const instance = getActiveWorkflowInstance(directory);
3608
+ if (instance && instance.status === "running") {
3609
+ const definition = loadWorkflowDefinition(instance.definition_path);
3610
+ if (definition) {
3611
+ const currentStep = definition.steps.find((s) => s.id === instance.current_step_id);
3612
+ return {
3613
+ contextInjection: `## Workflow In Progress
3614
+ Workflow "${instance.definition_name}" is already running.
3615
+ Current step: **${currentStep?.name ?? instance.current_step_id}**
3616
+ Goal: "${instance.goal}"
3617
+
3618
+ Continue with the current step.`,
3619
+ switchAgent: currentStep?.agent ?? null
3620
+ };
3621
+ }
2960
3622
  }
3623
+ return { contextInjection: null, switchAgent: null };
2961
3624
  }
2962
- if (result.warnings.length > 0) {
2963
- if (result.errors.length > 0)
2964
- lines.push("");
2965
- lines.push("**Warnings:**");
2966
- for (const warn of result.warnings) {
2967
- lines.push(`- [${warn.category}] ${warn.message}`);
2968
- }
3625
+ return {
3626
+ contextInjection: action.prompt ?? null,
3627
+ switchAgent: action.agent ?? null
3628
+ };
3629
+ }
3630
+ function startNewWorkflow(workflowName, goal, sessionId, directory) {
3631
+ const workflows = discoverWorkflows(directory);
3632
+ const match = workflows.find((w) => w.definition.name === workflowName);
3633
+ if (!match) {
3634
+ const available = workflows.map((w) => w.definition.name).join(", ");
3635
+ return {
3636
+ contextInjection: `## Workflow Not Found
3637
+ No workflow definition named "${workflowName}" was found.
3638
+ ${available ? `Available workflows: ${available}` : "No workflow definitions available."}`,
3639
+ switchAgent: null
3640
+ };
2969
3641
  }
2970
- return lines.join(`
2971
- `);
3642
+ const action = startWorkflow({
3643
+ definition: match.definition,
3644
+ definitionPath: match.path,
3645
+ goal,
3646
+ sessionId,
3647
+ directory
3648
+ });
3649
+ log("Workflow started", {
3650
+ workflowName: match.definition.name,
3651
+ goal,
3652
+ agent: action.agent
3653
+ });
3654
+ return {
3655
+ contextInjection: action.prompt ?? null,
3656
+ switchAgent: action.agent ?? null
3657
+ };
2972
3658
  }
2973
- function buildFreshContext(planPath, planName, progress, startSha) {
2974
- const shaLine = startSha ? `
2975
- **Start SHA**: ${startSha}` : "";
2976
- return `## Starting Plan: ${planName}
2977
- **Plan file**: ${planPath}
2978
- **Progress**: ${progress.completed}/${progress.total} tasks completed${shaLine}
2979
-
2980
- Read the plan file now and begin executing from the first unchecked \`- [ ]\` task.
2981
-
2982
- **SIDEBAR TODOS DO THIS FIRST:**
2983
- Before starting any work, use todowrite to populate the sidebar:
2984
- 1. Create a summary todo (in_progress): "${planName} ${progress.completed}/${progress.total}"
2985
- 2. Create a todo for the first unchecked task (in_progress)
2986
- 3. Create todos for the next 2-3 tasks (pending)
2987
- Keep each todo under 35 chars. Update as you complete tasks.`;
3659
+ // src/features/workflow/commands.ts
3660
+ var PAUSE_PATTERNS = [/\bworkflow\s+pause\b/i, /\bpause\s+workflow\b/i];
3661
+ var SKIP_PATTERNS = [/\bworkflow\s+skip\b/i, /\bskip\s+step\b/i];
3662
+ var ABORT_PATTERNS = [/\bworkflow\s+abort\b/i, /\babort\s+workflow\b/i];
3663
+ var STATUS_PATTERNS = [/\bworkflow\s+status\b/i];
3664
+ function handleWorkflowCommand(message, directory) {
3665
+ const instance = getActiveWorkflowInstance(directory);
3666
+ if (!instance)
3667
+ return { handled: false };
3668
+ const trimmed = message.trim();
3669
+ if (matchesAny(trimmed, PAUSE_PATTERNS)) {
3670
+ return handlePause(directory, instance);
3671
+ }
3672
+ if (matchesAny(trimmed, SKIP_PATTERNS)) {
3673
+ return handleSkip(directory, instance);
3674
+ }
3675
+ if (matchesAny(trimmed, ABORT_PATTERNS)) {
3676
+ return handleAbort(directory, instance);
3677
+ }
3678
+ if (matchesAny(trimmed, STATUS_PATTERNS)) {
3679
+ return handleStatus(directory, instance);
3680
+ }
3681
+ return { handled: false };
2988
3682
  }
2989
- function buildResumeContext(planPath, planName, progress, startSha) {
2990
- const remaining = progress.total - progress.completed;
2991
- const shaLine = startSha ? `
2992
- **Start SHA**: ${startSha}` : "";
2993
- return `## Resuming Plan: ${planName}
2994
- **Plan file**: ${planPath}
2995
- **Progress**: ${progress.completed}/${progress.total} tasks completed
2996
- **Status**: RESUMING continuing from where the previous session left off.${shaLine}
3683
+ function matchesAny(text, patterns) {
3684
+ return patterns.some((p) => p.test(text));
3685
+ }
3686
+ function handlePause(directory, instance) {
3687
+ if (instance.status !== "running") {
3688
+ return {
3689
+ handled: true,
3690
+ contextInjection: `## Workflow Not Running
3691
+ The workflow "${instance.definition_name}" is currently ${instance.status}. Cannot pause.`
3692
+ };
3693
+ }
3694
+ pauseWorkflow(directory, "Paused by user command");
3695
+ return {
3696
+ handled: true,
3697
+ contextInjection: `## Workflow Paused
3698
+ Workflow "${instance.definition_name}" has been paused.
3699
+ Goal: "${instance.goal}"
3700
+ Current step: ${instance.current_step_id}
2997
3701
 
2998
- Read the plan file now and continue from the first unchecked \`- [ ]\` task.
3702
+ To resume, run \`/run-workflow\`.`
3703
+ };
3704
+ }
3705
+ function handleSkip(directory, instance) {
3706
+ if (instance.status !== "running") {
3707
+ return {
3708
+ handled: true,
3709
+ contextInjection: `## Workflow Not Running
3710
+ The workflow "${instance.definition_name}" is currently ${instance.status}. Cannot skip step.`
3711
+ };
3712
+ }
3713
+ const currentStepId = instance.current_step_id;
3714
+ const action = skipStep(directory);
3715
+ if (action.type === "inject_prompt") {
3716
+ return {
3717
+ handled: true,
3718
+ contextInjection: `## Step Skipped
3719
+ Skipped step "${currentStepId}".
2999
3720
 
3000
- **SIDEBAR TODOS — RESTORE STATE:**
3001
- Previous session's todos are lost. Use todowrite to restore the sidebar:
3002
- 1. Create a summary todo (in_progress): "${planName} ${progress.completed}/${progress.total}"
3003
- 2. Create a todo for the next unchecked task (in_progress)
3004
- 3. Create todos for the following 2-3 tasks (pending)
3005
- Keep each todo under 35 chars. ${remaining} task${remaining !== 1 ? "s" : ""} remaining.`;
3721
+ ${action.prompt}`,
3722
+ switchAgent: action.agent
3723
+ };
3724
+ }
3725
+ if (action.type === "complete") {
3726
+ return {
3727
+ handled: true,
3728
+ contextInjection: `## Step Skipped — Workflow Complete
3729
+ Skipped step "${currentStepId}".
3730
+ ${action.reason ?? "All steps have been completed."}`
3731
+ };
3732
+ }
3733
+ return {
3734
+ handled: true,
3735
+ contextInjection: `## Step Skipped
3736
+ Skipped step "${currentStepId}".`
3737
+ };
3006
3738
  }
3739
+ function handleAbort(directory, instance) {
3740
+ const name = instance.definition_name;
3741
+ const goal = instance.goal;
3742
+ abortWorkflow(directory);
3743
+ return {
3744
+ handled: true,
3745
+ contextInjection: `## Workflow Aborted
3746
+ Workflow "${name}" has been cancelled.
3747
+ Goal: "${goal}"
3007
3748
 
3008
- // src/hooks/work-continuation.ts
3009
- var CONTINUATION_MARKER = "<!-- weave:continuation -->";
3010
- var MAX_STALE_CONTINUATIONS = 3;
3011
- function checkContinuation(input) {
3012
- const { directory } = input;
3013
- const state = readWorkState(directory);
3014
- if (!state) {
3015
- return { continuationPrompt: null };
3749
+ The workflow instance has been terminated and the active pointer cleared.`
3750
+ };
3751
+ }
3752
+ function handleStatus(directory, instance) {
3753
+ const definition = loadWorkflowDefinition(instance.definition_path);
3754
+ let stepsDisplay = "";
3755
+ if (definition) {
3756
+ const lines = [];
3757
+ for (const stepDef of definition.steps) {
3758
+ const stepState = instance.steps[stepDef.id];
3759
+ if (!stepState)
3760
+ continue;
3761
+ if (stepState.status === "completed") {
3762
+ const summary = stepState.summary ? ` → ${truncate(stepState.summary, 80)}` : "";
3763
+ lines.push(`- [✓] ${stepDef.name}${summary}`);
3764
+ } else if (stepState.status === "active") {
3765
+ lines.push(`- [→] ${stepDef.name} (active)`);
3766
+ } else if (stepState.status === "skipped") {
3767
+ lines.push(`- [⊘] ${stepDef.name} (skipped)`);
3768
+ } else {
3769
+ lines.push(`- [ ] ${stepDef.name}`);
3770
+ }
3771
+ }
3772
+ stepsDisplay = lines.join(`
3773
+ `);
3016
3774
  }
3017
- if (state.paused) {
3018
- return { continuationPrompt: null };
3775
+ const completedCount = Object.values(instance.steps).filter((s) => s.status === "completed").length;
3776
+ const totalCount = Object.keys(instance.steps).length;
3777
+ return {
3778
+ handled: true,
3779
+ contextInjection: `## Workflow Status: ${instance.definition_name}
3780
+ **Goal**: "${instance.goal}"
3781
+ **Instance**: ${instance.instance_id}
3782
+ **Status**: ${instance.status}
3783
+ **Progress**: ${completedCount}/${totalCount} steps
3784
+
3785
+ ### Steps
3786
+ ${stepsDisplay}`
3787
+ };
3788
+ }
3789
+ function truncate(text, maxLength) {
3790
+ if (text.length <= maxLength)
3791
+ return text;
3792
+ return text.slice(0, maxLength - 3) + "...";
3793
+ }
3794
+ // src/hooks/start-work-hook.ts
3795
+ function handleStartWork(input) {
3796
+ const { promptText, sessionId, directory } = input;
3797
+ if (!promptText.includes("<session-context>")) {
3798
+ return { contextInjection: null, switchAgent: null };
3019
3799
  }
3020
- if (state.session_ids.length > 0 && !state.session_ids.includes(input.sessionId)) {
3021
- return { continuationPrompt: null };
3800
+ const workflowWarning = checkWorkflowActive(directory);
3801
+ if (workflowWarning) {
3802
+ return { contextInjection: workflowWarning, switchAgent: null };
3022
3803
  }
3023
- const progress = getPlanProgress(state.active_plan);
3024
- if (progress.isComplete) {
3025
- return { continuationPrompt: null };
3804
+ const explicitPlanName = extractPlanName(promptText);
3805
+ const existingState = readWorkState(directory);
3806
+ const allPlans = findPlans(directory);
3807
+ if (explicitPlanName) {
3808
+ return handleExplicitPlan(explicitPlanName, allPlans, sessionId, directory);
3026
3809
  }
3027
- if (state.continuation_completed_snapshot === undefined) {
3028
- state.continuation_completed_snapshot = progress.completed;
3029
- state.stale_continuation_count = 0;
3030
- writeWorkState(directory, state);
3031
- } else if (progress.completed > state.continuation_completed_snapshot) {
3032
- state.continuation_completed_snapshot = progress.completed;
3033
- state.stale_continuation_count = 0;
3034
- writeWorkState(directory, state);
3035
- } else {
3036
- state.stale_continuation_count = (state.stale_continuation_count ?? 0) + 1;
3037
- if (state.stale_continuation_count >= MAX_STALE_CONTINUATIONS) {
3038
- state.paused = true;
3039
- writeWorkState(directory, state);
3040
- return { continuationPrompt: null };
3810
+ if (existingState) {
3811
+ const progress = getPlanProgress(existingState.active_plan);
3812
+ if (!progress.isComplete) {
3813
+ const validation = validatePlan(existingState.active_plan, directory);
3814
+ if (!validation.valid) {
3815
+ clearWorkState(directory);
3816
+ return {
3817
+ switchAgent: "tapestry",
3818
+ contextInjection: `## Plan Validation Failed
3819
+ The active plan "${existingState.plan_name}" has structural issues. Work state has been cleared.
3820
+
3821
+ ${formatValidationResults(validation)}
3822
+
3823
+ Tell the user to fix the plan file and run /start-work again.`
3824
+ };
3825
+ }
3826
+ appendSessionId(directory, sessionId);
3827
+ resumeWork(directory);
3828
+ const resumeContext = buildResumeContext(existingState.active_plan, existingState.plan_name, progress, existingState.start_sha);
3829
+ if (validation.warnings.length > 0) {
3830
+ return {
3831
+ switchAgent: "tapestry",
3832
+ contextInjection: `${resumeContext}
3833
+
3834
+ ### Validation Warnings
3835
+ ${formatValidationResults(validation)}`
3836
+ };
3837
+ }
3838
+ return {
3839
+ switchAgent: "tapestry",
3840
+ contextInjection: resumeContext
3841
+ };
3842
+ }
3843
+ }
3844
+ return handlePlanDiscovery(allPlans, sessionId, directory);
3845
+ }
3846
+ function checkWorkflowActive(directory) {
3847
+ const instance = getActiveWorkflowInstance(directory);
3848
+ if (!instance)
3849
+ return null;
3850
+ if (instance.status !== "running" && instance.status !== "paused")
3851
+ return null;
3852
+ const definition = loadWorkflowDefinition(instance.definition_path);
3853
+ const totalSteps = definition ? definition.steps.length : 0;
3854
+ const completedSteps = Object.values(instance.steps).filter((s) => s.status === "completed").length;
3855
+ const status = instance.status === "paused" ? "paused" : "running";
3856
+ return `## Active Workflow Detected
3857
+
3858
+ There is currently an active workflow: "${instance.definition_name}" (${instance.instance_id})
3859
+ Goal: "${instance.goal}"
3860
+ Status: ${status} • Progress: ${completedSteps}/${totalSteps} steps complete
3861
+
3862
+ Starting plan execution will conflict with the active workflow — both systems use the idle loop and only one can drive at a time.
3863
+
3864
+ **Options:**
3865
+ - **Proceed anyway** — the workflow will be paused and can be resumed with \`/run-workflow\` after the plan completes
3866
+ - **Abort the workflow first** — cancel the workflow, then start the plan
3867
+ - **Cancel** — don't start the plan, continue with the workflow`;
3868
+ }
3869
+ function extractPlanName(promptText) {
3870
+ const match = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i);
3871
+ if (!match)
3872
+ return null;
3873
+ const cleaned = match[1].trim();
3874
+ return cleaned || null;
3875
+ }
3876
+ function handleExplicitPlan(requestedName, allPlans, sessionId, directory) {
3877
+ const matched = findPlanByName(allPlans, requestedName);
3878
+ if (!matched) {
3879
+ const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete);
3880
+ const listing = incompletePlans.length > 0 ? incompletePlans.map((p) => ` - ${getPlanName(p)}`).join(`
3881
+ `) : " (none)";
3882
+ return {
3883
+ switchAgent: "tapestry",
3884
+ contextInjection: `## Plan Not Found
3885
+ No plan matching "${requestedName}" was found.
3886
+
3887
+ Available incomplete plans:
3888
+ ${listing}
3889
+
3890
+ Tell the user which plans are available and ask them to specify one.`
3891
+ };
3892
+ }
3893
+ const progress = getPlanProgress(matched);
3894
+ if (progress.isComplete) {
3895
+ return {
3896
+ switchAgent: "tapestry",
3897
+ contextInjection: `## Plan Already Complete
3898
+ The plan "${getPlanName(matched)}" has all ${progress.total} tasks completed.
3899
+ Tell the user this plan is already done and suggest creating a new one with Pattern.`
3900
+ };
3901
+ }
3902
+ const validation = validatePlan(matched, directory);
3903
+ if (!validation.valid) {
3904
+ return {
3905
+ switchAgent: "tapestry",
3906
+ contextInjection: `## Plan Validation Failed
3907
+ The plan "${getPlanName(matched)}" has structural issues that must be fixed before execution can begin.
3908
+
3909
+ ${formatValidationResults(validation)}
3910
+
3911
+ Tell the user to fix these issues in the plan file and try again.`
3912
+ };
3913
+ }
3914
+ clearWorkState(directory);
3915
+ const state = createWorkState(matched, sessionId, "tapestry", directory);
3916
+ writeWorkState(directory, state);
3917
+ const freshContext = buildFreshContext(matched, getPlanName(matched), progress, state.start_sha);
3918
+ if (validation.warnings.length > 0) {
3919
+ return {
3920
+ switchAgent: "tapestry",
3921
+ contextInjection: `${freshContext}
3922
+
3923
+ ### Validation Warnings
3924
+ ${formatValidationResults(validation)}`
3925
+ };
3926
+ }
3927
+ return {
3928
+ switchAgent: "tapestry",
3929
+ contextInjection: freshContext
3930
+ };
3931
+ }
3932
+ function handlePlanDiscovery(allPlans, sessionId, directory) {
3933
+ if (allPlans.length === 0) {
3934
+ return {
3935
+ switchAgent: "tapestry",
3936
+ contextInjection: "## No Plans Found\nNo plan files found at `.weave/plans/`.\nTell the user to switch to Pattern agent to create a work plan first."
3937
+ };
3938
+ }
3939
+ const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete);
3940
+ if (incompletePlans.length === 0) {
3941
+ return {
3942
+ switchAgent: "tapestry",
3943
+ contextInjection: `## All Plans Complete
3944
+ All existing plans have been completed.
3945
+ Tell the user to switch to Pattern agent to create a new plan.`
3946
+ };
3947
+ }
3948
+ if (incompletePlans.length === 1) {
3949
+ const plan = incompletePlans[0];
3950
+ const progress = getPlanProgress(plan);
3951
+ const validation = validatePlan(plan, directory);
3952
+ if (!validation.valid) {
3953
+ return {
3954
+ switchAgent: "tapestry",
3955
+ contextInjection: `## Plan Validation Failed
3956
+ The plan "${getPlanName(plan)}" has structural issues that must be fixed before execution can begin.
3957
+
3958
+ ${formatValidationResults(validation)}
3959
+
3960
+ Tell the user to fix these issues in the plan file and try again.`
3961
+ };
3962
+ }
3963
+ const state = createWorkState(plan, sessionId, "tapestry", directory);
3964
+ writeWorkState(directory, state);
3965
+ const freshContext = buildFreshContext(plan, getPlanName(plan), progress, state.start_sha);
3966
+ if (validation.warnings.length > 0) {
3967
+ return {
3968
+ switchAgent: "tapestry",
3969
+ contextInjection: `${freshContext}
3970
+
3971
+ ### Validation Warnings
3972
+ ${formatValidationResults(validation)}`
3973
+ };
3974
+ }
3975
+ return {
3976
+ switchAgent: "tapestry",
3977
+ contextInjection: freshContext
3978
+ };
3979
+ }
3980
+ const listing = incompletePlans.map((p) => {
3981
+ const progress = getPlanProgress(p);
3982
+ return ` - **${getPlanName(p)}** (${progress.completed}/${progress.total} tasks done)`;
3983
+ }).join(`
3984
+ `);
3985
+ return {
3986
+ switchAgent: "tapestry",
3987
+ contextInjection: `## Multiple Plans Found
3988
+ There are ${incompletePlans.length} incomplete plans:
3989
+ ${listing}
3990
+
3991
+ Ask the user which plan to work on. They can run \`/start-work [plan-name]\` to select one.`
3992
+ };
3993
+ }
3994
+ function findPlanByName(plans, requestedName) {
3995
+ const lower = requestedName.toLowerCase();
3996
+ const exact = plans.find((p) => getPlanName(p).toLowerCase() === lower);
3997
+ if (exact)
3998
+ return exact;
3999
+ const partial = plans.find((p) => getPlanName(p).toLowerCase().includes(lower));
4000
+ return partial || null;
4001
+ }
4002
+ function formatValidationResults(result) {
4003
+ const lines = [];
4004
+ if (result.errors.length > 0) {
4005
+ lines.push("**Errors (blocking):**");
4006
+ for (const err of result.errors) {
4007
+ lines.push(`- [${err.category}] ${err.message}`);
4008
+ }
4009
+ }
4010
+ if (result.warnings.length > 0) {
4011
+ if (result.errors.length > 0)
4012
+ lines.push("");
4013
+ lines.push("**Warnings:**");
4014
+ for (const warn of result.warnings) {
4015
+ lines.push(`- [${warn.category}] ${warn.message}`);
4016
+ }
4017
+ }
4018
+ return lines.join(`
4019
+ `);
4020
+ }
4021
+ function buildFreshContext(planPath, planName, progress, startSha) {
4022
+ const shaLine = startSha ? `
4023
+ **Start SHA**: ${startSha}` : "";
4024
+ return `## Starting Plan: ${planName}
4025
+ **Plan file**: ${planPath}
4026
+ **Progress**: ${progress.completed}/${progress.total} tasks completed${shaLine}
4027
+
4028
+ Read the plan file now and begin executing from the first unchecked \`- [ ]\` task.
4029
+
4030
+ **SIDEBAR TODOS — DO THIS FIRST:**
4031
+ Before starting any work, use todowrite to populate the sidebar:
4032
+ 1. Create a summary todo (in_progress): "${planName} ${progress.completed}/${progress.total}"
4033
+ 2. Create a todo for the first unchecked task (in_progress)
4034
+ 3. Create todos for the next 2-3 tasks (pending)
4035
+ Keep each todo under 35 chars. Update as you complete tasks.`;
4036
+ }
4037
+ function buildResumeContext(planPath, planName, progress, startSha) {
4038
+ const remaining = progress.total - progress.completed;
4039
+ const shaLine = startSha ? `
4040
+ **Start SHA**: ${startSha}` : "";
4041
+ return `## Resuming Plan: ${planName}
4042
+ **Plan file**: ${planPath}
4043
+ **Progress**: ${progress.completed}/${progress.total} tasks completed
4044
+ **Status**: RESUMING — continuing from where the previous session left off.${shaLine}
4045
+
4046
+ Read the plan file now and continue from the first unchecked \`- [ ]\` task.
4047
+
4048
+ **SIDEBAR TODOS — RESTORE STATE:**
4049
+ Previous session's todos are lost. Use todowrite to restore the sidebar:
4050
+ 1. Create a summary todo (in_progress): "${planName} ${progress.completed}/${progress.total}"
4051
+ 2. Create a todo for the next unchecked task (in_progress)
4052
+ 3. Create todos for the following 2-3 tasks (pending)
4053
+ Keep each todo under 35 chars. ${remaining} task${remaining !== 1 ? "s" : ""} remaining.`;
4054
+ }
4055
+
4056
+ // src/hooks/work-continuation.ts
4057
+ var CONTINUATION_MARKER = "<!-- weave:continuation -->";
4058
+ var MAX_STALE_CONTINUATIONS = 3;
4059
+ function checkContinuation(input) {
4060
+ const { directory } = input;
4061
+ const state = readWorkState(directory);
4062
+ if (!state) {
4063
+ return { continuationPrompt: null };
4064
+ }
4065
+ if (state.paused) {
4066
+ return { continuationPrompt: null };
4067
+ }
4068
+ if (state.session_ids.length > 0 && !state.session_ids.includes(input.sessionId)) {
4069
+ return { continuationPrompt: null };
4070
+ }
4071
+ const progress = getPlanProgress(state.active_plan);
4072
+ if (progress.isComplete) {
4073
+ return { continuationPrompt: null };
4074
+ }
4075
+ if (state.continuation_completed_snapshot === undefined) {
4076
+ state.continuation_completed_snapshot = progress.completed;
4077
+ state.stale_continuation_count = 0;
4078
+ writeWorkState(directory, state);
4079
+ } else if (progress.completed > state.continuation_completed_snapshot) {
4080
+ state.continuation_completed_snapshot = progress.completed;
4081
+ state.stale_continuation_count = 0;
4082
+ writeWorkState(directory, state);
4083
+ } else {
4084
+ state.stale_continuation_count = (state.stale_continuation_count ?? 0) + 1;
4085
+ if (state.stale_continuation_count >= MAX_STALE_CONTINUATIONS) {
4086
+ state.paused = true;
4087
+ writeWorkState(directory, state);
4088
+ return { continuationPrompt: null };
4089
+ }
4090
+ writeWorkState(directory, state);
4091
+ }
4092
+ const remaining = progress.total - progress.completed;
4093
+ return {
4094
+ continuationPrompt: `${CONTINUATION_MARKER}
4095
+ You have an active work plan with incomplete tasks. Continue working.
4096
+
4097
+ **Plan**: ${state.plan_name}
4098
+ **File**: ${state.active_plan}
4099
+ **Progress**: ${progress.completed}/${progress.total} tasks completed (${remaining} remaining)
4100
+
4101
+ 1. Read the plan file NOW to check exact current progress
4102
+ 2. Use todowrite to restore sidebar: summary todo "${state.plan_name} ${progress.completed}/${progress.total}" (in_progress) + next task (in_progress) + 2-3 upcoming (pending). Max 35 chars each.
4103
+ 3. Find the first unchecked \`- [ ]\` task
4104
+ 4. Execute it, verify it, mark \`- [ ]\` → \`- [x]\`
4105
+ 5. Update sidebar todos as you complete tasks
4106
+ 6. Do not stop until all tasks are complete`
4107
+ };
4108
+ }
4109
+
4110
+ // src/hooks/verification-reminder.ts
4111
+ function buildVerificationReminder(input) {
4112
+ const planContext = input.planName && input.progress ? `
4113
+ **Plan**: ${input.planName} (${input.progress.completed}/${input.progress.total} tasks done)` : "";
4114
+ return {
4115
+ verificationPrompt: `## Verification Required
4116
+ ${planContext}
4117
+
4118
+ Before marking this task complete, verify the work:
4119
+
4120
+ 1. **Read the changes**: \`git diff --stat\` then Read each changed file
4121
+ 2. **Run checks**: Run relevant tests, check for linting/type errors
4122
+ 3. **Validate behavior**: Does the code actually do what was requested?
4123
+ 4. **Gate decision**: Can you explain what every changed line does?
4124
+
4125
+ If uncertain about quality, delegate to \`weft\` agent for a formal review:
4126
+ \`call_weave_agent(agent="weft", prompt="Review the changes for [task description]")\`
4127
+
4128
+ MANDATORY: If changes touch auth, crypto, certificates, tokens, signatures, or input validation, you MUST delegate to \`warp\` agent for a security audit — this is NOT optional:
4129
+ \`call_weave_agent(agent="warp", prompt="Security audit the changes for [task description]")\`
4130
+
4131
+ Only mark complete when ALL checks pass.`
4132
+ };
4133
+ }
4134
+
4135
+ // src/hooks/create-hooks.ts
4136
+ function createHooks(args) {
4137
+ const { pluginConfig, isHookEnabled, directory, analyticsEnabled = false } = args;
4138
+ const writeGuardState = createWriteGuardState();
4139
+ const writeGuard = createWriteGuard(writeGuardState);
4140
+ const contextWindowThresholds = {
4141
+ warningPct: pluginConfig.experimental?.context_window_warning_threshold ?? 0.8,
4142
+ criticalPct: pluginConfig.experimental?.context_window_critical_threshold ?? 0.95
4143
+ };
4144
+ return {
4145
+ checkContextWindow: isHookEnabled("context-window-monitor") ? (state) => checkContextWindow(state, contextWindowThresholds) : null,
4146
+ writeGuard: isHookEnabled("write-existing-file-guard") ? writeGuard : null,
4147
+ shouldInjectRules: isHookEnabled("rules-injector") ? shouldInjectRules : null,
4148
+ getRulesForFile: isHookEnabled("rules-injector") ? getRulesForFile : null,
4149
+ firstMessageVariant: isHookEnabled("first-message-variant") ? { shouldApplyVariant, markApplied, markSessionCreated, clearSession } : null,
4150
+ processMessageForKeywords: isHookEnabled("keyword-detector") ? processMessageForKeywords : null,
4151
+ patternMdOnly: isHookEnabled("pattern-md-only") ? checkPatternWrite : null,
4152
+ startWork: isHookEnabled("start-work") ? (promptText, sessionId) => handleStartWork({ promptText, sessionId, directory }) : null,
4153
+ workContinuation: isHookEnabled("work-continuation") ? (sessionId) => checkContinuation({ sessionId, directory }) : null,
4154
+ workflowStart: isHookEnabled("workflow") ? (promptText, sessionId) => handleRunWorkflow({ promptText, sessionId, directory }) : null,
4155
+ workflowContinuation: isHookEnabled("workflow") ? (sessionId, lastAssistantMessage, lastUserMessage) => checkWorkflowContinuation({ sessionId, directory, lastAssistantMessage, lastUserMessage }) : null,
4156
+ workflowCommand: isHookEnabled("workflow") ? (message) => handleWorkflowCommand(message, directory) : null,
4157
+ verificationReminder: isHookEnabled("verification-reminder") ? buildVerificationReminder : null,
4158
+ analyticsEnabled
4159
+ };
4160
+ }
4161
+ // src/hooks/session-token-state.ts
4162
+ var sessionMap = new Map;
4163
+ function setContextLimit(sessionId, maxTokens) {
4164
+ const existing = sessionMap.get(sessionId);
4165
+ sessionMap.set(sessionId, {
4166
+ usedTokens: existing?.usedTokens ?? 0,
4167
+ maxTokens
4168
+ });
4169
+ }
4170
+ function updateUsage(sessionId, inputTokens) {
4171
+ if (inputTokens <= 0)
4172
+ return;
4173
+ const existing = sessionMap.get(sessionId);
4174
+ sessionMap.set(sessionId, {
4175
+ maxTokens: existing?.maxTokens ?? 0,
4176
+ usedTokens: inputTokens
4177
+ });
4178
+ }
4179
+ function getState(sessionId) {
4180
+ return sessionMap.get(sessionId);
4181
+ }
4182
+ function clearSession2(sessionId) {
4183
+ sessionMap.delete(sessionId);
4184
+ }
4185
+ // src/features/analytics/storage.ts
4186
+ import { existsSync as existsSync12, mkdirSync as mkdirSync4, appendFileSync as appendFileSync2, readFileSync as readFileSync9, writeFileSync as writeFileSync3, statSync as statSync2 } from "fs";
4187
+ import { join as join10 } from "path";
4188
+
4189
+ // src/features/analytics/types.ts
4190
+ var ANALYTICS_DIR = ".weave/analytics";
4191
+ var SESSION_SUMMARIES_FILE = "session-summaries.jsonl";
4192
+ var FINGERPRINT_FILE = "fingerprint.json";
4193
+ var METRICS_REPORTS_FILE = "metrics-reports.jsonl";
4194
+ var MAX_METRICS_ENTRIES = 100;
4195
+ function zeroTokenUsage() {
4196
+ return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
4197
+ }
4198
+
4199
+ // src/features/analytics/storage.ts
4200
+ var MAX_SESSION_ENTRIES = 1000;
4201
+ function ensureAnalyticsDir(directory) {
4202
+ const dir = join10(directory, ANALYTICS_DIR);
4203
+ mkdirSync4(dir, { recursive: true, mode: 448 });
4204
+ return dir;
4205
+ }
4206
+ function appendSessionSummary(directory, summary) {
4207
+ try {
4208
+ const dir = ensureAnalyticsDir(directory);
4209
+ const filePath = join10(dir, SESSION_SUMMARIES_FILE);
4210
+ const line = JSON.stringify(summary) + `
4211
+ `;
4212
+ appendFileSync2(filePath, line, { encoding: "utf-8", mode: 384 });
4213
+ try {
4214
+ const TYPICAL_ENTRY_BYTES = 200;
4215
+ const rotationSizeThreshold = MAX_SESSION_ENTRIES * TYPICAL_ENTRY_BYTES * 0.9;
4216
+ const { size } = statSync2(filePath);
4217
+ if (size > rotationSizeThreshold) {
4218
+ const content = readFileSync9(filePath, "utf-8");
4219
+ const lines = content.split(`
4220
+ `).filter((l) => l.trim().length > 0);
4221
+ if (lines.length > MAX_SESSION_ENTRIES) {
4222
+ const trimmed = lines.slice(-MAX_SESSION_ENTRIES).join(`
4223
+ `) + `
4224
+ `;
4225
+ writeFileSync3(filePath, trimmed, { encoding: "utf-8", mode: 384 });
4226
+ }
4227
+ }
4228
+ } catch {}
4229
+ return true;
4230
+ } catch {
4231
+ return false;
4232
+ }
4233
+ }
4234
+ function readSessionSummaries(directory) {
4235
+ const filePath = join10(directory, ANALYTICS_DIR, SESSION_SUMMARIES_FILE);
4236
+ try {
4237
+ if (!existsSync12(filePath))
4238
+ return [];
4239
+ const content = readFileSync9(filePath, "utf-8");
4240
+ const lines = content.split(`
4241
+ `).filter((line) => line.trim().length > 0);
4242
+ const summaries = [];
4243
+ for (const line of lines) {
4244
+ try {
4245
+ summaries.push(JSON.parse(line));
4246
+ } catch {}
4247
+ }
4248
+ return summaries;
4249
+ } catch {
4250
+ return [];
4251
+ }
4252
+ }
4253
+ function writeFingerprint(directory, fingerprint) {
4254
+ try {
4255
+ const dir = ensureAnalyticsDir(directory);
4256
+ const filePath = join10(dir, FINGERPRINT_FILE);
4257
+ writeFileSync3(filePath, JSON.stringify(fingerprint, null, 2), { encoding: "utf-8", mode: 384 });
4258
+ return true;
4259
+ } catch {
4260
+ return false;
4261
+ }
4262
+ }
4263
+ function readFingerprint(directory) {
4264
+ const filePath = join10(directory, ANALYTICS_DIR, FINGERPRINT_FILE);
4265
+ try {
4266
+ if (!existsSync12(filePath))
4267
+ return null;
4268
+ const content = readFileSync9(filePath, "utf-8");
4269
+ const parsed = JSON.parse(content);
4270
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.stack))
4271
+ return null;
4272
+ return parsed;
4273
+ } catch {
4274
+ return null;
4275
+ }
4276
+ }
4277
+ function writeMetricsReport(directory, report) {
4278
+ try {
4279
+ const dir = ensureAnalyticsDir(directory);
4280
+ const filePath = join10(dir, METRICS_REPORTS_FILE);
4281
+ const line = JSON.stringify(report) + `
4282
+ `;
4283
+ appendFileSync2(filePath, line, { encoding: "utf-8", mode: 384 });
4284
+ try {
4285
+ const TYPICAL_ENTRY_BYTES = 200;
4286
+ const rotationSizeThreshold = MAX_METRICS_ENTRIES * TYPICAL_ENTRY_BYTES * 0.9;
4287
+ const { size } = statSync2(filePath);
4288
+ if (size > rotationSizeThreshold) {
4289
+ const content = readFileSync9(filePath, "utf-8");
4290
+ const lines = content.split(`
4291
+ `).filter((l) => l.trim().length > 0);
4292
+ if (lines.length > MAX_METRICS_ENTRIES) {
4293
+ const trimmed = lines.slice(-MAX_METRICS_ENTRIES).join(`
4294
+ `) + `
4295
+ `;
4296
+ writeFileSync3(filePath, trimmed, { encoding: "utf-8", mode: 384 });
4297
+ }
4298
+ }
4299
+ } catch {}
4300
+ return true;
4301
+ } catch {
4302
+ return false;
4303
+ }
4304
+ }
4305
+ function readMetricsReports(directory) {
4306
+ const filePath = join10(directory, ANALYTICS_DIR, METRICS_REPORTS_FILE);
4307
+ try {
4308
+ if (!existsSync12(filePath))
4309
+ return [];
4310
+ const content = readFileSync9(filePath, "utf-8");
4311
+ const lines = content.split(`
4312
+ `).filter((line) => line.trim().length > 0);
4313
+ const reports = [];
4314
+ for (const line of lines) {
4315
+ try {
4316
+ reports.push(JSON.parse(line));
4317
+ } catch {}
4318
+ }
4319
+ return reports;
4320
+ } catch {
4321
+ return [];
4322
+ }
4323
+ }
4324
+
4325
+ // src/features/analytics/token-report.ts
4326
+ function generateTokenReport(summaries) {
4327
+ if (summaries.length === 0) {
4328
+ return "No session data available.";
4329
+ }
4330
+ const sections = [];
4331
+ const totalSessions = summaries.length;
4332
+ const totalMessages = summaries.reduce((sum, s) => sum + (s.tokenUsage?.totalMessages ?? 0), 0);
4333
+ const totalInput = summaries.reduce((sum, s) => sum + (s.tokenUsage?.inputTokens ?? 0), 0);
4334
+ const totalOutput = summaries.reduce((sum, s) => sum + (s.tokenUsage?.outputTokens ?? 0), 0);
4335
+ const totalReasoning = summaries.reduce((sum, s) => sum + (s.tokenUsage?.reasoningTokens ?? 0), 0);
4336
+ const totalCacheRead = summaries.reduce((sum, s) => sum + (s.tokenUsage?.cacheReadTokens ?? 0), 0);
4337
+ const totalCacheWrite = summaries.reduce((sum, s) => sum + (s.tokenUsage?.cacheWriteTokens ?? 0), 0);
4338
+ const totalCost = summaries.reduce((sum, s) => sum + (s.totalCost ?? 0), 0);
4339
+ sections.push(`## Overall Totals
4340
+ ` + `- Sessions: ${fmt(totalSessions)}
4341
+ ` + `- Messages: ${fmt(totalMessages)}
4342
+ ` + `- Input tokens: ${fmt(totalInput)}
4343
+ ` + `- Output tokens: ${fmt(totalOutput)}
4344
+ ` + `- Reasoning tokens: ${fmt(totalReasoning)}
4345
+ ` + `- Cache read tokens: ${fmt(totalCacheRead)}
4346
+ ` + `- Cache write tokens: ${fmt(totalCacheWrite)}
4347
+ ` + `- Total cost: ${fmtCost(totalCost)}`);
4348
+ const agentGroups = new Map;
4349
+ for (const s of summaries) {
4350
+ const key = s.agentName ?? "(unknown)";
4351
+ const group = agentGroups.get(key);
4352
+ if (group) {
4353
+ group.push(s);
4354
+ } else {
4355
+ agentGroups.set(key, [s]);
4356
+ }
4357
+ }
4358
+ const agentStats = Array.from(agentGroups.entries()).map(([agent, sessions]) => {
4359
+ const agentCost = sessions.reduce((sum, s) => sum + (s.totalCost ?? 0), 0);
4360
+ const agentTokens = sessions.reduce((sum, s) => sum + (s.tokenUsage?.inputTokens ?? 0) + (s.tokenUsage?.outputTokens ?? 0) + (s.tokenUsage?.reasoningTokens ?? 0), 0);
4361
+ const avgTokens = sessions.length > 0 ? Math.round(agentTokens / sessions.length) : 0;
4362
+ const avgCost = sessions.length > 0 ? agentCost / sessions.length : 0;
4363
+ return { agent, sessions: sessions.length, avgTokens, avgCost, totalCost: agentCost };
4364
+ }).sort((a, b) => b.totalCost - a.totalCost);
4365
+ const agentLines = agentStats.map((a) => `- **${a.agent}**: ${fmt(a.sessions)} session${a.sessions === 1 ? "" : "s"}, ` + `avg ${fmt(a.avgTokens)} tokens/session, ` + `avg ${fmtCost(a.avgCost)}/session, ` + `total ${fmtCost(a.totalCost)}`);
4366
+ sections.push(`## Per-Agent Breakdown
4367
+ ${agentLines.join(`
4368
+ `)}`);
4369
+ const top5 = [...summaries].sort((a, b) => (b.totalCost ?? 0) - (a.totalCost ?? 0)).slice(0, 5);
4370
+ const top5Lines = top5.map((s) => {
4371
+ const id = s.sessionId.length > 8 ? s.sessionId.slice(0, 8) : s.sessionId;
4372
+ const agent = s.agentName ?? "(unknown)";
4373
+ const cost = fmtCost(s.totalCost ?? 0);
4374
+ const tokens = (s.tokenUsage?.inputTokens ?? 0) + (s.tokenUsage?.outputTokens ?? 0) + (s.tokenUsage?.reasoningTokens ?? 0);
4375
+ const duration = fmtDuration(s.durationMs);
4376
+ return `- \`${id}\` ${agent} — ${cost}, ${fmt(tokens)} tokens, ${duration}`;
4377
+ });
4378
+ sections.push(`## Top 5 Costliest Sessions
4379
+ ${top5Lines.join(`
4380
+ `)}`);
4381
+ return sections.join(`
4382
+
4383
+ `);
4384
+ }
4385
+ function fmt(n) {
4386
+ return n.toLocaleString("en-US");
4387
+ }
4388
+ function fmtCost(n) {
4389
+ return `$${n.toFixed(2)}`;
4390
+ }
4391
+ function fmtDuration(ms) {
4392
+ const totalSeconds = Math.round(ms / 1000);
4393
+ const minutes = Math.floor(totalSeconds / 60);
4394
+ const seconds = totalSeconds % 60;
4395
+ if (minutes === 0)
4396
+ return `${seconds}s`;
4397
+ return `${minutes}m ${seconds}s`;
4398
+ }
4399
+
4400
+ // src/features/analytics/format-metrics.ts
4401
+ function formatNumber(n) {
4402
+ return n.toLocaleString("en-US");
4403
+ }
4404
+ function formatDuration(ms) {
4405
+ const totalSeconds = Math.round(ms / 1000);
4406
+ if (totalSeconds < 60)
4407
+ return `${totalSeconds}s`;
4408
+ const minutes = Math.floor(totalSeconds / 60);
4409
+ const seconds = totalSeconds % 60;
4410
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
4411
+ }
4412
+ function formatDate(iso) {
4413
+ try {
4414
+ const d = new Date(iso);
4415
+ return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
4416
+ } catch {
4417
+ return iso;
4418
+ }
4419
+ }
4420
+ function formatReport(report) {
4421
+ const lines = [];
4422
+ const date = formatDate(report.generatedAt);
4423
+ lines.push(`#### \uD83D\uDCCA ${report.planName} (${date})`);
4424
+ lines.push("");
4425
+ lines.push("| Metric | Value |");
4426
+ lines.push("|--------|-------|");
4427
+ lines.push(`| Coverage | ${Math.round(report.adherence.coverage * 100)}% |`);
4428
+ lines.push(`| Precision | ${Math.round(report.adherence.precision * 100)}% |`);
4429
+ lines.push(`| Sessions | ${report.sessionCount} |`);
4430
+ lines.push(`| Duration | ${formatDuration(report.durationMs)} |`);
4431
+ lines.push(`| Input Tokens | ${formatNumber(report.tokenUsage.input)} |`);
4432
+ lines.push(`| Output Tokens | ${formatNumber(report.tokenUsage.output)} |`);
4433
+ if (report.tokenUsage.reasoning > 0) {
4434
+ lines.push(`| Reasoning Tokens | ${formatNumber(report.tokenUsage.reasoning)} |`);
4435
+ }
4436
+ if (report.tokenUsage.cacheRead > 0 || report.tokenUsage.cacheWrite > 0) {
4437
+ lines.push(`| Cache Read | ${formatNumber(report.tokenUsage.cacheRead)} |`);
4438
+ lines.push(`| Cache Write | ${formatNumber(report.tokenUsage.cacheWrite)} |`);
4439
+ }
4440
+ if (report.adherence.unplannedChanges.length > 0) {
4441
+ lines.push("");
4442
+ lines.push(`**Unplanned Changes**: ${report.adherence.unplannedChanges.map((f) => `\`${f}\``).join(", ")}`);
4443
+ }
4444
+ if (report.adherence.missedFiles.length > 0) {
4445
+ lines.push("");
4446
+ lines.push(`**Missed Files**: ${report.adherence.missedFiles.map((f) => `\`${f}\``).join(", ")}`);
4447
+ }
4448
+ return lines.join(`
4449
+ `);
4450
+ }
4451
+ function aggregateSessionTokens(summaries) {
4452
+ const total = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
4453
+ for (const s of summaries) {
4454
+ if (s.tokenUsage) {
4455
+ total.input += s.tokenUsage.inputTokens;
4456
+ total.output += s.tokenUsage.outputTokens;
4457
+ total.reasoning += s.tokenUsage.reasoningTokens;
4458
+ total.cacheRead += s.tokenUsage.cacheReadTokens;
4459
+ total.cacheWrite += s.tokenUsage.cacheWriteTokens;
4460
+ }
4461
+ }
4462
+ return total;
4463
+ }
4464
+ function topTools(summaries, limit = 5) {
4465
+ const counts = {};
4466
+ for (const s of summaries) {
4467
+ for (const t of s.toolUsage) {
4468
+ counts[t.tool] = (counts[t.tool] ?? 0) + t.count;
4469
+ }
4470
+ }
4471
+ return Object.entries(counts).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count).slice(0, limit);
4472
+ }
4473
+ function formatMetricsMarkdown(reports, summaries, args) {
4474
+ if (reports.length === 0 && summaries.length === 0) {
4475
+ return [
4476
+ "## Weave Metrics Dashboard",
4477
+ "",
4478
+ "No metrics data yet.",
4479
+ "",
4480
+ "To generate metrics:",
4481
+ '1. Enable analytics in `weave.json`: `{ "analytics": { "enabled": true } }`',
4482
+ "2. Create and complete a plan using Pattern and `/start-work`",
4483
+ "3. Metrics are generated automatically when a plan completes"
4484
+ ].join(`
4485
+ `);
4486
+ }
4487
+ const lines = ["## Weave Metrics Dashboard"];
4488
+ let filteredReports = reports;
4489
+ const trimmedArgs = args?.trim() ?? "";
4490
+ if (trimmedArgs && trimmedArgs !== "all") {
4491
+ filteredReports = reports.filter((r) => r.planName.toLowerCase().includes(trimmedArgs.toLowerCase()));
4492
+ }
4493
+ if (!trimmedArgs || trimmedArgs === "") {
4494
+ filteredReports = filteredReports.slice(-5);
4495
+ }
4496
+ if (filteredReports.length > 0) {
4497
+ lines.push("");
4498
+ lines.push("### Recent Plan Metrics");
4499
+ for (let i = 0;i < filteredReports.length; i++) {
4500
+ lines.push("");
4501
+ lines.push(formatReport(filteredReports[i]));
4502
+ if (i < filteredReports.length - 1) {
4503
+ lines.push("");
4504
+ lines.push("---");
4505
+ }
4506
+ }
4507
+ } else if (reports.length > 0 && trimmedArgs) {
4508
+ lines.push("");
4509
+ lines.push(`### Recent Plan Metrics`);
4510
+ lines.push("");
4511
+ lines.push(`No reports found matching "${trimmedArgs}".`);
4512
+ }
4513
+ if (summaries.length > 0) {
4514
+ lines.push("");
4515
+ lines.push("---");
4516
+ lines.push("");
4517
+ lines.push("### Aggregate Session Stats");
4518
+ lines.push("");
4519
+ const totalTokens = aggregateSessionTokens(summaries);
4520
+ const avgDuration = summaries.reduce((sum, s) => sum + s.durationMs, 0) / summaries.length;
4521
+ const tools = topTools(summaries);
4522
+ lines.push(`- **Sessions tracked**: ${summaries.length}`);
4523
+ lines.push(`- **Total input tokens**: ${formatNumber(totalTokens.input)}`);
4524
+ lines.push(`- **Total output tokens**: ${formatNumber(totalTokens.output)}`);
4525
+ lines.push(`- **Average session duration**: ${formatDuration(avgDuration)}`);
4526
+ if (tools.length > 0) {
4527
+ const toolStr = tools.map((t) => `${t.tool} (${formatNumber(t.count)})`).join(", ");
4528
+ lines.push(`- **Top tools**: ${toolStr}`);
4529
+ }
4530
+ }
4531
+ return lines.join(`
4532
+ `);
4533
+ }
4534
+
4535
+ // src/features/analytics/plan-parser.ts
4536
+ import { readFileSync as readFileSync10 } from "fs";
4537
+ function extractSection2(content, heading) {
4538
+ const lines = content.split(`
4539
+ `);
4540
+ let startIdx = -1;
4541
+ for (let i = 0;i < lines.length; i++) {
4542
+ if (lines[i].trim() === heading) {
4543
+ startIdx = i + 1;
4544
+ break;
4545
+ }
4546
+ }
4547
+ if (startIdx === -1)
4548
+ return null;
4549
+ const sectionLines = [];
4550
+ for (let i = startIdx;i < lines.length; i++) {
4551
+ if (/^## /.test(lines[i]))
4552
+ break;
4553
+ sectionLines.push(lines[i]);
4554
+ }
4555
+ return sectionLines.join(`
4556
+ `);
4557
+ }
4558
+ function extractFilePath2(raw) {
4559
+ let cleaned = raw.replace(/^\s*(create|modify|new:|add)\s+/i, "").replace(/\(new\)/gi, "").trim();
4560
+ const firstToken = cleaned.split(/\s+/)[0];
4561
+ if (firstToken && (firstToken.includes("/") || firstToken.endsWith(".ts") || firstToken.endsWith(".js") || firstToken.endsWith(".json") || firstToken.endsWith(".md"))) {
4562
+ cleaned = firstToken;
4563
+ } else if (!cleaned.includes("/")) {
4564
+ return null;
4565
+ }
4566
+ return cleaned || null;
4567
+ }
4568
+ function extractPlannedFiles(planPath) {
4569
+ let content;
4570
+ try {
4571
+ content = readFileSync10(planPath, "utf-8");
4572
+ } catch {
4573
+ return [];
4574
+ }
4575
+ const todosSection = extractSection2(content, "## TODOs");
4576
+ if (todosSection === null)
4577
+ return [];
4578
+ const files = new Set;
4579
+ const lines = todosSection.split(`
4580
+ `);
4581
+ for (const line of lines) {
4582
+ const filesMatch = /^\s*\*\*Files\*\*:?\s*(.+)$/.exec(line);
4583
+ if (!filesMatch)
4584
+ continue;
4585
+ const rawValue = filesMatch[1].trim();
4586
+ const parts = rawValue.split(",");
4587
+ for (const part of parts) {
4588
+ const trimmed = part.trim();
4589
+ if (!trimmed)
4590
+ continue;
4591
+ const filePath = extractFilePath2(trimmed);
4592
+ if (filePath) {
4593
+ files.add(filePath);
4594
+ }
3041
4595
  }
3042
- writeWorkState(directory, state);
3043
4596
  }
3044
- const remaining = progress.total - progress.completed;
3045
- return {
3046
- continuationPrompt: `${CONTINUATION_MARKER}
3047
- You have an active work plan with incomplete tasks. Continue working.
3048
-
3049
- **Plan**: ${state.plan_name}
3050
- **File**: ${state.active_plan}
3051
- **Progress**: ${progress.completed}/${progress.total} tasks completed (${remaining} remaining)
3052
-
3053
- 1. Read the plan file NOW to check exact current progress
3054
- 2. Use todowrite to restore sidebar: summary todo "${state.plan_name} ${progress.completed}/${progress.total}" (in_progress) + next task (in_progress) + 2-3 upcoming (pending). Max 35 chars each.
3055
- 3. Find the first unchecked \`- [ ]\` task
3056
- 4. Execute it, verify it, mark \`- [ ]\` → \`- [x]\`
3057
- 5. Update sidebar todos as you complete tasks
3058
- 6. Do not stop until all tasks are complete`
3059
- };
4597
+ return Array.from(files);
3060
4598
  }
3061
4599
 
3062
- // src/hooks/verification-reminder.ts
3063
- function buildVerificationReminder(input) {
3064
- const planContext = input.planName && input.progress ? `
3065
- **Plan**: ${input.planName} (${input.progress.completed}/${input.progress.total} tasks done)` : "";
3066
- return {
3067
- verificationPrompt: `## Verification Required
3068
- ${planContext}
3069
-
3070
- Before marking this task complete, verify the work:
3071
-
3072
- 1. **Read the changes**: \`git diff --stat\` then Read each changed file
3073
- 2. **Run checks**: Run relevant tests, check for linting/type errors
3074
- 3. **Validate behavior**: Does the code actually do what was requested?
3075
- 4. **Gate decision**: Can you explain what every changed line does?
3076
-
3077
- If uncertain about quality, delegate to \`weft\` agent for a formal review:
3078
- \`call_weave_agent(agent="weft", prompt="Review the changes for [task description]")\`
3079
-
3080
- MANDATORY: If changes touch auth, crypto, certificates, tokens, signatures, or input validation, you MUST delegate to \`warp\` agent for a security audit — this is NOT optional:
3081
- \`call_weave_agent(agent="warp", prompt="Security audit the changes for [task description]")\`
3082
-
3083
- Only mark complete when ALL checks pass.`
3084
- };
4600
+ // src/features/analytics/git-diff.ts
4601
+ import { execFileSync } from "child_process";
4602
+ var SHA_PATTERN = /^[0-9a-f]{4,40}$/i;
4603
+ function getChangedFiles(directory, fromSha) {
4604
+ if (!SHA_PATTERN.test(fromSha)) {
4605
+ return [];
4606
+ }
4607
+ try {
4608
+ const output = execFileSync("git", ["diff", "--name-only", `${fromSha}..HEAD`], {
4609
+ cwd: directory,
4610
+ encoding: "utf-8",
4611
+ timeout: 1e4,
4612
+ stdio: ["pipe", "pipe", "pipe"]
4613
+ });
4614
+ return output.split(`
4615
+ `).map((line) => line.trim()).filter((line) => line.length > 0);
4616
+ } catch {
4617
+ return [];
4618
+ }
3085
4619
  }
3086
4620
 
3087
- // src/hooks/create-hooks.ts
3088
- function createHooks(args) {
3089
- const { pluginConfig, isHookEnabled, directory, analyticsEnabled = false } = args;
3090
- const writeGuardState = createWriteGuardState();
3091
- const writeGuard = createWriteGuard(writeGuardState);
3092
- const contextWindowThresholds = {
3093
- warningPct: pluginConfig.experimental?.context_window_warning_threshold ?? 0.8,
3094
- criticalPct: pluginConfig.experimental?.context_window_critical_threshold ?? 0.95
3095
- };
4621
+ // src/features/analytics/adherence.ts
4622
+ function normalizePath(p) {
4623
+ return p.trim().toLowerCase().replace(/^\.\//, "");
4624
+ }
4625
+ function calculateAdherence(plannedFiles, actualFiles) {
4626
+ const plannedNorm = new Set(plannedFiles.map(normalizePath));
4627
+ const actualNorm = new Set(actualFiles.map(normalizePath));
4628
+ const plannedFilesChanged = [];
4629
+ const missedFiles = [];
4630
+ const unplannedChanges = [];
4631
+ for (const planned of plannedFiles) {
4632
+ if (actualNorm.has(normalizePath(planned))) {
4633
+ plannedFilesChanged.push(planned);
4634
+ } else {
4635
+ missedFiles.push(planned);
4636
+ }
4637
+ }
4638
+ for (const actual of actualFiles) {
4639
+ if (!plannedNorm.has(normalizePath(actual))) {
4640
+ unplannedChanges.push(actual);
4641
+ }
4642
+ }
4643
+ const totalPlannedFiles = plannedFiles.length;
4644
+ const totalActualFiles = actualFiles.length;
4645
+ const coverage = totalPlannedFiles === 0 ? 1 : plannedFilesChanged.length / totalPlannedFiles;
4646
+ const precision = totalActualFiles === 0 ? 1 : plannedFilesChanged.length / totalActualFiles;
3096
4647
  return {
3097
- checkContextWindow: isHookEnabled("context-window-monitor") ? (state) => checkContextWindow(state, contextWindowThresholds) : null,
3098
- writeGuard: isHookEnabled("write-existing-file-guard") ? writeGuard : null,
3099
- shouldInjectRules: isHookEnabled("rules-injector") ? shouldInjectRules : null,
3100
- getRulesForFile: isHookEnabled("rules-injector") ? getRulesForFile : null,
3101
- firstMessageVariant: isHookEnabled("first-message-variant") ? { shouldApplyVariant, markApplied, markSessionCreated, clearSession } : null,
3102
- processMessageForKeywords: isHookEnabled("keyword-detector") ? processMessageForKeywords : null,
3103
- patternMdOnly: isHookEnabled("pattern-md-only") ? checkPatternWrite : null,
3104
- startWork: isHookEnabled("start-work") ? (promptText, sessionId) => handleStartWork({ promptText, sessionId, directory }) : null,
3105
- workContinuation: isHookEnabled("work-continuation") ? (sessionId) => checkContinuation({ sessionId, directory }) : null,
3106
- verificationReminder: isHookEnabled("verification-reminder") ? buildVerificationReminder : null,
3107
- analyticsEnabled
4648
+ coverage,
4649
+ precision,
4650
+ plannedFilesChanged,
4651
+ unplannedChanges,
4652
+ missedFiles,
4653
+ totalPlannedFiles,
4654
+ totalActualFiles
3108
4655
  };
3109
4656
  }
3110
- // src/hooks/session-token-state.ts
3111
- var sessionMap = new Map;
3112
- function setContextLimit(sessionId, maxTokens) {
3113
- const existing = sessionMap.get(sessionId);
3114
- sessionMap.set(sessionId, {
3115
- usedTokens: existing?.usedTokens ?? 0,
3116
- maxTokens
3117
- });
3118
- }
3119
- function updateUsage(sessionId, inputTokens) {
3120
- if (inputTokens <= 0)
3121
- return;
3122
- const existing = sessionMap.get(sessionId);
3123
- sessionMap.set(sessionId, {
3124
- maxTokens: existing?.maxTokens ?? 0,
3125
- usedTokens: inputTokens
3126
- });
3127
- }
3128
- function getState(sessionId) {
3129
- return sessionMap.get(sessionId);
4657
+
4658
+ // src/features/analytics/plan-token-aggregator.ts
4659
+ function aggregateTokensForPlan(directory, sessionIds) {
4660
+ const summaries = readSessionSummaries(directory);
4661
+ const sessionIdSet = new Set(sessionIds);
4662
+ const total = zeroTokenUsage();
4663
+ for (const summary of summaries) {
4664
+ if (!sessionIdSet.has(summary.sessionId))
4665
+ continue;
4666
+ if (summary.tokenUsage) {
4667
+ total.input += summary.tokenUsage.inputTokens;
4668
+ total.output += summary.tokenUsage.outputTokens;
4669
+ total.reasoning += summary.tokenUsage.reasoningTokens;
4670
+ total.cacheRead += summary.tokenUsage.cacheReadTokens;
4671
+ total.cacheWrite += summary.tokenUsage.cacheWriteTokens;
4672
+ }
4673
+ }
4674
+ return total;
3130
4675
  }
3131
- function clearSession2(sessionId) {
3132
- sessionMap.delete(sessionId);
4676
+
4677
+ // src/features/analytics/generate-metrics-report.ts
4678
+ function generateMetricsReport(directory, state) {
4679
+ try {
4680
+ const plannedFiles = extractPlannedFiles(state.active_plan);
4681
+ const actualFiles = state.start_sha ? getChangedFiles(directory, state.start_sha) : [];
4682
+ const adherence = calculateAdherence(plannedFiles, actualFiles);
4683
+ const tokenUsage = aggregateTokensForPlan(directory, state.session_ids);
4684
+ const summaries = readSessionSummaries(directory);
4685
+ const matchingSummaries = summaries.filter((s) => state.session_ids.includes(s.sessionId));
4686
+ const durationMs = matchingSummaries.reduce((sum, s) => sum + s.durationMs, 0);
4687
+ const report = {
4688
+ planName: getPlanName(state.active_plan),
4689
+ generatedAt: new Date().toISOString(),
4690
+ adherence,
4691
+ quality: undefined,
4692
+ gaps: undefined,
4693
+ tokenUsage,
4694
+ durationMs,
4695
+ sessionCount: state.session_ids.length,
4696
+ startSha: state.start_sha,
4697
+ sessionIds: [...state.session_ids]
4698
+ };
4699
+ const written = writeMetricsReport(directory, report);
4700
+ if (!written) {
4701
+ log("[analytics] Failed to write metrics report (non-fatal)");
4702
+ return null;
4703
+ }
4704
+ log("[analytics] Metrics report generated", {
4705
+ plan: report.planName,
4706
+ coverage: adherence.coverage,
4707
+ precision: adherence.precision
4708
+ });
4709
+ return report;
4710
+ } catch (err) {
4711
+ log("[analytics] Failed to generate metrics report (non-fatal)", {
4712
+ error: String(err)
4713
+ });
4714
+ return null;
4715
+ }
3133
4716
  }
4717
+
3134
4718
  // src/plugin/plugin-interface.ts
4719
+ var FINALIZE_TODOS_MARKER = "<!-- weave:finalize-todos -->";
3135
4720
  function createPluginInterface(args) {
3136
4721
  const { pluginConfig, hooks, tools, configHandler, agents, client, directory = "", tracker } = args;
4722
+ const lastAssistantMessageText = new Map;
4723
+ const lastUserMessageText = new Map;
4724
+ const todoFinalizedSessions = new Set;
3137
4725
  return {
3138
4726
  tool: tools,
3139
4727
  config: async (config) => {
@@ -3187,13 +4775,78 @@ ${result.contextInjection}`;
3187
4775
  }
3188
4776
  }
3189
4777
  }
4778
+ if (hooks.workflowStart) {
4779
+ const parts = _output.parts;
4780
+ const message = _output.message;
4781
+ const promptText = parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
4782
+ `).trim() ?? "";
4783
+ if (promptText.includes("workflow engine will inject context")) {
4784
+ const result = hooks.workflowStart(promptText, sessionID);
4785
+ if (result.switchAgent && message) {
4786
+ message.agent = getAgentDisplayName(result.switchAgent);
4787
+ }
4788
+ if (result.contextInjection && parts) {
4789
+ const idx = parts.findIndex((p) => p.type === "text" && p.text);
4790
+ if (idx >= 0 && parts[idx].text) {
4791
+ parts[idx].text += `
4792
+
4793
+ ---
4794
+ ${result.contextInjection}`;
4795
+ } else {
4796
+ parts.push({ type: "text", text: result.contextInjection });
4797
+ }
4798
+ }
4799
+ }
4800
+ }
4801
+ {
4802
+ const parts = _output.parts;
4803
+ const userText = parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
4804
+ `).trim() ?? "";
4805
+ if (userText && sessionID) {
4806
+ lastUserMessageText.set(sessionID, userText);
4807
+ if (!userText.includes(FINALIZE_TODOS_MARKER)) {
4808
+ todoFinalizedSessions.delete(sessionID);
4809
+ }
4810
+ }
4811
+ }
4812
+ if (hooks.workflowCommand) {
4813
+ const parts = _output.parts;
4814
+ const message = _output.message;
4815
+ const userText = parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
4816
+ `).trim() ?? "";
4817
+ if (userText) {
4818
+ const cmdResult = hooks.workflowCommand(userText);
4819
+ if (cmdResult.handled) {
4820
+ if (cmdResult.contextInjection && parts) {
4821
+ const idx = parts.findIndex((p) => p.type === "text" && p.text);
4822
+ if (idx >= 0 && parts[idx].text) {
4823
+ parts[idx].text += `
4824
+
4825
+ ---
4826
+ ${cmdResult.contextInjection}`;
4827
+ } else {
4828
+ parts.push({ type: "text", text: cmdResult.contextInjection });
4829
+ }
4830
+ }
4831
+ if (cmdResult.switchAgent && message) {
4832
+ message.agent = getAgentDisplayName(cmdResult.switchAgent);
4833
+ }
4834
+ }
4835
+ }
4836
+ }
3190
4837
  if (directory) {
3191
4838
  const parts = _output.parts;
3192
4839
  const promptText = parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
3193
4840
  `).trim() ?? "";
3194
4841
  const isStartWork = promptText.includes("<session-context>");
3195
4842
  const isContinuation = promptText.includes(CONTINUATION_MARKER);
3196
- if (!isStartWork && !isContinuation) {
4843
+ const isWorkflowContinuation = promptText.includes(WORKFLOW_CONTINUATION_MARKER);
4844
+ const isTodoFinalize = promptText.includes(FINALIZE_TODOS_MARKER);
4845
+ const isActiveWorkflow = (() => {
4846
+ const wf = getActiveWorkflowInstance(directory);
4847
+ return wf != null && wf.status === "running";
4848
+ })();
4849
+ if (!isStartWork && !isContinuation && !isWorkflowContinuation && !isTodoFinalize && !isActiveWorkflow) {
3197
4850
  const state = readWorkState(directory);
3198
4851
  if (state && !state.paused) {
3199
4852
  pauseWork(directory);
@@ -3210,6 +4863,9 @@ ${result.contextInjection}`;
3210
4863
  setContextLimit(sessionId, maxTokens);
3211
4864
  log("[context-window] Captured context limit", { sessionId, maxTokens });
3212
4865
  }
4866
+ if (tracker && hooks.analyticsEnabled && sessionId && input.agent) {
4867
+ tracker.setAgentName(sessionId, input.agent);
4868
+ }
3213
4869
  },
3214
4870
  "chat.headers": async (_input, _output) => {},
3215
4871
  event: async (input) => {
@@ -3227,44 +4883,123 @@ ${result.contextInjection}`;
3227
4883
  if (event.type === "session.deleted") {
3228
4884
  const evt = event;
3229
4885
  clearSession2(evt.properties.info.id);
4886
+ todoFinalizedSessions.delete(evt.properties.info.id);
3230
4887
  if (tracker && hooks.analyticsEnabled) {
3231
4888
  try {
3232
4889
  tracker.endSession(evt.properties.info.id);
3233
4890
  } catch (err) {
3234
4891
  log("[analytics] Failed to end session (non-fatal)", { error: String(err) });
3235
4892
  }
4893
+ if (directory) {
4894
+ try {
4895
+ const state = readWorkState(directory);
4896
+ if (state) {
4897
+ const progress = getPlanProgress(state.active_plan);
4898
+ if (progress.isComplete) {
4899
+ generateMetricsReport(directory, state);
4900
+ }
4901
+ }
4902
+ } catch (err) {
4903
+ log("[analytics] Failed to generate metrics report on session end (non-fatal)", { error: String(err) });
4904
+ }
4905
+ }
3236
4906
  }
3237
4907
  }
3238
- if (event.type === "message.updated" && hooks.checkContextWindow) {
4908
+ if (event.type === "message.updated") {
3239
4909
  const evt = event;
3240
4910
  const info = evt.properties?.info;
3241
4911
  if (info?.role === "assistant" && info.sessionID) {
3242
- const inputTokens = info.tokens?.input ?? 0;
3243
- if (inputTokens > 0) {
3244
- updateUsage(info.sessionID, inputTokens);
3245
- const tokenState = getState(info.sessionID);
3246
- if (tokenState && tokenState.maxTokens > 0) {
3247
- const result = hooks.checkContextWindow({
3248
- usedTokens: tokenState.usedTokens,
3249
- maxTokens: tokenState.maxTokens,
3250
- sessionId: info.sessionID
3251
- });
3252
- if (result.action !== "none") {
3253
- log("[context-window] Threshold crossed", {
3254
- sessionId: info.sessionID,
3255
- action: result.action,
3256
- usagePct: result.usagePct
4912
+ if (hooks.checkContextWindow) {
4913
+ const inputTokens = info.tokens?.input ?? 0;
4914
+ if (inputTokens > 0) {
4915
+ updateUsage(info.sessionID, inputTokens);
4916
+ const tokenState = getState(info.sessionID);
4917
+ if (tokenState && tokenState.maxTokens > 0) {
4918
+ const result = hooks.checkContextWindow({
4919
+ usedTokens: tokenState.usedTokens,
4920
+ maxTokens: tokenState.maxTokens,
4921
+ sessionId: info.sessionID
3257
4922
  });
4923
+ if (result.action !== "none") {
4924
+ log("[context-window] Threshold crossed", {
4925
+ sessionId: info.sessionID,
4926
+ action: result.action,
4927
+ usagePct: result.usagePct
4928
+ });
4929
+ }
3258
4930
  }
3259
4931
  }
3260
4932
  }
3261
4933
  }
3262
4934
  }
4935
+ if (event.type === "message.updated" && tracker && hooks.analyticsEnabled) {
4936
+ const evt = event;
4937
+ const info = evt.properties?.info;
4938
+ if (info?.role === "assistant" && info.sessionID) {
4939
+ if (typeof info.cost === "number" && info.cost > 0) {
4940
+ tracker.trackCost(info.sessionID, info.cost);
4941
+ }
4942
+ if (info.tokens) {
4943
+ tracker.trackTokenUsage(info.sessionID, {
4944
+ input: info.tokens.input ?? 0,
4945
+ output: info.tokens.output ?? 0,
4946
+ reasoning: info.tokens.reasoning ?? 0,
4947
+ cacheRead: info.tokens.cache?.read ?? 0,
4948
+ cacheWrite: info.tokens.cache?.write ?? 0
4949
+ });
4950
+ }
4951
+ }
4952
+ }
3263
4953
  if (event.type === "tui.command.execute") {
3264
4954
  const evt = event;
3265
4955
  if (evt.properties?.command === "session.interrupt") {
3266
4956
  pauseWork(directory);
3267
4957
  log("[work-continuation] User interrupt detected — work paused");
4958
+ if (directory) {
4959
+ const activeWorkflow = getActiveWorkflowInstance(directory);
4960
+ if (activeWorkflow && activeWorkflow.status === "running") {
4961
+ pauseWorkflow(directory, "User interrupt");
4962
+ log("[workflow] User interrupt detected — workflow paused");
4963
+ }
4964
+ }
4965
+ }
4966
+ }
4967
+ if (event.type === "message.part.updated") {
4968
+ const evt = event;
4969
+ const part = evt.properties?.part;
4970
+ if (part?.type === "text" && part.sessionID && part.text) {
4971
+ lastAssistantMessageText.set(part.sessionID, part.text);
4972
+ }
4973
+ }
4974
+ let continuationFired = false;
4975
+ if (hooks.workflowContinuation && event.type === "session.idle") {
4976
+ const evt = event;
4977
+ const sessionId = evt.properties?.sessionID ?? "";
4978
+ if (sessionId && directory) {
4979
+ const activeWorkflow = getActiveWorkflowInstance(directory);
4980
+ if (activeWorkflow && activeWorkflow.status === "running") {
4981
+ const lastMsg = lastAssistantMessageText.get(sessionId) ?? undefined;
4982
+ const lastUserMsg = lastUserMessageText.get(sessionId) ?? undefined;
4983
+ const result = hooks.workflowContinuation(sessionId, lastMsg, lastUserMsg);
4984
+ if (result.continuationPrompt && client) {
4985
+ try {
4986
+ await client.session.promptAsync({
4987
+ path: { id: sessionId },
4988
+ body: {
4989
+ parts: [{ type: "text", text: result.continuationPrompt }],
4990
+ ...result.switchAgent ? { agent: getAgentDisplayName(result.switchAgent) } : {}
4991
+ }
4992
+ });
4993
+ log("[workflow] Injected workflow continuation prompt", {
4994
+ sessionId,
4995
+ agent: result.switchAgent
4996
+ });
4997
+ } catch (err) {
4998
+ log("[workflow] Failed to inject workflow continuation", { sessionId, error: String(err) });
4999
+ }
5000
+ return;
5001
+ }
5002
+ }
3268
5003
  }
3269
5004
  }
3270
5005
  if (hooks.workContinuation && event.type === "session.idle") {
@@ -3281,6 +5016,7 @@ ${result.contextInjection}`;
3281
5016
  }
3282
5017
  });
3283
5018
  log("[work-continuation] Injected continuation prompt", { sessionId });
5019
+ continuationFired = true;
3284
5020
  } catch (err) {
3285
5021
  log("[work-continuation] Failed to inject continuation", { sessionId, error: String(err) });
3286
5022
  }
@@ -3289,6 +5025,43 @@ ${result.contextInjection}`;
3289
5025
  }
3290
5026
  }
3291
5027
  }
5028
+ if (event.type === "session.idle" && client && !continuationFired) {
5029
+ const evt = event;
5030
+ const sessionId = evt.properties?.sessionID ?? "";
5031
+ if (sessionId && !todoFinalizedSessions.has(sessionId)) {
5032
+ try {
5033
+ const todosResponse = await client.session.todo({ path: { id: sessionId } });
5034
+ const todos = todosResponse.data ?? [];
5035
+ const hasInProgress = todos.some((t) => t.status === "in_progress");
5036
+ if (hasInProgress) {
5037
+ todoFinalizedSessions.add(sessionId);
5038
+ const inProgressItems = todos.filter((t) => t.status === "in_progress").map((t) => ` - "${t.content}"`).join(`
5039
+ `);
5040
+ await client.session.promptAsync({
5041
+ path: { id: sessionId },
5042
+ body: {
5043
+ parts: [
5044
+ {
5045
+ type: "text",
5046
+ text: `${FINALIZE_TODOS_MARKER}
5047
+ You have finished your work but left these todos as in_progress:
5048
+ ${inProgressItems}
5049
+
5050
+ Use todowrite NOW to mark all of them as "completed" (or "cancelled" if abandoned). Do not do any other work — just update the todos and stop.`
5051
+ }
5052
+ ]
5053
+ }
5054
+ });
5055
+ log("[todo-finalize] Injected finalize prompt for in_progress todos", {
5056
+ sessionId,
5057
+ count: todos.filter((t) => t.status === "in_progress").length
5058
+ });
5059
+ }
5060
+ } catch (err) {
5061
+ log("[todo-finalize] Failed to check/finalize todos (non-fatal)", { sessionId, error: String(err) });
5062
+ }
5063
+ }
5064
+ }
3292
5065
  },
3293
5066
  "tool.execute.before": async (input, _output) => {
3294
5067
  const toolArgs = _output.args;
@@ -3342,79 +5115,40 @@ ${result.contextInjection}`;
3342
5115
  const agentArg = input.tool === "task" && inputArgs ? inputArgs.subagent_type ?? inputArgs.description ?? "unknown" : undefined;
3343
5116
  tracker.trackToolEnd(input.sessionID, input.tool, input.callID, agentArg);
3344
5117
  }
5118
+ },
5119
+ "command.execute.before": async (input, output) => {
5120
+ const { command, arguments: args2 } = input;
5121
+ const parts = output.parts;
5122
+ if (command === "token-report") {
5123
+ const summaries = readSessionSummaries(directory);
5124
+ const reportText = generateTokenReport(summaries);
5125
+ parts.push({ type: "text", text: reportText });
5126
+ }
5127
+ if (command === "metrics") {
5128
+ if (!hooks.analyticsEnabled) {
5129
+ parts.push({
5130
+ type: "text",
5131
+ text: 'Analytics is not enabled. To enable it, set `"analytics": { "enabled": true }` in your `weave.json`.'
5132
+ });
5133
+ return;
5134
+ }
5135
+ const reports = readMetricsReports(directory);
5136
+ const summaries = readSessionSummaries(directory);
5137
+ const metricsMarkdown = formatMetricsMarkdown(reports, summaries, args2);
5138
+ parts.push({ type: "text", text: metricsMarkdown });
5139
+ }
3345
5140
  }
3346
5141
  };
3347
5142
  }
3348
-
3349
- // src/features/analytics/types.ts
3350
- var ANALYTICS_DIR = ".weave/analytics";
3351
- var SESSION_SUMMARIES_FILE = "session-summaries.jsonl";
3352
- var FINGERPRINT_FILE = "fingerprint.json";
3353
- // src/features/analytics/storage.ts
3354
- import { existsSync as existsSync8, mkdirSync as mkdirSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "fs";
3355
- import { join as join7 } from "path";
3356
- var MAX_SESSION_ENTRIES = 1000;
3357
- function ensureAnalyticsDir(directory) {
3358
- const dir = join7(directory, ANALYTICS_DIR);
3359
- mkdirSync2(dir, { recursive: true, mode: 448 });
3360
- return dir;
3361
- }
3362
- function appendSessionSummary(directory, summary) {
3363
- try {
3364
- const dir = ensureAnalyticsDir(directory);
3365
- const filePath = join7(dir, SESSION_SUMMARIES_FILE);
3366
- const line = JSON.stringify(summary) + `
3367
- `;
3368
- appendFileSync2(filePath, line, { encoding: "utf-8", mode: 384 });
3369
- try {
3370
- const content = readFileSync7(filePath, "utf-8");
3371
- const lines = content.split(`
3372
- `).filter((l) => l.trim().length > 0);
3373
- if (lines.length > MAX_SESSION_ENTRIES) {
3374
- const trimmed = lines.slice(-MAX_SESSION_ENTRIES).join(`
3375
- `) + `
3376
- `;
3377
- writeFileSync2(filePath, trimmed, { encoding: "utf-8", mode: 384 });
3378
- }
3379
- } catch {}
3380
- return true;
3381
- } catch {
3382
- return false;
3383
- }
3384
- }
3385
- function writeFingerprint(directory, fingerprint) {
3386
- try {
3387
- const dir = ensureAnalyticsDir(directory);
3388
- const filePath = join7(dir, FINGERPRINT_FILE);
3389
- writeFileSync2(filePath, JSON.stringify(fingerprint, null, 2), { encoding: "utf-8", mode: 384 });
3390
- return true;
3391
- } catch {
3392
- return false;
3393
- }
3394
- }
3395
- function readFingerprint(directory) {
3396
- const filePath = join7(directory, ANALYTICS_DIR, FINGERPRINT_FILE);
3397
- try {
3398
- if (!existsSync8(filePath))
3399
- return null;
3400
- const content = readFileSync7(filePath, "utf-8");
3401
- const parsed = JSON.parse(content);
3402
- if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.stack))
3403
- return null;
3404
- return parsed;
3405
- } catch {
3406
- return null;
3407
- }
3408
- }
3409
5143
  // src/features/analytics/fingerprint.ts
3410
- import { existsSync as existsSync9, readFileSync as readFileSync9, readdirSync as readdirSync3 } from "fs";
3411
- import { join as join9 } from "path";
5144
+ import { existsSync as existsSync13, readFileSync as readFileSync12, readdirSync as readdirSync5 } from "fs";
5145
+ import { join as join12 } from "path";
3412
5146
  import { arch } from "os";
3413
5147
 
3414
5148
  // src/shared/version.ts
3415
- import { readFileSync as readFileSync8 } from "fs";
5149
+ import { readFileSync as readFileSync11 } from "fs";
3416
5150
  import { fileURLToPath } from "url";
3417
- import { dirname as dirname2, join as join8 } from "path";
5151
+ import { dirname as dirname2, join as join11 } from "path";
3418
5152
  var cachedVersion;
3419
5153
  function getWeaveVersion() {
3420
5154
  if (cachedVersion !== undefined)
@@ -3423,7 +5157,7 @@ function getWeaveVersion() {
3423
5157
  const thisDir = dirname2(fileURLToPath(import.meta.url));
3424
5158
  for (const rel of ["../../package.json", "../package.json"]) {
3425
5159
  try {
3426
- const pkg = JSON.parse(readFileSync8(join8(thisDir, rel), "utf-8"));
5160
+ const pkg = JSON.parse(readFileSync11(join11(thisDir, rel), "utf-8"));
3427
5161
  if (pkg.name === "@opencode_weave/weave" && typeof pkg.version === "string") {
3428
5162
  const version = pkg.version;
3429
5163
  cachedVersion = version;
@@ -3528,7 +5262,7 @@ function detectStack(directory) {
3528
5262
  const detected = [];
3529
5263
  for (const marker of STACK_MARKERS) {
3530
5264
  for (const file of marker.files) {
3531
- if (existsSync9(join9(directory, file))) {
5265
+ if (existsSync13(join12(directory, file))) {
3532
5266
  detected.push({
3533
5267
  name: marker.name,
3534
5268
  confidence: marker.confidence,
@@ -3539,9 +5273,9 @@ function detectStack(directory) {
3539
5273
  }
3540
5274
  }
3541
5275
  try {
3542
- const pkgPath = join9(directory, "package.json");
3543
- if (existsSync9(pkgPath)) {
3544
- const pkg = JSON.parse(readFileSync9(pkgPath, "utf-8"));
5276
+ const pkgPath = join12(directory, "package.json");
5277
+ if (existsSync13(pkgPath)) {
5278
+ const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
3545
5279
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
3546
5280
  if (deps.react) {
3547
5281
  detected.push({
@@ -3554,7 +5288,7 @@ function detectStack(directory) {
3554
5288
  } catch {}
3555
5289
  if (!detected.some((d) => d.name === "dotnet")) {
3556
5290
  try {
3557
- const entries = readdirSync3(directory);
5291
+ const entries = readdirSync5(directory);
3558
5292
  const dotnetFile = entries.find((e) => e.endsWith(".csproj") || e.endsWith(".fsproj") || e.endsWith(".sln"));
3559
5293
  if (dotnetFile) {
3560
5294
  detected.push({
@@ -3574,27 +5308,27 @@ function detectStack(directory) {
3574
5308
  });
3575
5309
  }
3576
5310
  function detectPackageManager(directory) {
3577
- if (existsSync9(join9(directory, "bun.lockb")))
5311
+ if (existsSync13(join12(directory, "bun.lockb")))
3578
5312
  return "bun";
3579
- if (existsSync9(join9(directory, "pnpm-lock.yaml")))
5313
+ if (existsSync13(join12(directory, "pnpm-lock.yaml")))
3580
5314
  return "pnpm";
3581
- if (existsSync9(join9(directory, "yarn.lock")))
5315
+ if (existsSync13(join12(directory, "yarn.lock")))
3582
5316
  return "yarn";
3583
- if (existsSync9(join9(directory, "package-lock.json")))
5317
+ if (existsSync13(join12(directory, "package-lock.json")))
3584
5318
  return "npm";
3585
- if (existsSync9(join9(directory, "package.json")))
5319
+ if (existsSync13(join12(directory, "package.json")))
3586
5320
  return "npm";
3587
5321
  return;
3588
5322
  }
3589
5323
  function detectMonorepo(directory) {
3590
5324
  for (const marker of MONOREPO_MARKERS) {
3591
- if (existsSync9(join9(directory, marker)))
5325
+ if (existsSync13(join12(directory, marker)))
3592
5326
  return true;
3593
5327
  }
3594
5328
  try {
3595
- const pkgPath = join9(directory, "package.json");
3596
- if (existsSync9(pkgPath)) {
3597
- const pkg = JSON.parse(readFileSync9(pkgPath, "utf-8"));
5329
+ const pkgPath = join12(directory, "package.json");
5330
+ if (existsSync13(pkgPath)) {
5331
+ const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
3598
5332
  if (pkg.workspaces)
3599
5333
  return true;
3600
5334
  }
@@ -3659,6 +5393,11 @@ function getOrCreateFingerprint(directory) {
3659
5393
  }
3660
5394
  }
3661
5395
  // src/features/analytics/session-tracker.ts
5396
+ function safeNum(v) {
5397
+ const n = Number(v);
5398
+ return Number.isFinite(n) && n >= 0 ? n : 0;
5399
+ }
5400
+
3662
5401
  class SessionTracker {
3663
5402
  sessions = new Map;
3664
5403
  directory;
@@ -3674,7 +5413,16 @@ class SessionTracker {
3674
5413
  startedAt: new Date().toISOString(),
3675
5414
  toolCounts: {},
3676
5415
  delegations: [],
3677
- inFlight: {}
5416
+ inFlight: {},
5417
+ totalCost: 0,
5418
+ tokenUsage: {
5419
+ inputTokens: 0,
5420
+ outputTokens: 0,
5421
+ reasoningTokens: 0,
5422
+ cacheReadTokens: 0,
5423
+ cacheWriteTokens: 0,
5424
+ totalMessages: 0
5425
+ }
3678
5426
  };
3679
5427
  this.sessions.set(sessionId, session);
3680
5428
  return session;
@@ -3708,6 +5456,29 @@ class SessionTracker {
3708
5456
  session.delegations.push(delegation);
3709
5457
  }
3710
5458
  }
5459
+ setAgentName(sessionId, agentName) {
5460
+ const session = this.sessions.get(sessionId);
5461
+ if (!session)
5462
+ return;
5463
+ if (!session.agentName) {
5464
+ session.agentName = agentName;
5465
+ }
5466
+ }
5467
+ trackCost(sessionId, cost) {
5468
+ const session = this.sessions.get(sessionId);
5469
+ if (!session)
5470
+ return;
5471
+ session.totalCost += safeNum(cost);
5472
+ }
5473
+ trackTokenUsage(sessionId, tokens) {
5474
+ const session = this.startSession(sessionId);
5475
+ session.tokenUsage.inputTokens += safeNum(tokens.input);
5476
+ session.tokenUsage.outputTokens += safeNum(tokens.output);
5477
+ session.tokenUsage.reasoningTokens += safeNum(tokens.reasoning);
5478
+ session.tokenUsage.cacheReadTokens += safeNum(tokens.cacheRead);
5479
+ session.tokenUsage.cacheWriteTokens += safeNum(tokens.cacheWrite);
5480
+ session.tokenUsage.totalMessages += 1;
5481
+ }
3711
5482
  endSession(sessionId) {
3712
5483
  const session = this.sessions.get(sessionId);
3713
5484
  if (!session)
@@ -3725,14 +5496,21 @@ class SessionTracker {
3725
5496
  toolUsage,
3726
5497
  delegations: session.delegations,
3727
5498
  totalToolCalls,
3728
- totalDelegations: session.delegations.length
5499
+ totalDelegations: session.delegations.length,
5500
+ agentName: session.agentName,
5501
+ totalCost: session.totalCost > 0 ? session.totalCost : undefined,
5502
+ tokenUsage: session.tokenUsage.totalMessages > 0 ? session.tokenUsage : undefined
3729
5503
  };
3730
5504
  try {
3731
5505
  appendSessionSummary(this.directory, summary);
3732
5506
  log("[analytics] Session summary persisted", {
3733
5507
  sessionId,
3734
5508
  totalToolCalls,
3735
- totalDelegations: session.delegations.length
5509
+ totalDelegations: session.delegations.length,
5510
+ ...summary.tokenUsage ? {
5511
+ inputTokens: summary.tokenUsage.inputTokens,
5512
+ outputTokens: summary.tokenUsage.outputTokens
5513
+ } : {}
3736
5514
  });
3737
5515
  } catch (err) {
3738
5516
  log("[analytics] Failed to persist session summary (non-fatal)", {
@@ -3759,8 +5537,7 @@ function createSessionTracker(directory) {
3759
5537
  // src/features/analytics/index.ts
3760
5538
  function createAnalytics(directory, fingerprint) {
3761
5539
  const tracker = createSessionTracker(directory);
3762
- const resolvedFingerprint = fingerprint ?? getOrCreateFingerprint(directory);
3763
- return { tracker, fingerprint: resolvedFingerprint };
5540
+ return { tracker, fingerprint };
3764
5541
  }
3765
5542
 
3766
5543
  // src/index.ts
@@ -3769,8 +5546,9 @@ var WeavePlugin = async (ctx) => {
3769
5546
  const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
3770
5547
  const isHookEnabled = (name) => !disabledHooks.has(name);
3771
5548
  const analyticsEnabled = pluginConfig.analytics?.enabled === true;
3772
- const fingerprint = analyticsEnabled ? getOrCreateFingerprint(ctx.directory) : null;
3773
- const configDir = join10(ctx.directory, ".opencode");
5549
+ const fingerprintEnabled = analyticsEnabled && pluginConfig.analytics?.use_fingerprint === true;
5550
+ const fingerprint = fingerprintEnabled ? getOrCreateFingerprint(ctx.directory) : null;
5551
+ const configDir = join13(ctx.directory, ".opencode");
3774
5552
  const toolsResult = await createTools({ ctx, pluginConfig });
3775
5553
  const managers = createManagers({ ctx, pluginConfig, resolveSkills: toolsResult.resolveSkillsFn, fingerprint, configDir });
3776
5554
  const hooks = createHooks({ pluginConfig, isHookEnabled, directory: ctx.directory, analyticsEnabled });