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.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/QUICKSTART.md +1 -1
  3. package/README.md +2 -2
  4. package/bin/check-landing-version.mjs +63 -0
  5. package/bin/check-scripts-citation-drift.mjs +61 -13
  6. package/bin/generate-plugin-manifests.mjs +2 -0
  7. package/bin/install.mjs +19 -11
  8. package/commands/verify-install.md +1 -1
  9. package/hooks/gateguard.mjs +22 -3
  10. package/hooks/query-cost-nudge.mjs +1 -0
  11. package/hooks/typecheck-stop.mjs +2 -1
  12. package/package.json +5 -3
  13. package/plugins/beginner.json +1 -1
  14. package/plugins/continuous-improvement/.claude-plugin/marketplace.json +1 -1
  15. package/plugins/continuous-improvement/.claude-plugin/plugin.json +1 -1
  16. package/plugins/continuous-improvement/README.md +1 -0
  17. package/plugins/continuous-improvement/commands/verify-install.md +1 -1
  18. package/plugins/continuous-improvement/hooks/gateguard.mjs +22 -3
  19. package/plugins/continuous-improvement/hooks/query-cost-nudge.mjs +1 -0
  20. package/plugins/continuous-improvement/hooks/typecheck-stop.mjs +2 -1
  21. package/plugins/continuous-improvement/scripts/README.md +33 -0
  22. package/plugins/continuous-improvement/scripts/detect-deploy-target.sh +66 -0
  23. package/plugins/continuous-improvement/scripts/get-deployed-sha.sh +113 -0
  24. package/plugins/continuous-improvement/scripts/git-state-snapshot.sh +48 -0
  25. package/plugins/continuous-improvement/scripts/resolve-verify-ladder.mjs +241 -0
  26. package/plugins/continuous-improvement/scripts/route-recommendation.mjs +178 -0
  27. package/plugins/continuous-improvement/scripts/route-recommendation.routes.json +213 -0
  28. package/plugins/continuous-improvement/scripts/run-synthetic.mjs +298 -0
  29. package/plugins/continuous-improvement/scripts/scan-past-mistakes.mjs +285 -0
  30. package/plugins/continuous-improvement/skills/deploy-receipt/SKILL.md +2 -2
  31. package/plugins/continuous-improvement/skills/gateguard/SKILL.md +2 -2
  32. package/plugins/continuous-improvement/skills/proceed-with-the-recommendation/SKILL.md +2 -2
  33. package/plugins/continuous-improvement/skills/reconcile/SKILL.md +1 -1
  34. package/plugins/continuous-improvement/skills/verification-loop/SKILL.md +5 -5
  35. package/plugins/continuous-improvement/skills/workspace-surface-audit/SKILL.md +1 -1
  36. package/plugins/continuous-improvement/skills/worktree-safety/SKILL.md +1 -1
  37. package/plugins/expert.json +1 -1
  38. package/scripts/README.md +33 -0
  39. package/scripts/detect-deploy-target.sh +66 -0
  40. package/scripts/get-deployed-sha.sh +113 -0
  41. package/scripts/git-state-snapshot.sh +48 -0
  42. package/scripts/resolve-verify-ladder.mjs +241 -0
  43. package/scripts/route-recommendation.mjs +178 -0
  44. package/scripts/route-recommendation.routes.json +213 -0
  45. package/scripts/run-synthetic.mjs +298 -0
  46. package/scripts/scan-past-mistakes.mjs +285 -0
  47. package/skills/deploy-receipt.md +2 -2
  48. package/skills/gateguard.md +2 -2
  49. package/skills/proceed-with-the-recommendation.md +2 -2
  50. package/skills/reconcile.md +1 -1
  51. package/skills/verification-loop.md +5 -5
  52. package/skills/workspace-surface-audit.md +1 -1
  53. 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();