@synergenius/flow-weaver-pack-weaver 0.9.7 → 0.9.9
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/bot/agent-loop.d.ts +20 -0
- package/dist/bot/agent-loop.d.ts.map +1 -0
- package/dist/bot/agent-loop.js +331 -0
- package/dist/bot/agent-loop.js.map +1 -0
- package/dist/bot/agent-provider.d.ts.map +1 -1
- package/dist/bot/agent-provider.js +3 -2
- package/dist/bot/agent-provider.js.map +1 -1
- package/dist/bot/approvals.js +17 -8
- package/dist/bot/approvals.js.map +1 -1
- package/dist/bot/assistant-core.d.ts +17 -0
- package/dist/bot/assistant-core.d.ts.map +1 -1
- package/dist/bot/assistant-core.js +418 -60
- package/dist/bot/assistant-core.js.map +1 -1
- package/dist/bot/assistant-tools.d.ts +1 -1
- package/dist/bot/assistant-tools.d.ts.map +1 -1
- package/dist/bot/assistant-tools.js +283 -9
- package/dist/bot/assistant-tools.js.map +1 -1
- package/dist/bot/bot-agent-channel.d.ts.map +1 -1
- package/dist/bot/bot-agent-channel.js +2 -0
- package/dist/bot/bot-agent-channel.js.map +1 -1
- package/dist/bot/bot-manager.d.ts +4 -0
- package/dist/bot/bot-manager.d.ts.map +1 -1
- package/dist/bot/bot-manager.js +72 -27
- package/dist/bot/bot-manager.js.map +1 -1
- package/dist/bot/conversation-store.d.ts +6 -5
- package/dist/bot/conversation-store.d.ts.map +1 -1
- package/dist/bot/conversation-store.js +98 -42
- package/dist/bot/conversation-store.js.map +1 -1
- package/dist/bot/cost-store.d.ts +3 -0
- package/dist/bot/cost-store.d.ts.map +1 -1
- package/dist/bot/cost-store.js +21 -10
- package/dist/bot/cost-store.js.map +1 -1
- package/dist/bot/cost-tracker.d.ts.map +1 -1
- package/dist/bot/cost-tracker.js +14 -1
- package/dist/bot/cost-tracker.js.map +1 -1
- package/dist/bot/cron-parser.d.ts.map +1 -1
- package/dist/bot/cron-parser.js +2 -0
- package/dist/bot/cron-parser.js.map +1 -1
- package/dist/bot/cron-scheduler.d.ts.map +1 -1
- package/dist/bot/cron-scheduler.js +1 -0
- package/dist/bot/cron-scheduler.js.map +1 -1
- package/dist/bot/device-connection.d.ts +13 -0
- package/dist/bot/device-connection.d.ts.map +1 -0
- package/dist/bot/device-connection.js +102 -0
- package/dist/bot/device-connection.js.map +1 -0
- package/dist/bot/error-classifier.d.ts.map +1 -1
- package/dist/bot/error-classifier.js +5 -0
- package/dist/bot/error-classifier.js.map +1 -1
- package/dist/bot/file-lock.d.ts.map +1 -1
- package/dist/bot/file-lock.js +13 -3
- package/dist/bot/file-lock.js.map +1 -1
- package/dist/bot/file-watcher.d.ts.map +1 -1
- package/dist/bot/file-watcher.js +1 -0
- package/dist/bot/file-watcher.js.map +1 -1
- package/dist/bot/genesis-prompt-context.d.ts +5 -0
- package/dist/bot/genesis-prompt-context.d.ts.map +1 -1
- package/dist/bot/genesis-prompt-context.js +55 -0
- package/dist/bot/genesis-prompt-context.js.map +1 -1
- package/dist/bot/genesis-store.d.ts +4 -0
- package/dist/bot/genesis-store.d.ts.map +1 -1
- package/dist/bot/genesis-store.js +79 -12
- package/dist/bot/genesis-store.js.map +1 -1
- package/dist/bot/improve-loop.d.ts +46 -0
- package/dist/bot/improve-loop.d.ts.map +1 -0
- package/dist/bot/improve-loop.js +592 -0
- package/dist/bot/improve-loop.js.map +1 -0
- package/dist/bot/insight-engine.d.ts +12 -0
- package/dist/bot/insight-engine.d.ts.map +1 -0
- package/dist/bot/insight-engine.js +256 -0
- package/dist/bot/insight-engine.js.map +1 -0
- package/dist/bot/knowledge-store.d.ts.map +1 -1
- package/dist/bot/knowledge-store.js +4 -1
- package/dist/bot/knowledge-store.js.map +1 -1
- package/dist/bot/pipeline-runner.d.ts.map +1 -1
- package/dist/bot/pipeline-runner.js +12 -4
- package/dist/bot/pipeline-runner.js.map +1 -1
- package/dist/bot/project-model.d.ts +25 -0
- package/dist/bot/project-model.d.ts.map +1 -0
- package/dist/bot/project-model.js +372 -0
- package/dist/bot/project-model.js.map +1 -0
- package/dist/bot/response-formatter.js +2 -3
- package/dist/bot/response-formatter.js.map +1 -1
- package/dist/bot/run-store.d.ts.map +1 -1
- package/dist/bot/run-store.js +10 -2
- package/dist/bot/run-store.js.map +1 -1
- package/dist/bot/safe-path.d.ts +1 -1
- package/dist/bot/safe-path.d.ts.map +1 -1
- package/dist/bot/safe-path.js +20 -1
- package/dist/bot/safe-path.js.map +1 -1
- package/dist/bot/safety.d.ts +10 -2
- package/dist/bot/safety.d.ts.map +1 -1
- package/dist/bot/safety.js +45 -2
- package/dist/bot/safety.js.map +1 -1
- package/dist/bot/session-state.d.ts +4 -0
- package/dist/bot/session-state.d.ts.map +1 -1
- package/dist/bot/session-state.js +52 -9
- package/dist/bot/session-state.js.map +1 -1
- package/dist/bot/slash-commands.d.ts.map +1 -1
- package/dist/bot/slash-commands.js +109 -3
- package/dist/bot/slash-commands.js.map +1 -1
- package/dist/bot/steering-engine.d.ts +67 -0
- package/dist/bot/steering-engine.d.ts.map +1 -0
- package/dist/bot/steering-engine.js +198 -0
- package/dist/bot/steering-engine.js.map +1 -0
- package/dist/bot/step-executor.d.ts.map +1 -1
- package/dist/bot/step-executor.js +62 -25
- package/dist/bot/step-executor.js.map +1 -1
- package/dist/bot/system-prompt.d.ts.map +1 -1
- package/dist/bot/system-prompt.js +5 -2
- package/dist/bot/system-prompt.js.map +1 -1
- package/dist/bot/task-queue.d.ts +6 -1
- package/dist/bot/task-queue.d.ts.map +1 -1
- package/dist/bot/task-queue.js +43 -4
- package/dist/bot/task-queue.js.map +1 -1
- package/dist/bot/tool-registry.d.ts +1 -1
- package/dist/bot/tool-registry.d.ts.map +1 -1
- package/dist/bot/tool-registry.js +65 -4
- package/dist/bot/tool-registry.js.map +1 -1
- package/dist/bot/trust-calculator.d.ts +34 -0
- package/dist/bot/trust-calculator.d.ts.map +1 -0
- package/dist/bot/trust-calculator.js +67 -0
- package/dist/bot/trust-calculator.js.map +1 -0
- package/dist/bot/types.d.ts +97 -0
- package/dist/bot/types.d.ts.map +1 -1
- package/dist/bot/update-checker.d.ts +21 -0
- package/dist/bot/update-checker.d.ts.map +1 -0
- package/dist/bot/update-checker.js +129 -0
- package/dist/bot/update-checker.js.map +1 -0
- package/dist/bot/weaver-tools.d.ts.map +1 -1
- package/dist/bot/weaver-tools.js +11 -4
- package/dist/bot/weaver-tools.js.map +1 -1
- package/dist/cli-bridge.d.ts +2 -0
- package/dist/cli-bridge.d.ts.map +1 -1
- package/dist/cli-bridge.js +3 -1
- package/dist/cli-bridge.js.map +1 -1
- package/dist/cli-handlers.d.ts +10 -1
- package/dist/cli-handlers.d.ts.map +1 -1
- package/dist/cli-handlers.js +141 -24
- package/dist/cli-handlers.js.map +1 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +749 -0
- package/dist/cli.js.map +1 -0
- package/dist/docs/weaver-config.md +15 -9
- package/dist/handlers/on-execution-completed.d.ts +11 -0
- package/dist/handlers/on-execution-completed.d.ts.map +1 -0
- package/dist/handlers/on-execution-completed.js +25 -0
- package/dist/handlers/on-execution-completed.js.map +1 -0
- package/dist/mcp-tools.d.ts.map +1 -1
- package/dist/mcp-tools.js +33 -0
- package/dist/mcp-tools.js.map +1 -1
- package/dist/node-types/genesis-approve.d.ts.map +1 -1
- package/dist/node-types/genesis-approve.js +28 -3
- package/dist/node-types/genesis-approve.js.map +1 -1
- package/dist/node-types/genesis-observe.d.ts.map +1 -1
- package/dist/node-types/genesis-observe.js +23 -13
- package/dist/node-types/genesis-observe.js.map +1 -1
- package/dist/node-types/genesis-propose.d.ts.map +1 -1
- package/dist/node-types/genesis-propose.js +8 -0
- package/dist/node-types/genesis-propose.js.map +1 -1
- package/dist/node-types/genesis-update-history.d.ts.map +1 -1
- package/dist/node-types/genesis-update-history.js +13 -0
- package/dist/node-types/genesis-update-history.js.map +1 -1
- package/dist/templates/weaver-template.d.ts +11 -0
- package/dist/templates/weaver-template.d.ts.map +1 -0
- package/dist/templates/weaver-template.js +53 -0
- package/dist/templates/weaver-template.js.map +1 -0
- package/dist/workflows/weaver-bot-session.d.ts +65 -0
- package/dist/workflows/weaver-bot-session.d.ts.map +1 -0
- package/dist/workflows/weaver-bot-session.js +68 -0
- package/dist/workflows/weaver-bot-session.js.map +1 -0
- package/dist/workflows/weaver.d.ts +24 -0
- package/dist/workflows/weaver.d.ts.map +1 -0
- package/dist/workflows/weaver.js +28 -0
- package/dist/workflows/weaver.js.map +1 -0
- package/flowweaver.manifest.json +28 -1
- package/package.json +6 -3
- package/src/bot/agent-provider.ts +3 -2
- package/src/bot/approvals.ts +16 -8
- package/src/bot/assistant-core.ts +420 -63
- package/src/bot/assistant-tools.ts +291 -9
- package/src/bot/bot-agent-channel.ts +2 -0
- package/src/bot/bot-manager.ts +70 -29
- package/src/bot/conversation-store.ts +87 -42
- package/src/bot/cost-store.ts +20 -9
- package/src/bot/cost-tracker.ts +13 -1
- package/src/bot/cron-parser.ts +1 -0
- package/src/bot/cron-scheduler.ts +1 -0
- package/src/bot/device-connection.ts +102 -0
- package/src/bot/error-classifier.ts +5 -0
- package/src/bot/file-lock.ts +12 -2
- package/src/bot/file-watcher.ts +1 -0
- package/src/bot/genesis-prompt-context.ts +61 -0
- package/src/bot/genesis-store.ts +68 -16
- package/src/bot/improve-loop.ts +651 -0
- package/src/bot/insight-engine.ts +273 -0
- package/src/bot/knowledge-store.ts +4 -1
- package/src/bot/pipeline-runner.ts +11 -6
- package/src/bot/project-model.ts +404 -0
- package/src/bot/response-formatter.ts +2 -3
- package/src/bot/run-store.ts +5 -2
- package/src/bot/safe-path.ts +20 -1
- package/src/bot/safety.ts +57 -3
- package/src/bot/session-state.ts +47 -7
- package/src/bot/slash-commands.ts +111 -3
- package/src/bot/steering-engine.ts +233 -0
- package/src/bot/step-executor.ts +66 -26
- package/src/bot/system-prompt.ts +5 -2
- package/src/bot/task-queue.ts +40 -4
- package/src/bot/tool-registry.ts +67 -5
- package/src/bot/trust-calculator.ts +87 -0
- package/src/bot/types.ts +104 -0
- package/src/bot/update-checker.ts +138 -0
- package/src/bot/weaver-tools.ts +10 -4
- package/src/cli-bridge.ts +4 -1
- package/src/cli-handlers.ts +150 -29
- package/src/handlers/on-execution-completed.ts +30 -0
- package/src/mcp-tools.ts +38 -0
- package/src/node-types/genesis-approve.ts +28 -3
- package/src/node-types/genesis-observe.ts +23 -12
- package/src/node-types/genesis-propose.ts +8 -0
- package/src/node-types/genesis-update-history.ts +12 -0
- package/src/ui/evolution-panel.tsx +96 -0
- package/src/ui/insights-widget.tsx +77 -0
|
@@ -21,10 +21,40 @@ export interface SlashCommand {
|
|
|
21
21
|
export const SLASH_COMMANDS: SlashCommand[] = [
|
|
22
22
|
{
|
|
23
23
|
name: '/help',
|
|
24
|
-
description: 'Show available commands',
|
|
24
|
+
description: 'Show available commands and capabilities',
|
|
25
25
|
handler: async (ctx) => {
|
|
26
|
-
|
|
26
|
+
// Check if this is a new project (no workflows)
|
|
27
|
+
let hasWorkflows = false;
|
|
28
|
+
try {
|
|
29
|
+
const fsMod = await import('node:fs');
|
|
30
|
+
const pathMod = await import('node:path');
|
|
31
|
+
const srcDir = pathMod.join(ctx.projectDir, 'src');
|
|
32
|
+
if (fsMod.existsSync(srcDir)) {
|
|
33
|
+
const files = fsMod.readdirSync(srcDir, { recursive: true }) as string[];
|
|
34
|
+
hasWorkflows = files.some(f => f.endsWith('.ts'));
|
|
35
|
+
}
|
|
36
|
+
} catch { /* scan failed */ }
|
|
37
|
+
|
|
38
|
+
ctx.out(`\n ${c.bold('Weaver Assistant')}\n`);
|
|
39
|
+
ctx.out(` Tell me what to build, fix, or explore. I use tools to do the work.\n\n`);
|
|
40
|
+
|
|
41
|
+
if (!hasWorkflows) {
|
|
42
|
+
ctx.out(` ${c.bold('Getting Started:')}\n`);
|
|
43
|
+
ctx.out(` ${c.cyan('1.')} "create a hello world workflow"\n`);
|
|
44
|
+
ctx.out(` ${c.cyan('2.')} "create a data pipeline that reads an API and transforms the result"\n`);
|
|
45
|
+
ctx.out(` ${c.cyan('3.')} "what is Flow Weaver and how does it work?"\n\n`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ctx.out(` ${c.bold('Capabilities:')}\n`);
|
|
49
|
+
ctx.out(` Workflows Create, validate, compile, diagram, describe, and modify\n`);
|
|
50
|
+
ctx.out(` Bots Spawn background workers that execute tasks autonomously\n`);
|
|
51
|
+
ctx.out(` Code Read, write, and patch files in your project\n`);
|
|
52
|
+
ctx.out(` Health Track project health, insights, cost, and trust level\n`);
|
|
53
|
+
ctx.out(` Evolution Propose and apply genesis improvements to bot workflows\n`);
|
|
54
|
+
ctx.out(` Shell Run commands, check TypeScript, run tests\n\n`);
|
|
55
|
+
ctx.out(` ${c.bold('Commands:')}\n`);
|
|
27
56
|
for (const cmd of SLASH_COMMANDS) {
|
|
57
|
+
if (cmd.name === '/help') continue;
|
|
28
58
|
ctx.out(` ${c.cyan(cmd.name.padEnd(12))} ${c.dim(cmd.description)}\n`);
|
|
29
59
|
}
|
|
30
60
|
ctx.out('\n');
|
|
@@ -36,7 +66,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
|
|
|
36
66
|
handler: async (ctx) => {
|
|
37
67
|
const bots = await ctx.executor('bot_list', {});
|
|
38
68
|
const summary = await ctx.executor('conversation_summary', {});
|
|
39
|
-
ctx.out(`\n ${bots.result}\n ${summary.result}\n\n`);
|
|
69
|
+
ctx.out(`\n Bots:\n ${bots.result}\n\n Conversation:\n ${summary.result}\n\n`);
|
|
40
70
|
},
|
|
41
71
|
},
|
|
42
72
|
{
|
|
@@ -53,6 +83,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
|
|
|
53
83
|
handler: async (ctx) => {
|
|
54
84
|
process.stderr.write('\x1b[2J\x1b[H');
|
|
55
85
|
ctx.onClear?.();
|
|
86
|
+
ctx.out(` ${c.dim('Screen cleared. New conversation started.')}\n\n`);
|
|
56
87
|
},
|
|
57
88
|
},
|
|
58
89
|
{
|
|
@@ -85,6 +116,22 @@ export const SLASH_COMMANDS: SlashCommand[] = [
|
|
|
85
116
|
ctx.onVerbose?.();
|
|
86
117
|
},
|
|
87
118
|
},
|
|
119
|
+
{
|
|
120
|
+
name: '/insights',
|
|
121
|
+
description: 'Show project insights and recommendations',
|
|
122
|
+
handler: async (ctx: SlashContext) => {
|
|
123
|
+
const result = await ctx.executor('project_insights', {});
|
|
124
|
+
ctx.out(`\n${result.result}\n\n`);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
name: '/health',
|
|
129
|
+
description: 'Show project health summary',
|
|
130
|
+
handler: async (ctx: SlashContext) => {
|
|
131
|
+
const result = await ctx.executor('project_health', {});
|
|
132
|
+
ctx.out(`\n${result.result}\n\n`);
|
|
133
|
+
},
|
|
134
|
+
},
|
|
88
135
|
{
|
|
89
136
|
name: '/history',
|
|
90
137
|
description: 'Show conversation history summary',
|
|
@@ -93,6 +140,67 @@ export const SLASH_COMMANDS: SlashCommand[] = [
|
|
|
93
140
|
ctx.out(`\n ${result.result}\n\n`);
|
|
94
141
|
},
|
|
95
142
|
},
|
|
143
|
+
{
|
|
144
|
+
name: '/improve',
|
|
145
|
+
description: 'Show status of the current or last improve run',
|
|
146
|
+
handler: async (ctx) => {
|
|
147
|
+
try {
|
|
148
|
+
const fsMod = await import('node:fs');
|
|
149
|
+
const pathMod = await import('node:path');
|
|
150
|
+
const osMod = await import('node:os');
|
|
151
|
+
const summaryDir = pathMod.join(osMod.homedir(), '.weaver', 'improve');
|
|
152
|
+
if (!fsMod.existsSync(summaryDir)) {
|
|
153
|
+
ctx.out(`\n No improve runs found. Start one with: weaver improve\n\n`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const files = fsMod.readdirSync(summaryDir).filter((f: string) => f.endsWith('.json')).sort().reverse();
|
|
157
|
+
if (files.length === 0) {
|
|
158
|
+
ctx.out(`\n No improve runs found.\n\n`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const latest = JSON.parse(fsMod.readFileSync(pathMod.join(summaryDir, files[0]!), 'utf-8'));
|
|
162
|
+
const duration = Math.round((new Date(latest.finishedAt).getTime() - new Date(latest.startedAt).getTime()) / 1000);
|
|
163
|
+
|
|
164
|
+
ctx.out(`\n Improve Run (${latest.reason})\n`);
|
|
165
|
+
ctx.out(` Branch: ${latest.branch}\n`);
|
|
166
|
+
ctx.out(` Duration: ${duration}s\n`);
|
|
167
|
+
ctx.out(` Successes: ${latest.successes} Failures: ${latest.failures} Skips: ${latest.skips} Blocked: ${latest.blocked}\n\n`);
|
|
168
|
+
for (const cy of latest.cycles) {
|
|
169
|
+
const icon = cy.outcome === 'success' ? '✓' : cy.outcome === 'failure' ? '✗' : '○';
|
|
170
|
+
ctx.out(` ${icon} Cycle ${cy.cycle}: [${cy.outcome}] ${cy.description.slice(0, 70)}\n`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Check if a run is currently active
|
|
174
|
+
try {
|
|
175
|
+
const { execFileSync } = await import('node:child_process');
|
|
176
|
+
const worktrees = execFileSync('git', ['worktree', 'list'], { encoding: 'utf-8', cwd: ctx.projectDir });
|
|
177
|
+
if (worktrees.includes('weaver-improve')) {
|
|
178
|
+
ctx.out(`\n LIVE: improve worktree active — run is in progress\n`);
|
|
179
|
+
}
|
|
180
|
+
} catch { /* git not available */ }
|
|
181
|
+
|
|
182
|
+
ctx.out('\n');
|
|
183
|
+
} catch (err) {
|
|
184
|
+
ctx.out(`\n Error reading improve status: ${err instanceof Error ? err.message : err}\n\n`);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: '/genesis',
|
|
190
|
+
description: 'Propose a workflow evolution based on project insights',
|
|
191
|
+
handler: async (ctx: SlashContext) => {
|
|
192
|
+
const result = await ctx.executor('genesis_propose', {});
|
|
193
|
+
ctx.out(`\n${result.result}\n\n`);
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
name: '/trust',
|
|
198
|
+
description: 'Show current trust level and factors',
|
|
199
|
+
handler: async (ctx: SlashContext) => {
|
|
200
|
+
const result = await ctx.executor('project_health', {});
|
|
201
|
+
ctx.out(`\n${result.result}\n\n`);
|
|
202
|
+
},
|
|
203
|
+
},
|
|
96
204
|
];
|
|
97
205
|
|
|
98
206
|
export function getSlashCompletions(partial: string): string[] {
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Steering Engine — time-based and event-based nudges that guide the
|
|
3
|
+
* assistant's behavior during long-running operations.
|
|
4
|
+
*
|
|
5
|
+
* Steers are injected into the system prompt when conditions are met.
|
|
6
|
+
* They're guidance, not hard stops (except urgent + "STOP" which triggers hasHardStop).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface TimeTrigger {
|
|
10
|
+
type: 'time';
|
|
11
|
+
afterMs: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface EventTrigger {
|
|
15
|
+
type: 'event';
|
|
16
|
+
event: string;
|
|
17
|
+
count?: number; // default 1
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Steer {
|
|
21
|
+
id: string;
|
|
22
|
+
trigger: TimeTrigger | EventTrigger;
|
|
23
|
+
message: string;
|
|
24
|
+
intensity: 'gentle' | 'firm' | 'urgent';
|
|
25
|
+
once: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const INTENSITY_PREFIX: Record<string, string> = {
|
|
29
|
+
gentle: '[CONTEXT NOTE]',
|
|
30
|
+
firm: '[STEER]',
|
|
31
|
+
urgent: '[URGENT STEER]',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export class SteeringEngine {
|
|
35
|
+
private readonly steers: Steer[];
|
|
36
|
+
private startTime: number;
|
|
37
|
+
private eventCounts = new Map<string, number>();
|
|
38
|
+
private firedOnce = new Set<string>();
|
|
39
|
+
private pendingMessages: string[] = [];
|
|
40
|
+
private hardStopTriggered = false;
|
|
41
|
+
// Track last event count when an event steer fired (for once: false repeat logic)
|
|
42
|
+
private eventSteerLastFired = new Map<string, number>();
|
|
43
|
+
|
|
44
|
+
constructor(steers: Steer[]) {
|
|
45
|
+
this.steers = steers;
|
|
46
|
+
this.startTime = Date.now();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check all steers and return the highest-priority triggered message,
|
|
51
|
+
* or null if nothing fired.
|
|
52
|
+
*/
|
|
53
|
+
check(): string | null {
|
|
54
|
+
const triggered: string[] = [];
|
|
55
|
+
const elapsed = Date.now() - this.startTime;
|
|
56
|
+
|
|
57
|
+
for (const steer of this.steers) {
|
|
58
|
+
if (steer.once && this.firedOnce.has(steer.id)) continue;
|
|
59
|
+
|
|
60
|
+
let shouldFire = false;
|
|
61
|
+
|
|
62
|
+
if (steer.trigger.type === 'time') {
|
|
63
|
+
shouldFire = elapsed >= steer.trigger.afterMs;
|
|
64
|
+
} else if (steer.trigger.type === 'event') {
|
|
65
|
+
const count = this.eventCounts.get(steer.trigger.event) ?? 0;
|
|
66
|
+
const threshold = steer.trigger.count ?? 1;
|
|
67
|
+
|
|
68
|
+
if (steer.once) {
|
|
69
|
+
shouldFire = count >= threshold;
|
|
70
|
+
} else {
|
|
71
|
+
// For repeat steers, fire every time we cross another threshold
|
|
72
|
+
const lastFired = this.eventSteerLastFired.get(steer.id) ?? 0;
|
|
73
|
+
shouldFire = count >= threshold && count > lastFired;
|
|
74
|
+
if (shouldFire) {
|
|
75
|
+
this.eventSteerLastFired.set(steer.id, count);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (shouldFire) {
|
|
81
|
+
const prefix = INTENSITY_PREFIX[steer.intensity] ?? '[STEER]';
|
|
82
|
+
const formatted = `${prefix} ${steer.message}`;
|
|
83
|
+
triggered.push(formatted);
|
|
84
|
+
|
|
85
|
+
if (steer.once) this.firedOnce.add(steer.id);
|
|
86
|
+
|
|
87
|
+
if (steer.intensity === 'urgent' && steer.message.includes('STOP')) {
|
|
88
|
+
this.hardStopTriggered = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (triggered.length === 0) return null;
|
|
94
|
+
|
|
95
|
+
// Add each triggered message individually to pending
|
|
96
|
+
this.pendingMessages.push(...triggered);
|
|
97
|
+
|
|
98
|
+
// Return combined for inline use
|
|
99
|
+
return triggered.join('\n\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Record an event occurrence. May trigger event-based steers on next check().
|
|
104
|
+
*/
|
|
105
|
+
recordEvent(event: string): void {
|
|
106
|
+
this.eventCounts.set(event, (this.eventCounts.get(event) ?? 0) + 1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get all pending messages since last call and clear the queue.
|
|
111
|
+
*/
|
|
112
|
+
getPendingMessages(): string[] {
|
|
113
|
+
const msgs = [...this.pendingMessages];
|
|
114
|
+
this.pendingMessages = [];
|
|
115
|
+
return msgs;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Reset all state for a new cycle. Restarts the timer.
|
|
120
|
+
*/
|
|
121
|
+
reset(): void {
|
|
122
|
+
this.startTime = Date.now();
|
|
123
|
+
this.eventCounts.clear();
|
|
124
|
+
this.firedOnce.clear();
|
|
125
|
+
this.pendingMessages = [];
|
|
126
|
+
this.hardStopTriggered = false;
|
|
127
|
+
this.eventSteerLastFired.clear();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Returns true if an urgent steer with "STOP" in the message has fired.
|
|
132
|
+
*/
|
|
133
|
+
hasHardStop(): boolean {
|
|
134
|
+
return this.hardStopTriggered;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Default steers that ship with every assistant session.
|
|
140
|
+
*/
|
|
141
|
+
export const DEFAULT_STEERS: Steer[] = [
|
|
142
|
+
{
|
|
143
|
+
id: 'direction-check',
|
|
144
|
+
trigger: { type: 'time', afterMs: 300_000 },
|
|
145
|
+
message: "You've been working for 5 minutes. Take a step back — are you sure this is the right approach? If not, pivot now.",
|
|
146
|
+
intensity: 'gentle',
|
|
147
|
+
once: true,
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
id: 'wrap-up',
|
|
151
|
+
trigger: { type: 'time', afterMs: 1_200_000 },
|
|
152
|
+
message: '20 minutes in. Focus on finishing your current change. If tests pass, commit. If not, fix only what\'s broken.',
|
|
153
|
+
intensity: 'firm',
|
|
154
|
+
once: true,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'final-warning',
|
|
158
|
+
trigger: { type: 'time', afterMs: 2_700_000 },
|
|
159
|
+
message: "45 minutes. Commit what works now. Revert anything that doesn't pass tests. Time is almost up.",
|
|
160
|
+
intensity: 'urgent',
|
|
161
|
+
once: true,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
id: 'hard-stop',
|
|
165
|
+
trigger: { type: 'time', afterMs: 3_600_000 },
|
|
166
|
+
message: 'STOP. 60 minutes reached. Commit passing work or revert everything.',
|
|
167
|
+
intensity: 'urgent',
|
|
168
|
+
once: true,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
id: 'repeated-errors',
|
|
172
|
+
trigger: { type: 'event', event: 'tool_error', count: 3 },
|
|
173
|
+
message: "You've hit 3 tool errors. Stop and reconsider your approach. Read the error messages carefully.",
|
|
174
|
+
intensity: 'firm',
|
|
175
|
+
once: false,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'test-failure-reflect',
|
|
179
|
+
trigger: { type: 'event', event: 'test_fail' },
|
|
180
|
+
message: "Tests failed. Don't just retry — think about WHY they failed. Read the error output carefully before making changes.",
|
|
181
|
+
intensity: 'gentle',
|
|
182
|
+
once: false,
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
id: 'write-blocked',
|
|
186
|
+
trigger: { type: 'event', event: 'file_write_blocked', count: 2 },
|
|
187
|
+
message: "File writes are being blocked. You're probably using the wrong path. Use paths relative to the project directory, not absolute paths.",
|
|
188
|
+
intensity: 'firm',
|
|
189
|
+
once: true,
|
|
190
|
+
},
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Additional steers for the improve loop.
|
|
195
|
+
*/
|
|
196
|
+
export const IMPROVE_STEERS: Steer[] = [
|
|
197
|
+
{
|
|
198
|
+
id: 'improve-focus',
|
|
199
|
+
trigger: { type: 'time', afterMs: 120_000 },
|
|
200
|
+
message: "2 minutes in. If you haven't started writing code yet, you're over-analyzing. Pick the simplest fix and start writing.",
|
|
201
|
+
intensity: 'gentle',
|
|
202
|
+
once: true,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: 'improve-commit-ready',
|
|
206
|
+
trigger: { type: 'event', event: 'test_pass' },
|
|
207
|
+
message: 'Tests pass. Commit your changes now unless you have a specific reason to keep going.',
|
|
208
|
+
intensity: 'firm',
|
|
209
|
+
once: false,
|
|
210
|
+
},
|
|
211
|
+
];
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Load steers from .weaver-steers.json, merged with defaults.
|
|
215
|
+
*/
|
|
216
|
+
export function loadSteers(projectDir: string, extras: Steer[] = []): Steer[] {
|
|
217
|
+
const base = [...DEFAULT_STEERS, ...extras];
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const fs = require('node:fs') as typeof import('node:fs');
|
|
221
|
+
const path = require('node:path') as typeof import('node:path');
|
|
222
|
+
const configPath = path.join(projectDir, '.weaver-steers.json');
|
|
223
|
+
if (fs.existsSync(configPath)) {
|
|
224
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
225
|
+
const userSteers: Steer[] = config.steers ?? [];
|
|
226
|
+
// User steers override defaults by ID
|
|
227
|
+
const userIds = new Set(userSteers.map(s => s.id));
|
|
228
|
+
return [...base.filter(s => !userIds.has(s.id)), ...userSteers];
|
|
229
|
+
}
|
|
230
|
+
} catch { /* config not available */ }
|
|
231
|
+
|
|
232
|
+
return base;
|
|
233
|
+
}
|
package/src/bot/step-executor.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { execSync } from 'node:child_process';
|
|
4
4
|
import { runCommand } from '@synergenius/flow-weaver';
|
|
5
|
+
import { BLOCKED_SHELL_PATTERNS } from './safety.js';
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Safety thresholds
|
|
@@ -25,21 +26,8 @@ const MAX_READ_SIZE = 1_048_576;
|
|
|
25
26
|
/** Maximum files returned by list-files. */
|
|
26
27
|
const MAX_LIST_FILES = 1000;
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
/\brm\s+(-[a-z]*r|-[a-z]*f)[a-z]*\s/i, // rm with -r or -f flags
|
|
31
|
-
/\bgit\s+push\b/i, // git push (no remote ops)
|
|
32
|
-
/\bgit\s+reset\s+--hard\b/i, // git reset --hard
|
|
33
|
-
/\bnpm\s+publish\b/i, // npm publish
|
|
34
|
-
/\bcurl\b.*\|\s*(sh|bash)\b/i, // curl | sh/bash
|
|
35
|
-
/\bwget\b.*\|\s*(sh|bash)\b/i, // wget | sh/bash
|
|
36
|
-
/\bsudo\b/i, // sudo
|
|
37
|
-
/\bchmod\s+777\b/i, // chmod 777
|
|
38
|
-
/\bkill\s+-9\b/i, // kill -9
|
|
39
|
-
/\bmkfs\b/i, // format disk
|
|
40
|
-
/\bdd\s+if=/i, // dd (disk destroyer)
|
|
41
|
-
/>\s*\/dev\/sd/i, // write to raw disk
|
|
42
|
-
];
|
|
29
|
+
// Shell blocklist imported from safety.ts — single source of truth.
|
|
30
|
+
// Do NOT add patterns here; add them in safety.ts instead.
|
|
43
31
|
|
|
44
32
|
/** Track files written in this process to enforce the per-plan cap. */
|
|
45
33
|
let filesWrittenThisPlan = 0;
|
|
@@ -55,11 +43,37 @@ export function resetPlanFileCounter(): void {
|
|
|
55
43
|
|
|
56
44
|
function assertSafePath(filePath: string, projectDir: string): void {
|
|
57
45
|
const resolved = path.resolve(projectDir, filePath);
|
|
58
|
-
|
|
46
|
+
const resolvedBase = path.resolve(projectDir);
|
|
47
|
+
if (!resolved.startsWith(resolvedBase + path.sep) && resolved !== resolvedBase) {
|
|
59
48
|
throw new Error(
|
|
60
49
|
`Path traversal blocked: "${filePath}" resolves outside project directory.`,
|
|
61
50
|
);
|
|
62
51
|
}
|
|
52
|
+
|
|
53
|
+
// Symlink protection: resolve real paths for any existing segments.
|
|
54
|
+
// Walk up from the resolved path to find the deepest existing ancestor,
|
|
55
|
+
// then verify its real path is still inside the project directory.
|
|
56
|
+
let checkPath = resolved;
|
|
57
|
+
while (!fs.existsSync(checkPath) && checkPath !== resolvedBase) {
|
|
58
|
+
checkPath = path.dirname(checkPath);
|
|
59
|
+
}
|
|
60
|
+
if (fs.existsSync(checkPath)) {
|
|
61
|
+
try {
|
|
62
|
+
const realPath = fs.realpathSync(checkPath);
|
|
63
|
+
const realBase = fs.realpathSync(resolvedBase);
|
|
64
|
+
if (!realPath.startsWith(realBase + path.sep) && realPath !== realBase) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Path traversal blocked: "${filePath}" resolves outside project directory via symlink.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err instanceof Error && err.message.includes('Path traversal')) throw err;
|
|
71
|
+
// If realpath fails (broken symlink etc), block it
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Path traversal blocked: "${filePath}" could not verify path safety.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
// ---------------------------------------------------------------------------
|
|
@@ -222,6 +236,21 @@ export async function executeStep(
|
|
|
222
236
|
};
|
|
223
237
|
}
|
|
224
238
|
|
|
239
|
+
// Shrink guard: block patches that would reduce file size by >50%
|
|
240
|
+
const originalSize = fileSize;
|
|
241
|
+
const newSize = Buffer.byteLength(content, 'utf-8');
|
|
242
|
+
if (originalSize > SHRINK_GUARD_MIN_SIZE && newSize < originalSize * MAX_SHRINK_RATIO) {
|
|
243
|
+
const shrinkPct = Math.round((1 - newSize / originalSize) * 100);
|
|
244
|
+
return {
|
|
245
|
+
file: filePath,
|
|
246
|
+
blocked: true,
|
|
247
|
+
blockReason:
|
|
248
|
+
`Refusing to patch ${path.basename(filePath)}: result (${newSize}B) ` +
|
|
249
|
+
`is ${shrinkPct}% smaller than original (${originalSize}B). ` +
|
|
250
|
+
`This looks like accidental content removal. Review patches carefully.`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
225
254
|
fs.writeFileSync(filePath, content, 'utf-8');
|
|
226
255
|
filesWrittenThisPlan++;
|
|
227
256
|
|
|
@@ -242,16 +271,21 @@ export async function executeStep(
|
|
|
242
271
|
if (!fs.existsSync(filePath)) {
|
|
243
272
|
return { output: `File not found: ${file}` };
|
|
244
273
|
}
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
274
|
+
try {
|
|
275
|
+
const stat = fs.statSync(filePath);
|
|
276
|
+
if (stat.isDirectory()) {
|
|
277
|
+
const entries = fs.readdirSync(filePath, { encoding: 'utf-8' });
|
|
278
|
+
return { output: `"${file}" is a directory. Contents:\n${entries.join('\n')}` };
|
|
279
|
+
}
|
|
280
|
+
if (stat.size > MAX_READ_SIZE) {
|
|
281
|
+
return { output: `File too large to read (${stat.size} bytes, max ${MAX_READ_SIZE}). Use run-shell with head/tail instead.` };
|
|
282
|
+
}
|
|
283
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
284
|
+
return { file: filePath, output: content };
|
|
285
|
+
} catch (err: unknown) {
|
|
286
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
287
|
+
return { output: `Error reading file "${file}": ${msg}` };
|
|
252
288
|
}
|
|
253
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
254
|
-
return { file: filePath, output: content };
|
|
255
289
|
}
|
|
256
290
|
|
|
257
291
|
// -----------------------------------------------------------------
|
|
@@ -302,7 +336,13 @@ export async function executeStep(
|
|
|
302
336
|
return { output: `Directory not found: ${dir}` };
|
|
303
337
|
}
|
|
304
338
|
|
|
305
|
-
|
|
339
|
+
let entries: string[];
|
|
340
|
+
try {
|
|
341
|
+
entries = fs.readdirSync(targetDir, { recursive: true, encoding: 'utf-8' }) as string[];
|
|
342
|
+
} catch (err: unknown) {
|
|
343
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
344
|
+
return { output: `Error listing directory "${dir}": ${msg}` };
|
|
345
|
+
}
|
|
306
346
|
let files = entries
|
|
307
347
|
.filter(e => {
|
|
308
348
|
if (e.includes('node_modules') || e.includes('.git')) return false;
|
package/src/bot/system-prompt.ts
CHANGED
|
@@ -224,8 +224,9 @@ export async function buildSystemPrompt(): Promise<string> {
|
|
|
224
224
|
docMeta.VALIDATION_CODES ?? [],
|
|
225
225
|
docMeta.CLI_COMMANDS ?? [],
|
|
226
226
|
);
|
|
227
|
-
} catch {
|
|
227
|
+
} catch (err) {
|
|
228
228
|
// Fallback if doc-metadata not available (e.g., older flow-weaver version)
|
|
229
|
+
console.warn('Failed to load doc-metadata for system prompt; using empty fallback:', err);
|
|
229
230
|
cachedPrompt = buildPromptFromMetadata([], [], [], [], []);
|
|
230
231
|
}
|
|
231
232
|
|
|
@@ -292,7 +293,9 @@ Be concise in your text responses — let tool results speak.`;
|
|
|
292
293
|
const plan = fs.readFileSync(planPath, 'utf-8').trim();
|
|
293
294
|
prompt += '\n\n## Project Plan & Vision\n\nIMPORTANT: All work MUST align with this plan. If a task contradicts the plan, skip it and explain why.\n\n' + plan;
|
|
294
295
|
}
|
|
295
|
-
} catch {
|
|
296
|
+
} catch (err) {
|
|
297
|
+
console.warn('Failed to load project plan file:', err);
|
|
298
|
+
}
|
|
296
299
|
}
|
|
297
300
|
|
|
298
301
|
if (contextBundle) {
|
package/src/bot/task-queue.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface QueuedTask {
|
|
|
14
14
|
priority: number;
|
|
15
15
|
addedAt: number;
|
|
16
16
|
status: 'pending' | 'running' | 'completed' | 'no-op' | 'failed' | 'cancelled';
|
|
17
|
+
/** PID of the process running this task (set when status becomes 'running'). */
|
|
18
|
+
runnerId?: number;
|
|
17
19
|
/** Error reason (set on failure) */
|
|
18
20
|
failureReason?: string;
|
|
19
21
|
}
|
|
@@ -57,7 +59,7 @@ export class TaskQueue {
|
|
|
57
59
|
// Queue size cap
|
|
58
60
|
const pendingCount = existing.filter(t => t.status === 'pending').length;
|
|
59
61
|
if (pendingCount >= MAX_PENDING) {
|
|
60
|
-
throw new Error(`Queue full (${MAX_PENDING} pending tasks).
|
|
62
|
+
throw new Error(`Queue full (${MAX_PENDING} pending tasks). Use queue_retry to resume failed tasks, or queue_list to review.`);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
const entry: QueuedTask = {
|
|
@@ -105,7 +107,15 @@ export class TaskQueue {
|
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
async markRunning(id: string): Promise<void> {
|
|
108
|
-
|
|
110
|
+
return withFileLock(this.filePath, () => {
|
|
111
|
+
const tasks = this.readAll();
|
|
112
|
+
const task = tasks.find(t => t.id === id);
|
|
113
|
+
if (task) {
|
|
114
|
+
task.status = 'running';
|
|
115
|
+
task.runnerId = process.pid;
|
|
116
|
+
this.writeAll(tasks);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
async markComplete(id: string): Promise<void> {
|
|
@@ -158,14 +168,22 @@ export class TaskQueue {
|
|
|
158
168
|
});
|
|
159
169
|
}
|
|
160
170
|
|
|
161
|
-
/** Reset orphaned "running" tasks to pending (crash recovery).
|
|
171
|
+
/** Reset orphaned "running" tasks to pending (crash recovery).
|
|
172
|
+
* Only resets tasks whose runner PID is no longer alive. */
|
|
162
173
|
async recoverOrphans(): Promise<number> {
|
|
163
174
|
return withFileLock(this.filePath, () => {
|
|
164
175
|
const tasks = this.readAll();
|
|
165
176
|
let count = 0;
|
|
166
177
|
for (const t of tasks) {
|
|
167
178
|
if (t.status === 'running') {
|
|
179
|
+
// Check if the runner process is still alive
|
|
180
|
+
if (t.runnerId != null) {
|
|
181
|
+
let alive = false;
|
|
182
|
+
try { process.kill(t.runnerId, 0); alive = true; } catch { /* process gone */ }
|
|
183
|
+
if (alive) continue; // skip — process is still working on this task
|
|
184
|
+
}
|
|
168
185
|
t.status = 'pending';
|
|
186
|
+
t.runnerId = undefined;
|
|
169
187
|
count++;
|
|
170
188
|
}
|
|
171
189
|
}
|
|
@@ -193,9 +211,27 @@ export class TaskQueue {
|
|
|
193
211
|
return records;
|
|
194
212
|
}
|
|
195
213
|
|
|
214
|
+
/** Atomically claim the next pending task: selects highest-priority and marks it running in one lock. */
|
|
215
|
+
async claimNext(): Promise<QueuedTask | null> {
|
|
216
|
+
return withFileLock(this.filePath, () => {
|
|
217
|
+
const tasks = this.readAll();
|
|
218
|
+
const pending = tasks.filter(t => t.status === 'pending');
|
|
219
|
+
pending.sort((a, b) => b.priority - a.priority || a.addedAt - b.addedAt);
|
|
220
|
+
const chosen = pending[0];
|
|
221
|
+
if (!chosen) return null;
|
|
222
|
+
chosen.status = 'running';
|
|
223
|
+
chosen.runnerId = process.pid;
|
|
224
|
+
this.writeAll(tasks);
|
|
225
|
+
return chosen;
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
196
229
|
private writeAll(tasks: QueuedTask[]): void {
|
|
197
230
|
const dir = path.dirname(this.filePath);
|
|
198
231
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
199
|
-
|
|
232
|
+
// Atomic write: write to temp file then rename to avoid partial reads on crash
|
|
233
|
+
const tmpPath = this.filePath + '.tmp.' + process.pid;
|
|
234
|
+
fs.writeFileSync(tmpPath, tasks.map(t => JSON.stringify(t)).join('\n') + (tasks.length > 0 ? '\n' : ''), 'utf-8');
|
|
235
|
+
fs.renameSync(tmpPath, this.filePath);
|
|
200
236
|
}
|
|
201
237
|
}
|