session-orchestrator 3.22.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/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- 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/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- 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/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- 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/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- 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 +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/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 +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -47,6 +47,128 @@ async function execWithTimeout(cmd, args, opts = {}) {
|
|
|
47
47
|
]);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/** C0 controls plus DEL — matching them is the POINT, hence the disable. */
|
|
51
|
+
// eslint-disable-next-line no-control-regex
|
|
52
|
+
const CONTROL_BYTE_RE = /[\u0000-\u001f\u007f]/g;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Replace every C0/DEL control byte with its printable `\uXXXX` escape.
|
|
56
|
+
*
|
|
57
|
+
* `JSON.stringify` covers the payload preview, but NOT `SyntaxError.message` —
|
|
58
|
+
* V8 quotes the offending input INTO that message verbatim, so an ANSI/CR
|
|
59
|
+
* payload reached the operator's terminal through the error text even after
|
|
60
|
+
* the preview was escaped. Both halves go through here.
|
|
61
|
+
*
|
|
62
|
+
* @param {unknown} text
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
function escapeControlBytes(text) {
|
|
66
|
+
return String(text).replace(
|
|
67
|
+
CONTROL_BYTE_RE,
|
|
68
|
+
(ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Name a parsed JSON value's type WITHOUT quoting any of its content.
|
|
74
|
+
*
|
|
75
|
+
* @param {unknown} value
|
|
76
|
+
* @returns {'null'|'array'|'object'|'string'|'number'|'boolean'|'undefined'}
|
|
77
|
+
*/
|
|
78
|
+
function jsonTypeOf(value) {
|
|
79
|
+
if (value === null) return 'null';
|
|
80
|
+
if (Array.isArray(value)) return 'array';
|
|
81
|
+
return /** @type {any} */ (typeof value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse CLI stdout as JSON, degrading an unparseable payload onto this module's
|
|
86
|
+
* documented failure channel instead of a bare `SyntaxError` (CWE-502).
|
|
87
|
+
*
|
|
88
|
+
* The throw IS that channel, not an escape from it: every call site runs under
|
|
89
|
+
* `checkCiStatus`'s outer catch, whose comment already names "unparseable
|
|
90
|
+
* output" as a case it converts to `console.warn` + `null`. Measured
|
|
91
|
+
* 2026-08-28 at 30940cb, BEFORE this helper existed: an HTML login page from
|
|
92
|
+
* `gh repo view`, a literal `null`, and an empty `glab` stdout ALL already
|
|
93
|
+
* returned `null` with a warn — nothing crashed, and
|
|
94
|
+
* `tests/lib/ci-status-banner.test.mjs` § "error containment" pinned it. So
|
|
95
|
+
* this does not fix a crash — the outer catch already turned non-JSON output
|
|
96
|
+
* into a warn + `null`. The rule this helper closes is lexical
|
|
97
|
+
* (`json-parse-untrusted-input`, see below), not a crash it prevents.
|
|
98
|
+
*
|
|
99
|
+
* What it does fix is the message. The raw parse error (`Unexpected token
|
|
100
|
+
* '<'`) named neither the CLI nor the request, and this banner spawns four
|
|
101
|
+
* different subprocesses — an operator reading that line at session-start
|
|
102
|
+
* could not tell which one returned garbage, which is the same
|
|
103
|
+
* "could not read looks like nothing to report" class #1022/#1039 attacked.
|
|
104
|
+
* Guarding at the parse also satisfies the `json-parse-untrusted-input` rule,
|
|
105
|
+
* which keys on a LEXICALLY enclosing try/catch and cannot see the outer one.
|
|
106
|
+
*
|
|
107
|
+
* Ceiling (BV-004): the payload preview is clamped to 120 characters. An
|
|
108
|
+
* unbounded one would bury the session-start banner it is printed beside — a
|
|
109
|
+
* paginated HTML error page is the realistic worst case. Revisit if a CLI
|
|
110
|
+
* starts emitting a diagnostic that needs more than one line to identify.
|
|
111
|
+
*
|
|
112
|
+
* The preview is emitted through `JSON.stringify`, not raw. It comes from a
|
|
113
|
+
* subprocess whose stdout this module does not control and lands in a
|
|
114
|
+
* `console.warn` beside the session-start banner: a payload carrying ANSI
|
|
115
|
+
* escapes, a `\r`, or a bare newline could otherwise repaint or overwrite the
|
|
116
|
+
* lines around it. `JSON.stringify` escapes every control byte and quotes the
|
|
117
|
+
* result, so the preview stays exactly one line of printable text.
|
|
118
|
+
*
|
|
119
|
+
* `expect` closes the second half of the same gap: valid JSON of the WRONG
|
|
120
|
+
* SHAPE parsed fine and escaped this named channel entirely. Measured
|
|
121
|
+
* 2026-08-28 at 7daa3d2 — a `gh repo view` that printed `null` reached
|
|
122
|
+
* `const { nameWithOwner } = …` and threw a bare
|
|
123
|
+
* `TypeError: Cannot destructure property 'nameWithOwner' of 'null'`, which
|
|
124
|
+
* names neither the CLI nor the request; a `glab api …/pipelines` that printed
|
|
125
|
+
* `null` was swallowed by `!Array.isArray(pipelines) → return null`, silent.
|
|
126
|
+
* Both now fail as the SAME named error the parse failure produces.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} stdout Raw child-process stdout (untrusted)
|
|
129
|
+
* @param {string} label The command that produced it, for the failure message
|
|
130
|
+
* @param {'object'|'array'} [expect] Required shape; omit to accept any JSON
|
|
131
|
+
* @returns {unknown} The parsed value
|
|
132
|
+
* @throws {Error} Named parse/shape failure carrying a bounded payload preview
|
|
133
|
+
*/
|
|
134
|
+
function parseCliJson(stdout, label, expect) {
|
|
135
|
+
const raw = String(stdout ?? '');
|
|
136
|
+
// Bounded (BV-004: 120 chars — a paginated HTML error page is the realistic
|
|
137
|
+
// worst case) AND escaped, so it can never break the line it is printed on.
|
|
138
|
+
const preview = raw.trim().slice(0, 120);
|
|
139
|
+
const shown = preview
|
|
140
|
+
? `got: ${escapeControlBytes(JSON.stringify(preview))}`
|
|
141
|
+
: 'got: (empty stdout)';
|
|
142
|
+
|
|
143
|
+
let parsed;
|
|
144
|
+
try {
|
|
145
|
+
parsed = JSON.parse(raw);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const reason = escapeControlBytes(err instanceof Error ? err.message : String(err));
|
|
148
|
+
// `cause` preserves the original for a debugger; the reason is ALSO
|
|
149
|
+
// inlined into the message because the outer catch reads `err.message`
|
|
150
|
+
// only — a cause-only wrapper would lose it on the operator-facing line.
|
|
151
|
+
throw new Error(
|
|
152
|
+
`${label} returned unparseable JSON (${reason}) — ${shown}`,
|
|
153
|
+
{ cause: err },
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A shape mismatch reports the JSON TYPE, never the payload. The parse
|
|
158
|
+
// succeeded, so the bytes add nothing an operator can act on — and
|
|
159
|
+
// `tests/lib/ci-status-banner.test.mjs` § "unexpected benign pipeline
|
|
160
|
+
// metadata" pins that a well-formed-but-wrong-shaped API body must not have
|
|
161
|
+
// its contents echoed anywhere. A type name carries no body content.
|
|
162
|
+
const actual = jsonTypeOf(parsed);
|
|
163
|
+
if (expect === 'array' && actual !== 'array') {
|
|
164
|
+
throw new Error(`${label} returned JSON of an unexpected shape — expected an array, got ${actual}`);
|
|
165
|
+
}
|
|
166
|
+
if (expect === 'object' && actual !== 'object') {
|
|
167
|
+
throw new Error(`${label} returned JSON of an unexpected shape — expected an object, got ${actual}`);
|
|
168
|
+
}
|
|
169
|
+
return parsed;
|
|
170
|
+
}
|
|
171
|
+
|
|
50
172
|
/**
|
|
51
173
|
* The one failure this probe can produce that the frozen
|
|
52
174
|
* `REMOTE_RESOLUTION_REASONS` set has no member for: the async timeout race
|
|
@@ -201,16 +323,17 @@ async function getHeadSha(repoRoot, deps = {}) {
|
|
|
201
323
|
* @param {string} apiPath
|
|
202
324
|
* @param {string} repoRoot
|
|
203
325
|
* @param {{ execFile?: Function, timeoutMs?: number, repoHost: string }} deps
|
|
326
|
+
* @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
|
|
204
327
|
* @returns {Promise<unknown>}
|
|
205
328
|
*/
|
|
206
|
-
async function glabApi(apiPath, repoRoot, deps = {}) {
|
|
329
|
+
async function glabApi(apiPath, repoRoot, deps = {}, expect = undefined) {
|
|
207
330
|
const args = ['api', apiPath, '--hostname', deps.repoHost];
|
|
208
331
|
const result = await execWithTimeout(
|
|
209
332
|
'glab',
|
|
210
333
|
args,
|
|
211
334
|
{ cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
|
|
212
335
|
);
|
|
213
|
-
return
|
|
336
|
+
return parseCliJson(result.stdout, `glab api ${apiPath}`, expect);
|
|
214
337
|
}
|
|
215
338
|
|
|
216
339
|
/**
|
|
@@ -222,9 +345,10 @@ async function glabApi(apiPath, repoRoot, deps = {}) {
|
|
|
222
345
|
* @param {string} apiPath
|
|
223
346
|
* @param {string} repoRoot
|
|
224
347
|
* @param {{ execFile?: Function, timeoutMs?: number, repoHost?: string }} deps
|
|
348
|
+
* @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
|
|
225
349
|
* @returns {Promise<unknown>}
|
|
226
350
|
*/
|
|
227
|
-
async function ghApi(apiPath, repoRoot, deps = {}) {
|
|
351
|
+
async function ghApi(apiPath, repoRoot, deps = {}, expect = undefined) {
|
|
228
352
|
const args = ['api', apiPath];
|
|
229
353
|
if (deps.repoHost) args.push('--hostname', deps.repoHost);
|
|
230
354
|
const result = await execWithTimeout(
|
|
@@ -232,7 +356,7 @@ async function ghApi(apiPath, repoRoot, deps = {}) {
|
|
|
232
356
|
args,
|
|
233
357
|
{ cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
|
|
234
358
|
);
|
|
235
|
-
return
|
|
359
|
+
return parseCliJson(result.stdout, `gh api ${apiPath}`, expect);
|
|
236
360
|
}
|
|
237
361
|
|
|
238
362
|
/**
|
|
@@ -276,14 +400,18 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
276
400
|
const currentSha = await getHeadSha(repoRoot, deps);
|
|
277
401
|
const apiDeps = { ...deps, repoHost: project.host };
|
|
278
402
|
const projectPath = `projects/${project.encodedProjectPath}`;
|
|
403
|
+
// `'array'` is load-bearing, not decoration: before it, a `glab api` that
|
|
404
|
+
// returned valid JSON of the wrong shape (`null`, `"ok"`, an object) fell
|
|
405
|
+
// into `!Array.isArray(pipelines) → return null` — a SILENT no-op an operator
|
|
406
|
+
// reads as "nothing to report". It now raises the same named error an
|
|
407
|
+
// unparseable payload does, so the outer catch warns.
|
|
279
408
|
const pipelines = await glabApi(
|
|
280
409
|
`${projectPath}/pipelines?order_by=updated_at&sort=desc&per_page=15`,
|
|
281
410
|
repoRoot,
|
|
282
411
|
apiDeps,
|
|
412
|
+
'array',
|
|
283
413
|
);
|
|
284
414
|
|
|
285
|
-
if (!Array.isArray(pipelines)) return null;
|
|
286
|
-
|
|
287
415
|
const currentPipeline = pipelines.find((p) => p.sha === currentSha);
|
|
288
416
|
|
|
289
417
|
if (!currentPipeline) {
|
|
@@ -312,6 +440,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
312
440
|
`${projectPath}/pipelines/${currentPipeline.id}/jobs`,
|
|
313
441
|
repoRoot,
|
|
314
442
|
apiDeps,
|
|
443
|
+
'array',
|
|
315
444
|
);
|
|
316
445
|
if (Array.isArray(jobs)) {
|
|
317
446
|
const softFailed = jobs
|
|
@@ -379,6 +508,7 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
379
508
|
`${projectPath}/pipelines/${currentPipeline.id}/jobs`,
|
|
380
509
|
repoRoot,
|
|
381
510
|
apiDeps,
|
|
511
|
+
'array',
|
|
382
512
|
);
|
|
383
513
|
if (Array.isArray(jobs)) {
|
|
384
514
|
const failedJob = jobs.find((j) => j.status === 'failed');
|
|
@@ -450,12 +580,22 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
450
580
|
repoViewArgs,
|
|
451
581
|
{ cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
|
|
452
582
|
);
|
|
453
|
-
|
|
583
|
+
// `'object'` before the destructuring: a `gh repo view` that printed `null`
|
|
584
|
+
// (or a bare string, or an array) used to throw a bare
|
|
585
|
+
// `TypeError: Cannot destructure property 'nameWithOwner' of 'null'`, whose
|
|
586
|
+
// message names neither the CLI nor the request — the exact identification
|
|
587
|
+
// failure this helper exists to fix.
|
|
588
|
+
const { nameWithOwner } = parseCliJson(
|
|
589
|
+
repoViewResult.stdout,
|
|
590
|
+
`gh ${repoViewArgs.join(' ')}`,
|
|
591
|
+
'object',
|
|
592
|
+
);
|
|
454
593
|
|
|
455
594
|
const data = await ghApi(
|
|
456
595
|
`repos/${nameWithOwner}/commits/HEAD/check-runs`,
|
|
457
596
|
repoRoot,
|
|
458
597
|
deps,
|
|
598
|
+
'object',
|
|
459
599
|
);
|
|
460
600
|
|
|
461
601
|
const checkRuns = data.check_runs;
|
|
@@ -516,12 +656,19 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
516
656
|
* or >= 2 remotes with no preference match) — a benign, measured absence
|
|
517
657
|
* - Required CLI (glab / gh) not in PATH
|
|
518
658
|
* - Any CLI invocation times out
|
|
519
|
-
* - JSON parse failure on CLI output
|
|
520
659
|
*
|
|
521
660
|
* Also returns `null`, but with a `console.warn` trace, when the VCS-detection
|
|
522
|
-
* QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring)
|
|
523
|
-
* present CLI rejected its invocation
|
|
524
|
-
*
|
|
661
|
+
* QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring), when a
|
|
662
|
+
* present CLI rejected its invocation, or when a CLI returned output this
|
|
663
|
+
* module could not parse (see {@link parseCliJson}). `null` alone cannot
|
|
664
|
+
* express "could not read" — see the outer catch and Step 1 for why the warn
|
|
665
|
+
* channel carries it.
|
|
666
|
+
*
|
|
667
|
+
* The unparseable-output case was listed above as SILENT until 2026-08-28.
|
|
668
|
+
* That was never the behaviour — it has always fallen through to the outer
|
|
669
|
+
* catch's warn branch (measured at 30940cb). The drift survived because the
|
|
670
|
+
* one test covering it asserted only the `null` and let the file-wide
|
|
671
|
+
* `console.warn` spy swallow the rest.
|
|
525
672
|
*
|
|
526
673
|
* @param {{
|
|
527
674
|
* repoRoot?: string,
|
|
@@ -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,
|
|
@@ -648,12 +648,57 @@ export function tokenizeCommand(command) {
|
|
|
648
648
|
return tokens;
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
+
/**
|
|
652
|
+
* Shell RESERVED WORDS and grouping tokens that bash recognises ONLY in COMMAND
|
|
653
|
+
* POSITION — the first word of a statement (#1145). Everywhere else they are
|
|
654
|
+
* ordinary arguments (`echo do`, `git commit -m then`), which is exactly why the
|
|
655
|
+
* drop below is position-gated rather than a blanket text filter.
|
|
656
|
+
*
|
|
657
|
+
* Two classes, both dropped, for the same reason:
|
|
658
|
+
* - OPENERS (`{`, `(`, `!`, `if`, `elif`, `then`, `else`, `while`, `until`,
|
|
659
|
+
* `do`) are followed by a COMMAND. Dropping one moves verb resolution
|
|
660
|
+
* towards the real command — the identical safety argument WRAPPER_UNWRAP
|
|
661
|
+
* makes for a transparent wrapper: a dropped token is a reserved word, never
|
|
662
|
+
* an interpreter, so this cannot turn a match into a miss.
|
|
663
|
+
* - CLOSERS (`}`, `)`, `fi`, `done`, `esac`) end one. In command position they
|
|
664
|
+
* are the whole segment, which then filters out as empty.
|
|
665
|
+
*
|
|
666
|
+
* Deliberately NOT listed:
|
|
667
|
+
* - `for` / `select` / `case` / `in` / `function` — followed by a NAME or WORD,
|
|
668
|
+
* never a command. Dropping them would invent a verb (`for t in a` → `t`)
|
|
669
|
+
* where the truthful answer is "this segment has no command".
|
|
670
|
+
* - `time` — a reserved word AND an external binary, already classified as a
|
|
671
|
+
* transparent wrapper in WRAPPER_UNWRAP. One classification per token, in
|
|
672
|
+
* one table: a second entry here would fork the wrapper-vs-keyword decision
|
|
673
|
+
* the way `VERB_PREFIXES` once forked WRAPPER_UNWRAP (#991).
|
|
674
|
+
*
|
|
675
|
+
* NAMED CEILING (BV-004): only a STANDALONE token is dropped. bash requires
|
|
676
|
+
* whitespace around `{` / `}`, so brace groups are always covered; a subshell
|
|
677
|
+
* written WITHOUT the space (`(rm -rf /)`) lexes as the glued word `(rm` and is
|
|
678
|
+
* not reached — the pre-#1145 behaviour, unchanged. Peeling the paren off the
|
|
679
|
+
* token TEXT would mutate a token five consumers read positionally, which is a
|
|
680
|
+
* larger contract change than this defect justifies. Revisit if a glued-paren
|
|
681
|
+
* form shows up in a real overflow triage or guard-bypass measurement.
|
|
682
|
+
*/
|
|
683
|
+
const COMMAND_POSITION_KEYWORDS = new Set([
|
|
684
|
+
'{', '(', '!', 'if', 'elif', 'then', 'else', 'while', 'until', 'do',
|
|
685
|
+
'}', ')', 'fi', 'done', 'esac',
|
|
686
|
+
]);
|
|
687
|
+
|
|
651
688
|
/**
|
|
652
689
|
* Split a tokenized command into chained segments on shell control operators
|
|
653
690
|
* (`;`, `&&`, `||`, `|`, `&`) and on newline separators (#981). Only UNQUOTED
|
|
654
691
|
* single-token operators split; an operator that arrived inside quotes stays
|
|
655
692
|
* part of its segment.
|
|
656
693
|
*
|
|
694
|
+
* A segment additionally has its COMMAND-POSITION reserved words stripped
|
|
695
|
+
* (#1145) — see {@link COMMAND_POSITION_KEYWORDS}. Without this the first token
|
|
696
|
+
* of a compound statement is `do` / `then` / `{` / `(` and NEVER the command,
|
|
697
|
+
* so every consumer that reads a segment head saw a keyword: measured against
|
|
698
|
+
* the live issue-budget guard with `max-per-session: 1, mode: strict`,
|
|
699
|
+
* `for t in a b c; do glab issue create …; done` and `{ glab issue create …; }`
|
|
700
|
+
* were allowed with NO accounting at all, while the plain form denied at 1/1.
|
|
701
|
+
*
|
|
657
702
|
* The newline separator is checked by its `operator` field as well as its text,
|
|
658
703
|
* so the split survives a future change to that token's spelling. Because a
|
|
659
704
|
* separator token is CONSUMED here, it can never reach a per-segment operand
|
|
@@ -678,6 +723,13 @@ function splitSegments(tokens) {
|
|
|
678
723
|
current = [];
|
|
679
724
|
continue;
|
|
680
725
|
}
|
|
726
|
+
// Reserved word in COMMAND POSITION (`current` still empty) → drop it, so
|
|
727
|
+
// the NEXT token becomes the segment head and is itself judged in command
|
|
728
|
+
// position (`do { rm …` peels both). A keyword anywhere else is an ordinary
|
|
729
|
+
// argument and is kept verbatim (#1145).
|
|
730
|
+
if (current.length === 0 && !tok.quoted && COMMAND_POSITION_KEYWORDS.has(tok.text)) {
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
681
733
|
current.push(tok);
|
|
682
734
|
}
|
|
683
735
|
segments.push(current);
|
|
@@ -763,6 +815,24 @@ export const WRAPPER_UNWRAP = new Map([
|
|
|
763
815
|
}],
|
|
764
816
|
['command', {}],
|
|
765
817
|
['nohup', {}],
|
|
818
|
+
// `exec cmd args` REPLACES the shell with `cmd` — it delegates to a real verb
|
|
819
|
+
// and never executes a command STRING, so by the #982 classification it is a
|
|
820
|
+
// TRANSPARENT wrapper, not an interpreter (contrast `su -c`, which is in
|
|
821
|
+
// SHELL_EXEC_INTERPRETERS for exactly the opposite reason). `-a NAME` is the
|
|
822
|
+
// one value-taking flag; `-c` (clear environment) and `-l` are BOOLEANS here
|
|
823
|
+
// — despite the spelling, `exec -c` carries no payload, so it must NOT reach
|
|
824
|
+
// DASH_C_SHELLS, and it does not: that set is keyed on the RESOLVED verb.
|
|
825
|
+
// Redirect-only `exec > file` keeps working. Measured both namespaces: on the
|
|
826
|
+
// RAW segment the wrapper consumes `exec` and the redirect operator resolves
|
|
827
|
+
// as the verb (`>`, previously `exec`); on scope-gate's paren-peeled,
|
|
828
|
+
// redirect-stripped projection the segment exhausts in wrappers and yields
|
|
829
|
+
// `verb: null, index: -1` — the shape a bare `sudo` already produced. Neither
|
|
830
|
+
// is `rm`/`tee`/`sed`/`dd`, and a redirect target is read from the redirect
|
|
831
|
+
// token stream rather than from the verb, so detection is unaffected:
|
|
832
|
+
// measured `extractBashWriteTargets('exec > src/out.ts')` → `['src/out.ts']`.
|
|
833
|
+
['exec', {
|
|
834
|
+
argFlags: new Set(['-a']),
|
|
835
|
+
}],
|
|
766
836
|
// `-o FILE` is the BSD/GNU `time` report destination and it TRUNCATES without
|
|
767
837
|
// `-a` (BSD time(1): "If file exists and the -a flag is not specified, the
|
|
768
838
|
// file will be overwritten"). With an empty spec the operand was read as the
|
|
@@ -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/);
|