spexcode 0.4.0 → 0.4.3
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 +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +163 -30
- package/spec-cli/src/cli.ts +97 -42
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +31 -16
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +53 -36
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +157 -76
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +266 -67
- package/spec-cli/src/help.ts +84 -41
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +163 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +115 -169
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +162 -0
- package/spec-cli/src/sessions.ts +210 -63
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +47 -24
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
- package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +80 -45
- package/spec-eval/src/evaltab.ts +4 -3
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/scenarios.ts +116 -4
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -398
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
- package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
- package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
- package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
- package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
- package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
- /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
package/spec-cli/src/tree.ts
CHANGED
|
@@ -12,9 +12,10 @@ export type TreeNode = {
|
|
|
12
12
|
version?: number
|
|
13
13
|
drift?: number
|
|
14
14
|
ghost?: boolean
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
reviewSummary?: {
|
|
16
|
+
issues?: { open: number }
|
|
17
|
+
evals?: { stalePass: number; staleFail: number }
|
|
18
|
+
}
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export type TreeOpts = { node?: string; depth?: number; color?: boolean }
|
|
@@ -27,9 +28,8 @@ const STATUS_ANSI: Record<string, string> = { merged: '32', active: '36', drift:
|
|
|
27
28
|
// board `evals` is already latest-per-scenario, so this is a straight filter — the same freshness
|
|
28
29
|
// axis the dashboard's grey ✓/✗ badges read (score.jsx readingScore).
|
|
29
30
|
function staleYatsu(n: TreeNode): number {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return n.scenarios.filter((s) => { const r = latest.get(s.name); return r && r.fresh === false }).length
|
|
31
|
+
const summary = n.reviewSummary?.evals
|
|
32
|
+
return summary ? summary.stalePass + summary.staleFail : 0
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
function childrenIndex(nodes: TreeNode[]): Map<string | null, TreeNode[]> {
|
|
@@ -67,7 +67,7 @@ export function renderTree(nodes: TreeNode[], opts: TreeOpts = {}): string {
|
|
|
67
67
|
if (n.drift) parts.push(c('33', `drift:${n.drift}`))
|
|
68
68
|
const stale = staleYatsu(n)
|
|
69
69
|
if (stale) parts.push(c('90', `stale:${stale}`))
|
|
70
|
-
if (n.
|
|
70
|
+
if (n.reviewSummary?.issues?.open) parts.push(c('31', `issues:${n.reviewSummary.issues.open}`))
|
|
71
71
|
return parts.length ? ' ' + parts.join(' ') : ''
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -109,7 +109,7 @@ export function treeJson(nodes: TreeNode[], opts: TreeOpts = {}): object[] {
|
|
|
109
109
|
const pruned = opts.depth !== undefined && depth >= opts.depth
|
|
110
110
|
return {
|
|
111
111
|
id: n.id, title: n.title, status: n.status, version: n.version ?? 0,
|
|
112
|
-
drift: n.drift ?? 0, staleYatsu: staleYatsu(n), openIssues: n.
|
|
112
|
+
drift: n.drift ?? 0, staleYatsu: staleYatsu(n), openIssues: n.reviewSummary?.issues?.open ?? 0,
|
|
113
113
|
...(n.ghost ? { ghost: true } : {}),
|
|
114
114
|
children: pruned ? kids.map((k) => k.id) : kids.map((k) => shape(k, depth + 1)),
|
|
115
115
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
|
2
|
-
import { join, resolve, relative } from 'node:path'
|
|
2
|
+
import { basename, join, resolve, relative } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
3
4
|
import { execFileSync } from 'node:child_process'
|
|
4
5
|
import { HARNESSES, type HarnessArtifacts } from './harness.js'
|
|
5
6
|
import { runtimeRoot, readConfig, mainCheckout } from './layout.js'
|
|
@@ -13,16 +14,20 @@ import { dematerialize } from './materialize.js'
|
|
|
13
14
|
// and the optional git hooks. EVERY removal is gated on a SpexCode IDENTITY STAMP (the managed-block
|
|
14
15
|
// sentinels, the shim's own dispatch.sh command line, the trust sentinels, the generated mark / name-scoped
|
|
15
16
|
// on-demand paths, the plugin name stamp), so it can only ever delete what SpexCode itself generated. The one
|
|
16
|
-
// inviolable rule: the user's
|
|
17
|
-
// WIRING, not the spec graph
|
|
17
|
+
// inviolable rule: the user's tracked intent ASSET (.spec/.plugins + spexcode.json) is NEVER touched — uninstall
|
|
18
|
+
// removes only generated WIRING and local runtime state, not the spec graph and adoption config they served.
|
|
18
19
|
|
|
19
20
|
// the standard plugin-host folders a host agent scans (in addition to any named in spexcode.json's `harnesses`).
|
|
20
|
-
const DEFAULT_PLUGIN_HOSTS = ['.claude', '.codex'] as const
|
|
21
|
+
const DEFAULT_PLUGIN_HOSTS = ['.claude', '.codex', '.zcode'] as const
|
|
22
|
+
|
|
23
|
+
// Init and uninstall share one ownership source for generated git hooks: the shipped canonical templates.
|
|
24
|
+
// Exact bytes prove the destination is still our derivative; any user edit withdraws that ownership.
|
|
25
|
+
const HOOK_TEMPLATES = fileURLToPath(new URL('../templates/hooks', import.meta.url))
|
|
21
26
|
|
|
22
27
|
// is this dir a SpexCode plugin bundle? Either its folder name is the identity stamp, or its
|
|
23
28
|
// `.claude-plugin/plugin.json` declares `name: spexcode`. Read-gated so a user's other plugin is never touched.
|
|
24
29
|
function isSpexcodeBundle(dir: string): boolean {
|
|
25
|
-
if (dir
|
|
30
|
+
if (basename(dir) === 'spexcode') return true
|
|
26
31
|
const manifest = join(dir, '.claude-plugin', 'plugin.json')
|
|
27
32
|
if (!existsSync(manifest)) return false
|
|
28
33
|
try {
|
|
@@ -52,6 +57,26 @@ function sweepPluginBundles(proj: string, hosts: readonly string[]): string[] {
|
|
|
52
57
|
return removed
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
// Arbitrary plugin landing folders are the one materialized path set that cannot be reconstructed from stamps
|
|
61
|
+
// alone, so materialize records them as data. Read every current per-tree ledger plus the legacy project-global
|
|
62
|
+
// ledger before uninstall removes the store; a stale bundle remains removable even after current config stopped
|
|
63
|
+
// naming its former host folder.
|
|
64
|
+
function pluginLedgerHosts(store: string): string[] {
|
|
65
|
+
const ledgers = [join(store, 'plugin-folders')]
|
|
66
|
+
const trees = join(store, 'trees')
|
|
67
|
+
if (existsSync(trees)) {
|
|
68
|
+
for (const e of readdirSync(trees, { withFileTypes: true })) {
|
|
69
|
+
if (e.isDirectory()) ledgers.push(join(trees, e.name, 'plugin-folders'))
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const hosts: string[] = []
|
|
73
|
+
for (const ledger of ledgers) {
|
|
74
|
+
if (!existsSync(ledger)) continue
|
|
75
|
+
hosts.push(...readFileSync(ledger, 'utf8').split('\n').map((line) => line.trim()).filter(Boolean))
|
|
76
|
+
}
|
|
77
|
+
return hosts
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
// resolve the repo's shared git hooks dir (the common dir's hooks/), or null when <dir> isn't a git repo.
|
|
56
81
|
function hooksDir(proj: string): string | null {
|
|
57
82
|
try {
|
|
@@ -65,18 +90,18 @@ function hooksDir(proj: string): string | null {
|
|
|
65
90
|
}
|
|
66
91
|
}
|
|
67
92
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
93
|
+
// Remove only byte-identical products of the canonical hook templates. Enumerating the same template directory
|
|
94
|
+
// init copies keeps every generated hook covered without a second name list; modified and unrelated hooks survive.
|
|
70
95
|
function removeHooks(proj: string): string[] {
|
|
71
96
|
const dir = hooksDir(proj)
|
|
72
97
|
if (!dir) return []
|
|
73
98
|
const removed: string[] = []
|
|
74
|
-
for (const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
99
|
+
for (const e of readdirSync(HOOK_TEMPLATES, { withFileTypes: true })) {
|
|
100
|
+
if (!e.isFile()) continue
|
|
101
|
+
const hook = join(dir, e.name)
|
|
102
|
+
if (!existsSync(hook) || !readFileSync(hook).equals(readFileSync(join(HOOK_TEMPLATES, e.name)))) continue
|
|
103
|
+
rmSync(hook, { force: true })
|
|
104
|
+
removed.push(e.name)
|
|
80
105
|
}
|
|
81
106
|
return removed
|
|
82
107
|
}
|
|
@@ -103,19 +128,20 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
|
|
|
103
128
|
// erase phase every materialize runs, asserted against the empty policy. One inverse, never a parallel one.
|
|
104
129
|
dematerialize(proj, arts)
|
|
105
130
|
|
|
106
|
-
// 3. the global per-project store
|
|
107
|
-
//
|
|
108
|
-
// not the user's spec asset, so the whole dir is ours.
|
|
131
|
+
// 3. Locate the global per-project store and recover every current/legacy plugin landing folder from its
|
|
132
|
+
// ledgers BEFORE deleting it. The store is the runtime tier, not the user's tracked intent asset.
|
|
109
133
|
let store: string | null = null
|
|
134
|
+
let ledgerHosts: string[] = []
|
|
110
135
|
try {
|
|
111
136
|
store = runtimeRoot(proj)
|
|
112
|
-
rmSync(store, { recursive: true, force: true })
|
|
113
137
|
} catch {
|
|
114
138
|
store = null
|
|
115
139
|
}
|
|
140
|
+
if (store) ledgerHosts = pluginLedgerHosts(store)
|
|
116
141
|
|
|
117
|
-
// 4.
|
|
118
|
-
|
|
142
|
+
// 4. Any spexcode-stamped plugin bundle under configured, standard, or ledger-recovered hosts. The ledger
|
|
143
|
+
// input is what closes plugin-folder A -> native/folder B even when the previous materialize never finished.
|
|
144
|
+
let pluginHosts: string[] = [...DEFAULT_PLUGIN_HOSTS, ...ledgerHosts]
|
|
119
145
|
try {
|
|
120
146
|
const targets = resolveHarnessTargets(readConfig(mainCheckout(proj)).harnesses)
|
|
121
147
|
pluginHosts = [...pluginHosts, ...targets.filter((t) => t.kind === 'plugin').map((t) => (t as { folder: string }).folder)]
|
|
@@ -124,12 +150,19 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
|
|
|
124
150
|
}
|
|
125
151
|
const bundles = sweepPluginBundles(proj, pluginHosts)
|
|
126
152
|
|
|
153
|
+
// 5. The whole store: per-tree manifests/hashes/ledgers, legacy project-global products, and sessions.
|
|
154
|
+
let removedStore: string | null = null
|
|
155
|
+
if (store && existsSync(store)) {
|
|
156
|
+
rmSync(store, { recursive: true, force: true })
|
|
157
|
+
removedStore = store
|
|
158
|
+
}
|
|
159
|
+
|
|
127
160
|
console.log(`✓ dematerialized (contract blocks, shims, Codex trust, skills, sub-agents, ignore blocks, content filter) for ${HARNESSES.map((h) => h.id).join(', ')}`)
|
|
128
|
-
if (
|
|
161
|
+
if (removedStore) console.log(`✓ removed the global per-project store (${removedStore})`)
|
|
129
162
|
if (bundles.length) console.log(`✓ removed plugin bundle(s): ${bundles.join(', ')}`)
|
|
130
163
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
164
|
+
// Git hooks are per-clone and may carry user logic → preserved unless --hooks (and even then only while
|
|
165
|
+
// byte-identical to a canonical generated template; a user edit withdraws our ownership).
|
|
133
166
|
if (opts.hooks) {
|
|
134
167
|
const removed = removeHooks(proj)
|
|
135
168
|
if (removed.length) console.log(`✓ removed git hooks (${removed.join(', ')})`)
|
|
@@ -140,6 +173,6 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
|
|
|
140
173
|
|
|
141
174
|
console.log(`
|
|
142
175
|
SpexCode wiring removed. Your spec data is untouched:
|
|
143
|
-
• .spec/
|
|
176
|
+
• .spec/ (including .plugins/) and spexcode.json remain — your tracked intent is never deleted by uninstall.
|
|
144
177
|
• To re-adopt later: \`spex init\` regenerates the shims, contract, trust, and global store.`)
|
|
145
178
|
}
|
|
@@ -9,26 +9,45 @@
|
|
|
9
9
|
# it must NOT be trusted. Resolve the thread id → record via the SAME `harness_session_id` alias the shell
|
|
10
10
|
# hooks use, AT COMMIT TIME (the record is swept on close, so read-time aliasing would fail), and stamp the
|
|
11
11
|
# resolved RECORD id.
|
|
12
|
+
# - Per-session-process harnesses (opencode; pi) export NO harness var to tool subprocesses — their identity
|
|
13
|
+
# channel IS the launch-injected SPEXCODE_SESSION_ID (each TUI is one process, no shared-server
|
|
14
|
+
# contamination). Trust it only as the LAST tier, and only when the record it names is verifiably not a
|
|
15
|
+
# codex record — so a codex shell that somehow lost its CODEX_THREAD_ID can never stamp the first
|
|
16
|
+
# session's id.
|
|
12
17
|
# No-op outside a governed session, or if a Session: trailer is already present (so the dogfood ritual / a
|
|
13
18
|
# human can still set it explicitly).
|
|
14
19
|
set -euo pipefail
|
|
15
20
|
msg_file="$1"
|
|
16
21
|
grep -qiE '^Session:[[:space:]]' "$msg_file" && exit 0
|
|
17
22
|
|
|
23
|
+
# the per-project store (mirrors layout.ts runtimeRoot / harness.sh hp_runtime_dir): keyed by the project
|
|
24
|
+
# (dirname of the ABSOLUTE git-common-dir), '/' and '.' → '-'.
|
|
25
|
+
gcd=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
|
|
26
|
+
|| gcd=$(realpath "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null) || gcd=""
|
|
27
|
+
rd=""
|
|
28
|
+
[ -n "$gcd" ] && rd="${SPEXCODE_HOME:-$HOME/.spexcode}/projects/$(printf '%s' "$(dirname "$gcd")" | sed 's#[/.]#-#g')"
|
|
29
|
+
|
|
18
30
|
sid="${CLAUDE_CODE_SESSION_ID:-}" # claude: the exported id IS the record id
|
|
19
|
-
if [ -z "$sid" ] && [ -n "${CODEX_THREAD_ID:-}" ]; then # codex: alias the thread id → record id
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
if [ -z "$sid" ] && [ -n "${CODEX_THREAD_ID:-}" ] && [ -n "$rd" ]; then # codex: alias the thread id → record id
|
|
32
|
+
if [ -e "$rd/sessions/$CODEX_THREAD_ID/session.json" ]; then
|
|
33
|
+
sid="$CODEX_THREAD_ID" # a governed record keyed by the thread id (rare/direct)
|
|
34
|
+
else # else the record that captured it as harness_session_id
|
|
35
|
+
# @@@ unmatched-is-noop - grep's no-match (exit 1) and an absent sessions glob (exit 2) are the
|
|
36
|
+
# NOT-GOVERNED case, not an error: any repo inherits a foreign CODEX_THREAD_ID from a codex session's
|
|
37
|
+
# shell, and under `set -euo pipefail` a bare grep|head assignment would abort the hook — and the
|
|
38
|
+
# commit — before the intended `[ -z "$sid" ] && exit 0` no-op. Genuine errors past this lookup
|
|
39
|
+
# (interpret-trailers etc.) still fail loud via set -e.
|
|
40
|
+
hit=$(grep -lF "\"harness_session_id\": \"$CODEX_THREAD_ID\"" "$rd"/sessions/*/session.json 2>/dev/null | head -1) || hit=""
|
|
41
|
+
[ -n "$hit" ] && sid=$(basename "$(dirname "$hit")")
|
|
42
|
+
fi
|
|
43
|
+
fi
|
|
44
|
+
if [ -z "$sid" ] && [ -n "${SPEXCODE_SESSION_ID:-}" ] && [ -n "$rd" ]; then
|
|
45
|
+
# last tier of the shared envSessionId precedence rule: no harness var claimed the commit, so the
|
|
46
|
+
# launch-injected record id is the identity — IF its record exists (governance check; swept on close) and
|
|
47
|
+
# is not codex's (the one harness whose shared server contaminates this env var).
|
|
48
|
+
rec="$rd/sessions/$SPEXCODE_SESSION_ID/session.json"
|
|
49
|
+
if [ -e "$rec" ] && ! grep -q '"harness": "codex"' "$rec"; then
|
|
50
|
+
sid="$SPEXCODE_SESSION_ID"
|
|
32
51
|
fi
|
|
33
52
|
fi
|
|
34
53
|
|
|
@@ -3,12 +3,14 @@ title: extract
|
|
|
3
3
|
surface: command
|
|
4
4
|
status: active
|
|
5
5
|
hue: 30
|
|
6
|
-
desc: Reverse-engineer a faithful spec tree out of existing code
|
|
6
|
+
desc: Reverse-engineer — or re-run to reconcile — a faithful spec tree out of existing code: responsibility nodes at contract altitude, intent never fabricated, every frontend node carrying a loss signal.
|
|
7
7
|
kind: mutating
|
|
8
8
|
---
|
|
9
|
-
Reverse-engineer a spec tree for the target source area(s) below —
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
Reverse-engineer a spec tree for the target source area(s) below — or **re-run to reconcile** an
|
|
10
|
+
already-extracted area, the same pass that closes the gaps code growth opens (an unclaimed governed file, a
|
|
11
|
+
frontend node missing its loss signal, a thin scenario). Extraction is not a one-shot. Aim for a tree where
|
|
12
|
+
every governed file is claimed and `spex spec lint` is clean, bodies at contract altitude and in the codebase's
|
|
13
|
+
own primary language (a predominantly-Chinese repo → Chinese specs).
|
|
12
14
|
|
|
13
15
|
{{targets}}
|
|
14
16
|
|
|
@@ -24,11 +26,11 @@ Then grow nodes under that spine:
|
|
|
24
26
|
claiming them all. Every governed file is claimed by at least one node; nest into subtrees where warranted.
|
|
25
27
|
- **Group wide layers; don't mirror the file tree — at every level, the root included.** One-node-per-folder
|
|
26
28
|
is a smell. If a node would have more than ~7 direct children you're under-grouping: add intermediate
|
|
27
|
-
**sub-domain** nodes that cluster siblings serving one concern (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
**sub-domain** nodes that cluster siblings serving one concern (model-config + selection + auth → a *model*
|
|
30
|
+
domain), and recurse until every level reads as a handful of siblings, not a flat wall. Fold cross-cutting
|
|
31
|
+
substrate (design system, i18n, platform glue) under one *foundation* node that claims the cluster's
|
|
32
|
+
barrel/wiring files (so it isn't pure-prose); split a fat folder holding several distinct jobs. Group by
|
|
33
|
+
responsibility, never to hit a number.
|
|
32
34
|
- **Stay at contract altitude.** State each node's intent, invariants, and outward behavior — what it
|
|
33
35
|
guarantees and why — not how the code does it.
|
|
34
36
|
- **Never fabricate intent.** Code shows *what it does*, rarely *why*. Read any README/design docs for real
|
|
@@ -44,17 +46,21 @@ Then grow nodes under that spine:
|
|
|
44
46
|
**Give every frontend node a loss signal.** A node that governs UI or visual code (`.tsx`/`.jsx`/`.vue`/
|
|
45
47
|
`.svelte`/`.css`, or the dashboard) is a blind spot until it carries a `eval.md` — so write one as you
|
|
46
48
|
extract it: a **real user-path** scenario — a goal and the steps to reach it through the running app (never a
|
|
47
|
-
bare render-check), covering a failure/empty/edge state — with a **description** of those steps
|
|
48
|
-
**expected** zero-loss result
|
|
49
|
+
bare render-check), covering a failure/empty/edge state — with a **description** of those steps, the
|
|
50
|
+
**expected** zero-loss result, and **≥1 `tags`** classifying it (a surface like `frontend-e2e`/`backend-api`/
|
|
51
|
+
`cli`, a device like `desktop`/`mobile`) drawn from the configured library (`lint.scenarioTags`); a tag
|
|
52
|
+
outside the library fails `scan`, so use an existing one or extend the library. Frontend scenarios are measured by looking (YATU) — a screenshot filed with
|
|
49
53
|
`spex eval add <node> --image <png> --pass`. Backend nodes don't need one yet; run `spex eval lint` to
|
|
50
|
-
list the frontend nodes still uncovered.
|
|
54
|
+
list the frontend nodes still uncovered. On a **re-run** this is also where you sharpen — give an uncovered
|
|
55
|
+
node its first scenario, tighten a thin one (a bare render-check, or a stale `expected`); `spex guide eval`
|
|
56
|
+
has the schema.
|
|
51
57
|
|
|
52
58
|
**Extract incrementally — don't plan the whole tree before writing.** For a large area (hundreds of files),
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
enumerating the whole partition up front or scripting a generator to emit it at once burns context before a
|
|
60
|
+
node lands and loses everything to one interruption. Fix the top-level cut and commit it, then take ONE
|
|
61
|
+
subtree at a time (write the leaf, list its files, lint, COMMIT) before the next — never more than one
|
|
62
|
+
subtree uncommitted, so progress survives context limits.
|
|
57
63
|
|
|
58
64
|
Confirm `spexcode.json`'s `governedRoots` points at the real source dirs first — lint reads silently empty
|
|
59
|
-
otherwise. Commit one node per commit (`spec: <id> — extract from <area>`)
|
|
60
|
-
|
|
65
|
+
otherwise. Commit one node per commit (`spec: <id> — extract from <area>`) and run `spex spec lint` after each:
|
|
66
|
+
0 errors, 0 coverage/altitude warnings.
|
|
@@ -6,14 +6,14 @@ hue: 130
|
|
|
6
6
|
desc: Resolve a node's breadth — a flat fan-out of too many children — by lifting children onto their true owner or a new grouping layer, only along real seams.
|
|
7
7
|
kind: mutating
|
|
8
8
|
---
|
|
9
|
-
A node flagged for **breadth** has
|
|
9
|
+
A node flagged by `spex doctor` for **breadth** has many direct children: a flat fan-out the eye may not hold. Breadth is an unvalidated health hypothesis, not a lint defect — a flat list of genuine peers can be right. Find the **natural seams** in the fan-out and lift the children onto them, *only where a real group exists*. Getting under the configured child budget is the floor, not the goal; never manufacture structure to hit a number.
|
|
10
10
|
|
|
11
11
|
{{targets}}
|
|
12
12
|
|
|
13
13
|
Read before you move: the over-broad node's spec, every child's spec, and the `[[links]]`/`related:` between them. A flat fan-out is rarely one undifferentiated list — some children are **misfiled** under the wrong parent, some **cohere into a surface no node owns yet**, and some are **genuine independent peers**. Sort each child into exactly one disposition:
|
|
14
14
|
|
|
15
15
|
- **Reparent under an existing sibling.** When a child's own spec says it is *part of* another child — its tab, its row, its input, a sub-surface of it — that sibling is its true owner. `git mv` it under that node. No new parent: the breadth was the symptom of a misparented child, and putting it under its real owner fixes the miscategorization for free. **Try this first** — it is the cheapest, most honest move and adds nothing to the tree. (Second-order case: if a reparent pushes the new host over budget, that host now needs its own seam.)
|
|
16
|
-
- **Group under a new intermediate parent.** When several children genuinely cohere — one surface, one concept, read together to understand one thing — but no existing node owns them. Create one parent along that seam. It must **earn its existence**: a body stating what the group *is* and why these children belong, a contract at altitude (it is a real node and must
|
|
16
|
+
- **Group under a new intermediate parent.** When several children genuinely cohere — one surface, one concept, read together to understand one thing — but no existing node owns them. Create one parent along that seam. It must **earn its existence**: a body stating what the group *is* and why these children belong, a contract at altitude (it is a real node and must stay clear in the doctor's altitude diagnosis) — never a hollow container echoing a table of contents. The two-for-one test: a true seam also makes the siblings around it read more clearly.
|
|
17
17
|
- **Leave it flat.** When a child shares no boundary with the rest. A flat list of genuine peers is sometimes right; refusing to force a "misc"/"everything-else" bucket is the correct move, not a failure. A couple of real groups plus a handful of still-flat peers is a good outcome.
|
|
18
18
|
|
|
19
19
|
Honor these:
|
|
@@ -22,4 +22,4 @@ Honor these:
|
|
|
22
22
|
- **Fewest, deepest-justified parents.** Don't trade one flat layer for six two-child wrappers — that relocates the sprawl instead of resolving it. Between two passing groupings, take the one that adds fewer nodes.
|
|
23
23
|
- Parent ids name the concept (kebab-case); give the parent a `hue` near its children's family.
|
|
24
24
|
|
|
25
|
-
Work the order: (1) read everything; (2) write each candidate seam with the one-sentence intent that justifies it — kill the unjustifiable, and mark each survivor as reparent-under-existing or new-parent; (3) make the moves, one reviewable commit per group (`spec: <parent> — regroup <children>`, with a `Session:` trailer); (4) run `spex spec lint` and confirm breadth is resolved
|
|
25
|
+
Work the order: (1) read everything; (2) write each candidate seam with the one-sentence intent that justifies it — kill the unjustifiable, and mark each survivor as reparent-under-existing or new-parent; (3) make the moves, one reviewable commit per group (`spec: <parent> — regroup <children>`, with a `Session:` trailer); (4) run `spex spec lint` and keep it at 0 errors, then run bare `spex doctor` and confirm the breadth finding is resolved. Uncommitted `git mv`s churn the drift count transiently — committing settles it; don't chase it.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: rename
|
|
3
|
+
surface: command
|
|
4
|
+
status: active
|
|
5
|
+
hue: 300
|
|
6
|
+
desc: Give this SpexCode session a short, specific name derived from the work it is currently doing.
|
|
7
|
+
kind: mutating
|
|
8
|
+
---
|
|
9
|
+
Review the work this session is currently doing and choose a short, specific name that will distinguish it
|
|
10
|
+
from the other sessions on the SpexCode board. Then run `spex session rename . "<name>"` to set that name.
|
|
11
|
+
Rename the SpexCode session, not the harness conversation, and do not edit project files for this task.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: commands
|
|
3
|
+
status: active
|
|
4
|
+
hue: 40
|
|
5
|
+
desc: Grouping shelf for the `surface: command` prompt presets offered wherever a human composes work for an agent. A shelf, not a surface — routing stays field-driven per surface.
|
|
6
|
+
---
|
|
7
|
+
# commands
|
|
8
|
+
|
|
9
|
+
The invocable **command** plugins live here: leaf plugins whose body is a prompt preset a human can pick
|
|
10
|
+
from the `/` dropdown while launching or driving a session, each carrying `surface: command`. Grouping them keeps `.plugins/` legible at
|
|
11
|
+
a glance — the command presets on this shelf, the skill plugins on [[skills]], the auxiliary system
|
|
12
|
+
contracts on [[prompts]], with [[core]] (the dev-flow contract subsystem) a flat child beside them.
|
|
13
|
+
|
|
14
|
+
Invocation belongs to the backend prompt boundary, not to whichever client happens to render the picker.
|
|
15
|
+
Every compose surface sends the raw `/<preset> [[node]]… <free text>` prompt; the shared resolver expands the
|
|
16
|
+
live `surface: command` body before either launch starts a worker or dispatch sends text to one. At
|
|
17
|
+
launch, the raw invocation remains the session's originating prompt and identity source, so links inside a
|
|
18
|
+
plugin body can never invent a node target. Dashboard and phone menus are therefore discovery/insertion
|
|
19
|
+
chrome, while dashboard, phone, CLI, API, and in-process fallback all invoke through the same backend
|
|
20
|
+
resolution. A preset with `{{targets}}` always receives the resolved target block; one without that placeholder
|
|
21
|
+
gets a target block only when the invocation actually names a target, so a targetless utility remains a small
|
|
22
|
+
prompt. An unknown leading `/name` stays ordinary prompt text and is never swallowed or guessed.
|
|
23
|
+
|
|
24
|
+
This node is a **shelf, not a surface** (the [[prompts]] shape): it declares no `surface` field and
|
|
25
|
+
gathers nothing itself. Discovery is recursive and field-driven (surface), so a resident plugs in
|
|
26
|
+
exactly as it would at the root — the gather set is path-independent, so shelving a command changes
|
|
27
|
+
nothing about what `/api/plugins` and the launcher offer. A plugin that serves BOTH surfaces (e.g.
|
|
28
|
+
[[distill]], skill and command) shelves once by its primary identity, never duplicated. The init
|
|
29
|
+
templates mirror this layout. The shelf stays pure presentation: moving a resident beneath it changes
|
|
30
|
+
neither that plugin's identity nor the surfaces gathered from its frontmatter.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: supervisor
|
|
3
|
+
surface: command
|
|
4
|
+
status: active
|
|
5
|
+
hue: 280
|
|
6
|
+
desc: Launch a supervisor agent that manages other agents from the main checkout to drive a goal to completion.
|
|
7
|
+
---
|
|
8
|
+
You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code. **FIRST, read `<root>/CLAUDE.md` — specifically its "Supervising — the manager loop" section** — that is your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize). Then drive the goal: decompose it into worker-sized tasks and dispatch one worker per independent task (`spex session new "<task>"` — give each ONLY its task; a task about one specific node mentions it as `[[<id>]]`, which only sets the branch name and board attribution; the session's real node links come from what it edits), monitor with `spex session watch`, review proposals with `spex session review <id>`, merge good ones with `git -C <root> merge --no-ff <branch>`, then close. Never let a worker self-merge; keep `spex spec lint` at 0 errors. To READ a worker's current state, use the one-shot snapshots (`spex session review <id>` or `spex session ls` — both return immediately); to WAIT for a worker, background `spex session wait <id>` — it is edge-triggered: it returns only when it OBSERVES the worker transition from non-actionable into an actionable status (an already-actionable arrival state does not return it), printing the observed status path — which is also how you wait for a dispatched merge to actually land; never block on `spex session watch`, which STREAMS forever and will freeze your turn. **Stay parked while your fleet runs:** after dispatching, background a `spex session wait <child>` per worker (per the manager loop) so that when you stop with children still working you land in `parked` — self-resuming on the next child transition — NOT `asking`; only go `asking` when you genuinely need the human. This matters because the dashboard **folds each child under you and shows YOUR own status for the whole group** (session-nesting, no child-status aggregation), so a supervisor that stays parked-while-they-run is what makes that folded group status honest. One footgun that bites a fresh supervisor: before `spex session close <id>`, confirm the merge landed (`git -C <root> log -1` shows HEAD at the new merge commit) — closing an unmerged branch discards the work. **DRAIN THE ISSUES** (issues / local-issues) as part of your loop: `spex issue ls` lists every open concern in one place — the taste concerns finished sessions recorded locally, AND the forge's issues, store-tagged. Cluster the same concern yourself (use judgment — duplicates are a recurrence SIGNAL, not noise; fold them into one) and weigh by recurrence AND novelty — **recurrence is salience, not importance, so never just fix the highest count**: a sharp single-voice concern can outrank a popular gripe. For the ones worth acting on, `spex session new "<task>"` a worker to land it (mention the concern's node as `[[<id>]]` if it has one), then `spex issue ls resolve <id> --as accepted|landed` (or `rejected`, with a reply saying why) so the store reflects the decision. Report progress as you go and when the goal is complete. Your goal follows:
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: tidy
|
|
3
|
+
surface: command
|
|
4
|
+
status: active
|
|
5
|
+
hue: 140
|
|
6
|
+
desc: Diagnose or fix a node's body altitude — grade it against the contract-surface test (read-only), or rewrite it to contract altitude — by what the invocation asks for.
|
|
7
|
+
kind: mutating
|
|
8
|
+
---
|
|
9
|
+
Bring each target spec node's body to **contract** altitude — observable behavior, not implementation, and not vague
|
|
10
|
+
hand-waving either. One node, two modes chosen by the invoking prompt: **diagnose** (grade only, change nothing) or
|
|
11
|
+
**fix** (rewrite in place). Default to diagnose when the ask is "how healthy / what's wrong", to fix when it is "tidy /
|
|
12
|
+
clean up".
|
|
13
|
+
|
|
14
|
+
{{targets}}
|
|
15
|
+
|
|
16
|
+
The objective rule for every sentence is the contract-surface test: *"could a behavior-preserving refactor delete or
|
|
17
|
+
change this?"*
|
|
18
|
+
|
|
19
|
+
- **Yes ⇒ it is implementation** (operators, call names, data structures, "added a parameter", step-by-step how-to). In
|
|
20
|
+
fix mode it leaves the body and becomes an `@@@title - explanation` comment at the code that owns it.
|
|
21
|
+
- **No ⇒ it is contract surface.** Keep it — public names, signatures, return types, invariants, edges/errors, the
|
|
22
|
+
WHEN → outcome a caller observes.
|
|
23
|
+
|
|
24
|
+
**Diagnose** (read-only — no edits, no commits). Run bare `spex doctor` once and consume its **Spec health
|
|
25
|
+
diagnosis** for every target; never restate or locally reproduce the altitude proxy thresholds. For each target
|
|
26
|
+
report two layers:
|
|
27
|
+
|
|
28
|
+
- **Deterministic (git + `spex spec lint`):** *Lint* — errors/warnings naming this node (integrity, living, coverage, drift);
|
|
29
|
+
*Drift* — whether its governed `code:` files moved ahead of its latest version, by how much; *Link-gap* — does `code:`
|
|
30
|
+
name every implementing file? an unlinked file is invisible to lint and drift, so the spec silently stops governing it.
|
|
31
|
+
- **Quality grade (judge the body, not the code):** score 1–5 — *declarative*, *refactor-resistant*, *edges*,
|
|
32
|
+
*testable*, *concise*. Include the doctor's altitude finding (or explicit clear result) as the cheap mechanical
|
|
33
|
+
signal. Two failure directions remain a semantic judgment: **too low** (a mechanics dump — leaks, code identifiers,
|
|
34
|
+
how-to) and **too thin** (so vague a refactor couldn't violate it, e.g. "validates input appropriately"; only you catch
|
|
35
|
+
this). End with a one-line verdict (`healthy` | `needs-tidy` | `too-thin` | `drifting` | `link-gap`) and the single
|
|
36
|
+
highest-value next action. Change nothing.
|
|
37
|
+
|
|
38
|
+
**Fix** (mutating — one commit per node). Rewrite the body at the right altitude: **preserve the contract** (never drop a
|
|
39
|
+
requirement; rephrase, don't delete meaning), **raise don't hollow out** (keep every testable specific; cut only the
|
|
40
|
+
how — too-thin is as broken as a mechanics dump), **cut redundancy** (say each thing once), **stay a living document**
|
|
41
|
+
(rewrite in place, never a `## vN` history — git carries versions). Commit per node (`spec: <id> — tidy to contract
|
|
42
|
+
altitude`) with a `Session:` trailer; run `spex spec lint` after each — it must stay at 0 errors.
|
|
43
|
+
Then run bare `spex doctor` and confirm the target no longer carries an altitude finding.
|
|
@@ -10,4 +10,4 @@ block: false
|
|
|
10
10
|
---
|
|
11
11
|
Catches the undeclared stop the [[stop-gate]] misses. When the harness signals — via an idle-prompt notification — that the agent is simply sitting idle at its prompt rather than working, this hook marks the session `idle`, so a session that quietly ran out of things to do is not left reading as active on the board.
|
|
12
12
|
|
|
13
|
-
It acts only on the idle-prompt notification, ignoring every other notification kind. It is guarded so it never clobbers a deliberate declaration: marking idle applies only to a session still in the undeclared `active` state, leaving any considered `awaiting`, `asking`, `parked`, or `error` claim untouched. Together with [[stop-gate]] and [[session-fail]] it closes the last gap where a session could stop without its true state reaching the board.
|
|
13
|
+
It acts only on the idle-prompt notification, ignoring every other notification kind. As a board-lifecycle hook it also acts only on a GOVERNED session — it resolves the record in the global store from the payload's `session_id` and no-ops unless `governed: true` — then marks idle via `spex internal session-idle --session <id>`. It is guarded so it never clobbers a deliberate declaration: marking idle applies only to a session still in the undeclared `active` state, leaving any considered `awaiting`, `asking`, `parked`, or `error` claim untouched. Together with [[stop-gate]] and [[session-fail]] it closes the last gap where a session could stop without its true state reaching the board.
|
|
@@ -15,4 +15,6 @@ The state is read from ONE structured field in the hook payload, never sniffed f
|
|
|
15
15
|
|
|
16
16
|
The one activity that does NOT count as the session acting is an IN-PROCESS SUBAGENT's tool call (the harness's Task tool — a sub-conversation inside the same process). Such a call fires the parent's hooks carrying the parent's `session_id`, so without a discriminator a supervising parent could never hold a declared state: its own subagents erased every park/ask within seconds and raced the stop-gate into "undeclared stop". The harness stamps subagent-executed calls with a top-level `agent_id` field the parent's own calls never carry; `hp_is_subagent` reads that stamp deterministically (scanning only the pre-`tool_input` payload prefix, where a tool parameter or file content can never fake an unescaped key), and this hook skips the flip entirely. A subagent working is its parent supervising, not the parent moving on — the parent's own next tool call still flips as before.
|
|
17
17
|
|
|
18
|
+
It is a board-lifecycle hook, so it acts only on a GOVERNED (dashboard-launched) session — it resolves that session's record in the global per-session store from the payload's `session_id` and no-ops unless `governed: true`. The state it writes lives in that record's `session.json` (state), which it edits directly in shell (the hot path stays jq-free).
|
|
19
|
+
|
|
18
20
|
This is the freshness half of the [[core]] discipline: it keeps the board honest about whether a session is working, waiting, or asking, so the gates and the dashboard read a true present state rather than a stale one.
|
|
@@ -10,4 +10,4 @@ block: false
|
|
|
10
10
|
---
|
|
11
11
|
When a turn ends not because the agent declared but because the API itself failed, this hook structurally marks the session `error`. A failed turn is a real outcome the board must show, and without this signal the session would freeze under whatever state it last held — reading as "active" or "awaiting" long after it actually died.
|
|
12
12
|
|
|
13
|
-
It is non-blocking and unconditional on the failure event: the failure already happened, so the only job is to record it truthfully. By turning an API error into a declared `error` state it keeps the [[stop-gate]] family's invariant intact — a session's displayed state always reflects what is really true of its last turn — for the one stop path the agent cannot narrate itself.
|
|
13
|
+
It is non-blocking and unconditional on the failure event: the failure already happened, so the only job is to record it truthfully. As a board-lifecycle hook it acts only on a GOVERNED session — resolved in the global store from the payload's `session_id` — and writes via `spex internal session-fail --session <id>`. By turning an API error into a declared `error` state it keeps the [[stop-gate]] family's invariant intact — a session's displayed state always reflects what is really true of its last turn — for the one stop path the agent cannot narrate itself.
|
|
@@ -1,54 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# @@@ spec-first - a
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# path). So this hook never names Claude's tools — it fires on Claude AND Codex alike.
|
|
13
|
-
# @@@ all sessions, global sentinel - spec-awareness is UNIVERSAL, so this is NOT gated on `governed`: it
|
|
14
|
-
# serves any agent (dashboard or user-self-launched). The once-per-session sentinel lives in the session's
|
|
15
|
-
# GLOBAL store dir (keyed by the harness session_id, grouped per-project — see hp_store_dir), created on
|
|
16
|
-
# demand. The node it points at is read from the global record when the session is bound to one (a dashboard
|
|
17
|
-
# session); a self-launched agent has no record, so it falls back to the generic nudge. cwd = the worktree.
|
|
2
|
+
# @@@ spec-first - a one-shot governed READ gate. It advances only when the adapter resolves a read path AND
|
|
3
|
+
# the spec graph resolves a real `code:` governor for that path. Irrelevant tools, unresolvable reads, and
|
|
4
|
+
# uncovered/related-only files leave the sentinel absent, so any number of ungoverned reads cannot mute the
|
|
5
|
+
# first later governed read. That read spends the gate and blocks once with its actual governor; retries pass.
|
|
6
|
+
# @@@ event vs matcher - materialized shims bind PreToolUse event-wide on every harness. hp_code_path ... read
|
|
7
|
+
# is the ONE adapter matcher that reduces Claude/Codex payload differences to a path. This handler owns the
|
|
8
|
+
# harness-agnostic state transition and governor lookup; it has no tool-name, harness, or filename branches.
|
|
9
|
+
# @@@ all sessions, global sentinel - file governance is independent of a record's `governed` bit, so the
|
|
10
|
+
# same gate serves dashboard and self-launched agents. The sentinel lives in the per-session global store dir
|
|
11
|
+
# (see hp_store_dir) and is created only by the first governed read. cwd = the worktree.
|
|
18
12
|
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
13
|
+
S="${SPEX:-spex}"
|
|
19
14
|
payload=$(cat 2>/dev/null)
|
|
20
15
|
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
21
16
|
sdir=$(hp_store_dir "$sid") || exit 0
|
|
22
|
-
rec="$sdir/session.json"
|
|
23
17
|
sent="$sdir/spec-checked"
|
|
24
|
-
[ -f "$sent" ] && exit 0
|
|
18
|
+
[ -f "$sent" ] && exit 0
|
|
25
19
|
|
|
26
|
-
|
|
27
|
-
# don't consume the one-shot for a non-code tool. A codex multi-file apply_patch yields several paths (one per
|
|
28
|
-
# line); this tool is a code access if ANY resolved path is a non-spec file.
|
|
29
|
-
paths=$(hp_code_path "$payload" access)
|
|
20
|
+
paths=$(hp_code_path "$payload" read)
|
|
30
21
|
[ -n "$paths" ] || exit 0
|
|
22
|
+
repo=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
|
31
23
|
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
24
|
+
# The internal projection uses the authoritative code: edge resolver and emits stable id<TAB>spec-path rows;
|
|
25
|
+
# empty output means uncovered or related-only, deliberately a non-transition.
|
|
26
|
+
path=""; owner=""
|
|
27
|
+
while IFS= read -r candidate; do
|
|
28
|
+
[ -n "$candidate" ] || continue
|
|
29
|
+
governors=$(cd "$repo" && $S internal spec-governors "$candidate" 2>/dev/null)
|
|
30
|
+
[ -n "$governors" ] || continue
|
|
31
|
+
path="$candidate"
|
|
32
|
+
owner=$(printf '%s\n' "$governors" | awk -F '\t' 'BEGIN{sep=""} {printf "%s%s [%s]",sep,$2,$1; sep=", "}')
|
|
33
|
+
break
|
|
39
34
|
done <<EOF
|
|
40
35
|
$paths
|
|
41
36
|
EOF
|
|
42
|
-
[ "$
|
|
37
|
+
[ -n "$owner" ] || exit 0
|
|
43
38
|
|
|
44
|
-
# first code access without having touched the spec → set the sentinel (so this fires exactly once), nudge once.
|
|
45
39
|
mkdir -p "$sdir"; : > "$sent"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
where="your node's spec (${sp:-.spec/.../$node/spec.md})"
|
|
50
|
-
else
|
|
51
|
-
where="the spec node that governs this area (run: spex spec search <topic>)"
|
|
52
|
-
fi
|
|
53
|
-
printf '{"decision":"block","reason":"Before working in this code, read %s FIRST — it is the current contract — and read its NEIGHBORS too (the parent that scopes it, the siblings it borders, the children that refine it), since its intent is only fully legible against the surrounding tree. Then act deliberately: changing the intent? edit the spec first so spec and code land together. implementing existing intent? make the code honor the spec. The one forbidden move is code that silently diverges from its spec. (Fires once per session, at your first code read or edit.)"}\n' "$where"
|
|
40
|
+
reason="Before accessing governed source $path, read its governing spec FIRST: $owner. Read the relevant NEIGHBORS too: the parent that scopes it, the siblings it borders, and the children that refine it. Then reconcile deliberately: change the spec if the intent is changing, or make the code honor it. The one forbidden move is code that silently diverges from its spec. (Fires once per session, at the first governed code read.)"
|
|
41
|
+
esc=$(printf '%s' "$reason" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS=""} NR>1{print "\\n"} {print}')
|
|
42
|
+
printf '{"decision":"block","reason":"%s"}\n' "$esc"
|
|
54
43
|
exit 0
|
|
@@ -8,8 +8,10 @@ events:
|
|
|
8
8
|
order: 20
|
|
9
9
|
block: true
|
|
10
10
|
---
|
|
11
|
-
A one-shot
|
|
11
|
+
A one-shot, governed-aware READ gate. Its state advances only when the agent is about to read code that has a real governor (`code:` ownership). The first such read creates the session sentinel and blocks once, naming the resolved governing spec and directing the agent through the relevant parent, sibling, and child contracts before retrying. Once that contract-read path has been demanded, later code reads pass.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The state machine has no transition for an irrelevant tool, an unresolvable path, or an uncovered/related-only file. In particular, any number of ungoverned reads remain allowed without consuming or muting the gate; a later governed read must still block. This is file governance, distinct from a session record's `governed` field: spec-awareness still serves dashboard-launched and user-self-launched agents alike, with the sentinel created on demand in the session's global store directory.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Event delivery and semantic matching have separate responsibilities. The hook subscribes to the shared `PreToolUse` lifecycle event because Claude and Codex shims deliver that event broadly. The harness adapter's single `read` matcher decides whether the payload represents a file read and extracts its path; the hook then asks the spec graph whether that path has a governor. Harness payload differences stay inside the adapter, while the gate and its state transitions stay one mechanism.
|
|
16
|
+
|
|
17
|
+
This enforces the read-the-contract-first rule of [[core]] only where a contract actually exists, at the moment before understanding hardens around governed code.
|