@agentwhy/cli 0.0.0-dev
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +2 -0
- package/README.md +181 -0
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/dist/adapter/claude-code/contract/assumptions.js +208 -0
- package/dist/adapter/claude-code/contract/denials.js +15 -0
- package/dist/adapter/claude-code/contract/entry-points.js +15 -0
- package/dist/adapter/claude-code/contract/fields.js +73 -0
- package/dist/adapter/claude-code/contract/hooks.js +96 -0
- package/dist/adapter/claude-code/contract/identifiers.js +15 -0
- package/dist/adapter/claude-code/contract/layout.js +29 -0
- package/dist/adapter/claude-code/contract/line-types.js +26 -0
- package/dist/adapter/claude-code/contract/projects.js +23 -0
- package/dist/adapter/claude-code/contract/recognition.js +14 -0
- package/dist/adapter/claude-code/contract/session-title.js +20 -0
- package/dist/adapter/claude-code/contract/settings.js +27 -0
- package/dist/adapter/claude-code/contract/task-notifications.js +33 -0
- package/dist/adapter/claude-code/contract/tools.js +37 -0
- package/dist/adapter/claude-code/contract/version.js +51 -0
- package/dist/adapter/claude-code/discovery/claude-code-project-catalogue.js +129 -0
- package/dist/adapter/claude-code/discovery/claude-code-recognition.js +46 -0
- package/dist/adapter/claude-code/discovery/claude-code-session-catalogue.js +100 -0
- package/dist/adapter/claude-code/discovery/claude-code-session-discovery.js +61 -0
- package/dist/adapter/claude-code/discovery/claude-code-session-titles.js +28 -0
- package/dist/adapter/claude-code/discovery/discovered-session.js +1 -0
- package/dist/adapter/claude-code/discovery/session-entries.js +41 -0
- package/dist/adapter/claude-code/discovery/transcript-tail.js +81 -0
- package/dist/adapter/claude-code/events/claude-code-session-source.js +197 -0
- package/dist/adapter/claude-code/events/delegation-index.js +15 -0
- package/dist/adapter/claude-code/events/transcript-scan.js +179 -0
- package/dist/adapter/claude-code/hooks/hook-output.js +42 -0
- package/dist/adapter/claude-code/hooks/pre-tool-use-input.js +23 -0
- package/dist/adapter/claude-code/hooks/stop-input.js +25 -0
- package/dist/adapter/claude-code/hooks/subagent-stop-input.js +26 -0
- package/dist/adapter/claude-code/policy/deny-rules.js +46 -0
- package/dist/adapter/claude-code/probe/claude-code-probe.js +86 -0
- package/dist/adapter/claude-code/probe/collectors/agent-tool-input-key-collector.js +24 -0
- package/dist/adapter/claude-code/probe/collectors/denial-kind-collector.js +20 -0
- package/dist/adapter/claude-code/probe/collectors/denied-call-collector.js +11 -0
- package/dist/adapter/claude-code/probe/collectors/identifier-collector.js +19 -0
- package/dist/adapter/claude-code/probe/collectors/line-collector.js +1 -0
- package/dist/adapter/claude-code/probe/collectors/line-type-collector.js +29 -0
- package/dist/adapter/claude-code/probe/collectors/sidechain-collector.js +20 -0
- package/dist/adapter/claude-code/probe/collectors/task-notification-collector.js +51 -0
- package/dist/adapter/claude-code/probe/collectors/tool-result-reference-collector.js +31 -0
- package/dist/adapter/claude-code/probe/collectors/tool-use-collector.js +24 -0
- package/dist/adapter/claude-code/probe/collectors/tool-version-collector.js +14 -0
- package/dist/adapter/claude-code/probe/collectors/top-level-key-collector.js +14 -0
- package/dist/adapter/claude-code/probe/collectors/unknown-line-type-key-collector.js +24 -0
- package/dist/adapter/claude-code/probe/collectors/working-directory-collector.js +28 -0
- package/dist/adapter/claude-code/probe/doctor-report.js +1 -0
- package/dist/adapter/claude-code/probe/meta-tally.js +42 -0
- package/dist/adapter/claude-code/probe/session-probe.js +1 -0
- package/dist/adapter/claude-code/probe/session-tally.js +41 -0
- package/dist/adapter/claude-code/probe/transcript-line.js +30 -0
- package/dist/adapter/claude-code/probe/transcript-tally.js +74 -0
- package/dist/adapter/claude-code/settings/deny-entries.js +65 -0
- package/dist/adapter/claude-code/settings/hook-entries.js +303 -0
- package/dist/adapter/codex/contract/actions.js +69 -0
- package/dist/adapter/codex/contract/assumptions.js +55 -0
- package/dist/adapter/codex/contract/capabilities.js +24 -0
- package/dist/adapter/codex/contract/delegations.js +30 -0
- package/dist/adapter/codex/contract/deliveries.js +25 -0
- package/dist/adapter/codex/contract/envelope.js +33 -0
- package/dist/adapter/codex/contract/hook-refusals.js +14 -0
- package/dist/adapter/codex/contract/hooks.js +46 -0
- package/dist/adapter/codex/contract/messages.js +47 -0
- package/dist/adapter/codex/contract/permissions.js +27 -0
- package/dist/adapter/codex/contract/reviews.js +13 -0
- package/dist/adapter/codex/contract/session.js +31 -0
- package/dist/adapter/codex/contract/turns.js +23 -0
- package/dist/adapter/codex/contract/version.js +15 -0
- package/dist/adapter/codex/discovery/codex-conversations.js +44 -0
- package/dist/adapter/codex/discovery/codex-project-catalogue.js +49 -0
- package/dist/adapter/codex/discovery/codex-recognition.js +20 -0
- package/dist/adapter/codex/discovery/codex-session-catalogue.js +28 -0
- package/dist/adapter/codex/discovery/codex-session-discovery.js +115 -0
- package/dist/adapter/codex/discovery/codex-session-index.js +21 -0
- package/dist/adapter/codex/discovery/codex-session-titles.js +135 -0
- package/dist/adapter/codex/discovery/session-header.js +86 -0
- package/dist/adapter/codex/discovery/session-owners.js +11 -0
- package/dist/adapter/codex/discovery/session-roots.js +49 -0
- package/dist/adapter/codex/events/action-items.js +138 -0
- package/dist/adapter/codex/events/capability-records.js +26 -0
- package/dist/adapter/codex/events/codex-session-source.js +121 -0
- package/dist/adapter/codex/events/hook-refusals.js +27 -0
- package/dist/adapter/codex/events/rollout-scan.js +614 -0
- package/dist/adapter/codex/events/turn-permissions.js +60 -0
- package/dist/adapter/codex/hooks/pre-tool-use-input.js +24 -0
- package/dist/adapter/codex/hooks/stop-refusals.js +57 -0
- package/dist/adapter/codex/probe/codex-doctor-report.js +1 -0
- package/dist/adapter/codex/probe/codex-probe.js +268 -0
- package/dist/adapter/codex/settings/codex-hooks.js +83 -0
- package/dist/cli/cli-command.js +1 -0
- package/dist/cli/command-router.js +42 -0
- package/dist/cli/commands/check-cli-command.js +128 -0
- package/dist/cli/commands/codex-stop-cli-command.js +27 -0
- package/dist/cli/commands/doctor-arguments.js +32 -0
- package/dist/cli/commands/doctor-cli-command.js +31 -0
- package/dist/cli/commands/doctor-usage.js +14 -0
- package/dist/cli/commands/init-cli-command.js +123 -0
- package/dist/cli/commands/menu-cli-command.js +58 -0
- package/dist/cli/commands/notify-cli-command.js +158 -0
- package/dist/cli/commands/refuse-cli-command.js +79 -0
- package/dist/cli/commands/report-arguments.js +62 -0
- package/dist/cli/commands/report-cli-command.js +36 -0
- package/dist/cli/commands/report-usage.js +41 -0
- package/dist/cli/commands/sessions-cli-command.js +184 -0
- package/dist/cli/commands/start-cli-command.js +83 -0
- package/dist/cli/commands/watch-cli-command.js +142 -0
- package/dist/cli/exit-codes.js +13 -0
- package/dist/cli/project-switches.js +46 -0
- package/dist/cli.js +80 -0
- package/dist/composition-root.js +411 -0
- package/dist/core/access/command-line.js +454 -0
- package/dist/core/access/listing.js +58 -0
- package/dist/core/access/path-shape.js +59 -0
- package/dist/core/access/path-tokens.js +37 -0
- package/dist/core/access/protected-access.js +310 -0
- package/dist/core/access/protected-values.js +102 -0
- package/dist/core/access/recorded-effect.js +67 -0
- package/dist/core/access/returns.js +173 -0
- package/dist/core/access/search-output.js +34 -0
- package/dist/core/access/search-reach.js +268 -0
- package/dist/core/access/uses.js +143 -0
- package/dist/core/access/words-before.js +52 -0
- package/dist/core/agent.js +1 -0
- package/dist/core/capability.js +11 -0
- package/dist/core/combined-project-catalogue.js +36 -0
- package/dist/core/combined-session-catalogue.js +25 -0
- package/dist/core/completeness.js +10 -0
- package/dist/core/context.js +1 -0
- package/dist/core/correlation/correlate.js +329 -0
- package/dist/core/delegation.js +1 -0
- package/dist/core/detector/prompt-signals.js +78 -0
- package/dist/core/entry-point.js +1 -0
- package/dist/core/event.js +1 -0
- package/dist/core/evidence.js +9 -0
- package/dist/core/late-message.js +32 -0
- package/dist/core/message.js +1 -0
- package/dist/core/nearest-project.js +28 -0
- package/dist/core/policy/default-policy.js +32 -0
- package/dist/core/policy/glob.js +56 -0
- package/dist/core/policy/parse-policy.js +70 -0
- package/dist/core/policy/policy.js +20 -0
- package/dist/core/policy/resolve-policy.js +16 -0
- package/dist/core/project-catalogue.js +1 -0
- package/dist/core/project-root.js +133 -0
- package/dist/core/provider-titles.js +10 -0
- package/dist/core/redaction/entropy.js +46 -0
- package/dist/core/redaction/redacted.js +4 -0
- package/dist/core/redaction/redactor.js +339 -0
- package/dist/core/redaction/secret-patterns.js +22 -0
- package/dist/core/redaction/value-shapes.js +81 -0
- package/dist/core/redaction/value-trace.js +110 -0
- package/dist/core/review.js +1 -0
- package/dist/core/session-catalogue.js +19 -0
- package/dist/core/session-filter.js +42 -0
- package/dist/core/session-format.js +27 -0
- package/dist/core/session-model.js +1 -0
- package/dist/core/session-reader.js +22 -0
- package/dist/core/session-records.js +1 -0
- package/dist/core/session-source.js +1 -0
- package/dist/core/session-titles.js +21 -0
- package/dist/core/turn.js +1 -0
- package/dist/doctor/codex-doctor.js +14 -0
- package/dist/doctor/doctor-use-case.js +1 -0
- package/dist/doctor/format-selecting-doctor.js +28 -0
- package/dist/doctor/render/attention/attention-rule.js +1 -0
- package/dist/doctor/render/attention/doctor-attention-rules.js +28 -0
- package/dist/doctor/render/attention/rules/damaged-meta-files-rule.js +8 -0
- package/dist/doctor/render/attention/rules/damaged-transcripts-rule.js +9 -0
- package/dist/doctor/render/attention/rules/incomplete-subagent-pairs-rule.js +6 -0
- package/dist/doctor/render/attention/rules/missing-main-transcript-rule.js +5 -0
- package/dist/doctor/render/attention/rules/missing-spilled-results-rule.js +6 -0
- package/dist/doctor/render/attention/rules/multiple-working-directories-rule.js +15 -0
- package/dist/doctor/render/attention/rules/unknown-denial-kinds-rule.js +5 -0
- package/dist/doctor/render/attention/rules/unknown-line-types-rule.js +6 -0
- package/dist/doctor/render/attention/rules/unrecognised-entries-rule.js +6 -0
- package/dist/doctor/render/attention/rules/unusable-directories-rule.js +12 -0
- package/dist/doctor/render/attention/transcript-scopes.js +6 -0
- package/dist/doctor/render/codex-text-doctor-renderer.js +97 -0
- package/dist/doctor/render/doctor-renderers.js +1 -0
- package/dist/doctor/render/json-doctor-renderer.js +7 -0
- package/dist/doctor/render/output-format.js +1 -0
- package/dist/doctor/render/text-doctor-renderer.js +124 -0
- package/dist/doctor/session-doctor.js +16 -0
- package/dist/infrastructure/clack-asker.js +24 -0
- package/dist/infrastructure/clack-chooser.js +40 -0
- package/dist/infrastructure/clack-multi-chooser.js +31 -0
- package/dist/infrastructure/file-alert-store.js +120 -0
- package/dist/infrastructure/file-checked-store.js +55 -0
- package/dist/infrastructure/file-mark-store.js +67 -0
- package/dist/infrastructure/file-onboarding-store.js +93 -0
- package/dist/infrastructure/folder-window.js +26 -0
- package/dist/infrastructure/node-agentwhy-invocation.js +99 -0
- package/dist/infrastructure/node-browser.js +40 -0
- package/dist/infrastructure/node-file-system.js +111 -0
- package/dist/infrastructure/node-local-server.js +115 -0
- package/dist/infrastructure/node-text-input.js +26 -0
- package/dist/infrastructure/os-notifier.js +21 -0
- package/dist/infrastructure/osascript-folder-chooser.js +53 -0
- package/dist/infrastructure/powershell-folder-chooser.js +78 -0
- package/dist/infrastructure/real-directory.js +18 -0
- package/dist/infrastructure/stream-printer.js +11 -0
- package/dist/infrastructure/terminal-banner.js +19 -0
- package/dist/ports/agentwhy-invocation.js +1 -0
- package/dist/ports/alert-store.js +1 -0
- package/dist/ports/asker.js +1 -0
- package/dist/ports/browser.js +1 -0
- package/dist/ports/checked-store.js +1 -0
- package/dist/ports/chooser.js +1 -0
- package/dist/ports/directory-reader.js +1 -0
- package/dist/ports/file-access-error.js +11 -0
- package/dist/ports/file-reader.js +1 -0
- package/dist/ports/file-tail-reader.js +1 -0
- package/dist/ports/file-writer.js +1 -0
- package/dist/ports/folder-chooser.js +1 -0
- package/dist/ports/local-server.js +1 -0
- package/dist/ports/mark-store.js +10 -0
- package/dist/ports/multi-chooser.js +1 -0
- package/dist/ports/notifier.js +1 -0
- package/dist/ports/onboarding-store.js +1 -0
- package/dist/ports/printer.js +1 -0
- package/dist/ports/text-input.js +1 -0
- package/dist/refuse/command-reach.js +135 -0
- package/dist/refuse/command-refusal.js +88 -0
- package/dist/refuse/render/codex-stop-words.js +21 -0
- package/dist/refuse/render/refusal-words.js +34 -0
- package/dist/report/build-report.js +1037 -0
- package/dist/report/check/actions-digest.js +73 -0
- package/dist/report/check/check-lines.js +83 -0
- package/dist/report/check/mark-request.js +90 -0
- package/dist/report/check/marks.js +84 -0
- package/dist/report/check/render/text-digest-renderer.js +332 -0
- package/dist/report/check/session-actions.js +90 -0
- package/dist/report/check/session-check.js +91 -0
- package/dist/report/choose-policy.js +61 -0
- package/dist/report/flow-reads.js +18 -0
- package/dist/report/private-files/tell-lists.js +84 -0
- package/dist/report/project-rules.js +33 -0
- package/dist/report/render/home-relative.js +18 -0
- package/dist/report/render/html-head.js +26 -0
- package/dist/report/render/html-report-components.js +48 -0
- package/dist/report/render/html-report-styles.js +500 -0
- package/dist/report/render/html-sidebar.js +42 -0
- package/dist/report/render/path-tail.js +14 -0
- package/dist/report/render/report-copy.js +1177 -0
- package/dist/report/render/report-page/advanced-view.js +167 -0
- package/dist/report/render/report-page/file-story.js +114 -0
- package/dist/report/render/report-page/files-view.js +381 -0
- package/dist/report/render/report-page/files.js +77 -0
- package/dist/report/render/report-page/fix-wizard-script.js +269 -0
- package/dist/report/render/report-page/fix-wizard.js +310 -0
- package/dist/report/render/report-page/helpers-view.js +383 -0
- package/dist/report/render/report-page/helpers.js +51 -0
- package/dist/report/render/report-page/item-names.js +63 -0
- package/dist/report/render/report-page/protect-patterns.js +13 -0
- package/dist/report/render/report-page/providers.js +208 -0
- package/dist/report/render/report-page/record-view.js +367 -0
- package/dist/report/render/report-page/report-page-renderer.js +129 -0
- package/dist/report/render/report-page/report-views.js +31 -0
- package/dist/report/render/report-page/story-window.js +356 -0
- package/dist/report/render/report-page/times.js +25 -0
- package/dist/report/render/report-page/to-do-view.js +299 -0
- package/dist/report/render/report-page/to-do.js +152 -0
- package/dist/report/render/report-page.js +1 -0
- package/dist/report/render/session-view.js +250 -0
- package/dist/report/render/text-report-renderer.js +981 -0
- package/dist/report/render/ui/add-file-popup.js +122 -0
- package/dist/report/render/ui/app-sidebar.js +78 -0
- package/dist/report/render/ui/ask-panel.js +80 -0
- package/dist/report/render/ui/avatar.js +19 -0
- package/dist/report/render/ui/backdrop.js +29 -0
- package/dist/report/render/ui/brand-mark.js +4 -0
- package/dist/report/render/ui/button.js +67 -0
- package/dist/report/render/ui/callout.js +20 -0
- package/dist/report/render/ui/chat-example.js +17 -0
- package/dist/report/render/ui/checklist.js +50 -0
- package/dist/report/render/ui/confirm-dialog.js +34 -0
- package/dist/report/render/ui/data-table.js +49 -0
- package/dist/report/render/ui/drawer.js +25 -0
- package/dist/report/render/ui/file-chip.js +37 -0
- package/dist/report/render/ui/fold-line.js +22 -0
- package/dist/report/render/ui/guide-card.js +25 -0
- package/dist/report/render/ui/hero.js +37 -0
- package/dist/report/render/ui/labelled-select.js +10 -0
- package/dist/report/render/ui/live-script.js +222 -0
- package/dist/report/render/ui/local-date.js +58 -0
- package/dist/report/render/ui/mode-icon.js +6 -0
- package/dist/report/render/ui/page-shell.js +101 -0
- package/dist/report/render/ui/pill-tabs.js +47 -0
- package/dist/report/render/ui/popup.js +77 -0
- package/dist/report/render/ui/progress.js +35 -0
- package/dist/report/render/ui/project-list.js +284 -0
- package/dist/report/render/ui/scope-choice.js +19 -0
- package/dist/report/render/ui/stats.js +21 -0
- package/dist/report/render/ui/status-icon.js +30 -0
- package/dist/report/render/ui/status-look.js +21 -0
- package/dist/report/render/ui/switch.js +11 -0
- package/dist/report/render/ui/tag.js +19 -0
- package/dist/report/render/ui/task-list.js +56 -0
- package/dist/report/render/ui/tokens.js +46 -0
- package/dist/report/render/ui/update-notice.js +146 -0
- package/dist/report/render/ui/words/app-words.js +163 -0
- package/dist/report/render/ui/words/conversations-words.js +457 -0
- package/dist/report/render/ui/words/month-words.js +59 -0
- package/dist/report/render/ui/words/onboarding-words.js +446 -0
- package/dist/report/render/ui/words/projects-words.js +217 -0
- package/dist/report/render/ui/words/report-words.js +2099 -0
- package/dist/report/render/ui/words/settings-words.js +611 -0
- package/dist/report/render/ui/words/to-fix-words.js +287 -0
- package/dist/report/report-model.js +2 -0
- package/dist/report/report-use-case.js +1 -0
- package/dist/report/rule-names.js +20 -0
- package/dist/report/session-report.js +163 -0
- package/dist/report/start/app-nav.js +37 -0
- package/dist/report/start/conversations/calendar-view.js +104 -0
- package/dist/report/start/conversations/conversation-columns.js +187 -0
- package/dist/report/start/conversations/conversations-renderer.js +54 -0
- package/dist/report/start/conversations/empty-week.js +120 -0
- package/dist/report/start/conversations/files-window.js +166 -0
- package/dist/report/start/conversations/period-section.js +280 -0
- package/dist/report/start/conversations/periods-script.js +440 -0
- package/dist/report/start/conversations/periods.js +110 -0
- package/dist/report/start/conversations/week-view.js +30 -0
- package/dist/report/start/conversations/weeks.js +17 -0
- package/dist/report/start/month/month-renderer.js +50 -0
- package/dist/report/start/month/month-view.js +135 -0
- package/dist/report/start/month/months.js +17 -0
- package/dist/report/start/onboarding/done.js +171 -0
- package/dist/report/start/onboarding/finish-onboarding.js +93 -0
- package/dist/report/start/onboarding/intro.js +100 -0
- package/dist/report/start/onboarding/onboarding-changes.js +40 -0
- package/dist/report/start/onboarding/onboarding-renderer.js +195 -0
- package/dist/report/start/onboarding/onboarding-script.js +400 -0
- package/dist/report/start/onboarding/onboarding-view.js +98 -0
- package/dist/report/start/onboarding/project-step.js +210 -0
- package/dist/report/start/onboarding/steps.js +239 -0
- package/dist/report/start/onboarding/welcome.js +66 -0
- package/dist/report/start/page-notice.js +10 -0
- package/dist/report/start/projects/index-projects.js +43 -0
- package/dist/report/start/projects/moved-page.js +82 -0
- package/dist/report/start/projects/project-switch.js +138 -0
- package/dist/report/start/projects/projects-window.js +67 -0
- package/dist/report/start/projects/temporary-space.js +18 -0
- package/dist/report/start/render/air-datepicker-vendor.js +27 -0
- package/dist/report/start/render/session-status.js +41 -0
- package/dist/report/start/render/start-words.js +84 -0
- package/dist/report/start/repository.js +28 -0
- package/dist/report/start/serve/index-handler.js +372 -0
- package/dist/report/start/serve/onboarding-request.js +50 -0
- package/dist/report/start/serve/settings-request.js +1 -0
- package/dist/report/start/serve/settings-setup.js +193 -0
- package/dist/report/start/session-index.js +1 -0
- package/dist/report/start/session-start.js +974 -0
- package/dist/report/start/settings/alerts-tab.js +104 -0
- package/dist/report/start/settings/files-tab.js +169 -0
- package/dist/report/start/settings/general-tab.js +71 -0
- package/dist/report/start/settings/settings-renderer.js +254 -0
- package/dist/report/start/settings/settings-script.js +161 -0
- package/dist/report/start/settings/settings-view.js +240 -0
- package/dist/report/start/settings/settings-windows.js +334 -0
- package/dist/report/start/settings-files.js +43 -0
- package/dist/report/start/to-fix/done-list.js +45 -0
- package/dist/report/start/to-fix/file-window.js +110 -0
- package/dist/report/start/to-fix/to-fix-list.js +70 -0
- package/dist/report/start/to-fix/to-fix-renderer.js +237 -0
- package/dist/report/start/to-fix/to-fix-script.js +94 -0
- package/dist/report/start/to-fix/to-fix-view.js +124 -0
- package/dist/report/watch/agent-alert.js +72 -0
- package/dist/report/watch/notice-choices.js +35 -0
- package/dist/report/watch/notice-settings.js +65 -0
- package/dist/report/watch/preferences.js +100 -0
- package/dist/report/watch/render/instruction-words.js +54 -0
- package/dist/report/watch/render/notice-words.js +182 -0
- package/dist/report/watch/subagent-watch.js +345 -0
- package/dist/report/watch/watch-notice.js +1 -0
- package/dist/setup/behind.js +18 -0
- package/dist/setup/codex-mirror.js +171 -0
- package/dist/setup/not-a-project.js +19 -0
- package/dist/setup/project-setup.js +644 -0
- package/dist/setup/protected-patterns.js +30 -0
- package/dist/shared/colour.js +23 -0
- package/dist/shared/compare.js +4 -0
- package/dist/shared/content-version.js +13 -0
- package/dist/shared/counter.js +11 -0
- package/dist/shared/file-stamp.js +4 -0
- package/dist/shared/json.js +13 -0
- package/dist/shared/label.js +13 -0
- package/dist/shared/package-name.js +6 -0
- package/dist/shared/plain-invocation.js +44 -0
- package/dist/shared/plain-version.js +21 -0
- package/dist/shared/printable.js +25 -0
- package/dist/shared/renderer.js +1 -0
- package/dist/shared/sentence.js +20 -0
- package/dist/shared/terminal-logo.js +41 -0
- package/dist/shared/text-table.js +15 -0
- package/dist/shared/wrap.js +29 -0
- package/package.json +70 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { HOME_PREFIX, HOOK_INPUT } from '../../adapter/claude-code/contract/hooks.js';
|
|
3
|
+
import { SETTINGS_FILES } from '../../adapter/claude-code/contract/settings.js';
|
|
4
|
+
import { watchInvocation } from '../../adapter/claude-code/settings/hook-entries.js';
|
|
5
|
+
import { blockNotice, chatNotice, terminalNotice } from '../../adapter/claude-code/hooks/hook-output.js';
|
|
6
|
+
import { parseJsonObject } from '../../shared/json.js';
|
|
7
|
+
import { parseStopInput } from '../../adapter/claude-code/hooks/stop-input.js';
|
|
8
|
+
import { parseSubagentStopInput } from '../../adapter/claude-code/hooks/subagent-stop-input.js';
|
|
9
|
+
import { withLateMessage } from '../../core/late-message.js';
|
|
10
|
+
import { buildReport } from '../build-report.js';
|
|
11
|
+
import { choosePolicy } from '../choose-policy.js';
|
|
12
|
+
import { alertOf, alerts, DEFAULT_THRESHOLD } from './agent-alert.js';
|
|
13
|
+
import { DEFAULT_CHANNELS, DEFAULT_CLEAN, DEFAULT_LANG, DEFAULT_SAID_AS, langOfLocale, } from './notice-choices.js';
|
|
14
|
+
import { instructionFor, reportCommand } from './render/instruction-words.js';
|
|
15
|
+
import { preferencesFor, readPreferences } from './preferences.js';
|
|
16
|
+
import { NOTICE_TITLE, withTitle } from './render/notice-words.js';
|
|
17
|
+
// Where they live now: both the notice and the preferences that choose it name them, and neither owns the other.
|
|
18
|
+
export { CLEAN_MODES, DEFAULT_CHANNELS, DEFAULT_CLEAN, NOTICE_CHANNELS } from './notice-choices.js';
|
|
19
|
+
/**
|
|
20
|
+
* The most a hook input is read to. The documented input is a few paths and the agent's last message; a megabyte of
|
|
21
|
+
* message is already unusual, and past this the input is not held at all.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_INPUT_BYTES = 16 * 1024 * 1024;
|
|
24
|
+
/**
|
|
25
|
+
* What was last said about the session's own agent, kept under a name no agent has
|
|
26
|
+
* (`the-agent-nobody-watches.md` R6): `alertOf` reads the whole transcript, so a value read in one turn is still
|
|
27
|
+
* there in the next, and without this the same line would arrive after every turn for the rest of the session.
|
|
28
|
+
*/
|
|
29
|
+
const OWN_REACH_SAID = 'session:said';
|
|
30
|
+
/**
|
|
31
|
+
* What the session has had said about it so far, kept under names no agent has, the way `OWN_REACH_SAID` is
|
|
32
|
+
* (`the-agent-tells-you.md` R6, R7). `take` clears a session's records, so both are written back after every read:
|
|
33
|
+
* a turn that finds nothing new still has to know what earlier turns found, or a clean line would say "nothing
|
|
34
|
+
* reached" over a session holding a value.
|
|
35
|
+
*/
|
|
36
|
+
const SESSION_COUNTS = 'session:counts';
|
|
37
|
+
/** That a clean line has been said once already, for the mode that says it once (R4). */
|
|
38
|
+
const CLEAN_SAID = 'session:clean';
|
|
39
|
+
/** That an unreadable preferences file has been said already: once a session, never after every turn (R24). */
|
|
40
|
+
const PREFERENCES_SAID = 'session:preferences';
|
|
41
|
+
/**
|
|
42
|
+
* The ways into Claude Code that have a person reading, as `CLAUDE_CODE_ENTRYPOINT` names them - measured on the
|
|
43
|
+
* terminal and on the editor extension (B9e2). `sdk-cli`, which is `claude -p`, is left out on purpose, and so is
|
|
44
|
+
* any name this version has not seen.
|
|
45
|
+
*/
|
|
46
|
+
const ATTENDED = ['cli', 'claude-vscode'];
|
|
47
|
+
/** The records the display keeps about the session itself. None of them is an agent, and none is ever counted as one. */
|
|
48
|
+
const KEPT_NAMES = [OWN_REACH_SAID, SESSION_COUNTS, CLEAN_SAID, PREFERENCES_SAID];
|
|
49
|
+
/** What the session had said about it before, plus what is being said now. Levels only, never what they were about. */
|
|
50
|
+
function countedWith(before, saying) {
|
|
51
|
+
return {
|
|
52
|
+
values: (before?.values ?? 0) + saying.filter((alert) => alert.level === 'value').length,
|
|
53
|
+
reached: (before?.reached ?? 0) + saying.filter((alert) => alert.level === 'reached').length,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* `agentwhy watch`: one finished agent, read the way `report` reads a session, and one notice about it
|
|
58
|
+
* (`specs/2026-09-16-when-an-agent-finishes.md`). Every way it can fail to look is a notice saying so - never an
|
|
59
|
+
* exception and never silence - because a hook that failed quietly reads exactly like an agent that did nothing.
|
|
60
|
+
*/
|
|
61
|
+
export class SubagentWatch {
|
|
62
|
+
#dependencies;
|
|
63
|
+
constructor(dependencies) {
|
|
64
|
+
this.#dependencies = dependencies;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* One hook run, whichever event it was. `Stop` says what the turn remembered; anything else is read as the
|
|
68
|
+
* `SubagentStop` this command began as. The event is read from the input rather than from a flag (R5), because
|
|
69
|
+
* the first attempt at measuring this put the probe on the wrong event and nothing ran at all.
|
|
70
|
+
*/
|
|
71
|
+
async run(options) {
|
|
72
|
+
const text = await this.#dependencies.input.readAll(MAX_INPUT_BYTES);
|
|
73
|
+
if (text === undefined)
|
|
74
|
+
return this.announce({ kind: 'not-checked', reason: 'input' }, options.channels ?? DEFAULT_CHANNELS);
|
|
75
|
+
// Where the session runs decides which project's answers apply, and it is on every input this hook is given.
|
|
76
|
+
const settled = await this.#settle(options, directoryIn(text));
|
|
77
|
+
const stop = parseStopInput(text);
|
|
78
|
+
if (!('unusable' in stop))
|
|
79
|
+
return this.#sayWhatTheTurnFound(stop.turn, settled);
|
|
80
|
+
const { notice, agent } = await this.#noticeFor(text, settled);
|
|
81
|
+
await this.#remember(notice, agent, settled);
|
|
82
|
+
return this.announce(notice, settled.channels);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* One question at a time, in the order R24 sets: the flag if it was written, then this project's answer, then this
|
|
86
|
+
* person's, then what this tool does when nobody has said anything. A file that cannot be read answers nothing and
|
|
87
|
+
* is remembered as unusable - the run still happens, on the built-in answers, because a hook that refused to work
|
|
88
|
+
* over a settings file is a hook that stops watching.
|
|
89
|
+
*/
|
|
90
|
+
async #settle(options, directory) {
|
|
91
|
+
const { files, preferencesPath } = this.#dependencies;
|
|
92
|
+
const read = readPreferences(await textOf(files, preferencesPath));
|
|
93
|
+
const chosen = read.kind === 'read' ? preferencesFor(read.preferences, directory) : {};
|
|
94
|
+
return {
|
|
95
|
+
...options,
|
|
96
|
+
on: options.on ?? chosen.on ?? DEFAULT_THRESHOLD,
|
|
97
|
+
clean: options.clean ?? chosen.clean ?? DEFAULT_CLEAN,
|
|
98
|
+
channels: options.channels ?? chosen.notify ?? DEFAULT_CHANNELS,
|
|
99
|
+
say: options.say ?? chosen.say ?? DEFAULT_SAID_AS,
|
|
100
|
+
// Chosen on the page or with `notify --lang`, then the system's, then English: never a flag, since no hook
|
|
101
|
+
// command line has one.
|
|
102
|
+
lang: chosen.lang ?? langOfLocale(this.#dependencies.locale) ?? DEFAULT_LANG,
|
|
103
|
+
preferencesUnusable: read.kind === 'unusable',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
async announce(notice, channels) {
|
|
107
|
+
const words = this.#dependencies.renderer.render(notice);
|
|
108
|
+
// The system notification is shown by this process; whether it was accepted changes nothing a hook could act on.
|
|
109
|
+
if (words !== '' && channels.includes('os'))
|
|
110
|
+
await this.#dependencies.notifier.notify(NOTICE_TITLE, words);
|
|
111
|
+
return { notice, output: channels.includes('terminal') ? terminalNotice(NOTICE_TITLE, words) : '' };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* At `Stop`: what the turn's delegated agents left behind, and what the session's own agent reached itself - the
|
|
115
|
+
* one agent with no `SubagentStop` of its own (`the-agent-nobody-watches.md` R2). Said once, and then not again
|
|
116
|
+
* unless it changes.
|
|
117
|
+
*/
|
|
118
|
+
async #sayWhatTheTurnFound(turn, options) {
|
|
119
|
+
// Taking clears the store, so a run that cannot show the words must not take them.
|
|
120
|
+
if (!options.channels.includes('chat'))
|
|
121
|
+
return { notice: { kind: 'quiet' }, output: '' };
|
|
122
|
+
const { store } = this.#dependencies;
|
|
123
|
+
const remembered = await store.take(turn.sessionId);
|
|
124
|
+
const kept = new Map(remembered.map((alert) => [alert.agentId, alert]));
|
|
125
|
+
const said = kept.get(OWN_REACH_SAID);
|
|
126
|
+
const delegated = remembered.filter((alert) => !KEPT_NAMES.includes(alert.agentId));
|
|
127
|
+
const own = await this.#ownReach(turn, options);
|
|
128
|
+
const reach = own.kind === 'alert' ? own.alert : undefined;
|
|
129
|
+
if (reach !== undefined)
|
|
130
|
+
await store.remember(turn.sessionId, { ...reach, agentId: OWN_REACH_SAID });
|
|
131
|
+
// Written back because `take` cleared them. A turn nobody looked at changes nothing that was said: without this the
|
|
132
|
+
// turn after it took the same finding for a new one and blocked a second time (R10). An unusable preferences file
|
|
133
|
+
// is said once a session (R24), whatever this turn says. A clean line said once is let go on a turn with a finding,
|
|
134
|
+
// on purpose: the next quiet turn then says what the session holds (R4, R6).
|
|
135
|
+
const unchanged = own.kind === 'not-looked' ? [said, kept.get(CLEAN_SAID), kept.get(PREFERENCES_SAID)] : [kept.get(PREFERENCES_SAID)];
|
|
136
|
+
for (const record of unchanged)
|
|
137
|
+
if (record !== undefined)
|
|
138
|
+
await store.remember(turn.sessionId, record);
|
|
139
|
+
const fresh = reach !== undefined && reach.words !== said?.words ? [reach] : [];
|
|
140
|
+
const saying = [...fresh, ...delegated];
|
|
141
|
+
// What this session has had said about it, this turn included. Written back because `take` cleared it.
|
|
142
|
+
const counts = countedWith(kept.get(SESSION_COUNTS)?.counts, saying);
|
|
143
|
+
if (counts.values > 0 || counts.reached > 0) {
|
|
144
|
+
await store.remember(turn.sessionId, { agentId: SESSION_COUNTS, level: 'counts', words: '', counts });
|
|
145
|
+
}
|
|
146
|
+
/*
|
|
147
|
+
* A turn this run could not look at is not a quiet turn, and must never be said as one: every way of failing to
|
|
148
|
+
* look is silence on this event (`the-agent-nobody-watches` R9), and a clean line over a reading that did not
|
|
149
|
+
* happen is the one sentence this tool must never write.
|
|
150
|
+
*/
|
|
151
|
+
const notice = saying.length > 0
|
|
152
|
+
? { kind: 'turn', remembered: saying }
|
|
153
|
+
: own.kind === 'not-looked'
|
|
154
|
+
? { kind: 'quiet' }
|
|
155
|
+
: (await this.#preferencesNotice(turn, options, kept.has(PREFERENCES_SAID))) ??
|
|
156
|
+
(await this.#cleanNotice(turn, options, counts, kept.has(CLEAN_SAID)));
|
|
157
|
+
// The other two channels carry a title of their own; a line in the conversation has none, so it says who speaks.
|
|
158
|
+
const words = this.#dependencies.renderer.render(notice);
|
|
159
|
+
const files = own.kind === 'alert' ? own.files : [];
|
|
160
|
+
if (this.#agentSpeaks(turn, options, fresh)) {
|
|
161
|
+
const command = reportCommand(turn.sessionId, await this.#invocation());
|
|
162
|
+
return { notice, output: blockNotice(instructionFor(files, command), withTitle(words)) };
|
|
163
|
+
}
|
|
164
|
+
return { notice, output: chatNotice(withTitle(words)) };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Whether this turn's finding is said by the session's own agent rather than as a line (R9-R14). Every condition
|
|
168
|
+
* is a measurement or a reason written down beside it, and all of them must hold:
|
|
169
|
+
*
|
|
170
|
+
* - **a value is being said now, for the first time, by the session's own agent** - the only level the agent
|
|
171
|
+
* speaks for (R8, R12b), and only where it is the one who found it. `fresh` holds the own-reach alert alone,
|
|
172
|
+
* never a delegated agent's: the instruction tells the agent "you read this file in this conversation" (R12,
|
|
173
|
+
* R12b), which is false, and checkable as false, of a file only a delegated subagent reached. A delegated
|
|
174
|
+
* agent's finding still reaches the person, as the line `saying` renders - just never as a claim this agent is
|
|
175
|
+
* asked to make about its own context. The finding was recorded as said before this is asked, because `Stop`
|
|
176
|
+
* fires twice for one turn (B8a), so the same finding never blocks twice (R10);
|
|
177
|
+
* - **the person asked for it**, or asked for nothing and has the default (R8);
|
|
178
|
+
* - **this is not already a continuation a block caused** - `stop_hook_active` read `true` there (B9c, R13);
|
|
179
|
+
* - **a person is reading** - the entry point is one measured attended (B9e2, R14). One this version does not
|
|
180
|
+
* know, or none at all, gets the line: a scripted run continued by a block prints an answer it never asked for.
|
|
181
|
+
*/
|
|
182
|
+
#agentSpeaks(turn, options, fresh) {
|
|
183
|
+
const { entryPoint } = this.#dependencies;
|
|
184
|
+
return fresh.some((alert) => alert.level === 'value') &&
|
|
185
|
+
options.say === 'agent' &&
|
|
186
|
+
!turn.active &&
|
|
187
|
+
entryPoint !== undefined && ATTENDED.includes(entryPoint);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* How the agent is to run agentwhy: the way the `Stop` hook running now does, read from the project's settings -
|
|
191
|
+
* the local file first, the one `init` writes. Found by a measurement (B9d): the offer named `agentwhy`, which was on
|
|
192
|
+
* no path in that project, and the command failed with exit 126. Where no settings say, or say it in anything but
|
|
193
|
+
* plain words, the way this hook's own process was started (`a-hook-runs-what-you-ran.md` J6).
|
|
194
|
+
*/
|
|
195
|
+
async #invocation() {
|
|
196
|
+
const { files, projectDirectory, invocation } = this.#dependencies;
|
|
197
|
+
if (projectDirectory === undefined)
|
|
198
|
+
return invocation.find();
|
|
199
|
+
for (const name of [SETTINGS_FILES.local, SETTINGS_FILES.shared]) {
|
|
200
|
+
const settings = parseJsonObject((await textOf(files, join(projectDirectory, SETTINGS_FILES.directory, name))) ?? '');
|
|
201
|
+
const running = settings === undefined ? undefined : watchInvocation(settings);
|
|
202
|
+
if (running !== undefined)
|
|
203
|
+
return running;
|
|
204
|
+
}
|
|
205
|
+
return invocation.find();
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* That the person's choices could not be read (R24): said once a session, on a turn with nothing else to say, and
|
|
209
|
+
* never as a block. Without it a broken file reads exactly like choices that were honoured - the person who set
|
|
210
|
+
* `off` and still hears a line, or set `every-turn` and hears nothing, would have no way to know why.
|
|
211
|
+
*/
|
|
212
|
+
async #preferencesNotice(turn, options, said) {
|
|
213
|
+
if (!options.preferencesUnusable)
|
|
214
|
+
return undefined;
|
|
215
|
+
// Written back whatever happens next, since `take` cleared it with everything else.
|
|
216
|
+
await this.#dependencies.store.remember(turn.sessionId, { agentId: PREFERENCES_SAID, level: 'preferences', words: '' });
|
|
217
|
+
return said ? undefined : { kind: 'preferences-unusable' };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* A turn that found nothing, where that is worth saying (R4-R6). It is the one notice that reports no finding, so
|
|
221
|
+
* it is held to two things: it never speaks where the person asked for silence, and it never says a session is
|
|
222
|
+
* clean when an earlier turn of it was not - what the turn found and what the session holds are said apart.
|
|
223
|
+
*/
|
|
224
|
+
async #cleanNotice(turn, options, counts, cleanSaid) {
|
|
225
|
+
const mode = options.clean;
|
|
226
|
+
if (mode === 'off')
|
|
227
|
+
return { kind: 'quiet' };
|
|
228
|
+
// Said once means once: the marker is written back, since `take` cleared it with everything else.
|
|
229
|
+
if (mode === 'once' && cleanSaid) {
|
|
230
|
+
await this.#dependencies.store.remember(turn.sessionId, { agentId: CLEAN_SAID, level: 'clean', words: '' });
|
|
231
|
+
return { kind: 'quiet' };
|
|
232
|
+
}
|
|
233
|
+
if (mode === 'once')
|
|
234
|
+
await this.#dependencies.store.remember(turn.sessionId, { agentId: CLEAN_SAID, level: 'clean', words: '' });
|
|
235
|
+
return { kind: 'clean', first: !cleanSaid && mode === 'once', counts, lang: options.lang };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* What the session's own agent reached, read from the primary transcript the input names and nothing else (R3).
|
|
239
|
+
* Every way of failing to look is silence here, not a notice: at the end of every turn, a line saying it could
|
|
240
|
+
* not look would arrive for the rest of the session, and `check` finds the same thing without a hook.
|
|
241
|
+
*/
|
|
242
|
+
async #ownReach(turn, options) {
|
|
243
|
+
const { source, files, createRedactor, home, renderer } = this.#dependencies;
|
|
244
|
+
if (turn.transcriptPath === undefined)
|
|
245
|
+
return { kind: 'not-looked' };
|
|
246
|
+
const policy = await choosePolicy(options, files, this.#dependencies.tell);
|
|
247
|
+
if ('errors' in policy)
|
|
248
|
+
return { kind: 'not-looked' };
|
|
249
|
+
const read = await source.read(expandHome(turn.transcriptPath, home));
|
|
250
|
+
if (read.gaps.some((gap) => gap.kind === 'session-missing'))
|
|
251
|
+
return { kind: 'not-looked' };
|
|
252
|
+
// B8b: the turn's last words are on the input, and B4c measured the same words missing from disk at hook time.
|
|
253
|
+
const model = turn.lastMessage === undefined ? read : withLateMessage(read, read.sessionId, turn.lastMessage);
|
|
254
|
+
const report = buildReport(model, policy.policy, createRedactor(model.projectRoot), { share: false, projectRoot: model.projectRoot });
|
|
255
|
+
const alert = alertOf(report, report.graph.main.index);
|
|
256
|
+
if (alert === undefined || !alerts(alert.level, options.on))
|
|
257
|
+
return { kind: 'nothing' };
|
|
258
|
+
return {
|
|
259
|
+
kind: 'alert',
|
|
260
|
+
alert: { agentId: OWN_REACH_SAID, level: alert.level, words: renderer.render({ kind: 'alert', alert }) },
|
|
261
|
+
files: readFrom(report, report.graph.main.index),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* What `chat` does at `SubagentStop`: keep the words for the end of the turn, because this event discards a
|
|
266
|
+
* `systemMessage` (B4d, B4f) and the agent's last text is not on disk for anyone to read later (B4c).
|
|
267
|
+
*/
|
|
268
|
+
async #remember(notice, agent, options) {
|
|
269
|
+
if (!options.channels.includes('chat'))
|
|
270
|
+
return;
|
|
271
|
+
if (notice.kind !== 'alert' && notice.kind !== 'not-checked')
|
|
272
|
+
return;
|
|
273
|
+
// Without a session there is no conversation to speak in; the immediate channels said it if they were asked to.
|
|
274
|
+
if (agent?.sessionId === undefined)
|
|
275
|
+
return;
|
|
276
|
+
const words = this.#dependencies.renderer.render(notice);
|
|
277
|
+
if (words === '')
|
|
278
|
+
return;
|
|
279
|
+
// A reason it could not look is remembered under the reason, so a hook that fires again says it once (R8).
|
|
280
|
+
const which = notice.kind === 'alert'
|
|
281
|
+
? { agentId: agent.agentId, level: notice.alert.level }
|
|
282
|
+
: { agentId: `not-checked:${notice.reason}`, level: 'not-checked' };
|
|
283
|
+
await this.#dependencies.store.remember(agent.sessionId, { ...which, words });
|
|
284
|
+
}
|
|
285
|
+
async #noticeFor(text, options) {
|
|
286
|
+
const { source, files, createRedactor, home } = this.#dependencies;
|
|
287
|
+
const parsed = parseSubagentStopInput(text);
|
|
288
|
+
if ('unusable' in parsed)
|
|
289
|
+
return { notice: { kind: 'not-checked', reason: 'input' } };
|
|
290
|
+
const { agent } = parsed;
|
|
291
|
+
const policy = await choosePolicy(options, files, this.#dependencies.tell);
|
|
292
|
+
if ('errors' in policy)
|
|
293
|
+
return { notice: { kind: 'not-checked', reason: 'policy' }, agent };
|
|
294
|
+
const read = await source.read(expandHome(agent.transcriptPath, home));
|
|
295
|
+
if (read.gaps.some((gap) => gap.kind === 'session-missing'))
|
|
296
|
+
return { notice: { kind: 'not-checked', reason: 'session' }, agent };
|
|
297
|
+
// R9: the last words the hook was handed, which B4c measured as not yet on disk on 4 of 4 agents.
|
|
298
|
+
const model = agent.lastMessage === undefined ? read : withLateMessage(read, agent.agentId, agent.lastMessage);
|
|
299
|
+
// R3: by the id the hook names, and by nothing else.
|
|
300
|
+
const agentIndex = model.agents.findIndex((candidate) => candidate.id === agent.agentId);
|
|
301
|
+
if (agentIndex < 0 || model.agents[agentIndex]?.id === model.sessionId) {
|
|
302
|
+
return { notice: { kind: 'not-checked', reason: 'agent-not-found' }, agent };
|
|
303
|
+
}
|
|
304
|
+
const report = buildReport(model, policy.policy, createRedactor(model.projectRoot), { share: false, projectRoot: model.projectRoot });
|
|
305
|
+
const alert = alertOf(report, agentIndex);
|
|
306
|
+
if (alert === undefined)
|
|
307
|
+
return { notice: { kind: 'not-checked', reason: 'agent-not-found' }, agent };
|
|
308
|
+
return { notice: alerts(alert.level, options.on) ? { kind: 'alert', alert } : { kind: 'quiet' }, agent };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The protected files a value in this session was **read from** by one agent - not every file the session touched,
|
|
313
|
+
* and not every file another agent in the same session touched. The instruction tells the agent "you read these",
|
|
314
|
+
* and a session checks that against its own context and argues with a claim that does not hold (R12b): a file only
|
|
315
|
+
* a delegated subagent reached is a file this agent never read, naming it is the same false sentence as naming a
|
|
316
|
+
* file the session only wrote a value into. Found by the first test of this, on a session that read `.env` and
|
|
317
|
+
* wrote into `.env.production`.
|
|
318
|
+
*/
|
|
319
|
+
function readFrom(report, agentIndex) {
|
|
320
|
+
return [...new Set([
|
|
321
|
+
...report.uses.filter((use) => use.agentIndex === agentIndex).flatMap((use) => use.files),
|
|
322
|
+
...report.returns
|
|
323
|
+
.filter((statement) => statement.agentIndex === agentIndex)
|
|
324
|
+
.flatMap((statement) => [...(statement.strength === 'value' ? statement.paths : []), ...statement.writtenFrom]),
|
|
325
|
+
])];
|
|
326
|
+
}
|
|
327
|
+
/** The working directory the hook input names, whichever event it was: what decides which project's answers apply. */
|
|
328
|
+
function directoryIn(text) {
|
|
329
|
+
const input = parseJsonObject(text);
|
|
330
|
+
const directory = input?.[HOOK_INPUT.fields.workingDirectory];
|
|
331
|
+
return typeof directory === 'string' && directory !== '' ? directory : undefined;
|
|
332
|
+
}
|
|
333
|
+
/** A file that is not there is not a file that failed: both are `undefined`, and `readPreferences` tells them apart. */
|
|
334
|
+
async function textOf(files, path) {
|
|
335
|
+
try {
|
|
336
|
+
return await files.readText(path);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/** A path the hooks reference writes with a leading `~/` in its examples: expanded, never read as relative. */
|
|
343
|
+
function expandHome(path, home) {
|
|
344
|
+
return path.startsWith(HOME_PREFIX) ? join(home, path.slice(HOME_PREFIX.length)) : path;
|
|
345
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { pinnedVersions } from '../adapter/claude-code/settings/hook-entries.js';
|
|
2
|
+
import { isOlder, plainVersion } from '../shared/plain-version.js';
|
|
3
|
+
/**
|
|
4
|
+
* U2 over the project's two settings files, as parsed: behind where a hook runs the pinned published way at a release
|
|
5
|
+
* older than `serving`. A serving version that is not a release, and hooks in any other form, are never behind.
|
|
6
|
+
*/
|
|
7
|
+
export function behind(files, serving) {
|
|
8
|
+
if (serving === undefined || plainVersion(serving) === undefined)
|
|
9
|
+
return undefined;
|
|
10
|
+
const older = (settings) => pinnedVersions(settings ?? {}).filter((version) => isOlder(version, serving));
|
|
11
|
+
const local = older(files.local);
|
|
12
|
+
const shared = older(files.shared);
|
|
13
|
+
const [first, ...rest] = [...local, ...shared];
|
|
14
|
+
if (first === undefined)
|
|
15
|
+
return undefined;
|
|
16
|
+
const from = rest.reduce((lowest, version) => (isOlder(version, lowest) ? version : lowest), first);
|
|
17
|
+
return { from, to: serving, shared: shared.includes(from) };
|
|
18
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { SETTINGS_FILES } from '../adapter/claude-code/contract/settings.js';
|
|
3
|
+
import { installedHooks, rulesReadByHooks, runningInvocation } from '../adapter/claude-code/settings/hook-entries.js';
|
|
4
|
+
import { PROJECT_HOOKS } from '../adapter/codex/contract/hooks.js';
|
|
5
|
+
import { codexRefuseCommand, codexRefuseIn, codexStopCommand, codexStopIn, withCodexRefuse, withoutCodexRefuse } from '../adapter/codex/settings/codex-hooks.js';
|
|
6
|
+
import { FileAccessError } from '../ports/file-access-error.js';
|
|
7
|
+
import { parseJsonObject } from '../shared/json.js';
|
|
8
|
+
/** Where Codex's hook file is, as a person reads it. */
|
|
9
|
+
const WHERE = `${PROJECT_HOOKS.directory}/${PROJECT_HOOKS.file}`;
|
|
10
|
+
/** CK8: what holds until a person approves the hook in Codex, measured (CKB5) - said wherever the hook is written. */
|
|
11
|
+
const UNTIL_APPROVED = 'Codex runs a new or changed hook only after you approve it in Codex: open Codex in this project and approve agentwhy\'s hook when it asks. Until then Codex skips it without a word, and does not block anything.';
|
|
12
|
+
/**
|
|
13
|
+
* `codex-blocks-too` CK5-CK9: after the setup it wraps has written Claude Code's settings - or found nothing to change -
|
|
14
|
+
* Codex's hook file is made to match them. agentwhy's Codex hook runs `refuse` with the invocation and the rules Claude
|
|
15
|
+
* Code's `refuse` has, where either settings file runs `refuse`, and is not there where neither does. Only in a project
|
|
16
|
+
* that uses Codex (CK6); with its own part of the plan and the same consent (CK7); and never approved on anyone's
|
|
17
|
+
* behalf (CK8).
|
|
18
|
+
*/
|
|
19
|
+
export class CodexMirror {
|
|
20
|
+
#dependencies;
|
|
21
|
+
constructor(dependencies) {
|
|
22
|
+
this.#dependencies = dependencies;
|
|
23
|
+
}
|
|
24
|
+
async run(options) {
|
|
25
|
+
// `init --codex` with nothing else named: Codex's hook alone, to match what Claude Code's settings already run.
|
|
26
|
+
if (options.codex === true && onlyCodex(options))
|
|
27
|
+
return (await this.#mirror(options)) ?? { outcome: 'unchanged', output: nothingFor(await this.#running()) };
|
|
28
|
+
const result = await this.#dependencies.setup.run(options);
|
|
29
|
+
if (result.outcome !== 'written' && result.outcome !== 'unchanged')
|
|
30
|
+
return result;
|
|
31
|
+
const codex = await this.#mirror(options);
|
|
32
|
+
if (codex === undefined)
|
|
33
|
+
return result;
|
|
34
|
+
const outcome = result.outcome === 'unchanged' && codex.outcome === 'written' ? 'written' : result.outcome;
|
|
35
|
+
return { outcome, output: `${result.output}${result.output.endsWith('\n') || result.output === '' ? '' : '\n'}\n${codex.output}` };
|
|
36
|
+
}
|
|
37
|
+
/** The change to Codex's hook file, written with consent; `undefined` where there is none to make. */
|
|
38
|
+
async #mirror(options) {
|
|
39
|
+
const running = await this.#running();
|
|
40
|
+
const directory = join(this.#dependencies.workingDirectory, PROJECT_HOOKS.directory);
|
|
41
|
+
const path = join(directory, PROJECT_HOOKS.file);
|
|
42
|
+
const read = await this.#readJson(path);
|
|
43
|
+
const current = read.kind === 'object' ? codexRefuseIn(read.file) : undefined;
|
|
44
|
+
const currentStop = read.kind === 'object' ? codexStopIn(read.file) : undefined;
|
|
45
|
+
if (running.kind === 'elsewhere') {
|
|
46
|
+
return current === undefined && !(await this.#usesCodex(options, read)) ? undefined : { outcome: 'refused', output: `Codex: ${ELSEWHERE}\n` };
|
|
47
|
+
}
|
|
48
|
+
const invoke = running.kind === 'none' ? undefined : options.invoke ?? running.invoke ?? (await this.#dependencies.invocation.find());
|
|
49
|
+
const wanted = invoke === undefined ? undefined : codexRefuseCommand(invoke, running.kind === 'refuse' ? running.settings : undefined);
|
|
50
|
+
const stop = invoke === undefined ? undefined : codexStopCommand(invoke);
|
|
51
|
+
if (wanted === current && stop === currentStop)
|
|
52
|
+
return undefined;
|
|
53
|
+
if (wanted !== undefined && !(await this.#usesCodex(options, read)))
|
|
54
|
+
return undefined;
|
|
55
|
+
if (read.kind === 'broken' || read.kind === 'unreadable') {
|
|
56
|
+
return {
|
|
57
|
+
outcome: 'refused',
|
|
58
|
+
output: `Codex: ${WHERE} ${read.kind === 'broken' ? 'is not a JSON object' : 'could not be read'}, so agentwhy's hook was not ${wanted === undefined ? 'taken out of it' : 'written to it'}.\n`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const before = read.kind === 'object' ? read.file : {};
|
|
62
|
+
const next = wanted === undefined || stop === undefined ? withoutCodexRefuse(before).file : withCodexRefuse(before, wanted, stop);
|
|
63
|
+
const plan = [
|
|
64
|
+
`And in Codex, agentwhy will change ${WHERE}:`,
|
|
65
|
+
'',
|
|
66
|
+
...(wanted === undefined
|
|
67
|
+
? [' Stop running', ' agentwhy refuse and its Codex conversation message']
|
|
68
|
+
: [' Run automatically', ` ${'before a shell command'.padEnd(24)}${wanted}`, ` ${'when a reply ends'.padEnd(24)}${stop}`]),
|
|
69
|
+
'',
|
|
70
|
+
...(wanted === undefined ? [] : [` ${UNTIL_APPROVED}`, '']),
|
|
71
|
+
' Everything else in the file stays. It is rewritten as two-space JSON.',
|
|
72
|
+
];
|
|
73
|
+
const consent = await this.#consent(options, plan);
|
|
74
|
+
if (typeof consent !== 'string')
|
|
75
|
+
return consent;
|
|
76
|
+
const said = consent === 'asked' ? '' : `${plan.join('\n')}\n\n`;
|
|
77
|
+
try {
|
|
78
|
+
await this.#dependencies.files.ensureDirectory(directory);
|
|
79
|
+
await this.#dependencies.files.writeText(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (!(error instanceof FileAccessError))
|
|
83
|
+
throw error;
|
|
84
|
+
return { outcome: 'unwritable', output: `${said}Codex: ${WHERE} could not be written, so Codex does not block anything yet.\n` };
|
|
85
|
+
}
|
|
86
|
+
// CK8: said once. Where the plan was printed above - `--yes`, a page - it already said what Codex needs.
|
|
87
|
+
const until = said === '' ? ` ${UNTIL_APPROVED}` : '';
|
|
88
|
+
return {
|
|
89
|
+
outcome: 'written',
|
|
90
|
+
output: `${said}${wanted === undefined
|
|
91
|
+
? `Codex: agentwhy's hook is out of ${WHERE}.\n`
|
|
92
|
+
: current === undefined
|
|
93
|
+
? `Codex: agentwhy's hook is in ${WHERE}. It blocks the same files there, once approved.${until}\n`
|
|
94
|
+
: `Codex: agentwhy's hook in ${WHERE} changed, so Codex asks you to approve it again.${until}\n`}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* How Claude Code's `refuse` runs here, read from the files as they are after the setup: the local file first, as
|
|
99
|
+
* `init` reads them. Its rules become Codex's (CK2), as a path in the project (CK3).
|
|
100
|
+
*/
|
|
101
|
+
async #running() {
|
|
102
|
+
const directory = join(this.#dependencies.workingDirectory, SETTINGS_FILES.directory);
|
|
103
|
+
for (const file of [SETTINGS_FILES.local, SETTINGS_FILES.shared]) {
|
|
104
|
+
const read = await this.#readJson(join(directory, file));
|
|
105
|
+
if (read.kind !== 'object' || !installedHooks(read.file).has('refuse'))
|
|
106
|
+
continue;
|
|
107
|
+
const rules = rulesReadByHooks(read.file).get('refuse') ?? 'default';
|
|
108
|
+
const invoke = runningInvocation(read.file);
|
|
109
|
+
if (rules === 'policy' || rules === 'other')
|
|
110
|
+
return { kind: 'elsewhere' };
|
|
111
|
+
return {
|
|
112
|
+
kind: 'refuse',
|
|
113
|
+
...(invoke === undefined ? {} : { invoke }),
|
|
114
|
+
...(rules === 'default' ? {} : { settings: `${SETTINGS_FILES.directory}/${SETTINGS_FILES[rules]}` }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return { kind: 'none' };
|
|
118
|
+
}
|
|
119
|
+
/** CK6: a Codex conversation listed for the project, a `.codex` folder in it, or `init --codex`. */
|
|
120
|
+
async #usesCodex(options, read) {
|
|
121
|
+
if (options.codex === true || read.kind !== 'absent')
|
|
122
|
+
return true;
|
|
123
|
+
try {
|
|
124
|
+
if ((await this.#dependencies.files.kindOf(join(this.#dependencies.workingDirectory, PROJECT_HOOKS.directory))) === 'directory')
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (!(error instanceof FileAccessError))
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
return this.#dependencies.codexConversations();
|
|
132
|
+
}
|
|
133
|
+
/** R7, as `ProjectSetup` asks it: `--yes`, a person at a terminal, or the plan printed and nothing written. */
|
|
134
|
+
async #consent(options, plan) {
|
|
135
|
+
if (options.yes)
|
|
136
|
+
return 'given';
|
|
137
|
+
if (!this.#dependencies.interactive) {
|
|
138
|
+
return { outcome: 'not-confirmed', output: `${plan.join('\n')}\n\nNothing was written for Codex. To write it: the same command with --yes\n` };
|
|
139
|
+
}
|
|
140
|
+
const chosen = await this.#dependencies.chooser.choose(`${plan.join('\n')}\n\nWrite it?`, [
|
|
141
|
+
{ label: 'Yes', detail: `write ${WHERE}` },
|
|
142
|
+
{ label: 'No', detail: 'leave Codex as it is' },
|
|
143
|
+
]);
|
|
144
|
+
return chosen === 0 ? 'asked' : { outcome: 'declined', output: `Nothing was written for Codex.\n` };
|
|
145
|
+
}
|
|
146
|
+
async #readJson(path) {
|
|
147
|
+
let text;
|
|
148
|
+
try {
|
|
149
|
+
text = await this.#dependencies.files.readText(path);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
if (!(error instanceof FileAccessError))
|
|
153
|
+
throw error;
|
|
154
|
+
return { kind: error.failure === 'not-found' ? 'absent' : 'unreadable' };
|
|
155
|
+
}
|
|
156
|
+
const file = parseJsonObject(text);
|
|
157
|
+
return file === undefined ? { kind: 'broken' } : { kind: 'object', file };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** CK2: rules Codex's hook cannot be pointed at from the project - a policy file, or a settings file of another place. */
|
|
161
|
+
const ELSEWHERE = "agentwhy's hook was not written: refuse here reads a policy or a settings file outside the project's two, which a hook in the project cannot be pointed at. Run agentwhy init --refuse to point it at this project's rules.";
|
|
162
|
+
/** `init --codex` and nothing else: no hook named, nothing to protect or take out, no update. */
|
|
163
|
+
function onlyCodex(options) {
|
|
164
|
+
return options.hooks === undefined && options.protect.length === 0 && !options.remove && (options.unprotect ?? []).length === 0 && options.update !== true;
|
|
165
|
+
}
|
|
166
|
+
/** What `init --codex` says where Codex's hook already matches, or there is nothing for it to follow. */
|
|
167
|
+
function nothingFor(running) {
|
|
168
|
+
return running.kind === 'none'
|
|
169
|
+
? "Codex: nothing to write. agentwhy blocks Codex with the same hook it blocks Claude Code with, and refuse doesn't run here yet: agentwhy init --refuse --codex\n"
|
|
170
|
+
: `Codex: ${WHERE} already runs agentwhy's hook. ${UNTIL_APPROVED}\n`;
|
|
171
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { parse, relative, resolve } from 'node:path';
|
|
2
|
+
/**
|
|
3
|
+
* Why a working directory is not a project, or `undefined` where it may be one (`.ai/specs/2026-09-27-which-project.md`
|
|
4
|
+
* V6). The home directory is where a new terminal starts, and its `.claude/settings.json` is Claude Code's settings for
|
|
5
|
+
* every project; a root is nobody's project. Compared as resolved paths, and through `relative`, which compares a Windows
|
|
6
|
+
* path without regard to case, as its file system does. An empty home names no directory, so it matches none.
|
|
7
|
+
*
|
|
8
|
+
* `realHome` is the same directory with every link followed. Found by a review: the working directory a shell gives is
|
|
9
|
+
* the physical path, while `HOME` may reach it through a link - `/home` a link to `/data/home` - so the two named one
|
|
10
|
+
* directory and were compared as two, and the home directory was set up as a project.
|
|
11
|
+
*/
|
|
12
|
+
export function notAProject(directory, home, realHome = home) {
|
|
13
|
+
const resolved = resolve(directory);
|
|
14
|
+
if ([home, realHome].some((one) => one !== '' && relative(resolve(one), resolved) === ''))
|
|
15
|
+
return 'home';
|
|
16
|
+
if (parse(resolved).root === resolved)
|
|
17
|
+
return 'root';
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|