session-orchestrator 3.22.0 → 3.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* generate-cursor-adapter.mjs — Cursor-native command + skill wrappers.
|
|
4
|
+
*
|
|
5
|
+
* Cursor loads slash commands from `.cursor/commands/*.md` and skills from
|
|
6
|
+
* `.cursor/skills/<name>/SKILL.md`. The canonical sources stay `commands/` and
|
|
7
|
+
* `skills/`; these wrappers keep one maintained body while giving Cursor a
|
|
8
|
+
* native entry per command/skill (same pattern as `generate-pi-prompts.mjs`).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node scripts/generate-cursor-adapter.mjs
|
|
12
|
+
* node scripts/generate-cursor-adapter.mjs --check
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const SCRIPT_DIR = path.dirname(__filename);
|
|
21
|
+
const ROOT = path.dirname(SCRIPT_DIR);
|
|
22
|
+
const COMMANDS_DIR = path.join(ROOT, 'commands');
|
|
23
|
+
const SKILLS_DIR = path.join(ROOT, 'skills');
|
|
24
|
+
const CURSOR_COMMANDS_DIR = path.join(ROOT, '.cursor', 'commands');
|
|
25
|
+
const CURSOR_SKILLS_DIR = path.join(ROOT, '.cursor', 'skills');
|
|
26
|
+
const CHECK_ONLY = process.argv.includes('--check');
|
|
27
|
+
const DESCRIPTION_MAX = 1024;
|
|
28
|
+
|
|
29
|
+
function isDir(p) {
|
|
30
|
+
try { return statSync(p).isDirectory(); } catch { return false; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function commandFiles() {
|
|
34
|
+
return readdirSync(COMMANDS_DIR)
|
|
35
|
+
.filter((name) => name.endsWith('.md'))
|
|
36
|
+
.sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function skillDirs() {
|
|
40
|
+
return readdirSync(SKILLS_DIR)
|
|
41
|
+
.filter((name) => isDir(path.join(SKILLS_DIR, name)) && existsSync(path.join(SKILLS_DIR, name, 'SKILL.md')))
|
|
42
|
+
.sort();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse YAML-ish frontmatter including `>` / `|` folded scalars.
|
|
47
|
+
* @param {string} content
|
|
48
|
+
* @returns {Record<string, string>}
|
|
49
|
+
*/
|
|
50
|
+
function parseFrontmatter(content) {
|
|
51
|
+
if (!content.startsWith('---\n')) return {};
|
|
52
|
+
const end = content.indexOf('\n---\n', 4);
|
|
53
|
+
if (end === -1) return {};
|
|
54
|
+
|
|
55
|
+
const lines = content.slice(4, end).split('\n');
|
|
56
|
+
const fields = {};
|
|
57
|
+
let i = 0;
|
|
58
|
+
while (i < lines.length) {
|
|
59
|
+
const match = lines[i].match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
60
|
+
if (!match) {
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const key = match[1];
|
|
65
|
+
const raw = match[2];
|
|
66
|
+
if (raw === '>' || raw === '| ' || raw === '|' || raw === '>-' || raw === '|-') {
|
|
67
|
+
const folded = [];
|
|
68
|
+
i += 1;
|
|
69
|
+
while (i < lines.length && (lines[i].startsWith(' ') || lines[i].startsWith('\t') || lines[i] === '')) {
|
|
70
|
+
folded.push(lines[i].replace(/^\s+/, ''));
|
|
71
|
+
i += 1;
|
|
72
|
+
}
|
|
73
|
+
const joiner = raw.startsWith('|') ? '\n' : ' ';
|
|
74
|
+
fields[key] = folded.filter(Boolean).join(joiner).trim();
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
fields[key] = raw.replace(/^["']|["']$/g, '');
|
|
78
|
+
i += 1;
|
|
79
|
+
}
|
|
80
|
+
return fields;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function yamlQuote(value) {
|
|
84
|
+
if (value === undefined || value === '') return null;
|
|
85
|
+
if (/[:#[\]{}&*!|>'"%@`]/.test(value) || value.includes('\n')) {
|
|
86
|
+
return JSON.stringify(value);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function frontmatterLine(key, value) {
|
|
92
|
+
if (value === undefined || value === '' || value === null) return null;
|
|
93
|
+
return `${key}: ${value}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function clampDescription(text) {
|
|
97
|
+
const collapsed = String(text || '').replace(/\s+/g, ' ').trim();
|
|
98
|
+
if (collapsed.length <= DESCRIPTION_MAX) return collapsed;
|
|
99
|
+
return collapsed.slice(0, DESCRIPTION_MAX - 1).trimEnd() + '…';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderCommand(commandFile) {
|
|
103
|
+
const commandPath = path.join(COMMANDS_DIR, commandFile);
|
|
104
|
+
const commandName = commandFile.replace(/\.md$/, '');
|
|
105
|
+
const fields = parseFrontmatter(readFileSync(commandPath, 'utf8'));
|
|
106
|
+
const frontmatter = [
|
|
107
|
+
'---',
|
|
108
|
+
frontmatterLine('description', yamlQuote(fields.description) ?? fields.description),
|
|
109
|
+
frontmatterLine('argument-hint', fields['argument-hint']),
|
|
110
|
+
'---',
|
|
111
|
+
].filter(Boolean).join('\n');
|
|
112
|
+
|
|
113
|
+
return `${frontmatter}
|
|
114
|
+
|
|
115
|
+
# /${commandName}
|
|
116
|
+
|
|
117
|
+
Use the Session Orchestrator command definition at \`commands/${commandFile}\`.
|
|
118
|
+
|
|
119
|
+
Arguments: $ARGUMENTS
|
|
120
|
+
|
|
121
|
+
Read that command file and follow it exactly. When it references \`$ARGUMENTS\`, substitute the arguments above. Keep all Session Orchestrator platform fallbacks intact.
|
|
122
|
+
|
|
123
|
+
Cursor has no Skill tool. When the command says to invoke a skill, Read \`skills/<skill-name>/SKILL.md\` and follow it. Supporting files (\`soul.md\`, phase docs) live in that same \`skills/<skill-name>/\` directory.
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isUserInvocable(value) {
|
|
128
|
+
return value === 'true' || value === true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function renderSkill(skillName) {
|
|
132
|
+
const skillPath = path.join(SKILLS_DIR, skillName, 'SKILL.md');
|
|
133
|
+
const fields = parseFrontmatter(readFileSync(skillPath, 'utf8'));
|
|
134
|
+
const description = clampDescription(fields.description || `Session Orchestrator skill: ${skillName}`);
|
|
135
|
+
const lines = [
|
|
136
|
+
'---',
|
|
137
|
+
`name: ${skillName}`,
|
|
138
|
+
`description: ${yamlQuote(description)}`,
|
|
139
|
+
];
|
|
140
|
+
if (!isUserInvocable(fields['user-invocable'])) {
|
|
141
|
+
lines.push('disable-model-invocation: true');
|
|
142
|
+
}
|
|
143
|
+
lines.push('---');
|
|
144
|
+
|
|
145
|
+
return `${lines.join('\n')}
|
|
146
|
+
|
|
147
|
+
# ${skillName}
|
|
148
|
+
|
|
149
|
+
Canonical skill: \`skills/${skillName}/SKILL.md\`
|
|
150
|
+
|
|
151
|
+
Read that file and follow it exactly. Resolve relative links against \`skills/${skillName}/\`, not this wrapper.
|
|
152
|
+
|
|
153
|
+
Cursor has no Skill tool. Treat "invoke the ${skillName} skill" as: Read \`skills/${skillName}/SKILL.md\`.
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function expectedCommands() {
|
|
158
|
+
const commands = new Map();
|
|
159
|
+
for (const commandFile of commandFiles()) {
|
|
160
|
+
commands.set(commandFile, renderCommand(commandFile));
|
|
161
|
+
}
|
|
162
|
+
return commands;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function expectedSkills() {
|
|
166
|
+
const skills = new Map();
|
|
167
|
+
for (const skillName of skillDirs()) {
|
|
168
|
+
skills.set(skillName, renderSkill(skillName));
|
|
169
|
+
}
|
|
170
|
+
return skills;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function checkMap(expected, dir, kind, fileNameOf) {
|
|
174
|
+
const failures = [];
|
|
175
|
+
for (const [name, content] of expected) {
|
|
176
|
+
const filePath = path.join(dir, fileNameOf(name));
|
|
177
|
+
if (!existsSync(filePath)) {
|
|
178
|
+
failures.push(`${kind} ${name}: missing`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const actual = readFileSync(filePath, 'utf8');
|
|
182
|
+
if (actual !== content) failures.push(`${kind} ${name}: stale`);
|
|
183
|
+
}
|
|
184
|
+
return failures;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function listGeneratedMarkdown(dir, recursiveDirs = false) {
|
|
188
|
+
if (!existsSync(dir)) return [];
|
|
189
|
+
if (!recursiveDirs) {
|
|
190
|
+
return readdirSync(dir).filter((name) => name.endsWith('.md'));
|
|
191
|
+
}
|
|
192
|
+
return readdirSync(dir).filter((name) => isDir(path.join(dir, name)) && existsSync(path.join(dir, name, 'SKILL.md')));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function checkAll(expectedCmds, expectedSkillsMap) {
|
|
196
|
+
const failures = [
|
|
197
|
+
...checkMap(expectedCmds, CURSOR_COMMANDS_DIR, 'command', (name) => name),
|
|
198
|
+
...checkMap(expectedSkillsMap, CURSOR_SKILLS_DIR, 'skill', (name) => path.join(name, 'SKILL.md')),
|
|
199
|
+
];
|
|
200
|
+
|
|
201
|
+
const expectedCommandNames = new Set(expectedCmds.keys());
|
|
202
|
+
for (const name of listGeneratedMarkdown(CURSOR_COMMANDS_DIR)) {
|
|
203
|
+
if (!expectedCommandNames.has(name)) failures.push(`command ${name}: orphan`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const expectedSkillNames = new Set(expectedSkillsMap.keys());
|
|
207
|
+
for (const name of listGeneratedMarkdown(CURSOR_SKILLS_DIR, true)) {
|
|
208
|
+
if (!expectedSkillNames.has(name)) failures.push(`skill ${name}: orphan`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (failures.length > 0) {
|
|
212
|
+
for (const failure of failures) {
|
|
213
|
+
process.stderr.write(`FAIL: ${failure}\n`);
|
|
214
|
+
}
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
process.stdout.write(`cursor adapter: ${expectedCmds.size} command(s), ${expectedSkillsMap.size} skill(s) up to date\n`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function writeAll(expectedCmds, expectedSkillsMap) {
|
|
222
|
+
mkdirSync(CURSOR_COMMANDS_DIR, { recursive: true });
|
|
223
|
+
if (existsSync(CURSOR_COMMANDS_DIR)) {
|
|
224
|
+
for (const name of readdirSync(CURSOR_COMMANDS_DIR)) {
|
|
225
|
+
if (name.endsWith('.md') && !expectedCmds.has(name)) {
|
|
226
|
+
rmSync(path.join(CURSOR_COMMANDS_DIR, name));
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for (const [name, content] of expectedCmds) {
|
|
231
|
+
writeFileSync(path.join(CURSOR_COMMANDS_DIR, name), content, 'utf8');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
mkdirSync(CURSOR_SKILLS_DIR, { recursive: true });
|
|
235
|
+
for (const name of existsSync(CURSOR_SKILLS_DIR) ? readdirSync(CURSOR_SKILLS_DIR) : []) {
|
|
236
|
+
const skillDir = path.join(CURSOR_SKILLS_DIR, name);
|
|
237
|
+
if (isDir(skillDir) && !expectedSkillsMap.has(name)) {
|
|
238
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
for (const [name, content] of expectedSkillsMap) {
|
|
242
|
+
const skillDir = path.join(CURSOR_SKILLS_DIR, name);
|
|
243
|
+
mkdirSync(skillDir, { recursive: true });
|
|
244
|
+
writeFileSync(path.join(skillDir, 'SKILL.md'), content, 'utf8');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
process.stdout.write(`cursor adapter: wrote ${expectedCmds.size} command(s), ${expectedSkillsMap.size} skill(s)\n`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const expectedCmds = expectedCommands();
|
|
251
|
+
const expectedSkillsMap = expectedSkills();
|
|
252
|
+
if (CHECK_ONLY) checkAll(expectedCmds, expectedSkillsMap);
|
|
253
|
+
else writeAll(expectedCmds, expectedSkillsMap);
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/github-protection-audit.mjs — read-only GitHub-mirror branch
|
|
4
|
+
* protection audit (GitLab issue #1079).
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS: `.claude/rules/security.md` § "Session Config Command
|
|
7
|
+
* Trust" argues that commit-gated files are trustworthy BECAUSE every change
|
|
8
|
+
* lands in `git log` and passes review — but that argument only holds when
|
|
9
|
+
* the DEPLOYING path is the SAME as the REVIEWING path. This repo's GitHub
|
|
10
|
+
* mirror breaks that: `origin` (GitLab) is reviewed via MR, `github` is
|
|
11
|
+
* pushed directly by `skills/session-end/SKILL.md`'s mirror-push block
|
|
12
|
+
* (`git push github HEAD`, no MR, no review) — and since a Vercel Git
|
|
13
|
+
* integration deploys on every push to that mirror's `main`, a compromised
|
|
14
|
+
* mirror token or an admin bypass IS a deploy with zero review. See
|
|
15
|
+
* `docs/github-mirror-protection.md` for the full runbook.
|
|
16
|
+
*
|
|
17
|
+
* THIS SCRIPT NEVER WRITES. It only reads `gh api`/`gh auth status`/
|
|
18
|
+
* `gh repo view` output and reports findings — no `-X PUT/PATCH/DELETE`, no
|
|
19
|
+
* token mutation, ever. Flipping `enforce_admins` or narrowing the token is
|
|
20
|
+
* an OPERATOR action documented in the runbook, not something this script
|
|
21
|
+
* performs.
|
|
22
|
+
*
|
|
23
|
+
* Three-state contract (mirrors `scripts/lib/mirror-issues-banner.mjs`):
|
|
24
|
+
* - success → the full envelope (see {@link auditGithubBranchProtection}'s
|
|
25
|
+
* doc), always including a `findings` array (possibly empty).
|
|
26
|
+
* - degraded → `{ degraded: '<reason>' }`. `gh` missing, not authenticated,
|
|
27
|
+
* a timed-out invocation, or any other query failure. NEVER treat this as
|
|
28
|
+
* "no findings" — it means "could not measure", not "measured, clean".
|
|
29
|
+
*
|
|
30
|
+
* CLI: prints exactly ONE JSON object on stdout. Exit 0 on a successful
|
|
31
|
+
* measurement (regardless of how many findings it carries — this is a
|
|
32
|
+
* report, not a gate), exit 2 on `degraded`.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { execFile as _execFile } from 'node:child_process';
|
|
36
|
+
import { promisify } from 'node:util';
|
|
37
|
+
import { pathToFileURL } from 'node:url';
|
|
38
|
+
import { resolveRepoSpec as _resolveRepoSpec, resolveRepoHost as _resolveRepoHost } from './lib/vcs-repo-spec.mjs';
|
|
39
|
+
|
|
40
|
+
const execFileAsync = promisify(_execFile);
|
|
41
|
+
|
|
42
|
+
/** Default timeout in ms for a single `gh` invocation. Mirrors `mirror-issues-banner.mjs`. */
|
|
43
|
+
export const DEFAULT_TIMEOUT_MS = 8000;
|
|
44
|
+
|
|
45
|
+
/** The VCS family this audit always queries — the mirror, never the auto-detected platform. */
|
|
46
|
+
const AUDIT_VCS = 'github';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Closed set of `degraded` reasons. Same shape as
|
|
50
|
+
* `mirror-issues-banner.mjs`'s `DEGRADED_REASONS`: a degraded result means
|
|
51
|
+
* "the mirror was NOT successfully measured" — never "the mirror is clean".
|
|
52
|
+
*
|
|
53
|
+
* @type {readonly ['no-github-remote','cli-missing','timeout','auth-error','parse-error','query-failed']}
|
|
54
|
+
*/
|
|
55
|
+
export const DEGRADED_REASONS = Object.freeze([
|
|
56
|
+
'no-github-remote',
|
|
57
|
+
'cli-missing',
|
|
58
|
+
'timeout',
|
|
59
|
+
'auth-error',
|
|
60
|
+
'parse-error',
|
|
61
|
+
'query-failed',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Classic PAT scopes broader than the fine-grained `contents:write` this
|
|
66
|
+
* repo's mirror push actually needs. `repo` is the specific scope GitLab
|
|
67
|
+
* issue #1079 names ("org-weit und deutlich mehr als der Deploy braucht");
|
|
68
|
+
* the rest are equally-or-more privileged siblings a rotated token could
|
|
69
|
+
* plausibly carry. A classic token has no scope that means EXACTLY
|
|
70
|
+
* `contents:write` — narrowing further requires a fine-grained PAT (see the
|
|
71
|
+
* runbook), so this list is deliberately conservative: it flags scopes that
|
|
72
|
+
* are unambiguously broader, not "any classic scope at all".
|
|
73
|
+
*
|
|
74
|
+
* @type {readonly string[]}
|
|
75
|
+
*/
|
|
76
|
+
export const UNSAFE_TOKEN_SCOPES = Object.freeze([
|
|
77
|
+
'repo',
|
|
78
|
+
'admin:org',
|
|
79
|
+
'admin:repo_hook',
|
|
80
|
+
'admin:public_key',
|
|
81
|
+
'admin:enterprise',
|
|
82
|
+
'delete_repo',
|
|
83
|
+
'site_admin',
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Run `cmd` with a timeout race. Mirrors `mirror-issues-banner.mjs:80-108`.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} cmd
|
|
90
|
+
* @param {string[]} args
|
|
91
|
+
* @param {{ cwd?: string, timeoutMs?: number, execFile?: Function }} [opts]
|
|
92
|
+
* @returns {Promise<{ stdout?: string, stderr?: string }>}
|
|
93
|
+
*/
|
|
94
|
+
async function execWithTimeout(cmd, args, opts = {}) {
|
|
95
|
+
const { cwd, timeoutMs = DEFAULT_TIMEOUT_MS, execFile = execFileAsync } = opts;
|
|
96
|
+
/** @type {NodeJS.Timeout | undefined} */
|
|
97
|
+
let timer;
|
|
98
|
+
try {
|
|
99
|
+
return await Promise.race([
|
|
100
|
+
execFile(cmd, args, { cwd, env: process.env }),
|
|
101
|
+
new Promise((_, reject) => {
|
|
102
|
+
timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
|
|
103
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
104
|
+
}),
|
|
105
|
+
]);
|
|
106
|
+
} finally {
|
|
107
|
+
if (timer) clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Map a failed `gh` invocation onto a {@link DEGRADED_REASONS} member.
|
|
113
|
+
* Mirrors `mirror-issues-banner.mjs`'s `classifyFailure`.
|
|
114
|
+
*
|
|
115
|
+
* @param {unknown} err
|
|
116
|
+
* @returns {'cli-missing'|'timeout'|'auth-error'|'query-failed'}
|
|
117
|
+
*/
|
|
118
|
+
function classifyFailure(err) {
|
|
119
|
+
const code = err && typeof err === 'object' ? /** @type {any} */ (err).code : undefined;
|
|
120
|
+
if (code === 'ENOENT') return 'cli-missing';
|
|
121
|
+
|
|
122
|
+
const message = err instanceof Error ? err.message : String(err ?? '');
|
|
123
|
+
if (message === 'timeout') return 'timeout';
|
|
124
|
+
|
|
125
|
+
const stderr = err && typeof err === 'object' ? String(/** @type {any} */ (err).stderr ?? '') : '';
|
|
126
|
+
const haystack = `${message}\n${stderr}`.toLowerCase();
|
|
127
|
+
if (
|
|
128
|
+
haystack.includes('gh auth login') ||
|
|
129
|
+
haystack.includes('not logged in') ||
|
|
130
|
+
haystack.includes('authentication') ||
|
|
131
|
+
haystack.includes('http 401') ||
|
|
132
|
+
haystack.includes('http 403')
|
|
133
|
+
) {
|
|
134
|
+
return 'auth-error';
|
|
135
|
+
}
|
|
136
|
+
return 'query-failed';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `true` when a failed `gh api .../protection` call means "this branch has
|
|
141
|
+
* no protection configured at all" (HTTP 404) rather than a real query
|
|
142
|
+
* failure. GitHub's API returns 404 (not an empty protection object) for an
|
|
143
|
+
* unprotected branch, so this must be distinguished from `classifyFailure`'s
|
|
144
|
+
* generic `query-failed` — it is a measured, reportable state, not a
|
|
145
|
+
* degraded one.
|
|
146
|
+
*
|
|
147
|
+
* @param {unknown} err
|
|
148
|
+
* @returns {boolean}
|
|
149
|
+
*/
|
|
150
|
+
function isUnprotectedBranch404(err) {
|
|
151
|
+
const message = err instanceof Error ? err.message : String(err ?? '');
|
|
152
|
+
const stderr = err && typeof err === 'object' ? String(/** @type {any} */ (err).stderr ?? '') : '';
|
|
153
|
+
const haystack = `${message}\n${stderr}`.toLowerCase();
|
|
154
|
+
return haystack.includes('http 404') || haystack.includes('branch not protected');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parse `gh auth status` combined stdout+stderr for the `Token scopes:` line.
|
|
159
|
+
* gh has changed which stream this prints to across versions (measured
|
|
160
|
+
* 2026-08-28 @ this repo: stdout), so both are scanned defensively.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} text
|
|
163
|
+
* @returns {string[]}
|
|
164
|
+
*/
|
|
165
|
+
export function parseTokenScopes(text) {
|
|
166
|
+
const match = /token scopes:\s*(.+)/i.exec(text);
|
|
167
|
+
if (!match) return [];
|
|
168
|
+
return match[1]
|
|
169
|
+
.split(',')
|
|
170
|
+
.map((s) => s.trim().replace(/^'|'$/g, ''))
|
|
171
|
+
.filter((s) => s.length > 0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Build a degraded result. Distinct from a success envelope on purpose —
|
|
176
|
+
* `degraded` must never be read as "clean".
|
|
177
|
+
*
|
|
178
|
+
* @param {string} reason
|
|
179
|
+
* @param {string} [detail]
|
|
180
|
+
* @returns {{ degraded: string, message: string }}
|
|
181
|
+
*/
|
|
182
|
+
function degradedResult(reason, detail) {
|
|
183
|
+
return {
|
|
184
|
+
degraded: reason,
|
|
185
|
+
message: `github-protection-audit: could not measure branch protection (${reason})` + (detail ? ` — ${detail}` : ''),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Audit the GitHub mirror's default-branch protection, read-only.
|
|
191
|
+
*
|
|
192
|
+
* Never mutates anything — every call is a `gh api`/`gh repo view`/
|
|
193
|
+
* `gh auth status` READ. Never throws; every failure path returns the
|
|
194
|
+
* degraded shape below.
|
|
195
|
+
*
|
|
196
|
+
* @param {{ repoRoot?: string, timeoutMs?: number }} [opts]
|
|
197
|
+
* @param {{ execFile?: Function, resolveRepoSpec?: Function, resolveRepoHost?: Function }} [deps] DI for tests only.
|
|
198
|
+
* @returns {Promise<
|
|
199
|
+
* | { degraded: string, message: string }
|
|
200
|
+
* | {
|
|
201
|
+
* repo: string,
|
|
202
|
+
* branch: string,
|
|
203
|
+
* enforce_admins: boolean,
|
|
204
|
+
* required_status_checks: { strict: boolean, contexts: string[] } | null,
|
|
205
|
+
* required_pull_request_reviews: boolean,
|
|
206
|
+
* allow_force_pushes: boolean,
|
|
207
|
+
* token_scopes: string[],
|
|
208
|
+
* findings: Array<{ id: string, severity: string, message: string }>,
|
|
209
|
+
* }
|
|
210
|
+
* >}
|
|
211
|
+
*/
|
|
212
|
+
export async function auditGithubBranchProtection(opts = {}, deps = {}) {
|
|
213
|
+
const { repoRoot = process.cwd(), timeoutMs = DEFAULT_TIMEOUT_MS } = opts ?? {};
|
|
214
|
+
const {
|
|
215
|
+
execFile = execFileAsync,
|
|
216
|
+
resolveRepoSpec = _resolveRepoSpec,
|
|
217
|
+
resolveRepoHost = _resolveRepoHost,
|
|
218
|
+
} = deps ?? {};
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
// Step 1 — resolve the mirror spec/host from `git remote`. No remote, no
|
|
222
|
+
// spawn: this is the self-disabling path shared with `mirror-issues-banner.mjs`.
|
|
223
|
+
let repoSpec;
|
|
224
|
+
let repoHost;
|
|
225
|
+
try {
|
|
226
|
+
repoSpec = resolveRepoSpec({ repoRoot, vcs: AUDIT_VCS });
|
|
227
|
+
repoHost = resolveRepoHost({ repoRoot, vcs: AUDIT_VCS });
|
|
228
|
+
} catch {
|
|
229
|
+
return degradedResult('no-github-remote');
|
|
230
|
+
}
|
|
231
|
+
if (!repoSpec || typeof repoSpec !== 'string') return degradedResult('no-github-remote');
|
|
232
|
+
|
|
233
|
+
const execDeps = { cwd: repoRoot, timeoutMs, execFile };
|
|
234
|
+
|
|
235
|
+
// Step 2 — resolve owner/repo + default branch.
|
|
236
|
+
let nameWithOwner;
|
|
237
|
+
let branch;
|
|
238
|
+
try {
|
|
239
|
+
const result = await execWithTimeout(
|
|
240
|
+
'gh',
|
|
241
|
+
['repo', 'view', repoSpec, '--json', 'nameWithOwner,defaultBranchRef'],
|
|
242
|
+
execDeps,
|
|
243
|
+
);
|
|
244
|
+
const parsed = JSON.parse(String(result?.stdout ?? ''));
|
|
245
|
+
nameWithOwner = parsed?.nameWithOwner;
|
|
246
|
+
branch = parsed?.defaultBranchRef?.name;
|
|
247
|
+
if (typeof nameWithOwner !== 'string' || typeof branch !== 'string' || !nameWithOwner || !branch) {
|
|
248
|
+
return degradedResult('parse-error', 'gh repo view returned an unusable shape');
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
return degradedResult(classifyFailure(err));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Step 3 — token scopes via `gh auth status`.
|
|
255
|
+
let tokenScopes;
|
|
256
|
+
try {
|
|
257
|
+
const authArgs = repoHost ? ['auth', 'status', '--hostname', repoHost] : ['auth', 'status'];
|
|
258
|
+
const result = await execWithTimeout('gh', authArgs, execDeps);
|
|
259
|
+
const combined = `${String(result?.stdout ?? '')}\n${String(result?.stderr ?? '')}`;
|
|
260
|
+
tokenScopes = parseTokenScopes(combined);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
return degradedResult(classifyFailure(err));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Step 4 — branch protection itself. A 404 means "not protected at all",
|
|
266
|
+
// a measured (not degraded) state — everything else is a real query failure.
|
|
267
|
+
let protection = null;
|
|
268
|
+
try {
|
|
269
|
+
const apiArgs = ['api', `repos/${nameWithOwner}/branches/${branch}/protection`];
|
|
270
|
+
if (repoHost) apiArgs.push('--hostname', repoHost);
|
|
271
|
+
const result = await execWithTimeout('gh', apiArgs, execDeps);
|
|
272
|
+
protection = JSON.parse(String(result?.stdout ?? ''));
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (!isUnprotectedBranch404(err)) return degradedResult(classifyFailure(err));
|
|
275
|
+
protection = null; // unprotected — fall through with defaults below
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const enforceAdmins = protection?.enforce_admins?.enabled === true;
|
|
279
|
+
const rawStatusChecks = protection?.required_status_checks ?? null;
|
|
280
|
+
const requiredStatusChecks = rawStatusChecks
|
|
281
|
+
? {
|
|
282
|
+
strict: rawStatusChecks.strict === true,
|
|
283
|
+
contexts: Array.isArray(rawStatusChecks.contexts) ? rawStatusChecks.contexts.map(String) : [],
|
|
284
|
+
}
|
|
285
|
+
: null;
|
|
286
|
+
// `required_pull_request_reviews` is OMITTED from the API response
|
|
287
|
+
// entirely when no review requirement is configured (measured live,
|
|
288
|
+
// 2026-08-28) — it is never `{ enabled: false }`. So presence-of-key,
|
|
289
|
+
// not a nested `.enabled` read, is the correct boolean derivation.
|
|
290
|
+
const requiredPullRequestReviews = protection !== null && 'required_pull_request_reviews' in protection;
|
|
291
|
+
const allowForcePushes = protection === null ? true : protection?.allow_force_pushes?.enabled === true;
|
|
292
|
+
|
|
293
|
+
/** @type {Array<{ id: string, severity: string, message: string }>} */
|
|
294
|
+
const findings = [];
|
|
295
|
+
|
|
296
|
+
if (protection === null) {
|
|
297
|
+
findings.push({
|
|
298
|
+
id: 'branch-not-protected',
|
|
299
|
+
severity: 'critical',
|
|
300
|
+
message: `${nameWithOwner}@${branch} has NO branch protection configured — any push (including a compromised mirror token) lands directly, unreviewed.`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!enforceAdmins) {
|
|
305
|
+
findings.push({
|
|
306
|
+
id: 'enforce-admins-disabled',
|
|
307
|
+
severity: 'high',
|
|
308
|
+
message:
|
|
309
|
+
'enforce_admins is false — an admin-scoped push (or token) bypasses required_status_checks entirely. ' +
|
|
310
|
+
'See GitLab #1079: this is the gap that makes the mirror a review-free deploy path.',
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const broadScopes = tokenScopes.filter((s) => UNSAFE_TOKEN_SCOPES.includes(s));
|
|
315
|
+
if (broadScopes.length > 0) {
|
|
316
|
+
findings.push({
|
|
317
|
+
id: 'token-scope-too-broad',
|
|
318
|
+
severity: 'medium',
|
|
319
|
+
message:
|
|
320
|
+
`Token scope(s) ${broadScopes.join(', ')} exceed what a mirror push needs (fine-grained contents:write ` +
|
|
321
|
+
`on this one repo). See docs/github-mirror-protection.md for the narrowing steps.`,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!requiredStatusChecks || requiredStatusChecks.contexts.length === 0) {
|
|
326
|
+
findings.push({
|
|
327
|
+
id: 'no-required-status-checks',
|
|
328
|
+
severity: 'medium',
|
|
329
|
+
message: 'No required status checks are configured on this branch — a push can land with a red or absent CI run.',
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
repo: nameWithOwner,
|
|
335
|
+
branch,
|
|
336
|
+
enforce_admins: enforceAdmins,
|
|
337
|
+
required_status_checks: requiredStatusChecks,
|
|
338
|
+
required_pull_request_reviews: requiredPullRequestReviews,
|
|
339
|
+
allow_force_pushes: allowForcePushes,
|
|
340
|
+
token_scopes: tokenScopes,
|
|
341
|
+
findings,
|
|
342
|
+
};
|
|
343
|
+
} catch (err) {
|
|
344
|
+
// Defensive catch-all: this script must never throw, only degrade.
|
|
345
|
+
return degradedResult('query-failed', err instanceof Error ? err.message : String(err ?? ''));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// CLI entry — only when run directly, not when imported (e.g. by tests).
|
|
350
|
+
const isMain =
|
|
351
|
+
process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
352
|
+
|
|
353
|
+
if (isMain) {
|
|
354
|
+
auditGithubBranchProtection({ repoRoot: process.cwd() }).then((result) => {
|
|
355
|
+
console.log(JSON.stringify(result));
|
|
356
|
+
process.exit('degraded' in result ? 2 : 0);
|
|
357
|
+
});
|
|
358
|
+
}
|
|
@@ -82,6 +82,7 @@ import {
|
|
|
82
82
|
RECOMMENDED_MARKERS,
|
|
83
83
|
isRecommendedOption,
|
|
84
84
|
} from './schema.mjs';
|
|
85
|
+
import { scanFenceBlocks } from '../validate/markdown-fences.mjs';
|
|
85
86
|
|
|
86
87
|
// ---------------------------------------------------------------------------
|
|
87
88
|
// Korpus-Abgrenzung
|
|
@@ -330,42 +331,17 @@ function lineOfOffset(starts, offset) {
|
|
|
330
331
|
}
|
|
331
332
|
|
|
332
333
|
/**
|
|
333
|
-
* Zaun-
|
|
334
|
+
* Zerlegt eine Markdown-Datei in Code-Zaun-Blöcke. **Zeilenanfangs- UND
|
|
335
|
+
* -ende-verankert** (`{ wholeLine: true }`, gemeinsamer Zaun-Tracker
|
|
336
|
+
* `../validate/markdown-fences.mjs`, #1181) — ohne den Anker schließt
|
|
334
337
|
* `skills/discovery/SKILL.md:373` seinen eigenen Block mitten im Fragetext
|
|
335
338
|
* (Falle 1).
|
|
336
|
-
*/
|
|
337
|
-
export const FENCE_LINE_PATTERN = /^[ \t]*(`{3,}|~{3,})[ \t]*([^\s`~]*)[ \t]*$/u;
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Zerlegt eine Markdown-Datei in Code-Zaun-Blöcke.
|
|
341
339
|
*
|
|
342
340
|
* @param {string} content
|
|
343
341
|
* @returns {Array<{openLine: number, closeLine: number, lang: string, bodyLines: string[], bodyStartLine: number}>}
|
|
344
342
|
*/
|
|
345
343
|
export function fencesOf(content) {
|
|
346
|
-
|
|
347
|
-
const fences = [];
|
|
348
|
-
let open = null;
|
|
349
|
-
for (let i = 0; i < lines.length; i++) {
|
|
350
|
-
const m = FENCE_LINE_PATTERN.exec(lines[i]);
|
|
351
|
-
if (!m) continue;
|
|
352
|
-
if (open === null) {
|
|
353
|
-
open = { marker: m[1][0], len: m[1].length, lang: m[2] || '', startIdx: i };
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
// Ein schließender Zaun muss dasselbe Zeichen und mindestens dieselbe
|
|
357
|
-
// Länge haben — sonst beendet ein ```js-Zaun einen ````-Block.
|
|
358
|
-
if (m[1][0] !== open.marker || m[1].length < open.len || m[2]) continue;
|
|
359
|
-
fences.push({
|
|
360
|
-
openLine: open.startIdx + 1,
|
|
361
|
-
closeLine: i + 1,
|
|
362
|
-
lang: open.lang,
|
|
363
|
-
bodyLines: lines.slice(open.startIdx + 1, i),
|
|
364
|
-
bodyStartLine: open.startIdx + 2,
|
|
365
|
-
});
|
|
366
|
-
open = null;
|
|
367
|
-
}
|
|
368
|
-
return fences;
|
|
344
|
+
return scanFenceBlocks(content, { wholeLine: true });
|
|
369
345
|
}
|
|
370
346
|
|
|
371
347
|
// ---------------------------------------------------------------------------
|