kc-beta 0.3.1 → 0.5.3

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 (49) hide show
  1. package/package.json +1 -1
  2. package/src/agent/confidence-scorer.js +8 -0
  3. package/src/agent/context.js +25 -0
  4. package/src/agent/corner-case-registry.js +5 -0
  5. package/src/agent/engine.js +514 -75
  6. package/src/agent/event-log.js +15 -2
  7. package/src/agent/history.js +91 -23
  8. package/src/agent/pipelines/initializer.js +3 -6
  9. package/src/agent/retry.js +9 -1
  10. package/src/agent/scheduler.js +276 -0
  11. package/src/agent/session-state.js +11 -2
  12. package/src/agent/task-manager.js +5 -0
  13. package/src/agent/tools/agent-tool.js +57 -14
  14. package/src/agent/tools/archive-file.js +94 -0
  15. package/src/agent/tools/copy-to-workspace.js +140 -0
  16. package/src/agent/tools/phase-advance.js +60 -0
  17. package/src/agent/tools/release.js +322 -0
  18. package/src/agent/tools/schedule-fetch.js +118 -0
  19. package/src/agent/tools/snapshot.js +101 -0
  20. package/src/agent/tools/workspace-file.js +10 -7
  21. package/src/agent/version-manager.js +29 -120
  22. package/src/agent/workspace.js +127 -4
  23. package/src/cli/components.js +4 -1
  24. package/src/cli/index.js +57 -4
  25. package/src/config.js +10 -1
  26. package/template/release-runtime/README.md.tmpl +84 -0
  27. package/template/release-runtime/kc_runtime/__init__.py +2 -0
  28. package/template/release-runtime/kc_runtime/confidence.py +93 -0
  29. package/template/release-runtime/kc_runtime/dashboard.py +208 -0
  30. package/template/release-runtime/render_dashboard.py +49 -0
  31. package/template/release-runtime/run.py +230 -0
  32. package/template/release-runtime/serve.sh +15 -0
  33. package/template/skills/en/meta/entity-extraction/SKILL.md +6 -0
  34. package/template/skills/en/meta-meta/bootstrap-workspace/SKILL.md +11 -0
  35. package/template/skills/en/meta-meta/quality-control/SKILL.md +13 -1
  36. package/template/skills/en/meta-meta/rule-extraction/SKILL.md +35 -0
  37. package/template/skills/en/meta-meta/rule-graph/SKILL.md +16 -0
  38. package/template/skills/en/meta-meta/skill-to-workflow/SKILL.md +8 -0
  39. package/template/skills/en/meta-meta/task-decomposition/SKILL.md +13 -0
  40. package/template/skills/en/meta-meta/version-control/SKILL.md +13 -0
  41. package/template/skills/zh/meta/entity-extraction/SKILL.md +6 -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/rule-extraction/SKILL.md +35 -0
  45. package/template/skills/zh/meta-meta/rule-graph/SKILL.md +16 -0
  46. package/template/skills/zh/meta-meta/skill-to-workflow/SKILL.md +8 -0
  47. package/template/skills/zh/meta-meta/task-decomposition/SKILL.md +16 -0
  48. package/template/skills/zh/meta-meta/version-control/SKILL.md +13 -0
  49. package/template/workspace.gitignore +22 -0
@@ -0,0 +1,322 @@
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
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const TEMPLATE_DIR = path.resolve(__dirname, "../../../template/release-runtime");
9
+
10
+ /**
11
+ * Bundle the current workspace into a portable, self-contained release for
12
+ * end users. The bundle has no kc-beta dependency — anyone with Python +
13
+ * a worker LLM API key can run `python run.py <doc>`.
14
+ *
15
+ * Sequence:
16
+ * 1. Snapshot the workspace (git tag + snapshots/<slug>/snapshot.json)
17
+ * 2. Read rules/catalog.json, filter by `include` if given
18
+ * 3. For each rule, find the latest workflow under workflows/<rule_id>/
19
+ * 4. Build output/releases/<slug>/ from template/release-runtime/ + workspace
20
+ * artifacts (workflows, glossary, catalog, corner_cases, calibration, models)
21
+ * 5. Write manifest.json + auto-generated README.md
22
+ * 6. Optionally include KC-selected fixtures from samples/
23
+ */
24
+ export class ReleaseTool extends BaseTool {
25
+ /**
26
+ * @param {import('../workspace.js').Workspace} workspace
27
+ * @param {object} [opts]
28
+ * @param {string} [opts.kcVersion]
29
+ */
30
+ constructor(workspace, { kcVersion = "0.5.1" } = {}) {
31
+ super();
32
+ this._workspace = workspace;
33
+ this._snapshot = new SnapshotTool(workspace);
34
+ this._kcVersion = kcVersion;
35
+ }
36
+
37
+ get name() { return "release"; }
38
+
39
+ get description() {
40
+ return (
41
+ "Bundle the current workspace into a portable release at " +
42
+ "output/releases/<slug>/. The bundle is self-contained — anyone with " +
43
+ "Python 3 and a worker LLM API key can run it via `python run.py <doc>`. " +
44
+ "Snapshots the workspace as a git tag (snap/release-<slug>) for reproducibility. " +
45
+ "Use when workflows have met accuracy thresholds and the system is ready " +
46
+ "to be handed off to end users or deployed for production runs."
47
+ );
48
+ }
49
+
50
+ get inputSchema() {
51
+ return {
52
+ type: "object",
53
+ properties: {
54
+ label: {
55
+ type: "string",
56
+ description: "Human-readable release name, e.g. 'v1' or 'q2-2026'. Slugified for the directory name.",
57
+ },
58
+ notes: {
59
+ type: "string",
60
+ description: "Optional release notes embedded in the manifest and README.",
61
+ },
62
+ include: {
63
+ type: "array",
64
+ items: { type: "string" },
65
+ description: "Optional rule-id allowlist. Default: all rules in catalog.json.",
66
+ },
67
+ fixtures: {
68
+ type: "array",
69
+ items: { type: "string" },
70
+ description: "Optional list of sample file paths (relative to samples/ or project) to bundle as fixtures/. KC should pick 1-3 representative samples.",
71
+ },
72
+ },
73
+ required: ["label"],
74
+ };
75
+ }
76
+
77
+ async execute(input) {
78
+ const label = (input.label || "").trim();
79
+ if (!label) return new ToolResult("label required", true);
80
+ const slug = label.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
81
+ if (!slug) return new ToolResult("label produced empty slug", true);
82
+
83
+ if (!fs.existsSync(TEMPLATE_DIR)) {
84
+ return new ToolResult(`release template missing at ${TEMPLATE_DIR}`, true);
85
+ }
86
+
87
+ // 1. Snapshot first — locks in commit + tag, regardless of whether bundle build succeeds
88
+ const snapResult = await this._snapshot.execute({
89
+ label: `release-${slug}`,
90
+ notes: `Release ${label} bundle source`,
91
+ });
92
+ if (snapResult.isError) return new ToolResult(`snapshot failed: ${snapResult.content}`, true);
93
+ const { tag: snapshotTag, commit: snapshotCommit } = this._readSnapshotMeta(`release-${slug}`);
94
+
95
+ // 2. Read catalog and filter
96
+ const catalogPath = path.join(this._workspace.cwd, "rules", "catalog.json");
97
+ if (!fs.existsSync(catalogPath)) {
98
+ return new ToolResult("rules/catalog.json not found — extract rules before releasing", true);
99
+ }
100
+ let catalog;
101
+ try { catalog = JSON.parse(fs.readFileSync(catalogPath, "utf-8")); }
102
+ catch (e) { return new ToolResult(`catalog.json invalid: ${e.message}`, true); }
103
+ if (!Array.isArray(catalog)) catalog = catalog.rules || [];
104
+
105
+ const includeSet = Array.isArray(input.include) && input.include.length > 0
106
+ ? new Set(input.include) : null;
107
+ const selectedRules = catalog.filter((r) => !includeSet || includeSet.has(r.id));
108
+ if (selectedRules.length === 0) {
109
+ return new ToolResult("no rules selected for release (empty catalog or include filter matched nothing)", true);
110
+ }
111
+
112
+ // 3. Build bundle dir
113
+ const bundleRel = path.join("output", "releases", slug);
114
+ const bundleAbs = this._workspace.resolvePath(bundleRel);
115
+ fs.mkdirSync(bundleAbs, { recursive: true });
116
+
117
+ // Copy Python runtime (run.py, render_dashboard.py, serve.sh, kc_runtime/)
118
+ this._copyDir(TEMPLATE_DIR, bundleAbs, { exclude: ["README.md.tmpl"] });
119
+ // Make .py executable, .sh executable
120
+ this._chmodPlusX(path.join(bundleAbs, "run.py"));
121
+ this._chmodPlusX(path.join(bundleAbs, "render_dashboard.py"));
122
+ this._chmodPlusX(path.join(bundleAbs, "serve.sh"));
123
+
124
+ // 4. Per-rule workflows
125
+ const ruleEntries = [];
126
+ const missingWorkflows = [];
127
+ for (const rule of selectedRules) {
128
+ const ruleId = rule.id;
129
+ const found = this._findLatestWorkflow(ruleId);
130
+ if (!found) {
131
+ missingWorkflows.push(ruleId);
132
+ continue;
133
+ }
134
+ const destRuleDir = path.join(bundleAbs, "workflows", ruleId);
135
+ fs.mkdirSync(destRuleDir, { recursive: true });
136
+ // Copy the workflow file
137
+ const wfFile = path.basename(found);
138
+ fs.copyFileSync(found, path.join(destRuleDir, wfFile));
139
+ this._chmodPlusX(path.join(destRuleDir, wfFile));
140
+ // Copy prompts/ if present
141
+ const promptsDir = path.join(this._workspace.cwd, "workflows", ruleId, "prompts");
142
+ if (fs.existsSync(promptsDir) && fs.statSync(promptsDir).isDirectory()) {
143
+ this._copyDir(promptsDir, path.join(destRuleDir, "prompts"));
144
+ }
145
+ ruleEntries.push({
146
+ id: ruleId,
147
+ title: rule.title || rule.description || "",
148
+ workflow: `workflows/${ruleId}/${wfFile}`,
149
+ });
150
+ }
151
+
152
+ if (ruleEntries.length === 0) {
153
+ return new ToolResult(
154
+ `no workflows found for any selected rule. Missing: ${missingWorkflows.join(", ")}`,
155
+ true,
156
+ );
157
+ }
158
+
159
+ // 5. Frozen workspace artifacts
160
+ this._copyIfExists(catalogPath, path.join(bundleAbs, "catalog.json"));
161
+ this._copyIfExists(path.join(this._workspace.cwd, "rules", "glossary.json"),
162
+ path.join(bundleAbs, "glossary.json"), { fallback: '{"version":1,"entries":[]}\n' });
163
+ this._copyIfExists(path.join(this._workspace.cwd, "corner_cases.json"),
164
+ path.join(bundleAbs, "corner_cases.json"), { fallback: '[]\n' });
165
+ this._copyIfExists(path.join(this._workspace.cwd, "confidence_calibration.json"),
166
+ path.join(bundleAbs, "confidence_calibration.json"),
167
+ { fallback: '{"historical_accuracy":{}}\n' });
168
+
169
+ // 6. models.json — pull tier mappings from workspace .env (worker tiers)
170
+ const models = this._readWorkerTiers();
171
+ fs.writeFileSync(path.join(bundleAbs, "models.json"),
172
+ JSON.stringify(models, null, 2) + "\n", "utf-8");
173
+
174
+ // 7. Optional fixtures
175
+ const fixtures = Array.isArray(input.fixtures) ? input.fixtures : [];
176
+ const bundledFixtures = [];
177
+ if (fixtures.length > 0) {
178
+ const fixDir = path.join(bundleAbs, "fixtures");
179
+ fs.mkdirSync(fixDir, { recursive: true });
180
+ for (const f of fixtures) {
181
+ const src = this._resolveFixture(f);
182
+ if (!src) continue;
183
+ const dst = path.join(fixDir, path.basename(src));
184
+ fs.copyFileSync(src, dst);
185
+ bundledFixtures.push(path.basename(src));
186
+ }
187
+ }
188
+
189
+ // 8. Manifest
190
+ const manifest = {
191
+ label,
192
+ slug,
193
+ snapshot_tag: snapshotTag,
194
+ snapshot_commit: snapshotCommit,
195
+ created_at: new Date().toISOString(),
196
+ notes: input.notes || "",
197
+ rules: ruleEntries,
198
+ models,
199
+ fixtures: bundledFixtures,
200
+ kc_beta_version: this._kcVersion,
201
+ };
202
+ fs.writeFileSync(path.join(bundleAbs, "manifest.json"),
203
+ JSON.stringify(manifest, null, 2) + "\n", "utf-8");
204
+
205
+ // 9. README from template
206
+ const readmeTmpl = fs.readFileSync(path.join(TEMPLATE_DIR, "README.md.tmpl"), "utf-8");
207
+ const rulesList = ruleEntries.map((r) => `- \`${r.id}\` — ${r.title || "(no title)"}`).join("\n");
208
+ const notesBlock = input.notes ? `> ${input.notes}\n` : "";
209
+ const readme = readmeTmpl
210
+ .replace(/\{LABEL\}/g, label)
211
+ .replace(/\{SLUG\}/g, slug)
212
+ .replace(/\{CREATED_AT\}/g, manifest.created_at)
213
+ .replace(/\{SNAPSHOT_TAG\}/g, snapshotTag || "(no tag — git unavailable)")
214
+ .replace(/\{SNAPSHOT_COMMIT\}/g, snapshotCommit || "(unknown)")
215
+ .replace(/\{KC_VERSION\}/g, this._kcVersion)
216
+ .replace(/\{NOTES_BLOCK\}/g, notesBlock)
217
+ .replace(/\{RULES_LIST\}/g, rulesList);
218
+ fs.writeFileSync(path.join(bundleAbs, "README.md"), readme, "utf-8");
219
+
220
+ // Bundle dir is in output/ (gitignored). Snapshot manifest in snapshots/ IS tracked.
221
+ const lines = [
222
+ `Release '${label}' bundled at ${bundleRel}`,
223
+ ` rules included: ${ruleEntries.length}` +
224
+ (missingWorkflows.length ? ` (skipped, no workflow: ${missingWorkflows.join(", ")})` : ""),
225
+ ` snapshot tag: ${snapshotTag || "(none)"}`,
226
+ ` fixtures: ${bundledFixtures.length > 0 ? bundledFixtures.join(", ") : "(none)"}`,
227
+ ``,
228
+ `Run from anywhere:`,
229
+ ` LLM_API_KEY=... TIER1=... python ${bundleRel}/run.py <doc>`,
230
+ `Open dashboards:`,
231
+ ` ${bundleRel}/serve.sh && open http://localhost:8080/`,
232
+ ``,
233
+ `Not in this release: HTTP serve framework, batch processor, sandboxing.`,
234
+ `Bundle is regenerable from the snapshot tag (output/releases/ is gitignored).`,
235
+ ];
236
+ return new ToolResult(lines.join("\n"));
237
+ }
238
+
239
+ // --- helpers ---
240
+
241
+ _readSnapshotMeta(slug) {
242
+ const p = path.join(this._workspace.cwd, "snapshots", slug, "snapshot.json");
243
+ if (!fs.existsSync(p)) return { tag: null, commit: null };
244
+ try {
245
+ const d = JSON.parse(fs.readFileSync(p, "utf-8"));
246
+ return { tag: d.tag || null, commit: d.commit || null };
247
+ } catch {
248
+ return { tag: null, commit: null };
249
+ }
250
+ }
251
+
252
+ _findLatestWorkflow(ruleId) {
253
+ const wfDir = path.join(this._workspace.cwd, "workflows", ruleId);
254
+ if (!fs.existsSync(wfDir) || !fs.statSync(wfDir).isDirectory()) return null;
255
+ const entries = fs.readdirSync(wfDir).sort();
256
+ const versioned = entries.filter((f) => /^workflow_v\d+\.py$/.test(f));
257
+ if (versioned.length > 0) return path.join(wfDir, versioned[versioned.length - 1]);
258
+ const any = entries.find((f) => f.endsWith(".py") && f.toLowerCase().includes("workflow"));
259
+ if (any) return path.join(wfDir, any);
260
+ const py = entries.find((f) => f.endsWith(".py"));
261
+ return py ? path.join(wfDir, py) : null;
262
+ }
263
+
264
+ _resolveFixture(rel) {
265
+ // Try samples/ first (workspace, then project), then plain workspace path
266
+ const candidates = [];
267
+ candidates.push(path.join(this._workspace.cwd, "samples", rel));
268
+ if (this._workspace.projectDir) {
269
+ candidates.push(path.join(this._workspace.projectDir, "samples", rel));
270
+ candidates.push(path.join(this._workspace.projectDir, rel));
271
+ }
272
+ candidates.push(path.join(this._workspace.cwd, rel));
273
+ for (const c of candidates) {
274
+ if (fs.existsSync(c) && fs.statSync(c).isFile()) return c;
275
+ }
276
+ return null;
277
+ }
278
+
279
+ _readWorkerTiers() {
280
+ const envPath = path.join(this._workspace.cwd, ".env");
281
+ const out = { tier1: "", tier2: "", tier3: "", tier4: "" };
282
+ if (!fs.existsSync(envPath)) return out;
283
+ const lines = fs.readFileSync(envPath, "utf-8").split("\n");
284
+ for (const line of lines) {
285
+ for (const t of ["TIER1", "TIER2", "TIER3", "TIER4"]) {
286
+ if (line.startsWith(`${t}=`)) {
287
+ out[t.toLowerCase()] = line.split("=").slice(1).join("=").trim();
288
+ }
289
+ }
290
+ }
291
+ return out;
292
+ }
293
+
294
+ _copyDir(src, dst, { exclude = [] } = {}) {
295
+ fs.mkdirSync(dst, { recursive: true });
296
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
297
+ if (exclude.includes(entry.name)) continue;
298
+ const srcPath = path.join(src, entry.name);
299
+ const dstPath = path.join(dst, entry.name);
300
+ if (entry.isDirectory()) {
301
+ this._copyDir(srcPath, dstPath, { exclude });
302
+ } else if (entry.isFile()) {
303
+ fs.copyFileSync(srcPath, dstPath);
304
+ }
305
+ }
306
+ }
307
+
308
+ _copyIfExists(src, dst, { fallback = null } = {}) {
309
+ if (fs.existsSync(src) && fs.statSync(src).isFile()) {
310
+ fs.copyFileSync(src, dst);
311
+ } else if (fallback !== null) {
312
+ fs.writeFileSync(dst, fallback, "utf-8");
313
+ }
314
+ }
315
+
316
+ _chmodPlusX(p) {
317
+ try {
318
+ const stat = fs.statSync(p);
319
+ fs.chmodSync(p, stat.mode | 0o111);
320
+ } catch { /* file may not exist; ignore */ }
321
+ }
322
+ }
@@ -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;