session-orchestrator 5.11.0 → 5.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +19 -0
  6. package/README.md +22 -12
  7. package/commands/session.md +3 -1
  8. package/docs/install.md +12 -8
  9. package/docs/session-config-reference.md +6 -6
  10. package/docs/session-config-template.md +2 -2
  11. package/hooks/hooks-codex.json +1 -1
  12. package/hooks/hooks.json +1 -1
  13. package/hooks/on-session-start.mjs +19 -10
  14. package/package.json +1 -2
  15. package/scripts/fleet-checkin.mjs +61 -3
  16. package/scripts/lib/config/remote-hosts.mjs +2 -2
  17. package/scripts/lib/config.mjs +1 -1
  18. package/scripts/lib/fleet-protocol.mjs +17 -0
  19. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  20. package/scripts/lib/session-invocation.mjs +14 -3
  21. package/scripts/lib/session-registry.mjs +14 -4
  22. package/scripts/lib/so-hud/collect.mjs +130 -0
  23. package/scripts/lib/so-hud/runtime.mjs +76 -0
  24. package/scripts/lib/state-md/idle-reset.mjs +239 -0
  25. package/scripts/lib/state-md/recommendations.mjs +26 -4
  26. package/scripts/lib/vault-mirror/namespace.mjs +3 -2
  27. package/scripts/lib/vault-mirror/process.mjs +143 -40
  28. package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
  29. package/scripts/lib/vault-mirror/utils.mjs +27 -13
  30. package/scripts/lib/vault-status/board-writer.mjs +32 -4
  31. package/scripts/resolve-session-invocation.mjs +64 -8
  32. package/scripts/so-hud.mjs +45 -0
  33. package/scripts/state-md-idle-reset.mjs +81 -0
  34. package/scripts/vault-orphans.mjs +171 -0
  35. package/skills/_shared/fleet-protocol.md +24 -2
  36. package/skills/_shared/state-ownership.md +3 -3
  37. package/skills/autopilot/SKILL.md +3 -2
  38. package/skills/session-end/SKILL.md +3 -2
  39. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  40. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  41. package/skills/session-end/references/session-summary-template.md +4 -0
  42. package/skills/session-start/SKILL.md +1 -1
  43. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  44. package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
  45. package/AGENTS.md +0 -207
@@ -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
  }
@@ -1,19 +1,67 @@
1
1
  #!/usr/bin/env node
2
2
  /** Stdin-only boundary for commands/session.md; never executes task context. */
3
- import { readFileSync } from 'node:fs';
3
+ import { accessSync, constants, readFileSync, statSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
4
5
  import { parseArgs } from 'node:util';
5
6
  import { resolveSessionInvocation } from './lib/session-invocation.mjs';
7
+ import { readCanonicalSessions } from './lib/sessions-canonical.mjs';
8
+ import { isRealSession } from './lib/session-schema/filters.mjs';
9
+ import { resolveEventsLedgerRoot } from './lib/platform.mjs';
6
10
 
7
- const HELP = `Usage: node scripts/resolve-session-invocation.mjs [--json] < arguments.txt
11
+ /**
12
+ * One ledger: `false` when absent, `undefined` when present but not a readable
13
+ * file, else whether it holds a real session.
14
+ *
15
+ * @param {string} root
16
+ * @returns {boolean|undefined}
17
+ */
18
+ function ledgerHasHistory(root) {
19
+ const file = join(root, '.orchestrator', 'metrics', 'sessions.jsonl');
20
+ try {
21
+ if (!statSync(file).isFile()) return undefined;
22
+ accessSync(file, constants.R_OK);
23
+ } catch (error) {
24
+ return error?.code === 'ENOENT' ? false : undefined;
25
+ }
26
+ return readCanonicalSessions({ filePath: file }).some(isRealSession);
27
+ }
28
+
29
+ /**
30
+ * Whether the repo holds a real session of its own: `true`/`false`, or
31
+ * `undefined` when that cannot be measured. sessions.jsonl is gitignored, so a
32
+ * linked worktree also reads its main checkout's ledger (`resolveEventsLedgerRoot`);
33
+ * one with history in either place has history. A ledger that exists but
34
+ * cannot be read is "not measured", never "no history".
35
+ *
36
+ * @param {string} repoRoot
37
+ * @returns {boolean|undefined}
38
+ */
39
+ function measureHistory(repoRoot) {
40
+ const roots = [...new Set([resolve(repoRoot), resolveEventsLedgerRoot(repoRoot)])];
41
+ let unmeasured = false;
42
+ for (const root of roots) {
43
+ const has = ledgerHasHistory(root);
44
+ if (has === true) return true;
45
+ if (has === undefined) unmeasured = true;
46
+ }
47
+ return unmeasured ? undefined : false;
48
+ }
49
+
50
+ const HELP = `Usage: node scripts/resolve-session-invocation.mjs [--json] [--repo-root <dir>] < arguments.txt
8
51
 
9
52
  Read the complete /session argument text from UTF-8 stdin.
10
53
  Resolve the leading housekeeping, feature, deep or ultradeep token; preserve
11
54
  the rest as task context. Empty input defaults to deep. An invalid mode emits
12
55
  a warning and resolves to deep, as the session command has always specified.
56
+ A bare /session in a repo whose .orchestrator/metrics/sessions.jsonl holds no
57
+ real session adds defaultReason "no-session-history": ask for task and mode
58
+ before starting. A linked git worktree reads its main checkout's ledger; an
59
+ unreadable ledger counts as not measured and adds nothing.
13
60
 
14
- --json Emit one JSON object instead of a human-readable summary
15
- --help Show this help
16
- --version Show the plugin version
61
+ --json Emit one JSON object instead of a human-readable summary
62
+ --repo-root <dir> Project root whose session history is read (default: cwd)
63
+ --help Show this help
64
+ --version Show the plugin version
17
65
 
18
66
  Example: node scripts/resolve-session-invocation.mjs --json < arguments.txt
19
67
  Write arguments.txt with a file tool; do not interpolate user text into a shell.
@@ -23,7 +71,12 @@ Exit codes: 0 resolved (including fallback), 1 bad CLI arguments, 2 stdin I/O er
23
71
  let options;
24
72
  try {
25
73
  options = parseArgs({
26
- options: { json: { type: 'boolean' }, help: { type: 'boolean' }, version: { type: 'boolean' } },
74
+ options: {
75
+ json: { type: 'boolean' },
76
+ 'repo-root': { type: 'string' },
77
+ help: { type: 'boolean' },
78
+ version: { type: 'boolean' },
79
+ },
27
80
  allowPositionals: false,
28
81
  }).values;
29
82
  } catch (error) {
@@ -45,7 +98,10 @@ if (options.help) {
45
98
  process.stderr.write('session-invocation: could not read UTF-8 stdin\n');
46
99
  process.exit(2);
47
100
  }
48
- const resolved = resolveSessionInvocation(input);
101
+ // History is read only for a bare /session: an explicit mode never needs it.
102
+ const hasHistory =
103
+ input.trim() === '' ? measureHistory(options['repo-root'] || process.cwd()) : undefined;
104
+ const resolved = resolveSessionInvocation(input, { hasHistory });
49
105
  if (resolved.invalidMode !== undefined) {
50
106
  process.stderr.write(
51
107
  `Invalid session type '${resolved.invalidMode}'. Valid types: housekeeping, feature, deep (alias: ultradeep). Falling back to deep.\n`,
@@ -54,6 +110,6 @@ if (options.help) {
54
110
  process.stdout.write(
55
111
  options.json
56
112
  ? `${JSON.stringify(resolved)}\n`
57
- : `Session type: ${resolved.sessionType}${resolved.profile ? ` (profile: ${resolved.profile})` : ''}\nTask context: ${resolved.context}\n`,
113
+ : `Session type: ${resolved.sessionType}${resolved.profile ? ` (profile: ${resolved.profile})` : ''}${resolved.defaultReason ? ` (default: ${resolved.defaultReason})` : ''}\nTask context: ${resolved.context}\n`,
58
114
  );
59
115
  }
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { execFile } from 'node:child_process';
7
+ import { promisify, parseArgs } from 'node:util';
8
+ import { measure } from './lib/so-hud/runtime.mjs';
9
+ import { isMainModule } from './lib/is-main-module.mjs';
10
+ const exec = promisify(execFile);
11
+ const SO_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
12
+
13
+ export async function main(argv, deps = {}) {
14
+ const output = deps.output ?? (s => process.stdout.write(s));
15
+ const error = deps.error ?? (s => process.stderr.write(s));
16
+ let values;
17
+ try {
18
+ ({ values } = parseArgs({ args: argv, options: { json: { type: 'boolean' }, 'repo-root': { type: 'string' }, 'register-root': { type: 'boolean' }, help: { type: 'boolean' } }, allowPositionals: false }));
19
+ if (values.help) {
20
+ output('so-hud --json [--repo-root <path>] [--register-root]\nRead-only HUD JSON v1. --register-root registers this SO installation (0600).\nDefault repo: git toplevel of cwd. Exit 0: measured; 2: invocation/registration error; 6: repository unavailable.\n');
21
+ return 0;
22
+ }
23
+ if (!values.json && !values['register-root']) throw new Error('--json required');
24
+ } catch { error('so-hud: --json erforderlich; siehe --help\n'); return 2; }
25
+ if (values['register-root']) {
26
+ try {
27
+ const dir = path.join(deps.home ?? os.homedir(), '.config/session-orchestrator/hud');
28
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
29
+ await fs.chmod(dir, 0o700);
30
+ const target = path.join(dir, 'so-root');
31
+ const temp = `${target}.${process.pid}.tmp`;
32
+ try { await fs.writeFile(temp, `${SO_ROOT}\n`, { mode: 0o600, flag: 'wx' }); await fs.rename(temp, target); }
33
+ finally { await fs.unlink(temp).catch(() => {}); }
34
+ } catch { error('so-hud: Registrierung fehlgeschlagen\n'); return 2; }
35
+ if (!values.json) return 0;
36
+ }
37
+ let root;
38
+ try {
39
+ root = values['repo-root'] ? path.resolve(values['repo-root']) : (await (deps.exec ?? exec)('git', ['rev-parse', '--show-toplevel'], { timeout: 700 })).stdout.trim();
40
+ if (!root || !(await fs.stat(root)).isDirectory()) throw new Error('missing');
41
+ } catch { error('so-hud: Repo nicht ermittelbar\n'); return 6; }
42
+ try { output(`${JSON.stringify(await (deps.measure ?? measure)(root))}\n`); return 0; }
43
+ catch { error('so-hud: Messung fehlgeschlagen\n'); return 6; }
44
+ }
45
+ if (isMainModule(import.meta.url)) process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ /** session-start Phase 1.5 Idle Reset (#1560): rewrite a completed STATE.md to idle, under the STATE.md lock. */
3
+ import { readFileSync, statSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+ import { resetStateMdToIdleOnDisk } from './lib/state-md/idle-reset.mjs';
7
+
8
+ const HELP = `Usage: node scripts/state-md-idle-reset.mjs [--repo-root <dir>] [--json]
9
+
10
+ Reset the repo's STATE.md to idle when its status is completed (session-start
11
+ Phase 1.5, Idle Reset rules 1-7): status idle, current-wave 0, Wave History
12
+ demoted into Previous Session, Deviations emptied, scope-baseline-* keys
13
+ deleted. The five recommendation keys stay in the frontmatter so the Phase 7.5
14
+ Mode-Selector can read them. Any other status is a no-op; reasons:
15
+ status-not-completed, unparseable, missing (no or empty STATE.md).
16
+
17
+ --repo-root <dir> Repository root holding the state dir (default: cwd)
18
+ --json Emit {"changed", "reason", "path"} instead of one line
19
+ --help Show this help
20
+ --version Show the plugin version
21
+
22
+ Example: node scripts/state-md-idle-reset.mjs --json
23
+ Exit codes: 0 reset or no-op, 1 bad CLI arguments,
24
+ 2 I/O error, --repo-root not a directory, or a write the
25
+ STATE.md guards refused
26
+ `;
27
+
28
+ /** Reasons under which writeStateMd refused a wanted write. */
29
+ const REFUSED = new Set(['size-ceiling', 'frontmatter-unsafe']);
30
+
31
+ let options;
32
+ try {
33
+ options = parseArgs({
34
+ options: {
35
+ 'repo-root': { type: 'string' },
36
+ json: { type: 'boolean' },
37
+ help: { type: 'boolean' },
38
+ version: { type: 'boolean' },
39
+ },
40
+ allowPositionals: false,
41
+ }).values;
42
+ } catch (error) {
43
+ process.stderr.write(`state-md-idle-reset: ${error.message}\n`);
44
+ process.exit(1);
45
+ }
46
+
47
+ if (options.help) {
48
+ process.stdout.write(HELP);
49
+ } else if (options.version) {
50
+ process.stdout.write(
51
+ `${JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version}\n`,
52
+ );
53
+ } else {
54
+ const repoRoot = resolve(options['repo-root'] ?? process.cwd());
55
+ // The STATE.md lock would otherwise create <repoRoot>/.orchestrator/ for a
56
+ // root that does not exist and report a harmless-looking `missing`.
57
+ let rootStat;
58
+ try {
59
+ rootStat = statSync(repoRoot, { throwIfNoEntry: false });
60
+ } catch {
61
+ rootStat = undefined; // EACCES / ELOOP: an I/O error, exit 2 below
62
+ }
63
+ if (!rootStat?.isDirectory()) {
64
+ process.stderr.write(`state-md-idle-reset: --repo-root is not a directory: ${repoRoot}\n`);
65
+ process.exit(2);
66
+ }
67
+ let result;
68
+ try {
69
+ result = await resetStateMdToIdleOnDisk(repoRoot);
70
+ } catch (error) {
71
+ process.stderr.write(`state-md-idle-reset: ${error.message}\n`);
72
+ process.exit(2);
73
+ }
74
+ const reason = result.reason ?? null;
75
+ process.stdout.write(
76
+ options.json
77
+ ? `${JSON.stringify({ changed: result.changed, reason, path: result.path })}\n`
78
+ : `${result.changed ? 'reset to idle' : `unchanged (${reason})`}: ${result.path}\n`,
79
+ );
80
+ if (REFUSED.has(reason)) process.exit(2);
81
+ }
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * vault-orphans.mjs - CLI for `findVaultOrphans` (#1563, session-end Phase 3.7e).
4
+ *
5
+ * Names the untracked files in the Meta-Vault modified since a given instant
6
+ * (the session's start). Read-only: it never commits, moves or deletes anything
7
+ * in the vault. See scripts/lib/session-end/vault-orphans.mjs for the design.
8
+ *
9
+ * USAGE
10
+ * node scripts/vault-orphans.mjs --since <ISO> [--vault-dir <dir>] [--json]
11
+ *
12
+ * EXIT CODES
13
+ * 0 measured (including zero candidates)
14
+ * 1 bad arguments
15
+ * 2 could not measure (no vault dir resolvable, not a git repo, git failed)
16
+ */
17
+
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+ import { parseArgs } from 'node:util';
21
+
22
+ import { findVaultOrphans } from './lib/session-end/vault-orphans.mjs';
23
+ import { _parseVaultIntegration } from './lib/config/vault-integration.mjs';
24
+ import { loadHostPaths } from './lib/config/host-paths.mjs';
25
+ import { resolveVaultDir } from './lib/config.mjs';
26
+ import { expandTilde } from './lib/common.mjs';
27
+ import { isMainModule } from './lib/is-main-module.mjs';
28
+
29
+ const HELP = `vault-orphans.mjs - name untracked vault files written since a session started (#1563)
30
+
31
+ USAGE
32
+ node scripts/vault-orphans.mjs --since <ISO> [--vault-dir <dir>] [--json]
33
+
34
+ DESCRIPTION
35
+ Runs the read-only \`git -C <vault-dir> ls-files --others --exclude-standard\`
36
+ and lists every untracked file whose mtime is at or after --since. Files whose
37
+ frontmatter names one of this plugin's generators (\`_generator:
38
+ session-orchestrator-...\`) are left out: their writer owns them.
39
+
40
+ The results are CANDIDATES: mtime is a hint, not proof that this session
41
+ wrote the file. Nothing is committed, moved or deleted.
42
+
43
+ OPTIONS
44
+ --since <ISO> Required. ISO 8601 instant with Z or an offset, e.g. the
45
+ STATE.md \`started_at\` (2026-10-11T08:00:00.000Z).
46
+ --vault-dir <dir> Vault working tree. Default: the host-local resolution
47
+ SO_VAULT_DIR > owner.yaml > committed
48
+ vault-integration.vault-dir in ./CLAUDE.md or ./AGENTS.md.
49
+ --json Print the result object as JSON on stdout.
50
+ --help Show this help.
51
+ --version Print the plugin version.
52
+
53
+ EXIT CODES
54
+ 0 measured, including zero candidates
55
+ 1 bad arguments
56
+ 2 could not measure (no vault dir, not a git repo, git failed)
57
+
58
+ EXAMPLE
59
+ node scripts/vault-orphans.mjs --since 2026-10-11T08:00:00.000Z --json
60
+ `;
61
+
62
+ /** ISO 8601 with an explicit zone - a zone-less value would be read as local time. */
63
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})$/;
64
+
65
+ /**
66
+ * Resolve the vault dir like the other vault writers: SO_VAULT_DIR > owner.yaml
67
+ * `vault-dirs:` cwd match > owner.yaml `paths.vault-dir` > committed value.
68
+ *
69
+ * @returns {string|null}
70
+ */
71
+ function resolveDefaultVaultDir() {
72
+ let content = '';
73
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
74
+ const p = join(process.cwd(), name);
75
+ if (existsSync(p)) {
76
+ try {
77
+ content = readFileSync(p, 'utf8');
78
+ break;
79
+ } catch {
80
+ /* try the next file */
81
+ }
82
+ }
83
+ }
84
+ const vi = _parseVaultIntegration(content);
85
+ const value = resolveVaultDir(vi['vault-dir'], loadHostPaths()).value;
86
+ return typeof value === 'string' && value.trim() !== '' ? expandTilde(value) : null;
87
+ }
88
+
89
+ /** @param {{status: 'ok', candidates: Array<{path: string}>, total: number, truncated: boolean, untracked: number}} r */
90
+ function renderHuman(r, since) {
91
+ const lines = [];
92
+ if (r.total === 0) {
93
+ lines.push(`vault-orphans: no untracked vault file modified since ${since} (${r.untracked} untracked in total)`);
94
+ } else {
95
+ lines.push(
96
+ `vault-orphans: ${r.total} untracked vault file(s) modified since ${since} - candidates only, ` +
97
+ `mtime does not prove this session wrote them (${r.untracked} untracked in total)`,
98
+ );
99
+ for (const c of r.candidates) lines.push(` ${c.path}`);
100
+ if (r.truncated) lines.push(` ... showing ${r.candidates.length} of ${r.total}`);
101
+ }
102
+ return `${lines.join('\n')}\n`;
103
+ }
104
+
105
+ /**
106
+ * CLI entry.
107
+ *
108
+ * @param {string[]} [argv]
109
+ * @param {{stdout: {write: Function}, stderr: {write: Function}}} [io]
110
+ * @returns {number} exit code
111
+ */
112
+ export function main(argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
113
+ let values;
114
+ try {
115
+ ({ values } = parseArgs({
116
+ args: argv,
117
+ options: {
118
+ since: { type: 'string' },
119
+ 'vault-dir': { type: 'string' },
120
+ json: { type: 'boolean', default: false },
121
+ help: { type: 'boolean', default: false },
122
+ version: { type: 'boolean', default: false },
123
+ },
124
+ strict: true,
125
+ allowPositionals: false,
126
+ }));
127
+ } catch (err) {
128
+ io.stderr.write(`vault-orphans: ${err.message}\nRun with --help for usage.\n`);
129
+ return 1;
130
+ }
131
+
132
+ if (values.help) {
133
+ io.stdout.write(HELP);
134
+ return 0;
135
+ }
136
+ if (values.version) {
137
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
138
+ io.stdout.write(`${pkg.version}\n`);
139
+ return 0;
140
+ }
141
+
142
+ const since = values.since;
143
+ const sinceMs = typeof since === 'string' && ISO_RE.test(since) ? Date.parse(since) : NaN;
144
+ if (!Number.isFinite(sinceMs)) {
145
+ io.stderr.write('vault-orphans: --since <ISO> is required, e.g. 2026-10-11T08:00:00.000Z\n');
146
+ return 1;
147
+ }
148
+ if (values['vault-dir'] !== undefined && values['vault-dir'].trim() === '') {
149
+ io.stderr.write('vault-orphans: --vault-dir must not be empty\n');
150
+ return 1;
151
+ }
152
+
153
+ const vaultDir =
154
+ values['vault-dir'] !== undefined ? expandTilde(values['vault-dir']) : resolveDefaultVaultDir();
155
+ const result = findVaultOrphans({ vaultDir, sinceMs });
156
+
157
+ if (result.status !== 'ok') {
158
+ io.stderr.write(
159
+ `vault-orphans: could not measure (${result.reason}${result.detail ? `: ${result.detail}` : ''})\n`,
160
+ );
161
+ if (values.json) io.stdout.write(`${JSON.stringify(result)}\n`);
162
+ return 2;
163
+ }
164
+
165
+ io.stdout.write(values.json ? `${JSON.stringify(result)}\n` : renderHuman(result, since));
166
+ return 0;
167
+ }
168
+
169
+ if (isMainModule(import.meta.url)) {
170
+ process.exitCode = main();
171
+ }
@@ -75,7 +75,14 @@ die vom Lease-Validator akzeptierte `adresse` einer aktiven Lease, sonst `null`
75
75
  Lease, kein Eintrag oder ein abgelehnter Wert — nie ein ungeprüfter String). `fallback_due_at` ist
76
76
  der Zeitpunkt, ab dem die Standard-Auflagen (unten) gelten, in der Form von `zeit` (Code:
77
77
  `fallbackDueAt()`); er steht nur auf stdout, die Check-in-Datei behält ihr Schema. Exit 0
78
- geschrieben, 2 ungültige Eingabe (Fehlerliste auf stderr, keine Datei), 1 Schreibfehler.
78
+ geschrieben, 2 ungültige Eingabe (Fehlerliste auf stderr, keine Datei) oder semantische Session-ID
79
+ von einem anderen Repo belegt (eine Zeile auf stderr mit JSON-escaped Repo-Namen, fremde Datei unverändert,
80
+ tmp entfernt, kein Event), 1 Schreibfehler.
81
+ Vor dem `rename` lehnt der Schreiber ein vorhandenes String-`repo` ungleich dem eigenen ab;
82
+ gleiches `repo`, unlesbare Dateien und ungültiges JSON dürfen überschrieben werden.
83
+ Die Prüfung folgt keinen Symlinks, öffnet nichtblockierend und liest nur reguläre Dateien
84
+ bis 64 KiB. Symlinks, andere Dateitypen, größere Dateien oder fehlende sichere Open-Flags
85
+ gelten für diese Prüfung als unlesbar (fail-open zum `rename`).
79
86
 
80
87
  | Feld | Inhalt |
81
88
  |---|---|
@@ -130,9 +137,24 @@ aktiven Navigator ist `fallback_due_at` gleich `zeit`, die Auflagen gelten also
130
137
  Lesende Arbeit und Schreiben im eigenen Worktree laufen währenddessen weiter; der Rückfall ist ein
131
138
  Dateistand mit Frist, kein Warten auf eine Antwort.
132
139
 
140
+ ## Verhalten ohne Flotte
141
+
142
+ Flottenintegration liegt vor, wenn `NAVIGATOR_CONFIG_DIR` gesetzt und nicht leer ist (ausdrückliches
143
+ Opt-in, auch vor dem ersten Anlegen) oder `~/.config/navigator/` als Verzeichnis existiert (Code:
144
+ `fleetIntegrationEnabled()`, #1559). Ein Flottenhost hat das Verzeichnis, weil der Koordinator dort
145
+ die Lease schreibt. Ohne Flottenintegration validiert `fleet-checkin.mjs` die Eingabe wie immer
146
+ (ungültig → Exit 2), schreibt dann aber nichts, legt kein Verzeichnis an, emittiert kein Event und
147
+ meldet `{"ok":true,"skipped":"no-fleet",…}`. Es gelten keine Auflagen, auch keine Standard-Auflagen:
148
+ ohne Flotte gibt es nichts zu koordinieren. Die Navigator-Zeile im Sessionstart-Banner entfällt
149
+ ebenso. Das gilt auch für `plattform: kopflos`: der Datensatz beweist keinen Koordinator, und ein
150
+ Schreiben legte das Verzeichnis an, das danach jede Solo-Session zur Flotte macht. Ein kopfloser
151
+ Lauf ohne Flottenintegration wendet stattdessen die Standard-Auflagen sofort an — er kann niemanden
152
+ fragen. Ein Flotten-Starter, der auf einem frischen Remote-Host startet, setzt deshalb
153
+ `NAVIGATOR_CONFIG_DIR` oder legt das Verzeichnis vorher an.
154
+
133
155
  ## Verhalten ohne Navigator
134
156
 
135
- Lease `none` oder `unreadable`: die Check-in-Datei wird **trotzdem** geschrieben (sie ist der
157
+ Flottenintegration vorhanden, Lease `none` oder `unreadable`: die Check-in-Datei wird **trotzdem** geschrieben (sie ist der
136
158
  zählbare Beleg), die Standard-Auflagen gelten sofort, es geht keine Nachricht ab. Kopflose Läufe
137
159
  sind per Nachricht nicht erreichbar und nutzen dieselben Dateien: vor jeder Welle `auflagen/` lesen.
138
160
 
@@ -40,7 +40,7 @@ The `session-type: none` + `status: idle` combination is used only for bootstrap
40
40
  | `## Wave History` | Completed wave records | wave-executor (post-wave) |
41
41
  | `## Deviations` | Plan adaptation log | wave-executor (step 3) |
42
42
  | `## What Not To Retry` | Failed/abandoned approaches not to repeat (#623) | session-end (Phase 1.6) |
43
- | `## Open Questions` | Unresolved user-facing questions (agent → gate → next session) | wave-executor (inter-wave) + session-end (marks answered) |
43
+ | `## Open Questions` | Unresolved user-facing questions (agent → gate → next session) | wave-executor (inter-wave) + session-end (marks answered; appends one vault-orphan entry at Phase 3.7e, #1563) |
44
44
 
45
45
  Wave History lines MAY include a `→ issue #NNN` suffix (or `→ existing #NNN` when a duplicate was detected) for SPIRAL/FAILED agents, linking to the auto-created carryover issue (#261). This is optional and backward-compatible; readers that do not recognize the notation can skip it. Session-end Phase 1.6 uses the presence of this suffix to decide whether to retro-file a carryover as a fallback safety net.
46
46
 
@@ -139,8 +139,8 @@ growing across sessions is the CCU-009 anti-pattern.
139
139
  | Skill | Access | Operations |
140
140
  |-------|--------|------------|
141
141
  | **wave-executor** | Read + Write (owner) | Creates STATE.md (Pre-Wave 1b), updates after each wave (current-wave, Wave History, Deviations); appends deduped `## Open Questions` at inter-wave checkpoints via `appendOpenQuestionOnDisk` (see `wave-loop.md` § 3e + Post-Wave step 6). |
142
- | **session-end** | Read + Status-only write | Reads for metrics extraction (Phase 1.7), sets `status: completed` (Phase 3.4). Exception: only fields modified are `status` in frontmatter and marking entries answered in `## Open Questions` via `markOpenQuestionAnsweredOnDisk` (Close Handover-Alignment-Gate). |
143
- | **session-start** | Read + conditional reset | Reads for continuity checks (Phase 1.5): inspects `status` field to detect crashed/paused sessions. Surfaces `## What Not To Retry` as a forced-read HISTORICAL block (Phase 6.5.1). May reset STATE.md to idle at the boundary between a completed session and a new session — only when prior `status: completed`. The reset clears `current-wave` (→ 0), sets `status: idle`, demotes `## Wave History` into `## Previous Session`, and empties `## Deviations` — but PRESERVES `## What Not To Retry` (cross-session continuity, #623) and `## Open Questions` (Close Handover-Alignment-Gate, PRD 2026-07-07). Never resets on `active` or `paused` (those paths are user-interactive). |
142
+ | **session-end** | Read + Status-only write | Reads for metrics extraction (Phase 1.7), sets `status: completed` (Phase 3.4). Exception: only fields modified are `status` in frontmatter and marking entries answered in `## Open Questions` via `markOpenQuestionAnsweredOnDisk` (Close Handover-Alignment-Gate), plus at most one appended `## Open Questions` entry naming vault-orphan candidates via `appendOpenQuestionOnDisk` (Phase 3.7e, #1563). |
143
+ | **session-start** | Read + conditional reset | Reads for continuity checks (Phase 1.5): inspects `status` field to detect crashed/paused sessions. Surfaces `## What Not To Retry` as a forced-read HISTORICAL block (Phase 6.5.1). May reset STATE.md to idle at the boundary between a completed session and a new session — only when prior `status: completed`, via `scripts/state-md-idle-reset.mjs` (#1560). The reset keeps the five recommendation keys in the frontmatter for the Phase 7.5 Mode-Selector, clears `current-wave` (→ 0), sets `status: idle`, demotes `## Wave History` into `## Previous Session`, and empties `## Deviations` — but PRESERVES `## What Not To Retry` (cross-session continuity, #623) and `## Open Questions` (Close Handover-Alignment-Gate, PRD 2026-07-07). Never resets on `active` or `paused` (those paths are user-interactive). |
144
144
  | **evolve** | Read-only | Reads `## Deviations` section for deviation pattern extraction (Step 2.2, pattern 5) |
145
145
 
146
146
  ### Shared-File Single-Writer Rule (`isolation: none` waves)
@@ -249,8 +249,9 @@ import { detectPeers } from '$PLUGIN_ROOT/scripts/lib/session-registry.mjs';
249
249
  const flags = parseFlags(process.argv.slice(2));
250
250
 
251
251
  const modeSelector = async () => {
252
- // Each iteration rebuilds signals from current disk state. STATE.md will be
253
- // freshly idle-reset by the previous /close, sessions.jsonl will have the
252
+ // Each iteration rebuilds signals from current disk state. STATE.md will
253
+ // carry the recommendation the previous /close wrote (the idle reset at the
254
+ // next session-start keeps those keys, #1560), sessions.jsonl will have the
254
255
  // new tail entry, etc. This is the contract: live signals every iteration.
255
256
  // `repoRoot` is passed explicitly (#1071) rather than left to the helper's
256
257
  // cwd default. Do NOT hand-write a `backlogLimit` here — the window default