@event4u/agent-config 12.0.0 → 13.0.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 +273 -0
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +46 -3
- package/dist/agent-src/commands/cost/report.md +23 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
- package/dist/agent-src/contexts/model-recommendations.md +6 -4
- package/dist/agent-src/contexts/subagent-configuration.md +69 -7
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
- package/dist/agent-src/rules/evaluator-independence.md +27 -5
- package/dist/agent-src/rules/git-history-discipline.md +5 -1
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
- package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
- package/dist/agent-src/rules/ui-audit-gate.md +9 -4
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
- package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
- package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -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/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
- package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
- package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
- package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
- package/dist/cli-delegate/cmd_conformance.js +4 -4
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +44 -23
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +10625 -8541
- package/dist/install/install.mjs +754 -484
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1302 -167
- 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/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/MIGRATION.md +20 -1
- package/docs/guidelines/design-fidelity-mechanics.md +21 -0
- package/docs/guidelines/design-handover-extraction.md +87 -0
- package/docs/guidelines/prompt-templates.md +24 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/src/agent-src/scripts/roadmap_gates.ts +30 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-coverage.yml +63 -11
- package/src/config/gate-violation-baselines.json +12 -0
- package/src/config/hook-token-budget.json +15 -2
- package/src/config/preamble-payload-budget.json +28 -0
- package/src/scripts/_dispatch.bash +10 -0
- package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
- package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
- package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
- package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
- package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
- 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 +1115 -0
- package/src/scripts/_lib/tier_budget_routing.ts +31 -331
- package/src/scripts/ai_council/clients.ts +197 -18
- 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/bench_ab_v2_complexity.ts +252 -0
- package/src/scripts/bench_ab_v2_run.ts +3 -42
- package/src/scripts/bench_ab_v2_safety.ts +296 -0
- package/src/scripts/bench_ab_v2_search.ts +343 -0
- package/src/scripts/bench_ab_v2_stats.ts +191 -0
- package/src/scripts/build_archive_index.ts +383 -0
- package/src/scripts/capture_skill_catalogue.ts +279 -317
- package/src/scripts/check_branch_freshness.ts +7 -1
- package/src/scripts/check_release_published.ts +6 -2
- package/src/scripts/check_release_trunk_sync.ts +18 -2
- package/src/scripts/check_source_size_budget.ts +428 -0
- package/src/scripts/check_suppression_hygiene.ts +5 -1
- package/src/scripts/condense.ts +50 -1
- package/src/scripts/cost/backfill_rates.mjs +298 -0
- package/src/scripts/cost/budget.mjs +12 -32
- package/src/scripts/cost_summary.ts +25 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -5
- package/src/scripts/evidence_report.ts +15 -4
- package/src/scripts/external_sources_denylist.json +1 -0
- package/src/scripts/hook_manifest.yaml +75 -15
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
- package/src/scripts/hooks/skill_route_hook.ts +226 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
- package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
- 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_design_antipattern_parity.ts +40 -6
- package/src/scripts/lint_harvest_provenance.ts +35 -1
- package/src/scripts/lint_plan_risk_register.ts +22 -9
- package/src/scripts/lint_roadmap_blockers.ts +109 -6
- package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
- package/src/scripts/lint_scheduled_deprecations.ts +738 -0
- package/src/scripts/lint_skill_router_head.ts +21 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
- package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
- package/src/scripts/mcp_server/tools.ts +87 -0
- package/src/scripts/migration_status.ts +17 -1
- package/src/scripts/probe_review_binding_drift.ts +731 -0
- package/src/scripts/release.ts +100 -6
- package/src/scripts/routing_doctor.ts +50 -13
- package/src/scripts/schemas/command.schema.json +3 -3
- package/src/scripts/schemas/skill.schema.json +20 -2
- package/src/scripts/schemas/subagent.schema.json +2 -2
- package/src/scripts/session_eol_report.ts +81 -0
- package/src/scripts/trigger_coverage.ts +224 -3
- package/src/scripts/workspace_doctor.ts +464 -0
- package/src/scripts/worktree_cleanup_check.ts +2 -1
- package/src/server/schemas/settings.ts +2 -2
- package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
- package/src/config/budget-routing.json +0 -7
|
@@ -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
|
+
}
|
|
@@ -122,8 +122,9 @@ export function checkWorktree(worktreePath: string): CheckResult {
|
|
|
122
122
|
return { allowed: reasons.length === 0, reasons };
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
interface WorktreeEntry {
|
|
125
|
+
export interface WorktreeEntry {
|
|
126
126
|
path: string;
|
|
127
|
+
/** Short branch name (`refs/heads/` stripped), or `null` when detached. */
|
|
127
128
|
branch: string | null;
|
|
128
129
|
}
|
|
129
130
|
|
|
@@ -80,13 +80,13 @@ export const settingsSchema = z.object({
|
|
|
80
80
|
),
|
|
81
81
|
per_tier: z.object({
|
|
82
82
|
cheap: z.number().min(0).nullable().default(null).describe(
|
|
83
|
-
'USD ceiling for the cheap model tier
|
|
83
|
+
'USD ceiling for the cheap model tier. Budget-aware delegation was ARCHIVED 2026-08-16 (docs/contracts/budget-routing.md), so no code routes on this today; the cap is still summed and reported by `budget.mjs tier`. null = no separate tier cap; global ceilings still apply.',
|
|
84
84
|
),
|
|
85
85
|
medium: z.number().min(0).nullable().default(null).describe(
|
|
86
86
|
'USD ceiling for the medium model tier. null = no separate tier cap.',
|
|
87
87
|
),
|
|
88
88
|
strong: z.number().min(0).nullable().default(null).describe(
|
|
89
|
-
'USD ceiling for the strong model tier. null = no separate tier cap
|
|
89
|
+
'USD ceiling for the strong model tier. null = no separate tier cap. The never-block-to-save-money relation this used to describe went with the archived budget-routing layer (docs/contracts/budget-routing.md); nothing routes between tiers today.',
|
|
90
90
|
),
|
|
91
91
|
}).default({ cheap: null, medium: null, strong: null }),
|
|
92
92
|
}),
|