spexcode 0.5.3 → 0.5.5
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/package.json +1 -1
- package/spec-cli/hooks/dispatch.sh +10 -0
- package/spec-cli/src/cli.ts +10 -9
- package/spec-cli/src/contract-filter.ts +73 -29
- package/spec-cli/src/guide.ts +2 -1
- package/spec-cli/src/harness.ts +426 -120
- package/spec-cli/src/host-resources.ts +20 -28
- package/spec-cli/src/index.ts +2 -1
- package/spec-cli/src/init.ts +26 -7
- package/spec-cli/src/layout.ts +3 -11
- package/spec-cli/src/materialize.ts +118 -49
- package/spec-cli/src/process-identity.ts +144 -19
- package/spec-cli/src/runtime-ownership.ts +5 -16
- package/spec-cli/src/sessions.ts +123 -17
- package/spec-cli/templates/hooks/pre-commit +3 -9
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
|
@@ -7,7 +7,7 @@ import { defaultHarness, HARNESSES, harnessById, sessionIdentityEnvVars, type Ha
|
|
|
7
7
|
import { listSessionIds, readConfig, readJsonConfig, readPublicRecordEntry, readRawRecord, runtimeRoot, type PublicRecordEntry, type RawRecord } from './layout.js'
|
|
8
8
|
import { repoRoot } from './git.js'
|
|
9
9
|
import { endpointRecordPath } from './host.js'
|
|
10
|
-
import { parseProcStat, processStartToken,
|
|
10
|
+
import { detachedRuntimeGenerationToken, parseProcStat, processStartToken, verifyDetachedRuntime, type ProcessIdentity } from './process-identity.js'
|
|
11
11
|
import { readBackendInstanceRecords, type BackendInstanceRecord } from './runtime-ownership.js'
|
|
12
12
|
|
|
13
13
|
type Proc = ProcessIdentity & {
|
|
@@ -384,6 +384,7 @@ const sessionStopBlocker = async (
|
|
|
384
384
|
harnessId: string | null,
|
|
385
385
|
recs = rawRecords(),
|
|
386
386
|
knownProbes?: Map<string, SharedRuntimeProbe>,
|
|
387
|
+
opts: { coldReceipt?: unknown } = {},
|
|
387
388
|
): Promise<string | null> => {
|
|
388
389
|
const allowed = harnessId
|
|
389
390
|
? new Set((harnessById(harnessId).sharedRuntimes?.(runtimeRoot()) ?? []).map((descriptor) => descriptor.key))
|
|
@@ -392,7 +393,6 @@ const sessionStopBlocker = async (
|
|
|
392
393
|
if (allowed && !allowed.has(key)) continue
|
|
393
394
|
const descriptor = entry.descriptor
|
|
394
395
|
const pid = runtimePid(descriptor.pidFile)
|
|
395
|
-
const startToken = pid ? processStartToken(pid) : null
|
|
396
396
|
const ownerCounts = new Map<string, number>()
|
|
397
397
|
for (const rec of entry.recs) if (rec.harness_session_id) ownerCounts.set(rec.harness_session_id, (ownerCounts.get(rec.harness_session_id) ?? 0) + 1)
|
|
398
398
|
const targetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id
|
|
@@ -401,24 +401,16 @@ const sessionStopBlocker = async (
|
|
|
401
401
|
if (!knownProbes && descriptor.mutationGuard) {
|
|
402
402
|
if (!targetThread) return `${descriptor.label} target has no exact governed thread identity`
|
|
403
403
|
if (!pid) return `${descriptor.label} target-scoped mutation guard has no readable owner PID`
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
try { stampBefore = readFileSync(descriptor.isolationFile, 'utf8').trim() } catch { /* legacy unsafe runtime */ }
|
|
408
|
-
if (!topologyBefore || topologyBefore.startToken !== startToken || topologyBefore.processGroupId !== pid || topologyBefore.sessionId !== pid ||
|
|
409
|
-
stampBefore !== `detached-v3 ${pid} ${startToken} ${pid} ${pid}`)
|
|
410
|
-
return `${descriptor.label} PID ${pid}@${startToken} has no matching live detached process-boundary record`
|
|
404
|
+
const identityBefore = verifyDetachedRuntime(pid, descriptor.receiptFile)
|
|
405
|
+
if (!identityBefore.ok)
|
|
406
|
+
return `${descriptor.label} PID ${pid} has no matching live detached process-boundary record: ${identityBefore.reason}`
|
|
411
407
|
let guard
|
|
412
|
-
try { guard = await descriptor.mutationGuard(targetThread) }
|
|
408
|
+
try { guard = await descriptor.mutationGuard(targetThread, opts) }
|
|
413
409
|
catch (error) { return `${descriptor.label} target-scoped mutation guard failed: ${(error as Error).message}` }
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
if (startAfter !== startToken || !topologyAfter || topologyAfter.startToken !== startToken ||
|
|
419
|
-
topologyAfter.processGroupId !== pid || topologyAfter.sessionId !== pid || stampAfter !== stampBefore)
|
|
420
|
-
return `${descriptor.label} PID/start/isolation identity changed during target-scoped mutation guard`
|
|
421
|
-
if (guard.descendantIds.length)
|
|
410
|
+
const identityAfter = verifyDetachedRuntime(pid, descriptor.receiptFile)
|
|
411
|
+
if (!identityAfter.ok || detachedRuntimeGenerationToken(identityAfter.identity) !== detachedRuntimeGenerationToken(identityBefore.identity))
|
|
412
|
+
return `${descriptor.label} PID/start/detached-receipt identity changed during target-scoped mutation guard${identityAfter.ok ? '' : `: ${identityAfter.reason}`}`
|
|
413
|
+
if (guard.descendantIds.length && guard.coldTeardownAuthorized !== true)
|
|
422
414
|
return `${descriptor.label} target thread ${targetThread} has owned descendants (${guard.descendantIds.join(', ')})`
|
|
423
415
|
if (!guard.healthy)
|
|
424
416
|
return `${descriptor.label} target thread ${targetThread} is unknown: ${guard.error || 'target-scoped mutation guard failed'}`
|
|
@@ -433,17 +425,13 @@ const sessionStopBlocker = async (
|
|
|
433
425
|
? `${siblings.length} live sibling thread(s)`
|
|
434
426
|
: probe.healthy ? `${probe.references.length} live thread reference(s)` : 'an unproven live reference set'
|
|
435
427
|
if (!pid) return `${descriptor.label} has ${liveReason} but no readable owner PID`
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
let stamp = ''
|
|
439
|
-
try { stamp = readFileSync(descriptor.isolationFile, 'utf8').trim() } catch { /* legacy unsafe runtime */ }
|
|
440
|
-
if (!topology || topology.startToken !== startToken || topology.processGroupId !== pid || topology.sessionId !== pid ||
|
|
441
|
-
stamp !== `detached-v3 ${pid} ${startToken} ${pid} ${pid}`) {
|
|
428
|
+
const identity = verifyDetachedRuntime(pid, descriptor.receiptFile)
|
|
429
|
+
if (!identity.ok) {
|
|
442
430
|
const refs = probe.references.map((reference) => reference.referenceId).join(', ') || 'no loaded threads'
|
|
443
|
-
return `${descriptor.label} PID ${pid}
|
|
431
|
+
return `${descriptor.label} PID ${pid} serves ${refs} and has no matching live detached process-boundary record: ${identity.reason}`
|
|
444
432
|
}
|
|
445
433
|
if (!probe.healthy)
|
|
446
|
-
return `${descriptor.label} PID ${pid}@${startToken} has an unproven live reference set: ${probe.error || 'unknown probe failure'}`
|
|
434
|
+
return `${descriptor.label} PID ${pid}@${identity.identity.startToken} has an unproven live reference set: ${probe.error || 'unknown probe failure'}`
|
|
447
435
|
}
|
|
448
436
|
return null
|
|
449
437
|
}
|
|
@@ -658,9 +646,13 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
|
|
|
658
646
|
return report
|
|
659
647
|
}
|
|
660
648
|
|
|
661
|
-
export async function assertSessionStopSafe(
|
|
649
|
+
export async function assertSessionStopSafe(
|
|
650
|
+
id: string,
|
|
651
|
+
rec: (HarnessLivenessRecord & { harness?: string }) | null,
|
|
652
|
+
opts: { coldReceipt?: unknown } = {},
|
|
653
|
+
): Promise<void> {
|
|
662
654
|
if (!rec) throw new ResourceConflict(`refusing to stop ${id}: no readable session record proves the adapter or leaf owner`)
|
|
663
|
-
const blocker = await sessionStopBlocker(id, rec.harness || null)
|
|
655
|
+
const blocker = await sessionStopBlocker(id, rec.harness || null, rawRecords(), undefined, opts)
|
|
664
656
|
if (blocker) throw new ResourceConflict(`refusing to stop ${id}: ${blocker}`)
|
|
665
657
|
}
|
|
666
658
|
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { resolveLayout, mainBranch } from './layout.js'
|
|
|
15
15
|
import { getBoardJson } from './graphCache.js'
|
|
16
16
|
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
17
17
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
18
|
-
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
18
|
+
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
19
19
|
import { superviseTimeline, readTimeline } from './session-timeline.js'
|
|
20
20
|
import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
|
|
21
21
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
@@ -684,6 +684,7 @@ installConnectionReaper(server as unknown as HttpServer)
|
|
|
684
684
|
injectWebSocket(server)
|
|
685
685
|
superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
|
|
686
686
|
superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
|
|
687
|
+
superviseTurnFailures() // reconcile adapter-owned native failure subscriptions across backend replacement
|
|
687
688
|
superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
|
|
688
689
|
console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
|
|
689
690
|
|
package/spec-cli/src/init.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, copyFileSync, readFileSync, readdirSync, renameSync, rmSync, statSync, chmodSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
-
import { join, resolve, relative } from 'node:path'
|
|
3
|
+
import { join, resolve, relative, dirname } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { execFileSync } from 'node:child_process'
|
|
6
6
|
import { readConfig, readJsonConfig } from './layout.js'
|
|
@@ -81,6 +81,20 @@ function resolveHooksDir(dir: string): string | null {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// Detect once, at adoption, while the root checkout still names the branch being adopted. Steady-state
|
|
85
|
+
// layout never re-derives trunk identity from a mutable checkout (an ordinary `git switch node/x` must not
|
|
86
|
+
// turn that feature branch into trunk).
|
|
87
|
+
function adoptionMainBranch(dir: string): string {
|
|
88
|
+
try {
|
|
89
|
+
const common = execFileSync('git', ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' }).trim()
|
|
90
|
+
const branch = execFileSync('git', ['-C', dirname(common), 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
|
|
91
|
+
if (branch) return branch
|
|
92
|
+
} catch { /* render the one product-level repair below */ }
|
|
93
|
+
const error = new Error('cannot determine the source-of-truth branch at adoption — check out the trunk in the root checkout, or set "mainBranch" in spexcode.json before re-running `spex init`')
|
|
94
|
+
error.name = 'ConfigError'
|
|
95
|
+
throw error
|
|
96
|
+
}
|
|
97
|
+
|
|
84
98
|
export async function specInit(targetArg: string | undefined, presetArg?: string, harnessArg?: string): Promise<void> {
|
|
85
99
|
const targetDir = resolve(targetArg ?? process.cwd())
|
|
86
100
|
|
|
@@ -153,18 +167,23 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
153
167
|
const cfgDest = join(targetDir, 'spexcode.json')
|
|
154
168
|
const nativeChosen = (chosenHarnesses as unknown[]).filter((m): m is string => typeof m === 'string')
|
|
155
169
|
if (existsSync(cfgDest)) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
170
|
+
const cfg = (readJsonConfig(cfgDest) ?? {}) as Record<string, unknown>
|
|
171
|
+
const stampedBranch = typeof cfg.mainBranch !== 'string' || !cfg.mainBranch.trim()
|
|
172
|
+
if (stampedBranch) cfg.mainBranch = adoptionMainBranch(targetDir)
|
|
173
|
+
if (flagRaw) cfg.harnesses = flagRaw
|
|
174
|
+
if (flagRaw || stampedBranch) {
|
|
160
175
|
writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
|
|
161
|
-
console.log(`✓ stamped
|
|
176
|
+
console.log(`✓ stamped ${[
|
|
177
|
+
flagRaw ? `"harnesses": ${JSON.stringify(flagRaw)}` : '',
|
|
178
|
+
stampedBranch ? `"mainBranch": ${JSON.stringify(cfg.mainBranch)}` : '',
|
|
179
|
+
].filter(Boolean).join(' and ')} into the existing spexcode.json (other fields untouched)`)
|
|
162
180
|
} else {
|
|
163
181
|
console.warn(`• spexcode.json already exists at ${cfgDest} — left untouched (harnesses: ${JSON.stringify(chosenHarnesses)}).`)
|
|
164
182
|
}
|
|
165
183
|
} else {
|
|
166
184
|
const cfg = (readJsonConfig(join(TEMPLATES, 'spexcode.json')) ?? {}) as Record<string, any>
|
|
167
185
|
cfg.harnesses = chosenHarnesses
|
|
186
|
+
cfg.mainBranch = adoptionMainBranch(targetDir)
|
|
168
187
|
if (nativeChosen.length && cfg.sessions?.launchers) {
|
|
169
188
|
cfg.sessions.launchers = Object.fromEntries(
|
|
170
189
|
Object.entries(cfg.sessions.launchers as Record<string, { harness?: string }>).filter(([, l]) => nativeChosen.includes(l.harness ?? 'claude')))
|
|
@@ -173,7 +192,7 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
173
192
|
}
|
|
174
193
|
writeFileSync(cfgDest, JSON.stringify(cfg, null, 2) + '\n')
|
|
175
194
|
const roots = JSON.stringify(readJsonConfig(cfgDest)?.lint?.governedRoots ?? null)
|
|
176
|
-
console.log(`✓ planted spexcode.json — harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
|
|
195
|
+
console.log(`✓ planted spexcode.json — mainBranch ${JSON.stringify(cfg.mainBranch)}, harnesses ${JSON.stringify(chosenHarnesses)}, launchers ${JSON.stringify(Object.keys(cfg.sessions?.launchers ?? {}))}; lint.governedRoots starts as ${roots} (the whole git-tracked tree, tests excluded)`)
|
|
177
196
|
}
|
|
178
197
|
|
|
179
198
|
// 2. install the git hooks. Unknown existing hooks are user-owned and stay byte-identical. An exact
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -9,7 +9,7 @@ export { encodeProject, spexcodeHome } from './project-store.js'
|
|
|
9
9
|
|
|
10
10
|
export type Config = {
|
|
11
11
|
main?: string // path to the source-of-truth checkout (default: the `main` worktree)
|
|
12
|
-
mainBranch?: string // source-of-truth
|
|
12
|
+
mainBranch?: string // stable source-of-truth branch stamped by init (default: "main")
|
|
13
13
|
branchPrefix?: string // how a branch names its node (default: "node/")
|
|
14
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]])
|
|
15
15
|
// RETIRED ([[residence]]) — the old three-word footprint vote. Materialized artifacts carry no facts and are never
|
|
@@ -115,16 +115,8 @@ export function gitCommonDir(): string {
|
|
|
115
115
|
|
|
116
116
|
export function mainBranch(): string {
|
|
117
117
|
let checkout: string
|
|
118
|
-
try {
|
|
119
|
-
|
|
120
|
-
} catch { return 'main' }
|
|
121
|
-
const override = readConfig(checkout).mainBranch?.trim()
|
|
122
|
-
if (override) return override
|
|
123
|
-
try {
|
|
124
|
-
const cur = git(['-C', checkout, 'symbolic-ref', '--short', 'HEAD']).trim()
|
|
125
|
-
if (cur) return cur
|
|
126
|
-
} catch { /* detached/bare checkout: use the documented conventional default */ }
|
|
127
|
-
return 'main'
|
|
118
|
+
try { checkout = mainCheckout() } catch { return 'main' }
|
|
119
|
+
return readConfig(checkout).mainBranch?.trim() || 'main'
|
|
128
120
|
}
|
|
129
121
|
|
|
130
122
|
// the MAIN checkout (the root working tree) for a project — the SAME answer from main OR any linked worktree
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, rmSync, rmdirSync } from 'node:fs'
|
|
1
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, renameSync, rmSync, rmdirSync } from 'node:fs'
|
|
2
2
|
import { join, dirname, relative } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import { execFileSync } from 'node:child_process'
|
|
@@ -6,10 +6,10 @@ import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from '
|
|
|
6
6
|
import { compileManifest } from './hooks.js'
|
|
7
7
|
import { writeManagedBlock, removeManagedBlock, HARNESSES, type HarnessArtifacts } from './harness.js'
|
|
8
8
|
import { git } from './git.js'
|
|
9
|
-
import { runtimeRoot, treeSlotDir, mainCheckout, readConfig } from './layout.js'
|
|
9
|
+
import { runtimeRoot, treeSlotDir, mainCheckout, readConfig, encodeProject } from './layout.js'
|
|
10
10
|
import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
|
|
11
11
|
import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
|
|
12
|
-
import { plantContractFilter, removeContractFilter, settleIndexStat } from './contract-filter.js'
|
|
12
|
+
import { plantContractFilter, removeContractFilter, retireLegacyContractBlock, settleIndexStat, type ContractFilterBinding, type ContractFilterPayload } from './contract-filter.js'
|
|
13
13
|
|
|
14
14
|
export type MaterializedArtifact = {
|
|
15
15
|
kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust'
|
|
@@ -34,8 +34,9 @@ export type MaterializeResult = { contentHash: string; planted: MaterializedArti
|
|
|
34
34
|
// implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
|
|
35
35
|
// unconditionally by its IDENTITY STAMP (sentinel blocks, the shim's dispatch.sh command line, the generated
|
|
36
36
|
// mark on skills/agents, the filter config namespace, the skip-worktree bit), then rewritten per the current
|
|
37
|
-
// policy (possibly to nothing).
|
|
38
|
-
//
|
|
37
|
+
// policy (possibly to nothing). There are no policy-pair branches. The one cross-tree migration receipt below
|
|
38
|
+
// preserves old common ignore entries until every registered tree owns its local projection; it never reads or
|
|
39
|
+
// reconstructs a sibling policy.
|
|
39
40
|
|
|
40
41
|
const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
|
|
41
42
|
const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
|
|
@@ -64,8 +65,8 @@ export function contentHash(proj: string): string {
|
|
|
64
65
|
// @@@ footprint kinds ([[residence]]) - the vote axis is RETIRED: materialized artifacts carry no facts, so
|
|
65
66
|
// they are NEVER tracked — there is exactly ONE residence behavior, not three. `.spec` + `spexcode.json` are ALWAYS
|
|
66
67
|
// tracked (git is the database — no knob can untrack them); machine facts (shims, spexcode.local.json),
|
|
67
|
-
// run residue (.worktrees/)
|
|
68
|
-
//
|
|
68
|
+
// run residue (.worktrees/) stays in the common exclude; tree-selected artifacts are hidden by a managed
|
|
69
|
+
// working .gitignore block whose tracked bytes stay pristine through the content filter. A contract file the host TRACKS — or one the user has begun
|
|
69
70
|
// writing THEIR OWN prose into — is covered by the clean/smudge content filter ([[content-filter]]). An
|
|
70
71
|
// environment without the generator (a teammate's clone, CI, a cloud agent) runs `spex materialize` in its
|
|
71
72
|
// setup step — there is no committed-artifact delivery mode.
|
|
@@ -74,7 +75,7 @@ export function retiredAxisNotice(cfg: { render?: string; private?: boolean }):
|
|
|
74
75
|
const field = cfg.render?.trim() ? `"render": "${cfg.render.trim()}"` : '"private": true'
|
|
75
76
|
console.error(
|
|
76
77
|
`spexcode: the render vote is retired — ${field} is ignored. Materialized artifacts are never tracked:\n` +
|
|
77
|
-
` ignore rules live in
|
|
78
|
+
` tree-local ignore rules live in a filtered working .gitignore, and a host-tracked contract is covered by the\n` +
|
|
78
79
|
` clean/smudge filter, and a clone without spex runs \`spex materialize\` in its setup step. Remove the\n` +
|
|
79
80
|
` field from spexcode.json / spexcode.local.json to retire this notice (see \`spex guide footprint\`).`,
|
|
80
81
|
)
|
|
@@ -90,6 +91,38 @@ function isTracked(proj: string, file: string): boolean {
|
|
|
90
91
|
try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
function registeredTrees(proj: string): string[] {
|
|
95
|
+
const rows = git(['-C', mainCheckout(proj), 'worktree', 'list', '--porcelain', '-z']).split('\0')
|
|
96
|
+
return rows.filter((row) => row.startsWith('worktree ')).map((row) => row.slice('worktree '.length))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const TREE_IGNORE_RECEIPT = 'tree-ignore-v1'
|
|
100
|
+
|
|
101
|
+
function hasLegacyTreeIgnore(proj: string): boolean {
|
|
102
|
+
return registeredTrees(proj).some((tree) => {
|
|
103
|
+
const slot = join(runtimeRoot(proj), 'trees', encodeProject(tree))
|
|
104
|
+
return existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, TREE_IGNORE_RECEIPT))
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function managedExcludeEntries(file: string): string[] {
|
|
109
|
+
if (!existsSync(file)) return []
|
|
110
|
+
const lines = readFileSync(file, 'utf8').split('\n')
|
|
111
|
+
const start = lines.indexOf('# spexcode:start')
|
|
112
|
+
const end = lines.indexOf('# spexcode:end', start + 1)
|
|
113
|
+
return start >= 0 && end > start ? lines.slice(start + 1, end).filter(Boolean) : []
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function selectionBody(selected: typeof HARNESSES, plugin = false): string {
|
|
117
|
+
return [...new Set([...selected.map((h) => h.dispatchId), ...(plugin ? ['plugin'] : [])])].sort().join('\n') + '\n'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function publishSelection(path: string, body: string): void {
|
|
121
|
+
const prepared = `${path}.${process.pid}.tmp`
|
|
122
|
+
writeFileSync(prepared, body)
|
|
123
|
+
renameSync(prepared, path)
|
|
124
|
+
}
|
|
125
|
+
|
|
93
126
|
// @@@ contract kind detection ([[residence]]) - a contract file's residence is a LIVE CONTENT FACT, not
|
|
94
127
|
// an install-time choice, re-judged on every materialize: TRACKED → filter domain; untracked + wholly ours
|
|
95
128
|
// (nothing left after stripping our sentinel block) → exclude domain; untracked + HOST CONTENT present (the
|
|
@@ -144,11 +177,11 @@ function sweepGeneratedAgents(dir: string | null): void {
|
|
|
144
177
|
// (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
|
|
145
178
|
// `arts` (live skill/agent node names) widens the sweep to pre-stamp legacy files; the GENERATED_MARK sweep
|
|
146
179
|
// covers everything materialized since, including products of renamed/deleted nodes.
|
|
147
|
-
|
|
180
|
+
function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
|
|
148
181
|
for (const h of HARNESSES) {
|
|
149
182
|
// h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
|
|
150
183
|
// stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
|
|
151
|
-
h.clean(proj, arts)
|
|
184
|
+
h.clean(proj, arts, preserveProject)
|
|
152
185
|
for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f) // legacy private-overlay bit — erase-only
|
|
153
186
|
sweepGeneratedSkills(h.skillDir(proj))
|
|
154
187
|
sweepGeneratedAgents(h.agentDir(proj))
|
|
@@ -156,8 +189,7 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
|
|
|
156
189
|
// same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
|
|
157
190
|
// generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
|
|
158
191
|
removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
|
|
159
|
-
|
|
160
|
-
removeContractFilter(proj) // AFTER the blocks left the working files
|
|
192
|
+
removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
|
|
161
193
|
// the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
|
|
162
194
|
// and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
|
|
163
195
|
// unstaged edit is never staged ([[content-filter]] edge 2).
|
|
@@ -179,6 +211,22 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
|
|
|
179
211
|
}
|
|
180
212
|
}
|
|
181
213
|
|
|
214
|
+
export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
|
|
215
|
+
const trees = registeredTrees(proj)
|
|
216
|
+
const current = git(['-C', proj, 'rev-parse', '--show-toplevel']).trim()
|
|
217
|
+
for (const tree of trees) {
|
|
218
|
+
if (!existsSync(tree)) throw new Error(`cannot dematerialize project while registered worktree ${tree} is inaccessible — repair or remove/prune it first`)
|
|
219
|
+
git(['-C', tree, 'rev-parse', '--show-toplevel'])
|
|
220
|
+
}
|
|
221
|
+
for (const tree of trees) {
|
|
222
|
+
// Only the caller's live spec may widen the legacy name sweep. Siblings are identity-stamp-only: the
|
|
223
|
+
// same name there may be user-owned or may not exist in this tree's divergent spec at all.
|
|
224
|
+
eraseTree(tree, tree === current ? arts : { skills: [], agents: [] }, false)
|
|
225
|
+
}
|
|
226
|
+
try { removeManagedBlock(infoExcludePath(proj), ['# ', ''], false) } catch { /* not a git repo */ }
|
|
227
|
+
removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')], true)
|
|
228
|
+
}
|
|
229
|
+
|
|
182
230
|
// the whole pay-per-change materialize. proj defaults to cwd. Its receipt is populated at each successful
|
|
183
231
|
// write so callers report the actual selected footprint instead of maintaining a second artifact inventory.
|
|
184
232
|
export function materialize(proj = process.cwd()): MaterializeResult {
|
|
@@ -198,9 +246,9 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
198
246
|
// own hand-written prose is not folded in — repo-local notes belong in the harness file's own
|
|
199
247
|
// block-outside region (untracked, per-clone), and anything that must reach EVERY agent is a plugin node.
|
|
200
248
|
const contract = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean).join('\n\n')
|
|
201
|
-
// WHICH harnesses to deliver into ([[harness-select]]):
|
|
202
|
-
//
|
|
203
|
-
const cfg = readConfig(
|
|
249
|
+
// WHICH harnesses to deliver into ([[harness-select]]): this tree's explicit spexcode.json `harnesses` set.
|
|
250
|
+
// resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
|
|
251
|
+
const cfg = readConfig(proj)
|
|
204
252
|
const targets = resolveHarnessTargets(cfg.harnesses)
|
|
205
253
|
retiredAxisNotice(cfg) // [[residence]] — the vote axis is retired
|
|
206
254
|
const { selected, plugins } = partitionHarnesses(targets)
|
|
@@ -212,7 +260,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
212
260
|
// ---- ERASE (the forgetting law): every landing point cleared by identity stamp, whatever policy — or
|
|
213
261
|
// legacy mode — wrote it last. Unselected harnesses need no separate prune branch: the erase already
|
|
214
262
|
// forgot them, and only the selected ones are asserted below.
|
|
215
|
-
|
|
263
|
+
eraseTree(proj, arts, true)
|
|
216
264
|
|
|
217
265
|
// ---- ASSERT: rewrite each landing point per the CURRENT policy.
|
|
218
266
|
// a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
|
|
@@ -236,18 +284,29 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
236
284
|
const contractPaths: string[] = []
|
|
237
285
|
for (const h of selected) {
|
|
238
286
|
if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f); record('contract', f) }
|
|
239
|
-
const shimFile = h.shimFile(proj)
|
|
240
|
-
mkdirSync(dirname(shimFile), { recursive: true })
|
|
241
287
|
const shim = h.shim(DISPATCH, SPEX)
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
288
|
+
if (h.shimScope === 'tree') {
|
|
289
|
+
const shimFile = h.shimFile(proj)
|
|
290
|
+
mkdirSync(dirname(shimFile), { recursive: true })
|
|
291
|
+
writeFileSync(shimFile, shim.content)
|
|
292
|
+
record('shim', shimFile)
|
|
293
|
+
machinePaths.push(shimFile)
|
|
294
|
+
}
|
|
246
295
|
// a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
|
|
247
296
|
// checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
|
|
248
297
|
const anchor = h.worktreeHookAnchor(proj)
|
|
249
298
|
if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.content); machinePaths.push(anchor); record('shim', anchor) }
|
|
250
299
|
}
|
|
300
|
+
const selectedByDispatch = new Map(selected.map((h) => [h.dispatchId, h]))
|
|
301
|
+
for (const h of selectedByDispatch.values()) {
|
|
302
|
+
const shim = h.shim(DISPATCH, SPEX)
|
|
303
|
+
if (h.shimScope === 'project') {
|
|
304
|
+
const file = h.shimFile(proj)
|
|
305
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, shim.content)
|
|
306
|
+
record('shim', file)
|
|
307
|
+
}
|
|
308
|
+
for (const file of h.writeTrust(proj, shim.cmd)) record('trust', file)
|
|
309
|
+
}
|
|
251
310
|
// (6) skills + (7) sub-agents — each surface node → the file the harness auto-discovers, one per selected
|
|
252
311
|
// harness that has the primitive (skillDir/agentDir null skips — the divergence is the adapter's line).
|
|
253
312
|
for (const sk of skillNodes) {
|
|
@@ -299,44 +358,54 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
299
358
|
}
|
|
300
359
|
}
|
|
301
360
|
writeFileSync(ledger, curFolders.join('\n'))
|
|
302
|
-
// (9)
|
|
303
|
-
//
|
|
304
|
-
// DECLARATION every other git door consults (checkout may overwrite, clean -fd spares, status/add -A/
|
|
305
|
-
// stash stay silent). The host's tracked .gitignore is never touched.
|
|
306
|
-
// Entries must be CHECKOUT-INVARIANT: the exclude lives in the COMMON git dir shared by the main checkout
|
|
307
|
-
// and every worktree, so each entry is anchored to the checkout it LIVES under — proj-relative when inside
|
|
308
|
-
// proj, else MAIN-checkout-relative (the codex shim resolves to `.codex/hooks.json` from any checkout; a
|
|
309
|
-
// pattern naming a main-only path is a harmless no-op in a worktree). A path under neither root is dropped.
|
|
361
|
+
// (9) ignore + mixed text. Only checkout-invariant residue and project-shared shims belong in the COMMON
|
|
362
|
+
// info/exclude; selection-dependent paths live in this tree's filtered working .gitignore.
|
|
310
363
|
const mc = mainCheckout(proj)
|
|
311
|
-
const anchor = (abs: string): string | null => {
|
|
312
|
-
const p = relative(proj, abs); if (!p.startsWith('..')) return p
|
|
313
|
-
const m = relative(mc, abs); if (!m.startsWith('..')) return m
|
|
314
|
-
return null
|
|
315
|
-
}
|
|
316
|
-
// machine facts + run residue, ignored under EVERY policy: the shims/anchors/bundles (bake this install's
|
|
317
|
-
// abs path), spexcode.local.json (the host overlay — a `git add -A` must never leak it), and the session
|
|
318
|
-
// residue (`.worktrees/` where launches plant worktrees; `.session` is the legacy per-worktree state file
|
|
319
|
-
// an old backend wrote). Static strings stay checkout-invariant.
|
|
320
364
|
const bundlePaths = curFolders.map((f) => pluginBundleDir(proj, f))
|
|
321
|
-
const
|
|
322
|
-
...[...
|
|
365
|
+
const commonEntries = [
|
|
366
|
+
...[...new Set(HARNESSES.filter((h) => h.shimScope === 'project' && existsSync(h.shimFile(proj)) &&
|
|
367
|
+
readFileSync(h.shimFile(proj), 'utf8').includes('dispatch.sh')).map((h) => relative(mc, h.shimFile(proj))))]
|
|
368
|
+
.filter((p) => !p.startsWith('..')),
|
|
323
369
|
'spexcode.local.json', '.worktrees/', '.session',
|
|
324
370
|
]
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
371
|
+
const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
|
|
372
|
+
const priorCommonEntries = managedExcludeEntries(infoExcludePath(proj))
|
|
373
|
+
|
|
374
|
+
// Contract residence stays a live fact. Selection-dependent untracked products are ignored by this tree's
|
|
375
|
+
// working .gitignore, whose own managed block is filtered when the host tracks/owns that file.
|
|
328
376
|
const filterContracts: string[] = []
|
|
329
377
|
const oursContracts: string[] = []
|
|
330
378
|
for (const f of contractPaths) {
|
|
331
379
|
if (isTracked(proj, f) || hostContentOf(f).trim()) filterContracts.push(f)
|
|
332
380
|
else oursContracts.push(f)
|
|
333
381
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
382
|
+
const localEntries = [...machinePaths, ...bundlePaths, ...artifactPaths, ...oursContracts]
|
|
383
|
+
.map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
|
|
384
|
+
const ignoreFile = join(proj, '.gitignore')
|
|
385
|
+
const ignoreTracked = isTracked(proj, ignoreFile)
|
|
386
|
+
const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
|
|
387
|
+
if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
|
|
388
|
+
const ignoreBody = entries(localEntries)
|
|
389
|
+
writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])
|
|
390
|
+
|
|
391
|
+
const payloads: ContractFilterPayload[] = filterContracts.map((file) => ({ file: relative(proj, file), content: contract }))
|
|
392
|
+
if (ignoreTracked || ignoreHost.trim()) payloads.push({ file: '.gitignore', content: ignoreBody })
|
|
393
|
+
const bindings: ContractFilterBinding[] = [
|
|
394
|
+
...[...new Set(HARNESSES.flatMap((h) => h.contractFiles(proj).map((file) => relative(proj, file))))]
|
|
395
|
+
.map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->', legacy: true })),
|
|
396
|
+
{ file: '.gitignore', start: '# spexcode:start', end: '# spexcode:end' },
|
|
397
|
+
]
|
|
398
|
+
if (payloads.length) plantContractFilter(proj, payloads, bindings)
|
|
399
|
+
// (5) finish diagnostics/migration, then atomically publish the allowlist LAST. Dispatch consumes only that
|
|
400
|
+
// final receipt; a killed writer leaves the preceding successful selection intact.
|
|
339
401
|
const h = contentHash(proj)
|
|
340
402
|
writeFileSync(join(rt, 'content-hash'), h)
|
|
403
|
+
writeFileSync(join(rt, 'contract-filter-v2'), '')
|
|
404
|
+
writeFileSync(join(rt, TREE_IGNORE_RECEIPT), '')
|
|
405
|
+
writeFileSync(join(runtimeRoot(proj), 'harness-selection-v1'), '')
|
|
406
|
+
retireLegacyContractBlock(proj)
|
|
407
|
+
const legacyEntries = hasLegacyTreeIgnore(proj) ? priorCommonEntries : []
|
|
408
|
+
writeManagedBlock(infoExcludePath(proj), entries([...commonEntries, ...legacyEntries]), ['# ', ''])
|
|
409
|
+
publishSelection(join(rt, 'harnesses'), selectionBody(selected, plugins.length > 0))
|
|
341
410
|
return { contentHash: h, planted }
|
|
342
411
|
}
|