jeo-code 0.7.15 → 0.7.17

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
@@ -7,6 +7,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
9
 
10
+ ## [0.7.17] - 2026-06-25
11
+ _Developer workflow parity (gjc `dev:link`/`dev:doctor`, adapted for jeo's zero-native-dep Bun runtime): the global `jeo` command can be linked to run this checkout's source hot to every edit, with a drift doctor that flags when `jeo` resolves to a compiled binary or an installed copy instead. README gains "Skill migration and bundled skill inspection" + "Development" sections. Also ships OKF concept-memory search/scoring with budget-aware injection and a round of workflow-prompt hardening (anti-punt, todo-first planning, verdict discipline) that keeps every loop escape hatch intact._
12
+
13
+ ### Added
14
+ - **`bun run dev:link` / `dev:doctor` run the global `jeo` from this checkout's source.** New `scripts/dev-link.ts`: `link` symlinks `jeo` → `<repo>/src/cli.ts` into `~/.local/bin` (override `JEO_DEV_LINK_DIR`), refuses to proceed when another `jeo` shadows the managed link earlier on `PATH`, ensures the `#!/usr/bin/env bun` entry stays executable, and runs a `--version` smoke test; `doctor` classifies whatever `jeo` the current `PATH` resolves to as `linked` (this hot source), `drift` (a compiled binary or npm-installed copy — exit 1), or `missing`. Unlike gjc there is no `build:native` prerequisite — jeo has zero native deps, so a bare symlink to the shebang entry is fully functional. Pure helpers (`findShadowingJeo`, `classifyDevDoctor`, `splitPathEntries`, `defaultLinkDir`, `resolveJeoOnPath`) back the `dev:link`/`dev:doctor` `package.json` scripts.
15
+ - **OKF concept memory gains in-memory search, scoring, and budget-aware injection.** `src/agent/memory.ts` adds `loadConcepts`/`scoreConcept`/`searchConcepts` over the OKF concept bundle (`.jeo/memory`), `index.md` progressive disclosure, and a relevance-selected `memoryPromptSection` that fills the `MEMORY_INJECT_MAX_CHARS` budget by picking whole high-scoring concepts instead of mid-string truncation, plus `recordFailedAttempt` for failure-aware recall (docs/okf_mem sprint-03 Search & Reference).
16
+
17
+ ### Changed
18
+ - **README documents skill migration/inspection and the dev workflow.** New "Skill migration and bundled skill inspection" section walks `jeo skills list` / `read <name>` / `sync --check` / `sync --force`, and a new "Development" section covers `dev:link`/`dev:doctor` and running from source — bringing the gajae-code README's targeted sections to jeo.
19
+ - **Workflow prompts (engine, goal-verifier, deep-interview, critic, architect) tightened against punting and false blocks.** The turn protocol now asks a multi-edit batch to name each change in its `reasoning` and a multi-file task to sketch a todo plan before the first edit, the `web_search` reflex re-searches when sources conflict, and OUTPUT_DISCIPLINE forbids punting with only a disclaimer/cutoff caveat/offer-to-search. The Goal Verifier judges the goal as written with positive per-requirement evidence (MET when satisfied, IMPOSSIBLE only when genuinely unsatisfiable); deep-interview stops filling ambiguity with assumptions yet still lowers its score when dimensions truly resolve; the critic treats softening as signal but never manufactures blocks; the architect notes a clean verdict ≠ no inspection and adds an `Inspected:` line. All escape hatches (single-use done latches, `MAX_RE_BLOCKS`, step-budget hard cap) are unchanged, so no new loop risk.
20
+
21
+ ### Fixed
22
+ - **Attaching an image no longer pushes the input caret several columns past the `[image #N]` tag.** Terminals pad a dragged-and-dropped image path with their own surrounding spaces, and the Ctrl+V clipboard insert added its own separator, so the swapped-in tag ended up flanked by multiple spaces and the caret parked well past it (the "spacebar ×3" feel). The two attach paths now share one tested helper in `src/util/file-attachment.ts`: `normalizeImageTags` collapses the whitespace around every `[image #N]` to exactly one space (trimming line edges, idempotent), `insertImageTag` (Ctrl+V) inserts the tag single-spaced with the caret right after it, and `attachImagePaths` (drag-drop / submit-time) normalizes its output and returns the precise post-tag `cursor`. The live REPL parks the box caret at that offset, so it sits immediately after the tag's single trailing space — ready for the prompt.
23
+
24
+ ### Verified
25
+ - **"Works beside your existing agent or bot" now reflects jeo's real interop surface (all four READMEs).** The table documents `--worktree <name>` (run in an isolated sibling git worktree — `src/commands/launch/tmux.ts` `resolveWorktree`) for the Codex/Claude/Claw rows and `jeo mcp serve` / `jeo mcp tools` (the MCP stdio tool contract — `src/commands/mcp.ts`) for the external-controller/bot row, instead of the prior generic "standard terminal/CLI interfaces" wording.
26
+ - `bun run typecheck` clean; full suite green — 1991 pass / 0 fail across 234 files — including the new `test/dev-link.test.ts` (target/link-dir resolution incl. `JEO_DEV_LINK_DIR` override, PATH splitting, earlier-on-PATH shadow detection, and doctor `linked`/`drift`/`missing` classification), the new `test/memory-search-okf.test.ts` (concept load/score/search, `index.md` disclosure, budget-aware relevance injection, failed-attempt recall), expanded `test/cycle-and-turn-budget.test.ts`, and expanded `test/file-attachment.test.ts` + `test/input-box.test.ts` (whitespace normalization around `[image #N]`, idempotence, `insertImageTag`/`caretAfterTag`/`attachImagePaths` caret offsets, and the end-to-end render proving the caret column lands right after the rendered tag). Real CLI runs verified `dev:link`/`dev:doctor` end-to-end against a temp link dir (symlink + `--version` smoke test, shadow-guard refusal, drift detection, `linked` after linking) and the macOS clipboard image reader end-to-end (`readClipboardImage` decoded a clipboard PNG via the `osascript` fallback).
27
+
28
+
29
+ ## [0.7.16] - 2026-06-25
30
+ _Bundled skills get a safe installer: `jeo skills sync` materializes the built-in workflow skills into `~/.jeo/skills`, preserving local edits by default, with a CI-friendly `--check` drift report and a `--force` overwrite._
31
+
32
+ ### Added
33
+ - **`jeo skills sync` installs and reconciles the bundled workflow skills into `~/.jeo/skills`.** New `syncBundledSkills(dir, { check, force })` (`src/commands/skills.ts`) reconciles the built-in `SKILLS` against a target dir — defaulting to `userSkillsDir()` (`~/.jeo/skills`, honoring `JEO_CONFIG_DIR`/`$HOME`, matching the highest-precedence flat dir in `skillDirs`). Missing skills are installed; a differing local copy is **preserved** by default (gjc `setup defaults` parity) unless `--force` overwrites it. `--check` is a pure drift report that writes nothing and sets a non-zero exit code on drift, so CI can gate on bundled-skill staleness. `--json` emits the structured `SkillSyncResult` (per-skill `status`/`action`, `drift`, `wrote`, `mode`). A new `bundledSkillFileContent` helper (`src/skills/catalog.ts`) defines the canonical on-disk bytes (raw `SKILL.md` when present, decorated render otherwise) shared by `sync` and the existing `--write` path. The `skills` command summary/usage (`src/cli/runner.ts`) and the list footer now advertise the subcommand.
34
+
35
+ ### Verified
36
+ - `bun run typecheck` clean; full suite green — 1960 pass / 0 fail across 233 files — including the new `test/skills-sync.test.ts` (fresh install, idempotent re-run, `--check` drift report without writing, default-preserve vs `--force` overwrite, single-file reinstall, non-zero `--check` exit via `JEO_CONFIG_DIR`, and `--json` shape).
37
+
38
+
10
39
  ## [0.7.15] - 2026-06-25
11
40
  _Rollback release: the runtime source and test suite are restored to the **0.7.9** state. The intervening 0.7.10–0.7.14 runtime changes (quiet-mode/`-q`, `/resume` transcript rework, `mutatedFiles` plumbing, Ollama `num_ctx`, tmux REPL reaping, and related engine/provider/TUI edits) are reverted. Published as a new version because npm cannot re-issue an existing version number; `latest` now serves code identical to 0.7.9._
12
41
 
package/README.ja.md CHANGED
@@ -153,13 +153,13 @@ jeo ultragoal
153
153
 
154
154
  | ツールまたはボット | 推奨される jeo コマンド | 境界 |
155
155
  | ----------- | ----------------------- | -------- |
156
- | Codex CLI | `jeo --tmux` または `jeo` | 兄弟 tmux セッションまたはインラインで実行されます。 |
157
- | Claude Code | `jeo --tmux` または `jeo` | jeo は Claude Code の拡張機能にはなりません。 |
156
+ | Codex CLI | `jeo --tmux --worktree <name>` または `jeo` | `--worktree` は jeo が管理する兄弟 git worktree を指定します(basename → 新しいブランチ)。既存のパスは先に `cd` してください。 |
157
+ | Claude Code | `jeo --tmux` または `jeo --tmux --worktree <name>` | jeo は Claude Code の拡張機能にはなりません。 |
158
158
  | OpenCode | `jeo` または `jeo --tmux` | 外部ランナーのワークフローのみ。 |
159
- | Claw Code | `jeo --tmux` | jeo は Claw Code にインストールされたり、置き換えたりしません。 |
160
- | 外部コントローラー / ボット | `jeo` (またはカスタム統合) | 外部コントローラーは、標準のターミナル/CLI インターフェースを介して jeo を駆動します。 |
159
+ | Claw Code | `jeo --tmux --worktree <name>` | jeo は Claw Code にインストールされたり、置き換えたりしません。 |
160
+ | 外部コントローラー / ボット | `jeo mcp serve` (MCP stdio サーバー) | 外部コントローラーはスクロールバックのスクレイピングではなく、MCP ツール契約を介して jeo を駆動します。 |
161
161
 
162
- `-q`/`--quiet` (または `JEO_QUIET=1`) を追加すると、起動バナー・ウェルカムアニメーション・リリースノート・再開ヒントが抑制され、jeo を別のエージェントと並べて実行したりボットから駆動したりできます。`-p`/`--print` は quiet を含みます。
162
+ `--worktree <name>` は隔離された兄弟 git worktree で jeo を実行するため(パスがあれば再利用、なければ basename ブランチで作成)、リスクのある作業やレビュー対象の作業がメインのチェックアウトに触れることはありません。`jeo mcp serve` は stdio を介して MCP 対応のあらゆるコントローラーに jeo のツールを公開します(`jeo mcp tools` で一覧表示)。`-q`/`--quiet` (または `JEO_QUIET=1`) を追加すると、起動バナー・ウェルカムアニメーション・リリースノート・再開ヒントが抑制され、jeo を別のエージェントと並べて実行したりボットから駆動したりできます。`-p`/`--print` は quiet を含みます。
163
163
 
164
164
 
165
165
  ## ローカルモデル
@@ -205,11 +205,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
205
205
  ## 変更履歴 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.17]** (2026-06-25) — Developer workflow parity (gjc `dev:link`/`dev:doctor`, adapted for jeo's zero-native-dep Bun runtime): the global `jeo` command can be linked to run this checkout's source hot to every edit, with a drift doctor that flags when `jeo` resolves to a compiled binary or an installed copy instead. README gains "Skill migration and bundled skill inspection" + "Development" sections. Also ships OKF concept-memory search/scoring with budget-aware injection and a round of workflow-prompt hardening (anti-punt, todo-first planning, verdict discipline) that keeps every loop escape hatch intact.
209
+ - **[0.7.16]** (2026-06-25) — Bundled skills get a safe installer: `jeo skills sync` materializes the built-in workflow skills into `~/.jeo/skills`, preserving local edits by default, with a CI-friendly `--check` drift report and a `--force` overwrite.
208
210
  - **[0.7.15]** (2026-06-25) — Rollback release: the runtime source and test suite are restored to the **0.7.9** state. The intervening 0.7.10–0.7.14 runtime changes (quiet-mode/`-q`, `/resume` transcript rework, `mutatedFiles` plumbing, Ollama `num_ctx`, tmux REPL reaping, and related engine/provider/TUI edits) are reverted. Published as a new version because npm cannot re-issue an existing version number; `latest` now serves code identical to 0.7.9.
209
211
  - **[0.7.14]** (2026-06-25) — Quieter, cleaner output: a new `-q`/`--quiet` flag strips launch banners and courtesy logs, `/resume` no longer dumps walls of raw escaped JSON into scrollback, and a turn that edits files now reliably surfaces those paths to orchestrators.
210
212
  - **[0.7.9]** (2026-06-24) — Local model providers actually run, and OAuth/login hygiene: Ollama requests now carry an explicit `num_ctx` so jeo's large system prompt no longer overflows Ollama's small default window (the "ollama 모델이 안 돌아간다" blocker), LM Studio reasoning models that emit the whole reply on the reasoning channel are recovered instead of dying empty, GGUF chat templates that can't render the `tools` array are retried with native tools stripped, the OAuth success tab auto-closes with a clear hint, the effective-credential picker prefers a verified OAuth login over a stray API key, and abandoned idle `jeo` tmux REPL sessions are reaped on launch.
211
- - **[0.7.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
212
- - **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -153,13 +153,13 @@ jeo ultragoal
153
153
 
154
154
  | 도구 또는 봇 | 권장 jeo 명령 | 경계 |
155
155
  | ----------- | ----------------------- | -------- |
156
- | Codex CLI | `jeo --tmux` 또는 `jeo` | 형제 tmux 세션 또는 인라인으로 실행됩니다. |
157
- | Claude Code | `jeo --tmux` 또는 `jeo` | jeo는 Claude Code 확장 프로그램이 되지 않습니다. |
156
+ | Codex CLI | `jeo --tmux --worktree <name>` 또는 `jeo` | `--worktree`는 jeo가 관리하는 형제 git worktree를 지정합니다(basename → 새 브랜치). 기존 경로는 먼저 `cd`로 이동하세요. |
157
+ | Claude Code | `jeo --tmux` 또는 `jeo --tmux --worktree <name>` | jeo는 Claude Code 확장 프로그램이 되지 않습니다. |
158
158
  | OpenCode | `jeo` 또는 `jeo --tmux` | 현재는 외부 러너 워크플로만 지원합니다. |
159
- | Claw Code | `jeo --tmux` | jeo는 Claw Code에 설치되거나 대체되지 않습니다. |
160
- | 외부 컨트롤러 / 봇 | `jeo` (또는 사용자 정의 통합) | 외부 컨트롤러는 표준 터미널/CLI 인터페이스를 통해 jeo를 구동합니다. |
159
+ | Claw Code | `jeo --tmux --worktree <name>` | jeo는 Claw Code에 설치되거나 대체되지 않습니다. |
160
+ | 외부 컨트롤러 / 봇 | `jeo mcp serve` (MCP stdio 서버) | 외부 컨트롤러는 스크롤백 스크래핑이 아니라 MCP 도구 계약을 통해 jeo를 구동합니다. |
161
161
 
162
- `-q`/`--quiet` (또는 `JEO_QUIET=1`)를 추가하면 시작 배너, 환영 애니메이션, 릴리스 노트, 재개 힌트가 억제되어 jeo를 다른 에이전트와 나란히 실행하거나 봇으로 구동할 수 있습니다. `-p`/`--print`는 quiet를 함의합니다.
162
+ `--worktree <name>`는 격리된 형제 git worktree에서 jeo를 실행하므로(경로가 있으면 재사용, 없으면 basename 브랜치로 생성) 위험하거나 검토가 필요한 작업이 메인 체크아웃을 건드리지 않습니다. `jeo mcp serve`는 stdio를 통해 MCP를 지원하는 모든 컨트롤러에 jeo의 도구를 노출합니다(`jeo mcp tools`로 목록 확인). `-q`/`--quiet` (또는 `JEO_QUIET=1`)를 추가하면 시작 배너, 환영 애니메이션, 릴리스 노트, 재개 힌트가 억제되어 jeo를 다른 에이전트와 나란히 실행하거나 봇으로 구동할 수 있습니다. `-p`/`--print`는 quiet를 함의합니다.
163
163
 
164
164
 
165
165
  ## 로컬 모델
@@ -205,11 +205,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
205
205
  ## 변경 이력 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.17]** (2026-06-25) — Developer workflow parity (gjc `dev:link`/`dev:doctor`, adapted for jeo's zero-native-dep Bun runtime): the global `jeo` command can be linked to run this checkout's source hot to every edit, with a drift doctor that flags when `jeo` resolves to a compiled binary or an installed copy instead. README gains "Skill migration and bundled skill inspection" + "Development" sections. Also ships OKF concept-memory search/scoring with budget-aware injection and a round of workflow-prompt hardening (anti-punt, todo-first planning, verdict discipline) that keeps every loop escape hatch intact.
209
+ - **[0.7.16]** (2026-06-25) — Bundled skills get a safe installer: `jeo skills sync` materializes the built-in workflow skills into `~/.jeo/skills`, preserving local edits by default, with a CI-friendly `--check` drift report and a `--force` overwrite.
208
210
  - **[0.7.15]** (2026-06-25) — Rollback release: the runtime source and test suite are restored to the **0.7.9** state. The intervening 0.7.10–0.7.14 runtime changes (quiet-mode/`-q`, `/resume` transcript rework, `mutatedFiles` plumbing, Ollama `num_ctx`, tmux REPL reaping, and related engine/provider/TUI edits) are reverted. Published as a new version because npm cannot re-issue an existing version number; `latest` now serves code identical to 0.7.9.
209
211
  - **[0.7.14]** (2026-06-25) — Quieter, cleaner output: a new `-q`/`--quiet` flag strips launch banners and courtesy logs, `/resume` no longer dumps walls of raw escaped JSON into scrollback, and a turn that edits files now reliably surfaces those paths to orchestrators.
210
212
  - **[0.7.9]** (2026-06-24) — Local model providers actually run, and OAuth/login hygiene: Ollama requests now carry an explicit `num_ctx` so jeo's large system prompt no longer overflows Ollama's small default window (the "ollama 모델이 안 돌아간다" blocker), LM Studio reasoning models that emit the whole reply on the reasoning channel are recovered instead of dying empty, GGUF chat templates that can't render the `tools` array are retried with native tools stripped, the OAuth success tab auto-closes with a clear hint, the effective-credential picker prefers a verified OAuth login over a stray API key, and abandoned idle `jeo` tmux REPL sessions are reaped on launch.
211
- - **[0.7.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
212
- - **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -154,13 +154,13 @@ Non-zero hook output is appended to the tool result the model reads (deduped per
154
154
 
155
155
  | Tool or bot | Recommended jeo command | Boundary |
156
156
  | ----------- | ----------------------- | -------- |
157
- | Codex CLI | `jeo --tmux` or `jeo` | Runs in a sibling tmux session or inline. |
158
- | Claude Code | `jeo --tmux` or `jeo` | jeo does not become a Claude Code extension. |
157
+ | Codex CLI | `jeo --tmux --worktree <name>` or `jeo` | `--worktree` names a jeo-managed sibling git worktree (basename → new branch); for an existing path, `cd` there first. |
158
+ | Claude Code | `jeo --tmux` or `jeo --tmux --worktree <name>` | jeo does not become a Claude Code extension. |
159
159
  | OpenCode | `jeo` or `jeo --tmux` | External-runner workflow only. |
160
- | Claw Code | `jeo --tmux` | jeo does not install into or replace Claw Code. |
161
- | External controller / bot | `jeo` (or custom integrations) | External controllers drive jeo through standard terminal/CLI interfaces. |
160
+ | Claw Code | `jeo --tmux --worktree <name>` | jeo does not install into or replace Claw Code. |
161
+ | External controller / bot | `jeo mcp serve` (MCP stdio server) | External controllers drive jeo over the MCP tool contract, not scrollback scraping. |
162
162
 
163
- Add `-q`/`--quiet` (or `JEO_QUIET=1`) to suppress startup banners, welcome animation, release notes, and resume hints so jeo can run cleanly beside another agent or be driven by a bot. `-p`/`--print` implies quiet.
163
+ `--worktree <name>` runs jeo in an isolated sibling git worktree (reused if the path exists, else created on a branch named after the basename) so risky or reviewable work never touches your main checkout. `jeo mcp serve` exposes jeo's tools to any MCP-capable controller over stdio (`jeo mcp tools` lists them). Add `-q`/`--quiet` (or `JEO_QUIET=1`) to suppress startup banners, the welcome animation, release notes, and resume hints so jeo runs cleanly beside another agent or is driven by a bot — `-p`/`--print` implies quiet.
164
164
 
165
165
 
166
166
  ## Local models
@@ -191,8 +191,33 @@ JEO_TOOL_OUTPUT_MAX=4000 # model-visible tool output cap (full output spi
191
191
 
192
192
  Retry behavior is tunable via `retry` in `~/.jeo/config.json` (`requestMaxRetries`, `streamMaxRetries`, `rateLimitRetries`, `failFastStatuses`, …). The step budget is dynamic by default — it extends while recent tool calls show novel progress and consolidates with a wrap-up when stalled; `--max-steps N` restores a bounded flow.
193
193
 
194
+ ## Skill migration and bundled skill inspection
195
+
196
+ When moving a workflow into jeo, inspect the bundled defaults before installing or overwriting anything:
197
+
198
+ ```bash
199
+ jeo skills list # bundled + user + project skills, with discovery dirs
200
+ jeo skills read ralplan # print one skill's full SKILL.md
201
+ jeo skills sync --check # report drift vs ~/.jeo/skills (non-zero exit on drift)
202
+ ```
203
+
204
+ `jeo skills sync` installs the bundled workflow skills (deep-interview, deep-dive, ralplan, team, ultragoal) into `~/.jeo/skills` and **preserves existing local files by default** — a differing local copy is reported as `preserved`, never clobbered. If `--check` flags a missing or different file, compare it with `jeo skills read <name>` first; use `jeo skills sync --force` only when you intentionally want to replace local default workflow skill files. Target a different dir with a trailing path argument (or `JEO_CONFIG_DIR`), and add `--json` for the structured `SkillSyncResult`.
205
+
206
+ ## Development
207
+
208
+ jeo is pure TypeScript on Bun with **zero native dependencies**, so the global `jeo` command can run this checkout's source directly — no build step, hot to every edit.
209
+
210
+ ```bash
211
+ bun install
212
+ bun run dev:link # symlink `jeo` -> <repo>/src/cli.ts into ~/.local/bin
213
+ bun run dev:doctor # report whether global `jeo` runs this source (linked/drift/missing)
214
+ ```
215
+
216
+ `dev:link` refuses to proceed if another `jeo` shadows the managed link earlier on `PATH` (override the destination with `JEO_DEV_LINK_DIR`) and runs a `--version` smoke test. `dev:doctor` exits non-zero when the resolved `jeo` is a compiled binary or an installed copy rather than this source. Run from source without linking via `bun src/cli.ts --help`. Bundled workflow skills live in source at `src/prompts/skills/<name>/SKILL.md`; verify with `bun run typecheck` and `bun test`.
217
+
194
218
  ## Publishing
195
219
 
220
+
196
221
  CI publishes via `.github/workflows/npm-publish.yml` — triggered by a published GitHub release, or manually with `workflow_dispatch` (optional dry-run). The workflow typechecks, tests, verifies the token (`npm whoami`), then runs `npm publish --provenance`.
197
222
 
198
223
  Required npm token permissions (repository secret `NPM_TOKEN`):
@@ -207,11 +232,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
207
232
  ## Changelog
208
233
 
209
234
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
235
+ - **[0.7.17]** (2026-06-25) — Developer workflow parity (gjc `dev:link`/`dev:doctor`, adapted for jeo's zero-native-dep Bun runtime): the global `jeo` command can be linked to run this checkout's source hot to every edit, with a drift doctor that flags when `jeo` resolves to a compiled binary or an installed copy instead. README gains "Skill migration and bundled skill inspection" + "Development" sections. Also ships OKF concept-memory search/scoring with budget-aware injection and a round of workflow-prompt hardening (anti-punt, todo-first planning, verdict discipline) that keeps every loop escape hatch intact.
236
+ - **[0.7.16]** (2026-06-25) — Bundled skills get a safe installer: `jeo skills sync` materializes the built-in workflow skills into `~/.jeo/skills`, preserving local edits by default, with a CI-friendly `--check` drift report and a `--force` overwrite.
210
237
  - **[0.7.15]** (2026-06-25) — Rollback release: the runtime source and test suite are restored to the **0.7.9** state. The intervening 0.7.10–0.7.14 runtime changes (quiet-mode/`-q`, `/resume` transcript rework, `mutatedFiles` plumbing, Ollama `num_ctx`, tmux REPL reaping, and related engine/provider/TUI edits) are reverted. Published as a new version because npm cannot re-issue an existing version number; `latest` now serves code identical to 0.7.9.
211
238
  - **[0.7.14]** (2026-06-25) — Quieter, cleaner output: a new `-q`/`--quiet` flag strips launch banners and courtesy logs, `/resume` no longer dumps walls of raw escaped JSON into scrollback, and a turn that edits files now reliably surfaces those paths to orchestrators.
212
239
  - **[0.7.9]** (2026-06-24) — Local model providers actually run, and OAuth/login hygiene: Ollama requests now carry an explicit `num_ctx` so jeo's large system prompt no longer overflows Ollama's small default window (the "ollama 모델이 안 돌아간다" blocker), LM Studio reasoning models that emit the whole reply on the reasoning channel are recovered instead of dying empty, GGUF chat templates that can't render the `tools` array are retried with native tools stripped, the OAuth success tab auto-closes with a clear hint, the effective-credential picker prefers a verified OAuth login over a stray API key, and abandoned idle `jeo` tmux REPL sessions are reaped on launch.
213
- - **[0.7.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
214
- - **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
215
240
 
216
241
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
217
242
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -153,13 +153,13 @@ jeo ultragoal
153
153
 
154
154
  | 工具或机器人 | 推荐的 jeo 命令 | 边界 |
155
155
  | ----------- | ----------------------- | -------- |
156
- | Codex CLI | `jeo --tmux` 或 `jeo` | 在同级 tmux 会话中运行或内联运行。 |
157
- | Claude Code | `jeo --tmux` 或 `jeo` | jeo 不会成为 Claude Code 的扩展。 |
156
+ | Codex CLI | `jeo --tmux --worktree <name>` 或 `jeo` | `--worktree` 指定一个由 jeo 管理的同级 git worktree(basename → 新分支);对于已有路径,请先 `cd` 进入。 |
157
+ | Claude Code | `jeo --tmux` 或 `jeo --tmux --worktree <name>` | jeo 不会成为 Claude Code 的扩展。 |
158
158
  | OpenCode | `jeo` 或 `jeo --tmux` | 仅限外部运行器工作流。 |
159
- | Claw Code | `jeo --tmux` | jeo 不会安装到或替换 Claw Code。 |
160
- | 外部控制器 / 机器人 | `jeo` (或自定义集成) | 外部控制器通过标准终端/CLI 接口驱动 jeo。 |
159
+ | Claw Code | `jeo --tmux --worktree <name>` | jeo 不会安装到或替换 Claw Code。 |
160
+ | 外部控制器 / 机器人 | `jeo mcp serve` (MCP stdio 服务器) | 外部控制器通过 MCP 工具契约驱动 jeo,而非抓取滚动回显。 |
161
161
 
162
- 添加 `-q`/`--quiet`(或 `JEO_QUIET=1`)可抑制启动横幅、欢迎动画、发布说明和恢复提示,使 jeo 能够与其他代理并行运行或由机器人驱动。`-p`/`--print` 隐含 quiet。
162
+ `--worktree <name>` 在隔离的同级 git worktree 中运行 jeo(路径存在则复用,否则以 basename 分支创建),因此有风险或需审查的工作绝不会触及您的主检出。`jeo mcp serve` 通过 stdio 向任何支持 MCP 的控制器公开 jeo 的工具(用 `jeo mcp tools` 列出)。添加 `-q`/`--quiet`(或 `JEO_QUIET=1`)可抑制启动横幅、欢迎动画、发布说明和恢复提示,使 jeo 能够与其他代理并行运行或由机器人驱动。`-p`/`--print` 隐含 quiet。
163
163
 
164
164
 
165
165
  ## 本地模型
@@ -205,11 +205,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
205
205
  ## 更新日志 (Changelog)
206
206
 
207
207
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
208
+ - **[0.7.17]** (2026-06-25) — Developer workflow parity (gjc `dev:link`/`dev:doctor`, adapted for jeo's zero-native-dep Bun runtime): the global `jeo` command can be linked to run this checkout's source hot to every edit, with a drift doctor that flags when `jeo` resolves to a compiled binary or an installed copy instead. README gains "Skill migration and bundled skill inspection" + "Development" sections. Also ships OKF concept-memory search/scoring with budget-aware injection and a round of workflow-prompt hardening (anti-punt, todo-first planning, verdict discipline) that keeps every loop escape hatch intact.
209
+ - **[0.7.16]** (2026-06-25) — Bundled skills get a safe installer: `jeo skills sync` materializes the built-in workflow skills into `~/.jeo/skills`, preserving local edits by default, with a CI-friendly `--check` drift report and a `--force` overwrite.
208
210
  - **[0.7.15]** (2026-06-25) — Rollback release: the runtime source and test suite are restored to the **0.7.9** state. The intervening 0.7.10–0.7.14 runtime changes (quiet-mode/`-q`, `/resume` transcript rework, `mutatedFiles` plumbing, Ollama `num_ctx`, tmux REPL reaping, and related engine/provider/TUI edits) are reverted. Published as a new version because npm cannot re-issue an existing version number; `latest` now serves code identical to 0.7.9.
209
211
  - **[0.7.14]** (2026-06-25) — Quieter, cleaner output: a new `-q`/`--quiet` flag strips launch banners and courtesy logs, `/resume` no longer dumps walls of raw escaped JSON into scrollback, and a turn that edits files now reliably surfaces those paths to orchestrators.
210
212
  - **[0.7.9]** (2026-06-24) — Local model providers actually run, and OAuth/login hygiene: Ollama requests now carry an explicit `num_ctx` so jeo's large system prompt no longer overflows Ollama's small default window (the "ollama 모델이 안 돌아간다" blocker), LM Studio reasoning models that emit the whole reply on the reasoning channel are recovered instead of dying empty, GGUF chat templates that can't render the `tools` array are retried with native tools stripped, the OAuth success tab auto-closes with a clear hint, the effective-credential picker prefers a verified OAuth login over a stray API key, and abandoned idle `jeo` tmux REPL sessions are reaped on launch.
211
- - **[0.7.8]** (2026-06-24) — Long-session performance & resource hygiene: the detached memory-distill worker now always terminates itself (closing the `jeo memory-distill` orphan pileup that pinned each session's transcript in RSS), the live reasoning/output block re-wraps only its trailing window (O(tail) per frame instead of O(len²) over a long stream), the team loop stops reprinting the todo guide every iteration, and the readline caret stays aligned after an image attach.
212
- - **[0.7.7]** (2026-06-23) — Long-running-process hygiene & input robustness: backgrounded grandchildren (`next dev &`, daemons) are now reaped at the turn boundary via per-command process groups — closing the climbing `next-server` RSS leak — while the prompt gains recursive fuzzy `@path` search, ANSI-safe and idle-merged bracketed paste, a configurable OSC 52 clipboard cap, per-role ralplan model routing, and leaked-reasoning-tag stripping on salvaged answers.
213
213
 
214
214
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
215
215
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.7.15",
3
+ "version": "0.7.17",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -52,7 +52,9 @@
52
52
  "publish:npm": "npm publish --access public --registry https://registry.npmjs.org/",
53
53
  "changelog:sync": "bun scripts/sync-changelog.ts",
54
54
  "test": "bun test",
55
- "verify:tmux": "bash scripts/tmux-verify.sh"
55
+ "verify:tmux": "bash scripts/tmux-verify.sh",
56
+ "dev:link": "bun scripts/dev-link.ts link",
57
+ "dev:doctor": "bun scripts/dev-link.ts doctor"
56
58
  },
57
59
  "dependencies": {
58
60
  "zod": "^3.24.1",
@@ -17,8 +17,8 @@ The core runtime loop, tool registry, session management, and state persistence
17
17
  | `hooks.ts` | Brief description of purpose |
18
18
  | `json.ts` | Brief description of purpose |
19
19
  | `loop.ts` | The primary execution loop orchestrating model calls and tool execution |
20
- | `loop-guards.ts` | Intermediate-judgment classification (gjc ultragoal-guard parity): named `GuardState` taxonomy, `GUARD_LIMITS` thresholds, and pure classifiers (`isVerificationSignal`, `repeatHint`, `classifyDoneGate`) consumed by `engine.ts` |
21
- | `memory.ts` | OKF concept-bundle memory: session distill, query-aware budget injection, legacy MEMORY.md migration (`migrateLegacyMemory`) + `JEO_MEMORY_LEGACY` rollback toggle |
20
+ | `loop-guards.ts` | Intermediate-judgment classification (gjc ultragoal-guard parity): named `GuardState` taxonomy, `GUARD_LIMITS` thresholds, and pure classifiers (`isVerificationSignal`, `repeatHint`, `classifyDoneGate` — incl. `done_stale_verification` for a passing test/build that predates the last edit) consumed by `engine.ts` |
21
+ | `memory.ts` | OKF concept-bundle memory: session distill, query-aware budget injection (failure-first `priorityOrder` + pinned-invariant reserved budget), mid-loop `recordFailedAttempt` capture, shared `upsertConceptFile` writer, legacy MEMORY.md migration (`migrateLegacyMemory`) + `JEO_MEMORY_LEGACY` rollback toggle |
22
22
  | `memory-okf.ts` | OKF v0.1 format layer: frontmatter parse/serialize, concept IDs, conformance validation |
23
23
  | `memory-graph.ts` | Concept cross-link graph: build/expand (1-hop search), broken-link-tolerant lint, optional graphify detection |
24
24
  | `model-recency.ts` | Brief description of purpose |
@@ -105,9 +105,9 @@ export const TOOL_PROTOCOL = [
105
105
  "",
106
106
  "Alternatively, you may batch up to 6 independent calls in a single turn using the following format:",
107
107
  '{ "reasoning": "<one short sentence>", "tools": [{ "tool": "<name>", "arguments": { ... } }, ...] }',
108
- "Batch only independent calls; NEVER batch 'done', and NEVER put a mutating tool (write/edit/bash) after another mutating tool in one batch whose inputs depend on the earlier one.",
109
- "Tool calibration: scale calls to difficulty — one for a known fact, a few for a normal task, more only when evidence is genuinely missing. Locate before you open: search/find first, then read the hit, instead of guessing paths.",
110
- "web_search reflex: if the request hinges on a name, version, library, or event you do not actually recognize, search before answering instead of guessing; never claim a result's absence proves nonexistence.",
108
+ "Batch only independent calls; NEVER batch 'done', and NEVER put a mutating tool (write/edit/bash) after another mutating tool in one batch whose inputs depend on the earlier one. In a multi-edit batch, let the turn `reasoning` name each change — not a vague one-liner.",
109
+ "Tool calibration: scale calls to difficulty — one for a known fact, a few for a normal task, more only when evidence is genuinely missing. For multi-file or multi-step work, sketch a short plan (todo) before the first edit; a one-line change goes straight to the edit. Locate before you open: search/find first, then read the hit, instead of guessing paths.",
110
+ "web_search reflex: if the request hinges on a name, version, library, or event you do not actually recognize, search before answering instead of guessing; never claim a result's absence proves nonexistence. When sources conflict or look incomplete, run another targeted search to disambiguate rather than picking one at random.",
111
111
  "Quoting fetched/searched text: paraphrase by default — quote at most one short phrase per source, cite it, and never paste long passages.",
112
112
  ].join("\n");
113
113
 
@@ -165,6 +165,7 @@ export const OUTPUT_DISCIPLINE = [
165
165
  "- When using lists, ensure each bullet carries a complete thought; avoid fragmented or shredded reports.",
166
166
  "- Don't stall on ambiguity: make reasonable assumptions and ask at most one clarifying question if absolutely necessary.",
167
167
  "- Report only what is done or in progress; never announce future work instead of doing it.",
168
+ "- Give a substantive answer first; never punt with only a disclaimer, a knowledge-cutoff caveat, or an offer to search — answer with what you have, then note the caveat.",
168
169
  "- Match reply length to the task: a one-line change gets a one-line report.",
169
170
  ].join("\n");
170
171
 
@@ -272,6 +273,10 @@ export interface AgentLoopResult {
272
273
  doneReason?: string;
273
274
  /** Summed provider token usage across the turn's steps, when reported. */
274
275
  usage?: { inputTokens: number; outputTokens: number };
276
+ /** Set ONLY when the turn ended in a guard-detected dead end the model could not
277
+ * recover from (repeated/cycled/consecutively-failing calls). Lets the caller
278
+ * capture the stall into failure memory; `undefined` for done/budget/cancelled. */
279
+ stopClass?: "consecutive_failure" | "cycle" | "repeat";
275
280
  }
276
281
 
277
282
 
@@ -369,7 +374,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
369
374
  // "Stopped: …" — throwing away everything found this turn — do ONE final no-tools
370
375
  // call asking the model to answer with what it already has. Mirrors the
371
376
  // budget-exhaustion wrap-up below. Best-effort: falls back to the plain stop.
372
- const consolidateStop = async (stopReason: string): Promise<AgentLoopResult> => {
377
+ const consolidateStop = async (stopReason: string, stopClass: AgentLoopResult["stopClass"]): Promise<AgentLoopResult> => {
373
378
  try {
374
379
  if (!opts.signal?.aborted) {
375
380
  const wrapUp = await invokeCallLlm(
@@ -391,12 +396,13 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
391
396
  return finish({
392
397
  done: false,
393
398
  steps: step,
399
+ stopClass,
394
400
  doneReason: `${consolidated}\n\n(Stopped: ${stopReason} — consolidated answer above from what was found; continue with a follow-up request)`,
395
401
  });
396
402
  }
397
403
  }
398
404
  } catch { /* best-effort; fall through to the plain stop message */ }
399
- return finish({ done: false, steps: step, doneReason: `Stopped: ${stopReason}` });
405
+ return finish({ done: false, steps: step, stopClass, doneReason: `Stopped: ${stopReason}` });
400
406
  };
401
407
  // No-progress guard: weak/local models often repeat the same tool call without
402
408
  // ever emitting `done`. Two escalating corrections (B), then a consolidated stop.
@@ -414,6 +420,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
414
420
  // doc/config changes where verification is genuinely not applicable).
415
421
  let sawMutation = false;
416
422
  let sawVerification = false;
423
+ // Monotonic ordering of mutation vs. verification events so the done gate can
424
+ // tell a STALE verification (passing test/build that predates the last edit)
425
+ // from a fresh one. A single counter captures intra-batch order too.
426
+ let mutVerSeq = 0;
427
+ let lastMutationSeq = 0;
428
+ let lastVerificationSeq = 0;
417
429
  let donePushbackUsed = false;
418
430
  // Caller-owned done gate (onBeforeDone) — also strictly once per turn.
419
431
  let beforeDoneNudgeUsed = false;
@@ -717,7 +729,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
717
729
  // done-verification gate — jeo's descendant of gjc's ultragoal-guard completion
718
730
  // state machine (plan/gjc-inheritance.md B4). The classifier owns the JUDGMENT
719
731
  // (which named state, which message); the loop owns the once-pushback latch.
720
- const doneGate = classifyDoneGate({ sawMutation, sawVerification, pendingHookFailure });
732
+ const doneGate = classifyDoneGate({
733
+ sawMutation,
734
+ sawVerification,
735
+ verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
736
+ pendingHookFailure,
737
+ });
721
738
  if (doneGate.block && !donePushbackUsed) {
722
739
  donePushbackUsed = true; // second done always passes — escape hatch
723
740
  pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
@@ -805,7 +822,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
805
822
  }
806
823
  if (repeatCount >= MAX_REPEAT) {
807
824
  const what = toolCalls.length === 1 ? `the same '${toolCalls[0].tool}' call` : "the same tool calls";
808
- return await consolidateStop(`repeated ${what} ${MAX_REPEAT}× even after explicit corrections (the model never signaled done)`);
825
+ return await consolidateStop(`repeated ${what} ${MAX_REPEAT}× even after explicit corrections (the model never signaled done)`, "repeat");
809
826
  }
810
827
 
811
828
  // Cycle guard: an A↔B (or A↔B↔C-minus-one) alternation never trips the
@@ -833,7 +850,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
833
850
  step++;
834
851
  continue;
835
852
  }
836
- return await consolidateStop(`the model cycled through the same tool calls for ${CYCLE_WINDOW} consecutive steps even after an explicit correction (it never signaled done)`);
853
+ return await consolidateStop(`the model cycled through the same tool calls for ${CYCLE_WINDOW} consecutive steps even after an explicit correction (it never signaled done)`, "cycle");
837
854
  }
838
855
 
839
856
  // Helper to execute a single tool call
@@ -1037,15 +1054,20 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1037
1054
  }
1038
1055
  // done-verification guard bookkeeping: write/edit successes mark the turn as
1039
1056
  // mutating; a successful bash whose command/output looks like a test/build run
1040
- // counts as verification. A verification AFTER the last mutation is what the
1041
- // done guard wants, but order-insensitive tracking keeps it one-pushback simple.
1057
+ // counts as verification. A monotonic seq records ORDER so the done gate can
1058
+ // reject a verification that predates the last mutation (stale evidence).
1042
1059
  for (let i = 0; i < toolCalls.length; i++) {
1043
1060
  if (!results[i].executed || !results[i].success) continue;
1044
1061
  const t = toolCalls[i].tool;
1045
- if (t === "write" || t === "edit") sawMutation = true;
1046
- else if (t === "bash") {
1062
+ if (t === "write" || t === "edit") {
1063
+ sawMutation = true;
1064
+ lastMutationSeq = ++mutVerSeq;
1065
+ } else if (t === "bash") {
1047
1066
  const cmd = String(toolCalls[i].arguments?.command ?? "");
1048
- if (isVerificationSignal(cmd, results[i].output)) sawVerification = true;
1067
+ if (isVerificationSignal(cmd, results[i].output)) {
1068
+ sawVerification = true;
1069
+ lastVerificationSeq = ++mutVerSeq;
1070
+ }
1049
1071
  }
1050
1072
  }
1051
1073
  // F6 (round 4 architect, Low): judge the step by its NON-TRIVIAL calls — a
@@ -1069,6 +1091,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1069
1091
  return finish({
1070
1092
  done: false,
1071
1093
  steps: step,
1094
+ stopClass: "consecutive_failure",
1072
1095
  doneReason: stopMsg,
1073
1096
  });
1074
1097
  }
@@ -33,6 +33,12 @@ Analyze the transcript carefully. Pay attention to:
33
33
  2. What actions the agent took (tool calls, file modifications, tests run).
34
34
  3. The final outcome and verification results.
35
35
 
36
+ Verdict discipline:
37
+ - Judge the goal exactly as written. Do NOT reframe, narrow, or reinterpret it to fit what the agent happened to build — if the goal as stated is not satisfied, that is NOT_MET, and name precisely what is missing.
38
+ - Require positive evidence per requirement: cite the concrete command, test, or file change that shows it was satisfied. The mere absence of an observed failure is NOT proof of success.
39
+ - Do not invent requirements beyond the stated goal. When every part of the goal as written has positive evidence, return MET — withholding MET to demand unrequested extras is itself an error.
40
+ - Reserve IMPOSSIBLE for goals that cannot be satisfied as specified (self-contradictory, or blocked by missing credentials/services), not for goals that are merely incomplete.
41
+
36
42
  You must respond with a JSON object containing:
37
43
  {
38
44
  "verdict": "MET" | "NOT_MET" | "IMPOSSIBLE",
@@ -27,6 +27,7 @@ export type GuardState =
27
27
  | "context_overflow_retry" // provider reported context overflow → ONE trim + retry
28
28
  | "refusal_retry" // transient safety refusal → bounded resend ladder
29
29
  | "done_unverified" // mutated files, no verification signal → pushback on done
30
+ | "done_stale_verification" // verified, then mutated again → re-verify before done
30
31
  | "done_hook_failing" // post-turn hook still failing → pushback on done
31
32
  | "done_ok"; // done accepted — the turn is finished
32
33
 
@@ -100,13 +101,19 @@ export interface DoneGateInput {
100
101
  sawMutation: boolean;
101
102
  /** A test/build/typecheck/lint command succeeded this turn. */
102
103
  sawVerification: boolean;
104
+ /**
105
+ * A mutation landed AFTER the most recent successful verification — so the
106
+ * passing test/build no longer reflects the current tree. Optional; defaults
107
+ * to false, in which case the gate uses the order-insensitive `sawVerification`.
108
+ */
109
+ verificationStale?: boolean;
103
110
  /** The run-command of the most recent still-failing post-turn hook, or null. */
104
111
  pendingHookFailure: string | null;
105
112
  }
106
113
 
107
114
  /** Verdict from {@link classifyDoneGate}: whether to bounce `done`, and the message. */
108
115
  export interface DoneGateVerdict {
109
- state: Extract<GuardState, "done_ok" | "done_unverified" | "done_hook_failing">;
116
+ state: Extract<GuardState, "done_ok" | "done_unverified" | "done_stale_verification" | "done_hook_failing">;
110
117
  /** When true, `done` should be bounced ONCE with `message` (the caller owns the once-gate). */
111
118
  block: boolean;
112
119
  /** Corrective message to push back on `done`; empty when `state === "done_ok"`. */
@@ -117,13 +124,15 @@ export interface DoneGateVerdict {
117
124
  * Classify whether a `done` should be accepted or bounced — the direct descendant of
118
125
  * gjc's `ultragoal-guard` completion gate (plan/gjc-inheritance.md B4).
119
126
  *
120
- * A turn that MUTATED files but has either NO verification signal or a still-failing
121
- * post-turn hook is blocked ONCE. The caller owns the single-pushback latch; a second
122
- * `done` always passes (the escape hatch for genuinely-unverifiable docs/config changes).
127
+ * A turn that MUTATED files is blocked ONCE when its verification is missing
128
+ * (no test/build signal), STALE (a passing run that predates the last edit), or
129
+ * a post-turn hook is still failing. The caller owns the single-pushback latch; a
130
+ * second `done` always passes (the escape hatch for unverifiable docs/config changes).
123
131
  */
124
132
  export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
125
133
  const hookFailing = input.pendingHookFailure !== null;
126
- const block = input.sawMutation && (!input.sawVerification || hookFailing);
134
+ const stale = input.sawVerification && input.verificationStale === true;
135
+ const block = input.sawMutation && (!input.sawVerification || stale || hookFailing);
127
136
  if (!block) return { state: "done_ok", block: false, message: "" };
128
137
  if (hookFailing) {
129
138
  return {
@@ -135,6 +144,16 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
135
144
  "If the hook failure is a false positive, call done again and say why in the reason.",
136
145
  };
137
146
  }
147
+ if (stale) {
148
+ return {
149
+ state: "done_stale_verification",
150
+ block: true,
151
+ message:
152
+ "You verified earlier, but then modified files again — your last passing test/build no longer reflects the current tree. " +
153
+ "Re-run the narrowest verification command against the latest changes, then call done. " +
154
+ "If the later edits are verification-irrelevant (docs/config-only), call done again and say why in the reason.",
155
+ };
156
+ }
138
157
  return {
139
158
  state: "done_unverified",
140
159
  block: true,
@@ -202,6 +202,10 @@ export interface Concept {
202
202
  tags: string[];
203
203
  /** high | medium | low — distiller defaults to "high"; drives core selection. */
204
204
  confidence: string;
205
+ /** Load-bearing invariant (frontmatter `pinned: true`): selected into the injection
206
+ * budget BEFORE any query/recency fill, so the cap can never evict it (philosophy
207
+ * synthesis R8 / consensus #3 — pinned invariants survive on a reserved budget). */
208
+ pinned: boolean;
205
209
  /** Bundle-relative path, e.g. `commands/bun-test.md`. */
206
210
  relPath: string;
207
211
  }
@@ -284,6 +288,7 @@ async function loadConceptsFromBundle(bundleDir: string): Promise<Concept[]> {
284
288
  body: parsed.body.trim(),
285
289
  tags: Array.isArray(fm.tags) ? fm.tags.filter((t): t is string => typeof t === "string") : [],
286
290
  confidence: typeof fm.confidence === "string" ? fm.confidence : "high",
291
+ pinned: fm.pinned === true,
287
292
  relPath,
288
293
  };
289
294
  cacheParsedConcept(file, sig, concept);
@@ -339,10 +344,16 @@ export function searchConcepts(concepts: Concept[], query: string): { concept: C
339
344
  .sort((a, b) => b.score - a.score);
340
345
  }
341
346
 
342
- /** Priority order for injection: high-confidence "core" concepts first, then by
343
- * query relevance (descending), then concepts the relevant ones LINK TO (1-hop
344
- * graph expansion — Sprint 04: a directly-hit concept pulls its neighbours in as
345
- * context ahead of unrelated noise), preserving input order as a stable tiebreak. */
347
+ /** Priority order for injection. Failure-first: a query-relevant FailedAttempt is
348
+ * surfaced AHEAD of everything else — resurfacing a known dead end is higher-leverage
349
+ * than reinforcing what already works ("못하는 게 없도록" — close the gaps, don't just
350
+ * polish strengths), and it is the mechanism by which the loop gets more precise the
351
+ * more it repeats (llm-wiki: accumulated failure knowledge sharpens future runs). Then
352
+ * high-confidence "core" concepts, then query relevance (descending), then concepts the
353
+ * relevant ones LINK TO (1-hop graph expansion — Sprint 04: a directly-hit concept pulls
354
+ * its neighbours in as context ahead of unrelated noise), preserving input order as a
355
+ * stable tiebreak. The failure boost only fires when the query actually hits the concept
356
+ * (score > 0), so an unrelated FailedAttempt never crowds out relevant context. */
346
357
  function priorityOrder(concepts: Concept[], query?: string): Concept[] {
347
358
  const tokens = tokenize(query);
348
359
  // 1-hop graph expansion: seed from concepts the query directly hits, then mark
@@ -354,14 +365,22 @@ function priorityOrder(concepts: Concept[], query?: string): Concept[] {
354
365
  for (const id of expandByGraph(seeds, graph, 1)) related.add(id);
355
366
  }
356
367
  return concepts
357
- .map((concept, i) => ({
358
- concept,
359
- i,
360
- core: concept.confidence === "high",
361
- score: scoreConcept(concept, tokens),
362
- related: related.has(conceptId(concept.relPath)),
363
- }))
368
+ .map((concept, i) => {
369
+ const score = scoreConcept(concept, tokens);
370
+ return {
371
+ concept,
372
+ i,
373
+ // Query-relevant past failure → resurface first so the loop is reminded of
374
+ // what NOT to repeat for the current task. Gated on score > 0: a stale,
375
+ // unrelated dead end stays out of the way.
376
+ failure: concept.type === "FailedAttempt" && score > 0,
377
+ core: concept.confidence === "high",
378
+ score,
379
+ related: related.has(conceptId(concept.relPath)),
380
+ };
381
+ })
364
382
  .sort((a, b) => {
383
+ if (a.failure !== b.failure) return a.failure ? -1 : 1;
365
384
  if (a.core !== b.core) return a.core ? -1 : 1;
366
385
  if (b.score !== a.score) return b.score - a.score;
367
386
  if (a.related !== b.related) return a.related ? -1 : 1;
@@ -404,14 +423,22 @@ function renderConcepts(concepts: Concept[]): string {
404
423
 
405
424
  /** Greedily select concepts (in priority order) whose grouped render stays within
406
425
  * `budget` chars, dropping the lowest-priority concepts first. At least the
407
- * top-priority concept is always kept (the framing/backstop cap still applies). */
426
+ * top-priority concept is always kept (the framing/backstop cap still applies).
427
+ *
428
+ * Pinned (load-bearing) invariants get a RESERVED budget: they are selected FIRST,
429
+ * before any query/recency fill, so a tight cap can never evict an invariant the
430
+ * gates depend on (philosophy-synthesis consensus #3). The remaining budget then
431
+ * holds the failure-first / core / query-relevant fill. */
408
432
  function selectWithinBudget(concepts: Concept[], query: string | undefined, budget: number): Concept[] {
409
- const ordered = priorityOrder(concepts, query);
410
433
  const selected: Concept[] = [];
411
- for (const c of ordered) {
412
- if (renderConcepts([...selected, c]).length <= budget) selected.push(c);
413
- }
414
- if (selected.length === 0 && ordered.length > 0) selected.push(ordered[0]!);
434
+ const take = (pool: Concept[]) => {
435
+ for (const c of priorityOrder(pool, query)) {
436
+ if (renderConcepts([...selected, c]).length <= budget) selected.push(c);
437
+ }
438
+ };
439
+ take(concepts.filter(c => c.pinned)); // reserved budget: invariants survive the cap
440
+ take(concepts.filter(c => !c.pinned)); // recency/query fill in the remaining budget
441
+ if (selected.length === 0 && concepts.length > 0) selected.push(priorityOrder(concepts, query)[0]!);
415
442
  return selected;
416
443
  }
417
444
 
@@ -610,6 +637,55 @@ async function cleanupStalePendingFiles(dir: string): Promise<void> {
610
637
  }
611
638
  }
612
639
 
640
+ /** Upsert ONE concept document into the bundle: resolve its file (dedup by title —
641
+ * same title overwrites in place, a colliding slug gets a numeric suffix), merge
642
+ * over any existing frontmatter, and atomically write it. The single source of
643
+ * truth for a concept file's on-disk shape — shared by the session-exit distiller
644
+ * and the deterministic recordFailedAttempt path. Does NOT touch index/log (callers
645
+ * batch that). Assumes a non-empty title; unknown types fall back to facts/. */
646
+ async function upsertConceptFile(
647
+ bundleDir: string,
648
+ c: { type: string; title: string; description?: string; body?: string; tags?: string[]; confidence?: string; links?: string[]; pinned?: boolean },
649
+ ): Promise<string> {
650
+ const dir = DIR_BY_TYPE[c.type] ?? "facts";
651
+ await fs.mkdir(path.join(bundleDir, dir), { recursive: true });
652
+ let slug = slugify(c.title);
653
+ let relPath = `${dir}/${slug}.md`;
654
+ let fullPath = path.join(bundleDir, relPath);
655
+ let suffix = 1;
656
+ while (true) {
657
+ try {
658
+ if ((parseConcept(await fs.readFile(fullPath, "utf-8")).frontmatter.title || "") === c.title) break;
659
+ slug = `${slugify(c.title)}-${suffix}`;
660
+ relPath = `${dir}/${slug}.md`;
661
+ fullPath = path.join(bundleDir, relPath);
662
+ suffix++;
663
+ } catch {
664
+ break;
665
+ }
666
+ }
667
+ let existingFm: Record<string, unknown> = {};
668
+ try {
669
+ existingFm = parseConcept(await fs.readFile(fullPath, "utf-8")).frontmatter as Record<string, unknown>;
670
+ } catch {}
671
+ const frontmatter = {
672
+ ...existingFm,
673
+ type: c.type,
674
+ title: c.title,
675
+ description: c.description ?? "",
676
+ tags: c.tags ?? [],
677
+ timestamp: new Date().toISOString(),
678
+ confidence: c.confidence ?? "high",
679
+ last_verified: new Date().toISOString().split("T")[0],
680
+ links: c.links ?? [],
681
+ ...(c.pinned ? { pinned: true } : {}),
682
+ };
683
+ const tmpPath = `${fullPath}.tmp-${process.pid}`;
684
+ await fs.writeFile(tmpPath, serializeConcept(frontmatter, c.body ?? ""), "utf-8");
685
+ await fs.rename(tmpPath, fullPath);
686
+ return relPath;
687
+ }
688
+
613
689
  export async function distillSessionMemory(
614
690
  history: Message[],
615
691
  cwd: string,
@@ -721,57 +797,15 @@ export async function distillSessionMemory(
721
797
  const title = typeof concept.title === "string" ? concept.title.trim() : "";
722
798
  if (!type || !title) continue;
723
799
  try {
724
- // Unknown types fall back to facts/ (lenient — OKF tolerates extra types).
725
- const dir = DIR_BY_TYPE[type] ?? "facts";
726
-
727
- const targetDir = path.join(bundleDir, dir);
728
- await fs.mkdir(targetDir, { recursive: true });
729
-
730
- let slug = slugify(title);
731
- let relPath = `${dir}/${slug}.md`;
732
- let fullPath = path.join(bundleDir, relPath);
733
-
734
- let suffix = 1;
735
- while (true) {
736
- try {
737
- const existingContent = await fs.readFile(fullPath, "utf-8");
738
- const parsed = parseConcept(existingContent);
739
- const existingTitle = parsed.frontmatter.title || "";
740
- if (existingTitle === title) {
741
- break;
742
- }
743
- slug = `${slugify(title)}-${suffix}`;
744
- relPath = `${dir}/${slug}.md`;
745
- fullPath = path.join(bundleDir, relPath);
746
- suffix++;
747
- } catch {
748
- break;
749
- }
750
- }
751
-
752
- let existingFm = {};
753
- try {
754
- const existingContent = await fs.readFile(fullPath, "utf-8");
755
- existingFm = parseConcept(existingContent).frontmatter;
756
- } catch {}
757
-
758
- const frontmatter = {
759
- ...existingFm,
760
- type,
800
+ await upsertConceptFile(bundleDir, {
801
+ type, // unknown types fall back to facts/ inside the helper (lenient)
761
802
  title,
762
803
  description: typeof concept.description === "string" ? concept.description : "",
804
+ body: typeof concept.body === "string" ? concept.body : "",
763
805
  tags: Array.isArray(concept.tags) ? concept.tags.filter((t): t is string => typeof t === "string") : [],
764
- timestamp: new Date().toISOString(),
765
806
  confidence: typeof concept.confidence === "string" ? concept.confidence : "high",
766
- last_verified: new Date().toISOString().split("T")[0],
767
807
  links: Array.isArray(concept.links) ? concept.links.filter((l): l is string => typeof l === "string") : [],
768
- };
769
-
770
- const serialized = serializeConcept(frontmatter, typeof concept.body === "string" ? concept.body : "");
771
- const tmpPath = `${fullPath}.tmp-${process.pid}`;
772
- await fs.writeFile(tmpPath, serialized, "utf-8");
773
- await fs.rename(tmpPath, fullPath);
774
-
808
+ });
775
809
  updatedConcepts.push({ title, type });
776
810
  } catch {
777
811
  // Skip just this concept; keep distilling the rest of the batch.
@@ -819,6 +853,46 @@ export async function distillSessionMemory(
819
853
  }
820
854
  }
821
855
 
856
+ /** Mid-session, deterministic (no-LLM) capture of a dead end into the OKF bundle.
857
+ * The session-exit distill only learns AFTER a session ends; a turn that stalled
858
+ * (consecutive-failure / cycle / repeat guard fired) is a dead end the *next turn
859
+ * of the SAME session* should already avoid. This writes ONE FailedAttempt concept
860
+ * immediately so the per-turn memory injection resurfaces it (priorityOrder puts a
861
+ * query-relevant FailedAttempt first), closing the loop the user described: each
862
+ * iteration gets sharper by building on accumulated failure knowledge. Confidence
863
+ * is "medium" (not "high") so it never enters the always-injected "core" tier — it
864
+ * only surfaces when the query actually hits it, and the richer session-exit distill
865
+ * later consolidates or replaces it. Best-effort: a write failure is swallowed. */
866
+ export async function recordFailedAttempt(
867
+ cwd: string,
868
+ attempt: { title: string; description?: string; body?: string; tags?: string[] },
869
+ ): Promise<{ recorded: boolean; skipped?: string }> {
870
+ if (jeoEnv("NO_MEMORY") === "1") return { recorded: false, skipped: "disabled (JEO_NO_MEMORY=1)" };
871
+ const title = attempt.title?.trim();
872
+ if (!title) return { recorded: false, skipped: "empty title" };
873
+ try {
874
+ const bundleDir = path.join(cwd, ".jeo", "memory");
875
+ // confidence "medium" keeps it out of the always-injected core tier (priorityOrder)
876
+ // — it only surfaces when the query actually hits it.
877
+ await upsertConceptFile(bundleDir, {
878
+ type: "FailedAttempt",
879
+ title,
880
+ description: attempt.description ?? "",
881
+ body: attempt.body ?? "",
882
+ tags: Array.isArray(attempt.tags) ? attempt.tags.filter(t => typeof t === "string") : [],
883
+ confidence: "medium",
884
+ });
885
+ await withIndexLock(bundleDir, async () => {
886
+ await rebuildIndex(bundleDir);
887
+ await updateLog(bundleDir, [{ title, type: "FailedAttempt" }]);
888
+ });
889
+ invalidateConceptCache();
890
+ return { recorded: true };
891
+ } catch (err: any) {
892
+ return { recorded: false, skipped: `record failed: ${err?.message ?? String(err)}` };
893
+ }
894
+ }
895
+
822
896
 
823
897
 
824
898
  // ── Detached background distillation (round-16) ──
package/src/cli/runner.ts CHANGED
@@ -103,8 +103,8 @@ export const COMMANDS: readonly CommandSpec[] = [
103
103
  },
104
104
  {
105
105
  name: "skills",
106
- summary: "List bundled workflow skills (jeo skills <name> for details).",
107
- usage: "skills [name]",
106
+ summary: "List bundled workflow skills, or sync them into ~/.jeo/skills (jeo skills <name> for details).",
107
+ usage: "skills [list|read <name>|sync [--check|--force] [dir]|--write [dir]] [--json]",
108
108
  loader: async () => {
109
109
  const m = await import("../commands/skills");
110
110
  return args => m.runSkillsCommand(args);
@@ -497,7 +497,9 @@ export async function runDeepInterviewEngine(opts: DeepInterviewEngineOptions =
497
497
  ` "constraints": ["Optional list of constraints once ambiguity is <= ${threshold}"],\n` +
498
498
  ` "acceptance_criteria": ["Concrete, testable acceptance criteria required before the seed can freeze"]\n` +
499
499
  `}\n` +
500
- `Ensure ambiguityScore drops dynamically as more detail is gathered. Do not report ambiguityScore <= ${threshold} unless acceptance_criteria is populated with concrete, testable checks.`
500
+ `Ensure ambiguityScore drops dynamically as more detail is gathered. Do not report ambiguityScore <= ${threshold} unless acceptance_criteria is populated with concrete, testable checks.\n` +
501
+ `Do not fill an ambiguous point with your own convenient assumption to push the score down — surface that assumption as the nextQuestion instead. Never supply unstated detail that makes the request look simpler or safer than the user actually wrote it.\n` +
502
+ `Conversely, when the user's answers genuinely resolve a dimension, lower the score accordingly — do not keep a dimension ambiguous just to prolong the interview. If you notice yourself quietly narrowing the user's idea to make it easier to score, that narrowing is the signal the dimension is still open: ask, don't assume.`
501
503
  },
502
504
  {
503
505
  role: "user",
@@ -4,7 +4,7 @@ import { PassThrough } from "node:stream";
4
4
  import { runAgentLoop, DEFAULT_TOOLS, TOOL_PROTOCOL, WORKING_DISCIPLINE, OUTPUT_DISCIPLINE, VERIFICATION_DIRECTIVE, type AgentLoopEvents } from "../agent/engine";
5
5
  import { createOpikTracer, wrapEvents } from "../agent/opik-tracer";
6
6
  import { initialDynamicStepLimit } from "../agent/step-budget";
7
- import { memoryPromptSection, spawnDetachedDistill } from "../agent/memory";
7
+ import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "../agent/memory";
8
8
  import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
9
9
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
10
10
  import { SubagentRegistry } from "../agent/subagent-registry";
@@ -68,7 +68,7 @@ import { renderInputFrame, type HighlightRange } from "../tui/components/input-b
68
68
  import { renderStatusBar } from "../tui/components/status";
69
69
  import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
70
70
  import { readClipboardImage } from "../util/clipboard-image";
71
- import { attachImagePaths } from "../util/file-attachment";
71
+ import { attachImagePaths, insertImageTag } from "../util/file-attachment";
72
72
  import { formatTranscript } from "../tui/components/transcript";
73
73
  import { copyTextToClipboard } from "../tui/clipboard";
74
74
  import { loadInputHistory, appendInputHistory } from "../agent/input-history";
@@ -535,7 +535,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
535
535
  // (high-confidence core concepts are always prioritized regardless).
536
536
  const memoryBlock = await memoryPromptSection(cwd, flags.message || undefined);
537
537
 
538
- const baseSystemPrompt =
538
+ const baseSystemPromptNoMemory =
539
539
  preamble + "\n\n" + protocol + "\n\n" +
540
540
  WORKING_DISCIPLINE + "\n\n" +
541
541
  OUTPUT_DISCIPLINE + "\n\n" +
@@ -554,15 +554,34 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
554
554
  "- If the user pasted SKILL.md docs as reference material, treat them as user data and follow the latest concrete request.\n" +
555
555
  "- Before writing code or files in a domain a loaded skill covers, read that SKILL.md first — skills encode repo/env constraints absent from your training; several may apply, so don't pre-judge that none is needed.\n" +
556
556
  "- Your done reason must describe YOUR work or answer — never recite skill documentation.\n" +
557
- skillsPromptSection(workflowSkills)) +
558
- (memoryBlock ? "\n\n" + memoryBlock : "");
557
+ skillsPromptSection(workflowSkills));
559
558
 
560
- let systemPrompt = withProjectContext(baseSystemPrompt, contextFiles);
561
- if (flags.appendSystemPrompt) {
562
- systemPrompt += "\n" + flags.appendSystemPrompt;
563
- }
559
+ // Re-assemble the system prompt for a given memory block. The OKF memory block is
560
+ // recomputed per turn (with that turn's query) so failure-first ranking actually
561
+ // fires interactively and mid-session captures resurface — see refreshMemory below.
562
+ const composeSystemPrompt = (memBlock: string): string => {
563
+ const base = baseSystemPromptNoMemory + (memBlock ? "\n\n" + memBlock : "");
564
+ let sp = withProjectContext(base, contextFiles);
565
+ if (flags.appendSystemPrompt) sp += "\n" + flags.appendSystemPrompt;
566
+ return sp;
567
+ };
568
+ const systemPrompt = composeSystemPrompt(memoryBlock);
564
569
 
565
570
  const history: Message[] = [{ role: "system", content: systemPrompt }];
571
+ // Per-turn OKF memory refresh: recompute the injected memory block with THIS turn's
572
+ // query and rewrite the system message in place. The session-start block was built
573
+ // once (no query in interactive mode, so failure-first ranking never fired); doing
574
+ // it per turn means each loop iteration pulls query-relevant past failures to the
575
+ // top and picks up dead ends captured earlier in the SAME session. Opt out with
576
+ // JEO_STATIC_MEMORY=1 (freeze the session-start block); JEO_NO_MEMORY=1 still wins.
577
+ const refreshSessionMemory = async (query: string): Promise<void> => {
578
+ if (jeoEnv("STATIC_MEMORY") === "1") return;
579
+ if (!history[0] || history[0].role !== "system") return;
580
+ try {
581
+ const fresh = await memoryPromptSection(cwd, query || undefined);
582
+ history[0] = { ...history[0], content: composeSystemPrompt(fresh) };
583
+ } catch { /* keep the prior system prompt on any failure */ }
584
+ };
566
585
  let sessionModel: string | undefined = initialSessionModel;
567
586
  // Session thinking-level override (`/thinking`); falls back to the config level.
568
587
  let sessionThinking: "minimal" | "low" | "medium" | "high" | "xhigh" | undefined = flags.thinking ?? cfg.thinkingLevel;
@@ -764,6 +783,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
764
783
  interactiveTurnActive = true;
765
784
  tui.start();
766
785
  }
786
+ // Refresh injected OKF memory with THIS turn's query before the model call so
787
+ // failure-first ranking fires and same-session dead ends resurface.
788
+ await refreshSessionMemory(userInput);
767
789
  const compRes = await maybeCompact(history, {
768
790
  model: activeModel,
769
791
  contextTokens,
@@ -1062,6 +1084,30 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1062
1084
  sessionUsage.outputTokens += result.usage.outputTokens;
1063
1085
  }
1064
1086
  sessionUsage.turns++;
1087
+
1088
+ // Mid-session failure capture: a guard-detected stall (the model repeated /
1089
+ // cycled / consecutively failed without recovering) is a dead end the rest of
1090
+ // THIS session should avoid. Record it into OKF now (deterministic, no LLM) so
1091
+ // the next turn's memory refresh resurfaces it. Best-effort; never blocks the turn.
1092
+ if (result.stopClass) {
1093
+ const task = userInput.replace(/\s+/g, " ").trim();
1094
+ const excerpt = task.length > 70 ? task.slice(0, 70) + "…" : task;
1095
+ const why = result.stopClass === "consecutive_failure"
1096
+ ? "consecutive failing tool calls"
1097
+ : result.stopClass === "cycle"
1098
+ ? "cycling through the same tool calls"
1099
+ : "repeating the same tool call";
1100
+ const tags = Array.from(new Set(
1101
+ task.toLowerCase().match(/[a-z0-9][a-z0-9_-]{3,}/g) ?? [],
1102
+ )).slice(0, 8);
1103
+ void recordFailedAttempt(cwd, {
1104
+ title: `Stalled on: ${excerpt}`,
1105
+ description: `A prior turn stalled (${why}) on this task — change approach before retrying.`,
1106
+ body: `Task: ${task.slice(0, 240)}\n\nThe agent gave up after ${why} (${result.steps} steps) and could not recover. ` +
1107
+ `Do NOT repeat the same line of attack; try a different decomposition, tool, or verification path.`,
1108
+ tags,
1109
+ }).catch(() => {});
1110
+ }
1065
1111
  const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
1066
1112
 
1067
1113
  if (turnConfig.gitAutoCommit) {
@@ -2671,7 +2717,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2671
2717
  if (dropped.images.length === 0 || dropped.text === before) return;
2672
2718
  pendingImages.push(...dropped.images);
2673
2719
  rli.line = dropped.text;
2674
- rli.cursor = dropped.text.length; // drop lands at the caret (line end in the common case)
2720
+ rli.cursor = dropped.cursor; // park right after the swapped-in tag (whitespace normalized)
2675
2721
  typedLine = dropped.text;
2676
2722
  // Keep readline's internal screen model in step with the swapped-in tag so
2677
2723
  // the caret is not offset on the next keystroke (mirrors the Ctrl+V path).
@@ -2736,12 +2782,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2736
2782
  const img = await readClipboardImage();
2737
2783
  if (!img) return;
2738
2784
  pendingImages.push(img);
2739
- const tag = `[image #${pendingImages.length}]`;
2740
2785
  const rli = rl as unknown as { line: string; cursor: number; _refreshLine?: () => void };
2741
2786
  const at = typeof rli.cursor === "number" ? rli.cursor : rli.line.length;
2742
- const sep = rli.line.length > 0 && at > 0 && rli.line[at - 1] !== " " ? " " : "";
2743
- rli.line = rli.line.slice(0, at) + sep + tag + " " + rli.line.slice(at);
2744
- rli.cursor = at + sep.length + tag.length + 1;
2787
+ // Insert the [image #N] tag at the caret with normalized single-space
2788
+ // padding (shared with the drag-drop path) so the caret lands right after
2789
+ // the tag instead of being pushed several columns by stray spaces.
2790
+ const ins = insertImageTag(rli.line, at, pendingImages.length);
2791
+ rli.line = ins.text;
2792
+ rli.cursor = ins.cursor;
2745
2793
  typedLine = rli.line;
2746
2794
  // Sync readline's internal screen model to the injected tag (same as the
2747
2795
  // slash/tab completion accept paths): without it readline's stale row/cursor
@@ -1,8 +1,101 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
- import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs } from "../skills/catalog";
3
+ import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs, bundledSkillFileContent, userSkillsDir } from "../skills/catalog";
4
4
  import { getLocalJeoDir } from "../agent/state";
5
5
 
6
+ export { userSkillsDir };
7
+
8
+ export type SkillSyncStatus = "missing" | "up-to-date" | "differs";
9
+ export type SkillSyncAction = "installed" | "overwritten" | "preserved" | "unchanged" | "none";
10
+
11
+ export interface SkillSyncEntry {
12
+ name: string;
13
+ /** State of the on-disk file BEFORE any write this run. */
14
+ status: SkillSyncStatus;
15
+ /** What sync did (or, for --check, "none"). */
16
+ action: SkillSyncAction;
17
+ path: string;
18
+ }
19
+
20
+ export interface SkillSyncResult {
21
+ dir: string;
22
+ entries: SkillSyncEntry[];
23
+ /** true when any bundled skill is missing or differs from its on-disk copy. */
24
+ drift: boolean;
25
+ /** Number of files actually written this run. */
26
+ wrote: number;
27
+ }
28
+
29
+ /** Reconcile the bundled workflow skills against `dir` (default {@link userSkillsDir}).
30
+ * Default install preserves existing local files (gjc `setup defaults` parity):
31
+ * missing skills are installed, differing ones preserved unless `force`. `check`
32
+ * is a pure drift report — it writes nothing. Returns a structured result so the
33
+ * CLI wrapper owns all console output / exit codes. */
34
+ export async function syncBundledSkills(
35
+ dir: string,
36
+ opts: { check?: boolean; force?: boolean } = {},
37
+ ): Promise<SkillSyncResult> {
38
+ const entries: SkillSyncEntry[] = [];
39
+ let wrote = 0;
40
+ if (!opts.check) await fs.mkdir(dir, { recursive: true });
41
+ for (const s of SKILLS) {
42
+ const file = path.join(dir, `${s.name}.md`);
43
+ const want = bundledSkillFileContent(s);
44
+ let have: string | null = null;
45
+ try { have = await fs.readFile(file, "utf-8"); } catch { /* missing */ }
46
+ const status: SkillSyncStatus = have === null ? "missing" : have === want ? "up-to-date" : "differs";
47
+ let action: SkillSyncAction = "none";
48
+ if (!opts.check) {
49
+ if (status === "missing") {
50
+ await fs.writeFile(file, want, "utf-8");
51
+ action = "installed";
52
+ wrote++;
53
+ } else if (status === "differs") {
54
+ if (opts.force) {
55
+ await fs.writeFile(file, want, "utf-8");
56
+ action = "overwritten";
57
+ wrote++;
58
+ } else {
59
+ action = "preserved";
60
+ }
61
+ } else {
62
+ action = "unchanged";
63
+ }
64
+ }
65
+ entries.push({ name: s.name, status, action, path: file });
66
+ }
67
+ return { dir, entries, drift: entries.some(e => e.status !== "up-to-date"), wrote };
68
+ }
69
+
70
+ async function runSkillsSync(cleanArgs: string[], isJson: boolean, cwd: string): Promise<void> {
71
+ const check = cleanArgs.includes("--check");
72
+ const force = cleanArgs.includes("--force");
73
+ const dirArg = cleanArgs.slice(1).find(a => !a.startsWith("--"));
74
+ const dir = dirArg ? path.resolve(cwd, dirArg) : userSkillsDir();
75
+ const result = await syncBundledSkills(dir, { check, force });
76
+
77
+ if (isJson) {
78
+ console.log(JSON.stringify({ ...result, mode: check ? "check" : force ? "force" : "install" }, null, 2));
79
+ } else if (check) {
80
+ console.log(`\n=== jeo skills sync --check ===\nTarget: ${dir}\n`);
81
+ for (const e of result.entries) console.log(` ${e.status.padEnd(11)} ${e.name}`);
82
+ console.log(
83
+ result.drift
84
+ ? `\nDrift detected. Run 'jeo skills sync' to install missing skills, or '--force' to overwrite differing local copies.`
85
+ : `\nAll ${result.entries.length} bundled skills are in sync.`,
86
+ );
87
+ } else {
88
+ console.log(`\n=== jeo skills sync ===\nTarget: ${dir}\n`);
89
+ for (const e of result.entries) console.log(` ${e.action.padEnd(11)} ${e.name}`);
90
+ const preserved = result.entries.filter(e => e.action === "preserved").length;
91
+ console.log(`\nWrote ${result.wrote} file(s)${preserved ? `, preserved ${preserved} local copy(ies) (use --force to overwrite)` : ""}.`);
92
+ }
93
+
94
+ // --check is CI-usable: non-zero exit signals drift (gjc `setup defaults --check` parity).
95
+ if (check && result.drift) process.exitCode = 1;
96
+ }
97
+
98
+
6
99
  function editDistance(a: string, b: string): number {
7
100
  const m = a.length, n = b.length;
8
101
  if (m === 0) return n;
@@ -40,12 +133,20 @@ export async function runSkillsCommand(args: string[] = []): Promise<void> {
40
133
  await fs.mkdir(dir, { recursive: true });
41
134
  for (const s of SKILLS) {
42
135
  const file = path.join(dir, `${s.name}.md`);
43
- await fs.writeFile(file, s.raw || `# ${s.name}\n\n${formatSkill(s)}\n`, "utf-8");
136
+ await fs.writeFile(file, bundledSkillFileContent(s), "utf-8");
44
137
  }
45
138
  console.log(`Wrote ${SKILLS.length} skill docs to ${dir}`);
46
139
  return;
47
140
  }
48
141
 
142
+ // `jeo skills sync [--check|--force] [dir]` — bundled skill inspection & safe install
143
+ // (gjc `setup defaults --check/--force` parity): install missing bundled skills into
144
+ // ~/.jeo/skills, preserve existing local copies by default, and report drift.
145
+ if (cleanArgs[0] === "sync") {
146
+ await runSkillsSync(cleanArgs, isJson, cwd);
147
+ return;
148
+ }
149
+
49
150
  // List/lookup over the MERGED set (bundled + ~/.jeo/skills + ~/.agents/skills + project dirs), matching the REPL /skill.
50
151
  const skills = await loadSkills(cwd);
51
152
  const command = cleanArgs[0];
@@ -60,6 +161,7 @@ export async function runSkillsCommand(args: string[] = []): Promise<void> {
60
161
  console.log(` ${s.name.padEnd(16)} ${s.summary}`);
61
162
  }
62
163
  console.log("\nInvoke: /skill <name> [intent] · $<name> [intent] · skill-owned slash aliases (e.g. /speckit.plan)");
164
+ console.log("Install/inspect bundled defaults: 'jeo skills sync' (preserve local) · '--check' (drift report) · '--force' (overwrite local)");
63
165
  console.log("Discovery dirs (later wins on name clash; JEO_SKILLS_DIR adds more):");
64
166
  for (const d of skillDirs(cwd)) console.log(` ${d}`);
65
167
  console.log("");
@@ -11,6 +11,7 @@ Assess architecture, maintainability, correctness, and spec compliance with file
11
11
  - Prioritize spec/root-cause correctness before style comments.
12
12
  - Rate findings by severity: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`.
13
13
  - Never return `APPROVE` if CRITICAL/HIGH issues remain.
14
+ - A clean verdict is not the absence of inspection: do not return `CLEAR`/`APPROVE` merely because no problem surfaced. Base the verdict on files and paths you concretely examined, and say which ones.
14
15
  </constraints>
15
16
 
16
17
  <execution_loop>
@@ -29,6 +30,7 @@ Assess architecture, maintainability, correctness, and spec compliance with file
29
30
  Your final `done.reason` MUST be markdown with these sections:
30
31
  - `Summary:`
31
32
  - `Findings:`
33
+ - `Inspected:` the files/paths you actually examined (evidence for the verdict)
32
34
  - `Recommendations:`
33
35
  - `Architectural Status:` one of `CLEAR`, `WATCH`, `BLOCK`
34
36
  - `Code Review Recommendation:` one of `APPROVE`, `COMMENT`, `REQUEST CHANGES`
@@ -10,6 +10,7 @@ Decide whether a plan or approach is actionable before execution proceeds.
10
10
  - Read-only: never modify files.
11
11
  - Do not invent problems; reject only with concrete gaps.
12
12
  - Simulate representative tasks against inspected evidence before deciding.
13
+ - Honesty cuts both ways: if you catch yourself softening a real, blocking gap into `[ITERATE]` just to avoid blocking, that softening is the signal the gap is real — name it. But never manufacture a block: when gaps are concrete yet fixable in-flight prefer `[ITERATE]` over `[REJECT]`, and return `[OKAY]` once the plan is genuinely actionable.
13
14
  </constraints>
14
15
 
15
16
  <execution_loop>
@@ -70,6 +70,13 @@ export function formatSkill(s: SkillDoc): string {
70
70
  ].filter(Boolean).join("\n");
71
71
  }
72
72
 
73
+ /** Canonical on-disk content for a bundled skill — the exact bytes `jeo skills
74
+ * --write` / `jeo skills sync` materialize into a `<name>.md` file. Prefers the
75
+ * original SKILL.md text; falls back to a decorated render for skills without raw. */
76
+ export function bundledSkillFileContent(s: SkillDoc): string {
77
+ return s.raw || `# ${s.name}\n\n${formatSkill(s)}\n`;
78
+ }
79
+
73
80
  function compactSkillExecutionBrief(skill: SkillDoc): string {
74
81
  const aliases = skillSlashAliases(skill);
75
82
  let details = skill.details;
@@ -254,6 +261,15 @@ export function skillSlashAliases(skill: SkillDoc): string[] {
254
261
  }
255
262
 
256
263
 
264
+ /** User-level bundled-skills install dir (`<config>/skills`, i.e. `~/.jeo/skills`
265
+ * honoring JEO_CONFIG_DIR / $HOME) — the highest-precedence flat skill dir and the
266
+ * destination `jeo skills sync` reconciles against. $HOME wins over os.homedir() so
267
+ * tests/sandboxes that re-point HOME are honored (see {@link skillDirs}). */
268
+ export function userSkillsDir(): string {
269
+ const userHome = process.env.HOME || os.homedir();
270
+ return path.join(jeoEnv("CONFIG_DIR") || path.join(userHome, ".jeo"), "skills");
271
+ }
272
+
257
273
  /** Global + per-project skill-doc directories (user-configurable SKILL.md files).
258
274
  * Ordered lowest → highest precedence (a later dir's skill overrides an earlier
259
275
  * one with the same name): foreign-ecosystem roots first, jeo-native last.
@@ -276,7 +292,7 @@ export function skillDirs(cwd: string = process.cwd()): string[] {
276
292
  path.join(userHome, ".claude", "skills"),
277
293
  path.join(home, "agent", "skills"),
278
294
  path.join(userHome, ".agents", "skills"),
279
- path.join(home, "skills"),
295
+ userSkillsDir(),
280
296
  path.join(cwd, ".claude", "skills"),
281
297
  path.join(cwd, ".agents", "skills"),
282
298
  path.join(cwd, ".jeo", "skills"),
@@ -18,6 +18,54 @@ import type { ImageAttachment } from "../ai/types";
18
18
 
19
19
  const IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|bmp)$/i;
20
20
 
21
+
22
+ /**
23
+ * Normalize the whitespace around every `[image #N]` tag to a single space:
24
+ * - one space before a tag that follows other text (none at line start),
25
+ * - one space after a tag that is followed by more text,
26
+ * - any run of spaces/tabs collapsed to one, and the whole string trimmed.
27
+ *
28
+ * A tag at the very end keeps NO trailing space (callers that want the caret to sit
29
+ * past it add exactly one). Terminals pad a dragged-and-dropped path with their own
30
+ * spacing and the Ctrl+V insert adds a separator, so without this the caret parks
31
+ * several columns past the tag — the "input point looks pushed by a few spaces" bug.
32
+ * Idempotent: re-normalizing already-clean text is a no-op.
33
+ */
34
+ export function normalizeImageTags(text: string): string {
35
+ return text
36
+ .replace(/[ \t]*(\[image #\d+\])[ \t]*/g, (m, tag: string, off: number, s: string) =>
37
+ (off > 0 ? " " : "") + tag + (off + m.length < s.length ? " " : ""),
38
+ )
39
+ .replace(/[ \t]{2,}/g, " ")
40
+ .trim();
41
+ }
42
+
43
+ /** Caret offset just past the `[image #n]` tag in `text` (and its single trailing
44
+ * space when present); falls back to end-of-text when the tag is absent. */
45
+ export function caretAfterTag(text: string, n: number): number {
46
+ const tag = `[image #${n}]`;
47
+ const idx = text.indexOf(tag);
48
+ if (idx < 0) return text.length;
49
+ let end = idx + tag.length;
50
+ if (text[end] === " ") end += 1; // sit after the separating space, not on it
51
+ return end;
52
+ }
53
+
54
+ /**
55
+ * Insert an `[image #n]` tag at `cursor` within `line` (the Ctrl+V clipboard-image
56
+ * path), normalizing surrounding whitespace so the tag is flanked by exactly one
57
+ * space. A tag landing at end-of-line gets one trailing space so the user can keep
58
+ * typing; the returned caret sits right after the tag (and that space).
59
+ */
60
+ export function insertImageTag(line: string, cursor: number, n: number): { text: string; cursor: number } {
61
+ const at = Math.max(0, Math.min(cursor, line.length));
62
+ const tag = `[image #${n}]`;
63
+ let text = normalizeImageTags(line.slice(0, at) + tag + line.slice(at));
64
+ const idx = text.indexOf(tag);
65
+ if (idx >= 0 && idx + tag.length >= text.length) text += " "; // trailing space at EOL for typing
66
+ return { text, cursor: caretAfterTag(text, n) };
67
+ }
68
+
21
69
  /** Detect an image media type from magic bytes, or null when the bytes are not a
22
70
  * recognised image. Used as the authoritative check (extension only gates the
23
71
  * candidate scan; the bytes decide). */
@@ -114,6 +162,10 @@ export interface AttachResult {
114
162
  text: string;
115
163
  /** The newly-read image attachments, in source order. */
116
164
  images: ImageAttachment[];
165
+ /** Caret offset to park after the swap: just past the trailing space of the LAST
166
+ * inserted tag (so the live box places the cursor right after the attachment,
167
+ * ready for the user's prompt). Equals `text.length` when nothing was attached. */
168
+ cursor: number;
117
169
  }
118
170
 
119
171
  /**
@@ -131,7 +183,7 @@ export async function attachImagePaths(
131
183
  read: FileReader = defaultReader,
132
184
  ): Promise<AttachResult> {
133
185
  const tokens = findImagePathTokens(text);
134
- if (tokens.length === 0) return { text, images: [] };
186
+ if (tokens.length === 0) return { text, images: [], cursor: text.length };
135
187
 
136
188
  const reads = await Promise.all(
137
189
  tokens.map(async (t) => {
@@ -154,5 +206,9 @@ export async function attachImagePaths(
154
206
  n += 1;
155
207
  }
156
208
  out += text.slice(cursor);
157
- return { text: out, images };
209
+ if (images.length === 0) return { text: out, images, cursor: out.length };
210
+ // Collapse the spacing terminals add around a dropped path so the swapped-in tag
211
+ // (and the caret parked after it) is not pushed several columns to the right.
212
+ const normalized = normalizeImageTags(out);
213
+ return { text: normalized, images, cursor: caretAfterTag(normalized, n - 1) };
158
214
  }