session-orchestrator 3.21.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 +11 -2
- 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/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +10 -10
- 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 +269 -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/memory-proposal-collector.md +6 -4
- 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/eli5.md +33 -0
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- 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 +7 -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 +52 -5
- package/docs/session-config-reference.md +57 -12
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +26 -16
- 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 +12 -2
- package/hooks/on-session-end.mjs +237 -104
- package/hooks/on-session-start.mjs +404 -110
- 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-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +133 -21
- 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 +3 -1
- package/pi/prompts/eli5.md +12 -0
- 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/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +12 -8
- 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/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -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 +221 -68
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config/vault-integration.mjs +12 -1
- 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/dispatcher/rank.mjs +4 -7
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +84 -11
- package/scripts/lib/gates/gate-helpers.mjs +93 -21
- 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/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +284 -31
- 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/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +104 -4
- 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 +165 -7
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- 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 +111 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +467 -72
- 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-auq-clarity.mjs +274 -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 +59 -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-rules.mjs +153 -9
- 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-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- 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/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +478 -0
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +6 -0
- package/scripts/validate-wave-scope.mjs +73 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -25
- package/skills/_shared/parallel-aware-preamble.md +35 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +61 -8
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +5 -4
- 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/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/discovery/SKILL.md +28 -24
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +18 -6
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +44 -4
- package/skills/session-end/SKILL.md +70 -24
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +48 -5
- 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 +96 -48
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- 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/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- 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 +129 -24
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/templates/_shared/journey-manifest.md +110 -0
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -18,6 +18,7 @@ import { promisify } from 'node:util';
|
|
|
18
18
|
import {
|
|
19
19
|
resolveRepoSpec,
|
|
20
20
|
resolveRepoHost,
|
|
21
|
+
resolveGitlabProjectTarget,
|
|
21
22
|
redactUrlCredentials,
|
|
22
23
|
detectVcsFamily,
|
|
23
24
|
isQueryFailure,
|
|
@@ -46,6 +47,128 @@ async function execWithTimeout(cmd, args, opts = {}) {
|
|
|
46
47
|
]);
|
|
47
48
|
}
|
|
48
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
|
+
|
|
49
172
|
/**
|
|
50
173
|
* The one failure this probe can produce that the frozen
|
|
51
174
|
* `REMOTE_RESOLUTION_REASONS` set has no member for: the async timeout race
|
|
@@ -191,48 +314,26 @@ async function getHeadSha(repoRoot, deps = {}) {
|
|
|
191
314
|
}
|
|
192
315
|
|
|
193
316
|
/**
|
|
194
|
-
*
|
|
317
|
+
* Run a host-pinned `glab api <path>` request and return parsed JSON.
|
|
195
318
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* @param {string} repoRoot
|
|
200
|
-
* @param {{ execFile?: Function, timeoutMs?: number, repoSpec?: string }} deps
|
|
201
|
-
* @returns {Promise<number>}
|
|
202
|
-
*/
|
|
203
|
-
async function getGlabProjectId(repoRoot, deps = {}) {
|
|
204
|
-
const args = ['repo', 'view', '--output', 'json'];
|
|
205
|
-
if (deps.repoSpec) args.push('-R', deps.repoSpec);
|
|
206
|
-
const result = await execWithTimeout(
|
|
207
|
-
'glab',
|
|
208
|
-
args,
|
|
209
|
-
{ cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
|
|
210
|
-
);
|
|
211
|
-
const parsed = JSON.parse(result.stdout);
|
|
212
|
-
return parsed.id;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Run `glab api <path>` and return parsed JSON.
|
|
217
|
-
*
|
|
218
|
-
* #872: `glab api` has no repo/`-R` concept — it accepts only `--hostname`
|
|
219
|
-
* to pin which GitLab instance the request targets. Pinned via
|
|
220
|
-
* `deps.repoHost` when resolved.
|
|
319
|
+
* GitLab's API accepts neither a remote URL nor `-R`; callers provide the host
|
|
320
|
+
* proven by `resolveGitlabProjectTarget` so this helper cannot fall back to
|
|
321
|
+
* ambient `GITLAB_HOST` configuration.
|
|
221
322
|
*
|
|
222
323
|
* @param {string} apiPath
|
|
223
324
|
* @param {string} repoRoot
|
|
224
|
-
* @param {{ execFile?: Function, timeoutMs?: number, repoHost
|
|
325
|
+
* @param {{ execFile?: Function, timeoutMs?: number, repoHost: string }} deps
|
|
326
|
+
* @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
|
|
225
327
|
* @returns {Promise<unknown>}
|
|
226
328
|
*/
|
|
227
|
-
async function glabApi(apiPath, repoRoot, deps = {}) {
|
|
228
|
-
const args = ['api', apiPath];
|
|
229
|
-
if (deps.repoHost) args.push('--hostname', deps.repoHost);
|
|
329
|
+
async function glabApi(apiPath, repoRoot, deps = {}, expect = undefined) {
|
|
330
|
+
const args = ['api', apiPath, '--hostname', deps.repoHost];
|
|
230
331
|
const result = await execWithTimeout(
|
|
231
332
|
'glab',
|
|
232
333
|
args,
|
|
233
334
|
{ cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
|
|
234
335
|
);
|
|
235
|
-
return
|
|
336
|
+
return parseCliJson(result.stdout, `glab api ${apiPath}`, expect);
|
|
236
337
|
}
|
|
237
338
|
|
|
238
339
|
/**
|
|
@@ -244,9 +345,10 @@ async function glabApi(apiPath, repoRoot, deps = {}) {
|
|
|
244
345
|
* @param {string} apiPath
|
|
245
346
|
* @param {string} repoRoot
|
|
246
347
|
* @param {{ execFile?: Function, timeoutMs?: number, repoHost?: string }} deps
|
|
348
|
+
* @param {'object'|'array'} [expect] Required payload shape (see `parseCliJson`)
|
|
247
349
|
* @returns {Promise<unknown>}
|
|
248
350
|
*/
|
|
249
|
-
async function ghApi(apiPath, repoRoot, deps = {}) {
|
|
351
|
+
async function ghApi(apiPath, repoRoot, deps = {}, expect = undefined) {
|
|
250
352
|
const args = ['api', apiPath];
|
|
251
353
|
if (deps.repoHost) args.push('--hostname', deps.repoHost);
|
|
252
354
|
const result = await execWithTimeout(
|
|
@@ -254,7 +356,7 @@ async function ghApi(apiPath, repoRoot, deps = {}) {
|
|
|
254
356
|
args,
|
|
255
357
|
{ cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
|
|
256
358
|
);
|
|
257
|
-
return
|
|
359
|
+
return parseCliJson(result.stdout, `gh api ${apiPath}`, expect);
|
|
258
360
|
}
|
|
259
361
|
|
|
260
362
|
/**
|
|
@@ -276,21 +378,40 @@ function ageDaysFrom(isoDate, now) {
|
|
|
276
378
|
*
|
|
277
379
|
* @param {string} repoRoot
|
|
278
380
|
* @param {number} now
|
|
279
|
-
* @param {{
|
|
381
|
+
* @param {{
|
|
382
|
+
* execFile?: Function,
|
|
383
|
+
* timeoutMs?: number,
|
|
384
|
+
* gitlabProject?: { host: string, encodedProjectPath: string },
|
|
385
|
+
* }} deps
|
|
280
386
|
* @returns {Promise<object|null>}
|
|
281
387
|
*/
|
|
282
388
|
async function checkGitlab(repoRoot, now, deps = {}) {
|
|
283
|
-
const
|
|
284
|
-
|
|
389
|
+
const project = deps.gitlabProject;
|
|
390
|
+
if (
|
|
391
|
+
!project ||
|
|
392
|
+
typeof project.host !== 'string' ||
|
|
393
|
+
typeof project.encodedProjectPath !== 'string' ||
|
|
394
|
+
project.host === '' ||
|
|
395
|
+
project.encodedProjectPath === ''
|
|
396
|
+
) {
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
285
399
|
|
|
400
|
+
const currentSha = await getHeadSha(repoRoot, deps);
|
|
401
|
+
const apiDeps = { ...deps, repoHost: project.host };
|
|
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.
|
|
286
408
|
const pipelines = await glabApi(
|
|
287
|
-
|
|
409
|
+
`${projectPath}/pipelines?order_by=updated_at&sort=desc&per_page=15`,
|
|
288
410
|
repoRoot,
|
|
289
|
-
|
|
411
|
+
apiDeps,
|
|
412
|
+
'array',
|
|
290
413
|
);
|
|
291
414
|
|
|
292
|
-
if (!Array.isArray(pipelines)) return null;
|
|
293
|
-
|
|
294
415
|
const currentPipeline = pipelines.find((p) => p.sha === currentSha);
|
|
295
416
|
|
|
296
417
|
if (!currentPipeline) {
|
|
@@ -316,9 +437,10 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
316
437
|
let allowFailureJobs;
|
|
317
438
|
try {
|
|
318
439
|
const jobs = await glabApi(
|
|
319
|
-
|
|
440
|
+
`${projectPath}/pipelines/${currentPipeline.id}/jobs`,
|
|
320
441
|
repoRoot,
|
|
321
|
-
|
|
442
|
+
apiDeps,
|
|
443
|
+
'array',
|
|
322
444
|
);
|
|
323
445
|
if (Array.isArray(jobs)) {
|
|
324
446
|
const softFailed = jobs
|
|
@@ -383,9 +505,10 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
383
505
|
let failingJobName;
|
|
384
506
|
try {
|
|
385
507
|
const jobs = await glabApi(
|
|
386
|
-
|
|
508
|
+
`${projectPath}/pipelines/${currentPipeline.id}/jobs`,
|
|
387
509
|
repoRoot,
|
|
388
|
-
|
|
510
|
+
apiDeps,
|
|
511
|
+
'array',
|
|
389
512
|
);
|
|
390
513
|
if (Array.isArray(jobs)) {
|
|
391
514
|
const failedJob = jobs.find((j) => j.status === 'failed');
|
|
@@ -433,9 +556,9 @@ async function checkGitlab(repoRoot, now, deps = {}) {
|
|
|
433
556
|
* `[HOST/]OWNER/REPO` shape `resolveRepoSpec({ vcs: 'github' })` returns is
|
|
434
557
|
* exactly the positional's documented input format.
|
|
435
558
|
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
559
|
+
* GitLab CI differs deliberately: it derives a host-pinned API target from
|
|
560
|
+
* the selected remote, while GitHub still needs this lookup because its API
|
|
561
|
+
* path requires `nameWithOwner`. Do not unify these call sites.
|
|
439
562
|
*
|
|
440
563
|
* `nameWithOwner` is NOT derivable from `deps.repoSpec`, so this lookup
|
|
441
564
|
* cannot be dropped: the spec carries a HOST prefix the `repos/<owner>/<repo>`
|
|
@@ -457,12 +580,22 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
457
580
|
repoViewArgs,
|
|
458
581
|
{ cwd: repoRoot, timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS, execFile: deps.execFile },
|
|
459
582
|
);
|
|
460
|
-
|
|
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
|
+
);
|
|
461
593
|
|
|
462
594
|
const data = await ghApi(
|
|
463
595
|
`repos/${nameWithOwner}/commits/HEAD/check-runs`,
|
|
464
596
|
repoRoot,
|
|
465
597
|
deps,
|
|
598
|
+
'object',
|
|
466
599
|
);
|
|
467
600
|
|
|
468
601
|
const checkRuns = data.check_runs;
|
|
@@ -523,12 +656,19 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
523
656
|
* or >= 2 remotes with no preference match) — a benign, measured absence
|
|
524
657
|
* - Required CLI (glab / gh) not in PATH
|
|
525
658
|
* - Any CLI invocation times out
|
|
526
|
-
* - JSON parse failure on CLI output
|
|
527
659
|
*
|
|
528
660
|
* Also returns `null`, but with a `console.warn` trace, when the VCS-detection
|
|
529
|
-
* QUERY ITSELF failed (`git` not on PATH, `git remote -v` erroring)
|
|
530
|
-
* present CLI rejected its invocation
|
|
531
|
-
*
|
|
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.
|
|
532
672
|
*
|
|
533
673
|
* @param {{
|
|
534
674
|
* repoRoot?: string,
|
|
@@ -540,10 +680,11 @@ async function checkGithub(repoRoot, deps = {}) {
|
|
|
540
680
|
* execFile?: Function,
|
|
541
681
|
* resolveRepoSpec?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
|
|
542
682
|
* resolveRepoHost?: (opts: { repoRoot: string, vcs: 'gitlab'|'github' }) => string|undefined,
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
683
|
+
* resolveGitlabProjectTarget?: (opts: { repoRoot: string }) =>
|
|
684
|
+
* { host: string, encodedProjectPath: string }|undefined,
|
|
685
|
+
* }} deps Dependency-injection seam for testing. GitLab defaults to
|
|
686
|
+
* `resolveGitlabProjectTarget`, which proves host and project path from one
|
|
687
|
+
* sanitized remote; GitHub retains the #872 spec/host resolvers.
|
|
547
688
|
* @returns {Promise<null | {
|
|
548
689
|
* status: 'green'|'red'|'unknown',
|
|
549
690
|
* ok: boolean,
|
|
@@ -572,6 +713,7 @@ export async function checkCiStatus(opts = {}, deps = {}) {
|
|
|
572
713
|
|
|
573
714
|
const resolveRepoSpecDep = deps.resolveRepoSpec ?? resolveRepoSpec;
|
|
574
715
|
const resolveRepoHostDep = deps.resolveRepoHost ?? resolveRepoHost;
|
|
716
|
+
const resolveGitlabProjectTargetDep = deps.resolveGitlabProjectTarget ?? resolveGitlabProjectTarget;
|
|
575
717
|
|
|
576
718
|
const depsWithExec = { execFile: execFileDep, timeoutMs };
|
|
577
719
|
|
|
@@ -605,22 +747,33 @@ export async function checkCiStatus(opts = {}, deps = {}) {
|
|
|
605
747
|
vcs = detected.vcs;
|
|
606
748
|
}
|
|
607
749
|
|
|
608
|
-
// Step 1b
|
|
609
|
-
//
|
|
610
|
-
// GITLAB_HOST/
|
|
611
|
-
//
|
|
612
|
-
//
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
//
|
|
750
|
+
// Step 1b: GitLab's API target must be proven before its first glab spawn.
|
|
751
|
+
// A missing target is never permission to fall back to ambient
|
|
752
|
+
// GITLAB_HOST/repository configuration. GitHub retains its distinct
|
|
753
|
+
// repository lookup because its API path requires `nameWithOwner`.
|
|
754
|
+
//
|
|
755
|
+
// It is also not an ABSENCE: detectVcs just proved a GitLab remote exists,
|
|
756
|
+
// so `!gitlabProject` means "remote present, its form was rejected" — a
|
|
757
|
+
// QUERY FAILURE by the same rule the block above states. Returning null
|
|
758
|
+
// silently put this repo back in the pre-#1039 state where "CI green" and
|
|
759
|
+
// "could not ask" look identical. Reachable inputs measured 2026-08-21:
|
|
760
|
+
// `git://` scheme, a doubled slash in the path, a query string.
|
|
618
761
|
if (vcs === 'gitlab') {
|
|
619
|
-
|
|
762
|
+
const gitlabProject = resolveGitlabProjectTargetDep({ repoRoot });
|
|
763
|
+
if (!gitlabProject) {
|
|
764
|
+
console.warn(
|
|
765
|
+
'WARN ci-status-banner: a GitLab remote was detected but its host/project path ' +
|
|
766
|
+
'could not be derived, banner suppressed — CI state is UNKNOWN, not "green".',
|
|
767
|
+
);
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
return await checkGitlab(repoRoot, now, { ...depsWithExec, gitlabProject });
|
|
620
771
|
}
|
|
621
772
|
|
|
622
773
|
if (vcs === 'github') {
|
|
623
|
-
|
|
774
|
+
const repoSpec = resolveRepoSpecDep({ repoRoot, vcs });
|
|
775
|
+
const repoHost = resolveRepoHostDep({ repoRoot, vcs });
|
|
776
|
+
return await checkGithub(repoRoot, { ...depsWithExec, repoSpec, repoHost });
|
|
624
777
|
}
|
|
625
778
|
|
|
626
779
|
// Unknown VCS value — silent no-op.
|
|
@@ -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
|
|
@@ -43,13 +43,36 @@ import { hasBlockHeader } from './block-header.mjs';
|
|
|
43
43
|
const ALLOWED_AUTONOMY = ['off', 'advisory', 'autonomous-gated'];
|
|
44
44
|
const DEFAULT_CONFIDENCE_FLOOR = 0.5;
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The display-only tail of an option LABEL, stripped before the enum match.
|
|
48
|
+
*
|
|
49
|
+
* The label and the stored value were the SAME string, which made the label
|
|
50
|
+
* unchangeable: both consumers (`skills/session-start/SKILL.md` Phase 1.1 and
|
|
51
|
+
* `skills/bootstrap/SKILL.md` Phase 3.5.1) hand the SELECTED LABEL straight to
|
|
52
|
+
* writeDispatcherAutonomyBlock(), and coerceAutonomy() falls back to 'off' on
|
|
53
|
+
* any value outside the enum. Putting AUQ-003's `(Recommended)` marker into the
|
|
54
|
+
* label would therefore have written `autonomy: off` for an operator who picked
|
|
55
|
+
* `advisory` — a silent downgrade, fail-closed and therefore invisible.
|
|
56
|
+
*
|
|
57
|
+
* Measured before this split (renderDispatcherAutonomyBlock, 2026-08-22):
|
|
58
|
+
* 'advisory' => autonomy: advisory
|
|
59
|
+
* 'Advisory (surface only)' => autonomy: off ← the downgrade
|
|
60
|
+
*
|
|
61
|
+
* Anchored at the end, so it can only ever strip a suffix. The enum values
|
|
62
|
+
* themselves are untouched and stay greppable.
|
|
63
|
+
*/
|
|
64
|
+
const RECOMMENDED_SUFFIX = /\s*\((?:Recommended|Empfohlen|Default)\)\s*$/u;
|
|
65
|
+
|
|
46
66
|
/**
|
|
47
67
|
* Coordinator-facing AUQ question definition for the one-time capture.
|
|
48
68
|
* Mirrors the shape of `getInterviewQuestions()` entries in owner-interview.mjs:
|
|
49
69
|
* { question, header, options: [{ label, description }], multiSelect }
|
|
50
70
|
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
71
|
+
* Each label BEGINS with its ALLOWED_AUTONOMY value; option 1 additionally
|
|
72
|
+
* carries the `(Recommended)` marker AUQ-003 puts in the label. `coerceAutonomy()`
|
|
73
|
+
* strips that marker, so the selected label still maps to the enum value written
|
|
74
|
+
* by writeDispatcherAutonomyBlock() — see the comment on RECOMMENDED_SUFFIX for
|
|
75
|
+
* why the two halves must not be the same string.
|
|
53
76
|
* Option 1 (`off`) is the recommended, fail-closed default.
|
|
54
77
|
*
|
|
55
78
|
* @returns {{ question: string, header: string, options: Array<{ label: string, description: string }>, multiSelect: boolean }}
|
|
@@ -57,23 +80,23 @@ const DEFAULT_CONFIDENCE_FLOOR = 0.5;
|
|
|
57
80
|
export function getDispatcherAutonomyQuestion() {
|
|
58
81
|
return {
|
|
59
82
|
question:
|
|
60
|
-
'
|
|
61
|
-
header: 'Dispatcher
|
|
83
|
+
'When /dispatcher looks across your repos for the next one to work on, how may it treat this repo?',
|
|
84
|
+
header: 'Dispatcher',
|
|
62
85
|
options: [
|
|
63
86
|
{
|
|
64
|
-
label: 'off',
|
|
87
|
+
label: 'off (Recommended)',
|
|
65
88
|
description:
|
|
66
|
-
'
|
|
89
|
+
'Nothing changes: this repo is never offered and never started for you. Safe default — you keep picking the work.',
|
|
67
90
|
},
|
|
68
91
|
{
|
|
69
92
|
label: 'advisory',
|
|
70
93
|
description:
|
|
71
|
-
'
|
|
94
|
+
'This repo may appear in the ranked suggestions, so you can compare it against the others. You still start each session.',
|
|
72
95
|
},
|
|
73
96
|
{
|
|
74
97
|
label: 'autonomous-gated',
|
|
75
98
|
description:
|
|
76
|
-
'
|
|
99
|
+
'Work may start here without asking, but only when the ranking beats the confidence floor written next to this setting.',
|
|
77
100
|
},
|
|
78
101
|
],
|
|
79
102
|
multiSelect: false,
|
|
@@ -128,7 +151,7 @@ export function isDispatcherAutonomyBlockPresent(claudeMdContent) {
|
|
|
128
151
|
*/
|
|
129
152
|
function coerceAutonomy(value) {
|
|
130
153
|
if (value === null || value === undefined) return 'off';
|
|
131
|
-
const normalized = String(value).toLowerCase().trim();
|
|
154
|
+
const normalized = String(value).toLowerCase().replace(RECOMMENDED_SUFFIX, '').trim();
|
|
132
155
|
return ALLOWED_AUTONOMY.includes(normalized) ? normalized : 'off';
|
|
133
156
|
}
|
|
134
157
|
|