@hybridlabor-api/aos 4.7.0 → 4.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.agents/agents.md +8 -3
  2. package/.agents/nodes.json +2 -1
  3. package/.agents/state.schema.json +24 -0
  4. package/.agents/vendor-manifest.json +6 -0
  5. package/.agents/workflows/startcycle-graph.md +2 -2
  6. package/.claude/agents/architect.md +5 -4
  7. package/.claude/agents/techlead.md +2 -0
  8. package/.claude/workflows/startcycle-dispatch.mjs +175 -17
  9. package/.opencode/agents/architect.md +5 -4
  10. package/.opencode/agents/techlead.md +2 -0
  11. package/THIRD_PARTY_NOTICES.md +9 -1
  12. package/bin/aos-doctor.mjs +386 -0
  13. package/bin/aos-store.mjs +171 -0
  14. package/docs/AUDIT_BRIEF_ECC_STORE.md +122 -0
  15. package/docs/PLAN_AOS_STORE_PHASE1.md +190 -0
  16. package/installer.js +68 -15
  17. package/lib/aos-archify-contract.mjs +277 -0
  18. package/lib/ecc-store-index.json +1 -0
  19. package/mcp_config.json +4 -0
  20. package/mcps/mcsc/packages/core/src/adapters/codex.js +160 -37
  21. package/package.json +7 -2
  22. package/scripts/build-ecc-store-index.mjs +147 -0
  23. package/skills/basic/startcycle-graph/SKILL.md +13 -0
  24. package/skills/global_config/agenttrail/bin/agenttrail.mjs +153 -42
  25. package/skills/global_config/agenttrail/public/index.html +5 -4
  26. package/skills/global_config/aos-setup/scripts/aos-doctor.mjs +8 -2
  27. package/skills/global_config/archify/examples/dataflow-product-analytics.html +15048 -0
  28. package/skills/global_config/archify/examples/lifecycle-agent-run.html +14983 -0
  29. package/skills/global_config/archify/examples/sequence-cache-miss-request.html +15063 -0
  30. package/skills/global_config/archify/examples/web-app-rendered.html +15012 -0
  31. package/skills/global_config/archify/examples/workflow-agent-tool-call-rendered.html +15054 -0
  32. package/skills/global_config/ask-tim/SKILL.md +10 -0
  33. package/skills/global_config/mcsc/SKILL.md +60 -0
  34. package/skills/global_config/plan-canvas/scripts/lib/plan-canvas/server.js +31 -6
  35. package/skills/global_config/plan-canvas/scripts/plan-canvas.js +4 -2
  36. package/docs/handover/agenttrail-for-ao.md +0 -58
  37. package/docs/handover/deja-for-ao.md +0 -51
@@ -20,6 +20,8 @@ const spawningPaths = new Set()
20
20
  let openBrowser = process.stdout.isTTY ? true : false
21
21
  let noOpen = false
22
22
  let hooksOnly = false
23
+ const MAX_BODY_BYTES = 64 * 1024
24
+ const MAX_CONTROL_BODY_BYTES = 4 * 1024
23
25
  let assumeYes = false
24
26
  let removeFlag = false
25
27
  let printFlag = false
@@ -57,6 +59,42 @@ for (let i = 0; i < argv.length; i++) {
57
59
  const planPath = planArg ? path.resolve(repo, planArg) : path.join(repo, 'PLAN.md')
58
60
  const atDir = path.join(repo, '.agenttrail')
59
61
 
62
+ function realDirectory(value) {
63
+ if (typeof value !== 'string' || value.length === 0) return null
64
+ try {
65
+ const resolved = fs.realpathSync(path.resolve(value))
66
+ return fs.statSync(resolved).isDirectory() ? resolved : null
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ function isWithin(root, candidate) {
73
+ const relativePath = path.relative(root, candidate)
74
+ return relativePath === '' || (relativePath !== '..' && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath))
75
+ }
76
+
77
+ function allowedSpawnDirectory(value) {
78
+ const target = realDirectory(value)
79
+ if (!target) return null
80
+ const roots = [repo]
81
+ for (const board of Array.isArray(boards) ? boards : []) if (board && board.repoPath) roots.push(board.repoPath)
82
+ try {
83
+ for (const file of fs.readdirSync(path.join(os.homedir(), '.agenttrail'))) {
84
+ if (!file.endsWith('.json')) continue
85
+ try {
86
+ const state = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.agenttrail', file), 'utf8'))
87
+ if (state && typeof state.repoPath === 'string') roots.push(state.repoPath)
88
+ } catch {}
89
+ }
90
+ } catch {}
91
+ for (const root of roots) {
92
+ const realRoot = realDirectory(root)
93
+ if (realRoot && isWithin(realRoot, target)) return target
94
+ }
95
+ return null
96
+ }
97
+
60
98
  async function askYesNo(q) {
61
99
  if (assumeYes || !process.stdin.isTTY) return true
62
100
  const rl = (await import('node:readline')).createInterface({ input: process.stdin, output: process.stdout })
@@ -71,7 +109,7 @@ function copyToClipboard(text) {
71
109
  return import('node:child_process').then(cp => new Promise(res => {
72
110
  const cmd = process.platform === 'darwin' ? 'pbcopy' : 'xclip'
73
111
  const args = process.platform === 'darwin' ? [] : ['-selection', 'clipboard']
74
- const p = cp.spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] })
112
+ const p = cp.spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'], shell: false })
75
113
  p.on('error', () => res(false)); p.on('close', () => res(true))
76
114
  p.stdin.end(text)
77
115
  })).catch(() => false)
@@ -87,8 +125,17 @@ if (cmd === 'ask') { process.exit(await askFlow()) }
87
125
  // each daemon keeps only events whose cwd lives inside its repo. Never blocks
88
126
  // the agent: 400ms cap, failures are silent.
89
127
  async function relayHook() {
90
- let raw = ''
91
- try { for await (const c of process.stdin) raw += c } catch {}
128
+ const chunks = []
129
+ let size = 0
130
+ try {
131
+ for await (const chunk of process.stdin) {
132
+ const buffer = Buffer.from(chunk)
133
+ size += buffer.length
134
+ if (size > MAX_BODY_BYTES) return
135
+ chunks.push(buffer)
136
+ }
137
+ } catch {}
138
+ const raw = Buffer.concat(chunks).toString('utf8')
92
139
  if (!raw) return
93
140
  // AOS patch: --agent <name> tags the hook event with the calling harness
94
141
  let body = raw
@@ -291,6 +338,28 @@ function relToRepo(p) {
291
338
  const r = path.resolve(String(p))
292
339
  return r === repo ? '' : r.startsWith(repo + path.sep) ? r.slice(repo.length + 1) : null
293
340
  }
341
+ // AOS patch: native plan/todo tools across harnesses, normalized to the
342
+ // {content, status} shape the frontend's run-todo rendering already expects.
343
+ // - Claude: `TodoWrite`, input {todos:[{content,status}]}
344
+ // - OpenCode: its real tool id is `todowrite` (verified live via `opencode
345
+ // run --format json`), but mcsc's opencode.js adapter title-cases the raw
346
+ // tool name before it reaches this hook (`part.tool.charAt(0).toUpperCase()
347
+ // + part.tool.slice(1)`), so it arrives here as `Todowrite`. Same input
348
+ // shape as Claude's, plus an extra `priority` field we ignore.
349
+ // - Codex: `update_plan`, input {explanation?, plan:[{step,status}]}. NOT
350
+ // verified live (Codex CLI's `--json` stream errored on auth in the
351
+ // environment this was built in, see report.md) — mapped on Codex's
352
+ // publicly documented tool contract; re-verify once Codex is authenticated.
353
+ function normalizeTodos(toolName, input) {
354
+ if (!input) return null
355
+ if ((toolName === 'TodoWrite' || toolName === 'Todowrite') && Array.isArray(input.todos)) {
356
+ return input.todos.map(t => ({ content: t.content, status: t.status }))
357
+ }
358
+ if (toolName === 'update_plan' && Array.isArray(input.plan)) {
359
+ return input.plan.map(t => ({ content: t.step, status: t.status }))
360
+ }
361
+ return null
362
+ }
294
363
  function handleHookEvent(ev) {
295
364
  const cwd = ev.cwd || ''
296
365
  if (!(cwd === repo || cwd.startsWith(repo + path.sep))) return false
@@ -327,9 +396,8 @@ function handleHookEvent(ev) {
327
396
  run.recentTools.unshift({ name: ev.tool_name, detail: toolDetail(ev.tool_input), at: Date.now(), ms: Date.now() - started })
328
397
  if (run.recentTools.length > 8) run.recentTools.length = 8
329
398
  run.currentTool = null
330
- if (ev.tool_name === 'TodoWrite' && ev.tool_input && Array.isArray(ev.tool_input.todos)) {
331
- run.todos = ev.tool_input.todos.map(t => ({ content: t.content, status: t.status }))
332
- }
399
+ const todos = normalizeTodos(ev.tool_name, ev.tool_input)
400
+ if (todos) run.todos = todos
333
401
  const rel = relToRepo(ev.tool_input && (ev.tool_input.file_path || ev.tool_input.notebook_path))
334
402
  if (rel) {
335
403
  heatFile(rel, Date.now())
@@ -503,15 +571,39 @@ function planStaleness() {
503
571
  const fresh = Date.now() - activity.at < 15 * 60e3
504
572
  return gap > 20 * 60e3 && fresh ? { stale: true, minutes: Math.round(gap / 60e3) } : { stale: false }
505
573
  }
574
+ // AOS patch: when no plan file exists yet, synthesize one synthetic
575
+ // component card per live run that has native todos (any harness — see
576
+ // normalizeTodos above) so the board renders instead of the empty
577
+ // "Waiting for the plan" state. A real plan file always wins outright: this
578
+ // only runs when planText is empty, and never mixes with parsed.nodes.
579
+ function syntheticPlanNodes() {
580
+ if (planText.length) return []
581
+ const out = []
582
+ for (const r of liveRuns()) {
583
+ if (!r.todos || !r.todos.length) continue
584
+ const id = 'live-' + r.id.replace(/[^a-z0-9]/gi, '').toLowerCase().slice(0, 20)
585
+ const done = r.todos.filter(t => t.status === 'completed').length
586
+ const status = r.todos.some(t => t.status === 'in_progress') ? 'active' : done === r.todos.length ? 'done' : 'pending'
587
+ out.push({
588
+ id, title: `${r.agent}'s live todos`, level: 'component', parent: null,
589
+ needs: [], links: [], files: [], tech: '', by: r.agent, kind: '', url: '',
590
+ status, synthetic: true,
591
+ touchedAt: r.lastEventAt, recent: [], filesList: [],
592
+ })
593
+ }
594
+ return out
595
+ }
506
596
  function model() {
507
597
  if (treeDirty) { tree = buildTree(repo); treeDirty = false }
508
598
  // AOS patch: include planFile (relative path) and aosPlan (boolean) for AOS UI customization
509
599
  const planFile = planArg ? path.relative(repo, planPath).split(path.sep).join('/') : undefined
510
600
  const aosPlan = Boolean(planArg)
601
+ const realPlanNodes = parsed.nodes.map(n => n.level === 'component' ? { ...n, touchedAt: compTouched[n.id] || null, recent: compRecent[n.id] || [], filesList: compFilesFor(n.id) } : n)
602
+ const planNodes = realPlanNodes.length ? realPlanNodes : syntheticPlanNodes()
511
603
  return {
512
604
  boards, port,
513
605
  runs: liveRuns(),
514
- session, plan: parsed.nodes.map(n => n.level === 'component' ? { ...n, touchedAt: compTouched[n.id] || null, recent: compRecent[n.id] || [], filesList: compFilesFor(n.id) } : n), tree,
606
+ session, plan: planNodes, tree,
515
607
  planTitle: parsed.title,
516
608
  hasPlan: planText.length > 0, treeTruncated,
517
609
  activity, recentActivity, planMtime, handoffs, asks, hotFiles: hotFiles(), cycles, // AOS patch: asks ride along so the open map updates live
@@ -593,14 +685,14 @@ function throttleBroadcast() {
593
685
  }
594
686
 
595
687
  // ---------- sibling boards (multi-repo: one daemon per repo, boards link to each other) ----------
596
- let boards = [{ port, project: path.basename(repo), self: true }]
688
+ let boards = [{ port, project: path.basename(repo), self: true, repoPath: repo }]
597
689
  async function discoverBoards() {
598
- const found = [{ port, project: session.project, self: true }]
690
+ const found = [{ port, project: session.project, self: true, repoPath: repo }]
599
691
  await Promise.allSettled(Array.from({ length: 15 }, (_, i) => 5330 + i).filter(p => p !== port).map(async p => {
600
692
  try {
601
693
  const r = await fetch(`http://127.0.0.1:${p}/whoami`, { signal: AbortSignal.timeout(300) })
602
694
  const j = await r.json()
603
- if (j && j.project) found.push({ port: p, project: j.project, self: false })
695
+ if (j && j.project) found.push({ port: p, project: j.project, self: false, repoPath: j.repoPath })
604
696
  } catch {}
605
697
  }))
606
698
  boards = found.sort((a, b) => a.port - b.port)
@@ -662,9 +754,18 @@ const server = http.createServer((req, res) => {
662
754
  res.writeHead(200, { 'content-type': 'text/html' }).end(fs.readFileSync(indexPath, 'utf8'))
663
755
  } else if (u.pathname.startsWith('/production_artifacts/')) {
664
756
  // AOS patch: serve pipeline artifacts so a card's relative `url:` (e.g. an archify diagram) opens
665
- const root = path.join(repo, 'production_artifacts')
666
- const f = path.resolve(repo, '.' + decodeURIComponent(u.pathname))
667
- if (!f.startsWith(root + path.sep) || !fs.statSync(f, { throwIfNoEntry: false })?.isFile()) { res.writeHead(404).end(); return }
757
+ let root
758
+ let f
759
+ try {
760
+ root = fs.realpathSync(path.join(repo, 'production_artifacts'))
761
+ f = fs.realpathSync(path.resolve(repo, '.' + decodeURIComponent(u.pathname)))
762
+ } catch {
763
+ res.writeHead(404).end()
764
+ return
765
+ }
766
+ let isFile = false
767
+ try { isFile = fs.statSync(f).isFile() } catch {}
768
+ if (!isWithin(root, f) || !isFile) { res.writeHead(404).end(); return }
668
769
  const type = f.endsWith('.html') ? 'text/html' : f.endsWith('.svg') ? 'image/svg+xml' : f.endsWith('.json') ? 'application/json' : 'text/plain'
669
770
  res.writeHead(200, { 'content-type': type + '; charset=utf-8' }).end(fs.readFileSync(f))
670
771
  } else if (u.pathname === '/whoami') {
@@ -701,30 +802,27 @@ const server = http.createServer((req, res) => {
701
802
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, prompt: BACKFILL_PROMPT }))
702
803
  }).catch(e => res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: String(e) })))
703
804
  } else if (u.pathname === '/setup-board' && req.method === 'POST') {
704
- let body = ''
705
- req.on('data', c => body += c)
706
- req.on('end', async () => {
805
+ readCapped(req, res, async body => {
707
806
  let out = { ok: false, error: 'no board there' }
708
807
  try {
709
- const p = Number(JSON.parse(body).port)
710
- out = await fetch(`http://127.0.0.1:${p}/setup`, { method: 'POST', signal: AbortSignal.timeout(5000) }).then(r => r.json())
808
+ const p = Number(JSON.parse(body || '{}').port)
809
+ if (!Number.isInteger(p) || p < 1 || p > 65535) out = { ok: false, error: 'bad port' }
810
+ else out = await fetch(`http://127.0.0.1:${p}/setup`, { method: 'POST', signal: AbortSignal.timeout(5000) }).then(r => r.json())
711
811
  } catch {}
712
812
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(out))
713
- })
813
+ }, MAX_CONTROL_BODY_BYTES)
714
814
  } else if (u.pathname === '/spawn' && req.method === 'POST') {
715
- let body = ''
716
- req.on('data', c => body += c)
717
- req.on('end', async () => {
815
+ readCapped(req, res, async body => {
718
816
  let out = { ok: false, error: 'bad request' }
719
817
  try {
720
- const p = path.resolve(String(JSON.parse(body).path || ''))
721
- if (!fs.existsSync(p) || !fs.statSync(p).isDirectory()) out = { ok: false, error: 'not a folder on this machine' }
818
+ const p = allowedSpawnDirectory(JSON.parse(body || '{}').path)
819
+ if (!p) out = { ok: false, error: 'spawn target is outside the allowed workspace' }
722
820
  else {
723
821
  let already = null
724
822
  for (const b of boards) {
725
823
  try {
726
824
  const w = await fetch(`http://127.0.0.1:${b.port}/whoami`, { signal: AbortSignal.timeout(300) }).then(r => r.json())
727
- if (w.repoPath === p) { already = b.port; break }
825
+ if (w.repoPath && realDirectory(w.repoPath) === p) { already = b.port; break }
728
826
  } catch {}
729
827
  }
730
828
  if (already) out = { ok: true, already }
@@ -732,7 +830,7 @@ const server = http.createServer((req, res) => {
732
830
  else {
733
831
  spawningPaths.add(p); setTimeout(() => spawningPaths.delete(p), 30000)
734
832
  const cp = await import('node:child_process')
735
- const child = cp.spawn(process.execPath, [fileURLToPath(import.meta.url), p, '--no-open'], { detached: true, stdio: 'ignore' })
833
+ const child = cp.spawn(process.execPath, [fileURLToPath(import.meta.url), p, '--no-open'], { detached: true, stdio: 'ignore', shell: false })
736
834
  child.unref()
737
835
  setTimeout(discoverBoards, 2500)
738
836
  setTimeout(discoverBoards, 6000)
@@ -741,11 +839,13 @@ const server = http.createServer((req, res) => {
741
839
  }
742
840
  } catch {}
743
841
  res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(out))
744
- })
842
+ }, MAX_CONTROL_BODY_BYTES)
745
843
  } else if (u.pathname === '/nudge') {
746
844
  const cwd = u.searchParams.get('cwd') || ''
747
845
  const st = planStaleness()
748
- const mine = cwd === repo || cwd.startsWith(repo + path.sep)
846
+ const candidate = realDirectory(cwd)
847
+ const root = realDirectory(repo)
848
+ const mine = !!(candidate && root && isWithin(root, candidate))
749
849
  res.writeHead(200, { 'content-type': 'text/plain' }).end(mine && st.stale
750
850
  ? `Note from agenttrail: PLAN.md in this repo is ${st.minutes} minutes behind the code. Re-verify task statuses against what you and other sessions actually changed, mark your current task [~], and keep files: globs current.`
751
851
  : '')
@@ -766,12 +866,10 @@ const server = http.createServer((req, res) => {
766
866
  clients.add(res)
767
867
  req.on('close', () => clients.delete(res))
768
868
  } else if (u.pathname === '/hook' && req.method === 'POST') {
769
- let body = ''
770
- req.on('data', c => body += c)
771
- req.on('end', () => {
772
- try { if (handleHookEvent(JSON.parse(body))) hookTick() } catch {}
869
+ readCapped(req, res, body => {
870
+ try { if (handleHookEvent(JSON.parse(body || '{}'))) hookTick() } catch {}
773
871
  res.writeHead(200).end()
774
- })
872
+ }, MAX_CONTROL_BODY_BYTES)
775
873
  } else if (u.pathname === '/ask' && req.method === 'POST') {
776
874
  // AOS patch: ask engine — register a question the human answers on the map.
777
875
  // Pure relay: an answer here never runs anything (go-gate keeps sole authority).
@@ -833,16 +931,29 @@ const server = http.createServer((req, res) => {
833
931
  } else res.writeHead(404).end()
834
932
  })
835
933
  // AOS patch: body reader for the ask/answer routes, capped at 4 KB
836
- function readCapped(req, res, cb) {
837
- let body = ''
934
+ function readCapped(req, res, cb, maxBytes = MAX_CONTROL_BODY_BYTES) {
935
+ const chunks = []
936
+ let size = 0
838
937
  let done = false
839
- req.on('data', c => {
938
+ req.on('data', chunk => {
939
+ if (done) return
940
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))
941
+ size += buffer.length
942
+ if (size > maxBytes) {
943
+ done = true
944
+ chunks.length = 0
945
+ res.writeHead(413, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'body too large' }))
946
+ req.destroy()
947
+ return
948
+ }
949
+ chunks.push(buffer)
950
+ })
951
+ req.on('end', () => {
840
952
  if (done) return
841
- body += c
842
- if (body.length > 4096) { done = true; body = ''; res.writeHead(413, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: false, error: 'body too large' })); req.destroy() }
953
+ done = true
954
+ cb(Buffer.concat(chunks).toString('utf8'))
843
955
  })
844
- req.on('end', () => { if (!done) { done = true; cb(body) } })
845
- req.on('error', () => {})
956
+ req.on('error', () => { done = true })
846
957
  }
847
958
  let lastHookTick = 0
848
959
  function hookTick() {
@@ -883,7 +994,7 @@ function onListen() {
883
994
  }
884
995
  if (openBrowser && !noOpen) {
885
996
  const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
886
- import('node:child_process').then(cp => cp.spawn(opener, [`http://localhost:${port}`], { stdio: 'ignore', detached: true }))
997
+ import('node:child_process').then(cp => cp.spawn(opener, [`http://localhost:${port}`], { stdio: 'ignore', detached: true, shell: false }))
887
998
  }
888
999
  firstRunFlow()
889
1000
  }
@@ -970,7 +1081,7 @@ async function upAll() {
970
1081
  for (const k of known) {
971
1082
  const live = await fetch(`http://127.0.0.1:${k.port}/whoami`, { signal: AbortSignal.timeout(300) }).then(r => r.json()).catch(() => null)
972
1083
  if (live) { console.log(`already up: ${k.repo} (:${k.port})`); continue }
973
- const child = cp.spawn(process.execPath, [fileURLToPath(import.meta.url), k.repo, '--port', String(k.port), '--no-open'], { detached: true, stdio: 'ignore' })
1084
+ const child = cp.spawn(process.execPath, [fileURLToPath(import.meta.url), k.repo, '--port', String(k.port), '--no-open'], { detached: true, stdio: 'ignore', shell: false })
974
1085
  child.unref()
975
1086
  console.log(`started: ${k.repo} (:${k.port})`)
976
1087
  }
@@ -318,7 +318,8 @@ function renderZero(){
318
318
  ${emptyStateBlock}
319
319
  </div>`
320
320
  }
321
- function isSkeletonPlan(){return M.plan.filter(n=>n.level==='task').length<=1}
321
+ function isSkeletonPlan(){return M.plan.filter(n=>n.level==='task').length<=1&&!hasSyntheticPlan(M)}
322
+ function hasSyntheticPlan(b){return(b.plan||[]).some(n=>n.synthetic)}
322
323
  function boardLayout(b){
323
324
  const phases=b.plan.filter(n=>n.level==='component')
324
325
  const byId=Object.fromEntries(phases.map(p=>[p.id,p])),depth={},visiting=new Set()
@@ -357,7 +358,7 @@ function regionCardHtml(b,L){
357
358
  const live=(b.runs||[]).filter(r=>!r.ended)
358
359
  const sq=b.plan.filter(n=>n.level==='component').map((c,i)=>`<rect x="${40+i*30}" y="${h/2+4}" width="20" height="20" rx="5" fill="${c.status==='done'?'#3f7f57':c.status==='blocked'?'var(--danger)':'none'}" stroke="${c.status==='active'?'var(--accent)':'var(--faint)'}" stroke-width="2.5"/>`).join('')
359
360
  const marks=live.map((r,i)=>{const ic=agentIcon(r.agent);return ic?`<g transform="translate(${40+i*34} ${h/2+44}) scale(1.1)"><path fill="${agentColor(r.agent)}" d="${ic}"/></g>`:''}).join('')
360
- return`<text x="40" y="${h/2-36}" fill="var(--text)" font-size="44" font-weight="650" font-family="var(--font)" letter-spacing="-1">${esc(b.project)}</text>${sq}${marks}${!b.hasPlan?`<text x="40" y="${h/2+44}" fill="var(--faint)" font-size="16" font-family="var(--font)">nothing mapped yet</text><g class="setup-btn" onclick="setupBoard(${b.port})" style="cursor:pointer"><rect x="40" y="${h/2+64}" width="172" height="40" rx="11" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.2"/><text x="126" y="${h/2+89}" text-anchor="middle" fill="var(--accent)" font-size="15.5" font-weight="600" font-family="var(--font)">Set up the map</text></g>`:''}${live.length?`<text x="${w-40}" y="${h/2-36}" text-anchor="end" fill="var(--dim)" font-size="16" font-family="var(--font)">${live.length} running</text>`:''}`
361
+ return`<text x="40" y="${h/2-36}" fill="var(--text)" font-size="44" font-weight="650" font-family="var(--font)" letter-spacing="-1">${esc(b.project)}</text>${sq}${marks}${(!b.hasPlan&&!hasSyntheticPlan(b))?`<text x="40" y="${h/2+44}" fill="var(--faint)" font-size="16" font-family="var(--font)">nothing mapped yet</text><g class="setup-btn" onclick="setupBoard(${b.port})" style="cursor:pointer"><rect x="40" y="${h/2+64}" width="172" height="40" rx="11" fill="var(--accent-bg)" stroke="var(--accent)" stroke-width="1.2"/><text x="126" y="${h/2+89}" text-anchor="middle" fill="var(--accent)" font-size="15.5" font-weight="600" font-family="var(--font)">Set up the map</text></g>`:(!b.hasPlan&&hasSyntheticPlan(b))?`<text x="40" y="${h/2+44}" fill="var(--faint)" font-size="16" font-family="var(--font)">live from todos, no plan file yet</text>`:''}${live.length?`<text x="${w-40}" y="${h/2-36}" text-anchor="end" fill="var(--dim)" font-size="16" font-family="var(--font)">${live.length} running</text>`:''}`
361
362
  }
362
363
  const CARD_LOD=0.3
363
364
  let _saved=null
@@ -366,7 +367,7 @@ function restoreBoardGlobals(){if(_saved){M=_saved.M;BP=_saved.BP;_saved=null}}
366
367
  function renderGraph(){
367
368
  const boards=boardsAll()
368
369
  if(!boards.length)return
369
- if(boards.length===1&&M&&(!M.hasPlan||isSkeletonPlan())){renderZero();return}
370
+ if(boards.length===1&&M&&((!M.hasPlan&&!hasSyntheticPlan(M))||isSkeletonPlan())){renderZero();return}
370
371
  regions={};graphPos={}
371
372
  const GAP=160,ROWMAX=3600;let cx=80,cy=80,rowH=0
372
373
  let content=''
@@ -381,7 +382,7 @@ function renderGraph(){
381
382
  for(const[id,q]of Object.entries(L.pos))graphPos[BP+':'+id]={...q,nodeX:q.nodeX+ox,nodeY:q.nodeY+56+oy}
382
383
  const hot=eff.activity&&Date.now()-eff.activity.at<60000
383
384
  const cardMode=graphZoom<CARD_LOD||!eff.hasPlan
384
- content+=`<g transform="translate(${ox} ${oy})"><rect width="${L.width}" height="${L.height+56}" rx="26" fill="var(--region)" stroke="${hot?'rgba(233,162,59,.5)':'var(--line)'}" stroke-width="1.5"/><text x="28" y="38" fill="var(--faint)" font-size="15" font-weight="600" font-family="var(--mono)" letter-spacing=".08em">${esc((eff.project||'').toUpperCase())}${eff.planStale&&eff.planStale.stale?`<tspan fill="var(--accent)"> · PLAN BEHIND</tspan>`:''}</text>${cardMode?regionCardHtml(eff,L):`<g transform="translate(0 56)">${boardInnerHtml(eff,L)}</g>`}</g>`
385
+ content+=`<g transform="translate(${ox} ${oy})"><rect width="${L.width}" height="${L.height+56}" rx="26" fill="var(--region)" stroke="${hot?'rgba(233,162,59,.5)':'var(--line)'}" stroke-width="1.5"/><text x="28" y="38" fill="var(--faint)" font-size="15" font-weight="600" font-family="var(--mono)" letter-spacing=".08em">${esc((eff.project||'').toUpperCase())}${eff.planStale&&eff.planStale.stale?`<tspan fill="var(--accent)"> · PLAN BEHIND</tspan>`:''}${hasSyntheticPlan(eff)?`<tspan fill="var(--dim)"> · LIVE TODOS, NO PLAN FILE</tspan>`:''}</text>${cardMode?regionCardHtml(eff,L):`<g transform="translate(0 56)">${boardInnerHtml(eff,L)}</g>`}</g>`
385
386
  cx+=L.width+GAP;rowH=Math.max(rowH,L.height+56)
386
387
  }
387
388
  M=realM;BP=realBP
@@ -52,7 +52,7 @@ const tilde = (p) => (p || '').replace(HOME, '~');
52
52
  // `which` does not exist on native Windows; cmd ships `where`, which prints one
53
53
  // path per line.
54
54
  const which = (bin) => {
55
- const probe = process.platform === 'win32' ? 'where' : 'which';
55
+ const probe = process.platform === 'win32' ? 'where.exe' : 'which';
56
56
  try { return tilde(execFileSync(probe, [bin], { encoding: 'utf8' }).trim().split(/\r?\n/)[0]); } catch { return null; }
57
57
  };
58
58
  const dirCount = (p) => { try { return readdirSync(p, { withFileTypes: true }).filter(d => d.isDirectory()).length; } catch { return 0; } };
@@ -100,7 +100,13 @@ function checkAos() {
100
100
 
101
101
  if (manifest && NET) {
102
102
  try {
103
- const latest = execFileSync('npm', ['view', '@hybridlabor-api/aos', 'version'], { encoding: 'utf8', timeout: 8000 }).trim();
103
+ const npmBin = IS_WIN ? 'npm.cmd' : 'npm';
104
+ const latest = execFileSync(npmBin, ['view', '@hybridlabor-api/aos', 'version'], {
105
+ encoding: 'utf8',
106
+ timeout: 8000,
107
+ stdio: ['ignore', 'pipe', 'ignore'],
108
+ shell: IS_WIN
109
+ }).trim();
104
110
  add('aos', 'version vs npm', manifest.version === latest, `local v${manifest.version} · npm v${latest}`,
105
111
  'npx -y @hybridlabor-api/aos@latest');
106
112
  } catch {