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.
Files changed (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +163 -30
  7. package/spec-cli/src/cli.ts +97 -42
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +31 -16
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +53 -36
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +157 -76
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +266 -67
  19. package/spec-cli/src/help.ts +84 -41
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +163 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +115 -169
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +162 -0
  39. package/spec-cli/src/sessions.ts +210 -63
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +47 -24
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
  48. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +80 -45
  90. package/spec-eval/src/evaltab.ts +4 -3
  91. package/spec-eval/src/humanok.ts +1 -1
  92. package/spec-eval/src/matrix.ts +693 -0
  93. package/spec-eval/src/scenarios.ts +116 -4
  94. package/spec-eval/src/sessioneval.ts +726 -66
  95. package/spec-cli/src/migrate-table.ts +0 -398
  96. package/spec-cli/src/migrate.ts +0 -386
  97. package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
  98. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  99. package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
  100. package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
  108. package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
  109. package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
  110. package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
  111. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
@@ -12,9 +12,10 @@ export type TreeNode = {
12
12
  version?: number
13
13
  drift?: number
14
14
  ghost?: boolean
15
- openIssues?: unknown[]
16
- scenarios?: { name: string }[]
17
- evals?: { scenario: string; fresh?: boolean }[]
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
- if (!n.scenarios?.length || !n.evals?.length) return 0
31
- const latest = new Map(n.evals.map((r) => [r.scenario, r]))
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.openIssues?.length) parts.push(c('31', `issues:${n.openIssues.length}`))
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.openIssues?.length ?? 0,
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 spec ASSET (.spec/.plugins) is NEVER touched — uninstall removes only generated
17
- // WIRING, not the spec graph that wiring served.
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.split('/').pop() === 'spexcode') return true
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
- // remove the SpexCode git hooks but ONLY a hook whose content carries a SpexCode identity marker, so a user's
69
- // own pre-commit is never deleted. Per-clone hooks are preserved by default; this runs only under `--hooks`.
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 name of ['pre-commit', 'prepare-commit-msg']) {
75
- const f = join(dir, name)
76
- if (existsSync(f) && /spexcode/i.test(readFileSync(f, 'utf8'))) {
77
- rmSync(f, { force: true })
78
- removed.push(name)
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 the per-tree materialize slots (trees/<enc>: manifest + content-hash +
107
- // plugin ledger), any legacy pre-slot manifest, and the session records. This is the runtime tier,
108
- // not the user's spec asset, so the whole dir is ours.
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. any spexcode-stamped plugin bundle, under the configured + standard plugin-host folders.
118
- let pluginHosts: string[] = [...DEFAULT_PLUGIN_HOSTS]
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 (store) console.log(`✓ removed the global per-project store (${store})`)
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
- // git hooks are per-clone and may carry user logic → preserved unless --hooks (and even then only a hook that
132
- // carries a SpexCode identity marker).
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/ and .plugins/ remain — your spec graph is YOURS, never deleted by uninstall.
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
- gcd=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
21
- || gcd=$(realpath "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null) || gcd=""
22
- if [ -n "$gcd" ]; then
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
- rd="${SPEXCODE_HOME:-$HOME/.spexcode}/projects/$(printf '%s' "$(dirname "$gcd")" | sed 's#[/.]#-#g')"
26
- if [ -e "$rd/sessions/$CODEX_THREAD_ID/session.json" ]; then
27
- sid="$CODEX_THREAD_ID" # a governed record keyed by the thread id (rare/direct)
28
- else # else the record that captured it as harness_session_id
29
- hit=$(grep -lF "\"harness_session_id\": \"$CODEX_THREAD_ID\"" "$rd"/sessions/*/session.json 2>/dev/null | head -1)
30
- [ -n "$hit" ] && sid=$(basename "$(dirname "$hit")")
31
- fi
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 responsibility nodes at contract altitude, intent never fabricated.
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 — code that has no specs yet. Aim for a
10
- tree where every governed file is claimed and `spex spec lint` is clean, with bodies at contract altitude and in
11
- the codebase's own primary language (a predominantly-Chinese repo Chinese specs).
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 (e.g. model-config + selection + auth +
28
- provider-compat → a *model* domain), and recurse until every level reads as a handful of siblings, not a
29
- flat wall. Fold cross-cutting substrate (design system, dialogs, i18n, utilities, platform glue) under one
30
- *foundation* node. A sub-domain node claims the cluster's barrel/wiring files (so it isn't pure-prose);
31
- equally, split a fat folder holding several distinct jobs. Group by responsibility, never to hit a number.
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 and the
48
- **expected** zero-loss result. Frontend scenarios are measured by looking — through the running product, as a real user would a screenshot filed with
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
- don't enumerate the whole partition up front or script a generator to emit it at once that burns context
54
- before a node lands and loses everything to one interruption. Fix the top-level cut and commit it, then take
55
- ONE subtree at a time (write the leaf, list its files, lint, COMMIT) before the next — never holding more than
56
- one subtree uncommitted, so progress survives context limits.
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>`) with a `Session:` trailer, and
60
- run `spex spec lint` after each: it must reach 0 errors, 0 coverage warnings, 0 altitude warnings.
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 too many direct children: a flat fan-out the eye can't hold. Breadth is altitude's structural twin the same comprehensibility limit, rotated from a body's depth onto the tree's width. Find the **natural seams** in the fan-out and lift the children onto them, *only where a real group exists*. Getting under the child budget is the floor, not the goal; never manufacture structure to hit a number.
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 pass altitude lint itself) — 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.
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 with no new errors. Uncommitted `git mv`s churn the drift count transiently — committing settles it; don't chase it.
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 ONE-SHOT PreToolUse nudge, wired alongside mark-active. The FIRST time a session
3
- # ACCESSES code READS or mutates a non-spec file WITHOUT having touched its spec, it blocks once to
4
- # remind: read the node's spec AND its neighbors first, then reconcile against it (change the spec, or make
5
- # the code honor it) never silently diverge. It once fired only on code-MUTATING tools, which let a pure
6
- # understanding/analysis session sail past it (the grounding gap): an agent reasoned straight from the code
7
- # without ever opening the contract. Widening the trigger to any code access (read or edit) closes that. The
8
- # sentinel makes it fire at most once per session; the re-issued tool call passes. An agent whose first code
9
- # touch IS its spec reading or editing it is blessed silently. Pure shell (no node/tsx).
10
- # @@@ harness-agnostic - WHICH tool/path counts as a code access is the [[harness-adapter]]'s call, read via
11
- # hp_code_path (Claude Read/Edit/Write/NotebookEdit + file_path; Codex tool_name:Bash + the parsed command
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 # already reminded or blessed this session → silent, every later access passes
18
+ [ -f "$sent" ] && exit 0
25
19
 
26
- # the code file(s) about to be read/edited (empty when this tool is not a code access, or no path resolved)
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
- # fires if ANY touched path is code; a touch that is ALL spec files IS spec-first → bless silently (set the
33
- # sentinel, allow). MUST come first: the nudge tells the agent to read its spec, so a spec-only access can
34
- # never be the thing we block.
35
- is_code=0
36
- while IFS= read -r p; do
37
- [ -n "$p" ] || continue
38
- case "$p" in */.spec/*|.spec/*|*/spec.md|spec.md) ;; *) is_code=1 ;; esac
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
- [ "$is_code" = 1 ] || { mkdir -p "$sdir"; : > "$sent"; exit 0; }
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
- node=$(sed -n 's/.*"node"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$rec" 2>/dev/null | head -1)
47
- if [ -n "$node" ]; then
48
- sp=$(find .spec -path "*/$node/spec.md" 2>/dev/null | head -1)
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 grounding gate. The first time a session touches code reads OR mutates any non-spec file without having opened its spec, it blocks once to demand the right order of work: read the governing node's spec first, since it is the current contract, and read its neighbors too, because a node's intent is only fully legible against the tree around it. Then reconcile deliberately change the spec if the intent is changing, or make the code honor it — never silently diverge.
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
- It blocks at most once per session: a sentinel records the first touch, and every later access passes. Reading is included on purpose, not just editing a pure analysis session that reasons straight from code without ever opening the contract is exactly the grounding gap this closes. Doing it right earns no nag: an agent whose first code touch IS its spec (reading or editing it) is blessed silently, and the session's own runtime state is ignored without consuming the one-shot.
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
- This enforces the read-the-contract-first rule of [[core]] at the moment of first contact, before understanding hardens around ungrounded code.
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.