session-orchestrator 3.20.0 → 3.22.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/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- 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 +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- 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/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- 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/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- 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
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Check: `gh`/`glab` commands cited in documentation actually exist (#1023).
|
|
4
|
+
*
|
|
5
|
+
* ## Why
|
|
6
|
+
*
|
|
7
|
+
* `skills/plan/mode-new.md` shipped two commands that never existed in any
|
|
8
|
+
* released CLI — `glab repo edit --visibility` and `glab group list`. Prose is
|
|
9
|
+
* not executed, so nothing caught them. This check executes the CLI's own help
|
|
10
|
+
* and compares the documented invocation against it.
|
|
11
|
+
*
|
|
12
|
+
* ## Oracle — the help text, NEVER the exit code
|
|
13
|
+
*
|
|
14
|
+
* `glab repo bogusnonexistentxyz --help` exits **0**, exactly like a real
|
|
15
|
+
* subcommand. An exit-code probe is therefore an assert-nothing oracle at the
|
|
16
|
+
* subcommand level. The only trustworthy signal is the `COMMANDS` section that
|
|
17
|
+
* `<bin> <group> --help` prints about itself.
|
|
18
|
+
*
|
|
19
|
+
* Consequences of parsing each group's OWN help, rather than a top-level
|
|
20
|
+
* membership table:
|
|
21
|
+
*
|
|
22
|
+
* 1. **Undocumented aliases resolve for free.** `glab pipeline` is a working
|
|
23
|
+
* alias for `glab ci` but is absent from `glab --help`'s COMMANDS. A
|
|
24
|
+
* top-level membership check reports its 6 in-repo uses as dead; asking
|
|
25
|
+
* `glab pipeline --help` returns `glab ci`'s own COMMANDS and it passes.
|
|
26
|
+
* 2. **Leaf commands exempt themselves structurally.** `gh api` / `glab api`
|
|
27
|
+
* take a positional endpoint, not a subcommand, and print no COMMANDS
|
|
28
|
+
* section at all. `commands === null` means "nothing to verify here" — so
|
|
29
|
+
* the two `glab api projects/...` lines in `skills/_shared/monitor-patterns.md`
|
|
30
|
+
* need no allowlist entry. The exemption is derived, not hardcoded.
|
|
31
|
+
*
|
|
32
|
+
* The exit code IS used one level up, to decide whether the GROUP exists:
|
|
33
|
+
* `glab group --help` exits 1 with `Unknown command "group"`.
|
|
34
|
+
*
|
|
35
|
+
* ## Two extraction channels, both load-bearing
|
|
36
|
+
*
|
|
37
|
+
* Shell fences alone miss `glab group list`, which lives in an inline code span
|
|
38
|
+
* in a prose sentence. Inline spans alone miss everything in a `bash` block.
|
|
39
|
+
* The inline channel only accepts a span that BEGINS with `gh `/`glab ` — a CLI
|
|
40
|
+
* name in the middle of a span is prose about a command, not a command.
|
|
41
|
+
*
|
|
42
|
+
* ## Negative examples in docs
|
|
43
|
+
*
|
|
44
|
+
* `skills/gitlab-ops/SKILL.md` documents commands NOT to run. The convention
|
|
45
|
+
* that keeps them out of this check is structural, not an allowlist (an
|
|
46
|
+
* allowlist would exempt the file richest in real commands): write a negative
|
|
47
|
+
* example either as a shell comment inside a fence, or as prose that does not
|
|
48
|
+
* open an inline span with `gh `/`glab `.
|
|
49
|
+
*
|
|
50
|
+
* ## Mode: WARN-only
|
|
51
|
+
*
|
|
52
|
+
* Findings print as WARN and still return 0. Two reasons: (a) the oracle is the
|
|
53
|
+
* LOCALLY installed CLI, so a version skew would red an unrelated commit; and
|
|
54
|
+
* (b) `scripts/validate-plugin.mjs` tallies `/^[ ]{2}FAIL:/gm` module-wide, so a
|
|
55
|
+
* single `FAIL:` line from a warn-class check reds the entire validator. `FAIL:`
|
|
56
|
+
* is reserved for the tool-error path. Promoting to FAIL is a one-line change
|
|
57
|
+
* once the CLI versions are pinned.
|
|
58
|
+
*
|
|
59
|
+
* A missing binary is a silent SKIP returning 0 — a CI runner without `glab`
|
|
60
|
+
* must not turn this check red.
|
|
61
|
+
*
|
|
62
|
+
* Verified against glab 1.91.0 / gh 2.86.0 (2026-08-15).
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
66
|
+
import path from 'node:path';
|
|
67
|
+
import { spawnSync } from 'node:child_process';
|
|
68
|
+
import { pathToFileURL } from 'node:url';
|
|
69
|
+
|
|
70
|
+
/** Directories walked for documentation. Mirrors check-vcs-repo-flag.mjs. */
|
|
71
|
+
const SCAN_DIRS = Object.freeze([
|
|
72
|
+
'.claude',
|
|
73
|
+
'agents',
|
|
74
|
+
'commands',
|
|
75
|
+
'docs',
|
|
76
|
+
'hooks',
|
|
77
|
+
'scripts',
|
|
78
|
+
'skills',
|
|
79
|
+
'templates',
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** Never descend into these. */
|
|
83
|
+
const EXCLUDED_DIRS = Object.freeze(['node_modules', '.git', 'dist', 'build', 'coverage']);
|
|
84
|
+
|
|
85
|
+
/** Fence languages whose body is shell. Anything else is prose. */
|
|
86
|
+
const SHELL_LANGS = Object.freeze(new Set(['bash', 'sh', 'shell', 'console', 'zsh']));
|
|
87
|
+
|
|
88
|
+
/** The CLIs this check knows how to interrogate. */
|
|
89
|
+
const BINS = Object.freeze(['gh', 'glab']);
|
|
90
|
+
|
|
91
|
+
/** Max chars of a cited command echoed back in a message. */
|
|
92
|
+
const SNIPPET_MAX = 120;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A token is judgeable only when it is a literal word. Anything carrying shell
|
|
96
|
+
* interpolation, a placeholder, or an API path is a template, not a subcommand.
|
|
97
|
+
*
|
|
98
|
+
* @param {string | undefined} token
|
|
99
|
+
* @returns {boolean}
|
|
100
|
+
*/
|
|
101
|
+
function isPlainToken(token) {
|
|
102
|
+
return typeof token === 'string' && /^[a-z][a-z0-9_-]*$/.test(token);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* An all-caps help section header (`USAGE`, `FLAGS`, `CORE COMMANDS`, …).
|
|
107
|
+
* glab pads its headers (` COMMANDS `); gh puts them at column 0.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} line
|
|
110
|
+
* @returns {boolean}
|
|
111
|
+
*/
|
|
112
|
+
function isSectionHeader(line) {
|
|
113
|
+
const trimmed = line.trim();
|
|
114
|
+
return trimmed.length >= 3 && /^[A-Z][A-Z0-9 /-]*$/.test(trimmed);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A section header that introduces a command list. Matches glab's `COMMANDS`
|
|
119
|
+
* and every gh variant (`CORE COMMANDS`, `TARGETED COMMANDS`, `ALIAS COMMANDS`).
|
|
120
|
+
*
|
|
121
|
+
* @param {string} line
|
|
122
|
+
* @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
function isCommandsHeader(line) {
|
|
125
|
+
return isSectionHeader(line) && /(?:^|\s)COMMANDS$/.test(line.trim());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse every COMMANDS section out of a help text.
|
|
130
|
+
*
|
|
131
|
+
* Entries are taken only at the section's MINIMUM indentation — that is the
|
|
132
|
+
* command column. Deeper-indented lines are wrapped description text, whose
|
|
133
|
+
* first word would otherwise be mistaken for a subcommand.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} helpText raw `<bin> <group> --help` output
|
|
136
|
+
* @returns {Set<string> | null} the subcommand names, or null when the command
|
|
137
|
+
* prints no COMMANDS section at all (a leaf command taking a positional).
|
|
138
|
+
*/
|
|
139
|
+
export function parseCommandsSection(helpText) {
|
|
140
|
+
const lines = helpText.split('\n');
|
|
141
|
+
/** @type {Set<string>} */
|
|
142
|
+
const names = new Set();
|
|
143
|
+
let sawSection = false;
|
|
144
|
+
|
|
145
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
146
|
+
if (!isCommandsHeader(lines[index])) continue;
|
|
147
|
+
sawSection = true;
|
|
148
|
+
|
|
149
|
+
/** @type {string[]} */
|
|
150
|
+
const block = [];
|
|
151
|
+
for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
|
|
152
|
+
if (isSectionHeader(lines[cursor])) break;
|
|
153
|
+
if (lines[cursor].trim() === '') continue;
|
|
154
|
+
block.push(lines[cursor]);
|
|
155
|
+
}
|
|
156
|
+
if (block.length === 0) continue;
|
|
157
|
+
|
|
158
|
+
const minIndent = Math.min(...block.map((line) => line.length - line.trimStart().length));
|
|
159
|
+
for (const line of block) {
|
|
160
|
+
if (line.length - line.trimStart().length !== minIndent) continue;
|
|
161
|
+
const first = line.trim().split(/\s+/)[0].replace(/[:,]$/, '');
|
|
162
|
+
if (isPlainToken(first)) names.add(first);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return sawSection ? names : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Join `\`-continuation lines into logical lines, keeping the START line number.
|
|
171
|
+
*
|
|
172
|
+
* @param {{line: number, text: string}[]} entries
|
|
173
|
+
* @returns {{line: number, text: string}[]}
|
|
174
|
+
*/
|
|
175
|
+
function joinContinuations(entries) {
|
|
176
|
+
/** @type {{line: number, text: string}[]} */
|
|
177
|
+
const joined = [];
|
|
178
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
179
|
+
const { line } = entries[index];
|
|
180
|
+
let { text } = entries[index];
|
|
181
|
+
while (
|
|
182
|
+
/\\\s*$/.test(text) &&
|
|
183
|
+
index + 1 < entries.length &&
|
|
184
|
+
entries[index + 1].line === entries[index].line + 1
|
|
185
|
+
) {
|
|
186
|
+
index += 1;
|
|
187
|
+
text = `${text.replace(/\\\s*$/, '')} ${entries[index].text.trim()}`;
|
|
188
|
+
}
|
|
189
|
+
joined.push({ line, text });
|
|
190
|
+
}
|
|
191
|
+
return joined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Pull candidate command texts out of a markdown body via both channels.
|
|
196
|
+
*
|
|
197
|
+
* Channel 1 — shell fences (CommonMark automaton, shell comments dropped).
|
|
198
|
+
* Channel 2 — inline code spans OUTSIDE any fence that BEGIN with `gh `/`glab `.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} body markdown content
|
|
201
|
+
* @returns {{line: number, text: string, channel: 'shell-fence'|'inline-span'}[]}
|
|
202
|
+
*/
|
|
203
|
+
export function extractCandidates(body) {
|
|
204
|
+
/** @type {{line: number, text: string, channel: 'shell-fence'|'inline-span'}[]} */
|
|
205
|
+
const candidates = [];
|
|
206
|
+
/** @type {{line: number, text: string}[]} */
|
|
207
|
+
const shellLines = [];
|
|
208
|
+
/** @type {{marker: string, length: number, shell: boolean} | null} */
|
|
209
|
+
let fence = null;
|
|
210
|
+
|
|
211
|
+
const lines = body.split('\n');
|
|
212
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
213
|
+
const raw = lines[index];
|
|
214
|
+
const fenceMatch = raw.match(/^\s*(`{3,}|~{3,})\s*([A-Za-z0-9_+-]*)/);
|
|
215
|
+
if (fenceMatch) {
|
|
216
|
+
const marker = fenceMatch[1][0];
|
|
217
|
+
const length = fenceMatch[1].length;
|
|
218
|
+
const lang = fenceMatch[2].toLowerCase();
|
|
219
|
+
if (fence === null) {
|
|
220
|
+
fence = { marker, length, shell: SHELL_LANGS.has(lang) };
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// A closing fence uses the same char, is at least as long, and has no info string.
|
|
224
|
+
if (marker === fence.marker && length >= fence.length && lang === '') {
|
|
225
|
+
fence = null;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
// Otherwise it is fence content (a nested fence inside a wider one).
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (fence !== null) {
|
|
232
|
+
if (!fence.shell) continue;
|
|
233
|
+
const stripped = raw.replace(/^\s*[$❯>]\s+/, '');
|
|
234
|
+
if (/^\s*#/.test(stripped)) continue;
|
|
235
|
+
shellLines.push({ line: index + 1, text: stripped });
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const span of raw.matchAll(/`([^`]+)`/g)) {
|
|
240
|
+
const text = span[1].trim();
|
|
241
|
+
if (/^(?:gh|glab)\s/.test(text)) {
|
|
242
|
+
candidates.push({ line: index + 1, text, channel: 'inline-span' });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const entry of joinContinuations(shellLines)) {
|
|
248
|
+
candidates.push({ line: entry.line, text: entry.text, channel: 'shell-fence' });
|
|
249
|
+
}
|
|
250
|
+
return candidates;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Split one candidate text into `(cli, group, sub)` triples.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} text
|
|
257
|
+
* @returns {{cli: string, group: string, sub: string | undefined}[]}
|
|
258
|
+
*/
|
|
259
|
+
export function parseInvocations(text) {
|
|
260
|
+
/** @type {{cli: string, group: string, sub: string | undefined}[]} */
|
|
261
|
+
const found = [];
|
|
262
|
+
const re = /\b(gh|glab)\s+([^\s;|&()]+)((?:\s+[^\s;|&()]+)*)/g;
|
|
263
|
+
/** @type {RegExpExecArray | null} */
|
|
264
|
+
let matched;
|
|
265
|
+
while ((matched = re.exec(text)) !== null) {
|
|
266
|
+
const rest = matched[3].trim().split(/\s+/).filter(Boolean);
|
|
267
|
+
// The first non-flag token after the group is the candidate subcommand.
|
|
268
|
+
const sub = rest.find((token) => !token.startsWith('-'));
|
|
269
|
+
found.push({ cli: matched[1], group: matched[2], sub });
|
|
270
|
+
}
|
|
271
|
+
return found;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Interrogate `<bin> <group> --help`, memoised per pair.
|
|
276
|
+
*
|
|
277
|
+
* Never piped: `$?` after a pipe measures the pipe's last stage, not the CLI.
|
|
278
|
+
*
|
|
279
|
+
* @param {string} bin
|
|
280
|
+
* @param {string} group
|
|
281
|
+
* @param {Map<string, {ok: boolean, commands: Set<string> | null}>} cache
|
|
282
|
+
* @returns {{ok: boolean, commands: Set<string> | null}}
|
|
283
|
+
*/
|
|
284
|
+
function probeHelp(bin, group, cache) {
|
|
285
|
+
const key = `${bin} ${group}`;
|
|
286
|
+
const cached = cache.get(key);
|
|
287
|
+
if (cached) return cached;
|
|
288
|
+
|
|
289
|
+
const result = spawnSync(bin, [group, '--help'], { encoding: 'utf8', timeout: 20_000 });
|
|
290
|
+
const text = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
291
|
+
const entry = {
|
|
292
|
+
ok: result.status === 0,
|
|
293
|
+
commands: result.status === 0 ? parseCommandsSection(text) : null,
|
|
294
|
+
};
|
|
295
|
+
cache.set(key, entry);
|
|
296
|
+
return entry;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Recursively collect `.md` files.
|
|
301
|
+
*
|
|
302
|
+
* @param {string} directory
|
|
303
|
+
* @param {string[]} acc
|
|
304
|
+
* @returns {string[]}
|
|
305
|
+
*/
|
|
306
|
+
function walk(directory, acc = []) {
|
|
307
|
+
if (!existsSync(directory)) return acc;
|
|
308
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
309
|
+
if (entry.isSymbolicLink()) continue;
|
|
310
|
+
if (EXCLUDED_DIRS.includes(entry.name)) continue;
|
|
311
|
+
const fullPath = path.join(directory, entry.name);
|
|
312
|
+
if (entry.isDirectory()) {
|
|
313
|
+
walk(fullPath, acc);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (entry.isFile() && path.extname(entry.name) === '.md') acc.push(fullPath);
|
|
317
|
+
}
|
|
318
|
+
return acc;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** @param {string} text @returns {string} */
|
|
322
|
+
function clampSnippet(text) {
|
|
323
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
324
|
+
return flat.length > SNIPPET_MAX ? `${flat.slice(0, SNIPPET_MAX - 1)}…` : flat;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Census the documentation corpus for dead `gh`/`glab` commands.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} pluginRoot absolute plugin root
|
|
331
|
+
* @returns {{ok: boolean, skipped: string[], summary: object, findings: object[], toolError: boolean}}
|
|
332
|
+
*/
|
|
333
|
+
export function inspectDocCliCommands(pluginRoot) {
|
|
334
|
+
/** @type {{kind: string, file: string, line: number, channel: string, command: string, message: string}[]} */
|
|
335
|
+
const findings = [];
|
|
336
|
+
/** @type {string[]} */
|
|
337
|
+
const skipped = [];
|
|
338
|
+
|
|
339
|
+
const available = BINS.filter((bin) => spawnSync('command', ['-v', bin], { shell: true }).status === 0);
|
|
340
|
+
for (const bin of BINS) {
|
|
341
|
+
if (!available.includes(bin)) skipped.push(bin);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const summary = {
|
|
345
|
+
filesScanned: 0,
|
|
346
|
+
shellFenceCandidates: 0,
|
|
347
|
+
inlineSpanCandidates: 0,
|
|
348
|
+
judged: 0,
|
|
349
|
+
leafExempt: 0,
|
|
350
|
+
templateSkipped: 0,
|
|
351
|
+
findings: 0,
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
if (available.length === 0) {
|
|
355
|
+
return { ok: true, skipped, summary, findings, toolError: false };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @type {string[]} */
|
|
359
|
+
let files;
|
|
360
|
+
try {
|
|
361
|
+
files = SCAN_DIRS.flatMap((dir) => walk(path.join(pluginRoot, dir)));
|
|
362
|
+
for (const entry of readdirSync(pluginRoot, { withFileTypes: true })) {
|
|
363
|
+
if (entry.isFile() && path.extname(entry.name) === '.md') {
|
|
364
|
+
files.push(path.join(pluginRoot, entry.name));
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
files.sort();
|
|
368
|
+
} catch (error) {
|
|
369
|
+
findings.push({
|
|
370
|
+
kind: 'tool-error',
|
|
371
|
+
file: '-',
|
|
372
|
+
line: 0,
|
|
373
|
+
channel: '-',
|
|
374
|
+
command: '-',
|
|
375
|
+
message: `cannot enumerate the scan corpus: ${error instanceof Error ? error.message : String(error)}`,
|
|
376
|
+
});
|
|
377
|
+
return { ok: false, skipped, summary, findings, toolError: true };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** @type {Map<string, {ok: boolean, commands: Set<string> | null}>} */
|
|
381
|
+
const helpCache = new Map();
|
|
382
|
+
|
|
383
|
+
for (const absolute of files) {
|
|
384
|
+
const relative = path.relative(pluginRoot, absolute);
|
|
385
|
+
/** @type {string} */
|
|
386
|
+
let body;
|
|
387
|
+
try {
|
|
388
|
+
body = readFileSync(absolute, 'utf8');
|
|
389
|
+
} catch (error) {
|
|
390
|
+
findings.push({
|
|
391
|
+
kind: 'tool-error',
|
|
392
|
+
file: relative,
|
|
393
|
+
line: 0,
|
|
394
|
+
channel: '-',
|
|
395
|
+
command: '-',
|
|
396
|
+
message: `cannot read: ${error instanceof Error ? error.message : String(error)}`,
|
|
397
|
+
});
|
|
398
|
+
return { ok: false, skipped, summary, findings, toolError: true };
|
|
399
|
+
}
|
|
400
|
+
summary.filesScanned += 1;
|
|
401
|
+
if (!/\b(?:gh|glab)\s/.test(body)) continue;
|
|
402
|
+
|
|
403
|
+
for (const candidate of extractCandidates(body)) {
|
|
404
|
+
if (candidate.channel === 'shell-fence') summary.shellFenceCandidates += 1;
|
|
405
|
+
else summary.inlineSpanCandidates += 1;
|
|
406
|
+
|
|
407
|
+
for (const { cli, group, sub } of parseInvocations(candidate.text)) {
|
|
408
|
+
if (!available.includes(cli)) continue;
|
|
409
|
+
if (!isPlainToken(group)) {
|
|
410
|
+
summary.templateSkipped += 1;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const help = probeHelp(cli, group, helpCache);
|
|
415
|
+
if (!help.ok) {
|
|
416
|
+
summary.judged += 1;
|
|
417
|
+
summary.findings += 1;
|
|
418
|
+
findings.push({
|
|
419
|
+
kind: 'dead-command-group',
|
|
420
|
+
file: relative,
|
|
421
|
+
line: candidate.line,
|
|
422
|
+
channel: candidate.channel,
|
|
423
|
+
command: `${cli} ${group}`,
|
|
424
|
+
message:
|
|
425
|
+
`\`${cli} ${group}\` does not exist — \`${cli} ${group} --help\` exits non-zero: ` +
|
|
426
|
+
clampSnippet(candidate.text),
|
|
427
|
+
});
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// A command with no COMMANDS section is a leaf taking a positional
|
|
432
|
+
// argument (`gh api <endpoint>`); there is no subcommand to verify.
|
|
433
|
+
if (help.commands === null) {
|
|
434
|
+
summary.leafExempt += 1;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
if (!isPlainToken(sub)) {
|
|
438
|
+
summary.templateSkipped += 1;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
summary.judged += 1;
|
|
443
|
+
if (!help.commands.has(/** @type {string} */ (sub))) {
|
|
444
|
+
summary.findings += 1;
|
|
445
|
+
findings.push({
|
|
446
|
+
kind: 'dead-subcommand',
|
|
447
|
+
file: relative,
|
|
448
|
+
line: candidate.line,
|
|
449
|
+
channel: candidate.channel,
|
|
450
|
+
command: `${cli} ${group} ${sub}`,
|
|
451
|
+
message:
|
|
452
|
+
`\`${cli} ${group} ${sub}\` is not in the COMMANDS list printed by ` +
|
|
453
|
+
`\`${cli} ${group} --help\`: ${clampSnippet(candidate.text)}`,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
|
|
461
|
+
return { ok: findings.length === 0, skipped, summary, findings, toolError: false };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Run the human-readable validator CLI.
|
|
466
|
+
*
|
|
467
|
+
* WARN-ONLY: findings print as WARN and still return 0 — see § Mode in the
|
|
468
|
+
* header for why a stray `FAIL:` line would red the whole validate-plugin gate.
|
|
469
|
+
*
|
|
470
|
+
* @param {string} pluginRoot absolute plugin root
|
|
471
|
+
* @returns {number} 0 = census completed, 2 = tool error
|
|
472
|
+
*/
|
|
473
|
+
export function runCheckDocCliCommands(pluginRoot) {
|
|
474
|
+
console.log('--- Check: gh/glab commands cited in docs exist (WARN-only) ---');
|
|
475
|
+
const inspection = inspectDocCliCommands(pluginRoot);
|
|
476
|
+
|
|
477
|
+
if (inspection.toolError) {
|
|
478
|
+
for (const item of inspection.findings) console.log(` FAIL: ${item.file} — ${item.message}`);
|
|
479
|
+
console.log('');
|
|
480
|
+
console.log(`Results: 0 passed, ${inspection.findings.length} failed`);
|
|
481
|
+
return 2;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const bin of inspection.skipped) {
|
|
485
|
+
console.log(` SKIP: \`${bin}\` is not installed — its citations were not verified`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
for (const item of inspection.findings) {
|
|
489
|
+
console.log(` WARN: [${item.kind}] ${item.file}:${item.line} — ${item.message}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const s = inspection.summary;
|
|
493
|
+
console.log(
|
|
494
|
+
` PASS: censused ${s.filesScanned} doc file(s) — ${s.shellFenceCandidates} shell-fence + ` +
|
|
495
|
+
`${s.inlineSpanCandidates} inline-span candidate(s); ${s.judged} invocation(s) judged against ` +
|
|
496
|
+
`live --help, ${s.leafExempt} leaf command(s) exempt (positional argument, no COMMANDS section), ` +
|
|
497
|
+
`${s.templateSkipped} templated token(s) unjudged, ${s.findings} dead`,
|
|
498
|
+
);
|
|
499
|
+
console.log('');
|
|
500
|
+
console.log('Results: 1 passed, 0 failed');
|
|
501
|
+
return 0;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const isMain = import.meta.url === pathToFileURL(process.argv[1] || '').href;
|
|
505
|
+
if (isMain) {
|
|
506
|
+
const args = process.argv.slice(2).filter((arg) => arg !== '--json');
|
|
507
|
+
const root = path.resolve(args[0] || process.cwd());
|
|
508
|
+
if (process.argv.includes('--json')) {
|
|
509
|
+
const inspection = inspectDocCliCommands(root);
|
|
510
|
+
process.stdout.write(`${JSON.stringify(inspection, (_k, v) => (v instanceof Set ? [...v] : v), 2)}\n`);
|
|
511
|
+
process.exit(inspection.toolError ? 2 : 0);
|
|
512
|
+
}
|
|
513
|
+
process.exit(runCheckDocCliCommands(root));
|
|
514
|
+
}
|
|
@@ -79,6 +79,22 @@ const DOCUMENTED_ASYMMETRIES = {
|
|
|
79
79
|
// no-op, not enforcement (#919-P2 class) — "pretending the payloads are
|
|
80
80
|
// compatible would create false enforcement".
|
|
81
81
|
PreToolUse: [
|
|
82
|
+
// pre-task-scope-disjoint (#1020): NOT ported by construction. The hook
|
|
83
|
+
// matches the `Agent` dispatch tool, which this platform does not have —
|
|
84
|
+
// Cursor IDE and Pi v1 have no parallel agent dispatch at all
|
|
85
|
+
// (skills/session-plan/SKILL.md Platform Note), and Codex has no payload
|
|
86
|
+
// adapter for it. A matcher that can never fire is not enforcement.
|
|
87
|
+
// pre-auq-clarity (#1107): NOT ported by construction. The hook matches
|
|
88
|
+
// the `AskUserQuestion` tool, which this platform does not have — it
|
|
89
|
+
// renders a numbered Markdown list instead (docs/codex-setup.md:112,
|
|
90
|
+
// docs/cursor-setup.md:86), and pi-hook-bridge's TOOL_NAME_MAP carries
|
|
91
|
+
// no `askuserquestion` entry. A matcher on a tool that never fires is a
|
|
92
|
+
// dead entry: maintenance cost that ASSERTS a protection it does not
|
|
93
|
+
// provide. Revisit the day the platform gains the tool — the mechanical
|
|
94
|
+
// witness for that day is the mapPiToolName assertion in
|
|
95
|
+
// tests/hooks/pre-auq-clarity-wiring.test.mjs, which goes red then.
|
|
96
|
+
'pre-auq-clarity.mjs',
|
|
97
|
+
'pre-task-scope-disjoint.mjs',
|
|
82
98
|
'skill-invocation-telemetry.mjs',
|
|
83
99
|
'enforce-scope.mjs',
|
|
84
100
|
'config-protection.mjs',
|
|
@@ -121,6 +137,22 @@ const DOCUMENTED_ASYMMETRIES = {
|
|
|
121
137
|
// surface, and the operator's 2026-07-31 rule is that a gap gets
|
|
122
138
|
// registered rather than faked. #958 tracks the port-or-justify.
|
|
123
139
|
PreToolUse: [
|
|
140
|
+
// pre-task-scope-disjoint (#1020): NOT ported by construction. The hook
|
|
141
|
+
// matches the `Agent` dispatch tool, which this platform does not have —
|
|
142
|
+
// Cursor IDE and Pi v1 have no parallel agent dispatch at all
|
|
143
|
+
// (skills/session-plan/SKILL.md Platform Note), and Codex has no payload
|
|
144
|
+
// adapter for it. A matcher that can never fire is not enforcement.
|
|
145
|
+
// pre-auq-clarity (#1107): NOT ported by construction. The hook matches
|
|
146
|
+
// the `AskUserQuestion` tool, which this platform does not have — it
|
|
147
|
+
// renders a numbered Markdown list instead (docs/codex-setup.md:112,
|
|
148
|
+
// docs/cursor-setup.md:86), and pi-hook-bridge's TOOL_NAME_MAP carries
|
|
149
|
+
// no `askuserquestion` entry. A matcher on a tool that never fires is a
|
|
150
|
+
// dead entry: maintenance cost that ASSERTS a protection it does not
|
|
151
|
+
// provide. Revisit the day the platform gains the tool — the mechanical
|
|
152
|
+
// witness for that day is the mapPiToolName assertion in
|
|
153
|
+
// tests/hooks/pre-auq-clarity-wiring.test.mjs, which goes red then.
|
|
154
|
+
'pre-auq-clarity.mjs',
|
|
155
|
+
'pre-task-scope-disjoint.mjs',
|
|
124
156
|
'skill-invocation-telemetry.mjs',
|
|
125
157
|
'pre-bash-sessions-ledger-guard.mjs', // #958
|
|
126
158
|
'pre-bash-templates-first.mjs', // #946
|
|
@@ -143,6 +175,22 @@ const DOCUMENTED_ASYMMETRIES = {
|
|
|
143
175
|
// mapped at all; the other eight PreToolUse handlers have no Cursor
|
|
144
176
|
// mapping whatsoever.
|
|
145
177
|
PreToolUse: [
|
|
178
|
+
// pre-task-scope-disjoint (#1020): NOT ported by construction. The hook
|
|
179
|
+
// matches the `Agent` dispatch tool, which this platform does not have —
|
|
180
|
+
// Cursor IDE and Pi v1 have no parallel agent dispatch at all
|
|
181
|
+
// (skills/session-plan/SKILL.md Platform Note), and Codex has no payload
|
|
182
|
+
// adapter for it. A matcher that can never fire is not enforcement.
|
|
183
|
+
// pre-auq-clarity (#1107): NOT ported by construction. The hook matches
|
|
184
|
+
// the `AskUserQuestion` tool, which this platform does not have — it
|
|
185
|
+
// renders a numbered Markdown list instead (docs/codex-setup.md:112,
|
|
186
|
+
// docs/cursor-setup.md:86), and pi-hook-bridge's TOOL_NAME_MAP carries
|
|
187
|
+
// no `askuserquestion` entry. A matcher on a tool that never fires is a
|
|
188
|
+
// dead entry: maintenance cost that ASSERTS a protection it does not
|
|
189
|
+
// provide. Revisit the day the platform gains the tool — the mechanical
|
|
190
|
+
// witness for that day is the mapPiToolName assertion in
|
|
191
|
+
// tests/hooks/pre-auq-clarity-wiring.test.mjs, which goes red then.
|
|
192
|
+
'pre-auq-clarity.mjs',
|
|
193
|
+
'pre-task-scope-disjoint.mjs',
|
|
146
194
|
'skill-invocation-telemetry.mjs', // #919
|
|
147
195
|
'enforce-scope.mjs', // #919
|
|
148
196
|
'config-protection.mjs', // #919
|