jeo-code 0.6.36 → 0.6.38

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,39 @@ 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.38] - 2026-06-21
10
+ _The OKF concept-bundle memory no longer silently drops what it learns: a legacy single-doc `MEMORY.md` (or a text-only distill fallback) can no longer shadow the concept bundle, break OKF conformance, or lose a turn's learnings — its content is folded into the concept merge and the stale blob is archived. Re-verified leak-free (`mem-probe`, 2000 turns, −570 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6)._
11
+
12
+ ### Fixed
13
+ - **A text-only distill fallback no longer loses the turn's learnings.** When the model returns no extractable JSON, `distillSessionMemory` used to write the raw text to a single-doc `MEMORY.md` blob — but `memoryPromptSection` injects that blob ONLY when the concept bundle is empty (concepts always win), so once any concept existed the fallback's learnings were recorded yet never injected. Now, if a concept bundle already exists, the dead blob is suppressed and the prior concepts are kept as the durable memory (`skipped: "distill produced no JSON; kept existing concept bundle (legacy blob suppressed)"`).
14
+ - **A lingering legacy `MEMORY.md` can no longer break OKF conformance.** A frontmatter-less single-doc `MEMORY.md` coexisting with the concept bundle made `validateBundle` non-conformant (`error: concept document missing YAML frontmatter block`) while the loader silently skipped it — an inconsistency that left the bundle permanently invalid (`migrateLegacyMemory` only runs on the explicit `jeo memory-migrate`). Distill now feeds the legacy doc's content into the concept-merge context so its learnings are absorbed, then archives it to `MEMORY.md.bak` on a successful JSON distill — off the active read path, with rollback preserved.
15
+ - **Concept enumeration skips non-concept blobs.** `existingConcepts` now skips any `.md` without YAML frontmatter, so a stray `MEMORY.md` can never be mis-parsed as a concept during the merge.
16
+
17
+ ### Changed
18
+ - **Distill prompt enforces concept granularity.** The merge prompt now forbids a catch-all "Project Memory Bundle" mega-concept and instructs one concept per distinct fact/command/gotcha/preference, so the bundle grows as discrete, cross-linkable concepts.
19
+ - **Per-session injection budget raised** from 3,000 to 5,000 chars (`MEMORY_INJECT_MAX_CHARS`) so a healthy multi-concept bundle is injected in full.
20
+
21
+ ### Verified
22
+ - `bun run typecheck` clean; full suite `1751 pass / 0 fail` (216 files).
23
+ - Memory/OKF suites `57 pass / 0 fail` across `memory`, `memory-okf`, `memory-distill-okf`, `memory-migration-okf`, `memory-search-okf`, `memory-graph-okf`.
24
+ - Live bundle proof: `validateBundle` flips `false (error: MEMORY.md)` → `true (no issues)` after the legacy archive, injection block intact (3,220 chars); bundle now holds discrete facts/commands/gotchas/preferences concepts + a valid `index.md`.
25
+ - `scripts/mem-probe.ts` (2000 turns × 40 tools) reports no long-term leak (−570 bytes/turn, exit-listeners flat at 1); `scripts/tmux-verify.sh battery` passes 6/6 on a fresh `jeo --tmux` boot.
26
+
27
+
28
+ ## [0.6.37] - 2026-06-20
29
+ _Two dead-end fixes: the boxed prompt's ↑/↓ now recalls input history on a soft-wrapped one-liner (only a genuine multi-line draft gets in-box caret nav), and every terminating Spec-first stage (deep-interview, ralplan, team) now surfaces a user-visible answer instead of silently stalling — re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot._
30
+
31
+ ### Fixed
32
+ - **The boxed prompt's ↑/↓ recalls input history again on a soft-wrapped line.** A long single line that the box wraps to several visual rows is no longer treated as multi-line: ↑/↓ fall through to readline's history recall (the dominant REPL expectation) instead of dragging the wrapped tail up a visual row. In-box vertical caret nav (textarea feel) is now gated behind a GENUINE multi-line draft — one carrying an explicit Shift+Enter / pasted break, stored as the private-use `MULTILINE_SENTINEL` — and still yields the arrows to an open slash list or the Ctrl+O history panel. New `isGenuineMultilineDraft` / `shouldBoxVerticalNav` helpers make the gate unit-testable independent of the live readline/PTY wiring.
33
+ - **Every terminating Spec-first workflow stage now surfaces a user-visible answer.** Three stages could previously reach a terminal state with no message explaining the outcome:
34
+ - **`team`** routes all subagent output through the engine `log()`/`io.output` sink (zero raw `console.log` in `executeTaskWithAgent`) and prints a `<role> report:` header followed by every line of the subagent's reason on success.
35
+ - **`deep-interview`** gates its `[Handoff Ready]` / `onProgress(complete)` signal on a real frozen seed: `freezeSeed` now returns `Promise<boolean>`, and a freeze failure emits `[HOLD]` and keeps the interview open instead of falsely claiming the requirement is crystallized.
36
+ - **`ralplan`** reports a discarded revision: an invalid `[ITERATE]` revision now logs `discarding the revision; the [ITERATE] verdict stands` instead of silently surfacing the stale verdict.
37
+
38
+ ### Verified
39
+ - `scripts/mem-probe.ts` (2000 LaunchTui turns) shows a flat post-GC heap — per-turn slope **−556 bytes/turn**, returning to its settled floor — with a single `exit` listener and zero `process` SIGINT/listener accumulation; `scripts/tmux-verify.sh smoke` boots `jeo --tmux` to a clean input box + model bar (EXIT 0). `bun run typecheck` is clean and `bun test` is **1751 pass / 0 fail** across 216 files, including the new `test/box-vertical-nav.test.ts` and the no-answer-deadend targeted suite (`team-run`/`team-schema`/`team-subagent`/`deep-interview`/`deep-interview-noninteractive`/`workflow-integrity`/`approve`/`parse-role-gate-verdict` → 71 pass / 0 fail).
40
+
41
+
9
42
  ## [0.6.36] - 2026-06-20
10
43
  _When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot._
11
44
 
package/README.ja.md CHANGED
@@ -200,11 +200,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
200
200
  ## 変更履歴 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.38]** (2026-06-21) — The OKF concept-bundle memory no longer silently drops what it learns: a legacy single-doc `MEMORY.md` (or a text-only distill fallback) can no longer shadow the concept bundle, break OKF conformance, or lose a turn's learnings — its content is folded into the concept merge and the stale blob is archived. Re-verified leak-free (`mem-probe`, 2000 turns, −570 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
204
+ - **[0.6.37]** (2026-06-20) — Two dead-end fixes: the boxed prompt's ↑/↓ now recalls input history on a soft-wrapped one-liner (only a genuine multi-line draft gets in-box caret nav), and every terminating Spec-first stage (deep-interview, ralplan, team) now surfaces a user-visible answer instead of silently stalling — re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
203
205
  - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
204
206
  - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
205
207
  - **[0.6.34]** (2026-06-20) — Per-session model memory — each saved session now remembers the model it was last using and restores it on `/resume` — plus clearer `jeo --tmux` attach diagnostics, a tmux session-name double-dash fix, and a more robust no-leak probe gate.
206
- - **[0.6.33]** (2026-06-19) — A redesigned `jeo` forge mark — a hollow line-board crayfish/eyeglass emblem drawn as thick rounded-corner tubes (no letters, no DNA helix) — that now renders inside compact-scaled forge cards, plus a unified verification directive that adds gjc's test-quality contract, and a fresh `jeo --tmux` no-leak re-verification.
207
- - **[0.6.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -200,11 +200,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
200
200
  ## 변경 이력 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.38]** (2026-06-21) — The OKF concept-bundle memory no longer silently drops what it learns: a legacy single-doc `MEMORY.md` (or a text-only distill fallback) can no longer shadow the concept bundle, break OKF conformance, or lose a turn's learnings — its content is folded into the concept merge and the stale blob is archived. Re-verified leak-free (`mem-probe`, 2000 turns, −570 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
204
+ - **[0.6.37]** (2026-06-20) — Two dead-end fixes: the boxed prompt's ↑/↓ now recalls input history on a soft-wrapped one-liner (only a genuine multi-line draft gets in-box caret nav), and every terminating Spec-first stage (deep-interview, ralplan, team) now surfaces a user-visible answer instead of silently stalling — re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
203
205
  - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
204
206
  - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
205
207
  - **[0.6.34]** (2026-06-20) — Per-session model memory — each saved session now remembers the model it was last using and restores it on `/resume` — plus clearer `jeo --tmux` attach diagnostics, a tmux session-name double-dash fix, and a more robust no-leak probe gate.
206
- - **[0.6.33]** (2026-06-19) — A redesigned `jeo` forge mark — a hollow line-board crayfish/eyeglass emblem drawn as thick rounded-corner tubes (no letters, no DNA helix) — that now renders inside compact-scaled forge cards, plus a unified verification directive that adds gjc's test-quality contract, and a fresh `jeo --tmux` no-leak re-verification.
207
- - **[0.6.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -200,11 +200,11 @@ Required npm token permissions (repository secret `NPM_TOKEN`):
200
200
  ## Changelog
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.38]** (2026-06-21) — The OKF concept-bundle memory no longer silently drops what it learns: a legacy single-doc `MEMORY.md` (or a text-only distill fallback) can no longer shadow the concept bundle, break OKF conformance, or lose a turn's learnings — its content is folded into the concept merge and the stale blob is archived. Re-verified leak-free (`mem-probe`, 2000 turns, −570 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
204
+ - **[0.6.37]** (2026-06-20) — Two dead-end fixes: the boxed prompt's ↑/↓ now recalls input history on a soft-wrapped one-liner (only a genuine multi-line draft gets in-box caret nav), and every terminating Spec-first stage (deep-interview, ralplan, team) now surfaces a user-visible answer instead of silently stalling — re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
203
205
  - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
204
206
  - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
205
207
  - **[0.6.34]** (2026-06-20) — Per-session model memory — each saved session now remembers the model it was last using and restores it on `/resume` — plus clearer `jeo --tmux` attach diagnostics, a tmux session-name double-dash fix, and a more robust no-leak probe gate.
206
- - **[0.6.33]** (2026-06-19) — A redesigned `jeo` forge mark — a hollow line-board crayfish/eyeglass emblem drawn as thick rounded-corner tubes (no letters, no DNA helix) — that now renders inside compact-scaled forge cards, plus a unified verification directive that adds gjc's test-quality contract, and a fresh `jeo --tmux` no-leak re-verification.
207
- - **[0.6.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -200,11 +200,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
200
200
  ## 更新日志 (Changelog)
201
201
 
202
202
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
203
+ - **[0.6.38]** (2026-06-21) — The OKF concept-bundle memory no longer silently drops what it learns: a legacy single-doc `MEMORY.md` (or a text-only distill fallback) can no longer shadow the concept bundle, break OKF conformance, or lose a turn's learnings — its content is folded into the concept merge and the stale blob is archived. Re-verified leak-free (`mem-probe`, 2000 turns, −570 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
204
+ - **[0.6.37]** (2026-06-20) — Two dead-end fixes: the boxed prompt's ↑/↓ now recalls input history on a soft-wrapped one-liner (only a genuine multi-line draft gets in-box caret nav), and every terminating Spec-first stage (deep-interview, ralplan, team) now surfaces a user-visible answer instead of silently stalling — re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
203
205
  - **[0.6.36]** (2026-06-20) — When `jeo --tmux` flips the mouse on so you can drag-select, the drag now actually lands on the system clipboard — the in-session tmux profile sets `set-clipboard on` + a local `copy-command` on the CURRENT session only — plus `/help` documents the drag-to-copy and the Shift/Option-drag escape hatch, re-verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot.
204
206
  - **[0.6.35]** (2026-06-20) — The prompt's Ctrl+C now clears a non-empty input box on the first press and only exits on the next press of an empty box; plus app-driven system-clipboard copy (OSC 52 + local tool, tmux-aware), drag-and-drop image attachment, a Ctrl-L prompt re-anchor, and a SIGCONT resume repaint — verified leak-free (`mem-probe`) with a fresh `jeo --tmux` boot check.
205
207
  - **[0.6.34]** (2026-06-20) — Per-session model memory — each saved session now remembers the model it was last using and restores it on `/resume` — plus clearer `jeo --tmux` attach diagnostics, a tmux session-name double-dash fix, and a more robust no-leak probe gate.
206
- - **[0.6.33]** (2026-06-19) — A redesigned `jeo` forge mark — a hollow line-board crayfish/eyeglass emblem drawn as thick rounded-corner tubes (no letters, no DNA helix) — that now renders inside compact-scaled forge cards, plus a unified verification directive that adds gjc's test-quality contract, and a fresh `jeo --tmux` no-leak re-verification.
207
- - **[0.6.32]** (2026-06-19) — Anthropic extended thinking is actually enabled now — the request finally sends a `thinking` block (adaptive for Opus/Sonnet 4.6+, budget for older), fixing reasoning on **opus-4-8** — plus a multi-token `/command`·`$skill` trigger highlight that paints every invocation and survives the trailing space, and a fresh `jeo --tmux` no-leak re-verification.
208
208
 
209
209
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
210
210
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.6.36",
3
+ "version": "0.6.38",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -22,7 +22,7 @@ import { buildConceptGraph, expandByGraph, lintConceptGraph, type GraphLintRepor
22
22
  /** On-disk document cap — the distill prompt instructs the model to stay under it. */
23
23
  export const MEMORY_MAX_CHARS = 6_000;
24
24
  /** Per-session prompt injection budget. */
25
- export const MEMORY_INJECT_MAX_CHARS = 3_000;
25
+ export const MEMORY_INJECT_MAX_CHARS = 5_000;
26
26
  /** Transcript slice fed to the distill call. */
27
27
  const TRANSCRIPT_MAX_CHARS = 12_000;
28
28
  /** A session shorter than this has nothing durable to learn. */
@@ -560,6 +560,7 @@ export async function distillSessionMemory(
560
560
  try {
561
561
  const content = await fs.readFile(file, "utf-8");
562
562
  const parsed = parseConcept(content);
563
+ if (!parsed.hasFrontmatter) continue; // skip MEMORY.md and other legacy blobs
563
564
  existingConcepts.push({
564
565
  type: parsed.frontmatter.type,
565
566
  title: parsed.frontmatter.title || "",
@@ -573,6 +574,11 @@ export async function distillSessionMemory(
573
574
  } catch {}
574
575
  }
575
576
  } catch {}
577
+ // A lingering single-doc MEMORY.md (legacy, or a prior text-only bootstrap
578
+ // fallback) is NOT a concept document — loadConcepts ignores it and it breaks
579
+ // OKF conformance. Feed its content into the merge context so its learnings
580
+ // are absorbed into concepts, then archive it on a successful JSON distill.
581
+ const legacyDoc = await loadMemory(cwd);
576
582
 
577
583
  const prompt: Message[] = [
578
584
  {
@@ -583,12 +589,21 @@ export async function distillSessionMemory(
583
589
  "Keep ONLY what helps future sessions in THIS repository: repo facts (structure, conventions, key files), " +
584
590
  "commands that work (build/test/run), gotchas (failures and their fixes), and user preferences. " +
585
591
  "Drop session-specific noise (one-off tasks, transient errors, conversational detail). " +
592
+ "CRITICAL RULES for concept granularity:\n" +
593
+ " 1. Create ONE concept per distinct fact/command/gotcha/preference — never combine multiple learnings into a single concept.\n" +
594
+ " 2. NEVER create a catch-all 'Project Memory Bundle' or similar mega-concept that lists many things. Split it.\n" +
595
+ " 3. Each Command concept covers exactly one command or workflow (e.g., 'bun test', NOT 'All bun commands').\n" +
596
+ " 4. Each Gotcha covers exactly one failure mode and its fix.\n" +
597
+ " 5. Each UserPreference covers exactly one observable preference.\n" +
598
+ " 6. RepoFact concepts describe structure/conventions — split by area (e.g., 'CLI entrypoint', 'Test setup').\n" +
599
+ " 7. If an existing concept is a mega-concept (lists many things), REPLACE it with properly split granular concepts.\n" +
600
+ " 8. Keep each concept body under 400 chars.\n\n" +
586
601
  "You must output a JSON object with a single key \"concepts\", which is an array of concept objects. " +
587
602
  "Each concept object must have the following fields:\n" +
588
603
  " - \"type\": one of \"RepoFact\", \"Command\", \"Gotcha\", \"UserPreference\"\n" +
589
604
  " - \"title\": a short, descriptive title (e.g., \"Bun test runner\")\n" +
590
605
  " - \"description\": a brief one-line summary of the concept\n" +
591
- " - \"body\": the detailed markdown content/body of the concept\n" +
606
+ " - \"body\": the detailed markdown content/body of the concept (≤400 chars)\n" +
592
607
  " - \"tags\": an array of string tags (optional)\n" +
593
608
  " - \"confidence\": one of \"high\", \"medium\", \"low\" (optional)\n" +
594
609
  " - \"links\": an array of other concept paths/IDs this concept links to (optional)\n\n" +
@@ -598,6 +613,7 @@ export async function distillSessionMemory(
598
613
  role: "user",
599
614
  content:
600
615
  `Existing concepts:\n${JSON.stringify(existingConcepts, null, 2)}\n\n` +
616
+ (legacyDoc ? `Legacy single-doc memory to fold into concepts:\n${legacyDoc}\n\n` : "") +
601
617
  `Session transcript (tail):\n${transcriptTail(history)}`
602
618
  }
603
619
  ];
@@ -695,9 +711,25 @@ export async function distillSessionMemory(
695
711
  if (updatedConcepts.length > 0) {
696
712
  await updateLog(bundleDir, updatedConcepts);
697
713
  }
714
+ // Concepts now own the durable memory and the legacy blob (if any) was
715
+ // folded into the merge above. Archive a lingering MEMORY.md off the active
716
+ // read path so it can never break OKF conformance or shadow the bundle.
717
+ if (legacyDoc) {
718
+ await fs.rename(memoryFilePath(cwd), `${memoryFilePath(cwd)}.bak`).catch(() => {});
719
+ }
698
720
  await cleanupStalePendingFiles(bundleDir);
699
721
  return { updated: true };
700
722
  } else {
723
+ // JSON extraction failed (text-only models often wrap or drop the JSON).
724
+ // The single-doc MEMORY.md is a BOOTSTRAP fallback only: it is injected by
725
+ // memoryPromptSection ONLY when the OKF concept bundle is empty. Writing it
726
+ // while concepts already exist would (a) be silently ignored at injection
727
+ // (concepts win) — losing the learning — and (b) break OKF conformance
728
+ // (MEMORY.md has no frontmatter). So when a bundle exists, do NOT clobber
729
+ // it with a dead blob; keep the prior concepts as the durable memory.
730
+ if (existingConcepts.length > 0) {
731
+ return { updated: false, skipped: "distill produced no JSON; kept existing concept bundle (legacy blob suppressed)" };
732
+ }
701
733
  const doc = distilled.trim().slice(0, MEMORY_MAX_CHARS);
702
734
  if (!doc) return { updated: false, skipped: "model returned an empty document" };
703
735
  const file = memoryFilePath(cwd);
@@ -567,7 +567,7 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
567
567
  let ambiguity = state.current_ambiguity ?? 1.0;
568
568
  let lastParsed: SocraticResponse | undefined;
569
569
 
570
- const freezeSeed = async (parsed: SocraticResponse): Promise<void> => {
570
+ const freezeSeed = async (parsed: SocraticResponse): Promise<boolean> => {
571
571
  const readiness = freezeReadiness(parsed);
572
572
  if (!readiness.ok) throw new Error(`Refusing to freeze seed: ${readiness.reason}.`);
573
573
 
@@ -594,7 +594,7 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
594
594
  `[ERROR] Seed round-trip self-check FAILED — the acceptance criteria would not survive ultragoal's parser ` +
595
595
  `(writer/parser drift). NOT freezing the seed. Got back: ${JSON.stringify(parsedBack)}`,
596
596
  );
597
- return;
597
+ return false;
598
598
  }
599
599
  await fs.writeFile(seedPath, seedContent, "utf-8");
600
600
  state!.current_phase = "complete";
@@ -603,6 +603,7 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
603
603
  state!.current_ambiguity = Math.min(state!.current_ambiguity ?? threshold, threshold);
604
604
  await writeWorkflowState("deep-interview", state!, cwd);
605
605
  log(`Saved frozen requirements spec seed to: ${seedPath}`);
606
+ return true;
606
607
  };
607
608
 
608
609
  while (round <= 10) {
@@ -629,12 +630,20 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
629
630
  const readiness = freezeReadiness(parsed);
630
631
  if (ambiguity <= threshold && readiness.ok) {
631
632
  log(`\n[SUCCESS] Ambiguity is <= ${(threshold * 100).toFixed(0)}%! Concluding requirements gather.`);
632
- await freezeSeed(parsed);
633
- log("\n[Handoff Ready] Requirement is crystallized. Next, run 'jeo ralplan' to build a plan.");
634
- if (opts.onProgress) {
635
- opts.onProgress({ skill: "deep-interview", phase: "complete" });
633
+ const frozen = await freezeSeed(parsed);
634
+ if (frozen) {
635
+ log("\n[Handoff Ready] Requirement is crystallized. Next, run 'jeo ralplan' to build a plan.");
636
+ if (opts.onProgress) {
637
+ opts.onProgress({ skill: "deep-interview", phase: "complete" });
638
+ }
639
+ break;
636
640
  }
637
- break;
641
+ // Freeze failed (e.g. round-trip self-check): do NOT emit a false
642
+ // "crystallized" handoff. Fall through to the normal question loop
643
+ // below — it already asks the follow-up, pushes history, and bumps
644
+ // the round — so the interview simply stays open.
645
+ log("\n[HOLD] Could not freeze the requirements seed — see the error above. Keeping the interview open.");
646
+
638
647
  }
639
648
 
640
649
  let nextQuestion = parsed.nextQuestion?.trim() || interviewLanguage.acceptanceFollowup;
@@ -51,6 +51,33 @@ export function isStandaloneBackspace(chunk: string): boolean {
51
51
  return chunk.length > 0 && /^[\x7f\b]+$/.test(chunk);
52
52
  }
53
53
 
54
+ /** Private-use sentinel the input filter substitutes for an EXPLICIT line break
55
+ * (Shift+Enter / a pasted newline) before the bytes reach readline, so the draft can
56
+ * carry hard newlines through readline's single-line buffer. A line that merely
57
+ * SOFT-WRAPS at the box width contains NO sentinel — that distinction is the whole
58
+ * point of `isGenuineMultilineDraft`. */
59
+ export const MULTILINE_SENTINEL = "\uE000";
60
+
61
+ /** True when the draft has at least one EXPLICIT line break (a sentinel) — i.e. the
62
+ * user deliberately made it multi-line. A long single line that the box soft-wraps to
63
+ * several visual rows is NOT multi-line and returns false. */
64
+ export function isGenuineMultilineDraft(line: string): boolean {
65
+ return line.includes(MULTILINE_SENTINEL);
66
+ }
67
+
68
+ /** Whether an Up/Down keystroke should move the caret BETWEEN the box's visual rows
69
+ * (textarea feel) instead of falling through to readline's input-history recall. True
70
+ * only for a genuinely multi-line draft with no slash dropdown or history panel owning
71
+ * the arrows. A soft-wrapped one-liner returns false, so ↑ recalls the previous prompt
72
+ * rather than dragging the wrapped tail up a visual row. */
73
+ export function shouldBoxVerticalNav(
74
+ line: string,
75
+ opts: { slashMatchCount: number; historyPanelOpen: boolean },
76
+ ): boolean {
77
+ return isGenuineMultilineDraft(line) && opts.slashMatchCount === 0 && !opts.historyPanelOpen;
78
+ }
79
+
80
+
54
81
  /**
55
82
  * macOS / fixterms combo-key normalization for the boxed prompt's line editor.
56
83
  *
@@ -136,6 +136,9 @@ import {
136
136
  matchTerminalReport,
137
137
  stripMouseReports,
138
138
  rewriteCursorCombos,
139
+ MULTILINE_SENTINEL,
140
+ isGenuineMultilineDraft,
141
+ shouldBoxVerticalNav,
139
142
  queuePromptInputChunk,
140
143
  captureLivePromptInputChunk,
141
144
  restoreQueuedLinesToPrefill,
@@ -203,6 +206,9 @@ export {
203
206
  matchTerminalReport,
204
207
  stripMouseReports,
205
208
  rewriteCursorCombos,
209
+ MULTILINE_SENTINEL,
210
+ isGenuineMultilineDraft,
211
+ shouldBoxVerticalNav,
206
212
  queuePromptInputChunk,
207
213
  captureLivePromptInputChunk,
208
214
  restoreQueuedLinesToPrefill,
@@ -1360,7 +1366,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1360
1366
  // readline sees them — readline inserts it as an ordinary character (no per-line
1361
1367
  // submit/race), the box renders it as a real line break, and it is expanded back to
1362
1368
  // "\n" on submit. On for any interactive TTY; JEO_NO_MULTILINE=1 reads stdin directly.
1363
- const SENTINEL = "\uE000";
1369
+ const SENTINEL = MULTILINE_SENTINEL;
1364
1370
  const SHIFT_ENTER_SEQS = ["\u001b[27;2;13~", "\u001b[13;2u"];
1365
1371
  // Multi-line input filter is ON for any interactive TTY: reliable multi-line paste
1366
1372
  // (fills the box, submits intact into the user card) is the default. The lone-"\n"
@@ -1438,14 +1444,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1438
1444
  // Option/Cmd+Backspace) into the canonical control bytes it DOES act on.
1439
1445
  const combo = matchCursorCombo(data, i);
1440
1446
  if (combo) { out += combo[1]; i += combo[0].length; continue; }
1441
- // Up/Down inside a multi-line / wrapped draft move the caret between the box's
1442
- // visual rows (textarea feel). Only when no slash list or history panel owns ↑/↓,
1443
- // and only away from the top/bottom edge — at the edge the keys fall through to
1444
- // readline so ↑/↓ still recalls input history.
1447
+ // Up/Down inside a GENUINELY multi-line draft (explicit Shift+Enter breaks →
1448
+ // SENTINEL) move the caret between the box's visual rows (textarea feel). A
1449
+ // single line that merely SOFT-WRAPS is NOT treated as multi-line: ↑/↓ fall
1450
+ // through to readline so they recall input history — the dominant REPL
1451
+ // expectation. (Without this gate, ↑ on a wrapped one-liner jumped the caret up
1452
+ // a visual row instead of recalling the previous prompt, so the last wrapped
1453
+ // word appeared to "follow the caret up".) Skipped when a slash list or history
1454
+ // panel owns ↑/↓, and at the top/bottom edge the keys still reach history.
1445
1455
  if ((data.startsWith("\u001b[", i) || data.startsWith("\u001bO", i)) && (data[i + 2] === "A" || data[i + 2] === "B")) {
1446
1456
  const dir = data[i + 2] === "A" ? "up" : "down";
1447
1457
  const line = activeRl?.line ?? "";
1448
- if (line.length > 0 && navMatches.length === 0 && promptHistoryLines == null && activeRl) {
1458
+ if (shouldBoxVerticalNav(line, { slashMatchCount: navMatches.length, historyPanelOpen: promptHistoryLines != null }) && activeRl) {
1449
1459
  const winCols = Math.max(24, (process.stdout.columns ?? 80) - 1);
1450
1460
  const textWidth = Math.max(1, Math.max(24, winCols) - 6);
1451
1461
  const cur = typeof activeRl.cursor === "number" ? activeRl.cursor : line.length;
@@ -2586,6 +2596,35 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2586
2596
  return;
2587
2597
  }
2588
2598
  if (!previewArmed || pickerActive) return;
2599
+ // Drag-and-drop file attach: a terminal delivers a dropped file as its PATH typed
2600
+ // into the box, wrapped in a bracketed paste. On paste-end, swap any readable image
2601
+ // path LIVE for the same `[image #N]` tag Ctrl+V uses, so the box shows the tag
2602
+ // instead of a long raw filesystem path. Non-image / unreadable paths and ordinary
2603
+ // text pastes match nothing and are a no-op. Submit-time attach (below) still covers
2604
+ // terminals that deliver a drop WITHOUT bracketing it.
2605
+ if (key?.name === "paste-end" && !pasteInFlight && !pasteLineFired) {
2606
+ pasteInFlight = true;
2607
+ // Defer one tick: the dropped bytes reach readline through the keyFilter
2608
+ // PassThrough, so rl.line is only settled after the current stream turn.
2609
+ setImmediate(() => {
2610
+ void (async () => {
2611
+ try {
2612
+ const rli = rl as unknown as { line: string; cursor: number };
2613
+ const before = rli.line;
2614
+ const dropped = await attachImagePaths(before, pendingImages.length + 1);
2615
+ if (dropped.images.length === 0 || dropped.text === before) return;
2616
+ pendingImages.push(...dropped.images);
2617
+ rli.line = dropped.text;
2618
+ rli.cursor = dropped.text.length; // drop lands at the caret (line end in the common case)
2619
+ typedLine = dropped.text;
2620
+ if (previewArmed) drawFooter(previewLines(typedLine, navIdx));
2621
+ } finally {
2622
+ pasteInFlight = false;
2623
+ }
2624
+ })();
2625
+ });
2626
+ return;
2627
+ }
2589
2628
  // Ctrl+L: redraw / re-anchor the prompt. The recovery for a footer whose in-place
2590
2629
  // anchor drifted after the screen scrolled (typed text stops showing in the box).
2591
2630
  if (key?.ctrl && key.name === "l") {
@@ -257,6 +257,12 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
257
257
  cleanPlan = revised;
258
258
  await fs.writeFile(planPath, cleanPlan, "utf-8");
259
259
  gate = await runConsensusCriticGate({ cwd, seedContent, plan: cleanPlan, signal: opts.signal });
260
+ } else {
261
+ // The revision did not parse as a schema/role-valid plan, so it cannot be
262
+ // re-gated — the original [ITERATE] verdict stands. Report this explicitly
263
+ // instead of silently discarding the revision attempt (which otherwise
264
+ // surfaces only as the unchanged "verdict: ITERATE" failure below).
265
+ log(`[ralplan] The revised plan was not schema/role-valid — discarding the revision; the [ITERATE] verdict stands.`);
260
266
  }
261
267
  }
262
268
  ralplanState.plan_path = planPath;
@@ -347,6 +347,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
347
347
  cwd,
348
348
  roleId: roleByIndex[activeIndex],
349
349
  strictMutations: opts.strictMutations ?? false,
350
+ log,
350
351
  });
351
352
 
352
353
  if (opts.signal?.aborted) {
@@ -393,13 +394,14 @@ export async function runTeamCommand(args: string[] = []): Promise<void> {
393
394
  }
394
395
  }
395
396
 
396
- async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: string; roleId?: string; strictMutations?: boolean }): Promise<boolean> {
397
+ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: string; roleId?: string; strictMutations?: boolean; log: (line: string) => void }): Promise<boolean> {
398
+ const log = ctx.log;
397
399
  const config = await readGlobalConfig();
398
400
  const role = getSubagentRole(ctx.roleId, config) ?? defaultSubagentRole();
399
401
  const renderOpts: RalphRenderOptions = { color: !!process.stdout.isTTY, indexed: true };
400
402
  const model = resolveSubagentModel(role.id, config);
401
403
  const maxSteps = resolveSubagentMaxSteps(role.id, config);
402
- console.log(` └─ Subagent: ${role.title} · model ${model} · ≤${maxSteps} steps`);
404
+ log(` └─ Subagent: ${role.title} · model ${model} · ≤${maxSteps} steps`);
403
405
 
404
406
  const contextTokens = catalogMetadata(model)?.contextTokens;
405
407
 
@@ -428,13 +430,13 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
428
430
  events: {
429
431
  onAssistant: (_raw, invocation) => {
430
432
  if (!invocation) {
431
- console.log(formatRalphStreamEvent("error", "invalid tool-call json; retrying", renderOpts));
433
+ log(formatRalphStreamEvent("error", "invalid tool-call json; retrying", renderOpts));
432
434
  } else if (invocation.tool !== "done") {
433
- console.log(formatRalphStreamEvent("step", `tool ${invocation.tool} requested`, renderOpts));
435
+ log(formatRalphStreamEvent("step", `tool ${invocation.tool} requested`, renderOpts));
434
436
  }
435
437
  },
436
438
  onStep: async step => {
437
- console.log(formatRalphStreamEvent("step", `${role.title} thinking ${step}/${maxSteps}`, renderOpts));
439
+ log(formatRalphStreamEvent("step", `${role.title} thinking ${step}/${maxSteps}`, renderOpts));
438
440
  try {
439
441
  await maybeCompact(history, { model, contextTokens });
440
442
  } catch (err) {
@@ -446,27 +448,27 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
446
448
  if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
447
449
  else if (tool === "bash") bashRuns++;
448
450
  }
449
- console.log(formatRalphStreamEvent(ok ? "complete" : "error", `tool ${tool}`, renderOpts));
451
+ log(formatRalphStreamEvent(ok ? "complete" : "error", `tool ${tool}`, renderOpts));
450
452
  },
451
- onNotice: msg => console.log(formatRalphStreamEvent("step", msg, renderOpts)),
453
+ onNotice: msg => log(formatRalphStreamEvent("step", msg, renderOpts)),
452
454
  },
453
455
  });
454
456
 
455
457
  const reason = result.doneReason?.trim() || `${role.title} did not converge within ${result.steps} steps`;
456
458
  if (!result.done) {
457
- console.log(formatRalphStreamEvent("error", reason, renderOpts));
459
+ log(formatRalphStreamEvent("error", reason, renderOpts));
458
460
  return false;
459
461
  }
460
462
 
461
463
  const contract = validateSubagentDoneReason(role, reason);
462
464
  if (!contract.ok) {
463
- console.log(formatRalphStreamEvent("error", `${role.title} report incomplete: missing ${contract.missing?.join(", ")}`, renderOpts));
465
+ log(formatRalphStreamEvent("error", `${role.title} report incomplete: missing ${contract.missing?.join(", ")}`, renderOpts));
464
466
  return false;
465
467
  }
466
468
 
467
469
  const gate = parseRoleGateVerdict(role.id, reason);
468
470
  if (!gate.ok) {
469
- console.log(formatRalphStreamEvent("error", gate.message ?? `${role.title} blocked execution`, renderOpts));
471
+ log(formatRalphStreamEvent("error", gate.message ?? `${role.title} blocked execution`, renderOpts));
470
472
  return false;
471
473
  }
472
474
 
@@ -483,11 +485,17 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
483
485
  const hardFail = ctx.strictMutations && bashRuns === 0;
484
486
  // Round-12: separate the tones so a passing advisory run doesn't masquerade
485
487
  // 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));
488
+ log(formatRalphStreamEvent(hardFail ? "error" : "warn", msg, renderOpts));
487
489
  if (hardFail) {
488
490
  return false;
489
491
  }
490
492
  }
491
- console.log(formatRalphStreamEvent("complete", `${role.title} finished task`, renderOpts));
493
+ // Surface the subagent's ACTUAL report to the user — not just a "finished"
494
+ // status. Previously `reason` was consumed only for validation/gating and the
495
+ // report content was discarded, so `team` runs produced no visible answer.
496
+ log(formatRalphStreamEvent("complete", `${role.title} finished task`, renderOpts));
497
+ log(`\n${role.title} report:`);
498
+ log(reason); // log() already splits multi-line strings across the io.output sink
499
+
492
500
  return true;
493
501
  }