session-orchestrator 3.22.0 → 3.23.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/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 +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- 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 +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- 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 +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -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 +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- 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/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 +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- 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/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- 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/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/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 +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- 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 +281 -13
- 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 +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- 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 +159 -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-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/sync.mjs +46 -8
- 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 +16 -32
- 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-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- 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-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- 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/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- 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 +11 -3
- 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 +30 -16
- 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 +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -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
|
|
@@ -24,6 +24,76 @@ import { matchBlockHeader } from './block-header.mjs';
|
|
|
24
24
|
* Consumers: `scripts/lib/config.mjs`, `skills/session-end/SKILL.md` Phase 3.6.8.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
/**
|
|
28
|
+
* CLOSED set of valid `reconcile.targets` members.
|
|
29
|
+
*
|
|
30
|
+
* At module scope on purpose: `targets` has TWO parse sites (the bracket-list
|
|
31
|
+
* branch and the bare-scalar `case 'targets'`), whereas `mode` has one. An
|
|
32
|
+
* inline literal duplicated across two sites is a second register that drifts —
|
|
33
|
+
* the shape mirrors `VALID_MODES` below, hoisted because the duplication is real.
|
|
34
|
+
*
|
|
35
|
+
* `global` is documented-but-unimplemented and is deliberately NOT a member:
|
|
36
|
+
* admitting a value nothing implements is the same defect class as the
|
|
37
|
+
* unvalidated pass-through this constant replaces.
|
|
38
|
+
*/
|
|
39
|
+
const VALID_TARGETS = Object.freeze(['repo-local', 'baseline']);
|
|
40
|
+
|
|
41
|
+
/** Fallback when every declared target was dropped (mirrors the `defaults` object). */
|
|
42
|
+
const DEFAULT_TARGETS = Object.freeze(['repo-local']);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Report dropped `reconcile.targets` members on stderr.
|
|
46
|
+
*
|
|
47
|
+
* stderr, NEVER stdout: `scripts/lib/config.mjs` consumers parse this parser's
|
|
48
|
+
* downstream output as JSON, so a diagnostic on stdout would corrupt it.
|
|
49
|
+
*
|
|
50
|
+
* Prior art for "make an attributable drop VISIBLE, not merely recorded":
|
|
51
|
+
* `warnDroppedStoreRecords` in `scripts/lib/reconcile/engine.mjs`.
|
|
52
|
+
*
|
|
53
|
+
* @param {string[]} dropped
|
|
54
|
+
*/
|
|
55
|
+
function warnUnknownTargets(dropped) {
|
|
56
|
+
try {
|
|
57
|
+
console.warn(
|
|
58
|
+
`⚠️ reconcile.targets: dropped unknown value(s) ${dropped.map((d) => JSON.stringify(d)).join(', ')} — ` +
|
|
59
|
+
`valid targets are ${VALID_TARGETS.join(' | ')}. The unknown value is IGNORED (no rule is ` +
|
|
60
|
+
`written for it); if every declared target was dropped, the parser falls back to ` +
|
|
61
|
+
`[${DEFAULT_TARGETS.join(', ')}].`,
|
|
62
|
+
);
|
|
63
|
+
} catch {
|
|
64
|
+
// A diagnostic must never become the failure it reports on.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Keep only {@link VALID_TARGETS} members, de-duplicated, order-preserving.
|
|
70
|
+
*
|
|
71
|
+
* Rejection is DROP + WARN, never a throw — not even under `enforcement: strict`:
|
|
72
|
+
* (a) this module's stated contract ("Tolerant parser: malformed values silently
|
|
73
|
+
* fall back to defaults") is obeyed by every other key here; making one key
|
|
74
|
+
* throw is an internal inconsistency;
|
|
75
|
+
* (b) `_parseReconcile` runs from `scripts/lib/config.mjs` at session-start, so a
|
|
76
|
+
* throw would fail session-start on a config typo;
|
|
77
|
+
* (c) the SILENCE was the defect, not the tolerance — the WARN closes it.
|
|
78
|
+
*
|
|
79
|
+
* Fail-loud belongs one layer up (`claude-md-drift-check` Check 6 could ERROR on
|
|
80
|
+
* an unknown value); that is deliberately NOT bundled here.
|
|
81
|
+
*
|
|
82
|
+
* @param {string[]} items
|
|
83
|
+
* @returns {string[]}
|
|
84
|
+
*/
|
|
85
|
+
function filterTargets(items) {
|
|
86
|
+
const kept = [];
|
|
87
|
+
const dropped = [];
|
|
88
|
+
for (const item of items) {
|
|
89
|
+
if (VALID_TARGETS.includes(item)) kept.push(item);
|
|
90
|
+
else dropped.push(item);
|
|
91
|
+
}
|
|
92
|
+
if (dropped.length > 0) warnUnknownTargets(dropped);
|
|
93
|
+
const uniq = [...new Set(kept)];
|
|
94
|
+
return uniq.length > 0 ? uniq : [...DEFAULT_TARGETS];
|
|
95
|
+
}
|
|
96
|
+
|
|
27
97
|
/**
|
|
28
98
|
* Parse the top-level `reconcile:` YAML block from markdown content.
|
|
29
99
|
*
|
|
@@ -32,8 +102,13 @@ import { matchBlockHeader } from './block-header.mjs';
|
|
|
32
102
|
* reconcile.mode: 'warn' — advisory only; rules NEVER auto-applied,
|
|
33
103
|
* every write is operator-AUQ-gated (enum: off|warn)
|
|
34
104
|
* reconcile.targets: ['repo-local']
|
|
35
|
-
* — where approved rules are written
|
|
36
|
-
*
|
|
105
|
+
* — where approved rules are written.
|
|
106
|
+
* CLOSED enum, see {@link VALID_TARGETS}:
|
|
107
|
+
* repo-local = `.claude/rules/` in this repo;
|
|
108
|
+
* baseline = `<baselineRoot>/proposals/`
|
|
109
|
+
* (issue #1099). An unknown member is DROPPED
|
|
110
|
+
* with a stderr WARN — see {@link filterTargets}
|
|
111
|
+
* for why a drop and not a throw.
|
|
37
112
|
* reconcile.rule-expiry-days: null — CRITICAL: must default to null so the
|
|
38
113
|
* reconcile engine (`emitter.mjs`
|
|
39
114
|
* `computeExpiresAt`) falls back to per-type
|
|
@@ -132,7 +207,7 @@ export function _parseReconcile(content) {
|
|
|
132
207
|
.split(',')
|
|
133
208
|
.map((s) => s.trim())
|
|
134
209
|
.filter(Boolean);
|
|
135
|
-
if (items.length > 0) targets = items;
|
|
210
|
+
if (items.length > 0) targets = filterTargets(items);
|
|
136
211
|
continue;
|
|
137
212
|
}
|
|
138
213
|
|
|
@@ -206,7 +281,7 @@ export function _parseReconcile(content) {
|
|
|
206
281
|
// targets inline-list with no brackets (e.g. targets: repo-local) — single value
|
|
207
282
|
case 'targets': {
|
|
208
283
|
if (v && !v.startsWith('[')) {
|
|
209
|
-
targets = [v];
|
|
284
|
+
targets = filterTargets([v]);
|
|
210
285
|
}
|
|
211
286
|
break;
|
|
212
287
|
}
|
|
@@ -131,7 +131,8 @@ export function findSessionConfigBlock(content, { occurrence = 'first' } = {}) {
|
|
|
131
131
|
/**
|
|
132
132
|
* Extract the raw ## Session Config block lines from markdown content.
|
|
133
133
|
* - CRLF-tolerant
|
|
134
|
-
* - Skips code fence lines (``` alone
|
|
134
|
+
* - Skips code fence lines (``` alone or with an info string)
|
|
135
|
+
* - Skips lines inside a multi-line `<!-- … -->` block (`htmlCommentSkipper`)
|
|
135
136
|
* - Strips trailing whitespace from each line
|
|
136
137
|
* @param {string} content
|
|
137
138
|
* @returns {string[]} lines of the Session Config block
|
|
@@ -140,6 +141,7 @@ export function _extractConfigSection(content) {
|
|
|
140
141
|
const lines = content.split(/\r?\n/);
|
|
141
142
|
const result = [];
|
|
142
143
|
let inSection = false;
|
|
144
|
+
const skipHtmlComment = htmlCommentSkipper();
|
|
143
145
|
|
|
144
146
|
for (const rawLine of lines) {
|
|
145
147
|
const line = rawLine.replace(/\r$/, '');
|
|
@@ -152,8 +154,17 @@ export function _extractConfigSection(content) {
|
|
|
152
154
|
if (inSection) {
|
|
153
155
|
// Next ## header closes the section
|
|
154
156
|
if (/^## /.test(line)) break;
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
+
// Commented-out config is documentation, not config (#1097 review).
|
|
158
|
+
// Shared with `collectUnparsableLines` so the two accept-sets cannot
|
|
159
|
+
// drift — see `htmlCommentSkipper`.
|
|
160
|
+
if (skipHtmlComment(line)) continue;
|
|
161
|
+
// Skip code fences — opener WITH an info string included (#1097).
|
|
162
|
+
// The old predicate was `line.trim() === '```'`, which skips a bare
|
|
163
|
+
// fence but lets ```` ```yaml ```` through as a config line. Harmless
|
|
164
|
+
// while an unmatched line was silently dropped; a false "unparsable"
|
|
165
|
+
// report the moment `collectUnparsableLines` below started naming them —
|
|
166
|
+
// and the fenced form is what one fleet repo actually writes.
|
|
167
|
+
if (isCodeFence(line)) continue;
|
|
157
168
|
// Strip trailing whitespace and collect
|
|
158
169
|
result.push(line.replace(/\s+$/, ''));
|
|
159
170
|
}
|
|
@@ -162,6 +173,135 @@ export function _extractConfigSection(content) {
|
|
|
162
173
|
return result;
|
|
163
174
|
}
|
|
164
175
|
|
|
176
|
+
/**
|
|
177
|
+
* True when `line` is a fenced-code-block delimiter — a bare ``` / ~~~ or an
|
|
178
|
+
* opener carrying an info string (```yaml).
|
|
179
|
+
*
|
|
180
|
+
* @param {string} line
|
|
181
|
+
* @returns {boolean}
|
|
182
|
+
*/
|
|
183
|
+
function isCodeFence(line) {
|
|
184
|
+
return /^\s*(?:```|~~~)/.test(line);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* THE multi-line `<!-- … -->` state machine for the Session Config block —
|
|
189
|
+
* the one place that knows whether a line is commented-out documentation.
|
|
190
|
+
*
|
|
191
|
+
* Exported-by-use (same argument as `_matchKVLine`): `_extractConfigSection`
|
|
192
|
+
* feeds `_parseKV`, and `collectUnparsableLines` classifies — so both call this
|
|
193
|
+
* and their accept-sets are identical BY CONSTRUCTION. They were not, and the
|
|
194
|
+
* divergence ran in exactly the dangerous direction: the collector skipped
|
|
195
|
+
* commented blocks while the extractor read them, so
|
|
196
|
+
*
|
|
197
|
+
* persistence: true
|
|
198
|
+
* <!--
|
|
199
|
+
* enforcement: strict
|
|
200
|
+
* -->
|
|
201
|
+
*
|
|
202
|
+
* yielded a LIVE `enforcement: strict` (measured: `_parseKV` returned it) that
|
|
203
|
+
* `collectUnparsableLines` reported as a clean block — and `parse-config.mjs`
|
|
204
|
+
* branches its own #1097 gate on `config.enforcement`. Commenting a key out is
|
|
205
|
+
* the most ordinary way to disable it; it must not arm the strictest path.
|
|
206
|
+
*
|
|
207
|
+
* Returns a fresh closure per call — it carries per-document state, so a shared
|
|
208
|
+
* instance would leak an unterminated comment from one document into the next.
|
|
209
|
+
*
|
|
210
|
+
* Ceiling (deliberate): the opener must be at the START of a line
|
|
211
|
+
* (`/^\s*<!--/`). A comment opened mid-line (`waves: 5 <!--`) therefore does
|
|
212
|
+
* NOT swallow the following lines — which keeps every single-line trailing
|
|
213
|
+
* `<!-- … -->` on a heading or key line behaving exactly as before, the form
|
|
214
|
+
* this repo's own convention encourages. Revisit if a fleet CLAUDE.md is ever
|
|
215
|
+
* measured opening a multi-line comment after a value.
|
|
216
|
+
*
|
|
217
|
+
* @returns {(line: string) => boolean} true when the line is inside (or opens)
|
|
218
|
+
* a multi-line HTML comment and must be ignored by both consumers.
|
|
219
|
+
*/
|
|
220
|
+
function htmlCommentSkipper() {
|
|
221
|
+
let inComment = false;
|
|
222
|
+
return function skipHtmlComment(line) {
|
|
223
|
+
if (inComment) {
|
|
224
|
+
if (line.includes('-->')) inComment = false;
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
if (/^\s*<!--/.test(line) && !line.includes('-->')) {
|
|
228
|
+
inComment = true;
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
return false;
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Replace C0/C1 control characters with `?` before a line is handed to a
|
|
237
|
+
* reporter.
|
|
238
|
+
*
|
|
239
|
+
* `collectUnparsableLines` returns text taken verbatim from a file that is
|
|
240
|
+
* itself the thing being reported as malformed, and its only consumer prints
|
|
241
|
+
* it on stderr after a `WARN unparsable Session Config line N:` prefix. A
|
|
242
|
+
* planted `\x1b[2K…\r` erases that prefix and rewrites the operator's terminal
|
|
243
|
+
* line — the report of a defect becoming the vehicle for hiding it. Sanitising
|
|
244
|
+
* at the SOURCE (rather than at the one printer) means every future consumer
|
|
245
|
+
* inherits the safe form.
|
|
246
|
+
*
|
|
247
|
+
* @param {string} text
|
|
248
|
+
* @returns {string}
|
|
249
|
+
*/
|
|
250
|
+
function sanitizeReportText(text) {
|
|
251
|
+
// eslint-disable-next-line no-control-regex -- the control chars ARE the target
|
|
252
|
+
return text.replace(/[\u0000-\u001F\u007F-\u009F]/g, '?');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* THE key/value predicate for a Session Config line — the one place that knows
|
|
257
|
+
* which of the two accepted spellings a line is in.
|
|
258
|
+
*
|
|
259
|
+
* Exported-by-use rather than by name: `_parseKV` builds the map from it and
|
|
260
|
+
* `collectUnparsableLines` decides "this line carries a key" from it, so the
|
|
261
|
+
* accept-set of the two can never drift. A classifier with its own regex would
|
|
262
|
+
* eventually report a line the map DID parse (noise) or stay silent on one it
|
|
263
|
+
* did not (the #1097 defect, unchanged).
|
|
264
|
+
*
|
|
265
|
+
* Format 1: `- **key:** value`
|
|
266
|
+
* Format 2: `key: value`, incl. the YAML list-item form `- key: value` (#497)
|
|
267
|
+
* and any leading indentation (sub-keys of a nested block land here too).
|
|
268
|
+
*
|
|
269
|
+
* @param {string} line
|
|
270
|
+
* @returns {{ key: string, value: string } | null} null when the line carries
|
|
271
|
+
* no key/value pair in either format.
|
|
272
|
+
*/
|
|
273
|
+
function _matchKVLine(line) {
|
|
274
|
+
let key;
|
|
275
|
+
let value;
|
|
276
|
+
|
|
277
|
+
// Format 1: - **key:** value
|
|
278
|
+
const fmt1 = line.match(/^\s*-\s+\*\*([^*:]+):\*\*\s*(.*)/);
|
|
279
|
+
if (fmt1) {
|
|
280
|
+
key = fmt1[1].trim();
|
|
281
|
+
value = fmt1[2].trim();
|
|
282
|
+
} else {
|
|
283
|
+
// Format 2: key: value — supports both plain "key: value" and
|
|
284
|
+
// YAML list-item form "- key: value" (issue #497). Key starts with
|
|
285
|
+
// letter; rest is alphanum/hyphen/underscore.
|
|
286
|
+
const fmt2 = line.match(/^\s*(?:-\s+)?([a-zA-Z][a-zA-Z0-9_-]+):\s+(.*)/);
|
|
287
|
+
if (!fmt2) return null;
|
|
288
|
+
key = fmt2[1].trim();
|
|
289
|
+
value = fmt2[2].trim();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!key) return null;
|
|
293
|
+
|
|
294
|
+
// Strip inline YAML comment (matches block-parser behaviour in _parseVaultSync etc.)
|
|
295
|
+
value = value.replace(/\s+#.*$/, '').trim();
|
|
296
|
+
|
|
297
|
+
// Strip surrounding double quotes from value
|
|
298
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
299
|
+
value = value.slice(1, -1);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return { key, value };
|
|
303
|
+
}
|
|
304
|
+
|
|
165
305
|
/**
|
|
166
306
|
* Parse the key-value pairs from extracted Session Config lines.
|
|
167
307
|
* Supports Format 1: `- **key:** value`
|
|
@@ -176,39 +316,9 @@ export function _parseKV(lines) {
|
|
|
176
316
|
|
|
177
317
|
for (const line of lines) {
|
|
178
318
|
if (!line.trim()) continue;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
// Format 1: - **key:** value
|
|
184
|
-
const fmt1 = line.match(/^\s*-\s+\*\*([^*:]+):\*\*\s*(.*)/);
|
|
185
|
-
if (fmt1) {
|
|
186
|
-
key = fmt1[1].trim();
|
|
187
|
-
value = fmt1[2].trim();
|
|
188
|
-
} else {
|
|
189
|
-
// Format 2: key: value — supports both plain "key: value" and
|
|
190
|
-
// YAML list-item form "- key: value" (issue #497). Key starts with
|
|
191
|
-
// letter; rest is alphanum/hyphen/underscore.
|
|
192
|
-
const fmt2 = line.match(/^\s*(?:-\s+)?([a-zA-Z][a-zA-Z0-9_-]+):\s+(.*)/);
|
|
193
|
-
if (fmt2) {
|
|
194
|
-
key = fmt2[1].trim();
|
|
195
|
-
value = fmt2[2].trim();
|
|
196
|
-
} else {
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (!key) continue;
|
|
202
|
-
|
|
203
|
-
// Strip inline YAML comment (matches block-parser behaviour in _parseVaultSync etc.)
|
|
204
|
-
value = value.replace(/\s+#.*$/, '').trim();
|
|
205
|
-
|
|
206
|
-
// Strip surrounding double quotes from value
|
|
207
|
-
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
208
|
-
value = value.slice(1, -1);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
allPairs.push([key, value]);
|
|
319
|
+
const match = _matchKVLine(line);
|
|
320
|
+
if (!match) continue;
|
|
321
|
+
allPairs.push([match.key, match.value]);
|
|
212
322
|
}
|
|
213
323
|
|
|
214
324
|
// Last match wins: build the map by iterating in order
|
|
@@ -218,3 +328,92 @@ export function _parseKV(lines) {
|
|
|
218
328
|
}
|
|
219
329
|
return kv;
|
|
220
330
|
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Fail-loud: lines inside the block that carry no meaning (#1097)
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* A line that opens a nested block: `vault-integration:` with no value, in any
|
|
338
|
+
* of the four fleet spellings (column-zero, `- key:`, `**key:**`, `- **key:**`)
|
|
339
|
+
* and at any indent. Deliberately NOT imported from block-header.mjs: that
|
|
340
|
+
* matcher answers "does this line open the block for THIS key" for a known key,
|
|
341
|
+
* while here the key is unknown by construction.
|
|
342
|
+
*/
|
|
343
|
+
const NESTED_BLOCK_HEADER_RE = /^\s*(?:-\s+)?(?:\*\*)?[a-zA-Z][a-zA-Z0-9_.-]*:(?:\*\*)?\s*$/;
|
|
344
|
+
|
|
345
|
+
/** A YAML sequence element that is not a `key: value` pair (` - some-value`). */
|
|
346
|
+
const LIST_ITEM_RE = /^\s*-\s+\S/;
|
|
347
|
+
|
|
348
|
+
/** Documentation, not config: `# yaml comment` and `<!-- html -->` / `> quote`. */
|
|
349
|
+
const COMMENT_RE = /^\s*(?:#|<!--|>)/;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Collect the lines inside `## Session Config` that carry no meaning for any
|
|
353
|
+
* parser — the fail-loud half of #1097.
|
|
354
|
+
*
|
|
355
|
+
* The measured defect this exists to end: a key the extractor cannot read is
|
|
356
|
+
* simply absent from the KV map, and every consumer then applies its DEFAULT.
|
|
357
|
+
* For a boolean that default is `false`, so a mis-spelled `vault-integration`
|
|
358
|
+
* block reads exactly like a deliberately disabled one — no error, anywhere, in
|
|
359
|
+
* any log (fleet evidence: a repo whose vault mirror sat at
|
|
360
|
+
* `skipped-vault-disabled` for two months with the key present in its CLAUDE.md).
|
|
361
|
+
*
|
|
362
|
+
* A line is accounted for — and therefore NOT reported — when it is:
|
|
363
|
+
* blank · a code fence · a `#`/`<!--`/`>` documentation line · a key/value
|
|
364
|
+
* pair in either accepted format (`_matchKVLine`) · a nested-block header
|
|
365
|
+
* (`key:` with no value) · a bare YAML list element (`- value`).
|
|
366
|
+
* Everything else is prose sitting where config is expected, which is the only
|
|
367
|
+
* shape a broken key can take once the six forms above are excluded.
|
|
368
|
+
*
|
|
369
|
+
* Line numbers are 1-based and count from the START OF THE DOCUMENT, not from
|
|
370
|
+
* the block — they are for an operator opening the file at that line. The text
|
|
371
|
+
* is run through `sanitizeReportText`, because it is quoted straight into a
|
|
372
|
+
* terminal by the one consumer that prints it.
|
|
373
|
+
*
|
|
374
|
+
* The accept-set is shared with the reader, not merely aligned with it: the
|
|
375
|
+
* `#`/`>` and blank/fence forms are literal here, and the multi-line HTML
|
|
376
|
+
* comment goes through the same `htmlCommentSkipper` call `_extractConfigSection`
|
|
377
|
+
* makes. A form the reader accepts but the classifier does not is noise; a form
|
|
378
|
+
* the READER accepts and the classifier waves through as documentation is the
|
|
379
|
+
* live-commented-out-key defect this shape exists to make unreachable.
|
|
380
|
+
*
|
|
381
|
+
* @param {string} content — full markdown document
|
|
382
|
+
* @returns {Array<{ line: number, text: string }>} empty when the block is
|
|
383
|
+
* well-formed, absent, or the content is not a string.
|
|
384
|
+
*/
|
|
385
|
+
export function collectUnparsableLines(content) {
|
|
386
|
+
if (typeof content !== 'string' || content === '') return [];
|
|
387
|
+
|
|
388
|
+
const lines = content.split(/\r?\n/);
|
|
389
|
+
const unparsable = [];
|
|
390
|
+
let inSection = false;
|
|
391
|
+
const skipHtmlComment = htmlCommentSkipper();
|
|
392
|
+
|
|
393
|
+
for (let i = 0; i < lines.length; i++) {
|
|
394
|
+
const line = lines[i].replace(/\r$/, '');
|
|
395
|
+
|
|
396
|
+
if (isSessionConfigHeading(line)) {
|
|
397
|
+
inSection = true;
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (!inSection) continue;
|
|
401
|
+
if (/^## /.test(line)) break;
|
|
402
|
+
|
|
403
|
+
// A multi-line HTML comment: everything up to `-->` is documentation.
|
|
404
|
+
// Same call, same order, as `_extractConfigSection` — that shared call is
|
|
405
|
+
// what makes the two accept-sets identical rather than merely similar.
|
|
406
|
+
if (skipHtmlComment(line)) continue;
|
|
407
|
+
|
|
408
|
+
if (!line.trim()) continue;
|
|
409
|
+
if (isCodeFence(line)) continue;
|
|
410
|
+
if (COMMENT_RE.test(line)) continue;
|
|
411
|
+
if (_matchKVLine(line)) continue;
|
|
412
|
+
if (NESTED_BLOCK_HEADER_RE.test(line)) continue;
|
|
413
|
+
if (LIST_ITEM_RE.test(line)) continue;
|
|
414
|
+
|
|
415
|
+
unparsable.push({ line: i + 1, text: sanitizeReportText(line.trim()) });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return unparsable;
|
|
419
|
+
}
|
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
* Never throws. Never mutates input.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* The values `enforcement:` accepts. Exported (#1097) so the gates that BRANCH
|
|
13
|
+
* on enforcement — `scripts/parse-config.mjs`'s unparsable-line gate is the
|
|
14
|
+
* first — read the same Set the validator judges against, instead of re-typing
|
|
15
|
+
* the literal `'strict'` beside a schema that could later add a mode.
|
|
16
|
+
*
|
|
17
|
+
* @type {Set<string>}
|
|
18
|
+
*/
|
|
19
|
+
export const ENFORCEMENT_VALUES = new Set(['strict', 'warn', 'off']);
|
|
12
20
|
// Exported (issue #836) so cross-layer mode-vocabulary parity tests can use
|
|
13
21
|
// this Set as the single source of truth for "the modes a Session Config
|
|
14
22
|
// `*.mode` key accepts" — rather than re-deriving a duplicate literal list
|
package/scripts/lib/config.mjs
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* events-rotation.mjs — _parseEventsRotation
|
|
15
15
|
* vault-integration.mjs — _parseVaultIntegration + _parseResourceThresholds
|
|
16
16
|
*
|
|
17
|
+
* One parser lives OUTSIDE that directory: `_parseExpressPath` in
|
|
18
|
+
* scripts/lib/express-path.mjs, alongside the express-path decision it configures.
|
|
19
|
+
* See that module's header for the reason and the revisit trigger.
|
|
20
|
+
*
|
|
17
21
|
* Originally ported from parse-config.sh (v2) plus its helper libs
|
|
18
22
|
* config-yaml-parser.sh and config-json-coercion.sh. Windows + CRLF safe.
|
|
19
23
|
*
|
|
@@ -72,6 +76,11 @@ import { _parseSkillEvolution } from './config/skill-evolution.mjs';
|
|
|
72
76
|
import { _parseDispatcherAutonomy, resolveDispatcherAutonomy } from './config/dispatcher-autonomy.mjs';
|
|
73
77
|
import { loadHostPaths, resolveHostPath } from './config/host-paths.mjs';
|
|
74
78
|
import { resolveNamedBaseline } from './named-baseline-resolver.mjs';
|
|
79
|
+
// express-path lives one level UP from config/ (see its module header for why):
|
|
80
|
+
// the parser is a sibling of config/state-md-lock.mjs in every respect except
|
|
81
|
+
// its directory. Only the pure parser is imported here — `evaluateExpressPath`
|
|
82
|
+
// and its telemetry stay out of this module's import graph by construction.
|
|
83
|
+
import { _parseExpressPath } from './express-path.mjs';
|
|
75
84
|
|
|
76
85
|
// Re-export the two functions that external callers import directly from this module.
|
|
77
86
|
export { _coerceEnum, _coerceCollisionRisk } from './config/coercers.mjs';
|
|
@@ -226,24 +235,57 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
|
|
|
226
235
|
if (agentMapping !== null) {
|
|
227
236
|
// Validate role keys against canonical list from skills/_shared/config-reading.md
|
|
228
237
|
const ALLOWED_ROLES = ['impl', 'test', 'db', 'ui', 'security', 'compliance', 'docs', 'perf'];
|
|
238
|
+
// Dispatch channels a mapping value may name via a `<channel>:<target>`
|
|
239
|
+
// prefix (#1150). Without a colon the value is a plain agent name and is
|
|
240
|
+
// dispatched natively, exactly as before.
|
|
241
|
+
// impl: code-implementer → native Agent dispatch (unchanged)
|
|
242
|
+
// impl: cursor:composer-2.5 → foreign model over the Cursor channel
|
|
243
|
+
// An unknown prefix is a typo or a channel this build cannot route, and
|
|
244
|
+
// must fail loudly: silently accepting it would dispatch to an agent that
|
|
245
|
+
// does not exist, which surfaces only as an empty wave much later.
|
|
246
|
+
const KNOWN_CHANNELS = ['cursor', 'session-orchestrator'];
|
|
247
|
+
// Accumulate every defect, then report once. A config with two bad entries
|
|
248
|
+
// must name BOTH — throwing on the first one makes the operator re-run the
|
|
249
|
+
// parse per defect, and each re-run hides the ones behind it.
|
|
229
250
|
const invalidKeys = [];
|
|
251
|
+
const valueProblems = [];
|
|
230
252
|
for (const [k, v] of Object.entries(agentMapping)) {
|
|
231
253
|
if (!ALLOWED_ROLES.includes(k)) {
|
|
232
254
|
invalidKeys.push(k);
|
|
233
255
|
continue;
|
|
234
256
|
}
|
|
235
257
|
if (typeof v !== 'string' || v === '') {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
258
|
+
valueProblems.push(`role '${k}' has invalid value '${v}' (expected non-empty string)`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const colonIdx = v.indexOf(':');
|
|
262
|
+
if (colonIdx !== -1) {
|
|
263
|
+
const channel = v.slice(0, colonIdx).trim();
|
|
264
|
+
const target = v.slice(colonIdx + 1).trim();
|
|
265
|
+
if (!KNOWN_CHANNELS.includes(channel)) {
|
|
266
|
+
valueProblems.push(
|
|
267
|
+
`role '${k}' names unknown channel '${channel}' in value '${v}' ` +
|
|
268
|
+
`(known channels: ${KNOWN_CHANNELS.join(', ')}; a value without ':' is a plain agent name)`
|
|
269
|
+
);
|
|
270
|
+
} else if (target === '') {
|
|
271
|
+
valueProblems.push(
|
|
272
|
+
`role '${k}' has channel '${channel}' but no target in value '${v}' ` +
|
|
273
|
+
`(expected '${channel}:<model-or-agent>')`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
239
276
|
}
|
|
240
277
|
}
|
|
278
|
+
const problems = [];
|
|
241
279
|
if (invalidKeys.length > 0) {
|
|
242
|
-
|
|
243
|
-
`
|
|
244
|
-
`(allowed: ${ALLOWED_ROLES.join(', ')})`
|
|
280
|
+
problems.push(
|
|
281
|
+
`invalid role key(s): ${invalidKeys.join(', ')} (allowed: ${ALLOWED_ROLES.join(', ')})`
|
|
245
282
|
);
|
|
246
283
|
}
|
|
284
|
+
problems.push(...valueProblems);
|
|
285
|
+
if (problems.length > 0) {
|
|
286
|
+
// Still fail loud — only the reporting shape changed, never the verdict.
|
|
287
|
+
throw new Error(`config.mjs: agent-mapping ${problems.join('; ')}`);
|
|
288
|
+
}
|
|
247
289
|
}
|
|
248
290
|
const enforcementGates = _coerceBoolObject(kv, 'enforcement-gates');
|
|
249
291
|
|
|
@@ -286,6 +328,14 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
|
|
|
286
328
|
// events-rotation: parsed from full content (standalone top-level block)
|
|
287
329
|
const eventsRotation = _parseEventsRotation(mdContent);
|
|
288
330
|
|
|
331
|
+
// express-path: parsed from full content (standalone top-level block, #214/#1119).
|
|
332
|
+
// Documented in docs/session-config-template.md:585 and
|
|
333
|
+
// docs/session-config-reference.md:1519 since #214, but never in the key schema —
|
|
334
|
+
// measured 2026-08-23, a probe carrying `express-path:\n enabled: false` produced
|
|
335
|
+
// 88 keys and none of them was `express-path`. The key was dropped even when it
|
|
336
|
+
// stood in the file, so the documented default was prose rather than a value.
|
|
337
|
+
const expressPath = _parseExpressPath(mdContent);
|
|
338
|
+
|
|
289
339
|
// vault-mirror.quality: parsed from full content (PRD F1.2 / issue #504)
|
|
290
340
|
const vaultMirror = _parseVaultMirrorQuality(mdContent);
|
|
291
341
|
|
|
@@ -482,6 +532,7 @@ export function parseSessionConfig(mdContent, { hostPaths } = {}) {
|
|
|
482
532
|
'context-coverage': contextCoverage,
|
|
483
533
|
'worktree-orphans': worktreeOrphans,
|
|
484
534
|
'events-rotation': eventsRotation,
|
|
535
|
+
'express-path': expressPath,
|
|
485
536
|
'test': testConfig,
|
|
486
537
|
'gitlab-portfolio': gitlabPortfolio,
|
|
487
538
|
'wave-reviewers': waveReviewers,
|
|
@@ -312,12 +312,23 @@ function evaluateSignals(state, alreadyEmitted, latestWave) {
|
|
|
312
312
|
}
|
|
313
313
|
|
|
314
314
|
/**
|
|
315
|
+
* Poll delay.
|
|
316
|
+
*
|
|
317
|
+
* The timer is deliberately NOT `unref()`d: it is the only handle this process
|
|
318
|
+
* holds, so an unref'd timer drains the event loop and node exits 0 the instant
|
|
319
|
+
* the first tick is scheduled — a monitor that supervises nothing while looking
|
|
320
|
+
* like a clean shutdown (it has already emitted `tail.started` and writes
|
|
321
|
+
* nothing to stderr). Measured 2026-08-25 on the unref'd variant:
|
|
322
|
+
* `node scripts/lib/convergence-monitor.mjs --tail --interval=1` returned
|
|
323
|
+
* exit 0 after 47 ms instead of running until SIGTERM. This is the mechanism
|
|
324
|
+
* behind #980 (convergence-monitor never fires) — the same defect A1 measured
|
|
325
|
+
* in `scripts/lib/wave-transcript-tail.mjs`, which this sleep() was copied to.
|
|
326
|
+
*
|
|
315
327
|
* @param {number} ms
|
|
316
328
|
*/
|
|
317
329
|
function sleep(ms) {
|
|
318
330
|
return new Promise((resolve) => {
|
|
319
|
-
|
|
320
|
-
t.unref?.();
|
|
331
|
+
setTimeout(resolve, ms);
|
|
321
332
|
});
|
|
322
333
|
}
|
|
323
334
|
|