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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/agent/confidence-scorer.js +8 -0
  3. package/src/agent/context-window.js +7 -2
  4. package/src/agent/context.js +25 -0
  5. package/src/agent/corner-case-registry.js +5 -0
  6. package/src/agent/engine.js +564 -76
  7. package/src/agent/event-log.js +15 -2
  8. package/src/agent/history.js +91 -23
  9. package/src/agent/pipelines/initializer.js +3 -6
  10. package/src/agent/retry.js +9 -1
  11. package/src/agent/rule-catalog-normalize.js +37 -0
  12. package/src/agent/scheduler.js +276 -0
  13. package/src/agent/session-state.js +11 -2
  14. package/src/agent/task-manager.js +5 -0
  15. package/src/agent/tools/agent-tool.js +57 -14
  16. package/src/agent/tools/archive-file.js +94 -0
  17. package/src/agent/tools/copy-to-workspace.js +140 -0
  18. package/src/agent/tools/phase-advance.js +60 -0
  19. package/src/agent/tools/release.js +323 -0
  20. package/src/agent/tools/rule-catalog.js +56 -4
  21. package/src/agent/tools/schedule-fetch.js +118 -0
  22. package/src/agent/tools/snapshot.js +101 -0
  23. package/src/agent/tools/workspace-file.js +10 -7
  24. package/src/agent/version-manager.js +29 -120
  25. package/src/agent/workspace.js +127 -4
  26. package/src/cli/components.js +68 -12
  27. package/src/cli/index.js +147 -15
  28. package/src/config.js +10 -1
  29. package/src/model-tiers.json +5 -5
  30. package/template/release-runtime/README.md.tmpl +84 -0
  31. package/template/release-runtime/kc_runtime/__init__.py +2 -0
  32. package/template/release-runtime/kc_runtime/confidence.py +93 -0
  33. package/template/release-runtime/kc_runtime/dashboard.py +208 -0
  34. package/template/release-runtime/render_dashboard.py +49 -0
  35. package/template/release-runtime/run.py +230 -0
  36. package/template/release-runtime/serve.sh +15 -0
  37. package/template/skills/en/meta-meta/bootstrap-workspace/SKILL.md +11 -0
  38. package/template/skills/en/meta-meta/quality-control/SKILL.md +13 -1
  39. package/template/skills/en/meta-meta/skill-to-workflow/SKILL.md +8 -0
  40. package/template/skills/en/meta-meta/task-decomposition/SKILL.md +13 -0
  41. package/template/skills/en/meta-meta/version-control/SKILL.md +13 -0
  42. package/template/skills/zh/meta-meta/bootstrap-workspace/SKILL.md +11 -0
  43. package/template/skills/zh/meta-meta/quality-control/SKILL.md +12 -0
  44. package/template/skills/zh/meta-meta/skill-to-workflow/SKILL.md +8 -0
  45. package/template/skills/zh/meta-meta/task-decomposition/SKILL.md +16 -0
  46. package/template/skills/zh/meta-meta/version-control/SKILL.md +13 -0
  47. 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
- return new ToolResult(`Unknown operation: ${op}`, true);
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 Array.isArray(data) ? data : [];
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
- const missing = [...REQUIRED_FIELDS].filter((f) => !(f in data));
83
- if (missing.length > 0) return new ToolResult(`Missing required fields: ${missing.join(", ")}`, true);
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. VersionManager hooks into workspace writes for automatic versioning.
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} [versionManager]
19
+ * @param {import('../version-manager.js').VersionManager} [_versionManager] - unused, kept for back-compat
16
20
  */
17
- constructor(workspace, versionManager) {
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
- // Version tracking only for workspace writes
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" && this._versionManager) {
118
- traceId = this._versionManager.onWrite(filePath, content);
120
+ if (scope === "workspace") {
121
+ traceId = this._workspace.autoCommit(filePath, "update");
119
122
  }
120
123
 
121
124
  const label = scope === "project" ? `[project] ${filePath}` : filePath;