@opencode_weave/weave 0.6.3 → 0.7.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) 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/fingerprint.d.ts +2 -1
  4. package/dist/features/analytics/format-metrics.d.ts +10 -0
  5. package/dist/features/analytics/generate-metrics-report.d.ts +17 -0
  6. package/dist/features/analytics/git-diff.d.ts +7 -0
  7. package/dist/features/analytics/index.d.ts +13 -6
  8. package/dist/features/analytics/plan-parser.d.ts +7 -0
  9. package/dist/features/analytics/plan-token-aggregator.d.ts +11 -0
  10. package/dist/features/analytics/session-tracker.d.ts +20 -0
  11. package/dist/features/analytics/storage.d.ts +13 -1
  12. package/dist/features/analytics/token-report.d.ts +14 -0
  13. package/dist/features/analytics/types.d.ts +91 -1
  14. package/dist/features/builtin-commands/templates/metrics.d.ts +1 -0
  15. package/dist/features/builtin-commands/templates/run-workflow.d.ts +1 -0
  16. package/dist/features/builtin-commands/types.d.ts +1 -1
  17. package/dist/features/workflow/commands.d.ts +17 -0
  18. package/dist/features/workflow/completion.d.ts +31 -0
  19. package/dist/features/workflow/constants.d.ts +12 -0
  20. package/dist/features/workflow/context.d.ts +16 -0
  21. package/dist/features/workflow/discovery.d.ts +19 -0
  22. package/dist/features/workflow/engine.d.ts +49 -0
  23. package/dist/features/workflow/hook.d.ts +47 -0
  24. package/dist/features/workflow/index.d.ts +15 -0
  25. package/dist/features/workflow/schema.d.ts +118 -0
  26. package/dist/features/workflow/storage.d.ts +51 -0
  27. package/dist/features/workflow/types.d.ts +142 -0
  28. package/dist/hooks/create-hooks.d.ts +6 -0
  29. package/dist/index.js +2183 -426
  30. package/dist/plugin/types.d.ts +1 -1
  31. package/dist/shared/index.d.ts +1 -0
  32. package/dist/shared/version.d.ts +5 -0
  33. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
- import { join as join9 } 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
@@ -1768,18 +1848,18 @@ function createBuiltinAgents(options = {}) {
1768
1848
  }
1769
1849
 
1770
1850
  // src/agents/prompt-loader.ts
1771
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
1772
- import { resolve, isAbsolute, normalize } from "path";
1851
+ import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
1852
+ import { resolve, isAbsolute, normalize, sep } from "path";
1773
1853
  function loadPromptFile(promptFilePath, basePath) {
1774
1854
  if (isAbsolute(promptFilePath)) {
1775
1855
  return null;
1776
1856
  }
1777
1857
  const base = resolve(basePath ?? process.cwd());
1778
1858
  const resolvedPath = normalize(resolve(base, promptFilePath));
1779
- if (!resolvedPath.startsWith(base + "/") && resolvedPath !== base) {
1859
+ if (!resolvedPath.startsWith(base + sep) && resolvedPath !== base) {
1780
1860
  return null;
1781
1861
  }
1782
- if (!existsSync2(resolvedPath)) {
1862
+ if (!existsSync3(resolvedPath)) {
1783
1863
  return null;
1784
1864
  }
1785
1865
  return readFileSync2(resolvedPath, "utf-8").trim();
@@ -2359,7 +2439,7 @@ var WORK_STATE_FILE = "state.json";
2359
2439
  var WORK_STATE_PATH = `${WEAVE_DIR}/${WORK_STATE_FILE}`;
2360
2440
  var PLANS_DIR = `${WEAVE_DIR}/plans`;
2361
2441
  // src/features/work-state/storage.ts
2362
- import { existsSync as existsSync6, readFileSync as readFileSync5, writeFileSync, unlinkSync, mkdirSync, readdirSync as readdirSync2, statSync } from "fs";
2442
+ import { existsSync as existsSync7, readFileSync as readFileSync5, writeFileSync, unlinkSync, mkdirSync as mkdirSync2, readdirSync as readdirSync2, statSync } from "fs";
2363
2443
  import { join as join6, basename } from "path";
2364
2444
  import { execSync } from "child_process";
2365
2445
  var UNCHECKED_RE = /^[-*]\s*\[\s*\]/gm;
@@ -2367,7 +2447,7 @@ var CHECKED_RE = /^[-*]\s*\[[xX]\]/gm;
2367
2447
  function readWorkState(directory) {
2368
2448
  const filePath = join6(directory, WEAVE_DIR, WORK_STATE_FILE);
2369
2449
  try {
2370
- if (!existsSync6(filePath))
2450
+ if (!existsSync7(filePath))
2371
2451
  return null;
2372
2452
  const raw = readFileSync5(filePath, "utf-8");
2373
2453
  const parsed = JSON.parse(raw);
@@ -2386,8 +2466,8 @@ function readWorkState(directory) {
2386
2466
  function writeWorkState(directory, state) {
2387
2467
  try {
2388
2468
  const dir = join6(directory, WEAVE_DIR);
2389
- if (!existsSync6(dir)) {
2390
- mkdirSync(dir, { recursive: true });
2469
+ if (!existsSync7(dir)) {
2470
+ mkdirSync2(dir, { recursive: true });
2391
2471
  }
2392
2472
  writeFileSync(join6(dir, WORK_STATE_FILE), JSON.stringify(state, null, 2), "utf-8");
2393
2473
  return true;
@@ -2398,7 +2478,7 @@ function writeWorkState(directory, state) {
2398
2478
  function clearWorkState(directory) {
2399
2479
  const filePath = join6(directory, WEAVE_DIR, WORK_STATE_FILE);
2400
2480
  try {
2401
- if (existsSync6(filePath)) {
2481
+ if (existsSync7(filePath)) {
2402
2482
  unlinkSync(filePath);
2403
2483
  }
2404
2484
  return true;
@@ -2442,7 +2522,7 @@ function getHeadSha(directory) {
2442
2522
  function findPlans(directory) {
2443
2523
  const plansDir = join6(directory, PLANS_DIR);
2444
2524
  try {
2445
- if (!existsSync6(plansDir))
2525
+ if (!existsSync7(plansDir))
2446
2526
  return [];
2447
2527
  const files = readdirSync2(plansDir).filter((f) => f.endsWith(".md")).map((f) => {
2448
2528
  const fullPath = join6(plansDir, f);
@@ -2455,7 +2535,7 @@ function findPlans(directory) {
2455
2535
  }
2456
2536
  }
2457
2537
  function getPlanProgress(planPath) {
2458
- if (!existsSync6(planPath)) {
2538
+ if (!existsSync7(planPath)) {
2459
2539
  return { total: 0, completed: 0, isComplete: true };
2460
2540
  }
2461
2541
  try {
@@ -2491,14 +2571,14 @@ function resumeWork(directory) {
2491
2571
  return writeWorkState(directory, state);
2492
2572
  }
2493
2573
  // src/features/work-state/validation.ts
2494
- import { readFileSync as readFileSync6, existsSync as existsSync7 } from "fs";
2495
- import { resolve as resolve3, sep } from "path";
2574
+ import { readFileSync as readFileSync6, existsSync as existsSync8 } from "fs";
2575
+ import { resolve as resolve3, sep as sep2 } from "path";
2496
2576
  function validatePlan(planPath, projectDir) {
2497
2577
  const errors = [];
2498
2578
  const warnings = [];
2499
2579
  const resolvedPlanPath = resolve3(planPath);
2500
2580
  const allowedDir = resolve3(projectDir, PLANS_DIR);
2501
- if (!resolvedPlanPath.startsWith(allowedDir + sep) && resolvedPlanPath !== allowedDir) {
2581
+ if (!resolvedPlanPath.startsWith(allowedDir + sep2) && resolvedPlanPath !== allowedDir) {
2502
2582
  errors.push({
2503
2583
  severity: "error",
2504
2584
  category: "structure",
@@ -2506,7 +2586,7 @@ function validatePlan(planPath, projectDir) {
2506
2586
  });
2507
2587
  return { valid: false, errors, warnings };
2508
2588
  }
2509
- if (!existsSync7(resolvedPlanPath)) {
2589
+ if (!existsSync8(resolvedPlanPath)) {
2510
2590
  errors.push({
2511
2591
  severity: "error",
2512
2592
  category: "structure",
@@ -2678,7 +2758,7 @@ function validateFileReferences(content, projectDir, warnings) {
2678
2758
  }
2679
2759
  const resolvedProject = resolve3(projectDir);
2680
2760
  const absolutePath = resolve3(projectDir, filePath);
2681
- if (!absolutePath.startsWith(resolvedProject + sep) && absolutePath !== resolvedProject) {
2761
+ if (!absolutePath.startsWith(resolvedProject + sep2) && absolutePath !== resolvedProject) {
2682
2762
  warnings.push({
2683
2763
  severity: "warning",
2684
2764
  category: "file-references",
@@ -2686,7 +2766,7 @@ function validateFileReferences(content, projectDir, warnings) {
2686
2766
  });
2687
2767
  continue;
2688
2768
  }
2689
- if (!existsSync7(absolutePath)) {
2769
+ if (!existsSync8(absolutePath)) {
2690
2770
  warnings.push({
2691
2771
  severity: "warning",
2692
2772
  category: "file-references",
@@ -2770,370 +2850,1865 @@ function validateVerificationSection(content, errors) {
2770
2850
  });
2771
2851
  }
2772
2852
  }
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 };
2853
+ // src/features/workflow/constants.ts
2854
+ var WORKFLOWS_STATE_DIR = ".weave/workflows";
2855
+ var INSTANCE_STATE_FILE = "state.json";
2856
+ var ACTIVE_INSTANCE_FILE = "active-instance.json";
2857
+ var WORKFLOWS_DIR_PROJECT = ".opencode/workflows";
2858
+ var WORKFLOWS_DIR_USER = "workflows";
2859
+ // src/features/workflow/storage.ts
2860
+ import { existsSync as existsSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync2, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3, readdirSync as readdirSync3 } from "fs";
2861
+ import { join as join7 } from "path";
2862
+ import { randomBytes } from "node:crypto";
2863
+ function generateInstanceId() {
2864
+ return `wf_${randomBytes(4).toString("hex")}`;
2865
+ }
2866
+ function generateSlug(goal) {
2867
+ return goal.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 50);
2868
+ }
2869
+ function createWorkflowInstance(definition, definitionPath, goal, sessionId) {
2870
+ const instanceId = generateInstanceId();
2871
+ const slug = generateSlug(goal);
2872
+ const firstStepId = definition.steps[0].id;
2873
+ const steps = {};
2874
+ for (const step of definition.steps) {
2875
+ steps[step.id] = {
2876
+ id: step.id,
2877
+ status: step.id === firstStepId ? "active" : "pending",
2878
+ ...step.id === firstStepId ? { started_at: new Date().toISOString() } : {}
2879
+ };
2778
2880
  }
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);
2881
+ return {
2882
+ instance_id: instanceId,
2883
+ definition_id: definition.name,
2884
+ definition_name: definition.name,
2885
+ definition_path: definitionPath,
2886
+ goal,
2887
+ slug,
2888
+ status: "running",
2889
+ started_at: new Date().toISOString(),
2890
+ session_ids: [sessionId],
2891
+ current_step_id: firstStepId,
2892
+ steps,
2893
+ artifacts: {}
2894
+ };
2895
+ }
2896
+ function readWorkflowInstance(directory, instanceId) {
2897
+ const filePath = join7(directory, WORKFLOWS_STATE_DIR, instanceId, INSTANCE_STATE_FILE);
2898
+ try {
2899
+ if (!existsSync9(filePath))
2900
+ return null;
2901
+ const raw = readFileSync7(filePath, "utf-8");
2902
+ const parsed = JSON.parse(raw);
2903
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
2904
+ return null;
2905
+ if (typeof parsed.instance_id !== "string")
2906
+ return null;
2907
+ return parsed;
2908
+ } catch {
2909
+ return null;
2784
2910
  }
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)}
2911
+ }
2912
+ function writeWorkflowInstance(directory, instance) {
2913
+ try {
2914
+ const dir = join7(directory, WORKFLOWS_STATE_DIR, instance.instance_id);
2915
+ if (!existsSync9(dir)) {
2916
+ mkdirSync3(dir, { recursive: true });
2917
+ }
2918
+ writeFileSync2(join7(dir, INSTANCE_STATE_FILE), JSON.stringify(instance, null, 2), "utf-8");
2919
+ return true;
2920
+ } catch {
2921
+ return false;
2922
+ }
2923
+ }
2924
+ function readActiveInstance(directory) {
2925
+ const filePath = join7(directory, WORKFLOWS_STATE_DIR, ACTIVE_INSTANCE_FILE);
2926
+ try {
2927
+ if (!existsSync9(filePath))
2928
+ return null;
2929
+ const raw = readFileSync7(filePath, "utf-8");
2930
+ const parsed = JSON.parse(raw);
2931
+ if (!parsed || typeof parsed !== "object" || typeof parsed.instance_id !== "string")
2932
+ return null;
2933
+ return parsed;
2934
+ } catch {
2935
+ return null;
2936
+ }
2937
+ }
2938
+ function setActiveInstance(directory, instanceId) {
2939
+ try {
2940
+ const dir = join7(directory, WORKFLOWS_STATE_DIR);
2941
+ if (!existsSync9(dir)) {
2942
+ mkdirSync3(dir, { recursive: true });
2943
+ }
2944
+ const pointer = { instance_id: instanceId };
2945
+ writeFileSync2(join7(dir, ACTIVE_INSTANCE_FILE), JSON.stringify(pointer, null, 2), "utf-8");
2946
+ return true;
2947
+ } catch {
2948
+ return false;
2949
+ }
2950
+ }
2951
+ function clearActiveInstance(directory) {
2952
+ const filePath = join7(directory, WORKFLOWS_STATE_DIR, ACTIVE_INSTANCE_FILE);
2953
+ try {
2954
+ if (existsSync9(filePath)) {
2955
+ unlinkSync2(filePath);
2956
+ }
2957
+ return true;
2958
+ } catch {
2959
+ return false;
2960
+ }
2961
+ }
2962
+ function getActiveWorkflowInstance(directory) {
2963
+ const pointer = readActiveInstance(directory);
2964
+ if (!pointer)
2965
+ return null;
2966
+ return readWorkflowInstance(directory, pointer.instance_id);
2967
+ }
2968
+ // src/features/workflow/discovery.ts
2969
+ import * as fs5 from "fs";
2970
+ import * as path5 from "path";
2971
+ import * as os3 from "os";
2972
+ import { parse as parseJsonc } from "jsonc-parser";
2973
+
2974
+ // src/features/workflow/schema.ts
2975
+ import { z as z2 } from "zod";
2976
+ var CompletionConfigSchema = z2.object({
2977
+ method: z2.enum(["user_confirm", "plan_created", "plan_complete", "review_verdict", "agent_signal"]),
2978
+ plan_name: z2.string().optional(),
2979
+ keywords: z2.array(z2.string()).optional()
2980
+ });
2981
+ var ArtifactRefSchema = z2.object({
2982
+ name: z2.string(),
2983
+ description: z2.string().optional()
2984
+ });
2985
+ var StepArtifactsSchema = z2.object({
2986
+ inputs: z2.array(ArtifactRefSchema).optional(),
2987
+ outputs: z2.array(ArtifactRefSchema).optional()
2988
+ });
2989
+ var WorkflowStepSchema = z2.object({
2990
+ id: z2.string().regex(/^[a-z][a-z0-9-]*$/, "Step ID must be lowercase alphanumeric with hyphens"),
2991
+ name: z2.string(),
2992
+ type: z2.enum(["interactive", "autonomous", "gate"]),
2993
+ agent: z2.string(),
2994
+ prompt: z2.string(),
2995
+ completion: CompletionConfigSchema,
2996
+ artifacts: StepArtifactsSchema.optional(),
2997
+ on_reject: z2.enum(["pause", "fail"]).optional()
2998
+ });
2999
+ var WorkflowDefinitionSchema = z2.object({
3000
+ name: z2.string().regex(/^[a-z][a-z0-9-]*$/, "Workflow name must be lowercase alphanumeric with hyphens"),
3001
+ description: z2.string().optional(),
3002
+ version: z2.number().int().positive(),
3003
+ steps: z2.array(WorkflowStepSchema).min(1, "Workflow must have at least one step")
3004
+ });
2797
3005
 
2798
- Tell the user to fix the plan file and run /start-work again.`
2799
- };
3006
+ // src/features/workflow/discovery.ts
3007
+ function loadWorkflowDefinition(filePath) {
3008
+ let raw;
3009
+ try {
3010
+ raw = fs5.readFileSync(filePath, "utf-8");
3011
+ } catch (err) {
3012
+ log("Failed to read workflow definition file", { filePath, error: String(err) });
3013
+ return null;
3014
+ }
3015
+ let parsed;
3016
+ try {
3017
+ parsed = parseJsonc(raw);
3018
+ } catch (err) {
3019
+ log("Failed to parse workflow definition JSONC", { filePath, error: String(err) });
3020
+ return null;
3021
+ }
3022
+ const result = WorkflowDefinitionSchema.safeParse(parsed);
3023
+ if (!result.success) {
3024
+ log("Workflow definition failed validation", {
3025
+ filePath,
3026
+ errors: result.error.issues.map((i) => i.message)
3027
+ });
3028
+ return null;
3029
+ }
3030
+ return result.data;
3031
+ }
3032
+ function scanWorkflowDirectory(directory, scope) {
3033
+ if (!fs5.existsSync(directory))
3034
+ return [];
3035
+ let entries;
3036
+ try {
3037
+ entries = fs5.readdirSync(directory, { withFileTypes: true });
3038
+ } catch (err) {
3039
+ log("Failed to read workflows directory", { directory, error: String(err) });
3040
+ return [];
3041
+ }
3042
+ const workflows = [];
3043
+ for (const entry of entries) {
3044
+ if (!entry.isFile())
3045
+ continue;
3046
+ if (!entry.name.endsWith(".jsonc") && !entry.name.endsWith(".json"))
3047
+ continue;
3048
+ const filePath = path5.join(directory, entry.name);
3049
+ const definition = loadWorkflowDefinition(filePath);
3050
+ if (definition) {
3051
+ workflows.push({ definition, path: filePath, scope });
3052
+ }
3053
+ }
3054
+ return workflows;
3055
+ }
3056
+ function discoverWorkflows(directory) {
3057
+ const projectDir = path5.join(directory, WORKFLOWS_DIR_PROJECT);
3058
+ const userDir = path5.join(os3.homedir(), ".config", "opencode", WORKFLOWS_DIR_USER);
3059
+ const userWorkflows = scanWorkflowDirectory(userDir, "user");
3060
+ const projectWorkflows = scanWorkflowDirectory(projectDir, "project");
3061
+ const byName = new Map;
3062
+ for (const wf of userWorkflows) {
3063
+ byName.set(wf.definition.name, wf);
3064
+ }
3065
+ for (const wf of projectWorkflows) {
3066
+ byName.set(wf.definition.name, wf);
3067
+ }
3068
+ return Array.from(byName.values());
3069
+ }
3070
+ // src/features/workflow/context.ts
3071
+ function resolveTemplate(template, instance, definition) {
3072
+ return template.replace(/\{\{(\w+)\.(\w+)\}\}/g, (_match, namespace, key) => {
3073
+ switch (namespace) {
3074
+ case "instance": {
3075
+ const instanceRecord = instance;
3076
+ const value = instanceRecord[key];
3077
+ return typeof value === "string" ? value : _match;
2800
3078
  }
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
- };
3079
+ case "artifacts": {
3080
+ const value = instance.artifacts[key];
3081
+ return value ?? "(not yet available)";
3082
+ }
3083
+ case "step": {
3084
+ const currentStep = definition.steps.find((s) => s.id === instance.current_step_id);
3085
+ if (!currentStep)
3086
+ return _match;
3087
+ const stepRecord = currentStep;
3088
+ const value = stepRecord[key];
3089
+ return typeof value === "string" ? value : _match;
2812
3090
  }
3091
+ default:
3092
+ return _match;
3093
+ }
3094
+ });
3095
+ }
3096
+ function buildContextHeader(instance, definition) {
3097
+ const currentStepIndex = definition.steps.findIndex((s) => s.id === instance.current_step_id);
3098
+ const currentStepDef = definition.steps[currentStepIndex];
3099
+ const stepLabel = currentStepDef ? `step ${currentStepIndex + 1} of ${definition.steps.length}: ${currentStepDef.name}` : `step ${currentStepIndex + 1} of ${definition.steps.length}`;
3100
+ const lines = [];
3101
+ lines.push("## Workflow Context");
3102
+ lines.push(`**Goal**: "${instance.goal}"`);
3103
+ lines.push(`**Workflow**: ${definition.name} (${stepLabel})`);
3104
+ lines.push("");
3105
+ const completedSteps = definition.steps.filter((s) => {
3106
+ const state = instance.steps[s.id];
3107
+ return state && state.status === "completed";
3108
+ });
3109
+ if (completedSteps.length > 0) {
3110
+ lines.push("### Completed Steps");
3111
+ for (const stepDef of completedSteps) {
3112
+ const state = instance.steps[stepDef.id];
3113
+ const summary = state.summary ? ` → "${truncateSummary(state.summary)}"` : "";
3114
+ lines.push(`- [✓] **${stepDef.name}**${summary}`);
3115
+ }
3116
+ lines.push("");
3117
+ }
3118
+ const artifactEntries = Object.entries(instance.artifacts);
3119
+ if (artifactEntries.length > 0) {
3120
+ lines.push("### Accumulated Artifacts");
3121
+ for (const [name, value] of artifactEntries) {
3122
+ lines.push(`- **${name}**: "${truncateSummary(value)}"`);
3123
+ }
3124
+ lines.push("");
3125
+ }
3126
+ return lines.join(`
3127
+ `);
3128
+ }
3129
+ function composeStepPrompt(stepDef, instance, definition) {
3130
+ const contextHeader = buildContextHeader(instance, definition);
3131
+ const resolvedPrompt = resolveTemplate(stepDef.prompt, instance, definition);
3132
+ return `${contextHeader}---
3133
+
3134
+ ## Your Task
3135
+ ${resolvedPrompt}`;
3136
+ }
3137
+ function truncateSummary(text) {
3138
+ const maxLength = 200;
3139
+ if (text.length <= maxLength)
3140
+ return text;
3141
+ return text.slice(0, maxLength - 3) + "...";
3142
+ }
3143
+ // src/features/workflow/completion.ts
3144
+ import { existsSync as existsSync11 } from "fs";
3145
+ import { join as join9 } from "path";
3146
+ var DEFAULT_CONFIRM_KEYWORDS = ["confirmed", "approved", "continue", "done", "let's proceed", "looks good", "lgtm"];
3147
+ var VERDICT_APPROVE_RE = /\[\s*APPROVE\s*\]/i;
3148
+ var VERDICT_REJECT_RE = /\[\s*REJECT\s*\]/i;
3149
+ var AGENT_SIGNAL_MARKER = "<!-- workflow:step-complete -->";
3150
+ function checkStepCompletion(method, context) {
3151
+ switch (method) {
3152
+ case "user_confirm":
3153
+ return checkUserConfirm(context);
3154
+ case "plan_created":
3155
+ return checkPlanCreated(context);
3156
+ case "plan_complete":
3157
+ return checkPlanComplete(context);
3158
+ case "review_verdict":
3159
+ return checkReviewVerdict(context);
3160
+ case "agent_signal":
3161
+ return checkAgentSignal(context);
3162
+ default:
3163
+ return { complete: false, reason: `Unknown completion method: ${method}` };
3164
+ }
3165
+ }
3166
+ function checkUserConfirm(context) {
3167
+ const { lastUserMessage, config } = context;
3168
+ if (!lastUserMessage)
3169
+ return { complete: false };
3170
+ const keywords = config.keywords ?? DEFAULT_CONFIRM_KEYWORDS;
3171
+ const lowerMessage = lastUserMessage.toLowerCase().trim();
3172
+ for (const keyword of keywords) {
3173
+ if (lowerMessage.includes(keyword.toLowerCase())) {
2813
3174
  return {
2814
- switchAgent: "tapestry",
2815
- contextInjection: resumeContext
3175
+ complete: true,
3176
+ summary: `User confirmed: "${lastUserMessage.slice(0, 100)}"`
2816
3177
  };
2817
3178
  }
2818
3179
  }
2819
- return handlePlanDiscovery(allPlans, sessionId, directory);
2820
- }
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;
3180
+ return { complete: false };
2827
3181
  }
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
- };
3182
+ function checkPlanCreated(context) {
3183
+ const { config, directory } = context;
3184
+ const planName = config.plan_name;
3185
+ if (!planName) {
3186
+ return { complete: false, reason: "plan_created requires plan_name in completion config" };
2844
3187
  }
2845
- const progress = getPlanProgress(matched);
2846
- if (progress.isComplete) {
3188
+ const plans = findPlans(directory);
3189
+ const matchingPlan = plans.find((p) => p.includes(planName));
3190
+ if (matchingPlan) {
2847
3191
  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.`
3192
+ complete: true,
3193
+ artifacts: { plan_path: matchingPlan },
3194
+ summary: `Plan created at ${matchingPlan}`
2852
3195
  };
2853
3196
  }
2854
- const validation = validatePlan(matched, directory);
2855
- if (!validation.valid) {
3197
+ const directPath = join9(directory, ".weave", "plans", `${planName}.md`);
3198
+ if (existsSync11(directPath)) {
2856
3199
  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.`
3200
+ complete: true,
3201
+ artifacts: { plan_path: directPath },
3202
+ summary: `Plan created at ${directPath}`
2864
3203
  };
2865
3204
  }
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) {
3205
+ return { complete: false };
3206
+ }
3207
+ function checkPlanComplete(context) {
3208
+ const { config, directory } = context;
3209
+ const planName = config.plan_name;
3210
+ if (!planName) {
3211
+ return { complete: false, reason: "plan_complete requires plan_name in completion config" };
3212
+ }
3213
+ const planPath = join9(directory, ".weave", "plans", `${planName}.md`);
3214
+ if (!existsSync11(planPath)) {
3215
+ return { complete: false, reason: `Plan file not found: ${planPath}` };
3216
+ }
3217
+ const progress = getPlanProgress(planPath);
3218
+ if (progress.isComplete) {
2871
3219
  return {
2872
- switchAgent: "tapestry",
2873
- contextInjection: `${freshContext}
2874
-
2875
- ### Validation Warnings
2876
- ${formatValidationResults(validation)}`
3220
+ complete: true,
3221
+ summary: `Plan completed: ${progress.completed}/${progress.total} tasks done`
2877
3222
  };
2878
3223
  }
2879
3224
  return {
2880
- switchAgent: "tapestry",
2881
- contextInjection: freshContext
3225
+ complete: false,
3226
+ reason: `Plan in progress: ${progress.completed}/${progress.total} tasks done`
2882
3227
  };
2883
3228
  }
2884
- function handlePlanDiscovery(allPlans, sessionId, directory) {
2885
- if (allPlans.length === 0) {
3229
+ function checkReviewVerdict(context) {
3230
+ const { lastAssistantMessage } = context;
3231
+ if (!lastAssistantMessage)
3232
+ return { complete: false };
3233
+ if (VERDICT_APPROVE_RE.test(lastAssistantMessage)) {
2886
3234
  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."
3235
+ complete: true,
3236
+ verdict: "approve",
3237
+ summary: "Review verdict: APPROVED"
2889
3238
  };
2890
3239
  }
2891
- const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete);
2892
- if (incompletePlans.length === 0) {
3240
+ if (VERDICT_REJECT_RE.test(lastAssistantMessage)) {
2893
3241
  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.`
3242
+ complete: true,
3243
+ verdict: "reject",
3244
+ summary: "Review verdict: REJECTED"
2898
3245
  };
2899
3246
  }
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)}`
2925
- };
2926
- }
3247
+ return { complete: false };
3248
+ }
3249
+ function checkAgentSignal(context) {
3250
+ const { lastAssistantMessage } = context;
3251
+ if (!lastAssistantMessage)
3252
+ return { complete: false };
3253
+ if (lastAssistantMessage.includes(AGENT_SIGNAL_MARKER)) {
2927
3254
  return {
2928
- switchAgent: "tapestry",
2929
- contextInjection: freshContext
3255
+ complete: true,
3256
+ summary: "Agent signaled completion"
2930
3257
  };
2931
3258
  }
2932
- const listing = incompletePlans.map((p) => {
2933
- const progress = getPlanProgress(p);
2934
- return ` - **${getPlanName(p)}** (${progress.completed}/${progress.total} tasks done)`;
2935
- }).join(`
2936
- `);
3259
+ return { complete: false };
3260
+ }
3261
+ // src/features/workflow/engine.ts
3262
+ function startWorkflow(input) {
3263
+ const { definition, definitionPath, goal, sessionId, directory } = input;
3264
+ const instance = createWorkflowInstance(definition, definitionPath, goal, sessionId);
3265
+ writeWorkflowInstance(directory, instance);
3266
+ setActiveInstance(directory, instance.instance_id);
3267
+ const firstStepDef = definition.steps[0];
3268
+ const prompt = composeStepPrompt(firstStepDef, instance, definition);
2937
3269
  return {
2938
- switchAgent: "tapestry",
2939
- contextInjection: `## Multiple Plans Found
2940
- There are ${incompletePlans.length} incomplete plans:
2941
- ${listing}
2942
-
2943
- Ask the user which plan to work on. They can run \`/start-work [plan-name]\` to select one.`
3270
+ type: "inject_prompt",
3271
+ prompt,
3272
+ agent: firstStepDef.agent
2944
3273
  };
2945
3274
  }
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;
3275
+ function checkAndAdvance(input) {
3276
+ const { directory, context } = input;
3277
+ const instance = getActiveWorkflowInstance(directory);
3278
+ if (!instance)
3279
+ return { type: "none" };
3280
+ if (instance.status !== "running")
3281
+ return { type: "none" };
3282
+ const definition = loadWorkflowDefinition(instance.definition_path);
3283
+ if (!definition)
3284
+ return { type: "none", reason: "Failed to load workflow definition" };
3285
+ const currentStepDef = definition.steps.find((s) => s.id === instance.current_step_id);
3286
+ if (!currentStepDef)
3287
+ return { type: "none", reason: "Current step not found in definition" };
3288
+ const stepState = instance.steps[instance.current_step_id];
3289
+ if (!stepState || stepState.status !== "active")
3290
+ return { type: "none" };
3291
+ const completionResult = checkStepCompletion(currentStepDef.completion.method, context);
3292
+ if (!completionResult.complete) {
3293
+ if (currentStepDef.type === "interactive")
3294
+ return { type: "none" };
3295
+ return { type: "none" };
3296
+ }
3297
+ if (currentStepDef.type === "gate" && completionResult.verdict === "reject") {
3298
+ return handleGateReject(directory, instance, currentStepDef, completionResult);
3299
+ }
3300
+ return advanceToNextStep(directory, instance, definition, completionResult);
3301
+ }
3302
+ function handleGateReject(directory, instance, currentStepDef, completionResult) {
3303
+ const stepState = instance.steps[currentStepDef.id];
3304
+ stepState.status = "completed";
3305
+ stepState.completed_at = new Date().toISOString();
3306
+ stepState.verdict = "reject";
3307
+ stepState.summary = completionResult.summary;
3308
+ const action = currentStepDef.on_reject ?? "pause";
3309
+ if (action === "fail") {
3310
+ instance.status = "failed";
3311
+ instance.ended_at = new Date().toISOString();
3312
+ clearActiveInstance(directory);
3313
+ } else {
3314
+ instance.status = "paused";
3315
+ instance.pause_reason = "Gate step rejected";
3316
+ }
3317
+ writeWorkflowInstance(directory, instance);
3318
+ return {
3319
+ type: "pause",
3320
+ reason: `Gate step "${currentStepDef.id}" rejected${action === "fail" ? " — workflow failed" : " — workflow paused"}`
3321
+ };
2953
3322
  }
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}`);
3323
+ function advanceToNextStep(directory, instance, definition, completionResult) {
3324
+ const currentStepDef = definition.steps.find((s) => s.id === instance.current_step_id);
3325
+ const currentIndex = definition.steps.indexOf(currentStepDef);
3326
+ const stepState = instance.steps[instance.current_step_id];
3327
+ stepState.status = "completed";
3328
+ stepState.completed_at = new Date().toISOString();
3329
+ stepState.summary = completionResult.summary;
3330
+ if (completionResult.verdict)
3331
+ stepState.verdict = completionResult.verdict;
3332
+ if (completionResult.artifacts) {
3333
+ stepState.artifacts = completionResult.artifacts;
3334
+ Object.assign(instance.artifacts, completionResult.artifacts);
3335
+ }
3336
+ if (currentIndex >= definition.steps.length - 1) {
3337
+ instance.status = "completed";
3338
+ instance.ended_at = new Date().toISOString();
3339
+ clearActiveInstance(directory);
3340
+ writeWorkflowInstance(directory, instance);
3341
+ return { type: "complete", reason: "Workflow completed — all steps done" };
3342
+ }
3343
+ const nextStepDef = definition.steps[currentIndex + 1];
3344
+ instance.current_step_id = nextStepDef.id;
3345
+ instance.steps[nextStepDef.id].status = "active";
3346
+ instance.steps[nextStepDef.id].started_at = new Date().toISOString();
3347
+ writeWorkflowInstance(directory, instance);
3348
+ const prompt = composeStepPrompt(nextStepDef, instance, definition);
3349
+ return {
3350
+ type: "inject_prompt",
3351
+ prompt,
3352
+ agent: nextStepDef.agent
3353
+ };
3354
+ }
3355
+ function pauseWorkflow(directory, reason) {
3356
+ const instance = getActiveWorkflowInstance(directory);
3357
+ if (!instance || instance.status !== "running")
3358
+ return false;
3359
+ instance.status = "paused";
3360
+ instance.pause_reason = reason ?? "Paused by user";
3361
+ return writeWorkflowInstance(directory, instance);
3362
+ }
3363
+ function resumeWorkflow(directory) {
3364
+ const instance = getActiveWorkflowInstance(directory);
3365
+ if (!instance || instance.status !== "paused")
3366
+ return { type: "none", reason: "No paused workflow to resume" };
3367
+ const definition = loadWorkflowDefinition(instance.definition_path);
3368
+ if (!definition)
3369
+ return { type: "none", reason: "Failed to load workflow definition" };
3370
+ instance.status = "running";
3371
+ instance.pause_reason = undefined;
3372
+ const currentStepState = instance.steps[instance.current_step_id];
3373
+ if (currentStepState.status !== "active") {
3374
+ currentStepState.status = "active";
3375
+ currentStepState.started_at = new Date().toISOString();
3376
+ }
3377
+ writeWorkflowInstance(directory, instance);
3378
+ const currentStepDef = definition.steps.find((s) => s.id === instance.current_step_id);
3379
+ if (!currentStepDef)
3380
+ return { type: "none", reason: "Current step not found in definition" };
3381
+ const prompt = composeStepPrompt(currentStepDef, instance, definition);
3382
+ return {
3383
+ type: "inject_prompt",
3384
+ prompt,
3385
+ agent: currentStepDef.agent
3386
+ };
3387
+ }
3388
+ function skipStep(directory) {
3389
+ const instance = getActiveWorkflowInstance(directory);
3390
+ if (!instance)
3391
+ return { type: "none", reason: "No active workflow" };
3392
+ const definition = loadWorkflowDefinition(instance.definition_path);
3393
+ if (!definition)
3394
+ return { type: "none", reason: "Failed to load workflow definition" };
3395
+ return advanceToNextStep(directory, instance, definition, {
3396
+ complete: true,
3397
+ summary: "Step skipped by user"
3398
+ });
3399
+ }
3400
+ function abortWorkflow(directory) {
3401
+ const instance = getActiveWorkflowInstance(directory);
3402
+ if (!instance)
3403
+ return false;
3404
+ instance.status = "cancelled";
3405
+ instance.ended_at = new Date().toISOString();
3406
+ clearActiveInstance(directory);
3407
+ return writeWorkflowInstance(directory, instance);
3408
+ }
3409
+ // src/features/workflow/hook.ts
3410
+ var WORKFLOW_CONTINUATION_MARKER = "<!-- weave:workflow-continuation -->";
3411
+ function parseWorkflowArgs(args) {
3412
+ const trimmed = args.trim();
3413
+ if (!trimmed)
3414
+ return { workflowName: null, goal: null };
3415
+ const quotedMatch = trimmed.match(/^(\S+)\s+"([^"]+)"$/);
3416
+ if (quotedMatch) {
3417
+ return { workflowName: quotedMatch[1], goal: quotedMatch[2] };
3418
+ }
3419
+ const singleQuotedMatch = trimmed.match(/^(\S+)\s+'([^']+)'$/);
3420
+ if (singleQuotedMatch) {
3421
+ return { workflowName: singleQuotedMatch[1], goal: singleQuotedMatch[2] };
3422
+ }
3423
+ const parts = trimmed.split(/\s+/);
3424
+ if (parts.length === 1) {
3425
+ return { workflowName: parts[0], goal: null };
3426
+ }
3427
+ return { workflowName: parts[0], goal: parts.slice(1).join(" ") };
3428
+ }
3429
+ function handleRunWorkflow(input) {
3430
+ const { promptText, sessionId, directory } = input;
3431
+ if (!promptText.includes("<session-context>")) {
3432
+ return { contextInjection: null, switchAgent: null };
3433
+ }
3434
+ const args = extractArguments(promptText);
3435
+ const { workflowName, goal } = parseWorkflowArgs(args);
3436
+ const workStateWarning = checkWorkStatePlanActive(directory);
3437
+ const activeInstance = getActiveWorkflowInstance(directory);
3438
+ if (!workflowName && !activeInstance) {
3439
+ const result = listAvailableWorkflows(directory);
3440
+ return prependWarning(result, workStateWarning);
3441
+ }
3442
+ if (!workflowName && activeInstance) {
3443
+ const result = resumeActiveWorkflow(directory);
3444
+ return prependWarning(result, workStateWarning);
3445
+ }
3446
+ if (workflowName && !goal && activeInstance && activeInstance.definition_id === workflowName) {
3447
+ const result = resumeActiveWorkflow(directory);
3448
+ return prependWarning(result, workStateWarning);
3449
+ }
3450
+ if (workflowName && goal) {
3451
+ if (activeInstance) {
3452
+ return {
3453
+ contextInjection: `## Workflow Already Active
3454
+ There is already an active workflow: "${activeInstance.definition_name}" (${activeInstance.instance_id}).
3455
+ Goal: "${activeInstance.goal}"
3456
+
3457
+ To start a new workflow, first abort the current one with \`/workflow abort\` or let it complete.`,
3458
+ switchAgent: null
3459
+ };
2960
3460
  }
3461
+ const result = startNewWorkflow(workflowName, goal, sessionId, directory);
3462
+ return prependWarning(result, workStateWarning);
2961
3463
  }
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}`);
3464
+ if (workflowName && !goal) {
3465
+ if (activeInstance) {
3466
+ return {
3467
+ contextInjection: `## Workflow Already Active
3468
+ There is already an active workflow: "${activeInstance.definition_name}" (${activeInstance.instance_id}).
3469
+ Goal: "${activeInstance.goal}"
3470
+
3471
+ Did you mean to resume the active workflow? Run \`/run-workflow\` without arguments to resume.`,
3472
+ switchAgent: null
3473
+ };
2968
3474
  }
3475
+ return {
3476
+ contextInjection: `## Goal Required
3477
+ To start the "${workflowName}" workflow, provide a goal:
3478
+ \`/run-workflow ${workflowName} "your goal here"\``,
3479
+ switchAgent: null
3480
+ };
3481
+ }
3482
+ return { contextInjection: null, switchAgent: null };
3483
+ }
3484
+ function checkWorkflowContinuation(input) {
3485
+ const { directory, lastAssistantMessage, lastUserMessage } = input;
3486
+ const instance = getActiveWorkflowInstance(directory);
3487
+ if (!instance)
3488
+ return { continuationPrompt: null, switchAgent: null };
3489
+ if (instance.status !== "running")
3490
+ return { continuationPrompt: null, switchAgent: null };
3491
+ const definition = loadWorkflowDefinition(instance.definition_path);
3492
+ if (!definition)
3493
+ return { continuationPrompt: null, switchAgent: null };
3494
+ const currentStepDef = definition.steps.find((s) => s.id === instance.current_step_id);
3495
+ if (!currentStepDef)
3496
+ return { continuationPrompt: null, switchAgent: null };
3497
+ const completionContext = {
3498
+ directory,
3499
+ config: currentStepDef.completion,
3500
+ artifacts: instance.artifacts,
3501
+ lastAssistantMessage,
3502
+ lastUserMessage
3503
+ };
3504
+ const action = checkAndAdvance({ directory, context: completionContext });
3505
+ switch (action.type) {
3506
+ case "inject_prompt":
3507
+ return {
3508
+ continuationPrompt: `${WORKFLOW_CONTINUATION_MARKER}
3509
+ ${action.prompt}`,
3510
+ switchAgent: action.agent ?? null
3511
+ };
3512
+ case "complete":
3513
+ return {
3514
+ continuationPrompt: `${WORKFLOW_CONTINUATION_MARKER}
3515
+ ## Workflow Complete
3516
+ ${action.reason ?? "All steps have been completed."}
3517
+
3518
+ Summarize what was accomplished across all workflow steps.`,
3519
+ switchAgent: null
3520
+ };
3521
+ case "pause":
3522
+ return {
3523
+ continuationPrompt: `${WORKFLOW_CONTINUATION_MARKER}
3524
+ ## Workflow Paused
3525
+ ${action.reason ?? "The workflow has been paused."}
3526
+
3527
+ Inform the user about the pause and what to do next.`,
3528
+ switchAgent: null
3529
+ };
3530
+ case "none":
3531
+ default:
3532
+ return { continuationPrompt: null, switchAgent: null };
2969
3533
  }
2970
- return lines.join(`
2971
- `);
2972
3534
  }
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}
3535
+ function checkWorkStatePlanActive(directory) {
3536
+ const state = readWorkState(directory);
3537
+ if (!state)
3538
+ return null;
3539
+ const progress = getPlanProgress(state.active_plan);
3540
+ if (progress.isComplete)
3541
+ return null;
3542
+ const status = state.paused ? "paused" : "running";
3543
+ const planName = state.plan_name ?? state.active_plan;
3544
+ return `## Active Plan Detected
2979
3545
 
2980
- Read the plan file now and begin executing from the first unchecked \`- [ ]\` task.
3546
+ There is currently an active plan being executed: "${planName}"
3547
+ Status: ${status} • Progress: ${progress.completed}/${progress.total} tasks complete
2981
3548
 
2982
- **SIDEBAR TODOSDO 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.`;
3549
+ Starting a workflow will take priority over the active plan plan continuation will be suspended while the workflow runs.
3550
+
3551
+ **Options:**
3552
+ - **Proceed anyway** the plan will be paused and can be resumed with \`/start-work\` after the workflow completes
3553
+ - **Abort the plan first** — abandon the current plan, then start the workflow
3554
+ - **Cancel** don't start the workflow, continue with the plan`;
2988
3555
  }
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}
3556
+ function prependWarning(result, warning) {
3557
+ if (!warning)
3558
+ return result;
3559
+ if (!result.contextInjection) {
3560
+ return { ...result, contextInjection: warning };
3561
+ }
3562
+ return { ...result, contextInjection: `${warning}
2997
3563
 
2998
- Read the plan file now and continue from the first unchecked \`- [ ]\` task.
3564
+ ---
2999
3565
 
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.`;
3566
+ ${result.contextInjection}` };
3567
+ }
3568
+ function extractArguments(promptText) {
3569
+ const match = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i);
3570
+ if (!match)
3571
+ return "";
3572
+ return match[1].trim();
3006
3573
  }
3574
+ function listAvailableWorkflows(directory) {
3575
+ const workflows = discoverWorkflows(directory);
3576
+ if (workflows.length === 0) {
3577
+ return {
3578
+ contextInjection: "## No Workflows Available\nNo workflow definitions found.\n\nWorkflow definitions should be placed in `.opencode/workflows/` (project) or `~/.config/opencode/workflows/` (user).",
3579
+ switchAgent: null
3580
+ };
3581
+ }
3582
+ const listing = workflows.map((w) => ` - **${w.definition.name}**: ${w.definition.description ?? "(no description)"} (${w.scope})`).join(`
3583
+ `);
3584
+ return {
3585
+ contextInjection: `## Available Workflows
3586
+ ${listing}
3007
3587
 
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 };
3588
+ To start a workflow, run:
3589
+ \`/run-workflow <name> "your goal"\``,
3590
+ switchAgent: null
3591
+ };
3592
+ }
3593
+ function resumeActiveWorkflow(directory) {
3594
+ const action = resumeWorkflow(directory);
3595
+ if (action.type === "none") {
3596
+ const instance = getActiveWorkflowInstance(directory);
3597
+ if (instance && instance.status === "running") {
3598
+ const definition = loadWorkflowDefinition(instance.definition_path);
3599
+ if (definition) {
3600
+ const currentStep = definition.steps.find((s) => s.id === instance.current_step_id);
3601
+ return {
3602
+ contextInjection: `## Workflow In Progress
3603
+ Workflow "${instance.definition_name}" is already running.
3604
+ Current step: **${currentStep?.name ?? instance.current_step_id}**
3605
+ Goal: "${instance.goal}"
3606
+
3607
+ Continue with the current step.`,
3608
+ switchAgent: currentStep?.agent ?? null
3609
+ };
3610
+ }
3611
+ }
3612
+ return { contextInjection: null, switchAgent: null };
3016
3613
  }
3017
- if (state.paused) {
3018
- return { continuationPrompt: null };
3614
+ return {
3615
+ contextInjection: action.prompt ?? null,
3616
+ switchAgent: action.agent ?? null
3617
+ };
3618
+ }
3619
+ function startNewWorkflow(workflowName, goal, sessionId, directory) {
3620
+ const workflows = discoverWorkflows(directory);
3621
+ const match = workflows.find((w) => w.definition.name === workflowName);
3622
+ if (!match) {
3623
+ const available = workflows.map((w) => w.definition.name).join(", ");
3624
+ return {
3625
+ contextInjection: `## Workflow Not Found
3626
+ No workflow definition named "${workflowName}" was found.
3627
+ ${available ? `Available workflows: ${available}` : "No workflow definitions available."}`,
3628
+ switchAgent: null
3629
+ };
3019
3630
  }
3020
- if (state.session_ids.length > 0 && !state.session_ids.includes(input.sessionId)) {
3021
- return { continuationPrompt: null };
3631
+ const action = startWorkflow({
3632
+ definition: match.definition,
3633
+ definitionPath: match.path,
3634
+ goal,
3635
+ sessionId,
3636
+ directory
3637
+ });
3638
+ log("Workflow started", {
3639
+ workflowName: match.definition.name,
3640
+ goal,
3641
+ agent: action.agent
3642
+ });
3643
+ return {
3644
+ contextInjection: action.prompt ?? null,
3645
+ switchAgent: action.agent ?? null
3646
+ };
3647
+ }
3648
+ // src/features/workflow/commands.ts
3649
+ var PAUSE_PATTERNS = [/\bworkflow\s+pause\b/i, /\bpause\s+workflow\b/i];
3650
+ var SKIP_PATTERNS = [/\bworkflow\s+skip\b/i, /\bskip\s+step\b/i];
3651
+ var ABORT_PATTERNS = [/\bworkflow\s+abort\b/i, /\babort\s+workflow\b/i];
3652
+ var STATUS_PATTERNS = [/\bworkflow\s+status\b/i];
3653
+ function handleWorkflowCommand(message, directory) {
3654
+ const instance = getActiveWorkflowInstance(directory);
3655
+ if (!instance)
3656
+ return { handled: false };
3657
+ const trimmed = message.trim();
3658
+ if (matchesAny(trimmed, PAUSE_PATTERNS)) {
3659
+ return handlePause(directory, instance);
3022
3660
  }
3023
- const progress = getPlanProgress(state.active_plan);
3024
- if (progress.isComplete) {
3025
- return { continuationPrompt: null };
3661
+ if (matchesAny(trimmed, SKIP_PATTERNS)) {
3662
+ return handleSkip(directory, instance);
3026
3663
  }
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 };
3664
+ if (matchesAny(trimmed, ABORT_PATTERNS)) {
3665
+ return handleAbort(directory, instance);
3666
+ }
3667
+ if (matchesAny(trimmed, STATUS_PATTERNS)) {
3668
+ return handleStatus(directory, instance);
3669
+ }
3670
+ return { handled: false };
3671
+ }
3672
+ function matchesAny(text, patterns) {
3673
+ return patterns.some((p) => p.test(text));
3674
+ }
3675
+ function handlePause(directory, instance) {
3676
+ if (instance.status !== "running") {
3677
+ return {
3678
+ handled: true,
3679
+ contextInjection: `## Workflow Not Running
3680
+ The workflow "${instance.definition_name}" is currently ${instance.status}. Cannot pause.`
3681
+ };
3682
+ }
3683
+ pauseWorkflow(directory, "Paused by user command");
3684
+ return {
3685
+ handled: true,
3686
+ contextInjection: `## Workflow Paused
3687
+ Workflow "${instance.definition_name}" has been paused.
3688
+ Goal: "${instance.goal}"
3689
+ Current step: ${instance.current_step_id}
3690
+
3691
+ To resume, run \`/run-workflow\`.`
3692
+ };
3693
+ }
3694
+ function handleSkip(directory, instance) {
3695
+ if (instance.status !== "running") {
3696
+ return {
3697
+ handled: true,
3698
+ contextInjection: `## Workflow Not Running
3699
+ The workflow "${instance.definition_name}" is currently ${instance.status}. Cannot skip step.`
3700
+ };
3701
+ }
3702
+ const currentStepId = instance.current_step_id;
3703
+ const action = skipStep(directory);
3704
+ if (action.type === "inject_prompt") {
3705
+ return {
3706
+ handled: true,
3707
+ contextInjection: `## Step Skipped
3708
+ Skipped step "${currentStepId}".
3709
+
3710
+ ${action.prompt}`,
3711
+ switchAgent: action.agent
3712
+ };
3713
+ }
3714
+ if (action.type === "complete") {
3715
+ return {
3716
+ handled: true,
3717
+ contextInjection: `## Step Skipped — Workflow Complete
3718
+ Skipped step "${currentStepId}".
3719
+ ${action.reason ?? "All steps have been completed."}`
3720
+ };
3721
+ }
3722
+ return {
3723
+ handled: true,
3724
+ contextInjection: `## Step Skipped
3725
+ Skipped step "${currentStepId}".`
3726
+ };
3727
+ }
3728
+ function handleAbort(directory, instance) {
3729
+ const name = instance.definition_name;
3730
+ const goal = instance.goal;
3731
+ abortWorkflow(directory);
3732
+ return {
3733
+ handled: true,
3734
+ contextInjection: `## Workflow Aborted
3735
+ Workflow "${name}" has been cancelled.
3736
+ Goal: "${goal}"
3737
+
3738
+ The workflow instance has been terminated and the active pointer cleared.`
3739
+ };
3740
+ }
3741
+ function handleStatus(directory, instance) {
3742
+ const definition = loadWorkflowDefinition(instance.definition_path);
3743
+ let stepsDisplay = "";
3744
+ if (definition) {
3745
+ const lines = [];
3746
+ for (const stepDef of definition.steps) {
3747
+ const stepState = instance.steps[stepDef.id];
3748
+ if (!stepState)
3749
+ continue;
3750
+ if (stepState.status === "completed") {
3751
+ const summary = stepState.summary ? ` → ${truncate(stepState.summary, 80)}` : "";
3752
+ lines.push(`- [✓] ${stepDef.name}${summary}`);
3753
+ } else if (stepState.status === "active") {
3754
+ lines.push(`- [→] ${stepDef.name} (active)`);
3755
+ } else if (stepState.status === "skipped") {
3756
+ lines.push(`- [⊘] ${stepDef.name} (skipped)`);
3757
+ } else {
3758
+ lines.push(`- [ ] ${stepDef.name}`);
3759
+ }
3760
+ }
3761
+ stepsDisplay = lines.join(`
3762
+ `);
3763
+ }
3764
+ const completedCount = Object.values(instance.steps).filter((s) => s.status === "completed").length;
3765
+ const totalCount = Object.keys(instance.steps).length;
3766
+ return {
3767
+ handled: true,
3768
+ contextInjection: `## Workflow Status: ${instance.definition_name}
3769
+ **Goal**: "${instance.goal}"
3770
+ **Instance**: ${instance.instance_id}
3771
+ **Status**: ${instance.status}
3772
+ **Progress**: ${completedCount}/${totalCount} steps
3773
+
3774
+ ### Steps
3775
+ ${stepsDisplay}`
3776
+ };
3777
+ }
3778
+ function truncate(text, maxLength) {
3779
+ if (text.length <= maxLength)
3780
+ return text;
3781
+ return text.slice(0, maxLength - 3) + "...";
3782
+ }
3783
+ // src/hooks/start-work-hook.ts
3784
+ function handleStartWork(input) {
3785
+ const { promptText, sessionId, directory } = input;
3786
+ if (!promptText.includes("<session-context>")) {
3787
+ return { contextInjection: null, switchAgent: null };
3788
+ }
3789
+ const workflowWarning = checkWorkflowActive(directory);
3790
+ if (workflowWarning) {
3791
+ return { contextInjection: workflowWarning, switchAgent: null };
3792
+ }
3793
+ const explicitPlanName = extractPlanName(promptText);
3794
+ const existingState = readWorkState(directory);
3795
+ const allPlans = findPlans(directory);
3796
+ if (explicitPlanName) {
3797
+ return handleExplicitPlan(explicitPlanName, allPlans, sessionId, directory);
3798
+ }
3799
+ if (existingState) {
3800
+ const progress = getPlanProgress(existingState.active_plan);
3801
+ if (!progress.isComplete) {
3802
+ const validation = validatePlan(existingState.active_plan, directory);
3803
+ if (!validation.valid) {
3804
+ clearWorkState(directory);
3805
+ return {
3806
+ switchAgent: "tapestry",
3807
+ contextInjection: `## Plan Validation Failed
3808
+ The active plan "${existingState.plan_name}" has structural issues. Work state has been cleared.
3809
+
3810
+ ${formatValidationResults(validation)}
3811
+
3812
+ Tell the user to fix the plan file and run /start-work again.`
3813
+ };
3814
+ }
3815
+ appendSessionId(directory, sessionId);
3816
+ resumeWork(directory);
3817
+ const resumeContext = buildResumeContext(existingState.active_plan, existingState.plan_name, progress, existingState.start_sha);
3818
+ if (validation.warnings.length > 0) {
3819
+ return {
3820
+ switchAgent: "tapestry",
3821
+ contextInjection: `${resumeContext}
3822
+
3823
+ ### Validation Warnings
3824
+ ${formatValidationResults(validation)}`
3825
+ };
3826
+ }
3827
+ return {
3828
+ switchAgent: "tapestry",
3829
+ contextInjection: resumeContext
3830
+ };
3831
+ }
3832
+ }
3833
+ return handlePlanDiscovery(allPlans, sessionId, directory);
3834
+ }
3835
+ function checkWorkflowActive(directory) {
3836
+ const instance = getActiveWorkflowInstance(directory);
3837
+ if (!instance)
3838
+ return null;
3839
+ if (instance.status !== "running" && instance.status !== "paused")
3840
+ return null;
3841
+ const definition = loadWorkflowDefinition(instance.definition_path);
3842
+ const totalSteps = definition ? definition.steps.length : 0;
3843
+ const completedSteps = Object.values(instance.steps).filter((s) => s.status === "completed").length;
3844
+ const status = instance.status === "paused" ? "paused" : "running";
3845
+ return `## Active Workflow Detected
3846
+
3847
+ There is currently an active workflow: "${instance.definition_name}" (${instance.instance_id})
3848
+ Goal: "${instance.goal}"
3849
+ Status: ${status} • Progress: ${completedSteps}/${totalSteps} steps complete
3850
+
3851
+ Starting plan execution will conflict with the active workflow — both systems use the idle loop and only one can drive at a time.
3852
+
3853
+ **Options:**
3854
+ - **Proceed anyway** — the workflow will be paused and can be resumed with \`/run-workflow\` after the plan completes
3855
+ - **Abort the workflow first** — cancel the workflow, then start the plan
3856
+ - **Cancel** — don't start the plan, continue with the workflow`;
3857
+ }
3858
+ function extractPlanName(promptText) {
3859
+ const match = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i);
3860
+ if (!match)
3861
+ return null;
3862
+ const cleaned = match[1].trim();
3863
+ return cleaned || null;
3864
+ }
3865
+ function handleExplicitPlan(requestedName, allPlans, sessionId, directory) {
3866
+ const matched = findPlanByName(allPlans, requestedName);
3867
+ if (!matched) {
3868
+ const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete);
3869
+ const listing = incompletePlans.length > 0 ? incompletePlans.map((p) => ` - ${getPlanName(p)}`).join(`
3870
+ `) : " (none)";
3871
+ return {
3872
+ switchAgent: "tapestry",
3873
+ contextInjection: `## Plan Not Found
3874
+ No plan matching "${requestedName}" was found.
3875
+
3876
+ Available incomplete plans:
3877
+ ${listing}
3878
+
3879
+ Tell the user which plans are available and ask them to specify one.`
3880
+ };
3881
+ }
3882
+ const progress = getPlanProgress(matched);
3883
+ if (progress.isComplete) {
3884
+ return {
3885
+ switchAgent: "tapestry",
3886
+ contextInjection: `## Plan Already Complete
3887
+ The plan "${getPlanName(matched)}" has all ${progress.total} tasks completed.
3888
+ Tell the user this plan is already done and suggest creating a new one with Pattern.`
3889
+ };
3890
+ }
3891
+ const validation = validatePlan(matched, directory);
3892
+ if (!validation.valid) {
3893
+ return {
3894
+ switchAgent: "tapestry",
3895
+ contextInjection: `## Plan Validation Failed
3896
+ The plan "${getPlanName(matched)}" has structural issues that must be fixed before execution can begin.
3897
+
3898
+ ${formatValidationResults(validation)}
3899
+
3900
+ Tell the user to fix these issues in the plan file and try again.`
3901
+ };
3902
+ }
3903
+ clearWorkState(directory);
3904
+ const state = createWorkState(matched, sessionId, "tapestry", directory);
3905
+ writeWorkState(directory, state);
3906
+ const freshContext = buildFreshContext(matched, getPlanName(matched), progress, state.start_sha);
3907
+ if (validation.warnings.length > 0) {
3908
+ return {
3909
+ switchAgent: "tapestry",
3910
+ contextInjection: `${freshContext}
3911
+
3912
+ ### Validation Warnings
3913
+ ${formatValidationResults(validation)}`
3914
+ };
3915
+ }
3916
+ return {
3917
+ switchAgent: "tapestry",
3918
+ contextInjection: freshContext
3919
+ };
3920
+ }
3921
+ function handlePlanDiscovery(allPlans, sessionId, directory) {
3922
+ if (allPlans.length === 0) {
3923
+ return {
3924
+ switchAgent: "tapestry",
3925
+ 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."
3926
+ };
3927
+ }
3928
+ const incompletePlans = allPlans.filter((p) => !getPlanProgress(p).isComplete);
3929
+ if (incompletePlans.length === 0) {
3930
+ return {
3931
+ switchAgent: "tapestry",
3932
+ contextInjection: `## All Plans Complete
3933
+ All existing plans have been completed.
3934
+ Tell the user to switch to Pattern agent to create a new plan.`
3935
+ };
3936
+ }
3937
+ if (incompletePlans.length === 1) {
3938
+ const plan = incompletePlans[0];
3939
+ const progress = getPlanProgress(plan);
3940
+ const validation = validatePlan(plan, directory);
3941
+ if (!validation.valid) {
3942
+ return {
3943
+ switchAgent: "tapestry",
3944
+ contextInjection: `## Plan Validation Failed
3945
+ The plan "${getPlanName(plan)}" has structural issues that must be fixed before execution can begin.
3946
+
3947
+ ${formatValidationResults(validation)}
3948
+
3949
+ Tell the user to fix these issues in the plan file and try again.`
3950
+ };
3951
+ }
3952
+ const state = createWorkState(plan, sessionId, "tapestry", directory);
3953
+ writeWorkState(directory, state);
3954
+ const freshContext = buildFreshContext(plan, getPlanName(plan), progress, state.start_sha);
3955
+ if (validation.warnings.length > 0) {
3956
+ return {
3957
+ switchAgent: "tapestry",
3958
+ contextInjection: `${freshContext}
3959
+
3960
+ ### Validation Warnings
3961
+ ${formatValidationResults(validation)}`
3962
+ };
3963
+ }
3964
+ return {
3965
+ switchAgent: "tapestry",
3966
+ contextInjection: freshContext
3967
+ };
3968
+ }
3969
+ const listing = incompletePlans.map((p) => {
3970
+ const progress = getPlanProgress(p);
3971
+ return ` - **${getPlanName(p)}** (${progress.completed}/${progress.total} tasks done)`;
3972
+ }).join(`
3973
+ `);
3974
+ return {
3975
+ switchAgent: "tapestry",
3976
+ contextInjection: `## Multiple Plans Found
3977
+ There are ${incompletePlans.length} incomplete plans:
3978
+ ${listing}
3979
+
3980
+ Ask the user which plan to work on. They can run \`/start-work [plan-name]\` to select one.`
3981
+ };
3982
+ }
3983
+ function findPlanByName(plans, requestedName) {
3984
+ const lower = requestedName.toLowerCase();
3985
+ const exact = plans.find((p) => getPlanName(p).toLowerCase() === lower);
3986
+ if (exact)
3987
+ return exact;
3988
+ const partial = plans.find((p) => getPlanName(p).toLowerCase().includes(lower));
3989
+ return partial || null;
3990
+ }
3991
+ function formatValidationResults(result) {
3992
+ const lines = [];
3993
+ if (result.errors.length > 0) {
3994
+ lines.push("**Errors (blocking):**");
3995
+ for (const err of result.errors) {
3996
+ lines.push(`- [${err.category}] ${err.message}`);
3997
+ }
3998
+ }
3999
+ if (result.warnings.length > 0) {
4000
+ if (result.errors.length > 0)
4001
+ lines.push("");
4002
+ lines.push("**Warnings:**");
4003
+ for (const warn of result.warnings) {
4004
+ lines.push(`- [${warn.category}] ${warn.message}`);
4005
+ }
4006
+ }
4007
+ return lines.join(`
4008
+ `);
4009
+ }
4010
+ function buildFreshContext(planPath, planName, progress, startSha) {
4011
+ const shaLine = startSha ? `
4012
+ **Start SHA**: ${startSha}` : "";
4013
+ return `## Starting Plan: ${planName}
4014
+ **Plan file**: ${planPath}
4015
+ **Progress**: ${progress.completed}/${progress.total} tasks completed${shaLine}
4016
+
4017
+ Read the plan file now and begin executing from the first unchecked \`- [ ]\` task.
4018
+
4019
+ **SIDEBAR TODOS — DO THIS FIRST:**
4020
+ Before starting any work, use todowrite to populate the sidebar:
4021
+ 1. Create a summary todo (in_progress): "${planName} ${progress.completed}/${progress.total}"
4022
+ 2. Create a todo for the first unchecked task (in_progress)
4023
+ 3. Create todos for the next 2-3 tasks (pending)
4024
+ Keep each todo under 35 chars. Update as you complete tasks.`;
4025
+ }
4026
+ function buildResumeContext(planPath, planName, progress, startSha) {
4027
+ const remaining = progress.total - progress.completed;
4028
+ const shaLine = startSha ? `
4029
+ **Start SHA**: ${startSha}` : "";
4030
+ return `## Resuming Plan: ${planName}
4031
+ **Plan file**: ${planPath}
4032
+ **Progress**: ${progress.completed}/${progress.total} tasks completed
4033
+ **Status**: RESUMING — continuing from where the previous session left off.${shaLine}
4034
+
4035
+ Read the plan file now and continue from the first unchecked \`- [ ]\` task.
4036
+
4037
+ **SIDEBAR TODOS — RESTORE STATE:**
4038
+ Previous session's todos are lost. Use todowrite to restore the sidebar:
4039
+ 1. Create a summary todo (in_progress): "${planName} ${progress.completed}/${progress.total}"
4040
+ 2. Create a todo for the next unchecked task (in_progress)
4041
+ 3. Create todos for the following 2-3 tasks (pending)
4042
+ Keep each todo under 35 chars. ${remaining} task${remaining !== 1 ? "s" : ""} remaining.`;
4043
+ }
4044
+
4045
+ // src/hooks/work-continuation.ts
4046
+ var CONTINUATION_MARKER = "<!-- weave:continuation -->";
4047
+ var MAX_STALE_CONTINUATIONS = 3;
4048
+ function checkContinuation(input) {
4049
+ const { directory } = input;
4050
+ const state = readWorkState(directory);
4051
+ if (!state) {
4052
+ return { continuationPrompt: null };
4053
+ }
4054
+ if (state.paused) {
4055
+ return { continuationPrompt: null };
4056
+ }
4057
+ if (state.session_ids.length > 0 && !state.session_ids.includes(input.sessionId)) {
4058
+ return { continuationPrompt: null };
4059
+ }
4060
+ const progress = getPlanProgress(state.active_plan);
4061
+ if (progress.isComplete) {
4062
+ return { continuationPrompt: null };
4063
+ }
4064
+ if (state.continuation_completed_snapshot === undefined) {
4065
+ state.continuation_completed_snapshot = progress.completed;
4066
+ state.stale_continuation_count = 0;
4067
+ writeWorkState(directory, state);
4068
+ } else if (progress.completed > state.continuation_completed_snapshot) {
4069
+ state.continuation_completed_snapshot = progress.completed;
4070
+ state.stale_continuation_count = 0;
4071
+ writeWorkState(directory, state);
4072
+ } else {
4073
+ state.stale_continuation_count = (state.stale_continuation_count ?? 0) + 1;
4074
+ if (state.stale_continuation_count >= MAX_STALE_CONTINUATIONS) {
4075
+ state.paused = true;
4076
+ writeWorkState(directory, state);
4077
+ return { continuationPrompt: null };
4078
+ }
4079
+ writeWorkState(directory, state);
4080
+ }
4081
+ const remaining = progress.total - progress.completed;
4082
+ return {
4083
+ continuationPrompt: `${CONTINUATION_MARKER}
4084
+ You have an active work plan with incomplete tasks. Continue working.
4085
+
4086
+ **Plan**: ${state.plan_name}
4087
+ **File**: ${state.active_plan}
4088
+ **Progress**: ${progress.completed}/${progress.total} tasks completed (${remaining} remaining)
4089
+
4090
+ 1. Read the plan file NOW to check exact current progress
4091
+ 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.
4092
+ 3. Find the first unchecked \`- [ ]\` task
4093
+ 4. Execute it, verify it, mark \`- [ ]\` → \`- [x]\`
4094
+ 5. Update sidebar todos as you complete tasks
4095
+ 6. Do not stop until all tasks are complete`
4096
+ };
4097
+ }
4098
+
4099
+ // src/hooks/verification-reminder.ts
4100
+ function buildVerificationReminder(input) {
4101
+ const planContext = input.planName && input.progress ? `
4102
+ **Plan**: ${input.planName} (${input.progress.completed}/${input.progress.total} tasks done)` : "";
4103
+ return {
4104
+ verificationPrompt: `## Verification Required
4105
+ ${planContext}
4106
+
4107
+ Before marking this task complete, verify the work:
4108
+
4109
+ 1. **Read the changes**: \`git diff --stat\` then Read each changed file
4110
+ 2. **Run checks**: Run relevant tests, check for linting/type errors
4111
+ 3. **Validate behavior**: Does the code actually do what was requested?
4112
+ 4. **Gate decision**: Can you explain what every changed line does?
4113
+
4114
+ If uncertain about quality, delegate to \`weft\` agent for a formal review:
4115
+ \`call_weave_agent(agent="weft", prompt="Review the changes for [task description]")\`
4116
+
4117
+ 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:
4118
+ \`call_weave_agent(agent="warp", prompt="Security audit the changes for [task description]")\`
4119
+
4120
+ Only mark complete when ALL checks pass.`
4121
+ };
4122
+ }
4123
+
4124
+ // src/hooks/create-hooks.ts
4125
+ function createHooks(args) {
4126
+ const { pluginConfig, isHookEnabled, directory, analyticsEnabled = false } = args;
4127
+ const writeGuardState = createWriteGuardState();
4128
+ const writeGuard = createWriteGuard(writeGuardState);
4129
+ const contextWindowThresholds = {
4130
+ warningPct: pluginConfig.experimental?.context_window_warning_threshold ?? 0.8,
4131
+ criticalPct: pluginConfig.experimental?.context_window_critical_threshold ?? 0.95
4132
+ };
4133
+ return {
4134
+ checkContextWindow: isHookEnabled("context-window-monitor") ? (state) => checkContextWindow(state, contextWindowThresholds) : null,
4135
+ writeGuard: isHookEnabled("write-existing-file-guard") ? writeGuard : null,
4136
+ shouldInjectRules: isHookEnabled("rules-injector") ? shouldInjectRules : null,
4137
+ getRulesForFile: isHookEnabled("rules-injector") ? getRulesForFile : null,
4138
+ firstMessageVariant: isHookEnabled("first-message-variant") ? { shouldApplyVariant, markApplied, markSessionCreated, clearSession } : null,
4139
+ processMessageForKeywords: isHookEnabled("keyword-detector") ? processMessageForKeywords : null,
4140
+ patternMdOnly: isHookEnabled("pattern-md-only") ? checkPatternWrite : null,
4141
+ startWork: isHookEnabled("start-work") ? (promptText, sessionId) => handleStartWork({ promptText, sessionId, directory }) : null,
4142
+ workContinuation: isHookEnabled("work-continuation") ? (sessionId) => checkContinuation({ sessionId, directory }) : null,
4143
+ workflowStart: isHookEnabled("workflow") ? (promptText, sessionId) => handleRunWorkflow({ promptText, sessionId, directory }) : null,
4144
+ workflowContinuation: isHookEnabled("workflow") ? (sessionId, lastAssistantMessage, lastUserMessage) => checkWorkflowContinuation({ sessionId, directory, lastAssistantMessage, lastUserMessage }) : null,
4145
+ workflowCommand: isHookEnabled("workflow") ? (message) => handleWorkflowCommand(message, directory) : null,
4146
+ verificationReminder: isHookEnabled("verification-reminder") ? buildVerificationReminder : null,
4147
+ analyticsEnabled
4148
+ };
4149
+ }
4150
+ // src/hooks/session-token-state.ts
4151
+ var sessionMap = new Map;
4152
+ function setContextLimit(sessionId, maxTokens) {
4153
+ const existing = sessionMap.get(sessionId);
4154
+ sessionMap.set(sessionId, {
4155
+ usedTokens: existing?.usedTokens ?? 0,
4156
+ maxTokens
4157
+ });
4158
+ }
4159
+ function updateUsage(sessionId, inputTokens) {
4160
+ if (inputTokens <= 0)
4161
+ return;
4162
+ const existing = sessionMap.get(sessionId);
4163
+ sessionMap.set(sessionId, {
4164
+ maxTokens: existing?.maxTokens ?? 0,
4165
+ usedTokens: inputTokens
4166
+ });
4167
+ }
4168
+ function getState(sessionId) {
4169
+ return sessionMap.get(sessionId);
4170
+ }
4171
+ function clearSession2(sessionId) {
4172
+ sessionMap.delete(sessionId);
4173
+ }
4174
+ // src/features/analytics/storage.ts
4175
+ import { existsSync as existsSync12, mkdirSync as mkdirSync4, appendFileSync as appendFileSync2, readFileSync as readFileSync9, writeFileSync as writeFileSync3, statSync as statSync2 } from "fs";
4176
+ import { join as join10 } from "path";
4177
+
4178
+ // src/features/analytics/types.ts
4179
+ var ANALYTICS_DIR = ".weave/analytics";
4180
+ var SESSION_SUMMARIES_FILE = "session-summaries.jsonl";
4181
+ var FINGERPRINT_FILE = "fingerprint.json";
4182
+ var METRICS_REPORTS_FILE = "metrics-reports.jsonl";
4183
+ var MAX_METRICS_ENTRIES = 100;
4184
+ function zeroTokenUsage() {
4185
+ return { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
4186
+ }
4187
+
4188
+ // src/features/analytics/storage.ts
4189
+ var MAX_SESSION_ENTRIES = 1000;
4190
+ function ensureAnalyticsDir(directory) {
4191
+ const dir = join10(directory, ANALYTICS_DIR);
4192
+ mkdirSync4(dir, { recursive: true, mode: 448 });
4193
+ return dir;
4194
+ }
4195
+ function appendSessionSummary(directory, summary) {
4196
+ try {
4197
+ const dir = ensureAnalyticsDir(directory);
4198
+ const filePath = join10(dir, SESSION_SUMMARIES_FILE);
4199
+ const line = JSON.stringify(summary) + `
4200
+ `;
4201
+ appendFileSync2(filePath, line, { encoding: "utf-8", mode: 384 });
4202
+ try {
4203
+ const TYPICAL_ENTRY_BYTES = 200;
4204
+ const rotationSizeThreshold = MAX_SESSION_ENTRIES * TYPICAL_ENTRY_BYTES * 0.9;
4205
+ const { size } = statSync2(filePath);
4206
+ if (size > rotationSizeThreshold) {
4207
+ const content = readFileSync9(filePath, "utf-8");
4208
+ const lines = content.split(`
4209
+ `).filter((l) => l.trim().length > 0);
4210
+ if (lines.length > MAX_SESSION_ENTRIES) {
4211
+ const trimmed = lines.slice(-MAX_SESSION_ENTRIES).join(`
4212
+ `) + `
4213
+ `;
4214
+ writeFileSync3(filePath, trimmed, { encoding: "utf-8", mode: 384 });
4215
+ }
4216
+ }
4217
+ } catch {}
4218
+ return true;
4219
+ } catch {
4220
+ return false;
4221
+ }
4222
+ }
4223
+ function readSessionSummaries(directory) {
4224
+ const filePath = join10(directory, ANALYTICS_DIR, SESSION_SUMMARIES_FILE);
4225
+ try {
4226
+ if (!existsSync12(filePath))
4227
+ return [];
4228
+ const content = readFileSync9(filePath, "utf-8");
4229
+ const lines = content.split(`
4230
+ `).filter((line) => line.trim().length > 0);
4231
+ const summaries = [];
4232
+ for (const line of lines) {
4233
+ try {
4234
+ summaries.push(JSON.parse(line));
4235
+ } catch {}
4236
+ }
4237
+ return summaries;
4238
+ } catch {
4239
+ return [];
4240
+ }
4241
+ }
4242
+ function writeFingerprint(directory, fingerprint) {
4243
+ try {
4244
+ const dir = ensureAnalyticsDir(directory);
4245
+ const filePath = join10(dir, FINGERPRINT_FILE);
4246
+ writeFileSync3(filePath, JSON.stringify(fingerprint, null, 2), { encoding: "utf-8", mode: 384 });
4247
+ return true;
4248
+ } catch {
4249
+ return false;
4250
+ }
4251
+ }
4252
+ function readFingerprint(directory) {
4253
+ const filePath = join10(directory, ANALYTICS_DIR, FINGERPRINT_FILE);
4254
+ try {
4255
+ if (!existsSync12(filePath))
4256
+ return null;
4257
+ const content = readFileSync9(filePath, "utf-8");
4258
+ const parsed = JSON.parse(content);
4259
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.stack))
4260
+ return null;
4261
+ return parsed;
4262
+ } catch {
4263
+ return null;
4264
+ }
4265
+ }
4266
+ function writeMetricsReport(directory, report) {
4267
+ try {
4268
+ const dir = ensureAnalyticsDir(directory);
4269
+ const filePath = join10(dir, METRICS_REPORTS_FILE);
4270
+ const line = JSON.stringify(report) + `
4271
+ `;
4272
+ appendFileSync2(filePath, line, { encoding: "utf-8", mode: 384 });
4273
+ try {
4274
+ const TYPICAL_ENTRY_BYTES = 200;
4275
+ const rotationSizeThreshold = MAX_METRICS_ENTRIES * TYPICAL_ENTRY_BYTES * 0.9;
4276
+ const { size } = statSync2(filePath);
4277
+ if (size > rotationSizeThreshold) {
4278
+ const content = readFileSync9(filePath, "utf-8");
4279
+ const lines = content.split(`
4280
+ `).filter((l) => l.trim().length > 0);
4281
+ if (lines.length > MAX_METRICS_ENTRIES) {
4282
+ const trimmed = lines.slice(-MAX_METRICS_ENTRIES).join(`
4283
+ `) + `
4284
+ `;
4285
+ writeFileSync3(filePath, trimmed, { encoding: "utf-8", mode: 384 });
4286
+ }
4287
+ }
4288
+ } catch {}
4289
+ return true;
4290
+ } catch {
4291
+ return false;
4292
+ }
4293
+ }
4294
+ function readMetricsReports(directory) {
4295
+ const filePath = join10(directory, ANALYTICS_DIR, METRICS_REPORTS_FILE);
4296
+ try {
4297
+ if (!existsSync12(filePath))
4298
+ return [];
4299
+ const content = readFileSync9(filePath, "utf-8");
4300
+ const lines = content.split(`
4301
+ `).filter((line) => line.trim().length > 0);
4302
+ const reports = [];
4303
+ for (const line of lines) {
4304
+ try {
4305
+ reports.push(JSON.parse(line));
4306
+ } catch {}
4307
+ }
4308
+ return reports;
4309
+ } catch {
4310
+ return [];
4311
+ }
4312
+ }
4313
+
4314
+ // src/features/analytics/token-report.ts
4315
+ function generateTokenReport(summaries) {
4316
+ if (summaries.length === 0) {
4317
+ return "No session data available.";
4318
+ }
4319
+ const sections = [];
4320
+ const totalSessions = summaries.length;
4321
+ const totalMessages = summaries.reduce((sum, s) => sum + (s.tokenUsage?.totalMessages ?? 0), 0);
4322
+ const totalInput = summaries.reduce((sum, s) => sum + (s.tokenUsage?.inputTokens ?? 0), 0);
4323
+ const totalOutput = summaries.reduce((sum, s) => sum + (s.tokenUsage?.outputTokens ?? 0), 0);
4324
+ const totalReasoning = summaries.reduce((sum, s) => sum + (s.tokenUsage?.reasoningTokens ?? 0), 0);
4325
+ const totalCacheRead = summaries.reduce((sum, s) => sum + (s.tokenUsage?.cacheReadTokens ?? 0), 0);
4326
+ const totalCacheWrite = summaries.reduce((sum, s) => sum + (s.tokenUsage?.cacheWriteTokens ?? 0), 0);
4327
+ const totalCost = summaries.reduce((sum, s) => sum + (s.totalCost ?? 0), 0);
4328
+ sections.push(`## Overall Totals
4329
+ ` + `- Sessions: ${fmt(totalSessions)}
4330
+ ` + `- Messages: ${fmt(totalMessages)}
4331
+ ` + `- Input tokens: ${fmt(totalInput)}
4332
+ ` + `- Output tokens: ${fmt(totalOutput)}
4333
+ ` + `- Reasoning tokens: ${fmt(totalReasoning)}
4334
+ ` + `- Cache read tokens: ${fmt(totalCacheRead)}
4335
+ ` + `- Cache write tokens: ${fmt(totalCacheWrite)}
4336
+ ` + `- Total cost: ${fmtCost(totalCost)}`);
4337
+ const agentGroups = new Map;
4338
+ for (const s of summaries) {
4339
+ const key = s.agentName ?? "(unknown)";
4340
+ const group = agentGroups.get(key);
4341
+ if (group) {
4342
+ group.push(s);
4343
+ } else {
4344
+ agentGroups.set(key, [s]);
4345
+ }
4346
+ }
4347
+ const agentStats = Array.from(agentGroups.entries()).map(([agent, sessions]) => {
4348
+ const agentCost = sessions.reduce((sum, s) => sum + (s.totalCost ?? 0), 0);
4349
+ const agentTokens = sessions.reduce((sum, s) => sum + (s.tokenUsage?.inputTokens ?? 0) + (s.tokenUsage?.outputTokens ?? 0) + (s.tokenUsage?.reasoningTokens ?? 0), 0);
4350
+ const avgTokens = sessions.length > 0 ? Math.round(agentTokens / sessions.length) : 0;
4351
+ const avgCost = sessions.length > 0 ? agentCost / sessions.length : 0;
4352
+ return { agent, sessions: sessions.length, avgTokens, avgCost, totalCost: agentCost };
4353
+ }).sort((a, b) => b.totalCost - a.totalCost);
4354
+ 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)}`);
4355
+ sections.push(`## Per-Agent Breakdown
4356
+ ${agentLines.join(`
4357
+ `)}`);
4358
+ const top5 = [...summaries].sort((a, b) => (b.totalCost ?? 0) - (a.totalCost ?? 0)).slice(0, 5);
4359
+ const top5Lines = top5.map((s) => {
4360
+ const id = s.sessionId.length > 8 ? s.sessionId.slice(0, 8) : s.sessionId;
4361
+ const agent = s.agentName ?? "(unknown)";
4362
+ const cost = fmtCost(s.totalCost ?? 0);
4363
+ const tokens = (s.tokenUsage?.inputTokens ?? 0) + (s.tokenUsage?.outputTokens ?? 0) + (s.tokenUsage?.reasoningTokens ?? 0);
4364
+ const duration = fmtDuration(s.durationMs);
4365
+ return `- \`${id}\` ${agent} — ${cost}, ${fmt(tokens)} tokens, ${duration}`;
4366
+ });
4367
+ sections.push(`## Top 5 Costliest Sessions
4368
+ ${top5Lines.join(`
4369
+ `)}`);
4370
+ return sections.join(`
4371
+
4372
+ `);
4373
+ }
4374
+ function fmt(n) {
4375
+ return n.toLocaleString("en-US");
4376
+ }
4377
+ function fmtCost(n) {
4378
+ return `$${n.toFixed(2)}`;
4379
+ }
4380
+ function fmtDuration(ms) {
4381
+ const totalSeconds = Math.round(ms / 1000);
4382
+ const minutes = Math.floor(totalSeconds / 60);
4383
+ const seconds = totalSeconds % 60;
4384
+ if (minutes === 0)
4385
+ return `${seconds}s`;
4386
+ return `${minutes}m ${seconds}s`;
4387
+ }
4388
+
4389
+ // src/features/analytics/format-metrics.ts
4390
+ function formatNumber(n) {
4391
+ return n.toLocaleString("en-US");
4392
+ }
4393
+ function formatDuration(ms) {
4394
+ const totalSeconds = Math.round(ms / 1000);
4395
+ if (totalSeconds < 60)
4396
+ return `${totalSeconds}s`;
4397
+ const minutes = Math.floor(totalSeconds / 60);
4398
+ const seconds = totalSeconds % 60;
4399
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
4400
+ }
4401
+ function formatDate(iso) {
4402
+ try {
4403
+ const d = new Date(iso);
4404
+ return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
4405
+ } catch {
4406
+ return iso;
4407
+ }
4408
+ }
4409
+ function formatReport(report) {
4410
+ const lines = [];
4411
+ const date = formatDate(report.generatedAt);
4412
+ lines.push(`#### \uD83D\uDCCA ${report.planName} (${date})`);
4413
+ lines.push("");
4414
+ lines.push("| Metric | Value |");
4415
+ lines.push("|--------|-------|");
4416
+ lines.push(`| Coverage | ${Math.round(report.adherence.coverage * 100)}% |`);
4417
+ lines.push(`| Precision | ${Math.round(report.adherence.precision * 100)}% |`);
4418
+ lines.push(`| Sessions | ${report.sessionCount} |`);
4419
+ lines.push(`| Duration | ${formatDuration(report.durationMs)} |`);
4420
+ lines.push(`| Input Tokens | ${formatNumber(report.tokenUsage.input)} |`);
4421
+ lines.push(`| Output Tokens | ${formatNumber(report.tokenUsage.output)} |`);
4422
+ if (report.tokenUsage.reasoning > 0) {
4423
+ lines.push(`| Reasoning Tokens | ${formatNumber(report.tokenUsage.reasoning)} |`);
4424
+ }
4425
+ if (report.tokenUsage.cacheRead > 0 || report.tokenUsage.cacheWrite > 0) {
4426
+ lines.push(`| Cache Read | ${formatNumber(report.tokenUsage.cacheRead)} |`);
4427
+ lines.push(`| Cache Write | ${formatNumber(report.tokenUsage.cacheWrite)} |`);
4428
+ }
4429
+ if (report.adherence.unplannedChanges.length > 0) {
4430
+ lines.push("");
4431
+ lines.push(`**Unplanned Changes**: ${report.adherence.unplannedChanges.map((f) => `\`${f}\``).join(", ")}`);
4432
+ }
4433
+ if (report.adherence.missedFiles.length > 0) {
4434
+ lines.push("");
4435
+ lines.push(`**Missed Files**: ${report.adherence.missedFiles.map((f) => `\`${f}\``).join(", ")}`);
4436
+ }
4437
+ return lines.join(`
4438
+ `);
4439
+ }
4440
+ function aggregateSessionTokens(summaries) {
4441
+ const total = { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 };
4442
+ for (const s of summaries) {
4443
+ if (s.tokenUsage) {
4444
+ total.input += s.tokenUsage.inputTokens;
4445
+ total.output += s.tokenUsage.outputTokens;
4446
+ total.reasoning += s.tokenUsage.reasoningTokens;
4447
+ total.cacheRead += s.tokenUsage.cacheReadTokens;
4448
+ total.cacheWrite += s.tokenUsage.cacheWriteTokens;
4449
+ }
4450
+ }
4451
+ return total;
4452
+ }
4453
+ function topTools(summaries, limit = 5) {
4454
+ const counts = {};
4455
+ for (const s of summaries) {
4456
+ for (const t of s.toolUsage) {
4457
+ counts[t.tool] = (counts[t.tool] ?? 0) + t.count;
4458
+ }
4459
+ }
4460
+ return Object.entries(counts).map(([tool, count]) => ({ tool, count })).sort((a, b) => b.count - a.count).slice(0, limit);
4461
+ }
4462
+ function formatMetricsMarkdown(reports, summaries, args) {
4463
+ if (reports.length === 0 && summaries.length === 0) {
4464
+ return [
4465
+ "## Weave Metrics Dashboard",
4466
+ "",
4467
+ "No metrics data yet.",
4468
+ "",
4469
+ "To generate metrics:",
4470
+ '1. Enable analytics in `weave.json`: `{ "analytics": { "enabled": true } }`',
4471
+ "2. Create and complete a plan using Pattern and `/start-work`",
4472
+ "3. Metrics are generated automatically when a plan completes"
4473
+ ].join(`
4474
+ `);
4475
+ }
4476
+ const lines = ["## Weave Metrics Dashboard"];
4477
+ let filteredReports = reports;
4478
+ const trimmedArgs = args?.trim() ?? "";
4479
+ if (trimmedArgs && trimmedArgs !== "all") {
4480
+ filteredReports = reports.filter((r) => r.planName.toLowerCase().includes(trimmedArgs.toLowerCase()));
4481
+ }
4482
+ if (!trimmedArgs || trimmedArgs === "") {
4483
+ filteredReports = filteredReports.slice(-5);
4484
+ }
4485
+ if (filteredReports.length > 0) {
4486
+ lines.push("");
4487
+ lines.push("### Recent Plan Metrics");
4488
+ for (let i = 0;i < filteredReports.length; i++) {
4489
+ lines.push("");
4490
+ lines.push(formatReport(filteredReports[i]));
4491
+ if (i < filteredReports.length - 1) {
4492
+ lines.push("");
4493
+ lines.push("---");
4494
+ }
4495
+ }
4496
+ } else if (reports.length > 0 && trimmedArgs) {
4497
+ lines.push("");
4498
+ lines.push(`### Recent Plan Metrics`);
4499
+ lines.push("");
4500
+ lines.push(`No reports found matching "${trimmedArgs}".`);
4501
+ }
4502
+ if (summaries.length > 0) {
4503
+ lines.push("");
4504
+ lines.push("---");
4505
+ lines.push("");
4506
+ lines.push("### Aggregate Session Stats");
4507
+ lines.push("");
4508
+ const totalTokens = aggregateSessionTokens(summaries);
4509
+ const avgDuration = summaries.reduce((sum, s) => sum + s.durationMs, 0) / summaries.length;
4510
+ const tools = topTools(summaries);
4511
+ lines.push(`- **Sessions tracked**: ${summaries.length}`);
4512
+ lines.push(`- **Total input tokens**: ${formatNumber(totalTokens.input)}`);
4513
+ lines.push(`- **Total output tokens**: ${formatNumber(totalTokens.output)}`);
4514
+ lines.push(`- **Average session duration**: ${formatDuration(avgDuration)}`);
4515
+ if (tools.length > 0) {
4516
+ const toolStr = tools.map((t) => `${t.tool} (${formatNumber(t.count)})`).join(", ");
4517
+ lines.push(`- **Top tools**: ${toolStr}`);
4518
+ }
4519
+ }
4520
+ return lines.join(`
4521
+ `);
4522
+ }
4523
+
4524
+ // src/features/analytics/plan-parser.ts
4525
+ import { readFileSync as readFileSync10 } from "fs";
4526
+ function extractSection2(content, heading) {
4527
+ const lines = content.split(`
4528
+ `);
4529
+ let startIdx = -1;
4530
+ for (let i = 0;i < lines.length; i++) {
4531
+ if (lines[i].trim() === heading) {
4532
+ startIdx = i + 1;
4533
+ break;
4534
+ }
4535
+ }
4536
+ if (startIdx === -1)
4537
+ return null;
4538
+ const sectionLines = [];
4539
+ for (let i = startIdx;i < lines.length; i++) {
4540
+ if (/^## /.test(lines[i]))
4541
+ break;
4542
+ sectionLines.push(lines[i]);
4543
+ }
4544
+ return sectionLines.join(`
4545
+ `);
4546
+ }
4547
+ function extractFilePath2(raw) {
4548
+ let cleaned = raw.replace(/^\s*(create|modify|new:|add)\s+/i, "").replace(/\(new\)/gi, "").trim();
4549
+ const firstToken = cleaned.split(/\s+/)[0];
4550
+ if (firstToken && (firstToken.includes("/") || firstToken.endsWith(".ts") || firstToken.endsWith(".js") || firstToken.endsWith(".json") || firstToken.endsWith(".md"))) {
4551
+ cleaned = firstToken;
4552
+ } else if (!cleaned.includes("/")) {
4553
+ return null;
4554
+ }
4555
+ return cleaned || null;
4556
+ }
4557
+ function extractPlannedFiles(planPath) {
4558
+ let content;
4559
+ try {
4560
+ content = readFileSync10(planPath, "utf-8");
4561
+ } catch {
4562
+ return [];
4563
+ }
4564
+ const todosSection = extractSection2(content, "## TODOs");
4565
+ if (todosSection === null)
4566
+ return [];
4567
+ const files = new Set;
4568
+ const lines = todosSection.split(`
4569
+ `);
4570
+ for (const line of lines) {
4571
+ const filesMatch = /^\s*\*\*Files\*\*:?\s*(.+)$/.exec(line);
4572
+ if (!filesMatch)
4573
+ continue;
4574
+ const rawValue = filesMatch[1].trim();
4575
+ const parts = rawValue.split(",");
4576
+ for (const part of parts) {
4577
+ const trimmed = part.trim();
4578
+ if (!trimmed)
4579
+ continue;
4580
+ const filePath = extractFilePath2(trimmed);
4581
+ if (filePath) {
4582
+ files.add(filePath);
4583
+ }
3041
4584
  }
3042
- writeWorkState(directory, state);
3043
4585
  }
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
- };
4586
+ return Array.from(files);
3060
4587
  }
3061
4588
 
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
- };
4589
+ // src/features/analytics/git-diff.ts
4590
+ import { execFileSync } from "child_process";
4591
+ var SHA_PATTERN = /^[0-9a-f]{4,40}$/i;
4592
+ function getChangedFiles(directory, fromSha) {
4593
+ if (!SHA_PATTERN.test(fromSha)) {
4594
+ return [];
4595
+ }
4596
+ try {
4597
+ const output = execFileSync("git", ["diff", "--name-only", `${fromSha}..HEAD`], {
4598
+ cwd: directory,
4599
+ encoding: "utf-8",
4600
+ timeout: 1e4,
4601
+ stdio: ["pipe", "pipe", "pipe"]
4602
+ });
4603
+ return output.split(`
4604
+ `).map((line) => line.trim()).filter((line) => line.length > 0);
4605
+ } catch {
4606
+ return [];
4607
+ }
3085
4608
  }
3086
4609
 
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
- };
4610
+ // src/features/analytics/adherence.ts
4611
+ function normalizePath(p) {
4612
+ return p.trim().toLowerCase().replace(/^\.\//, "");
4613
+ }
4614
+ function calculateAdherence(plannedFiles, actualFiles) {
4615
+ const plannedNorm = new Set(plannedFiles.map(normalizePath));
4616
+ const actualNorm = new Set(actualFiles.map(normalizePath));
4617
+ const plannedFilesChanged = [];
4618
+ const missedFiles = [];
4619
+ const unplannedChanges = [];
4620
+ for (const planned of plannedFiles) {
4621
+ if (actualNorm.has(normalizePath(planned))) {
4622
+ plannedFilesChanged.push(planned);
4623
+ } else {
4624
+ missedFiles.push(planned);
4625
+ }
4626
+ }
4627
+ for (const actual of actualFiles) {
4628
+ if (!plannedNorm.has(normalizePath(actual))) {
4629
+ unplannedChanges.push(actual);
4630
+ }
4631
+ }
4632
+ const totalPlannedFiles = plannedFiles.length;
4633
+ const totalActualFiles = actualFiles.length;
4634
+ const coverage = totalPlannedFiles === 0 ? 1 : plannedFilesChanged.length / totalPlannedFiles;
4635
+ const precision = totalActualFiles === 0 ? 1 : plannedFilesChanged.length / totalActualFiles;
3096
4636
  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
4637
+ coverage,
4638
+ precision,
4639
+ plannedFilesChanged,
4640
+ unplannedChanges,
4641
+ missedFiles,
4642
+ totalPlannedFiles,
4643
+ totalActualFiles
3108
4644
  };
3109
4645
  }
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);
4646
+
4647
+ // src/features/analytics/plan-token-aggregator.ts
4648
+ function aggregateTokensForPlan(directory, sessionIds) {
4649
+ const summaries = readSessionSummaries(directory);
4650
+ const sessionIdSet = new Set(sessionIds);
4651
+ const total = zeroTokenUsage();
4652
+ for (const summary of summaries) {
4653
+ if (!sessionIdSet.has(summary.sessionId))
4654
+ continue;
4655
+ if (summary.tokenUsage) {
4656
+ total.input += summary.tokenUsage.inputTokens;
4657
+ total.output += summary.tokenUsage.outputTokens;
4658
+ total.reasoning += summary.tokenUsage.reasoningTokens;
4659
+ total.cacheRead += summary.tokenUsage.cacheReadTokens;
4660
+ total.cacheWrite += summary.tokenUsage.cacheWriteTokens;
4661
+ }
4662
+ }
4663
+ return total;
3130
4664
  }
3131
- function clearSession2(sessionId) {
3132
- sessionMap.delete(sessionId);
4665
+
4666
+ // src/features/analytics/generate-metrics-report.ts
4667
+ function generateMetricsReport(directory, state) {
4668
+ try {
4669
+ const plannedFiles = extractPlannedFiles(state.active_plan);
4670
+ const actualFiles = state.start_sha ? getChangedFiles(directory, state.start_sha) : [];
4671
+ const adherence = calculateAdherence(plannedFiles, actualFiles);
4672
+ const tokenUsage = aggregateTokensForPlan(directory, state.session_ids);
4673
+ const summaries = readSessionSummaries(directory);
4674
+ const matchingSummaries = summaries.filter((s) => state.session_ids.includes(s.sessionId));
4675
+ const durationMs = matchingSummaries.reduce((sum, s) => sum + s.durationMs, 0);
4676
+ const report = {
4677
+ planName: getPlanName(state.active_plan),
4678
+ generatedAt: new Date().toISOString(),
4679
+ adherence,
4680
+ quality: undefined,
4681
+ gaps: undefined,
4682
+ tokenUsage,
4683
+ durationMs,
4684
+ sessionCount: state.session_ids.length,
4685
+ startSha: state.start_sha,
4686
+ sessionIds: [...state.session_ids]
4687
+ };
4688
+ const written = writeMetricsReport(directory, report);
4689
+ if (!written) {
4690
+ log("[analytics] Failed to write metrics report (non-fatal)");
4691
+ return null;
4692
+ }
4693
+ log("[analytics] Metrics report generated", {
4694
+ plan: report.planName,
4695
+ coverage: adherence.coverage,
4696
+ precision: adherence.precision
4697
+ });
4698
+ return report;
4699
+ } catch (err) {
4700
+ log("[analytics] Failed to generate metrics report (non-fatal)", {
4701
+ error: String(err)
4702
+ });
4703
+ return null;
4704
+ }
3133
4705
  }
4706
+
3134
4707
  // src/plugin/plugin-interface.ts
3135
4708
  function createPluginInterface(args) {
3136
4709
  const { pluginConfig, hooks, tools, configHandler, agents, client, directory = "", tracker } = args;
4710
+ const lastAssistantMessageText = new Map;
4711
+ const lastUserMessageText = new Map;
3137
4712
  return {
3138
4713
  tool: tools,
3139
4714
  config: async (config) => {
@@ -3187,13 +4762,74 @@ ${result.contextInjection}`;
3187
4762
  }
3188
4763
  }
3189
4764
  }
4765
+ if (hooks.workflowStart) {
4766
+ const parts = _output.parts;
4767
+ const message = _output.message;
4768
+ const promptText = parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
4769
+ `).trim() ?? "";
4770
+ if (promptText.includes("workflow engine will inject context")) {
4771
+ const result = hooks.workflowStart(promptText, sessionID);
4772
+ if (result.switchAgent && message) {
4773
+ message.agent = getAgentDisplayName(result.switchAgent);
4774
+ }
4775
+ if (result.contextInjection && parts) {
4776
+ const idx = parts.findIndex((p) => p.type === "text" && p.text);
4777
+ if (idx >= 0 && parts[idx].text) {
4778
+ parts[idx].text += `
4779
+
4780
+ ---
4781
+ ${result.contextInjection}`;
4782
+ } else {
4783
+ parts.push({ type: "text", text: result.contextInjection });
4784
+ }
4785
+ }
4786
+ }
4787
+ }
4788
+ {
4789
+ const parts = _output.parts;
4790
+ const userText = parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
4791
+ `).trim() ?? "";
4792
+ if (userText && sessionID) {
4793
+ lastUserMessageText.set(sessionID, userText);
4794
+ }
4795
+ }
4796
+ if (hooks.workflowCommand) {
4797
+ const parts = _output.parts;
4798
+ const message = _output.message;
4799
+ const userText = parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
4800
+ `).trim() ?? "";
4801
+ if (userText) {
4802
+ const cmdResult = hooks.workflowCommand(userText);
4803
+ if (cmdResult.handled) {
4804
+ if (cmdResult.contextInjection && parts) {
4805
+ const idx = parts.findIndex((p) => p.type === "text" && p.text);
4806
+ if (idx >= 0 && parts[idx].text) {
4807
+ parts[idx].text += `
4808
+
4809
+ ---
4810
+ ${cmdResult.contextInjection}`;
4811
+ } else {
4812
+ parts.push({ type: "text", text: cmdResult.contextInjection });
4813
+ }
4814
+ }
4815
+ if (cmdResult.switchAgent && message) {
4816
+ message.agent = getAgentDisplayName(cmdResult.switchAgent);
4817
+ }
4818
+ }
4819
+ }
4820
+ }
3190
4821
  if (directory) {
3191
4822
  const parts = _output.parts;
3192
4823
  const promptText = parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
3193
4824
  `).trim() ?? "";
3194
4825
  const isStartWork = promptText.includes("<session-context>");
3195
4826
  const isContinuation = promptText.includes(CONTINUATION_MARKER);
3196
- if (!isStartWork && !isContinuation) {
4827
+ const isWorkflowContinuation = promptText.includes(WORKFLOW_CONTINUATION_MARKER);
4828
+ const isActiveWorkflow = (() => {
4829
+ const wf = getActiveWorkflowInstance(directory);
4830
+ return wf != null && wf.status === "running";
4831
+ })();
4832
+ if (!isStartWork && !isContinuation && !isWorkflowContinuation && !isActiveWorkflow) {
3197
4833
  const state = readWorkState(directory);
3198
4834
  if (state && !state.paused) {
3199
4835
  pauseWork(directory);
@@ -3210,6 +4846,9 @@ ${result.contextInjection}`;
3210
4846
  setContextLimit(sessionId, maxTokens);
3211
4847
  log("[context-window] Captured context limit", { sessionId, maxTokens });
3212
4848
  }
4849
+ if (tracker && hooks.analyticsEnabled && sessionId && input.agent) {
4850
+ tracker.setAgentName(sessionId, input.agent);
4851
+ }
3213
4852
  },
3214
4853
  "chat.headers": async (_input, _output) => {},
3215
4854
  event: async (input) => {
@@ -3233,38 +4872,115 @@ ${result.contextInjection}`;
3233
4872
  } catch (err) {
3234
4873
  log("[analytics] Failed to end session (non-fatal)", { error: String(err) });
3235
4874
  }
4875
+ if (directory) {
4876
+ try {
4877
+ const state = readWorkState(directory);
4878
+ if (state) {
4879
+ const progress = getPlanProgress(state.active_plan);
4880
+ if (progress.isComplete) {
4881
+ generateMetricsReport(directory, state);
4882
+ }
4883
+ }
4884
+ } catch (err) {
4885
+ log("[analytics] Failed to generate metrics report on session end (non-fatal)", { error: String(err) });
4886
+ }
4887
+ }
3236
4888
  }
3237
4889
  }
3238
- if (event.type === "message.updated" && hooks.checkContextWindow) {
4890
+ if (event.type === "message.updated") {
3239
4891
  const evt = event;
3240
4892
  const info = evt.properties?.info;
3241
4893
  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
4894
+ if (hooks.checkContextWindow) {
4895
+ const inputTokens = info.tokens?.input ?? 0;
4896
+ if (inputTokens > 0) {
4897
+ updateUsage(info.sessionID, inputTokens);
4898
+ const tokenState = getState(info.sessionID);
4899
+ if (tokenState && tokenState.maxTokens > 0) {
4900
+ const result = hooks.checkContextWindow({
4901
+ usedTokens: tokenState.usedTokens,
4902
+ maxTokens: tokenState.maxTokens,
4903
+ sessionId: info.sessionID
3257
4904
  });
4905
+ if (result.action !== "none") {
4906
+ log("[context-window] Threshold crossed", {
4907
+ sessionId: info.sessionID,
4908
+ action: result.action,
4909
+ usagePct: result.usagePct
4910
+ });
4911
+ }
3258
4912
  }
3259
4913
  }
3260
4914
  }
3261
4915
  }
3262
4916
  }
4917
+ if (event.type === "message.updated" && tracker && hooks.analyticsEnabled) {
4918
+ const evt = event;
4919
+ const info = evt.properties?.info;
4920
+ if (info?.role === "assistant" && info.sessionID) {
4921
+ if (typeof info.cost === "number" && info.cost > 0) {
4922
+ tracker.trackCost(info.sessionID, info.cost);
4923
+ }
4924
+ if (info.tokens) {
4925
+ tracker.trackTokenUsage(info.sessionID, {
4926
+ input: info.tokens.input ?? 0,
4927
+ output: info.tokens.output ?? 0,
4928
+ reasoning: info.tokens.reasoning ?? 0,
4929
+ cacheRead: info.tokens.cache?.read ?? 0,
4930
+ cacheWrite: info.tokens.cache?.write ?? 0
4931
+ });
4932
+ }
4933
+ }
4934
+ }
3263
4935
  if (event.type === "tui.command.execute") {
3264
4936
  const evt = event;
3265
4937
  if (evt.properties?.command === "session.interrupt") {
3266
4938
  pauseWork(directory);
3267
4939
  log("[work-continuation] User interrupt detected — work paused");
4940
+ if (directory) {
4941
+ const activeWorkflow = getActiveWorkflowInstance(directory);
4942
+ if (activeWorkflow && activeWorkflow.status === "running") {
4943
+ pauseWorkflow(directory, "User interrupt");
4944
+ log("[workflow] User interrupt detected — workflow paused");
4945
+ }
4946
+ }
4947
+ }
4948
+ }
4949
+ if (event.type === "message.part.updated") {
4950
+ const evt = event;
4951
+ const part = evt.properties?.part;
4952
+ if (part?.type === "text" && part.sessionID && part.text) {
4953
+ lastAssistantMessageText.set(part.sessionID, part.text);
4954
+ }
4955
+ }
4956
+ if (hooks.workflowContinuation && event.type === "session.idle") {
4957
+ const evt = event;
4958
+ const sessionId = evt.properties?.sessionID ?? "";
4959
+ if (sessionId && directory) {
4960
+ const activeWorkflow = getActiveWorkflowInstance(directory);
4961
+ if (activeWorkflow && activeWorkflow.status === "running") {
4962
+ const lastMsg = lastAssistantMessageText.get(sessionId) ?? undefined;
4963
+ const lastUserMsg = lastUserMessageText.get(sessionId) ?? undefined;
4964
+ const result = hooks.workflowContinuation(sessionId, lastMsg, lastUserMsg);
4965
+ if (result.continuationPrompt && client) {
4966
+ try {
4967
+ await client.session.promptAsync({
4968
+ path: { id: sessionId },
4969
+ body: {
4970
+ parts: [{ type: "text", text: result.continuationPrompt }],
4971
+ ...result.switchAgent ? { agent: getAgentDisplayName(result.switchAgent) } : {}
4972
+ }
4973
+ });
4974
+ log("[workflow] Injected workflow continuation prompt", {
4975
+ sessionId,
4976
+ agent: result.switchAgent
4977
+ });
4978
+ } catch (err) {
4979
+ log("[workflow] Failed to inject workflow continuation", { sessionId, error: String(err) });
4980
+ }
4981
+ return;
4982
+ }
4983
+ }
3268
4984
  }
3269
4985
  }
3270
4986
  if (hooks.workContinuation && event.type === "session.idle") {
@@ -3342,74 +5058,62 @@ ${result.contextInjection}`;
3342
5058
  const agentArg = input.tool === "task" && inputArgs ? inputArgs.subagent_type ?? inputArgs.description ?? "unknown" : undefined;
3343
5059
  tracker.trackToolEnd(input.sessionID, input.tool, input.callID, agentArg);
3344
5060
  }
5061
+ },
5062
+ "command.execute.before": async (input, output) => {
5063
+ const { command, arguments: args2 } = input;
5064
+ const parts = output.parts;
5065
+ if (command === "token-report") {
5066
+ const summaries = readSessionSummaries(directory);
5067
+ const reportText = generateTokenReport(summaries);
5068
+ parts.push({ type: "text", text: reportText });
5069
+ }
5070
+ if (command === "metrics") {
5071
+ if (!hooks.analyticsEnabled) {
5072
+ parts.push({
5073
+ type: "text",
5074
+ text: 'Analytics is not enabled. To enable it, set `"analytics": { "enabled": true }` in your `weave.json`.'
5075
+ });
5076
+ return;
5077
+ }
5078
+ const reports = readMetricsReports(directory);
5079
+ const summaries = readSessionSummaries(directory);
5080
+ const metricsMarkdown = formatMetricsMarkdown(reports, summaries, args2);
5081
+ parts.push({ type: "text", text: metricsMarkdown });
5082
+ }
3345
5083
  }
3346
5084
  };
3347
5085
  }
5086
+ // src/features/analytics/fingerprint.ts
5087
+ import { existsSync as existsSync13, readFileSync as readFileSync12, readdirSync as readdirSync5 } from "fs";
5088
+ import { join as join12 } from "path";
5089
+ import { arch } from "os";
3348
5090
 
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);
5091
+ // src/shared/version.ts
5092
+ import { readFileSync as readFileSync11 } from "fs";
5093
+ import { fileURLToPath } from "url";
5094
+ import { dirname as dirname2, join as join11 } from "path";
5095
+ var cachedVersion;
5096
+ function getWeaveVersion() {
5097
+ if (cachedVersion !== undefined)
5098
+ return cachedVersion;
3397
5099
  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
- }
5100
+ const thisDir = dirname2(fileURLToPath(import.meta.url));
5101
+ for (const rel of ["../../package.json", "../package.json"]) {
5102
+ try {
5103
+ const pkg = JSON.parse(readFileSync11(join11(thisDir, rel), "utf-8"));
5104
+ if (pkg.name === "@opencode_weave/weave" && typeof pkg.version === "string") {
5105
+ const version = pkg.version;
5106
+ cachedVersion = version;
5107
+ return version;
5108
+ }
5109
+ } catch {}
5110
+ }
5111
+ } catch {}
5112
+ cachedVersion = "0.0.0";
5113
+ return cachedVersion;
3408
5114
  }
5115
+
3409
5116
  // src/features/analytics/fingerprint.ts
3410
- import { existsSync as existsSync9, readFileSync as readFileSync8, readdirSync as readdirSync3 } from "fs";
3411
- import { join as join8 } from "path";
3412
- import { arch } from "os";
3413
5117
  var STACK_MARKERS = [
3414
5118
  {
3415
5119
  name: "typescript",
@@ -3501,7 +5205,7 @@ function detectStack(directory) {
3501
5205
  const detected = [];
3502
5206
  for (const marker of STACK_MARKERS) {
3503
5207
  for (const file of marker.files) {
3504
- if (existsSync9(join8(directory, file))) {
5208
+ if (existsSync13(join12(directory, file))) {
3505
5209
  detected.push({
3506
5210
  name: marker.name,
3507
5211
  confidence: marker.confidence,
@@ -3512,9 +5216,9 @@ function detectStack(directory) {
3512
5216
  }
3513
5217
  }
3514
5218
  try {
3515
- const pkgPath = join8(directory, "package.json");
3516
- if (existsSync9(pkgPath)) {
3517
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf-8"));
5219
+ const pkgPath = join12(directory, "package.json");
5220
+ if (existsSync13(pkgPath)) {
5221
+ const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
3518
5222
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
3519
5223
  if (deps.react) {
3520
5224
  detected.push({
@@ -3527,7 +5231,7 @@ function detectStack(directory) {
3527
5231
  } catch {}
3528
5232
  if (!detected.some((d) => d.name === "dotnet")) {
3529
5233
  try {
3530
- const entries = readdirSync3(directory);
5234
+ const entries = readdirSync5(directory);
3531
5235
  const dotnetFile = entries.find((e) => e.endsWith(".csproj") || e.endsWith(".fsproj") || e.endsWith(".sln"));
3532
5236
  if (dotnetFile) {
3533
5237
  detected.push({
@@ -3547,27 +5251,27 @@ function detectStack(directory) {
3547
5251
  });
3548
5252
  }
3549
5253
  function detectPackageManager(directory) {
3550
- if (existsSync9(join8(directory, "bun.lockb")))
5254
+ if (existsSync13(join12(directory, "bun.lockb")))
3551
5255
  return "bun";
3552
- if (existsSync9(join8(directory, "pnpm-lock.yaml")))
5256
+ if (existsSync13(join12(directory, "pnpm-lock.yaml")))
3553
5257
  return "pnpm";
3554
- if (existsSync9(join8(directory, "yarn.lock")))
5258
+ if (existsSync13(join12(directory, "yarn.lock")))
3555
5259
  return "yarn";
3556
- if (existsSync9(join8(directory, "package-lock.json")))
5260
+ if (existsSync13(join12(directory, "package-lock.json")))
3557
5261
  return "npm";
3558
- if (existsSync9(join8(directory, "package.json")))
5262
+ if (existsSync13(join12(directory, "package.json")))
3559
5263
  return "npm";
3560
5264
  return;
3561
5265
  }
3562
5266
  function detectMonorepo(directory) {
3563
5267
  for (const marker of MONOREPO_MARKERS) {
3564
- if (existsSync9(join8(directory, marker)))
5268
+ if (existsSync13(join12(directory, marker)))
3565
5269
  return true;
3566
5270
  }
3567
5271
  try {
3568
- const pkgPath = join8(directory, "package.json");
3569
- if (existsSync9(pkgPath)) {
3570
- const pkg = JSON.parse(readFileSync8(pkgPath, "utf-8"));
5272
+ const pkgPath = join12(directory, "package.json");
5273
+ if (existsSync13(pkgPath)) {
5274
+ const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
3571
5275
  if (pkg.workspaces)
3572
5276
  return true;
3573
5277
  }
@@ -3593,7 +5297,8 @@ function generateFingerprint(directory) {
3593
5297
  packageManager: detectPackageManager(directory),
3594
5298
  primaryLanguage: detectPrimaryLanguage(stack),
3595
5299
  os: process.platform,
3596
- arch: arch()
5300
+ arch: arch(),
5301
+ weaveVersion: getWeaveVersion()
3597
5302
  };
3598
5303
  }
3599
5304
  function fingerprintProject(directory) {
@@ -3614,8 +5319,16 @@ function fingerprintProject(directory) {
3614
5319
  function getOrCreateFingerprint(directory) {
3615
5320
  try {
3616
5321
  const existing = readFingerprint(directory);
3617
- if (existing)
3618
- return existing;
5322
+ if (existing) {
5323
+ const currentVersion = getWeaveVersion();
5324
+ if (existing.weaveVersion === currentVersion) {
5325
+ return existing;
5326
+ }
5327
+ log("[analytics] Fingerprint version mismatch — regenerating", {
5328
+ cached: existing.weaveVersion ?? "none",
5329
+ current: currentVersion
5330
+ });
5331
+ }
3619
5332
  return fingerprintProject(directory);
3620
5333
  } catch (err) {
3621
5334
  log("[analytics] getOrCreateFingerprint failed (non-fatal)", { error: String(err) });
@@ -3623,6 +5336,11 @@ function getOrCreateFingerprint(directory) {
3623
5336
  }
3624
5337
  }
3625
5338
  // src/features/analytics/session-tracker.ts
5339
+ function safeNum(v) {
5340
+ const n = Number(v);
5341
+ return Number.isFinite(n) && n >= 0 ? n : 0;
5342
+ }
5343
+
3626
5344
  class SessionTracker {
3627
5345
  sessions = new Map;
3628
5346
  directory;
@@ -3638,7 +5356,16 @@ class SessionTracker {
3638
5356
  startedAt: new Date().toISOString(),
3639
5357
  toolCounts: {},
3640
5358
  delegations: [],
3641
- inFlight: {}
5359
+ inFlight: {},
5360
+ totalCost: 0,
5361
+ tokenUsage: {
5362
+ inputTokens: 0,
5363
+ outputTokens: 0,
5364
+ reasoningTokens: 0,
5365
+ cacheReadTokens: 0,
5366
+ cacheWriteTokens: 0,
5367
+ totalMessages: 0
5368
+ }
3642
5369
  };
3643
5370
  this.sessions.set(sessionId, session);
3644
5371
  return session;
@@ -3672,6 +5399,29 @@ class SessionTracker {
3672
5399
  session.delegations.push(delegation);
3673
5400
  }
3674
5401
  }
5402
+ setAgentName(sessionId, agentName) {
5403
+ const session = this.sessions.get(sessionId);
5404
+ if (!session)
5405
+ return;
5406
+ if (!session.agentName) {
5407
+ session.agentName = agentName;
5408
+ }
5409
+ }
5410
+ trackCost(sessionId, cost) {
5411
+ const session = this.sessions.get(sessionId);
5412
+ if (!session)
5413
+ return;
5414
+ session.totalCost += safeNum(cost);
5415
+ }
5416
+ trackTokenUsage(sessionId, tokens) {
5417
+ const session = this.startSession(sessionId);
5418
+ session.tokenUsage.inputTokens += safeNum(tokens.input);
5419
+ session.tokenUsage.outputTokens += safeNum(tokens.output);
5420
+ session.tokenUsage.reasoningTokens += safeNum(tokens.reasoning);
5421
+ session.tokenUsage.cacheReadTokens += safeNum(tokens.cacheRead);
5422
+ session.tokenUsage.cacheWriteTokens += safeNum(tokens.cacheWrite);
5423
+ session.tokenUsage.totalMessages += 1;
5424
+ }
3675
5425
  endSession(sessionId) {
3676
5426
  const session = this.sessions.get(sessionId);
3677
5427
  if (!session)
@@ -3689,14 +5439,21 @@ class SessionTracker {
3689
5439
  toolUsage,
3690
5440
  delegations: session.delegations,
3691
5441
  totalToolCalls,
3692
- totalDelegations: session.delegations.length
5442
+ totalDelegations: session.delegations.length,
5443
+ agentName: session.agentName,
5444
+ totalCost: session.totalCost > 0 ? session.totalCost : undefined,
5445
+ tokenUsage: session.tokenUsage.totalMessages > 0 ? session.tokenUsage : undefined
3693
5446
  };
3694
5447
  try {
3695
5448
  appendSessionSummary(this.directory, summary);
3696
5449
  log("[analytics] Session summary persisted", {
3697
5450
  sessionId,
3698
5451
  totalToolCalls,
3699
- totalDelegations: session.delegations.length
5452
+ totalDelegations: session.delegations.length,
5453
+ ...summary.tokenUsage ? {
5454
+ inputTokens: summary.tokenUsage.inputTokens,
5455
+ outputTokens: summary.tokenUsage.outputTokens
5456
+ } : {}
3700
5457
  });
3701
5458
  } catch (err) {
3702
5459
  log("[analytics] Failed to persist session summary (non-fatal)", {
@@ -3723,8 +5480,7 @@ function createSessionTracker(directory) {
3723
5480
  // src/features/analytics/index.ts
3724
5481
  function createAnalytics(directory, fingerprint) {
3725
5482
  const tracker = createSessionTracker(directory);
3726
- const resolvedFingerprint = fingerprint ?? getOrCreateFingerprint(directory);
3727
- return { tracker, fingerprint: resolvedFingerprint };
5483
+ return { tracker, fingerprint };
3728
5484
  }
3729
5485
 
3730
5486
  // src/index.ts
@@ -3733,8 +5489,9 @@ var WeavePlugin = async (ctx) => {
3733
5489
  const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
3734
5490
  const isHookEnabled = (name) => !disabledHooks.has(name);
3735
5491
  const analyticsEnabled = pluginConfig.analytics?.enabled === true;
3736
- const fingerprint = analyticsEnabled ? getOrCreateFingerprint(ctx.directory) : null;
3737
- const configDir = join9(ctx.directory, ".opencode");
5492
+ const fingerprintEnabled = analyticsEnabled && pluginConfig.analytics?.use_fingerprint === true;
5493
+ const fingerprint = fingerprintEnabled ? getOrCreateFingerprint(ctx.directory) : null;
5494
+ const configDir = join13(ctx.directory, ".opencode");
3738
5495
  const toolsResult = await createTools({ ctx, pluginConfig });
3739
5496
  const managers = createManagers({ ctx, pluginConfig, resolveSkills: toolsResult.resolveSkillsFn, fingerprint, configDir });
3740
5497
  const hooks = createHooks({ pluginConfig, isHookEnabled, directory: ctx.directory, analyticsEnabled });