jeo-code 0.6.11 → 0.6.13

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.6.13] - 2026-06-16
10
+ _`team` engine: concrete uncommitted-work reporting and stricter empty-run handling._
11
+
12
+ ### Changed
13
+ - **`team` re-runs report concrete uncommitted work.** Instead of a speculative warning, the engine now probes the working tree with `git status --porcelain` and reports the actual uncommitted-file count, so you know whether real partial work is present before re-running on it.
14
+ - **`--strict-mutations` fails a no-op mutating run.** A mutating role that performed no write/edit/bash is now a hard failure (`stream:error`) rather than silently passing; a bash-only run stays an advisory `stream:warn` (new tone) so a passing advisory doesn't masquerade as an error.
15
+
16
+ ## [0.6.12] - 2026-06-16
17
+ _OKF-backed memory distillation — session learnings become structured concept files._
18
+
19
+ ### Added
20
+ - **OKF-backed memory distillation (OKF Sprint 02).** Session distillation now emits structured OKF v0.1 concept files — each with `type` / `title` / `description` / `body` / `tags` / `confidence` / `links` — filed into typed directories (unknown types fall back to `facts/`), deduped by title, merged with the existing concept bundle, then indexed and logged. The distiller prompts the model for a `{ concepts: [...] }` payload and parses it leniently (`tryExtractJsonObject`) so text-only providers still land. Builds on the 0.6.10 OKF format layer.
21
+
9
22
  ## [0.6.11] - 2026-06-16
10
23
  _Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt._
11
24
 
package/README.ja.md CHANGED
@@ -158,11 +158,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
158
158
  ## 変更履歴 (Changelog)
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
162
+ - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
161
163
  - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
162
164
  - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
163
165
  - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
164
- - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
165
- - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -158,11 +158,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
158
158
  ## 변경 이력 (Changelog)
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
162
+ - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
161
163
  - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
162
164
  - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
163
165
  - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
164
- - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
165
- - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -158,11 +158,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
158
158
  ## Changelog
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
162
+ - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
161
163
  - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
162
164
  - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
163
165
  - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
164
- - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
165
- - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -158,11 +158,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
158
158
  ## 更新日志 (Changelog)
159
159
 
160
160
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
161
+ - **[0.6.13]** (2026-06-16) — `team` engine: concrete uncommitted-work reporting and stricter empty-run handling.
162
+ - **[0.6.12]** (2026-06-16) — OKF-backed memory distillation — session learnings become structured concept files.
161
163
  - **[0.6.11]** (2026-06-16) — Larger reasoning budgets, and terminal capability-response sequences kept out of the prompt.
162
164
  - **[0.6.10]** (2026-06-16) — OKF memory-format foundation and a hardened bashTool subprocess drain.
163
165
  - **[0.6.9]** (2026-06-16) — Live streaming blocks size to their content and the viewport instead of a fixed rectangle.
164
- - **[0.6.8]** (2026-06-16) — OAuth loopback callback host pinned to `localhost` to match provider-registered redirect URIs.
165
- - **[0.6.7]** (2026-06-16) — Mouse-report input corruption fixed under `jeo --tmux`, and a full-width TUI at one consistent width.
166
166
 
167
167
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
168
168
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -13,6 +13,8 @@ import { spawn as nodeSpawn } from "node:child_process";
13
13
  import * as path from "node:path";
14
14
  import { callLlm, type Message } from "./loop";
15
15
  import { jeoEnv } from "../util/env";
16
+ import { parseConcept, serializeConcept, slugify, isReservedFile } from "./memory-okf";
17
+ import { tryExtractJsonObject } from "./json";
16
18
 
17
19
  /** On-disk document cap — the distill prompt instructs the model to stay under it. */
18
20
  export const MEMORY_MAX_CHARS = 6_000;
@@ -22,6 +24,16 @@ export const MEMORY_INJECT_MAX_CHARS = 3_000;
22
24
  const TRANSCRIPT_MAX_CHARS = 12_000;
23
25
  /** A session shorter than this has nothing durable to learn. */
24
26
  const MIN_HISTORY_MESSAGES = 4;
27
+ /** Single source of truth for the four jeo concept types the distiller files:
28
+ * type → on-disk subdir → index.md section header, in display order. Add a
29
+ * row here (one place) to introduce a new filed/rendered type. */
30
+ const TYPE_LAYOUT = [
31
+ { type: "RepoFact", dir: "facts", header: "Repo Facts" },
32
+ { type: "Command", dir: "commands", header: "Commands" },
33
+ { type: "Gotcha", dir: "gotchas", header: "Gotchas" },
34
+ { type: "UserPreference", dir: "preferences", header: "User Preferences" },
35
+ ] as const;
36
+ const DIR_BY_TYPE: Record<string, string> = Object.fromEntries(TYPE_LAYOUT.map(t => [t.type, t.dir]));
25
37
 
26
38
  export function memoryFilePath(cwd: string): string {
27
39
  return path.join(cwd, ".jeo", "memory", "MEMORY.md");
@@ -83,6 +95,135 @@ export interface DistillResult {
83
95
  * Best-effort by design: any failure is reported in the result, never thrown —
84
96
  * a memory write must not be able to break session exit.
85
97
  */
98
+
99
+ async function findMarkdownFiles(dir: string): Promise<string[]> {
100
+ const files: string[] = [];
101
+ async function recurse(currentDir: string) {
102
+ let entries;
103
+ try {
104
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
105
+ } catch {
106
+ return;
107
+ }
108
+ for (const entry of entries) {
109
+ const fullPath = path.join(currentDir, entry.name);
110
+ if (entry.isDirectory()) {
111
+ if (entry.name === "raw") continue;
112
+ await recurse(fullPath);
113
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
114
+ files.push(fullPath);
115
+ }
116
+ }
117
+ }
118
+ await recurse(dir);
119
+ return files;
120
+ }
121
+
122
+ async function rebuildIndex(bundleDir: string): Promise<void> {
123
+ const files = await findMarkdownFiles(bundleDir);
124
+ const concepts: { type: string; title: string; relPath: string }[] = [];
125
+ for (const file of files) {
126
+ const relPath = path.relative(bundleDir, file);
127
+ if (isReservedFile(relPath)) continue;
128
+ try {
129
+ const content = await fs.readFile(file, "utf-8");
130
+ const parsed = parseConcept(content);
131
+ concepts.push({
132
+ type: (parsed.frontmatter.type as string) || "RepoFact",
133
+ title: (parsed.frontmatter.title as string) || path.basename(file, ".md"),
134
+ relPath,
135
+ });
136
+ } catch {
137
+ // ignore
138
+ }
139
+ }
140
+
141
+ let body = "# Index\n\n";
142
+ for (const { type, header } of TYPE_LAYOUT) {
143
+ const list = concepts.filter(c => c.type === type);
144
+ if (list.length === 0) continue;
145
+ body += `## ${header}\n`;
146
+ for (const c of list) {
147
+ body += `- [${c.title}](/${c.relPath.replace(/\\/g, "/")})\n`;
148
+ }
149
+ body += "\n";
150
+ }
151
+
152
+ const indexContent = serializeConcept({ okf_version: "0.1" }, body.trim());
153
+ const indexPath = path.join(bundleDir, "index.md");
154
+ const tmpPath = `${indexPath}.tmp-${process.pid}`;
155
+ await fs.writeFile(tmpPath, indexContent, "utf-8");
156
+ await fs.rename(tmpPath, indexPath);
157
+ }
158
+
159
+ async function updateLog(bundleDir: string, updatedConcepts: { title: string; type: string }[]): Promise<void> {
160
+ const logPath = path.join(bundleDir, "log.md");
161
+ let existingContent = "";
162
+ try {
163
+ existingContent = await fs.readFile(logPath, "utf-8");
164
+ } catch {
165
+ existingContent = "# Directory Update Log\n";
166
+ }
167
+
168
+ const today = new Date().toISOString().split("T")[0];
169
+ const heading = `## ${today}`;
170
+
171
+ let entry = "";
172
+ for (const c of updatedConcepts) {
173
+ entry += `* **${c.type}**: ${c.title}\n`;
174
+ }
175
+ if (!entry) return;
176
+
177
+ let newContent = "";
178
+ if (existingContent.includes(heading)) {
179
+ const lines = existingContent.split("\n");
180
+ const idx = lines.findIndex(l => l.trim() === heading);
181
+ lines.splice(idx + 1, 0, entry.trim());
182
+ newContent = lines.join("\n");
183
+ } else {
184
+ const lines = existingContent.split("\n");
185
+ let insertIdx = 0;
186
+ if (lines[0]?.startsWith("# ")) {
187
+ insertIdx = 1;
188
+ while (insertIdx < lines.length && lines[insertIdx].trim() === "") {
189
+ insertIdx++;
190
+ }
191
+ }
192
+ lines.splice(insertIdx, 0, `${heading}\n${entry}`);
193
+ newContent = lines.join("\n");
194
+ }
195
+
196
+ const tmpPath = `${logPath}.tmp-${process.pid}`;
197
+ await fs.writeFile(tmpPath, newContent, "utf-8");
198
+ await fs.rename(tmpPath, logPath);
199
+ }
200
+
201
+ export async function saveRawPayload(bundleDir: string, payload: any): Promise<void> {
202
+ const rawDir = path.join(bundleDir, "raw");
203
+ await fs.mkdir(rawDir, { recursive: true });
204
+ const filename = `session-${Date.now()}-${process.pid}.json`;
205
+ const filePath = path.join(rawDir, filename);
206
+ await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf-8");
207
+ }
208
+
209
+ async function cleanupStalePendingFiles(dir: string): Promise<void> {
210
+ try {
211
+ const entries = await fs.readdir(dir, { withFileTypes: true });
212
+ const now = Date.now();
213
+ for (const entry of entries) {
214
+ if (entry.isFile() && entry.name.startsWith("pending-distill-") && entry.name.endsWith(".json")) {
215
+ const filePath = path.join(dir, entry.name);
216
+ const stat = await fs.stat(filePath);
217
+ if (now - stat.mtimeMs > 24 * 60 * 60 * 1000) {
218
+ await fs.unlink(filePath).catch(() => {});
219
+ }
220
+ }
221
+ }
222
+ } catch {
223
+ // ignore
224
+ }
225
+ }
226
+
86
227
  export async function distillSessionMemory(
87
228
  history: Message[],
88
229
  cwd: string,
@@ -92,44 +233,154 @@ export async function distillSessionMemory(
92
233
  const body = history.filter(m => m.role !== "system");
93
234
  if (body.length < MIN_HISTORY_MESSAGES) return { updated: false, skipped: "session too short" };
94
235
  try {
95
- const existing = await loadMemory(cwd);
236
+ const bundleDir = path.join(cwd, ".jeo", "memory");
237
+ const existingConcepts: any[] = [];
238
+ try {
239
+ const files = await findMarkdownFiles(bundleDir);
240
+ for (const file of files) {
241
+ const relPath = path.relative(bundleDir, file);
242
+ if (isReservedFile(relPath)) continue;
243
+ try {
244
+ const content = await fs.readFile(file, "utf-8");
245
+ const parsed = parseConcept(content);
246
+ existingConcepts.push({
247
+ type: parsed.frontmatter.type,
248
+ title: parsed.frontmatter.title || "",
249
+ description: parsed.frontmatter.description,
250
+ body: parsed.body,
251
+ tags: parsed.frontmatter.tags,
252
+ confidence: parsed.frontmatter.confidence,
253
+ links: parsed.frontmatter.links,
254
+ path: relPath,
255
+ });
256
+ } catch {}
257
+ }
258
+ } catch {}
259
+
96
260
  const prompt: Message[] = [
97
261
  {
98
262
  role: "system",
99
263
  content:
100
- "You maintain a compact project memory document for a coding agent. " +
101
- "Merge durable learnings from the session transcript into the existing memory. " +
264
+ "You maintain a compact project memory bundle for a coding agent. " +
265
+ "Extract durable learnings from the session transcript and merge them with the existing concepts. " +
102
266
  "Keep ONLY what helps future sessions in THIS repository: repo facts (structure, conventions, key files), " +
103
267
  "commands that work (build/test/run), gotchas (failures and their fixes), and user preferences. " +
104
268
  "Drop session-specific noise (one-off tasks, transient errors, conversational detail). " +
105
- `Output the FULL updated document as markdown bullets under those four headings, max ${MEMORY_MAX_CHARS} characters. ` +
106
- "Output ONLY the document no preamble, no fences.",
269
+ "You must output a JSON object with a single key \"concepts\", which is an array of concept objects. " +
270
+ "Each concept object must have the following fields:\n" +
271
+ " - \"type\": one of \"RepoFact\", \"Command\", \"Gotcha\", \"UserPreference\"\n" +
272
+ " - \"title\": a short, descriptive title (e.g., \"Bun test runner\")\n" +
273
+ " - \"description\": a brief one-line summary of the concept\n" +
274
+ " - \"body\": the detailed markdown content/body of the concept\n" +
275
+ " - \"tags\": an array of string tags (optional)\n" +
276
+ " - \"confidence\": one of \"high\", \"medium\", \"low\" (optional)\n" +
277
+ " - \"links\": an array of other concept paths/IDs this concept links to (optional)\n\n" +
278
+ "Output ONLY the JSON object. Do not include any markdown formatting, preamble, or explanation."
107
279
  },
108
280
  {
109
281
  role: "user",
110
282
  content:
111
- `Existing memory document:\n${existing || "(empty)"}\n\n` +
112
- `Session transcript (tail):\n${transcriptTail(history)}`,
113
- },
283
+ `Existing concepts:\n${JSON.stringify(existingConcepts, null, 2)}\n\n` +
284
+ `Session transcript (tail):\n${transcriptTail(history)}`
285
+ }
114
286
  ];
287
+
115
288
  const timeoutMs = opts.timeoutMs ?? 20_000;
116
289
  const distilled = await Promise.race([
117
- callLlm(prompt, { model: opts.model, jsonMode: false, maxTokens: 2_000 }),
290
+ callLlm(prompt, { model: opts.model, jsonMode: true, maxTokens: 2_000 }),
118
291
  new Promise<never>((_, reject) => setTimeout(() => reject(new Error(`memory distill timed out after ${timeoutMs}ms`)), timeoutMs)),
119
292
  ]);
120
- const doc = distilled.trim().slice(0, MEMORY_MAX_CHARS);
121
- if (!doc) return { updated: false, skipped: "model returned an empty document" };
122
- const file = memoryFilePath(cwd);
123
- await fs.mkdir(path.dirname(file), { recursive: true });
124
- const tmp = `${file}.tmp-${process.pid}`;
125
- await fs.writeFile(tmp, doc + "\n", "utf-8");
126
- await fs.rename(tmp, file); // atomic: a crash mid-write never corrupts the doc
127
- return { updated: true };
293
+
294
+ // Robust extraction: the distill prompt requests JSON, but text-only providers
295
+ // (the default antigravity backend) routinely wrap it in prose or fences.
296
+ // tryExtractJsonObject recovers the first balanced {...}, tolerating that noise;
297
+ // a null result means the model gave plain text → old MEMORY.md fallback below.
298
+ const parsedJson = tryExtractJsonObject<{ concepts?: unknown }>(distilled);
299
+
300
+
301
+ if (parsedJson && Array.isArray(parsedJson.concepts)) {
302
+ await fs.mkdir(bundleDir, { recursive: true });
303
+ const updatedConcepts: { title: string; type: string }[] = [];
304
+
305
+ for (const concept of parsedJson.concepts) {
306
+ if (!concept.type || !concept.title) continue;
307
+ // Unknown types fall back to facts/ (lenient — OKF tolerates extra types).
308
+ const dir = DIR_BY_TYPE[concept.type] ?? "facts";
309
+
310
+ const targetDir = path.join(bundleDir, dir);
311
+ await fs.mkdir(targetDir, { recursive: true });
312
+
313
+ let slug = slugify(concept.title);
314
+ let relPath = `${dir}/${slug}.md`;
315
+ let fullPath = path.join(bundleDir, relPath);
316
+
317
+ let suffix = 1;
318
+ while (true) {
319
+ try {
320
+ const existingContent = await fs.readFile(fullPath, "utf-8");
321
+ const parsed = parseConcept(existingContent);
322
+ const existingTitle = parsed.frontmatter.title || "";
323
+ if (existingTitle === concept.title) {
324
+ break;
325
+ }
326
+ slug = `${slugify(concept.title)}-${suffix}`;
327
+ relPath = `${dir}/${slug}.md`;
328
+ fullPath = path.join(bundleDir, relPath);
329
+ suffix++;
330
+ } catch {
331
+ break;
332
+ }
333
+ }
334
+
335
+ let existingFm = {};
336
+ try {
337
+ const existingContent = await fs.readFile(fullPath, "utf-8");
338
+ existingFm = parseConcept(existingContent).frontmatter;
339
+ } catch {}
340
+
341
+ const frontmatter = {
342
+ ...existingFm,
343
+ type: concept.type,
344
+ title: concept.title,
345
+ description: concept.description || "",
346
+ tags: concept.tags || [],
347
+ timestamp: new Date().toISOString(),
348
+ confidence: concept.confidence || "high",
349
+ last_verified: new Date().toISOString().split("T")[0],
350
+ links: concept.links || [],
351
+ };
352
+
353
+ const serialized = serializeConcept(frontmatter, concept.body || "");
354
+ const tmpPath = `${fullPath}.tmp-${process.pid}`;
355
+ await fs.writeFile(tmpPath, serialized, "utf-8");
356
+ await fs.rename(tmpPath, fullPath);
357
+
358
+ updatedConcepts.push({ title: concept.title, type: concept.type });
359
+ }
360
+
361
+ await rebuildIndex(bundleDir);
362
+ if (updatedConcepts.length > 0) {
363
+ await updateLog(bundleDir, updatedConcepts);
364
+ }
365
+ await cleanupStalePendingFiles(bundleDir);
366
+ return { updated: true };
367
+ } else {
368
+ const doc = distilled.trim().slice(0, MEMORY_MAX_CHARS);
369
+ if (!doc) return { updated: false, skipped: "model returned an empty document" };
370
+ const file = memoryFilePath(cwd);
371
+ await fs.mkdir(path.dirname(file), { recursive: true });
372
+ const tmp = `${file}.tmp-${process.pid}`;
373
+ await fs.writeFile(tmp, doc + "\n", "utf-8");
374
+ await fs.rename(tmp, file);
375
+ return { updated: true };
376
+ }
128
377
  } catch (err: any) {
129
378
  return { updated: false, skipped: `distill failed: ${err?.message ?? String(err)}` };
130
379
  }
131
380
  }
132
381
 
382
+
383
+
133
384
  // ── Detached background distillation (round-16) ──
134
385
  // The exit-path `await distillSessionMemory(...)` blocked /exit and ^C^C for up
135
386
  // to 20s on a final LLM call. Quitting must be INSTANT: the parent now writes a
@@ -189,7 +440,10 @@ export async function runMemoryDistillCommand(args: string[]): Promise<void> {
189
440
  const payloadPath = (args[0] ?? "").trim();
190
441
  if (!payloadPath) return;
191
442
  try {
192
- const payload = JSON.parse(await fs.readFile(payloadPath, "utf-8")) as { model?: string; messages?: Message[] };
443
+ const payloadContent = await fs.readFile(payloadPath, "utf-8");
444
+ const payload = JSON.parse(payloadContent) as { model?: string; messages?: Message[] };
445
+ const bundleDir = path.join(process.cwd(), ".jeo", "memory");
446
+ await saveRawPayload(bundleDir, payload);
193
447
  if (Array.isArray(payload.messages)) {
194
448
  await distillSessionMemory(payload.messages, process.cwd(), { model: payload.model });
195
449
  }
@@ -199,3 +453,4 @@ export async function runMemoryDistillCommand(args: string[]): Promise<void> {
199
453
  await fs.unlink(payloadPath).catch(() => {});
200
454
  }
201
455
  }
456
+
package/src/cli/runner.ts CHANGED
@@ -70,9 +70,10 @@ export const COMMANDS: readonly CommandSpec[] = [
70
70
  {
71
71
  name: "team",
72
72
  summary: "Execute the planning blueprint (Executor subagent tools).",
73
+ usage: "team [--strict-mutations]",
73
74
  loader: async () => {
74
75
  const m = await import("../commands/team");
75
- return async () => m.runTeamCommand();
76
+ return args => m.runTeamCommand(args);
76
77
  },
77
78
  },
78
79
  {
@@ -11,6 +11,7 @@ import {
11
11
  import { runAgentLoop } from "../agent/engine";
12
12
  import { maybeCompact } from "../agent/compaction";
13
13
  import { catalogMetadata } from "../ai";
14
+ import { gitDirtyCount } from "./launch/tmux";
14
15
  import { readGlobalConfig } from "../agent/state";
15
16
  import {
16
17
  defaultSubagentRole,
@@ -26,7 +27,7 @@ import type { Message } from "../agent/loop";
26
27
  import { loadProjectContext, withProjectContext } from "../agent/context-files";
27
28
  import { categoryBadge } from "../tui/components/category-index";
28
29
 
29
- export type RalphStreamKind = "step" | "complete" | "error";
30
+ export type RalphStreamKind = "step" | "complete" | "error" | "warn";
30
31
 
31
32
  export interface RalphRenderOptions {
32
33
  color?: boolean;
@@ -55,19 +56,20 @@ export function formatRalphTodoGuide(
55
56
  return lines;
56
57
  }
57
58
 
59
+ const STREAM_TINTS = {
60
+ complete: chalk.green.bold,
61
+ error: chalk.red.bold,
62
+ warn: chalk.yellow.bold,
63
+ step: chalk.cyan.bold,
64
+ } satisfies Record<RalphStreamKind, (s: string) => string>;
65
+
58
66
  export function formatRalphStreamEvent(kind: RalphStreamKind, message: string, opts: RalphRenderOptions = {}): string {
59
- const label = kind === "complete" ? "complete" : kind === "error" ? "error" : "step";
60
- if (!opts.color && !opts.indexed) return ` └─ stream:${label} ${message}`;
67
+ // ponytail: the label is always the kind itself no mapping table needed.
68
+ if (!opts.color && !opts.indexed) return ` └─ stream:${kind} ${message}`;
61
69
  const color = opts.color === true;
62
70
  const badge = `${categoryBadge("subagent", { color })} `;
63
- const tint = color
64
- ? kind === "complete"
65
- ? chalk.green.bold
66
- : kind === "error"
67
- ? chalk.red.bold
68
- : chalk.cyan.bold
69
- : (s: string) => s;
70
- return ` ${badge}${tint(`stream:${label}`)} ${message}`;
71
+ const tint = color ? STREAM_TINTS[kind] : (s: string) => s;
72
+ return ` ${badge}${tint(`stream:${kind}`)} ${message}`;
71
73
  }
72
74
 
73
75
  export interface RalphSubagentPromptContext {
@@ -163,6 +165,9 @@ export interface TeamEngineOptions {
163
165
  io?: {
164
166
  output?: (line: string) => void;
165
167
  };
168
+ /** When true, a mutating role that finishes WITHOUT any successful
169
+ * write/edit/bash fails the task instead of merely warning (round-11). */
170
+ strictMutations?: boolean;
166
171
  }
167
172
 
168
173
  export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok: boolean; reason?: string }> {
@@ -301,12 +306,17 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
301
306
  }
302
307
 
303
308
  // Round-8: a previous run halted on a task — its partial edits may still be
304
- // on disk. Warn loudly before re-running on top of them, then clear the marker.
309
+ // on disk. Round-12: instead of a speculative warning, probe the working tree
310
+ // with `git status --porcelain` and report the CONCRETE uncommitted count so
311
+ // the user knows whether real partial work is present before re-running on it.
305
312
  if (teamState.current_phase === "failed" && teamState.failed_task) {
306
- log(
307
- `[WARN] The previous run FAILED on "${teamState.failed_task}" and may have left partial edits on disk. ` +
308
- `Review the working tree before trusting this re-run executing the task again on top of partial work can duplicate changes.`,
309
- );
313
+ const dirty = gitDirtyCount(cwd);
314
+ const treeNote = dirty === undefined
315
+ ? `The working tree could not be inspected (not a git repo or git unavailable) review it manually before trusting this re-run.`
316
+ : dirty > 0
317
+ ? `git reports ${dirty} uncommitted change(s) — these may include partial edits from the halted task; review (e.g. 'git status', 'git diff') before re-running, as executing the task again on top of partial work can duplicate changes.`
318
+ : `git reports a clean working tree — no partial edits from the halted task remain on disk, so this re-run starts from a known state.`;
319
+ log(`[WARN] The previous run FAILED on "${teamState.failed_task}". ${treeNote}`);
310
320
  teamState.current_phase = "executing";
311
321
  delete teamState.failed_task;
312
322
  }
@@ -336,6 +346,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
336
346
  completed: teamState.completed_tasks ?? [],
337
347
  cwd,
338
348
  roleId: roleByIndex[activeIndex],
349
+ strictMutations: opts.strictMutations ?? false,
339
350
  });
340
351
 
341
352
  if (opts.signal?.aborted) {
@@ -374,14 +385,15 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
374
385
  }
375
386
  }
376
387
 
377
- export async function runTeamCommand(): Promise<void> {
378
- const res = await runTeamEngine();
388
+ export async function runTeamCommand(args: string[] = []): Promise<void> {
389
+ const strictMutations = args.includes("--strict-mutations") || args.includes("--strict");
390
+ const res = await runTeamEngine({ strictMutations });
379
391
  if (!res.ok) {
380
392
  process.exitCode = 1;
381
393
  }
382
394
  }
383
395
 
384
- async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: string; roleId?: string }): Promise<boolean> {
396
+ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: string; roleId?: string; strictMutations?: boolean }): Promise<boolean> {
385
397
  const config = await readGlobalConfig();
386
398
  const role = getSubagentRole(ctx.roleId, config) ?? defaultSubagentRole();
387
399
  const renderOpts: RalphRenderOptions = { color: !!process.stdout.isTTY, indexed: true };
@@ -465,7 +477,16 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
465
477
  const msg = bashRuns === 0
466
478
  ? `${role.title} completed WITHOUT any successful write/edit/bash — treat its changed-files claim as unverified.`
467
479
  : `${role.title} completed with only bash (no write/edit) — verify its changed-files claim independently.`;
468
- console.log(formatRalphStreamEvent("error", msg, renderOpts));
480
+ // Round-11: under --strict-mutations, a mutating role that took NO action at
481
+ // all (no write/edit/bash) is a hard failure — an empty run must not pass as
482
+ // a completed task. bash-only stays advisory to avoid penalizing shell edits.
483
+ const hardFail = ctx.strictMutations && bashRuns === 0;
484
+ // Round-12: separate the tones so a passing advisory run doesn't masquerade
485
+ // as a stream:error — only a real hard-fail is red; an advisory note is warn.
486
+ console.log(formatRalphStreamEvent(hardFail ? "error" : "warn", msg, renderOpts));
487
+ if (hardFail) {
488
+ return false;
489
+ }
469
490
  }
470
491
  console.log(formatRalphStreamEvent("complete", `${role.title} finished task`, renderOpts));
471
492
  return true;