jeo-code 0.7.1 → 0.7.2

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,23 @@ 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.7.2] - 2026-06-22
10
+ _Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6)._
11
+
12
+ ### Added
13
+ - **Per-criterion `{verify: <command>}` directive for `ultragoal`.** An acceptance criterion authored with a trailing `{verify: <shell command>}` is now INDIVIDUALLY verifiable — `parseCriterion` (in `src/agent/seed.ts`) splits the directive off the criterion text (round-tripping unmangled through the seed's JSON-encoded list), and `ultragoal` runs that command and records a real PASS/FAIL in the ledger. Criteria without a directive stay UNVERIFIED on a green suite (honest by default, strengthenable on demand). The overall report now uses a status taxonomy: **FAILED** (suite red OR any checked criterion failed), **SUCCESS** (every criterion individually verified and passed), **PARTIAL** (some verified, others still unverified), and **SUITE_GREEN** (green suite, nothing individually proven). The verdict (`ok`) now reflects `status !== "FAILED"` rather than the raw suite result.
14
+
15
+ ### Changed
16
+ - **The boxed prompt's stdin→readline byte rewriter is extracted into a pure function.** `filterPromptInputChunk` (in `src/commands/launch/input.ts`) now holds the FULL keystroke wiring — bracketed-paste folding across chunks, mouse/terminal-report swallowing, Shift+Enter → hard-break SENTINEL, combo-key normalization, and the boundary-aware Up/Down box navigation — so it is testable WITHOUT a live readline/PTY. `launch.ts`'s live handler is now a thin adapter over it, eliminating the duplicated decision logic that previously diverged from its `boxVerticalNavAction` helper.
17
+
18
+ ### Fixed
19
+ - **The "↓ cuts the lower text" multi-line draft regression is now covered by direct byte-stream tests.** A new `test/prompt-key-filter.test.ts` (16 cases) drives raw escape sequences through `filterPromptInputChunk` exactly as the live `kfDataHandler` does — asserting which bytes reach readline and how the caret/paste state mutate — the closest automated stand-in for live-terminal verification of the box-navigation fix shipped in 0.7.1.
20
+
21
+ ### Verified
22
+ - `bun run typecheck` clean; full suite **1826 pass / 0 fail** (222 files).
23
+ - `scripts/mem-probe.ts` (2000 turns × 40 tools): no long-term leak (exit 0; settled heap floor flat at ~4.3 MB, net +0.49 MB, exit-listeners flat at 1).
24
+ - `scripts/tmux-verify.sh battery`: **6/6 PASSED** on a fresh `jeo --tmux` boot (boot, `/help`, unknown `$skill`, `/agents`, `$ultragoal`, unresolved `/command`).
25
+
9
26
  ## [0.7.1] - 2026-06-22
10
27
  _TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6)._
11
28
 
package/README.ja.md CHANGED
@@ -193,11 +193,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
193
193
  ## 変更履歴 (Changelog)
194
194
 
195
195
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
196
+ - **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
196
197
  - **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
197
198
  - **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
198
199
  - **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
199
200
  - **[0.6.40]** (2026-06-21) — Wire-level stream heartbeat: ANY bytes from the provider (SSE keepalive/ping, filtered events) now re-arm the idle watchdog — a connected-but-quiet stream can no longer trip a false `stream idle` retry. Default idle window raised to 300s so Ollama/llama.cpp model-load silence no longer exhausts retries and stops the turn.
200
- - **[0.6.39]** (2026-06-21) — A long "thinking" phase no longer trips a false stream-idle retry: reasoning/thinking deltas now act as a stream heartbeat, so a model that streams thought tokens past the idle window before any visible text is no longer mistaken for a stalled stream and retried (which discarded the in-progress reasoning). Re-verified leak-free (`mem-probe`, 2000 turns, −525 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
201
201
 
202
202
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
203
203
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -193,11 +193,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
193
193
  ## 변경 이력 (Changelog)
194
194
 
195
195
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
196
+ - **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
196
197
  - **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
197
198
  - **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
198
199
  - **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
199
200
  - **[0.6.40]** (2026-06-21) — Wire-level stream heartbeat: ANY bytes from the provider (SSE keepalive/ping, filtered events) now re-arm the idle watchdog — a connected-but-quiet stream can no longer trip a false `stream idle` retry. Default idle window raised to 300s so Ollama/llama.cpp model-load silence no longer exhausts retries and stops the turn.
200
- - **[0.6.39]** (2026-06-21) — A long "thinking" phase no longer trips a false stream-idle retry: reasoning/thinking deltas now act as a stream heartbeat, so a model that streams thought tokens past the idle window before any visible text is no longer mistaken for a stalled stream and retried (which discarded the in-progress reasoning). Re-verified leak-free (`mem-probe`, 2000 turns, −525 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
201
201
 
202
202
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
203
203
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -195,11 +195,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
195
195
  ## Changelog
196
196
 
197
197
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
198
+ - **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
198
199
  - **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
199
200
  - **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
200
201
  - **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
201
202
  - **[0.6.40]** (2026-06-21) — Wire-level stream heartbeat: ANY bytes from the provider (SSE keepalive/ping, filtered events) now re-arm the idle watchdog — a connected-but-quiet stream can no longer trip a false `stream idle` retry. Default idle window raised to 300s so Ollama/llama.cpp model-load silence no longer exhausts retries and stops the turn.
202
- - **[0.6.39]** (2026-06-21) — A long "thinking" phase no longer trips a false stream-idle retry: reasoning/thinking deltas now act as a stream heartbeat, so a model that streams thought tokens past the idle window before any visible text is no longer mistaken for a stalled stream and retried (which discarded the in-progress reasoning). Re-verified leak-free (`mem-probe`, 2000 turns, −525 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
203
203
 
204
204
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
205
205
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -193,11 +193,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
193
193
  ## 更新日志 (Changelog)
194
194
 
195
195
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
196
+ - **[0.7.2]** (2026-06-22) — Workflow honesty + prompt-input testability: `ultragoal` acceptance criteria can now carry a trailing `{verify: <command>}` directive that makes a criterion **individually** provable (real PASS/FAIL instead of a blanket UNVERIFIED on a green suite), with a SUCCESS / PARTIAL / SUITE_GREEN / FAILED status taxonomy; the boxed prompt's full stdin→readline keystroke rewriter is extracted into a pure, PTY-free function so the "↓ cuts the lower text" fix and the rest of the wiring are unit-tested directly. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
196
197
  - **[0.7.1]** (2026-06-22) — TUI polish + provider breadth: the live forge-card border and forge mark now flow in the **active theme's** own neon palette instead of a fixed brand gradient, the boxed prompt's Up/Down keys no longer wipe a multi-line draft at its top/bottom edge, the Tencent Cloud MaaS catalog gains the live-verified DeepSeek/MiniMax/GLM model families (with catalog backfill for providers that expose no models-list endpoint), and the `jeo --tmux` smoke check drops a false-positive launcher-log grep. Verified leak-free (`mem-probe`, 2000 turns, exit 0) with a fresh `jeo --tmux` boot battery (6/6).
197
198
  - **[0.7.0]** (2026-06-22) — Adds an opt-in, fail-closed **computer-use** capability (a `computer` tool + `jeo computer` CLI for screenshot/click/type/keypress/scroll/drag/wait/batch), hardens the spec-first workflow against silent post-consensus plan edits and PID-reuse lock starvation, makes OAuth refresh degrade cleanly when the refresh token is dead, and teaches tool-arg JSON repair to tolerate unescaped control characters. Verified leak-free (`mem-probe`, 2000 turns, −447 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
198
199
  - **[0.6.41]** (2026-06-21) — The non-streaming model path now inherits the v0.6.40 300s window: an unattended long-reasoning turn (compaction, ralplan, deep-interview, memory distill, goal-verify, subagent/autopilot steps) is no longer false-aborted at the old 120s wall cap. Re-verified leak-free (`mem-probe`, 2000 turns, −610 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
199
200
  - **[0.6.40]** (2026-06-21) — Wire-level stream heartbeat: ANY bytes from the provider (SSE keepalive/ping, filtered events) now re-arm the idle watchdog — a connected-but-quiet stream can no longer trip a false `stream idle` retry. Default idle window raised to 300s so Ollama/llama.cpp model-load silence no longer exhausts retries and stops the turn.
200
- - **[0.6.39]** (2026-06-21) — A long "thinking" phase no longer trips a false stream-idle retry: reasoning/thinking deltas now act as a stream heartbeat, so a model that streams thought tokens past the idle window before any visible text is no longer mistaken for a stalled stream and retried (which discarded the in-progress reasoning). Re-verified leak-free (`mem-probe`, 2000 turns, −525 bytes/turn slope) with a fresh `jeo --tmux` boot battery (6/6).
201
201
 
202
202
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
203
203
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
package/src/agent/seed.ts CHANGED
@@ -50,3 +50,27 @@ export function parseSeedList(content: string, name: string): string[] {
50
50
  export function parseSeedAcceptanceCriteria(content: string): string[] {
51
51
  return parseSeedList(content, "acceptance_criteria");
52
52
  }
53
+ /** A single acceptance criterion, optionally carrying an executable check command.
54
+ * Authors make a criterion INDIVIDUALLY verifiable by appending a trailing
55
+ * `{verify: <shell command>}` directive; ultragoal runs that command and records a
56
+ * real PASS/FAIL instead of leaving the criterion UNVERIFIED. Criteria without a
57
+ * directive stay honest-by-default (UNVERIFIED on a green suite). */
58
+ export interface ParsedCriterion {
59
+ /** Human-readable criterion text with any {verify:...} directive stripped. */
60
+ text: string;
61
+ /** Optional shell command whose exit status proves this specific criterion. */
62
+ verify?: string;
63
+ }
64
+
65
+ const VERIFY_DIRECTIVE = /\s*\{verify:\s*([^}]+?)\s*\}\s*$/i;
66
+
67
+ /** Split an optional trailing `{verify: <cmd>}` directive off a criterion string.
68
+ * The directive lives INSIDE the JSON-encoded seed value, so it round-trips
69
+ * unmangled through `yamlList`/`parseSeedList` like any other criterion text. */
70
+ export function parseCriterion(raw: string): ParsedCriterion {
71
+ const match = raw.match(VERIFY_DIRECTIVE);
72
+ if (!match) return { text: raw.trim() };
73
+ const verify = match[1].trim();
74
+ const text = raw.replace(VERIFY_DIRECTIVE, "").trim();
75
+ return verify ? { text, verify } : { text };
76
+ }
@@ -504,4 +504,111 @@ export function decideCtrlC(
504
504
  ): CtrlCAction {
505
505
  if (msSinceLastCtrlC < collapseMs) return "ignore";
506
506
  return hasInput ? "clear" : "exit";
507
+ }
508
+
509
+ /** Shift+Enter encodings the box rewrites to a hard-break SENTINEL: the xterm
510
+ * `modifyOtherKeys` form (`CSI 27;2;13~`) and the kitty keyboard-protocol form
511
+ * (`CSI 13;2u`). Shared by the live input filter and its tests so they can't drift. */
512
+ export const SHIFT_ENTER_SEQS: readonly string[] = ["\u001b[27;2;13~", "\u001b[13;2u"];
513
+
514
+ /** Minimal readline view the prompt key-filter reads (and mutates `cursor` on a
515
+ * vertical-nav "move"). The live filter passes the real readline interface; tests
516
+ * pass a plain object. */
517
+ export interface PromptKeyFilterRl {
518
+ line: string;
519
+ cursor: number;
520
+ }
521
+
522
+ /** Per-keystroke context the filter needs that is NOT derivable from the bytes:
523
+ * the opt-in lone-LF Shift+Enter toggle, whether a slash dropdown / Ctrl+O history
524
+ * panel currently owns the arrow keys, and the terminal width (for the box's
525
+ * soft-wrap row model). */
526
+ export interface PromptKeyFilterEnv {
527
+ loneLfShiftEnter: boolean;
528
+ slashMatchCount: number;
529
+ historyPanelOpen: boolean;
530
+ /** `process.stdout.columns ?? 80` — the raw column count BEFORE the box's own insets. */
531
+ columns: number;
532
+ }
533
+
534
+ /** Carried across chunks: a bracketed paste can span several stdin chunks, so the
535
+ * in-paste flag must survive between filter calls. */
536
+ export interface PromptKeyFilterState {
537
+ inPaste: boolean;
538
+ }
539
+
540
+ export interface PromptKeyFilterResult {
541
+ /** Bytes to forward to readline. */
542
+ out: string;
543
+ /** True when the whole chunk was consumed and NOTHING should be written (the
544
+ * empty-line standalone-Backspace guard) — distinct from an empty `out`. */
545
+ drop: boolean;
546
+ }
547
+
548
+ /** The stdin → readline byte rewriter for the boxed multi-line prompt, extracted as a
549
+ * pure function so the full keystroke wiring — paste folding, mouse/terminal-report
550
+ * swallowing, Shift+Enter → SENTINEL, combo-key normalization, and the boundary-aware
551
+ * Up/Down box navigation — is testable WITHOUT a live readline/PTY. The live filter in
552
+ * `launch.ts` is a thin adapter over this: feed it each chunk, forward `out` unless
553
+ * `drop`, and let it mutate `rl.cursor` (textarea caret moves) and `state.inPaste`.
554
+ *
555
+ * The "swallow" branch is the fix for the "↓ cuts the lower text" bug: at the top/bottom
556
+ * visual row of a GENUINE multi-line draft the key is consumed (no bytes emitted) so it
557
+ * can't reach readline's input-history recall and wipe the message being composed. */
558
+ export function filterPromptInputChunk(
559
+ data: string,
560
+ rl: PromptKeyFilterRl | null,
561
+ env: PromptKeyFilterEnv,
562
+ state: PromptKeyFilterState,
563
+ ): PromptKeyFilterResult {
564
+ // Empty-line Backspace guard: a standalone Backspace with an empty buffer is a no-op
565
+ // that some Bun readline builds turn into a spurious `close` (hard exit) — drop it
566
+ // before it reaches readline. Forwarded normally inside a paste or with text present.
567
+ if (!state.inPaste && isStandaloneBackspace(data) && !(rl?.line ?? "")) {
568
+ return { out: "", drop: true };
569
+ }
570
+ let out = "";
571
+ let i = 0;
572
+ while (i < data.length) {
573
+ if (!state.inPaste && data.startsWith(PASTE_START, i)) { state.inPaste = true; out += PASTE_START; i += PASTE_START.length; continue; }
574
+ if (state.inPaste && data.startsWith(PASTE_END, i)) { state.inPaste = false; out += PASTE_END; i += PASTE_END.length; continue; }
575
+ if (state.inPaste) {
576
+ if (data.startsWith("\r\n", i)) { out += MULTILINE_SENTINEL; i += 2; continue; }
577
+ if (data[i] === "\n" || data[i] === "\r") { out += MULTILINE_SENTINEL; i += 1; continue; }
578
+ out += data[i]; i += 1; continue;
579
+ }
580
+ const mouse = matchMouseReport(data, i);
581
+ if (mouse > 0) { i += mouse; continue; }
582
+ const report = matchTerminalReport(data, i);
583
+ if (report > 0) { i += report; continue; }
584
+ let matched = false;
585
+ for (const seq of SHIFT_ENTER_SEQS) {
586
+ if (data.startsWith(seq, i)) { out += MULTILINE_SENTINEL; i += seq.length; matched = true; break; }
587
+ }
588
+ if (matched) continue;
589
+ const combo = matchCursorCombo(data, i);
590
+ if (combo) { out += combo[1]; i += combo[0].length; continue; }
591
+ // Up/Down between the box's visual rows (textarea feel) for any MULTI-ROW draft;
592
+ // at the top/bottom edge a soft-wrapped one-liner falls through to history recall,
593
+ // but a genuine multi-line draft swallows the key (the "↓ cuts text" fix).
594
+ if ((data.startsWith("\u001b[", i) || data.startsWith("\u001bO", i)) && (data[i + 2] === "A" || data[i + 2] === "B")) {
595
+ const dir = data[i + 2] === "A" ? "up" : "down";
596
+ const line = rl?.line ?? "";
597
+ if (shouldBoxVerticalNav(line, { slashMatchCount: env.slashMatchCount, historyPanelOpen: env.historyPanelOpen }) && rl) {
598
+ const winCols = Math.max(24, env.columns - 1);
599
+ const textWidth = Math.max(1, Math.max(24, winCols) - 6);
600
+ const cur = typeof rl.cursor === "number" ? rl.cursor : line.length;
601
+ const action = boxVerticalNavAction(line.split(MULTILINE_SENTINEL).join("\n"), line, cur, textWidth, dir);
602
+ if (action.kind === "move") { rl.cursor = action.cursor; i += 3; continue; }
603
+ if (action.kind === "swallow") { i += 3; continue; } // keep the multi-line draft intact
604
+ }
605
+ out += data.slice(i, i + 3); i += 3; continue;
606
+ }
607
+ if (env.loneLfShiftEnter && data[i] === "\n") { out += MULTILINE_SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
608
+ // Ctrl+L (form feed): the prompt redraw hotkey, handled on the process.stdin keypress
609
+ // listener — never forward it to readline as a literal char.
610
+ if (data[i] === "\u000c") { i += 1; continue; }
611
+ out += data[i]; i += 1;
612
+ }
613
+ return { out, drop: false };
507
614
  }
@@ -146,6 +146,7 @@ import {
146
146
  createInFlightAbortHarness,
147
147
  classifyMidTurnLine,
148
148
  decideCtrlC,
149
+ filterPromptInputChunk,
149
150
  } from "./launch/input";
150
151
  import {
151
152
  gatedStdout,
@@ -1394,7 +1395,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1394
1395
  // submit/race), the box renders it as a real line break, and it is expanded back to
1395
1396
  // "\n" on submit. On for any interactive TTY; JEO_NO_MULTILINE=1 reads stdin directly.
1396
1397
  const SENTINEL = MULTILINE_SENTINEL;
1397
- const SHIFT_ENTER_SEQS = ["\u001b[27;2;13~", "\u001b[13;2u"];
1398
1398
  // Multi-line input filter is ON for any interactive TTY: reliable multi-line paste
1399
1399
  // (fills the box, submits intact into the user card) is the default. The lone-"\n"
1400
1400
  // Shift+Enter rule stays opt-in (JEO_MULTILINE=1) — it needs ghostty's
@@ -1435,73 +1435,25 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1435
1435
  // `keybind = shift+enter=text:\n`, passes tmux unchanged even with extended-keys
1436
1436
  // off) and the xterm "\x1b[27;2;13~" / kitty "\x1b[13;2u" sequences. Enter ("\r")
1437
1437
  // passes through and submits.
1438
- let kfInPaste = false;
1438
+ // Stateful across chunks (a bracketed paste can span several). The byte-rewrite logic
1439
+ // lives in `filterPromptInputChunk` (pure + exported) so the full keystroke wiring is
1440
+ // testable without a live readline/PTY; this handler is the thin live adapter.
1441
+ const kfState = { inPaste: false };
1439
1442
  const kfDataHandler = (chunk: Buffer) => {
1440
1443
  const data = chunk.toString("utf8");
1441
- // Empty-line Backspace guard: a standalone Backspace with nothing to delete is a
1442
- // no-op, but some Bun readline builds emit a spurious `close` for it — which the
1443
- // REPL treats as a hard exit. Drop it before it reaches readline. (Inside a paste,
1444
- // or with text in the buffer, backspace is forwarded normally so editing works.)
1445
- if (!kfInPaste && isStandaloneBackspace(data) && !(activeRl?.line ?? "")) return;
1446
- let out = "";
1447
- let i = 0;
1448
- while (i < data.length) {
1449
- if (!kfInPaste && data.startsWith(PASTE_START, i)) { kfInPaste = true; out += PASTE_START; i += PASTE_START.length; continue; }
1450
- if (kfInPaste && data.startsWith(PASTE_END, i)) { kfInPaste = false; out += PASTE_END; i += PASTE_END.length; continue; }
1451
- if (kfInPaste) {
1452
- if (data.startsWith("\r\n", i)) { out += SENTINEL; i += 2; continue; }
1453
- if (data[i] === "\n" || data[i] === "\r") { out += SENTINEL; i += 1; continue; }
1454
- out += data[i]; i += 1; continue;
1455
- }
1456
- // Swallow MOUSE-REPORT sequences (tmux `mouse on` from --tmux, or a stale pane):
1457
- // their payload bytes would otherwise be typed into the prompt. Never in a paste.
1458
- const mouse = matchMouseReport(data, i);
1459
- if (mouse > 0) { i += mouse; continue; }
1460
- // Swallow TERMINAL CAPABILITY-RESPONSE sequences (DA1/DA2/XTVERSION/OSC replies):
1461
- // tmux probes the outer terminal on attach and its answers (`62;4;9;22c…>|xterm.js…`)
1462
- // can land on stdin; without this they spray into the prompt as typed text.
1463
- const report = matchTerminalReport(data, i);
1464
- if (report > 0) { i += report; continue; }
1465
- let matched = false;
1466
- for (const seq of SHIFT_ENTER_SEQS) {
1467
- if (data.startsWith(seq, i)) { out += SENTINEL; i += seq.length; matched = true; break; }
1468
- }
1469
- if (matched) continue;
1470
- // Normalize macOS/fixterms combo cursor keys readline ignores (Option/Cmd+arrow,
1471
- // Option/Cmd+Backspace) into the canonical control bytes it DOES act on.
1472
- const combo = matchCursorCombo(data, i);
1473
- if (combo) { out += combo[1]; i += combo[0].length; continue; }
1474
- // Up/Down move the caret BETWEEN the box's visual rows (textarea feel) for any
1475
- // MULTI-ROW draft — explicit Shift+Enter breaks (→ SENTINEL) OR a long line the
1476
- // box soft-wraps. The decision is boundary-aware: an in-box visual row exists →
1477
- // move the caret there. With NO row to move to (↑ on the TOP row, ↓ on the BOTTOM
1478
- // row), a SOFT-WRAPPED one-liner falls through to readline to recall input history
1479
- // (the dominant REPL expectation), but a GENUINE multi-line draft SWALLOWS the key
1480
- // instead — falling through there would let readline's history nav WIPE the
1481
- // multi-line message being composed (the "↓ cuts the lower text" bug). Skipped
1482
- // entirely when a slash list or Ctrl+O history panel owns ↑/↓.
1483
-
1484
- if ((data.startsWith("\u001b[", i) || data.startsWith("\u001bO", i)) && (data[i + 2] === "A" || data[i + 2] === "B")) {
1485
- const dir = data[i + 2] === "A" ? "up" : "down";
1486
- const line = activeRl?.line ?? "";
1487
- if (shouldBoxVerticalNav(line, { slashMatchCount: navMatches.length, historyPanelOpen: promptHistoryLines != null }) && activeRl) {
1488
- const winCols = Math.max(24, (process.stdout.columns ?? 80) - 1);
1489
- const textWidth = Math.max(1, Math.max(24, winCols) - 6);
1490
- const cur = typeof activeRl.cursor === "number" ? activeRl.cursor : line.length;
1491
- const action = boxVerticalNavAction(expandSentinel(line), line, cur, textWidth, dir);
1492
- if (action.kind === "move") { activeRl.cursor = action.cursor; i += 3; continue; }
1493
- if (action.kind === "swallow") { i += 3; continue; } // keep the multi-line draft intact
1494
- }
1495
- out += data.slice(i, i + 3); i += 3; continue;
1496
- }
1497
- if (loneLfShiftEnter && data[i] === "\n") { out += SENTINEL; i += 1; continue; } // lone LF = Shift+Enter (opt-in)
1498
- // Ctrl+L (form feed): consumed as the prompt "redraw / re-anchor" hotkey (handled on
1499
- // the process.stdin 'keypress' listener), so it must never reach readline as a literal
1500
- // char that would otherwise insert garbage / desync the box from rl.line.
1501
- if (data[i] === "\u000c") { i += 1; continue; }
1502
- out += data[i]; i += 1;
1503
- }
1504
- kf.write(out);
1444
+ const result = filterPromptInputChunk(
1445
+ data,
1446
+ activeRl ? (activeRl as unknown as { line: string; cursor: number }) : null,
1447
+ {
1448
+ loneLfShiftEnter,
1449
+ slashMatchCount: navMatches.length,
1450
+ historyPanelOpen: promptHistoryLines != null,
1451
+ columns: process.stdout.columns ?? 80,
1452
+ },
1453
+ kfState,
1454
+ );
1455
+ if (result.drop) return;
1456
+ kf.write(result.out);
1505
1457
  };
1506
1458
  process.stdin.on("data", kfDataHandler);
1507
1459
  promptListenerCleanups.push(() => process.stdin.off("data", kfDataHandler));
@@ -2,7 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import { readWorkflowState, writeWorkflowState, getLocalJeoDir, type WorkflowState } from "../agent/state";
4
4
  import { bashTool } from "../agent/tools";
5
- import { parseSeedAcceptanceCriteria } from "../agent/seed";
5
+ import { parseSeedAcceptanceCriteria, parseCriterion } from "../agent/seed";
6
6
 
7
7
  export interface UltragoalEngineOptions {
8
8
  cwd?: string;
@@ -80,13 +80,16 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
80
80
 
81
81
  log(`Loaded ${criteria.length} acceptance criteria for verification.\n`);
82
82
 
83
- // Round-7 #2 (architect ref 7-Round7Workflow): the previous per-criterion loop
84
- // was verification THEATER — every criterion ran the same global `bun test`
85
- // (or a guaranteed-green `--help` when the text mentioned run/cli) and a
86
- // fabricated per-criterion ✅/❌ matrix was written to the ledger. Honest
87
- // contract: run the suite ONCE as a global signal; individual criteria are
88
- // UNVERIFIED unless individually proven. SUCCESS is not claimable from a
89
- // signal that cannot fail for the cases it pretends to cover.
83
+ // Round-7 #2 (architect ref 7-Round7Workflow) + per-criterion verification:
84
+ // The suite still runs ONCE as a global signal — a green suite NEVER fabricates a
85
+ // per-criterion PASS (the old "run/cli → guaranteed-green --help" theater is gone).
86
+ // What's new: a criterion authored with a trailing `{verify: <command>}` directive
87
+ // is INDIVIDUALLY verifiable — ultragoal runs that command and records a real
88
+ // PASS/FAIL. Criteria without a directive stay UNVERIFIED on green (we never
89
+ // specifically proved them): honest by default, strengthenable on demand.
90
+ const parsed = criteria.map(parseCriterion);
91
+ const verifiableCount = parsed.filter(c => c.verify).length;
92
+
90
93
  log(`[CHECK] Running the verification suite once ('bun test') — a global signal, not per-criterion proof.`);
91
94
  if (opts.onProgress) {
92
95
  opts.onProgress({ skill: "ultragoal", phase: "verifying", detail: "Running verification suite" });
@@ -94,11 +97,33 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
94
97
  const suite = await bashTool("bun test", cwd);
95
98
  log(` └─ Suite: ${suite.success ? "GREEN" : "FAILED"}`);
96
99
 
97
- const results: { criterion: string; status: "unverified" | "failed"; note: string }[] = criteria.map(criterion =>
98
- suite.success
99
- ? { criterion, status: "unverified", note: "suite green — criterion not individually verified" }
100
- : { criterion, status: "failed", note: "verification suite failed" },
101
- );
100
+ if (opts.signal?.aborted) {
101
+ return { ok: false, reason: "aborted" };
102
+ }
103
+
104
+ // Per-criterion checks: only a criterion carrying a {verify:...} directive runs an
105
+ // individual command; everything else inherits the global suite signal.
106
+ const results: { criterion: string; status: "passed" | "unverified" | "failed"; note: string }[] = [];
107
+ for (const c of parsed) {
108
+ if (c.verify) {
109
+ if (opts.signal?.aborted) return { ok: false, reason: "aborted" };
110
+ if (opts.onProgress) {
111
+ opts.onProgress({ skill: "ultragoal", phase: "verifying", detail: `Verifying: ${c.text}` });
112
+ }
113
+ log(`[CHECK] ${c.text}\n └─ $ ${c.verify}`);
114
+ const check = await bashTool(c.verify, cwd);
115
+ log(` ${check.success ? "PASS" : "FAIL"}`);
116
+ results.push(
117
+ check.success
118
+ ? { criterion: c.text, status: "passed", note: `verified by \`${c.verify}\`` }
119
+ : { criterion: c.text, status: "failed", note: `\`${c.verify}\` exited non-zero` },
120
+ );
121
+ } else if (suite.success) {
122
+ results.push({ criterion: c.text, status: "unverified", note: "suite green — criterion not individually verified (add {verify: <cmd>})" });
123
+ } else {
124
+ results.push({ criterion: c.text, status: "failed", note: "verification suite failed" });
125
+ }
126
+ }
102
127
 
103
128
  if (opts.signal?.aborted) {
104
129
  return { ok: false, reason: "aborted" };
@@ -110,18 +135,37 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
110
135
  const reportPath = path.join(reportDir, "ultragoal-report.md");
111
136
 
112
137
  const totalCount = results.length;
113
- const status = suite.success ? "SUITE_GREEN" : "FAILED";
138
+ const passedCount = results.filter(r => r.status === "passed").length;
139
+ const failedCount = results.filter(r => r.status === "failed").length;
140
+ const unverifiedCount = results.filter(r => r.status === "unverified").length;
141
+
142
+ // Overall status taxonomy:
143
+ // FAILED — the global suite is red, OR any individually-checked criterion failed.
144
+ // SUCCESS — every criterion was individually verified and passed (no UNVERIFIED left).
145
+ // PARTIAL — some criteria individually passed, but others remain UNVERIFIED.
146
+ // SUITE_GREEN — suite green but NO criterion was individually verified (legacy honest default).
147
+ let status: string;
148
+ if (!suite.success || failedCount > 0) {
149
+ status = "FAILED";
150
+ } else if (passedCount > 0 && unverifiedCount === 0) {
151
+ status = "SUCCESS";
152
+ } else if (passedCount > 0) {
153
+ status = "PARTIAL";
154
+ } else {
155
+ status = "SUITE_GREEN";
156
+ }
114
157
 
115
158
  const reportContent =
116
159
  `# Ultragoal Verification Report: ${interviewState.slug}\n` +
117
160
  `Date: ${new Date().toISOString()}\n` +
118
- `Status: ${status} — suite ${suite.success ? "green" : "FAILED"}; ${totalCount} acceptance criteria recorded (not individually verified)\n` +
161
+ `Status: ${status} — suite ${suite.success ? "green" : "FAILED"}; ` +
162
+ `${totalCount} criteria (${passedCount} verified, ${unverifiedCount} unverified, ${failedCount} failed)\n` +
119
163
  `Plan: ${teamState?.plan_path ?? "(team not run)"}\n` +
120
164
  `Execution: ${teamState?.current_phase === "complete" ? "team complete" : "team NOT complete — verified current disk state"}\n\n` +
121
165
  `## Criteria Record\n` +
122
166
  `| Criterion | Status | Note |\n` +
123
167
  `|---|---|---|\n` +
124
- results.map(r => `| ${r.criterion} | ${r.status === "failed" ? "❌ FAILED" : "⚠️ UNVERIFIED"} | ${r.note} |`).join("\n") +
168
+ results.map(r => `| ${r.criterion} | ${r.status === "passed" ? "✅ PASSED" : r.status === "failed" ? "❌ FAILED" : "⚠️ UNVERIFIED"} | ${r.note} |`).join("\n") +
125
169
  `\n`;
126
170
 
127
171
  // Atomic temp+rename (zeroclaw): a torn report must not disagree with the state JSON.
@@ -144,18 +188,24 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
144
188
  plan_path: teamState?.plan_path,
145
189
  status,
146
190
  suite_green: suite.success,
191
+ passed: passedCount,
147
192
  total: totalCount,
148
193
  };
149
194
  await writeWorkflowState("ultragoal", ultragoalState, cwd);
150
195
 
151
196
  log(`\n[VERIFICATION COMPLETE] Report saved to: ${reportPath}`);
152
- log(`Overall status: ${status} — ${totalCount} criteria recorded; none individually verified (add per-criterion checks for stronger claims).`);
197
+ log(
198
+ verifiableCount > 0
199
+ ? `Overall status: ${status} — ${passedCount}/${totalCount} criteria individually verified, ${unverifiedCount} unverified, ${failedCount} failed.`
200
+ : `Overall status: ${status} — ${totalCount} criteria recorded; none individually verified (add {verify: <cmd>} to a criterion for a real per-criterion check).`,
201
+ );
202
+
153
203
 
154
204
  if (opts.onProgress) {
155
205
  opts.onProgress({ skill: "ultragoal", phase: "complete" });
156
206
  }
157
207
 
158
- return { ok: suite.success };
208
+ return { ok: status !== "FAILED" };
159
209
  }
160
210
 
161
211
  export async function runUltragoalCommand(): Promise<void> {