@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.
- package/LICENSE +21 -0
- package/README.md +170 -0
- package/bin/desk +9 -0
- package/bin/distill +2 -0
- package/bin/estate-sync +2 -0
- package/bin/launch +2 -0
- package/bin/memory-sync +2 -0
- package/bin/migrate-inspect +2 -0
- package/bin/pa +2 -0
- package/bin/pa-coordinator-hook +2 -0
- package/bin/pa-init +2 -0
- package/bin/research +2 -0
- package/bin/scheduler +2 -0
- package/bin/secrets +2 -0
- package/bin/slice +2 -0
- package/bin/smoke +9 -0
- package/bin/sync +2 -0
- package/dist/engineering-pa-cli/README.md +205 -0
- package/dist/engineering-pa-cli/bin/estate-sync +2 -0
- package/dist/engineering-pa-cli/bin/launch +2 -0
- package/dist/engineering-pa-cli/bin/pa +2 -0
- package/dist/engineering-pa-cli/bin/pa-coordinator-hook +12 -0
- package/dist/engineering-pa-cli/bin/research +2 -0
- package/dist/engineering-pa-cli/bin/scheduler +2 -0
- package/dist/engineering-pa-cli/bin/secrets +2 -0
- package/dist/engineering-pa-cli/bin/smoke +9 -0
- package/dist/engineering-pa-cli/bin/sync +2 -0
- package/dist/engineering-pa-cli/config/coordinator-allowlist.json +118 -0
- package/dist/engineering-pa-cli/docs/coordinator-hook.md +211 -0
- package/dist/engineering-pa-cli/docs/estate-sync.md +412 -0
- package/dist/engineering-pa-cli/docs/gsc.md +175 -0
- package/dist/engineering-pa-cli/docs/lint-linkedin.md +139 -0
- package/dist/engineering-pa-cli/docs/secrets.md +221 -0
- package/dist/engineering-pa-cli/docs/smoke.md +113 -0
- package/dist/engineering-pa-cli/docs/staging-alias.md +114 -0
- package/dist/engineering-pa-cli/index.mjs +17 -0
- package/dist/engineering-pa-cli/package.json +37 -0
- package/dist/engineering-pa-cli/src/autonomy/cli.mjs +329 -0
- package/dist/engineering-pa-cli/src/autonomy/envelope.mjs +301 -0
- package/dist/engineering-pa-cli/src/coordinator-hook/cli.mjs +140 -0
- package/dist/engineering-pa-cli/src/coordinator-hook/config.mjs +134 -0
- package/dist/engineering-pa-cli/src/coordinator-hook/message.mjs +47 -0
- package/dist/engineering-pa-cli/src/coordinator-hook/policy.mjs +211 -0
- package/dist/engineering-pa-cli/src/coordinator-hook/repo-root.mjs +44 -0
- package/dist/engineering-pa-cli/src/coordinator-hook/self.mjs +30 -0
- package/dist/engineering-pa-cli/src/environment/cli.mjs +287 -0
- package/dist/engineering-pa-cli/src/environment/probes.mjs +302 -0
- package/dist/engineering-pa-cli/src/gsc/auth.mjs +148 -0
- package/dist/engineering-pa-cli/src/gsc/cli.mjs +93 -0
- package/dist/engineering-pa-cli/src/gsc/cloudflare.mjs +318 -0
- package/dist/engineering-pa-cli/src/gsc/commands/add-property.mjs +70 -0
- package/dist/engineering-pa-cli/src/gsc/commands/list-properties.mjs +33 -0
- package/dist/engineering-pa-cli/src/gsc/commands/pull-analytics.mjs +130 -0
- package/dist/engineering-pa-cli/src/gsc/commands/submit-sitemap.mjs +41 -0
- package/dist/engineering-pa-cli/src/gsc/commands/verify-via-cf-dns.mjs +189 -0
- package/dist/engineering-pa-cli/src/knowledge/call-log.mjs +59 -0
- package/dist/engineering-pa-cli/src/knowledge/cli.mjs +242 -0
- package/dist/engineering-pa-cli/src/knowledge/eval.mjs +446 -0
- package/dist/engineering-pa-cli/src/knowledge/formatters.mjs +331 -0
- package/dist/engineering-pa-cli/src/knowledge/graph.mjs +710 -0
- package/dist/engineering-pa-cli/src/launch/README.md +214 -0
- package/dist/engineering-pa-cli/src/launch/cli.mjs +51 -0
- package/dist/engineering-pa-cli/src/launch/commands/launch.mjs +106 -0
- package/dist/engineering-pa-cli/src/launch/identity.mjs +43 -0
- package/dist/engineering-pa-cli/src/launch/planner.mjs +44 -0
- package/dist/engineering-pa-cli/src/launch/repo-root.mjs +53 -0
- package/dist/engineering-pa-cli/src/launch/runner.mjs +147 -0
- package/dist/engineering-pa-cli/src/launch/task-loader.mjs +85 -0
- package/dist/engineering-pa-cli/src/lint/cli.mjs +61 -0
- package/dist/engineering-pa-cli/src/lint/commands/linkedin.mjs +133 -0
- package/dist/engineering-pa-cli/src/lint/detectors.mjs +90 -0
- package/dist/engineering-pa-cli/src/lint/linter.mjs +69 -0
- package/dist/engineering-pa-cli/src/lint/reporter.mjs +32 -0
- package/dist/engineering-pa-cli/src/lint/rules.mjs +132 -0
- package/dist/engineering-pa-cli/src/pa/cli.mjs +369 -0
- package/dist/engineering-pa-cli/src/pa/doc-groups.mjs +164 -0
- package/dist/engineering-pa-cli/src/pa/io.mjs +80 -0
- package/dist/engineering-pa-cli/src/pa/project-cli.mjs +385 -0
- package/dist/engineering-pa-cli/src/pa/projects.mjs +352 -0
- package/dist/engineering-pa-cli/src/pa/repo-root.mjs +64 -0
- package/dist/engineering-pa-cli/src/pa/session.mjs +254 -0
- package/dist/engineering-pa-cli/src/pa/work-cli.mjs +164 -0
- package/dist/engineering-pa-cli/src/pa/work.mjs +196 -0
- package/dist/engineering-pa-cli/src/pa/workstreams.mjs +201 -0
- package/dist/engineering-pa-cli/src/research/README.md +360 -0
- package/dist/engineering-pa-cli/src/research/briefs/extractor.md +103 -0
- package/dist/engineering-pa-cli/src/research/briefs/searcher.md +113 -0
- package/dist/engineering-pa-cli/src/research/briefs/synthesizer.md +124 -0
- package/dist/engineering-pa-cli/src/research/briefs/verifier.md +86 -0
- package/dist/engineering-pa-cli/src/research/cli.mjs +141 -0
- package/dist/engineering-pa-cli/src/research/commands/gates.mjs +176 -0
- package/dist/engineering-pa-cli/src/research/commands/list.mjs +56 -0
- package/dist/engineering-pa-cli/src/research/commands/mark.mjs +97 -0
- package/dist/engineering-pa-cli/src/research/commands/paths.mjs +69 -0
- package/dist/engineering-pa-cli/src/research/commands/prune.mjs +86 -0
- package/dist/engineering-pa-cli/src/research/commands/run.mjs +94 -0
- package/dist/engineering-pa-cli/src/research/commands/show.mjs +43 -0
- package/dist/engineering-pa-cli/src/research/docs/operator-present-flow.md +120 -0
- package/dist/engineering-pa-cli/src/research/format.mjs +59 -0
- package/dist/engineering-pa-cli/src/research/promote.mjs +260 -0
- package/dist/engineering-pa-cli/src/research/schema/finding.schema.json +55 -0
- package/dist/engineering-pa-cli/src/research/schema/gates.schema.json +34 -0
- package/dist/engineering-pa-cli/src/research/schema/manifest.schema.json +59 -0
- package/dist/engineering-pa-cli/src/research/schema/plan.schema.json +41 -0
- package/dist/engineering-pa-cli/src/research/schema/sources.schema.json +31 -0
- package/dist/engineering-pa-cli/src/research/store/findings.mjs +61 -0
- package/dist/engineering-pa-cli/src/research/store/gates.mjs +73 -0
- package/dist/engineering-pa-cli/src/research/store/index.mjs +312 -0
- package/dist/engineering-pa-cli/src/research/store/manifest.mjs +56 -0
- package/dist/engineering-pa-cli/src/research/store/plan.mjs +59 -0
- package/dist/engineering-pa-cli/src/research/store/sources.mjs +44 -0
- package/dist/engineering-pa-cli/src/research/store/validate.mjs +92 -0
- package/dist/engineering-pa-cli/src/research/workflows/research-normal.js +573 -0
- package/dist/engineering-pa-cli/src/scheduler/cli.mjs +197 -0
- package/dist/engineering-pa-cli/src/secrets/cli.mjs +254 -0
- package/dist/engineering-pa-cli/src/secrets/commands/backup-template.mjs +67 -0
- package/dist/engineering-pa-cli/src/secrets/commands/backup.mjs +85 -0
- package/dist/engineering-pa-cli/src/secrets/commands/delete.mjs +58 -0
- package/dist/engineering-pa-cli/src/secrets/commands/find.mjs +36 -0
- package/dist/engineering-pa-cli/src/secrets/commands/get.mjs +49 -0
- package/dist/engineering-pa-cli/src/secrets/commands/init.mjs +69 -0
- package/dist/engineering-pa-cli/src/secrets/commands/list.mjs +37 -0
- package/dist/engineering-pa-cli/src/secrets/commands/rotate.mjs +46 -0
- package/dist/engineering-pa-cli/src/secrets/commands/set.mjs +123 -0
- package/dist/engineering-pa-cli/src/secrets/commands/status.mjs +146 -0
- package/dist/engineering-pa-cli/src/secrets/commands/vaults.mjs +57 -0
- package/dist/engineering-pa-cli/src/secrets/commands/verify.mjs +143 -0
- package/dist/engineering-pa-cli/src/secrets/vault.mjs +766 -0
- package/dist/engineering-pa-cli/src/smoke/checks.mjs +199 -0
- package/dist/engineering-pa-cli/src/smoke/cli.mjs +108 -0
- package/dist/engineering-pa-cli/src/smoke/commands/run.mjs +237 -0
- package/dist/engineering-pa-cli/src/smoke/runner.mjs +191 -0
- package/dist/engineering-pa-cli/src/staging-alias/cli.mjs +91 -0
- package/dist/engineering-pa-cli/src/staging-alias/cloudflare.mjs +380 -0
- package/dist/engineering-pa-cli/src/staging-alias/commands/list.mjs +36 -0
- package/dist/engineering-pa-cli/src/staging-alias/commands/set.mjs +188 -0
- package/dist/engineering-pa-cli/src/staging-alias/commands/status.mjs +113 -0
- package/dist/engineering-pa-cli/src/staging-alias/commands/unset.mjs +95 -0
- package/dist/engineering-pa-cli/src/staging-alias/sites.mjs +105 -0
- package/dist/engineering-pa-cli/src/sync/cli.mjs +151 -0
- package/dist/engineering-pa-cli/src/sync/git.mjs +149 -0
- package/dist/engineering-pa-cli/src/sync/lock.mjs +30 -0
- package/dist/engineering-pa-cli/src/sync/regenerated-artifacts.mjs +114 -0
- package/dist/engineering-pa-cli/src/sync/registry-scope.mjs +260 -0
- package/dist/engineering-pa-cli/src/sync/run.mjs +531 -0
- package/dist/engineering-pa-cli/src/sync/runner.mjs +62 -0
- package/dist/engineering-pa-cli/src/sync/runs-digest.mjs +138 -0
- package/dist/engineering-pa-core/README.md +60 -0
- package/dist/engineering-pa-core/bin/desk +9 -0
- package/dist/engineering-pa-core/bin/distill +2 -0
- package/dist/engineering-pa-core/bin/memory-sync +2 -0
- package/dist/engineering-pa-core/bin/slice +2 -0
- package/dist/engineering-pa-core/docs/autonomy-envelope-tracker.md +111 -0
- package/dist/engineering-pa-core/docs/blueprints/README.md +45 -0
- package/dist/engineering-pa-core/docs/blueprints/docker-services/blueprint.md +168 -0
- package/dist/engineering-pa-core/docs/blueprints/git-identity/blueprint.md +116 -0
- package/dist/engineering-pa-core/docs/blueprints/ideas/custom-workspace-views.md +9 -0
- package/dist/engineering-pa-core/docs/blueprints/ideas/knowledge-domain-patterns.md +15 -0
- package/dist/engineering-pa-core/docs/blueprints/ideas/office-integration.md +11 -0
- package/dist/engineering-pa-core/docs/blueprints/ideas/team-scaling.md +9 -0
- package/dist/engineering-pa-core/docs/blueprints/ideas/work-personal-separation.md +9 -0
- package/dist/engineering-pa-core/docs/blueprints/scheduled-distillation/blueprint.md +129 -0
- package/dist/engineering-pa-core/docs/blueprints/tool-integration/blueprint.md +110 -0
- package/dist/engineering-pa-core/docs/commands/init.md +159 -0
- package/dist/engineering-pa-core/docs/commands/schedule.md +98 -0
- package/dist/engineering-pa-core/docs/commands/upgrade.md +67 -0
- package/dist/engineering-pa-core/docs/entity-location-map.md +93 -0
- package/dist/engineering-pa-core/docs/knowledge-scaffold.md +121 -0
- package/dist/engineering-pa-core/docs/lock.md +155 -0
- package/dist/engineering-pa-core/docs/memory-sync.md +28 -0
- package/dist/engineering-pa-core/docs/pa-operating-model.md +457 -0
- package/dist/engineering-pa-core/docs/scheduling.md +35 -0
- package/dist/engineering-pa-core/docs/standards/README.md +24 -0
- package/dist/engineering-pa-core/docs/standards/briefing-contract.md +161 -0
- package/dist/engineering-pa-core/docs/standards/tooling.md +240 -0
- package/dist/engineering-pa-core/docs/tools/distill.md +230 -0
- package/dist/engineering-pa-core/docs/tools/memory-sync.md +98 -0
- package/dist/engineering-pa-core/docs/tools/scheduler.md +243 -0
- package/dist/engineering-pa-core/docs/tools/slice.md +150 -0
- package/dist/engineering-pa-core/docs/upstream-issue-filing.md +88 -0
- package/dist/engineering-pa-core/docs/working-directory.md +29 -0
- package/dist/engineering-pa-core/docs/workspace-operations.md +46 -0
- package/dist/engineering-pa-core/index.mjs +21 -0
- package/dist/engineering-pa-core/knowledge/README.md +53 -0
- package/dist/engineering-pa-core/knowledge/canonical/.gitkeep +0 -0
- package/dist/engineering-pa-core/knowledge/content-voice/README.md +64 -0
- package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-baseline.md +133 -0
- package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-shape.md +58 -0
- package/dist/engineering-pa-core/knowledge/content-voice/banned-ai-tells-taxonomy.md +33 -0
- package/dist/engineering-pa-core/knowledge/content-voice/content-pipeline-routing.md +58 -0
- package/dist/engineering-pa-core/knowledge/content-voice/new-tell-capture-procedure.md +40 -0
- package/dist/engineering-pa-core/knowledge/content-voice/voice-guide-shape.md +43 -0
- package/dist/engineering-pa-core/knowledge/delegation-dispatch/README.md +34 -0
- package/dist/engineering-pa-core/knowledge/delegation-dispatch/briefing-contract.md +77 -0
- package/dist/engineering-pa-core/knowledge/delegation-dispatch/delegation-modes.md +84 -0
- package/dist/engineering-pa-core/knowledge/delegation-dispatch/dispatch-envelope.md +238 -0
- package/dist/engineering-pa-core/knowledge/delegation-dispatch/task-groups-and-orchestrators.md +62 -0
- package/dist/engineering-pa-core/knowledge/delegation-dispatch/team-improvement-loop.md +48 -0
- package/dist/engineering-pa-core/knowledge/delegation-dispatch/tool-development-policy.md +48 -0
- package/dist/engineering-pa-core/knowledge/engineering-discipline/README.md +33 -0
- package/dist/engineering-pa-core/knowledge/engineering-discipline/blast-radius-and-safety.md +50 -0
- package/dist/engineering-pa-core/knowledge/engineering-discipline/investigate-root-causes.md +48 -0
- package/dist/engineering-pa-core/knowledge/engineering-discipline/role-ladder.md +57 -0
- package/dist/engineering-pa-core/knowledge/engineering-discipline/verify-runtime-behaviour.md +53 -0
- package/dist/engineering-pa-core/knowledge/engineering-discipline/worktree-isolation.md +50 -0
- package/dist/engineering-pa-core/knowledge/knowledge-memory/README.md +33 -0
- package/dist/engineering-pa-core/knowledge/knowledge-memory/claude-md-hygiene.md +47 -0
- package/dist/engineering-pa-core/knowledge/knowledge-memory/journal-and-self-improvement.md +53 -0
- package/dist/engineering-pa-core/knowledge/knowledge-memory/knowledge-retrieval-discipline.md +62 -0
- package/dist/engineering-pa-core/knowledge/knowledge-memory/librarian-synthesis-mandate.md +56 -0
- package/dist/engineering-pa-core/knowledge/knowledge-memory/staging-and-observations.md +58 -0
- package/dist/engineering-pa-core/knowledge/operator-interface/README.md +32 -0
- package/dist/engineering-pa-core/knowledge/operator-interface/communication-style.md +33 -0
- package/dist/engineering-pa-core/knowledge/operator-interface/escalation-and-correction.md +45 -0
- package/dist/engineering-pa-core/knowledge/operator-interface/push-back-discipline.md +42 -0
- package/dist/engineering-pa-core/knowledge/operator-interface/session-boot-and-end.md +56 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/README.md +37 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/desk-cli-reference.md +52 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/environment-awareness.md +91 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/mcp-servers-catalogue.md +52 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/pa-cli-reference.md +55 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/scheduling-and-launchd.md +56 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/tool-discovery-discipline.md +49 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/working-directory-vs-project-root.md +43 -0
- package/dist/engineering-pa-core/knowledge/platform-tooling/workspace-server.md +51 -0
- package/dist/engineering-pa-core/knowledge/raw/.gitkeep +0 -0
- package/dist/engineering-pa-core/knowledge/scaffold/domains.json +5 -0
- package/dist/engineering-pa-core/knowledge/schema/.gitkeep +0 -0
- package/dist/engineering-pa-core/knowledge/work-management/README.md +33 -0
- package/dist/engineering-pa-core/knowledge/work-management/desk-cli.md +84 -0
- package/dist/engineering-pa-core/knowledge/work-management/progress-md.md +48 -0
- package/dist/engineering-pa-core/knowledge/work-management/projects.md +64 -0
- package/dist/engineering-pa-core/knowledge/work-management/spawn-log.md +48 -0
- package/dist/engineering-pa-core/knowledge/work-management/work-items.md +67 -0
- package/dist/engineering-pa-core/package.json +26 -0
- package/dist/engineering-pa-core/roles/agent-ops/definition.md +79 -0
- package/dist/engineering-pa-core/roles/dispatch-briefer/definition.md +195 -0
- package/dist/engineering-pa-core/roles/librarian/definition.md +176 -0
- package/dist/engineering-pa-core/roles/pa/definition.md +93 -0
- package/dist/engineering-pa-core/roles/system-admin/definition.md +106 -0
- package/dist/engineering-pa-core/roles/tool-developer/definition.md +116 -0
- package/dist/engineering-pa-core/roles/writer/definition.md +130 -0
- package/dist/engineering-pa-core/schema/autonomy-envelope.schema.json +61 -0
- package/dist/engineering-pa-core/schema/project.json +114 -0
- package/dist/engineering-pa-core/schema/schedule.json +63 -0
- package/dist/engineering-pa-core/schema/work-item.json +92 -0
- package/dist/engineering-pa-core/src/config/pa-package-name.mjs +30 -0
- package/dist/engineering-pa-core/src/desk/cli.mjs +95 -0
- package/dist/engineering-pa-core/src/desk/commands.mjs +398 -0
- package/dist/engineering-pa-core/src/desk/format.mjs +84 -0
- package/dist/engineering-pa-core/src/desk/repo.mjs +172 -0
- package/dist/engineering-pa-core/src/desk/team-scope.mjs +142 -0
- package/dist/engineering-pa-core/src/distill/cli.mjs +112 -0
- package/dist/engineering-pa-core/src/distill/prereqs.mjs +171 -0
- package/dist/engineering-pa-core/src/distill/prompt.mjs +74 -0
- package/dist/engineering-pa-core/src/distill/run.mjs +230 -0
- package/dist/engineering-pa-core/src/distill/runner.mjs +86 -0
- package/dist/engineering-pa-core/src/distill/staging.mjs +59 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/backlink-indexer.mjs +154 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/graph-builder.mjs +186 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/index-builder.mjs +225 -0
- package/dist/engineering-pa-core/src/knowledge/retrieval/query.mjs +263 -0
- package/dist/engineering-pa-core/src/lock/lock.mjs +202 -0
- package/dist/engineering-pa-core/src/memory-sync/cli-support.mjs +12 -0
- package/dist/engineering-pa-core/src/memory-sync/cli.mjs +56 -0
- package/dist/engineering-pa-core/src/memory-sync/commands/pull.mjs +44 -0
- package/dist/engineering-pa-core/src/memory-sync/commands/push.mjs +46 -0
- package/dist/engineering-pa-core/src/memory-sync/mirror.mjs +114 -0
- package/dist/engineering-pa-core/src/memory-sync/repo.mjs +65 -0
- package/dist/engineering-pa-core/src/scheduler/detect.mjs +111 -0
- package/dist/engineering-pa-core/src/scheduler/host.mjs +38 -0
- package/dist/engineering-pa-core/src/scheduler/index.mjs +13 -0
- package/dist/engineering-pa-core/src/scheduler/install.mjs +854 -0
- package/dist/engineering-pa-core/src/scheduler/list.mjs +168 -0
- package/dist/engineering-pa-core/src/scheduler/remove.mjs +176 -0
- package/dist/engineering-pa-core/src/scheduler/verify.mjs +435 -0
- package/dist/engineering-pa-core/src/session-boot/autonomy-injection.mjs +84 -0
- package/dist/engineering-pa-core/src/slice/cli.mjs +139 -0
- package/dist/engineering-pa-core/src/slice/inputs.mjs +174 -0
- package/dist/engineering-pa-core/src/slice/prompt.mjs +94 -0
- package/dist/engineering-pa-core/src/slice/prompts/dave-boot.md +91 -0
- package/dist/engineering-pa-core/src/slice/run.mjs +334 -0
- package/dist/engineering-pa-core-extensions-atlassian/README.md +31 -0
- package/dist/engineering-pa-core-extensions-atlassian/index.mjs +9 -0
- package/dist/engineering-pa-core-extensions-atlassian/package.json +19 -0
- package/dist/engineering-pa-core-extensions-n8n/README.md +29 -0
- package/dist/engineering-pa-core-extensions-n8n/index.mjs +8 -0
- package/dist/engineering-pa-core-extensions-n8n/package.json +19 -0
- package/dist/engineering-pa-librarian/README.md +38 -0
- package/dist/engineering-pa-librarian/index.mjs +12 -0
- package/dist/engineering-pa-librarian/package.json +19 -0
- package/dist/engineering-pa-migrate-inspect/README.md +131 -0
- package/dist/engineering-pa-migrate-inspect/bin/migrate-inspect +2 -0
- package/dist/engineering-pa-migrate-inspect/package.json +21 -0
- package/dist/engineering-pa-migrate-inspect/src/age-key-migrate.mjs +83 -0
- package/dist/engineering-pa-migrate-inspect/src/cli.mjs +97 -0
- package/dist/engineering-pa-migrate-inspect/src/inspector.mjs +237 -0
- package/dist/engineering-pa-migrate-inspect/src/platforms/linux.mjs +87 -0
- package/dist/engineering-pa-migrate-inspect/src/platforms/macos.mjs +105 -0
- package/dist/engineering-pa-migrate-inspect/src/runner.mjs +87 -0
- package/dist/engineering-pa-runbook/README.md +103 -0
- package/dist/engineering-pa-runbook/package.json +18 -0
- package/dist/engineering-pa-runbook/reference/dex-template.runbook.json +66 -0
- package/dist/engineering-pa-runbook/reference/hq-dave.runbook.json +69 -0
- package/dist/engineering-pa-runbook/reference/smarthome01.runbook.json +92 -0
- package/dist/engineering-pa-runbook/schema/inspection.schema.json +113 -0
- package/dist/engineering-pa-runbook/schema/runbook.schema.json +67 -0
- package/dist/engineering-pa-runbook/src/validator.mjs +167 -0
- package/dist/engineering-pa-scaffold/README.md +164 -0
- package/dist/engineering-pa-scaffold/bin/pa-init +2 -0
- package/dist/engineering-pa-scaffold/index.mjs +42 -0
- package/dist/engineering-pa-scaffold/package.json +22 -0
- package/dist/engineering-pa-scaffold/src/cli.mjs +115 -0
- package/dist/engineering-pa-scaffold/src/commands/init.mjs +169 -0
- package/dist/engineering-pa-scaffold/src/generator.mjs +354 -0
- package/dist/engineering-pa-scaffold/src/prompts.mjs +125 -0
- package/dist/engineering-pa-scaffold/src/runner.mjs +142 -0
- package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +196 -0
- package/dist/engineering-pa-scaffold/templates/operator-README.md.template +44 -0
- package/dist/engineering-pa-types-engineering/README.md +35 -0
- package/dist/engineering-pa-types-engineering/docs/commands/product-build.md +120 -0
- package/dist/engineering-pa-types-engineering/index.mjs +9 -0
- package/dist/engineering-pa-types-engineering/package.json +19 -0
- package/dist/engineering-pa-types-engineering/roles/product-engineer/definition.md +650 -0
- package/dist/engineering-pa-types-engineering/roles/product-engineer/templates/build-spec.md +82 -0
- package/dist/engineering-pa-types-engineering/roles/product-engineer/templates/preflight-checklist.md +158 -0
- package/dist/engineering-pa-types-engineering/roles/product-engineer/templates/worker-brief.md +130 -0
- package/dist/engineering-pa-workspace/README.md +197 -0
- package/dist/engineering-pa-workspace/app/assets/md.js +187 -0
- package/dist/engineering-pa-workspace/app/assets/modal.js +120 -0
- package/dist/engineering-pa-workspace/app/assets/theme.css +132 -0
- package/dist/engineering-pa-workspace/app/assets/theme.js +159 -0
- package/dist/engineering-pa-workspace/app/banner.js +320 -0
- package/dist/engineering-pa-workspace/app/delegations.html +345 -0
- package/dist/engineering-pa-workspace/app/home.html +1060 -0
- package/dist/engineering-pa-workspace/app/index.html +361 -0
- package/dist/engineering-pa-workspace/app/knowledge-map.html +163 -0
- package/dist/engineering-pa-workspace/app/knowledge.html +304 -0
- package/dist/engineering-pa-workspace/app/nav.json +75 -0
- package/dist/engineering-pa-workspace/app/notes.html +199 -0
- package/dist/engineering-pa-workspace/app/onboarding.html +592 -0
- package/dist/engineering-pa-workspace/app/profile.html +243 -0
- package/dist/engineering-pa-workspace/app/project-detail.html +432 -0
- package/dist/engineering-pa-workspace/app/projects.html +297 -0
- package/dist/engineering-pa-workspace/app/repos.html +598 -0
- package/dist/engineering-pa-workspace/app/schedules.html +486 -0
- package/dist/engineering-pa-workspace/app/secrets.html +164 -0
- package/dist/engineering-pa-workspace/app/staging.html +319 -0
- package/dist/engineering-pa-workspace/app/tasks.html +465 -0
- package/dist/engineering-pa-workspace/app/vendor/chart.umd.min.js +21 -0
- package/dist/engineering-pa-workspace/app/viewer.html +116 -0
- package/dist/engineering-pa-workspace/docs/DAEMON.md +104 -0
- package/dist/engineering-pa-workspace/docs/page-blueprint.md +286 -0
- package/dist/engineering-pa-workspace/docs/status-mon.md +168 -0
- package/dist/engineering-pa-workspace/index.mjs +12 -0
- package/dist/engineering-pa-workspace/nav.json.example +8 -0
- package/dist/engineering-pa-workspace/package.json +27 -0
- package/dist/engineering-pa-workspace/src/api.mjs +2086 -0
- package/dist/engineering-pa-workspace/src/install-daemon.mjs +573 -0
- package/dist/engineering-pa-workspace/src/nav.mjs +190 -0
- package/dist/engineering-pa-workspace/src/secrets-api.mjs +175 -0
- package/dist/engineering-pa-workspace/src/server.mjs +516 -0
- package/dist/engineering-pa-workspace/src/status-mon/poll.mjs +220 -0
- package/dist/engineering-pa-workspace/src/status-mon/poller.mjs +113 -0
- package/dist/engineering-pa-workspace/src/status-mon/run.mjs +133 -0
- package/dist/engineering-pa-workspace/src/supervisor.mjs +264 -0
- package/dist/engineering-pa-workspace/src/ws.mjs +633 -0
- package/index.mjs +60 -0
- package/package.json +58 -0
- package/templates/banned-ai-tells.md +102 -0
- package/templates/purpose.md +105 -0
- 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
|
+
|