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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- 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
|
|
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
|
|
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
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
+
}
|