@workerstack/repo 0.0.0-stage → 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/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +0 -3
- package/agents/global-settings/antigravity.json +3 -0
- package/agents/global-settings/claude.json +6 -0
- package/agents/global-settings/codex.toml +2 -0
- package/agents/global-settings/cursor.json +3 -0
- package/agents/global-settings/grok.toml +36 -0
- package/agents/global-settings/manifest.json +36 -0
- package/agents/grok/hooks/orchestrate-parent.json +60 -0
- package/agents/grok/hooks/rtk.json +16 -0
- package/agents/grok/hooks/rulesync.json +3 -0
- package/agents/grok/roles/scout.toml +5 -0
- package/agents/hooks.jsonc +67 -0
- package/agents/mcp-policy.jsonc +16 -0
- package/agents/rules/overview.md +21 -0
- package/agents/settings/claude.json +5 -0
- package/agents/skills/ask/SKILL.md +13 -0
- package/agents/skills/auto/SKILL.md +13 -0
- package/agents/skills/away/SKILL.md +19 -0
- package/agents/skills/blast-radius/SKILL.md +54 -0
- package/agents/skills/bro/SKILL.md +9 -0
- package/agents/skills/browser/SKILL.md +16 -0
- package/agents/skills/clasp/SKILL.md +11 -0
- package/agents/skills/commit/SKILL.md +35 -0
- package/agents/skills/commit-and-push/SKILL.md +9 -0
- package/agents/skills/commit-push/SKILL.md +59 -0
- package/agents/skills/commit-push-deploy/SKILL.md +74 -0
- package/agents/skills/commit-push-release/SKILL.md +75 -0
- package/agents/skills/correct/SKILL.md +39 -0
- package/agents/skills/do/SKILL.md +21 -0
- package/agents/skills/explain/SKILL.md +13 -0
- package/agents/skills/google-workspace/SKILL.md +12 -0
- package/agents/skills/handoff/SKILL.md +81 -0
- package/agents/skills/implement-plan/SKILL.md +29 -0
- package/agents/skills/implement-roadmap/SKILL.md +27 -0
- package/agents/skills/jev/SKILL.md +17 -0
- package/agents/skills/listen/SKILL.md +15 -0
- package/agents/skills/pickup/SKILL.md +58 -0
- package/agents/skills/principle-attack-the-premise/SKILL.md +24 -0
- package/agents/skills/principle-boundary-discipline/SKILL.md +38 -0
- package/agents/skills/principle-build-the-lever/SKILL.md +28 -0
- package/agents/skills/principle-encode-lessons-in-structure/SKILL.md +15 -0
- package/agents/skills/principle-exhaust-the-design-space/SKILL.md +12 -0
- package/agents/skills/principle-experience-first/SKILL.md +22 -0
- package/agents/skills/principle-explain-the-number/SKILL.md +21 -0
- package/agents/skills/principle-fix-root-causes/SKILL.md +24 -0
- package/agents/skills/principle-foundational-thinking/SKILL.md +25 -0
- package/agents/skills/principle-guard-the-context-window/SKILL.md +16 -0
- package/agents/skills/principle-laziness-protocol/SKILL.md +13 -0
- package/agents/skills/principle-make-operations-idempotent/SKILL.md +25 -0
- package/agents/skills/principle-migrate-callers-then-delete-legacy-apis/SKILL.md +18 -0
- package/agents/skills/principle-minimize-reader-load/SKILL.md +22 -0
- package/agents/skills/principle-model-the-domain/SKILL.md +26 -0
- package/agents/skills/principle-never-block-on-the-human/SKILL.md +17 -0
- package/agents/skills/principle-outcome-oriented-execution/SKILL.md +25 -0
- package/agents/skills/principle-prove-it-works/SKILL.md +25 -0
- package/agents/skills/principle-redesign-from-first-principles/SKILL.md +18 -0
- package/agents/skills/principle-separate-before-serializing-shared-state/SKILL.md +20 -0
- package/agents/skills/principle-sequence-verifiable-units/SKILL.md +22 -0
- package/agents/skills/principle-subtract-before-you-add/SKILL.md +21 -0
- package/agents/skills/principle-test-behavior-not-implementation/SKILL.md +24 -0
- package/agents/skills/principle-type-system-discipline/SKILL.md +17 -0
- package/agents/skills/review-plan/SKILL.md +28 -0
- package/agents/skills/review-roadmap/SKILL.md +28 -0
- package/agents/skills/show-me/SKILL.md +21 -0
- package/agents/skills/speak/SKILL.md +15 -0
- package/agents/skills/su/SKILL.md +11 -0
- package/agents/skills/teach/SKILL.md +25 -0
- package/agents/skills/tmp/SKILL.md +54 -0
- package/agents/skills/typescript-best-practices/SKILL.md +32 -0
- package/agents/skills/typescript-best-practices/references/patterns.md +328 -0
- package/agents/skills/unslop/SKILL.md +62 -0
- package/agents/skills/write-plan/SKILL.md +33 -0
- package/agents/skills/write-roadmap/SKILL.md +66 -0
- package/agents/subagents/implementer-deep.md +57 -0
- package/agents/subagents/implementer-fast.md +57 -0
- package/agents/subagents/orchestrator.md +43 -0
- package/agents/subagents/researcher.md +47 -0
- package/agents/subagents/scout.md +44 -0
- package/bin/repo.ts +4 -0
- package/cli/actionlint-default.test.ts +38 -0
- package/cli/actionlint-download.test.ts +162 -0
- package/cli/actionlint-real.test.ts +33 -0
- package/cli/actionlint.constants.ts +43 -0
- package/cli/actionlint.test.ts +108 -0
- package/cli/actionlint.ts +178 -0
- package/cli/bin.test.ts +54 -0
- package/cli/bin.ts +40 -0
- package/cli/check-changelog.test.ts +40 -0
- package/cli/check-changelog.ts +12 -0
- package/cli/check-comments.test.ts +157 -0
- package/cli/check-comments.ts +123 -0
- package/cli/check-env-modules.test.ts +85 -0
- package/cli/check-env-modules.ts +56 -0
- package/cli/check-magic.test.ts +139 -0
- package/cli/check-magic.ts +120 -0
- package/cli/check-manifests.constants.ts +4 -0
- package/cli/check-manifests.test.ts +210 -0
- package/cli/check-manifests.ts +71 -0
- package/cli/check-nightly.test.ts +103 -0
- package/cli/check-nightly.ts +37 -0
- package/cli/check-ratchets-missing.test.ts +46 -0
- package/cli/check-ratchets-mutation.test.ts +49 -0
- package/cli/check-ratchets.constants.ts +20 -0
- package/cli/check-ratchets.test.ts +190 -0
- package/cli/check-ratchets.ts +193 -0
- package/cli/check-rejections.test.ts +220 -0
- package/cli/check-rejections.ts +153 -0
- package/cli/check-reports.ts +37 -0
- package/cli/check-tsconfig.test.ts +507 -0
- package/cli/check-tsconfig.ts +205 -0
- package/cli/commands.test.ts +114 -0
- package/cli/commands.ts +218 -0
- package/cli/commit-changelog.ts +27 -0
- package/cli/commit-msg-changelog.test.ts +93 -0
- package/cli/commit-msg-range.test.ts +126 -0
- package/cli/commit-msg-red-proof.test.ts +147 -0
- package/cli/commit-msg-repo.test.ts +153 -0
- package/cli/commit-msg.test.ts +101 -0
- package/cli/commit-msg.ts +194 -0
- package/cli/commit-range.constants.ts +17 -0
- package/cli/commit-range.ts +57 -0
- package/cli/config-knip.test.ts +70 -0
- package/cli/config-nightly.test.ts +84 -0
- package/cli/config-tdd.test.ts +46 -0
- package/cli/config.test.ts +576 -0
- package/cli/config.ts +261 -0
- package/cli/conventional.ts +9 -0
- package/cli/coverage-config.ts +62 -0
- package/cli/coverage.constants.ts +6 -0
- package/cli/coverage.test.ts +199 -0
- package/cli/coverage.ts +127 -0
- package/cli/env.constants.ts +10 -0
- package/cli/env.test.ts +56 -0
- package/cli/env.ts +72 -0
- package/cli/fixtures/base-config.ts +13 -0
- package/cli/fixtures/gate-world.ts +48 -0
- package/cli/fixtures/knip-run.ts +38 -0
- package/cli/fixtures/mutate-fakes.ts +137 -0
- package/cli/fixtures/planted-repo.ts +50 -0
- package/cli/fixtures/publish-world.ts +71 -0
- package/cli/fixtures/red-proof-repo.ts +72 -0
- package/cli/fixtures/release-context.ts +15 -0
- package/cli/fixtures/release-world.ts +111 -0
- package/cli/fixtures/stub-bin.ts +40 -0
- package/cli/guard-diagnostics.test.ts +157 -0
- package/cli/guard-diagnostics.ts +195 -0
- package/cli/init.test.ts +487 -0
- package/cli/init.ts +275 -0
- package/cli/knip-check-ignores.test.ts +106 -0
- package/cli/knip-check-real.test.ts +65 -0
- package/cli/knip-check.constants.ts +46 -0
- package/cli/knip-check.test.ts +114 -0
- package/cli/knip-check.ts +169 -0
- package/cli/knip-schema.ts +67 -0
- package/cli/known-failures.constants.ts +22 -0
- package/cli/known-failures.test.ts +138 -0
- package/cli/known-failures.ts +131 -0
- package/cli/lint/as-needs-reason.ts +41 -0
- package/cli/lint/boundaries.ts +73 -0
- package/cli/lint/class-file.ts +39 -0
- package/cli/lint/config-file.ts +32 -0
- package/cli/lint/entry-file.ts +54 -0
- package/cli/lint/env-in-config.ts +66 -0
- package/cli/lint/filename-matches-export.ts +63 -0
- package/cli/lint/index-file.ts +22 -0
- package/cli/lint/index.ts +47 -0
- package/cli/lint/is-reason.ts +6 -0
- package/cli/lint/manifest.ts +46 -0
- package/cli/lint/names.ts +59 -0
- package/cli/lint/no-commented-out-code.ts +105 -0
- package/cli/lint/no-mock-of-subject.ts +64 -0
- package/cli/lint/no-module-side-effects.ts +94 -0
- package/cli/lint/no-module-state.ts +58 -0
- package/cli/lint/no-side-effect-import.ts +17 -0
- package/cli/lint/no-v8-ignore.ts +18 -0
- package/cli/lint/script-rules.ts +9 -0
- package/cli/lint/skip-on-platform.constants.ts +8 -0
- package/cli/lint/skip-on-platform.ts +118 -0
- package/cli/lint/todo-needs-id.ts +63 -0
- package/cli/lint/types-file.ts +28 -0
- package/cli/lint-schema.ts +32 -0
- package/cli/magic/fixtures/magic-repo.ts +64 -0
- package/cli/magic/magic-hit.ts +2 -0
- package/cli/magic/magic-nodes.ts +44 -0
- package/cli/magic/magic-numbers.test.ts +99 -0
- package/cli/magic/magic-numbers.ts +117 -0
- package/cli/magic/magic-strings.test.ts +38 -0
- package/cli/magic/magic-strings.ts +81 -0
- package/cli/main.test.ts +853 -0
- package/cli/main.ts +292 -0
- package/cli/mutate-args.ts +79 -0
- package/cli/mutate-check.test.ts +147 -0
- package/cli/mutate-deps.ts +42 -0
- package/cli/mutate-edges.test.ts +150 -0
- package/cli/mutate-files.test.ts +100 -0
- package/cli/mutate-files.ts +31 -0
- package/cli/mutate-floors.test.ts +91 -0
- package/cli/mutate-floors.ts +104 -0
- package/cli/mutate-real-deps.ts +57 -0
- package/cli/mutate-real.test.ts +96 -0
- package/cli/mutate-report.ts +96 -0
- package/cli/mutate-run.ts +128 -0
- package/cli/mutate-shards.ts +64 -0
- package/cli/mutate.constants.ts +67 -0
- package/cli/mutate.test.ts +182 -0
- package/cli/mutate.ts +64 -0
- package/cli/nested-vitest-flags.ts +8 -0
- package/cli/nightly-config.constants.ts +14 -0
- package/cli/nightly-config.ts +62 -0
- package/cli/prompt.test.ts +202 -0
- package/cli/prompt.ts +1 -0
- package/cli/red-proof-edges.test.ts +78 -0
- package/cli/red-proof-failures.test.ts +82 -0
- package/cli/red-proof-tree.test.ts +157 -0
- package/cli/red-proof-verdict.test.ts +44 -0
- package/cli/red-proof.constants.ts +14 -0
- package/cli/red-proof.ts +202 -0
- package/cli/release/changelog-parse.ts +83 -0
- package/cli/release/changelog.test.ts +141 -0
- package/cli/release/changelog.ts +106 -0
- package/cli/release/gate-refusals.test.ts +85 -0
- package/cli/release/gate.test.ts +63 -0
- package/cli/release/gate.ts +70 -0
- package/cli/release/git.ts +66 -0
- package/cli/release/github.test.ts +128 -0
- package/cli/release/github.ts +46 -0
- package/cli/release/list-dir.test.ts +11 -0
- package/cli/release/list-dir.ts +6 -0
- package/cli/release/main.test.ts +48 -0
- package/cli/release/main.ts +51 -0
- package/cli/release/manifest.test.ts +24 -0
- package/cli/release/manifest.ts +34 -0
- package/cli/release/pack.test.ts +107 -0
- package/cli/release/pack.ts +76 -0
- package/cli/release/plan.ts +127 -0
- package/cli/release/publish-run.test.ts +98 -0
- package/cli/release/publish.test.ts +127 -0
- package/cli/release/publish.ts +125 -0
- package/cli/release/refuse.ts +5 -0
- package/cli/release/release-messages.constants.ts +96 -0
- package/cli/release/release-refusals.test.ts +149 -0
- package/cli/release/release.constants.ts +128 -0
- package/cli/release/release.test.ts +199 -0
- package/cli/release/release.ts +145 -0
- package/cli/release/version.test.ts +118 -0
- package/cli/release/version.ts +69 -0
- package/cli/release/wait.test.ts +101 -0
- package/cli/release/wait.ts +95 -0
- package/cli/script-diagnostics.test.ts +76 -0
- package/cli/script-diagnostics.ts +62 -0
- package/cli/scripts/board.ts +519 -0
- package/cli/scripts/browser.ts +994 -0
- package/cli/scripts/change-set.ts +105 -0
- package/cli/scripts/check-globals.ts +199 -0
- package/cli/scripts/ci-skips.constants.ts +17 -0
- package/cli/scripts/ci-skips.ts +118 -0
- package/cli/scripts/ci-wait-run.ts +28 -0
- package/cli/scripts/ci-wait.constants.ts +44 -0
- package/cli/scripts/ci-wait.ts +175 -0
- package/cli/scripts/ci.constants.ts +15 -0
- package/cli/scripts/ci.ts +208 -0
- package/cli/scripts/clean.ts +363 -0
- package/cli/scripts/comments-step.ts +15 -0
- package/cli/scripts/doctor.ts +828 -0
- package/cli/scripts/emit-agent-shell-tools.ts +91 -0
- package/cli/scripts/emit-mcp-policy.ts +402 -0
- package/cli/scripts/folder-map.ts +353 -0
- package/cli/scripts/gate-fat-tools.ts +1095 -0
- package/cli/scripts/gitignore-guard.ts +338 -0
- package/cli/scripts/google-workspace.ts +779 -0
- package/cli/scripts/hook-command.ts +72 -0
- package/cli/scripts/hook-input.ts +178 -0
- package/cli/scripts/hook-io.constants.ts +5 -0
- package/cli/scripts/hook-io.ts +46 -0
- package/cli/scripts/hooks-union.ts +469 -0
- package/cli/scripts/interactive.ts +30 -0
- package/cli/scripts/jev.ts +420 -0
- package/cli/scripts/job.ts +934 -0
- package/cli/scripts/jsonc.ts +9 -0
- package/cli/scripts/lessons.ts +564 -0
- package/cli/scripts/lint-baseline.ts +233 -0
- package/cli/scripts/lint-main.ts +24 -0
- package/cli/scripts/lint-skips.ts +139 -0
- package/cli/scripts/lint.constants.ts +6 -0
- package/cli/scripts/lint.ts +142 -0
- package/cli/scripts/listen.ts +962 -0
- package/cli/scripts/managed-text.ts +47 -0
- package/cli/scripts/orchestrate-parent.ts +1155 -0
- package/cli/scripts/parse-need.ts +81 -0
- package/cli/scripts/persist-handoff.ts +228 -0
- package/cli/scripts/persist-implementation.ts +276 -0
- package/cli/scripts/persist-plan.ts +888 -0
- package/cli/scripts/persist-roadmap.ts +478 -0
- package/cli/scripts/persist-tmp.ts +177 -0
- package/cli/scripts/persist.ts +54 -0
- package/cli/scripts/phase-files.ts +121 -0
- package/cli/scripts/prompt.ts +38 -0
- package/cli/scripts/purge-global-mcps.ts +313 -0
- package/cli/scripts/rejections-step.ts +37 -0
- package/cli/scripts/rtk-grok.ts +102 -0
- package/cli/scripts/scrub-spawn-prompt.ts +103 -0
- package/cli/scripts/scrub-text-lib.ts +58 -0
- package/cli/scripts/session-map.ts +69 -0
- package/cli/scripts/setup.ts +862 -0
- package/cli/scripts/skill-conflicts.ts +82 -0
- package/cli/scripts/speak.ts +370 -0
- package/cli/scripts/strategy.ts +168 -0
- package/cli/scripts/sync-global-settings.ts +316 -0
- package/cli/scripts/turn-steps.ts +249 -0
- package/cli/scripts/turn.ts +157 -0
- package/cli/scripts/typecheck-step.ts +43 -0
- package/cli/scripts/vscode-tasks.ts +175 -0
- package/cli/scripts/workspace-root.ts +160 -0
- package/cli/scripts/worktree.ts +806 -0
- package/cli/spawn.test.ts +34 -0
- package/cli/spawn.ts +27 -0
- package/cli/staged-change.constants.ts +11 -0
- package/cli/staged-change.ts +79 -0
- package/cli/stryker-preload.test.ts +50 -0
- package/cli/stryker-preload.ts +9 -0
- package/cli/sync/agents/frontmatter.test.ts +197 -0
- package/cli/sync/agents/frontmatter.ts +140 -0
- package/cli/sync/agents/hooks.test.ts +517 -0
- package/cli/sync/agents/hooks.ts +401 -0
- package/cli/sync/agents/rules.test.ts +229 -0
- package/cli/sync/agents/rules.ts +84 -0
- package/cli/sync/agents/sources.test.ts +375 -0
- package/cli/sync/agents/sources.ts +244 -0
- package/cli/sync/agents/subagents.test.ts +149 -0
- package/cli/sync/agents/subagents.ts +158 -0
- package/cli/sync/agents-text.test.ts +31 -0
- package/cli/sync/agents-text.ts +36 -0
- package/cli/sync/fixtures/copied.README.md +35 -0
- package/cli/sync/fixtures/copied.tgz +0 -0
- package/cli/sync/fixtures/maps/golden/INDEX.md +22 -0
- package/cli/sync/fixtures/maps-ws.tgz +0 -0
- package/cli/sync/git-files.test.ts +340 -0
- package/cli/sync/git-files.ts +197 -0
- package/cli/sync/git-hooks.test.ts +94 -0
- package/cli/sync/golden.test.ts +15 -0
- package/cli/sync/hooks-mode.test.ts +99 -0
- package/cli/sync/hosts.ts +28 -0
- package/cli/sync/knip-config.constants.ts +4 -0
- package/cli/sync/knip-config.test.ts +147 -0
- package/cli/sync/knip-config.ts +108 -0
- package/cli/sync/manifest.test.ts +78 -0
- package/cli/sync/manifest.ts +60 -0
- package/cli/sync/maps.test.ts +304 -0
- package/cli/sync/maps.ts +297 -0
- package/cli/sync/migrate.test.ts +2000 -0
- package/cli/sync/migrate.ts +1104 -0
- package/cli/sync/nightly-jobs.test.ts +63 -0
- package/cli/sync/nightly.constants.ts +48 -0
- package/cli/sync/nightly.test.ts +100 -0
- package/cli/sync/nightly.ts +75 -0
- package/cli/sync/outputs.test.ts +351 -0
- package/cli/sync/outputs.ts +168 -0
- package/cli/sync/oxlint-config.test.ts +178 -0
- package/cli/sync/oxlint-config.ts +168 -0
- package/cli/sync/release-jobs.test.ts +29 -0
- package/cli/sync/release-reports.test.ts +31 -0
- package/cli/sync/release-workflow.constants.ts +61 -0
- package/cli/sync/release-workflow.test.ts +192 -0
- package/cli/sync/release-workflow.ts +86 -0
- package/cli/sync/release.constants.ts +93 -0
- package/cli/sync/release.test.ts +187 -0
- package/cli/sync/release.ts +112 -0
- package/cli/sync/repo-dir.test.ts +371 -0
- package/cli/sync/repo-dir.ts +205 -0
- package/cli/sync/standards.constants.ts +8 -0
- package/cli/sync/standards.test.ts +206 -0
- package/cli/sync/standards.ts +179 -0
- package/cli/sync/stock-history.json +2796 -0
- package/cli/sync/stock-history.test.ts +66 -0
- package/cli/sync/stock-history.ts +84 -0
- package/cli/sync/stock.test.ts +47 -0
- package/cli/sync/stock.ts +24 -0
- package/cli/sync/trees.test.ts +621 -0
- package/cli/sync/trees.ts +280 -0
- package/cli/sync/validate.test.ts +299 -0
- package/cli/sync/validate.ts +402 -0
- package/cli/sync/workspace.test.ts +180 -0
- package/cli/sync/workspace.ts +138 -0
- package/cli/sync/write.test.ts +341 -0
- package/cli/sync/write.ts +231 -0
- package/cli/sync.test.ts +1495 -0
- package/cli/sync.ts +360 -0
- package/cli/tdd-scope.test.ts +36 -0
- package/cli/tdd-scope.ts +41 -0
- package/cli/test-files.ts +16 -0
- package/cli/ts-program.ts +69 -0
- package/cli/tsconfig.constants.ts +8 -0
- package/cli/workerstack-config.types.ts +21 -0
- package/dist/cli/env.d.ts +10 -0
- package/dist/cli/env.js +64 -0
- package/dist/presets/temp-root-env.d.ts +10 -0
- package/dist/presets/temp-root-env.js +20 -0
- package/dist/presets/temp-root.constants.d.ts +6 -0
- package/dist/presets/temp-root.constants.js +6 -0
- package/dist/presets/temp-root.d.ts +2 -0
- package/dist/presets/temp-root.js +60 -0
- package/dist/presets/vitest.constants.d.ts +16 -0
- package/dist/presets/vitest.constants.js +16 -0
- package/dist/presets/vitest.d.ts +11 -0
- package/dist/presets/vitest.js +67 -0
- package/dist/presets/workers-plugin.d.ts +10 -0
- package/dist/presets/workers-plugin.js +54 -0
- package/package.json +141 -4
- package/references/INDEX.md +100 -0
- package/references/browser.md +66 -0
- package/references/cascade.md +15 -0
- package/references/clasp.md +21 -0
- package/references/cloudflare.md +36 -0
- package/references/google-workspace.md +79 -0
- package/references/hooks.md +103 -0
- package/references/implementation-format.md +119 -0
- package/references/jev.md +56 -0
- package/references/lessons.md +73 -0
- package/references/listen.md +80 -0
- package/references/map.md +107 -0
- package/references/output.md +47 -0
- package/references/plan-format.md +264 -0
- package/references/review-loop.md +48 -0
- package/references/roadmap-format.md +104 -0
- package/references/rtk.md +27 -0
- package/references/scope.md +76 -0
- package/references/setup.md +42 -0
- package/references/speak.md +59 -0
- package/tsconfig/base.json +28 -0
- package/tsconfig/bun.d.ts +1 -0
- package/tsconfig/bun.json +8 -0
- package/tsconfig/reset.d.ts +1 -0
- package/tsconfig/strict.json +12 -0
- package/tsconfig/web.json +7 -0
- package/tsconfig/worker.d.ts +1 -0
- package/tsconfig/worker.json +8 -0
|
@@ -0,0 +1,1155 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/** PreToolUse fat-gate + persist rewrite. Stop writes session receipt. SubagentStop intercepts need: search|exec|slice|fetch.
|
|
3
|
+
Fail-open: parse/crash → empty stdout, exit 0.
|
|
4
|
+
*/
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { MAX_PROMPT_CHARS, rewriteSpawnToolInput, spawnCapReason } from "./scrub-spawn-prompt.ts";
|
|
8
|
+
import {
|
|
9
|
+
answerDeny,
|
|
10
|
+
answerRewrite,
|
|
11
|
+
fatHookOutput,
|
|
12
|
+
processFatTools,
|
|
13
|
+
rewriteScratchPath,
|
|
14
|
+
stampPlanSave,
|
|
15
|
+
WRITE_PATH_FIELDS,
|
|
16
|
+
} from "./gate-fat-tools.ts";
|
|
17
|
+
import { projectEdit } from "./gitignore-guard.ts";
|
|
18
|
+
import { holdingCheckout, LESSONS_DIR, matchLessons, persistLesson } from "./lessons.ts";
|
|
19
|
+
import { persistPlan, preparePlanUpdate, validatePlan } from "./persist-plan.ts";
|
|
20
|
+
import { persistRoadmap, prepareRoadmapUpdate, validateRoadmap } from "./persist-roadmap.ts";
|
|
21
|
+
import { persistHandoff } from "./persist-handoff.ts";
|
|
22
|
+
import { persistImplementation, isFullImplementationBody, validateImplementation } from "./persist-implementation.ts";
|
|
23
|
+
import {
|
|
24
|
+
isUnderDir,
|
|
25
|
+
isUnderHandoffs,
|
|
26
|
+
isUnderImplementations,
|
|
27
|
+
isUnderPlans,
|
|
28
|
+
isUnderRoadmaps,
|
|
29
|
+
isUnderTmp,
|
|
30
|
+
persistTmp,
|
|
31
|
+
} from "./persist-tmp.ts";
|
|
32
|
+
import { parseNeed } from "./parse-need.ts";
|
|
33
|
+
import { resolveJobCwd, runJob } from "./job.ts";
|
|
34
|
+
import { otherLines, recordHook } from "./board.ts";
|
|
35
|
+
import { canonicalPath, resolveRoots, resolveTyped, type Roots } from "./workspace-root.ts";
|
|
36
|
+
import { isRecord, type HookInput } from "./hook-input.ts";
|
|
37
|
+
import { processHookIo, readHookInput, type HookIo } from "./hook-io.ts";
|
|
38
|
+
import { gitAskedWords, mainCheckoutWriteReason } from "./worktree.ts";
|
|
39
|
+
import {
|
|
40
|
+
closingLine,
|
|
41
|
+
parseApproval,
|
|
42
|
+
parseClosingLine,
|
|
43
|
+
parseMode,
|
|
44
|
+
parseStrategy,
|
|
45
|
+
STRATEGIES,
|
|
46
|
+
stampPath as strategyStampPath,
|
|
47
|
+
type Mode,
|
|
48
|
+
} from "./strategy.ts";
|
|
49
|
+
import { readEnv } from "../env.ts";
|
|
50
|
+
|
|
51
|
+
type ToolInput = Record<string, unknown>;
|
|
52
|
+
type Strategy = (typeof STRATEGIES)[number];
|
|
53
|
+
/** A reviewed plan or roadmap, as the closing line names it. */
|
|
54
|
+
type Approvable = Parameters<typeof closingLine>[0][number];
|
|
55
|
+
interface ApprovalItem extends Approvable {
|
|
56
|
+
path: string;
|
|
57
|
+
mtime: number;
|
|
58
|
+
}
|
|
59
|
+
/** What every persist function answers to a full-content save. */
|
|
60
|
+
type PersistOutcome = { ok: true; path: string; content: string } | { ok: false; error: string };
|
|
61
|
+
type Persist = (args: { workspaceRoot: string; checkout: string; content: string }) => PersistOutcome;
|
|
62
|
+
type StrategyWrite = { ok: true; content: string | null } | { ok: false; error: string };
|
|
63
|
+
|
|
64
|
+
export const ORCHESTRATOR_LOGS_DIR = ".tmp/repo/orchestrator-logs";
|
|
65
|
+
/** One-release dual-read; drop next release. */
|
|
66
|
+
export const LEGACY_ORCHESTRATOR_LOGS_DIR = ".tmp/orchestrator-logs";
|
|
67
|
+
export const DENY_REASON = "denied";
|
|
68
|
+
const TRANSCRIPT_TAIL_BYTES = 512 * 1024;
|
|
69
|
+
const SPAWN_TOOLS = new Set(["spawnsubagent", "task", "agent", "spawnagent"]);
|
|
70
|
+
const WRITE_TOOLS = new Set(["write", "edit", "searchreplace"]);
|
|
71
|
+
const INTERCEPT_JOBS = new Set(["search", "exec", "slice", "fetch"]);
|
|
72
|
+
const MAX_INTERCEPT = 3;
|
|
73
|
+
const MAX_STOP = 3;
|
|
74
|
+
const AUTO_RE = /^\s*\/auto\s*$/;
|
|
75
|
+
const ASK_RE = /^\s*\/ask\s*$/;
|
|
76
|
+
const AWAY_RE = /^\s*\/away\s*$/;
|
|
77
|
+
export const SESSION_GATE_STAMP = "session-gate";
|
|
78
|
+
/** One-release dual-read of 0.5 leftover stamp. */
|
|
79
|
+
export const AUTO_ASK_STAMP = "auto-ask";
|
|
80
|
+
export const ASK_STOP_REASON = "ask after this step";
|
|
81
|
+
export const AWAY_STOP_REASON =
|
|
82
|
+
'away: do not ask; decide, log "- decision: {question} → {choice}, because {why}" in the plan, and continue';
|
|
83
|
+
const WAIT_SCOUT = "[orchestrator]: wait scout";
|
|
84
|
+
|
|
85
|
+
/** What a handler decides: the exit code and, when the host should see one, the one reply `main` writes. */
|
|
86
|
+
interface HookResult {
|
|
87
|
+
code: number;
|
|
88
|
+
reply?: object;
|
|
89
|
+
}
|
|
90
|
+
const PASS: HookResult = { code: 0 };
|
|
91
|
+
const replyWith = (reply: object): HookResult => ({ code: 0, reply });
|
|
92
|
+
|
|
93
|
+
export function main(_argv?: string[], io: HookIo = processHookIo()): number {
|
|
94
|
+
try {
|
|
95
|
+
const data = readHookInput(io) ?? {};
|
|
96
|
+
const event = eventKey(readEnv().GROK_HOOK_EVENT || data.hookEventName || data.hook_event_name || "");
|
|
97
|
+
const result = routeEvent(event, data);
|
|
98
|
+
if (result.reply) io.write(JSON.stringify(result.reply));
|
|
99
|
+
return result.code;
|
|
100
|
+
} catch {
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function routeEvent(event: string, data: HookInput): HookResult {
|
|
106
|
+
if (event === "pretooluse") return preToolUse(data);
|
|
107
|
+
if (event === "userpromptsubmit") return userPromptSubmit(data);
|
|
108
|
+
if (event === "beforesubmitprompt") return beforeSubmitPrompt(data);
|
|
109
|
+
if (event === "stop") return stop(data);
|
|
110
|
+
if (event === "subagentstop") return interceptNeed(data, "SubagentStop");
|
|
111
|
+
return PASS;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function preToolUse(data: HookInput): HookResult {
|
|
115
|
+
const toolName = String(data.toolName || data.tool_name || "");
|
|
116
|
+
const toolKey = eventKey(toolName);
|
|
117
|
+
const toolInput = data.toolInput ?? data.tool_input;
|
|
118
|
+
|
|
119
|
+
if (SPAWN_TOOLS.has(toolKey)) {
|
|
120
|
+
const verdict = rewriteSpawn(toolInput, data);
|
|
121
|
+
if ("deny" in verdict) return replyWith(verdict.deny);
|
|
122
|
+
recordReviewSpawn(data, toolInput);
|
|
123
|
+
return verdict.updated ? replyWith(answerRewrite(data, verdict.updated)) : PASS;
|
|
124
|
+
}
|
|
125
|
+
if (WRITE_TOOLS.has(toolKey)) {
|
|
126
|
+
// A full-content plan Write is answered below, before the gate sees it, so the stamp is written here.
|
|
127
|
+
if (typeof toolInput?.content === "string") stampPlanSave(data);
|
|
128
|
+
const persisted = parentWrite(data, toolInput);
|
|
129
|
+
if (persisted !== null) return persisted;
|
|
130
|
+
}
|
|
131
|
+
const fat = emitFat(data);
|
|
132
|
+
if (fat !== null) return fat;
|
|
133
|
+
const notice = toolInput && WRITE_TOOLS.has(toolKey) ? lessonNotice(data, toolInput) : null;
|
|
134
|
+
return notice ? replyWith(notice) : PASS;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The path the host named, in whichever field it used; "" when none. */
|
|
138
|
+
function writeTarget(toolInput: ToolInput): string {
|
|
139
|
+
for (const key of ["file_path", "filePath", "path", "target_file"]) {
|
|
140
|
+
const v = toolInput[key];
|
|
141
|
+
if (typeof v === "string" && v) return v;
|
|
142
|
+
}
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function isLessonFile(filePath: string, workspaceRoot: string | null | undefined): boolean {
|
|
147
|
+
return isUnderDir(filePath, workspaceRoot, LESSONS_DIR.split("/"));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Lessons that match a file this session edits, each shown once per session. Fails open.
|
|
151
|
+
function lessonNotice(data: HookInput, toolInput: ToolInput): object | null {
|
|
152
|
+
try {
|
|
153
|
+
const roots = resolveRoots(data);
|
|
154
|
+
const target = writeTarget(toolInput);
|
|
155
|
+
if (!target) return null;
|
|
156
|
+
const abs = resolveTyped(roots, target);
|
|
157
|
+
const holder = holdingCheckout(abs, roots);
|
|
158
|
+
if (isLessonFile(abs, holder) || rewriteScratchPath(abs, roots) || isUnderDir(abs, roots.root, [".tmp", "repo"]))
|
|
159
|
+
return null;
|
|
160
|
+
const rows = matchLessons({ roots, files: [abs] });
|
|
161
|
+
if (!rows.length) return null;
|
|
162
|
+
const sid = sessionIdOf(data);
|
|
163
|
+
const seenFile = sid ? path.join(roots.root, ".tmp", "repo", "sessions", sid, "lessons-seen.json") : "";
|
|
164
|
+
let seen: string[] = [];
|
|
165
|
+
if (seenFile) {
|
|
166
|
+
try {
|
|
167
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(seenFile, "utf8"));
|
|
168
|
+
seen = Array.isArray(parsed) ? parsed.filter((v): v is string => typeof v === "string") : [];
|
|
169
|
+
} catch {
|
|
170
|
+
seen = [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const fresh = rows.filter((r) => !seen.includes(r.file));
|
|
174
|
+
if (!fresh.length) return null;
|
|
175
|
+
if (seenFile) {
|
|
176
|
+
fs.mkdirSync(path.dirname(seenFile), { recursive: true });
|
|
177
|
+
fs.writeFileSync(seenFile, JSON.stringify([...seen, ...fresh.map((r) => r.file)]));
|
|
178
|
+
}
|
|
179
|
+
const rel = path.relative(holder, abs).split(path.sep).join("/");
|
|
180
|
+
const lines = fresh.map((r) => `- ${r.file} (hits ${r.hits}): ${r.summary}`);
|
|
181
|
+
return {
|
|
182
|
+
hookSpecificOutput: {
|
|
183
|
+
hookEventName: "PreToolUse",
|
|
184
|
+
additionalContext: `Recorded lessons match ${rel}:\n${lines.join("\n")}`,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
} catch {
|
|
188
|
+
return null; // A lessons read error never blocks an edit.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// A lesson Write is validated, rendered, and reindexed in the checkout that holds it, and the host saves it; an Edit
|
|
193
|
+
// cannot be reindexed before it lands. The main checkout takes none: the deny answers here, since the gate never sees this Write.
|
|
194
|
+
function lessonWrite(data: HookInput, roots: Roots, ws: string, rawPath: string, toolInput: ToolInput): HookResult {
|
|
195
|
+
const abs = path.resolve(ws, rawPath);
|
|
196
|
+
const shared = mainCheckoutWriteReason(abs, roots);
|
|
197
|
+
if (shared) return replyWith(answerDeny(data, shared));
|
|
198
|
+
const body = toolInput.content;
|
|
199
|
+
if (typeof body !== "string") {
|
|
200
|
+
return replyWith(
|
|
201
|
+
answerDeny(
|
|
202
|
+
data,
|
|
203
|
+
"Edit is not allowed on lesson files. Write the full lesson (60 lines max) or run bun repo lessons hit <slug> --evidence <text>",
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const result = persistLesson({ workspaceRoot: ws, filePath: canonicalPath(abs), content: body });
|
|
208
|
+
if (!result.ok) return replyWith(answerDeny(data, result.error));
|
|
209
|
+
return replyWith(answerRewrite(data, Object.assign(withPath(toolInput, abs), { content: result.content })));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function emitFat(data: HookInput): HookResult | null {
|
|
213
|
+
const fat = fatHookOutput(data, processFatTools(data));
|
|
214
|
+
return fat ? replyWith(fat) : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Hosts name the target differently; point every key the host sent at the absolute file.
|
|
218
|
+
function withPath(toolInput: ToolInput, abs: string): ToolInput {
|
|
219
|
+
const next: ToolInput = Object.assign({}, toolInput);
|
|
220
|
+
const keys: string[] = WRITE_PATH_FIELDS.filter((key) => typeof next[key] === "string" && next[key]);
|
|
221
|
+
for (const key of keys) next[key] = abs;
|
|
222
|
+
return next;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// An Edit of a saved plan is checked against the text it would produce; a valid one runs as sent.
|
|
226
|
+
function planEdit(data: HookInput, abs: string, toolInput: ToolInput): HookResult | null {
|
|
227
|
+
const previous = fs.readFileSync(abs, "utf8");
|
|
228
|
+
const next = projectEdit(previous, toolInput);
|
|
229
|
+
if (next == null) return null;
|
|
230
|
+
const errors = validatePlan(path.basename(abs), next, { fresh: false, previous });
|
|
231
|
+
return errors.length ? replyWith(answerDeny(data, errors.join("; "))) : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// An Edit of a saved roadmap is checked the same way.
|
|
235
|
+
function roadmapEdit(data: HookInput, abs: string, toolInput: ToolInput): HookResult | null {
|
|
236
|
+
const previous = fs.readFileSync(abs, "utf8");
|
|
237
|
+
const next = projectEdit(previous, toolInput);
|
|
238
|
+
if (next == null) return null;
|
|
239
|
+
const errors = validateRoadmap(path.basename(abs), next, { fresh: false, previous });
|
|
240
|
+
return errors.length ? replyWith(answerDeny(data, errors.join("; "))) : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function parentWrite(data: HookInput, toolInput: ToolInput | undefined): HookResult | null {
|
|
244
|
+
if (!toolInput) return replyWith(answerDeny(data, DENY_REASON));
|
|
245
|
+
const roots = resolveRoots(data);
|
|
246
|
+
const ws = roots.root;
|
|
247
|
+
const typed = writeTarget(toolInput);
|
|
248
|
+
if (!typed) return null;
|
|
249
|
+
const rawPath = rewriteScratchPath(typed, roots) || path.resolve(roots.cwd, typed);
|
|
250
|
+
// Classify in the roots' spelling; answers keep the typed spelling, which Claude Code's read-before-write knows.
|
|
251
|
+
const canon = canonicalPath(rawPath);
|
|
252
|
+
let persist: Persist;
|
|
253
|
+
let approvable = false;
|
|
254
|
+
const holder = holdingCheckout(canon, roots);
|
|
255
|
+
if (isLessonFile(canon, holder)) return lessonWrite(data, roots, holder, rawPath, toolInput);
|
|
256
|
+
if (isUnderPlans(canon, ws)) {
|
|
257
|
+
const abs = path.resolve(rawPath);
|
|
258
|
+
let exists = false;
|
|
259
|
+
try {
|
|
260
|
+
exists = fs.statSync(abs).isFile();
|
|
261
|
+
} catch {
|
|
262
|
+
exists = false;
|
|
263
|
+
}
|
|
264
|
+
if (exists && typeof toolInput.content !== "string") return approvedEdit(data, abs, planEdit(data, abs, toolInput));
|
|
265
|
+
persist = exists ? (args: Parameters<Persist>[0]) => preparePlanUpdate({ ...args, filePath: abs }) : persistPlan;
|
|
266
|
+
approvable = true;
|
|
267
|
+
} else if (isUnderRoadmaps(canon, ws)) {
|
|
268
|
+
const roadmapPath = path.resolve(rawPath);
|
|
269
|
+
let exists = false;
|
|
270
|
+
try {
|
|
271
|
+
exists = fs.statSync(roadmapPath).isFile();
|
|
272
|
+
} catch {
|
|
273
|
+
exists = false;
|
|
274
|
+
}
|
|
275
|
+
if (exists && typeof toolInput.content !== "string") {
|
|
276
|
+
return approvedEdit(data, roadmapPath, roadmapEdit(data, roadmapPath, toolInput));
|
|
277
|
+
}
|
|
278
|
+
persist = exists
|
|
279
|
+
? (args: Parameters<Persist>[0]) => prepareRoadmapUpdate({ ...args, filePath: roadmapPath })
|
|
280
|
+
: persistRoadmap;
|
|
281
|
+
approvable = true;
|
|
282
|
+
} else if (isUnderHandoffs(canon, ws)) {
|
|
283
|
+
persist = persistHandoff;
|
|
284
|
+
} else if (isUnderImplementations(canon, ws)) {
|
|
285
|
+
persist = persistImplementation;
|
|
286
|
+
const abs = path.resolve(rawPath);
|
|
287
|
+
const content = typeof toolInput.content === "string" ? toolInput.content : "";
|
|
288
|
+
const named = /^\s*IMPL_NAME:/m.test(content);
|
|
289
|
+
let exists = false;
|
|
290
|
+
try {
|
|
291
|
+
exists = fs.statSync(abs).isFile();
|
|
292
|
+
} catch {
|
|
293
|
+
exists = false;
|
|
294
|
+
}
|
|
295
|
+
if (!named && exists) {
|
|
296
|
+
if (!isFullImplementationBody(content)) return null;
|
|
297
|
+
const errors = validateImplementation(path.basename(abs), content);
|
|
298
|
+
if (errors.length) return replyWith(answerDeny(data, errors.join("; ")));
|
|
299
|
+
return replyWith(answerRewrite(data, Object.assign(withPath(toolInput, abs), { content })));
|
|
300
|
+
}
|
|
301
|
+
} else if (isUnderTmp(canon, ws)) {
|
|
302
|
+
persist = persistTmp;
|
|
303
|
+
} else {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
const content = typeof toolInput.content === "string" ? toolInput.content : "";
|
|
307
|
+
const result = persist({ workspaceRoot: ws, checkout: roots.checkout, content });
|
|
308
|
+
if (!result.ok) return replyWith(answerDeny(data, result.error));
|
|
309
|
+
const saved = path.resolve(ws, result.path);
|
|
310
|
+
if (approvable) recordApproval(data, saved);
|
|
311
|
+
return replyWith(answerRewrite(data, Object.assign(withPath(toolInput, saved), { content: result.content })));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A valid Edit (null) passes through to the host; it still counts as a save.
|
|
315
|
+
function approvedEdit(data: HookInput, abs: string, denied: HookResult | null): HookResult | null {
|
|
316
|
+
if (denied === null) recordApproval(data, abs);
|
|
317
|
+
return denied;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// One closing line asks for approval; a bare `y` approves exactly what it names.
|
|
321
|
+
const APPROVABLE_STATUS: Record<Approvable["kind"], Set<string>> = {
|
|
322
|
+
plan: new Set(["ready"]),
|
|
323
|
+
roadmap: new Set(["draft", "locked"]),
|
|
324
|
+
};
|
|
325
|
+
const TURN_START_STAMP = "turn-start";
|
|
326
|
+
const APPROVAL_BLOCK_STAMP = "approval-block";
|
|
327
|
+
|
|
328
|
+
export function closingLineOf(msg: unknown):
|
|
329
|
+
| {
|
|
330
|
+
kind: "plan" | "roadmap";
|
|
331
|
+
serial: number;
|
|
332
|
+
strategy: "default" | "review-fast" | "review-deep";
|
|
333
|
+
}[]
|
|
334
|
+
| null {
|
|
335
|
+
const lines = String(msg || "")
|
|
336
|
+
.split("\n")
|
|
337
|
+
.map((l) => l.replace(/^[\s`*_>]+/, "").replace(/[\s`*_]+$/, ""))
|
|
338
|
+
.filter(Boolean);
|
|
339
|
+
return parseClosingLine(lines.at(-1) || "");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const itemKey = (item: ItemRef): string => `${item.kind} ${item.serial}`;
|
|
343
|
+
const queueText = (items: Approvable[]): string => items.map(itemKey).join(", then ");
|
|
344
|
+
|
|
345
|
+
function sessionFile(data: HookInput, name: string): string | null {
|
|
346
|
+
const sid = sessionIdOf(data);
|
|
347
|
+
if (!sid || sid === "default") return null;
|
|
348
|
+
return path.join(resolveRoots(data).root, ".tmp", "repo", "sessions", sid, name);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const approvalsFile = (data: HookInput): string | null => sessionFile(data, "approvals.txt");
|
|
352
|
+
|
|
353
|
+
function readLines(file: string): string[] {
|
|
354
|
+
try {
|
|
355
|
+
return fs
|
|
356
|
+
.readFileSync(file, "utf8")
|
|
357
|
+
.split(/\r?\n/)
|
|
358
|
+
.map((l) => l.trim())
|
|
359
|
+
.filter(Boolean);
|
|
360
|
+
} catch {
|
|
361
|
+
return [];
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function recordApproval(data: HookInput, abs: string): void {
|
|
366
|
+
const file = approvalsFile(data);
|
|
367
|
+
if (!file || readLines(file).includes(abs)) return;
|
|
368
|
+
try {
|
|
369
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
370
|
+
fs.appendFileSync(file, abs + "\n");
|
|
371
|
+
} catch {
|
|
372
|
+
/* fail-open */
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function frontmatterOf(text: string): Record<string, string> {
|
|
377
|
+
const out: Record<string, string> = {};
|
|
378
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
379
|
+
if (!m) return out;
|
|
380
|
+
// Group 1 always participates in a match, so m[1] is a string.
|
|
381
|
+
for (const line of m[1]!.split(/\r?\n/)) {
|
|
382
|
+
const kv = /^([a-z]+):\s*(.*)$/.exec(line);
|
|
383
|
+
// Both groups always participate in a match.
|
|
384
|
+
if (kv) out[kv[1]!] = kv[2]!.trim();
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// True when a roadmap slice's Location names this plan; Windows paths compare case-insensitively.
|
|
390
|
+
export function isSliceOf(planPath: string, roadmapText: string, impl: typeof path = path): boolean {
|
|
391
|
+
const norm = (p: string): string => {
|
|
392
|
+
const n = impl.normalize(impl.resolve(p));
|
|
393
|
+
return impl === path.win32 ? n.toLowerCase() : n;
|
|
394
|
+
};
|
|
395
|
+
const target = norm(planPath);
|
|
396
|
+
for (const m of String(roadmapText).matchAll(/^\s*Location:\s*(.+?)\s*$/gm)) {
|
|
397
|
+
// The Location group always participates in a match.
|
|
398
|
+
if (norm(m[1]!) === target) return true;
|
|
399
|
+
}
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function inProgressRoadmaps(ws: string): string[] {
|
|
404
|
+
const dir = path.join(ws, ".tmp", "repo", "roadmaps");
|
|
405
|
+
let names: string[] = [];
|
|
406
|
+
try {
|
|
407
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith(".md"));
|
|
408
|
+
} catch {
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
const texts: string[] = [];
|
|
412
|
+
for (const name of names) {
|
|
413
|
+
try {
|
|
414
|
+
const text = fs.readFileSync(path.join(dir, name), "utf8");
|
|
415
|
+
if (frontmatterOf(text).status === "in-progress") texts.push(text);
|
|
416
|
+
} catch {
|
|
417
|
+
/* skip unreadable */
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return texts;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/*
|
|
424
|
+
* A review's lifecycle, per item, keyed `plan N` / `roadmap N` and never by path text:
|
|
425
|
+
* unreviewed no review spawn recorded and `reviews` is 0
|
|
426
|
+
* open a review spawn recorded `reviews` at spawn, and the file's `reviews` is not above it yet
|
|
427
|
+
* settled `reviews` is above the recorded number, or nothing was recorded and `reviews` is at least 1
|
|
428
|
+
* Only a settled item may be named by a closing line, approved with `y`, or handed off with `h`.
|
|
429
|
+
*/
|
|
430
|
+
type ItemRef = Pick<Approvable, "kind" | "serial">;
|
|
431
|
+
type ReviewState = "unreviewed" | "open" | "settled";
|
|
432
|
+
type ReviewsOpen = Record<string, number>;
|
|
433
|
+
const REVIEW_SPAWN_RE = /^\s*\/review-(plan|roadmap)\s+(\d+)(?!\S)/;
|
|
434
|
+
const ITEM_DIRS: Record<Approvable["kind"], string> = { plan: "plans", roadmap: "roadmaps" };
|
|
435
|
+
|
|
436
|
+
const reviewsOpenFile = (data: HookInput): string | null => sessionFile(data, "reviews-open.json");
|
|
437
|
+
|
|
438
|
+
function readReviewsOpen(data: HookInput): ReviewsOpen {
|
|
439
|
+
const file = reviewsOpenFile(data);
|
|
440
|
+
if (!file) return {};
|
|
441
|
+
try {
|
|
442
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
443
|
+
if (!isRecord(parsed)) return {};
|
|
444
|
+
const out: ReviewsOpen = {};
|
|
445
|
+
for (const [k, v] of Object.entries(parsed)) if (typeof v === "number") out[k] = v;
|
|
446
|
+
return out;
|
|
447
|
+
} catch {
|
|
448
|
+
return {};
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// The saved file for a plan or roadmap serial, found through its directory; null when none holds that serial.
|
|
453
|
+
function itemFile(ws: string, ref: ItemRef): string | null {
|
|
454
|
+
const dir = path.join(ws, ".tmp", "repo", ITEM_DIRS[ref.kind]);
|
|
455
|
+
let names: string[];
|
|
456
|
+
try {
|
|
457
|
+
names = fs.readdirSync(dir).filter((n) => n.startsWith(`${ref.serial}-`) && n.endsWith(".md"));
|
|
458
|
+
} catch {
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
for (const name of names) {
|
|
462
|
+
const abs = path.join(dir, name);
|
|
463
|
+
try {
|
|
464
|
+
if (Number(frontmatterOf(fs.readFileSync(abs, "utf8")).serial) === ref.serial) return abs;
|
|
465
|
+
} catch {
|
|
466
|
+
/* skip unreadable */
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const reviewsOf = (fm: Record<string, string>): number => Number(fm.reviews) || 0;
|
|
473
|
+
|
|
474
|
+
function reviewState(open: ReviewsOpen, ref: ItemRef, reviews: number): ReviewState {
|
|
475
|
+
const atSpawn = open[itemKey(ref)];
|
|
476
|
+
if (atSpawn !== undefined) return reviews > atSpawn ? "settled" : "open";
|
|
477
|
+
return reviews >= 1 ? "settled" : "unreviewed";
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// The review state of the file a ref names; null when no file holds it.
|
|
481
|
+
function reviewStateOf(ws: string, open: ReviewsOpen, ref: ItemRef): ReviewState | null {
|
|
482
|
+
const file = itemFile(ws, ref);
|
|
483
|
+
if (!file) return null;
|
|
484
|
+
return reviewState(open, ref, reviewsOf(frontmatterOf(fs.readFileSync(file, "utf8"))));
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// A spawn whose prompt starts `/review-plan N` or `/review-roadmap N` records the item's `reviews` at spawn.
|
|
488
|
+
function recordReviewSpawn(data: HookInput, toolInput: ToolInput | undefined): void {
|
|
489
|
+
try {
|
|
490
|
+
const m = REVIEW_SPAWN_RE.exec(typeof toolInput?.prompt === "string" ? toolInput.prompt : "");
|
|
491
|
+
const file = reviewsOpenFile(data);
|
|
492
|
+
if (!m || !file) return;
|
|
493
|
+
const ref: ItemRef = { kind: m[1] === "plan" ? "plan" : "roadmap", serial: Number(m[2]) };
|
|
494
|
+
const abs = itemFile(resolveRoots(data).root, ref);
|
|
495
|
+
if (!abs) return;
|
|
496
|
+
const reviews = reviewsOf(frontmatterOf(fs.readFileSync(abs, "utf8")));
|
|
497
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
498
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
499
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...readReviewsOpen(data), [itemKey(ref)]: reviews }));
|
|
500
|
+
fs.renameSync(tmp, file);
|
|
501
|
+
} catch {
|
|
502
|
+
/* fail-open: no record means the file's reviews decides */
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Stop reason when the reply's closing line names an item whose review has not settled.
|
|
507
|
+
function closingReviewReason(data: HookInput, named: Approvable[] | null): string | null {
|
|
508
|
+
if (!named) return null;
|
|
509
|
+
const ws = resolveRoots(data).root;
|
|
510
|
+
const open = readReviewsOpen(data);
|
|
511
|
+
for (const n of named) {
|
|
512
|
+
const state = reviewStateOf(ws, open, n);
|
|
513
|
+
if (state === "unreviewed")
|
|
514
|
+
return `${itemKey(n)} has reviews: 0; run /review-${n.kind} ${n.serial} in a fresh agent first`;
|
|
515
|
+
if (state === "open") {
|
|
516
|
+
return `${itemKey(n)}'s review has not settled; wait for it, settle its findings with reviews bumped, then end with the closing line`;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Reviewed plans and roadmaps this session saved that still wait for a `y`, in first-save order.
|
|
523
|
+
function approvableItems(data: HookInput): ApprovalItem[] {
|
|
524
|
+
const file = approvalsFile(data);
|
|
525
|
+
if (!file) return [];
|
|
526
|
+
const ws = resolveRoots(data).root;
|
|
527
|
+
const running = inProgressRoadmaps(ws);
|
|
528
|
+
const open = readReviewsOpen(data);
|
|
529
|
+
const items: ApprovalItem[] = [];
|
|
530
|
+
for (const abs of readLines(file)) {
|
|
531
|
+
const kind: Approvable["kind"] | null = isUnderPlans(abs, ws)
|
|
532
|
+
? "plan"
|
|
533
|
+
: isUnderRoadmaps(abs, ws)
|
|
534
|
+
? "roadmap"
|
|
535
|
+
: null;
|
|
536
|
+
if (!kind) continue;
|
|
537
|
+
let text: string;
|
|
538
|
+
let mtime: number;
|
|
539
|
+
try {
|
|
540
|
+
text = fs.readFileSync(abs, "utf8");
|
|
541
|
+
mtime = fs.statSync(abs).mtimeMs;
|
|
542
|
+
} catch {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const fm = frontmatterOf(text);
|
|
546
|
+
const ref: ItemRef = { kind, serial: Number(fm.serial) };
|
|
547
|
+
if (!APPROVABLE_STATUS[kind].has(fm.status ?? "") || reviewState(open, ref, reviewsOf(fm)) !== "settled") continue;
|
|
548
|
+
if (kind === "plan" && running.some((t) => isSliceOf(abs, t))) continue;
|
|
549
|
+
const strategy = parseStrategy(fm.strategy);
|
|
550
|
+
if (!strategy) continue;
|
|
551
|
+
items.push({ ...ref, strategy, path: abs, mtime });
|
|
552
|
+
}
|
|
553
|
+
return items;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function stampMtime(data: HookInput, prefix: string): number | null {
|
|
557
|
+
const p = resolveStamp(data, prefix);
|
|
558
|
+
if (!p) return null;
|
|
559
|
+
return fs.statSync(p).mtimeMs;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Stop reason when this turn saved a reviewed item and the reply does not end with a closing line covering it.
|
|
563
|
+
function approvalBlockReason(data: HookInput, msg: string): string | null {
|
|
564
|
+
if (!approvalsFile(data) || resolveStamp(data, APPROVAL_BLOCK_STAMP)) return null;
|
|
565
|
+
const turnStart = stampMtime(data, TURN_START_STAMP);
|
|
566
|
+
if (turnStart == null) return null;
|
|
567
|
+
const items = approvableItems(data);
|
|
568
|
+
const pending = items.filter((i) => i.mtime >= turnStart);
|
|
569
|
+
if (!pending.length) return null;
|
|
570
|
+
const named = closingLineOf(msg);
|
|
571
|
+
const known = new Set(items.map(itemKey));
|
|
572
|
+
// The strategy a line shows must be the one the file holds, so a bare `y` never runs one the person did not see.
|
|
573
|
+
const shownTruly = (n: Approvable): boolean =>
|
|
574
|
+
items.some((i) => itemKey(i) === itemKey(n) && i.strategy === n.strategy);
|
|
575
|
+
const covered =
|
|
576
|
+
named &&
|
|
577
|
+
pending.every((p) => named.some((n) => itemKey(n) === itemKey(p))) &&
|
|
578
|
+
named.every((n) => known.has(itemKey(n)) && shownTruly(n));
|
|
579
|
+
if (covered) return null;
|
|
580
|
+
return `end with: ${closingLine(items)}`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const runOf = (i: ApprovalItem): string =>
|
|
584
|
+
i.kind === "plan"
|
|
585
|
+
? `run /implement-plan ${i.serial} (${i.path})`
|
|
586
|
+
: `set status locked, then run /implement-roadmap ${i.serial} (${i.path})`;
|
|
587
|
+
const approveStep = (i: ApprovalItem): string => `${itemKey(i)}: ${runOf(i)}`;
|
|
588
|
+
const strategyStep = (i: ApprovalItem, strategy: Strategy): string =>
|
|
589
|
+
`${itemKey(i)}: strategy ${strategy} was written to its frontmatter; re-read the ${i.kind}, then ${runOf(i)}`;
|
|
590
|
+
|
|
591
|
+
// The file's text with its `strategy:` frontmatter line set, validated through the persist module; null when nothing would change.
|
|
592
|
+
function strategyWrite(ws: string, item: ApprovalItem, strategy: Strategy): StrategyWrite {
|
|
593
|
+
const previous = fs.readFileSync(item.path, "utf8");
|
|
594
|
+
const m = /^---\r?\n[\s\S]*?\r?\n---(?=\r?\n)/.exec(previous);
|
|
595
|
+
if (!m) return { ok: false, error: "no frontmatter" };
|
|
596
|
+
const next = m[0].replace(/^strategy:.*$/m, `strategy: ${strategy}`) + previous.slice(m[0].length);
|
|
597
|
+
if (next === previous) return { ok: true, content: null };
|
|
598
|
+
const prepare = item.kind === "plan" ? preparePlanUpdate : prepareRoadmapUpdate;
|
|
599
|
+
const result = prepare({ workspaceRoot: ws, filePath: item.path, content: next });
|
|
600
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
601
|
+
return { ok: true, content: result.content };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Context for a reply of `y`, `y <strategy>`, `h`, `h fast` or `h deep` that answers the previous reply's closing line.
|
|
605
|
+
function approvalContext(data: HookInput, prompt: string): string {
|
|
606
|
+
const reply = parseApproval(prompt);
|
|
607
|
+
if (!reply) return "";
|
|
608
|
+
const tp = data.transcript_path || data.transcriptPath;
|
|
609
|
+
if (!tp) return "";
|
|
610
|
+
const named = closingLineOf(lastAssistantFromTranscript(tp));
|
|
611
|
+
if (!named) return "";
|
|
612
|
+
if (reply.kind === "unknown") {
|
|
613
|
+
return `unknown strategy "${reply.word}"; use y, ${STRATEGIES.map((s) => `y ${s}`)
|
|
614
|
+
.slice(0, -1)
|
|
615
|
+
.join(", ")} or y ${STRATEGIES.at(-1)}`;
|
|
616
|
+
}
|
|
617
|
+
const refused = reply.kind === "handoff" ? "h did not hand off" : "y did not approve";
|
|
618
|
+
const ws = resolveRoots(data).root;
|
|
619
|
+
const open = readReviewsOpen(data);
|
|
620
|
+
for (const n of named) {
|
|
621
|
+
const state = reviewStateOf(ws, open, n);
|
|
622
|
+
if (state && state !== "settled") {
|
|
623
|
+
return `${refused} ${itemKey(n)}: its review has not settled; wait for it, then end with the closing line again`;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
const items = approvableItems(data);
|
|
627
|
+
const resolved: ApprovalItem[] = [];
|
|
628
|
+
for (const n of named) {
|
|
629
|
+
const hit = items.find((i) => itemKey(i) === itemKey(n));
|
|
630
|
+
if (!hit) return "";
|
|
631
|
+
resolved.push(hit);
|
|
632
|
+
}
|
|
633
|
+
const wanted = reply.strategy;
|
|
634
|
+
// A bare reply runs the strategy each file holds, so it must be the one the line showed.
|
|
635
|
+
// resolved is built one-to-one from named, so named[k] exists.
|
|
636
|
+
if (wanted === null && resolved.some((i, k) => i.strategy !== named[k]!.strategy)) return "";
|
|
637
|
+
const writes: StrategyChange[] = [];
|
|
638
|
+
for (const i of resolved) {
|
|
639
|
+
if (wanted === null) {
|
|
640
|
+
writes.push({ item: i, content: null });
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
const w = strategyWrite(ws, i, wanted);
|
|
644
|
+
if (!w.ok) return `${refused} ${itemKey(i)}: strategy ${wanted} could not be written (${w.error})`;
|
|
645
|
+
writes.push({ item: i, content: w.content });
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
for (const w of writes) if (w.content !== null) fs.writeFileSync(w.item.path, w.content);
|
|
649
|
+
} catch (err) {
|
|
650
|
+
return `${refused} ${queueText(named)}: strategy ${wanted} could not be written (${String(err)})`;
|
|
651
|
+
}
|
|
652
|
+
if (reply.kind === "handoff") return handoffContext(data, ws, writes, wanted);
|
|
653
|
+
const approves = `y approves ${queueText(named)}: `;
|
|
654
|
+
return (
|
|
655
|
+
approves +
|
|
656
|
+
writes
|
|
657
|
+
.map((w) => (w.content === null || wanted === null ? approveStep(w.item) : strategyStep(w.item, wanted)))
|
|
658
|
+
.join("; ")
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
type StrategyChange = { item: ApprovalItem; content: string | null };
|
|
663
|
+
|
|
664
|
+
const handoffLeaf = (i: ApprovalItem): string =>
|
|
665
|
+
i.kind === "plan" ? `/implement-plan ${i.serial}` : `set status locked, then /implement-roadmap ${i.serial}`;
|
|
666
|
+
|
|
667
|
+
// One handoff for the whole queue line, in its order; Watch-outs stay empty because the files carry their own.
|
|
668
|
+
function handoffBody(sid: string, queue: { item: ApprovalItem; strategy: Strategy }[]): string {
|
|
669
|
+
const name = `run ${queue.map((q) => itemKey(q.item)).join(" then ")}`;
|
|
670
|
+
return [
|
|
671
|
+
`HANDOFF_NAME: ${name}`,
|
|
672
|
+
"",
|
|
673
|
+
`# ${name}`,
|
|
674
|
+
"",
|
|
675
|
+
"## Goal",
|
|
676
|
+
`Run ${queueText(queue.map((q) => q.item))} in a fresh session.`,
|
|
677
|
+
"",
|
|
678
|
+
"## State",
|
|
679
|
+
...queue.map((q) => `- ${itemKey(q.item)}: ${q.item.path} (strategy: ${q.strategy})`),
|
|
680
|
+
"",
|
|
681
|
+
"## Context",
|
|
682
|
+
`- handed off by an h reply in session ${sid}`,
|
|
683
|
+
"",
|
|
684
|
+
"## Next",
|
|
685
|
+
"1 [ ] Run the queue in order",
|
|
686
|
+
...queue.map((q, k) => `1.${k + 1} [ ] ${handoffLeaf(q.item)}`),
|
|
687
|
+
"",
|
|
688
|
+
"## Watch-outs",
|
|
689
|
+
"(none)",
|
|
690
|
+
"",
|
|
691
|
+
].join("\n");
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function handoffContext(data: HookInput, ws: string, writes: StrategyChange[], wanted: Strategy | null): string {
|
|
695
|
+
const queue = writes.map((w) => ({ item: w.item, strategy: wanted ?? w.item.strategy, written: w.content !== null }));
|
|
696
|
+
const shown = queue
|
|
697
|
+
.map((q) => `${itemKey(q.item)} (strategy ${q.strategy}${q.written ? " written" : ""})`)
|
|
698
|
+
.join(", then ");
|
|
699
|
+
const result = persistHandoff({ workspaceRoot: ws, content: handoffBody(sessionIdOf(data), queue) });
|
|
700
|
+
if (!result.ok)
|
|
701
|
+
return `h did not hand off ${queueText(queue.map((q) => q.item))}: the handoff could not be written (${result.error})`;
|
|
702
|
+
const abs = path.resolve(ws, result.path);
|
|
703
|
+
return `h handed off ${shown} to ${abs}; tell the person to run /pickup ${result.serial} in a fresh session, and do no work here`;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** A spawn over the prompt cap is denied; otherwise the scrubbed input, or null when nothing changed. */
|
|
707
|
+
function rewriteSpawn(
|
|
708
|
+
toolInput: ToolInput | undefined,
|
|
709
|
+
data: HookInput,
|
|
710
|
+
): { deny: object } | { updated: ToolInput | null } {
|
|
711
|
+
const updated = rewriteSpawnToolInput(toolInput);
|
|
712
|
+
const prompt =
|
|
713
|
+
updated && typeof updated.prompt === "string"
|
|
714
|
+
? updated.prompt
|
|
715
|
+
: toolInput && typeof toolInput.prompt === "string"
|
|
716
|
+
? toolInput.prompt
|
|
717
|
+
: "";
|
|
718
|
+
if (prompt.length > MAX_PROMPT_CHARS) {
|
|
719
|
+
return { deny: answerDeny(data, spawnCapReason(resolveRoots(data).root)) };
|
|
720
|
+
}
|
|
721
|
+
return { updated };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function userPromptOf(data: HookInput): string {
|
|
725
|
+
return String(data.prompt ?? data.userPrompt ?? data.user_prompt ?? data.content ?? "");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function isHostStopBanner(prompt: string): boolean {
|
|
729
|
+
if (/^\s*Stop hook feedback:/.test(prompt)) return true;
|
|
730
|
+
if (/Blocked by stop hook/.test(prompt)) return true;
|
|
731
|
+
if (/task[-_ ]?notification/i.test(prompt)) return true;
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function countLines(file: string): number {
|
|
736
|
+
try {
|
|
737
|
+
return fs
|
|
738
|
+
.readFileSync(file, "utf8")
|
|
739
|
+
.split("\n")
|
|
740
|
+
.map((l) => l.trim())
|
|
741
|
+
.filter(Boolean).length;
|
|
742
|
+
} catch {
|
|
743
|
+
return 0;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function sessionReceiptContext(data: HookInput): string {
|
|
748
|
+
const sid = sessionIdOf(data);
|
|
749
|
+
if (!sid || sid === "default") return "";
|
|
750
|
+
const dir = path.join(resolveRoots(data).root, ".tmp", "repo", "sessions", sid);
|
|
751
|
+
if (!fs.existsSync(dir)) return "";
|
|
752
|
+
const wrote = countLines(path.join(dir, "wrote.txt"));
|
|
753
|
+
const read = countLines(path.join(dir, "read.txt"));
|
|
754
|
+
return `${wrote} wrote, ${read} read. session=.tmp/repo/sessions/${sid}/`;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function writeSessionReceipt(data: HookInput, msg: string): void {
|
|
758
|
+
const sid = sessionIdOf(data);
|
|
759
|
+
if (!sid || sid === "default") return;
|
|
760
|
+
const dir = path.join(resolveRoots(data).root, ".tmp", "repo", "sessions", sid);
|
|
761
|
+
try {
|
|
762
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
763
|
+
} catch {
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
const wrote = countLines(path.join(dir, "wrote.txt"));
|
|
767
|
+
const read = countLines(path.join(dir, "read.txt"));
|
|
768
|
+
const first = firstNonEmptyStripped(msg).slice(0, 200);
|
|
769
|
+
const lines = [first, `${wrote} wrote, ${read} read.`, `session=.tmp/repo/sessions/${sid}/`].filter(Boolean);
|
|
770
|
+
try {
|
|
771
|
+
fs.writeFileSync(path.join(dir, "receipt.txt"), lines.join("\n") + "\n");
|
|
772
|
+
} catch {
|
|
773
|
+
/* fail-open */
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// What git the user's latest message asked for, read by the gate; each prompt overwrites it, so an empty list clears it.
|
|
778
|
+
function writeGitAsked(data: HookInput, prompt: string): void {
|
|
779
|
+
const sid = sessionIdOf(data);
|
|
780
|
+
// The gate finds this file beside the session's strategy stamp, with the sid cleaned the same way.
|
|
781
|
+
if (!sid.replace(/[^\w-]/g, "")) return;
|
|
782
|
+
try {
|
|
783
|
+
const dir = path.dirname(strategyStampPath(resolveRoots(data).root, sid));
|
|
784
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
785
|
+
const dest = path.join(dir, "git-asked.json");
|
|
786
|
+
const tmp = `${dest}.${process.pid}.tmp`;
|
|
787
|
+
fs.writeFileSync(tmp, JSON.stringify({ words: gitAskedWords(prompt) }));
|
|
788
|
+
fs.renameSync(tmp, dest);
|
|
789
|
+
} catch {
|
|
790
|
+
/* fail-open: no stamp means no grant */
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Cursor's beforeSubmitPrompt does only this: no /ask stamp, board line, approval or reply.
|
|
795
|
+
function beforeSubmitPrompt(data: HookInput): HookResult {
|
|
796
|
+
writeGitAsked(data, userPromptOf(data));
|
|
797
|
+
return PASS;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function userPromptSubmit(data: HookInput): HookResult {
|
|
801
|
+
const prompt = userPromptOf(data);
|
|
802
|
+
if (isHostStopBanner(prompt)) return PASS;
|
|
803
|
+
writeGitAsked(data, prompt);
|
|
804
|
+
unlinkStamp(data, "stop-block");
|
|
805
|
+
const approval = approvalContext(data, prompt);
|
|
806
|
+
if (approvalsFile(data)) {
|
|
807
|
+
writeStamp(data, TURN_START_STAMP, String(Date.now()));
|
|
808
|
+
unlinkStamp(data, APPROVAL_BLOCK_STAMP);
|
|
809
|
+
}
|
|
810
|
+
applySessionGateSlash(data, prompt);
|
|
811
|
+
let ctx = effectiveSessionContext(data);
|
|
812
|
+
if (approval) ctx = `${ctx}\n${approval}`;
|
|
813
|
+
try {
|
|
814
|
+
recordHook(data, readEnv());
|
|
815
|
+
const others = otherLines(data, readEnv());
|
|
816
|
+
if (others) ctx = `${ctx}\n${others}`;
|
|
817
|
+
} catch {
|
|
818
|
+
// Board is best-effort. The session receipt still ships.
|
|
819
|
+
}
|
|
820
|
+
return replyWith({
|
|
821
|
+
hookSpecificOutput: {
|
|
822
|
+
hookEventName: "UserPromptSubmit",
|
|
823
|
+
additionalContext: ctx,
|
|
824
|
+
},
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export function isWaitScoutExact(msg: unknown): boolean {
|
|
829
|
+
const nonempty = String(msg || "")
|
|
830
|
+
.split("\n")
|
|
831
|
+
.filter((l) => l.trim());
|
|
832
|
+
if (nonempty.length !== 1) return false;
|
|
833
|
+
// The length check above leaves exactly one line.
|
|
834
|
+
return nonempty[0]!.replace(/^[\s`*_>]+/, "") === WAIT_SCOUT;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export function hasStepAsk(msg: unknown): boolean {
|
|
838
|
+
if (isWaitScoutExact(msg) || closingLineOf(msg)) return true;
|
|
839
|
+
return String(msg || "")
|
|
840
|
+
.split("\n")
|
|
841
|
+
.map((l) => l.replace(/^[\s`*_>]+/, "").trim())
|
|
842
|
+
.filter(Boolean)
|
|
843
|
+
.some((l) => l.includes("?"));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function firstNonEmptyStripped(msg: unknown): string {
|
|
847
|
+
for (const line of String(msg || "").split("\n")) {
|
|
848
|
+
if (!line.trim()) continue;
|
|
849
|
+
return line.replace(/^[\s`*_>]+/, "");
|
|
850
|
+
}
|
|
851
|
+
return "";
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function payloadAssistantMessage(data: HookInput): string {
|
|
855
|
+
return data.lastAssistantMessage || data.last_assistant_message || "";
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function isAssistantRecord(obj: unknown): obj is Record<string, unknown> {
|
|
859
|
+
if (!isRecord(obj)) return false;
|
|
860
|
+
const type = String(obj.type || "").toLowerCase();
|
|
861
|
+
if (type === "user" || type === "human" || type === "tool" || type === "tool_result") {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
if (type === "assistant") return true;
|
|
865
|
+
const role = String(obj.role || (isRecord(obj.message) && obj.message.role) || "").toLowerCase();
|
|
866
|
+
if (role === "user" || role === "human") return false;
|
|
867
|
+
return role === "assistant";
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function assistantTextFromRecord(obj: unknown): string | null {
|
|
871
|
+
if (!isAssistantRecord(obj)) return null;
|
|
872
|
+
const msg = isRecord(obj.message) ? obj.message : obj;
|
|
873
|
+
const content = msg.content ?? obj.content;
|
|
874
|
+
if (Array.isArray(content)) {
|
|
875
|
+
const texts: string[] = [];
|
|
876
|
+
for (const part of content) {
|
|
877
|
+
if (!isRecord(part)) continue;
|
|
878
|
+
const pt = String(part.type || "");
|
|
879
|
+
if (pt === "tool_use" || pt === "tool_result" || pt === "function_call") continue;
|
|
880
|
+
if (typeof part.text === "string" && part.text) texts.push(part.text);
|
|
881
|
+
}
|
|
882
|
+
const joined = texts.join("\n").trim();
|
|
883
|
+
return joined ? joined : null;
|
|
884
|
+
}
|
|
885
|
+
if (typeof content === "string" && content.trim()) return content;
|
|
886
|
+
if (typeof obj.text === "string" && obj.text.trim()) return obj.text;
|
|
887
|
+
return null;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
export function lastAssistantFromTranscript(filePath: string): string {
|
|
891
|
+
try {
|
|
892
|
+
if (!filePath) return "";
|
|
893
|
+
const st = fs.statSync(filePath);
|
|
894
|
+
const start = Math.max(0, st.size - TRANSCRIPT_TAIL_BYTES);
|
|
895
|
+
const fd = fs.openSync(filePath, "r");
|
|
896
|
+
try {
|
|
897
|
+
const buf = Buffer.alloc(st.size - start);
|
|
898
|
+
fs.readSync(fd, buf, 0, buf.length, start);
|
|
899
|
+
const text = buf.toString("utf8");
|
|
900
|
+
const lines = text.split("\n");
|
|
901
|
+
if (start > 0 && lines.length) lines.shift();
|
|
902
|
+
let last = "";
|
|
903
|
+
for (const line of lines) {
|
|
904
|
+
const t = line.trim();
|
|
905
|
+
if (!t) continue;
|
|
906
|
+
let rec: unknown;
|
|
907
|
+
try {
|
|
908
|
+
rec = JSON.parse(t);
|
|
909
|
+
} catch {
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
const extracted = assistantTextFromRecord(rec);
|
|
913
|
+
if (extracted) last = extracted;
|
|
914
|
+
}
|
|
915
|
+
return last;
|
|
916
|
+
} finally {
|
|
917
|
+
fs.closeSync(fd);
|
|
918
|
+
}
|
|
919
|
+
} catch {
|
|
920
|
+
return "";
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function stop(data: HookInput): HookResult {
|
|
925
|
+
if (data.subagentType || data.subagent_type) {
|
|
926
|
+
return interceptNeed(data, "Stop");
|
|
927
|
+
}
|
|
928
|
+
if (data.stopHookActive || data.stop_hook_active) {
|
|
929
|
+
return PASS;
|
|
930
|
+
}
|
|
931
|
+
const reason = String(data.reason || "");
|
|
932
|
+
if (reason && reason !== "end_turn") return PASS;
|
|
933
|
+
|
|
934
|
+
const payloadMsg = payloadAssistantMessage(data);
|
|
935
|
+
let msg = payloadMsg;
|
|
936
|
+
if (!msg) {
|
|
937
|
+
const tp = data.transcript_path || data.transcriptPath || "";
|
|
938
|
+
msg = lastAssistantFromTranscript(tp);
|
|
939
|
+
}
|
|
940
|
+
writeSessionReceipt(data, msg);
|
|
941
|
+
if (isWaitScoutExact(msg)) return PASS;
|
|
942
|
+
const named = closingLineOf(msg);
|
|
943
|
+
const reviewReason = closingReviewReason(data, named);
|
|
944
|
+
if (reviewReason) return replyWith({ decision: "block", reason: reviewReason });
|
|
945
|
+
const approvalReason = approvalBlockReason(data, msg);
|
|
946
|
+
if (approvalReason) {
|
|
947
|
+
writeStamp(data, APPROVAL_BLOCK_STAMP, "1");
|
|
948
|
+
return replyWith({ decision: "block", reason: approvalReason });
|
|
949
|
+
}
|
|
950
|
+
const mode = effectiveModeOf(data);
|
|
951
|
+
if (mode === "away" && !named && endsWithQuestion(msg)) return cappedStopBlock(data, AWAY_STOP_REASON);
|
|
952
|
+
if (mode === "ask" && !hasStepAsk(msg)) return cappedStopBlock(data, ASK_STOP_REASON);
|
|
953
|
+
return PASS;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// The ask and away blocks share one counter and its cap, so a true blocker still reaches the person.
|
|
957
|
+
function cappedStopBlock(data: HookInput, reason: string): HookResult {
|
|
958
|
+
const n = readStampInt(data, "stop-block");
|
|
959
|
+
if (n >= MAX_STOP) return PASS;
|
|
960
|
+
writeStamp(data, "stop-block", String(n + 1));
|
|
961
|
+
return replyWith({ decision: "block", reason });
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function endsWithQuestion(msg: string): boolean {
|
|
965
|
+
const last = msg
|
|
966
|
+
.split("\n")
|
|
967
|
+
.map((l) => l.replace(/[\s`*_]+$/, ""))
|
|
968
|
+
.filter(Boolean)
|
|
969
|
+
.at(-1);
|
|
970
|
+
return Boolean(last?.endsWith("?"));
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function interceptNeed(data: HookInput, hookEventName: string): HookResult {
|
|
974
|
+
const roots = resolveRoots(data);
|
|
975
|
+
if (data.stopHookActive || data.stop_hook_active) {
|
|
976
|
+
return PASS;
|
|
977
|
+
}
|
|
978
|
+
const msg = data.lastAssistantMessage || data.last_assistant_message || "";
|
|
979
|
+
const parsed = parseNeed(msg);
|
|
980
|
+
if (!parsed.ok) {
|
|
981
|
+
return PASS;
|
|
982
|
+
}
|
|
983
|
+
const jobs = parsed.jobs;
|
|
984
|
+
if (jobs.length > 4 || !jobs.every((j) => INTERCEPT_JOBS.has(j.job))) {
|
|
985
|
+
return PASS;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
let n = readStampInt(data, "need-intercept");
|
|
989
|
+
if (n >= MAX_INTERCEPT) {
|
|
990
|
+
return PASS;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const cwd = roots.cwd;
|
|
994
|
+
const parts: string[] = [];
|
|
995
|
+
for (const job of jobs) {
|
|
996
|
+
let result: { fallback: boolean; text: string };
|
|
997
|
+
try {
|
|
998
|
+
const jobCwd = job.cwd ? resolveJobCwd(job.cwd, cwd) : cwd;
|
|
999
|
+
if (!jobCwd) {
|
|
1000
|
+
result = { fallback: true, text: "FALLBACK\n" };
|
|
1001
|
+
} else {
|
|
1002
|
+
result = runJob({
|
|
1003
|
+
job: job.job,
|
|
1004
|
+
query: job.query,
|
|
1005
|
+
cwd: jobCwd,
|
|
1006
|
+
root: roots.root,
|
|
1007
|
+
path: job.path,
|
|
1008
|
+
glob: job.glob,
|
|
1009
|
+
stash: job.stash,
|
|
1010
|
+
from: job.from,
|
|
1011
|
+
to: job.to,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
} catch {
|
|
1015
|
+
result = { fallback: true, text: "FALLBACK\n" };
|
|
1016
|
+
}
|
|
1017
|
+
if (result.fallback) {
|
|
1018
|
+
return PASS;
|
|
1019
|
+
}
|
|
1020
|
+
parts.push(result.text.trimEnd());
|
|
1021
|
+
}
|
|
1022
|
+
writeStamp(data, "need-intercept", String(n + 1));
|
|
1023
|
+
const reason = parts.join("\n");
|
|
1024
|
+
return replyWith({
|
|
1025
|
+
decision: "block",
|
|
1026
|
+
hookSpecificOutput: {
|
|
1027
|
+
hookEventName,
|
|
1028
|
+
additionalContext: reason,
|
|
1029
|
+
},
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function stampPath(data: HookInput, prefix: string): string {
|
|
1034
|
+
const root = resolveRoots(data).root;
|
|
1035
|
+
const sid = sessionIdOf(data) || "default";
|
|
1036
|
+
return path.join(root, ORCHESTRATOR_LOGS_DIR, prefix + "-" + sid);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function legacyStampPath(data: HookInput, prefix: string): string {
|
|
1040
|
+
const root = resolveRoots(data).root;
|
|
1041
|
+
const sid = sessionIdOf(data) || "default";
|
|
1042
|
+
return path.join(root, LEGACY_ORCHESTRATOR_LOGS_DIR, prefix + "-" + sid);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function resolveStamp(data: HookInput, prefix: string): string | null {
|
|
1046
|
+
const neu = stampPath(data, prefix);
|
|
1047
|
+
if (fs.existsSync(neu)) return neu;
|
|
1048
|
+
const old = legacyStampPath(data, prefix);
|
|
1049
|
+
if (fs.existsSync(old)) return old;
|
|
1050
|
+
return null;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function writeStamp(data: HookInput, prefix: string, body: string): string {
|
|
1054
|
+
const p = stampPath(data, prefix);
|
|
1055
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1056
|
+
fs.writeFileSync(p, body);
|
|
1057
|
+
return p;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function readStampInt(data: HookInput, prefix: string): number {
|
|
1061
|
+
const p = resolveStamp(data, prefix);
|
|
1062
|
+
if (!p) return 0;
|
|
1063
|
+
const n = parseInt(fs.readFileSync(p, "utf8"), 10);
|
|
1064
|
+
if (!Number.isFinite(n) || n < 0) return 0;
|
|
1065
|
+
return n;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function sessionIdOf(data: HookInput): string {
|
|
1069
|
+
return readEnv().GROK_SESSION_ID || data.sessionId || data.session_id || "";
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function unlinkQuiet(p: string): void {
|
|
1073
|
+
try {
|
|
1074
|
+
fs.unlinkSync(p);
|
|
1075
|
+
} catch {
|
|
1076
|
+
/* missing is fine */
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function unlinkStamp(data: HookInput, prefix: string): void {
|
|
1081
|
+
unlinkQuiet(stampPath(data, prefix));
|
|
1082
|
+
unlinkQuiet(legacyStampPath(data, prefix));
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function sessionGateStampPath(data: HookInput, prefix: string): string | null {
|
|
1086
|
+
const root = resolveRoots(data).root;
|
|
1087
|
+
const sid = sessionIdOf(data);
|
|
1088
|
+
if (!root || !sid || sid === "default") return null;
|
|
1089
|
+
return path.join(root, ORCHESTRATOR_LOGS_DIR, prefix + "-" + sid);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function resolveSessionGateStamp(data: HookInput): string | null {
|
|
1093
|
+
for (const prefix of [SESSION_GATE_STAMP, AUTO_ASK_STAMP]) {
|
|
1094
|
+
const neu = sessionGateStampPath(data, prefix);
|
|
1095
|
+
if (neu && fs.existsSync(neu)) return neu;
|
|
1096
|
+
const old = path.join(resolveRoots(data).root, LEGACY_ORCHESTRATOR_LOGS_DIR, prefix + "-" + sessionIdOf(data));
|
|
1097
|
+
if (sessionIdOf(data) && fs.existsSync(old)) return old;
|
|
1098
|
+
}
|
|
1099
|
+
return null;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function applySessionGateSlash(data: HookInput, prompt: string): void {
|
|
1103
|
+
let slash = "";
|
|
1104
|
+
if (AUTO_RE.test(prompt)) slash = "auto";
|
|
1105
|
+
else if (ASK_RE.test(prompt)) slash = "ask";
|
|
1106
|
+
else if (AWAY_RE.test(prompt)) slash = "away";
|
|
1107
|
+
if (!slash) return;
|
|
1108
|
+
const p = sessionGateStampPath(data, SESSION_GATE_STAMP);
|
|
1109
|
+
const legacy = sessionGateStampPath(data, AUTO_ASK_STAMP);
|
|
1110
|
+
if (!p || !legacy) return;
|
|
1111
|
+
unlinkQuiet(legacy);
|
|
1112
|
+
if (slash === "auto") {
|
|
1113
|
+
unlinkQuiet(p);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1117
|
+
fs.writeFileSync(p, slash);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The mode this session runs: the newest in-progress plan or roadmap in this session's approvals with a valid `mode:`,
|
|
1122
|
+
* else the /ask or /away stamp, else auto. Another session's files never count.
|
|
1123
|
+
*/
|
|
1124
|
+
export function effectiveModeOf(data: HookInput): Mode {
|
|
1125
|
+
const file = approvalsFile(data);
|
|
1126
|
+
for (const abs of file ? readLines(file).reverse() : []) {
|
|
1127
|
+
try {
|
|
1128
|
+
const fm = frontmatterOf(fs.readFileSync(abs, "utf8"));
|
|
1129
|
+
const mode = parseMode(fm.mode);
|
|
1130
|
+
if (fm.status === "in-progress" && mode) return mode;
|
|
1131
|
+
} catch {
|
|
1132
|
+
/* skip unreadable */
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
try {
|
|
1136
|
+
const p = resolveSessionGateStamp(data);
|
|
1137
|
+
const stamped = p ? parseMode(fs.readFileSync(p, "utf8").trim()) : null;
|
|
1138
|
+
if (stamped) return stamped;
|
|
1139
|
+
} catch {
|
|
1140
|
+
/* fall through */
|
|
1141
|
+
}
|
|
1142
|
+
return "auto";
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
export function effectiveSessionContext(data: HookInput): string {
|
|
1146
|
+
const gate = effectiveModeOf(data);
|
|
1147
|
+
const receipt = sessionReceiptContext(data);
|
|
1148
|
+
return receipt ? `sessionGate=${gate}. ${receipt}` : `sessionGate=${gate}`;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function eventKey(s: unknown): string {
|
|
1152
|
+
return String(s || "")
|
|
1153
|
+
.toLowerCase()
|
|
1154
|
+
.replace(/[^a-z0-9]/g, "");
|
|
1155
|
+
}
|