session-orchestrator 3.16.0 → 3.19.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 (220) 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 +130 -412
  6. package/README.md +17 -12
  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/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  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 +97 -0
  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/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
@@ -14,6 +14,11 @@
14
14
  * - Never throws — all errors are caught and returned as `{ stripped: [],
15
15
  * error: string }` so callers can log and proceed with close unblocked.
16
16
  * - Supports GitLab (`glab`) and GitHub (`gh`). VCS is a required param.
17
+ * - Every `glab`/`gh` spawn is pinned to the resolved repo via `-R <spec>`
18
+ * (#839) — see `scripts/lib/vcs-repo-spec.mjs` for why: a bare spawn
19
+ * falls back to the ambient `GITLAB_HOST`/`GH_HOST`, which can silently
20
+ * resolve to the wrong GitLab instance on a multi-host machine, turning
21
+ * this whole module into a silent no-op.
17
22
  *
18
23
  * Usage (from session-end Phase 5 issue-close loop):
19
24
  *
@@ -28,6 +33,7 @@
28
33
  */
29
34
 
30
35
  import { execFileSync } from 'node:child_process';
36
+ import { resolveRepoSpec } from './vcs-repo-spec.mjs';
31
37
 
32
38
  /** Regex that matches any `status:*` label name. */
33
39
  const STATUS_LABEL_RE = /^status:/;
@@ -57,15 +63,19 @@ function runCli(cmd, args) {
57
63
  /**
58
64
  * Fetch the label names currently applied to an issue.
59
65
  *
60
- * @param {{ issueId: number | string, vcs: 'gitlab' | 'github' }} opts
66
+ * @param {{ issueId: number | string, vcs: 'gitlab' | 'github', spec?: string }} opts
67
+ * `spec` is the resolved `-R`/`--repo` host-pinning value (#839); omitted
68
+ * (undefined) means no `-R` is appended — never emit `-R undefined`.
61
69
  * @returns {{ ok: boolean, labels: string[], stderr: string }}
62
70
  */
63
- function fetchLabels({ issueId, vcs }) {
71
+ function fetchLabels({ issueId, vcs, spec }) {
64
72
  const id = String(issueId);
65
73
 
66
74
  if (vcs === 'github') {
67
75
  // `gh issue view <NUMBER> --json labels` returns { labels: [{name, ...}] }
68
- const res = runCli('gh', ['issue', 'view', id, '--json', 'labels']);
76
+ const args = ['issue', 'view', id, '--json', 'labels'];
77
+ if (spec) args.push('-R', spec);
78
+ const res = runCli('gh', args);
69
79
  if (!res.ok) return { ok: false, labels: [], stderr: res.stderr };
70
80
  let parsed;
71
81
  try {
@@ -81,7 +91,9 @@ function fetchLabels({ issueId, vcs }) {
81
91
 
82
92
  // Default: gitlab via glab.
83
93
  // `glab issue view <IID> --output json` returns an issue object with a `labels` array.
84
- const res = runCli('glab', ['issue', 'view', id, '--output', 'json']);
94
+ const args = ['issue', 'view', id, '--output', 'json'];
95
+ if (spec) args.push('-R', spec);
96
+ const res = runCli('glab', args);
85
97
  if (!res.ok) return { ok: false, labels: [], stderr: res.stderr };
86
98
  let parsed;
87
99
  try {
@@ -89,7 +101,7 @@ function fetchLabels({ issueId, vcs }) {
89
101
  } catch (e) {
90
102
  return { ok: false, labels: [], stderr: `JSON parse failed: ${e && e.message ? e.message : String(e)}` };
91
103
  }
92
- // GitLab JSON shape: { labels: ["status:ready", "priority:high", ...] }
104
+ // GitLab JSON shape: { labels: ["status:ready", "priority::high", ...] }
93
105
  const labels = Array.isArray(parsed?.labels)
94
106
  ? parsed.labels.map((l) => (typeof l === 'string' ? l : String(l))).filter(Boolean)
95
107
  : [];
@@ -105,11 +117,22 @@ function fetchLabels({ issueId, vcs }) {
105
117
  *
106
118
  * @param {{
107
119
  * issueId: number | string,
108
- * vcs?: 'gitlab' | 'github'
120
+ * vcs?: 'gitlab' | 'github',
121
+ * repoRoot?: string,
122
+ * resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab' | 'github' }) => string | undefined
109
123
  * }} opts
124
+ * `repoRoot` defaults to `process.cwd()`. `resolveRepoSpecFn` is the
125
+ * injectable seam for the `-R`/`--repo` host-pinning resolution (#839) —
126
+ * defaults to the real `resolveRepoSpec` (shells out to `git remote
127
+ * get-url`); tests inject a stub instead of shelling out.
110
128
  * @returns {Promise<{ stripped: string[], error?: string }>}
111
129
  */
112
- export async function stripStatusLabels({ issueId, vcs = 'gitlab' } = {}) {
130
+ export async function stripStatusLabels({
131
+ issueId,
132
+ vcs = 'gitlab',
133
+ repoRoot = process.cwd(),
134
+ resolveRepoSpecFn = resolveRepoSpec,
135
+ } = {}) {
113
136
  try {
114
137
  const id = String(issueId ?? '').trim();
115
138
  if (!id || id === 'undefined' || id === 'null') {
@@ -118,8 +141,12 @@ export async function stripStatusLabels({ issueId, vcs = 'gitlab' } = {}) {
118
141
 
119
142
  const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
120
143
 
144
+ // Resolve the -R/--repo host-pinning spec ONCE, reused by both the fetch
145
+ // and the strip call below (#839). undefined ⇒ no -R is appended anywhere.
146
+ const spec = resolveRepoSpecFn({ repoRoot, vcs: vcsResolved });
147
+
121
148
  // Step 1: fetch current labels.
122
- const { ok, labels, stderr } = fetchLabels({ issueId: id, vcs: vcsResolved });
149
+ const { ok, labels, stderr } = fetchLabels({ issueId: id, vcs: vcsResolved, spec });
123
150
  if (!ok) {
124
151
  return { stripped: [], error: `failed to fetch labels: ${stderr}` };
125
152
  }
@@ -139,11 +166,14 @@ export async function stripStatusLabels({ issueId, vcs = 'gitlab' } = {}) {
139
166
  for (const label of toStrip) {
140
167
  args.push('--remove-label', label);
141
168
  }
169
+ if (spec) args.push('-R', spec);
142
170
  stripRes = runCli('gh', args);
143
171
  } else {
144
172
  // `glab issue update <IID> --unlabel label1,label2,...`
145
173
  // glab accepts comma-separated list in a single --unlabel flag value.
146
- stripRes = runCli('glab', ['issue', 'update', id, '--unlabel', toStrip.join(',')]);
174
+ const args = ['issue', 'update', id, '--unlabel', toStrip.join(',')];
175
+ if (spec) args.push('-R', spec);
176
+ stripRes = runCli('glab', args);
147
177
  }
148
178
 
149
179
  if (!stripRes.ok) {
@@ -0,0 +1,47 @@
1
+ /**
2
+ * label-scope.mjs — scope-tolerant label comparison.
3
+ *
4
+ * The canonical priority-label spelling is the GitLab SCOPED form
5
+ * `priority::<level>`. Producers were migrated to it because the instance had
6
+ * drifted to 416 scoped / 249 unscoped / 7 bare labels, and a data migration
7
+ * that is not preceded by a producer migration is undone within a day.
8
+ *
9
+ * The DATA migration is a separate, later step, so for now both spellings
10
+ * coexist on real issues. Every consumer that MATCHES a label therefore
11
+ * compares through `normalizeLabel()`, which collapses the scope separator:
12
+ * `priority::high` and `priority:high` are the same label for matching
13
+ * purposes, while the emitted/default spelling stays canonical.
14
+ *
15
+ * Deliberately NOT applied to label WRITES — those always emit the canonical
16
+ * scoped form.
17
+ *
18
+ * Stdlib-free leaf module.
19
+ */
20
+
21
+ /**
22
+ * Collapse a scoped label's double colon to a single colon and lowercase it,
23
+ * yielding a comparison key that is identical for `key::value` and `key:value`.
24
+ *
25
+ * @param {unknown} label
26
+ * @returns {string} normalized comparison key ('' for non-strings)
27
+ */
28
+ export function normalizeLabel(label) {
29
+ if (typeof label !== 'string') return '';
30
+ return label.trim().toLowerCase().replace(/::/g, ':');
31
+ }
32
+
33
+ /**
34
+ * Build a Set of normalized comparison keys from a label list.
35
+ *
36
+ * @param {unknown} labels
37
+ * @returns {Set<string>}
38
+ */
39
+ export function normalizedLabelSet(labels) {
40
+ const set = new Set();
41
+ if (!Array.isArray(labels)) return set;
42
+ for (const l of labels) {
43
+ const key = normalizeLabel(l);
44
+ if (key) set.add(key);
45
+ }
46
+ return set;
47
+ }
@@ -83,8 +83,13 @@ export const LEARNING_TYPE_REGISTRY = Object.freeze({
83
83
  'fragile-file': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: true }),
84
84
  'effective-sizing': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: false }),
85
85
  'recurring-issue': Object.freeze({ ttlDays: 45, agentProposable: true, ruleConvertible: true }),
86
- 'workflow-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: false }),
87
- 'proven-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: false }),
86
+ // workflow-pattern / proven-pattern: flipped ruleConvertible false->true
87
+ // (issue #900) the real corpus census showed a large volume of live
88
+ // `workflow-pattern`/`proven-pattern` records (post type-alias-normalization,
89
+ // see LEARNING_TYPE_ALIASES below) that carried usable file_paths but were
90
+ // structurally unconvertible before this flip.
91
+ 'workflow-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
92
+ 'proven-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
88
93
  'anti-pattern': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: true }),
89
94
  'autopilot-effectiveness': Object.freeze({ ttlDays: 90, agentProposable: true, ruleConvertible: false }),
90
95
  // autonomy-verdict (#683): repo/scope readiness synthesis from autopilot
@@ -106,6 +111,30 @@ export const LEARNING_TYPE_REGISTRY = Object.freeze({
106
111
  'stagnation-class-frequency': Object.freeze({ ttlDays: 60, agentProposable: false, ruleConvertible: true }),
107
112
  });
108
113
 
114
+ /**
115
+ * Free-form producer-dialect type names -> their canonical
116
+ * {@link LEARNING_TYPE_REGISTRY} counterpart (issue #900). The real corpus
117
+ * accumulated learnings stamped with type names that were never registered
118
+ * (`gotcha`, `pattern`, ...) — these are semantically the SAME classes as
119
+ * two registered types, just written with a different literal. Applied by
120
+ * {@link normalizeDialects} on both the read funnel (`normalizeLearning`)
121
+ * and the write/migration funnel (`migrateLegacyLearning`), so every
122
+ * downstream consumer (TTL derivation, `CONVERT_TYPES` / `PROPOSAL_TYPES`
123
+ * membership, reconcile eligibility) sees one canonical type space —
124
+ * mirrors the existing `files` -> `file_paths` dialect-normalization
125
+ * pattern one level up (type name instead of field name).
126
+ *
127
+ * INVARIANT (guarded by a test): no alias KEY may collide with a
128
+ * `LEARNING_TYPE_REGISTRY` key — a collision would silently rewrite an
129
+ * already-canonical type into a different one.
130
+ *
131
+ * @type {Readonly<Record<string, string>>}
132
+ */
133
+ export const LEARNING_TYPE_ALIASES = Object.freeze({
134
+ gotcha: 'anti-pattern',
135
+ pattern: 'proven-pattern',
136
+ });
137
+
109
138
  /**
110
139
  * Per-type TTL policy (in days) for `expires_at` derivation. Derived from
111
140
  * {@link LEARNING_TYPE_REGISTRY} — see parent module documentation for tier
@@ -275,7 +304,10 @@ const _warnedSessionIdConflict = new Set();
275
304
  * session_id/source_session conflict (see below); idempotent; never throws;
276
305
  * non-objects (and arrays) pass through unchanged. Does NOT mutate its input.
277
306
  *
278
- * Dialect rules (Epic #723 B2 — census 2026-07-02):
307
+ * Dialect rules (Epic #723 B2 — census 2026-07-02; type-alias rule added
308
+ * issue #900):
309
+ * - `type` → canonical type — resolved via {@link LEARNING_TYPE_ALIASES}
310
+ * (e.g. `gotcha` → `anti-pattern`, `pattern` → `proven-pattern`).
279
311
  * - `files` → `file_paths` — verbatim value move; an empty array is
280
312
  * preserved as an empty `file_paths`; a canonical
281
313
  * `file_paths` already present wins (legacy `files` dropped).
@@ -310,6 +342,14 @@ export function normalizeDialects(entry, { reserializeTimestamps = true } = {})
310
342
 
311
343
  const out = { ...entry };
312
344
 
345
+ // type alias resolution (#900) — canonicalize free-form producer type names
346
+ // (e.g. `gotcha`, `pattern`) to their LEARNING_TYPE_REGISTRY counterpart so
347
+ // every downstream consumer (TTL derivation, CONVERT_TYPES / PROPOSAL_TYPES
348
+ // membership, reconcile eligibility) sees one canonical type space.
349
+ if (typeof out.type === 'string' && out.type in LEARNING_TYPE_ALIASES) {
350
+ out.type = LEARNING_TYPE_ALIASES[out.type];
351
+ }
352
+
313
353
  // files → file_paths (verbatim value move; canonical wins; empty array kept).
314
354
  if ('files' in out) {
315
355
  if (!('file_paths' in out)) {
@@ -70,7 +70,6 @@ import { readLock, isLockLive, isPidAliveOnHost, LOCK_PATH, DEFAULT_TTL_HOURS }
70
70
  import { emitEvent } from './events.mjs';
71
71
 
72
72
  const REAPED_ARCHIVE_SUBDIR = '.orchestrator/tmp/reaped-locks';
73
- const EVENTS_RELPATH = '.orchestrator/metrics/events.jsonl';
74
73
  const REAPED_EVENT = 'orchestrator.session.lock.reaped';
75
74
  const CURRENT_SESSION_RELPATH = '.orchestrator/current-session.json';
76
75
 
@@ -550,7 +549,7 @@ async function evaluateRepo(repoRoot, { nowMs, dryRun, currentSessionId, reapMod
550
549
  reap_mode: reapMode,
551
550
  current_session: currentSession,
552
551
  },
553
- { filePath: path.join(repoRoot, EVENTS_RELPATH) },
552
+ { repoRoot },
554
553
  );
555
554
  } catch {
556
555
  // Observability is best-effort — the archive-move already succeeded.
@@ -22,7 +22,11 @@
22
22
  * schema_version 1
23
23
  *
24
24
  * Optional fields:
25
- * proposed_by_agent string | undefined — identifier of the submitting agent
25
+ * proposed_by_agent string | undefined — identifier of the submitting agent
26
+ * file_paths string[] | undefined — repo-relative path(s) this
27
+ * proposal applies to (issue #900 C). Omitted entirely when empty/absent
28
+ * — never set to `[]`. Required, downstream, for a promoted learning to
29
+ * ever become /reconcile-eligible (see `reconcile/eligibility.mjs`).
26
30
  */
27
31
 
28
32
  import { randomUUID } from 'node:crypto';
@@ -86,6 +90,9 @@ const EVIDENCE_MAX = 5000;
86
90
  * @param {number} opts.confidence — [0, 1]
87
91
  * @param {string} opts.waveId — e.g. 'W2'
88
92
  * @param {string} [opts.proposedByAgent] — optional agent identifier
93
+ * @param {string[]} [opts.filePaths] — optional repo-relative path(s) this
94
+ * proposal applies to (issue #900 C). Set on the record ONLY when a
95
+ * non-empty array is supplied — mirrors the `proposedByAgent` pattern.
89
96
  * @returns {object} complete proposal record
90
97
  */
91
98
  export function createProposalRecord({
@@ -96,6 +103,7 @@ export function createProposalRecord({
96
103
  confidence,
97
104
  waveId,
98
105
  proposedByAgent,
106
+ filePaths,
99
107
  }) {
100
108
  const record = {
101
109
  id: randomUUID(),
@@ -113,6 +121,10 @@ export function createProposalRecord({
113
121
  record.proposed_by_agent = proposedByAgent;
114
122
  }
115
123
 
124
+ if (Array.isArray(filePaths) && filePaths.length > 0) {
125
+ record.file_paths = filePaths;
126
+ }
127
+
116
128
  return record;
117
129
  }
118
130
 
@@ -238,6 +250,29 @@ export function validateProposalRecord(record) {
238
250
  errors.push('proposed_by_agent must be a string when present');
239
251
  }
240
252
 
253
+ // file_paths (optional, but if present must be a non-empty array of
254
+ // non-empty, newline-free, glob-metacharacter-free strings) — issue #900 C,
255
+ // Q3-MED fix pass: a glob metacharacter (* ? [ ] { }) surviving this schema
256
+ // gate would later reach emitter.mjs::globsFromFilePaths verbatim for a
257
+ // top-level (dirname==='.') entry, effectively producing an always-on rule
258
+ // glob (e.g. `file_paths: ['**']`). `createProposalRecord` never sets an
259
+ // empty array (it omits the key), so a present-but-empty `[]` is itself a
260
+ // signal of a malformed/tampered record — reject it explicitly.
261
+ if ('file_paths' in record) {
262
+ const fp = record.file_paths;
263
+ const isValidArray =
264
+ Array.isArray(fp) &&
265
+ fp.length > 0 &&
266
+ fp.every(
267
+ (p) => typeof p === 'string' && p.length > 0 && !/[\r\n]/.test(p) && !/[*?[\]{}]/.test(p),
268
+ );
269
+ if (!isValidArray) {
270
+ errors.push(
271
+ 'file_paths must be a non-empty array of non-empty, newline-free, glob-metacharacter-free strings when present',
272
+ );
273
+ }
274
+ }
275
+
241
276
  if (errors.length > 0) {
242
277
  return { ok: false, errors };
243
278
  }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * moc-staleness-banner.mjs — Session-start banner for issue #831 (building
3
+ * block B2).
4
+ *
5
+ * Surfaces a `warn` banner during session-start Phase 4 when a Meta-Vault
6
+ * MOC (map-of-content) index note — a `<vaultDir>/08-topics/*-moc.md` file —
7
+ * has an `updated:` frontmatter older than a configurable threshold (default
8
+ * 90 days). Nothing in this repo reads `08-topics/` before this module; it
9
+ * is the first consumer.
10
+ *
11
+ * Design notes:
12
+ * - Mirrors the contract used by every other Phase 4 banner
13
+ * (`scripts/lib/peer-cards/staleness-banner.mjs`, `scripts/lib/vault-staleness-banner.mjs`,
14
+ * `scripts/lib/loop-readiness-banner.mjs`, `scripts/lib/reconcile-nudge-banner.mjs`,
15
+ * and sibling building block `scripts/lib/context-coverage-banner.mjs` — B4
16
+ * of this same issue): a single `checkXxx()` entry point that returns
17
+ * `null` (silent no-op) or `{ severity, message, ... }` — never an array,
18
+ * never `undefined`, never a throw.
19
+ * - Synchronous — the probe only touches `existsSync`/`readdirSync`/`readFileSync`,
20
+ * so unlike the async peer-cards/reconcile-nudge probes this one needs no
21
+ * `await` at the call site (mirrors `checkLoopReadiness` / `checkContextCoverage`).
22
+ * - Never throws. Wrapped in an outermost defensive `try/catch`; every
23
+ * individually-fallible filesystem call additionally gets its own inner
24
+ * bare (no-binding) catch with a one-line explanatory comment.
25
+ * - `vault-dir` resolution mirrors the host-local-override pattern used
26
+ * throughout the plugin (issue #653): an injected `opts.vaultDir` test
27
+ * seam wins, then `config['vault-integration']['vault-dir']`, else the
28
+ * probe silently no-ops (no vault configured — nothing to check).
29
+ * - The committed repo default for `vault-integration.vault-dir` is
30
+ * tilde-prefixed (`~/Projects/vault`) and is NOT pre-expanded anywhere
31
+ * upstream of this module — `expandTilde()` from `./common.mjs` is applied
32
+ * unconditionally before the first `path.join`, exactly as
33
+ * `checkContextCoverage()` does for the same reason.
34
+ * - A `*-moc.md` file whose `updated:` frontmatter is missing or
35
+ * unparseable is EXCLUDED, not reported stale — mirrors
36
+ * `scripts/lib/peer-cards/staleness-banner.mjs` (~L61-76): the corrective
37
+ * action there is "fix the frontmatter", not this banner's "refresh the
38
+ * `updated:` date" remediation hint. Gated via `Number.isFinite(days)`.
39
+ *
40
+ * Cross-references:
41
+ * - `scripts/lib/config/moc-staleness.mjs` (`_parseMocStaleness`) — the
42
+ * `moc-staleness:` Session Config block parser. NOT wired into
43
+ * `scripts/lib/config.mjs` by this module — the coordinator registers it
44
+ * separately. The exact lines to add there:
45
+ *
46
+ * import { _parseMocStaleness } from './config/moc-staleness.mjs';
47
+ * // ... later, alongside the other top-level block parses:
48
+ * const mocStaleness = _parseMocStaleness(mdContent);
49
+ * // ... in the returned config object:
50
+ * 'moc-staleness': mocStaleness,
51
+ *
52
+ * - `scripts/lib/common.mjs` (`expandTilde`) — shared tilde-expansion helper.
53
+ * - `scripts/lib/context-coverage-banner.mjs` — sibling B4 building block for
54
+ * the same issue; this module mirrors its structure closely.
55
+ * - `skills/session-start/SKILL.md` Phase 4 — banner render site (wiring
56
+ * snippet supplied separately; this module does not edit that file).
57
+ * - Issue #831 (building block B2).
58
+ */
59
+
60
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
61
+ import path from 'node:path';
62
+ import YAML from 'js-yaml';
63
+
64
+ import { expandTilde } from './common.mjs';
65
+
66
+ /** Vault-relative directory holding MOC (map-of-content) index notes. */
67
+ const MOC_SUBDIR = '08-topics';
68
+
69
+ /** Filename suffix that identifies a MOC note within `08-topics/`. */
70
+ const MOC_SUFFIX = '-moc.md';
71
+
72
+ /** Fallback staleness threshold (days) when config supplies none/invalid. */
73
+ const DEFAULT_THRESHOLD_DAYS = 90;
74
+
75
+ /** Matches a leading `---\n...\n---` YAML frontmatter fence. */
76
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
77
+
78
+ /**
79
+ * Above this many stale filenames, the message truncates the name list and
80
+ * says so explicitly rather than silently dropping names past the limit.
81
+ */
82
+ const MAX_STALE_NAMES_IN_MESSAGE = 20;
83
+
84
+ /**
85
+ * Resolve the vault directory to scan.
86
+ *
87
+ * Precedence: `opts.vaultDir` (test seam) > `config['vault-integration']['vault-dir']` > null.
88
+ *
89
+ * @param {string|undefined} vaultDir
90
+ * @param {unknown} config
91
+ * @returns {string|null} raw (not-yet-tilde-expanded) vault dir, or null when unresolvable
92
+ */
93
+ function _resolveRawVaultDir(vaultDir, config) {
94
+ if (typeof vaultDir === 'string' && vaultDir.length > 0) return vaultDir;
95
+
96
+ if (config && typeof config === 'object') {
97
+ const vaultIntegration = /** @type {Record<string, unknown>} */ (config)['vault-integration'];
98
+ if (vaultIntegration && typeof vaultIntegration === 'object') {
99
+ const raw = /** @type {Record<string, unknown>} */ (vaultIntegration)['vault-dir'];
100
+ if (typeof raw === 'string' && raw.length > 0) return raw;
101
+ }
102
+ }
103
+
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Read a single MOC file and compute whole days since its `updated:`
109
+ * frontmatter field. Returns `NaN` (never throws) when the file cannot be
110
+ * read, has no frontmatter fence, the YAML fails to parse, `updated` is
111
+ * missing/non-string, or `updated` is not a parseable date — every one of
112
+ * these cases is the caller's signal to EXCLUDE the file, not report it
113
+ * stale.
114
+ *
115
+ * @param {string} filePath — absolute path to a `*-moc.md` file
116
+ * @param {number} nowMs — epoch ms clock
117
+ * @returns {number} whole days since `updated:`, or `NaN`
118
+ */
119
+ function _readMocStalenessDays(filePath, nowMs) {
120
+ let raw;
121
+ try {
122
+ raw = readFileSync(filePath, 'utf8');
123
+ } catch {
124
+ return NaN;
125
+ }
126
+
127
+ const m = raw.match(FRONTMATTER_RE);
128
+ if (!m) return NaN;
129
+
130
+ let fm;
131
+ try {
132
+ fm = YAML.load(m[1]);
133
+ } catch {
134
+ return NaN;
135
+ }
136
+
137
+ if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return NaN;
138
+
139
+ // js-yaml's default schema auto-resolves an ISO-8601-shaped scalar (the
140
+ // canonical `updated:` form) into a native `Date`, NOT a string — verified:
141
+ // `YAML.load('updated: 2026-01-01T00:00:00Z').updated instanceof Date`.
142
+ // Accept both shapes; anything else (missing, number, array, …) excludes.
143
+ const rawUpdated = fm.updated;
144
+ let updatedMs;
145
+ if (rawUpdated instanceof Date) {
146
+ updatedMs = rawUpdated.getTime();
147
+ } else if (typeof rawUpdated === 'string') {
148
+ updatedMs = Date.parse(rawUpdated);
149
+ } else {
150
+ return NaN;
151
+ }
152
+ if (!Number.isFinite(updatedMs)) return NaN;
153
+
154
+ return Math.floor((nowMs - updatedMs) / 86_400_000);
155
+ }
156
+
157
+ /**
158
+ * Format the stale-filename list for the banner message, truncating (with an
159
+ * explicit note) past `MAX_STALE_NAMES_IN_MESSAGE`.
160
+ *
161
+ * @param {Array<{file: string, days: number}>} stale
162
+ * @returns {string}
163
+ */
164
+ function _formatStaleNames(stale) {
165
+ const parts = stale.map((s) => `${s.file} (${s.days}d)`);
166
+ if (parts.length <= MAX_STALE_NAMES_IN_MESSAGE) return parts.join(', ');
167
+ const shown = parts.slice(0, MAX_STALE_NAMES_IN_MESSAGE).join(', ');
168
+ const hiddenCount = parts.length - MAX_STALE_NAMES_IN_MESSAGE;
169
+ return `${shown}, and ${hiddenCount} more (name list truncated)`;
170
+ }
171
+
172
+ /**
173
+ * Check MOC staleness and produce a session-start banner.
174
+ *
175
+ * @param {object} [opts]
176
+ * @param {string} [opts.repoRoot] — REQUIRED absolute path to the repo root.
177
+ * @param {string} [opts.vaultDir] — test seam; overrides the config-resolved vault dir.
178
+ * @param {Date|number} [opts.now] — injectable clock for deterministic tests.
179
+ * @param {object} [opts.config] — optional already-parsed Session Config (avoids
180
+ * a second CLAUDE.md (or AGENTS.md on Codex CLI) read; caller passes `$CONFIG`, mirrors `checkContextCoverage`).
181
+ * Read keys: `config['moc-staleness']` (`.enabled`, `.mode`, `.thresholds.moc`)
182
+ * and `config['vault-integration']['vault-dir']`.
183
+ * @returns {null | { severity: 'warn', message: string, stale: Array<{file: string, days: number}> }}
184
+ */
185
+ export function checkMocStaleness({ repoRoot, vaultDir, now, config } = {}) {
186
+ try {
187
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
188
+
189
+ const cfg =
190
+ config &&
191
+ typeof config === 'object' &&
192
+ config['moc-staleness'] &&
193
+ typeof config['moc-staleness'] === 'object'
194
+ ? config['moc-staleness']
195
+ : {};
196
+
197
+ // Config gate — returns null BEFORE any filesystem I/O. Explicit opt-in
198
+ // required: `cfg.enabled` must be the literal `true`, not merely
199
+ // truthy/absent. A config block that is entirely absent (or present
200
+ // without an `enabled` key) must fail CLOSED, not open — see issue #831
201
+ // fail-open regression (a config carrying `vault-integration.vault-dir`
202
+ // but no `moc-staleness` block previously ran the probe unsolicited,
203
+ // because `undefined !== false`).
204
+ if (cfg?.enabled !== true || cfg?.mode === 'off') return null;
205
+
206
+ const rawVaultDir = _resolveRawVaultDir(vaultDir, config);
207
+ if (!rawVaultDir) return null;
208
+
209
+ const resolvedVaultDir = expandTilde(rawVaultDir);
210
+ const topicsDir = path.join(resolvedVaultDir, MOC_SUBDIR);
211
+
212
+ if (!existsSync(topicsDir)) return null;
213
+
214
+ let entries;
215
+ try {
216
+ entries = readdirSync(topicsDir);
217
+ } catch {
218
+ return null;
219
+ }
220
+ if (!Array.isArray(entries) || entries.length === 0) return null;
221
+
222
+ entries = [...entries].sort();
223
+
224
+ const mocFiles = entries.filter((f) => typeof f === 'string' && f.endsWith(MOC_SUFFIX));
225
+ if (mocFiles.length === 0) return null;
226
+
227
+ const nowMs =
228
+ now instanceof Date
229
+ ? now.getTime()
230
+ : typeof now === 'number' && Number.isFinite(now)
231
+ ? now
232
+ : Date.now();
233
+
234
+ const cfgThreshold =
235
+ cfg.thresholds && typeof cfg.thresholds === 'object' ? cfg.thresholds.moc : undefined;
236
+ const thresholdDays =
237
+ typeof cfgThreshold === 'number' && Number.isFinite(cfgThreshold) && cfgThreshold > 0
238
+ ? cfgThreshold
239
+ : DEFAULT_THRESHOLD_DAYS;
240
+
241
+ const stale = [];
242
+ for (const file of mocFiles) {
243
+ const filePath = path.join(topicsDir, file);
244
+ let days;
245
+ try {
246
+ days = _readMocStalenessDays(filePath, nowMs);
247
+ } catch {
248
+ days = NaN;
249
+ }
250
+ // Missing/unparseable `updated:` is EXCLUDED, not reported stale.
251
+ if (!Number.isFinite(days)) continue;
252
+ if (days > thresholdDays) stale.push({ file, days });
253
+ }
254
+
255
+ if (stale.length === 0) return null;
256
+
257
+ const subjectLabel = stale.length === 1 ? '1 MOC stale' : `${stale.length} MOCs stale`;
258
+ const finding = `${subjectLabel} (>${thresholdDays} days)`;
259
+ const remediation = 'review and refresh the `updated:` frontmatter.';
260
+ const message = `⚠ moc-staleness: ${finding} — ${_formatStaleNames(stale)} — ${remediation}`;
261
+
262
+ return { severity: 'warn', message, stale };
263
+ } catch {
264
+ // Defensive catch-all — banner must never throw.
265
+ return null;
266
+ }
267
+ }