kc-beta 0.7.3 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -4
- package/bin/kc-beta.js +20 -6
- package/package.json +3 -2
- package/src/agent/engine.js +493 -132
- package/src/agent/pipelines/_advance-hints.js +92 -0
- package/src/agent/pipelines/_milestone-derive.js +387 -17
- package/src/agent/pipelines/initializer.js +4 -1
- package/src/agent/pipelines/skill-authoring.js +30 -1
- package/src/agent/skill-loader.js +433 -111
- package/src/agent/tools/agent-tool.js +2 -2
- package/src/agent/tools/consult-skill.js +127 -0
- package/src/agent/tools/copy-to-workspace.js +4 -3
- package/src/agent/tools/dashboard-render.js +48 -1
- package/src/agent/tools/document-parse.js +31 -2
- package/src/agent/tools/phase-advance.js +17 -13
- package/src/agent/tools/release.js +378 -8
- package/src/agent/tools/sandbox-exec.js +65 -8
- package/src/agent/tools/worker-llm-call.js +95 -15
- package/src/agent/tools/workspace-file.js +7 -7
- package/src/agent/workspace.js +25 -4
- package/src/cli/components.js +4 -1
- package/src/cli/index.js +97 -1
- package/src/config.js +20 -3
- package/src/marathon/driver.js +217 -0
- package/src/marathon/prompts.js +93 -0
- package/template/.env.template +16 -0
- package/template/AGENT.md +182 -7
- package/template/skills/en/{meta-meta/auto-model-selection → auto-model-selection}/SKILL.md +1 -0
- package/template/skills/en/{meta-meta/bootstrap-workspace → bootstrap-workspace}/SKILL.md +15 -0
- package/template/skills/{zh/meta → en}/compliance-judgment/SKILL.md +1 -0
- package/template/skills/en/{meta/confidence-system → confidence-system}/SKILL.md +1 -0
- package/template/skills/en/{meta/corner-case-management → corner-case-management}/SKILL.md +1 -0
- package/template/skills/en/{meta/cross-document-verification → cross-document-verification}/SKILL.md +1 -0
- package/template/skills/en/{meta-meta/dashboard-reporting → dashboard-reporting}/SKILL.md +1 -0
- package/template/skills/en/{meta/data-sensibility → data-sensibility}/SKILL.md +1 -0
- package/template/skills/{zh/meta → en}/document-chunking/SKILL.md +1 -0
- package/template/skills/en/{meta/document-parsing → document-parsing}/SKILL.md +1 -0
- package/template/skills/{zh/meta → en}/entity-extraction/SKILL.md +1 -0
- package/template/skills/en/{meta-meta/evolution-loop → evolution-loop}/SKILL.md +1 -0
- package/template/skills/en/{meta-meta/pdf-review-dashboard → pdf-review-dashboard}/SKILL.md +1 -0
- package/template/skills/en/{meta-meta/quality-control → quality-control}/SKILL.md +10 -0
- package/template/skills/en/{meta-meta/rule-extraction → rule-extraction}/SKILL.md +1 -0
- package/template/skills/en/{meta-meta/rule-graph → rule-graph}/SKILL.md +1 -0
- package/template/skills/en/{meta-meta/skill-authoring → skill-authoring}/SKILL.md +40 -0
- package/template/skills/en/skill-creator/SKILL.md +2 -1
- package/template/skills/en/{meta-meta/skill-to-workflow → skill-to-workflow}/SKILL.md +58 -4
- package/template/skills/en/{meta-meta/task-decomposition → task-decomposition}/SKILL.md +1 -0
- package/template/skills/en/{meta/tree-processing → tree-processing}/SKILL.md +1 -0
- package/template/skills/en/{meta-meta/version-control → version-control}/SKILL.md +1 -0
- package/template/skills/en/{meta-meta/work-decomposition → work-decomposition}/SKILL.md +51 -6
- package/template/skills/phase_skills.yaml +112 -0
- package/template/skills/zh/{meta-meta/auto-model-selection → auto-model-selection}/SKILL.md +1 -0
- package/template/skills/zh/{meta-meta/bootstrap-workspace → bootstrap-workspace}/SKILL.md +15 -0
- package/template/skills/zh/compliance-judgment/SKILL.md +83 -0
- package/template/skills/zh/{meta/confidence-system → confidence-system}/SKILL.md +1 -0
- package/template/skills/zh/{meta/corner-case-management → corner-case-management}/SKILL.md +1 -0
- package/template/skills/zh/{meta/cross-document-verification → cross-document-verification}/SKILL.md +1 -0
- package/template/skills/zh/{meta-meta/dashboard-reporting → dashboard-reporting}/SKILL.md +1 -0
- package/template/skills/zh/{meta/data-sensibility → data-sensibility}/SKILL.md +1 -0
- package/template/skills/zh/document-chunking/SKILL.md +40 -0
- package/template/skills/zh/document-parsing/SKILL.md +102 -0
- package/template/skills/zh/entity-extraction/SKILL.md +121 -0
- package/template/skills/zh/{meta-meta/evolution-loop → evolution-loop}/SKILL.md +1 -0
- package/template/skills/zh/{meta-meta/pdf-review-dashboard → pdf-review-dashboard}/SKILL.md +1 -0
- package/template/skills/zh/{meta-meta/quality-control → quality-control}/SKILL.md +10 -0
- package/template/skills/zh/{meta-meta/rule-extraction → rule-extraction}/SKILL.md +1 -0
- package/template/skills/zh/{meta-meta/rule-graph → rule-graph}/SKILL.md +1 -0
- package/template/skills/zh/{meta-meta/skill-authoring → skill-authoring}/SKILL.md +40 -0
- package/template/skills/zh/skill-creator/SKILL.md +205 -200
- package/template/skills/zh/skill-to-workflow/SKILL.md +243 -0
- package/template/skills/zh/{meta-meta/task-decomposition → task-decomposition}/SKILL.md +1 -0
- package/template/skills/zh/tree-processing/SKILL.md +126 -0
- package/template/skills/zh/{meta-meta/version-control → version-control}/SKILL.md +1 -0
- package/template/skills/zh/{meta-meta/work-decomposition → work-decomposition}/SKILL.md +49 -4
- package/template/workflows/common/llm_client.py +168 -0
- package/template/workflows/common/utils.py +132 -0
- package/template/CLAUDE.md +0 -150
- package/template/skills/en/meta/compliance-judgment/SKILL.md +0 -82
- package/template/skills/en/meta/document-chunking/SKILL.md +0 -32
- package/template/skills/en/meta/entity-extraction/SKILL.md +0 -120
- package/template/skills/zh/meta/document-parsing/SKILL.md +0 -101
- package/template/skills/zh/meta/tree-processing/SKILL.md +0 -121
- package/template/skills/zh/meta-meta/skill-to-workflow/SKILL.md +0 -188
- /package/template/skills/en/{meta/compliance-judgment → compliance-judgment}/references/output-format.md +0 -0
- /package/template/skills/en/{meta/cross-document-verification → cross-document-verification}/references/contradiction-taxonomy.md +0 -0
- /package/template/skills/en/{meta-meta/dashboard-reporting → dashboard-reporting}/scripts/generate_dashboard.py +0 -0
- /package/template/skills/en/{meta/document-parsing → document-parsing}/references/parser-catalog.md +0 -0
- /package/template/skills/en/{meta-meta/evolution-loop → evolution-loop}/references/convergence-guide.md +0 -0
- /package/template/skills/en/{meta-meta/pdf-review-dashboard → pdf-review-dashboard}/scripts/generate_review.js +0 -0
- /package/template/skills/en/{meta-meta/quality-control → quality-control}/references/qa-layers.md +0 -0
- /package/template/skills/en/{meta-meta/quality-control → quality-control}/references/sampling-strategies.md +0 -0
- /package/template/skills/en/{meta-meta/rule-extraction → rule-extraction}/references/chunking-strategies.md +0 -0
- /package/template/skills/en/{meta-meta/skill-authoring → skill-authoring}/references/skill-format-spec.md +0 -0
- /package/template/skills/en/{meta-meta/skill-to-workflow → skill-to-workflow}/references/worker-llm-catalog.md +0 -0
- /package/template/skills/en/{meta-meta/task-decomposition → task-decomposition}/references/decision-matrix.md +0 -0
- /package/template/skills/en/{meta-meta/version-control → version-control}/references/trace-id-spec.md +0 -0
- /package/template/skills/zh/{meta/compliance-judgment → compliance-judgment}/references/output-format.md +0 -0
- /package/template/skills/zh/{meta/cross-document-verification → cross-document-verification}/references/contradiction-taxonomy.md +0 -0
- /package/template/skills/zh/{meta-meta/dashboard-reporting → dashboard-reporting}/scripts/generate_dashboard.py +0 -0
- /package/template/skills/zh/{meta/document-parsing → document-parsing}/references/parser-catalog.md +0 -0
- /package/template/skills/zh/{meta-meta/evolution-loop → evolution-loop}/references/convergence-guide.md +0 -0
- /package/template/skills/zh/{meta-meta/pdf-review-dashboard → pdf-review-dashboard}/scripts/generate_review.js +0 -0
- /package/template/skills/zh/{meta-meta/quality-control → quality-control}/references/qa-layers.md +0 -0
- /package/template/skills/zh/{meta-meta/quality-control → quality-control}/references/sampling-strategies.md +0 -0
- /package/template/skills/zh/{meta-meta/rule-extraction → rule-extraction}/references/chunking-strategies.md +0 -0
- /package/template/skills/zh/{meta-meta/skill-authoring → skill-authoring}/references/skill-format-spec.md +0 -0
- /package/template/skills/zh/{meta-meta/skill-to-workflow → skill-to-workflow}/references/worker-llm-catalog.md +0 -0
- /package/template/skills/zh/{meta-meta/task-decomposition → task-decomposition}/references/decision-matrix.md +0 -0
- /package/template/skills/zh/{meta-meta/version-control → version-control}/references/trace-id-spec.md +0 -0
|
@@ -49,7 +49,10 @@ export class WorkerLLMCallTool extends BaseTool {
|
|
|
49
49
|
return (
|
|
50
50
|
"Call a worker LLM at a specified tier (tier1-tier4) for extraction, " +
|
|
51
51
|
"judgment, or other verification tasks. Tier1 is most capable/expensive, " +
|
|
52
|
-
"tier4 is cheapest.
|
|
52
|
+
"tier4 is cheapest. Pass `prompt` for a single call OR `prompts: [...]` " +
|
|
53
|
+
"for batch (parallel up to concurrency=5). Returns response(s) with " +
|
|
54
|
+
"model used and token counts. v0.8 P2-B: batch mode keeps the engine " +
|
|
55
|
+
"visible to LLM usage instead of agents bypassing via direct HTTP."
|
|
53
56
|
);
|
|
54
57
|
}
|
|
55
58
|
|
|
@@ -58,29 +61,105 @@ export class WorkerLLMCallTool extends BaseTool {
|
|
|
58
61
|
type: "object",
|
|
59
62
|
properties: {
|
|
60
63
|
tier: { type: "string", enum: ["tier1", "tier2", "tier3", "tier4"], description: "Worker LLM tier to use" },
|
|
61
|
-
prompt: { type: "string", description: "The user/task prompt to send" },
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
prompt: { type: "string", description: "The user/task prompt to send (single-call mode)" },
|
|
65
|
+
prompts: {
|
|
66
|
+
type: "array",
|
|
67
|
+
items: { type: "string" },
|
|
68
|
+
description: "Batch mode: array of prompts processed in parallel (up to concurrency=5). All share the same tier + system_prompt. Mutually exclusive with `prompt`.",
|
|
69
|
+
},
|
|
70
|
+
system_prompt: { type: "string", description: "Optional system prompt for context (shared across all prompts in batch mode)" },
|
|
71
|
+
max_tokens: { type: "integer", description: "Maximum tokens per response (default 4096)" },
|
|
72
|
+
concurrency: { type: "integer", description: "Batch mode only: max parallel requests (default 5, max 10)" },
|
|
64
73
|
},
|
|
65
|
-
required: ["tier"
|
|
74
|
+
required: ["tier"],
|
|
66
75
|
};
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
async execute(input) {
|
|
70
79
|
const tier = input.tier || "tier2";
|
|
71
|
-
const prompt = input.prompt || "";
|
|
72
80
|
const systemPrompt = input.system_prompt;
|
|
73
81
|
const maxTokens = input.max_tokens || 4096;
|
|
74
82
|
|
|
75
|
-
if (!prompt) return new ToolResult("No prompt provided", true);
|
|
76
83
|
if (!this._apiKey) return new ToolResult("Worker LLM API key not configured", true);
|
|
77
84
|
|
|
85
|
+
// v0.8 P2-B: batch mode dispatch
|
|
86
|
+
if (Array.isArray(input.prompts)) {
|
|
87
|
+
return this._executeBatch(input.prompts, { tier, systemPrompt, maxTokens, concurrency: input.concurrency });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const prompt = input.prompt || "";
|
|
91
|
+
if (!prompt) return new ToolResult("No prompt provided (pass `prompt` for single-call or `prompts: [...]` for batch)", true);
|
|
92
|
+
|
|
93
|
+
const result = await this._executeOne({ prompt, tier, systemPrompt, maxTokens });
|
|
94
|
+
if (result.error) return new ToolResult(result.error, true);
|
|
95
|
+
return new ToolResult(JSON.stringify(result.payload, null, 2));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* v0.8 P2-B: process N prompts in parallel with concurrency control.
|
|
100
|
+
* Returns aggregated results as a JSON array under "results" with
|
|
101
|
+
* summary stats (total_in, total_out, n_failed). Partial failures don't
|
|
102
|
+
* fail the whole call — individual results carry their own error flag.
|
|
103
|
+
*/
|
|
104
|
+
async _executeBatch(prompts, { tier, systemPrompt, maxTokens, concurrency }) {
|
|
105
|
+
if (prompts.length === 0) return new ToolResult("Empty prompts array", true);
|
|
78
106
|
this._loadTiers();
|
|
79
107
|
const models = this._tierModels[tier] || [];
|
|
80
108
|
if (models.length === 0) {
|
|
81
109
|
return new ToolResult(`No models configured for ${tier}. Check .env TIER1-TIER4 settings.`, true);
|
|
82
110
|
}
|
|
83
111
|
|
|
112
|
+
const limit = Math.max(1, Math.min(10, Number.isFinite(concurrency) ? concurrency : 5));
|
|
113
|
+
const results = new Array(prompts.length);
|
|
114
|
+
let cursor = 0;
|
|
115
|
+
let tokensIn = 0;
|
|
116
|
+
let tokensOut = 0;
|
|
117
|
+
let nFailed = 0;
|
|
118
|
+
|
|
119
|
+
const worker = async () => {
|
|
120
|
+
while (true) {
|
|
121
|
+
const idx = cursor++;
|
|
122
|
+
if (idx >= prompts.length) break;
|
|
123
|
+
const r = await this._executeOne({ prompt: prompts[idx], tier, systemPrompt, maxTokens });
|
|
124
|
+
if (r.error) {
|
|
125
|
+
results[idx] = { index: idx, error: r.error };
|
|
126
|
+
nFailed++;
|
|
127
|
+
} else {
|
|
128
|
+
results[idx] = { index: idx, ...r.payload };
|
|
129
|
+
tokensIn += r.payload.tokens_in || 0;
|
|
130
|
+
tokensOut += r.payload.tokens_out || 0;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
await Promise.all(Array.from({ length: limit }, () => worker()));
|
|
136
|
+
|
|
137
|
+
const summary = {
|
|
138
|
+
n_total: prompts.length,
|
|
139
|
+
n_succeeded: prompts.length - nFailed,
|
|
140
|
+
n_failed: nFailed,
|
|
141
|
+
total_tokens_in: tokensIn,
|
|
142
|
+
total_tokens_out: tokensOut,
|
|
143
|
+
tier,
|
|
144
|
+
concurrency: limit,
|
|
145
|
+
results,
|
|
146
|
+
};
|
|
147
|
+
return new ToolResult(JSON.stringify(summary, null, 2), nFailed > 0 && nFailed === prompts.length);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Single-prompt path. Returns {error?: string, payload?: {...}}.
|
|
152
|
+
* Used by both single-call and batch modes; batch dedups the tier
|
|
153
|
+
* lookup and shares concurrency with multiple in-flight invocations.
|
|
154
|
+
*/
|
|
155
|
+
async _executeOne({ prompt, tier, systemPrompt, maxTokens }) {
|
|
156
|
+
if (!prompt) return { error: "Empty prompt" };
|
|
157
|
+
this._loadTiers();
|
|
158
|
+
const models = this._tierModels[tier] || [];
|
|
159
|
+
if (models.length === 0) {
|
|
160
|
+
return { error: `No models configured for ${tier}. Check .env TIER1-TIER4 settings.` };
|
|
161
|
+
}
|
|
162
|
+
|
|
84
163
|
const messages = [];
|
|
85
164
|
if (systemPrompt) messages.push({ role: "system", content: systemPrompt });
|
|
86
165
|
messages.push({ role: "user", content: prompt });
|
|
@@ -98,14 +177,15 @@ export class WorkerLLMCallTool extends BaseTool {
|
|
|
98
177
|
if (resp.ok) {
|
|
99
178
|
const data = await resp.json();
|
|
100
179
|
const usage = data.usage || {};
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
180
|
+
return {
|
|
181
|
+
payload: {
|
|
182
|
+
response: data.choices[0].message.content,
|
|
183
|
+
model_used: model,
|
|
184
|
+
tier,
|
|
185
|
+
tokens_in: usage.prompt_tokens || 0,
|
|
186
|
+
tokens_out: usage.completion_tokens || 0,
|
|
187
|
+
},
|
|
107
188
|
};
|
|
108
|
-
return new ToolResult(JSON.stringify(result, null, 2));
|
|
109
189
|
}
|
|
110
190
|
lastError = `${model}: HTTP ${resp.status}`;
|
|
111
191
|
} catch (e) {
|
|
@@ -113,6 +193,6 @@ export class WorkerLLMCallTool extends BaseTool {
|
|
|
113
193
|
}
|
|
114
194
|
}
|
|
115
195
|
|
|
116
|
-
return
|
|
196
|
+
return { error: `All models for ${tier} failed. Last error: ${lastError}` };
|
|
117
197
|
}
|
|
118
198
|
}
|
|
@@ -30,9 +30,7 @@ export class WorkspaceFileTool extends BaseTool {
|
|
|
30
30
|
"Read, write, or list files. " +
|
|
31
31
|
"scope='workspace' (default): KC's working directory for rules, skills, workflows, results. " +
|
|
32
32
|
"scope='project': the user's project folder where KC was launched — source regulations and samples live here. " +
|
|
33
|
-
"Operations: read (returns file content), write (creates/overwrites a file), list (shows directory contents).
|
|
34
|
-
"read returns up to 50,000 chars per call; longer files are truncated. " +
|
|
35
|
-
"For full reads of regulation/rule documents (typically smaller than this cap), prefer this tool over sandbox_exec."
|
|
33
|
+
"Operations: read (returns file content), write (creates/overwrites a file), list (shows directory contents)."
|
|
36
34
|
);
|
|
37
35
|
}
|
|
38
36
|
|
|
@@ -163,10 +161,12 @@ export class WorkspaceFileTool extends BaseTool {
|
|
|
163
161
|
return new ToolResult(msg);
|
|
164
162
|
};
|
|
165
163
|
|
|
166
|
-
// v0.7.3: route writes to shared
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
164
|
+
// v0.7.4 (re-applied from v0.7.3 G1a): route writes to shared
|
|
165
|
+
// coordination paths (rules/catalog.json, tasks.json,
|
|
166
|
+
// refs/manifest.json, etc.) through the workspace lock so
|
|
167
|
+
// concurrent writers serialize. No-op for non-shared paths and
|
|
168
|
+
// for project-scope writes (project dir is the user's, not
|
|
169
|
+
// shared engine state).
|
|
170
170
|
if (scope === "workspace") {
|
|
171
171
|
return await this._workspace.withSharedLockIfApplicable(filePath, doWrite);
|
|
172
172
|
}
|
package/src/agent/workspace.js
CHANGED
|
@@ -170,11 +170,12 @@ export class Workspace {
|
|
|
170
170
|
* @param {{timeoutMs?: number, retryMs?: number, staleMs?: number}} [opts]
|
|
171
171
|
* @returns {Promise<T>}
|
|
172
172
|
*/
|
|
173
|
-
async withFileLock(relPath, fn, { timeoutMs = 10_000, retryMs = 50, staleMs = 60_000 } = {}) {
|
|
173
|
+
async withFileLock(relPath, fn, { timeoutMs = 10_000, retryMs = 50, staleMs = 60_000, eventLog = null, blockedWarnMs = 5_000 } = {}) {
|
|
174
174
|
const target = this.resolvePath(relPath);
|
|
175
175
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
176
176
|
const lockPath = target + ".lock";
|
|
177
177
|
const start = Date.now();
|
|
178
|
+
let blockedWarned = false;
|
|
178
179
|
|
|
179
180
|
while (true) {
|
|
180
181
|
let fd;
|
|
@@ -193,7 +194,24 @@ export class Workspace {
|
|
|
193
194
|
// Lockfile vanished between EEXIST and stat — retry to acquire.
|
|
194
195
|
continue;
|
|
195
196
|
}
|
|
196
|
-
|
|
197
|
+
// v0.8 P4-C: emit lock_blocked event once when wait crosses
|
|
198
|
+
// blockedWarnMs (default 5s). Lets parent see subagent contention
|
|
199
|
+
// before the call fails. 贷款 v0.7.5 audit: subagent burned 5 min
|
|
200
|
+
// on silent lock contention; parent only saw it as a long-running
|
|
201
|
+
// subagent. Now there's a visible signal.
|
|
202
|
+
const waited = Date.now() - start;
|
|
203
|
+
if (!blockedWarned && waited > blockedWarnMs && eventLog?.append) {
|
|
204
|
+
try {
|
|
205
|
+
eventLog.append("lock_blocked", {
|
|
206
|
+
path: relPath,
|
|
207
|
+
waited_ms: waited,
|
|
208
|
+
session_id: this.sessionId,
|
|
209
|
+
pid: process.pid,
|
|
210
|
+
});
|
|
211
|
+
} catch { /* best-effort */ }
|
|
212
|
+
blockedWarned = true;
|
|
213
|
+
}
|
|
214
|
+
if (waited > timeoutMs) {
|
|
197
215
|
throw new Error(`Timeout acquiring lock on ${relPath} after ${timeoutMs}ms (held by another engine)`);
|
|
198
216
|
}
|
|
199
217
|
await new Promise((r) => setTimeout(r, retryMs));
|
|
@@ -221,8 +239,11 @@ export class Workspace {
|
|
|
221
239
|
* Lets callsites uniformly wrap their writes without knowing which
|
|
222
240
|
* paths are shared.
|
|
223
241
|
*/
|
|
224
|
-
async withSharedLockIfApplicable(relPath, fn) {
|
|
225
|
-
|
|
242
|
+
async withSharedLockIfApplicable(relPath, fn, opts = {}) {
|
|
243
|
+
// v0.8 P4-C: forward optional {eventLog, ...} through to withFileLock
|
|
244
|
+
// so lock_blocked events can fire from any call site (workspace_file,
|
|
245
|
+
// rule_catalog, etc.) once they pass their engine's eventLog.
|
|
246
|
+
if (isSharedCoordinationPath(relPath)) return this.withFileLock(relPath, fn, opts);
|
|
226
247
|
return fn();
|
|
227
248
|
}
|
|
228
249
|
|
package/src/cli/components.js
CHANGED
|
@@ -89,7 +89,7 @@ function truncateVisual(s, maxCells) {
|
|
|
89
89
|
return head + "…" + tail;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export function StatusBar({ sessionId, phase, contextTokens, contextLimit }) {
|
|
92
|
+
export function StatusBar({ sessionId, phase, contextTokens, contextLimit, marathonActive }) {
|
|
93
93
|
const samplesRef = useRef([]);
|
|
94
94
|
const peakRef = useRef(0);
|
|
95
95
|
|
|
@@ -136,6 +136,9 @@ export function StatusBar({ sessionId, phase, contextTokens, contextLimit }) {
|
|
|
136
136
|
h(Text, { dimColor: true, wrap: "truncate-end" }, " ⏵⏵ KC "),
|
|
137
137
|
h(Text, { dimColor: true, wrap: "truncate-end" }, displaySessionId ? `[${displaySessionId}]` : ""),
|
|
138
138
|
phase ? h(Text, { color: "cyan", wrap: "truncate-end" }, ` ${phase.toUpperCase()}`) : null,
|
|
139
|
+
// v0.8.1 P8-A: marathon-mode indicator. Only renders when active —
|
|
140
|
+
// normal interactive mode shows no indicator (avoid clutter).
|
|
141
|
+
marathonActive ? h(Text, { color: "magenta", bold: true, wrap: "truncate-end" }, " 🏃 MARATHON") : null,
|
|
139
142
|
h(Text, { color: "green", wrap: "truncate-end" }, " ● "),
|
|
140
143
|
h(Text, { color: ctxColor, wrap: "truncate-end" }, `CTX: ${ctxLabel}/${limitLabel} (${pct}%)`),
|
|
141
144
|
showPeak ? h(Text, { dimColor: true, wrap: "truncate-end" }, ` · peak ${fmt(peak)}`) : null,
|
package/src/cli/index.js
CHANGED
|
@@ -59,6 +59,8 @@ function App({ engine, config }) {
|
|
|
59
59
|
const [spinnerStatus, setSpinnerStatus] = useState(null);
|
|
60
60
|
const [contextTokens, setContextTokens] = useState(0);
|
|
61
61
|
const [contextLimit, setContextLimit] = useState(config.kcContextLimit || 200000);
|
|
62
|
+
// v0.8.1 P8-A: marathon-mode indicator for StatusBar.
|
|
63
|
+
const [marathonActive, setMarathonActive] = useState(false);
|
|
62
64
|
const [taskList, setTaskList] = useState([]);
|
|
63
65
|
const [taskProgress, setTaskProgress] = useState(null);
|
|
64
66
|
|
|
@@ -124,6 +126,11 @@ function App({ engine, config }) {
|
|
|
124
126
|
setCurrentTool(null);
|
|
125
127
|
setSpinnerStatus(null);
|
|
126
128
|
updateContextStats();
|
|
129
|
+
// v0.8.1 P8-A: refresh marathon indicator. If the driver
|
|
130
|
+
// self-terminated (max_wallclock / finalization_settled),
|
|
131
|
+
// engine clears marathonDriver on next decideNext loop;
|
|
132
|
+
// we sync the TUI state here.
|
|
133
|
+
setMarathonActive(engineRef.current.isMarathonActive());
|
|
127
134
|
break;
|
|
128
135
|
|
|
129
136
|
case "tool_start":
|
|
@@ -221,6 +228,9 @@ function App({ engine, config }) {
|
|
|
221
228
|
" /sessions List all sessions\n" +
|
|
222
229
|
" /resume <name> Resume a previous session\n" +
|
|
223
230
|
" /rename <name> Rename current session\n" +
|
|
231
|
+
" /marathon <goal> Activate marathon mode (chains turns automatically)\n" +
|
|
232
|
+
" /marathon off Deactivate marathon (return to interactive)\n" +
|
|
233
|
+
" /marathon status Show marathon driver state\n" +
|
|
224
234
|
" /exit Quit",
|
|
225
235
|
});
|
|
226
236
|
return true;
|
|
@@ -593,6 +603,84 @@ function App({ engine, config }) {
|
|
|
593
603
|
}
|
|
594
604
|
return true;
|
|
595
605
|
|
|
606
|
+
case "/marathon": {
|
|
607
|
+
// v0.8.1 P8-A: inline marathon mode. `/marathon <goal>` activates;
|
|
608
|
+
// `/marathon off` deactivates; `/marathon status` shows snapshot.
|
|
609
|
+
const sub = arg.split(/\s+/)[0]?.toLowerCase();
|
|
610
|
+
if (sub === "off" || sub === "stop") {
|
|
611
|
+
const final = engineRef.current.exitMarathonMode("user_off");
|
|
612
|
+
setMarathonActive(false);
|
|
613
|
+
if (final) {
|
|
614
|
+
addMessage({
|
|
615
|
+
role: "system",
|
|
616
|
+
content: `Marathon mode OFF.\n decisions: ${final.decisionCount}\n runtime: ${Math.round(final.runtimeMs / 1000)}s\n last phase: ${final.currentPhase}`,
|
|
617
|
+
});
|
|
618
|
+
} else {
|
|
619
|
+
addMessage({ role: "system", content: "Marathon was not active." });
|
|
620
|
+
}
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
if (sub === "status") {
|
|
624
|
+
if (!engineRef.current.isMarathonActive()) {
|
|
625
|
+
addMessage({ role: "system", content: "Marathon mode is OFF." });
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
const s = engineRef.current.marathonDriver.getStatus();
|
|
629
|
+
const lines = [
|
|
630
|
+
`Marathon mode ON`,
|
|
631
|
+
` goal: ${s.goal.slice(0, 100)}${s.goal.length > 100 ? "..." : ""}`,
|
|
632
|
+
` language: ${s.language}`,
|
|
633
|
+
` started: ${s.startedAt} (${Math.round(s.runtimeMs / 60000)} min ago)`,
|
|
634
|
+
` current_phase: ${s.currentPhase}`,
|
|
635
|
+
` turns this phase: ${s.turnsThisPhase}`,
|
|
636
|
+
` total decisions: ${s.decisionCount}`,
|
|
637
|
+
];
|
|
638
|
+
if (s.recentDecisions?.length) {
|
|
639
|
+
lines.push(` recent decisions:`);
|
|
640
|
+
for (const d of s.recentDecisions.slice(-3)) {
|
|
641
|
+
lines.push(` ${d.ts.slice(11, 19)} [${d.template}] ${d.reason}`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
addMessage({ role: "system", content: lines.join("\n") });
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
// `/marathon <goal>` — activate
|
|
648
|
+
if (!arg) {
|
|
649
|
+
addMessage({
|
|
650
|
+
role: "system",
|
|
651
|
+
content:
|
|
652
|
+
"Usage:\n" +
|
|
653
|
+
" /marathon <goal description> Activate marathon mode with the given goal\n" +
|
|
654
|
+
" /marathon off Deactivate (return to interactive)\n" +
|
|
655
|
+
" /marathon status Show current driver state\n\n" +
|
|
656
|
+
"Marathon mode chains turns automatically using templated continuation prompts.\n" +
|
|
657
|
+
"F5 strict one-phase-per-prompt is bypassed while active. /resume after a crash\n" +
|
|
658
|
+
"does NOT auto-restore marathon — re-type /marathon to re-engage.",
|
|
659
|
+
});
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
try {
|
|
663
|
+
const status = engineRef.current.enterMarathonMode(arg);
|
|
664
|
+
setMarathonActive(true);
|
|
665
|
+
addMessage({
|
|
666
|
+
role: "system",
|
|
667
|
+
content:
|
|
668
|
+
`🏃 Marathon mode ON.\n` +
|
|
669
|
+
` goal: ${arg.slice(0, 200)}${arg.length > 200 ? "..." : ""}\n` +
|
|
670
|
+
` language: ${status.language}\n` +
|
|
671
|
+
` stop conditions: ${Math.round(status.maxWallclockMs / 3600000)}h wall-clock OR 5 turns settled in finalization\n\n` +
|
|
672
|
+
`Next turn will use the marathon initial prompt. Type /marathon off to disengage.`,
|
|
673
|
+
});
|
|
674
|
+
// Immediately trigger a turn with the initial prompt
|
|
675
|
+
const initialPrompt = engineRef.current.marathonDriver.getInitialPrompt();
|
|
676
|
+
// Hand the initial prompt to the same runTurn path as a user message
|
|
677
|
+
runTurn(initialPrompt);
|
|
678
|
+
} catch (e) {
|
|
679
|
+
addMessage({ role: "system", content: `Marathon activation failed: ${e.message}` });
|
|
680
|
+
}
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
|
|
596
684
|
case "/exit":
|
|
597
685
|
case "/quit":
|
|
598
686
|
// Save state + stop diagnostics before exit
|
|
@@ -752,7 +840,7 @@ function App({ engine, config }) {
|
|
|
752
840
|
placeholderRight: queueSize > 0 ? `(${queueSize} queued)` : null,
|
|
753
841
|
}),
|
|
754
842
|
h(HRule),
|
|
755
|
-
h(StatusBar, { sessionId, phase, contextTokens, contextLimit }),
|
|
843
|
+
h(StatusBar, { sessionId, phase, contextTokens, contextLimit, marathonActive }),
|
|
756
844
|
);
|
|
757
845
|
}
|
|
758
846
|
|
|
@@ -821,6 +909,14 @@ export async function main({ languageOverride } = {}) {
|
|
|
821
909
|
};
|
|
822
910
|
process.on("SIGINT", saveOnExit);
|
|
823
911
|
process.on("SIGTERM", saveOnExit);
|
|
912
|
+
// v0.8.1 P8-B: SIGHUP coverage. E2E #11 found macOS sends signals to
|
|
913
|
+
// descendant processes when a Terminal.app window closes or quits;
|
|
914
|
+
// nohup masks SIGHUP but not SIGTERM, and we already cover SIGTERM.
|
|
915
|
+
// Adding SIGHUP makes the kc-beta process robust against terminal
|
|
916
|
+
// teardown even if it's not nohup'd. Without this, a closed terminal
|
|
917
|
+
// can leave KC half-shut-down (events.jsonl flushed, but no
|
|
918
|
+
// marathon_detach event, no clean session-state save).
|
|
919
|
+
process.on("SIGHUP", saveOnExit);
|
|
824
920
|
|
|
825
921
|
const instance = render(h(App, { engine, config }));
|
|
826
922
|
await instance.waitUntilExit();
|
package/src/config.js
CHANGED
|
@@ -21,7 +21,11 @@ function loadGlobalConfig() {
|
|
|
21
21
|
* Parse a .env file into a key-value object.
|
|
22
22
|
* Handles KEY=VALUE lines, ignores comments and blank lines.
|
|
23
23
|
*/
|
|
24
|
-
|
|
24
|
+
// v0.8 P1-B: exported so engine.js can re-overlay workspace .env after
|
|
25
|
+
// the workspace directory is known (cli/index.js calls loadSettings()
|
|
26
|
+
// without a workspace path because the path isn't known until the engine
|
|
27
|
+
// constructs the Workspace object).
|
|
28
|
+
export function loadEnvFile(envPath) {
|
|
25
29
|
if (!fs.existsSync(envPath)) return {};
|
|
26
30
|
// v0.7.0 H9: defend bootstrap against a .env that exists but isn't
|
|
27
31
|
// readable (permission denied, unexpected directory, encoding error,
|
|
@@ -90,7 +94,7 @@ export function loadSettings(workspacePath) {
|
|
|
90
94
|
tier3: env.TIER3 || gc.tiers?.tier3 || "",
|
|
91
95
|
tier4: env.TIER4 || gc.tiers?.tier4 || "",
|
|
92
96
|
|
|
93
|
-
// VLM tiers (vision/OCR models). v0.7.
|
|
97
|
+
// VLM tiers (vision/OCR models). v0.7.4: accept OCR_MODEL_TIER* as
|
|
94
98
|
// alias since template/.env.template + initializer.js seed that name.
|
|
95
99
|
// VLM_TIER* takes precedence when both are set.
|
|
96
100
|
vlmTier1: env.VLM_TIER1 || env.OCR_MODEL_TIER1 || gc.vlm_tiers?.tier1 || "",
|
|
@@ -110,7 +114,20 @@ export function loadSettings(workspacePath) {
|
|
|
110
114
|
|
|
111
115
|
// Workspace (process.env wins — for parallel benchmark runs)
|
|
112
116
|
kcWorkspaceRoot: penv.KC_WORKSPACE_ROOT || gc.workspace_root || path.join(os.homedir(), ".kc_agent", "workspaces"),
|
|
113
|
-
|
|
117
|
+
// v0.8 P1-F sandbox_exec timeout model. Default 120s (Claude Code parity),
|
|
118
|
+
// max 600s (10 min) ceiling. Agent can pass per-call timeout_ms up to max.
|
|
119
|
+
// Legacy KC_EXEC_TIMEOUT (seconds) accepted as deprecation alias for default.
|
|
120
|
+
kcExecDefaultTimeoutMs: parseInt(
|
|
121
|
+
env.KC_EXEC_DEFAULT_TIMEOUT_MS ||
|
|
122
|
+
(env.KC_EXEC_TIMEOUT ? String(parseInt(env.KC_EXEC_TIMEOUT, 10) * 1000) : "") ||
|
|
123
|
+
"120000",
|
|
124
|
+
10,
|
|
125
|
+
),
|
|
126
|
+
kcExecMaxTimeoutMs: parseInt(env.KC_EXEC_MAX_TIMEOUT_MS || "600000", 10),
|
|
127
|
+
// Legacy alias kept for any consumer reading it directly. Computed
|
|
128
|
+
// from the new ms-based field for consistency. New code should read
|
|
129
|
+
// kcExecDefaultTimeoutMs / kcExecMaxTimeoutMs.
|
|
130
|
+
kcExecTimeout: parseInt(env.KC_EXEC_TIMEOUT || "120", 10),
|
|
114
131
|
|
|
115
132
|
// Accuracy thresholds
|
|
116
133
|
skillAccuracy: parseFloat(env.SKILL_ACCURACY || gc.accuracy_threshold?.toString() || "0.9"),
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// v0.8.1 P8-A — marathon driver as inline state machine.
|
|
2
|
+
//
|
|
3
|
+
// v0.8.0 shipped this as a separate-process driver (bin/kc-marathon.js)
|
|
4
|
+
// that tailed events.jsonl + wrote prompts to .kc_marathon/inbox.jsonl.
|
|
5
|
+
// E2E #11 audits found both drivers died silently within 10 min when
|
|
6
|
+
// the terminal closed or laptop slept (SIGHUP/SIGTERM unhandled). The
|
|
7
|
+
// engine survived both deaths because it lives in a different process.
|
|
8
|
+
//
|
|
9
|
+
// v0.8.1 redesign per user proposal (2026-05-15):
|
|
10
|
+
// - Single process: driver runs inline as part of the engine
|
|
11
|
+
// - Activated via `/marathon <goal>` slash command in kc-beta TUI
|
|
12
|
+
// - Engine calls decideNext(state) after each turn_complete to get
|
|
13
|
+
// the next continuation prompt (or null if marathon should end)
|
|
14
|
+
// - No filesystem IPC (no inbox, no active marker, no state.json)
|
|
15
|
+
// - State persists via engine's existing session-state.json
|
|
16
|
+
//
|
|
17
|
+
// The state machine logic from v0.8.0 is preserved verbatim — only
|
|
18
|
+
// the I/O wrapper changes. Templates (renderPrompt) unchanged.
|
|
19
|
+
|
|
20
|
+
import { renderPrompt } from "./prompts.js";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_STUCK_AFTER_MS = 30 * 60 * 1000; // 30 min
|
|
23
|
+
const DEFAULT_MAX_WALLCLOCK_MS = 12 * 60 * 60 * 1000; // 12 h
|
|
24
|
+
|
|
25
|
+
export class MarathonDriver {
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} opts
|
|
28
|
+
* @param {string} opts.goal — the marathon goal-description prompt
|
|
29
|
+
* @param {string} [opts.language] — "en" or "zh"
|
|
30
|
+
* @param {number} [opts.maxWallclockMs] — stop after this much wall time
|
|
31
|
+
* @param {number} [opts.stuckAfterMs] — emit unstick prompt after idle
|
|
32
|
+
*/
|
|
33
|
+
constructor(opts = {}) {
|
|
34
|
+
if (!opts.goal || typeof opts.goal !== "string") {
|
|
35
|
+
throw new Error("MarathonDriver requires a non-empty `goal` string");
|
|
36
|
+
}
|
|
37
|
+
this.goal = opts.goal;
|
|
38
|
+
this.language = opts.language === "zh" ? "zh" : "en";
|
|
39
|
+
this.maxWallclockMs = opts.maxWallclockMs ?? DEFAULT_MAX_WALLCLOCK_MS;
|
|
40
|
+
this.stuckAfterMs = opts.stuckAfterMs ?? DEFAULT_STUCK_AFTER_MS;
|
|
41
|
+
|
|
42
|
+
this.startedAt = Date.now();
|
|
43
|
+
this.lastDecisionAt = 0;
|
|
44
|
+
this.decisionCount = 0;
|
|
45
|
+
this.currentPhase = "bootstrap";
|
|
46
|
+
this.lastMilestones = {};
|
|
47
|
+
this.turnsThisPhase = 0;
|
|
48
|
+
this.lastEventTs = Date.now();
|
|
49
|
+
this.initialDelivered = false;
|
|
50
|
+
this.stopped = false;
|
|
51
|
+
this.stopReason = null;
|
|
52
|
+
|
|
53
|
+
// Decision history (kept in-memory; surfaced in /marathon status).
|
|
54
|
+
// Bounded to last 100 to cap memory.
|
|
55
|
+
this.decisions = [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Engine calls this once BEFORE the initial turn after /marathon was
|
|
60
|
+
* typed. Returns the goal-description prompt to feed into runTurn.
|
|
61
|
+
*/
|
|
62
|
+
getInitialPrompt() {
|
|
63
|
+
const out = renderPrompt(
|
|
64
|
+
"initial",
|
|
65
|
+
this._stateSnapshot(),
|
|
66
|
+
this.language,
|
|
67
|
+
);
|
|
68
|
+
this._recordDecision("initial", "marathon kickoff", out);
|
|
69
|
+
this.initialDelivered = true;
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Engine calls decideNext(state) after each turn_complete event.
|
|
75
|
+
* Returns { prompt, template, reason } if marathon should continue,
|
|
76
|
+
* or null if a stop condition is met (engine will exit marathon mode).
|
|
77
|
+
*
|
|
78
|
+
* @param {object} state — engine snapshot:
|
|
79
|
+
* {currentPhase, milestones, phaseChanged, errorSeen, turnsThisPhase}
|
|
80
|
+
*/
|
|
81
|
+
decideNext(state = {}) {
|
|
82
|
+
if (this.stopped) return null;
|
|
83
|
+
|
|
84
|
+
// Update tracked state from engine
|
|
85
|
+
if (state.currentPhase && state.currentPhase !== this.currentPhase) {
|
|
86
|
+
this.currentPhase = state.currentPhase;
|
|
87
|
+
this.turnsThisPhase = 0;
|
|
88
|
+
}
|
|
89
|
+
if (state.milestones) this.lastMilestones = state.milestones;
|
|
90
|
+
if (typeof state.turnsThisPhase === "number") {
|
|
91
|
+
this.turnsThisPhase = state.turnsThisPhase;
|
|
92
|
+
} else {
|
|
93
|
+
this.turnsThisPhase += 1;
|
|
94
|
+
}
|
|
95
|
+
this.lastEventTs = Date.now();
|
|
96
|
+
|
|
97
|
+
// Stop conditions
|
|
98
|
+
if (this._shouldStop()) {
|
|
99
|
+
this.stopped = true;
|
|
100
|
+
// Emit one final "stop" prompt so the agent has a chance to wrap up.
|
|
101
|
+
const out = renderPrompt("stop", this._stateSnapshot(), this.language);
|
|
102
|
+
this._recordDecision("stop", this.stopReason, out);
|
|
103
|
+
return { prompt: out, template: "stop", reason: this.stopReason };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let template = "continue_phase";
|
|
107
|
+
let reason = "turn_complete in same phase";
|
|
108
|
+
|
|
109
|
+
if (state.errorSeen) {
|
|
110
|
+
template = "unstick";
|
|
111
|
+
reason = "engine emitted error event";
|
|
112
|
+
} else if (state.phaseChanged) {
|
|
113
|
+
if (this.currentPhase === "finalization") {
|
|
114
|
+
template = "finalize";
|
|
115
|
+
reason = "reached finalization";
|
|
116
|
+
} else {
|
|
117
|
+
template = "continue_phase";
|
|
118
|
+
reason = `entered ${this.currentPhase}`;
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
const idleMs = Date.now() - this.lastEventTs;
|
|
122
|
+
if (idleMs > this.stuckAfterMs) {
|
|
123
|
+
template = "unstick";
|
|
124
|
+
reason = `idle for ${Math.round(idleMs / 60000)} min`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const out = renderPrompt(template, this._stateSnapshot(), this.language);
|
|
129
|
+
this._recordDecision(template, reason, out);
|
|
130
|
+
return { prompt: out, template, reason };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** User-invoked manual stop (e.g., `/marathon off`). */
|
|
134
|
+
stop(reason = "user_off") {
|
|
135
|
+
this.stopped = true;
|
|
136
|
+
this.stopReason = reason;
|
|
137
|
+
this._recordDecision("manual_stop", reason, "");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Snapshot for /marathon status command + audit. */
|
|
141
|
+
getStatus() {
|
|
142
|
+
return {
|
|
143
|
+
active: !this.stopped,
|
|
144
|
+
goal: this.goal,
|
|
145
|
+
language: this.language,
|
|
146
|
+
startedAt: new Date(this.startedAt).toISOString(),
|
|
147
|
+
runtimeMs: Date.now() - this.startedAt,
|
|
148
|
+
currentPhase: this.currentPhase,
|
|
149
|
+
turnsThisPhase: this.turnsThisPhase,
|
|
150
|
+
decisionCount: this.decisionCount,
|
|
151
|
+
lastDecisionAt: this.lastDecisionAt ? new Date(this.lastDecisionAt).toISOString() : null,
|
|
152
|
+
stopReason: this.stopReason,
|
|
153
|
+
maxWallclockMs: this.maxWallclockMs,
|
|
154
|
+
stuckAfterMs: this.stuckAfterMs,
|
|
155
|
+
recentDecisions: this.decisions.slice(-5),
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Serialize for session-state.json persistence (NOT used for auto-resume per user-locked decision; included for audit visibility only). */
|
|
160
|
+
toJSON() {
|
|
161
|
+
return {
|
|
162
|
+
goal: this.goal,
|
|
163
|
+
language: this.language,
|
|
164
|
+
maxWallclockMs: this.maxWallclockMs,
|
|
165
|
+
stuckAfterMs: this.stuckAfterMs,
|
|
166
|
+
startedAt: this.startedAt,
|
|
167
|
+
currentPhase: this.currentPhase,
|
|
168
|
+
turnsThisPhase: this.turnsThisPhase,
|
|
169
|
+
decisionCount: this.decisionCount,
|
|
170
|
+
initialDelivered: this.initialDelivered,
|
|
171
|
+
stopped: this.stopped,
|
|
172
|
+
stopReason: this.stopReason,
|
|
173
|
+
// Note: decisions array not persisted (memory-only)
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── internals ──────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
_stateSnapshot() {
|
|
180
|
+
return {
|
|
181
|
+
goal: this.goal,
|
|
182
|
+
currentPhase: this.currentPhase,
|
|
183
|
+
milestones: this.lastMilestones,
|
|
184
|
+
idleSec: Math.round((Date.now() - this.lastEventTs) / 1000),
|
|
185
|
+
lastEventType: this._lastEventType || null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_shouldStop() {
|
|
190
|
+
if (this.stopped) return true;
|
|
191
|
+
if (Date.now() - this.startedAt > this.maxWallclockMs) {
|
|
192
|
+
this.stopReason = "max_wallclock";
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
if (
|
|
196
|
+
this.currentPhase === "finalization" &&
|
|
197
|
+
this.turnsThisPhase >= 5
|
|
198
|
+
) {
|
|
199
|
+
this.stopReason = "finalization_settled";
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
_recordDecision(template, reason, prompt) {
|
|
206
|
+
this.decisionCount += 1;
|
|
207
|
+
this.lastDecisionAt = Date.now();
|
|
208
|
+
this.decisions.push({
|
|
209
|
+
ts: new Date().toISOString(),
|
|
210
|
+
template,
|
|
211
|
+
reason,
|
|
212
|
+
currentPhase: this.currentPhase,
|
|
213
|
+
promptPreview: (prompt || "").slice(0, 200),
|
|
214
|
+
});
|
|
215
|
+
if (this.decisions.length > 100) this.decisions.shift();
|
|
216
|
+
}
|
|
217
|
+
}
|