@event4u/agent-config 11.0.0 → 12.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +173 -229
- package/agents/templates/.ai-council.yml.example +46 -3
- package/dist/agent-src/commands/agent-handoff.md +0 -1
- package/dist/agent-src/commands/agent-status.md +0 -1
- package/dist/agent-src/commands/agents/audit.md +0 -1
- package/dist/agent-src/commands/agents/init.md +0 -1
- package/dist/agent-src/commands/agents/optimize.md +0 -1
- package/dist/agent-src/commands/agents/user/accept.md +0 -1
- package/dist/agent-src/commands/agents/user/delete.md +0 -1
- package/dist/agent-src/commands/agents/user/init.md +0 -1
- package/dist/agent-src/commands/agents/user/review.md +0 -1
- package/dist/agent-src/commands/agents/user/show.md +0 -1
- package/dist/agent-src/commands/agents/user/update.md +0 -1
- package/dist/agent-src/commands/agents/user.md +0 -1
- package/dist/agent-src/commands/agents.md +0 -1
- package/dist/agent-src/commands/analytics/prune.md +0 -1
- package/dist/agent-src/commands/analytics/show.md +0 -1
- package/dist/agent-src/commands/analytics.md +0 -1
- package/dist/agent-src/commands/analyze/conformance.md +0 -1
- package/dist/agent-src/commands/analyze/decision.md +0 -1
- package/dist/agent-src/commands/analyze/inbox.md +0 -1
- package/dist/agent-src/commands/analyze/incident.md +0 -1
- package/dist/agent-src/commands/analyze/near-miss.md +0 -1
- package/dist/agent-src/commands/analyze/postmortem.md +0 -1
- package/dist/agent-src/commands/analyze/premortem.md +0 -1
- package/dist/agent-src/commands/analyze/reference-repo.md +0 -1
- package/dist/agent-src/commands/analyze.md +0 -1
- package/dist/agent-src/commands/brand/identity.md +0 -1
- package/dist/agent-src/commands/brand/review.md +0 -1
- package/dist/agent-src/commands/brand/strategy.md +0 -1
- package/dist/agent-src/commands/brand/tokens.md +0 -1
- package/dist/agent-src/commands/brand/voice.md +0 -1
- package/dist/agent-src/commands/brand.md +0 -1
- package/dist/agent-src/commands/bug/fix.md +0 -1
- package/dist/agent-src/commands/bug/investigate.md +0 -1
- package/dist/agent-src/commands/bug.md +0 -1
- package/dist/agent-src/commands/challenge-me/vision.md +0 -1
- package/dist/agent-src/commands/challenge-me/with-docs.md +0 -1
- package/dist/agent-src/commands/challenge-me.md +0 -1
- package/dist/agent-src/commands/chat-history/import.md +0 -1
- package/dist/agent-src/commands/chat-history.md +0 -1
- package/dist/agent-src/commands/check-current-md.md +0 -1
- package/dist/agent-src/commands/commit/in-chunks.md +0 -1
- package/dist/agent-src/commands/commit.md +0 -1
- package/dist/agent-src/commands/condense.md +0 -1
- package/dist/agent-src/commands/context/create.md +0 -1
- package/dist/agent-src/commands/context/refactor.md +0 -1
- package/dist/agent-src/commands/context.md +0 -1
- package/dist/agent-src/commands/contribution-precheck.md +0 -1
- package/dist/agent-src/commands/cost/profile.md +0 -1
- package/dist/agent-src/commands/cost/report.md +0 -1
- package/dist/agent-src/commands/cost.md +0 -1
- package/dist/agent-src/commands/council/analysis.md +0 -1
- package/dist/agent-src/commands/council/debate.md +0 -1
- package/dist/agent-src/commands/council/default.md +0 -1
- package/dist/agent-src/commands/council/design.md +0 -1
- package/dist/agent-src/commands/council/optimize.md +0 -1
- package/dist/agent-src/commands/council/pr.md +0 -1
- package/dist/agent-src/commands/council.md +0 -1
- package/dist/agent-src/commands/design-system/capture.md +0 -1
- package/dist/agent-src/commands/design-system/generate.md +0 -1
- package/dist/agent-src/commands/design-system/import.md +0 -1
- package/dist/agent-src/commands/design-system.md +0 -1
- package/dist/agent-src/commands/estimate-ticket.md +0 -1
- package/dist/agent-src/commands/explain-run.md +0 -1
- package/dist/agent-src/commands/feature/dev.md +0 -1
- package/dist/agent-src/commands/feature/explore.md +0 -1
- package/dist/agent-src/commands/feature/plan.md +0 -1
- package/dist/agent-src/commands/feature/refactor.md +0 -1
- package/dist/agent-src/commands/feature/roadmap.md +0 -1
- package/dist/agent-src/commands/feature.md +0 -1
- package/dist/agent-src/commands/fix/ci.md +0 -1
- package/dist/agent-src/commands/fix/comments.md +0 -1
- package/dist/agent-src/commands/fix/portability.md +0 -1
- package/dist/agent-src/commands/fix/pr-comments-loop.md +0 -1
- package/dist/agent-src/commands/fix/pr-comments.md +0 -1
- package/dist/agent-src/commands/fix/quality.md +0 -1
- package/dist/agent-src/commands/fix/refs.md +0 -1
- package/dist/agent-src/commands/fix/route.md +0 -1
- package/dist/agent-src/commands/fix/seeder.md +0 -1
- package/dist/agent-src/commands/fix.md +0 -1
- package/dist/agent-src/commands/ghostwriter/delete.md +0 -1
- package/dist/agent-src/commands/ghostwriter/fetch.md +0 -1
- package/dist/agent-src/commands/ghostwriter/list.md +0 -1
- package/dist/agent-src/commands/ghostwriter/show.md +0 -1
- package/dist/agent-src/commands/ghostwriter/write.md +0 -1
- package/dist/agent-src/commands/ghostwriter.md +0 -1
- package/dist/agent-src/commands/grill-me.md +0 -1
- package/dist/agent-src/commands/humanize.md +0 -1
- package/dist/agent-src/commands/image/analyse.md +0 -1
- package/dist/agent-src/commands/image/create.md +0 -1
- package/dist/agent-src/commands/image/verify.md +0 -1
- package/dist/agent-src/commands/image.md +0 -1
- package/dist/agent-src/commands/implement-ticket.md +0 -1
- package/dist/agent-src/commands/jira-ticket.md +0 -1
- package/dist/agent-src/commands/judge/on-diff.md +0 -1
- package/dist/agent-src/commands/judge/solo.md +0 -1
- package/dist/agent-src/commands/judge/steps.md +0 -1
- package/dist/agent-src/commands/judge.md +0 -1
- package/dist/agent-src/commands/knowledge/cross-repo.md +0 -1
- package/dist/agent-src/commands/knowledge/forget.md +0 -1
- package/dist/agent-src/commands/knowledge/ingest.md +0 -1
- package/dist/agent-src/commands/knowledge/list.md +0 -1
- package/dist/agent-src/commands/knowledge.md +0 -1
- package/dist/agent-src/commands/memory/add.md +0 -1
- package/dist/agent-src/commands/memory/learn-low-impact.md +0 -1
- package/dist/agent-src/commands/memory/load.md +0 -1
- package/dist/agent-src/commands/memory/mine-session.md +0 -1
- package/dist/agent-src/commands/memory/promote.md +0 -1
- package/dist/agent-src/commands/memory/propose.md +0 -1
- package/dist/agent-src/commands/memory.md +0 -1
- package/dist/agent-src/commands/mission/upgrade.md +0 -1
- package/dist/agent-src/commands/mode.md +0 -1
- package/dist/agent-src/commands/module/create.md +0 -1
- package/dist/agent-src/commands/module/explore.md +0 -1
- package/dist/agent-src/commands/module.md +0 -1
- package/dist/agent-src/commands/optimize/agents-dir.md +0 -1
- package/dist/agent-src/commands/optimize/augmentignore.md +0 -1
- package/dist/agent-src/commands/optimize/deep.md +0 -1
- package/dist/agent-src/commands/optimize/project.md +0 -1
- package/dist/agent-src/commands/optimize/prompt.md +0 -1
- package/dist/agent-src/commands/optimize/rtk.md +0 -1
- package/dist/agent-src/commands/optimize/skills.md +0 -1
- package/dist/agent-src/commands/optimize.md +0 -1
- package/dist/agent-src/commands/orchestrate.md +0 -1
- package/dist/agent-src/commands/override/create.md +0 -1
- package/dist/agent-src/commands/override/manage.md +0 -1
- package/dist/agent-src/commands/override.md +0 -1
- package/dist/agent-src/commands/package/reset.md +0 -1
- package/dist/agent-src/commands/package/test.md +0 -1
- package/dist/agent-src/commands/package.md +0 -1
- package/dist/agent-src/commands/post-as/ghostwriter.md +0 -1
- package/dist/agent-src/commands/post-as/me.md +0 -1
- package/dist/agent-src/commands/post-as.md +0 -1
- package/dist/agent-src/commands/pr/create/description-only.md +0 -1
- package/dist/agent-src/commands/pr/create.md +23 -9
- package/dist/agent-src/commands/prediction-pool.md +0 -1
- package/dist/agent-src/commands/prepare-for-review.md +0 -1
- package/dist/agent-src/commands/profile/activate.md +0 -1
- package/dist/agent-src/commands/profile/deactivate.md +0 -1
- package/dist/agent-src/commands/profile/show.md +0 -1
- package/dist/agent-src/commands/profile.md +0 -1
- package/dist/agent-src/commands/project/analyze.md +0 -1
- package/dist/agent-src/commands/project/health.md +0 -1
- package/dist/agent-src/commands/project.md +0 -1
- package/dist/agent-src/commands/refine-ticket.md +0 -1
- package/dist/agent-src/commands/research/deep.md +0 -1
- package/dist/agent-src/commands/research/report.md +0 -1
- package/dist/agent-src/commands/research.md +0 -1
- package/dist/agent-src/commands/review/changes.md +0 -1
- package/dist/agent-src/commands/review/routing.md +0 -1
- package/dist/agent-src/commands/review.md +0 -1
- package/dist/agent-src/commands/roadmap/ai-council.md +0 -1
- package/dist/agent-src/commands/roadmap/create.md +0 -1
- package/dist/agent-src/commands/roadmap/materialize.md +0 -1
- package/dist/agent-src/commands/roadmap/next.md +0 -1
- package/dist/agent-src/commands/roadmap/process-full.md +0 -1
- package/dist/agent-src/commands/roadmap/process-phase.md +0 -1
- package/dist/agent-src/commands/roadmap/process-step.md +0 -1
- package/dist/agent-src/commands/roadmap.md +0 -1
- package/dist/agent-src/commands/rule-compliance-audit.md +0 -1
- package/dist/agent-src/commands/security-audit-config.md +0 -1
- package/dist/agent-src/commands/skill/preview.md +0 -1
- package/dist/agent-src/commands/skill.md +0 -1
- package/dist/agent-src/commands/skills/discover.md +0 -1
- package/dist/agent-src/commands/skills.md +0 -1
- package/dist/agent-src/commands/sync/agent-settings.md +0 -1
- package/dist/agent-src/commands/sync/gitignore/fix.md +0 -1
- package/dist/agent-src/commands/sync/gitignore.md +0 -1
- package/dist/agent-src/commands/sync.md +0 -1
- package/dist/agent-src/commands/tdd/green.md +0 -1
- package/dist/agent-src/commands/tdd/red.md +0 -1
- package/dist/agent-src/commands/tdd/refactor.md +0 -1
- package/dist/agent-src/commands/tdd.md +0 -1
- package/dist/agent-src/commands/team/adversarial.md +0 -1
- package/dist/agent-src/commands/team/delegate.md +0 -1
- package/dist/agent-src/commands/team/review.md +0 -1
- package/dist/agent-src/commands/team/status.md +0 -1
- package/dist/agent-src/commands/team-knowledge/bootstrap.md +0 -1
- package/dist/agent-src/commands/team-knowledge/consolidate.md +0 -1
- package/dist/agent-src/commands/team-knowledge.md +0 -1
- package/dist/agent-src/commands/team.md +0 -1
- package/dist/agent-src/commands/tests/create.md +0 -1
- package/dist/agent-src/commands/tests/e2e-heal.md +0 -1
- package/dist/agent-src/commands/tests/e2e-plan.md +0 -1
- package/dist/agent-src/commands/tests/execute.md +0 -1
- package/dist/agent-src/commands/tests.md +0 -1
- package/dist/agent-src/commands/threat-model.md +0 -1
- package/dist/agent-src/commands/update-form-request-messages.md +0 -1
- package/dist/agent-src/commands/upstream-contribute.md +0 -1
- package/dist/agent-src/commands/video/from-script.md +0 -1
- package/dist/agent-src/commands/video/from-song.md +0 -1
- package/dist/agent-src/commands/video/scene.md +0 -1
- package/dist/agent-src/commands/video/stitch.md +0 -1
- package/dist/agent-src/commands/video/storyboard.md +0 -1
- package/dist/agent-src/commands/video.md +0 -1
- package/dist/agent-src/commands/work.md +0 -1
- package/dist/agent-src/commands/worktree/cleanup.md +0 -1
- package/dist/agent-src/commands/worktree/create.md +0 -1
- package/dist/agent-src/commands/worktree/status.md +0 -1
- package/dist/agent-src/commands/worktree/verify.md +0 -1
- package/dist/agent-src/commands/worktree.md +0 -1
- package/dist/agent-src/contexts/model-recommendations.md +6 -4
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +32 -1
- package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -0
- package/dist/agent-src/skills/git-workflow/SKILL.md +9 -1
- package/dist/agent-src/skills/pdf-tools/SKILL.md +1 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +7 -7
- package/dist/agent-src/skills/skill-writing/SKILL.md +35 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +41 -3
- package/dist/cli/commands/commands.candidates.test.js +1 -1
- package/dist/cli/commands/commands.candidates.test.js.map +1 -1
- package/dist/cli/commands/commands.js +9 -15
- package/dist/cli/commands/commands.js.map +1 -1
- package/dist/cli/commands/profiles.test.js +7 -7
- package/dist/cli/commands/profiles.test.js.map +1 -1
- package/dist/cli/discovery/loadManifest.js.map +1 -1
- package/dist/cli/main.js +2 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-K34QTSD5.js +3895 -0
- package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-LKZ5DOYW.js} +557 -297
- package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-O52B4F5S.js} +123 -2338
- package/dist/cli-delegate/cmd_conformance.js +18 -6
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +213 -412
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +2 -2
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +3 -3
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +3 -3
- package/dist/hooks/dispatch.js +2588 -1082
- package/dist/install/install.mjs +744 -482
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +159 -15
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/model_tier.js +9 -0
- package/dist/scripts/_lib/model_tier.js.map +1 -1
- package/dist/scripts/condense.js +50 -0
- package/dist/scripts/condense.js.map +1 -1
- package/docs/MIGRATION.md +21 -1
- package/docs/contracts/settings-classes.md +41 -0
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +30 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +32 -1
- package/src/config/agent-settings.template.yml +34 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +39 -9
- package/src/config/gate-violation-baselines.json +9 -3
- package/src/config/hook-token-budget.json +7 -1
- package/src/config/preamble-payload-budget.json +28 -0
- package/src/scripts/_dispatch.bash +52 -5
- package/src/scripts/_lib/git_common_dir.ts +173 -0
- package/src/scripts/_lib/model_tier.ts +9 -0
- package/src/scripts/_lib/release_highlights.ts +108 -7
- package/src/scripts/_lib/skill_catalogue.ts +862 -0
- package/src/scripts/ai_council/clients.ts +182 -3
- package/src/scripts/ai_council/config.ts +16 -1
- package/src/scripts/ai_council/low_impact.ts +14 -1
- package/src/scripts/ai_council/orchestrator.ts +14 -1
- package/src/scripts/ai_council/transport_resolver.ts +32 -2
- package/src/scripts/audit_command_surface.ts +21 -15
- package/src/scripts/build_catalog_index.ts +1 -1
- package/src/scripts/build_discovery_manifest.ts +30 -34
- package/src/scripts/capture_skill_catalogue.ts +185 -328
- package/src/scripts/check_branch_freshness.ts +217 -62
- package/src/scripts/check_release_published.ts +6 -2
- package/src/scripts/check_release_trunk_sync.ts +18 -2
- package/src/scripts/condense.ts +50 -1
- package/src/scripts/dispatch_r2_reviewer.ts +15 -5
- package/src/scripts/evidence_report.ts +15 -4
- package/src/scripts/gen_discovery_baseline.ts +3 -3
- package/src/scripts/hook_manifest.yaml +67 -13
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +17 -4
- package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
- package/src/scripts/hot_context_hook.ts +13 -1
- package/src/scripts/install.ts +129 -0
- package/src/scripts/janitor.ts +77 -2
- package/src/scripts/lint_command_tiers.ts +29 -75
- package/src/scripts/lint_originality.ts +101 -14
- package/src/scripts/lint_plan_risk_register.ts +22 -9
- package/src/scripts/lint_roadmap_blockers.ts +109 -6
- package/src/scripts/lint_scheduled_deprecations.ts +738 -0
- package/src/scripts/migration_status.ts +17 -1
- package/src/scripts/probe_review_binding_drift.ts +731 -0
- package/src/scripts/release.ts +95 -1
- package/src/scripts/schemas/command.schema.json +6 -11
- package/src/scripts/schemas/skill.schema.json +12 -2
- package/src/scripts/schemas/subagent.schema.json +2 -2
- package/src/scripts/session_eol_report.ts +81 -0
- package/src/scripts/skill_linter.ts +82 -2
- package/src/scripts/workspace_doctor.ts +464 -0
- package/src/scripts/worktree_cleanup_check.ts +113 -8
- package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* workspace:doctor — read-only answer to "where am I", with provenance.
|
|
4
|
+
*
|
|
5
|
+
* Phase 3 of `road-to-inbox-harvest-2026-08-c-workspace-identity`. Two reads,
|
|
6
|
+
* one command, and **no action**: the roadmap's Non-goals put worktree deletion
|
|
7
|
+
* out of scope, and its Risk 3 names "the pressure read becomes a disposal path
|
|
8
|
+
* by accident" as the failure to design against. There is therefore no `--fix`,
|
|
9
|
+
* no `--prune`, and no removal plan here — `worktree_cleanup_check.ts` owns
|
|
10
|
+
* that question behind its own approval.
|
|
11
|
+
*
|
|
12
|
+
* 1. **Identity** — the five fields of `workspaceIdentity()` plus the session
|
|
13
|
+
* facts a location question actually needs: the roadmap claim, foreign live
|
|
14
|
+
* records, expired-but-unpruned records, and whether this checkout is
|
|
15
|
+
* contained in the main worktree. Every field prints its provenance, in the
|
|
16
|
+
* shape `routing:doctor` uses, because a value whose source is invisible is
|
|
17
|
+
* indistinguishable from a guess.
|
|
18
|
+
*
|
|
19
|
+
* 2. **Worktree pressure** — how large the estate is and how much of it is
|
|
20
|
+
* merged. The buckets **partition** the registered set, so they sum to the
|
|
21
|
+
* `git worktree list` total exactly. The live-session count is deliberately
|
|
22
|
+
* NOT one of them: it overlaps every bucket, and printing it inside the
|
|
23
|
+
* partition would invite a reader to add it in.
|
|
24
|
+
*
|
|
25
|
+
* Exit code is 0 whenever the command could read the repository. It is a
|
|
26
|
+
* report, not a gate: nothing here has a pass/fail opinion, so a non-zero exit
|
|
27
|
+
* would have to invent one.
|
|
28
|
+
*
|
|
29
|
+
* `--strict` exits 1 when **any** identity field is unresolved. Note what that
|
|
30
|
+
* means in a repo with no `refs/remotes/origin/HEAD` on disk: `prBase` is
|
|
31
|
+
* unresolved *correctly*, so `--strict` is red there by design. It is a probe
|
|
32
|
+
* for "is every field answerable here", not a health check — and this file
|
|
33
|
+
* previously claimed a per-field selection that does not exist.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { execFileSync } from 'node:child_process';
|
|
37
|
+
import * as fs from 'node:fs';
|
|
38
|
+
import * as path from 'node:path';
|
|
39
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
40
|
+
|
|
41
|
+
import {
|
|
42
|
+
type IdentityField,
|
|
43
|
+
type WorkspaceIdentity,
|
|
44
|
+
workspaceIdentity,
|
|
45
|
+
} from './_lib/git_common_dir.js';
|
|
46
|
+
import { gitEnv } from './_lib/git_env.js';
|
|
47
|
+
import {
|
|
48
|
+
read_live_records,
|
|
49
|
+
register_dir,
|
|
50
|
+
type SessionRecord,
|
|
51
|
+
} from './_lib/session_register.js';
|
|
52
|
+
import { listWorktrees, resolveTrunk } from './worktree_cleanup_check.js';
|
|
53
|
+
|
|
54
|
+
const IDENTITY_FIELDS = ['repoRoot', 'mainWorktree', 'currentWorktree', 'branch', 'prBase'] as const;
|
|
55
|
+
type IdentityFieldName = (typeof IDENTITY_FIELDS)[number];
|
|
56
|
+
|
|
57
|
+
interface SessionView {
|
|
58
|
+
/** Roadmap slug this session claims, or `null`; `source` says where from. */
|
|
59
|
+
readonly claim: string | null;
|
|
60
|
+
readonly claim_source: string;
|
|
61
|
+
/** Live foreign records, i.e. every live record that is not this session's. */
|
|
62
|
+
readonly live_records: number;
|
|
63
|
+
/** Live records whose `worktree` is this checkout — a real collision. */
|
|
64
|
+
readonly conflicting: number;
|
|
65
|
+
/** Record files on disk that `read_live_records` judged expired. */
|
|
66
|
+
readonly stale: number;
|
|
67
|
+
/** Where the register lives, or why it could not be located. */
|
|
68
|
+
readonly register: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ContainmentView {
|
|
72
|
+
/** Is the current checkout inside the main worktree's directory tree? */
|
|
73
|
+
readonly contained: boolean | null;
|
|
74
|
+
readonly reason: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface PressureView {
|
|
78
|
+
readonly trunk: string | null;
|
|
79
|
+
/** Where `trunk` came from — a candidate search, not a recorded default. */
|
|
80
|
+
readonly trunk_provenance: string;
|
|
81
|
+
readonly registered: number;
|
|
82
|
+
/** Same count from a DIFFERENT command; a mismatch is the real signal. */
|
|
83
|
+
readonly independent_registered: number | null;
|
|
84
|
+
/** Branch appears in `for-each-ref --merged <trunk>`. */
|
|
85
|
+
readonly merged: number;
|
|
86
|
+
/** Branch exists and is NOT merged into the trunk. */
|
|
87
|
+
readonly unmerged: number;
|
|
88
|
+
/** No branch to ask about (detached), or the merged probe was unavailable. */
|
|
89
|
+
readonly unclassifiable: number;
|
|
90
|
+
/** Overlaps the three buckets above on purpose; never summed with them. */
|
|
91
|
+
readonly with_live_session: number;
|
|
92
|
+
/** `merged + unmerged + unclassifiable`, printed so the sum is auditable. */
|
|
93
|
+
readonly partition_total: number;
|
|
94
|
+
readonly note: string | null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface WorkspaceDoctorReport {
|
|
98
|
+
readonly identity: Record<IdentityFieldName, IdentityField>;
|
|
99
|
+
readonly session: SessionView;
|
|
100
|
+
readonly containment: ContainmentView;
|
|
101
|
+
readonly pressure: PressureView;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _git(cwd: string, args: readonly string[]): string | null {
|
|
105
|
+
try {
|
|
106
|
+
return execFileSync('git', [...args], {
|
|
107
|
+
cwd,
|
|
108
|
+
encoding: 'utf-8',
|
|
109
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
110
|
+
timeout: 15_000,
|
|
111
|
+
// cwd decides, never an inherited GIT_DIR (hook environments).
|
|
112
|
+
env: gitEnv(),
|
|
113
|
+
}).trim();
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Independent count of registered worktrees, parsed from the **plain**
|
|
121
|
+
* `git worktree list` rather than the porcelain form `listWorktrees` reads.
|
|
122
|
+
*
|
|
123
|
+
* This exists so the partition assertion below is falsifiable. Comparing
|
|
124
|
+
* `merged + unmerged + unclassifiable` against `rows.length` — where the
|
|
125
|
+
* buckets are incremented once per row — is a tautology that holds by
|
|
126
|
+
* construction and cannot detect a dropped entry. Two different commands with
|
|
127
|
+
* two different output formats can disagree; that disagreement is the signal.
|
|
128
|
+
*/
|
|
129
|
+
export function independentRegisteredCount(repoPath: string): number | null {
|
|
130
|
+
const out = _git(repoPath, ['worktree', 'list']);
|
|
131
|
+
if (out === null) return null;
|
|
132
|
+
return out.split('\n').filter((l) => l.trim() !== '').length;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Short branch names merged into `trunk`, in **one** subprocess.
|
|
137
|
+
*
|
|
138
|
+
* Replaces one `merge-base --is-ancestor` spawn per registered worktree —
|
|
139
|
+
* measured at 307 spawns and 15.2 s wall for a read-only report. It also
|
|
140
|
+
* removes a silent misclassification: `_git` returns `null` on any failure
|
|
141
|
+
* (bad ref, timeout, git absent), and mapping that onto "not an ancestor"
|
|
142
|
+
* reported a failed probe as `unmerged`. Here a failed call is `null` and the
|
|
143
|
+
* caller sends every row to `unclassifiable` instead of guessing a side.
|
|
144
|
+
*/
|
|
145
|
+
function mergedBranches(repoPath: string, trunk: string): Set<string> | null {
|
|
146
|
+
const out = _git(repoPath, ['for-each-ref', '--merged', trunk, '--format=%(refname:short)', 'refs/heads/']);
|
|
147
|
+
if (out === null) return null;
|
|
148
|
+
return new Set(out.split('\n').map((l) => l.trim()).filter((l) => l !== ''));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function realpathOrSelf(p: string): string {
|
|
152
|
+
try {
|
|
153
|
+
return fs.realpathSync(p);
|
|
154
|
+
} catch {
|
|
155
|
+
return p;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Is `child` inside `parent`?
|
|
161
|
+
*
|
|
162
|
+
* Both sides are realpath-normalised first, and the comparison is
|
|
163
|
+
* separator-anchored: a plain `startsWith` would report `/a/repo-backup` as
|
|
164
|
+
* contained in `/a/repo`.
|
|
165
|
+
*/
|
|
166
|
+
export function isContained(parent: string, child: string): boolean {
|
|
167
|
+
const p = realpathOrSelf(parent);
|
|
168
|
+
const c = realpathOrSelf(child);
|
|
169
|
+
if (p === c) return false; // the main worktree is not "inside" itself
|
|
170
|
+
return c.startsWith(p.endsWith(path.sep) ? p : p + path.sep);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function collectSession(identity: WorkspaceIdentity, sessionId: string | null): SessionView {
|
|
174
|
+
const main = identity.mainWorktree;
|
|
175
|
+
const here = identity.currentWorktree;
|
|
176
|
+
if (!main.resolved) {
|
|
177
|
+
return {
|
|
178
|
+
claim: null,
|
|
179
|
+
claim_source: 'unavailable — no main worktree to anchor the register on',
|
|
180
|
+
live_records: 0,
|
|
181
|
+
conflicting: 0,
|
|
182
|
+
stale: 0,
|
|
183
|
+
register: `unresolvable: ${main.reason}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const dir = register_dir(main.value);
|
|
187
|
+
if (dir === null) {
|
|
188
|
+
return {
|
|
189
|
+
claim: null,
|
|
190
|
+
claim_source: 'unavailable — register_dir declined to resolve',
|
|
191
|
+
live_records: 0,
|
|
192
|
+
conflicting: 0,
|
|
193
|
+
stale: 0,
|
|
194
|
+
register: 'unresolvable',
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// `read_live_records` filters expired ones out; the difference against the
|
|
199
|
+
// files on disk is the stale count. Never prune here — this is a report.
|
|
200
|
+
const live = read_live_records(dir);
|
|
201
|
+
let onDisk = 0;
|
|
202
|
+
try {
|
|
203
|
+
onDisk = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).length;
|
|
204
|
+
} catch {
|
|
205
|
+
onDisk = 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const mine = sessionId === null ? null : live.find((r) => r.session_id === sessionId) ?? null;
|
|
209
|
+
const foreign: SessionRecord[] = live.filter((r) => r.session_id !== sessionId);
|
|
210
|
+
const hereValue = here.resolved ? realpathOrSelf(here.value) : null;
|
|
211
|
+
const conflicting =
|
|
212
|
+
hereValue === null ? 0 : foreign.filter((r) => realpathOrSelf(r.worktree) === hereValue).length;
|
|
213
|
+
|
|
214
|
+
let claim: string | null = null;
|
|
215
|
+
let claimSource: string;
|
|
216
|
+
if (mine?.roadmap_slug != null && mine.roadmap_slug !== '') {
|
|
217
|
+
claim = mine.roadmap_slug;
|
|
218
|
+
claimSource = 'this session\'s register record';
|
|
219
|
+
} else if (sessionId === null) {
|
|
220
|
+
claimSource = 'no session id in the environment — the host exports none, or this is not an agent session';
|
|
221
|
+
} else {
|
|
222
|
+
claimSource = 'no claim in this session\'s record (none picked, or the heartbeat has not lifted it yet)';
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
claim,
|
|
227
|
+
claim_source: claimSource,
|
|
228
|
+
live_records: foreign.length,
|
|
229
|
+
conflicting,
|
|
230
|
+
stale: Math.max(0, onDisk - live.length),
|
|
231
|
+
register: dir,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function collectContainment(identity: WorkspaceIdentity): ContainmentView {
|
|
236
|
+
const main = identity.mainWorktree;
|
|
237
|
+
const here = identity.currentWorktree;
|
|
238
|
+
if (!main.resolved) return { contained: null, reason: `main worktree unresolved: ${main.reason}` };
|
|
239
|
+
if (!here.resolved) return { contained: null, reason: `current worktree unresolved: ${here.reason}` };
|
|
240
|
+
if (realpathOrSelf(main.value) === realpathOrSelf(here.value)) {
|
|
241
|
+
// `null`, not `false`: the question does not apply, and rendering it as
|
|
242
|
+
// the word "outside" beside the reason "this IS the main worktree" was
|
|
243
|
+
// a self-contradicting line.
|
|
244
|
+
return { contained: null, reason: 'this IS the main worktree — containment does not apply' };
|
|
245
|
+
}
|
|
246
|
+
const contained = isContained(main.value, here.value);
|
|
247
|
+
return {
|
|
248
|
+
contained,
|
|
249
|
+
reason: contained
|
|
250
|
+
? 'this checkout lives inside the main worktree'
|
|
251
|
+
: 'this checkout lives outside the main worktree — a sibling or unrelated directory',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function collectPressure(identity: WorkspaceIdentity, live: SessionRecord[]): PressureView {
|
|
256
|
+
const main = identity.mainWorktree;
|
|
257
|
+
if (!main.resolved) {
|
|
258
|
+
return {
|
|
259
|
+
trunk: null,
|
|
260
|
+
trunk_provenance: 'not attempted — no main worktree to resolve from',
|
|
261
|
+
registered: 0,
|
|
262
|
+
independent_registered: null,
|
|
263
|
+
merged: 0,
|
|
264
|
+
unmerged: 0,
|
|
265
|
+
unclassifiable: 0,
|
|
266
|
+
with_live_session: 0,
|
|
267
|
+
partition_total: 0,
|
|
268
|
+
note: `no main worktree to enumerate from: ${main.reason}`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
// Both helpers are the EXPORTED ones from worktree_cleanup_check. Re-deriving
|
|
272
|
+
// them here would have been a fourth `worktree list --porcelain` parser and a
|
|
273
|
+
// second `resolveTrunk` beside the two this roadmap's own census counts — the
|
|
274
|
+
// duplication the whole change exists to remove.
|
|
275
|
+
const rows = listWorktrees(main.value);
|
|
276
|
+
// `resolveTrunk` returns the literal 'HEAD' when no trunk candidate resolves.
|
|
277
|
+
const trunkRef = resolveTrunk(main.value);
|
|
278
|
+
const trunk = trunkRef === 'HEAD' ? null : trunkRef;
|
|
279
|
+
const merged_set = trunk === null ? null : mergedBranches(main.value, trunk);
|
|
280
|
+
const livePaths = new Set(live.map((r) => realpathOrSelf(r.worktree)));
|
|
281
|
+
|
|
282
|
+
let merged = 0;
|
|
283
|
+
let unmerged = 0;
|
|
284
|
+
let unclassifiable = 0;
|
|
285
|
+
let withLive = 0;
|
|
286
|
+
for (const row of rows) {
|
|
287
|
+
if (livePaths.has(realpathOrSelf(row.path))) withLive += 1;
|
|
288
|
+
if (row.branch === null || merged_set === null) {
|
|
289
|
+
unclassifiable += 1;
|
|
290
|
+
} else if (merged_set.has(row.branch)) {
|
|
291
|
+
merged += 1;
|
|
292
|
+
} else {
|
|
293
|
+
unmerged += 1;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const independent = independentRegisteredCount(main.value);
|
|
298
|
+
const notes: string[] = [];
|
|
299
|
+
if (trunk === null) {
|
|
300
|
+
notes.push(
|
|
301
|
+
'no trunk ref resolved — every entry is unclassifiable rather than assumed merged',
|
|
302
|
+
);
|
|
303
|
+
} else if (merged_set === null) {
|
|
304
|
+
notes.push(
|
|
305
|
+
`the merged-branch probe against ${trunk} failed — every entry is unclassifiable rather than assumed unmerged`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (independent !== null && independent !== rows.length) {
|
|
309
|
+
notes.push(
|
|
310
|
+
`COUNT DISAGREEMENT: the porcelain parse sees ${rows.length}, the plain listing ${independent}`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
trunk,
|
|
316
|
+
trunk_provenance:
|
|
317
|
+
trunk === null
|
|
318
|
+
? 'unresolved — no candidate ref verified'
|
|
319
|
+
: 'candidate-ref search (first of origin/main, origin/master, main, master that resolves) — NOT a recorded remote default, which is what identity.prBase reports',
|
|
320
|
+
registered: rows.length,
|
|
321
|
+
independent_registered: independent,
|
|
322
|
+
merged,
|
|
323
|
+
unmerged,
|
|
324
|
+
unclassifiable,
|
|
325
|
+
with_live_session: withLive,
|
|
326
|
+
partition_total: merged + unmerged + unclassifiable,
|
|
327
|
+
note: notes.length === 0 ? null : notes.join(' · '),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function collectReport(start: string, sessionId: string | null): WorkspaceDoctorReport {
|
|
332
|
+
const identity = workspaceIdentity(start);
|
|
333
|
+
const session = collectSession(identity, sessionId);
|
|
334
|
+
const main = identity.mainWorktree;
|
|
335
|
+
const dir = main.resolved ? register_dir(main.value) : null;
|
|
336
|
+
const live = dir === null ? [] : read_live_records(dir);
|
|
337
|
+
return {
|
|
338
|
+
identity: {
|
|
339
|
+
repoRoot: identity.repoRoot,
|
|
340
|
+
mainWorktree: identity.mainWorktree,
|
|
341
|
+
currentWorktree: identity.currentWorktree,
|
|
342
|
+
branch: identity.branch,
|
|
343
|
+
prBase: identity.prBase,
|
|
344
|
+
},
|
|
345
|
+
session,
|
|
346
|
+
containment: collectContainment(identity),
|
|
347
|
+
pressure: collectPressure(identity, live),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function renderField(name: string, f: IdentityField): string {
|
|
352
|
+
const marker = f.resolved ? '✅' : '⚠️';
|
|
353
|
+
const body = f.resolved ? `${f.value} [${f.provenance}]` : `UNRESOLVED — ${f.reason}`;
|
|
354
|
+
return ` ${marker} ${name.padEnd(16)} ${body}`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function render(report: WorkspaceDoctorReport): string {
|
|
358
|
+
const lines: string[] = [];
|
|
359
|
+
lines.push('workspace:doctor · read-only · no disposal path by design');
|
|
360
|
+
lines.push('');
|
|
361
|
+
lines.push('Identity (every field with its provenance):');
|
|
362
|
+
for (const name of IDENTITY_FIELDS) {
|
|
363
|
+
lines.push(renderField(name, report.identity[name]));
|
|
364
|
+
}
|
|
365
|
+
lines.push('');
|
|
366
|
+
|
|
367
|
+
const s = report.session;
|
|
368
|
+
lines.push('Session:');
|
|
369
|
+
lines.push(` claim: ${s.claim ?? 'none'} [${s.claim_source}]`);
|
|
370
|
+
lines.push(` register: ${s.register}`);
|
|
371
|
+
lines.push(
|
|
372
|
+
` foreign live records: ${s.live_records} · conflicting on THIS checkout: ${s.conflicting} · expired-not-pruned: ${s.stale}`,
|
|
373
|
+
);
|
|
374
|
+
lines.push(
|
|
375
|
+
' advisory, never a lock — two sessions can claim in the same millisecond, and an idle session leaves the register while its user returns',
|
|
376
|
+
);
|
|
377
|
+
lines.push('');
|
|
378
|
+
|
|
379
|
+
const c = report.containment;
|
|
380
|
+
lines.push(`Path containment: ${c.contained === null ? 'n/a' : c.contained ? 'inside' : 'outside'} — ${c.reason}`);
|
|
381
|
+
lines.push('');
|
|
382
|
+
|
|
383
|
+
const p = report.pressure;
|
|
384
|
+
lines.push(`Worktree pressure (trunk = ${p.trunk ?? 'unresolved'}):`);
|
|
385
|
+
lines.push(` trunk provenance: ${p.trunk_provenance}`);
|
|
386
|
+
lines.push(` registered: ${p.registered}`);
|
|
387
|
+
lines.push(` merged into trunk: ${p.merged}`);
|
|
388
|
+
lines.push(` carrying unmerged commits:${String(p.unmerged).padStart(4)}`);
|
|
389
|
+
lines.push(` unclassifiable: ${String(p.unclassifiable).padStart(4)} (detached, or no merged probe)`);
|
|
390
|
+
const independentOk = p.independent_registered === null || p.independent_registered === p.registered;
|
|
391
|
+
lines.push(
|
|
392
|
+
` ── partition sums to ${p.partition_total} of ${p.registered}` +
|
|
393
|
+
`, cross-checked against an independent count of ${p.independent_registered ?? 'n/a'}` +
|
|
394
|
+
`${p.partition_total === p.registered && independentOk ? ' ✅' : ' ❌ MISMATCH'}`,
|
|
395
|
+
);
|
|
396
|
+
lines.push(
|
|
397
|
+
` with a live session record: ${p.with_live_session} (overlaps the buckets above — not part of the partition)`,
|
|
398
|
+
);
|
|
399
|
+
if (p.note !== null) lines.push(` note: ${p.note}`);
|
|
400
|
+
lines.push('');
|
|
401
|
+
lines.push('Disposal is out of scope here (roadmap Non-goals; Risk 3). `worktree:cleanup` owns it, behind its own approval.');
|
|
402
|
+
return lines.join('\n');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
406
|
+
let start = process.cwd();
|
|
407
|
+
let json = false;
|
|
408
|
+
let strict = false;
|
|
409
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
410
|
+
const a = argv[i];
|
|
411
|
+
if (a === '--from' && argv[i + 1] !== undefined) {
|
|
412
|
+
start = path.resolve(String(argv[(i += 1)]));
|
|
413
|
+
} else if (a === '--json') {
|
|
414
|
+
json = true;
|
|
415
|
+
} else if (a === '--strict') {
|
|
416
|
+
strict = true;
|
|
417
|
+
} else if (a === '--help' || a === '-h') {
|
|
418
|
+
process.stdout.write(
|
|
419
|
+
'workspace:doctor [--from <dir>] [--json] [--strict]\n' +
|
|
420
|
+
' Read-only workspace identity + worktree pressure report.\n' +
|
|
421
|
+
' --strict exits 1 when any identity field is unresolved.\n',
|
|
422
|
+
);
|
|
423
|
+
return 0;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const sessionId = process.env['CLAUDE_CODE_SESSION_ID'] ?? process.env['AGENT_SESSION_ID'] ?? null;
|
|
428
|
+
const report = collectReport(start, sessionId);
|
|
429
|
+
process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : `${render(report)}\n`);
|
|
430
|
+
|
|
431
|
+
if (strict) {
|
|
432
|
+
const unresolved = IDENTITY_FIELDS.filter((n) => !report.identity[n].resolved);
|
|
433
|
+
if (unresolved.length > 0) {
|
|
434
|
+
process.stderr.write(`unresolved identity field(s): ${unresolved.join(', ')}\n`);
|
|
435
|
+
return 1;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
442
|
+
function _isCliEntry(): boolean {
|
|
443
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
if (process.argv[1] === undefined) {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
450
|
+
if (import.meta.url === argvUrl) {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
455
|
+
const argv1 = fs.realpathSync(path.resolve(process.argv[1]));
|
|
456
|
+
return here === argv1;
|
|
457
|
+
} catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (_isCliEntry()) {
|
|
463
|
+
process.exit(main());
|
|
464
|
+
}
|
|
@@ -34,6 +34,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
34
34
|
import * as fs from 'node:fs';
|
|
35
35
|
import * as path from 'node:path';
|
|
36
36
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
37
|
+
import { read_live_records, register_dir } from './_lib/session_register.js';
|
|
37
38
|
|
|
38
39
|
export interface CheckResult {
|
|
39
40
|
allowed: boolean;
|
|
@@ -121,8 +122,9 @@ export function checkWorktree(worktreePath: string): CheckResult {
|
|
|
121
122
|
return { allowed: reasons.length === 0, reasons };
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
interface WorktreeEntry {
|
|
125
|
+
export interface WorktreeEntry {
|
|
125
126
|
path: string;
|
|
127
|
+
/** Short branch name (`refs/heads/` stripped), or `null` when detached. */
|
|
126
128
|
branch: string | null;
|
|
127
129
|
}
|
|
128
130
|
|
|
@@ -304,8 +306,20 @@ function isAncestor(repoPath: string, branch: string, trunk: string): boolean {
|
|
|
304
306
|
}
|
|
305
307
|
}
|
|
306
308
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
+
/**
|
|
310
|
+
* `basePath` MUST be the main worktree, never the invoking repo path.
|
|
311
|
+
*
|
|
312
|
+
* Measured 2026-08-13, and the failure is total rather than partial: called with
|
|
313
|
+
* a linked worktree as the base, every sibling worktree resolves to `..` and the
|
|
314
|
+
* safe set collapses to empty. Same binary, same repo, same minute — from inside
|
|
315
|
+
* `.claude/worktrees/<branch>`: 304 registered, safe 0, 278 "non-standard"; from
|
|
316
|
+
* the main checkout: safe 181, 81 non-standard. A maintainer who ran the
|
|
317
|
+
* inventory from a worktree would read "nothing to clean" and conclude the
|
|
318
|
+
* estate was fine, which is the worst shape a cleanup tool can fail in — it
|
|
319
|
+
* fails silently, and toward inaction.
|
|
320
|
+
*/
|
|
321
|
+
export function isStandardLocation(basePath: string, worktreePath: string): boolean {
|
|
322
|
+
const rel = path.relative(canonical(basePath), canonical(worktreePath));
|
|
309
323
|
if (rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
|
310
324
|
return STANDARD_WORKTREE_DIRS.some(
|
|
311
325
|
(dir) => rel === dir || rel.startsWith(`${dir}${path.sep}`),
|
|
@@ -348,16 +362,70 @@ function lastGitActivity(worktreePath: string): Date | null {
|
|
|
348
362
|
* Classify every worktree. `now` is injectable so the live-window boundary is
|
|
349
363
|
* testable rather than wall-clock dependent.
|
|
350
364
|
*/
|
|
351
|
-
|
|
365
|
+
/**
|
|
366
|
+
* Two conditions the `safe` predicate does NOT carry by default, both off unless
|
|
367
|
+
* asked for. They exist because an approval can be stricter than the tool, and
|
|
368
|
+
* silently removing under the looser predicate is the substitution such an
|
|
369
|
+
* approval is written to prevent.
|
|
370
|
+
*
|
|
371
|
+
* `minAgeDays` is a floor on git inactivity, layered ON TOP of the 48-hour live
|
|
372
|
+
* window rather than replacing it: 48h answers "is someone in it right now", a
|
|
373
|
+
* 60-day floor answers "is this abandoned". Different questions.
|
|
374
|
+
*
|
|
375
|
+
* `excludeLiveSessions` reads the session register — real records with a real
|
|
376
|
+
* `worktree` path and a real TTL — instead of inferring occupancy from file
|
|
377
|
+
* mtime, which this module's own header already rules out as a liveness signal.
|
|
378
|
+
*/
|
|
379
|
+
export interface InventoryOptions {
|
|
380
|
+
now?: Date;
|
|
381
|
+
minAgeDays?: number | null;
|
|
382
|
+
excludeLiveSessions?: boolean;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function buildInventory(
|
|
386
|
+
repoPath: string,
|
|
387
|
+
nowOrOptions: Date | InventoryOptions = new Date(),
|
|
388
|
+
): Inventory {
|
|
389
|
+
const opts: InventoryOptions =
|
|
390
|
+
nowOrOptions instanceof Date ? { now: nowOrOptions } : nowOrOptions;
|
|
391
|
+
const now = opts.now ?? new Date();
|
|
392
|
+
const minAgeDays = opts.minAgeDays ?? null;
|
|
393
|
+
const ageCutoff = minAgeDays === null ? null : now.getTime() - minAgeDays * 86400 * 1000;
|
|
394
|
+
return buildInventoryInner(repoPath, now, ageCutoff, minAgeDays, opts.excludeLiveSessions === true);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function buildInventoryInner(
|
|
398
|
+
repoPath: string,
|
|
399
|
+
nowArg: Date,
|
|
400
|
+
ageCutoff: number | null,
|
|
401
|
+
minAgeDays: number | null,
|
|
402
|
+
excludeLiveSessions: boolean,
|
|
403
|
+
): Inventory {
|
|
404
|
+
const now = nowArg;
|
|
352
405
|
const trunk = resolveTrunk(repoPath);
|
|
353
406
|
const entries = listWorktrees(repoPath);
|
|
354
407
|
const mainPath = canonical(entries.length > 0 ? entries[0]!.path : repoPath);
|
|
355
408
|
const liveCutoff = now.getTime() - LIVE_WINDOW_HOURS * 3600 * 1000;
|
|
356
409
|
|
|
410
|
+
// Registered session worktrees, canonicalised so the comparison survives a
|
|
411
|
+
// symlinked checkout root the same way every other path here does. Empty set
|
|
412
|
+
// when the option is off, when no register directory exists (the normal
|
|
413
|
+
// state before any session registers), or when every record has expired.
|
|
414
|
+
const sessionPaths = new Set<string>();
|
|
415
|
+
if (excludeLiveSessions) {
|
|
416
|
+
const dir = register_dir(mainPath);
|
|
417
|
+
if (dir !== null) {
|
|
418
|
+
for (const rec of read_live_records(dir, { now })) {
|
|
419
|
+
sessionPaths.add(canonical(rec.worktree));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
357
424
|
const rows: InventoryRow[] = entries.map((e) => {
|
|
358
425
|
const resolved = canonical(e.path);
|
|
359
426
|
const isMain = resolved === mainPath;
|
|
360
|
-
|
|
427
|
+
// `mainPath`, NOT `repoPath` — see isStandardLocation's contract.
|
|
428
|
+
const standardLocation = isMain ? true : isStandardLocation(mainPath, resolved);
|
|
361
429
|
const reasons: string[] = [];
|
|
362
430
|
|
|
363
431
|
if (!fs.existsSync(resolved)) {
|
|
@@ -389,6 +457,20 @@ export function buildInventory(repoPath: string, now: Date = new Date()): Invent
|
|
|
389
457
|
if (e.branch === null) {
|
|
390
458
|
reasons.push('detached HEAD — no branch to judge reachability for');
|
|
391
459
|
}
|
|
460
|
+
if (sessionPaths.has(resolved)) {
|
|
461
|
+
reasons.push('a live agent session is registered in this worktree');
|
|
462
|
+
}
|
|
463
|
+
if (ageCutoff !== null && (activity === null || activity.getTime() >= ageCutoff)) {
|
|
464
|
+
// `activity === null` disqualifies rather than passes: an unreadable
|
|
465
|
+
// git-dir mtime is absence of evidence, and under an explicit
|
|
466
|
+
// minimum-age floor that has to read as "cannot show it is old
|
|
467
|
+
// enough", never as "old enough".
|
|
468
|
+
reasons.push(
|
|
469
|
+
activity === null
|
|
470
|
+
? `git activity unreadable — cannot prove ${minAgeDays}+ days of inactivity`
|
|
471
|
+
: `git activity within the last ${minAgeDays} days`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
392
474
|
if (!standardLocation) {
|
|
393
475
|
reasons.push('non-standard location — outside the conventional worktree roots');
|
|
394
476
|
}
|
|
@@ -465,7 +547,7 @@ export function removalPlan(inv: Inventory): string[] {
|
|
|
465
547
|
function usage(): never {
|
|
466
548
|
process.stderr.write(
|
|
467
549
|
'usage: worktree_cleanup_check check <worktree-path> | scope-overlap [repo-path] | ' +
|
|
468
|
-
'inventory [repo-path] [--json|--plan]\n',
|
|
550
|
+
'inventory [repo-path] [--json|--plan] [--min-age-days=N] [--exclude-live-sessions]\n',
|
|
469
551
|
);
|
|
470
552
|
process.exit(2);
|
|
471
553
|
}
|
|
@@ -509,9 +591,32 @@ export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
|
509
591
|
const rest = argv.slice(1);
|
|
510
592
|
const flags = rest.filter((a) => a.startsWith('--'));
|
|
511
593
|
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
512
|
-
|
|
594
|
+
const KNOWN = ['--json', '--plan', '--exclude-live-sessions'];
|
|
595
|
+
const ageFlag = flags.find((f) => f.startsWith('--min-age-days='));
|
|
596
|
+
if (
|
|
597
|
+
positional.length > 1 ||
|
|
598
|
+
flags.some((f) => !KNOWN.includes(f) && !f.startsWith('--min-age-days='))
|
|
599
|
+
) {
|
|
600
|
+
usage();
|
|
601
|
+
}
|
|
602
|
+
let minAgeDays: number | null = null;
|
|
603
|
+
if (ageFlag !== undefined) {
|
|
604
|
+
const raw = ageFlag.slice('--min-age-days='.length);
|
|
605
|
+
const n = Number(raw);
|
|
606
|
+
// Refuse rather than silently fall back to null: a typo'd floor that
|
|
607
|
+
// reads as "no floor" would widen the safe set at exactly the moment
|
|
608
|
+
// the caller asked to narrow it.
|
|
609
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
|
|
610
|
+
process.stderr.write(`--min-age-days needs a non-negative integer, got '${raw}'\n`);
|
|
611
|
+
return 2;
|
|
612
|
+
}
|
|
613
|
+
minAgeDays = n;
|
|
614
|
+
}
|
|
513
615
|
const repo = path.resolve(positional[0] ?? '.');
|
|
514
|
-
const inv = buildInventory(repo
|
|
616
|
+
const inv = buildInventory(repo, {
|
|
617
|
+
minAgeDays,
|
|
618
|
+
excludeLiveSessions: flags.includes('--exclude-live-sessions'),
|
|
619
|
+
});
|
|
515
620
|
|
|
516
621
|
if (flags.includes('--json')) {
|
|
517
622
|
process.stdout.write(`${JSON.stringify(inv, null, 2)}\n`);
|