session-orchestrator 3.23.0 → 3.24.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/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* Behaviour (PRD §F1.3):
|
|
11
11
|
* 1. Skip if `bootstrap.lock` is missing (repo not yet bootstrapped —
|
|
12
12
|
* the bootstrap-gate handles that path).
|
|
13
|
-
* 2. Skip if `sessions.jsonl`
|
|
14
|
-
*
|
|
13
|
+
* 2. Skip if the `sessions.jsonl` DISTINCT-session count ≥
|
|
14
|
+
* `silence-after-sessions` — identities, not lines (see `countSessions`);
|
|
15
|
+
* the operator has already engaged at least once.
|
|
15
16
|
* 3. Skip if bootstrap age < `nudge-after-hours` (give the operator a
|
|
16
17
|
* reasonable window after bootstrap before nudging).
|
|
17
18
|
* 4. Emit otherwise, with a `markerPath` when the migration marker
|
|
@@ -34,6 +35,7 @@ import { stat, readFile, unlink } from 'node:fs/promises';
|
|
|
34
35
|
import path from 'node:path';
|
|
35
36
|
|
|
36
37
|
import { parseBootstrapLock } from './bootstrap-lock-freshness.mjs';
|
|
38
|
+
import { countSessionsInJsonl } from './sessions-canonical.mjs';
|
|
37
39
|
|
|
38
40
|
/** Milliseconds in one hour — exported for testability. */
|
|
39
41
|
export const MS_PER_HOUR = 60 * 60 * 1000;
|
|
@@ -77,24 +79,31 @@ function buildBannerLines({ bootstrappedAt = null } = {}) {
|
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
/**
|
|
80
|
-
* Best-effort
|
|
81
|
-
* missing OR empty OR unreadable — all three are
|
|
82
|
-
* cold-start decision ("no sessions yet").
|
|
82
|
+
* Best-effort counter of DISTINCT physical sessions in an NDJSON ledger.
|
|
83
|
+
* Returns 0 when the file is missing OR empty OR unreadable — all three are
|
|
84
|
+
* equivalent for the cold-start decision ("no sessions yet").
|
|
85
|
+
*
|
|
86
|
+
* Counts IDENTITIES, not lines (#1167): `sessions.jsonl` is append-only, so one
|
|
87
|
+
* physical session can occupy two lines (a backfilled abandoned stub plus the
|
|
88
|
+
* authoritative record that supersedes it, or the systemic double-stub pair
|
|
89
|
+
* written by the two backfill writers). A raw line count therefore reports
|
|
90
|
+
* "the operator has already engaged" one session earlier than is true, and the
|
|
91
|
+
* cold-start nudge — one-shot per repo at the default threshold of 1 — is
|
|
92
|
+
* silently never emitted.
|
|
83
93
|
*
|
|
84
94
|
* Reads the full file because sessions.jsonl is small (1 line/session, ~2 KB
|
|
85
|
-
* each — even 100 sessions = ~200 KB).
|
|
95
|
+
* each — even 100 sessions = ~200 KB). The async `readFile` is kept (the
|
|
96
|
+
* SessionStart hook's 5s budget must not block); only the collapse rules come
|
|
97
|
+
* from the shared canonical module.
|
|
86
98
|
*
|
|
87
99
|
* @param {string} filePath
|
|
88
100
|
* @returns {Promise<number>}
|
|
89
101
|
*/
|
|
90
|
-
async function
|
|
102
|
+
async function countSessions(filePath) {
|
|
91
103
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// both Unix (\n) and Windows (\r\n) line endings.
|
|
96
|
-
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
97
|
-
return lines.length;
|
|
104
|
+
// Counting rule (blank/malformed lines, id-less records) lives in
|
|
105
|
+
// `countSessionsInJsonl`; only the async read is local.
|
|
106
|
+
return countSessionsInJsonl(await readFile(filePath, 'utf8'));
|
|
98
107
|
} catch {
|
|
99
108
|
return 0;
|
|
100
109
|
}
|
|
@@ -166,7 +175,7 @@ export async function detectColdStart(opts = {}) {
|
|
|
166
175
|
|
|
167
176
|
// PRD §F1.3 Ubiquitous: silence once the repo has ≥ N sessions. This is
|
|
168
177
|
// the auto-silence path — banner is one-shot per repo (default N=1).
|
|
169
|
-
const sessionCount = await
|
|
178
|
+
const sessionCount = await countSessions(sessionsPath);
|
|
170
179
|
if (sessionCount >= silenceAfterSessions) {
|
|
171
180
|
return {
|
|
172
181
|
shouldEmit: false,
|
|
@@ -31,6 +31,11 @@
|
|
|
31
31
|
*
|
|
32
32
|
* The two `**` markers are independent (`**key:` and `key:**` both match),
|
|
33
33
|
* mirroring the tolerant #823 vault-integration regex.
|
|
34
|
+
*
|
|
35
|
+
* `matchBlockHeaderDetailed` (#1185) is a SEPARATE, additive matcher below for
|
|
36
|
+
* parsers that need what this contract deliberately rejects — an indented
|
|
37
|
+
* (nested) header and/or an inline value. It does not change the contract
|
|
38
|
+
* above; `matchBlockHeader`/`hasBlockHeader` keep exactly this behaviour.
|
|
34
39
|
*/
|
|
35
40
|
|
|
36
41
|
/**
|
|
@@ -73,6 +78,56 @@ export function matchBlockHeader(line, key) {
|
|
|
73
78
|
return blockHeaderRe(key).test(line);
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Build the indent-aware, inline-value-capturing block-header regex for a key.
|
|
83
|
+
* A strict SUPERSET of `blockHeaderRe`: it additionally matches an ARBITRARY
|
|
84
|
+
* leading indent (group 1) and an optional inline value trailing the colon
|
|
85
|
+
* (group 2) — the two things `matchBlockHeader` deliberately rejects (a
|
|
86
|
+
* nested sub-key, and a header carrying a value, per the module docblock).
|
|
87
|
+
*
|
|
88
|
+
* @param {string} key
|
|
89
|
+
* @returns {RegExp}
|
|
90
|
+
*/
|
|
91
|
+
function detailedBlockHeaderRe(key) {
|
|
92
|
+
return new RegExp(
|
|
93
|
+
'^(\\s*)(?:-\\s+)?(?:\\*\\*)?' + escapeRegExp(key) + ':(?:\\*\\*)?(?:[ \\t]+(.*))?$'
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Indent + inline-value variant of `matchBlockHeader` (#1185). Additive —
|
|
99
|
+
* `matchBlockHeader`/`hasBlockHeader` are unchanged and every existing caller
|
|
100
|
+
* keeps its current behaviour untouched.
|
|
101
|
+
*
|
|
102
|
+
* Built for parsers whose header can be NESTED under a parent block (e.g.
|
|
103
|
+
* `health-endpoints:` one level under `ecosystem-health:`) and/or carry an
|
|
104
|
+
* INLINE value (`health-endpoints: [{name: …}]`) on the same line — exactly
|
|
105
|
+
* the two forms `matchBlockHeader` treats as "not a top-level block-opener".
|
|
106
|
+
* This is a separate, purpose-built matcher, not a relaxed replacement: a
|
|
107
|
+
* caller that only needs the boolean top-level check keeps using
|
|
108
|
+
* `matchBlockHeader`.
|
|
109
|
+
*
|
|
110
|
+
* The captured value is RAW text, never comment-stripped — `key: # note`
|
|
111
|
+
* reports `value: '# note'`, exactly as the pre-#1185 `health-endpoints.mjs`
|
|
112
|
+
* `HEADER_RE` did. A caller that needs comment semantics strips them itself
|
|
113
|
+
* (as `health-endpoints.mjs` already does for its block BODY via its own
|
|
114
|
+
* `stripComment()` — only the header line's raw capture moved here).
|
|
115
|
+
*
|
|
116
|
+
* @param {string} line — a single line (callers strip the trailing `\n`)
|
|
117
|
+
* @param {string} key — the literal block key
|
|
118
|
+
* @returns {{indent: number, value: string|null}|null} `null` on no match;
|
|
119
|
+
* `value` is `null` for a bare header (`key:`, optionally trailing
|
|
120
|
+
* whitespace only), else the trimmed text following the colon.
|
|
121
|
+
*/
|
|
122
|
+
export function matchBlockHeaderDetailed(line, key) {
|
|
123
|
+
if (typeof line !== 'string' || typeof key !== 'string' || key === '') return null;
|
|
124
|
+
const m = line.match(detailedBlockHeaderRe(key));
|
|
125
|
+
if (!m) return null;
|
|
126
|
+
const indent = m[1].length;
|
|
127
|
+
const trimmed = (m[2] ?? '').trim();
|
|
128
|
+
return { indent, value: trimmed === '' ? null : trimmed };
|
|
129
|
+
}
|
|
130
|
+
|
|
76
131
|
/**
|
|
77
132
|
* Multiline presence variant: true when ANY line of `content` opens the
|
|
78
133
|
* top-level block for `key`. For whole-file PRESENCE guards (e.g.
|
|
@@ -10,6 +10,11 @@ import { matchBlockHeader } from './block-header.mjs';
|
|
|
10
10
|
* transcript. v1 is log+warn only (events.jsonl + stderr WARN); exit 2
|
|
11
11
|
* (blocking) is reserved for a future hard-gate.
|
|
12
12
|
*
|
|
13
|
+
* OFF by default (opt-in) — issue #567's original acceptance criterion. The
|
|
14
|
+
* #690 flip to ON (2026-06-25) was reverted 2026-09-02 (#1191) after fleet
|
|
15
|
+
* measurement: 6,946 `discovery_validator_violation` events accumulated in 18
|
|
16
|
+
* repos that never declared the block.
|
|
17
|
+
*
|
|
13
18
|
* Returns `{ enabled }`.
|
|
14
19
|
* Tolerant parser: malformed values silently fall back to defaults.
|
|
15
20
|
*
|
|
@@ -21,14 +26,14 @@ import { matchBlockHeader } from './block-header.mjs';
|
|
|
21
26
|
* Independent of the `## Session Config` section boundary.
|
|
22
27
|
*
|
|
23
28
|
* Defaults:
|
|
24
|
-
* enabled:
|
|
29
|
+
* enabled: false (opt-in)
|
|
25
30
|
*
|
|
26
31
|
* @param {string} content — full file contents
|
|
27
32
|
* @returns {{ enabled: boolean }}
|
|
28
33
|
*/
|
|
29
34
|
export function _parseDiscoveryValidator(content) {
|
|
30
35
|
const defaults = {
|
|
31
|
-
enabled:
|
|
36
|
+
enabled: false,
|
|
32
37
|
};
|
|
33
38
|
|
|
34
39
|
const lines = content.split(/\r?\n/);
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* health-endpoints.mjs — Content-scoped parser for the `health-endpoints:`
|
|
3
|
+
* Session Config key, plus the block-form reading of `ecosystem-health:`.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS (#1174): `health-endpoints` was read off the FLAT KV map via
|
|
6
|
+
* `_coerceList`, which bails to `null` the moment a value contains `{` and
|
|
7
|
+
* cannot see a nested YAML block at all. The ecosystem wizard
|
|
8
|
+
* (`scripts/lib/ecosystem-wizard/config-writer.mjs` `_buildEcosystemSnippetLines`)
|
|
9
|
+
* writes exactly the block form:
|
|
10
|
+
*
|
|
11
|
+
* ecosystem-health:
|
|
12
|
+
* health-endpoints:
|
|
13
|
+
* - name: API
|
|
14
|
+
* url: https://api.example.com/health
|
|
15
|
+
*
|
|
16
|
+
* Measured before the fix: the flat KV map ended up with bare `name` / `url`
|
|
17
|
+
* entries (last one wins → silent data loss), `health-endpoints` resolved to
|
|
18
|
+
* `null`, and because the header line `ecosystem-health:` carries no value it
|
|
19
|
+
* never matched the KV regex either — so the whole feature stayed dark while
|
|
20
|
+
* the wizard reported success. Consumers (`skills/ecosystem-health/SKILL.md`,
|
|
21
|
+
* `docs/session-config-reference.md`) expect an array of `{ name, url }`.
|
|
22
|
+
*
|
|
23
|
+
* ACCEPTED FORMS (contract — every one of these is written by some producer in
|
|
24
|
+
* this repo today):
|
|
25
|
+
*
|
|
26
|
+
* A) inline object array (docs/USER-GUIDE.md):
|
|
27
|
+
* health-endpoints: [{name: "API", url: "https://a/health"}, {name: "W", url: "http://w:8080/z"}]
|
|
28
|
+
*
|
|
29
|
+
* B) nested block, top-level OR one level under `ecosystem-health:`
|
|
30
|
+
* (the wizard's output, skills/ecosystem-health/wizard.md):
|
|
31
|
+
* health-endpoints:
|
|
32
|
+
* - name: API
|
|
33
|
+
* url: https://a/health
|
|
34
|
+
* Block list items may themselves be inline objects
|
|
35
|
+
* (`- { name: API, url: … }`) — the form docs/session-config-template.md
|
|
36
|
+
* uses.
|
|
37
|
+
*
|
|
38
|
+
* C) bare list of URLs — each URL becomes its own name
|
|
39
|
+
* (`{ name: <url>, url: <url> }`). Both spellings are accepted, because
|
|
40
|
+
* an operator extending the wizard's BLOCK by hand writes the block one:
|
|
41
|
+
* health-endpoints: [https://a/health, https://b/health]
|
|
42
|
+
* and
|
|
43
|
+
* health-endpoints:
|
|
44
|
+
* - https://a/health
|
|
45
|
+
*
|
|
46
|
+
* Empty list (`[]`) → `[]`. `none` / `null` / key absent → `null`.
|
|
47
|
+
*
|
|
48
|
+
* NOT accepted (deliberately): the `name=url` shorthand. An item carrying `=`
|
|
49
|
+
* is treated verbatim as a Form-C URL, never split.
|
|
50
|
+
*
|
|
51
|
+
* Malformed input (an entry missing `name` or `url`, an unmatched brace) →
|
|
52
|
+
* `null` plus exactly ONE `console.warn` line prefixed `config: health-endpoints:`.
|
|
53
|
+
* Never throws: a broken config key must not take down every session-start.
|
|
54
|
+
*
|
|
55
|
+
* Consumers: `scripts/lib/config.mjs` (`health-endpoints`, `ecosystem-health`).
|
|
56
|
+
*
|
|
57
|
+
* The header line itself (indent + optional inline value) is matched via the
|
|
58
|
+
* shared `matchBlockHeaderDetailed()` (#1185) rather than a private regex —
|
|
59
|
+
* this was the one parser in `scripts/lib/config/` whose header needed BOTH
|
|
60
|
+
* arbitrary indent (nested under `ecosystem-health:`) AND an inline value
|
|
61
|
+
* (Form A/C), which the plain `matchBlockHeader()` contract deliberately
|
|
62
|
+
* excludes. See `scripts/lib/config/block-header.mjs` for the shared matcher.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import { matchBlockHeader, matchBlockHeaderDetailed } from './block-header.mjs';
|
|
66
|
+
import { _coerceBoolean } from './coercers.mjs';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A scalar that is a URL: a scheme followed by `//`. Used to tell a Form-C
|
|
70
|
+
* bare-URL list item (`- https://a/health`) from a `key: value` pair
|
|
71
|
+
* (`- name: API`) inside a block. `https://…` would otherwise match the
|
|
72
|
+
* key/value regex with key `https`, which is why the check must come first.
|
|
73
|
+
*/
|
|
74
|
+
const URL_SCALAR_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Strip a trailing YAML comment. Only a `#` PRECEDED BY WHITESPACE counts, so a
|
|
78
|
+
* URL fragment (`https://a/health#frag`) survives untouched.
|
|
79
|
+
* @param {string} s
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
function stripComment(s) {
|
|
83
|
+
return s.replace(/\s+#.*$/, '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Strip one layer of matching surrounding quotes.
|
|
88
|
+
* @param {string} v
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function unquote(v) {
|
|
92
|
+
if (v.length >= 2 && ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'")))) {
|
|
93
|
+
return v.slice(1, -1);
|
|
94
|
+
}
|
|
95
|
+
return v;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Split on commas that sit at brace/bracket depth 0 and outside quotes — so a
|
|
100
|
+
* comma inside a quoted URL never splits an entry.
|
|
101
|
+
* @param {string} s
|
|
102
|
+
* @returns {string[]}
|
|
103
|
+
*/
|
|
104
|
+
function splitTopLevel(s) {
|
|
105
|
+
const out = [];
|
|
106
|
+
let depth = 0;
|
|
107
|
+
let quote = null;
|
|
108
|
+
let cur = '';
|
|
109
|
+
for (const ch of s) {
|
|
110
|
+
if (quote) {
|
|
111
|
+
cur += ch;
|
|
112
|
+
if (ch === quote) quote = null;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (ch === '"' || ch === "'") { quote = ch; cur += ch; continue; }
|
|
116
|
+
if (ch === '{' || ch === '[') depth++;
|
|
117
|
+
else if (ch === '}' || ch === ']') depth--;
|
|
118
|
+
if (ch === ',' && depth === 0) { out.push(cur); cur = ''; continue; }
|
|
119
|
+
cur += ch;
|
|
120
|
+
}
|
|
121
|
+
out.push(cur);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Split `{…}, {…}` into the inner text of each object. Quote-aware, so a `}`
|
|
127
|
+
* inside a quoted value does not close an object.
|
|
128
|
+
* @param {string} s
|
|
129
|
+
* @returns {string[]|null} null on an unmatched brace.
|
|
130
|
+
*/
|
|
131
|
+
function splitObjects(s) {
|
|
132
|
+
const out = [];
|
|
133
|
+
let depth = 0;
|
|
134
|
+
let quote = null;
|
|
135
|
+
let cur = '';
|
|
136
|
+
for (const ch of s) {
|
|
137
|
+
if (quote) {
|
|
138
|
+
if (depth > 0) cur += ch;
|
|
139
|
+
if (ch === quote) quote = null;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (ch === '"' || ch === "'") {
|
|
143
|
+
quote = ch;
|
|
144
|
+
if (depth > 0) cur += ch;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (ch === '{') {
|
|
148
|
+
depth++;
|
|
149
|
+
if (depth === 1) { cur = ''; continue; }
|
|
150
|
+
} else if (ch === '}') {
|
|
151
|
+
depth--;
|
|
152
|
+
if (depth < 0) return null;
|
|
153
|
+
if (depth === 0) { out.push(cur); cur = ''; continue; }
|
|
154
|
+
}
|
|
155
|
+
if (depth > 0) cur += ch;
|
|
156
|
+
}
|
|
157
|
+
if (depth !== 0 || quote) return null;
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Parse the inner text of one `{ name: X, url: Y }` object.
|
|
163
|
+
* @param {string} inner
|
|
164
|
+
* @returns {{name: string, url: string}|null} null when name or url is missing.
|
|
165
|
+
*/
|
|
166
|
+
function parseObjectBody(inner) {
|
|
167
|
+
let name = null;
|
|
168
|
+
let url = null;
|
|
169
|
+
for (const pair of splitTopLevel(inner)) {
|
|
170
|
+
const colonIdx = pair.indexOf(':');
|
|
171
|
+
if (colonIdx === -1) continue;
|
|
172
|
+
const k = pair.slice(0, colonIdx).trim().toLowerCase();
|
|
173
|
+
const v = unquote(pair.slice(colonIdx + 1).trim());
|
|
174
|
+
if (k === 'name') name = v;
|
|
175
|
+
else if (k === 'url') url = v;
|
|
176
|
+
}
|
|
177
|
+
if (!name || !url) return null;
|
|
178
|
+
return { name, url };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Parse an inline value (everything right of `health-endpoints:` on one line).
|
|
183
|
+
* @param {string} raw
|
|
184
|
+
* @returns {{ok: true, value: {name: string, url: string}[]|null} | {ok: false}}
|
|
185
|
+
*/
|
|
186
|
+
function parseInlineValue(raw) {
|
|
187
|
+
let v = raw.trim();
|
|
188
|
+
// A trailing comment is only stripped when the value ends with `]` — that
|
|
189
|
+
// keeps `#` inside an unbracketed value (a URL fragment) intact.
|
|
190
|
+
const bracketed = v.match(/^(\[.*\])\s*(?:#.*)?$/s);
|
|
191
|
+
if (bracketed) v = bracketed[1];
|
|
192
|
+
|
|
193
|
+
if (v === '' || v === 'none' || v === 'null') return { ok: true, value: null };
|
|
194
|
+
|
|
195
|
+
const stripped = v.replace(/^\[/, '').replace(/\]$/, '').trim();
|
|
196
|
+
if (stripped === '') return { ok: true, value: [] };
|
|
197
|
+
|
|
198
|
+
if (stripped.includes('{')) {
|
|
199
|
+
const bodies = splitObjects(stripped);
|
|
200
|
+
if (bodies === null) return { ok: false };
|
|
201
|
+
const entries = [];
|
|
202
|
+
for (const body of bodies) {
|
|
203
|
+
const entry = parseObjectBody(body);
|
|
204
|
+
if (entry === null) return { ok: false };
|
|
205
|
+
entries.push(entry);
|
|
206
|
+
}
|
|
207
|
+
return entries.length === 0 ? { ok: false } : { ok: true, value: entries };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Form C — bare list of URLs; the URL is its own name.
|
|
211
|
+
const urls = splitTopLevel(stripped).map((s) => unquote(s.trim())).filter((s) => s.length > 0);
|
|
212
|
+
if (urls.length === 0) return { ok: true, value: [] };
|
|
213
|
+
return { ok: true, value: urls.map((u) => ({ name: u, url: u })) };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Parse the indented block that follows a valueless `health-endpoints:` header.
|
|
218
|
+
* Terminates at the first non-blank line indented no deeper than the header —
|
|
219
|
+
* the same rule `issue-budget.mjs` uses, generalised so a header nested under
|
|
220
|
+
* `ecosystem-health:` terminates correctly too.
|
|
221
|
+
*
|
|
222
|
+
* @param {string[]} lines — all document lines
|
|
223
|
+
* @param {number} start — index of the first line after the header
|
|
224
|
+
* @param {number} headerIndent — indent width of the header line
|
|
225
|
+
* @returns {{ok: true, value: {name: string, url: string}[]|null} | {ok: false}}
|
|
226
|
+
*/
|
|
227
|
+
function parseBlock(lines, start, headerIndent) {
|
|
228
|
+
/** @type {{name: string|null, url: string|null}[]} */
|
|
229
|
+
const entries = [];
|
|
230
|
+
let cur = null;
|
|
231
|
+
|
|
232
|
+
for (let i = start; i < lines.length; i++) {
|
|
233
|
+
const line = lines[i].replace(/\r$/, '');
|
|
234
|
+
if (line.trim() === '') continue;
|
|
235
|
+
const indent = line.match(/^[ \t]*/)[0].length;
|
|
236
|
+
if (indent <= headerIndent) break;
|
|
237
|
+
|
|
238
|
+
const clean = stripComment(line).replace(/\s+$/, '');
|
|
239
|
+
if (clean.trim() === '') continue;
|
|
240
|
+
|
|
241
|
+
const itemMatch = clean.match(/^[ \t]*-\s*(.*)$/);
|
|
242
|
+
if (itemMatch) {
|
|
243
|
+
const rest = itemMatch[1].trim();
|
|
244
|
+
if (rest.startsWith('{')) {
|
|
245
|
+
const bodies = splitObjects(rest);
|
|
246
|
+
if (bodies === null || bodies.length === 0) return { ok: false };
|
|
247
|
+
for (const body of bodies) {
|
|
248
|
+
const entry = parseObjectBody(body);
|
|
249
|
+
if (entry === null) return { ok: false };
|
|
250
|
+
entries.push(entry);
|
|
251
|
+
}
|
|
252
|
+
cur = null;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
// Form C inside a block: a bare URL item is its own name — the same rule
|
|
256
|
+
// the inline form applies. Without this, `- https://a/health` fell into
|
|
257
|
+
// assignKV, matched as key `https`, and the whole key WARNed to null
|
|
258
|
+
// while the identical inline list parsed fine.
|
|
259
|
+
const scalar = unquote(rest);
|
|
260
|
+
if (URL_SCALAR_RE.test(scalar)) {
|
|
261
|
+
entries.push({ name: scalar, url: scalar });
|
|
262
|
+
cur = null;
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
cur = { name: null, url: null };
|
|
266
|
+
entries.push(cur);
|
|
267
|
+
if (rest === '') continue;
|
|
268
|
+
if (!assignKV(cur, rest)) return { ok: false };
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const kvMatch = clean.match(/^[ \t]*[a-zA-Z][a-zA-Z0-9_-]*:\s*/);
|
|
273
|
+
if (!kvMatch) return { ok: false };
|
|
274
|
+
if (cur === null) return { ok: false };
|
|
275
|
+
if (!assignKV(cur, clean.trim())) return { ok: false };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (entries.length === 0) return { ok: true, value: null };
|
|
279
|
+
for (const e of entries) {
|
|
280
|
+
if (!e.name || !e.url) return { ok: false };
|
|
281
|
+
}
|
|
282
|
+
return { ok: true, value: entries.map((e) => ({ name: e.name, url: e.url })) };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Assign one `key: value` line into an in-progress entry. Unknown keys are
|
|
287
|
+
* ignored (forward-compatible with a future `timeout:` sub-key); a line that
|
|
288
|
+
* is not a `key: value` pair at all is a parse failure.
|
|
289
|
+
*
|
|
290
|
+
* @param {{name: string|null, url: string|null}} entry
|
|
291
|
+
* @param {string} text
|
|
292
|
+
* @returns {boolean} false when the line is not a key/value pair.
|
|
293
|
+
*/
|
|
294
|
+
function assignKV(entry, text) {
|
|
295
|
+
const m = text.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
|
|
296
|
+
if (!m) return false;
|
|
297
|
+
const k = m[1].toLowerCase();
|
|
298
|
+
const v = unquote(m[2].trim());
|
|
299
|
+
if (k === 'name') entry.name = v;
|
|
300
|
+
else if (k === 'url') entry.url = v;
|
|
301
|
+
return true;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Parse `health-endpoints` from the raw markdown content.
|
|
306
|
+
*
|
|
307
|
+
* Scans the FULL document (not just the `## Session Config` fence), matching
|
|
308
|
+
* every sibling block parser in this directory, and takes the FIRST
|
|
309
|
+
* `health-endpoints:` occurrence — top-level or nested under `ecosystem-health:`.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} content — full file contents
|
|
312
|
+
* @returns {{name: string, url: string}[]|null} `null` when absent, explicitly
|
|
313
|
+
* `none`/`null`, or malformed (malformed additionally emits one WARN).
|
|
314
|
+
*/
|
|
315
|
+
export function _parseHealthEndpoints(content) {
|
|
316
|
+
if (typeof content !== 'string' || content === '') return null;
|
|
317
|
+
|
|
318
|
+
const lines = content.split(/\r?\n/);
|
|
319
|
+
for (let i = 0; i < lines.length; i++) {
|
|
320
|
+
const line = lines[i].replace(/\r$/, '');
|
|
321
|
+
const m = matchBlockHeaderDetailed(line, 'health-endpoints');
|
|
322
|
+
if (!m) continue;
|
|
323
|
+
|
|
324
|
+
const headerIndent = m.indent;
|
|
325
|
+
const inline = m.value ?? '';
|
|
326
|
+
const result = inline !== ''
|
|
327
|
+
? parseInlineValue(inline)
|
|
328
|
+
: parseBlock(lines, i + 1, headerIndent);
|
|
329
|
+
|
|
330
|
+
if (!result.ok) {
|
|
331
|
+
console.warn(
|
|
332
|
+
`config: health-endpoints: malformed value at line ${i + 1} — expected a list of {name, url} entries; ignoring.`
|
|
333
|
+
);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
return result.value;
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Read `ecosystem-health` in its BLOCK form (`ecosystem-health:` with no value,
|
|
343
|
+
* followed by an indented body — what the wizard writes). The scalar form
|
|
344
|
+
* (`ecosystem-health: true`) is read by the caller off the KV map and takes
|
|
345
|
+
* precedence; this function only answers the case the KV map cannot see.
|
|
346
|
+
*
|
|
347
|
+
* A non-empty block means the feature is configured, hence enabled — unless the
|
|
348
|
+
* block carries an explicit `enabled: false`.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} content — full file contents
|
|
351
|
+
* @returns {boolean|null} `null` when no block form is present (caller keeps its default).
|
|
352
|
+
*/
|
|
353
|
+
export function _parseEcosystemHealthBlockEnabled(content) {
|
|
354
|
+
if (typeof content !== 'string' || content === '') return null;
|
|
355
|
+
|
|
356
|
+
const lines = content.split(/\r?\n/);
|
|
357
|
+
let inBlock = false;
|
|
358
|
+
const blockLines = [];
|
|
359
|
+
for (const rawLine of lines) {
|
|
360
|
+
const line = rawLine.replace(/\r$/, '');
|
|
361
|
+
if (!inBlock) {
|
|
362
|
+
if (matchBlockHeader(line, 'ecosystem-health')) inBlock = true;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (line.length > 0 && !/^\s/.test(line)) break;
|
|
366
|
+
blockLines.push(line);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const meaningful = blockLines.filter((l) => stripComment(l).trim() !== '');
|
|
370
|
+
if (!inBlock || meaningful.length === 0) return null;
|
|
371
|
+
|
|
372
|
+
for (const l of meaningful) {
|
|
373
|
+
const m = stripComment(l).trim().match(/^enabled:\s*(\S+)$/i);
|
|
374
|
+
// Same truth table as the scalar path — `_coerceBoolean` accepts only
|
|
375
|
+
// true/false (case-insensitively) and THROWS on anything else. A local
|
|
376
|
+
// `=== 'true'` comparison silently mapped `yes` to false, so the block form
|
|
377
|
+
// disagreed with `ecosystem-health: yes` on the very same document.
|
|
378
|
+
// NB: `_coerceBoolean` uses its `key` argument for BOTH the map lookup and
|
|
379
|
+
// the error text, so the synthetic map must be keyed by that same string.
|
|
380
|
+
if (m) return _coerceBoolean(new Map([['ecosystem-health.enabled', m[1]]]), 'ecosystem-health.enabled', false);
|
|
381
|
+
}
|
|
382
|
+
return true;
|
|
383
|
+
}
|