session-orchestrator 5.3.0 → 5.5.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 (260) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/test-audit/SKILL.md +21 -0
  3. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/.claude-plugin/plugin.json +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  8. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  9. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  10. package/.cursor/commands/test-audit.md +14 -0
  11. package/.cursor/rules/060-evolve.mdc +28 -13
  12. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  13. package/.cursor/skills/test-audit/SKILL.md +12 -0
  14. package/.cursor-plugin/plugin.json +1 -1
  15. package/.orchestrator/policy/blocked-commands.json +1 -1
  16. package/AGENTS.md +9 -31
  17. package/CHANGELOG.md +162 -0
  18. package/README.md +14 -10
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +10 -1
  21. package/docs/ci-setup.md +154 -4
  22. package/docs/components.md +11 -11
  23. package/docs/events-schema.md +25 -19
  24. package/docs/install.md +14 -2
  25. package/docs/migration-v4.md +24 -17
  26. package/docs/persona-panel.md +5 -4
  27. package/docs/plugin-architecture-v3.md +1 -1
  28. package/docs/rule-authoring.md +27 -12
  29. package/docs/scope-collision-guard.md +40 -16
  30. package/docs/session-config-reference.md +61 -12
  31. package/docs/session-config-template.md +11 -2
  32. package/docs/vault-docs-architecture.md +8 -4
  33. package/hooks/_lib/hook-import-set.json +42 -29
  34. package/hooks/_lib/lock-bootstrap.mjs +118 -26
  35. package/hooks/config-protection.mjs +1 -1
  36. package/hooks/enforce-commands.mjs +28 -13
  37. package/hooks/enforce-scope.mjs +201 -23
  38. package/hooks/hooks-codex.json +1 -1
  39. package/hooks/hooks-cursor.json +5 -0
  40. package/hooks/hooks-pi.json +5 -0
  41. package/hooks/hooks.json +6 -1
  42. package/hooks/on-session-start.mjs +57 -2
  43. package/hooks/on-stop.mjs +70 -168
  44. package/hooks/post-bash-write-verify.mjs +98 -17
  45. package/hooks/post-edit-validate.mjs +15 -5
  46. package/hooks/post-tool-batch-wave-signal.mjs +32 -152
  47. package/hooks/pre-bash-memory-propose-audit.mjs +71 -10
  48. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  49. package/hooks/pre-bash-staging-fence.mjs +29 -8
  50. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  51. package/hooks/pre-task-scope-disjoint.mjs +1143 -279
  52. package/hooks/subagent-telemetry.mjs +96 -19
  53. package/hooks/wave-scope-commit-guard.mjs +168 -72
  54. package/package.json +2 -6
  55. package/pi/prompts/test-audit.md +12 -0
  56. package/rules/README.md +6 -1
  57. package/rules/_index.md +3 -3
  58. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  59. package/rules/opt-in-domain/prompt-caching.md +5 -1
  60. package/rules/opt-in-stack/backend-data.md +9 -1
  61. package/rules/opt-in-stack/backend.md +7 -1
  62. package/rules/opt-in-stack/frontend.md +6 -1
  63. package/rules/opt-in-stack/security-web.md +7 -1
  64. package/rules/opt-in-stack/swift.md +5 -1
  65. package/scripts/apply-session-learnings.mjs +513 -0
  66. package/scripts/archive-closed-prds.mjs +40 -12
  67. package/scripts/autopilot.mjs +57 -54
  68. package/scripts/backfill-abandoned-sessions.mjs +81 -6
  69. package/scripts/backfill-learnings-from-vault.mjs +49 -22
  70. package/scripts/baseline-archetypes.mjs +1 -1
  71. package/scripts/check-package-manager.mjs +62 -5
  72. package/scripts/codex-install.mjs +3 -0
  73. package/scripts/cursor-install.mjs +47 -6
  74. package/scripts/emit-session.mjs +298 -2
  75. package/scripts/export-hw-learnings.mjs +40 -10
  76. package/scripts/fleet-checkin.mjs +170 -0
  77. package/scripts/lib/agent-frontmatter.mjs +3 -3
  78. package/scripts/lib/auto-dream.mjs +7 -6
  79. package/scripts/lib/autopilot/flags.mjs +6 -1
  80. package/scripts/lib/autopilot/loop.mjs +32 -13
  81. package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
  82. package/scripts/lib/baseline-archetypes.mjs +41 -22
  83. package/scripts/lib/blocked-commands-policy.mjs +40 -0
  84. package/scripts/lib/common.mjs +41 -0
  85. package/scripts/lib/config/host-paths.mjs +79 -0
  86. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  87. package/scripts/lib/config/reaper.mjs +7 -6
  88. package/scripts/lib/config/vault-integration.mjs +151 -16
  89. package/scripts/lib/config-schema.mjs +13 -0
  90. package/scripts/lib/config.mjs +75 -10
  91. package/scripts/lib/eval/engine.mjs +13 -0
  92. package/scripts/lib/events-retention-banner.mjs +393 -0
  93. package/scripts/lib/events-rotation.mjs +21 -6
  94. package/scripts/lib/events.mjs +72 -5
  95. package/scripts/lib/fleet-protocol.mjs +288 -0
  96. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  97. package/scripts/lib/gates/gate-full.mjs +7 -2
  98. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  99. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  100. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  101. package/scripts/lib/hardening.mjs +1 -0
  102. package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
  103. package/scripts/lib/instruction-budget-guard.mjs +77 -11
  104. package/scripts/lib/learnings/expiry-sweep.mjs +420 -22
  105. package/scripts/lib/learnings/io.mjs +274 -37
  106. package/scripts/lib/learnings/schema.mjs +163 -15
  107. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  108. package/scripts/lib/lock-reaper.mjs +38 -14
  109. package/scripts/lib/maintenance-due-banner.mjs +23 -8
  110. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  111. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  112. package/scripts/lib/named-vault-resolver.mjs +147 -5
  113. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  114. package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
  115. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  116. package/scripts/lib/orphan-reaper/reaper-audit.mjs +323 -0
  117. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  118. package/scripts/lib/orphan-reaper/scan-throttle.mjs +144 -0
  119. package/scripts/lib/orphan-reaper/scan.mjs +510 -0
  120. package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
  121. package/scripts/lib/orphan-reaper.mjs +47 -1315
  122. package/scripts/lib/owner-yaml.mjs +104 -6
  123. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  124. package/scripts/lib/platform.mjs +206 -2
  125. package/scripts/lib/plugin-update-banner.mjs +3 -1
  126. package/scripts/lib/process-group.mjs +134 -22
  127. package/scripts/lib/project-hygiene.mjs +21 -1
  128. package/scripts/lib/quality-gate.mjs +45 -5
  129. package/scripts/lib/resource-probe.mjs +1 -1
  130. package/scripts/lib/rule-loader.mjs +100 -62
  131. package/scripts/lib/rules-sync.mjs +27 -12
  132. package/scripts/lib/scope-baseline.mjs +119 -29
  133. package/scripts/lib/scope-echo.mjs +7 -0
  134. package/scripts/lib/scope-gate.mjs +194 -27
  135. package/scripts/lib/session-close-backfill.mjs +129 -4
  136. package/scripts/lib/session-end/tail-runner.mjs +10 -5
  137. package/scripts/lib/session-lock-shape.mjs +6 -3
  138. package/scripts/lib/session-lock.mjs +320 -67
  139. package/scripts/lib/session-record-repair.mjs +183 -26
  140. package/scripts/lib/session-registry.mjs +67 -17
  141. package/scripts/lib/session-schema/constants.mjs +30 -4
  142. package/scripts/lib/session-schema/validator.mjs +28 -1
  143. package/scripts/lib/session-start-probes.mjs +242 -68
  144. package/scripts/lib/session-token-rollup.mjs +252 -54
  145. package/scripts/lib/sessions-canonical.mjs +100 -9
  146. package/scripts/lib/sessions-staleness-banner.mjs +3 -1
  147. package/scripts/lib/sh-quote.mjs +29 -0
  148. package/scripts/lib/subagents-schema.mjs +30 -3
  149. package/scripts/lib/tail-window.mjs +27 -3
  150. package/scripts/lib/telemetry/pricing.mjs +121 -71
  151. package/scripts/lib/telemetry/sync.mjs +17 -0
  152. package/scripts/lib/tests-src-ratio.mjs +215 -23
  153. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  154. package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
  155. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  156. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  157. package/scripts/lib/validate/check-agents.mjs +1 -1
  158. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  159. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  160. package/scripts/lib/validate/check-test-value-bans.mjs +388 -27
  161. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  162. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  163. package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
  164. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  165. package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
  166. package/scripts/lib/vault-mirror/namespace.mjs +47 -26
  167. package/scripts/lib/vault-mirror/process.mjs +350 -62
  168. package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
  169. package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
  170. package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
  171. package/scripts/lib/vault-mirror/telemetry.mjs +25 -11
  172. package/scripts/lib/vault-status/board-writer.mjs +53 -12
  173. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  174. package/scripts/lib/vcs-repo-spec.mjs +8 -0
  175. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  176. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  177. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  178. package/scripts/lib/wave-resource-gate.mjs +34 -8
  179. package/scripts/materialize-wave-scope.mjs +7 -4
  180. package/scripts/memory-propose.mjs +18 -5
  181. package/scripts/release-session-lock.mjs +2 -1
  182. package/scripts/release.mjs +11 -3
  183. package/scripts/repair-invalid-sessions.mjs +37 -4
  184. package/scripts/run-migrate-v2-cross-repo.mjs +171 -31
  185. package/scripts/run-quality-gate.mjs +331 -307
  186. package/scripts/self-update.mjs +229 -0
  187. package/scripts/site-numbers.mjs +163 -5
  188. package/scripts/sweep-expired-learnings.mjs +603 -20
  189. package/scripts/sweep-expired-rules.mjs +2 -1
  190. package/scripts/validate-plugin.mjs +7 -0
  191. package/scripts/validate-wave-scope.mjs +21 -2
  192. package/scripts/vault-backfill.mjs +50 -37
  193. package/scripts/vault-integration-watcher.mjs +2 -1
  194. package/scripts/vault-mirror.mjs +238 -75
  195. package/scripts/wave-scope-binding.mjs +2 -1
  196. package/skills/_shared/config-reading.md +3 -3
  197. package/skills/_shared/fleet-protocol.md +134 -0
  198. package/skills/_shared/state-ownership.md +41 -12
  199. package/skills/autopilot/SKILL.md +25 -11
  200. package/skills/bootstrap/SKILL.md +6 -4
  201. package/skills/bootstrap/_shared-template.md +97 -2
  202. package/skills/bootstrap/fast-template.md +4 -1
  203. package/skills/bootstrap/private-contract.md +2 -1
  204. package/skills/bootstrap/public-fallback.md +6 -3
  205. package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
  206. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  207. package/skills/bootstrap/standard-template.md +2 -0
  208. package/skills/claude-md-drift-check/SKILL.md +10 -10
  209. package/skills/claude-md-drift-check/checker.mjs +26 -18
  210. package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
  211. package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
  212. package/skills/discovery/probes/vault-staleness.mjs +9 -3
  213. package/skills/docs-orchestrator/SKILL.md +12 -5
  214. package/skills/docs-orchestrator/audience-mapping.md +5 -3
  215. package/skills/evolve/SKILL.md +13 -4
  216. package/skills/evolve/references/evolve-analyze-mode.md +42 -18
  217. package/skills/memory-cleanup/SKILL.md +1 -1
  218. package/skills/persona-panel/SKILL.md +2 -2
  219. package/skills/persona-panel/persona-format.md +2 -2
  220. package/skills/persona-panel/presets/designer-lens.md +1 -1
  221. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  222. package/skills/persona-panel/presets/pm-lens.md +1 -1
  223. package/skills/plan/mode-retro.md +19 -4
  224. package/skills/quality-gates/SKILL.md +6 -4
  225. package/skills/remote-offload/SKILL.md +4 -2
  226. package/skills/session-end/learning-patterns.md +23 -13
  227. package/skills/session-end/metrics-collection.md +6 -7
  228. package/skills/session-end/phase-3-2-docs-verification.md +7 -6
  229. package/skills/session-end/plan-verification.md +25 -9
  230. package/skills/session-end/references/phase-3-documentation-updates.md +13 -3
  231. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  232. package/skills/session-end/references/session-summary-template.md +1 -1
  233. package/skills/session-end/session-metrics-write.md +65 -68
  234. package/skills/session-plan/SKILL.md +0 -3
  235. package/skills/session-start/SKILL.md +8 -2
  236. package/skills/session-start/references/operations-contract.md +6 -0
  237. package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
  238. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  239. package/skills/session-start/references/phase-4-ssot-environment-check.md +10 -3
  240. package/skills/test-audit/SKILL.md +152 -0
  241. package/skills/test-audit/references/campaign.md +111 -0
  242. package/skills/test-audit/references/mutate.sh +50 -0
  243. package/skills/vault-mirror/SKILL.md +21 -11
  244. package/skills/wave-executor/SKILL.md +2 -2
  245. package/skills/wave-executor/circuit-breaker.md +1 -1
  246. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  247. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -1
  248. package/skills/wave-executor/references/wave-loop-review.md +4 -6
  249. package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
  250. package/templates/personas/README.md +5 -5
  251. package/templates/personas/accounting-compliance.v1.md +1 -1
  252. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  253. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  254. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  255. package/templates/personas/buyer-p3-build.v1.md +1 -1
  256. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  257. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  258. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  259. package/templates/personas/klima-ai-expert.v1.md +1 -1
  260. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -1,12 +1,17 @@
1
- <!-- source: session-orchestrator plugin (canonical: rules/opt-in-stack/frontend.md) -->
2
1
  ---
3
2
  globs:
4
3
  - src/**/*.tsx
5
4
  - src/**/*.css
6
5
  - src/**/*.module.css
7
6
  - "**/components/**/*.{ts,tsx}"
7
+ paths:
8
+ - src/**/*.tsx
9
+ - src/**/*.css
10
+ - src/**/*.module.css
11
+ - "**/components/**/*.{ts,tsx}"
8
12
  tier: wave-only
9
13
  ---
14
+ <!-- source: session-orchestrator plugin (canonical: rules/opt-in-stack/frontend.md) -->
10
15
  # Frontend Rules (Path-scoped)
11
16
 
12
17
  ## React & Next.js
@@ -1,4 +1,3 @@
1
- <!-- source: session-orchestrator plugin (canonical: rules/opt-in-stack/security-web.md) -->
2
1
  ---
3
2
  globs:
4
3
  - src/middleware*.ts
@@ -6,8 +5,15 @@ globs:
6
5
  - next.config.*
7
6
  - src/lib/csp*
8
7
  - src/lib/cors*
8
+ paths:
9
+ - src/middleware*.ts
10
+ - src/app/**
11
+ - next.config.*
12
+ - src/lib/csp*
13
+ - src/lib/cors*
9
14
  tier: wave-only
10
15
  ---
16
+ <!-- source: session-orchestrator plugin (canonical: rules/opt-in-stack/security-web.md) -->
11
17
 
12
18
  # Security — Web & API (Path-scoped)
13
19
 
@@ -1,11 +1,15 @@
1
- <!-- source: session-orchestrator plugin (canonical: rules/opt-in-stack/swift.md) -->
2
1
  ---
3
2
  globs:
4
3
  - "**/*.swift"
5
4
  - "**/Package.swift"
6
5
  - "**/*.xcodeproj/**"
6
+ paths:
7
+ - "**/*.swift"
8
+ - "**/Package.swift"
9
+ - "**/*.xcodeproj/**"
7
10
  tier: wave-only
8
11
  ---
12
+ <!-- source: session-orchestrator plugin (canonical: rules/opt-in-stack/swift.md) -->
9
13
  # Swift Rules (Path-scoped)
10
14
 
11
15
  ## Language & Style
@@ -0,0 +1,513 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * apply-session-learnings.mjs — the ONLY sanctioned session-end write path for
4
+ * `learnings.jsonl` (GitLab #1446 / GitHub #69). session-end Phase 3.6 and the
5
+ * `/plan retro` §3.3 learnings update hand their collected changes to this CLI;
6
+ * neither may rewrite the store with a shell `>` or append to it with `>>`
7
+ * (`scripts/lib/validate/check-learnings-shell-writes.mjs` gates the prose).
8
+ *
9
+ * Why a CLI and not prose: until #1446, Phase 3.6 step 4g told the coordinator
10
+ * to "write the entire result back (atomic rewrite with `>`)" from a template
11
+ * that carried no `scope` and no `file_paths`. A shell rewrite bypasses
12
+ * `validateLearning`, the `.bak-<ISO>` snapshot and the archive sidecar — and
13
+ * the producer that wrote a file PATH into the `scope` column (GH#69) was
14
+ * never rejected by anything.
15
+ *
16
+ * Input (stdin, or `--input PATH`), one JSON object:
17
+ * {
18
+ * "confidence_updates": [{ "id": "<existing id>", "operation": "confirm"|"contradict" }],
19
+ * "new_learnings": [ <complete learning objects> ]
20
+ * }
21
+ * Both keys are optional (absent = empty list).
22
+ *
23
+ * Steps — every one delegates to an existing helper:
24
+ * 1. read the store `readLearnings()` (io.mjs)
25
+ * 2. confirm → +0.15, cap 1.0, `expires_at` reset via `deriveExpiresAt(now, type)`
26
+ * contradict → −0.2, floor 0, `expires_at` untouched
27
+ * 3. new learnings → stamped like `appendLearning()` (`created_at`,
28
+ * `expires_at`, `schema_version: 1` when absent), then STRICT
29
+ * `validateLearning()` — one invalid record exits 1 with nothing written
30
+ * 4. passive decay (#89) → `learning-decay-rate` subtracted from every
31
+ * existing learning NOT confirmed/contradicted this run, clamped ≥ 0,
32
+ * `expires_at` untouched
33
+ * 5. prune (expired, confidence ≤ 0) + consolidate (type+subject, highest
34
+ * confidence wins) + archive the losers + atomic rewrite with `.bak-<ISO>`
35
+ * → `pruneLearnings()` (expiry-sweep.mjs). Archived records carry this
36
+ * run's DECAYED confidence, a value never stored in the live file: decay
37
+ * (step 4) runs before prune by design, so a record pruned at
38
+ * `confidence <= 0` is archived with the value that pruned it.
39
+ *
40
+ * On `--apply`, steps 1-5 run as ONE critical section under the store lock
41
+ * (`withLearningsLock()`, io.mjs; lock file `<store>.lock`): a parallel
42
+ * `--apply` or an `appendLearning()` cannot land between the read and the
43
+ * rewrite and be lost. A lock not acquired within 10 s exits 2, nothing written.
44
+ *
45
+ * Usage:
46
+ * node scripts/apply-session-learnings.mjs [--input PATH] [--file PATH]
47
+ * [--repo-root PATH] [--dry-run|--apply] [--json] [--decay-rate N]
48
+ *
49
+ * Flags:
50
+ * --input PATH JSON input file (default: read stdin)
51
+ * --repo-root PATH Repo root (default: cwd). Resolves a relative `--file`,
52
+ * supplies the Session Config for the decay rate, and pins
53
+ * the telemetry record.
54
+ * --file PATH Learnings store (default: .orchestrator/metrics/learnings.jsonl).
55
+ * The archive is its sibling `learnings-archive.jsonl`.
56
+ * --dry-run Preview the summary; write nothing, no `.bak` (DEFAULT)
57
+ * --apply Perform the archive append + store rewrite
58
+ * --json Emit one machine-parseable JSON summary line
59
+ * --decay-rate N Passive decay per run, 0..1 (default: Session Config
60
+ * `learning-decay-rate`, else 0.05). 0 disables decay.
61
+ *
62
+ * Telemetry: `--apply` emits `orchestrator.learnings.session_write_applied`
63
+ * after the write — its presence is the proof the write ran. Best-effort: a
64
+ * telemetry failure never changes the exit code. Skipped (stderr WARN) when
65
+ * `--file` lies outside `--repo-root` after resolving symlinks on both sides,
66
+ * so the record is never pinned to a repo
67
+ * whose store was not written.
68
+ *
69
+ * Exit codes:
70
+ * 0 Success (including a no-op)
71
+ * 1 Usage/input/validation error — nothing written
72
+ * 2 Library/IO error (unreadable store, a failure inside the
73
+ * prune/rewrite, store lock not acquired). Unparseable store lines are
74
+ * NOT an error: they are kept verbatim, counted as `malformed` in the
75
+ * summary, and WARNed on stderr with their line numbers.
76
+ */
77
+
78
+ import { readFileSync, realpathSync } from 'node:fs';
79
+ import path from 'node:path';
80
+ import { validatePathInsideProject } from './lib/path-utils.mjs';
81
+ import { LearningsLockError, readLearnings, withLearningsLock } from './lib/learnings/io.mjs';
82
+ import { pruneLearnings } from './lib/learnings/expiry-sweep.mjs';
83
+ import {
84
+ CURRENT_SCHEMA_VERSION,
85
+ deriveExpiresAt,
86
+ validateLearning,
87
+ } from './lib/learnings/schema.mjs';
88
+ import { parseSessionConfig, readConfigFile } from './lib/config.mjs';
89
+ import { isMainModule } from './lib/is-main-module.mjs';
90
+
91
+ const DEFAULT_FILE = '.orchestrator/metrics/learnings.jsonl';
92
+ const ARCHIVE_BASENAME = 'learnings-archive.jsonl';
93
+ const DEFAULT_DECAY_RATE = 0.05;
94
+ const CONFIRM_DELTA = 0.15;
95
+ const CONTRADICT_DELTA = 0.2;
96
+ const OPERATIONS = new Set(['confirm', 'contradict']);
97
+
98
+ /** Event name — registered in docs/events-schema.md. */
99
+ export const SESSION_WRITE_EVENT = 'orchestrator.learnings.session_write_applied';
100
+
101
+ class UsageError extends Error {}
102
+
103
+ /**
104
+ * Round to 6 decimals. Without it, ten 0.05 decay steps from 0.5 leave a
105
+ * float residue > 0 that the `confidence <= 0` prune never catches.
106
+ *
107
+ * @param {number} n
108
+ * @returns {number}
109
+ */
110
+ function round6(n) {
111
+ return Math.round(n * 1e6) / 1e6;
112
+ }
113
+
114
+ function printHelp() {
115
+ process.stdout.write(
116
+ 'Usage: node scripts/apply-session-learnings.mjs [--input PATH] [--file PATH] ' +
117
+ '[--repo-root PATH] [--dry-run|--apply] [--json] [--decay-rate N]\n',
118
+ );
119
+ }
120
+
121
+ function parseArgs(argv) {
122
+ const opts = { input: null, file: null, repoRoot: null, apply: false, json: false, decayRate: null };
123
+ const valueOf = (i, flag) => {
124
+ const v = argv[i + 1];
125
+ if (v === undefined || v.startsWith('--')) throw new UsageError(`${flag} requires a value`);
126
+ return v;
127
+ };
128
+ for (let i = 0; i < argv.length; i += 1) {
129
+ const a = argv[i];
130
+ switch (a) {
131
+ case '--help':
132
+ case '-h':
133
+ opts.help = true;
134
+ break;
135
+ case '--apply':
136
+ opts.apply = true;
137
+ break;
138
+ case '--dry-run':
139
+ opts.apply = false;
140
+ break;
141
+ case '--json':
142
+ opts.json = true;
143
+ break;
144
+ case '--input':
145
+ opts.input = valueOf(i, a);
146
+ i += 1;
147
+ break;
148
+ case '--file':
149
+ opts.file = valueOf(i, a);
150
+ i += 1;
151
+ break;
152
+ case '--repo-root':
153
+ opts.repoRoot = valueOf(i, a);
154
+ i += 1;
155
+ break;
156
+ case '--decay-rate': {
157
+ const raw = valueOf(i, a);
158
+ const n = Number(raw);
159
+ if (raw.trim() === '' || !Number.isFinite(n) || n < 0 || n > 1) {
160
+ throw new UsageError(`--decay-rate must be a number in [0, 1], got: ${raw}`);
161
+ }
162
+ opts.decayRate = n;
163
+ i += 1;
164
+ break;
165
+ }
166
+ default:
167
+ throw new UsageError(`unknown argument: ${a}`);
168
+ }
169
+ }
170
+ return opts;
171
+ }
172
+
173
+ /**
174
+ * Parse and shape-check the input document. Throws UsageError.
175
+ *
176
+ * @param {string} raw
177
+ * @returns {{ updates: {id: string, operation: string}[], newLearnings: object[] }}
178
+ */
179
+ function parseInput(raw) {
180
+ let doc;
181
+ try {
182
+ doc = JSON.parse(raw);
183
+ } catch (err) {
184
+ throw new UsageError(`input is not valid JSON: ${err.message}`);
185
+ }
186
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
187
+ throw new UsageError('input must be a JSON object {confidence_updates, new_learnings}');
188
+ }
189
+ const updates = doc.confidence_updates ?? [];
190
+ const newLearnings = doc.new_learnings ?? [];
191
+ if (!Array.isArray(updates)) throw new UsageError('confidence_updates must be an array');
192
+ if (!Array.isArray(newLearnings)) throw new UsageError('new_learnings must be an array');
193
+ const seen = new Set();
194
+ updates.forEach((u, i) => {
195
+ if (!u || typeof u.id !== 'string' || u.id.length === 0) {
196
+ throw new UsageError(`confidence_updates[${i}]: id must be a non-empty string`);
197
+ }
198
+ if (!OPERATIONS.has(u.operation)) {
199
+ throw new UsageError(
200
+ `confidence_updates[${i}] (id=${u.id}): operation must be confirm|contradict, got: ${u.operation}`,
201
+ );
202
+ }
203
+ if (seen.has(u.id)) throw new UsageError(`confidence_updates[${i}]: id ${u.id} listed twice`);
204
+ seen.add(u.id);
205
+ });
206
+ return { updates, newLearnings };
207
+ }
208
+
209
+ /**
210
+ * Session Config `learning-decay-rate`, or the default when no config is
211
+ * readable. Never throws.
212
+ *
213
+ * @param {string} repoRoot
214
+ * @returns {Promise<number>}
215
+ */
216
+ async function resolveDecayRate(repoRoot) {
217
+ try {
218
+ const cfg = parseSessionConfig(await readConfigFile(repoRoot));
219
+ const rate = cfg?.['learning-decay-rate'];
220
+ return typeof rate === 'number' && Number.isFinite(rate) ? rate : DEFAULT_DECAY_RATE;
221
+ } catch {
222
+ return DEFAULT_DECAY_RATE;
223
+ }
224
+ }
225
+
226
+ /**
227
+ * Build the next store generation from the current one plus the input.
228
+ * Pure apart from the clock. Throws UsageError on an unknown/duplicate id or
229
+ * an invalid new learning.
230
+ *
231
+ * @returns {{ next: object[], confirmed: number, contradicted: number, appended: number,
232
+ * decayed: number, undecayable: number }}
233
+ */
234
+ export function buildNextGeneration({ current, updates, newLearnings, decayRate, nowIso }) {
235
+ const byId = new Map();
236
+ for (const entry of current) {
237
+ if (typeof entry?.id === 'string' && entry.id.length > 0) byId.set(entry.id, entry);
238
+ }
239
+
240
+ const updateOf = new Map();
241
+ for (const u of updates) {
242
+ if (!byId.has(u.id)) {
243
+ throw new UsageError(`confidence_updates: id ${u.id} not found in the store`);
244
+ }
245
+ updateOf.set(u.id, u.operation);
246
+ }
247
+
248
+ let confirmed = 0;
249
+ let contradicted = 0;
250
+ let decayed = 0;
251
+ let undecayable = 0;
252
+ const next = current.map((entry) => {
253
+ const op = typeof entry?.id === 'string' ? updateOf.get(entry.id) : undefined;
254
+ const c = entry?.confidence;
255
+ if (op === 'confirm') {
256
+ confirmed += 1;
257
+ return {
258
+ ...entry,
259
+ confidence: round6(Math.min(1, (typeof c === 'number' ? c : 0) + CONFIRM_DELTA)),
260
+ expires_at: deriveExpiresAt(nowIso, entry.type),
261
+ };
262
+ }
263
+ if (op === 'contradict') {
264
+ contradicted += 1;
265
+ return { ...entry, confidence: round6(Math.max(0, (typeof c === 'number' ? c : 0) - CONTRADICT_DELTA)) };
266
+ }
267
+ if (decayRate > 0 && typeof c === 'number' && c > 0) {
268
+ // An id-less record is keyed by a content fingerprint in pruneLearnings'
269
+ // reconcile pass, so changing its confidence would archive the old
270
+ // version as a spurious "pruned" duplicate every run. It is left to age
271
+ // out via `expires_at` instead — revisit once the legacy id-less records
272
+ // are gone (`migrateLegacyLearning` backfill).
273
+ if (typeof entry?.id !== 'string' || entry.id.length === 0) {
274
+ undecayable += 1;
275
+ return entry;
276
+ }
277
+ decayed += 1;
278
+ return { ...entry, confidence: round6(Math.max(0, c - decayRate)) };
279
+ }
280
+ return entry;
281
+ });
282
+
283
+ const newIds = new Set();
284
+ newLearnings.forEach((raw, i) => {
285
+ const label = `new_learnings[${i}] (id=${raw?.id ?? '<missing>'})`;
286
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
287
+ throw new UsageError(`${label}: must be an object`);
288
+ }
289
+ // Stamping mirrors appendLearning() (io.mjs) so both write paths agree.
290
+ const createdAt =
291
+ typeof raw.created_at === 'string' && raw.created_at.length > 0 ? raw.created_at : nowIso;
292
+ const stamped = {
293
+ ...raw,
294
+ created_at: createdAt,
295
+ expires_at:
296
+ typeof raw.expires_at === 'string' && raw.expires_at.length > 0
297
+ ? raw.expires_at
298
+ : deriveExpiresAt(createdAt, raw.type),
299
+ schema_version: raw.schema_version ?? CURRENT_SCHEMA_VERSION,
300
+ };
301
+ let validated;
302
+ try {
303
+ validated = validateLearning(stamped);
304
+ } catch (err) {
305
+ throw new UsageError(`${label}: ${err.message}`);
306
+ }
307
+ if (byId.has(validated.id) || newIds.has(validated.id)) {
308
+ throw new UsageError(`${label}: id already exists — use confidence_updates to reinforce it`);
309
+ }
310
+ newIds.add(validated.id);
311
+ next.push(validated);
312
+ });
313
+
314
+ return { next, confirmed, contradicted, appended: newIds.size, decayed, undecayable };
315
+ }
316
+
317
+ /**
318
+ * A path resolved through symlinks when it exists, else absolute as given.
319
+ *
320
+ * @param {string} p
321
+ * @returns {string}
322
+ */
323
+ function canonicalPath(p) {
324
+ try {
325
+ return realpathSync(p);
326
+ } catch {
327
+ return path.resolve(p);
328
+ }
329
+ }
330
+
331
+ async function emitSessionWriteApplied({ repoRoot, filePath, payload }) {
332
+ // Canonical, not lexical (#1506.4b; same check as sweep-expired-learnings
333
+ // --drop-malformed, #1500 F4): a store reached through a symlinked directory
334
+ // inside the repo but living outside it is refused, and a root spelled
335
+ // through a symlink (macOS /var → /private/var) still accepts the store
336
+ // inside it. Runs after the write, so the store exists and resolves.
337
+ const file = canonicalPath(filePath);
338
+ const rel = path.relative(canonicalPath(repoRoot), file);
339
+ if (!validatePathInsideProject(file, repoRoot, { canonicalizeRoot: true }).ok) {
340
+ process.stderr.write(
341
+ `apply-session-learnings: skipped ${SESSION_WRITE_EVENT} — ${filePath} lies outside ` +
342
+ `--repo-root ${repoRoot}\n`,
343
+ );
344
+ return;
345
+ }
346
+ try {
347
+ const { emitEvent, sessionAttribution } = await import('./lib/events.mjs');
348
+ await emitEvent(
349
+ SESSION_WRITE_EVENT,
350
+ { file: rel, ...payload, ...sessionAttribution(repoRoot) },
351
+ { repoRoot },
352
+ );
353
+ } catch (err) {
354
+ // Best-effort telemetry — the write already happened and is authoritative.
355
+ process.stderr.write(`apply-session-learnings: ${SESSION_WRITE_EVENT} not recorded: ${err?.message ?? err}\n`);
356
+ }
357
+ }
358
+
359
+ async function main(argv) {
360
+ let opts;
361
+ let input;
362
+ try {
363
+ opts = parseArgs(argv);
364
+ if (opts.help) {
365
+ printHelp();
366
+ return 0;
367
+ }
368
+ if (opts.input === null && process.stdin.isTTY) {
369
+ throw new UsageError('no input — pipe JSON on stdin or pass --input PATH');
370
+ }
371
+ let raw;
372
+ try {
373
+ raw = readFileSync(opts.input ?? 0, 'utf8');
374
+ } catch (err) {
375
+ throw new UsageError(`cannot read input: ${err.message}`);
376
+ }
377
+ input = parseInput(raw);
378
+ } catch (err) {
379
+ if (err instanceof UsageError) {
380
+ process.stderr.write(`apply-session-learnings: ${err.message}\n`);
381
+ printHelp();
382
+ return 1;
383
+ }
384
+ throw err;
385
+ }
386
+
387
+ const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
388
+ const filePath = path.resolve(repoRoot, opts.file ?? DEFAULT_FILE);
389
+ const archivePath = path.join(path.dirname(filePath), ARCHIVE_BASENAME);
390
+ const decayRate = opts.decayRate ?? (await resolveDecayRate(repoRoot));
391
+ const now = new Date();
392
+ const nowIso = now.toISOString();
393
+
394
+ // Read → build → prune/rewrite: one critical section on --apply (see header).
395
+ // Returns `{ code }` for an early exit, else the three results.
396
+ const readBuildPrune = async () => {
397
+ let read;
398
+ try {
399
+ read = await readLearnings(filePath);
400
+ } catch (err) {
401
+ process.stderr.write(`apply-session-learnings: cannot read ${filePath}: ${err.message}\n`);
402
+ return { code: 2 };
403
+ }
404
+ // A malformed store line is no reason to refuse (#1489): pruneLearnings()
405
+ // keeps it verbatim at the end of the rewritten store, never archives it,
406
+ // and prints the WARN; the count rides on the summary as `malformed`.
407
+ // Refusing here blocked the session-end learnings write on every close,
408
+ // because nothing on the sanctioned write path ever removes such a line.
409
+
410
+ let built;
411
+ try {
412
+ built = buildNextGeneration({
413
+ current: read.entries,
414
+ updates: input.updates,
415
+ newLearnings: input.newLearnings,
416
+ decayRate,
417
+ nowIso,
418
+ });
419
+ } catch (err) {
420
+ if (err instanceof UsageError) {
421
+ process.stderr.write(`apply-session-learnings: ${err.message} — nothing written\n`);
422
+ return { code: 1 };
423
+ }
424
+ throw err;
425
+ }
426
+
427
+ let res;
428
+ try {
429
+ // Joins the lock held below reentrantly; takes none on a dry run.
430
+ res = await pruneLearnings({ filePath, archivePath, entries: built.next, now, dryRun: !opts.apply });
431
+ } catch (err) {
432
+ process.stderr.write(`apply-session-learnings: prune/rewrite failed: ${err.message}\n`);
433
+ return { code: 2 };
434
+ }
435
+ return { read, built, res };
436
+ };
437
+
438
+ let outcome;
439
+ try {
440
+ outcome = opts.apply ? await withLearningsLock(filePath, readBuildPrune) : await readBuildPrune();
441
+ } catch (err) {
442
+ if (err instanceof LearningsLockError) {
443
+ process.stderr.write(`apply-session-learnings: ${err.message}\n`);
444
+ return 2;
445
+ }
446
+ throw err;
447
+ }
448
+ if ('code' in outcome) return outcome.code;
449
+ const { read, built, res } = outcome;
450
+
451
+ const byReason = res.byReason ?? {};
452
+ const pruned = (byReason.expired ?? 0) + (byReason.pruned ?? 0);
453
+ const summary = {
454
+ dry_run: !opts.apply,
455
+ file: filePath,
456
+ read: read.entries.length,
457
+ malformed: read.malformed.length,
458
+ confirmed: built.confirmed,
459
+ contradicted: built.contradicted,
460
+ appended: built.appended,
461
+ decay_rate: decayRate,
462
+ decayed: built.decayed,
463
+ undecayable: built.undecayable,
464
+ pruned,
465
+ consolidated: byReason.superseded ?? 0,
466
+ archived: res.archived,
467
+ kept: res.kept,
468
+ by_reason: byReason,
469
+ written: opts.apply ? res.kept : 0,
470
+ };
471
+
472
+ // The event's presence is documented as proof the write ran
473
+ // (docs/events-schema.md). pruneLearnings() returns early WITHOUT writing
474
+ // exactly when the store and the next generation are both empty — the only
475
+ // path that reports `scanned: 0` — so no store + empty input emits nothing.
476
+ if (opts.apply && res.scanned > 0) {
477
+ await emitSessionWriteApplied({
478
+ repoRoot,
479
+ filePath,
480
+ payload: {
481
+ appended: built.appended,
482
+ confirmed: built.confirmed,
483
+ contradicted: built.contradicted,
484
+ decayed: built.decayed,
485
+ pruned,
486
+ },
487
+ });
488
+ }
489
+
490
+ if (opts.json) {
491
+ process.stdout.write(`${JSON.stringify(summary)}\n`);
492
+ } else {
493
+ process.stdout.write(
494
+ `${summary.dry_run ? '[dry-run] ' : ''}learnings: read ${summary.read}, confirmed ${summary.confirmed}, ` +
495
+ `contradicted ${summary.contradicted}, appended ${summary.appended}, decayed ${summary.decayed} ` +
496
+ `(rate ${decayRate}), pruned ${pruned}, consolidated ${summary.consolidated}, kept ${summary.kept}` +
497
+ `${summary.dry_run ? ' — nothing written' : ` — wrote ${summary.written}`}\n`,
498
+ );
499
+ }
500
+ return 0;
501
+ }
502
+
503
+ if (isMainModule(import.meta.url)) {
504
+ main(process.argv.slice(2)).then(
505
+ (code) => {
506
+ process.exitCode = code;
507
+ },
508
+ (err) => {
509
+ process.stderr.write(`apply-session-learnings: ${err?.stack ?? err}\n`);
510
+ process.exitCode = 2;
511
+ },
512
+ );
513
+ }
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * Flow:
14
14
  * 1. findProjectRoot → resolve CLAUDE.md → parseSessionConfig → vault-integration.vault-dir
15
- * (host-resolved: SO_VAULT_DIR env > owner.yaml paths.vault-dir > committed).
15
+ * (host-resolved: SO_VAULT_DIR env > owner.yaml vault-dirs: cwd match >
16
+ * owner.yaml paths.vault-dir > committed — `resolveVaultDir` in lib/config.mjs).
16
17
  * 2. Enumerate tracked .md files under --prd-dir (git ls-files), excluding
17
18
  * *.original-uncommitted.md. A missing/empty --prd-dir yields [] → clean
18
19
  * report, exit 0 (no crash) — see listTrackedPrds.
@@ -43,7 +44,7 @@ import { readFileSync } from 'node:fs';
43
44
  import { spawnSync } from 'node:child_process';
44
45
  import { join } from 'node:path';
45
46
 
46
- import { findProjectRoot, resolveInstructionFile, warn } from './lib/common.mjs';
47
+ import { findProjectRoot, redactHomeDir, resolveInstructionFile, warn } from './lib/common.mjs';
47
48
  import { parseSessionConfig } from './lib/config.mjs';
48
49
  import { glabRun as defaultGlabRun } from './lib/vault-backfill/glab.mjs';
49
50
  import { archiveFileToVault, titleFromMarkdown } from './lib/vault-archive.mjs';
@@ -380,8 +381,13 @@ EXIT CODES
380
381
  }
381
382
 
382
383
  function printHuman(archived, skipped, isDryRun, vaultDir, vaultSubdir) {
384
+ // Runs as a custom phase at every session start and end, so this line lands
385
+ // in every transcript — home dir as `~` (#1490). `--json` keeps the real
386
+ // `vaultDir`: that field is a machine contract, not a log line. A
387
+ // `skipped[].reason` is redacted in BOTH outputs — it carries fs error text,
388
+ // and main() redacts it before it enters the array (8273d97a).
383
389
  process.stdout.write(
384
- `Doc archive ${isDryRun ? '(dry-run)' : '(apply)'} → ${vaultDir}/${vaultSubdir}\n`,
390
+ `Doc archive ${isDryRun ? '(dry-run)' : '(apply)'} → ${redactHomeDir(`${vaultDir}/${vaultSubdir}`)}\n`,
385
391
  );
386
392
  if (archived.length > 0) {
387
393
  process.stdout.write(` ${isDryRun ? 'WOULD ARCHIVE' : 'ARCHIVED'} (${archived.length}):\n`);
@@ -487,19 +493,38 @@ export function main({
487
493
  const root = repoRoot ?? findProjectRoot();
488
494
  const instr = resolveInstructionFile(root);
489
495
  if (!instr) {
490
- process.stderr.write(`archive-closed-prds: no CLAUDE.md/AGENTS.md at ${root}.\n`);
496
+ process.stderr.write(`archive-closed-prds: no CLAUDE.md/AGENTS.md at ${redactHomeDir(root)}.\n`);
491
497
  return { code: 1, archived: [], skipped: [] };
492
498
  }
493
499
 
494
500
  let vaultDir;
501
+ let vaultIntegration;
495
502
  try {
496
503
  const content = readFileSync(instr.path, 'utf8');
497
504
  const config = parseSessionConfig(content, hostPaths ? { hostPaths } : undefined);
498
- vaultDir = config?.['vault-integration']?.['vault-dir'];
505
+ vaultIntegration = config?.['vault-integration'];
506
+ vaultDir = vaultIntegration?.['vault-dir'];
499
507
  } catch (err) {
500
- process.stderr.write(`archive-closed-prds: failed to parse Session Config: ${err.message}\n`);
508
+ process.stderr.write(`archive-closed-prds: failed to parse Session Config: ${redactHomeDir(err.message)}\n`);
501
509
  return { code: 2, archived: [], skipped: [] };
502
510
  }
511
+ // The switch every vault writer obeys (board-writer, narrative-mirror): off
512
+ // → nothing archived, exit 0. parseSessionConfig has already applied the
513
+ // host-local lowering (resolveVaultIntegrationHost: env SO_VAULT_INTEGRATION
514
+ // > owner.yaml > committed). This runs with --apply at every session start
515
+ // and end, so ignoring it wrote PRDs into the vault and git-rm'd them (#1496).
516
+ if (vaultIntegration?.enabled !== true) {
517
+ const override = vaultIntegration?.['host-override'];
518
+ const disabled = `vault-integration is ${override ? `switched off by ${override}` : 'not enabled'} — nothing archived`;
519
+ if (json) {
520
+ process.stdout.write(
521
+ JSON.stringify({ dryRun: isDryRun, vaultDir: null, vaultSubdir, archived: [], skipped: [], disabled }, null, 2) + '\n',
522
+ );
523
+ } else {
524
+ process.stdout.write(`Doc archive: ${disabled}\n`);
525
+ }
526
+ return { code: 0, archived: [], skipped: [] };
527
+ }
503
528
  if (!vaultDir || typeof vaultDir !== 'string' || vaultDir.trim() === '') {
504
529
  process.stderr.write(
505
530
  'archive-closed-prds: vault-integration.vault-dir is not configured — cannot archive.\n',
@@ -533,8 +558,10 @@ export function main({
533
558
  try {
534
559
  header = readHeaderRegion(abs);
535
560
  } catch (err) {
536
- skipped.push({ source: rel, reason: `unreadable: ${err.message}` });
537
- warn(`archive-closed-prds: cannot read ${rel}: ${err.message}`);
561
+ // fs errors name the ABSOLUTE path they failed on (#1490).
562
+ const msg = redactHomeDir(err.message);
563
+ skipped.push({ source: rel, reason: `unreadable: ${msg}` });
564
+ warn(`archive-closed-prds: cannot read ${rel}: ${msg}`);
538
565
  continue;
539
566
  }
540
567
 
@@ -602,8 +629,9 @@ export function main({
602
629
  title: titleFromMarkdown(header),
603
630
  });
604
631
  } catch (err) {
605
- skipped.push({ source: rel, reason: `archive-failed: ${err.message}`, iid, via, ownership });
606
- warn(`archive-closed-prds: failed to archive ${rel}: ${err.message}`);
632
+ const msg = redactHomeDir(err.message);
633
+ skipped.push({ source: rel, reason: `archive-failed: ${msg}`, iid, via, ownership });
634
+ warn(`archive-closed-prds: failed to archive ${rel}: ${msg}`);
607
635
  continue;
608
636
  }
609
637
  entry.iid = iid;
@@ -613,7 +641,7 @@ export function main({
613
641
  if (!isDryRun) {
614
642
  const rm = gitRunFn(['-C', root, 'rm', '--', rel]);
615
643
  entry.removed = rm.ok;
616
- if (!rm.ok) warn(`archive-closed-prds: 'git rm ${rel}' failed: ${rm.stderr.trim()}`);
644
+ if (!rm.ok) warn(`archive-closed-prds: 'git rm ${rel}' failed: ${redactHomeDir(rm.stderr.trim())}`);
617
645
  }
618
646
 
619
647
  archived.push(entry);
@@ -640,7 +668,7 @@ if (isMainModule(import.meta.url)) {
640
668
  const { code } = main();
641
669
  process.exit(code ?? 0);
642
670
  } catch (err) {
643
- process.stderr.write(`archive-closed-prds: unexpected error: ${err?.stack ?? err}\n`);
671
+ process.stderr.write(`archive-closed-prds: unexpected error: ${redactHomeDir(String(err?.stack ?? err))}\n`);
644
672
  process.exit(2);
645
673
  }
646
674
  }