session-orchestrator 5.5.0 → 5.7.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-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +27 -13
- package/docs/USER-GUIDE.md +1 -1
- package/docs/rule-authoring.md +10 -0
- package/docs/scope-collision-guard.md +5 -3
- package/docs/session-config-reference.md +1 -1
- package/docs/session-config-template.md +1 -1
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-stop.mjs +8 -2
- package/hooks/post-bash-write-verify.mjs +121 -7
- package/hooks/post-tool-batch-wave-signal.mjs +11 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -8
- package/hooks/pre-task-scope-disjoint.mjs +20 -35
- package/hooks/wave-scope-commit-guard.mjs +117 -20
- package/package.json +8 -3
- package/scripts/backfill-abandoned-sessions.mjs +5 -7
- package/scripts/backfill-learnings-from-vault.mjs +17 -6
- package/scripts/lib/dispatcher/rank.mjs +5 -1
- package/scripts/lib/events.mjs +62 -49
- package/scripts/lib/io.mjs +9 -4
- package/scripts/lib/learnings/candidates.mjs +7 -16
- package/scripts/lib/memory-proposals/store.mjs +9 -2
- package/scripts/lib/reconcile/rule-expiry-sweep.mjs +52 -0
- package/scripts/lib/scope-gate.mjs +8 -1
- package/scripts/lib/session-close-backfill.mjs +104 -13
- package/scripts/lib/validate/check-test-value-bans.mjs +9 -0
- package/scripts/lib/vault-mirror/process.mjs +388 -56
- package/scripts/lib/vault-mirror/prune.mjs +413 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +13 -5
- package/scripts/lib/vault-mirror/retention.mjs +388 -0
- package/scripts/lib/vault-status/board-writer.mjs +6 -6
- package/scripts/lib/wave-transcript-tail.mjs +11 -2
- package/scripts/print-applicable-rules.mjs +1 -25
- package/scripts/release.mjs +4 -0
- package/scripts/validate-wave-scope.mjs +39 -4
- package/scripts/vault-mirror-prune.mjs +145 -0
- package/scripts/vault-mirror.mjs +77 -12
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +6 -16
- package/skills/vault-mirror/SKILL.md +27 -2
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +3 -1
- package/hooks/agent-teams-h3-test.sh +0 -362
- package/scripts/spikes/h3-agent-teams/preflight.sh +0 -53
- package/scripts/spikes/h3-agent-teams/run-h3.sh +0 -112
- package/scripts/spikes/h3-agent-teams/setup.sh +0 -137
- package/scripts/spikes/h3-agent-teams/toggle.sh +0 -38
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* vault-mirror-prune.mjs — CLI for the mirror-zone retention prune (#1513).
|
|
4
|
+
*
|
|
5
|
+
* Applies the vault-mirror retention rules to the notes the mirror wrote
|
|
6
|
+
* BEFORE those rules existed: expired / superseded / duplicate learnings,
|
|
7
|
+
* metrics-only session notes (rolled up first), namespace-alias folders and
|
|
8
|
+
* flat legacy notes. All decisions live in `scripts/lib/vault-mirror/prune.mjs`
|
|
9
|
+
* (rules table in its header) and `retention.mjs`; this file is argv parsing,
|
|
10
|
+
* the manifest, output and exit codes.
|
|
11
|
+
*
|
|
12
|
+
* SAFETY: dry run is the DEFAULT and writes nothing — not even the manifest
|
|
13
|
+
* unless `--manifest` names a path. `--apply` moves files (archive → under
|
|
14
|
+
* `90-archive/mirror/`), never deletes a note's content, never touches a note
|
|
15
|
+
* without the mirror's `_generator` marker.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* node scripts/vault-mirror-prune.mjs --vault-dir PATH [--apply]
|
|
19
|
+
* [--manifest PATH] [--now ISO] [--min-narrative-chars N]
|
|
20
|
+
* [--alias FROM=TO ...] [--json]
|
|
21
|
+
*
|
|
22
|
+
* Exit codes: 0 success · 1 usage error · 2 apply finished with per-path errors
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { writeFileSync } from 'node:fs';
|
|
26
|
+
import { resolve } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { expandTilde, redactHomeDir } from './lib/common.mjs';
|
|
29
|
+
import { isMainModule } from './lib/is-main-module.mjs';
|
|
30
|
+
import { MANIFEST_SCHEMA, applyPrune, planPrune } from './lib/vault-mirror/prune.mjs';
|
|
31
|
+
|
|
32
|
+
const HELP = `Usage: node scripts/vault-mirror-prune.mjs --vault-dir PATH [--apply] [--manifest PATH]
|
|
33
|
+
[--now ISO] [--min-narrative-chars N] [--alias FROM=TO ...] [--json]
|
|
34
|
+
|
|
35
|
+
Plans (default) or applies the vault-mirror retention rules to 40-learnings/
|
|
36
|
+
and 50-sessions/ of a vault. Only notes carrying the vault-mirror _generator
|
|
37
|
+
marker are considered; hand-written notes are never touched.
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--vault-dir PATH Vault root (required; no host-local default on purpose)
|
|
41
|
+
--apply Write: rollups, then archive moves to 90-archive/mirror/, then renames
|
|
42
|
+
--manifest PATH Write the JSON manifest (path, reason, action, target) there
|
|
43
|
+
--now ISO Injected clock for the expiry check (default: now)
|
|
44
|
+
--min-narrative-chars N Session narrative gate (default 400, as vault-mirror.quality)
|
|
45
|
+
--alias FROM=TO Explicit namespace alias (repeatable); the hyphen rule applies without it
|
|
46
|
+
--json Print the summary as one JSON line
|
|
47
|
+
|
|
48
|
+
Exit codes: 0 success 1 usage error 2 apply finished with per-path errors
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
function usageError(msg) {
|
|
52
|
+
process.stderr.write(`vault-mirror-prune: ${msg}\n`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function parseArgs(argv) {
|
|
57
|
+
const args = { vaultDir: null, apply: false, manifest: null, now: null, minNarrativeChars: 400, aliases: {}, json: false };
|
|
58
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
const value = () => {
|
|
61
|
+
const v = argv[i + 1];
|
|
62
|
+
if (v === undefined || v.startsWith('--')) usageError(`${a} needs a value`);
|
|
63
|
+
i += 1;
|
|
64
|
+
return v;
|
|
65
|
+
};
|
|
66
|
+
if (a === '--help' || a === '-h') {
|
|
67
|
+
process.stdout.write(HELP);
|
|
68
|
+
process.exit(0);
|
|
69
|
+
} else if (a === '--vault-dir') args.vaultDir = value();
|
|
70
|
+
else if (a === '--apply') args.apply = true;
|
|
71
|
+
else if (a === '--dry-run') args.apply = false;
|
|
72
|
+
else if (a === '--manifest') args.manifest = value();
|
|
73
|
+
else if (a === '--now') {
|
|
74
|
+
args.now = value();
|
|
75
|
+
if (!Number.isFinite(Date.parse(args.now))) usageError(`--now is not a date: ${args.now}`);
|
|
76
|
+
} else if (a === '--min-narrative-chars') {
|
|
77
|
+
const raw = value();
|
|
78
|
+
if (!/^\d+$/.test(raw)) usageError(`--min-narrative-chars must be a non-negative integer, got ${raw}`);
|
|
79
|
+
args.minNarrativeChars = Number(raw);
|
|
80
|
+
} else if (a === '--alias') {
|
|
81
|
+
const m = /^([a-z0-9][a-z0-9-]*)=([a-z0-9][a-z0-9-]*)$/.exec(value());
|
|
82
|
+
if (!m) usageError('--alias expects FROM=TO with lowercase kebab namespaces');
|
|
83
|
+
args.aliases[m[1]] = m[2];
|
|
84
|
+
} else if (a === '--json') args.json = true;
|
|
85
|
+
else usageError(`unknown argument ${a}`);
|
|
86
|
+
}
|
|
87
|
+
if (!args.vaultDir) usageError('--vault-dir is required');
|
|
88
|
+
return args;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function main(argv = process.argv.slice(2)) {
|
|
92
|
+
const args = parseArgs(argv);
|
|
93
|
+
const vaultDir = resolve(expandTilde(args.vaultDir));
|
|
94
|
+
const today = new Date(args.now ? Date.parse(args.now) : Date.now()).toISOString().slice(0, 10);
|
|
95
|
+
const plan = planPrune({ vaultDir, today, minNarrativeChars: args.minNarrativeChars, aliases: args.aliases });
|
|
96
|
+
|
|
97
|
+
const manifest = {
|
|
98
|
+
schema: MANIFEST_SCHEMA,
|
|
99
|
+
generated_at: new Date().toISOString(),
|
|
100
|
+
today,
|
|
101
|
+
dry_run: !args.apply,
|
|
102
|
+
min_narrative_chars: args.minNarrativeChars,
|
|
103
|
+
aliases: plan.aliases,
|
|
104
|
+
skipped_handwritten: plan.handwritten,
|
|
105
|
+
counts: plan.counts,
|
|
106
|
+
rollups: Object.fromEntries(Object.entries(plan.rollups).map(([k, rows]) => [k, rows.length])),
|
|
107
|
+
actions: plan.actions,
|
|
108
|
+
};
|
|
109
|
+
let applied = null;
|
|
110
|
+
if (args.apply) {
|
|
111
|
+
applied = applyPrune(plan, { vaultDir });
|
|
112
|
+
// The apply result belongs in the manifest: notes written beside an
|
|
113
|
+
// occupied archive target (`suffixed`) and per-path errors.
|
|
114
|
+
manifest.applied = applied;
|
|
115
|
+
}
|
|
116
|
+
if (args.manifest) writeFileSync(resolve(expandTilde(args.manifest)), JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
117
|
+
|
|
118
|
+
const summary = {
|
|
119
|
+
dry_run: !args.apply,
|
|
120
|
+
counts: plan.counts,
|
|
121
|
+
rollups: Object.keys(plan.rollups).length,
|
|
122
|
+
aliases: plan.aliases,
|
|
123
|
+
skipped_handwritten: plan.handwritten,
|
|
124
|
+
...(applied ? { applied: { ...applied, suffixed: applied.suffixed.length, errors: applied.errors.length } } : {}),
|
|
125
|
+
};
|
|
126
|
+
if (args.json) process.stdout.write(JSON.stringify(summary) + '\n');
|
|
127
|
+
else {
|
|
128
|
+
process.stdout.write(`vault-mirror-prune ${args.apply ? 'APPLY' : 'dry run'} (today ${today})\n`);
|
|
129
|
+
for (const [k, n] of Object.entries(plan.counts).sort()) process.stdout.write(` ${k.padEnd(36)} ${n}\n`);
|
|
130
|
+
process.stdout.write(` rollup notes ${Object.keys(plan.rollups).length}\n`);
|
|
131
|
+
for (const [from, to] of Object.entries(plan.aliases)) process.stdout.write(` alias ${from} -> ${to}\n`);
|
|
132
|
+
if (applied) {
|
|
133
|
+
process.stdout.write(` applied: archived ${applied.archived}, moved ${applied.moved}, rollups written ${applied.rollupsWritten}, suffixed ${applied.suffixed.length}, errors ${applied.errors.length}\n`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (applied && applied.errors.length > 0) {
|
|
137
|
+
for (const e of applied.errors.slice(0, 20)) process.stderr.write(`vault-mirror-prune: ${e.path}: ${redactHomeDir(e.error)}\n`);
|
|
138
|
+
return 2;
|
|
139
|
+
}
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isMainModule(import.meta.url)) {
|
|
144
|
+
process.exit(main());
|
|
145
|
+
}
|
package/scripts/vault-mirror.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* `--repo-root` — #1503)
|
|
21
21
|
*
|
|
22
22
|
* Output: one JSON line per action on stdout:
|
|
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":"..."}
|
|
23
|
+
* {"action":"created|updated|skipped-noop|skipped-handwritten|skipped-collision-resolved|skipped-foreign-owner|skipped-invalid|skipped-quality-low|skipped-abandoned|skipped-duplicate-session|skipped-metrics-only|skipped-expired|skipped-duplicate-insight","path":"...","kind":"...","id":"..."}
|
|
24
24
|
*
|
|
25
25
|
* Idempotency rules:
|
|
26
26
|
* 1. File does not exist → create.
|
|
@@ -45,8 +45,10 @@
|
|
|
45
45
|
*
|
|
46
46
|
* Quality gate (PRD F1.2):
|
|
47
47
|
* Learnings with confidence < --quality-min-confidence emit `skipped-quality-low`.
|
|
48
|
-
* Sessions
|
|
49
|
-
*
|
|
48
|
+
* Sessions whose free text (`notes`/`narrative`/`summary`) is shorter than
|
|
49
|
+
* --quality-min-narrative-chars get no note: `skipped-metrics-only` with one row
|
|
50
|
+
* in the month rollup `50-sessions/<repo>/_rollup-YYYY-MM.md` (#1513; rules in
|
|
51
|
+
* scripts/lib/vault-mirror/retention.mjs). Quality gate runs BEFORE --force; --force does NOT
|
|
50
52
|
* bypass the filter. Quality-skipped entries emit `path: null` and an
|
|
51
53
|
* additional `reason` field describing the violated threshold.
|
|
52
54
|
*
|
|
@@ -65,14 +67,16 @@
|
|
|
65
67
|
* Part of session-orchestrator vault-mirror (Issue #14).
|
|
66
68
|
*/
|
|
67
69
|
|
|
68
|
-
import { existsSync, realpathSync } from 'node:fs';
|
|
69
|
-
import { basename, dirname, resolve } from 'node:path';
|
|
70
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
71
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
70
72
|
import { createInterface } from 'node:readline';
|
|
71
73
|
import { createReadStream } from 'node:fs';
|
|
72
74
|
|
|
73
75
|
import {
|
|
74
76
|
processLearning,
|
|
75
77
|
processSession,
|
|
78
|
+
processArchivedLearning,
|
|
79
|
+
learningNoteSlugs,
|
|
76
80
|
getMaskerStats,
|
|
77
81
|
emitAction,
|
|
78
82
|
} from './lib/vault-mirror/process.mjs';
|
|
@@ -81,6 +85,7 @@ import { emitEvent } from './lib/events.mjs';
|
|
|
81
85
|
import { autoCommitVaultMirror } from './lib/vault-mirror/auto-commit.mjs';
|
|
82
86
|
import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
|
|
83
87
|
import { resolveRepoNamespace } from './lib/vault-mirror/namespace.mjs';
|
|
88
|
+
import { SESSION_NARRATIVE_FIELDS, canonicalNamespace } from './lib/vault-mirror/retention.mjs';
|
|
84
89
|
import { sessionSourceRecord } from './lib/vault-mirror/render-sessions.mjs';
|
|
85
90
|
import { checkCanonicalVault, describeOriginForLog, findRepoRoot, normalizeRemote } from './lib/named-vault-resolver.mjs';
|
|
86
91
|
import { loadOwnerConfig } from './lib/owner-yaml.mjs';
|
|
@@ -249,8 +254,9 @@ if (flagValues.help === true) {
|
|
|
249
254
|
' project holding --source (<root>/.orchestrator/metrics/), else',
|
|
250
255
|
' the git repo containing it, else the cwd. Refused (exit 2,',
|
|
251
256
|
' "source-repo-mismatch") when --source lies in a different repo.',
|
|
252
|
-
' --quality-min-narrative-chars <int> Sessions: minimum
|
|
253
|
-
'
|
|
257
|
+
' --quality-min-narrative-chars <int> Sessions: minimum free text in notes/narrative/summary',
|
|
258
|
+
' (default 400, 0 = off). Below it: "skipped-metrics-only",',
|
|
259
|
+
' one row in 50-sessions/<repo>/_rollup-YYYY-MM.md.',
|
|
254
260
|
' --quality-min-confidence <float> Learnings: minimum confidence threshold (default 0.5).',
|
|
255
261
|
' Entries below the threshold emit "skipped-quality-low".',
|
|
256
262
|
'',
|
|
@@ -641,6 +647,9 @@ async function main() {
|
|
|
641
647
|
// superseded duplicates included — filled while buffering below, read by
|
|
642
648
|
// processSession's owner guard (a key from this ledger is never foreign).
|
|
643
649
|
sourceRecordKeys: new Set(),
|
|
650
|
+
// #1513: note slugs of every LIVE learning in --source; the archive pass
|
|
651
|
+
// below never marks a note one of them still owns.
|
|
652
|
+
liveLearningSlugs: new Set(),
|
|
644
653
|
qualityMinNarrativeChars,
|
|
645
654
|
qualityMinConfidence,
|
|
646
655
|
};
|
|
@@ -662,14 +671,15 @@ async function main() {
|
|
|
662
671
|
* always supplies a real line number.
|
|
663
672
|
* @returns {Promise<void>}
|
|
664
673
|
*/
|
|
665
|
-
async function dispatchEntry(entry, entryLineNum) {
|
|
674
|
+
async function dispatchEntry(entry, entryLineNum, processor = null) {
|
|
666
675
|
try {
|
|
667
676
|
// Both processors return the `action` string they emitted (every one of
|
|
668
677
|
// their exit paths is an `emitAction` call), so the tally needs no second
|
|
669
678
|
// census of the 18 call sites in process.mjs — a census that would go
|
|
670
679
|
// stale the first time a branch is added.
|
|
671
|
-
const action =
|
|
672
|
-
|
|
680
|
+
const action = processor
|
|
681
|
+
? await processor(entry, entryLineNum, ctx)
|
|
682
|
+
: kind === 'learning'
|
|
673
683
|
? await processLearning(entry, entryLineNum, ctx)
|
|
674
684
|
: await processSession(entry, entryLineNum, ctx);
|
|
675
685
|
tally(action);
|
|
@@ -776,9 +786,39 @@ async function main() {
|
|
|
776
786
|
continue;
|
|
777
787
|
}
|
|
778
788
|
|
|
789
|
+
for (const slug of learningNoteSlugs(entry)) ctx.liveLearningSlugs.add(slug);
|
|
779
790
|
await dispatchEntry(entry, lineNum);
|
|
780
791
|
}
|
|
781
792
|
|
|
793
|
+
// #1513 ARCHIVE PASS (`--kind learning` only): records /evolve or the expiry
|
|
794
|
+
// sweep moved out of `learnings.jsonl` live on in the sibling
|
|
795
|
+
// `learnings-archive.jsonl`. Their vault notes used to stay `status: draft`
|
|
796
|
+
// forever, because the mirror only ever saw the live store. Each archived
|
|
797
|
+
// record now marks its existing note archived (never creates one). Counted
|
|
798
|
+
// into `total` like any other line, so the run partition still holds. A
|
|
799
|
+
// malformed archive line is reported and skipped — the archive is secondary
|
|
800
|
+
// input and must not abort a mirror run that already wrote the live store.
|
|
801
|
+
if (kind === 'learning' && basename(resolve(source)) === 'learnings.jsonl') {
|
|
802
|
+
const archivePath = join(dirname(resolve(source)), 'learnings-archive.jsonl');
|
|
803
|
+
if (existsSync(archivePath)) {
|
|
804
|
+
const archiveLines = readFileSync(archivePath, 'utf8').split('\n');
|
|
805
|
+
for (let i = 0; i < archiveLines.length; i += 1) {
|
|
806
|
+
const trimmed = archiveLines[i].trim();
|
|
807
|
+
if (!trimmed) continue;
|
|
808
|
+
let archived;
|
|
809
|
+
try {
|
|
810
|
+
archived = JSON.parse(trimmed);
|
|
811
|
+
} catch {
|
|
812
|
+
process.stderr.write(`WARN vault-mirror: malformed JSON on learnings-archive.jsonl line ${i + 1} — skipped\n`);
|
|
813
|
+
continue;
|
|
814
|
+
}
|
|
815
|
+
if (archived === null || typeof archived !== 'object') continue;
|
|
816
|
+
runState.total++;
|
|
817
|
+
await dispatchEntry(archived, i + 1, processArchivedLearning);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
782
822
|
if (kind === 'session') {
|
|
783
823
|
// Only an OBJECT entry carrying a non-empty `session_id` is eligible for
|
|
784
824
|
// the dedup collapse — the same predicate canonicalizeSessions itself uses
|
|
@@ -803,6 +843,30 @@ async function main() {
|
|
|
803
843
|
// never a guess (scripts/lib/sessions-canonical.mjs header, "RULE ORDER").
|
|
804
844
|
const survivors = new Set(canonicalizeSessions(identifiable));
|
|
805
845
|
|
|
846
|
+
// #1513 review H2: "newest wins" must not cost a session its narrative. A
|
|
847
|
+
// re-appended record often carries only the metrics; the free text sits on
|
|
848
|
+
// the line it supersedes (vault ledger: 1.172 chars on line 101, none on the
|
|
849
|
+
// winning line 108). The winner inherits every narrative field it LACKS
|
|
850
|
+
// from the losers, latest loser first — its own fields always win.
|
|
851
|
+
const inherited = new Map();
|
|
852
|
+
for (const e of sessionEntries) {
|
|
853
|
+
if (!isIdentifiable(e) || survivors.has(e)) continue;
|
|
854
|
+
const carry = inherited.get(e.session_id) ?? {};
|
|
855
|
+
for (const field of SESSION_NARRATIVE_FIELDS) {
|
|
856
|
+
if (e[field] !== undefined && e[field] !== null && e[field] !== '') carry[field] = e[field];
|
|
857
|
+
}
|
|
858
|
+
inherited.set(e.session_id, carry);
|
|
859
|
+
}
|
|
860
|
+
const withNarrative = (e) => {
|
|
861
|
+
const carry = isIdentifiable(e) ? inherited.get(e.session_id) : undefined;
|
|
862
|
+
if (!carry) return e;
|
|
863
|
+
const merged = { ...e };
|
|
864
|
+
for (const [field, value] of Object.entries(carry)) {
|
|
865
|
+
if (merged[field] === undefined || merged[field] === null || merged[field] === '') merged[field] = value;
|
|
866
|
+
}
|
|
867
|
+
return merged;
|
|
868
|
+
};
|
|
869
|
+
|
|
806
870
|
for (let i = 0; i < sessionEntries.length; i++) {
|
|
807
871
|
const entry = sessionEntries[i];
|
|
808
872
|
if (isIdentifiable(entry) && !survivors.has(entry)) {
|
|
@@ -831,7 +895,7 @@ async function main() {
|
|
|
831
895
|
);
|
|
832
896
|
continue;
|
|
833
897
|
}
|
|
834
|
-
await dispatchEntry(entry, sessionLineNums[i]);
|
|
898
|
+
await dispatchEntry(withNarrative(entry), sessionLineNums[i]);
|
|
835
899
|
}
|
|
836
900
|
}
|
|
837
901
|
|
|
@@ -863,7 +927,8 @@ async function main() {
|
|
|
863
927
|
autoCommitVaultMirror(
|
|
864
928
|
resolve(vaultDir),
|
|
865
929
|
sessionIdArg,
|
|
866
|
-
|
|
930
|
+
// #1513 review M2: the folder the processors actually wrote into.
|
|
931
|
+
canonicalNamespace(resolve(vaultDir), resolveRepoNamespace({ vaultName, repoRoot: ctx.repoRoot })),
|
|
867
932
|
);
|
|
868
933
|
}
|
|
869
934
|
}
|
|
@@ -113,7 +113,7 @@ In-place beside a peer is survivable when the two scopes are DECLARED to each ot
|
|
|
113
113
|
Four steps, in order. Steps 1 and 2 are the protocol; 3 and 4 are what keeps it honest.
|
|
114
114
|
|
|
115
115
|
1. **Declare the COMPLETE path list.** The arriving session sends the peer every path it will write — including the ones a script produces (fixtures, snapshots, result files, temp helpers), not only the ones it plans to edit by hand. A path omitted here is a path the peer's guard reports as a violation. In the same message it adopts the peer's resource rules.
|
|
116
|
-
2. **The peer unions.** The receiving coordinator adds those paths to its `allowedPaths` as ONE record `peer-session-<id>` in the wave's scope manifest, re-asserts subset/disjointness, and re-materializes them on every wave rollover (`skills/wave-executor/wave-loop.md` § Scope Manifest).
|
|
116
|
+
2. **The peer unions.** The receiving coordinator adds those paths to its `allowedPaths` as ONE record `peer-session-<id>` in the wave's scope manifest, re-asserts subset/disjointness, and re-materializes them on every wave rollover (`skills/wave-executor/wave-loop.md` § Scope Manifest). Mid-wave, re-run the FULL sequence of `skills/wave-executor/references/wave-loop-scope-manifest.md` § 3, including § 3.3's skeleton write (`"allowedPaths": []`) before the final manifest: `--union` excludes `peer-session-*` records, so re-materializing the sidecar alone leaves the manifest bytes unchanged, `bash-write-verify` keeps the sidecar bound to the OLD hash, and every agreed peer write then reports as a violation for the rest of the wave (#1504 point 5). The skeleton rewrite changes the manifest hash, which re-binds the new sidecar.
|
|
117
117
|
3. **Probe with ONE real write.** Before dispatching any agent, make one small PLANNED Edit from the declared list. A denial here costs one edit; the same denial found after a fan-out costs the wave.
|
|
118
118
|
4. **Announce before committing.** The arriving session sends its final file list, the peer sequences its own push behind it, and the SHAs come back. The git index is shared (PSA-007) — sequencing it is the only thing that makes two sessions in one checkout committable.
|
|
119
119
|
|
|
@@ -41,6 +41,7 @@ import { isSessionConfigHeading } from '../../scripts/lib/config/section-extract
|
|
|
41
41
|
import { FRONTMATTER_NOT_AT_TOP, parseGlobsFrontmatter } from '../../scripts/lib/rule-loader.mjs';
|
|
42
42
|
import { resolveRepoSpec } from '../../scripts/lib/vcs-repo-spec.mjs';
|
|
43
43
|
import { userInvocableSkills } from '../../scripts/lib/user-invocable-skills.mjs';
|
|
44
|
+
import { learningKeyOf } from '../../scripts/lib/learnings/kebab.mjs';
|
|
44
45
|
|
|
45
46
|
const FORWARD_HEADING_RE =
|
|
46
47
|
/(?:^|\b)(what'?s?\s+next|backlog|open\s+issues?|offene\s+(?:issues?|themen)|todo|next\s+steps?|roadmap)(?:$|\b)/i;
|
|
@@ -1143,9 +1144,9 @@ function main() {
|
|
|
1143
1144
|
// `learning-key` and verify it matches a non-expired entry in
|
|
1144
1145
|
// `.orchestrator/metrics/learnings.jsonl`.
|
|
1145
1146
|
//
|
|
1146
|
-
// Key derivation
|
|
1147
|
-
//
|
|
1148
|
-
//
|
|
1147
|
+
// Key derivation is the shared `learningKeyOf` (scripts/lib/learnings/kebab.mjs):
|
|
1148
|
+
// `${type.trim()}/${kebab(title || subject)}`, blank title/subject skipped,
|
|
1149
|
+
// null for an empty type or slug — so a stamped key matches its own learning.
|
|
1149
1150
|
//
|
|
1150
1151
|
// WARN (never error) when:
|
|
1151
1152
|
// - NEITHER a learnings.jsonl entry NOR a valid `evidence-digest` frontmatter
|
|
@@ -1216,13 +1217,6 @@ function main() {
|
|
|
1216
1217
|
// copies are pinned equal by `tests/lib/reconcile/renderer.test.mjs`.
|
|
1217
1218
|
const EVIDENCE_DIGEST_RE = /^sha256-v1:[0-9a-f]{64}$/;
|
|
1218
1219
|
|
|
1219
|
-
// Slugify function mirroring emitter.mjs `kebab()`.
|
|
1220
|
-
const kebab = (s) =>
|
|
1221
|
-
String(s)
|
|
1222
|
-
.toLowerCase()
|
|
1223
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
1224
|
-
.replace(/^-+|-+$/g, '');
|
|
1225
|
-
|
|
1226
1220
|
// Build the set of known learning keys from learnings.jsonl.
|
|
1227
1221
|
// key → expires_at (ISO string or null)
|
|
1228
1222
|
const knownKeys = new Map();
|
|
@@ -1239,12 +1233,8 @@ function main() {
|
|
|
1239
1233
|
let entry;
|
|
1240
1234
|
try { entry = JSON.parse(trimmed); } catch { continue; }
|
|
1241
1235
|
if (!entry || typeof entry !== 'object') continue;
|
|
1242
|
-
const
|
|
1243
|
-
|
|
1244
|
-
(typeof entry.title === 'string' && entry.title !== '' ? entry.title : '') ||
|
|
1245
|
-
(typeof entry.subject === 'string' && entry.subject !== '' ? entry.subject : '');
|
|
1246
|
-
if (!type || !subjectOrTitle) continue;
|
|
1247
|
-
const derivedKey = `${type}/${kebab(subjectOrTitle)}`;
|
|
1236
|
+
const derivedKey = learningKeyOf(entry);
|
|
1237
|
+
if (!derivedKey) continue;
|
|
1248
1238
|
// Store the most-recent expires_at (later entries overwrite earlier ones
|
|
1249
1239
|
// with the same key — safe; duplicates are rare and same-key learnings
|
|
1250
1240
|
// share the same expiry semantics).
|
|
@@ -59,7 +59,10 @@ One JSON line is written to stdout for each non-empty JSONL entry processed. Exi
|
|
|
59
59
|
| `skipped-collision-resolved` | A file at the target path has the generator marker but a different `id`; a disambiguated slug was used instead. |
|
|
60
60
|
| `skipped-foreign-owner` | A generator note at the target path was rendered from another record (#1503), and this write must not replace it. **Sessions:** its `source-record` differs from this write and occurs nowhere in this `--source` ledger (`reason: "source-record mismatch"`), or its `id` names a different session (`reason: "id mismatch"`). **Learnings:** only for the disambiguated `<slug>-<uuid8>.md` file — a mismatch on the main `<slug>.md` is routed to disambiguation instead (`skipped-collision-resolved`, see Idempotency rule 6). One `SKIP foreign owner` line goes to stderr; `--force` does not bypass it. A leak in the foreign note is masked in place — owner, content and `source-record` kept — and the line carries `healed_leak: true`. A note without `source-record` (written before #1503) stays writable. |
|
|
61
61
|
| `skipped-invalid` | Entry is missing one or more required fields; entry skipped, processing continues. |
|
|
62
|
-
| `skipped-quality-low` |
|
|
62
|
+
| `skipped-quality-low` | Learning `confidence` below `vault-mirror.quality.min-confidence` (CLI: `--quality-min-confidence`, default `0.5`), or a metrics-only session whose note ALREADY exists (left untouched; the pruner decides). Carries `reason` and `path: null`. Runs **before** `--force`; `--force` does not bypass it. |
|
|
63
|
+
| `skipped-metrics-only` | Session record below the narrative gate (#1513, see Retention): no note; one row was added to the month rollup `50-sessions/<repo>/_rollup-YYYY-MM.md` (`path`). An unchanged rollup is `skipped-noop`. |
|
|
64
|
+
| `skipped-expired` | Learning past its `expires_at` with no note yet (#1513): nothing is created. |
|
|
65
|
+
| `skipped-duplicate-insight` | A mirror-owned, non-archived note in the same namespace (or the flat layout) already carries the same normalised insight (#1513); `path` names it. Only consulted where a NEW file would be created. |
|
|
63
66
|
|
|
64
67
|
### Output line shape
|
|
65
68
|
|
|
@@ -92,10 +95,32 @@ The numeric prefix (`50-sessions/`, `40-learnings/`) follows the vault folder or
|
|
|
92
95
|
|
|
93
96
|
**Path contract note:** Issue #187 shipped the flat numeric-prefix layout (deferring the per-`<repo>` subfolder it sketched). Issue #660 adds that per-repo subfolder for write-isolation (above) while keeping the numeric-prefix ordering. If you need a different layout, file a new issue — do NOT silently change the script.
|
|
94
97
|
|
|
95
|
-
**Learning-coverage self-healing after a store recovery (#740).** vault-mirror always mirrors the CURRENT `learnings.jsonl` store contents at run time — if an earlier session's store was truncated or corrupted and later recovered (e.g. from a `.bak`), the resulting transient coverage gap in the vault is closed automatically by the next mirror run, with no manual backfill required. Note also that `vault-mirror.quality.min-narrative-chars` (see
|
|
98
|
+
**Learning-coverage self-healing after a store recovery (#740).** vault-mirror always mirrors the CURRENT `learnings.jsonl` store contents at run time — if an earlier session's store was truncated or corrupted and later recovered (e.g. from a `.bak`), the resulting transient coverage gap in the vault is closed automatically by the next mirror run, with no manual backfill required. Note also that `vault-mirror.quality.min-narrative-chars` (see Retention below) gates **sessions only** — `processLearning` never applies a narrative-length filter, so a short learning `insight` is never skipped for length; only the confidence gate (`vault-integration.quality.min-confidence`) applies to learnings.
|
|
96
99
|
|
|
97
100
|
**Host-local pseudonym mapping (Epic #725 D5).** Without a map, EVERY owner-leaky repo namespace collapses to a single `redacted-repo/` subdir — which breaks the #660 write-isolation (N private repos share one directory → id/slug collisions) and destroys cross-repo attribution. A host-local **pseudonym map** fixes this: each real repo slug is assigned a stable, non-leaky pseudonym, so the vault gets `40-learnings/<pseudonym>/` (and matching `source-repo` frontmatter, via W2's attribution threading) instead of everyone landing in `redacted-repo/`. The map is a JSON object `{ "real-slug": "pseudonym-slug", … }` at a host-local path referenced by `owner.yaml` → `paths.namespace-map-path` (env `SO_NAMESPACE_MAP` overrides; precedence env > owner.yaml > unset). `resolveRepoNamespace()` consults the map ONLY at the redaction site (i.e. only when a repo would otherwise be redacted), looking up both the sanitised segment and the raw identifier; a hit returns the pseudonym and skips redaction, so clean/public repos never touch the map (or its lazy owner.yaml read). **Privacy guarantee:** the map file lives OUTSIDE every repo (alongside `owner.yaml` under `~/.config/session-orchestrator/`) and is never committed — only the pseudonyms reach the vault, the real names stay host-local. Each pseudonym is validated (must be a kebab slug AND must not itself be owner-leaky); invalid or leaky pseudonyms are dropped with a WARN. **Fallback:** an absent, unreadable, or malformed map — and any UNMAPPED owner-leaky repo — still redacts to `redacted-repo`, identical to pre-#725 behaviour. Implementation: `scripts/lib/vault-mirror/pseudonym-map.mjs` (`loadPseudonymMap`) + `namespace.mjs`.
|
|
98
101
|
|
|
102
|
+
## Retention (#1513)
|
|
103
|
+
|
|
104
|
+
The mirror used to write every record and never mark or remove anything; by 2026-10-03 the two zones were 78 % of the Meta-Vault, 91 % of the session notes carried nothing beyond git, and 2.826 expired learnings still read `status: draft`. The rules live in ONE module, `scripts/lib/vault-mirror/retention.mjs`, shared by the mirror and the pruner:
|
|
105
|
+
|
|
106
|
+
1. **Narrative gate (sessions).** A record gets its own note only when its free-text fields `notes`, `narrative`, `summary` (strings or string arrays, whitespace collapsed) reach `vault-mirror.quality.min-narrative-chars` (default `400`; `0` disables). Wave/agent/file tables are not narrative. All three renderer generations print exactly these fields into `## Notes` (`sessionNarrativeText`), which is what the pruner measures; an existing note with less free text than its record is rewritten once (heal), and when a ledger holds the same `session_id` twice the winning record inherits the narrative fields it lacks from the line it supersedes. Below the gate the record becomes one row (date, session id, type, waves, agents, files) in `50-sessions/<repo>/_rollup-YYYY-MM.md`, keyed by session id, sorted and rendered deterministically — re-runs are no-ops. Calibration (Jev audit, 3.003 notes): free text under 300 chars held 1 of 2.802 notes scored >= 2.5 "beyond git"; from 800 chars on, 18 of 55.
|
|
107
|
+
2. **Legacy slug (learnings).** The pre-#725 slug (subject without the whitespace → hyphen pre-map) is recognised: when only that variant of THIS record's note exists, it is updated in place instead of a canonical twin being created. New notes always get the canonical slug.
|
|
108
|
+
3. **Write-time dedupe (learnings).** Before a new file is created, the normalised `## Insight` (case, whitespace, typographic quotes/dashes, trailing punctuation ignored) is looked up among the mirror-owned, non-archived notes of the namespace, and among flat notes whose `source-repo` is this namespace → `skipped-duplicate-insight`. Placeholder insights (`(legacy record …`, `(none recorded)`, …) and keys under 40 characters never count as the same learning.
|
|
109
|
+
5. **Namespace alias.** Folders equal modulo hyphens are one namespace; the one with more hyphens is canonical (`gotzendorferv2` → `gotzendorfer-v2`). The mirror resolves this before every write and for its auto-commit scope; the pruner uses the same function (`canonicalAmong`).
|
|
110
|
+
4. **Expiry and leaving the store (learnings).** An expired record (`expires_at` before today) is never created; its existing note is re-rendered with `status: archived` + `archived-reason: expired`. After the live pass, `--kind learning` also reads the sibling `learnings-archive.jsonl` and marks each archived record's existing note `archived` with `archived-reason: expired` (`_archive_reason: expired`) or `superseded` (every other reason) — never creating one, never touching a note a live record still owns. `archived` + `archived-reason` instead of `status: expired|superseded` on purpose: the vault frontmatter status enum (SSOT projects-baseline) has no such values, and a status outside it fails vault-sync in any vault that validates the mirror zones.
|
|
111
|
+
|
|
112
|
+
**Bestand: `scripts/vault-mirror-prune.mjs`.** Applies the same rules to notes written before them. Dry run is the default and writes nothing; `--manifest PATH` writes the JSON manifest (`path`, `reason`, `action`, `target`, `keep`, `rollup`). Expiry is decided first, so an expired or archived copy is never the one kept. Reasons: `duplicate` (canonical copy among the LIVE copies: own namespace folder > any namespace folder > `status: verified` > later `expires` > `source-record` > post-#725 slug > newer `updated`), `superseded` (same slug modulo hyphens, different insight; newest kept), `expired`, `metrics-only-session` (its rollup row is written first), `namespace-alias` (a folder that equals another modulo hyphens and has fewer of them, or `--alias FROM=TO`), `flat-relocate` (flat note whose `source-repo` names its namespace). `--apply` writes the rollups, then moves archived notes to `90-archive/mirror/<zone>/<same path>` with `status: archived` + `archived-reason`, then renames the relocated ones — never deletes, never touches a note without the `_generator` marker, re-checks ownership right before each move, refuses any source or target that resolves outside the vault (realpath of the nearest existing ancestor), writes a note whose archive target is occupied by OTHER content beside it as `<name>.dup-<hash8>.md` (listed under `applied.suffixed` in the manifest), and is idempotent (a second run plans nothing). `90-archive/` is outside vault-sync's checked zones but inside its link-target register, so wiki-links into archived notes keep resolving; the mirror's source-session index walks `90-archive/mirror/50-sessions/` for the same reason, and `backfill-learnings-from-vault.mjs` falls back to `90-archive/mirror/40-learnings/`.
|
|
113
|
+
|
|
114
|
+
**Order (runbook).** (1) Update the plugin on EVERY host that runs session-end against this vault (M4 and M5): an old mirror re-creates every note the pruner moved. (2) Run one mirror pass per repo ledger so session notes written before the narrative fields were rendered are healed — otherwise the pruner reads them as metrics-only. (3) Dry run, read the manifest. (4) `--apply`, then commit the vault.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
# (2) per repo, from its canonical checkout
|
|
118
|
+
node scripts/vault-mirror.mjs --vault-dir <vault> --source <repo>/.orchestrator/metrics/sessions.jsonl --kind session --repo-root <repo> --no-commit
|
|
119
|
+
# (3) + (4)
|
|
120
|
+
node scripts/vault-mirror-prune.mjs --vault-dir <vault> --manifest /tmp/vault-prune-manifest.json # plan
|
|
121
|
+
node scripts/vault-mirror-prune.mjs --vault-dir <vault> --apply --manifest /tmp/vault-prune-applied.json
|
|
122
|
+
```
|
|
123
|
+
|
|
99
124
|
## Idempotency
|
|
100
125
|
|
|
101
126
|
vault-mirror is safe to run multiple times against the same JSONL source:
|
|
@@ -100,7 +100,7 @@ Before each wave dispatch:
|
|
|
100
100
|
--assert-disjoint "$WAVE_SCOPES_SIDECAR" < <state-dir>/wave-scope.json
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
-
Exit 1 (one stderr message per collision) means two agents were handed the same file: fix the session plan, re-materialize, re-assert. Never widen the union to make it pass. This runs **before** 3.3 because a union computed over colliding scopes launders the defect into the very artefact meant to prevent it — `allowedPaths` then grants the file and every later gate sees a legal write.
|
|
103
|
+
Exit 1 (one stderr message per collision) means two agents were handed the same file: fix the session plan, re-materialize, re-assert. Where no manifest exists yet (`/plan` checking a draft), pass `--no-manifest` instead of piping a fabricated one — it checks the sidecar alone, and is rejected together with `--union` / `--assert-subset` (#1510 f). Never widen the union to make it pass. This runs **before** 3.3 because a union computed over colliding scopes launders the defect into the very artefact meant to prevent it — `allowedPaths` then grants the file and every later gate sees a legal write.
|
|
104
104
|
|
|
105
105
|
**3.3 — compute the union.** `--union` is a QUERY MODE that still requires a schema-valid manifest on stdin, so write the skeleton first with `"allowedPaths": []`, then:
|
|
106
106
|
|
|
@@ -125,6 +125,8 @@ Before each wave dispatch:
|
|
|
125
125
|
|
|
126
126
|
> Measured at `HEAD=730ee9d`, 2026-08-03, clean-tree, via `git ls-files | grep -E '^(scripts|hooks|skills)/.*\.mjs$'` for the denominator, matched against `git ls-files | grep -E '^tests/.*\.test\.mjs$'` by basename (85.4% figure) and by mirrored path (62.0% figure). Re-measure before citing these downstream — a count re-briefed later is a claim about the past (`.claude/rules/parallel-sessions.md` § PSA-006).
|
|
127
127
|
|
|
128
|
+
> **Over-grant ceiling (#1030):** the glob is a basename *prefix* (`tests/**/{basename}*.test.mjs`, `scripts/lib/scope-gate.mjs:312`), so a production file sharing a basename with another receives the other's test siblings — and a stem that prefixes another (`cli` → `cli-flags`) does too, so the figure below is a lower bound. Measured at `HEAD=0e0c4b08`, 2026-10-03: `git ls-files '*.mjs' | grep -v '^tests/' | xargs -n1 basename | sort | uniq -d | wc -l` → **34** colliding basenames, covering **80 of 572** production `.mjs` (**14.0 %**; files via `… | sort | uniq -D | wc -l`, denominator via `git ls-files '*.mjs' | grep -v '^tests/' | wc -l`; top: `schema.mjs` 8×). The error direction is the harmless one — a too-wide grant, never a denied write. **Revisit-Trigger:** a plan author disables the sibling expansion to dodge a false-positive collision between two same-named production files — then scope the glob by directory, but NOT by a raw `{dir}`: `renderSiblingTemplate` fills it with `scripts/lib`, while the test tree drops the `scripts/` prefix (`tests/lib/`), so a raw `{dir}` would deny real test writes; it needs a source→test prefix mapping first.
|
|
129
|
+
|
|
128
130
|
**The sibling rule is repo-configurable, not a hardcoded layout.** THIS repo has zero `__tests__/` directories and no co-located tests; consumer-repo shapes (`<file>.test.*` beside the source, `<dir>/__tests__/**`, `supabase/migrations/** → supabase/tests/**`) are configured per repo and do not apply here.
|
|
129
131
|
|
|
130
132
|
Three ordering constraints, all load-bearing:
|