kc-beta 0.3.2 → 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 (43) 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-meta/bootstrap-workspace/SKILL.md +11 -0
  34. package/template/skills/en/meta-meta/quality-control/SKILL.md +13 -1
  35. package/template/skills/en/meta-meta/skill-to-workflow/SKILL.md +8 -0
  36. package/template/skills/en/meta-meta/task-decomposition/SKILL.md +13 -0
  37. package/template/skills/en/meta-meta/version-control/SKILL.md +13 -0
  38. package/template/skills/zh/meta-meta/bootstrap-workspace/SKILL.md +11 -0
  39. package/template/skills/zh/meta-meta/quality-control/SKILL.md +12 -0
  40. package/template/skills/zh/meta-meta/skill-to-workflow/SKILL.md +8 -0
  41. package/template/skills/zh/meta-meta/task-decomposition/SKILL.md +16 -0
  42. package/template/skills/zh/meta-meta/version-control/SKILL.md +13 -0
  43. package/template/workspace.gitignore +22 -0
@@ -12,15 +12,28 @@ import { estimateTokens } from "./token-counter.js";
12
12
  export class EventLog {
13
13
  /**
14
14
  * @param {string} workspacePath - Session workspace directory
15
+ * @param {object} [opts]
16
+ * @param {string} [opts.logDir] - Override absolute path for the events directory
17
+ * (used for sub-agent isolation, Bug 2)
15
18
  */
16
- constructor(workspacePath) {
17
- this._dir = path.join(workspacePath, "logs");
19
+ constructor(workspacePath, opts = {}) {
20
+ this._dir = opts.logDir || path.join(workspacePath, "logs");
18
21
  this._logPath = path.join(this._dir, "events.jsonl");
19
22
  this._seq = 0;
20
23
  this._estimatedTokens = 0;
21
24
  this._initFromExisting();
22
25
  }
23
26
 
27
+ /**
28
+ * Re-point this event log at a new directory. Used by
29
+ * `engine.renameSession()` (Bug 3).
30
+ */
31
+ _setWorkspacePath(newWorkspacePath, opts = {}) {
32
+ this._dir = opts.logDir || path.join(newWorkspacePath, "logs");
33
+ this._logPath = path.join(this._dir, "events.jsonl");
34
+ // Sequence counter stays — we keep counting from where we left off.
35
+ }
36
+
24
37
  /** Current sequence number */
25
38
  get currentSeq() { return this._seq; }
26
39
 
@@ -1,31 +1,42 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { estimateTokens } from "./token-counter.js";
4
+
5
+ // Belt-and-suspenders cap on any single message's content. Block 11 tool-call
6
+ // offloading already prevents tool outputs from getting this big in normal use,
7
+ // but old/migrated workspaces and edge cases benefit from a hard ceiling that
8
+ // keeps a single bloated message from blowing the model's context budget alone.
9
+ const DEFAULT_MAX_MESSAGE_TOKENS = 30000;
3
10
 
4
11
  /**
5
12
  * Manages the message list for the OpenAI-compatible API.
6
- * Persists to workspace/logs/conversation/ on every write.
7
- * Loads existing history when workspacePath is provided.
13
+ * Persists to <conversationDir>/ on every write.
14
+ *
15
+ * @param {string} [workspacePath] - Workspace directory (default conversation dir is workspacePath/logs/conversation/)
16
+ * @param {object} [opts]
17
+ * @param {string} [opts.conversationDir] - Override absolute path (used for sub-agent isolation, Bug 2)
18
+ * @param {number} [opts.maxMessageTokens] - Per-message content cap (default 30000)
8
19
  */
9
20
  export class ConversationHistory {
10
- /**
11
- * @param {string} [workspacePath] - Workspace directory for persistence
12
- */
13
- constructor(workspacePath) {
21
+ constructor(workspacePath, opts = {}) {
14
22
  /** @type {Array<object>} API messages */
15
23
  this._messages = [];
16
24
  /** @type {Array<object>} Flat display log for replay */
17
25
  this._displayLog = [];
18
26
  this._workspacePath = workspacePath || null;
27
+ this._conversationDir = opts.conversationDir || (workspacePath ? path.join(workspacePath, "logs", "conversation") : null);
28
+ this._maxMessageTokens = opts.maxMessageTokens ?? DEFAULT_MAX_MESSAGE_TOKENS;
19
29
 
20
- if (this._workspacePath) this._load();
30
+ if (this._conversationDir) this._load();
21
31
  }
22
32
 
23
33
  get messages() { return this._messages; }
24
34
  get displayLog() { return this._displayLog; }
25
35
 
26
36
  addUser(text) {
27
- this._messages.push({ role: "user", content: text });
28
- this._displayLog.push({ role: "user", content: text });
37
+ const capped = this._capContent(text);
38
+ this._messages.push({ role: "user", content: capped });
39
+ this._displayLog.push({ role: "user", content: capped });
29
40
  this._save();
30
41
  }
31
42
 
@@ -34,15 +45,19 @@ export class ConversationHistory {
34
45
  * @param {object} message
35
46
  */
36
47
  addRaw(message) {
37
- this._messages.push(message);
48
+ const msg = { ...message };
49
+ if (typeof msg.content === "string") {
50
+ msg.content = this._capContent(msg.content);
51
+ }
52
+ this._messages.push(msg);
38
53
 
39
- const role = message.role || "";
54
+ const role = msg.role || "";
40
55
  if (role === "assistant") {
41
- const content = message.content || "";
56
+ const content = msg.content || "";
42
57
  if (content) {
43
58
  this._displayLog.push({ role: "agent", content });
44
59
  }
45
- for (const tc of message.tool_calls || []) {
60
+ for (const tc of msg.tool_calls || []) {
46
61
  const fn = tc.function || {};
47
62
  let toolInput = {};
48
63
  try { toolInput = JSON.parse(fn.arguments || "{}"); } catch { /* ignore */ }
@@ -53,7 +68,7 @@ export class ConversationHistory {
53
68
  });
54
69
  }
55
70
  } else if (role === "tool") {
56
- const content = message.content || "";
71
+ const content = msg.content || "";
57
72
  // Update the last tool entry with output
58
73
  for (let i = this._displayLog.length - 1; i >= 0; i--) {
59
74
  if (this._displayLog[i].role === "tool" && !("toolOutput" in this._displayLog[i])) {
@@ -66,33 +81,86 @@ export class ConversationHistory {
66
81
  this._save();
67
82
  }
68
83
 
84
+ /**
85
+ * Cap a single message's content if it exceeds maxMessageTokens. Cuts the
86
+ * middle, keeps head + tail, leaves a marker pointing at logs/events.jsonl
87
+ * for the full content (event log keeps everything via appendFileSync).
88
+ *
89
+ * CJK-aware: derives the character budget from a sample-based chars/token
90
+ * ratio. Latin sample ≈ 4 chars/token, CJK ≈ 0.67 chars/token. Iterative
91
+ * tightening converges on the cap; clamping prevents accidental doubling
92
+ * if the heuristic is ever wrong.
93
+ */
94
+ _capContent(content) {
95
+ if (typeof content !== "string" || !content) return content;
96
+ const tokens = estimateTokens(content);
97
+ if (tokens <= this._maxMessageTokens) return content;
98
+
99
+ const target = Math.max(100, this._maxMessageTokens - 50); // reserve for marker
100
+ const ratio = this._charsPerToken(content);
101
+ let charBudget = Math.max(100, Math.floor(target * ratio));
102
+
103
+ for (let i = 0; i < 5; i++) {
104
+ const head = Math.min(Math.floor(charBudget * 0.6), content.length);
105
+ const tail = Math.min(Math.floor(charBudget * 0.3), Math.max(0, content.length - head));
106
+ // If proposed slices would cover the whole input, truncating creates
107
+ // no savings — return original (the safety-net cap rather doubles than truncates).
108
+ if (head + tail >= content.length) return content;
109
+ const result = content.slice(0, head) + this._truncMarker(tokens) + content.slice(-tail);
110
+ if (estimateTokens(result) <= this._maxMessageTokens) return result;
111
+ charBudget = Math.floor(charBudget * 0.7);
112
+ }
113
+
114
+ // Last resort after 5 iterations: head only, no tail.
115
+ const headOnly = Math.min(Math.max(charBudget, 100), content.length);
116
+ return content.slice(0, headOnly) + this._truncMarker(tokens);
117
+ }
118
+
119
+ /** Sample-based chars-per-token ratio. Inverse of estimateTokens rate. */
120
+ _charsPerToken(content) {
121
+ const sample = content.slice(0, 2000);
122
+ const t = estimateTokens(sample) || 1;
123
+ return Math.max(0.5, sample.length / t);
124
+ }
125
+
126
+ _truncMarker(originalTokens) {
127
+ return `\n\n[…truncated, ${originalTokens} tokens; full content in logs/events.jsonl…]\n\n`;
128
+ }
129
+
130
+ /**
131
+ * Re-point this history at a new conversation directory. Used by
132
+ * `engine.renameSession()` (Bug 3) when the workspace is renamed.
133
+ */
134
+ _setWorkspacePath(newWorkspacePath, opts = {}) {
135
+ this._workspacePath = newWorkspacePath;
136
+ this._conversationDir = opts.conversationDir || path.join(newWorkspacePath, "logs", "conversation");
137
+ }
138
+
69
139
  _save() {
70
- if (!this._workspacePath) return;
71
- const convDir = path.join(this._workspacePath, "logs", "conversation");
72
- fs.mkdirSync(convDir, { recursive: true });
140
+ if (!this._conversationDir) return;
141
+ fs.mkdirSync(this._conversationDir, { recursive: true });
73
142
  fs.writeFileSync(
74
- path.join(convDir, "messages.json"),
143
+ path.join(this._conversationDir, "messages.json"),
75
144
  JSON.stringify(this._messages, null, 2),
76
145
  "utf-8",
77
146
  );
78
147
  fs.writeFileSync(
79
- path.join(convDir, "display.json"),
148
+ path.join(this._conversationDir, "display.json"),
80
149
  JSON.stringify(this._displayLog, null, 2),
81
150
  "utf-8",
82
151
  );
83
152
  }
84
153
 
85
154
  _load() {
86
- if (!this._workspacePath) return;
87
- const convDir = path.join(this._workspacePath, "logs", "conversation");
155
+ if (!this._conversationDir) return;
88
156
 
89
- const msgPath = path.join(convDir, "messages.json");
157
+ const msgPath = path.join(this._conversationDir, "messages.json");
90
158
  if (fs.existsSync(msgPath)) {
91
159
  try { this._messages = JSON.parse(fs.readFileSync(msgPath, "utf-8")); }
92
160
  catch { this._messages = []; }
93
161
  }
94
162
 
95
- const displayPath = path.join(convDir, "display.json");
163
+ const displayPath = path.join(this._conversationDir, "display.json");
96
164
  if (fs.existsSync(displayPath)) {
97
165
  try { this._displayLog = JSON.parse(fs.readFileSync(displayPath, "utf-8")); }
98
166
  catch { this._displayLog = []; }
@@ -73,15 +73,12 @@ export class ProjectInitializer extends Pipeline {
73
73
  fs.writeFileSync(envPath, envContent, "utf-8");
74
74
  }
75
75
 
76
- const manifestPath = path.join(this._workspace.cwd, "versions.json");
77
- if (!fs.existsSync(manifestPath)) {
78
- fs.writeFileSync(manifestPath, JSON.stringify({ version: "0.1.0", entries: [] }, null, 2), "utf-8");
79
- }
80
-
81
- // AGENT.md — per-project context (agent can modify)
76
+ // AGENT.md per-project context (agent can modify). Auto-commit so
77
+ // git captures the seed.
82
78
  const agentMdPath = path.join(this._workspace.cwd, "AGENT.md");
83
79
  if (!fs.existsSync(agentMdPath) && fs.existsSync(AGENT_MD_TEMPLATE)) {
84
80
  fs.copyFileSync(AGENT_MD_TEMPLATE, agentMdPath);
81
+ this._workspace.autoCommit?.("AGENT.md", "seed");
85
82
  }
86
83
 
87
84
  this.workspaceCreated = true;
@@ -12,18 +12,26 @@ const JITTER_FRACTION = 0.2;
12
12
  const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504, 520, 522, 524]);
13
13
  const NON_RETRYABLE_STATUS = new Set([400, 401, 403, 404, 422]);
14
14
 
15
+ // Phrases that indicate the request itself is too large for the model.
16
+ // Some providers return these as 400 (correct), others as 500/503 (incorrect),
17
+ // but in either case retrying just delays the inevitable failure.
18
+ const CONTEXT_LENGTH_PATTERNS = /context_length|context length|maximum context|too many tokens|prompt is too long|input length|input is too long|exceeds.{0,20}context|exceeded the max|reduce the length/i;
19
+
15
20
  /**
16
21
  * Determine if an error is retryable.
17
22
  * @param {Error} err
18
23
  * @returns {boolean}
19
24
  */
20
25
  function isRetryable(err) {
26
+ const msg = err.message || "";
27
+ // Context-length errors are non-retryable regardless of status code
28
+ if (CONTEXT_LENGTH_PATTERNS.test(msg)) return false;
29
+
21
30
  if (err.status) {
22
31
  if (NON_RETRYABLE_STATUS.has(err.status)) return false;
23
32
  if (RETRYABLE_STATUS.has(err.status)) return true;
24
33
  }
25
34
  // Network errors (ECONNRESET, ETIMEDOUT, fetch TypeError, AbortError)
26
- const msg = err.message || "";
27
35
  if (/ECONNRESET|ETIMEDOUT|ENOTFOUND|ECONNREFUSED|UND_ERR|fetch failed|network|socket hang up/i.test(msg)) {
28
36
  return true;
29
37
  }
@@ -0,0 +1,276 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const SCHEDULES_REL = "schedules.json";
5
+ const WRAPPERS_DIR_REL = path.join("scripts", "ingest");
6
+ const INGEST_LOG_REL = path.join("logs", "ingest.log");
7
+ const VALID_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
8
+
9
+ /**
10
+ * Per-session schedule registry + wrapper-script generator.
11
+ *
12
+ * KC defines fetch jobs (each is a shell command). For each enabled job,
13
+ * Scheduler renders a self-contained wrapper script under
14
+ * `workspace/scripts/ingest/<id>.sh`. The user installs the wrapper into
15
+ * their crontab via `crontab -e`. Cron invokes the script directly — no
16
+ * kc-beta runtime dependency, works while kc-beta is closed.
17
+ *
18
+ * The wrapper:
19
+ * - exports WORKSPACE / INPUT_DIR / PROJECT_DIR env vars
20
+ * - snapshots input/ filenames before running
21
+ * - runs the user's command
22
+ * - prefixes any new files in input/ with `<job-id>_<YYYYMMDD-HHMMSS>_`
23
+ * - appends to logs/ingest.log
24
+ */
25
+ export class Scheduler {
26
+ /**
27
+ * @param {import('./workspace.js').Workspace} workspace
28
+ */
29
+ constructor(workspace) {
30
+ this._workspace = workspace;
31
+ }
32
+
33
+ // --- registry ---
34
+
35
+ load() {
36
+ const p = this._abs(SCHEDULES_REL);
37
+ if (!fs.existsSync(p)) return { version: 1, jobs: [] };
38
+ try {
39
+ const data = JSON.parse(fs.readFileSync(p, "utf-8"));
40
+ if (!data.jobs) return { version: 1, jobs: [] };
41
+ return data;
42
+ } catch {
43
+ return { version: 1, jobs: [] };
44
+ }
45
+ }
46
+
47
+ save(state) {
48
+ const p = this._abs(SCHEDULES_REL);
49
+ fs.mkdirSync(path.dirname(p), { recursive: true });
50
+ fs.writeFileSync(p, JSON.stringify(state, null, 2), "utf-8");
51
+ this._workspace.autoCommit?.(SCHEDULES_REL, "schedules");
52
+ }
53
+
54
+ list() {
55
+ return this.load().jobs;
56
+ }
57
+
58
+ /**
59
+ * Add a job. Returns { ok: true, job } or { ok: false, reason }.
60
+ * @param {{id:string, command:string, description?:string, cron_hint?:string, enabled?:boolean}} input
61
+ */
62
+ add(input) {
63
+ const id = (input.id || "").trim();
64
+ const command = (input.command || "").trim();
65
+ if (!VALID_ID.test(id)) return { ok: false, reason: `id must match ${VALID_ID} (got '${id}')` };
66
+ if (!command) return { ok: false, reason: "command is required" };
67
+
68
+ const state = this.load();
69
+ if (state.jobs.find((j) => j.id === id)) {
70
+ return { ok: false, reason: `job '${id}' already exists; remove first or pick another id` };
71
+ }
72
+
73
+ const job = {
74
+ id,
75
+ type: "shell",
76
+ command,
77
+ description: input.description || "",
78
+ cron_hint: input.cron_hint || "",
79
+ enabled: input.enabled !== false,
80
+ created_at: new Date().toISOString(),
81
+ };
82
+ state.jobs.push(job);
83
+ this.save(state);
84
+ if (job.enabled) this.renderWrapper(job);
85
+ return { ok: true, job };
86
+ }
87
+
88
+ remove(id) {
89
+ const state = this.load();
90
+ const before = state.jobs.length;
91
+ state.jobs = state.jobs.filter((j) => j.id !== id);
92
+ if (state.jobs.length === before) return { ok: false, reason: `no job '${id}'` };
93
+ this.save(state);
94
+ this._removeWrapper(id);
95
+ return { ok: true };
96
+ }
97
+
98
+ setEnabled(id, enabled) {
99
+ const state = this.load();
100
+ const job = state.jobs.find((j) => j.id === id);
101
+ if (!job) return { ok: false, reason: `no job '${id}'` };
102
+ job.enabled = !!enabled;
103
+ this.save(state);
104
+ if (job.enabled) this.renderWrapper(job);
105
+ else this._removeWrapper(id);
106
+ return { ok: true, job };
107
+ }
108
+
109
+ // --- wrapper script ---
110
+
111
+ /**
112
+ * Render the per-job wrapper script. Returns absolute path.
113
+ * @param {object} job
114
+ */
115
+ renderWrapper(job) {
116
+ const wrappersDirAbs = this._abs(WRAPPERS_DIR_REL);
117
+ fs.mkdirSync(wrappersDirAbs, { recursive: true });
118
+ const scriptRel = path.join(WRAPPERS_DIR_REL, `${job.id}.sh`);
119
+ const scriptAbs = this._abs(scriptRel);
120
+
121
+ const wsAbs = this._workspace.cwd;
122
+ const projectAbs = this._workspace.projectDir || "";
123
+ const ingestLogAbs = this._abs(INGEST_LOG_REL);
124
+ const inputDirAbs = path.join(wsAbs, "input");
125
+
126
+ // Uses `find -newer` against a sentinel file to detect new arrivals.
127
+ // Portable across macOS BSD find and GNU find (no -printf, no comm/process-sub).
128
+ const body = [
129
+ "#!/bin/sh",
130
+ "# KC Agent ingestion wrapper",
131
+ `# Job: ${job.id}`,
132
+ `# Description: ${(job.description || "").replace(/[\r\n]/g, " ")}`,
133
+ `# Generated: ${new Date().toISOString()}`,
134
+ "# Do not edit by hand — regenerated by `schedule_fetch`.",
135
+ "",
136
+ "set -u",
137
+ `WORKSPACE=${shQuote(wsAbs)}`,
138
+ `INPUT_DIR=${shQuote(inputDirAbs)}`,
139
+ `PROJECT_DIR=${shQuote(projectAbs)}`,
140
+ `LOG_FILE=${shQuote(ingestLogAbs)}`,
141
+ `JOB_ID=${shQuote(job.id)}`,
142
+ 'export WORKSPACE INPUT_DIR PROJECT_DIR',
143
+ "",
144
+ 'mkdir -p "$INPUT_DIR" "$(dirname "$LOG_FILE")"',
145
+ 'TS=$(date -u +%Y%m%d-%H%M%S)',
146
+ "",
147
+ '# Sentinel file for `find -newer` to detect arrivals',
148
+ 'SENTINEL=$(mktemp -t kc_ingest_sentinel.XXXXXX) || exit 1',
149
+ 'sleep 1 # ensure mtime granularity',
150
+ "",
151
+ `echo "[$TS] job=$JOB_ID start" >> "$LOG_FILE"`,
152
+ `{ ${job.command}; } >> "$LOG_FILE" 2>&1`,
153
+ 'EXIT_CODE=$?',
154
+ "",
155
+ "# Rename any files newer than the sentinel: prepend job id + timestamp.",
156
+ "# Skip files already prefixed with this job id (idempotent re-runs).",
157
+ 'find "$INPUT_DIR" -maxdepth 1 -type f -newer "$SENTINEL" 2>/dev/null | while IFS= read -r f; do',
158
+ ' base=$(basename "$f")',
159
+ ' case "$base" in',
160
+ ` "$\{JOB_ID\}_"*) continue ;;`,
161
+ ' esac',
162
+ ' mv "$f" "$INPUT_DIR/${JOB_ID}_${TS}_${base}"',
163
+ 'done',
164
+ "",
165
+ 'rm -f "$SENTINEL"',
166
+ `echo "[$TS] job=$JOB_ID exit=$EXIT_CODE" >> "$LOG_FILE"`,
167
+ 'exit $EXIT_CODE',
168
+ "",
169
+ ].join("\n");
170
+
171
+ fs.writeFileSync(scriptAbs, body, "utf-8");
172
+ fs.chmodSync(scriptAbs, 0o755);
173
+ this._workspace.autoCommit?.(scriptRel, "wrapper");
174
+ return scriptAbs;
175
+ }
176
+
177
+ _removeWrapper(id) {
178
+ const scriptRel = path.join(WRAPPERS_DIR_REL, `${id}.sh`);
179
+ const scriptAbs = this._abs(scriptRel);
180
+ if (fs.existsSync(scriptAbs)) {
181
+ fs.unlinkSync(scriptAbs);
182
+ this._workspace.autoCommit?.(scriptRel, "wrapper-remove");
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Generate paste-ready crontab lines for all enabled jobs.
188
+ * If a job lacks `cron_hint`, the line is commented out with a hint to fill it in.
189
+ * @returns {string}
190
+ */
191
+ formatCrontab() {
192
+ const jobs = this.list().filter((j) => j.enabled);
193
+ if (jobs.length === 0) return "(no enabled jobs to install)";
194
+
195
+ const lines = [
196
+ "# KC Agent ingestion jobs — install with `crontab -e` then paste these lines.",
197
+ `# Generated ${new Date().toISOString()}`,
198
+ "",
199
+ ];
200
+ for (const j of jobs) {
201
+ const scriptAbs = this._abs(path.join(WRAPPERS_DIR_REL, `${j.id}.sh`));
202
+ if (j.cron_hint) {
203
+ lines.push(`# ${j.description || j.id}`);
204
+ lines.push(`${j.cron_hint} ${scriptAbs}`);
205
+ } else {
206
+ lines.push(`# ${j.description || j.id} — set the schedule yourself (5 cron fields)`);
207
+ lines.push(`# <minute> <hour> <day-of-month> <month> <day-of-week> ${scriptAbs}`);
208
+ }
209
+ lines.push("");
210
+ }
211
+ return lines.join("\n").trimEnd();
212
+ }
213
+
214
+ /**
215
+ * Tail of logs/ingest.log (last N lines), or empty string if missing.
216
+ */
217
+ tailLog(lines = 20) {
218
+ const p = this._abs(INGEST_LOG_REL);
219
+ if (!fs.existsSync(p)) return "";
220
+ const body = fs.readFileSync(p, "utf-8");
221
+ return body.split("\n").filter(Boolean).slice(-lines).join("\n");
222
+ }
223
+
224
+ /**
225
+ * Count of files directly under input/ (excluding subdirs like archived/).
226
+ */
227
+ pendingInputCount() {
228
+ const dir = path.join(this._workspace.cwd, "input");
229
+ if (!fs.existsSync(dir)) return 0;
230
+ try {
231
+ return fs.readdirSync(dir, { withFileTypes: true })
232
+ .filter((e) => e.isFile())
233
+ .length;
234
+ } catch {
235
+ return 0;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Regenerate wrapper scripts for all enabled jobs. Used by
241
+ * `engine.renameSession()` (Bug 3) — wrappers bake in absolute paths to
242
+ * the workspace, so a rename needs them re-rendered with the new paths.
243
+ *
244
+ * Returns `{ regenerated, disabled, failed }`:
245
+ * - `regenerated`: ids of jobs whose wrappers were successfully re-rendered
246
+ * - `disabled`: ids of jobs we skipped because they're disabled (no wrapper expected)
247
+ * - `failed`: list of `{id, error}` for jobs whose render call threw
248
+ * Splitting "skipped" into disabled vs failed lets the CLI surface render
249
+ * failures (e.g. disk full) instead of silently swallowing them.
250
+ */
251
+ regenerateAllWrappers() {
252
+ const out = { regenerated: [], disabled: [], failed: [] };
253
+ for (const job of this.list()) {
254
+ if (!job.enabled) { out.disabled.push(job.id); continue; }
255
+ try {
256
+ this.renderWrapper(job);
257
+ out.regenerated.push(job.id);
258
+ } catch (e) {
259
+ out.failed.push({ id: job.id, error: e?.message || String(e) });
260
+ }
261
+ }
262
+ return out;
263
+ }
264
+
265
+ // --- helpers ---
266
+
267
+ _abs(rel) {
268
+ return path.join(this._workspace.cwd, rel);
269
+ }
270
+ }
271
+
272
+ /** POSIX shell-quote a string. */
273
+ function shQuote(s) {
274
+ if (s === "") return "''";
275
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
276
+ }
@@ -10,9 +10,18 @@ import path from "node:path";
10
10
  export class SessionState {
11
11
  /**
12
12
  * @param {string} workspacePath - Session workspace directory
13
+ * @param {object} [opts]
14
+ * @param {string} [opts.statePath] - Override absolute path (used for sub-agent isolation, Bug 2)
13
15
  */
14
- constructor(workspacePath) {
15
- this._path = path.join(workspacePath, "session-state.json");
16
+ constructor(workspacePath, opts = {}) {
17
+ this._path = opts.statePath || path.join(workspacePath, "session-state.json");
18
+ }
19
+
20
+ /**
21
+ * Re-point at a new state file. Used by `engine.renameSession()` (Bug 3).
22
+ */
23
+ _setWorkspacePath(newWorkspacePath, opts = {}) {
24
+ this._path = opts.statePath || path.join(newWorkspacePath, "session-state.json");
16
25
  }
17
26
 
18
27
  /** Whether a session state file exists */
@@ -16,6 +16,11 @@ export class TaskManager {
16
16
  this._load();
17
17
  }
18
18
 
19
+ /** Re-point at a new tasks.json. Used by `engine.renameSession()` (Bug 3). */
20
+ _setWorkspacePath(newWorkspacePath) {
21
+ this._path = path.join(newWorkspacePath, "tasks.json");
22
+ }
23
+
19
24
  // --- Task CRUD ---
20
25
 
21
26
  /**