spexcode 0.1.6 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -35
- package/README.zh-CN.md +135 -0
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +184 -122
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +64 -24
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +186 -19
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +362 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +279 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +301 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +700 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +32 -0
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +145 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
// @@@ the local issue store - the LOCAL store of the one Issue object ([[issues]] / [[local-issues]] /
|
|
2
|
+
// [[mentions]]). A thread IS an Issue whose `store` is 'local' — membership implied by WHERE the file lives,
|
|
3
|
+
// never written into it. One thread = one PLAIN markdown file at <main>/.spec/.issues/<id>.md; there is
|
|
4
|
+
// deliberately no content-kind taxonomy (a change suggestion, an annotation, a Q&A are the same mechanism —
|
|
5
|
+
// the prose says what it is). Others sign/reply/discuss like an async chatroom; a supervisor drains it via
|
|
6
|
+
// `spex issues` (reading is the port's job, issues.ts — this module owns only the store + its write verbs).
|
|
7
|
+
// Because a thread file is NOT named spec.md, the spec walk never nodes it and isSpecMd ignores it —
|
|
8
|
+
// invisible to lint / drift / deriveStatus / board with ZERO exemption. The store lives on the TRUNK, not
|
|
9
|
+
// per-branch: reads and writes target the main checkout and commit STRAIGHT to it (--no-verify, provably
|
|
10
|
+
// store-only), so a post-merge thread lands durably even though the author's own branch already merged.
|
|
11
|
+
// A committing write is allowed ONLY from the trunk checkout itself (isPrimaryCheckout) — a linked-worktree
|
|
12
|
+
// backend sharing that main is refused loud, never left to fabricate a stray commit on a main it doesn't own;
|
|
13
|
+
// SPEXCODE_ISSUES_DIR routes an e2e/test rig to a disposable plain-file store (see localStoreDir below).
|
|
14
|
+
// The on-disk dir was historically `.spec/.forum`; a one-shot self-migration ([[issues-store-rename]])
|
|
15
|
+
// renames any legacy `.spec/.forum` to `.spec/.issues` on the first store touch after a toolchain update.
|
|
16
|
+
import { readdirSync, existsSync, mkdirSync, writeFileSync, readFileSync, rmdirSync, statSync } from 'node:fs'
|
|
17
|
+
import { join, dirname, resolve as resolvePath } from 'node:path'
|
|
18
|
+
import { git, headSha, repoRoot } from './git.js'
|
|
19
|
+
import { mainCheckout, envSessionId, readConfig } from './layout.js'
|
|
20
|
+
import { parseMentions, dispatchMentions, notifyOriginator, deliveredIds, summarize, type DispatchOutcome, type LoopIn } from './mentions.js'
|
|
21
|
+
import type { Issue, Reply } from './issues.js'
|
|
22
|
+
|
|
23
|
+
const LOCAL_STORE_REL = '.spec/.issues'
|
|
24
|
+
// the pre-rename dir ([[issues-store-rename]]); ensureStoreMigrated() renames it to LOCAL_STORE_REL once.
|
|
25
|
+
const LEGACY_STORE_REL = '.spec/.forum'
|
|
26
|
+
|
|
27
|
+
// @@@ the on/off switch - the issues workflow is an OPT-OUTABLE feature (default ON). The single source of
|
|
28
|
+
// truth is `spexcode.json`'s `issues.enabled` (the same settings file that carries every other toggle),
|
|
29
|
+
// read via readConfig so a machine-local `spexcode.local.json` can override it. OFF silences the post-merge
|
|
30
|
+
// nudge (and, in the dashboard, hides the issues view); the raw write verbs stay usable, since running one
|
|
31
|
+
// is explicit consent. `spex issues on|off` flips the flag on disk — effective immediately, no commit
|
|
32
|
+
// needed, because readConfig reads the working tree. The dashboard toggle is a thin wrapper over this same
|
|
33
|
+
// switch. (The key was historically `proposals.enabled`; a pre-rename value still reads, and the next
|
|
34
|
+
// toggle write rewrites it under `issues` — the same self-heal-on-touch discipline as the store-dir rename.)
|
|
35
|
+
export const issuesEnabled = (): boolean => {
|
|
36
|
+
const cfg = readConfig(mainCheckout())
|
|
37
|
+
return cfg.issues?.enabled ?? (cfg as { proposals?: { enabled?: boolean } }).proposals?.enabled ?? true
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setEnabled(on: boolean): void {
|
|
41
|
+
const f = join(mainCheckout(), 'spexcode.json')
|
|
42
|
+
const cfg = existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : {}
|
|
43
|
+
cfg.issues = { ...(cfg.issues || {}), enabled: on }
|
|
44
|
+
delete cfg.proposals // the pre-rename key, superseded by `issues` on this write
|
|
45
|
+
writeFileSync(f, JSON.stringify(cfg, null, 2) + '\n')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const list = (v: string | undefined): string[] =>
|
|
49
|
+
v ? v.split(',').map((s) => s.trim()).filter(Boolean) : []
|
|
50
|
+
|
|
51
|
+
// @@@ where a store write lands — ONE deterministic rule ([[local-issues]] "only the trunk checkout may
|
|
52
|
+
// commit"). Every backend resolves the store to `mainCheckout()` (dirname of the shared git-common-dir), so a
|
|
53
|
+
// backend serving a LINKED WORKTREE would git-commit a stray issue onto the REAL, live main — dirtying it and
|
|
54
|
+
// racing its index.lock. The rule:
|
|
55
|
+
// 1. SPEXCODE_ISSUES_DIR override → a DISPOSABLE store of plain files (no git, no shared main); the e2e/test
|
|
56
|
+
// seam. Both reads and writes target it, so a test rig throws its whole store away.
|
|
57
|
+
// 2. else the trunk `.spec/.issues/` — but a *committing* write is allowed ONLY from the trunk checkout
|
|
58
|
+
// itself (repoRoot === mainCheckout: the primary backend, or a throwaway clone owning its own main). A
|
|
59
|
+
// linked-worktree backend is refused loud (requirePrimaryStore) rather than silently writing someone
|
|
60
|
+
// else's main. Reads always resolve to the trunk (visibility never hostages on being primary).
|
|
61
|
+
const overrideStoreDir = (): string | null => {
|
|
62
|
+
const p = process.env.SPEXCODE_ISSUES_DIR?.trim()
|
|
63
|
+
return p ? p : null
|
|
64
|
+
}
|
|
65
|
+
// is THIS process rooted in the trunk checkout itself? True for the primary backend and for a clone (its own
|
|
66
|
+
// .git → its own disposable main); false for a linked-worktree backend, the store-write footgun.
|
|
67
|
+
const isPrimaryCheckout = (): boolean => {
|
|
68
|
+
try { return resolvePath(repoRoot()) === resolvePath(mainCheckout()) } catch { return false }
|
|
69
|
+
}
|
|
70
|
+
// fail LOUD before a committing write from a non-trunk checkout: never fabricate a commit on a shared main
|
|
71
|
+
// this process does not own. (The disposable override bypasses this — it commits to nothing.)
|
|
72
|
+
function requirePrimaryStore(action: string): void {
|
|
73
|
+
if (isPrimaryCheckout()) return
|
|
74
|
+
throw new Error(
|
|
75
|
+
`refusing to ${action}: this backend serves a linked worktree (${resolvePath(repoRoot())}), not the trunk ` +
|
|
76
|
+
`checkout (${resolvePath(mainCheckout())}) — committing here would land a stray issue on the REAL main and ` +
|
|
77
|
+
`race its index. Run the write from the trunk checkout (or its backend), or, for a throwaway/e2e run, set ` +
|
|
78
|
+
`SPEXCODE_ISSUES_DIR=<disposable-dir> to write plain files nowhere near the real store.`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// the local issue store dir — a fixed path directly under the trunk's .spec (name-independent, unlike the
|
|
82
|
+
// .config system which nests under the named root node), OR the disposable override. Every read and write goes here.
|
|
83
|
+
const localStoreDir = (): string => overrideStoreDir() ?? join(mainCheckout(), LOCAL_STORE_REL)
|
|
84
|
+
// the author's signature: the effective governed session id (envSessionId handles the claude/codex split).
|
|
85
|
+
const currentSession = (): string => envSessionId() || 'unknown'
|
|
86
|
+
// a synchronous sleep for the commit-retry backoff (Date/timers-free, safe in any runtime).
|
|
87
|
+
const sleep = (ms: number) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
88
|
+
|
|
89
|
+
// ── file format ──────────────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
// frontmatter (concern/by/status/nodes/evidence/signers/created) + a prose body, then any replies — each
|
|
91
|
+
// preceded by a `<!-- reply: <by> @ <iso> -->` sentinel: invisible in rendered markdown, unambiguous to
|
|
92
|
+
// parse. Only the store writes these files, so a fixed shape is safe. Lists are `key: a, b` scalars so a
|
|
93
|
+
// +1 sign is a one-line change.
|
|
94
|
+
//
|
|
95
|
+
// A REMARK ([[remark-substrate]]) is a reply carrying extra state, appended to the SAME sentinel as a
|
|
96
|
+
// ` :: <space-joined k=v attrs>` tail (a plain reply has no tail → parses unchanged, backward compatible):
|
|
97
|
+
// rid=<id> stable per-remark id — its presence marks the reply a remark
|
|
98
|
+
// sha=<targetCodeSha> the reading the remark was authored against
|
|
99
|
+
// resolved=<by>@<at> present only once resolved (absent ⟹ resolved:false)
|
|
100
|
+
const REPLY_RE = /^<!-- reply: (.+?) @ (.+?)(?: :: (.+))? -->$/
|
|
101
|
+
|
|
102
|
+
function parse(id: string, text: string): Issue {
|
|
103
|
+
const m = text.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
|
|
104
|
+
const fm: Record<string, string> = {}
|
|
105
|
+
for (const line of (m ? m[1] : '').split('\n')) {
|
|
106
|
+
const mm = line.match(/^([a-zA-Z]+):\s*(.*)$/)
|
|
107
|
+
if (mm) fm[mm[1]] = mm[2].trim()
|
|
108
|
+
}
|
|
109
|
+
const body: string[] = []
|
|
110
|
+
const replies: Reply[] = []
|
|
111
|
+
let cur: Reply | null = null
|
|
112
|
+
for (const line of (m ? m[2] : text).replace(/^\n+/, '').split('\n')) {
|
|
113
|
+
const rm = line.match(REPLY_RE)
|
|
114
|
+
if (rm) { cur = { by: rm[1], at: rm[2], body: '', ...parseRemarkAttrs(rm[3]) }; replies.push(cur); continue }
|
|
115
|
+
if (cur) cur.body += (cur.body ? '\n' : '') + line
|
|
116
|
+
else body.push(line)
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
id,
|
|
120
|
+
store: 'local',
|
|
121
|
+
concern: fm.concern || id,
|
|
122
|
+
by: fm.by || 'unknown',
|
|
123
|
+
status: fm.status || 'open',
|
|
124
|
+
nodes: list(fm.nodes),
|
|
125
|
+
signers: list(fm.signers),
|
|
126
|
+
created: fm.created || '',
|
|
127
|
+
body: body.join('\n').trim(),
|
|
128
|
+
replies: replies.map((r) => ({ ...r, body: r.body.trim() })),
|
|
129
|
+
evidence: list(fm.evidence),
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// user body text must never FORGE a reply sentinel: a line matching `<!-- reply: … -->` in a body would be
|
|
134
|
+
// re-read as a thread boundary, splitting the thread and truncating the body. Neutralize the marker in user
|
|
135
|
+
// content (a zero-width space breaks the `<!-- reply:` prefix — invisible on render, idempotent), so only
|
|
136
|
+
// serialize's OWN sentinels parse as boundaries. Frontmatter scalars are single-line-stripped for the same
|
|
137
|
+
// reason (a newline/`---` in a concern can't break the block).
|
|
138
|
+
const safeBody = (t: string): string => t.trim().replace(/<!-- reply:/g, '<!--reply:')
|
|
139
|
+
const safeScalar = (t: string): string => t.replace(/[\r\n]+/g, ' ').trim()
|
|
140
|
+
|
|
141
|
+
// the ` :: k=v k=v` remark tail on a reply sentinel ↔ the reply's remark fields. `undefined` attrs (a plain
|
|
142
|
+
// reply) → no remark fields; `rid` present → the reply IS a remark ([[remark-substrate]]). Values are
|
|
143
|
+
// space-free (ids, a codeSha, a session id, an ISO instant), so a space-split is unambiguous.
|
|
144
|
+
function parseRemarkAttrs(attrs: string | undefined): Partial<Reply> {
|
|
145
|
+
if (!attrs) return {}
|
|
146
|
+
const kv = new Map<string, string>()
|
|
147
|
+
for (const tok of attrs.trim().split(/\s+/)) { const i = tok.indexOf('='); if (i > 0) kv.set(tok.slice(0, i), tok.slice(i + 1)) }
|
|
148
|
+
if (!kv.has('rid')) return {}
|
|
149
|
+
const out: Partial<Reply> = { rid: kv.get('rid'), targetCodeSha: kv.get('sha') ?? '', resolved: false }
|
|
150
|
+
const r = kv.get('resolved')
|
|
151
|
+
if (r) { const at = r.indexOf('@'); out.resolved = true; out.resolvedBy = r.slice(0, at); out.resolvedAt = r.slice(at + 1) }
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
function serializeRemarkAttrs(r: Reply): string {
|
|
155
|
+
if (r.rid === undefined) return ''
|
|
156
|
+
const parts = [`rid=${r.rid}`, `sha=${r.targetCodeSha ?? ''}`]
|
|
157
|
+
if (r.resolved) parts.push(`resolved=${r.resolvedBy ?? ''}@${r.resolvedAt ?? ''}`)
|
|
158
|
+
return ` :: ${parts.join(' ')}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function serialize(p: Issue): string {
|
|
162
|
+
const fm = [
|
|
163
|
+
`concern: ${safeScalar(p.concern)}`,
|
|
164
|
+
`by: ${safeScalar(p.by)}`,
|
|
165
|
+
`status: ${safeScalar(p.status)}`,
|
|
166
|
+
p.nodes.length ? `nodes: ${p.nodes.join(', ')}` : '',
|
|
167
|
+
p.evidence.length ? `evidence: ${p.evidence.join(', ')}` : '',
|
|
168
|
+
p.signers.length ? `signers: ${p.signers.join(', ')}` : '',
|
|
169
|
+
`created: ${p.created}`,
|
|
170
|
+
].filter(Boolean)
|
|
171
|
+
let out = `---\n${fm.join('\n')}\n---\n\n${safeBody(p.body)}\n`
|
|
172
|
+
for (const r of p.replies) out += `\n<!-- reply: ${safeScalar(r.by)} @ ${safeScalar(r.at)}${serializeRemarkAttrs(r)} -->\n${safeBody(r.body)}\n`
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function loadLocalIssues(): Issue[] {
|
|
177
|
+
ensureStoreMigrated() // read path: a pre-rename deployment migrates on first read, so no thread is lost
|
|
178
|
+
const dir = localStoreDir()
|
|
179
|
+
if (!existsSync(dir)) return []
|
|
180
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
181
|
+
.filter((e) => e.isFile() && e.name.endsWith('.md'))
|
|
182
|
+
.map((e) => parse(e.name.replace(/\.md$/, ''), readFileSync(join(dir, e.name), 'utf8')))
|
|
183
|
+
.sort((a, b) => a.created.localeCompare(b.created))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function loadOne(id: string): Issue {
|
|
187
|
+
ensureStoreMigrated()
|
|
188
|
+
const f = join(localStoreDir(), `${id}.md`)
|
|
189
|
+
if (!existsSync(f)) throw new Error(`no local issue '${id}' (see \`spex issues --all --store local\`)`)
|
|
190
|
+
return parse(id, readFileSync(f, 'utf8'))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// a filesystem-safe, readable, collision-free id from the concern (slug + numeric suffix if taken).
|
|
194
|
+
function uniqueId(concern: string): string {
|
|
195
|
+
const base = concern.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'issue'
|
|
196
|
+
const dir = localStoreDir()
|
|
197
|
+
let id = base
|
|
198
|
+
for (let n = 2; existsSync(join(dir, `${id}.md`)); n++) id = `${base}-${n}`
|
|
199
|
+
return id
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// @@@ store lock - local-issue writes contend two ways: git commits share the repo index.lock, and a
|
|
203
|
+
// reply's read-modify-write of one thread file loses an update if two race (both read the base, last write
|
|
204
|
+
// wins). A single cross-process lock serializes the WHOLE prepare→write→commit, killing both. It is an
|
|
205
|
+
// atomic `mkdir` (in .git, never committed); a lock whose holder crashed is stolen after it goes stale.
|
|
206
|
+
// Because the commit is `--no-verify` fast (below), serialized writes stay quick even under a burst. The
|
|
207
|
+
// lock FILE keeps its historical `spexcode-forum.lock` name ON PURPOSE: during the one-shot store-dir
|
|
208
|
+
// migration ([[issues-store-rename]]) an old and a new toolchain can run against the SAME checkout at once,
|
|
209
|
+
// and they mutually exclude only while contending on the SAME lock name — renaming it would open exactly
|
|
210
|
+
// the migration race the lock exists to close.
|
|
211
|
+
let lockHeld = false // re-entrancy guard so ensureStoreMigrated() no-ops when reached from inside a hold
|
|
212
|
+
function withStoreLock<T>(fn: () => T): T {
|
|
213
|
+
// the disposable store locks inside its own dir; the trunk store locks in the shared `.git` (one lock name
|
|
214
|
+
// across every worktree of the clone, so all writers of the SAME real store mutually exclude).
|
|
215
|
+
const override = overrideStoreDir()
|
|
216
|
+
const lock = override ? join(override, '.spexcode-issues.lock') : join(mainCheckout(), '.git', 'spexcode-forum.lock')
|
|
217
|
+
mkdirSync(dirname(lock), { recursive: true }) // parent must exist for the atomic mkdir-acquire (a no-op for .git)
|
|
218
|
+
for (let i = 0; ; i++) {
|
|
219
|
+
try { mkdirSync(lock); break } // atomic acquire
|
|
220
|
+
catch {
|
|
221
|
+
try { if (Date.now() - statSync(lock).mtimeMs > 20000) rmdirSync(lock) } catch { /* released meanwhile */ }
|
|
222
|
+
sleep(40 + Math.floor(Math.random() * 80)) // spin with jitter until free / stolen
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
lockHeld = true
|
|
226
|
+
try { return fn() } finally { lockHeld = false; try { rmdirSync(lock) } catch { /* already gone */ } }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// @@@ one-shot store-dir migration ([[issues-store-rename]]) - the local issue store's on-disk dir was
|
|
230
|
+
// historically `.spec/.forum`; it is now `.spec/.issues`. Any pre-rename deployment migrates ITSELF on the
|
|
231
|
+
// first store touch after its toolchain updates: a single committed `git mv` on the trunk, so a thread's
|
|
232
|
+
// whole reply history reads identically afterwards (`git log --follow` traces through the rename — the data
|
|
233
|
+
// is git, and a rename preserves it). Called at every store entrypoint BEFORE the lock is taken; it takes
|
|
234
|
+
// the lock itself to do the mv, so the whole find→check→mv is atomic against a concurrent first-touch burst:
|
|
235
|
+
// exactly ONE mv commit, because a racer that waited on the lock re-checks under it and finds nothing to do.
|
|
236
|
+
// Fast path: no legacy dir → instant, no lock (the common case after migration / on a fresh repo). Both
|
|
237
|
+
// dirs present (pathological) → fail LOUD with the repair, never a silent merge.
|
|
238
|
+
function ensureStoreMigrated(): void {
|
|
239
|
+
if (lockHeld) return // inside a hold: an outer entrypoint already ran this
|
|
240
|
+
if (overrideStoreDir()) return // disposable store: no legacy trunk dir to migrate
|
|
241
|
+
if (!isPrimaryCheckout()) return // a git-committing migration is trunk-only; the primary migrates on its next touch
|
|
242
|
+
const root = mainCheckout()
|
|
243
|
+
if (!existsSync(join(root, LEGACY_STORE_REL))) return // fresh or already-migrated: nothing to do (no lock)
|
|
244
|
+
withStoreLock(() => {
|
|
245
|
+
const r = mainCheckout()
|
|
246
|
+
const legacy = join(r, LEGACY_STORE_REL), current = join(r, LOCAL_STORE_REL)
|
|
247
|
+
if (!existsSync(legacy)) return // a racer migrated it while we waited on the lock
|
|
248
|
+
if (existsSync(current)) throw new Error(
|
|
249
|
+
`both ${LEGACY_STORE_REL} and ${LOCAL_STORE_REL} exist in ${r} — refusing to auto-merge the local ` +
|
|
250
|
+
`issue store. Reconcile by hand: move any threads from ${LEGACY_STORE_REL} into ${LOCAL_STORE_REL}, ` +
|
|
251
|
+
`then \`git rm -r ${LEGACY_STORE_REL} && git commit\`, and re-run.`)
|
|
252
|
+
git(['-C', r, 'mv', LEGACY_STORE_REL, LOCAL_STORE_REL])
|
|
253
|
+
git(['-C', r, 'commit', '--no-verify', '-m', `issues: store dir ${LEGACY_STORE_REL} → ${LOCAL_STORE_REL}`,
|
|
254
|
+
'--', LEGACY_STORE_REL, LOCAL_STORE_REL])
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// write + commit ONE store file STRAIGHT to the trunk. The commit is `--no-verify`: the file is DATA,
|
|
259
|
+
// structurally invisible to spec-lint, and the commit is provably store-only (one .spec/.issues/ path), so the
|
|
260
|
+
// pre-commit gate would only pass anyway — running it just burns seconds (tsx cold-start) holding the lock.
|
|
261
|
+
// A NO-CHANGE write is idempotent SUCCESS, never a failure: when the serialized bytes already equal the
|
|
262
|
+
// stored state (a duplicate resolve/sign — the store IS the requested state), `git commit` would exit 1
|
|
263
|
+
// 'nothing to commit', so we detect the no-op after `add` (the staged path equals HEAD → status is silent)
|
|
264
|
+
// and skip the commit. Returns whether the store actually changed, so a caller can say 'already <state>'.
|
|
265
|
+
// MUST run while holding withStoreLock — it is the write half of a locked read-modify-write; its callers
|
|
266
|
+
// (commitStore, findOrCreateEvalThread) own the lock, so it never acquires one itself (mkdir is not re-entrant).
|
|
267
|
+
function writeStoreFile(p: Issue, message: string): boolean {
|
|
268
|
+
const override = overrideStoreDir()
|
|
269
|
+
if (override) { // disposable store: a plain file, never a commit, never a shared main
|
|
270
|
+
mkdirSync(override, { recursive: true })
|
|
271
|
+
const f = join(override, `${p.id}.md`)
|
|
272
|
+
const data = serialize(p)
|
|
273
|
+
if (existsSync(f) && readFileSync(f, 'utf8') === data) return false
|
|
274
|
+
writeFileSync(f, data)
|
|
275
|
+
return true
|
|
276
|
+
}
|
|
277
|
+
requirePrimaryStore('write a local issue') // a linked-worktree backend must NOT commit onto the real main
|
|
278
|
+
const root = mainCheckout()
|
|
279
|
+
const rel = `${LOCAL_STORE_REL}/${p.id}.md`
|
|
280
|
+
mkdirSync(join(root, LOCAL_STORE_REL), { recursive: true })
|
|
281
|
+
writeFileSync(join(root, rel), serialize(p))
|
|
282
|
+
git(['-C', root, 'add', '--', rel])
|
|
283
|
+
if (!git(['-C', root, 'status', '--porcelain', '--', rel]).trim()) return false // staged == HEAD: the no-op
|
|
284
|
+
git(['-C', root, 'commit', '--no-verify', '-m', message, '--', rel])
|
|
285
|
+
return true
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// prepare (a FRESH read-modify or a new thread) + write + commit a single store file, all under the store
|
|
289
|
+
// lock so the read-modify-write is atomic. prepare() runs INSIDE the lock, so a reply/sign/resolve reads the
|
|
290
|
+
// current thread, never a stale copy. A pre-rename store migrates first (before the lock — ensure takes it).
|
|
291
|
+
// `changed: false` = the store already held exactly this state (see writeStoreFile) — success, nothing committed.
|
|
292
|
+
function commitStore(message: string, prepare: () => Issue): { issue: Issue; changed: boolean } {
|
|
293
|
+
ensureStoreMigrated()
|
|
294
|
+
return withStoreLock(() => {
|
|
295
|
+
const p = prepare()
|
|
296
|
+
const changed = writeStoreFile(p, message)
|
|
297
|
+
return { issue: p, changed }
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// `author` defaults to the effective session id, but a caller (the dashboard's human write path) may pass
|
|
302
|
+
// `'human'` — the write mechanism is identical either way, only the signature differs.
|
|
303
|
+
// The thread's `nodes:` are INFERRED from the text's `[[node]]` topic links ([[mentions]] — the one in-text
|
|
304
|
+
// reference primitive every input already carries), unioned with any explicitly-passed ids (`--node`): a
|
|
305
|
+
// writer links nodes by writing them, never by re-typing ids into a separate field.
|
|
306
|
+
export function openIssue(concern: string, opts: { nodes?: string[]; body?: string; evidence?: string[]; author?: string } = {}): Issue {
|
|
307
|
+
const nodes = [...new Set([...(opts.nodes || []), ...parseMentions(`${concern}\n${opts.body || ''}`).nodes])]
|
|
308
|
+
return commitStore(`issue: ${concern}`, () => ({
|
|
309
|
+
id: uniqueId(concern), // minted INSIDE the lock, so two racing posts can't pick the same id
|
|
310
|
+
store: 'local',
|
|
311
|
+
concern,
|
|
312
|
+
by: opts.author || currentSession(),
|
|
313
|
+
status: 'open',
|
|
314
|
+
nodes,
|
|
315
|
+
signers: [],
|
|
316
|
+
created: new Date().toISOString(),
|
|
317
|
+
body: (opts.body || `(no detail given — ${concern})`).trim(),
|
|
318
|
+
replies: [],
|
|
319
|
+
evidence: opts.evidence || [],
|
|
320
|
+
})).issue
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// mint a per-thread-unique remark id (retry on the rare collision within one thread). Short + readable so a
|
|
324
|
+
// `<thread-id>#<rid>` ref is typeable; stable, so a resolve/retract never lands on the wrong remark.
|
|
325
|
+
function mintRid(existing: Set<string>): string {
|
|
326
|
+
let rid: string
|
|
327
|
+
do { rid = 'r' + Math.random().toString(36).slice(2, 6) } while (existing.has(rid))
|
|
328
|
+
return rid
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// a REMARK ([[remark-substrate]]) rides the SAME committed reply write, just stamping the remark fields: a
|
|
332
|
+
// fresh unresolved bit + a minted stable rid + the codeSha it was authored against. Absent `remark` this is
|
|
333
|
+
// an ordinary reply, unchanged. Returns the thread; the new reply is its last, so a caller reads back its rid.
|
|
334
|
+
export function reply(id: string, body: string, author?: string, evidence?: string[], remark?: { targetCodeSha: string }): Issue {
|
|
335
|
+
const by = author || currentSession()
|
|
336
|
+
return commitStore(remark ? `remark(${id}): by ${by}` : `issue(${id}): reply by ${by}`, () => {
|
|
337
|
+
const p = loadOne(id) // fresh read under the lock → no lost-update when replies race
|
|
338
|
+
const post: Reply = { by, at: new Date().toISOString(), body: body.trim() }
|
|
339
|
+
if (remark) { post.rid = mintRid(new Set(p.replies.map((r) => r.rid).filter((x): x is string => !!x))); post.targetCodeSha = remark.targetCodeSha; post.resolved = false }
|
|
340
|
+
p.replies.push(post)
|
|
341
|
+
// an anchored annotation carries its frame blob: the reply's evidence hashes accrue onto the THREAD's
|
|
342
|
+
// typed evidence[] (deduped), so the thread stays the one place a video finding's blobs are indexed.
|
|
343
|
+
if (evidence?.length) p.evidence = [...new Set([...p.evidence, ...evidence])]
|
|
344
|
+
return p
|
|
345
|
+
}).issue
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// @@@ the PROGRAMMATIC store write surface — the dashboard's human write path calls these (author `'human'`).
|
|
349
|
+
// The store is git-native data, so a human's write goes through the SAME open/reply the CLI uses (committed
|
|
350
|
+
// straight to the trunk), and — because the store is the programmatic surface — a human's @-mention DOES
|
|
351
|
+
// dispatch (a human summons an agent from the issues page, per [[mentions]]). Each returns the written thread
|
|
352
|
+
// plus the @-dispatch outcomes so a caller can echo who was notified.
|
|
353
|
+
// The two reply deliveries are orthogonal: `evidence?` (f15b) carries a video annotation's frame blobs onto
|
|
354
|
+
// the thread; the originator loop-in ([[mentions]]) notifies who raised the thread. Both apply on every reply.
|
|
355
|
+
export async function replyLocalIssue(id: string, body: string, author: string, evidence?: string[], remark?: { targetCodeSha: string }): Promise<{ thread: Issue; outcomes: DispatchOutcome[]; loopIn: LoopIn | null }> {
|
|
356
|
+
const thread = reply(id, body, author, evidence, remark)
|
|
357
|
+
const node = thread.nodes[0] || null
|
|
358
|
+
const outcomes = await dispatchMentions(body, { threadId: id, node, author, status: thread.status })
|
|
359
|
+
// implicit originator loop-in ([[mentions]] / [[remark-substrate]] R3): a courtesy copy down the fallback
|
|
360
|
+
// chain — the reading's filer, then the node's governing session — delivered to the first online link.
|
|
361
|
+
const loopIn = await notifyOriginator(await threadOriginators(thread), author, body,
|
|
362
|
+
{ threadId: id, node, alreadyDelivered: deliveredIds(outcomes) })
|
|
363
|
+
return { thread, outcomes, loopIn }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The FALLBACK CHAIN of candidates a reply loops in ([[mentions]] loop-in / [[remark-substrate]] R3's dispatch
|
|
367
|
+
// clause), tried in order until one is online. A plain thread's only candidate is its author (`by`). An
|
|
368
|
+
// EVAL-COMMENT thread (concern `eval: <node> · <scenario>`, the eval-remark track) chains: the agent who FILED
|
|
369
|
+
// the reading the remark judges FIRST, then — when that filer is offline/absent — the NODE's governing session,
|
|
370
|
+
// so an unresolved remark still REACHES an agent who can act on it. This is notification only; it resolves
|
|
371
|
+
// nothing (R3: resolve is a deliberate `spex resolve`). Non-eval threads pay nothing (no yatsu/specs import).
|
|
372
|
+
const EVAL_CONCERN_RE = /^eval: (.+?) · (.+)$/ // node first (never contains ' · '), then the scenario (may)
|
|
373
|
+
async function threadOriginators(thread: Issue): Promise<(string | null)[]> {
|
|
374
|
+
const m = EVAL_CONCERN_RE.exec(thread.concern)
|
|
375
|
+
if (!m) return [thread.by]
|
|
376
|
+
const node = m[1].trim(), scenario = m[2].trim()
|
|
377
|
+
const { evalReadingFiler } = await import('../../spec-yatsu/src/filing.js')
|
|
378
|
+
return [evalReadingFiler(node, scenario), await nodeGoverningSession(node)]
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// A node's governing session — the `session` its spec resolves to (the Session: trailer of its latest version,
|
|
382
|
+
// else the frontmatter `session:` fallback; specs.ts owns that derivation). The fallback link when a reading's
|
|
383
|
+
// filer is unreachable. null when the node is unknown or has no governing session.
|
|
384
|
+
async function nodeGoverningSession(nodeId: string): Promise<string | null> {
|
|
385
|
+
const { loadSpecs } = await import('./specs.js')
|
|
386
|
+
return (await loadSpecs()).find((s) => s.id === nodeId)?.session ?? null
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export async function postLocalIssue(
|
|
390
|
+
concern: string,
|
|
391
|
+
opts: { nodes?: string[]; body?: string; evidence?: string[]; author: string },
|
|
392
|
+
): Promise<{ thread: Issue; outcomes: DispatchOutcome[] }> {
|
|
393
|
+
const thread = openIssue(concern, { nodes: opts.nodes, body: opts.body, evidence: opts.evidence, author: opts.author })
|
|
394
|
+
const outcomes = await dispatchMentions(opts.body || concern, { threadId: thread.id, node: thread.nodes[0] || null, author: opts.author, status: thread.status })
|
|
395
|
+
return { thread, outcomes }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function sign(id: string): string[] {
|
|
399
|
+
const by = currentSession()
|
|
400
|
+
return commitStore(`issue(${id}): signed by ${by}`, () => {
|
|
401
|
+
const p = loadOne(id)
|
|
402
|
+
if (!p.signers.includes(by)) p.signers.push(by)
|
|
403
|
+
return p
|
|
404
|
+
}).issue.signers
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const RESOLUTIONS = new Set(['accepted', 'rejected', 'landed'])
|
|
408
|
+
// `already` = the thread was in that state before this call — an idempotent success, nothing committed.
|
|
409
|
+
export function resolve(id: string, as: string): { as: string; already: boolean } {
|
|
410
|
+
if (!RESOLUTIONS.has(as)) throw new Error(`resolution must be one of: ${[...RESOLUTIONS].join(' | ')}`)
|
|
411
|
+
const { changed } = commitStore(`issue(${id}): resolve ${as}`, () => {
|
|
412
|
+
const p = loadOne(id)
|
|
413
|
+
p.status = as
|
|
414
|
+
return p
|
|
415
|
+
})
|
|
416
|
+
return { as, already: !changed }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ── remarks ([[remark-substrate]]) ──────────────────────────────────────────────────────────────────
|
|
420
|
+
// A remark is a reply carrying a resolvable bit, attached to a HOST: a local issue, or a scenario keyed by
|
|
421
|
+
// (node, scenario). The scenario track is NOT a new store — it is the annotator's lazy eval thread, keyed by
|
|
422
|
+
// its `eval: <node> · <scenario>` concern; a remark reuses it, creating it on first remark as a stub
|
|
423
|
+
// container (every remark is a reply, never the thread body, so the resolved bit always lives in one place).
|
|
424
|
+
const evalConcernKey = (node: string, scenario: string): string => `eval: ${node} · ${scenario}`
|
|
425
|
+
|
|
426
|
+
// find-or-create the ONE scenario thread for (node, scenario), keyed by its eval concern, ATOMICALLY under
|
|
427
|
+
// the store lock. R4 says a scenario's remark track lives ONCE — but a concurrent first-remark burst is a
|
|
428
|
+
// normal dogfood situation (SpexCode runs parallel workers), and if the not-found read sat OUTSIDE the lock
|
|
429
|
+
// two racers could both read "absent" and both create, minting a second thread whose remarks are invisible
|
|
430
|
+
// to the concern key (a silent teeth blind spot). Holding one lock across both the find AND the create closes
|
|
431
|
+
// that window: a racer either sees the thread the first created, or is the first. The stub is a pure
|
|
432
|
+
// container (its body carries a [[wiki-link]], never an @-mention), so it needs no async dispatch — a
|
|
433
|
+
// synchronous create suffices, and staying sync is exactly what lets it share the lock hold.
|
|
434
|
+
function findOrCreateEvalThread(node: string, scenario: string, author: string): Issue {
|
|
435
|
+
ensureStoreMigrated() // migrate before the lock (ensure takes it itself; never nest a store-lock hold)
|
|
436
|
+
const concern = evalConcernKey(node, scenario)
|
|
437
|
+
return withStoreLock(() => {
|
|
438
|
+
const existing = loadLocalIssues().find((t) => t.store === 'local' && t.concern === concern)
|
|
439
|
+
if (existing) return existing
|
|
440
|
+
const p: Issue = {
|
|
441
|
+
id: uniqueId(concern), store: 'local', concern, by: author, status: 'open',
|
|
442
|
+
nodes: [node], signers: [], created: new Date().toISOString(),
|
|
443
|
+
body: `Remarks on the \`${scenario}\` eval of [[${node}]].`, replies: [], evidence: [],
|
|
444
|
+
}
|
|
445
|
+
writeStoreFile(p, `issue: ${concern}`)
|
|
446
|
+
return p
|
|
447
|
+
})
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function resolveRemarkHost(host: { issue?: string; node?: string; scenario?: string }, author: string): string {
|
|
451
|
+
if (host.scenario) {
|
|
452
|
+
const node = host.node
|
|
453
|
+
if (!node) throw new Error('a scenario remark needs a node plus --scenario <name>')
|
|
454
|
+
return findOrCreateEvalThread(node, host.scenario, author).id
|
|
455
|
+
}
|
|
456
|
+
if (!host.issue) throw new Error('a remark needs a host: an issue id, or a node with --scenario <name>')
|
|
457
|
+
return loadOne(host.issue).id // throws loudly if the issue doesn't exist
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// author a remark on a host — the ONE write both the CLI (`spex remark`) and the server call. Stamps the
|
|
461
|
+
// codeSha it was authored against (the worktree HEAD by default — R2). Returns the `<thread-id>#<rid>` ref.
|
|
462
|
+
export async function remarkOnHost(
|
|
463
|
+
host: { issue?: string; node?: string; scenario?: string },
|
|
464
|
+
body: string,
|
|
465
|
+
opts: { codeSha?: string; author?: string; evidence?: string[] } = {},
|
|
466
|
+
): Promise<{ ref: string; rid: string; codeSha: string; thread: Issue; outcomes: DispatchOutcome[]; loopIn: LoopIn | null }> {
|
|
467
|
+
const author = opts.author || currentSession()
|
|
468
|
+
const codeSha = opts.codeSha || headSha(repoRoot())
|
|
469
|
+
const id = resolveRemarkHost(host, author)
|
|
470
|
+
const { thread, outcomes, loopIn } = await replyLocalIssue(id, body, author, opts.evidence, { targetCodeSha: codeSha })
|
|
471
|
+
const rid = thread.replies[thread.replies.length - 1].rid!
|
|
472
|
+
return { ref: `${id}#${rid}`, rid, codeSha, thread, outcomes, loopIn }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// a remark ref is `<thread-id>#<rid>`; the thread id (a store slug) never contains '#', so split on the last.
|
|
476
|
+
function parseRemarkRef(ref: string): { id: string; rid: string } {
|
|
477
|
+
const i = ref.lastIndexOf('#')
|
|
478
|
+
if (i <= 0 || i === ref.length - 1) throw new Error(`bad remark ref '${ref}' — expected <thread-id>#<rid> (the id \`spex remark\` printed)`)
|
|
479
|
+
return { id: ref.slice(0, i), rid: ref.slice(i + 1) }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// resolve a remark (R3): a DELIBERATE call, agent-only (the dashboard's `human` is rejected — a human
|
|
483
|
+
// RETRACTS their own, resolve is a second party's judgment), NEVER the author (no self-resolve), and
|
|
484
|
+
// MONOTONIC (no un-resolve — a regression is a NEW remark). `by` is the resolving party.
|
|
485
|
+
export function resolveRemark(ref: string, by: string): { thread: Issue; rid: string } {
|
|
486
|
+
const { id, rid } = parseRemarkRef(ref)
|
|
487
|
+
const { issue: thread } = commitStore(`remark(${id}#${rid}): resolved by ${by}`, () => {
|
|
488
|
+
const p = loadOne(id)
|
|
489
|
+
const r = p.replies.find((x) => x.rid === rid)
|
|
490
|
+
if (!r) throw new Error(`no remark '${ref}' in that thread`)
|
|
491
|
+
if (!by || by === 'human' || by === 'unknown') throw new Error(`resolve is agent-only (needs a real session identity, got '${by || 'none'}'): a human withdraws their own remark with \`spex retract\`, not resolve`)
|
|
492
|
+
if (r.resolved) throw new Error(`remark '${ref}' is already resolved — monotonic: a regression is a NEW remark, never an un-resolve`)
|
|
493
|
+
if (r.by === by) throw new Error(`refusing to self-resolve '${ref}': the author (${by}) may not resolve their own remark — resolve is a second party's deliberate judgment`)
|
|
494
|
+
r.resolved = true; r.resolvedBy = by; r.resolvedAt = new Date().toISOString()
|
|
495
|
+
return p
|
|
496
|
+
})
|
|
497
|
+
return { thread, rid }
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// retract a remark (R3): the AUTHOR withdraws their OWN remark, removing it — but ONLY while it is
|
|
501
|
+
// unresolved. Only the author may retract; and once a SECOND party has deliberately resolved it, the remark
|
|
502
|
+
// (and that recorded judgment) is part of the record — retract may not erase it. This makes R3's
|
|
503
|
+
// monotonicity two-sided: resolve can't be undone, and retract can't back-door an un-resolve by deleting the
|
|
504
|
+
// resolved remark. A regression after a resolve is a NEW remark, never a retract-and-reraise.
|
|
505
|
+
export function retractRemark(ref: string, by: string): { thread: Issue; rid: string } {
|
|
506
|
+
const { id, rid } = parseRemarkRef(ref)
|
|
507
|
+
const { issue: thread } = commitStore(`remark(${id}#${rid}): retracted by ${by}`, () => {
|
|
508
|
+
const p = loadOne(id)
|
|
509
|
+
const idx = p.replies.findIndex((x) => x.rid === rid)
|
|
510
|
+
if (idx < 0) throw new Error(`no remark '${ref}' in that thread`)
|
|
511
|
+
if (p.replies[idx].by !== by) throw new Error(`only the author (${p.replies[idx].by}) may retract '${ref}' — you are '${by}'`)
|
|
512
|
+
if (p.replies[idx].resolved) throw new Error(`refusing to retract '${ref}': it was resolved by ${p.replies[idx].resolvedBy} — a resolved remark is part of the record (monotonic), retract only withdraws an UNRESOLVED remark; a regression is a NEW remark`)
|
|
513
|
+
p.replies.splice(idx, 1)
|
|
514
|
+
return p
|
|
515
|
+
})
|
|
516
|
+
return { thread, rid }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// the post-merge nudge TEXT ([[local-issues]]) — produced HERE so the toggle and the wording live in one
|
|
520
|
+
// place; the post-merge git hook is a thin caller that just echoes this. Returns '' when the feature is OFF,
|
|
521
|
+
// so the hook prints nothing.
|
|
522
|
+
export function nudge(node: string): string {
|
|
523
|
+
if (!issuesEnabled()) return ''
|
|
524
|
+
return [
|
|
525
|
+
'── issues ─────────────────────────────────────────────────────────',
|
|
526
|
+
`Your work (${node || 'this node'}) just landed. Two issue checks before you close:`,
|
|
527
|
+
'',
|
|
528
|
+
'1. CLOSE what you finished. An issue whose work just landed is resolved, not',
|
|
529
|
+
' left open — the open set is the OUTSTANDING work, so a stale open reads as a',
|
|
530
|
+
' lie: spex issues --store local then spex issues resolve <id> --as landed',
|
|
531
|
+
'',
|
|
532
|
+
'2. RECORD only what OUTLIVES this task — a concern you are NOT acting on now:',
|
|
533
|
+
' an off-mainline smell / awkward boundary / wish, or a trivial-but-must-not-',
|
|
534
|
+
" forget to-do that doesn't earn a spec node. NOT a bug tracker (that is the",
|
|
535
|
+
' spec graph + the forge), NOT your assigned task or a fix you are about to',
|
|
536
|
+
' make — those need no issue. Only the taste that would otherwise evaporate:',
|
|
537
|
+
' spex issues # read first — sign/reply if already raised',
|
|
538
|
+
' spex issues open "<concern>" [--node <id>] # else open one',
|
|
539
|
+
'A supervisor drains the store later. (Advisory — skip if nothing is owed.)',
|
|
540
|
+
'───────────────────────────────────────────────────────────────────',
|
|
541
|
+
].join('\n')
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// @@@ close-time issue closeout ([[local-issues]] / [[state]]) — the DATA half of the propose-close nudge:
|
|
545
|
+
// appended to the `done --propose close` declaration BESIDE the resource-cleanup reminder (cli.ts, same
|
|
546
|
+
// insertion point, same semantics — a nudge, never a gate). Data-driven so it earns its line: it lists the
|
|
547
|
+
// still-open local threads THIS session touched (authored or replied; eval `eval: <node> · <scenario>`
|
|
548
|
+
// containers excluded — they host remarks and outlive every session by design) and prints NOTHING when the
|
|
549
|
+
// session owes nothing, when the feature is OFF, or when there is no session identity. Some issues rightly
|
|
550
|
+
// outlive their session (a taste concern waiting for the drain), so the ask is resolve OR say why it stays
|
|
551
|
+
// open — never a forced close.
|
|
552
|
+
export function closeoutNudge(sessionId: string | null | undefined): string {
|
|
553
|
+
if (!sessionId || sessionId === 'unknown' || !issuesEnabled()) return ''
|
|
554
|
+
const mine = loadLocalIssues().filter((t) =>
|
|
555
|
+
t.status === 'open' && !EVAL_CONCERN_RE.test(t.concern) &&
|
|
556
|
+
(t.by === sessionId || t.replies.some((r) => r.by === sessionId)))
|
|
557
|
+
if (!mine.length) return ''
|
|
558
|
+
return `\n\nIssue closeout — ${mine.length} still-open local issue(s) you touched (opened or replied): ${mine.map((t) => t.id).join(', ')}. For each, resolve it now if its work is finished (\`spex issues resolve <id> --as landed|rejected|accepted\`), or reply why it should stay open past this session (\`spex issues reply <id> --body "<why>"\`). Some issues rightly outlive their session — this is a reminder to sweep, not a gate.`
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ───────────────────────── CLI ─────────────────────────
|
|
562
|
+
const fl = (args: string[], name: string): string | undefined => {
|
|
563
|
+
const i = args.indexOf(`--${name}`)
|
|
564
|
+
return i >= 0 ? args[i + 1] : undefined
|
|
565
|
+
}
|
|
566
|
+
const VALUE_FLAGS = new Set(['--node', '--body', '--as', '--evidence', '--scenario', '--code-sha'])
|
|
567
|
+
// bare positionals, skipping flags + their values.
|
|
568
|
+
function bare(args: string[]): string[] {
|
|
569
|
+
const out: string[] = []
|
|
570
|
+
for (let i = 0; i < args.length; i++) {
|
|
571
|
+
const t = args[i]
|
|
572
|
+
if (t.startsWith('--')) { if (VALUE_FLAGS.has(t)) i++; continue }
|
|
573
|
+
out.push(t)
|
|
574
|
+
}
|
|
575
|
+
return out
|
|
576
|
+
}
|
|
577
|
+
// `--body -` reads stdin; `--body "text"` is literal; absent → undefined.
|
|
578
|
+
function readBody(args: string[]): string | undefined {
|
|
579
|
+
const v = fl(args, 'body')
|
|
580
|
+
if (v === undefined) return undefined
|
|
581
|
+
return v === '-' ? readFileSync(0, 'utf8') : v
|
|
582
|
+
}
|
|
583
|
+
// a repeatable value flag: every `--<name> <value>` pair, in order.
|
|
584
|
+
const repeated = (args: string[], name: string): string[] =>
|
|
585
|
+
args.flatMap((a, i) => (a === `--${name}` ? [args[i + 1]] : [])).filter(Boolean) as string[]
|
|
586
|
+
|
|
587
|
+
// the local-issue WRITE verbs, folded into the one issues surface (`spex issues <sub>` — the read routes
|
|
588
|
+
// here when its first positional is a write sub): open "<concern>" [--node id…] [--evidence hash…]
|
|
589
|
+
// [--body -|text], the id-based reply | sign | resolve, and the feature toggle on | off | status. `nudge`
|
|
590
|
+
// is internal (the post-merge hook's caller). Store is a property of the issue, never a second command.
|
|
591
|
+
export async function runIssueWrite(args: string[]): Promise<number> {
|
|
592
|
+
const sub = args[0]
|
|
593
|
+
try {
|
|
594
|
+
if (sub === 'on' || sub === 'off') {
|
|
595
|
+
setEnabled(sub === 'on')
|
|
596
|
+
console.log(`issues workflow ${sub.toUpperCase()} — spexcode.json issues.enabled = ${sub === 'on'}${sub === 'on' ? '' : ' (post-merge nudge silenced; dashboard issues view hidden)'}`)
|
|
597
|
+
return 0
|
|
598
|
+
}
|
|
599
|
+
if (sub === 'status') { console.log(`issues workflow is ${issuesEnabled() ? 'ON' : 'OFF'}`); return 0 }
|
|
600
|
+
if (sub === 'reply') {
|
|
601
|
+
const id = bare(args.slice(1))[0]
|
|
602
|
+
const body = readBody(args)
|
|
603
|
+
if (!id || !body) { console.error('usage: spex issues reply <issue-id> --body -|<text> [--evidence <hash>…]'); return 2 }
|
|
604
|
+
// the ONE store-routed reply verb ([[issues]]): a forge id posts a real comment through the driver,
|
|
605
|
+
// a local id commits to the store — the same command either way (dynamic import: no static cycle).
|
|
606
|
+
const r = await (await import('./issues.js')).replyIssue(id, body, { evidence: repeated(args, 'evidence') })
|
|
607
|
+
console.log(r.store === 'local'
|
|
608
|
+
? `replied to '${id}' — ${r.replies?.length} post(s) in thread`
|
|
609
|
+
: `commented on '${id}' — ${r.url}`)
|
|
610
|
+
const s = summarize(r.outcomes, r.loopIn)
|
|
611
|
+
if (s) console.log(` ${s}`)
|
|
612
|
+
return 0
|
|
613
|
+
}
|
|
614
|
+
if (sub === 'sign') {
|
|
615
|
+
const id = bare(args.slice(1))[0]
|
|
616
|
+
if (!id) { console.error('usage: spex issues sign <issue-id>'); return 2 }
|
|
617
|
+
console.log(`signed '${id}' — signers: ${sign(id).join(', ')}`)
|
|
618
|
+
return 0
|
|
619
|
+
}
|
|
620
|
+
if (sub === 'resolve') {
|
|
621
|
+
const id = bare(args.slice(1))[0]
|
|
622
|
+
const as = fl(args, 'as')
|
|
623
|
+
if (!id || !as) { console.error('usage: spex issues resolve <issue-id> --as accepted|rejected|landed'); return 2 }
|
|
624
|
+
const r = resolve(id, as)
|
|
625
|
+
console.log(r.already ? `'${id}' already ${r.as} — store unchanged` : `resolved '${id}' → ${r.as}`)
|
|
626
|
+
return 0
|
|
627
|
+
}
|
|
628
|
+
if (sub === 'nudge') {
|
|
629
|
+
// internal: the post-merge hook calls this to print the (toggle-aware) nudge for a merged node.
|
|
630
|
+
const text = nudge(bare(args.slice(1))[0] || '')
|
|
631
|
+
if (text) console.log(text)
|
|
632
|
+
return 0
|
|
633
|
+
}
|
|
634
|
+
// `open`: start a new local issue. The concern is the bare positional(s) after the sub.
|
|
635
|
+
const concern = sub === 'open' ? bare(args.slice(1)).join(' ').trim() : ''
|
|
636
|
+
if (!concern) {
|
|
637
|
+
console.error('usage: spex issues open "<concern>" [--node <id>…] [--evidence <hash>…] [--body -|<text>]\n spex issues reply|sign|resolve <issue-id> … | on|off|status')
|
|
638
|
+
return 2
|
|
639
|
+
}
|
|
640
|
+
const p = openIssue(concern, { nodes: repeated(args, 'node'), body: readBody(args), evidence: repeated(args, 'evidence') })
|
|
641
|
+
console.log(`opened '${p.id}'${p.nodes.length ? ` (re: ${p.nodes.join(', ')})` : ''} — committed to the local issue store; read it with \`spex issues\``)
|
|
642
|
+
const s = summarize(await dispatchMentions(p.body || concern, { threadId: p.id, node: p.nodes[0] || null, author: p.by, status: p.status }))
|
|
643
|
+
if (s) console.log(` ${s}`)
|
|
644
|
+
return 0
|
|
645
|
+
} catch (e) {
|
|
646
|
+
console.error(`spex issues: ${e instanceof Error ? e.message : e}`)
|
|
647
|
+
return 1
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// the first positionals runIssueWrite handles — the issues command routes these to it, everything else is
|
|
652
|
+
// the read. Exported so the router and the runner can never drift.
|
|
653
|
+
export const ISSUE_WRITE_SUBS = new Set(['open', 'reply', 'sign', 'resolve', 'on', 'off', 'status', 'nudge'])
|
|
654
|
+
|
|
655
|
+
// ── remark CLI ([[remark-substrate]]) — CLI-first: the whole author→resolve→retract loop, no server needed ──
|
|
656
|
+
// `spex remark <host> --body -|<text> [--code-sha <sha>] [--scenario <name>] [--evidence <hash>…]`
|
|
657
|
+
// host = a local issue id, OR a <node> with --scenario <name>. Records targetCodeSha (default: worktree HEAD).
|
|
658
|
+
export async function runRemark(args: string[]): Promise<number> {
|
|
659
|
+
try {
|
|
660
|
+
const scenario = fl(args, 'scenario')
|
|
661
|
+
const positional = bare(args)[0]
|
|
662
|
+
const body = readBody(args)
|
|
663
|
+
if (!positional || !body) {
|
|
664
|
+
console.error('usage: spex remark <issue-id | node --scenario name> --body -|<text> [--code-sha <sha>] [--evidence <hash>…]')
|
|
665
|
+
return 2
|
|
666
|
+
}
|
|
667
|
+
const host = scenario ? { node: positional, scenario } : { issue: positional }
|
|
668
|
+
const r = await remarkOnHost(host, body, { codeSha: fl(args, 'code-sha'), evidence: repeated(args, 'evidence') })
|
|
669
|
+
console.log(`remark ${r.ref} (against ${r.codeSha.slice(0, 7) || 'HEAD'}) — read it with \`spex issues --all\``)
|
|
670
|
+
const s = summarize(r.outcomes, r.loopIn)
|
|
671
|
+
if (s) console.log(` ${s}`)
|
|
672
|
+
return 0
|
|
673
|
+
} catch (e) {
|
|
674
|
+
console.error(`spex remark: ${e instanceof Error ? e.message : e}`)
|
|
675
|
+
return 1
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// `spex resolve <remark-ref>` — flip resolved=true (agent-only, never the author, monotonic — see resolveRemark).
|
|
680
|
+
export async function runResolve(args: string[]): Promise<number> {
|
|
681
|
+
const ref = bare(args)[0]
|
|
682
|
+
if (!ref) { console.error('usage: spex resolve <remark-ref> (the <thread-id>#<rid> `spex remark` printed)'); return 2 }
|
|
683
|
+
try {
|
|
684
|
+
const by = currentSession()
|
|
685
|
+
resolveRemark(ref, by)
|
|
686
|
+
console.log(`resolved remark ${ref} — by ${by}`)
|
|
687
|
+
return 0
|
|
688
|
+
} catch (e) { console.error(`spex resolve: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// `spex retract <remark-ref>` — the author withdraws their OWN remark, removing it (author-only — see retractRemark).
|
|
692
|
+
export async function runRetract(args: string[]): Promise<number> {
|
|
693
|
+
const ref = bare(args)[0]
|
|
694
|
+
if (!ref) { console.error('usage: spex retract <remark-ref>'); return 2 }
|
|
695
|
+
try {
|
|
696
|
+
retractRemark(ref, currentSession())
|
|
697
|
+
console.log(`retracted remark ${ref}`)
|
|
698
|
+
return 0
|
|
699
|
+
} catch (e) { console.error(`spex retract: ${e instanceof Error ? e.message : e}`); return 1 }
|
|
700
|
+
}
|