elanous 0.2.16 → 0.2.18
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/apps/pwa/out/404/index.html +1 -1
- package/apps/pwa/out/404.html +1 -1
- package/apps/pwa/out/_next/static/P6YTVgzuZz5oTZhY4Din_/_buildManifest.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{1307-b46d45b705e8c9ae.js → 1307-30dff22c9e1f3073.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/195-fb1d7690bf30bab4.js +1 -0
- package/apps/pwa/out/_next/static/chunks/1986-33e13d648a9df765.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2537-6c289376caa88ca1.js +1 -0
- package/apps/pwa/out/_next/static/chunks/2699-325cc923ba7b8631.js +1 -0
- package/apps/pwa/out/_next/static/chunks/3857-4f936cdb30322aa6.js +1 -0
- package/apps/pwa/out/_next/static/chunks/4554-5761612531d0146b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/4565-3d7e3fdeb43686a8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/5977-a11f37254196b659.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{6158.9c0449a9a8d0064b.js → 6158.af2e93b7adff709b.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/6877-74399f69c7da1433.js +1 -0
- package/apps/pwa/out/_next/static/chunks/7797-df5aa0381fdc078f.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8401-8615a660c84cdb03.js +1 -0
- package/apps/pwa/out/_next/static/chunks/8695-846034ce44852c70.js +1 -0
- package/apps/pwa/out/_next/static/chunks/9247-269e4464cc4b491c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-d92f7ee3994e84c7.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/autopilot/{page-3cb52e5ea8fc2fa5.js → page-4733c967b56a4277.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-d22fd849cc22f104.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/botlab/{page-c1e20d7d338713f1.js → page-bb742f995c6769e7.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/bots/{page-482c5dbde699947c.js → page-02b073a2ac793b83.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-60c3da708f9a0ccc.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/chat/page-8a9b3594bc23289b.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/consult/page-a0eb02638b1e6f31.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/control/{page-a0e2a6d1bfb52fc5.js → page-308111abadf35670.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/decisions/page-6eaf7bac37830c21.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/design-check/{page-c53598cfce6156f2.js → page-5d97b2901e0d1ea6.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/editor/{page-13a63953f53c6a23.js → page-c39554c321576982.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/exec/{page-5d3c8429d04f3831.js → page-8c3d8e63ae2e1c98.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/field/{page-3193bdc0bbab047a.js → page-9b72a07d424701c6.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-bd5d586429167fea.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/intake/{page-6107ca8ddd5e3bdd.js → page-a572f58cd3ca5864.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-59b7834d497c2255.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/live/page-be5ea965a07fbc41.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/loops/page-c130b63862437814.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/market/page-fa244bd146a00ae8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-44d56d6391965a6a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/morning/{page-4167348049166852.js → page-83a700d4c754d802.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/{not-found-de52e8fdf9095f7a.js → not-found-7b3170f374f77b9d.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-5f23d0c66a3b4793.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/checklist/{page-ca3952cd78a83ca2.js → page-a3c58f5c72a5f3f9.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-a2c4625596f8d69c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-0e9099531d239f11.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-e8e1ecaea7cd71d2.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/page-a1b7ffed710a93e0.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/reflection/{page-1ebfe049c640e171.js → page-1467047104141297.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-2da6f9b8a38c6978.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-102310930791197a.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-f331e38fefc00393.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/settings/page-6ed0b73bae8351d8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/setup/done/{page-abe8e3b74bea1ffc.js → page-44d5f767491692bf.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-43336055451f6e37.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/share/{page-d28573d6792b4275.js → page-3cc47dca0922ab9a.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-a77ee355a2304ff0.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/tasks/{page-d751de5f8062ff5e.js → page-b17cd46cf0e9b8cf.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/term/{page-fe1759f94003b32b.js → page-61553851aa606146.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/today/{page-d64c984e855c8c68.js → page-a1f585e154e61c59.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-7bf54c9bb9274af8.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/vault/{page-9afdee66b820c76e.js → page-5150ef17b5383a57.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-b9d8ce70792c1672.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-a5a3de231f2cc7d5.js +1 -0
- package/apps/pwa/out/_next/static/chunks/app/workspace/{page-3544f154c87b43ee.js → page-134da468538baf48.js} +1 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-31ac4afa2dcd915c.js +1 -0
- package/apps/pwa/out/_next/static/chunks/{webpack-539cde99554be20f.js → webpack-c2782b330447ffb6.js} +1 -1
- package/apps/pwa/out/_next/static/css/7fa358fec7da7c47.css +1 -0
- package/apps/pwa/out/approvals/index.html +1 -1
- package/apps/pwa/out/approvals/index.txt +14 -14
- package/apps/pwa/out/autopilot/index.html +1 -1
- package/apps/pwa/out/autopilot/index.txt +14 -14
- package/apps/pwa/out/board/index.html +1 -1
- package/apps/pwa/out/board/index.txt +14 -14
- package/apps/pwa/out/botlab/index.html +1 -1
- package/apps/pwa/out/botlab/index.txt +14 -14
- package/apps/pwa/out/bots/index.html +1 -1
- package/apps/pwa/out/bots/index.txt +14 -14
- package/apps/pwa/out/ceo/index.html +1 -1
- package/apps/pwa/out/ceo/index.txt +14 -14
- package/apps/pwa/out/chat/index.html +1 -1
- package/apps/pwa/out/chat/index.txt +14 -14
- package/apps/pwa/out/consult/index.html +1 -1
- package/apps/pwa/out/consult/index.txt +14 -14
- package/apps/pwa/out/control/index.html +1 -1
- package/apps/pwa/out/control/index.txt +14 -14
- package/apps/pwa/out/decisions/index.html +1 -0
- package/apps/pwa/out/decisions/index.txt +30 -0
- package/apps/pwa/out/design-check/index.html +1 -1
- package/apps/pwa/out/design-check/index.txt +14 -14
- package/apps/pwa/out/editor/index.html +1 -1
- package/apps/pwa/out/editor/index.txt +14 -14
- package/apps/pwa/out/exec/index.html +1 -1
- package/apps/pwa/out/exec/index.txt +14 -14
- package/apps/pwa/out/field/index.html +1 -1
- package/apps/pwa/out/field/index.txt +14 -14
- package/apps/pwa/out/healthz +1 -1
- package/apps/pwa/out/index.html +1 -1
- package/apps/pwa/out/index.txt +14 -14
- package/apps/pwa/out/inside/index.html +1 -1
- package/apps/pwa/out/inside/index.txt +14 -14
- package/apps/pwa/out/intake/index.html +1 -1
- package/apps/pwa/out/intake/index.txt +14 -14
- package/apps/pwa/out/live/index.html +1 -1
- package/apps/pwa/out/live/index.txt +14 -14
- package/apps/pwa/out/loops/index.html +1 -1
- package/apps/pwa/out/loops/index.txt +14 -14
- package/apps/pwa/out/market/index.html +1 -1
- package/apps/pwa/out/market/index.txt +14 -14
- package/apps/pwa/out/missions/placeholder/index.html +1 -1
- package/apps/pwa/out/missions/placeholder/index.txt +14 -14
- package/apps/pwa/out/morning/index.html +1 -1
- package/apps/pwa/out/morning/index.txt +14 -14
- package/apps/pwa/out/observatory/index.html +1 -1
- package/apps/pwa/out/observatory/index.txt +14 -14
- package/apps/pwa/out/ops/checklist/index.html +1 -1
- package/apps/pwa/out/ops/checklist/index.txt +14 -14
- package/apps/pwa/out/ops/release/index.html +1 -1
- package/apps/pwa/out/ops/release/index.txt +14 -14
- package/apps/pwa/out/ops/seats/index.html +1 -1
- package/apps/pwa/out/ops/seats/index.txt +14 -14
- package/apps/pwa/out/outputs/index.html +1 -1
- package/apps/pwa/out/outputs/index.txt +14 -14
- package/apps/pwa/out/reflection/index.html +1 -1
- package/apps/pwa/out/reflection/index.txt +15 -15
- package/apps/pwa/out/scheduler/index.html +1 -1
- package/apps/pwa/out/scheduler/index.txt +14 -14
- package/apps/pwa/out/sessions/index.html +1 -1
- package/apps/pwa/out/sessions/index.txt +14 -14
- package/apps/pwa/out/settings/devices/index.html +1 -1
- package/apps/pwa/out/settings/devices/index.txt +14 -14
- package/apps/pwa/out/settings/index.html +1 -1
- package/apps/pwa/out/settings/index.txt +14 -14
- package/apps/pwa/out/setup/done/index.html +1 -1
- package/apps/pwa/out/setup/done/index.txt +14 -14
- package/apps/pwa/out/setup/index.html +1 -1
- package/apps/pwa/out/setup/index.txt +14 -14
- package/apps/pwa/out/share/index.html +1 -1
- package/apps/pwa/out/share/index.txt +14 -14
- package/apps/pwa/out/showroom/index.html +1 -1
- package/apps/pwa/out/showroom/index.txt +14 -14
- package/apps/pwa/out/tasks/index.html +1 -1
- package/apps/pwa/out/tasks/index.txt +14 -14
- package/apps/pwa/out/term/index.html +1 -1
- package/apps/pwa/out/term/index.txt +14 -14
- package/apps/pwa/out/today/index.html +1 -1
- package/apps/pwa/out/today/index.txt +14 -14
- package/apps/pwa/out/trace/index.html +1 -1
- package/apps/pwa/out/trace/index.txt +14 -14
- package/apps/pwa/out/vault/index.html +1 -1
- package/apps/pwa/out/vault/index.txt +14 -14
- package/apps/pwa/out/workflows/chat-ui/index.html +1 -1
- package/apps/pwa/out/workflows/chat-ui/index.txt +14 -14
- package/apps/pwa/out/workflows/index.html +1 -1
- package/apps/pwa/out/workflows/index.txt +14 -14
- package/apps/pwa/out/workspace/index.html +1 -1
- package/apps/pwa/out/workspace/index.txt +14 -14
- package/apps/pwa/out/worktrees/index.html +1 -1
- package/apps/pwa/out/worktrees/index.txt +14 -14
- package/graphs/intake/intake-daily.yaml +9 -2
- package/graphs/intake/news-sources.yaml +4 -0
- package/graphs/intake/recipes.yaml +6 -1
- package/package.json +2 -1
- package/samples/workflows/templates/approval-gated-deploy.yaml +30 -0
- package/samples/workflows/templates/chat-driven-research.yaml +35 -0
- package/samples/workflows/templates/discord-listen-and-respond.yaml +29 -0
- package/samples/workflows/templates/http-poll-then-alert.yaml +30 -0
- package/samples/workflows/templates/manual-quickstart.yaml +21 -0
- package/samples/workflows/templates/scheduled-daily-digest.yaml +30 -0
- package/samples/workflows/templates/webhook-deploy-hook.yaml +27 -0
- package/scripts/__fixtures__/intake-news.xml +7 -0
- package/scripts/ci-public-export-import-gate.ts +7 -2
- package/scripts/ci-typecheck-changed.ts +21 -6
- package/scripts/discord-seat-probe.ts +3 -2
- package/scripts/docs/generate-cli-anatomy.ts +162 -0
- package/scripts/goals/authoring-metrics.ts +73 -0
- package/scripts/intake-news.ts +169 -0
- package/scripts/landing-heal/must-fix-match.ts +7 -7
- package/scripts/landing-heal/run-mission.ts +3 -2
- package/scripts/public-export-test-run.ts +37 -2
- package/scripts/release-loop/checklist-gate-node.ts +13 -11
- package/scripts/release-loop/cut-branch.ts +2 -1
- package/scripts/release-loop/cutoff.ts +4 -2
- package/scripts/release-loop/docs-land-node.ts +77 -11
- package/scripts/release-loop/docs-node.ts +1 -1
- package/scripts/release-loop/gate-node.ts +99 -14
- package/scripts/release-loop/node-verdict.ts +2 -1
- package/scripts/release-loop/npm-publish-node.ts +11 -7
- package/scripts/release-loop/ops-upgrade-node.ts +8 -1
- package/scripts/release-loop/preflight.ts +246 -0
- package/scripts/release-loop/publish-node.ts +10 -3
- package/scripts/release-loop/release-docs.ts +134 -0
- package/scripts/release-loop/release-readiness.ts +11 -6
- package/scripts/release-loop/release-version.ts +48 -0
- package/scripts/release-loop/resume-release.ts +115 -0
- package/scripts/release-loop/unattended-release.ts +43 -7
- package/scripts/release-loop/version-node.ts +149 -41
- package/scripts/release-story/draft.ts +42 -14
- package/scripts/rhythm/daily.ts +25 -4
- package/scripts/skills/public-check.ts +62 -12
- package/scripts/test-deterministic.ts +51 -33
- package/scripts/test-diet/effectiveness.ts +62 -1
- package/scripts/test-diet/lib.ts +1 -1
- package/scripts/test-diet/node.ts +8 -1
- package/src/agent-mission/driver.ts +53 -10
- package/src/agent-mission/review-loop.ts +4 -3
- package/src/agent-substrate/pr-comment-meta.ts +20 -0
- package/src/autopilot/build/llm-conflict-merge.ts +28 -8
- package/src/autopilot/track-agent.ts +5 -3
- package/src/briefing/brief-items.ts +86 -17
- package/src/cli/autopilot-cli.ts +86 -1
- package/src/cli/brief-cli.ts +36 -2
- package/src/cli/decisions-cli.ts +4 -1
- package/src/cli/freeze-cli.ts +3 -2
- package/src/cli/intake-cli.ts +74 -2
- package/src/cli/log-event-names.ts +1 -0
- package/src/cli/notify-cli.ts +75 -0
- package/src/cli/pr-cli.ts +12 -1
- package/src/cli/provider-cli.ts +24 -0
- package/src/cli/release-cli.ts +101 -17
- package/src/cli/self-update-canary.ts +46 -0
- package/src/cli/self-update.ts +200 -1
- package/src/cli/tasks-cli.ts +309 -3
- package/src/context-bus/condense.ts +19 -3
- package/src/context-bus/context-day.ts +77 -24
- package/src/context-bus/context-first.ts +33 -0
- package/src/context-card/index.ts +45 -0
- package/src/context-card/task.ts +76 -0
- package/src/dashboard/slash-runtime/dashboard-handlers.ts +2 -0
- package/src/decisions/decision-ledger.ts +10 -1
- package/src/discord-self-message.ts +20 -2
- package/src/discord-slash-wire.ts +1 -0
- package/src/domains/outbound-alert.ts +200 -27
- package/src/domains/outbound-log-probe.ts +16 -1
- package/src/domains/release-tool.ts +2 -2
- package/src/execution-loop/gates.ts +11 -2
- package/src/flow-loop/split.ts +8 -4
- package/src/git-fs/worktree.ts +23 -3
- package/src/harness/harness-cli-command.ts +151 -11
- package/src/harness/harness-pod-dispatch.ts +11 -0
- package/src/harness/harness-queue.ts +466 -32
- package/src/harness/helper-scan.ts +10 -3
- package/src/harness/nested-elanous-policy.ts +23 -4
- package/src/hq/fence-wrapper.ts +123 -0
- package/src/hq/hq.ts +8 -0
- package/src/hq/seats.ts +369 -0
- package/src/index.ts +190 -23
- package/src/intake-plane/adopt-gate.ts +49 -0
- package/src/intake-plane/check.ts +3 -0
- package/src/intake-plane/collect-github.ts +57 -25
- package/src/intake-plane/digest.ts +39 -4
- package/src/intake-plane/discord-seat-work.ts +8 -2
- package/src/intake-plane/requirement-funnel.ts +316 -0
- package/src/knowledge/know-find.ts +10 -1
- package/src/loops/budget.ts +36 -0
- package/src/loops/orchestrator/finish-rate.ts +73 -1
- package/src/loops/orchestrator/grid-place.ts +11 -0
- package/src/loops/orchestrator/tick.ts +295 -20
- package/src/maturity/feature-maturity.ts +3 -2
- package/src/nexus/api/decisions-route.ts +11 -1
- package/src/nexus/api/grid.ts +61 -0
- package/src/nexus/api/http-server.ts +19 -1
- package/src/nexus/api/loop-edges.ts +69 -13
- package/src/nexus/api/ops-seats-sources.ts +2 -2
- package/src/nexus/api/task-cards-api.ts +19 -0
- package/src/nexus/api/workflow-templates.ts +3 -0
- package/src/oauth/codex.ts +120 -2
- package/src/onboarding/first-chat-gate.ts +29 -0
- package/src/release-loop/checklist.ts +104 -6
- package/src/release-loop/feature-store.ts +10 -9
- package/src/release-loop/landing-freeze.ts +24 -14
- package/src/release-loop/placement.ts +13 -3
- package/src/release-loop/rubric.ts +80 -0
- package/src/seat-dispatch/seat-ask.ts +79 -16
- package/src/seat-loop/seat-loop.ts +107 -37
- package/src/self-dev/ask-launch-flow.ts +50 -10
- package/src/self-dev/ask-path-candidates.ts +68 -0
- package/src/self-dev/dev-pipeline.ts +4 -0
- package/src/self-dev/draft-triage-rules.ts +1 -1
- package/src/self-dev/orchestrate-cli.ts +22 -0
- package/src/self-dev/round-pr-supersede.ts +244 -0
- package/src/self-dev/run-store.ts +5 -1
- package/src/self-dev/run-supervisor.ts +21 -1
- package/src/self-dev/supervisor-verdict-edges.ts +46 -0
- package/src/self-implement/follow-up-goals.ts +2 -2
- package/src/self-implement/frozen-merges.ts +25 -20
- package/src/self-implement/gate-remote.ts +427 -0
- package/src/self-implement/goal-author-clarification.ts +81 -0
- package/src/self-implement/goal-author-cli.ts +33 -5
- package/src/self-implement/goal-author.ts +93 -12
- package/src/self-implement/goal-clarification-escalation.ts +37 -1
- package/src/self-implement/goal-document-write.ts +28 -0
- package/src/self-implement/heal-intake.ts +4 -0
- package/src/self-implement/host-regate.ts +3 -1
- package/src/self-implement/l8-merge-queue.ts +29 -5
- package/src/self-implement/orchestrator.ts +78 -13
- package/src/self-implement/prior-draft-findings.ts +4 -3
- package/src/skills/skill-feedback.ts +76 -0
- package/src/task-agent/actions.ts +151 -0
- package/src/task-agent/judge.ts +122 -0
- package/src/task-agent/mission.ts +423 -0
- package/src/task-agent/shadow.ts +90 -0
- package/src/task-agent/task-hand.ts +235 -0
- package/src/task-cards/card-store.ts +4 -1
- package/src/task-orchestrator/surfaces/pod-failure-reason.ts +43 -4
- package/src/task-orchestrator/surfaces/pod-lease.ts +5 -2
- package/src/task-orchestrator/surfaces/pod-pool.ts +81 -13
- package/src/task-orchestrator/surfaces/self-implement-pod.ts +225 -35
- package/src/telegram-commands.ts +1 -1
- package/src/telegram-loops-command.ts +22 -15
- package/src/telegram.ts +5 -20
- package/src/user-config.ts +86 -4
- package/src/version/packed-revision.json +1 -1
- package/apps/pwa/out/_next/static/chunks/2699-63b73fa4d3bcf6ff.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4554-3565eea1f1495ba7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/4614-21403aac730d4586.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5135-44008fe8b1f2aa41.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5233-bf28bd6bd7a32099.js +0 -1
- package/apps/pwa/out/_next/static/chunks/5977-c952712c87a1f757.js +0 -1
- package/apps/pwa/out/_next/static/chunks/6877-7c6208705c284124.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7395-164e238f471b5d7b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/7787-02a1c0b04e25e811.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8827-e2f5b09a8d37e83b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/8949-ecb4128a0858b911.js +0 -1
- package/apps/pwa/out/_next/static/chunks/899-46946fe8363e1262.js +0 -1
- package/apps/pwa/out/_next/static/chunks/9247-29d889b809288e65.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/approvals/page-79c3ed84baaedd03.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/board/page-a424ef80885d1743.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ceo/page-1e962b31d1f11081.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/chat/page-8c2d9526164f5d09.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/consult/page-ffff6d819fbc6e0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/inside/page-76270efaee638f17.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/layout-dce686e582c23937.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/live/page-d3d53b146416e422.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/loops/page-1a61c7ca1ceb3ae7.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/market/page-51f811fa119308b6.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/missions/[id]/page-dacff46049e1557e.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/observatory/page-36a7d994f4ca0299.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/release/page-5b042736aa113756.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/ops/seats/page-acb749f1e432bcf3.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/outputs/page-cd78862707999911.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/page-54b9ef8afe6278fd.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/scheduler/page-57924393e3e4e539.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/sessions/page-9e0ce6c8129fa641.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/devices/page-31fa1ce4c4aff9ae.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/settings/page-0f5cd57ca928f42b.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/setup/page-4364ef9f4e7c7f0f.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/showroom/page-59f03fda76c03d22.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/trace/page-86914b0dee8ab7f8.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/chat-ui/page-d4aef67abf264874.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/workflows/page-1ea64acadd704ace.js +0 -1
- package/apps/pwa/out/_next/static/chunks/app/worktrees/page-30c2073bfc21c004.js +0 -1
- package/apps/pwa/out/_next/static/css/001d5fbaee388a25.css +0 -1
- package/apps/pwa/out/_next/static/rv9cierV0jSOvDBm1g0XB/_buildManifest.js +0 -1
- /package/apps/pwa/out/_next/static/{rv9cierV0jSOvDBm1g0XB → P6YTVgzuZz5oTZhY4Din_}/_ssgManifest.js +0 -0
|
@@ -81,9 +81,16 @@ export function classifyStoppedPr({ body, comments }: { body: string; comments:
|
|
|
81
81
|
action = '증거 재측 — 인용된 판정 명령을 호스트에서 다시 돌려 결과를 PR 에 남김; 코드 수정 아님';
|
|
82
82
|
} else if (/review-budget/i.test(stopEvidence) || basis === 'must-fix-reported') {
|
|
83
83
|
// Real supervisor bodies (UNCONVERGEABLE · implementation-deficit) carry `classificationBasis: must-fix-reported` instead of the RFC word.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
const previous = latest && reviews.find((review) => review.round === latest.round - 1);
|
|
85
|
+
if (latest && previous && latest.round >= 2 && latest.count > 0 && latest.count >= previous.count) {
|
|
86
|
+
category = 'review-oscillation';
|
|
87
|
+
signals.push(`must-fix ${previous.count}→${latest.count} (round ${previous.round}→${latest.round}) → review-oscillation`);
|
|
88
|
+
action = '헬퍼 자식 spawn — 기존 시험 설계 보존 후 한 라운드';
|
|
89
|
+
} else {
|
|
90
|
+
if (basis === 'must-fix-reported') signals.push('classificationBasis=must-fix-reported → review-budget');
|
|
91
|
+
category = 'review-budget';
|
|
92
|
+
action = latest?.count ? '헬퍼 자식 spawn — must-fix 원문과 기존 시험 설계로 한 라운드' : '하지 않음 — must-fix 0 또는 미관측; 수리 자식 대상 아님';
|
|
93
|
+
}
|
|
87
94
|
} else if (/no-progress/i.test(stopEvidence) || basis === 'no-must-fix-without-clean-worktree-or-completed-without-changes') {
|
|
88
95
|
if (basis === 'no-must-fix-without-clean-worktree-or-completed-without-changes') signals.push(`classificationBasis=${basis} → no-progress`);
|
|
89
96
|
category = 'no-progress';
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// an allow flag is ignored and the launch stays refused.
|
|
14
14
|
|
|
15
15
|
import { debug } from '../debug/log.js';
|
|
16
|
+
import { getUserConfig } from '../user-config.js';
|
|
16
17
|
|
|
17
18
|
export const ELANOUS_NESTED_DEPTH_ENV = 'ELANOUS_NESTED_DEPTH';
|
|
18
19
|
|
|
@@ -26,7 +27,7 @@ export interface NestedElanousDecision {
|
|
|
26
27
|
readonly depth: number;
|
|
27
28
|
/** True when depth >= 1 supplied allow and it was ignored. */
|
|
28
29
|
readonly allowIgnored: boolean;
|
|
29
|
-
readonly reason: 'depth-0' | 'depth-0-allow' | 'depth-unknown' | 'nested-refused' | 'allow-ignored';
|
|
30
|
+
readonly reason: 'depth-0' | 'depth-0-allow' | 'depth-unknown' | 'nested-refused' | 'allow-ignored' | 'depth-cap';
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** Current process depth from the nested-depth marker. Unset or unreadable = 0 (outer launcher). */
|
|
@@ -56,11 +57,25 @@ export function nestedDepthCodexArgs(backendName: string, childDepth: number): s
|
|
|
56
57
|
* Allow decision. Depth 0 allows only when `allow` is true.
|
|
57
58
|
* Depth >= 1 always refuses; a supplied allow is ignored and logged.
|
|
58
59
|
*/
|
|
60
|
+
/** Nested depth cap: explicit value, else `harness.nestedElanousMaxDepth`, else 2. */
|
|
61
|
+
export function resolveNestedDepthCap(maxDepth?: number): number {
|
|
62
|
+
const configured = maxDepth ?? getUserConfig().harness?.nestedElanousMaxDepth;
|
|
63
|
+
return typeof configured === 'number' && Number.isSafeInteger(configured) && configured > 0 ? configured : 2;
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
export function decideNestedElanousLaunch(input: {
|
|
60
67
|
readonly depth: number;
|
|
61
68
|
readonly allow?: boolean;
|
|
69
|
+
readonly maxDepth?: number;
|
|
70
|
+
/** False for an ordinary mission spawn that did not request a nested launch — no cap log. */
|
|
71
|
+
readonly logCap?: boolean;
|
|
62
72
|
}): NestedElanousDecision {
|
|
63
73
|
const depth = Number.isInteger(input.depth) && input.depth >= 0 ? input.depth : 0;
|
|
74
|
+
const cap = resolveNestedDepthCap(input.maxDepth);
|
|
75
|
+
if (depth >= cap) {
|
|
76
|
+
if (input.logCap !== false) debug.log('agent-mission.nested', 'depth-cap', { depth, cap });
|
|
77
|
+
return { allowed: false, depth, allowIgnored: input.allow === true, reason: 'depth-cap' };
|
|
78
|
+
}
|
|
64
79
|
if (depth >= 1) {
|
|
65
80
|
if (input.allow === true) {
|
|
66
81
|
debug.log('agent-mission.nested', 'allow-ignored', { depth });
|
|
@@ -101,14 +116,18 @@ export function gateNestedElanousLaunch(input: {
|
|
|
101
116
|
readonly nestedLaunchRequested: boolean;
|
|
102
117
|
readonly allow?: boolean;
|
|
103
118
|
readonly configAllow?: boolean;
|
|
119
|
+
readonly maxDepth?: number;
|
|
104
120
|
}): NestedElanousDecision {
|
|
105
121
|
const depth = Number.isInteger(input.depth) && input.depth >= 0 ? input.depth : 0;
|
|
122
|
+
const allow = input.nestedLaunchRequested && (input.allow === true || (depth === 0 && input.configAllow === true));
|
|
106
123
|
if (!input.nestedLaunchRequested) {
|
|
107
|
-
return decideNestedElanousLaunch({ depth, allow: false });
|
|
124
|
+
return decideNestedElanousLaunch({ depth, allow: false, logCap: false, ...(input.maxDepth === undefined ? {} : { maxDepth: input.maxDepth }) });
|
|
108
125
|
}
|
|
109
126
|
if (!backendCarriesNestedDepth(input.backendName)) {
|
|
127
|
+
// The depth-unknown refusal stands; reaching the cap is still recorded.
|
|
128
|
+
const cap = resolveNestedDepthCap(input.maxDepth);
|
|
129
|
+
if (depth >= cap) debug.log('agent-mission.nested', 'depth-cap', { depth, cap, backend: input.backendName });
|
|
110
130
|
return { allowed: false, depth, allowIgnored: false, reason: 'depth-unknown' };
|
|
111
131
|
}
|
|
112
|
-
|
|
113
|
-
return decideNestedElanousLaunch({ depth, allow });
|
|
132
|
+
return decideNestedElanousLaunch({ depth, allow, ...(input.maxDepth === undefined ? {} : { maxDepth: input.maxDepth }) });
|
|
114
133
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, constants, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { createPatch } from 'diff';
|
|
6
|
+
|
|
7
|
+
const quote = (value: string): string => `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
8
|
+
|
|
9
|
+
/** Only the installed entrypoint runs the fence; never use a checkout as a fallback.
|
|
10
|
+
* Arbiter (cloud-vm) outage retains the current lease and forbids promotion;
|
|
11
|
+
* this wrapper does not change the fence's lease or promotion decisions.
|
|
12
|
+
* cron runs with PATH=/usr/bin:/bin, so bun is baked in as an absolute path (OP 10-06 NO-GO). */
|
|
13
|
+
export function renderHqFenceWrapper({ entry, configDir, bun, logFile }: { entry: string; configDir: string; bun: string; logFile?: string }): string {
|
|
14
|
+
if (!isAbsolute(bun)) throw new Error(`hq-fence: bun must be an absolute path: ${bun}`);
|
|
15
|
+
const log = logFile ?? join(configDir, 'logs', 'hq-fence.log');
|
|
16
|
+
return `#!/bin/sh
|
|
17
|
+
# Arbiter (cloud-vm) outage: retain the lease, do not promote; hq fence owns that decision.
|
|
18
|
+
ENTRY=${quote(entry)}
|
|
19
|
+
CONFIG_DIR=${quote(configDir)}
|
|
20
|
+
# cron has no PATH for bun — the absolute path was resolved when this wrapper was installed.
|
|
21
|
+
BUN=${quote(bun)}
|
|
22
|
+
LOG=${quote(log)}
|
|
23
|
+
# Last resort when no alert path works: one line in a log file, no bun needed.
|
|
24
|
+
log_line() {
|
|
25
|
+
mkdir -p "$(dirname "$LOG")" 2>/dev/null
|
|
26
|
+
printf '%s %s\\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "$LOG" 2>/dev/null
|
|
27
|
+
}
|
|
28
|
+
# The alert goes through the same installed entry; PATH \`elanous\` is only a fallback when the entry itself is missing.
|
|
29
|
+
# Alert bun: the installed one first, then usual absolute install locations (still no PATH lookup).
|
|
30
|
+
alert_bun() {
|
|
31
|
+
for b in "$BUN" "$HOME/.bun/bin/bun" /opt/homebrew/bin/bun /usr/local/bin/bun; do
|
|
32
|
+
if [ -x "$b" ]; then echo "$b"; return 0; fi
|
|
33
|
+
done
|
|
34
|
+
return 1
|
|
35
|
+
}
|
|
36
|
+
alert() {
|
|
37
|
+
if [ -f "$ENTRY" ] && AB=$(alert_bun); then
|
|
38
|
+
if "$AB" "$ENTRY" --config-dir "$CONFIG_DIR" hq fence-wrapper alert "$1"; then return 0; fi
|
|
39
|
+
echo "hq-fence: owner alert failed: $1" >&2
|
|
40
|
+
log_line "owner alert failed: $1"
|
|
41
|
+
return 0
|
|
42
|
+
fi
|
|
43
|
+
if command -v elanous >/dev/null 2>&1 && elanous --config-dir "$CONFIG_DIR" hq fence-wrapper alert "$1"; then return 0; fi
|
|
44
|
+
echo "hq-fence: owner alert failed: $1" >&2
|
|
45
|
+
log_line "owner alert failed: $1"
|
|
46
|
+
}
|
|
47
|
+
if [ ! -x "$BUN" ]; then
|
|
48
|
+
echo "hq-fence: bun missing or not executable: $BUN" >&2
|
|
49
|
+
# Exactly one line on disk; the alert is tried only through an absolute fallback bun (never PATH).
|
|
50
|
+
log_line "bun missing or not executable: $BUN (rc=127)"
|
|
51
|
+
if [ -f "$ENTRY" ] && AB=$(alert_bun); then
|
|
52
|
+
"$AB" "$ENTRY" --config-dir "$CONFIG_DIR" hq fence-wrapper alert "hq-fence: bun missing or not executable: $BUN (rc=127)" || echo "hq-fence: owner alert failed (fallback bun $AB)" >&2
|
|
53
|
+
fi
|
|
54
|
+
exit 127
|
|
55
|
+
fi
|
|
56
|
+
if [ ! -f "$ENTRY" ] || [ ! -r "$ENTRY" ]; then
|
|
57
|
+
echo "hq-fence: installed entrypoint missing: $ENTRY" >&2
|
|
58
|
+
alert "hq-fence: installed entrypoint missing: $ENTRY"
|
|
59
|
+
exit 1
|
|
60
|
+
fi
|
|
61
|
+
cd "$(dirname "$ENTRY")/.." || { alert "hq-fence: installed directory missing"; exit 1; }
|
|
62
|
+
if [ $# -lt 1 ] || [ -z "$1" ]; then
|
|
63
|
+
echo "hq-fence: role required" >&2
|
|
64
|
+
alert "hq-fence: role required"
|
|
65
|
+
exit 2
|
|
66
|
+
fi
|
|
67
|
+
ROLE=$1
|
|
68
|
+
shift
|
|
69
|
+
"$BUN" "$ENTRY" --config-dir "$CONFIG_DIR" hq fence --role "$ROLE" -- /bin/sh -c "$*"
|
|
70
|
+
rc=$?
|
|
71
|
+
if [ "$rc" -ne 0 ]; then alert "hq-fence: fence failed (rc=$rc, role=$ROLE)"; fi
|
|
72
|
+
exit "$rc"
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Absolute bun for the wrapper: this process when it is bun, else `command -v bun` resolved now (install time). */
|
|
77
|
+
export function resolveHqFenceBun(deps: { isBun?: boolean; execPath?: string; lookup?: () => string } = {}): string {
|
|
78
|
+
const isBun = deps.isBun ?? typeof process.versions.bun === 'string';
|
|
79
|
+
const candidate = isBun
|
|
80
|
+
? (deps.execPath ?? process.execPath)
|
|
81
|
+
: (deps.lookup ?? (() => spawnSync('/bin/sh', ['-c', 'command -v bun'], { encoding: 'utf8' }).stdout ?? ''))().trim();
|
|
82
|
+
if (!candidate || !isAbsolute(candidate)) throw new Error(`hq-fence: cannot resolve an absolute bun path (got ${JSON.stringify(candidate)})`);
|
|
83
|
+
return realpathSync(candidate);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function installedHqFenceEntry(home = homedir()): string {
|
|
87
|
+
return join(home, '.local', 'share', 'elanous', 'current', 'node_modules', 'elanous', 'bin', 'elanous.mjs');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Literal \`cd <path>\` lines only — a worktree path built from a variable is not detected. */
|
|
91
|
+
export function repositoryWorktreeCdWarning(body: string): string | undefined {
|
|
92
|
+
return body.split('\n').some(line => /^\s*cd\s+(?:['"]?[^\s'";]+\/)?(?:wt-[^\s'";]+|[^\s'";]+\/repo\.worktrees\/[^\s'";]+)(?:['"]?\s*(?:&&|\|\||;|#|$))/.test(line))
|
|
93
|
+
? 'hq fence-audit: warning — hq-fence wrapper cd targets a repository worktree' : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function installHqFenceWrapper(configDir: string, body: string, yes: boolean, deps: {
|
|
97
|
+
now?: () => Date;
|
|
98
|
+
} = {}): { path: string; preview: string; backup?: string } {
|
|
99
|
+
const path = join(configDir, 'bin', 'hq-fence');
|
|
100
|
+
let present = false;
|
|
101
|
+
try { present = !!lstatSync(path); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
|
102
|
+
if (present && !lstatSync(path).isFile()) throw new Error(`hq-fence: refusing non-regular wrapper: ${path}`);
|
|
103
|
+
const old = present ? readFileSync(path, 'utf8') : undefined;
|
|
104
|
+
const preview = `hq-fence proposed: ${path}\n${body}\n${createPatch(path, old ?? '', body, 'existing', 'proposed')}`;
|
|
105
|
+
if (!yes) return { path, preview };
|
|
106
|
+
let backup: string | undefined;
|
|
107
|
+
if (present) {
|
|
108
|
+
const backups = join(configDir, 'backups');
|
|
109
|
+
mkdirSync(backups, { recursive: true });
|
|
110
|
+
const stamp = (deps.now ?? (() => new Date()))().toISOString().replaceAll(':', '-');
|
|
111
|
+
backup = join(backups, `hq-fence.${stamp}`);
|
|
112
|
+
copyFileSync(path, backup, constants.COPYFILE_EXCL);
|
|
113
|
+
}
|
|
114
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
115
|
+
const tempDir = mkdtempSync(join(dirname(path), '.hq-fence-'));
|
|
116
|
+
try {
|
|
117
|
+
const temp = join(tempDir, 'hq-fence');
|
|
118
|
+
writeFileSync(temp, body, { flag: 'wx', mode: 0o755 });
|
|
119
|
+
chmodSync(temp, 0o755);
|
|
120
|
+
renameSync(temp, path);
|
|
121
|
+
} finally { rmSync(tempDir, { recursive: true, force: true }); }
|
|
122
|
+
return { path, preview, ...(backup ? { backup } : {}) };
|
|
123
|
+
}
|
package/src/hq/hq.ts
CHANGED
|
@@ -205,6 +205,14 @@ function mutate(r: Resolved, decide: (record: LeaseRecord | null, now: number) =
|
|
|
205
205
|
return { ok: false, reason: 'lost the race five times', record: null };
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
/** Snapshot for read-only consumers: never advances the seen-generation marker or CASes the arbiter. */
|
|
209
|
+
export function readHqLease(deps: HqDeps = {}) {
|
|
210
|
+
const { store } = resolve(deps);
|
|
211
|
+
const { now, raw } = store.read();
|
|
212
|
+
const record = parseLease(raw);
|
|
213
|
+
return { record, ageSeconds: record ? now - record.renewedAt : null, expired: record ? leaseExpired(record, now) : null };
|
|
214
|
+
}
|
|
215
|
+
|
|
208
216
|
export type LeaseAction = 'acquire' | 'renew' | 'status' | 'release';
|
|
209
217
|
export function hqLease(action: LeaseAction, deps: HqDeps = {}, opts: { host?: string; expectedHolder?: string; expectedGeneration?: number } = {}) {
|
|
210
218
|
const r0 = resolve(deps);
|
package/src/hq/seats.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// HQ seat folders are disposable views of one bare repository. No scheduler or human checkout is modified here.
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
|
|
4
|
+
import { runGitCommand } from '../git-fs/runner.js';
|
|
5
|
+
|
|
6
|
+
type HqSeatRole = 'OP' | 'MK' | 'TC' | 'UX';
|
|
7
|
+
const ROLES: readonly string[] = ['OP', 'MK', 'TC', 'UX'];
|
|
8
|
+
interface HouseLedger {
|
|
9
|
+
home: { kind: 'home'; path: string; source: 'cli-resolved' };
|
|
10
|
+
// `reclaiming` = the merged tip a `work done` proved before it started removing; it lets an interrupted done resume.
|
|
11
|
+
sandboxes: Record<string, { kind: 'sandbox'; path: string; owner: HqSeatRole; reclaiming?: string }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hqRoot(home = process.env.HOME): string {
|
|
15
|
+
if (!home) throw new Error('HOME is required (or pass --root)');
|
|
16
|
+
return join(home, 'elanous-hq');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// A first fetch or checkout of the real monorepo takes minutes; only fetch gets the long ceiling.
|
|
20
|
+
function git(cwd: string, ...args: string[]): string {
|
|
21
|
+
const result = runGitCommand(cwd, args, { encoding: 'utf8', timeout: args.includes('fetch') ? 1_800_000 : 600_000 });
|
|
22
|
+
if (result.status !== 0) throw new Error(`git ${args.join(' ')}: ${result.stderr.trim() || `exit ${result.status}`}`);
|
|
23
|
+
return result.stdout.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function repository(root: string): string {
|
|
27
|
+
const repo = join(root, 'repo.git');
|
|
28
|
+
if (git(repo, 'rev-parse', '--is-bare-repository') !== 'true') throw new Error(`not a bare repository: ${repo}`);
|
|
29
|
+
return repo;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ledgerPath(root: string): string { return join(root, 'house.json'); }
|
|
33
|
+
function readLedger(root: string): HouseLedger {
|
|
34
|
+
const ledger = JSON.parse(readFileSync(ledgerPath(root), 'utf8')) as HouseLedger;
|
|
35
|
+
if (!ledger.home?.path || !ledger.sandboxes || typeof ledger.sandboxes !== 'object') throw new Error('invalid house.json; refusing to guess houses');
|
|
36
|
+
return ledger;
|
|
37
|
+
}
|
|
38
|
+
function saveLedger(root: string, ledger: HouseLedger): void {
|
|
39
|
+
const path = ledgerPath(root);
|
|
40
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
41
|
+
writeFileSync(tmp, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
42
|
+
try { renameSync(tmp, path); }
|
|
43
|
+
catch (error) { rmSync(tmp, { force: true }); throw error; }
|
|
44
|
+
}
|
|
45
|
+
// The lock covers the read as well as the atomic rename: a rename alone cannot prevent lost updates.
|
|
46
|
+
// It also serializes repo.git worktree/branch mutations: the git runner retries lock contention, and a
|
|
47
|
+
// retried `worktree add -b` that already created its branch fails with "branch already exists".
|
|
48
|
+
function withLedgerLock<T>(root: string, update: () => T, waitMs = 120_000): T {
|
|
49
|
+
const lock = `${ledgerPath(root)}.lock`;
|
|
50
|
+
const deadline = Date.now() + waitMs;
|
|
51
|
+
while (true) {
|
|
52
|
+
try { mkdirSync(lock); break; }
|
|
53
|
+
catch (error) {
|
|
54
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
55
|
+
if (Date.now() >= deadline) throw new Error(`house.json lock timed out: ${lock}`);
|
|
56
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
try { return update(); }
|
|
60
|
+
finally { rmdirSync(lock); }
|
|
61
|
+
}
|
|
62
|
+
/** Caller must hold the ledger lock. */
|
|
63
|
+
function updateLedgerLocked(root: string, update: (ledger: HouseLedger) => void): void {
|
|
64
|
+
const ledger = readLedger(root);
|
|
65
|
+
update(ledger);
|
|
66
|
+
saveLedger(root, ledger);
|
|
67
|
+
}
|
|
68
|
+
function roleOf(value: string): HqSeatRole {
|
|
69
|
+
if (!ROLES.includes(value)) throw new Error(`invalid seat role: ${value} (OP|MK|TC|UX)`);
|
|
70
|
+
return value as HqSeatRole;
|
|
71
|
+
}
|
|
72
|
+
function workName(name: string): string {
|
|
73
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) throw new Error(`invalid work name: ${name}`);
|
|
74
|
+
return name;
|
|
75
|
+
}
|
|
76
|
+
function clean(path: string): boolean { return git(path, 'status', '--porcelain') === ''; }
|
|
77
|
+
function assertRegisteredWorktree(repo: string, path: string): void {
|
|
78
|
+
// The path itself must be the registered worktree: a symlink to another registered worktree
|
|
79
|
+
// (e.g. seats/TC -> work/TC-fix1) would otherwise pass and let a refresh detach that work.
|
|
80
|
+
if (lstatSync(path).isSymbolicLink()) throw new Error(`not a worktree of ${repo}: ${path} (symlink)`);
|
|
81
|
+
const own = join(realpathSync(dirname(path)), basename(path));
|
|
82
|
+
const entries = git(repo, 'worktree', 'list', '--porcelain').split('\n');
|
|
83
|
+
const registered = entries.some(line => line.startsWith('worktree ') && line.slice(9) === own);
|
|
84
|
+
if (!registered) throw new Error(`not a worktree of ${repo}: ${path}`);
|
|
85
|
+
if (realpathSync(git(path, 'rev-parse', '--git-common-dir')) !== realpathSync(repo)) {
|
|
86
|
+
throw new Error(`not a worktree of ${repo}: ${path}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const SANDBOX_IGNORE = '/.elanous-test/';
|
|
90
|
+
// The sandbox is filled by ordinary work, so every view of repo.git ignores it: `status` stays clean and
|
|
91
|
+
// `worktree remove` (no --force) deletes it with the worktree instead of refusing a merged work.
|
|
92
|
+
function ensureSandboxIgnored(repo: string): void {
|
|
93
|
+
const exclude = join(repo, 'info', 'exclude');
|
|
94
|
+
const current = existsSync(exclude) ? readFileSync(exclude, 'utf8') : '';
|
|
95
|
+
if (current.split('\n').includes(SANDBOX_IGNORE)) return;
|
|
96
|
+
mkdirSync(join(repo, 'info'), { recursive: true });
|
|
97
|
+
writeFileSync(exclude, `${current}${current && !current.endsWith('\n') ? '\n' : ''}${SANDBOX_IGNORE}\n`);
|
|
98
|
+
}
|
|
99
|
+
// A local relative origin (`../remote.git`) is relative to the calling checkout, not to repo.git. Git itself
|
|
100
|
+
// resolves it from the checkout's top level even when run from a subdirectory, so the same base is used here.
|
|
101
|
+
function callerOrigin(): string {
|
|
102
|
+
const origin = git(process.cwd(), 'remote', 'get-url', 'origin');
|
|
103
|
+
const url = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(origin);
|
|
104
|
+
const scpLike = /^[^/]*:/.test(origin);
|
|
105
|
+
if (url || scpLike || isAbsolute(origin)) return origin;
|
|
106
|
+
return resolve(git(process.cwd(), 'rev-parse', '--show-toplevel'), origin);
|
|
107
|
+
}
|
|
108
|
+
function mergedIntoMain(repo: string, commit: string): boolean {
|
|
109
|
+
const merged = runGitCommand(repo, ['merge-base', '--is-ancestor', commit, 'refs/remotes/origin/main'], { encoding: 'utf8' });
|
|
110
|
+
if (merged.status !== 0 && merged.status !== 1) throw new Error(`cannot verify merge: ${merged.stderr.trim()}`);
|
|
111
|
+
return merged.status === 0;
|
|
112
|
+
}
|
|
113
|
+
// Git lock files are created exclusively; ours carry our pid. A lock left by a killed `work done` is never
|
|
114
|
+
// removed automatically (between our check and a delete, a live git could have re-taken it): the error names it.
|
|
115
|
+
function takeLock(lock: string): void {
|
|
116
|
+
try { writeFileSync(lock, `hq-done ${process.pid}\n`, { flag: 'wx' }); }
|
|
117
|
+
catch (error) {
|
|
118
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
119
|
+
let content = '';
|
|
120
|
+
try { content = readFileSync(lock, 'utf8').trim(); } catch { /* gone or unreadable: report as held */ }
|
|
121
|
+
const owner = /^hq-done (\d+)$/.exec(content);
|
|
122
|
+
if (owner && !processAlive(Number(owner[1]))) throw new Error(`stale lock from a stopped hq work done (pid ${owner[1]}): remove ${lock} and rerun`);
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function processAlive(pid: number): boolean {
|
|
127
|
+
try { process.kill(pid, 0); return true; }
|
|
128
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === 'EPERM'; }
|
|
129
|
+
}
|
|
130
|
+
// Scoped `worktree prune`: drop only the admin entry whose checkout is `path` and is already gone.
|
|
131
|
+
function removeStaleWorktreeAdmin(repo: string, path: string): void {
|
|
132
|
+
const admins = join(repo, 'worktrees');
|
|
133
|
+
if (!existsSync(admins) || existsSync(path)) return;
|
|
134
|
+
for (const name of readdirSync(admins)) {
|
|
135
|
+
const gitdir = join(admins, name, 'gitdir');
|
|
136
|
+
if (existsSync(gitdir) && readFileSync(gitdir, 'utf8').trim() === join(path, '.git')) rmSync(join(admins, name), { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function quoteShell(value: string): string { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
140
|
+
|
|
141
|
+
/** Origin is read from the calling checkout; nothing in that checkout is moved or reconfigured. */
|
|
142
|
+
export function hqInit(options: { root?: string; home?: string } = {}): { root: string; created: boolean; cron: string } {
|
|
143
|
+
const root = resolve(options.root ?? hqRoot());
|
|
144
|
+
const repo = join(root, 'repo.git');
|
|
145
|
+
if (!options.home) throw new Error('resolved home house is required to register house.json');
|
|
146
|
+
mkdirSync(options.home, { recursive: true });
|
|
147
|
+
const home = realpathSync(options.home);
|
|
148
|
+
mkdirSync(root, { recursive: true });
|
|
149
|
+
// Concurrent inits serialize here: the repo.git setup and the first house.json registration are both
|
|
150
|
+
// decided under the ledger lock, so neither side can overwrite the other or miss a home mismatch.
|
|
151
|
+
const created = withLedgerLock(root, () => {
|
|
152
|
+
if (existsSync(ledgerPath(root))) {
|
|
153
|
+
const registered = readLedger(root).home.path;
|
|
154
|
+
if (registered !== home) throw new Error(`house.json home mismatch: registered ${registered}; requested ${home}`);
|
|
155
|
+
}
|
|
156
|
+
const fresh = !existsSync(repo);
|
|
157
|
+
if (fresh) {
|
|
158
|
+
const source = callerOrigin();
|
|
159
|
+
git(root, 'init', '--bare', repo);
|
|
160
|
+
git(repo, 'remote', 'add', 'origin', source);
|
|
161
|
+
}
|
|
162
|
+
repository(root);
|
|
163
|
+
if (!fresh) {
|
|
164
|
+
// An existing mirror must be of the repository init is run for; never re-point or reuse another one.
|
|
165
|
+
const mirrored = runGitCommand(repo, ['remote', 'get-url', 'origin'], { encoding: 'utf8' });
|
|
166
|
+
const caller = callerOrigin();
|
|
167
|
+
if (mirrored.status === 0 && mirrored.stdout.trim() !== caller) throw new Error(`repo.git mirrors ${mirrored.stdout.trim()}, not ${caller}; refusing to reuse ${root}`);
|
|
168
|
+
}
|
|
169
|
+
// An interrupted first fetch leaves a bare repository but no origin/main. Finish it on retry.
|
|
170
|
+
const remote = runGitCommand(repo, ['remote', 'get-url', 'origin'], { encoding: 'utf8' });
|
|
171
|
+
if (remote.status !== 0) {
|
|
172
|
+
// Only an empty, just-initialized repo.git (an init stopped before `remote add`) is finished here.
|
|
173
|
+
if (git(repo, 'for-each-ref') !== '' || existsSync(join(repo, 'worktrees'))) throw new Error(`repo.git has no origin but is not empty; refusing to adopt ${repo}`);
|
|
174
|
+
const source = callerOrigin();
|
|
175
|
+
git(repo, 'remote', 'add', 'origin', source);
|
|
176
|
+
}
|
|
177
|
+
const main = runGitCommand(repo, ['rev-parse', '--verify', 'refs/remotes/origin/main'], { encoding: 'utf8' });
|
|
178
|
+
if (fresh || remote.status !== 0 || main.status !== 0) {
|
|
179
|
+
git(repo, 'config', 'remote.origin.fetch', '+refs/heads/*:refs/remotes/origin/*');
|
|
180
|
+
git(repo, 'fetch', 'origin');
|
|
181
|
+
}
|
|
182
|
+
git(repo, 'rev-parse', '--verify', 'refs/remotes/origin/main');
|
|
183
|
+
if (!existsSync(ledgerPath(root))) {
|
|
184
|
+
saveLedger(root, { home: { kind: 'home', path: home, source: 'cli-resolved' }, sandboxes: {} });
|
|
185
|
+
}
|
|
186
|
+
return fresh;
|
|
187
|
+
}, 2_100_000); // longer than the 30-minute first fetch, so a concurrent init reaches the home check
|
|
188
|
+
// Print only: OP installs the line. Only existing role views can be refreshed; init never creates seats.
|
|
189
|
+
// cron runs with a minimal PATH, so the line names this runtime and CLI entry by absolute path.
|
|
190
|
+
const cli = [process.execPath, process.argv[1]].filter((part): part is string => Boolean(part)).map(part => quoteShell(resolve(part))).join(' ');
|
|
191
|
+
const script = `git --git-dir=${quoteShell(repo)} fetch origin && ${ROLES.map(role => `( [ ! -d ${quoteShell(join(root, 'seats', role))} ] || ${cli} hq seat ${role} --refresh --root ${quoteShell(root)} )`).join(' && ')}`;
|
|
192
|
+
const cron = `*/5 * * * * ${script}`;
|
|
193
|
+
return { root, created, cron };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function hqSeat(value: string, options: { root?: string; refresh?: boolean } = {}): { path: string; outcome: 'created' | 'unchanged' | 'refreshed' | 'dirty' } {
|
|
197
|
+
const role = roleOf(value);
|
|
198
|
+
const root = resolve(options.root ?? hqRoot());
|
|
199
|
+
const repo = repository(root);
|
|
200
|
+
readLedger(root);
|
|
201
|
+
const path = join(root, 'seats', role);
|
|
202
|
+
// A new seat starts from the current origin/main, not from whatever the mirror last fetched.
|
|
203
|
+
if (!existsSync(path)) git(repo, 'fetch', 'origin');
|
|
204
|
+
const created = withLedgerLock(root, () => {
|
|
205
|
+
if (existsSync(path)) return false;
|
|
206
|
+
mkdirSync(join(root, 'seats'), { recursive: true });
|
|
207
|
+
// A seat folder deleted by hand leaves its git registration behind; drop only that one so the seat is recreated.
|
|
208
|
+
removeStaleWorktreeAdmin(repo, join(realpathSync(join(root, 'seats')), role));
|
|
209
|
+
git(repo, 'worktree', 'add', '--detach', path, 'refs/remotes/origin/main');
|
|
210
|
+
return true;
|
|
211
|
+
});
|
|
212
|
+
if (created) return { path, outcome: 'created' };
|
|
213
|
+
// Refuse an unrelated checkout even when it is clean; do not touch its HEAD.
|
|
214
|
+
assertRegisteredWorktree(repo, path);
|
|
215
|
+
// Seats are always detached; a worktree on a branch here (e.g. a work moved into seats/) is someone's work.
|
|
216
|
+
const onBranch = runGitCommand(path, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { encoding: 'utf8' });
|
|
217
|
+
if (onBranch.status === 0) throw new Error(`not a seat: ${path} is on branch ${onBranch.stdout.trim()}; refusing to touch it`);
|
|
218
|
+
// A dirty seat must be byte-for-byte untouched, including its detached HEAD.
|
|
219
|
+
if (options.refresh && !clean(path)) return { path, outcome: 'dirty' };
|
|
220
|
+
if (!options.refresh) return { path, outcome: 'unchanged' };
|
|
221
|
+
git(repo, 'fetch', 'origin');
|
|
222
|
+
// Concurrent refreshes of one seat (cron and a person) serialize on the house lock; each re-checks first.
|
|
223
|
+
return withLedgerLock(root, () => {
|
|
224
|
+
assertRegisteredWorktree(repo, path);
|
|
225
|
+
if (runGitCommand(path, ['symbolic-ref', '--quiet', 'HEAD'], { encoding: 'utf8' }).status === 0) throw new Error(`not a seat: ${path} is on a branch; refusing to touch it`);
|
|
226
|
+
if (!clean(path)) return { path, outcome: 'dirty' as const };
|
|
227
|
+
// Commits made on the detached seat are not on origin/main; moving HEAD would orphan them. Treat as dirty.
|
|
228
|
+
if (!mergedIntoMain(repo, git(path, 'rev-parse', 'HEAD'))) return { path, outcome: 'dirty' as const };
|
|
229
|
+
git(path, 'checkout', '--detach', 'refs/remotes/origin/main');
|
|
230
|
+
return { path, outcome: 'refreshed' as const };
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function hqWork(action: 'new' | 'done', value: string, nameValue: string, options: { root?: string } = {}): { path: string; outcome: 'created' | 'removed' } {
|
|
235
|
+
const role = roleOf(value);
|
|
236
|
+
const name = workName(nameValue);
|
|
237
|
+
const root = resolve(options.root ?? hqRoot());
|
|
238
|
+
const repo = repository(root);
|
|
239
|
+
readLedger(root);
|
|
240
|
+
const branch = `work/${role}-${name}`;
|
|
241
|
+
const path = join(root, 'work', `${role}-${name}`);
|
|
242
|
+
if (action === 'new') {
|
|
243
|
+
if (existsSync(path)) throw new Error(`work already exists: ${path}`);
|
|
244
|
+
// fetch stays outside the lock: it only moves remote-tracking refs, is idempotent and lock-retried, and the
|
|
245
|
+
// cron line runs it unlocked anyway. Branch, worktree and ledger changes below are all under the lock.
|
|
246
|
+
git(repo, 'fetch', 'origin');
|
|
247
|
+
withLedgerLock(root, () => {
|
|
248
|
+
if (existsSync(path)) throw new Error(`work already exists: ${path}`);
|
|
249
|
+
mkdirSync(join(root, 'work'), { recursive: true });
|
|
250
|
+
ensureSandboxIgnored(repo);
|
|
251
|
+
const base = git(repo, 'rev-parse', '--verify', 'refs/remotes/origin/main');
|
|
252
|
+
git(repo, 'worktree', 'add', '-b', branch, path, base);
|
|
253
|
+
try {
|
|
254
|
+
const sandbox = join(path, '.elanous-test');
|
|
255
|
+
mkdirSync(sandbox);
|
|
256
|
+
updateLedgerLocked(root, ledger => {
|
|
257
|
+
ledger.sandboxes[path] = { kind: 'sandbox', path: realpathSync(sandbox), owner: role };
|
|
258
|
+
});
|
|
259
|
+
} catch (error) {
|
|
260
|
+
// Roll back only a work that is still exactly what was just created (clean, at base, branch at base),
|
|
261
|
+
// without --force and with a compare-and-delete; anything a hook or another writer added is kept.
|
|
262
|
+
let untouched = false;
|
|
263
|
+
// `--ignored` also sees files in the (ignored) sandbox; only the empty sandbox dir we made is invisible to it.
|
|
264
|
+
try { untouched = git(path, 'rev-parse', '--verify', 'HEAD') === base && git(path, 'status', '--porcelain', '--ignored') === ''; } catch { /* unreadable: keep it */ }
|
|
265
|
+
const removed = untouched ? runGitCommand(repo, ['worktree', 'remove', path], { encoding: 'utf8' }) : undefined;
|
|
266
|
+
const unbranched = removed?.status === 0 ? runGitCommand(repo, ['update-ref', '-d', `refs/heads/${branch}`, base], { encoding: 'utf8' }) : undefined;
|
|
267
|
+
const rolledBack = !untouched ? `work kept: it changed after creation: ${path}`
|
|
268
|
+
: removed?.status !== 0 ? `work kept: ${removed?.stderr.trim()}`
|
|
269
|
+
: unbranched?.status !== 0 ? `rollback incomplete; branch kept: ${unbranched?.stderr.trim()}`
|
|
270
|
+
: 'rolled back';
|
|
271
|
+
throw new Error(`work registration failed (${rolledBack}): ${error instanceof Error ? error.message : String(error)}`);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
return { path, outcome: 'created' };
|
|
275
|
+
}
|
|
276
|
+
git(repo, 'fetch', 'origin');
|
|
277
|
+
withLedgerLock(root, () => {
|
|
278
|
+
if (!existsSync(path)) {
|
|
279
|
+
// Reconcile a done that removed the worktree but failed before the ledger or branch caught up.
|
|
280
|
+
if (!readLedger(root).sandboxes[path]) throw new Error(`work does not exist: ${path}`);
|
|
281
|
+
// A moved worktree (`git worktree move`) is still live work on this branch: never reclaim it from here.
|
|
282
|
+
// Only this work's own stale registration is cleaned below; other (maybe briefly missing) worktrees are left.
|
|
283
|
+
const own = join(existsSync(dirname(path)) ? realpathSync(dirname(path)) : dirname(path), basename(path));
|
|
284
|
+
const elsewhere = git(repo, 'worktree', 'list', '--porcelain').split('\n\n')
|
|
285
|
+
.filter(block => block.split('\n').includes(`branch refs/heads/${branch}`))
|
|
286
|
+
.map(block => block.split('\n')[0]!.slice(9))
|
|
287
|
+
.find(location => location !== own);
|
|
288
|
+
if (elsewhere) throw new Error(`work branch ${branch} is checked out at ${elsewhere}; refusing to reclaim ${path}`);
|
|
289
|
+
const ref = runGitCommand(repo, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], { encoding: 'utf8' });
|
|
290
|
+
if (ref.status !== 0) {
|
|
291
|
+
// Without the branch, the only merge proof is the tip an interrupted done recorded before removing.
|
|
292
|
+
const recorded = readLedger(root).sandboxes[path]?.reclaiming;
|
|
293
|
+
if (!recorded || !mergedIntoMain(repo, recorded)) throw new Error(`cannot verify merge: branch ${branch} is missing; house.json entry kept: ${path}`);
|
|
294
|
+
updateLedgerLocked(root, ledger => { delete ledger.sandboxes[path]; });
|
|
295
|
+
removeStaleWorktreeAdmin(repo, own);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const tip = ref.stdout.trim();
|
|
299
|
+
const merged = runGitCommand(repo, ['merge-base', '--is-ancestor', tip, 'refs/remotes/origin/main'], { encoding: 'utf8' });
|
|
300
|
+
if (merged.status !== 0) throw new Error(merged.status === 1 ? `work is not merged into origin/main: ${branch}` : `cannot verify merge: ${merged.stderr}`);
|
|
301
|
+
// Same order as a normal done: the entry goes first, so a save that fails again keeps the branch (the proof).
|
|
302
|
+
const entry = readLedger(root).sandboxes[path];
|
|
303
|
+
updateLedgerLocked(root, ledger => { delete ledger.sandboxes[path]; });
|
|
304
|
+
const deleted = runGitCommand(repo, ['update-ref', '-d', `refs/heads/${branch}`, tip], { encoding: 'utf8' });
|
|
305
|
+
if (deleted.status !== 0) {
|
|
306
|
+
// The branch moved after the merge check: put the registration back so done can be retried.
|
|
307
|
+
updateLedgerLocked(root, ledger => { ledger.sandboxes[path] = entry; });
|
|
308
|
+
throw new Error(`work branch moved after the merge check; registration kept: ${branch}: ${deleted.stderr.trim()}`);
|
|
309
|
+
}
|
|
310
|
+
removeStaleWorktreeAdmin(repo, own);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
// Only work that `hq work new` registered is reclaimed; a hand-made worktree at the same path is not ours.
|
|
314
|
+
const entry = readLedger(root).sandboxes[path];
|
|
315
|
+
if (entry?.owner !== role) throw new Error(`work is not registered in house.json; refusing to remove: ${path}`);
|
|
316
|
+
assertRegisteredWorktree(repo, path);
|
|
317
|
+
if (!clean(path)) throw new Error(`work is dirty; refusing to remove: ${path}`);
|
|
318
|
+
const ref = `refs/heads/${branch}`;
|
|
319
|
+
const attached = runGitCommand(path, ['symbolic-ref', '--quiet', '--short', 'HEAD'], { encoding: 'utf8' });
|
|
320
|
+
const branchTip = runGitCommand(repo, ['rev-parse', '--verify', '--quiet', ref], { encoding: 'utf8' });
|
|
321
|
+
// Resume: an interrupted done left the work detached at its recorded merged tip, with the branch either
|
|
322
|
+
// still at that tip (stopped before the delete) or already deleted (stopped before the removal).
|
|
323
|
+
const branchPresent = branchTip.status === 0;
|
|
324
|
+
const resuming = attached.status !== 0 && entry.reclaiming !== undefined
|
|
325
|
+
&& git(path, 'rev-parse', 'HEAD') === entry.reclaiming
|
|
326
|
+
&& (!branchPresent || branchTip.stdout.trim() === entry.reclaiming);
|
|
327
|
+
if (!resuming && (attached.status !== 0 || attached.stdout.trim() !== branch)) throw new Error(`work branch mismatch: ${path}`);
|
|
328
|
+
const tip = resuming ? entry.reclaiming! : branchTip.stdout.trim();
|
|
329
|
+
// The ancestor check is against origin/main, not bare HEAD (which may still point to master).
|
|
330
|
+
if (!mergedIntoMain(repo, tip)) throw new Error(`work is not merged into origin/main: ${branch}`);
|
|
331
|
+
// Removal order keeps every interruption resumable: record the proven tip → detach → hold the work's git
|
|
332
|
+
// locks → compare-and-delete the branch → remove the worktree → drop the registration last.
|
|
333
|
+
// Holding this worktree's HEAD.lock and index.lock makes git refuse every commit and `add` in it until the
|
|
334
|
+
// removal ends (`worktree remove` itself does not need them), so no commit can land between the last
|
|
335
|
+
// check and the removal. The locks live in the worktree's admin dir, which the removal deletes.
|
|
336
|
+
updateLedgerLocked(root, ledger => { ledger.sandboxes[path] = { ...entry, reclaiming: tip }; });
|
|
337
|
+
const admin = git(path, 'rev-parse', '--absolute-git-dir');
|
|
338
|
+
const held: string[] = [];
|
|
339
|
+
const release = (): void => { for (const lock of held.splice(0)) rmSync(lock, { force: true }); };
|
|
340
|
+
const restore = (): string => {
|
|
341
|
+
release();
|
|
342
|
+
const head = runGitCommand(path, ['rev-parse', '--verify', 'HEAD'], { encoding: 'utf8' });
|
|
343
|
+
runGitCommand(repo, ['update-ref', ref, head.status === 0 ? head.stdout.trim() : tip, ''], { encoding: 'utf8' });
|
|
344
|
+
const reattached = runGitCommand(path, ['symbolic-ref', 'HEAD', ref], { encoding: 'utf8' }).status === 0;
|
|
345
|
+
// If HEAD could not be re-attached (e.g. a stale HEAD.lock), keep `reclaiming` so the next done resumes.
|
|
346
|
+
const restored = reattached ? { kind: entry.kind, path: entry.path, owner: entry.owner } : { ...entry, reclaiming: tip };
|
|
347
|
+
try { updateLedgerLocked(root, ledger => { ledger.sandboxes[path] = restored; }); return reattached ? 'registration restored' : 'left resumable'; }
|
|
348
|
+
catch (error) { return `REGISTRATION NOT RESTORED (fix ${path} in house.json by hand): ${error instanceof Error ? error.message : String(error)}`; }
|
|
349
|
+
};
|
|
350
|
+
const fail = (message: string): never => { throw new Error(`${message}; nothing removed: ${path} · ${restore()}`); };
|
|
351
|
+
if (!resuming) git(path, 'checkout', '--quiet', '--detach');
|
|
352
|
+
for (const name of ['HEAD.lock', 'index.lock']) {
|
|
353
|
+
const lock = join(admin, name);
|
|
354
|
+
try { takeLock(lock); held.push(lock); }
|
|
355
|
+
catch (error) { fail(`work is busy (another git process holds ${name}): ${error instanceof Error ? error.message : String(error)}`); }
|
|
356
|
+
}
|
|
357
|
+
if (git(path, 'rev-parse', 'HEAD') !== tip) fail('work gained a commit after the merge check');
|
|
358
|
+
if (branchPresent) {
|
|
359
|
+
const deleted = runGitCommand(repo, ['update-ref', '-d', ref, tip], { encoding: 'utf8' });
|
|
360
|
+
if (deleted.status !== 0) fail(`work branch moved after the merge check: ${deleted.stderr.trim()}`);
|
|
361
|
+
}
|
|
362
|
+
if (!clean(path)) fail('work changed during done');
|
|
363
|
+
// Without --force, git also refuses a worktree that became dirty after the check above.
|
|
364
|
+
const removed = runGitCommand(repo, ['worktree', 'remove', path], { encoding: 'utf8' });
|
|
365
|
+
if (removed.status !== 0) fail(`git worktree remove failed: ${removed.stderr.trim()}`);
|
|
366
|
+
updateLedgerLocked(root, ledger => { delete ledger.sandboxes[path]; });
|
|
367
|
+
});
|
|
368
|
+
return { path, outcome: 'removed' };
|
|
369
|
+
}
|