spexcode 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +13 -10
  4. package/spec-cli/hooks/dispatch.sh +28 -40
  5. package/spec-cli/hooks/harness.sh +43 -7
  6. package/spec-cli/src/board.ts +2 -1
  7. package/spec-cli/src/boardCache.ts +27 -1
  8. package/spec-cli/src/boardStream.ts +5 -4
  9. package/spec-cli/src/cli.ts +93 -20
  10. package/spec-cli/src/commit-surgery.ts +81 -0
  11. package/spec-cli/src/contract-filter.ts +156 -0
  12. package/spec-cli/src/doctor.ts +11 -5
  13. package/spec-cli/src/git.ts +18 -4
  14. package/spec-cli/src/guide.ts +94 -38
  15. package/spec-cli/src/harness-select.ts +2 -2
  16. package/spec-cli/src/harness.ts +22 -8
  17. package/spec-cli/src/help.ts +28 -16
  18. package/spec-cli/src/index.ts +9 -6
  19. package/spec-cli/src/init.ts +17 -10
  20. package/spec-cli/src/issues.ts +7 -5
  21. package/spec-cli/src/layout.ts +31 -13
  22. package/spec-cli/src/lint.ts +19 -0
  23. package/spec-cli/src/localIssues.ts +16 -4
  24. package/spec-cli/src/materialize.ts +214 -144
  25. package/spec-cli/src/mentions.ts +5 -3
  26. package/spec-cli/src/plugin-harness.ts +10 -9
  27. package/spec-cli/src/ranker.ts +25 -8
  28. package/spec-cli/src/runtime-guard.ts +44 -0
  29. package/spec-cli/src/search.bench.mjs +15 -5
  30. package/spec-cli/src/sessions.ts +96 -22
  31. package/spec-cli/src/specs.ts +38 -18
  32. package/spec-cli/src/supervise.ts +2 -2
  33. package/spec-cli/src/tsx-bin.ts +6 -8
  34. package/spec-cli/src/uninstall.ts +18 -19
  35. package/spec-cli/src/worktree-sources.ts +52 -13
  36. package/spec-cli/templates/hooks/post-checkout +22 -0
  37. package/spec-cli/templates/hooks/post-merge +15 -9
  38. package/spec-cli/templates/hooks/pre-commit +10 -0
  39. package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
  40. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
  41. package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
  42. package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
  43. package/spec-dashboard/dist/assets/Dashboard-BlRRsxE7.js +27 -0
  44. package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
  45. package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
  46. package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
  47. package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
  48. package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
  49. package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
  50. package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
  51. package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
  52. package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
  53. package/spec-dashboard/dist/index.html +2 -2
  54. package/spec-forge/src/cli.ts +2 -2
  55. package/spec-forge/src/drivers/gitlab.ts +168 -0
  56. package/spec-forge/src/drivers.ts +80 -2
  57. package/spec-forge/src/resident.ts +10 -5
  58. package/spec-yatsu/src/cli.ts +37 -16
  59. package/spec-yatsu/src/evaltab.ts +6 -3
  60. package/spec-yatsu/src/filing.ts +13 -8
  61. package/spec-yatsu/src/freshness.ts +97 -22
  62. package/spec-yatsu/src/proof.ts +14 -3
  63. package/spec-yatsu/src/scenariofresh.ts +38 -11
  64. package/spec-yatsu/src/yatsu.ts +52 -28
  65. package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
  66. package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
  67. package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
  68. package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
  69. package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
@@ -1,19 +1,58 @@
1
- import { existsSync, symlinkSync } from 'node:fs'
2
- import { join } from 'node:path'
1
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { git } from './git.js'
3
4
 
4
- // @@@ worktree-sources ([[private-overlay]]) - a fresh session worktree checks out only TRACKED content, and
5
- // private mode keeps `.spec` + `spexcode.json` untracked (spexcode.local.json is untracked in BOTH modes) —
6
- // so git alone hands a dispatched agent a worktree with NO spec tree: every hook handler script is absent
7
- // (the dispatcher silently runs nothing), spex sees zero nodes, and the gate re-renders per event over empty
8
- // config roots. Link those sources from the main checkout instead. On a default-mode repo the checkout
9
- // already carries them, so each link guard no-ops — one mechanism, never a mode branch. A failure degrades
10
- // that worker (no hooks, no specs), so it is reported, not swallowed.
11
- export function linkUntrackedSpecSources(main: string, wt: string): void {
12
- for (const f of ['.spec', 'spexcode.json', 'spexcode.local.json']) {
5
+ // @@@ worktree-sources ([[residence]]) - a fresh session worktree is fed by THREE transports, one per
6
+ // source kind, and the kind decides the transport never a mode branch:
7
+ // - TRACKED project state (`.spec`, `spexcode.json`) arrives by GIT CHECKOUT: the sources are always
8
+ // tracked (git is the database), so `git worktree add` alone delivers them. No symlink a link is a
9
+ // WRITE-SEMANTICS declaration (write-through to the main tree), and spec writes go back through the
10
+ // branch/merge ritual, not through a side channel.
11
+ // - MATERIALIZED ARTIFACTS (contract blocks, shims, skills) are DERIVED transported by re-materialize,
12
+ // not by link or copy:
13
+ // sessions.ts materializes into the worktree at creation, and the git-native anchors (pre-commit /
14
+ // post-checkout / post-merge — [[commit-surgery]]) re-materialize on change.
15
+ // - HOST state (`spexcode.local.json`, machine-local and never tracked) is COPIED — a snapshot: the worker
16
+ // reads the same launchers/policy the host had at dispatch, but its writes land on its own copy and die
17
+ // with the worktree, never on the host's real config (a worker once wrote "its" test config through the
18
+ // old symlink and wiped the host's launchers → every later dispatch 401'd).
19
+ // This module owns only the third transport (plus hiding what it seeds). A failure degrades that worker
20
+ // (default launchers/policy), so it is reported, not swallowed.
21
+ export function seedWorktreeHostState(main: string, wt: string): void {
22
+ const f = 'spexcode.local.json'
23
+ try {
24
+ if (!existsSync(join(main, f)) || existsSync(join(wt, f))) return
25
+ copyFileSync(join(main, f), join(wt, f))
26
+ } catch (e) {
27
+ console.error(`spexcode: could not seed ${f} from ${main} into worktree ${wt} — that worker runs on defaults (${e})`)
28
+ return
29
+ }
30
+ hideSeededFromGit(wt, [f])
31
+ }
32
+
33
+ // what we seed, we hide: a seeded entry git still sees is force-add bait (a real PR once carried seeded
34
+ // files into a product repo). `.git/info/exclude` lives in the COMMON git dir, so one write hides the entry
35
+ // in every linked worktree AND the main checkout. Only entries seeded by THIS call and reported un-ignored
36
+ // by `git check-ignore` are written: idempotent across dispatches, and a repo whose materialize already ignores
37
+ // the overlay (materialize's block under any policy) writes nothing — the self-heal for a half-configured repo.
38
+ function hideSeededFromGit(wt: string, seeded: string[]): void {
39
+ for (const f of seeded) {
13
40
  try {
14
- if (existsSync(join(main, f)) && !existsSync(join(wt, f))) symlinkSync(join(main, f), join(wt, f))
41
+ if (isIgnored(wt, f)) continue
42
+ const exclude = join(git(['-C', wt, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim(), 'info', 'exclude')
43
+ mkdirSync(dirname(exclude), { recursive: true })
44
+ const cur = existsSync(exclude) ? readFileSync(exclude, 'utf8') : ''
45
+ appendFileSync(exclude, `${cur && !cur.endsWith('\n') ? '\n' : ''}${f}\n`)
15
46
  } catch (e) {
16
- console.error(`spexcode: could not link ${f} from ${main} into worktree ${wt} — that worker runs without it (${e})`)
47
+ console.error(`spexcode: could not hide seeded ${f} in the shared info/exclude for ${wt} — it will show untracked there (${e})`)
17
48
  }
18
49
  }
19
50
  }
51
+
52
+ function isIgnored(wt: string, f: string): boolean {
53
+ try { git(['-C', wt, 'check-ignore', '-q', f]); return true }
54
+ catch (e: any) {
55
+ if (e?.status === 1) return false // check-ignore's documented "not ignored" exit
56
+ throw e
57
+ }
58
+ }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env bash
2
+ # @@@ footprint refresh (post-checkout) ([[commit-surgery]]) - a branch checkout is one of the three git
3
+ # transitions that can move the materialize's inputs: .spec/.config content changes across branches, and a
4
+ # contract file's TRACKEDNESS can flip (switching to a branch that tracks CLAUDE.md checks out the pristine
5
+ # index prose — the re-materialize here writes the block back into the working file, binds the clean/smudge
6
+ # filter, and withdraws the exclude entry; the kind-transition heals itself). Git-native anchors only —
7
+ # never a harness event. Quiet, best-effort: a failed refresh self-heals at the next anchor (pre-commit's
8
+ # unconditional materialize is the correctness backstop).
9
+ #
10
+ # args: <prev-HEAD> <new-HEAD> <flag>; flag=1 is a branch checkout, flag=0 a file checkout (git checkout --
11
+ # <path> restores files and moves nothing the materialize depends on — skip those).
12
+ [ "${3:-0}" = "1" ] || exit 0
13
+ main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
14
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
15
+ if command -v spex >/dev/null 2>&1; then
16
+ spex internal refresh-footprint >/dev/null 2>&1 || true
17
+ elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
18
+ "$repo_root/node_modules/.bin/spex" internal refresh-footprint >/dev/null 2>&1 || true
19
+ elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
20
+ "$main_root/spec-cli/bin/spex.mjs" internal refresh-footprint >/dev/null 2>&1 || true
21
+ fi
22
+ exit 0
@@ -9,19 +9,25 @@
9
9
  # the feature is OFF in spexcode.json), so this hook is a THIN resolver + caller: resolve `spex` the same
10
10
  # three ways the pre-commit hook does, pass the merged node, echo whatever it prints. No CLI resolvable → no
11
11
  # nudge (safe degradation, same as the lint shim's advisory mode).
12
+ main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
13
+ repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
14
+ spex_run() {
15
+ if command -v spex >/dev/null 2>&1; then spex "$@"
16
+ elif [ -x "$repo_root/node_modules/.bin/spex" ]; then "$repo_root/node_modules/.bin/spex" "$@"
17
+ elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then "$main_root/spec-cli/bin/spex.mjs" "$@"
18
+ else return 127; fi
19
+ }
20
+
21
+ # @@@ footprint refresh ([[commit-surgery]]) - a merge is one of the three git transitions that can move the
22
+ # materialize's inputs (.spec/.config content, contract-file trackedness), so re-materialize here — git-native
23
+ # anchors only, never a harness event. Quiet, best-effort: a failed refresh self-heals at the next anchor.
24
+ spex_run internal refresh-footprint >/dev/null 2>&1 || true
25
+
12
26
  subj=$(git log -1 --format=%s 2>/dev/null)
13
27
  case "$subj" in
14
28
  "merge node/"*) ;;
15
29
  *) exit 0 ;;
16
30
  esac
17
31
  node=$(printf '%s' "$subj" | sed -n 's#^merge \(node/[^:]*\):.*#\1#p')
18
- main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
19
- repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
20
- if command -v spex >/dev/null 2>&1; then
21
- spex issues nudge "$node"
22
- elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
23
- "$repo_root/node_modules/.bin/spex" issues nudge "$node"
24
- elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
25
- "$main_root/spec-cli/bin/spex.mjs" issues nudge "$node"
26
- fi
32
+ spex_run issues nudge "$node"
27
33
  exit 0
@@ -64,6 +64,16 @@ if [ "$branch" = "$trunk" ] && [ ! -f "$git_dir/MERGE_HEAD" ] && [ -z "${SPEXCOD
64
64
  exit 1
65
65
  fi
66
66
 
67
+ # @@@ footprint surgery ([[commit-surgery]]) - the history anchor: an UNCONDITIONAL materialize (masks fresh
68
+ # at the one moment history is written) + staged-index repair — strip the spexcode sentinel block from any
69
+ # staged blob (source = the staged blob, so `git add -p` partial staging survives), unstage HEAD-untracked
70
+ # generated artifacts. REPAIRS AND PROCEEDS, never rejects; one note per repair on stderr. Runs INSIDE the
71
+ # hook env on purpose: the surgery honors GIT_INDEX_FILE so a pathspec/`-a` commit's TEMPORARY index is the
72
+ # one repaired. Advisory when spex is unresolvable or errors (CI lint enforces); never blocks the commit.
73
+ if [ -n "$spex_kind" ]; then
74
+ spex_cli internal commit-surgery || echo "• SpexCode: footprint surgery skipped (error above) — a generated artifact may be staged; CI still enforces." >&2
75
+ fi
76
+
67
77
  # @@@ spec-lint - the hook is just a thin shim over `spex lint`. It blocks on errors only (broken
68
78
  # spec↔code links); coverage/drift are warnings. Bypass with SPEXCODE_SKIP_LINT=1. It shims through the
69
79
  # shared spex_cli() resolved above (repoRoot() discovers the cwd's toplevel via git.ts, which strips the
@@ -12,6 +12,6 @@ The blocking stop gate, with two jobs, each holding a hard loop-break so it neve
12
12
 
13
13
  The COMMIT gate keeps a done/merge proposal honest: such a proposal is rejected while the branch still carries uncommitted work or is zero commits ahead of main, because the ritual commits the spec and code BEFORE proposing. Clean work is allowed to stop; a dirty proposal blocks once with the reason, and if the agent ignores it the gate escapes by downgrading to `asking` so a false "ready to merge" can never stand.
14
14
 
15
- The DECLARE gate refuses to let a session stop in an undeclared `active` state, since a state is a claim the board and other agents act on, not a box ticked to end a turn. A declared state stops freely; an undeclared first stop blocks once to make the agent pick the true state; on the forced continuation it auto-declares a safe default — committed work becomes `awaiting`, otherwise `asking` — so the loop is guaranteed to end.
15
+ The DECLARE gate refuses to let a session stop in an undeclared `active` state, since a state is a claim the board and other agents act on, not a box ticked to end a turn. A declared state stops freely; an undeclared first stop blocks once to make the agent pick the true state; on the forced continuation it auto-declares a safe default — committed work becomes `awaiting`, otherwise `asking` — so the loop is guaranteed to end. The full block text (choices, each with its application condition, plus the discipline of declaring as the turn's LAST call — any later tool call re-flips the record to active) prints once per session, marked by a sentinel file beside the session record; later undeclared stops get a one-line reminder that stays self-explanatory — menu, declare-last, and the `spex help session` entry that recovers the full conditions.
16
16
 
17
17
  It is the enforcement edge of [[core]]: nothing leaves a session except as committed work under a truthful declaration. The freshness it reads is set by [[mark-active]].
@@ -109,12 +109,30 @@ if [ "$cont" = true ]; then
109
109
  exit 0
110
110
  fi
111
111
 
112
- # first stop in an undeclared state -> nudge exactly once. The reason names the PATH-independent CLI ($S)
113
- # ONCE as a shared `<CLI> session <choice>` prefix, then lists the five choices as a compact newline menu of
114
- # bare subcommands so the terminal output stays legible instead of repeating the long abs path per option.
115
- # It EMPHASIZES that each state is a CLAIM others act on (not a box to tick to end the turn) and gives the
116
- # precise APPLICATION CONDITION for each so the agent picks the TRUE one. park is policed hardest because
117
- # a false park (no real background task) reads on the board as "fine, self-resuming" when the agent actually
118
- # needs the human, which is the most damaging mislabel.
119
- printf '{"decision":"block","reason":"Your session state is a CLAIM the board, your supervisor, and other agents act on — not a box to tick to end the turn. Stopping undeclared makes your outcome a guess. Pick the ONE that is TRUE right now and run `%s session <choice>`, choosing the <choice> whose condition holds:\\n • done --propose merge spec+code COMMITTED on the branch and genuinely ready for a human to review/merge (not just probably-done).\\n • done --propose nothing — committed, but you are NOT proposing a merge; paused for the human to look.\\n • park --note <what-you-await> — ONLY when a real BACKGROUND TASK will wake you (a spex wait you backgrounded, a running build/job). If nothing is actually running to resume you, you are NOT parked — you are waiting on the human, so use ask; never use park as a default to clear this gate.\\n • done --propose close — you propose discarding this worktree.\\n • ask --note <your-question> — you need the human: a real question, or you are simply stopped awaiting direction; you resume only when they reply."}\n' "$S"
112
+ # first stop in an undeclared state -> block. The FULL teaching text prints ONCE per session; every later
113
+ # undeclared stop gets a ONE-LINE version (a heavy session hits this gate 15-20x a night re-printing the
114
+ # full menu each time is pure token noise). The once-sentinel is a plain file beside session.json in the
115
+ # session's global store dir the same per-session-sentinel mechanism as the CLI's note-echo-taught; $sdir
116
+ # is already alias-resolved here, so a codex thread id lands on the same file, and an unwritable dir just
117
+ # teaches again (never blocks the block). The terse line must stay SELF-EXPLANATORY: an agent whose context
118
+ # was compacted may never have seen the full text, so the line carries the whole command menu, the
119
+ # declare-LAST discipline, and the `help session` entry that re-explains each choice's condition — every bit
120
+ # of the full-to-terse information gap is recoverable from the entry, none of it from memory.
121
+ taught="$sdir/stop-gate-taught"
122
+ if [ -f "$taught" ]; then
123
+ printf '{"decision":"block","reason":"undeclared stop — pick the ONE true state and declare it as the LAST call of your turn: `%s session <done --propose merge|nothing|close / park --note <what-you-await> / ask --note <your-question>>`. Which choice is true (and why park is never a default): `%s help session`."}\n' "$S" "$S"
124
+ exit 0
125
+ fi
126
+ touch "$taught" 2>/dev/null || true
127
+ # The full reason names the PATH-independent CLI ($S) ONCE as a shared `<CLI> session <choice>` prefix, then
128
+ # lists the five choices as a compact newline menu of bare subcommands — so the terminal output stays legible
129
+ # instead of repeating the long abs path per option. It EMPHASIZES that each state is a CLAIM others act on
130
+ # (not a box to tick to end the turn) and gives the precise APPLICATION CONDITION for each — so the agent
131
+ # picks the TRUE one. park is policed hardest because a false park (no real background task) reads on the
132
+ # board as "fine, self-resuming" when the agent actually needs the human, which is the most damaging mislabel.
133
+ # It ends with the ORDERING discipline — declare LAST, then stop — because a declaration followed by more
134
+ # tool calls honestly re-flips the record to active (mark-active, by design) and re-blocks the next stop;
135
+ # this block text is the one place every undeclared stopper is guaranteed to read, so the teaching that
136
+ # kills the park->block->re-park loop at its source lives here.
137
+ printf '{"decision":"block","reason":"Your session state is a CLAIM the board, your supervisor, and other agents act on — not a box to tick to end the turn. Stopping undeclared makes your outcome a guess. Pick the ONE that is TRUE right now and run `%s session <choice>`, choosing the <choice> whose condition holds:\\n • done --propose merge — spec+code COMMITTED on the branch and genuinely ready for a human to review/merge (not just probably-done).\\n • done --propose nothing — committed, but you are NOT proposing a merge; paused for the human to look.\\n • park --note <what-you-await> — ONLY when a real BACKGROUND TASK will wake you (a spex wait you backgrounded, a running build/job). If nothing is actually running to resume you, you are NOT parked — you are waiting on the human, so use ask; never use park as a default to clear this gate.\\n • done --propose close — you propose discarding this worktree.\\n • ask --note <your-question> — you need the human: a real question, or you are simply stopped awaiting direction; you resume only when they reply.\\n\\nDECLARE LAST, THEN STOP: finish everything else in the turn first — speak, send your messages, arm your background waits — and make the declaration your FINAL call. Any tool call AFTER it flips your record back to active (mark-active, by design: activity is activity), so the next stop re-blocks and demands a fresh declaration; declaring last kills that loop at its source.\\n\\n(This full explanation shows once per session; later undeclared stops get a one-line reminder. `%s help session` re-explains the choices any time.)"}\n' "$S" "$S"
120
138
  exit 0
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ // digest.mjs — locate a dead session's transcript on disk and print a compact markdown digest.
3
+ // Usage: node digest.mjs <session-id | path/to/transcript.jsonl>
4
+ // Read-only: never resumes, prompts, or mutates the session. Exit 1 (loud) when no transcript is found.
5
+ //
6
+ // Harness coverage: claude (projects/<enc-cwd>/<id>.jsonl) and codex (sessions/YYYY/MM/DD/rollout-*<id>.jsonl).
7
+ // The digest keeps the high-signal stream — user prompts in full, assistant text, tool calls as one-liners,
8
+ // error results — and drops the bulk (tool outputs, attachments, sidechains, reasoning).
9
+
10
+ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+ import { homedir } from 'node:os'
13
+
14
+ const arg = process.argv[2]
15
+ if (!arg) { console.error('usage: digest.mjs <session-id | transcript.jsonl>'); process.exit(1) }
16
+
17
+ const TRUNC = (s, n) => { s = String(s ?? '').trim(); return s.length > n ? s.slice(0, n) + ` …[+${s.length - n} chars]` : s }
18
+
19
+ // ---- locate -------------------------------------------------------------
20
+ const claudeRoots = () => {
21
+ const roots = []
22
+ if (process.env.CLAUDE_CONFIG_DIR) roots.push(process.env.CLAUDE_CONFIG_DIR)
23
+ for (const e of readdirSync(homedir(), { withFileTypes: true }))
24
+ if (e.isDirectory() && e.name.startsWith('.claude')) roots.push(join(homedir(), e.name))
25
+ return roots
26
+ }
27
+ const findClaude = (id) => {
28
+ for (const root of claudeRoots()) {
29
+ const proj = join(root, 'projects')
30
+ if (!existsSync(proj)) continue
31
+ for (const d of readdirSync(proj)) {
32
+ const f = join(proj, d, `${id}.jsonl`)
33
+ if (existsSync(f)) return f
34
+ }
35
+ }
36
+ return null
37
+ }
38
+ const findCodex = (id) => {
39
+ const root = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions')
40
+ if (!existsSync(root)) return null
41
+ const stack = [root]
42
+ while (stack.length) {
43
+ const dir = stack.pop()
44
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
45
+ if (e.isDirectory()) stack.push(join(dir, e.name))
46
+ else if (e.name.startsWith('rollout-') && e.name.includes(id) && e.name.endsWith('.jsonl')) return join(dir, e.name)
47
+ }
48
+ }
49
+ return null
50
+ }
51
+ const file = arg.endsWith('.jsonl') ? arg : (findClaude(arg) || findCodex(arg))
52
+ if (!file || !existsSync(file)) { console.error(`no transcript found for "${arg}" (searched claude projects/ and codex sessions/)`); process.exit(1) }
53
+
54
+ // ---- parse --------------------------------------------------------------
55
+ const lines = readFileSync(file, 'utf8').split('\n').filter(Boolean)
56
+ const out = []
57
+ const meta = { cwd: null, branch: null, first: null, last: null }
58
+ const filesTouched = new Set()
59
+ let errors = 0
60
+ const seen = new Set()
61
+
62
+ // harness-injected preamble arrives typed as "user" (system-reminders, AGENTS.md folds, codex permission/skill
63
+ // blocks) — it is noise to a digest, and real human prompts essentially never open with these markers.
64
+ const isInjected = (t) => /^(<|# AGENTS\.md instructions|Caveat: The messages below)/.test(t.trimStart())
65
+ const userBlock = (t) => { t = String(t ?? '').trim(); return t && !isInjected(t) ? `\n## user\n${t}` : null }
66
+
67
+ const toolLine = (name, input = {}) => {
68
+ if ((name === 'Edit' || name === 'Write' || name === 'NotebookEdit') && input.file_path) filesTouched.add(input.file_path)
69
+ const hint = input.description || input.file_path || input.command || input.prompt || input.query || input.pattern || ''
70
+ return `→ ${name}${hint ? ` · ${TRUNC(hint, 160)}` : ''}`
71
+ }
72
+
73
+ for (const raw of lines) {
74
+ let l; try { l = JSON.parse(raw) } catch { continue }
75
+ const ts = l.timestamp
76
+ if (ts) { meta.first ||= ts; meta.last = ts }
77
+ if (l.cwd) meta.cwd ||= l.cwd
78
+ if (l.gitBranch && l.gitBranch !== 'HEAD') meta.branch ||= l.gitBranch
79
+
80
+ // claude shape: {type: user|assistant, message:{content}, isSidechain}
81
+ if (l.type === 'user' || l.type === 'assistant') {
82
+ if (l.isSidechain) continue // subagent noise
83
+ const c = l.message?.content
84
+ if (typeof c === 'string') { const u = userBlock(c); if (u) out.push(u); continue }
85
+ for (const item of c || []) {
86
+ if (item.type === 'text' && item.text?.trim()) {
87
+ if (l.type === 'user') { const u = userBlock(item.text); if (u) out.push(u) }
88
+ else out.push(TRUNC(item.text, 2000))
89
+ }
90
+ else if (item.type === 'tool_use') out.push(toolLine(item.name, item.input))
91
+ else if (item.type === 'tool_result' && item.is_error) {
92
+ errors++
93
+ const t = Array.isArray(item.content) ? item.content.map((x) => x.text || '').join(' ') : item.content
94
+ out.push(`⚠ tool error: ${TRUNC(t, 400)}`)
95
+ }
96
+ }
97
+ continue
98
+ }
99
+
100
+ // codex shape: {type: session_meta|response_item|event_msg, payload:{...}}. The same message is logged
101
+ // BOTH as a response_item and an event_msg (version-dependent which exists) — dedupe on the text.
102
+ const p = l.payload
103
+ if (!p) continue
104
+ if (l.type === 'session_meta') { meta.cwd ||= p.cwd; continue }
105
+ const pushMsg = (role, text) => {
106
+ text = String(text ?? '').trim()
107
+ if (!text || seen.has(text)) return
108
+ seen.add(text)
109
+ if (role === 'user') { const u = userBlock(text); if (u) out.push(u) }
110
+ else if (role === 'assistant') out.push(TRUNC(text, 2000)) // developer/system roles are harness plumbing
111
+ }
112
+ if (l.type === 'event_msg' && (p.type === 'user_message' || p.type === 'agent_message'))
113
+ pushMsg(p.type === 'user_message' ? 'user' : 'assistant', p.message)
114
+ else if (l.type === 'response_item') {
115
+ if (p.type === 'message') {
116
+ pushMsg(p.role, (p.content || []).map((x) => x.text || '').join('\n'))
117
+ } else if (p.type === 'function_call') {
118
+ let input = {}; try { input = JSON.parse(p.arguments || '{}') } catch {}
119
+ out.push(toolLine(p.name, input))
120
+ } else if (p.type === 'function_call_output' && /error/i.test(String(p.output).slice(0, 200))) {
121
+ errors++
122
+ out.push(`⚠ tool error: ${TRUNC(p.output, 400)}`)
123
+ }
124
+ }
125
+ }
126
+
127
+ // ---- emit ---------------------------------------------------------------
128
+ const kb = Math.round(statSync(file).size / 1024)
129
+ console.log(`# transcript digest — ${arg}`)
130
+ console.log(`- file: ${file} (${kb} KB, ${lines.length} lines)`)
131
+ if (meta.first) console.log(`- span: ${meta.first} → ${meta.last}`)
132
+ if (meta.cwd) console.log(`- cwd: ${meta.cwd}`)
133
+ if (meta.branch) console.log(`- branch: ${meta.branch}`)
134
+ console.log(out.join('\n'))
135
+ console.log(`\n---\n- transcript: ${file}\n- tool errors seen: ${errors}`)
136
+ if (filesTouched.size) console.log(`- files edited:\n${[...filesTouched].map((f) => ` - ${f}`).join('\n')}`)
@@ -0,0 +1,74 @@
1
+ ---
2
+ title: distill
3
+ surface: skill, command
4
+ status: active
5
+ hue: 210
6
+ desc: Use when the human wants to inherit a past or dead session's knowledge and work — "distill session X / 继承那个 session 的经验 / 接手它的工作 / 把之前 session 的东西捞回来 / harvest, salvage a finished session". Given a session id, read its transcript from disk (NEVER resume or re-prompt it — its cache is cold and a re-prime is expensive), distill goal · decisions · traps · next steps into the current session, and if its worktree/branch never merged, carry the work over and retire the resources.
7
+ ---
8
+
9
+ # distill
10
+
11
+ Inherit a finished (or dead) session's **mind and desk** without waking it — mind = its transcript on disk,
12
+ desk = its worktree/branch. The one iron rule: **never resume, reopen, send to, or otherwise re-prompt the
13
+ old session** (cold cache: any turn pays a full re-prime). Everything below is read-only files and plain git.
14
+
15
+ ## 1 · resolve the session
16
+
17
+ Input: a session id — SpexCode's, a bare harness id (claude / codex thread), or a transcript `.jsonl` path.
18
+
19
+ - **SpexCode session** (first choice — the join is first-class): its record is
20
+ `~/.spexcode/projects/*/sessions/<id>/session.json` — glob for the id, prefix ok. Take `worktree_path`,
21
+ `branch`, `harness`, `harness_session_id`, `status`, `title`; the originating goal is
22
+ `spex session prompt <id>`. For a claude-harness session the transcript id IS the SpexCode session id;
23
+ for codex it is `harness_session_id`.
24
+ - **Any other session**: treat the arg as the harness's own id. The transcript carries `cwd` (and, unless
25
+ the worktree was detached, a branch) — the digest header surfaces them; that is your join to its desk.
26
+
27
+ ## 2 · digest the transcript — mechanical first, model second
28
+
29
+ `node .spec/<root>/.config/distill/digest.mjs <id-or-path>` locates the transcript (claude:
30
+ `$CLAUDE_CONFIG_DIR` and every `~/.claude*` config dir → `projects/*/<id>.jsonl`; codex: `$CODEX_HOME` or
31
+ `~/.codex` → `sessions/**/rollout-*<id>.jsonl`) and prints a compact digest: the human's prompts in full,
32
+ the agent's own text, tool calls as one-liners, error results, and a footer with the files it edited and
33
+ the raw transcript path. It exits loud when nothing is found — do not fall back to resuming the session.
34
+
35
+ Read the digest yourself when small; big (>~100 KB) → a subagent returns only the distillation below, so
36
+ the inheritance never floods your own context. Its ⚠ error lines and footer are step 3's trap material.
37
+
38
+ ## 3 · distill — forward-looking, not narrative
39
+
40
+ Completed work is git's job to remember; do not re-narrate it — and never paste raw transcript. State in
41
+ your reply, and work from, what the transcript knows that git does not:
42
+
43
+ - **Goal & landing** — what it set out to do, and where it actually stopped (merged? proposal pending?
44
+ abandoned mid-flight?).
45
+ - **Decisions & why** — the direction that was settled, including options weighed and rejected.
46
+ - **Traps** — failures, dead ends, gotchas, and every correction the human made. These are the
47
+ highest-value lines in the whole transcript.
48
+ - **Unfinished / next actions** — what it would have done next.
49
+ - **Pointers** — files edited, spec nodes touched, and the raw transcript path itself, so later questions
50
+ drill into the source instead of inheriting everything up front.
51
+
52
+ ## 4 · salvage the desk
53
+
54
+ The SpexCode record names the worktree/branch; otherwise the digest's `cwd` may be a linked worktree
55
+ (`git -C <cwd> rev-parse --git-common-dir`). Salvage inside that repo — it need not be the one you sit in.
56
+ Cross-check the digest's files-edited footer against that worktree: a manager-style session's edits often
57
+ live OUTSIDE it (main-checkout config, other repos) — those need a by-hand look, not the recipe below.
58
+
59
+ - **Already merged** (`git merge-base --is-ancestor <branch> <trunk>`) → nothing to salvage; note it and
60
+ go to cleanup. A tip that EQUALS the merge-base carried no commits — say "never committed", not "merged".
61
+ - **Unmerged commits** → carry them onto your current branch: `git cherry-pick <base>..<branch>` (keeps
62
+ authorship and `Session:` trailers); fall back to applying `git diff <base> <branch>` when the history
63
+ is too messy to replay.
64
+ - **Uncommitted changes** in the old worktree → `git -C <wt> status --porcelain`; apply its diff to your
65
+ tree and copy untracked files over. Commit the salvage in your own tree, naming the origin session in
66
+ the message.
67
+
68
+ ## 5 · clean up — only after the salvage LANDED
69
+
70
+ Cleanup discards state — verify the salvaged commits are in your tree (or the branch genuinely merged) first.
71
+
72
+ - SpexCode session: `spex session close <id>` retires the session and its worktree in one verb.
73
+ - Bare worktree: `git worktree remove <wt>`, + `git branch -D <branch>` once confirmed carried or merged.
74
+ - In doubt, keep the resources and say so — a kept worktree costs disk; a wrong cleanup costs the work.