@aperant/framework 0.12.1 → 0.13.0-next.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/package.json CHANGED
@@ -1,139 +1,139 @@
1
1
  {
2
- "name": "@aperant/framework",
3
- "version": "0.12.1",
4
- "description": "AI coding framework — composable skills for planning, executing, verifying, and reviewing code with any LLM provider. Works as Claude Code commands, Codex tasks, or programmatically.",
5
- "author": "Mikalsen AI <hello@mikalsen.ai>",
6
- "type": "module",
7
- "main": "./dist/index.js",
8
- "types": "./dist/index.d.ts",
9
- "bin": {
10
- "framework": "./bin/apt-tools.mjs",
11
- "apt-tools": "./bin/apt-tools.mjs",
12
- "apt-proof-video": "./bin/apt-proof-video.mjs"
13
- },
14
- "exports": {
15
- ".": {
16
- "types": "./dist/index.d.ts",
17
- "import": "./dist/index.js"
18
- },
19
- "./types": {
20
- "types": "./dist/types/index.d.ts",
21
- "import": "./dist/types/index.js"
22
- },
23
- "./cost": {
24
- "types": "./dist/cost/index.d.ts",
25
- "import": "./dist/cost/index.js"
26
- },
27
- "./mappers": {
28
- "types": "./dist/mappers/index.d.ts",
29
- "import": "./dist/mappers/index.js"
30
- },
31
- "./schemas": {
32
- "types": "./dist/schemas/index.d.ts",
33
- "import": "./dist/schemas/index.js"
34
- },
35
- "./standalone": {
36
- "types": "./dist/standalone/index.d.ts",
37
- "import": "./dist/standalone/index.js"
38
- },
39
- "./driver-sdk": {
40
- "types": "./dist/driver-sdk/index.d.ts",
41
- "import": "./dist/driver-sdk/index.js"
42
- },
43
- "./coordination/event-schema": {
44
- "types": "./src/cli/coordination/event-schema.d.ts",
45
- "import": "./src/cli/coordination/event-schema.mjs"
46
- },
47
- "./roadmap/conductor-view": {
48
- "types": "./src/cli/roadmap/conductor-view.d.ts",
49
- "import": "./src/cli/roadmap/conductor-view.mjs"
50
- },
51
- "./design/scan": {
52
- "import": "./src/cli/design/scan.mjs"
53
- },
54
- "./design/extract-repo": {
55
- "import": "./src/cli/design/extract-repo.mjs"
56
- },
57
- "./design/synthesize": {
58
- "import": "./src/cli/design/synthesize.mjs"
59
- }
60
- },
61
- "files": [
62
- "dist/",
63
- "bin/",
64
- "src/cli/",
65
- "src/cost/",
66
- "commands/",
67
- "skills/",
68
- "agents/",
69
- "prompts/",
70
- "templates/",
71
- "context/",
72
- "workflows/",
73
- "drivers/",
74
- "examples/",
75
- "LICENSE",
76
- "README.md",
77
- "CHANGELOG.md",
78
- "!**/__tests__/**",
79
- "!**/*.test.*",
80
- "!**/*.spec.*",
81
- "!**/*.test.d.ts.map",
82
- "!**/*.test.js.map"
83
- ],
84
- "keywords": [
85
- "ai",
86
- "framework",
87
- "coding-assistant",
88
- "claude-code",
89
- "codex",
90
- "aperant"
91
- ],
92
- "license": "AGPL-3.0-only",
93
- "homepage": "https://github.com/Mikalsen-AI/aperant-cloud-desktop/tree/main/packages/framework#readme",
94
- "bugs": {
95
- "url": "https://github.com/Mikalsen-AI/aperant-cloud-desktop/issues"
96
- },
97
- "repository": {
98
- "type": "git",
99
- "url": "https://github.com/Mikalsen-AI/aperant-cloud-desktop.git",
100
- "directory": "packages/framework"
101
- },
102
- "engines": {
103
- "node": ">=18.0.0"
104
- },
105
- "publishConfig": {
106
- "access": "public"
107
- },
108
- "dependencies": {
109
- "@clack/prompts": "^1.2.0",
110
- "proper-lockfile": "^4.1.2",
111
- "yaml": "^2.8.3",
112
- "zod": "^4.3.6"
113
- },
114
- "optionalDependencies": {
115
- "@babel/parser": "^7.29.2",
116
- "@babel/traverse": "^7.29.0",
117
- "@google/genai": "^1.50.1",
118
- "css-tree": "^3.2.1",
119
- "playwright": "^1.59.1"
120
- },
121
- "devDependencies": {
122
- "@vitest/coverage-v8": "^4.1.5"
123
- },
124
- "scripts": {
125
- "build": "tsc && node scripts/extract-personas-schema.mjs",
126
- "dev": "tsc --watch --preserveWatchOutput",
127
- "typecheck": "tsc --noEmit",
128
- "lint": "biome check . --diagnostic-level=error",
129
- "lint:fix": "biome check --write . --diagnostic-level=error",
130
- "clean": "rm -rf dist",
131
- "test": "vitest run",
132
- "c28:cutover": "node scripts/c28-cutover.mjs",
133
- "build-plugin": "node scripts/build-plugin.mjs",
134
- "publish-plugin": "node scripts/publish-plugin.mjs",
135
- "publish:verdaccio": "node scripts/publish-verdaccio.mjs",
136
- "release:verdaccio": "pnpm build && pnpm publish:verdaccio",
137
- "ship:verdaccio": "npm version patch --no-git-tag-version && pnpm build && pnpm publish:verdaccio"
138
- }
139
- }
2
+ "name": "@aperant/framework",
3
+ "version": "0.13.0-next.0",
4
+ "description": "AI coding framework — composable skills for planning, executing, verifying, and reviewing code with any LLM provider. Works as Claude Code commands, Codex tasks, or programmatically.",
5
+ "author": "Mikalsen AI <hello@mikalsen.ai>",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "bin": {
10
+ "framework": "./bin/apt-tools.mjs",
11
+ "apt-tools": "./bin/apt-tools.mjs",
12
+ "apt-proof-video": "./bin/apt-proof-video.mjs"
13
+ },
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "import": "./dist/index.js"
18
+ },
19
+ "./types": {
20
+ "types": "./dist/types/index.d.ts",
21
+ "import": "./dist/types/index.js"
22
+ },
23
+ "./cost": {
24
+ "types": "./dist/cost/index.d.ts",
25
+ "import": "./dist/cost/index.js"
26
+ },
27
+ "./mappers": {
28
+ "types": "./dist/mappers/index.d.ts",
29
+ "import": "./dist/mappers/index.js"
30
+ },
31
+ "./schemas": {
32
+ "types": "./dist/schemas/index.d.ts",
33
+ "import": "./dist/schemas/index.js"
34
+ },
35
+ "./standalone": {
36
+ "types": "./dist/standalone/index.d.ts",
37
+ "import": "./dist/standalone/index.js"
38
+ },
39
+ "./driver-sdk": {
40
+ "types": "./dist/driver-sdk/index.d.ts",
41
+ "import": "./dist/driver-sdk/index.js"
42
+ },
43
+ "./coordination/event-schema": {
44
+ "types": "./src/cli/coordination/event-schema.d.ts",
45
+ "import": "./src/cli/coordination/event-schema.mjs"
46
+ },
47
+ "./roadmap/conductor-view": {
48
+ "types": "./src/cli/roadmap/conductor-view.d.ts",
49
+ "import": "./src/cli/roadmap/conductor-view.mjs"
50
+ },
51
+ "./design/scan": {
52
+ "import": "./src/cli/design/scan.mjs"
53
+ },
54
+ "./design/extract-repo": {
55
+ "import": "./src/cli/design/extract-repo.mjs"
56
+ },
57
+ "./design/synthesize": {
58
+ "import": "./src/cli/design/synthesize.mjs"
59
+ }
60
+ },
61
+ "scripts": {
62
+ "build": "tsc && node scripts/extract-personas-schema.mjs",
63
+ "dev": "tsc --watch --preserveWatchOutput",
64
+ "typecheck": "tsc --noEmit",
65
+ "lint": "biome check . --diagnostic-level=error",
66
+ "lint:fix": "biome check --write . --diagnostic-level=error",
67
+ "clean": "rm -rf dist",
68
+ "test": "vitest run",
69
+ "c28:cutover": "node scripts/c28-cutover.mjs",
70
+ "build-plugin": "node scripts/build-plugin.mjs",
71
+ "publish-plugin": "node scripts/publish-plugin.mjs",
72
+ "publish:verdaccio": "node scripts/publish-verdaccio.mjs",
73
+ "release:verdaccio": "pnpm build && pnpm publish:verdaccio",
74
+ "ship:verdaccio": "npm version patch --no-git-tag-version && pnpm build && pnpm publish:verdaccio"
75
+ },
76
+ "files": [
77
+ "dist/",
78
+ "bin/",
79
+ "src/cli/",
80
+ "src/cost/",
81
+ "commands/",
82
+ "skills/",
83
+ "agents/",
84
+ "prompts/",
85
+ "templates/",
86
+ "context/",
87
+ "workflows/",
88
+ "drivers/",
89
+ "examples/",
90
+ "LICENSE",
91
+ "README.md",
92
+ "CHANGELOG.md",
93
+ "!**/__tests__/**",
94
+ "!**/*.test.*",
95
+ "!**/*.spec.*",
96
+ "!**/*.test.d.ts.map",
97
+ "!**/*.test.js.map"
98
+ ],
99
+ "keywords": [
100
+ "ai",
101
+ "framework",
102
+ "coding-assistant",
103
+ "claude-code",
104
+ "codex",
105
+ "aperant"
106
+ ],
107
+ "license": "AGPL-3.0-only",
108
+ "homepage": "https://github.com/Mikalsen-AI/aperant-cloud-desktop/tree/main/packages/framework#readme",
109
+ "bugs": {
110
+ "url": "https://github.com/Mikalsen-AI/aperant-cloud-desktop/issues"
111
+ },
112
+ "repository": {
113
+ "type": "git",
114
+ "url": "https://github.com/Mikalsen-AI/aperant-cloud-desktop.git",
115
+ "directory": "packages/framework"
116
+ },
117
+ "engines": {
118
+ "node": ">=18.0.0"
119
+ },
120
+ "publishConfig": {
121
+ "access": "public"
122
+ },
123
+ "dependencies": {
124
+ "@clack/prompts": "^1.2.0",
125
+ "proper-lockfile": "^4.1.2",
126
+ "yaml": "^2.8.3",
127
+ "zod": "^4.3.6"
128
+ },
129
+ "optionalDependencies": {
130
+ "@babel/parser": "^7.29.2",
131
+ "@babel/traverse": "^7.29.0",
132
+ "@google/genai": "^1.50.1",
133
+ "css-tree": "^3.2.1",
134
+ "playwright": "^1.59.1"
135
+ },
136
+ "devDependencies": {
137
+ "@vitest/coverage-v8": "^4.1.5"
138
+ }
139
+ }
@@ -52,9 +52,16 @@ you must follow when classifying.
52
52
  Before doing ANYTHING else, classify the user's input:
53
53
 
54
54
  ```bash
55
- node packages/framework/bin/apt-tools.mjs route . --input "$ARGUMENTS"
55
+ APT_FORCE_TTY=1 node packages/framework/bin/apt-tools.mjs route . --input "$ARGUMENTS"
56
56
  ```
57
57
 
58
+ The `APT_FORCE_TTY=1` prefix is the EXPLICIT interactive signal — not a real
59
+ TTY — and it is what makes the catch-up panel compute on this top-level `/apt`
60
+ call (the Bash tool runs `route` as a captured, non-TTY subprocess, so the
61
+ isTTY sniff alone would never open the gate). Spawned-subagent, fan-out, and
62
+ background `route` calls deliberately OMIT this signal (or set
63
+ `APT_CATCH_UP_DISABLE=1`) so the panel never fires inside a nested call.
64
+
58
65
  Parse the JSON result. The `mode` field determines what you do next. Display the routing decision to the user:
59
66
 
60
67
  ```
@@ -98,9 +105,10 @@ While you were away:
98
105
  ```
99
106
 
100
107
  This is display-only here — the Last-Seen Marker advance happens in route.mjs,
101
- not in this skill. The panel is computed lazily on interactive (TTY) calls
102
- only; non-TTY/background/spawned calls carry no `catch_up` field. Do not call
103
- it an "activity feed", "changelog", or "notifications" it is the
108
+ not in this skill. The panel is computed lazily only when the explicit
109
+ interactive signal (`APT_FORCE_TTY=1`, set above) is present NOT a real TTY;
110
+ non-TTY/background/spawned calls omit the signal and carry no `catch_up` field.
111
+ Do not call it an "activity feed", "changelog", or "notifications" — it is the
104
112
  Welcome-Back Summary + Team-Change Digest.
105
113
 
106
114
  Then proceed to the matching mode handler below. Do NOT deviate from the routing decision.
@@ -11,6 +11,7 @@ spawns_agent: false
11
11
  agent_name: null
12
12
  task_context: create-new
13
13
  default_track: DEBUG
14
+ passthrough_policy: exact-only
14
15
  default_execution_mode: auto
15
16
  execution_modes:
16
17
  - auto
@@ -11,6 +11,7 @@ spawns_agent: true
11
11
  agent_name: "apt-improver"
12
12
  task_context: create-new
13
13
  default_track: DEEP
14
+ passthrough_policy: exact-only
14
15
  default_execution_mode: step
15
16
  execution_modes:
16
17
  - auto
@@ -10,6 +10,7 @@ internal: false
10
10
  spawns_agent: true
11
11
  agent_name: "apt-reviewer"
12
12
  task_context: require-existing
13
+ passthrough_policy: exact-only
13
14
  default_execution_mode: auto
14
15
  execution_modes:
15
16
  - auto
@@ -11,6 +11,7 @@ spawns_agent: false
11
11
  agent_name: null
12
12
  task_context: require-existing
13
13
  default_track: STANDARD
14
+ passthrough_policy: exact-only
14
15
  default_execution_mode: step
15
16
  execution_modes:
16
17
  - auto
@@ -102,12 +102,26 @@ function readUpdateCheckCache() {
102
102
  }
103
103
 
104
104
  /**
105
- * Is this an interactive (TTY) apt call? The catch-up panel + Last-Seen Marker
106
- * are gated to interactive contexts (LD-02) exactly like the statusline
107
- * isTTY gate so spawned subagents, fan-out workers, and non-TTY/piped calls
108
- * are strict no-ops (they neither read nor write the marker). `APT_FORCE_TTY`
109
- * forces the gate open for tests; `APT_CATCH_UP_DISABLE` forces it closed so a
110
- * background spawner can opt every nested call out.
105
+ * Is this an interactive `/apt` call? The catch-up panel + Last-Seen Marker
106
+ * are gated to interactive contexts (LD-02) so spawned subagents, fan-out
107
+ * workers, and background/piped calls are strict no-ops (they neither read nor
108
+ * write the marker).
109
+ *
110
+ * The PRODUCTION trigger is the EXPLICIT interactive signal `APT_FORCE_TTY=1`,
111
+ * which the top-level `/apt` skill sets on its `apt-tools route` call
112
+ * (`skills/apt/SKILL.md`). The skill runs `route` via the Bash tool — a
113
+ * captured, non-TTY subprocess whose stdout/stderr are piped, never a PTY — so
114
+ * the `process.stdout/stderr.isTTY` sniff below is ALWAYS false on that path.
115
+ * The explicit signal is therefore the load-bearing opener; the isTTY sniff is
116
+ * only a SECONDARY fallback so a genuinely-interactive direct `apt-tools route`
117
+ * CLI call (a real terminal) still computes the panel.
118
+ *
119
+ * `APT_FORCE_TTY` does double duty: the test-override role AND the production
120
+ * interactive signal (no new env var — keeps the schema + opt-out unchanged).
121
+ * `APT_CATCH_UP_DISABLE` forces the gate closed so a background spawner can opt
122
+ * every nested call out (it wins over the signal). Spawned / fan-out / nested
123
+ * route calls deliberately set NEITHER (or set APT_CATCH_UP_DISABLE), so the
124
+ * panel never fires inside a sub-agent.
111
125
  */
112
126
  function isInteractiveTty() {
113
127
  if (process.env.APT_CATCH_UP_DISABLE === '1') return false
@@ -131,7 +145,7 @@ function isInteractiveTty() {
131
145
  * isInteractiveTty), so background/spawned calls never touch the marker.
132
146
  *
133
147
  * @param {string} targetDir
134
- * @returns {object|null} the catch_up panel, or null.
148
+ * @returns {{ welcome_back: object|null, team_digest: object|null }|null} the catch_up panel, or null.
135
149
  */
136
150
  function computeCatchUpForRoute(targetDir) {
137
151
  // Setup mirrors cmdCatchUp in commands/catch-up.mjs (recompute-only variant).
@@ -223,6 +237,118 @@ function normalizeAptInvocation(input) {
223
237
  */
224
238
  const PR_REVIEW_SIGNAL = /(?:\bPR\s*#?\s*\d+|#\d+|\bpull\s*request\s*#?\s*\d+)\b/i
225
239
 
240
+ /**
241
+ * English function/stop words used by `isArgShapedRemainder` (ID-04) to detect
242
+ * a prose-shaped remainder. Conservative on purpose: a remainder containing any
243
+ * of these reads like a sentence, so under `exact-only` it falls through to the
244
+ * host-LLM classify path instead of hijacking the slug. Hoisted to module scope
245
+ * (frozen) so the Set is built once at load time. Lowercase keys only — the
246
+ * helper lowercases tokens before lookup.
247
+ */
248
+ const PASSTHROUGH_STOP_WORDS = Object.freeze(
249
+ new Set([
250
+ 'the',
251
+ 'a',
252
+ 'an',
253
+ 'is',
254
+ 'are',
255
+ 'was',
256
+ 'were',
257
+ 'be',
258
+ 'been',
259
+ 'keeps',
260
+ 'keep',
261
+ 'now',
262
+ 'please',
263
+ 'that',
264
+ 'this',
265
+ 'these',
266
+ 'those',
267
+ 'my',
268
+ 'our',
269
+ 'your',
270
+ 'their',
271
+ 'it',
272
+ 'its',
273
+ 'to',
274
+ 'of',
275
+ 'in',
276
+ 'on',
277
+ 'at',
278
+ 'for',
279
+ 'and',
280
+ 'but',
281
+ 'or',
282
+ 'so',
283
+ 'then',
284
+ 'when',
285
+ 'while',
286
+ 'because',
287
+ 'i',
288
+ 'we',
289
+ 'they',
290
+ 'you',
291
+ 'fails',
292
+ 'failing',
293
+ 'failed',
294
+ 'crashing',
295
+ 'crashed',
296
+ 'broken',
297
+ 'with',
298
+ 'from',
299
+ 'into',
300
+ ]),
301
+ )
302
+
303
+ /**
304
+ * Pure, deterministic classifier (ID-04) for the remainder after a slug's first
305
+ * word, used by the `exact-only` passthrough policy. Returns true when the
306
+ * remainder is ARG-SHAPED (→ passthrough) and false when it is PROSE-SHAPED
307
+ * (→ fall through to the host-LLM classify path). No I/O.
308
+ *
309
+ * Arg-shaped iff:
310
+ * 1. empty (bare slug, e.g. `triage`), OR
311
+ * 2. EVERY whitespace token is flag-shaped (`--fix-all` / `-x`), path-shaped
312
+ * (contains `/`, `\`, or `.` → `src/foo.ts`), or numeric/id-shaped
313
+ * (`42` / `#7`), OR
314
+ * 3. it is a SHORT phrase (≤3 tokens) that does NOT read like a sentence:
315
+ * the leading token is NOT sentence-case (`/^[A-Z]/` and not an ALL-CAPS
316
+ * acronym) AND no token is an English function/stop word.
317
+ *
318
+ * Otherwise prose-shaped → false. See ID-04's case table + TD-03 for the
319
+ * pinned contract.
320
+ *
321
+ * @param {string} remainder the slug's trailing args, already trimmed/joined
322
+ * @returns {boolean} true → arg-shaped (passthrough); false → prose (classify)
323
+ */
324
+ export function isArgShapedRemainder(remainder) {
325
+ const trimmed = typeof remainder === 'string' ? remainder.trim() : ''
326
+ // 1. Empty / bare slug.
327
+ if (trimmed === '') return true
328
+
329
+ const tokens = trimmed.split(/\s+/)
330
+
331
+ const isFlag = (t) => /^--?[\w-]+$/.test(t)
332
+ const isPath = (t) => /[/\\.]/.test(t)
333
+ const isNumericId = (t) => /^#?\d+$/.test(t)
334
+
335
+ // 2. Every token is flag / path / numeric-id shaped.
336
+ if (tokens.every((t) => isFlag(t) || isPath(t) || isNumericId(t))) return true
337
+
338
+ // 3. Short phrase (≤3 tokens) that does not read like a sentence.
339
+ if (tokens.length > 3) return false
340
+ const lead = tokens[0]
341
+ // Sentence-case leading token (Capitalized, not an ALL-CAPS acronym) reads
342
+ // like the start of a prose sentence.
343
+ const isAllCapsAcronym = /^[A-Z0-9]+$/.test(lead)
344
+ if (/^[A-Z]/.test(lead) && !isAllCapsAcronym) return false
345
+ // Any English function/stop word anywhere → prose.
346
+ for (const t of tokens) {
347
+ if (PASSTHROUGH_STOP_WORDS.has(t.toLowerCase())) return false
348
+ }
349
+ return true
350
+ }
351
+
226
352
  /**
227
353
  * Canonical skill-slug index — reads every skill's agent_name and
228
354
  * spawns_agent flag from its frontmatter so the router doesn't carry a
@@ -239,7 +365,7 @@ const PR_REVIEW_SIGNAL = /(?:\bPR\s*#?\s*\d+|#\d+|\bpull\s*request\s*#?\s*\d+)\b
239
365
  * so this keeps the two paths consistent.
240
366
  *
241
367
  * @param {import('../route/skill-discover.mjs').DiscoveredSkill[]} skills
242
- * @returns {{ agentOf: (slug: string) => string|null, spawnsAgent: (slug: string) => boolean, taskContextOf: (slug: string) => string|null, defaultTrackOf: (slug: string) => string|null, knownSlugs: Set<string>, isUserInvocable: (slug: string) => boolean }}
368
+ * @returns {{ agentOf: (slug: string) => string|null, spawnsAgent: (slug: string) => boolean, taskContextOf: (slug: string) => string|null, defaultTrackOf: (slug: string) => string|null, passthroughPolicyOf: (slug: string) => 'freetext'|'exact-only'|'never', knownSlugs: Set<string>, isUserInvocable: (slug: string) => boolean }}
243
369
  */
244
370
  function skillIndex(skills) {
245
371
  const byName = new Map()
@@ -267,6 +393,9 @@ function skillIndex(skills) {
267
393
  defaultTrackOf(slug) {
268
394
  return byName.get(slug)?.default_track ?? null
269
395
  },
396
+ passthroughPolicyOf(slug) {
397
+ return byName.get(slug)?.passthrough_policy ?? 'freetext'
398
+ },
270
399
  isUserInvocable(slug) {
271
400
  return userInvocableSlugs.has(slug)
272
401
  },
@@ -386,11 +515,13 @@ export function cmdRoute(projectDir, extraArgs) {
386
515
  }
387
516
  }
388
517
 
389
- // Catch-up panel (LD-11): lazy-compute on interactive (TTY) calls only, and
390
- // only when config exists (init has no config yet). The compute also
391
- // advances the Last-Seen Marker after panel eval (LD-02). Non-TTY calls are
392
- // strict no-ops no read, no write, no field. Folded onto every returned
393
- // envelope below as a top-level `catch_up` field, mirroring update_check.
518
+ // Catch-up panel (LD-11): lazy-compute only on interactive `/apt` calls
519
+ // (explicit `APT_FORCE_TTY=1` signal preferred, TTY sniff fallback see
520
+ // isInteractiveTty), and only when config exists (init has no config yet).
521
+ // The compute also advances the Last-Seen Marker after panel eval (LD-02).
522
+ // Non-interactive calls are strict no-ops no read, no write, no field.
523
+ // Folded onto every returned envelope below as a top-level `catch_up` field,
524
+ // mirroring update_check.
394
525
  const catch_up = hasConfig && isInteractiveTty() ? computeCatchUpForRoute(targetDir) : null
395
526
 
396
527
  // ---------------- Deterministic branches (zero-behavior-change) ----------------
@@ -465,7 +596,36 @@ export function cmdRoute(projectDir, extraArgs) {
465
596
  // The PR_REVIEW_SIGNAL regex itself is defined at module scope above.
466
597
  const isPrReviewFreetext = firstWord === 'review' && PR_REVIEW_SIGNAL.test(remainingArgs)
467
598
 
468
- if (!isPrReviewFreetext && index.knownSlugs.has(firstWord)) {
599
+ // Per-skill passthrough policy (Item 2). The policy lives in frontmatter and
600
+ // is read via the index — NO hardcoded slug list enters route.mjs.
601
+ // - freetext (default): always passthrough on a first-word match (today's
602
+ // behavior, byte-identical);
603
+ // - exact-only: passthrough only when the remainder is arg-shaped — a prose
604
+ // remainder ('Triage the failing harness now') falls through to
605
+ // classify (the classify envelope still carries the skill, so a
606
+ // wrong fall-through self-heals, unlike a silent hijack);
607
+ // - never: always classify.
608
+ // When the gate fails we do NOT early-return — control falls through to the
609
+ // classify path below, which still surfaces the skill for re-selection.
610
+ //
611
+ // The pre-existing `review` PR-review carve-out (isPrReviewFreetext) is the
612
+ // authoritative prose-vs-invocation discriminator for `review` and STAYS
613
+ // as-is this task (spec Out-of-Scope). So when the carve-out has already
614
+ // governed `review` (firstWord === 'review'), the exact-only heuristic is
615
+ // bypassed — applying both would double-gate `review` and break the locked
616
+ // PR-review false-positive walls (e.g. "review the pull request feature",
617
+ // no-digit → passthrough). The carve-out already routed every digit-bearing
618
+ // PR reference to classify above.
619
+ const policy = index.passthroughPolicyOf(firstWord)
620
+ const carveOutGovernsReview = firstWord === 'review'
621
+ const passthroughOk =
622
+ policy === 'freetext' || carveOutGovernsReview
623
+ ? true
624
+ : policy === 'never'
625
+ ? false
626
+ : isArgShapedRemainder(remainingArgs)
627
+
628
+ if (!isPrReviewFreetext && index.knownSlugs.has(firstWord) && passthroughOk) {
469
629
  const shouldSpawn = index.spawnsAgent(firstWord)
470
630
  const gate_preview = previewGatesFor(firstWord)
471
631
  const defaultTrack = index.defaultTrackOf(firstWord)
@@ -50,6 +50,7 @@ const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n/
50
50
  * @property {string[]} execution_modes
51
51
  * @property {'create-new'|'require-existing'|'self-managed'|'none'} task_context
52
52
  * @property {('QUICK'|'STANDARD'|'DEEP'|'DEBUG')|undefined} default_track
53
+ * @property {'freetext'|'exact-only'|'never'} passthrough_policy router passthrough policy; always set (defaults to 'freetext' when the frontmatter omits it)
53
54
  * @property {string} file_path absolute path to the SKILL.md
54
55
  */
55
56
 
@@ -170,6 +171,7 @@ function readSkill(file) {
170
171
  execution_modes: Array.isArray(d.execution_modes) ? [...d.execution_modes] : [],
171
172
  task_context: d.task_context,
172
173
  default_track: d.default_track,
174
+ passthrough_policy: d.passthrough_policy ?? 'freetext',
173
175
  file_path: file,
174
176
  },
175
177
  }
@@ -100,6 +100,25 @@ export const TASK_CONTEXTS = Object.freeze([
100
100
  */
101
101
  export const TRACK_VALUES = Object.freeze(['QUICK', 'STANDARD', 'DEEP', 'DEBUG'])
102
102
 
103
+ /**
104
+ * Per-skill router passthrough policy (Item-2 router fix). Controls whether the
105
+ * router's first-word skill-passthrough match passes through to this skill or
106
+ * falls through to the host-LLM classify path when the input is free-text prose:
107
+ * - `freetext` (default when omitted) — current behavior: any input whose
108
+ * first word equals the slug passes through, byte-identical.
109
+ * - `exact-only` — pass through ONLY when the remainder is arg-shaped (empty /
110
+ * bare slug, all flag-or-path-or-numeric tokens, or a short
111
+ * ≤3-token lowercase noun phrase). A prose-shaped remainder
112
+ * (sentence-case opener, English stop word, or >3 tokens) falls
113
+ * through to classify. Opt-in for action-verb slugs that are
114
+ * also everyday imperatives (triage/debug/review/improve).
115
+ * - `never` — reserved: always fall through to classify. No skill uses it
116
+ * this task; the value exists for symmetry + future use.
117
+ * Read by route.mjs via skillIndex.passthroughPolicyOf(slug) — no hardcoded
118
+ * slug list enters the router.
119
+ */
120
+ export const PASSTHROUGH_POLICIES = Object.freeze(['freetext', 'exact-only', 'never'])
121
+
103
122
  /**
104
123
  * Required XML-style section tags that MUST appear (opening + closing) in the
105
124
  * SKILL.md body. Order is not enforced — authors can lay out the sections
@@ -160,6 +179,10 @@ export const SkillFrontmatterSchema = z
160
179
  execution_modes: z.array(ExecutionModeSchema).min(1),
161
180
  task_context: z.enum([...TASK_CONTEXTS]),
162
181
  default_track: z.enum([...TRACK_VALUES]).optional(),
182
+ // Per-skill router passthrough policy (Item-2). OPTIONAL — omission
183
+ // resolves to 'freetext' in skill-discover.mjs (byte-identical to
184
+ // today), so every existing SKILL.md validates unchanged.
185
+ passthrough_policy: z.enum([...PASSTHROUGH_POLICIES]).optional(),
163
186
  // Legacy / optional pass-throughs — kept as opt-in so migrations
164
187
  // don't immediately break skills that carry them.
165
188
  triggers: z.array(z.string()).optional(),