@event4u/agent-config 9.27.0 → 9.28.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/AGENTS.md +12 -11
- package/CHANGELOG.md +99 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +24 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +10 -3
- package/dist/agent-src/rules/artifact-engagement-recording.md +4 -1
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/brand-source-of-truth.md +33 -6
- package/dist/agent-src/rules/context-hygiene.md +3 -1
- package/dist/agent-src/rules/council-availability.md +93 -0
- package/dist/agent-src/rules/cross-source-consistency.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +29 -5
- package/dist/agent-src/rules/design-fidelity.md +3 -0
- package/dist/agent-src/rules/evaluator-independence.md +2 -1
- package/dist/agent-src/rules/external-code-graph-interop.md +2 -1
- package/dist/agent-src/rules/finance-safety-floor.md +1 -1
- package/dist/agent-src/rules/git-history-discipline.md +1 -1
- package/dist/agent-src/rules/history-discipline.md +3 -1
- package/dist/agent-src/rules/legal-safety-floor.md +1 -1
- package/dist/agent-src/rules/no-pr-progress-comments.md +3 -1
- package/dist/agent-src/rules/onboarding-gate.md +3 -1
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +4 -1
- package/dist/agent-src/rules/role-mode-adherence.md +1 -1
- package/dist/agent-src/rules/scale-discipline.md +3 -1
- package/dist/agent-src/rules/self-repair-loop.md +103 -0
- package/dist/agent-src/rules/session-canary.md +2 -1
- package/dist/agent-src/rules/strategy-safety-floor.md +1 -1
- package/dist/agent-src/rules/token-budget-discipline.md +3 -1
- package/dist/agent-src/rules/tool-safety.md +5 -1
- package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
- package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
- package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
- package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
- package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
- package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
- package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
- package/dist/agent-src/templates/AGENTS.md +6 -10
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +7 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-22GCIJ2O.js +270 -0
- package/dist/cli-delegate/{chunk-BCPIOAGV.js → chunk-2BE5I2MN.js} +8 -8
- package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-7OMQCH4V.js} +1 -1
- package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-GB2FSUEE.js} +57 -7
- package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-JXARCGVN.js} +3 -3
- package/dist/cli-delegate/{chunk-3KJIMFTB.js → chunk-PQSAQKHW.js} +466 -304
- package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +134 -0
- package/dist/cli-delegate/cmd_conformance.js +7 -7
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +4 -4
- package/dist/cli-delegate/cmd_explain.js +80 -330
- package/dist/cli-delegate/cmd_export.js +5 -5
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_mcp_available.js +433 -0
- package/dist/cli-delegate/cmd_migrate.js +3 -3
- package/dist/cli-delegate/cmd_packs_active.js +153 -0
- package/dist/cli-delegate/cmd_preflight.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +5 -5
- package/dist/cli-delegate/cmd_route_audit.js +8 -8
- package/dist/cli-delegate/cmd_route_explain.js +4 -4
- package/dist/cli-delegate/cmd_settings_get.js +272 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +5 -5
- package/dist/cli-delegate/cmd_settings_set.js +7 -2967
- package/dist/cli-delegate/cmd_sync.js +5 -5
- package/dist/cli-delegate/cmd_uninstall.js +5 -5
- package/dist/cli-delegate/cmd_update.js +4 -4
- package/dist/cli-delegate/cmd_upgrade.js +4 -4
- package/dist/cli-delegate/cmd_validate.js +5 -5
- package/dist/cli-delegate/cmd_versions.js +4 -4
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +100 -40
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -5
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +6 -4
- package/dist/hooks/dispatch.js +9004 -6132
- package/dist/install/install.mjs +697 -507
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +127 -20
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +80 -15
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +29 -8
- package/docs/guidelines/agent-infra/memory-access.md +14 -3
- package/package.json +1 -1
- package/src/agent-src/templates/AGENTS.md +6 -10
- package/src/config/budgets.yml +29 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +26 -0
- package/src/scripts/_cli/cmd_brand_status.ts +174 -0
- package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
- package/src/scripts/_cli/cmd_packs_active.ts +225 -0
- package/src/scripts/_cli/cmd_settings_get.ts +324 -0
- package/src/scripts/_dispatch.bash +57 -0
- package/src/scripts/_lib/agent_settings.ts +82 -16
- package/src/scripts/_lib/carrier_divergence.ts +135 -0
- package/src/scripts/_lib/host_capability.ts +84 -1
- package/src/scripts/_lib/prompt_shape.ts +88 -0
- package/src/scripts/_lib/review_skipped_record.ts +102 -0
- package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
- package/src/scripts/_lib/self_repair.ts +342 -0
- package/src/scripts/_lib/self_repair_store.ts +100 -0
- package/src/scripts/_lib/transcript_entry.ts +80 -0
- package/src/scripts/check_branch_freshness.ts +255 -0
- package/src/scripts/check_council_config_location.ts +125 -1
- package/src/scripts/check_standing_rule_delivery.ts +314 -0
- package/src/scripts/config/profiles.ts +13 -3
- package/src/scripts/conformance_scan.ts +272 -24
- package/src/scripts/council_availability_hook.ts +188 -0
- package/src/scripts/council_cli.ts +126 -7
- package/src/scripts/hook_manifest.yaml +107 -26
- package/src/scripts/hooks/block_no_verify.ts +39 -2
- package/src/scripts/hooks/block_unauthorized_git.ts +156 -7
- package/src/scripts/hooks/concern_registry.ts +10 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +450 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
- package/src/scripts/install.ts +151 -0
- package/src/scripts/language_mirror_hook.ts +30 -37
- package/src/scripts/measure_scope_dedup.ts +19 -13
- package/src/scripts/onboarding_gate_hook.ts +40 -1
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/preamble_byte_census.ts +9 -20
- package/src/scripts/report_carrier_divergence.ts +364 -0
- package/src/scripts/report_skill_activation.ts +257 -0
- package/src/scripts/report_skill_obligation_violations.ts +579 -0
- package/src/scripts/routing_doctor.ts +77 -6
- package/src/scripts/self_repair_cli.ts +240 -0
- package/src/scripts/self_repair_hook.ts +241 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One definition of "these two carriers deliver the same rule" — shared by the
|
|
3
|
+
* C-3 reachability harness and the advisory divergence report.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS IS A LIBRARY AND NOT TWO COPIES. Two surfaces need the comparison
|
|
6
|
+
* and they ask different questions of it. `measure_scope_dedup` asks *how many*
|
|
7
|
+
* installed twins a byte-identity dedup could skip, anchored on the projection
|
|
8
|
+
* source. `report_carrier_divergence` asks *which* rules a reader must look at,
|
|
9
|
+
* anchored on the union of both carriers. The directory walks are legitimately
|
|
10
|
+
* different; the comparison is not, and a second copy of it is how "differs only
|
|
11
|
+
* in the ownership stamp" and "differs in body" drift into meaning two things.
|
|
12
|
+
*
|
|
13
|
+
* The comparison itself is the load-bearing part, so it is stated once:
|
|
14
|
+
*
|
|
15
|
+
* 1. bytes equal → `identical`
|
|
16
|
+
* 2. equal after removing the two
|
|
17
|
+
* installer ownership keys → `provenance-only`
|
|
18
|
+
* 3. otherwise → `body-diff`
|
|
19
|
+
*
|
|
20
|
+
* Class 2 exists because `install.ts` stamps `package:` and `source_path:` into
|
|
21
|
+
* every rule it installs while the in-repo projection stamps nothing, so a
|
|
22
|
+
* provenance-only difference survives even a perfect version alignment. Keeping
|
|
23
|
+
* it apart from class 3 is what turns a bare "0 identical twins" into an answer:
|
|
24
|
+
* `provenanceOnly == shared` means aligning versions would buy nothing, and
|
|
25
|
+
* `bodyDiff > 0` means the two carriers are delivering different text — the only
|
|
26
|
+
* class where a reader has to act.
|
|
27
|
+
*
|
|
28
|
+
* NOT A LICENCE TO DEDUP. Classifying a pair `provenance-only` says the bodies
|
|
29
|
+
* match; it does NOT say the rule is safe to drop from one carrier. Byte-identity
|
|
30
|
+
* remains the dedup predicate, deliberately — see
|
|
31
|
+
* `agents/settings/contexts/dedup-reachability-refusal.md`, which records that
|
|
32
|
+
* decision and its five reopen conditions. Nothing here relaxes it.
|
|
33
|
+
*/
|
|
34
|
+
import * as fs from 'node:fs';
|
|
35
|
+
import * as path from 'node:path';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The keys `install.ts` adds to every installed rule (`_set_key(fm_lines,
|
|
39
|
+
* 'package', …)` and `source_path` at install.ts:2733). Removing them is what
|
|
40
|
+
* separates an installer stamp from a content change.
|
|
41
|
+
*/
|
|
42
|
+
export const OWNERSHIP_KEYS = ['package', 'source_path'] as const;
|
|
43
|
+
|
|
44
|
+
export type PairVerdict = 'identical' | 'provenance-only' | 'body-diff';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Drop the ownership stamp lines, wherever they appear in the FILE.
|
|
48
|
+
*
|
|
49
|
+
* Not scoped to the frontmatter, and the docstring says so because the scope is
|
|
50
|
+
* wider than the intent. Two reasons it stays this way: `install.ts` writes the
|
|
51
|
+
* keys without any guarantee about fence position, and `scope_dedup.test.ts`
|
|
52
|
+
* deliberately pins the fence-less shape (a stamp with no `---` around it) as the
|
|
53
|
+
* install condition. Residual risk, stated rather than hidden: a rule body that
|
|
54
|
+
* contains a line literally starting `package:` or `source_path:` — a YAML
|
|
55
|
+
* example, an indent-free config snippet — has that line removed from both sides,
|
|
56
|
+
* so a genuine difference confined to such a line would read `provenance-only`
|
|
57
|
+
* instead of `body-diff`. Measured over the shipped corpus: no rule body carries
|
|
58
|
+
* one. Re-check that before treating the class as impossible rather than absent.
|
|
59
|
+
*/
|
|
60
|
+
export function stripOwnershipKeys(text: string): string {
|
|
61
|
+
return text
|
|
62
|
+
.split('\n')
|
|
63
|
+
.filter((line) => !OWNERSHIP_KEYS.some((k) => line.startsWith(`${k}:`)))
|
|
64
|
+
.join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compare one rule's two copies.
|
|
69
|
+
*
|
|
70
|
+
* Takes buffers rather than paths: the callers already hold them, and a pure
|
|
71
|
+
* function is what lets both surfaces pin the same three-way split in tests
|
|
72
|
+
* without a filesystem.
|
|
73
|
+
*/
|
|
74
|
+
export function comparePair(a: Buffer, b: Buffer): PairVerdict {
|
|
75
|
+
if (a.equals(b)) {
|
|
76
|
+
return 'identical';
|
|
77
|
+
}
|
|
78
|
+
if (stripOwnershipKeys(a.toString('utf-8')) === stripOwnershipKeys(b.toString('utf-8'))) {
|
|
79
|
+
return 'provenance-only';
|
|
80
|
+
}
|
|
81
|
+
return 'body-diff';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface RuleDirCensus {
|
|
85
|
+
files: number;
|
|
86
|
+
/**
|
|
87
|
+
* BYTES, not characters — `statSync().size`. The name is kept because every
|
|
88
|
+
* consumer of this figure (`preamble_byte_census`, `measure_scope_dedup`, the
|
|
89
|
+
* conformance scan) already divides it by 4 under that name, and one honest
|
|
90
|
+
* docstring beats a rename that would silently move three published baselines.
|
|
91
|
+
*
|
|
92
|
+
* The gap is measured, not assumed: over the 110-rule project tree, 406 502
|
|
93
|
+
* bytes against 402 823 characters — **0.91 %** overhead from the em-dashes,
|
|
94
|
+
* arrows and status glyphs the prose uses. So the published token estimate
|
|
95
|
+
* reads 101 626 where a character basis gives 100 706. Under 1 % and in the
|
|
96
|
+
* conservative direction for a payload figure, which is why it is documented
|
|
97
|
+
* rather than repaired; re-measure before quoting it as negligible on a corpus
|
|
98
|
+
* with more non-ASCII than this one.
|
|
99
|
+
*/
|
|
100
|
+
chars: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* How much rule text one carrier delivers.
|
|
105
|
+
*
|
|
106
|
+
* `statSync`, not `lstatSync`, on purpose: a project-scope entry is a symlink
|
|
107
|
+
* into `dist/`, and the bytes the HOST reads are the target's. Measuring the link
|
|
108
|
+
* would report ~50 bytes per rule and make the whole payload vanish.
|
|
109
|
+
*
|
|
110
|
+
* Moved here from `preamble_byte_census` when `conformance_scan` needed it:
|
|
111
|
+
* that module pulls `yaml` and the cold-start report, and importing a dependency
|
|
112
|
+
* chain into a bundled CLI path to reach fifteen lines of `statSync` is the wrong
|
|
113
|
+
* trade. `preamble_byte_census` re-exports this so its own callers are unchanged
|
|
114
|
+
* and there is still exactly one definition.
|
|
115
|
+
*/
|
|
116
|
+
export function censusRuleDir(dir: string): RuleDirCensus {
|
|
117
|
+
let files = 0;
|
|
118
|
+
let chars = 0;
|
|
119
|
+
let entries: string[];
|
|
120
|
+
try {
|
|
121
|
+
entries = fs.readdirSync(dir);
|
|
122
|
+
} catch {
|
|
123
|
+
return { files: 0, chars: 0 };
|
|
124
|
+
}
|
|
125
|
+
for (const name of entries) {
|
|
126
|
+
if (!name.endsWith('.md')) continue;
|
|
127
|
+
try {
|
|
128
|
+
chars += fs.statSync(path.join(dir, name)).size;
|
|
129
|
+
files += 1;
|
|
130
|
+
} catch {
|
|
131
|
+
// Unreadable entry — skip rather than fail the whole census.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { files, chars };
|
|
135
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host-capability manifest normalizer.
|
|
2
|
+
* Host-capability manifest normalizer + committed capability registry.
|
|
3
3
|
*
|
|
4
4
|
* Pure, no-I/O helper that takes a partial / unknown object (typically the
|
|
5
5
|
* `subagents.host_capabilities` override from `.agent-settings.yml`, or the
|
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
* have no subagent primitive.
|
|
12
12
|
* - A missing or non-boolean field resolves to `false`, never `true`.
|
|
13
13
|
* - `schema_version` is always forced to `1`.
|
|
14
|
+
*
|
|
15
|
+
* `resolveHostCapabilities` (F5, road-to-orchestrator-discipline-carriers)
|
|
16
|
+
* adds a resolution order in front of the safe default so a fresh install on
|
|
17
|
+
* a KNOWN host does not ship delegation dead by default:
|
|
18
|
+
*
|
|
19
|
+
* explicit `subagents.host_capabilities` override (wins, whole-object,
|
|
20
|
+
* unchanged semantics) → committed registry row for the detected host
|
|
21
|
+
* (`HOST_CAPABILITY_REGISTRY`) → `SAFE_DEFAULT` (all false).
|
|
22
|
+
*
|
|
23
|
+
* The registry lists only OBSERVED capabilities for a host this repo has
|
|
24
|
+
* actually measured (see the roadmap's transcript evidence) — never a
|
|
25
|
+
* speculative entry for a host nobody has verified.
|
|
14
26
|
*/
|
|
15
27
|
|
|
16
28
|
/** Resolved host-capability manifest. All booleans, `schema_version` fixed at 1. */
|
|
@@ -57,3 +69,74 @@ export function normalizeHostManifest(input: unknown): HostCapabilityManifest {
|
|
|
57
69
|
separate_quota_pool: asBool(src.separate_quota_pool),
|
|
58
70
|
};
|
|
59
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Committed registry — known host identifiers → OBSERVED capability sets.
|
|
75
|
+
*
|
|
76
|
+
* Keyed by the platform identifier this repo already uses for the same host
|
|
77
|
+
* across the hook/dispatch layer (`--platform claude`, `platforms['claude']`
|
|
78
|
+
* in `claude_settings_hooks.ts`, `TOOL_IDS` in `probe_skill_registration.ts`)
|
|
79
|
+
* — NOT the separate `toolDetection.ts` binary-detection id (`'claude-code'`),
|
|
80
|
+
* which names a different namespace (installed-CLI presence, not the running
|
|
81
|
+
* host identity a hook/dispatch envelope carries).
|
|
82
|
+
*
|
|
83
|
+
* Each row is partial: an omitted field stays at the `SAFE_DEFAULT` `false`
|
|
84
|
+
* when the row is applied. Claude Code is the one host this roadmap's
|
|
85
|
+
* transcript evidence measured spawning and running subagents concurrently
|
|
86
|
+
* (`Agent` tool dispatch, parallel tool-use blocks); polling and a separate
|
|
87
|
+
* quota pool were never observed, so they stay `false` — add a field only
|
|
88
|
+
* once it is itself observed, never by inference.
|
|
89
|
+
*/
|
|
90
|
+
const HOST_CAPABILITY_REGISTRY: Readonly<Record<string, Partial<HostCapabilityManifest>>> = {
|
|
91
|
+
claude: { subagent_spawn: true, parallel_spawn: true },
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the effective host-capability manifest for `hostId`.
|
|
96
|
+
*
|
|
97
|
+
* Resolution order (highest wins), per
|
|
98
|
+
* `host-capability-manifest.md § Resolution`:
|
|
99
|
+
*
|
|
100
|
+
* 1. `override` — the `subagents.host_capabilities` settings value, when
|
|
101
|
+
* present as a non-null object. Normalized exactly as {@link normalizeHostManifest}
|
|
102
|
+
* always has: a WHOLE-OBJECT override, each field independently coerced,
|
|
103
|
+
* a field absent from the override object resolves to `false`. This is
|
|
104
|
+
* the pre-existing, unchanged behavior — a present override still wins
|
|
105
|
+
* outright over the registry row, it does not merge field-by-field with it.
|
|
106
|
+
* 2. The committed {@link HOST_CAPABILITY_REGISTRY} row for `hostId`, when no
|
|
107
|
+
* override is present and the host is known.
|
|
108
|
+
* 3. `SAFE_DEFAULT` — unknown `hostId`, or no override and no registry row.
|
|
109
|
+
*
|
|
110
|
+
* `hostId` is a caller-supplied identifier (e.g. the same string a hook
|
|
111
|
+
* envelope's `platform` field carries) — this function does no environment
|
|
112
|
+
* probing of its own; see `host-capability-manifest.md` for why resolution
|
|
113
|
+
* stays a per-session, agent/caller-supplied fact rather than a live probe.
|
|
114
|
+
*
|
|
115
|
+
* An array override does NOT count as present (F8, review): `typeof [] ===
|
|
116
|
+
* 'object'` in JS, so a bare `typeof override === 'object'` check let a
|
|
117
|
+
* stray array reach `normalizeHostManifest`, which reads named properties
|
|
118
|
+
* off it (`.subagent_spawn`, …) that an array never has — every field
|
|
119
|
+
* silently coerces to `false`, exactly like an explicit empty-object
|
|
120
|
+
* override, even though nobody supplied one. An array is not a manifest
|
|
121
|
+
* shape at all, so it is treated the same as `null`/non-object: fall
|
|
122
|
+
* through to the registry row.
|
|
123
|
+
*/
|
|
124
|
+
export function resolveHostCapabilities(
|
|
125
|
+
hostId: string | null | undefined,
|
|
126
|
+
override?: unknown,
|
|
127
|
+
): HostCapabilityManifest {
|
|
128
|
+
if (
|
|
129
|
+
override !== undefined &&
|
|
130
|
+
override !== null &&
|
|
131
|
+
typeof override === 'object' &&
|
|
132
|
+
!Array.isArray(override)
|
|
133
|
+
) {
|
|
134
|
+
return normalizeHostManifest(override);
|
|
135
|
+
}
|
|
136
|
+
const row =
|
|
137
|
+
hostId !== null && hostId !== undefined ? HOST_CAPABILITY_REGISTRY[hostId] : undefined;
|
|
138
|
+
if (row === undefined) {
|
|
139
|
+
return { ...SAFE_DEFAULT };
|
|
140
|
+
}
|
|
141
|
+
return { ...SAFE_DEFAULT, ...row, schema_version: 1 };
|
|
142
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a "prompt" actually is — shared by the language hook and the conformance
|
|
3
|
+
* scanner so the two cannot classify the same entry differently.
|
|
4
|
+
*
|
|
5
|
+
* They did. Round 6 measured it: `isSyntheticPrompt` lived in
|
|
6
|
+
* `language_mirror_hook` and was never referenced from `conformance_scan`, whose
|
|
7
|
+
* only general net was `length > 2500 && english`. So the hook skipped
|
|
8
|
+
* harness-generated turns and the scanner counted them, and every era split
|
|
9
|
+
* argued about two different populations without knowing it.
|
|
10
|
+
*
|
|
11
|
+
* The asymmetry ran the other way too, and that half had no net on either side:
|
|
12
|
+
* a prompt whose bulk is pasted content in another language pinned to the paste.
|
|
13
|
+
* That fired on the very session that received the round-6 review — the pin read
|
|
14
|
+
* English because an English draft was pasted below German prose.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here is language-specific. A rule that names a language cannot answer
|
|
17
|
+
* a bidirectional question.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Is this "prompt" a harness-generated turn rather than something a human typed?
|
|
22
|
+
*
|
|
23
|
+
* MEASURED, IN THE LANGUAGE HOOK'S OWN AUDIT (round 5, 2026-08-07). The state
|
|
24
|
+
* file read mid-session:
|
|
25
|
+
*
|
|
26
|
+
* { "language": "en", "source": "prompt", "prompt_chars": 6627,
|
|
27
|
+
* "de_markers": 0, "en_markers": 63 }
|
|
28
|
+
*
|
|
29
|
+
* No human wrote those 6,627 characters. They were a background-task completion
|
|
30
|
+
* notification, injected as a user turn, which `user_prompt_submit` sees and the
|
|
31
|
+
* hook classified as the trigger — flipping a German session to `en` for every
|
|
32
|
+
* later turn, and recording `source: "prompt"`, a false provenance claim.
|
|
33
|
+
*
|
|
34
|
+
* The markers are structural, not linguistic: the harness stamps these turns
|
|
35
|
+
* with fixed envelope tags. Matching prose would misfire on a human quoting a
|
|
36
|
+
* notification; matching the tags cannot, because a human typing
|
|
37
|
+
* `<task-notification>` is quoting, and a quote does not open at character zero.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately conservative: an unrecognised synthetic shape falls through and
|
|
40
|
+
* is treated as a chat message. Under-filtering keeps prior behaviour;
|
|
41
|
+
* over-filtering would silently stop pinning real prompts, which is worse.
|
|
42
|
+
* Measured across 1 540 user-role entries: 509 open at character zero with a
|
|
43
|
+
* marker, and in 0 of them does human text survive wrapper removal.
|
|
44
|
+
*/
|
|
45
|
+
export function isSyntheticPrompt(prompt: string): boolean {
|
|
46
|
+
const head = prompt.trimStart().slice(0, 400);
|
|
47
|
+
return (
|
|
48
|
+
head.startsWith("[SYSTEM NOTIFICATION - NOT USER INPUT]") ||
|
|
49
|
+
head.startsWith("<task-notification>") ||
|
|
50
|
+
head.startsWith("<system-reminder>") ||
|
|
51
|
+
head.startsWith("<local-command-caveat>") ||
|
|
52
|
+
/^\[SYSTEM NOTIFICATION\b/.test(head)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A line that starts pasted DOCUMENT content rather than typed instruction.
|
|
58
|
+
*
|
|
59
|
+
* Distinct from the output-shaped heads `instructionText` already removes (stack
|
|
60
|
+
* traces, diffs, `remote:` lines). Those look like machine output. This catches
|
|
61
|
+
* the other paste shape, which looks like prose: a roadmap, a review, a spec —
|
|
62
|
+
* pasted whole, under its own headings, below one typed sentence.
|
|
63
|
+
*
|
|
64
|
+
* The markers are structural for the same reason as above: a heading, a
|
|
65
|
+
* frontmatter fence, or a bare tag line is document furniture. A typed chat turn
|
|
66
|
+
* rarely carries one; a pasted document essentially always does.
|
|
67
|
+
*/
|
|
68
|
+
const DOCUMENT_HEAD = /^\s*(#{1,6}\s|-{3,}\s*$|={3,}\s*$|<[A-Za-z][\w-]*>\s*$)/;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The part of a prompt a human plausibly typed: everything before the first
|
|
72
|
+
* pasted document begins.
|
|
73
|
+
*
|
|
74
|
+
* Callers pass text that has already had output-shaped pastes removed. Returning
|
|
75
|
+
* the LEAD rather than a filtered whole is deliberate — a paste can be
|
|
76
|
+
* interleaved, and reassembling the human fragments would invent a text nobody
|
|
77
|
+
* wrote. The lead is the one span whose authorship is not in question.
|
|
78
|
+
*/
|
|
79
|
+
export function humanAuthoredLead(text: string): string {
|
|
80
|
+
const kept: string[] = [];
|
|
81
|
+
for (const line of text.split("\n")) {
|
|
82
|
+
if (DOCUMENT_HEAD.test(line)) {
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
kept.push(line);
|
|
86
|
+
}
|
|
87
|
+
return kept.join("\n");
|
|
88
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `review_skipped` telemetry line builder (pure, no I/O).
|
|
3
|
+
*
|
|
4
|
+
* Purpose-built, MINIMAL extension of the audit-log-v1 envelope
|
|
5
|
+
* (`docs/contracts/audit-log-v1.md`) for the F4-lite end-review-nudge concern
|
|
6
|
+
* (road-to-orchestrator-discipline-carriers Phase 5). It reuses the same
|
|
7
|
+
* envelope shape and the same file location
|
|
8
|
+
* (`agents/runtime/state/audit/<YYYY-MM>.jsonl`) as
|
|
9
|
+
* `_lib/orchestration_record.ts`'s `orchestration` object, but does not
|
|
10
|
+
* import that module: `buildOrchestrationLine` requires `spawn_count`, which
|
|
11
|
+
* has no meaning for a review-skip event, and its enum-constant tables are
|
|
12
|
+
* private to that file. Adding a second additive top-level object
|
|
13
|
+
* (`review_skipped`) is exactly the pattern the contract already uses for
|
|
14
|
+
* `orchestration` — "Unknown trailing fields are forward-compat extensions;
|
|
15
|
+
* readers MUST NOT raise on them."
|
|
16
|
+
*
|
|
17
|
+
* PRIVACY BY CONSTRUCTION: the `review_skipped` object carries `diff_lines`
|
|
18
|
+
* (a non-negative integer count) and `mutation_measure` (a closed 2-value
|
|
19
|
+
* enum — never free text). There is no field capable of holding a file
|
|
20
|
+
* path, a prompt, or any free-form content — never add one (mirrors
|
|
21
|
+
* `domain-safety-pii` § Surface 2 / the `artifact-engagement` event shape).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether `diff_lines` is an exact sum or a guaranteed-over-threshold
|
|
26
|
+
* approximation (`end_review_nudge_hook.ts`'s `UNTRACKED_FILE_CAP` path —
|
|
27
|
+
* past that many untracked non-doc files, the hook refuses to spawn one
|
|
28
|
+
* `git diff --no-index` subprocess per file and reports a value merely
|
|
29
|
+
* guaranteed to exceed the fire threshold, not an exact count). Mixing
|
|
30
|
+
* exact and approximated counts in this telemetry stream, unlabeled, would
|
|
31
|
+
* corrupt any future blocking-threshold calibration that reads it — hence
|
|
32
|
+
* this field, rather than folding the approximation silently into
|
|
33
|
+
* `diff_lines`.
|
|
34
|
+
*/
|
|
35
|
+
export type MutationMeasure = 'exact' | 'capped_approximation';
|
|
36
|
+
|
|
37
|
+
export interface ReviewSkippedInput {
|
|
38
|
+
/** Non-doc tracked-file mutation size that triggered the nudge (count only). */
|
|
39
|
+
diff_lines: number;
|
|
40
|
+
/** Whether `diff_lines` is exact or a capped approximation — see above. */
|
|
41
|
+
mutation_measure: MutationMeasure;
|
|
42
|
+
/** ISO-8601 UTC timestamp; caller supplies (keeps this fn pure/deterministic). */
|
|
43
|
+
ts: string;
|
|
44
|
+
/** Stable id (ULID, UUID, or content hash); caller supplies. */
|
|
45
|
+
id: string;
|
|
46
|
+
work_id?: string | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface BuiltReviewSkippedLine {
|
|
50
|
+
line: Record<string, unknown> | null;
|
|
51
|
+
errors: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isNonNegInt(n: unknown): n is number {
|
|
55
|
+
return typeof n === 'number' && Number.isFinite(n) && Number.isInteger(n) && n >= 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* High-risk threshold for the line's own `risk_class` (audit-log-v1 field,
|
|
60
|
+
* not the hook's fire threshold): a skipped review over a large diff carries
|
|
61
|
+
* more residual risk than one just past the fire threshold.
|
|
62
|
+
*/
|
|
63
|
+
const HIGH_RISK_DIFF_LINES = 200;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build + validate ONE audit-log-v1 line carrying a `review_skipped` object.
|
|
67
|
+
* Returns `{line, errors}`; when `errors` is non-empty, `line` is null and
|
|
68
|
+
* the caller must NOT write.
|
|
69
|
+
*/
|
|
70
|
+
export function buildReviewSkippedLine(input: ReviewSkippedInput): BuiltReviewSkippedLine {
|
|
71
|
+
const errors: string[] = [];
|
|
72
|
+
if (!isNonNegInt(input.diff_lines)) {
|
|
73
|
+
errors.push('diff_lines must be a non-negative integer');
|
|
74
|
+
}
|
|
75
|
+
if (input.mutation_measure !== 'exact' && input.mutation_measure !== 'capped_approximation') {
|
|
76
|
+
errors.push('mutation_measure must be "exact" or "capped_approximation"');
|
|
77
|
+
}
|
|
78
|
+
if (!input.ts) errors.push('ts (ISO-8601 UTC) is required');
|
|
79
|
+
if (!input.id) errors.push('id (ULID, UUID, or content hash) is required');
|
|
80
|
+
|
|
81
|
+
if (errors.length) return { line: null, errors };
|
|
82
|
+
|
|
83
|
+
const line: Record<string, unknown> = {
|
|
84
|
+
schema_version: 1,
|
|
85
|
+
id: input.id,
|
|
86
|
+
ts: input.ts,
|
|
87
|
+
work_id: input.work_id ?? `review-skipped-${input.ts}`,
|
|
88
|
+
phase: 'report',
|
|
89
|
+
outcome: 'skipped',
|
|
90
|
+
confidence_band: 'medium',
|
|
91
|
+
risk_class: input.diff_lines >= HIGH_RISK_DIFF_LINES ? 'high' : 'medium',
|
|
92
|
+
memory: { asks: 0, hits: 0 },
|
|
93
|
+
verify: { claims: 0, first_try_passes: 0 },
|
|
94
|
+
rules_applied: ['delegation-policy'],
|
|
95
|
+
persona: null,
|
|
96
|
+
input_kind: 'prompt',
|
|
97
|
+
type: 'note',
|
|
98
|
+
review_skipped: { diff_lines: input.diff_lines, mutation_measure: input.mutation_measure },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return { line, errors: [] };
|
|
102
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule-layer overlap detection — is the corpus about to be delivered twice?
|
|
3
|
+
*
|
|
4
|
+
* Claude Code loads `~/.claude/rules/` and `<project>/.claude/rules/` **both**,
|
|
5
|
+
* user layer first, with no dedup between them: a rule present in both is in
|
|
6
|
+
* standing context twice. Measured on a maintainer machine 2026-08-08 — 112
|
|
7
|
+
* global entries + 92 project entries, 91 shared basenames, 176,354 exact-BPE
|
|
8
|
+
* tokens of which 74,137 (42%) redundant. Evidence:
|
|
9
|
+
* `agents/evidence/analysis/standing-rule-delivery-topologies.md`; the host's
|
|
10
|
+
* loading contract: `agents/evidence/analysis/claude-code-rules-dir-contract.md`.
|
|
11
|
+
*
|
|
12
|
+
* This module answers three questions and nothing else:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Do two layers overlap, and by how much?** ({@link compareLayers})
|
|
15
|
+
* 2. **Is the overlap a pure duplicate, or a content skew?** — the difference
|
|
16
|
+
* decides whether suppression is enough or a refresh is also needed.
|
|
17
|
+
* 3. **How is a layer suppressed without deleting it?** — the host's own
|
|
18
|
+
* `claudeMdExcludes` settings key ({@link claudeMdExcludesGlob},
|
|
19
|
+
* {@link mergeClaudeMdExcludes}).
|
|
20
|
+
*
|
|
21
|
+
* The comparison half is pure so it is testable from fixtures; only
|
|
22
|
+
* {@link readRuleLayer} touches the filesystem, and nothing here ever writes or
|
|
23
|
+
* unlinks. Deleting a user's `~/.claude/rules/` would be a Hard-Floor action
|
|
24
|
+
* (`non-destructive-by-default`), so no function in this module can reach one.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Frontmatter keys the installer stamps onto its OWN copies, and which therefore
|
|
32
|
+
* differ between a global (installed) copy and a project (projected) copy of the
|
|
33
|
+
* same rule without either being stale.
|
|
34
|
+
*
|
|
35
|
+
* Kept in lockstep with `install.ts`'s `_set_key(fm_lines, 'package', …)` /
|
|
36
|
+
* `_set_key(fm_lines, 'source_path', …)` pair. Adding a third stamped key there
|
|
37
|
+
* without adding it here makes every rule read as divergent.
|
|
38
|
+
*/
|
|
39
|
+
export const INSTALLER_PROVENANCE_KEYS: readonly string[] = ['package', 'source_path'];
|
|
40
|
+
|
|
41
|
+
/** One rule layer: its directory and `basename → text` for every `.md` in it. */
|
|
42
|
+
export interface RuleLayer {
|
|
43
|
+
readonly dir: string;
|
|
44
|
+
readonly files: ReadonlyMap<string, string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What {@link compareLayers} found. Counts are chars; token conversion is the caller's. */
|
|
48
|
+
export interface OverlapReport {
|
|
49
|
+
readonly schema_version: 1;
|
|
50
|
+
/** Basenames present in both layers. */
|
|
51
|
+
readonly overlap: readonly string[];
|
|
52
|
+
/** Of {@link overlap}: bodies equal once the provenance keys are stripped — pure duplicates. */
|
|
53
|
+
readonly duplicate: readonly string[];
|
|
54
|
+
/** Of {@link overlap}: bodies genuinely differ — a content skew. Suppression alone is NOT enough. */
|
|
55
|
+
readonly divergent: readonly string[];
|
|
56
|
+
/** Basenames only in the first (global) layer. */
|
|
57
|
+
readonly global_only: readonly string[];
|
|
58
|
+
/** Basenames only in the second (project) layer. */
|
|
59
|
+
readonly project_only: readonly string[];
|
|
60
|
+
/** Chars the SECOND layer contributes redundantly — what suppressing it recovers. */
|
|
61
|
+
readonly redundant_chars: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The three answers a user may give when an overlap is detected. */
|
|
65
|
+
export type LayerChoice = 'global' | 'project' | 'both-acknowledged';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Strip the installer's provenance keys from a rule's frontmatter block.
|
|
69
|
+
*
|
|
70
|
+
* Only lines inside the leading `---` … `---` fence are considered, and only
|
|
71
|
+
* those whose key is in {@link INSTALLER_PROVENANCE_KEYS}. A body line that
|
|
72
|
+
* happens to start with `package:` survives — stripping it would make two
|
|
73
|
+
* genuinely different rules compare equal, which is the failure direction that
|
|
74
|
+
* matters here.
|
|
75
|
+
*
|
|
76
|
+
* A file with no frontmatter fence is returned unchanged.
|
|
77
|
+
*/
|
|
78
|
+
export function stripProvenance(text: string): string {
|
|
79
|
+
const lines = text.split('\n');
|
|
80
|
+
if (lines[0]?.trim() !== '---') return text;
|
|
81
|
+
let close = -1;
|
|
82
|
+
for (let i = 1; i < lines.length; i++) {
|
|
83
|
+
if (lines[i]?.trim() === '---') {
|
|
84
|
+
close = i;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (close === -1) return text;
|
|
89
|
+
const kept: string[] = [];
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
if (i > 0 && i < close) {
|
|
92
|
+
const key = /^([A-Za-z_][A-Za-z0-9_-]*):/.exec(lines[i] ?? '')?.[1];
|
|
93
|
+
if (key !== undefined && INSTALLER_PROVENANCE_KEYS.includes(key)) continue;
|
|
94
|
+
}
|
|
95
|
+
kept.push(lines[i] ?? '');
|
|
96
|
+
}
|
|
97
|
+
return kept.join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Sorted basenames of `a` that are absent from `b`. */
|
|
101
|
+
function only_in(a: ReadonlyMap<string, string>, b: ReadonlyMap<string, string>): string[] {
|
|
102
|
+
return [...a.keys()].filter((k) => !b.has(k)).sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Compare two rule layers. Pure — no filesystem, no clock, no environment.
|
|
107
|
+
*
|
|
108
|
+
* `global_layer` is the user-level layer and `project_layer` the project one,
|
|
109
|
+
* because {@link OverlapReport.redundant_chars} is measured on the SECOND
|
|
110
|
+
* argument: it answers "what does suppressing the project layer recover", which
|
|
111
|
+
* is the direction the installer offers first (the project layer is the one it
|
|
112
|
+
* is usually about to write).
|
|
113
|
+
*/
|
|
114
|
+
export function compareLayers(
|
|
115
|
+
global_layer: ReadonlyMap<string, string>,
|
|
116
|
+
project_layer: ReadonlyMap<string, string>,
|
|
117
|
+
): OverlapReport {
|
|
118
|
+
const overlap = [...global_layer.keys()].filter((k) => project_layer.has(k)).sort();
|
|
119
|
+
const duplicate: string[] = [];
|
|
120
|
+
const divergent: string[] = [];
|
|
121
|
+
let redundant_chars = 0;
|
|
122
|
+
for (const name of overlap) {
|
|
123
|
+
const g = stripProvenance(global_layer.get(name) ?? '');
|
|
124
|
+
const p = stripProvenance(project_layer.get(name) ?? '');
|
|
125
|
+
if (g === p) {
|
|
126
|
+
duplicate.push(name);
|
|
127
|
+
redundant_chars += (project_layer.get(name) ?? '').length;
|
|
128
|
+
} else {
|
|
129
|
+
divergent.push(name);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
schema_version: 1,
|
|
134
|
+
overlap,
|
|
135
|
+
duplicate,
|
|
136
|
+
divergent,
|
|
137
|
+
global_only: only_in(global_layer, project_layer),
|
|
138
|
+
project_only: only_in(project_layer, global_layer),
|
|
139
|
+
redundant_chars,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Read every `.md` in `dir` into a {@link RuleLayer}, or `null` when the
|
|
145
|
+
* directory does not exist.
|
|
146
|
+
*
|
|
147
|
+
* Symlinks are followed, because the project layer is a symlink projection into
|
|
148
|
+
* `dist/agent-src/rules/` and its CONTENT is what lands in context. An entry
|
|
149
|
+
* whose target is unreadable (a dangling symlink) is skipped rather than
|
|
150
|
+
* throwing — a broken link contributes no bytes to the session either.
|
|
151
|
+
*/
|
|
152
|
+
export function readRuleLayer(dir: string): RuleLayer | null {
|
|
153
|
+
let names: string[];
|
|
154
|
+
try {
|
|
155
|
+
names = fs.readdirSync(dir);
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
const files = new Map<string, string>();
|
|
160
|
+
for (const name of names.filter((n) => n.endsWith('.md')).sort()) {
|
|
161
|
+
try {
|
|
162
|
+
files.set(name, fs.readFileSync(path.join(dir, name), 'utf-8'));
|
|
163
|
+
} catch {
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { dir, files };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The `claudeMdExcludes` glob that suppresses one rules directory.
|
|
172
|
+
*
|
|
173
|
+
* The host matches this key against ABSOLUTE paths, so the directory is
|
|
174
|
+
* resolved before the `/**` suffix is appended. Documented example from the
|
|
175
|
+
* host's own docs: `"/home/user/monorepo/other-team/.claude/rules/**"`.
|
|
176
|
+
*/
|
|
177
|
+
export function claudeMdExcludesGlob(dir: string): string {
|
|
178
|
+
return `${path.resolve(dir).replace(/\/+$/, '')}/**`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Append `entry` to an existing `claudeMdExcludes` value, preserving everything
|
|
183
|
+
* already there.
|
|
184
|
+
*
|
|
185
|
+
* `claudeMdExcludes` arrays MERGE across settings layers, so an install that
|
|
186
|
+
* replaced the array would silently drop another layer's exclusions — a
|
|
187
|
+
* different defect from the one being fixed. Non-string members of a
|
|
188
|
+
* hand-edited array are preserved in order; an absent or non-array value starts
|
|
189
|
+
* a fresh single-entry array. Already present → returned unchanged, so the
|
|
190
|
+
* operation is idempotent across re-installs.
|
|
191
|
+
*/
|
|
192
|
+
export function mergeClaudeMdExcludes(existing: unknown, entry: string): unknown[] {
|
|
193
|
+
if (!Array.isArray(existing)) return [entry];
|
|
194
|
+
if (existing.includes(entry)) return [...existing];
|
|
195
|
+
return [...existing, entry];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** What the installer should do, given an overlap report and the user's choice. */
|
|
199
|
+
export interface LayerAction {
|
|
200
|
+
/** Which layer this install writes. */
|
|
201
|
+
readonly write: 'global' | 'project' | 'both';
|
|
202
|
+
/** Directory to suppress via `claudeMdExcludes`, or `null` when nothing is suppressed. */
|
|
203
|
+
readonly suppress_dir: string | null;
|
|
204
|
+
/** True when the overlap contains divergent bodies — suppression alone loses obligations. */
|
|
205
|
+
readonly refresh_required: boolean;
|
|
206
|
+
/** One-line human summary; the installer prints it verbatim. */
|
|
207
|
+
readonly note: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Turn a report plus a {@link LayerChoice} into the action to take. Pure.
|
|
212
|
+
*
|
|
213
|
+
* `both-acknowledged` writes both and suppresses nothing — a deliberate
|
|
214
|
+
* two-layer setup is legitimate and the flag exists so the installer does not
|
|
215
|
+
* have to guess. The other two choices name the layer to KEEP and suppress the
|
|
216
|
+
* other; suppression never deletes.
|
|
217
|
+
*
|
|
218
|
+
* `refresh_required` fires on any divergent body: suppressing a layer whose
|
|
219
|
+
* copies differ would drop whichever obligations only the suppressed copy
|
|
220
|
+
* carried, so the caller must refresh before suppressing. This is the guard
|
|
221
|
+
* against the version-skew case, which the 2026-08-08 measurement found absent
|
|
222
|
+
* but nothing prevents later.
|
|
223
|
+
*/
|
|
224
|
+
export function decideLayerAction(
|
|
225
|
+
report: OverlapReport,
|
|
226
|
+
choice: LayerChoice,
|
|
227
|
+
global_dir: string,
|
|
228
|
+
project_dir: string,
|
|
229
|
+
): LayerAction {
|
|
230
|
+
const refresh_required = report.divergent.length > 0;
|
|
231
|
+
const skew = refresh_required
|
|
232
|
+
? ` ${report.divergent.length} shared rule(s) differ in body — refresh before suppressing, or obligations only the suppressed copy carries are lost.`
|
|
233
|
+
: '';
|
|
234
|
+
if (choice === 'both-acknowledged') {
|
|
235
|
+
return {
|
|
236
|
+
write: 'both',
|
|
237
|
+
suppress_dir: null,
|
|
238
|
+
refresh_required,
|
|
239
|
+
note:
|
|
240
|
+
`Keeping both rule layers by request: ${report.overlap.length} shared rule(s), `
|
|
241
|
+
+ `${report.redundant_chars} chars delivered twice per session. Nothing suppressed, nothing deleted.${skew}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
const keep = choice === 'global' ? global_dir : project_dir;
|
|
245
|
+
const drop = choice === 'global' ? project_dir : global_dir;
|
|
246
|
+
return {
|
|
247
|
+
write: choice,
|
|
248
|
+
suppress_dir: drop,
|
|
249
|
+
refresh_required,
|
|
250
|
+
note:
|
|
251
|
+
`Keeping ${choice} rule layer (${keep}); suppressing ${drop} via claudeMdExcludes — `
|
|
252
|
+
+ `recovers ${report.redundant_chars} chars per session. No file is deleted or rewritten.${skew}`,
|
|
253
|
+
};
|
|
254
|
+
}
|