spexcode 0.5.9 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/spec-cli/src/cli.ts +69 -70
- package/spec-cli/src/client.ts +1 -1
- 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 +107 -0
- package/spec-cli/src/doctor.ts +2 -1
- package/spec-cli/src/file-write.ts +22 -0
- package/spec-cli/src/git.ts +30 -8
- package/spec-cli/src/graphStream.ts +79 -11
- package/spec-cli/src/harness.ts +12 -10
- package/spec-cli/src/help.ts +9 -14
- package/spec-cli/src/index.ts +10 -2
- package/spec-cli/src/init.ts +8 -17
- package/spec-cli/src/layout.ts +35 -7
- package/spec-cli/src/materialize.ts +150 -107
- package/spec-cli/src/plugin-harness.ts +19 -8
- package/spec-cli/src/process-identity.ts +13 -0
- package/spec-cli/src/reviews.ts +55 -11
- package/spec-cli/src/session-cursors.ts +11 -17
- package/spec-cli/src/session-follow.ts +6 -6
- package/spec-cli/src/sessions.ts +224 -38
- package/spec-cli/src/uninstall.ts +2 -1
- 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/mark-active/mark-active.sh +5 -41
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +1 -1
- 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-dashboard/dist/assets/{App-b8Nh0sgk.js → App-F9uaAVcH.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-CvAjfRC2.js → Dashboard-Ba_jhxp1.js} +3 -3
- package/spec-dashboard/dist/assets/{EvalsPage-Bz-nMKoS.js → EvalsPage-FixoOg_n.js} +2 -2
- package/spec-dashboard/dist/assets/{IssuesPage-CAP64YWE.js → IssuesPage-CuKLFhH3.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-D9L1Va8Z.js → MobileApp-CHgEHORJ.js} +2 -2
- package/spec-dashboard/dist/assets/{Modal-Drscez-d.js → Modal-CQgYymmr.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-qW6uOJL8.js → PageScroll-hT7UTLvD.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-CjybFBmR.js → ProjectsPage-CtXxakF9.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Dl9v0JFM.js → SessionInterface-Bpie-9fs.js} +12 -12
- package/spec-dashboard/dist/assets/{SessionWindow-iOk0yHoU.js → SessionWindow-CixDi4PI.js} +1 -1
- package/spec-dashboard/dist/assets/{Settings-BZ1lGRJs.js → Settings-C2MsucfE.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +13 -0
- package/spec-dashboard/dist/assets/{data-Bwd3kAVL.js → data-B-RQmit6.js} +1 -1
- package/spec-dashboard/dist/assets/{index-DAbQBBK_.css → index-CsI8DElI.css} +1 -1
- package/spec-dashboard/dist/assets/{index-paP-z_Vd.js → index-DrVao0Ep.js} +2 -2
- package/spec-dashboard/dist/assets/{launch-B-bYdWmh.js → launch-BBH02b1v.js} +1 -1
- 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 +7 -8
- package/spec-eval/src/evaltab.ts +25 -5
- package/spec-eval/src/freshness.ts +52 -8
- package/spec-eval/src/scenariofresh.ts +58 -8
- package/spec-eval/src/scenarios.ts +60 -12
- package/spec-eval/src/sessioneval.ts +122 -30
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +0 -13
- package/spec-eval/src/matrix.ts +0 -693
package/spec-cli/src/init.ts
CHANGED
|
@@ -1,29 +1,20 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, copyFileSync, readFileSync, readdirSync, renameSync, rmSync, statSync, chmodSync, writeFileSync } from 'node:fs'
|
|
2
|
-
import { createHash } from 'node:crypto'
|
|
3
2
|
import { join, resolve, relative, dirname } from 'node:path'
|
|
4
3
|
import { fileURLToPath } from 'node:url'
|
|
5
4
|
import { execFileSync } from 'node:child_process'
|
|
6
5
|
import { readConfig, readJsonConfig } from './layout.js'
|
|
7
6
|
import { resolveHarnessTargets, parseHarnessFlag, NATIVE_HARNESS_IDS } from './harness-select.js'
|
|
7
|
+
import { gitBinary } from './git.js'
|
|
8
8
|
|
|
9
9
|
// this file lives at <pkgRoot>/src/init.ts, so `..` is the package root — the same derivation the
|
|
10
10
|
// launch paths use, never a hardcoded repo path (so a relocated/installed package still finds its data).
|
|
11
11
|
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
12
12
|
const TEMPLATES = join(pkgRoot, 'templates')
|
|
13
13
|
const MANAGED_HOOK_HEADER = '# spexcode-managed-hook-v1'
|
|
14
|
-
const LEGACY_MANAGED_HOOKS: Record<string, Set<string>> = {
|
|
15
|
-
'pre-commit': new Set(['fc33a6ff2b444fa47210a2594b6a714613d9c62fd8ec77a9a13aec81143de8a7']),
|
|
16
|
-
'prepare-commit-msg': new Set(['08b26ad8ffb305a64b7cddc868231854c84d539b8e5e9324702bd2c293044183']),
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function hookDigest(path: string): string {
|
|
20
|
-
return createHash('sha256').update(readFileSync(path)).digest('hex')
|
|
21
|
-
}
|
|
22
14
|
|
|
23
|
-
function isManagedHook(path: string
|
|
15
|
+
function isManagedHook(path: string): boolean {
|
|
24
16
|
const source = readFileSync(path, 'utf8')
|
|
25
17
|
return source.split('\n').slice(0, 4).includes(MANAGED_HOOK_HEADER)
|
|
26
|
-
|| LEGACY_MANAGED_HOOKS[name]?.has(createHash('sha256').update(source).digest('hex')) === true
|
|
27
18
|
}
|
|
28
19
|
|
|
29
20
|
function replaceHook(source: string, dest: string): void {
|
|
@@ -71,7 +62,7 @@ function copyTreeNoClobber(srcDir: string, destDir: string, base: string): strin
|
|
|
71
62
|
// hooks/ (shared across all worktrees). Returns null (with a loud reason) when <dir> isn't a git repo.
|
|
72
63
|
function resolveHooksDir(dir: string): string | null {
|
|
73
64
|
try {
|
|
74
|
-
const common = execFileSync(
|
|
65
|
+
const common = execFileSync(gitBinary(process.env), ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
75
66
|
encoding: 'utf8',
|
|
76
67
|
stdio: ['ignore', 'pipe', 'ignore'], // swallow git's own "fatal: not a git repository" — our warning is the signal
|
|
77
68
|
}).trim()
|
|
@@ -86,8 +77,8 @@ function resolveHooksDir(dir: string): string | null {
|
|
|
86
77
|
// turn that feature branch into trunk).
|
|
87
78
|
function adoptionMainBranch(dir: string): string {
|
|
88
79
|
try {
|
|
89
|
-
const common = execFileSync(
|
|
90
|
-
const branch = execFileSync(
|
|
80
|
+
const common = execFileSync(gitBinary(process.env), ['-C', dir, 'rev-parse', '--path-format=absolute', '--git-common-dir'], { encoding: 'utf8' }).trim()
|
|
81
|
+
const branch = execFileSync(gitBinary(process.env), ['-C', dirname(common), 'symbolic-ref', '--short', 'HEAD'], { encoding: 'utf8' }).trim()
|
|
91
82
|
if (branch) return branch
|
|
92
83
|
} catch { /* render the one product-level repair below */ }
|
|
93
84
|
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`')
|
|
@@ -113,7 +104,7 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
113
104
|
// NOT `git init` for them: creating a repo is a side effect beyond init's remit (a subdir, a dir not
|
|
114
105
|
// meant as a repo root), and `git init` is one deliberate command.
|
|
115
106
|
try {
|
|
116
|
-
execFileSync(
|
|
107
|
+
execFileSync(gitBinary(process.env), ['-C', targetDir, 'rev-parse', '--is-inside-work-tree'], { stdio: ['ignore', 'ignore', 'ignore'] })
|
|
117
108
|
} catch {
|
|
118
109
|
console.error(`spex init: ${targetDir} is not a git repository. SpexCode is git-backed (git is the version database; the hooks live in .git). Run \`git init\` there first, then \`spex init\`.`)
|
|
119
110
|
process.exit(1)
|
|
@@ -210,8 +201,8 @@ export async function specInit(targetArg: string | undefined, presetArg?: string
|
|
|
210
201
|
const source = join(hooksSrc, e.name)
|
|
211
202
|
const dest = join(hooksDir, e.name)
|
|
212
203
|
if (existsSync(dest)) {
|
|
213
|
-
if (
|
|
214
|
-
if (isManagedHook(dest
|
|
204
|
+
if (readFileSync(dest).equals(readFileSync(source))) continue
|
|
205
|
+
if (isManagedHook(dest)) {
|
|
215
206
|
replaceHook(source, dest)
|
|
216
207
|
refreshed.push(e.name)
|
|
217
208
|
} else {
|
package/spec-cli/src/layout.ts
CHANGED
|
@@ -170,10 +170,28 @@ export function readUploadPolicy(root: string): UploadPolicy {
|
|
|
170
170
|
// the shared git common dir (env-stripped git() so a hook's exported GIT_DIR can't misdirect it). Memoized:
|
|
171
171
|
// it's a process constant, but mainBranch()/mainRoot() resolve it per call (~60 git rev-parse forks per board build without the cache).
|
|
172
172
|
let commonDirCache: string | null = null
|
|
173
|
+
const commonDirsByPath = new Map<string, string>()
|
|
174
|
+
const topsByPath = new Map<string, string>()
|
|
173
175
|
export function gitCommonDir(): string {
|
|
174
176
|
if (commonDirCache === null) commonDirCache = git(['rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
175
177
|
return commonDirCache
|
|
176
178
|
}
|
|
179
|
+
function commonDirFor(proj: string): string {
|
|
180
|
+
const key = resolve(proj)
|
|
181
|
+
const known = commonDirsByPath.get(key)
|
|
182
|
+
if (known) return known
|
|
183
|
+
const common = git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
184
|
+
commonDirsByPath.set(key, common)
|
|
185
|
+
return common
|
|
186
|
+
}
|
|
187
|
+
function topFor(proj: string): string {
|
|
188
|
+
const key = resolve(proj)
|
|
189
|
+
const known = topsByPath.get(key)
|
|
190
|
+
if (known) return known
|
|
191
|
+
const top = git(['-C', proj, 'rev-parse', '--show-toplevel']).trim()
|
|
192
|
+
topsByPath.set(key, top)
|
|
193
|
+
return top
|
|
194
|
+
}
|
|
177
195
|
|
|
178
196
|
export function mainBranch(): string {
|
|
179
197
|
let checkout: string
|
|
@@ -186,9 +204,7 @@ export function mainBranch(): string {
|
|
|
186
204
|
// `.codex` (codex-rs hooks_config_folder override), NOT the worktree's, so the codex hooks shim + trust
|
|
187
205
|
// materialize here while AGENTS.md/skills stay per-worktree — see [[harness-adapter]] (harness.ts).
|
|
188
206
|
export function mainCheckout(proj?: string): string {
|
|
189
|
-
const gcd = proj
|
|
190
|
-
? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
191
|
-
: gitCommonDir()
|
|
207
|
+
const gcd = proj ? commonDirFor(proj) : gitCommonDir()
|
|
192
208
|
return dirname(gcd)
|
|
193
209
|
}
|
|
194
210
|
|
|
@@ -222,9 +238,7 @@ export function mainRoot(proj?: string): string {
|
|
|
222
238
|
// proj-aware for `spex init <dir>` / materialize(proj); cwd-based default for the hooks/board. The shell
|
|
223
239
|
// hooks mirror this as hp_runtime_dir.
|
|
224
240
|
export function runtimeRoot(proj?: string): string {
|
|
225
|
-
const gcd = proj
|
|
226
|
-
? git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
227
|
-
: gitCommonDir()
|
|
241
|
+
const gcd = proj ? commonDirFor(proj) : gitCommonDir()
|
|
228
242
|
return projectRuntimeRoot(gcd)
|
|
229
243
|
}
|
|
230
244
|
// the per-WORKTREE materialize slot — <runtime>/trees/<enc(worktree-toplevel)> — holding the materialize
|
|
@@ -236,7 +250,7 @@ export function runtimeRoot(proj?: string): string {
|
|
|
236
250
|
// land on the same slot from the same tree, and only from the same tree. Throws when `wt` is not a live
|
|
237
251
|
// git tree (fail loud); a best-effort caller (the close-time GC) wraps it.
|
|
238
252
|
export function treeSlotDir(wt: string): string {
|
|
239
|
-
const top =
|
|
253
|
+
const top = topFor(wt)
|
|
240
254
|
return join(runtimeRoot(wt), 'trees', encodeProject(top || wt))
|
|
241
255
|
}
|
|
242
256
|
// this project's per-session records dir, one session's dir, its structured record, and a sibling artifact —
|
|
@@ -448,6 +462,20 @@ export function listSessionIds(): string[] {
|
|
|
448
462
|
return ents.filter((d) => d.isDirectory()).map((d) => d.name)
|
|
449
463
|
}
|
|
450
464
|
|
|
465
|
+
// @@@ branch -> session, so a moved ref can DERIVE its invalidation scope - a session's evaluation depends
|
|
466
|
+
// on its own branch tip, the base branch tip and their merge-base; no other ref participates. Without this
|
|
467
|
+
// map a ref watcher knows only that "something under refs/ moved" and the honest fallback is to invalidate
|
|
468
|
+
// every session ([[taste]] 19). One record read per session, and only when the session store itself moved.
|
|
469
|
+
export function sessionBranchIndex(): Map<string, string> {
|
|
470
|
+
const index = new Map<string, string>()
|
|
471
|
+
for (const id of listSessionIds()) {
|
|
472
|
+
let branch: unknown
|
|
473
|
+
try { branch = readRawRecord(id)?.branch } catch { continue } // a corrupt record narrows nothing
|
|
474
|
+
if (typeof branch === 'string' && branch) index.set(branch, id)
|
|
475
|
+
}
|
|
476
|
+
return index
|
|
477
|
+
}
|
|
478
|
+
|
|
451
479
|
// memo the overlay (4 git diffs/worktree, all .spec-scoped) keyed on fork-point merge-base + HEAD + spec
|
|
452
480
|
// sig + MAIN'S TIP ([[worktree-linker]]): the main-tip component is what lets a merge landing identical
|
|
453
481
|
// content dissolve a worktree's now-moot ops — the recompute it triggers is cheap because every diff is
|
|
@@ -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,34 +64,22 @@ 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)
|
|
@@ -108,46 +99,96 @@ function hostContentOf(file: string): string {
|
|
|
108
99
|
if (!existsSync(file)) return ''
|
|
109
100
|
return stripSpexcodeBlock(readFileSync(file, 'utf8'))
|
|
110
101
|
}
|
|
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
102
|
// the identity stamp on every generated skill/agent file — what lets the erase phase forget a product whose
|
|
119
103
|
// NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names).
|
|
120
104
|
export const GENERATED_MARK = '<!-- spexcode:generated -->'
|
|
121
|
-
function
|
|
122
|
-
if (!dir || !existsSync(dir)) return
|
|
105
|
+
function pruneGeneratedSkills(dir: string | null, keep: ReadonlySet<string>): boolean {
|
|
106
|
+
if (!dir || !existsSync(dir)) return false
|
|
107
|
+
let changed = false
|
|
123
108
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
124
109
|
if (!e.isDirectory()) continue
|
|
125
110
|
const f = join(dir, e.name, 'SKILL.md')
|
|
126
|
-
try {
|
|
111
|
+
try {
|
|
112
|
+
if (!keep.has(e.name) && existsSync(f) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) {
|
|
113
|
+
rmSync(join(dir, e.name), { recursive: true, force: true })
|
|
114
|
+
changed = true
|
|
115
|
+
}
|
|
116
|
+
} catch { /* unreadable → not provably ours */ }
|
|
127
117
|
}
|
|
118
|
+
return changed
|
|
128
119
|
}
|
|
129
|
-
function
|
|
130
|
-
if (!dir || !existsSync(dir)) return
|
|
120
|
+
function pruneGeneratedAgents(dir: string | null, keep: ReadonlySet<string>): boolean {
|
|
121
|
+
if (!dir || !existsSync(dir)) return false
|
|
122
|
+
let changed = false
|
|
131
123
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
132
124
|
if (!e.isFile() || !e.name.endsWith('.md')) continue
|
|
133
125
|
const f = join(dir, e.name)
|
|
134
|
-
try {
|
|
126
|
+
try {
|
|
127
|
+
if (!keep.has(e.name.slice(0, -3)) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) {
|
|
128
|
+
rmSync(f, { force: true })
|
|
129
|
+
changed = true
|
|
130
|
+
}
|
|
131
|
+
} catch { /* unreadable → not provably ours */ }
|
|
132
|
+
}
|
|
133
|
+
return changed
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
type TreeTargets = {
|
|
137
|
+
contracts: ReadonlySet<string>
|
|
138
|
+
treeShims: ReadonlySet<string>
|
|
139
|
+
anchors: ReadonlySet<string>
|
|
140
|
+
skills: ReadonlyMap<string, ReadonlySet<string>>
|
|
141
|
+
agents: ReadonlyMap<string, ReadonlySet<string>>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function cleanupHarnessDirs(proj: string): void {
|
|
145
|
+
const roots = new Set([proj, mainCheckout(proj)])
|
|
146
|
+
const dirs = HARNESSES.flatMap((h) => {
|
|
147
|
+
const anchor = h.worktreeHookAnchor(proj)
|
|
148
|
+
return [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
|
|
149
|
+
}).filter((d): d is string => !!d)
|
|
150
|
+
for (const d of [...new Set([...dirs, ...dirs.map((dir) => dirname(dir))])]
|
|
151
|
+
.filter((dir) => !roots.has(dir)).sort((a, b) => b.length - a.length)) {
|
|
152
|
+
try { rmdirSync(d) } catch {}
|
|
135
153
|
}
|
|
136
154
|
}
|
|
137
155
|
|
|
156
|
+
function reconcileTree(proj: string, targets: TreeTargets, tracked: (file: string) => boolean): void {
|
|
157
|
+
let removed = false
|
|
158
|
+
const contractFiles = new Set(HARNESSES.flatMap((h) => h.contractFiles(proj)))
|
|
159
|
+
for (const file of contractFiles) {
|
|
160
|
+
if (targets.contracts.has(file) || !existsSync(file)) continue
|
|
161
|
+
const text = readFileSync(file, 'utf8')
|
|
162
|
+
if (!text.includes('<!-- spexcode:start -->')) continue
|
|
163
|
+
removeManagedBlock(file, ['<!-- ', ' -->'], !tracked(file))
|
|
164
|
+
removed = true
|
|
165
|
+
}
|
|
166
|
+
const treeShims = new Set(HARNESSES.filter((h) => h.shimScope === 'tree').map((h) => h.shimFile(proj)))
|
|
167
|
+
const anchors = new Set(HARNESSES.map((h) => h.worktreeHookAnchor(proj)).filter((path): path is string => !!path))
|
|
168
|
+
for (const file of [...treeShims, ...anchors]) {
|
|
169
|
+
if (targets.treeShims.has(file) || targets.anchors.has(file) || !existsSync(file)) continue
|
|
170
|
+
if (readFileSync(file, 'utf8').includes('dispatch.sh')) { rmSync(file, { force: true }); removed = true }
|
|
171
|
+
}
|
|
172
|
+
for (const dir of new Set(HARNESSES.map((h) => h.skillDir(proj)).filter((path): path is string => !!path)))
|
|
173
|
+
removed = pruneGeneratedSkills(dir, targets.skills.get(dir) ?? new Set()) || removed
|
|
174
|
+
for (const dir of new Set(HARNESSES.map((h) => h.agentDir(proj)).filter((path): path is string => !!path)))
|
|
175
|
+
removed = pruneGeneratedAgents(dir, targets.agents.get(dir) ?? new Set()) || removed
|
|
176
|
+
if (removed) cleanupHarnessDirs(proj)
|
|
177
|
+
}
|
|
178
|
+
|
|
138
179
|
function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolean): void {
|
|
139
180
|
for (const h of HARNESSES) {
|
|
140
181
|
// h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
|
|
141
182
|
// stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
|
|
142
183
|
h.clean(proj, arts, preserveProject)
|
|
143
|
-
for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f)
|
|
144
|
-
|
|
145
|
-
|
|
184
|
+
for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f)
|
|
185
|
+
pruneGeneratedSkills(h.skillDir(proj), new Set())
|
|
186
|
+
pruneGeneratedAgents(h.agentDir(proj), new Set())
|
|
146
187
|
}
|
|
147
188
|
// same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
|
|
148
189
|
// generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
|
|
149
190
|
removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
|
|
150
|
-
|
|
191
|
+
clearContractFilterPayload(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
|
|
151
192
|
// the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
|
|
152
193
|
// and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
|
|
153
194
|
// unstaged edit is never staged ([[content-filter]] edge 2).
|
|
@@ -157,16 +198,7 @@ function eraseTree(proj: string, arts: HarnessArtifacts, preserveProject: boolea
|
|
|
157
198
|
// because a harness may nest its shim a level below its home (opencode's .opencode/plugins/, pi's
|
|
158
199
|
// .pi/extensions/) — but never the checkout roots themselves. rmdirSync is NON-recursive, so a dir holding
|
|
159
200
|
// 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
|
-
}
|
|
201
|
+
cleanupHarnessDirs(proj)
|
|
170
202
|
}
|
|
171
203
|
|
|
172
204
|
export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
|
|
@@ -177,7 +209,7 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
|
|
|
177
209
|
git(['-C', tree, 'rev-parse', '--show-toplevel'])
|
|
178
210
|
}
|
|
179
211
|
for (const tree of trees) {
|
|
180
|
-
// Only the caller's live spec may widen the
|
|
212
|
+
// Only the caller's live spec may widen the name sweep. Siblings are identity-stamp-only: the
|
|
181
213
|
// same name there may be user-owned or may not exist in this tree's divergent spec at all.
|
|
182
214
|
eraseTree(tree, tree === current ? arts : { skills: [], agents: [] }, false)
|
|
183
215
|
}
|
|
@@ -196,7 +228,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
196
228
|
}
|
|
197
229
|
// (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
|
|
198
230
|
const manifest = join(rt, 'hooks-manifest')
|
|
199
|
-
|
|
231
|
+
writeFileIfChanged(manifest, compileManifest())
|
|
200
232
|
record('hook manifest', manifest)
|
|
201
233
|
// (2) the contract = the surface:system plugin bodies (in name order), written WHOLE into EACH harness's
|
|
202
234
|
// contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
|
|
@@ -213,14 +245,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
213
245
|
const skillNodes = loadSkillConfig()
|
|
214
246
|
const agentNodes = loadAgentConfig()
|
|
215
247
|
const commandNodes = loadConfig()
|
|
216
|
-
const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
|
|
217
248
|
|
|
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
249
|
// a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
|
|
225
250
|
// over the body instructions, closed by the GENERATED_MARK identity stamp (what the erase phase keys on).
|
|
226
251
|
// One pure artifact builder shared by every harness — divergence is only its skillDir.
|
|
@@ -239,53 +264,80 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
239
264
|
// because their residence is the live three-state kind detection below, not a static entry.
|
|
240
265
|
const artifactPaths: string[] = []
|
|
241
266
|
const machinePaths: string[] = []
|
|
242
|
-
const
|
|
267
|
+
const contractTargets = new Map<string, string>()
|
|
268
|
+
const treeShimTargets = new Map<string, string>()
|
|
269
|
+
const anchorTargets = new Map<string, string>()
|
|
270
|
+
const skillTargets = new Map<string, string>()
|
|
271
|
+
const agentTargets = new Map<string, string>()
|
|
272
|
+
const skillsByDir = new Map<string, Set<string>>()
|
|
273
|
+
const agentsByDir = new Map<string, Set<string>>()
|
|
274
|
+
const addTarget = (targets: Map<string, string>, path: string, content: string) => {
|
|
275
|
+
const prior = targets.get(path)
|
|
276
|
+
if (prior !== undefined && prior !== content) throw new Error(`conflicting materialize targets for ${path}`)
|
|
277
|
+
targets.set(path, content)
|
|
278
|
+
}
|
|
243
279
|
for (const h of selected) {
|
|
244
|
-
if (contract) for (const f of h.contractFiles(proj))
|
|
280
|
+
if (contract) for (const f of h.contractFiles(proj)) addTarget(contractTargets, f, contract)
|
|
245
281
|
const shim = h.shim(DISPATCH, SPEX)
|
|
246
282
|
if (h.shimScope === 'tree') {
|
|
247
|
-
|
|
248
|
-
mkdirSync(dirname(shimFile), { recursive: true })
|
|
249
|
-
writeFileSync(shimFile, shim.content)
|
|
250
|
-
record('shim', shimFile)
|
|
251
|
-
machinePaths.push(shimFile)
|
|
283
|
+
addTarget(treeShimTargets, h.shimFile(proj), shim.content)
|
|
252
284
|
}
|
|
253
285
|
// a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
|
|
254
286
|
// checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
|
|
255
287
|
const anchor = h.worktreeHookAnchor(proj)
|
|
256
|
-
if (anchor)
|
|
288
|
+
if (anchor) addTarget(anchorTargets, anchor, shim.content)
|
|
289
|
+
}
|
|
290
|
+
for (const sk of skillNodes) for (const h of selected) {
|
|
291
|
+
const dir = h.skillDir(proj); if (!dir) continue
|
|
292
|
+
addTarget(skillTargets, join(dir, sk.name, 'SKILL.md'), skillArtifact(sk))
|
|
293
|
+
const names = skillsByDir.get(dir) ?? new Set<string>(); names.add(sk.name); skillsByDir.set(dir, names)
|
|
294
|
+
}
|
|
295
|
+
for (const ag of agentNodes) for (const h of selected) {
|
|
296
|
+
const dir = h.agentDir(proj); if (!dir) continue
|
|
297
|
+
addTarget(agentTargets, join(dir, `${ag.name}.md`), agentArtifact(ag))
|
|
298
|
+
const names = agentsByDir.get(dir) ?? new Set<string>(); names.add(ag.name); agentsByDir.set(dir, names)
|
|
299
|
+
}
|
|
300
|
+
const tracked = new Map<string, boolean>()
|
|
301
|
+
const isTrackedHere = (file: string) => {
|
|
302
|
+
const known = tracked.get(file)
|
|
303
|
+
if (known !== undefined) return known
|
|
304
|
+
const value = isTracked(proj, file); tracked.set(file, value); return value
|
|
305
|
+
}
|
|
306
|
+
reconcileTree(proj, {
|
|
307
|
+
contracts: new Set(contractTargets.keys()), treeShims: new Set(treeShimTargets.keys()), anchors: new Set(anchorTargets.keys()),
|
|
308
|
+
skills: skillsByDir, agents: agentsByDir,
|
|
309
|
+
}, isTrackedHere)
|
|
310
|
+
const changedMaterialized = new Set<string>()
|
|
311
|
+
for (const [file, content] of contractTargets) {
|
|
312
|
+
if (writeManagedBlock(file, content)) changedMaterialized.add(file)
|
|
313
|
+
record('contract', file)
|
|
314
|
+
}
|
|
315
|
+
const contractPaths = [...contractTargets.keys()]
|
|
316
|
+
for (const [file, content] of treeShimTargets) {
|
|
317
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
318
|
+
record('shim', file); machinePaths.push(file)
|
|
319
|
+
}
|
|
320
|
+
for (const [file, content] of anchorTargets) {
|
|
321
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
322
|
+
record('shim', file); machinePaths.push(file)
|
|
257
323
|
}
|
|
258
324
|
const selectedByDispatch = new Map(selected.map((h) => [h.dispatchId, h]))
|
|
259
325
|
for (const h of selectedByDispatch.values()) {
|
|
260
326
|
const shim = h.shim(DISPATCH, SPEX)
|
|
261
327
|
if (h.shimScope === 'project') {
|
|
262
328
|
const file = h.shimFile(proj)
|
|
263
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
329
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, shim.content)
|
|
264
330
|
record('shim', file)
|
|
265
331
|
}
|
|
266
332
|
for (const file of h.writeTrust(proj, shim.cmd)) record('trust', file)
|
|
267
333
|
}
|
|
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
|
-
}
|
|
334
|
+
for (const [file, content] of skillTargets) {
|
|
335
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
336
|
+
artifactPaths.push(file); record('skill', file)
|
|
279
337
|
}
|
|
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
|
-
}
|
|
338
|
+
for (const [file, content] of agentTargets) {
|
|
339
|
+
mkdirSync(dirname(file), { recursive: true }); writeFileIfChanged(file, content)
|
|
340
|
+
artifactPaths.push(file); record('agent', file)
|
|
289
341
|
}
|
|
290
342
|
// (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
|
|
291
343
|
// bundle per selected folder. A plugin is EXCLUSIVE (`selected` is empty then). Pruning a DESELECTED
|
|
@@ -294,11 +346,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
294
346
|
// global store records the folders emitted last run; any prev folder absent from the current set is
|
|
295
347
|
// clean()ed, then the current folders are emitted and the ledger rewritten.
|
|
296
348
|
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) : []
|
|
349
|
+
const prevFolders = existsSync(ledger) ? readFileSync(ledger, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
|
|
302
350
|
const curFolders = plugins.map((p) => p.folder)
|
|
303
351
|
for (const f of prevFolders) if (!curFolders.includes(f)) cleanPlugin(proj, f)
|
|
304
352
|
if (plugins.length) {
|
|
@@ -315,7 +363,7 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
315
363
|
record('plugin bundle', pluginBundleDir(proj, p.folder))
|
|
316
364
|
}
|
|
317
365
|
}
|
|
318
|
-
|
|
366
|
+
writeFileIfChanged(ledger, curFolders.join('\n'))
|
|
319
367
|
// (9) ignore + mixed text. Only checkout-invariant residue and project-shared shims belong in the COMMON
|
|
320
368
|
// info/exclude; selection-dependent paths live in this tree's filtered working .gitignore.
|
|
321
369
|
const mc = mainCheckout(proj)
|
|
@@ -327,43 +375,38 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
327
375
|
'spexcode.local.json', '.worktrees/', '.session',
|
|
328
376
|
]
|
|
329
377
|
const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
|
|
330
|
-
const priorCommonEntries = managedExcludeEntries(infoExcludePath(proj))
|
|
331
|
-
|
|
332
378
|
// Contract residence stays a live fact. Selection-dependent untracked products are ignored by this tree's
|
|
333
379
|
// working .gitignore, whose own managed block is filtered when the host tracks/owns that file.
|
|
334
380
|
const filterContracts: string[] = []
|
|
335
381
|
const oursContracts: string[] = []
|
|
336
382
|
for (const f of contractPaths) {
|
|
337
|
-
if (
|
|
383
|
+
if (isTrackedHere(f) || hostContentOf(f).trim()) filterContracts.push(f)
|
|
338
384
|
else oursContracts.push(f)
|
|
339
385
|
}
|
|
340
386
|
const localEntries = [...machinePaths, ...bundlePaths, ...artifactPaths, ...oursContracts]
|
|
341
387
|
.map((p) => relative(proj, p)).filter((p) => !p.startsWith('..'))
|
|
342
388
|
const ignoreFile = join(proj, '.gitignore')
|
|
343
|
-
const ignoreTracked =
|
|
389
|
+
const ignoreTracked = isTrackedHere(ignoreFile)
|
|
344
390
|
const ignoreHost = existsSync(ignoreFile) ? readFileSync(ignoreFile, 'utf8') : ''
|
|
345
391
|
if (!ignoreTracked && !ignoreHost.trim()) localEntries.push('.gitignore')
|
|
346
392
|
const ignoreBody = entries(localEntries)
|
|
347
|
-
writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])
|
|
393
|
+
if (writeManagedBlock(ignoreFile, ignoreBody, ['# ', ''])) changedMaterialized.add(ignoreFile)
|
|
348
394
|
|
|
349
395
|
const payloads: ContractFilterPayload[] = filterContracts.map((file) => ({ file: relative(proj, file), content: contract }))
|
|
350
396
|
if (ignoreTracked || ignoreHost.trim()) payloads.push({ file: '.gitignore', content: ignoreBody })
|
|
351
397
|
const bindings: ContractFilterBinding[] = [
|
|
352
398
|
...[...new Set(HARNESSES.flatMap((h) => h.contractFiles(proj).map((file) => relative(proj, file))))]
|
|
353
|
-
.map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->'
|
|
399
|
+
.map((file) => ({ file, start: '<!-- spexcode:start -->', end: '<!-- spexcode:end -->' })),
|
|
354
400
|
{ file: '.gitignore', start: '# spexcode:start', end: '# spexcode:end' },
|
|
355
401
|
]
|
|
356
|
-
if (payloads.length) plantContractFilter(proj, payloads, bindings)
|
|
357
|
-
|
|
402
|
+
if (payloads.length) plantContractFilter(proj, payloads, bindings, [...changedMaterialized])
|
|
403
|
+
else if (contractFilterPlanted(proj)) removeContractFilter(proj, [...HARNESSES.flatMap((h) => h.contractFiles(proj)), join(proj, '.gitignore')])
|
|
404
|
+
// (5) finish diagnostics, then atomically publish the allowlist LAST. Dispatch consumes only that
|
|
358
405
|
// final receipt; a killed writer leaves the preceding successful selection intact.
|
|
359
406
|
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]), ['# ', ''])
|
|
407
|
+
writeFileIfChanged(join(rt, 'content-hash'), h)
|
|
408
|
+
writeFileIfChanged(join(runtimeRoot(proj), 'harness-selection-v1'), '')
|
|
409
|
+
writeManagedBlock(infoExcludePath(proj), entries(commonEntries), ['# ', ''])
|
|
367
410
|
publishSelection(join(rt, 'harnesses'), selectionBody(selected, plugins.length > 0))
|
|
368
411
|
return { contentHash: h, planted }
|
|
369
412
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
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
7
|
// Claude-plugin bundle dropped into the host-agent-scanned folder [[harness-select]] resolved (e.g. `.zcode` /
|
|
@@ -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(
|