@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
@@ -18,6 +18,11 @@
18
18
  import { existsSync } from 'node:fs';
19
19
 
20
20
  import { sha256File } from './plan.js';
21
+ import {
22
+ classifyOwnership,
23
+ recordedHashesForRoot,
24
+ type RecordedHashes,
25
+ } from './recordedOwnership.js';
21
26
  import type {
22
27
  ConflictEntry,
23
28
  ConflictPolicy,
@@ -47,7 +52,7 @@ export interface ResolveInputs {
47
52
  * Decision matrix (mirrors the Python legacy with the 3-option prompt
48
53
  * collapsed into `surface`):
49
54
  *
50
- * | Exists? | Idempotent? | Known? | Force? | → Outcome |
55
+ * | Exists? | Idempotent? | Known? | Force? | → Outcome | code-comment-allow report-comment -- pre-existing decision matrix; this file entered the gate's changed-file scope for an unrelated edit
51
56
  * |---------|-------------|--------|--------|------------|
52
57
  * | no | — | — | — | `write` |
53
58
  * | yes | yes | — | — | `skip` |
@@ -56,9 +61,23 @@ export interface ResolveInputs {
56
61
  * | yes | no | no | yes | `write` |
57
62
  * | yes | no | no | no | `surface` |
58
63
  *
59
- * Headless callers (B1 CLI) collapse `surface` to `skip` automatically
60
- * because there is no UI to defer to; the apply layer records the entry
61
- * under {@link ApplyResult.conflicts} so the wizard can pick it up next.
64
+ * Deliberately NOT extended with the recorded-digest ownership that
65
+ * {@link computeConflicts} consumes. This function has no caller in `src/` —
66
+ * the only references are its own definition and the `{@link}` above — so an
67
+ * `ownership` input would be a parameter nobody supplies, on a decision
68
+ * nobody reads. The ownership split lives where it is live: in the report.
69
+ *
70
+ * What this resolver decides is in any case NOT what the installer does. The
71
+ * single writer is `src/scripts/install.ts`, whose conflict resolver
72
+ * (`src/install/conflictTracker.ts`) reads nothing from this module; `skip`
73
+ * here means "the planner would not touch it", never "your edit is safe".
74
+ *
75
+ * Since the owner ruling of 2026-09-21 that writer no longer overwrites
76
+ * unconditionally — a managed file diverging from its recorded digest is
77
+ * preserved and the package content staged beside it (`src/install/preserve.ts`).
78
+ * The two still reach that answer independently: this module decides from a
79
+ * plan, the writer re-hashes the destination at the moment it is about to
80
+ * mutate it. A `skip` here remains a statement about the plan.
62
81
  */
63
82
  export function resolveFileConflict(inputs: ResolveInputs): ConflictOutcome {
64
83
  const { targetPath, idempotent, exists, policy } = inputs;
@@ -155,21 +174,57 @@ export const CONFLICT_BATCH_THRESHOLD = 5;
155
174
  *
156
175
  * Pure-ish — reads from the filesystem only to compute idempotency
157
176
  * (`existsSync` + `sha256File`). Skips bridges (`sha256 === null`) since
158
- * bridge writers own a separate idempotency model. Skips entries whose
159
- * `path` is in `policy.knownPaths`, and skips entries that are
160
- * byte-equal to the planned content. Returns an empty array when
177
+ * bridge writers own a separate idempotency model, and skips entries that
178
+ * are byte-equal to the planned content. Returns an empty array when
161
179
  * `policy.force` is true — overwrite mode silences the screen.
180
+ *
181
+ * Ownership (Phase 5.1) replaces the blanket `policy.knownPaths` skip:
182
+ *
183
+ * - `recorded-unchanged` — ours and untouched. Skipped, and now for a reason
184
+ * that survives a package upgrade: the planned bytes differing from the
185
+ * recorded bytes is an upgrade, not a collision. This is a REDUCTION in
186
+ * what a manifest-carrying tree reports — `cmd_preflight` builds an empty
187
+ * `knownPaths`, so every such file used to be a finding — and it is the
188
+ * intended direction: a routine upgrade is not a conflict.
189
+ * - `recorded-modified` — ours and edited since. **Reported**, where before
190
+ * it was silently dropped whenever the path sat in `knownPaths`. Reported
191
+ * is all it is: the installer does not consult this matrix, so being named
192
+ * here is not protection from the next deploy.
193
+ * - `unknown` — no digest recorded (no manifest, an unreadable
194
+ * one, or a bridge entry). Falls back to `policy.knownPaths`, so a tree
195
+ * with no manifest reports exactly what it reported before. A tree WITH a
196
+ * manifest reports differently in both directions, by design: it gains the
197
+ * modified rows above and loses the unchanged ones below.
198
+ *
199
+ * `recorded` is injectable for tests; by default it is read from the
200
+ * manifest at `plan.root`.
162
201
  */
163
- export function computeConflicts(plan: InstallPlan): readonly ConflictEntry[] {
202
+ export function computeConflicts(
203
+ plan: InstallPlan,
204
+ recordedOverride?: RecordedHashes,
205
+ ): readonly ConflictEntry[] {
206
+ // Force returns before the manifest is read. As a default parameter the
207
+ // map was built at call entry and discarded on the next line, so every
208
+ // force-mode plan paid a read and a YAML parse for nothing.
164
209
  if (plan.policy.force) return [];
210
+ const recorded = recordedOverride ?? recordedHashesForRoot(plan.root);
165
211
  const out: ConflictEntry[] = [];
166
212
  for (const entries of Object.values(plan.filesByTool)) {
167
213
  for (const entry of entries) {
168
214
  if (entry.kind === 'bridge') continue;
169
215
  if (entry.sha256 === null) continue;
170
216
  if (!existsSync(entry.path)) continue;
171
- if (plan.policy.knownPaths.has(entry.path)) continue;
217
+ // The on-disk digest is computed BEFORE the ownership decision so
218
+ // that decision can consume it. Behaviour-identical to computing it
219
+ // after the `knownPaths` test — the remaining guards are unchanged.
220
+ // The added cost is one digest per EXISTING path in
221
+ // `policy.knownPaths`, the set the removed short-circuit used to
222
+ // skip; everything else was already being hashed. That set is empty
223
+ // for `cmd_preflight` and caller-supplied for the plan route.
172
224
  const onDisk = sha256File(entry.path);
225
+ const ownership = classifyOwnership(recorded.get(entry.path), onDisk);
226
+ if (ownership === 'recorded-unchanged') continue;
227
+ if (ownership === 'unknown' && plan.policy.knownPaths.has(entry.path)) continue;
173
228
  if (onDisk === entry.sha256) continue;
174
229
  out.push({
175
230
  path: entry.path,
@@ -177,6 +232,7 @@ export function computeConflicts(plan: InstallPlan): readonly ConflictEntry[] {
177
232
  plannedSha256: entry.sha256,
178
233
  existingSha256: onDisk,
179
234
  mergeable: isJsonTarget(entry),
235
+ ownership,
180
236
  });
181
237
  }
182
238
  }
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Per-run conflict tracking for `agent-config init` — the stateful half of the
3
+ * owner ruling of 2026-09-21 (road-to-a-conformance-check-that-can-fail
4
+ * Phase 5.1). The decision itself is pure and lives in `./preserve.ts`; this
5
+ * module holds the per-run state that decision needs, stages the sidecar, and
6
+ * folds the result into the run's exit code.
7
+ *
8
+ * It sits beside the decision rather than inside `src/scripts/install.ts`
9
+ * because that file is the largest source this package ships and
10
+ * `check_source_size_budget` ratchets exactly that number. A feature's state
11
+ * machine is paid for here, where it is also the coherent unit: everything in
12
+ * this file is about one question — may the writer replace this managed file,
13
+ * and what does it owe the operator when it may not.
14
+ *
15
+ * The installer's reporting surface (`warn`, `fail`, the transaction log, the
16
+ * NDJSON progress stream) is injected rather than imported: importing it would
17
+ * be a cycle, and re-implementing it would give one install run two voices.
18
+ */
19
+ import * as crypto from 'node:crypto';
20
+ import * as fs from 'node:fs';
21
+ import * as path from 'node:path';
22
+
23
+ import { mkdirp, pathExists, resolvePath, sha256OfFile } from './fsPrimitives.js';
24
+ import {
25
+ EXIT_COMPLETED_WITH_CONFLICTS,
26
+ conflictSummaryMessage,
27
+ decideDeployWrite,
28
+ foreignSidecarMessage,
29
+ preservedFileMessage,
30
+ sidecarPathFor,
31
+ type DeployWriteDecision,
32
+ } from './preserve.js';
33
+ import { recordedHashesForRoot, type RecordedHashes } from './recordedOwnership.js';
34
+
35
+ /** The installer-owned reporting surface the tracker borrows. */
36
+ export interface ConflictTrackerHost {
37
+ /** `install.ts::warn` — stderr, deliberately NOT gated on QUIET. */
38
+ warn(msg: string): void;
39
+ /** `install.ts::fail` — prints the doctor hint, then throws `SystemExitError(1)`. */
40
+ fail(msg: string): never;
41
+ /** `install.ts::_log_tx_entry('write', target)`. */
42
+ logWrite(target: string): void;
43
+ /** `install.ts::_emit_progress` — a no-op unless `--progress-ndjson` is set. */
44
+ emitProgress(frame: Record<string, unknown>): void;
45
+ /**
46
+ * `install.ts::_inject_package_tag` — stamps `package:` / `source_path:`
47
+ * into a deployed `.md`.
48
+ *
49
+ * The tracker needs it because the sidecar must be what the installer WOULD
50
+ * have written, and a deployed file is the package bytes PLUS this tag. A
51
+ * raw copy is not that file: merging it by hand leaves content that matches
52
+ * no recorded digest and carries none of the ownership evidence the reaper
53
+ * reads, so the obvious way to resolve a conflict produced a file the tool
54
+ * no longer recognised as its own.
55
+ */
56
+ tagDeployed(target: string, source: string, packageRoot: string | null): void;
57
+ }
58
+
59
+ /**
60
+ * Mutable per-run state.
61
+ *
62
+ * `recorded` is read lazily and cached for the whole run, so the digests
63
+ * compared against are the ones recorded BEFORE this run started — the manifest
64
+ * is rewritten at the end of the install, and re-reading it mid-run would
65
+ * compare a file against a digest this very run had just recorded for it.
66
+ */
67
+ export interface ConflictState {
68
+ root: string | null;
69
+ recorded: RecordedHashes | null;
70
+ preserved: string[];
71
+ /**
72
+ * Sidecar path → the digest of the package content staged there this run.
73
+ *
74
+ * Recorded into the manifest beside the preserved target, and it is the
75
+ * only durable proof that this installer wrote that sidecar. Without it,
76
+ * a sidecar staged against package v2 and still unmerged when v3 installs
77
+ * is indistinguishable from a stranger's file at the same path — which is
78
+ * why the run used to abort there.
79
+ */
80
+ staged: Map<string, string | null>;
81
+ }
82
+
83
+ export interface ConflictTracker {
84
+ /** Live state, exposed so the installer can re-export it for its tests. */
85
+ readonly conflictState: ConflictState;
86
+ /** Reset the tracker and point it at the tree whose manifest records ownership. */
87
+ begin(root: string | null): void;
88
+ /** Decide what the writer does with one deploy target. */
89
+ resolve(target: string, force: boolean): DeployWriteDecision;
90
+ /** Stage package content beside a preserved target, then record and report it. */
91
+ preserve(target: string, source: string, packageRoot: string | null): void;
92
+ /** The same, for a managed file the installer generates rather than copies. */
93
+ preserveContent(target: string, content: string): void;
94
+ /** Fold preserved-file conflicts into the run's exit code and report them. */
95
+ finalizeRc(rc: number): number;
96
+ }
97
+
98
+ /**
99
+ * Build a tracker bound to one installer's reporting surface.
100
+ *
101
+ * A factory rather than module-level state so the reporting surface is injected
102
+ * rather than imported. It is NOT what keeps two installs in one process apart:
103
+ * `src/scripts/install.ts` calls this once at module scope, so both runs share
104
+ * the instance and therefore the `preserved` list. `begin()` is what scopes the
105
+ * state to a run, and it is the only thing that does.
106
+ */
107
+ export function createConflictTracker(host: ConflictTrackerHost): ConflictTracker {
108
+ const conflictState: ConflictState = {
109
+ root: null,
110
+ recorded: null,
111
+ preserved: [],
112
+ staged: new Map(),
113
+ };
114
+
115
+ /**
116
+ * Recorded digest for `target`, or `undefined` when this tree cannot say.
117
+ *
118
+ * Two keys are tried, and the plain `path.resolve` one comes FIRST because
119
+ * it is the form that actually matches: `_file_entry` records the path
120
+ * verbatim as the copy loop built it, and `readRecordedHashes` re-resolves
121
+ * with `path.resolve` and no realpath. Looking up only the realpath would
122
+ * miss every entry on macOS, where the temp and home trees sit behind the
123
+ * realpath symlink that rewrites a `/var` prefix onto its private twin —
124
+ * the feature would have been inert on the platform it was developed on.
125
+ * (Spelled out rather than quoted: `check_bundle_path_leakage` scans the
126
+ * tracked bundles this file is compiled into, and the literal form is a
127
+ * build-machine path leak there.) The realpath is kept as a second key so
128
+ * a manifest written through a symlinked deploy root still resolves.
129
+ */
130
+ function recordedHashFor(target: string): string | null | undefined {
131
+ if (conflictState.root === null) return undefined;
132
+ conflictState.recorded ??= recordedHashesForRoot(conflictState.root);
133
+ const plain = path.resolve(target);
134
+ if (conflictState.recorded.has(plain)) return conflictState.recorded.get(plain);
135
+ const real = resolvePath(target);
136
+ return real === plain ? undefined : conflictState.recorded.get(real);
137
+ }
138
+
139
+ /**
140
+ * Produce the bytes this run would have written, then reconcile them with
141
+ * whatever already sits at the sidecar path.
142
+ *
143
+ * The candidate is built into a temp file FIRST so the comparison is against
144
+ * the bytes that would actually be staged rather than against the raw
145
+ * package source — those differ, because a deployed file is the package
146
+ * bytes plus the install-time `package:` tag, and comparing the untagged
147
+ * source would call every already-staged sidecar stale and rewrite it on
148
+ * every run.
149
+ *
150
+ * The temp name ends in the TARGET's extension because `_inject_package_tag`
151
+ * keys on it: a sidecar is `<name>.md.agent-config.new`, whose extension is
152
+ * `.new`, so tagging it in place would silently do nothing.
153
+ */
154
+ function stageInto(target: string, sidecar: string, produce: (tmp: string) => void): void {
155
+ mkdirp(path.dirname(sidecar));
156
+ const tmp = path.join(
157
+ path.dirname(sidecar),
158
+ `.${path.basename(sidecar)}.${process.pid}.${crypto.randomBytes(6).toString('hex')}${path.extname(target)}`,
159
+ );
160
+ const discard = (): void => {
161
+ try {
162
+ fs.unlinkSync(tmp);
163
+ } catch {
164
+ /* swallow — best-effort cleanup of our own temp file */
165
+ }
166
+ };
167
+ let candidate: string | null;
168
+ try {
169
+ produce(tmp);
170
+ candidate = sha256OfFile(tmp);
171
+ } catch (exc) {
172
+ discard();
173
+ host.fail(
174
+ `Could not stage package content for ${target} at ${sidecar}: ${String(exc)}. ` +
175
+ 'Nothing was written; the managed file is unchanged.',
176
+ );
177
+ }
178
+ if (pathExists(sidecar)) {
179
+ const onDisk = sha256OfFile(sidecar);
180
+ // Three outcomes, and only the third is a refusal. Identical bytes:
181
+ // already staged, so the file is left alone. Bytes this tree
182
+ // RECORDED staging: an earlier run staged them against an older
183
+ // package version and the user has not merged them yet — the
184
+ // staging workflow behaving as designed, so the stale copy is
185
+ // refreshed. Anything else is unattributable and the run stops.
186
+ //
187
+ // Ownership is decided by the recorded digest, never by whether the
188
+ // bytes match the CURRENT package: that test called every unmerged
189
+ // sidecar foreign the moment a new version shipped, and aborted the
190
+ // whole install saying it could not show it wrote a file it had
191
+ // written one version earlier.
192
+ if (onDisk !== null && onDisk === candidate) {
193
+ discard();
194
+ conflictState.staged.set(sidecar, onDisk);
195
+ return;
196
+ }
197
+ if (onDisk === null || onDisk !== recordedHashFor(sidecar)) {
198
+ discard();
199
+ host.fail(foreignSidecarMessage(target, sidecar));
200
+ }
201
+ }
202
+ try {
203
+ fs.renameSync(tmp, sidecar);
204
+ } catch (exc) {
205
+ discard();
206
+ host.fail(
207
+ `Could not stage package content for ${target} at ${sidecar}: ${String(exc)}. ` +
208
+ 'Nothing was written; the managed file is unchanged.',
209
+ );
210
+ }
211
+ conflictState.staged.set(sidecar, candidate);
212
+ host.logWrite(sidecar);
213
+ }
214
+
215
+ /** Record and report one preserved target, once per destination. */
216
+ function record(target: string, sidecar: string): void {
217
+ // Deduped: two plan entries can resolve to one destination, and a count
218
+ // is a promise about FILES. Double-counting inflates the summary, the
219
+ // NDJSON frame and anything downstream reading either.
220
+ if (conflictState.preserved.includes(target)) return;
221
+ conflictState.preserved.push(target);
222
+ host.warn(preservedFileMessage(target, sidecar));
223
+ }
224
+
225
+ return {
226
+ conflictState,
227
+
228
+ begin(root: string | null): void {
229
+ conflictState.root = root;
230
+ conflictState.recorded = null;
231
+ conflictState.preserved = [];
232
+ conflictState.staged = new Map();
233
+ },
234
+
235
+ /**
236
+ * The on-disk digest is computed HERE rather than carried from the
237
+ * plan: this runs immediately before the caller copies over `target`,
238
+ * so a file edited between planning and writing is still classified
239
+ * from the bytes that are actually about to be destroyed.
240
+ */
241
+ resolve(target: string, force: boolean): DeployWriteDecision {
242
+ const exists = pathExists(target);
243
+ return decideDeployWrite({
244
+ exists,
245
+ recordedSha256: exists ? recordedHashFor(target) : undefined,
246
+ onDiskSha256: exists ? sha256OfFile(target) : null,
247
+ force,
248
+ });
249
+ },
250
+
251
+ /**
252
+ * Stage the package content beside a preserved target.
253
+ *
254
+ * The staged bytes are what the installer WOULD have written — the
255
+ * package file plus its install-time tag — so merging the sidecar by
256
+ * hand yields a file that matches the digest this run records and still
257
+ * carries the ownership evidence the reaper reads.
258
+ */
259
+ preserve(target: string, source: string, packageRoot: string | null): void {
260
+ const sidecar = sidecarPathFor(target);
261
+ stageInto(target, sidecar, (tmp) => {
262
+ fs.copyFileSync(source, tmp);
263
+ host.tagDeployed(tmp, source, packageRoot);
264
+ });
265
+ record(target, sidecar);
266
+ },
267
+
268
+ /**
269
+ * The same, for a managed file the installer GENERATES rather than
270
+ * copies (the Claude Desktop marker).
271
+ *
272
+ * Those writers are recorded in the manifest with a digest, so they are
273
+ * in the class the ruling covers, and one of them wrote unconditionally
274
+ * — ignoring even `--force` — because the tracker was wired only into
275
+ * the copy loop.
276
+ */
277
+ preserveContent(target: string, content: string): void {
278
+ const sidecar = sidecarPathFor(target);
279
+ stageInto(target, sidecar, (tmp) => fs.writeFileSync(tmp, content, 'utf-8'));
280
+ record(target, sidecar);
281
+ },
282
+
283
+ /**
284
+ * Runs before the installer's terminal NDJSON frame so that frame can
285
+ * carry the count. A failing run keeps its own code — a conflict never
286
+ * masks a failure.
287
+ */
288
+ finalizeRc(rc: number): number {
289
+ const count = conflictState.preserved.length;
290
+ if (count === 0) return rc;
291
+ host.emitProgress({ type: 'conflicts', count, paths: [...conflictState.preserved] });
292
+ // `warn` writes to stderr and is deliberately NOT gated on QUIET:
293
+ // the wizard runs the installer with QUIET set, and a preserved
294
+ // edit is the one thing a silent run must still say.
295
+ host.warn(conflictSummaryMessage(count));
296
+ return rc === 0 ? EXIT_COMPLETED_WITH_CONFLICTS : rc;
297
+ },
298
+ };
299
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Filesystem primitives shared by the installer and its conflict tracker.
3
+ *
4
+ * These four moved out of `src/scripts/install.ts` when `conflictTracker.ts`
5
+ * needed them. The tracker cannot import the installer — that is a cycle — and
6
+ * re-declaring `pathExists` / `sha256OfFile` beside it would put two
7
+ * definitions of "does this file exist" and "what are its bytes" on the same
8
+ * install path, which is the one place they have to agree. Passing them in as
9
+ * callbacks was the other option and it buys nothing: they are pure, total
10
+ * functions of their arguments carrying no installer state, so a shared module
11
+ * is both the smaller wiring and the more honest unit.
12
+ *
13
+ * The selection rule is exactly that — shared with `conflictTracker.ts`. The
14
+ * installer's other private helpers (`expanduser`, `isDir`, `readText`, …) stay
15
+ * where they are; moving them too would be a restructure this change does not
16
+ * need and cannot justify.
17
+ */
18
+ import * as crypto from 'node:crypto';
19
+ import * as fs from 'node:fs';
20
+ import * as path from 'node:path';
21
+
22
+ /** `Path.resolve()` — absolute, symlink-resolved where possible. */
23
+ export function resolvePath(p: string): string {
24
+ try {
25
+ return fs.realpathSync(path.resolve(p));
26
+ } catch {
27
+ return path.resolve(p);
28
+ }
29
+ }
30
+
31
+ export function pathExists(p: string): boolean {
32
+ try {
33
+ fs.statSync(p);
34
+ return true;
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ /** `path.mkdir(parents=True, exist_ok=True)`. */
41
+ export function mkdirp(p: string): void {
42
+ fs.mkdirSync(p, { recursive: true });
43
+ }
44
+
45
+ /** `hashlib.sha256(data).hexdigest()` of a file's bytes, or null when unreadable. */
46
+ export function sha256OfFile(p: string): string | null {
47
+ let data: Buffer;
48
+ try {
49
+ data = fs.readFileSync(p);
50
+ } catch {
51
+ return null;
52
+ }
53
+ return crypto.createHash('sha256').update(data).digest('hex');
54
+ }