session-orchestrator 5.10.0 → 5.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +51 -0
  6. package/README.md +22 -11
  7. package/SECURITY.md +1 -1
  8. package/agents/code-implementer.md +7 -1
  9. package/commands/session.md +3 -1
  10. package/docs/agent-authoring.md +2 -0
  11. package/docs/events-schema.md +2 -1
  12. package/docs/fleet-checkin-audit.md +172 -0
  13. package/docs/install.md +12 -8
  14. package/docs/role-profile-contract.md +139 -0
  15. package/docs/scope-collision-guard.md +3 -0
  16. package/docs/session-config-reference.md +7 -7
  17. package/docs/session-config-template.md +2 -2
  18. package/evals/instruction-ablation/run.mjs +11 -6
  19. package/hooks/_lib/hook-import-set.json +27 -2
  20. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +8 -1
  24. package/hooks/on-session-start.mjs +26 -10
  25. package/hooks/post-bash-write-verify.mjs +16 -3
  26. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  27. package/hooks/subagent-telemetry.mjs +8 -3
  28. package/hooks/wave-scope-commit-guard.mjs +4 -1
  29. package/package.json +1 -2
  30. package/rules/opt-in-stack/security-web.md +1 -1
  31. package/scripts/emit-session.mjs +13 -0
  32. package/scripts/fleet-checkin-audit.mjs +138 -0
  33. package/scripts/fleet-checkin.mjs +61 -3
  34. package/scripts/lib/common.mjs +2 -4
  35. package/scripts/lib/config/remote-hosts.mjs +2 -2
  36. package/scripts/lib/config.mjs +1 -1
  37. package/scripts/lib/context-overhead-format.mjs +57 -0
  38. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  39. package/scripts/lib/fleet-protocol.mjs +17 -0
  40. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  41. package/scripts/lib/memory-proposals/store.mjs +245 -9
  42. package/scripts/lib/project-hygiene.mjs +37 -0
  43. package/scripts/lib/role-profile/schema.mjs +219 -0
  44. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  45. package/scripts/lib/session-invocation.mjs +14 -3
  46. package/scripts/lib/session-lock.mjs +57 -10
  47. package/scripts/lib/session-record-repair.mjs +63 -30
  48. package/scripts/lib/session-registry.mjs +14 -4
  49. package/scripts/lib/session-schema/constants.mjs +8 -0
  50. package/scripts/lib/session-schema/validator.mjs +33 -0
  51. package/scripts/lib/so-hud/collect.mjs +130 -0
  52. package/scripts/lib/so-hud/runtime.mjs +76 -0
  53. package/scripts/lib/state-md/idle-reset.mjs +239 -0
  54. package/scripts/lib/state-md/recommendations.mjs +26 -4
  55. package/scripts/lib/subagents-schema.mjs +9 -0
  56. package/scripts/lib/telemetry/pricing.mjs +11 -0
  57. package/scripts/lib/vault-mirror/namespace.mjs +3 -2
  58. package/scripts/lib/vault-mirror/process.mjs +143 -40
  59. package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
  60. package/scripts/lib/vault-mirror/utils.mjs +27 -13
  61. package/scripts/lib/vault-status/board-writer.mjs +32 -4
  62. package/scripts/measure-context-overhead.sh +4 -21
  63. package/scripts/release.mjs +359 -11
  64. package/scripts/resolve-session-invocation.mjs +64 -8
  65. package/scripts/run-quality-gate.mjs +47 -0
  66. package/scripts/so-hud.mjs +45 -0
  67. package/scripts/state-md-idle-reset.mjs +81 -0
  68. package/scripts/validate-wave-scope.mjs +59 -6
  69. package/scripts/vault-orphans.mjs +171 -0
  70. package/skills/_shared/fleet-protocol.md +24 -2
  71. package/skills/_shared/state-ownership.md +3 -3
  72. package/skills/autopilot/SKILL.md +3 -2
  73. package/skills/claude-md-drift-check/checker.mjs +8 -6
  74. package/skills/npm-publish/SKILL.md +3 -3
  75. package/skills/release/SKILL.md +2 -2
  76. package/skills/repo-audit/SKILL.md +1 -1
  77. package/skills/session-end/SKILL.md +3 -2
  78. package/skills/session-end/metrics-collection.md +2 -2
  79. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  80. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  81. package/skills/session-end/references/session-summary-template.md +4 -0
  82. package/skills/session-end/session-metrics-write.md +1 -1
  83. package/skills/session-start/SKILL.md +1 -1
  84. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  85. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  86. package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
  87. package/skills/wave-executor/circuit-breaker.md +1 -0
  88. package/AGENTS.md +0 -205
@@ -226,6 +226,49 @@ function failedFilesFromGateStdout(stdout) {
226
226
  return named.length > 0 ? named : null;
227
227
  }
228
228
 
229
+ /**
230
+ * The reporting variants — they exit 0 even after a failing check, so their
231
+ * event NAME never carries the check verdict (#1459). `full-gate` exits non-zero
232
+ * on a failing check, so the name already says it there.
233
+ */
234
+ const REPORTING_VARIANTS = new Set(['baseline', 'incremental', 'per-file']);
235
+
236
+ /**
237
+ * Did any check of a reporting-variant envelope fail? (#1487 point 3)
238
+ *
239
+ * Three states, never two — the same absent-is-not-zero contract as `counts`:
240
+ * - `true` — `typecheck` or `test` carries `status: 'fail'` (a killed command
241
+ * is published as `fail` too, see `runCheck`);
242
+ * - `false` — both command objects are present, neither is stubbed, at least
243
+ * one actually `pass`ed and none failed — the envelope MEASURED
244
+ * that nothing failed;
245
+ * - `null` — the envelope is unparseable or a command object is missing or
246
+ * carries an unknown status, a command is a stub (echo/noop
247
+ * reports `pass` without running), or both were skipped. The
248
+ * caller then OMITS the key: a
249
+ * default `false` would read as "measured green" for a run that
250
+ * measured nothing.
251
+ *
252
+ * Never throws.
253
+ *
254
+ * @param {string} stdout — the gate sub-script's captured stdout.
255
+ * @returns {boolean|null}
256
+ */
257
+ function checkFailedFromGateStdout(stdout) {
258
+ if (typeof stdout !== 'string' || !stdout.trim()) return null;
259
+ let parsed;
260
+ try {
261
+ parsed = JSON.parse(stdout);
262
+ } catch {
263
+ return null;
264
+ }
265
+ const statuses = ['typecheck', 'test'].map((name) => parsed?.[name]?.status);
266
+ if (statuses.includes('fail')) return true;
267
+ if (parsed?.stubbed?.typecheck || parsed?.stubbed?.test) return null;
268
+ if (!statuses.every((st) => st === 'pass' || st === 'skip')) return null;
269
+ return statuses.includes('pass') ? false : null;
270
+ }
271
+
229
272
  /**
230
273
  * Validate and resolve the `--ledger-root` flag (see the telemetry block below).
231
274
  *
@@ -570,12 +613,16 @@ async function main() {
570
613
  // record that run as `passed`. Their exit code stays 0 on purpose: it has
571
614
  // never carried the check verdict there (a failing check exits 0 too).
572
615
  const failed = exitCode !== 0 || killFields.timed_out === true;
616
+ // #1487 point 3 (owner decision, option a): the reporting variants keep
617
+ // `.passed` and their exit code, but carry the check verdict as a field.
618
+ const checkFailed = REPORTING_VARIANTS.has(variant) ? checkFailedFromGateStdout(gateStdout) : null;
573
619
  await emitEvent(
574
620
  `orchestrator.quality_gate.${failed ? 'failed' : 'passed'}`,
575
621
  {
576
622
  variant,
577
623
  exit_code: exitCode,
578
624
  ...killFields,
625
+ ...(checkFailed !== null ? { check_failed: checkFailed } : {}),
579
626
  ...(counts ? { counts } : {}),
580
627
  ...(failedFiles ? { failed_files: failedFiles } : {}),
581
628
  ...(waveNumber !== null ? { wave_number: waveNumber } : {}),
@@ -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
+ }
@@ -92,6 +92,7 @@ import {
92
92
  assertFileScopeSubset,
93
93
  assertTestSiblingCoverage,
94
94
  testSiblingExpansionApplies,
95
+ testSiblingsFor,
95
96
  TEST_SIBLING_EXPANSION_ROLES,
96
97
  findScopeCollisions,
97
98
  unionFileScopes,
@@ -663,6 +664,44 @@ function knownRepoFiles() {
663
664
  }
664
665
  }
665
666
 
667
+ /**
668
+ * WARN — never fail — when two agents' RAW scopes are disjoint but test-sibling
669
+ * expansion hands both the same synthesized glob (#1026 point 2, owner decision
670
+ * option a). `scripts/lib/foo.mjs` and `hooks/foo.mjs` share no file, yet both
671
+ * expand to `tests/**\/foo*.test.mjs`, so the union grants each agent the
672
+ * other's tests. Exit code is unchanged: the glob is a GRANT that may match no
673
+ * file at all, so failing the wave on it would block plans that never collide.
674
+ *
675
+ * `role` gates expansion exactly as `--union` does; `undefined` (sidecar-only
676
+ * `--no-manifest`, or a manifest without a role) checks anyway, because there
677
+ * the expansion that will run later is unknown and silence would hide it.
678
+ * Compares synthesized siblings only, by exact string — a sibling glob one agent
679
+ * DECLARED by hand is not matched (named ceiling; revisit if a wave plan is seen
680
+ * declaring sibling globs explicitly). Overlapping globs of DIFFERENT basenames
681
+ * (`foo*` also covers `foo-bar.test.mjs`) are not matched either.
682
+ *
683
+ * @param {Array<{id?: string, files: string[]}>} agentScopes
684
+ * @param {string|undefined} role
685
+ */
686
+ function warnSiblingGlobCollisions(agentScopes, role) {
687
+ if (role !== undefined && !testSiblingExpansionApplies({ role })) return;
688
+ const owners = new Map(); // sibling glob -> agent ids, first-seen order
689
+ agentScopes.forEach((rec, i) => {
690
+ const id = typeof rec.id === 'string' && rec.id.length > 0 ? rec.id : `<unnamed#${i}>`;
691
+ for (const glob of testSiblingsFor(rec.files)) {
692
+ const ids = owners.get(glob) ?? [];
693
+ if (!ids.includes(id)) ids.push(id);
694
+ owners.set(glob, ids);
695
+ }
696
+ });
697
+ for (const [glob, ids] of owners) {
698
+ if (ids.length < 2) continue;
699
+ warn(
700
+ `sibling-glob: agents ${ids.map((id) => `"${id}"`).join(', ')} are disjoint as declared but test-sibling expansion grants each of them ${glob} — split the same-basename files across waves or name the tests explicitly (#1026)`,
701
+ );
702
+ }
703
+ }
704
+
666
705
  /**
667
706
  * Assert that no file is claimed by two agents of the SAME wave (#1020).
668
707
  * Exits 1 with one message per collision (plus one per duplicate agent id);
@@ -670,8 +709,8 @@ function knownRepoFiles() {
670
709
  *
671
710
  * @param {string} sidecarPath
672
711
  */
673
- function assertDisjointOrDie(sidecarPath) {
674
- if (!reportScopeCollisions(sidecarPath)) process.exit(1);
712
+ function assertDisjointOrDie(sidecarPath, role) {
713
+ if (!reportScopeCollisions(sidecarPath, role)) process.exit(1);
675
714
  }
676
715
 
677
716
  /**
@@ -683,12 +722,17 @@ function assertDisjointOrDie(sidecarPath) {
683
722
  * @param {string} sidecarPath
684
723
  * @returns {boolean} true when the wave is disjoint
685
724
  */
686
- function reportScopeCollisions(sidecarPath) {
725
+ function reportScopeCollisions(sidecarPath, role) {
687
726
  const agentScopes = readAgentScopesOrDie(sidecarPath, '--assert-disjoint');
688
727
  const { ok, collisions, duplicateIds } = findScopeCollisions(agentScopes, {
689
728
  knownFiles: knownRepoFiles(),
690
729
  });
691
- if (ok) return true;
730
+ // Only a raw-disjoint wave gets the sibling hint: its text says the scopes
731
+ // are disjoint as declared, which is false once a real collision exists.
732
+ if (ok) {
733
+ warnSiblingGlobCollisions(agentScopes, role);
734
+ return true;
735
+ }
692
736
 
693
737
  // Duplicate ids FIRST: they are a malformed plan, and a reader who fixes them
694
738
  // may well change which collisions remain.
@@ -708,6 +752,15 @@ function reportScopeCollisions(sidecarPath) {
708
752
  return false;
709
753
  }
710
754
 
755
+ /**
756
+ * The manifest's `role` when it is a string, else `undefined` (unknown).
757
+ * @param {unknown} obj
758
+ * @returns {string|undefined}
759
+ */
760
+ function roleOf(obj) {
761
+ return obj !== null && typeof obj === 'object' && typeof obj.role === 'string' ? obj.role : undefined;
762
+ }
763
+
711
764
  /**
712
765
  * QUERY MODE (#1020): print `expandTestSiblings(unionFileScopes(scopes), {role})`
713
766
  * as JSON on stdout, using the MANIFEST'S own role. Replaces the manifest echo —
@@ -757,7 +810,7 @@ function validate(
757
810
  }
758
811
  // #1026.4 — the collision check reads only the sidecar, so a schema error
759
812
  // must not hide its findings: report both, exit 1 either way.
760
- if (assertDisjointPath) reportScopeCollisions(assertDisjointPath);
813
+ if (assertDisjointPath) reportScopeCollisions(assertDisjointPath, roleOf(obj));
761
814
  process.exit(1);
762
815
  }
763
816
 
@@ -771,7 +824,7 @@ function validate(
771
824
  // violates BOTH the subset relation and disjointness must keep the older,
772
825
  // byte-pinned subset message. Only ever ADDS a failure mode.
773
826
  if (assertDisjointPath) {
774
- assertDisjointOrDie(assertDisjointPath);
827
+ assertDisjointOrDie(assertDisjointPath, roleOf(obj));
775
828
  }
776
829
 
777
830
  // #1020 QUERY MODE — replaces the echo below; see the STDOUT CONTRACT note in
@@ -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
@@ -555,10 +555,11 @@ function buildSurfaceDescriptors(vaultDir, commandsDir) {
555
555
  id: 'test-count',
556
556
  noun: 'test files',
557
557
  actual: countTestFiles(vaultDir),
558
- // "N test files" — the README badge claims a runtime PASS count
558
+ // "N test files" or "N+ test files" (an explicit lower bound).
559
+ // The README badge claims a runtime PASS count
559
560
  // ("9303 tests"), which a static checker cannot derive, so we match only
560
- // the explicit file-count phrasing. No doc claims this today → skipped.
561
- claimRe: /\b(\d+)\s+test\s+files?\b/gi,
561
+ // the explicit file-count phrasing.
562
+ claimRe: /\b(\d+)(\+)?\s+test\s+files?\b/gi,
562
563
  skipMsg: 'test-count: no tests/ directory found',
563
564
  },
564
565
  ];
@@ -1831,7 +1832,7 @@ function main() {
1831
1832
  }
1832
1833
  }
1833
1834
 
1834
- // Surface-count family: one EXACT-count drift check per active surface.
1835
+ // Surface counts are exact, except explicit "N+ test files" lower bounds.
1835
1836
  // A surface only fires when the doc makes an explicit numeric claim that
1836
1837
  // its regex matches; an unclaimed surface produces no errors (skip).
1837
1838
  for (const surface of activeSurfaces) {
@@ -1840,10 +1841,11 @@ function main() {
1840
1841
  let m;
1841
1842
  while ((m = re.exec(line)) !== null) {
1842
1843
  const claimed = parseInt(m[1], 10);
1843
- if (claimed !== surface.actual) {
1844
+ const lowerBound = surface.id === 'test-count' && m[2] === '+';
1845
+ if (lowerBound ? surface.actual < claimed : claimed !== surface.actual) {
1844
1846
  const err = {
1845
1847
  check: surface.id, file: rel, line: lineNum,
1846
- message: `Narrative claims ${claimed} ${surface.noun} but actual on-disk count is ${surface.actual}`,
1848
+ message: `Narrative claims ${claimed}${lowerBound ? '+' : ''} ${surface.noun} but actual on-disk count is ${surface.actual}`,
1847
1849
  extracted: m[0],
1848
1850
  count: { surface: surface.id, actual: surface.actual, claimed },
1849
1851
  };
@@ -39,7 +39,7 @@ The script gates mechanics. These three are yours, and it will not make them for
39
39
 
40
40
  **2. What a leak means when one is found.** A hit from the leakage gate is not a pattern to silence. Decide which of two it is: a real leak (fix `package.json` `files`, re-pack, re-check) or genuine over-matching (fix `LEAKAGE_PATTERNS` in `scripts/release.mjs` **with a test**). There is no third option, and neither is "publish anyway and clean it up in the next version" — an npm publish is not revocable, and unpublishing burns the version number permanently. Operator handling detail: `docs/distribution/npm-publish-checklist.md` § 3.
41
41
 
42
- **3. When to abort instead of repair.** Abort — do not patch forward — when the failure is upstream of the target-confirmed npm receipt: a red preflight row, a lagging `github` mirror, CI not green on the exact commit **on either platform** (`--check` carries both `ci-green-on-head` for GitLab and `ci-green-on-head-github` for the mirror, whose macOS matrix leg has no GitLab equivalent), a dead token, or a publish that did not issue the target receipt. These are cheap to fix and re-run from the top. Repair-in-place is only appropriate *after* that receipt, where the version is already immutable: registry propagation, a missing GitHub release, or a lagging site deploy can be reconciled because npm already has the correct artifact. When `--publish` reports **Post-publish reconciliation required**, **do not rerun `--publish`**; repair the listed state directly. `skills/release/SKILL.md` § Abort criteria is the operative list.
42
+ **3. When to abort instead of repair.** Abort — do not patch forward — when the failure is upstream of the target-confirmed npm receipt: a red preflight row, a lagging `github` mirror, CI not green on the exact commit **on either platform** (`--check` carries both `ci-green-on-head` for GitLab and `ci-green-on-head-github` for the mirror, whose macOS matrix leg has no GitLab equivalent), a dead token, or a publish that did not issue the target receipt. These are cheap to fix and re-run from the top. Repair-in-place is only appropriate *after* that receipt, where the version is already immutable: registry availability (`--reconcile`), a missing GitHub release, or a lagging site deploy can be reconciled because npm already has the correct artifact. When `--publish` reports **Post-publish reconciliation required**, **do not rerun `--publish`**; repair the listed state directly. `skills/release/SKILL.md` § Abort criteria is the operative list.
43
43
 
44
44
  ## Failure-mode table
45
45
 
@@ -48,13 +48,13 @@ The script gates mechanics. These three are yours, and it will not make them for
48
48
  | `E403 ... Two-factor authentication or granular access token with bypass 2fa enabled is required` — no OTP prompt | Account has no 2FA enrolled AND token (if any) lacks Bypass-2FA | Create granular token with all four requirements above, or enroll 2FA |
49
49
  | Same E403 despite a fresh token | Token created without the Bypass-2FA checkbox, or Read-only, or package-scoped on a first publish | Re-create: RW + All packages + Bypass-2FA |
50
50
  | `npm whoami` silent or non-zero | Token expired, or `.env.local` missing | Re-create the token; do not proceed — the preflight fails this row on purpose |
51
- | `E404` on `npm view` after a target-confirmed publish receipt | Registry propagation (rare, seconds) | Let the script finish its tag/push/GitHub/site tail, then reconcile the registry result; do **not** rerun `--publish` |
51
+ | `E404` on `npm view <pkg>@<target>` after a target-confirmed publish receipt (npm answered HTTP 202) | The upload is **accepted, not yet available**. npm scans every new publish before it becomes installable: "typically around five minutes", "up to 15 minutes or more" at peak or depending on content/size, no service guarantee; the outcome may be published, **held for manual review**, or **blocked** (GitHub changelog 2026-07-28, read 2026-10-08, npm CLI 11.19.0). 5.10.0 stayed 404 for more than 25 minutes. A 404 alone does **not** tell you which of these applies — the package token cannot see the scan state | Let the script finish its tag/push/GitHub/site tail, then run `node scripts/release.mjs --reconcile --target <target>` (read-only, resumable; exit 0 only once installable). While still `pending`, check the owner-visible side: npm says a blocked package's publisher "may receive a notification with the option to appeal" — look at the npm account notifications/e-mail of the publishing account and the package page on npmjs.com. Do **not** rerun `--publish` and do not declare it blocked from the 404 |
52
52
  | `ENEEDAUTH` | No login/token at all | Token flow above, or `npm login` |
53
53
  | OTP prompt appears but flow is non-interactive (`!`-prefix, script) | No TTY for the prompt | Use the token flow, or a real terminal |
54
54
 
55
55
  ## Post-publish — the human half
56
56
 
57
- `--publish` attempts registry verification and polls the live site itself. A target-confirmed receipt plus a delayed registry result is a reconciliation outcome, not a failed publish or a retry instruction. What still needs a person:
57
+ `--publish` attempts registry verification and polls the live site itself. A target-confirmed receipt plus a delayed registry result is a reconciliation outcome, not a failed publish or a retry instruction: the receipt is persisted as `.orchestrator/runtime/release-<target>.json`, and `--reconcile` resumes from it. The proof lives in the gitignored runtime dir of the checkout or worktree that ran `--publish`: run `--reconcile` from that same checkout, because any other checkout reports `proof-missing`. What still needs a person:
58
58
 
59
59
  1. **Rotate/delete the token** at https://www.npmjs.com/settings/<user>/tokens. A token that ever transited a conversation, a screenshot, or any log is burned — rotate immediately.
60
60
  2. **pi.dev gallery**: indexing is asynchronous — check https://pi.dev/packages later; do not block on it.