session-orchestrator 5.10.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 (88) 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 +51 -0
  6. package/README.md +22 -11
  7. package/SECURITY.md +1 -1
  8. package/agents/code-implementer.md +7 -1
  9. package/commands/session.md +3 -1
  10. package/docs/agent-authoring.md +2 -0
  11. package/docs/events-schema.md +2 -1
  12. package/docs/fleet-checkin-audit.md +172 -0
  13. package/docs/install.md +12 -8
  14. package/docs/role-profile-contract.md +139 -0
  15. package/docs/scope-collision-guard.md +3 -0
  16. package/docs/session-config-reference.md +7 -7
  17. package/docs/session-config-template.md +2 -2
  18. package/evals/instruction-ablation/run.mjs +11 -6
  19. package/hooks/_lib/hook-import-set.json +27 -2
  20. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +8 -1
  24. package/hooks/on-session-start.mjs +26 -10
  25. package/hooks/post-bash-write-verify.mjs +16 -3
  26. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  27. package/hooks/subagent-telemetry.mjs +8 -3
  28. package/hooks/wave-scope-commit-guard.mjs +4 -1
  29. package/package.json +1 -2
  30. package/rules/opt-in-stack/security-web.md +1 -1
  31. package/scripts/emit-session.mjs +13 -0
  32. package/scripts/fleet-checkin-audit.mjs +138 -0
  33. package/scripts/fleet-checkin.mjs +61 -3
  34. package/scripts/lib/common.mjs +2 -4
  35. package/scripts/lib/config/remote-hosts.mjs +2 -2
  36. package/scripts/lib/config.mjs +1 -1
  37. package/scripts/lib/context-overhead-format.mjs +57 -0
  38. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  39. package/scripts/lib/fleet-protocol.mjs +17 -0
  40. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  41. package/scripts/lib/memory-proposals/store.mjs +245 -9
  42. package/scripts/lib/project-hygiene.mjs +37 -0
  43. package/scripts/lib/role-profile/schema.mjs +219 -0
  44. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  45. package/scripts/lib/session-invocation.mjs +14 -3
  46. package/scripts/lib/session-lock.mjs +57 -10
  47. package/scripts/lib/session-record-repair.mjs +63 -30
  48. package/scripts/lib/session-registry.mjs +14 -4
  49. package/scripts/lib/session-schema/constants.mjs +8 -0
  50. package/scripts/lib/session-schema/validator.mjs +33 -0
  51. package/scripts/lib/so-hud/collect.mjs +130 -0
  52. package/scripts/lib/so-hud/runtime.mjs +76 -0
  53. package/scripts/lib/state-md/idle-reset.mjs +239 -0
  54. package/scripts/lib/state-md/recommendations.mjs +26 -4
  55. package/scripts/lib/subagents-schema.mjs +9 -0
  56. package/scripts/lib/telemetry/pricing.mjs +11 -0
  57. package/scripts/lib/vault-mirror/namespace.mjs +3 -2
  58. package/scripts/lib/vault-mirror/process.mjs +143 -40
  59. package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
  60. package/scripts/lib/vault-mirror/utils.mjs +27 -13
  61. package/scripts/lib/vault-status/board-writer.mjs +32 -4
  62. package/scripts/measure-context-overhead.sh +4 -21
  63. package/scripts/release.mjs +359 -11
  64. package/scripts/resolve-session-invocation.mjs +64 -8
  65. package/scripts/run-quality-gate.mjs +47 -0
  66. package/scripts/so-hud.mjs +45 -0
  67. package/scripts/state-md-idle-reset.mjs +81 -0
  68. package/scripts/validate-wave-scope.mjs +59 -6
  69. package/scripts/vault-orphans.mjs +171 -0
  70. package/skills/_shared/fleet-protocol.md +24 -2
  71. package/skills/_shared/state-ownership.md +3 -3
  72. package/skills/autopilot/SKILL.md +3 -2
  73. package/skills/claude-md-drift-check/checker.mjs +8 -6
  74. package/skills/npm-publish/SKILL.md +3 -3
  75. package/skills/release/SKILL.md +2 -2
  76. package/skills/repo-audit/SKILL.md +1 -1
  77. package/skills/session-end/SKILL.md +3 -2
  78. package/skills/session-end/metrics-collection.md +2 -2
  79. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  80. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  81. package/skills/session-end/references/session-summary-template.md +4 -0
  82. package/skills/session-end/session-metrics-write.md +1 -1
  83. package/skills/session-start/SKILL.md +1 -1
  84. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  85. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  86. package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
  87. package/skills/wave-executor/circuit-breaker.md +1 -0
  88. package/AGENTS.md +0 -205
@@ -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
@@ -10,32 +10,43 @@
10
10
  import { parseSessionId } from '../session-id.mjs';
11
11
  import { digestSha256Short } from '../crypto-digest-utils.mjs';
12
12
 
13
+ /** Normalize letters for new note slugs. */
14
+ function normalizeSlugLetters(value) {
15
+ const german = { ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' };
16
+ return value.normalize('NFC').toLowerCase()
17
+ .replace(/[äöüß]/g, (letter) => german[letter])
18
+ .normalize('NFKD').replace(/\p{M}/gu, '');
19
+ }
20
+
21
+ /** Historical ASCII deletion, frozen for record identity and old note lookup. */
22
+ export function legacySubjectToSlug(subject) {
23
+ return slugSubject(subject, (value) => value.toLowerCase());
24
+ }
25
+
13
26
  /**
14
27
  * Convert a subject string to a kebab slug.
15
28
  * - If subject contains slashes, collapse to last path segment.
29
+ * - Transliterate German letters and decompose accents.
16
30
  * - Replace dots and underscores with hyphens.
17
31
  * - Strip all non-[a-z0-9-] chars.
18
32
  * - Collapse consecutive hyphens.
19
33
  * - Trim leading/trailing hyphens.
20
34
  */
21
35
  export function subjectToSlug(subject) {
22
- let s = subject;
36
+ return slugSubject(subject, normalizeSlugLetters);
37
+ }
23
38
 
24
- // Collapse slash paths to last segment
39
+ function slugSubject(subject, normalize) {
40
+ let s = subject;
25
41
  if (s.includes('/')) {
26
42
  const parts = s.split('/').filter(Boolean);
27
43
  s = parts[parts.length - 1];
28
44
  }
29
-
30
- // Normalise: lowercase, dots/underscores → hyphens, strip invalid chars
31
- s = s
32
- .toLowerCase()
45
+ return normalize(s)
33
46
  .replace(/[._]/g, '-')
34
47
  .replace(/[^a-z0-9-]/g, '')
35
48
  .replace(/-+/g, '-')
36
49
  .replace(/^-+|-+$/g, '');
37
-
38
- return s;
39
50
  }
40
51
 
41
52
  const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -80,8 +91,8 @@ export const TAG_MAX_LENGTH = 64;
80
91
  * @returns {string}
81
92
  */
82
93
  export function slugifyTagSegment(input) {
83
- const slug = String(input ?? '')
84
- .toLowerCase()
94
+ // Tags are persistent identifiers, so retain historical ASCII deletion.
95
+ const slug = String(input ?? '').toLowerCase()
85
96
  .replace(/[\s._]+/g, '-')
86
97
  .replace(/[^a-z0-9-]/g, '')
87
98
  .replace(/-+/g, '-')
@@ -123,9 +134,11 @@ export function buildTag(segments) {
123
134
  */
124
135
  export function slugifyIdSafe(input) {
125
136
  // Normalise whitespace to hyphens first (so "My ID" → "my-id" rather than
126
- // "myid"), then delegate to subjectToSlug for the rest of the kebab contract.
137
+ // "myid"), then preserve the historical ASCII kebab contract.
127
138
  const normalised = String(input ?? '').replace(/\s+/g, '-');
128
- const slug = subjectToSlug(normalised);
139
+ // Session identity retains historical deletion; transliteration is for new
140
+ // learning names, never an incidental Session-ID migration.
141
+ const slug = legacySubjectToSlug(normalised);
129
142
  if (!slug) return null;
130
143
  // Schema requires min length 2; pad a single-char slug deterministically.
131
144
  return slug.length >= 2 ? slug : `${slug}-x`;
@@ -198,7 +211,8 @@ export function resolveSourceSessionLink(source_session, opts = {}) {
198
211
  if (!raw || raw === 'unknown') return { isLink: false, target: raw || 'unknown' };
199
212
  // Provenance tags like "agent-proposed@wave-1" are never session notes.
200
213
  if (/@/.test(raw)) return { isLink: false, target: raw };
201
- const slug = subjectToSlug(raw) || raw;
214
+ // Session targets retain the same historical identity as their note filenames.
215
+ const slug = legacySubjectToSlug(raw) || raw;
202
216
  if (typeof opts.noteExists === 'function') {
203
217
  // EXISTENCE-based (authoritative when a predicate is supplied).
204
218
  return opts.noteExists(slug) ? { isLink: true, target: slug } : { isLink: false, target: raw };
@@ -48,7 +48,7 @@
48
48
  * canonical-vault refusal (#1450). Plain Node ESM. No external deps.
49
49
  */
50
50
 
51
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
51
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs';
52
52
  import os from 'node:os';
53
53
  import path from 'node:path';
54
54
 
@@ -56,6 +56,7 @@ import { emitEvent, sessionAttribution } from '../events.mjs';
56
56
  import { isLockLive, readLock, DEFAULT_TTL_HOURS } from '../session-lock.mjs';
57
57
  import { stableHostname } from '../host-identity.mjs';
58
58
  import { readRegistry, repoPathHash, isRegistryEntryFresh } from '../session-registry.mjs';
59
+ import { digestSha256 } from '../crypto-digest-utils.mjs';
59
60
  import { parseFrontmatter } from '../vault-mirror/utils.mjs';
60
61
  import { readConfigFile, parseSessionConfig } from '../config.mjs';
61
62
  import { validatePathInsideProject } from '../path-utils.mjs';
@@ -134,10 +135,29 @@ const CASE_INSENSITIVE_FS = process.platform === 'darwin' || process.platform ==
134
135
  * @returns {string|null} `KEY_LENGTH`-char hex prefix, or null for an unusable path.
135
136
  */
136
137
  export function boardKey(repoRoot) {
138
+ if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
139
+ try {
140
+ let resolved = path.resolve(repoRoot);
141
+ try { resolved = realpathSync.native(resolved); } catch { /* Keep unavailable paths' legacy identity. */ }
142
+ // Fold AFTER realpath, then hash directly: repoPathHash would restore
143
+ // on-disk casing and change existing case-folded board keys on macOS.
144
+ return digestSha256(CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved).slice(0, KEY_LENGTH);
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Pre-#1557 lexical board key, used to migrate rows when alias paths now
152
+ * resolve to a canonical key. BV-004 revisit/deletion trigger: remove once a
153
+ * host-wide board audit confirms no rows written before the #1557 canonical-key
154
+ * rollout remain for 30 consecutive days; age alone does not prove migration.
155
+ */
156
+ function legacyBoardKey(repoRoot) {
137
157
  if (typeof repoRoot !== 'string' || repoRoot.length === 0) return null;
138
158
  try {
139
159
  const resolved = path.resolve(repoRoot);
140
- return repoPathHash(CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved).slice(0, KEY_LENGTH);
160
+ return digestSha256(CASE_INSENSITIVE_FS ? resolved.toLowerCase() : resolved).slice(0, KEY_LENGTH);
141
161
  } catch {
142
162
  return null;
143
163
  }
@@ -435,6 +455,7 @@ export async function collectRows({ repos, now = new Date(), registry, priorStat
435
455
  rows.push({
436
456
  repo: repoName,
437
457
  key,
458
+ legacyKey: legacyBoardKey(repo.repoRoot), // Internal migration identity; never rendered.
438
459
  status,
439
460
  session: status === STATUS_FREI ? null : session,
440
461
  branch: status === STATUS_FREI ? null : branch,
@@ -1098,8 +1119,8 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
1098
1119
  // skips `frei` candidates and the TTL pass only rewrites `status`, never
1099
1120
  // removes a row), and the board would grow a permanent duplicate per repo.
1100
1121
  // So a fresh row first claims its hash slot; if that slot is new, it ADOPTS
1101
- // the legacy name slot for the same folded name — one board write converts
1102
- // the row, and the migration is complete for that repo.
1122
+ // the legacy name slot for the same folded name and the pre-#1557 lexical
1123
+ // key slot — one board write converts the row for that repo.
1103
1124
  const merged = new Map(staleRederivedRows);
1104
1125
  for (const row of rows) {
1105
1126
  const slot = row.key ? hashSlot(row.key) : nameSlot(row.repo);
@@ -1107,6 +1128,13 @@ async function mirrorBoardInner({ repoRoot, repos, explicitStatus, now = new Dat
1107
1128
  // First keyed write for this repo — take over its legacy row rather than
1108
1129
  // rendering a second one beside it.
1109
1130
  merged.delete(nameSlot(row.repo));
1131
+ if (row.legacyKey && row.legacyKey !== row.key) {
1132
+ // An 8-char key can collide: adopt the legacy-key row only when it
1133
+ // names this repo, never another repo's row that shares the prefix.
1134
+ const legacySlot = hashSlot(row.legacyKey);
1135
+ const legacyRow = merged.get(legacySlot);
1136
+ if (legacyRow && foldKey(legacyRow.repo) === foldKey(row.repo)) merged.delete(legacySlot);
1137
+ }
1110
1138
  }
1111
1139
  merged.set(slot, row);
1112
1140
  }
@@ -44,6 +44,8 @@
44
44
  set -uo pipefail
45
45
 
46
46
  PROMPT='Antworte nur mit dem Wort: OK'
47
+ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
48
+ FORMATTER="$SCRIPT_DIR/lib/context-overhead-format.mjs"
47
49
 
48
50
  measure_one() {
49
51
  local dir="$1" label="$2"
@@ -51,29 +53,10 @@ measure_one() {
51
53
  printf '%s\tNO-SUCH-DIR\n' "$label"
52
54
  return
53
55
  fi
54
- ( cd "$dir" && claude -p "$PROMPT" --output-format json 2>/dev/null ) | node -e "
55
- let s = '';
56
- process.stdin.on('data', (d) => (s += d)).on('end', () => {
57
- const label = process.argv[1];
58
- try {
59
- const j = JSON.parse(s);
60
- const u = j.usage || {};
61
- const ctx =
62
- (u.input_tokens || 0) +
63
- (u.cache_creation_input_tokens || 0) +
64
- (u.cache_read_input_tokens || 0);
65
- process.stdout.write(
66
- [label, ctx, u.cache_creation_input_tokens || 0, u.cache_read_input_tokens || 0,
67
- u.output_tokens || 0, (j.total_cost_usd || 0).toFixed(4)].join('\t') + '\n',
68
- );
69
- } catch {
70
- process.stdout.write(label + '\tPARSE-ERROR\n');
71
- }
72
- });
73
- " "$label"
56
+ ( cd "$dir" && claude -p "$PROMPT" --output-format json 2>/dev/null ) | node "$FORMATTER" -- "$label"
74
57
  }
75
58
 
76
- header() { printf 'LABEL\tCONTEXT_TOK\tcache_create\tcache_read\toutput\tUSD\n'; }
59
+ header() { node "$FORMATTER" --header; }
77
60
 
78
61
  # ── --ablate mode ───────────────────────────────────────────────────
79
62
  if [ "${1:-}" = "--ablate" ]; then