@event4u/agent-config 14.21.0 → 14.23.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +164 -187
- package/CREDITS.md +37 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +380 -5
- package/dist/agent-src/commands/analyze/repo.md +4 -2
- package/dist/agent-src/commands/pr/merge.md +47 -38
- package/dist/agent-src/commands/roadmap/next.md +8 -0
- package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +69 -0
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +5 -5
- package/dist/agent-src/contexts/execution/autonomy-mechanics.md +12 -0
- package/dist/agent-src/rules/external-code-graph-interop.md +19 -14
- package/dist/agent-src/rules/notes-first-reasoning.md +36 -25
- package/dist/agent-src/rules/token-efficiency.md +26 -3
- package/dist/agent-src/scripts/_cli_entry.ts +51 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +120 -27
- package/dist/agent-src/scripts/roadmap_gates.ts +4 -20
- package/dist/agent-src/scripts/roadmap_set_step.ts +5 -2
- package/dist/agent-src/scripts/stubs_due.ts +4 -18
- package/dist/agent-src/scripts/update_roadmap_progress.ts +7 -24
- package/dist/agent-src/skills/code-intelligence/SKILL.md +82 -1
- package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +9 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +17 -0
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +52 -2
- package/dist/agent-src/templates/agent-settings.md +0 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/_cli_entry.js +8 -0
- package/dist/cli-delegate/_lib/pin_resolver.js +170 -0
- package/dist/cli-delegate/archival_sweep.js +8 -0
- package/dist/cli-delegate/archive_completed_roadmaps.js +542 -0
- package/dist/cli-delegate/check_update_banner.js +923 -0
- package/dist/cli-delegate/{chunk-ZVBOHSVF.js → chunk-2KKXJW25.js} +23 -9
- package/dist/cli-delegate/chunk-5EK5WQN6.js +28 -0
- package/dist/cli-delegate/chunk-7XGD2OEU.js +96 -0
- package/dist/cli-delegate/chunk-DBH4AHDK.js +155 -0
- package/dist/cli-delegate/{chunk-4FS4WRXP.js → chunk-IAI3XL43.js} +15 -3
- package/dist/cli-delegate/chunk-JO25G5H7.js +20 -0
- package/dist/cli-delegate/{chunk-643Z7FIC.js → chunk-KCNTUSPI.js} +7 -1
- package/dist/cli-delegate/chunk-LTYVIY76.js +7961 -0
- package/dist/cli-delegate/chunk-QAJS5YWY.js +999 -0
- package/dist/cli-delegate/{chunk-YS6XTLEK.js → chunk-TYSY33MD.js} +56 -6
- package/dist/cli-delegate/chunk-ULET4BYS.js +328 -0
- package/dist/cli-delegate/chunk-YDQSHTD7.js +187 -0
- package/dist/cli-delegate/chunk-YWVXLR44.js +48 -0
- package/dist/cli-delegate/chunk-ZZQBUHFK.js +273 -0
- package/dist/cli-delegate/cmd_conformance.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +45 -13
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_session_recycle.js +4 -14
- 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/cli-delegate/dashboard_mode.js +16 -0
- package/dist/cli-delegate/gate_budget.js +24 -0
- package/dist/cli-delegate/gate_execute.js +25 -0
- package/dist/cli-delegate/guarded_baseline.js +18 -0
- package/dist/cli-delegate/resume_probe.js +22 -0
- package/dist/cli-delegate/roadmap_gates.js +638 -0
- package/dist/cli-delegate/roadmap_set_step.js +173 -0
- package/dist/cli-delegate/stub_queue.js +47 -0
- package/dist/cli-delegate/stubs_due.js +38 -0
- package/dist/cli-delegate/ticket_bundles.js +59 -0
- package/dist/cli-delegate/update_roadmap_progress.js +60 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +13 -13
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.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 +1813 -946
- package/dist/install/claudeLayerCarriage.js +237 -0
- package/dist/install/claudePathsPlan.js +79 -0
- package/dist/install/hostLayerFingerprint.js +1 -1
- package/dist/install/install.mjs +21 -3
- package/dist/install/partitionEligibility.js +231 -117
- package/dist/install/ruleLayerPartition.js +10 -7
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +10870 -7641
- package/dist/scripts/_lib/lean_projection_mode.js +108 -0
- package/dist/scripts/_lib/router_match.js +243 -0
- package/dist/scripts/_lib/rule_injection.js +216 -0
- package/dist/scripts/condense.js +52 -45
- package/dist/scripts/project_thin_rules.js +139 -19
- package/dist/server/schemas/settings.js +16 -3
- package/docs/MIGRATION.md +105 -1
- package/docs/contracts/settings-classes.md +40 -8
- package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +73 -0
- package/docs/guidelines/agent-infra/notes-horizon-mechanics.md +90 -0
- package/llms.txt +1 -1
- package/package.json +7 -4
- package/src/agent-src/scripts/_cli_entry.ts +51 -0
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +120 -27
- package/src/agent-src/scripts/roadmap_gates.ts +4 -20
- package/src/agent-src/scripts/roadmap_set_step.ts +5 -2
- package/src/agent-src/scripts/stubs_due.ts +4 -18
- package/src/agent-src/scripts/update_roadmap_progress.ts +7 -24
- package/src/config/agent-settings.template.yml +47 -1
- package/src/config/continuity-surface.json +516 -0
- package/src/config/estate-count-budget.json +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +29 -8
- package/src/config/gate-violation-baselines.json +8 -8
- package/src/config/gitignore-block.txt +19 -0
- package/src/config/hook-token-budget.json +7 -6
- package/src/config/host-injection-effect.json +73 -0
- package/src/config/packed-binary-manifest.json +33 -0
- package/src/config/preamble-payload-budget.json +112 -105
- package/src/config/publish-surface.json +4 -2
- package/src/config/routing-coverage-seed.json +9 -2
- package/src/install/claudeLayerCarriage.ts +273 -0
- package/src/install/claudePathsPlan.ts +76 -0
- package/src/install/hostLayerFingerprint.ts +1 -1
- package/src/install/partitionEligibility.ts +261 -127
- package/src/install/ruleLayerPartition.ts +19 -23
- package/src/scripts/_cli/cmd_session_recycle.ts +2 -25
- package/src/scripts/_cli/handoff_sessions.ts +14 -44
- package/src/scripts/_dispatch.bash +77 -27
- package/src/scripts/_lib/auto_dispatch.ts +2 -2
- package/src/scripts/_lib/catalogue_layer_parity.ts +146 -0
- package/src/scripts/_lib/continuity_slot.ts +295 -0
- package/src/scripts/_lib/continuity_surface.ts +296 -0
- package/src/scripts/_lib/continuity_writer.ts +253 -0
- package/src/scripts/_lib/corpus_manifest.ts +26 -9
- package/src/scripts/_lib/hook_settings.ts +86 -1
- package/src/scripts/_lib/injection_effect.ts +151 -0
- package/src/scripts/_lib/layer_overlap_notice.ts +50 -6
- package/src/scripts/_lib/lean_projection_mode.ts +127 -0
- package/src/scripts/_lib/packed_binary_predicate.ts +262 -0
- package/src/scripts/_lib/pin_resolver.ts +13 -0
- package/src/scripts/_lib/recycle_envelope_paths.ts +109 -21
- package/src/scripts/_lib/regression_neighbourhood.ts +159 -22
- package/src/scripts/_lib/retired_status.ts +74 -0
- package/src/scripts/_lib/session_eol.ts +44 -0
- package/src/scripts/_lib/session_index_trust.ts +346 -0
- package/src/scripts/_lib/skill_catalogue.ts +156 -21
- package/src/scripts/_lib/standing_bound_ratchet.ts +154 -0
- package/src/scripts/_lib/subagent_capsule.ts +117 -8
- package/src/scripts/_lib/vendored_grammar_upstream.ts +280 -0
- package/src/scripts/ai_council/api_transport.ts +5 -0
- package/src/scripts/ai_council/deliberation_metrics.ts +353 -0
- package/src/scripts/ai_council/prompts.ts +9 -0
- package/src/scripts/ai_council/synthesis_disagreement.ts +206 -0
- package/src/scripts/audit_mcp_tools.ts +69 -25
- package/src/scripts/build_mcp_catalog.ts +24 -1
- package/src/scripts/capture_skill_catalogue.ts +24 -11
- package/src/scripts/check_candidate_lines.ts +499 -0
- package/src/scripts/check_continuity_surface.ts +475 -0
- package/src/scripts/check_dependency_floors.ts +13 -6
- package/src/scripts/check_estate_count.ts +40 -79
- package/src/scripts/check_host_tree_parity.ts +291 -0
- package/src/scripts/check_pack_size.ts +262 -23
- package/src/scripts/check_preamble_payload_budget.ts +136 -7
- package/src/scripts/check_roadmap_trackable.ts +19 -16
- package/src/scripts/check_rule_layer_partition.ts +54 -34
- package/src/scripts/check_rule_projection_integrity.ts +102 -2
- package/src/scripts/check_single_delivery.ts +97 -31
- package/src/scripts/check_update_banner.ts +13 -0
- package/src/scripts/ci_settle.ts +205 -5
- package/src/scripts/code_graph/build.ts +217 -15
- package/src/scripts/code_graph/cli.ts +334 -15
- package/src/scripts/code_graph/detect.ts +38 -0
- package/src/scripts/code_graph/extract.ts +11 -0
- package/src/scripts/code_graph/grammar_probe.ts +43 -0
- package/src/scripts/code_graph/loader.ts +88 -14
- package/src/scripts/code_graph/query.ts +144 -11
- package/src/scripts/code_graph/resolution_tiers.ts +238 -0
- package/src/scripts/code_graph/sqlite_store.ts +262 -6
- package/src/scripts/code_graph/types.ts +117 -3
- package/src/scripts/code_graph/validate.ts +26 -1
- package/src/scripts/code_graph/verbs.ts +590 -0
- package/src/scripts/condense.ts +54 -42
- package/src/scripts/corpus_manifest.ts +5 -5
- package/src/scripts/council_cli.ts +11 -12
- package/src/scripts/count_intent_disagreement.ts +426 -0
- package/src/scripts/handoff_context_hook.ts +118 -1
- package/src/scripts/hook_effect_doctor.ts +82 -1
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +87 -12
- package/src/scripts/hooks/block_speaking_inbox_dir.ts +182 -16
- package/src/scripts/hooks/chain_nudge_hook.ts +282 -0
- package/src/scripts/hooks/code_graph_context_hook.ts +201 -0
- package/src/scripts/hooks/concern_registry.ts +4 -2
- package/src/scripts/hooks/host_semantics.ts +1 -1
- package/src/scripts/hooks/orchestration_record_hook.ts +2 -2
- package/src/scripts/hooks/push_settle_hook.ts +3 -2
- package/src/scripts/hooks/rule_inject_hook.ts +108 -21
- package/src/scripts/hooks/session_eol_hook.ts +120 -2
- package/src/scripts/hooks/skill_route_hook.ts +11 -4
- package/src/scripts/hot_context_hook.ts +62 -7
- package/src/scripts/inbox_source_census.ts +322 -0
- package/src/scripts/install-hooks.sh +82 -0
- package/src/scripts/{lint_carrier_integrity.ts → lint_deferral_integrity.ts} +60 -214
- package/src/scripts/lint_plan_risk_register.ts +13 -17
- package/src/scripts/lint_roadmap_blockers.ts +164 -1
- package/src/scripts/mcp_server/consumer_tool_catalog.json +152 -2
- package/src/scripts/mcp_server/graph_tools.ts +294 -0
- package/src/scripts/mcp_server/path_util.ts +31 -0
- package/src/scripts/mcp_server/suggest_skill.ts +72 -0
- package/src/scripts/mcp_server/tool_catalog_source.ts +7 -1
- package/src/scripts/mcp_server/tools.ts +5 -69
- package/src/scripts/media/lib/adapter-contract.md +7 -2
- package/src/scripts/media/lib/fixtures/flux/asset-0001.png +0 -0
- package/src/scripts/media/lib/fixtures/gemini-image/asset-0001.png +0 -0
- package/src/scripts/media/lib/fixtures/ideogram/asset-0001.png +0 -0
- package/src/scripts/model_rule_injection.ts +61 -4
- package/src/scripts/project_thin_rules.ts +149 -16
- package/src/scripts/rdp_candidates_delta.ts +276 -0
- package/src/scripts/rdp_quality_eval.ts +217 -19
- package/src/scripts/release_mix_taxonomy.json +3 -2
- package/src/scripts/report_host_injection_effect.ts +189 -0
- package/src/scripts/report_layer_overlap.ts +41 -0
- package/src/scripts/report_skill_menu_census.ts +341 -0
- package/src/scripts/report_standing_payload_by_host.ts +650 -0
- package/src/scripts/schemas/agent-settings.schema.json +7 -1
- package/src/scripts/session_memory_index.ts +81 -8
- package/src/scripts/skill_tools/score_skill_relevance.ts +70 -6
- package/src/scripts/skill_tools/suggest_skill_for_task.ts +37 -3
- package/src/server/schemas/settings.ts +23 -4
- package/src/vendor/grammars/README.md +86 -0
- package/src/vendor/grammars/tree-sitter-javascript.wasm +0 -0
- package/src/vendor/grammars/tree-sitter-php.wasm +0 -0
- package/src/vendor/grammars/tree-sitter-typescript.wasm +0 -0
- package/src/scripts/_lib/carrier_status.ts +0 -118
- package/src/scripts/_lib/estate_carrier_cases.ts +0 -72
- package/src/scripts/hooks/code_graph_nudge_hook.ts +0 -191
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic continuity-record writer.
|
|
3
|
+
*
|
|
4
|
+
* `road-to-continuity-writer-activation` step 1.2. Until this module existed
|
|
5
|
+
* `agent-config session:recycle` was the ONLY writer of the continuity record
|
|
6
|
+
* in the tree, which is why `verb:session:recycle` counts on the
|
|
7
|
+
* `public_continuity_commands` axis and why an advisory has to tell a human to
|
|
8
|
+
* run it before `/clear`: continuity did not happen unless somebody
|
|
9
|
+
* remembered.
|
|
10
|
+
*
|
|
11
|
+
* Every field here is computed from on-disk state and nothing is asked of a
|
|
12
|
+
* model. That is not a cost optimisation, it is what makes the record a
|
|
13
|
+
* `continuity_record` rather than a `main_session` one: the judgement fields
|
|
14
|
+
* (`decisions`, `failed_approaches`, `successful_approaches`, …) are FORBIDDEN
|
|
15
|
+
* on the variant precisely so a deterministic writer cannot fill one with a
|
|
16
|
+
* guess and a reader cannot be left unable to tell a derived record from a
|
|
17
|
+
* judged one.
|
|
18
|
+
*
|
|
19
|
+
* Two gates, and both are needed:
|
|
20
|
+
*
|
|
21
|
+
* 1. `continuity.auto_record` must be `on`. It ships `off`, so with the
|
|
22
|
+
* shipped default this module is never reached and the tree behaves
|
|
23
|
+
* exactly as it does today.
|
|
24
|
+
* 2. The session must be SUBSTANTIVE by the committed floor
|
|
25
|
+
* (`is_substantive`) and must have a resolvable task. Both come from the
|
|
26
|
+
* concern's own counters and claim state, never from file presence.
|
|
27
|
+
*
|
|
28
|
+
* The task gate is the sharper of the two, and its bound is stated rather than
|
|
29
|
+
* hidden: a record needs at least one `acceptance_criteria` entry, and the only
|
|
30
|
+
* deterministic source of one in this tree is the roadmap a session has
|
|
31
|
+
* claimed. A session with no claim therefore leaves no record — and the
|
|
32
|
+
* alternative, writing a placeholder acceptance criterion, would assert a
|
|
33
|
+
* definition of "done" nobody set, which is the exact failure the variant's
|
|
34
|
+
* forbidden-key set exists to prevent.
|
|
35
|
+
*/
|
|
36
|
+
import * as fs from 'node:fs';
|
|
37
|
+
|
|
38
|
+
import { load_agent_settings } from './agent_settings.js';
|
|
39
|
+
import { resolvePredecessor } from './recycle_envelope_paths.js';
|
|
40
|
+
import { CHECKBOX_LINE, phaseLines } from './roadmap_checkboxes.js';
|
|
41
|
+
import { readHead, roadmapPath } from './run_checkpoint.js';
|
|
42
|
+
import { is_substantive, type StoredEolCounters } from './session_eol.js';
|
|
43
|
+
import { CAPSULE_SCHEMA_VERSION, MAX_ENTRIES, MAX_LINE_CHARS } from './subagent_capsule.js';
|
|
44
|
+
|
|
45
|
+
/** Settings key that arms this writer. Ships `off`. */
|
|
46
|
+
export const AUTO_RECORD_KEY = 'continuity.auto_record';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read `continuity.auto_record` from the merged settings cascade.
|
|
50
|
+
*
|
|
51
|
+
* The cascade merges the shipped template, so a workspace with no settings file
|
|
52
|
+
* resolves `off` through the template rather than through the trailing
|
|
53
|
+
* `return false` — which is why that line is defence against an unreadable
|
|
54
|
+
* cascade and not the path a default install takes. Both directions are pinned
|
|
55
|
+
* by fixtures, because "absent" and "off" arriving by different routes is
|
|
56
|
+
* exactly the kind of thing a reader assumes and a test has to establish.
|
|
57
|
+
*/
|
|
58
|
+
export function auto_record_enabled(root: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
const settings = load_agent_settings({ cwd: root });
|
|
61
|
+
const section = settings['continuity'];
|
|
62
|
+
if (section && typeof section === 'object' && !Array.isArray(section)) {
|
|
63
|
+
const v = (section as Record<string, unknown>)['auto_record'];
|
|
64
|
+
// YAML 1.1 parses a bare `on` as boolean true — accept both.
|
|
65
|
+
return v === 'on' || v === true;
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// fail-closed: unreadable settings leave the writer disarmed
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Settings key that governs run-checkpoint production. Ships `on`. */
|
|
74
|
+
export const RUN_CHECKPOINTS_KEY = 'continuity.run_checkpoints';
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read `continuity.run_checkpoints` from the merged settings cascade.
|
|
78
|
+
*
|
|
79
|
+
* Fails OPEN, unlike its sibling above, and the asymmetry is the point: this
|
|
80
|
+
* switch governs behaviour the tree already had, so an unreadable cascade must
|
|
81
|
+
* leave it running. Its sibling arms something new, so an unreadable cascade
|
|
82
|
+
* must leave that disarmed. A single helper with one polarity would have been
|
|
83
|
+
* wrong for one of the two.
|
|
84
|
+
*/
|
|
85
|
+
export function run_checkpoints_enabled(root: string): boolean {
|
|
86
|
+
try {
|
|
87
|
+
const settings = load_agent_settings({ cwd: root });
|
|
88
|
+
const section = settings['continuity'];
|
|
89
|
+
if (section && typeof section === 'object' && !Array.isArray(section)) {
|
|
90
|
+
const v = (section as Record<string, unknown>)['run_checkpoints'];
|
|
91
|
+
if (v === 'off' || v === false) return false;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// fail-open: an unreadable cascade never silently removes a recovery aid
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Why no record was built, when none was. Always a stated reason. */
|
|
100
|
+
export interface WriterDecision {
|
|
101
|
+
record: Record<string, unknown> | null;
|
|
102
|
+
reason: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** How many `remaining` / `acceptance_criteria` lines a record carries. */
|
|
106
|
+
const LIST_CAP = 12;
|
|
107
|
+
|
|
108
|
+
function _clip(line: string): string {
|
|
109
|
+
const one = line.replace(/\s+/g, ' ').trim();
|
|
110
|
+
return one.length <= MAX_LINE_CHARS ? one : `${one.slice(0, MAX_LINE_CHARS - 1)}…`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Open, closed and parked step counts plus the open step bodies.
|
|
115
|
+
*
|
|
116
|
+
* Deliberately reuses `phaseLines` + `CHECKBOX_LINE` rather than a fourth
|
|
117
|
+
* regex: a writer that disagreed with the dashboard about whether a roadmap is
|
|
118
|
+
* finished would put that disagreement into the record a successor resumes
|
|
119
|
+
* from.
|
|
120
|
+
*/
|
|
121
|
+
export function roadmapShape(text: string): { done: number; total: number; open: string[] } {
|
|
122
|
+
let done = 0;
|
|
123
|
+
let total = 0;
|
|
124
|
+
const open: string[] = [];
|
|
125
|
+
for (const raw of phaseLines(text)) {
|
|
126
|
+
const m = CHECKBOX_LINE.exec(raw);
|
|
127
|
+
if (m === null) continue;
|
|
128
|
+
total += 1;
|
|
129
|
+
const mark = m[1];
|
|
130
|
+
if (mark === 'x' || mark === 'X') {
|
|
131
|
+
done += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (mark === '~' || mark === '-') continue;
|
|
135
|
+
if (open.length < LIST_CAP) {
|
|
136
|
+
open.push(_clip((m[2] ?? '').replace(/\*\*/g, '')));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { done, total, open };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Checkbox bodies under an `## Acceptance Criteria` heading.
|
|
144
|
+
*
|
|
145
|
+
* `phaseLines` deliberately excludes this section — an autonomous run must not
|
|
146
|
+
* mistake an acceptance criterion for its next step — so it is read separately
|
|
147
|
+
* here, which is the one place the two readings legitimately differ.
|
|
148
|
+
*/
|
|
149
|
+
export function acceptanceCriteria(text: string): string[] {
|
|
150
|
+
const lines = text.split('\n');
|
|
151
|
+
const out: string[] = [];
|
|
152
|
+
let inside = false;
|
|
153
|
+
for (const raw of lines) {
|
|
154
|
+
const heading = /^(#{2,3})[ \t]+(.*?)[ \t]*$/.exec(raw);
|
|
155
|
+
if (heading !== null) {
|
|
156
|
+
inside = /^Acceptance[ \t]+Criteria\b/i.test(heading[2] ?? '');
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!inside) continue;
|
|
160
|
+
const m = CHECKBOX_LINE.exec(raw);
|
|
161
|
+
if (m === null) continue;
|
|
162
|
+
if (out.length < LIST_CAP) {
|
|
163
|
+
out.push(_clip((m[2] ?? '').replace(/\*\*/g, '')));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface BuildInput {
|
|
170
|
+
/** Workspace root — the record's `workspace`, and the root every read is relative to. */
|
|
171
|
+
root: string;
|
|
172
|
+
/** Raw session id, as the record paths and the claim state key on it. */
|
|
173
|
+
sessionId: string;
|
|
174
|
+
/** The roadmap slug this session claimed, or `null` when it claimed none. */
|
|
175
|
+
slug: string | null;
|
|
176
|
+
/** The concern's own counters for this session. */
|
|
177
|
+
counters: StoredEolCounters | null;
|
|
178
|
+
/** Injected for determinism in tests. */
|
|
179
|
+
now?: Date;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Build the record, or say why there is none.
|
|
184
|
+
*
|
|
185
|
+
* Never throws and never shells out: this runs on the Stop path, where a
|
|
186
|
+
* subprocess is a latency cost paid on every turn's end and an exception is
|
|
187
|
+
* worse than a missing record. `head` is read from `.git` directly (worktree
|
|
188
|
+
* aware) rather than through `git`, and the richer anchor fields
|
|
189
|
+
* `session:recycle` collects with `git status` are deliberately omitted — the
|
|
190
|
+
* trade is one drift line for a Stop path that spawns nothing, and it is stated
|
|
191
|
+
* in `docs/contracts/continuity-record-slot.md` rather than left for a reader
|
|
192
|
+
* to discover by diffing two producers.
|
|
193
|
+
*/
|
|
194
|
+
export function buildContinuityRecord(input: BuildInput): WriterDecision {
|
|
195
|
+
const { root, sessionId, slug, counters } = input;
|
|
196
|
+
const now = input.now ?? new Date();
|
|
197
|
+
|
|
198
|
+
if (!is_substantive(counters)) {
|
|
199
|
+
return {
|
|
200
|
+
record: null,
|
|
201
|
+
reason: 'no record — the session is not substantive by the committed floor',
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
if (slug === null || slug.trim() === '') {
|
|
205
|
+
return {
|
|
206
|
+
record: null,
|
|
207
|
+
reason: 'no record — no claimed roadmap, so no deterministic acceptance criterion exists',
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let text: string;
|
|
212
|
+
try {
|
|
213
|
+
text = fs.readFileSync(roadmapPath(root, slug), 'utf-8');
|
|
214
|
+
} catch {
|
|
215
|
+
return { record: null, reason: `no record — the claimed roadmap ${slug} is not readable` };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const shape = roadmapShape(text);
|
|
219
|
+
if (shape.total === 0) {
|
|
220
|
+
return { record: null, reason: `no record — ${slug} carries no phase checkboxes to count` };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const declared = acceptanceCriteria(text);
|
|
224
|
+
const criteria =
|
|
225
|
+
declared.length > 0
|
|
226
|
+
? declared
|
|
227
|
+
: // A roadmap with no declared criteria still has one definitional
|
|
228
|
+
// completion condition, and it is derived from the artefact class
|
|
229
|
+
// rather than judged: every step closed. Anything richer would be
|
|
230
|
+
// a definition of done nobody wrote.
|
|
231
|
+
[`every phase step in agents/roadmaps/${slug}.md is closed`];
|
|
232
|
+
|
|
233
|
+
const record: Record<string, unknown> = {
|
|
234
|
+
capsule_version: CAPSULE_SCHEMA_VERSION,
|
|
235
|
+
variant: 'continuity_record',
|
|
236
|
+
summary: _clip(`${slug}: ${shape.done} of ${shape.total} steps closed`),
|
|
237
|
+
task: _clip(slug),
|
|
238
|
+
workspace: root,
|
|
239
|
+
written_at: now.toISOString(),
|
|
240
|
+
acceptance_criteria: criteria.slice(0, MAX_ENTRIES),
|
|
241
|
+
remaining: shape.open.slice(0, MAX_ENTRIES),
|
|
242
|
+
predecessor: resolvePredecessor(root, sessionId),
|
|
243
|
+
artifact_paths: [`agents/roadmaps/${slug}.md`],
|
|
244
|
+
};
|
|
245
|
+
if (sessionId.trim() !== '') {
|
|
246
|
+
record['session_id'] = sessionId.trim();
|
|
247
|
+
}
|
|
248
|
+
const head = readHead(root);
|
|
249
|
+
if (head !== null) {
|
|
250
|
+
record['head'] = head;
|
|
251
|
+
}
|
|
252
|
+
return { record, reason: `record built for ${slug} (${shape.done}/${shape.total} closed)` };
|
|
253
|
+
}
|
|
@@ -46,7 +46,19 @@ import {
|
|
|
46
46
|
import { isExclusivelyPackageOnly } from '../../install/partitionEligibility.js';
|
|
47
47
|
import { dedupableRules } from '../../install/ruleLayerPartition.js';
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Bumped to v2 on 2026-09-07 with the `partition_active` -> `host_layer_verified`
|
|
51
|
+
* rename, after a neutral review named the alternative: `parseManifest` accepts
|
|
52
|
+
* any object carrying the declared version and validates three fields, and
|
|
53
|
+
* `diffManifests` compares with `String(e) !== String(a)`. So a v1 manifest
|
|
54
|
+
* (which has `partition_active` and no `host_layer_verified`) compared against
|
|
55
|
+
* another v1 yields `"undefined" === "undefined"` on the renamed field — NO
|
|
56
|
+
* difference reported, on a field neither side has. That is verbatim the failure
|
|
57
|
+
* `parseManifest`'s own error text says the version check exists to prevent:
|
|
58
|
+
* "comparing it partially would report equivalence from the fields that happened
|
|
59
|
+
* to survive". A rename inside a frozen version is exactly that.
|
|
60
|
+
*/
|
|
61
|
+
export const CORPUS_MANIFEST_VERSION = 'corpus-manifest-v2';
|
|
50
62
|
|
|
51
63
|
/** The tool directory the experiment's corpus is enumerated from. */
|
|
52
64
|
export const CORPUS_TOOL_DIR = '.claude/rules';
|
|
@@ -142,7 +154,7 @@ export interface ProjectionDecision {
|
|
|
142
154
|
* `null` is the default and is the honest value for a capture that cannot
|
|
143
155
|
* establish it. Every other field in this struct is a function of the
|
|
144
156
|
* `(repoRoot, userHome)` pair the capture was given; this one is not.
|
|
145
|
-
* `
|
|
157
|
+
* `hostLayerVerified` resolves through `resolveHostLayerVerdict`, which reads
|
|
146
158
|
* the host layer at `os.homedir()` and the install lockfile at its own
|
|
147
159
|
* fixed location — ignoring `userHome` entirely — and memoises the answer
|
|
148
160
|
* for the life of the process. So a capture over a temporary tree that
|
|
@@ -150,7 +162,7 @@ export interface ProjectionDecision {
|
|
|
150
162
|
* one process would reuse the first verdict. A caller for which those
|
|
151
163
|
* globals ARE the run's own host supplies the predicate; nobody else does.
|
|
152
164
|
*/
|
|
153
|
-
readonly
|
|
165
|
+
readonly host_layer_verified: boolean | null;
|
|
154
166
|
readonly tool_id: string | null;
|
|
155
167
|
/** The global layer directory, home-relative; `null` when this host has none. */
|
|
156
168
|
readonly layer_dir: string | null;
|
|
@@ -365,7 +377,7 @@ export interface ProjectionDecisionOptions {
|
|
|
365
377
|
* The host partition verdict, supplied by a caller for which the process
|
|
366
378
|
* globals it reads are this run's own host. Omitted → `null`.
|
|
367
379
|
*/
|
|
368
|
-
readonly
|
|
380
|
+
readonly hostLayerVerified?: ((repoRoot: string) => boolean) | undefined;
|
|
369
381
|
}
|
|
370
382
|
|
|
371
383
|
export function captureProjectionDecision(
|
|
@@ -401,7 +413,8 @@ export function captureProjectionDecision(
|
|
|
401
413
|
sha256: layerPath === null ? null : sha256OfFile(path.join(layerPath, n)),
|
|
402
414
|
}));
|
|
403
415
|
return {
|
|
404
|
-
|
|
416
|
+
host_layer_verified:
|
|
417
|
+
opts.hostLayerVerified === undefined ? null : opts.hostLayerVerified(repoRoot),
|
|
405
418
|
tool_id: toolId,
|
|
406
419
|
layer_dir: layerPath === null ? null : homeRelative(layerPath, userHome),
|
|
407
420
|
carries: verdict.carries,
|
|
@@ -451,8 +464,8 @@ export interface CaptureOptions {
|
|
|
451
464
|
readonly limit: number;
|
|
452
465
|
readonly enumerationRule: string;
|
|
453
466
|
readonly env?: Readonly<Record<string, string | undefined>> | undefined;
|
|
454
|
-
/** See {@link ProjectionDecisionOptions.
|
|
455
|
-
readonly
|
|
467
|
+
/** See {@link ProjectionDecisionOptions.hostLayerVerified}. Omitted → `null`. */
|
|
468
|
+
readonly hostLayerVerified?: ((repoRoot: string) => boolean) | undefined;
|
|
456
469
|
/** See {@link UserScopeOptions.ruleNames}. Omitted → the projection-source superset. */
|
|
457
470
|
readonly ruleNames?: readonly string[] | undefined;
|
|
458
471
|
}
|
|
@@ -531,7 +544,7 @@ export function captureManifest(opts: CaptureOptions): CorpusManifest {
|
|
|
531
544
|
user_scope_population:
|
|
532
545
|
opts.ruleNames === undefined ? 'projection-source-superset' : 'caller-supplied',
|
|
533
546
|
projection: captureProjectionDecision(repoRoot, userHome, {
|
|
534
|
-
|
|
547
|
+
hostLayerVerified: opts.hostLayerVerified,
|
|
535
548
|
}),
|
|
536
549
|
generator_config: generatorConfig,
|
|
537
550
|
runtime: { node: process.version, platform: os.platform(), arch: os.arch() },
|
|
@@ -656,7 +669,11 @@ export function diffManifests(expected: CorpusManifest, actual: CorpusManifest):
|
|
|
656
669
|
// The partition half, which is where the observed skips actually came from.
|
|
657
670
|
// Diffed by digest rather than by name list because the inventory runs to
|
|
658
671
|
// three figures and a diff nobody reads is a diff that does not exist.
|
|
659
|
-
push(
|
|
672
|
+
push(
|
|
673
|
+
'projection.host_layer_verified',
|
|
674
|
+
expected.projection.host_layer_verified,
|
|
675
|
+
actual.projection.host_layer_verified,
|
|
676
|
+
);
|
|
660
677
|
push('projection.carries', expected.projection.carries, actual.projection.carries);
|
|
661
678
|
push('projection.reason', expected.projection.reason, actual.projection.reason);
|
|
662
679
|
push('projection.layer_digest', expected.projection.layer_digest, actual.projection.layer_digest);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `.agent-settings.yml` without dragging a YAML parser into a hook.
|
|
4
4
|
*
|
|
5
5
|
* Every default-OFF hook needs the same two-level lookup, and the tree already
|
|
6
|
-
* carries two hand-rolled copies of it (`
|
|
6
|
+
* carries two hand-rolled copies of it (`code_graph_context_hook (no flag)` and the
|
|
7
7
|
* equivalent inside `design_slop_hook`). Adding a third copy for the route
|
|
8
8
|
* nudge is what this file prevents; the two existing copies are pre-existing
|
|
9
9
|
* duplication and are deliberately left alone — folding them in is a separate,
|
|
@@ -110,3 +110,88 @@ export function leanProjectionModeRaw(root: string): string {
|
|
|
110
110
|
}
|
|
111
111
|
return '';
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Raw `lean_projection.hosts` list out of `.agent-settings.yml`, or `[]`.
|
|
116
|
+
*
|
|
117
|
+
* Same indentation-shaped discipline as `leanProjectionModeRaw` above and for
|
|
118
|
+
* the same reason — a hook must never fail a tool call because a YAML parser
|
|
119
|
+
* could not load — extended to the three list shapes a human writes:
|
|
120
|
+
*
|
|
121
|
+
* lean_projection:
|
|
122
|
+
* hosts: [claude-code] # inline
|
|
123
|
+
* lean_projection:
|
|
124
|
+
* hosts: # block, indented under the key
|
|
125
|
+
* - claude-code
|
|
126
|
+
* lean_projection:
|
|
127
|
+
* hosts: # block, at the key's own indent
|
|
128
|
+
* - claude-code
|
|
129
|
+
*
|
|
130
|
+
* All three are legal YAML and `js-yaml` loads them identically; the third was
|
|
131
|
+
* read as `[]` until 2026-09-08 (R2 finding 2) and is covered by three fixtures
|
|
132
|
+
* in `tests/scripts/lean_projection_hosts.test.ts`.
|
|
133
|
+
*
|
|
134
|
+
* Interpretation is NOT done here. `[]` means "nothing was written", and
|
|
135
|
+
* `_lib/lean_projection_mode.ts::resolveLeanProjectionHosts` decides that this
|
|
136
|
+
* means the default rather than "no host" — keeping that decision in one place
|
|
137
|
+
* is what stops the projector and the delivery concern from disagreeing, which
|
|
138
|
+
* is the whole reason the mode reader is split this way.
|
|
139
|
+
*/
|
|
140
|
+
export function leanProjectionHostsRaw(root: string): string[] {
|
|
141
|
+
const file = path.join(root, SETTINGS_FILE);
|
|
142
|
+
let text: string;
|
|
143
|
+
try {
|
|
144
|
+
if (!fs.statSync(file).isFile()) return [];
|
|
145
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
146
|
+
} catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const strip = (s: string): string => s.trim().replace(/^["']|["']$/g, '');
|
|
150
|
+
let inSection = false;
|
|
151
|
+
let inBlock = false;
|
|
152
|
+
let blockIndent = -1;
|
|
153
|
+
const out: string[] = [];
|
|
154
|
+
for (const raw of text.split(/\r\n|\r|\n/)) {
|
|
155
|
+
const line = raw.replace(/\s+$/, '');
|
|
156
|
+
if (!line || line.replace(/^\s+/, '').startsWith('#')) continue;
|
|
157
|
+
if (!(line.startsWith(' ') || line.startsWith('\t'))) {
|
|
158
|
+
inSection = /^lean_projection\s*:\s*$/.test(line);
|
|
159
|
+
inBlock = false;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (!inSection) continue;
|
|
163
|
+
const indent = (/^\s*/.exec(line) as RegExpExecArray)[0].length;
|
|
164
|
+
if (inBlock) {
|
|
165
|
+
const item = /^\s*-\s*(\S.*)$/.exec(line);
|
|
166
|
+
// `>=`, not `>`: YAML lets a block sequence sit at its KEY's own
|
|
167
|
+
// indent, and `js-yaml` — the parser `condense` uses on this same
|
|
168
|
+
// file — loads that shape identically to the deeper-indented one.
|
|
169
|
+
// With `>` the same-indent form parsed as `[]`, which
|
|
170
|
+
// `resolveLeanProjectionHosts` reads as "nothing was written" and
|
|
171
|
+
// answers with the default `[claude-code]` while the projector
|
|
172
|
+
// thins whatever the file actually says. Two readers disagreeing
|
|
173
|
+
// about the delivery-host set is the one failure this split exists
|
|
174
|
+
// to prevent (R2 finding 2). A SHALLOWER item still ends the block:
|
|
175
|
+
// it belongs to a different mapping level, never to `hosts`.
|
|
176
|
+
if (item !== null && indent >= blockIndent) {
|
|
177
|
+
const v = strip(item[1] ?? '');
|
|
178
|
+
if (v !== '') out.push(v);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
inBlock = false;
|
|
182
|
+
}
|
|
183
|
+
const inline = /^\s+hosts\s*:\s*\[([^\]]*)\]\s*$/.exec(line);
|
|
184
|
+
if (inline !== null) {
|
|
185
|
+
for (const part of (inline[1] ?? '').split(',')) {
|
|
186
|
+
const v = strip(part);
|
|
187
|
+
if (v !== '') out.push(v);
|
|
188
|
+
}
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (/^\s+hosts\s*:\s*$/.test(line)) {
|
|
192
|
+
inBlock = true;
|
|
193
|
+
blockIndent = indent;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a delivered rule body actually reach the model's context on this host?
|
|
3
|
+
*
|
|
4
|
+
* `road-to-delivery-on-hook-hosts` step 1.1, and the record owner ruling E3
|
|
5
|
+
* admits a host on.
|
|
6
|
+
*
|
|
7
|
+
* THE ONE THING THIS MODULE EXISTS TO REFUSE
|
|
8
|
+
* ------------------------------------------
|
|
9
|
+
* A host that BINDS `user_prompt_submit` looks, from every artefact in this
|
|
10
|
+
* tree, exactly like a host that DELIVERS. Cowork is the proof that they are
|
|
11
|
+
* different: it binds eight slots and its trampoline discards dispatcher output
|
|
12
|
+
* and `exit 0`s (`docs/enforcement-by-host.md:21`). Cursor and Cline bind the
|
|
13
|
+
* same slot the delivery concern needs, and whether their hook stdout reaches
|
|
14
|
+
* the model is unmeasured.
|
|
15
|
+
*
|
|
16
|
+
* So the binding is computable and the effect is not. This module keeps them
|
|
17
|
+
* apart by construction:
|
|
18
|
+
*
|
|
19
|
+
* · `emits` — computed. Is the concern bound on the slot at all?
|
|
20
|
+
* Read from the manifest. Cheap, always available,
|
|
21
|
+
* and NEVER promoted to an observation.
|
|
22
|
+
* · `injection_effect` — observed. Did a model visibly act on a delivered
|
|
23
|
+
* body? Read from a committed record and from nowhere
|
|
24
|
+
* else. There is no code path that derives it.
|
|
25
|
+
*
|
|
26
|
+
* An `observed-*` value is admissible only with the four-part citation the
|
|
27
|
+
* host-capability observation protocol requires (host · host version ·
|
|
28
|
+
* transcript or artefact reference · date) — see
|
|
29
|
+
* `_lib/host_capability.ts:183`. A record missing any part is rejected rather
|
|
30
|
+
* than downgraded, because a half-cited observation is the shape that gets
|
|
31
|
+
* quoted as a whole one.
|
|
32
|
+
*
|
|
33
|
+
* `unobserved` is a RESULT, not a gap. Most hosts cannot be observed from a
|
|
34
|
+
* Claude Code session at all, and the roadmap's K6 makes that an outcome: the
|
|
35
|
+
* host keeps its full rule bodies and loses nothing while it waits.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import * as fs from 'node:fs';
|
|
39
|
+
import * as path from 'node:path';
|
|
40
|
+
|
|
41
|
+
/** Where the committed observations live. One file, so there is one place to audit. */
|
|
42
|
+
export const RECORD_REL = path.join('src', 'config', 'host-injection-effect.json');
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The three states, and the reason there is no fourth.
|
|
46
|
+
*
|
|
47
|
+
* `observed-false` is not "probably not" — it is a recorded observation that a
|
|
48
|
+
* delivered body did NOT reach the model, which is as citable as the positive.
|
|
49
|
+
* Anything not observed in either direction is `unobserved`.
|
|
50
|
+
*/
|
|
51
|
+
export const INJECTION_STATES = ['observed-true', 'observed-false', 'unobserved'] as const;
|
|
52
|
+
export type InjectionState = (typeof INJECTION_STATES)[number];
|
|
53
|
+
|
|
54
|
+
export interface InjectionObservation {
|
|
55
|
+
readonly state: InjectionState;
|
|
56
|
+
/** Host version the observation was taken on. Required for an `observed-*` state. */
|
|
57
|
+
readonly host_version: string | null;
|
|
58
|
+
/** Transcript or artefact reference. Required for an `observed-*` state. */
|
|
59
|
+
readonly transcript: string | null;
|
|
60
|
+
/** ISO date of the observation. Required for an `observed-*` state. */
|
|
61
|
+
readonly date: string | null;
|
|
62
|
+
/** Why it reads the way it does — required always, including for `unobserved`. */
|
|
63
|
+
readonly reason: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type InjectionRecord = Readonly<Record<string, InjectionObservation>>;
|
|
67
|
+
|
|
68
|
+
/** A malformed or under-cited row, in the words a maintainer needs to fix it. */
|
|
69
|
+
export interface InjectionProblem {
|
|
70
|
+
readonly host: string;
|
|
71
|
+
readonly message: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate a record.
|
|
76
|
+
*
|
|
77
|
+
* The citation requirement is enforced HERE rather than at the reader, so every
|
|
78
|
+
* consumer gets the same answer: an `observed-*` row without all three citation
|
|
79
|
+
* fields is a problem, and `unobserved` needs only its reason.
|
|
80
|
+
*/
|
|
81
|
+
export function recordProblems(rec: InjectionRecord): InjectionProblem[] {
|
|
82
|
+
const out: InjectionProblem[] = [];
|
|
83
|
+
for (const [host, obs] of Object.entries(rec)) {
|
|
84
|
+
// A leading `_comment` is the convention every config JSON in this tree
|
|
85
|
+
// uses to carry its own contract, and it is not a host row. Skipping the
|
|
86
|
+
// underscore prefix rather than a literal `_comment` keeps a second
|
|
87
|
+
// annotation key from becoming a fake host with a fake verdict.
|
|
88
|
+
if (host.startsWith('_')) continue;
|
|
89
|
+
if (!(INJECTION_STATES as readonly string[]).includes(obs.state)) {
|
|
90
|
+
out.push({ host, message: `state ${JSON.stringify(obs.state)} is not one of ${INJECTION_STATES.join(' | ')}` });
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (typeof obs.reason !== 'string' || obs.reason.trim().length < 20) {
|
|
94
|
+
out.push({ host, message: 'reason is missing or shorter than 20 characters — an unexplained row reads as answered' });
|
|
95
|
+
}
|
|
96
|
+
if (obs.state === 'unobserved') continue;
|
|
97
|
+
for (const field of ['host_version', 'transcript', 'date'] as const) {
|
|
98
|
+
const v = obs[field];
|
|
99
|
+
if (typeof v !== 'string' || v.trim() === '') {
|
|
100
|
+
out.push({
|
|
101
|
+
host,
|
|
102
|
+
message:
|
|
103
|
+
`state ${obs.state} without \`${field}\` — the observation protocol ` +
|
|
104
|
+
'(host · host version · transcript · date) admits no partial citation',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Load the committed record. A missing file is an empty record, never a throw. */
|
|
113
|
+
export function loadRecord(root: string): InjectionRecord {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(fs.readFileSync(path.join(root, RECORD_REL), 'utf-8')) as InjectionRecord;
|
|
116
|
+
} catch {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The state for one host — `unobserved` when the record says nothing.
|
|
123
|
+
*
|
|
124
|
+
* Defaulting to `unobserved` rather than to a computed guess is the whole
|
|
125
|
+
* contract: a host nobody has looked at must never inherit a neighbour's
|
|
126
|
+
* verdict, and a bound slot must never imply a delivered body.
|
|
127
|
+
*/
|
|
128
|
+
export function stateFor(rec: InjectionRecord, host: string): InjectionState {
|
|
129
|
+
return rec[host]?.state ?? 'unobserved';
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Is the concern bound on this host's slot? Computed from the manifest.
|
|
134
|
+
*
|
|
135
|
+
* Deliberately a separate function from `stateFor`, with a name that cannot be
|
|
136
|
+
* mistaken for it. This answers "could a body be emitted here", never "did one
|
|
137
|
+
* arrive".
|
|
138
|
+
*/
|
|
139
|
+
export function concernBound(
|
|
140
|
+
platforms: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>>,
|
|
141
|
+
host: string,
|
|
142
|
+
slot: string,
|
|
143
|
+
concern: string,
|
|
144
|
+
): boolean {
|
|
145
|
+
return (platforms[host]?.[slot] ?? []).includes(concern);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Does this host admit to `lean_projection.hosts` under E3? Observation only. */
|
|
149
|
+
export function admissibleUnderE3(rec: InjectionRecord, host: string): boolean {
|
|
150
|
+
return stateFor(rec, host) === 'observed-true';
|
|
151
|
+
}
|
|
@@ -54,12 +54,16 @@ const TYPES = ['rules', 'skills', 'commands', 'personas', 'user-types', 'agents'
|
|
|
54
54
|
export function overlapFindings(homeDir: string, projectRoot: string): string[] {
|
|
55
55
|
const findings: string[] = [];
|
|
56
56
|
for (const type of TYPES) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
// `commands` keys on the POSIX SUBPATH, matching
|
|
58
|
+
// `check_single_delivery`'s own key. Top-level names are the CLUSTER, and
|
|
59
|
+
// since per-name withholding (2026-09-07) the project layer holds exactly
|
|
60
|
+
// the commands the host lacks — which can share a cluster with commands the
|
|
61
|
+
// host has. Observed the same day: `analyze/` in both layers over two
|
|
62
|
+
// disjoint command sets, reported here as `commands=1`. This notice and
|
|
63
|
+
// that gate must not disagree about the same question.
|
|
64
|
+
const g = _entries(path.join(homeDir, '.claude', type), type === 'commands');
|
|
65
|
+
const p = _entries(path.join(projectRoot, '.claude', type), type === 'commands');
|
|
66
|
+
if (g === null || p === null) {
|
|
63
67
|
continue;
|
|
64
68
|
}
|
|
65
69
|
const gset = new Set(g);
|
|
@@ -69,6 +73,46 @@ export function overlapFindings(homeDir: string, projectRoot: string): string[]
|
|
|
69
73
|
return findings;
|
|
70
74
|
}
|
|
71
75
|
|
|
76
|
+
/**
|
|
77
|
+
* One layer's entry names — top level, or every `*.md` subpath when `recursive`.
|
|
78
|
+
*
|
|
79
|
+
* `null` means the directory is not readable, which the caller must treat as "skip
|
|
80
|
+
* this type": one layer absent means nothing is doubled there.
|
|
81
|
+
*/
|
|
82
|
+
function _entries(dir: string, recursive: boolean): string[] | null {
|
|
83
|
+
if (!recursive) {
|
|
84
|
+
try {
|
|
85
|
+
return fs.readdirSync(dir);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
let top: fs.Dirent[];
|
|
91
|
+
try {
|
|
92
|
+
top = fs.readdirSync(dir, { withFileTypes: true });
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const out: string[] = [];
|
|
97
|
+
const walk = (kids: fs.Dirent[], abs: string, rel: string): void => {
|
|
98
|
+
for (const k of kids) {
|
|
99
|
+
const childRel = rel === '' ? k.name : `${rel}/${k.name}`;
|
|
100
|
+
if (k.isDirectory()) {
|
|
101
|
+
try {
|
|
102
|
+
walk(fs.readdirSync(path.join(abs, k.name), { withFileTypes: true }), path.join(abs, k.name), childRel);
|
|
103
|
+
} catch {
|
|
104
|
+
// An unreadable subdirectory contributes no names; the layer
|
|
105
|
+
// itself was readable, so the type stays compared.
|
|
106
|
+
}
|
|
107
|
+
} else if (k.name.endsWith('.md') && k.name !== 'README.md') {
|
|
108
|
+
out.push(childRel);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
walk(top, dir, '');
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
72
116
|
/** Render the notice, or `null` when there is nothing to say. */
|
|
73
117
|
export function overlapNotice(homeDir: string, projectRoot: string): string | null {
|
|
74
118
|
const findings = overlapFindings(homeDir, projectRoot);
|