spexcode 0.4.1 → 0.4.3
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 +37 -17
- package/package.json +9 -5
- package/spec-cli/README.md +23 -14
- package/spec-cli/hooks/dispatch.sh +7 -5
- package/spec-cli/hooks/harness.sh +19 -8
- package/spec-cli/src/anchors.ts +110 -18
- package/spec-cli/src/cli.ts +88 -37
- package/spec-cli/src/client.ts +34 -11
- package/spec-cli/src/doctor.ts +174 -36
- package/spec-cli/src/gateway-auth.ts +194 -0
- package/spec-cli/src/gateway-hub.ts +360 -0
- package/spec-cli/src/gateway.ts +27 -19
- package/spec-cli/src/git.ts +12 -5
- package/spec-cli/src/graph.ts +54 -43
- package/spec-cli/src/graphStream.ts +265 -34
- package/spec-cli/src/guide.ts +105 -55
- package/spec-cli/src/harness-select.ts +20 -6
- package/spec-cli/src/harness.ts +181 -33
- package/spec-cli/src/help.ts +64 -27
- package/spec-cli/src/host.ts +610 -0
- package/spec-cli/src/identity-presets.d.ts +13 -0
- package/spec-cli/src/identity-presets.js +128 -0
- package/spec-cli/src/index.ts +145 -86
- package/spec-cli/src/init.ts +53 -25
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +25 -121
- package/spec-cli/src/login-page.ts +12 -5
- package/spec-cli/src/materialize.ts +39 -13
- package/spec-cli/src/mentions.ts +16 -11
- package/spec-cli/src/opencode.ts +145 -0
- package/spec-cli/src/pi-harness.ts +124 -0
- package/spec-cli/src/project-identity.ts +73 -0
- package/spec-cli/src/pty-bridge.ts +321 -521
- package/spec-cli/src/pty-helper.mjs +86 -0
- package/spec-cli/src/reaper.ts +16 -8
- package/spec-cli/src/reviewSnapshot.ts +23 -0
- package/spec-cli/src/reviews.ts +347 -0
- package/spec-cli/src/session-timeline.ts +14 -0
- package/spec-cli/src/sessions.ts +188 -66
- package/spec-cli/src/shim-runtime.ts +169 -0
- package/spec-cli/src/slash-commands.ts +76 -0
- package/spec-cli/src/source-files.ts +79 -0
- package/spec-cli/src/specs.ts +12 -7
- package/spec-cli/src/supervise.ts +37 -15
- package/spec-cli/src/tree.ts +8 -8
- package/spec-cli/src/uninstall.ts +56 -23
- package/spec-cli/templates/hooks/prepare-commit-msg +32 -18
- package/spec-cli/templates/spec/project/.plugins/commands/extract/spec.md +24 -18
- package/spec-cli/templates/spec/project/.plugins/commands/regroup/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
- package/spec-cli/templates/spec/project/.plugins/commands/spec.md +22 -8
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +29 -11
- package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
- package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
- package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
- package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +38 -50
- package/spec-cli/templates/spec/project/.plugins/skills/spec.md +9 -7
- package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
- package/spec-cli/templates/spec/project/spec.md +6 -5
- package/spec-cli/templates/spexcode.json +4 -2
- package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
- package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
- package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
- package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
- package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
- package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
- package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
- package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
- package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
- package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
- package/spec-dashboard/dist/index.html +10 -10
- package/spec-dashboard/src/reviewFilters.js +279 -0
- package/spec-dashboard/src/reviewQuery.js +174 -0
- package/spec-dashboard/src/session.js +167 -0
- package/spec-eval/src/cli.ts +49 -28
- package/spec-eval/src/humanok.ts +1 -1
- package/spec-eval/src/matrix.ts +693 -0
- package/spec-eval/src/sessioneval.ts +726 -66
- package/spec-cli/src/migrate-table.ts +0 -408
- package/spec-cli/src/migrate.ts +0 -386
- package/spec-dashboard/dist/assets/Dashboard-C_fGmOKK.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-Cnr1s3bq.js +0 -2
- package/spec-dashboard/dist/assets/FoldToggle-x9gtO1OQ.js +0 -1
- package/spec-dashboard/dist/assets/IssuesPage-5f_vL-JV.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-DEO1jgGM.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-CAlbMOFR.js +0 -66
- package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
- package/spec-dashboard/dist/assets/SessionWindow-JYbpPwNB.js +0 -13
- package/spec-dashboard/dist/assets/Settings-DKb5Ji_X.js +0 -1
- package/spec-dashboard/dist/assets/index-BQu-oJ8J.js +0 -41
- package/spec-dashboard/dist/assets/index-BbMkwuix.css +0 -1
- package/spec-dashboard/dist/assets/launch-BM9GgvkX.js +0 -6
package/spec-cli/src/layout.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join, dirname } from 'node:path'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import { git, repoRoot, gitA, headSha, worktreeSpecSig, worktreeSpecDelta, type NodeOp } from './git.js'
|
|
5
5
|
import { guardWorktree } from './resilience.js'
|
|
6
|
-
import { HARNESSES } from './harness.js'
|
|
6
|
+
import { HARNESSES, type HarnessId } from './harness.js'
|
|
7
7
|
|
|
8
8
|
type Config = {
|
|
9
9
|
main?: string // path to the source-of-truth checkout (default: the `main` worktree)
|
|
@@ -20,13 +20,13 @@ type Config = {
|
|
|
20
20
|
// RETIRED (residence compat): the old private-overlay toggle — ignored with the same loud notice;
|
|
21
21
|
// its data-untrack semantics are long gone. See `spex guide footprint` MIGRATIONS.
|
|
22
22
|
private?: boolean
|
|
23
|
-
// which harness targets `spex materialize` delivers into — native
|
|
24
|
-
// bundle; resolved + validated by [[harness-select]] (harness-select.ts).
|
|
23
|
+
// which harness targets `spex materialize` delivers into — a native HarnessId or a {plugin:"<folder>"}
|
|
24
|
+
// bundle; resolved + validated by [[harness-select]] (harness-select.ts). REQUIRED — no default set; `spex init --harness` stamps it.
|
|
25
25
|
harnesses?: (string | { plugin?: string })[]
|
|
26
26
|
dashboard?: {
|
|
27
27
|
apiUrl?: string // the per-project backend the board proxies to (read frontend-side; see api-endpoint)
|
|
28
28
|
title?: string // override for the browser-tab name (default: the repo-root basename; see tab-title)
|
|
29
|
-
icon?: string //
|
|
29
|
+
icon?: string // project identity icon: a picker preset id; existing emoji/Iconify/URL values remain supported ([[identity-config]])
|
|
30
30
|
}
|
|
31
31
|
sessions?: {
|
|
32
32
|
maxActive?: number // concurrency cap: max agents AUTONOMOUSLY PROGRESSING at once (default 8; see sessions.ts maxActive)
|
|
@@ -34,7 +34,7 @@ type Config = {
|
|
|
34
34
|
// its harness AND its exact launch command; the chosen NAME is persisted on the record so resume reuses the
|
|
35
35
|
// same auth. `harness` defaults to 'claude'. Host-specific `cmd`s (abs wrapper paths) belong in the
|
|
36
36
|
// gitignored spexcode.local.json — the name is portable, the cmd is a machine fact.
|
|
37
|
-
launchers?: { [name: string]: { harness?:
|
|
37
|
+
launchers?: { [name: string]: { harness?: HarnessId; cmd: string } }
|
|
38
38
|
defaultLauncher?: string // the launcher a create with no explicit --launcher/dropdown pick uses; required for no-choice creates
|
|
39
39
|
}
|
|
40
40
|
serve?: {
|
package/spec-cli/src/lint.ts
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, existsSync, statSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { repoRoot, git, driftIndex, historyIndex, rowsFor } from './git.js'
|
|
4
4
|
import { loadSpecs } from './specs.js'
|
|
5
5
|
import { readJsonConfig } from './layout.js'
|
|
6
6
|
import { extractors, extractorFor, extOf, resolveAnchor, windowCommits, anchorHitCommits } from './anchors.js'
|
|
7
|
+
import { DEFAULT_TEST_GLOBS, sourcePolicyDescription, trackedSourceFiles } from './source-files.js'
|
|
7
8
|
|
|
8
9
|
export type Finding = { level: 'error' | 'warn'; rule: string; spec?: string; file?: string; msg: string }
|
|
9
10
|
|
|
10
11
|
export type LintConfig = {
|
|
11
|
-
governedRoots: string[] // dirs whose source files must each be governed by a spec
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
maxChildren: number // breadth budget: warn at >= this many direct children
|
|
12
|
+
governedRoots: string[] // dirs whose tracked source files must each be governed by a spec. '.' = whole project.
|
|
13
|
+
sourceIncludeGlobs: string[] | null // null includes every tracked regular text file; [] intentionally includes none
|
|
14
|
+
sourceExcludeGlobs: string[] // explicit source-policy subtraction
|
|
15
|
+
sourceExtensions: string[] | null // compatibility shorthand compiled into sourceIncludeGlobs
|
|
16
|
+
testGlobs: string[] // globs EXCLUDED from coverage; set [] to govern tests too
|
|
17
17
|
maxOwners: number // warn when a file is governed (code:) by > this many nodes
|
|
18
18
|
scenarioTags: string[] // the closed vocabulary an eval scenario's `tags:` must draw from; extend it to mint a new tag
|
|
19
19
|
scopedCodeMiss: 'warn' | 'ignore' // the file-level drift ADVISORY on a selector-scoped code: file whose window has no
|
|
@@ -23,11 +23,10 @@ export type LintConfig = {
|
|
|
23
23
|
}
|
|
24
24
|
const DEFAULT_CONFIG: LintConfig = {
|
|
25
25
|
governedRoots: ['spec-dashboard/src', 'spec-cli/src'],
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
maxChildren: 8,
|
|
26
|
+
sourceIncludeGlobs: null,
|
|
27
|
+
sourceExcludeGlobs: [],
|
|
28
|
+
sourceExtensions: null,
|
|
29
|
+
testGlobs: DEFAULT_TEST_GLOBS,
|
|
31
30
|
maxOwners: 3,
|
|
32
31
|
scenarioTags: ['frontend-e2e', 'backend-api', 'cli', 'desktop', 'mobile'],
|
|
33
32
|
scopedCodeMiss: 'warn',
|
|
@@ -36,107 +35,36 @@ export function loadConfig(root: string): LintConfig {
|
|
|
36
35
|
// Absent spexcode.json → tuned defaults; a MALFORMED one throws LOUD (readJsonConfig) rather than
|
|
37
36
|
// silently reverting the author's budgets to defaults and green-washing the very warnings they tuned.
|
|
38
37
|
const c = readJsonConfig(join(root, 'spexcode.json'))?.lint ?? {}
|
|
39
|
-
const merged = { ...DEFAULT_CONFIG, ...c
|
|
38
|
+
const merged = { ...DEFAULT_CONFIG, ...c }
|
|
40
39
|
return normalizeConfig(merged)
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
// adopter makes reading the prose, so we accept-what-they-meant rather than reject:
|
|
46
|
-
// - a LEADING DOT on an extension: the matcher is `\.(ext)$`, so a literal ".ts" becomes `\..ts$` and never
|
|
47
|
-
// matches. Strip leading dots → ["ts"] and [".ts"] both work (prose historically showed ".ts").
|
|
48
|
-
// - a testGlob with NO "/": globs anchor to the full repo-relative path, so a bare "*.test.ts" matches only
|
|
49
|
-
// ROOT-level files and leaks every nested test into coverage. A slash-less glob is a basename intent →
|
|
50
|
-
// prepend "**/" so it matches that basename at any depth (the default "**/*.test.*" already does).
|
|
42
|
+
// Compile every author-facing source selector into one include-minus-exclude/test policy. In particular,
|
|
43
|
+
// sourceExtensions is compatibility syntax only: it contributes include globs and never reaches discovery.
|
|
51
44
|
export function normalizeConfig(cfg: LintConfig): LintConfig {
|
|
52
45
|
// a mistyped enum silently reverting to the default would green-wash (or over-warn) exactly the
|
|
53
46
|
// advisory the author meant to tune — same fail-loud rule as a malformed spexcode.json.
|
|
54
47
|
if (cfg.scopedCodeMiss !== 'warn' && cfg.scopedCodeMiss !== 'ignore')
|
|
55
48
|
throw new Error(`spexcode.json lint.scopedCodeMiss must be "warn" or "ignore", got ${JSON.stringify(cfg.scopedCodeMiss)}`)
|
|
56
49
|
const dedot = (xs: string[]) => xs.map((x) => x.replace(/^\.+/, ''))
|
|
50
|
+
const anyDepth = (xs: string[]) => xs.map((g) => (g.includes('/') ? g : `**/${g}`))
|
|
51
|
+
const extensions = cfg.sourceExtensions === null ? null : dedot(cfg.sourceExtensions)
|
|
52
|
+
const includes = cfg.sourceIncludeGlobs === null && extensions === null
|
|
53
|
+
? null
|
|
54
|
+
: [...new Set([...anyDepth(cfg.sourceIncludeGlobs ?? []), ...(extensions ?? []).map((ext) => `**/*.${ext}`)])]
|
|
57
55
|
return {
|
|
58
56
|
...cfg,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
sourceIncludeGlobs: includes,
|
|
58
|
+
sourceExcludeGlobs: anyDepth(cfg.sourceExcludeGlobs),
|
|
59
|
+
sourceExtensions: extensions,
|
|
60
|
+
testGlobs: anyDepth(cfg.testGlobs),
|
|
62
61
|
}
|
|
63
62
|
}
|
|
64
63
|
|
|
65
|
-
// the source-file matcher, built from the configurable `sourceExtensions` knob. Coverage uses it to decide
|
|
66
|
-
// which tracked files must be governed; eval lint's `eval-coverage` reuses THE SAME knob so ONE setting
|
|
67
|
-
// defines "source" for both coverage axes — a non-web project (Rust/Go/Python .rs/.go/.py) sets it once and
|
|
68
|
-
// both the coverage warning and the loss-signal blind-spot check follow, with no second web-only allowlist.
|
|
69
|
-
export const sourceExtRe = (extensions: string[]) => new RegExp(`\\.(${extensions.join('|')})$`)
|
|
70
|
-
|
|
71
|
-
// a minimal glob → RegExp anchored to the full repo-relative path: `**` = any dirs, `*` = within a segment.
|
|
72
|
-
function globToRe(glob: string): RegExp {
|
|
73
|
-
const body = glob.split(/(\*\*\/|\*\*|\*|\?)/).map((seg) => {
|
|
74
|
-
if (seg === '**/') return '(?:.*/)?'
|
|
75
|
-
if (seg === '**') return '.*'
|
|
76
|
-
if (seg === '*') return '[^/]*'
|
|
77
|
-
if (seg === '?') return '[^/]'
|
|
78
|
-
return seg.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
79
|
-
}).join('')
|
|
80
|
-
return new RegExp(`^${body}$`)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// coverage enumerates source via GIT-TRACKED files (`git ls-files`, through git() which strips the hook's
|
|
84
|
-
// GIT_DIR), NOT a raw fs walk. Tracked-only auto-excludes node_modules + build output (gitignored), nested
|
|
85
|
-
// or linked worktrees + submodules (a separate index), `.git`, and anything untracked — so governedRoots
|
|
86
|
-
// '.' means "all tracked source" with no fs explosion and no hand-maintained skip list (git IS the database).
|
|
87
|
-
// Test files drop per cfg.testGlobs (default *.test.*; set [] to govern tests too).
|
|
88
|
-
function trackedSourceFiles(root: string, roots: string[], src: RegExp, testGlobs: string[]): string[] {
|
|
89
|
-
const testRes = testGlobs.map(globToRe)
|
|
90
|
-
const out = new Set<string>()
|
|
91
|
-
for (const r of roots) {
|
|
92
|
-
let listed = ''
|
|
93
|
-
try { listed = git(['-C', root, 'ls-files', '-z', '--', r]) } catch { continue }
|
|
94
|
-
for (const f of listed.split('\0')) {
|
|
95
|
-
if (!f || !src.test(f) || testRes.some((re) => re.test(f))) continue
|
|
96
|
-
out.add(f)
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return [...out]
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// code-identifier signals: camelCase | snake_case | foo( | `backticked` | /a/path.ext | bare file.ext. Only
|
|
103
|
-
// the bare-filename branch needs the extension allowlist (config, so a non-TS project recognises its own
|
|
104
|
-
// sources) — without it a bare `word.word` would match ordinary prose like "e.g".
|
|
105
|
-
function identRe(extensions: string[]): RegExp {
|
|
106
|
-
const ext = extensions.join('|')
|
|
107
|
-
return new RegExp(`[a-z][A-Za-z0-9]*[A-Z][A-Za-z0-9]*|\\b[a-z]+_[a-z0-9_]+\\b|\\b\\w+\\(|\`[^\`]+\`|\\/[\\w./-]+\\.\\w+|\\b[\\w-]+\\.(${ext})\\b`, 'g')
|
|
108
|
-
}
|
|
109
|
-
// step-by-step how-to phrasing: numbered steps, or sequencing connectives that walk through mechanics.
|
|
110
|
-
const STEP_LINE = /^\s*(\d+[.)]\s|[-*]\s*(first|then|next|finally)\b)|(^|[,;]\s*)(first|then|next|finally),/i
|
|
111
|
-
// returns a one-line reason naming whichever low-altitude proxy(ies) tripped (length / identifier density /
|
|
112
|
-
// step-by-step), or null when the body is at altitude.
|
|
113
|
-
function altitude(body: string, cfg: LintConfig, ident: RegExp): string | null {
|
|
114
|
-
const a = cfg.altitude
|
|
115
|
-
const lines = body.split('\n')
|
|
116
|
-
const nb = lines.filter((l) => l.trim()).length
|
|
117
|
-
const chars = body.length
|
|
118
|
-
// identifiers and step phrasing are read from PROSE only — a fenced code sample is acknowledged code,
|
|
119
|
-
// not low-altitude narration, so it inflates length but not density.
|
|
120
|
-
let inFence = false, signals = 0, steps = 0
|
|
121
|
-
for (const l of lines) {
|
|
122
|
-
if (/^\s*```/.test(l)) { inFence = !inFence; continue }
|
|
123
|
-
if (inFence || !l.trim()) continue
|
|
124
|
-
signals += l.match(ident)?.length ?? 0
|
|
125
|
-
if (STEP_LINE.test(l)) steps++
|
|
126
|
-
}
|
|
127
|
-
const density = signals / Math.max(1, nb)
|
|
128
|
-
const why: string[] = []
|
|
129
|
-
if (nb > a.lineBudget || chars > a.charBudget) why.push(`${nb} non-blank lines / ${chars} chars over budget (${a.lineBudget}/${a.charBudget})`)
|
|
130
|
-
if (nb > a.sizeable && density > a.dense) why.push(`code-identifier density ${density.toFixed(2)}/line over ${a.dense}`)
|
|
131
|
-
if (nb > a.sizeable && steps >= a.steps) why.push(`${steps} step-by-step how-to lines`)
|
|
132
|
-
return why.length ? why.join('; ') : null
|
|
133
|
-
}
|
|
134
|
-
|
|
135
64
|
export async function specLint(): Promise<Finding[]> {
|
|
136
65
|
const root = repoRoot()
|
|
137
66
|
const cfg = loadConfig(root)
|
|
138
|
-
const
|
|
139
|
-
const srcRe = sourceExtRe(cfg.sourceExtensions)
|
|
67
|
+
const governed = trackedSourceFiles(root, cfg.governedRoots, cfg)
|
|
140
68
|
const specs = await loadSpecs()
|
|
141
69
|
const out: Finding[] = []
|
|
142
70
|
|
|
@@ -252,33 +180,9 @@ export async function specLint(): Promise<Finding[]> {
|
|
|
252
180
|
}
|
|
253
181
|
}
|
|
254
182
|
|
|
255
|
-
// altitude: a body that re-narrates mechanics instead of stating contract/intent (WARN — soft budget).
|
|
256
|
-
for (const s of specs) {
|
|
257
|
-
const why = altitude(s.body, cfg, ident)
|
|
258
|
-
if (why) out.push({ level: 'warn', rule: 'altitude', spec: s.id, msg: `'${s.id}' body reads low-altitude (mechanics, not contract): ${why}` })
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// breadth: a node with too many DIRECT children is altitude's structural twin — splitting a node to pass
|
|
262
|
-
// altitude shouldn't just relocate the sprawl into a wide flat fan-out (WARN — soft, advisory). Children
|
|
263
|
-
// are derived from the parent links loadSpecs already computes; no explicit child array to keep in sync.
|
|
264
|
-
const childCount = new Map<string, number>()
|
|
265
|
-
for (const s of specs) if (s.parent) childCount.set(s.parent, (childCount.get(s.parent) ?? 0) + 1)
|
|
266
|
-
for (const s of specs) {
|
|
267
|
-
const n = childCount.get(s.id) ?? 0
|
|
268
|
-
if (n >= cfg.maxChildren)
|
|
269
|
-
out.push({ level: 'warn', rule: 'breadth', spec: s.id, msg: `'${s.id}' has ${n} direct child nodes (>= ${cfg.maxChildren}) — is an intermediate grouping layer missing? (a flat list of genuine peers is sometimes right — ignore if so)` })
|
|
270
|
-
}
|
|
271
|
-
|
|
272
183
|
// coverage: every governed source file must be claimed by at least one spec.
|
|
273
|
-
const governed = trackedSourceFiles(root, cfg.governedRoots, srcRe, cfg.testGlobs)
|
|
274
|
-
// no governed source found at all → make it a SELF-EXPLANATORY repair entrypoint, not a dead end. The two
|
|
275
|
-
// knobs governing this are BOTH web-tuned by default (extensions ts/tsx/js/jsx; roots this repo's own dirs),
|
|
276
|
-
// so a non-web adopter (Rust/Go/Python) hits zero source two ways: right dir but wrong extension, or an
|
|
277
|
-
// unset root. Naming BOTH knobs, echoing their CURRENT values (so the mismatch is visible — "searching .ts
|
|
278
|
-
// in a .py tree"), and stating the `lint`-key nesting (a top-level key silently no-ops) turns the warning
|
|
279
|
-
// into the fix. Concrete non-web extension examples so the repair is copy-pasteable, not a schema hunt.
|
|
280
184
|
if (governed.length === 0)
|
|
281
|
-
out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — 0 source
|
|
185
|
+
out.push({ level: 'warn', rule: 'coverage', msg: `governing NOTHING — 0 source candidates under governedRoots [${cfg.governedRoots.join(', ')}]; ${sourcePolicyDescription(cfg)}. Repair these knobs under the "lint" key in spexcode.json (top-level keys are ignored): governedRoots, sourceIncludeGlobs, sourceExcludeGlobs, testGlobs; sourceExtensions remains compatibility shorthand for include globs.` })
|
|
282
186
|
for (const f of governed)
|
|
283
187
|
if (!claimed.has(f)) out.push({ level: 'warn', rule: 'coverage', file: f, msg: `no spec governs: ${f}` })
|
|
284
188
|
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// @@@ login page - the gateway's gate is a DESIGNED page, not the browser's Basic-auth dialog (which can't
|
|
2
2
|
// be styled and feels like a 1998 intranet). Self-contained: inline CSS + SVG, zero external assets, so it
|
|
3
3
|
// renders before anything is authorised. Dark, calm, a single password field; an error state when the
|
|
4
|
-
// password is wrong. The form POSTs to
|
|
5
|
-
|
|
4
|
+
// password is wrong. The form POSTs to `action` (same-origin), which mints the auth cookie and redirects.
|
|
5
|
+
// One page serves every gate: the single-project gateway's /login (the defaults) and the multi-project
|
|
6
|
+
// hub's admin + per-project logins ([[gateway-hub]] passes action/heading/sub). Every option is
|
|
7
|
+
// HTML-escaped at this sink — projectIds are path-derived, attacker-influencable strings.
|
|
8
|
+
export function loginPage(error = false, opts: { action?: string; heading?: string; sub?: string } = {}): string {
|
|
9
|
+
const esc = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]!))
|
|
10
|
+
const action = esc(opts.action ?? '/login')
|
|
11
|
+
const heading = esc(opts.heading ?? 'Restricted access')
|
|
12
|
+
const sub = esc(opts.sub ?? 'This is a private agent workspace. Enter the access password to continue.')
|
|
6
13
|
return `<!doctype html>
|
|
7
14
|
<html lang="en">
|
|
8
15
|
<head>
|
|
@@ -58,7 +65,7 @@ export function loginPage(error = false): string {
|
|
|
58
65
|
</style>
|
|
59
66
|
</head>
|
|
60
67
|
<body>
|
|
61
|
-
<form class="card" method="POST" action="
|
|
68
|
+
<form class="card" method="POST" action="${action}" autocomplete="off">
|
|
62
69
|
<div class="mark">
|
|
63
70
|
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
64
71
|
<rect x="2" y="2" width="28" height="28" rx="8" fill="#0e1626" stroke="#3f6fe0" stroke-opacity="0.5"/>
|
|
@@ -66,8 +73,8 @@ export function loginPage(error = false): string {
|
|
|
66
73
|
</svg>
|
|
67
74
|
<b>Spex<span>Code</span></b>
|
|
68
75
|
</div>
|
|
69
|
-
<h1
|
|
70
|
-
<p class="sub"
|
|
76
|
+
<h1>${heading}</h1>
|
|
77
|
+
<p class="sub">${sub}</p>
|
|
71
78
|
${error ? '<div class="err">Incorrect password — try again.</div>' : ''}
|
|
72
79
|
<label for="password">Password</label>
|
|
73
80
|
<input id="password" name="password" type="password" autofocus required placeholder="••••••••••">
|
|
@@ -11,6 +11,12 @@ import { resolveHarnessTargets, partitionHarnesses } from './harness-select.js'
|
|
|
11
11
|
import { emitPlugin, cleanPlugin, pluginBundleDir, pluginVersion } from './plugin-harness.js'
|
|
12
12
|
import { plantContractFilter, removeContractFilter, settleIndexStat } from './contract-filter.js'
|
|
13
13
|
|
|
14
|
+
export type MaterializedArtifact = {
|
|
15
|
+
kind: 'hook manifest' | 'contract' | 'shim' | 'skill' | 'agent' | 'plugin bundle' | 'trust'
|
|
16
|
+
path: string
|
|
17
|
+
}
|
|
18
|
+
export type MaterializeResult = { contentHash: string; planted: MaterializedArtifact[] }
|
|
19
|
+
|
|
14
20
|
// @@@ materialize - the materialize step (≈0.85s), anchored on GIT-NATIVE events only ([[commit-surgery]]):
|
|
15
21
|
// spex verbs (init/materialize), session-worktree creation, and the planted git hooks (pre-commit,
|
|
16
22
|
// post-checkout, post-merge) — never a harness event; the harness is a READER of the materialized files, not
|
|
@@ -156,22 +162,36 @@ export function dematerialize(proj = process.cwd(), arts: HarnessArtifacts = { s
|
|
|
156
162
|
// and even unfiltered the phantom-`M` lingers) — settle the index stat, content-guarded so a user's real
|
|
157
163
|
// unstaged edit is never staged ([[content-filter]] edge 2).
|
|
158
164
|
try { settleIndexStat(proj, HARNESSES.flatMap((h) => h.contractFiles(proj))) } catch { /* not a git repo */ }
|
|
159
|
-
// leaving nothing behind: drop the now-EMPTY dirs the assert phase mkdir'ed (.claude/.codex
|
|
160
|
-
// skills/agents subdirs
|
|
165
|
+
// leaving nothing behind: drop the now-EMPTY dirs the assert phase mkdir'ed (.claude/.codex/.opencode/.pi
|
|
166
|
+
// and their skills/agents/plugins/extensions subdirs). Each dir AND its parent are swept deepest-first,
|
|
167
|
+
// because a harness may nest its shim a level below its home (opencode's .opencode/plugins/, pi's
|
|
168
|
+
// .pi/extensions/) — but never the checkout roots themselves. rmdirSync is NON-recursive, so a dir holding
|
|
161
169
|
// any user file survives untouched; `.git/spexcode/` is deliberately NOT swept (shared per-clone home).
|
|
162
170
|
for (const h of HARNESSES) {
|
|
163
171
|
const anchor = h.worktreeHookAnchor(proj)
|
|
164
|
-
|
|
165
|
-
|
|
172
|
+
const dirs = [h.skillDir(proj), h.agentDir(proj), dirname(h.shimFile(proj)), anchor ? dirname(anchor) : null]
|
|
173
|
+
.filter((d): d is string => !!d)
|
|
174
|
+
const roots = new Set([proj, mainCheckout(proj)])
|
|
175
|
+
const sweep = [...new Set([...dirs, ...dirs.map((d) => dirname(d))])]
|
|
176
|
+
.filter((d) => !roots.has(d))
|
|
177
|
+
.sort((a, b) => b.length - a.length)
|
|
178
|
+
for (const d of sweep) { try { rmdirSync(d) } catch { /* non-empty or absent — keep */ } }
|
|
166
179
|
}
|
|
167
180
|
}
|
|
168
181
|
|
|
169
|
-
// the whole pay-per-change materialize. proj defaults to cwd.
|
|
170
|
-
|
|
182
|
+
// the whole pay-per-change materialize. proj defaults to cwd. Its receipt is populated at each successful
|
|
183
|
+
// write so callers report the actual selected footprint instead of maintaining a second artifact inventory.
|
|
184
|
+
export function materialize(proj = process.cwd()): MaterializeResult {
|
|
171
185
|
const rt = treeSlotDir(proj) // this tree's slot in the global store, not the worktree
|
|
172
186
|
mkdirSync(rt, { recursive: true })
|
|
187
|
+
const planted: MaterializedArtifact[] = []
|
|
188
|
+
const record = (kind: MaterializedArtifact['kind'], path: string) => {
|
|
189
|
+
if (!planted.some((a) => a.kind === kind && a.path === path)) planted.push({ kind, path })
|
|
190
|
+
}
|
|
173
191
|
// (1) hook manifest (persistent — the dispatcher reads it; regenerated only here, on change).
|
|
174
|
-
|
|
192
|
+
const manifest = join(rt, 'hooks-manifest')
|
|
193
|
+
writeFileSync(manifest, compileManifest())
|
|
194
|
+
record('hook manifest', manifest)
|
|
175
195
|
// (2) the contract = the tracked docs guide (the hand-written agent/contributor notes — the ONE piece of
|
|
176
196
|
// in-tree prose) FOLLOWED BY the surface:system bodies (in name order), written WHOLE into EACH harness's
|
|
177
197
|
// contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
|
|
@@ -216,17 +236,18 @@ export function materialize(proj = process.cwd()): string {
|
|
|
216
236
|
const machinePaths: string[] = []
|
|
217
237
|
const contractPaths: string[] = []
|
|
218
238
|
for (const h of selected) {
|
|
219
|
-
if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f) }
|
|
239
|
+
if (contract) for (const f of h.contractFiles(proj)) { writeManagedBlock(f, contract); contractPaths.push(f); record('contract', f) }
|
|
220
240
|
const shimFile = h.shimFile(proj)
|
|
221
241
|
mkdirSync(dirname(shimFile), { recursive: true })
|
|
222
242
|
const shim = h.shim(DISPATCH, SPEX)
|
|
223
|
-
writeFileSync(shimFile, shim.
|
|
224
|
-
|
|
243
|
+
writeFileSync(shimFile, shim.content)
|
|
244
|
+
record('shim', shimFile)
|
|
245
|
+
for (const f of h.writeTrust(proj, shim.cmd)) record('trust', f)
|
|
225
246
|
machinePaths.push(shimFile)
|
|
226
247
|
// a linked-worktree ANCHOR copy of the shim, when the harness needs one (codex: the shim lives at the main
|
|
227
248
|
// checkout, so the worktree gets no `.codex/` unless we place one). One adapter line; null otherwise.
|
|
228
249
|
const anchor = h.worktreeHookAnchor(proj)
|
|
229
|
-
if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.
|
|
250
|
+
if (anchor) { mkdirSync(dirname(anchor), { recursive: true }); writeFileSync(anchor, shim.content); machinePaths.push(anchor); record('shim', anchor) }
|
|
230
251
|
}
|
|
231
252
|
// (6) skills + (7) sub-agents — each surface node → the file the harness auto-discovers, one per selected
|
|
232
253
|
// harness that has the primitive (skillDir/agentDir null skips — the divergence is the adapter's line).
|
|
@@ -237,6 +258,7 @@ export function materialize(proj = process.cwd()): string {
|
|
|
237
258
|
mkdirSync(dirname(f), { recursive: true })
|
|
238
259
|
writeFileSync(f, skillArtifact(sk))
|
|
239
260
|
artifactPaths.push(f)
|
|
261
|
+
record('skill', f)
|
|
240
262
|
}
|
|
241
263
|
}
|
|
242
264
|
for (const ag of agentNodes) {
|
|
@@ -246,6 +268,7 @@ export function materialize(proj = process.cwd()): string {
|
|
|
246
268
|
mkdirSync(dirname(f), { recursive: true })
|
|
247
269
|
writeFileSync(f, agentArtifact(ag))
|
|
248
270
|
artifactPaths.push(f)
|
|
271
|
+
record('agent', f)
|
|
249
272
|
}
|
|
250
273
|
}
|
|
251
274
|
// (8) the PLUGIN target ([[plugin-harness]]): materialize the whole system into one self-contained Claude-plugin
|
|
@@ -271,7 +294,10 @@ export function materialize(proj = process.cwd()): string {
|
|
|
271
294
|
spex: SPEX,
|
|
272
295
|
version: pluginVersion(),
|
|
273
296
|
}
|
|
274
|
-
for (const p of plugins)
|
|
297
|
+
for (const p of plugins) {
|
|
298
|
+
emitPlugin(proj, p.folder, bundle)
|
|
299
|
+
record('plugin bundle', pluginBundleDir(proj, p.folder))
|
|
300
|
+
}
|
|
275
301
|
}
|
|
276
302
|
writeFileSync(ledger, curFolders.join('\n'))
|
|
277
303
|
// (9) the ignore rules — ALWAYS the per-clone .git/info/exclude ([[residence]]): the exclude is not a
|
|
@@ -313,5 +339,5 @@ export function materialize(proj = process.cwd()): string {
|
|
|
313
339
|
// (5) stamp the content-hash marker LAST (a diagnostic freshness record; a crash mid-materialize leaves it stale).
|
|
314
340
|
const h = contentHash(proj)
|
|
315
341
|
writeFileSync(join(rt, 'content-hash'), h)
|
|
316
|
-
return h
|
|
342
|
+
return { contentHash: h, planted }
|
|
317
343
|
}
|
package/spec-cli/src/mentions.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
// `@<token>` at a word boundary is an actor; `[[<id>]]` is a topic. Token chars are any unicode
|
|
10
10
|
// letter/number plus [_-] (a CJK session handle or node id is first-class — same charset the launch-side
|
|
11
11
|
// MENTION and the dashboard's MENTION_RE use); a session id, a short label/prefix, or the literal `new`.
|
|
12
|
-
//
|
|
13
|
-
|
|
12
|
+
// Only `new` may carry `:<launcher>` ([[launcher-select]]); keeping that choice in the token makes the same
|
|
13
|
+
// prose work from the CLI and dashboard, with no composer-only field. Both forms are deduped first-seen.
|
|
14
|
+
const ACTOR_RE = /(?:^|\s)@([\p{L}\p{N}_-]+)(?::([\p{L}\p{N}_.-]+))?/gu
|
|
14
15
|
const NODE_RE = /\[\[([^\]\s]+)\]\]/g
|
|
15
16
|
|
|
16
17
|
const uniq = (xs: string[]): string[] => [...new Set(xs)]
|
|
@@ -30,7 +31,7 @@ export function stripRefSigil(token: string): string {
|
|
|
30
31
|
export function parseMentions(text: string): { actors: string[]; nodes: string[] } {
|
|
31
32
|
const actors: string[] = []
|
|
32
33
|
const nodes: string[] = []
|
|
33
|
-
for (const m of text.matchAll(ACTOR_RE)) actors.push(m[1])
|
|
34
|
+
for (const m of text.matchAll(ACTOR_RE)) actors.push(m[1] === 'new' && m[2] ? `new:${m[2]}` : m[1])
|
|
34
35
|
for (const m of text.matchAll(NODE_RE)) nodes.push(m[1])
|
|
35
36
|
return { actors: uniq(actors), nodes: uniq(nodes) }
|
|
36
37
|
}
|
|
@@ -39,17 +40,19 @@ export function parseMentions(text: string): { actors: string[]; nodes: string[]
|
|
|
39
40
|
// A minimal shape of what dispatchMentions needs from a Session (so this stays testable without sessions.ts).
|
|
40
41
|
export type ActorSession = { id: string; node: string | null; name: string | null; title: string | null; liveness: string }
|
|
41
42
|
export type Resolved =
|
|
42
|
-
| { token: string; kind: 'new' }
|
|
43
|
+
| { token: string; kind: 'new'; launcher?: string }
|
|
43
44
|
| { token: string; kind: 'session'; session: ActorSession }
|
|
44
45
|
| { token: string; kind: 'unresolved' }
|
|
45
46
|
|
|
46
|
-
// Resolve each actor token to a `new` sentinel
|
|
47
|
-
// closed agent; `@new` acts on its behalf), or unresolved.
|
|
48
|
-
// → name/title (case-insensitive) → name/title prefix.
|
|
47
|
+
// Resolve each actor token to a `new` sentinel (optionally carrying its explicit launcher), an ONLINE
|
|
48
|
+
// session (never a dead one — you don't summon a closed agent; `@new` acts on its behalf), or unresolved.
|
|
49
|
+
// Match order: new/new:<launcher> → exact id → id prefix → name/title (case-insensitive) → name/title prefix.
|
|
50
|
+
// First online match wins.
|
|
49
51
|
export function resolveActors(tokens: string[], sessions: ActorSession[]): Resolved[] {
|
|
50
52
|
const online = sessions.filter((s) => s.liveness === 'online')
|
|
51
53
|
return tokens.map((token): Resolved => {
|
|
52
|
-
|
|
54
|
+
const fresh = /^new(?::(.+))?$/.exec(token)
|
|
55
|
+
if (fresh) return { token, kind: 'new', ...(fresh[1] ? { launcher: fresh[1] } : {}) }
|
|
53
56
|
const t = token.toLowerCase()
|
|
54
57
|
const label = (s: ActorSession) => (s.name || s.title || '').toLowerCase()
|
|
55
58
|
const hit =
|
|
@@ -84,7 +87,9 @@ function mentionPrompt(threadId: string, node: string | null, author: string, te
|
|
|
84
87
|
// A non-open thread is settled work: a fresh worker spawned onto it must not re-implement what already
|
|
85
88
|
// landed, so the prompt leads with the status and a verify-on-main-first instruction.
|
|
86
89
|
export function newWorkerPrompt(threadId: string, node: string | null, author: string, text: string, status?: string | null): string {
|
|
87
|
-
|
|
90
|
+
// Keep inherited scope inside the text the worker receives: newSession derives its node only from the
|
|
91
|
+
// raw prompt's first [[id]] mention, so issue dispatch gets no private node-binding argument.
|
|
92
|
+
const on = node ? ` on node [[${node}]]` : ''
|
|
88
93
|
const settled = status && status !== 'open'
|
|
89
94
|
? `NOTE: this thread is already resolved (status: ${status}) — the work it describes has likely LANDED. ` +
|
|
90
95
|
`Verify the current state on main FIRST; if main already satisfies the thread, reply with that finding ` +
|
|
@@ -115,7 +120,7 @@ export async function dispatchMentions(
|
|
|
115
120
|
// deliberate audit/re-measure), but the worker prompt carries the status and the outcome line warns.
|
|
116
121
|
const settled = ctx.status && ctx.status !== 'open' ? ctx.status : undefined
|
|
117
122
|
try {
|
|
118
|
-
const s = await newSession(
|
|
123
|
+
const s = await newSession(newWorkerPrompt(ctx.threadId, ctx.node, ctx.author, text, ctx.status), spawnParent(ctx.author, sessions), r.launcher)
|
|
119
124
|
out.push({ token: r.token, result: 'spawned', detail: s.id, ...(settled ? { note: `thread ${settled}` } : {}) })
|
|
120
125
|
} catch (e) { out.push({ token: r.token, result: 'failed', detail: e instanceof Error ? e.message : String(e) }) }
|
|
121
126
|
continue
|
|
@@ -205,7 +210,7 @@ export function summarize(outcomes: DispatchOutcome[], loopIn?: LoopIn | null):
|
|
|
205
210
|
const parts: string[] = []
|
|
206
211
|
if (outcomes.length) parts.push('@ ' + outcomes.map((o) => {
|
|
207
212
|
if (o.result === 'sent') return `${o.token}→sent`
|
|
208
|
-
if (o.result === 'spawned') return
|
|
213
|
+
if (o.result === 'spawned') return `${o.token}→${o.detail}${o.note ? ` ⚠ ${o.note} — likely already landed` : ''}`
|
|
209
214
|
if (o.result === 'offline') return `${o.token}→offline (stored)`
|
|
210
215
|
if (o.result === 'unresolved') return `${o.token}→? (no live session; stored)`
|
|
211
216
|
return `${o.token}→failed (${o.detail})`
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { shimRuntimeSource } from './shim-runtime.js'
|
|
2
|
+
|
|
3
|
+
// @@@ opencode plugin generator - the opencode adapter's "shim" is not a settings JSON but a generated
|
|
4
|
+
// opencode PLUGIN (.opencode/plugins/spexcode.ts, auto-loaded by opencode from the project tree) that runs
|
|
5
|
+
// inside the agent process. It is a THIN HOST over the shared shim runtime ([[shim-runtime]], embedded
|
|
6
|
+
// verbatim): this generator declares opencode's event-bus mapping (session.created → SessionStart,
|
|
7
|
+
// chat.message → UserPromptSubmit, tool.execute.before/after → Pre/PostToolUse, session.idle → Stop) and its
|
|
8
|
+
// host bindings; the payload synthesis, the block verdict (exit 2 + stdout decision:block JSON), and the
|
|
9
|
+
// rendezvous server come from the runtime. opencode's host-specific verdict consumers ([[opencode-harness]]):
|
|
10
|
+
// a PreToolUse block THROWS (opencode aborts the tool call); a Stop block re-injects the gate's reason as a
|
|
11
|
+
// follow-up prompt via the SDK client, closing the stop-gate loop. What re-enters is always the parsed
|
|
12
|
+
// REASON, never the escaped wire JSON. The rendezvous inject is client.session.prompt into the ROOT session,
|
|
13
|
+
// gated by canInject (a plugin with no adopted session reply-rejects instead of confirming an undeliverable
|
|
14
|
+
// prompt). What genuinely stays opencode-only below: session tracking (root vs subagent children, the
|
|
15
|
+
// agent_id stamp), the minted-id capture, and the two resume seeds.
|
|
16
|
+
// This module is a PURE content producer (no imports from harness.ts — the adapter object lives there, in the
|
|
17
|
+
// one HARNESSES seam) so there is no import cycle.
|
|
18
|
+
|
|
19
|
+
// the lifecycle events the generated plugin can emit (drives the shim + hooks-manifest binding). opencode's
|
|
20
|
+
// bus has no failed-stop and no idle/attention notification event, so StopFailure/Notification are genuinely
|
|
21
|
+
// absent — the same five codex binds.
|
|
22
|
+
export const OPENCODE_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'] as const
|
|
23
|
+
|
|
24
|
+
// opencode tool name → Claude tool name, so [[inject-spec-first]] / [[inject-spec-of-file]] and every other
|
|
25
|
+
// claude-family handler read the payload with zero opencode knowledge. Unknown tools pass through untouched
|
|
26
|
+
// (opencode's names are lowercase). Exported for tests.
|
|
27
|
+
export const OPENCODE_TOOL_NAMES: Record<string, string> = {
|
|
28
|
+
bash: 'Bash', edit: 'Edit', write: 'Write', read: 'Read', grep: 'Grep', glob: 'Glob', list: 'LS',
|
|
29
|
+
patch: 'Edit', todowrite: 'TodoWrite', todoread: 'TodoRead', webfetch: 'WebFetch', task: 'Task',
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// the generated plugin source. `dispatch` and `spex` are absolute paths baked at materialize time (same as
|
|
33
|
+
// every shim); the 'dispatch.sh' substring doubles as the identity stamp cleanHarness gates removal on. The
|
|
34
|
+
// file is plain TS with no @opencode-ai/plugin type import, so it loads regardless of the host project's
|
|
35
|
+
// node_modules; opencode calls every exported function with its plugin context.
|
|
36
|
+
export function opencodePluginSource(dispatch: string, spex: string): string {
|
|
37
|
+
return `// spexcode: GENERATED by spex materialize — do not edit (regenerated per clone; see [[opencode-harness]])
|
|
38
|
+
// Bridges opencode's event bus into SpexCode's dispatch.sh (claude-shaped payloads) and serves the
|
|
39
|
+
// per-session rendezvous socket for prompt delivery + liveness.
|
|
40
|
+
import { spawn } from "node:child_process"
|
|
41
|
+
${shimRuntimeSource('opencode', dispatch, spex)}
|
|
42
|
+
|
|
43
|
+
const TOOL_NAMES = ${JSON.stringify(OPENCODE_TOOL_NAMES)}
|
|
44
|
+
|
|
45
|
+
export const SpexcodePlugin = async (ctx) => {
|
|
46
|
+
const client = ctx && ctx.client
|
|
47
|
+
const cwd = (ctx && (ctx.directory || (ctx.app && ctx.app.path && ctx.app.path.cwd))) || process.cwd()
|
|
48
|
+
const recordId = (process.env.SPEXCODE_SESSION_ID || "").trim()
|
|
49
|
+
const rt = spexShimRuntime({ sessionId: () => recordId, cwd: () => cwd })
|
|
50
|
+
|
|
51
|
+
// session tracking: the ROOT opencode session is this worker's conversation; child sessions (subagents)
|
|
52
|
+
// are stamped agent_id so a parent's declared state stays out of its subagents' reach (the same
|
|
53
|
+
// discriminator claude's Task subagents carry). A RESUMED session re-fires no bus event until poked, so
|
|
54
|
+
// event-driven adoption alone leaves a resumed worker unreachable (the daemon reply-rejects every
|
|
55
|
+
// delivery): a --session resume seeds rootSession from the launch env (the resume marker's id), and a
|
|
56
|
+
// --continue resume falls back to asking the SDK for the newest root session.
|
|
57
|
+
let rootSession = (process.env.SPEXCODE_OPENCODE_RESUME_ID || "").trim()
|
|
58
|
+
const children = new Set()
|
|
59
|
+
const stamp = (sid) => (sid && children.has(sid) ? { agent_id: sid } : {})
|
|
60
|
+
// opencode MINTS its own session id — report it once so the backend stores it as harness_session_id
|
|
61
|
+
// (the id session resume passes to \`opencode --session <id>\`). Fire-and-forget; a failure only costs resume-by-id.
|
|
62
|
+
let captured = !!rootSession // a seeded (resumed) id is already on the record — don't re-report it
|
|
63
|
+
const capture = (sid) => {
|
|
64
|
+
if (captured || !sid || !recordId) return
|
|
65
|
+
captured = true
|
|
66
|
+
try { spawn(SPEX, ["internal", "opencode-capture", sid], { cwd, env: process.env, stdio: "ignore" }) } catch { /* resume falls back to --continue */ }
|
|
67
|
+
}
|
|
68
|
+
const adopt = (sid) => { if (!rootSession && sid) { rootSession = sid; capture(sid) } }
|
|
69
|
+
const injectPrompt = async (text) => {
|
|
70
|
+
if (!client || !rootSession) throw new Error("no session to prompt")
|
|
71
|
+
await client.session.prompt({ path: { id: rootSession }, body: { parts: [{ type: "text", text }] } })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// --continue resume: no owned id was ever captured, and a reattached session fires no bus event until
|
|
75
|
+
// poked — ask the SDK for the newest root session (what --continue itself reattached) so deliveries can
|
|
76
|
+
// inject. Gated on the launch marker so a FRESH launch can never adopt a stale session; best-effort with
|
|
77
|
+
// bounded retries (the server may still be loading at plugin init), shape-defensive, and adopt() ignores
|
|
78
|
+
// the late result if a real event won the race.
|
|
79
|
+
if (!rootSession && process.env.SPEXCODE_OPENCODE_CONTINUE && client && client.session && typeof client.session.list === "function") {
|
|
80
|
+
(async () => {
|
|
81
|
+
for (let i = 0; i < 10 && !rootSession; i++) {
|
|
82
|
+
try {
|
|
83
|
+
const res = await client.session.list()
|
|
84
|
+
const arr = (res && (res.data || res)) || []
|
|
85
|
+
const roots = (Array.isArray(arr) ? arr : []).filter((s) => s && s.id && !s.parentID)
|
|
86
|
+
const at = (s) => (s.time && (s.time.updated || s.time.created)) || 0
|
|
87
|
+
roots.sort((a, b) => at(b) - at(a))
|
|
88
|
+
if (roots.length) { adopt(roots[0].id); return }
|
|
89
|
+
} catch { /* not up yet — retry */ }
|
|
90
|
+
await new Promise((r) => setTimeout(r, 1000))
|
|
91
|
+
}
|
|
92
|
+
})()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// the rendezvous daemon, from the shared runtime: {type:"reply"} injects into the root session (a plugin
|
|
96
|
+
// that adopted no session yet reply-rejects synchronously, BEFORE repaint-done, so the sender fails loud
|
|
97
|
+
// instead of confirming a prompt that can never inject); {type:"repaint"} answers repaint-done in the same
|
|
98
|
+
// synchronous parse pass — confirmation means PARSED, not processed. The injection itself (a whole model
|
|
99
|
+
// turn: the SDK prompt call resolves only when the TURN ends) runs BEHIND the confirm.
|
|
100
|
+
rt.serveRendezvous(injectPrompt, { canInject: () => !!(client && rootSession) })
|
|
101
|
+
|
|
102
|
+
const toolPayload = (input, output) => {
|
|
103
|
+
const sid = (input && input.sessionID) || ""
|
|
104
|
+
return {
|
|
105
|
+
...stamp(sid), // agent_id must precede tool_input — hp_is_subagent checks keys only before tool_input
|
|
106
|
+
tool_name: TOOL_NAMES[(input && input.tool) || ""] || ((input && input.tool) || ""),
|
|
107
|
+
tool_input: rt.toolInput(output && output.args, "filePath"),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
event: async ({ event }) => {
|
|
113
|
+
const t = event && event.type
|
|
114
|
+
const p = (event && event.properties) || {}
|
|
115
|
+
if (t === "session.created") {
|
|
116
|
+
const info = p.info || {}
|
|
117
|
+
if (info.parentID) children.add(info.id)
|
|
118
|
+
else if (!rootSession) { adopt(info.id); await rt.dispatchEvent("SessionStart", { source: "startup" }) }
|
|
119
|
+
} else if (t === "session.idle") {
|
|
120
|
+
const sid = p.sessionID || ""
|
|
121
|
+
if (sid && rootSession && sid !== rootSession) return // a subagent going idle is not this worker's Stop
|
|
122
|
+
adopt(sid)
|
|
123
|
+
// the stop-gate loop closes in-process via the runtime's dispatchStop: stop_hook_active rides the
|
|
124
|
+
// payload (the gate's loop-termination bit) and the gate's parsed reason re-enters as a follow-up
|
|
125
|
+
// prompt; an inject the host can no longer take is reported loud.
|
|
126
|
+
await rt.dispatchStop((reason) => injectPrompt(reason), "blocked by a spexcode hook")
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
"chat.message": async (input, output) => {
|
|
130
|
+
const sid = (output && output.message && output.message.sessionID) || (input && input.sessionID) || ""
|
|
131
|
+
if (!(sid && children.has(sid))) adopt(sid)
|
|
132
|
+
const text = ((output && output.parts) || []).filter((x) => x && x.type === "text").map((x) => x.text).join("\\n")
|
|
133
|
+
await rt.dispatchEvent("UserPromptSubmit", { ...stamp(sid), prompt: text })
|
|
134
|
+
},
|
|
135
|
+
"tool.execute.before": async (input, output) => {
|
|
136
|
+
const r = await rt.dispatchEvent("PreToolUse", toolPayload(input, output))
|
|
137
|
+
if (rt.blocked(r)) throw new Error(rt.blockReason(r, "blocked by a spexcode hook")) // aborts the tool call — the PreToolUse block contract
|
|
138
|
+
},
|
|
139
|
+
"tool.execute.after": async (input, output) => {
|
|
140
|
+
await rt.dispatchEvent("PostToolUse", toolPayload(input, output))
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
`
|
|
145
|
+
}
|