session-orchestrator 3.24.0 → 4.0.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/.agents/skills/architecture/SKILL.md +18 -0
- package/.agents/skills/autopilot/SKILL.md +17 -0
- package/.agents/skills/bootstrap/SKILL.md +20 -0
- package/.agents/skills/brainstorm/SKILL.md +22 -0
- package/.agents/skills/claude-md-drift-check/SKILL.md +15 -0
- package/.agents/skills/convergence-monitoring/SKILL.md +22 -0
- package/.agents/skills/debug/SKILL.md +22 -0
- package/.agents/skills/discovery/SKILL.md +20 -0
- package/.agents/skills/dispatcher/SKILL.md +15 -0
- package/.agents/skills/docs-orchestrator/SKILL.md +18 -0
- package/.agents/skills/ecosystem-health/SKILL.md +20 -0
- package/.agents/skills/eli5/SKILL.md +20 -0
- package/.agents/skills/eval/SKILL.md +21 -0
- package/.agents/skills/evolve/SKILL.md +21 -0
- package/.agents/skills/frontmatter-guard/SKILL.md +15 -0
- package/.agents/skills/gitlab-ops/SKILL.md +20 -0
- package/.agents/skills/gitlab-portfolio/SKILL.md +15 -0
- package/.agents/skills/grill/SKILL.md +22 -0
- package/.agents/skills/hook-development/SKILL.md +15 -0
- package/.agents/skills/mcp-builder/SKILL.md +15 -0
- package/.agents/skills/memory-cleanup/SKILL.md +21 -0
- package/.agents/skills/mode-selector/SKILL.md +17 -0
- package/.agents/skills/npm-publish/SKILL.md +16 -0
- package/.agents/skills/peekaboo-driver/SKILL.md +18 -0
- package/.agents/skills/persona-panel/SKILL.md +17 -0
- package/.agents/skills/plan/SKILL.md +20 -0
- package/.agents/skills/playwright-driver/SKILL.md +20 -0
- package/.agents/skills/quality-gates/SKILL.md +20 -0
- package/.agents/skills/reconcile/SKILL.md +21 -0
- package/.agents/skills/remote-offload/SKILL.md +20 -0
- package/.agents/skills/repo-audit/SKILL.md +16 -0
- package/.agents/skills/session-end/SKILL.md +20 -0
- package/.agents/skills/session-plan/SKILL.md +20 -0
- package/.agents/skills/session-start/SKILL.md +20 -0
- package/.agents/skills/spinout/SKILL.md +16 -0
- package/.agents/skills/sunset-review/SKILL.md +16 -0
- package/.agents/skills/test-runner/SKILL.md +20 -0
- package/.agents/skills/tmux-layout/SKILL.md +21 -0
- package/.agents/skills/using-orchestrator/SKILL.md +17 -0
- package/.agents/skills/vault-mirror/SKILL.md +15 -0
- package/.agents/skills/vault-sync/SKILL.md +15 -0
- package/.agents/skills/wave-executor/SKILL.md +20 -0
- package/.agents/skills/write-executable-plan/SKILL.md +22 -0
- 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.md +2 -2
- package/.cursor/commands/bootstrap.md +1 -1
- package/.cursor/commands/brainstorm.md +1 -1
- package/.cursor/commands/debug.md +1 -1
- package/.cursor/commands/discovery.md +1 -1
- package/.cursor/commands/dispatcher.md +2 -2
- package/.cursor/commands/eli5.md +2 -2
- package/.cursor/commands/eval.md +2 -2
- package/.cursor/commands/evolve.md +1 -1
- package/.cursor/commands/go.md +1 -1
- package/.cursor/commands/grill.md +2 -2
- package/.cursor/commands/memory-cleanup.md +2 -2
- package/.cursor/commands/persona-panel.md +1 -1
- package/.cursor/commands/plan.md +1 -1
- package/.cursor/commands/portfolio.md +1 -1
- package/.cursor/commands/reconcile.md +2 -2
- package/.cursor/commands/release.md +2 -2
- package/.cursor/commands/session.md +2 -2
- package/.cursor/commands/spinout.md +2 -2
- package/.cursor/commands/sunset-review.md +2 -2
- package/.cursor/commands/templates-ack.md +2 -2
- package/.cursor/commands/test.md +2 -2
- package/.cursor/skills/brainstorm/SKILL.md +1 -1
- package/.cursor/skills/eval/SKILL.md +1 -1
- package/.cursor/skills/quality-gates/SKILL.md +1 -1
- package/.cursor/skills/remote-offload/SKILL.md +1 -1
- package/.orchestrator/policy/blocked-commands.json +121 -0
- package/.orchestrator/policy/ecosystem.schema.json +66 -0
- package/.orchestrator/policy/quality-gates.example.json +16 -0
- package/.orchestrator/policy/quality-gates.schema.json +38 -0
- package/.orchestrator/policy/templates-policy.json +27 -0
- package/.orchestrator/policy/test-profiles.json +47 -0
- package/AGENTS.md +225 -0
- package/CHANGELOG.md +1125 -2
- package/NOTICE +11 -6
- package/README.md +127 -94
- package/agents/eval-judge.md +1 -1
- package/agents/skill-applied-judge.md +1 -1
- package/assets/wave-lifecycle.svg +98 -0
- package/commands/release.md +6 -3
- package/commands/session.md +18 -3
- package/docs/README.md +4 -0
- package/{agents/AGENTS.md → docs/agent-authoring.md} +19 -26
- package/docs/baseline.md +67 -0
- package/docs/ci-setup.md +108 -62
- package/docs/codex-setup.md +65 -21
- package/docs/components.md +36 -15
- package/docs/cursor-setup.md +6 -2
- package/docs/events-schema.md +9 -6
- package/docs/instruction-delivery.md +62 -0
- package/{agents/memory-proposal-collector.md → docs/memory-proposal-flow.md} +1 -8
- package/docs/migration-v4.md +341 -0
- package/docs/pi-setup.md +6 -1
- package/docs/plugin-architecture-v3.md +1 -1
- package/docs/rule-authoring.md +85 -19
- package/docs/scope-collision-guard.md +5 -5
- package/docs/session-config-reference.md +57 -56
- package/docs/session-config-template.md +6 -29
- package/docs/telemetry.md +157 -3
- package/docs/vault-docs-architecture.md +50 -11
- package/hooks/_lib/hook-import-set.json +1487 -0
- package/hooks/_lib/subagent-transcript.mjs +562 -0
- package/hooks/config-protection.mjs +2 -2
- package/hooks/cwd-change-restore.mjs +2 -2
- package/hooks/enforce-commands.mjs +69 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +10 -0
- package/hooks/hooks-pi.json +5 -0
- package/hooks/hooks.json +6 -1
- package/hooks/loop-guard.mjs +3 -3
- package/hooks/on-session-end.mjs +2 -2
- package/hooks/on-session-start.mjs +103 -2
- package/hooks/on-stop.mjs +36 -11
- package/hooks/operator-steer.mjs +2 -2
- package/hooks/post-bash-write-verify.mjs +85 -0
- package/hooks/post-edit-import-probe.mjs +344 -0
- package/hooks/post-subagent-discovery-validator.mjs +187 -431
- package/hooks/post-tool-batch-wave-signal.mjs +118 -4
- package/hooks/post-tool-failure-corrective-context.mjs +2 -2
- package/hooks/post-tooluse-frontend-slop.mjs +3 -3
- package/hooks/pre-bash-destructive-guard.mjs +39 -13
- package/hooks/skill-invocation-telemetry.mjs +17 -5
- package/hooks/subagent-telemetry.mjs +13 -4
- package/monitors/monitors.json +3 -3
- package/package.json +9 -1
- package/pi/prompts/session.md +2 -2
- package/plugin.json +27 -0
- package/scripts/backfill-abandoned-sessions.mjs +50 -4
- package/scripts/backfill-learnings-from-vault.mjs +9 -3
- package/scripts/dialectic-deriver.mjs +73 -8
- package/scripts/export-hw-learnings.mjs +113 -1
- package/scripts/generate-agents-skills.mjs +378 -0
- package/scripts/generate-cursor-adapter.mjs +45 -8
- package/scripts/generate-hook-import-set.mjs +249 -0
- package/scripts/lib/agent-status.mjs +13 -2
- package/scripts/lib/auto-dream.mjs +38 -36
- package/scripts/lib/autonomy/suitability.mjs +6 -0
- package/scripts/lib/autopilot/loop.mjs +2 -2
- package/scripts/lib/ci-status-banner.mjs +220 -75
- package/scripts/lib/codex/plugin-contract.mjs +82 -6
- package/scripts/lib/config/auto-dream.mjs +2 -1
- package/scripts/lib/config/block-header.mjs +8 -0
- package/scripts/lib/config/block-preprocess.mjs +177 -0
- package/scripts/lib/config/broken-window.mjs +2 -1
- package/scripts/lib/config/cold-start.mjs +2 -1
- package/scripts/lib/config/config-protection.mjs +22 -2
- package/scripts/lib/config/context-coverage.mjs +2 -1
- package/scripts/lib/config/cross-repo.mjs +2 -1
- package/scripts/lib/config/custom-phases.mjs +2 -1
- package/scripts/lib/config/dialectic.mjs +2 -1
- package/scripts/lib/config/discovery-validator.mjs +2 -1
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +24 -1
- package/scripts/lib/config/dispatcher-autonomy.mjs +2 -1
- package/scripts/lib/config/docs-orchestrator.mjs +2 -1
- package/scripts/lib/config/docs-staleness.mjs +2 -1
- package/scripts/lib/config/drift-check.mjs +2 -1
- package/scripts/lib/config/eval.mjs +2 -1
- package/scripts/lib/config/events-rotation.mjs +2 -1
- package/scripts/lib/config/evolve.mjs +8 -2
- package/scripts/lib/config/frontend-slop-hook.mjs +7 -3
- package/scripts/lib/config/gitlab-portfolio.mjs +2 -1
- package/scripts/lib/config/handover-gate.mjs +2 -1
- package/scripts/lib/config/health-endpoints.mjs +7 -2
- package/scripts/lib/config/issue-budget.mjs +2 -1
- package/scripts/lib/config/loop-guard.mjs +2 -1
- package/scripts/lib/config/memory.mjs +2 -1
- package/scripts/lib/config/moc-staleness.mjs +2 -1
- package/scripts/lib/config/persona-gate-wave.mjs +2 -1
- package/scripts/lib/config/private-config-dir.mjs +67 -0
- package/scripts/lib/config/reconcile.mjs +2 -1
- package/scripts/lib/config/remote-hosts.mjs +2 -1
- package/scripts/lib/config/section-extractor.mjs +7 -1
- package/scripts/lib/config/skill-evolution.mjs +2 -1
- package/scripts/lib/config/slopcheck.mjs +2 -1
- package/scripts/lib/config/state-md-lock.mjs +2 -1
- package/scripts/lib/config/templates-first.mjs +2 -1
- package/scripts/lib/config/test.mjs +2 -1
- package/scripts/lib/config/vault-integration.mjs +7 -1
- package/scripts/lib/config/vault-mirror-quality.mjs +2 -1
- package/scripts/lib/config/vault-staleness.mjs +2 -1
- package/scripts/lib/config/vault-sync.mjs +2 -1
- package/scripts/lib/config/verification-auto-fix.mjs +2 -1
- package/scripts/lib/config/wave-reviewers.mjs +2 -1
- package/scripts/lib/config/worktree-orphans.mjs +2 -1
- package/scripts/lib/convergence-monitor.mjs +82 -16
- package/scripts/lib/dispatcher/rank.mjs +124 -48
- package/scripts/lib/ecosystem-health.mjs +16 -2
- package/scripts/lib/eval/engine.mjs +9 -1
- package/scripts/lib/eval/session-resolve.mjs +23 -4
- package/scripts/lib/events.mjs +22 -6
- package/scripts/lib/frontmatter-guard.mjs +131 -13
- package/scripts/lib/gates/gate-full.mjs +26 -0
- package/scripts/lib/gates/gate-helpers.mjs +76 -0
- package/scripts/lib/hardware-pattern-detector.mjs +18 -1
- package/scripts/lib/harness-audit/categories/category4.mjs +31 -11
- package/scripts/lib/host-identity.mjs +50 -11
- package/scripts/lib/instruction-budget-guard.mjs +171 -5
- package/scripts/lib/learnings/evolve-telemetry.mjs +178 -0
- package/scripts/lib/learnings/io.mjs +60 -6
- package/scripts/lib/memory-proposals/store.mjs +30 -22
- package/scripts/lib/owner-config-banner.mjs +43 -6
- package/scripts/lib/owner-config-loader.mjs +21 -10
- package/scripts/lib/owner-interview.mjs +3 -3
- package/scripts/lib/owner-yaml.mjs +207 -14
- package/scripts/lib/platform.mjs +108 -15
- package/scripts/lib/plugin-update-banner.mjs +406 -0
- package/scripts/lib/project-hygiene.mjs +38 -2
- package/scripts/lib/qg-command-drift-banner.mjs +50 -12
- package/scripts/lib/quality-gate.mjs +133 -44
- package/scripts/lib/reconcile/emitter.mjs +68 -6
- package/scripts/lib/reconcile/engine.mjs +13 -4
- package/scripts/lib/reconcile/idempotency.mjs +37 -4
- package/scripts/lib/reconcile/writer.mjs +40 -18
- package/scripts/lib/session-close-backfill.mjs +67 -9
- package/scripts/lib/session-id.mjs +12 -23
- package/scripts/lib/session-identity/own-session.mjs +125 -10
- package/scripts/lib/session-lock-shape.mjs +43 -0
- package/scripts/lib/session-lock.mjs +5 -10
- package/scripts/lib/session-registry.mjs +25 -9
- package/scripts/lib/session-schema/constants.mjs +36 -2
- package/scripts/lib/session-schema/validator.mjs +38 -4
- package/scripts/lib/session-start-probes.mjs +18 -1
- package/scripts/lib/sessions-staleness-banner.mjs +18 -11
- package/scripts/lib/skill-health/join.mjs +17 -4
- package/scripts/lib/state-md.mjs +78 -0
- package/scripts/lib/sunset/walker.mjs +6 -0
- package/scripts/lib/telemetry/schema.mjs +181 -9
- package/scripts/lib/telemetry/sync.mjs +368 -12
- package/scripts/lib/validate/check-agents-skills.mjs +327 -0
- package/scripts/lib/validate/check-agents.mjs +3 -3
- package/scripts/lib/validate/check-cursor-adapter.mjs +234 -72
- package/scripts/lib/validate/check-hooks-symmetry.mjs +45 -16
- package/scripts/lib/validate/check-owner-leakage.mjs +281 -20
- package/scripts/lib/validate/check-skill-links.mjs +163 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +47 -28
- package/scripts/lib/validate/check-unwired-features.mjs +0 -2
- package/scripts/lib/validate/check-validator-registration.mjs +10 -4
- package/scripts/lib/validate/enumerate-repo-files.mjs +317 -0
- package/scripts/lib/vault-backfill/template.mjs +63 -6
- package/scripts/lib/vault-mirror/process.mjs +165 -42
- package/scripts/lib/vault-mirror/telemetry.mjs +2 -2
- package/scripts/lib/vault-status/narrative-mirror.mjs +127 -18
- package/scripts/lib/wave-executor/dispatch-common.mjs +164 -0
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +7 -142
- package/scripts/lib/wave-executor/remote-dispatch.mjs +5 -7
- package/scripts/lib/wave-resource-gate.mjs +8 -2
- package/scripts/lib/wave-sizing.mjs +4 -1
- package/scripts/lib/wave-transcript-tail.mjs +118 -4
- package/scripts/materialize-wave-scope.mjs +12 -5
- package/scripts/memory-propose.mjs +19 -5
- package/scripts/migrate-cold-start-seed.mjs +4 -1
- package/scripts/parse-config.mjs +60 -3
- package/scripts/release.mjs +337 -29
- package/scripts/repair-invalid-sessions.mjs +3 -3
- package/scripts/run-quality-gate.mjs +128 -11
- package/scripts/sweep-expired-learnings.mjs +90 -0
- package/scripts/sync-vault-schema.mjs +3 -1
- package/scripts/telemetry.mjs +2 -2
- package/scripts/validate-plugin.mjs +161 -0
- package/scripts/validate-wave-scope.mjs +28 -8
- package/scripts/wave-scope-binding.mjs +215 -0
- package/skills/_shared/instruction-file-resolution.md +10 -0
- package/skills/_shared/parallel-aware-preamble.md +1 -0
- package/skills/_shared/platform-tools.md +1 -1
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/architecture/SKILL.md +7 -5
- package/skills/{domain-model/SKILL.md → architecture/references/domain-model.md} +9 -9
- package/skills/autopilot/SKILL.md +4 -18
- package/skills/claude-md-drift-check/SKILL.md +5 -1
- package/skills/claude-md-drift-check/checker.mjs +62 -2
- package/skills/convergence-monitoring/SIGNALS.md +55 -0
- package/skills/discovery/probes/vault-staleness.mjs +37 -13
- package/skills/discovery/probes-arch.md +20 -18
- package/skills/dispatcher/SKILL.md +3 -2
- package/skills/evolve/SKILL.md +65 -26
- package/skills/frontmatter-guard/SKILL.md +11 -5
- package/skills/npm-publish/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +33 -0
- package/skills/remote-offload/SKILL.md +1 -1
- package/skills/session-end/SKILL.md +18 -905
- package/skills/session-end/phase-3-6-tail.md +10 -3
- package/skills/session-end/plan-verification.md +221 -155
- package/skills/session-end/references/phase-2-quality-gate.md +93 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +229 -0
- package/skills/session-end/references/phase-4a-worktree-cleanup.md +120 -0
- package/skills/session-end/references/phase-4b-worktree-orphan-sweep.md +58 -0
- package/skills/session-end/references/phase-5-issue-cleanup.md +104 -0
- package/skills/session-end/references/session-summary-template.md +62 -0
- package/skills/session-plan/SKILL.md +49 -0
- package/skills/session-start/SKILL.md +22 -904
- package/skills/session-start/phase-8-5-express-path.md +1 -1
- package/skills/session-start/references/phase-1-1-dispatcher-autonomy-capture.md +55 -0
- package/skills/session-start/references/phase-1-2-session-lock.md +140 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +254 -0
- package/skills/session-start/references/phase-1-7-vault-status-board.md +53 -0
- package/skills/session-start/references/phase-2-7-portfolio-snapshot.md +75 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +155 -0
- package/skills/session-start/references/phase-6-5-forced-reads.md +75 -0
- package/skills/session-start/references/phase-6-6-project-intelligence.md +81 -0
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +103 -0
- package/skills/vault-sync/validator.mjs +21 -27
- package/skills/wave-executor/SKILL.md +15 -1
- package/skills/wave-executor/references/wave-loop-dispatch.md +612 -0
- package/skills/wave-executor/references/wave-loop-review.md +570 -0
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +162 -0
- package/skills/wave-executor/wave-loop.md +14 -1309
- package/templates/_shared/journey-manifest.md +10 -6
- package/.cursor/commands/autopilot-multi.md +0 -14
- package/.cursor/commands/contract-version-bump.md +0 -14
- package/.cursor/commands/journey-audit.md +0 -14
- package/.cursor/skills/contract-version-bump/SKILL.md +0 -12
- package/.cursor/skills/daily/SKILL.md +0 -12
- package/.cursor/skills/domain-model/SKILL.md +0 -13
- package/.cursor/skills/journey-audit/SKILL.md +0 -13
- package/.cursor/skills/skill-creator/SKILL.md +0 -13
- package/.cursor/skills/ubiquitous-language/SKILL.md +0 -13
- package/commands/autopilot-multi.md +0 -74
- package/commands/contract-version-bump.md +0 -28
- package/commands/journey-audit.md +0 -43
- package/pi/prompts/autopilot-multi.md +0 -12
- package/pi/prompts/contract-version-bump.md +0 -12
- package/pi/prompts/journey-audit.md +0 -12
- package/scripts/autopilot-multi.mjs +0 -885
- package/scripts/backfill-learnings-expires.mjs +0 -196
- package/scripts/backfill-learnings.mjs +0 -203
- package/scripts/fleet-instruction-scan.mjs +0 -141
- package/scripts/lib/autopilot/dep-graph.mjs +0 -417
- package/scripts/lib/autopilot/multi-killswitch.mjs +0 -184
- package/scripts/lib/webhook-url.mjs +0 -105
- package/scripts/lifecycle-sim-v6.mjs +0 -347
- package/scripts/migrate-learnings-jsonl.mjs +0 -189
- package/scripts/migrate-subagents-jsonl.mjs +0 -196
- package/scripts/upload-social-preview.mjs +0 -316
- package/skills/_shared/model-selection.md +0 -64
- package/skills/contract-version-bump/SKILL.md +0 -219
- package/skills/daily/SKILL.md +0 -222
- package/skills/daily/generate.sh +0 -92
- package/skills/daily/templates/daily.md.tpl +0 -36
- package/skills/journey-audit/SKILL.md +0 -270
- package/skills/skill-creator/SKILL.md +0 -168
- package/skills/ubiquitous-language/SKILL.md +0 -97
- package/skills/vault-sync/package-lock.json +0 -40
- /package/skills/{domain-model → architecture/references}/ADR-FORMAT.md +0 -0
- /package/skills/{domain-model → architecture/references}/CONTEXT-FORMAT.md +0 -0
|
@@ -81,11 +81,12 @@
|
|
|
81
81
|
* 1 — at least one failure (or usage error / unreadable root)
|
|
82
82
|
*/
|
|
83
83
|
|
|
84
|
-
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
84
|
+
import { readFileSync, readdirSync, statSync, existsSync, realpathSync } from 'node:fs';
|
|
85
85
|
import { join, extname, relative, basename, sep, resolve } from 'node:path';
|
|
86
86
|
import { execFileSync } from 'node:child_process';
|
|
87
87
|
import { argv } from 'node:process';
|
|
88
88
|
import { fileURLToPath } from 'node:url';
|
|
89
|
+
import { createRequire } from 'node:module';
|
|
89
90
|
// NOTE: the two host-local helper modules (../config/host-paths.mjs and
|
|
90
91
|
// ./confidential-names.mjs) are imported DYNAMICALLY inside
|
|
91
92
|
// getConfidentialNamePatterns(), NOT statically here. This scanner is a
|
|
@@ -93,7 +94,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
93
94
|
// "Reuse the same scanner") — the .husky/pre-commit E2E and any consumer that
|
|
94
95
|
// copies ONLY this file into a fresh tree would otherwise crash at module load
|
|
95
96
|
// with ERR_MODULE_NOT_FOUND, blocking clean commits. Dynamic import lets CP11 go
|
|
96
|
-
//
|
|
97
|
+
// inert (one WARN line, exit unchanged) when the helpers are absent while CP1–CP10
|
|
98
|
+
// run unchanged. That degrade is scoped to ERR_MODULE_NOT_FOUND ALONE (#1244) —
|
|
99
|
+
// every other CP11 load failure fails CLOSED; see getConfidentialNamePatterns().
|
|
97
100
|
|
|
98
101
|
// ---------------------------------------------------------------------------
|
|
99
102
|
// CLI / import-mode detection (#661)
|
|
@@ -103,7 +106,25 @@ import { fileURLToPath } from 'node:url';
|
|
|
103
106
|
// AND an importable library (the canonicalization helpers are unit-tested in
|
|
104
107
|
// isolation). When imported, the top-level scan + process.exit() must NOT run.
|
|
105
108
|
// `isMain` is true only when this file is the node entry point.
|
|
106
|
-
|
|
109
|
+
// REALPATH BOTH SIDES (#1244 / same class as the #1153 argv[1] main-guard finding).
|
|
110
|
+
// `fileURLToPath(import.meta.url)` is already canonicalized by Node's ESM loader,
|
|
111
|
+
// so a plain `resolve(argv[1])` comparison silently fails whenever the invocation
|
|
112
|
+
// path traverses a symlink — on macOS every `/tmp/...` path does (`/tmp` →
|
|
113
|
+
// `/private/tmp`). The failure mode is the worst one a security guard has: isMain
|
|
114
|
+
// stays false, runScan() never runs, and the process prints NOTHING and exits 0,
|
|
115
|
+
// i.e. a clean-looking pass that never scanned a byte. realpathSync both sides so
|
|
116
|
+
// the two spellings of the same file compare equal; if realpathSync throws (path
|
|
117
|
+
// gone, permission denied) fall back to the historical comparison.
|
|
118
|
+
function canonicalPath(p) {
|
|
119
|
+
try {
|
|
120
|
+
return realpathSync(p);
|
|
121
|
+
} catch {
|
|
122
|
+
return p;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const isMain =
|
|
126
|
+
argv[1] !== undefined &&
|
|
127
|
+
canonicalPath(resolve(argv[1])) === canonicalPath(fileURLToPath(import.meta.url));
|
|
107
128
|
|
|
108
129
|
// CLI: single positional arg required (only enforced when run directly).
|
|
109
130
|
const pluginRoot = argv[2];
|
|
@@ -568,8 +589,10 @@ function escapeRegex(s) {
|
|
|
568
589
|
// its cases red (#974).
|
|
569
590
|
//
|
|
570
591
|
// The dynamic-import degrade used by getConfidentialNamePatterns() is NOT
|
|
571
|
-
// available here. That helper degrades to `[]`
|
|
572
|
-
//
|
|
592
|
+
// available here. That helper degrades to `[]` for the standalone-copy case alone
|
|
593
|
+
// — CP11 goes inert, CP1–CP10 keep running, nothing leaks (every OTHER load
|
|
594
|
+
// failure there is now a counted FAIL, #1244). A failed REDACTION has the opposite
|
|
595
|
+
// failure direction:
|
|
573
596
|
// it prints confidential names verbatim into a PUBLIC GitHub-Actions log, which
|
|
574
597
|
// is precisely the exposure this function exists to prevent (Fix 1 below). The
|
|
575
598
|
// redaction sink must be unconditionally present, so it lives inline.
|
|
@@ -645,6 +668,78 @@ function redactSpans(line, patterns) {
|
|
|
645
668
|
return out;
|
|
646
669
|
}
|
|
647
670
|
|
|
671
|
+
/**
|
|
672
|
+
* The THREE modules getConfidentialNamePatterns() imports directly, as absolute
|
|
673
|
+
* URLs resolved against THIS file. Used only to classify an ERR_MODULE_NOT_FOUND:
|
|
674
|
+
* `err.url` carries the URL of the module that could not be found (measured on
|
|
675
|
+
* Node 24 — a relative specifier yields `url`, a bare package specifier yields
|
|
676
|
+
* none), so a miss on one of these three is the standalone single-file copy,
|
|
677
|
+
* while a miss anywhere DEEPER (a transitive of an in-tree helper, or a bare
|
|
678
|
+
* package) is a broken install that must fail CLOSED rather than go inert.
|
|
679
|
+
*/
|
|
680
|
+
const CP11_DIRECT_SIBLING_URLS = new Set(
|
|
681
|
+
['../config/host-paths.mjs', './confidential-names.mjs', '../owner-yaml.mjs'].map(
|
|
682
|
+
(spec) => new URL(spec, import.meta.url).href,
|
|
683
|
+
),
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* True when an ERR_MODULE_NOT_FOUND names one of this scanner's own three DIRECT
|
|
688
|
+
* helper imports — i.e. the documented standalone-vendoring shape. False for a
|
|
689
|
+
* transitive relative module or a bare package (no `err.url` at all), which is a
|
|
690
|
+
* broken in-tree install: CP11 then fails closed instead of silently returning
|
|
691
|
+
* zero patterns while names ARE configured.
|
|
692
|
+
*
|
|
693
|
+
* @param {{ url?: string }} err
|
|
694
|
+
* @returns {boolean}
|
|
695
|
+
*/
|
|
696
|
+
function isMissingDirectSibling(err) {
|
|
697
|
+
return typeof err?.url === 'string' && CP11_DIRECT_SIBLING_URLS.has(err.url);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Was `paths.confidential-names-file` actually written into owner.yaml?
|
|
702
|
+
*
|
|
703
|
+
* WHY THIS RE-READS THE FILE. `loadOwnerConfig()` reports an invalid OPTIONAL
|
|
704
|
+
* section only as `droppedSections: [{ section: 'paths', errors }]` and replaces
|
|
705
|
+
* `config.paths` with the DEFAULTS — the raw keys of the dropped section are not
|
|
706
|
+
* recoverable from its return value, and its `errors[]` name a key only for the
|
|
707
|
+
* per-key `paths.<key> must be a string` case (a `paths: 42` shape names none).
|
|
708
|
+
* So the one question CP11's verdict turns on — did the operator configure a
|
|
709
|
+
* names file AT ALL — has no answer in the loader's contract today. Re-parsing
|
|
710
|
+
* the file for that single key is the minimal derivation; widening the loader's
|
|
711
|
+
* return shape would touch every one of its callers.
|
|
712
|
+
*
|
|
713
|
+
* Only reached when the YAML already parsed once inside loadOwnerConfig (the
|
|
714
|
+
* dropped-section branch implies that), so `js-yaml` is resolvable here; 'unknown'
|
|
715
|
+
* is the defensive residue and makes the caller fail closed.
|
|
716
|
+
*
|
|
717
|
+
* Returns a CLASS, never the value: the configured path is host-local and this
|
|
718
|
+
* scanner's output is captured by a PUBLIC CI mirror (see the no-path rule above).
|
|
719
|
+
*
|
|
720
|
+
* @param {string} ownerYamlPath
|
|
721
|
+
* @returns {'configured'|'absent'|'unknown'}
|
|
722
|
+
*/
|
|
723
|
+
function rawConfidentialNamesKeyState(ownerYamlPath) {
|
|
724
|
+
let parsed;
|
|
725
|
+
try {
|
|
726
|
+
const yaml = createRequire(import.meta.url)('js-yaml');
|
|
727
|
+
parsed = yaml.load(readFileSync(ownerYamlPath, 'utf8'));
|
|
728
|
+
} catch {
|
|
729
|
+
return 'unknown';
|
|
730
|
+
}
|
|
731
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return 'unknown';
|
|
732
|
+
const paths = parsed.paths;
|
|
733
|
+
// `paths:` absent, null, or not a mapping at all → the key cannot be in there.
|
|
734
|
+
if (paths === null || typeof paths !== 'object' || Array.isArray(paths)) return 'absent';
|
|
735
|
+
const value = paths['confidential-names-file'];
|
|
736
|
+
if (value === undefined || value === null) return 'absent';
|
|
737
|
+
// A non-string (or empty-string) value is still an ATTEMPT to configure CP11 —
|
|
738
|
+
// except '' , which is the schema's documented "no override" spelling.
|
|
739
|
+
if (typeof value === 'string' && value.trim() === '') return 'absent';
|
|
740
|
+
return 'configured';
|
|
741
|
+
}
|
|
742
|
+
|
|
648
743
|
/**
|
|
649
744
|
* CP11: build word-boundary, case-insensitive regexes from the host-local
|
|
650
745
|
* confidential-names list (#728a). Mirrors CP6_PATTERNS (private slugs), but the
|
|
@@ -667,24 +762,167 @@ function redactSpans(line, patterns) {
|
|
|
667
762
|
* with ERR_MODULE_NOT_FOUND. When the helpers are unresolvable (or throw), CP11
|
|
668
763
|
* degrades to inert ([] patterns) and CP1–CP10 run unchanged.
|
|
669
764
|
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
765
|
+
* FAIL CLOSED WHEN CP11 WAS EXPECTED (GitLab #1244). The single bare
|
|
766
|
+
* `try { … } catch { return [] }` this function used to be conflated three
|
|
767
|
+
* outcomes that must not share a verdict, and printed `PASS` for all three:
|
|
768
|
+
* (a) the helpers are unresolvable — the STANDALONE single-file copy. The
|
|
769
|
+
* intended degrade, and the ONLY one: inert + one WARN line, exit unchanged.
|
|
770
|
+
* (b) CP11 is not configured at all (no names file, no env) — the ~99% default:
|
|
771
|
+
* inactive, silent, PASS. Unchanged. This INCLUDES an owner.yaml whose
|
|
772
|
+
* `paths:` section was dropped as invalid for some OTHER key (the #1244
|
|
773
|
+
* fix over-reached here and failed every commit on such a host): the raw
|
|
774
|
+
* key is re-read (rawConfidentialNamesKeyState) and, when absent, CP11 is
|
|
775
|
+
* INACTIVE — one WARN naming the dropped section, no FAIL, exit unchanged.
|
|
776
|
+
* (c) CP11 IS configured — env set, a names file resolved, or the raw
|
|
777
|
+
* `paths.confidential-names-file` key present in a `paths:` section that
|
|
778
|
+
* was dropped as invalid — or its configuration is unknowable because
|
|
779
|
+
* owner.yaml exists but cannot be parsed (e.g. `js-yaml` missing) — and
|
|
780
|
+
* could not be loaded. Previously indistinguishable from (b): the scanner matched
|
|
781
|
+
* nothing and printed `PASS: no owner-privacy leakage found`. A guard that
|
|
782
|
+
* cannot run must say so and FAIL, never report the clean verdict it did
|
|
783
|
+
* not earn — so this returns a `disabledReason` that runScan turns into a
|
|
784
|
+
* `CP11 DISABLED` stderr line plus a counted FAIL (exit 1).
|
|
785
|
+
*
|
|
786
|
+
* The reason strings deliberately carry NO PATH. This scanner's stdout+stderr are
|
|
787
|
+
* captured by a PUBLIC GitHub-Actions mirror, and the confidential-names path is
|
|
788
|
+
* host-local — echoing it there would leak the very `/Users/<name>/…` shape CP1
|
|
789
|
+
* exists to block. The operator knows their own path; the CLASS of failure is what
|
|
790
|
+
* this line has to convey.
|
|
791
|
+
*
|
|
792
|
+
* @returns {Promise<{ patterns: RegExp[], disabledReason?: string, inertWarn?: string }>}
|
|
672
793
|
*/
|
|
673
794
|
async function getConfidentialNamePatterns() {
|
|
795
|
+
let helpers;
|
|
796
|
+
try {
|
|
797
|
+
helpers = {
|
|
798
|
+
hostPaths: await import('../config/host-paths.mjs'),
|
|
799
|
+
confidentialNames: await import('./confidential-names.mjs'),
|
|
800
|
+
// Already a transitive dependency (host-paths.mjs imports it statically), so
|
|
801
|
+
// this adds no file to the standalone-copy chain the husky E2E mirrors.
|
|
802
|
+
ownerYaml: await import('../owner-yaml.mjs'),
|
|
803
|
+
};
|
|
804
|
+
} catch (err) {
|
|
805
|
+
if (err?.code === 'ERR_MODULE_NOT_FOUND' && isMissingDirectSibling(err)) {
|
|
806
|
+
// (a) standalone single-file vendoring — the documented degrade.
|
|
807
|
+
return {
|
|
808
|
+
patterns: [],
|
|
809
|
+
inertWarn: 'CP11 inert — confidential-names helpers not resolvable (standalone copy)',
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
// Any OTHER import failure is a broken in-tree install, not the vendoring case.
|
|
813
|
+
return { patterns: [], disabledReason: `confidential-names helpers failed to load (${err?.name ?? 'Error'})` };
|
|
814
|
+
}
|
|
815
|
+
|
|
674
816
|
try {
|
|
675
|
-
const { loadHostPaths, resolveHostPath } =
|
|
676
|
-
const { loadConfidentialNames } =
|
|
677
|
-
const
|
|
678
|
-
|
|
817
|
+
const { loadHostPaths, resolveHostPath } = helpers.hostPaths;
|
|
818
|
+
const { loadConfidentialNames } = helpers.confidentialNames;
|
|
819
|
+
const { loadOwnerConfig, resolveOwnerYamlPath } = helpers.ownerYaml;
|
|
820
|
+
|
|
821
|
+
// Load owner.yaml ONCE and hand the same result to loadHostPaths, so the
|
|
822
|
+
// env>owner.yaml>default precedence is unchanged while the load's own health
|
|
823
|
+
// (reason / droppedSections) stays visible here.
|
|
824
|
+
const owner = loadOwnerConfig();
|
|
825
|
+
const namesPath = resolveHostPath('confidential-names-file', '', loadHostPaths({ ownerLoader: () => owner }));
|
|
826
|
+
|
|
827
|
+
if (typeof namesPath !== 'string' || namesPath.trim() === '') {
|
|
828
|
+
// Nothing resolves a names file. That is either (b) — genuinely
|
|
829
|
+
// unconfigured — or a state in which the answer is UNKNOWABLE because the
|
|
830
|
+
// owner.yaml that would carry it could not be read. Unknowable is (c).
|
|
831
|
+
if (existsSync(resolveOwnerYamlPath())) {
|
|
832
|
+
if (owner.reason === 'yaml-parser-missing') {
|
|
833
|
+
return {
|
|
834
|
+
patterns: [],
|
|
835
|
+
disabledReason:
|
|
836
|
+
"owner.yaml exists but 'js-yaml' is not installed, so a configured confidential-names-file cannot be resolved (run 'npm install')",
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
if (owner.reason === 'unparseable') {
|
|
840
|
+
return {
|
|
841
|
+
patterns: [],
|
|
842
|
+
disabledReason:
|
|
843
|
+
'owner.yaml exists but could not be parsed, so a configured confidential-names-file cannot be resolved',
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
if (owner.droppedSections?.some((d) => d.section === 'paths')) {
|
|
847
|
+
// The paths: section was replaced by its default because SOME key in it
|
|
848
|
+
// is invalid — which says nothing yet about whether CP11 was configured.
|
|
849
|
+
// Re-read the RAW file for that one key (the loader does not expose it on
|
|
850
|
+
// this branch; see rawConfidentialNamesKeyState) and only then decide.
|
|
851
|
+
const rawKey = rawConfidentialNamesKeyState(resolveOwnerYamlPath());
|
|
852
|
+
if (rawKey === 'configured') {
|
|
853
|
+
return {
|
|
854
|
+
patterns: [],
|
|
855
|
+
disabledReason:
|
|
856
|
+
'owner.yaml has an invalid paths: section, so a configured confidential-names-file cannot be resolved',
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
if (rawKey === 'unknown') {
|
|
860
|
+
return {
|
|
861
|
+
patterns: [],
|
|
862
|
+
disabledReason:
|
|
863
|
+
'owner.yaml has an invalid paths: section and could not be re-read, so a configured confidential-names-file cannot be ruled out',
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
// 'absent' — CP11 was never configured here. Inactive, not disabled:
|
|
867
|
+
// ONE WARN naming the dropped section, no FAIL, exit unchanged.
|
|
868
|
+
return {
|
|
869
|
+
patterns: [],
|
|
870
|
+
inertWarn:
|
|
871
|
+
'CP11 inactive — owner.yaml\'s paths: section was dropped as invalid, but it configures no confidential-names-file',
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
return { patterns: [] }; // (b) the ~99% default — inactive, silent.
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// A names file IS configured. loadConfidentialNames() returns null for BOTH
|
|
879
|
+
// "unusable" and "deliberately empty list", so classify the file first — an
|
|
880
|
+
// empty list is an operator choice (inactive, silent), a missing/malformed
|
|
881
|
+
// one is (c).
|
|
882
|
+
const unusable = classifyNamesFile(namesPath);
|
|
883
|
+
if (unusable) return { patterns: [], disabledReason: unusable };
|
|
884
|
+
|
|
679
885
|
const names = loadConfidentialNames({ namesPath });
|
|
680
|
-
if (!names) return [];
|
|
681
|
-
return names.map((name) => new RegExp(`\\b${escapeRegex(name)}\\b`, 'i'));
|
|
682
|
-
} catch {
|
|
683
|
-
//
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
886
|
+
if (!names) return { patterns: [] }; // readable, well-formed, zero usable entries.
|
|
887
|
+
return { patterns: names.map((name) => new RegExp(`\\b${escapeRegex(name)}\\b`, 'i')) };
|
|
888
|
+
} catch (err) {
|
|
889
|
+
// The helpers resolved but something below threw. CP11 cannot run — fail closed.
|
|
890
|
+
return { patterns: [], disabledReason: `confidential-names resolution failed (${err?.name ?? 'Error'})` };
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Classify a CONFIGURED confidential-names file as usable or not.
|
|
896
|
+
*
|
|
897
|
+
* `loadConfidentialNames()` collapses "file missing / unreadable / malformed /
|
|
898
|
+
* not an array" and "well-formed but empty" into one `null` return, which is
|
|
899
|
+
* exactly the distinction CP11's fail-closed verdict turns on. Rather than widen
|
|
900
|
+
* that module's contract (it has four other consumers of its `null`), this reads
|
|
901
|
+
* the file once more for classification only. It never returns file CONTENT — the
|
|
902
|
+
* reason string carries the error CLASS alone, matching the confidential-names
|
|
903
|
+
* privacy invariant and the no-path rule above.
|
|
904
|
+
*
|
|
905
|
+
* @param {string} namesPath
|
|
906
|
+
* @returns {string|null} a reason string when unusable, null when usable.
|
|
907
|
+
*/
|
|
908
|
+
function classifyNamesFile(namesPath) {
|
|
909
|
+
let raw;
|
|
910
|
+
try {
|
|
911
|
+
if (!existsSync(namesPath)) {
|
|
912
|
+
return 'a confidential-names-file is configured but does not exist';
|
|
913
|
+
}
|
|
914
|
+
raw = readFileSync(namesPath, 'utf8');
|
|
915
|
+
} catch (err) {
|
|
916
|
+
return `the configured confidential-names-file is unreadable (${err?.name ?? 'Error'})`;
|
|
917
|
+
}
|
|
918
|
+
try {
|
|
919
|
+
if (!Array.isArray(JSON.parse(raw))) {
|
|
920
|
+
return 'the configured confidential-names-file is not a JSON array';
|
|
921
|
+
}
|
|
922
|
+
} catch (err) {
|
|
923
|
+
return `the configured confidential-names-file contains malformed JSON (${err?.name ?? 'Error'})`;
|
|
687
924
|
}
|
|
925
|
+
return null;
|
|
688
926
|
}
|
|
689
927
|
|
|
690
928
|
// ---------------------------------------------------------------------------
|
|
@@ -945,7 +1183,24 @@ const scanFiles = textFiles.filter((f) => {
|
|
|
945
1183
|
// unresolvable (standalone single-file copy) → the CP11 block below is a no-op.
|
|
946
1184
|
// Awaited once, before the per-line loop, because the helpers are now dynamically
|
|
947
1185
|
// imported (standalone-safe) — CP1–CP10 behaviour is unchanged.
|
|
948
|
-
|
|
1186
|
+
//
|
|
1187
|
+
// #1244: a CP11 that was EXPECTED but could not load is a DISABLED guard, not a
|
|
1188
|
+
// clean scan — it is announced on stderr and counted as a FAIL so the run exits
|
|
1189
|
+
// non-zero. CP1–CP10 still run to completion either way: a disabled CP11 must not
|
|
1190
|
+
// suppress the findings the other ten rules can still make.
|
|
1191
|
+
const cp11 = await getConfidentialNamePatterns();
|
|
1192
|
+
const cp11Patterns = cp11.patterns;
|
|
1193
|
+
if (cp11.inertWarn) {
|
|
1194
|
+
console.error(`WARN: ${cp11.inertWarn}`);
|
|
1195
|
+
}
|
|
1196
|
+
if (cp11.disabledReason) {
|
|
1197
|
+
console.error(`CP11 DISABLED: ${cp11.disabledReason}`);
|
|
1198
|
+
// Shaped like the per-file FAIL lines (`<where> — <CPn label>: <content>`) so the
|
|
1199
|
+
// report's `— CPn` attribution parser sees CP11 here too; `<scan-wide>` stands in
|
|
1200
|
+
// for the file position because a disabled guard is a property of the RUN, not of
|
|
1201
|
+
// any one file. The reason carries no path (see getConfidentialNamePatterns).
|
|
1202
|
+
fail(`<scan-wide> — CP11 (guard disabled): ${cp11.disabledReason}`);
|
|
1203
|
+
}
|
|
949
1204
|
|
|
950
1205
|
/** @type {Array<{relPath: string, lineNum: number, pattern: string, lineContent: string}>} */
|
|
951
1206
|
const violations = [];
|
|
@@ -1089,7 +1344,13 @@ for (const filePath of scanFiles) {
|
|
|
1089
1344
|
// The spec does not say to deduplicate, so keep as-is.
|
|
1090
1345
|
|
|
1091
1346
|
if (violations.length === 0) {
|
|
1092
|
-
|
|
1347
|
+
// #1244: only claim the clean verdict the run actually earned. With CP11
|
|
1348
|
+
// disabled, ten of eleven rules ran — say that instead of "no leakage found".
|
|
1349
|
+
if (cp11.disabledReason) {
|
|
1350
|
+
console.log(` (CP1–CP10 found no leakage across ${scanFiles.length} scanned files; CP11 did not run)`);
|
|
1351
|
+
} else {
|
|
1352
|
+
pass(`no owner-privacy leakage found across ${scanFiles.length} scanned files`);
|
|
1353
|
+
}
|
|
1093
1354
|
} else {
|
|
1094
1355
|
for (const v of violations) {
|
|
1095
1356
|
// Choke-point redaction (Fix 1 + Fix 2): scrub every configured confidential
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-skill-links.mjs — every relative markdown link under an instruction surface must resolve.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS (measured 2026-09-06, session main-2026-09-06-deep-1):
|
|
6
|
+
* The #1157 `references/` splits moved 16 phase blocks out of three oversized skill bodies and
|
|
7
|
+
* verified each move with a sha256 of the moved block plus a full-reconstruction hash. That method
|
|
8
|
+
* proves the CONTENT is unchanged — and is blind by construction to the one defect class a move
|
|
9
|
+
* creates: a relative link whose correctness depends on the file's DEPTH in the tree.
|
|
10
|
+
*
|
|
11
|
+
* Three links broke and no gate saw it. The worst was
|
|
12
|
+
* `skills/session-end/references/phase-3-documentation-updates.md` → `./phase-3-6-tail.md`, which is
|
|
13
|
+
* the dispatcher for the six session-end tail phases (memory proposals, expired-learnings sweep,
|
|
14
|
+
* auto-dream, skill judge, auto-dialectic, reconciliation). From inside `references/` that path
|
|
15
|
+
* resolves one directory too deep; the target sits a level up. A coordinator following the prose
|
|
16
|
+
* would have found nothing there and silently skipped the tail.
|
|
17
|
+
*
|
|
18
|
+
* The existing neighbours cannot cover this: `check-skill-script-paths.mjs` scans only
|
|
19
|
+
* `scripts/**.mjs|.sh` and `hooks/**.sh` TARGETS, and `claude-md-drift-check` counts dead script
|
|
20
|
+
* citations, not intra-surface markdown links. Different predicate, different blind spot.
|
|
21
|
+
*
|
|
22
|
+
* WHAT IT CHECKS
|
|
23
|
+
* For every `.md` under the scanned surfaces, every inline link `[text](target)` whose target is
|
|
24
|
+
* relative (not http(s):, not mailto:, not `#anchor`, not an absolute path) must exist on disk,
|
|
25
|
+
* resolved against the LINKING FILE's own directory. A `#fragment` suffix is stripped before the
|
|
26
|
+
* existence check — anchors are out of scope (no heading index here); the path half is not.
|
|
27
|
+
*
|
|
28
|
+
* DELIBERATE NON-CHECKS
|
|
29
|
+
* - Link text, anchors, and http(s) reachability (a network check in a validator is a flake).
|
|
30
|
+
* - Reference-style links and bare `<...>` autolinks: not used by this repo's instruction files
|
|
31
|
+
* (measured: 0 occurrences). If one appears, this checker stays silent rather than guessing —
|
|
32
|
+
* recorded here so the gap is known rather than assumed absent.
|
|
33
|
+
* - Fenced code blocks AND inline-code spans are skipped: a path inside an example command or
|
|
34
|
+
* inside backticks is illustrative, not a link. The inline-code carve-out is not cosmetic —
|
|
35
|
+
* `skills/memory-cleanup/SKILL.md:163` documents the MEMORY.md index FORMAT as
|
|
36
|
+
* `` `- [Title](file.md) — hook` ``, and without it that literal template is the checker's
|
|
37
|
+
* only "finding", i.e. the guard's first act would be to demand a doc be made wrong.
|
|
38
|
+
*
|
|
39
|
+
* Exit 0 = every relative link resolves. Exit 1 = at least one does not; each is printed as
|
|
40
|
+
* `file:line target` so it can be fixed without a search.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
44
|
+
import { join, dirname, resolve, relative, sep } from 'node:path';
|
|
45
|
+
|
|
46
|
+
/** Surfaces whose markdown is instruction, i.e. read and acted on. */
|
|
47
|
+
export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents', '.claude/rules']);
|
|
48
|
+
|
|
49
|
+
/** Path segments that end the walk: vendored or machine-owned trees, never instruction. */
|
|
50
|
+
export const PRUNE_DIRS = new Set(['node_modules', '.git', 'coverage', 'dist', '.pnpm']);
|
|
51
|
+
|
|
52
|
+
const SKIP_TARGET = /^(https?:|mailto:|#|\/)/i;
|
|
53
|
+
const LINK_RE = /\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
|
|
54
|
+
const INLINE_CODE_RE = /(`+)[^`]*?\1/g;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Blank out inline-code spans, preserving column count so a reported line number still lines up.
|
|
58
|
+
* A `[x](y)` inside backticks is quoted TEXT, not a link — see DELIBERATE NON-CHECKS above.
|
|
59
|
+
*/
|
|
60
|
+
function stripInlineCode(line) {
|
|
61
|
+
return line.replace(INLINE_CODE_RE, (m) => ' '.repeat(m.length));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Does the path exist? A stat error (ENOENT, ELOOP, EACCES) is a non-resolving link, not a crash. */
|
|
65
|
+
function statOk(abs) {
|
|
66
|
+
try { statSync(abs); return true; } catch { return false; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Lines inside fenced code blocks — a path in an example is not a link. */
|
|
70
|
+
function fencedLineNumbers(text) {
|
|
71
|
+
const fenced = new Set();
|
|
72
|
+
let inFence = false;
|
|
73
|
+
text.split('\n').forEach((line, i) => {
|
|
74
|
+
if (/^\s*(```|~~~)/.test(line)) { inFence = !inFence; fenced.add(i + 1); return; }
|
|
75
|
+
if (inFence) fenced.add(i + 1);
|
|
76
|
+
});
|
|
77
|
+
return fenced;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Markdown files under the scanned surfaces, enumerated from the FILESYSTEM.
|
|
82
|
+
*
|
|
83
|
+
* Deliberately not `git ls-files`: that lists tracked files only, so a brand-new instruction file
|
|
84
|
+
* — the exact moment a split or a new skill lands — is invisible to the sweep until it is staged,
|
|
85
|
+
* and the check would report clean on the tree that carries the defect. This repo has the incident
|
|
86
|
+
* on record (`.claude/rules/measurement-discipline.md` § "A `git grep` drift sweep cannot see
|
|
87
|
+
* untracked files": a release sweep passed, then failed after the commit, from the same working
|
|
88
|
+
* tree with no edit in between). The cost of the filesystem walk is that a gitignored `.md` under
|
|
89
|
+
* these four directories would also be checked — there are none, and one would be a finding worth
|
|
90
|
+
* seeing anyway.
|
|
91
|
+
*
|
|
92
|
+
* @returns {string[]} repo-relative paths, sorted for stable output
|
|
93
|
+
*/
|
|
94
|
+
export function listMarkdown(repoRoot) {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const dir of SCAN_DIRS) {
|
|
97
|
+
const abs = join(repoRoot, dir);
|
|
98
|
+
let entries;
|
|
99
|
+
try { entries = readdirSync(abs, { recursive: true, withFileTypes: true }); } catch { continue; }
|
|
100
|
+
for (const e of entries) {
|
|
101
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue;
|
|
102
|
+
// parentPath is absolute; make the record repo-relative with POSIX separators.
|
|
103
|
+
const rel = relative(repoRoot, join(e.parentPath ?? abs, e.name));
|
|
104
|
+
const posix = sep === '/' ? rel : rel.split(sep).join('/');
|
|
105
|
+
// Vendored trees are not an instruction surface: `skills/vault-sync/node_modules/` is real
|
|
106
|
+
// and its bundled READMEs link into their own upstream repo layout, which is not ours to fix.
|
|
107
|
+
if (posix.split('/').some((s) => PRUNE_DIRS.has(s))) continue;
|
|
108
|
+
out.push(posix);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out.sort();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @returns {{ok: boolean, checked: number, files: number, findings: Array<{file: string, line: number, target: string}>}}
|
|
116
|
+
*/
|
|
117
|
+
export function checkSkillLinks(repoRoot = process.cwd()) {
|
|
118
|
+
const findings = [];
|
|
119
|
+
let checked = 0;
|
|
120
|
+
const files = listMarkdown(repoRoot);
|
|
121
|
+
|
|
122
|
+
for (const rel of files) {
|
|
123
|
+
const abs = join(repoRoot, rel);
|
|
124
|
+
let text;
|
|
125
|
+
try { text = readFileSync(abs, 'utf8'); } catch { continue; }
|
|
126
|
+
const fenced = fencedLineNumbers(text);
|
|
127
|
+
const baseDir = dirname(abs);
|
|
128
|
+
|
|
129
|
+
text.split('\n').forEach((line, idx) => {
|
|
130
|
+
const lineNo = idx + 1;
|
|
131
|
+
if (fenced.has(lineNo)) return;
|
|
132
|
+
for (const m of stripInlineCode(line).matchAll(LINK_RE)) {
|
|
133
|
+
const raw = m[1];
|
|
134
|
+
if (!raw || SKIP_TARGET.test(raw)) continue;
|
|
135
|
+
const target = raw.split('#')[0];
|
|
136
|
+
if (!target) continue; // pure fragment
|
|
137
|
+
checked += 1;
|
|
138
|
+
const resolved = resolve(baseDir, target);
|
|
139
|
+
// Never let a link escape the repo: an out-of-tree target is a finding, not a pass.
|
|
140
|
+
const inside = !relative(repoRoot, resolved).startsWith('..');
|
|
141
|
+
if (!inside || !statOk(resolved)) findings.push({ file: rel, line: lineNo, target: raw });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { ok: findings.length === 0, checked, files: files.length, findings };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function main() {
|
|
150
|
+
const repoRoot = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
|
|
151
|
+
const { ok, checked, files, findings } = checkSkillLinks(repoRoot);
|
|
152
|
+
if (!ok) {
|
|
153
|
+
for (const f of findings) {
|
|
154
|
+
process.stderr.write(` FAIL: ${f.file}:${f.line} → ${f.target} — relative link does not resolve from this file's directory\n`);
|
|
155
|
+
}
|
|
156
|
+
process.stderr.write(`Results: ${findings.length} unresolved relative link(s) in ${files} markdown file(s) under ${SCAN_DIRS.join(', ')}\n`);
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
// Two-space indent: validate-plugin.mjs tallies on /^ {2}(PASS|FAIL):/m.
|
|
160
|
+
process.stdout.write(` PASS: ${checked} relative link(s) in ${files} markdown file(s) resolve\n`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (import.meta.url === `file://${process.argv[1]}`) main();
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Check: every `scripts/**.mjs` path cited in `skills/`, `commands
|
|
4
|
-
* `agents/` either EXISTS or is annotated as deliberately absent
|
|
5
|
-
* Extended (#1187) to also cite `scripts/**.sh` and `hooks/**.sh` —
|
|
6
|
-
* "## Mode: BLOCKING for `.mjs`, ADVISORY for `.sh`" below for why that
|
|
7
|
-
* is advisory, not blocking.
|
|
3
|
+
* Check: every `scripts/**.mjs` path cited in `skills/`, `commands/`,
|
|
4
|
+
* `agents/` and `docs/` either EXISTS or is annotated as deliberately absent
|
|
5
|
+
* (#1176). Extended (#1187) to also cite `scripts/**.sh` and `hooks/**.sh` —
|
|
6
|
+
* see "## Mode: BLOCKING for `.mjs`, ADVISORY for `.sh`" below for why that
|
|
7
|
+
* half is advisory, not blocking. `docs/` joined `SCAN_DIRS` in #1208, after
|
|
8
|
+
* the 22 dead paths it carried at the time (9 `.mjs`, all ADR/reference
|
|
9
|
+
* prose) were annotated — see that section below for the census and why
|
|
10
|
+
* widening the scan root had to wait for the annotation pass, not precede it.
|
|
8
11
|
*
|
|
9
12
|
* ## Why
|
|
10
13
|
*
|
|
@@ -12,8 +15,8 @@
|
|
|
12
15
|
* `node scripts/lib/auto-commit.mjs` costs an operator a failed command and a
|
|
13
16
|
* re-derivation of what the file was supposed to do — and nothing in the
|
|
14
17
|
* corpus notices, because a markdown file compiles under every gate. Measured
|
|
15
|
-
* 2026-09-02 @ c3ab480: 237 distinct citations across the three scan
|
|
16
|
-
* 7 of them dead.
|
|
18
|
+
* 2026-09-02 @ c3ab480: 237 distinct citations across the (then three) scan
|
|
19
|
+
* roots, 7 of them dead.
|
|
17
20
|
*
|
|
18
21
|
* ## Fences are skipped, and that is most of the answer
|
|
19
22
|
*
|
|
@@ -58,27 +61,37 @@
|
|
|
58
61
|
* The `.sh` half of the citation grammar (below) does not get that same
|
|
59
62
|
* severity by default. A #1176 repo-wide grep (`scripts/hooks` prose across
|
|
60
63
|
* `skills/commands/agents/docs/hooks`) found 27 distinct `.sh` citations, 21
|
|
61
|
-
* dead — but only ONE of those 27
|
|
62
|
-
* (`skills/contract-version-bump/SKILL.md:134`,
|
|
63
|
-
* cross-repo path — see the dry-run note at
|
|
64
|
-
* `strictSh` option). The other 26
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
64
|
+
* dead — but at the time only ONE of those 27 sat inside this checker's
|
|
65
|
+
* (then three) scan roots (`skills/contract-version-bump/SKILL.md:134`,
|
|
66
|
+
* itself arguably a cross-repo path — see the dry-run note at
|
|
67
|
+
* `scanSkillScriptPaths`'s `strictSh` option). The other 26 lived in `docs/`,
|
|
68
|
+
* which this checker did not yet scan.
|
|
69
|
+
*
|
|
70
|
+
* #1208 closed that gap in two steps, annotation before widening rather than
|
|
71
|
+
* the reverse: first, a `dirs: ['docs']` re-scan (530 citations, 66 files)
|
|
72
|
+
* found 50 findings — 22 unique dead paths (9 `.mjs`, 15 `.sh`) across
|
|
73
|
+
* 24 (file, path) pairs, concentrated in `docs/adr/*.md` (ADR prose citing
|
|
74
|
+
* not-yet-built modules like `scripts/lib/tool-adapter.mjs`) and
|
|
75
|
+
* `docs/changelog/v2.md` (23 `.sh` citations to the pre-`.mjs`-migration
|
|
76
|
+
* shell scripts, #218/#317 — historical by construction). Every one of the
|
|
77
|
+
* 22 was annotated (`planned #<iid>` for the ADR gaps, `historical` for the
|
|
78
|
+
* changelog, `example` for the one illustrative path in
|
|
79
|
+
* `docs/scope-collision-guard.md`) — zero of them were real defects. Only
|
|
80
|
+
* then did `SCAN_DIRS` gain `'docs'`, so the widening added zero new
|
|
81
|
+
* BLOCKING findings on arrival (re-verify: `scanSkillScriptPaths({
|
|
82
|
+
* pluginRoot, dirs: ['docs'] })` → `ok: true`, `findings: 0`). The wider
|
|
83
|
+
* `hooks/` `.sh` prose census (26 of the 27 `.sh` citations above are outside
|
|
84
|
+
* `SCAN_DIRS` even now, since `hooks/` prose itself is not a scanned root)
|
|
85
|
+
* remains a follow-up for whoever owns those files.
|
|
75
86
|
*
|
|
76
87
|
* A `.sh` finding is therefore `WARN:` by default (visible, never blocking —
|
|
77
88
|
* `ok` and the CLI exit code ignore `severity: 'warn'` findings) and only
|
|
78
89
|
* becomes `FAIL:`/blocking under the `--strict-sh` CLI flag (or
|
|
79
90
|
* `strictSh: true` for `scanSkillScriptPaths()` callers) — flip that default
|
|
80
91
|
* once the dead `.sh` citations this checker CAN see are fixed by their doc
|
|
81
|
-
* owner (BV-004 revisit trigger).
|
|
92
|
+
* owner (BV-004 revisit trigger). `--strict-sh` gained a validate-plugin run
|
|
93
|
+
* surface in #1208 (advisory, non-blocking — see `scripts/validate-plugin.mjs`
|
|
94
|
+
* near its `check-skill-script-paths.mjs` call).
|
|
82
95
|
*
|
|
83
96
|
* @module scripts/lib/validate/check-skill-script-paths
|
|
84
97
|
*/
|
|
@@ -86,11 +99,11 @@
|
|
|
86
99
|
import { existsSync, readFileSync } from 'node:fs';
|
|
87
100
|
import path from 'node:path';
|
|
88
101
|
import { pathToFileURL } from 'node:url';
|
|
89
|
-
import {
|
|
102
|
+
import { enumerateRepoFiles } from './enumerate-repo-files.mjs';
|
|
90
103
|
import { forEachLine } from './markdown-fences.mjs';
|
|
91
104
|
|
|
92
105
|
/** Documentation roots whose prose is treated as a claim about the repo. */
|
|
93
|
-
export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents']);
|
|
106
|
+
export const SCAN_DIRS = Object.freeze(['skills', 'commands', 'agents', 'docs']);
|
|
94
107
|
|
|
95
108
|
/**
|
|
96
109
|
* A cited script path. One regex, one alternation, reused for every
|
|
@@ -246,10 +259,16 @@ export function scanSkillScriptPaths({ pluginRoot, dirs = SCAN_DIRS, strictSh =
|
|
|
246
259
|
/** @type {string[]} */
|
|
247
260
|
let files;
|
|
248
261
|
try {
|
|
249
|
-
// The
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
|
|
262
|
+
// The population is "exists in this repo, tracked or not" (#1248) — NOT
|
|
263
|
+
// "is versioned". A doc that cites a dead script is a defect the moment it
|
|
264
|
+
// is written; the bare git index cannot see it until it is staged, so the
|
|
265
|
+
// check reported clean on the exact tree carrying the bug (measured: an
|
|
266
|
+
// untracked `skills/zz-probe/SKILL.md` → `1 passed, 0 failed` before
|
|
267
|
+
// `git add -A`, `0 passed, 1 failed` after). `enumerateRepoFiles` still
|
|
268
|
+
// honours `.gitignore`, so the #1143 exposure a bare `readdirSync` walk
|
|
269
|
+
// would reintroduce (a worktree under `.claude/worktrees/`, gitignored
|
|
270
|
+
// `docs/specs/*.md`) stays closed — see that module's header.
|
|
271
|
+
files = enumerateRepoFiles({ repoRoot: pluginRoot, dirs, exts: ['.md'] });
|
|
253
272
|
} catch (error) {
|
|
254
273
|
findings.push({
|
|
255
274
|
kind: 'tool-error',
|
|
@@ -302,8 +302,6 @@ const ALLOWLIST = Object.freeze({
|
|
|
302
302
|
'prose-only consumer — skills/wave-executor/wave-loop.md gates the per-wave commit step on this key; the commit itself is a coordinator action, not a script',
|
|
303
303
|
'instruction-budget':
|
|
304
304
|
'dedicated reader outside the parser layer — scripts/lib/instruction-budget-guard.mjs parses this block itself (S2 exemption only; S1 evidence is real)',
|
|
305
|
-
webhooks:
|
|
306
|
-
'dedicated reader outside the parser layer — scripts/lib/webhook-url.mjs resolves these URLs env-first (S2 exemption only; S1 evidence is real)',
|
|
307
305
|
});
|
|
308
306
|
|
|
309
307
|
/**
|