@repo-os/repoos 0.5.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +110 -0
- package/README.md +272 -0
- package/dist/.build-info.json +4 -0
- package/dist/.build-stamp.json +3 -0
- package/dist/cli/colors.d.ts +13 -0
- package/dist/cli/colors.js +37 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +221 -0
- package/dist/commands/check.d.ts +170 -0
- package/dist/commands/check.js +1227 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +70 -0
- package/dist/commands/gc.d.ts +1 -0
- package/dist/commands/gc.js +57 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +699 -0
- package/dist/commands/screenshots.d.ts +7 -0
- package/dist/commands/screenshots.js +77 -0
- package/dist/commands/serve.d.ts +55 -0
- package/dist/commands/serve.js +204 -0
- package/dist/commands/status.d.ts +140 -0
- package/dist/commands/status.js +569 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/tasks.d.ts +47 -0
- package/dist/commands/tasks.js +334 -0
- package/dist/commands/tunnel.d.ts +1 -0
- package/dist/commands/tunnel.js +1271 -0
- package/dist/commands/ui-harness.d.ts +53 -0
- package/dist/commands/ui-harness.js +62 -0
- package/dist/commands/ui-smoke.d.ts +6 -0
- package/dist/commands/ui-smoke.js +220 -0
- package/dist/commands/upgrade.d.ts +15 -0
- package/dist/commands/upgrade.js +188 -0
- package/dist/core/auth-store.d.ts +81 -0
- package/dist/core/auth-store.js +530 -0
- package/dist/core/auth.d.ts +75 -0
- package/dist/core/auth.js +124 -0
- package/dist/core/bootstrap.d.ts +41 -0
- package/dist/core/bootstrap.js +236 -0
- package/dist/core/build.d.ts +92 -0
- package/dist/core/build.js +249 -0
- package/dist/core/canary.d.ts +10 -0
- package/dist/core/canary.js +14 -0
- package/dist/core/check-failure-summary.d.ts +10 -0
- package/dist/core/check-failure-summary.js +34 -0
- package/dist/core/config.d.ts +160 -0
- package/dist/core/config.js +1288 -0
- package/dist/core/context-pack.d.ts +74 -0
- package/dist/core/context-pack.js +778 -0
- package/dist/core/db.d.ts +236 -0
- package/dist/core/db.js +747 -0
- package/dist/core/detect.d.ts +69 -0
- package/dist/core/detect.js +251 -0
- package/dist/core/docs.d.ts +76 -0
- package/dist/core/docs.js +225 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +215 -0
- package/dist/core/git.d.ts +505 -0
- package/dist/core/git.js +1453 -0
- package/dist/core/indexer.d.ts +33 -0
- package/dist/core/indexer.js +282 -0
- package/dist/core/input.d.ts +66 -0
- package/dist/core/input.js +222 -0
- package/dist/core/logger.d.ts +70 -0
- package/dist/core/logger.js +138 -0
- package/dist/core/models.d.ts +45 -0
- package/dist/core/models.js +291 -0
- package/dist/core/net-probe.d.ts +4 -0
- package/dist/core/net-probe.js +20 -0
- package/dist/core/providers/deepinfra.d.ts +23 -0
- package/dist/core/providers/deepinfra.js +44 -0
- package/dist/core/providers/index.d.ts +24 -0
- package/dist/core/providers/index.js +59 -0
- package/dist/core/providers/openrouter.d.ts +28 -0
- package/dist/core/providers/openrouter.js +82 -0
- package/dist/core/providers/spend.d.ts +86 -0
- package/dist/core/providers/spend.js +286 -0
- package/dist/core/providers/types.d.ts +56 -0
- package/dist/core/providers/types.js +39 -0
- package/dist/core/repoos.d.ts +59 -0
- package/dist/core/repoos.js +221 -0
- package/dist/core/runtime.d.ts +35 -0
- package/dist/core/runtime.js +167 -0
- package/dist/core/skills-registry.d.ts +39 -0
- package/dist/core/skills-registry.js +148 -0
- package/dist/core/skills-registry.test.d.ts +1 -0
- package/dist/core/skills-registry.test.js +52 -0
- package/dist/core/sysmem.d.ts +7 -0
- package/dist/core/sysmem.js +55 -0
- package/dist/core/tailscale.d.ts +2 -0
- package/dist/core/tailscale.js +19 -0
- package/dist/core/task.d.ts +53 -0
- package/dist/core/task.js +310 -0
- package/dist/core/toml-line.d.ts +20 -0
- package/dist/core/toml-line.js +52 -0
- package/dist/core/toml-validate.d.ts +44 -0
- package/dist/core/toml-validate.js +335 -0
- package/dist/core/tunnel-assistant.d.ts +30 -0
- package/dist/core/tunnel-assistant.js +65 -0
- package/dist/core/tunnel-registry.d.ts +96 -0
- package/dist/core/tunnel-registry.js +267 -0
- package/dist/core/tunnel-registry.test.d.ts +1 -0
- package/dist/core/tunnel-registry.test.js +309 -0
- package/dist/core/tunnel.d.ts +103 -0
- package/dist/core/tunnel.js +320 -0
- package/dist/core/types.d.ts +811 -0
- package/dist/core/types.js +27 -0
- package/dist/core/worktree-gc.d.ts +57 -0
- package/dist/core/worktree-gc.js +194 -0
- package/dist/server/__tests__/integration-job.test.d.ts +1 -0
- package/dist/server/__tests__/integration-job.test.js +129 -0
- package/dist/server/agents.d.ts +1020 -0
- package/dist/server/agents.js +4070 -0
- package/dist/server/attachments.d.ts +51 -0
- package/dist/server/attachments.js +130 -0
- package/dist/server/auto-engineering.d.ts +45 -0
- package/dist/server/auto-engineering.js +275 -0
- package/dist/server/branchless-release.d.ts +22 -0
- package/dist/server/branchless-release.js +67 -0
- package/dist/server/built-in-agents.d.ts +305 -0
- package/dist/server/built-in-agents.js +1838 -0
- package/dist/server/cto-monitor.d.ts +35 -0
- package/dist/server/cto-monitor.js +281 -0
- package/dist/server/cto.d.ts +125 -0
- package/dist/server/cto.js +590 -0
- package/dist/server/deployments.d.ts +119 -0
- package/dist/server/deployments.js +325 -0
- package/dist/server/diff-snapshot.d.ts +20 -0
- package/dist/server/diff-snapshot.js +73 -0
- package/dist/server/done.d.ts +160 -0
- package/dist/server/done.js +488 -0
- package/dist/server/freeform.d.ts +55 -0
- package/dist/server/freeform.js +179 -0
- package/dist/server/handoff.d.ts +99 -0
- package/dist/server/handoff.js +575 -0
- package/dist/server/hetzner.d.ts +45 -0
- package/dist/server/hetzner.js +117 -0
- package/dist/server/icons.d.ts +8 -0
- package/dist/server/icons.js +195 -0
- package/dist/server/integration-job.d.ts +75 -0
- package/dist/server/integration-job.js +144 -0
- package/dist/server/integration-orchestrator.d.ts +211 -0
- package/dist/server/integration-orchestrator.js +1299 -0
- package/dist/server/integration-status.d.ts +48 -0
- package/dist/server/integration-status.js +71 -0
- package/dist/server/live-index.d.ts +295 -0
- package/dist/server/live-index.js +452 -0
- package/dist/server/model-test.d.ts +20 -0
- package/dist/server/model-test.js +100 -0
- package/dist/server/ntfy.d.ts +57 -0
- package/dist/server/ntfy.js +112 -0
- package/dist/server/pm-attachments.d.ts +35 -0
- package/dist/server/pm-attachments.js +181 -0
- package/dist/server/pm-runs.d.ts +58 -0
- package/dist/server/pm-runs.js +80 -0
- package/dist/server/preview.d.ts +174 -0
- package/dist/server/preview.js +641 -0
- package/dist/server/release.d.ts +88 -0
- package/dist/server/release.js +449 -0
- package/dist/server/reload.d.ts +194 -0
- package/dist/server/reload.js +586 -0
- package/dist/server/remote-validation.d.ts +132 -0
- package/dist/server/remote-validation.js +478 -0
- package/dist/server/repo-lock.d.ts +36 -0
- package/dist/server/repo-lock.js +141 -0
- package/dist/server/review-guard.d.ts +14 -0
- package/dist/server/review-guard.js +159 -0
- package/dist/server/review.d.ts +243 -0
- package/dist/server/review.js +1211 -0
- package/dist/server/routes/agents.d.ts +6 -0
- package/dist/server/routes/agents.js +28 -0
- package/dist/server/routes/auth.d.ts +60 -0
- package/dist/server/routes/auth.js +755 -0
- package/dist/server/routes/config.d.ts +20 -0
- package/dist/server/routes/config.js +347 -0
- package/dist/server/routes/debugger.d.ts +33 -0
- package/dist/server/routes/debugger.js +360 -0
- package/dist/server/routes/deployments.d.ts +3 -0
- package/dist/server/routes/deployments.js +16 -0
- package/dist/server/routes/docs.d.ts +8 -0
- package/dist/server/routes/docs.js +135 -0
- package/dist/server/routes/helpers.d.ts +21 -0
- package/dist/server/routes/helpers.js +171 -0
- package/dist/server/routes/index.d.ts +19 -0
- package/dist/server/routes/index.js +18 -0
- package/dist/server/routes/info.d.ts +17 -0
- package/dist/server/routes/info.js +177 -0
- package/dist/server/routes/inputs.d.ts +7 -0
- package/dist/server/routes/inputs.js +125 -0
- package/dist/server/routes/model-providers.d.ts +27 -0
- package/dist/server/routes/model-providers.js +94 -0
- package/dist/server/routes/models.d.ts +3 -0
- package/dist/server/routes/models.js +36 -0
- package/dist/server/routes/notify.d.ts +2 -0
- package/dist/server/routes/notify.js +17 -0
- package/dist/server/routes/playground.d.ts +29 -0
- package/dist/server/routes/playground.js +81 -0
- package/dist/server/routes/release.d.ts +20 -0
- package/dist/server/routes/release.js +86 -0
- package/dist/server/routes/router.d.ts +12 -0
- package/dist/server/routes/router.js +32 -0
- package/dist/server/routes/skill-registry.d.ts +5 -0
- package/dist/server/routes/skill-registry.js +51 -0
- package/dist/server/routes/tasks.d.ts +58 -0
- package/dist/server/routes/tasks.js +1442 -0
- package/dist/server/routes/transcribe.d.ts +2 -0
- package/dist/server/routes/transcribe.js +124 -0
- package/dist/server/routes/types.d.ts +68 -0
- package/dist/server/routes/types.js +1 -0
- package/dist/server/routes/ui.d.ts +5 -0
- package/dist/server/routes/ui.js +81 -0
- package/dist/server/routes/utils.d.ts +3 -0
- package/dist/server/routes/utils.js +23 -0
- package/dist/server/serve-reaper.d.ts +135 -0
- package/dist/server/serve-reaper.js +491 -0
- package/dist/server/server.d.ts +49 -0
- package/dist/server/server.js +2171 -0
- package/dist/server/supervisor.d.ts +89 -0
- package/dist/server/supervisor.js +231 -0
- package/dist/server/system.d.ts +198 -0
- package/dist/server/system.js +480 -0
- package/dist/server/task-check.d.ts +45 -0
- package/dist/server/task-check.js +71 -0
- package/dist/server/task-transitions.d.ts +51 -0
- package/dist/server/task-transitions.js +78 -0
- package/dist/server/task-watchdog.d.ts +158 -0
- package/dist/server/task-watchdog.js +681 -0
- package/dist/server/test-run.d.ts +28 -0
- package/dist/server/test-run.js +81 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +194 -0
- package/dist/server/write.d.ts +94 -0
- package/dist/server/write.js +345 -0
- package/dist/ui/assets/AgentsView-CF4wRvHh.css +1 -0
- package/dist/ui/assets/AgentsView-D-myL-kA.js +1 -0
- package/dist/ui/assets/ContextView-D3TC-RSp.css +1 -0
- package/dist/ui/assets/ContextView-DsOqAdyE.js +4 -0
- package/dist/ui/assets/DashboardView-BOhsIJqO.js +1 -0
- package/dist/ui/assets/DashboardView-Ck9o0RP2.css +1 -0
- package/dist/ui/assets/DeploymentsView--iIUVfEH.css +1 -0
- package/dist/ui/assets/DeploymentsView-DXu_FYR2.js +1 -0
- package/dist/ui/assets/InputsView-24HVbVPk.js +1 -0
- package/dist/ui/assets/InputsView-BLb7GHtx.css +1 -0
- package/dist/ui/assets/LoginView-6QlGg6cx.css +1 -0
- package/dist/ui/assets/LoginView-BqupI0Lt.js +1 -0
- package/dist/ui/assets/ReleasesView-BEG1A0YH.css +1 -0
- package/dist/ui/assets/ReleasesView-CAgMuxpe.js +3 -0
- package/dist/ui/assets/SettingsView-CA-NYSMK.css +1 -0
- package/dist/ui/assets/SettingsView-O3iZSnjg.js +3 -0
- package/dist/ui/assets/WorkView-D8Tl6qwy.js +1 -0
- package/dist/ui/assets/WorkView-DMYL2s3W.css +1 -0
- package/dist/ui/assets/abnfDiagram-VCTEODGH-J5lkSnEE.js +1 -0
- package/dist/ui/assets/arc-BHZnmhhM.js +1 -0
- package/dist/ui/assets/architecture-7GRP2DOG-pLdFt-xS.js +1 -0
- package/dist/ui/assets/architectureDiagram-5GKGNRK7-C3k89uKX.js +36 -0
- package/dist/ui/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/assets/blockDiagram-I7D4REHJ-AWNoBzop.js +129 -0
- package/dist/ui/assets/button-lbikqSph.js +1 -0
- package/dist/ui/assets/c4Diagram-7LVT6UL2-DKG1D5gd.js +38 -0
- package/dist/ui/assets/channel-gA4qBv3A.js +1 -0
- package/dist/ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/assets/chunk-4HAMMTFA-D1YR8lF_.js +62 -0
- package/dist/ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/assets/chunk-75Z2AOVW-5GuBxfsL.js +2 -0
- package/dist/ui/assets/chunk-DU6HZSFF-DfdNNqwa.js +127 -0
- package/dist/ui/assets/chunk-F27PBJKO-BPZzinQ4.js +1 -0
- package/dist/ui/assets/chunk-FOHPRMQF-l2z4K2jD.js +161 -0
- package/dist/ui/assets/chunk-GMAD6QVW-DGWvSKj5.js +72 -0
- package/dist/ui/assets/chunk-GVQU2GXP-D6Aw75wJ.js +1 -0
- package/dist/ui/assets/chunk-IMKFNOWR-B8mMBKCW.js +231 -0
- package/dist/ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/assets/chunk-L3NEJ4N5-D5OJuXYE.js +1 -0
- package/dist/ui/assets/chunk-OSK3NFVY-B7r9w2UW.js +10 -0
- package/dist/ui/assets/chunk-P2QGCYS3-C74X4AQX.js +1 -0
- package/dist/ui/assets/chunk-POPQ4Y6H-C5_u1gUS.js +1 -0
- package/dist/ui/assets/chunk-PWAF6VOD-DfoTLtxn.js +1 -0
- package/dist/ui/assets/chunk-SHT3W25Y-D40m2eqz.js +168 -0
- package/dist/ui/assets/chunk-SVP7TREG-euxi0Kap.js +88 -0
- package/dist/ui/assets/chunk-TICWLB2K-fkdxngzN.js +206 -0
- package/dist/ui/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- package/dist/ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/assets/classDiagram-ZZMXUADV-BzW8bq2P.js +1 -0
- package/dist/ui/assets/classDiagram-v2-VYDZK3BY-BzW8bq2P.js +1 -0
- package/dist/ui/assets/cose-bilkent-JH36ORCC-BTcgMon1.js +1 -0
- package/dist/ui/assets/cynefin-OW5HDTMX-B5m_COsS.js +1 -0
- package/dist/ui/assets/cynefinDiagram-5FMLGOSQ-C-AG6CGe.js +62 -0
- package/dist/ui/assets/cytoscape.esm-FBntH-9t.js +321 -0
- package/dist/ui/assets/dagre-GXQ25YYZ-DDfDnp8L.js +4 -0
- package/dist/ui/assets/dagre-PrKaheQc.js +1 -0
- package/dist/ui/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/assets/diagram-S7CK7UJ4-DjQZAlEY.js +30 -0
- package/dist/ui/assets/diagram-UQ7AKVKN-DAZ9yp1B.js +41 -0
- package/dist/ui/assets/diagram-VSXAHHWV-BWepUKJD.js +3 -0
- package/dist/ui/assets/diagram-VX7I27RA-EcV1J81b.js +24 -0
- package/dist/ui/assets/diagram-Z3DM3KII-Dbz7XU_K.js +24 -0
- package/dist/ui/assets/dist-DTg6UBE_.js +1 -0
- package/dist/ui/assets/ebnfDiagram-PWID7BFC-BUyQw8Mm.js +1 -0
- package/dist/ui/assets/erDiagram-RLTQ6QDP-WVf8ukKt.js +99 -0
- package/dist/ui/assets/eventmodeling-NTZA5JFV-DHqyLOAn.js +1 -0
- package/dist/ui/assets/flowDiagram-HODETNUW-CYk_ze5K.js +1 -0
- package/dist/ui/assets/ganttDiagram-EL5Y4UJY-rB4MZuB-.js +292 -0
- package/dist/ui/assets/gitGraph-4MIJSDKK-BOTWM8dY.js +1 -0
- package/dist/ui/assets/gitGraphDiagram-WWUBYQGX-eYscuhtP.js +106 -0
- package/dist/ui/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/assets/index-BACBfnga.css +1 -0
- package/dist/ui/assets/index-BOQ-ust6.js +26 -0
- package/dist/ui/assets/info-A6RAGUB7-_67wOORe.js +1 -0
- package/dist/ui/assets/infoDiagram-27XIBGKW-XVXbddTA.js +2 -0
- package/dist/ui/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/assets/ishikawaDiagram-5VMMS53U-ClLNiEru.js +70 -0
- package/dist/ui/assets/journeyDiagram-3NMN7TZE-NRawjR6i.js +139 -0
- package/dist/ui/assets/kanban-definition-UXKFOSKX-BgSzBfIx.js +89 -0
- package/dist/ui/assets/katex-DolUETbr.js +257 -0
- package/dist/ui/assets/line-KAyfuul4.js +1 -0
- package/dist/ui/assets/linear-cCCPOpu7.js +1 -0
- package/dist/ui/assets/mermaid-parser.core-CgXZk1iN.js +7 -0
- package/dist/ui/assets/mermaid.core-BUUkChHW.js +44 -0
- package/dist/ui/assets/mindmap-definition-YA3MSWOX-Cpn7vW4r.js +96 -0
- package/dist/ui/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/assets/packet-AYTQ26CC-0lZwM-d6.js +1 -0
- package/dist/ui/assets/path-COt_16Va.js +1 -0
- package/dist/ui/assets/pegDiagram-XKGWAZYB-impy0inn.js +1 -0
- package/dist/ui/assets/pie-WAS4IAKB-CqfdGHYg.js +1 -0
- package/dist/ui/assets/pieDiagram-E7YTZNPT-CxjL4I1h.js +39 -0
- package/dist/ui/assets/quadrantDiagram-AXDQQJYC-Cjd1QMIe.js +7 -0
- package/dist/ui/assets/radar-RG4KPBEZ-CRkQIwiv.js +1 -0
- package/dist/ui/assets/railroad-74A4TZTK-i67XSWTB.js +1 -0
- package/dist/ui/assets/railroad-abnf-HS5TGJTU-Cv9m5aS3.js +1 -0
- package/dist/ui/assets/railroad-ebnf-LZEXJU2U-C5ZIn-Jh.js +1 -0
- package/dist/ui/assets/railroad-peg-WCYAUIDC-CGra9Oq-.js +1 -0
- package/dist/ui/assets/railroadDiagram-O6MQD6OU-_pK0qyaY.js +1 -0
- package/dist/ui/assets/releases-B0WNV-SY.js +1 -0
- package/dist/ui/assets/repoos-cto-square.webp +0 -0
- package/dist/ui/assets/repoos-helpful-pm-square.webp +0 -0
- package/dist/ui/assets/repoos-orchestrator-square.webp +0 -0
- package/dist/ui/assets/repoos-performance-agent-square.webp +0 -0
- package/dist/ui/assets/repoos-ross-from-friends-square.webp +0 -0
- package/dist/ui/assets/repoos-tech-debt-agent-square.webp +0 -0
- package/dist/ui/assets/requirementDiagram-BXWQKSXE-CRrFX-yw.js +84 -0
- package/dist/ui/assets/rough.esm-By172zw-.js +1 -0
- package/dist/ui/assets/sankeyDiagram-P5KCCOFB-X_xFRj9E.js +40 -0
- package/dist/ui/assets/sequenceDiagram-WJ2MYXX4-Byb49lKf.js +162 -0
- package/dist/ui/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/dist/ui/assets/src-BH-TyZbA.js +1 -0
- package/dist/ui/assets/stateDiagram-D77RDMKH-CzaPrqYF.js +1 -0
- package/dist/ui/assets/stateDiagram-v2-MP3YSRHH-BCXckz7K.js +1 -0
- package/dist/ui/assets/swimlanes-42K2YHIH-Cx_1lnqr.js +1 -0
- package/dist/ui/assets/swimlanesDiagram-VR7AAH4N-54qsKi6A.js +8 -0
- package/dist/ui/assets/timeline-definition-24CTP7MA-B1IbZPQ-.js +120 -0
- package/dist/ui/assets/treeView-Q6P3EWNA-DFWhVE3d.js +1 -0
- package/dist/ui/assets/treemap-WGGIJYW6-BmbYPThn.js +1 -0
- package/dist/ui/assets/vendor-ui-DPm7zLxi.js +3 -0
- package/dist/ui/assets/vendor-vue-CobgyEJp.js +1 -0
- package/dist/ui/assets/vennDiagram-4TSXK5OY-Ae2AtRpt.js +34 -0
- package/dist/ui/assets/wardley-WFR3VGLG-BtD4X4tL.js +1 -0
- package/dist/ui/assets/wardleyDiagram-VM6X3IG4-B3YN2fTd.js +78 -0
- package/dist/ui/assets/xychartDiagram-S5SC5T6Z-CIzJfMKA.js +7 -0
- package/dist/ui/favicon.svg +11 -0
- package/dist/ui/index.html +49 -0
- package/dist/ui/sw.js +48 -0
- package/package.json +91 -0
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config resolution. Zero-config by default; `repoos.toml` at the repo root
|
|
3
|
+
* can override any field. We parse only the flat subset of TOML we need, again
|
|
4
|
+
* to avoid a runtime dependency.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from "node:fs";
|
|
7
|
+
import { cpus } from "node:os";
|
|
8
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
9
|
+
import { STATUSES } from "./types.js";
|
|
10
|
+
import { stripTomlComment, unquoteTomlString } from "./toml-line.js";
|
|
11
|
+
/** Coding agents an Agent can run under. */
|
|
12
|
+
export const AGENT_CLIS = [
|
|
13
|
+
"opencode",
|
|
14
|
+
"claude code",
|
|
15
|
+
"qwen code",
|
|
16
|
+
"kiro",
|
|
17
|
+
"codex",
|
|
18
|
+
"github copilot",
|
|
19
|
+
];
|
|
20
|
+
/** Models an Agent can pin (or "default" for the coding agent's default). */
|
|
21
|
+
export const AGENT_MODELS = ["default", "big pickle", "deepseek v4"];
|
|
22
|
+
/** Built-in agents, seeded at runtime when the config has none. */
|
|
23
|
+
export const DEFAULT_AGENTS = [
|
|
24
|
+
{
|
|
25
|
+
name: "Ross",
|
|
26
|
+
cli: "opencode",
|
|
27
|
+
model: "big pickle",
|
|
28
|
+
enabled: true,
|
|
29
|
+
instructions: "You are Ross, a repository-aware assistant inspired by Ross Geller from Friends — warm, enthusiastic, and genuinely curious about code and context. You answer questions about this repository, RepoOS, tasks, statuses, issues, and code with clear, helpful explanations and occasional dry wit. Never edit files, change task state, or modify the repository.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: "engineer",
|
|
33
|
+
cli: "opencode",
|
|
34
|
+
model: "big pickle",
|
|
35
|
+
enabled: true,
|
|
36
|
+
instructions: "Implements tasks: reads the task file, writes clean code, runs `repoos check`, updates the task status.",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: "reviewer",
|
|
40
|
+
cli: "opencode",
|
|
41
|
+
model: "big pickle",
|
|
42
|
+
enabled: true,
|
|
43
|
+
instructions: "Reviews a task the moment it lands in `review`: reads the diff in the task's worktree and reports bugs, edge cases, and suggestions for the human signing off. Never changes a task's status.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "pm",
|
|
47
|
+
cli: "opencode",
|
|
48
|
+
model: "big pickle",
|
|
49
|
+
enabled: true,
|
|
50
|
+
instructions: "Owns the roadmap: moves tasks between statuses, writes activity entries, keeps the work board tidy.",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: "cto",
|
|
54
|
+
cli: "opencode",
|
|
55
|
+
model: "big pickle",
|
|
56
|
+
enabled: false,
|
|
57
|
+
instructions: "You are the CTO: an always-on board monitor and un-sticker. Watch for stuck tasks, stale reviews, zombie processes, and broken builds. Report what you find, escalate when needed, and ask the human before taking action. Never move a task to done, merge branches, delete worktrees, change config, or spend money.",
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
/** Default agent names — these are seeded and cannot be removed. */
|
|
61
|
+
export const DEFAULT_AGENT_NAMES = DEFAULT_AGENTS.map((a) => a.name);
|
|
62
|
+
const REPO_GUIDE_NAME = "Ross";
|
|
63
|
+
const REPO_GUIDE_LEGACY_NAME = "RepoOS Guide";
|
|
64
|
+
/**
|
|
65
|
+
* Add Ross to an existing stored agent list without replacing user edits.
|
|
66
|
+
* When the stored config has the legacy "RepoOS Guide" name, rename it to "Ross"
|
|
67
|
+
* so the chat continues working. The other defaults deliberately are not
|
|
68
|
+
* re-seeded: a user may have removed one of those roles from an older configuration.
|
|
69
|
+
*/
|
|
70
|
+
export function agentsForConfig(config) {
|
|
71
|
+
const stored = Array.isArray(config.agents) ? config.agents : [];
|
|
72
|
+
if (!stored.length)
|
|
73
|
+
return DEFAULT_AGENTS.map((agent) => ({ ...agent }));
|
|
74
|
+
// Migrate legacy "RepoOS Guide" name to "Ross", preserving all other fields
|
|
75
|
+
const migrated = stored.map((agent) => agent.name.toLowerCase() === REPO_GUIDE_LEGACY_NAME.toLowerCase()
|
|
76
|
+
? { ...agent, name: REPO_GUIDE_NAME }
|
|
77
|
+
: agent);
|
|
78
|
+
const names = new Set(migrated.map((agent) => agent.name.toLowerCase()));
|
|
79
|
+
const guide = DEFAULT_AGENTS.find((agent) => agent.name === REPO_GUIDE_NAME);
|
|
80
|
+
return [
|
|
81
|
+
...migrated.map((agent) => ({ ...agent })),
|
|
82
|
+
...(guide && !names.has(REPO_GUIDE_NAME.toLowerCase()) ? [{ ...guide }] : []),
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* "Auto" default for `maxConcurrentAgents`. An agent's own test pool
|
|
87
|
+
* is capped separately (vite.config.ts `test.poolOptions.forks.maxForks: 2`),
|
|
88
|
+
* so one agent's worst-case footprint is bounded rather than "the whole
|
|
89
|
+
* machine" — this can size off total cores directly instead of dividing them
|
|
90
|
+
* away defensively. `cores / 2` leaves headroom for that ~2-worker pool plus
|
|
91
|
+
* the agent process's own overhead per concurrent agent; capped at 8 so a
|
|
92
|
+
* many-core desktop doesn't queue dozens of agents whose non-test work (tool
|
|
93
|
+
* calls, I/O) still contends over shared resources like the git index.
|
|
94
|
+
*/
|
|
95
|
+
export function defaultMaxConcurrentAgents() {
|
|
96
|
+
return Math.max(2, Math.min(8, Math.floor(cpus().length / 2)));
|
|
97
|
+
}
|
|
98
|
+
export const DEFAULT_CONFIG = {
|
|
99
|
+
workDir: "work",
|
|
100
|
+
docsDir: "docs",
|
|
101
|
+
skillsDir: "skills",
|
|
102
|
+
inputsDir: "inputs",
|
|
103
|
+
taskExtensions: [".md"],
|
|
104
|
+
defaultStatus: "inbox",
|
|
105
|
+
defaultAssignee: "unassigned",
|
|
106
|
+
cacheDir: ".repoos",
|
|
107
|
+
theme: "system",
|
|
108
|
+
uiTheme: "classic",
|
|
109
|
+
defaultTaskMode: "freeform",
|
|
110
|
+
tunnelEnabled: false,
|
|
111
|
+
ntfyEnabled: false,
|
|
112
|
+
ntfyTopic: "",
|
|
113
|
+
ntfyBaseUrl: "https://ntfy.sh",
|
|
114
|
+
agents: [],
|
|
115
|
+
supervisor: {
|
|
116
|
+
enabled: false,
|
|
117
|
+
interval: 300,
|
|
118
|
+
mode: "observe",
|
|
119
|
+
},
|
|
120
|
+
watchdog: {
|
|
121
|
+
enabled: true,
|
|
122
|
+
stalenessMs: 5 * 60 * 1000,
|
|
123
|
+
autoTransition: true,
|
|
124
|
+
},
|
|
125
|
+
autoEngineeringMode: false,
|
|
126
|
+
maxActiveTasks: 3,
|
|
127
|
+
worktreeWarnThreshold: 20,
|
|
128
|
+
whisper: {
|
|
129
|
+
provider: "none",
|
|
130
|
+
apiKey: "",
|
|
131
|
+
},
|
|
132
|
+
auth: {
|
|
133
|
+
enabled: false,
|
|
134
|
+
sessionMaxAge: 2592000,
|
|
135
|
+
},
|
|
136
|
+
remoteValidation: {
|
|
137
|
+
enabled: false,
|
|
138
|
+
provider: "hetzner",
|
|
139
|
+
serverType: "cax31",
|
|
140
|
+
location: "hil",
|
|
141
|
+
idleShutdownMinutes: 8,
|
|
142
|
+
maxServerLifetimeMinutes: 120,
|
|
143
|
+
fallbackToLocal: false,
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* Sibling directory that holds per-task agent worktrees, e.g.
|
|
148
|
+
* `/path/to/repoos` -> `/path/to/repoos-worktrees/`. Kept OUTSIDE the repo
|
|
149
|
+
* root so a linked worktree can never dirty the main checkout or confuse
|
|
150
|
+
* `repoos check` staleness. `<branch>` path segments are kept so a worktree
|
|
151
|
+
* for `feat/123-x` lives at `<dir>/feat/123-x`.
|
|
152
|
+
*/
|
|
153
|
+
export function worktreesDir(root) {
|
|
154
|
+
return join(dirname(root), `${basename(root)}-worktrees`);
|
|
155
|
+
}
|
|
156
|
+
/** The port range `deriveServePort` picks from — deliberately just above the
|
|
157
|
+
* classic 7171 so a derived port never collides with a repo that pins it. */
|
|
158
|
+
const DERIVED_PORT_BASE = 7200;
|
|
159
|
+
const DERIVED_PORT_SPAN = 800;
|
|
160
|
+
/**
|
|
161
|
+
* A stable default serve port for a repo, derived from its canonical root
|
|
162
|
+
* path (djb2 hash → `${DERIVED_PORT_BASE}`..`${DERIVED_PORT_BASE + DERIVED_PORT_SPAN - 1}`).
|
|
163
|
+
* Deterministic per checkout, so two repos on one machine don't both default
|
|
164
|
+
* to 7171 and reap each other. Exported for tests.
|
|
165
|
+
*/
|
|
166
|
+
export function deriveServePort(root) {
|
|
167
|
+
let canonical = root;
|
|
168
|
+
try {
|
|
169
|
+
canonical = realpathSync(root);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* not on disk (a test fixture path) — hash the literal string */
|
|
173
|
+
}
|
|
174
|
+
let hash = 5381;
|
|
175
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
176
|
+
hash = ((hash << 5) + hash + canonical.charCodeAt(i)) >>> 0;
|
|
177
|
+
}
|
|
178
|
+
return DERIVED_PORT_BASE + (hash % DERIVED_PORT_SPAN);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Resolve the port `repoos serve` should bind: an explicit `--port` flag wins,
|
|
182
|
+
* then `servePort` in repoos.toml, then a stable per-repo derived port.
|
|
183
|
+
*/
|
|
184
|
+
export function resolveServePort(root, config, portFlag) {
|
|
185
|
+
if (Number.isInteger(portFlag) && portFlag > 0)
|
|
186
|
+
return portFlag;
|
|
187
|
+
if (Number.isInteger(config.servePort) && config.servePort > 0) {
|
|
188
|
+
return config.servePort;
|
|
189
|
+
}
|
|
190
|
+
return deriveServePort(root);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Load `.env` from the repo root into `process.env`, if present. Zero
|
|
194
|
+
* runtime deps (no dotenv package) — a minimal `KEY=value` parser, one line
|
|
195
|
+
* per variable. Real env vars already set take precedence over the file, so
|
|
196
|
+
* e.g. a systemd unit's `Environment=` still wins. Safe to call more than
|
|
197
|
+
* once; safe when `.env` doesn't exist.
|
|
198
|
+
*/
|
|
199
|
+
export function loadDotEnv(root = findRepoRoot()) {
|
|
200
|
+
const envPath = join(root, ".env");
|
|
201
|
+
if (!existsSync(envPath))
|
|
202
|
+
return;
|
|
203
|
+
const text = readFileSync(envPath, "utf8");
|
|
204
|
+
for (const rawLine of text.split("\n")) {
|
|
205
|
+
const line = rawLine.trim();
|
|
206
|
+
if (!line || line.startsWith("#"))
|
|
207
|
+
continue;
|
|
208
|
+
const eq = line.indexOf("=");
|
|
209
|
+
if (eq === -1)
|
|
210
|
+
continue;
|
|
211
|
+
const key = line.slice(0, eq).trim();
|
|
212
|
+
if (!key || key in process.env)
|
|
213
|
+
continue;
|
|
214
|
+
let value = line.slice(eq + 1).trim();
|
|
215
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
216
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
217
|
+
value = value.slice(1, -1);
|
|
218
|
+
}
|
|
219
|
+
process.env[key] = value;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Persist a local secret to the gitignored `.env` at the repo root, keeping
|
|
224
|
+
* the same env-var-secret convention `loadDotEnv` reads back (dev backdoor
|
|
225
|
+
* code, auth provider keys, model-provider API keys). An empty `value`
|
|
226
|
+
* removes the line entirely so a cleared secret can't linger as `KEY=`.
|
|
227
|
+
* Also updates `process.env` directly: `loadDotEnv` skips keys already
|
|
228
|
+
* present in the environment, so a re-`loadConfig` alone would never refresh
|
|
229
|
+
* a value the booting process had already seen.
|
|
230
|
+
*
|
|
231
|
+
* Only ever called with the fixed secret names this codebase owns (the
|
|
232
|
+
* model-provider keys today) — callers validate that before calling. The
|
|
233
|
+
* value is written raw, one `KEY=value` line, exactly what `loadDotEnv`
|
|
234
|
+
* parses back.
|
|
235
|
+
*/
|
|
236
|
+
export function setDotEnvSecret(root, key, value) {
|
|
237
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
238
|
+
throw new Error(`Invalid env var name: ${JSON.stringify(key)}`);
|
|
239
|
+
}
|
|
240
|
+
if (value.includes("\n") || value.includes("\r")) {
|
|
241
|
+
throw new Error("Secret value must not contain newlines");
|
|
242
|
+
}
|
|
243
|
+
const envPath = join(root, ".env");
|
|
244
|
+
const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
245
|
+
const lines = existing.replace(/\r\n/g, "\n").split("\n");
|
|
246
|
+
const kept = [];
|
|
247
|
+
let found = false;
|
|
248
|
+
for (const rawLine of lines) {
|
|
249
|
+
const trimmed = rawLine.trim();
|
|
250
|
+
const eq = trimmed.indexOf("=");
|
|
251
|
+
const name = eq > 0 ? trimmed.slice(0, eq).trim() : "";
|
|
252
|
+
if (name === key) {
|
|
253
|
+
found = true;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
kept.push(rawLine);
|
|
257
|
+
}
|
|
258
|
+
if (value)
|
|
259
|
+
kept.push(`${key}=${value}`);
|
|
260
|
+
if (!found && lines.length && kept[kept.length - 1] === "")
|
|
261
|
+
kept.pop();
|
|
262
|
+
writeFileSync(envPath, kept.join("\n") + "\n", "utf8");
|
|
263
|
+
if (value)
|
|
264
|
+
process.env[key] = value;
|
|
265
|
+
else
|
|
266
|
+
delete process.env[key];
|
|
267
|
+
}
|
|
268
|
+
/** Walk upward from `start` to find the repo root (nearest .git or repoos.toml). */
|
|
269
|
+
export function findRepoRoot(start = process.cwd()) {
|
|
270
|
+
let dir = resolve(start);
|
|
271
|
+
// eslint-disable-next-line no-constant-condition
|
|
272
|
+
while (true) {
|
|
273
|
+
if (existsSync(join(dir, ".git")) || existsSync(join(dir, "repoos.toml"))) {
|
|
274
|
+
return dir;
|
|
275
|
+
}
|
|
276
|
+
const parent = resolve(dir, "..");
|
|
277
|
+
if (parent === dir)
|
|
278
|
+
return resolve(start); // hit filesystem root; fall back
|
|
279
|
+
dir = parent;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* True when `dir` is a LINKED WORKTREE root rather than a real repo root: a
|
|
284
|
+
* worktree's `.git` is a FILE reading `gitdir: …`, while a real root's `.git`
|
|
285
|
+
* is a directory. Cheap — one stat, no git subprocess.
|
|
286
|
+
*
|
|
287
|
+
* `findRepoRoot` does not make this distinction (it matches any `.git`), which
|
|
288
|
+
* is exactly why board reads run from inside a worktree used to silently
|
|
289
|
+
* resolve to the worktree's own copy of the task files instead of the live
|
|
290
|
+
* board's main checkout (the #0068 false-positive).
|
|
291
|
+
*/
|
|
292
|
+
export function isLinkedWorktreeRoot(dir) {
|
|
293
|
+
const gitPath = join(dir, ".git");
|
|
294
|
+
if (!existsSync(gitPath))
|
|
295
|
+
return false;
|
|
296
|
+
try {
|
|
297
|
+
return statSync(gitPath).isFile();
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* The MAIN checkout root for a linked worktree, or null when `dir` is not a
|
|
305
|
+
* linked worktree root. A worktree's `.git` file points at
|
|
306
|
+
* `<main>/.git/worktrees/<name>` (absolute, or relative to the worktree); the
|
|
307
|
+
* main root is the first ancestor of that pointer whose own `.git` is a
|
|
308
|
+
* directory. Fail-soft: any anomaly yields null and callers fall back to the
|
|
309
|
+
* resolved root.
|
|
310
|
+
*/
|
|
311
|
+
export function mainCheckoutRoot(dir) {
|
|
312
|
+
if (!isLinkedWorktreeRoot(dir))
|
|
313
|
+
return null;
|
|
314
|
+
let text;
|
|
315
|
+
try {
|
|
316
|
+
text = readFileSync(join(dir, ".git"), "utf8");
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
const m = text.match(/^gitdir:\s*(.+)$/m);
|
|
322
|
+
if (!m)
|
|
323
|
+
return null;
|
|
324
|
+
let probe = resolve(dir, m[1].trim());
|
|
325
|
+
// eslint-disable-next-line no-constant-condition
|
|
326
|
+
while (true) {
|
|
327
|
+
if (existsSync(join(probe, ".git")) && !isLinkedWorktreeRoot(probe)) {
|
|
328
|
+
return probe;
|
|
329
|
+
}
|
|
330
|
+
const parent = resolve(probe, "..");
|
|
331
|
+
if (parent === probe)
|
|
332
|
+
return null; // hit filesystem root — not a worktree
|
|
333
|
+
probe = parent;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* The root LIVE-BOARD reads (`repoos show`/`list`/`index`) should resolve to:
|
|
338
|
+
* the MAIN checkout even when the CLI runs inside a task worktree, so a
|
|
339
|
+
* readback can never false-positive on the worktree's own copy. `fromWorktree`
|
|
340
|
+
* signals that resolution jumped the main checkout — callers surface it so the
|
|
341
|
+
* behavior is never silent. Mutating commands keep `findRepoRoot` semantics
|
|
342
|
+
* (they must act on the directory they run in).
|
|
343
|
+
*/
|
|
344
|
+
export function boardRoot(start) {
|
|
345
|
+
const resolved = findRepoRoot(start);
|
|
346
|
+
const main = mainCheckoutRoot(resolved);
|
|
347
|
+
return main ? { root: main, fromWorktree: true } : { root: resolved, fromWorktree: false };
|
|
348
|
+
}
|
|
349
|
+
/** Extremely small flat-TOML reader: `key = value`, `[section]` headers, and `[[array-of-tables]]`. */
|
|
350
|
+
function parseFlatToml(text) {
|
|
351
|
+
const out = {};
|
|
352
|
+
let section = "";
|
|
353
|
+
let arrayTable = null;
|
|
354
|
+
let arrayTableKey = "";
|
|
355
|
+
for (const rawLine of text.replace(/\r\n/g, "\n").split("\n")) {
|
|
356
|
+
const line = stripTomlComment(rawLine).trim();
|
|
357
|
+
if (!line)
|
|
358
|
+
continue;
|
|
359
|
+
const arrSec = line.match(/^\[\[([^\]]+)\]\]$/);
|
|
360
|
+
if (arrSec) {
|
|
361
|
+
section = "";
|
|
362
|
+
arrayTableKey = arrSec[1];
|
|
363
|
+
const arr = out[arrayTableKey] ?? [];
|
|
364
|
+
arrayTable = {};
|
|
365
|
+
arr.push(arrayTable);
|
|
366
|
+
out[arrayTableKey] = arr;
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const sec = line.match(/^\[([^\]]+)\]$/);
|
|
370
|
+
if (sec) {
|
|
371
|
+
section = sec[1];
|
|
372
|
+
arrayTable = null;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const kv = line.match(/^([A-Za-z0-9_.-]+)\s*=\s*(.+)$/);
|
|
376
|
+
if (!kv)
|
|
377
|
+
continue;
|
|
378
|
+
const key = section ? `${section}.${kv[1]}` : kv[1];
|
|
379
|
+
let val = kv[2].trim();
|
|
380
|
+
const s = val;
|
|
381
|
+
if (s.startsWith("[") && s.endsWith("]")) {
|
|
382
|
+
val = s
|
|
383
|
+
.slice(1, -1)
|
|
384
|
+
.split(",")
|
|
385
|
+
.map((x) => unquoteTomlString(x.trim()))
|
|
386
|
+
.filter(Boolean);
|
|
387
|
+
}
|
|
388
|
+
else if (/^-?\d+$/.test(s)) {
|
|
389
|
+
val = Number(s);
|
|
390
|
+
}
|
|
391
|
+
else if (s === "true" || s === "false") {
|
|
392
|
+
val = s === "true";
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
val = unquoteTomlString(s);
|
|
396
|
+
}
|
|
397
|
+
if (arrayTable && arrayTableKey) {
|
|
398
|
+
arrayTable[kv[1]] = val;
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
out[key] = val;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return out;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Parse the `[preview]` section (plus `[[preview.targets]]` tables) from flat
|
|
408
|
+
* TOML. Exported for tests; `loadConfig` merges the result into the config.
|
|
409
|
+
* Rows without a usable `command` are dropped rather than poisoning resolution.
|
|
410
|
+
*/
|
|
411
|
+
export function parsePreviewConfig(parsed) {
|
|
412
|
+
const preview = {};
|
|
413
|
+
const command = parsed["preview.command"];
|
|
414
|
+
if (typeof command === "string" && command.trim())
|
|
415
|
+
preview.command = command.trim();
|
|
416
|
+
const cwd = parsed["preview.cwd"];
|
|
417
|
+
if (typeof cwd === "string" && cwd.trim())
|
|
418
|
+
preview.cwd = cwd.trim();
|
|
419
|
+
const readyPath = normalizeReadyPath(parsed["preview.readyPath"]);
|
|
420
|
+
if (readyPath)
|
|
421
|
+
preview.readyPath = readyPath;
|
|
422
|
+
const readyTimeoutMs = normalizeReadyTimeoutMs(parsed["preview.readyTimeoutMs"]);
|
|
423
|
+
if (readyTimeoutMs)
|
|
424
|
+
preview.readyTimeoutMs = readyTimeoutMs;
|
|
425
|
+
if (Array.isArray(parsed["preview.targets"])) {
|
|
426
|
+
const targets = [];
|
|
427
|
+
// Target names are the pick/label key the UI and resolution use (#0379), so
|
|
428
|
+
// they must be unique. Auto-derived names (`areas.join("/")`) collide the
|
|
429
|
+
// moment two targets claim the same area, and explicit duplicates are just
|
|
430
|
+
// as easy to write; either would render two identical picker options and
|
|
431
|
+
// make `resolvePreviewTarget(name)` select the first silently. Disambiguate
|
|
432
|
+
// deterministically with a numeric suffix instead of dropping a target.
|
|
433
|
+
const usedNames = new Set();
|
|
434
|
+
for (const raw of parsed["preview.targets"]) {
|
|
435
|
+
if (typeof raw !== "object" || raw === null)
|
|
436
|
+
continue;
|
|
437
|
+
const r = raw;
|
|
438
|
+
const targetCommand = typeof r.command === "string" ? r.command.trim() : "";
|
|
439
|
+
if (!targetCommand)
|
|
440
|
+
continue;
|
|
441
|
+
const areasRaw = r.areas;
|
|
442
|
+
const areas = (Array.isArray(areasRaw) ? areasRaw : [areasRaw])
|
|
443
|
+
.map((a) => (typeof a === "string" ? a.trim() : ""))
|
|
444
|
+
.filter(Boolean);
|
|
445
|
+
const base = typeof r.name === "string" && r.name.trim() ? r.name.trim() : areas.join("/") || "target";
|
|
446
|
+
let name = base;
|
|
447
|
+
for (let n = 2; usedNames.has(name); n++)
|
|
448
|
+
name = `${base} (${n})`;
|
|
449
|
+
usedNames.add(name);
|
|
450
|
+
const target = { name, areas, command: targetCommand };
|
|
451
|
+
const targetCwd = typeof r.cwd === "string" ? r.cwd.trim() : "";
|
|
452
|
+
if (targetCwd)
|
|
453
|
+
target.cwd = targetCwd;
|
|
454
|
+
const targetReadyPath = normalizeReadyPath(r.ready_path ?? r.readyPath);
|
|
455
|
+
if (targetReadyPath)
|
|
456
|
+
target.readyPath = targetReadyPath;
|
|
457
|
+
const targetReadyTimeoutMs = normalizeReadyTimeoutMs(r.ready_timeout_ms ?? r.readyTimeoutMs);
|
|
458
|
+
if (targetReadyTimeoutMs)
|
|
459
|
+
target.readyTimeoutMs = targetReadyTimeoutMs;
|
|
460
|
+
targets.push(target);
|
|
461
|
+
}
|
|
462
|
+
if (targets.length)
|
|
463
|
+
preview.targets = targets;
|
|
464
|
+
}
|
|
465
|
+
const hasCustom = Boolean(preview.command) || Boolean(preview.targets?.length);
|
|
466
|
+
return hasCustom ? preview : undefined;
|
|
467
|
+
}
|
|
468
|
+
/** Normalize a configured readiness path to a leading-slash path, or undefined. */
|
|
469
|
+
function normalizeReadyPath(value) {
|
|
470
|
+
if (typeof value !== "string")
|
|
471
|
+
return undefined;
|
|
472
|
+
const trimmed = value.trim();
|
|
473
|
+
if (!trimmed)
|
|
474
|
+
return undefined;
|
|
475
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
476
|
+
}
|
|
477
|
+
/** Normalize a configured readiness timeout (ms) to a positive finite number, or undefined. */
|
|
478
|
+
function normalizeReadyTimeoutMs(value) {
|
|
479
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
480
|
+
return undefined;
|
|
481
|
+
return value;
|
|
482
|
+
}
|
|
483
|
+
export function loadConfig(rootArg) {
|
|
484
|
+
const root = rootArg ? resolve(rootArg) : findRepoRoot();
|
|
485
|
+
// Load .env before resolving [auth]/[whisper] secrets below, so every path
|
|
486
|
+
// that boots a real server (repoos serve, previews, the UI smoke test)
|
|
487
|
+
// sees the same env-sourced values consistently, not just the CLI's own
|
|
488
|
+
// entrypoint.
|
|
489
|
+
loadDotEnv(root);
|
|
490
|
+
const cfg = { root, ...DEFAULT_CONFIG };
|
|
491
|
+
const tomlPath = join(root, "repoos.toml");
|
|
492
|
+
if (existsSync(tomlPath)) {
|
|
493
|
+
const parsed = parseFlatToml(readFileSync(tomlPath, "utf8"));
|
|
494
|
+
const get = (k) => parsed[k] ?? parsed[`repoos.${k}`];
|
|
495
|
+
if (typeof get("workDir") === "string")
|
|
496
|
+
cfg.workDir = get("workDir");
|
|
497
|
+
if (typeof get("docsDir") === "string")
|
|
498
|
+
cfg.docsDir = get("docsDir");
|
|
499
|
+
if (typeof get("skillsDir") === "string")
|
|
500
|
+
cfg.skillsDir = get("skillsDir");
|
|
501
|
+
if (typeof get("inputsDir") === "string")
|
|
502
|
+
cfg.inputsDir = get("inputsDir");
|
|
503
|
+
if (Array.isArray(get("taskExtensions")))
|
|
504
|
+
cfg.taskExtensions = get("taskExtensions");
|
|
505
|
+
if (typeof get("defaultStatus") === "string")
|
|
506
|
+
cfg.defaultStatus = get("defaultStatus");
|
|
507
|
+
if (typeof get("defaultAssignee") === "string")
|
|
508
|
+
cfg.defaultAssignee = get("defaultAssignee");
|
|
509
|
+
if (typeof get("cacheDir") === "string")
|
|
510
|
+
cfg.cacheDir = get("cacheDir");
|
|
511
|
+
if (typeof get("strictBuild") === "boolean")
|
|
512
|
+
cfg.strictBuild = get("strictBuild");
|
|
513
|
+
const tunnelEnabled = parsed["tunnel.enabled"];
|
|
514
|
+
if (typeof tunnelEnabled === "boolean")
|
|
515
|
+
cfg.tunnelEnabled = tunnelEnabled;
|
|
516
|
+
// Optional release surface. No [release] block means no Releases navigation
|
|
517
|
+
// or API action, keeping this entirely dormant for other repositories.
|
|
518
|
+
const releaseEnabled = parsed["release.enabled"];
|
|
519
|
+
if (typeof releaseEnabled === "boolean") {
|
|
520
|
+
cfg.release = { ...cfg.release, enabled: releaseEnabled };
|
|
521
|
+
for (const key of [
|
|
522
|
+
"provider",
|
|
523
|
+
"name",
|
|
524
|
+
"branch",
|
|
525
|
+
"versionFile",
|
|
526
|
+
"tagPrefix",
|
|
527
|
+
"remote",
|
|
528
|
+
"repository",
|
|
529
|
+
"workflow",
|
|
530
|
+
]) {
|
|
531
|
+
const value = parsed[`release.${key}`];
|
|
532
|
+
if (typeof value === "string")
|
|
533
|
+
cfg.release[key] = value;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// Optional deployments surface (#0340). A [[deployments]] array of tables —
|
|
537
|
+
// one per (service, branch) — is what turns the Deployments nav item and
|
|
538
|
+
// API on; no block at all means the feature is entirely dormant for this
|
|
539
|
+
// repository, exactly like [release] above. Rows with no name/branch are
|
|
540
|
+
// dropped rather than poisoning the page.
|
|
541
|
+
if (Array.isArray(parsed.deployments)) {
|
|
542
|
+
const rows = [];
|
|
543
|
+
for (const raw of parsed.deployments) {
|
|
544
|
+
if (typeof raw !== "object" || raw === null)
|
|
545
|
+
continue;
|
|
546
|
+
const r = raw;
|
|
547
|
+
if (typeof r.name !== "string" || !r.name.trim())
|
|
548
|
+
continue;
|
|
549
|
+
if (typeof r.branch !== "string" || !r.branch.trim())
|
|
550
|
+
continue;
|
|
551
|
+
const row = { name: r.name.trim(), branch: r.branch.trim() };
|
|
552
|
+
if (typeof r.service === "string" && r.service.trim())
|
|
553
|
+
row.service = r.service.trim();
|
|
554
|
+
if (typeof r.provider === "string" && r.provider.trim())
|
|
555
|
+
row.provider = r.provider.trim();
|
|
556
|
+
if (typeof r.url === "string" && r.url.trim())
|
|
557
|
+
row.url = r.url.trim();
|
|
558
|
+
// TOML spelling is `dashboard_url`; normalized to camelCase here.
|
|
559
|
+
if (typeof r.dashboard_url === "string" && r.dashboard_url.trim())
|
|
560
|
+
row.dashboardUrl = r.dashboard_url.trim();
|
|
561
|
+
if (typeof r.subdir === "string" && r.subdir.trim())
|
|
562
|
+
row.subdir = r.subdir.trim();
|
|
563
|
+
rows.push(row);
|
|
564
|
+
}
|
|
565
|
+
if (rows.length)
|
|
566
|
+
cfg.deployments = rows;
|
|
567
|
+
}
|
|
568
|
+
if (typeof get("ntfyEnabled") === "boolean")
|
|
569
|
+
cfg.ntfyEnabled = get("ntfyEnabled");
|
|
570
|
+
if (typeof get("ntfyTopic") === "string")
|
|
571
|
+
cfg.ntfyTopic = get("ntfyTopic");
|
|
572
|
+
if (typeof get("ntfyBaseUrl") === "string")
|
|
573
|
+
cfg.ntfyBaseUrl = get("ntfyBaseUrl");
|
|
574
|
+
const taskMode = get("defaultTaskMode");
|
|
575
|
+
if (taskMode === "freeform" || taskMode === "manual")
|
|
576
|
+
cfg.defaultTaskMode = taskMode;
|
|
577
|
+
if (Array.isArray(parsed.agents))
|
|
578
|
+
cfg.agents = parsed.agents;
|
|
579
|
+
if (typeof get("autoEngineeringMode") === "boolean")
|
|
580
|
+
cfg.autoEngineeringMode = get("autoEngineeringMode");
|
|
581
|
+
const maxActiveTasks = get("maxActiveTasks");
|
|
582
|
+
if (typeof maxActiveTasks === "number" && maxActiveTasks >= 1 && maxActiveTasks <= 20)
|
|
583
|
+
cfg.maxActiveTasks = maxActiveTasks;
|
|
584
|
+
const worktreeWarnThreshold = get("worktreeWarnThreshold");
|
|
585
|
+
if (typeof worktreeWarnThreshold === "number" && worktreeWarnThreshold >= 0)
|
|
586
|
+
cfg.worktreeWarnThreshold = Math.floor(worktreeWarnThreshold);
|
|
587
|
+
else if (typeof worktreeWarnThreshold === "string" && /^\d+$/.test(worktreeWarnThreshold))
|
|
588
|
+
cfg.worktreeWarnThreshold = Number(worktreeWarnThreshold);
|
|
589
|
+
const servePort = get("servePort");
|
|
590
|
+
const servePortNum = typeof servePort === "number"
|
|
591
|
+
? servePort
|
|
592
|
+
: typeof servePort === "string" && /^\d+$/.test(servePort)
|
|
593
|
+
? Number(servePort)
|
|
594
|
+
: NaN;
|
|
595
|
+
if (Number.isInteger(servePortNum) && servePortNum >= 1 && servePortNum <= 65535)
|
|
596
|
+
cfg.servePort = servePortNum;
|
|
597
|
+
// Older Settings builds wrote this select value as a quoted TOML string.
|
|
598
|
+
// Accept a strict integer string on load so existing repos immediately
|
|
599
|
+
// recover, while the API now writes new values as numbers.
|
|
600
|
+
if (typeof maxActiveTasks === "string" && /^(?:[1-9]|1\d|20)$/.test(maxActiveTasks))
|
|
601
|
+
cfg.maxActiveTasks = Number(maxActiveTasks);
|
|
602
|
+
const maxConcurrentAgents = get("maxConcurrentAgents");
|
|
603
|
+
if (typeof maxConcurrentAgents === "number" &&
|
|
604
|
+
maxConcurrentAgents >= 1 &&
|
|
605
|
+
maxConcurrentAgents <= 16)
|
|
606
|
+
cfg.maxConcurrentAgents = maxConcurrentAgents;
|
|
607
|
+
// [check] section (#0348) — opt-in commands for `repoos check` steps that
|
|
608
|
+
// are only meaningful for projects that declare them. A project can instead
|
|
609
|
+
// declare a `smoke` package.json script for a zero-config default; this
|
|
610
|
+
// config value overrides it when both are present. Both the `[check]` and
|
|
611
|
+
// `[checks]` spellings are accepted (the task's prose used `checks.uiSmoke`).
|
|
612
|
+
const checkUiSmoke = parsed["check.uiSmoke"] ?? parsed["checks.uiSmoke"];
|
|
613
|
+
if (typeof checkUiSmoke === "string" && checkUiSmoke.trim()) {
|
|
614
|
+
cfg.check = { ...cfg.check, uiSmoke: checkUiSmoke.trim() };
|
|
615
|
+
}
|
|
616
|
+
// [check] stylesheet guards (#0351) — the CSS-layering and theme-contrast
|
|
617
|
+
// steps read a project-declared stylesheet and token vocabulary instead of
|
|
618
|
+
// a RepoOS-hardcoded path and token names. A row missing its required keys
|
|
619
|
+
// is dropped rather than poisoning the guard.
|
|
620
|
+
const checkUiStylesheet = parsed["check.uiStylesheet"] ?? parsed["checks.uiStylesheet"];
|
|
621
|
+
if (typeof checkUiStylesheet === "string" && checkUiStylesheet.trim()) {
|
|
622
|
+
cfg.check = { ...cfg.check, uiStylesheet: checkUiStylesheet.trim() };
|
|
623
|
+
}
|
|
624
|
+
const checkThemeScopes = parsed["check.themeScopes"] ?? parsed["checks.themeScopes"];
|
|
625
|
+
if (Array.isArray(checkThemeScopes)) {
|
|
626
|
+
const scopes = [];
|
|
627
|
+
for (const raw of checkThemeScopes) {
|
|
628
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
629
|
+
continue;
|
|
630
|
+
const r = raw;
|
|
631
|
+
if (typeof r.selector !== "string" || !r.selector.trim())
|
|
632
|
+
continue;
|
|
633
|
+
if (typeof r.name !== "string" || !r.name.trim())
|
|
634
|
+
continue;
|
|
635
|
+
const scope = { selector: r.selector.trim(), name: r.name.trim() };
|
|
636
|
+
if (Array.isArray(r.inherits)) {
|
|
637
|
+
const inherits = r.inherits
|
|
638
|
+
.filter((v) => typeof v === "string" && v.trim() !== "")
|
|
639
|
+
.map((v) => v.trim());
|
|
640
|
+
if (inherits.length)
|
|
641
|
+
scope.inherits = inherits;
|
|
642
|
+
}
|
|
643
|
+
scopes.push(scope);
|
|
644
|
+
}
|
|
645
|
+
if (scopes.length)
|
|
646
|
+
cfg.check = { ...cfg.check, themeScopes: scopes };
|
|
647
|
+
}
|
|
648
|
+
const checkContrastPairs = parsed["check.contrastPairs"] ?? parsed["checks.contrastPairs"];
|
|
649
|
+
if (Array.isArray(checkContrastPairs)) {
|
|
650
|
+
const pairs = [];
|
|
651
|
+
for (const raw of checkContrastPairs) {
|
|
652
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
653
|
+
continue;
|
|
654
|
+
const r = raw;
|
|
655
|
+
if (typeof r.fg !== "string" || !r.fg.trim())
|
|
656
|
+
continue;
|
|
657
|
+
if (typeof r.bg !== "string" || !r.bg.trim())
|
|
658
|
+
continue;
|
|
659
|
+
pairs.push({ fg: r.fg.trim(), bg: r.bg.trim() });
|
|
660
|
+
}
|
|
661
|
+
if (pairs.length)
|
|
662
|
+
cfg.check = { ...cfg.check, contrastPairs: pairs };
|
|
663
|
+
}
|
|
664
|
+
const checkGradientTokens = parsed["check.gradientTokens"] ?? parsed["checks.gradientTokens"];
|
|
665
|
+
if (Array.isArray(checkGradientTokens)) {
|
|
666
|
+
const tokens = checkGradientTokens
|
|
667
|
+
.filter((v) => typeof v === "string" && v.trim() !== "")
|
|
668
|
+
.map((v) => v.trim());
|
|
669
|
+
if (tokens.length)
|
|
670
|
+
cfg.check = { ...cfg.check, gradientTokens: tokens };
|
|
671
|
+
}
|
|
672
|
+
const checkBackdropToken = parsed["check.backdropToken"] ?? parsed["checks.backdropToken"];
|
|
673
|
+
if (typeof checkBackdropToken === "string" && checkBackdropToken.trim()) {
|
|
674
|
+
cfg.check = { ...cfg.check, backdropToken: checkBackdropToken.trim() };
|
|
675
|
+
}
|
|
676
|
+
// [check] bare-require source roots (#0352). The guard is generic but the
|
|
677
|
+
// directories to scan are RepoOS-shaped, so a managed project declares its
|
|
678
|
+
// own; empty rows are dropped rather than poisoning the scan.
|
|
679
|
+
const checkBareRequireDirs = parsed["check.bareRequireDirs"] ?? parsed["checks.bareRequireDirs"];
|
|
680
|
+
if (Array.isArray(checkBareRequireDirs)) {
|
|
681
|
+
const dirs = checkBareRequireDirs
|
|
682
|
+
.filter((v) => typeof v === "string" && v.trim() !== "")
|
|
683
|
+
.map((v) => v.trim());
|
|
684
|
+
if (dirs.length)
|
|
685
|
+
cfg.check = { ...cfg.check, bareRequireDirs: dirs };
|
|
686
|
+
}
|
|
687
|
+
const checkBareRequireExcludes = parsed["check.bareRequireExcludes"] ?? parsed["checks.bareRequireExcludes"];
|
|
688
|
+
if (Array.isArray(checkBareRequireExcludes)) {
|
|
689
|
+
const excludes = checkBareRequireExcludes
|
|
690
|
+
.filter((v) => typeof v === "string" && v.trim() !== "")
|
|
691
|
+
.map((v) => v.trim());
|
|
692
|
+
if (excludes.length)
|
|
693
|
+
cfg.check = { ...cfg.check, bareRequireExcludes: excludes };
|
|
694
|
+
}
|
|
695
|
+
// [preview] section (#0362) — how to preview a task's worktree. The
|
|
696
|
+
// project's own command runs, selected by the task's `area`; when no
|
|
697
|
+
// section is declared there is no implicit default (#0370).
|
|
698
|
+
const preview = parsePreviewConfig(parsed);
|
|
699
|
+
if (preview)
|
|
700
|
+
cfg.preview = preview;
|
|
701
|
+
// [whisper] section — voice transcription for vibe-coding.
|
|
702
|
+
const whisperProvider = parsed["whisper.provider"];
|
|
703
|
+
if (typeof whisperProvider === "string" &&
|
|
704
|
+
["groq", "openai", "none"].includes(whisperProvider)) {
|
|
705
|
+
cfg.whisper = { ...cfg.whisper, provider: whisperProvider };
|
|
706
|
+
}
|
|
707
|
+
const whisperApiKey = parsed["whisper.apiKey"];
|
|
708
|
+
if (typeof whisperApiKey === "string") {
|
|
709
|
+
cfg.whisper = { ...cfg.whisper, apiKey: whisperApiKey };
|
|
710
|
+
}
|
|
711
|
+
else {
|
|
712
|
+
// Env var fallbacks, provider-aware: a generic key wins, then the
|
|
713
|
+
// provider's own var — never send an OPENAI_API_KEY to Groq.
|
|
714
|
+
const provider = cfg.whisper?.provider ?? "none";
|
|
715
|
+
const envKey = process.env.REPOOS_WHISPER_KEY ??
|
|
716
|
+
(provider === "groq"
|
|
717
|
+
? process.env.GROQ_API_KEY
|
|
718
|
+
: provider === "openai"
|
|
719
|
+
? process.env.OPENAI_API_KEY
|
|
720
|
+
: (process.env.GROQ_API_KEY ?? process.env.OPENAI_API_KEY));
|
|
721
|
+
if (envKey) {
|
|
722
|
+
cfg.whisper = { ...cfg.whisper, apiKey: envKey };
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
// [watchdog] section (0180) — the task watchdog over active tasks.
|
|
726
|
+
const watchdogEnabled = parsed["watchdog.enabled"];
|
|
727
|
+
if (typeof watchdogEnabled === "boolean") {
|
|
728
|
+
cfg.watchdog = { ...cfg.watchdog, enabled: watchdogEnabled };
|
|
729
|
+
}
|
|
730
|
+
const watchdogStaleness = parsed["watchdog.stalenessMs"];
|
|
731
|
+
if (typeof watchdogStaleness === "number" && watchdogStaleness >= 60_000) {
|
|
732
|
+
cfg.watchdog = { ...cfg.watchdog, stalenessMs: watchdogStaleness };
|
|
733
|
+
}
|
|
734
|
+
const watchdogAutoTransition = parsed["watchdog.autoTransition"];
|
|
735
|
+
if (typeof watchdogAutoTransition === "boolean") {
|
|
736
|
+
cfg.watchdog = { ...cfg.watchdog, autoTransition: watchdogAutoTransition };
|
|
737
|
+
}
|
|
738
|
+
// [auth] section — authentication configuration.
|
|
739
|
+
const authEnabled = parsed["auth.enabled"];
|
|
740
|
+
if (typeof authEnabled === "boolean") {
|
|
741
|
+
cfg.auth = { ...cfg.auth, enabled: authEnabled };
|
|
742
|
+
}
|
|
743
|
+
// Secrets prefer an env var over the (git-tracked) config file, same
|
|
744
|
+
// fallback pattern as [whisper] above — env wins when both are set.
|
|
745
|
+
const authSessionSecret = parsed["auth.sessionSecret"] ?? process.env.REPOOS_AUTH_SESSION_SECRET;
|
|
746
|
+
if (typeof authSessionSecret === "string" && authSessionSecret) {
|
|
747
|
+
cfg.auth = { ...cfg.auth, sessionSecret: authSessionSecret };
|
|
748
|
+
}
|
|
749
|
+
const authSessionMaxAge = parsed["auth.sessionMaxAge"];
|
|
750
|
+
if (typeof authSessionMaxAge === "number" || typeof authSessionMaxAge === "string") {
|
|
751
|
+
const num = typeof authSessionMaxAge === "string" ? Number(authSessionMaxAge) : authSessionMaxAge;
|
|
752
|
+
if (Number.isFinite(num) && num > 0) {
|
|
753
|
+
// Accept values in days (1-1000) or seconds (>=300s). Assume values <300 are days.
|
|
754
|
+
const ageInSeconds = num < 300 ? num * 86400 : num;
|
|
755
|
+
if (ageInSeconds >= 300) {
|
|
756
|
+
cfg.auth = { ...cfg.auth, sessionMaxAge: ageInSeconds };
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
const authBootstrapAdmin = parsed["auth.bootstrapAdmin"];
|
|
761
|
+
if (typeof authBootstrapAdmin === "string") {
|
|
762
|
+
cfg.auth = { ...cfg.auth, bootstrapAdmin: authBootstrapAdmin };
|
|
763
|
+
}
|
|
764
|
+
// Dev backdoor OTP: env-var only, never a repoos.toml key, so it can
|
|
765
|
+
// never end up in a git-tracked config file. `verifyOtp` also refuses to
|
|
766
|
+
// honor it outside NODE_ENV !== "production" as a second guard.
|
|
767
|
+
const authDevBackdoorCode = process.env.REPOOS_AUTH_DEV_BACKDOOR_CODE;
|
|
768
|
+
if (typeof authDevBackdoorCode === "string" &&
|
|
769
|
+
authDevBackdoorCode &&
|
|
770
|
+
process.env.NODE_ENV !== "production") {
|
|
771
|
+
cfg.auth = { ...cfg.auth, devBackdoorCode: authDevBackdoorCode };
|
|
772
|
+
}
|
|
773
|
+
// Email provider — fromAddress isn't sensitive and stays config-only;
|
|
774
|
+
// apiKey may come from the config file or REPOOS_RESEND_API_KEY.
|
|
775
|
+
const emailProviderType = parsed["auth.emailProvider.type"];
|
|
776
|
+
if (typeof emailProviderType === "string" && emailProviderType === "resend") {
|
|
777
|
+
const emailApiKey = parsed["auth.emailProvider.apiKey"] ?? process.env.REPOOS_RESEND_API_KEY;
|
|
778
|
+
const emailFrom = parsed["auth.emailProvider.fromAddress"];
|
|
779
|
+
const emailFromName = parsed["auth.emailProvider.fromName"];
|
|
780
|
+
if (typeof emailApiKey === "string" && emailApiKey && typeof emailFrom === "string") {
|
|
781
|
+
cfg.auth = {
|
|
782
|
+
...cfg.auth,
|
|
783
|
+
emailProvider: {
|
|
784
|
+
type: "resend",
|
|
785
|
+
apiKey: emailApiKey,
|
|
786
|
+
fromAddress: emailFrom,
|
|
787
|
+
...(typeof emailFromName === "string" && emailFromName
|
|
788
|
+
? { fromName: emailFromName }
|
|
789
|
+
: {}),
|
|
790
|
+
},
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
// Google OAuth — clientId isn't sensitive and stays config-only;
|
|
795
|
+
// clientSecret may come from the config file or REPOOS_GOOGLE_CLIENT_SECRET.
|
|
796
|
+
const googleClientId = parsed["auth.google.clientId"];
|
|
797
|
+
const googleClientSecret = parsed["auth.google.clientSecret"] ?? process.env.REPOOS_GOOGLE_CLIENT_SECRET;
|
|
798
|
+
if (typeof googleClientId === "string" &&
|
|
799
|
+
typeof googleClientSecret === "string" &&
|
|
800
|
+
googleClientSecret) {
|
|
801
|
+
cfg.auth = {
|
|
802
|
+
...cfg.auth,
|
|
803
|
+
google: { clientId: googleClientId, clientSecret: googleClientSecret },
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
// [remoteValidation] section — run the close-out build+test on a cloud VM.
|
|
807
|
+
// The API token and SSH key path are env-only (never a git-tracked TOML
|
|
808
|
+
// key), same rule as the [auth] secrets above.
|
|
809
|
+
const rvEnabled = parsed["remoteValidation.enabled"];
|
|
810
|
+
if (typeof rvEnabled === "boolean") {
|
|
811
|
+
cfg.remoteValidation = { ...cfg.remoteValidation, enabled: rvEnabled };
|
|
812
|
+
}
|
|
813
|
+
const rvServerType = parsed["remoteValidation.serverType"];
|
|
814
|
+
if (typeof rvServerType === "string" && rvServerType) {
|
|
815
|
+
cfg.remoteValidation = { ...cfg.remoteValidation, serverType: rvServerType };
|
|
816
|
+
}
|
|
817
|
+
const rvLocation = parsed["remoteValidation.location"];
|
|
818
|
+
if (typeof rvLocation === "string" && rvLocation) {
|
|
819
|
+
cfg.remoteValidation = { ...cfg.remoteValidation, location: rvLocation };
|
|
820
|
+
}
|
|
821
|
+
const rvSnapshot = parsed["remoteValidation.snapshotId"];
|
|
822
|
+
if (typeof rvSnapshot === "string" && rvSnapshot) {
|
|
823
|
+
cfg.remoteValidation = { ...cfg.remoteValidation, snapshotId: rvSnapshot };
|
|
824
|
+
}
|
|
825
|
+
const rvSshKeyName = parsed["remoteValidation.sshKeyName"];
|
|
826
|
+
if (typeof rvSshKeyName === "string" && rvSshKeyName) {
|
|
827
|
+
cfg.remoteValidation = { ...cfg.remoteValidation, sshKeyName: rvSshKeyName };
|
|
828
|
+
}
|
|
829
|
+
const rvIdle = parsed["remoteValidation.idleShutdownMinutes"];
|
|
830
|
+
if (typeof rvIdle === "number" && rvIdle >= 0) {
|
|
831
|
+
cfg.remoteValidation = { ...cfg.remoteValidation, idleShutdownMinutes: rvIdle };
|
|
832
|
+
}
|
|
833
|
+
const rvMaxLife = parsed["remoteValidation.maxServerLifetimeMinutes"];
|
|
834
|
+
if (typeof rvMaxLife === "number" && rvMaxLife >= 10) {
|
|
835
|
+
cfg.remoteValidation = { ...cfg.remoteValidation, maxServerLifetimeMinutes: rvMaxLife };
|
|
836
|
+
}
|
|
837
|
+
const rvFallback = parsed["remoteValidation.fallbackToLocal"];
|
|
838
|
+
if (typeof rvFallback === "boolean") {
|
|
839
|
+
cfg.remoteValidation = { ...cfg.remoteValidation, fallbackToLocal: rvFallback };
|
|
840
|
+
}
|
|
841
|
+
const rvReleases = parsed["remoteValidation.useForReleases"];
|
|
842
|
+
if (typeof rvReleases === "boolean") {
|
|
843
|
+
cfg.remoteValidation = { ...cfg.remoteValidation, useForReleases: rvReleases };
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// Model-provider API keys (0327): env-only, same rule as the [auth] secrets
|
|
847
|
+
// above — they must never land in the git-tracked repoos.toml. Read after
|
|
848
|
+
// the TOML block (env vars need no repoos.toml to exist), into the flat
|
|
849
|
+
// `modelProviders` config section the model-providers route consumes.
|
|
850
|
+
const modelProviders = {};
|
|
851
|
+
const openrouterApiKey = process.env.REPOOS_OPENROUTER_API_KEY;
|
|
852
|
+
if (openrouterApiKey)
|
|
853
|
+
modelProviders.openrouterApiKey = openrouterApiKey;
|
|
854
|
+
const opencodeGoApiKey = process.env.REPOOS_OPENCODE_GO_API_KEY;
|
|
855
|
+
if (opencodeGoApiKey)
|
|
856
|
+
modelProviders.opencodeGoApiKey = opencodeGoApiKey;
|
|
857
|
+
if (modelProviders.openrouterApiKey || modelProviders.opencodeGoApiKey) {
|
|
858
|
+
cfg.modelProviders = modelProviders;
|
|
859
|
+
}
|
|
860
|
+
cfg.builtInAgents = loadBuiltInAgentsConfig(root, cfg.cacheDir);
|
|
861
|
+
return cfg;
|
|
862
|
+
}
|
|
863
|
+
export function getConfigSchema() {
|
|
864
|
+
return [
|
|
865
|
+
{
|
|
866
|
+
key: "tunnelEnabled",
|
|
867
|
+
label: "Cloudflare Tunnel",
|
|
868
|
+
type: "boolean",
|
|
869
|
+
tier: "live",
|
|
870
|
+
restartRequired: false,
|
|
871
|
+
default: DEFAULT_CONFIG.tunnelEnabled,
|
|
872
|
+
description: "Publish local apps securely through Cloudflare Tunnel + Access",
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
key: "ntfyEnabled",
|
|
876
|
+
label: "ntfy notifications",
|
|
877
|
+
type: "boolean",
|
|
878
|
+
tier: "live",
|
|
879
|
+
restartRequired: false,
|
|
880
|
+
default: DEFAULT_CONFIG.ntfyEnabled,
|
|
881
|
+
description: "Send push notifications on task lifecycle events",
|
|
882
|
+
},
|
|
883
|
+
{
|
|
884
|
+
key: "ntfyTopic",
|
|
885
|
+
label: "ntfy topic",
|
|
886
|
+
type: "string",
|
|
887
|
+
tier: "live",
|
|
888
|
+
restartRequired: false,
|
|
889
|
+
default: DEFAULT_CONFIG.ntfyTopic,
|
|
890
|
+
description: "The ntfy topic RepoOS publishes events to (e.g. repoos_myproject)",
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
key: "ntfyBaseUrl",
|
|
894
|
+
label: "ntfy base URL",
|
|
895
|
+
type: "string",
|
|
896
|
+
tier: "guarded",
|
|
897
|
+
restartRequired: false,
|
|
898
|
+
default: DEFAULT_CONFIG.ntfyBaseUrl,
|
|
899
|
+
description: "Self-hosted ntfy server base URL (NTFY_BASE_URL env var overrides)",
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
key: "defaultStatus",
|
|
903
|
+
label: "Default status",
|
|
904
|
+
type: "select",
|
|
905
|
+
tier: "restart",
|
|
906
|
+
restartRequired: true,
|
|
907
|
+
default: DEFAULT_CONFIG.defaultStatus,
|
|
908
|
+
options: STATUSES.map((s) => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) })),
|
|
909
|
+
description: "Status assigned to new tasks",
|
|
910
|
+
},
|
|
911
|
+
{
|
|
912
|
+
key: "defaultTaskMode",
|
|
913
|
+
label: "New-task mode",
|
|
914
|
+
type: "select",
|
|
915
|
+
tier: "live",
|
|
916
|
+
restartRequired: false,
|
|
917
|
+
default: DEFAULT_CONFIG.defaultTaskMode,
|
|
918
|
+
options: [
|
|
919
|
+
{ value: "freeform", label: "Freeform (AI writes the task)" },
|
|
920
|
+
{ value: "manual", label: "Manual form" },
|
|
921
|
+
],
|
|
922
|
+
description: "Which flow the New task drawer opens with",
|
|
923
|
+
},
|
|
924
|
+
{
|
|
925
|
+
key: "defaultAssignee",
|
|
926
|
+
label: "Default assignee",
|
|
927
|
+
type: "select",
|
|
928
|
+
tier: "restart",
|
|
929
|
+
restartRequired: true,
|
|
930
|
+
default: DEFAULT_CONFIG.defaultAssignee,
|
|
931
|
+
options: [
|
|
932
|
+
{ value: "unassigned", label: "Unassigned" },
|
|
933
|
+
{ value: "ai", label: "AI agent" },
|
|
934
|
+
{ value: "human", label: "Human" },
|
|
935
|
+
],
|
|
936
|
+
description: "Default assignee for new tasks",
|
|
937
|
+
},
|
|
938
|
+
{
|
|
939
|
+
key: "strictBuild",
|
|
940
|
+
label: "Strict build check",
|
|
941
|
+
type: "boolean",
|
|
942
|
+
tier: "restart",
|
|
943
|
+
restartRequired: true,
|
|
944
|
+
default: false,
|
|
945
|
+
description: "Exit with error on stale build instead of warning",
|
|
946
|
+
},
|
|
947
|
+
{
|
|
948
|
+
key: "workDir",
|
|
949
|
+
label: "Work directory",
|
|
950
|
+
type: "string",
|
|
951
|
+
tier: "guarded",
|
|
952
|
+
restartRequired: true,
|
|
953
|
+
default: DEFAULT_CONFIG.workDir,
|
|
954
|
+
description: "Directory holding task files (relative to repo root)",
|
|
955
|
+
},
|
|
956
|
+
{
|
|
957
|
+
key: "docsDir",
|
|
958
|
+
label: "Docs directory",
|
|
959
|
+
type: "string",
|
|
960
|
+
tier: "guarded",
|
|
961
|
+
restartRequired: true,
|
|
962
|
+
default: DEFAULT_CONFIG.docsDir,
|
|
963
|
+
description: "Directory holding context docs (relative to repo root)",
|
|
964
|
+
},
|
|
965
|
+
{
|
|
966
|
+
key: "skillsDir",
|
|
967
|
+
label: "Skills directory",
|
|
968
|
+
type: "string",
|
|
969
|
+
tier: "guarded",
|
|
970
|
+
restartRequired: true,
|
|
971
|
+
default: DEFAULT_CONFIG.skillsDir,
|
|
972
|
+
description: "Directory holding skills (relative to repo root)",
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
key: "taskExtensions",
|
|
976
|
+
label: "Task extensions",
|
|
977
|
+
type: "array",
|
|
978
|
+
tier: "guarded",
|
|
979
|
+
restartRequired: true,
|
|
980
|
+
default: DEFAULT_CONFIG.taskExtensions,
|
|
981
|
+
description: "File extensions treated as tasks (comma-separated)",
|
|
982
|
+
},
|
|
983
|
+
{
|
|
984
|
+
key: "cacheDir",
|
|
985
|
+
label: "Cache directory",
|
|
986
|
+
type: "string",
|
|
987
|
+
tier: "guarded",
|
|
988
|
+
restartRequired: true,
|
|
989
|
+
default: DEFAULT_CONFIG.cacheDir,
|
|
990
|
+
description: "Directory for derived index cache (relative to repo root)",
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
key: "autoEngineeringMode",
|
|
994
|
+
label: "Auto-engineering mode",
|
|
995
|
+
type: "boolean",
|
|
996
|
+
tier: "live",
|
|
997
|
+
restartRequired: false,
|
|
998
|
+
default: DEFAULT_CONFIG.autoEngineeringMode,
|
|
999
|
+
description: "Automatically select and start ready tasks up to the maximum",
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
key: "maxActiveTasks",
|
|
1003
|
+
label: "Maximum active tasks",
|
|
1004
|
+
type: "select",
|
|
1005
|
+
tier: "live",
|
|
1006
|
+
restartRequired: false,
|
|
1007
|
+
default: DEFAULT_CONFIG.maxActiveTasks,
|
|
1008
|
+
options: Array.from({ length: 20 }, (_, i) => {
|
|
1009
|
+
const val = i + 1;
|
|
1010
|
+
return { value: String(val), label: String(val) };
|
|
1011
|
+
}),
|
|
1012
|
+
description: "Maximum number of simultaneously active tasks when auto-engineering mode is enabled (1-20)",
|
|
1013
|
+
},
|
|
1014
|
+
{
|
|
1015
|
+
key: "worktreeWarnThreshold",
|
|
1016
|
+
label: "Worktree warning threshold",
|
|
1017
|
+
type: "select",
|
|
1018
|
+
tier: "live",
|
|
1019
|
+
restartRequired: false,
|
|
1020
|
+
default: DEFAULT_CONFIG.worktreeWarnThreshold,
|
|
1021
|
+
options: [
|
|
1022
|
+
{ value: "0", label: "Off" },
|
|
1023
|
+
...[10, 15, 20, 25, 30, 40, 50].map((v) => ({ value: String(v), label: String(v) })),
|
|
1024
|
+
],
|
|
1025
|
+
description: "Advisory ceiling on registered git worktrees. Above it, the Control page's Codebase card turns amber and the server logs a `repoos gc` reminder. Never blocks a task.",
|
|
1026
|
+
},
|
|
1027
|
+
{
|
|
1028
|
+
key: "maxConcurrentAgents",
|
|
1029
|
+
label: "Maximum concurrent agent processes",
|
|
1030
|
+
type: "select",
|
|
1031
|
+
tier: "live",
|
|
1032
|
+
restartRequired: false,
|
|
1033
|
+
default: defaultMaxConcurrentAgents(),
|
|
1034
|
+
options: Array.from({ length: 16 }, (_, i) => {
|
|
1035
|
+
const val = i + 1;
|
|
1036
|
+
return { value: String(val), label: String(val) };
|
|
1037
|
+
}),
|
|
1038
|
+
description: `How many agent CLI processes (start/send/chat) may run at once; extras queue. ` +
|
|
1039
|
+
`Default (${defaultMaxConcurrentAgents()}) is computed from this machine's CPU count.`,
|
|
1040
|
+
},
|
|
1041
|
+
{
|
|
1042
|
+
key: "whisper.provider",
|
|
1043
|
+
label: "Voice transcription provider",
|
|
1044
|
+
type: "select",
|
|
1045
|
+
tier: "live",
|
|
1046
|
+
group: "voice",
|
|
1047
|
+
restartRequired: false,
|
|
1048
|
+
default: DEFAULT_CONFIG.whisper?.provider ?? "none",
|
|
1049
|
+
options: [
|
|
1050
|
+
{ value: "none", label: "Disabled" },
|
|
1051
|
+
{ value: "groq", label: "Groq (whisper-large-v3)" },
|
|
1052
|
+
{ value: "openai", label: "OpenAI (whisper-1)" },
|
|
1053
|
+
],
|
|
1054
|
+
description: "Provider for voice-to-text transcription in text areas",
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
key: "whisper.apiKey",
|
|
1058
|
+
label: "Voice transcription API key",
|
|
1059
|
+
type: "string",
|
|
1060
|
+
tier: "live",
|
|
1061
|
+
group: "voice",
|
|
1062
|
+
restartRequired: false,
|
|
1063
|
+
default: "",
|
|
1064
|
+
description: "API key for the selected provider (never sent to browser; stored in repoos.toml or REPOOS_WHISPER_KEY env var)",
|
|
1065
|
+
},
|
|
1066
|
+
{
|
|
1067
|
+
key: "auth.enabled",
|
|
1068
|
+
label: "Authentication",
|
|
1069
|
+
type: "boolean",
|
|
1070
|
+
tier: "restart",
|
|
1071
|
+
restartRequired: true,
|
|
1072
|
+
default: false,
|
|
1073
|
+
description: "Require login to access RepoOS (email OTP or Google OAuth)",
|
|
1074
|
+
},
|
|
1075
|
+
{
|
|
1076
|
+
key: "auth.sessionMaxAge",
|
|
1077
|
+
label: "Session duration (seconds)",
|
|
1078
|
+
type: "string",
|
|
1079
|
+
tier: "restart",
|
|
1080
|
+
restartRequired: true,
|
|
1081
|
+
default: "604800",
|
|
1082
|
+
description: "How long a login session lasts in seconds (default 604800 = 7 days)",
|
|
1083
|
+
},
|
|
1084
|
+
{
|
|
1085
|
+
key: "remoteValidation.enabled",
|
|
1086
|
+
label: "Remote validation runner",
|
|
1087
|
+
type: "boolean",
|
|
1088
|
+
tier: "restart",
|
|
1089
|
+
restartRequired: true,
|
|
1090
|
+
default: false,
|
|
1091
|
+
description: "Run the close-out build + test suite on a disposable Hetzner VM instead of this machine " +
|
|
1092
|
+
"(needs HETZNER_API_TOKEN + REPOOS_REMOTE_SSH_KEY env vars and [remoteValidation] snapshotId/sshKeyName in repoos.toml — see docs/remote-validation.md). Sends repo contents to a third-party host.",
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
key: "remoteValidation.fallbackToLocal",
|
|
1096
|
+
label: "Remote validation: fall back to local",
|
|
1097
|
+
type: "boolean",
|
|
1098
|
+
tier: "restart",
|
|
1099
|
+
restartRequired: true,
|
|
1100
|
+
default: false,
|
|
1101
|
+
description: "When the remote runner is unreachable, run the full gate locally instead of keeping the task in review for retry.",
|
|
1102
|
+
},
|
|
1103
|
+
{
|
|
1104
|
+
key: "remoteValidation.useForReleases",
|
|
1105
|
+
label: "Remote validation: also validate releases",
|
|
1106
|
+
type: "boolean",
|
|
1107
|
+
tier: "restart",
|
|
1108
|
+
restartRequired: true,
|
|
1109
|
+
default: false,
|
|
1110
|
+
description: "Cut releases on the same Hetzner runner as close-outs (off by default — a release is watched live, so the provision delay reads as a regression; opt in per repo). Only applies when the runner is enabled.",
|
|
1111
|
+
},
|
|
1112
|
+
];
|
|
1113
|
+
}
|
|
1114
|
+
function serializeTomlVal(val) {
|
|
1115
|
+
if (typeof val === "string")
|
|
1116
|
+
return JSON.stringify(val);
|
|
1117
|
+
if (typeof val === "number")
|
|
1118
|
+
return String(val);
|
|
1119
|
+
if (typeof val === "boolean")
|
|
1120
|
+
return val ? "true" : "false";
|
|
1121
|
+
if (Array.isArray(val)) {
|
|
1122
|
+
return `[${val.map((v) => JSON.stringify(v)).join(", ")}]`;
|
|
1123
|
+
}
|
|
1124
|
+
return String(val);
|
|
1125
|
+
}
|
|
1126
|
+
/** True when a value is an array of plain objects → serialized as [[tables]]. */
|
|
1127
|
+
function isTableArray(val) {
|
|
1128
|
+
return (Array.isArray(val) &&
|
|
1129
|
+
val.length > 0 &&
|
|
1130
|
+
val.every((v) => typeof v === "object" && v !== null && !Array.isArray(v)));
|
|
1131
|
+
}
|
|
1132
|
+
/** Serialize an array of plain objects as `[[key]]` table blocks. */
|
|
1133
|
+
function serializeTableArray(key, rows) {
|
|
1134
|
+
return rows
|
|
1135
|
+
.map((row) => {
|
|
1136
|
+
const body = Object.entries(row)
|
|
1137
|
+
.filter(([, v]) => v !== undefined)
|
|
1138
|
+
.map(([k, v]) => `${k} = ${serializeTomlVal(v)}`);
|
|
1139
|
+
return `[[${key}]]\n${body.join("\n")}`;
|
|
1140
|
+
})
|
|
1141
|
+
.join("\n");
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Patch a repoos.toml file with the given key-value pairs, preserving all
|
|
1145
|
+
* other lines (comments, formatting, unknown keys).
|
|
1146
|
+
*/
|
|
1147
|
+
export function patchTomlConfig(tomlPath, patch) {
|
|
1148
|
+
if (!existsSync(tomlPath)) {
|
|
1149
|
+
writeFileSync(tomlPath, "", "utf8");
|
|
1150
|
+
}
|
|
1151
|
+
const text = readFileSync(tomlPath, "utf8");
|
|
1152
|
+
let result = text.replace(/\r\n/g, "\n").split("\n");
|
|
1153
|
+
let modified = false;
|
|
1154
|
+
// Array-of-tables keys ([[agents]]): drop the existing blocks and append the
|
|
1155
|
+
// freshly serialized ones at the end of the file.
|
|
1156
|
+
for (const [key, rawVal] of Object.entries(patch)) {
|
|
1157
|
+
if (!isTableArray(rawVal))
|
|
1158
|
+
continue;
|
|
1159
|
+
const blocks = serializeTableArray(key, rawVal);
|
|
1160
|
+
const kept = [];
|
|
1161
|
+
let i = 0;
|
|
1162
|
+
while (i < result.length) {
|
|
1163
|
+
if (stripTomlComment(result[i]).trim() === `[[${key}]]`) {
|
|
1164
|
+
i++;
|
|
1165
|
+
while (i < result.length) {
|
|
1166
|
+
const s = stripTomlComment(result[i]).trim();
|
|
1167
|
+
if (s.startsWith("["))
|
|
1168
|
+
break;
|
|
1169
|
+
i++;
|
|
1170
|
+
}
|
|
1171
|
+
continue;
|
|
1172
|
+
}
|
|
1173
|
+
kept.push(result[i]);
|
|
1174
|
+
i++;
|
|
1175
|
+
}
|
|
1176
|
+
while (kept.length && kept[kept.length - 1].trim() === "")
|
|
1177
|
+
kept.pop();
|
|
1178
|
+
kept.push(blocks);
|
|
1179
|
+
result = kept;
|
|
1180
|
+
modified = true;
|
|
1181
|
+
}
|
|
1182
|
+
// Scalars and plain arrays: in-place line-preserving patch.
|
|
1183
|
+
for (const [key, rawVal] of Object.entries(patch)) {
|
|
1184
|
+
if (isTableArray(rawVal))
|
|
1185
|
+
continue;
|
|
1186
|
+
const serialized = serializeTomlVal(rawVal);
|
|
1187
|
+
let found = false;
|
|
1188
|
+
for (let i = 0; i < result.length; i++) {
|
|
1189
|
+
const stripped = stripTomlComment(result[i]).trim();
|
|
1190
|
+
if (!stripped || stripped.startsWith("["))
|
|
1191
|
+
continue;
|
|
1192
|
+
const kv = stripped.match(/^([A-Za-z0-9_.-]+)\s*=\s*/);
|
|
1193
|
+
if (kv && kv[1] === key) {
|
|
1194
|
+
const indent = result[i].match(/^\s*/)?.[0] || "";
|
|
1195
|
+
result[i] = `${indent}${key} = ${serialized}`;
|
|
1196
|
+
modified = true;
|
|
1197
|
+
found = true;
|
|
1198
|
+
break;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (!found) {
|
|
1202
|
+
// A brand-new scalar must land at root scope. Appending at the very end
|
|
1203
|
+
// of the file is only safe when nothing after it is a `[section]` or
|
|
1204
|
+
// `[[array-of-tables]]` block — otherwise the line reads back as a
|
|
1205
|
+
// member of that table instead of the root config (the reader has no
|
|
1206
|
+
// way to know the table "ended" without a following header). Insert
|
|
1207
|
+
// before the first header line instead, so newly-saved keys are always
|
|
1208
|
+
// unambiguously root-level regardless of what tables follow.
|
|
1209
|
+
const firstHeaderIndex = result.findIndex((l) => stripTomlComment(l).trim().startsWith("["));
|
|
1210
|
+
if (firstHeaderIndex === -1) {
|
|
1211
|
+
result.push(`${key} = ${serialized}`);
|
|
1212
|
+
}
|
|
1213
|
+
else {
|
|
1214
|
+
result.splice(firstHeaderIndex, 0, `${key} = ${serialized}`);
|
|
1215
|
+
}
|
|
1216
|
+
modified = true;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
if (modified) {
|
|
1220
|
+
writeFileSync(tomlPath, result.join("\n") + "\n", "utf8");
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Absolute path of the built-in agent state sidecar (a JSON file living next
|
|
1225
|
+
* to the cache dir). It holds runtime state — enabled/schedule/last run — that
|
|
1226
|
+
* the Tech Debt Agent's server-side scheduler reads and the Agents page
|
|
1227
|
+
* writes; it is deliberately NOT part of repoos.toml.
|
|
1228
|
+
*/
|
|
1229
|
+
export function builtInAgentsPath(root, cacheDir) {
|
|
1230
|
+
return join(root, cacheDir ?? DEFAULT_CONFIG.cacheDir, "built-in-agents.json");
|
|
1231
|
+
}
|
|
1232
|
+
const BUILT_IN_SCHEDULES = ["daily", "weekly", "manual"];
|
|
1233
|
+
/** Coerce an unknown PATCH/read value into a sane BuiltInAgentConfig. */
|
|
1234
|
+
export function sanitizeBuiltInAgent(value) {
|
|
1235
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
1236
|
+
return null;
|
|
1237
|
+
const raw = value;
|
|
1238
|
+
const out = {};
|
|
1239
|
+
if (typeof raw.enabled === "boolean")
|
|
1240
|
+
out.enabled = raw.enabled;
|
|
1241
|
+
if (typeof raw.schedule === "string" && BUILT_IN_SCHEDULES.includes(raw.schedule)) {
|
|
1242
|
+
out.schedule = raw.schedule;
|
|
1243
|
+
}
|
|
1244
|
+
if (typeof raw.lastRunAt === "string" && !Number.isNaN(Date.parse(raw.lastRunAt))) {
|
|
1245
|
+
out.lastRunAt = raw.lastRunAt;
|
|
1246
|
+
}
|
|
1247
|
+
if (typeof raw.cli === "string")
|
|
1248
|
+
out.cli = raw.cli;
|
|
1249
|
+
if (typeof raw.model === "string")
|
|
1250
|
+
out.model = raw.model;
|
|
1251
|
+
return out;
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Coerce a whole record of built-in agent state (as read from the sidecar or
|
|
1255
|
+
* sent via PATCH) into a safe shape. Invalid entries are dropped; a valid
|
|
1256
|
+
* entry with no recognized fields is dropped too.
|
|
1257
|
+
*/
|
|
1258
|
+
export function sanitizeBuiltInAgents(value) {
|
|
1259
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
1260
|
+
return {};
|
|
1261
|
+
const out = {};
|
|
1262
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
1263
|
+
const clean = sanitizeBuiltInAgent(entry);
|
|
1264
|
+
if (clean && Object.keys(clean).length > 0)
|
|
1265
|
+
out[name] = clean;
|
|
1266
|
+
}
|
|
1267
|
+
return out;
|
|
1268
|
+
}
|
|
1269
|
+
/** Read the built-in agent state sidecar, or undefined when absent/unreadable. */
|
|
1270
|
+
export function loadBuiltInAgentsConfig(root, cacheDir) {
|
|
1271
|
+
const file = builtInAgentsPath(root, cacheDir);
|
|
1272
|
+
try {
|
|
1273
|
+
if (!existsSync(file))
|
|
1274
|
+
return undefined;
|
|
1275
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
1276
|
+
return sanitizeBuiltInAgents(parsed);
|
|
1277
|
+
}
|
|
1278
|
+
catch {
|
|
1279
|
+
// A corrupt sidecar must never block config loading — treat as empty.
|
|
1280
|
+
return {};
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
/** Persist the built-in agent state sidecar, creating the cache dir as needed. */
|
|
1284
|
+
export function saveBuiltInAgentsConfig(root, state, cacheDir) {
|
|
1285
|
+
const file = builtInAgentsPath(root, cacheDir);
|
|
1286
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1287
|
+
writeFileSync(file, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
1288
|
+
}
|