@event4u/agent-config 16.0.0 → 16.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +297 -137
  5. package/CONTRIBUTING.md +9 -0
  6. package/README.md +2 -2
  7. package/dist/agent-src/commands/analyze/inbox.md +26 -0
  8. package/dist/agent-src/commands/analyze/roadmap-repos.md +27 -0
  9. package/dist/agent-src/commands/challenge-me/closure.md +178 -0
  10. package/dist/agent-src/commands/challenge-me.md +9 -2
  11. package/dist/agent-src/commands/council/default.md +25 -5
  12. package/dist/agent-src/commands/feature/plan.md +28 -1
  13. package/dist/agent-src/commands/feature/roadmap.md +28 -1
  14. package/dist/agent-src/commands/implement-ticket.md +27 -0
  15. package/dist/agent-src/commands/jira-ticket.md +27 -0
  16. package/dist/agent-src/commands/pr/create.md +29 -0
  17. package/dist/agent-src/commands/pr/merge.md +60 -9
  18. package/dist/agent-src/commands/roadmap/ai-council.md +7 -1
  19. package/dist/agent-src/commands/roadmap/create.md +74 -27
  20. package/dist/agent-src/commands/roadmap/materialize.md +27 -0
  21. package/dist/agent-src/commands/roadmap/process-full.md +123 -18
  22. package/dist/agent-src/commands/team.md +22 -0
  23. package/dist/agent-src/contexts/authority/scope-growth-ownership.md +80 -0
  24. package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +47 -0
  25. package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +44 -3
  26. package/dist/agent-src/contexts/execution/autonomy-mechanics.md +15 -15
  27. package/dist/agent-src/contexts/execution/decision-closure-execution.md +93 -0
  28. package/dist/agent-src/contexts/execution/fix-loop-ladder.md +108 -0
  29. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +4 -2
  30. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +75 -6
  31. package/dist/agent-src/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  32. package/dist/agent-src/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  33. package/dist/agent-src/guidelines/design-fidelity-routing.md +29 -0
  34. package/dist/agent-src/rules/autonomous-execution.md +15 -8
  35. package/dist/agent-src/rules/delegation-policy.md +23 -45
  36. package/dist/agent-src/rules/design-fidelity.md +32 -37
  37. package/dist/agent-src/rules/evaluator-independence.md +43 -49
  38. package/dist/agent-src/rules/test-first.md +41 -0
  39. package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
  40. package/dist/agent-src/skills/ai-council/SKILL.md +11 -3
  41. package/dist/agent-src/skills/api-design/data/manifest.json +3 -3
  42. package/dist/agent-src/skills/database/data/manifest.json +4 -4
  43. package/dist/agent-src/skills/database/data/query-tuning.csv +3 -3
  44. package/dist/agent-src/skills/design-review/SKILL.md +50 -0
  45. package/dist/agent-src/skills/existing-ui-audit/SKILL.md +14 -4
  46. package/dist/agent-src/skills/experiment-loop/SKILL.md +31 -1
  47. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  48. package/dist/agent-src/skills/storybook-workshop/SKILL.md +42 -3
  49. package/dist/agent-src/skills/threat-modeling/SKILL.md +9 -4
  50. package/dist/agent-src/skills/threat-modeling/data/manifest.json +4 -4
  51. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +37 -3
  52. package/dist/agent-src/templates/agent-settings.md +1 -1
  53. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  54. package/dist/agent-src/templates/roadmaps.md +188 -0
  55. package/dist/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  56. package/dist/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  57. package/dist/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  58. package/dist/cli-delegate/check_update_banner.js +40 -2
  59. package/dist/cli-delegate/chunk-4E2W63DA.js +335 -0
  60. package/dist/cli-delegate/chunk-5R7HOLAF.js +99 -0
  61. package/dist/cli-delegate/{chunk-IVBPRCQO.js → chunk-6KFEC3G7.js} +1 -1
  62. package/dist/cli-delegate/{chunk-2TAQ2K4V.js → chunk-6KM6ORRY.js} +55 -386
  63. package/dist/cli-delegate/{chunk-2XY7ILBT.js → chunk-FC75PGJE.js} +42 -2
  64. package/dist/cli-delegate/{chunk-GYC3VRYN.js → chunk-FXYUYU4N.js} +57 -13
  65. package/dist/cli-delegate/{chunk-55ACLDUE.js → chunk-GJCBB2UG.js} +168 -10
  66. package/dist/cli-delegate/{chunk-AK6HODNQ.js → chunk-HDVVGPQW.js} +873 -539
  67. package/dist/cli-delegate/{chunk-H32DGUJH.js → chunk-P6ZHP5W2.js} +1 -1
  68. package/dist/cli-delegate/{chunk-57PN4GYJ.js → chunk-RGNIWTEA.js} +1 -1
  69. package/dist/cli-delegate/chunk-ROYYTEZU.js +39 -0
  70. package/dist/cli-delegate/{chunk-3FKR64R5.js → chunk-UNCRJBT3.js} +31 -7
  71. package/dist/cli-delegate/{chunk-2DK2VJWG.js → chunk-WGXTVKCQ.js} +20 -6
  72. package/dist/cli-delegate/chunk-YDPILCOL.js +152 -0
  73. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  74. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  75. package/dist/cli-delegate/cmd_conformance.js +90 -56
  76. package/dist/cli-delegate/cmd_doctor.js +7 -5
  77. package/dist/cli-delegate/cmd_explain.js +2 -2
  78. package/dist/cli-delegate/cmd_export.js +9 -7
  79. package/dist/cli-delegate/cmd_fleet.js +4 -2
  80. package/dist/cli-delegate/cmd_handoff.js +4 -4
  81. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  82. package/dist/cli-delegate/cmd_migrate.js +2 -2
  83. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  84. package/dist/cli-delegate/cmd_preflight.js +4 -2
  85. package/dist/cli-delegate/cmd_prune.js +10 -8
  86. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  87. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  88. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  89. package/dist/cli-delegate/cmd_settings_get.js +28 -2
  90. package/dist/cli-delegate/cmd_settings_migrate.js +9 -7
  91. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  92. package/dist/cli-delegate/cmd_sync.js +10 -8
  93. package/dist/cli-delegate/cmd_uninstall.js +16 -13
  94. package/dist/cli-delegate/cmd_update.js +1 -1
  95. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  96. package/dist/cli-delegate/cmd_validate.js +12 -10
  97. package/dist/cli-delegate/cmd_versions.js +1 -1
  98. package/dist/discovery/deprecation-report.md +1 -1
  99. package/dist/discovery/discovery-manifest.json +85 -38
  100. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  101. package/dist/discovery/discovery-manifest.summary.md +4 -4
  102. package/dist/discovery/dormancy-report.md +1 -1
  103. package/dist/discovery/orphan-report.md +1 -1
  104. package/dist/discovery/packs.json +10 -8
  105. package/dist/discovery/trust-report.md +3 -3
  106. package/dist/discovery/workspaces.json +8 -6
  107. package/dist/hooks/dispatch.js +1832 -1007
  108. package/dist/install/claudeRuleRewrite.js +14 -3
  109. package/dist/install/conflict.js +60 -10
  110. package/dist/install/conflictTracker.js +218 -0
  111. package/dist/install/fsPrimitives.js +54 -0
  112. package/dist/install/install.mjs +2007 -1481
  113. package/dist/install/manifestFiles.js +259 -0
  114. package/dist/install/preflight.js +14 -2
  115. package/dist/install/preserve.js +157 -0
  116. package/dist/install/recordedOwnership.js +132 -0
  117. package/dist/install/txlog.js +11 -2
  118. package/dist/mcp/registry-manifest.json +3 -3
  119. package/dist/mcp/server.json +2 -2
  120. package/dist/mcp/server.mjs +398 -39
  121. package/dist/router.json +1 -1
  122. package/dist/scripts/_lib/agent_settings.js +3 -2
  123. package/dist/scripts/_lib/fs_atomic.js +167 -0
  124. package/dist/scripts/_lib/installed_tools.js +460 -0
  125. package/dist/scripts/_lib/lean_projection_mode.js +3 -2
  126. package/dist/scripts/_lib/settings_renamed_keys.js +78 -0
  127. package/dist/server/routes/install.js +8 -0
  128. package/dist/server/routes/wizard.js +9 -3
  129. package/dist/server/schemas/settings.js +17 -5
  130. package/dist/shared/missionExecution.js +97 -0
  131. package/dist/ui/assets/{index-D0jaCRPM.js → index-DH1xc62v.js} +17 -17
  132. package/dist/ui/index.html +1 -1
  133. package/docs/MIGRATION.md +103 -0
  134. package/docs/contracts/settings-classes.md +26 -7
  135. package/docs/guidelines/agent-infra/active-remediation-mechanics.md +57 -0
  136. package/docs/guidelines/agent-infra/evaluator-independence-mechanics.md +102 -0
  137. package/docs/guidelines/design-fidelity-routing.md +29 -0
  138. package/package.json +1 -1
  139. package/src/agent-src/templates/scripts/work_engine/directives/backend/_self_fix.ts +62 -2
  140. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +1 -0
  141. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +1 -0
  142. package/src/config/agent-settings.template.yml +139 -27
  143. package/src/config/auto-rule-budget.json +16 -0
  144. package/src/config/ci-local-parity.yml +18 -5
  145. package/src/config/continuity-surface.json +21 -0
  146. package/src/config/gate-coverage.yml +146 -13
  147. package/src/config/gate-violation-baselines.json +6 -6
  148. package/src/config/hook-token-budget.json +2 -0
  149. package/src/config/loop-surfaces.yaml +215 -0
  150. package/src/config/platform-anchor.json +3 -2
  151. package/src/config/release-gate-locality.yml +11 -0
  152. package/src/config/routing-coverage-seed.json +18 -4
  153. package/src/config/rule-enforcement-baseline.json +0 -1
  154. package/src/install/claudeRuleRewrite.ts +16 -2
  155. package/src/install/conflict.ts +65 -9
  156. package/src/install/conflictTracker.ts +299 -0
  157. package/src/install/fsPrimitives.ts +54 -0
  158. package/src/install/manifestFiles.ts +312 -0
  159. package/src/install/preflight.ts +16 -2
  160. package/src/install/preserve.ts +205 -0
  161. package/src/install/recordedOwnership.ts +149 -0
  162. package/src/install/txlog.ts +11 -2
  163. package/src/install/types.ts +19 -4
  164. package/src/scripts/_cli/cmd_conformance.ts +88 -15
  165. package/src/scripts/_cli/cmd_doctor.ts +4 -3
  166. package/src/scripts/_cli/cmd_route_explain.ts +9 -2
  167. package/src/scripts/_cli/cmd_settings_get.ts +44 -0
  168. package/src/scripts/_cli/doctor_execution.ts +90 -0
  169. package/src/scripts/_lib/activation_payload.ts +247 -0
  170. package/src/scripts/_lib/agent_settings.ts +4 -2
  171. package/src/scripts/_lib/authority_path.ts +102 -0
  172. package/src/scripts/_lib/cascade_base.ts +94 -0
  173. package/src/scripts/_lib/changelog_eras.ts +7 -2
  174. package/src/scripts/_lib/continuation_ladder.ts +149 -2
  175. package/src/scripts/_lib/council_transport.ts +129 -0
  176. package/src/scripts/_lib/delivery_ready.ts +112 -0
  177. package/src/scripts/_lib/forge_protection.ts +267 -0
  178. package/src/scripts/_lib/hook_platform_keys.ts +27 -0
  179. package/src/scripts/_lib/json_python_parity.ts +127 -0
  180. package/src/scripts/_lib/lean_projection_mode.ts +14 -3
  181. package/src/scripts/_lib/loop_surfaces.ts +382 -0
  182. package/src/scripts/_lib/mission_record.ts +190 -0
  183. package/src/scripts/_lib/obligation_frequency.ts +217 -0
  184. package/src/scripts/_lib/obligations.ts +402 -0
  185. package/src/scripts/_lib/orchestration_record.ts +11 -20
  186. package/src/scripts/_lib/release_highlights.ts +199 -10
  187. package/src/scripts/_lib/release_holds.ts +493 -0
  188. package/src/scripts/_lib/release_migration_gate.ts +184 -0
  189. package/src/scripts/_lib/rides_along.ts +102 -0
  190. package/src/scripts/_lib/settings_renamed_keys.ts +82 -0
  191. package/src/scripts/_lib/test_delta.ts +166 -0
  192. package/src/scripts/_lib/test_provenance.ts +171 -0
  193. package/src/scripts/_lib/typed_op_grant.ts +246 -0
  194. package/src/scripts/_lib/typed_op_watch.ts +185 -0
  195. package/src/scripts/ai_council/config.ts +8 -11
  196. package/src/scripts/ai_council/fallback_config.ts +5 -3
  197. package/src/scripts/ai_council/ownership_classes.ts +118 -0
  198. package/src/scripts/ask_block_census.ts +285 -15
  199. package/src/scripts/check_enforcement_matrix.ts +624 -0
  200. package/src/scripts/check_gate_reachability.ts +173 -10
  201. package/src/scripts/check_generator_sync.ts +954 -0
  202. package/src/scripts/check_preamble_payload_budget.ts +75 -2
  203. package/src/scripts/check_release_highlights.ts +33 -1
  204. package/src/scripts/check_release_holds.ts +567 -0
  205. package/src/scripts/check_rule_projection_integrity.ts +22 -12
  206. package/src/scripts/check_test_delta.ts +294 -0
  207. package/src/scripts/check_test_weakening.ts +299 -0
  208. package/src/scripts/closure_scan.ts +333 -0
  209. package/src/scripts/compile_hook_manifest.ts +43 -3
  210. package/src/scripts/council_record_shape.ts +209 -0
  211. package/src/scripts/hook_manifest.json +1 -1
  212. package/src/scripts/hook_manifest.yaml +88 -9
  213. package/src/scripts/hooks/concern_registry.ts +4 -0
  214. package/src/scripts/hooks/design_pass_hook.ts +128 -3
  215. package/src/scripts/hooks/evidence_independence.ts +96 -0
  216. package/src/scripts/hooks/memory_recall_hook.ts +540 -0
  217. package/src/scripts/hooks/obligation_settle_hook.ts +182 -0
  218. package/src/scripts/hooks/rule_inject_hook.ts +49 -1
  219. package/src/scripts/hooks/run_continuation_hook.ts +17 -20
  220. package/src/scripts/hooks_doctor.ts +85 -0
  221. package/src/scripts/hooks_status.ts +19 -0
  222. package/src/scripts/install.ts +106 -206
  223. package/src/scripts/janitor.ts +7 -0
  224. package/src/scripts/lint_decision_classes.ts +464 -0
  225. package/src/scripts/lint_hook_manifest.ts +11 -0
  226. package/src/scripts/lint_major_migration_sections.ts +450 -0
  227. package/src/scripts/lint_plan_risk_register.ts +47 -3
  228. package/src/scripts/lint_roadmap_blockers.ts +61 -0
  229. package/src/scripts/lint_roadmap_producers.ts +202 -0
  230. package/src/scripts/measure_rule_budget.ts +101 -5
  231. package/src/scripts/new_roadmap.ts +37 -0
  232. package/src/scripts/orchestration_record.ts +0 -5
  233. package/src/scripts/release.ts +14 -98
  234. package/src/scripts/release_drill.ts +267 -1
  235. package/src/scripts/release_publication.ts +51 -0
  236. package/src/scripts/report_enforcement_drift.ts +566 -0
  237. package/src/scripts/report_host_injection_effect.ts +3 -11
  238. package/src/scripts/report_standing_payload_by_host.ts +26 -152
  239. package/src/scripts/schemas/agent-settings.schema.json +23 -0
  240. package/src/scripts/schemas/command.schema.json +4 -0
  241. package/src/scripts/scope_growth.ts +219 -0
  242. package/src/scripts/trigger_eval_grandfather.json +0 -1
  243. package/src/scripts/ui_conformance_probe.ts +622 -0
  244. package/src/server/schemas/settings.ts +31 -7
  245. package/src/shared/missionExecution.ts +126 -0
  246. package/dist/cli-delegate/chunk-523HCFYO.js +0 -90
@@ -0,0 +1,312 @@
1
+ /**
2
+ * The installed-tools manifest's per-tool `files[]` inventory — building it,
3
+ * reading back what a previous install recorded, and reconciling the two.
4
+ *
5
+ * THE GAP THIS CLOSES.
6
+ *
7
+ * `files[].sha256` is the only record of what this installer wrote where. The
8
+ * conflict tracker added in Phase 5.1 reads exactly that field to tell a
9
+ * managed file still carrying our bytes from one the user has edited
10
+ * (`recordedOwnership.classifyOwnership`). A path with no recorded digest
11
+ * classifies `unknown`, and `unknown` writes.
12
+ *
13
+ * So a run that DROPS a recorded digest does not merely lose bookkeeping — it
14
+ * re-arms the overwrite that preservation exists to prevent, one run later.
15
+ * Three drops were live, and all three end in the same silent data loss:
16
+ *
17
+ * 1. **A preserved file was never recorded.** The writer pushes a path into
18
+ * `written_paths` only when it actually copies; the preserve branch returns
19
+ * early. The manifest is rebuilt from `written_paths`, so the very run that
20
+ * protected an edit erased the digest proving it was an edit. The next run
21
+ * saw `unknown` and overwrote — preservation survived exactly one install.
22
+ * 2. **A failed deploy wiped its tool's whole inventory.** `deploy_failed`
23
+ * produced an empty list, and an empty list is authoritative replacement,
24
+ * not "nothing to say".
25
+ * 3. **Every tool outside the current selection lost its inventory too.**
26
+ * `installed_tools.read_manifest` is a hand-rolled parser that drops nested
27
+ * `files[]` by design (ADR-200), so the entries carried into the rewrite
28
+ * came back empty and were written back empty. `upsert_tool`'s "keep what
29
+ * was there" fallback could never fire, because nothing was ever there by
30
+ * the time it looked.
31
+ *
32
+ * (3) is why this module hydrates rather than patching the preserve branch:
33
+ * the same digest loss reaches the same file through three doors, and repairing
34
+ * one leaves the other two open.
35
+ *
36
+ * WHY A FOURTH READER OF THIS FILE.
37
+ *
38
+ * `installed_tools.read_manifest` (degraded by design), `install_drift`
39
+ * (digests it re-hashes itself) and `recordedOwnership.readRecordedHashes` (a
40
+ * flat path→digest map, no tool attribution, no `kind`) already read it. None
41
+ * answers the question here — *which tool recorded which entries, verbatim,
42
+ * so they can be written back unchanged* — and widening either of the latter
43
+ * two would change a shape their own tests pin. What this module does not
44
+ * duplicate is `manifest_path()`: that stays the single place deciding where
45
+ * the manifest is, so the `AGENT_CONFIG_INSTALLED_TOOLS` override behaves
46
+ * identically in all four.
47
+ */
48
+
49
+ import { existsSync, readFileSync } from 'node:fs';
50
+ import { homedir } from 'node:os';
51
+ import { isAbsolute, join, resolve } from 'node:path';
52
+
53
+ import * as YAML from 'yaml';
54
+
55
+ import { sha256OfFile } from './fsPrimitives.js';
56
+ import { sidecarPathFor } from './preserve.js';
57
+
58
+ /** One `tools[].files[]` record, in the shape the manifest writer emits. */
59
+ export type ManifestFileEntry = Record<string, unknown>;
60
+
61
+ /** A recorded entry plus the absolute path it resolves to on this machine. */
62
+ export interface RecordedFile {
63
+ /** The entry exactly as recorded — written back byte-identical. */
64
+ readonly entry: ManifestFileEntry;
65
+ /** `entry.path` with `~` expanded and resolved against the project root. */
66
+ readonly resolved: string;
67
+ }
68
+
69
+ /** What a previous install recorded for one tool. */
70
+ export interface RecordedToolInventory {
71
+ readonly files: readonly RecordedFile[];
72
+ readonly mergedKeys: readonly ManifestFileEntry[];
73
+ }
74
+
75
+ /** Tool id → what the manifest records for it. */
76
+ export type RecordedByTool = ReadonlyMap<string, RecordedToolInventory>;
77
+
78
+ /** Build one `files[]` record for `p`. */
79
+ export function fileEntry(p: string, kind: string, hashContent: boolean): ManifestFileEntry {
80
+ return { path: p, kind, sha256: hashContent ? sha256OfFile(p) : null };
81
+ }
82
+
83
+ /** A deploy tuple: `[written, skipped, status, paths]`. */
84
+ export type DeployTuple = readonly [number, number, string, string[]];
85
+
86
+ /**
87
+ * Turn deploy results into the per-tool inventory this run is authoritative for.
88
+ *
89
+ * `null` — not an empty list — for a tool whose deploy did not complete. The
90
+ * distinction is the whole point: an empty list says "this tool owns no files
91
+ * now", which replaces and therefore erases; `null` says "this run learned
92
+ * nothing about this tool", which is what a failed deploy actually knows, and
93
+ * the caller carries the previous inventory forward instead.
94
+ */
95
+ export function filesByToolFromDeploy(
96
+ deployResults: Record<string, DeployTuple>,
97
+ ): Record<string, ManifestFileEntry[] | null> {
98
+ const out: Record<string, ManifestFileEntry[] | null> = {};
99
+ for (const toolId of Object.keys(deployResults)) {
100
+ const [, , status, paths] = deployResults[toolId] as DeployTuple;
101
+ if (status === 'deployed') {
102
+ out[toolId] = paths.map((p) => fileEntry(p, 'deployed', true));
103
+ } else if (status === 'marker') {
104
+ out[toolId] = paths.map((p) => fileEntry(p, 'marker', true));
105
+ } else {
106
+ out[toolId] = null;
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+
112
+ /**
113
+ * The bridge marker each selected tool claims, as a one-entry inventory.
114
+ *
115
+ * `bridgeMarker` is injected because the marker table lives with the installer's
116
+ * tool registry; passing it keeps that registry in one place instead of giving
117
+ * this module a second copy to drift from. Bridge entries are recorded WITHOUT
118
+ * a digest — a bridge is a pointer the consumer owns and edits, so there is
119
+ * nothing for this tree to claim ownership of, and `classifyOwnership` reads a
120
+ * null digest as `unknown` and leaves the file alone.
121
+ */
122
+ export function filesByToolFromBridges(
123
+ tools: Set<string>,
124
+ projectRoot: string,
125
+ scope: string,
126
+ bridgeMarker: (toolId: string, scope: string) => string,
127
+ ): Record<string, ManifestFileEntry[]> {
128
+ const out: Record<string, ManifestFileEntry[]> = {};
129
+ for (const toolId of [...tools].sort()) {
130
+ const marker = bridgeMarker(toolId, scope);
131
+ if (!marker) continue;
132
+ const markerPath = isAbsolute(marker) ? marker : join(projectRoot, marker);
133
+ out[toolId] = [fileEntry(markerPath, 'bridge', false)];
134
+ }
135
+ return out;
136
+ }
137
+
138
+ /** Expand a leading `~`, matching `installed_tools.expanduser`. */
139
+ function expanduser(p: string): string {
140
+ if (p === '~') return homedir();
141
+ if (p.startsWith('~/') || (process.platform === 'win32' && p.startsWith('~\\'))) {
142
+ return join(homedir(), p.slice(2));
143
+ }
144
+ return p;
145
+ }
146
+
147
+ /** Resolve a manifest-recorded path the way every other reader of this file does. */
148
+ function resolveRecorded(raw: string, projectRoot: string): string {
149
+ const expanded = expanduser(raw);
150
+ return isAbsolute(expanded) ? resolve(expanded) : resolve(projectRoot, expanded);
151
+ }
152
+
153
+ /**
154
+ * Read `tools[].files[]` and `tools[].merged_keys[]` per tool.
155
+ *
156
+ * A missing, unreadable or malformed manifest yields an empty map rather than
157
+ * throwing — an install must never fail because a lockfile is corrupt, and an
158
+ * empty answer degrades to exactly the behaviour this module replaced.
159
+ */
160
+ export function readRecordedByTool(manifestPath: string, projectRoot: string): RecordedByTool {
161
+ const out = new Map<string, RecordedToolInventory>();
162
+ if (!existsSync(manifestPath)) return out;
163
+ let doc: unknown;
164
+ try {
165
+ doc = YAML.parse(readFileSync(manifestPath, 'utf8'));
166
+ } catch {
167
+ return out;
168
+ }
169
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return out;
170
+ const tools = (doc as Record<string, unknown>)['tools'];
171
+ if (!Array.isArray(tools)) return out;
172
+
173
+ for (const tool of tools) {
174
+ if (tool === null || typeof tool !== 'object' || Array.isArray(tool)) continue;
175
+ const rec = tool as Record<string, unknown>;
176
+ const name = rec['name'];
177
+ if (typeof name !== 'string' || name.length === 0) continue;
178
+ const files: RecordedFile[] = [];
179
+ const rawFiles = rec['files'];
180
+ if (Array.isArray(rawFiles)) {
181
+ for (const entry of rawFiles) {
182
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue;
183
+ const raw = (entry as Record<string, unknown>)['path'];
184
+ if (typeof raw !== 'string' || raw.length === 0) continue;
185
+ files.push({
186
+ entry: entry as ManifestFileEntry,
187
+ resolved: resolveRecorded(raw, projectRoot),
188
+ });
189
+ }
190
+ }
191
+ const rawMerged = rec['merged_keys'];
192
+ const mergedKeys = Array.isArray(rawMerged) ? (rawMerged as ManifestFileEntry[]) : [];
193
+ out.set(name, { files, mergedKeys });
194
+ }
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * Put the nested inventories back onto entries read by the degraded parser.
200
+ *
201
+ * `installed_tools.read_manifest` returns every tool with `files: []` and
202
+ * `merged_keys: []` whatever the file held, and those entries are what the
203
+ * rewrite carries forward for tools the current run does not touch. Without
204
+ * this, installing tool A erases tool B's recorded digests — and B's managed
205
+ * files then classify `unknown` and get overwritten the next time B installs.
206
+ *
207
+ * Mutates in place and returns the same array: the caller's `entries` list is
208
+ * already its own copy, and rebuilding it would drop unknown-but-recorded
209
+ * keys that this module has no business deciding about.
210
+ */
211
+ export function hydrateRecordedInventories(
212
+ entries: ManifestFileEntry[],
213
+ prior: RecordedByTool,
214
+ ): ManifestFileEntry[] {
215
+ for (const entry of entries) {
216
+ const name = entry['name'];
217
+ if (typeof name !== 'string') continue;
218
+ const recorded = prior.get(name);
219
+ if (recorded === undefined) continue;
220
+ if (Array.isArray(entry['files']) && (entry['files'] as unknown[]).length === 0) {
221
+ entry['files'] = recorded.files.map((f) => f.entry);
222
+ }
223
+ if (Array.isArray(entry['merged_keys']) && (entry['merged_keys'] as unknown[]).length === 0) {
224
+ entry['merged_keys'] = [...recorded.mergedKeys];
225
+ }
226
+ }
227
+ return entries;
228
+ }
229
+
230
+ /**
231
+ * The inventory to record for one tool this run.
232
+ *
233
+ * - `fresh === null | undefined` → the previous inventory, unchanged. A run
234
+ * that learned nothing about a tool records what was already known.
235
+ * - otherwise → what this run wrote, plus a carried-forward entry for every
236
+ * PRESERVED path this tool recorded before.
237
+ *
238
+ * The carried entry keeps the path and kind it had, and takes the digest of
239
+ * the content STAGED this run. Both halves are load-bearing.
240
+ *
241
+ * The digest means "the package content this installer last produced for this
242
+ * path". A preserved run produced that content too — it staged it in the
243
+ * sidecar instead of writing it — so recording it keeps the field's meaning
244
+ * exact and makes the documented resolution work: merge the sidecar and the
245
+ * file matches, so the conflict clears and the next install refreshes it
246
+ * normally. Carrying the OLDER digest instead would leave a correctly merged
247
+ * file looking modified forever, resolvable only with `--force`.
248
+ *
249
+ * What it must never be is a re-hash of the target. That records the USER's
250
+ * content as ours, and the run after that classifies the edit
251
+ * `recorded-unchanged` and overwrites it — the same data loss, one
252
+ * indirection further away.
253
+ */
254
+ export function reconcileToolFiles(
255
+ fresh: readonly ManifestFileEntry[] | null | undefined,
256
+ prior: RecordedToolInventory | undefined,
257
+ preserved: PreservedIndex,
258
+ ): ManifestFileEntry[] | null {
259
+ if (fresh === null || fresh === undefined) {
260
+ return prior === undefined ? null : prior.files.map((f) => f.entry);
261
+ }
262
+ if (prior === undefined || preserved.size === 0) return [...fresh];
263
+ const seen = new Set<string>();
264
+ for (const entry of fresh) {
265
+ const raw = entry['path'];
266
+ if (typeof raw === 'string') seen.add(resolve(expanduser(raw)));
267
+ }
268
+ const out = [...fresh];
269
+ for (const file of prior.files) {
270
+ const staged = preserved.get(file.resolved);
271
+ if (staged === undefined || seen.has(file.resolved)) continue;
272
+ out.push(staged.sha256 === null ? file.entry : { ...file.entry, sha256: staged.sha256 });
273
+ if (staged.sidecar !== null) out.push(staged.sidecar);
274
+ }
275
+ return out;
276
+ }
277
+
278
+ /**
279
+ * Resolved target path → the manifest entry for the sidecar staged beside it.
280
+ *
281
+ * The attribution problem this solves: a preserved path is not in any tool's
282
+ * written list, so nothing in the deploy results says which tool owns it. The
283
+ * PREVIOUS manifest does — it recorded that path under a tool — so
284
+ * {@link reconcileToolFiles} matches on the prior inventory and the sidecar
285
+ * rides along with its target rather than being filed by guesswork.
286
+ */
287
+ export type PreservedIndex = ReadonlyMap<string, PreservedRecord>;
288
+
289
+ /** What one preserved target contributes to the manifest. */
290
+ export interface PreservedRecord {
291
+ /** Digest of the content staged for it, or `null` when nothing was staged. */
292
+ readonly sha256: string | null;
293
+ /** The sidecar's own entry — this run's proof that it staged that file. */
294
+ readonly sidecar: ManifestFileEntry | null;
295
+ }
296
+
297
+ /** Build the {@link PreservedIndex} from the tracker's per-run state. */
298
+ export function preservedIndex(
299
+ preservedTargets: readonly string[],
300
+ staged: ReadonlyMap<string, string | null>,
301
+ ): PreservedIndex {
302
+ const out = new Map<string, PreservedRecord>();
303
+ for (const target of preservedTargets) {
304
+ const sidecar = sidecarPathFor(target);
305
+ const sha = staged.get(sidecar) ?? null;
306
+ out.set(resolve(target), {
307
+ sha256: sha,
308
+ sidecar: sha === null ? null : { path: sidecar, kind: 'sidecar', sha256: sha },
309
+ });
310
+ }
311
+ return out;
312
+ }
@@ -155,8 +155,22 @@ export function checkConflicts(inputs: PlanInputs): PreflightFinding[] {
155
155
  id: 'conflicts' as const,
156
156
  severity: 'warning' as const,
157
157
  path: c.path,
158
- message: `existing file conflicts with planned ${c.kind} content`,
159
- remedy: 'resolve interactively, or pass --force to overwrite',
158
+ message:
159
+ c.ownership === 'recorded-modified'
160
+ ? `managed ${c.kind} file has been edited since we wrote it`
161
+ : `existing file conflicts with planned ${c.kind} content`,
162
+ // A remedy here may only describe what an install actually does. Before
163
+ // the owner ruling of 2026-09-21 the writer overwrote every deployed
164
+ // file unconditionally, so the `recorded-modified` arm told the
165
+ // operator to back their edit up. It now preserves that file and stages
166
+ // the package content beside it, so the same sentence would be false in
167
+ // the opposite direction — it would understate a protection that ships.
168
+ // The `unknown` arm is unchanged: nothing is recorded for that path, so
169
+ // the writer still writes over it.
170
+ remedy:
171
+ c.ownership === 'recorded-modified'
172
+ ? 'installing preserves your edit and stages package content as <path>.agent-config.new; --force replaces it instead'
173
+ : 'back up this file — installing writes the planned content over it',
160
174
  }));
161
175
  }
162
176
 
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Preserve a user-modified managed file — road-to-a-conformance-check-that-can-fail
3
+ * Phase 5.1, owner ruling 2026-09-21 (option (a), after a 1/1 council split).
4
+ *
5
+ * THE GAP THIS CLOSES. Until this module existed, `agent-config init` overwrote
6
+ * every deployed file unconditionally: the conflict resolver in
7
+ * `src/scripts/install.ts` returned `write` for every target, and the module
8
+ * header recorded `--force` as an accepted no-op "because installs always
9
+ * overwrite". The planner produced a `ConflictEntry` list naming the files the
10
+ * user had edited, and the writer read none of it — so the report named the
11
+ * file and the next install replaced it.
12
+ *
13
+ * WHAT THE RULING CHANGES. A managed file whose bytes diverge from the digest
14
+ * the install manifest recorded is preserved; the package content it would
15
+ * have been replaced with is staged beside it as a sidecar, and the run ends
16
+ * with a distinct exit code so the resulting staleness is loud rather than
17
+ * silent.
18
+ *
19
+ * NO SEMANTIC CLASSIFICATION, DELIBERATELY. Any divergence from the recorded
20
+ * digest gets identical treatment. There is no comment-only detection, no
21
+ * format-aware diff, no three-way merge — each would drag a per-format parser
22
+ * into the installer's trusted computing base, and a syntactically trivial
23
+ * edit can still be an intentional one. One byte of divergence is the whole
24
+ * test.
25
+ *
26
+ * NO FALLBACK TO OVERWRITING. There is no conflict threshold above which
27
+ * preservation stops, no resource-pressure escape, and no bulk accept flag.
28
+ * `--force` is the single escape hatch and it is explicit, per-run and
29
+ * documented. Failing closed here means leaving a file alone, which is the
30
+ * recoverable direction.
31
+ *
32
+ * NO UNPROVEN OWNERSHIP CLAIM OVER AN EXISTING SIDECAR. A pathname matching the
33
+ * convention is not provenance, and destroying an unrecognised file while
34
+ * claiming to protect files would be the defect this module exists to remove,
35
+ * wearing the fix's clothes. So a file at the sidecar path is replaced only
36
+ * when the manifest RECORDS this tree staging those exact bytes; a sidecar
37
+ * still holding an older version's content is therefore refreshed rather than
38
+ * refused, because leaving it unmerged is the staging workflow working. Bytes
39
+ * matching neither the current content nor anything recorded are left alone and
40
+ * the run stops.
41
+ */
42
+
43
+ import { classifyOwnership } from './recordedOwnership.js';
44
+
45
+ /**
46
+ * Suffix appended to a preserved file's path to name its staged package copy.
47
+ *
48
+ * Tool-owned on purpose. A bare `.new` is a namespace editors, patch tools and
49
+ * humans already write into, so an install that claimed it would collide with
50
+ * files it has no relationship to; `.agent-config.new` says who wrote it.
51
+ */
52
+ export const SIDECAR_SUFFIX = '.agent-config.new';
53
+
54
+ /**
55
+ * Exit code for a run that installed everything it could but preserved at
56
+ * least one user-modified file.
57
+ *
58
+ * Distinct from `0` (fully current) and from `1` (the run failed). It is the
59
+ * load-bearing half of the ruling: preserving an edit leaves the active
60
+ * installation stale, and a stale install that exits `0` is silently stale.
61
+ * `2` is already argparse's usage-error code, so this is `3`.
62
+ */
63
+ export const EXIT_COMPLETED_WITH_CONFLICTS = 3;
64
+
65
+ /** What the writer should do with one planned target. */
66
+ export type DeployWriteDecision = 'write' | 'preserve';
67
+
68
+ /** Inputs to {@link decideDeployWrite}. */
69
+ export interface DeployWriteInputs {
70
+ /** True when the target already exists on disk. */
71
+ readonly exists: boolean;
72
+ /**
73
+ * The SHA-256 the install manifest recorded for this target.
74
+ *
75
+ * `undefined` — not recorded at all. `null` — recorded without a digest
76
+ * (bridge entries carry `sha256: null`). Both mean this tree cannot say
77
+ * whether the bytes on disk are ours.
78
+ */
79
+ readonly recordedSha256: string | null | undefined;
80
+ /**
81
+ * The SHA-256 of the bytes on disk, computed immediately before the caller
82
+ * mutates the file — not at plan time.
83
+ *
84
+ * The planner's `ConflictEntry` list is advisory state, not a safety
85
+ * capability: minutes can pass between planning and writing, and a file
86
+ * edited in that window would be classified from a digest that no longer
87
+ * describes it. Re-hashing at the write site closes that window.
88
+ *
89
+ * `null` when the file could not be read.
90
+ */
91
+ readonly onDiskSha256: string | null;
92
+ /** True when the run carries `--force`. */
93
+ readonly force: boolean;
94
+ }
95
+
96
+ /**
97
+ * Decide whether the writer may replace one managed file.
98
+ *
99
+ * A target that does not exist is written. A forced run writes. Otherwise the
100
+ * recorded-ownership verdict decides: `recorded-modified` preserves, and both
101
+ * `recorded-unchanged` and `unknown` write.
102
+ *
103
+ * The `unknown` arm is the one worth stating out loud, because it is what
104
+ * keeps this change from being a regression. No recorded digest means no
105
+ * divergence to measure — a tree with no manifest, a global anchor that holds
106
+ * none, or a bridge entry recorded without a hash. Preserving there would
107
+ * stage a sidecar beside every pre-existing file on a first install, which is
108
+ * not protection, it is noise. The ruling covers a file this tree can show it
109
+ * wrote and can show has changed since; everything else keeps the behavior it
110
+ * had, and `recordedOwnership` already documents that floor.
111
+ */
112
+ export function decideDeployWrite(inputs: DeployWriteInputs): DeployWriteDecision {
113
+ if (!inputs.exists) return 'write';
114
+ if (inputs.force) return 'write';
115
+ const ownership = classifyOwnership(inputs.recordedSha256, inputs.onDiskSha256);
116
+ return ownership === 'recorded-modified' ? 'preserve' : 'write';
117
+ }
118
+
119
+ /** Path of the sidecar staged beside `target`. */
120
+ export function sidecarPathFor(target: string): string {
121
+ return `${target}${SIDECAR_SUFFIX}`;
122
+ }
123
+
124
+ /**
125
+ * Per-file report line for a preserved target.
126
+ *
127
+ * Says three things in one breath, and all three are load-bearing: the edit
128
+ * survived, the package content is somewhere findable, and the active
129
+ * installation is not current — that last clause is what turns a silently
130
+ * stale tree into a stated one.
131
+ */
132
+ export function preservedFileMessage(target: string, sidecar: string): string {
133
+ return (
134
+ `Preserved user-modified ${target}. Package content was written to ${sidecar}; ` +
135
+ 'the active installation is not current. Review and merge it, or rerun with ' +
136
+ '--force to replace the managed file.'
137
+ );
138
+ }
139
+
140
+ /** End-of-run summary naming the conflict count and what it means. */
141
+ export function conflictSummaryMessage(count: number): string {
142
+ const noun = count === 1 ? 'file' : 'files';
143
+ return (
144
+ `Completed with conflicts: ${count} user-modified ${noun} preserved. ` +
145
+ `Package content is staged alongside as *${SIDECAR_SUFFIX}; the active installation ` +
146
+ `is not current until you merge it, or rerun with --force to replace the managed ${noun}.`
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Refusal message for a sidecar path already holding bytes this run cannot
152
+ * attribute to itself.
153
+ *
154
+ * The run fails rather than continuing: the package content was never written
155
+ * anywhere, so the target is neither refreshed nor staged, and calling that
156
+ * "completed with conflicts" would report a staging that did not happen.
157
+ */
158
+ export function foreignSidecarMessage(target: string, sidecar: string): string {
159
+ return (
160
+ `Refusing to replace ${sidecar}: it already exists with different content and this ` +
161
+ `install cannot show it wrote it. Package content for ${target} was NOT staged. ` +
162
+ 'Move or delete that file and re-run, or rerun with --force to replace the managed file instead.'
163
+ );
164
+ }
165
+
166
+ /**
167
+ * Summary payload for the GUI wizard's `done` SSE frame.
168
+ *
169
+ * `conflicts` rides on the installer's own `done` frame when the run preserved
170
+ * a user-modified managed file (exit {@link EXIT_COMPLETED_WITH_CONFLICTS}).
171
+ * The wizard forwards it rather than dropping it: a preserved edit means the
172
+ * active installation is not current, and that frame is the browser's only
173
+ * channel for saying so. Omitted when zero, so the historical summary shape is
174
+ * unchanged for every run that has no conflicts.
175
+ *
176
+ * It lives here rather than in the route because the conflict semantics are
177
+ * this module's, and because `src/server/routes/wizard.ts` sits against the
178
+ * 1,500-line source ceiling that `check_source_size_budget` ratchets.
179
+ */
180
+ /**
181
+ * Did the installer COMPLETE, whatever it exited with?
182
+ *
183
+ * `3` is a completion with preserved files, not a failure. The wizard's
184
+ * fallback path — the one that speaks when the installer dies before its
185
+ * terminal frame — tested `exitCode === 0` and reported everything else as
186
+ * `BRIDGE_FAILED`, so a run that installed everything it could and kept the
187
+ * user's edits was announced to the operator as a failed install.
188
+ *
189
+ * Here rather than in the route for the same reason as {@link installDoneSummary}:
190
+ * the semantics are this module's, and `src/server/routes/wizard.ts` sits
191
+ * against the 1,500-line source ceiling that `check_source_size_budget`
192
+ * ratchets.
193
+ */
194
+ export function installCompleted(exitCode: number): boolean {
195
+ return exitCode === 0 || exitCode === EXIT_COMPLETED_WITH_CONFLICTS;
196
+ }
197
+
198
+ export function installDoneSummary(
199
+ frame: Record<string, unknown>,
200
+ written: number,
201
+ total: number,
202
+ ): { written: number; total: number; conflicts?: number } {
203
+ const conflicts = typeof frame.conflicts === 'number' ? frame.conflicts : 0;
204
+ return conflicts > 0 ? { written, total, conflicts } : { written, total };
205
+ }