session-orchestrator 3.16.0 → 3.17.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/CHANGELOG.md +25 -0
- package/README.md +13 -11
- package/docs/README.md +2 -1
- package/docs/components.md +2 -2
- package/docs/pi-setup.md +1 -1
- package/docs/session-config-reference.md +65 -0
- package/docs/session-config-template.md +27 -0
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/package.json +12 -2
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config.mjs +15 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/dispatcher/enumerate.mjs +151 -31
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/harness-audit/categories/category4.mjs +13 -2
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/skill-health/join.mjs +35 -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/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/mcp-server.sh +15 -3
- package/scripts/telemetry.mjs +250 -0
- package/skills/npm-publish/SKILL.md +81 -0
- package/skills/session-end/SKILL.md +74 -1
- package/skills/session-start/SKILL.md +77 -1
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +121 -34
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { matchBlockHeader } from './block-header.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* moc-staleness.mjs — Parser for the top-level `moc-staleness:` YAML block.
|
|
5
|
+
*
|
|
6
|
+
* Config block shape (see docs/session-config-template.md):
|
|
7
|
+
* moc-staleness:
|
|
8
|
+
* enabled: false
|
|
9
|
+
* thresholds:
|
|
10
|
+
* moc: 90
|
|
11
|
+
* mode: warn
|
|
12
|
+
*
|
|
13
|
+
* Mirrors the docs-staleness.mjs parser design (issue #831) — a single
|
|
14
|
+
* "moc" tier threshold (days), not a per-tier map like vault-staleness.mjs.
|
|
15
|
+
*
|
|
16
|
+
* ZERO IMPORTS other than ./block-header.mjs by design:
|
|
17
|
+
* tests/lib/config/cycle-guard.test.mjs forbids any scripts/lib/config/*.mjs
|
|
18
|
+
* from importing ../config.mjs.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse the top-level `moc-staleness:` YAML block from markdown content.
|
|
23
|
+
* Defaults: enabled=false, thresholds={moc:90}, mode="warn".
|
|
24
|
+
* @param {string} content — full file contents
|
|
25
|
+
* @returns {{enabled: boolean, thresholds: {moc: number}, mode: string}}
|
|
26
|
+
*/
|
|
27
|
+
export function _parseMocStaleness(content) {
|
|
28
|
+
const defaults = {
|
|
29
|
+
enabled: false,
|
|
30
|
+
thresholds: { moc: 90 },
|
|
31
|
+
mode: 'warn',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const lines = content.split(/\r?\n/);
|
|
35
|
+
let inBlock = false;
|
|
36
|
+
const blockLines = [];
|
|
37
|
+
|
|
38
|
+
for (const rawLine of lines) {
|
|
39
|
+
const line = rawLine.replace(/\r$/, '');
|
|
40
|
+
if (!inBlock) {
|
|
41
|
+
if (matchBlockHeader(line, 'moc-staleness')) inBlock = true;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (line.length > 0 && !/^\s/.test(line)) break;
|
|
45
|
+
blockLines.push(line);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (blockLines.length === 0) return defaults;
|
|
49
|
+
|
|
50
|
+
let msEnabled = false;
|
|
51
|
+
let msMode = 'warn';
|
|
52
|
+
const msThresholds = { moc: 90 };
|
|
53
|
+
let inThresholdsBlock = false;
|
|
54
|
+
|
|
55
|
+
for (const rawLine of blockLines) {
|
|
56
|
+
const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
|
|
57
|
+
if (!clean.trim()) continue;
|
|
58
|
+
|
|
59
|
+
// Deeper indented key (thresholds sub-keys)
|
|
60
|
+
const deepMatch = clean.match(/^\s{4,}([a-zA-Z_-]+):\s*(.*)/);
|
|
61
|
+
if (deepMatch && inThresholdsBlock) {
|
|
62
|
+
const k = deepMatch[1];
|
|
63
|
+
let v = deepMatch[2].trim();
|
|
64
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
|
|
65
|
+
else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
|
|
66
|
+
if (k === 'moc') {
|
|
67
|
+
const n = parseFloat(v);
|
|
68
|
+
if (Number.isFinite(n) && n > 0) msThresholds[k] = n;
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Top-level key under moc-staleness (2-space indent)
|
|
74
|
+
const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
|
|
75
|
+
if (!kvMatch) continue;
|
|
76
|
+
|
|
77
|
+
inThresholdsBlock = false;
|
|
78
|
+
|
|
79
|
+
const k = kvMatch[1];
|
|
80
|
+
let v = kvMatch[2].trim();
|
|
81
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
|
|
82
|
+
else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
|
|
83
|
+
|
|
84
|
+
switch (k) {
|
|
85
|
+
case 'enabled':
|
|
86
|
+
msEnabled = v.toLowerCase() === 'true';
|
|
87
|
+
break;
|
|
88
|
+
case 'mode':
|
|
89
|
+
if (['strict', 'warn', 'off'].includes(v)) msMode = v;
|
|
90
|
+
break;
|
|
91
|
+
case 'thresholds':
|
|
92
|
+
inThresholdsBlock = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { enabled: msEnabled, thresholds: msThresholds, mode: msMode };
|
|
98
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { matchBlockHeader } from './block-header.mjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* worktree-orphans.mjs — Parser for the top-level `worktree-orphans:` YAML block.
|
|
5
|
+
*
|
|
6
|
+
* Config block shape (see docs/session-config-template.md):
|
|
7
|
+
* worktree-orphans:
|
|
8
|
+
* enabled: false
|
|
9
|
+
* base-branch: main
|
|
10
|
+
* mode: warn
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the docs-staleness.mjs parser design (issue #831 / B5), but flat —
|
|
13
|
+
* there is no nested threshold map, just three scalar keys.
|
|
14
|
+
*
|
|
15
|
+
* Opt-in by design: `enabled` defaults to `false`, so a repo that has never
|
|
16
|
+
* heard of this block never pays a single git invocation for it.
|
|
17
|
+
*
|
|
18
|
+
* ZERO IMPORTS beyond ./block-header.mjs: tests/lib/config/cycle-guard.test.mjs
|
|
19
|
+
* forbids any scripts/lib/config/*.mjs from importing ../config.mjs.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Valid `base-branch` character set — mirrors `ENTER_WORKTREE_BRANCH_RE` in
|
|
24
|
+
* scripts/lib/autopilot/worktree-pipeline.mjs (itself mirroring the private
|
|
25
|
+
* `isValidBranch()` in scripts/lib/session-id.mjs). Duplicated rather than
|
|
26
|
+
* imported because this parser is dependency-free by contract (see header) and
|
|
27
|
+
* `isValidBranch` is module-private; worktree-pipeline.mjs sets the precedent
|
|
28
|
+
* for mirroring it locally.
|
|
29
|
+
*/
|
|
30
|
+
const BASE_BRANCH_CHARSET = /^[A-Za-z0-9._/-]+$/;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Decide whether a `base-branch` value is safe to hand to the Phase 4b sweep.
|
|
34
|
+
*
|
|
35
|
+
* The consumer (scripts/lib/session-end/worktree-orphan-sweep.mjs) interpolates
|
|
36
|
+
* this value into the argv token `` `${baseBranch}..${branch}` `` for
|
|
37
|
+
* `git rev-list --count`. Two failure modes make a charset check load-bearing:
|
|
38
|
+
*
|
|
39
|
+
* 1. OPTION-SHAPED values. `git rev-list` parses a leading-`-` token as an
|
|
40
|
+
* OPTION, not a revision. `--glob=refs/heads/*` makes rev-list answer about
|
|
41
|
+
* a completely different ref set and exit 0 with `0` — a silent WRONG
|
|
42
|
+
* answer, not an error, so the sweep's conservative-on-error guard never
|
|
43
|
+
* fires and every worktree is reported as a 0-ahead orphan.
|
|
44
|
+
* 2. RANGE-CORRUPTING values. A value containing `..` yields `a..b..branch`.
|
|
45
|
+
*
|
|
46
|
+
* No shell-out: `git check-ref-format --branch` is the semantic reference, but
|
|
47
|
+
* a config parser must stay a pure function. The charset + prefix/suffix rules
|
|
48
|
+
* below are a conservative SUBSET of what git accepts — a rejected value falls
|
|
49
|
+
* back to the `main` default rather than failing the parse, because a
|
|
50
|
+
* mistyped branch name must never escalate into a broken session config.
|
|
51
|
+
*
|
|
52
|
+
* @param {unknown} v
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
export function _isSafeBaseBranch(v) {
|
|
56
|
+
if (typeof v !== 'string' || v.length === 0) return false;
|
|
57
|
+
// Rejects whitespace and every shell-ish character (= * ; | & $ ` ' " ( ) < >),
|
|
58
|
+
// which also rejects `--glob=refs/heads/*` on the `=` and `*` alone.
|
|
59
|
+
if (!BASE_BRANCH_CHARSET.test(v)) return false;
|
|
60
|
+
// The charset permits `-` so that `my-branch` works; a LEADING `-` is the
|
|
61
|
+
// option-shaped case and must be rejected explicitly.
|
|
62
|
+
if (v.startsWith('-')) return false;
|
|
63
|
+
// Would corrupt the `<base>..<branch>` range token at the sink.
|
|
64
|
+
if (v.includes('..')) return false;
|
|
65
|
+
// git check-ref-format: no leading/trailing `/` or `.`, no `.lock` suffix.
|
|
66
|
+
if (v.startsWith('/') || v.endsWith('/')) return false;
|
|
67
|
+
if (v.startsWith('.') || v.endsWith('.')) return false;
|
|
68
|
+
if (v.endsWith('.lock')) return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse the top-level `worktree-orphans:` YAML block from markdown content.
|
|
74
|
+
* Defaults: enabled=false, base-branch="main", mode="warn".
|
|
75
|
+
*
|
|
76
|
+
* @param {string} content — full file contents
|
|
77
|
+
* @returns {{enabled: boolean, 'base-branch': string, mode: string}}
|
|
78
|
+
*/
|
|
79
|
+
export function _parseWorktreeOrphans(content) {
|
|
80
|
+
const defaults = {
|
|
81
|
+
enabled: false,
|
|
82
|
+
'base-branch': 'main',
|
|
83
|
+
mode: 'warn',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
if (typeof content !== 'string' || content === '') return defaults;
|
|
87
|
+
|
|
88
|
+
const lines = content.split(/\r?\n/);
|
|
89
|
+
let inBlock = false;
|
|
90
|
+
const blockLines = [];
|
|
91
|
+
|
|
92
|
+
for (const rawLine of lines) {
|
|
93
|
+
const line = rawLine.replace(/\r$/, '');
|
|
94
|
+
if (!inBlock) {
|
|
95
|
+
// #830: bold-tolerant header match — never a hand-rolled regex.
|
|
96
|
+
if (matchBlockHeader(line, 'worktree-orphans')) inBlock = true;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (line.length > 0 && !/^\s/.test(line)) break;
|
|
100
|
+
blockLines.push(line);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (blockLines.length === 0) return defaults;
|
|
104
|
+
|
|
105
|
+
let woEnabled = false;
|
|
106
|
+
let woBaseBranch = 'main';
|
|
107
|
+
let woMode = 'warn';
|
|
108
|
+
|
|
109
|
+
for (const rawLine of blockLines) {
|
|
110
|
+
const clean = rawLine.replace(/\s*#.*$/, '').replace(/\s+$/, '');
|
|
111
|
+
if (!clean.trim()) continue;
|
|
112
|
+
|
|
113
|
+
const kvMatch = clean.match(/^\s+([a-zA-Z_-]+):\s*(.*)/);
|
|
114
|
+
if (!kvMatch) continue;
|
|
115
|
+
|
|
116
|
+
const k = kvMatch[1];
|
|
117
|
+
let v = kvMatch[2].trim();
|
|
118
|
+
if (v.startsWith('"') && v.endsWith('"') && v.length >= 2) v = v.slice(1, -1);
|
|
119
|
+
else if (v.startsWith("'") && v.endsWith("'") && v.length >= 2) v = v.slice(1, -1);
|
|
120
|
+
|
|
121
|
+
switch (k) {
|
|
122
|
+
case 'enabled':
|
|
123
|
+
woEnabled = v.toLowerCase() === 'true';
|
|
124
|
+
break;
|
|
125
|
+
case 'base-branch':
|
|
126
|
+
// NOT "any non-empty scalar": an option-shaped or range-corrupting
|
|
127
|
+
// value is silently dropped in favour of the safe `main` default.
|
|
128
|
+
// See _isSafeBaseBranch() for why this is a security boundary.
|
|
129
|
+
if (_isSafeBaseBranch(v)) woBaseBranch = v;
|
|
130
|
+
break;
|
|
131
|
+
case 'mode':
|
|
132
|
+
if (['warn', 'off'].includes(v)) woMode = v;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { enabled: woEnabled, 'base-branch': woBaseBranch, mode: woMode };
|
|
138
|
+
}
|
package/scripts/lib/config.mjs
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* drift-check.mjs — _parseDriftCheck
|
|
9
9
|
* docs-orchestrator.mjs — _parseDocsOrchestrator
|
|
10
10
|
* vault-staleness.mjs — _parseVaultStaleness
|
|
11
|
+
* moc-staleness.mjs — _parseMocStaleness
|
|
12
|
+
* context-coverage.mjs — _parseContextCoverage
|
|
13
|
+
* worktree-orphans.mjs — _parseWorktreeOrphans
|
|
11
14
|
* events-rotation.mjs — _parseEventsRotation
|
|
12
15
|
* vault-integration.mjs — _parseVaultIntegration + _parseResourceThresholds
|
|
13
16
|
*
|
|
@@ -38,6 +41,9 @@ import { _parseDriftCheck } from './config/drift-check.mjs';
|
|
|
38
41
|
import { _parseDocsOrchestrator } from './config/docs-orchestrator.mjs';
|
|
39
42
|
import { _parseVaultStaleness } from './config/vault-staleness.mjs';
|
|
40
43
|
import { _parseDocsStaleness } from './config/docs-staleness.mjs';
|
|
44
|
+
import { _parseMocStaleness } from './config/moc-staleness.mjs';
|
|
45
|
+
import { _parseContextCoverage } from './config/context-coverage.mjs';
|
|
46
|
+
import { _parseWorktreeOrphans } from './config/worktree-orphans.mjs';
|
|
41
47
|
import { _parseEventsRotation } from './config/events-rotation.mjs';
|
|
42
48
|
import { _parseVaultIntegration, _parseResourceThresholds } from './config/vault-integration.mjs';
|
|
43
49
|
import { _parseTest } from './config/test.mjs';
|
|
@@ -262,6 +268,12 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
|
|
|
262
268
|
const vaultStaleness = _parseVaultStaleness(mdContent);
|
|
263
269
|
// docs-staleness: parsed from full content (standalone top-level block, #781)
|
|
264
270
|
const docsStaleness = _parseDocsStaleness(mdContent);
|
|
271
|
+
// moc-staleness: parsed from full content (standalone top-level block, #831/B2)
|
|
272
|
+
const mocStaleness = _parseMocStaleness(mdContent);
|
|
273
|
+
// context-coverage: parsed from full content (standalone top-level block, #831/B4)
|
|
274
|
+
const contextCoverage = _parseContextCoverage(mdContent);
|
|
275
|
+
// worktree-orphans: parsed from full content (standalone top-level block, #831/B5)
|
|
276
|
+
const worktreeOrphans = _parseWorktreeOrphans(mdContent);
|
|
265
277
|
|
|
266
278
|
// events-rotation: parsed from full content (standalone top-level block)
|
|
267
279
|
const eventsRotation = _parseEventsRotation(mdContent);
|
|
@@ -449,6 +461,9 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
|
|
|
449
461
|
'docs-orchestrator': docsOrchestrator,
|
|
450
462
|
'vault-staleness': vaultStaleness,
|
|
451
463
|
'docs-staleness': docsStaleness,
|
|
464
|
+
'moc-staleness': mocStaleness,
|
|
465
|
+
'context-coverage': contextCoverage,
|
|
466
|
+
'worktree-orphans': worktreeOrphans,
|
|
452
467
|
'events-rotation': eventsRotation,
|
|
453
468
|
'test': testConfig,
|
|
454
469
|
'gitlab-portfolio': gitlabPortfolio,
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-coverage-banner.mjs — Session-start banner for issue #831 (building
|
|
3
|
+
* block B4).
|
|
4
|
+
*
|
|
5
|
+
* Surfaces a `warn` banner during session-start Phase 4 when a REGISTERED
|
|
6
|
+
* vault project — a `<vaultDir>/01-projects/<slug>/` directory that contains
|
|
7
|
+
* an `_overview.md` — has neither a `context.md` nor a `_passive.md` file. A
|
|
8
|
+
* manual audit found 11 such gap folders in one vault; this probe makes the
|
|
9
|
+
* gap mechanically visible at session-start instead of relying on manual
|
|
10
|
+
* sweeps.
|
|
11
|
+
*
|
|
12
|
+
* "Registered" is deliberately NOT reinvented here. `discoverVaultRepos()`
|
|
13
|
+
* (`scripts/lib/gitlab-portfolio/vcs-detect.mjs`) already establishes the
|
|
14
|
+
* exact convention this probe reuses: a `01-projects/<slug>/` directory
|
|
15
|
+
* without an `_overview.md` is silently skipped — it is not a project, and
|
|
16
|
+
* therefore it can never be a "gap".
|
|
17
|
+
*
|
|
18
|
+
* Design notes:
|
|
19
|
+
* - Mirrors the contract used by every other Phase 4 banner
|
|
20
|
+
* (`scripts/lib/vault-staleness-banner.mjs`, `scripts/lib/loop-readiness-banner.mjs`,
|
|
21
|
+
* `scripts/lib/reconcile-nudge-banner.mjs`): a single `checkXxx()` entry
|
|
22
|
+
* point that returns `null` (silent no-op) or `{ severity, message, ... }`
|
|
23
|
+
* — never an array, never `undefined`, never a throw.
|
|
24
|
+
* - Synchronous — the probe only touches `existsSync`/`readdirSync`/`statSync`,
|
|
25
|
+
* so unlike the async peer-cards/reconcile-nudge probes this one needs no
|
|
26
|
+
* `await` at the call site (mirrors `checkLoopReadiness`).
|
|
27
|
+
* - Never throws. Wrapped in an outermost defensive `try/catch`; every
|
|
28
|
+
* individually-fallible filesystem call additionally gets its own inner
|
|
29
|
+
* bare (no-binding) catch with a one-line explanatory comment.
|
|
30
|
+
* - `vault-dir` resolution mirrors the host-local-override pattern used
|
|
31
|
+
* throughout the plugin (issue #653): an injected `opts.vaultDir` test
|
|
32
|
+
* seam wins, then `config['vault-integration']['vault-dir']`, else the
|
|
33
|
+
* probe silently no-ops (no vault configured — nothing to check).
|
|
34
|
+
* - The committed repo default for `vault-integration.vault-dir` is
|
|
35
|
+
* tilde-prefixed (`~/Projects/vault`) and is NOT pre-expanded anywhere
|
|
36
|
+
* upstream of this module — `expandTilde()` from `./common.mjs` is applied
|
|
37
|
+
* unconditionally before the first `path.join`, exactly as
|
|
38
|
+
* `discoverVaultRepos()` does inline for the same reason.
|
|
39
|
+
*
|
|
40
|
+
* Cross-references:
|
|
41
|
+
* - `scripts/lib/gitlab-portfolio/vcs-detect.mjs` (`discoverVaultRepos`) —
|
|
42
|
+
* the canonical "registered" definition this probe reuses.
|
|
43
|
+
* - `scripts/lib/config/context-coverage.mjs` (`_parseContextCoverage`) —
|
|
44
|
+
* the `context-coverage:` Session Config block parser. NOT wired into
|
|
45
|
+
* `scripts/lib/config.mjs` by this module — the coordinator registers it
|
|
46
|
+
* separately. The exact lines to add there:
|
|
47
|
+
*
|
|
48
|
+
* import { _parseContextCoverage } from './config/context-coverage.mjs';
|
|
49
|
+
* // ... later, alongside the other top-level block parses:
|
|
50
|
+
* const contextCoverage = _parseContextCoverage(mdContent);
|
|
51
|
+
* // ... in the returned config object:
|
|
52
|
+
* 'context-coverage': contextCoverage,
|
|
53
|
+
*
|
|
54
|
+
* - `skills/session-start/SKILL.md` Phase 4 — banner render site (wiring
|
|
55
|
+
* snippet supplied separately; this module does not edit that file).
|
|
56
|
+
* - Issue #831 (building block B4).
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
60
|
+
import path from 'node:path';
|
|
61
|
+
|
|
62
|
+
import { expandTilde } from './common.mjs';
|
|
63
|
+
|
|
64
|
+
/** Vault-relative projects directory (mirrors discoverVaultRepos()'s own constant). */
|
|
65
|
+
const PROJECTS_SUBDIR = '01-projects';
|
|
66
|
+
|
|
67
|
+
/** File marking a `01-projects/<slug>/` directory as REGISTERED (discoverVaultRepos() convention). */
|
|
68
|
+
const OVERVIEW_FILE = '_overview.md';
|
|
69
|
+
|
|
70
|
+
/** Either file's presence satisfies "coverage" for a registered project. */
|
|
71
|
+
const COVERAGE_FILES = ['context.md', '_passive.md'];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Above this many gap slugs, the message truncates the name list and says so
|
|
75
|
+
* explicitly rather than silently dropping names past the limit.
|
|
76
|
+
*/
|
|
77
|
+
const MAX_GAP_NAMES_IN_MESSAGE = 20;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the vault directory to scan.
|
|
81
|
+
*
|
|
82
|
+
* Precedence: `opts.vaultDir` (test seam) > `config['vault-integration']['vault-dir']` > null.
|
|
83
|
+
*
|
|
84
|
+
* @param {string|undefined} vaultDir
|
|
85
|
+
* @param {unknown} config
|
|
86
|
+
* @returns {string|null} raw (not-yet-tilde-expanded) vault dir, or null when unresolvable
|
|
87
|
+
*/
|
|
88
|
+
function _resolveRawVaultDir(vaultDir, config) {
|
|
89
|
+
if (typeof vaultDir === 'string' && vaultDir.length > 0) return vaultDir;
|
|
90
|
+
|
|
91
|
+
if (config && typeof config === 'object') {
|
|
92
|
+
const vaultIntegration = /** @type {Record<string, unknown>} */ (config)['vault-integration'];
|
|
93
|
+
if (vaultIntegration && typeof vaultIntegration === 'object') {
|
|
94
|
+
const raw = /** @type {Record<string, unknown>} */ (vaultIntegration)['vault-dir'];
|
|
95
|
+
if (typeof raw === 'string' && raw.length > 0) return raw;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format the gap-slug list for the banner message, truncating (with an
|
|
104
|
+
* explicit note) past `MAX_GAP_NAMES_IN_MESSAGE`.
|
|
105
|
+
*
|
|
106
|
+
* @param {string[]} slugs
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
function _formatGapNames(slugs) {
|
|
110
|
+
if (slugs.length <= MAX_GAP_NAMES_IN_MESSAGE) return slugs.join(', ');
|
|
111
|
+
const shown = slugs.slice(0, MAX_GAP_NAMES_IN_MESSAGE).join(', ');
|
|
112
|
+
const hiddenCount = slugs.length - MAX_GAP_NAMES_IN_MESSAGE;
|
|
113
|
+
return `${shown}, and ${hiddenCount} more (name list truncated)`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Check context-coverage and produce a session-start banner.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} [opts]
|
|
120
|
+
* @param {string} [opts.repoRoot] — REQUIRED absolute path to the repo root.
|
|
121
|
+
* @param {string} [opts.vaultDir] — test seam; overrides the config-resolved vault dir.
|
|
122
|
+
* @param {object} [opts.config] — optional already-parsed Session Config (avoids
|
|
123
|
+
* a second CLAUDE.md (or AGENTS.md on Codex CLI) read; caller passes `$CONFIG`, mirrors `checkReconcileNudge`).
|
|
124
|
+
* Read keys: `config['context-coverage']` (`.enabled`, `.mode`) and
|
|
125
|
+
* `config['vault-integration']['vault-dir']`.
|
|
126
|
+
* @returns {null | {severity: 'warn', message: string, gaps: Array<{slug: string}>, registered: number, covered: number}}
|
|
127
|
+
*/
|
|
128
|
+
export function checkContextCoverage({ repoRoot, vaultDir, config } = {}) {
|
|
129
|
+
try {
|
|
130
|
+
if (!repoRoot || typeof repoRoot !== 'string') return null;
|
|
131
|
+
|
|
132
|
+
const cfg =
|
|
133
|
+
config &&
|
|
134
|
+
typeof config === 'object' &&
|
|
135
|
+
config['context-coverage'] &&
|
|
136
|
+
typeof config['context-coverage'] === 'object'
|
|
137
|
+
? config['context-coverage']
|
|
138
|
+
: {};
|
|
139
|
+
|
|
140
|
+
// Config gate — returns null BEFORE any filesystem I/O. Explicit opt-in
|
|
141
|
+
// required: `cfg.enabled` must be the literal `true`, not merely
|
|
142
|
+
// truthy/absent. A config block that is entirely absent (or present
|
|
143
|
+
// without an `enabled` key) must fail CLOSED, not open — see issue #831
|
|
144
|
+
// fail-open regression (a config carrying `vault-integration.vault-dir`
|
|
145
|
+
// but no `context-coverage` block previously ran the probe unsolicited,
|
|
146
|
+
// because `undefined !== false`).
|
|
147
|
+
if (cfg?.enabled !== true || cfg?.mode === 'off') return null;
|
|
148
|
+
|
|
149
|
+
const rawVaultDir = _resolveRawVaultDir(vaultDir, config);
|
|
150
|
+
if (!rawVaultDir) return null;
|
|
151
|
+
|
|
152
|
+
const resolvedVaultDir = expandTilde(rawVaultDir);
|
|
153
|
+
const projectsDir = path.join(resolvedVaultDir, PROJECTS_SUBDIR);
|
|
154
|
+
|
|
155
|
+
let entries;
|
|
156
|
+
try {
|
|
157
|
+
entries = readdirSync(projectsDir);
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
if (!Array.isArray(entries) || entries.length === 0) return null;
|
|
162
|
+
|
|
163
|
+
entries = [...entries].sort();
|
|
164
|
+
|
|
165
|
+
let registered = 0;
|
|
166
|
+
const gaps = [];
|
|
167
|
+
|
|
168
|
+
for (const entry of entries) {
|
|
169
|
+
if (typeof entry !== 'string' || entry.startsWith('.')) continue;
|
|
170
|
+
|
|
171
|
+
const entryPath = path.join(projectsDir, entry);
|
|
172
|
+
|
|
173
|
+
let stat;
|
|
174
|
+
try {
|
|
175
|
+
stat = statSync(entryPath);
|
|
176
|
+
} catch {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (!stat || !stat.isDirectory()) continue;
|
|
180
|
+
|
|
181
|
+
// "Registered" is defined ELSEWHERE (discoverVaultRepos()) — a
|
|
182
|
+
// directory lacking `_overview.md` is not a project, and therefore not
|
|
183
|
+
// a gap. Do not invent a second definition here.
|
|
184
|
+
let hasOverview = false;
|
|
185
|
+
try {
|
|
186
|
+
hasOverview = existsSync(path.join(entryPath, OVERVIEW_FILE));
|
|
187
|
+
} catch {
|
|
188
|
+
// best-effort — treat an unreadable path as "no _overview.md".
|
|
189
|
+
}
|
|
190
|
+
if (!hasOverview) continue;
|
|
191
|
+
|
|
192
|
+
registered += 1;
|
|
193
|
+
|
|
194
|
+
let isCovered = false;
|
|
195
|
+
for (const file of COVERAGE_FILES) {
|
|
196
|
+
try {
|
|
197
|
+
if (existsSync(path.join(entryPath, file))) {
|
|
198
|
+
isCovered = true;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// best-effort — treat an unreadable path as "not covered by this file".
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!isCovered) gaps.push({ slug: entry });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (registered === 0 || gaps.length === 0) return null;
|
|
210
|
+
|
|
211
|
+
const covered = registered - gaps.length;
|
|
212
|
+
const gapSlugs = gaps.map((g) => g.slug);
|
|
213
|
+
|
|
214
|
+
const finding = `${gaps.length} of ${registered} registered projects lack context.md and _passive.md`;
|
|
215
|
+
const remediation = 'add a context.md or mark the project passive with _passive.md.';
|
|
216
|
+
const message = `⚠ context-coverage: ${finding} — ${_formatGapNames(gapSlugs)} — ${remediation}`;
|
|
217
|
+
|
|
218
|
+
return { severity: 'warn', message, gaps, registered, covered };
|
|
219
|
+
} catch {
|
|
220
|
+
// Defensive catch-all — banner must never throw.
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|