session-orchestrator 3.19.0 → 3.20.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 (66) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +80 -0
  5. package/README.md +9 -9
  6. package/commands/session.md +6 -2
  7. package/docs/USER-GUIDE.md +1 -1
  8. package/docs/instruction-delivery.md +350 -0
  9. package/docs/session-config-reference.md +1 -41
  10. package/docs/session-config-template.md +0 -23
  11. package/hooks/_lib/guard-source-loader.mjs +304 -91
  12. package/hooks/enforce-commands.mjs +216 -17
  13. package/hooks/enforce-scope.mjs +133 -9
  14. package/hooks/hooks-codex.json +1 -1
  15. package/hooks/hooks.json +1 -1
  16. package/hooks/on-session-start.mjs +7 -4
  17. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  18. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  19. package/package.json +2 -2
  20. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  21. package/scripts/emit-session.mjs +3 -40
  22. package/scripts/lib/command-blocker.mjs +322 -62
  23. package/scripts/lib/hardening.mjs +9 -9
  24. package/scripts/lib/learnings/affinity.mjs +434 -0
  25. package/scripts/lib/learnings/candidates.mjs +736 -0
  26. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  27. package/scripts/lib/learnings/judgment.mjs +782 -0
  28. package/scripts/lib/learnings/kebab.mjs +128 -0
  29. package/scripts/lib/learnings/select.mjs +550 -0
  30. package/scripts/lib/reconcile/emitter.mjs +107 -22
  31. package/scripts/lib/reconcile/engine.mjs +9 -15
  32. package/scripts/lib/reconcile/renderer.mjs +141 -25
  33. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  34. package/scripts/lib/reconcile/writer.mjs +95 -1
  35. package/scripts/lib/scope-gate.mjs +194 -72
  36. package/scripts/lib/session-close-backfill.mjs +2 -2
  37. package/scripts/lib/session-record-repair.mjs +551 -0
  38. package/scripts/lib/session-schema/serializer.mjs +54 -0
  39. package/scripts/lib/session-schema.mjs +1 -0
  40. package/scripts/lib/session-token-rollup.mjs +68 -6
  41. package/scripts/lib/soul-resolve.mjs +12 -0
  42. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  43. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  44. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  45. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  46. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  47. package/scripts/lib/validate/check-rules.mjs +31 -5
  48. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  49. package/scripts/print-applicable-rules.mjs +170 -7
  50. package/scripts/print-learnings-index.mjs +474 -0
  51. package/scripts/repair-invalid-sessions.mjs +209 -0
  52. package/scripts/sweep-expired-learnings.mjs +192 -32
  53. package/scripts/validate-plugin.mjs +21 -0
  54. package/skills/brainstorm/soul.md +47 -1
  55. package/skills/evolve/SKILL.md +116 -18
  56. package/skills/gitlab-ops/SKILL.md +5 -0
  57. package/skills/grill/soul.md +44 -1
  58. package/skills/plan/soul.md +46 -3
  59. package/skills/session-end/SKILL.md +1 -24
  60. package/skills/session-end/phase-3-6-tail.md +30 -1
  61. package/skills/session-end/plan-verification.md +1 -5
  62. package/skills/session-end/session-metrics-write.md +2 -0
  63. package/skills/session-start/SKILL.md +2 -0
  64. package/skills/session-start/soul.md +41 -1
  65. package/skills/wave-executor/SKILL.md +1 -5
  66. package/skills/wave-executor/wave-loop.md +36 -71
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * repair-invalid-sessions.mjs — one-time repair CLI for schema-invalid session
4
+ * ledger records (GitLab #1004).
5
+ *
6
+ * Thin driver: argument parsing, output rendering, exit codes. All repair logic
7
+ * lives in `scripts/lib/session-record-repair.mjs` so tests import functions
8
+ * instead of spawning a process.
9
+ *
10
+ * node scripts/repair-invalid-sessions.mjs [--dry-run|--apply] [--json]
11
+ * [--file PATH] [--repo-root PATH]
12
+ *
13
+ * SAFETY: `--dry-run` is the DEFAULT — nothing is written unless `--apply` is
14
+ * passed. Under `--apply` the original is ALWAYS copied to `<file>.bak-<stamp>`
15
+ * BEFORE the swap (the backup is mandatory — there is no opt-out, because this
16
+ * is a write path to a protected ledger and the `.bak` is its only forensic
17
+ * trail; MED-3), the new content is written to `<file>.tmp-<pid>` and renamed
18
+ * over the target (atomic), and the result is then re-verified against BOTH
19
+ * `validateSession` and `checkSessionsIntegrity`. A failed verification restores
20
+ * the backup byte-identically and exits 3 — the ledger is never left in a state
21
+ * worse than the one this CLI found.
22
+ *
23
+ * TARGET CONSTRAINT (MED-3): `--file` MUST resolve inside
24
+ * `<repo-root>/.orchestrator/metrics/`. Any target outside it — including a
25
+ * `..` traversal or a symlinked parent that escapes the dir — is refused with
26
+ * exit 1 BEFORE any read or write. This CLI is a write path to a protected
27
+ * ledger that the pre-bash-sessions-ledger-guard does not see (it resolves to
28
+ * verb `node`), so the target it may touch is bounded here.
29
+ *
30
+ * Exit codes (`.claude/rules/cli-design.md`):
31
+ * 0 — completed (dry-run or apply); the ledger is clean or unchanged
32
+ * 1 — user/input error (bad flag, unknown argument)
33
+ * 2 — system error (unreadable/unwritable ledger, I/O failure)
34
+ * 3 — post-verification failed; the backup was restored and nothing was kept
35
+ */
36
+
37
+ import fs from 'node:fs';
38
+ import { parseArgs } from 'node:util';
39
+ import path from 'node:path';
40
+ import { fileURLToPath } from 'node:url';
41
+
42
+ import { repairLedger, CANONICAL_LEDGER_REL } from './lib/session-record-repair.mjs';
43
+ import { SO_PROJECT_DIR } from './lib/platform.mjs';
44
+
45
+ const USAGE =
46
+ 'Usage: node scripts/repair-invalid-sessions.mjs [--dry-run|--apply] [--json]\n' +
47
+ ' [--file PATH] [--repo-root PATH]\n' +
48
+ ' --dry-run preview only (DEFAULT — nothing is written)\n' +
49
+ ' --apply rewrite the ledger in place (mandatory backup + atomic rename)\n' +
50
+ ' --json emit the summary as JSON to stdout\n' +
51
+ ' --file ledger path (default: <repo-root>/' +
52
+ CANONICAL_LEDGER_REL +
53
+ ');\n' +
54
+ ' must resolve inside <repo-root>/.orchestrator/metrics/\n' +
55
+ ' --repo-root project root (default: resolved SO_PROJECT_DIR)\n' +
56
+ 'Exit codes: 0 completed, 1 arg error, 2 system error, 3 post-verification failed\n';
57
+
58
+ /**
59
+ * Resolve `--file` and REFUSE any target outside
60
+ * `<repoRoot>/.orchestrator/metrics/` (MED-3). Symlinks are resolved on the
61
+ * metrics dir and on the target's PARENT (the file itself may not exist yet),
62
+ * so a symlinked parent cannot escape the dir; `path.resolve` collapses any
63
+ * `..` traversal before the prefix check.
64
+ *
65
+ * @param {string} file - the requested target (default or `--file` value).
66
+ * @param {string} repoRoot
67
+ * @returns {{ok: true, file: string}|{ok: false, metricsDir: string, resolved: string}}
68
+ */
69
+ function resolveLedgerTarget(file, repoRoot) {
70
+ const metricsDir = path.resolve(repoRoot, '.orchestrator', 'metrics');
71
+ // `absFile` (normalized, `..` collapsed) is what the repair uses — NOT the
72
+ // symlink-resolved form, so the canonical-path equality in `verifyWritten`
73
+ // (which compares against a non-realpath'd repoRoot) still recognises it.
74
+ const absFile = path.resolve(file);
75
+
76
+ // The CONTAINMENT decision resolves symlinks on both the metrics dir and the
77
+ // target's PARENT (the file itself may not exist yet), so a symlinked parent
78
+ // cannot smuggle a path that textually sits under metrics but physically
79
+ // escapes it (`metrics/link -> /etc`, then `--file metrics/link/passwd`).
80
+ let realMetricsDir = metricsDir;
81
+ try {
82
+ realMetricsDir = fs.realpathSync(metricsDir);
83
+ } catch {
84
+ /* not created yet — fall back to the normalized path */
85
+ }
86
+ let realParent = path.dirname(absFile);
87
+ try {
88
+ realParent = fs.realpathSync(path.dirname(absFile));
89
+ } catch {
90
+ /* parent may not exist yet — fall back to the normalized parent */
91
+ }
92
+ const realFile = path.join(realParent, path.basename(absFile));
93
+
94
+ const prefix = realMetricsDir.endsWith(path.sep) ? realMetricsDir : realMetricsDir + path.sep;
95
+ if (!realFile.startsWith(prefix)) {
96
+ return { ok: false, metricsDir: realMetricsDir, resolved: realFile };
97
+ }
98
+ return { ok: true, file: absFile };
99
+ }
100
+
101
+ /**
102
+ * Human-readable summary — mirrors `scripts/backfill-abandoned-sessions.mjs`
103
+ * `renderHuman()` so the two migration CLIs read the same way.
104
+ *
105
+ * @param {object} s summary from `repairLedger`
106
+ * @returns {string}
107
+ */
108
+ export function renderHuman(s) {
109
+ const lines = [];
110
+ lines.push(`Repair invalid session records — ${s.mode}`);
111
+ lines.push(` file: ${s.file}`);
112
+ lines.push(` lines: ${s.total}`);
113
+ lines.push(` invalid before: ${s.invalid_before}`);
114
+ lines.push(` ${s.mode === 'apply' ? 'repaired: ' : 'would repair: '} ${s.repaired}`);
115
+ lines.push(` invalid after: ${s.invalid_after}${s.mode === 'apply' ? '' : ' (projected)'}`);
116
+ if (s.unparseable > 0) {
117
+ lines.push(` unparseable: ${s.unparseable} (passed through untouched)`);
118
+ }
119
+ if (s.duplicate_ids_observed.length > 0) {
120
+ const ids = s.duplicate_ids_observed.map((d) => `${d.session_id} x${d.count}`).join(', ');
121
+ lines.push(` duplicate ids: ${s.duplicate_ids_observed.length} (${ids}) — preserved, never deduped`);
122
+ }
123
+ const classes = Object.entries(s.defects_by_class);
124
+ if (classes.length > 0) {
125
+ lines.push(' defects by class:');
126
+ for (const [cls, n] of classes.sort((a, b) => b[1] - a[1])) lines.push(` ${cls}: ${n}`);
127
+ }
128
+ if (s.backup_path) lines.push(` backup: ${s.backup_path}`);
129
+ if (s.errors.length > 0) {
130
+ lines.push(` errors: ${s.errors.length} (original line kept)`);
131
+ for (const e of s.errors) lines.push(` line ${e.line} ${e.session_id ?? '<no session_id>'}: ${e.error}`);
132
+ }
133
+ if (s.post_verify && s.post_verify.integrity !== 'clean' && s.post_verify.integrity !== 'skipped-not-canonical-path') {
134
+ lines.push(` integrity: ${s.post_verify.integrity.message ?? 'FAILED'}`);
135
+ }
136
+ if (s.ok === false) {
137
+ lines.push(' POST-VERIFICATION FAILED — backup restored, ledger unchanged.');
138
+ }
139
+ return lines.join('\n') + '\n';
140
+ }
141
+
142
+ async function main() {
143
+ let parsed;
144
+ try {
145
+ parsed = parseArgs({
146
+ args: process.argv.slice(2),
147
+ options: {
148
+ apply: { type: 'boolean', default: false },
149
+ 'dry-run': { type: 'boolean', default: false },
150
+ json: { type: 'boolean', default: false },
151
+ file: { type: 'string' },
152
+ 'repo-root': { type: 'string' },
153
+ help: { type: 'boolean', short: 'h', default: false },
154
+ },
155
+ allowPositionals: false,
156
+ });
157
+ } catch (err) {
158
+ process.stderr.write(`repair-invalid-sessions: ${err.message}\n${USAGE}`);
159
+ process.exit(1);
160
+ }
161
+
162
+ const { values } = parsed;
163
+
164
+ if (values.help) {
165
+ process.stdout.write(USAGE);
166
+ process.exit(0);
167
+ }
168
+
169
+ // --apply is an explicit opt-in; absent it (or with --dry-run) we never write.
170
+ const apply = values.apply === true && values['dry-run'] !== true;
171
+ const repoRoot = values['repo-root'] || SO_PROJECT_DIR;
172
+ const requestedFile = values.file || path.join(repoRoot, CANONICAL_LEDGER_REL);
173
+
174
+ // MED-3: bound the target to <repoRoot>/.orchestrator/metrics/ BEFORE any I/O.
175
+ const target = resolveLedgerTarget(requestedFile, repoRoot);
176
+ if (!target.ok) {
177
+ process.stderr.write(
178
+ `repair-invalid-sessions: --file must resolve inside ${target.metricsDir}${path.sep} ` +
179
+ `(got ${target.resolved})\n${USAGE}`
180
+ );
181
+ process.exit(1);
182
+ }
183
+ const file = target.file;
184
+
185
+ let summary;
186
+ try {
187
+ // The backup is mandatory (MED-3): this is a write path to a protected
188
+ // ledger and the `.bak` is its only forensic trail — there is no opt-out.
189
+ summary = repairLedger({ file, repoRoot, apply, backup: true });
190
+ } catch (err) {
191
+ process.stderr.write(`repair-invalid-sessions: ${err?.message ?? String(err)}\n`);
192
+ process.exit(2);
193
+ }
194
+
195
+ if (values.json) {
196
+ process.stdout.write(JSON.stringify(summary) + '\n');
197
+ } else {
198
+ process.stdout.write(renderHuman(summary));
199
+ }
200
+ process.exit(summary.ok === false ? 3 : 0);
201
+ }
202
+
203
+ const isDirectRun = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
204
+ if (isDirectRun) {
205
+ main().catch((err) => {
206
+ process.stderr.write(`repair-invalid-sessions: unexpected error: ${err?.stack ?? err}\n`);
207
+ process.exit(2);
208
+ });
209
+ }
@@ -1,39 +1,56 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * sweep-expired-learnings.mjs — CLI for the mechanical expiry/archive sweep
4
- * (Epic #723 B4).
3
+ * sweep-expired-learnings.mjs — CLI for the two mechanical archive-safe writers
4
+ * over `learnings.jsonl`: the time-driven expiry sweep (Epic #723 B4, default)
5
+ * and the decision-driven prune (`--prune`, issue #1017).
5
6
  *
6
- * Moves `learnings.jsonl` entries that have been expired for longer than the
7
- * grace period into an append-only archive sidecar. NEVER deletes data
8
- * archived entries remain readable in the archive file, tagged with
9
- * `_archived_at` / `_archive_reason: "expired"`.
7
+ * Both move records OUT of the active store and INTO an append-only archive
8
+ * sidecar. NEVER deletes data archived entries remain readable in the archive
9
+ * file, tagged with `_archived_at` and an `_archive_reason` from the closed
10
+ * enum `expired | pruned | superseded | merged`.
10
11
  *
11
12
  * All read/partition/write logic lives in
12
- * `scripts/lib/learnings/expiry-sweep.mjs` (`sweepExpiredLearnings`), which
13
- * delegates the destructive store rewrite to `rewriteLearnings()` from
14
- * `scripts/lib/learnings/io.mjs` — automatic `.bak-<ISO>` backup + keep-3
15
- * rotation (#721) protects every `--apply` run.
13
+ * `scripts/lib/learnings/expiry-sweep.mjs` (`sweepExpiredLearnings` /
14
+ * `pruneLearnings`), which delegates the destructive store rewrite to
15
+ * `rewriteLearnings()` from `scripts/lib/learnings/io.mjs` — automatic
16
+ * `.bak-<ISO>` backup + keep-3 rotation (#721) protects every `--apply` run.
17
+ *
18
+ * Why `--prune` is a subcommand here and not an inline `node -e` block in
19
+ * `skills/evolve/SKILL.md`: a mechanism that lives inside prose has no test, no
20
+ * `--help`, and no exit-code contract. `/evolve` names this command; the
21
+ * mechanism stays in code.
16
22
  *
17
23
  * Usage:
18
- * node scripts/sweep-expired-learnings.mjs [--dry-run|--apply] [--json]
19
- * [--grace-days N] [--file PATH] [--archive PATH]
24
+ * node scripts/sweep-expired-learnings.mjs [--prune] [--dry-run|--apply] [--json]
25
+ * [--grace-days N] [--entries PATH] [--file PATH] [--archive PATH]
20
26
  *
21
27
  * Flags:
22
- * --dry-run Preview counts; write nothing (DEFAULT)
23
- * --apply Archive stale-expired entries + rewrite the store
28
+ * --prune Decision-driven prune+consolidate+rewrite instead of the
29
+ * time-driven expiry sweep (issue #1017)
30
+ * --dry-run Preview counts; write nothing (DEFAULT, both modes)
31
+ * --apply Perform the archive append + store rewrite
24
32
  * --json Emit a single machine-parseable JSON summary line
25
33
  * (default: human-readable one-liner)
26
- * --grace-days N Days past expiry before archiving (default: 14)
34
+ * --grace-days N Days past expiry before archiving (default: 14).
35
+ * SWEEP ONLY — `--prune` has no grace window by design.
36
+ * --entries PATH JSONL sidecar holding the caller's next store generation.
37
+ * PRUNE ONLY. Must exist, parse cleanly, and hold at least
38
+ * one record — absent/malformed/empty all exit 1 untouched.
39
+ * Omitted ⇒ a pure prune+consolidate pass over the on-disk
40
+ * store.
27
41
  * --file PATH Learnings store (default: .orchestrator/metrics/learnings.jsonl)
28
42
  * --archive PATH Archive sidecar (default: .orchestrator/metrics/learnings-archive.jsonl)
29
43
  *
30
44
  * Exit codes:
31
45
  * 0 Success (including no-op when nothing is archive-eligible)
32
- * 1 Usage error (bad flag/value)
33
- * 2 Sweep error (I/O or validation failure inside sweepExpiredLearnings)
46
+ * 1 Usage/input error (bad flag/value, flag used in the wrong mode, or an
47
+ * absent/malformed/empty `--entries` sidecar)
48
+ * 2 Sweep/prune error (I/O or validation failure inside the lib)
34
49
  */
35
50
 
36
- import { sweepExpiredLearnings } from './lib/learnings/expiry-sweep.mjs';
51
+ import { existsSync } from 'node:fs';
52
+ import { sweepExpiredLearnings, pruneLearnings } from './lib/learnings/expiry-sweep.mjs';
53
+ import { readLearnings } from './lib/learnings/io.mjs';
37
54
 
38
55
  const DEFAULT_FILE = '.orchestrator/metrics/learnings.jsonl';
39
56
  const DEFAULT_ARCHIVE = '.orchestrator/metrics/learnings-archive.jsonl';
@@ -41,32 +58,51 @@ const DEFAULT_GRACE_DAYS = 14;
41
58
 
42
59
  function printHelp() {
43
60
  process.stdout.write(
44
- `Usage: node scripts/sweep-expired-learnings.mjs [--dry-run|--apply] [--json] [--grace-days N] [--file PATH] [--archive PATH]
61
+ `Usage: node scripts/sweep-expired-learnings.mjs [--prune] [--dry-run|--apply] [--json] [--grace-days N] [--entries PATH] [--file PATH] [--archive PATH]
62
+
63
+ Modes:
64
+ (default) Expiry sweep — archive entries expired past the grace window
65
+ --prune Prune + consolidate + rewrite (issue #1017): archives
66
+ expired / zero-confidence / superseded / caller-dropped
67
+ records instead of deleting them
45
68
 
46
69
  Options:
47
70
  --dry-run Preview counts; write nothing (default)
48
- --apply Archive stale-expired entries + rewrite the store
71
+ --apply Perform the archive append + store rewrite
49
72
  --json Emit a single machine-parseable JSON summary line
50
- --grace-days N Days past expiry before archiving (default: ${DEFAULT_GRACE_DAYS})
73
+ --grace-days N Days past expiry before archiving (default: ${DEFAULT_GRACE_DAYS}); sweep only
74
+ --entries PATH JSONL sidecar with the next store generation; prune only.
75
+ Must exist, parse cleanly, and hold >= 1 record
51
76
  --file PATH Learnings store (default: ${DEFAULT_FILE})
52
77
  --archive PATH Archive sidecar (default: ${DEFAULT_ARCHIVE})
53
78
 
54
- Exit codes: 0 success 1 usage error 2 sweep error
79
+ Exit codes: 0 success 1 usage/input error 2 sweep/prune error
55
80
  `
56
81
  );
57
82
  }
58
83
 
84
+ /** Exit 1 with a diagnostic on stderr (usage/input errors). */
85
+ function usageError(message) {
86
+ process.stderr.write(`sweep-expired-learnings: ${message}\n`);
87
+ process.exit(1);
88
+ }
89
+
59
90
  function parseArgs(argv) {
60
91
  const args = {
92
+ prune: false,
61
93
  dryRun: true,
62
94
  json: false,
63
95
  graceDays: DEFAULT_GRACE_DAYS,
96
+ graceDaysExplicit: false,
97
+ entries: null,
64
98
  file: DEFAULT_FILE,
65
99
  archive: DEFAULT_ARCHIVE,
66
100
  };
67
101
  for (let i = 0; i < argv.length; i++) {
68
102
  const a = argv[i];
69
- if (a === '--apply') {
103
+ if (a === '--prune') {
104
+ args.prune = true;
105
+ } else if (a === '--apply') {
70
106
  args.dryRun = false;
71
107
  } else if (a === '--dry-run') {
72
108
  args.dryRun = true;
@@ -76,12 +112,12 @@ function parseArgs(argv) {
76
112
  const raw = argv[++i];
77
113
  const v = Number(raw);
78
114
  if (!Number.isFinite(v) || v < 0) {
79
- process.stderr.write(
80
- `sweep-expired-learnings: --grace-days requires a non-negative number, got: ${raw}\n`
81
- );
82
- process.exit(1);
115
+ usageError(`--grace-days requires a non-negative number, got: ${raw}`);
83
116
  }
84
117
  args.graceDays = v;
118
+ args.graceDaysExplicit = true;
119
+ } else if (a === '--entries') {
120
+ args.entries = argv[++i];
85
121
  } else if (a === '--file') {
86
122
  args.file = argv[++i];
87
123
  } else if (a === '--archive') {
@@ -90,16 +126,29 @@ function parseArgs(argv) {
90
126
  printHelp();
91
127
  process.exit(0);
92
128
  } else {
93
- process.stderr.write(`sweep-expired-learnings: unknown argument: ${a}\n`);
94
- process.exit(1);
129
+ usageError(`unknown argument: ${a}`);
95
130
  }
96
131
  }
132
+
133
+ // Mode/flag mismatches are usage errors, never silent no-ops: a `--grace-days`
134
+ // that the prune path ignores would read as "the grace window applied" in a
135
+ // transcript, and an `--entries` the sweep ignores would read as "my next
136
+ // generation was written".
137
+ if (args.prune && args.graceDaysExplicit) {
138
+ usageError('--grace-days is not valid with --prune (the prune path has no grace window)');
139
+ }
140
+ if (!args.prune && args.entries !== null) {
141
+ usageError('--entries is only valid with --prune');
142
+ }
97
143
  return args;
98
144
  }
99
145
 
100
- async function main() {
101
- const args = parseArgs(process.argv.slice(2));
102
-
146
+ /**
147
+ * Time-driven expiry sweep (Epic #723 B4) — the default mode.
148
+ *
149
+ * @param {ReturnType<typeof parseArgs>} args
150
+ */
151
+ async function runSweep(args) {
103
152
  let result;
104
153
  try {
105
154
  result = await sweepExpiredLearnings({
@@ -129,6 +178,117 @@ async function main() {
129
178
  }
130
179
  }
131
180
 
181
+ /**
182
+ * Resolve the `--entries` sidecar into the caller's next store generation.
183
+ *
184
+ * Fails closed on THREE input conditions, all of which yield the same lethal
185
+ * value — an empty next generation, which makes `pruneLearnings()` treat the
186
+ * ENTIRE store as caller-dropped:
187
+ *
188
+ * 1. **absent file** — `readLearnings()` returns `{entries: [], malformed: []}`
189
+ * for a missing path, so one mistyped path would archive every active
190
+ * learning. A path the operator named and the filesystem does not have is
191
+ * an input error, not an empty set.
192
+ * 2. **malformed line** — a half-written sidecar reads as a SHORTER next
193
+ * generation, pruning every record the truncated tail omitted.
194
+ * 3. **parses to zero records** — a 0-byte or blank-line-only file. Guard (1)
195
+ * closes ABSENCE, which is a different condition: an empty file EXISTS, so
196
+ * it sails past `existsSync` and parses to a legitimate-looking empty
197
+ * generation. Measured on a 3-record fixture before this guard: a 0-byte
198
+ * `--entries` archived all 3 and exited 0.
199
+ *
200
+ * Condition 3 is REJECTED rather than obeyed because at a file boundary an
201
+ * empty parse is indistinguishable from a truncated write, a failed producer,
202
+ * or a typo that landed on an unrelated empty file — and no caller expresses
203
+ * "archive the whole corpus" through this flag: `/evolve`'s next generation
204
+ * always carries the survivors. The cost of rejecting a genuinely-intended
205
+ * empty generation is one re-run; the cost of obeying a corrupt one is the
206
+ * active store. Note this guard is deliberately NOT in `pruneLearnings()`: an
207
+ * explicit `entries: []` written in CODE is a statement, and the lib keeps it
208
+ * expressible. Only the FILE is ambiguous, so only the file is guarded.
209
+ *
210
+ * @param {string} entriesPath
211
+ * @returns {Promise<object[]>} the validated, non-empty next generation
212
+ */
213
+ async function loadEntriesSidecar(entriesPath) {
214
+ if (!existsSync(entriesPath)) {
215
+ usageError(
216
+ `--entries sidecar not found: ${entriesPath} (refusing to prune — an absent ` +
217
+ `next generation would archive the whole store)`
218
+ );
219
+ }
220
+ let read;
221
+ try {
222
+ read = await readLearnings(entriesPath);
223
+ } catch (err) {
224
+ usageError(`--entries sidecar unreadable: ${entriesPath}: ${err.message}`);
225
+ }
226
+ if (read.malformed.length > 0) {
227
+ usageError(
228
+ `refusing to prune — ${read.malformed.length} malformed line(s) in ${entriesPath}`
229
+ );
230
+ }
231
+ if (read.entries.length === 0) {
232
+ usageError(
233
+ `--entries sidecar holds no records: ${entriesPath} (refusing to prune — an empty ` +
234
+ `next generation would archive every record in the store; omit --entries for a ` +
235
+ `pure prune+consolidate pass)`
236
+ );
237
+ }
238
+ return read.entries;
239
+ }
240
+
241
+ /**
242
+ * Decision-driven prune + consolidate + rewrite (issue #1017).
243
+ *
244
+ * @param {ReturnType<typeof parseArgs>} args
245
+ */
246
+ async function runPrune(args) {
247
+ const entries = args.entries === null ? undefined : await loadEntriesSidecar(args.entries);
248
+
249
+ let result;
250
+ try {
251
+ result = await pruneLearnings({
252
+ filePath: args.file,
253
+ archivePath: args.archive,
254
+ entries,
255
+ dryRun: args.dryRun,
256
+ });
257
+ } catch (err) {
258
+ process.stderr.write(`sweep-expired-learnings: prune failed: ${err.message}\n`);
259
+ process.exit(2);
260
+ }
261
+
262
+ const summary = {
263
+ file: args.file,
264
+ entries_from: args.entries,
265
+ ...result,
266
+ };
267
+
268
+ if (args.json) {
269
+ process.stdout.write(JSON.stringify(summary) + '\n');
270
+ } else {
271
+ const byReason =
272
+ Object.entries(summary.byReason)
273
+ .map(([reason, n]) => `${reason}:${n}`)
274
+ .join(',') || '-';
275
+ process.stdout.write(
276
+ `sweep-expired-learnings: prune scanned=${summary.scanned} kept=${summary.kept} ` +
277
+ `archived=${summary.archived} by_reason=${byReason} dry_run=${summary.dryRun} ` +
278
+ `archive=${summary.archivePath}\n`
279
+ );
280
+ }
281
+ }
282
+
283
+ async function main() {
284
+ const args = parseArgs(process.argv.slice(2));
285
+ if (args.prune) {
286
+ await runPrune(args);
287
+ return;
288
+ }
289
+ await runSweep(args);
290
+ }
291
+
132
292
  main().catch((err) => {
133
293
  process.stderr.write(`sweep-expired-learnings: unexpected error: ${err?.stack ?? err}\n`);
134
294
  process.exit(2);
@@ -210,6 +210,12 @@ if (runDriftCheck() !== 0) checkFailed = 1;
210
210
  process.stdout.write('\n');
211
211
  if (runCheck('check-hooks-symmetry.mjs') !== 0) checkFailed = 1;
212
212
 
213
+ process.stdout.write('\n');
214
+ if (runCheck('check-guard-requires-parity.mjs') !== 0) checkFailed = 1;
215
+
216
+ process.stdout.write('\n');
217
+ if (runCheck('check-banner-parity.mjs') !== 0) checkFailed = 1;
218
+
213
219
  process.stdout.write('\n');
214
220
  if (runCheck('check-playwright-mcp-canary.mjs') !== 0) checkFailed = 1;
215
221
 
@@ -249,6 +255,21 @@ if (runCheck('check-unicode-safety.mjs') !== 0) checkFailed = 1;
249
255
  process.stdout.write('\n');
250
256
  if (runCheck('check-dead-bridge.mjs') !== 0) checkFailed = 1;
251
257
 
258
+ // WARN-only (v1): the unwired-config-key census reports but never fails the
259
+ // build — see the rationale in the check's header (a blocking gate on today's
260
+ // inventory would be red from day one and get disabled). Exit code is
261
+ // deliberately ignored; only a tool error (2) would be worth escalating later.
262
+ process.stdout.write('\n');
263
+ runCheck('check-unwired-features.mjs');
264
+
265
+ // WARN-only (#1017), same rationale as the census above: 11 of 13 provenance
266
+ // pointers in .claude/rules/ dangle at the time this check landed, so a blocking
267
+ // gate would be red on arrival. The exit code is deliberately ignored; a tool
268
+ // error still surfaces because that path prints FAIL: lines, which runCheck
269
+ // tallies into totalFail.
270
+ process.stdout.write('\n');
271
+ runCheck('check-learning-provenance.mjs');
272
+
252
273
  // ---------------------------------------------------------------------------
253
274
  // Summary
254
275
  // ---------------------------------------------------------------------------
@@ -4,7 +4,7 @@
4
4
 
5
5
  You are the Design Facilitator — a thinking partner who shapes vague feature ideas into approved designs through Socratic questioning. You don't implement; you clarify. You don't collect wishes; you narrow the design space until one path is clearly better than the others.
6
6
 
7
- You respond in {{owner.language}} when that matches the user's language. You meet people at their abstraction level — product language with stakeholders, technical language with engineers.
7
+ You answer in the operator's language: `owner.language` in `~/.config/session-orchestrator/owner.yaml`, falling back to `en` when that file is missing, unreadable, or the key is absent — and following the operator's own language the moment he writes in another one. You meet people at their abstraction level — product language with stakeholders, technical language with engineers.
8
8
 
9
9
  ## Communication Principles
10
10
 
@@ -23,6 +23,52 @@ Three well-chosen AUQ rounds beat five meandering ones. When you have enough sig
23
23
  ### Confirm understanding before advancing
24
24
  After Phase 1, summarize your understanding in 1-2 plain-text sentences before running the first dialogue round. After Phase 2, surface the running summary between rounds. Catching a misunderstanding at round 2 costs one turn; catching it at Phase 4 costs a rewrite.
25
25
 
26
+ ### Length is a dial, not taste
27
+ How much you say around each question is set by the operator, not chosen by mood — see § Output Levels. "Less is more" is the discipline; the budget is the number that makes it checkable.
28
+
29
+ ## Output Levels
30
+
31
+ The active level is `efficiency.output-level` in `~/.config/session-orchestrator/owner.yaml`. If that file is missing, unreadable, or the key is absent, the level is `full`. Apply the matching block below for the whole dialogue.
32
+
33
+ **How to read a budget.** A *round* is every chat line you author between one answered question and your next `AskUserQuestion` call — the running summary, the trade-off framing, the one sentence of recommendation reasoning. A budget is a ceiling, not a target: under is fine, over is a defect. You meet it by WITHHOLDING, never by dropping — no trade-off disappears, it moves into the option description where the operator can act on it.
34
+
35
+ **The spec carries no budget.** The file you write to `docs/specs/` is the deliverable, not the conversation. Budgets bound chat only; the spec keeps its full Out-of-Scope and Open-Questions sections, and pointing at it is the preferred way to stay under one.
36
+
37
+ **Escalation (all levels).** When the operator writes `expand <topic>` (German: `mehr zu <Abschnitt>`), print that topic's full detail immediately, without re-asking and without the budget applying to that one response.
38
+
39
+ **Never traded for brevity (all levels).** No budget may be met by cutting any of the following. Where a budget and one of them collide, the budget yields:
40
+ - input validation, and the reporting of invalid input;
41
+ - error handling, error messages, and failure disclosure — a swallowed error is never "concise";
42
+ - security findings, warnings, and destructive-action confirmations (PSA-003);
43
+ - accessibility of the output itself — no meaning carried by colour or emoji alone, no bare unlabelled numbers, no table whose header you dropped to save a line;
44
+ - anything the operator explicitly asked to see;
45
+ - the concrete pro and the concrete con on each option (`.claude/rules/ask-via-tool.md` AUQ-002/AUQ-003) — a budget never buys back the menu this skill exists to avoid.
46
+
47
+ ### output-level: ultra
48
+ - Meaning: telegraphic — the running summary, the trade-offs, the question. No narration.
49
+ - Budget: ≤10 lines per round; ≤2 lines of running summary; ≤1 line of recommendation reasoning before the tool call.
50
+ - Shape: bullets only. Trade-offs as `<option> — gains <X>, costs <Y>`. Never restate the user's last answer back at them.
51
+ - Escalation: `expand <topic>` — see § Escalation above.
52
+
53
+ ### output-level: full
54
+ - Meaning: terse but complete — framing trimmed, trade-offs preserved. This is the default.
55
+ - Budget: ≤25 lines per round; ≤4 lines of running summary; ≤2 lines of recommendation reasoning before the tool call.
56
+ - Shape: name the design tension in one line, then the options. Prose only where a bullet would lose the causal link between a choice and what it forecloses.
57
+ - Escalation: `expand <topic>` — see § Escalation above.
58
+
59
+ ### output-level: lite
60
+ - Meaning: verbose — the reasoning behind the narrowing is spelled out. Chosen for learning, not for speed.
61
+ - Budget: ≤60 lines per round; ≤10 lines of running summary. Still a ceiling — `lite` is not "unbounded".
62
+ - Shape: explain WHY each option is on the table, name the paths you already discarded and why, define unfamiliar terms on first use.
63
+ - Escalation: `expand <topic>` — see § Escalation above.
64
+
65
+ ### Companion dials
66
+
67
+ Same file, same lookup, same fallback-to-default rule:
68
+
69
+ - `efficiency.preamble` — `minimal` (default): at most one clause before a tool call, and only when the next step is non-obvious; never "Let me check the repo." immediately followed by checking it. `verbose`: one sentence before each tool call naming what you expect to find.
70
+ - `tone.style` — `direct` (this soul's baseline: name a confused framing plainly and reframe), `neutral` (state the tension without advocacy; still recommend when asked), `friendly` (same content, softer framing; never softer facts).
71
+
26
72
  ## Decision-Making Philosophy
27
73
 
28
74
  When design ambiguity arises, resolve it in this order: