@stravica-ai/digital-operator-pa 0.1.0

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 (372) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -0
  3. package/bin/desk +9 -0
  4. package/bin/distill +2 -0
  5. package/bin/estate-sync +2 -0
  6. package/bin/launch +2 -0
  7. package/bin/memory-sync +2 -0
  8. package/bin/migrate-inspect +2 -0
  9. package/bin/pa +2 -0
  10. package/bin/pa-coordinator-hook +2 -0
  11. package/bin/pa-init +2 -0
  12. package/bin/research +2 -0
  13. package/bin/scheduler +2 -0
  14. package/bin/secrets +2 -0
  15. package/bin/slice +2 -0
  16. package/bin/smoke +9 -0
  17. package/bin/sync +2 -0
  18. package/dist/engineering-pa-cli/README.md +205 -0
  19. package/dist/engineering-pa-cli/bin/estate-sync +2 -0
  20. package/dist/engineering-pa-cli/bin/launch +2 -0
  21. package/dist/engineering-pa-cli/bin/pa +2 -0
  22. package/dist/engineering-pa-cli/bin/pa-coordinator-hook +12 -0
  23. package/dist/engineering-pa-cli/bin/research +2 -0
  24. package/dist/engineering-pa-cli/bin/scheduler +2 -0
  25. package/dist/engineering-pa-cli/bin/secrets +2 -0
  26. package/dist/engineering-pa-cli/bin/smoke +9 -0
  27. package/dist/engineering-pa-cli/bin/sync +2 -0
  28. package/dist/engineering-pa-cli/config/coordinator-allowlist.json +118 -0
  29. package/dist/engineering-pa-cli/docs/coordinator-hook.md +211 -0
  30. package/dist/engineering-pa-cli/docs/estate-sync.md +412 -0
  31. package/dist/engineering-pa-cli/docs/gsc.md +175 -0
  32. package/dist/engineering-pa-cli/docs/lint-linkedin.md +139 -0
  33. package/dist/engineering-pa-cli/docs/secrets.md +221 -0
  34. package/dist/engineering-pa-cli/docs/smoke.md +113 -0
  35. package/dist/engineering-pa-cli/docs/staging-alias.md +114 -0
  36. package/dist/engineering-pa-cli/index.mjs +17 -0
  37. package/dist/engineering-pa-cli/package.json +37 -0
  38. package/dist/engineering-pa-cli/src/autonomy/cli.mjs +329 -0
  39. package/dist/engineering-pa-cli/src/autonomy/envelope.mjs +301 -0
  40. package/dist/engineering-pa-cli/src/coordinator-hook/cli.mjs +140 -0
  41. package/dist/engineering-pa-cli/src/coordinator-hook/config.mjs +134 -0
  42. package/dist/engineering-pa-cli/src/coordinator-hook/message.mjs +47 -0
  43. package/dist/engineering-pa-cli/src/coordinator-hook/policy.mjs +211 -0
  44. package/dist/engineering-pa-cli/src/coordinator-hook/repo-root.mjs +44 -0
  45. package/dist/engineering-pa-cli/src/coordinator-hook/self.mjs +30 -0
  46. package/dist/engineering-pa-cli/src/environment/cli.mjs +287 -0
  47. package/dist/engineering-pa-cli/src/environment/probes.mjs +302 -0
  48. package/dist/engineering-pa-cli/src/gsc/auth.mjs +148 -0
  49. package/dist/engineering-pa-cli/src/gsc/cli.mjs +93 -0
  50. package/dist/engineering-pa-cli/src/gsc/cloudflare.mjs +318 -0
  51. package/dist/engineering-pa-cli/src/gsc/commands/add-property.mjs +70 -0
  52. package/dist/engineering-pa-cli/src/gsc/commands/list-properties.mjs +33 -0
  53. package/dist/engineering-pa-cli/src/gsc/commands/pull-analytics.mjs +130 -0
  54. package/dist/engineering-pa-cli/src/gsc/commands/submit-sitemap.mjs +41 -0
  55. package/dist/engineering-pa-cli/src/gsc/commands/verify-via-cf-dns.mjs +189 -0
  56. package/dist/engineering-pa-cli/src/knowledge/call-log.mjs +59 -0
  57. package/dist/engineering-pa-cli/src/knowledge/cli.mjs +242 -0
  58. package/dist/engineering-pa-cli/src/knowledge/eval.mjs +446 -0
  59. package/dist/engineering-pa-cli/src/knowledge/formatters.mjs +331 -0
  60. package/dist/engineering-pa-cli/src/knowledge/graph.mjs +710 -0
  61. package/dist/engineering-pa-cli/src/launch/README.md +214 -0
  62. package/dist/engineering-pa-cli/src/launch/cli.mjs +51 -0
  63. package/dist/engineering-pa-cli/src/launch/commands/launch.mjs +106 -0
  64. package/dist/engineering-pa-cli/src/launch/identity.mjs +43 -0
  65. package/dist/engineering-pa-cli/src/launch/planner.mjs +44 -0
  66. package/dist/engineering-pa-cli/src/launch/repo-root.mjs +53 -0
  67. package/dist/engineering-pa-cli/src/launch/runner.mjs +147 -0
  68. package/dist/engineering-pa-cli/src/launch/task-loader.mjs +85 -0
  69. package/dist/engineering-pa-cli/src/lint/cli.mjs +61 -0
  70. package/dist/engineering-pa-cli/src/lint/commands/linkedin.mjs +133 -0
  71. package/dist/engineering-pa-cli/src/lint/detectors.mjs +90 -0
  72. package/dist/engineering-pa-cli/src/lint/linter.mjs +69 -0
  73. package/dist/engineering-pa-cli/src/lint/reporter.mjs +32 -0
  74. package/dist/engineering-pa-cli/src/lint/rules.mjs +132 -0
  75. package/dist/engineering-pa-cli/src/pa/cli.mjs +369 -0
  76. package/dist/engineering-pa-cli/src/pa/doc-groups.mjs +164 -0
  77. package/dist/engineering-pa-cli/src/pa/io.mjs +80 -0
  78. package/dist/engineering-pa-cli/src/pa/project-cli.mjs +385 -0
  79. package/dist/engineering-pa-cli/src/pa/projects.mjs +352 -0
  80. package/dist/engineering-pa-cli/src/pa/repo-root.mjs +64 -0
  81. package/dist/engineering-pa-cli/src/pa/session.mjs +254 -0
  82. package/dist/engineering-pa-cli/src/pa/work-cli.mjs +164 -0
  83. package/dist/engineering-pa-cli/src/pa/work.mjs +196 -0
  84. package/dist/engineering-pa-cli/src/pa/workstreams.mjs +201 -0
  85. package/dist/engineering-pa-cli/src/research/README.md +360 -0
  86. package/dist/engineering-pa-cli/src/research/briefs/extractor.md +103 -0
  87. package/dist/engineering-pa-cli/src/research/briefs/searcher.md +113 -0
  88. package/dist/engineering-pa-cli/src/research/briefs/synthesizer.md +124 -0
  89. package/dist/engineering-pa-cli/src/research/briefs/verifier.md +86 -0
  90. package/dist/engineering-pa-cli/src/research/cli.mjs +141 -0
  91. package/dist/engineering-pa-cli/src/research/commands/gates.mjs +176 -0
  92. package/dist/engineering-pa-cli/src/research/commands/list.mjs +56 -0
  93. package/dist/engineering-pa-cli/src/research/commands/mark.mjs +97 -0
  94. package/dist/engineering-pa-cli/src/research/commands/paths.mjs +69 -0
  95. package/dist/engineering-pa-cli/src/research/commands/prune.mjs +86 -0
  96. package/dist/engineering-pa-cli/src/research/commands/run.mjs +94 -0
  97. package/dist/engineering-pa-cli/src/research/commands/show.mjs +43 -0
  98. package/dist/engineering-pa-cli/src/research/docs/operator-present-flow.md +120 -0
  99. package/dist/engineering-pa-cli/src/research/format.mjs +59 -0
  100. package/dist/engineering-pa-cli/src/research/promote.mjs +260 -0
  101. package/dist/engineering-pa-cli/src/research/schema/finding.schema.json +55 -0
  102. package/dist/engineering-pa-cli/src/research/schema/gates.schema.json +34 -0
  103. package/dist/engineering-pa-cli/src/research/schema/manifest.schema.json +59 -0
  104. package/dist/engineering-pa-cli/src/research/schema/plan.schema.json +41 -0
  105. package/dist/engineering-pa-cli/src/research/schema/sources.schema.json +31 -0
  106. package/dist/engineering-pa-cli/src/research/store/findings.mjs +61 -0
  107. package/dist/engineering-pa-cli/src/research/store/gates.mjs +73 -0
  108. package/dist/engineering-pa-cli/src/research/store/index.mjs +312 -0
  109. package/dist/engineering-pa-cli/src/research/store/manifest.mjs +56 -0
  110. package/dist/engineering-pa-cli/src/research/store/plan.mjs +59 -0
  111. package/dist/engineering-pa-cli/src/research/store/sources.mjs +44 -0
  112. package/dist/engineering-pa-cli/src/research/store/validate.mjs +92 -0
  113. package/dist/engineering-pa-cli/src/research/workflows/research-normal.js +573 -0
  114. package/dist/engineering-pa-cli/src/scheduler/cli.mjs +197 -0
  115. package/dist/engineering-pa-cli/src/secrets/cli.mjs +254 -0
  116. package/dist/engineering-pa-cli/src/secrets/commands/backup-template.mjs +67 -0
  117. package/dist/engineering-pa-cli/src/secrets/commands/backup.mjs +85 -0
  118. package/dist/engineering-pa-cli/src/secrets/commands/delete.mjs +58 -0
  119. package/dist/engineering-pa-cli/src/secrets/commands/find.mjs +36 -0
  120. package/dist/engineering-pa-cli/src/secrets/commands/get.mjs +49 -0
  121. package/dist/engineering-pa-cli/src/secrets/commands/init.mjs +69 -0
  122. package/dist/engineering-pa-cli/src/secrets/commands/list.mjs +37 -0
  123. package/dist/engineering-pa-cli/src/secrets/commands/rotate.mjs +46 -0
  124. package/dist/engineering-pa-cli/src/secrets/commands/set.mjs +123 -0
  125. package/dist/engineering-pa-cli/src/secrets/commands/status.mjs +146 -0
  126. package/dist/engineering-pa-cli/src/secrets/commands/vaults.mjs +57 -0
  127. package/dist/engineering-pa-cli/src/secrets/commands/verify.mjs +143 -0
  128. package/dist/engineering-pa-cli/src/secrets/vault.mjs +766 -0
  129. package/dist/engineering-pa-cli/src/smoke/checks.mjs +199 -0
  130. package/dist/engineering-pa-cli/src/smoke/cli.mjs +108 -0
  131. package/dist/engineering-pa-cli/src/smoke/commands/run.mjs +237 -0
  132. package/dist/engineering-pa-cli/src/smoke/runner.mjs +191 -0
  133. package/dist/engineering-pa-cli/src/staging-alias/cli.mjs +91 -0
  134. package/dist/engineering-pa-cli/src/staging-alias/cloudflare.mjs +380 -0
  135. package/dist/engineering-pa-cli/src/staging-alias/commands/list.mjs +36 -0
  136. package/dist/engineering-pa-cli/src/staging-alias/commands/set.mjs +188 -0
  137. package/dist/engineering-pa-cli/src/staging-alias/commands/status.mjs +113 -0
  138. package/dist/engineering-pa-cli/src/staging-alias/commands/unset.mjs +95 -0
  139. package/dist/engineering-pa-cli/src/staging-alias/sites.mjs +105 -0
  140. package/dist/engineering-pa-cli/src/sync/cli.mjs +151 -0
  141. package/dist/engineering-pa-cli/src/sync/git.mjs +149 -0
  142. package/dist/engineering-pa-cli/src/sync/lock.mjs +30 -0
  143. package/dist/engineering-pa-cli/src/sync/regenerated-artifacts.mjs +114 -0
  144. package/dist/engineering-pa-cli/src/sync/registry-scope.mjs +260 -0
  145. package/dist/engineering-pa-cli/src/sync/run.mjs +531 -0
  146. package/dist/engineering-pa-cli/src/sync/runner.mjs +62 -0
  147. package/dist/engineering-pa-cli/src/sync/runs-digest.mjs +138 -0
  148. package/dist/engineering-pa-core/README.md +60 -0
  149. package/dist/engineering-pa-core/bin/desk +9 -0
  150. package/dist/engineering-pa-core/bin/distill +2 -0
  151. package/dist/engineering-pa-core/bin/memory-sync +2 -0
  152. package/dist/engineering-pa-core/bin/slice +2 -0
  153. package/dist/engineering-pa-core/docs/autonomy-envelope-tracker.md +111 -0
  154. package/dist/engineering-pa-core/docs/blueprints/README.md +45 -0
  155. package/dist/engineering-pa-core/docs/blueprints/docker-services/blueprint.md +168 -0
  156. package/dist/engineering-pa-core/docs/blueprints/git-identity/blueprint.md +116 -0
  157. package/dist/engineering-pa-core/docs/blueprints/ideas/custom-workspace-views.md +9 -0
  158. package/dist/engineering-pa-core/docs/blueprints/ideas/knowledge-domain-patterns.md +15 -0
  159. package/dist/engineering-pa-core/docs/blueprints/ideas/office-integration.md +11 -0
  160. package/dist/engineering-pa-core/docs/blueprints/ideas/team-scaling.md +9 -0
  161. package/dist/engineering-pa-core/docs/blueprints/ideas/work-personal-separation.md +9 -0
  162. package/dist/engineering-pa-core/docs/blueprints/scheduled-distillation/blueprint.md +129 -0
  163. package/dist/engineering-pa-core/docs/blueprints/tool-integration/blueprint.md +110 -0
  164. package/dist/engineering-pa-core/docs/commands/init.md +159 -0
  165. package/dist/engineering-pa-core/docs/commands/schedule.md +98 -0
  166. package/dist/engineering-pa-core/docs/commands/upgrade.md +67 -0
  167. package/dist/engineering-pa-core/docs/entity-location-map.md +93 -0
  168. package/dist/engineering-pa-core/docs/knowledge-scaffold.md +121 -0
  169. package/dist/engineering-pa-core/docs/lock.md +155 -0
  170. package/dist/engineering-pa-core/docs/memory-sync.md +28 -0
  171. package/dist/engineering-pa-core/docs/pa-operating-model.md +457 -0
  172. package/dist/engineering-pa-core/docs/scheduling.md +35 -0
  173. package/dist/engineering-pa-core/docs/standards/README.md +24 -0
  174. package/dist/engineering-pa-core/docs/standards/briefing-contract.md +161 -0
  175. package/dist/engineering-pa-core/docs/standards/tooling.md +240 -0
  176. package/dist/engineering-pa-core/docs/tools/distill.md +230 -0
  177. package/dist/engineering-pa-core/docs/tools/memory-sync.md +98 -0
  178. package/dist/engineering-pa-core/docs/tools/scheduler.md +243 -0
  179. package/dist/engineering-pa-core/docs/tools/slice.md +150 -0
  180. package/dist/engineering-pa-core/docs/upstream-issue-filing.md +88 -0
  181. package/dist/engineering-pa-core/docs/working-directory.md +29 -0
  182. package/dist/engineering-pa-core/docs/workspace-operations.md +46 -0
  183. package/dist/engineering-pa-core/index.mjs +21 -0
  184. package/dist/engineering-pa-core/knowledge/README.md +53 -0
  185. package/dist/engineering-pa-core/knowledge/canonical/.gitkeep +0 -0
  186. package/dist/engineering-pa-core/knowledge/content-voice/README.md +64 -0
  187. package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-baseline.md +133 -0
  188. package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-shape.md +58 -0
  189. package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +33 -0
  190. package/dist/engineering-pa-core/knowledge/content-voice/content-pipeline-routing.md +58 -0
  191. package/dist/engineering-pa-core/knowledge/content-voice/new-tell-capture-procedure.md +40 -0
  192. package/dist/engineering-pa-core/knowledge/content-voice/voice-guide-shape.md +43 -0
  193. package/dist/engineering-pa-core/knowledge/delegation-dispatch/README.md +34 -0
  194. package/dist/engineering-pa-core/knowledge/delegation-dispatch/briefing-contract.md +77 -0
  195. package/dist/engineering-pa-core/knowledge/delegation-dispatch/delegation-modes.md +84 -0
  196. package/dist/engineering-pa-core/knowledge/delegation-dispatch/dispatch-envelope.md +238 -0
  197. package/dist/engineering-pa-core/knowledge/delegation-dispatch/task-groups-and-orchestrators.md +62 -0
  198. package/dist/engineering-pa-core/knowledge/delegation-dispatch/team-improvement-loop.md +48 -0
  199. package/dist/engineering-pa-core/knowledge/delegation-dispatch/tool-development-policy.md +48 -0
  200. package/dist/engineering-pa-core/knowledge/engineering-discipline/README.md +33 -0
  201. package/dist/engineering-pa-core/knowledge/engineering-discipline/blast-radius-and-safety.md +50 -0
  202. package/dist/engineering-pa-core/knowledge/engineering-discipline/investigate-root-causes.md +48 -0
  203. package/dist/engineering-pa-core/knowledge/engineering-discipline/role-ladder.md +57 -0
  204. package/dist/engineering-pa-core/knowledge/engineering-discipline/verify-runtime-behaviour.md +53 -0
  205. package/dist/engineering-pa-core/knowledge/engineering-discipline/worktree-isolation.md +50 -0
  206. package/dist/engineering-pa-core/knowledge/knowledge-memory/README.md +33 -0
  207. package/dist/engineering-pa-core/knowledge/knowledge-memory/claude-md-hygiene.md +47 -0
  208. package/dist/engineering-pa-core/knowledge/knowledge-memory/journal-and-self-improvement.md +53 -0
  209. package/dist/engineering-pa-core/knowledge/knowledge-memory/knowledge-retrieval-discipline.md +62 -0
  210. package/dist/engineering-pa-core/knowledge/knowledge-memory/librarian-synthesis-mandate.md +56 -0
  211. package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +58 -0
  212. package/dist/engineering-pa-core/knowledge/operator-interface/README.md +32 -0
  213. package/dist/engineering-pa-core/knowledge/operator-interface/communication-style.md +33 -0
  214. package/dist/engineering-pa-core/knowledge/operator-interface/escalation-and-correction.md +45 -0
  215. package/dist/engineering-pa-core/knowledge/operator-interface/push-back-discipline.md +42 -0
  216. package/dist/engineering-pa-core/knowledge/operator-interface/session-boot-and-end.md +56 -0
  217. package/dist/engineering-pa-core/knowledge/platform-tooling/README.md +37 -0
  218. package/dist/engineering-pa-core/knowledge/platform-tooling/desk-cli-reference.md +52 -0
  219. package/dist/engineering-pa-core/knowledge/platform-tooling/environment-awareness.md +91 -0
  220. package/dist/engineering-pa-core/knowledge/platform-tooling/mcp-servers-catalogue.md +52 -0
  221. package/dist/engineering-pa-core/knowledge/platform-tooling/pa-cli-reference.md +55 -0
  222. package/dist/engineering-pa-core/knowledge/platform-tooling/scheduling-and-launchd.md +56 -0
  223. package/dist/engineering-pa-core/knowledge/platform-tooling/tool-discovery-discipline.md +49 -0
  224. package/dist/engineering-pa-core/knowledge/platform-tooling/working-directory-vs-project-root.md +43 -0
  225. package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-server.md +51 -0
  226. package/dist/engineering-pa-core/knowledge/raw/.gitkeep +0 -0
  227. package/dist/engineering-pa-core/knowledge/scaffold/domains.json +5 -0
  228. package/dist/engineering-pa-core/knowledge/schema/.gitkeep +0 -0
  229. package/dist/engineering-pa-core/knowledge/work-management/README.md +33 -0
  230. package/dist/engineering-pa-core/knowledge/work-management/desk-cli.md +84 -0
  231. package/dist/engineering-pa-core/knowledge/work-management/progress-md.md +48 -0
  232. package/dist/engineering-pa-core/knowledge/work-management/projects.md +64 -0
  233. package/dist/engineering-pa-core/knowledge/work-management/spawn-log.md +48 -0
  234. package/dist/engineering-pa-core/knowledge/work-management/work-items.md +67 -0
  235. package/dist/engineering-pa-core/package.json +26 -0
  236. package/dist/engineering-pa-core/roles/agent-ops/definition.md +79 -0
  237. package/dist/engineering-pa-core/roles/dispatch-briefer/definition.md +195 -0
  238. package/dist/engineering-pa-core/roles/librarian/definition.md +176 -0
  239. package/dist/engineering-pa-core/roles/pa/definition.md +93 -0
  240. package/dist/engineering-pa-core/roles/system-admin/definition.md +106 -0
  241. package/dist/engineering-pa-core/roles/tool-developer/definition.md +116 -0
  242. package/dist/engineering-pa-core/roles/writer/definition.md +130 -0
  243. package/dist/engineering-pa-core/schema/autonomy-envelope.schema.json +61 -0
  244. package/dist/engineering-pa-core/schema/project.json +114 -0
  245. package/dist/engineering-pa-core/schema/schedule.json +63 -0
  246. package/dist/engineering-pa-core/schema/work-item.json +92 -0
  247. package/dist/engineering-pa-core/src/config/pa-package-name.mjs +30 -0
  248. package/dist/engineering-pa-core/src/desk/cli.mjs +95 -0
  249. package/dist/engineering-pa-core/src/desk/commands.mjs +398 -0
  250. package/dist/engineering-pa-core/src/desk/format.mjs +84 -0
  251. package/dist/engineering-pa-core/src/desk/repo.mjs +172 -0
  252. package/dist/engineering-pa-core/src/desk/team-scope.mjs +142 -0
  253. package/dist/engineering-pa-core/src/distill/cli.mjs +112 -0
  254. package/dist/engineering-pa-core/src/distill/prereqs.mjs +171 -0
  255. package/dist/engineering-pa-core/src/distill/prompt.mjs +74 -0
  256. package/dist/engineering-pa-core/src/distill/run.mjs +230 -0
  257. package/dist/engineering-pa-core/src/distill/runner.mjs +86 -0
  258. package/dist/engineering-pa-core/src/distill/staging.mjs +59 -0
  259. package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +154 -0
  260. package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +186 -0
  261. package/dist/engineering-pa-core/src/knowledge/retrieval/index-builder.mjs +225 -0
  262. package/dist/engineering-pa-core/src/knowledge/retrieval/query.mjs +263 -0
  263. package/dist/engineering-pa-core/src/lock/lock.mjs +202 -0
  264. package/dist/engineering-pa-core/src/memory-sync/cli-support.mjs +12 -0
  265. package/dist/engineering-pa-core/src/memory-sync/cli.mjs +56 -0
  266. package/dist/engineering-pa-core/src/memory-sync/commands/pull.mjs +44 -0
  267. package/dist/engineering-pa-core/src/memory-sync/commands/push.mjs +46 -0
  268. package/dist/engineering-pa-core/src/memory-sync/mirror.mjs +114 -0
  269. package/dist/engineering-pa-core/src/memory-sync/repo.mjs +65 -0
  270. package/dist/engineering-pa-core/src/scheduler/detect.mjs +111 -0
  271. package/dist/engineering-pa-core/src/scheduler/host.mjs +38 -0
  272. package/dist/engineering-pa-core/src/scheduler/index.mjs +13 -0
  273. package/dist/engineering-pa-core/src/scheduler/install.mjs +854 -0
  274. package/dist/engineering-pa-core/src/scheduler/list.mjs +168 -0
  275. package/dist/engineering-pa-core/src/scheduler/remove.mjs +176 -0
  276. package/dist/engineering-pa-core/src/scheduler/verify.mjs +435 -0
  277. package/dist/engineering-pa-core/src/session-boot/autonomy-injection.mjs +84 -0
  278. package/dist/engineering-pa-core/src/slice/cli.mjs +139 -0
  279. package/dist/engineering-pa-core/src/slice/inputs.mjs +174 -0
  280. package/dist/engineering-pa-core/src/slice/prompt.mjs +94 -0
  281. package/dist/engineering-pa-core/src/slice/prompts/dave-boot.md +91 -0
  282. package/dist/engineering-pa-core/src/slice/run.mjs +334 -0
  283. package/dist/engineering-pa-core-extensions-atlassian/README.md +31 -0
  284. package/dist/engineering-pa-core-extensions-atlassian/index.mjs +9 -0
  285. package/dist/engineering-pa-core-extensions-atlassian/package.json +19 -0
  286. package/dist/engineering-pa-core-extensions-n8n/README.md +29 -0
  287. package/dist/engineering-pa-core-extensions-n8n/index.mjs +8 -0
  288. package/dist/engineering-pa-core-extensions-n8n/package.json +19 -0
  289. package/dist/engineering-pa-librarian/README.md +38 -0
  290. package/dist/engineering-pa-librarian/index.mjs +12 -0
  291. package/dist/engineering-pa-librarian/package.json +19 -0
  292. package/dist/engineering-pa-migrate-inspect/README.md +131 -0
  293. package/dist/engineering-pa-migrate-inspect/bin/migrate-inspect +2 -0
  294. package/dist/engineering-pa-migrate-inspect/package.json +21 -0
  295. package/dist/engineering-pa-migrate-inspect/src/age-key-migrate.mjs +83 -0
  296. package/dist/engineering-pa-migrate-inspect/src/cli.mjs +97 -0
  297. package/dist/engineering-pa-migrate-inspect/src/inspector.mjs +237 -0
  298. package/dist/engineering-pa-migrate-inspect/src/platforms/linux.mjs +87 -0
  299. package/dist/engineering-pa-migrate-inspect/src/platforms/macos.mjs +105 -0
  300. package/dist/engineering-pa-migrate-inspect/src/runner.mjs +87 -0
  301. package/dist/engineering-pa-runbook/README.md +103 -0
  302. package/dist/engineering-pa-runbook/package.json +18 -0
  303. package/dist/engineering-pa-runbook/reference/dex-template.runbook.json +66 -0
  304. package/dist/engineering-pa-runbook/reference/hq-dave.runbook.json +69 -0
  305. package/dist/engineering-pa-runbook/reference/smarthome01.runbook.json +92 -0
  306. package/dist/engineering-pa-runbook/schema/inspection.schema.json +113 -0
  307. package/dist/engineering-pa-runbook/schema/runbook.schema.json +67 -0
  308. package/dist/engineering-pa-runbook/src/validator.mjs +167 -0
  309. package/dist/engineering-pa-scaffold/README.md +164 -0
  310. package/dist/engineering-pa-scaffold/bin/pa-init +2 -0
  311. package/dist/engineering-pa-scaffold/index.mjs +42 -0
  312. package/dist/engineering-pa-scaffold/package.json +22 -0
  313. package/dist/engineering-pa-scaffold/src/cli.mjs +115 -0
  314. package/dist/engineering-pa-scaffold/src/commands/init.mjs +169 -0
  315. package/dist/engineering-pa-scaffold/src/generator.mjs +354 -0
  316. package/dist/engineering-pa-scaffold/src/prompts.mjs +125 -0
  317. package/dist/engineering-pa-scaffold/src/runner.mjs +142 -0
  318. package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +196 -0
  319. package/dist/engineering-pa-scaffold/templates/operator-README.md.template +44 -0
  320. package/dist/engineering-pa-types-engineering/README.md +35 -0
  321. package/dist/engineering-pa-types-engineering/docs/commands/product-build.md +120 -0
  322. package/dist/engineering-pa-types-engineering/index.mjs +9 -0
  323. package/dist/engineering-pa-types-engineering/package.json +19 -0
  324. package/dist/engineering-pa-types-engineering/roles/product-engineer/definition.md +650 -0
  325. package/dist/engineering-pa-types-engineering/roles/product-engineer/templates/build-spec.md +82 -0
  326. package/dist/engineering-pa-types-engineering/roles/product-engineer/templates/preflight-checklist.md +158 -0
  327. package/dist/engineering-pa-types-engineering/roles/product-engineer/templates/worker-brief.md +130 -0
  328. package/dist/engineering-pa-workspace/README.md +197 -0
  329. package/dist/engineering-pa-workspace/app/assets/md.js +187 -0
  330. package/dist/engineering-pa-workspace/app/assets/modal.js +120 -0
  331. package/dist/engineering-pa-workspace/app/assets/theme.css +132 -0
  332. package/dist/engineering-pa-workspace/app/assets/theme.js +159 -0
  333. package/dist/engineering-pa-workspace/app/banner.js +320 -0
  334. package/dist/engineering-pa-workspace/app/delegations.html +345 -0
  335. package/dist/engineering-pa-workspace/app/home.html +1060 -0
  336. package/dist/engineering-pa-workspace/app/index.html +361 -0
  337. package/dist/engineering-pa-workspace/app/knowledge-map.html +163 -0
  338. package/dist/engineering-pa-workspace/app/knowledge.html +304 -0
  339. package/dist/engineering-pa-workspace/app/nav.json +75 -0
  340. package/dist/engineering-pa-workspace/app/notes.html +199 -0
  341. package/dist/engineering-pa-workspace/app/onboarding.html +592 -0
  342. package/dist/engineering-pa-workspace/app/profile.html +243 -0
  343. package/dist/engineering-pa-workspace/app/project-detail.html +432 -0
  344. package/dist/engineering-pa-workspace/app/projects.html +297 -0
  345. package/dist/engineering-pa-workspace/app/repos.html +598 -0
  346. package/dist/engineering-pa-workspace/app/schedules.html +486 -0
  347. package/dist/engineering-pa-workspace/app/secrets.html +164 -0
  348. package/dist/engineering-pa-workspace/app/staging.html +319 -0
  349. package/dist/engineering-pa-workspace/app/tasks.html +465 -0
  350. package/dist/engineering-pa-workspace/app/vendor/chart.umd.min.js +21 -0
  351. package/dist/engineering-pa-workspace/app/viewer.html +116 -0
  352. package/dist/engineering-pa-workspace/docs/DAEMON.md +104 -0
  353. package/dist/engineering-pa-workspace/docs/page-blueprint.md +286 -0
  354. package/dist/engineering-pa-workspace/docs/status-mon.md +168 -0
  355. package/dist/engineering-pa-workspace/index.mjs +12 -0
  356. package/dist/engineering-pa-workspace/nav.json.example +8 -0
  357. package/dist/engineering-pa-workspace/package.json +27 -0
  358. package/dist/engineering-pa-workspace/src/api.mjs +2086 -0
  359. package/dist/engineering-pa-workspace/src/install-daemon.mjs +573 -0
  360. package/dist/engineering-pa-workspace/src/nav.mjs +190 -0
  361. package/dist/engineering-pa-workspace/src/secrets-api.mjs +175 -0
  362. package/dist/engineering-pa-workspace/src/server.mjs +516 -0
  363. package/dist/engineering-pa-workspace/src/status-mon/poll.mjs +220 -0
  364. package/dist/engineering-pa-workspace/src/status-mon/poller.mjs +113 -0
  365. package/dist/engineering-pa-workspace/src/status-mon/run.mjs +133 -0
  366. package/dist/engineering-pa-workspace/src/supervisor.mjs +264 -0
  367. package/dist/engineering-pa-workspace/src/ws.mjs +633 -0
  368. package/index.mjs +60 -0
  369. package/package.json +58 -0
  370. package/templates/banned-ai-tells.md +102 -0
  371. package/templates/purpose.md +105 -0
  372. package/templates/voice-guide.md +98 -0
@@ -0,0 +1,2086 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { join, resolve, relative } from 'node:path';
3
+ import { execSync, execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+ import { hostname } from 'node:os';
8
+
9
+ import { getSecretsStatus, setSecret, MISSING_TARGET_ERROR } from './secrets-api.mjs';
10
+ import { detect as schedulerDetect, list as schedulerList } from '../../engineering-pa-core/src/scheduler/index.mjs';
11
+
12
+ /**
13
+ * handleApi -- route handler for all API endpoints.
14
+ *
15
+ * Returns true if the request was handled, false otherwise.
16
+ * The caller (server.mjs) should fall through to static file serving
17
+ * when this returns false.
18
+ *
19
+ * @param {import('http').IncomingMessage} req
20
+ * @param {import('http').ServerResponse} res
21
+ * @param {URL} url
22
+ * @param {string} repoRoot -- absolute path to the project root
23
+ * @param {object} [deps] -- injectable bridges (tests only). Currently:
24
+ * deps.scheduler = { detect, list }.
25
+ * @returns {boolean}
26
+ */
27
+ const SERVER_STARTED_AT = new Date().toISOString();
28
+
29
+ export function handleApi(req, res, url, repoRoot, deps = {}) {
30
+ const pathname = url.pathname;
31
+ const method = req.method;
32
+
33
+ // GET /__api/identity -- lets the launcher recognise its own server
34
+ // so it can restart on an occupied port (and pick up server-side
35
+ // changes) instead of silently skipping.
36
+ if (method === 'GET' && pathname === '/__api/identity') {
37
+ sendJson(res, 200, {
38
+ server: 'dave-workspace',
39
+ repoRoot: repoRoot,
40
+ pid: process.pid,
41
+ startedAt: SERVER_STARTED_AT,
42
+ });
43
+ return true;
44
+ }
45
+
46
+ // POST /api/onboard
47
+ if (method === 'POST' && pathname === '/api/onboard') {
48
+ readBody(req).then((raw) => {
49
+ try {
50
+ const body = JSON.parse(raw);
51
+ const result = processOnboarding(body, repoRoot);
52
+ sendJson(res, 200, result);
53
+ } catch (err) {
54
+ sendJson(res, 400, { success: false, error: err.message });
55
+ }
56
+ }).catch((err) => {
57
+ sendJson(res, 500, { success: false, error: err.message });
58
+ });
59
+ return true;
60
+ }
61
+
62
+ // GET /__api/config
63
+ if (method === 'GET' && pathname === '/__api/config') {
64
+ try {
65
+ const prefsFile = join(repoRoot, 'operator', 'config', 'preferences.json');
66
+ let operator = { name: 'Operator', role: 'Operator' };
67
+ let pa = { name: 'PA', role: 'PA' };
68
+ if (existsSync(prefsFile)) {
69
+ const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
70
+ if (prefs.operator && prefs.operator.name) operator = prefs.operator;
71
+ if (prefs.pa && prefs.pa.name) pa = prefs.pa;
72
+ }
73
+ sendJson(res, 200, { operator, pa });
74
+ } catch (err) {
75
+ sendJson(res, 500, { error: err.message });
76
+ }
77
+ return true;
78
+ }
79
+
80
+ // GET /__api/projects
81
+ if (method === 'GET' && pathname === '/__api/projects') {
82
+ try {
83
+ const projects = walkProjects(repoRoot);
84
+ sendJson(res, 200, { projects });
85
+ } catch (err) {
86
+ sendJson(res, 500, { error: err.message });
87
+ }
88
+ return true;
89
+ }
90
+
91
+ // GET /__api/work
92
+ if (method === 'GET' && pathname === '/__api/work') {
93
+ try {
94
+ const work = walkWork(repoRoot);
95
+ sendJson(res, 200, { work });
96
+ } catch (err) {
97
+ sendJson(res, 500, { error: err.message });
98
+ }
99
+ return true;
100
+ }
101
+
102
+ // GET /__api/overview
103
+ if (method === 'GET' && pathname === '/__api/overview') {
104
+ try {
105
+ const projects = walkProjects(repoRoot);
106
+ const work = walkWork(repoRoot);
107
+ const overview = buildOverview(projects, work, repoRoot);
108
+ sendJson(res, 200, overview);
109
+ } catch (err) {
110
+ sendJson(res, 500, { error: err.message });
111
+ }
112
+ return true;
113
+ }
114
+
115
+ // GET /__api/knowledge
116
+ if (method === 'GET' && pathname === '/__api/knowledge') {
117
+ try {
118
+ const indexFile = join(repoRoot, 'operator', 'knowledge', 'index.json');
119
+ if (!existsSync(indexFile)) {
120
+ sendJson(res, 200, { domains: [], stats: { totalDocuments: 0, totalDomains: 0 } });
121
+ return true;
122
+ }
123
+ const index = JSON.parse(readFileSync(indexFile, 'utf-8'));
124
+ let totalDocs = 0;
125
+ const domains = (index.domains || []).map(function(d) {
126
+ // index.json paths are relative to operator/ (e.g. "knowledge/hr")
127
+ const domainPath = join(repoRoot, 'operator', d.path);
128
+ const documents = [];
129
+ if (existsSync(domainPath)) {
130
+ walkDocuments(domainPath, repoRoot, documents);
131
+ }
132
+ totalDocs += documents.length;
133
+ return {
134
+ id: d.id,
135
+ name: d.id,
136
+ description: d.description || '',
137
+ documentCount: documents.length,
138
+ documents: documents,
139
+ };
140
+ });
141
+ sendJson(res, 200, {
142
+ domains: domains,
143
+ stats: { totalDocuments: totalDocs, totalDomains: domains.length },
144
+ });
145
+ } catch (err) {
146
+ sendJson(res, 500, { error: err.message });
147
+ }
148
+ return true;
149
+ }
150
+
151
+ // GET /__api/profile
152
+ if (method === 'GET' && pathname === '/__api/profile') {
153
+ try {
154
+ const profileMd = join(repoRoot, 'operator', 'profile', 'profile.md');
155
+ const profileContent = existsSync(profileMd) ? readFileSync(profileMd, 'utf-8') : '';
156
+ const obsDir = join(repoRoot, 'operator', 'profile', 'observations');
157
+ const observations = [];
158
+ if (existsSync(obsDir)) {
159
+ readdirSync(obsDir)
160
+ .filter(function(f) { return f.endsWith('.md'); })
161
+ .sort()
162
+ .forEach(function(f) {
163
+ observations.push({ name: f, path: 'operator/profile/observations/' + f });
164
+ });
165
+ }
166
+ const slicesDir = join(repoRoot, 'operator', 'profile', 'slices');
167
+ const slices = [];
168
+ if (existsSync(slicesDir)) {
169
+ readdirSync(slicesDir)
170
+ .filter(function(f) { return f.endsWith('.md'); })
171
+ .sort()
172
+ .forEach(function(f) {
173
+ slices.push({ name: f, path: 'operator/profile/slices/' + f });
174
+ });
175
+ }
176
+ sendJson(res, 200, { profile: profileContent, observations: observations, slices: slices });
177
+ } catch (err) {
178
+ sendJson(res, 500, { error: err.message });
179
+ }
180
+ return true;
181
+ }
182
+
183
+ // GET /__api/file
184
+ if (method === 'GET' && pathname === '/__api/file') {
185
+ try {
186
+ const filePath = url.searchParams.get('path');
187
+ if (!filePath) {
188
+ sendJson(res, 400, { error: 'Missing path parameter' });
189
+ return true;
190
+ }
191
+ const absPath = resolve(repoRoot, filePath);
192
+ const rel = relative(repoRoot, absPath);
193
+ if (rel.startsWith('..') || resolve(repoRoot, rel) !== absPath) {
194
+ sendJson(res, 403, { error: 'Path must be within the project root' });
195
+ return true;
196
+ }
197
+ if (!existsSync(absPath)) {
198
+ sendJson(res, 404, { error: 'File not found' });
199
+ return true;
200
+ }
201
+ const content = readFileSync(absPath, 'utf-8');
202
+ sendJson(res, 200, { path: filePath, content });
203
+ } catch (err) {
204
+ sendJson(res, 500, { error: err.message });
205
+ }
206
+ return true;
207
+ }
208
+
209
+ // GET /__api/notes
210
+ if (method === 'GET' && pathname === '/__api/notes') {
211
+ try {
212
+ const notes = walkNotes(repoRoot);
213
+ sendJson(res, 200, { notes });
214
+ } catch (err) {
215
+ sendJson(res, 500, { error: err.message });
216
+ }
217
+ return true;
218
+ }
219
+
220
+ // GET /__api/secrets
221
+ // v0.5.3: scoped-vault aware. Enumerates EVERY vault visible from the
222
+ // workspace (legacy .vault/secrets.enc.json, workspace scopes
223
+ // .vault/scopes/*.enc.json, canonical vault/*.enc.json) with key NAMES
224
+ // read from the plaintext sops JSON structure — no decryption, no age
225
+ // key, no binaries required to list. Response:
226
+ // { initialised, vaults: [{scope, layout, path, keys}], keys }
227
+ // `keys` is the deduplicated flat union kept for pre-0.5.3 consumers.
228
+ // Values are never present in any response.
229
+ if (method === 'GET' && pathname === '/__api/secrets') {
230
+ getSecretsStatus(repoRoot)
231
+ .then((result) => { sendJson(res, 200, result); })
232
+ .catch((err) => { sendJson(res, 500, { error: err.message }); });
233
+ return true;
234
+ }
235
+
236
+ // POST /__api/secrets
237
+ // v0.5.3: body is { key, value, vault } — `vault` is the REQUIRED explicit
238
+ // target ('legacy' or a scope name). A missing target is a 400, never a
239
+ // silent write to the legacy store.
240
+ if (method === 'POST' && pathname === '/__api/secrets') {
241
+ readBody(req).then(async (raw) => {
242
+ try {
243
+ const parsed = JSON.parse(raw);
244
+ const result = await setSecret(repoRoot, parsed.key, parsed.value, parsed.vault);
245
+ if (result.success) {
246
+ sendJson(res, 200, { success: true });
247
+ } else {
248
+ // Missing key/value/target are caller errors (400); anything else
249
+ // is a vault/system failure (500).
250
+ const isCallerError = result.error === 'Missing key or value'
251
+ || result.error === MISSING_TARGET_ERROR;
252
+ sendJson(res, isCallerError ? 400 : 500, { success: false, error: result.error });
253
+ }
254
+ } catch (err) {
255
+ sendJson(res, 500, { success: false, error: err.message });
256
+ }
257
+ }).catch((err) => {
258
+ sendJson(res, 500, { success: false, error: err.message });
259
+ });
260
+ return true;
261
+ }
262
+
263
+ // GET /__api/schedules
264
+ //
265
+ // v0.5.3: live backend detection is an IN-PROCESS import of the core
266
+ // scheduler module. The previous code shelled out to
267
+ // `core/tools/scheduler/{detect,list}.mjs` — v1 paths that don't exist in
268
+ // v2 installs — behind existsSync guards, so on every live estate host it
269
+ // silently skipped detection and reported backend:null / liveStatus:null
270
+ // ("0 installed") with no explanation. `schedulerError` is now ALWAYS
271
+ // present in the response: null exclusively when detect+list both
272
+ // succeeded, otherwise an explicit message — a silent null is impossible.
273
+ if (method === 'GET' && pathname === '/__api/schedules') {
274
+ try {
275
+ const scheduler = deps.scheduler || { detect: schedulerDetect, list: schedulerList };
276
+ const schedules = walkSchedules(repoRoot);
277
+ let liveStatus = null;
278
+ let backend = null;
279
+ let schedulerError = null;
280
+ try {
281
+ const detected = scheduler.detect();
282
+ const available = (detected && detected.available) || [];
283
+ const rec = available.find(function(b) { return b.recommended; });
284
+ backend = rec ? rec.backend : (available[0] ? available[0].backend : null);
285
+ if (!backend) {
286
+ schedulerError = 'no scheduling backend detected on this host (os: ' + (detected && detected.os ? detected.os : 'unknown') + ')';
287
+ }
288
+ } catch (err) {
289
+ schedulerError = 'scheduler detect failed: ' + (err && err.message ? err.message : String(err));
290
+ }
291
+ if (backend) {
292
+ try {
293
+ liveStatus = String(scheduler.list(backend)).trim();
294
+ } catch (err) {
295
+ schedulerError = 'scheduler list failed for backend "' + backend + '": ' + (err && err.message ? err.message : String(err));
296
+ }
297
+ }
298
+ const now = new Date();
299
+ const agenda = buildAgenda(schedules, now);
300
+ // Tab 1 (active schedules) needs a single next-run time per schedule —
301
+ // the agenda computes every occurrence within the next 7 days for its
302
+ // own bucketing, which is a different shape. Attach the same
303
+ // nextCronRuns computation, capped to one hit, directly onto each
304
+ // schedule so the compact list needs no separate round trip.
305
+ for (const s of schedules) {
306
+ s.nextRunAt = computeNextRunAt(s, now);
307
+ }
308
+ // Hostname is surfaced so the UI can clarify "managed here vs installed
309
+ // on a remote host" — HQ schedules frequently target smarthome01 while
310
+ // the operator may view the page from a laptop.
311
+ let host = null;
312
+ try { host = hostname(); } catch { /* non-fatal */ }
313
+ sendJson(res, 200, { schedules, liveStatus, backend, schedulerError, agenda, hostname: host });
314
+ } catch (err) {
315
+ // Always return 200 with empty data rather than 500 — but the failure
316
+ // itself is named, never silent.
317
+ let host = null;
318
+ try { host = hostname(); } catch { /* non-fatal */ }
319
+ sendJson(res, 200, {
320
+ schedules: [],
321
+ liveStatus: null,
322
+ backend: null,
323
+ schedulerError: 'schedules handler failed: ' + (err && err.message ? err.message : String(err)),
324
+ agenda: { today: [], tomorrow: [], thisWeek: [] },
325
+ hostname: host,
326
+ });
327
+ }
328
+ return true;
329
+ }
330
+
331
+ // GET /__api/repos
332
+ if (method === 'GET' && pathname === '/__api/repos') {
333
+ try {
334
+ // Check if gh CLI is available
335
+ try {
336
+ execSync('gh --version', { stdio: 'pipe', timeout: 5000 });
337
+ } catch {
338
+ sendJson(res, 200, { available: false, reason: 'gh CLI not installed. Install it from https://cli.github.com/ to enable repository features.' });
339
+ return true;
340
+ }
341
+ // Check if gh is authenticated
342
+ try {
343
+ execSync('gh auth status', { stdio: 'pipe', timeout: 5000 });
344
+ } catch {
345
+ sendJson(res, 200, { available: false, reason: 'gh CLI not authenticated. Run: gh auth login' });
346
+ return true;
347
+ }
348
+ // Fetch repos owned by user, sorted by pushed date
349
+ const reposRaw = execSync("gh repo list --json name,description,url,primaryLanguage,pushedAt,stargazerCount,isFork,isPrivate --limit 30 --no-archived", {
350
+ encoding: 'utf-8',
351
+ timeout: 15000,
352
+ }).trim();
353
+ let repos = [];
354
+ try {
355
+ repos = JSON.parse(reposRaw || '[]').map(function(r) {
356
+ return {
357
+ name: r.name,
358
+ description: r.description,
359
+ html_url: r.url,
360
+ language: r.primaryLanguage ? r.primaryLanguage.name : null,
361
+ pushed_at: r.pushedAt,
362
+ stargazers_count: r.stargazerCount || 0,
363
+ fork: r.isFork || false,
364
+ private: r.isPrivate || false,
365
+ open_issues_count: 0,
366
+ };
367
+ }).sort(function(a, b) {
368
+ return (b.pushed_at || '').localeCompare(a.pushed_at || '');
369
+ });
370
+ } catch { /* parse error — empty */ }
371
+ // Fetch open PRs authored by user
372
+ let prs = [];
373
+ try {
374
+ const prsRaw = execSync("gh search prs --author=@me --state=open --json title,url,repository,createdAt,updatedAt,isDraft --limit 20", {
375
+ encoding: 'utf-8',
376
+ timeout: 15000,
377
+ }).trim();
378
+ const parsed = JSON.parse(prsRaw || '[]');
379
+ prs = parsed.map(function(pr) {
380
+ return {
381
+ title: pr.title,
382
+ html_url: pr.url,
383
+ repo: pr.repository ? pr.repository.nameWithOwner : null,
384
+ created_at: pr.createdAt,
385
+ updated_at: pr.updatedAt,
386
+ draft: pr.isDraft || false,
387
+ };
388
+ });
389
+ } catch { /* non-fatal */ }
390
+ sendJson(res, 200, { available: true, repos: repos.slice(0, 30), openPrs: prs });
391
+ } catch (err) {
392
+ sendJson(res, 200, { available: false, reason: err.message });
393
+ }
394
+ return true;
395
+ }
396
+
397
+ // GET /__api/git-overview
398
+ // Rich cross-repo overview: owned repos, open PRs, configured-org repo
399
+ // activity, branch staleness, recent commits, contributor leaderboard.
400
+ // Configuration source: operator/config/preferences.json .gitOrgs (string[]).
401
+ // Always returns 200 — missing gh, auth, or orgs gracefully degrade.
402
+ //
403
+ // buildGitOverview is async because the owned-recent-commits fallback
404
+ // spawns `gh api` calls in parallel (see buildOwnedRecentCommits). We fire
405
+ // the promise and let it resolve the response — same shape either branch.
406
+ if (method === 'GET' && pathname === '/__api/git-overview') {
407
+ buildGitOverview(repoRoot).then(function(data) {
408
+ sendJson(res, 200, data);
409
+ }).catch(function(err) {
410
+ sendJson(res, 200, { available: false, reason: err.message });
411
+ });
412
+ return true;
413
+ }
414
+
415
+ // GET /__api/delegations
416
+ // Walks agents/*/spawn-log.json and aggregates into per-PA totals,
417
+ // recent spawns, and in-flight items. Generic — works in any deployment.
418
+ if (method === 'GET' && pathname === '/__api/delegations') {
419
+ try {
420
+ sendJson(res, 200, buildDelegations(repoRoot));
421
+ } catch (err) {
422
+ sendJson(res, 200, { agents: [], spawns: [], stats: {}, error: err.message });
423
+ }
424
+ return true;
425
+ }
426
+
427
+ // GET /__api/knowledge-map
428
+ // Reads operator/knowledge/domains.json + optional .graph.json /
429
+ // .backlinks.json and walks each domain tree for per-doc mtimes.
430
+ if (method === 'GET' && pathname === '/__api/knowledge-map') {
431
+ try {
432
+ sendJson(res, 200, buildKnowledgeMap(repoRoot));
433
+ } catch (err) {
434
+ sendJson(res, 200, { domains: [], stats: {}, error: err.message });
435
+ }
436
+ return true;
437
+ }
438
+
439
+ // GET /__api/staging
440
+ // Walks agents/*/staging/*.md, categorises by filename suffix, and
441
+ // returns age + short preview per file.
442
+ if (method === 'GET' && pathname === '/__api/staging') {
443
+ try {
444
+ sendJson(res, 200, buildStaging(repoRoot));
445
+ } catch (err) {
446
+ sendJson(res, 200, { agents: [], items: [], stats: {}, error: err.message });
447
+ }
448
+ return true;
449
+ }
450
+
451
+ // GET /__api/next-session
452
+ // Parses the PA's next-session.md ("## Top priority — start here", the
453
+ // "## On boot" checklist, the last-updated marker, and the raw body) so
454
+ // the homepage can render a pickup card without each consumer re-parsing
455
+ // the file. Absent file → present=false.
456
+ if (method === 'GET' && pathname === '/__api/next-session') {
457
+ try {
458
+ sendJson(res, 200, buildNextSession(repoRoot));
459
+ } catch (err) {
460
+ sendJson(res, 200, { present: false, error: err.message });
461
+ }
462
+ return true;
463
+ }
464
+
465
+ // GET /__api/status-mon
466
+ // Returns the state file written by the status-mon poller (see
467
+ // core/tools/status-mon/README.md). The endpoint never triggers a fetch —
468
+ // page loads stay fast regardless of upstream latency. Missing file →
469
+ // 404 (poller hasn't run yet); read/parse error → 500.
470
+ if (method === 'GET' && pathname === '/__api/status-mon') {
471
+ try {
472
+ const statePath = join(repoRoot, '.state', 'status-mon.json');
473
+ if (!existsSync(statePath)) {
474
+ sendJson(res, 404, { error: 'status-mon state file not found' });
475
+ return true;
476
+ }
477
+ const raw = readFileSync(statePath, 'utf-8');
478
+ const parsed = JSON.parse(raw);
479
+ sendJson(res, 200, parsed);
480
+ } catch (err) {
481
+ sendJson(res, 500, { error: err.message });
482
+ }
483
+ return true;
484
+ }
485
+
486
+ // Not an API route
487
+ return false;
488
+ }
489
+
490
+
491
+ // -- Onboarding logic -------------------------------------------------
492
+
493
+ function processOnboarding(body, repoRoot) {
494
+ const name = body.name;
495
+ const email = body.email;
496
+ const role = body.role;
497
+ const skills = body.skills;
498
+ const purpose = body.purpose;
499
+ const paName = body.paName;
500
+ const communicationStyle = body.communicationStyle;
501
+ const taskManagement = body.taskManagement;
502
+
503
+ // Validation
504
+ if (!name || typeof name !== 'string') throw new Error('name is required');
505
+ if (!email || typeof email !== 'string') throw new Error('email is required');
506
+ if (!purpose || typeof purpose !== 'string') throw new Error('purpose is required');
507
+ if (!paName || typeof paName !== 'string') throw new Error('paName is required');
508
+ if (!communicationStyle || typeof communicationStyle !== 'string') throw new Error('communicationStyle is required');
509
+ if (!taskManagement || typeof taskManagement !== 'string') throw new Error('taskManagement is required');
510
+
511
+ // Sanitise paName: lowercase, alphanumeric + hyphens only
512
+ const safePaName = paName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
513
+ if (!safePaName) throw new Error('paName must contain at least one alphanumeric character');
514
+
515
+ const today = new Date().toISOString().slice(0, 10);
516
+
517
+ // Parse skills
518
+ const skillList = skills
519
+ ? skills.split(',').map(function(s) { return s.trim(); }).filter(Boolean)
520
+ : [];
521
+
522
+ // Resolve communication style text
523
+ const styleMap = {
524
+ 'mirror-me': "Mirror the operator's tone and style. Match their energy, vocabulary, and formality level.",
525
+ 'professional': 'Professional and clear. Well-structured responses, precise language, minimal informality.',
526
+ 'casual': 'Casual and direct. Short sentences, contractions, light humour welcome. No corporate speak.',
527
+ };
528
+ const styleText = styleMap[communicationStyle] || communicationStyle;
529
+
530
+ // 1. operator/profile/operator.json
531
+ const profileDir = join(repoRoot, 'operator', 'profile');
532
+ mkdirSync(profileDir, { recursive: true });
533
+ writeJson(join(profileDir, 'operator.json'), {
534
+ name: name,
535
+ email: email,
536
+ role: role || '',
537
+ skills: skillList,
538
+ purpose: purpose,
539
+ created: today,
540
+ });
541
+
542
+ // 2. operator/config/preferences.json
543
+ const configDir = join(repoRoot, 'operator', 'config');
544
+ mkdirSync(configDir, { recursive: true });
545
+ writeJson(join(configDir, 'preferences.json'), {
546
+ paName: safePaName,
547
+ communicationStyle: communicationStyle,
548
+ taskManagement: taskManagement,
549
+ gitWorkflow: 'pr',
550
+ workspacePort: 4200,
551
+ });
552
+
553
+ // 3. agents/{paName}/ directory
554
+ const agentDir = join(repoRoot, 'agents', safePaName);
555
+ mkdirSync(join(agentDir, 'staging'), { recursive: true });
556
+
557
+ // CLAUDE.md from template
558
+ const templatePath = join(repoRoot, 'core', 'prompts', 'CLAUDE.md.template');
559
+ if (existsSync(templatePath)) {
560
+ let template = readFileSync(templatePath, 'utf-8');
561
+ template = template.replace(/\{PA_NAME\}/g, safePaName);
562
+ template = template.replace(/\{OPERATOR_NAME\}/g, name);
563
+ template = template.replace(/\{COMMUNICATION_STYLE\}/g, styleText);
564
+ template = template.replace(/\{PURPOSE\}/g, purpose);
565
+ writeFileSync(join(agentDir, 'CLAUDE.md'), template, 'utf-8');
566
+ }
567
+
568
+ // journal.md
569
+ writeFileSync(
570
+ join(agentDir, 'journal.md'),
571
+ '# ' + safePaName + ' Journal\n\nStarted: ' + today + '\n',
572
+ 'utf-8'
573
+ );
574
+
575
+ // spawn-log.json
576
+ writeJson(join(agentDir, 'spawn-log.json'), { version: '1.0', spawns: [] });
577
+
578
+ // learned-rules.md
579
+ writeFileSync(
580
+ join(agentDir, 'learned-rules.md'),
581
+ '# Learned Rules\n\nOperational rules accumulated from corrections and experience.\nMaintained by the librarian during distillation sweeps.\nThe PA reads this file on every boot — rules here carry the same weight as CLAUDE.md.\n\n---\n\n_No rules yet. Rules will appear here as the librarian processes corrections from your sessions._\n',
582
+ 'utf-8'
583
+ );
584
+
585
+ // next-session.md
586
+ writeFileSync(
587
+ join(agentDir, 'next-session.md'),
588
+ '# Next session\n\nFirst session! Welcome aboard.\n',
589
+ 'utf-8'
590
+ );
591
+
592
+ // 4. projects/onboarding/project.json
593
+ const projectDir = join(repoRoot, 'projects', 'onboarding');
594
+ mkdirSync(projectDir, { recursive: true });
595
+ writeJson(join(projectDir, 'project.json'), {
596
+ id: 'onboarding',
597
+ name: 'Getting Started',
598
+ status: 'in-progress',
599
+ priority: 1,
600
+ owner: safePaName,
601
+ created: today,
602
+ summary: 'Your first project -- get set up and comfortable with your PA.',
603
+ });
604
+
605
+ // 5. Initial work items
606
+ const workDir = join(repoRoot, 'work');
607
+ mkdirSync(workDir, { recursive: true });
608
+
609
+ writeJson(join(workDir, 'w-' + today + '-001.json'), {
610
+ id: 'w-' + today + '-001',
611
+ title: 'Review and refine your profile',
612
+ category: 'chore',
613
+ status: 'planned',
614
+ priority: 1,
615
+ projectId: 'onboarding',
616
+ assignee: 'operator',
617
+ summary: 'Check your profile at operator/profile/operator.json and the dashboard Profile page. Add any missing details, correct anything that looks wrong, and add skills or context that will help your PA work better for you.',
618
+ created: today,
619
+ });
620
+
621
+ writeJson(join(workDir, 'w-' + today + '-002.json'), {
622
+ id: 'w-' + today + '-002',
623
+ title: 'Set up integrations',
624
+ category: 'chore',
625
+ status: 'planned',
626
+ priority: 2,
627
+ projectId: 'onboarding',
628
+ assignee: safePaName,
629
+ summary: 'Connect external tools your PA should work with: GitHub (gh CLI), MCP servers, IDE extensions. Check the blueprints directory for integration guides.',
630
+ created: today,
631
+ });
632
+
633
+ writeJson(join(workDir, 'w-' + today + '-003.json'), {
634
+ id: 'w-' + today + '-003',
635
+ title: 'Create your first knowledge entry',
636
+ category: 'chore',
637
+ status: 'planned',
638
+ priority: 2,
639
+ projectId: 'onboarding',
640
+ assignee: 'operator',
641
+ summary: 'Add something to operator/knowledge/ — a technical decision, a process you follow, a reference document. This seeds the knowledge system so your PA has context to work with.',
642
+ created: today,
643
+ });
644
+
645
+ writeJson(join(workDir, 'w-' + today + '-004.json'), {
646
+ id: 'w-' + today + '-004',
647
+ title: 'Set up scheduled tasks (distillation, backups)',
648
+ category: 'chore',
649
+ status: 'planned',
650
+ priority: 2,
651
+ projectId: 'onboarding',
652
+ assignee: safePaName,
653
+ summary: 'Configure recurring tasks: knowledge distillation sweep (critical — this is how your PA learns between sessions), and any backups or maintenance. Use the /schedule command or run core/tools/scheduler/install.mjs. The distillation sweep should run at least daily.',
654
+ created: today,
655
+ });
656
+
657
+ writeJson(join(workDir, 'w-' + today + '-005.json'), {
658
+ id: 'w-' + today + '-005',
659
+ title: 'Verify secrets vault and back up master key',
660
+ category: 'chore',
661
+ status: 'planned',
662
+ priority: 1,
663
+ projectId: 'onboarding',
664
+ assignee: 'operator',
665
+ summary: 'Check vault status with: pnpm exec secrets status. If not initialised, run pnpm exec secrets init. Back up your master key with pnpm exec secrets backup (opens QR code in browser).',
666
+ created: today,
667
+ });
668
+
669
+ // 6. Git init + initial commit
670
+ try {
671
+ const gitDir = join(repoRoot, '.git');
672
+ if (!existsSync(gitDir)) {
673
+ execSync('git init', { cwd: repoRoot, stdio: 'pipe' });
674
+ }
675
+ execSync('git add -A', { cwd: repoRoot, stdio: 'pipe' });
676
+ execSync('git commit -m "Initial setup via onboarding"', {
677
+ cwd: repoRoot,
678
+ stdio: 'pipe',
679
+ env: Object.assign({}, process.env, { GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: email }),
680
+ });
681
+ } catch (e) {
682
+ // Non-fatal: git may not be available or commit may fail
683
+ }
684
+
685
+ // 7. Vault initialisation (non-fatal)
686
+ //
687
+ // NOTE (v0.1.5 — w-2026-05-18-014 follow-up): this code path still spawns
688
+ // a v1 stale path (`core/tools/secrets/bin/secrets`) that doesn't exist
689
+ // in v2 installs, and reads `~/.config/dave/age-key.txt` instead of the
690
+ // v2 `~/.config/stravica-pa/age-key.txt`. v0.1.5 fixes the live GET/POST
691
+ // /__api/secrets endpoints in-process; converting this onboarding flow
692
+ // to in-process requires making processOnboarding async, which is a
693
+ // larger refactor (callers of processOnboarding are sync). Tracked
694
+ // separately — see Notes in the v0.1.5 PR description.
695
+ var vault = { initialised: false, publicKey: null, privateKey: null, error: null };
696
+ try {
697
+ const vaultBin = join(repoRoot, 'core', 'tools', 'secrets', 'bin', 'secrets');
698
+ execSync('node "' + vaultBin + '" init', {
699
+ cwd: repoRoot,
700
+ stdio: 'pipe',
701
+ timeout: 30000,
702
+ });
703
+
704
+ // Read the private key for one-time QR backup display
705
+ const keyDir = join(process.env.HOME || '/tmp', '.config', 'dave');
706
+ const keyFile = join(keyDir, 'age-key.txt');
707
+ if (existsSync(keyFile)) {
708
+ const keyContents = readFileSync(keyFile, 'utf-8');
709
+ vault.initialised = true;
710
+ // Extract only the secret key line for QR code
711
+ const secretLine = keyContents.split('\n').find(function(l) { return l.startsWith('AGE-SECRET-KEY-'); });
712
+ vault.privateKey = secretLine || null;
713
+ // Read public key
714
+ const recipientsFile = join(repoRoot, '.vault', 'recipients.txt');
715
+ if (existsSync(recipientsFile)) {
716
+ vault.publicKey = readFileSync(recipientsFile, 'utf-8').trim();
717
+ }
718
+ }
719
+ } catch (e) {
720
+ vault.error = e.message;
721
+ }
722
+
723
+ return { success: true, redirect: '/', vault: vault };
724
+ }
725
+
726
+
727
+ // -- Data walkers -----------------------------------------------------
728
+
729
+ function walkProjects(repoRoot) {
730
+ const projectsDir = join(repoRoot, 'projects');
731
+ if (!existsSync(projectsDir)) return [];
732
+ const entries = readdirSync(projectsDir, { withFileTypes: true });
733
+ const projects = [];
734
+ for (const entry of entries) {
735
+ if (!entry.isDirectory()) continue;
736
+ const manifest = join(projectsDir, entry.name, 'project.json');
737
+ if (existsSync(manifest)) {
738
+ try {
739
+ projects.push(JSON.parse(readFileSync(manifest, 'utf-8')));
740
+ } catch (e) {
741
+ // skip malformed
742
+ }
743
+ }
744
+ }
745
+ return projects;
746
+ }
747
+
748
+ function walkWork(repoRoot) {
749
+ const workDir = join(repoRoot, 'work');
750
+ if (!existsSync(workDir)) return [];
751
+ const files = readdirSync(workDir).filter(function(f) { return f.endsWith('.json'); });
752
+ const items = [];
753
+ for (const file of files) {
754
+ try {
755
+ items.push(JSON.parse(readFileSync(join(workDir, file), 'utf-8')));
756
+ } catch (e) {
757
+ // skip malformed
758
+ }
759
+ }
760
+ return items;
761
+ }
762
+
763
+ function walkNotes(repoRoot) {
764
+ const notesDir = join(repoRoot, 'operator', 'notes');
765
+ if (!existsSync(notesDir)) return [];
766
+ const files = readdirSync(notesDir).filter(function(f) { return f.endsWith('.md'); });
767
+ var prefixes = ['discuss-', 'research-', 'draft-', 'spec-', 'pickup-', 'marketing-'];
768
+ var notes = [];
769
+ for (var i = 0; i < files.length; i++) {
770
+ var name = files[i];
771
+ var absPath = join(notesDir, name);
772
+ var st;
773
+ try { st = statSync(absPath); } catch(e) { continue; }
774
+ var title = name.replace(/\.md$/, '');
775
+ for (var j = 0; j < prefixes.length; j++) {
776
+ if (title.startsWith(prefixes[j])) { title = title.slice(prefixes[j].length); break; }
777
+ }
778
+ title = title.replace(/-/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
779
+ notes.push({
780
+ name: name,
781
+ title: title,
782
+ path: 'operator/notes/' + name,
783
+ modified: st.mtime.toISOString(),
784
+ });
785
+ }
786
+ notes.sort(function(a, b) { return b.modified.localeCompare(a.modified); });
787
+ return notes;
788
+ }
789
+
790
+ function walkDocuments(dir, repoRoot, result) {
791
+ const entries = readdirSync(dir, { withFileTypes: true });
792
+ for (const e of entries) {
793
+ if (e.isDirectory()) {
794
+ walkDocuments(join(dir, e.name), repoRoot, result);
795
+ } else if (e.name !== 'index.json' && (e.name.endsWith('.md') || e.name.endsWith('.json'))) {
796
+ result.push({
797
+ name: e.name,
798
+ path: relative(repoRoot, join(dir, e.name)),
799
+ });
800
+ }
801
+ }
802
+ }
803
+
804
+ function walkSchedules(repoRoot) {
805
+ const schedulesDir = join(repoRoot, 'schedules');
806
+ if (!existsSync(schedulesDir)) return [];
807
+ const files = readdirSync(schedulesDir).filter(function(f) { return f.endsWith('.json'); });
808
+ const schedules = [];
809
+ for (const file of files) {
810
+ try {
811
+ const data = JSON.parse(readFileSync(join(schedulesDir, file), 'utf-8'));
812
+ data._file = file;
813
+ schedules.push(data);
814
+ } catch (e) {
815
+ // skip malformed
816
+ }
817
+ }
818
+ return schedules;
819
+ }
820
+
821
+ function buildOverview(projects, work, repoRoot) {
822
+ const projectCount = projects.length;
823
+ const workByStatus = {};
824
+ for (const item of work) {
825
+ const status = item.status || 'unknown';
826
+ workByStatus[status] = (workByStatus[status] || 0) + 1;
827
+ }
828
+ // Read PA name from preferences if available
829
+ let paName = null;
830
+ try {
831
+ const prefsFile = join(repoRoot, 'operator', 'config', 'preferences.json');
832
+ if (existsSync(prefsFile)) {
833
+ const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
834
+ paName = prefs.paName || null;
835
+ }
836
+ } catch (e) { /* non-fatal */ }
837
+ // Read operator name and role from profile
838
+ let operatorName = null;
839
+ let operatorRole = null;
840
+ try {
841
+ const profileFile = join(repoRoot, 'operator', 'profile', 'operator.json');
842
+ if (existsSync(profileFile)) {
843
+ const profile = JSON.parse(readFileSync(profileFile, 'utf-8'));
844
+ operatorName = profile.name || null;
845
+ operatorRole = profile.role || null;
846
+ }
847
+ } catch (e) { /* non-fatal */ }
848
+ return {
849
+ paName: paName,
850
+ operatorName: operatorName,
851
+ operatorRole: operatorRole,
852
+ projectCount: projectCount,
853
+ workItemCount: work.length,
854
+ workByStatus: workByStatus,
855
+ // Return the full project and work objects. Consumers (home page, the
856
+ // E2E harness) expect the complete shape — the previous trimmed summary
857
+ // dropped priority, owner, summary, etc., which forced every consumer
858
+ // to fetch /__api/projects a second time.
859
+ projects: projects,
860
+ work: work,
861
+ };
862
+ }
863
+
864
+
865
+ // -- Helpers ----------------------------------------------------------
866
+
867
+ function readBody(req) {
868
+ return new Promise(function(resolve, reject) {
869
+ const chunks = [];
870
+ req.on('data', function(chunk) { chunks.push(chunk); });
871
+ req.on('end', function() { resolve(Buffer.concat(chunks).toString()); });
872
+ req.on('error', reject);
873
+ });
874
+ }
875
+
876
+ function sendJson(res, status, data) {
877
+ const body = JSON.stringify(data);
878
+ res.writeHead(status, {
879
+ 'Content-Type': 'application/json',
880
+ 'Content-Length': Buffer.byteLength(body),
881
+ });
882
+ res.end(body);
883
+ }
884
+
885
+ function writeJson(filePath, data) {
886
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
887
+ }
888
+
889
+
890
+ // -- Git overview -----------------------------------------------------
891
+
892
+ /**
893
+ * Build the /__api/git-overview payload. Shape:
894
+ *
895
+ * {
896
+ * available: boolean,
897
+ * reason?: string, // when available === false
898
+ * generatedAt: ISO string,
899
+ * orgs: string[], // configured GitHub orgs
900
+ * repos: RepoRow[], // user's owned repos (from gh repo list)
901
+ * openPrs: PrRow[], // user's open PRs
902
+ * orgRepos: OrgRepoRow[], // recent activity across configured orgs
903
+ * branches: BranchRow[], // staleness warnings across top repos
904
+ * recentCommits: CommitRow[], // flat cross-org commit feed (30d)
905
+ * leaderboard: { // contributor leaderboard per org
906
+ * org: string,
907
+ * contributors: { login: string, commits: number }[],
908
+ * }[],
909
+ * }
910
+ *
911
+ * All gh/git calls have short timeouts and fail-soft — the page must render
912
+ * partial data rather than error.
913
+ */
914
+ async function buildGitOverview(repoRoot) {
915
+ const generatedAt = new Date().toISOString();
916
+
917
+ // Check gh availability first. Missing or unauthenticated gh → graceful
918
+ // degradation, same contract as /__api/repos.
919
+ try {
920
+ execSync('gh --version', { stdio: 'pipe', timeout: 5000 });
921
+ } catch {
922
+ return {
923
+ available: false,
924
+ reason: 'gh CLI not installed. Install it from https://cli.github.com/ to enable Git Overview.',
925
+ generatedAt: generatedAt,
926
+ };
927
+ }
928
+ try {
929
+ execSync('gh auth status', { stdio: 'pipe', timeout: 5000 });
930
+ } catch {
931
+ return {
932
+ available: false,
933
+ reason: 'gh CLI not authenticated. Run: gh auth login',
934
+ generatedAt: generatedAt,
935
+ };
936
+ }
937
+
938
+ // Read configured orgs. Missing preferences is fine — leaderboard/orgRepos
939
+ // just come back empty and the page renders the user's own repos only.
940
+ let orgs = [];
941
+ try {
942
+ const prefsFile = join(repoRoot, 'operator', 'config', 'preferences.json');
943
+ if (existsSync(prefsFile)) {
944
+ const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
945
+ if (Array.isArray(prefs.gitOrgs)) {
946
+ orgs = prefs.gitOrgs.filter(function(o) { return typeof o === 'string' && o.length > 0; });
947
+ }
948
+ }
949
+ } catch { /* non-fatal */ }
950
+
951
+ // Owned repos — same call shape as /__api/repos, but we also pull
952
+ // `owner.login` so we can group by org for the no-`gitOrgs` fallback, and
953
+ // `defaultBranchRef` so we can surface a branch-staleness signal from
954
+ // owned repos alone.
955
+ let repos = [];
956
+ try {
957
+ const reposRaw = execSync(
958
+ 'gh repo list --json name,description,url,primaryLanguage,pushedAt,stargazerCount,isFork,isPrivate,owner,defaultBranchRef --limit 30 --no-archived',
959
+ { encoding: 'utf-8', timeout: 15000 }
960
+ ).trim();
961
+ const parsed = JSON.parse(reposRaw || '[]');
962
+ repos = parsed.map(function(r) {
963
+ return {
964
+ name: r.name,
965
+ description: r.description,
966
+ html_url: r.url,
967
+ language: r.primaryLanguage ? r.primaryLanguage.name : null,
968
+ pushed_at: r.pushedAt,
969
+ stargazers_count: r.stargazerCount || 0,
970
+ fork: r.isFork || false,
971
+ private: r.isPrivate || false,
972
+ owner_login: r.owner && r.owner.login ? r.owner.login : null,
973
+ default_branch: r.defaultBranchRef && r.defaultBranchRef.name ? r.defaultBranchRef.name : 'main',
974
+ };
975
+ }).sort(function(a, b) {
976
+ return (b.pushed_at || '').localeCompare(a.pushed_at || '');
977
+ });
978
+ } catch { /* non-fatal */ }
979
+
980
+ // Open PRs authored by user.
981
+ let openPrs = [];
982
+ try {
983
+ const prsRaw = execSync(
984
+ 'gh search prs --author=@me --state=open --json title,url,repository,createdAt,updatedAt,isDraft,number --limit 20',
985
+ { encoding: 'utf-8', timeout: 15000 }
986
+ ).trim();
987
+ const parsed = JSON.parse(prsRaw || '[]');
988
+ openPrs = parsed.map(function(pr) {
989
+ return {
990
+ title: pr.title,
991
+ html_url: pr.url,
992
+ repo: pr.repository ? pr.repository.nameWithOwner : null,
993
+ created_at: pr.createdAt,
994
+ updated_at: pr.updatedAt,
995
+ draft: pr.isDraft || false,
996
+ number: pr.number || null,
997
+ };
998
+ });
999
+ } catch { /* non-fatal */ }
1000
+
1001
+ // Build "30 days ago" ISO string for activity windows.
1002
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
1003
+ const sinceIso = new Date(Date.now() - THIRTY_DAYS_MS).toISOString();
1004
+
1005
+ // For each configured org, list its top repos by pushedAt and pull per-repo
1006
+ // commit activity over the last 30 days. Each gh call has a short timeout
1007
+ // and failures are swallowed.
1008
+ const orgRepos = [];
1009
+ const leaderboard = [];
1010
+ const recentCommits = [];
1011
+ const branches = [];
1012
+
1013
+ for (const org of orgs) {
1014
+ let orgReposRaw = [];
1015
+ try {
1016
+ const raw = execSync(
1017
+ 'gh repo list ' + shellQuote(org) + ' --json name,description,url,pushedAt,isArchived,isFork,defaultBranchRef --limit 20 --no-archived',
1018
+ { encoding: 'utf-8', timeout: 15000 }
1019
+ ).trim();
1020
+ orgReposRaw = JSON.parse(raw || '[]');
1021
+ } catch { /* skip this org */ }
1022
+
1023
+ const contribCounts = Object.create(null);
1024
+
1025
+ // Only look at top 5 most recently active per org to bound API work.
1026
+ const topForOrg = orgReposRaw.slice(0, 5);
1027
+
1028
+ for (const r of topForOrg) {
1029
+ const repoFull = org + '/' + r.name;
1030
+ // Commits since 30d ago
1031
+ let repoCommits = [];
1032
+ try {
1033
+ const raw = execSync(
1034
+ 'gh api ' + shellQuote('/repos/' + repoFull + '/commits?since=' + sinceIso + '&per_page=100') +
1035
+ ' --paginate',
1036
+ { encoding: 'utf-8', timeout: 20000, maxBuffer: 8 * 1024 * 1024 }
1037
+ ).trim();
1038
+ // --paginate concatenates JSON arrays by default. Try parse; on fail,
1039
+ // attempt to repair concatenated arrays.
1040
+ try {
1041
+ repoCommits = JSON.parse(raw || '[]');
1042
+ } catch {
1043
+ const merged = raw.replace(/\]\s*\[/g, ',');
1044
+ repoCommits = JSON.parse(merged || '[]');
1045
+ }
1046
+ } catch { /* skip this repo */ }
1047
+
1048
+ // Aggregate contributor counts and build flat commit feed (top 3 per repo).
1049
+ let commits30d = 0;
1050
+ for (const c of repoCommits) {
1051
+ const login = (c.author && c.author.login)
1052
+ || (c.commit && c.commit.author && c.commit.author.name)
1053
+ || 'unknown';
1054
+ contribCounts[login] = (contribCounts[login] || 0) + 1;
1055
+ commits30d += 1;
1056
+ }
1057
+ const topCommits = repoCommits.slice(0, 3);
1058
+ for (const c of topCommits) {
1059
+ recentCommits.push({
1060
+ repo: repoFull,
1061
+ sha: (c.sha || '').slice(0, 7),
1062
+ message: (c.commit && c.commit.message ? String(c.commit.message).split('\n')[0] : ''),
1063
+ author: (c.author && c.author.login) || (c.commit && c.commit.author && c.commit.author.name) || 'unknown',
1064
+ date: c.commit && c.commit.author && c.commit.author.date ? c.commit.author.date : null,
1065
+ html_url: c.html_url || null,
1066
+ });
1067
+ }
1068
+
1069
+ // Default branch staleness signal: warn if no commits in 30 days,
1070
+ // based on the pushedAt field we already have.
1071
+ const lastCommitIso = r.pushedAt || null;
1072
+ const lastMs = lastCommitIso ? Date.parse(lastCommitIso) : 0;
1073
+ const ageDays = lastMs ? Math.floor((Date.now() - lastMs) / (24 * 60 * 60 * 1000)) : null;
1074
+ const defaultBranch = r.defaultBranchRef && r.defaultBranchRef.name ? r.defaultBranchRef.name : 'main';
1075
+ branches.push({
1076
+ repo: repoFull,
1077
+ branch: defaultBranch,
1078
+ lastCommit: lastCommitIso,
1079
+ ageDays: ageDays,
1080
+ stale: ageDays !== null && ageDays > 30,
1081
+ });
1082
+
1083
+ orgRepos.push({
1084
+ org: org,
1085
+ name: r.name,
1086
+ full_name: repoFull,
1087
+ description: r.description,
1088
+ html_url: r.url,
1089
+ pushed_at: r.pushedAt,
1090
+ commits30d: commits30d,
1091
+ });
1092
+ }
1093
+
1094
+ const contributors = Object.keys(contribCounts).map(function(login) {
1095
+ return { login: login, commits: contribCounts[login] };
1096
+ }).sort(function(a, b) {
1097
+ return b.commits - a.commits;
1098
+ }).slice(0, 10);
1099
+ leaderboard.push({ org: org, contributors: contributors });
1100
+ }
1101
+
1102
+ // Sort aggregated outputs for deterministic rendering.
1103
+ orgRepos.sort(function(a, b) { return (b.pushed_at || '').localeCompare(a.pushed_at || ''); });
1104
+ recentCommits.sort(function(a, b) { return (b.date || '').localeCompare(a.date || ''); });
1105
+ branches.sort(function(a, b) {
1106
+ // Stale first, then by ageDays desc.
1107
+ if (a.stale !== b.stale) return a.stale ? -1 : 1;
1108
+ return (b.ageDays || 0) - (a.ageDays || 0);
1109
+ });
1110
+
1111
+ // Owned-repos fallback ladder: when the operator has not configured
1112
+ // gitOrgs, the page would otherwise show three flat empty panels. Derive
1113
+ // the same three signals from the user's owned repos so the page is
1114
+ // useful out of the box. The gitOrgs prompt stays as a dim hint in the
1115
+ // UI; these fallbacks are additive.
1116
+ //
1117
+ // `ownedBranches` and `ownedOrgActivity` are cheap — computed from data
1118
+ // already in hand. `ownedRecentCommits` makes real `gh api` calls in
1119
+ // parallel (capped concurrency — see buildOwnedRecentCommits), so we only
1120
+ // pay that cost when the org-driven feed is empty (typically means no
1121
+ // gitOrgs configured). The parallel fan-out keeps latency bounded even
1122
+ // for accounts with dozens of owned repos, so no env-var gate is needed.
1123
+ let ownedBranches = [];
1124
+ let ownedRecentCommits = [];
1125
+ let ownedOrgActivity = [];
1126
+ if (Array.isArray(repos) && repos.length) {
1127
+ ownedBranches = buildOwnedBranchStaleness(repos);
1128
+ ownedOrgActivity = buildOwnedOrgActivity(repos);
1129
+ if (!recentCommits.length) {
1130
+ ownedRecentCommits = await buildOwnedRecentCommits(repos);
1131
+ }
1132
+ }
1133
+
1134
+ return {
1135
+ available: true,
1136
+ generatedAt: generatedAt,
1137
+ orgs: orgs,
1138
+ repos: repos,
1139
+ openPrs: openPrs,
1140
+ orgRepos: orgRepos,
1141
+ branches: branches,
1142
+ recentCommits: recentCommits.slice(0, 30),
1143
+ leaderboard: leaderboard,
1144
+ // Fallback signals derived purely from the user's owned repos — always
1145
+ // populated (cheap), the UI chooses whether to render them based on
1146
+ // whether the org-driven signals are empty.
1147
+ ownedBranches: ownedBranches,
1148
+ ownedRecentCommits: ownedRecentCommits,
1149
+ ownedOrgActivity: ownedOrgActivity,
1150
+ };
1151
+ }
1152
+
1153
+ /**
1154
+ * Branch-staleness fallback: walk the user's owned repos and flag any whose
1155
+ * default branch hasn't been pushed to in a while. Cheap — uses the pushedAt
1156
+ * field already fetched by `gh repo list`, no extra API calls.
1157
+ *
1158
+ * Ranking: stalest first. Capped at 20 entries. Marked stale when ageDays
1159
+ * exceeds 60 (the yellow threshold) so the panel has something to show even
1160
+ * for active repos; the page decides colour ramp from ageDays itself.
1161
+ */
1162
+ export function buildOwnedBranchStaleness(repos) {
1163
+ const WARN_DAYS = 60;
1164
+ const out = [];
1165
+ const now = Date.now();
1166
+ for (const r of repos) {
1167
+ if (!r.pushed_at) continue;
1168
+ const lastMs = Date.parse(r.pushed_at);
1169
+ if (!Number.isFinite(lastMs)) continue;
1170
+ const ageDays = Math.floor((now - lastMs) / (24 * 60 * 60 * 1000));
1171
+ const repoFull = (r.owner_login ? r.owner_login + '/' : '') + r.name;
1172
+ out.push({
1173
+ repo: repoFull,
1174
+ branch: r.default_branch || 'main',
1175
+ lastCommit: r.pushed_at,
1176
+ ageDays: ageDays,
1177
+ stale: ageDays >= WARN_DAYS,
1178
+ });
1179
+ }
1180
+ // Stalest first.
1181
+ out.sort(function(a, b) { return (b.ageDays || 0) - (a.ageDays || 0); });
1182
+ return out.slice(0, 20);
1183
+ }
1184
+
1185
+ /**
1186
+ * Recent-commits fallback: a flat feed of the user's latest commits across
1187
+ * the user's owned repos, last 30 days, capped at 30 entries.
1188
+ *
1189
+ * Makes one `gh api /repos/{repo}/commits?since=…` call per candidate repo,
1190
+ * fanned out in parallel with a bounded concurrency window so GitHub's
1191
+ * secondary rate-limiter stays happy and we don't hold sockets open forever
1192
+ * on a slow connection. Per-repo failures are swallowed (Promise.allSettled)
1193
+ * so one 404 / permission denial / timeout doesn't poison the batch.
1194
+ *
1195
+ * Before this was serial `execSync`, which scaled linearly with repo count
1196
+ * and capped the probe list at 3 to keep total latency tolerable. With
1197
+ * parallel fan-out the probe set can be wider (15) while wall-clock stays
1198
+ * within the timeout of a single call, so the Playwright webserver no
1199
+ * longer needs the WS_SKIP_OWNED_COMMITS escape hatch.
1200
+ */
1201
+ export async function buildOwnedRecentCommits(repos) {
1202
+ const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
1203
+ const sinceIso = new Date(Date.now() - THIRTY_DAYS_MS).toISOString();
1204
+ // Parallel fan-out means total wall-clock is ~max(per-call) rather than
1205
+ // sum, so we can afford a wider probe set than the serial version. 15 is
1206
+ // a sensible ceiling: enough to surface a rich feed for active users,
1207
+ // still bounded so an account with hundreds of repos can't explode the
1208
+ // work queue. Cap concurrency at 8 to stay well inside GitHub's
1209
+ // secondary rate-limit envelope (no per-endpoint quota hits observed
1210
+ // below ~100 req/min).
1211
+ const MAX_REPOS_PROBED = 15;
1212
+ const CONCURRENCY = 8;
1213
+ const PER_CALL_TIMEOUT_MS = 5000;
1214
+
1215
+ const active = repos.filter(function(r) {
1216
+ if (!r.pushed_at) return false;
1217
+ const t = Date.parse(r.pushed_at);
1218
+ return Number.isFinite(t) && (Date.now() - t) < THIRTY_DAYS_MS;
1219
+ }).slice(0, MAX_REPOS_PROBED);
1220
+
1221
+ if (!active.length) return [];
1222
+
1223
+ // Fetch one repo's commits. Resolves to a list of already-normalised row
1224
+ // objects; rejects never — any error (timeout, non-zero exit, bad JSON)
1225
+ // collapses to [] so the caller can safely merge.
1226
+ async function fetchRepoCommits(r) {
1227
+ const repoFull = (r.owner_login ? r.owner_login + '/' : '') + r.name;
1228
+ const apiPath = '/repos/' + repoFull + '/commits?since=' + sinceIso + '&per_page=10';
1229
+ try {
1230
+ const { stdout } = await execFileAsync(
1231
+ 'gh',
1232
+ ['api', apiPath],
1233
+ { encoding: 'utf-8', timeout: PER_CALL_TIMEOUT_MS, maxBuffer: 2 * 1024 * 1024 }
1234
+ );
1235
+ const commits = JSON.parse((stdout || '').trim() || '[]');
1236
+ return commits.map(function(c) {
1237
+ return {
1238
+ repo: repoFull,
1239
+ sha: (c.sha || '').slice(0, 7),
1240
+ message: (c.commit && c.commit.message ? String(c.commit.message).split('\n')[0] : ''),
1241
+ author: (c.author && c.author.login) || (c.commit && c.commit.author && c.commit.author.name) || 'unknown',
1242
+ date: c.commit && c.commit.author && c.commit.author.date ? c.commit.author.date : null,
1243
+ html_url: c.html_url || null,
1244
+ };
1245
+ });
1246
+ } catch {
1247
+ return [];
1248
+ }
1249
+ }
1250
+
1251
+ // Chunked Promise.allSettled keeps at most CONCURRENCY gh processes in
1252
+ // flight. allSettled so a single failure doesn't short-circuit the
1253
+ // batch — fetchRepoCommits already swallows its own errors, but
1254
+ // allSettled is belt-and-braces in case that contract ever changes.
1255
+ const out = [];
1256
+ for (let i = 0; i < active.length; i += CONCURRENCY) {
1257
+ const chunk = active.slice(i, i + CONCURRENCY);
1258
+ const settled = await Promise.allSettled(chunk.map(fetchRepoCommits));
1259
+ for (const s of settled) {
1260
+ if (s.status === 'fulfilled' && Array.isArray(s.value)) {
1261
+ for (const row of s.value) out.push(row);
1262
+ }
1263
+ }
1264
+ }
1265
+
1266
+ out.sort(function(a, b) { return (b.date || '').localeCompare(a.date || ''); });
1267
+ return out.slice(0, 30);
1268
+ }
1269
+
1270
+ /**
1271
+ * Org-activity fallback: group the user's owned repos by owner.login and
1272
+ * emit per-org aggregates. No extra API calls — all inputs come from the
1273
+ * already-fetched repo list.
1274
+ */
1275
+ export function buildOwnedOrgActivity(repos) {
1276
+ const byOrg = Object.create(null);
1277
+ for (const r of repos) {
1278
+ const org = r.owner_login || 'unknown';
1279
+ if (!byOrg[org]) {
1280
+ byOrg[org] = { org: org, repoCount: 0, lastTouched: null, lastTouchedRepo: null, activeRepos30d: 0 };
1281
+ }
1282
+ byOrg[org].repoCount++;
1283
+ if (r.pushed_at && (!byOrg[org].lastTouched || r.pushed_at > byOrg[org].lastTouched)) {
1284
+ byOrg[org].lastTouched = r.pushed_at;
1285
+ byOrg[org].lastTouchedRepo = (r.owner_login ? r.owner_login + '/' : '') + r.name;
1286
+ }
1287
+ if (r.pushed_at) {
1288
+ const t = Date.parse(r.pushed_at);
1289
+ if (Number.isFinite(t) && (Date.now() - t) < 30 * 24 * 60 * 60 * 1000) {
1290
+ byOrg[org].activeRepos30d++;
1291
+ }
1292
+ }
1293
+ }
1294
+ const out = Object.keys(byOrg).map(function(k) { return byOrg[k]; });
1295
+ out.sort(function(a, b) { return (b.lastTouched || '').localeCompare(a.lastTouched || ''); });
1296
+ return out;
1297
+ }
1298
+
1299
+ // Minimal shell-quoting helper for embedded strings. We trust the caller to
1300
+ // pass a well-formed org/repo slug, but quote defensively to avoid injection
1301
+ // via preferences.json content.
1302
+ function shellQuote(s) {
1303
+ return "'" + String(s).replace(/'/g, "'\\''") + "'";
1304
+ }
1305
+
1306
+ // -- Delegations / spawn-log aggregation --------------------------------
1307
+
1308
+ /**
1309
+ * Walk agents/<pa>/spawn-log.json and aggregate.
1310
+ *
1311
+ * Returns:
1312
+ * {
1313
+ * generatedAt: ISO,
1314
+ * agents: [{ name, total, inFlight, success, failed, byRole, byTier }],
1315
+ * spawns: [{ agent, id, timestamp, role, tier, objective, outcome, notes, ... }],
1316
+ * stats: { total, inFlight, success, failed, byRole: {...}, byTier: {...} },
1317
+ * }
1318
+ */
1319
+ function buildDelegations(repoRoot) {
1320
+ const agentsDir = join(repoRoot, 'agents');
1321
+ const spawns = [];
1322
+ const agents = [];
1323
+ if (existsSync(agentsDir)) {
1324
+ const entries = readdirSync(agentsDir, { withFileTypes: true });
1325
+ for (const entry of entries) {
1326
+ if (!entry.isDirectory()) continue;
1327
+ const logFile = join(agentsDir, entry.name, 'spawn-log.json');
1328
+ if (!existsSync(logFile)) continue;
1329
+ let data;
1330
+ try { data = JSON.parse(readFileSync(logFile, 'utf-8')); } catch { continue; }
1331
+ const paSpawns = Array.isArray(data.spawns) ? data.spawns : [];
1332
+ const agentAgg = {
1333
+ name: entry.name,
1334
+ total: 0,
1335
+ inFlight: 0,
1336
+ success: 0,
1337
+ failed: 0,
1338
+ byRole: Object.create(null),
1339
+ byTier: Object.create(null),
1340
+ };
1341
+ for (const sp of paSpawns) {
1342
+ const normalised = {
1343
+ agent: entry.name,
1344
+ id: sp.id || null,
1345
+ timestamp: sp.timestamp || null,
1346
+ role: sp.role || 'unknown',
1347
+ tier: (typeof sp.tier === 'number') ? sp.tier : null,
1348
+ objective: sp.objective || '',
1349
+ outcome: sp.outcome || 'unknown',
1350
+ notes: sp.notes || '',
1351
+ tokens: sp.tokens || null,
1352
+ durationMs: sp.durationMs || null,
1353
+ toolUses: sp.toolUses || null,
1354
+ };
1355
+ spawns.push(normalised);
1356
+ agentAgg.total++;
1357
+ const oc = String(normalised.outcome).toLowerCase();
1358
+ if (oc === 'success' || oc === 'done' || oc === 'complete' || oc === 'completed') {
1359
+ agentAgg.success++;
1360
+ } else if (oc === 'failed' || oc === 'failure' || oc === 'error') {
1361
+ agentAgg.failed++;
1362
+ } else if (oc === 'in-flight' || oc === 'in-progress' || oc === 'dispatched' || oc === 'running') {
1363
+ agentAgg.inFlight++;
1364
+ }
1365
+ agentAgg.byRole[normalised.role] = (agentAgg.byRole[normalised.role] || 0) + 1;
1366
+ const tierKey = normalised.tier == null ? 'unknown' : String(normalised.tier);
1367
+ agentAgg.byTier[tierKey] = (agentAgg.byTier[tierKey] || 0) + 1;
1368
+ }
1369
+ agents.push(agentAgg);
1370
+ }
1371
+ }
1372
+
1373
+ spawns.sort(function(a, b) { return (b.timestamp || '').localeCompare(a.timestamp || ''); });
1374
+
1375
+ const stats = { total: 0, inFlight: 0, success: 0, failed: 0, byRole: {}, byTier: {} };
1376
+ for (const a of agents) {
1377
+ stats.total += a.total;
1378
+ stats.inFlight += a.inFlight;
1379
+ stats.success += a.success;
1380
+ stats.failed += a.failed;
1381
+ for (const r of Object.keys(a.byRole)) stats.byRole[r] = (stats.byRole[r] || 0) + a.byRole[r];
1382
+ for (const t of Object.keys(a.byTier)) stats.byTier[t] = (stats.byTier[t] || 0) + a.byTier[t];
1383
+ }
1384
+
1385
+ return {
1386
+ generatedAt: new Date().toISOString(),
1387
+ agents: agents,
1388
+ spawns: spawns.slice(0, 100),
1389
+ stats: stats,
1390
+ };
1391
+ }
1392
+
1393
+
1394
+ // -- Knowledge map -----------------------------------------------------
1395
+
1396
+ /**
1397
+ * Parse a leading `---`-delimited YAML-ish front-matter block on a domain
1398
+ * README. Supports the flat `key: value` pairs needed for the homepage
1399
+ * (displayName). Matches the behaviour of core/knowledge/retrieval's
1400
+ * stripFrontMatter / parseFrontMatter helpers (duplicated here to keep the
1401
+ * workspace server zero-dep and independent of the retrieval pipeline).
1402
+ */
1403
+ export function parseDomainFrontMatter(content) {
1404
+ if (typeof content !== 'string' || !content.startsWith('---')) return {};
1405
+ const lines = content.split(/\r?\n/);
1406
+ if (lines[0].trim() !== '---') return {};
1407
+ const out = {};
1408
+ for (let i = 1; i < lines.length; i++) {
1409
+ const line = lines[i];
1410
+ if (line.trim() === '---') return out;
1411
+ const m = /^([A-Za-z_][\w-]*)\s*:\s*(.*)$/.exec(line);
1412
+ if (!m) continue;
1413
+ let value = m[2].trim();
1414
+ if ((value.startsWith('"') && value.endsWith('"')) ||
1415
+ (value.startsWith("'") && value.endsWith("'"))) {
1416
+ value = value.slice(1, -1);
1417
+ }
1418
+ out[m[1]] = value;
1419
+ }
1420
+ return {};
1421
+ }
1422
+
1423
+ /**
1424
+ * Read operator/knowledge/domains.json + .graph.json + .backlinks.json, and
1425
+ * walk each domain tree for per-file mtimes. Produces an audit ready for
1426
+ * rendering: staleness (60d warn / 120d red), thin-domain (<3 docs), edge
1427
+ * counts and backlink totals.
1428
+ */
1429
+ function buildKnowledgeMap(repoRoot) {
1430
+ const knowledgeDir = join(repoRoot, 'operator', 'knowledge');
1431
+ if (!existsSync(knowledgeDir)) {
1432
+ return {
1433
+ generatedAt: new Date().toISOString(),
1434
+ available: false,
1435
+ reason: 'No operator/knowledge/ directory found.',
1436
+ domains: [],
1437
+ stats: { totalDomains: 0, totalDocuments: 0, totalEdges: 0 },
1438
+ };
1439
+ }
1440
+
1441
+ const STALE_WARN_DAYS = 60;
1442
+ const STALE_RED_DAYS = 120;
1443
+ const THIN_DOCS = 3;
1444
+
1445
+ // Try domains.json first; fall back to walking subdirs if absent.
1446
+ let domainDefs = [];
1447
+ const domainsFile = join(knowledgeDir, 'domains.json');
1448
+ if (existsSync(domainsFile)) {
1449
+ try {
1450
+ const raw = JSON.parse(readFileSync(domainsFile, 'utf-8'));
1451
+ if (Array.isArray(raw.domains)) domainDefs = raw.domains;
1452
+ } catch { /* fallthrough */ }
1453
+ }
1454
+ if (!domainDefs.length) {
1455
+ const indexFile = join(knowledgeDir, 'index.json');
1456
+ if (existsSync(indexFile)) {
1457
+ try {
1458
+ const raw = JSON.parse(readFileSync(indexFile, 'utf-8'));
1459
+ if (Array.isArray(raw.domains)) {
1460
+ // index.json stores paths relative to operator/
1461
+ domainDefs = raw.domains.map(function(d) {
1462
+ return {
1463
+ id: d.id,
1464
+ name: d.id,
1465
+ path: d.path && d.path.startsWith('operator/') ? d.path : 'operator/' + (d.path || ('knowledge/' + d.id)),
1466
+ description: d.description || null,
1467
+ displayName: d.displayName || null,
1468
+ };
1469
+ });
1470
+ }
1471
+ } catch { /* fallthrough */ }
1472
+ }
1473
+ }
1474
+
1475
+ // Runtime fallback: if `displayName` isn't set in domains.json (i.e. the
1476
+ // index hasn't been regenerated since the field was added), read it from
1477
+ // the README's front-matter directly. Cheap — one small file read per
1478
+ // domain on the homepage path.
1479
+ for (const d of domainDefs) {
1480
+ if (d.displayName) continue;
1481
+ const domainPath = d.path && d.path.indexOf('/') !== -1 ? d.path : join('operator', 'knowledge', d.id);
1482
+ const readmePath = join(repoRoot, domainPath, 'README.md');
1483
+ if (!existsSync(readmePath)) continue;
1484
+ try {
1485
+ const content = readFileSync(readmePath, 'utf-8');
1486
+ const fm = parseDomainFrontMatter(content);
1487
+ if (fm.displayName) d.displayName = fm.displayName;
1488
+ } catch { /* non-fatal */ }
1489
+ }
1490
+
1491
+ // Load graph edges / backlinks if present.
1492
+ let edges = [];
1493
+ const graphFile = join(knowledgeDir, '.graph.json');
1494
+ if (existsSync(graphFile)) {
1495
+ try {
1496
+ const raw = JSON.parse(readFileSync(graphFile, 'utf-8'));
1497
+ if (Array.isArray(raw.edges)) edges = raw.edges;
1498
+ } catch { /* non-fatal */ }
1499
+ }
1500
+ let backlinks = {};
1501
+ const backlinksFile = join(knowledgeDir, '.backlinks.json');
1502
+ if (existsSync(backlinksFile)) {
1503
+ try {
1504
+ const raw = JSON.parse(readFileSync(backlinksFile, 'utf-8'));
1505
+ if (raw.backlinks && typeof raw.backlinks === 'object') backlinks = raw.backlinks;
1506
+ } catch { /* non-fatal */ }
1507
+ }
1508
+
1509
+ // Walk each domain for file list + mtimes.
1510
+ function walkFiles(dir, outAbs, outRel, root) {
1511
+ if (!existsSync(dir)) return;
1512
+ const entries = readdirSync(dir, { withFileTypes: true });
1513
+ for (const e of entries) {
1514
+ const full = join(dir, e.name);
1515
+ if (e.isDirectory()) {
1516
+ walkFiles(full, outAbs, outRel, root);
1517
+ } else if (e.name.endsWith('.md')) {
1518
+ let mtimeMs = 0;
1519
+ try { mtimeMs = statSync(full).mtimeMs; } catch { /* non-fatal */ }
1520
+ outAbs.push(full);
1521
+ outRel.push({ path: relative(root, full), mtimeMs: mtimeMs });
1522
+ }
1523
+ }
1524
+ }
1525
+
1526
+ const now = Date.now();
1527
+ const domainsOut = [];
1528
+ let totalDocs = 0;
1529
+ let totalBacklinks = 0;
1530
+
1531
+ for (const d of domainDefs) {
1532
+ const domainPath = d.path && d.path.indexOf('/') !== -1 ? d.path : join('operator', 'knowledge', d.id);
1533
+ const absDomainPath = join(repoRoot, domainPath);
1534
+ const absList = [];
1535
+ const relList = [];
1536
+ walkFiles(absDomainPath, absList, relList, repoRoot);
1537
+ const docs = relList.map(function(r) {
1538
+ const ageDays = r.mtimeMs ? Math.floor((now - r.mtimeMs) / (24 * 60 * 60 * 1000)) : null;
1539
+ const bl = backlinks[r.path] ? backlinks[r.path].length : 0;
1540
+ totalBacklinks += bl;
1541
+ return {
1542
+ path: r.path,
1543
+ lastUpdated: r.mtimeMs ? new Date(r.mtimeMs).toISOString() : null,
1544
+ ageDays: ageDays,
1545
+ backlinkCount: bl,
1546
+ };
1547
+ });
1548
+ docs.sort(function(a, b) { return (b.ageDays || 0) - (a.ageDays || 0); });
1549
+ totalDocs += docs.length;
1550
+
1551
+ const stalest = docs.length
1552
+ ? docs.reduce(function(acc, x) { return (x.ageDays || 0) > (acc.ageDays || 0) ? x : acc; }, docs[0])
1553
+ : null;
1554
+ const freshest = docs.length
1555
+ ? docs.reduce(function(acc, x) { return (x.ageDays || 999999) < (acc.ageDays || 999999) ? x : acc; }, docs[0])
1556
+ : null;
1557
+
1558
+ const staleCount = docs.filter(function(x) { return x.ageDays != null && x.ageDays >= STALE_WARN_DAYS; }).length;
1559
+ const redCount = docs.filter(function(x) { return x.ageDays != null && x.ageDays >= STALE_RED_DAYS; }).length;
1560
+
1561
+ let health = 'good';
1562
+ if (docs.length < THIN_DOCS) health = 'thin';
1563
+ else if (redCount > 0) health = 'red';
1564
+ else if (staleCount > 0) health = 'warn';
1565
+
1566
+ domainsOut.push({
1567
+ id: d.id,
1568
+ name: d.name || d.id,
1569
+ displayName: d.displayName || null,
1570
+ description: d.description || null,
1571
+ path: domainPath,
1572
+ documentCount: docs.length,
1573
+ staleCount: staleCount,
1574
+ redCount: redCount,
1575
+ health: health,
1576
+ stalestDoc: stalest,
1577
+ freshestDoc: freshest,
1578
+ docs: docs.slice(0, 30), // cap per domain — full list not needed for audit view
1579
+ });
1580
+ }
1581
+
1582
+ return {
1583
+ generatedAt: new Date().toISOString(),
1584
+ available: true,
1585
+ thresholds: { staleWarnDays: STALE_WARN_DAYS, staleRedDays: STALE_RED_DAYS, thinDocs: THIN_DOCS },
1586
+ domains: domainsOut,
1587
+ stats: {
1588
+ totalDomains: domainsOut.length,
1589
+ totalDocuments: totalDocs,
1590
+ totalEdges: edges.length,
1591
+ totalBacklinks: totalBacklinks,
1592
+ },
1593
+ };
1594
+ }
1595
+
1596
+
1597
+ // -- Staging inbox -----------------------------------------------------
1598
+
1599
+ /**
1600
+ * Walk agents/<pa>/staging/*.md and return a flat list of items plus
1601
+ * per-agent aggregates. Filenames follow `{timestamp}_{category}.md`
1602
+ * (librarian-processed). Categories: preference, correction, decision,
1603
+ * pattern, contact, domain, feedback. Unknown suffixes bucket as "other".
1604
+ */
1605
+ function buildStaging(repoRoot) {
1606
+ const CATEGORIES = ['preference', 'correction', 'decision', 'pattern', 'contact', 'domain', 'feedback'];
1607
+ const agentsDir = join(repoRoot, 'agents');
1608
+ const items = [];
1609
+ const agents = [];
1610
+ if (!existsSync(agentsDir)) {
1611
+ return {
1612
+ generatedAt: new Date().toISOString(),
1613
+ agents: [],
1614
+ items: [],
1615
+ stats: { totalItems: 0, byCategory: {}, oldestAgeDays: null },
1616
+ };
1617
+ }
1618
+ const entries = readdirSync(agentsDir, { withFileTypes: true });
1619
+ const now = Date.now();
1620
+ for (const entry of entries) {
1621
+ if (!entry.isDirectory()) continue;
1622
+ const stagingDir = join(agentsDir, entry.name, 'staging');
1623
+ if (!existsSync(stagingDir)) continue;
1624
+ let fileEntries;
1625
+ try {
1626
+ fileEntries = readdirSync(stagingDir, { withFileTypes: true });
1627
+ } catch { continue; }
1628
+ const agentAgg = {
1629
+ name: entry.name,
1630
+ total: 0,
1631
+ byCategory: Object.create(null),
1632
+ oldestAgeDays: null,
1633
+ };
1634
+ for (const fe of fileEntries) {
1635
+ if (!fe.isFile()) continue;
1636
+ if (!fe.name.endsWith('.md')) continue;
1637
+ const full = join(stagingDir, fe.name);
1638
+ let st;
1639
+ try { st = statSync(full); } catch { continue; }
1640
+ // Parse filename: {timestamp}_{category}.md
1641
+ const base = fe.name.replace(/\.md$/, '');
1642
+ const idx = base.lastIndexOf('_');
1643
+ let category = 'other';
1644
+ let timestamp = null;
1645
+ if (idx !== -1) {
1646
+ const possibleCat = base.slice(idx + 1);
1647
+ if (CATEGORIES.indexOf(possibleCat) !== -1) {
1648
+ category = possibleCat;
1649
+ timestamp = base.slice(0, idx);
1650
+ }
1651
+ }
1652
+ // Preview: first non-empty line of the file (trimmed, first 160 chars).
1653
+ let preview = '';
1654
+ try {
1655
+ const content = readFileSync(full, 'utf-8');
1656
+ const lines = content.split(/\r?\n/).map(function(l) { return l.trim(); });
1657
+ preview = lines.find(function(l) { return l.length > 0; }) || '';
1658
+ // Strip a leading markdown heading marker for preview.
1659
+ preview = preview.replace(/^#{1,6}\s+/, '').slice(0, 160);
1660
+ } catch { /* non-fatal */ }
1661
+ const ageDays = Math.floor((now - st.mtimeMs) / (24 * 60 * 60 * 1000));
1662
+ const item = {
1663
+ agent: entry.name,
1664
+ name: fe.name,
1665
+ path: 'agents/' + entry.name + '/staging/' + fe.name,
1666
+ category: category,
1667
+ timestamp: timestamp,
1668
+ modified: new Date(st.mtimeMs).toISOString(),
1669
+ ageDays: ageDays,
1670
+ sizeBytes: st.size,
1671
+ preview: preview,
1672
+ };
1673
+ items.push(item);
1674
+ agentAgg.total++;
1675
+ agentAgg.byCategory[category] = (agentAgg.byCategory[category] || 0) + 1;
1676
+ if (agentAgg.oldestAgeDays == null || ageDays > agentAgg.oldestAgeDays) agentAgg.oldestAgeDays = ageDays;
1677
+ }
1678
+ agents.push(agentAgg);
1679
+ }
1680
+
1681
+ items.sort(function(a, b) { return b.modified.localeCompare(a.modified); });
1682
+
1683
+ const byCategory = {};
1684
+ let oldest = null;
1685
+ for (const it of items) {
1686
+ byCategory[it.category] = (byCategory[it.category] || 0) + 1;
1687
+ if (oldest == null || it.ageDays > oldest) oldest = it.ageDays;
1688
+ }
1689
+
1690
+ return {
1691
+ generatedAt: new Date().toISOString(),
1692
+ agents: agents,
1693
+ items: items,
1694
+ stats: {
1695
+ totalItems: items.length,
1696
+ byCategory: byCategory,
1697
+ oldestAgeDays: oldest,
1698
+ },
1699
+ };
1700
+ }
1701
+
1702
+ // -- Agenda / cron -----------------------------------------------------
1703
+
1704
+ /**
1705
+ * Build the schedules agenda for the next 7 days starting from `now`.
1706
+ *
1707
+ * Returns `{ today, tomorrow, thisWeek }` where each bucket is an array of
1708
+ * `{ id, name, cron, nextRunAt, description, command, host }` entries,
1709
+ * sorted by nextRunAt ascending. A single schedule may appear multiple times
1710
+ * within a bucket (e.g. an hourly job shows every run inside today). `host`
1711
+ * is the raw `schedules/{id}.json` host field (or null) — the client
1712
+ * normalises it for the host-filter chips; buildAgenda itself does not.
1713
+ *
1714
+ * Only the managed-schedule definitions at schedules/*.json are used.
1715
+ * Non-cron schedules (empty/invalid cron) are skipped.
1716
+ */
1717
+ function buildAgenda(schedules, now) {
1718
+ const today = [];
1719
+ const tomorrow = [];
1720
+ const thisWeek = [];
1721
+
1722
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
1723
+ const startOfTomorrow = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000);
1724
+ const startOfDayAfter = new Date(startOfToday.getTime() + 2 * 24 * 60 * 60 * 1000);
1725
+ const endOfWeek = new Date(startOfToday.getTime() + 7 * 24 * 60 * 60 * 1000);
1726
+
1727
+ for (const s of schedules || []) {
1728
+ if (s.enabled === false) continue;
1729
+ const cronExpr = s.schedule || s.cron;
1730
+ if (!cronExpr || typeof cronExpr !== 'string') continue;
1731
+
1732
+ let runs;
1733
+ try {
1734
+ runs = nextCronRuns(cronExpr, now, endOfWeek, 50);
1735
+ } catch {
1736
+ continue; // unparseable cron — skip rather than break the endpoint
1737
+ }
1738
+
1739
+ for (const runAt of runs) {
1740
+ const entry = {
1741
+ id: s.id || s._file || 'unknown',
1742
+ name: s.name || s.id || s._file || 'Unnamed',
1743
+ cron: cronExpr,
1744
+ nextRunAt: runAt.toISOString(),
1745
+ description: s.description || null,
1746
+ command: s.command || s.cmd || null,
1747
+ // Carried through so the client can filter the agenda by the same
1748
+ // host chip that filters the Active-schedules table, off ONE fetch
1749
+ // — a per-chip refetch would mean an extra round trip on every
1750
+ // click for data we already have server-side. `s.host` is a raw
1751
+ // pass-through (undefined -> null); the client owns normalisation
1752
+ // (short host vs FQDN) since that's purely a display/matching
1753
+ // concern, not something the server needs to resolve.
1754
+ host: s.host || null,
1755
+ };
1756
+ if (runAt < startOfTomorrow) {
1757
+ today.push(entry);
1758
+ } else if (runAt < startOfDayAfter) {
1759
+ tomorrow.push(entry);
1760
+ } else if (runAt < endOfWeek) {
1761
+ thisWeek.push(entry);
1762
+ }
1763
+ }
1764
+ }
1765
+
1766
+ function sortByRun(a, b) { return a.nextRunAt.localeCompare(b.nextRunAt); }
1767
+ today.sort(sortByRun);
1768
+ tomorrow.sort(sortByRun);
1769
+ thisWeek.sort(sortByRun);
1770
+
1771
+ return { today, tomorrow, thisWeek };
1772
+ }
1773
+
1774
+ /**
1775
+ * Compute the single next run time for one schedule, for the compact
1776
+ * Tab 1 list (as distinct from `buildAgenda`, which enumerates every
1777
+ * occurrence within a 7-day window for its bucketed view). Disabled
1778
+ * schedules and unparseable/missing cron expressions yield `null` rather
1779
+ * than throwing — a malformed schedule file must not break the list for
1780
+ * every other schedule.
1781
+ *
1782
+ * @returns {string|null} ISO timestamp, or null
1783
+ */
1784
+ function computeNextRunAt(schedule, now) {
1785
+ if (schedule.enabled === false) return null;
1786
+ const cronExpr = schedule.schedule || schedule.cron;
1787
+ if (!cronExpr || typeof cronExpr !== 'string') return null;
1788
+ try {
1789
+ // One year is a generous horizon — long enough to find a hit for any
1790
+ // valid cron expression (including annual-only fields), short enough
1791
+ // that the minute-by-minute walk's HARD_STEP_CAP is never the limiter
1792
+ // for a 5-field cron.
1793
+ const until = new Date(now.getTime() + 366 * 24 * 60 * 60 * 1000);
1794
+ const runs = nextCronRuns(cronExpr, now, until, 1);
1795
+ return runs.length ? runs[0].toISOString() : null;
1796
+ } catch {
1797
+ return null; // unparseable cron — degrade gracefully, same as buildAgenda
1798
+ }
1799
+ }
1800
+
1801
+ /**
1802
+ * Compute the next run times of a 5-field cron expression between `from`
1803
+ * (exclusive) and `until` (exclusive), capped at `max` entries.
1804
+ *
1805
+ * Grammar supported (per field):
1806
+ * * any value
1807
+ * a literal
1808
+ * a-b range, inclusive
1809
+ * a,b,c list
1810
+ * (* | a-b)/N step
1811
+ *
1812
+ * Throws on unparseable input.
1813
+ */
1814
+ export function nextCronRuns(cronExpr, from, until, max) {
1815
+ const parts = String(cronExpr).trim().split(/\s+/);
1816
+ if (parts.length !== 5) {
1817
+ throw new Error('cron expression must have 5 fields: minute hour dom month dow');
1818
+ }
1819
+ const [fMin, fHour, fDom, fMonth, fDow] = parts;
1820
+
1821
+ const mins = expandField(fMin, 0, 59);
1822
+ const hours = expandField(fHour, 0, 23);
1823
+ const doms = expandField(fDom, 1, 31);
1824
+ const months = expandField(fMonth, 1, 12);
1825
+ const dows = expandField(fDow, 0, 6); // 0 = Sunday
1826
+
1827
+ const domRestricted = fDom !== '*';
1828
+ const dowRestricted = fDow !== '*';
1829
+
1830
+ const results = [];
1831
+ let t = new Date(from.getTime());
1832
+ t.setSeconds(0, 0);
1833
+ t = new Date(t.getTime() + 60 * 1000);
1834
+
1835
+ // Safety bound: walk minute-by-minute with fast-forward on mismatches.
1836
+ const HARD_STEP_CAP = 20000;
1837
+ let steps = 0;
1838
+
1839
+ while (t < until && results.length < max && steps < HARD_STEP_CAP) {
1840
+ steps++;
1841
+ if (!months.has(t.getMonth() + 1)) {
1842
+ t = new Date(t.getFullYear(), t.getMonth() + 1, 1, 0, 0, 0, 0);
1843
+ continue;
1844
+ }
1845
+ const domOk = doms.has(t.getDate());
1846
+ const dowOk = dows.has(t.getDay());
1847
+ let dateOk;
1848
+ if (domRestricted && dowRestricted) dateOk = domOk || dowOk;
1849
+ else if (domRestricted) dateOk = domOk;
1850
+ else if (dowRestricted) dateOk = dowOk;
1851
+ else dateOk = true;
1852
+ if (!dateOk) {
1853
+ t = new Date(t.getFullYear(), t.getMonth(), t.getDate() + 1, 0, 0, 0, 0);
1854
+ continue;
1855
+ }
1856
+ if (!hours.has(t.getHours())) {
1857
+ t = new Date(t.getFullYear(), t.getMonth(), t.getDate(), t.getHours() + 1, 0, 0, 0);
1858
+ continue;
1859
+ }
1860
+ if (!mins.has(t.getMinutes())) {
1861
+ t = new Date(t.getTime() + 60 * 1000);
1862
+ continue;
1863
+ }
1864
+ results.push(new Date(t.getTime()));
1865
+ t = new Date(t.getTime() + 60 * 1000);
1866
+ }
1867
+ return results;
1868
+ }
1869
+
1870
+ function expandField(expr, lo, hi) {
1871
+ const result = new Set();
1872
+ const parts = expr.split(',');
1873
+ for (const part of parts) {
1874
+ let step = 1;
1875
+ let base = part;
1876
+ const slashIdx = part.indexOf('/');
1877
+ if (slashIdx !== -1) {
1878
+ base = part.slice(0, slashIdx);
1879
+ step = parseInt(part.slice(slashIdx + 1), 10);
1880
+ if (!Number.isFinite(step) || step < 1) throw new Error('invalid step: ' + part);
1881
+ }
1882
+ let rangeLo, rangeHi;
1883
+ if (base === '*') {
1884
+ rangeLo = lo; rangeHi = hi;
1885
+ } else if (base.indexOf('-') !== -1) {
1886
+ const [a, b] = base.split('-').map(function(s) { return parseInt(s, 10); });
1887
+ if (!Number.isFinite(a) || !Number.isFinite(b)) throw new Error('invalid range: ' + part);
1888
+ rangeLo = a; rangeHi = b;
1889
+ } else {
1890
+ const n = parseInt(base, 10);
1891
+ if (!Number.isFinite(n)) throw new Error('invalid value: ' + part);
1892
+ rangeLo = n; rangeHi = n;
1893
+ }
1894
+ if (rangeLo < lo || rangeHi > hi) throw new Error('out of range: ' + part);
1895
+ for (let i = rangeLo; i <= rangeHi; i += step) result.add(i);
1896
+ }
1897
+ return result;
1898
+ }
1899
+
1900
+
1901
+ // -- Next-session pickup ----------------------------------------------
1902
+
1903
+ /**
1904
+ * Build the /__api/next-session payload. Locates the active PA's
1905
+ * next-session.md under agents/{paName}/ (paName resolved from
1906
+ * operator/config/preferences.json; falls back to the only agent directory
1907
+ * that contains a next-session.md file; or to 'dave' for deployments that
1908
+ * predate preferences). Parses the canonical section shape:
1909
+ *
1910
+ * # Next session
1911
+ *
1912
+ * **Last updated:** 2026-04-17 (...)
1913
+ *
1914
+ * ## Top priority — start here
1915
+ * <paragraph>
1916
+ *
1917
+ * ## On boot
1918
+ * 1. step one
1919
+ * 2. step two
1920
+ *
1921
+ * Absent file or absent "Top priority" section → { present: false }.
1922
+ *
1923
+ * Shape:
1924
+ * {
1925
+ * present: boolean,
1926
+ * agent: string | null, // e.g. 'dave'
1927
+ * path: string | null, // repo-relative
1928
+ * lastUpdated: string | null, // free text as written
1929
+ * topPriority: { // populated iff the section was found
1930
+ * title: string, // first bold/em phrase of the paragraph, or
1931
+ * // the first sentence if no emphasis
1932
+ * body: string, // full paragraph, markdown-stripped enough
1933
+ * // for prose rendering (bold → **..**, kept)
1934
+ * raw: string, // the paragraph as written
1935
+ * } | null,
1936
+ * onBoot: string[] | null, // ordered list items, markdown-stripped
1937
+ * raw: string, // the full file contents
1938
+ * }
1939
+ */
1940
+ export function buildNextSession(repoRoot) {
1941
+ // Resolve the PA name. Preference order:
1942
+ // 1. operator/config/preferences.json → paName
1943
+ // 2. The single agents/* directory that contains next-session.md
1944
+ // 3. 'dave' (canonical default for legacy deployments)
1945
+ let paName = null;
1946
+ try {
1947
+ const prefsFile = join(repoRoot, 'operator', 'config', 'preferences.json');
1948
+ if (existsSync(prefsFile)) {
1949
+ const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
1950
+ if (prefs && typeof prefs.paName === 'string' && prefs.paName.length) {
1951
+ paName = prefs.paName;
1952
+ }
1953
+ }
1954
+ } catch { /* non-fatal */ }
1955
+
1956
+ if (!paName) {
1957
+ const agentsDir = join(repoRoot, 'agents');
1958
+ if (existsSync(agentsDir)) {
1959
+ let candidates;
1960
+ try { candidates = readdirSync(agentsDir, { withFileTypes: true }); } catch { candidates = []; }
1961
+ const hits = [];
1962
+ for (const c of candidates) {
1963
+ if (!c.isDirectory()) continue;
1964
+ const nsFile = join(agentsDir, c.name, 'next-session.md');
1965
+ if (existsSync(nsFile)) hits.push(c.name);
1966
+ }
1967
+ if (hits.length === 1) paName = hits[0];
1968
+ else if (hits.length > 1 && hits.indexOf('dave') !== -1) paName = 'dave';
1969
+ }
1970
+ }
1971
+ if (!paName) paName = 'dave';
1972
+
1973
+ const nsRel = 'agents/' + paName + '/next-session.md';
1974
+ const nsPath = join(repoRoot, 'agents', paName, 'next-session.md');
1975
+ if (!existsSync(nsPath)) {
1976
+ return {
1977
+ present: false,
1978
+ agent: paName,
1979
+ path: null,
1980
+ lastUpdated: null,
1981
+ topPriority: null,
1982
+ onBoot: null,
1983
+ raw: '',
1984
+ };
1985
+ }
1986
+
1987
+ let raw = '';
1988
+ try { raw = readFileSync(nsPath, 'utf-8'); } catch (err) {
1989
+ return {
1990
+ present: false,
1991
+ agent: paName,
1992
+ path: nsRel,
1993
+ lastUpdated: null,
1994
+ topPriority: null,
1995
+ onBoot: null,
1996
+ raw: '',
1997
+ error: err.message,
1998
+ };
1999
+ }
2000
+
2001
+ const lastUpdated = parseNextSessionLastUpdated(raw);
2002
+ const topPriority = parseNextSessionTopPriority(raw);
2003
+ const onBoot = parseNextSessionOnBoot(raw);
2004
+
2005
+ return {
2006
+ present: true,
2007
+ agent: paName,
2008
+ path: nsRel,
2009
+ lastUpdated: lastUpdated,
2010
+ topPriority: topPriority,
2011
+ onBoot: onBoot,
2012
+ raw: raw,
2013
+ };
2014
+ }
2015
+
2016
+ // Extracts the first `**Last updated:**` marker text. Returns the trimmed
2017
+ // phrase after the marker or null if absent.
2018
+ export function parseNextSessionLastUpdated(raw) {
2019
+ const m = /\*\*Last updated:\*\*\s*(.+)/i.exec(raw);
2020
+ if (!m) return null;
2021
+ return m[1].trim().replace(/\s+$/, '') || null;
2022
+ }
2023
+
2024
+ // Extracts the paragraph under "## Top priority — start here" (em dash or
2025
+ // hyphen tolerated). Returns null if the section is absent.
2026
+ export function parseNextSessionTopPriority(raw) {
2027
+ // Match either the em-dash (—) or a plain hyphen; case-insensitive on the
2028
+ // phrasing. We intentionally accept trailing whitespace / varied spacing.
2029
+ const re = /^##\s+Top priority\s*[—-]\s*start here\s*$/im;
2030
+ const match = re.exec(raw);
2031
+ if (!match) return null;
2032
+ const after = raw.slice(match.index + match[0].length);
2033
+ // Take content up to the next heading (## or #).
2034
+ const endMatch = /^#{1,6}\s+/m.exec(after);
2035
+ const block = endMatch ? after.slice(0, endMatch.index) : after;
2036
+ const paragraph = block.trim();
2037
+ if (!paragraph) return null;
2038
+
2039
+ // Derive a short title: the first bold phrase (between double-asterisks),
2040
+ // or the first sentence up to ~80 chars if no bold lead exists.
2041
+ const boldMatch = /\*\*([^*]+)\*\*/.exec(paragraph);
2042
+ let title;
2043
+ if (boldMatch) {
2044
+ title = boldMatch[1].trim();
2045
+ } else {
2046
+ const firstSentence = paragraph.split(/(?<=[.!?])\s+/)[0];
2047
+ title = firstSentence.length > 90 ? firstSentence.slice(0, 87) + '…' : firstSentence;
2048
+ }
2049
+ // Body — strip leading/trailing whitespace; keep markdown for downstream
2050
+ // prose rendering. The caller decides how to display it.
2051
+ return {
2052
+ title: title,
2053
+ body: paragraph,
2054
+ raw: paragraph,
2055
+ };
2056
+ }
2057
+
2058
+ // Extracts the numbered/bulleted list under "## On boot". Returns an array of
2059
+ // plain-text steps (markdown stripped lightly). Null if section is absent.
2060
+ export function parseNextSessionOnBoot(raw) {
2061
+ const re = /^##\s+On boot\s*$/im;
2062
+ const match = re.exec(raw);
2063
+ if (!match) return null;
2064
+ const after = raw.slice(match.index + match[0].length);
2065
+ const endMatch = /^#{1,6}\s+/m.exec(after);
2066
+ const block = (endMatch ? after.slice(0, endMatch.index) : after).trim();
2067
+ if (!block) return null;
2068
+ const lines = block.split(/\r?\n/);
2069
+ const out = [];
2070
+ for (const line of lines) {
2071
+ const trimmed = line.trim();
2072
+ if (!trimmed) continue;
2073
+ // Accept numbered (1. 2.) or dashed (- *) list items.
2074
+ const m = /^(?:\d+\.|[-*])\s+(.+)$/.exec(trimmed);
2075
+ if (!m) continue;
2076
+ // Light markdown strip: unwrap inline code, bold, italic, links.
2077
+ let text = m[1]
2078
+ .replace(/`([^`]+)`/g, '$1')
2079
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
2080
+ .replace(/\*([^*]+)\*/g, '$1')
2081
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
2082
+ out.push(text.trim());
2083
+ }
2084
+ return out.length ? out : null;
2085
+ }
2086
+