session-orchestrator 3.22.0 → 3.24.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/remote-offload/SKILL.md +13 -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 +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- 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 +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- 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 +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -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 +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- 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/hooks/subagent-telemetry.mjs +11 -26
- 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 +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- 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/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- 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/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- 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/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- 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 +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- 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 +517 -18
- 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 +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- 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 +220 -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-canonical.mjs +446 -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/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- 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 +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- 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-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- 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-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- 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/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- 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 +20 -9
- 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 +54 -17
- 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 +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -33,10 +33,16 @@
|
|
|
33
33
|
* the engine performs is into the reconcile-candidates sidecar (via
|
|
34
34
|
* idempotency.mjs), and even that is skipped under `dryRun`.
|
|
35
35
|
*
|
|
36
|
-
* ── never-throws contract
|
|
37
|
-
* `runReconcile` NEVER throws to its caller
|
|
38
|
-
* degrades to a recorded rejection (never a
|
|
39
|
-
* error returns a zeroed result
|
|
36
|
+
* ── never-throws contract (PIPELINE errors only) ─────────────────────────────
|
|
37
|
+
* Once the pipeline is running, `runReconcile` NEVER throws to its caller: a
|
|
38
|
+
* per-learning emit/render failure degrades to a recorded rejection (never a
|
|
39
|
+
* crash), and any unexpected top-level pipeline error returns a zeroed result
|
|
40
|
+
* with an `error` field. The ONE exception is a caller-INPUT validation error
|
|
41
|
+
* at the very top of `runReconcile` — an explicitly-passed `trigger` outside
|
|
42
|
+
* `KNOWN_TRIGGERS` (issue #1201 Part A) THROWS synchronously before the
|
|
43
|
+
* pipeline ever starts, the same class of failure as passing a malformed
|
|
44
|
+
* `params` object to any other function in this codebase; see
|
|
45
|
+
* `assertKnownTrigger` below.
|
|
40
46
|
*
|
|
41
47
|
* Plain Node ESM, no external deps — Node 20+ stdlib + the four siblings only.
|
|
42
48
|
*
|
|
@@ -70,6 +76,16 @@
|
|
|
70
76
|
* `capped` is a diagnostic sub-count that lets a report distinguish
|
|
71
77
|
* "genuinely ineligible" rejections from "eligible but cut by the volume
|
|
72
78
|
* brake" ones at a glance.
|
|
79
|
+
* @property {number} alreadyMaterialized - count of eligible learnings that were
|
|
80
|
+
* NOT proposed this run because they are already terminal — either the
|
|
81
|
+
* idempotency sidecar already carries a `processed_at` stamp for their
|
|
82
|
+
* `learning_key` (`isProcessed`), or a `.claude/rules/*.md` file already
|
|
83
|
+
* carries a matching `learning-key`/`learning-id` provenance marker
|
|
84
|
+
* (issue #484: 9 of 10 proposals in one run were exactly this). Runs
|
|
85
|
+
* BEFORE the `maxProposalsPerRun` volume brake so an already-materialized
|
|
86
|
+
* learning never consumes a new learning's quota. Same accounting pattern
|
|
87
|
+
* as `capped`: each one is ALSO counted inside `rejected`, and
|
|
88
|
+
* `totalLearnings === proposed + rejected` still holds unchanged.
|
|
73
89
|
* @property {boolean} written
|
|
74
90
|
* @property {number} [skipped] - how many persisted sidecar lines the store's
|
|
75
91
|
* read-side shape guard rejected and this run therefore DROPPED from disk
|
|
@@ -88,9 +104,10 @@
|
|
|
88
104
|
* @property {string} [error] - present only when the never-throws top-level guard fired.
|
|
89
105
|
*/
|
|
90
106
|
|
|
91
|
-
import { readFileSync } from 'node:fs';
|
|
107
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
92
108
|
import { isAbsolute, join } from 'node:path';
|
|
93
109
|
|
|
110
|
+
import { expandTilde } from '../common.mjs';
|
|
94
111
|
import { learningKeyOf } from '../learnings/kebab.mjs';
|
|
95
112
|
import { migrateLegacyLearning, normalizeLearning } from '../learnings/schema.mjs';
|
|
96
113
|
import { filterEligible } from './eligibility.mjs';
|
|
@@ -99,7 +116,9 @@ import { renderRule } from './renderer.mjs';
|
|
|
99
116
|
import {
|
|
100
117
|
DEFAULT_STORE_PATH,
|
|
101
118
|
buildCandidate,
|
|
119
|
+
isProcessed,
|
|
102
120
|
makeCandidateId,
|
|
121
|
+
loadCandidates as realLoadCandidates,
|
|
103
122
|
mergeCandidates as realMergeCandidates,
|
|
104
123
|
} from './idempotency.mjs';
|
|
105
124
|
|
|
@@ -130,6 +149,7 @@ function zeroedResult(error) {
|
|
|
130
149
|
proposed: 0,
|
|
131
150
|
rejected: 0,
|
|
132
151
|
capped: 0,
|
|
152
|
+
alreadyMaterialized: 0,
|
|
133
153
|
written: false,
|
|
134
154
|
},
|
|
135
155
|
};
|
|
@@ -238,6 +258,172 @@ function warnDroppedStoreRecords(skipped) {
|
|
|
238
258
|
}
|
|
239
259
|
}
|
|
240
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Session Config placeholder convention: a path key whose committed value is a
|
|
263
|
+
* marker that MUST be overridden host-locally (this repo ships
|
|
264
|
+
* `plan-baseline-path: OVERRIDE-IN-owner.yaml` in CLAUDE.md — or AGENTS.md on
|
|
265
|
+
* Codex CLI — § Session Config).
|
|
266
|
+
* Treating the marker as a real path would create `./OVERRIDE-IN-owner.yaml/`.
|
|
267
|
+
*/
|
|
268
|
+
const PLACEHOLDER_PATH_RE = /^OVERRIDE-IN-/;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Report that the `baseline` write-target was dropped for this run.
|
|
272
|
+
*
|
|
273
|
+
* stderr, never stdout — `scripts/lib/config.mjs` consumers parse stdout as JSON.
|
|
274
|
+
* Same posture as {@link warnDroppedStoreRecords}: an attributable drop must be
|
|
275
|
+
* VISIBLE, and a diagnostic must never become the failure it reports on.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} reason
|
|
278
|
+
*/
|
|
279
|
+
function warnBaselineTargetDropped(reason) {
|
|
280
|
+
try {
|
|
281
|
+
console.warn(
|
|
282
|
+
`⚠️ reconcile: target "baseline" DROPPED for this run — ${reason}. ` +
|
|
283
|
+
`Resolution order is SO_BASELINE_PATH env > owner.yaml paths.baseline-path > ` +
|
|
284
|
+
`committed plan-baseline-path. No baseline proposal is surfaced in the approval ` +
|
|
285
|
+
`AUQ and nothing is written outside this repo; every other target is unaffected.`,
|
|
286
|
+
);
|
|
287
|
+
} catch {
|
|
288
|
+
// A diagnostic must never become the failure it reports on.
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Decide which reconcile targets can ACTUALLY be written this run (issue #1099).
|
|
294
|
+
*
|
|
295
|
+
* Two of the three no-op checks live here, upstream of both the approval AUQ and
|
|
296
|
+
* the writer, because the important half is not the refused write — it is that
|
|
297
|
+
* **the operator must never be asked to approve a write to a destination that
|
|
298
|
+
* cannot exist.** A `baseline` target whose root is unresolvable, is still the
|
|
299
|
+
* committed placeholder, or is not absolute is therefore dropped from the
|
|
300
|
+
* effective list BEFORE proposals are surfaced. The third check (the root does
|
|
301
|
+
* not exist on disk) belongs to `writer.mjs`, the only layer holding the
|
|
302
|
+
* filesystem at write time.
|
|
303
|
+
*
|
|
304
|
+
* Pure and never-throws: it resolves nothing from disk and reads no env — the
|
|
305
|
+
* caller passes the already-resolved `baselineRoot` (`config['plan-baseline-path']`,
|
|
306
|
+
* which `scripts/lib/config.mjs` has already run through the full 3-tier chain).
|
|
307
|
+
*
|
|
308
|
+
* @param {{targets?: unknown, baselineRoot?: unknown}} [opts]
|
|
309
|
+
* @returns {{targets: string[], baselineRoot: string|null, dropped: string[], reason: string|null}}
|
|
310
|
+
* `targets` — the effective list; `baselineRoot` — the ~-expanded root, or null
|
|
311
|
+
* when `baseline` is not in play; `dropped`/`reason` — the audit trail.
|
|
312
|
+
*/
|
|
313
|
+
export function resolveEffectiveTargets({ targets, baselineRoot } = {}) {
|
|
314
|
+
const declared = Array.isArray(targets) ? targets.filter((t) => typeof t === 'string' && t.length > 0) : [];
|
|
315
|
+
const list = [...new Set(declared.length > 0 ? declared : ['repo-local'])];
|
|
316
|
+
|
|
317
|
+
if (!list.includes('baseline')) {
|
|
318
|
+
return { targets: list, baselineRoot: null, dropped: [], reason: null };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const raw = typeof baselineRoot === 'string' ? baselineRoot.trim() : '';
|
|
322
|
+
const expanded = raw === '' ? '' : expandTilde(raw);
|
|
323
|
+
|
|
324
|
+
let reason = null;
|
|
325
|
+
if (raw === '') {
|
|
326
|
+
reason = 'no baseline path is configured on any tier';
|
|
327
|
+
} else if (PLACEHOLDER_PATH_RE.test(raw)) {
|
|
328
|
+
reason = `the committed placeholder "${raw}" was never overridden host-locally`;
|
|
329
|
+
} else if (!isAbsolute(expanded)) {
|
|
330
|
+
reason = `"${raw}" is not an absolute path after ~-expansion`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (reason === null) {
|
|
334
|
+
return { targets: list, baselineRoot: expanded, dropped: [], reason: null };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
warnBaselineTargetDropped(reason);
|
|
338
|
+
return {
|
|
339
|
+
targets: list.filter((t) => t !== 'baseline'),
|
|
340
|
+
baselineRoot: null,
|
|
341
|
+
dropped: ['baseline'],
|
|
342
|
+
reason,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Default sidecar-candidate loader for the issue #484 idempotency dedupe
|
|
348
|
+
* check (below, step 3a). Deliberately gated on `repoRoot` being a
|
|
349
|
+
* caller-supplied, non-empty string — UNLIKE `defaultLoadLearnings` and
|
|
350
|
+
* `realMergeCandidates`, this does NOT fall back to `process.cwd()` when
|
|
351
|
+
* `repoRoot` is absent. Every existing engine test exercises this module via
|
|
352
|
+
* `opts.learnings` with no `repoRoot`, precisely to avoid touching this repo's
|
|
353
|
+
* OWN `.orchestrator/runtime/reconcile-candidates.jsonl`; a cwd fallback here
|
|
354
|
+
* would silently read it. The disk-touching default is reserved for callers
|
|
355
|
+
* that always pass an explicit `repoRoot` (the `/reconcile` skill resolves it
|
|
356
|
+
* via `git rev-parse --show-toplevel`).
|
|
357
|
+
* @param {string|undefined} repoRoot
|
|
358
|
+
* @returns {{ records: import('./idempotency.mjs').ReconcileCandidate[] }}
|
|
359
|
+
*/
|
|
360
|
+
function defaultLoadCandidatesForDedupe(repoRoot) {
|
|
361
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return { records: [] };
|
|
362
|
+
const { records } = realLoadCandidates({ repoRoot });
|
|
363
|
+
return { records };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Frontmatter form emitted by renderer.mjs: `learning-key: <value>` (no backticks, no leading dash). */
|
|
367
|
+
const FRONTMATTER_LEARNING_KEY_RE = /^learning-key:\s*(.+)$/gm;
|
|
368
|
+
/** Provenance-body form emitted by renderer.mjs: `` - learning-key: `<value>` ``. */
|
|
369
|
+
const BODY_LEARNING_KEY_RE = /-\s*learning-key:\s*`([^`]+)`/g;
|
|
370
|
+
/** Provenance-body form emitted by renderer.mjs: `` - learning-id: `<value>` ``. */
|
|
371
|
+
const BODY_LEARNING_ID_RE = /-\s*learning-id:\s*`([^`]+)`/g;
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Scan `<repoRoot>/.claude/rules/*.md` for the provenance markers the
|
|
375
|
+
* renderer stamps on every machine-generated rule — the frontmatter
|
|
376
|
+
* `learning-key:` line and the body `## Provenance` block's `learning-key`/
|
|
377
|
+
* `learning-id` bullets (`renderer.mjs`) — and return the two identity sets a
|
|
378
|
+
* learning can already be materialized under. A learning whose derived
|
|
379
|
+
* `learning_key` OR raw `.id` appears in either set already has a rule file
|
|
380
|
+
* on disk: re-proposing it is the issue #484 defect (9 of 10 proposals in one
|
|
381
|
+
* run were learnings a `.claude/rules/` file already covered).
|
|
382
|
+
*
|
|
383
|
+
* Gated the same way as {@link defaultLoadCandidatesForDedupe}: an absent
|
|
384
|
+
* `repoRoot` yields empty sets rather than falling back to `process.cwd()`.
|
|
385
|
+
* Never throws — a missing `.claude/rules/` dir or an unreadable file
|
|
386
|
+
* degrades to "nothing materialized" for that source, never a crash.
|
|
387
|
+
* @param {string|undefined} repoRoot
|
|
388
|
+
* @returns {{ keys: Set<string>, ids: Set<string> }}
|
|
389
|
+
*/
|
|
390
|
+
function defaultReadMaterializedProvenance(repoRoot) {
|
|
391
|
+
const keys = new Set();
|
|
392
|
+
const ids = new Set();
|
|
393
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) return { keys, ids };
|
|
394
|
+
|
|
395
|
+
const rulesDir = join(repoRoot, '.claude', 'rules');
|
|
396
|
+
let entries;
|
|
397
|
+
try {
|
|
398
|
+
entries = readdirSync(rulesDir);
|
|
399
|
+
} catch {
|
|
400
|
+
return { keys, ids }; // no rules dir yet → nothing materialized
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
if (!entry.endsWith('.md')) continue;
|
|
405
|
+
let content;
|
|
406
|
+
try {
|
|
407
|
+
content = readFileSync(join(rulesDir, entry), 'utf8');
|
|
408
|
+
} catch {
|
|
409
|
+
continue; // unreadable file — skip it, do not fail the whole scan
|
|
410
|
+
}
|
|
411
|
+
for (const m of content.matchAll(FRONTMATTER_LEARNING_KEY_RE)) {
|
|
412
|
+
const v = m[1].trim();
|
|
413
|
+
if (v) keys.add(v);
|
|
414
|
+
}
|
|
415
|
+
for (const m of content.matchAll(BODY_LEARNING_KEY_RE)) {
|
|
416
|
+
const v = m[1].trim();
|
|
417
|
+
if (v) keys.add(v);
|
|
418
|
+
}
|
|
419
|
+
for (const m of content.matchAll(BODY_LEARNING_ID_RE)) {
|
|
420
|
+
const v = m[1].trim();
|
|
421
|
+
if (v && v !== 'n/a') ids.add(v);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return { keys, ids };
|
|
425
|
+
}
|
|
426
|
+
|
|
241
427
|
/**
|
|
242
428
|
* Run the reconciliation engine.
|
|
243
429
|
*
|
|
@@ -273,10 +459,17 @@ function warnDroppedStoreRecords(skipped) {
|
|
|
273
459
|
* direct learnings injection (takes precedence over `loadLearnings`).
|
|
274
460
|
* @param {typeof realMergeCandidates} [opts.merge]
|
|
275
461
|
* override the sidecar merge (so tests never touch real `.orchestrator/runtime/`).
|
|
462
|
+
* @param {(repoRoot?: string) => { records: import('./idempotency.mjs').ReconcileCandidate[] }} [opts.loadCandidates]
|
|
463
|
+
* override the idempotency-sidecar read used by the issue #484 dedupe step (below,
|
|
464
|
+
* step 3a) — defaults to {@link defaultLoadCandidatesForDedupe} (repoRoot-gated, no
|
|
465
|
+
* cwd fallback; see that function's doc for why).
|
|
466
|
+
* @param {(repoRoot?: string) => { keys: Set<string>, ids: Set<string> }} [opts.readMaterializedProvenance]
|
|
467
|
+
* override the `.claude/rules/` provenance scan used by the same dedupe step —
|
|
468
|
+
* defaults to {@link defaultReadMaterializedProvenance} (same repoRoot gate).
|
|
276
469
|
* @param {boolean} [opts.dryRun] - when true, compute proposals but SKIP the merge entirely.
|
|
277
470
|
* @returns {Promise<ReconcileResult>}
|
|
278
471
|
*/
|
|
279
|
-
|
|
472
|
+
async function runReconcileInner(
|
|
280
473
|
{
|
|
281
474
|
repoRoot,
|
|
282
475
|
ruleExpiryDays,
|
|
@@ -294,6 +487,12 @@ export async function runReconcile(
|
|
|
294
487
|
// (the documented DI seam) — either location flips it on.
|
|
295
488
|
const dryRun = dryRunParam === true || opts.dryRun === true;
|
|
296
489
|
const merge = typeof opts.merge === 'function' ? opts.merge : realMergeCandidates;
|
|
490
|
+
const loadCandidatesForDedupe =
|
|
491
|
+
typeof opts.loadCandidates === 'function' ? opts.loadCandidates : defaultLoadCandidatesForDedupe;
|
|
492
|
+
const readMaterializedProvenance =
|
|
493
|
+
typeof opts.readMaterializedProvenance === 'function'
|
|
494
|
+
? opts.readMaterializedProvenance
|
|
495
|
+
: defaultReadMaterializedProvenance;
|
|
297
496
|
// Volume brake (#900 D) — always active; a missing/invalid override falls
|
|
298
497
|
// back to the same default the Session Config parser uses.
|
|
299
498
|
const maxProposalsPerRun =
|
|
@@ -331,20 +530,8 @@ export async function runReconcile(
|
|
|
331
530
|
minInsightChars,
|
|
332
531
|
});
|
|
333
532
|
|
|
334
|
-
// --- Pipeline step 3b — volume brake (#900 D) ---------------------------
|
|
335
|
-
// Sort eligible learnings by confidence DESC (ties keep their original,
|
|
336
|
-
// stable relative order) and keep only the top `maxProposalsPerRun`. The
|
|
337
|
-
// rest are cut BEFORE they ever reach the emitter — never proposed this
|
|
338
|
-
// run — and recorded as `capped` rejections in step 4b below so a report
|
|
339
|
-
// stays honest about the cut instead of silently dropping them.
|
|
340
533
|
const confidenceOf = (l) =>
|
|
341
534
|
l && typeof l === 'object' && typeof l.confidence === 'number' ? l.confidence : 0;
|
|
342
|
-
const sortedEligible = eligible
|
|
343
|
-
.map((learning, index) => ({ learning, index }))
|
|
344
|
-
.sort((a, b) => confidenceOf(b.learning) - confidenceOf(a.learning) || a.index - b.index)
|
|
345
|
-
.map(({ learning }) => learning);
|
|
346
|
-
const keptEligible = sortedEligible.slice(0, maxProposalsPerRun);
|
|
347
|
-
const cappedEligible = sortedEligible.slice(maxProposalsPerRun);
|
|
348
535
|
|
|
349
536
|
/** @type {ReconcileProposal[]} */
|
|
350
537
|
const proposals = [];
|
|
@@ -353,6 +540,92 @@ export async function runReconcile(
|
|
|
353
540
|
/** @type {import('./idempotency.mjs').ReconcileCandidate[]} */
|
|
354
541
|
const candidates = [];
|
|
355
542
|
|
|
543
|
+
// --- Pipeline step 3a — idempotency + on-disk dedupe (issue #484) ------
|
|
544
|
+
// Runs BEFORE the volume brake (step 3b) so an already-materialized
|
|
545
|
+
// learning does not consume `maxProposalsPerRun` quota that a genuinely
|
|
546
|
+
// new learning could use — the #484 defect measured on a real repo was
|
|
547
|
+
// exactly this: 9 of 10 proposals in one run were learnings that already
|
|
548
|
+
// had a `.claude/rules/` file on disk, crowding out the tenth new one.
|
|
549
|
+
// Two independent sources both count as terminal, either is sufficient:
|
|
550
|
+
// - the idempotency sidecar already carries a `processed_at` stamp for
|
|
551
|
+
// this `learning_key` (`isProcessed`, previously computed but NEVER
|
|
552
|
+
// called from this module — the other half of #484);
|
|
553
|
+
// - a `.claude/rules/*.md` file already carries a matching
|
|
554
|
+
// `learning-key`/`learning-id` provenance marker, discovered by
|
|
555
|
+
// scanning disk directly (covers the case where a rule was written
|
|
556
|
+
// without ever going through this sidecar, e.g. hand-authored).
|
|
557
|
+
const { records: existingCandidates } = loadCandidatesForDedupe(repoRoot) ?? { records: [] };
|
|
558
|
+
const materialized = readMaterializedProvenance(repoRoot) ?? { keys: new Set(), ids: new Set() };
|
|
559
|
+
|
|
560
|
+
/** @type {Array<Record<string, unknown>>} */
|
|
561
|
+
const stillEligible = [];
|
|
562
|
+
let alreadyMaterialized = 0;
|
|
563
|
+
|
|
564
|
+
for (const learning of eligible) {
|
|
565
|
+
const learningKey = rejectedLearningKey(learning);
|
|
566
|
+
const learningId =
|
|
567
|
+
learning &&
|
|
568
|
+
typeof learning === 'object' &&
|
|
569
|
+
typeof learning.id === 'string' &&
|
|
570
|
+
learning.id.length > 0
|
|
571
|
+
? learning.id
|
|
572
|
+
: null;
|
|
573
|
+
|
|
574
|
+
const sidecarTerminal =
|
|
575
|
+
learningKey !== null && isProcessed({ learning_key: learningKey }, existingCandidates);
|
|
576
|
+
const onDisk =
|
|
577
|
+
(learningKey !== null && materialized.keys.has(learningKey)) ||
|
|
578
|
+
(learningId !== null && materialized.ids.has(learningId));
|
|
579
|
+
|
|
580
|
+
if (!sidecarTerminal && !onDisk) {
|
|
581
|
+
stillEligible.push(learning);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
alreadyMaterialized += 1;
|
|
586
|
+
const type = learningType(learning);
|
|
587
|
+
const reason = sidecarTerminal
|
|
588
|
+
? 'already processed — the idempotency sidecar already carries a terminal verdict for this learning-key'
|
|
589
|
+
: 'already materialized — a .claude/rules/ file already carries this learning-key/learning-id';
|
|
590
|
+
rejected.push({ learningKey, type, reason, status: 'rejected' });
|
|
591
|
+
|
|
592
|
+
// Freshly discovered on-disk materialization (not yet reflected in the
|
|
593
|
+
// sidecar): stamp it terminal now so a FUTURE run's `isProcessed()`
|
|
594
|
+
// check catches it without re-scanning `.claude/rules/` every time.
|
|
595
|
+
// Routed through the SAME `candidates` array + `merge()` call as every
|
|
596
|
+
// other record produced this run (step 6 below), so — like everything
|
|
597
|
+
// else here — it is skipped entirely under `dryRun`; this is not a
|
|
598
|
+
// second write path.
|
|
599
|
+
if (onDisk && !sidecarTerminal && learningKey !== null) {
|
|
600
|
+
const stamped = buildCandidate({
|
|
601
|
+
id: makeCandidateId(learningKey, `materialized-${type}`),
|
|
602
|
+
learningKey,
|
|
603
|
+
slug: '',
|
|
604
|
+
status: 'proposed',
|
|
605
|
+
reason,
|
|
606
|
+
confidence: confidenceOf(learning),
|
|
607
|
+
createdAt,
|
|
608
|
+
});
|
|
609
|
+
stamped.processed_at = createdAt;
|
|
610
|
+
stamped.outcome = 'already-on-disk';
|
|
611
|
+
candidates.push(stamped);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// --- Pipeline step 3b — volume brake (#900 D) ---------------------------
|
|
616
|
+
// Sort STILL-eligible learnings (post-dedupe) by confidence DESC (ties
|
|
617
|
+
// keep their original, stable relative order) and keep only the top
|
|
618
|
+
// `maxProposalsPerRun`. The rest are cut BEFORE they ever reach the
|
|
619
|
+
// emitter — never proposed this run — and recorded as `capped`
|
|
620
|
+
// rejections in step 4b below so a report stays honest about the cut
|
|
621
|
+
// instead of silently dropping them.
|
|
622
|
+
const sortedEligible = stillEligible
|
|
623
|
+
.map((learning, index) => ({ learning, index }))
|
|
624
|
+
.sort((a, b) => confidenceOf(b.learning) - confidenceOf(a.learning) || a.index - b.index)
|
|
625
|
+
.map(({ learning }) => learning);
|
|
626
|
+
const keptEligible = sortedEligible.slice(0, maxProposalsPerRun);
|
|
627
|
+
const cappedEligible = sortedEligible.slice(maxProposalsPerRun);
|
|
628
|
+
|
|
356
629
|
// --- Pipeline step 4 — per eligible learning (wrapped per-item) ---------
|
|
357
630
|
for (const learning of keptEligible) {
|
|
358
631
|
try {
|
|
@@ -499,6 +772,7 @@ export async function runReconcile(
|
|
|
499
772
|
proposed: proposals.length,
|
|
500
773
|
rejected: rejected.length,
|
|
501
774
|
capped: cappedEligible.length,
|
|
775
|
+
alreadyMaterialized,
|
|
502
776
|
written,
|
|
503
777
|
};
|
|
504
778
|
// Additive + absence-preserving: the key exists ONLY when the store was
|
|
@@ -512,3 +786,228 @@ export async function runReconcile(
|
|
|
512
786
|
return zeroedResult(msg);
|
|
513
787
|
}
|
|
514
788
|
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Ledger name of the reconcile run event (issue #1192). Catalogued in
|
|
792
|
+
* `docs/events-schema.md`; `events-schema.mjs` needs no registration — it
|
|
793
|
+
* validates the NAME shape only, and this name already satisfies it.
|
|
794
|
+
*/
|
|
795
|
+
export const RECONCILE_EVENT = 'orchestrator.reconcile.completed';
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Closed enum of `trigger` values a caller may explicitly pass to
|
|
799
|
+
* {@link runReconcile} (issue #1201 Part A / Discovery D8). Two of the three
|
|
800
|
+
* real call sites pinned `trigger` by PROSE inside a `.md` skill file —
|
|
801
|
+
* `skill` (`skills/reconcile/SKILL.md`) and `session-end`
|
|
802
|
+
* (`skills/session-end/phase-3-6-tail.md`) — where nothing enforced the
|
|
803
|
+
* string ever matched this list; only `phase-skip`
|
|
804
|
+
* (`scripts/lib/session-end/phase-skip.mjs`) pinned it in code. The two `.md`
|
|
805
|
+
* callers are migrated onto {@link runReconcileFromSkill} /
|
|
806
|
+
* {@link runReconcileAtSessionEnd} in this same change, so "prose sets the
|
|
807
|
+
* trigger" stops being possible for them at all.
|
|
808
|
+
*/
|
|
809
|
+
export const KNOWN_TRIGGERS = Object.freeze(['skill', 'session-end', 'phase-skip']);
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* Reject an explicitly-passed `trigger` that is not in {@link KNOWN_TRIGGERS}
|
|
813
|
+
* — including the literal `'unknown'`, which is a DEFAULT this module mints
|
|
814
|
+
* for an ABSENT trigger, never a value a caller should pass on purpose. An
|
|
815
|
+
* absent trigger (`undefined`) is deliberately let through unchanged: it is
|
|
816
|
+
* legacy behaviour for a caller that has not yet been migrated onto a pinned
|
|
817
|
+
* wrapper, and `buildReconcilePayload` already defaults it to `'unknown'` so
|
|
818
|
+
* the per-trigger denominator in the ledger stays honest about which runs are
|
|
819
|
+
* unattributed rather than silently breaking them.
|
|
820
|
+
*
|
|
821
|
+
* BV-004 revisit trigger: `scripts/lib/session-end/phase-skip.mjs` is the one
|
|
822
|
+
* remaining caller passing `trigger: 'phase-skip'` as a hand-written literal
|
|
823
|
+
* rather than through {@link runReconcileFromPhaseSkip} (out of this task's
|
|
824
|
+
* file scope) — once every caller is migrated onto a pinned wrapper, flip the
|
|
825
|
+
* absent-trigger default below from "let it run" to a throw, closing the last
|
|
826
|
+
* legacy path.
|
|
827
|
+
*
|
|
828
|
+
* @param {unknown} trigger
|
|
829
|
+
* @throws {Error} when `trigger` is defined but not in `KNOWN_TRIGGERS`.
|
|
830
|
+
* @returns {void}
|
|
831
|
+
*/
|
|
832
|
+
export function assertKnownTrigger(trigger) {
|
|
833
|
+
if (trigger === undefined) return;
|
|
834
|
+
if (!KNOWN_TRIGGERS.includes(trigger)) {
|
|
835
|
+
throw new Error(
|
|
836
|
+
`runReconcile: invalid trigger ${JSON.stringify(trigger)} — must be one of: ` +
|
|
837
|
+
`${KNOWN_TRIGGERS.join(', ')} (or omitted entirely, for a not-yet-migrated caller).`,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Clamp for the `reason` string on the abort path — a message can be long. */
|
|
843
|
+
const REASON_MAX_CHARS = 300;
|
|
844
|
+
|
|
845
|
+
/** The closed target enum `resolveEffectiveTargets` recognises. */
|
|
846
|
+
const KNOWN_TARGETS = ['repo-local', 'baseline'];
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Build the `orchestrator.reconcile.completed` payload from a finished run.
|
|
850
|
+
*
|
|
851
|
+
* Counter fields are written INCLUDING `0`: each was MEASURED over the whole
|
|
852
|
+
* run, so a written zero is the payload (same contract as the vault-mirror run
|
|
853
|
+
* event). The two absence-preserving exceptions are `store_records_dropped`
|
|
854
|
+
* (absent ⇒ the candidate store was never inspected — dryRun, empty
|
|
855
|
+
* short-circuit, error path) and `targets` (absent ⇒ the caller asserted no
|
|
856
|
+
* target list). `aborted`/`reason` appear only when the never-throws guard
|
|
857
|
+
* fired; their absence means "ran to the end", never "unknown".
|
|
858
|
+
*
|
|
859
|
+
* @param {ReconcileResult} result
|
|
860
|
+
* @param {{ trigger?: string, targets?: string[], dryRun?: boolean, durationMs: number }} ctx
|
|
861
|
+
* @returns {Record<string, unknown>}
|
|
862
|
+
*/
|
|
863
|
+
function buildReconcilePayload(result, ctx) {
|
|
864
|
+
const summary = (result && result.summary) || {};
|
|
865
|
+
/** @type {Record<string, unknown>} */
|
|
866
|
+
const payload = {
|
|
867
|
+
trigger: typeof ctx.trigger === 'string' && ctx.trigger.trim() !== '' ? ctx.trigger : 'unknown',
|
|
868
|
+
dry_run: ctx.dryRun === true,
|
|
869
|
+
learnings_total: summary.totalLearnings ?? 0,
|
|
870
|
+
eligible: summary.eligible ?? 0,
|
|
871
|
+
proposals: summary.proposed ?? 0,
|
|
872
|
+
rejected: summary.rejected ?? 0,
|
|
873
|
+
capped: summary.capped ?? 0,
|
|
874
|
+
already_materialized: summary.alreadyMaterialized ?? 0,
|
|
875
|
+
written: summary.written === true,
|
|
876
|
+
duration_ms: ctx.durationMs,
|
|
877
|
+
};
|
|
878
|
+
// `targets` originates in operator-authored Session Config (`reconcile.targets`)
|
|
879
|
+
// and is unbounded there. Allowlisted to the CLOSED enum `resolveEffectiveTargets`
|
|
880
|
+
// recognises before it enters the ledger and the optional Clank webhook (Q2-F4):
|
|
881
|
+
// anything else is not a target this engine can act on, so recording it would
|
|
882
|
+
// be a verbatim echo of untrusted text, never a measurement. Omitted when empty.
|
|
883
|
+
const targets = Array.isArray(ctx.targets)
|
|
884
|
+
? [...new Set(ctx.targets.filter((t) => KNOWN_TARGETS.includes(t)))]
|
|
885
|
+
: [];
|
|
886
|
+
if (targets.length > 0) payload.targets = targets;
|
|
887
|
+
if (typeof summary.skipped === 'number') payload.store_records_dropped = summary.skipped;
|
|
888
|
+
if (typeof result?.error === 'string' && result.error !== '') {
|
|
889
|
+
payload.aborted = 'engine-error';
|
|
890
|
+
payload.reason = result.error.slice(0, REASON_MAX_CHARS);
|
|
891
|
+
}
|
|
892
|
+
return payload;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Record one reconcile run in the repo's event ledger — best-effort.
|
|
897
|
+
*
|
|
898
|
+
* Refuses the ambient `SO_PROJECT_DIR` destination when no `repoRoot` was
|
|
899
|
+
* given: most engine tests call `runReconcile` without one, and a fallback
|
|
900
|
+
* would append synthetic records to the operator's REAL fleet ledger on every
|
|
901
|
+
* `npm test` (#1119, `scripts/lib/express-path.mjs`). Diagnostics on stderr.
|
|
902
|
+
*
|
|
903
|
+
* @param {ReconcileResult} result
|
|
904
|
+
* @param {{ repoRoot?: string, trigger?: string, targets?: string[], dryRun?: boolean, durationMs: number }} ctx
|
|
905
|
+
*/
|
|
906
|
+
async function emitReconcileCompleted(result, ctx) {
|
|
907
|
+
const { repoRoot } = ctx;
|
|
908
|
+
if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
|
|
909
|
+
process.stderr.write(
|
|
910
|
+
`reconcile: skipped ${RECONCILE_EVENT} — no repoRoot given; ` +
|
|
911
|
+
'refusing the ambient SO_PROJECT_DIR destination (#1119).\n',
|
|
912
|
+
);
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
const { emitEvent } = await import('../events.mjs');
|
|
916
|
+
await emitEvent(RECONCILE_EVENT, buildReconcilePayload(result, ctx), { repoRoot });
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Public boundary: run the reconciliation pipeline and record the run.
|
|
921
|
+
*
|
|
922
|
+
* A thin WRAPPER, deliberately: the pipeline has three return points (empty
|
|
923
|
+
* short-circuit, normal tail, never-throws catch), and an inline emit would
|
|
924
|
+
* miss two of them — including the empty corpus and the error path, the two
|
|
925
|
+
* runs an operator most needs recorded (`.claude/rules/host-resources.md`
|
|
926
|
+
* § HR-105). Same shape as `runNarrativeMirror` + `mirrorNarrative` in
|
|
927
|
+
* `scripts/lib/vault-status/narrative-mirror.mjs`.
|
|
928
|
+
*
|
|
929
|
+
* The emit is wrapped in try/catch because `emitEvent` THROWS
|
|
930
|
+
* `EventValidationError` on an invalid record — without the catch, telemetry
|
|
931
|
+
* would break this function's never-throws contract. The pipeline's result is
|
|
932
|
+
* returned UNTOUCHED whether or not the ledger accepted the record.
|
|
933
|
+
*
|
|
934
|
+
* @param {Object} [params] - see {@link runReconcileInner}, plus:
|
|
935
|
+
* @param {'skill'|'session-end'|'phase-skip'} [params.trigger] - which caller
|
|
936
|
+
* invoked this run; recorded ALWAYS (default `'unknown'` when omitted) so
|
|
937
|
+
* the per-trigger denominator is complete. Not read by the pipeline.
|
|
938
|
+
* VALIDATED against {@link KNOWN_TRIGGERS} via {@link assertKnownTrigger}
|
|
939
|
+
* when explicitly passed — an unknown string, including the literal
|
|
940
|
+
* `'unknown'`, THROWS synchronously; an absent trigger is still let
|
|
941
|
+
* through unchanged (issue #1201 Part A — see `assertKnownTrigger`'s
|
|
942
|
+
* BV-004 revisit-trigger note for why the absent case is not also a
|
|
943
|
+
* throw yet). Prefer {@link runReconcileFromSkill},
|
|
944
|
+
* {@link runReconcileAtSessionEnd}, or {@link runReconcileFromPhaseSkip}
|
|
945
|
+
* over passing `trigger` here directly.
|
|
946
|
+
* @param {string[]} [params.targets] - the caller's effective target list
|
|
947
|
+
* (`resolveEffectiveTargets`); recorded when non-empty, omitted otherwise.
|
|
948
|
+
* Not read by the pipeline.
|
|
949
|
+
* @param {Object} [opts] - see {@link runReconcileInner}.
|
|
950
|
+
* @returns {Promise<ReconcileResult>}
|
|
951
|
+
*/
|
|
952
|
+
export async function runReconcile(params = {}, opts = {}) {
|
|
953
|
+
assertKnownTrigger(params.trigger);
|
|
954
|
+
const t0 = Date.now();
|
|
955
|
+
const result = await runReconcileInner(params, opts);
|
|
956
|
+
try {
|
|
957
|
+
await emitReconcileCompleted(result, {
|
|
958
|
+
repoRoot: params.repoRoot,
|
|
959
|
+
trigger: params.trigger,
|
|
960
|
+
targets: params.targets,
|
|
961
|
+
dryRun: params.dryRun === true || opts.dryRun === true,
|
|
962
|
+
durationMs: Date.now() - t0,
|
|
963
|
+
});
|
|
964
|
+
} catch {
|
|
965
|
+
// Best-effort telemetry — never the reason a reconcile run fails.
|
|
966
|
+
}
|
|
967
|
+
return result;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/**
|
|
971
|
+
* Pin `trigger: 'skill'` in code for the `/reconcile` skill (Phase 2.3,
|
|
972
|
+
* `skills/reconcile/SKILL.md`) — the skill's own `.md` prose no longer sets
|
|
973
|
+
* the trigger string itself (issue #1201 Part A). Any `trigger` the caller
|
|
974
|
+
* passes in `params` is OVERWRITTEN; every other field forwards unchanged.
|
|
975
|
+
*
|
|
976
|
+
* @param {Object} [params] - see {@link runReconcile} `params`.
|
|
977
|
+
* @param {Object} [opts] - see {@link runReconcile} `opts` (DI seams).
|
|
978
|
+
* @returns {Promise<ReconcileResult>}
|
|
979
|
+
*/
|
|
980
|
+
export async function runReconcileFromSkill(params = {}, opts = {}) {
|
|
981
|
+
return runReconcile({ ...params, trigger: 'skill' }, opts);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
/**
|
|
985
|
+
* Pin `trigger: 'session-end'` in code for session-end Phase 3.6.8
|
|
986
|
+
* (`skills/session-end/phase-3-6-tail.md`) — see
|
|
987
|
+
* {@link runReconcileFromSkill} for the rationale and contract.
|
|
988
|
+
*
|
|
989
|
+
* @param {Object} [params] - see {@link runReconcile} `params`.
|
|
990
|
+
* @param {Object} [opts] - see {@link runReconcile} `opts` (DI seams).
|
|
991
|
+
* @returns {Promise<ReconcileResult>}
|
|
992
|
+
*/
|
|
993
|
+
export async function runReconcileAtSessionEnd(params = {}, opts = {}) {
|
|
994
|
+
return runReconcile({ ...params, trigger: 'session-end' }, opts);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Pin `trigger: 'phase-skip'` in code for the session-end skip-plan
|
|
999
|
+
* aggregator (`scripts/lib/session-end/phase-skip.mjs`, the highest-volume
|
|
1000
|
+
* probe-only caller) — see {@link runReconcileFromSkill} for the rationale
|
|
1001
|
+
* and contract. NOT YET wired into `phase-skip.mjs` itself, which is out of
|
|
1002
|
+
* this task's file scope and still passes `trigger: 'phase-skip'` as a
|
|
1003
|
+
* hand-written literal directly to `runReconcile`; exported here so that
|
|
1004
|
+
* migration is a one-line import swap (see `assertKnownTrigger`'s BV-004
|
|
1005
|
+
* revisit-trigger note).
|
|
1006
|
+
*
|
|
1007
|
+
* @param {Object} [params] - see {@link runReconcile} `params`.
|
|
1008
|
+
* @param {Object} [opts] - see {@link runReconcile} `opts` (DI seams).
|
|
1009
|
+
* @returns {Promise<ReconcileResult>}
|
|
1010
|
+
*/
|
|
1011
|
+
export async function runReconcileFromPhaseSkip(params = {}, opts = {}) {
|
|
1012
|
+
return runReconcile({ ...params, trigger: 'phase-skip' }, opts);
|
|
1013
|
+
}
|