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