@opencode_weave/weave 0.6.4 → 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 (30) hide show
  1. package/dist/config/schema.d.ts +9 -0
  2. package/dist/features/analytics/adherence.d.ts +10 -0
  3. package/dist/features/analytics/format-metrics.d.ts +10 -0
  4. package/dist/features/analytics/generate-metrics-report.d.ts +17 -0
  5. package/dist/features/analytics/git-diff.d.ts +7 -0
  6. package/dist/features/analytics/index.d.ts +13 -6
  7. package/dist/features/analytics/plan-parser.d.ts +7 -0
  8. package/dist/features/analytics/plan-token-aggregator.d.ts +11 -0
  9. package/dist/features/analytics/session-tracker.d.ts +20 -0
  10. package/dist/features/analytics/storage.d.ts +13 -1
  11. package/dist/features/analytics/token-report.d.ts +14 -0
  12. package/dist/features/analytics/types.d.ts +89 -1
  13. package/dist/features/builtin-commands/templates/metrics.d.ts +1 -0
  14. package/dist/features/builtin-commands/templates/run-workflow.d.ts +1 -0
  15. package/dist/features/builtin-commands/types.d.ts +1 -1
  16. package/dist/features/workflow/commands.d.ts +17 -0
  17. package/dist/features/workflow/completion.d.ts +31 -0
  18. package/dist/features/workflow/constants.d.ts +12 -0
  19. package/dist/features/workflow/context.d.ts +16 -0
  20. package/dist/features/workflow/discovery.d.ts +19 -0
  21. package/dist/features/workflow/engine.d.ts +49 -0
  22. package/dist/features/workflow/hook.d.ts +47 -0
  23. package/dist/features/workflow/index.d.ts +15 -0
  24. package/dist/features/workflow/schema.d.ts +118 -0
  25. package/dist/features/workflow/storage.d.ts +51 -0
  26. package/dist/features/workflow/types.d.ts +142 -0
  27. package/dist/hooks/create-hooks.d.ts +6 -0
  28. package/dist/index.js +2143 -422
  29. package/dist/plugin/types.d.ts +1 -1
  30. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // src/index.ts
2
- import { join as join10 } from "path";
2
+ import { join as join13 } from "path";
3
3
 
4
4
  // src/config/loader.ts
5
- import { existsSync, readFileSync } from "node:fs";
5
+ import { existsSync as existsSync2, readFileSync } from "node:fs";
6
6
  import { join as join2 } from "node:path";
7
- import { homedir } from "node:os";
7
+ import { homedir as homedir2 } from "node:os";
8
8
  import { parse } from "jsonc-parser";
9
9
 
10
10
  // src/config/schema.ts
@@ -77,7 +77,11 @@ var CustomAgentConfigSchema = z.object({
77
77
  });
78
78
  var CustomAgentsConfigSchema = z.record(z.string(), CustomAgentConfigSchema);
79
79
  var AnalyticsConfigSchema = z.object({
80
- enabled: z.boolean().optional()
80
+ enabled: z.boolean().optional(),
81
+ use_fingerprint: z.boolean().optional()
82
+ });
83
+ var WorkflowConfigSchema = z.object({
84
+ disabled_workflows: z.array(z.string()).optional()
81
85
  });
82
86
  var WeaveConfigSchema = z.object({
83
87
  $schema: z.string().optional(),
@@ -91,7 +95,8 @@ var WeaveConfigSchema = z.object({
91
95
  background: BackgroundConfigSchema.optional(),
92
96
  analytics: AnalyticsConfigSchema.optional(),
93
97
  tmux: TmuxConfigSchema.optional(),
94
- experimental: ExperimentalConfigSchema.optional()
98
+ experimental: ExperimentalConfigSchema.optional(),
99
+ workflows: WorkflowConfigSchema.optional()
95
100
  });
96
101
 
97
102
  // src/config/merge.ts
@@ -134,7 +139,20 @@ function mergeConfigs(user, project) {
134
139
  import * as fs from "fs";
135
140
  import * as path from "path";
136
141
  import * as os from "os";
137
- var LOG_FILE = path.join(os.tmpdir(), "weave-opencode.log");
142
+ function getLogDir() {
143
+ const home = os.homedir();
144
+ return path.join(home, ".opencode", "logs");
145
+ }
146
+ function resolveLogFile() {
147
+ const dir = getLogDir();
148
+ try {
149
+ if (!fs.existsSync(dir)) {
150
+ fs.mkdirSync(dir, { recursive: true });
151
+ }
152
+ } catch {}
153
+ return path.join(dir, "weave-opencode.log");
154
+ }
155
+ var LOG_FILE = resolveLogFile();
138
156
  function log(message, data) {
139
157
  try {
140
158
  const timestamp = new Date().toISOString();
@@ -170,15 +188,15 @@ function readJsoncFile(filePath) {
170
188
  }
171
189
  function detectConfigFile(basePath) {
172
190
  const jsoncPath = basePath + ".jsonc";
173
- if (existsSync(jsoncPath))
191
+ if (existsSync2(jsoncPath))
174
192
  return jsoncPath;
175
193
  const jsonPath = basePath + ".json";
176
- if (existsSync(jsonPath))
194
+ if (existsSync2(jsonPath))
177
195
  return jsonPath;
178
196
  return null;
179
197
  }
180
198
  function loadWeaveConfig(directory, _ctx, _homeDir) {
181
- const userBasePath = join2(_homeDir ?? homedir(), ".config", "opencode", "weave-opencode");
199
+ const userBasePath = join2(_homeDir ?? homedir2(), ".config", "opencode", "weave-opencode");
182
200
  const projectBasePath = join2(directory, ".opencode", "weave-opencode");
183
201
  const userConfigPath = detectConfigFile(userBasePath);
184
202
  const projectConfigPath = detectConfigFile(projectBasePath);
@@ -267,6 +285,38 @@ For each unchecked \`- [ ]\` task in the plan:
267
285
  - Do NOT stop until all checkboxes are checked or you are explicitly told to stop
268
286
  - After all tasks are complete, report a final summary`;
269
287
 
288
+ // src/features/builtin-commands/templates/metrics.ts
289
+ var METRICS_TEMPLATE = `You are being activated by the /metrics command to present Weave analytics data to the user.
290
+
291
+ ## Your Mission
292
+ Present the injected metrics data in a clear, readable format. The data has already been loaded and formatted by the command hook — simply relay it to the user.
293
+
294
+ ## Instructions
295
+
296
+ 1. **Read the injected context below** — it contains pre-formatted metrics markdown
297
+ 2. **Present it to the user** as-is — do NOT re-fetch or recalculate anything
298
+ 3. **Answer follow-up questions** about the data if the user asks
299
+ 4. If the data indicates analytics is disabled or no data exists, relay that message directly`;
300
+
301
+ // src/features/builtin-commands/templates/run-workflow.ts
302
+ var RUN_WORKFLOW_TEMPLATE = `You are being activated by the /run-workflow command to execute a multi-step workflow.
303
+
304
+ ## Your Mission
305
+ The workflow engine will inject context below with:
306
+ - The workflow definition to use
307
+ - The user's goal for this workflow instance
308
+ - The current step and its prompt
309
+ - Context from any previously completed steps
310
+
311
+ Follow the injected step prompt. When the step is complete, the workflow engine will
312
+ automatically advance you to the next step.
313
+
314
+ ## Rules
315
+ - Focus on the current step's task only
316
+ - Signal completion clearly (the workflow engine detects it)
317
+ - Do NOT skip ahead to future steps
318
+ - If you need user input, ask for it and wait`;
319
+
270
320
  // src/features/builtin-commands/commands.ts
271
321
  var BUILTIN_COMMANDS = {
272
322
  "start-work": {
@@ -279,6 +329,36 @@ ${START_WORK_TEMPLATE}
279
329
  <session-context>Session ID: $SESSION_ID Timestamp: $TIMESTAMP</session-context>
280
330
  <user-request>$ARGUMENTS</user-request>`,
281
331
  argumentHint: "[plan-name]"
332
+ },
333
+ "token-report": {
334
+ name: "token-report",
335
+ description: "Show token usage and cost report across sessions",
336
+ agent: "loom",
337
+ template: `<command-instruction>
338
+ Display the token usage report that has been injected below. Present it clearly to the user.
339
+ </command-instruction>
340
+ <token-report>$ARGUMENTS</token-report>`
341
+ },
342
+ metrics: {
343
+ name: "metrics",
344
+ description: "Show Weave analytics and plan metrics reports",
345
+ agent: "loom",
346
+ template: `<command-instruction>
347
+ ${METRICS_TEMPLATE}
348
+ </command-instruction>
349
+ <metrics-data>$ARGUMENTS</metrics-data>`,
350
+ argumentHint: "[plan-name|all]"
351
+ },
352
+ "run-workflow": {
353
+ name: "run-workflow",
354
+ description: "Run a multi-step workflow",
355
+ agent: "loom",
356
+ template: `<command-instruction>
357
+ ${RUN_WORKFLOW_TEMPLATE}
358
+ </command-instruction>
359
+ <session-context>Session ID: $SESSION_ID Timestamp: $TIMESTAMP</session-context>
360
+ <user-request>$ARGUMENTS</user-request>`,
361
+ argumentHint: '<workflow-name> ["goal"]'
282
362
  }
283
363
  };
284
364
  // src/managers/config-handler.ts
@@ -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,246 +2850,1203 @@ 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
- };
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)) {
3254
+ return {
3255
+ complete: true,
3256
+ summary: "Agent signaled completion"
3257
+ };
3258
+ }
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);
3269
+ return {
3270
+ type: "inject_prompt",
3271
+ prompt,
3272
+ agent: firstStepDef.agent
3273
+ };
3274
+ }
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
+ };
3322
+ }
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
+ };
3460
+ }
3461
+ const result = startNewWorkflow(workflowName, goal, sessionId, directory);
3462
+ return prependWarning(result, workStateWarning);
3463
+ }
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
+ };
2926
3474
  }
2927
3475
  return {
2928
- switchAgent: "tapestry",
2929
- contextInjection: freshContext
3476
+ contextInjection: `## Goal Required
3477
+ To start the "${workflowName}" workflow, provide a goal:
3478
+ \`/run-workflow ${workflowName} "your goal here"\``,
3479
+ switchAgent: null
2930
3480
  };
2931
3481
  }
2932
- const listing = incompletePlans.map((p) => {
2933
- const progress = getPlanProgress(p);
2934
- return ` - **${getPlanName(p)}** (${progress.completed}/${progress.total} tasks done)`;
2935
- }).join(`
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 };
3533
+ }
3534
+ }
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
3545
+
3546
+ There is currently an active plan being executed: "${planName}"
3547
+ Status: ${status} • Progress: ${progress.completed}/${progress.total} tasks complete
3548
+
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`;
3555
+ }
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}
3563
+
3564
+ ---
3565
+
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();
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(`
2936
3583
  `);
2937
3584
  return {
2938
- switchAgent: "tapestry",
2939
- contextInjection: `## Multiple Plans Found
2940
- There are ${incompletePlans.length} incomplete plans:
3585
+ contextInjection: `## Available Workflows
2941
3586
  ${listing}
2942
3587
 
2943
- Ask the user which plan to work on. They can run \`/start-work [plan-name]\` to select one.`
3588
+ To start a workflow, run:
3589
+ \`/run-workflow <name> "your goal"\``,
3590
+ switchAgent: null
2944
3591
  };
2945
3592
  }
2946
- function findPlanByName(plans, requestedName) {
2947
- const lower = requestedName.toLowerCase();
2948
- const exact = plans.find((p) => getPlanName(p).toLowerCase() === lower);
2949
- if (exact)
2950
- return exact;
2951
- const partial = plans.find((p) => getPlanName(p).toLowerCase().includes(lower));
2952
- return partial || null;
2953
- }
2954
- function formatValidationResults(result) {
2955
- const lines = [];
2956
- if (result.errors.length > 0) {
2957
- lines.push("**Errors (blocking):**");
2958
- for (const err of result.errors) {
2959
- lines.push(`- [${err.category}] ${err.message}`);
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
+ }
2960
3611
  }
3612
+ return { contextInjection: null, switchAgent: null };
2961
3613
  }
2962
- if (result.warnings.length > 0) {
2963
- if (result.errors.length > 0)
2964
- lines.push("");
2965
- lines.push("**Warnings:**");
2966
- for (const warn of result.warnings) {
2967
- lines.push(`- [${warn.category}] ${warn.message}`);
2968
- }
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
+ };
2969
3630
  }
2970
- return lines.join(`
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);
3660
+ }
3661
+ if (matchesAny(trimmed, SKIP_PATTERNS)) {
3662
+ return handleSkip(directory, instance);
3663
+ }
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(`
2971
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
+ };
2972
3777
  }
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}
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.
2979
3809
 
2980
- Read the plan file now and begin executing from the first unchecked \`- [ ]\` task.
3810
+ ${formatValidationResults(validation)}
2981
3811
 
2982
- **SIDEBAR TODOS DO THIS FIRST:**
2983
- Before starting any work, use todowrite to populate the sidebar:
2984
- 1. Create a summary todo (in_progress): "${planName} ${progress.completed}/${progress.total}"
2985
- 2. Create a todo for the first unchecked task (in_progress)
2986
- 3. Create todos for the next 2-3 tasks (pending)
2987
- Keep each todo under 35 chars. Update as you complete tasks.`;
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);
2988
3834
  }
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}
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
2997
3846
 
2998
- Read the plan file now and continue from the first unchecked \`- [ ]\` task.
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
2999
3850
 
3000
- **SIDEBAR TODOSRESTORE 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.`;
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;
3006
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.
3007
3875
 
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;
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;
3013
4050
  const state = readWorkState(directory);
3014
4051
  if (!state) {
3015
4052
  return { continuationPrompt: null };
@@ -3017,123 +4054,661 @@ function checkContinuation(input) {
3017
4054
  if (state.paused) {
3018
4055
  return { continuationPrompt: null };
3019
4056
  }
3020
- if (state.session_ids.length > 0 && !state.session_ids.includes(input.sessionId)) {
3021
- return { continuationPrompt: null };
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
+ }
4584
+ }
4585
+ }
4586
+ return Array.from(files);
4587
+ }
4588
+
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 [];
3022
4595
  }
3023
- const progress = getPlanProgress(state.active_plan);
3024
- if (progress.isComplete) {
3025
- return { continuationPrompt: null };
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 [];
3026
4607
  }
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 };
4608
+ }
4609
+
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);
3041
4625
  }
3042
- writeWorkState(directory, state);
3043
4626
  }
3044
- const remaining = progress.total - progress.completed;
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;
3045
4636
  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`
4637
+ coverage,
4638
+ precision,
4639
+ plannedFilesChanged,
4640
+ unplannedChanges,
4641
+ missedFiles,
4642
+ totalPlannedFiles,
4643
+ totalActualFiles
3059
4644
  };
3060
4645
  }
3061
4646
 
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
- };
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;
3085
4664
  }
3086
4665
 
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
- };
3096
- 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
3108
- };
3109
- }
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);
3130
- }
3131
- function clearSession2(sessionId) {
3132
- sessionMap.delete(sessionId);
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,79 +5058,40 @@ ${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
  }
3348
-
3349
- // src/features/analytics/types.ts
3350
- var ANALYTICS_DIR = ".weave/analytics";
3351
- var SESSION_SUMMARIES_FILE = "session-summaries.jsonl";
3352
- var FINGERPRINT_FILE = "fingerprint.json";
3353
- // src/features/analytics/storage.ts
3354
- import { existsSync as existsSync8, mkdirSync as mkdirSync2, appendFileSync as appendFileSync2, readFileSync as readFileSync7, writeFileSync as writeFileSync2 } from "fs";
3355
- import { join as join7 } from "path";
3356
- var MAX_SESSION_ENTRIES = 1000;
3357
- function ensureAnalyticsDir(directory) {
3358
- const dir = join7(directory, ANALYTICS_DIR);
3359
- mkdirSync2(dir, { recursive: true, mode: 448 });
3360
- return dir;
3361
- }
3362
- function appendSessionSummary(directory, summary) {
3363
- try {
3364
- const dir = ensureAnalyticsDir(directory);
3365
- const filePath = join7(dir, SESSION_SUMMARIES_FILE);
3366
- const line = JSON.stringify(summary) + `
3367
- `;
3368
- appendFileSync2(filePath, line, { encoding: "utf-8", mode: 384 });
3369
- try {
3370
- const content = readFileSync7(filePath, "utf-8");
3371
- const lines = content.split(`
3372
- `).filter((l) => l.trim().length > 0);
3373
- if (lines.length > MAX_SESSION_ENTRIES) {
3374
- const trimmed = lines.slice(-MAX_SESSION_ENTRIES).join(`
3375
- `) + `
3376
- `;
3377
- writeFileSync2(filePath, trimmed, { encoding: "utf-8", mode: 384 });
3378
- }
3379
- } catch {}
3380
- return true;
3381
- } catch {
3382
- return false;
3383
- }
3384
- }
3385
- function writeFingerprint(directory, fingerprint) {
3386
- try {
3387
- const dir = ensureAnalyticsDir(directory);
3388
- const filePath = join7(dir, FINGERPRINT_FILE);
3389
- writeFileSync2(filePath, JSON.stringify(fingerprint, null, 2), { encoding: "utf-8", mode: 384 });
3390
- return true;
3391
- } catch {
3392
- return false;
3393
- }
3394
- }
3395
- function readFingerprint(directory) {
3396
- const filePath = join7(directory, ANALYTICS_DIR, FINGERPRINT_FILE);
3397
- try {
3398
- if (!existsSync8(filePath))
3399
- return null;
3400
- const content = readFileSync7(filePath, "utf-8");
3401
- const parsed = JSON.parse(content);
3402
- if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.stack))
3403
- return null;
3404
- return parsed;
3405
- } catch {
3406
- return null;
3407
- }
3408
- }
3409
5086
  // src/features/analytics/fingerprint.ts
3410
- import { existsSync as existsSync9, readFileSync as readFileSync9, readdirSync as readdirSync3 } from "fs";
3411
- import { join as join9 } from "path";
5087
+ import { existsSync as existsSync13, readFileSync as readFileSync12, readdirSync as readdirSync5 } from "fs";
5088
+ import { join as join12 } from "path";
3412
5089
  import { arch } from "os";
3413
5090
 
3414
5091
  // src/shared/version.ts
3415
- import { readFileSync as readFileSync8 } from "fs";
5092
+ import { readFileSync as readFileSync11 } from "fs";
3416
5093
  import { fileURLToPath } from "url";
3417
- import { dirname as dirname2, join as join8 } from "path";
5094
+ import { dirname as dirname2, join as join11 } from "path";
3418
5095
  var cachedVersion;
3419
5096
  function getWeaveVersion() {
3420
5097
  if (cachedVersion !== undefined)
@@ -3423,7 +5100,7 @@ function getWeaveVersion() {
3423
5100
  const thisDir = dirname2(fileURLToPath(import.meta.url));
3424
5101
  for (const rel of ["../../package.json", "../package.json"]) {
3425
5102
  try {
3426
- const pkg = JSON.parse(readFileSync8(join8(thisDir, rel), "utf-8"));
5103
+ const pkg = JSON.parse(readFileSync11(join11(thisDir, rel), "utf-8"));
3427
5104
  if (pkg.name === "@opencode_weave/weave" && typeof pkg.version === "string") {
3428
5105
  const version = pkg.version;
3429
5106
  cachedVersion = version;
@@ -3528,7 +5205,7 @@ function detectStack(directory) {
3528
5205
  const detected = [];
3529
5206
  for (const marker of STACK_MARKERS) {
3530
5207
  for (const file of marker.files) {
3531
- if (existsSync9(join9(directory, file))) {
5208
+ if (existsSync13(join12(directory, file))) {
3532
5209
  detected.push({
3533
5210
  name: marker.name,
3534
5211
  confidence: marker.confidence,
@@ -3539,9 +5216,9 @@ function detectStack(directory) {
3539
5216
  }
3540
5217
  }
3541
5218
  try {
3542
- const pkgPath = join9(directory, "package.json");
3543
- if (existsSync9(pkgPath)) {
3544
- const pkg = JSON.parse(readFileSync9(pkgPath, "utf-8"));
5219
+ const pkgPath = join12(directory, "package.json");
5220
+ if (existsSync13(pkgPath)) {
5221
+ const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
3545
5222
  const deps = { ...pkg.dependencies, ...pkg.devDependencies };
3546
5223
  if (deps.react) {
3547
5224
  detected.push({
@@ -3554,7 +5231,7 @@ function detectStack(directory) {
3554
5231
  } catch {}
3555
5232
  if (!detected.some((d) => d.name === "dotnet")) {
3556
5233
  try {
3557
- const entries = readdirSync3(directory);
5234
+ const entries = readdirSync5(directory);
3558
5235
  const dotnetFile = entries.find((e) => e.endsWith(".csproj") || e.endsWith(".fsproj") || e.endsWith(".sln"));
3559
5236
  if (dotnetFile) {
3560
5237
  detected.push({
@@ -3574,27 +5251,27 @@ function detectStack(directory) {
3574
5251
  });
3575
5252
  }
3576
5253
  function detectPackageManager(directory) {
3577
- if (existsSync9(join9(directory, "bun.lockb")))
5254
+ if (existsSync13(join12(directory, "bun.lockb")))
3578
5255
  return "bun";
3579
- if (existsSync9(join9(directory, "pnpm-lock.yaml")))
5256
+ if (existsSync13(join12(directory, "pnpm-lock.yaml")))
3580
5257
  return "pnpm";
3581
- if (existsSync9(join9(directory, "yarn.lock")))
5258
+ if (existsSync13(join12(directory, "yarn.lock")))
3582
5259
  return "yarn";
3583
- if (existsSync9(join9(directory, "package-lock.json")))
5260
+ if (existsSync13(join12(directory, "package-lock.json")))
3584
5261
  return "npm";
3585
- if (existsSync9(join9(directory, "package.json")))
5262
+ if (existsSync13(join12(directory, "package.json")))
3586
5263
  return "npm";
3587
5264
  return;
3588
5265
  }
3589
5266
  function detectMonorepo(directory) {
3590
5267
  for (const marker of MONOREPO_MARKERS) {
3591
- if (existsSync9(join9(directory, marker)))
5268
+ if (existsSync13(join12(directory, marker)))
3592
5269
  return true;
3593
5270
  }
3594
5271
  try {
3595
- const pkgPath = join9(directory, "package.json");
3596
- if (existsSync9(pkgPath)) {
3597
- const pkg = JSON.parse(readFileSync9(pkgPath, "utf-8"));
5272
+ const pkgPath = join12(directory, "package.json");
5273
+ if (existsSync13(pkgPath)) {
5274
+ const pkg = JSON.parse(readFileSync12(pkgPath, "utf-8"));
3598
5275
  if (pkg.workspaces)
3599
5276
  return true;
3600
5277
  }
@@ -3659,6 +5336,11 @@ function getOrCreateFingerprint(directory) {
3659
5336
  }
3660
5337
  }
3661
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
+
3662
5344
  class SessionTracker {
3663
5345
  sessions = new Map;
3664
5346
  directory;
@@ -3674,7 +5356,16 @@ class SessionTracker {
3674
5356
  startedAt: new Date().toISOString(),
3675
5357
  toolCounts: {},
3676
5358
  delegations: [],
3677
- 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
+ }
3678
5369
  };
3679
5370
  this.sessions.set(sessionId, session);
3680
5371
  return session;
@@ -3708,6 +5399,29 @@ class SessionTracker {
3708
5399
  session.delegations.push(delegation);
3709
5400
  }
3710
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
+ }
3711
5425
  endSession(sessionId) {
3712
5426
  const session = this.sessions.get(sessionId);
3713
5427
  if (!session)
@@ -3725,14 +5439,21 @@ class SessionTracker {
3725
5439
  toolUsage,
3726
5440
  delegations: session.delegations,
3727
5441
  totalToolCalls,
3728
- 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
3729
5446
  };
3730
5447
  try {
3731
5448
  appendSessionSummary(this.directory, summary);
3732
5449
  log("[analytics] Session summary persisted", {
3733
5450
  sessionId,
3734
5451
  totalToolCalls,
3735
- totalDelegations: session.delegations.length
5452
+ totalDelegations: session.delegations.length,
5453
+ ...summary.tokenUsage ? {
5454
+ inputTokens: summary.tokenUsage.inputTokens,
5455
+ outputTokens: summary.tokenUsage.outputTokens
5456
+ } : {}
3736
5457
  });
3737
5458
  } catch (err) {
3738
5459
  log("[analytics] Failed to persist session summary (non-fatal)", {
@@ -3759,8 +5480,7 @@ function createSessionTracker(directory) {
3759
5480
  // src/features/analytics/index.ts
3760
5481
  function createAnalytics(directory, fingerprint) {
3761
5482
  const tracker = createSessionTracker(directory);
3762
- const resolvedFingerprint = fingerprint ?? getOrCreateFingerprint(directory);
3763
- return { tracker, fingerprint: resolvedFingerprint };
5483
+ return { tracker, fingerprint };
3764
5484
  }
3765
5485
 
3766
5486
  // src/index.ts
@@ -3769,8 +5489,9 @@ var WeavePlugin = async (ctx) => {
3769
5489
  const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
3770
5490
  const isHookEnabled = (name) => !disabledHooks.has(name);
3771
5491
  const analyticsEnabled = pluginConfig.analytics?.enabled === true;
3772
- const fingerprint = analyticsEnabled ? getOrCreateFingerprint(ctx.directory) : null;
3773
- const configDir = join10(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");
3774
5495
  const toolsResult = await createTools({ ctx, pluginConfig });
3775
5496
  const managers = createManagers({ ctx, pluginConfig, resolveSkills: toolsResult.resolveSkillsFn, fingerprint, configDir });
3776
5497
  const hooks = createHooks({ pluginConfig, isHookEnabled, directory: ctx.directory, analyticsEnabled });