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
@@ -1,130 +1,39 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
1
+ /**
2
+ * Trace ID utility.
3
+ *
4
+ * As of v0.4.0 (Block 11), real version history is kept by git
5
+ * (per-session repo, auto-committed by Workspace.autoCommit). This module
6
+ * is now just a stable place for trace ID generation, used by tools that
7
+ * need to cross-reference a write or a result with the event log.
8
+ *
9
+ * The legacy versions.json manifest in pre-v0.4.0 workspaces is left
10
+ * untouched — nothing reads it any more, but old data is preserved.
11
+ */
3
12
 
4
- const VERSIONED_DIRS = new Set(["workflows", "rule_skills", "rules"]);
5
- const VERSIONED_EXTS = new Set([".py", ".json", ".md", ".txt"]);
13
+ /**
14
+ * Generate a trace ID like "20260417_114203_R001_workflow_result".
15
+ * @param {string} ruleId
16
+ * @param {string} [label]
17
+ * @returns {string}
18
+ */
19
+ export function generateTraceId(ruleId, label = "") {
20
+ const now = new Date().toISOString().replace(/[-:T]/g, (m) =>
21
+ m === "T" ? "_" : ""
22
+ ).slice(0, 15);
23
+ const suffix = label ? `_${label}` : "";
24
+ return `${now}_${ruleId}${suffix}`;
25
+ }
6
26
 
7
27
  /**
8
- * Structural component: every write to versioned directories gets tracked.
9
- * - Immutable version copies tracked in manifest
10
- * - Manifest at versions.json: tracks lineage, timestamps, change reasons
11
- * - Trace ID generation: {timestamp}_{rule_id}_{version}
12
- * Cannot be bypassed — hooks into WorkspaceFileTool.
28
+ * Back-compat shell. The class is retained so existing constructors
29
+ * that take a VersionManager don't break, but it carries no state.
13
30
  */
14
31
  export class VersionManager {
15
- /**
16
- * @param {string} workspacePath
17
- */
18
- constructor(workspacePath) {
19
- this._workspace = workspacePath;
20
- this._manifestPath = path.join(workspacePath, "versions.json");
21
- this._manifest = this._loadManifest();
22
- }
23
-
24
- _loadManifest() {
25
- if (fs.existsSync(this._manifestPath)) {
26
- try {
27
- return JSON.parse(fs.readFileSync(this._manifestPath, "utf-8"));
28
- } catch {
29
- // fall through
30
- }
31
- }
32
- return { version: "0.1.0", entries: [] };
33
- }
34
-
35
- _saveManifest() {
36
- fs.writeFileSync(
37
- this._manifestPath,
38
- JSON.stringify(this._manifest, null, 2),
39
- "utf-8",
40
- );
41
- }
42
-
43
- /**
44
- * Check if a path falls within versioned directories.
45
- * @param {string} relPath
46
- * @returns {boolean}
47
- */
48
- shouldVersion(relPath) {
49
- const parts = relPath.split(path.sep);
50
- if (parts.length === 0) return false;
51
- const topDir = parts[0];
52
- const ext = path.extname(relPath);
53
- return VERSIONED_DIRS.has(topDir) && VERSIONED_EXTS.has(ext);
54
- }
55
-
56
- /**
57
- * Called on file write. Returns trace ID if versioned, null otherwise.
58
- * @param {string} relPath
59
- * @param {string} content
60
- * @returns {string|null}
61
- */
62
- onWrite(relPath, content) {
63
- if (!this.shouldVersion(relPath)) return null;
64
-
65
- const version = this._nextVersion(relPath);
66
- const now = new Date().toISOString().replace(/[-:T]/g, (m) =>
67
- m === "T" ? "_" : ""
68
- ).slice(0, 15);
69
-
70
- // Extract rule_id from path if possible (e.g., rule_skills/R001/SKILL.md → R001)
71
- const parts = relPath.split(path.sep);
72
- const ruleId = parts.length > 1 ? parts[1] : "global";
73
-
74
- const traceId = `${now}_${ruleId}_v${version}`;
75
-
76
- const entry = {
77
- file: relPath,
78
- version,
79
- trace_id: traceId,
80
- timestamp: new Date().toISOString(),
81
- rule_id: ruleId,
82
- size_chars: content.length,
83
- };
84
-
85
- this._manifest.entries.push(entry);
86
- this._saveManifest();
87
-
88
- return traceId;
89
- }
90
-
91
- _nextVersion(relPath) {
92
- const existing = this._manifest.entries
93
- .filter((e) => e.file === relPath)
94
- .map((e) => e.version);
95
- return Math.max(0, ...existing) + 1;
96
- }
97
-
98
- /**
99
- * Get all version entries for a file.
100
- * @param {string} relPath
101
- * @returns {Array<object>}
102
- */
103
- getVersions(relPath) {
104
- return this._manifest.entries.filter((e) => e.file === relPath);
105
- }
106
-
107
- /**
108
- * Get the most recent version entry for a file.
109
- * @param {string} relPath
110
- * @returns {object|null}
111
- */
112
- latestVersion(relPath) {
113
- const versions = this.getVersions(relPath);
114
- return versions.length > 0 ? versions[versions.length - 1] : null;
32
+ constructor(_workspacePath) {
33
+ // No-op. Workspace path is no longer needed.
115
34
  }
116
35
 
117
- /**
118
- * Generate a standalone trace ID for results, QC records, etc.
119
- * @param {string} ruleId
120
- * @param {string} [label]
121
- * @returns {string}
122
- */
123
36
  generateTraceId(ruleId, label = "") {
124
- const now = new Date().toISOString().replace(/[-:T]/g, (m) =>
125
- m === "T" ? "_" : ""
126
- ).slice(0, 15);
127
- const suffix = label ? `_${label}` : "";
128
- return `${now}_${ruleId}${suffix}`;
37
+ return generateTraceId(ruleId, label);
129
38
  }
130
39
  }
@@ -1,24 +1,41 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import crypto from "node:crypto";
4
+ import { execFileSync, spawnSync } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { generateTraceId } from "./version-manager.js";
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const GITIGNORE_TEMPLATE = path.resolve(__dirname, "../../template/workspace.gitignore");
4
10
 
5
11
  /**
6
12
  * Per-session workspace directory with path traversal protection.
7
13
  * Each agent session gets its own directory under the workspace root.
8
14
  * All file operations by tools must go through resolvePath().
15
+ *
16
+ * As of v0.4.0 (Block 11): the workspace is also a git repo. Writes to
17
+ * non-gitignored paths are auto-committed via autoCommit() so KC has a
18
+ * real version history of its outputs.
9
19
  */
10
20
  export class Workspace {
11
21
  /**
12
22
  * @param {string} root - Workspace root directory
13
23
  * @param {string} [sessionId] - Session identifier (auto-generated if omitted)
14
24
  * @param {string} [projectDir] - User's project directory (CWD at launch)
25
+ * @param {object} [opts]
26
+ * @param {boolean} [opts.gitAutoCommit=true] - If false, skip git init / auto-commit
15
27
  */
16
- constructor(root, sessionId, projectDir) {
28
+ constructor(root, sessionId, projectDir, opts = {}) {
17
29
  this.root = path.resolve(root);
18
30
  this.sessionId = sessionId || crypto.randomUUID().replace(/-/g, "").slice(0, 12);
19
31
  this.path = path.resolve(this.root, this.sessionId);
20
32
  this.projectDir = projectDir ? path.resolve(projectDir) : null;
33
+ this._currentPhase = "bootstrap";
21
34
  fs.mkdirSync(this.path, { recursive: true });
35
+
36
+ this._gitAutoCommitEnabled = opts.gitAutoCommit !== false;
37
+ this._gitAvailable = this._gitAutoCommitEnabled && Workspace.isGitInstalled();
38
+ if (this._gitAvailable) this._initGitRepo();
22
39
  }
23
40
 
24
41
  /** @returns {string} Current workspace directory */
@@ -26,6 +43,16 @@ export class Workspace {
26
43
  return this.path;
27
44
  }
28
45
 
46
+ /** @returns {boolean} Whether auto-commit is wired up for this session */
47
+ get gitAvailable() {
48
+ return this._gitAvailable;
49
+ }
50
+
51
+ /** Update the current phase (used in auto-commit messages). */
52
+ setPhase(phase) {
53
+ if (phase) this._currentPhase = phase;
54
+ }
55
+
29
56
  /**
30
57
  * Resolve a user-supplied relative path against the workspace.
31
58
  * Rejects absolute paths and any path that escapes the workspace via .. or symlinks.
@@ -66,23 +93,62 @@ export class Workspace {
66
93
  }
67
94
 
68
95
  /**
69
- * Rename the workspace folder. Returns the new sessionId.
96
+ * Auto-commit a workspace write. Silently no-ops if the path is gitignored,
97
+ * if there's nothing to commit, or if git isn't available. Returns the trace
98
+ * ID generated for this write (always returned, even if no commit happened,
99
+ * so callers can cross-reference with the event log).
100
+ *
101
+ * @param {string} relPath - workspace-relative path that was just written
102
+ * @param {string} [opLabel="update"] - short verb for the commit message
103
+ * @returns {string} trace id
104
+ */
105
+ autoCommit(relPath, opLabel = "update") {
106
+ const ruleId = this._extractRuleId(relPath);
107
+ const traceId = generateTraceId(ruleId, opLabel);
108
+
109
+ if (!this._gitAvailable) return traceId;
110
+
111
+ try {
112
+ const r = spawnSync("git", ["add", "--", relPath], {
113
+ cwd: this.path,
114
+ stdio: "ignore",
115
+ });
116
+ if (r.status !== 0) return traceId; // gitignored or other add error — skip commit
117
+ const msg = `[${this._currentPhase}] ${opLabel} ${relPath} [trace:${traceId}]`;
118
+ spawnSync("git", ["commit", "-m", msg, "--allow-empty-message"], {
119
+ cwd: this.path,
120
+ stdio: "ignore",
121
+ });
122
+ // Status doesn't matter — "nothing to commit" is fine.
123
+ } catch {
124
+ // Never let a git failure break a workspace write.
125
+ }
126
+ return traceId;
127
+ }
128
+
129
+ /**
130
+ * Rename the workspace folder. Returns `{ sessionId, oldCwd, newCwd }` so
131
+ * callers can cascade the new path to subsystems that captured cwd at
132
+ * construction (Bug 3).
70
133
  * @param {string} newName
71
- * @returns {string}
134
+ * @returns {{sessionId: string, oldCwd: string, newCwd: string, changed: boolean}}
72
135
  */
73
136
  rename(newName) {
74
137
  newName = newName.trim().replace(/ /g, "_").replace(/\//g, "_");
75
138
  if (!newName) throw new Error("Name cannot be empty");
76
139
  const newPath = path.join(this.root, newName);
140
+ const oldCwd = this.path;
77
141
  if (fs.existsSync(newPath) && path.resolve(newPath) !== path.resolve(this.path)) {
78
142
  throw new Error(`Session '${newName}' already exists`);
79
143
  }
144
+ let changed = false;
80
145
  if (path.resolve(newPath) !== path.resolve(this.path)) {
81
146
  fs.renameSync(this.path, newPath);
82
147
  this.path = path.resolve(newPath);
83
148
  this.sessionId = newName;
149
+ changed = true;
84
150
  }
85
- return this.sessionId;
151
+ return { sessionId: this.sessionId, oldCwd, newCwd: this.path, changed };
86
152
  }
87
153
 
88
154
  /**
@@ -102,4 +168,61 @@ export class Workspace {
102
168
  }
103
169
  return sessions;
104
170
  }
171
+
172
+ /** Probe whether the `git` executable is on PATH. Cached per process. */
173
+ static isGitInstalled() {
174
+ if (Workspace._gitProbeCache !== undefined) return Workspace._gitProbeCache;
175
+ try {
176
+ execFileSync("git", ["--version"], { stdio: "ignore" });
177
+ Workspace._gitProbeCache = true;
178
+ } catch {
179
+ Workspace._gitProbeCache = false;
180
+ }
181
+ return Workspace._gitProbeCache;
182
+ }
183
+
184
+ // --- private helpers ---
185
+
186
+ _initGitRepo() {
187
+ const gitDir = path.join(this.path, ".git");
188
+ const gitignorePath = path.join(this.path, ".gitignore");
189
+ const isFresh = !fs.existsSync(gitDir);
190
+
191
+ if (isFresh) {
192
+ try {
193
+ spawnSync("git", ["init", "--initial-branch=main"], { cwd: this.path, stdio: "ignore" });
194
+ // --initial-branch isn't supported on older git; fall back silently
195
+ if (!fs.existsSync(gitDir)) {
196
+ spawnSync("git", ["init"], { cwd: this.path, stdio: "ignore" });
197
+ }
198
+ // Local identity so commits don't depend on user's global config
199
+ spawnSync("git", ["config", "user.name", "kc-agent"], { cwd: this.path, stdio: "ignore" });
200
+ spawnSync("git", ["config", "user.email", "agent@kc.local"], { cwd: this.path, stdio: "ignore" });
201
+ } catch {
202
+ this._gitAvailable = false;
203
+ return;
204
+ }
205
+ }
206
+
207
+ // Always ensure .gitignore is present (template may have evolved)
208
+ if (!fs.existsSync(gitignorePath) && fs.existsSync(GITIGNORE_TEMPLATE)) {
209
+ fs.copyFileSync(GITIGNORE_TEMPLATE, gitignorePath);
210
+ }
211
+
212
+ if (isFresh) {
213
+ // Initial commit — captures whatever's already in the dir (for migrated workspaces)
214
+ spawnSync("git", ["add", "-A"], { cwd: this.path, stdio: "ignore" });
215
+ const msg = fs.existsSync(path.join(this.path, "AGENT.md"))
216
+ ? `Migrated session ${this.sessionId} to git-tracked workspace`
217
+ : `Initialized session ${this.sessionId}`;
218
+ spawnSync("git", ["commit", "--allow-empty", "-m", msg], { cwd: this.path, stdio: "ignore" });
219
+ }
220
+ }
221
+
222
+ /** Extract rule ID from a path like rule_skills/R001/SKILL.md → "R001". */
223
+ _extractRuleId(relPath) {
224
+ const parts = relPath.split(path.sep);
225
+ if (parts.length >= 2 && /^R\d+/i.test(parts[1])) return parts[1];
226
+ return "global";
227
+ }
105
228
  }
@@ -42,12 +42,20 @@ export function StatusBar({ sessionId, phase, contextTokens, contextLimit }) {
42
42
  ? `${(contextLimit / 1000).toFixed(0)}k`
43
43
  : `${contextLimit || 0}`;
44
44
 
45
+ // Soft-threshold hint — shows up before auto-windowing kicks in at ~70%
46
+ // so users know they can run /compact to reduce context more aggressively
47
+ // than windowing does. Red hint at 80%+ means it's time to compact NOW.
48
+ const compactHint = pct >= 80 ? " · 💾 /compact"
49
+ : pct >= 60 ? " · 💾 建议 /compact"
50
+ : "";
51
+
45
52
  return h(Box, { marginTop: 0 },
46
53
  h(Text, { dimColor: true }, " ⏵⏵ KC Agent CLI "),
47
54
  h(Text, { dimColor: true }, sessionId ? `[${sessionId}]` : ""),
48
55
  phase ? h(Text, { color: "cyan" }, ` ${phase.toUpperCase()}`) : null,
49
56
  h(Text, { color: "green" }, " ● "),
50
57
  h(Text, { color: ctxColor }, `CTX: ${ctxLabel}/${limitLabel} (${pct}%)`),
58
+ compactHint ? h(Text, { color: ctxColor }, compactHint) : null,
51
59
  h(Text, { dimColor: true }, ` · ${LENAT_QUOTE}`),
52
60
  );
53
61
  }
@@ -88,7 +96,7 @@ export function TaskDashboard({ tasks, progress }) {
88
96
 
89
97
  // --- Welcome banner ---
90
98
 
91
- export function WelcomeBanner({ projectDir } = {}) {
99
+ export function WelcomeBanner({ projectDir, pendingInputCount = 0 } = {}) {
92
100
  return h(Box, { flexDirection: "column", marginBottom: 1, borderStyle: "round", borderColor: "gray", paddingLeft: 1, paddingRight: 1 },
93
101
  h(Box, null,
94
102
  h(Text, { bold: true }, "KC AGENT CLI"),
@@ -102,6 +110,9 @@ export function WelcomeBanner({ projectDir } = {}) {
102
110
  h(Text, { color: "yellow", dimColor: true }, "KC has full read/write access to this directory. We recommend backing up important files."),
103
111
  )
104
112
  : null,
113
+ pendingInputCount > 0
114
+ ? h(Text, { color: "cyan" }, `📥 ${pendingInputCount} new file(s) pending in input/ — run /schedule for details`)
115
+ : null,
105
116
  h(Text, null, ""),
106
117
  h(Text, { dimColor: true }, "Product of Memium / kitchen-engineer42"),
107
118
  );
@@ -109,29 +120,74 @@ export function WelcomeBanner({ projectDir } = {}) {
109
120
 
110
121
  // --- Tool block ---
111
122
 
112
- export function ToolBlock({ name, input, output, isError, isRunning }) {
123
+ /**
124
+ * Tool-result block.
125
+ *
126
+ * Rendering modes:
127
+ * - isRunning → yellow border, no output (spinner shown elsewhere).
128
+ * - isError → red border, ALWAYS show full output (errors are short + critical).
129
+ * - isRecent: true → green border, show up to ~4 lines + "N lines hidden" footer.
130
+ * - isRecent: false → header only (header includes line count + byte count).
131
+ *
132
+ * The full output is always on disk in logs/events.jsonl. Keeping the Ink
133
+ * tree slim is what lets KC handle long sessions without OOM / typing lag.
134
+ */
135
+ const RECENT_PREVIEW_LINES = 4;
136
+
137
+ export function ToolBlock({ name, input, output, isError, isRunning, isRecent = true }) {
113
138
  const borderColor = isRunning ? "yellow" : isError ? "red" : "green";
139
+ const outStr = typeof output === "string" ? output : "";
140
+ const lines = outStr ? outStr.split("\n") : [];
141
+ const bytes = outStr.length;
142
+
143
+ const header = h(Box, null,
144
+ h(Text, { color: borderColor }, "┃ "),
145
+ h(Text, { dimColor: true }, name),
146
+ input ? h(Text, { dimColor: true }, ` ${JSON.stringify(input).slice(0, 120)}`) : null,
147
+ outStr && !isRunning
148
+ ? h(Text, { dimColor: true }, ` (${lines.length} 行 / ${bytes} 字节)`)
149
+ : null,
150
+ );
151
+
152
+ // Errors: always show in full (short + critical).
153
+ if (isError && outStr) {
154
+ return h(Box, { flexDirection: "column", marginLeft: 2 },
155
+ header,
156
+ h(Box, { flexDirection: "column" },
157
+ ...lines.map((line, i) =>
158
+ h(Box, { key: i },
159
+ h(Text, { color: "red" }, "┃ "),
160
+ h(Text, { color: "red" }, line),
161
+ ),
162
+ ),
163
+ ),
164
+ );
165
+ }
166
+
167
+ // Off-screen / not-recent: header only. Full output remains on disk.
168
+ if (!isRecent || !outStr) {
169
+ return h(Box, { marginLeft: 2 }, header);
170
+ }
114
171
 
172
+ // Recent + successful: show preview + truncation footer.
173
+ const previewLines = lines.slice(0, RECENT_PREVIEW_LINES);
174
+ const remaining = lines.length - previewLines.length;
115
175
  return h(Box, { flexDirection: "column", marginLeft: 2 },
116
- h(Box, null,
117
- h(Text, { color: borderColor }, "┃ "),
118
- h(Text, { dimColor: true }, name),
119
- input ? h(Text, { dimColor: true }, ` ${JSON.stringify(input)}`) : null,
120
- ),
121
- output ? h(Box, { flexDirection: "column" },
122
- ...output.split("\n").slice(0, 20).map((line, i) =>
176
+ header,
177
+ h(Box, { flexDirection: "column" },
178
+ ...previewLines.map((line, i) =>
123
179
  h(Box, { key: i },
124
180
  h(Text, { color: borderColor }, "┃ "),
125
181
  h(Text, null, line),
126
182
  ),
127
183
  ),
128
- output.split("\n").length > 20
184
+ remaining > 0
129
185
  ? h(Box, null,
130
186
  h(Text, { color: borderColor }, "┃ "),
131
- h(Text, { dimColor: true }, `... ${output.split("\n").length - 20} more lines`),
187
+ h(Text, { dimColor: true }, `… ${remaining} 行已省略(在 logs/events.jsonl 中完整保留)`),
132
188
  )
133
189
  : null,
134
- ) : null,
190
+ ),
135
191
  );
136
192
  }
137
193