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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +30 -16
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- 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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
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(
|
|
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
|
|
22
|
-
*
|
|
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
|
-
*
|
|
70
|
-
*
|
|
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}
|
|
73
|
-
* @
|
|
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
|
|
76
|
-
|
|
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
|
|
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
|
|
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
|
|
260
|
-
* @param {WriterRejectedItem[]} [opts.rejected]
|
|
261
|
-
* @param {string} opts.repoRoot
|
|
262
|
-
* @param {string} [opts.
|
|
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({
|
|
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
|
|
293
|
-
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
}
|
|
608
|
+
}
|
|
312
609
|
|
|
313
610
|
// ── Step 1: write approved rule files ──────────────────────────────────
|
|
314
611
|
for (const item of approvedItems) {
|
|
315
|
-
|
|
316
|
-
//
|
|
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
|
-
|
|
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
|
|
351
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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>';
|