spexcode 0.5.8 → 0.5.9
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/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,11 +36,13 @@
|
|
|
36
36
|
"prepack": "node scripts/sync-init-plugins.mjs --check && node scripts/prepack.mjs"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
+
"tsx": "^4.19.2"
|
|
40
|
+
},
|
|
41
|
+
"optionalDependencies": {
|
|
39
42
|
"@hono/node-server": "^1.13.7",
|
|
40
43
|
"@hono/node-ws": "^1.0.4",
|
|
41
44
|
"hono": "^4.6.14",
|
|
42
|
-
"node-pty": "1.2.0-beta.14"
|
|
43
|
-
"tsx": "^4.19.2"
|
|
45
|
+
"node-pty": "1.2.0-beta.14"
|
|
44
46
|
},
|
|
45
47
|
"devDependencies": {
|
|
46
48
|
"@types/node": "^20.16.0",
|
package/spec-cli/bin/spex.mjs
CHANGED
|
@@ -65,17 +65,5 @@ if (args[0] === 'serve' || args[0] === 'dashboard') {
|
|
|
65
65
|
.split(',').map((key) => key.trim()).filter(Boolean)
|
|
66
66
|
for (const key of identityKeys) delete env[key]
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
...(env.SPEXCODE_MAINTENANCE_BROKER_FDS || '').split(','),
|
|
70
|
-
env.SPEXCODE_MAINTENANCE_DELEGATE_FD || '',
|
|
71
|
-
].map(Number).filter((fd) => Number.isInteger(fd) && fd >= 3)
|
|
72
|
-
if (inheritedMaintenanceFds.length) {
|
|
73
|
-
const req = createRequire(join(pkg, 'package.json'))
|
|
74
|
-
const tsxImport = req.resolve('tsx/esm')
|
|
75
|
-
const stdio = Array(Math.max(2, ...inheritedMaintenanceFds) + 1).fill('ignore')
|
|
76
|
-
stdio[0] = stdio[1] = stdio[2] = 'inherit'
|
|
77
|
-
for (const fd of inheritedMaintenanceFds) stdio[fd] = 'inherit'
|
|
78
|
-
spawn(process.execPath, ['--import', tsxImport, cli, ...args], { stdio, env })
|
|
79
|
-
.on('exit', (code) => process.exit(code ?? 0))
|
|
80
|
-
} else spawn(process.execPath, [tsxCli, cli, ...args], { stdio: 'inherit', env })
|
|
68
|
+
spawn(process.execPath, [tsxCli, cli, ...args], { stdio: 'inherit', env })
|
|
81
69
|
.on('exit', (code) => process.exit(code ?? 0))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ mark-active - the SINGLE freshness hook, wired to BOTH UserPromptSubmit and PreToolUse. It branches
|
|
3
|
+
# on ONE structured signal read straight from the hook payload (stdin JSON), so the state is HARD — never
|
|
4
|
+
# text-sniffed from the TUI:
|
|
5
|
+
# the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
|
|
6
|
+
# (the deterministic capture of a question).
|
|
7
|
+
# any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/note).
|
|
8
|
+
# WHAT counts as "asking" is the [[harness-adapter]]'s call (Claude: the AskUserQuestion tool; Codex: the
|
|
9
|
+
# request_user_input tool) — read via hp_is_ask, so this hook never names a harness tool.
|
|
10
|
+
# Fires BEFORE the tool runs, so a `spex session done` declaration (itself a tool) lands AFTER this and wins;
|
|
11
|
+
# the next real tool flips back to active, forcing a fresh Stop-gate declaration. Pure shell (no node/tsx) so
|
|
12
|
+
# it stays cheap on every tool call — it value-replaces status/proposal/note in session.json with sed, never jq.
|
|
13
|
+
# @@@ global store - state lives NOT in the worktree but in the per-session GLOBAL record session.json, keyed
|
|
14
|
+
# by the harness session_id, grouped per-project (see hp_store_dir). GATED on `governed`: a user-self-launched
|
|
15
|
+
# (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
|
|
16
|
+
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
17
|
+
payload=$(cat 2>/dev/null)
|
|
18
|
+
# an IN-PROCESS SUBAGENT's tool call (Claude's Task tool) fires the parent's hooks with the PARENT's
|
|
19
|
+
# session_id — flipping here let a supervising parent's own subagents erase its declared park/ask within
|
|
20
|
+
# seconds and race the stop-gate into "undeclared stop" (issue #60). A subagent working is not the parent
|
|
21
|
+
# agent ACTING, so its calls never touch the record; the parent's own next tool call still flips. The
|
|
22
|
+
# discriminator is the payload's own top-level agent_id stamp (hp_is_subagent) — deterministic, never a
|
|
23
|
+
# timing window.
|
|
24
|
+
[ -n "$(hp_is_subagent "$payload")" ] && exit 0
|
|
25
|
+
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
26
|
+
sdir=$(hp_store_dir "$sid") || exit 0
|
|
27
|
+
rec="$sdir/session.json"
|
|
28
|
+
# board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
|
|
29
|
+
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
30
|
+
|
|
31
|
+
jget() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$rec" 2>/dev/null | head -1; }
|
|
32
|
+
|
|
33
|
+
if [ -n "$(hp_is_ask "$payload")" ]; then
|
|
34
|
+
status=asking
|
|
35
|
+
note=$(hp_ask_note "$payload") # first question's text → the note (best-effort)
|
|
36
|
+
else
|
|
37
|
+
status=active
|
|
38
|
+
note=
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
# cheap path: already active with nothing stale to clear → no-op (the common every-tool case).
|
|
42
|
+
[ "$status" = active ] && [ "$(jget status)" = active ] && [ -z "$(jget proposal)" ] && [ -z "$(jget note)" ] && exit 0
|
|
43
|
+
|
|
44
|
+
# value-replace status + clear proposal + (re)set note, in place. The record is written one-field-per-line
|
|
45
|
+
# with these keys ALWAYS present (sessions.ts writeRecord), so each is a single value substitution — no key
|
|
46
|
+
# add/remove, no JSON parser. Escape \ / & in the note for the sed REPLACEMENT (the note never contains ").
|
|
47
|
+
note_esc=$(printf '%s' "$note" | sed 's/[\\/&]/\\&/g')
|
|
48
|
+
tmp=$(mktemp) || exit 0
|
|
49
|
+
sed -e "s/\(\"status\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$status\"/" \
|
|
50
|
+
-e "s/\(\"proposal\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"\"/" \
|
|
51
|
+
-e "s/\(\"note\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$note_esc\"/" \
|
|
52
|
+
"$rec" > "$tmp" && mv "$tmp" "$rec"
|
|
53
|
+
exit 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ mark-active - the SINGLE freshness hook, wired to BOTH UserPromptSubmit and PreToolUse. It branches
|
|
3
|
+
# on ONE structured signal read straight from the hook payload (stdin JSON), so the state is HARD — never
|
|
4
|
+
# text-sniffed from the TUI:
|
|
5
|
+
# the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
|
|
6
|
+
# (the deterministic capture of a question).
|
|
7
|
+
# any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/note).
|
|
8
|
+
# WHAT counts as "asking" is the [[harness-adapter]]'s call (Claude: the AskUserQuestion tool; Codex: the
|
|
9
|
+
# request_user_input tool) — read via hp_is_ask, so this hook never names a harness tool.
|
|
10
|
+
# Fires BEFORE the tool runs, so a `spex session done` declaration (itself a tool) lands AFTER this and wins;
|
|
11
|
+
# the next real tool flips back to active, forcing a fresh Stop-gate declaration. Pure shell (no node/tsx) so
|
|
12
|
+
# it stays cheap on every tool call — it value-replaces status/proposal/note in session.json with sed, never jq.
|
|
13
|
+
# @@@ global store - state lives NOT in the worktree but in the per-session GLOBAL record session.json, keyed
|
|
14
|
+
# by the harness session_id, grouped per-project (see hp_store_dir). GATED on `governed`: a user-self-launched
|
|
15
|
+
# (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
|
|
16
|
+
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
17
|
+
payload=$(cat 2>/dev/null)
|
|
18
|
+
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
19
|
+
sdir=$(hp_store_dir "$sid") || exit 0
|
|
20
|
+
rec="$sdir/session.json"
|
|
21
|
+
# board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
|
|
22
|
+
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
23
|
+
|
|
24
|
+
jget() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$rec" 2>/dev/null | head -1; }
|
|
25
|
+
|
|
26
|
+
if [ -n "$(hp_is_ask "$payload")" ]; then
|
|
27
|
+
status=asking
|
|
28
|
+
note=$(hp_ask_note "$payload") # first question's text → the note (best-effort)
|
|
29
|
+
else
|
|
30
|
+
status=active
|
|
31
|
+
note=
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# cheap path: already active with nothing stale to clear → no-op (the common every-tool case).
|
|
35
|
+
[ "$status" = active ] && [ "$(jget status)" = active ] && [ -z "$(jget proposal)" ] && [ -z "$(jget note)" ] && exit 0
|
|
36
|
+
|
|
37
|
+
# value-replace status + clear proposal + (re)set note, in place. The record is written one-field-per-line
|
|
38
|
+
# with these keys ALWAYS present (sessions.ts writeRecord), so each is a single value substitution — no key
|
|
39
|
+
# add/remove, no JSON parser. Escape \ / & in the note for the sed REPLACEMENT (the note never contains ").
|
|
40
|
+
note_esc=$(printf '%s' "$note" | sed 's/[\\/&]/\\&/g')
|
|
41
|
+
tmp=$(mktemp) || exit 0
|
|
42
|
+
sed -e "s/\(\"status\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$status\"/" \
|
|
43
|
+
-e "s/\(\"proposal\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"\"/" \
|
|
44
|
+
-e "s/\(\"note\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$note_esc\"/" \
|
|
45
|
+
"$rec" > "$tmp" && mv "$tmp" "$rec"
|
|
46
|
+
exit 0
|
|
@@ -28,12 +28,14 @@ event="${1:?usage: dispatch.sh <harness> <Event>}"
|
|
|
28
28
|
export SPEXCODE_HARNESS="$harness"
|
|
29
29
|
# the harness.sh path (the adapter's shell mirror) — sibling of this script; hook handlers source it, and we
|
|
30
30
|
# source it here too for hp_runtime_dir (the per-project store dir).
|
|
31
|
-
|
|
31
|
+
hook_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
32
|
+
tool_root="$(cd "$hook_root/.." && pwd)"
|
|
33
|
+
export SPEXCODE_HARNESS_LIB="$hook_root/harness.sh"
|
|
32
34
|
. "$SPEXCODE_HARNESS_LIB"
|
|
33
35
|
if [ -n "${SPEX:-}" ]; then
|
|
34
36
|
read -r -a spex_cmd <<< "$SPEX"
|
|
35
37
|
else
|
|
36
|
-
spex_cmd=("$
|
|
38
|
+
spex_cmd=("$tool_root/bin/spex.mjs")
|
|
37
39
|
fi
|
|
38
40
|
proj="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
39
41
|
# the manifest lives in THIS tree's materialize slot of the GLOBAL per-project store (mirrors layout.treeSlotDir),
|
|
@@ -67,30 +69,24 @@ fi
|
|
|
67
69
|
[ -f "$manifest" ] || exit 0 # no manifest yet (materialize never ran) → nothing to dispatch
|
|
68
70
|
input="$(cat 2>/dev/null || true)" # capture stdin ONCE; each handler gets its own copy
|
|
69
71
|
err="/tmp/.spex-hook-$$.err" # per-dispatch (pid-unique) stderr capture; no cross-session race
|
|
70
|
-
|
|
71
|
-
cleanup() {
|
|
72
|
-
[ -z "$ticket" ] || "${spex_cmd[@]}" internal maintenance-end "$ticket" "$$" >/dev/null 2>&1 || true
|
|
73
|
-
rm -f "$err"
|
|
74
|
-
}
|
|
72
|
+
cleanup() { rm -f "$err"; }
|
|
75
73
|
trap cleanup EXIT
|
|
76
74
|
trap 'exit 130' INT
|
|
77
75
|
trap 'exit 143' HUP TERM
|
|
78
|
-
sid="$(hp_session_id "$input")"
|
|
79
|
-
[ -n "$sid" ] || sid=hook-dispatch
|
|
80
|
-
if ! ticket="$("${spex_cmd[@]}" internal maintenance-begin "$$" "$sid" 2>"$err")"; then
|
|
81
|
-
reason="$(cat "$err" 2>/dev/null)"
|
|
82
|
-
[ -n "$reason" ] || reason='maintenance_active: hook dispatch was not admitted'
|
|
83
|
-
escaped="$(printf '%s' "$reason" | sed 's/\\/\\\\/g; s/"/\\"/g')"
|
|
84
|
-
printf '{"decision":"block","reason":"%s"}' "$escaped"
|
|
85
|
-
[ "$SPEXCODE_HARNESS" = codex ] && printf '%s\n' "$reason" >&2
|
|
86
|
-
exit 2
|
|
87
|
-
fi
|
|
88
|
-
export SPEXCODE_MAINTENANCE_PARENT_TICKET="$ticket"
|
|
89
76
|
rc=0
|
|
90
77
|
# manifest line: event<TAB>order<TAB>block<TAB>script (pre-sorted by event,order,script)
|
|
91
78
|
while IFS=$'\t' read -r ev order block script; do
|
|
92
79
|
[ "$ev" = "$event" ] || continue
|
|
93
|
-
|
|
80
|
+
handler="$proj/$script"
|
|
81
|
+
# A seeded core hook is tracked project source, so package replacement cannot safely overwrite it. These
|
|
82
|
+
# byte-exact default revisions compose an ask note into JSON with sed; route only them to the package
|
|
83
|
+
# implementation. `cmp` makes a user-modified hook ineligible without a platform-specific hash utility.
|
|
84
|
+
if [ "$script" = '.spec/project/.plugins/core/mark-active/mark-active.sh' ] &&
|
|
85
|
+
{ cmp -s "$handler" "$hook_root/compat/mark-active-sed-v0.fixture" ||
|
|
86
|
+
cmp -s "$handler" "$hook_root/compat/mark-active-0.5.2-eef1.fixture"; }; then
|
|
87
|
+
handler="$tool_root/templates/spec/project/.plugins/core/mark-active/mark-active.sh"
|
|
88
|
+
fi
|
|
89
|
+
out="$(printf '%s' "$input" | bash "$handler" 2>"$err")"; code=$?
|
|
94
90
|
[ -n "$out" ] && printf '%s' "$out"
|
|
95
91
|
if [ "$block" = "true" ] && { [ "$code" = "2" ] || printf '%s' "$out" | grep -q '"decision"[[:space:]]*:[[:space:]]*"block"'; }; then
|
|
96
92
|
cat "$err" >&2
|
package/spec-cli/src/anchors.ts
CHANGED
|
@@ -382,6 +382,22 @@ export const extOf = (path: string): string => {
|
|
|
382
382
|
|
|
383
383
|
// ---- anchor resolution (language-agnostic) ----
|
|
384
384
|
export type AnchorResolution = { ok: Unit } | { dead: true } | { ambiguous: number }
|
|
385
|
+
// @@@ ONE classification for "does this selector resolve here" - the gate (reading the candidate TIP) and the
|
|
386
|
+
// freshness probe (reading the WORKING TREE) both have to answer it, and each used to branch on
|
|
387
|
+
// dead/ambiguous itself. Two copies of a verdict rule drift, and these two already had: the gate warns on a
|
|
388
|
+
// type-only unit, the probe never noticed one. The TEXT each reads stays its own business — a gate must judge
|
|
389
|
+
// the tree it is gating, never a dirty worktree — but the VERDICT is decided once, here, so a new outcome
|
|
390
|
+
// cannot reach one caller and silently miss the other.
|
|
391
|
+
export type SelectorVerdict = { selector: string } & ({ ok: Unit } | { dead: true } | { ambiguous: number })
|
|
392
|
+
export function resolveSelectors(units: Unit[], selectors: readonly string[]): SelectorVerdict[] {
|
|
393
|
+
return selectors.map((selector) => {
|
|
394
|
+
const r = resolveAnchor(units, selector)
|
|
395
|
+
if ('dead' in r) return { selector, dead: true as const }
|
|
396
|
+
if ('ambiguous' in r) return { selector, ambiguous: r.ambiguous }
|
|
397
|
+
return { selector, ok: r.ok }
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
|
|
385
401
|
export function resolveAnchor(units: Unit[], symbol: string): AnchorResolution {
|
|
386
402
|
const hits = units.filter((u) => u.name === symbol)
|
|
387
403
|
if (!hits.length) return { dead: true }
|
|
@@ -418,9 +434,10 @@ export function diffHunkRanges(patch: string, side: 'old' | 'new' = 'new'): [num
|
|
|
418
434
|
type FileRevisionUnits = { units: Unit[] } | { absent: true } | { unparseable: string }
|
|
419
435
|
const fileRevisionUnitMemo = new Map<string, FileRevisionUnits>()
|
|
420
436
|
const MEMO_MAX = 4096
|
|
437
|
+
const fileRevisionMemoKey = (objectFormat: string, oid: string, x: Extractor, path: string) => `${objectFormat}\0${oid}\0${x.memoKey(path)}`
|
|
421
438
|
async function unitsAtFileRevision(commit: string, path: string, x: Extractor, objectFormat: string, oid: string | null, text?: string): Promise<FileRevisionUnits> {
|
|
422
439
|
if (!oid) return { absent: true }
|
|
423
|
-
const key =
|
|
440
|
+
const key = fileRevisionMemoKey(objectFormat, oid, x, path)
|
|
424
441
|
const hit = fileRevisionUnitMemo.get(key)
|
|
425
442
|
if (hit) return hit
|
|
426
443
|
if (text === undefined) throw new Error(`git cat-file --batch omitted object ${oid} for ${commit}:${path}`)
|
|
@@ -435,14 +452,50 @@ async function unitsAtFileRevision(commit: string, path: string, x: Extractor, o
|
|
|
435
452
|
// retain one before-image per parent and only all-parent authored rows; an owned line never widens to an
|
|
436
453
|
// adjacent inherited line merely because Git placed both in one `@@@` hunk.
|
|
437
454
|
type HunkRanges = { after: DiffLineRange[]; before: DiffLineRange[][] }
|
|
455
|
+
// @@@ RANGE_SEMANTICS - hunk ranges must not depend on ambient Git state, because a range that moves with
|
|
456
|
+
// config or attributes is neither a sound anchor verdict nor a reusable fact. Outside the commit, three
|
|
457
|
+
// classes decide them: presentation (`.gitattributes` `-diff` prints `Binary files … differ` with no `@@` at
|
|
458
|
+
// all — measured: one hunk bare, ZERO under `src/x.py -diff`, so an attribute edit silently disabled an
|
|
459
|
+
// anchored contract's drift; a textconv or external driver replaces the text compared), the ALGORITHM and
|
|
460
|
+
// its heuristics (`diff.algorithm`, `diff.indentHeuristic` — both repo config), and hunk COALESCING
|
|
461
|
+
// (`diff.interHunkContext`). All are pinned here, on the two readers this node owns.
|
|
462
|
+
//
|
|
463
|
+
// The algorithm's VALUE is a product choice, not a formality, because the algorithms genuinely disagree:
|
|
464
|
+
// on `c a c a` -> `c c a a a`, myers authors new lines 4-5 and deletes old line 2, while histogram authors
|
|
465
|
+
// new lines 2-3 and deletes old line 3 — so a unit on line 3 is a HIT under histogram and a MISS under
|
|
466
|
+
// myers, on one commit. histogram is pinned because it aligns the change with the unit that actually moved,
|
|
467
|
+
// which for a BLOCKING gate is the conservative direction; myers (Git's default) would silently miss that
|
|
468
|
+
// drift. So this pin is not merely determinism: it settles which reading a verdict means.
|
|
469
|
+
// `--no-color` is not cosmetic here: under an ambient `color.ui=always` Git prefixes every `@@` with an
|
|
470
|
+
// ANSI escape, so a `/^@@/` parse finds NO hunks and every anchored path reads as "nothing changed" — a
|
|
471
|
+
// silent zero-drift verdict for the whole corpus, and one that would then be memoized. `-M -l0` pins rename
|
|
472
|
+
// detection with no candidate limit, matching the identity stream this engine's events come from, so a large
|
|
473
|
+
// rename set cannot quietly change which pairs are compared.
|
|
474
|
+
const RANGE_SEMANTICS = [
|
|
475
|
+
'--text', '--no-textconv', '--no-ext-diff', '--no-color',
|
|
476
|
+
'--diff-algorithm=histogram', '--no-indent-heuristic', '--inter-hunk-context=0',
|
|
477
|
+
'-M', '-l0',
|
|
478
|
+
] as const
|
|
438
479
|
const hunkMemo = new Map<string, HunkRanges>()
|
|
439
|
-
|
|
480
|
+
// @@@ the reusable fact is a diff of ORDERED IMAGES, not of a commit id - a commit id is not immutable
|
|
481
|
+
// interpretation: `refs/replace` can swap the object, and a graft or an unshallow can change its parents, so
|
|
482
|
+
// one (commit,path) can name two different diffs inside one process. The inputs that actually decide the
|
|
483
|
+
// hunks are the result image and each parent image, in order, each identified by its resolved blob oid and
|
|
484
|
+
// historical path. Keying on those makes the key move exactly when an input does, and the oids are already
|
|
485
|
+
// resolved by this read's one `cat-file --batch-check`, so completeness costs no extra child and no new state.
|
|
486
|
+
const ABSENT_IMAGE = '-'
|
|
487
|
+
const hunkMemoKey = (images: string[]) => images.join('\0')
|
|
488
|
+
function rememberHunks(key: string, ranges: HunkRanges): HunkRanges {
|
|
489
|
+
if (hunkMemo.size >= MEMO_MAX) hunkMemo.clear()
|
|
490
|
+
hunkMemo.set(key, ranges)
|
|
491
|
+
return ranges
|
|
492
|
+
}
|
|
493
|
+
async function hunksAt(root: string, event: DriftPathEvent, key: string): Promise<HunkRanges> {
|
|
440
494
|
const paths = [...new Set([event.historicalPath, ...event.parents.map((parent) => parent.historicalPath)])]
|
|
441
|
-
const key = `${event.commit}\0${paths.join('\x1e')}`
|
|
442
495
|
const hit = hunkMemo.get(key)
|
|
443
496
|
if (hit) return hit
|
|
444
497
|
const merge = event.parents.length > 1
|
|
445
|
-
const out = await gitRequiredA(['-C', root, '-c', 'core.quotePath=false', 'show', '--cc', '--combined-all-paths', '--unified=0',
|
|
498
|
+
const out = await gitRequiredA(['-C', root, '-c', 'core.quotePath=false', 'show', '--cc', '--combined-all-paths', '--unified=0', ...RANGE_SEMANTICS, '--format=', event.commit,
|
|
446
499
|
...(merge ? [] : ['--', ...paths])],
|
|
447
500
|
`cannot derive anchor hunks for ${event.commit}:${event.historicalPath}`)
|
|
448
501
|
let ranges: HunkRanges = { after: [], before: [[]] }
|
|
@@ -458,18 +511,35 @@ async function hunksAt(root: string, event: DriftPathEvent): Promise<HunkRanges>
|
|
|
458
511
|
if (newCount > 0) ranges.after.push([newStart, newStart + newCount - 1])
|
|
459
512
|
}
|
|
460
513
|
}
|
|
461
|
-
|
|
462
|
-
hunkMemo.set(key, ranges)
|
|
463
|
-
return ranges
|
|
514
|
+
return rememberHunks(key, ranges)
|
|
464
515
|
}
|
|
465
516
|
|
|
466
517
|
// Ordinary commits whose two images keep one path share a single query. Renames and merges retain distinct
|
|
467
|
-
// image paths, so they stay on hunksAt's exact event-shaped query.
|
|
468
|
-
|
|
518
|
+
// image paths, so they stay on hunksAt's exact event-shaped query. Commits ride argv here, so a build-wide
|
|
519
|
+
// batch is split to stay clear of the kernel's exec argument limit; the records are order-independent.
|
|
520
|
+
//
|
|
521
|
+
// @@@ the demand set is the MISSES - a hunk is reusable by the identity of the IMAGES it diffed, never by the
|
|
522
|
+
// commit id (see hunkMemoKey): under that identity, plus this seam's pinned interpretation, the fact cannot be
|
|
523
|
+
// reinterpreted inside one process, so the batch may ask git only for what it has not read. Asking for the
|
|
524
|
+
// whole window again is what made a repeated read cost the CORPUS instead of the movement: a re-lint after one
|
|
525
|
+
// trunk commit or one dirty edit re-forked one `log --patch` per anchored path (22 here) to re-derive
|
|
526
|
+
// byte-identical answers.
|
|
527
|
+
const HUNK_COMMIT_CHUNK = 400
|
|
528
|
+
async function hunksAtMany(root: string, path: string, entries: Map<string, string>): Promise<Map<string, HunkRanges>> {
|
|
469
529
|
const result = new Map<string, HunkRanges>()
|
|
470
|
-
const ordinary = [
|
|
530
|
+
const ordinary: string[] = []
|
|
531
|
+
for (const [commit, key] of entries) {
|
|
532
|
+
const hit = hunkMemo.get(key)
|
|
533
|
+
if (hit) result.set(commit, hit)
|
|
534
|
+
else ordinary.push(commit)
|
|
535
|
+
}
|
|
471
536
|
if (!ordinary.length) return result
|
|
472
|
-
|
|
537
|
+
for (let cursor = 0; cursor < ordinary.length; cursor += HUNK_COMMIT_CHUNK)
|
|
538
|
+
await hunkRecordsInto(root, ordinary.slice(cursor, cursor + HUNK_COMMIT_CHUNK), path, entries, result)
|
|
539
|
+
return result
|
|
540
|
+
}
|
|
541
|
+
async function hunkRecordsInto(root: string, ordinary: string[], path: string, keys: Map<string, string>, result: Map<string, HunkRanges>): Promise<void> {
|
|
542
|
+
const out = await gitRequiredA(['-C', root, '-c', 'core.quotePath=false', 'log', '--no-walk', '--no-merges', '--patch', '--unified=0', ...RANGE_SEMANTICS,
|
|
473
543
|
`--format=${RS}%H`, ...ordinary, '--', path], `cannot derive anchor hunks for ${path}`)
|
|
474
544
|
for (const rec of out.split(RS)) {
|
|
475
545
|
const normalized = rec.replace(/^\n/, '')
|
|
@@ -485,9 +555,9 @@ async function hunksAtMany(root: string, commits: string[], path: string): Promi
|
|
|
485
555
|
if (oldCount > 0) ranges.before[0].push([oldStart, oldStart + oldCount - 1])
|
|
486
556
|
if (newCount > 0) ranges.after.push([newStart, newStart + newCount - 1])
|
|
487
557
|
}
|
|
488
|
-
|
|
558
|
+
const key = keys.get(hash)
|
|
559
|
+
result.set(hash, key === undefined ? ranges : rememberHunks(key, ranges))
|
|
489
560
|
}
|
|
490
|
-
return result
|
|
491
561
|
}
|
|
492
562
|
|
|
493
563
|
// the drift window of an anchored file: every commit to `path` not reachable from the spec's version
|
|
@@ -510,67 +580,126 @@ type AnchorRevision = { commit: string; path: string }
|
|
|
510
580
|
const anchorRevisionKey = ({ commit, path }: AnchorRevision) => `${commit}\0${path}`
|
|
511
581
|
|
|
512
582
|
// One lint can judge several selectors with overlapping windows. Their Git images and ordinary hunks are
|
|
513
|
-
//
|
|
514
|
-
|
|
583
|
+
// reusable under the identity of those images, so the batch owns them once and each query keeps its own
|
|
584
|
+
// selector verdict.
|
|
585
|
+
async function runAnchorQueries(root: string, queries: AnchorHitQuery[], regs: Extractor[], stopAtFirstHit: boolean): Promise<AnchorHit[][]> {
|
|
515
586
|
if (!queries.length) return []
|
|
516
587
|
const objectFormat = gitObjectFormat(root)
|
|
517
588
|
const revisions = new Map<string, AnchorRevision>()
|
|
518
|
-
const
|
|
589
|
+
const ordinaryEvents: { path: string; commit: string; event: DriftPathEvent }[] = []
|
|
519
590
|
for (const { win } of queries) for (const event of win) {
|
|
520
591
|
const refs = [{ commit: event.commit, path: event.historicalPath }, ...event.parents.map(({ commit, historicalPath }) => ({ commit, path: historicalPath }))]
|
|
521
592
|
for (const ref of refs) revisions.set(anchorRevisionKey(ref), ref)
|
|
522
593
|
if (event.parents.length > 1 || event.parents.some((parent) => parent.historicalPath !== event.historicalPath)) continue
|
|
523
|
-
|
|
524
|
-
commits.add(event.commit)
|
|
525
|
-
ordinaryByPath.set(event.historicalPath, commits)
|
|
594
|
+
ordinaryEvents.push({ path: event.historicalPath, commit: event.commit, event })
|
|
526
595
|
}
|
|
527
596
|
const refs = [...revisions.values()]
|
|
528
|
-
const oids = batchRevisionOids(root, refs.map(({ commit, path }) => `${commit}:${path}`))
|
|
529
|
-
|
|
597
|
+
const oids = await batchRevisionOids(root, refs.map(({ commit, path }) => `${commit}:${path}`))
|
|
598
|
+
// Every image this read will diff is now resolved, so the reusable hunk fact can be named by the identity
|
|
599
|
+
// that actually decides it: the result image and each parent image, in order, oid + historical path.
|
|
600
|
+
const oidByRef = new Map<string, string | null>()
|
|
601
|
+
for (let index = 0; index < refs.length; index++) oidByRef.set(anchorRevisionKey(refs[index]), oids[index])
|
|
602
|
+
const imageOf = (commit: string, path: string) => `${oidByRef.get(anchorRevisionKey({ commit, path })) ?? ABSENT_IMAGE}:${path}`
|
|
603
|
+
const imageIdentity = (event: DriftPathEvent) => hunkMemoKey([
|
|
604
|
+
imageOf(event.commit, event.historicalPath),
|
|
605
|
+
...event.parents.map(({ commit, historicalPath }) => imageOf(commit, historicalPath)),
|
|
606
|
+
])
|
|
607
|
+
const ordinaryByPath = new Map<string, Map<string, string>>()
|
|
608
|
+
for (const { path, commit, event } of ordinaryEvents) {
|
|
609
|
+
const entries = ordinaryByPath.get(path) ?? new Map<string, string>()
|
|
610
|
+
entries.set(commit, imageIdentity(event))
|
|
611
|
+
ordinaryByPath.set(path, entries)
|
|
612
|
+
}
|
|
613
|
+
const ordinaryHunks = new Map<string, Map<string, HunkRanges>>()
|
|
614
|
+
for (const [path, entries] of ordinaryByPath) ordinaryHunks.set(path, await hunksAtMany(root, path, entries))
|
|
615
|
+
// The image's own memo answers before any bytes move, so the read asks git only for the revisions this
|
|
616
|
+
// process has not parsed yet — the retention half of the same rule the hunk demand set follows.
|
|
530
617
|
const units = new Map<string, FileRevisionUnits>()
|
|
531
|
-
|
|
532
|
-
const ref
|
|
533
|
-
const
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
618
|
+
const primeUnits = async (keys: Set<string>): Promise<void> => {
|
|
619
|
+
const pending: { key: string; ref: AnchorRevision; oid: string; x: Extractor }[] = []
|
|
620
|
+
const wanted = new Set<string>()
|
|
621
|
+
for (const key of keys) {
|
|
622
|
+
if (units.has(key)) continue
|
|
623
|
+
const ref = revisions.get(key)!, oid = oidByRef.get(key) ?? null
|
|
624
|
+
const x = extractorFor(regs, extOf(ref.path))
|
|
625
|
+
const ready = x?.ready()
|
|
626
|
+
if (!x || ready !== true) {
|
|
627
|
+
units.set(key, { unparseable: !x ? `no designated extractor for ${ref.path}` : String(ready) })
|
|
628
|
+
continue
|
|
629
|
+
}
|
|
630
|
+
if (!oid) { units.set(key, { absent: true }); continue }
|
|
631
|
+
const hit = fileRevisionUnitMemo.get(fileRevisionMemoKey(objectFormat, oid, x, ref.path))
|
|
632
|
+
if (hit) { units.set(key, hit); continue }
|
|
633
|
+
pending.push({ key, ref, oid, x }); wanted.add(oid)
|
|
538
634
|
}
|
|
539
|
-
|
|
635
|
+
if (!pending.length) return
|
|
636
|
+
const blobs = await batchBlobTexts(root, [...wanted])
|
|
637
|
+
for (const { key, ref, oid, x } of pending)
|
|
638
|
+
units.set(key, await unitsAtFileRevision(ref.commit, ref.path, x, objectFormat, oid, blobs.get(oid)))
|
|
540
639
|
}
|
|
541
|
-
const ordinaryHunks = new Map<string, Map<string, HunkRanges>>()
|
|
542
|
-
for (const [path, commits] of ordinaryByPath) ordinaryHunks.set(path, await hunksAtMany(root, [...commits], path))
|
|
543
640
|
const intersects = (ranges: DiffLineRange[], candidates: Unit[]) =>
|
|
544
641
|
ranges.some(([start, end]) => candidates.some((unit) => start <= unit.end && unit.start <= end))
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
642
|
+
// @@@ the scan advances in DOUBLING corpus-wide rounds - an existence read may stop at its window's first
|
|
643
|
+
// hit, but which event that is cannot be known before the images are parsed, so the demand set has to be
|
|
644
|
+
// discovered rather than declared. Each round still asks the WHOLE unsettled corpus at once (never one
|
|
645
|
+
// reading at a time, which is what would re-fork the batch per unit), and doubling bounds the rounds by hit
|
|
646
|
+
// DEPTH, not by how many readings are asked. An enumeration read takes its whole remaining window in one
|
|
647
|
+
// slice, so it keeps the single-round shape it has always had.
|
|
648
|
+
const runs = queries.map(() => ({ hits: new Map<string, { selectors: Set<string>; unparseable?: string }>(), cursor: 0, settled: false }))
|
|
649
|
+
for (let chunk = 1; ; chunk *= 2) {
|
|
650
|
+
const slices: { run: typeof runs[number]; symbols: string[]; events: DriftPathEvent[] }[] = []
|
|
651
|
+
for (let index = 0; index < queries.length; index++) {
|
|
652
|
+
const run = runs[index], { win, symbols } = queries[index]
|
|
653
|
+
if (run.settled || run.cursor >= win.length) continue
|
|
654
|
+
slices.push({ run, symbols, events: win.slice(run.cursor, run.cursor + (stopAtFirstHit ? chunk : win.length - run.cursor)) })
|
|
655
|
+
}
|
|
656
|
+
if (!slices.length) break
|
|
657
|
+
const keys = new Set<string>()
|
|
658
|
+
for (const { events } of slices) for (const event of events) {
|
|
659
|
+
keys.add(anchorRevisionKey({ commit: event.commit, path: event.historicalPath }))
|
|
660
|
+
for (const parent of event.parents) keys.add(anchorRevisionKey({ commit: parent.commit, path: parent.historicalPath }))
|
|
661
|
+
}
|
|
662
|
+
await primeUnits(keys)
|
|
663
|
+
for (const { run, symbols, events } of slices) {
|
|
664
|
+
for (const event of events) {
|
|
665
|
+
run.cursor++
|
|
666
|
+
const after = units.get(anchorRevisionKey({ commit: event.commit, path: event.historicalPath }))!
|
|
667
|
+
const before = event.parents.map(({ commit, historicalPath }) => units.get(anchorRevisionKey({ commit, path: historicalPath }))!)
|
|
668
|
+
const ranges = ordinaryHunks.get(event.historicalPath)?.get(event.commit)
|
|
669
|
+
?? await hunksAt(root, event, imageIdentity(event))
|
|
670
|
+
if (event.parents.length && ranges.before.length !== before.length)
|
|
671
|
+
throw new Error(`anchor diff for ${event.commit}:${event.historicalPath} has ${ranges.before.length} parent ranges for ${before.length} parents`)
|
|
672
|
+
const hit = run.hits.get(event.commit) ?? { selectors: new Set<string>() }
|
|
673
|
+
const broken = [after, ...before].find((image) => 'unparseable' in image)
|
|
674
|
+
if (broken && 'unparseable' in broken) {
|
|
675
|
+
for (const symbol of symbols) hit.selectors.add(symbol)
|
|
676
|
+
hit.unparseable = broken.unparseable
|
|
677
|
+
} else {
|
|
678
|
+
for (const symbol of symbols) {
|
|
679
|
+
const afterUnits = 'units' in after ? after.units.filter((unit) => unit.name === symbol) : []
|
|
680
|
+
const authoredAfter = intersects(ranges.after, afterUnits)
|
|
681
|
+
const authoredBefore = before.length > 0 && before.every((image, parent) =>
|
|
682
|
+
'units' in image && intersects(ranges.before[parent], image.units.filter((unit) => unit.name === symbol)))
|
|
683
|
+
if (authoredAfter || authoredBefore) hit.selectors.add(symbol)
|
|
684
|
+
}
|
|
567
685
|
}
|
|
686
|
+
if (hit.selectors.size) run.hits.set(event.commit, hit)
|
|
687
|
+
if (stopAtFirstHit && run.hits.size) { run.settled = true; break }
|
|
568
688
|
}
|
|
569
|
-
if (hit.selectors.size) hits.set(event.commit, hit)
|
|
570
689
|
}
|
|
571
|
-
results.push([...hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) })))
|
|
572
690
|
}
|
|
573
|
-
return
|
|
691
|
+
return runs.map((run) => [...run.hits].map(([commit, hit]) => ({ commit, selectors: [...hit.selectors], ...(hit.unparseable ? { unparseable: hit.unparseable } : {}) })))
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// The two consumers ask DIFFERENT questions of this engine, so they enter through different doors rather
|
|
695
|
+
// than through one call with a mode flag. Enumeration must scan every window event because its answer IS the
|
|
696
|
+
// per-commit, per-selector list; existence is discharged by the first hit, and scanning past it computes rows
|
|
697
|
+
// nobody reads.
|
|
698
|
+
export async function anchorHitQueries(root: string, queries: AnchorHitQuery[], regs: Extractor[]): Promise<AnchorHit[][]> {
|
|
699
|
+
return runAnchorQueries(root, queries, regs, false)
|
|
700
|
+
}
|
|
701
|
+
export async function anchorHitExists(root: string, queries: AnchorHitQuery[], regs: Extractor[]): Promise<boolean[]> {
|
|
702
|
+
return (await runAnchorQueries(root, queries, regs, true)).map((hits) => hits.length > 0)
|
|
574
703
|
}
|
|
575
704
|
export async function anchorHitCommits(root: string, win: DriftPathEvent[], symbols: string[], regs: Extractor[]): Promise<AnchorHit[]> {
|
|
576
705
|
return (await anchorHitQueries(root, [{ win, symbols }], regs))[0]
|
package/spec-cli/src/attach.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
// @@@ session attach - the HUMAN escape hatch into a worker: every session is just a tmux session on the
|
|
2
|
-
// backend's private socket, and the most direct way to see or rescue one is to sit in it. This verb is the
|
|
3
|
-
// sanctioned foreground `tmux attach` — no programmatic exception-handling ambition, the user fixes it by
|
|
4
|
-
// being there. It is deliberately the ONE session verb that does NOT route through the backend
|
|
5
|
-
// ([[remote-client]]'s exception): a terminal cannot be brokered over HTTP, and attaching a tmux CLIENT to
|
|
6
|
-
// the same server is tmux's native multi-client support, not a second actor on the socket. That makes it
|
|
7
|
-
// LOCAL-only by nature — the guards below fail loud (never degrade) when the premise doesn't hold.
|
|
8
1
|
import { spawnSync } from 'node:child_process'
|
|
9
2
|
import { networkInterfaces } from 'node:os'
|
|
10
3
|
import { alive, apiBase, TMUX_SOCK } from './sessions.js'
|
|
11
|
-
import { runSessionOperation } from './session-maintenance.js'
|
|
12
4
|
|
|
13
5
|
const AGENT_ALTERNATIVES = 'read the pane with `spex session show <SEL> --capture`, drive it with `session send` (plain text first; `--keys` only as a last resort)'
|
|
14
6
|
|
|
@@ -40,14 +32,12 @@ export async function attachSession(id: string): Promise<number> {
|
|
|
40
32
|
An agent must not run it inside a turn (it freezes you); ${AGENT_ALTERNATIVES}.`)
|
|
41
33
|
process.exit(2)
|
|
42
34
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
console.error(`spex session attach: ${id} is offline — no live tmux session to attach.
|
|
35
|
+
if (!(await alive(id))) {
|
|
36
|
+
console.error(`spex session attach: ${id} is offline — no live tmux session to attach.
|
|
46
37
|
Bring it back with \`spex session resume ${id}\`, or read its record with \`spex session show ${id}\`.`)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
})
|
|
38
|
+
return 1
|
|
39
|
+
}
|
|
40
|
+
console.log(`attaching to ${id} — detach with C-b d (the session keeps running)`)
|
|
41
|
+
const r = spawnSync('tmux', ['-u', '-L', TMUX_SOCK, 'attach-session', '-t', id], { stdio: 'inherit' })
|
|
42
|
+
return r.status ?? 1
|
|
53
43
|
}
|