@spexcode/spec-cli 0.6.7 → 0.7.0-next.0
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/bin/spex.mjs +175 -21
- package/dist/cli.js +121 -59
- package/dist/client.d.ts +1 -3
- package/dist/client.js +49 -30
- package/dist/codex-runtime-generations.d.ts +11 -0
- package/dist/codex-runtime-generations.js +46 -9
- package/dist/delivery-lock.d.ts +2 -0
- package/dist/delivery-lock.js +58 -0
- package/dist/doctor.js +53 -11
- package/dist/file-write.js +14 -2
- package/dist/gateway-hub.js +2 -1
- package/dist/gateway.js +6 -3
- package/dist/graphCache.js +32 -2
- package/dist/graphSnapshot.js +57 -2
- package/dist/graphStream.d.ts +2 -0
- package/dist/graphStream.js +83 -3
- package/dist/guide.js +29 -18
- package/dist/harness-select.js +16 -3
- package/dist/harness.d.ts +24 -6
- package/dist/harness.js +510 -72
- package/dist/help.js +11 -8
- package/dist/hook-prompts.js +8 -0
- package/dist/host-resources.js +29 -8
- package/dist/host.d.ts +7 -0
- package/dist/host.js +98 -1
- package/dist/index.js +294 -36
- package/dist/init.js +1 -1
- package/dist/lint.js +70 -35
- package/dist/listen.d.ts +3 -2
- package/dist/listen.js +14 -2
- package/dist/machine-peer.d.ts +2 -1
- package/dist/machine-peer.js +46 -15
- package/dist/materialize.d.ts +2 -2
- package/dist/materialize.js +176 -35
- package/dist/opencode.js +10 -2
- package/dist/pi-harness.js +18 -5
- package/dist/pi-headless.d.ts +2 -0
- package/dist/pi-headless.js +27 -0
- package/dist/pty-bridge.js +14 -14
- package/dist/reviews.js +12 -7
- package/dist/runtime-ownership.d.ts +11 -0
- package/dist/runtime-ownership.js +79 -1
- package/dist/session-application.d.ts +23 -0
- package/dist/session-application.js +189 -0
- package/dist/session-declarations.js +13 -1
- package/dist/session-files.d.ts +6 -0
- package/dist/session-files.js +13 -1
- package/dist/session-follow.js +39 -22
- package/dist/session-record-lock.d.ts +3 -0
- package/dist/session-record-lock.js +94 -0
- package/dist/session-runtime-adapter.d.ts +44 -0
- package/dist/session-runtime-adapter.js +37 -0
- package/dist/session-timeline.d.ts +25 -2
- package/dist/session-timeline.js +68 -11
- package/dist/session-transcript.d.ts +57 -0
- package/dist/session-transcript.js +137 -0
- package/dist/session-web.js +4 -4
- package/dist/sessions.d.ts +112 -15
- package/dist/sessions.js +1553 -764
- package/dist/shim-runtime.js +14 -0
- package/dist/source-list.d.ts +13 -0
- package/dist/source-list.js +99 -0
- package/dist/source-read.d.ts +16 -0
- package/dist/source-read.js +84 -0
- package/dist/spec-attachments.d.ts +7 -0
- package/dist/spec-attachments.js +89 -0
- package/dist/spec-body-edit.d.ts +23 -0
- package/dist/spec-body-edit.js +138 -0
- package/dist/supervise.js +15 -6
- package/hooks/dispatch.sh +19 -31
- package/hooks/harness.sh +6 -6
- package/package.json +8 -6
- package/templates/hooks/post-checkout +4 -2
- package/templates/hooks/post-merge +2 -1
- package/templates/hooks/pre-commit +5 -3
- package/templates/hooks/reference-transaction +5 -3
- package/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/templates/spec/project/.plugins/core/spec.md +2 -0
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/templates/spec/project/.plugins/spec.md +7 -0
- package/dist/execution-trace.d.ts +0 -26
- package/dist/execution-trace.js +0 -475
- package/dist/session-execution.d.ts +0 -10
- package/dist/session-execution.js +0 -70
- package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- package/hooks/compat/mark-active-sed-v0.fixture +0 -46
|
@@ -18,6 +18,6 @@ The block text is where the declaration ritual is taught, so it is written to be
|
|
|
18
18
|
|
|
19
19
|
The clean-done eval nudge is advisory only and must never corrupt the Stop hook protocol. Claude-family hooks can receive it as `hookSpecificOutput.additionalContext`; Codex Stop allows are silent because Codex treats unsupported non-block stdout as invalid hook JSON. Blocking decisions stay shared across harnesses through `{"decision":"block"}` plus the dispatcher’s Codex stderr bridge.
|
|
20
20
|
|
|
21
|
-
Both gates act ONLY on a GOVERNED (dashboard-launched) session: the gate resolves the session's record in the global store from the payload's `session_id`, and on a non-governed (user-self-launched) record — or none — it exits 0 SILENTLY. A self-launched agent has no board to feed, so the declare-demand must never misfire on it.
|
|
21
|
+
Both gates act ONLY on a GOVERNED (dashboard-launched) session: the gate resolves the session's record in the global store from the payload's `session_id`, and on a non-governed (user-self-launched) record — or none — it exits 0 SILENTLY. A self-launched agent has no board to feed, so the declare-demand must never misfire on it. The forced-continuation fallback writes `asking` through `spex internal session-state --session <id>` — the canonical writer, with no delivery or build side effects — passing the id explicitly since there is no worktree file to read it from. Porcelain declarations remain agent-facing; the hook never invokes them to repair its own stop.
|
|
22
22
|
|
|
23
23
|
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]].
|
|
@@ -11,28 +11,20 @@
|
|
|
11
11
|
# without inventing a completion state.
|
|
12
12
|
# $SPEX is the PATH-independent CLI invocation (abs tsx + cli) injected by settingsArg, so the gate's own
|
|
13
13
|
# auto-default AND the command it shows the agent both work even when `spex` is absent from PATH.
|
|
14
|
-
# @@@
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# misfiring on a self-launched codex/claude). cwd = the session worktree (resolves the project key + the
|
|
19
|
-
# commit-gate's git); state writes go through `$SPEX session … --session <id>` (TS owns the JSON).
|
|
14
|
+
# @@@ governed gate - the session id comes from the payload. The gate acts ONLY on a GOVERNED
|
|
15
|
+
# (dashboard-launched) session: a user-self-launched agent has no board to feed, so an undeclared stop is
|
|
16
|
+
# none of our business. Lifecycle status/proposal come from the canonical session application through one
|
|
17
|
+
# CLI read; this shell never treats runtime.json as a second lifecycle database.
|
|
20
18
|
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
21
19
|
S="${SPEX:-spex}"
|
|
22
20
|
input=$(cat 2>/dev/null || true)
|
|
23
21
|
sid=$(hp_session_id "$input"); [ -n "$sid" ] || exit 0
|
|
24
22
|
sdir=$(hp_store_dir "$sid") || exit 0
|
|
25
|
-
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# the single writer ([[sessions-core]]), so anchoring to the line start is what keeps a neighbouring note's
|
|
31
|
-
# escaped quote from being read as this field's value — the read half of the same rule that stops shell from
|
|
32
|
-
# ever composing the record.
|
|
33
|
-
jget() { sed -n "s/^[[:space:]]*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\",\?$/\1/p" "$rec" 2>/dev/null | head -1; }
|
|
34
|
-
status=$(jget status)
|
|
35
|
-
proposal=$(jget proposal)
|
|
23
|
+
# non-governed (or no record) → silently let the stop through. THIS is the self-launch fix. The CLI response
|
|
24
|
+
# is governed<TAB>status<TAB>proposal; status/proposal are canonical, while governed is identity metadata.
|
|
25
|
+
hook_state=$($S internal session-hook-state --session "$sid" 2>/dev/null) || exit 0
|
|
26
|
+
IFS=$'\t' read -r governed status proposal <<< "$hook_state"
|
|
27
|
+
[ "$governed" = 1 ] || exit 0
|
|
36
28
|
|
|
37
29
|
# the value of the payload's structured `stop_hook_active` field (true on the hook-forced continuation),
|
|
38
30
|
# read by field name rather than substring-sniffing the JSON blob. ([a-z]* captures true/false portably —
|
|
@@ -91,7 +83,10 @@ if [ "${status:-active}" = awaiting ] && { [ "$proposal" = merge ] || [ "$propos
|
|
|
91
83
|
exit 0 # work is committed and ahead of main -> the proposal is honest, let it stop.
|
|
92
84
|
fi
|
|
93
85
|
if [ "$cont" = true ]; then
|
|
94
|
-
|
|
86
|
+
# The hook is a thin boundary. Do not call the porcelain declaration here: it may trigger
|
|
87
|
+
# delivery/build work and can remain running after the harness has already accepted the stop.
|
|
88
|
+
# The internal writer is the same canonical lifecycle path, with no dispatch side effects.
|
|
89
|
+
$S internal session-state asking --session "$sid" --note "stopped with uncommitted work — commit your spec+code on the node branch, then re-declare done" >/dev/null 2>&1 || true
|
|
95
90
|
exit 0
|
|
96
91
|
fi
|
|
97
92
|
reason=$($S internal hook-prompt stop-gate --variant commit --reason "$gatemsg" --cli "$S" --propose "$proposal") || exit 1
|
|
@@ -106,13 +101,15 @@ fi
|
|
|
106
101
|
if [ "$cont" = true ]; then
|
|
107
102
|
# The forced continuation also stopped without declaring. Escape into asking: no default may invent a
|
|
108
103
|
# completed lane, and the stopped agent now needs a human prompt to choose merge, close, ask, or park.
|
|
109
|
-
|
|
104
|
+
# Keep this fallback inside the hook/CLI boundary. The porcelain `session ask` command can wait on
|
|
105
|
+
# delivery and workspace builds; a stop hook must settle the canonical state independently of those paths.
|
|
106
|
+
$S internal session-state asking --session "$sid" --note "auto: stopped without declaring — choose merge, close, ask, or park; done --propose nothing records no state" >/dev/null 2>&1 || true
|
|
110
107
|
exit 0
|
|
111
108
|
fi
|
|
112
109
|
|
|
113
110
|
# first stop in an undeclared state -> block. The FULL teaching text prints ONCE per session; every later
|
|
114
111
|
# undeclared stop gets a ONE-LINE version (a heavy session hits this gate 15-20x a night — re-printing the
|
|
115
|
-
# full menu each time is pure token noise). The once-sentinel is a plain file beside
|
|
112
|
+
# full menu each time is pure token noise). The once-sentinel is a plain file beside runtime.json in the
|
|
116
113
|
# session's global store dir — the same per-session-sentinel mechanism as the CLI's note-echo-taught; $sdir
|
|
117
114
|
# is already alias-resolved here, so a codex thread id lands on the same file, and an unwritable dir just
|
|
118
115
|
# teaches again (never blocks the block). The terse line must stay SELF-EXPLANATORY: an agent whose context
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: merge
|
|
3
|
+
surface: skill, command
|
|
4
|
+
status: active
|
|
5
|
+
hue: 130
|
|
6
|
+
desc: Land this session's completed branch into the repository's source-of-truth branch as one verified no-ff merge, preserve unrelated dirty work, push it, and settle the session honestly. Use when the user says /merge, asks to merge or land this session, or when a supervisor dispatches a merge.
|
|
7
|
+
kind: mutating
|
|
8
|
+
---
|
|
9
|
+
# merge
|
|
10
|
+
|
|
11
|
+
Land the current SpexCode session's branch; do not dispatch another merge request back to yourself.
|
|
12
|
+
|
|
13
|
+
1. Inspect the current session, branch, worktree, source-of-truth checkout, and live Git status. Treat every
|
|
14
|
+
pre-existing dirty or untracked path as user-owned. Record its exact status and diff fingerprint; never
|
|
15
|
+
discard it, fold it into your commit, or hide it behind an unverified stash.
|
|
16
|
+
2. Commit this session's intended spec and code first. In this worktree, merge the latest source-of-truth
|
|
17
|
+
head into the session branch. Resolve conflicts here, then rerun the focused proof, build, `spex spec lint`,
|
|
18
|
+
and `spex eval lint --changed` required by the changed behavior.
|
|
19
|
+
3. Immediately before landing, verify
|
|
20
|
+
`git merge-base --is-ancestor <source-head> <session-head>`. If it fails, sync again. A clean textual merge
|
|
21
|
+
is not product proof.
|
|
22
|
+
4. In the source-of-truth checkout, make one `--no-ff` merge of the already-synced session tip. Do not resolve
|
|
23
|
+
conflicts there. If unrelated dirty work prevents the merge, preserve it byte-for-byte and report the exact
|
|
24
|
+
overlap rather than forcing, resetting, or committing it.
|
|
25
|
+
5. Verify the source checkout has no `MERGE_HEAD`, the session tip is its ancestor, unrelated dirty
|
|
26
|
+
fingerprints are unchanged, and the post-merge gates pass. Push the source-of-truth branch only after
|
|
27
|
+
those checks.
|
|
28
|
+
6. Stop test-owned processes and publish any evidence a human must inspect. If the work is fully landed and
|
|
29
|
+
no decision remains, run `spex session done --propose close` as the final action. Never close your own
|
|
30
|
+
session directly. If a real decision or external wake-up remains, declare the truthful alternative instead.
|
|
31
|
+
|
|
32
|
+
`spex session merge <SEL>` is the supervisor-facing dispatcher that sends this workflow to another session.
|
|
33
|
+
Inside the target session, execute the workflow above; do not call `spex session merge .` recursively.
|
|
@@ -11,9 +11,5 @@ where an agent invokes them on demand, each carrying `surface: skill`. Grouping
|
|
|
11
11
|
legible at a glance — the skill plugins on this shelf, the command presets on [[commands]], the auxiliary
|
|
12
12
|
system contracts on [[prompts]], with [[core]] a flat child beside them.
|
|
13
13
|
|
|
14
|
-
This node is a **shelf, not a surface
|
|
15
|
-
|
|
16
|
-
exactly as it would at the root. A plugin that serves BOTH surfaces — [[distill]] is skill and command —
|
|
17
|
-
shelves here by its primary (skill) identity and still gathers as a command through its field. The init
|
|
18
|
-
templates mirror this layout. The shelf stays pure presentation: moving a resident beneath it changes
|
|
19
|
-
neither that plugin's identity nor the surfaces gathered from its frontmatter.
|
|
14
|
+
This node is a **shelf, not a surface**. Its routing and relocation invariant is owned once by
|
|
15
|
+
[[.plugins]]'s shelf invariant; this node only describes skill-specific materialization and invocation.
|
|
@@ -21,3 +21,10 @@ materialized into the agent's contract.
|
|
|
21
21
|
Which plugins `spex init` ships is the init-preset rule. `seed: false` excludes a plugin subtree; shared
|
|
22
22
|
plugins have one body and one helper set — there is no separately authored adopter variant. Dogfood eval
|
|
23
23
|
scenarios/readings remain with the implementation and git history they measure.
|
|
24
|
+
|
|
25
|
+
## shelf invariant
|
|
26
|
+
|
|
27
|
+
Grouping shelves (`[[commands]]`, `[[skills]]`, `[[review]]`, and `[[prompts]]`) are presentation parents, not
|
|
28
|
+
surfaces. Discovery is recursive and field-driven, so moving a resident under a shelf changes neither its
|
|
29
|
+
identity nor the surfaces gathered from its frontmatter. Each shelf therefore states only its surface-specific
|
|
30
|
+
purpose; it must not copy this routing invariant.
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
export type ExecutionTurn = Readonly<{
|
|
2
|
-
token: string;
|
|
3
|
-
acceptedAt: string;
|
|
4
|
-
}>;
|
|
5
|
-
export type ExecutionStepKind = 'command' | 'read' | 'write' | 'search' | 'tool';
|
|
6
|
-
export type ExecutionStep = Readonly<{
|
|
7
|
-
id: string;
|
|
8
|
-
kind: ExecutionStepKind;
|
|
9
|
-
label: string;
|
|
10
|
-
detail?: string;
|
|
11
|
-
state: 'running' | 'done';
|
|
12
|
-
}>;
|
|
13
|
-
export type ExecutionTrace = Readonly<{
|
|
14
|
-
revision: string;
|
|
15
|
-
turnId: string | null;
|
|
16
|
-
workingNote: string | null;
|
|
17
|
-
steps: readonly ExecutionStep[];
|
|
18
|
-
}>;
|
|
19
|
-
type ExportLoader = (threadId: string) => string;
|
|
20
|
-
export declare function codexRolloutPath(threadId: string, root?: string): string | null;
|
|
21
|
-
export declare function readCodexExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string): ExecutionTrace;
|
|
22
|
-
export declare function readProjectJsonlExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string): ExecutionTrace;
|
|
23
|
-
export declare function readSessionJsonlExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string): ExecutionTrace;
|
|
24
|
-
export declare function readLocalStoreExecutionTrace(threadId: string, turn: ExecutionTurn | null, root?: string, load?: ExportLoader): ExecutionTrace;
|
|
25
|
-
export declare const noExecutionTrace: (_threadId: string, _turn: ExecutionTurn | null) => ExecutionTrace | null;
|
|
26
|
-
export {};
|
package/dist/execution-trace.js
DELETED
|
@@ -1,475 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { closeSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
const cache = new Map();
|
|
6
|
-
const sessionJsonlPaths = new Map();
|
|
7
|
-
const localStoreCache = new Map();
|
|
8
|
-
const emptyTrace = (turn, revision = '0') => ({
|
|
9
|
-
revision: `${turn?.token ?? '0'}:${revision}`,
|
|
10
|
-
turnId: turn?.token ?? null,
|
|
11
|
-
workingNote: null,
|
|
12
|
-
steps: [],
|
|
13
|
-
});
|
|
14
|
-
const object = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
15
|
-
const items = (value) => Array.isArray(value) ? value : [];
|
|
16
|
-
const text = (value) => typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
17
|
-
const field = (value, ...names) => {
|
|
18
|
-
for (const name of names) {
|
|
19
|
-
const found = text(value[name]);
|
|
20
|
-
if (found)
|
|
21
|
-
return found;
|
|
22
|
-
}
|
|
23
|
-
return null;
|
|
24
|
-
};
|
|
25
|
-
const children = (dir) => { try {
|
|
26
|
-
return readdirSync(dir).sort().reverse();
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return [];
|
|
30
|
-
} };
|
|
31
|
-
const sensitive = /(token|secret|password|authorization|bearer|cookie|credential|api[_-]?key|private[ _-]?key)/i;
|
|
32
|
-
const detailFields = [
|
|
33
|
-
['cmd', 'cmd'], ['command', 'command'], ['path', 'path'], ['file_path', 'path'],
|
|
34
|
-
['query', 'query'], ['pattern', 'pattern'], ['url', 'url'], ['workdir', 'in'],
|
|
35
|
-
];
|
|
36
|
-
function toolKind(name) {
|
|
37
|
-
const normalized = name.toLowerCase();
|
|
38
|
-
if (/(search|browse|web)/.test(normalized))
|
|
39
|
-
return 'search';
|
|
40
|
-
if (/(read|list|find|grep|glob|stat)/.test(normalized))
|
|
41
|
-
return 'read';
|
|
42
|
-
if (/(write|edit|patch|create|delete|move|rename)/.test(normalized))
|
|
43
|
-
return 'write';
|
|
44
|
-
if (/(exec|shell|command|terminal|bash|npm|git)/.test(normalized))
|
|
45
|
-
return 'command';
|
|
46
|
-
return 'tool';
|
|
47
|
-
}
|
|
48
|
-
function toolLabel(name, kind) {
|
|
49
|
-
const labels = {
|
|
50
|
-
command: 'Run command', read: 'Read files', write: 'Update files', search: 'Search', tool: 'Use tool',
|
|
51
|
-
};
|
|
52
|
-
return name.trim() || labels[kind];
|
|
53
|
-
}
|
|
54
|
-
function recordInput(value) {
|
|
55
|
-
if (value && typeof value === 'object' && !Array.isArray(value))
|
|
56
|
-
return value;
|
|
57
|
-
if (typeof value !== 'string')
|
|
58
|
-
return null;
|
|
59
|
-
try {
|
|
60
|
-
const parsed = JSON.parse(value);
|
|
61
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
function sensitiveInput(value) {
|
|
68
|
-
if (typeof value === 'string')
|
|
69
|
-
return sensitive.test(value);
|
|
70
|
-
if (Array.isArray(value))
|
|
71
|
-
return value.some(sensitiveInput);
|
|
72
|
-
if (value && typeof value === 'object')
|
|
73
|
-
return Object.entries(value)
|
|
74
|
-
.some(([key, entry]) => sensitive.test(key) || sensitiveInput(entry));
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
function compactValue(key, value) {
|
|
78
|
-
const compact = String(value).trim().replace(/\s+/g, ' ');
|
|
79
|
-
if (key === 'path' || key === 'file_path' || key === 'workdir') {
|
|
80
|
-
const parts = compact.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
81
|
-
return parts.slice(-2).join('/') || compact;
|
|
82
|
-
}
|
|
83
|
-
return compact.length > 96 ? `${compact.slice(0, 95)}...` : compact;
|
|
84
|
-
}
|
|
85
|
-
function detailFromInput(value) {
|
|
86
|
-
const input = recordInput(value);
|
|
87
|
-
if (!input || sensitiveInput(input))
|
|
88
|
-
return undefined;
|
|
89
|
-
const details = [];
|
|
90
|
-
for (const [key, title] of detailFields) {
|
|
91
|
-
const value = input[key];
|
|
92
|
-
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
|
|
93
|
-
continue;
|
|
94
|
-
details.push(`${title}: ${compactValue(key, value)}`);
|
|
95
|
-
if (details.length === 2)
|
|
96
|
-
break;
|
|
97
|
-
}
|
|
98
|
-
const lineStart = input.line_start;
|
|
99
|
-
const lineEnd = input.line_end;
|
|
100
|
-
if (details.length < 2 && (typeof lineStart === 'number' || typeof lineEnd === 'number')) {
|
|
101
|
-
details.push(`lines: ${typeof lineStart === 'number' ? lineStart : 1}-${typeof lineEnd === 'number' ? lineEnd : lineStart}`);
|
|
102
|
-
}
|
|
103
|
-
return details.length ? details.join(' · ') : undefined;
|
|
104
|
-
}
|
|
105
|
-
function displayableNote(value) {
|
|
106
|
-
const note = text(value)?.replace(/\s+/g, ' ');
|
|
107
|
-
return note ? (note.length > 240 ? `${note.slice(0, 239)}...` : note) : null;
|
|
108
|
-
}
|
|
109
|
-
function stepFromInput(id, name, input, state) {
|
|
110
|
-
const kind = toolKind(name);
|
|
111
|
-
const detail = detailFromInput(input);
|
|
112
|
-
return { id, kind, label: toolLabel(name, kind), ...(detail ? { detail } : {}), state };
|
|
113
|
-
}
|
|
114
|
-
function timestamp(value) {
|
|
115
|
-
if (typeof value === 'number' && Number.isFinite(value))
|
|
116
|
-
return value;
|
|
117
|
-
if (typeof value !== 'string' || !value.trim())
|
|
118
|
-
return null;
|
|
119
|
-
const number = Number(value);
|
|
120
|
-
if (Number.isFinite(number))
|
|
121
|
-
return number;
|
|
122
|
-
const parsed = Date.parse(value);
|
|
123
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
124
|
-
}
|
|
125
|
-
function identities(...values) {
|
|
126
|
-
const result = new Set();
|
|
127
|
-
for (const value of values)
|
|
128
|
-
for (const key of ['id', 'uuid', 'message_id', 'messageId', 'client_id', 'clientId', 'client_user_message_id', 'clientUserMessageId', 'promptId']) {
|
|
129
|
-
const id = field(value || {}, key);
|
|
130
|
-
if (id)
|
|
131
|
-
result.add(id);
|
|
132
|
-
}
|
|
133
|
-
return [...result];
|
|
134
|
-
}
|
|
135
|
-
function at(value) {
|
|
136
|
-
for (const key of ['timestamp', 'created_at', 'createdAt', 'created', 'time']) {
|
|
137
|
-
const found = timestamp(value[key]);
|
|
138
|
-
if (found !== null)
|
|
139
|
-
return found;
|
|
140
|
-
}
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
function isPrompt(content) {
|
|
144
|
-
if (typeof content === 'string')
|
|
145
|
-
return !!content.trim();
|
|
146
|
-
const blocks = items(content);
|
|
147
|
-
return blocks.some((block) => object(block)?.type !== 'tool_result');
|
|
148
|
-
}
|
|
149
|
-
function project(events, sourceRevision, turn) {
|
|
150
|
-
let start = -1;
|
|
151
|
-
if (!turn) {
|
|
152
|
-
for (const [index, event] of events.entries())
|
|
153
|
-
if (event.type === 'boundary')
|
|
154
|
-
start = index;
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
const acceptedAt = timestamp(turn.acceptedAt);
|
|
158
|
-
const exact = events.flatMap((event, index) => event.type === 'boundary' && event.ids.includes(turn.token) ? [index] : []);
|
|
159
|
-
start = exact.length === 1 ? exact[0] : -1;
|
|
160
|
-
if (start < 0 && exact.length === 0 && acceptedAt !== null) {
|
|
161
|
-
const candidates = events.flatMap((event, index) => event.type === 'boundary' && event.at !== null && event.at >= acceptedAt
|
|
162
|
-
? [{ index, at: event.at }] : []);
|
|
163
|
-
const earliest = candidates.reduce((value, candidate) => value === null || candidate.at < value ? candidate.at : value, null);
|
|
164
|
-
const matches = earliest === null ? [] : candidates.filter((candidate) => candidate.at === earliest);
|
|
165
|
-
if (matches.length === 1)
|
|
166
|
-
start = matches[0].index;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
if (turn && start < 0)
|
|
170
|
-
return emptyTrace(turn, sourceRevision);
|
|
171
|
-
let note = null;
|
|
172
|
-
let steps = [];
|
|
173
|
-
for (let index = start + 1; index < events.length; index++) {
|
|
174
|
-
const event = events[index];
|
|
175
|
-
if (event.type === 'boundary')
|
|
176
|
-
return emptyTrace(turn, sourceRevision);
|
|
177
|
-
if (event.type === 'note') {
|
|
178
|
-
note = event.text;
|
|
179
|
-
steps = [];
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
if (event.type === 'call') {
|
|
183
|
-
if (note)
|
|
184
|
-
steps.push(event.step);
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
const step = steps.find((value) => value.id === event.id);
|
|
188
|
-
if (step)
|
|
189
|
-
steps[steps.indexOf(step)] = { ...step, state: 'done' };
|
|
190
|
-
}
|
|
191
|
-
return note
|
|
192
|
-
? { revision: `${turn?.token ?? '0'}:${sourceRevision}`, turnId: turn?.token ?? null, workingNote: note, steps }
|
|
193
|
-
: emptyTrace(turn, sourceRevision);
|
|
194
|
-
}
|
|
195
|
-
function applyRolloutEvent(value, ordinal) {
|
|
196
|
-
const event = object(value);
|
|
197
|
-
const payload = object(event?.payload);
|
|
198
|
-
if (!event || !payload)
|
|
199
|
-
return [];
|
|
200
|
-
const result = [];
|
|
201
|
-
const type = field(payload, 'type');
|
|
202
|
-
if ((event.type === 'event_msg' && type === 'user_message')
|
|
203
|
-
|| (event.type === 'response_item' && (type === 'message' || type === 'input_message') && payload.role === 'user')) {
|
|
204
|
-
result.push({ type: 'boundary', ids: identities(payload, event), at: at(payload) ?? at(event) });
|
|
205
|
-
}
|
|
206
|
-
if (event.type === 'event_msg' && type === 'agent_message' && payload.phase === 'commentary') {
|
|
207
|
-
const note = displayableNote(payload.message ?? payload.text);
|
|
208
|
-
if (note)
|
|
209
|
-
result.push({ type: 'note', text: note });
|
|
210
|
-
}
|
|
211
|
-
if (event.type === 'response_item' && (type === 'custom_tool_call' || type === 'function_call')) {
|
|
212
|
-
const name = field(payload, 'name', 'tool_name') || 'tool';
|
|
213
|
-
const id = field(payload, 'call_id', 'id') || `tool-${ordinal}`;
|
|
214
|
-
result.push({ type: 'call', step: stepFromInput(id, name, payload.input ?? payload.arguments ?? payload.args, 'running') });
|
|
215
|
-
}
|
|
216
|
-
if (event.type === 'response_item' && (type === 'custom_tool_call_output' || type === 'function_call_output')) {
|
|
217
|
-
const id = field(payload, 'call_id', 'id');
|
|
218
|
-
if (id)
|
|
219
|
-
result.push({ type: 'done', id });
|
|
220
|
-
}
|
|
221
|
-
if (event.type === 'event_msg' && type === 'web_search_end') {
|
|
222
|
-
result.push({ type: 'call', step: stepFromInput(field(payload, 'id', 'call_id') || `search-${ordinal}`, 'Search', payload, 'done') });
|
|
223
|
-
}
|
|
224
|
-
return result;
|
|
225
|
-
}
|
|
226
|
-
function applyProjectJsonlEvent(value, ordinal) {
|
|
227
|
-
const entry = object(value);
|
|
228
|
-
const message = object(entry?.message);
|
|
229
|
-
if (!entry || !message)
|
|
230
|
-
return [];
|
|
231
|
-
const result = [];
|
|
232
|
-
if (entry.type === 'user' && message.role === 'user' && isPrompt(message.content))
|
|
233
|
-
result.push({ type: 'boundary', ids: identities(entry, message), at: at(entry) ?? at(message) });
|
|
234
|
-
if (entry.type === 'assistant' && message.role === 'assistant')
|
|
235
|
-
for (const blockValue of items(message.content)) {
|
|
236
|
-
const block = object(blockValue);
|
|
237
|
-
if (block?.type === 'text') {
|
|
238
|
-
const note = displayableNote(block.text);
|
|
239
|
-
if (note)
|
|
240
|
-
result.push({ type: 'note', text: note });
|
|
241
|
-
}
|
|
242
|
-
if (block?.type === 'tool_use') {
|
|
243
|
-
const id = field(block, 'id') || `tool-${ordinal}`;
|
|
244
|
-
result.push({ type: 'call', step: stepFromInput(id, field(block, 'name') || 'tool', block.input, 'running') });
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
if (entry.type === 'user' && message.role === 'user')
|
|
248
|
-
for (const blockValue of items(message.content)) {
|
|
249
|
-
const block = object(blockValue);
|
|
250
|
-
if (block?.type === 'tool_result') {
|
|
251
|
-
const id = field(block, 'tool_use_id');
|
|
252
|
-
if (id)
|
|
253
|
-
result.push({ type: 'done', id });
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
return result;
|
|
257
|
-
}
|
|
258
|
-
function applySessionJsonlEvent(value, ordinal) {
|
|
259
|
-
const entry = object(value);
|
|
260
|
-
const message = object(entry?.message);
|
|
261
|
-
if (!entry || entry.type !== 'message' || !message)
|
|
262
|
-
return [];
|
|
263
|
-
const result = [];
|
|
264
|
-
if (message.role === 'user' && isPrompt(message.content))
|
|
265
|
-
result.push({ type: 'boundary', ids: identities(entry, message), at: at(entry) ?? at(message) });
|
|
266
|
-
if (message.role === 'assistant')
|
|
267
|
-
for (const blockValue of items(message.content)) {
|
|
268
|
-
const block = object(blockValue);
|
|
269
|
-
if (block?.type === 'text') {
|
|
270
|
-
const note = displayableNote(block.text);
|
|
271
|
-
if (note)
|
|
272
|
-
result.push({ type: 'note', text: note });
|
|
273
|
-
}
|
|
274
|
-
if (block?.type === 'toolCall') {
|
|
275
|
-
const id = field(block, 'id') || `tool-${ordinal}`;
|
|
276
|
-
result.push({ type: 'call', step: stepFromInput(id, field(block, 'name') || 'tool', block.arguments, 'running') });
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
if (message.role === 'toolResult') {
|
|
280
|
-
const id = field(message, 'toolCallId');
|
|
281
|
-
if (id)
|
|
282
|
-
result.push({ type: 'done', id });
|
|
283
|
-
}
|
|
284
|
-
return result;
|
|
285
|
-
}
|
|
286
|
-
function completeLines(buffer) {
|
|
287
|
-
const lines = [];
|
|
288
|
-
let start = 0;
|
|
289
|
-
for (let index = 0; index < buffer.length; index++) {
|
|
290
|
-
if (buffer[index] !== 10)
|
|
291
|
-
continue;
|
|
292
|
-
if (index > start)
|
|
293
|
-
lines.push(buffer.subarray(start, index));
|
|
294
|
-
start = index + 1;
|
|
295
|
-
}
|
|
296
|
-
return { lines, remainder: buffer.subarray(start) };
|
|
297
|
-
}
|
|
298
|
-
function readFrom(path, start) {
|
|
299
|
-
let fd = null;
|
|
300
|
-
try {
|
|
301
|
-
const size = statSync(path).size;
|
|
302
|
-
if (size <= start)
|
|
303
|
-
return Buffer.alloc(0);
|
|
304
|
-
const chunk = Buffer.allocUnsafe(size - start);
|
|
305
|
-
fd = openSync(path, 'r');
|
|
306
|
-
readSync(fd, chunk, 0, chunk.length, start);
|
|
307
|
-
return chunk;
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
return null;
|
|
311
|
-
}
|
|
312
|
-
finally {
|
|
313
|
-
if (fd !== null)
|
|
314
|
-
closeSync(fd);
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
function readIncremental(path, apply, turn) {
|
|
318
|
-
if (!path)
|
|
319
|
-
return emptyTrace(turn);
|
|
320
|
-
let size;
|
|
321
|
-
try {
|
|
322
|
-
size = statSync(path).size;
|
|
323
|
-
}
|
|
324
|
-
catch {
|
|
325
|
-
return emptyTrace(turn);
|
|
326
|
-
}
|
|
327
|
-
const prior = cache.get(path);
|
|
328
|
-
const trace = prior && prior.size <= size
|
|
329
|
-
? { ...prior, events: [...prior.events] }
|
|
330
|
-
: { size: 0, remainder: Buffer.alloc(0), events: [] };
|
|
331
|
-
const appended = readFrom(path, trace.size);
|
|
332
|
-
if (appended === null)
|
|
333
|
-
return emptyTrace(turn);
|
|
334
|
-
const { lines, remainder } = completeLines(Buffer.concat([trace.remainder, appended]));
|
|
335
|
-
for (const [index, line] of lines.entries()) {
|
|
336
|
-
try {
|
|
337
|
-
trace.events.push(...apply(JSON.parse(line.toString('utf8')), trace.size + index));
|
|
338
|
-
}
|
|
339
|
-
catch { /* malformed native data is ignored */ }
|
|
340
|
-
}
|
|
341
|
-
trace.size = size;
|
|
342
|
-
trace.remainder = remainder;
|
|
343
|
-
cache.set(path, trace);
|
|
344
|
-
return project(trace.events, String(size), turn);
|
|
345
|
-
}
|
|
346
|
-
const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'sessions');
|
|
347
|
-
export function codexRolloutPath(threadId, root = codexSessionsDir()) {
|
|
348
|
-
for (const year of children(root))
|
|
349
|
-
for (const month of children(join(root, year)))
|
|
350
|
-
for (const day of children(join(root, year, month))) {
|
|
351
|
-
const dir = join(root, year, month, day);
|
|
352
|
-
const file = children(dir).find((name) => name.includes(threadId));
|
|
353
|
-
if (file)
|
|
354
|
-
return join(dir, file);
|
|
355
|
-
}
|
|
356
|
-
return null;
|
|
357
|
-
}
|
|
358
|
-
export function readCodexExecutionTrace(threadId, turn, root = codexSessionsDir()) {
|
|
359
|
-
return readIncremental(codexRolloutPath(threadId, root), applyRolloutEvent, turn);
|
|
360
|
-
}
|
|
361
|
-
const projectTranscriptRoot = () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
|
|
362
|
-
function projectJsonlPath(threadId, root = projectTranscriptRoot()) {
|
|
363
|
-
for (const project of children(root)) {
|
|
364
|
-
const path = join(root, project, `${threadId}.jsonl`);
|
|
365
|
-
try {
|
|
366
|
-
if (statSync(path).isFile())
|
|
367
|
-
return path;
|
|
368
|
-
}
|
|
369
|
-
catch { /* try next */ }
|
|
370
|
-
}
|
|
371
|
-
return null;
|
|
372
|
-
}
|
|
373
|
-
export function readProjectJsonlExecutionTrace(threadId, turn, root = projectTranscriptRoot()) {
|
|
374
|
-
return readIncremental(projectJsonlPath(threadId, root), applyProjectJsonlEvent, turn);
|
|
375
|
-
}
|
|
376
|
-
const sessionJsonlRoot = () => join(process.env.SPEXCODE_PI_AGENT_DIR || join(homedir(), '.pi', 'agent'), 'sessions');
|
|
377
|
-
function sessionJsonlPath(threadId, root = sessionJsonlRoot()) {
|
|
378
|
-
const key = `${root}:${threadId}`;
|
|
379
|
-
const cached = sessionJsonlPaths.get(key);
|
|
380
|
-
if (cached) {
|
|
381
|
-
try {
|
|
382
|
-
if (statSync(cached).isFile())
|
|
383
|
-
return cached;
|
|
384
|
-
}
|
|
385
|
-
catch {
|
|
386
|
-
sessionJsonlPaths.delete(key);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
for (const directory of children(root))
|
|
390
|
-
for (const file of children(join(root, directory))) {
|
|
391
|
-
if (!file.endsWith('.jsonl'))
|
|
392
|
-
continue;
|
|
393
|
-
const path = join(root, directory, file);
|
|
394
|
-
try {
|
|
395
|
-
const header = object(JSON.parse(readFileSync(path, 'utf8').split('\n', 1)[0]));
|
|
396
|
-
if (header?.type === 'session' && header.id === threadId) {
|
|
397
|
-
sessionJsonlPaths.set(key, path);
|
|
398
|
-
return path;
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
catch { /* unreadable entries are not a match */ }
|
|
402
|
-
}
|
|
403
|
-
return null;
|
|
404
|
-
}
|
|
405
|
-
export function readSessionJsonlExecutionTrace(threadId, turn, root = sessionJsonlRoot()) {
|
|
406
|
-
return readIncremental(sessionJsonlPath(threadId, root), applySessionJsonlEvent, turn);
|
|
407
|
-
}
|
|
408
|
-
const localStoreRoot = () => process.env.SPEXCODE_OPENCODE_DATA_DIR
|
|
409
|
-
|| join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode');
|
|
410
|
-
function readLocalExport(threadId) {
|
|
411
|
-
return execFileSync(process.env.SPEXCODE_OPENCODE_CMD || 'opencode', ['export', threadId, '--sanitize'], {
|
|
412
|
-
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
function localStoreRevision(root) {
|
|
416
|
-
try {
|
|
417
|
-
const database = statSync(join(root, 'opencode.db'));
|
|
418
|
-
let writeAheadLog = '0:0';
|
|
419
|
-
try {
|
|
420
|
-
const stat = statSync(join(root, 'opencode.db-wal'));
|
|
421
|
-
writeAheadLog = `${stat.size}:${Math.floor(stat.mtimeMs)}`;
|
|
422
|
-
}
|
|
423
|
-
catch { /* a checkpointed database has no separate write-ahead log */ }
|
|
424
|
-
return `${database.size}:${Math.floor(database.mtimeMs)}:${writeAheadLog}`;
|
|
425
|
-
}
|
|
426
|
-
catch {
|
|
427
|
-
return null;
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
function localStoreEvents(value) {
|
|
431
|
-
const events = [];
|
|
432
|
-
for (const messageValue of items(object(value)?.messages)) {
|
|
433
|
-
const message = object(messageValue);
|
|
434
|
-
const info = object(message?.info);
|
|
435
|
-
if (!message || !info)
|
|
436
|
-
continue;
|
|
437
|
-
if (info.role === 'user')
|
|
438
|
-
events.push({ type: 'boundary', ids: identities(info, message), at: at(object(info.time) || info) });
|
|
439
|
-
if (info.role !== 'assistant')
|
|
440
|
-
continue;
|
|
441
|
-
for (const partValue of items(message.parts)) {
|
|
442
|
-
const part = object(partValue);
|
|
443
|
-
if (part?.type === 'text') {
|
|
444
|
-
const note = displayableNote(part.text);
|
|
445
|
-
if (note)
|
|
446
|
-
events.push({ type: 'note', text: note });
|
|
447
|
-
}
|
|
448
|
-
if (part?.type === 'tool') {
|
|
449
|
-
const state = object(part.state);
|
|
450
|
-
const status = field(state || {}, 'status')?.toLowerCase() || '';
|
|
451
|
-
const id = field(part, 'callID', 'id') || `tool-${events.length}`;
|
|
452
|
-
events.push({ type: 'call', step: stepFromInput(id, field(part, 'tool') || 'tool', state?.input, /completed|error|cancelled/.test(status) ? 'done' : 'running') });
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
return events;
|
|
457
|
-
}
|
|
458
|
-
export function readLocalStoreExecutionTrace(threadId, turn, root = localStoreRoot(), load = readLocalExport) {
|
|
459
|
-
const revision = localStoreRevision(root);
|
|
460
|
-
if (!revision)
|
|
461
|
-
return emptyTrace(turn);
|
|
462
|
-
const key = `${root}:${threadId}`;
|
|
463
|
-
let cached = localStoreCache.get(key);
|
|
464
|
-
if (!cached || cached.revision !== revision) {
|
|
465
|
-
try {
|
|
466
|
-
cached = { revision, events: localStoreEvents(JSON.parse(load(threadId))) };
|
|
467
|
-
localStoreCache.set(key, cached);
|
|
468
|
-
}
|
|
469
|
-
catch {
|
|
470
|
-
return emptyTrace(turn, revision);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
return project(cached.events, revision, turn);
|
|
474
|
-
}
|
|
475
|
-
export const noExecutionTrace = (_threadId, _turn) => null;
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import type { Context } from 'hono';
|
|
2
|
-
import type { ExecutionStep } from './execution-trace.js';
|
|
3
|
-
export type SessionExecution = Readonly<{
|
|
4
|
-
revision: string;
|
|
5
|
-
turnId: string | null;
|
|
6
|
-
workingNote: string | null;
|
|
7
|
-
steps: readonly ExecutionStep[];
|
|
8
|
-
}>;
|
|
9
|
-
export declare function readSessionExecution(id: string): SessionExecution | null;
|
|
10
|
-
export declare function sessionExecutionStream(c: Context): Promise<Response>;
|