spexcode 0.5.2 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +13 -52
- package/spec-cli/src/cli.ts +209 -71
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +946 -71
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +4 -8
- package/spec-cli/src/pi-headless.ts +17 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +6 -3
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +6 -6
- package/spec-cli/src/sessions.ts +1249 -208
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
- package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
- package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
- package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
- package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
|
@@ -54,14 +54,39 @@ function isTextWorktreeFile(root: string, path: string): boolean {
|
|
|
54
54
|
// Source discovery classifies tracked paths and bytes only. Language structure belongs to anchors.ts's
|
|
55
55
|
// adapter registry; adding a language must never add a branch here.
|
|
56
56
|
export function isSourceFile(root: string, path: string, policy: SourcePolicy): boolean {
|
|
57
|
+
if (!isSourcePath(path, policy)) return false
|
|
58
|
+
return isTextWorktreeFile(root, path)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isSourcePath(path: string, policy: SourcePolicy): boolean {
|
|
57
62
|
if (isSpexCodeData(path)) return false
|
|
58
63
|
if (policy.sourceIncludeGlobs !== null && !policy.sourceIncludeGlobs.some((glob) => globToRe(glob).test(path))) return false
|
|
59
64
|
if (policy.sourceExcludeGlobs.some((glob) => globToRe(glob).test(path))) return false
|
|
60
65
|
if (policy.testGlobs.some((glob) => globToRe(glob).test(path))) return false
|
|
61
|
-
return
|
|
66
|
+
return true
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
export function trackedSourceFiles(root: string, roots: string[], policy: SourcePolicy): string[] {
|
|
69
|
+
export function trackedSourceFiles(root: string, roots: string[], policy: SourcePolicy, tip = 'HEAD'): string[] {
|
|
70
|
+
if (tip !== 'HEAD') {
|
|
71
|
+
let listed = '', textOut = ''
|
|
72
|
+
try {
|
|
73
|
+
listed = git(['-C', root, '-c', 'core.quotePath=false', 'ls-tree', '-r', '-z', '-l', tip, '--', ...roots])
|
|
74
|
+
textOut = git(['-C', root, '-c', 'core.quotePath=false', 'grep', '-I', '-l', '-z', '-e', '', tip, '--', ...roots])
|
|
75
|
+
} catch { /* git grep exits 1 when the tree slice has no non-empty text */ }
|
|
76
|
+
const text = new Set(textOut.split('\0').map((entry) => {
|
|
77
|
+
const prefix = `${tip}:`
|
|
78
|
+
return entry.startsWith(prefix) ? entry.slice(prefix.length) : entry
|
|
79
|
+
}).filter(Boolean))
|
|
80
|
+
const out = new Set<string>()
|
|
81
|
+
for (const record of listed.split('\0')) {
|
|
82
|
+
if (!record) continue
|
|
83
|
+
const m = record.match(/^100\d+ blob [0-9a-f]+\s+(\d+)\t([\s\S]+)$/)
|
|
84
|
+
if (!m) continue
|
|
85
|
+
const path = m[2]
|
|
86
|
+
if (isSourcePath(path, policy) && (+m[1] === 0 || text.has(path))) out.add(path)
|
|
87
|
+
}
|
|
88
|
+
return [...out]
|
|
89
|
+
}
|
|
65
90
|
const out = new Set<string>()
|
|
66
91
|
for (const governedRoot of roots) {
|
|
67
92
|
let listed = ''
|
package/spec-cli/src/specs.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
2
2
|
import { readFile, readdir } from 'node:fs/promises'
|
|
3
3
|
import { join, relative, basename } from 'node:path'
|
|
4
|
-
import { repoRoot, historyIndex, rowsFor, statsFor, pathsStats, driftIndex, driftFor, fileDiffAt
|
|
5
|
-
|
|
4
|
+
import { repoRoot, historyIndex, rowsFor, statsFor, pathsStats, driftIndex, driftFor, fileDiffAt,
|
|
5
|
+
sourceIndexes, treeTextFiles, type HistoryIndex, type DriftIndex } from './git.js'
|
|
6
|
+
import { parseCodeEntry, parseRelation, relationClaimsPath } from './anchors.js'
|
|
6
7
|
|
|
7
8
|
// a node is any directory under .spec holding a spec.md; its parent is the nearest ancestor that also holds one.
|
|
8
9
|
const ROOT = repoRoot()
|
|
@@ -12,7 +13,7 @@ type FmValue = string | string[]
|
|
|
12
13
|
type Raw = { id: string; parent: string | null; relPath: string; fm: Record<string, FmValue>; body: string }
|
|
13
14
|
|
|
14
15
|
// line-based frontmatter: scalars are `key: value`; an empty key followed by `- item` lines is a list (e.g. `code:`).
|
|
15
|
-
function parseFrontmatter(src: string) {
|
|
16
|
+
export function parseFrontmatter(src: string) {
|
|
16
17
|
const fm: Record<string, FmValue> = {}
|
|
17
18
|
let body = src
|
|
18
19
|
const m = src.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/)
|
|
@@ -153,7 +154,21 @@ async function walkAsync(dir: string, parent: string | null, acc: Raw[], root: s
|
|
|
153
154
|
if (e.isDirectory()) await walkAsync(join(dir, e.name), myId, acc, root)
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
|
-
|
|
157
|
+
export type SpecTreeSnapshot = { tip: string; files: ReadonlyMap<string, string> }
|
|
158
|
+
|
|
159
|
+
async function rawsAsync(root: string, tip = 'HEAD', snapshot?: SpecTreeSnapshot): Promise<Raw[]> {
|
|
160
|
+
if (snapshot || tip !== 'HEAD') {
|
|
161
|
+
const acc: Raw[] = []
|
|
162
|
+
const files = snapshot?.files ?? treeTextFiles(root, tip, '.spec')
|
|
163
|
+
for (const [relPath, source] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
|
|
164
|
+
if (!relPath.endsWith('/spec.md')) continue
|
|
165
|
+
const segs = relPath.split('/')
|
|
166
|
+
const { fm, body } = parseFrontmatter(source)
|
|
167
|
+
acc.push({ id: segs[segs.length - 2], parent: null, relPath, fm, body })
|
|
168
|
+
}
|
|
169
|
+
reId(acc)
|
|
170
|
+
return acc
|
|
171
|
+
}
|
|
157
172
|
const acc: Raw[] = []
|
|
158
173
|
const specDir = join(root, '.spec')
|
|
159
174
|
if (existsSync(specDir)) await walkAsync(specDir, null, acc, root)
|
|
@@ -164,12 +179,7 @@ async function rawsAsync(root: string): Promise<Raw[]> {
|
|
|
164
179
|
// the claim rule shared by both relations (exact path, dir-prefix, or *-glob). See [[governed-related]].
|
|
165
180
|
function claimMatcher(file: string): (cf: string) => boolean {
|
|
166
181
|
const rel = file.startsWith('/') ? relative(ROOT, file) : file
|
|
167
|
-
return (
|
|
168
|
-
if (cf === rel) return true
|
|
169
|
-
if (rel.startsWith(cf.replace(/\/+$/, '') + '/')) return true
|
|
170
|
-
if (cf.includes('*')) return new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$').test(rel)
|
|
171
|
-
return false
|
|
172
|
-
}
|
|
182
|
+
return (claim) => relationClaimsPath(claim, rel)
|
|
173
183
|
}
|
|
174
184
|
|
|
175
185
|
// spec node(s) that GOVERN a file (frontmatter `code:` — source of truth, drives drift + eval freshness); reads only
|
|
@@ -227,12 +237,29 @@ export function specContent(id: string): { body: string; parts: ReturnType<typeo
|
|
|
227
237
|
// instead ([[source-of-truth]]'s several-checkouts principle at the loader level): its .spec is the
|
|
228
238
|
// branch's pending proposal, so eval surfaces rooted at a session must load the spec tree from the SAME
|
|
229
239
|
// root as their readings/indexes, or a branch-NEW node simply does not exist for them.
|
|
230
|
-
export
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
240
|
+
export type LoadSpecsOptions = {
|
|
241
|
+
tip?: string
|
|
242
|
+
history?: HistoryIndex | null
|
|
243
|
+
drift?: DriftIndex | null
|
|
244
|
+
snapshot?: SpecTreeSnapshot
|
|
245
|
+
}
|
|
246
|
+
export async function loadSpecs(root: string = ROOT, options: LoadSpecsOptions = {}) {
|
|
247
|
+
// The default pair shares one immutable-event snapshot; explicit sides let callers skip or supply either
|
|
248
|
+
// projection. Every node below is then a pure in-memory lookup.
|
|
249
|
+
const tip = options.tip ?? 'HEAD'
|
|
250
|
+
if (options.snapshot && options.snapshot.tip !== tip) {
|
|
251
|
+
throw new Error(`loadSpecs snapshot tip '${options.snapshot.tip}' does not match requested tip '${tip}'`)
|
|
252
|
+
}
|
|
253
|
+
const indexes = options.history === undefined && options.drift === undefined
|
|
254
|
+
? sourceIndexes(root, tip)
|
|
255
|
+
: Promise.all([
|
|
256
|
+
options.history === null ? Promise.resolve(null) : options.history ?? historyIndex(root, tip),
|
|
257
|
+
options.drift === null ? Promise.resolve(null) : options.drift ?? driftIndex(root, tip),
|
|
258
|
+
])
|
|
259
|
+
const [[idx, didx], allRaws] = await Promise.all([indexes, rawsAsync(root, tip, options.snapshot)])
|
|
260
|
+
const loaded = []
|
|
261
|
+
for (const r of allRaws) {
|
|
262
|
+
const h = idx ? rowsFor(idx, r.relPath) : []
|
|
236
263
|
// session = the Session: trailer of the node's latest version; frontmatter `session:` is the fallback.
|
|
237
264
|
const fmSession = str(r.fm.session)
|
|
238
265
|
const session = h[0]?.session || (fmSession && fmSession !== 'null' ? fmSession : null)
|
|
@@ -249,19 +276,24 @@ export async function loadSpecs(root: string = ROOT) {
|
|
|
249
276
|
const relatedScoped = relatedRel.entries.filter((e) => e.selectors.length > 0)
|
|
250
277
|
const relationProblems = [...codeRel.problems, ...relatedRel.problems]
|
|
251
278
|
const S = h[0]?.hash || ''
|
|
252
|
-
const driftFiles =
|
|
253
|
-
|
|
254
|
-
.
|
|
279
|
+
const driftFiles = []
|
|
280
|
+
for (const f of code) {
|
|
281
|
+
const d = didx ? { file: f, behind: driftFor(didx, S, f, r.id) } : { file: f, behind: 0 }
|
|
282
|
+
if (d.behind > 0) driftFiles.push(d)
|
|
283
|
+
}
|
|
255
284
|
const drift = driftFiles.reduce((a, d) => a + d.behind, 0)
|
|
256
285
|
// related drift is the SOFT tier ([[governed-related]]): same ancestry basis, but it stays OUT of
|
|
257
286
|
// `drift` — it never feeds status, the commit gate, or eval freshness. It surfaces only as a lint warn nudge.
|
|
258
287
|
// A SCOPED related entry is excluded here: its file-level movement is silent by design — only a
|
|
259
288
|
// selector HIT warns, and that verdict needs the anchor engine, so lint derives it, not the loader.
|
|
260
|
-
const relatedDriftFiles =
|
|
261
|
-
|
|
262
|
-
.
|
|
289
|
+
const relatedDriftFiles = []
|
|
290
|
+
for (const e of relatedRel.entries) {
|
|
291
|
+
if (e.selectors.length) continue
|
|
292
|
+
const d = didx ? { file: e.path, behind: driftFor(didx, S, e.path, r.id) } : { file: e.path, behind: 0 }
|
|
293
|
+
if (d.behind > 0) relatedDriftFiles.push(d)
|
|
294
|
+
}
|
|
263
295
|
const fmStatus = str(r.fm.status, '') || null
|
|
264
|
-
|
|
296
|
+
loaded.push({
|
|
265
297
|
id: r.id,
|
|
266
298
|
parent: r.parent,
|
|
267
299
|
path: r.relPath,
|
|
@@ -287,8 +319,9 @@ export async function loadSpecs(root: string = ROOT) {
|
|
|
287
319
|
// cold load); the history tab fetches it lazily via specDiffAt. See [[work-pane]].
|
|
288
320
|
body: r.body.trim(),
|
|
289
321
|
parts: parseParts(r.body),
|
|
290
|
-
}
|
|
291
|
-
}
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
return loaded
|
|
292
325
|
}
|
|
293
326
|
|
|
294
327
|
// per-node version timeline; each row sums the node's spec.md stat (rename-followed, via statsFor) and its
|
|
@@ -4,7 +4,8 @@ import net from 'node:net'
|
|
|
4
4
|
import http from 'node:http'
|
|
5
5
|
import { randomUUID } from 'node:crypto'
|
|
6
6
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
7
|
-
import {
|
|
7
|
+
import type { Dirent } from 'node:fs'
|
|
8
|
+
import { stat, readdir } from 'node:fs/promises'
|
|
8
9
|
import { fileURLToPath } from 'node:url'
|
|
9
10
|
import { dirname, join } from 'node:path'
|
|
10
11
|
import { installProcessGuards } from './resilience.js'
|
|
@@ -14,6 +15,9 @@ import { tsxBin } from './tsx-bin.js'
|
|
|
14
15
|
import { publishEndpoint, dropOwnEndpoint } from './host.js'
|
|
15
16
|
import { repoRoot as servedRepoRoot } from './git.js'
|
|
16
17
|
import { resolveProjectIdentity } from './project-identity.js'
|
|
18
|
+
import { startResourceMonitor } from './host-resources.js'
|
|
19
|
+
import { registerBackendInstance, unregisterBackendInstance } from './runtime-ownership.js'
|
|
20
|
+
import { sessionIdentityEnvVars } from './harness.js'
|
|
17
21
|
|
|
18
22
|
// the supervisor OWNS the public port, so it must outlive any transient throw: an uncaught error here is
|
|
19
23
|
// logged and survived, never an exit that closes the port (and the tmux session) and takes the frontend down.
|
|
@@ -50,6 +54,13 @@ const watchRoots = [
|
|
|
50
54
|
// different serve generation fails the match and is treated as offline, never proxied to.
|
|
51
55
|
const instanceId = randomUUID()
|
|
52
56
|
const projectRoot = servedRepoRoot() // the actual git tree whose source/spec/config the child serves
|
|
57
|
+
// A backend is a project control plane, never the session that happened to invoke `spex serve`. Strip every
|
|
58
|
+
// adapter identity before any hot child is spawned; otherwise all backend work is falsely charged to that
|
|
59
|
+
// caller long after its session ends.
|
|
60
|
+
for (const key of sessionIdentityEnvVars()) delete process.env[key]
|
|
61
|
+
process.env.SPEXCODE_PROJECT_ROOT = projectRoot
|
|
62
|
+
process.env.SPEXCODE_INSTANCE_ID = instanceId
|
|
63
|
+
registerBackendInstance(instanceId, process.pid, projectRoot)
|
|
53
64
|
|
|
54
65
|
type Backend = { port: number; child: ChildProcess }
|
|
55
66
|
let current: Backend | null = null // which internal port new proxy connections forward to
|
|
@@ -176,7 +187,7 @@ function dropEndpoint(): void {
|
|
|
176
187
|
try { dropOwnEndpoint(instanceId, projectRoot) } catch { /* not ours / already gone */ }
|
|
177
188
|
}
|
|
178
189
|
|
|
179
|
-
const shutdown = () => { dropEndpoint(); try { current?.child.kill('SIGTERM') } catch { /* */ } process.exit(0) }
|
|
190
|
+
const shutdown = () => { dropEndpoint(); unregisterBackendInstance(instanceId); try { current?.child.kill('SIGTERM') } catch { /* */ } process.exit(0) }
|
|
180
191
|
process.on('SIGINT', shutdown)
|
|
181
192
|
process.on('SIGTERM', shutdown)
|
|
182
193
|
|
|
@@ -187,7 +198,7 @@ current = first
|
|
|
187
198
|
// SIGTERM (not SIGKILL): the child is a `tsx` wrapper around the real node server — SIGKILL kills the wrapper
|
|
188
199
|
// instantly and ORPHANS the server still bound to its port, whereas tsx FORWARDS SIGTERM to it (the same
|
|
189
200
|
// signal the reload drain uses). Sent synchronously here, before the process.exit that follows.
|
|
190
|
-
const reapChild = () => { try { current?.child.kill('SIGTERM') } catch { /* already gone */ } }
|
|
201
|
+
const reapChild = () => { unregisterBackendInstance(instanceId); try { current?.child.kill('SIGTERM') } catch { /* already gone */ } }
|
|
191
202
|
if (publicCfg) {
|
|
192
203
|
// public mode: the raw proxy stays on loopback; the password-gated gateway owns the public port.
|
|
193
204
|
const distDir = resolveDistDir() // bundled <pkg>/dashboard-dist when installed, else monorepo spec-dashboard/dist
|
|
@@ -197,6 +208,7 @@ if (publicCfg) {
|
|
|
197
208
|
} else {
|
|
198
209
|
listenOrExit(proxy, publicPort, { label: 'supervisor', cleanup: reapChild, onListen: () => { recordEndpoint(childApiBase); console.log(`spec-cli supervisor serving on http://localhost:${publicPort} (zero-downtime reloads, backend :${first.port})`) } })
|
|
199
210
|
}
|
|
211
|
+
startResourceMonitor()
|
|
200
212
|
|
|
201
213
|
// watch every imported source tree; debounce a burst of writes (a merge touching several files across
|
|
202
214
|
// packages) into one reload. A root that can't be watched (a package absent in some checkout) is logged
|
|
@@ -207,19 +219,27 @@ if (publicCfg) {
|
|
|
207
219
|
// deaf: every 2s, take the newest .ts/.js/.json mtime across the trees; a jump triggers the debounced reload
|
|
208
220
|
// (a burst of writes still lands as one). Polling a few small src trees is negligible cost.
|
|
209
221
|
let timer: NodeJS.Timeout | undefined
|
|
210
|
-
const newestMtime = (dir: string): number => {
|
|
222
|
+
const newestMtime = async (dir: string): Promise<number> => {
|
|
211
223
|
let max = 0
|
|
212
224
|
let entries: Dirent[]
|
|
213
|
-
try { entries =
|
|
225
|
+
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return 0 } // a tree absent in some checkout → skip
|
|
214
226
|
for (const e of entries) {
|
|
215
|
-
if (e.isDirectory()) { if (e.name !== 'node_modules') max = Math.max(max, newestMtime(join(dir, e.name))) }
|
|
216
|
-
else if (/\.(ts|js|mjs|json)$/.test(e.name)) { try { max = Math.max(max,
|
|
227
|
+
if (e.isDirectory()) { if (e.name !== 'node_modules') max = Math.max(max, await newestMtime(join(dir, e.name))) }
|
|
228
|
+
else if (/\.(ts|js|mjs|json)$/.test(e.name)) { try { max = Math.max(max, (await stat(join(dir, e.name))).mtimeMs) } catch { /* raced unlink */ } }
|
|
217
229
|
}
|
|
218
230
|
return max
|
|
219
231
|
}
|
|
220
|
-
const scanMtime = (): number => {
|
|
221
|
-
let
|
|
232
|
+
const scanMtime = async (): Promise<number> => {
|
|
233
|
+
let m = 0
|
|
234
|
+
for (const root of watchRoots) m = Math.max(m, await newestMtime(root))
|
|
235
|
+
return m
|
|
236
|
+
}
|
|
237
|
+
let lastMtime = await scanMtime() // baseline at boot — only a LATER change reloads
|
|
238
|
+
let scanning = false
|
|
222
239
|
setInterval(() => {
|
|
223
|
-
|
|
224
|
-
|
|
240
|
+
if (scanning) return
|
|
241
|
+
scanning = true
|
|
242
|
+
void scanMtime().then((m) => {
|
|
243
|
+
if (m > lastMtime) { lastMtime = m; clearTimeout(timer); timer = setTimeout(() => void reload('code change'), 150) }
|
|
244
|
+
}).finally(() => { scanning = false })
|
|
225
245
|
}, 2000).unref()
|
|
@@ -150,15 +150,16 @@ export function uninstall(targetArg: string | undefined, opts: { hooks?: boolean
|
|
|
150
150
|
}
|
|
151
151
|
const bundles = sweepPluginBundles(proj, pluginHosts)
|
|
152
152
|
|
|
153
|
-
// 5. The whole store: per-tree manifests/hashes/ledgers
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
153
|
+
// 5. The whole store: per-tree manifests/hashes/ledgers and sessions.
|
|
154
|
+
const removedStores: string[] = []
|
|
155
|
+
for (const candidate of new Set([store].filter((path): path is string => !!path))) {
|
|
156
|
+
if (!existsSync(candidate)) continue
|
|
157
|
+
rmSync(candidate, { recursive: true, force: true })
|
|
158
|
+
removedStores.push(candidate)
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
console.log(`✓ dematerialized (contract blocks, shims, Codex trust, skills, sub-agents, ignore blocks, content filter) for ${HARNESSES.map((h) => h.id).join(', ')}`)
|
|
161
|
-
if (
|
|
162
|
+
if (removedStores.length) console.log(`✓ removed the global per-project store (${removedStores.join(', ')})`)
|
|
162
163
|
if (bundles.length) console.log(`✓ removed plugin bundle(s): ${bundles.join(', ')}`)
|
|
163
164
|
|
|
164
165
|
// Git hooks are per-clone and may carry user logic → preserved unless --hooks (and even then only while
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# spexcode-managed-hook-v1
|
|
3
|
+
# Candidate lint is driven solely by reference-transaction. This hook intentionally carries no marker,
|
|
4
|
+
# message hash, tree binding, or TTL; Git's immutable commit object is the only candidate identity.
|
|
5
|
+
# Git does not apply scissors cleanup to every no-editor/-F path, so normalize the final message here,
|
|
6
|
+
# after every editor, using scissors only when the repository explicitly selected that cleanup mode.
|
|
7
|
+
if [ "$(git config --get commit.cleanup 2>/dev/null || true)" = scissors ]; then
|
|
8
|
+
msg_file="$1"
|
|
9
|
+
tmp="${msg_file}.spexcode-scissors.$$"
|
|
10
|
+
awk '/^# ------------------------ >8 ------------------------$/ { exit } { print }' "$msg_file" \
|
|
11
|
+
| git stripspace --strip-comments > "$tmp"
|
|
12
|
+
mv "$tmp" "$msg_file"
|
|
13
|
+
fi
|
|
14
|
+
exit 0
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
+
# spexcode-managed-hook-v1
|
|
2
3
|
# @@@ shared spex runner - resolve the `spex` CLI ONCE, into spex_cli(), so BOTH the main-guard trunk
|
|
3
4
|
# lookup and the spec-lint shim shim through the same mechanism (no duplicated, drift-prone resolution).
|
|
4
5
|
# This must work for BOTH ways this hook ships: a real project that installed @spexcode/spec-cli, and the
|
|
@@ -27,6 +28,19 @@ spex_cli() {
|
|
|
27
28
|
esac
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
# A commit containing only issue storage cannot change governed code or an anchor range. Keep this as a
|
|
32
|
+
# path-property check at the hook boundary so the common dashboard write does not pay materialize/eval
|
|
33
|
+
# backstops; any mixed commit continues through the full hook chain and the reference transaction gate.
|
|
34
|
+
staged_issue_only=1
|
|
35
|
+
staged_any=0
|
|
36
|
+
while IFS= read -r -d '' staged_path; do
|
|
37
|
+
staged_any=1
|
|
38
|
+
case "$staged_path" in .spec/.issues/*) ;; *) staged_issue_only=0 ;; esac
|
|
39
|
+
done < <(git diff --cached --name-only -z 2>/dev/null)
|
|
40
|
+
if [ "$staged_any" -eq 1 ] && [ "$staged_issue_only" -eq 1 ]; then
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
30
44
|
# @@@ tree-unchanged stamp - a commit whose tree EQUALS HEAD's tree (`git write-tree` over the commit's own
|
|
31
45
|
# index — the hook's GIT_INDEX_FILE, a temp HEAD-tree index for `commit --only --allow-empty`) adds NO
|
|
32
46
|
# content: it cannot smuggle code past the main-guard below and cannot introduce spec↔code drift, so the
|
|
@@ -74,14 +88,20 @@ if [ -n "$spex_kind" ]; then
|
|
|
74
88
|
spex_cli internal commit-surgery || echo "• SpexCode: footprint surgery skipped (error above) — a generated artifact may be staged; CI still enforces." >&2
|
|
75
89
|
fi
|
|
76
90
|
|
|
77
|
-
# @@@ spec-lint -
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
91
|
+
# @@@ spec-lint - reference-transaction owns the candidate gate. When the installed consumer is the
|
|
92
|
+
# SpexCode unmarked predicate, running full HEAD lint here would restore the history-scaled cost this gate
|
|
93
|
+
# removes. A missing/custom consumer keeps the old full lint fallback, so a hook collision never reduces
|
|
94
|
+
# local coverage. Static identity matters: never execute a user-owned hook with made-up probe arguments.
|
|
95
|
+
# Bypass with SPEXCODE_SKIP_LINT=1. It shims through shared spex_cli() resolved above.
|
|
81
96
|
if [ -z "${SPEXCODE_SKIP_LINT:-}" ]; then
|
|
82
97
|
if [ -n "$spex_kind" ]; then
|
|
83
|
-
|
|
84
|
-
|
|
98
|
+
consume_hook=$(git rev-parse --git-path hooks/reference-transaction 2>/dev/null)
|
|
99
|
+
if [ -x "$consume_hook" ] && grep -q 'one unmarked predicate at Git' "$consume_hook" 2>/dev/null; then
|
|
100
|
+
lint_rc=0
|
|
101
|
+
else
|
|
102
|
+
spex_cli spec lint >&2
|
|
103
|
+
lint_rc=$?
|
|
104
|
+
fi
|
|
85
105
|
# exit 75 = the launcher's mid-merge guard (spex's own source holds conflict markers — transient).
|
|
86
106
|
# Degrade to advisory rather than walling every commit behind a merge someone else is resolving.
|
|
87
107
|
if [ "$lint_rc" -eq 75 ]; then
|
|
@@ -1,57 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
+
# spexcode-managed-hook-v1
|
|
2
3
|
# @@@ session-stamp - auto-attribute a commit to the SESSION that authored it, ACROSS HARNESSES. SpexCode
|
|
3
4
|
# reads the `Session:` trailer as a version's attribution (git is the database), keyed by the SpexCode record
|
|
4
|
-
# id so the dashboard's session window links to it.
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# session
|
|
17
|
-
#
|
|
18
|
-
#
|
|
5
|
+
# id so the dashboard's session window links to it.
|
|
6
|
+
#
|
|
7
|
+
# The id is simply READ here, because it was established where it is KNOWN: whoever creates an agent's
|
|
8
|
+
# execution context injects that context's own record id, and nothing else may carry one.
|
|
9
|
+
# - a session launch bakes `SPEXCODE_SESSION_ID=<record id>` into the agent's process — and STRIPS every
|
|
10
|
+
# session-identity variable it inherited first, so the pane's ancestry (a tmux server started long ago by
|
|
11
|
+
# someone else) cannot smuggle one in;
|
|
12
|
+
# - a codex thread gets the same variable injected into every command it spawns, at `thread/start`, via
|
|
13
|
+
# codex's own `shell_environment_policy.set` — the tool shell is a child of the SHARED app-server and must
|
|
14
|
+
# therefore inherit nothing, so the backend hands it its identity per thread instead;
|
|
15
|
+
# - that shared app-server, and any other process we own that belongs to no single session, is spawned with
|
|
16
|
+
# those variables stripped. A process that outlived its session handing its id to strangers is exactly how
|
|
17
|
+
# this trailer once named a session that no longer existed (github#76).
|
|
18
|
+
# So there is nothing to resolve, alias, or verify at commit time: the variable is present only in processes
|
|
19
|
+
# that genuinely belong to that session, and it already IS the record id. No store lookup, no per-harness
|
|
20
|
+
# tiers, and nothing derived from the current directory — where a process stands says nothing about who it is.
|
|
21
|
+
# No id → no trailer. An existing Session: trailer is left alone, so the ritual / a human can set one by hand.
|
|
19
22
|
set -euo pipefail
|
|
20
23
|
msg_file="$1"
|
|
21
24
|
grep -qiE '^Session:[[:space:]]' "$msg_file" && exit 0
|
|
25
|
+
[ -n "${SPEXCODE_SESSION_ID:-}" ] || exit 0
|
|
22
26
|
|
|
23
|
-
# the per-project store (mirrors layout.ts runtimeRoot / harness.sh hp_runtime_dir): keyed by the project
|
|
24
|
-
# (dirname of the ABSOLUTE git-common-dir), '/' and '.' → '-'.
|
|
25
|
-
gcd=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
|
|
26
|
-
|| gcd=$(realpath "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null) || gcd=""
|
|
27
|
-
rd=""
|
|
28
|
-
[ -n "$gcd" ] && rd="${SPEXCODE_HOME:-$HOME/.spexcode}/projects/$(printf '%s' "$(dirname "$gcd")" | sed 's#[/.]#-#g')"
|
|
29
|
-
|
|
30
|
-
sid="${CLAUDE_CODE_SESSION_ID:-}" # claude: the exported id IS the record id
|
|
31
|
-
if [ -z "$sid" ] && [ -n "${CODEX_THREAD_ID:-}" ] && [ -n "$rd" ]; then # codex: alias the thread id → record id
|
|
32
|
-
if [ -e "$rd/sessions/$CODEX_THREAD_ID/session.json" ]; then
|
|
33
|
-
sid="$CODEX_THREAD_ID" # a governed record keyed by the thread id (rare/direct)
|
|
34
|
-
else # else the record that captured it as harness_session_id
|
|
35
|
-
# @@@ unmatched-is-noop - grep's no-match (exit 1) and an absent sessions glob (exit 2) are the
|
|
36
|
-
# NOT-GOVERNED case, not an error: any repo inherits a foreign CODEX_THREAD_ID from a codex session's
|
|
37
|
-
# shell, and under `set -euo pipefail` a bare grep|head assignment would abort the hook — and the
|
|
38
|
-
# commit — before the intended `[ -z "$sid" ] && exit 0` no-op. Genuine errors past this lookup
|
|
39
|
-
# (interpret-trailers etc.) still fail loud via set -e.
|
|
40
|
-
hit=$(grep -lF "\"harness_session_id\": \"$CODEX_THREAD_ID\"" "$rd"/sessions/*/session.json 2>/dev/null | head -1) || hit=""
|
|
41
|
-
[ -n "$hit" ] && sid=$(basename "$(dirname "$hit")")
|
|
42
|
-
fi
|
|
43
|
-
fi
|
|
44
|
-
if [ -z "$sid" ] && [ -n "${SPEXCODE_SESSION_ID:-}" ] && [ -n "$rd" ]; then
|
|
45
|
-
# last tier of the shared envSessionId precedence rule: no harness var claimed the commit, so the
|
|
46
|
-
# launch-injected record id is the identity — IF its record exists (governance check; swept on close) and
|
|
47
|
-
# is not codex's (the one harness whose shared server contaminates this env var).
|
|
48
|
-
rec="$rd/sessions/$SPEXCODE_SESSION_ID/session.json"
|
|
49
|
-
if [ -e "$rec" ] && ! grep -q '"harness": "codex"' "$rec"; then
|
|
50
|
-
sid="$SPEXCODE_SESSION_ID"
|
|
51
|
-
fi
|
|
52
|
-
fi
|
|
53
|
-
|
|
54
|
-
[ -z "$sid" ] && exit 0
|
|
55
27
|
# Stamp via interpret-trailers (git's own sample-hook mechanism), NOT a raw printf append: appending after a
|
|
56
28
|
# message that already carries a trailer block (e.g. `spex spec ack`'s Spec-OK) opens a new paragraph, and git then
|
|
57
29
|
# parses ONLY the last paragraph as trailers — the earlier trailers silently become body prose. interpret-trailers
|
|
@@ -59,5 +31,5 @@ fi
|
|
|
59
31
|
# on an unterminated message (git merge -m writes MERGE_MSG without one) it folds the trailer into the subject
|
|
60
32
|
# paragraph, where git's trailer parser never looks — the reason merge commits' Session: never parsed.
|
|
61
33
|
[ -n "$(tail -c1 "$msg_file")" ] && echo >> "$msg_file"
|
|
62
|
-
git interpret-trailers --in-place --trailer "Session: $
|
|
34
|
+
git interpret-trailers --in-place --trailer "Session: $SPEXCODE_SESSION_ID" "$msg_file"
|
|
63
35
|
exit 0
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# spexcode-managed-hook-v1
|
|
3
|
+
# @@@ candidate lint ([[code-anchor]]) - one unmarked predicate at Git's prepared ref boundary:
|
|
4
|
+
# a refs/heads update that introduces a commit object which is not already reachable from refs/reflogs.
|
|
5
|
+
# Structural ref plumbing is inert; no commit-msg arm, marker, TTL, tree, parent, or message identity exists.
|
|
6
|
+
set -u
|
|
7
|
+
state="${1:-}"
|
|
8
|
+
payload=$(cat)
|
|
9
|
+
[ -z "${SPEXCODE_GATE_TRACE:-}" ] || printf 'spexcode-gate: reference state=%s action=%s payload=%s\n' "$state" "${GIT_REFLOG_ACTION:-}" "$(printf '%s' "$payload" | tr '\n' ';')" >&2
|
|
10
|
+
[ "$state" = prepared ] || exit 0
|
|
11
|
+
[ -z "${SPEXCODE_SKIP_LINT:-}" ] || exit 0
|
|
12
|
+
|
|
13
|
+
candidate=
|
|
14
|
+
candidate_old=
|
|
15
|
+
operation_git_dir=$(git rev-parse --git-dir 2>/dev/null || printf .git)
|
|
16
|
+
while read -r old new ref; do
|
|
17
|
+
[ -n "${new:-}" ] || continue
|
|
18
|
+
case "$ref" in HEAD|refs/heads/*) ;; *) continue ;; esac
|
|
19
|
+
case "$new" in *[!0-9a-f]*) continue ;; esac
|
|
20
|
+
[ "$(git cat-file -t "$new" 2>/dev/null || true)" = commit ] || continue
|
|
21
|
+
# Git exposes no operation name at this boundary. Do not infer one from old=0 or from ancestor process
|
|
22
|
+
# command lines: an unreachable ref creation is the same observable candidate as any other new ref object.
|
|
23
|
+
case "$old" in '' ) continue ;; esac
|
|
24
|
+
candidate="$new"
|
|
25
|
+
candidate_old="$old"
|
|
26
|
+
break
|
|
27
|
+
done <<< "$payload"
|
|
28
|
+
[ -n "$candidate" ] || exit 0
|
|
29
|
+
|
|
30
|
+
# Rebase/cherry-pick/revert/am author new content even when a detached rewrite left the object in a reflog.
|
|
31
|
+
# Ordinary reset/branch/checkout targets are already reachable and therefore remain inert.
|
|
32
|
+
force_operation=0
|
|
33
|
+
if [ -d "$operation_git_dir/rebase-merge" ] || [ -d "$operation_git_dir/rebase-apply" ] \
|
|
34
|
+
|| [ -d "$operation_git_dir/sequencer" ] || [ -f "$operation_git_dir/CHERRY_PICK_HEAD" ] \
|
|
35
|
+
|| [ -f "$operation_git_dir/REVERT_HEAD" ]; then
|
|
36
|
+
force_operation=1
|
|
37
|
+
fi
|
|
38
|
+
if [ "$force_operation" -eq 0 ]; then
|
|
39
|
+
if [ -n "$(git for-each-ref --contains "$candidate" --format='%(refname)' 2>/dev/null)" ] \
|
|
40
|
+
|| git reflog --all --format=%H 2>/dev/null | grep -Fqx "$candidate"; then
|
|
41
|
+
[ -z "${SPEXCODE_GATE_TRACE:-}" ] || echo "spexcode-gate: reachable candidate=$candidate skipped" >&2
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
fi
|
|
45
|
+
[ -z "${SPEXCODE_GATE_TRACE:-}" ] || echo "spexcode-gate: candidate=$candidate force=$force_operation" >&2
|
|
46
|
+
|
|
47
|
+
# Local issue/remark commits change only non-topology data. With no code path and no node spec.md in the
|
|
48
|
+
# candidate diff, an anchored unit cannot have moved and no governing claim can have changed. Prove that
|
|
49
|
+
# narrow shape directly at the ref boundary so dashboard writes do not launch a full CLI merely to rediscover
|
|
50
|
+
# the same fact. This is candidate classification, not a writer-set bypass: every other shape still lints.
|
|
51
|
+
issue_only=1
|
|
52
|
+
parent_count=$(git rev-list --parents -n1 "$candidate" 2>/dev/null | awk '{print NF-1}')
|
|
53
|
+
[ "${parent_count:-0}" -gt 1 ] && issue_only=0
|
|
54
|
+
changed_paths=$(git -c core.quotePath=false diff-tree --no-commit-id --name-only -r -m "$candidate_old" "$candidate" 2>/dev/null || true)
|
|
55
|
+
[ -n "$changed_paths" ] || issue_only=0
|
|
56
|
+
while IFS= read -r path; do
|
|
57
|
+
[ -n "$path" ] || continue
|
|
58
|
+
case "$path" in .spec/.issues/*) ;; *) issue_only=0; break ;; esac
|
|
59
|
+
done <<< "$changed_paths"
|
|
60
|
+
if [ "$issue_only" -eq 1 ]; then
|
|
61
|
+
[ -z "${SPEXCODE_GATE_TRACE:-}" ] || echo "spexcode-gate: issue-only candidate=$candidate skipped before lint" >&2
|
|
62
|
+
exit 0
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
main_root=$(dirname "$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)")
|
|
66
|
+
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
|
67
|
+
spex_kind=
|
|
68
|
+
if command -v spex >/dev/null 2>&1; then
|
|
69
|
+
spex_kind=path
|
|
70
|
+
elif [ -x "$repo_root/node_modules/.bin/spex" ]; then
|
|
71
|
+
spex_kind=local
|
|
72
|
+
elif [ -x "$main_root/spec-cli/bin/spex.mjs" ]; then
|
|
73
|
+
spex_kind=pkg
|
|
74
|
+
fi
|
|
75
|
+
spex_cli() {
|
|
76
|
+
case "$spex_kind" in
|
|
77
|
+
path) spex "$@" ;;
|
|
78
|
+
local) "$repo_root/node_modules/.bin/spex" "$@" ;;
|
|
79
|
+
pkg) "$main_root/spec-cli/bin/spex.mjs" "$@" ;;
|
|
80
|
+
*) return 127 ;;
|
|
81
|
+
esac
|
|
82
|
+
}
|
|
83
|
+
if [ -z "$spex_kind" ]; then
|
|
84
|
+
echo "• SpexCode: candidate spec-lint skipped — no \`spex\` CLI found; CI still enforces." >&2
|
|
85
|
+
exit 0
|
|
86
|
+
fi
|
|
87
|
+
SPEXCODE_GATE_SCOPE_ONLY=1 spex_cli spec lint --pending "$candidate" >&2
|
|
88
|
+
lint_rc=$?
|
|
89
|
+
if [ "$lint_rc" -eq 76 ]; then
|
|
90
|
+
[ -z "${SPEXCODE_GATE_TRACE:-}" ] || echo "spexcode-gate: non-governed candidate=$candidate skipped before full lint" >&2
|
|
91
|
+
exit 0
|
|
92
|
+
fi
|
|
93
|
+
[ -z "${SPEXCODE_GATE_TRACE:-}" ] || echo "spexcode-gate: lint_rc=$lint_rc candidate=$candidate" >&2
|
|
94
|
+
if [ "$lint_rc" -eq 75 ]; then
|
|
95
|
+
echo "• SpexCode: candidate spec-lint skipped — spex is paused mid-merge (exit 75); CI still enforces." >&2
|
|
96
|
+
exit 0
|
|
97
|
+
fi
|
|
98
|
+
if [ "$lint_rc" -ne 0 ]; then
|
|
99
|
+
echo "✗ SpexCode: candidate spec-lint failed; the ref is unchanged and staged/sequencer state is intact." >&2
|
|
100
|
+
echo " Explicit bypass: SPEXCODE_SKIP_LINT=1 git commit …" >&2
|
|
101
|
+
echo " Continue after staging a repair: git merge --continue | git cherry-pick --continue | git rebase --continue" >&2
|
|
102
|
+
echo " Abort the active operation: git merge --abort | git cherry-pick --abort | git rebase --abort" >&2
|
|
103
|
+
exit 1
|
|
104
|
+
fi
|
|
105
|
+
exit 0
|
|
@@ -5,4 +5,4 @@ status: active
|
|
|
5
5
|
hue: 280
|
|
6
6
|
desc: Launch a supervisor agent that manages other agents from the main checkout to drive a goal to completion.
|
|
7
7
|
---
|
|
8
|
-
You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code.
|
|
8
|
+
You are a SpexCode supervisor — a **manager**, not a feature worker. Your work base is the main checkout (the repository root), NOT your own worktree: do all git via `git -C <root>`, everything else via the `spex` CLI, and never write feature code. This preset IS your complete playbook (dispatch → monitor → review → merge → close, and how to parallelize) — the CLI's own `spex help session` is the reference for every verb's exact semantics. Drive the goal: decompose it into worker-sized tasks and dispatch one worker per independent task (`spex session new "<task>"` — give each ONLY its task; a task about one specific node mentions it as `[[<id>]]`, which only sets the branch name and board attribution; the session's real node links come from what it edits), monitor with `spex session watch`, review proposals with `spex session review <id>`, dispatch the merge of good ones back to their own session (`spex session merge <id>`; the doer syncs the base into its own worktree first, so what reaches `<root>` is a trivial merge) and confirm it landed, then close. `<root>` is the fleet's ONE landing door — it takes one landing at a time, so a worker whose merge finds it mid-merge waits rather than racing, and you never fix up another lane's half-merged index yourself. Never let a worker self-merge; keep `spex spec lint` at 0 errors. To READ a worker's current state, use the one-shot snapshots (`spex session review <id>` or `spex session ls` — both return immediately); to WAIT for a worker, background `spex session wait <id>` — it is edge-triggered: it returns only when it OBSERVES the worker transition from non-actionable into an actionable status (an already-actionable arrival state does not return it), printing the observed status path — which is also how you wait for a dispatched merge to actually land; never block on `spex session watch`, which STREAMS forever and will freeze your turn. **Stay parked while your fleet runs:** after dispatching, background a `spex session wait <child>` per worker (per the manager loop) so that when you stop with children still working you land in `parked` — self-resuming on the next child transition — NOT `asking`; only go `asking` when you genuinely need the human. This matters because the dashboard **folds each child under you and shows YOUR own status for the whole group** (session-nesting, no child-status aggregation), so a supervisor that stays parked-while-they-run is what makes that folded group status honest. One footgun that bites a fresh supervisor: before `spex session close <id>`, confirm the merge landed (`git -C <root> log -1` shows HEAD at the new merge commit) — closing an unmerged branch discards the work. **DRAIN THE ISSUES** (issues / local-issues) as part of your loop: `spex issue ls` lists every open concern in one place — the taste concerns finished sessions recorded locally, AND the forge's issues, store-tagged. Cluster the same concern yourself (use judgment — duplicates are a recurrence SIGNAL, not noise; fold them into one) and weigh by recurrence AND novelty — **recurrence is salience, not importance, so never just fix the highest count**: a sharp single-voice concern can outrank a popular gripe. For the ones worth acting on, `spex session new "<task>"` a worker to land it (mention the concern's node as `[[<id>]]` if it has one), then `spex issue ls resolve <id> --as accepted|landed` (or `rejected`, with a reply saying why) so the store reflects the decision. Report progress as you go and when the goal is complete. Your goal follows:
|