session-orchestrator 3.17.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 (221) 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/.cursor/rules/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -23,24 +23,43 @@
23
23
  * `confidence`, `expires-at`. Glob values are always double-quoted (the loader
24
24
  * strips surrounding quotes), which keeps `*`/`[`/`{` safe.
25
25
  *
26
+ * ── Untrusted-input containment (issue #1015) ────────────────────────────────
27
+ * Every field this renderer interpolates is AGENT-AUTHORED, and the file it
28
+ * produces becomes a project instruction delivered to every agent in every
29
+ * session — permanently, with no revocation. {@link renderRule} therefore
30
+ * applies `sanitize.mjs` at the render point, in two modes:
31
+ * - MACHINE values (`description`, `globs[]`, `host-class`, `learning-key`,
32
+ * `confidence`, `expires-at`, `id`, `source_session`) are ASSERTED and the
33
+ * record REJECTED (throw) on any violation — never silently repaired.
34
+ * - PROSE (`title`/`subject`, `insight`, `evidence`) is stripped of
35
+ * unambiguous non-content, hard-capped in bytes, and FRAMED in the
36
+ * `untrusted-content` envelope that states it is data, not instruction.
37
+ * The guard lives HERE and not only in the emitter for the same reason the
38
+ * brandmauer is re-checked below: `renderRule` is exported and callable with
39
+ * hand-built metadata, so the emitter's guards do not bind on it.
40
+ *
26
41
  * @module reconcile/renderer
27
42
  */
28
43
 
29
44
  import { createHash } from 'node:crypto';
30
45
 
31
- /**
32
- * Slugify a string into a stable kebab-case token: lowercases, collapses every
33
- * run of non-`[a-z0-9]` chars into a single `-`, and trims leading/trailing `-`.
34
- *
35
- * @param {string} s
36
- * @returns {string}
37
- */
38
- function kebab(s) {
39
- return String(s)
40
- .toLowerCase()
41
- .replace(/[^a-z0-9]+/g, '-')
42
- .replace(/^-+|-+$/g, '');
43
- }
46
+ import { kebab } from '../learnings/kebab.mjs';
47
+ import {
48
+ EVIDENCE_ITEM_MAX_BYTES,
49
+ EVIDENCE_MAX_BYTES,
50
+ EXPIRES_AT_RE,
51
+ HOST_CLASS_RE,
52
+ INSIGHT_MAX_BYTES,
53
+ LEARNING_KEY_RE,
54
+ PROVENANCE_TOKEN_RE,
55
+ TITLE_MAX_BYTES,
56
+ UNTRUSTED_BEGIN,
57
+ UNTRUSTED_END,
58
+ assertMachineToken,
59
+ assertSafeDescription,
60
+ assertSafeGlob,
61
+ sanitizeProse,
62
+ } from './sanitize.mjs';
44
63
 
45
64
  /**
46
65
  * Short, stable SHA-1 prefix (first 7 hex chars) of an input string.
@@ -93,20 +112,47 @@ export function deriveSlug(learning) {
93
112
  * Array → one `- ` bullet per item; string → as-is; absent/empty →
94
113
  * `(no evidence recorded)`.
95
114
  *
115
+ * Untrusted (#1015): every item passes through {@link sanitizeProse} with a
116
+ * per-bullet byte cap, and the whole region is bounded by
117
+ * {@link EVIDENCE_MAX_BYTES}. Items that would cross the region budget are
118
+ * dropped with a machine-emitted note — visibly, never silently. Because
119
+ * `EVIDENCE_ITEM_MAX_BYTES < EVIDENCE_MAX_BYTES` by construction, at least the
120
+ * first item always fits.
121
+ *
96
122
  * @param {unknown} evidence
97
123
  * @returns {string}
124
+ * @throws {Error} when an item carries a delivery-wrapper forgery literal
98
125
  */
99
126
  function renderEvidence(evidence) {
100
- if (Array.isArray(evidence)) {
101
- const lines = evidence
102
- .filter((e) => e !== null && e !== undefined && String(e) !== '')
103
- .map((e) => `- ${String(e)}`);
104
- return lines.length > 0 ? lines.join('\n') : '(no evidence recorded)';
127
+ const isArray = Array.isArray(evidence);
128
+ const items = isArray
129
+ ? evidence.filter((e) => e !== null && e !== undefined && String(e) !== '')
130
+ : typeof evidence === 'string' && evidence !== ''
131
+ ? [evidence]
132
+ : [];
133
+
134
+ if (items.length === 0) return '(no evidence recorded)';
135
+
136
+ /** @type {string[]} */
137
+ const lines = [];
138
+ let usedBytes = 0;
139
+ for (const item of items) {
140
+ const safe = sanitizeProse(item, { field: 'evidence', maxBytes: EVIDENCE_ITEM_MAX_BYTES });
141
+ const line = isArray ? `- ${safe}` : safe;
142
+ const width = Buffer.byteLength(line, 'utf8');
143
+ if (lines.length > 0 && usedBytes + width > EVIDENCE_MAX_BYTES) break;
144
+ lines.push(line);
145
+ usedBytes += width;
105
146
  }
106
- if (typeof evidence === 'string' && evidence !== '') {
107
- return evidence;
147
+
148
+ const omitted = items.length - lines.length;
149
+ if (omitted > 0) {
150
+ lines.push(
151
+ `- […${omitted} further evidence item(s) omitted by the reconciliation engine: ${EVIDENCE_MAX_BYTES}-byte region cap]`,
152
+ );
108
153
  }
109
- return '(no evidence recorded)';
154
+
155
+ return lines.join('\n');
110
156
  }
111
157
 
112
158
  /**
@@ -120,6 +166,14 @@ function renderEvidence(evidence) {
120
166
  * @returns {{ slug: string, path: string, content: string }}
121
167
  * @throws {Error} when `metadata.globs` is empty AND `metadata.hostClass` is
122
168
  * falsy — the never-always-on invariant (defends the emitter's guard).
169
+ * @throws {Error} (`reconcile-sanitize: rejecting record — …`) when a machine
170
+ * value is malformed (a `description` with a control char, a dangerous
171
+ * invisible, a wrapper-forgery literal or over its byte budget; a `globs[]`
172
+ * element with a quote/control char/invisible; a non-token
173
+ * `host-class`/`learning-key`/`id`/`source_session`; a non-finite
174
+ * `confidence`; a non-`YYYY-MM-DD` `expires-at`) or when prose forges the
175
+ * delivery wrapper's framing — issue #1015. The record is REJECTED, never
176
+ * repaired.
123
177
  */
124
178
  export function renderRule(learning, metadata) {
125
179
  if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) {
@@ -139,18 +193,59 @@ export function renderRule(learning, metadata) {
139
193
  );
140
194
  }
141
195
 
196
+ // ── Machine-value gate (#1015) — assert, never repair ─────────────────────
197
+ // Each of these is serialised into a frontmatter scalar (or, for the last
198
+ // two, into an inline-code span). A violation REJECTS the whole record: the
199
+ // engine catches the throw and records an audited `emit/render error: …`
200
+ // rejection, so a hostile record costs one rejected proposal, never a rule
201
+ // file. Repairing instead of rejecting would invent information — see
202
+ // `sanitize.mjs` for the per-field reasoning.
203
+ // `description` is the ONE agent-authored value emitted outside the untrusted
204
+ // envelope (a frontmatter scalar cannot carry the envelope's HTML comment —
205
+ // the loader would read the comment AS the description). It therefore gets
206
+ // equivalent neutralisation instead of framing: no frontmatter escape, no
207
+ // smuggled invisibles, no delivery-wrapper forgery, bounded length. See
208
+ // `sanitize.mjs` § assertSafeDescription.
209
+ assertSafeDescription(metadata.description);
210
+ for (const glob of globs) assertSafeGlob(glob);
211
+ if (hostClass !== undefined) {
212
+ assertMachineToken(hostClass, { field: 'host-class', pattern: HOST_CLASS_RE });
213
+ }
214
+ assertMachineToken(metadata.learningKey, { field: 'learning-key', pattern: LEARNING_KEY_RE });
215
+ if (!Number.isFinite(metadata.confidence)) {
216
+ // A non-finite confidence is SILENTLY DROPPED by the loader's `Number(...)`
217
+ // coercion, so the rule would load with no confidence at all rather than
218
+ // fail loudly.
219
+ throw new Error(
220
+ `reconcile-sanitize: rejecting record — confidence must be a finite number (got ${JSON.stringify(metadata.confidence)})`,
221
+ );
222
+ }
223
+ assertMachineToken(metadata.expiresAt, { field: 'expires-at', pattern: EXPIRES_AT_RE });
224
+
142
225
  const slug = deriveSlug(learning);
143
226
  const path = `.claude/rules/${slug}.md`;
144
227
 
145
- const humanTitle =
228
+ // Prose sources. `deriveSlug` is safe by construction (its `kebab` collapses
229
+ // every non-`[a-z0-9]` run, so no title can steer the output path), but the
230
+ // rendered H1 carries the raw value — `title` outranks `subject` and has no
231
+ // schema constraint at all, so it is capped and framed like any other prose.
232
+ const rawTitle =
146
233
  (typeof learning.title === 'string' && learning.title !== '' ? learning.title : '') ||
147
234
  (typeof learning.subject === 'string' && learning.subject !== '' ? learning.subject : '') ||
148
235
  metadata.learningKey ||
149
236
  slug;
237
+ const safeTitle = sanitizeProse(rawTitle, { field: 'title', maxBytes: TITLE_MAX_BYTES })
238
+ // The H1 is a single line: a newline in the title would end the heading and
239
+ // let the remainder render as top-level markdown of the attacker's choosing.
240
+ .replace(/[\t\n]+/g, ' ')
241
+ .trim();
242
+ // A title made only of stripped invisibles sanitises to '' — fall back to the
243
+ // slug so the H1 is never empty.
244
+ const humanTitle = safeTitle !== '' ? safeTitle : slug;
150
245
 
151
246
  const insight =
152
247
  typeof learning.insight === 'string' && learning.insight !== ''
153
- ? learning.insight
248
+ ? sanitizeProse(learning.insight, { field: 'insight', maxBytes: INSIGHT_MAX_BYTES })
154
249
  : '(no insight recorded)';
155
250
 
156
251
  // ── Frontmatter (fixed key order; loader-parseable) ──────────────────────
@@ -182,11 +277,30 @@ export function renderRule(learning, metadata) {
182
277
  fm.push('---');
183
278
 
184
279
  // ── Body (free markdown; does not affect frontmatter parsing) ────────────
185
- const learningId = learning.id ? String(learning.id) : 'n/a';
186
- const sourceSession = learning.source_session ? String(learning.source_session) : 'n/a';
280
+ // Both provenance values are rendered INSIDE an inline-code span below, which
281
+ // an interior backtick would close so they are asserted as machine tokens
282
+ // rather than escaped. The 'n/a' fallbacks are machine-emitted, not asserted.
283
+ const learningId = learning.id
284
+ ? assertMachineToken(String(learning.id), {
285
+ field: 'learning-id',
286
+ pattern: PROVENANCE_TOKEN_RE,
287
+ })
288
+ : 'n/a';
289
+ const sourceSession = learning.source_session
290
+ ? assertMachineToken(String(learning.source_session), {
291
+ field: 'source-session',
292
+ pattern: PROVENANCE_TOKEN_RE,
293
+ })
294
+ : 'n/a';
187
295
 
296
+ // The untrusted region (H1 + insight + Evidence) is FRAMED: the envelope
297
+ // states, in machine-generated text the record cannot forge, that everything
298
+ // inside is agent-authored data rather than an instruction to the reading
299
+ // agent. Provenance stays OUTSIDE the envelope — it is machine-derived and
300
+ // asserted above.
188
301
  const body = [
189
302
  '',
303
+ UNTRUSTED_BEGIN,
190
304
  `# Auto-generated rule: ${humanTitle}`,
191
305
  '',
192
306
  insight,
@@ -194,6 +308,8 @@ export function renderRule(learning, metadata) {
194
308
  '## Evidence',
195
309
  renderEvidence(learning.evidence),
196
310
  '',
311
+ UNTRUSTED_END,
312
+ '',
197
313
  '<!-- provenance (auto-generated by the reconciliation engine — do not hand-edit) -->',
198
314
  '## Provenance',
199
315
  `- learning-key: \`${metadata.learningKey}\``,