session-orchestrator 5.4.0 → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/060-evolve.mdc +5 -5
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +95 -3
- package/README.md +11 -11
- package/docs/ci-setup.md +120 -23
- package/docs/components.md +7 -7
- package/docs/events-schema.md +17 -14
- package/docs/install.md +14 -2
- package/docs/migration-v4.md +24 -17
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/scope-collision-guard.md +15 -13
- package/docs/session-config-reference.md +7 -7
- package/docs/vault-docs-architecture.md +8 -4
- package/hooks/_lib/hook-import-set.json +17 -8
- package/hooks/_lib/lock-bootstrap.mjs +118 -26
- package/hooks/config-protection.mjs +1 -1
- package/hooks/enforce-commands.mjs +28 -13
- package/hooks/enforce-scope.mjs +201 -23
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +4 -2
- package/hooks/on-stop.mjs +70 -170
- package/hooks/post-bash-write-verify.mjs +98 -17
- package/hooks/post-edit-validate.mjs +15 -5
- package/hooks/post-tool-batch-wave-signal.mjs +32 -154
- package/hooks/pre-bash-memory-propose-audit.mjs +19 -7
- package/hooks/pre-bash-staging-fence.mjs +29 -8
- package/hooks/pre-task-scope-disjoint.mjs +584 -207
- package/hooks/wave-scope-commit-guard.mjs +168 -72
- package/package.json +2 -6
- package/scripts/apply-session-learnings.mjs +35 -14
- package/scripts/archive-closed-prds.mjs +38 -11
- package/scripts/autopilot.mjs +8 -1
- package/scripts/backfill-abandoned-sessions.mjs +80 -5
- package/scripts/backfill-learnings-from-vault.mjs +49 -22
- package/scripts/codex-install.mjs +3 -0
- package/scripts/cursor-install.mjs +46 -2
- package/scripts/emit-session.mjs +5 -3
- package/scripts/export-hw-learnings.mjs +18 -7
- package/scripts/fleet-checkin.mjs +26 -3
- package/scripts/lib/autopilot/loop.mjs +25 -10
- package/scripts/lib/autopilot/stall-sampler.mjs +13 -9
- package/scripts/lib/blocked-commands-policy.mjs +40 -0
- package/scripts/lib/common.mjs +41 -0
- package/scripts/lib/config/vault-integration.mjs +151 -16
- package/scripts/lib/config.mjs +5 -1
- package/scripts/lib/eval/engine.mjs +13 -0
- package/scripts/lib/events-retention-banner.mjs +393 -0
- package/scripts/lib/events-rotation.mjs +21 -6
- package/scripts/lib/events.mjs +51 -3
- package/scripts/lib/fleet-protocol.mjs +20 -2
- package/scripts/lib/hardening.mjs +1 -0
- package/scripts/lib/harness-audit/categories/category4.mjs +8 -6
- package/scripts/lib/instruction-budget-guard.mjs +8 -0
- package/scripts/lib/learnings/expiry-sweep.mjs +387 -20
- package/scripts/lib/learnings/io.mjs +125 -16
- package/scripts/lib/lock-reaper.mjs +38 -14
- package/scripts/lib/maintenance-due-banner.mjs +23 -8
- package/scripts/lib/named-vault-resolver.mjs +48 -5
- package/scripts/lib/orphan-reaper/no-follow-append.mjs +87 -0
- package/scripts/lib/orphan-reaper/reaper-audit.mjs +101 -69
- package/scripts/lib/orphan-reaper/scan-throttle.mjs +79 -27
- package/scripts/lib/orphan-reaper/scan.mjs +4 -0
- package/scripts/lib/orphan-reaper/trigger.mjs +173 -0
- package/scripts/lib/orphan-reaper.mjs +5 -1
- package/scripts/lib/platform.mjs +206 -2
- package/scripts/lib/plugin-update-banner.mjs +3 -1
- package/scripts/lib/process-group.mjs +83 -22
- package/scripts/lib/scope-baseline.mjs +116 -25
- package/scripts/lib/scope-echo.mjs +7 -0
- package/scripts/lib/scope-gate.mjs +194 -27
- package/scripts/lib/session-close-backfill.mjs +129 -4
- package/scripts/lib/session-end/tail-runner.mjs +10 -5
- package/scripts/lib/session-lock-shape.mjs +6 -3
- package/scripts/lib/session-lock.mjs +320 -67
- package/scripts/lib/session-registry.mjs +41 -16
- package/scripts/lib/session-start-probes.mjs +242 -68
- package/scripts/lib/session-token-rollup.mjs +39 -34
- package/scripts/lib/sessions-staleness-banner.mjs +3 -1
- package/scripts/lib/tail-window.mjs +27 -3
- package/scripts/lib/telemetry/pricing.mjs +5 -2
- package/scripts/lib/telemetry/sync.mjs +17 -0
- package/scripts/lib/tests-src-ratio.mjs +77 -2
- package/scripts/lib/tmux-layout/telemetry-stats.mjs +12 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +320 -32
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +1 -1
- package/scripts/lib/vault-mirror/auto-commit.mjs +5 -3
- package/scripts/lib/vault-mirror/namespace.mjs +47 -26
- package/scripts/lib/vault-mirror/process.mjs +350 -62
- package/scripts/lib/vault-mirror/pseudonym-map.mjs +14 -5
- package/scripts/lib/vault-mirror/render-learnings.mjs +66 -3
- package/scripts/lib/vault-mirror/render-sessions.mjs +28 -4
- package/scripts/lib/vault-mirror/telemetry.mjs +18 -11
- package/scripts/lib/vault-status/board-writer.mjs +5 -1
- package/scripts/lib/vcs-repo-spec.mjs +8 -0
- package/scripts/materialize-wave-scope.mjs +7 -4
- package/scripts/release.mjs +1 -0
- package/scripts/run-migrate-v2-cross-repo.mjs +14 -2
- package/scripts/run-quality-gate.mjs +8 -5
- package/scripts/self-update.mjs +229 -0
- package/scripts/sweep-expired-learnings.mjs +560 -28
- package/scripts/validate-wave-scope.mjs +21 -2
- package/scripts/vault-backfill.mjs +50 -37
- package/scripts/vault-mirror.mjs +170 -33
- package/skills/_shared/fleet-protocol.md +74 -71
- package/skills/_shared/state-ownership.md +38 -14
- package/skills/autopilot/SKILL.md +1 -1
- package/skills/bootstrap/SKILL.md +5 -3
- package/skills/bootstrap/_shared-template.md +96 -1
- package/skills/bootstrap/fast-template.md +3 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +5 -3
- package/skills/bootstrap/standard-template.md +2 -0
- package/skills/discovery/probes/ssot-code-diff.mjs +12 -6
- package/skills/discovery/probes/vault-narrative-staleness.mjs +6 -2
- package/skills/discovery/probes/vault-staleness.mjs +9 -3
- package/skills/docs-orchestrator/SKILL.md +12 -5
- package/skills/docs-orchestrator/audience-mapping.md +5 -3
- package/skills/evolve/SKILL.md +11 -2
- package/skills/evolve/references/evolve-analyze-mode.md +37 -17
- package/skills/session-end/learning-patterns.md +3 -3
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-2-docs-verification.md +7 -6
- package/skills/session-end/plan-verification.md +25 -9
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/session-metrics-write.md +45 -10
- package/skills/session-start/SKILL.md +3 -3
- package/skills/session-start/references/operations-contract.md +5 -3
- package/skills/session-start/references/phase-1-2-session-lock.md +32 -7
- package/skills/session-start/references/phase-4-ssot-environment-check.md +8 -1
- package/skills/vault-mirror/SKILL.md +21 -11
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/circuit-breaker.md +1 -1
- package/skills/wave-executor/references/wave-executor-state-init.md +1 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +8 -0
- package/skills/wave-executor/references/wave-loop-review.md +2 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +13 -5
- package/.agents/skills/navigator/SKILL.md +0 -22
- package/.agents/skills/navigator/agents/openai.yaml +0 -5
- package/.codex-plugin/skills/navigator/SKILL.md +0 -22
- package/.codex-plugin/skills/navigator/agents/openai.yaml +0 -5
- package/.cursor/commands/navigator.md +0 -15
- package/.cursor/skills/navigator/SKILL.md +0 -13
- package/pi/prompts/navigator.md +0 -12
- package/skills/navigator/SKILL.md +0 -193
- package/skills/navigator/references/account-check.sh +0 -87
- package/skills/navigator/references/fleet-decisions.mjs +0 -231
- package/skills/navigator/references/slot-check.sh +0 -114
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
* `git ls-files` — spawned HERE, in the CLI layer,
|
|
36
36
|
* because scripts/lib/scope-gate.mjs is hook-safe and
|
|
37
37
|
* must not spawn a process (see its module header).
|
|
38
|
+
* Runs even when the manifest fails schema validation
|
|
39
|
+
* (#1026.4): it reads only the sidecar, so schema
|
|
40
|
+
* errors and collisions are reported together (exit 1).
|
|
38
41
|
* --union <path> #1020. QUERY MODE. Read the same sidecar, compute
|
|
39
42
|
* `expandTestSiblings(unionFileScopes(scopes), {role})`
|
|
40
43
|
* using the MANIFEST'S OWN `role`, and print the
|
|
@@ -638,11 +641,24 @@ function knownRepoFiles() {
|
|
|
638
641
|
* @param {string} sidecarPath
|
|
639
642
|
*/
|
|
640
643
|
function assertDisjointOrDie(sidecarPath) {
|
|
644
|
+
if (!reportScopeCollisions(sidecarPath)) process.exit(1);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Run the #1020 collision check and write one stderr line per finding.
|
|
649
|
+
* Reads ONLY the sidecar, never the manifest — which is why {@link validate}
|
|
650
|
+
* can still run it when the manifest fails schema validation (#1026.4).
|
|
651
|
+
* Sidecar I/O and shape defects exit via {@link readAgentScopesOrDie}.
|
|
652
|
+
*
|
|
653
|
+
* @param {string} sidecarPath
|
|
654
|
+
* @returns {boolean} true when the wave is disjoint
|
|
655
|
+
*/
|
|
656
|
+
function reportScopeCollisions(sidecarPath) {
|
|
641
657
|
const agentScopes = readAgentScopesOrDie(sidecarPath, '--assert-disjoint');
|
|
642
658
|
const { ok, collisions, duplicateIds } = findScopeCollisions(agentScopes, {
|
|
643
659
|
knownFiles: knownRepoFiles(),
|
|
644
660
|
});
|
|
645
|
-
if (ok) return;
|
|
661
|
+
if (ok) return true;
|
|
646
662
|
|
|
647
663
|
// Duplicate ids FIRST: they are a malformed plan, and a reader who fixes them
|
|
648
664
|
// may well change which collisions remain.
|
|
@@ -659,7 +675,7 @@ function assertDisjointOrDie(sidecarPath) {
|
|
|
659
675
|
process.stderr.write(
|
|
660
676
|
`ERROR: ${collisions.length} scope collision(s), ${duplicateIds.length} duplicate id(s) — every file must belong to exactly ONE agent per wave (#1020; .claude/rules/parallel-sessions.md § Decision Tree)\n`,
|
|
661
677
|
);
|
|
662
|
-
|
|
678
|
+
return false;
|
|
663
679
|
}
|
|
664
680
|
|
|
665
681
|
/**
|
|
@@ -709,6 +725,9 @@ function validate(
|
|
|
709
725
|
for (const e of errors) {
|
|
710
726
|
process.stderr.write(`ERROR: ${e}\n`);
|
|
711
727
|
}
|
|
728
|
+
// #1026.4 — the collision check reads only the sidecar, so a schema error
|
|
729
|
+
// must not hide its findings: report both, exit 1 either way.
|
|
730
|
+
if (assertDisjointPath) reportScopeCollisions(assertDisjointPath);
|
|
712
731
|
process.exit(1);
|
|
713
732
|
}
|
|
714
733
|
|
|
@@ -38,6 +38,10 @@ import {
|
|
|
38
38
|
} from './lib/vault-backfill/template.mjs';
|
|
39
39
|
|
|
40
40
|
import { validateManifest, SLUG_RE } from './lib/vault-backfill/manifest.mjs';
|
|
41
|
+
import { _parseVaultIntegration } from './lib/config/vault-integration.mjs';
|
|
42
|
+
import { loadHostPaths, resolveVaultIntegrationHost } from './lib/config/host-paths.mjs';
|
|
43
|
+
import { resolveVaultDir } from './lib/config.mjs';
|
|
44
|
+
import { expandTilde } from './lib/common.mjs';
|
|
41
45
|
|
|
42
46
|
// ── CLI argument parsing ──────────────────────────────────────────────────────
|
|
43
47
|
|
|
@@ -85,9 +89,29 @@ let hadWriteError = false;
|
|
|
85
89
|
|
|
86
90
|
if (flagVerbose) setGlabVerbose(true);
|
|
87
91
|
|
|
88
|
-
// ── Session Config reader
|
|
92
|
+
// ── Session Config reader ─────────────────────────────────────────────────────
|
|
89
93
|
|
|
90
|
-
/**
|
|
94
|
+
/**
|
|
95
|
+
* Read vault-integration.{gitlab-groups,vault-dir} from CLAUDE.md / AGENTS.md in
|
|
96
|
+
* CWD through the canonical Session Config parser (#1094), so every form it
|
|
97
|
+
* accepts (block, inline object, dash/bold bullet) reaches this CLI too.
|
|
98
|
+
*
|
|
99
|
+
* `vault-dir` resolves HOST-LOCALLY like every other vault writer:
|
|
100
|
+
* SO_VAULT_DIR > owner.yaml `vault-dirs:` cwd match > owner.yaml
|
|
101
|
+
* `paths.vault-dir` > the committed value (`resolveVaultDir`). The committed
|
|
102
|
+
* value alone would put `--apply` stubs into whatever vault the committed file
|
|
103
|
+
* names, even on a host whose owner.yaml points elsewhere. Not
|
|
104
|
+
* `parseSessionConfig`: it throws on an invalid value in any unrelated block.
|
|
105
|
+
*
|
|
106
|
+
* `enabled` is the switch the other vault writers obey (board-writer,
|
|
107
|
+
* narrative-mirror: `enabled !== true` → skip), after the same host-local
|
|
108
|
+
* lowering `parseSessionConfig` applies — env `SO_VAULT_INTEGRATION` >
|
|
109
|
+
* owner.yaml `vault-integration:` > committed (#1496: stubs were created with
|
|
110
|
+
* the integration off, from an owner.yaml `paths.vault-dir` alone).
|
|
111
|
+
*
|
|
112
|
+
* @returns {{'gitlab-groups': string[]|null, 'vault-dir': string|null, enabled: boolean,
|
|
113
|
+
* 'host-override': string|null}}
|
|
114
|
+
*/
|
|
91
115
|
function readVaultIntegrationConfig() {
|
|
92
116
|
const candidates = [join(process.cwd(), 'CLAUDE.md'), join(process.cwd(), 'AGENTS.md')];
|
|
93
117
|
|
|
@@ -98,45 +122,24 @@ function readVaultIntegrationConfig() {
|
|
|
98
122
|
}
|
|
99
123
|
}
|
|
100
124
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (!inBlock && /^vault-integration:\s*$/.test(line)) { inBlock = true; continue; }
|
|
111
|
-
if (inBlock) {
|
|
112
|
-
if (line.length > 0 && !/^\s/.test(line)) break;
|
|
113
|
-
|
|
114
|
-
const m = line.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
|
|
115
|
-
if (!m) continue;
|
|
116
|
-
|
|
117
|
-
const k = m[1].trim();
|
|
118
|
-
let v = m[2].trim().replace(/\s*#.*$/, '').trim();
|
|
119
|
-
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
120
|
-
v = v.slice(1, -1);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (k === 'gitlab-groups' && v) {
|
|
124
|
-
gitlabGroups = v.replace(/^\[/, '').replace(/\]$/, '').trim() || null;
|
|
125
|
-
}
|
|
126
|
-
if (k === 'vault-dir' && v && v !== 'none' && v !== 'null') {
|
|
127
|
-
vaultDir = v;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return { 'gitlab-groups': gitlabGroups, 'vault-dir': vaultDir };
|
|
125
|
+
const hostCtx = loadHostPaths();
|
|
126
|
+
const vi = resolveVaultIntegrationHost(_parseVaultIntegration(content ?? ''), hostCtx);
|
|
127
|
+
const vaultDir = resolveVaultDir(vi['vault-dir'], hostCtx).value ?? null;
|
|
128
|
+
return {
|
|
129
|
+
'gitlab-groups': vi['gitlab-groups'],
|
|
130
|
+
'vault-dir': vaultDir,
|
|
131
|
+
enabled: vi.enabled === true,
|
|
132
|
+
'host-override': vi['host-override'],
|
|
133
|
+
};
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
// ── Vault-dir folder stub ─────────────────────────────────────────────────────
|
|
136
137
|
|
|
137
138
|
function createVaultFolderStub(vaultDir, slug) {
|
|
138
139
|
if (!vaultDir) return;
|
|
139
|
-
|
|
140
|
+
// `~/…` is the documented vault-dir shape; resolve() alone does not expand it
|
|
141
|
+
// and would put the stub under `<cwd>/~/…`.
|
|
142
|
+
const stubDir = join(resolve(expandTilde(vaultDir)), '01-projects', slug);
|
|
140
143
|
try {
|
|
141
144
|
if (!existsSync(stubDir)) {
|
|
142
145
|
if (applyWrites) {
|
|
@@ -399,10 +402,20 @@ async function main() {
|
|
|
399
402
|
if (flagGroups) {
|
|
400
403
|
groups = flagGroups.split(',').map((g) => g.trim()).filter(Boolean);
|
|
401
404
|
} else if (cfgConfig['gitlab-groups']) {
|
|
402
|
-
groups = cfgConfig['gitlab-groups']
|
|
405
|
+
groups = cfgConfig['gitlab-groups'];
|
|
403
406
|
}
|
|
404
407
|
|
|
405
|
-
|
|
408
|
+
// The configured vault-dir counts only while vault-integration is on; an
|
|
409
|
+
// explicit --vault-dir is this run's own request and still wins.
|
|
410
|
+
let vaultDir = flagVaultDir ?? null;
|
|
411
|
+
if (vaultDir === null && cfgConfig['vault-dir'] !== null) {
|
|
412
|
+
if (cfgConfig.enabled) {
|
|
413
|
+
vaultDir = cfgConfig['vault-dir'];
|
|
414
|
+
} else {
|
|
415
|
+
const why = cfgConfig['host-override'] ? `switched off by ${cfgConfig['host-override']}` : 'not enabled';
|
|
416
|
+
log(`vault-integration is ${why} — no vault folder stubs (pass --vault-dir to create them anyway)`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
406
419
|
|
|
407
420
|
if (!flagYes && (!groups || groups.length === 0)) {
|
|
408
421
|
die(1, 'no GitLab groups specified — pass --groups <CSV> or set vault-integration.gitlab-groups in CLAUDE.md');
|
package/scripts/vault-mirror.mjs
CHANGED
|
@@ -8,23 +8,40 @@
|
|
|
8
8
|
* CLI usage:
|
|
9
9
|
* node vault-mirror.mjs --vault-dir <path> --source <jsonl-path> --kind <learning|session>
|
|
10
10
|
* [--dry-run] [--strict-schema] [--no-commit] [--force]
|
|
11
|
-
* [--session-id <id>]
|
|
11
|
+
* [--session-id <id>] [--repo-root <path>] [--vault-name <name>]
|
|
12
12
|
* [--quality-min-narrative-chars <int>] (sessions only; default 400)
|
|
13
13
|
* [--quality-min-confidence <float>] (learnings only; default 0.5)
|
|
14
14
|
*
|
|
15
15
|
* Exit codes:
|
|
16
16
|
* 0 — success (including idempotent no-op)
|
|
17
17
|
* 1 — validation error (malformed JSON line, bad slug, etc.)
|
|
18
|
-
* 2 — filesystem error
|
|
18
|
+
* 2 — filesystem error, or a pre-loop refusal (missing vault-dir / source,
|
|
19
|
+
* non-canonical vault, `--source` belonging to a different repo than
|
|
20
|
+
* `--repo-root` — #1503)
|
|
19
21
|
*
|
|
20
22
|
* Output: one JSON line per action on stdout:
|
|
21
|
-
* {"action":"created|updated|skipped-noop|skipped-handwritten|skipped-collision-resolved|skipped-invalid|skipped-quality-low|skipped-abandoned|skipped-duplicate-session","path":"...","kind":"...","id":"..."}
|
|
23
|
+
* {"action":"created|updated|skipped-noop|skipped-handwritten|skipped-collision-resolved|skipped-foreign-owner|skipped-invalid|skipped-quality-low|skipped-abandoned|skipped-duplicate-session","path":"...","kind":"...","id":"..."}
|
|
22
24
|
*
|
|
23
25
|
* Idempotency rules:
|
|
24
26
|
* 1. File does not exist → create.
|
|
25
27
|
* 2. File exists, has _generator marker, id matches → overwrite only if updated would advance; else skipped-noop.
|
|
26
28
|
* 3. File exists, lacks _generator → skip (hand-written). Log to stderr.
|
|
27
29
|
* 4. File exists, has _generator, id differs → collision-disambiguate by appending -<first8 of uuid>.
|
|
30
|
+
* (Sessions have no disambiguation: a different-id note is skipped-foreign-owner.)
|
|
31
|
+
* 5. File exists, has _generator, but its `source-record` names a different record
|
|
32
|
+
* than this write (#1503): learnings → collision-disambiguate as in rule 4;
|
|
33
|
+
* sessions → skipped-foreign-owner unless the key occurs in this --source ledger.
|
|
34
|
+
* A leak in such a foreign note is masked IN PLACE (owner, content and
|
|
35
|
+
* source-record kept; `healed_leak: true` on the action) — never rewritten
|
|
36
|
+
* from this record. If the masked frontmatter would not parse, it is left
|
|
37
|
+
* unchanged with a stderr WARN.
|
|
38
|
+
*
|
|
39
|
+
* Repo root (#1503): the namespace is the identity of the repo the --source
|
|
40
|
+
* ledger belongs to — `--repo-root` when given, else the project holding
|
|
41
|
+
* --source (`<root>/.orchestrator/metrics/`), else the git repo containing it,
|
|
42
|
+
* else the cwd (a loose source outside any git repo). It is never the cwd
|
|
43
|
+
* when the source sits in a repo: running from another repo's checkout used to
|
|
44
|
+
* write one project's records over another project's notes.
|
|
28
45
|
*
|
|
29
46
|
* Quality gate (PRD F1.2):
|
|
30
47
|
* Learnings with confidence < --quality-min-confidence emit `skipped-quality-low`.
|
|
@@ -48,8 +65,8 @@
|
|
|
48
65
|
* Part of session-orchestrator vault-mirror (Issue #14).
|
|
49
66
|
*/
|
|
50
67
|
|
|
51
|
-
import { existsSync } from 'node:fs';
|
|
52
|
-
import { basename, resolve } from 'node:path';
|
|
68
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
69
|
+
import { basename, dirname, resolve } from 'node:path';
|
|
53
70
|
import { createInterface } from 'node:readline';
|
|
54
71
|
import { createReadStream } from 'node:fs';
|
|
55
72
|
|
|
@@ -64,11 +81,12 @@ import { emitEvent } from './lib/events.mjs';
|
|
|
64
81
|
import { autoCommitVaultMirror } from './lib/vault-mirror/auto-commit.mjs';
|
|
65
82
|
import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
|
|
66
83
|
import { resolveRepoNamespace } from './lib/vault-mirror/namespace.mjs';
|
|
67
|
-
import {
|
|
84
|
+
import { sessionSourceRecord } from './lib/vault-mirror/render-sessions.mjs';
|
|
85
|
+
import { checkCanonicalVault, describeOriginForLog, findRepoRoot, normalizeRemote } from './lib/named-vault-resolver.mjs';
|
|
68
86
|
import { loadOwnerConfig } from './lib/owner-yaml.mjs';
|
|
69
87
|
import { canonicalizeSessions } from './lib/sessions-canonical.mjs';
|
|
70
88
|
import { isMainModule } from './lib/is-main-module.mjs';
|
|
71
|
-
import { expandTilde } from './lib/common.mjs';
|
|
89
|
+
import { expandTilde, redactHomeDir } from './lib/common.mjs';
|
|
72
90
|
|
|
73
91
|
// ── Canonical-vault helpers (#600 D2 / #607 D2) ────────────────────────────────
|
|
74
92
|
// These are module-level (above the CLI bootstrap) so the module is import-safe
|
|
@@ -131,9 +149,10 @@ export function _normalizeRemote(url) {
|
|
|
131
149
|
// is always measured.
|
|
132
150
|
// - Same ledger as the masker emit: `emitEvent` is called 2-arg so both events
|
|
133
151
|
// from one run resolve the SAME destination (`SO_PROJECT_DIR`, i.e.
|
|
134
|
-
// `CLAUDE_PROJECT_DIR` or the CWD walk-up).
|
|
135
|
-
//
|
|
136
|
-
//
|
|
152
|
+
// `CLAUDE_PROJECT_DIR` or the CWD walk-up). The repo root that decides the
|
|
153
|
+
// vault NAMESPACE (#1503: `--repo-root`, else derived from `--source`) is
|
|
154
|
+
// deliberately NOT used for the ledger — routing telemetry by it would split
|
|
155
|
+
// a single run's records across two ledgers.
|
|
137
156
|
//
|
|
138
157
|
// #1147 moved both emitters into `scripts/lib/vault-mirror/telemetry.mjs` and
|
|
139
158
|
// widened the coverage from "the two skipped-invalid branches" to "every entry,
|
|
@@ -186,13 +205,14 @@ if (_isDirectInvocation) {
|
|
|
186
205
|
kind: null,
|
|
187
206
|
'session-id': null,
|
|
188
207
|
'vault-name': null,
|
|
208
|
+
'repo-root': null,
|
|
189
209
|
'quality-min-narrative-chars': null,
|
|
190
210
|
'quality-min-confidence': null,
|
|
191
211
|
},
|
|
192
212
|
});
|
|
193
213
|
} catch (err) {
|
|
194
214
|
if (err instanceof CliFlagError) {
|
|
195
|
-
process.stderr.write(`vault-mirror: ${err.message}\n`);
|
|
215
|
+
process.stderr.write(`vault-mirror: ${redactHomeDir(err.message)}\n`);
|
|
196
216
|
process.exit(1);
|
|
197
217
|
}
|
|
198
218
|
throw err;
|
|
@@ -206,7 +226,7 @@ if (flagValues.help === true) {
|
|
|
206
226
|
[
|
|
207
227
|
'Usage: node vault-mirror.mjs --vault-dir <path> --source <jsonl-path> --kind <learning|session>',
|
|
208
228
|
' [--dry-run] [--strict-schema] [--no-commit] [--force]',
|
|
209
|
-
' [--session-id <id>]',
|
|
229
|
+
' [--session-id <id>] [--repo-root <path>] [--vault-name <name>]',
|
|
210
230
|
' [--quality-min-narrative-chars <int>]',
|
|
211
231
|
' [--quality-min-confidence <float>]',
|
|
212
232
|
'',
|
|
@@ -224,6 +244,11 @@ if (flagValues.help === true) {
|
|
|
224
244
|
' When set, mirrors write under <vault-name>/ instead of the',
|
|
225
245
|
' git-derived repo identifier. Maps to vault-integration.vault-name',
|
|
226
246
|
' in Session Config. Sanitised to a lowercase kebab slug.',
|
|
247
|
+
' --repo-root <path> The repo whose ledger --source is (#1503). Its .vault.yaml',
|
|
248
|
+
' slug / git remote decides the vault namespace. Default: the',
|
|
249
|
+
' project holding --source (<root>/.orchestrator/metrics/), else',
|
|
250
|
+
' the git repo containing it, else the cwd. Refused (exit 2,',
|
|
251
|
+
' "source-repo-mismatch") when --source lies in a different repo.',
|
|
227
252
|
' --quality-min-narrative-chars <int> Sessions: minimum rendered-narrative length (default 400).',
|
|
228
253
|
' Entries below the threshold emit "skipped-quality-low".',
|
|
229
254
|
' --quality-min-confidence <float> Learnings: minimum confidence threshold (default 0.5).',
|
|
@@ -249,6 +274,7 @@ const noCommit = flagValues['no-commit'] === true;
|
|
|
249
274
|
const force = flagValues.force === true;
|
|
250
275
|
const sessionIdArg = flagValues['session-id'];
|
|
251
276
|
const vaultName = flagValues['vault-name'] ?? null;
|
|
277
|
+
const repoRootArg = flagValues['repo-root'] ?? null;
|
|
252
278
|
|
|
253
279
|
// Quality-gate thresholds (PRD F1.2). Parse as numbers; reject malformed input
|
|
254
280
|
// loudly so CI cannot accidentally pass a string ("400px") and silently fall
|
|
@@ -289,7 +315,7 @@ const qualityMinConfidence = parseFloatFlag(
|
|
|
289
315
|
|
|
290
316
|
if (!vaultDir || !source || !kind) {
|
|
291
317
|
process.stderr.write(
|
|
292
|
-
'Usage: node vault-mirror.mjs --vault-dir <path> --source <jsonl-path> --kind <learning|session> [--dry-run] [--strict-schema] [--no-commit] [--force] [--session-id <id>] [--quality-min-narrative-chars <int>] [--quality-min-confidence <float>]\n',
|
|
318
|
+
'Usage: node vault-mirror.mjs --vault-dir <path> --source <jsonl-path> --kind <learning|session> [--dry-run] [--strict-schema] [--no-commit] [--force] [--session-id <id>] [--repo-root <path>] [--vault-name <name>] [--quality-min-narrative-chars <int>] [--quality-min-confidence <float>]\n',
|
|
293
319
|
);
|
|
294
320
|
process.exit(1);
|
|
295
321
|
}
|
|
@@ -343,16 +369,47 @@ function pathForLog(p) {
|
|
|
343
369
|
return `.../${basename(String(p ?? ''))}`;
|
|
344
370
|
}
|
|
345
371
|
|
|
372
|
+
/**
|
|
373
|
+
* The project a ledger belongs to (#1503): the directory that holds it under
|
|
374
|
+
* the `.orchestrator/metrics/` convention, else the git repo containing it,
|
|
375
|
+
* else `null`.
|
|
376
|
+
*
|
|
377
|
+
* The convention comes FIRST because the git toplevel is the wrong answer for
|
|
378
|
+
* a project nested inside a repo — a monorepo package with its own
|
|
379
|
+
* `.orchestrator/` and `.vault.yaml` (measured: `--repo-root <package>` was
|
|
380
|
+
* refused against the monorepo's namespace, and without the flag the package's
|
|
381
|
+
* notes moved to the monorepo's folder). Every producer writes the ledgers to
|
|
382
|
+
* `<project>/.orchestrator/metrics/`, which is also exactly the directory whose
|
|
383
|
+
* `.vault.yaml` named the namespace before #1503 (callers ran from it).
|
|
384
|
+
*
|
|
385
|
+
* Named ceiling: a ledger stored under some project's `.orchestrator/metrics/`
|
|
386
|
+
* is attributed to that project whoever wrote its records — a foreign ledger
|
|
387
|
+
* COPIED there is not detected here; the session `source-record` guard in
|
|
388
|
+
* process.mjs is the remaining defence for that shape.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} realSource - the source path after `realpathSync`.
|
|
391
|
+
* @returns {string|null}
|
|
392
|
+
*/
|
|
393
|
+
function ledgerOwnerRoot(realSource) {
|
|
394
|
+
const metricsDir = dirname(realSource);
|
|
395
|
+
const orchestratorDir = dirname(metricsDir);
|
|
396
|
+
if (basename(metricsDir) === 'metrics' && basename(orchestratorDir) === '.orchestrator') {
|
|
397
|
+
return dirname(orchestratorDir);
|
|
398
|
+
}
|
|
399
|
+
return findRepoRoot(dirname(realSource));
|
|
400
|
+
}
|
|
401
|
+
|
|
346
402
|
// ── Run-level accounting + run close-out (#1147) ──────────────────────────────
|
|
347
403
|
//
|
|
348
404
|
// Deliberately OUTSIDE main(): the run event's whole contract is that it is
|
|
349
405
|
// written ONCE PER RUN and that its ABSENCE is the broken-emitter signal
|
|
350
|
-
// (HR-105).
|
|
351
|
-
// the top of main (missing vault-dir, non-canonical vault, missing source
|
|
352
|
-
// malformed-JSON abort and the filesystem-error
|
|
353
|
-
// `process.exit`, which no `finally` and no
|
|
354
|
-
// top-level `main().catch`, which runs in a
|
|
355
|
-
// exist. Keeping the counters and the
|
|
406
|
+
// (HR-105). Seven exits bypass main's normal tail — the four PRE-LOOP aborts at
|
|
407
|
+
// the top of main (missing vault-dir, non-canonical vault, missing source,
|
|
408
|
+
// source/repo-root mismatch), the malformed-JSON abort and the filesystem-error
|
|
409
|
+
// abort inside the loop (all six `process.exit`, which no `finally` and no
|
|
410
|
+
// `catch` can intercept), and the top-level `main().catch`, which runs in a
|
|
411
|
+
// scope where main's locals no longer exist. Keeping the counters and the
|
|
412
|
+
// emitter out here is what lets all seven
|
|
356
413
|
// close the run out through ONE function instead of each re-deriving the
|
|
357
414
|
// payload.
|
|
358
415
|
const runState = {
|
|
@@ -381,10 +438,10 @@ const tally = (action) => {
|
|
|
381
438
|
* counted were the ones that vanished from the ledger, in the one shape
|
|
382
439
|
* ("no record") that the docstring reserves for a broken emitter.
|
|
383
440
|
*
|
|
384
|
-
* @param {'missing-vault-dir'|'vault-not-canonical'|'missing-source'|'malformed-json'|'filesystem-error'|'unexpected-error'} [aborted]
|
|
441
|
+
* @param {'missing-vault-dir'|'vault-not-canonical'|'missing-source'|'source-repo-mismatch'|'malformed-json'|'filesystem-error'|'unexpected-error'} [aborted]
|
|
385
442
|
* Omitted on a complete run. When present it LABELS the counters as partial:
|
|
386
443
|
* every line after the abort was never attempted, so the classes no longer
|
|
387
|
-
* partition `total`. On the
|
|
444
|
+
* partition `total`. On the PRE-LOOP values (#1151, #1503) nothing was
|
|
388
445
|
* attempted at all — `total` is a measured 0 and the label is what separates
|
|
389
446
|
* "never started" from "ran over an empty source".
|
|
390
447
|
* @returns {Promise<void>}
|
|
@@ -442,7 +499,7 @@ async function finishRun(aborted) {
|
|
|
442
499
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
443
500
|
|
|
444
501
|
async function main() {
|
|
445
|
-
// The
|
|
502
|
+
// The PRE-LOOP aborts below (#1151, #1503) close the run out through the same
|
|
446
503
|
// `finishRun` every other exit uses. They are the runs that never reached
|
|
447
504
|
// their first entry — a bad vault-dir, a wrong vault, a missing source — and
|
|
448
505
|
// until now they were the only outcomes that left NO record at all, which is
|
|
@@ -477,6 +534,80 @@ async function main() {
|
|
|
477
534
|
process.exit(2);
|
|
478
535
|
}
|
|
479
536
|
|
|
537
|
+
// ── Repo root + source/repo-root agreement (#1503) ──────────────────────────
|
|
538
|
+
//
|
|
539
|
+
// The namespace every note is written under is the identity of `repoRoot`.
|
|
540
|
+
// Until #1503 that was hard-wired to the invocation cwd, while `--source` was
|
|
541
|
+
// free to name ANY ledger: a session that ran this CLI from another repo's
|
|
542
|
+
// checkout with `--source <ThisRepo>/.orchestrator/metrics/sessions.jsonl`
|
|
543
|
+
// wrote 130 of this repo's records under the OTHER repo's namespace and
|
|
544
|
+
// overwrote 3 of its notes — slug and session-id collisions are routine across
|
|
545
|
+
// repos. The fix is to stop asking the cwd:
|
|
546
|
+
// 1. `--repo-root` when given — the caller states which repo it mirrors;
|
|
547
|
+
// 2. else the ledger's own project — see {@link ledgerOwnerRoot};
|
|
548
|
+
// 3. else the cwd — only for a source that is neither a project ledger nor
|
|
549
|
+
// inside a git repo (a copied ledger, the test suites' mkdtemp sources),
|
|
550
|
+
// where nothing better is knowable.
|
|
551
|
+
// Step 2's answer is also what an explicit `--repo-root` is checked against.
|
|
552
|
+
//
|
|
553
|
+
// The ledger's REAL path decides, not the path it was handed under: a symlink
|
|
554
|
+
// inside repo A that points at repo B's ledger is B's ledger. `realpathSync`
|
|
555
|
+
// falls back to the lexical path only when it throws (the file was just
|
|
556
|
+
// proven to exist, so that is a race or a permission edge, not a normal case).
|
|
557
|
+
// The same holds for `--repo-root`: a remote-less, slug-less repo is named by
|
|
558
|
+
// its directory, so a root reached through a symlink would otherwise carry the
|
|
559
|
+
// LINK's name and be refused against its own ledger.
|
|
560
|
+
const realOrLexical = (p) => {
|
|
561
|
+
try {
|
|
562
|
+
return realpathSync(p);
|
|
563
|
+
} catch {
|
|
564
|
+
return p;
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
const sourceRealPath = realOrLexical(resolve(source));
|
|
568
|
+
const sourceRepoRoot = ledgerOwnerRoot(sourceRealPath);
|
|
569
|
+
const repoRoot = repoRootArg
|
|
570
|
+
? realOrLexical(resolve(expandTilde(repoRootArg)))
|
|
571
|
+
: (sourceRepoRoot ?? process.cwd());
|
|
572
|
+
|
|
573
|
+
// A source with no owning project cannot be checked against anything: the
|
|
574
|
+
// namespace then rests on the cwd or on an unverifiable `--repo-root`, and a
|
|
575
|
+
// ledger copied out of ANOTHER repo would be written over this repo's notes
|
|
576
|
+
// without a refusal. Say so once — the run proceeds, because a loose ledger
|
|
577
|
+
// (a copy, a test fixture) is a legitimate input.
|
|
578
|
+
if (sourceRepoRoot === null) {
|
|
579
|
+
const ns = resolveRepoNamespace({ vaultName, repoRoot });
|
|
580
|
+
process.stderr.write(
|
|
581
|
+
repoRootArg
|
|
582
|
+
? `WARN vault-mirror: --source ${pathForLog(source)} is neither a project ledger (.orchestrator/metrics/) nor inside a git repo, so --repo-root cannot be checked against it; writing under namespace '${ns}'\n`
|
|
583
|
+
: `WARN vault-mirror: --source ${pathForLog(source)} is neither a project ledger (.orchestrator/metrics/) nor inside a git repo; the namespace '${ns}' comes from the cwd — pass --repo-root to state which repo this ledger belongs to\n`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Fail closed when the two disagree: an explicit `--repo-root` whose identity
|
|
588
|
+
// differs from the repo the ledger lives in is exactly the incident shape
|
|
589
|
+
// (callers pass `--repo-root "$PWD"`, so a run from the wrong checkout lands
|
|
590
|
+
// here instead of in another project's notes). NAMESPACES are compared, not
|
|
591
|
+
// paths — a worktree and its main checkout are different directories that
|
|
592
|
+
// share one `.vault.yaml` slug and one remote. `vaultName` is left out on
|
|
593
|
+
// purpose: it overrides the FOLDER, and the question here is whether the
|
|
594
|
+
// ledger belongs to the repo being mirrored. Named ceiling: two owner-leaky
|
|
595
|
+
// repos without a pseudonym-map entry both resolve to `redacted-repo` and pass
|
|
596
|
+
// this check — they already share that one folder (#725 D5), so the refusal
|
|
597
|
+
// could not keep them apart anyway.
|
|
598
|
+
if (repoRootArg && sourceRepoRoot !== null) {
|
|
599
|
+
const repoNs = resolveRepoNamespace({ repoRoot });
|
|
600
|
+
const sourceNs = resolveRepoNamespace({ repoRoot: sourceRepoRoot });
|
|
601
|
+
if (repoNs !== sourceNs) {
|
|
602
|
+
// Both values are leak-guarded namespaces — safe for logs, unlike the paths.
|
|
603
|
+
process.stderr.write(
|
|
604
|
+
`vault-mirror: refusing to mirror — --source ${pathForLog(source)} belongs to repo namespace '${sourceNs}', but --repo-root resolves to '${repoNs}'. Run with the repo root the ledger belongs to.\n`,
|
|
605
|
+
);
|
|
606
|
+
await finishRun('source-repo-mismatch');
|
|
607
|
+
process.exit(2);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
480
611
|
const rl = createInterface({
|
|
481
612
|
input: createReadStream(resolve(source), 'utf8'),
|
|
482
613
|
crlfDelay: Infinity,
|
|
@@ -502,10 +633,14 @@ async function main() {
|
|
|
502
633
|
kind,
|
|
503
634
|
force,
|
|
504
635
|
vaultName,
|
|
505
|
-
// #1389: the repo whose `.vault.yaml`
|
|
506
|
-
//
|
|
507
|
-
// shared by every processor call and the auto-commit below.
|
|
508
|
-
repoRoot
|
|
636
|
+
// #1389/#1503: the repo whose identity (`.vault.yaml` slug, else git remote)
|
|
637
|
+
// is the namespace — resolved ONCE above from `--repo-root` / `--source`,
|
|
638
|
+
// and shared by every processor call and the auto-commit below.
|
|
639
|
+
repoRoot,
|
|
640
|
+
// #1503: every `source-record` key the `--kind session` source carries,
|
|
641
|
+
// superseded duplicates included — filled while buffering below, read by
|
|
642
|
+
// processSession's owner guard (a key from this ledger is never foreign).
|
|
643
|
+
sourceRecordKeys: new Set(),
|
|
509
644
|
qualityMinNarrativeChars,
|
|
510
645
|
qualityMinConfidence,
|
|
511
646
|
};
|
|
@@ -541,7 +676,7 @@ async function main() {
|
|
|
541
676
|
} catch (err) {
|
|
542
677
|
// Validation errors (missing required fields) → per-entry skip, not a global failure
|
|
543
678
|
if (err.message.startsWith('vault-mirror:')) {
|
|
544
|
-
process.stderr.write(`${err.message}\n`);
|
|
679
|
+
process.stderr.write(`${redactHomeDir(err.message)}\n`);
|
|
545
680
|
const entryId = entry?.id ?? entry?.session_id ?? null;
|
|
546
681
|
process.stdout.write(
|
|
547
682
|
JSON.stringify({ action: 'skipped-invalid', path: null, kind, id: entryId }) + '\n',
|
|
@@ -554,7 +689,7 @@ async function main() {
|
|
|
554
689
|
line: entryLineNum,
|
|
555
690
|
recordId: entryId,
|
|
556
691
|
skipClass: 'validation',
|
|
557
|
-
reason: err.message,
|
|
692
|
+
reason: redactHomeDir(err.message),
|
|
558
693
|
dryRun,
|
|
559
694
|
});
|
|
560
695
|
return;
|
|
@@ -573,7 +708,7 @@ async function main() {
|
|
|
573
708
|
(typeof err.code === 'string' && err.code.length > 0) || Boolean(err.syscall);
|
|
574
709
|
if (!isSystemError) {
|
|
575
710
|
process.stderr.write(
|
|
576
|
-
`vault-mirror: mapper crash on line ${entryLineNum} (${err.message}) — record skipped\n`,
|
|
711
|
+
`vault-mirror: mapper crash on line ${entryLineNum} (${redactHomeDir(err.message)}) — record skipped\n`,
|
|
577
712
|
);
|
|
578
713
|
const entryId = entry?.id ?? entry?.session_id ?? null;
|
|
579
714
|
process.stdout.write(
|
|
@@ -593,13 +728,13 @@ async function main() {
|
|
|
593
728
|
line: entryLineNum,
|
|
594
729
|
recordId: entryId,
|
|
595
730
|
skipClass: 'mapper-crash',
|
|
596
|
-
reason: err.message,
|
|
731
|
+
reason: redactHomeDir(err.message),
|
|
597
732
|
dryRun,
|
|
598
733
|
});
|
|
599
734
|
return;
|
|
600
735
|
}
|
|
601
736
|
// Unexpected filesystem errors → fatal
|
|
602
|
-
process.stderr.write(`vault-mirror: filesystem error on line ${entryLineNum}: ${err.message}\n`);
|
|
737
|
+
process.stderr.write(`vault-mirror: filesystem error on line ${entryLineNum}: ${redactHomeDir(err.message)}\n`);
|
|
603
738
|
await finishRun('filesystem-error');
|
|
604
739
|
process.exit(2);
|
|
605
740
|
}
|
|
@@ -625,7 +760,7 @@ async function main() {
|
|
|
625
760
|
try {
|
|
626
761
|
entry = JSON.parse(trimmed);
|
|
627
762
|
} catch (err) {
|
|
628
|
-
process.stderr.write(`vault-mirror: malformed JSON on line ${lineNum}: ${err.message}\n`);
|
|
763
|
+
process.stderr.write(`vault-mirror: malformed JSON on line ${lineNum}: ${redactHomeDir(err.message)}\n`);
|
|
629
764
|
// Close the run out BEFORE exiting: `process.exit` runs no `finally`, so
|
|
630
765
|
// without this the abort is the one outcome that leaves no run record —
|
|
631
766
|
// exactly the shape reserved for a broken emitter.
|
|
@@ -635,6 +770,8 @@ async function main() {
|
|
|
635
770
|
|
|
636
771
|
if (kind === 'session') {
|
|
637
772
|
sessionEntries.push(entry);
|
|
773
|
+
const recordKey = sessionSourceRecord(entry);
|
|
774
|
+
if (recordKey !== null) ctx.sourceRecordKeys.add(recordKey);
|
|
638
775
|
sessionLineNums.push(lineNum);
|
|
639
776
|
continue;
|
|
640
777
|
}
|
|
@@ -732,7 +869,7 @@ async function main() {
|
|
|
732
869
|
}
|
|
733
870
|
|
|
734
871
|
main().catch(async (err) => {
|
|
735
|
-
process.stderr.write(`vault-mirror: unexpected error: ${err.message}\n`);
|
|
872
|
+
process.stderr.write(`vault-mirror: unexpected error: ${redactHomeDir(err.message)}\n`);
|
|
736
873
|
// Same reason as the two in-loop aborts: an unexpected throw is a run that
|
|
737
874
|
// ENDED, and the ledger has to say so. `finishRun` never throws, so this
|
|
738
875
|
// cannot turn a diagnosable crash into a silent one.
|