@event4u/agent-config 9.26.0 → 9.27.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 +57 -229
- package/dist/agent-src/commands/analyze/conformance.md +19 -0
- package/dist/agent-src/commands/roadmap/next.md +65 -3
- package/dist/agent-src/rules/active-remediation.md +2 -0
- package/dist/agent-src/rules/analysis-skill-routing.md +2 -0
- package/dist/agent-src/rules/architecture.md +2 -0
- package/dist/agent-src/rules/artifact-drafting-protocol.md +2 -0
- package/dist/agent-src/rules/artifact-engagement-recording.md +2 -0
- package/dist/agent-src/rules/augment-edit-discipline.md +2 -0
- package/dist/agent-src/rules/autonomous-execution.md +2 -0
- package/dist/agent-src/rules/brand-consistency.md +2 -0
- package/dist/agent-src/rules/brand-source-of-truth.md +2 -0
- package/dist/agent-src/rules/broken-access-control.md +2 -0
- package/dist/agent-src/rules/cli-output-handling.md +2 -0
- package/dist/agent-src/rules/code-comment-discipline.md +2 -0
- package/dist/agent-src/rules/code-provenance.md +2 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +2 -0
- package/dist/agent-src/rules/commit-conventions.md +2 -0
- package/dist/agent-src/rules/communication-through-line.md +2 -0
- package/dist/agent-src/rules/content-quoting-floor.md +2 -0
- package/dist/agent-src/rules/context-hygiene.md +10 -0
- package/dist/agent-src/rules/copilot-routing.md +2 -0
- package/dist/agent-src/rules/cross-source-consistency.md +2 -0
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/delegation-policy.md +2 -0
- package/dist/agent-src/rules/design-fidelity.md +2 -0
- package/dist/agent-src/rules/design-review-after-ui-write.md +4 -0
- package/dist/agent-src/rules/devcontainer-routing.md +2 -0
- package/dist/agent-src/rules/doc-screenshot-hygiene.md +2 -0
- package/dist/agent-src/rules/docker-commands.md +2 -0
- package/dist/agent-src/rules/domain-adoption-policy.md +2 -0
- package/dist/agent-src/rules/domain-safety-disclaimer.md +2 -0
- package/dist/agent-src/rules/domain-safety-pii.md +2 -0
- package/dist/agent-src/rules/domain-safety-retention.md +2 -0
- package/dist/agent-src/rules/downstream-changes.md +2 -0
- package/dist/agent-src/rules/engineering-safety-floor.md +2 -0
- package/dist/agent-src/rules/evaluator-independence.md +13 -3
- package/dist/agent-src/rules/external-code-graph-interop.md +2 -0
- package/dist/agent-src/rules/external-reference-deep-dive.md +2 -0
- package/dist/agent-src/rules/fast-path-marker-visibility.md +2 -0
- package/dist/agent-src/rules/finance-safety-floor.md +2 -0
- package/dist/agent-src/rules/framework-neutrality-in-generic-skills.md +2 -0
- package/dist/agent-src/rules/git-history-discipline.md +3 -1
- package/dist/agent-src/rules/guidelines.md +2 -0
- package/dist/agent-src/rules/history-discipline.md +2 -0
- package/dist/agent-src/rules/icon-consistency.md +2 -0
- package/dist/agent-src/rules/image-likeness-and-rights.md +2 -0
- package/dist/agent-src/rules/improve-before-implement.md +2 -0
- package/dist/agent-src/rules/invite-challenge.md +2 -0
- package/dist/agent-src/rules/laravel-routing.md +2 -0
- package/dist/agent-src/rules/laravel-translations.md +2 -0
- package/dist/agent-src/rules/legal-safety-floor.md +2 -0
- package/dist/agent-src/rules/lethal-trifecta-guard.md +2 -0
- package/dist/agent-src/rules/linked-projects-onboarding-gate.md +2 -0
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +2 -0
- package/dist/agent-src/rules/markdown-safe-codeblocks.md +2 -0
- package/dist/agent-src/rules/media-governance-routing.md +2 -0
- package/dist/agent-src/rules/media-sync-ground-truth.md +2 -0
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -0
- package/dist/agent-src/rules/missing-tool-handling.md +2 -0
- package/dist/agent-src/rules/model-recommendation.md +2 -0
- package/dist/agent-src/rules/no-attribution-footers.md +2 -0
- package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +2 -0
- package/dist/agent-src/rules/no-pr-progress-comments.md +2 -0
- package/dist/agent-src/rules/no-roadmap-references.md +2 -0
- package/dist/agent-src/rules/notes-first-reasoning.md +2 -0
- package/dist/agent-src/rules/onboarding-gate.md +2 -0
- package/dist/agent-src/rules/output-discipline.md +2 -0
- package/dist/agent-src/rules/package-ci-checks.md +2 -0
- package/dist/agent-src/rules/persona-governance.md +2 -0
- package/dist/agent-src/rules/php-coding.md +2 -0
- package/dist/agent-src/rules/prefer-enums-over-literals.md +2 -0
- package/dist/agent-src/rules/preservation-guard.md +2 -0
- package/dist/agent-src/rules/provider-lifecycle-discipline.md +2 -0
- package/dist/agent-src/rules/question-not-instruction.md +2 -0
- package/dist/agent-src/rules/reviewer-awareness.md +2 -0
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +2 -0
- package/dist/agent-src/rules/roadmap-progress-sync.md +2 -0
- package/dist/agent-src/rules/role-mode-adherence.md +2 -0
- package/dist/agent-src/rules/rule-type-governance.md +2 -0
- package/dist/agent-src/rules/runtime-safety.md +2 -0
- package/dist/agent-src/rules/scale-discipline.md +2 -0
- package/dist/agent-src/rules/secret-vcs-guard.md +2 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +2 -0
- package/dist/agent-src/rules/senior-engineering-discipline.md +2 -0
- package/dist/agent-src/rules/session-canary.md +61 -19
- package/dist/agent-src/rules/settings-ask-protocol.md +4 -0
- package/dist/agent-src/rules/size-enforcement.md +2 -0
- package/dist/agent-src/rules/skill-improvement-trigger.md +2 -0
- package/dist/agent-src/rules/skill-quality.md +2 -0
- package/dist/agent-src/rules/slash-command-routing-policy.md +2 -0
- package/dist/agent-src/rules/source-confidentiality.md +2 -0
- package/dist/agent-src/rules/source-discovery-gate.md +2 -0
- package/dist/agent-src/rules/source-of-truth.md +2 -0
- package/dist/agent-src/rules/spreadsheet-source-quality.md +2 -0
- package/dist/agent-src/rules/strategy-safety-floor.md +2 -0
- package/dist/agent-src/rules/symfony-routing.md +2 -0
- package/dist/agent-src/rules/think-before-action.md +2 -0
- package/dist/agent-src/rules/token-budget-discipline.md +2 -0
- package/dist/agent-src/rules/token-efficiency.md +2 -0
- package/dist/agent-src/rules/token-optimizer-maintenance.md +2 -0
- package/dist/agent-src/rules/tool-safety.md +2 -0
- package/dist/agent-src/rules/ui-audit-gate.md +4 -0
- package/dist/agent-src/rules/untrusted-input-defense.md +2 -0
- package/dist/agent-src/rules/upstream-proposal.md +2 -0
- package/dist/agent-src/rules/user-interaction.md +2 -0
- package/dist/agent-src/rules/user-interrupt-priority.md +2 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-SJQMJI7M.js → chunk-3KJIMFTB.js} +1 -1
- package/dist/cli-delegate/{chunk-BSLUHV2F.js → chunk-BCPIOAGV.js} +148 -133
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/cli-delegate/cmd_export.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 +109 -109
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +796 -289
- package/dist/install/install.mjs +1 -1
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +571 -417
- package/dist/scripts/condense.js +33 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/io/yamlIO.js +85 -0
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/settings.js +1 -12
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/wizard.js +52 -2
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsCarveOut.js +118 -0
- package/dist/shared/settingsCarveOut.js.map +1 -0
- package/dist/shared/settingsSurface.js +7 -1
- package/dist/shared/settingsSurface.js.map +1 -1
- package/dist/ui/assets/index-DmR0mbk4.js.map +1 -1
- package/package.json +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/scripts/_cli/cmd_doctor.ts +6 -21
- package/src/scripts/_dispatch.bash +18 -0
- package/src/scripts/_lib/git_common_dir.ts +128 -0
- package/src/scripts/_lib/obligation_frequency.ts +391 -0
- package/src/scripts/_lib/session_register.ts +361 -0
- package/src/scripts/check_enforcement_coverage.ts +192 -7
- package/src/scripts/check_rule_projection_integrity.ts +364 -0
- package/src/scripts/condense.ts +36 -2
- package/src/scripts/conformance_scan.ts +35 -0
- package/src/scripts/generate_settings_reference.ts +232 -0
- package/src/scripts/hook_manifest.yaml +68 -27
- package/src/scripts/hooks/block_no_verify.ts +56 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +67 -1
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/language_mirror_hook.ts +56 -6
- package/src/scripts/orchestration_backfill.ts +354 -0
- package/src/scripts/release.ts +142 -36
- package/src/scripts/schemas/rule.schema.json +5 -0
- package/src/scripts/session_canary_hook.ts +35 -3
- package/src/scripts/session_register_hook.ts +288 -0
- package/src/scripts/sessions_cli.ts +140 -0
- package/src/scripts/validate_frontmatter.ts +68 -0
- package/src/server/io/yamlIO.ts +87 -0
- package/src/shared/settingsCarveOut.ts +143 -0
- package/src/shared/settingsSurface.ts +7 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@event4u/agent-config",
|
|
3
3
|
"mcpName": "io.github.event4u-app/agent-config",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.27.0",
|
|
5
5
|
"description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
@@ -58,11 +58,11 @@
|
|
|
58
58
|
"deterministic": false
|
|
59
59
|
},
|
|
60
60
|
"cli_help_command_count": {
|
|
61
|
-
"max":
|
|
62
|
-
"last_measured":
|
|
63
|
-
"last_measured_at": "2026-08-
|
|
61
|
+
"max": 88,
|
|
62
|
+
"last_measured": 88,
|
|
63
|
+
"last_measured_at": "2026-08-07",
|
|
64
64
|
"method": "registry enumeration — count of `{ name: '...', disposition` entries in src/cli/registry.ts (NOT --help prose parsing; methods historically disagreed 74 vs 76 vs 79)",
|
|
65
|
-
"baseline_note": "80 → 81 on 2026-08-03 (release 9.15.0 gate). This RECORDS growth that already shipped through its own reviewed PR; it does not authorize new surface. The +1 is f02db54c2 (fix(cli): register routing:doctor in the CLI registry) — the command existed, only its registry entry was missing, and the registering PR did not move this budget. The gate did its job again: breach WARNS on main and BLOCKS on release, which is the conscious-decision point rather than a silently raised cap. Re-measured 2026-08-03 via the pinned method: 81. Headroom stays zero by design — the next registry addition must move this number in its own PR. 81 → 82 on 2026-08-04: the `handoff` subcommand (road-to-agent-handoff-resume Phase 4) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 82. 82 → 84 on 2026-08-04: the `route:explain` + `route:audit` verbs (routing-correctness validation surface) register in the same PR that adds the surface. Re-measured via the pinned method: 84. 84 → 85 on 2026-08-05: the `settings:set` verb (road-to-zero-ceremony-settings Phase 2 — the one agent-reachable settings writer) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 85. 85 → 86 on 2026-08-06: the `conformance:behavior` verb (road-to-agent-behavior-conformance Phase 5 — replays the local transcript store through the four mechanised conformance checks) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 86."
|
|
65
|
+
"baseline_note": "80 → 81 on 2026-08-03 (release 9.15.0 gate). This RECORDS growth that already shipped through its own reviewed PR; it does not authorize new surface. The +1 is f02db54c2 (fix(cli): register routing:doctor in the CLI registry) — the command existed, only its registry entry was missing, and the registering PR did not move this budget. The gate did its job again: breach WARNS on main and BLOCKS on release, which is the conscious-decision point rather than a silently raised cap. Re-measured 2026-08-03 via the pinned method: 81. Headroom stays zero by design — the next registry addition must move this number in its own PR. 81 → 82 on 2026-08-04: the `handoff` subcommand (road-to-agent-handoff-resume Phase 4) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 82. 82 → 84 on 2026-08-04: the `route:explain` + `route:audit` verbs (routing-correctness validation surface) register in the same PR that adds the surface. Re-measured via the pinned method: 84. 84 → 85 on 2026-08-05: the `settings:set` verb (road-to-zero-ceremony-settings Phase 2 — the one agent-reachable settings writer) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 85. 85 → 86 on 2026-08-06: the `conformance:behavior` verb (road-to-agent-behavior-conformance Phase 5 — replays the local transcript store through the four mechanised conformance checks) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 86. 86 → 88 on 2026-08-07: the `sessions:list` + `sessions:claim` verbs (road-to-parallel-session-coordination — the shell surface of the shared session register, needed because /roadmap:next is command markdown and can only reach the register through a shell call) register in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 88."
|
|
66
66
|
},
|
|
67
67
|
"mcp_public_tool_count": {
|
|
68
68
|
"max": 19,
|
|
@@ -90,6 +90,7 @@ import * as installed_tools from '../_lib/installed_tools.js';
|
|
|
90
90
|
import * as user_global_paths from '../_lib/user_global_paths.js';
|
|
91
91
|
import * as global_deploy_inventory from '../_lib/global_deploy_inventory.js';
|
|
92
92
|
import { censusDuplicateScope } from '../_lib/duplicate_scope_census.js';
|
|
93
|
+
import { git_common_dir } from '../_lib/git_common_dir.js';
|
|
93
94
|
import * as sync_gitattributes from '../sync_gitattributes.js';
|
|
94
95
|
import {
|
|
95
96
|
PROJECT_ROOT_ENV,
|
|
@@ -897,27 +898,11 @@ const _PLACEHOLDER_EMAIL_RE = /^(t@t\.t|.*@example\.(com|org)|.*@localhost)$/i;
|
|
|
897
898
|
const _PLACEHOLDER_NAME_RE = /^(t|test|user|nobody|x)$/i;
|
|
898
899
|
|
|
899
900
|
export function _git_config_path(project_root: string): string | null {
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
}
|
|
906
|
-
// Worktree: `.git` is a file "gitdir: <path>".
|
|
907
|
-
const m = readText(gd).match(/^gitdir:\s*(.+)\s*$/m);
|
|
908
|
-
if (m === null) {
|
|
909
|
-
return null;
|
|
910
|
-
}
|
|
911
|
-
const gdPath = path.resolve(project_root, m[1]!.trim());
|
|
912
|
-
const commondirFile = path.join(gdPath, 'commondir');
|
|
913
|
-
if (pathExists(commondirFile)) {
|
|
914
|
-
const common = path.resolve(gdPath, readText(commondirFile).trim());
|
|
915
|
-
return path.join(common, 'config');
|
|
916
|
-
}
|
|
917
|
-
return path.join(gdPath, 'config');
|
|
918
|
-
} catch {
|
|
919
|
-
return null;
|
|
920
|
-
}
|
|
901
|
+
// The `.git`-file / `commondir` walk this used to inline now lives in
|
|
902
|
+
// `_lib/git_common_dir.ts`, so the session register and this check cannot
|
|
903
|
+
// drift into two different answers to "where is the common dir".
|
|
904
|
+
const common = git_common_dir(project_root);
|
|
905
|
+
return common === null ? null : path.join(common, 'config');
|
|
921
906
|
}
|
|
922
907
|
|
|
923
908
|
function _parse_git_user(configText: string): { name: string | null; email: string | null } {
|
|
@@ -177,6 +177,10 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
|
|
|
177
177
|
projection (from the pinned token baseline). Flags: --format
|
|
178
178
|
code-graph Deterministic code-graph engine (ADR-124, Class A).
|
|
179
179
|
Usage: code-graph build|validate|detect|query|explain|affected|path [options]
|
|
180
|
+
sessions:list List live agent sessions on this repository (shared
|
|
181
|
+
session register). Flags: --json
|
|
182
|
+
sessions:claim Claim a roadmap for this session so other sessions skip
|
|
183
|
+
it; --release clears the claim
|
|
180
184
|
roadmap:progress Regenerate agents/roadmaps-progress.md from open roadmaps
|
|
181
185
|
roadmap:progress-check Fail if agents/roadmaps-progress.md is stale (for CI)
|
|
182
186
|
roadmap:archive Archive completed roadmaps (branch-touched by default;
|
|
@@ -579,6 +583,18 @@ cmd_mcp_run() {
|
|
|
579
583
|
exec_ts "$server_main" "$@"
|
|
580
584
|
}
|
|
581
585
|
|
|
586
|
+
# Shared session register (road-to-parallel-session-coordination). Resolved from
|
|
587
|
+
# PACKAGE_ROOT, not the consumer projection: the CLI imports the register library
|
|
588
|
+
# under src/scripts/_lib/, which is package-internal and never shipped into
|
|
589
|
+
# dist/agent-src/scripts/. Same shape as cmd_settings_set.
|
|
590
|
+
cmd_sessions_list() {
|
|
591
|
+
exec_ts "$PACKAGE_ROOT/src/scripts/sessions_cli.ts" list "$@"
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
cmd_sessions_claim() {
|
|
595
|
+
exec_ts "$PACKAGE_ROOT/src/scripts/sessions_cli.ts" claim "$@"
|
|
596
|
+
}
|
|
597
|
+
|
|
582
598
|
cmd_roadmap_progress() {
|
|
583
599
|
local script
|
|
584
600
|
script="$(resolve_script "dist/agent-src/scripts/update_roadmap_progress.ts" ".augment/scripts/update_roadmap_progress.ts")"
|
|
@@ -1252,6 +1268,8 @@ main() {
|
|
|
1252
1268
|
benchmark) cmd_benchmark "$@" ;;
|
|
1253
1269
|
code-graph) cmd_code_graph "$@" ;;
|
|
1254
1270
|
conformance:behavior) cmd_conformance_behavior "$@" ;;
|
|
1271
|
+
sessions:list) cmd_sessions_list "$@" ;;
|
|
1272
|
+
sessions:claim) cmd_sessions_claim "$@" ;;
|
|
1255
1273
|
roadmap:progress) cmd_roadmap_progress "$@" ;;
|
|
1256
1274
|
roadmap:progress-check) cmd_roadmap_progress_check "$@" ;;
|
|
1257
1275
|
roadmap:archive) cmd_roadmap_archive "$@" ;;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve a repository's **common git directory** — the one directory every
|
|
3
|
+
* worktree of a repo shares.
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `_cli/cmd_doctor.ts::_git_config_path`, which has resolved the
|
|
6
|
+
* `commondir` file this way since the git-identity check shipped. It is
|
|
7
|
+
* extracted rather than re-implemented on purpose: a repo with two different
|
|
8
|
+
* answers to "where is the common dir" is a bug waiting for a symlinked parent,
|
|
9
|
+
* and the worktree-cleanup work already hit that class once.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this reads files instead of shelling out to git
|
|
12
|
+
*
|
|
13
|
+
* `git rev-parse --git-common-dir` is the obvious implementation and is wrong
|
|
14
|
+
* for the callers here, twice over:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Inherited `GIT_DIR` overrides discovery.** Git hooks export `GIT_DIR`,
|
|
17
|
+
* and every child inherits it, so `execFileSync('git', …, { cwd })` silently
|
|
18
|
+
* resolves against the *hook's* repository rather than `cwd` — in a linked
|
|
19
|
+
* worktree those differ, and the symptom is wrong data, not an error. That
|
|
20
|
+
* is exactly what `_lib/git_env.ts` exists to strip. A file-based resolution
|
|
21
|
+
* has nothing to override and needs no such guard.
|
|
22
|
+
* 2. **The raw output is not comparable across checkouts.** Measured
|
|
23
|
+
* 2026-08-07: from a main checkout `git rev-parse --git-common-dir` prints
|
|
24
|
+
* the **relative** `.git`; from a linked worktree it prints an absolute
|
|
25
|
+
* path. A consumer comparing the raw strings concludes the two checkouts
|
|
26
|
+
* have different common dirs. Only the resolved realpath is a repo identity.
|
|
27
|
+
*
|
|
28
|
+
* ## Symlinked parents converge
|
|
29
|
+
*
|
|
30
|
+
* Measured on a throwaway repo reached both directly and through a symlinked
|
|
31
|
+
* ancestor: all four accesses (main / worktree × real / symlinked) resolve to
|
|
32
|
+
* the same directory, because the returned path is realpath-normalised here.
|
|
33
|
+
* This is the opposite of the worktree-cleanup finding that motivated the
|
|
34
|
+
* check — there realpath reporting caused a mis-classification; here it is the
|
|
35
|
+
* property that makes one shared register possible.
|
|
36
|
+
*
|
|
37
|
+
* Never throws: every caller treats "no common dir" as a reason to degrade,
|
|
38
|
+
* not as an error worth failing a hook over.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import fs from 'node:fs';
|
|
42
|
+
import path from 'node:path';
|
|
43
|
+
|
|
44
|
+
/** `fs.realpathSync` that falls back to the input when the path is unreadable. */
|
|
45
|
+
function realpath_or_self(p: string): string {
|
|
46
|
+
try {
|
|
47
|
+
return fs.realpathSync(p);
|
|
48
|
+
} catch {
|
|
49
|
+
return p;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The **per-worktree** git directory for `project_root` — `.git` in a plain
|
|
55
|
+
* checkout, the linked gitdir in a worktree. This is where `HEAD` lives, so it
|
|
56
|
+
* is the right anchor for "which branch is THIS checkout on", as opposed to
|
|
57
|
+
* `git_common_dir` which is deliberately shared across worktrees.
|
|
58
|
+
*/
|
|
59
|
+
export function git_dir(project_root: string): string | null {
|
|
60
|
+
const dot_git = path.join(project_root, '.git');
|
|
61
|
+
try {
|
|
62
|
+
const st = fs.statSync(dot_git);
|
|
63
|
+
if (st.isDirectory()) {
|
|
64
|
+
return realpath_or_self(dot_git);
|
|
65
|
+
}
|
|
66
|
+
const m = fs.readFileSync(dot_git, 'utf-8').match(/^gitdir:\s*(.+)\s*$/m);
|
|
67
|
+
if (m === null) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return realpath_or_self(path.resolve(project_root, m[1]!.trim()));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Current branch name for `project_root`, or `null` on a detached HEAD or
|
|
78
|
+
* outside a repo.
|
|
79
|
+
*
|
|
80
|
+
* Reads `<git-dir>/HEAD` directly rather than shelling out, for the same reason
|
|
81
|
+
* as everything else in this module: hooks export `GIT_DIR`, and an inherited
|
|
82
|
+
* one silently redirects `git` to the wrong repository.
|
|
83
|
+
*/
|
|
84
|
+
export function current_branch(project_root: string): string | null {
|
|
85
|
+
const gd = git_dir(project_root);
|
|
86
|
+
if (gd === null) return null;
|
|
87
|
+
try {
|
|
88
|
+
const head = fs.readFileSync(path.join(gd, 'HEAD'), 'utf-8').trim();
|
|
89
|
+
const m = head.match(/^ref:\s*refs\/heads\/(.+)$/);
|
|
90
|
+
return m === null ? null : m[1]!.trim();
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The common git directory for `project_root`, realpath-normalised, or `null`
|
|
98
|
+
* when `project_root` is not inside a git repository.
|
|
99
|
+
*
|
|
100
|
+
* - Plain checkout → `<project_root>/.git`.
|
|
101
|
+
* - Linked worktree → the `commondir` target of the worktree's gitdir, i.e. the
|
|
102
|
+
* main checkout's `.git`, which is identical from every worktree of the repo.
|
|
103
|
+
* - Worktree gitdir without a `commondir` file → the gitdir itself (degenerate,
|
|
104
|
+
* but the historical behaviour and not this change's to alter).
|
|
105
|
+
*/
|
|
106
|
+
export function git_common_dir(project_root: string): string | null {
|
|
107
|
+
const dot_git = path.join(project_root, '.git');
|
|
108
|
+
try {
|
|
109
|
+
const st = fs.statSync(dot_git);
|
|
110
|
+
if (st.isDirectory()) {
|
|
111
|
+
return realpath_or_self(dot_git);
|
|
112
|
+
}
|
|
113
|
+
// Linked worktree: `.git` is a FILE containing "gitdir: <path>".
|
|
114
|
+
const m = fs.readFileSync(dot_git, 'utf-8').match(/^gitdir:\s*(.+)\s*$/m);
|
|
115
|
+
if (m === null) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const gitdir = path.resolve(project_root, m[1]!.trim());
|
|
119
|
+
const commondir_file = path.join(gitdir, 'commondir');
|
|
120
|
+
if (fs.existsSync(commondir_file)) {
|
|
121
|
+
const common = path.resolve(gitdir, fs.readFileSync(commondir_file, 'utf-8').trim());
|
|
122
|
+
return realpath_or_self(common);
|
|
123
|
+
}
|
|
124
|
+
return realpath_or_self(gitdir);
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Obligation frequency, carrier frequency, and the coverage lattice between them.
|
|
3
|
+
*
|
|
4
|
+
* `enforced_by` answers *does something carry this rule*. It does not answer
|
|
5
|
+
* *does it carry it often enough*, and the difference is not academic:
|
|
6
|
+
* `session-canary` declares `hook:session-canary`, the hook exists, is wired and
|
|
7
|
+
* fires — so the coverage instrument counts the rule as enforced — while the hook
|
|
8
|
+
* sits in the `session_start` slot and the rule's obligation is per task. The
|
|
9
|
+
* rule's own 30-session audit measured the opening canary dropped on ~13 of 15
|
|
10
|
+
* task starts. Enforced on paper, broken in fact.
|
|
11
|
+
*
|
|
12
|
+
* This module supplies the missing half: a declared obligation period per rule
|
|
13
|
+
* (frontmatter `obligation_frequency`), a carrier period resolved per slot AND
|
|
14
|
+
* per platform, and a `covers()` relation that is honest about the two ways the
|
|
15
|
+
* intuitive linear ordering produces FALSE GREENS.
|
|
16
|
+
*
|
|
17
|
+
* ## The value set is a forest, not a chain
|
|
18
|
+
*
|
|
19
|
+
* Four roots. A carrier in one root never covers an obligation in another:
|
|
20
|
+
*
|
|
21
|
+
* lifecycle per-turn ⊃ per-task ⊃ per-session
|
|
22
|
+
* tool-call per-edit ⊃ per-file-write
|
|
23
|
+
* repository per-commit
|
|
24
|
+
* external-event per-event
|
|
25
|
+
*
|
|
26
|
+
* Two corrections that a magnitude-ordered chain would get wrong:
|
|
27
|
+
*
|
|
28
|
+
* - `per-edit` and `per-turn` are INCOMPARABLE. `pre_tool_use`/`post_tool_use`
|
|
29
|
+
* fire per tool call, so a plain conversational reply fires them zero times.
|
|
30
|
+
* A per-turn obligation carried only by a per-edit hook is uncovered on
|
|
31
|
+
* exactly those turns — and a chain would paint that green.
|
|
32
|
+
* - `per-event` is a SEPARATE ROOT, not the bottom of the lifecycle chain. An
|
|
33
|
+
* external event fires on its own clock: a CI gate firing three times inside
|
|
34
|
+
* one session is not covered by a `session_start` check, yet a chain with
|
|
35
|
+
* `per-event` at the bottom would accept the session-scoped carrier as
|
|
36
|
+
* dominating it and report green.
|
|
37
|
+
*
|
|
38
|
+
* ## Point carriers vs sweep carriers — the third correction
|
|
39
|
+
*
|
|
40
|
+
* The forest rule above is stated for carriers that fire AT an event and see
|
|
41
|
+
* only that event: a hook. A CI validator is not that shape. It fires once per
|
|
42
|
+
* commit and inspects the whole tree, so it catches every edit inside the commit
|
|
43
|
+
* regardless of how many there were. Treating it as a `per-commit` point carrier
|
|
44
|
+
* would make every `validator:`-carried rule with a per-edit obligation a
|
|
45
|
+
* finding — roughly a fifth of the declared corpus at once, all from one
|
|
46
|
+
* modelling error, which is precisely the unusable-first-run failure this audit
|
|
47
|
+
* must avoid.
|
|
48
|
+
*
|
|
49
|
+
* So carriers have a MODE. A `sweep` carrier covers any obligation whose
|
|
50
|
+
* violations land in a durable artefact (`per-edit`, `per-file-write`,
|
|
51
|
+
* `per-commit`). It covers none of the transient ones (`per-turn`, `per-task`,
|
|
52
|
+
* `per-session`, `per-event`), because a reply's language, a greeting, or a
|
|
53
|
+
* fetched-content decision leaves nothing in the tree for a sweep to read.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
// ------------------------------------------------------------------ vocabulary
|
|
57
|
+
|
|
58
|
+
export type Frequency =
|
|
59
|
+
| 'per-edit'
|
|
60
|
+
| 'per-turn'
|
|
61
|
+
| 'per-task'
|
|
62
|
+
| 'per-session'
|
|
63
|
+
| 'per-event'
|
|
64
|
+
| 'per-commit'
|
|
65
|
+
| 'per-file-write'
|
|
66
|
+
| 'none';
|
|
67
|
+
|
|
68
|
+
export const FREQUENCIES: readonly Frequency[] = [
|
|
69
|
+
'per-edit',
|
|
70
|
+
'per-turn',
|
|
71
|
+
'per-task',
|
|
72
|
+
'per-session',
|
|
73
|
+
'per-event',
|
|
74
|
+
'per-commit',
|
|
75
|
+
'per-file-write',
|
|
76
|
+
'none',
|
|
77
|
+
] as const;
|
|
78
|
+
|
|
79
|
+
export function is_frequency(v: unknown): v is Frequency {
|
|
80
|
+
return typeof v === 'string' && (FREQUENCIES as readonly string[]).includes(v);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type Root = 'lifecycle' | 'tool-call' | 'repository' | 'external-event' | 'none';
|
|
84
|
+
|
|
85
|
+
const ROOT_OF: Record<Frequency, Root> = {
|
|
86
|
+
'per-turn': 'lifecycle',
|
|
87
|
+
'per-task': 'lifecycle',
|
|
88
|
+
'per-session': 'lifecycle',
|
|
89
|
+
'per-edit': 'tool-call',
|
|
90
|
+
'per-file-write': 'tool-call',
|
|
91
|
+
'per-commit': 'repository',
|
|
92
|
+
'per-event': 'external-event',
|
|
93
|
+
none: 'none',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Cross-root coverage that IS real, enumerated rather than inferred.
|
|
98
|
+
*
|
|
99
|
+
* `per-commit` keeps its own root because it is not a lifecycle event — a
|
|
100
|
+
* `--fixup` during a rebase has no preceding user turn, so no lifecycle carrier
|
|
101
|
+
* covers it. But in an agent runtime every commit the agent makes is issued
|
|
102
|
+
* through a tool call, so a point carrier in the tool-call root DOES observe it:
|
|
103
|
+
* `block-no-verify` sits in `pre_tool_use` and inspects the very `git commit`
|
|
104
|
+
* command. Refusing that edge on a tidy root rule would report the commit guard
|
|
105
|
+
* as failing to carry the commit rule, which is the opposite of true.
|
|
106
|
+
*
|
|
107
|
+
* The relation is one-directional. A per-commit carrier does not cover a
|
|
108
|
+
* per-edit obligation: one commit can hold twenty edits, and a point carrier at
|
|
109
|
+
* the commit sees the command, not each edit that preceded it.
|
|
110
|
+
*/
|
|
111
|
+
const CROSS_ROOT_COVERAGE: ReadonlyArray<readonly [Frequency, Frequency]> = [
|
|
112
|
+
['per-edit', 'per-commit'],
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
export function root_of(f: Frequency): Root {
|
|
116
|
+
return ROOT_OF[f];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Within-root inclusion depth: a SMALLER number fires more often, so it is a
|
|
121
|
+
* superset of every larger number in the same root. Cross-root comparison is
|
|
122
|
+
* rejected before this table is consulted.
|
|
123
|
+
*/
|
|
124
|
+
const DEPTH: Record<Frequency, number> = {
|
|
125
|
+
'per-turn': 0,
|
|
126
|
+
'per-task': 1,
|
|
127
|
+
'per-session': 2,
|
|
128
|
+
'per-edit': 0,
|
|
129
|
+
'per-file-write': 1,
|
|
130
|
+
'per-commit': 0,
|
|
131
|
+
'per-event': 0,
|
|
132
|
+
none: 0,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// -------------------------------------------------------------------- carriers
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* How a carrier observes the world.
|
|
139
|
+
*
|
|
140
|
+
* `point` — fires at an event and sees that event (a hook).
|
|
141
|
+
* `sweep` — fires once and inspects the whole artefact tree (a CI validator, a
|
|
142
|
+
* test suite). Its reach is bounded by what lands in the tree, not by
|
|
143
|
+
* how often it runs.
|
|
144
|
+
*/
|
|
145
|
+
export type CarrierMode = 'point' | 'sweep';
|
|
146
|
+
|
|
147
|
+
/** Obligations whose violations leave a durable trace a sweep can read. */
|
|
148
|
+
const SWEEPABLE: ReadonlySet<Frequency> = new Set<Frequency>([
|
|
149
|
+
'per-edit',
|
|
150
|
+
'per-file-write',
|
|
151
|
+
'per-commit',
|
|
152
|
+
]);
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Does `carrier` cover `obligation`?
|
|
156
|
+
*
|
|
157
|
+
* An obligation of `none` is covered vacuously — there is nothing recurring to
|
|
158
|
+
* carry, which is what `none` means. Everything else is decided by mode first,
|
|
159
|
+
* then root, then within-root depth.
|
|
160
|
+
*/
|
|
161
|
+
export function covers(
|
|
162
|
+
carrier: { frequency: Frequency; mode: CarrierMode },
|
|
163
|
+
obligation: Frequency,
|
|
164
|
+
): boolean {
|
|
165
|
+
if (obligation === 'none') return true;
|
|
166
|
+
if (carrier.mode === 'sweep') return SWEEPABLE.has(obligation);
|
|
167
|
+
if (carrier.frequency === 'none') return false;
|
|
168
|
+
if (CROSS_ROOT_COVERAGE.some(([c, o]) => c === carrier.frequency && o === obligation)) {
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
if (ROOT_OF[carrier.frequency] !== ROOT_OF[obligation]) return false;
|
|
172
|
+
return DEPTH[carrier.frequency] <= DEPTH[obligation];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** True when ANY of a carrier's firing periods covers the obligation. */
|
|
176
|
+
export function covers_any(frequencies: readonly Frequency[], obligation: Frequency): boolean {
|
|
177
|
+
return frequencies.some((f) => covers({ frequency: f, mode: 'point' }, obligation));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ------------------------------------------------------------- the decay guard
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Prose signals that suggest a period, keyed to the value they suggest.
|
|
184
|
+
*
|
|
185
|
+
* A declared value can be right the day it is written and wrong two edits later,
|
|
186
|
+
* and nothing in the join notices — the field would then be a stale claim
|
|
187
|
+
* wearing a schema. This heuristic reads the rule's own prose and reports when
|
|
188
|
+
* it demands a TIGHTER period than the frontmatter declares.
|
|
189
|
+
*
|
|
190
|
+
* Deliberately one-directional. Prose looser than the declaration is not a
|
|
191
|
+
* finding: a rule may state a per-session cache and still carry a per-turn
|
|
192
|
+
* obligation elsewhere in its body, and flagging that would fire on most of the
|
|
193
|
+
* corpus. Prose demanding MORE often than declared is the direction where the
|
|
194
|
+
* join silently over-credits a carrier.
|
|
195
|
+
*/
|
|
196
|
+
const PROSE_SIGNALS: ReadonlyArray<readonly [Frequency, RegExp]> = [
|
|
197
|
+
['per-turn', /\b(every|each) (reply|turn|answer|response)\b/i],
|
|
198
|
+
['per-turn', /\bper (reply|turn)\b/i],
|
|
199
|
+
['per-task', /\b(every|each) (new )?task\b/i],
|
|
200
|
+
['per-edit', /\bafter every (code )?edit\b/i],
|
|
201
|
+
['per-edit', /\b(every|each) edit\b/i],
|
|
202
|
+
['per-commit', /\b(in the )?same commit\b/i],
|
|
203
|
+
['per-commit', /\b(every|each) commit\b/i],
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Frequencies the prose demands but the declared value does not cover.
|
|
208
|
+
*
|
|
209
|
+
* Empty is the passing answer. A non-empty result is a WARNING — the heuristic
|
|
210
|
+
* is a keyword match over prose and will be noisy, so it never fails a build;
|
|
211
|
+
* an audited mismatch is silenced with an explicit `# frequency-override:`
|
|
212
|
+
* comment, which makes a suppression a decision someone signed rather than
|
|
213
|
+
* silence.
|
|
214
|
+
*/
|
|
215
|
+
export function frequency_prose_conflicts(body: string, declared: Frequency): Frequency[] {
|
|
216
|
+
const out: Frequency[] = [];
|
|
217
|
+
for (const [freq, re] of PROSE_SIGNALS) {
|
|
218
|
+
if (!re.test(body)) continue;
|
|
219
|
+
if (covers({ frequency: declared, mode: 'point' }, freq)) continue;
|
|
220
|
+
if (!out.includes(freq)) out.push(freq);
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** True when the file carries an audited `# frequency-override: <reason>` note. */
|
|
226
|
+
export function has_frequency_override(text: string): boolean {
|
|
227
|
+
return /^#\s*frequency-override:\s*\S/m.test(text);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// --------------------------------------------------------- slot × platform map
|
|
231
|
+
|
|
232
|
+
export type Slot =
|
|
233
|
+
| 'session_start'
|
|
234
|
+
| 'session_end'
|
|
235
|
+
| 'stop'
|
|
236
|
+
| 'user_prompt_submit'
|
|
237
|
+
| 'pre_tool_use'
|
|
238
|
+
| 'post_tool_use';
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Default firing period per slot, before per-platform native-event corrections.
|
|
242
|
+
*
|
|
243
|
+
* `stop` is per-turn, NOT session end. On Claude Code the native `Stop` event
|
|
244
|
+
* fires after every assistant reply — `hook_manifest.yaml` says so itself,
|
|
245
|
+
* describing the `stop` write as a "deterministic … overwrite of hot-context.md",
|
|
246
|
+
* i.e. a working-memory refresh per reply. True session end is the separate
|
|
247
|
+
* `session_end` slot.
|
|
248
|
+
*/
|
|
249
|
+
const SLOT_FREQUENCY_DEFAULT: Record<Slot, Frequency> = {
|
|
250
|
+
session_start: 'per-session',
|
|
251
|
+
session_end: 'per-session',
|
|
252
|
+
stop: 'per-turn',
|
|
253
|
+
user_prompt_submit: 'per-turn',
|
|
254
|
+
pre_tool_use: 'per-edit',
|
|
255
|
+
post_tool_use: 'per-edit',
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Per-platform overrides where the native event a slot is mapped from means
|
|
260
|
+
* something other than the default.
|
|
261
|
+
*
|
|
262
|
+
* Cline maps `stop` from `TaskCancel` — the session is interrupted with partial
|
|
263
|
+
* state (`hook_manifest.yaml`: "TaskCancel maps to stop because the session is
|
|
264
|
+
* interrupted with partial state"). That is an interruption on its own clock,
|
|
265
|
+
* not a per-reply beat, so it belongs to the external-event root and covers no
|
|
266
|
+
* lifecycle obligation. Collapsing it into per-turn is the semantic type error
|
|
267
|
+
* a one-dimensional slot→frequency table cannot express.
|
|
268
|
+
*/
|
|
269
|
+
const SLOT_FREQUENCY_OVERRIDE: Record<string, Partial<Record<Slot, Frequency>>> = {
|
|
270
|
+
cline: { stop: 'per-event' },
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
export function slot_frequency(platform: string, slot: Slot): Frequency {
|
|
274
|
+
return SLOT_FREQUENCY_OVERRIDE[platform]?.[slot] ?? SLOT_FREQUENCY_DEFAULT[slot];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Platforms with no hook surface at all.
|
|
279
|
+
*
|
|
280
|
+
* Copilot declares `fallback_only: true` in the manifest — concerns route
|
|
281
|
+
* through rule-only fallback and the dispatcher no-ops. Every hook-declared
|
|
282
|
+
* carrier fires zero times there, for every rule equally. Including it in the
|
|
283
|
+
* join would turn one structural fact into a finding on every hook-carried rule
|
|
284
|
+
* at once, which reports a platform property as if it were a per-rule defect.
|
|
285
|
+
* It is excluded by declaration, and the exclusion is named in the report rather
|
|
286
|
+
* than hidden.
|
|
287
|
+
*/
|
|
288
|
+
export function is_hook_capable(platform_block: Record<string, unknown>): boolean {
|
|
289
|
+
return platform_block['fallback_only'] !== true;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ------------------------------------------------- hook manifest platform read
|
|
293
|
+
|
|
294
|
+
export interface PlatformBinding {
|
|
295
|
+
/** Platform id → slot → concern ids bound in that slot. */
|
|
296
|
+
slots: Map<string, Map<Slot, string[]>>;
|
|
297
|
+
/** Platform ids that carry no hook surface at all. */
|
|
298
|
+
fallback_only: Set<string>;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const SLOT_NAMES: ReadonlySet<string> = new Set<string>([
|
|
302
|
+
'session_start',
|
|
303
|
+
'session_end',
|
|
304
|
+
'stop',
|
|
305
|
+
'user_prompt_submit',
|
|
306
|
+
'pre_tool_use',
|
|
307
|
+
'post_tool_use',
|
|
308
|
+
]);
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Read the `platforms:` block of `hook_manifest.yaml`.
|
|
312
|
+
*
|
|
313
|
+
* A narrow reader, matching the one `check_enforcement_coverage.parse_hook_manifest`
|
|
314
|
+
* already uses for `concerns:` — the block is a fixed two-level shape
|
|
315
|
+
* (`platform: slot: [concern, …]`) and the only facts needed are slot membership
|
|
316
|
+
* and the `fallback_only` sentinel. Deliberately NOT a second YAML dependency.
|
|
317
|
+
*/
|
|
318
|
+
export function parse_hook_platforms(text: string): PlatformBinding {
|
|
319
|
+
const slots = new Map<string, Map<Slot, string[]>>();
|
|
320
|
+
const fallback_only = new Set<string>();
|
|
321
|
+
let in_platforms = false;
|
|
322
|
+
let current: string | null = null;
|
|
323
|
+
|
|
324
|
+
for (const raw of text.split('\n')) {
|
|
325
|
+
const line = raw.replace(/\s+$/, '');
|
|
326
|
+
if (/^platforms:\s*$/.test(line)) {
|
|
327
|
+
in_platforms = true;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (!in_platforms) continue;
|
|
331
|
+
if (/^\S/.test(line)) break; // left the platforms block
|
|
332
|
+
if (line.trim() === '' || /^\s*#/.test(line)) continue;
|
|
333
|
+
|
|
334
|
+
const platform = /^ {2}([a-z0-9_-]+):\s*$/.exec(line)?.[1];
|
|
335
|
+
if (platform !== undefined) {
|
|
336
|
+
current = platform;
|
|
337
|
+
slots.set(current, new Map());
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (current === null) continue;
|
|
341
|
+
|
|
342
|
+
if (/^\s+fallback_only:\s*true\s*$/.test(line)) {
|
|
343
|
+
fallback_only.add(current);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const m = /^\s+([a-z_]+):\s*\[(.*)\]\s*$/.exec(line);
|
|
347
|
+
if (m === null) continue;
|
|
348
|
+
const slot = m[1] as string;
|
|
349
|
+
if (!SLOT_NAMES.has(slot)) continue;
|
|
350
|
+
const concerns = (m[2] as string)
|
|
351
|
+
.split(',')
|
|
352
|
+
.map((s) => s.trim())
|
|
353
|
+
.filter((s) => s.length > 0);
|
|
354
|
+
slots.get(current)?.set(slot as Slot, concerns);
|
|
355
|
+
}
|
|
356
|
+
return { slots, fallback_only };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Resolve, per platform, EVERY period at which `concern` fires — a set, never a
|
|
361
|
+
* scalar.
|
|
362
|
+
*
|
|
363
|
+
* A concern bound in more than one slot fires at the UNION of their periods, and
|
|
364
|
+
* those periods can sit in different roots: `minimal-safe-diff` is bound in
|
|
365
|
+
* `session_start` (per-session), `user_prompt_submit` (per-turn) AND
|
|
366
|
+
* `post_tool_use` (per-edit). Collapsing that to one "strongest" value has no
|
|
367
|
+
* correct answer, because per-turn and per-edit are incomparable by
|
|
368
|
+
* construction — and picking either drops a root the carrier really does cover.
|
|
369
|
+
* Doing so reported `minimal-safe-diff` as failing to carry its own per-edit
|
|
370
|
+
* obligation while a post_tool_use binding was sitting right there.
|
|
371
|
+
*
|
|
372
|
+
* An empty array means the concern is bound in no slot on that platform, which
|
|
373
|
+
* is a distinct answer from any frequency and must not be collapsed into one.
|
|
374
|
+
*/
|
|
375
|
+
export function carrier_frequency_by_platform(
|
|
376
|
+
concern: string,
|
|
377
|
+
binding: PlatformBinding,
|
|
378
|
+
): Record<string, Frequency[]> {
|
|
379
|
+
const out: Record<string, Frequency[]> = {};
|
|
380
|
+
for (const [platform, slot_map] of binding.slots) {
|
|
381
|
+
if (binding.fallback_only.has(platform)) continue; // named in the report, not joined
|
|
382
|
+
const found: Frequency[] = [];
|
|
383
|
+
for (const [slot, concerns] of slot_map) {
|
|
384
|
+
if (!concerns.includes(concern)) continue;
|
|
385
|
+
const f = slot_frequency(platform, slot);
|
|
386
|
+
if (!found.includes(f)) found.push(f);
|
|
387
|
+
}
|
|
388
|
+
out[platform] = found;
|
|
389
|
+
}
|
|
390
|
+
return out;
|
|
391
|
+
}
|