spexcode 0.2.5 → 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 (36) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/dispatch.sh +28 -40
  4. package/spec-cli/hooks/harness.sh +29 -19
  5. package/spec-cli/src/cli.ts +21 -11
  6. package/spec-cli/src/commit-surgery.ts +81 -0
  7. package/spec-cli/src/contract-filter.ts +16 -14
  8. package/spec-cli/src/doctor.ts +11 -5
  9. package/spec-cli/src/guide.ts +61 -58
  10. package/spec-cli/src/harness-select.ts +2 -2
  11. package/spec-cli/src/harness.ts +5 -5
  12. package/spec-cli/src/help.ts +20 -17
  13. package/spec-cli/src/init.ts +5 -40
  14. package/spec-cli/src/layout.ts +26 -20
  15. package/spec-cli/src/materialize.ts +102 -126
  16. package/spec-cli/src/mentions.ts +5 -3
  17. package/spec-cli/src/plugin-harness.ts +10 -9
  18. package/spec-cli/src/sessions.ts +22 -13
  19. package/spec-cli/src/specs.ts +9 -4
  20. package/spec-cli/src/uninstall.ts +6 -5
  21. package/spec-cli/src/worktree-sources.ts +6 -4
  22. package/spec-cli/templates/hooks/post-checkout +22 -0
  23. package/spec-cli/templates/hooks/post-merge +15 -9
  24. package/spec-cli/templates/hooks/pre-commit +10 -0
  25. package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
  26. package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
  27. package/spec-dashboard/dist/assets/{Dashboard-CMRJGfYI.js → Dashboard-BlRRsxE7.js} +7 -7
  28. package/spec-dashboard/dist/assets/{EvalsPage-Dj2mxcfW.js → EvalsPage-BzVE38-Z.js} +1 -1
  29. package/spec-dashboard/dist/assets/{FoldToggle-BfNpeyRQ.js → FoldToggle-DFuLVOeu.js} +1 -1
  30. package/spec-dashboard/dist/assets/{IssuesPage-DfY315kt.js → IssuesPage-CzDaazhe.js} +1 -1
  31. package/spec-dashboard/dist/assets/{MobileApp-BGdC0A0P.js → MobileApp-CXQrQCNp.js} +1 -1
  32. package/spec-dashboard/dist/assets/{SessionInterface-BOBCAR0t.js → SessionInterface-D1pUBl6q.js} +8 -8
  33. package/spec-dashboard/dist/assets/{SessionWindow-D7YmjV0i.js → SessionWindow-Y25Bwg1e.js} +5 -5
  34. package/spec-dashboard/dist/assets/{Settings-BlSNmpH_.js → Settings-R610Vbzd.js} +1 -1
  35. package/spec-dashboard/dist/assets/{index-BFdzpd_O.js → index-BO0Zuweu.js} +2 -2
  36. package/spec-dashboard/dist/index.html +1 -1
@@ -102,7 +102,9 @@ function walk(dir: string, parent: string | null, acc: Raw[]) {
102
102
  // function over this same universe (every spec node), so a colliding leaf carries one canonical id
103
103
  // system-wide instead of a second, diverging bare-leaf scheme.
104
104
  export function mintIds(segs: string[][]): string[] {
105
- const suffix = (s: string[], k: number) => s.slice(s.length - k).join('_')
105
+ // NFC pins one canonical byte form for a non-ASCII dir name (macOS hands out NFD basenames), so a typed
106
+ // `[[中文节点]]` (NFC, what an IME emits) string-matches the minted id on every platform.
107
+ const suffix = (s: string[], k: number) => s.slice(s.length - k).join('_').normalize('NFC')
106
108
  return segs.map((s, i) => {
107
109
  let k = 1
108
110
  while (k < s.length && segs.some((o, j) => j !== i && o.length >= k && suffix(o, k) === suffix(s, k))) k++
@@ -357,7 +359,10 @@ function loadSurface(surface: 'command' | 'system' | 'hook' | 'skill' | 'agent')
357
359
  // @@@ skip pending - a `status: pending` plugin is DECLARED INTENT, not yet active. It renders on the
358
360
  // board (via loadSpecs) but must NOT gather: neither a command preset, nor folded into a system prompt,
359
361
  // nor a live hook. Only built/active plugins surface here, so pending stubs stay inert.
360
- if (str(fm.surface) === surface && str(fm.status) !== 'pending') {
362
+ // the surface field may name SEVERAL surfaces (comma-separated or a YAML list) — the node plugs
363
+ // into every one it lists, so the match is membership, not equality.
364
+ const surfaces = list(fm.surface).flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean)
365
+ if (surfaces.includes(surface) && str(fm.status) !== 'pending') {
361
366
  out.push({
362
367
  name,
363
368
  title: str(fm.title, name),
@@ -390,10 +395,10 @@ export function loadSystemConfig(): ConfigPreset[] { return loadSurface('system'
390
395
  // the hook handlers (compiled into the per-session hook manifest the dispatcher reads). Each carries its
391
396
  // `events`/`order`/`block` binding + co-located script `files`.
392
397
  export function loadHookConfig(): ConfigPreset[] { return loadSurface('hook') }
393
- // the skill bundles (rendered into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
398
+ // the skill bundles (materialized into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
394
399
  // load-trigger and its `body` is the on-demand instructions; loadSurface passes the folder basename as `name`.
395
400
  export function loadSkillConfig(): ConfigPreset[] { return loadSurface('skill') }
396
- // the sub-agent definitions (rendered into each harness's auto-discovered agent dir, e.g. claude's
401
+ // the sub-agent definitions (materialized into each harness's auto-discovered agent dir, e.g. claude's
397
402
  // .claude/agents/<name>.md). Like a skill, the node's `desc` is the on-demand load-trigger and its `body` is the
398
403
  // agent's system prompt; additionally its `tools` field is the harness tool allowlist for the spawned agent.
399
404
  export function loadAgentConfig(): ConfigPreset[] { return loadSurface('agent') }
@@ -8,8 +8,8 @@ import { loadSkillConfig, loadAgentConfig } from './specs.js'
8
8
  import { dematerialize } from './materialize.js'
9
9
 
10
10
  // @@@ spex-uninstall - materialize(∅) plus the store: the in-tree/global-config backout IS dematerialize (the
11
- // same identity-stamped erase phase every render runs first — the forgetting law's empty policy), and this
12
- // command adds only what a render never owns per-run: the global per-project store, the plugin-bundle sweep,
11
+ // same identity-stamped erase phase every materialize runs first — the forgetting law's empty policy), and this
12
+ // command adds only what a materialize never owns per-run: the global per-project store, the plugin-bundle sweep,
13
13
  // and the optional git hooks. EVERY removal is gated on a SpexCode IDENTITY STAMP (the managed-block
14
14
  // sentinels, the shim's own dispatch.sh command line, the trust sentinels, the generated mark / name-scoped
15
15
  // on-demand paths, the plugin name stamp), so it can only ever delete what SpexCode itself generated. The one
@@ -100,11 +100,12 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
100
100
 
101
101
  // 1+2. materialize(∅): every harness's artifacts (contract block, shim, trust, skills/agents), the managed
102
102
  // .gitignore + info/exclude blocks, any legacy skip-worktree bit, and the content filter — the SAME
103
- // erase phase every render runs, asserted against the empty policy. One inverse, never a parallel one.
103
+ // erase phase every materialize runs, asserted against the empty policy. One inverse, never a parallel one.
104
104
  dematerialize(proj, arts)
105
105
 
106
- // 3. the global per-project store — manifest + content-hash marker + gate lock + session records. This is the
107
- // runtime tier, not the user's spec asset, so the whole dir is ours.
106
+ // 3. the global per-project store — the per-tree materialize slots (trees/<enc>: manifest + content-hash +
107
+ // plugin ledger), any legacy pre-slot manifest, and the session records. This is the runtime tier,
108
+ // not the user's spec asset, so the whole dir is ours.
108
109
  let store: string | null = null
109
110
  try {
110
111
  store = runtimeRoot(proj)
@@ -2,14 +2,16 @@ import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync } fro
2
2
  import { dirname, join } from 'node:path'
3
3
  import { git } from './git.js'
4
4
 
5
- // @@@ worktree-sources ([[render-policy]]) - a fresh session worktree is fed by THREE transports, one per
5
+ // @@@ worktree-sources ([[residence]]) - a fresh session worktree is fed by THREE transports, one per
6
6
  // source kind, and the kind decides the transport — never a mode branch:
7
7
  // - TRACKED project state (`.spec`, `spexcode.json`) arrives by GIT CHECKOUT: the sources are always
8
8
  // tracked (git is the database), so `git worktree add` alone delivers them. No symlink — a link is a
9
9
  // WRITE-SEMANTICS declaration (write-through to the main tree), and spec writes go back through the
10
10
  // branch/merge ritual, not through a side channel.
11
- // - RENDERS (contract blocks, shims, skills) are DERIVED — transported by re-render, not by link or copy:
12
- // sessions.ts materializes into the worktree at creation, and the dispatch gate re-renders on change.
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.
13
15
  // - HOST state (`spexcode.local.json`, machine-local and never tracked) is COPIED — a snapshot: the worker
14
16
  // reads the same launchers/policy the host had at dispatch, but its writes land on its own copy and die
15
17
  // with the worktree, never on the host's real config (a worker once wrote "its" test config through the
@@ -31,7 +33,7 @@ export function seedWorktreeHostState(main: string, wt: string): void {
31
33
  // what we seed, we hide: a seeded entry git still sees is force-add bait (a real PR once carried seeded
32
34
  // files into a product repo). `.git/info/exclude` lives in the COMMON git dir, so one write hides the entry
33
35
  // in every linked worktree AND the main checkout. Only entries seeded by THIS call and reported un-ignored
34
- // by `git check-ignore` are written: idempotent across dispatches, and a repo whose render already ignores
36
+ // by `git check-ignore` are written: idempotent across dispatches, and a repo whose materialize already ignores
35
37
  // the overlay (materialize's block under any policy) writes nothing — the self-heal for a half-configured repo.
36
38
  function hideSeededFromGit(wt: string, seeded: string[]): void {
37
39
  for (const f of seeded) {
@@ -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
@@ -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.