session-orchestrator 5.3.0 → 5.4.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 (206) hide show
  1. package/.agents/skills/claude-md-drift-check/SKILL.md +1 -1
  2. package/.agents/skills/navigator/SKILL.md +22 -0
  3. package/.agents/skills/navigator/agents/openai.yaml +5 -0
  4. package/.agents/skills/test-audit/SKILL.md +21 -0
  5. package/.agents/skills/test-audit/agents/openai.yaml +5 -0
  6. package/.claude-plugin/marketplace.json +1 -1
  7. package/.claude-plugin/plugin.json +1 -1
  8. package/.codex-plugin/plugin.json +1 -1
  9. package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +1 -1
  10. package/.codex-plugin/skills/navigator/SKILL.md +22 -0
  11. package/.codex-plugin/skills/navigator/agents/openai.yaml +5 -0
  12. package/.codex-plugin/skills/test-audit/SKILL.md +21 -0
  13. package/.codex-plugin/skills/test-audit/agents/openai.yaml +5 -0
  14. package/.cursor/commands/navigator.md +15 -0
  15. package/.cursor/commands/test-audit.md +14 -0
  16. package/.cursor/rules/060-evolve.mdc +27 -12
  17. package/.cursor/skills/claude-md-drift-check/SKILL.md +1 -1
  18. package/.cursor/skills/navigator/SKILL.md +13 -0
  19. package/.cursor/skills/test-audit/SKILL.md +12 -0
  20. package/.cursor-plugin/plugin.json +1 -1
  21. package/.orchestrator/policy/blocked-commands.json +1 -1
  22. package/AGENTS.md +9 -31
  23. package/CHANGELOG.md +70 -0
  24. package/README.md +14 -10
  25. package/docs/agent-authoring.md +2 -2
  26. package/docs/baseline.md +10 -1
  27. package/docs/ci-setup.md +57 -4
  28. package/docs/components.md +12 -12
  29. package/docs/events-schema.md +11 -8
  30. package/docs/persona-panel.md +5 -4
  31. package/docs/rule-authoring.md +27 -12
  32. package/docs/scope-collision-guard.md +26 -4
  33. package/docs/session-config-reference.md +55 -6
  34. package/docs/session-config-template.md +11 -2
  35. package/hooks/_lib/hook-import-set.json +27 -23
  36. package/hooks/hooks-codex.json +1 -1
  37. package/hooks/hooks-cursor.json +5 -0
  38. package/hooks/hooks-pi.json +5 -0
  39. package/hooks/hooks.json +6 -1
  40. package/hooks/on-session-start.mjs +53 -0
  41. package/hooks/on-stop.mjs +3 -1
  42. package/hooks/post-tool-batch-wave-signal.mjs +3 -1
  43. package/hooks/pre-bash-memory-propose-audit.mjs +52 -3
  44. package/hooks/pre-bash-sessions-ledger-guard.mjs +28 -2
  45. package/hooks/pre-edit-sessions-ledger-guard.mjs +162 -0
  46. package/hooks/pre-task-scope-disjoint.mjs +559 -72
  47. package/hooks/subagent-telemetry.mjs +96 -19
  48. package/package.json +3 -3
  49. package/pi/prompts/navigator.md +12 -0
  50. package/pi/prompts/test-audit.md +12 -0
  51. package/rules/README.md +6 -1
  52. package/rules/_index.md +3 -3
  53. package/rules/always-on/bash-harness-pitfalls.md +7 -1
  54. package/rules/opt-in-domain/prompt-caching.md +5 -1
  55. package/rules/opt-in-stack/backend-data.md +9 -1
  56. package/rules/opt-in-stack/backend.md +7 -1
  57. package/rules/opt-in-stack/frontend.md +6 -1
  58. package/rules/opt-in-stack/security-web.md +7 -1
  59. package/rules/opt-in-stack/swift.md +5 -1
  60. package/scripts/apply-session-learnings.mjs +492 -0
  61. package/scripts/archive-closed-prds.mjs +2 -1
  62. package/scripts/autopilot.mjs +49 -53
  63. package/scripts/backfill-abandoned-sessions.mjs +1 -1
  64. package/scripts/baseline-archetypes.mjs +1 -1
  65. package/scripts/check-package-manager.mjs +62 -5
  66. package/scripts/cursor-install.mjs +1 -4
  67. package/scripts/emit-session.mjs +296 -2
  68. package/scripts/export-hw-learnings.mjs +22 -3
  69. package/scripts/fleet-checkin.mjs +147 -0
  70. package/scripts/lib/agent-frontmatter.mjs +3 -3
  71. package/scripts/lib/auto-dream.mjs +7 -6
  72. package/scripts/lib/autopilot/flags.mjs +6 -1
  73. package/scripts/lib/autopilot/loop.mjs +10 -6
  74. package/scripts/lib/baseline-archetypes.mjs +41 -22
  75. package/scripts/lib/config/host-paths.mjs +79 -0
  76. package/scripts/lib/config/persona-gate-wave.mjs +3 -3
  77. package/scripts/lib/config/reaper.mjs +7 -6
  78. package/scripts/lib/config-schema.mjs +13 -0
  79. package/scripts/lib/config.mjs +71 -10
  80. package/scripts/lib/events.mjs +21 -2
  81. package/scripts/lib/fleet-protocol.mjs +270 -0
  82. package/scripts/lib/gates/gate-baseline.mjs +13 -3
  83. package/scripts/lib/gates/gate-full.mjs +7 -2
  84. package/scripts/lib/gates/gate-helpers.mjs +93 -2
  85. package/scripts/lib/gates/gate-incremental.mjs +23 -22
  86. package/scripts/lib/gates/gate-per-file.mjs +16 -7
  87. package/scripts/lib/instruction-budget-guard.mjs +69 -11
  88. package/scripts/lib/learnings/expiry-sweep.mjs +34 -3
  89. package/scripts/lib/learnings/io.mjs +154 -26
  90. package/scripts/lib/learnings/schema.mjs +163 -15
  91. package/scripts/lib/learnings/sizing-subject.mjs +2 -2
  92. package/scripts/lib/memory-proposals/schema.mjs +11 -5
  93. package/scripts/lib/named-baseline-resolver.mjs +53 -28
  94. package/scripts/lib/named-vault-resolver.mjs +104 -5
  95. package/scripts/lib/orphan-reaper/defaults.mjs +41 -0
  96. package/scripts/lib/orphan-reaper/ps-snapshot.mjs +181 -0
  97. package/scripts/lib/orphan-reaper/reaper-audit.mjs +291 -0
  98. package/scripts/lib/orphan-reaper/reaper-decide.mjs +395 -0
  99. package/scripts/lib/orphan-reaper/scan-throttle.mjs +92 -0
  100. package/scripts/lib/orphan-reaper/scan.mjs +506 -0
  101. package/scripts/lib/orphan-reaper.mjs +43 -1315
  102. package/scripts/lib/owner-yaml.mjs +104 -6
  103. package/scripts/lib/persona-panel/catalog-loader.mjs +1 -1
  104. package/scripts/lib/process-group.mjs +62 -11
  105. package/scripts/lib/project-hygiene.mjs +21 -1
  106. package/scripts/lib/quality-gate.mjs +45 -5
  107. package/scripts/lib/resource-probe.mjs +1 -1
  108. package/scripts/lib/rule-loader.mjs +100 -62
  109. package/scripts/lib/rules-sync.mjs +27 -12
  110. package/scripts/lib/scope-baseline.mjs +3 -4
  111. package/scripts/lib/session-record-repair.mjs +183 -26
  112. package/scripts/lib/session-registry.mjs +27 -2
  113. package/scripts/lib/session-schema/constants.mjs +30 -4
  114. package/scripts/lib/session-schema/validator.mjs +28 -1
  115. package/scripts/lib/session-token-rollup.mjs +245 -52
  116. package/scripts/lib/sessions-canonical.mjs +100 -9
  117. package/scripts/lib/sh-quote.mjs +29 -0
  118. package/scripts/lib/subagents-schema.mjs +30 -3
  119. package/scripts/lib/telemetry/pricing.mjs +118 -71
  120. package/scripts/lib/tests-src-ratio.mjs +140 -23
  121. package/scripts/lib/tmux-layout/layouts.mjs +1 -11
  122. package/scripts/lib/tmux-layout/tmux-shell.mjs +2 -11
  123. package/scripts/lib/ux-grill/reconcile.mjs +1 -1
  124. package/scripts/lib/validate/check-agents.mjs +1 -1
  125. package/scripts/lib/validate/check-hooks-symmetry.mjs +5 -0
  126. package/scripts/lib/validate/check-learnings-shell-writes.mjs +214 -0
  127. package/scripts/lib/validate/check-test-value-bans.mjs +87 -14
  128. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  129. package/scripts/lib/validate/check-unwired-features.mjs +1 -1
  130. package/scripts/lib/validate-vendored-rules.mjs +206 -81
  131. package/scripts/lib/vault-mirror/telemetry.mjs +8 -1
  132. package/scripts/lib/vault-status/board-writer.mjs +48 -11
  133. package/scripts/lib/vault-status/narrative-mirror.mjs +40 -9
  134. package/scripts/lib/wave-executor/dispatch-common.mjs +10 -18
  135. package/scripts/lib/wave-executor/foreign-dispatch.mjs +1 -1
  136. package/scripts/lib/wave-executor/remote-dispatch.mjs +1 -1
  137. package/scripts/lib/wave-resource-gate.mjs +34 -8
  138. package/scripts/memory-propose.mjs +18 -5
  139. package/scripts/release-session-lock.mjs +2 -1
  140. package/scripts/release.mjs +10 -3
  141. package/scripts/repair-invalid-sessions.mjs +37 -4
  142. package/scripts/run-migrate-v2-cross-repo.mjs +158 -30
  143. package/scripts/run-quality-gate.mjs +323 -302
  144. package/scripts/site-numbers.mjs +163 -5
  145. package/scripts/sweep-expired-learnings.mjs +56 -5
  146. package/scripts/sweep-expired-rules.mjs +2 -1
  147. package/scripts/validate-plugin.mjs +7 -0
  148. package/scripts/vault-integration-watcher.mjs +2 -1
  149. package/scripts/vault-mirror.mjs +72 -46
  150. package/scripts/wave-scope-binding.mjs +2 -1
  151. package/skills/_shared/config-reading.md +3 -3
  152. package/skills/_shared/fleet-protocol.md +131 -0
  153. package/skills/_shared/state-ownership.md +5 -0
  154. package/skills/autopilot/SKILL.md +25 -11
  155. package/skills/bootstrap/SKILL.md +1 -1
  156. package/skills/bootstrap/_shared-template.md +1 -1
  157. package/skills/bootstrap/fast-template.md +1 -1
  158. package/skills/bootstrap/private-contract.md +2 -1
  159. package/skills/bootstrap/public-fallback.md +6 -3
  160. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +2 -1
  161. package/skills/claude-md-drift-check/SKILL.md +10 -10
  162. package/skills/claude-md-drift-check/checker.mjs +26 -18
  163. package/skills/evolve/SKILL.md +2 -2
  164. package/skills/evolve/references/evolve-analyze-mode.md +10 -6
  165. package/skills/memory-cleanup/SKILL.md +1 -1
  166. package/skills/navigator/SKILL.md +193 -0
  167. package/skills/navigator/references/account-check.sh +87 -0
  168. package/skills/navigator/references/fleet-decisions.mjs +231 -0
  169. package/skills/navigator/references/slot-check.sh +114 -0
  170. package/skills/persona-panel/SKILL.md +2 -2
  171. package/skills/persona-panel/persona-format.md +2 -2
  172. package/skills/persona-panel/presets/designer-lens.md +1 -1
  173. package/skills/persona-panel/presets/engineer-lens.md +1 -1
  174. package/skills/persona-panel/presets/pm-lens.md +1 -1
  175. package/skills/plan/mode-retro.md +19 -4
  176. package/skills/quality-gates/SKILL.md +6 -4
  177. package/skills/remote-offload/SKILL.md +4 -2
  178. package/skills/session-end/learning-patterns.md +22 -12
  179. package/skills/session-end/metrics-collection.md +5 -6
  180. package/skills/session-end/references/phase-3-documentation-updates.md +2 -2
  181. package/skills/session-end/references/phase-4a-worktree-cleanup.md +2 -0
  182. package/skills/session-end/references/session-summary-template.md +1 -1
  183. package/skills/session-end/session-metrics-write.md +22 -60
  184. package/skills/session-plan/SKILL.md +0 -3
  185. package/skills/session-start/SKILL.md +6 -0
  186. package/skills/session-start/references/operations-contract.md +4 -0
  187. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  188. package/skills/session-start/references/phase-4-ssot-environment-check.md +2 -2
  189. package/skills/test-audit/SKILL.md +152 -0
  190. package/skills/test-audit/references/campaign.md +111 -0
  191. package/skills/test-audit/references/mutate.sh +50 -0
  192. package/skills/wave-executor/SKILL.md +1 -1
  193. package/skills/wave-executor/references/wave-executor-state-init.md +3 -0
  194. package/skills/wave-executor/references/wave-loop-dispatch.md +3 -1
  195. package/skills/wave-executor/references/wave-loop-review.md +3 -5
  196. package/templates/personas/README.md +5 -5
  197. package/templates/personas/accounting-compliance.v1.md +1 -1
  198. package/templates/personas/accounting-tax-advisor.v1.md +1 -1
  199. package/templates/personas/buyer-p1-cto.v1.md +1 -1
  200. package/templates/personas/buyer-p2-kanzlei.v1.md +1 -1
  201. package/templates/personas/buyer-p3-build.v1.md +1 -1
  202. package/templates/personas/buyer-p4-tech-veto.v1.md +1 -1
  203. package/templates/personas/buyer-p5-solo.v1.md +1 -1
  204. package/templates/personas/buyer-p6-ld.v1.md +1 -1
  205. package/templates/personas/klima-ai-expert.v1.md +1 -1
  206. package/templates/personas/klima-physicist.v1.md +1 -1
@@ -91,6 +91,13 @@
91
91
  * silent skip — a generator that quietly stops filling a surface is the exact
92
92
  * failure this file exists to end.
93
93
  *
94
+ * ## The sitemap `<lastmod>` (`site/sitemap.xml`, #1484)
95
+ *
96
+ * `--write` changes what `/` and `/de` SHOW, so it also dates them in the
97
+ * sitemap: `lastmod` becomes max(lastmod, the page's own `counted-at` cell).
98
+ * `--check` reports a `lastmod` older than that cell as drift. Rule, mapping and
99
+ * reasons: `SITEMAP_PAGES`.
100
+ *
94
101
  * Exit codes (`.claude/rules/cli-design.md`):
95
102
  * 0 — no drift (--check) / files updated or already current (--write)
96
103
  * 1 — drift found (--check), or a contract violation in either mode
@@ -99,7 +106,7 @@
99
106
  */
100
107
 
101
108
  import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'node:fs';
102
- import { join, resolve, relative } from 'node:path';
109
+ import { join, resolve, relative, sep } from 'node:path';
103
110
  import { execFileSync } from 'node:child_process';
104
111
 
105
112
  import { writeStdoutLineSync, writeJsonAtomicSync } from './lib/io.mjs';
@@ -963,6 +970,118 @@ export function syncCensusBlocks(siteDir, values, { write = false } = {}) {
963
970
  return CENSUS_BLOCK_FILES.map((file) => syncCensusBlock(siteDir, values, { write, file }));
964
971
  }
965
972
 
973
+ // ---------------------------------------------------------------------------
974
+ // The sitemap <lastmod> in site/sitemap.xml (#1484)
975
+ // ---------------------------------------------------------------------------
976
+
977
+ /** The sitemap, relative to the SITE directory. */
978
+ export const SITEMAP_FILE = 'sitemap.xml';
979
+
980
+ /**
981
+ * Every page ↔ sitemap `<loc>`, explicitly.
982
+ *
983
+ * Why the sitemap is this generator's business: `--write` changes what `/` and
984
+ * `/de` show (version card, number cards, "Counted on … at commit …"), and the
985
+ * release that ran it left their `lastmod` behind. Measured 2026-10-01: `/`
986
+ * claimed 2026-09-08 while its content was counted 2026-09-22.
987
+ *
988
+ * The rule takes no second clock. A page's `counted-at` cell is the date this
989
+ * script last changed that page — `rewrite()` stamps every cell in one pass —
990
+ * so `lastmod` becomes max(lastmod, that cell), and never moves backwards. A
991
+ * page without a `counted-at` cell (the guide and the legal pages, whose only
992
+ * cell is the header version) is never dated: a version bump in a page header
993
+ * is not a content change.
994
+ *
995
+ * Unknown either way is an ERROR, never a skip (the `METRIC_IDS` rule): a
996
+ * sitemap `<loc>` missing here would never be dated, and a page carrying cells
997
+ * that is missing here would drift unseen.
998
+ */
999
+ export const SITEMAP_PAGES = Object.freeze([
1000
+ { page: 'index.html', loc: 'https://session-orchestrator.com/' },
1001
+ { page: 'de/index.html', loc: 'https://session-orchestrator.com/de' },
1002
+ { page: 'guide/index.html', loc: 'https://session-orchestrator.com/guide' },
1003
+ { page: 'impressum/index.html', loc: 'https://session-orchestrator.com/impressum' },
1004
+ { page: 'datenschutz/index.html', loc: 'https://session-orchestrator.com/datenschutz' },
1005
+ ]);
1006
+
1007
+ const URL_BLOCK_RE = /<url>([\s\S]*?)<\/url>/g;
1008
+ const ISO_DAY_RE = /^\d{4}-\d{2}-\d{2}$/;
1009
+
1010
+ /**
1011
+ * Judge (and optionally rewrite) the `<lastmod>` of every sitemap entry. Only the
1012
+ * date text of an entry that moves is rewritten; the rest of the file is handed
1013
+ * back byte-for-byte.
1014
+ *
1015
+ * @param {string} siteDir
1016
+ * @param {Array<{page:string, metrics:string[], countedAt:string|null}>} pages
1017
+ * every HTML file under `siteDir` carrying metric cells: its site-relative path
1018
+ * (`/`-separated), its metric ids, and its `counted-at` cell as it stands AFTER
1019
+ * this run (`null` when it has none)
1020
+ * @param {{write?: boolean}} [opts]
1021
+ * @returns {{present:boolean, file:string, errors?:string[],
1022
+ * drift?:Array<{loc:string, line:number, actual:string, expected:string}>,
1023
+ * written?:boolean}}
1024
+ */
1025
+ export function syncSitemapLastmod(siteDir, pages, { write = false } = {}) {
1026
+ const abs = join(siteDir, SITEMAP_FILE);
1027
+ // Absent file: like the census blocks, not this generator's to create.
1028
+ if (!existsSync(abs) || !statSync(abs).isFile()) return { present: false, file: abs };
1029
+
1030
+ const errors = [];
1031
+ const dated = new Map(); // loc → the counting date its page carries
1032
+ for (const p of pages) {
1033
+ const entry = SITEMAP_PAGES.find((e) => e.page === p.page);
1034
+ if (!entry) {
1035
+ errors.push(
1036
+ `${p.page} carries metric cells but has no sitemap mapping — known pages: ` +
1037
+ SITEMAP_PAGES.map((e) => e.page).join(', '),
1038
+ );
1039
+ } else if (p.countedAt !== null) {
1040
+ if (ISO_DAY_RE.test(p.countedAt)) dated.set(entry.loc, p.countedAt);
1041
+ else errors.push(`${p.page}: counted-at cell "${p.countedAt}" is not a YYYY-MM-DD date`);
1042
+ } else if (p.metrics.some((m) => m !== 'version')) {
1043
+ // Counted cells with no stamp to date them by: their changes would never
1044
+ // reach the sitemap.
1045
+ errors.push(`${p.page} carries counted cells but no counted-at cell — its lastmod cannot be dated`);
1046
+ }
1047
+ }
1048
+
1049
+ const text = readFileSync(abs, 'utf8');
1050
+ const drift = [];
1051
+ const seen = new Set();
1052
+ const next = text.replace(URL_BLOCK_RE, (block, inner, offset) => {
1053
+ const loc = /<loc>([^<]*)<\/loc>/.exec(inner)?.[1].trim();
1054
+ if (loc === undefined || !SITEMAP_PAGES.some((e) => e.loc === loc)) {
1055
+ errors.push(
1056
+ `unknown sitemap <loc> "${loc ?? '(none)'}" — known: ${SITEMAP_PAGES.map((e) => e.loc).join(', ')}`,
1057
+ );
1058
+ return block;
1059
+ }
1060
+ seen.add(loc);
1061
+ const lm = /<lastmod>([^<]*)<\/lastmod>/.exec(block);
1062
+ if (!lm || !ISO_DAY_RE.test(lm[1])) {
1063
+ errors.push(`<lastmod> of ${loc} is missing or not a YYYY-MM-DD date`);
1064
+ return block;
1065
+ }
1066
+ const date = dated.get(loc);
1067
+ // ISO days compare as strings. Never backwards: a lastmod a hand edit dated
1068
+ // after the count stays.
1069
+ if (date === undefined || date <= lm[1]) return block;
1070
+ drift.push({ loc, line: lineOf(text, offset + lm.index), actual: lm[1], expected: date });
1071
+ return `${block.slice(0, lm.index)}<lastmod>${date}</lastmod>${block.slice(lm.index + lm[0].length)}`;
1072
+ });
1073
+ for (const loc of dated.keys()) {
1074
+ if (!seen.has(loc)) errors.push(`${loc} carries a counted-at cell but has no sitemap <url> entry`);
1075
+ }
1076
+
1077
+ const out = { present: true, file: abs, errors, drift, written: false };
1078
+ if (write && errors.length === 0 && next !== text) {
1079
+ writeFileSync(abs, next, 'utf8');
1080
+ out.written = true;
1081
+ }
1082
+ return out;
1083
+ }
1084
+
966
1085
  // ---------------------------------------------------------------------------
967
1086
  // Markup
968
1087
  // ---------------------------------------------------------------------------
@@ -1134,6 +1253,7 @@ function printHelp() {
1134
1253
  writeStdoutLineSync(' --check (default) report drift, change nothing; exit 1 on drift');
1135
1254
  writeStdoutLineSync(` --write rewrite the cells in place; also refreshes ${CENSUS_FILE.join('/')}`);
1136
1255
  writeStdoutLineSync(' (skipped when --site points outside the repo\'s own site/)');
1256
+ writeStdoutLineSync(` and dates each counted page's <lastmod> in <site>/${SITEMAP_FILE}`);
1137
1257
  writeStdoutLineSync(' --json machine-readable envelope on stdout');
1138
1258
  writeStdoutLineSync(' --site <dir> site directory (default <repo-root>/site)');
1139
1259
  writeStdoutLineSync(' --version print the package version');
@@ -1208,12 +1328,18 @@ export function main(argv = process.argv.slice(2), env = {}) {
1208
1328
  // the source producing it is corrupt, and writing the rest would be a
1209
1329
  // partial write reported as success.
1210
1330
  let rejectedTotal = 0;
1331
+ // Every page carrying cells, for the sitemap's <lastmod> (syncSitemapLastmod).
1332
+ const pages = [];
1211
1333
 
1212
- for (const abs of files) {
1213
- // Repo-relative when the file is inside the repo; absolute otherwise (a
1214
- // `--site` fixture in $TMPDIR would otherwise render as ../../../../var/...).
1334
+ // Repo-relative when the file is inside the repo; absolute otherwise (a
1335
+ // `--site` fixture in $TMPDIR would otherwise render as ../../../../var/...).
1336
+ const display = (abs) => {
1215
1337
  const r = relative(root, abs);
1216
- const rel = r && !r.startsWith('..') ? r : abs;
1338
+ return r && !r.startsWith('..') ? r : abs;
1339
+ };
1340
+
1341
+ for (const abs of files) {
1342
+ const rel = display(abs);
1217
1343
  const html = readFileSync(abs, 'utf8');
1218
1344
  const spans = inspectHtml(html, values);
1219
1345
  spanTotal += spans.length;
@@ -1250,6 +1376,16 @@ export function main(argv = process.argv.slice(2), env = {}) {
1250
1376
  }
1251
1377
  }
1252
1378
 
1379
+ if (spans.length > 0) {
1380
+ const stamp = spans.find((s) => s.metric === 'counted-at');
1381
+ pages.push({
1382
+ page: relative(siteDir, abs).split(sep).join('/'),
1383
+ metrics: spans.map((s) => s.metric),
1384
+ // As the page stands AFTER this run: a written file carries every value.
1385
+ countedAt: stamp ? (written > 0 ? values['counted-at'] : stamp.actual.trim()) : null,
1386
+ });
1387
+ }
1388
+
1253
1389
  report.push({
1254
1390
  file: rel,
1255
1391
  spans: spans.length,
@@ -1294,6 +1430,17 @@ export function main(argv = process.argv.slice(2), env = {}) {
1294
1430
  // read `llmsFull`; `censusBlocks` below carries all of them.
1295
1431
  const llms = censusBlocks[CENSUS_BLOCK_FILES.indexOf(LLMS_FULL_FILE)];
1296
1432
 
1433
+ // The sitemap <lastmod>: a date older than the counting stamp on its page is
1434
+ // drift, like a stale number. Written from the pages as they stand after the
1435
+ // loop above, so page and sitemap come from the same run.
1436
+ const sitemap = syncSitemapLastmod(siteDir, pages, { write: args.write });
1437
+ const sitemapRel = display(sitemap.file);
1438
+ for (const e of sitemap.errors ?? []) {
1439
+ stderr(`Error: ${sitemapRel}: ${e}`);
1440
+ contractTotal += 1;
1441
+ }
1442
+ driftTotal += sitemap.drift?.length ?? 0;
1443
+
1297
1444
  // The named silent-failure class: a generator that matches nothing, changes
1298
1445
  // nothing, and reports success. Zero spans means the markup contract is not in
1299
1446
  // the page — that is a defect, in BOTH modes, never a no-op.
@@ -1390,6 +1537,13 @@ export function main(argv = process.argv.slice(2), env = {}) {
1390
1537
  stale: b.stale === true,
1391
1538
  written: b.written === true,
1392
1539
  })),
1540
+ sitemap: {
1541
+ file: sitemap.present ? sitemapRel : null,
1542
+ present: sitemap.present,
1543
+ errors: sitemap.errors ?? [],
1544
+ drift: sitemap.drift ?? [],
1545
+ written: sitemap.written === true,
1546
+ },
1393
1547
  censusWritten,
1394
1548
  ok,
1395
1549
  },
@@ -1404,6 +1558,7 @@ export function main(argv = process.argv.slice(2), env = {}) {
1404
1558
  censusBlocks
1405
1559
  .map((b, i) => (b.written ? ` + ${CENSUS_BLOCK_FILES[i]}` : ''))
1406
1560
  .join('') +
1561
+ (sitemap.written ? ` + ${SITEMAP_FILE}` : '') +
1407
1562
  (censusWritten ? ` + ${CENSUS_FILE.join('/')}` : ''),
1408
1563
  );
1409
1564
  } else {
@@ -1416,6 +1571,9 @@ export function main(argv = process.argv.slice(2), env = {}) {
1416
1571
  }
1417
1572
  }
1418
1573
  for (const l of censusDriftLines) stdout(l);
1574
+ for (const d of sitemap.drift ?? []) {
1575
+ stdout(`DRIFT ${sitemapRel}:${d.line} lastmod ${d.loc}: sitemap says "${d.actual}", page counted "${d.expected}"`);
1576
+ }
1419
1577
  stdout(
1420
1578
  driftTotal === 0 && !noSpans && contractTotal === 0
1421
1579
  ? `site-numbers: ${spanTotal} metric cell(s) current across ${files.length} file(s)`
@@ -37,6 +37,9 @@
37
37
  * --entries PATH JSONL sidecar holding the caller's next store generation.
38
38
  * PRUNE ONLY. Must exist, parse cleanly, and hold at least
39
39
  * one record — absent/malformed/empty all exit 1 untouched.
40
+ * A NEW record (its `id` not in the store) must pass strict
41
+ * `validateLearning()`, else exit 1 untouched (GH#69);
42
+ * records already in the store keep the tolerant path.
40
43
  * Omitted ⇒ a pure prune+consolidate pass over the on-disk
41
44
  * store.
42
45
  * --file PATH Learnings store (default: .orchestrator/metrics/learnings.jsonl)
@@ -61,14 +64,18 @@
61
64
  *
62
65
  * Exit codes:
63
66
  * 0 Success (including no-op when nothing is archive-eligible)
64
- * 1 Usage/input error (bad flag/value, flag used in the wrong mode, or an
65
- * absent/malformed/empty `--entries` sidecar)
67
+ * 1 Usage/input error (bad flag/value, flag used in the wrong mode, an
68
+ * absent/malformed/empty `--entries` sidecar, or a NEW `--entries` record
69
+ * that fails strict schema validation — dry run and apply alike)
66
70
  * 2 Sweep/prune error (I/O or validation failure inside the lib)
67
71
  */
68
72
 
69
73
  import { existsSync } from 'node:fs';
74
+ import path from 'node:path';
75
+ import { isDeepStrictEqual } from 'node:util';
70
76
  import { sweepExpiredLearnings, pruneLearnings } from './lib/learnings/expiry-sweep.mjs';
71
77
  import { readLearnings } from './lib/learnings/io.mjs';
78
+ import { validateLearning } from './lib/learnings/schema.mjs';
72
79
  import { emitEvolveCompleted } from './lib/learnings/evolve-telemetry.mjs';
73
80
 
74
81
  const DEFAULT_FILE = '.orchestrator/metrics/learnings.jsonl';
@@ -91,11 +98,14 @@ Options:
91
98
  --json Emit a single machine-parseable JSON summary line
92
99
  --grace-days N Days past expiry before archiving (default: ${DEFAULT_GRACE_DAYS}); sweep only
93
100
  --entries PATH JSONL sidecar with the next store generation; prune only.
94
- Must exist, parse cleanly, and hold >= 1 record
101
+ Must exist, parse cleanly, and hold >= 1 record; a NEW
102
+ record (id not in the store) must pass strict schema
103
+ validation, else exit 1 with nothing written
95
104
  --file PATH Learnings store (default: ${DEFAULT_FILE})
96
105
  --archive PATH Archive sidecar (default: ${DEFAULT_ARCHIVE})
97
106
 
98
- Exit codes: 0 success 1 usage/input error 2 sweep/prune error
107
+ Exit codes: 0 success 1 usage/input error (incl. an invalid new --entries record)
108
+ 2 sweep/prune error
99
109
  `
100
110
  );
101
111
  }
@@ -184,7 +194,8 @@ function parseArgs(argv) {
184
194
  args.skipped = typeof raw === 'string' ? raw.split(',').filter((s) => s.length > 0) : [];
185
195
  args.telemetryExplicit = true;
186
196
  } else if (a === '--repo-root') {
187
- args.repoRoot = argv[++i];
197
+ // Absolute on purpose: emitEvent refuses a relative root (#1468).
198
+ args.repoRoot = path.resolve(argv[++i]);
188
199
  args.telemetryExplicit = true;
189
200
  } else if (a === '--help' || a === '-h') {
190
201
  printHelp();
@@ -308,6 +319,45 @@ async function loadEntriesSidecar(entriesPath) {
308
319
  return read.entries;
309
320
  }
310
321
 
322
+ /**
323
+ * Strict-validate every `--entries` record the store does not already hold.
324
+ *
325
+ * `pruneLearnings()` rewrites through `rewriteLearnings()`, which runs
326
+ * `legacyTolerant` so records ALREADY in the store (legacy shapes, EventDrop
327
+ * #386) survive a round-trip. Without this gate the same tolerance admitted
328
+ * NEW records too — a sidecar record with `scope: "src/app/x.tsx"` and no
329
+ * `created_at`/`expires_at` was written (GH#69). New = its `id` is absent from
330
+ * the store; an id-less record counts as known only when an id-less store
331
+ * record is deep-equal to it (the carry-through case — linear scan over the
332
+ * id-less subset, fine while such records stay rare; revisit if a store holds
333
+ * hundreds of them). Runs before any write, in dry run and apply alike.
334
+ *
335
+ * @param {object[]} entries - the parsed `--entries` generation
336
+ * @param {string} filePath - the learnings store
337
+ */
338
+ async function rejectInvalidNewRecords(entries, filePath) {
339
+ let store;
340
+ try {
341
+ ({ entries: store } = await readLearnings(filePath));
342
+ } catch (err) {
343
+ process.stderr.write(`sweep-expired-learnings: prune failed: cannot read ${filePath}: ${err.message}\n`);
344
+ process.exit(2);
345
+ }
346
+ const idOf = (e) => (typeof e?.id === 'string' && e.id.length > 0 ? e.id : null);
347
+ const storeIds = new Set(store.map(idOf).filter((id) => id !== null));
348
+ const storeIdless = store.filter((e) => idOf(e) === null);
349
+ for (const entry of entries) {
350
+ const id = idOf(entry);
351
+ const known = id !== null ? storeIds.has(id) : storeIdless.some((s) => isDeepStrictEqual(s, entry));
352
+ if (known) continue;
353
+ try {
354
+ validateLearning(entry);
355
+ } catch (err) {
356
+ usageError(`--entries: new record ${id ?? '(no id)'} is invalid: ${err.message} — nothing written`);
357
+ }
358
+ }
359
+ }
360
+
311
361
  /**
312
362
  * Decision-driven prune + consolidate + rewrite (issue #1017).
313
363
  *
@@ -315,6 +365,7 @@ async function loadEntriesSidecar(entriesPath) {
315
365
  */
316
366
  async function runPrune(args) {
317
367
  const entries = args.entries === null ? undefined : await loadEntriesSidecar(args.entries);
368
+ if (entries !== undefined) await rejectInvalidNewRecords(entries, args.file);
318
369
 
319
370
  let result;
320
371
  try {
@@ -29,6 +29,7 @@
29
29
  * per-file error on the `--apply` path)
30
30
  */
31
31
 
32
+ import path from 'node:path';
32
33
  import { emitEvent } from './lib/events.mjs';
33
34
  import { isMainModule } from './lib/is-main-module.mjs';
34
35
  import {
@@ -100,7 +101,7 @@ export function parseArgs(argv) {
100
101
  } else if (a === '--repo-root') {
101
102
  const raw = argv[++i];
102
103
  if (typeof raw !== 'string' || raw.length === 0) usageError('--repo-root requires a path');
103
- args.repoRoot = raw;
104
+ args.repoRoot = path.resolve(raw);
104
105
  } else if (a === '--learnings') {
105
106
  const raw = argv[++i];
106
107
  if (typeof raw !== 'string' || raw.length === 0) usageError('--learnings requires a path');
@@ -513,6 +513,13 @@ runStrictShAdvisory();
513
513
  // session-end tail phases from their dispatcher.
514
514
  if (runCheck('check-skill-links.mjs') !== 0) checkFailed = 1;
515
515
 
516
+ // BLOCKING (#1446 / GH#69): instruction prose that tells an agent to write
517
+ // `learnings.jsonl` with a shell `>` / `>>`. The store is gitignored, so a bad
518
+ // write has no VCS restore; the sanctioned writers (`apply-session-learnings.mjs`,
519
+ // `sweep-expired-learnings.mjs --prune`) validate, snapshot `.bak` and archive.
520
+ process.stdout.write('\n');
521
+ if (runCheck('check-learnings-shell-writes.mjs') !== 0) checkFailed = 1;
522
+
516
523
  // WARN-only: a state-mutating `git` call in tests/ that names no target resolves
517
524
  // its destination from the ambient cwd (or an inherited GIT_DIR) — the 2026-08-19
518
525
  // incident, where fixture commits, a fixture remote and a fixture identity landed
@@ -35,6 +35,7 @@ import { getCrossRepoProjects, getConfinementRoot } from './lib/config/cross-rep
35
35
  import { validatePathInsideProject } from './lib/path-utils.mjs';
36
36
  import { resolveRepoSpec, redactUrlCredentials } from './lib/vcs-repo-spec.mjs';
37
37
  import { expandTilde } from './lib/common.mjs';
38
+ import { shellQuote } from './lib/sh-quote.mjs';
38
39
 
39
40
  // ── Argument parsing ──────────────────────────────────────────────────────────
40
41
 
@@ -285,7 +286,7 @@ function buildTickBody(issueMap, depIds, verdict, streak, date) {
285
286
  function buildFlipBody(repos) {
286
287
  // Defense-in-depth: regex+confinement upstream already filter, but shell-quote here
287
288
  // makes the embedded bash safe under any future bypass.
288
- const repoList = repos.map(r => `'${r.replace(/'/g, "'\\''")}'`).join(' ');
289
+ const repoList = repos.map(shellQuote).join(' ');
289
290
  // The sed/add/commit one-liner targets the project-instruction file. Most
290
291
  // ecosystem repos use CLAUDE.md, but Codex-CLI repos use AGENTS.md as a
291
292
  // transparent alias — see skills/_shared/instruction-file-resolution.md.
@@ -18,7 +18,7 @@
18
18
  * 2 — filesystem error
19
19
  *
20
20
  * Output: one JSON line per action on stdout:
21
- * {"action":"created|updated|skipped-noop|skipped-handwritten|skipped-collision-resolved|skipped-invalid|skipped-quality-low","path":"...","kind":"...","id":"..."}
21
+ * {"action":"created|updated|skipped-noop|skipped-handwritten|skipped-collision-resolved|skipped-invalid|skipped-quality-low|skipped-abandoned|skipped-duplicate-session","path":"...","kind":"...","id":"..."}
22
22
  *
23
23
  * Idempotency rules:
24
24
  * 1. File does not exist → create.
@@ -48,22 +48,27 @@
48
48
  * Part of session-orchestrator vault-mirror (Issue #14).
49
49
  */
50
50
 
51
- import { spawnSync } from 'node:child_process';
52
51
  import { existsSync } from 'node:fs';
53
- import { resolve } from 'node:path';
52
+ import { basename, resolve } from 'node:path';
54
53
  import { createInterface } from 'node:readline';
55
54
  import { createReadStream } from 'node:fs';
56
55
 
57
- import { processLearning, processSession, getMaskerStats } from './lib/vault-mirror/process.mjs';
56
+ import {
57
+ processLearning,
58
+ processSession,
59
+ getMaskerStats,
60
+ emitAction,
61
+ } from './lib/vault-mirror/process.mjs';
58
62
  import { emitMirrorEvent, emitMirrorRunEvent } from './lib/vault-mirror/telemetry.mjs';
59
63
  import { emitEvent } from './lib/events.mjs';
60
64
  import { autoCommitVaultMirror } from './lib/vault-mirror/auto-commit.mjs';
61
65
  import { parseColumnFlags, CliFlagError } from './lib/cli-flags.mjs';
62
66
  import { resolveRepoNamespace } from './lib/vault-mirror/namespace.mjs';
63
- import { resolveCanonicalSuffixes } from './lib/named-vault-resolver.mjs';
67
+ import { checkCanonicalVault, describeOriginForLog, normalizeRemote } from './lib/named-vault-resolver.mjs';
64
68
  import { loadOwnerConfig } from './lib/owner-yaml.mjs';
65
69
  import { canonicalizeSessions } from './lib/sessions-canonical.mjs';
66
70
  import { isMainModule } from './lib/is-main-module.mjs';
71
+ import { expandTilde } from './lib/common.mjs';
67
72
 
68
73
  // ── Canonical-vault helpers (#600 D2 / #607 D2) ────────────────────────────────
69
74
  // These are module-level (above the CLI bootstrap) so the module is import-safe
@@ -85,7 +90,7 @@ export function _resolveCanonicalSuffix(envValue) {
85
90
  return envValue && envValue.trim() ? envValue.trim() : '/agents/vault';
86
91
  }
87
92
 
88
- // Kept for documentation; the guard now uses resolveCanonicalSuffixes() which
93
+ // Kept for documentation; the guard now uses checkCanonicalVault() which
89
94
  // generalises this to N suffixes. The `_` prefix satisfies the no-unused-vars rule.
90
95
  const _CANONICAL_VAULT_SUFFIX = _resolveCanonicalSuffix(
91
96
  process.env.VAULT_MIRROR_CANONICAL_SUFFIX,
@@ -93,18 +98,14 @@ const _CANONICAL_VAULT_SUFFIX = _resolveCanonicalSuffix(
93
98
 
94
99
  /**
95
100
  * Normalize a git remote URL to a host/path tail for canonical-suffix matching
96
- * (#607 D2 — exported for unit tests). Strips `.git`, the `git@host:` / scheme
97
- * prefixes, and trailing slashes.
101
+ * (#607 D2 — exported for unit tests). Since #1450 the ONE implementation is
102
+ * `normalizeRemote` in lib/named-vault-resolver.mjs, shared with the board
103
+ * writer's guard; this name is kept for the existing test imports.
98
104
  * @param {string} url
99
105
  * @returns {string}
100
106
  */
101
107
  export function _normalizeRemote(url) {
102
- return String(url ?? '')
103
- .trim()
104
- .replace(/\.git$/, '')
105
- .replace(/^git@([^:]+):/, '$1/')
106
- .replace(/^[a-z]+:\/\//, '')
107
- .replace(/\/+$/, '');
108
+ return normalizeRemote(url);
108
109
  }
109
110
 
110
111
  // ── Mirror telemetry (#1116) ──────────────────────────────────────────────────
@@ -235,7 +236,11 @@ if (flagValues.help === true) {
235
236
  process.exit(0);
236
237
  }
237
238
 
238
- const vaultDir = flagValues['vault-dir'];
239
+ // Expand a leading `~` before anything touches the path: owner.yaml `vault-dirs:` /
240
+ // `paths.vault-dir` and the committed Session Config all accept `~/…`, and a quoted
241
+ // `--vault-dir "~/…"` reaches us literally. `resolve('~/x')` would yield `<cwd>/~/x`
242
+ // and the existsSync check below would abort with `missing-vault-dir` (agents/vault#319).
243
+ const vaultDir = expandTilde(flagValues['vault-dir']);
239
244
  const source = flagValues.source;
240
245
  const kind = flagValues.kind;
241
246
  const dryRun = flagValues['dry-run'] === true;
@@ -323,6 +328,21 @@ if (kind !== 'learning' && kind !== 'session') {
323
328
  // imports) so they are import-safe and unit-testable; see _resolveCanonicalSuffix
324
329
  // / _normalizeRemote there (#607 D2).
325
330
 
331
+ /**
332
+ * Render a CLI path flag for a stderr line as its basename only (#1479).
333
+ *
334
+ * This CLI's stderr lands in CI logs and session transcripts, and an absolute
335
+ * `--vault-dir` / `--source` carries the operator's home directory — the
336
+ * owner-leakage class `.claude/rules/security.md` § Owner-Privacy guards. The
337
+ * basename still says WHICH value was wrong; the caller passed the full path
338
+ * and already knows it.
339
+ * @param {string} p
340
+ * @returns {string}
341
+ */
342
+ function pathForLog(p) {
343
+ return `.../${basename(String(p ?? ''))}`;
344
+ }
345
+
326
346
  // ── Run-level accounting + run close-out (#1147) ──────────────────────────────
327
347
  //
328
348
  // Deliberately OUTSIDE main(): the run event's whole contract is that it is
@@ -430,32 +450,29 @@ async function main() {
430
450
  // are all `0`, and `aborted` is what makes that zero readable as "never
431
451
  // started" rather than "ran over an empty source".
432
452
  if (!existsSync(resolve(vaultDir))) {
433
- process.stderr.write(`vault-mirror: vault-dir not found: ${vaultDir}\n`);
453
+ process.stderr.write(`vault-mirror: vault-dir not found: ${pathForLog(vaultDir)}\n`);
434
454
  await finishRun('missing-vault-dir');
435
455
  process.exit(2);
436
456
  }
437
457
 
438
- if (process.env.VAULT_MIRROR_SKIP_CANONICAL_CHECK !== '1') {
439
- const res = spawnSync('git', ['-C', resolve(vaultDir), 'remote', 'get-url', 'origin'], {
440
- encoding: 'utf8',
441
- });
442
- const canonicalSuffixes = resolveCanonicalSuffixes({
443
- ownerConfig: loadOwnerConfig().config,
444
- env: process.env,
445
- });
446
- const ok = res.status === 0 && canonicalSuffixes.some((s) => _normalizeRemote(res.stdout).endsWith(s));
447
- if (!ok) {
448
- const got = res.status === 0 ? res.stdout.trim() : 'no git origin';
449
- process.stderr.write(
450
- `vault-mirror: refusing to mirror — "${vaultDir}" is not the canonical Meta-Vault (expected git origin ending in one of: ${canonicalSuffixes.join(', ')}; got ${got})\n`,
451
- );
452
- await finishRun('vault-not-canonical');
453
- process.exit(2);
454
- }
458
+ // The check itself — origin probe, suffix match, the SKIP_CANONICAL_CHECK
459
+ // escape hatch — is the shared `checkCanonicalVault` (#1450), the same one the
460
+ // board writer applies, so the two vault writers cannot drift apart again.
461
+ const canonical = checkCanonicalVault({
462
+ vaultDir: resolve(vaultDir),
463
+ ownerConfig: loadOwnerConfig().config,
464
+ env: process.env,
465
+ });
466
+ if (!canonical.ok) {
467
+ process.stderr.write(
468
+ `vault-mirror: refusing to mirror — vault is not the canonical Meta-Vault (expected git origin ending in one of: ${canonical.expected.join(', ')}; got ${describeOriginForLog(canonical.got) || 'no git origin'})\n`,
469
+ );
470
+ await finishRun('vault-not-canonical');
471
+ process.exit(2);
455
472
  }
456
473
 
457
474
  if (!existsSync(resolve(source))) {
458
- process.stderr.write(`vault-mirror: source file not found: ${source}\n`);
475
+ process.stderr.write(`vault-mirror: source file not found: ${pathForLog(source)}\n`);
459
476
  await finishRun('missing-source');
460
477
  process.exit(2);
461
478
  }
@@ -654,18 +671,27 @@ async function main() {
654
671
  if (isIdentifiable(entry) && !survivors.has(entry)) {
655
672
  // A losing duplicate: an earlier line whose `session_id` a LATER line
656
673
  // in this same batch supersedes or overwrites (crash-recovery
657
- // re-append, #1068 stub/supersede pair). No dispatch, no stdout line,
658
- // no tally for it — the winning occurrence (dispatched below, at its
659
- // own position) already produces the ONE note this physical session
660
- // gets. BV-004 ceiling: `runState.total` still counts this raw line,
661
- // so `created+updated+skipped+failed` no longer partitions `total`
662
- // for a `--kind session` run that collapsed at least one duplicate —
663
- // no test pins that invariant for session kind (only for `learning`,
664
- // where duplicates are not collapsed), and a partially-written vault
665
- // from a batch that could not be fully deduped is the worse failure
666
- // mode. Revisit with a dedicated telemetry action if an operator ever
667
- // needs to name WHICH lines were collapsed, not just how many notes
668
- // were written.
674
+ // re-append, #1068 stub/supersede pair). Never dispatched — the winning
675
+ // occurrence (dispatched below, at its own position) produces the ONE
676
+ // note this physical session gets. It is still ACCOUNTED for (#1291):
677
+ // `skipped-duplicate-session` goes through the same per-entry path as
678
+ // every other skip (stdout line + per-entry ledger record, no `path`
679
+ // because no target file exists for it) and into the tally, so
680
+ // `created+updated+skipped+failed === total` partitions a `--kind
681
+ // session` run that collapsed duplicates, and the ledger names WHICH
682
+ // lines were collapsed, not just how many notes were written.
683
+ tally(
684
+ await emitAction({
685
+ action: 'skipped-duplicate-session',
686
+ path: null,
687
+ kind,
688
+ id: entry.session_id,
689
+ vaultDir: ctx.vaultDir,
690
+ meta: { line: sessionLineNums[i] },
691
+ line: sessionLineNums[i],
692
+ dryRun,
693
+ }),
694
+ );
669
695
  continue;
670
696
  }
671
697
  await dispatchEntry(entry, sessionLineNums[i]);
@@ -37,6 +37,7 @@
37
37
  */
38
38
 
39
39
  import { parseArgs } from 'node:util';
40
+ import path from 'node:path';
40
41
  import { attributionForRecord, emitEvent } from './lib/events.mjs';
41
42
  import { MANIFEST_SESSION_KEYS } from './lib/session-identity/own-session.mjs';
42
43
  import { isMainModule } from './lib/is-main-module.mjs';
@@ -109,7 +110,7 @@ async function main(argv) {
109
110
  return;
110
111
  }
111
112
 
112
- const repoRoot = parsed.values['repo-root'] ?? process.cwd();
113
+ const repoRoot = path.resolve(parsed.values['repo-root'] ?? process.cwd());
113
114
  const binding = resolveBinding(repoRoot);
114
115
 
115
116
  if (Object.keys(binding).length === 0) {
@@ -133,14 +133,14 @@ Learnings live exclusively in `.orchestrator/metrics/learnings.jsonl`. The pre-`
133
133
 
134
134
  The learning lifecycle states are:
135
135
 
136
- - **Created**: `confidence: 0.5`, `expires_at`: current date + `learning-expiry-days` (default: 30)
137
- - **Confirmed** (same type+subject seen again): `confidence += 0.15` (cap 1.0), `expires_at` reset
136
+ - **Created**: `confidence: 0.5`, `expires_at` = `created_at` + a per-TYPE TTL — `deriveExpiresAt(created_at, type)` with `LEARNING_TTL_DAYS[type] ?? LEARNING_TTL_DAYS.default` (`scripts/lib/learnings/schema.mjs`; 30–90 days per type, 60 for an unknown type). A caller-supplied `expires_at` is kept. `learning-expiry-days` does NOT set it: `scripts/lib/config.mjs` still parses that key (default 30), but no script or hook reads the parsed value — it survives only in skill prose (the `/evolve` Review-mode *Extend* action).
137
+ - **Confirmed** (same type+subject seen again): `confidence += 0.15` (cap 1.0), `expires_at` reset to `deriveExpiresAt(now, type)`
138
138
  - **Contradicted** (evidence against): `confidence -= 0.2` — do NOT reset `expires_at` (let the learning decay naturally if contradicted)
139
139
  - **Decayed** (untouched this session): `confidence -= learning-decay-rate` (from Session Config, default `0.05`). Applied at session-end after touched-set update, before prune. Clamped to 0.0. Does NOT reset `expires_at`.
140
140
  - **Expired**: `expires_at < current date` — removed on next write
141
141
  - **Dead**: `confidence <= 0.0` — removed on next write
142
142
 
143
- **Expiration check semantics:** Compare `expires_at` by date portion only (ignore time-of-day) to avoid intra-day jitter. When writing `expires_at`, set it to `<current_date>T00:00:00Z + learning-expiry-days` (midnight UTC).
143
+ **Expiration check semantics:** The code compares full timestamps, not dates: `pruneLearnings()` (`scripts/lib/learnings/expiry-sweep.mjs`) archives when `expires_at < now`; the time-driven sweep archives only after a 14-day grace window past `expires_at`. When writing `expires_at`, derive it with `deriveExpiresAt()` (full ISO timestamp, no midnight rounding) — never from `learning-expiry-days`.
144
144
 
145
145
  **Confidence bounds enforcement:** After EVERY increment or decrement, clamp confidence to [0.0, 1.0]. A learning at 0.95 confirmed becomes 1.0 (not 1.10). A learning at 0.1 contradicted becomes 0.0 and is pruned.
146
146