session-orchestrator 3.22.0 → 3.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (268) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  74. package/.cursor/skills/session-end/SKILL.md +13 -0
  75. package/.cursor/skills/session-plan/SKILL.md +13 -0
  76. package/.cursor/skills/session-start/SKILL.md +13 -0
  77. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  78. package/.cursor/skills/spinout/SKILL.md +12 -0
  79. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  80. package/.cursor/skills/test-runner/SKILL.md +13 -0
  81. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  82. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  83. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  84. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  85. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  86. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  87. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  88. package/.mcp.json +4 -1
  89. package/CHANGELOG.md +168 -0
  90. package/README.md +18 -15
  91. package/agents/AGENTS.md +23 -4
  92. package/agents/code-implementer.md +2 -1
  93. package/agents/db-specialist.md +2 -1
  94. package/agents/docs-writer.md +3 -1
  95. package/agents/eval-judge.md +1 -1
  96. package/agents/session-reviewer.md +7 -1
  97. package/agents/test-writer.md +2 -1
  98. package/agents/ui-developer.md +2 -1
  99. package/commands/bootstrap.md +2 -2
  100. package/commands/close.md +3 -1
  101. package/commands/go.md +1 -1
  102. package/commands/journey-audit.md +43 -0
  103. package/docs/USER-GUIDE.md +2 -2
  104. package/docs/ci-setup.md +14 -0
  105. package/docs/codex-setup.md +64 -0
  106. package/docs/components.md +6 -6
  107. package/docs/cursor-setup.md +26 -47
  108. package/docs/events-schema.md +76 -4
  109. package/docs/github-mirror-protection.md +197 -0
  110. package/docs/pi-setup.md +2 -0
  111. package/docs/rule-authoring.md +3 -1
  112. package/docs/scope-collision-guard.md +49 -2
  113. package/docs/session-config-reference.md +26 -4
  114. package/docs/session-config-template.md +4 -3
  115. package/docs/telemetry.md +22 -0
  116. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  117. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  118. package/hooks/enforce-scope.mjs +64 -0
  119. package/hooks/hooks-codex.json +1 -1
  120. package/hooks/hooks-cursor.json +201 -20
  121. package/hooks/hooks-pi.json +1 -1
  122. package/hooks/hooks.json +2 -2
  123. package/hooks/on-session-end.mjs +211 -10
  124. package/hooks/on-session-start.mjs +214 -11
  125. package/hooks/on-stop.mjs +48 -9
  126. package/hooks/post-subagent-discovery-validator.mjs +34 -3
  127. package/hooks/post-tool-batch-wave-signal.mjs +11 -2
  128. package/hooks/pre-bash-issue-budget.mjs +117 -4
  129. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  130. package/hooks/pre-bash-staging-fence.mjs +4 -0
  131. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  132. package/hooks/skill-invocation-telemetry.mjs +21 -10
  133. package/monitors/monitors.json +6 -0
  134. package/package.json +1 -1
  135. package/pi/prompts/journey-audit.md +12 -0
  136. package/rules/_index.md +9 -1
  137. package/rules/always-on/ask-via-tool.md +62 -0
  138. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  139. package/rules/always-on/build-value.md +47 -0
  140. package/rules/always-on/cross-session-messaging.md +59 -0
  141. package/rules/always-on/loop-and-monitor.md +221 -0
  142. package/rules/always-on/parallel-sessions.md +142 -12
  143. package/rules/always-on/receiving-review.md +108 -0
  144. package/rules/always-on/test-value.md +40 -0
  145. package/rules/always-on/verification-before-completion.md +77 -0
  146. package/scripts/archive-closed-prds.mjs +258 -18
  147. package/scripts/autopilot.mjs +5 -0
  148. package/scripts/backfill-evidence-digest.mjs +376 -0
  149. package/scripts/cursor-install.mjs +89 -48
  150. package/scripts/export-hw-learnings.mjs +143 -2
  151. package/scripts/express-path.mjs +299 -0
  152. package/scripts/generate-cursor-adapter.mjs +253 -0
  153. package/scripts/github-protection-audit.mjs +358 -0
  154. package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
  155. package/scripts/lib/build-live-signals.mjs +24 -5
  156. package/scripts/lib/ci-status-banner.mjs +158 -11
  157. package/scripts/lib/command-blocker.mjs +70 -0
  158. package/scripts/lib/config/reconcile.mjs +79 -4
  159. package/scripts/lib/config/section-extractor.mjs +235 -36
  160. package/scripts/lib/config-schema.mjs +9 -1
  161. package/scripts/lib/config.mjs +57 -6
  162. package/scripts/lib/convergence-monitor.mjs +13 -2
  163. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  164. package/scripts/lib/dispatcher/cli.mjs +2 -2
  165. package/scripts/lib/express-path.mjs +327 -0
  166. package/scripts/lib/file-lock.mjs +22 -4
  167. package/scripts/lib/gates/gate-full.mjs +81 -8
  168. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  169. package/scripts/lib/git-config-drift.mjs +134 -5
  170. package/scripts/lib/host-identity.mjs +247 -2
  171. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  172. package/scripts/lib/issue-budget.mjs +229 -30
  173. package/scripts/lib/learnings/io.mjs +55 -10
  174. package/scripts/lib/learnings/schema.mjs +95 -28
  175. package/scripts/lib/lock-reaper.mjs +7 -1
  176. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  177. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  178. package/scripts/lib/memory-banner.mjs +5 -2
  179. package/scripts/lib/memory-paths.mjs +15 -6
  180. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  181. package/scripts/lib/platform.mjs +72 -9
  182. package/scripts/lib/plugin-root.mjs +143 -19
  183. package/scripts/lib/project-hygiene.mjs +43 -3
  184. package/scripts/lib/quality-gate.mjs +271 -13
  185. package/scripts/lib/reconcile/emitter.mjs +87 -19
  186. package/scripts/lib/reconcile/engine.mjs +281 -13
  187. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  188. package/scripts/lib/reconcile/renderer.mjs +148 -3
  189. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  190. package/scripts/lib/reconcile/writer.mjs +415 -84
  191. package/scripts/lib/rule-loader.mjs +37 -2
  192. package/scripts/lib/rules-sync.mjs +51 -8
  193. package/scripts/lib/scope-gate.mjs +90 -0
  194. package/scripts/lib/session-close-backfill.mjs +369 -28
  195. package/scripts/lib/session-discovery.mjs +13 -3
  196. package/scripts/lib/session-end/phase-skip.mjs +37 -4
  197. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  198. package/scripts/lib/session-id.mjs +30 -14
  199. package/scripts/lib/session-identity/own-session.mjs +159 -0
  200. package/scripts/lib/session-lock.mjs +85 -30
  201. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  202. package/scripts/lib/session-schema/validator.mjs +40 -0
  203. package/scripts/lib/session-start-probes.mjs +608 -0
  204. package/scripts/lib/session-transition.mjs +277 -0
  205. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  206. package/scripts/lib/spiral-carryover.mjs +90 -9
  207. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  208. package/scripts/lib/state-md/mission-status.mjs +350 -52
  209. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  210. package/scripts/lib/state-md.mjs +12 -2
  211. package/scripts/lib/telemetry/sync.mjs +46 -8
  212. package/scripts/lib/validate/check-agents.mjs +66 -0
  213. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  214. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  215. package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
  216. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  217. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  218. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  219. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  220. package/scripts/lib/validate/check-unwired-features.mjs +340 -32
  221. package/scripts/lib/validate/repo-files.mjs +275 -0
  222. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  223. package/scripts/lib/vault-mirror/process.mjs +99 -43
  224. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  225. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  226. package/scripts/lib/vault-status/board-writer.mjs +211 -10
  227. package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
  228. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  229. package/scripts/lib/wave-transcript-tail.mjs +869 -0
  230. package/scripts/materialize-wave-scope.mjs +209 -12
  231. package/scripts/mcp-server.sh +11 -2
  232. package/scripts/parse-config.mjs +65 -0
  233. package/scripts/token-audit.sh +9 -2
  234. package/scripts/validate-plugin.mjs +3 -0
  235. package/scripts/validate-wave-scope.mjs +67 -0
  236. package/scripts/vault-mirror.mjs +203 -34
  237. package/skills/_shared/monitor-patterns.md +31 -5
  238. package/skills/_shared/parallel-aware-auq.md +1 -1
  239. package/skills/_shared/parallel-aware-preamble.md +4 -2
  240. package/skills/_shared/platform-tools.md +11 -5
  241. package/skills/_shared/state-ownership.md +29 -2
  242. package/skills/autopilot/SKILL.md +5 -1
  243. package/skills/bootstrap/SKILL.md +3 -3
  244. package/skills/bootstrap/_shared-template.md +18 -10
  245. package/skills/bootstrap/deep-template.md +10 -6
  246. package/skills/bootstrap/fast-template.md +15 -8
  247. package/skills/bootstrap/standard-template.md +10 -6
  248. package/skills/claude-md-drift-check/checker.mjs +39 -11
  249. package/skills/dispatcher/SKILL.md +1 -1
  250. package/skills/journey-audit/SKILL.md +269 -0
  251. package/skills/peekaboo-driver/SKILL.md +15 -3
  252. package/skills/persona-panel/SKILL.md +1 -1
  253. package/skills/reconcile/SKILL.md +41 -1
  254. package/skills/session-end/SKILL.md +17 -4
  255. package/skills/session-end/metrics-collection.md +7 -4
  256. package/skills/session-end/phase-3-6-tail.md +11 -3
  257. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  258. package/skills/session-plan/SKILL.md +6 -1
  259. package/skills/session-plan/wave-template.md +1 -0
  260. package/skills/session-start/SKILL.md +30 -16
  261. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  262. package/skills/session-start/phase-8-5-express-path.md +77 -12
  263. package/skills/vault-sync/validator.mjs +31 -0
  264. package/skills/wave-executor/SKILL.md +4 -2
  265. package/skills/wave-executor/circuit-breaker.md +34 -9
  266. package/skills/wave-executor/wave-loop.md +102 -19
  267. package/templates/_shared/journey-manifest.md +110 -0
  268. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -1,25 +1,39 @@
1
1
  /**
2
2
  * writer.mjs — FA3 writer seam for the Reconciliation Engine (Epic #693, issue #696).
3
3
  *
4
- * Persists APPROVED reconciliation rule proposals to `.claude/rules/` AFTER
5
- * operator approval. This is the one and only module that writes `.claude/rules/`
6
- * on behalf of the engine — the FA2 engine/renderer NEVER touch the filesystem
7
- * for rule files.
4
+ * Persists APPROVED reconciliation rule proposals AFTER operator approval, to
5
+ * every target named in `opts.targets` (issue #1099 `repo-local`
6
+ * `<repoRoot>/.claude/rules/`, `baseline` `<baselineRoot>/proposals/`; the
7
+ * CLOSED table is {@link TARGET_DIRS}, and a target with no row writes nothing).
8
+ * This is the one and only module that writes rule files on behalf of the engine
9
+ * — the FA2 engine/renderer NEVER touch the filesystem for rule files.
8
10
  *
9
11
  * Responsibilities:
10
12
  * - Acquire a per-write file lock (`.orchestrator/rules.lock`) to serialise
11
13
  * concurrent writers — mirrors PSA-005 (withStateMdLock) pattern.
12
14
  * - For each approved proposal: path-safety guard → STRUCTURAL content gate
13
15
  * (#1015, see {@link frontmatterRefusalReason}) → mkdirSync → atomic
14
- * tmp+rename write.
15
- * - For each rejected proposal: JSONL-append to `.orchestrator/reconcile.rejected.log`.
16
+ * tmp+rename write → stamp the idempotency sidecar terminal via
17
+ * `markCandidateProcessed` (issue #484 point 1) so a later reconcile run's
18
+ * `isProcessed()` check does not re-propose the same learning.
19
+ * - For each rejected proposal: JSONL-append to `.orchestrator/reconcile.rejected.log`,
20
+ * and — for an OPERATOR rejection only (see {@link isOperatorRejection}) —
21
+ * stamp the idempotency sidecar terminal with `outcome: 'rejected'` (issue
22
+ * #1042) so the operator's "no" survives into the next run.
16
23
  * - Never throws — all failures are collected into errors[] and returned.
17
24
  *
18
- * Path-safety:
19
- * - `validatePathInsideProject(item.path, repoRoot, {canonicalizeRoot:true})` is
25
+ * Path-safety (re-anchored per target in #1099, NOT widened):
26
+ * - `validatePathInsideProject(<rel>, <target root>, {canonicalizeRoot:true})` is
20
27
  * the primary guard (two-phase lexical + realpath, CWE-22 defence).
21
- * - Additional assertion: resolved path must be inside `<repoRoot>/.claude/rules/`.
22
- * - Both guards must pass; failure skips the record and pushes an error string.
28
+ * - Additional assertion: the resolved path must be inside that target's fixed
29
+ * subdirectory. Anchored on `repoRoot` the pre-#1099 hardcoding it would
30
+ * reject every baseline path, which is why re-anchoring is the fix.
31
+ * - For `leaf: 'slug'` targets the filename is derived from `item.slug`, never
32
+ * from `item.path`, and must match {@link SLUG_RE}. `slug` is
33
+ * `kebab()`-produced (`[a-z0-9-]` only), so that branch has no
34
+ * attacker-controllable path component at all.
35
+ * - All applicable guards must pass; failure skips the (item, target) pair and
36
+ * pushes an error string.
23
37
  *
24
38
  * Atomic write strategy (rule files):
25
39
  * - Write content to `<target>.XXXXXXXX.tmp` via `writeFileSync`, then
@@ -40,13 +54,14 @@
40
54
  * @module reconcile/writer
41
55
  */
42
56
 
43
- import { mkdirSync, writeFileSync, renameSync, appendFileSync, realpathSync } from 'node:fs';
57
+ import { mkdirSync, writeFileSync, renameSync, appendFileSync, realpathSync, statSync } from 'node:fs';
44
58
  import { randomBytes } from 'node:crypto';
45
59
  import path from 'node:path';
46
60
 
47
61
  import { withFileLock } from '../file-lock.mjs';
48
62
  import { validatePathInsideProject } from '../path-utils.mjs';
49
63
  import { parseGlobsFrontmatter } from '../rule-loader.mjs';
64
+ import { markCandidateProcessed } from './idempotency.mjs';
50
65
 
51
66
  // ---------------------------------------------------------------------------
52
67
  // Path constants (relative to repoRoot)
@@ -61,19 +76,220 @@ const RULES_DIR_REL = path.join('.claude', 'rules');
61
76
  /** Rejected-proposals log for rules declined by the operator (repo-relative). */
62
77
  const REJECTED_LOG_REL = path.join('.orchestrator', 'reconcile.rejected.log');
63
78
 
79
+ /**
80
+ * CLOSED write-target table (issue #1099).
81
+ *
82
+ * A target with NO ROW HERE writes NOTHING — the absence of a row IS the
83
+ * refusal, so adding a target stays a deliberate, reviewable act rather than a
84
+ * fall-through. `global` is documented-but-unimplemented upstream (see
85
+ * `VALID_TARGETS` in `scripts/lib/config/reconcile.mjs`) and deliberately has no
86
+ * row here either.
87
+ *
88
+ * Per row:
89
+ * - `root` — which key of the caller-supplied roots map anchors the write.
90
+ * - `subdir` — the fixed subdirectory under that root. NEVER caller-supplied.
91
+ * - `leaf` — where the FILENAME comes from:
92
+ * `'path'` → `item.path` (repo-local). This is the pre-#1099 contract and
93
+ * stays: the three live path-traversal tests in
94
+ * `tests/lib/reconcile/writer.test.mjs` are the standing proof that its
95
+ * guard bites, and switching repo-local to slug-derivation would make
96
+ * `item.path` unreachable and silently retire them.
97
+ * `'slug'` → `item.slug` (baseline). `slug` comes from `deriveSlug`
98
+ * (`renderer.mjs`), which is `kebab()`-produced and therefore
99
+ * `[a-z0-9-]`-only — so the baseline branch has NO attacker-controllable
100
+ * path component at all. {@link SLUG_RE} re-asserts that here rather than
101
+ * trusting the upstream derivation.
102
+ * - `requireExistingRoot` — when true the root must ALREADY exist as a
103
+ * directory and is NEVER created. A typo'd baseline path must not
104
+ * silently mint a whole directory tree that looks like a successful write.
105
+ */
106
+ const TARGET_DIRS = Object.freeze({
107
+ 'repo-local': Object.freeze({
108
+ root: 'repoRoot',
109
+ subdir: RULES_DIR_REL,
110
+ leaf: 'path',
111
+ requireExistingRoot: false,
112
+ }),
113
+ baseline: Object.freeze({
114
+ root: 'baselineRoot',
115
+ subdir: 'proposals',
116
+ leaf: 'slug',
117
+ requireExistingRoot: true,
118
+ }),
119
+ });
120
+
121
+ /** Default target set — byte-identical to the pre-#1099 behaviour. */
122
+ const DEFAULT_TARGETS = Object.freeze(['repo-local']);
123
+
124
+ /**
125
+ * The only shape a slug-derived filename may take. Mirrors exactly what
126
+ * `kebab()` (`scripts/lib/learnings/kebab.mjs`) can produce: lowercase
127
+ * alphanumerics and hyphens, never a leading hyphen. `.`, `..`, `/`, `\` and
128
+ * every absolute form are unrepresentable, so a crafted slug cannot traverse.
129
+ */
130
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
131
+
64
132
  // ---------------------------------------------------------------------------
65
133
  // Internal helpers
66
134
  // ---------------------------------------------------------------------------
67
135
 
68
136
  /**
69
- * Resolve the absolute rules directory for the given repoRoot. Used both for
70
- * the per-proposal assertion and for mkdirSync.
137
+ * Normalise the caller's target list: strings only, de-duplicated,
138
+ * order-preserving.
139
+ *
140
+ * OMITTED (`undefined`/non-array) ⇒ {@link DEFAULT_TARGETS} — the pre-#1099
141
+ * back-compat path. An EXPLICIT empty array ⇒ stays empty, and that distinction
142
+ * is load-bearing rather than pedantic: `resolveEffectiveTargets`
143
+ * (`engine.mjs`) returns `[]` when `targets: [baseline]` was declared and the
144
+ * baseline root turned out unusable. Defaulting that `[]` back to
145
+ * `['repo-local']` would silently redirect a baseline-only write INTO this repo
146
+ * — the operator asked for one destination and would get a different one.
147
+ * Nothing is written for an empty list; the caller sees one `errors[]` entry
148
+ * rather than a success-shaped no-op.
149
+ *
150
+ * @param {unknown} targets
151
+ * @returns {string[]}
152
+ */
153
+ function normalizeTargets(targets) {
154
+ if (!Array.isArray(targets)) return [...DEFAULT_TARGETS];
155
+ return [...new Set(targets.filter((t) => typeof t === 'string' && t.length > 0))];
156
+ }
157
+
158
+ /**
159
+ * @typedef {Object} PreparedTarget
160
+ * @property {boolean} ok - false ⇒ every write to this target is skipped.
161
+ * @property {string} [dir] - canonical absolute directory writes land in.
162
+ * @property {string} [root]- canonical absolute root the confinement anchors on.
163
+ * @property {object} [spec]- the {@link TARGET_DIRS} row.
164
+ */
165
+
166
+ /**
167
+ * Prepare ONE write target: resolve its root, refuse a missing or non-existent
168
+ * root, create only the fixed subdirectory, and run the parent-symlink
169
+ * hardening check.
170
+ *
171
+ * Runs once per distinct target rather than once per batch (pre-#1099 it was a
172
+ * single `rulesDirSafe` boolean): with two targets a symlinked `.claude/rules/`
173
+ * must disqualify repo-local WITHOUT also disqualifying baseline, and vice
174
+ * versa.
71
175
  *
72
- * @param {string} repoRoot
73
- * @returns {string}
176
+ * @param {string} target
177
+ * @param {{repoRoot?: string, baselineRoot?: string}} roots
178
+ * @param {string[]} errors - mutated in place with any refusal reason.
179
+ * @returns {PreparedTarget}
74
180
  */
75
- function rulesAbsDir(repoRoot) {
76
- return path.resolve(repoRoot, RULES_DIR_REL);
181
+ function prepareTarget(target, roots, errors) {
182
+ const spec = TARGET_DIRS[target];
183
+ if (!spec) {
184
+ errors.push(
185
+ `target "${target}": no row in the write-target table — nothing written (known targets: ${Object.keys(TARGET_DIRS).join(', ')})`,
186
+ );
187
+ return { ok: false };
188
+ }
189
+
190
+ const root = roots[spec.root];
191
+ if (typeof root !== 'string' || root.length === 0) {
192
+ errors.push(`target "${target}": no ${spec.root} supplied — skipped (no-op, not a failure)`);
193
+ return { ok: false };
194
+ }
195
+
196
+ if (spec.requireExistingRoot) {
197
+ let isDir;
198
+ try {
199
+ isDir = statSync(root).isDirectory();
200
+ } catch {
201
+ isDir = false;
202
+ }
203
+ if (!isDir) {
204
+ errors.push(
205
+ `target "${target}": root "${root}" does not exist as a directory — skipped; a non-existent root is NEVER created (a typo would otherwise mint a directory tree that looks like a successful write)`,
206
+ );
207
+ return { ok: false };
208
+ }
209
+ }
210
+
211
+ let canonRoot = root;
212
+ try {
213
+ canonRoot = realpathSync(root);
214
+ } catch {
215
+ /* ENOENT/EACCES: fall back to the lexical root */
216
+ }
217
+ const expectedDir = path.resolve(canonRoot, spec.subdir);
218
+
219
+ // Parent-directory symlink hardening (#697 security follow-up, re-anchored per
220
+ // target in #1099): if the subdir is itself a pre-planted symlink to a
221
+ // directory outside its root, a lexically-safe leaf path would still be
222
+ // written through it. Requires local FS write access to exploit (below the VCS
223
+ // trust boundary) but the guard is one cheap call. mkdir creates ONLY the
224
+ // fixed subdir — the root's own existence was decided above.
225
+ try {
226
+ mkdirSync(path.resolve(root, spec.subdir), { recursive: true });
227
+ const realDir = realpathSync(path.resolve(root, spec.subdir));
228
+ if (realDir !== expectedDir && !realDir.startsWith(expectedDir + path.sep)) {
229
+ errors.push(
230
+ `path-confinement: ${spec.subdir}/ resolves outside "${root}" (symlinked dir) — all approved writes to target "${target}" skipped`,
231
+ );
232
+ return { ok: false };
233
+ }
234
+ } catch {
235
+ /* mkdir/realpath failure — per-item writes will surface errors normally */
236
+ }
237
+
238
+ return { ok: true, dir: expectedDir, root: canonRoot, spec };
239
+ }
240
+
241
+ /**
242
+ * Resolve the absolute write target for ONE (item, target) pair.
243
+ *
244
+ * Both leaf strategies end in the SAME two guards — `validatePathInsideProject`
245
+ * (two-phase lexical + realpath, CWE-22 defence) plus a belt-and-braces
246
+ * `startsWith` assertion — anchored on the PER-TARGET root. Anchoring them on
247
+ * `repoRoot` (the pre-#1099 hardcoding) would reject every baseline path, so
248
+ * re-anchoring is what keeps this a re-aim rather than a widening.
249
+ *
250
+ * @param {WriterApprovedItem} item
251
+ * @param {string} target
252
+ * @param {PreparedTarget} prep
253
+ * @param {{repoRoot?: string, baselineRoot?: string}} roots
254
+ * @param {string[]} errors - mutated in place with any refusal reason.
255
+ * @returns {string|null} absolute destination path, or null when refused.
256
+ */
257
+ function resolveDest(item, target, prep, roots, errors) {
258
+ const spec = prep.spec;
259
+ const root = roots[spec.root];
260
+
261
+ /** @type {string} */
262
+ let candidateRel;
263
+
264
+ if (spec.leaf === 'slug') {
265
+ const slug = item.slug;
266
+ if (typeof slug !== 'string' || !SLUG_RE.test(slug)) {
267
+ errors.push(
268
+ `slug-safety: target "${target}" derives its filename from item.slug, and ${JSON.stringify(slug)} is not a bare kebab slug (${SLUG_RE}) — skipped`,
269
+ );
270
+ return null;
271
+ }
272
+ candidateRel = path.join(spec.subdir, `${slug}.md`);
273
+ } else {
274
+ if (!item || typeof item.path !== 'string' || item.path.length === 0) {
275
+ errors.push(`approved item missing path: ${JSON.stringify(item)}`);
276
+ return null;
277
+ }
278
+ candidateRel = item.path;
279
+ }
280
+
281
+ const pathResult = validatePathInsideProject(candidateRel, root, { canonicalizeRoot: true });
282
+ if (!pathResult.ok) {
283
+ errors.push(`path-safety (${pathResult.reason}): "${candidateRel}" [target ${target}] — skipped`);
284
+ return null;
285
+ }
286
+
287
+ const absPath = pathResult.realPath ?? pathResult.lexicalPath;
288
+ if (!absPath.startsWith(prep.dir + path.sep) && absPath !== prep.dir) {
289
+ errors.push(`path-confinement: "${candidateRel}" resolves outside ${spec.subdir}/ [target ${target}] — skipped`);
290
+ return null;
291
+ }
292
+ return absPath;
77
293
  }
78
294
 
79
295
  /**
@@ -151,7 +367,15 @@ function writeTextAtomic(destPath, content) {
151
367
  * caller may still write a document with no frontmatter.
152
368
  *
153
369
  * CEILING (BV-004): the marker set is a fixed three-key list, not a schema
154
- * lookup. Revisit if the renderer gains a fourth provenance key, or if a second
370
+ * lookup and the renderer HAS since gained a fourth provenance key
371
+ * (`evidence-digest: sha256-v1:<hex>`). That key is deliberately NOT in the
372
+ * marker set, and the ceiling did not need raising, because this gate is
373
+ * POSITIVE-KEY-ONLY: it asks whether the required keys are PRESENT, never
374
+ * whether an unknown key appeared. A new renderer key therefore passes
375
+ * untouched by construction — pinned by the `evidence-digest` case in
376
+ * `tests/lib/reconcile/writer.test.mjs`. Add a key to the marker set only when
377
+ * its ABSENCE should refuse a write; adding `evidence-digest` there would refuse
378
+ * every document rendered before the key existed. Revisit if a second
155
379
  * non-reconcile caller of `writeApprovedRules` appears — today there is exactly
156
380
  * one production caller and it passes renderer output.
157
381
  *
@@ -219,6 +443,42 @@ function frontmatterRefusalReason(content) {
219
443
  return `rule fails the never-always-on invariant: ${problems.join('; ')}`;
220
444
  }
221
445
 
446
+ /**
447
+ * True iff a rejected item is an OPERATOR rejection — a proposal that was
448
+ * rendered, surfaced in the approval AUQ and then declined — as opposed to an
449
+ * engine-side rejection (ineligible, `capped — max-proposals-per-run`, or
450
+ * already-materialized).
451
+ *
452
+ * The distinction decides whether the item gets a TERMINAL sidecar stamp, so it
453
+ * has to be conservative in a specific direction. A false negative costs
454
+ * nothing beyond today's behaviour (the learning is simply re-proposed next
455
+ * run); a false positive would stamp an engine rejection terminal and thereby
456
+ * permanently suppress a learning that was only capped by the volume brake or
457
+ * not yet mature enough — silently, and with no way back short of editing the
458
+ * sidecar by hand.
459
+ *
460
+ * The discriminator is the rendered `content`: `engine.mjs` pushes its own
461
+ * rejections as `{learningKey, type, reason, status:'rejected'}` — they never
462
+ * reach the renderer, so they never carry `content`/`slug`/`path` — while the
463
+ * proposals the operator declines are full `ReconcileProposal` records whose
464
+ * `content` is the very rule text the AUQ showed him.
465
+ *
466
+ * CEILING (BV-004): this reads an implicit signal, not an explicit marker,
467
+ * because the one production caller (`skills/session-end/phase-3-6-tail.md`
468
+ * step 6/7) concatenates engine rejections and operator-declined proposals into
469
+ * ONE `rejected` array and marks neither. Revisit if that caller starts passing
470
+ * rendered content on engine-side rejections, or if it gains an explicit
471
+ * operator-rejection flag — then key on the flag instead.
472
+ *
473
+ * @param {WriterRejectedItem & {content?: unknown, learningKey?: unknown}} item
474
+ * @returns {boolean}
475
+ */
476
+ function isOperatorRejection(item) {
477
+ if (!item || typeof item !== 'object') return false;
478
+ if (typeof item.content !== 'string' || item.content.length === 0) return false;
479
+ return typeof item.learningKey === 'string' && item.learningKey.length > 0;
480
+ }
481
+
222
482
  // ---------------------------------------------------------------------------
223
483
  // Public API
224
484
  // ---------------------------------------------------------------------------
@@ -226,7 +486,10 @@ function frontmatterRefusalReason(content) {
226
486
  /**
227
487
  * @typedef {Object} WriterApprovedItem
228
488
  * @property {string} slug - kebab-case slug (from renderer.mjs / engine.mjs).
489
+ * THE filename source for every target whose {@link TARGET_DIRS} row declares
490
+ * `leaf: 'slug'` (today: `baseline`).
229
491
  * @property {string} path - repo-relative rule path (`.claude/rules/<slug>.md`).
492
+ * THE filename source for `leaf: 'path'` targets (today: `repo-local`).
230
493
  * @property {string} content - full rendered markdown content.
231
494
  * @property {string} [learningKey]
232
495
  * @property {number} [confidence]
@@ -235,11 +498,22 @@ function frontmatterRefusalReason(content) {
235
498
  */
236
499
 
237
500
  /**
501
+ * A record in the `rejected` array. Two shapes arrive here through the same
502
+ * array (see {@link isOperatorRejection}):
503
+ * - an ENGINE rejection — `{learningKey, type, reason, status:'rejected'}`;
504
+ * - an OPERATOR-declined proposal — a full `ReconcileProposal`, i.e. the
505
+ * engine shape PLUS `slug`/`path`/`content`/`confidence`/`candidateId`.
506
+ * Only the latter gets a terminal sidecar stamp.
507
+ *
238
508
  * @typedef {Object} WriterRejectedItem
239
509
  * @property {string} [learningKey]
240
510
  * @property {string} [type]
241
511
  * @property {string} [reason]
242
512
  * @property {string} [status]
513
+ * @property {string} [slug] - operator-declined proposals only.
514
+ * @property {string} [content] - operator-declined proposals only; THE discriminator.
515
+ * @property {number} [confidence] - operator-declined proposals only.
516
+ * @property {string} [candidateId] - operator-declined proposals only.
243
517
  */
244
518
 
245
519
  /**
@@ -250,22 +524,45 @@ function frontmatterRefusalReason(content) {
250
524
  */
251
525
 
252
526
  /**
253
- * Persist approved reconciliation rule proposals to `.claude/rules/` and
527
+ * Persist approved reconciliation rule proposals to every requested target and
254
528
  * archive rejected proposals to the rejected log.
255
529
  *
256
530
  * NEVER throws — all per-item failures are collected into `errors[]`.
257
531
  *
532
+ * Targets (issue #1099): `targets` names the {@link TARGET_DIRS} rows to write.
533
+ * Omitted ⇒ `['repo-local']` ⇒ byte-identical to the pre-#1099 behaviour. One
534
+ * approved proposal written to two targets counts TWICE in `written` (it is a
535
+ * file count, not a proposal count) and stamps the idempotency sidecar ONCE.
536
+ *
537
+ * `baselineRoot` ABSENT IS NOT AN ERROR — it is the documented no-op path: a
538
+ * `baseline` target with no root, a root that is a placeholder, or a root that
539
+ * does not exist on disk each skip that target with an `errors[]` entry while
540
+ * every other target still writes. The caller is expected to have dropped
541
+ * `baseline` from `targets` upstream in that case (see `resolveEffectiveTargets`
542
+ * in `scripts/lib/reconcile/engine.mjs`); this layer is the second line, held
543
+ * here because it is the only layer holding the filesystem at write time.
544
+ *
258
545
  * @param {Object} opts
259
- * @param {WriterApprovedItem[]} opts.approved - proposals approved by the operator.
260
- * @param {WriterRejectedItem[]} [opts.rejected] - proposals declined by the operator.
261
- * @param {string} opts.repoRoot - absolute repo root path.
262
- * @param {string} [opts.sessionId] - current session id (informational; unused in v1).
546
+ * @param {WriterApprovedItem[]} opts.approved - proposals approved by the operator.
547
+ * @param {WriterRejectedItem[]} [opts.rejected] - proposals declined by the operator.
548
+ * @param {string} opts.repoRoot - absolute repo root path.
549
+ * @param {string} [opts.baselineRoot] - absolute projects-baseline root; absent no-op for the `baseline` target.
550
+ * @param {string[]} [opts.targets] - target ids; default `['repo-local']`.
551
+ * @param {string} [opts.sessionId] - current session id (informational; unused in v1).
263
552
  * @returns {Promise<WriteApprovedRulesResult>}
264
553
  */
265
- export async function writeApprovedRules({ approved, rejected = [], repoRoot, sessionId: _sessionId }) {
554
+ export async function writeApprovedRules({
555
+ approved,
556
+ rejected = [],
557
+ repoRoot,
558
+ baselineRoot,
559
+ targets,
560
+ sessionId: _sessionId,
561
+ }) {
266
562
  // Defensive: coerce inputs
267
563
  const approvedItems = Array.isArray(approved) ? approved : [];
268
564
  const rejectedItems = Array.isArray(rejected) ? rejected : [];
565
+ const effectiveTargets = normalizeTargets(targets);
269
566
 
270
567
  if (approvedItems.length === 0 && rejectedItems.length === 0) {
271
568
  return { written: 0, archived: 0, errors: [] };
@@ -289,80 +586,87 @@ export async function writeApprovedRules({ approved, rejected = [], repoRoot, se
289
586
  let written = 0;
290
587
  let archived = 0;
291
588
 
292
- const rulesDir = rulesAbsDir(repoRoot);
293
-
294
- // Parent-directory symlink hardening (#697 security follow-up): if
295
- // `.claude/rules/` is itself a pre-planted symlink to a directory outside
296
- // the repo, a lexically-safe leaf path would still be written through it.
297
- // Resolve the directory's realpath once and refuse all writes if it
298
- // escapes the canonical repo root. Requires local FS write access to
299
- // exploit (below the VCS trust boundary) but the guard is one cheap call.
300
- let rulesDirSafe = true;
301
- try {
302
- mkdirSync(rulesDir, { recursive: true });
303
- let canonRoot = repoRoot;
304
- try { canonRoot = realpathSync(repoRoot); } catch { /* fall back to lexical */ }
305
- const expectedRulesDir = path.resolve(canonRoot, RULES_DIR_REL);
306
- const realRulesDir = realpathSync(rulesDir);
307
- if (realRulesDir !== expectedRulesDir && !realRulesDir.startsWith(expectedRulesDir + path.sep)) {
308
- errors.push('path-confinement: .claude/rules/ resolves outside the repo (symlinked dir) — all approved writes skipped');
309
- rulesDirSafe = false;
589
+ const roots = { repoRoot, baselineRoot };
590
+ // Shared stamp for every candidate this batch writes — mirrors `rejectedAt`
591
+ // below (step 2), one timestamp per invocation rather than one per item.
592
+ const approvedAt = new Date().toISOString();
593
+
594
+ // Per-target pre-flight root resolution, existence refusal, subdir
595
+ // creation and parent-symlink hardening, once per DISTINCT target rather
596
+ // than once per batch or once per item.
597
+ /** @type {Map<string, PreparedTarget>} */
598
+ const prepared = new Map();
599
+ if (approvedItems.length > 0) {
600
+ if (effectiveTargets.length === 0) {
601
+ errors.push(
602
+ `no write target in effect — ${approvedItems.length} approved proposal(s) not written (an explicitly empty \`targets\` is honoured, never defaulted back to repo-local)`,
603
+ );
604
+ }
605
+ for (const target of effectiveTargets) {
606
+ prepared.set(target, prepareTarget(target, roots, errors));
310
607
  }
311
- } catch { /* mkdir/realpath failure — per-item writes will surface errors normally */ }
608
+ }
312
609
 
313
610
  // ── Step 1: write approved rule files ──────────────────────────────────
314
611
  for (const item of approvedItems) {
315
- if (!rulesDirSafe) break;
316
- // Guard: item must have a path string
317
- if (!item || typeof item.path !== 'string' || item.path.length === 0) {
318
- errors.push(`approved item missing path: ${JSON.stringify(item)}`);
319
- continue;
320
- }
321
-
322
- // Primary path-safety guard (two-phase lexical + realpath, CWE-22 defence)
323
- const pathResult = validatePathInsideProject(item.path, repoRoot, { canonicalizeRoot: true });
324
- if (!pathResult.ok) {
325
- errors.push(`path-safety (${pathResult.reason}): "${item.path}" — skipped`);
326
- continue;
327
- }
328
-
329
- // Resolve the absolute write target from the validated lexical path
330
- const absPath = pathResult.realPath ?? pathResult.lexicalPath;
331
-
332
- // Defense-in-depth: assert the resolved path is inside .claude/rules/.
333
- // Canonicalize repoRoot the same way validatePathInsideProject does
334
- // (opts.canonicalizeRoot:true) so the prefix check is consistent on
335
- // platforms where os.tmpdir() has a symlink (e.g. macOS /var → /private/var).
336
- let canonRoot = repoRoot;
337
- try { canonRoot = realpathSync(repoRoot); } catch { /* ENOENT/EACCES: fall back to lexical */ }
338
- const resolvedRulesDir = path.resolve(canonRoot, RULES_DIR_REL);
339
- if (!absPath.startsWith(resolvedRulesDir + path.sep) && absPath !== resolvedRulesDir) {
340
- errors.push(`path-confinement: "${item.path}" resolves outside .claude/rules/ — skipped`);
341
- continue;
342
- }
612
+ // Item-level guards run ONCE, before any target loop: content is a
613
+ // property of the proposal, not of where it lands.
343
614
 
344
615
  // Guard: content must be a string
345
- if (typeof item.content !== 'string') {
346
- errors.push(`approved item "${item.path}" has non-string content skipped`);
616
+ if (!item || typeof item.content !== 'string') {
617
+ const label = item && typeof item.path === 'string' ? item.path : `slug=${item && item.slug}`;
618
+ errors.push(`approved item "${label}" has non-string content — skipped`);
347
619
  continue;
348
620
  }
349
621
 
350
- // Structural content gate (#1015) — runs BEFORE any mkdir/tmp-file
351
- // creation, so a refused write leaves no `.tmp` residue behind.
622
+ // Structural content gate (#1015) — runs BEFORE any tmp-file creation,
623
+ // so a refused write leaves no `.tmp` residue behind.
352
624
  const refusal = frontmatterRefusalReason(item.content);
353
625
  if (refusal !== null) {
354
- errors.push(`content-structure: "${item.path}" ${refusal} — skipped`);
626
+ const label = typeof item.path === 'string' ? item.path : `slug=${item.slug}`;
627
+ errors.push(`content-structure: "${label}" — ${refusal} — skipped`);
355
628
  continue;
356
629
  }
357
630
 
358
- // Ensure .claude/rules/ exists and write atomically
359
- try {
360
- mkdirSync(rulesDir, { recursive: true });
361
- writeTextAtomic(absPath, item.content);
362
- written++;
363
- } catch (err) {
364
- const msg = err && err.message ? err.message : String(err);
365
- errors.push(`write failed "${item.path}": ${msg}`);
631
+ let writeOk = false;
632
+ for (const target of effectiveTargets) {
633
+ const prep = prepared.get(target);
634
+ if (!prep || !prep.ok) continue;
635
+
636
+ const absPath = resolveDest(item, target, prep, roots, errors);
637
+ if (absPath === null) continue;
638
+
639
+ try {
640
+ writeTextAtomic(absPath, item.content);
641
+ written++;
642
+ writeOk = true;
643
+ } catch (err) {
644
+ const msg = err && err.message ? err.message : String(err);
645
+ errors.push(`write failed "${absPath}" [target ${target}]: ${msg}`);
646
+ }
647
+ }
648
+
649
+ // Stamp the idempotency sidecar (issue #484 point 1): once a rule
650
+ // file is on disk, the candidate that proposed it must be marked
651
+ // terminal, or the next reconcile run has no way to know the
652
+ // proposal was already acted on and re-proposes it. Best-effort and
653
+ // gated on a successful write — the rule file landing is the thing
654
+ // that matters; a stamp failure is reported but does not undo it.
655
+ if (writeOk && typeof item.learningKey === 'string' && item.learningKey.length > 0) {
656
+ const stampResult = markCandidateProcessed({
657
+ learningKey: item.learningKey,
658
+ outcome: 'written',
659
+ processedAt: approvedAt,
660
+ fallbackSlug: item.slug,
661
+ fallbackCandidateId: item.candidateId,
662
+ fallbackConfidence: item.confidence,
663
+ repoRoot,
664
+ });
665
+ if (!stampResult.written) {
666
+ errors.push(
667
+ `sidecar-stamp failed for "${item.path ?? item.slug}" (learningKey=${item.learningKey}) — rule file was written but the idempotency sidecar was not updated`,
668
+ );
669
+ }
366
670
  }
367
671
  }
368
672
 
@@ -398,6 +702,33 @@ export async function writeApprovedRules({ approved, rejected = [], repoRoot, se
398
702
  const line = JSON.stringify(archiveRecord) + '\n';
399
703
  appendFileSync(rejectedLogPath, line, 'utf8');
400
704
  archived++;
705
+
706
+ // Stamp the idempotency sidecar for an OPERATOR rejection
707
+ // (issue #1042): the rejected log is an append-only AUDIT trail —
708
+ // nothing reads it back — so without this stamp `isProcessed()`
709
+ // finds no terminal verdict for the learning and the next run
710
+ // re-proposes the exact rule the operator just declined. Same
711
+ // shape and same single writer as the approved path above; only
712
+ // `outcome` differs. Engine-side rejections are deliberately NOT
713
+ // stamped (see `isOperatorRejection`) — a capped or not-yet-mature
714
+ // learning must stay proposable. Best-effort and gated on a
715
+ // successful append, mirroring the approved path.
716
+ if (isOperatorRejection(item)) {
717
+ const stampResult = markCandidateProcessed({
718
+ learningKey: item.learningKey,
719
+ outcome: 'rejected',
720
+ processedAt: rejectedAt,
721
+ fallbackSlug: item.slug,
722
+ fallbackCandidateId: item.candidateId,
723
+ fallbackConfidence: item.confidence,
724
+ repoRoot,
725
+ });
726
+ if (!stampResult.written) {
727
+ errors.push(
728
+ `sidecar-stamp failed for rejected "${item.learningKey}" — the rejection was archived but the idempotency sidecar was not updated, so a later run may re-propose it`,
729
+ );
730
+ }
731
+ }
401
732
  } catch (err) {
402
733
  const msg = err && err.message ? err.message : String(err);
403
734
  const key = (item && item.learningKey) ? item.learningKey : '<unknown>';