pantheon-opencode 1.3.3 → 1.3.5-beta.52.c75b04d

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.
Files changed (52) hide show
  1. package/AGENTS.md +919 -0
  2. package/README.md +157 -15
  3. package/bin/pantheon-init.mjs +177 -120
  4. package/opencode.json +3 -6
  5. package/package.json +8 -4
  6. package/scripts/build-agents-md.mjs +195 -0
  7. package/scripts/cost.mjs +17 -0
  8. package/scripts/doctor.mjs +56 -22
  9. package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
  10. package/scripts/install/opencode.mjs +189 -38
  11. package/scripts/install/plugin.mjs +112 -28
  12. package/scripts/install-mcp.mjs +1 -1
  13. package/scripts/redaction-gate.mjs +69 -0
  14. package/scripts/release-beta-version.mjs +80 -0
  15. package/scripts/release-notes.mjs +317 -0
  16. package/scripts/test-context-compression.sh +5 -2
  17. package/scripts/validate-package.mjs +56 -0
  18. package/scripts/versioning.mjs +117 -18
  19. package/src/agents/zeus.md +6 -2
  20. package/src/pantheon/background-job-board.ts +24 -38
  21. package/src/pantheon/chat-reminders.ts +79 -0
  22. package/src/pantheon/compaction-assert.ts +160 -0
  23. package/src/pantheon/cost-command.ts +57 -18
  24. package/src/pantheon/delegation-compaction.ts +143 -16
  25. package/src/pantheon/delegation-finalize.ts +8 -1
  26. package/src/pantheon/delegation-notify.ts +19 -157
  27. package/src/pantheon/delegation.ts +330 -28
  28. package/src/pantheon/dispatch-guard.ts +9 -4
  29. package/src/pantheon/goal-loop.ts +7 -4
  30. package/src/pantheon/hashline/tool.ts +2 -1
  31. package/src/pantheon/logger.ts +117 -0
  32. package/src/pantheon/plugin-once.ts +76 -0
  33. package/src/pantheon/preemptive-compact.ts +76 -0
  34. package/src/pantheon/presets.d.mts +31 -0
  35. package/src/pantheon/presets.mjs +105 -3
  36. package/src/pantheon/todo-enforcer.ts +79 -27
  37. package/src/pantheon/todo-preserve.ts +295 -0
  38. package/src/plugin.ts +177 -50
  39. package/src/plugins/pantheon-hooks.ts +35 -22
  40. package/src/plugins/tui/dist/server.d.ts +17 -0
  41. package/src/plugins/tui/dist/server.d.ts.map +1 -0
  42. package/src/plugins/tui/dist/server.js +20 -0
  43. package/src/plugins/tui/dist/server.js.map +1 -0
  44. package/src/plugins/tui/dist/tui.d.ts +303 -1
  45. package/src/plugins/tui/dist/tui.d.ts.map +1 -1
  46. package/src/plugins/tui/dist/tui.js +977 -350
  47. package/src/plugins/tui/dist/tui.tsx +1427 -150
  48. package/src/plugins/tui/package.json +4 -3
  49. package/src/plugins/tui/src/index.tsx +1427 -150
  50. package/src/plugins/tui/src/server.ts +15 -0
  51. package/src/plugins/tui/tsdown.config.ts +1 -1
  52. package/src/routing.yml +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pantheon-opencode",
3
- "version": "1.3.3",
3
+ "version": "1.3.5-beta.52.c75b04d",
4
4
  "description": "Multi-agent orchestration for OpenCode — 14 specialized agents, TDD, Themis review gates.",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -10,6 +10,7 @@
10
10
  "postinstall": "node scripts/postinstall.mjs",
11
11
  "prepare": "husky",
12
12
  "setup": "node bin/pantheon-init.mjs init",
13
+ "build:agents-md": "node scripts/build-agents-md.mjs",
13
14
  "setup:lite": "node bin/pantheon-init.mjs init --clean --components agents,skills,instructions",
14
15
  "doctor": "node scripts/doctor.mjs",
15
16
  "prune": "node scripts/prune.mjs",
@@ -21,7 +22,7 @@
21
22
  "version:status": "node scripts/versioning.mjs status",
22
23
  "version:apply": "node scripts/versioning.mjs apply",
23
24
  "version:check": "node scripts/version-check.mjs",
24
- "release:dry-run": "node scripts/versioning.mjs status && npm pack --dry-run",
25
+ "release:dry-run": "node scripts/versioning.mjs status && node scripts/release-notes.mjs && npm pack --dry-run",
25
26
  "publish:next": "npm publish --tag next --access public",
26
27
  "lint": "npx biome ci .",
27
28
  "typecheck": "npx tsc --noEmit",
@@ -30,7 +31,7 @@
30
31
  "hooks:install": "pre-commit install",
31
32
  "hooks:update": "pre-commit autoupdate",
32
33
  "test:security": "npm run secret-scan && node tests/test_install_mcp_vision.mjs && node tests/test_doctor_layers.mjs",
33
- "test:node": "node --test tests/*.mjs",
34
+ "test:node": "node --test tests/*.mjs tests/pantheon/*.mjs",
34
35
  "test:ts": "for f in tests/pantheon/*.test.ts; do npx tsx \"$f\" || exit 1; done",
35
36
  "test": "python3 -m pytest tests/ -q 2>&1 || echo \"No tests found\"",
36
37
  "test:ci": "python3 -m pytest tests/ -q --tb=short 2>&1 || true"
@@ -64,7 +65,10 @@
64
65
  "scripts/hooks/**",
65
66
  "src/mcp/requirements*",
66
67
  "src/plugins/tui/**",
67
- "src/skills/**"
68
+ "!src/plugins/tui/node_modules/**",
69
+ "src/skills/**",
70
+ "!**/__pycache__/**",
71
+ "!**/*.pyc"
68
72
  ],
69
73
  "dependencies": {
70
74
  "js-yaml": "^4.3.0",
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * build-agents-md.mjs — Generate the repo AGENTS.md from canonical sources.
4
+ *
5
+ * AGENTS.md is the single instruction file BOTH OpenCode V1 and V2 load
6
+ * (V1 auto-discovers it, V2 loads it explicitly; the `instructions` config
7
+ * key is accepted-but-ignored by V2). To keep instruction content in parity
8
+ * across both versions without duplication, this script consolidates:
9
+ *
10
+ * (a) a static header (project description + agent table + setup notes)
11
+ * (b) every src/instructions/*.instructions.md body, embedded under a
12
+ * `## <name>` section marker (name taken from frontmatter)
13
+ *
14
+ * into one generated AGENTS.md. src/instructions/*.instructions.md remains
15
+ * the source of truth — AGENTS.md is a committed build artifact, never
16
+ * hand-edited.
17
+ *
18
+ * Usage:
19
+ * node scripts/build-agents-md.mjs # write AGENTS.md to repo root
20
+ * node scripts/build-agents-md.mjs --check # exit 1 if AGENTS.md is stale
21
+ *
22
+ * No runtime dependencies (Node >= 18, stdlib only). Idempotent: running it
23
+ * twice produces byte-identical output.
24
+ */
25
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
26
+ import { dirname, join } from 'node:path'
27
+ import { fileURLToPath } from 'node:url'
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url))
30
+ export const ROOT = join(__dirname, '..')
31
+ const INSTRUCTIONS_DIR = join(ROOT, 'src', 'instructions')
32
+ const AGENTS_MD_PATH = join(ROOT, 'AGENTS.md')
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Static header — preserved verbatim from the original AGENTS.md (agent table,
36
+ // setup notes, sandbox rules, conventions). Backticks are escaped for the
37
+ // template literal.
38
+ // ---------------------------------------------------------------------------
39
+ const STATIC_HEADER = `# Pantheon Agent System — OpenCode
40
+
41
+ This project uses the Pantheon multi-agent framework with 14 specialized agents.
42
+
43
+ ## Available Agents
44
+
45
+ | Agent | Role |
46
+ |-------|------|
47
+ | @aphrodite | Frontend specialist — React 19, TypeScript strict, WCAG accessibility, responsive design, TDD, modern API patterns, deprecated npm detection. Calls apollo for discovery, sends to themis for review. |
48
+ | @apollo | Read-only investigation scout — 3–10 parallel searches across codebase, external docs, and GitHub. Called by: athena, zeus, hermes, aphrodite, demeter. No edits, no commands. |
49
+ | @athena | Strategic planner & architect — research-first, plan-only, never implements. Plans include quality gates (ruff/Biome, dep detection, LTS policy). Calls apollo for discovery. |
50
+ | @demeter | Database specialist — SQLAlchemy 2.0, Alembic, query optimization, N+1 prevention, TDD migrations, modern DB libs. Calls apollo for discovery, sends to themis. |
51
+ | @gaia | Remote sensing domain specialist — satellite image processing, spectral analysis, SAR, change detection, time series, ML/DL classification. Read-only analysis of geospatial data. |
52
+ | @hephaestus | AI tooling & pipelines specialist — LangChain/LangGraph chains, RAG architecture, vector stores, embedding strategies. Forges AI infrastructure. Calls apollo, sends to themis. |
53
+ | @hermes | Backend specialist — FastAPI, Python, async, TDD (RED→GREEN→REFACTOR), modern Python stdlib, obsolete lib detection. Calls apollo for discovery, sends to themis. |
54
+ | @iris | GitHub operations specialist — branches, pull requests, issues, releases, tags. Called by zeus after review. Never pushes or merges without explicit human approval. Integrates with VS Code GitHub Pull Requests extension. |
55
+ | @mnemosyne | Memory bank quality owner — initializes .pantheon/memory-bank/, writes ADRs and task records on explicit request. Called by zeus. Never invoked automatically after phases. |
56
+ | @nyx | Observability & monitoring specialist — OpenTelemetry tracing, token/cost tracking, agent performance analytics, LangSmith integration. Calls apollo for discovery, sends to themis. |
57
+ | @prometheus | Infrastructure + model provider specialist — Docker, CI/CD, multi-model routing, cost optimization, provider abstraction |
58
+ | @talos | Hotfix express lane — direct fixes for small bugs, CSS, typos, minor logic. No TDD ceremony, no orchestration overhead. Standalone, no subagents. Escalates complex issues to zeus. |
59
+ | @themis | Quality & security gate — ruff/Biome linting, dead/legacy code detection, OWASP Top 10, coverage >80%, correctness, deprecation audit. Called by implementers; escalates blockers to zeus. |
60
+ | @zeus | Central orchestrator — never implements. Delegates to: athena, apollo, hermes, aphrodite, demeter, prometheus, themis, iris, mnemosyne, talos, hephaestus, nyx |
61
+
62
+ ## OpenCode Setup
63
+
64
+ See [INSTALLATION.md](docs/INSTALLATION.md) for setup instructions.
65
+
66
+ - Build: \`npm test\`
67
+ - Test: \`npm test\`
68
+ - Lint: \`npm run lint\`
69
+
70
+ ## Teste de Instalação Global (sandbox)
71
+
72
+ Para validar a instalação global do pacote pantheon-opencode COMO UM USUÁRIO REAL, use o sandbox isolado em \`~/pantheon-sandbox/\` (fora do repo, HOME + prefix npm + venv próprios). O ambiente de dev mistura 3 instalações + config global + venv — NÃO serve para testar instalação/empacotamento.
73
+
74
+ - Rodar: \`bash ~/pantheon-sandbox/run-test.sh\` (opencode mcp list 5/5 connected + doctor 0 erros + abre TUI isolado)
75
+ - Regra para agentes: ao validar instalação global (npm pack, \`init\`, MCPs, hooks), usar o sandbox — NUNCA testar no ambiente de dev
76
+ - Descarte: \`rm -rf ~/pantheon-sandbox\`
77
+ - Detalhes: ver \`~/pantheon-sandbox/README.md\`
78
+
79
+ ## Conventions
80
+
81
+ - TDD: Write failing test first, then implement
82
+ - Coverage minimum: 80%
83
+ - Async/await on all I/O
84
+ - Type hints on all functions
85
+ - PRs always update the README — every pull request must document newly added features, behaviors, env vars, or commands in the README before being opened.
86
+ `
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Frontmatter parsing (stdlib only — no yaml dependency)
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Parse YAML-ish frontmatter from an instruction file.
94
+ *
95
+ * Only the `name` field is extracted (single-line scalar). The frontmatter
96
+ * block itself is stripped from the embedded body.
97
+ *
98
+ * @param {string} content - Raw file content
99
+ * @returns {{ name: string, body: string } | null}
100
+ */
101
+ function parseInstruction(content) {
102
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
103
+ if (!match) return null
104
+ const fm = match[1]
105
+ const nameMatch = fm.match(/^name:\s*["']?([^"'\r\n]+)["']?\s*$/m)
106
+ if (!nameMatch) return null
107
+ return {
108
+ name: nameMatch[1].trim(),
109
+ body: content.slice(match[0].length).trimEnd() + '\n',
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Read all src/instructions/*.instructions.md files, sorted by filename for
115
+ * deterministic output.
116
+ *
117
+ * @returns {Array<{ file: string, name: string, body: string }>}
118
+ */
119
+ export function readInstructions() {
120
+ if (!existsSync(INSTRUCTIONS_DIR)) return []
121
+ const files = readdirSync(INSTRUCTIONS_DIR)
122
+ .filter((f) => f.endsWith('.instructions.md'))
123
+ .sort()
124
+ const out = []
125
+ for (const file of files) {
126
+ const content = readFileSync(join(INSTRUCTIONS_DIR, file), 'utf8')
127
+ const parsed = parseInstruction(content)
128
+ if (parsed) out.push({ file, ...parsed })
129
+ }
130
+ return out
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Generation
135
+ // ---------------------------------------------------------------------------
136
+
137
+ /**
138
+ * Generate the full AGENTS.md content.
139
+ *
140
+ * @param {Array<{ file: string, name: string, body: string }>} instructions
141
+ * @returns {string}
142
+ */
143
+ export function generateAgentsMd(instructions) {
144
+ const sections = [
145
+ '<!-- Generated by scripts/build-agents-md.mjs — do not edit directly. -->',
146
+ '<!-- Source of truth: src/instructions/*.instructions.md (AGENTS.md is a committed build artifact). -->',
147
+ '',
148
+ STATIC_HEADER.trimEnd(),
149
+ '',
150
+ '---',
151
+ '',
152
+ '# Agent Instructions',
153
+ '',
154
+ 'The following sections are consolidated from `src/instructions/*.instructions.md`',
155
+ 'so both OpenCode V1 (auto-loads AGENTS.md) and V2 (loads AGENTS.md, ignores the',
156
+ '`instructions` config key) receive identical instruction content.',
157
+ '',
158
+ ]
159
+ for (const instr of instructions) {
160
+ sections.push(
161
+ `<!-- Source: src/instructions/${instr.file} -->`,
162
+ `## ${instr.name}`,
163
+ '',
164
+ instr.body.trimEnd(),
165
+ '',
166
+ )
167
+ }
168
+ return sections.join('\n').trimEnd() + '\n'
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // CLI entry point
173
+ // ---------------------------------------------------------------------------
174
+
175
+ function main() {
176
+ const checkOnly = process.argv.includes('--check')
177
+ const instructions = readInstructions()
178
+ const content = generateAgentsMd(instructions)
179
+
180
+ if (checkOnly) {
181
+ const existing = existsSync(AGENTS_MD_PATH) ? readFileSync(AGENTS_MD_PATH, 'utf8') : ''
182
+ if (existing !== content) {
183
+ console.error(`❌ AGENTS.md is stale — run: node scripts/build-agents-md.mjs`)
184
+ process.exit(1)
185
+ }
186
+ console.log(`✅ AGENTS.md is up to date (${instructions.length} instruction sections)`)
187
+ return
188
+ }
189
+
190
+ writeFileSync(AGENTS_MD_PATH, content, 'utf8')
191
+ const lines = content.split('\n').length
192
+ console.log(`✅ Generated AGENTS.md (${instructions.length} instruction sections, ${lines} lines)`)
193
+ }
194
+
195
+ main()
package/scripts/cost.mjs CHANGED
@@ -26,6 +26,22 @@ function fail(message) {
26
26
  process.exitCode = 1
27
27
  }
28
28
 
29
+ function assertCompatibleSchema(db) {
30
+ const table = db
31
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message'")
32
+ .get()
33
+ if (!table)
34
+ throw new Error('incompatible opencode.db schema: required "message" table is missing')
35
+ const columns = db.prepare('PRAGMA table_info(message)').all()
36
+ const names = new Set(columns.map((column) => column.name))
37
+ const missing = ['data', 'time_created'].filter((name) => !names.has(name))
38
+ if (missing.length > 0) {
39
+ throw new Error(
40
+ `incompatible opencode.db schema: message table is missing ${missing.join(', ')}; select the matching V1/V2 database`,
41
+ )
42
+ }
43
+ }
44
+
29
45
  function main() {
30
46
  const [, , dbPath, daysArg] = process.argv
31
47
  const days = Number(daysArg ?? '7')
@@ -35,6 +51,7 @@ function main() {
35
51
 
36
52
  try {
37
53
  const db = new DatabaseSync(dbPath, { readOnly: true })
54
+ assertCompatibleSchema(db)
38
55
  const since = Date.now() - days * 86_400_000
39
56
  const rows = db.prepare('SELECT data FROM message WHERE time_created >= ?').all(since)
40
57
  db.close()
@@ -34,7 +34,7 @@ import { fileURLToPath } from 'node:url'
34
34
 
35
35
  const __dirname = dirname(fileURLToPath(import.meta.url))
36
36
  const ROOT = join(__dirname, '..')
37
- const AGENTS_DIR = join(ROOT, "src", "agents")
37
+ const AGENTS_DIR = join(ROOT, 'src', 'agents')
38
38
  const PLATFORM_DIR = join(ROOT, 'platform')
39
39
 
40
40
  // All known platforms (must match platform/ subdirectories with adapter.json)
@@ -246,7 +246,9 @@ function checkAgentFiles(args) {
246
246
 
247
247
  // Determine which platforms to check
248
248
  if (!existsSync(PLATFORM_DIR)) {
249
- info(`Platform component not distributed in this package — platform check skipped (${args.profile} profile)`)
249
+ info(
250
+ `Platform component not distributed in this package — platform check skipped (${args.profile} profile)`,
251
+ )
250
252
  return
251
253
  }
252
254
  const platformDirs = readdirSync(PLATFORM_DIR, { withFileTypes: true })
@@ -255,7 +257,9 @@ function checkAgentFiles(args) {
255
257
  .filter((name) => !args.platform || name === args.platform)
256
258
 
257
259
  if (platformDirs.length === 0) {
258
- info(`No platform directories found in platform/ — platform check skipped (${args.profile} profile)`)
260
+ info(
261
+ `No platform directories found in platform/ — platform check skipped (${args.profile} profile)`,
262
+ )
259
263
  return
260
264
  }
261
265
 
@@ -377,7 +381,8 @@ function checkMcpConfig(args) {
377
381
 
378
382
  const mcp = cfg.data.mcp
379
383
  if (!mcp || typeof mcp !== 'object') {
380
- if (requiredMcpNames.length > 0) error(`${cfg.label}: required MCPs missing — no "mcp" section`)
384
+ if (requiredMcpNames.length > 0)
385
+ error(`${cfg.label}: required MCPs missing — no "mcp" section`)
381
386
  else info(`${cfg.label}: no "mcp" section (${args.profile} profile — optional)`)
382
387
  continue
383
388
  }
@@ -450,7 +455,9 @@ function checkMcpConfig(args) {
450
455
  const exe = cmd[0]
451
456
  if (!exe.includes('/')) {
452
457
  // PATH-resolved executable — cannot verify statically; surface for manual check
453
- info(`${cfg.label}: MCP "${name}" uses PATH executable "${exe}" (verify with \`which ${exe}\`)`)
458
+ info(
459
+ `${cfg.label}: MCP "${name}" uses PATH executable "${exe}" (verify with \`which ${exe}\`)`,
460
+ )
454
461
  continue
455
462
  }
456
463
  if (!existsSync(exe)) {
@@ -467,7 +474,8 @@ function checkMcpConfig(args) {
467
474
  if (requiredMcpNames.length > 0) {
468
475
  const configured = new Set(configs.flatMap((cfg) => Object.keys(cfg.data.mcp ?? {})))
469
476
  for (const required of requiredMcpNames) {
470
- if (configured.has(required)) pass(`Required MCP "${required}" configured in an available config`)
477
+ if (configured.has(required))
478
+ pass(`Required MCP "${required}" configured in an available config`)
471
479
  else error(`Required MCP "${required}" not configured in any available config`)
472
480
  }
473
481
  }
@@ -535,12 +543,17 @@ function mcpInitializeSmoke(command, cwd, timeoutMs) {
535
543
  const msg = JSON.parse(line.trim())
536
544
  if (msg.id === 1 && msg.result) {
537
545
  const info = msg.result.serverInfo
538
- const label = info?.name ? `${info.name}${info.version ? ` ${info.version}` : ''}` : undefined
546
+ const label = info?.name
547
+ ? `${info.name}${info.version ? ` ${info.version}` : ''}`
548
+ : undefined
539
549
  finish({ ok: true, serverName: label })
540
550
  return
541
551
  }
542
552
  if (msg.error) {
543
- finish({ ok: false, reason: `initialize error: ${msg.error.code ?? ''} ${msg.error.message ?? ''}` })
553
+ finish({
554
+ ok: false,
555
+ reason: `initialize error: ${msg.error.code ?? ''} ${msg.error.message ?? ''}`,
556
+ })
544
557
  }
545
558
  } catch {
546
559
  /* not JSON yet — keep buffering */
@@ -592,7 +605,9 @@ async function checkMcpRuntimeSmoke(args) {
592
605
  for (const { name, command, cwd, source } of local) {
593
606
  const result = await mcpInitializeSmoke(command, cwd, 10000)
594
607
  if (result.ok) {
595
- pass(`MCP "${name}" (${source}): initialize handshake OK${result.serverName ? ` — ${result.serverName}` : ''}`)
608
+ pass(
609
+ `MCP "${name}" (${source}): initialize handshake OK${result.serverName ? ` — ${result.serverName}` : ''}`,
610
+ )
596
611
  } else {
597
612
  error(`MCP "${name}" (${source}): ${result.reason}`)
598
613
  }
@@ -645,7 +660,9 @@ function checkPermissionMismatches(args) {
645
660
  // Locate the validation script
646
661
  const validator = join(ROOT, 'scripts', 'validate-agent-frontmatter.py')
647
662
  if (!existsSync(validator)) {
648
- info(`validate-agent-frontmatter.py not found — optional helper skipped (${args.profile} profile)`)
663
+ info(
664
+ `validate-agent-frontmatter.py not found — optional helper skipped (${args.profile} profile)`,
665
+ )
649
666
  return
650
667
  }
651
668
 
@@ -876,12 +893,24 @@ function checkRequirementLine(line, installed) {
876
893
  const cmp = compareVersions(have, want)
877
894
  let ok = false
878
895
  switch (op) {
879
- case '==': ok = cmp === 0; break
880
- case '>=': ok = cmp >= 0; break
881
- case '<=': ok = cmp <= 0; break
882
- case '>': ok = cmp > 0; break
883
- case '<': ok = cmp < 0; break
884
- case '!=': ok = cmp !== 0; break
896
+ case '==':
897
+ ok = cmp === 0
898
+ break
899
+ case '>=':
900
+ ok = cmp >= 0
901
+ break
902
+ case '<=':
903
+ ok = cmp <= 0
904
+ break
905
+ case '>':
906
+ ok = cmp > 0
907
+ break
908
+ case '<':
909
+ ok = cmp < 0
910
+ break
911
+ case '!=':
912
+ ok = cmp !== 0
913
+ break
885
914
  case '~=': {
886
915
  const spec = parseVersion(want)
887
916
  const got = parseVersion(have)
@@ -961,12 +990,12 @@ function checkVenvLayer(args) {
961
990
  */
962
991
  export function summaryMessage(summaryCounts, summaryExitCode) {
963
992
  if (summaryCounts.error > 0 || summaryExitCode !== 0) {
964
- const errorText = summaryCounts.error > 0
965
- ? `${summaryCounts.error} blocking error(s) found`
966
- : 'Blocking check failure'
967
- const warningText = summaryCounts.warn > 0
968
- ? `; ${summaryCounts.warn} warning(s) remain advisory`
969
- : ''
993
+ const errorText =
994
+ summaryCounts.error > 0
995
+ ? `${summaryCounts.error} blocking error(s) found`
996
+ : 'Blocking check failure'
997
+ const warningText =
998
+ summaryCounts.warn > 0 ? `; ${summaryCounts.warn} warning(s) remain advisory` : ''
970
999
  return `❌ ${errorText}${warningText} — exit code ${summaryExitCode}`
971
1000
  }
972
1001
 
@@ -1005,6 +1034,11 @@ async function main() {
1005
1034
  process.exit(0)
1006
1035
  }
1007
1036
 
1037
+ // Report the resolved validation profile up front (auto → global/sandbox)
1038
+ // so callers — including the `doctor` CLI wrapper — can see which policy
1039
+ // gate applied before any check output.
1040
+ console.log(`Validation profile: ${args.profile}`)
1041
+
1008
1042
  if (!existsSync(args.target)) {
1009
1043
  console.error(`❌ Target directory does not exist: ${args.target}`)
1010
1044
  process.exit(2)
@@ -383,12 +383,27 @@ fi
383
383
  # Emit a schema-compatible line: {event, timestamp, agent, status, reason,
384
384
  # session_id, result, delegation_id, task_id, duration_ms} — P0-2: the
385
385
  # delegation_id joins this record to the matching SubagentStart; task_id and
386
- # duration_ms ride in their OWN fields. Built with json.dumps so arbitrary
386
+ # duration_ms ride in their OWN fields. 1.3.4: task_id is OMITTED when empty
387
+ # (never "" — the real child id is always present when a job exists) and
388
+ # duration_ms is emitted as a NUMBER (the plugin computes it; stringifying it
389
+ # broke downstream aggregation). Built with json.dumps so arbitrary
387
390
  # reason/result text cannot break the JSON.
388
391
  LOG_LINE=$(python3 -c '
389
392
  import json
390
393
  import sys
391
- print(json.dumps({
394
+
395
+
396
+ def _num(v):
397
+ """Best-effort numeric parse — the plugin duration_ms is a number; a
398
+ non-numeric value (e.g. unset) becomes null, never a string."""
399
+ try:
400
+ return int(float(v))
401
+ except (TypeError, ValueError):
402
+ return None
403
+
404
+
405
+ task_id = sys.argv[8] or ""
406
+ line = {
392
407
  "event": "SubagentStop",
393
408
  "timestamp": sys.argv[1],
394
409
  "agent": sys.argv[2],
@@ -397,9 +412,11 @@ print(json.dumps({
397
412
  "session_id": sys.argv[5],
398
413
  "result": sys.argv[6],
399
414
  "delegation_id": sys.argv[7],
400
- "task_id": sys.argv[8],
401
- "duration_ms": sys.argv[9] or None,
402
- }, ensure_ascii=False))
415
+ }
416
+ if task_id:
417
+ line["task_id"] = task_id
418
+ line["duration_ms"] = _num(sys.argv[9])
419
+ print(json.dumps(line, ensure_ascii=False))
403
420
  ' "$TIMESTAMP" "$AGENT_NAME" "$STATUS" "$REASON" "$SESSION_ID" "$RESULT_SNIPPET" "$DELEGATION_ID" "$TASK_ID" "$DURATION_MS")
404
421
 
405
422
  LOG_FILE="$LOG_DIR/delegations.log"