session-orchestrator 5.11.0 → 5.12.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.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +19 -0
  6. package/README.md +22 -12
  7. package/commands/session.md +3 -1
  8. package/docs/install.md +12 -8
  9. package/docs/session-config-reference.md +6 -6
  10. package/docs/session-config-template.md +2 -2
  11. package/hooks/hooks-codex.json +1 -1
  12. package/hooks/hooks.json +1 -1
  13. package/hooks/on-session-start.mjs +19 -10
  14. package/package.json +1 -2
  15. package/scripts/fleet-checkin.mjs +61 -3
  16. package/scripts/lib/config/remote-hosts.mjs +2 -2
  17. package/scripts/lib/config.mjs +1 -1
  18. package/scripts/lib/fleet-protocol.mjs +17 -0
  19. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  20. package/scripts/lib/session-invocation.mjs +14 -3
  21. package/scripts/lib/session-registry.mjs +14 -4
  22. package/scripts/lib/so-hud/collect.mjs +130 -0
  23. package/scripts/lib/so-hud/runtime.mjs +76 -0
  24. package/scripts/lib/state-md/idle-reset.mjs +239 -0
  25. package/scripts/lib/state-md/recommendations.mjs +26 -4
  26. package/scripts/lib/vault-mirror/namespace.mjs +3 -2
  27. package/scripts/lib/vault-mirror/process.mjs +143 -40
  28. package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
  29. package/scripts/lib/vault-mirror/utils.mjs +27 -13
  30. package/scripts/lib/vault-status/board-writer.mjs +32 -4
  31. package/scripts/resolve-session-invocation.mjs +64 -8
  32. package/scripts/so-hud.mjs +45 -0
  33. package/scripts/state-md-idle-reset.mjs +81 -0
  34. package/scripts/vault-orphans.mjs +171 -0
  35. package/skills/_shared/fleet-protocol.md +24 -2
  36. package/skills/_shared/state-ownership.md +3 -3
  37. package/skills/autopilot/SKILL.md +3 -2
  38. package/skills/session-end/SKILL.md +3 -2
  39. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  40. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  41. package/skills/session-end/references/session-summary-template.md +4 -0
  42. package/skills/session-start/SKILL.md +1 -1
  43. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  44. package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
  45. package/AGENTS.md +0 -207
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Idle Reset for STATE.md (issue #1560) — the code form of session-start
3
+ * Phase 1.5 § "Idle Reset" (skills/session-start/references/phase-1-5-session-continuity.md).
4
+ *
5
+ * Until #1560 the reset existed only as prose and every coordinator rewrote
6
+ * STATE.md by hand. Its Rule 6 deleted the five recommendation keys from the
7
+ * frontmatter, which is exactly what Phase 7.5's `buildLiveSignals()` reads —
8
+ * so after every clean `/close` the Mode-Selector saw no recommendation and
9
+ * fell back to `feature` at confidence 0. The keys now STAY in the frontmatter:
10
+ * they are the session-to-session handoff, and session-end Phase 3.7a (their
11
+ * sole writer) overwrites them at the next close.
12
+ *
13
+ * Deliberately NOT re-exported from `scripts/lib/state-md.mjs`: that barrel is
14
+ * part of the hook import closure (`hooks/_lib/hook-import-set.json`), and no
15
+ * hook needs the reset. The CLI `scripts/state-md-idle-reset.mjs` imports this
16
+ * module directly.
17
+ */
18
+
19
+ import { parseStateMd, serializeStateMd } from './yaml-parser.mjs';
20
+ import { existsSync } from 'node:fs';
21
+ import { resolveStateMdPath, updateFrontmatterFields, writeStateMd } from './frontmatter-mutators.mjs';
22
+ import { parseRecommendations } from './recommendations.mjs';
23
+
24
+ /** Rule 7: the drift-baseline keys that must not survive into session N+1. */
25
+ const SCOPE_BASELINE_KEYS = [
26
+ 'scope-baseline-intent',
27
+ 'scope-baseline-owner-boundary',
28
+ 'scope-baseline-planned-files',
29
+ 'scope-baseline-session',
30
+ 'scope-baseline-frozen-at',
31
+ ];
32
+
33
+ /** A level-2 heading (`## X`), never a deeper one (`### X`). */
34
+ const H2_RE = /^##(?!#)\s/;
35
+
36
+ /** Rule 6: heading of the human-readable recommendations copy. */
37
+ const RECOMMENDATIONS_HEADING = '### Recommendations (archived from v1.1 frontmatter)';
38
+
39
+ /**
40
+ * Splits a STATE.md body into the text before the first level-2 heading and
41
+ * one record per level-2 section. Every record keeps its raw lines (heading
42
+ * included, up to the next level-2 heading), so re-joining an untouched
43
+ * section is byte-identical.
44
+ *
45
+ * KNOWN CEILING: a `## ` line inside a fenced code block is read as a heading,
46
+ * the same limitation every helper in body-sections.mjs shares. No STATE.md
47
+ * section observed so far carries fenced code.
48
+ *
49
+ * @param {string} body
50
+ * @returns {{preamble: string[], sections: Array<{heading: string, lines: string[]}>}}
51
+ */
52
+ function splitSections(body) {
53
+ const preamble = [];
54
+ const sections = [];
55
+ for (const line of body.split('\n')) {
56
+ if (H2_RE.test(line)) sections.push({ heading: line, lines: [line] });
57
+ else if (sections.length === 0) preamble.push(line);
58
+ else sections[sections.length - 1].lines.push(line);
59
+ }
60
+ return { preamble, sections };
61
+ }
62
+
63
+ /** @param {string} heading @param {string} title */
64
+ function isSection(heading, title) {
65
+ // The title must END there (or open a parenthesis): `## Wave History Archive`
66
+ // is a different section.
67
+ return new RegExp(`^##\\s+${title}(?:\\s*\\(|\\s*$)`).test(heading);
68
+ }
69
+
70
+ /** Drops leading and trailing blank lines. */
71
+ function trimBlankLines(lines) {
72
+ let start = 0;
73
+ let end = lines.length;
74
+ while (start < end && lines[start].trim() === '') start++;
75
+ while (end > start && lines[end - 1].trim() === '') end--;
76
+ return lines.slice(start, end);
77
+ }
78
+
79
+ /** @param {number} x a ratio in 0..1 */
80
+ function pct(x) {
81
+ return `${Math.round(x * 100)}%`;
82
+ }
83
+
84
+ /**
85
+ * Rule 6: the human-readable copy of the recommendation fields. Null fields
86
+ * are omitted; `null` overall when there is nothing to show.
87
+ *
88
+ * @param {object} frontmatter
89
+ * @returns {string|null}
90
+ */
91
+ function renderRecommendationsBlock(frontmatter) {
92
+ const rec = parseRecommendations(frontmatter);
93
+ if (rec === null) return null;
94
+ const bullets = [];
95
+ if (rec.mode !== null) bullets.push(`- **Recommended mode:** ${rec.mode}`);
96
+ if (rec.rationale !== null) bullets.push(`- **Rationale:** ${rec.rationale}`);
97
+ if (rec.completionRate !== null) bullets.push(`- **Completion rate:** ${pct(rec.completionRate)}`);
98
+ if (rec.carryoverRatio !== null) bullets.push(`- **Carryover ratio:** ${pct(rec.carryoverRatio)}`);
99
+ if (rec.priorities !== null) {
100
+ const list = rec.priorities.length > 0 ? rec.priorities.map((id) => `#${id}`).join(', ') : 'none';
101
+ bullets.push(`- **Top priorities:** ${list}`);
102
+ }
103
+ if (bullets.length === 0) return null;
104
+ return [RECOMMENDATIONS_HEADING, ...bullets].join('\n');
105
+ }
106
+
107
+ /**
108
+ * Removes one `### <heading>` subsection (up to the next `### ` or `## ` line).
109
+ *
110
+ * @param {string[]} lines
111
+ * @param {string} heading the full `### …` line
112
+ * @returns {string[]}
113
+ */
114
+ function dropSubsection(lines, heading) {
115
+ const out = [];
116
+ let skipping = false;
117
+ for (const line of lines) {
118
+ if (line.trimEnd() === heading) skipping = true;
119
+ else if (skipping && /^#{2,3}\s/.test(line)) skipping = false;
120
+ if (!skipping) out.push(line);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Resets a `status: completed` STATE.md to idle (Phase 1.5 § Idle Reset,
127
+ * Rules 1-7). Pure: string in, result out, no file I/O.
128
+ *
129
+ * 1. `status: idle`. 2. `current-wave: 0`. 3. Every `## Wave History` body moves
130
+ * into a new `## Previous Session` section (appended at the end of the body,
131
+ * replacing any earlier one) under `### Wave History (<session>)`; the original
132
+ * section is removed. 4. `## Deviations` keeps its heading with an empty body;
133
+ * `## What Not To Retry` and `## Open Questions` stay byte-for-byte. 5. Other
134
+ * frontmatter keys are untouched. 6. The five recommendation keys STAY in the
135
+ * frontmatter (Phase 7.5 reads them); a readable copy heads `## Previous Session`.
136
+ * 7. The five `scope-baseline-*` keys are deleted, no other key.
137
+ *
138
+ * When neither a Wave History section nor a recommendation exists, an existing
139
+ * `## Previous Session` is left as it is — there is nothing to replace it with.
140
+ * With recommendations but no Wave History (a coordinator-direct session), the
141
+ * earlier archive body stays below the new recommendations block.
142
+ *
143
+ * @param {string} contents STATE.md contents
144
+ * @returns {{changed: boolean, contents: string, reason?: 'status-not-completed'|'unparseable'}}
145
+ */
146
+ export function resetStateMdToIdle(contents) {
147
+ const parsed = parseStateMd(contents);
148
+ if (parsed === null) return { changed: false, contents, reason: 'unparseable' };
149
+ if (parsed.frontmatter.status !== 'completed') {
150
+ return { changed: false, contents, reason: 'status-not-completed' };
151
+ }
152
+
153
+ const { preamble, sections } = splitSections(parsed.body);
154
+ const waveHistory = sections.filter((s) => isSection(s.heading, 'Wave History'));
155
+ const recBlock = renderRecommendationsBlock(parsed.frontmatter);
156
+ const archive = waveHistory.length > 0 || recBlock !== null;
157
+ // Rule 6 PREPENDS: without a new Wave History, the earlier archive stays below
158
+ // the fresh recommendations block (minus its own, now superseded, copy).
159
+ const keptArchive =
160
+ waveHistory.length === 0
161
+ ? trimBlankLines(
162
+ dropSubsection(
163
+ sections.filter((s) => isSection(s.heading, 'Previous Session')).flatMap((s) => s.lines.slice(1)),
164
+ RECOMMENDATIONS_HEADING,
165
+ ),
166
+ )
167
+ : [];
168
+
169
+ const lines = [...preamble];
170
+ for (const { heading, lines: own } of sections) {
171
+ if (isSection(heading, 'Wave History')) continue;
172
+ if (archive && isSection(heading, 'Previous Session')) continue;
173
+ lines.push(...(isSection(heading, 'Deviations') ? [heading, ''] : own));
174
+ }
175
+
176
+ let body = lines.join('\n');
177
+ if (archive) {
178
+ const blocks = [];
179
+ if (recBlock !== null) blocks.push(recBlock);
180
+ if (waveHistory.length > 0) {
181
+ const session = typeof parsed.frontmatter.session === 'string' ? parsed.frontmatter.session : '';
182
+ const heading = session ? `### Wave History (${session})` : '### Wave History';
183
+ const moved = trimBlankLines(waveHistory.flatMap((s) => s.lines.slice(1)));
184
+ blocks.push([heading, ...moved].join('\n'));
185
+ }
186
+ if (keptArchive.length > 0) blocks.push(keptArchive.join('\n'));
187
+ // Only ever ADD newlines before the new section — trimming would change the
188
+ // trailing bytes of whatever section now precedes it (possibly WNTR).
189
+ if (body.length > 0 && !body.endsWith('\n\n')) body += body.endsWith('\n') ? '\n' : '\n\n';
190
+ body += `## Previous Session\n\n${blocks.join('\n\n')}\n`;
191
+ }
192
+
193
+ const fields = { status: 'idle', 'current-wave': 0 };
194
+ for (const key of SCOPE_BASELINE_KEYS) fields[key] = null;
195
+ const next = updateFrontmatterFields(
196
+ serializeStateMd({ frontmatter: parsed.frontmatter, body }),
197
+ fields,
198
+ );
199
+ return { changed: next !== contents, contents: next };
200
+ }
201
+
202
+ /**
203
+ * Applies `resetStateMdToIdle` to the repo's STATE.md under the STATE.md lock
204
+ * (`writeStateMd`). A missing or empty STATE.md is a no-op with reason `missing`.
205
+ * A write the size-ceiling / frontmatter-safe guards refuse comes back as
206
+ * `changed: false` with that guard's reason.
207
+ *
208
+ * @param {string} repoRoot absolute repository root (required)
209
+ * @param {object} [opts] passed through to `writeStateMd`
210
+ * @returns {Promise<{changed: boolean, reason?: string, path: string}>}
211
+ * @throws {Error} when repoRoot is missing
212
+ */
213
+ export async function resetStateMdToIdleOnDisk(repoRoot, opts = {}) {
214
+ if (!repoRoot) {
215
+ throw new Error(`resetStateMdToIdleOnDisk: repoRoot is required (got ${typeof repoRoot}).`);
216
+ }
217
+ // No STATE.md: answer before the lock, which would otherwise create
218
+ // <repoRoot>/.orchestrator/ in whatever directory a mistyped root names.
219
+ const statePath = resolveStateMdPath(repoRoot);
220
+ if (!existsSync(statePath)) return { changed: false, reason: 'missing', path: statePath };
221
+ let reason;
222
+ const result = await writeStateMd(
223
+ repoRoot,
224
+ (contents) => {
225
+ if (contents === '') {
226
+ reason = 'missing';
227
+ return null;
228
+ }
229
+ const reset = resetStateMdToIdle(contents);
230
+ reason = reset.reason;
231
+ return reset.changed ? reset.contents : null;
232
+ },
233
+ opts,
234
+ );
235
+ const out = { changed: result.written, path: result.path };
236
+ const why = result.reason ?? reason;
237
+ if (why !== undefined) out.reason = why;
238
+ return out;
239
+ }
@@ -37,10 +37,7 @@ export function parseRecommendations(frontmatter) {
37
37
  const mode = typeof frontmatter['recommended-mode'] === 'string'
38
38
  ? frontmatter['recommended-mode']
39
39
  : null;
40
- const priorities = Array.isArray(frontmatter['top-priorities'])
41
- && frontmatter['top-priorities'].every((x) => Number.isInteger(x))
42
- ? frontmatter['top-priorities'].slice()
43
- : null;
40
+ const priorities = parsePriorities(frontmatter['top-priorities']);
44
41
  const carryoverRatio = typeof frontmatter['carryover-ratio'] === 'number'
45
42
  && !Number.isNaN(frontmatter['carryover-ratio'])
46
43
  ? frontmatter['carryover-ratio']
@@ -55,3 +52,28 @@ export function parseRecommendations(frontmatter) {
55
52
 
56
53
  return { mode, priorities, carryoverRatio, completionRate, rationale };
57
54
  }
55
+
56
+ /** An entry that is exactly an issue reference of THIS repo: `42` or `#42`. */
57
+ const ISSUE_REF_RE = /^#?\d+$/;
58
+
59
+ /**
60
+ * Normalises `top-priorities` to issue numbers. Integers pass; strings that are
61
+ * exactly an issue ref (`"#42"`, `"42"`) become integers; every other entry is
62
+ * dropped. A hand-written `"#370 fix banner"` or `"other-repo#74"` is never
63
+ * mined for digits, because the number may belong to another repo. An array
64
+ * that held entries but keeps none is a type mismatch (`null`), as before.
65
+ *
66
+ * @param {unknown} raw
67
+ * @returns {number[]|null}
68
+ */
69
+ function parsePriorities(raw) {
70
+ if (!Array.isArray(raw)) return null;
71
+ const ids = [];
72
+ for (const entry of raw) {
73
+ if (Number.isInteger(entry)) ids.push(entry);
74
+ else if (typeof entry === 'string' && ISSUE_REF_RE.test(entry.trim())) {
75
+ ids.push(Number.parseInt(entry.trim().replace('#', ''), 10));
76
+ }
77
+ }
78
+ return raw.length > 0 && ids.length === 0 ? null : ids;
79
+ }
@@ -55,7 +55,7 @@
55
55
 
56
56
  import { basename, resolve } from 'node:path';
57
57
 
58
- import { subjectToSlug } from './utils.mjs';
58
+ import { legacySubjectToSlug } from './utils.mjs';
59
59
  import { isOwnerLeakySegment } from '../../lib/validate/check-owner-leakage.mjs';
60
60
  import { loadPseudonymMap } from './pseudonym-map.mjs';
61
61
  import { loadHostPaths, resolveHostPath } from '../config/host-paths.mjs';
@@ -311,7 +311,8 @@ export function resolveRepoNamespace({ vaultName = null, repoRoot = null } = {})
311
311
  : (declaredSlug ?? deriveRepo({ repoRoot: root }));
312
312
 
313
313
  // Sanitise: collapse to last path segment, lowercase, strip non-[a-z0-9-].
314
- const seg = subjectToSlug(base);
314
+ // Repository storage identity stays historical; only new note names transliterate.
315
+ const seg = legacySubjectToSlug(base);
315
316
 
316
317
  // Leak-guard: check both the raw base AND the sanitised segment.
317
318
  // A personal home path or a private project slug must be caught before writing
@@ -10,7 +10,7 @@ import { basename, join, resolve, sep } from 'node:path';
10
10
  import { createRequire } from 'node:module';
11
11
  import { randomUUID } from 'node:crypto';
12
12
  import { createSecretValueMasker } from '../secret-masker.mjs';
13
- import { subjectToSlug, isValidSlug, uuidPrefix8, recordKeySuffix8, toDate, parseFrontmatter } from './utils.mjs';
13
+ import { subjectToSlug, legacySubjectToSlug, isValidSlug, uuidPrefix8, recordKeySuffix8, toDate, parseFrontmatter } from './utils.mjs';
14
14
  import { isRealSession } from '../session-schema/filters.mjs';
15
15
  import { resolveRepoNamespace } from './namespace.mjs';
16
16
  import { detectLearningSchema, normalizeLearningEntry, generateLearningNote, generateLearningNoteV2, learningSourceRecord } from './render-learnings.mjs';
@@ -19,7 +19,6 @@ import { emitMirrorEvent } from './telemetry.mjs';
19
19
  import {
20
20
  ARCHIVE_ROOT,
21
21
  canonicalNamespace,
22
- legacyConcatSlug,
23
22
  noteNarrativeChars,
24
23
  markArchived,
25
24
  noteInsightKey,
@@ -904,7 +903,7 @@ function maskEntrySecrets(entry) {
904
903
  * @param {'v1'|'v2'} schema
905
904
  * @returns {string}
906
905
  */
907
- function learningSlug(entry, schema) {
906
+ function learningSlug(entry, schema, slugger = learningNameSlugger(entry)) {
908
907
  const entryId = entry.id;
909
908
  let slugSource;
910
909
  if (schema === 'v2') {
@@ -916,7 +915,7 @@ function learningSlug(entry, schema) {
916
915
  }
917
916
  let slug;
918
917
  if (typeof slugSource === 'string' && slugSource.length > 0) {
919
- slug = subjectToSlug(slugSource);
918
+ slug = slugger(slugSource);
920
919
  } else {
921
920
  slug = '';
922
921
  }
@@ -936,6 +935,52 @@ function learningSlug(entry, schema) {
936
935
  }
937
936
 
938
937
 
938
+ /** A record without representable provenance (v1 or v2) keeps its historical name. */
939
+ function learningNameSlugger(entry) {
940
+ return learningSourceRecord(entry) === null ? legacySubjectToSlug : subjectToSlug;
941
+ }
942
+
943
+ /** Compare BEFORE filename capping so a changed concatenated name is protected. */
944
+ function hasLearningSlugTransition(entry, schema) {
945
+ if (learningNameSlugger(entry) === legacySubjectToSlug) return false;
946
+ const source = schema === 'v2' ? entry.id : entry.subject;
947
+ if (typeof source !== 'string' || source.length === 0) return false;
948
+ const normalized = schema === 'v1' ? source.trim().replace(/\s+/g, '-') : source;
949
+ return subjectToSlug(normalized) !== legacySubjectToSlug(normalized) ||
950
+ (schema === 'v1' && subjectToSlug(source) !== legacySubjectToSlug(source));
951
+ }
952
+
953
+ function historicalConcatSlug(subject) {
954
+ if (typeof subject !== 'string') return '';
955
+ return legacySubjectToSlug(subject);
956
+ }
957
+
958
+ /** All historical names, including both generations of disambiguation suffix. */
959
+ function learningSlugVariants(entry, schema) {
960
+ const bases = new Set([learningSlug(entry, schema), learningSlug(entry, schema, legacySubjectToSlug)]);
961
+ if (schema === 'v1') {
962
+ const concat = historicalConcatSlug(entry.subject);
963
+ if (isValidSlug(concat)) {
964
+ bases.add(concat);
965
+ if (concat.length > 240) bases.add(concat.slice(0, 240).replace(/-+$/, ''));
966
+ }
967
+ }
968
+ const names = new Set(bases);
969
+ const id = String(entry.id);
970
+ for (const base of bases) {
971
+ names.add(`${base}-${uuidPrefix8(id)}`);
972
+ names.add(`${base}-${recordKeySuffix8(learningSourceRecord(entry) ?? id)}`);
973
+ }
974
+ return [...names];
975
+ }
976
+
977
+ /** Only explicit, exact provenance permits assigning an old name to a record. */
978
+ function positiveLearningOwner(fm, name, repoNs, recordKey) {
979
+ return recordKey !== null && notOurGenerator(fm) === null &&
980
+ fm['id'] === name && fm['source-record'] === recordKey && fm['source-repo'] === repoNs;
981
+ }
982
+
983
+
939
984
  export async function processLearning(rawEntry, _lineNum, ctx) {
940
985
  const {
941
986
  vaultDir,
@@ -1023,10 +1068,8 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1023
1068
  // #1513 review M2: a hyphen alias (`gotzendorferv2`) resolves to the
1024
1069
  // canonical folder already in the vault (`gotzendorfer-v2`), so the mirror
1025
1070
  // and the pruner agree on where a repo's notes live.
1026
- const repoNs = canonicalNamespace(
1027
- vaultDir,
1028
- resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1029
- );
1071
+ const stableNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null });
1072
+ const repoNs = canonicalNamespace(vaultDir, stableNs);
1030
1073
  // #725 D2: thread the resolved repo namespace into the learning frontmatter as
1031
1074
  // `source-repo` for cross-repo attribution. repoNs is already sanitised +
1032
1075
  // leak-guarded by resolveRepoNamespace, so it is safe to interpolate as-is. The
@@ -1075,18 +1118,56 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1075
1118
  return null;
1076
1119
  };
1077
1120
  const targetDir = join(resolve(vaultDir), '40-learnings', repoNs);
1078
- if (!dryRun) mkdirSync(targetDir, { recursive: true });
1079
1121
 
1080
1122
  let targetPath = join(targetDir, `${slug}.md`);
1081
1123
 
1124
+ // #1548: inspect EVERY candidate before mutation when normalization changes
1125
+ // a name. Missing/foreign/ambiguous provenance is not permission to create a
1126
+ // second note, including under --force. Keep the actual flat or namespace path.
1127
+ const slugTransition = hasLearningSlugTransition(entry, schema);
1128
+ if (slugTransition) {
1129
+ const matches = [];
1130
+ for (const dir of [targetDir, join(resolve(vaultDir), '40-learnings')]) {
1131
+ for (const name of learningSlugVariants(entry, schema)) {
1132
+ const path = join(dir, `${name}.md`);
1133
+ if (!existsSync(path)) continue;
1134
+ const fm = parseFrontmatter(readFileSync(path, 'utf8'));
1135
+ matches.push({ path, name, valid: positiveLearningOwner(fm, name, repoNs, writer.recordKey) });
1136
+ }
1137
+ }
1138
+ if (matches.length > 1 || matches.some((match) => !match.valid)) {
1139
+ return emitEntryAction(_lineNum, ctx, {
1140
+ action: 'skipped-foreign-owner', path: null, id: entryId,
1141
+ meta: { reason: 'slug transition: ambiguous or missing/contradictory provenance' },
1142
+ });
1143
+ }
1144
+ if (matches.length === 1) {
1145
+ slug = matches[0].name;
1146
+ targetPath = matches[0].path;
1147
+ }
1148
+ }
1149
+
1150
+ // A provenance-less flat v2 note cannot be assigned to this namespace.
1151
+ // Keep it untouched rather than creating a second copy on an advanced date.
1152
+ if (schema === 'v2' && writer.recordKey === null &&
1153
+ learningSlugVariants(entry, schema).some((name) =>
1154
+ existsSync(join(resolve(vaultDir), '40-learnings', name + '.md')))) {
1155
+ return emitEntryAction(_lineNum, ctx, {
1156
+ action: 'skipped-foreign-owner', path: null, id: entryId,
1157
+ meta: { reason: 'flat note: missing representable source provenance' },
1158
+ });
1159
+ }
1160
+
1161
+ if (!dryRun) mkdirSync(targetDir, { recursive: true });
1162
+
1082
1163
  // #1513 LEGACY SLUG: until #725 D1 a v1 subject was slugged WITHOUT the
1083
1164
  // whitespace → hyphen pre-map ("3parallelimplagents…"); the canonical form
1084
1165
  // hyphenates ("3-parallel-impl-agents…"). When only the legacy variant of
1085
1166
  // THIS record's note exists, it IS the note: it is updated in place under
1086
1167
  // its own name instead of a second file being created beside it (the vault
1087
1168
  // carried 364 such twins). New notes always get the canonical name.
1088
- if (schema === 'v1') {
1089
- const legacySlug = legacyConcatSlug(entry.subject);
1169
+ if (!slugTransition && schema === 'v1') {
1170
+ const legacySlug = historicalConcatSlug(entry.subject);
1090
1171
  const flatDir = join(resolve(vaultDir), '40-learnings');
1091
1172
  if (
1092
1173
  isValidSlug(legacySlug) &&
@@ -1104,7 +1185,9 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1104
1185
  foreignOwnerReason(legacyFm, writer) === null
1105
1186
  ) {
1106
1187
  slug = legacySlug;
1107
- targetPath = join(targetDir, `${legacySlug}.md`);
1188
+ targetPath = positiveLearningOwner(legacyFm, legacySlug, repoNs, writer.recordKey)
1189
+ ? legacyPath
1190
+ : join(targetDir, `${legacySlug}.md`);
1108
1191
  break;
1109
1192
  }
1110
1193
  }
@@ -1114,8 +1197,8 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1114
1197
  // #660 IDEMPOTENCY DUAL-PROBE: before treating the namespaced path as absent,
1115
1198
  // also check the legacy flat path. If a note with the same slug already exists
1116
1199
  // in the flat layout (pre-namespace migration), treat it as existing to avoid
1117
- // duplicating the note. The deferred-migration decision means we only skip;
1118
- // we do NOT move the flat note into the namespaced dir here.
1200
+ // duplicating the note. Positive provenance also permits an update at the
1201
+ // actual flat path; notes without that evidence keep their historical rules.
1119
1202
  const legacyFlatPath = join(resolve(vaultDir), '40-learnings', `${slug}.md`);
1120
1203
  // Set when a still-leaking legacy flat note was healed in place below, so the
1121
1204
  // action emitted for THIS entry can say so (see the write sites at the end).
@@ -1142,7 +1225,11 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1142
1225
  if (legacyForeign && legacyStillLeaks) {
1143
1226
  healedForeignLeak = maskForeignNoteInPlace(legacyFlatPath, legacyContent, ctx);
1144
1227
  }
1145
- if (legacyMarked && !legacyForeign) {
1228
+ if (positiveLearningOwner(legacyFm, slug, repoNs, writer.recordKey)) {
1229
+ // A proven flat note is the update target. Let the common existing-note
1230
+ // branch below compare/render/heal it ONCE; never pre-write a healed copy.
1231
+ targetPath = legacyFlatPath;
1232
+ } else if (legacyMarked && !legacyForeign) {
1146
1233
  const entryUpdated = toDate(dateSource);
1147
1234
  if (!force && legacyFm['updated'] && legacyFm['updated'] >= entryUpdated) {
1148
1235
  // Date has not advanced — but content may have changed (confidence, insight, etc.).
@@ -1313,7 +1400,7 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1313
1400
 
1314
1401
  // #1506.2: the main note is this record's — a live disambiguation copy of
1315
1402
  // the same record (left by an interrupted takeover) is superseded.
1316
- const stale = fm['source-record'] === writer.recordKey
1403
+ const stale = !slugTransition && fm['source-record'] === writer.recordKey
1317
1404
  ? staleOwnDisambig(targetDir, slug, entryId, writer)
1318
1405
  : null;
1319
1406
 
@@ -1351,14 +1438,19 @@ export async function processLearning(rawEntry, _lineNum, ctx) {
1351
1438
  return emitEntryAction(_lineNum, ctx, { action: 'skipped-noop', path: targetPath, id: slug });
1352
1439
  }
1353
1440
 
1354
- // Overwrite with advanced updated date (or forced re-render)
1441
+ // Overwrite with advanced updated date (or forced re-render). A retained
1442
+ // flat note, including an old Unicode slug, is healed by this single write.
1355
1443
  const content = generator(entry, slug, generatorOpts);
1444
+ healedLegacyFlat = !dryRun && targetPath === legacyFlatPath && maskerWouldChange(existingContent);
1356
1445
  if (!dryRun) writeFileSync(targetPath, content, 'utf8');
1357
1446
  return emitEntryAction(_lineNum, ctx, {
1358
1447
  action: 'updated',
1359
1448
  path: targetPath,
1360
1449
  id: slug,
1361
- ...(stale !== null ? { meta: { superseded_id: basename(stale.path, '.md') } } : {}),
1450
+ ...((stale !== null || healedLegacyFlat) ? { meta: {
1451
+ ...(stale !== null ? { superseded_id: basename(stale.path, '.md') } : {}),
1452
+ ...(healedLegacyFlat ? { healed_legacy_flat: true } : {}),
1453
+ } } : {}),
1362
1454
  });
1363
1455
  }
1364
1456
 
@@ -1465,17 +1557,7 @@ function takeOverMainNote({
1465
1557
  export function learningNoteSlugs(rawEntry) {
1466
1558
  const entry = normalizeLearningEntry(rawEntry);
1467
1559
  if (!entry || entry.id === null || entry.id === undefined) return [];
1468
- const schema = detectLearningSchema(entry);
1469
- const slugs = [learningSlug(entry, schema)];
1470
- if (schema === 'v1') {
1471
- const legacy = legacyConcatSlug(entry.subject);
1472
- if (isValidSlug(legacy) && !slugs.includes(legacy)) slugs.push(legacy);
1473
- }
1474
- const id = String(entry.id);
1475
- for (const name of [`${slugs[0]}-${uuidPrefix8(id)}`, `${slugs[0]}-${recordKeySuffix8(learningSourceRecord(entry) ?? id)}`]) {
1476
- if (!slugs.includes(name)) slugs.push(name);
1477
- }
1478
- return slugs;
1560
+ return learningSlugVariants(entry, detectLearningSchema(entry));
1479
1561
  }
1480
1562
 
1481
1563
  /**
@@ -1515,13 +1597,36 @@ export async function processArchivedLearning(rawEntry, _lineNum, ctx) {
1515
1597
  meta: { reason: 'archived record: a live record owns this note' },
1516
1598
  });
1517
1599
  if (names.length === 0) return liveOwned();
1518
- const repoNs = canonicalNamespace(
1519
- vaultDir,
1520
- resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1521
- );
1600
+ const stableNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null });
1601
+ const repoNs = canonicalNamespace(vaultDir, stableNs);
1522
1602
  const nsDir = join(resolve(vaultDir), '40-learnings', repoNs);
1523
1603
  const flatDir = join(resolve(vaultDir), '40-learnings');
1524
1604
  const writer = { recordKey: learningSourceRecord(entry) };
1605
+ const schema = detectLearningSchema(entry);
1606
+ if (schema === 'v2' && writer.recordKey === null &&
1607
+ allNames.some((name) => existsSync(join(flatDir, name + '.md')))) {
1608
+ return emitEntryAction(_lineNum, ctx, {
1609
+ action: 'skipped-foreign-owner', path: null, id: entry.id,
1610
+ meta: { reason: 'flat note: missing representable source provenance' },
1611
+ });
1612
+ }
1613
+ if (hasLearningSlugTransition(entry, schema)) {
1614
+ const matches = [];
1615
+ for (const dir of [nsDir, flatDir]) {
1616
+ for (const name of allNames) {
1617
+ const path = join(dir, `${name}.md`);
1618
+ if (!existsSync(path)) continue;
1619
+ const fm = parseFrontmatter(readFileSync(path, 'utf8'));
1620
+ matches.push(positiveLearningOwner(fm, name, repoNs, writer.recordKey));
1621
+ }
1622
+ }
1623
+ if (matches.length > 1 || matches.some((valid) => !valid)) {
1624
+ return emitEntryAction(_lineNum, ctx, {
1625
+ action: 'skipped-foreign-owner', path: null, id: entry.id,
1626
+ meta: { reason: 'slug transition: ambiguous or missing/contradictory provenance' },
1627
+ });
1628
+ }
1629
+ }
1525
1630
  for (const dir of [nsDir, flatDir]) {
1526
1631
  for (const name of names) {
1527
1632
  const path = join(dir, `${name}.md`);
@@ -1615,8 +1720,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1615
1720
  const generator =
1616
1721
  schema === 'v3' ? generateSessionNoteV3 : schema === 'v2' ? generateSessionNoteV2 : generateSessionNote;
1617
1722
 
1618
- // Validate session_id as a filesystem-safe slug; fall back via subjectToSlug
1619
- // (which collapses slashes to last segment + strips invalid chars) before
1723
+ // Validate session_id as a filesystem-safe slug; preserve old Session identity
1724
+ // with the historical sanitizer (collapse slash paths + strip invalid chars), before
1620
1725
  // resorting to a uuid-derived slug. Without subjectToSlug, raw slashes in
1621
1726
  // rawSessionId (e.g. "feat/opus-4-7-...") would survive uuidPrefix8 and
1622
1727
  // produce a path with directory separators in the basename.
@@ -1624,7 +1729,7 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1624
1729
  if (isValidSlug(rawSessionId)) {
1625
1730
  session_id = rawSessionId;
1626
1731
  } else if (typeof rawSessionId === 'string' && rawSessionId.length > 0) {
1627
- const sanitised = subjectToSlug(rawSessionId);
1732
+ const sanitised = legacySubjectToSlug(rawSessionId);
1628
1733
  session_id = isValidSlug(sanitised) ? sanitised : `session-${uuidPrefix8(rawSessionId)}`;
1629
1734
  } else {
1630
1735
  session_id = `session-${uuidPrefix8(randomUUID())}`;
@@ -1671,10 +1776,8 @@ export async function processSession(rawEntry, _lineNum, ctx) {
1671
1776
  // owner-leaky repo's real name reached the vault through the session-note
1672
1777
  // frontmatter even though the directory AND the learning `source-repo` field
1673
1778
  // were already pseudonym-mapped/redacted (#732 leak-guard bypass).
1674
- const repoNs = canonicalNamespace(
1675
- vaultDir,
1676
- resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null }),
1677
- );
1779
+ const stableNs = resolveRepoNamespace({ vaultName: ctx?.vaultName ?? null, repoRoot: ctx?.repoRoot ?? null });
1780
+ const repoNs = canonicalNamespace(vaultDir, stableNs);
1678
1781
 
1679
1782
  // Narrative gate (#1513, replaces the PRD F1.2 rendered-length gate). A
1680
1783
  // session gets its own note only when its FREE-TEXT fields (`notes`,
@@ -5,7 +5,7 @@
5
5
  * sourceRecordValue, learningSourceRecord, sourceRecordLine (#1503)
6
6
  */
7
7
 
8
- import { toDate, truncateAtWord, yamlQuoteIfNeeded, subjectToSlug, isValidSlug, buildTag, resolveSourceSessionLink } from './utils.mjs';
8
+ import { toDate, truncateAtWord, yamlQuoteIfNeeded, legacySubjectToSlug, isValidSlug, buildTag, resolveSourceSessionLink } from './utils.mjs';
9
9
 
10
10
  const GENERATOR_MARKER = 'session-orchestrator-vault-mirror@1';
11
11
 
@@ -118,9 +118,10 @@ export function normalizeLearningEntry(entry) {
118
118
  e.evidence = joinList(e.evidence_sessions) ?? joinList(e.files) ?? joinList(e.sessions) ?? '(none recorded)';
119
119
  }
120
120
  if (e.id === null || e.id === undefined) {
121
- // subjectToSlug strips whitespace without hyphenating (designed for
121
+ // Keep historical ASCII deletion for record identity; only note names
122
+ // transliterate. The old slugger strips whitespace (designed for
122
123
  // kebab-ish inputs) — pre-map whitespace to hyphens for prose subjects.
123
- const derived = firstString(e.subject) ? subjectToSlug(e.subject.trim().replace(/\s+/g, '-')) : '';
124
+ const derived = firstString(e.subject) ? legacySubjectToSlug(e.subject.trim().replace(/\s+/g, '-')) : '';
124
125
  if (isValidSlug(derived)) e.id = derived;
125
126
  }
126
127
  if (e.source_session === null || e.source_session === undefined) {
@@ -157,7 +158,7 @@ export function generateLearningNote(entry, slug, opts = {}) {
157
158
  // through buildTag so each is kebab-slugified and capped at 64 chars. The
158
159
  // existing source/<session> slug is preserved (buildTag is idempotent for an
159
160
  // already-kebab value and adds the length cap).
160
- const sourceTag = subjectToSlug(String(source_session)) || 'unknown';
161
+ const sourceTag = legacySubjectToSlug(String(source_session)) || 'unknown';
161
162
  const tags = `[${buildTag(['learning', type])}, ${buildTag(['status', status])}, ${buildTag(['source', sourceTag])}]`;
162
163
 
163
164
  // Check if expires has a value; it's optional in schema
@@ -230,7 +231,7 @@ export function generateLearningNoteV2(entry, slug, opts = {}) {
230
231
  const title = yamlQuoteIfNeeded(titleRaw);
231
232
 
232
233
  // #602: type/status interpolated raw upstream — slugify + cap each segment.
233
- const scopeTag = subjectToSlug(scope) || 'unscoped';
234
+ const scopeTag = legacySubjectToSlug(scope) || 'unscoped';
234
235
  const tags = `[${buildTag(['learning', type])}, ${buildTag(['status', status])}, ${buildTag(['scope', scopeTag])}]`;
235
236
 
236
237
  // #725 D2: emit `source-repo` for cross-repo attribution when the caller threads