spexcode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/spex.mjs +15 -0
- package/dashboard-dist/assets/index-B60MILFg.js +139 -0
- package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
- package/dashboard-dist/index.html +16 -0
- package/package.json +35 -0
- package/src/board.ts +119 -0
- package/src/cli.ts +487 -0
- package/src/client.ts +102 -0
- package/src/gateway.ts +241 -0
- package/src/git.ts +492 -0
- package/src/guide.ts +134 -0
- package/src/harness.ts +674 -0
- package/src/hooks.ts +41 -0
- package/src/index.ts +233 -0
- package/src/init.ts +120 -0
- package/src/layout.ts +246 -0
- package/src/lint.ts +206 -0
- package/src/login-page.ts +79 -0
- package/src/materialize.ts +85 -0
- package/src/pty-bridge.ts +235 -0
- package/src/ranker.ts +129 -0
- package/src/resilience.ts +41 -0
- package/src/search.bench.mjs +47 -0
- package/src/search.ts +24 -0
- package/src/self.ts +256 -0
- package/src/sessions.ts +1469 -0
- package/src/slash-commands.ts +242 -0
- package/src/specs.ts +331 -0
- package/src/supervise.ts +158 -0
- package/src/uploads.ts +31 -0
- package/templates/hooks/pre-commit +57 -0
- package/templates/hooks/prepare-commit-msg +14 -0
- package/templates/spec/project/.config/core/idle/idle.sh +15 -0
- package/templates/spec/project/.config/core/idle/spec.md +13 -0
- package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
- package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
- package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
- package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
- package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
- package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
- package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
- package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
- package/templates/spec/project/.config/core/spec.md +13 -0
- package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
- package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
- package/templates/spec/project/.config/extract/spec.md +60 -0
- package/templates/spec/project/.config/forge-link/spec.md +9 -0
- package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
- package/templates/spec/project/.config/regroup/spec.md +25 -0
- package/templates/spec/project/.config/scenario/spec.md +32 -0
- package/templates/spec/project/.config/spec.md +15 -0
- package/templates/spec/project/.config/supervisor/spec.md +8 -0
- package/templates/spec/project/.config/tidy/spec.md +25 -0
- package/templates/spec/project/spec.md +19 -0
- package/templates/spexcode.json +5 -0
package/src/supervise.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// SO_REUSEPORT is ENOTSUP on macOS/Node, so the supervisor owns the public port as a raw-TCP proxy and
|
|
2
|
+
// runs the Hono server as a child on a private port (raw piping carries WS upgrades too).
|
|
3
|
+
import net from 'node:net'
|
|
4
|
+
import http from 'node:http'
|
|
5
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
6
|
+
import { statSync, readdirSync, type Dirent } from 'node:fs'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { installProcessGuards } from './resilience.js'
|
|
10
|
+
import { resolvePublicConfig, startGateway, ensureDashboardBuilt, resolveDistDir } from './gateway.js'
|
|
11
|
+
|
|
12
|
+
// the supervisor OWNS the public port, so it must outlive any transient throw: an uncaught error here is
|
|
13
|
+
// logged and survived, never an exit that closes the port (and the tmux session) and takes the frontend down.
|
|
14
|
+
installProcessGuards()
|
|
15
|
+
|
|
16
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
const tsx = join(here, '..', 'node_modules', '.bin', 'tsx') // this package's own tsx (no build step)
|
|
18
|
+
const entry = join(here, 'index.ts') // the real Hono server
|
|
19
|
+
const publicPort = Number(process.env.PORT || 8787)
|
|
20
|
+
|
|
21
|
+
// @@@ public mode ([[public-mode]]) - with `spex serve --public`, the supervisor is NOT the internet face:
|
|
22
|
+
// the gateway is. The raw-TCP proxy retreats to a loopback internal port (the trusted boundary local agents
|
|
23
|
+
// reach) and the password-gated TLS gateway takes the public port, proxying /api + WS back to that loopback
|
|
24
|
+
// port. Off → unchanged: the proxy itself owns the public port and SPEXCODE_API_URL points there.
|
|
25
|
+
const publicCfg = resolvePublicConfig(join(here, '..', '..'))
|
|
26
|
+
// the port the raw-TCP proxy actually binds: a private loopback port in public mode, the public port otherwise.
|
|
27
|
+
const proxyPort = publicCfg ? await freePort() : publicPort
|
|
28
|
+
// what launched agents inherit as their `spex` endpoint — ALWAYS the loopback proxy, so local agents never
|
|
29
|
+
// meet the password gate (loopback is the trust boundary). Equals the public port when public mode is off.
|
|
30
|
+
const childApiBase = `http://127.0.0.1:${proxyPort}`
|
|
31
|
+
|
|
32
|
+
// every package src tree the child imports at runtime; spec-dashboard is absent (its own vite/HMR).
|
|
33
|
+
const repoRoot = join(here, '..', '..')
|
|
34
|
+
const watchRoots = [
|
|
35
|
+
here, // spec-cli/src — the backend's own source
|
|
36
|
+
join(repoRoot, 'spec-forge', 'src'),
|
|
37
|
+
join(repoRoot, 'spec-yatsu', 'src'),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
type Backend = { port: number; child: ChildProcess }
|
|
41
|
+
let current: Backend | null = null // which internal port new proxy connections forward to
|
|
42
|
+
let reloading = false // single-flight guard for reload()
|
|
43
|
+
let pending = false // a code change arrived mid-reload → reload again when done
|
|
44
|
+
|
|
45
|
+
// grab an ephemeral port by binding :0, then release it for the child to claim (negligible rebind race).
|
|
46
|
+
function freePort(): Promise<number> {
|
|
47
|
+
return new Promise((res, rej) => {
|
|
48
|
+
const s = net.createServer()
|
|
49
|
+
s.once('error', rej)
|
|
50
|
+
s.listen(0, '127.0.0.1', () => { const p = (s.address() as net.AddressInfo).port; s.close(() => res(p)) })
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// poll GET /health until 200; ~15s budget (150 × 100ms) covers a slow tsx+Hono cold start. False → keep old.
|
|
55
|
+
function waitHealthy(port: number, tries = 150): Promise<boolean> {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
const retry = (left: number) => { if (left <= 1) resolve(false); else setTimeout(() => attempt(left - 1), 100) }
|
|
58
|
+
const attempt = (left: number) => {
|
|
59
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/health', timeout: 1000 }, (r) => {
|
|
60
|
+
r.resume()
|
|
61
|
+
if (r.statusCode === 200) resolve(true); else retry(left)
|
|
62
|
+
})
|
|
63
|
+
req.on('error', () => retry(left))
|
|
64
|
+
req.on('timeout', () => { req.destroy(); retry(left) })
|
|
65
|
+
}
|
|
66
|
+
attempt(tries)
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// boot a fresh backend child on a free internal port and wait until it's serving.
|
|
71
|
+
async function boot(): Promise<Backend | null> {
|
|
72
|
+
const port = await freePort()
|
|
73
|
+
// PORT pins the child's PRIVATE bind port; SPEXCODE_API_URL pins everything the child SPAWNS (launched
|
|
74
|
+
// sessions + their hooks) at the PUBLIC port, so a launched agent's own `spex` reaches the stable proxy
|
|
75
|
+
// and never inherits this ephemeral, soon-retired port (apiBase() prefers SPEXCODE_API_URL over PORT).
|
|
76
|
+
const child = spawn(tsx, [entry], { stdio: 'inherit', env: { ...process.env, PORT: String(port), SPEXCODE_API_URL: process.env.SPEXCODE_API_URL || childApiBase } })
|
|
77
|
+
// if the ACTIVE backend dies unexpectedly (crash, OOM), restart it so the public port keeps serving.
|
|
78
|
+
// Planned retirement sets current to the NEW child first, so the old child's exit fails this identity
|
|
79
|
+
// check and is ignored. boot()'s ~5s health budget rate-limits any crash loop.
|
|
80
|
+
child.on('exit', (code, sig) => {
|
|
81
|
+
if (current?.child === child) { console.error(`[supervisor] active backend exited (${code ?? sig}) — restarting`); current = null; void reload('crash') }
|
|
82
|
+
})
|
|
83
|
+
if (await waitHealthy(port)) return { port, child }
|
|
84
|
+
try { child.kill('SIGKILL') } catch { /* already gone */ }
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// boot → health-gate → atomic flip → drain old. The flip is a single assignment; the old child is killed
|
|
89
|
+
// only after a drain delay, so a connection mid-flip is never refused.
|
|
90
|
+
async function reload(reason: string): Promise<void> {
|
|
91
|
+
if (reloading) { pending = true; return }
|
|
92
|
+
reloading = true
|
|
93
|
+
try {
|
|
94
|
+
do {
|
|
95
|
+
pending = false
|
|
96
|
+
const next = await boot()
|
|
97
|
+
if (!next) { console.error(`[supervisor] new backend failed health check (${reason}) — keeping current`); break }
|
|
98
|
+
const old = current
|
|
99
|
+
current = next // atomic flip: new connections now route to `next`
|
|
100
|
+
console.log(`[supervisor] reloaded (${reason}) → backend :${next.port}`)
|
|
101
|
+
if (old) setTimeout(() => { try { old.child.kill('SIGTERM') } catch { /* already gone */ } }, 500)
|
|
102
|
+
} while (pending)
|
|
103
|
+
} finally { reloading = false }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// the public port: a raw byte pipe to the current backend. Works for HTTP and WS upgrades alike.
|
|
107
|
+
const proxy = net.createServer((client) => {
|
|
108
|
+
const target = current
|
|
109
|
+
if (!target) { client.destroy(); return }
|
|
110
|
+
const up = net.connect(target.port, '127.0.0.1')
|
|
111
|
+
client.setNoDelay(true); up.setNoDelay(true) // proxy a request promptly — don't let Nagle add latency
|
|
112
|
+
const bail = () => { client.destroy(); up.destroy() }
|
|
113
|
+
client.on('error', bail); up.on('error', bail)
|
|
114
|
+
client.pipe(up); up.pipe(client)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const shutdown = () => { try { current?.child.kill('SIGTERM') } catch { /* */ } process.exit(0) }
|
|
118
|
+
process.on('SIGINT', shutdown)
|
|
119
|
+
process.on('SIGTERM', shutdown)
|
|
120
|
+
|
|
121
|
+
const first = await boot()
|
|
122
|
+
if (!first) { console.error('[supervisor] initial backend failed to start'); process.exit(1) }
|
|
123
|
+
current = first
|
|
124
|
+
if (publicCfg) {
|
|
125
|
+
// public mode: the raw proxy stays on loopback; the password-gated gateway owns the public port.
|
|
126
|
+
const distDir = resolveDistDir() // bundled <pkg>/dashboard-dist when installed, else monorepo spec-dashboard/dist
|
|
127
|
+
ensureDashboardBuilt(repoRoot, distDir)
|
|
128
|
+
proxy.listen(proxyPort, '127.0.0.1', () => console.log(`spec-cli supervisor on loopback :${proxyPort} (zero-downtime reloads, backend :${first.port})`))
|
|
129
|
+
startGateway({ publicPort, upstreamPort: proxyPort, password: publicCfg.password, tls: publicCfg.tls, distDir })
|
|
130
|
+
} else {
|
|
131
|
+
proxy.listen(publicPort, () => console.log(`spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})`))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// watch every imported source tree; debounce a burst of writes (a merge touching several files across
|
|
135
|
+
// packages) into one reload. A root that can't be watched (a package absent in some checkout) is logged
|
|
136
|
+
// and skipped — the supervisor owns the public port and must never die over a missing watch.
|
|
137
|
+
// fs.watch — even recursive — is NOT reliable for a long-lived supervisor: its inotify watches are silently
|
|
138
|
+
// dropped when a watched dir is rewritten (a git merge, a `materialize`) and NEVER re-established, so reloads
|
|
139
|
+
// quietly stop (observed: the watcher fired a few times then went deaf for hours). A cheap mtime poll can't go
|
|
140
|
+
// deaf: every 2s, take the newest .ts/.js/.json mtime across the trees; a jump triggers the debounced reload
|
|
141
|
+
// (a burst of writes still lands as one). Polling a few small src trees is negligible cost.
|
|
142
|
+
let timer: NodeJS.Timeout | undefined
|
|
143
|
+
const newestMtime = (dir: string): number => {
|
|
144
|
+
let max = 0
|
|
145
|
+
let entries: Dirent[]
|
|
146
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return 0 } // a tree absent in some checkout → skip
|
|
147
|
+
for (const e of entries) {
|
|
148
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') max = Math.max(max, newestMtime(join(dir, e.name))) }
|
|
149
|
+
else if (/\.(ts|js|mjs|json)$/.test(e.name)) { try { max = Math.max(max, statSync(join(dir, e.name)).mtimeMs) } catch { /* raced unlink */ } }
|
|
150
|
+
}
|
|
151
|
+
return max
|
|
152
|
+
}
|
|
153
|
+
const scanMtime = (): number => { let m = 0; for (const root of watchRoots) m = Math.max(m, newestMtime(root)); return m }
|
|
154
|
+
let lastMtime = scanMtime() // baseline at boot — only a LATER change reloads
|
|
155
|
+
setInterval(() => {
|
|
156
|
+
const m = scanMtime()
|
|
157
|
+
if (m > lastMtime) { lastMtime = m; clearTimeout(timer); timer = setTimeout(() => void reload('code change'), 150) }
|
|
158
|
+
}, 2000).unref()
|
package/src/uploads.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { basename, join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
// the backend's tmpdir — same /tmp home as the rendezvous sockets (sessions.ts), shared with the worker.
|
|
6
|
+
const UPLOAD_DIR = join(tmpdir(), 'spexcode-uploads')
|
|
7
|
+
|
|
8
|
+
// a generous ceiling so a stray huge upload can't quietly fill /tmp — over it we fail loud (the route 413s)
|
|
9
|
+
// rather than write. Real screenshots/attachments sit far under this.
|
|
10
|
+
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
|
11
|
+
|
|
12
|
+
// strip a client-supplied filename to a safe basename: no directory parts, only [A-Za-z0-9._-], no leading
|
|
13
|
+
// dots — so a crafted name (`../../etc/x`, `.bashrc`) can never escape UPLOAD_DIR. The extension is kept for
|
|
14
|
+
// readability; an empty/exotic name falls back to a generic stem.
|
|
15
|
+
function safeName(name: string): string {
|
|
16
|
+
const base = basename(name || '').replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '')
|
|
17
|
+
return base || 'upload'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let seq = 0
|
|
21
|
+
|
|
22
|
+
// write one uploaded file into UPLOAD_DIR under a collision-proof `<time>-<seq>-<name>` stem and return its
|
|
23
|
+
// absolute path — the string the dashboard splices into the prompt. Creates the dir on first use.
|
|
24
|
+
export async function saveUpload(file: File): Promise<string> {
|
|
25
|
+
mkdirSync(UPLOAD_DIR, { recursive: true })
|
|
26
|
+
const buf = Buffer.from(await file.arrayBuffer())
|
|
27
|
+
const stamp = `${Date.now().toString(36)}-${(seq++).toString(36)}`
|
|
28
|
+
const path = join(UPLOAD_DIR, `${stamp}-${safeName(file.name)}`)
|
|
29
|
+
writeFileSync(path, buf)
|
|
30
|
+
return path
|
|
31
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ main-guard - the worktree model: never author directly on main; main only RECEIVES merges.
|
|
3
|
+
# Reject a direct commit while HEAD is main. Merges pass (MERGE_HEAD present).
|
|
4
|
+
# Escape hatch for seeding / eager topology: SPEXCODE_ALLOW_MAIN=1 git commit …
|
|
5
|
+
branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo)
|
|
6
|
+
git_dir=$(git rev-parse --git-dir 2>/dev/null)
|
|
7
|
+
if [ "$branch" = "main" ] && [ ! -f "$git_dir/MERGE_HEAD" ] && [ -z "${SPEXCODE_ALLOW_MAIN:-}" ]; then
|
|
8
|
+
echo "✗ SpexCode: direct commits on main are blocked." >&2
|
|
9
|
+
echo " Work in a worktree (.worktrees/<node>, branch node/<id>) and merge back." >&2
|
|
10
|
+
echo " Merges pass automatically; for seeding/topology: SPEXCODE_ALLOW_MAIN=1 git commit …" >&2
|
|
11
|
+
exit 1
|
|
12
|
+
fi
|
|
13
|
+
|
|
14
|
+
# @@@ spec-lint - the hook is just a thin shim over `spex lint`. It blocks on errors only (broken
|
|
15
|
+
# spec↔code links); coverage/drift are warnings. Bypass with SPEXCODE_SKIP_LINT=1.
|
|
16
|
+
#
|
|
17
|
+
# Resolving the `spex` CLI must work for BOTH ways this hook ships: a real project that installed
|
|
18
|
+
# @spexcode/spec-cli, and the SpexCode monorepo dogfooding itself from source. cwd stays the committing
|
|
19
|
+
# worktree either way, so lint validates THIS worktree's `.spec` (repoRoot() discovers the cwd's toplevel
|
|
20
|
+
# via git.ts, which strips the hook's GIT_DIR). First resolution that exists wins:
|
|
21
|
+
# 1. `spex` on PATH - installed globally / on the shell PATH.
|
|
22
|
+
# 2. <repo>/node_modules/.bin/spex - a project that did `npm i @spexcode/spec-cli` (the dep's bin).
|
|
23
|
+
# 3. monorepo tsx + cli.ts by path - dogfood: run the CLI from source. main is the parent of the shared
|
|
24
|
+
# git *common* dir (a fresh session worktree has no node_modules of its own, so we reach the main one).
|
|
25
|
+
if [ -z "${SPEXCODE_SKIP_LINT:-}" ]; then
|
|
26
|
+
main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
|
|
27
|
+
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
28
|
+
# @@@ one resolved runner - resolve `spex` ONCE into spex_run(), so every spec-cli pre-commit check
|
|
29
|
+
# (lint, the yatsu stray-blob backstop) shims through the same mechanism instead of re-resolving.
|
|
30
|
+
spex_found=1
|
|
31
|
+
if command -v spex >/dev/null 2>&1; then
|
|
32
|
+
spex_run() { spex "$@" >&2; }
|
|
33
|
+
elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
|
|
34
|
+
spex_run() { "$repo_root/node_modules/.bin/spex" "$@" >&2; }
|
|
35
|
+
elif [ -x "$main_root/spec-cli/node_modules/.bin/tsx" ] && [ -f "$main_root/spec-cli/src/cli.ts" ]; then
|
|
36
|
+
spex_run() { "$main_root/spec-cli/node_modules/.bin/tsx" "$main_root/spec-cli/src/cli.ts" "$@" >&2; }
|
|
37
|
+
else
|
|
38
|
+
spex_found=0
|
|
39
|
+
fi
|
|
40
|
+
if [ "$spex_found" = 1 ]; then
|
|
41
|
+
if ! spex_run lint; then
|
|
42
|
+
echo "✗ SpexCode: spec-lint failed — fix the spec↔code links or bypass with SPEXCODE_SKIP_LINT=1." >&2
|
|
43
|
+
exit 1
|
|
44
|
+
fi
|
|
45
|
+
# @@@ yatsu backstop - reject a staged yatsu offender: a stray pixel blob copied into the tree (blobs
|
|
46
|
+
# belong in the shared git common dir, never committed) OR a malformed yatsu.md (a scenario schema
|
|
47
|
+
# violation). Logic lives in spec-yatsu; this is the lint-shim's twin. (this node's contract)
|
|
48
|
+
if ! spex_run yatsu check-staged; then
|
|
49
|
+
echo "✗ SpexCode: yatsu check-staged failed (see above) — fix it or bypass with SPEXCODE_SKIP_LINT=1." >&2
|
|
50
|
+
exit 1
|
|
51
|
+
fi
|
|
52
|
+
else
|
|
53
|
+
# Advisory hook: no CLI found (project hasn't installed @spexcode/spec-cli; monorepo not `npm install`ed).
|
|
54
|
+
echo "• SpexCode: spec-lint skipped — no \`spex\` CLI found (install @spexcode/spec-cli); CI still enforces." >&2
|
|
55
|
+
fi
|
|
56
|
+
fi
|
|
57
|
+
exit 0
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ session-stamp - auto-attribute a commit to the Claude Code session that authored it. SpexCode
|
|
3
|
+
# reads the `Session:` trailer as a version's attribution (git is the database). CLAUDE_CODE_SESSION_ID
|
|
4
|
+
# is the live session id Claude Code exports — and it EQUALS the `--session-id` we launch worktree
|
|
5
|
+
# sessions with, so a spec node's version attribution links to the live session in the dashboard's
|
|
6
|
+
# session window for free. No-op outside Claude Code, or if a Session: trailer is already present
|
|
7
|
+
# (so the dogfood ritual / a human can still set it explicitly).
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
msg_file="$1"
|
|
10
|
+
sid="${CLAUDE_CODE_SESSION_ID:-}"
|
|
11
|
+
[ -z "$sid" ] && exit 0
|
|
12
|
+
grep -qiE '^Session:[[:space:]]' "$msg_file" && exit 0
|
|
13
|
+
printf '\nSession: %s\n' "$sid" >> "$msg_file"
|
|
14
|
+
exit 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# On an idle_prompt notification, mark the session idle (the active-only guard in `session idle` keeps a
|
|
3
|
+
# deliberate awaiting/asking/parked/error declaration from being clobbered). GATED on `governed`: only a
|
|
4
|
+
# dashboard-launched session has board state to mark — a self-launched agent's idle is none of our business.
|
|
5
|
+
# State lives in the per-session GLOBAL record session.json (keyed by the harness session_id, grouped per-
|
|
6
|
+
# project — see hp_store_dir); the id is passed to the cli via `--session` so it writes the right record
|
|
7
|
+
# without depending on the worktree (which no longer holds any session file). NOTE the Notification event is
|
|
8
|
+
# Claude-only ([[harness-adapter]]: Codex fires no Notification), so this never runs under Codex.
|
|
9
|
+
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
10
|
+
payload=$(cat 2>/dev/null)
|
|
11
|
+
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
12
|
+
sdir=$(hp_store_dir "$sid") || exit 0
|
|
13
|
+
rec="$sdir/session.json"
|
|
14
|
+
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
15
|
+
[ "$(hp_notification_type "$payload")" = idle_prompt ] && exec ${SPEX:-spex} session idle --session "$sid"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: idle
|
|
3
|
+
surface: hook
|
|
4
|
+
status: active
|
|
5
|
+
hue: 200
|
|
6
|
+
events:
|
|
7
|
+
- Notification
|
|
8
|
+
order: 10
|
|
9
|
+
block: false
|
|
10
|
+
---
|
|
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
|
+
|
|
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.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ mark-active - the SINGLE freshness hook, wired to BOTH UserPromptSubmit and PreToolUse. It branches
|
|
3
|
+
# on ONE structured signal read straight from the hook payload (stdin JSON), so the state is HARD — never
|
|
4
|
+
# text-sniffed from the TUI:
|
|
5
|
+
# the agent is pausing to ask the HUMAN (hp_is_ask) → status: asking, with the question text as the note
|
|
6
|
+
# (the deterministic capture of a question).
|
|
7
|
+
# any other tool, or a prompt submit → the agent is working → status: active (drop a now-stale proposal/note).
|
|
8
|
+
# WHAT counts as "asking" is the [[harness-adapter]]'s call (Claude: the AskUserQuestion tool; Codex: the
|
|
9
|
+
# request_user_input tool) — read via hp_is_ask, so this hook never names a harness tool.
|
|
10
|
+
# Fires BEFORE the tool runs, so a `spex session done` declaration (itself a tool) lands AFTER this and wins;
|
|
11
|
+
# the next real tool flips back to active, forcing a fresh Stop-gate declaration. Pure shell (no node/tsx) so
|
|
12
|
+
# it stays cheap on every tool call — it value-replaces status/proposal/note in session.json with sed, never jq.
|
|
13
|
+
# @@@ global store - state lives NOT in the worktree but in the per-session GLOBAL record session.json, keyed
|
|
14
|
+
# by the harness session_id, grouped per-project (see hp_store_dir). GATED on `governed`: a user-self-launched
|
|
15
|
+
# (non-governed) session has no board to feed, so this no-ops on it. cwd = the session worktree.
|
|
16
|
+
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
17
|
+
payload=$(cat 2>/dev/null)
|
|
18
|
+
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
19
|
+
sdir=$(hp_store_dir "$sid") || exit 0
|
|
20
|
+
rec="$sdir/session.json"
|
|
21
|
+
# board-lifecycle gate: only a GOVERNED (dashboard-launched) session has a board state to maintain.
|
|
22
|
+
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
23
|
+
|
|
24
|
+
jget() { sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" "$rec" 2>/dev/null | head -1; }
|
|
25
|
+
|
|
26
|
+
if [ -n "$(hp_is_ask "$payload")" ]; then
|
|
27
|
+
status=asking
|
|
28
|
+
note=$(hp_ask_note "$payload") # first question's text → the note (best-effort)
|
|
29
|
+
else
|
|
30
|
+
status=active
|
|
31
|
+
note=
|
|
32
|
+
fi
|
|
33
|
+
|
|
34
|
+
# cheap path: already active with nothing stale to clear → no-op (the common every-tool case).
|
|
35
|
+
[ "$status" = active ] && [ "$(jget status)" = active ] && [ -z "$(jget proposal)" ] && [ -z "$(jget note)" ] && exit 0
|
|
36
|
+
|
|
37
|
+
# value-replace status + clear proposal + (re)set note, in place. The record is written one-field-per-line
|
|
38
|
+
# with these keys ALWAYS present (sessions.ts writeRecord), so each is a single value substitution — no key
|
|
39
|
+
# add/remove, no JSON parser. Escape \ / & in the note for the sed REPLACEMENT (the note never contains ").
|
|
40
|
+
note_esc=$(printf '%s' "$note" | sed 's/[\\/&]/\\&/g')
|
|
41
|
+
tmp=$(mktemp) || exit 0
|
|
42
|
+
sed -e "s/\(\"status\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$status\"/" \
|
|
43
|
+
-e "s/\(\"proposal\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"\"/" \
|
|
44
|
+
-e "s/\(\"note\"[[:space:]]*:[[:space:]]*\)\"[^\"]*\"/\1\"$note_esc\"/" \
|
|
45
|
+
"$rec" > "$tmp" && mv "$tmp" "$rec"
|
|
46
|
+
exit 0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: mark-active
|
|
3
|
+
surface: hook
|
|
4
|
+
status: active
|
|
5
|
+
hue: 200
|
|
6
|
+
events:
|
|
7
|
+
- UserPromptSubmit
|
|
8
|
+
- PreToolUse
|
|
9
|
+
order: 10
|
|
10
|
+
block: false
|
|
11
|
+
---
|
|
12
|
+
The single freshness signal for a session. Any work a session does — a new prompt, or any tool about to run — flips its declared state to `active`, which also drops a now-stale proposal or note: once the agent is moving again, an old "ready to merge" claim no longer stands. The one exception is asking the human: when the tool is AskUserQuestion the state becomes `asking`, carrying the question itself as the note, so a pause for the human is captured deterministically without the agent having to also declare it.
|
|
13
|
+
|
|
14
|
+
The state is read from ONE structured field in the hook payload, never sniffed from the terminal UI, so the signal is hard rather than guessed. Because it fires before the tool runs, a deliberate [[stop-gate]] declaration (itself made via a tool) lands after this and wins; the next real tool flips back to `active`, forcing a fresh declaration at the following stop. It is pure shell so it stays cheap firing on every tool call.
|
|
15
|
+
|
|
16
|
+
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.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Mark the session errored when a turn ends on an API failure (StopFailure). GATED on `governed`: only a
|
|
3
|
+
# dashboard-launched session has board state to mark. State lives in the per-session GLOBAL record (keyed by
|
|
4
|
+
# the harness session_id, grouped per-project — see hp_store_dir); the id is passed to the cli via `--session`
|
|
5
|
+
# so it writes the right record without depending on the worktree.
|
|
6
|
+
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
7
|
+
payload=$(cat 2>/dev/null)
|
|
8
|
+
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
9
|
+
sdir=$(hp_store_dir "$sid") || exit 0
|
|
10
|
+
rec="$sdir/session.json"
|
|
11
|
+
grep -q '"governed"[[:space:]]*:[[:space:]]*true' "$rec" 2>/dev/null || exit 0
|
|
12
|
+
exec ${SPEX:-spex} session fail --session "$sid"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: session-fail
|
|
3
|
+
surface: hook
|
|
4
|
+
status: active
|
|
5
|
+
hue: 200
|
|
6
|
+
events:
|
|
7
|
+
- StopFailure
|
|
8
|
+
order: 10
|
|
9
|
+
block: false
|
|
10
|
+
---
|
|
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
|
+
|
|
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.
|
|
@@ -0,0 +1,54 @@
|
|
|
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.
|
|
18
|
+
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
19
|
+
payload=$(cat 2>/dev/null)
|
|
20
|
+
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
21
|
+
sdir=$(hp_store_dir "$sid") || exit 0
|
|
22
|
+
rec="$sdir/session.json"
|
|
23
|
+
sent="$sdir/spec-checked"
|
|
24
|
+
[ -f "$sent" ] && exit 0 # already reminded or blessed this session → silent, every later access passes
|
|
25
|
+
|
|
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)
|
|
30
|
+
[ -n "$paths" ] || exit 0
|
|
31
|
+
|
|
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
|
|
39
|
+
done <<EOF
|
|
40
|
+
$paths
|
|
41
|
+
EOF
|
|
42
|
+
[ "$is_code" = 1 ] || { mkdir -p "$sdir"; : > "$sent"; exit 0; }
|
|
43
|
+
|
|
44
|
+
# first code access without having touched the spec → set the sentinel (so this fires exactly once), nudge once.
|
|
45
|
+
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 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"
|
|
54
|
+
exit 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: spec-first
|
|
3
|
+
surface: hook
|
|
4
|
+
status: active
|
|
5
|
+
hue: 200
|
|
6
|
+
events:
|
|
7
|
+
- PreToolUse
|
|
8
|
+
order: 20
|
|
9
|
+
block: true
|
|
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.
|
|
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.
|
|
14
|
+
|
|
15
|
+
This enforces the read-the-contract-first rule of [[core]] at the moment of first contact, before understanding hardens around ungrounded code.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# @@@ spec-of-file - a PostToolUse ANNOTATE hook: the FIRST time a session edits a given file, it tells the
|
|
3
|
+
# agent which spec node(s) GOVERN it — and, when a file is OVER-owned (> maxOwners), flags it as doing too
|
|
4
|
+
# much and points at the split — so the contract is in view AT THE MOMENT OF THE EDIT, not just later at
|
|
5
|
+
# commit (lint/drift). NON-BLOCKING (additionalContext only — never a verdict) and dedup'd PER FILE via a
|
|
6
|
+
# ledger, so a 50-edit refactor annotates each file ONCE. Uses MAIN's tsx+cli ($SPEX) for the file→spec
|
|
7
|
+
# resolve (`spex owner`); cwd = the session worktree.
|
|
8
|
+
# @@@ harness-agnostic - WHICH tool/path counts as a code MUTATION is the [[harness-adapter]]'s call, read via
|
|
9
|
+
# hp_code_path … mutate (Claude Edit/Write/NotebookEdit + file_path; Codex tool_name:Bash + an apply_patch /
|
|
10
|
+
# write-shape command). So this annotates edits on Claude AND Codex.
|
|
11
|
+
# @@@ all sessions, global ledger - like [[spec-first]], spec-awareness is UNIVERSAL so this is NOT gated on
|
|
12
|
+
# `governed`. The once-per-file ledger lives in the session's GLOBAL store dir (keyed by the harness
|
|
13
|
+
# session_id, grouped per-project — see hp_store_dir).
|
|
14
|
+
. "${SPEXCODE_HARNESS_LIB:?harness.sh not exported by dispatch.sh}"
|
|
15
|
+
S="${SPEX:-spex}"
|
|
16
|
+
payload=$(cat 2>/dev/null)
|
|
17
|
+
sid=$(hp_session_id "$payload"); [ -n "$sid" ] || exit 0
|
|
18
|
+
sdir=$(hp_store_dir "$sid") || exit 0
|
|
19
|
+
|
|
20
|
+
# the code file(s) just MUTATED (empty when this tool didn't mutate a file, e.g. a pure read). A codex
|
|
21
|
+
# multi-file apply_patch yields several paths (one per line) — annotate EACH governed code file, once.
|
|
22
|
+
paths=$(hp_code_path "$payload" mutate)
|
|
23
|
+
[ -n "$paths" ] || exit 0
|
|
24
|
+
led="$sdir/spec-of-file-seen" # dedupe: once per session per file. Lists already-annotated paths.
|
|
25
|
+
msg=""
|
|
26
|
+
while IFS= read -r path; do
|
|
27
|
+
[ -n "$path" ] || continue
|
|
28
|
+
# editing the spec itself is not a governed-code edit → nothing to annotate.
|
|
29
|
+
case "$path" in */.spec/*|.spec/*|*/spec.md|spec.md) continue ;; esac
|
|
30
|
+
[ -f "$led" ] && grep -qxF -- "$path" "$led" && continue
|
|
31
|
+
mkdir -p "$sdir"; echo "$path" >> "$led"
|
|
32
|
+
m=$($S owner "$path" --actionable 2>/dev/null) # --actionable: silent on a sanely-owned file; speaks only for an OVER-owned / uncovered file
|
|
33
|
+
[ -n "$m" ] || continue
|
|
34
|
+
msg="${msg:+$msg
|
|
35
|
+
}$m"
|
|
36
|
+
done <<EOF
|
|
37
|
+
$paths
|
|
38
|
+
EOF
|
|
39
|
+
[ -n "$msg" ] || exit 0
|
|
40
|
+
esc=$(printf '%s' "$msg" | sed 's/\\/\\\\/g; s/"/\\"/g' | awk 'BEGIN{ORS=""} NR>1{print "\\n"} {print}')
|
|
41
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"%s"}}\n' "$esc"
|
|
42
|
+
exit 0
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: spec-of-file
|
|
3
|
+
surface: hook
|
|
4
|
+
status: active
|
|
5
|
+
hue: 200
|
|
6
|
+
events:
|
|
7
|
+
- PostToolUse
|
|
8
|
+
order: 10
|
|
9
|
+
block: false
|
|
10
|
+
---
|
|
11
|
+
A non-blocking per-edit annotation. The first time a session edits a given file, it names the spec node(s) that GOVERN that file — and, when a file is over-owned, flags that it is doing too much and points at the split — so the contract is in view at the very moment of the edit, not only later at commit or drift time.
|
|
12
|
+
|
|
13
|
+
It never renders a verdict: it only adds context, so it can inform without interrupting. It is deduplicated once per file via a session ledger, so a fifty-edit refactor annotates each file once rather than on every write — the discipline that keeps a pervasive signal from decaying into the noise it is meant to cure. It speaks only when there is something to say: a sanely-owned file draws silence, an over-owned or uncovered one draws the pointer.
|
|
14
|
+
|
|
15
|
+
This is the at-the-keystroke companion to the read-first gate [[spec-first]] and the commit-time checks: together they keep the [[core]] rule — code must not silently diverge from its spec — visible across the whole edit loop.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: core
|
|
3
|
+
surface: system
|
|
4
|
+
status: active
|
|
5
|
+
hue: 200
|
|
6
|
+
desc: A config plugin — the minimal spec-discipline contract folded into every launched agent.
|
|
7
|
+
code:
|
|
8
|
+
---
|
|
9
|
+
Commit your spec node and the code it justifies BEFORE you declare done or propose merge — the commit comes first, never as an afterthought to a declaration.
|
|
10
|
+
|
|
11
|
+
A spec body is a living current-state document: it states the node's PRESENT intent and is rewritten in place. Never accrete a "## vN" changelog heading, and never add current-state or verdict sections — version history is git's job, not the body's.
|
|
12
|
+
|
|
13
|
+
An independently-scoped feature gets its OWN spec node: if you build something separately scoped while working, create a sibling node for it rather than bundling it into your assigned node's commit (cosmetic polish riding along is the smell).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: stop-gate
|
|
3
|
+
surface: hook
|
|
4
|
+
status: active
|
|
5
|
+
hue: 200
|
|
6
|
+
events:
|
|
7
|
+
- Stop
|
|
8
|
+
order: 10
|
|
9
|
+
block: true
|
|
10
|
+
---
|
|
11
|
+
The blocking stop gate, with two jobs, each holding a hard loop-break so it never blocks twice on the same cause and never lets a dishonest stop through.
|
|
12
|
+
|
|
13
|
+
The COMMIT gate keeps a done/merge proposal honest: such a proposal is rejected while the branch still carries uncommitted work or is zero commits ahead of main, because the ritual commits the spec and code BEFORE proposing. Clean work is allowed to stop; a dirty proposal blocks once with the reason, and if the agent ignores it the gate escapes by downgrading to `asking` so a false "ready to merge" can never stand.
|
|
14
|
+
|
|
15
|
+
The DECLARE gate refuses to let a session stop in an undeclared `active` state, since a state is a claim the board and other agents act on, not a box ticked to end a turn. A declared state stops freely; an undeclared first stop blocks once to make the agent pick the true state; on the forced continuation it auto-declares a safe default — committed work becomes `awaiting`, otherwise `asking` — so the loop is guaranteed to end.
|
|
16
|
+
|
|
17
|
+
It is the enforcement edge of [[core]]: nothing leaves a session except as committed work under a truthful declaration. The freshness it reads is set by [[mark-active]].
|