spexcode 0.5.2 → 0.5.4
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 +15 -4
- package/spec-cli/src/harness.ts +1133 -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 +698 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +73 -15
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +189 -41
- 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 +174 -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 +88 -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 +1251 -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 +29 -15
- 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
package/spec-cli/src/layout.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { join, dirname } from 'node:path'
|
|
3
|
-
import { homedir } from 'node:os'
|
|
4
3
|
import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
|
|
5
4
|
import { guardWorktree } from './resilience.js'
|
|
6
5
|
import { HARNESSES, type HarnessId } from './harness.js'
|
|
6
|
+
import { encodeProject, projectRuntimeRoot, spexcodeHome } from './project-store.js'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
export { encodeProject, spexcodeHome } from './project-store.js'
|
|
9
|
+
|
|
10
|
+
export type Config = {
|
|
9
11
|
main?: string // path to the source-of-truth checkout (default: the `main` worktree)
|
|
10
|
-
mainBranch?: string // source-of-truth
|
|
12
|
+
mainBranch?: string // stable source-of-truth branch stamped by init (default: "main")
|
|
11
13
|
branchPrefix?: string // how a branch names its node (default: "node/")
|
|
12
14
|
preset?: string // the SELECTED init preset — which cumulative .plugins tier `spex init` seeds (default 'default'; seed-time only, no launcher gate; read by init.ts; see [[init-preset]])
|
|
13
15
|
// RETIRED ([[residence]]) — the old three-word footprint vote. Materialized artifacts carry no facts and are never
|
|
@@ -38,6 +40,13 @@ type Config = {
|
|
|
38
40
|
launchers?: { [name: string]: { harness?: HarnessId; cmd: string } }
|
|
39
41
|
defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses; required for no-choice creates
|
|
40
42
|
}
|
|
43
|
+
resources?: {
|
|
44
|
+
sessionRssMiB?: number // resident-memory budget for one session owner (default 1024)
|
|
45
|
+
backendRssMiB?: number // resident-memory budget for this project's backend instance (default 2048)
|
|
46
|
+
idleCpuPercent?: number // CPU budget for a non-progressing owner (default 2)
|
|
47
|
+
sampleMs?: number // CPU measurement window for an on-demand report (default 1000)
|
|
48
|
+
reportIntervalMs?: number // supervisor-owned snapshot cadence (default 60000)
|
|
49
|
+
}
|
|
41
50
|
serve?: {
|
|
42
51
|
// public-exposure config for `spex serve --public` (resolved gateway-side; see [[public-mode]] / gateway.ts).
|
|
43
52
|
// The password is NEVER read from here — flag/env only — so this file stays committable.
|
|
@@ -58,11 +67,12 @@ type Config = {
|
|
|
58
67
|
// `serve`, `harnesses`, `render`, and `preset` are frontend/runtime/policy concerns (read separately via readConfig —
|
|
59
68
|
// preset by init.ts at seed time, harnesses by [[harness-select]]; see api-endpoint / sessions.ts maxActive /
|
|
60
69
|
// gateway.ts), NOT layout fields, so they stay out of the convention rather than forcing a default.
|
|
61
|
-
type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
|
|
70
|
+
type Convention = Required<Omit<Config, 'dashboard' | 'sessions' | 'resources' | 'serve' | 'harnesses' | 'preset' | 'issues' | 'forge' | 'private' | 'render'>>
|
|
62
71
|
|
|
63
72
|
export type Worktree = {
|
|
64
73
|
path: string; branch: string | null; node: string | null
|
|
65
74
|
session: string | null; status: string | null; isMain: boolean
|
|
75
|
+
liveness?: 'offline' | 'unknown'
|
|
66
76
|
ops: NodeOp[] // pending spec-node changes this worktree makes vs main (the board's overlay)
|
|
67
77
|
}
|
|
68
78
|
export type Layout = { main: string; convention: Convention; worktrees: Worktree[] }
|
|
@@ -104,13 +114,9 @@ export function gitCommonDir(): string {
|
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
export function mainBranch(): string {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const cur = git(['-C', mainCheckout(), 'symbolic-ref', '--short', 'HEAD']).trim()
|
|
111
|
-
if (cur) return cur
|
|
112
|
-
} catch { /* fall through to the conventional default */ }
|
|
113
|
-
return 'main'
|
|
117
|
+
let checkout: string
|
|
118
|
+
try { checkout = mainCheckout() } catch { return 'main' }
|
|
119
|
+
return readConfig(checkout).mainBranch?.trim() || 'main'
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
// the MAIN checkout (the root working tree) for a project — the SAME answer from main OR any linked worktree
|
|
@@ -132,14 +138,8 @@ export function mainCheckout(proj?: string): string {
|
|
|
132
138
|
// so the board enumerates ONE directory. This is the single seam that knows where the store sits; sessions.ts
|
|
133
139
|
// and the shell hooks resolve through the SAME scheme (the hooks reimplement it in bash, so any change here
|
|
134
140
|
// must be mirrored in .plugins/core/*/). SPEXCODE_HOME overrides the root for test isolation.
|
|
135
|
-
export function spexcodeHome(): string {
|
|
136
|
-
return process.env.SPEXCODE_HOME || join(homedir(), '.spexcode')
|
|
137
|
-
}
|
|
138
141
|
// encode a project-root path into ONE safe directory segment (Claude's scheme: path separators → '-'). The
|
|
139
142
|
// SAME transform runs in TS and in the shell hooks, so a board read and a hook write land on the SAME dir.
|
|
140
|
-
export function encodeProject(root: string): string {
|
|
141
|
-
return root.replace(/[/.]/g, '-')
|
|
142
|
-
}
|
|
143
143
|
// this project's per-PROJECT runtime tier — the sessions/ records AND the per-TREE materialize slots (below) —
|
|
144
144
|
// living under the SAME global per-project dir, so NOTHING SpexCode materializes stays in the worktree (the
|
|
145
145
|
// worktree holds only the harness-discovered CLAUDE.md/AGENTS.md + shims, which must sit in-tree).
|
|
@@ -149,7 +149,7 @@ export function runtimeRoot(proj?: string): string {
|
|
|
149
149
|
const gcd = proj
|
|
150
150
|
? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
151
151
|
: gitCommonDir()
|
|
152
|
-
return
|
|
152
|
+
return projectRuntimeRoot(gcd)
|
|
153
153
|
}
|
|
154
154
|
// the per-WORKTREE materialize slot — <runtime>/trees/<enc(worktree-toplevel)> — holding the materialize
|
|
155
155
|
// products that are a pure function of ONE tree's .plugins (hooks-manifest, content-hash, plugin-folders).
|
|
@@ -178,9 +178,61 @@ export type RawRecord = {
|
|
|
178
178
|
node: string | null; title: string | null; name: string | null; parent?: string | null
|
|
179
179
|
status: string; proposal: string | null; merges: number; note: string | null
|
|
180
180
|
sortkey: number | null; createdAt: number; harness?: string; harness_session_id?: string
|
|
181
|
+
stopped?: boolean
|
|
182
|
+
archived?: boolean // the human ARCHIVED this session ([[archive]]) — only a proven cold/offline row; absent → false on old records
|
|
183
|
+
cold_proof?: string // durable exact leaf + adapter cold proof; absent on legacy archives, which remain visible hazards
|
|
184
|
+
adapter_recovery?: string // explicit lifecycle recovery required after a partial adapter mutation; absent on old records
|
|
181
185
|
launcher?: string // the launcher profile this session was created under ([[launcher-select]]); absent/empty only on old records predating launchers
|
|
182
186
|
launch_cmd?: string // the RESOLVED base launcher command PINNED at creation, so a resume replays the EXACT launcher (and its config-dir env) that made the conversation, never a since-changed default ([[launcher-select]] resume-launcher-pin); absent → old record, fall back to the launcher name / ambient
|
|
187
|
+
launch_readiness_pending?: '' | RawLaunchReadinessPending
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const SESSION_LIFECYCLES = ['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'] as const
|
|
191
|
+
export const SESSION_PROPOSALS = ['merge', 'nothing', 'close'] as const
|
|
192
|
+
export type SessionLifecycle = typeof SESSION_LIFECYCLES[number]
|
|
193
|
+
export type SessionProposal = typeof SESSION_PROPOSALS[number]
|
|
194
|
+
const sessionLifecycles = new Set<string>(SESSION_LIFECYCLES)
|
|
195
|
+
const sessionProposals = new Set<string>(SESSION_PROPOSALS)
|
|
196
|
+
export const isSessionLifecycle = (value: unknown): value is SessionLifecycle =>
|
|
197
|
+
typeof value === 'string' && sessionLifecycles.has(value)
|
|
198
|
+
export const isSessionProposal = (value: unknown): value is SessionProposal =>
|
|
199
|
+
typeof value === 'string' && sessionProposals.has(value)
|
|
200
|
+
|
|
201
|
+
export type RawLaunchReadinessOriginal = {
|
|
202
|
+
status: string
|
|
203
|
+
proposal: string | null
|
|
204
|
+
note: string | null
|
|
205
|
+
stopped: boolean
|
|
206
|
+
archived: boolean
|
|
207
|
+
cold_proof: string | null
|
|
208
|
+
adapter_recovery: string | null
|
|
183
209
|
}
|
|
210
|
+
|
|
211
|
+
export type RawLaunchReadinessPending = {
|
|
212
|
+
version: 1
|
|
213
|
+
startedAt: number
|
|
214
|
+
original: RawLaunchReadinessOriginal
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// A launch candidate is durable before it is public. Readers of the authored lifecycle use this one parser
|
|
218
|
+
// so the board and the independent timeline observer cannot disagree about an in-flight resume. Invalid
|
|
219
|
+
// pending bytes throw: a damaged publication fence is unknowable state, never permission to project online.
|
|
220
|
+
export function rawLaunchReadinessOriginal(raw: RawRecord): RawLaunchReadinessOriginal | null {
|
|
221
|
+
const pending = raw.launch_readiness_pending
|
|
222
|
+
if (pending == null || pending === '') return null
|
|
223
|
+
const original = pending && typeof pending === 'object' ? pending.original : null
|
|
224
|
+
if (pending.version !== 1 || !Number.isFinite(pending.startedAt) || !original || typeof original !== 'object'
|
|
225
|
+
|| !isSessionLifecycle(original.status)
|
|
226
|
+
|| !(original.proposal === null || original.proposal === '' || isSessionProposal(original.proposal))
|
|
227
|
+
|| !(typeof original.note === 'string' || original.note === null)
|
|
228
|
+
|| typeof original.stopped !== 'boolean' || typeof original.archived !== 'boolean'
|
|
229
|
+
|| !(typeof original.cold_proof === 'string' || original.cold_proof === null)
|
|
230
|
+
|| !(typeof original.adapter_recovery === 'string' || original.adapter_recovery === null)) {
|
|
231
|
+
throw new Error(`session '${raw.session_id}' has an invalid launch_readiness_pending fence`)
|
|
232
|
+
}
|
|
233
|
+
return original
|
|
234
|
+
}
|
|
235
|
+
|
|
184
236
|
// the agent's OWN session id from the environment — the only locator now that the record left the worktree.
|
|
185
237
|
// Three tiers, in order:
|
|
186
238
|
// (1) a harness's per-thread env var (`sessionEnvVar`) RESOLVED VIA THE ALIAS — when it lands on a governed
|
|
@@ -211,11 +263,80 @@ export function envSessionId(): string | null {
|
|
|
211
263
|
for (const h of HARNESSES) { const v = process.env[h.sessionEnvVar]; if (v && v.trim()) return v.trim() }
|
|
212
264
|
return null
|
|
213
265
|
}
|
|
214
|
-
|
|
266
|
+
// @@@ RecordEntry - a record read has THREE outcomes, and collapsing them is what let a live session read as
|
|
267
|
+
// "no session record". ABSENT (no file) is the legitimate nothing — a self-launched agent that only ever wrote
|
|
268
|
+
// spec-discipline sentinels has a store dir and no record. CORRUPT (present but unparseable, or parseable but
|
|
269
|
+
// not a record) is a FACT about a session that exists, so it must reach the surfaces as itself instead of
|
|
270
|
+
// masquerading as absence: sessions-core refuses every writer on it and the board gives it its own row. Any
|
|
271
|
+
// OTHER read failure (permissions, I/O) still THROWS — a transient fault must not read as either.
|
|
272
|
+
export type RecordEntry =
|
|
273
|
+
| { kind: 'ok'; raw: RawRecord }
|
|
274
|
+
| { kind: 'absent' }
|
|
275
|
+
| { kind: 'corrupt'; path: string; error: string }
|
|
276
|
+
|
|
277
|
+
export type PublicRecordEntry =
|
|
278
|
+
| { kind: 'ok'; raw: RawRecord; liveness: 'offline' | null }
|
|
279
|
+
| { kind: 'absent' }
|
|
280
|
+
| { kind: 'corrupt'; sessionId: string; governed: boolean | null; path: string; error: string; liveness: 'unknown' }
|
|
281
|
+
|
|
282
|
+
export function readRecordEntry(id: string): RecordEntry {
|
|
283
|
+
const path = sessionRecordPath(id)
|
|
284
|
+
let text: string
|
|
285
|
+
try { text = readFileSync(path, 'utf8') }
|
|
286
|
+
catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'absent' }; throw e }
|
|
287
|
+
let raw: unknown
|
|
288
|
+
try { raw = JSON.parse(text) }
|
|
289
|
+
catch (e) { return { kind: 'corrupt', path, error: e instanceof Error ? e.message : String(e) } }
|
|
290
|
+
if (!raw || typeof raw !== 'object' || !(raw as RawRecord).session_id)
|
|
291
|
+
return { kind: 'corrupt', path, error: 'parsed, but carries no session_id — not a session record' }
|
|
292
|
+
return { kind: 'ok', raw: raw as RawRecord }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// The ONE public session-record parser. Internal mutation/readiness code uses readRecordEntry's exact raw
|
|
296
|
+
// candidate; every public projection passes through here. A valid pending fence replaces all lifecycle-facing
|
|
297
|
+
// fields with its frozen original and forces offline liveness. Malformed pending bytes remain a present,
|
|
298
|
+
// corrupt/unknown row instead of leaking candidate state or disappearing as absence.
|
|
299
|
+
export function projectPublicRecordEntry(id: string, entry: RecordEntry): PublicRecordEntry {
|
|
300
|
+
if (entry.kind === 'absent') return entry
|
|
301
|
+
if (entry.kind === 'corrupt') return {
|
|
302
|
+
kind: 'corrupt', sessionId: id, governed: null, path: entry.path, error: entry.error, liveness: 'unknown',
|
|
303
|
+
}
|
|
215
304
|
try {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
305
|
+
const original = rawLaunchReadinessOriginal(entry.raw)
|
|
306
|
+
if (!original) return { kind: 'ok', raw: entry.raw, liveness: null }
|
|
307
|
+
return {
|
|
308
|
+
kind: 'ok',
|
|
309
|
+
raw: {
|
|
310
|
+
...entry.raw,
|
|
311
|
+
status: original.status,
|
|
312
|
+
proposal: original.proposal || null,
|
|
313
|
+
note: original.note || null,
|
|
314
|
+
stopped: original.stopped,
|
|
315
|
+
archived: original.archived,
|
|
316
|
+
cold_proof: original.cold_proof ?? undefined,
|
|
317
|
+
adapter_recovery: original.adapter_recovery ?? undefined,
|
|
318
|
+
launch_readiness_pending: '',
|
|
319
|
+
},
|
|
320
|
+
liveness: 'offline',
|
|
321
|
+
}
|
|
322
|
+
} catch (error) {
|
|
323
|
+
return {
|
|
324
|
+
kind: 'corrupt',
|
|
325
|
+
sessionId: id,
|
|
326
|
+
governed: typeof entry.raw.governed === 'boolean' ? entry.raw.governed : null,
|
|
327
|
+
path: sessionRecordPath(id),
|
|
328
|
+
error: error instanceof Error ? error.message : String(error),
|
|
329
|
+
liveness: 'unknown',
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function readPublicRecordEntry(id: string): PublicRecordEntry {
|
|
335
|
+
return projectPublicRecordEntry(id, readRecordEntry(id))
|
|
336
|
+
}
|
|
337
|
+
export function readRawRecord(id: string): RawRecord | null {
|
|
338
|
+
try { const e = readRecordEntry(id); return e.kind === 'ok' ? e.raw : null }
|
|
339
|
+
catch { return null }
|
|
219
340
|
}
|
|
220
341
|
// resolve a possibly-ALIASED session id to its raw record. A codex hook or spawned command can carry the codex
|
|
221
342
|
// THREAD id — payload session_id / CODEX_THREAD_ID — not the SpexCode record id the store is keyed by. Direct id
|
|
@@ -223,13 +344,20 @@ export function readRawRecord(id: string): RawRecord | null {
|
|
|
223
344
|
// before any tool turn).
|
|
224
345
|
// Null when neither resolves. Mirrors the shell `hp_store_dir` alias grep — one resolution rule, both layers.
|
|
225
346
|
export function readAliasedRawRecord(id: string): RawRecord | null {
|
|
226
|
-
const
|
|
227
|
-
|
|
347
|
+
const e = readAliasedRecordEntry(id)
|
|
348
|
+
return e.kind === 'ok' ? e.raw : null
|
|
349
|
+
}
|
|
350
|
+
// the same alias resolution, keeping the three-way outcome. A CORRUPT record at the direct id settles the
|
|
351
|
+
// question — we found this session and cannot read it; walking on to the alias would report a corrupt record
|
|
352
|
+
// as absent, the exact collapse this type exists to prevent.
|
|
353
|
+
export function readAliasedRecordEntry(id: string): RecordEntry {
|
|
354
|
+
const direct = readRecordEntry(id)
|
|
355
|
+
if (direct.kind !== 'absent') return direct
|
|
228
356
|
for (const sid of listSessionIds()) {
|
|
229
357
|
const r = readRawRecord(sid)
|
|
230
|
-
if (r && r.harness_session_id && r.harness_session_id === id) return r
|
|
358
|
+
if (r && r.harness_session_id && r.harness_session_id === id) return { kind: 'ok', raw: r }
|
|
231
359
|
}
|
|
232
|
-
return
|
|
360
|
+
return { kind: 'absent' }
|
|
233
361
|
}
|
|
234
362
|
// every session_id this project has a record for (the board's enumeration source — replaces `git worktree
|
|
235
363
|
// list`). A MISSING store dir means no session ever launched → []. But any OTHER readdir failure THROWS
|
|
@@ -242,24 +370,27 @@ export function listSessionIds(): string[] {
|
|
|
242
370
|
return ents.filter((d) => d.isDirectory()).map((d) => d.name)
|
|
243
371
|
}
|
|
244
372
|
|
|
245
|
-
// memo the overlay (
|
|
246
|
-
//
|
|
373
|
+
// memo the overlay (4 git diffs/worktree, all .spec-scoped) keyed on fork-point merge-base + HEAD + spec
|
|
374
|
+
// sig + MAIN'S TIP ([[worktree-linker]]): the main-tip component is what lets a merge landing identical
|
|
375
|
+
// content dissolve a worktree's now-moot ops — the recompute it triggers is cheap because every diff is
|
|
376
|
+
// .spec-scoped.
|
|
247
377
|
const deltaCache = new Map<string, { key: string; ops: NodeOp[] }>()
|
|
248
378
|
const safeHead = (p: string): string => { try { return headSha(p) } catch { return '' } }
|
|
249
379
|
const safeMergeBase = async (wtPath: string, mainRef: string): Promise<string> => {
|
|
250
380
|
try { return (await gitA(['-C', wtPath, 'merge-base', mainRef, 'HEAD'])).trim() } catch { return '' }
|
|
251
381
|
}
|
|
252
382
|
let layoutHeadWarned = false
|
|
253
|
-
async function cachedDelta(wtPath: string, mainRef: string): Promise<NodeOp[]> {
|
|
383
|
+
async function cachedDelta(wtPath: string, mainRef: string, mainSha: string): Promise<NodeOp[]> {
|
|
254
384
|
const wtHead = safeHead(wtPath)
|
|
255
385
|
const base = await safeMergeBase(wtPath, mainRef)
|
|
256
|
-
// fail loud, never stale: if the merge-base or
|
|
257
|
-
// cache and recompute (warn once) rather than risk serving a delta keyed on an empty sha
|
|
258
|
-
|
|
259
|
-
|
|
386
|
+
// fail loud, never stale: if the merge-base, HEAD, or main tip can't be read the key is untrustworthy —
|
|
387
|
+
// bypass the cache and recompute (warn once) rather than risk serving a delta keyed on an empty sha
|
|
388
|
+
// across a real change.
|
|
389
|
+
if (!base || !wtHead || !mainSha) {
|
|
390
|
+
if (!layoutHeadWarned) { layoutHeadWarned = true; console.warn('spec-cli: layout overlay cache bypassed (unreadable merge-base/HEAD/main tip), recomputing every read') }
|
|
260
391
|
return worktreeSpecDelta(wtPath, mainRef)
|
|
261
392
|
}
|
|
262
|
-
const key = `${base}\0${wtHead}\0${worktreeSpecSig(wtPath)}`
|
|
393
|
+
const key = `${base}\0${wtHead}\0${mainSha}\0${worktreeSpecSig(wtPath)}`
|
|
263
394
|
const hit = deltaCache.get(wtPath)
|
|
264
395
|
if (hit && hit.key === key) return hit.ops
|
|
265
396
|
const ops = await worktreeSpecDelta(wtPath, mainRef, base)
|
|
@@ -284,20 +415,37 @@ export async function resolveLayout(): Promise<Layout> {
|
|
|
284
415
|
// independent → compute (or cache-hit) in parallel, keyed by worktree path as before. guardWorktree wraps
|
|
285
416
|
// each: a worktree whose dir was genuinely removed mid-read (a worker self-merged + retired it) is OMITTED;
|
|
286
417
|
// one that still exists but hit a transient detail failure is kept as a DEGRADED row from the last cached delta.
|
|
287
|
-
const
|
|
288
|
-
|
|
418
|
+
const publicEntries = listSessionIds().map((id) => readPublicRecordEntry(id))
|
|
419
|
+
.filter((entry) => entry.kind === 'corrupt' ? entry.governed !== false : entry.kind === 'ok' && entry.raw.governed)
|
|
420
|
+
const records = publicEntries.flatMap((entry) => entry.kind === 'ok' ? [entry] : [])
|
|
421
|
+
// main's tip, resolved ONCE per board read — a component of every worktree's overlay cache key
|
|
422
|
+
// ([[worktree-linker]]: landed content must dissolve the ops it made moot).
|
|
423
|
+
const mainSha = await (async () => {
|
|
424
|
+
try { return (await gitA(['-C', main, 'rev-parse', '--verify', `${mainRef}^{commit}`])).trim() } catch { return '' }
|
|
425
|
+
})()
|
|
426
|
+
const rows = await Promise.all(records.map(({ raw: r, liveness }) => {
|
|
289
427
|
const node = r.node ?? (r.branch && r.branch.startsWith(convention.branchPrefix) ? r.branch.slice(convention.branchPrefix.length) : null)
|
|
290
|
-
const base: Worktree = { path: r.worktree_path, branch: r.branch, node, session: r.session_id, status: r.status, isMain: false, ops: [] }
|
|
428
|
+
const base: Worktree = { path: r.worktree_path, branch: r.branch, node, session: r.session_id, status: r.status, isMain: false, ...(liveness ? { liveness } : {}), ops: [] }
|
|
429
|
+
// @@@ archived rows cost nothing - a shelved session ([[archive]]) keeps its row (the record is the
|
|
430
|
+
// existence truth) but skips the per-worktree spec-delta entirely: that git-history probe is the board's
|
|
431
|
+
// dominant per-row cost, and shelving is exactly the human saying "stop spending attention here". So the
|
|
432
|
+
// price of a retained archive is one enumerated record, NOT a git walk per poll.
|
|
433
|
+
if (r.archived) return Promise.resolve(base)
|
|
291
434
|
return guardWorktree<Worktree>(r.worktree_path,
|
|
292
|
-
async (): Promise<Worktree> => ({ ...base, ops: await cachedDelta(r.worktree_path, mainRef) }),
|
|
435
|
+
async (): Promise<Worktree> => ({ ...base, ops: await cachedDelta(r.worktree_path, mainRef, mainSha) }),
|
|
293
436
|
(): Worktree => ({ ...base, ops: deltaCache.get(r.worktree_path)?.ops ?? [] }))
|
|
294
437
|
}))
|
|
295
|
-
const
|
|
438
|
+
const corruptRows: Worktree[] = publicEntries.flatMap((entry) => entry.kind === 'corrupt'
|
|
439
|
+
? [{ path: '', branch: null, node: null, session: entry.sessionId, status: 'corrupt', liveness: 'unknown', isMain: false, ops: [] }]
|
|
440
|
+
: [])
|
|
441
|
+
const sessionWorktrees = [...rows.filter((w): w is Worktree => w !== null), ...corruptRows]
|
|
296
442
|
// the main checkout row (isMain) — always present, carries no overlay; it anchors the merged tree the board draws.
|
|
297
443
|
const mainRow: Worktree = { path: main, branch: base, node: null, session: null, status: null, isMain: true, ops: [] }
|
|
298
444
|
const worktrees = [mainRow, ...sessionWorktrees]
|
|
299
|
-
// drop cache entries for worktrees no longer
|
|
300
|
-
|
|
445
|
+
// drop cache entries for worktrees that may no longer hold one — closed sessions (gone from the store) AND
|
|
446
|
+
// newly-archived ones (which no longer compute a delta), so archiving SELF-EVICTS its cached ops instead of
|
|
447
|
+
// stranding them in a map nothing prunes.
|
|
448
|
+
const live = new Set(records.filter(({ raw }) => !raw.archived).map(({ raw }) => raw.worktree_path))
|
|
301
449
|
for (const k of [...deltaCache.keys()]) if (!live.has(k)) deltaCache.delete(k)
|
|
302
450
|
return { main: convention.main || main || root, convention, worktrees }
|
|
303
451
|
}
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import { repoRoot, git,
|
|
4
|
-
import { loadSpecs } from './specs.js'
|
|
3
|
+
import { repoRoot, git, sourceIndexes, rowsFor, treeFilePaths, treeFileText } from './git.js'
|
|
4
|
+
import { loadSpecs, parseFrontmatter } from './specs.js'
|
|
5
5
|
import { readJsonConfig } from './layout.js'
|
|
6
|
-
import { extractors, extractorFor, extOf, resolveAnchor,
|
|
6
|
+
import { extractors, extractorFor, extOf, parseCodeEntry, relationClaimsPath, resolveAnchor, windowEvents, anchorHitCommits } from './anchors.js'
|
|
7
7
|
import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
|
|
8
8
|
|
|
9
9
|
export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
|
|
@@ -31,10 +31,17 @@ const DEFAULT_CONFIG: LintConfig = {
|
|
|
31
31
|
scenarioTags: ['frontend-e2e', 'backend-api', 'cli', 'desktop', 'mobile'],
|
|
32
32
|
scopedCodeMiss: 'warn',
|
|
33
33
|
}
|
|
34
|
-
export function loadConfig(root: string): LintConfig {
|
|
34
|
+
export function loadConfig(root: string, pendingSource?: string | null): LintConfig {
|
|
35
35
|
// Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
|
|
36
36
|
// silently reverting the author's budgets to defaults and green-washing the very warnings they tuned.
|
|
37
|
-
|
|
37
|
+
let parsed: any
|
|
38
|
+
if (pendingSource === undefined) parsed = readJsonConfig(join(root, 'spexcode.json'))
|
|
39
|
+
else if (pendingSource === null) parsed = {}
|
|
40
|
+
else {
|
|
41
|
+
try { parsed = JSON.parse(pendingSource) }
|
|
42
|
+
catch (e: any) { throw new Error(`invalid JSON in candidate spexcode.json: ${e?.message ?? e}`) }
|
|
43
|
+
}
|
|
44
|
+
const c = parsed?.lint ?? {}
|
|
38
45
|
const merged = { ...DEFAULT_CONFIG, ...c }
|
|
39
46
|
return normalizeConfig(merged)
|
|
40
47
|
}
|
|
@@ -61,11 +68,92 @@ export function normalizeConfig(cfg: LintConfig): LintConfig {
|
|
|
61
68
|
}
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
export
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const
|
|
71
|
+
export type SpecLintOptions = { tip?: string }
|
|
72
|
+
|
|
73
|
+
function untrackedAdoptionFiles(root: string): string[] {
|
|
74
|
+
if (git(['-C', root, 'rev-parse', '--is-inside-work-tree']).trim() !== 'true') return []
|
|
75
|
+
const status = git([
|
|
76
|
+
'-C', root,
|
|
77
|
+
'-c', 'core.quotePath=false',
|
|
78
|
+
'status', '--porcelain=v1', '-z', '--untracked-files=all',
|
|
79
|
+
'--', '.spec', 'spexcode.json',
|
|
80
|
+
])
|
|
81
|
+
return status.split('\0')
|
|
82
|
+
.filter((entry) => entry.startsWith('?? '))
|
|
83
|
+
.map((entry) => entry.slice(3))
|
|
84
|
+
.filter(Boolean)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pendingChangedPaths(root: string, tip: string): string[] {
|
|
88
|
+
try {
|
|
89
|
+
git(['-C', root, 'rev-parse', `${tip}^{commit}`])
|
|
90
|
+
// `-m` compares a merge with every parent. An `ours` merge can leave the result tree
|
|
91
|
+
// identical to its first parent while still making an unacknowledged side-branch commit
|
|
92
|
+
// reachable; first-parent-only paths would filter that debt out of the pending anchor window.
|
|
93
|
+
const fields = git(['-C', root, '-c', 'core.quotePath=false', 'diff-tree', '--no-commit-id', '--name-status', '-z', '-r', '-m', '-M', tip])
|
|
94
|
+
.split('\0').filter(Boolean)
|
|
95
|
+
const changed: string[] = []
|
|
96
|
+
for (let i = 0; i < fields.length;) {
|
|
97
|
+
const status = fields[i++]
|
|
98
|
+
if (/^[RC]/.test(status)) {
|
|
99
|
+
changed.push(fields[i++], fields[i++])
|
|
100
|
+
} else {
|
|
101
|
+
changed.push(fields[i++])
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return [...new Set(changed.filter(Boolean))]
|
|
105
|
+
} catch { return [] }
|
|
106
|
+
}
|
|
107
|
+
// Cheap pending classification for the reference hook. It reads only the candidate tree and claims; it
|
|
108
|
+
// never constructs either history index. A normal lint call deliberately does not use this
|
|
109
|
+
// shortcut so its full findings/oracle contract remains unchanged.
|
|
110
|
+
export async function pendingTouchesGoverned(root: string, tip: string): Promise<boolean> {
|
|
111
|
+
// A merge can introduce reachable side-branch debt without changing the result tree. The first-parent
|
|
112
|
+
// diff is insufficient for a scope proof, so all multi-parent candidates stay on the full lint path.
|
|
113
|
+
const parentCount = git(['-C', root, 'rev-list', '--parents', '-n1', tip]).trim().split(/\s+/).length - 1
|
|
114
|
+
if (parentCount > 1) return true
|
|
115
|
+
const changed = pendingChangedPaths(root, tip)
|
|
116
|
+
if (!changed.length) return true
|
|
117
|
+
// `governedRoots` is source discovery policy, not the set of actual code claims: a spec may deliberately
|
|
118
|
+
// govern a path outside those roots. Read only the candidate spec tree (no history/drift indexes) so the
|
|
119
|
+
// scope proof follows the same code:/related: declarations that lint later enforces.
|
|
120
|
+
const specs = await loadSpecs(root, { tip, history: null, drift: null })
|
|
121
|
+
const claims = specs.flatMap((spec) => [...spec.code, ...spec.related])
|
|
122
|
+
return changed.some((path) => claims.some((claim) => relationClaimsPath(claim, path))
|
|
123
|
+
|| path === 'spexcode.json' || path === 'spexcode.local.json'
|
|
124
|
+
|| (path.startsWith('.spec/') && !path.startsWith('.spec/.issues/'))
|
|
125
|
+
|| path === '.spec')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function specLint(root = repoRoot(), regs = extractors(root), options: SpecLintOptions = {}): Promise<Finding[]> {
|
|
129
|
+
const tip = options.tip ?? 'HEAD'
|
|
130
|
+
const pending = tip !== 'HEAD'
|
|
131
|
+
const changed = pending ? pendingChangedPaths(root, tip) : []
|
|
132
|
+
const files = pending ? treeFilePaths(root, tip) : null
|
|
133
|
+
const directories = new Set<string>()
|
|
134
|
+
for (const file of files ?? []) {
|
|
135
|
+
const parts = file.split('/')
|
|
136
|
+
for (let i = 1; i < parts.length; i++) directories.add(parts.slice(0, i).join('/'))
|
|
137
|
+
}
|
|
138
|
+
const existsAtTip = (path: string) => files ? files.has(path) || directories.has(path.replace(/\/+$/, '')) : existsSync(join(root, path))
|
|
139
|
+
const isDirectoryAtTip = (path: string) => files
|
|
140
|
+
? !files.has(path) && directories.has(path.replace(/\/+$/, ''))
|
|
141
|
+
: statSync(join(root, path)).isDirectory()
|
|
142
|
+
const textAtTip = (path: string) => pending ? treeFileText(root, tip, path) : readFileSync(join(root, path), 'utf8')
|
|
143
|
+
const cfg = loadConfig(root, pending ? treeFileText(root, tip, 'spexcode.json') : undefined)
|
|
144
|
+
const untracked = untrackedAdoptionFiles(root)
|
|
145
|
+
if (untracked.length) {
|
|
146
|
+
const shown = untracked.slice(0, 6)
|
|
147
|
+
const suffix = untracked.length > shown.length ? ` (+${untracked.length - shown.length} more)` : ''
|
|
148
|
+
return [{
|
|
149
|
+
level: 'error',
|
|
150
|
+
rule: 'integrity',
|
|
151
|
+
msg: `project source of truth is untracked: ${shown.join(', ')}${suffix} — add it with \`git add .spec spexcode.json\` and commit it; generated harness files such as .codex/, .claude/, and AGENTS.md are machine-local`,
|
|
152
|
+
}]
|
|
153
|
+
}
|
|
154
|
+
const governed = trackedSourceFiles(root, cfg.governedRoots, cfg, tip)
|
|
155
|
+
const [hidx, didx] = await sourceIndexes(root, tip)
|
|
156
|
+
const specs = await loadSpecs(root, { tip, history: hidx, drift: didx })
|
|
69
157
|
const out: Finding[] = []
|
|
70
158
|
|
|
71
159
|
// integrity + build the file -> owners map. A relation's STRUCTURAL problems (a duplicate entry,
|
|
@@ -73,14 +161,16 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
73
161
|
// [[code-anchor]]) are integrity errors: malformed edges block like broken ones.
|
|
74
162
|
const owners = new Map<string, string[]>()
|
|
75
163
|
const claimed = new Set<string>()
|
|
164
|
+
const governedClaims = new Set<string>()
|
|
76
165
|
for (const s of specs) {
|
|
77
166
|
for (const p of s.relationProblems)
|
|
78
167
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, msg: `'${s.id}' ${p}` })
|
|
79
168
|
const scopedPaths = new Set(s.codeScoped.map((e) => e.path))
|
|
80
169
|
for (const f of s.code) {
|
|
81
|
-
if (!
|
|
170
|
+
if (!existsAtTip(f))
|
|
82
171
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing file: ${f}` })
|
|
83
172
|
claimed.add(f)
|
|
173
|
+
governedClaims.add(f)
|
|
84
174
|
// a selector-SCOPED entry claims named units, not the whole file, so it stays out of the owners
|
|
85
175
|
// bound below ([[code-anchor]]) — `spex spec owner` still displays it as a (scoped) governor.
|
|
86
176
|
if (!scopedPaths.has(f)) owners.set(f, [...(owners.get(f) ?? []), s.id])
|
|
@@ -96,11 +186,34 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
96
186
|
// `related:` is the coverage net: govern is a sharp ideally-one-file pointer, so most files are reached by
|
|
97
187
|
// related, not govern (see [[governed-related]]). It carries coverage but never drift, never eval freshness.
|
|
98
188
|
for (const s of specs) for (const f of s.related) {
|
|
99
|
-
if (!
|
|
189
|
+
if (!existsAtTip(f))
|
|
100
190
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: f, msg: `spec '${s.id}' lists a missing related file: ${f}` })
|
|
101
191
|
claimed.add(f)
|
|
102
192
|
}
|
|
103
193
|
|
|
194
|
+
// A candidate may retire a node together with the implementation it owned, or transfer that path to a
|
|
195
|
+
// new node. It may not delete the governor while leaving the governed subject behind: ordinary coverage
|
|
196
|
+
// is advisory, so without this transition check a candidate could erase the very contract that would
|
|
197
|
+
// reject its code change. Read only deleted spec blobs from HEAD; no second history index is needed.
|
|
198
|
+
if (pending) {
|
|
199
|
+
let deleted: string[] = []
|
|
200
|
+
try {
|
|
201
|
+
deleted = git(['-C', root, '-c', 'core.quotePath=false', 'diff', '--diff-filter=D', '--name-only', 'HEAD', tip, '--', '.spec'])
|
|
202
|
+
.split('\n').map((path) => path.trim()).filter((path) => path.endsWith('/spec.md'))
|
|
203
|
+
} catch { /* an unreadable comparison is already surfaced by the candidate history reads */ }
|
|
204
|
+
for (const specPath of deleted) {
|
|
205
|
+
const oldSource = treeFileText(root, 'HEAD', specPath)
|
|
206
|
+
if (oldSource === null) continue
|
|
207
|
+
const value = parseFrontmatter(oldSource).fm.code
|
|
208
|
+
const rows = Array.isArray(value) ? value : value ? [value] : []
|
|
209
|
+
for (const row of rows) {
|
|
210
|
+
const path = parseCodeEntry(row).path
|
|
211
|
+
if (existsAtTip(path) && !governedClaims.has(path))
|
|
212
|
+
out.push({ level: 'error', rule: 'integrity', file: path, msg: `candidate deletes governor '${specPath}' but leaves its governed subject '${path}' without a code: owner — delete the retired implementation too, or transfer it to a real node in this commit` })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
104
217
|
// id-format: a node id (its leaf dir basename) passes an EXACT per-character whitelist — an ascii char
|
|
105
218
|
// must be [a-z0-9-]; a non-ascii char must be a unicode letter/number (judged on NFC, the mint's
|
|
106
219
|
// canonical form) — and is UNIQUE tree-wide (ERROR). This is THE id vocabulary, defined once (the
|
|
@@ -200,23 +313,22 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
200
313
|
|
|
201
314
|
// code anchors ([[code-anchor]]): a code:/related: entry may pin named units (`path#symbol` — any
|
|
202
315
|
// number per base file, OR'd). On code:, the anchor is the BLOCKING tier of drift: a window
|
|
203
|
-
// commit (spec's last version..
|
|
316
|
+
// commit (spec's last version..tip, ordinary diff or merge-authored combined diff) whose zero-context hunks
|
|
204
317
|
// intersect any pinned unit's line range — extracted from the file AS OF that commit, by the
|
|
205
318
|
// extension's ONE designated extractor — is ONE anchor-drift ERROR naming the hit selectors, unless a
|
|
206
319
|
// Spec-OK ack covers it. On related:, the SAME engine yields only a soft warn on a hit — a scoped
|
|
207
320
|
// related miss is silent (never blocks, no ack, no eval freshness). Resolution failures are never
|
|
208
|
-
// silent for either relation: a dead or ambiguous selector, a selector on a directory, an
|
|
209
|
-
// working-tree file
|
|
210
|
-
// run here
|
|
211
|
-
const regs = extractors(root)
|
|
212
|
-
const [didx, hidx] = await Promise.all([driftIndex(root), historyIndex(root)])
|
|
321
|
+
// silent for either relation: a dead or ambiguous selector, a selector on a directory, and an
|
|
322
|
+
// unparseable working-tree file ERROR. An extension with no designated extractor, or a designated
|
|
323
|
+
// extractor that cannot run here, also ERRORS but skips those anchors so the remaining checks continue.
|
|
213
324
|
const readyWarned = new Set<string>()
|
|
214
325
|
for (const s of specs) {
|
|
215
326
|
for (const { relation, entries } of [{ relation: 'code' as const, entries: s.codeScoped }, { relation: 'related' as const, entries: s.relatedScoped }]) {
|
|
216
327
|
for (const { path, selectors } of entries) {
|
|
328
|
+
if (pending && !changed.some((file) => relationClaimsPath(path, file))) continue
|
|
217
329
|
const x = extractorFor(regs, extOf(path))
|
|
218
330
|
if (!x) {
|
|
219
|
-
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files —
|
|
331
|
+
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' anchors ${path}#${selectors.join(', #')} (${relation}:), but no extractor is designated for '.${extOf(path)}' files — anchor validation was skipped and remains unverified; add a LangSpec row (anchors.ts) or drop the selector(s)` })
|
|
220
332
|
continue
|
|
221
333
|
}
|
|
222
334
|
const ready = x.ready()
|
|
@@ -225,13 +337,17 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
225
337
|
if (!readyWarned.has(x.id + ready)) { readyWarned.add(x.id + ready); out.push({ level: 'error', rule: 'integrity', msg: `anchor extractor '${x.id}' cannot run: ${ready}` }) }
|
|
226
338
|
continue
|
|
227
339
|
}
|
|
228
|
-
if (!
|
|
229
|
-
if (
|
|
340
|
+
if (!existsAtTip(path)) continue // the missing FILE already errored above
|
|
341
|
+
if (isDirectoryAtTip(path)) {
|
|
230
342
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `'${s.id}' puts a selector on a directory (${relation}: ${path}#${selectors[0]}) — a selector scopes ONE real file` })
|
|
231
343
|
continue
|
|
232
344
|
}
|
|
233
345
|
let units
|
|
234
|
-
try {
|
|
346
|
+
try {
|
|
347
|
+
const source = textAtTip(path)
|
|
348
|
+
if (source === null) throw new Error(`candidate tree has no file '${path}'`)
|
|
349
|
+
units = x.extract(source, path)
|
|
350
|
+
} catch (e: any) {
|
|
235
351
|
out.push({ level: 'error', rule: 'integrity', spec: s.id, file: path, msg: `anchor ${path}#${selectors.join(', #')} ('${s.id}') is unverifiable — the current file does not parse: ${e?.message ?? e}` })
|
|
236
352
|
continue
|
|
237
353
|
}
|
|
@@ -253,17 +369,26 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
253
369
|
}
|
|
254
370
|
if (!live.length) continue
|
|
255
371
|
const since = rowsFor(hidx, s.path)[0]?.hash || ''
|
|
256
|
-
const win =
|
|
372
|
+
const win = windowEvents(didx, since, path, s.id)
|
|
257
373
|
if (!win.length) continue
|
|
258
|
-
const hits = await anchorHitCommits(root, win,
|
|
374
|
+
const hits = await anchorHitCommits(root, win, live, regs)
|
|
259
375
|
if (!hits.length) continue
|
|
260
376
|
const hitSyms = [...new Set(hits.flatMap((h) => h.selectors))]
|
|
261
377
|
const shas = hits.map((h) => h.commit.slice(0, 8)).join(', ')
|
|
262
378
|
const unparseable = hits.filter((h) => h.unparseable)
|
|
263
379
|
const parseNote = unparseable.length ? ` (${unparseable.length} of these could not be parsed at that commit — counted as hits conservatively)` : ''
|
|
264
|
-
if (relation === 'code')
|
|
265
|
-
|
|
266
|
-
|
|
380
|
+
if (relation === 'code') {
|
|
381
|
+
const current = pending && hits.some((hit) => hit.commit === tip)
|
|
382
|
+
const older = pending && hits.some((hit) => hit.commit !== tip)
|
|
383
|
+
const remedy = !pending
|
|
384
|
+
? `update the spec, or 'spex spec ack ${s.id} --reason "…"' if the contract still holds`
|
|
385
|
+
: current && older
|
|
386
|
+
? `update the spec in this commit; its own hit can be declared by retrying with 'git commit --trailer "Spec-OK: ${s.id}" …', but the listed older debt must be cleared first (an in-commit declaration never pardons ancestors)`
|
|
387
|
+
: current
|
|
388
|
+
? `update the spec in this commit, or retry with 'git commit --trailer "Spec-OK: ${s.id}" …'; a later empty ack cannot pre-author this candidate`
|
|
389
|
+
: `update the spec in this commit, or clear this older debt with 'spex spec ack ${s.id} --reason "…"' before retrying the candidate`
|
|
390
|
+
out.push({ level: 'error', rule: 'anchor-drift', spec: s.id, file: path, msg: `${path}#${hitSyms.join(', #')} was changed by ${hits.length} commit(s) since spec '${s.id}' v${s.version} [${shas}]${parseNote} — the anchored contract's code moved: ${remedy}` })
|
|
391
|
+
} else
|
|
267
392
|
out.push({ level: 'warn', rule: 'related-drift', spec: s.id, file: path, msg: `related ${path}#${hitSyms.join(', #')} ('${s.id}') was changed by ${hits.length} commit(s) since v${s.version} [${shas}]${parseNote} — a scoped dependency shifted, worth a glance (SOFT: never blocks, no ack, no eval staleness)` })
|
|
268
393
|
}
|
|
269
394
|
}
|
|
@@ -178,11 +178,15 @@ export function loadOne(id: string): Issue {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
// a filesystem-safe, readable, collision-free id from the concern (slug + numeric suffix if taken).
|
|
181
|
+
// RESERVED_IDS are the words an issue address gives its own meaning — `new` is the compose PAGE
|
|
182
|
+
// ([[issues-view]]), so an issue owning that id would be unreachable at its own detail address. The slug
|
|
183
|
+
// steps around them with the same numeric suffix a taken id gets: a collision, not a silent shadow.
|
|
184
|
+
const RESERVED_IDS = new Set(['new'])
|
|
181
185
|
function uniqueId(concern: string): string {
|
|
182
186
|
const base = concern.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'issue'
|
|
183
187
|
const dir = localStoreDir()
|
|
184
188
|
let id = base
|
|
185
|
-
for (let n = 2; existsSync(join(dir, `${id}.md`)); n++) id = `${base}-${n}`
|
|
189
|
+
for (let n = 2; RESERVED_IDS.has(id) || existsSync(join(dir, `${id}.md`)); n++) id = `${base}-${n}`
|
|
186
190
|
return id
|
|
187
191
|
}
|
|
188
192
|
|