@aperant/framework 0.12.0 → 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/CHANGELOG.md +46 -0
- package/dist/cli/commands/route.d.mts +22 -0
- package/dist/cli/commands/route.d.mts.map +1 -1
- package/dist/cli/commands/route.mjs +169 -14
- package/dist/cli/commands/route.mjs.map +1 -1
- package/dist/cli/route/skill-discover.d.mts +4 -0
- package/dist/cli/route/skill-discover.d.mts.map +1 -1
- package/dist/cli/route/skill-discover.mjs +2 -0
- package/dist/cli/route/skill-discover.mjs.map +1 -1
- package/dist/cli/skill-author/contract.d.mts +21 -0
- package/dist/cli/skill-author/contract.d.mts.map +1 -1
- package/dist/cli/skill-author/contract.mjs +22 -0
- package/dist/cli/skill-author/contract.mjs.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/skills/apt/SKILL.md +10 -3
- package/dist/plugin/skills/apt-debug/SKILL.md +1 -0
- package/dist/plugin/skills/apt-improve/SKILL.md +1 -0
- package/dist/plugin/skills/apt-review/SKILL.md +1 -0
- package/dist/plugin/skills/apt-triage/SKILL.md +1 -0
- package/package.json +138 -138
- package/prompts/inbox-clarification.md +6 -0
- package/prompts/inbox-triage.md +1 -0
- package/skills/apt/SKILL.md +14 -6
- package/skills/apt-debug/SKILL.md +1 -0
- package/skills/apt-improve/SKILL.md +1 -0
- package/skills/apt-review/SKILL.md +1 -0
- package/skills/apt-triage/SKILL.md +1 -0
- package/src/cli/commands/route.mjs +174 -14
- package/src/cli/route/skill-discover.mjs +2 -0
- package/src/cli/skill-author/contract.mjs +23 -0
|
@@ -102,12 +102,26 @@ function readUpdateCheckCache() {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/**
|
|
105
|
-
* Is this an interactive
|
|
106
|
-
* are gated to interactive contexts (LD-02)
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
//
|
|
393
|
-
//
|
|
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
|
-
|
|
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(),
|