spexcode 0.2.4 → 0.2.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/bin/spex.mjs +13 -10
- package/spec-cli/hooks/harness.sh +32 -6
- package/spec-cli/src/board.ts +2 -1
- package/spec-cli/src/boardCache.ts +27 -1
- package/spec-cli/src/boardStream.ts +5 -4
- package/spec-cli/src/cli.ts +82 -19
- package/spec-cli/src/contract-filter.ts +154 -0
- package/spec-cli/src/git.ts +18 -4
- package/spec-cli/src/guide.ts +91 -38
- package/spec-cli/src/harness.ts +18 -4
- package/spec-cli/src/help.ts +16 -7
- package/spec-cli/src/index.ts +9 -6
- package/spec-cli/src/init.ts +52 -10
- package/spec-cli/src/issues.ts +7 -5
- package/spec-cli/src/layout.ts +20 -8
- package/spec-cli/src/lint.ts +19 -0
- package/spec-cli/src/localIssues.ts +16 -4
- package/spec-cli/src/materialize.ts +218 -124
- package/spec-cli/src/ranker.ts +25 -8
- package/spec-cli/src/runtime-guard.ts +44 -0
- package/spec-cli/src/search.bench.mjs +15 -5
- package/spec-cli/src/sessions.ts +81 -16
- package/spec-cli/src/specs.ts +29 -14
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tsx-bin.ts +6 -8
- package/spec-cli/src/uninstall.ts +15 -17
- package/spec-cli/src/worktree-sources.ts +50 -13
- package/spec-cli/templates/spec/project/.config/core/stop-gate/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +26 -8
- package/spec-dashboard/dist/assets/Dashboard-CMRJGfYI.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-Dj2mxcfW.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-B5leylLf.js → FoldToggle-BfNpeyRQ.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-DfY315kt.js +1 -0
- package/spec-dashboard/dist/assets/{MobileApp-RHNECU6x.js → MobileApp-BGdC0A0P.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-YLD6IOmC.js → SessionInterface-BOBCAR0t.js} +5 -5
- package/spec-dashboard/dist/assets/SessionWindow-D7YmjV0i.js +9 -0
- package/spec-dashboard/dist/assets/{Settings-ZnOwskMZ.js → Settings-BlSNmpH_.js} +1 -1
- package/spec-dashboard/dist/assets/{index-BdRQfrkR.js → index-BFdzpd_O.js} +2 -2
- package/spec-dashboard/dist/assets/index-uGs9v_9o.css +1 -0
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-forge/src/cli.ts +2 -2
- package/spec-forge/src/drivers/gitlab.ts +168 -0
- package/spec-forge/src/drivers.ts +80 -2
- package/spec-forge/src/resident.ts +10 -5
- package/spec-yatsu/src/cli.ts +37 -16
- package/spec-yatsu/src/evaltab.ts +6 -3
- package/spec-yatsu/src/filing.ts +13 -8
- package/spec-yatsu/src/freshness.ts +97 -22
- package/spec-yatsu/src/proof.ts +14 -3
- package/spec-yatsu/src/scenariofresh.ts +38 -11
- package/spec-yatsu/src/yatsu.ts +52 -28
- package/spec-dashboard/dist/assets/Dashboard-Dlg78cbC.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-CDxc1-in.js +0 -3
- package/spec-dashboard/dist/assets/IssuesPage-C2yFXiO-.js +0 -1
- package/spec-dashboard/dist/assets/SessionWindow-CmKtpNUX.js +0 -9
- package/spec-dashboard/dist/assets/index-DEc5Ru3l.css +0 -1
|
@@ -1,27 +1,33 @@
|
|
|
1
|
-
import { writeFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs'
|
|
1
|
+
import { writeFileSync, mkdirSync, readFileSync, existsSync, readdirSync, 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'
|
|
5
5
|
import { loadSystemConfig, loadSkillConfig, loadAgentConfig, loadConfig } from './specs.js'
|
|
6
6
|
import { compileManifest } from './hooks.js'
|
|
7
|
-
import { writeManagedBlock, removeManagedBlock, type HarnessArtifacts } from './harness.js'
|
|
7
|
+
import { writeManagedBlock, removeManagedBlock, HARNESSES, type HarnessArtifacts } from './harness.js'
|
|
8
8
|
import { git } from './git.js'
|
|
9
9
|
import { runtimeRoot, 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, settleIndexStat } from './contract-filter.js'
|
|
12
13
|
|
|
13
14
|
// @@@ materialize - the "pay-per-change" node step (≈0.85s) the cheap shell gate invokes ONLY when the
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
15
|
+
// content-hash moved. It renders the spec tree's surface nodes into the flat artifacts each consumer reads
|
|
16
|
+
// cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the whole system via
|
|
17
|
+
// harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it), (2) the CONTRACT — the
|
|
18
|
+
// tracked docs guide (docs/AGENT_GUIDE.md) FOLLOWED BY the surface:system bodies — written WHOLE into each
|
|
19
|
+
// harness's contract file(s), (3) the thin SHIMS (every event → dispatch.sh), (4) the per-harness TRUST
|
|
20
|
+
// (Codex's deterministic trusted_hash; Claude none). EVERY harness-specific fact is owned by the
|
|
21
|
+
// [[harness-adapter]] (harness.ts) — this file just loops over HARNESSES.
|
|
22
|
+
//
|
|
23
|
+
// THE FORGETTING LAW ([[harness-delivery]]): materialize(P₂) ∘ materialize(P₁) = materialize(P₂) — a render
|
|
24
|
+
// under the current policy fully forgets every prior policy's artifacts; idempotence is the special case
|
|
25
|
+
// P₂ = P₁, and dematerialize (= materialize(∅), what `spex uninstall` builds on) is the empty policy. The
|
|
26
|
+
// implementation is ERASE-THEN-ASSERT over a CLOSED set of landing points: each is first erased
|
|
27
|
+
// unconditionally by its IDENTITY STAMP (sentinel blocks, the shim's dispatch.sh command line, the generated
|
|
28
|
+
// mark on skills/agents, the filter config namespace, the skip-worktree bit), then rewritten per the current
|
|
29
|
+
// policy (possibly to nothing). No ledger of past states, no pairwise migration branches — the erase IS the
|
|
30
|
+
// migration, whatever the previous state was.
|
|
25
31
|
|
|
26
32
|
const PKG = fileURLToPath(new URL('..', import.meta.url)) // installed spec-cli root
|
|
27
33
|
const DISPATCH = join(PKG, 'hooks', 'dispatch.sh')
|
|
@@ -33,9 +39,11 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
|
33
39
|
// worktree — the worktree keeps zero SpexCode-rendered runtime; only the harness-discovered contract files +
|
|
34
40
|
// shims (which the harness must find in-tree) are written under proj below.
|
|
35
41
|
|
|
36
|
-
// the deterministic content fingerprint of the config roots. ONE definition —
|
|
37
|
-
// mirror (harness.sh) — which the dispatch.sh gate ALSO calls, so the gate and
|
|
38
|
-
//
|
|
42
|
+
// the deterministic content fingerprint of the config roots + THE RENDERER ITSELF. ONE definition —
|
|
43
|
+
// `hp_config_hash` in the shell mirror (harness.sh) — which the dispatch.sh gate ALSO calls, so the gate and
|
|
44
|
+
// this renderer can never disagree on "changed". It folds in hp_renderer_version (the toolchain-side content
|
|
45
|
+
// hash), so a TOOLCHAIN update moves the key and the next gate self-heals the rendered artifacts — closing
|
|
46
|
+
// the "toolchain updated but nothing re-materialized" hole ([[harness-delivery]]).
|
|
39
47
|
export function contentHash(proj: string): string {
|
|
40
48
|
try {
|
|
41
49
|
const harnessSh = join(PKG, 'hooks', 'harness.sh')
|
|
@@ -43,15 +51,45 @@ export function contentHash(proj: string): string {
|
|
|
43
51
|
} catch { return '' }
|
|
44
52
|
}
|
|
45
53
|
|
|
46
|
-
// @@@
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// ignore
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
54
|
+
// @@@ render policy ([[render-policy]]) - ONE share axis, voted only for the machine-independent RENDERS.
|
|
55
|
+
// `.spec` + `spexcode.json` are ALWAYS tracked (git is the database — no knob can untrack them); machine
|
|
56
|
+
// facts (shims, spexcode.local.json) are ALWAYS ignored; run residue (.worktrees/) is always ignored. The
|
|
57
|
+
// vote decides where the renders (contract blocks, skills, agents) sit — and, recursively, where their
|
|
58
|
+
// ignore rules live: `committed` drops the render entries from the ignore block (the renders are ordinary
|
|
59
|
+
// committed files), `ignored` (default) keeps the block in the TRACKED .gitignore, `hidden` moves the whole
|
|
60
|
+
// block to the per-clone .git/info/exclude and covers a HOST-TRACKED contract file with the clean/smudge
|
|
61
|
+
// content filter ([[content-filter]]) instead of the retired skip-worktree bit.
|
|
62
|
+
export type RenderPolicy = 'committed' | 'ignored' | 'hidden'
|
|
63
|
+
export function resolveRenderPolicy(cfg: { render?: string; private?: boolean }, proj?: string): RenderPolicy {
|
|
64
|
+
const r = cfg.render?.trim()
|
|
65
|
+
if (r) {
|
|
66
|
+
if (r !== 'committed' && r !== 'ignored' && r !== 'hidden') {
|
|
67
|
+
const err = new Error(`invalid render policy '${r}' — the render axis has three words: committed | ignored | hidden (see \`spex guide footprint\`)`)
|
|
68
|
+
err.name = 'ConfigError'
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
return r
|
|
72
|
+
}
|
|
73
|
+
if (cfg.private) {
|
|
74
|
+
// LEGACY private:true — the retired untrack-private mode. Its render half maps to 'hidden'; its
|
|
75
|
+
// data-untrack half is GONE (the spec sources are always tracked now). Loud, non-fatal: the deployment
|
|
76
|
+
// keeps working while the notice names the two migration moves.
|
|
77
|
+
let untracked: string[] = []
|
|
78
|
+
if (proj) untracked = ['.spec', 'spexcode.json'].filter((p) => existsSync(join(proj, p)) && !isTracked(proj, p))
|
|
79
|
+
console.error(
|
|
80
|
+
`spexcode: \`private: true\` is retired — reading it as \`"render": "hidden"\`.\n` +
|
|
81
|
+
` → migrate spexcode.local.json: replace "private": true with "render": "hidden".` +
|
|
82
|
+
(untracked.length
|
|
83
|
+
? `\n → your spec sources are still untracked (${untracked.join(' + ')}); the untrack-private mode is gone, so track them once:\n` +
|
|
84
|
+
` git add ${untracked.join(' ')} (then commit on your branch)\n` +
|
|
85
|
+
` WARNING: tracking is not retroactive secrecy — history already pushed elsewhere cannot be recalled.`
|
|
86
|
+
: ''),
|
|
87
|
+
)
|
|
88
|
+
return 'hidden'
|
|
89
|
+
}
|
|
90
|
+
return 'ignored'
|
|
91
|
+
}
|
|
92
|
+
|
|
55
93
|
function gitCommonDirOf(proj: string): string {
|
|
56
94
|
return git(['-C', proj, 'rev-parse', '--path-format=absolute', '--git-common-dir']).trim()
|
|
57
95
|
}
|
|
@@ -61,11 +99,97 @@ function infoExcludePath(proj: string): string {
|
|
|
61
99
|
function isTracked(proj: string, file: string): boolean {
|
|
62
100
|
try { git(['-C', proj, 'ls-files', '--error-unmatch', file]); return true } catch { return false }
|
|
63
101
|
}
|
|
64
|
-
|
|
65
|
-
//
|
|
66
|
-
|
|
102
|
+
|
|
103
|
+
// @@@ adoption vote hint ([[render-policy]] story 4 + 21) - the one-time decision guidance for the "mystery M"
|
|
104
|
+
// moment: under the DEFAULT policy a host-TRACKED contract file (a team's own CLAUDE.md/AGENTS.md) carries the
|
|
105
|
+
// generated block and shows honestly dirty in status — deliberate, but a first-minute adopter just sees an
|
|
106
|
+
// unexplained modification. So the HUMAN surfaces (spex init + the manual `spex materialize` verb — never the
|
|
107
|
+
// silent gate/bootstrap renders) print this guidance exactly while the vote is still open: only when (a) no
|
|
108
|
+
// explicit `render` is set (the default `ignored` is in effect by omission, not by choice — an explicit vote,
|
|
109
|
+
// any word, retires the hint) and (b) a SELECTED harness's contract file is actually host-tracked. Mechanism,
|
|
110
|
+
// not interaction: plain stdout naming the three words, their consequences, and where the vote lives — init is
|
|
111
|
+
// routinely run by agents, so there is no TUI prompt to hang on.
|
|
112
|
+
export function renderVoteHint(proj = process.cwd()): string | null {
|
|
113
|
+
const cfg = readConfig(mainCheckout(proj))
|
|
114
|
+
if (cfg.render?.trim() || cfg.private) return null // an explicit vote (or the legacy mapping) — decided
|
|
115
|
+
const { selected } = partitionHarnesses(resolveHarnessTargets(cfg.harnesses))
|
|
116
|
+
const tracked = selected.flatMap((h) => h.contractFiles(proj)).filter((f) => isTracked(proj, f))
|
|
117
|
+
if (!tracked.length) return null
|
|
118
|
+
const names = tracked.map((f) => relative(proj, f)).join(' + ')
|
|
119
|
+
const [be, carry] = tracked.length > 1 ? ['are', 'carry'] : ['is', 'carries']
|
|
120
|
+
return [
|
|
121
|
+
`note: ${names} ${be} tracked by this repo and now ${carry} the generated <!-- spexcode --> block — the`,
|
|
122
|
+
`modification you see in git status. That dirtiness is HONEST under the current default (render: ignored),`,
|
|
123
|
+
`kept visible on purpose until you vote where the generated renders should live:`,
|
|
124
|
+
` committed renders become ordinary committed files — teammates/CI get the contract natively`,
|
|
125
|
+
` ignored (current default) renders stay generated+gitignored; a tracked contract file stays dirty`,
|
|
126
|
+
` hidden zero repo footprint — ignore rules live in .git/info/exclude and the tracked file is`,
|
|
127
|
+
` covered by the clean/smudge filter (index stays pristine, status clean)`,
|
|
128
|
+
`Vote once and this note retires: "render": "committed" | "ignored" in spexcode.json (a project fact) or`,
|
|
129
|
+
`"render": "hidden" in spexcode.local.json (a host fact) — or adopt in one step: spex init --render <word>.`,
|
|
130
|
+
`Full model: spex guide footprint.`,
|
|
131
|
+
].join('\n')
|
|
132
|
+
}
|
|
133
|
+
// clear a legacy skip-worktree bit (the retired private-overlay mechanism; erase-only now — nothing asserts
|
|
134
|
+
// it). Best-effort: an index race or a non-repo must not fail the render.
|
|
135
|
+
function clearSkipWorktree(proj: string, file: string): void {
|
|
67
136
|
if (!isTracked(proj, file)) return
|
|
68
|
-
try { git(['-C', proj, 'update-index',
|
|
137
|
+
try { git(['-C', proj, 'update-index', '--no-skip-worktree', file]) } catch { /* best-effort */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// the identity stamp on every generated skill/agent file — what lets the erase phase forget a product whose
|
|
141
|
+
// NODE was renamed or deleted (the name-scoped sweep can only reconstruct paths the LIVE config still names).
|
|
142
|
+
const GENERATED_MARK = '<!-- spexcode:generated -->'
|
|
143
|
+
function sweepGeneratedSkills(dir: string | null): void {
|
|
144
|
+
if (!dir || !existsSync(dir)) return
|
|
145
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
146
|
+
if (!e.isDirectory()) continue
|
|
147
|
+
const f = join(dir, e.name, 'SKILL.md')
|
|
148
|
+
try { if (existsSync(f) && readFileSync(f, 'utf8').includes(GENERATED_MARK)) rmSync(join(dir, e.name), { recursive: true, force: true }) } catch { /* unreadable → not provably ours */ }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function sweepGeneratedAgents(dir: string | null): void {
|
|
152
|
+
if (!dir || !existsSync(dir)) return
|
|
153
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
154
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue
|
|
155
|
+
const f = join(dir, e.name)
|
|
156
|
+
try { if (readFileSync(f, 'utf8').includes(GENERATED_MARK)) rmSync(f, { force: true }) } catch { /* unreadable → not provably ours */ }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// @@@ dematerialize - materialize(∅): the ERASE phase, also the whole of a backout ([[spex-uninstall]] adds
|
|
161
|
+
// only the global store + plugin sweep + optional git hooks on top). Every removal is gated on an identity
|
|
162
|
+
// stamp, so it deletes ONLY what a render wrote — never the user's prose, settings, or any .spec data. Order
|
|
163
|
+
// matters once: the managed blocks leave the WORKING contract files before the content filter's config goes
|
|
164
|
+
// (edge ③ in [[content-filter]] — a block outliving its clean filter surfaces as an uncommitted change).
|
|
165
|
+
// `arts` (live skill/agent node names) widens the sweep to pre-stamp legacy files; the GENERATED_MARK sweep
|
|
166
|
+
// covers everything rendered since, including products of renamed/deleted nodes.
|
|
167
|
+
export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { skills: [], agents: [] }): void {
|
|
168
|
+
for (const h of HARNESSES) {
|
|
169
|
+
// h.clean = the adapter's surgical inverse: contract block (sentinels, deleteIfEmpty), the dispatch.sh-
|
|
170
|
+
// stamped shim + worktree anchor, the trust block, and the arts-named skill/agent files.
|
|
171
|
+
h.clean(proj, arts)
|
|
172
|
+
for (const f of h.contractFiles(proj)) clearSkipWorktree(proj, f) // legacy private-overlay bit — erase-only
|
|
173
|
+
sweepGeneratedSkills(h.skillDir(proj))
|
|
174
|
+
sweepGeneratedAgents(h.agentDir(proj))
|
|
175
|
+
}
|
|
176
|
+
// same authorship rule as the contract files: deleteIfEmpty only when .gitignore is UNTRACKED (wholly-ours
|
|
177
|
+
// generated file); a HOST-TRACKED .gitignore that carried nothing but our block is stripped, never deleted.
|
|
178
|
+
removeManagedBlock(join(proj, '.gitignore'), ['# ', ''], !isTracked(proj, '.gitignore'))
|
|
179
|
+
try { removeManagedBlock(infoExcludePath(proj), ['# ', ''], false) } catch { /* not a git repo */ }
|
|
180
|
+
removeContractFilter(proj) // AFTER the blocks left the working files
|
|
181
|
+
// the block-strip left tracked contract files stat-dirty (under a filter git NEVER content-verifies them,
|
|
182
|
+
// and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
|
|
183
|
+
// unstaged edit is never staged ([[content-filter]] edge 2).
|
|
184
|
+
try { settleIndexStat(proj, HARNESSES.flatMap((h) => h.contractFiles(proj))) } catch { /* not a git repo */ }
|
|
185
|
+
// leaving nothing behind: drop the now-EMPTY dirs the assert phase mkdir'ed (.claude/.codex and their
|
|
186
|
+
// skills/agents subdirs — children listed before parents). rmdirSync is NON-recursive, so a dir holding
|
|
187
|
+
// any user file survives untouched; `.git/spexcode/` is deliberately NOT swept (shared per-clone home).
|
|
188
|
+
for (const h of HARNESSES) {
|
|
189
|
+
const anchor = h.worktreeHookAnchor(proj)
|
|
190
|
+
for (const d of [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null])
|
|
191
|
+
if (d) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
|
|
192
|
+
}
|
|
69
193
|
}
|
|
70
194
|
|
|
71
195
|
// the whole pay-per-change render. proj defaults to cwd. Returns the new content-hash it stamped.
|
|
@@ -77,93 +201,85 @@ export function materialize(proj = process.cwd()): string {
|
|
|
77
201
|
// (2) the contract = the tracked docs guide (the hand-written agent/contributor notes — the ONE piece of
|
|
78
202
|
// in-tree prose) FOLLOWED BY the surface:system bodies (in name order), written WHOLE into EACH harness's
|
|
79
203
|
// contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
|
|
80
|
-
// The contract files are generated artifacts (gitignored below), so the guide is the single source a
|
|
81
|
-
// self-launched agent reads from — assembling it in keeps guide + contract reaching the agent together.
|
|
82
204
|
const guidePath = join(proj, 'docs', 'AGENT_GUIDE.md')
|
|
83
205
|
const guide = existsSync(guidePath) ? readFileSync(guidePath, 'utf8').trim() : ''
|
|
84
206
|
const systemBodies = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean)
|
|
85
207
|
const contract = [guide, ...systemBodies].filter(Boolean).join('\n\n')
|
|
86
208
|
// WHICH harnesses to deliver into ([[harness-select]]): the spexcode.json `harnesses` set (default = every
|
|
87
209
|
// native harness). resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
|
|
88
|
-
// selected harnesses are write()n below; unselected ones are clean()ed (pruned) after — so dropping a harness
|
|
89
|
-
// from the config removes its products on the next re-materialize. The plugin EMITTER is a later node.
|
|
90
210
|
const cfg = readConfig(mainCheckout(proj))
|
|
91
211
|
const targets = resolveHarnessTargets(cfg.harnesses)
|
|
92
|
-
const
|
|
93
|
-
const { selected,
|
|
212
|
+
const policy = resolveRenderPolicy(cfg, proj) // [[render-policy]] — committed | ignored | hidden
|
|
213
|
+
const { selected, plugins } = partitionHarnesses(targets)
|
|
94
214
|
const skillNodes = loadSkillConfig()
|
|
95
215
|
const agentNodes = loadAgentConfig()
|
|
96
216
|
const commandNodes = loadConfig()
|
|
217
|
+
const arts: HarnessArtifacts = { skills: skillNodes.map((s) => s.name), agents: agentNodes.map((a) => a.name) }
|
|
218
|
+
|
|
219
|
+
// ---- ERASE (the forgetting law): every landing point cleared by identity stamp, whatever policy — or
|
|
220
|
+
// legacy mode — wrote it last. Unselected harnesses need no separate prune branch: the erase already
|
|
221
|
+
// forgot them, and only the selected ones are asserted below.
|
|
222
|
+
dematerialize(proj, arts)
|
|
223
|
+
|
|
224
|
+
// ---- ASSERT: rewrite each landing point per the CURRENT policy.
|
|
97
225
|
// a skill node → the agentskills.io SKILL.md primitive: `name`+`description` frontmatter (the load-trigger)
|
|
98
|
-
// over the body instructions
|
|
226
|
+
// over the body instructions, closed by the GENERATED_MARK identity stamp (what the erase phase keys on).
|
|
227
|
+
// One pure render shared by every harness — divergence is only its skillDir.
|
|
99
228
|
const renderSkill = (sk: { name: string; desc: string; body: string }) =>
|
|
100
|
-
`---\nname: ${sk.name}\ndescription: ${JSON.stringify(sk.desc)}\n---\n\n${sk.body}\n`
|
|
229
|
+
`---\nname: ${sk.name}\ndescription: ${JSON.stringify(sk.desc)}\n---\n\n${sk.body}\n\n${GENERATED_MARK}\n`
|
|
101
230
|
// an agent node → a coding-agent sub-agent definition (the same primitive .claude/agents/*.md ships): the
|
|
102
231
|
// node's `desc` is the on-demand load-trigger, its `tools` the harness tool allowlist, its body the agent's
|
|
103
|
-
// system prompt.
|
|
232
|
+
// system prompt. Same stamp, same reason.
|
|
104
233
|
const renderAgent = (ag: { name: string; desc: string; tools: string[]; body: string }) =>
|
|
105
|
-
`---\nname: ${ag.name}\ndescription: ${ag.desc}\ntools: ${ag.tools.join(', ')}\n---\n\n${ag.body}\n`
|
|
106
|
-
// a command node → a host `/`-menu command file: the
|
|
107
|
-
//
|
|
108
|
-
// the dashboard /api/slash-commands instead, so this render is plugin-only.
|
|
234
|
+
`---\nname: ${ag.name}\ndescription: ${ag.desc}\ntools: ${ag.tools.join(', ')}\n---\n\n${ag.body}\n\n${GENERATED_MARK}\n`
|
|
235
|
+
// a command node → a host `/`-menu command file: plugin-only (the native path serves command presets via
|
|
236
|
+
// the dashboard /api/slash-commands instead).
|
|
109
237
|
const renderCommand = (cm: { desc: string; body: string }) =>
|
|
110
238
|
(cm.desc ? `---\ndescription: ${JSON.stringify(cm.desc)}\n---\n\n` : '') + `${cm.body}\n`
|
|
111
|
-
//
|
|
112
|
-
|
|
239
|
+
// two ignore classes, split because only `committed` treats them differently: RENDERS are machine-
|
|
240
|
+
// independent products the team may choose to commit; MACHINE paths (shims bake this install's abs path,
|
|
241
|
+
// bundles too) are never committable under any policy.
|
|
242
|
+
const renderPaths: string[] = []
|
|
243
|
+
const machinePaths: string[] = []
|
|
113
244
|
for (const h of selected) {
|
|
114
|
-
if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract);
|
|
245
|
+
if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); renderPaths.push(f) }
|
|
115
246
|
const shimFile = h.shimFile(proj)
|
|
116
247
|
mkdirSync(dirname(shimFile), { recursive: true })
|
|
117
248
|
const shim = h.shim(DISPATCH, SPEX)
|
|
118
249
|
writeFileSync(shimFile, shim.json)
|
|
119
250
|
h.writeTrust(proj, shim.cmd)
|
|
120
|
-
|
|
251
|
+
machinePaths.push(shimFile)
|
|
121
252
|
// a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
|
|
122
|
-
// checkout, so the worktree gets no `.codex/` unless we place one
|
|
123
|
-
// thread's hooks if a `.codex/` under the worktree root anchors the project layer). One adapter line; null
|
|
124
|
-
// for a harness whose shim already sits in the worktree (claude) or on the main checkout itself.
|
|
253
|
+
// checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
|
|
125
254
|
const anchor = h.worktreeHookAnchor(proj)
|
|
126
|
-
if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.json);
|
|
255
|
+
if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.json); machinePaths.push(anchor) }
|
|
127
256
|
}
|
|
128
|
-
// (6) skills - each
|
|
129
|
-
// harness
|
|
130
|
-
// join the same managed .gitignore block below. A harness with no skill primitive (skillDir null) is skipped.
|
|
257
|
+
// (6) skills + (7) sub-agents — each surface node → the file the harness auto-discovers, one per selected
|
|
258
|
+
// harness that has the primitive (skillDir/agentDir null skips — the divergence is the adapter's line).
|
|
131
259
|
for (const sk of skillNodes) {
|
|
132
260
|
for (const h of selected) {
|
|
133
261
|
const dir = h.skillDir(proj); if (!dir) continue
|
|
134
262
|
const f = join(dir, sk.name, 'SKILL.md')
|
|
135
263
|
mkdirSync(dirname(f), { recursive: true })
|
|
136
264
|
writeFileSync(f, renderSkill(sk))
|
|
137
|
-
|
|
265
|
+
renderPaths.push(f)
|
|
138
266
|
}
|
|
139
267
|
}
|
|
140
|
-
// (7) sub-agents - each `surface: agent` node → a <name>.md the harness auto-discovers, written into every
|
|
141
|
-
// harness's own agentDir (Claude .claude/agents). The SAME pattern as skills: generated wiring, so the
|
|
142
|
-
// paths join the same managed .gitignore block below. A harness with no agent primitive (agentDir null,
|
|
143
|
-
// e.g. Codex) is skipped — no `if (codex)`, the divergence is the adapter's agentDir line.
|
|
144
268
|
for (const ag of agentNodes) {
|
|
145
269
|
for (const h of selected) {
|
|
146
270
|
const dir = h.agentDir(proj); if (!dir) continue
|
|
147
271
|
const f = join(dir, `${ag.name}.md`)
|
|
148
272
|
mkdirSync(dirname(f), { recursive: true })
|
|
149
273
|
writeFileSync(f, renderAgent(ag))
|
|
150
|
-
|
|
274
|
+
renderPaths.push(f)
|
|
151
275
|
}
|
|
152
276
|
}
|
|
153
|
-
// (8)
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
for (const h of unselected) h.clean(proj, arts)
|
|
160
|
-
// (8b) the PLUGIN target ([[plugin-harness]]): render the whole system into one self-contained Claude-plugin
|
|
161
|
-
// bundle per selected folder. A plugin is EXCLUSIVE (so `selected` is already empty — every native was
|
|
162
|
-
// pruned above). Pruning a DESELECTED plugin folder (plugin→native, or folder A→B) needs the PREVIOUS
|
|
163
|
-
// folder set, which the live config no longer names — so a tiny ledger in the global store records the
|
|
164
|
-
// folders emitted last run; any prev folder absent from the current set is clean()ed (the bundle's
|
|
165
|
-
// inverse), then the current folders are emitted and the ledger rewritten. Bounded + surgical: cleanPlugin
|
|
166
|
-
// is identity-gated on the bundle's own plugin.json.
|
|
277
|
+
// (8) the PLUGIN target ([[plugin-harness]]): render the whole system into one self-contained Claude-plugin
|
|
278
|
+
// bundle per selected folder. A plugin is EXCLUSIVE (`selected` is empty then). Pruning a DESELECTED
|
|
279
|
+
// folder needs the PREVIOUS folder set, which the live config no longer names — the one landing point
|
|
280
|
+
// the identity-stamped erase cannot enumerate (a folder is an arbitrary path) — so a tiny ledger in the
|
|
281
|
+
// global store records the folders emitted last run; any prev folder absent from the current set is
|
|
282
|
+
// clean()ed, then the current folders are emitted and the ledger rewritten.
|
|
167
283
|
const ledger = join(rt, 'plugin-folders')
|
|
168
284
|
const prevFolders = existsSync(ledger) ? readFileSync(ledger, 'utf8').split('\n').map((l) => l.trim()).filter(Boolean) : []
|
|
169
285
|
const curFolders = plugins.map((p) => p.folder)
|
|
@@ -180,65 +296,43 @@ export function materialize(proj = process.cwd()): string {
|
|
|
180
296
|
for (const p of plugins) emitPlugin(proj, p.folder, render)
|
|
181
297
|
}
|
|
182
298
|
writeFileSync(ledger, curFolders.join('\n'))
|
|
183
|
-
// (
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// main checkout and every worktree, so if the block differed by where materialize ran, whichever flavor got
|
|
191
|
-
// committed would leave the OTHER checkout dirtying it on every render. The only artifact that varies is the
|
|
192
|
-
// codex hooks shim, which materializes at the MAIN checkout (a linked worktree's codex reads the root's
|
|
193
|
-
// hooks — see harness.ts): from main it is `.codex/hooks.json` (inside proj), from a worktree it is `../…`
|
|
194
|
-
// (escapes proj). So an ignore entry is anchored to the checkout it LIVES under — proj-relative when inside
|
|
195
|
-
// proj, else MAIN-checkout-relative — which resolves the codex shim to `.codex/hooks.json` from any checkout
|
|
196
|
-
// (a pattern naming a main-only path is a harmless no-op in a worktree). Every checkout now emits the same
|
|
197
|
-
// set → the committed `.gitignore` is stable, never re-dirtied. A path under neither root is not ignorable
|
|
198
|
-
// here (dropped).
|
|
299
|
+
// (9) the ignore rules — themselves an artifact whose HOME the same axis decides ([[render-policy]]):
|
|
300
|
+
// committed/ignored → a managed `#` block in the TRACKED .gitignore (the team sees the rule);
|
|
301
|
+
// hidden → the identical block in the per-clone .git/info/exclude (zero repo footprint).
|
|
302
|
+
// Entries must be CHECKOUT-INVARIANT: `.gitignore` is ONE tracked file shared by the main checkout and
|
|
303
|
+
// every worktree, so each entry is anchored to the checkout it LIVES under — proj-relative when inside
|
|
304
|
+
// proj, else MAIN-checkout-relative (the codex shim resolves to `.codex/hooks.json` from any checkout; a
|
|
305
|
+
// pattern naming a main-only path is a harmless no-op in a worktree). A path under neither root is dropped.
|
|
199
306
|
const mc = mainCheckout(proj)
|
|
200
307
|
const anchor = (abs: string): string | null => {
|
|
201
308
|
const p = relative(proj, abs); if (!p.startsWith('..')) return p
|
|
202
309
|
const m = relative(mc, abs); if (!m.startsWith('..')) return m
|
|
203
310
|
return null
|
|
204
311
|
}
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
// each emitted plugin bundle is a generated, machine-local artifact too (its hooks.json bakes THIS install's
|
|
210
|
-
// SPEX path), so its dir joins the same managed block — regenerated per clone/launch, never committed.
|
|
312
|
+
// machine facts + run residue, ignored under EVERY policy: the shims/anchors/bundles (bake this install's
|
|
313
|
+
// abs path), spexcode.local.json (the host overlay — a `git add -A` must never leak it), and the session
|
|
314
|
+
// residue (`.worktrees/` where launches plant worktrees; `.session` is the legacy per-worktree state file
|
|
315
|
+
// an old backend wrote). Static strings stay checkout-invariant.
|
|
211
316
|
const bundlePaths = curFolders.map((f) => pluginBundleDir(proj, f))
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
writeManagedBlock(infoExcludePath(proj), entries, ['# ', ''])
|
|
228
|
-
removeManagedBlock(gitignore, ['# ', ''], true)
|
|
229
|
-
// the ONE manual step private mode can't do for you: .git/info/exclude hides UNTRACKED paths only, so a
|
|
230
|
-
// .spec/spexcode.json ALREADY committed (a repo that adopted DEFAULT mode first) stays visible until
|
|
231
|
-
// un-tracked. Surface it loudly rather than leaving a silent leak — the agent doing setup acts on this.
|
|
232
|
-
const stillTracked = ['.spec', 'spexcode.json'].filter((p) => isTracked(proj, p))
|
|
233
|
-
if (stillTracked.length) console.error(
|
|
234
|
-
`spexcode private: ${stillTracked.join(' + ')} still git-tracked — private mode hides UNTRACKED paths only.\n` +
|
|
235
|
-
` → run once: git rm -r --cached ${stillTracked.join(' ')} (keeps the files on disk, stops tracking them; then commit on your branch)`,
|
|
236
|
-
)
|
|
317
|
+
const machineEntries = [
|
|
318
|
+
...[...machinePaths, ...bundlePaths].map(anchor).filter((p): p is string => p !== null),
|
|
319
|
+
'spexcode.local.json', '.worktrees/', '.session',
|
|
320
|
+
]
|
|
321
|
+
const renderEntries = renderPaths.map(anchor).filter((p): p is string => p !== null)
|
|
322
|
+
const entries = (list: string[]) => [...new Set(list)].sort().join('\n')
|
|
323
|
+
if (policy === 'hidden') {
|
|
324
|
+
writeManagedBlock(infoExcludePath(proj), entries([...machineEntries, ...renderEntries]), ['# ', ''])
|
|
325
|
+
// a HOST-TRACKED contract file cannot be ignored — cover it with the clean/smudge content filter
|
|
326
|
+
// ([[content-filter]]) so the block lives in the working tree while the index keeps the pristine prose.
|
|
327
|
+
// Untracked contract files are wholly ours: generate + exclude suffices, no filter (weakest tool).
|
|
328
|
+
const trackedContracts = selected
|
|
329
|
+
.flatMap((h) => h.contractFiles(proj))
|
|
330
|
+
.filter((f) => contract && isTracked(proj, f))
|
|
331
|
+
if (trackedContracts.length) plantContractFilter(proj, trackedContracts, contract)
|
|
237
332
|
} else {
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
if (
|
|
241
|
-
removeManagedBlock(infoExcludePath(proj), ['# ', ''], false)
|
|
333
|
+
// committed: the renders become ordinary committed files — ONLY their entries leave the block.
|
|
334
|
+
const list = policy === 'committed' ? machineEntries : [...machineEntries, ...renderEntries]
|
|
335
|
+
if (list.length) writeManagedBlock(join(proj, '.gitignore'), entries(list), ['# ', ''])
|
|
242
336
|
}
|
|
243
337
|
// (5) stamp the content-hash marker LAST (so a crash mid-render leaves it stale → re-renders next gate).
|
|
244
338
|
const h = contentHash(proj)
|
package/spec-cli/src/ranker.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// is why they sit in flat plateaus rather than being fitted to any case.
|
|
6
6
|
const W_NAME_PREFIX = 8
|
|
7
7
|
const W_NAME_SUBSTR = 5
|
|
8
|
-
const W_DESC =
|
|
8
|
+
const W_DESC = 2
|
|
9
9
|
const W_BODY = 1
|
|
10
10
|
|
|
11
11
|
// a tiny stoplist of question scaffolding + length-1 tokens, dropped so "how does the … is it …" can't drown
|
|
@@ -19,19 +19,34 @@ const STOP = new Set([
|
|
|
19
19
|
'be', 'can', 'just', 'them', 'they', 'their', 'so', 'if', 'not', 'no', 'but', 'vs', 'us', 'we', 'you',
|
|
20
20
|
])
|
|
21
21
|
|
|
22
|
-
//
|
|
22
|
+
// The corpus is mostly English but not only — some nodes carry CJK prose (the root spexcode node is a whole
|
|
23
|
+
// Chinese paragraph), and the dashboard palette ([[shared-ranker]]) ranks session/issue titles that are
|
|
24
|
+
// frequently Chinese. CJK has no spaces, so a whitespace/`[^a-z0-9]` split silently discards ALL of it. We
|
|
25
|
+
// tokenize the SAME way on both sides: an ASCII alphanumeric run is one token; each CJK character is its own
|
|
26
|
+
// token (a unigram). Unigrams — not bigrams — keep the shared prefix-match/IDF/BM25 machinery untouched (a
|
|
27
|
+
// single-char query still matches, no bigram edge cases) and stay BLUNT & ROBUST, the floor's whole stance.
|
|
28
|
+
// Han (incl. Ext-A + compat) plus Japanese kana; enough to cover the CJK a spec body or a session title carries.
|
|
29
|
+
const CJK = '\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff\\u3040-\\u30ff' // Ext-A · Unified · Compat · kana
|
|
30
|
+
const TOKEN_RE = new RegExp(`[a-z0-9]+|[${CJK}]`, 'g')
|
|
31
|
+
const CJK_RE = new RegExp(`[${CJK}]`)
|
|
32
|
+
function isCjk(t: string): boolean { return CJK_RE.test(t) }
|
|
33
|
+
function tokenize(text: string): string[] {
|
|
34
|
+
return text.toLowerCase().match(TOKEN_RE) ?? []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// tokenize, lowercase, drop stopwords + length-1 ASCII tokens (a length-1 CJK token is a real word — keep it), de-dup.
|
|
23
38
|
export function terms(query: string): string[] {
|
|
24
39
|
const seen = new Set<string>()
|
|
25
|
-
for (const w of query
|
|
26
|
-
if (w.length > 1 && !STOP.has(w)) seen.add(w)
|
|
40
|
+
for (const w of tokenize(query)) {
|
|
41
|
+
if (isCjk(w) || (w.length > 1 && !STOP.has(w))) seen.add(w)
|
|
27
42
|
}
|
|
28
43
|
return [...seen]
|
|
29
44
|
}
|
|
30
45
|
|
|
31
46
|
// the words of a field, lowercased — used for word-boundary (prefix-of-a-word) matching, which kills
|
|
32
|
-
// short-token pollution (`main` must not match inside `domain`).
|
|
47
|
+
// short-token pollution (`main` must not match inside `domain`); CJK chars are single-char words.
|
|
33
48
|
function words(text: string): string[] {
|
|
34
|
-
return text
|
|
49
|
+
return tokenize(text)
|
|
35
50
|
}
|
|
36
51
|
|
|
37
52
|
// light query-side stem for prefix matching: drop a trailing plural 's' (len≥4, not 'ss') then a mute 'e'
|
|
@@ -87,8 +102,10 @@ function snippetFor(text: string, desc: string, qterms: string[], window = 140):
|
|
|
87
102
|
const lower = flat.toLowerCase()
|
|
88
103
|
let at = -1
|
|
89
104
|
for (const t of qterms) {
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
// ASCII terms locate at a word boundary (so `main` doesn't hit inside `domain`); a CJK term has no
|
|
106
|
+
// `\b` around it (JS `\b` is ASCII-only), so locate it by plain substring.
|
|
107
|
+
const i = isCjk(t) ? lower.indexOf(t) : lower.search(new RegExp('\\b' + t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
|
108
|
+
if (i >= 0 && (at < 0 || i < at)) at = i
|
|
92
109
|
}
|
|
93
110
|
if (at < 0) {
|
|
94
111
|
const fb = (desc || flat).replace(/\s+/g, ' ').trim()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
// @@@ session-runtime guard ([[platform-support]]) - SpexCode's session orchestration rests on POSIX
|
|
4
|
+
// primitives with no native-Windows analog: tmux (the durable detached PTY + capture-pane scrollback +
|
|
5
|
+
// multi-client reattach fabric), hand-written bash launch scripts, and filesystem-path AF_UNIX control
|
|
6
|
+
// sockets. The supported runtime is POSIX — Linux, macOS, or Windows *via WSL2* (a real Linux kernel where
|
|
7
|
+
// tmux/bash/unix-sockets all work). This is the honest gate at the entry to the session runtime: detect the
|
|
8
|
+
// load-bearing primitive (tmux) missing and print ONE actionable line naming the fix, instead of letting a
|
|
9
|
+
// cryptic downstream ENOENT be the user's first signal. Read-only CLI (init/lint/board) never calls this,
|
|
10
|
+
// so it still runs anywhere the launcher does — only the session-launch path (`spex serve`) is gated.
|
|
11
|
+
|
|
12
|
+
// tmux presence is the primitive we actually depend on, so probe THAT rather than assuming by platform:
|
|
13
|
+
// this also catches a bare POSIX box that simply hasn't installed tmux, not only native Windows.
|
|
14
|
+
export function hasTmux(): boolean {
|
|
15
|
+
try {
|
|
16
|
+
return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0
|
|
17
|
+
} catch {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Pure so it is unit-testable without spawning or exiting: null = runtime OK; otherwise the stderr lines.
|
|
23
|
+
// The pointer branches on platform because the honest repair differs — WSL2 on Windows (no POSIX analog at
|
|
24
|
+
// all), install-tmux on a POSIX host that merely lacks it.
|
|
25
|
+
export function sessionRuntimeBlock(env: { hasTmux: boolean; platform: string }): string[] | null {
|
|
26
|
+
if (env.hasTmux) return null
|
|
27
|
+
const lines = ['spex: the session runtime needs a POSIX host (tmux, bash, and unix-domain sockets).']
|
|
28
|
+
if (env.platform === 'win32') {
|
|
29
|
+
lines.push('Native Windows has no analog for these — run SpexCode under WSL2 (a real Linux kernel):')
|
|
30
|
+
lines.push(' wsl --install # then, in the Ubuntu shell: nvm install 22 && npm i -g spexcode')
|
|
31
|
+
} else {
|
|
32
|
+
lines.push('tmux is not on PATH — install it and retry (e.g. `apt install tmux`, `brew install tmux`).')
|
|
33
|
+
}
|
|
34
|
+
return lines
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Called at the top of the session-launching command path (`spex serve`). Exits 69 (EX_UNAVAILABLE: a
|
|
38
|
+
// required support program does not exist) — a distinct, honest code, not a swallowed error or a stacktrace.
|
|
39
|
+
export function assertSessionRuntime(): void {
|
|
40
|
+
const block = sessionRuntimeBlock({ hasTmux: hasTmux(), platform: process.platform })
|
|
41
|
+
if (!block) return
|
|
42
|
+
for (const line of block) console.error(line)
|
|
43
|
+
process.exit(69)
|
|
44
|
+
}
|