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.
- 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 +19 -0
- package/README.md +22 -12
- package/commands/session.md +3 -1
- package/docs/install.md +12 -8
- package/docs/session-config-reference.md +6 -6
- package/docs/session-config-template.md +2 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +19 -10
- package/package.json +1 -2
- package/scripts/fleet-checkin.mjs +61 -3
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config.mjs +1 -1
- package/scripts/lib/fleet-protocol.mjs +17 -0
- package/scripts/lib/session-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-registry.mjs +14 -4
- 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/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/resolve-session-invocation.mjs +64 -8
- package/scripts/so-hud.mjs +45 -0
- package/scripts/state-md-idle-reset.mjs +81 -0
- 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/session-end/SKILL.md +3 -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-start/SKILL.md +1 -1
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
- 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 =
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|