spexcode 0.6.0 → 0.6.2
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 +97 -149
- package/package.json +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +27 -5
- package/spec-cli/src/cli.ts +239 -75
- package/spec-cli/src/client.ts +113 -14
- package/spec-cli/src/codex-runtime-generations.ts +97 -7
- package/spec-cli/src/commit-surgery.ts +2 -1
- package/spec-cli/src/contract-filter.ts +58 -42
- package/spec-cli/src/delivery-queue.ts +62 -4
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/execution-trace.ts +444 -0
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/gateway-hub.ts +10 -8
- package/spec-cli/src/gateway.ts +99 -6
- package/spec-cli/src/git.ts +503 -105
- package/spec-cli/src/graph.ts +56 -6
- package/spec-cli/src/graphCache.ts +86 -17
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/guide.ts +93 -4
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +355 -75
- package/spec-cli/src/help.ts +30 -23
- package/spec-cli/src/host-resources.ts +62 -7
- package/spec-cli/src/identity-presets.js +16 -6
- package/spec-cli/src/index.ts +109 -14
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +151 -40
- package/spec-cli/src/lint.ts +37 -7
- package/spec-cli/src/materialize.ts +160 -112
- package/spec-cli/src/plugin-harness.ts +29 -18
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +103 -30
- package/spec-cli/src/session-execution.ts +68 -0
- package/spec-cli/src/session-files.ts +121 -0
- package/spec-cli/src/session-follow.ts +4 -4
- package/spec-cli/src/session-reparent.ts +33 -0
- package/spec-cli/src/session-timeline.ts +163 -14
- package/spec-cli/src/session-web.ts +135 -0
- package/spec-cli/src/sessions.ts +748 -111
- package/spec-cli/src/uninstall.ts +3 -2
- package/spec-cli/templates/hooks/post-checkout +3 -0
- package/spec-cli/templates/hooks/post-merge +1 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
- package/spec-dashboard/dist/assets/{App-b8Nh0sgk.js → App-B6IbFbGu.js} +2 -2
- package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-BG9rNOgV.js} +1 -1
- package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-B3EcgeA3.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-CNZOugWV.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-C2CRHmvs.js} +1 -1
- package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-1nYgZ4CL.js} +1 -1
- package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
- package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
- package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DLVeOHL-.js} +10 -10
- package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-yxCYr64x.js} +29 -29
- package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/reviewFilters.js +5 -0
- package/spec-dashboard/src/session.js +5 -1
- package/spec-eval/src/cli.ts +13 -9
- package/spec-eval/src/evaltab.ts +32 -10
- package/spec-eval/src/freshness.ts +404 -81
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +234 -148
- package/spec-dashboard/dist/assets/Dashboard-CvAjfRC2.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Bz-nMKoS.js +0 -2
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +0 -2
- package/spec-dashboard/dist/assets/SessionInterface-Dl9v0JFM.js +0 -39
- package/spec-dashboard/dist/assets/Settings-BZ1lGRJs.js +0 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-dashboard/dist/assets/data-Bwd3kAVL.js +0 -1
- package/spec-dashboard/dist/assets/index-DAbQBBK_.css +0 -1
- package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
- package/spec-eval/src/matrix.ts +0 -693
|
@@ -5,11 +5,12 @@ import { execFileSync } from 'node:child_process'
|
|
|
5
5
|
import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from './specs.js'
|
|
6
6
|
import { compileManifest } from './hooks.js'
|
|
7
7
|
import { writeManagedBlock, removeManagedBlock, HARNESSES, type HarnessArtifacts } from './harness.js'
|
|
8
|
-
import { git } from './git.js'
|
|
9
|
-
import { runtimeRoot, treeSlotDir, mainCheckout, readConfig
|
|
8
|
+
import { git, gitBinary } from './git.js'
|
|
9
|
+
import { runtimeRoot, treeSlotDir, mainCheckout, readConfig } 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,
|
|
12
|
+
import { clearContractFilterPayload, contractFilterPlanted, plantContractFilter, removeContractFilter, settleIndexStat, type ContractFilterBinding, type ContractFilterPayload } from './contract-filter.js'
|
|
13
|
+
import { writeFileIfChanged } from './file-write.js'
|
|
13
14
|
|
|
14
15
|
export type MaterializedArtifact = {
|
|
15
16
|
kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust'
|
|
@@ -37,7 +38,9 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
|
37
38
|
export function contentHash(proj: string): string {
|
|
38
39
|
try {
|
|
39
40
|
const harnessSh = join(PKG, 'hooks', 'harness.sh')
|
|
40
|
-
|
|
41
|
+
const gitDir = dirname(gitBinary(process.env))
|
|
42
|
+
const env = { ...process.env, PATH: `${gitDir}:${process.env.PATH || ''}` }
|
|
43
|
+
return execFileSync('bash', ['-c', `cd "${proj}" && . "${harnessSh}" && hp_config_hash`], { env }).toString().trim()
|
|
41
44
|
} catch { return '' }
|
|
42
45
|
}
|
|
43
46
|
|
|
@@ -61,93 +64,136 @@ function infoExcludePath(proj: string): string {
|
|
|
61
64
|
function isTracked(proj: string, file: string): boolean {
|
|
62
65
|
try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
|
|
63
66
|
}
|
|
67
|
+
function clearSkipWorktree(proj: string, file: string): void {
|
|
68
|
+
if (!isTracked(proj, file)) return
|
|
69
|
+
try { git(['-C', proj, 'update-index', '--no-skip-worktree', file]) } catch {}
|
|
70
|
+
}
|
|
64
71
|
|
|
65
72
|
function registeredTrees(proj: string): string[] {
|
|
66
73
|
const rows = git(['-C', mainCheckout(proj), 'worktree', 'list', '--porcelain', '-z']).split('\0')
|
|
67
74
|
return rows.filter((row) => row.startsWith('worktree ')).map((row) => row.slice('worktree '.length))
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
const TREE_IGNORE_RECEIPT = 'tree-ignore-v1'
|
|
71
|
-
|
|
72
|
-
function hasLegacyTreeIgnore(proj: string): boolean {
|
|
73
|
-
return registeredTrees(proj).some((tree) => {
|
|
74
|
-
const slot = join(runtimeRoot(proj), 'trees', encodeProject(tree))
|
|
75
|
-
return existsSync(join(slot, 'content-hash')) && !existsSync(join(slot, TREE_IGNORE_RECEIPT))
|
|
76
|
-
})
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function managedExcludeEntries(file: string): string[] {
|
|
80
|
-
if (!existsSync(file)) return []
|
|
81
|
-
const lines = readFileSync(file, 'utf8').split('\n')
|
|
82
|
-
const start = lines.indexOf('# spexcode:start')
|
|
83
|
-
const end = lines.indexOf('# spexcode:end', start + 1)
|
|
84
|
-
return start >= 0 && end > start ? lines.slice(start + 1, end).filter(Boolean) : []
|
|
85
|
-
}
|
|
86
|
-
|
|
87
77
|
function selectionBody(selected: typeof HARNESSES, plugin = false): string {
|
|
88
78
|
return [...new Set([...selected.map((h) => h.dispatchId), ...(plugin ? ['plugin'] : [])])].sort().join('\n') + '\n'
|
|
89
79
|
}
|
|
90
80
|
|
|
91
81
|
function publishSelection(path: string, body: string): void {
|
|
82
|
+
try { if (readFileSync(path, 'utf8') === body) return } catch (error: any) { if (error?.code !== 'ENOENT') throw error }
|
|
92
83
|
const prepared = `${path}.${process.pid}.tmp`
|
|
93
84
|
writeFileSync(prepared, body)
|
|
94
85
|
renameSync(prepared, path)
|
|
95
86
|
}
|
|
96
87
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
88
|
+
function managedBlockPattern(comment: readonly [string, string]): RegExp {
|
|
89
|
+
const [open, close] = comment
|
|
90
|
+
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
91
|
+
return new RegExp(`\\n*${escape(`${open}spexcode:start${close}`)}[\\s\\S]*?${escape(`${open}spexcode:end${close}`)}\\n*`)
|
|
92
|
+
}
|
|
93
|
+
export function stripSpexcodeBlock(text: string, comment: readonly [string, string] = ['<!-- ', ' -->']): string {
|
|
94
|
+
const sentinel = managedBlockPattern(comment)
|
|
95
|
+
const m = sentinel.exec(text)
|
|
100
96
|
if (!m) return text
|
|
101
97
|
// mirror removeManagedBlock exactly: our block + its surrounding blanks collapse to one '\n', and only a
|
|
102
98
|
// block sitting at the TOP of the file drops the leading newline (a host file beginning with its own
|
|
103
99
|
// blank lines keeps them — clean(smudge(x)) == x).
|
|
104
|
-
const replaced = text.replace(
|
|
100
|
+
const replaced = text.replace(sentinel, '\n')
|
|
105
101
|
return m.index === 0 ? replaced.replace(/^\n+/, '') : replaced
|
|
106
102
|
}
|
|
107
103
|
function hostContentOf(file: string): string {
|
|
108
104
|
if (!existsSync(file)) return ''
|
|
109
105
|
return stripSpexcodeBlock(readFileSync(file, 'utf8'))
|
|
110
106
|
}
|
|
111
|
-
// clear a legacy skip-worktree bit (the retired private-overlay mechanism; erase-only now — nothing asserts
|
|
112
|
-
// it). Best-effort: an index race or a non-repo must not fail the materialize.
|
|
113
|
-
function clearSkipWorktree(proj: string, file: string): void {
|
|
114
|
-
if (!isTracked(proj, file)) return
|
|
115
|
-
try { git(['-C', proj, 'update-index', '--no-skip-worktree', file]) } catch { /* best-effort */ }
|
|
116
|
-
}
|
|
117
|
-
|
|
118
107
|
// the identity stamp on every generated skill/agent file — what lets the erase phase forget a product whose
|
|
119
108
|
// NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names).
|
|
120
109
|
export const GENERATED_MARK = '<!-- spexcode:generated -->'
|
|
121
|
-
function
|
|
122
|
-
if (!dir || !existsSync(dir)) return
|
|
110
|
+
function pruneGeneratedSkills(dir: string | null, keep: ReadonlySet<string>): boolean {
|
|
111
|
+
if (!dir || !existsSync(dir)) return false
|
|
112
|
+
let changed = false
|
|
123
113
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
124
114
|
if (!e.isDirectory()) continue
|
|
125
115
|
const f = join(dir, e.name, 'SKILL.md')
|
|
126
|
-
try {
|
|
116
|
+
try {
|
|
117
|
+
if (!keep.has(e.name) && existsSync(f) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) {
|
|
118
|
+
rmSync(join(dir, e.name), { recursive: true, force: true })
|
|
119
|
+
changed = true
|
|
120
|
+
}
|
|
121
|
+
} catch { /* unreadable → not provably ours */ }
|
|
127
122
|
}
|
|
123
|
+
return changed
|
|
128
124
|
}
|
|
129
|
-
function
|
|
130
|
-
if (!dir || !existsSync(dir)) return
|
|
125
|
+
function pruneGeneratedAgents(dir: string | null, keep: ReadonlySet<string>): boolean {
|
|
126
|
+
if (!dir || !existsSync(dir)) return false
|
|
127
|
+
let changed = false
|
|
131
128
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
132
129
|
if (!e.isFile() || !e.name.endsWith('.md')) continue
|
|
133
130
|
const f = join(dir, e.name)
|
|
134
|
-
try {
|
|
131
|
+
try {
|
|
132
|
+
if (!keep.has(e.name.slice(0, -3)) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) {
|
|
133
|
+
rmSync(f, { force: true })
|
|
134
|
+
changed = true
|
|
135
|
+
}
|
|
136
|
+
} catch { /* unreadable → not provably ours */ }
|
|
137
|
+
}
|
|
138
|
+
return changed
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type TreeTargets = {
|
|
142
|
+
contracts: ReadonlySet<string>
|
|
143
|
+
treeShims: ReadonlySet<string>
|
|
144
|
+
anchors: ReadonlySet<string>
|
|
145
|
+
skills: ReadonlyMap<string, ReadonlySet<string>>
|
|
146
|
+
agents: ReadonlyMap<string, ReadonlySet<string>>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function cleanupHarnessDirs(proj: string): void {
|
|
150
|
+
const roots = new Set([proj, mainCheckout(proj)])
|
|
151
|
+
const dirs = HARNESSES.flatMap((h) => {
|
|
152
|
+
const anchor = h.worktreeHookAnchor(proj)
|
|
153
|
+
return [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
|
|
154
|
+
}).filter((d): d is string => !!d)
|
|
155
|
+
for (const d of [...new Set([...dirs, ...dirs.map((dir) => dirname(dir))])]
|
|
156
|
+
.filter((dir) => !roots.has(dir)).sort((a, b) => b.length - a.length)) {
|
|
157
|
+
try { rmdirSync(d) } catch {}
|
|
135
158
|
}
|
|
136
159
|
}
|
|
137
160
|
|
|
161
|
+
function reconcileTree(proj: string, targets: TreeTargets, tracked: (file: string) => boolean): void {
|
|
162
|
+
let removed = false
|
|
163
|
+
const contractFiles = new Set(HARNESSES.flatMap((h) => h.contractFiles(proj)))
|
|
164
|
+
for (const file of contractFiles) {
|
|
165
|
+
if (targets.contracts.has(file) || !existsSync(file)) continue
|
|
166
|
+
const text = readFileSync(file, 'utf8')
|
|
167
|
+
if (!text.includes('<!-- spexcode:start -->')) continue
|
|
168
|
+
removeManagedBlock(file, ['<!-- ', ' -->'], !tracked(file))
|
|
169
|
+
removed = true
|
|
170
|
+
}
|
|
171
|
+
const treeShims = new Set(HARNESSES.filter((h) => h.shimScope === 'tree').map((h) => h.shimFile(proj)))
|
|
172
|
+
const anchors = new Set(HARNESSES.map((h) => h.worktreeHookAnchor(proj)).filter((path): path is string => !!path))
|
|
173
|
+
for (const file of [...treeShims, ...anchors]) {
|
|
174
|
+
if (targets.treeShims.has(file) || targets.anchors.has(file) || !existsSync(file)) continue
|
|
175
|
+
if (readFileSync(file, 'utf8').includes('dispatch.sh')) { rmSync(file, { force: true }); removed = true }
|
|
176
|
+
}
|
|
177
|
+
for (const dir of new Set(HARNESSES.map((h) => h.skillDir(proj)).filter((path): path is string => !!path)))
|
|
178
|
+
removed = pruneGeneratedSkills(dir, targets.skills.get(dir) ?? new Set()) || removed
|
|
179
|
+
for (const dir of new Set(HARNESSES.map((h) => h.agentDir(proj)).filter((path): path is string => !!path)))
|
|
180
|
+
removed = pruneGeneratedAgents(dir, targets.agents.get(dir) ?? new Set()) || removed
|
|
181
|
+
if (removed) cleanupHarnessDirs(proj)
|
|
182
|
+
}
|
|
183
|
+
|
|
138
184
|
function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
|
|
139
185
|
for (const h of HARNESSES) {
|
|
140
186
|
// h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
|
|
141
187
|
// stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
|
|
142
188
|
h.clean(proj, arts, preserveProject)
|
|
143
|
-
for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f)
|
|
144
|
-
|
|
145
|
-
|
|
189
|
+
for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f)
|
|
190
|
+
pruneGeneratedSkills(h.skillDir(proj), new Set())
|
|
191
|
+
pruneGeneratedAgents(h.agentDir(proj), new Set())
|
|
146
192
|
}
|
|
147
193
|
// same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
|
|
148
194
|
// generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
|
|
149
195
|
removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
|
|
150
|
-
|
|
196
|
+
clearContractFilterPayload(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
|
|
151
197
|
// the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
|
|
152
198
|
// and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
|
|
153
199
|
// unstaged edit is never staged ([[content-filter]] edge 2).
|
|
@@ -157,16 +203,7 @@ function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolea
|
|
|
157
203
|
// because a harness may nest its shim a level below its home (opencode's .opencode/plugins/, pi's
|
|
158
204
|
// .pi/extensions/) — but never the checkout roots themselves. rmdirSync is NON-recursive, so a dir holding
|
|
159
205
|
// any user file survives untouched; `.git/spexcode/` is deliberately NOT swept (shared per-clone home).
|
|
160
|
-
|
|
161
|
-
const anchor = h.worktreeHookAnchor(proj)
|
|
162
|
-
const dirs = [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
|
|
163
|
-
.filter((d): d is string => !!d)
|
|
164
|
-
const roots = new Set([proj, mainCheckout(proj)])
|
|
165
|
-
const sweep = [...new Set([...dirs, ...dirs.map((d) => dirname(d))])]
|
|
166
|
-
.filter((d) => !roots.has(d))
|
|
167
|
-
.sort((a, b) => b.length - a.length)
|
|
168
|
-
for (const d of sweep) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
|
|
169
|
-
}
|
|
206
|
+
cleanupHarnessDirs(proj)
|
|
170
207
|
}
|
|
171
208
|
|
|
172
209
|
export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
|
|
@@ -177,7 +214,7 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
|
|
|
177
214
|
git(['-C', tree, 'rev-parse', '--show-toplevel'])
|
|
178
215
|
}
|
|
179
216
|
for (const tree of trees) {
|
|
180
|
-
// Only the caller's live spec may widen the
|
|
217
|
+
// Only the caller's live spec may widen the name sweep. Siblings are identity-stamp-only: the
|
|
181
218
|
// same name there may be user-owned or may not exist in this tree's divergent spec at all.
|
|
182
219
|
eraseTree(tree, tree === current ? arts : { skills: [], agents: [] }, false)
|
|
183
220
|
}
|
|
@@ -196,7 +233,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
196
233
|
}
|
|
197
234
|
// (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
|
|
198
235
|
const manifest = join(rt, 'hooks-manifest')
|
|
199
|
-
|
|
236
|
+
writeFileIfChanged(manifest, compileManifest())
|
|
200
237
|
record('hook manifest', manifest)
|
|
201
238
|
// (2) the contract = the surface:system plugin bodies (in name order), written WHOLE into EACH harness's
|
|
202
239
|
// contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
|
|
@@ -213,14 +250,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
213
250
|
const skillNodes = loadSkillConfig()
|
|
214
251
|
const agentNodes = loadAgentConfig()
|
|
215
252
|
const commandNodes = loadConfig()
|
|
216
|
-
const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
|
|
217
253
|
|
|
218
|
-
// ---- ERASE (the forgetting law): every landing point cleared by identity stamp, whatever policy — or
|
|
219
|
-
// legacy mode — wrote it last. Unselected harnesses need no separate prune branch: the erase already
|
|
220
|
-
// forgot them, and only the selected ones are asserted below.
|
|
221
|
-
eraseTree(proj, arts, true)
|
|
222
|
-
|
|
223
|
-
// ---- ASSERT: rewrite each landing point per the CURRENT policy.
|
|
224
254
|
// a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
|
|
225
255
|
// over the body instructions, closed by the GENERATED_MARK identity stamp (what the erase phase keys on).
|
|
226
256
|
// One pure artifact builder shared by every harness — divergence is only its skillDir.
|
|
@@ -239,53 +269,80 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
239
269
|
// because their residence is the live three-state kind detection below, not a static entry.
|
|
240
270
|
const artifactPaths: string[] = []
|
|
241
271
|
const machinePaths: string[] = []
|
|
242
|
-
const
|
|
272
|
+
const contractTargets = new Map<string, string>()
|
|
273
|
+
const treeShimTargets = new Map<string, string>()
|
|
274
|
+
const anchorTargets = new Map<string, string>()
|
|
275
|
+
const skillTargets = new Map<string, string>()
|
|
276
|
+
const agentTargets = new Map<string, string>()
|
|
277
|
+
const skillsByDir = new Map<string, Set<string>>()
|
|
278
|
+
const agentsByDir = new Map<string, Set<string>>()
|
|
279
|
+
const addTarget = (targets: Map<string, string>, path: string, content: string) => {
|
|
280
|
+
const prior = targets.get(path)
|
|
281
|
+
if (prior !== undefined && prior !== content) throw new Error(`conflicting materialize targets for ${path}`)
|
|
282
|
+
targets.set(path, content)
|
|
283
|
+
}
|
|
243
284
|
for (const h of selected) {
|
|
244
|
-
if (contract) for (const f of h.contractFiles(proj))
|
|
285
|
+
if (contract) for (const f of h.contractFiles(proj)) addTarget(contractTargets, f, contract)
|
|
245
286
|
const shim = h.shim(DISPATCH, SPEX)
|
|
246
287
|
if (h.shimScope === 'tree') {
|
|
247
|
-
|
|
248
|
-
mkdirSync(dirname(shimFile), { recursive: true })
|
|
249
|
-
writeFileSync(shimFile, shim.content)
|
|
250
|
-
record('shim', shimFile)
|
|
251
|
-
machinePaths.push(shimFile)
|
|
288
|
+
addTarget(treeShimTargets, h.shimFile(proj), shim.content)
|
|
252
289
|
}
|
|
253
290
|
// a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
|
|
254
291
|
// checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
|
|
255
292
|
const anchor = h.worktreeHookAnchor(proj)
|
|
256
|
-
if (anchor)
|
|
293
|
+
if (anchor) addTarget(anchorTargets, anchor, shim.content)
|
|
294
|
+
}
|
|
295
|
+
for (const sk of skillNodes) for (const h of selected) {
|
|
296
|
+
const dir = h.skillDir(proj); if (!dir) continue
|
|
297
|
+
addTarget(skillTargets, join(dir, sk.name, 'SKILL.md'), skillArtifact(sk))
|
|
298
|
+
const names = skillsByDir.get(dir) ?? new Set<string>(); names.add(sk.name); skillsByDir.set(dir, names)
|
|
299
|
+
}
|
|
300
|
+
for (const ag of agentNodes) for (const h of selected) {
|
|
301
|
+
const dir = h.agentDir(proj); if (!dir) continue
|
|
302
|
+
addTarget(agentTargets, join(dir, `${ag.name}.md`), agentArtifact(ag))
|
|
303
|
+
const names = agentsByDir.get(dir) ?? new Set<string>(); names.add(ag.name); agentsByDir.set(dir, names)
|
|
304
|
+
}
|
|
305
|
+
const tracked = new Map<string, boolean>()
|
|
306
|
+
const isTrackedHere = (file: string) => {
|
|
307
|
+
const known = tracked.get(file)
|
|
308
|
+
if (known !== undefined) return known
|
|
309
|
+
const value = isTracked(proj, file); tracked.set(file, value); return value
|
|
310
|
+
}
|
|
311
|
+
reconcileTree(proj, {
|
|
312
|
+
contracts: new Set(contractTargets.keys()), treeShims: new Set(treeShimTargets.keys()), anchors: new Set(anchorTargets.keys()),
|
|
313
|
+
skills: skillsByDir, agents: agentsByDir,
|
|
314
|
+
}, isTrackedHere)
|
|
315
|
+
const changedMaterialized = new Set<string>()
|
|
316
|
+
for (const [file, content] of contractTargets) {
|
|
317
|
+
if (writeManagedBlock(file, content)) changedMaterialized.add(file)
|
|
318
|
+
record('contract', file)
|
|
319
|
+
}
|
|
320
|
+
const contractPaths = [...contractTargets.keys()]
|
|
321
|
+
for (const [file, content] of treeShimTargets) {
|
|
322
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
323
|
+
record('shim', file); machinePaths.push(file)
|
|
324
|
+
}
|
|
325
|
+
for (const [file, content] of anchorTargets) {
|
|
326
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
327
|
+
record('shim', file); machinePaths.push(file)
|
|
257
328
|
}
|
|
258
329
|
const selectedByDispatch = new Map(selected.map((h) => [h.dispatchId, h]))
|
|
259
330
|
for (const h of selectedByDispatch.values()) {
|
|
260
331
|
const shim = h.shim(DISPATCH, SPEX)
|
|
261
332
|
if (h.shimScope === 'project') {
|
|
262
333
|
const file = h.shimFile(proj)
|
|
263
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
334
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, shim.content)
|
|
264
335
|
record('shim', file)
|
|
265
336
|
}
|
|
266
337
|
for (const file of h.writeTrust(proj, shim.cmd)) record('trust', file)
|
|
267
338
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
for (const h of selected) {
|
|
272
|
-
const dir = h.skillDir(proj); if (!dir) continue
|
|
273
|
-
const f = join(dir, sk.name, 'SKILL.md')
|
|
274
|
-
mkdirSync(dirname(f), { recursive: true })
|
|
275
|
-
writeFileSync(f, skillArtifact(sk))
|
|
276
|
-
artifactPaths.push(f)
|
|
277
|
-
record('skill', f)
|
|
278
|
-
}
|
|
339
|
+
for (const [file, content] of skillTargets) {
|
|
340
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
341
|
+
artifactPaths.push(file); record('skill', file)
|
|
279
342
|
}
|
|
280
|
-
for (const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
const f = join(dir, `${ag.name}.md`)
|
|
284
|
-
mkdirSync(dirname(f), { recursive: true })
|
|
285
|
-
writeFileSync(f, agentArtifact(ag))
|
|
286
|
-
artifactPaths.push(f)
|
|
287
|
-
record('agent', f)
|
|
288
|
-
}
|
|
343
|
+
for (const [file, content] of agentTargets) {
|
|
344
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
345
|
+
artifactPaths.push(file); record('agent', file)
|
|
289
346
|
}
|
|
290
347
|
// (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
|
|
291
348
|
// bundle per selected folder. A plugin is EXCLUSIVE (`selected` is empty then). Pruning a DESELECTED
|
|
@@ -294,11 +351,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
294
351
|
// global store records the folders emitted last run; any prev folder absent from the current set is
|
|
295
352
|
// clean()ed, then the current folders are emitted and the ledger rewritten.
|
|
296
353
|
const ledger = join(rt, 'plugin-folders')
|
|
297
|
-
|
|
298
|
-
// the prev set so a deselected folder is still pruned; every write lands in the slot from here on.
|
|
299
|
-
const legacyLedger = join(runtimeRoot(proj), 'plugin-folders')
|
|
300
|
-
const ledgerSrc = existsSync(ledger) ? ledger : legacyLedger
|
|
301
|
-
const prevFolders = existsSync(ledgerSrc) ? readFileSync(ledgerSrc, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
|
|
354
|
+
const prevFolders = existsSync(ledger) ? readFileSync(ledger, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
|
|
302
355
|
const curFolders = plugins.map((p) => p.folder)
|
|
303
356
|
for (const f of prevFolders) if (!curFolders.includes(f)) cleanPlugin(proj, f)
|
|
304
357
|
if (plugins.length) {
|
|
@@ -315,7 +368,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
315
368
|
record('plugin bundle', pluginBundleDir(proj, p.folder))
|
|
316
369
|
}
|
|
317
370
|
}
|
|
318
|
-
|
|
371
|
+
writeFileIfChanged(ledger, curFolders.join('\n'))
|
|
319
372
|
// (9) ignore + mixed text. Only checkout-invariant residue and project-shared shims belong in the COMMON
|
|
320
373
|
// info/exclude; selection-dependent paths live in this tree's filtered working .gitignore.
|
|
321
374
|
const mc = mainCheckout(proj)
|
|
@@ -327,43 +380,38 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
327
380
|
'spexcode.local.json', '.worktrees/', '.session',
|
|
328
381
|
]
|
|
329
382
|
const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
|
|
330
|
-
const priorCommonEntries = managedExcludeEntries(infoExcludePath(proj))
|
|
331
|
-
|
|
332
383
|
// Contract residence stays a live fact. Selection-dependent untracked products are ignored by this tree's
|
|
333
384
|
// working .gitignore, whose own managed block is filtered when the host tracks/owns that file.
|
|
334
385
|
const filterContracts: string[] = []
|
|
335
386
|
const oursContracts: string[] = []
|
|
336
387
|
for (const f of contractPaths) {
|
|
337
|
-
if (
|
|
388
|
+
if (isTrackedHere(f) || hostContentOf(f).trim()) filterContracts.push(f)
|
|
338
389
|
else oursContracts.push(f)
|
|
339
390
|
}
|
|
340
391
|
const localEntries = [...machinePaths, ...bundlePaths, ...artifactPaths, ...oursContracts]
|
|
341
392
|
.map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
|
|
342
393
|
const ignoreFile = join(proj, '.gitignore')
|
|
343
|
-
const ignoreTracked =
|
|
344
|
-
const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
|
|
394
|
+
const ignoreTracked = isTrackedHere(ignoreFile)
|
|
395
|
+
const ignoreHost = existsSync(ignoreFile) ? stripSpexcodeBlock(readFileSync(ignoreFile, 'utf8'), ['# ', '']) : ''
|
|
345
396
|
if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
|
|
346
397
|
const ignoreBody = entries(localEntries)
|
|
347
|
-
writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])
|
|
398
|
+
if (writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])) changedMaterialized.add(ignoreFile)
|
|
348
399
|
|
|
349
400
|
const payloads: ContractFilterPayload[] = filterContracts.map((file) => ({ file: relative(proj, file), content: contract }))
|
|
350
401
|
if (ignoreTracked || ignoreHost.trim()) payloads.push({ file: '.gitignore', content: ignoreBody })
|
|
351
402
|
const bindings: ContractFilterBinding[] = [
|
|
352
403
|
...[...new Set(HARNESSES.flatMap((h) => h.contractFiles(proj).map((file) => relative(proj, file))))]
|
|
353
|
-
.map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->'
|
|
404
|
+
.map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->' })),
|
|
354
405
|
{ file: '.gitignore', start: '# spexcode:start', end: '# spexcode:end' },
|
|
355
406
|
]
|
|
356
|
-
if (payloads.length) plantContractFilter(proj, payloads, bindings)
|
|
357
|
-
|
|
407
|
+
if (payloads.length) plantContractFilter(proj, payloads, bindings, [...changedMaterialized])
|
|
408
|
+
else if (contractFilterPlanted(proj)) removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
|
|
409
|
+
// (5) finish diagnostics, then atomically publish the allowlist LAST. Dispatch consumes only that
|
|
358
410
|
// final receipt; a killed writer leaves the preceding successful selection intact.
|
|
359
411
|
const h = contentHash(proj)
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
writeFileSync(join(runtimeRoot(proj), 'harness-selection-v1'), '')
|
|
364
|
-
retireLegacyContractBlock(proj)
|
|
365
|
-
const legacyEntries = hasLegacyTreeIgnore(proj) ? priorCommonEntries : []
|
|
366
|
-
writeManagedBlock(infoExcludePath(proj), entries([...commonEntries, ...legacyEntries]), ['# ', ''])
|
|
412
|
+
writeFileIfChanged(join(rt, 'content-hash'), h)
|
|
413
|
+
writeFileIfChanged(join(runtimeRoot(proj), 'harness-selection-v1'), '')
|
|
414
|
+
writeManagedBlock(infoExcludePath(proj), entries(commonEntries), ['# ', ''])
|
|
367
415
|
publishSelection(join(rt, 'harnesses'), selectionBody(selected, plugins.length > 0))
|
|
368
416
|
return { contentHash: h, planted }
|
|
369
417
|
}
|
|
@@ -1,31 +1,32 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
|
|
2
2
|
import { join, dirname } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { copyFileIfChanged, writeFileIfChanged } from './file-write.js'
|
|
4
5
|
|
|
5
6
|
// @@@ plugin-harness - the PLUGIN BUNDLE emitter: materialize the whole SpexCode system into ONE self-contained
|
|
6
|
-
// Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.
|
|
7
|
+
// Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.adopter-a` /
|
|
7
8
|
// `.claude` → <folder>/plugins/spexcode/). It is the plugin-target counterpart of the native per-harness
|
|
8
9
|
// write [[harness-adapter]] does — chosen INSTEAD of the natives (plugin exclusivity), so [[harness-delivery]]'s
|
|
9
10
|
// materialize prunes every native first, then emits this. The bundle follows the de-facto Claude-plugin schema
|
|
10
11
|
// (a `.claude-plugin/plugin.json` pointing at hooks/skills/commands/agents); the host's discovery order is
|
|
11
|
-
// `.
|
|
12
|
-
// the ONE `.claude-plugin` bundle reaches
|
|
12
|
+
// `.adopter-a-plugin > .claude-plugin > .codex-plugin` and adopter-a/Claude both read a `.claude-plugin` directly, so
|
|
13
|
+
// the ONE `.claude-plugin` bundle reaches AdopterA, Claude, and (future) Codex from a single emit.
|
|
13
14
|
//
|
|
14
15
|
// The contract is NOT delivered by an always-on CLAUDE.md block here (the bundle never touches the repo's own
|
|
15
16
|
// files) — it maps to a SessionStart hook that emits hookSpecificOutput.additionalContext (the harness-neutral
|
|
16
|
-
// injection Claude/
|
|
17
|
+
// injection Claude/adopter-a normalize, the superpowers pattern), so a plugin host gets the contract with no
|
|
17
18
|
// --append-system-prompt. The hooks reuse the SAME dispatch.sh wiring as the natives, located via the host's
|
|
18
19
|
// ${CLAUDE_PLUGIN_ROOT} variable; dispatch.sh's first arg is the harness id `plugin`, so its shell mirror
|
|
19
|
-
// (harness.sh) parses payloads as the claude family (
|
|
20
|
+
// (harness.sh) parses payloads as the claude family (adopter-a/Claude share Claude's tool names + file_path).
|
|
20
21
|
|
|
21
22
|
const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
|
|
22
23
|
const HOOKS_SRC = join(PKG, 'hooks') // the canonical dispatch.sh + harness.sh source
|
|
23
24
|
const PLUGIN_NAME = 'spexcode'
|
|
24
25
|
// the host substitutes ${CLAUDE_PLUGIN_ROOT} with the bundle's own absolute path before running a hook command
|
|
25
|
-
// (the same variable
|
|
26
|
+
// (the same variable adopter-a's hook-compat honours), so dispatch.sh/inject-contract.sh resolve regardless of
|
|
26
27
|
// where the host scanned the bundle from.
|
|
27
28
|
const PLUGIN_ROOT = '${CLAUDE_PLUGIN_ROOT}'
|
|
28
|
-
// the lifecycle events the bundle binds — the Claude/
|
|
29
|
+
// the lifecycle events the bundle binds — the Claude/adopter-a superset; a host that fires fewer (Codex) simply
|
|
29
30
|
// never invokes the extras, so binding all is harmless and one emit serves every host.
|
|
30
31
|
const PLUGIN_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop', 'StopFailure', 'Notification'] as const
|
|
31
32
|
|
|
@@ -50,7 +51,7 @@ export function pluginBundleDir(proj: string, folder: string): string {
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
// the de-facto Claude-plugin manifest: name (the bundle identity clean() gates on), version, description, and
|
|
53
|
-
// the component pointers Claude/
|
|
54
|
+
// the component pointers Claude/adopter-a discover (hooks.json + the skills/commands/agents dirs).
|
|
54
55
|
function pluginManifest(version: string): string {
|
|
55
56
|
return JSON.stringify({
|
|
56
57
|
name: PLUGIN_NAME,
|
|
@@ -63,7 +64,7 @@ function pluginManifest(version: string): string {
|
|
|
63
64
|
}, null, 2)
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
// hooks.json in the Claude/
|
|
67
|
+
// hooks.json in the Claude/adopter-a-compatible shape { "hooks": { "<Event>": [{ "hooks": [command…] }] } }. Every
|
|
67
68
|
// event → the SHARED dispatch.sh (`plugin` baked as its harness id, SPEX inherited by handlers); SessionStart
|
|
68
69
|
// ALSO runs inject-contract.sh first, so the contract additionalContext lands alongside the normal dispatch.
|
|
69
70
|
function pluginHooksJson(spex: string): string {
|
|
@@ -89,7 +90,7 @@ function contractContextJson(contract: string): string {
|
|
|
89
90
|
// stand-in for --append-system-prompt). A bare cat — all the encoding happened at materialize time.
|
|
90
91
|
const INJECT_SH = `#!/usr/bin/env bash
|
|
91
92
|
# Emit the SpexCode contract as SessionStart additionalContext — the harness-neutral contract injection (the
|
|
92
|
-
# superpowers pattern; Claude/
|
|
93
|
+
# superpowers pattern; Claude/adopter-a normalize hookSpecificOutput.additionalContext) that replaces a plugin
|
|
93
94
|
# host's missing --append-system-prompt. The JSON was written at materialize time, so this is a trivial cat.
|
|
94
95
|
here="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
95
96
|
[ -f "$here/contract-context.json" ] && cat "$here/contract-context.json"
|
|
@@ -103,23 +104,33 @@ export function emitPlugin(proj: string, folder: string, r: PluginBundle): void
|
|
|
103
104
|
const hooksDir = join(bundle, 'hooks')
|
|
104
105
|
mkdirSync(meta, { recursive: true })
|
|
105
106
|
mkdirSync(hooksDir, { recursive: true })
|
|
106
|
-
|
|
107
|
+
writeFileIfChanged(join(meta, 'plugin.json'), pluginManifest(r.version))
|
|
107
108
|
// hooks: the SHARED dispatcher + its shell mirror (copied verbatim — the exact native wiring), the contract
|
|
108
109
|
// injector + its pre-encoded payload, and the event→dispatch binding.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
copyFileIfChanged(join(HOOKS_SRC, 'dispatch.sh'), join(hooksDir, 'dispatch.sh'))
|
|
111
|
+
copyFileIfChanged(join(HOOKS_SRC, 'harness.sh'), join(hooksDir, 'harness.sh'))
|
|
112
|
+
writeFileIfChanged(join(hooksDir, 'inject-contract.sh'), INJECT_SH)
|
|
113
|
+
writeFileIfChanged(join(hooksDir, 'contract-context.json'), contractContextJson(r.contract))
|
|
114
|
+
writeFileIfChanged(join(hooksDir, 'hooks.json'), pluginHooksJson(r.spex))
|
|
114
115
|
// skills / agents / commands — the Claude-plugin layout, the SAME materialized contents as the native dirs.
|
|
116
|
+
reconcileBundleDirectory(join(bundle, 'skills'), new Set(r.skills.map((s) => s.name)))
|
|
117
|
+
reconcileBundleDirectory(join(bundle, 'agents'), new Set(r.agents.map((a) => `${a.name}.md`)))
|
|
118
|
+
reconcileBundleDirectory(join(bundle, 'commands'), new Set(r.commands.map((c) => `${c.name}.md`)))
|
|
115
119
|
for (const s of r.skills) writeBundleFile(join(bundle, 'skills', s.name, 'SKILL.md'), s.content)
|
|
116
120
|
for (const a of r.agents) writeBundleFile(join(bundle, 'agents', `${a.name}.md`), a.content)
|
|
117
121
|
for (const c of r.commands) writeBundleFile(join(bundle, 'commands', `${c.name}.md`), c.content)
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
function reconcileBundleDirectory(dir: string, expectedNames: ReadonlySet<string>): void {
|
|
125
|
+
if (!existsSync(dir)) return
|
|
126
|
+
for (const entry of readdirSync(dir)) {
|
|
127
|
+
if (!expectedNames.has(entry)) rmSync(join(dir, entry), { recursive: true, force: true })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
function writeBundleFile(f: string, content: string): void {
|
|
121
132
|
mkdirSync(dirname(f), { recursive: true })
|
|
122
|
-
|
|
133
|
+
writeFileIfChanged(f, content)
|
|
123
134
|
}
|
|
124
135
|
|
|
125
136
|
// the INVERSE of emitPlugin — prune the bundle when its folder is DESELECTED ([[harness-delivery]] tracks the
|
|
@@ -142,6 +142,19 @@ export function verifyDetachedRuntime(pid: number, receiptFile: string, adapter:
|
|
|
142
142
|
return live
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
// The inverse question of verifyDetachedRuntime: not "is the recorded process running right now" but "is it
|
|
146
|
+
// provably GONE". The two are not complements — a missing receipt, an unreadable identity, or a PID we never
|
|
147
|
+
// recorded answers neither, and a caller that treats "unproven" as "dead" would retire a live runtime it can
|
|
148
|
+
// simply no longer address. Only a recorded start identity that the live PID no longer matches (including a
|
|
149
|
+
// PID that is not running at all) is proof of death.
|
|
150
|
+
export function detachedRuntimeIsGone(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): boolean {
|
|
151
|
+
let receipt: DetachedLaunchReceiptV4 | null
|
|
152
|
+
try { receipt = parseDetachedLaunchReceipt(readFileSync(receiptFile, 'utf8'), adapter.platform) }
|
|
153
|
+
catch { return false }
|
|
154
|
+
if (!receipt || receipt.pid !== pid) return false
|
|
155
|
+
return adapter.startToken(pid) !== receipt.startToken
|
|
156
|
+
}
|
|
157
|
+
|
|
145
158
|
// A v3 scope is only a migration witness when every recorded and live Linux identity agrees. Readers never
|
|
146
159
|
// call this: minting a v4 receipt belongs to the write admission path that needs the shared runtime.
|
|
147
160
|
export function migrateLegacyDetachedRuntimeReceipt(
|