kc-beta 0.6.2 → 0.7.0

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 (52) hide show
  1. package/LICENSE +81 -0
  2. package/LICENSE-COMMERCIAL.md +125 -0
  3. package/README.md +21 -3
  4. package/package.json +14 -5
  5. package/src/agent/context-window.js +9 -12
  6. package/src/agent/context.js +14 -1
  7. package/src/agent/document-parser.js +169 -0
  8. package/src/agent/engine.js +367 -18
  9. package/src/agent/history/event-history.js +222 -0
  10. package/src/agent/llm-client.js +55 -0
  11. package/src/agent/message-utils.js +63 -0
  12. package/src/agent/pipelines/_milestone-derive.js +511 -0
  13. package/src/agent/pipelines/base.js +21 -0
  14. package/src/agent/pipelines/distillation.js +28 -15
  15. package/src/agent/pipelines/extraction.js +103 -36
  16. package/src/agent/pipelines/finalization.js +178 -11
  17. package/src/agent/pipelines/index.js +6 -1
  18. package/src/agent/pipelines/initializer.js +74 -8
  19. package/src/agent/pipelines/production-qc.js +31 -44
  20. package/src/agent/pipelines/skill-authoring.js +97 -80
  21. package/src/agent/pipelines/skill-testing.js +67 -23
  22. package/src/agent/retry.js +10 -2
  23. package/src/agent/scheduler.js +14 -2
  24. package/src/agent/session-state.js +18 -1
  25. package/src/agent/skill-loader.js +13 -7
  26. package/src/agent/skill-validator.js +19 -5
  27. package/src/agent/task-manager.js +61 -5
  28. package/src/agent/tools/document-chunk.js +21 -9
  29. package/src/agent/tools/phase-advance.js +18 -3
  30. package/src/agent/tools/release.js +51 -9
  31. package/src/agent/tools/rule-catalog.js +11 -1
  32. package/src/agent/tools/workspace-file.js +32 -0
  33. package/src/agent/workspace.js +39 -1
  34. package/src/cli/components.js +64 -14
  35. package/src/cli/index.js +62 -3
  36. package/src/cli/meme.js +26 -25
  37. package/src/config.js +65 -22
  38. package/src/model-tiers.json +24 -8
  39. package/src/providers.js +42 -0
  40. package/template/release/v1/README.md.tmpl +108 -0
  41. package/template/release/v1/catalog.json.tmpl +4 -0
  42. package/template/release/v1/kc_runtime/__init__.py +11 -0
  43. package/template/release/v1/kc_runtime/confidence.py +63 -0
  44. package/template/release/v1/kc_runtime/doc_parser.py +127 -0
  45. package/template/release/v1/manifest.json.tmpl +11 -0
  46. package/template/release/v1/render_dashboard.py +117 -0
  47. package/template/release/v1/run.py +212 -0
  48. package/template/release/v1/serve.sh +17 -0
  49. package/template/skills/en/meta-meta/work-decomposition/SKILL.md +266 -0
  50. package/template/skills/en/skill-creator/SKILL.md +1 -1
  51. package/template/skills/zh/meta-meta/work-decomposition/SKILL.md +264 -0
  52. package/template/skills/zh/skill-creator/SKILL.md +1 -1
@@ -104,10 +104,25 @@ export class PhaseAdvanceTool extends BaseTool {
104
104
  return new ToolResult(`Advanced${beforePhase ? ` from ${beforePhase}` : ""} to ${to}${input.force ? " (forced)" : ""}`);
105
105
  }
106
106
 
107
- // Truly refused — non-adjacent transition without force, or terminal-phase
108
- // forward attempt. Give the actionable hint.
107
+ // Truly refused — possible reasons: non-adjacent transition,
108
+ // terminal-phase forward attempt, or hard-tracking gate (source phase's
109
+ // exit criteria not met by engine telemetry).
110
+ //
111
+ // v0.7.0 A3: refusal text no longer advertises `force:true`. E2E #5
112
+ // showed every conductor reading the old refusal hint and force-bypassing
113
+ // immediately (12/12 transitions). The escape valve remains in the input
114
+ // schema (discoverable) but isn't hand-fed to the LLM here. Instead,
115
+ // direct the agent at the missing milestones it can satisfy.
109
116
  return new ToolResult(
110
- `Did not advance to ${to}. Transition is non-adjacent${beforePhase ? ` (currently in ${beforePhase})` : ""} set force:true to override, or advance to the immediate-next phase first.`,
117
+ `Did not advance to ${to} (currently in ${beforePhase || "?"}). ` +
118
+ `Likely cause: source-phase exit criteria not met. ` +
119
+ `Run /status (or read the phase describeState block in this turn's system reminder) ` +
120
+ `to see which milestones are missing, then produce the disk artifacts that satisfy them — ` +
121
+ `the engine derives milestones from filesystem facts (rule_skills/<id>/SKILL.md, check.py, ` +
122
+ `workflows/<id>/*.py, output/results/*.json, etc.). ` +
123
+ `If the transition is non-adjacent or this phase truly is done despite the gate, ` +
124
+ `re-call with the documented schema flag. The engine logged the precise reason in ` +
125
+ `events.jsonl as 'phase_advance_refused'.`,
111
126
  false,
112
127
  );
113
128
  }
@@ -151,8 +151,23 @@ export class ReleaseTool extends BaseTool {
151
151
  }
152
152
 
153
153
  if (ruleEntries.length === 0) {
154
+ // v0.7.0 #98: actionable error. The previous "no workflows found
155
+ // for any selected rule" message left agents confused (E2E #5 GLM
156
+ // dismissed it with "不影响系统功能" and shipped anyway, with a
157
+ // broken bundle). Spell out the canonical layout, the accepted
158
+ // alternatives, and where the agent's actual files are.
154
159
  return new ToolResult(
155
- `no workflows found for any selected rule. Missing: ${missingWorkflows.join(", ")}`,
160
+ `Release tool found no workflows for the selected rules.\n\n` +
161
+ `Missing: ${missingWorkflows.slice(0, 10).join(", ")}` +
162
+ (missingWorkflows.length > 10 ? ` (+ ${missingWorkflows.length - 10} more)` : "") + "\n\n" +
163
+ `Accepted layouts (release tool checks all three):\n` +
164
+ ` workflows/<ruleId>/workflow_v1.py (canonical)\n` +
165
+ ` workflows/<ruleId>_workflow.py (flat)\n` +
166
+ ` workflows/<ruleId>.json (regex_skill manifest with .entry path)\n\n` +
167
+ `If your workflow files exist under a different layout, either ` +
168
+ `relocate them or write a workflows/<ruleId>.json manifest pointing ` +
169
+ `at the actual file. Do NOT ship the release without workflows — ` +
170
+ `the run.py harness needs them at runtime.`,
156
171
  true,
157
172
  );
158
173
  }
@@ -251,15 +266,42 @@ export class ReleaseTool extends BaseTool {
251
266
  }
252
267
 
253
268
  _findLatestWorkflow(ruleId) {
269
+ // Canonical: workflows/<ruleId>/workflow_v#.py (subdirectory layout)
254
270
  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;
271
+ if (fs.existsSync(wfDir) && fs.statSync(wfDir).isDirectory()) {
272
+ const entries = fs.readdirSync(wfDir).sort();
273
+ const versioned = entries.filter((f) => /^workflow_v\d+\.py$/.test(f));
274
+ if (versioned.length > 0) return path.join(wfDir, versioned[versioned.length - 1]);
275
+ const any = entries.find((f) => f.endsWith(".py") && f.toLowerCase().includes("workflow"));
276
+ if (any) return path.join(wfDir, any);
277
+ const py = entries.find((f) => f.endsWith(".py"));
278
+ if (py) return path.join(wfDir, py);
279
+ }
280
+
281
+ // v0.7.0 #98: fall back to flat layouts seen in E2E #5 — both DS
282
+ // and GLM produced workflows that release.js's strict per-dir check
283
+ // missed. Accept these so the release tool actually packages the
284
+ // agent's work instead of returning "no workflows found".
285
+ const flatRoot = path.join(this._workspace.cwd, "workflows");
286
+ if (fs.existsSync(flatRoot)) {
287
+ // GLM-style flat: workflows/R001_workflow.py
288
+ const flat = path.join(flatRoot, `${ruleId}_workflow.py`);
289
+ if (fs.existsSync(flat) && fs.statSync(flat).isFile()) return flat;
290
+ // DS-style manifest: workflows/R001.json (regex_skill pointer)
291
+ const manifest = path.join(flatRoot, `${ruleId}.json`);
292
+ if (fs.existsSync(manifest) && fs.statSync(manifest).isFile()) {
293
+ try {
294
+ const data = JSON.parse(fs.readFileSync(manifest, "utf-8"));
295
+ if (data?.entry) {
296
+ const entryPath = path.isAbsolute(data.entry)
297
+ ? data.entry
298
+ : path.join(this._workspace.cwd, data.entry);
299
+ if (fs.existsSync(entryPath)) return entryPath;
300
+ }
301
+ } catch { /* manifest unreadable; skip */ }
302
+ }
303
+ }
304
+ return null;
263
305
  }
264
306
 
265
307
  _resolveFixture(rel) {
@@ -56,7 +56,17 @@ export class RuleCatalogTool extends BaseTool {
56
56
  constructor(workspace) {
57
57
  super();
58
58
  this._workspace = workspace;
59
- this._catalogPath = path.join(workspace.cwd, "rules", "catalog.json");
59
+ // v0.6.3: do NOT cache the absolute path here. engine.renameSession()
60
+ // moves the workspace dir via fs.renameSync and updates this._workspace.cwd
61
+ // via Workspace.rename(), but tool instances live in the registry and
62
+ // never see the cascade — a cached _catalogPath would keep writing to the
63
+ // pre-rename absolute path, mkdir-recreating the orphaned directory and
64
+ // stranding all rule_catalog state. Resolve on every call instead.
65
+ }
66
+
67
+ /** Always read workspace.cwd at call time so /rename is followed. */
68
+ get _catalogPath() {
69
+ return path.join(this._workspace.cwd, "rules", "catalog.json");
60
70
  }
61
71
 
62
72
  get name() { return "rule_catalog"; }
@@ -113,6 +113,37 @@ export class WorkspaceFileTool extends BaseTool {
113
113
  }
114
114
  const resolved = this._resolveForScope(filePath, scope);
115
115
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
116
+
117
+ // v0.7.0 Group M (#84 remainder): on case-insensitive filesystems
118
+ // (macOS/Windows defaults), warn when the target's basename collides
119
+ // with an existing sibling differing only in case. Write proceeds
120
+ // — agents may legitimately overwrite — but the agent gets visible
121
+ // signal so it doesn't end up confused like E2E #5 GLM ("SKILL.md
122
+ // disappeared" when the inode was shared with skill.md). Workspace-
123
+ // scope only; project-dir scope is the user's territory.
124
+ let collisionNote = "";
125
+ if (
126
+ scope === "workspace" &&
127
+ this._workspace.fsCaseSensitive === false
128
+ ) {
129
+ try {
130
+ const parent = path.dirname(resolved);
131
+ const targetBase = path.basename(resolved);
132
+ const targetLower = targetBase.toLowerCase();
133
+ const siblings = fs.readdirSync(parent);
134
+ const collision = siblings.find(
135
+ (s) => s !== targetBase && s.toLowerCase() === targetLower,
136
+ );
137
+ if (collision) {
138
+ collisionNote =
139
+ ` ⚠ case-collision: case-insensitive filesystem already has '${collision}'` +
140
+ ` at this path; both names resolve to the same inode. Pick one canonical case` +
141
+ ` (lowercase preferred for skill files) and use it consistently — otherwise` +
142
+ ` archive_file / Read on either name affects the other.`;
143
+ }
144
+ } catch { /* readdirSync may fail on a fresh dir; that's fine, no collision possible */ }
145
+ }
146
+
116
147
  fs.writeFileSync(resolved, content, "utf-8");
117
148
 
118
149
  // Auto-commit to git for workspace writes (silently no-ops if gitignored or git unavailable)
@@ -124,6 +155,7 @@ export class WorkspaceFileTool extends BaseTool {
124
155
  const label = scope === "project" ? `[project] ${filePath}` : filePath;
125
156
  let msg = `Wrote ${content.length} chars to ${label}`;
126
157
  if (traceId) msg += ` [trace: ${traceId}]`;
158
+ if (collisionNote) msg += collisionNote;
127
159
  return new ToolResult(msg);
128
160
  }
129
161
 
@@ -62,6 +62,38 @@ export class Workspace {
62
62
  this._gitAutoCommitEnabled = opts.gitAutoCommit !== false;
63
63
  this._gitAvailable = this._gitAutoCommitEnabled && Workspace.isGitInstalled();
64
64
  if (this._gitAvailable) this._initGitRepo();
65
+
66
+ // v0.7.0 F1: detect filesystem case-sensitivity once at construction.
67
+ // macOS HFS+/APFS default + Windows NTFS default are case-insensitive
68
+ // — `SKILL.md` and `skill.md` resolve to the same inode. E2E #5 GLM
69
+ // hit this exactly: wrote skill.md, then SKILL.md (overwrote it),
70
+ // then archive_file moved skill.md (moved both since same inode),
71
+ // then agent saw "SKILL.md disappeared". Surfacing the FS property
72
+ // here lets tools warn on case-collision attempts.
73
+ this.fsCaseSensitive = Workspace._probeCaseSensitivity(this.path);
74
+ }
75
+
76
+ /**
77
+ * v0.7.0 F1: cheap probe — write a known-case marker, stat its
78
+ * lowercased twin. If the stat succeeds, the FS folded the case →
79
+ * case-insensitive. Probe runs once per session at construction.
80
+ * Marker is deleted regardless. Returns true on case-sensitive (Linux
81
+ * default), false on case-insensitive (macOS/Windows defaults).
82
+ */
83
+ static _probeCaseSensitivity(dir) {
84
+ const probe = path.join(dir, ".kc-case-probe-MIXED");
85
+ const twin = path.join(dir, ".kc-case-probe-mixed");
86
+ try {
87
+ fs.writeFileSync(probe, "");
88
+ const sensitive = !fs.existsSync(twin);
89
+ try { fs.unlinkSync(probe); } catch { /* ignore */ }
90
+ return sensitive;
91
+ } catch {
92
+ // Couldn't probe (permission?); assume sensitive (Linux default)
93
+ // since false-positive on macOS doesn't break correctness, just
94
+ // misses the warning opportunity.
95
+ return true;
96
+ }
65
97
  }
66
98
 
67
99
  /** @returns {string} Current workspace directory */
@@ -249,7 +281,13 @@ export class Workspace {
249
281
  * `withFileLock` (async) for all paths that allow it.
250
282
  */
251
283
  withSyncFileLock(relPath, fn, { timeoutMs = 5_000, staleMs = 30_000 } = {}) {
252
- const lockPath = path.join(this.path, `${relPath}.lock`);
284
+ // v0.7.0 H3: route through resolvePath() so a caller passing
285
+ // "../../shared.json" can't write a lockfile outside the workspace
286
+ // root (the async sibling withFileLock already does this — sync
287
+ // version was the asymmetric weak spot). Cross-session lock-file
288
+ // contamination shut down.
289
+ const resolved = this.resolvePath(relPath);
290
+ const lockPath = `${resolved}.lock`;
253
291
  return this._withSyncLockAtPath(lockPath, fn, { timeoutMs, staleMs });
254
292
  }
255
293
 
@@ -52,19 +52,64 @@ const LENAT_QUOTE = "Intelligence is ten million rules.";
52
52
  // display jumpy. Peak stays at the highest seen this session.
53
53
  const CTX_SAMPLE_WINDOW = 30;
54
54
 
55
+ // Visual width of a string with CJK chars counted as 2 cells each.
56
+ // Used to truncate session IDs etc. so the status bar fits in a single
57
+ // terminal row regardless of terminal width.
58
+ function visualWidth(s) {
59
+ let w = 0;
60
+ for (const ch of s || "") {
61
+ const cp = ch.codePointAt(0);
62
+ // CJK + fullwidth + emoji rough heuristic — wide if codepoint ≥ 0x1100.
63
+ w += cp >= 0x1100 ? 2 : 1;
64
+ }
65
+ return w;
66
+ }
67
+
68
+ function truncateVisual(s, maxCells) {
69
+ if (!s) return s;
70
+ if (visualWidth(s) <= maxCells) return s;
71
+ // Middle-truncate so renamed sessions keep both ends visible
72
+ // (e.g. "资管新规测试062-GLM" → "资管新规…2-GLM" — model suffix preserved).
73
+ const headBudget = Math.floor((maxCells - 1) / 2);
74
+ const tailBudget = maxCells - 1 - headBudget;
75
+ const chars = [...s];
76
+ let w = 0; let head = "";
77
+ for (const ch of chars) {
78
+ const cw = ch.codePointAt(0) >= 0x1100 ? 2 : 1;
79
+ if (w + cw > headBudget) break;
80
+ w += cw; head += ch;
81
+ }
82
+ let tw = 0; let tail = "";
83
+ for (let i = chars.length - 1; i >= 0; i--) {
84
+ const ch = chars[i];
85
+ const cw = ch.codePointAt(0) >= 0x1100 ? 2 : 1;
86
+ if (tw + cw > tailBudget) break;
87
+ tw += cw; tail = ch + tail;
88
+ }
89
+ return head + "…" + tail;
90
+ }
91
+
55
92
  export function StatusBar({ sessionId, phase, contextTokens, contextLimit }) {
56
93
  const samplesRef = useRef([]);
57
94
  const peakRef = useRef(0);
58
95
 
59
- // Push current sample + cap the ring
96
+ // v0.7.0 C3: ref mutations live inside useEffect, not in the render
97
+ // body. React may invoke renders multiple times (StrictMode, concurrent
98
+ // rendering, suspense replay); mutating refs inline duplicated the
99
+ // pushed sample on every replay and let peak drift higher than the
100
+ // real history. Effect runs once per commit.
101
+ useEffect(() => {
102
+ const samples = samplesRef.current;
103
+ samples.push(contextTokens || 0);
104
+ if (samples.length > CTX_SAMPLE_WINDOW) samples.shift();
105
+ if ((contextTokens || 0) > peakRef.current) peakRef.current = contextTokens || 0;
106
+ }, [contextTokens]);
107
+
60
108
  const samples = samplesRef.current;
61
- samples.push(contextTokens || 0);
62
- if (samples.length > CTX_SAMPLE_WINDOW) samples.shift();
63
109
  const smoothed = samples.length > 0
64
110
  ? Math.round(samples.reduce((a, b) => a + b, 0) / samples.length)
65
- : 0;
66
- if ((contextTokens || 0) > peakRef.current) peakRef.current = contextTokens || 0;
67
- const peak = peakRef.current;
111
+ : (contextTokens || 0);
112
+ const peak = Math.max(peakRef.current, contextTokens || 0);
68
113
 
69
114
  const pct = contextLimit ? Math.round((smoothed / contextLimit) * 100) : 0;
70
115
  const ctxColor = pct > 80 ? "red" : pct > 60 ? "yellow" : "green";
@@ -83,15 +128,19 @@ export function StatusBar({ sessionId, phase, contextTokens, contextLimit }) {
83
128
  : pct >= 60 ? " · 💾 建议 /compact"
84
129
  : "";
85
130
 
131
+ // Truncate session ID to keep the bar single-row even with CJK names.
132
+ // 14 visual cells covers most short UUIDs and ~6-7 CJK chars.
133
+ const displaySessionId = sessionId ? truncateVisual(sessionId, 14) : "";
134
+
86
135
  return h(Box, { marginTop: 0 },
87
- h(Text, { dimColor: true }, " ⏵⏵ KC Agent CLI "),
88
- h(Text, { dimColor: true }, sessionId ? `[${sessionId}]` : ""),
89
- phase ? h(Text, { color: "cyan" }, ` ${phase.toUpperCase()}`) : null,
90
- h(Text, { color: "green" }, " ● "),
91
- h(Text, { color: ctxColor }, `CTX: ${ctxLabel}/${limitLabel} (${pct}%)`),
92
- showPeak ? h(Text, { dimColor: true }, ` · peak ${fmt(peak)}`) : null,
93
- compactHint ? h(Text, { color: ctxColor }, compactHint) : null,
94
- h(Text, { dimColor: true }, ` · ${LENAT_QUOTE}`),
136
+ h(Text, { dimColor: true, wrap: "truncate-end" }, " ⏵⏵ KC "),
137
+ h(Text, { dimColor: true, wrap: "truncate-end" }, displaySessionId ? `[${displaySessionId}]` : ""),
138
+ phase ? h(Text, { color: "cyan", wrap: "truncate-end" }, ` ${phase.toUpperCase()}`) : null,
139
+ h(Text, { color: "green", wrap: "truncate-end" }, " ● "),
140
+ h(Text, { color: ctxColor, wrap: "truncate-end" }, `CTX: ${ctxLabel}/${limitLabel} (${pct}%)`),
141
+ showPeak ? h(Text, { dimColor: true, wrap: "truncate-end" }, ` · peak ${fmt(peak)}`) : null,
142
+ compactHint ? h(Text, { color: ctxColor, wrap: "truncate-end" }, compactHint) : null,
143
+ h(Text, { dimColor: true, wrap: "truncate-end" }, ` · ${LENAT_QUOTE}`),
95
144
  );
96
145
  }
97
146
 
@@ -161,6 +210,7 @@ export function WelcomeBanner({ projectDir, pendingInputCount = 0 } = {}) {
161
210
  " rules/02 as core; rules/03-10 are supporting context only.\""),
162
211
  h(Text, null, ""),
163
212
  h(Text, { dimColor: true }, "Product of Memium / kitchen-engineer42"),
213
+ h(Text, { dimColor: true }, "PolyForm Noncommercial 1.0.0 — commercial use requires a separate license. See LICENSE-COMMERCIAL.md."),
164
214
  );
165
215
  }
166
216
 
package/src/cli/index.js CHANGED
@@ -76,6 +76,13 @@ function App({ engine, config }) {
76
76
  }, []);
77
77
 
78
78
  const addMessage = useCallback((msg) => {
79
+ // v0.7.0 H6: dismiss welcome banner once any real message lands.
80
+ // The banner state was initialized true and never set false — the
81
+ // banner stayed on every frame for the entire session, eating
82
+ // permanent screen real estate. Conditionally clear on first
83
+ // user/agent/tool-result message; system-only messages don't
84
+ // dismiss (they're often just the banner-side info itself).
85
+ if (msg && msg.role !== "system") setShowWelcome(false);
79
86
  setMessages((prev) => {
80
87
  if (prev.length < MAX_RETAINED_MESSAGES) return [...prev, msg];
81
88
  // Cap hit: drop the oldest non-system entry. If everything is system
@@ -385,7 +392,16 @@ function App({ engine, config }) {
385
392
  const sched = new Scheduler(engineRef.current.workspace);
386
393
  const jobs = sched.list();
387
394
  if (jobs.length === 0) {
388
- addMessage({ role: "system", content: "No scheduled ingestion jobs. Ask KC to set one up via the schedule_fetch tool." });
395
+ // v0.6.3.1: also surface pending input files. The welcome banner
396
+ // tells the user "run /schedule for details" when input/ has
397
+ // unseen files, but the no-jobs branch used to ignore those —
398
+ // user got a dead-end "no jobs" reply with the files invisible.
399
+ const pending = sched.pendingInputCount();
400
+ const tail = sched.tailLog(8);
401
+ let body = "No scheduled ingestion jobs. Ask KC to set one up via the schedule_fetch tool.";
402
+ if (pending > 0) body += `\n\nPending in input/: ${pending} file(s) (drop into workspace input/ to be picked up).`;
403
+ if (tail) body += `\n\nlogs/ingest.log (last 8):\n${tail}`;
404
+ addMessage({ role: "system", content: body });
389
405
  } else {
390
406
  const lines = jobs.map((j) => {
391
407
  const status = j.enabled ? "✓ enabled" : "· disabled";
@@ -423,6 +439,11 @@ function App({ engine, config }) {
423
439
  streamingRef.current = true;
424
440
  setStreaming(true);
425
441
  setSpinnerStatus("Compacting...");
442
+ // v0.7.0 H7: top-level .catch on the IIFE — the inner try/catch
443
+ // handles the compact() failure path; this tail .catch silences
444
+ // any secondary rejection from the catch handler or finally
445
+ // block (e.g., addMessage throw). Without it, those would be
446
+ // UnhandledPromiseRejection in strict-mode Node.
426
447
  (async () => {
427
448
  try {
428
449
  const result = await engineRef.current.compact();
@@ -469,7 +490,7 @@ function App({ engine, config }) {
469
490
  runTurn(next);
470
491
  }
471
492
  }
472
- })();
493
+ })().catch(() => { /* H7 defensive tail */ });
473
494
  return true;
474
495
  }
475
496
 
@@ -528,6 +549,10 @@ function App({ engine, config }) {
528
549
  }
529
550
  } else {
530
551
  // Resume a previous session
552
+ // v0.7.0 H8: top-level .catch on the IIFE so a throw inside
553
+ // addMessage()/setMessages() (e.g., during the catch handler
554
+ // itself, or in Ink reconciler) doesn't surface as an
555
+ // UnhandledPromiseRejection that crashes Node strict-mode.
531
556
  (async () => {
532
557
  try {
533
558
  const client = new LLMClient({
@@ -541,6 +566,17 @@ function App({ engine, config }) {
541
566
  setSessionId(resumed.workspace.sessionId);
542
567
  setPhase(resumed.currentPhase);
543
568
  setMessages([]);
569
+ // v0.7.0 F2: re-populate TaskBoard state from the resumed
570
+ // engine's TaskManager. Without this, the TUI showed an
571
+ // empty task list after /resume even when tasks.json on
572
+ // disk had pending work. The setTaskList path mirrors what
573
+ // the per-event tasks_progress handler does for live
574
+ // sessions.
575
+ try {
576
+ const tasks = resumed.taskManager.getAllTasks();
577
+ setTaskList(tasks);
578
+ setTaskProgress(resumed.taskManager.progress);
579
+ } catch { /* taskManager unavailable on very old session-state */ }
544
580
  addMessage({
545
581
  role: "system",
546
582
  content:
@@ -552,7 +588,8 @@ function App({ engine, config }) {
552
588
  } catch (err) {
553
589
  addMessage({ role: "system", content: `Resume failed: ${err.message}` });
554
590
  }
555
- })();
591
+ })().catch(() => { /* defended above; tail catch silences any
592
+ secondary rejection from the catch handler itself */ });
556
593
  }
557
594
  return true;
558
595
 
@@ -562,6 +599,13 @@ function App({ engine, config }) {
562
599
  try { engineRef.current.saveState(); } catch { /* ignore */ }
563
600
  try { engineRef.current.stop(); } catch { /* ignore */ }
564
601
  exit();
602
+ // v0.6.3.1: force-exit after a brief grace window. Ink's exit()
603
+ // unmounts the TUI but in-flight LLM streams / subagent fetches
604
+ // / unflushed appendFileSync handles can keep the Node event loop
605
+ // alive indefinitely on long sessions. The 500ms gives saveState
606
+ // and any synchronous flushes time to complete; after that we
607
+ // hard-exit so the user's terminal returns to the shell promptly.
608
+ setTimeout(() => process.exit(0), 500).unref();
565
609
  return true;
566
610
 
567
611
  default:
@@ -756,9 +800,24 @@ export async function main({ languageOverride } = {}) {
756
800
 
757
801
  // Save state on process exit + stop background diagnostics (B0.1 heap
758
802
  // sampler). saveState is idempotent; stop() is safe to call twice.
803
+ //
804
+ // v0.6.3.1: handler must terminate. Pre-fix it only saved + returned, which
805
+ // overrides Node's default SIGINT behavior — the process kept running with
806
+ // active LLM streams / subagent fetches keeping the event loop alive, and
807
+ // mashing ^C did nothing visible. Now: first ^C saves and tries clean exit
808
+ // after 500ms; second ^C hard-kills with no further saves.
809
+ let interruptCount = 0;
759
810
  const saveOnExit = () => {
811
+ interruptCount++;
812
+ if (interruptCount >= 2) {
813
+ // Second interrupt — user wants out NOW
814
+ process.stderr.write("\nForce-exiting (second interrupt).\n");
815
+ process.exit(130); // 128 + SIGINT
816
+ }
760
817
  try { engine.saveState(); } catch { /* ignore */ }
761
818
  try { engine.stop(); } catch { /* ignore */ }
819
+ process.stderr.write("\nReceived interrupt — saving state, then exiting in 500ms (press again to force).\n");
820
+ setTimeout(() => process.exit(130), 500).unref();
762
821
  };
763
822
  process.on("SIGINT", saveOnExit);
764
823
  process.on("SIGTERM", saveOnExit);
package/src/cli/meme.js CHANGED
@@ -1,32 +1,34 @@
1
- import React, { useState } from "react";
1
+ // AUTO-GENERATED by scripts/build-meme.js DO NOT EDIT BY HAND.
2
+ // Source of truth lives in src/cli/meme.source.js.
3
+ //
4
+ // v0.7.0 Group K2: the textual easter-egg payload (lyrics, team
5
+ // handles, watermark) is XOR-encoded + base64 in BLOB below. The
6
+ // decoder is intentionally simple — the goal is to defeat grep-based
7
+ // plagiarism scraping, not to provide cryptographic protection.
8
+ // Determined reverse-engineering can recover the data; the watermark
9
+ // survives and identifies origin in any copies. Please leave it in.
10
+
11
+ import React from "react";
2
12
  import { Box, Text, useInput } from "ink";
3
13
 
4
14
  const h = React.createElement;
5
15
 
6
- // F6: /meme easter egg. Intentionally not listed in /help — discovery
7
- // is the point. Press ESC or Enter to dismiss. Content per the v0.6.0
8
- // plan (item 15) — lyrics + team credit.
16
+ const _K = Buffer.from([
17
+ 0x4b, 0x43, 0x37, 0x31, 0x2a, 0x68, 0x61, 0x72, 0x6e, 0x65, 0x73, 0x73, 0x2a, 0x66, 0x61, 0x74,
18
+ ]);
19
+ const _BLOB = "MGFbSFgBAgFMXyhRY0ENGGs0VlheSAAcCkUAHEUIQ1hpFFIWWA1BARoXEh1OAwVUJC0XRUINQRALBBAbCEpDPSVjWERYSAUACwQeUQZENhFrM1ZDXkgVHQFFABxFCENYaQFCRQoBD1IBEAFTRg8EB2lvFWVCDRMXSRZTEgoSEwE/KxdFRUgHGwABUV8IMgkRayZZVQoBElIAAARRBkQgVD8sWl5YGg4FThIWU0cTEgBrMVJQSQBBFAEXUV8IMg5UKSYXWU8JExZMOF9RXgMAGWl5bBNqWFA0BxYbUQZEITUmJltYS0pNUC4kHQBPCiobKCZZEwZKITMaFxYGWURNVgsAVkNFBAgcCyYhPwhKQzQPKkFYWQEOHCoMARZJEg4GEzYVHQgoJgAPBhY0XwlDWGkDcENPDQ89HAQdFE9ETVYLC1ZdTCVDXkwlGBpeBQkRJW5SX00BDxcLF0dBCEpDNAcqW0hiHQAcCUdfUWorAAY+KFgTBkohIwcEHR9DCghWZ2F3QkUGBh8PClFfCCYVHC4sWRMGSiEqBwIGEghKQzQTGseupvdDXkwlChtCC0NYaQNNXk8SDhdMSVEzUB8ZACMmQ0NFBA1QM0lRBEsSBAYmIkVaCFJDOS1FscQKNg4YMgVYQ0dILx0ABhweRwMTFyIiWxEbRlFcXkWxxAqkyFQGJlpYXwVBXU4OGgdJDgQaZiZZVkMGBBccUUFT6NFBHD83R0IQR04VBxEbBkhIAhsmbFxYXgsJFwBIFh1NDw8RLjEDAwUDAl8NCRpRVw==";
9
20
 
10
- const LYRICS = [
11
- "I'll wait and soon",
12
- "We're stranded on the beach",
13
- "In our dream",
14
- "We part too soon",
15
- "But in our lies",
16
- "There's a truth to find",
17
- "The end is new",
18
- "A tomorrow we must reach for",
19
- "To be heard",
20
- ];
21
+ function _decode() {
22
+ const buf = Buffer.from(_BLOB, "base64");
23
+ const out = Buffer.alloc(buf.length);
24
+ for (let i = 0; i < buf.length; i++) out[i] = buf[i] ^ _K[i % _K.length];
25
+ return JSON.parse(out.toString("utf-8"));
26
+ }
21
27
 
22
- const TEAM = [
23
- "@kitchen-engineer42", "@Xigua", "@Amelia", "@01Fish",
24
- "@zyxthetroll", "@theon", "@DivisionDirectorXu",
25
- "@AnselKocen", "@CarolineCRL", "@GraceGuo",
26
- "@XY🌟", "@HalfM", "@GreenOrange",
27
- "@LilyHuang", "@Qianlili", "@songmao",
28
- "@zoezoe", "@yhhm",
29
- ];
28
+ const _payload = _decode();
29
+ const LYRICS = _payload.lyrics;
30
+ const TEAM = _payload.team;
31
+ const __KC_MEME_WATERMARK__ = _payload.watermark;
30
32
 
31
33
  export function MemeOverlay({ onDismiss }) {
32
34
  useInput((input, key) => {
@@ -34,7 +36,6 @@ export function MemeOverlay({ onDismiss }) {
34
36
  });
35
37
 
36
38
  return h(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1, marginTop: 1, marginBottom: 1 },
37
- // Lyrics block
38
39
  h(Box, { flexDirection: "column" },
39
40
  ...LYRICS.map((line, i) =>
40
41
  h(Text, { key: `l-${i}`, color: "cyan", italic: true }, line),
@@ -43,7 +44,6 @@ export function MemeOverlay({ onDismiss }) {
43
44
  h(Text, null, ""),
44
45
  h(Text, { dimColor: true }, "─".repeat(60)),
45
46
  h(Text, null, ""),
46
- // Team credit
47
47
  h(Text, { color: "yellow", bold: true },
48
48
  "Here's to all the smart minds that are/were part of our team:"),
49
49
  h(Text, null, ""),
@@ -54,5 +54,6 @@ export function MemeOverlay({ onDismiss }) {
54
54
  ),
55
55
  h(Text, null, ""),
56
56
  h(Text, { dimColor: true }, "Press ESC or Enter to dismiss."),
57
+ h(Text, { dimColor: true }, __KC_MEME_WATERMARK__),
57
58
  );
58
59
  }