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.
- package/.claude-plugin/marketplace.json +12 -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 +51 -0
- package/README.md +22 -11
- package/SECURITY.md +1 -1
- package/agents/code-implementer.md +7 -1
- package/commands/session.md +3 -1
- package/docs/agent-authoring.md +2 -0
- package/docs/events-schema.md +2 -1
- package/docs/fleet-checkin-audit.md +172 -0
- package/docs/install.md +12 -8
- package/docs/role-profile-contract.md +139 -0
- package/docs/scope-collision-guard.md +3 -0
- package/docs/session-config-reference.md +7 -7
- package/docs/session-config-template.md +2 -2
- package/evals/instruction-ablation/run.mjs +11 -6
- package/hooks/_lib/hook-import-set.json +27 -2
- package/hooks/_lib/orchestrator-ignore.mjs +112 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +8 -1
- package/hooks/on-session-start.mjs +26 -10
- package/hooks/post-bash-write-verify.mjs +16 -3
- package/hooks/pre-task-scope-disjoint.mjs +12 -0
- package/hooks/subagent-telemetry.mjs +8 -3
- package/hooks/wave-scope-commit-guard.mjs +4 -1
- package/package.json +1 -2
- package/rules/opt-in-stack/security-web.md +1 -1
- package/scripts/emit-session.mjs +13 -0
- package/scripts/fleet-checkin-audit.mjs +138 -0
- package/scripts/fleet-checkin.mjs +61 -3
- package/scripts/lib/common.mjs +2 -4
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config.mjs +1 -1
- package/scripts/lib/context-overhead-format.mjs +57 -0
- package/scripts/lib/fleet-checkin-audit.mjs +244 -0
- package/scripts/lib/fleet-protocol.mjs +17 -0
- package/scripts/lib/memory-proposals/sink.mjs +79 -17
- package/scripts/lib/memory-proposals/store.mjs +245 -9
- package/scripts/lib/project-hygiene.mjs +37 -0
- package/scripts/lib/role-profile/schema.mjs +219 -0
- package/scripts/lib/session-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-lock.mjs +57 -10
- package/scripts/lib/session-record-repair.mjs +63 -30
- package/scripts/lib/session-registry.mjs +14 -4
- package/scripts/lib/session-schema/constants.mjs +8 -0
- package/scripts/lib/session-schema/validator.mjs +33 -0
- package/scripts/lib/so-hud/collect.mjs +130 -0
- package/scripts/lib/so-hud/runtime.mjs +76 -0
- package/scripts/lib/state-md/idle-reset.mjs +239 -0
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- package/scripts/lib/subagents-schema.mjs +9 -0
- package/scripts/lib/telemetry/pricing.mjs +11 -0
- package/scripts/lib/vault-mirror/namespace.mjs +3 -2
- package/scripts/lib/vault-mirror/process.mjs +143 -40
- package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
- package/scripts/lib/vault-mirror/utils.mjs +27 -13
- package/scripts/lib/vault-status/board-writer.mjs +32 -4
- package/scripts/measure-context-overhead.sh +4 -21
- package/scripts/release.mjs +359 -11
- package/scripts/resolve-session-invocation.mjs +64 -8
- package/scripts/run-quality-gate.mjs +47 -0
- package/scripts/so-hud.mjs +45 -0
- package/scripts/state-md-idle-reset.mjs +81 -0
- package/scripts/validate-wave-scope.mjs +59 -6
- package/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +24 -2
- package/skills/_shared/state-ownership.md +3 -3
- package/skills/autopilot/SKILL.md +3 -2
- package/skills/claude-md-drift-check/checker.mjs +8 -6
- package/skills/npm-publish/SKILL.md +3 -3
- package/skills/release/SKILL.md +2 -2
- package/skills/repo-audit/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/metrics-collection.md +2 -2
- package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-end/session-metrics-write.md +1 -1
- package/skills/session-start/SKILL.md +1 -1
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
- package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
- package/skills/wave-executor/circuit-breaker.md +1 -0
- 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 =
|
|
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
|
|
1027
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
1118
|
-
//
|
|
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 (
|
|
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: {
|
|
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
|
-
|
|
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
|
|
1519
|
-
|
|
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;
|
|
1619
|
-
//
|
|
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 =
|
|
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
|
|
1675
|
-
|
|
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,
|
|
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
|
-
//
|
|
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) ?
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
36
|
+
return slugSubject(subject, normalizeSlugLetters);
|
|
37
|
+
}
|
|
23
38
|
|
|
24
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
137
|
+
// "myid"), then preserve the historical ASCII kebab contract.
|
|
127
138
|
const normalised = String(input ?? '').replace(/\s+/g, '-');
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1102
|
-
//
|
|
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
|
|
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() {
|
|
59
|
+
header() { node "$FORMATTER" --header; }
|
|
77
60
|
|
|
78
61
|
# ── --ablate mode ───────────────────────────────────────────────────
|
|
79
62
|
if [ "${1:-}" = "--ablate" ]; then
|