kc-beta 0.3.2 → 0.5.4
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/package.json +1 -1
- package/src/agent/confidence-scorer.js +8 -0
- package/src/agent/context-window.js +7 -2
- package/src/agent/context.js +25 -0
- package/src/agent/corner-case-registry.js +5 -0
- package/src/agent/engine.js +564 -76
- package/src/agent/event-log.js +15 -2
- package/src/agent/history.js +91 -23
- package/src/agent/pipelines/initializer.js +3 -6
- package/src/agent/retry.js +9 -1
- package/src/agent/rule-catalog-normalize.js +37 -0
- package/src/agent/scheduler.js +276 -0
- package/src/agent/session-state.js +11 -2
- package/src/agent/task-manager.js +5 -0
- package/src/agent/tools/agent-tool.js +57 -14
- package/src/agent/tools/archive-file.js +94 -0
- package/src/agent/tools/copy-to-workspace.js +140 -0
- package/src/agent/tools/phase-advance.js +60 -0
- package/src/agent/tools/release.js +323 -0
- package/src/agent/tools/rule-catalog.js +56 -4
- package/src/agent/tools/schedule-fetch.js +118 -0
- package/src/agent/tools/snapshot.js +101 -0
- package/src/agent/tools/workspace-file.js +10 -7
- package/src/agent/version-manager.js +29 -120
- package/src/agent/workspace.js +127 -4
- package/src/cli/components.js +68 -12
- package/src/cli/index.js +147 -15
- package/src/config.js +10 -1
- package/src/model-tiers.json +5 -5
- package/template/release-runtime/README.md.tmpl +84 -0
- package/template/release-runtime/kc_runtime/__init__.py +2 -0
- package/template/release-runtime/kc_runtime/confidence.py +93 -0
- package/template/release-runtime/kc_runtime/dashboard.py +208 -0
- package/template/release-runtime/render_dashboard.py +49 -0
- package/template/release-runtime/run.py +230 -0
- package/template/release-runtime/serve.sh +15 -0
- package/template/skills/en/meta-meta/bootstrap-workspace/SKILL.md +11 -0
- package/template/skills/en/meta-meta/quality-control/SKILL.md +13 -1
- package/template/skills/en/meta-meta/skill-to-workflow/SKILL.md +8 -0
- package/template/skills/en/meta-meta/task-decomposition/SKILL.md +13 -0
- package/template/skills/en/meta-meta/version-control/SKILL.md +13 -0
- package/template/skills/zh/meta-meta/bootstrap-workspace/SKILL.md +11 -0
- package/template/skills/zh/meta-meta/quality-control/SKILL.md +12 -0
- package/template/skills/zh/meta-meta/skill-to-workflow/SKILL.md +8 -0
- package/template/skills/zh/meta-meta/task-decomposition/SKILL.md +16 -0
- package/template/skills/zh/meta-meta/version-control/SKILL.md +13 -0
- package/template/workspace.gitignore +22 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { BaseTool, ToolResult } from "./base.js";
|
|
5
|
+
import { SnapshotTool } from "./snapshot.js";
|
|
6
|
+
import { normalizeRuleCatalog } from "../rule-catalog-normalize.js";
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const TEMPLATE_DIR = path.resolve(__dirname, "../../../template/release-runtime");
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Bundle the current workspace into a portable, self-contained release for
|
|
13
|
+
* end users. The bundle has no kc-beta dependency — anyone with Python +
|
|
14
|
+
* a worker LLM API key can run `python run.py <doc>`.
|
|
15
|
+
*
|
|
16
|
+
* Sequence:
|
|
17
|
+
* 1. Snapshot the workspace (git tag + snapshots/<slug>/snapshot.json)
|
|
18
|
+
* 2. Read rules/catalog.json, filter by `include` if given
|
|
19
|
+
* 3. For each rule, find the latest workflow under workflows/<rule_id>/
|
|
20
|
+
* 4. Build output/releases/<slug>/ from template/release-runtime/ + workspace
|
|
21
|
+
* artifacts (workflows, glossary, catalog, corner_cases, calibration, models)
|
|
22
|
+
* 5. Write manifest.json + auto-generated README.md
|
|
23
|
+
* 6. Optionally include KC-selected fixtures from samples/
|
|
24
|
+
*/
|
|
25
|
+
export class ReleaseTool extends BaseTool {
|
|
26
|
+
/**
|
|
27
|
+
* @param {import('../workspace.js').Workspace} workspace
|
|
28
|
+
* @param {object} [opts]
|
|
29
|
+
* @param {string} [opts.kcVersion]
|
|
30
|
+
*/
|
|
31
|
+
constructor(workspace, { kcVersion = "0.5.1" } = {}) {
|
|
32
|
+
super();
|
|
33
|
+
this._workspace = workspace;
|
|
34
|
+
this._snapshot = new SnapshotTool(workspace);
|
|
35
|
+
this._kcVersion = kcVersion;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get name() { return "release"; }
|
|
39
|
+
|
|
40
|
+
get description() {
|
|
41
|
+
return (
|
|
42
|
+
"Bundle the current workspace into a portable release at " +
|
|
43
|
+
"output/releases/<slug>/. The bundle is self-contained — anyone with " +
|
|
44
|
+
"Python 3 and a worker LLM API key can run it via `python run.py <doc>`. " +
|
|
45
|
+
"Snapshots the workspace as a git tag (snap/release-<slug>) for reproducibility. " +
|
|
46
|
+
"Use when workflows have met accuracy thresholds and the system is ready " +
|
|
47
|
+
"to be handed off to end users or deployed for production runs."
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get inputSchema() {
|
|
52
|
+
return {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
label: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "Human-readable release name, e.g. 'v1' or 'q2-2026'. Slugified for the directory name.",
|
|
58
|
+
},
|
|
59
|
+
notes: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Optional release notes embedded in the manifest and README.",
|
|
62
|
+
},
|
|
63
|
+
include: {
|
|
64
|
+
type: "array",
|
|
65
|
+
items: { type: "string" },
|
|
66
|
+
description: "Optional rule-id allowlist. Default: all rules in catalog.json.",
|
|
67
|
+
},
|
|
68
|
+
fixtures: {
|
|
69
|
+
type: "array",
|
|
70
|
+
items: { type: "string" },
|
|
71
|
+
description: "Optional list of sample file paths (relative to samples/ or project) to bundle as fixtures/. KC should pick 1-3 representative samples.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
required: ["label"],
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async execute(input) {
|
|
79
|
+
const label = (input.label || "").trim();
|
|
80
|
+
if (!label) return new ToolResult("label required", true);
|
|
81
|
+
const slug = label.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
82
|
+
if (!slug) return new ToolResult("label produced empty slug", true);
|
|
83
|
+
|
|
84
|
+
if (!fs.existsSync(TEMPLATE_DIR)) {
|
|
85
|
+
return new ToolResult(`release template missing at ${TEMPLATE_DIR}`, true);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 1. Snapshot first — locks in commit + tag, regardless of whether bundle build succeeds
|
|
89
|
+
const snapResult = await this._snapshot.execute({
|
|
90
|
+
label: `release-${slug}`,
|
|
91
|
+
notes: `Release ${label} bundle source`,
|
|
92
|
+
});
|
|
93
|
+
if (snapResult.isError) return new ToolResult(`snapshot failed: ${snapResult.content}`, true);
|
|
94
|
+
const { tag: snapshotTag, commit: snapshotCommit } = this._readSnapshotMeta(`release-${slug}`);
|
|
95
|
+
|
|
96
|
+
// 2. Read catalog and filter
|
|
97
|
+
const catalogPath = path.join(this._workspace.cwd, "rules", "catalog.json");
|
|
98
|
+
if (!fs.existsSync(catalogPath)) {
|
|
99
|
+
return new ToolResult("rules/catalog.json not found — extract rules before releasing", true);
|
|
100
|
+
}
|
|
101
|
+
let catalog;
|
|
102
|
+
try { catalog = JSON.parse(fs.readFileSync(catalogPath, "utf-8")); }
|
|
103
|
+
catch (e) { return new ToolResult(`catalog.json invalid: ${e.message}`, true); }
|
|
104
|
+
catalog = normalizeRuleCatalog(catalog);
|
|
105
|
+
|
|
106
|
+
const includeSet = Array.isArray(input.include) && input.include.length > 0
|
|
107
|
+
? new Set(input.include) : null;
|
|
108
|
+
const selectedRules = catalog.filter((r) => !includeSet || includeSet.has(r.id));
|
|
109
|
+
if (selectedRules.length === 0) {
|
|
110
|
+
return new ToolResult("no rules selected for release (empty catalog or include filter matched nothing)", true);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 3. Build bundle dir
|
|
114
|
+
const bundleRel = path.join("output", "releases", slug);
|
|
115
|
+
const bundleAbs = this._workspace.resolvePath(bundleRel);
|
|
116
|
+
fs.mkdirSync(bundleAbs, { recursive: true });
|
|
117
|
+
|
|
118
|
+
// Copy Python runtime (run.py, render_dashboard.py, serve.sh, kc_runtime/)
|
|
119
|
+
this._copyDir(TEMPLATE_DIR, bundleAbs, { exclude: ["README.md.tmpl"] });
|
|
120
|
+
// Make .py executable, .sh executable
|
|
121
|
+
this._chmodPlusX(path.join(bundleAbs, "run.py"));
|
|
122
|
+
this._chmodPlusX(path.join(bundleAbs, "render_dashboard.py"));
|
|
123
|
+
this._chmodPlusX(path.join(bundleAbs, "serve.sh"));
|
|
124
|
+
|
|
125
|
+
// 4. Per-rule workflows
|
|
126
|
+
const ruleEntries = [];
|
|
127
|
+
const missingWorkflows = [];
|
|
128
|
+
for (const rule of selectedRules) {
|
|
129
|
+
const ruleId = rule.id;
|
|
130
|
+
const found = this._findLatestWorkflow(ruleId);
|
|
131
|
+
if (!found) {
|
|
132
|
+
missingWorkflows.push(ruleId);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const destRuleDir = path.join(bundleAbs, "workflows", ruleId);
|
|
136
|
+
fs.mkdirSync(destRuleDir, { recursive: true });
|
|
137
|
+
// Copy the workflow file
|
|
138
|
+
const wfFile = path.basename(found);
|
|
139
|
+
fs.copyFileSync(found, path.join(destRuleDir, wfFile));
|
|
140
|
+
this._chmodPlusX(path.join(destRuleDir, wfFile));
|
|
141
|
+
// Copy prompts/ if present
|
|
142
|
+
const promptsDir = path.join(this._workspace.cwd, "workflows", ruleId, "prompts");
|
|
143
|
+
if (fs.existsSync(promptsDir) && fs.statSync(promptsDir).isDirectory()) {
|
|
144
|
+
this._copyDir(promptsDir, path.join(destRuleDir, "prompts"));
|
|
145
|
+
}
|
|
146
|
+
ruleEntries.push({
|
|
147
|
+
id: ruleId,
|
|
148
|
+
title: rule.title || rule.description || "",
|
|
149
|
+
workflow: `workflows/${ruleId}/${wfFile}`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (ruleEntries.length === 0) {
|
|
154
|
+
return new ToolResult(
|
|
155
|
+
`no workflows found for any selected rule. Missing: ${missingWorkflows.join(", ")}`,
|
|
156
|
+
true,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 5. Frozen workspace artifacts
|
|
161
|
+
this._copyIfExists(catalogPath, path.join(bundleAbs, "catalog.json"));
|
|
162
|
+
this._copyIfExists(path.join(this._workspace.cwd, "rules", "glossary.json"),
|
|
163
|
+
path.join(bundleAbs, "glossary.json"), { fallback: '{"version":1,"entries":[]}\n' });
|
|
164
|
+
this._copyIfExists(path.join(this._workspace.cwd, "corner_cases.json"),
|
|
165
|
+
path.join(bundleAbs, "corner_cases.json"), { fallback: '[]\n' });
|
|
166
|
+
this._copyIfExists(path.join(this._workspace.cwd, "confidence_calibration.json"),
|
|
167
|
+
path.join(bundleAbs, "confidence_calibration.json"),
|
|
168
|
+
{ fallback: '{"historical_accuracy":{}}\n' });
|
|
169
|
+
|
|
170
|
+
// 6. models.json — pull tier mappings from workspace .env (worker tiers)
|
|
171
|
+
const models = this._readWorkerTiers();
|
|
172
|
+
fs.writeFileSync(path.join(bundleAbs, "models.json"),
|
|
173
|
+
JSON.stringify(models, null, 2) + "\n", "utf-8");
|
|
174
|
+
|
|
175
|
+
// 7. Optional fixtures
|
|
176
|
+
const fixtures = Array.isArray(input.fixtures) ? input.fixtures : [];
|
|
177
|
+
const bundledFixtures = [];
|
|
178
|
+
if (fixtures.length > 0) {
|
|
179
|
+
const fixDir = path.join(bundleAbs, "fixtures");
|
|
180
|
+
fs.mkdirSync(fixDir, { recursive: true });
|
|
181
|
+
for (const f of fixtures) {
|
|
182
|
+
const src = this._resolveFixture(f);
|
|
183
|
+
if (!src) continue;
|
|
184
|
+
const dst = path.join(fixDir, path.basename(src));
|
|
185
|
+
fs.copyFileSync(src, dst);
|
|
186
|
+
bundledFixtures.push(path.basename(src));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 8. Manifest
|
|
191
|
+
const manifest = {
|
|
192
|
+
label,
|
|
193
|
+
slug,
|
|
194
|
+
snapshot_tag: snapshotTag,
|
|
195
|
+
snapshot_commit: snapshotCommit,
|
|
196
|
+
created_at: new Date().toISOString(),
|
|
197
|
+
notes: input.notes || "",
|
|
198
|
+
rules: ruleEntries,
|
|
199
|
+
models,
|
|
200
|
+
fixtures: bundledFixtures,
|
|
201
|
+
kc_beta_version: this._kcVersion,
|
|
202
|
+
};
|
|
203
|
+
fs.writeFileSync(path.join(bundleAbs, "manifest.json"),
|
|
204
|
+
JSON.stringify(manifest, null, 2) + "\n", "utf-8");
|
|
205
|
+
|
|
206
|
+
// 9. README from template
|
|
207
|
+
const readmeTmpl = fs.readFileSync(path.join(TEMPLATE_DIR, "README.md.tmpl"), "utf-8");
|
|
208
|
+
const rulesList = ruleEntries.map((r) => `- \`${r.id}\` — ${r.title || "(no title)"}`).join("\n");
|
|
209
|
+
const notesBlock = input.notes ? `> ${input.notes}\n` : "";
|
|
210
|
+
const readme = readmeTmpl
|
|
211
|
+
.replace(/\{LABEL\}/g, label)
|
|
212
|
+
.replace(/\{SLUG\}/g, slug)
|
|
213
|
+
.replace(/\{CREATED_AT\}/g, manifest.created_at)
|
|
214
|
+
.replace(/\{SNAPSHOT_TAG\}/g, snapshotTag || "(no tag — git unavailable)")
|
|
215
|
+
.replace(/\{SNAPSHOT_COMMIT\}/g, snapshotCommit || "(unknown)")
|
|
216
|
+
.replace(/\{KC_VERSION\}/g, this._kcVersion)
|
|
217
|
+
.replace(/\{NOTES_BLOCK\}/g, notesBlock)
|
|
218
|
+
.replace(/\{RULES_LIST\}/g, rulesList);
|
|
219
|
+
fs.writeFileSync(path.join(bundleAbs, "README.md"), readme, "utf-8");
|
|
220
|
+
|
|
221
|
+
// Bundle dir is in output/ (gitignored). Snapshot manifest in snapshots/ IS tracked.
|
|
222
|
+
const lines = [
|
|
223
|
+
`Release '${label}' bundled at ${bundleRel}`,
|
|
224
|
+
` rules included: ${ruleEntries.length}` +
|
|
225
|
+
(missingWorkflows.length ? ` (skipped, no workflow: ${missingWorkflows.join(", ")})` : ""),
|
|
226
|
+
` snapshot tag: ${snapshotTag || "(none)"}`,
|
|
227
|
+
` fixtures: ${bundledFixtures.length > 0 ? bundledFixtures.join(", ") : "(none)"}`,
|
|
228
|
+
``,
|
|
229
|
+
`Run from anywhere:`,
|
|
230
|
+
` LLM_API_KEY=... TIER1=... python ${bundleRel}/run.py <doc>`,
|
|
231
|
+
`Open dashboards:`,
|
|
232
|
+
` ${bundleRel}/serve.sh && open http://localhost:8080/`,
|
|
233
|
+
``,
|
|
234
|
+
`Not in this release: HTTP serve framework, batch processor, sandboxing.`,
|
|
235
|
+
`Bundle is regenerable from the snapshot tag (output/releases/ is gitignored).`,
|
|
236
|
+
];
|
|
237
|
+
return new ToolResult(lines.join("\n"));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- helpers ---
|
|
241
|
+
|
|
242
|
+
_readSnapshotMeta(slug) {
|
|
243
|
+
const p = path.join(this._workspace.cwd, "snapshots", slug, "snapshot.json");
|
|
244
|
+
if (!fs.existsSync(p)) return { tag: null, commit: null };
|
|
245
|
+
try {
|
|
246
|
+
const d = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
247
|
+
return { tag: d.tag || null, commit: d.commit || null };
|
|
248
|
+
} catch {
|
|
249
|
+
return { tag: null, commit: null };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_findLatestWorkflow(ruleId) {
|
|
254
|
+
const wfDir = path.join(this._workspace.cwd, "workflows", ruleId);
|
|
255
|
+
if (!fs.existsSync(wfDir) || !fs.statSync(wfDir).isDirectory()) return null;
|
|
256
|
+
const entries = fs.readdirSync(wfDir).sort();
|
|
257
|
+
const versioned = entries.filter((f) => /^workflow_v\d+\.py$/.test(f));
|
|
258
|
+
if (versioned.length > 0) return path.join(wfDir, versioned[versioned.length - 1]);
|
|
259
|
+
const any = entries.find((f) => f.endsWith(".py") && f.toLowerCase().includes("workflow"));
|
|
260
|
+
if (any) return path.join(wfDir, any);
|
|
261
|
+
const py = entries.find((f) => f.endsWith(".py"));
|
|
262
|
+
return py ? path.join(wfDir, py) : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_resolveFixture(rel) {
|
|
266
|
+
// Try samples/ first (workspace, then project), then plain workspace path
|
|
267
|
+
const candidates = [];
|
|
268
|
+
candidates.push(path.join(this._workspace.cwd, "samples", rel));
|
|
269
|
+
if (this._workspace.projectDir) {
|
|
270
|
+
candidates.push(path.join(this._workspace.projectDir, "samples", rel));
|
|
271
|
+
candidates.push(path.join(this._workspace.projectDir, rel));
|
|
272
|
+
}
|
|
273
|
+
candidates.push(path.join(this._workspace.cwd, rel));
|
|
274
|
+
for (const c of candidates) {
|
|
275
|
+
if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
_readWorkerTiers() {
|
|
281
|
+
const envPath = path.join(this._workspace.cwd, ".env");
|
|
282
|
+
const out = { tier1: "", tier2: "", tier3: "", tier4: "" };
|
|
283
|
+
if (!fs.existsSync(envPath)) return out;
|
|
284
|
+
const lines = fs.readFileSync(envPath, "utf-8").split("\n");
|
|
285
|
+
for (const line of lines) {
|
|
286
|
+
for (const t of ["TIER1", "TIER2", "TIER3", "TIER4"]) {
|
|
287
|
+
if (line.startsWith(`${t}=`)) {
|
|
288
|
+
out[t.toLowerCase()] = line.split("=").slice(1).join("=").trim();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_copyDir(src, dst, { exclude = [] } = {}) {
|
|
296
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
297
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
298
|
+
if (exclude.includes(entry.name)) continue;
|
|
299
|
+
const srcPath = path.join(src, entry.name);
|
|
300
|
+
const dstPath = path.join(dst, entry.name);
|
|
301
|
+
if (entry.isDirectory()) {
|
|
302
|
+
this._copyDir(srcPath, dstPath, { exclude });
|
|
303
|
+
} else if (entry.isFile()) {
|
|
304
|
+
fs.copyFileSync(srcPath, dstPath);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_copyIfExists(src, dst, { fallback = null } = {}) {
|
|
310
|
+
if (fs.existsSync(src) && fs.statSync(src).isFile()) {
|
|
311
|
+
fs.copyFileSync(src, dst);
|
|
312
|
+
} else if (fallback !== null) {
|
|
313
|
+
fs.writeFileSync(dst, fallback, "utf-8");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
_chmodPlusX(p) {
|
|
318
|
+
try {
|
|
319
|
+
const stat = fs.statSync(p);
|
|
320
|
+
fs.chmodSync(p, stat.mode | 0o111);
|
|
321
|
+
} catch { /* file may not exist; ignore */ }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
@@ -1,10 +1,52 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { BaseTool, ToolResult } from "./base.js";
|
|
4
|
+
import { normalizeRuleCatalog } from "../rule-catalog-normalize.js";
|
|
4
5
|
|
|
5
6
|
const REQUIRED_FIELDS = new Set(["id", "source_ref", "description"]);
|
|
6
7
|
const RECOMMENDED_FIELDS = new Set(["falsifiability_statement", "test_case_stub", "applicable_sections"]);
|
|
7
8
|
|
|
9
|
+
// Field-name aliases — LLMs frequently produce `source` or 来源 instead of
|
|
10
|
+
// `source_ref`, `desc` instead of `description`. Rather than making 38+ failed
|
|
11
|
+
// calls before the model figures out the canonical names (as observed in the
|
|
12
|
+
// v0.5.3 E2E test), accept the common aliases and canonicalize on ingest.
|
|
13
|
+
const FIELD_ALIASES = {
|
|
14
|
+
source: "source_ref",
|
|
15
|
+
reference: "source_ref",
|
|
16
|
+
ref: "source_ref",
|
|
17
|
+
"来源": "source_ref",
|
|
18
|
+
desc: "description",
|
|
19
|
+
"描述": "description",
|
|
20
|
+
rule_id: "id",
|
|
21
|
+
ruleId: "id",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function normalizeRuleData(data) {
|
|
25
|
+
if (!data || typeof data !== "object") return data;
|
|
26
|
+
const out = { ...data };
|
|
27
|
+
for (const [alias, canonical] of Object.entries(FIELD_ALIASES)) {
|
|
28
|
+
if (out[alias] !== undefined && out[canonical] === undefined) {
|
|
29
|
+
out[canonical] = out[alias];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function missingFieldError(missing, data) {
|
|
36
|
+
// Concrete, actionable error. The generic "Missing required fields: id,
|
|
37
|
+
// source_ref, description" confused agents (they couldn't tell which field
|
|
38
|
+
// they'd actually failed to provide). Point at the first missing field, name
|
|
39
|
+
// what was supplied, and mention the aliases so the model can self-correct.
|
|
40
|
+
const provided = Object.keys(data || {}).slice(0, 8).join(", ") || "(none)";
|
|
41
|
+
const first = missing[0];
|
|
42
|
+
const rest = missing.length > 1 ? ` (also missing: ${missing.slice(1).join(", ")})` : "";
|
|
43
|
+
return (
|
|
44
|
+
`Missing field '${first}' in data.${rest} ` +
|
|
45
|
+
`Provided keys: {${provided}}. ` +
|
|
46
|
+
`Accepted aliases: source/来源/reference → source_ref, desc/描述 → description, rule_id → id.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
8
50
|
/**
|
|
9
51
|
* CRUD on the rule registry with schema enforcement.
|
|
10
52
|
* Enforces required fields (id, source_ref, description) on create/update.
|
|
@@ -48,14 +90,22 @@ export class RuleCatalogTool extends BaseTool {
|
|
|
48
90
|
if (op === "create") return this._create(data);
|
|
49
91
|
if (op === "update") return this._update(ruleId || data.id || "", data);
|
|
50
92
|
if (op === "delete") return this._delete(ruleId || data.id || "");
|
|
51
|
-
|
|
93
|
+
// More helpful than "Unknown operation: " — tells the agent exactly what's
|
|
94
|
+
// allowed and what shape to call with next time (observed in v0.5.3 E2E
|
|
95
|
+
// where GLM-5.1 sent input: {} 38+ times without learning).
|
|
96
|
+
return new ToolResult(
|
|
97
|
+
`rule_catalog requires {operation}. Got: ${op ? `'${op}'` : "(empty)"}. ` +
|
|
98
|
+
`Valid operations: list, read, create, update, delete. ` +
|
|
99
|
+
`Examples: {"operation":"list"} · {"operation":"create","data":{"id":"R-01","source_ref":"民法典 710","description":"..."}}`,
|
|
100
|
+
true,
|
|
101
|
+
);
|
|
52
102
|
}
|
|
53
103
|
|
|
54
104
|
_load() {
|
|
55
105
|
if (!fs.existsSync(this._catalogPath)) return [];
|
|
56
106
|
try {
|
|
57
107
|
const data = JSON.parse(fs.readFileSync(this._catalogPath, "utf-8"));
|
|
58
|
-
return
|
|
108
|
+
return normalizeRuleCatalog(data);
|
|
59
109
|
} catch { return []; }
|
|
60
110
|
}
|
|
61
111
|
|
|
@@ -79,8 +129,9 @@ export class RuleCatalogTool extends BaseTool {
|
|
|
79
129
|
}
|
|
80
130
|
|
|
81
131
|
_create(data) {
|
|
82
|
-
|
|
83
|
-
|
|
132
|
+
data = normalizeRuleData(data);
|
|
133
|
+
const missing = [...REQUIRED_FIELDS].filter((f) => !data[f]);
|
|
134
|
+
if (missing.length > 0) return new ToolResult(missingFieldError(missing, data), true);
|
|
84
135
|
const rules = this._load();
|
|
85
136
|
if (rules.some((r) => r.id === data.id)) return new ToolResult(`Rule already exists: ${data.id}. Use update.`, true);
|
|
86
137
|
const warnings = [...RECOMMENDED_FIELDS].filter((f) => !(f in data));
|
|
@@ -93,6 +144,7 @@ export class RuleCatalogTool extends BaseTool {
|
|
|
93
144
|
|
|
94
145
|
_update(ruleId, data) {
|
|
95
146
|
if (!ruleId) return new ToolResult("rule_id required for update", true);
|
|
147
|
+
data = normalizeRuleData(data);
|
|
96
148
|
const rules = this._load();
|
|
97
149
|
const idx = rules.findIndex((r) => r.id === ruleId);
|
|
98
150
|
if (idx < 0) return new ToolResult(`Rule not found: ${ruleId}`, true);
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { BaseTool, ToolResult } from "./base.js";
|
|
2
|
+
import { Scheduler } from "../scheduler.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Manage scheduled document-ingestion jobs. Each job is a shell command that
|
|
6
|
+
* lands files in `input/`. KC writes a per-job wrapper script under
|
|
7
|
+
* `scripts/ingest/<id>.sh`; the user installs the script into their crontab
|
|
8
|
+
* (or other OS scheduler) themselves via `crontab -e`.
|
|
9
|
+
*
|
|
10
|
+
* KC does NOT run jobs itself — it generates the artifacts the OS scheduler
|
|
11
|
+
* needs. This means scheduled ingestion works while kc-beta is closed.
|
|
12
|
+
*/
|
|
13
|
+
export class ScheduleFetchTool extends BaseTool {
|
|
14
|
+
constructor(workspace) {
|
|
15
|
+
super();
|
|
16
|
+
this._workspace = workspace;
|
|
17
|
+
this._sched = new Scheduler(workspace);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
get name() { return "schedule_fetch"; }
|
|
21
|
+
|
|
22
|
+
get description() {
|
|
23
|
+
return (
|
|
24
|
+
"Manage scheduled ingestion jobs that pull documents into input/. " +
|
|
25
|
+
"Each job is a shell command (e.g. rsync, curl, wget, custom script). " +
|
|
26
|
+
"KC writes a per-job wrapper script under scripts/ingest/<id>.sh; the user " +
|
|
27
|
+
"installs the script into their system cron (or launchd / Task Scheduler) " +
|
|
28
|
+
"via 'crontab -e'. Jobs run while kc-beta is closed. " +
|
|
29
|
+
"Files landing in input/ are auto-prefixed with <job-id>_<UTC-timestamp>_ " +
|
|
30
|
+
"so origin and ingest time are visible in the filename."
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get inputSchema() {
|
|
35
|
+
return {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: {
|
|
38
|
+
operation: {
|
|
39
|
+
type: "string",
|
|
40
|
+
enum: ["add", "list", "remove", "enable", "disable", "print_crontab"],
|
|
41
|
+
description: "Operation: add (register), list, remove, enable, disable, print_crontab.",
|
|
42
|
+
},
|
|
43
|
+
id: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Job identifier (alphanumeric + _-). Required for add/remove/enable/disable.",
|
|
46
|
+
},
|
|
47
|
+
command: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Shell command for add. Available env vars: $WORKSPACE, $INPUT_DIR, $PROJECT_DIR. Example: 'rsync -av /shared/regs/ \"$INPUT_DIR\"/'.",
|
|
50
|
+
},
|
|
51
|
+
description: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Optional human-readable description of the job.",
|
|
54
|
+
},
|
|
55
|
+
cron_hint: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "Optional cron expression (5 fields) to embed in the printed crontab line. KC does NOT evaluate it; the OS does. Example: '0 9 * * *' for daily at 09:00.",
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
required: ["operation"],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async execute(input) {
|
|
65
|
+
const op = input.operation;
|
|
66
|
+
switch (op) {
|
|
67
|
+
case "add": {
|
|
68
|
+
const r = this._sched.add({
|
|
69
|
+
id: input.id,
|
|
70
|
+
command: input.command,
|
|
71
|
+
description: input.description,
|
|
72
|
+
cron_hint: input.cron_hint,
|
|
73
|
+
});
|
|
74
|
+
if (!r.ok) return new ToolResult(`add failed: ${r.reason}`, true);
|
|
75
|
+
const scriptRel = `scripts/ingest/${r.job.id}.sh`;
|
|
76
|
+
return new ToolResult(
|
|
77
|
+
`Job '${r.job.id}' registered. Wrapper script: ${scriptRel}\n` +
|
|
78
|
+
`Install via 'crontab -e' — use 'schedule_fetch print_crontab' to get the line.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
case "list": {
|
|
82
|
+
const jobs = this._sched.list();
|
|
83
|
+
if (jobs.length === 0) return new ToolResult("(no jobs registered)");
|
|
84
|
+
const lines = jobs.map((j) => {
|
|
85
|
+
const status = j.enabled ? "enabled" : "disabled";
|
|
86
|
+
const hint = j.cron_hint ? ` cron_hint='${j.cron_hint}'` : "";
|
|
87
|
+
return ` ${j.id} [${status}]${hint}\n cmd: ${j.command}\n ${j.description ? `desc: ${j.description}` : ""}`;
|
|
88
|
+
});
|
|
89
|
+
const tail = this._sched.tailLog(5);
|
|
90
|
+
return new ToolResult(
|
|
91
|
+
lines.join("\n") +
|
|
92
|
+
(tail ? `\n\nlogs/ingest.log (last 5 lines):\n${tail}` : "")
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
case "remove": {
|
|
96
|
+
if (!input.id) return new ToolResult("id required for remove", true);
|
|
97
|
+
const r = this._sched.remove(input.id);
|
|
98
|
+
return new ToolResult(r.ok ? `Removed '${input.id}'.` : `remove failed: ${r.reason}`, !r.ok);
|
|
99
|
+
}
|
|
100
|
+
case "enable":
|
|
101
|
+
case "disable": {
|
|
102
|
+
if (!input.id) return new ToolResult(`id required for ${op}`, true);
|
|
103
|
+
const r = this._sched.setEnabled(input.id, op === "enable");
|
|
104
|
+
return new ToolResult(
|
|
105
|
+
r.ok
|
|
106
|
+
? `Job '${input.id}' ${op}d.${op === "disable" ? " Wrapper script removed; remember to remove the line from your crontab too." : ""}`
|
|
107
|
+
: `${op} failed: ${r.reason}`,
|
|
108
|
+
!r.ok,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
case "print_crontab": {
|
|
112
|
+
return new ToolResult(this._sched.formatCrontab());
|
|
113
|
+
}
|
|
114
|
+
default:
|
|
115
|
+
return new ToolResult(`Unknown operation: ${op}`, true);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { BaseTool, ToolResult } from "./base.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a named snapshot of the current workspace state.
|
|
8
|
+
* A snapshot is a git tag (`snap/<slug>`) plus a manifest at
|
|
9
|
+
* snapshots/<slug>/snapshot.json. Used for release bundles (Block 8) or
|
|
10
|
+
* before risky operations.
|
|
11
|
+
*
|
|
12
|
+
* Auto-commits any pending changes before tagging so the snapshot is always
|
|
13
|
+
* a valid commit. If git isn't available, the manifest is still written but
|
|
14
|
+
* no tag is created.
|
|
15
|
+
*/
|
|
16
|
+
export class SnapshotTool extends BaseTool {
|
|
17
|
+
constructor(workspace) {
|
|
18
|
+
super();
|
|
19
|
+
this._workspace = workspace;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get name() { return "snapshot"; }
|
|
23
|
+
|
|
24
|
+
get description() {
|
|
25
|
+
return (
|
|
26
|
+
"Create a named snapshot of the current workspace state (git tag + manifest). " +
|
|
27
|
+
"Use to freeze a moment for a release bundle or before a risky operation. " +
|
|
28
|
+
"Auto-commits any pending changes before tagging."
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
get inputSchema() {
|
|
33
|
+
return {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
label: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "Human-readable label, e.g. 'release-v1' or 'before-skill-rewrite'.",
|
|
39
|
+
},
|
|
40
|
+
notes: {
|
|
41
|
+
type: "string",
|
|
42
|
+
description: "Optional description recorded in the snapshot manifest.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: ["label"],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async execute(input) {
|
|
50
|
+
const label = (input.label || "").trim();
|
|
51
|
+
if (!label) return new ToolResult("label required", true);
|
|
52
|
+
const notes = input.notes || "";
|
|
53
|
+
|
|
54
|
+
const slug = label.replace(/[^A-Za-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
|
|
55
|
+
if (!slug) return new ToolResult("label produced empty slug", true);
|
|
56
|
+
|
|
57
|
+
let commitSha = null;
|
|
58
|
+
let tagName = null;
|
|
59
|
+
|
|
60
|
+
if (this._workspace.gitAvailable) {
|
|
61
|
+
// Auto-commit any pending changes (so the snapshot is reproducible)
|
|
62
|
+
spawnSync("git", ["add", "-A"], { cwd: this._workspace.cwd, stdio: "ignore" });
|
|
63
|
+
spawnSync("git", ["commit", "-m", `snapshot: ${label}`, "--allow-empty"], {
|
|
64
|
+
cwd: this._workspace.cwd, stdio: "ignore",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
tagName = `snap/${slug}`;
|
|
68
|
+
// Force the tag in case the same label is re-used
|
|
69
|
+
spawnSync("git", ["tag", "-f", tagName], { cwd: this._workspace.cwd, stdio: "ignore" });
|
|
70
|
+
|
|
71
|
+
const r = spawnSync("git", ["rev-parse", "HEAD"], {
|
|
72
|
+
cwd: this._workspace.cwd, encoding: "utf-8",
|
|
73
|
+
});
|
|
74
|
+
if (r.status === 0) commitSha = r.stdout.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const snapDirRel = path.join("snapshots", slug);
|
|
78
|
+
const snapDirAbs = this._workspace.resolvePath(snapDirRel);
|
|
79
|
+
fs.mkdirSync(snapDirAbs, { recursive: true });
|
|
80
|
+
const manifestRel = path.join(snapDirRel, "snapshot.json");
|
|
81
|
+
const manifestAbs = this._workspace.resolvePath(manifestRel);
|
|
82
|
+
const manifest = {
|
|
83
|
+
label,
|
|
84
|
+
slug,
|
|
85
|
+
tag: tagName,
|
|
86
|
+
commit: commitSha,
|
|
87
|
+
created_at: new Date().toISOString(),
|
|
88
|
+
notes: notes || null,
|
|
89
|
+
};
|
|
90
|
+
fs.writeFileSync(manifestAbs, JSON.stringify(manifest, null, 2), "utf-8");
|
|
91
|
+
this._workspace.autoCommit(manifestRel, "snapshot");
|
|
92
|
+
|
|
93
|
+
const lines = [
|
|
94
|
+
`Snapshot '${label}' created.`,
|
|
95
|
+
tagName ? ` tag: ${tagName}` : " tag: (skipped — git unavailable)",
|
|
96
|
+
commitSha ? ` commit: ${commitSha}` : "",
|
|
97
|
+
` manifest: ${manifestRel}`,
|
|
98
|
+
].filter(Boolean);
|
|
99
|
+
return new ToolResult(lines.join("\n"));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -7,17 +7,20 @@ const MAX_READ = 50_000;
|
|
|
7
7
|
/**
|
|
8
8
|
* Read, write, or list files in the workspace or project directory.
|
|
9
9
|
* All paths are resolved relative to the chosen scope with
|
|
10
|
-
* traversal protection.
|
|
10
|
+
* traversal protection. Workspace writes are auto-committed by Workspace.autoCommit
|
|
11
|
+
* (skips gitignored paths and silently no-ops if git is unavailable).
|
|
12
|
+
*
|
|
13
|
+
* The second `versionManager` arg is retained for back-compat with the engine
|
|
14
|
+
* constructor but is no longer required for any logic.
|
|
11
15
|
*/
|
|
12
16
|
export class WorkspaceFileTool extends BaseTool {
|
|
13
17
|
/**
|
|
14
18
|
* @param {import('../workspace.js').Workspace} workspace
|
|
15
|
-
* @param {import('../version-manager.js').VersionManager} [
|
|
19
|
+
* @param {import('../version-manager.js').VersionManager} [_versionManager] - unused, kept for back-compat
|
|
16
20
|
*/
|
|
17
|
-
constructor(workspace,
|
|
21
|
+
constructor(workspace, _versionManager) {
|
|
18
22
|
super();
|
|
19
23
|
this._workspace = workspace;
|
|
20
|
-
this._versionManager = versionManager || null;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
get name() { return "workspace_file"; }
|
|
@@ -112,10 +115,10 @@ export class WorkspaceFileTool extends BaseTool {
|
|
|
112
115
|
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
113
116
|
fs.writeFileSync(resolved, content, "utf-8");
|
|
114
117
|
|
|
115
|
-
//
|
|
118
|
+
// Auto-commit to git for workspace writes (silently no-ops if gitignored or git unavailable)
|
|
116
119
|
let traceId = null;
|
|
117
|
-
if (scope === "workspace"
|
|
118
|
-
traceId = this.
|
|
120
|
+
if (scope === "workspace") {
|
|
121
|
+
traceId = this._workspace.autoCommit(filePath, "update");
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
const label = scope === "project" ? `[project] ${filePath}` : filePath;
|