continuous-improvement 3.20.0 → 3.20.4
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/QUICKSTART.md +1 -1
- package/README.md +2 -2
- package/bin/check-landing-version.mjs +63 -0
- package/bin/check-scripts-citation-drift.mjs +61 -13
- package/bin/generate-plugin-manifests.mjs +2 -0
- package/bin/install.mjs +19 -11
- package/commands/verify-install.md +1 -1
- package/hooks/gateguard.mjs +22 -3
- package/hooks/query-cost-nudge.mjs +1 -0
- package/hooks/typecheck-stop.mjs +2 -1
- package/package.json +5 -3
- package/plugins/beginner.json +1 -1
- package/plugins/continuous-improvement/.claude-plugin/marketplace.json +1 -1
- package/plugins/continuous-improvement/.claude-plugin/plugin.json +1 -1
- package/plugins/continuous-improvement/README.md +1 -0
- package/plugins/continuous-improvement/commands/verify-install.md +1 -1
- package/plugins/continuous-improvement/hooks/gateguard.mjs +22 -3
- package/plugins/continuous-improvement/hooks/query-cost-nudge.mjs +1 -0
- package/plugins/continuous-improvement/hooks/typecheck-stop.mjs +2 -1
- package/plugins/continuous-improvement/scripts/README.md +33 -0
- package/plugins/continuous-improvement/scripts/detect-deploy-target.sh +66 -0
- package/plugins/continuous-improvement/scripts/get-deployed-sha.sh +113 -0
- package/plugins/continuous-improvement/scripts/git-state-snapshot.sh +48 -0
- package/plugins/continuous-improvement/scripts/resolve-verify-ladder.mjs +241 -0
- package/plugins/continuous-improvement/scripts/route-recommendation.mjs +178 -0
- package/plugins/continuous-improvement/scripts/route-recommendation.routes.json +213 -0
- package/plugins/continuous-improvement/scripts/run-synthetic.mjs +298 -0
- package/plugins/continuous-improvement/scripts/scan-past-mistakes.mjs +285 -0
- package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +2 -2
- package/plugins/continuous-improvement/skills/gateguard/SKILL.md +2 -2
- package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +2 -2
- package/plugins/continuous-improvement/skills/reconcile/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/verification-loop/SKILL.md +5 -5
- package/plugins/continuous-improvement/skills/workspace-surface-audit/SKILL.md +1 -1
- package/plugins/continuous-improvement/skills/worktree-safety/SKILL.md +1 -1
- package/plugins/expert.json +1 -1
- package/scripts/README.md +33 -0
- package/scripts/detect-deploy-target.sh +66 -0
- package/scripts/get-deployed-sha.sh +113 -0
- package/scripts/git-state-snapshot.sh +48 -0
- package/scripts/resolve-verify-ladder.mjs +241 -0
- package/scripts/route-recommendation.mjs +178 -0
- package/scripts/route-recommendation.routes.json +213 -0
- package/scripts/run-synthetic.mjs +298 -0
- package/scripts/scan-past-mistakes.mjs +285 -0
- package/skills/deploy-receipt.md +2 -2
- package/skills/gateguard.md +2 -2
- package/skills/proceed-with-the-recommendation.md +2 -2
- package/skills/reconcile.md +1 -1
- package/skills/verification-loop.md +5 -5
- package/skills/workspace-surface-audit.md +1 -1
- package/skills/worktree-safety.md +1 -1
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scripts/detect-deploy-target.sh
|
|
3
|
+
#
|
|
4
|
+
# Detect the auto-deploy provider for the repo rooted at the current working
|
|
5
|
+
# directory (or the first argument, if supplied). Composability primitive used
|
|
6
|
+
# by skills and hooks that need to know "does this repo auto-deploy, and if so
|
|
7
|
+
# from where?" without each restating the file-marker table.
|
|
8
|
+
#
|
|
9
|
+
# Output: one of
|
|
10
|
+
# railway | cloudflare | vercel | netlify | fly | appengine | apprunner |
|
|
11
|
+
# gha-deploy | none
|
|
12
|
+
#
|
|
13
|
+
# Resolution priority (first match wins):
|
|
14
|
+
# 1. railway.toml | railway.json → railway
|
|
15
|
+
# 2. wrangler.toml | wrangler.jsonc → cloudflare
|
|
16
|
+
# 3. vercel.json | .vercel/ → vercel
|
|
17
|
+
# 4. netlify.toml → netlify
|
|
18
|
+
# 5. fly.toml → fly
|
|
19
|
+
# 6. app.yaml → appengine
|
|
20
|
+
# 7. apprunner.yaml → apprunner
|
|
21
|
+
# 8. .github/workflows/*.{yml,yaml} containing "deploy:" job → gha-deploy
|
|
22
|
+
# 9. nothing matched → none
|
|
23
|
+
#
|
|
24
|
+
# Always exits 0. `none` is a valid result, not an error condition.
|
|
25
|
+
#
|
|
26
|
+
# Cited by:
|
|
27
|
+
# - skills/verification-loop.md Phase 8 (deploy-receipt handoff trigger)
|
|
28
|
+
# - skills/deploy-receipt.md "When to Activate" gate
|
|
29
|
+
|
|
30
|
+
set -u
|
|
31
|
+
|
|
32
|
+
ROOT="${1:-$PWD}"
|
|
33
|
+
|
|
34
|
+
emit() {
|
|
35
|
+
printf '%s\n' "$1"
|
|
36
|
+
exit 0
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Order is the contract — earlier rows shadow later ones when multiple
|
|
40
|
+
# markers exist in the same repo.
|
|
41
|
+
[ -f "$ROOT/railway.toml" ] && emit railway
|
|
42
|
+
[ -f "$ROOT/railway.json" ] && emit railway
|
|
43
|
+
[ -f "$ROOT/wrangler.toml" ] && emit cloudflare
|
|
44
|
+
[ -f "$ROOT/wrangler.jsonc" ] && emit cloudflare
|
|
45
|
+
[ -f "$ROOT/vercel.json" ] && emit vercel
|
|
46
|
+
[ -d "$ROOT/.vercel" ] && emit vercel
|
|
47
|
+
[ -f "$ROOT/netlify.toml" ] && emit netlify
|
|
48
|
+
[ -f "$ROOT/fly.toml" ] && emit fly
|
|
49
|
+
[ -f "$ROOT/app.yaml" ] && emit appengine
|
|
50
|
+
[ -f "$ROOT/apprunner.yaml" ] && emit apprunner
|
|
51
|
+
|
|
52
|
+
# GitHub Actions deploy workflow: scan .github/workflows/*.{yml,yaml} for a
|
|
53
|
+
# job whose key is literally `deploy:`. Cheap and conservative — false
|
|
54
|
+
# positives (a non-deploy job named "deploy") are unlikely; false negatives
|
|
55
|
+
# (a deploy job named something else) require the repo to declare the
|
|
56
|
+
# provider via one of the file markers above instead.
|
|
57
|
+
if [ -d "$ROOT/.github/workflows" ]; then
|
|
58
|
+
if grep -lE '^[[:space:]]*deploy:[[:space:]]*$' \
|
|
59
|
+
"$ROOT/.github/workflows/"*.yml \
|
|
60
|
+
"$ROOT/.github/workflows/"*.yaml 2>/dev/null \
|
|
61
|
+
| head -n 1 | grep -q .; then
|
|
62
|
+
emit gha-deploy
|
|
63
|
+
fi
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
emit none
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scripts/get-deployed-sha.sh
|
|
3
|
+
#
|
|
4
|
+
# Print the currently-deployed commit SHA for an auto-deploy provider, or
|
|
5
|
+
# print the CLI command shape (with `--show-command`) without executing it.
|
|
6
|
+
# Composability primitive that owns the per-provider CLI knowledge so skills
|
|
7
|
+
# can cite one path instead of restating the 5-provider extraction table.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# bash scripts/get-deployed-sha.sh <provider>
|
|
11
|
+
# bash scripts/get-deployed-sha.sh --show-command <provider>
|
|
12
|
+
#
|
|
13
|
+
# Providers: railway | cloudflare | vercel | netlify | fly
|
|
14
|
+
#
|
|
15
|
+
# Default mode: runs the provider CLI, pipes through jq, prints the SHA on
|
|
16
|
+
# stdout. Requires the CLI to be installed and authenticated; exits 3 with a
|
|
17
|
+
# clear error if the CLI is missing.
|
|
18
|
+
#
|
|
19
|
+
# --show-command mode: prints the command pipeline that would run, without
|
|
20
|
+
# executing it. Useful for skill citations, dry-runs, and tests that should
|
|
21
|
+
# not require live CLI auth.
|
|
22
|
+
#
|
|
23
|
+
# Exit codes:
|
|
24
|
+
# 0 — SHA printed (default mode) or command printed (--show-command mode)
|
|
25
|
+
# 2 — missing or unknown provider (usage error)
|
|
26
|
+
# 3 — required CLI not installed (default mode only)
|
|
27
|
+
# non-zero — CLI failure (passed through)
|
|
28
|
+
#
|
|
29
|
+
# Cited by:
|
|
30
|
+
# - skills/verification-loop.md Phase 8 (deploy-receipt handoff trigger)
|
|
31
|
+
# - skills/deploy-receipt.md Route A (provider CLI extraction)
|
|
32
|
+
|
|
33
|
+
set -u
|
|
34
|
+
|
|
35
|
+
SHOW_COMMAND=false
|
|
36
|
+
PROVIDER=""
|
|
37
|
+
|
|
38
|
+
while [ $# -gt 0 ]; do
|
|
39
|
+
case "$1" in
|
|
40
|
+
--show-command)
|
|
41
|
+
SHOW_COMMAND=true
|
|
42
|
+
shift
|
|
43
|
+
;;
|
|
44
|
+
-h|--help)
|
|
45
|
+
sed -n '2,/^$/p' "$0" >&2
|
|
46
|
+
exit 0
|
|
47
|
+
;;
|
|
48
|
+
*)
|
|
49
|
+
if [ -z "$PROVIDER" ]; then
|
|
50
|
+
PROVIDER="$1"
|
|
51
|
+
else
|
|
52
|
+
printf 'usage: get-deployed-sha.sh [--show-command] <provider>\n' >&2
|
|
53
|
+
exit 2
|
|
54
|
+
fi
|
|
55
|
+
shift
|
|
56
|
+
;;
|
|
57
|
+
esac
|
|
58
|
+
done
|
|
59
|
+
|
|
60
|
+
if [ -z "$PROVIDER" ]; then
|
|
61
|
+
printf 'usage: get-deployed-sha.sh [--show-command] <provider>\n' >&2
|
|
62
|
+
exit 2
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# Single source of truth for the per-provider command pipeline. Each value is
|
|
66
|
+
# the literal pipeline that would run; the jq filter extracts the SHA.
|
|
67
|
+
case "$PROVIDER" in
|
|
68
|
+
railway)
|
|
69
|
+
CMD='railway status --json | jq -r .deployments[0].meta.commitHash'
|
|
70
|
+
CLI=railway
|
|
71
|
+
;;
|
|
72
|
+
cloudflare)
|
|
73
|
+
CMD='wrangler deployments list --json | jq -r .[0].metadata.deployment_trigger.metadata.commit_hash'
|
|
74
|
+
CLI=wrangler
|
|
75
|
+
;;
|
|
76
|
+
vercel)
|
|
77
|
+
CMD='vercel inspect "$(vercel ls --json | jq -r .[0].url)" --json | jq -r .gitSource.sha'
|
|
78
|
+
CLI=vercel
|
|
79
|
+
;;
|
|
80
|
+
netlify)
|
|
81
|
+
CMD='netlify api listSiteDeploys --data="{\"site_id\":\"$NETLIFY_SITE_ID\"}" | jq -r .[0].commit_ref'
|
|
82
|
+
CLI=netlify
|
|
83
|
+
;;
|
|
84
|
+
fly)
|
|
85
|
+
CMD='fly releases --json | jq -r .[0].commit_sha'
|
|
86
|
+
CLI=fly
|
|
87
|
+
;;
|
|
88
|
+
*)
|
|
89
|
+
printf 'unknown or unsupported provider: %s\n' "$PROVIDER" >&2
|
|
90
|
+
printf 'supported: railway | cloudflare | vercel | netlify | fly\n' >&2
|
|
91
|
+
exit 2
|
|
92
|
+
;;
|
|
93
|
+
esac
|
|
94
|
+
|
|
95
|
+
if [ "$SHOW_COMMAND" = "true" ]; then
|
|
96
|
+
printf '%s\n' "$CMD"
|
|
97
|
+
exit 0
|
|
98
|
+
fi
|
|
99
|
+
|
|
100
|
+
if ! command -v "$CLI" >/dev/null 2>&1; then
|
|
101
|
+
printf 'required CLI "%s" not installed for provider "%s"\n' "$CLI" "$PROVIDER" >&2
|
|
102
|
+
exit 3
|
|
103
|
+
fi
|
|
104
|
+
|
|
105
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
106
|
+
printf 'required CLI "jq" not installed\n' >&2
|
|
107
|
+
exit 3
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# Execute the pipeline. eval is intentional — the per-provider CMD contains
|
|
111
|
+
# pipes and command substitution that need shell interpretation. CMDs are
|
|
112
|
+
# sourced from the literal table above, not user input.
|
|
113
|
+
eval "$CMD"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# scripts/git-state-snapshot.sh
|
|
3
|
+
#
|
|
4
|
+
# Emit a single-line JSON envelope describing the current git working-tree
|
|
5
|
+
# state. Composability primitive used by skills that need to baseline or check
|
|
6
|
+
# git state without each restating the same 3-command triple.
|
|
7
|
+
#
|
|
8
|
+
# Fields:
|
|
9
|
+
# - head: short SHA returned by `git rev-parse --short HEAD`
|
|
10
|
+
# - upstream: short SHA of `@{u}` if the branch tracks an upstream, else "none"
|
|
11
|
+
# - dirty: integer count of lines from `git status --porcelain` (0 == clean)
|
|
12
|
+
# - root: absolute path from `git rev-parse --show-toplevel`
|
|
13
|
+
# - branch: `git symbolic-ref --short HEAD`, else "detached"
|
|
14
|
+
#
|
|
15
|
+
# Outside a git repository the script prints `{"error":"not-a-git-repo"}` and
|
|
16
|
+
# exits 1. All other failures are treated as a non-git-repo condition rather
|
|
17
|
+
# than emitting a partial envelope.
|
|
18
|
+
#
|
|
19
|
+
# Cited by:
|
|
20
|
+
# - skills/gateguard.md (Parallel-Actor Gate baseline + divergence)
|
|
21
|
+
# - skills/worktree-safety.md (Root + branch alignment)
|
|
22
|
+
# - skills/workspace-surface-audit.md (Environment Grain — parallel-actor row)
|
|
23
|
+
|
|
24
|
+
set -u
|
|
25
|
+
|
|
26
|
+
head=$(git rev-parse --short HEAD 2>/dev/null) || {
|
|
27
|
+
printf '{"error":"not-a-git-repo"}\n'
|
|
28
|
+
exit 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if upstream=$(git rev-parse --short '@{u}' 2>/dev/null); then
|
|
32
|
+
upstream_field=$(printf '"%s"' "$upstream")
|
|
33
|
+
else
|
|
34
|
+
upstream_field='"none"'
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
38
|
+
|
|
39
|
+
root=$(git rev-parse --show-toplevel 2>/dev/null || printf 'unknown')
|
|
40
|
+
|
|
41
|
+
if branch=$(git symbolic-ref --short HEAD 2>/dev/null); then
|
|
42
|
+
branch_field=$(printf '"%s"' "$branch")
|
|
43
|
+
else
|
|
44
|
+
branch_field='"detached"'
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
printf '{"head":"%s","upstream":%s,"dirty":%s,"root":"%s","branch":%s}\n' \
|
|
48
|
+
"$head" "$upstream_field" "$dirty" "$root" "$branch_field"
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/resolve-verify-ladder.mjs
|
|
3
|
+
//
|
|
4
|
+
// Resolve the per-project verification ladder for `verification-loop` Phase 0.
|
|
5
|
+
// Encodes the four-step resolution priority — manifest > package.json sniff >
|
|
6
|
+
// per-language toolchain > ask-operator — so the skill body cites this script
|
|
7
|
+
// instead of restating the 80+ lines of priority prose inline.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// node scripts/resolve-verify-ladder.mjs # pretty fenced block, cwd
|
|
11
|
+
// node scripts/resolve-verify-ladder.mjs <repo-root> # pretty, explicit root
|
|
12
|
+
// node scripts/resolve-verify-ladder.mjs --json # JSON for machines
|
|
13
|
+
// node scripts/resolve-verify-ladder.mjs --json <repo-root>
|
|
14
|
+
//
|
|
15
|
+
// Output (default — pretty fenced block):
|
|
16
|
+
//
|
|
17
|
+
// verify-ladder (resolved):
|
|
18
|
+
// build: npm run build (sniff:package.json:scripts.build)
|
|
19
|
+
// typecheck: npx tsc --noEmit (manifest)
|
|
20
|
+
// ...
|
|
21
|
+
//
|
|
22
|
+
// Output (--json — single JSON object on stdout):
|
|
23
|
+
//
|
|
24
|
+
// { "build": { "command": "npm run build", "source": "sniff:..." }, ... }
|
|
25
|
+
//
|
|
26
|
+
// Phases: build, typecheck, lint, test, security, deploy_receipt, synthetic_checks.
|
|
27
|
+
// Sources: "manifest", "manifest:null" (explicitly skipped), "sniff:package.json:<key>",
|
|
28
|
+
// "sniff:Cargo.toml" | "sniff:go.mod" | "sniff:pyproject.toml" | "sniff:Gemfile",
|
|
29
|
+
// "ask-operator" (nothing matched).
|
|
30
|
+
//
|
|
31
|
+
// Cited by:
|
|
32
|
+
// - skills/verification-loop.md Phase 0
|
|
33
|
+
|
|
34
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { argv, cwd, exit, stdout } from "node:process";
|
|
37
|
+
|
|
38
|
+
const PHASES = [
|
|
39
|
+
"build",
|
|
40
|
+
"typecheck",
|
|
41
|
+
"lint",
|
|
42
|
+
"test",
|
|
43
|
+
"security",
|
|
44
|
+
"deploy_receipt",
|
|
45
|
+
"synthetic_checks",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
function parseArgs() {
|
|
49
|
+
const args = argv.slice(2);
|
|
50
|
+
let json = false;
|
|
51
|
+
let root = cwd();
|
|
52
|
+
for (const a of args) {
|
|
53
|
+
if (a === "--json") {
|
|
54
|
+
json = true;
|
|
55
|
+
} else if (a === "-h" || a === "--help") {
|
|
56
|
+
stdout.write(
|
|
57
|
+
"usage: resolve-verify-ladder.mjs [--json] [<repo-root>]\n",
|
|
58
|
+
);
|
|
59
|
+
exit(0);
|
|
60
|
+
} else {
|
|
61
|
+
root = a;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { json, root };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeReadJson(path) {
|
|
68
|
+
if (!existsSync(path)) return null;
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readManifest(root) {
|
|
77
|
+
return safeReadJson(join(root, ".claude", "verify-ladder.json"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readPackageJson(root) {
|
|
81
|
+
return safeReadJson(join(root, "package.json"));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sniffPackageJsonScript(pkg, phase) {
|
|
85
|
+
if (!pkg || !pkg.scripts) return null;
|
|
86
|
+
const scripts = pkg.scripts;
|
|
87
|
+
// Priority: verify:<phase> > <phase> > <phase>:* (wildcard tail).
|
|
88
|
+
// Phase aliases:
|
|
89
|
+
// typecheck also matches `tsc` (common in repos that just use `tsc`).
|
|
90
|
+
// security also matches `audit` (common in npm projects).
|
|
91
|
+
const candidates = [`verify:${phase}`, phase];
|
|
92
|
+
if (phase === "typecheck") candidates.push("tsc");
|
|
93
|
+
if (phase === "security") candidates.push("audit");
|
|
94
|
+
|
|
95
|
+
for (const name of candidates) {
|
|
96
|
+
if (typeof scripts[name] === "string") {
|
|
97
|
+
const cmd = name === "test" ? "npm test" : `npm run ${name}`;
|
|
98
|
+
return {
|
|
99
|
+
command: cmd,
|
|
100
|
+
source: `sniff:package.json:scripts.${name}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Last resort: <phase>:* wildcard tail.
|
|
106
|
+
const wildcardKey = Object.keys(scripts).find((k) => k.startsWith(`${phase}:`));
|
|
107
|
+
if (wildcardKey) {
|
|
108
|
+
return {
|
|
109
|
+
command: `npm run ${wildcardKey}`,
|
|
110
|
+
source: `sniff:package.json:scripts.${wildcardKey}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const PER_LANGUAGE = [
|
|
118
|
+
{
|
|
119
|
+
marker: "Cargo.toml",
|
|
120
|
+
commands: {
|
|
121
|
+
build: "cargo build",
|
|
122
|
+
typecheck: "cargo check --all-targets",
|
|
123
|
+
lint: "cargo clippy --all-targets -- -D warnings",
|
|
124
|
+
test: "cargo test",
|
|
125
|
+
security: "cargo audit",
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
marker: "go.mod",
|
|
130
|
+
commands: {
|
|
131
|
+
build: "go build ./...",
|
|
132
|
+
typecheck: "go vet ./...",
|
|
133
|
+
lint: "go vet ./...",
|
|
134
|
+
test: "go test ./...",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
marker: "pyproject.toml",
|
|
139
|
+
commands: {
|
|
140
|
+
typecheck: "pyright",
|
|
141
|
+
lint: "ruff check .",
|
|
142
|
+
test: "pytest",
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
marker: "Gemfile",
|
|
147
|
+
commands: {
|
|
148
|
+
lint: "bundle exec rubocop",
|
|
149
|
+
test: "bundle exec rspec",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
function sniffPerLanguage(root, phase) {
|
|
155
|
+
for (const lang of PER_LANGUAGE) {
|
|
156
|
+
if (!existsSync(join(root, lang.marker))) continue;
|
|
157
|
+
if (lang.commands[phase]) {
|
|
158
|
+
return { command: lang.commands[phase], source: `sniff:${lang.marker}` };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function resolve(root) {
|
|
165
|
+
const manifest = readManifest(root);
|
|
166
|
+
const pkg = readPackageJson(root);
|
|
167
|
+
const ladder = {};
|
|
168
|
+
|
|
169
|
+
for (const phase of PHASES) {
|
|
170
|
+
// 1. Manifest layer. Underscore-prefixed keys in the manifest are
|
|
171
|
+
// documentation/examples — verify-ladder.example.json uses keys like
|
|
172
|
+
// "_doc", "_node_example" — so PHASES never starts with "_" and we
|
|
173
|
+
// can rely on hasOwn for an exact phase match.
|
|
174
|
+
if (
|
|
175
|
+
manifest &&
|
|
176
|
+
Object.prototype.hasOwnProperty.call(manifest, phase) &&
|
|
177
|
+
!phase.startsWith("_")
|
|
178
|
+
) {
|
|
179
|
+
const value = manifest[phase];
|
|
180
|
+
if (value === null) {
|
|
181
|
+
ladder[phase] = { command: null, source: "manifest:null" };
|
|
182
|
+
} else if (typeof value === "string") {
|
|
183
|
+
ladder[phase] = { command: value, source: "manifest" };
|
|
184
|
+
} else {
|
|
185
|
+
ladder[phase] = { command: null, source: "manifest:invalid" };
|
|
186
|
+
}
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 2. package.json scripts sniff.
|
|
191
|
+
const pkgHit = sniffPackageJsonScript(pkg, phase);
|
|
192
|
+
if (pkgHit) {
|
|
193
|
+
ladder[phase] = pkgHit;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 3. Per-language toolchain sniff.
|
|
198
|
+
const langHit = sniffPerLanguage(root, phase);
|
|
199
|
+
if (langHit) {
|
|
200
|
+
ladder[phase] = langHit;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 4. Nothing matched — operator must declare or set to null.
|
|
205
|
+
ladder[phase] = { command: null, source: "ask-operator" };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return ladder;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function pretty(ladder) {
|
|
212
|
+
const widest = Math.max(...Object.keys(ladder).map((k) => k.length));
|
|
213
|
+
const lines = ["verify-ladder (resolved):"];
|
|
214
|
+
for (const [phase, entry] of Object.entries(ladder)) {
|
|
215
|
+
const label = `${phase}:`.padEnd(widest + 2);
|
|
216
|
+
let line;
|
|
217
|
+
if (entry.command === null && entry.source === "ask-operator") {
|
|
218
|
+
line = ` ${label} (ask operator — no marker found)`;
|
|
219
|
+
} else if (entry.command === null && entry.source === "manifest:null") {
|
|
220
|
+
line = ` ${label} (skipped — manifest set this field to null)`;
|
|
221
|
+
} else if (entry.command === null) {
|
|
222
|
+
line = ` ${label} (skipped — ${entry.source})`;
|
|
223
|
+
} else {
|
|
224
|
+
line = ` ${label} ${entry.command} (${entry.source})`;
|
|
225
|
+
}
|
|
226
|
+
lines.push(line);
|
|
227
|
+
}
|
|
228
|
+
return lines.join("\n") + "\n";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function main() {
|
|
232
|
+
const { json, root } = parseArgs();
|
|
233
|
+
const ladder = resolve(root);
|
|
234
|
+
if (json) {
|
|
235
|
+
stdout.write(JSON.stringify(ladder, null, 2) + "\n");
|
|
236
|
+
} else {
|
|
237
|
+
stdout.write(pretty(ladder));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
main();
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/route-recommendation.mjs
|
|
3
|
+
//
|
|
4
|
+
// Route a single recommendation item to its preferred-skill chain + inline
|
|
5
|
+
// fallback, using the data-driven routing table in
|
|
6
|
+
// `scripts/route-recommendation.routes.json`.
|
|
7
|
+
//
|
|
8
|
+
// Replaces the 26-row Phase 3 routing table that `proceed-with-the-recommendation`
|
|
9
|
+
// would otherwise re-derive every list walk. The skill body cites this script
|
|
10
|
+
// once instead of restating the table inline; the routes.json file is the
|
|
11
|
+
// programmatic source of truth.
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// node scripts/route-recommendation.mjs "<recommendation item>"
|
|
15
|
+
// node scripts/route-recommendation.mjs --json "<recommendation item>"
|
|
16
|
+
// node scripts/route-recommendation.mjs --list # all rows
|
|
17
|
+
// node scripts/route-recommendation.mjs --list --json # all rows as JSON
|
|
18
|
+
//
|
|
19
|
+
// Output (default):
|
|
20
|
+
// Match: <row name>
|
|
21
|
+
// Preferred: <skill1> → <skill2>
|
|
22
|
+
// Fallback: <inline-fallback text>
|
|
23
|
+
// Marker: (Reference behavior — does not require <plugin>.)
|
|
24
|
+
//
|
|
25
|
+
// Output (--json):
|
|
26
|
+
// { "input": "...", "match": { name, preferred, fallback, marker }, "candidates": [...] }
|
|
27
|
+
//
|
|
28
|
+
// Cited by:
|
|
29
|
+
// - skills/proceed-with-the-recommendation.md Phase 3 routing table
|
|
30
|
+
|
|
31
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { argv, exit, stderr, stdout } from "node:process";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
|
|
36
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const ROUTES_PATH = join(SCRIPT_DIR, "route-recommendation.routes.json");
|
|
38
|
+
|
|
39
|
+
function loadRoutes() {
|
|
40
|
+
if (!existsSync(ROUTES_PATH)) {
|
|
41
|
+
stderr.write(`routes file not found: ${ROUTES_PATH}\n`);
|
|
42
|
+
exit(3);
|
|
43
|
+
}
|
|
44
|
+
const raw = readFileSync(ROUTES_PATH, "utf8");
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
if (!parsed || !Array.isArray(parsed.rows)) {
|
|
47
|
+
stderr.write(`routes file is malformed (expected { rows: [...] })\n`);
|
|
48
|
+
exit(3);
|
|
49
|
+
}
|
|
50
|
+
return parsed.rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseArgs() {
|
|
54
|
+
const args = argv.slice(2);
|
|
55
|
+
const out = { json: false, list: false, input: null };
|
|
56
|
+
const positional = [];
|
|
57
|
+
for (let i = 0; i < args.length; i++) {
|
|
58
|
+
const a = args[i];
|
|
59
|
+
if (a === "--json") out.json = true;
|
|
60
|
+
else if (a === "--list") out.list = true;
|
|
61
|
+
else if (a === "-h" || a === "--help") {
|
|
62
|
+
stdout.write(
|
|
63
|
+
"usage: route-recommendation.mjs [--json] <recommendation item>\n" +
|
|
64
|
+
" route-recommendation.mjs --list [--json]\n",
|
|
65
|
+
);
|
|
66
|
+
exit(0);
|
|
67
|
+
} else {
|
|
68
|
+
positional.push(a);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!out.list) {
|
|
72
|
+
if (positional.length === 0) {
|
|
73
|
+
stderr.write("usage: route-recommendation.mjs [--json] <recommendation item>\n");
|
|
74
|
+
stderr.write("input required: pass the recommendation text as the positional arg\n");
|
|
75
|
+
exit(2);
|
|
76
|
+
}
|
|
77
|
+
out.input = positional.join(" ").trim();
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function compileRow(row) {
|
|
83
|
+
const patterns = Array.isArray(row.patterns) ? row.patterns : [];
|
|
84
|
+
const compiled = [];
|
|
85
|
+
for (const p of patterns) {
|
|
86
|
+
try {
|
|
87
|
+
compiled.push(new RegExp(p, "i"));
|
|
88
|
+
} catch {
|
|
89
|
+
// skip malformed pattern — surface via warning, don't crash
|
|
90
|
+
stderr.write(`warning: bad regex in routes.json (${row.name}): ${p}\n`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return { ...row, _compiled: compiled };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function matchRow(row, input) {
|
|
97
|
+
for (const re of row._compiled) {
|
|
98
|
+
if (re.test(input)) return true;
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function asciiArrow(preferred) {
|
|
104
|
+
return preferred.join(" -> ");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pretty(matchResult) {
|
|
108
|
+
if (!matchResult.match) {
|
|
109
|
+
return [
|
|
110
|
+
`No routing match for input: "${matchResult.input}"`,
|
|
111
|
+
`ask-operator: this recommendation doesn't fit any known routing row.`,
|
|
112
|
+
`Either pick a row from --list manually, or surface to the operator.`,
|
|
113
|
+
"",
|
|
114
|
+
].join("\n");
|
|
115
|
+
}
|
|
116
|
+
const m = matchResult.match;
|
|
117
|
+
const lines = [
|
|
118
|
+
`Match: ${m.name}`,
|
|
119
|
+
`Preferred: ${asciiArrow(m.preferred)}`,
|
|
120
|
+
`Fallback: ${m.fallback}`,
|
|
121
|
+
];
|
|
122
|
+
if (m.marker) {
|
|
123
|
+
lines.push(`Marker: (Reference behavior — does not require ${m.marker}.)`);
|
|
124
|
+
}
|
|
125
|
+
if (matchResult.candidates.length > 1) {
|
|
126
|
+
lines.push("");
|
|
127
|
+
lines.push(`Other candidates (top match wins):`);
|
|
128
|
+
for (const c of matchResult.candidates.slice(1, 4)) {
|
|
129
|
+
lines.push(` - ${c.name}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
lines.push("");
|
|
133
|
+
return lines.join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function main() {
|
|
137
|
+
const args = parseArgs();
|
|
138
|
+
const rawRows = loadRoutes();
|
|
139
|
+
const rows = rawRows.map(compileRow);
|
|
140
|
+
|
|
141
|
+
if (args.list) {
|
|
142
|
+
if (args.json) {
|
|
143
|
+
const stripped = rows.map(({ _compiled, ...row }) => row);
|
|
144
|
+
stdout.write(JSON.stringify({ rows: stripped }, null, 2) + "\n");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const row of rows) {
|
|
148
|
+
stdout.write(`- ${row.name}\n`);
|
|
149
|
+
stdout.write(` preferred: ${asciiArrow(row.preferred)}\n`);
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const candidates = rows.filter((r) => matchRow(r, args.input));
|
|
155
|
+
const result = {
|
|
156
|
+
input: args.input,
|
|
157
|
+
match: candidates.length > 0 ? stripCompiled(candidates[0]) : null,
|
|
158
|
+
candidates: candidates.map(stripCompiled),
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (args.json) {
|
|
162
|
+
stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
163
|
+
} else {
|
|
164
|
+
stdout.write(pretty(result));
|
|
165
|
+
}
|
|
166
|
+
// Surface the ask-operator hint on stderr regardless of mode, so callers
|
|
167
|
+
// that read stderr can detect no-match without re-parsing stdout.
|
|
168
|
+
if (!result.match) {
|
|
169
|
+
stderr.write("ask-operator: no routing row matched the input\n");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function stripCompiled(row) {
|
|
174
|
+
const { _compiled, ...rest } = row;
|
|
175
|
+
return rest;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
main();
|