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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/hooks/dispatch.sh +28 -40
- package/spec-cli/hooks/harness.sh +29 -19
- package/spec-cli/src/cli.ts +21 -11
- package/spec-cli/src/commit-surgery.ts +81 -0
- package/spec-cli/src/contract-filter.ts +16 -14
- package/spec-cli/src/doctor.ts +11 -5
- package/spec-cli/src/guide.ts +61 -58
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +5 -5
- package/spec-cli/src/help.ts +20 -17
- package/spec-cli/src/init.ts +5 -40
- package/spec-cli/src/layout.ts +26 -20
- package/spec-cli/src/materialize.ts +102 -126
- package/spec-cli/src/mentions.ts +5 -3
- package/spec-cli/src/plugin-harness.ts +10 -9
- package/spec-cli/src/sessions.ts +22 -13
- package/spec-cli/src/specs.ts +9 -4
- package/spec-cli/src/uninstall.ts +6 -5
- package/spec-cli/src/worktree-sources.ts +6 -4
- package/spec-cli/templates/hooks/post-checkout +22 -0
- package/spec-cli/templates/hooks/post-merge +15 -9
- package/spec-cli/templates/hooks/pre-commit +10 -0
- package/spec-cli/templates/spec/project/.config/distill/digest.mjs +136 -0
- package/spec-cli/templates/spec/project/.config/distill/spec.md +74 -0
- package/spec-dashboard/dist/assets/{Dashboard-CMRJGfYI.js → Dashboard-BlRRsxE7.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Dj2mxcfW.js → EvalsPage-BzVE38-Z.js} +1 -1
- package/spec-dashboard/dist/assets/{FoldToggle-BfNpeyRQ.js → FoldToggle-DFuLVOeu.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-DfY315kt.js → IssuesPage-CzDaazhe.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-BGdC0A0P.js → MobileApp-CXQrQCNp.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-BOBCAR0t.js → SessionInterface-D1pUBl6q.js} +8 -8
- package/spec-dashboard/dist/assets/{SessionWindow-D7YmjV0i.js → SessionWindow-Y25Bwg1e.js} +5 -5
- package/spec-dashboard/dist/assets/{Settings-BlSNmpH_.js → Settings-R610Vbzd.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BFdzpd_O.js → index-BO0Zuweu.js} +2 -2
- package/spec-dashboard/dist/index.html +1 -1
package/spec-cli/src/specs.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
12
|
-
// command adds only what a
|
|
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
|
|
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 —
|
|
107
|
-
//
|
|
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 ([[
|
|
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
|
-
// -
|
|
12
|
-
//
|
|
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
|
|
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
|
-
|
|
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.
|