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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +13 -10
- package/spec-cli/hooks/dispatch.sh +28 -40
- package/spec-cli/hooks/harness.sh +43 -7
- package/spec-cli/src/board.ts +2 -1
- package/spec-cli/src/boardCache.ts +27 -1
- package/spec-cli/src/boardStream.ts +5 -4
- package/spec-cli/src/cli.ts +93 -20
- package/spec-cli/src/commit-surgery.ts +81 -0
- package/spec-cli/src/contract-filter.ts +156 -0
- package/spec-cli/src/doctor.ts +11 -5
- package/spec-cli/src/git.ts +18 -4
- package/spec-cli/src/guide.ts +94 -38
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +22 -8
- package/spec-cli/src/help.ts +28 -16
- package/spec-cli/src/index.ts +9 -6
- package/spec-cli/src/init.ts +17 -10
- package/spec-cli/src/issues.ts +7 -5
- package/spec-cli/src/layout.ts +31 -13
- package/spec-cli/src/lint.ts +19 -0
- package/spec-cli/src/localIssues.ts +16 -4
- package/spec-cli/src/materialize.ts +214 -144
- package/spec-cli/src/mentions.ts +5 -3
- package/spec-cli/src/plugin-harness.ts +10 -9
- package/spec-cli/src/ranker.ts +25 -8
- package/spec-cli/src/runtime-guard.ts +44 -0
- package/spec-cli/src/search.bench.mjs +15 -5
- package/spec-cli/src/sessions.ts +96 -22
- package/spec-cli/src/specs.ts +38 -18
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tsx-bin.ts +6 -8
- package/spec-cli/src/uninstall.ts +18 -19
- package/spec-cli/src/worktree-sources.ts +52 -13
- 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/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
- 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-BlRRsxE7.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BzVE38-Z.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-DFuLVOeu.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CzDaazhe.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-CXQrQCNp.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-D1pUBl6q.js} +8 -8
- package/spec-dashboard/dist/assets/SessionWindow-Y25Bwg1e.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-R610Vbzd.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BO0Zuweu.js} +2 -2
- package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +2 -2
- package/spec-forge/src/drivers/gitlab.ts +168 -0
- package/spec-forge/src/drivers.ts +80 -2
- package/spec-forge/src/resident.ts +10 -5
- package/spec-yatsu/src/cli.ts +37 -16
- package/spec-yatsu/src/evaltab.ts +6 -3
- package/spec-yatsu/src/filing.ts +13 -8
- package/spec-yatsu/src/freshness.ts +97 -22
- package/spec-yatsu/src/proof.ts +14 -3
- package/spec-yatsu/src/scenariofresh.ts +38 -11
- package/spec-yatsu/src/yatsu.ts +52 -28
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
- package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
|
@@ -1,19 +1,58 @@
|
|
|
1
|
-
import { existsSync,
|
|
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 ([[
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// (
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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 ->
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
|
|
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.
|