session-orchestrator 3.21.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 +11 -2
- 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/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +10 -10
- 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 +269 -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/memory-proposal-collector.md +6 -4
- 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/eli5.md +33 -0
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- 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 +7 -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 +52 -5
- package/docs/session-config-reference.md +57 -12
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +26 -16
- 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 +12 -2
- package/hooks/on-session-end.mjs +237 -104
- package/hooks/on-session-start.mjs +404 -110
- 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-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +133 -21
- 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 +3 -1
- package/pi/prompts/eli5.md +12 -0
- 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/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +12 -8
- 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/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -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 +221 -68
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config/vault-integration.mjs +12 -1
- 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/dispatcher/rank.mjs +4 -7
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +84 -11
- package/scripts/lib/gates/gate-helpers.mjs +93 -21
- 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/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +284 -31
- 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/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +104 -4
- 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 +165 -7
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- 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 +111 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +467 -72
- 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-auq-clarity.mjs +274 -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 +59 -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-rules.mjs +153 -9
- 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-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- 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/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +478 -0
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +6 -0
- package/scripts/validate-wave-scope.mjs +73 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -25
- package/skills/_shared/parallel-aware-preamble.md +35 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +61 -8
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +5 -4
- 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/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/discovery/SKILL.md +28 -24
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +18 -6
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +44 -4
- package/skills/session-end/SKILL.md +70 -24
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +48 -5
- 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 +96 -48
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- 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/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- 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 +129 -24
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/templates/_shared/journey-manifest.md +110 -0
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -42,10 +42,20 @@ import {
|
|
|
42
42
|
* append touches disk, so a bad write can never reach the file.
|
|
43
43
|
*
|
|
44
44
|
* @param {object} validated — already validated+normalized learning entry
|
|
45
|
+
* @param {{ legacyTolerant?: boolean }} [opts] — GitLab #386. When `true`,
|
|
46
|
+
* a field that was ALREADY ABSENT on `validated` (e.g. a legacy record with
|
|
47
|
+
* no `source_session`, tolerated by `readLearnings()`) stays tolerated after
|
|
48
|
+
* the round-trip too — the re-validation call below runs in the same
|
|
49
|
+
* tolerant mode. This does NOT weaken the #662 guarantee: a key that WAS
|
|
50
|
+
* present on `validated` (even `undefined`) and is no longer a key on the
|
|
51
|
+
* reparsed object is genuine JSON.stringify corruption, detected by the
|
|
52
|
+
* dedicated `droppedKeys` check below and thrown regardless of
|
|
53
|
+
* `legacyTolerant`. Default `false` — `appendLearning`'s single-record path
|
|
54
|
+
* calls this with no options and is unaffected.
|
|
45
55
|
* @returns {string} the verified JSONL line (newline-terminated)
|
|
46
56
|
* @throws {ValidationError} when the serialized line does not round-trip
|
|
47
57
|
*/
|
|
48
|
-
function serializeLearningLineChecked(validated) {
|
|
58
|
+
function serializeLearningLineChecked(validated, { legacyTolerant = false } = {}) {
|
|
49
59
|
let line;
|
|
50
60
|
try {
|
|
51
61
|
line = JSON.stringify(validated);
|
|
@@ -66,9 +76,23 @@ function serializeLearningLineChecked(validated) {
|
|
|
66
76
|
`serialized learning line does not parse back as JSON: ${err.message}`
|
|
67
77
|
);
|
|
68
78
|
}
|
|
79
|
+
if (legacyTolerant) {
|
|
80
|
+
// A key that existed on `validated` (present, even as `undefined`) but
|
|
81
|
+
// vanished from `reparsed` was DROPPED by JSON.stringify — the exact
|
|
82
|
+
// undefined/NaN/etc. corruption #662 exists to catch. A key that was
|
|
83
|
+
// never on `validated` in the first place (the #386 legacy-field case)
|
|
84
|
+
// cannot appear here, because we only iterate `validated`'s own keys.
|
|
85
|
+
const droppedKeys = Object.keys(validated).filter((k) => !(k in reparsed));
|
|
86
|
+
if (droppedKeys.length > 0) {
|
|
87
|
+
throw new ValidationError(
|
|
88
|
+
`learning lost field(s) during JSON round-trip serialization ` +
|
|
89
|
+
`(non-serializable value?): ${droppedKeys.join(', ')}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
69
93
|
// Re-validate the round-tripped shape — catches required fields that were
|
|
70
|
-
// present as `undefined`/`NaN` before stringify but vanished after.
|
|
71
|
-
validateLearning(reparsed);
|
|
94
|
+
// present as `undefined`/`NaN` before stringify but vanished/coerced after.
|
|
95
|
+
validateLearning(reparsed, { legacyTolerant });
|
|
72
96
|
return line + '\n';
|
|
73
97
|
}
|
|
74
98
|
|
|
@@ -203,29 +227,50 @@ async function rotateBackups(dir, baseName, keep = BACKUP_KEEP) {
|
|
|
203
227
|
* - `backup` (default `true`): before the destructive rename, copy the current
|
|
204
228
|
* file to `${filePath}.bak-<ISO>`, then rotate to keep only the newest
|
|
205
229
|
* {@link BACKUP_KEEP}. Rotation is best-effort and never blocks the rewrite.
|
|
230
|
+
* - `legacyTolerant` (default `true`, GitLab #386): this function is a
|
|
231
|
+
* ROUND-TRIP writer — its usual caller (`sweepExpiredLearnings` /
|
|
232
|
+
* `pruneLearnings` in `expiry-sweep.mjs`) reads the store with
|
|
233
|
+
* `readLearnings()` first, and that reader already tolerates a legacy
|
|
234
|
+
* record missing e.g. `source_session` (WARN, pass through unchanged — see
|
|
235
|
+
* `normalizeLearning`). Before this option existed, `rewriteLearnings()`
|
|
236
|
+
* re-validated with the SAME strict gate `appendLearning()` uses for a
|
|
237
|
+
* brand-new single record, so re-writing the unchanged KEEP batch of a
|
|
238
|
+
* mechanical sweep could throw on data the reader itself had just accepted
|
|
239
|
+
* — `sweep-expired-learnings --apply` failed on ANY store holding one such
|
|
240
|
+
* record, even though the sweep never touches that record's fields. The
|
|
241
|
+
* default is `true` precisely because the sweep/prune call sites cannot be
|
|
242
|
+
* changed to opt in explicitly without touching `expiry-sweep.mjs`, which
|
|
243
|
+
* passes no `legacyTolerant`; every field that genuinely CANNOT survive a
|
|
244
|
+
* round-trip (a value JSON.stringify drops or coerces, e.g. `undefined`/
|
|
245
|
+
* `NaN`) is still caught by the #662 checked serializer regardless of this
|
|
246
|
+
* flag — see {@link serializeLearningLineChecked}. Pass `false` to restore
|
|
247
|
+
* the pre-#386 fully-strict behaviour.
|
|
206
248
|
*
|
|
207
249
|
* @param {string} filePath
|
|
208
250
|
* @param {object[]} entries
|
|
209
|
-
* @param {{dryRun?: boolean, backup?: boolean}} [opts]
|
|
251
|
+
* @param {{dryRun?: boolean, backup?: boolean, legacyTolerant?: boolean}} [opts]
|
|
210
252
|
* @returns {Promise<object[]>} validated entries (always returned, even dryRun)
|
|
211
253
|
*/
|
|
212
254
|
export async function rewriteLearnings(
|
|
213
255
|
filePath,
|
|
214
256
|
entries,
|
|
215
|
-
{ dryRun = false, backup = true } = {}
|
|
257
|
+
{ dryRun = false, backup = true, legacyTolerant = true } = {}
|
|
216
258
|
) {
|
|
217
259
|
const validated = entries.map((e) =>
|
|
218
|
-
validateLearning(
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
260
|
+
validateLearning(
|
|
261
|
+
{
|
|
262
|
+
...e,
|
|
263
|
+
schema_version: e?.schema_version ?? CURRENT_SCHEMA_VERSION,
|
|
264
|
+
},
|
|
265
|
+
{ legacyTolerant }
|
|
266
|
+
)
|
|
222
267
|
);
|
|
223
268
|
// Pre-write round-trip self-validation (#662): serialize ALL entries through
|
|
224
269
|
// the checked serializer before touching disk — a single bad entry throws
|
|
225
270
|
// ValidationError and the file is left untouched (atomicity preserved because
|
|
226
271
|
// we validate the full batch first, then write once). This runs even under
|
|
227
272
|
// dryRun, so an invalid entry is still rejected on a dry probe.
|
|
228
|
-
const lines = validated.map((e) => serializeLearningLineChecked(e));
|
|
273
|
+
const lines = validated.map((e) => serializeLearningLineChecked(e, { legacyTolerant }));
|
|
229
274
|
|
|
230
275
|
// dryRun (#721): validation has run; deliberately do NOT touch disk — no
|
|
231
276
|
// rewrite, no backup — and hand the validated entries back to the caller.
|
|
@@ -59,7 +59,7 @@ export const CURRENT_ANONYMIZATION_VERSION = 1;
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* Type capability registry (Epic #723 I1, issue #733 Teil b) — the single
|
|
62
|
-
* source of truth for every learning `type`'s TTL policy AND its
|
|
62
|
+
* source of truth for every learning `type`'s TTL policy AND its three
|
|
63
63
|
* cross-module capability axes. Before this registry existed, three modules
|
|
64
64
|
* independently hand-maintained overlapping type lists that drifted out of
|
|
65
65
|
* sync (`memory-proposals/schema.mjs` PROPOSAL_TYPES, `reconcile/eligibility.mjs`
|
|
@@ -76,39 +76,54 @@ export const CURRENT_ANONYMIZATION_VERSION = 1;
|
|
|
76
76
|
* - ruleConvertible: may appear in `reconcile/eligibility.mjs` CONVERT_TYPES
|
|
77
77
|
* (i.e. the reconcile engine may convert this type into a
|
|
78
78
|
* conditional `.claude/rules/*.md` rule proposal).
|
|
79
|
+
* - hostScoped: this type's content is genuinely chip/OS-specific, so
|
|
80
|
+
* `reconcile/emitter.mjs` may copy a record's `host_class`
|
|
81
|
+
* through as the emitted rule's `host-class` activation
|
|
82
|
+
* axis (issue #1090; derived set: that module's
|
|
83
|
+
* HOST_SPECIFIC_TYPES). `false` for every general finding,
|
|
84
|
+
* whose `host_class` merely records the machine it was
|
|
85
|
+
* authored on and must never gate the rule.
|
|
86
|
+
*
|
|
87
|
+
* Every entry declares every axis explicitly — an omitted flag would read as
|
|
88
|
+
* `undefined` (falsy) and silently opt a new type out of a capability without
|
|
89
|
+
* anyone deciding that. Guarded by a test in
|
|
90
|
+
* `tests/lib/learnings-schema-normalization.test.mjs`.
|
|
79
91
|
*/
|
|
80
92
|
export const LEARNING_TYPE_REGISTRY = Object.freeze({
|
|
81
|
-
'mode-selector-accuracy': Object.freeze({ ttlDays: 30, agentProposable: true, ruleConvertible: false }),
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
'
|
|
93
|
+
'mode-selector-accuracy': Object.freeze({ ttlDays: 30, agentProposable: true, ruleConvertible: false, hostScoped: false }),
|
|
94
|
+
// hardware-pattern is the ONLY hostScoped type today: its content IS the
|
|
95
|
+
// chip/OS, so gating the emitted rule by host-class is faithful rather than
|
|
96
|
+
// an accidental one-machine restriction (#1090).
|
|
97
|
+
'hardware-pattern': Object.freeze({ ttlDays: 60, agentProposable: true, ruleConvertible: false, hostScoped: true }),
|
|
98
|
+
'fragile-file': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
99
|
+
'effective-sizing': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: false, hostScoped: false }),
|
|
100
|
+
'recurring-issue': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
86
101
|
// workflow-pattern / proven-pattern: flipped ruleConvertible false->true
|
|
87
102
|
// (issue #900) — the real corpus census showed a large volume of live
|
|
88
103
|
// `workflow-pattern`/`proven-pattern` records (post type-alias-normalization,
|
|
89
104
|
// see LEARNING_TYPE_ALIASES below) that carried usable file_paths but were
|
|
90
105
|
// structurally unconvertible before this flip.
|
|
91
|
-
'workflow-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
92
|
-
'proven-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
93
|
-
'anti-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
94
|
-
'autopilot-effectiveness': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: false }),
|
|
106
|
+
'workflow-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
107
|
+
'proven-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
108
|
+
'anti-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
109
|
+
'autopilot-effectiveness': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: false, hostScoped: false }),
|
|
95
110
|
// autonomy-verdict (#683): repo/scope readiness synthesis from autopilot
|
|
96
111
|
// effectiveness plus skill-judge signals. 90d matches the operational
|
|
97
112
|
// autopilot-effectiveness horizon it depends on. Analyzer-only — never
|
|
98
113
|
// agent-proposable.
|
|
99
|
-
'autonomy-verdict': Object.freeze({ ttlDays: 90, agentProposable: false, ruleConvertible: false }),
|
|
114
|
+
'autonomy-verdict': Object.freeze({ ttlDays: 90, agentProposable: false, ruleConvertible: false, hostScoped: false }),
|
|
100
115
|
// domain-regression (#638): a sidecar-sourced regression flag (metric baseline→recent
|
|
101
116
|
// delta) surfaced via /evolve extra-sources. 60d aligns with the moderate-decay tier
|
|
102
117
|
// (hardware-pattern / default) — a regression signal should age out if it stops recurring.
|
|
103
|
-
'domain-regression': Object.freeze({ ttlDays: 60, agentProposable: true, ruleConvertible: false }),
|
|
104
|
-
'convention': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
105
|
-
'architecture-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
106
|
-
'design-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
|
|
118
|
+
'domain-regression': Object.freeze({ ttlDays: 60, agentProposable: true, ruleConvertible: false, hostScoped: false }),
|
|
119
|
+
'convention': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
120
|
+
'architecture-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
121
|
+
'design-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true, hostScoped: false }),
|
|
107
122
|
// fragile-pattern / stagnation-class-frequency: 0 live instances as of
|
|
108
123
|
// 2026-07-02 (see reconcile/eligibility.mjs census) — CONVERT-eligible but
|
|
109
124
|
// NOT agent-proposable (analyzer-synthesized classes, not agent-observed).
|
|
110
|
-
'fragile-pattern': Object.freeze({ ttlDays: 45, agentProposable: false, ruleConvertible: true }),
|
|
111
|
-
'stagnation-class-frequency': Object.freeze({ ttlDays: 60, agentProposable: false, ruleConvertible: true }),
|
|
125
|
+
'fragile-pattern': Object.freeze({ ttlDays: 45, agentProposable: false, ruleConvertible: true, hostScoped: false }),
|
|
126
|
+
'stagnation-class-frequency': Object.freeze({ ttlDays: 60, agentProposable: false, ruleConvertible: true, hostScoped: false }),
|
|
112
127
|
});
|
|
113
128
|
|
|
114
129
|
/**
|
|
@@ -215,47 +230,99 @@ export class ValidationError extends Error {
|
|
|
215
230
|
* Throws ValidationError on contract violations. Does NOT mutate input.
|
|
216
231
|
*
|
|
217
232
|
* @param {object} entry — candidate learning
|
|
233
|
+
* @param {{ legacyTolerant?: boolean }} [opts] — GitLab #386. When `true`, a
|
|
234
|
+
* value `normalizeLearning()` already passes through UNCHECKED (or merely
|
|
235
|
+
* DEFAULTED, never validated) on read is no longer rejected here either:
|
|
236
|
+
* an out-of-enum `schema_version` (measured against the real EventDrop.at
|
|
237
|
+
* store: 3 live records carry `schema_version: 2`), the
|
|
238
|
+
* LEGACY_REQUIRED_FIELDS presence check, the `confidence` range/type check
|
|
239
|
+
* when NO `confidence` key is present, and the `scope`/`host_class`/
|
|
240
|
+
* `anonymized` shape checks (measured live: a record with `scope:
|
|
241
|
+
* "src/components/landing/mobile-sticky-cta.tsx"` — a producer bug that
|
|
242
|
+
* wrote a file path into the scope column, which `normalizeLearning` never
|
|
243
|
+
* validates and therefore never rejects). A field that IS present keeps
|
|
244
|
+
* being validated regardless when its DATA TYPE can be silently corrupted
|
|
245
|
+
* by a JSON round-trip (a present-but-malformed `confidence` still throws
|
|
246
|
+
* — see io.mjs #662): this option relaxes "you must HAVE a valid value",
|
|
247
|
+
* never "if a round-trip could have silently mangled it, that's fine too".
|
|
248
|
+
* The privacy CONTRACT (`scope: 'public'` requiring `anonymized`/
|
|
249
|
+
* `host_class` to be consistent) is NEVER relaxed — it only ever fires for
|
|
250
|
+
* a record that genuinely claims `scope: 'public'`, which is exactly the
|
|
251
|
+
* case a round-trip must not silently launder. Default `false` — every
|
|
252
|
+
* existing direct caller (including `appendLearning`'s single-record write
|
|
253
|
+
* path) is unaffected.
|
|
218
254
|
* @returns {object} normalized entry with scope/host_class/anonymized defaulted
|
|
219
255
|
*/
|
|
220
|
-
export function validateLearning(entry) {
|
|
256
|
+
export function validateLearning(entry, { legacyTolerant = false } = {}) {
|
|
221
257
|
if (!entry || typeof entry !== 'object') {
|
|
222
258
|
throw new ValidationError('learning must be an object');
|
|
223
259
|
}
|
|
224
260
|
|
|
225
261
|
// schema_version: 0 (implicit/legacy), 1 (current). Both accepted.
|
|
262
|
+
// `normalizeLearning()` never validates this value on read (any value
|
|
263
|
+
// passes through as-is), so under legacyTolerant an ABSENT schema_version
|
|
264
|
+
// (defaults to the valid `0`) or one already present with SOME value is
|
|
265
|
+
// both fine — only a truly missing-AND-invalid combination cannot occur
|
|
266
|
+
// (missing defaults to 0, which is always valid), so in practice this skips
|
|
267
|
+
// the check whenever legacyTolerant is set and a non-legacy value survived
|
|
268
|
+
// a prior read (e.g. `schema_version: 2`, measured live in production).
|
|
226
269
|
const schemaVersion = entry.schema_version ?? 0;
|
|
227
|
-
if (schemaVersion !== 0 && schemaVersion !== 1) {
|
|
270
|
+
if (!legacyTolerant && schemaVersion !== 0 && schemaVersion !== 1) {
|
|
228
271
|
throw new ValidationError(
|
|
229
272
|
`schema_version must be 0 (legacy) or 1, got: ${schemaVersion}`
|
|
230
273
|
);
|
|
231
274
|
}
|
|
232
275
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
276
|
+
if (!legacyTolerant) {
|
|
277
|
+
for (const field of LEGACY_REQUIRED_FIELDS) {
|
|
278
|
+
if (!(field in entry)) {
|
|
279
|
+
throw new ValidationError(`learning missing required field: ${field}`);
|
|
280
|
+
}
|
|
236
281
|
}
|
|
237
282
|
}
|
|
238
283
|
|
|
239
|
-
|
|
240
|
-
|
|
284
|
+
// confidence carries its own type/range check in addition to the presence
|
|
285
|
+
// loop above, so it needs its own legacyTolerant gate: skip ONLY when the
|
|
286
|
+
// key is genuinely absent (mirrors the presence loop); a present-but-bad
|
|
287
|
+
// value (wrong type, out of range, or `null` after a JSON round-trip
|
|
288
|
+
// coerced a non-serializable value — see io.mjs #662) still throws under
|
|
289
|
+
// legacyTolerant, same as under strict mode.
|
|
290
|
+
if (!legacyTolerant || 'confidence' in entry) {
|
|
291
|
+
if (typeof entry.confidence !== 'number' || entry.confidence < 0 || entry.confidence > 1) {
|
|
292
|
+
throw new ValidationError(`confidence must be a number in [0, 1], got: ${entry.confidence}`);
|
|
293
|
+
}
|
|
241
294
|
}
|
|
242
295
|
|
|
296
|
+
// scope/host_class/anonymized: `normalizeLearning()` only DEFAULTS an
|
|
297
|
+
// absent value (`d.scope ?? 'local'`, etc.) on read — it never validates a
|
|
298
|
+
// PRESENT one, so a corrupt legacy value round-trips silently today
|
|
299
|
+
// (measured live in production: a record with `scope:
|
|
300
|
+
// "src/components/landing/mobile-sticky-cta.tsx"` — evidently a producer
|
|
301
|
+
// bug that wrote a file path into the scope column). All three checks are
|
|
302
|
+
// therefore skipped under legacyTolerant. Unlike `confidence` (a number,
|
|
303
|
+
// where `NaN`/`Infinity` are non-JSON-safe and silently become `null`
|
|
304
|
+
// across a JSON round-trip — the #662 concern), a string/string-or-null/
|
|
305
|
+
// boolean value round-trips through JSON byte-for-byte, so there is no
|
|
306
|
+
// corresponding round-trip-corruption risk to guard against here.
|
|
243
307
|
const scope = entry.scope ?? 'local';
|
|
244
|
-
if (!VALID_SCOPES.includes(scope)) {
|
|
308
|
+
if (!legacyTolerant && !VALID_SCOPES.includes(scope)) {
|
|
245
309
|
throw new ValidationError(`scope must be one of ${VALID_SCOPES.join('|')}, got: ${scope}`);
|
|
246
310
|
}
|
|
247
311
|
|
|
248
312
|
const hostClass = entry.host_class ?? null;
|
|
249
|
-
if (hostClass !== null && typeof hostClass !== 'string') {
|
|
313
|
+
if (!legacyTolerant && hostClass !== null && typeof hostClass !== 'string') {
|
|
250
314
|
throw new ValidationError(`host_class must be string or null, got: ${typeof hostClass}`);
|
|
251
315
|
}
|
|
252
316
|
|
|
253
317
|
const anonymized = entry.anonymized ?? false;
|
|
254
|
-
if (typeof anonymized !== 'boolean') {
|
|
318
|
+
if (!legacyTolerant && typeof anonymized !== 'boolean') {
|
|
255
319
|
throw new ValidationError(`anonymized must be boolean, got: ${typeof anonymized}`);
|
|
256
320
|
}
|
|
257
321
|
|
|
258
|
-
// Privacy contract
|
|
322
|
+
// Privacy contract — NEVER relaxed, legacyTolerant or not: an out-of-enum
|
|
323
|
+
// `scope` (garbage or otherwise) is by definition not `'public'`, so this
|
|
324
|
+
// pair only ever fires for a record that genuinely claims `scope: 'public'`
|
|
325
|
+
// — exactly the case a round-trip must not silently launder.
|
|
259
326
|
if (scope === 'public' && !anonymized) {
|
|
260
327
|
throw new ValidationError(
|
|
261
328
|
'scope=public requires anonymized=true (privacy contract violation)'
|
|
@@ -68,6 +68,7 @@ import path from 'node:path';
|
|
|
68
68
|
import { enumerateCandidates } from './dispatcher/enumerate.mjs';
|
|
69
69
|
import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS } from './session-lock.mjs';
|
|
70
70
|
import { emitEvent } from './events.mjs';
|
|
71
|
+
import { hostnamesMatch, lockHostCandidate } from './host-identity.mjs';
|
|
71
72
|
|
|
72
73
|
const REAPED_ARCHIVE_SUBDIR = '.orchestrator/tmp/reaped-locks';
|
|
73
74
|
const REAPED_EVENT = 'orchestrator.session.lock.reaped';
|
|
@@ -463,7 +464,12 @@ async function evaluateRepo(repoRoot, { nowMs, dryRun, currentSessionId, reapMod
|
|
|
463
464
|
};
|
|
464
465
|
}
|
|
465
466
|
|
|
466
|
-
|
|
467
|
+
// #1072: alias-aware host identity. A raw comparison classified this
|
|
468
|
+
// machine's OWN orphaned lock as cross-host after a hostname flip, so it was
|
|
469
|
+
// never reaped and every subsequent session needed operator intervention.
|
|
470
|
+
// The invariant is unchanged for a genuinely foreign host: its name was never
|
|
471
|
+
// written into this machine's self-alias ledger, so it can never match.
|
|
472
|
+
const ownHost = hostnamesMatch(lockHostCandidate(lock), D.hostname());
|
|
467
473
|
|
|
468
474
|
// Invariant (c): cross-host leases are NEVER auto-reaped — only listed.
|
|
469
475
|
if (!ownHost) {
|
|
@@ -28,6 +28,7 @@ import path from 'node:path';
|
|
|
28
28
|
import crypto from 'node:crypto';
|
|
29
29
|
|
|
30
30
|
import { tryAcquireFileLock } from '../file-lock.mjs';
|
|
31
|
+
import { hostnamesMatch, lockHostCandidate, stableHostname } from '../host-identity.mjs';
|
|
31
32
|
import { nowIso, delay, parseLockBody } from './lock-body.mjs';
|
|
32
33
|
|
|
33
34
|
// ---------------------------------------------------------------------------
|
|
@@ -67,7 +68,9 @@ function stagingFenceLockPathFor(repoRoot) {
|
|
|
67
68
|
function buildStagingFenceLockBody({ holder }) {
|
|
68
69
|
return {
|
|
69
70
|
pid: process.pid,
|
|
71
|
+
// `host` raw + `host_id` normalised (#1072) — see buildStateLockBody.
|
|
70
72
|
host: os.hostname(),
|
|
73
|
+
host_id: stableHostname(),
|
|
71
74
|
acquiredAt: nowIso(),
|
|
72
75
|
holder: typeof holder === 'string' && holder.length > 0 ? holder : `pid-${process.pid}`,
|
|
73
76
|
};
|
|
@@ -178,7 +181,8 @@ export function releaseStagingFenceLock({ repoRoot, holder } = {}) {
|
|
|
178
181
|
|
|
179
182
|
const ownerMatch = typeof holder === 'string' && holder.length > 0
|
|
180
183
|
? lock.holder === holder
|
|
181
|
-
:
|
|
184
|
+
// #1072: alias-aware host identity, not a raw os.hostname() comparison.
|
|
185
|
+
: lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
|
|
182
186
|
|
|
183
187
|
if (!ownerMatch) {
|
|
184
188
|
return { ok: false, reason: 'not-owner' };
|
|
@@ -41,6 +41,7 @@ import crypto from 'node:crypto';
|
|
|
41
41
|
|
|
42
42
|
import { _parseStateMdLock } from '../config/state-md-lock.mjs';
|
|
43
43
|
import { tryAcquireFileLock } from '../file-lock.mjs';
|
|
44
|
+
import { hostnamesMatch, lockHostCandidate, stableHostname } from '../host-identity.mjs';
|
|
44
45
|
import { nowIso, delay, parseLockBody } from './lock-body.mjs';
|
|
45
46
|
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
@@ -78,7 +79,11 @@ function stateLockPathFor(repoRoot) {
|
|
|
78
79
|
function buildStateLockBody({ holder }) {
|
|
79
80
|
return {
|
|
80
81
|
pid: process.pid,
|
|
82
|
+
// `host` raw + `host_id` normalised (#1072). The written body comes from
|
|
83
|
+
// tryAcquireFileLock, which carries the same pair — this keeps the shape
|
|
84
|
+
// declared here truthful about what lands on disk.
|
|
81
85
|
host: os.hostname(),
|
|
86
|
+
host_id: stableHostname(),
|
|
82
87
|
acquiredAt: nowIso(),
|
|
83
88
|
holder: typeof holder === 'string' && holder.length > 0 ? holder : `pid-${process.pid}`,
|
|
84
89
|
};
|
|
@@ -224,7 +229,9 @@ export function releaseStateLock({ repoRoot, sessionId, holder } = {}) {
|
|
|
224
229
|
const expectedHolder = holder ?? sessionId ?? null;
|
|
225
230
|
const ownerMatch = expectedHolder !== null
|
|
226
231
|
? lock.holder === expectedHolder
|
|
227
|
-
:
|
|
232
|
+
// #1072: alias-aware host identity — a raw comparison strands the lock as
|
|
233
|
+
// 'not-owner' once os.hostname() flips spelling mid-session.
|
|
234
|
+
: lock.pid === process.pid && hostnamesMatch(lockHostCandidate(lock), os.hostname());
|
|
228
235
|
|
|
229
236
|
if (!ownerMatch) {
|
|
230
237
|
return { ok: false, reason: 'not-owner' };
|
|
@@ -308,7 +308,7 @@ async function countMemoryFiles(dir) {
|
|
|
308
308
|
*
|
|
309
309
|
* @param {object} args
|
|
310
310
|
* @param {string} args.repoRoot — absolute path
|
|
311
|
-
* @param {string} [args.memoryDir] — defaults to `resolveMemoryDir()`
|
|
311
|
+
* @param {string} [args.memoryDir] — defaults to `resolveMemoryDir(repoRoot)`
|
|
312
312
|
* @param {string} [args.learningsPath] — defaults to `<repoRoot>/.orchestrator/metrics/learnings.jsonl`
|
|
313
313
|
* @param {Date} [args.now] — injectable clock for tests
|
|
314
314
|
* @param {{enabled?: boolean, 'half-life-days'?: number, 'floor-factor'?: number}} [args.evolveDecay]
|
|
@@ -326,9 +326,12 @@ export async function readBannerInputs({ repoRoot, memoryDir, learningsPath, now
|
|
|
326
326
|
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
327
327
|
throw new TypeError('readBannerInputs: repoRoot is required (absolute path)');
|
|
328
328
|
}
|
|
329
|
+
// #1071: bind the memory dir to the SAME root as learnings/sessions/peer-cards
|
|
330
|
+
// below. Called with no argument it derived from the ambient `process.cwd()`,
|
|
331
|
+
// which reported `0 memory files` beside a 245-session count from `repoRoot`.
|
|
329
332
|
const memDir = typeof memoryDir === 'string' && memoryDir.length > 0
|
|
330
333
|
? memoryDir
|
|
331
|
-
: resolveMemoryDir();
|
|
334
|
+
: resolveMemoryDir(repoRoot);
|
|
332
335
|
const learningsFile = typeof learningsPath === 'string' && learningsPath.length > 0
|
|
333
336
|
? learningsPath
|
|
334
337
|
: path.join(repoRoot, '.orchestrator', 'metrics', 'learnings.jsonl');
|
|
@@ -15,17 +15,26 @@ import os from 'node:os';
|
|
|
15
15
|
/**
|
|
16
16
|
* Resolve the project-specific memory directory used by the Claude Code harness.
|
|
17
17
|
*
|
|
18
|
-
* Mirrors the harness convention: `~/.claude/projects/<encoded-
|
|
19
|
-
* where `<encoded-
|
|
20
|
-
* dot replacement matters for users with a trailing-`.` in their home
|
|
21
|
-
* (e.g. `/Users/<owner>.`) — without it the resolved path diverges from
|
|
18
|
+
* Mirrors the harness convention: `~/.claude/projects/<encoded-root>/memory/`
|
|
19
|
+
* where `<encoded-root>` is the project root with BOTH `/` AND `.` replaced by
|
|
20
|
+
* `-`. The dot replacement matters for users with a trailing-`.` in their home
|
|
21
|
+
* dir (e.g. `/Users/<owner>.`) — without it the resolved path diverges from
|
|
22
22
|
* what the harness actually wrote.
|
|
23
23
|
*
|
|
24
24
|
* Verified empirically against `~/.claude/projects/` directory naming.
|
|
25
25
|
*
|
|
26
|
+
* `repoRoot` is explicit (#1071) because every OTHER banner input is already
|
|
27
|
+
* bound to it: with the memory dir alone derived from the ambient `process.cwd()`,
|
|
28
|
+
* a caller reading sessions/learnings/peer-cards out of the primary checkout
|
|
29
|
+
* reported `0 memory files` whenever the process ran from a worktree or a
|
|
30
|
+
* subdirectory. Defaulted, so existing zero-arg callers keep their behaviour.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} [repoRoot] — absolute project root; defaults to `process.cwd()`.
|
|
26
33
|
* @returns {string} Absolute path to the memory directory (not guaranteed to exist).
|
|
27
34
|
*/
|
|
28
|
-
export function resolveMemoryDir() {
|
|
29
|
-
const
|
|
35
|
+
export function resolveMemoryDir(repoRoot = process.cwd()) {
|
|
36
|
+
const root =
|
|
37
|
+
typeof repoRoot === 'string' && repoRoot.length > 0 ? repoRoot : process.cwd();
|
|
38
|
+
const encoded = root.replaceAll('/', '-').replaceAll('.', '-');
|
|
30
39
|
return path.join(os.homedir(), '.claude', 'projects', encoded, 'memory');
|
|
31
40
|
}
|
|
@@ -82,6 +82,57 @@ export function safeBootstrapLock(signals) {
|
|
|
82
82
|
: null;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Read a session record's completion rate, tolerating both the shape
|
|
87
|
+
* `sessions.jsonl` actually writes and the legacy flat one.
|
|
88
|
+
*
|
|
89
|
+
* Production records nest it: `{effectiveness: {completion_rate: 0.77, …}}`.
|
|
90
|
+
* The top-level `completion_rate` key is ABSENT — not present-but-null, which
|
|
91
|
+
* an earlier revision of this comment claimed. Measured 2026-08-28 @ 7daa3d2
|
|
92
|
+
* over `.orchestrator/metrics/sessions.jsonl` (281 records):
|
|
93
|
+
* `jq -s '{hasTopLevelKey: ([.[]|select(has("completion_rate"))]|length), …}'`
|
|
94
|
+
* → `hasTopLevelKey: 0`, `nested: 182`, and 99 records carrying NO completion
|
|
95
|
+
* rate in either shape. Reading only the flat field therefore made every
|
|
96
|
+
* average 0 in production — see #1071.
|
|
97
|
+
*
|
|
98
|
+
* Precedence mirrors `completionOf()` in
|
|
99
|
+
* `scripts/lib/evolve/autopilot-effectiveness.mjs`: nested canonical fields
|
|
100
|
+
* first, flat legacy fields as a backward-compatible fallback for
|
|
101
|
+
* hand-written/pre-nesting records.
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} session
|
|
104
|
+
* @returns {number} completion rate, or 0 when unknown
|
|
105
|
+
*/
|
|
106
|
+
function completionRateOf(session) {
|
|
107
|
+
if (session === null || typeof session !== 'object') return 0;
|
|
108
|
+
const eff = (session.effectiveness !== null && typeof session.effectiveness === 'object')
|
|
109
|
+
? session.effectiveness
|
|
110
|
+
: {};
|
|
111
|
+
const raw =
|
|
112
|
+
eff.completion_rate ?? eff.completion_ratio ?? session.completion_rate ?? session.completion_ratio;
|
|
113
|
+
return typeof raw === 'number' && !Number.isNaN(raw) ? raw : 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Mean completion rate across a session slice. Unknown values contribute 0 and
|
|
118
|
+
* the divisor stays the slice length — an incomplete slice can never inflate
|
|
119
|
+
* the average past the >= 0.9 bonus threshold.
|
|
120
|
+
*
|
|
121
|
+
* The divisor is the load-bearing half, not the numerator: 99 of the 281
|
|
122
|
+
* records in production carry no completion rate in EITHER shape (measured
|
|
123
|
+
* 2026-08-28 @ 7daa3d2), so a divisor of "known values only" would let a single
|
|
124
|
+
* 1.0 session beside two unknowns score a perfect 1.0 and hand out the trend
|
|
125
|
+
* bonus on one data point. Pinned by the divisor test in
|
|
126
|
+
* `tests/lib/mode-selector/scoring.test.mjs`.
|
|
127
|
+
*
|
|
128
|
+
* @param {any[]} sessions
|
|
129
|
+
* @returns {number}
|
|
130
|
+
*/
|
|
131
|
+
function avgCompletionRate(sessions) {
|
|
132
|
+
if (sessions.length === 0) return 0;
|
|
133
|
+
return sessions.reduce((sum, s) => sum + completionRateOf(s), 0) / sessions.length;
|
|
134
|
+
}
|
|
135
|
+
|
|
85
136
|
// ---------------------------------------------------------------------------
|
|
86
137
|
// Public API
|
|
87
138
|
// ---------------------------------------------------------------------------
|
|
@@ -107,9 +158,7 @@ export function computeDelta(candidateMode, signals) {
|
|
|
107
158
|
(s) => typeof s === 'object' && s !== null && s.session_type === candidateMode,
|
|
108
159
|
);
|
|
109
160
|
if (allMatch) {
|
|
110
|
-
const avgCompletion =
|
|
111
|
-
last3.reduce((sum, s) => sum + (typeof s.completion_rate === 'number' ? s.completion_rate : 0), 0) /
|
|
112
|
-
3;
|
|
161
|
+
const avgCompletion = avgCompletionRate(last3);
|
|
113
162
|
trendBonus = avgCompletion >= 0.9 ? 0.15 : 0.075;
|
|
114
163
|
}
|
|
115
164
|
}
|
|
@@ -151,9 +200,7 @@ export function computeDelta(candidateMode, signals) {
|
|
|
151
200
|
(s) => typeof s === 'object' && s !== null && s.session_type === otherMode,
|
|
152
201
|
);
|
|
153
202
|
if (allOther) {
|
|
154
|
-
const avgCompletion =
|
|
155
|
-
last3.reduce((sum, s) => sum + (typeof s.completion_rate === 'number' ? s.completion_rate : 0), 0) /
|
|
156
|
-
3;
|
|
203
|
+
const avgCompletion = avgCompletionRate(last3);
|
|
157
204
|
if (avgCompletion >= 0.9) {
|
|
158
205
|
penalty += 0.10;
|
|
159
206
|
break; // only one other dominant mode possible
|