@gtrabanco/pi-agentic-workflow 0.1.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.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.es.md +154 -0
  3. package/README.md +148 -0
  4. package/dist/config/defaults.d.ts +8 -0
  5. package/dist/config/defaults.js +11 -0
  6. package/dist/config/load.d.ts +19 -0
  7. package/dist/config/load.js +76 -0
  8. package/dist/config/merge.d.ts +4 -0
  9. package/dist/config/merge.js +43 -0
  10. package/dist/config/schema.d.ts +21 -0
  11. package/dist/config/schema.js +144 -0
  12. package/dist/config/types.d.ts +53 -0
  13. package/dist/config/types.js +12 -0
  14. package/dist/extension/factory.d.ts +47 -0
  15. package/dist/extension/factory.js +49 -0
  16. package/dist/extension/index.d.ts +4 -0
  17. package/dist/extension/index.js +84 -0
  18. package/dist/routing/catalogue.d.ts +35 -0
  19. package/dist/routing/catalogue.js +79 -0
  20. package/dist/routing/dispatch.d.ts +34 -0
  21. package/dist/routing/dispatch.js +191 -0
  22. package/dist/routing/state.d.ts +13 -0
  23. package/dist/routing/state.js +65 -0
  24. package/dist/routing/types.d.ts +90 -0
  25. package/dist/routing/types.js +3 -0
  26. package/dist/settings/console.d.ts +55 -0
  27. package/dist/settings/console.js +266 -0
  28. package/dist/settings/store.d.ts +3 -0
  29. package/dist/settings/store.js +24 -0
  30. package/dist/settings/view.d.ts +11 -0
  31. package/dist/settings/view.js +46 -0
  32. package/package.json +59 -0
  33. package/skills/audit-docs/SKILL.md +171 -0
  34. package/skills/audit-pr/SKILL.md +201 -0
  35. package/skills/audit-pr/references/01_MERGE_GATES.md +24 -0
  36. package/skills/audit-pr/references/02_CLOSURE_AND_SCOPE_GATES.md +83 -0
  37. package/skills/audit-pr/references/03_AUDIT_PROCESS.md +69 -0
  38. package/skills/audit-pr/references/04_VERDICT.md +69 -0
  39. package/skills/audit-pr/references/05_ROUTING_AND_GUARDRAILS.md +58 -0
  40. package/skills/audit-pr/references/PORTABILITY.md +17 -0
  41. package/skills/design-feature/SKILL.md +162 -0
  42. package/skills/design-feature/references/INTERVIEW.md +52 -0
  43. package/skills/design-feature/references/PORTABILITY.md +15 -0
  44. package/skills/design-feature/references/UPSERT_EXAMPLE.md +8 -0
  45. package/skills/design-feature/references/WRITE_AND_UPSERT.md +92 -0
  46. package/skills/discover-repository-state/SKILL.md +83 -0
  47. package/skills/execute-phase/SKILL.md +115 -0
  48. package/skills/execute-phase/references/BATCH_AND_PORTABILITY.md +54 -0
  49. package/skills/execute-phase/references/CLOSEOUT.md +74 -0
  50. package/skills/execute-phase/references/DESCOPE.md +35 -0
  51. package/skills/execute-phase/references/EXECUTION_CONTRACT.md +147 -0
  52. package/skills/execute-phase/references/FOLDING.md +73 -0
  53. package/skills/execute-phase/references/FORGE_BODY.md +24 -0
  54. package/skills/execute-phase/references/HANDOFF.md +21 -0
  55. package/skills/execute-phase/references/OPPORTUNISTIC_FINDING.md +45 -0
  56. package/skills/execute-phase/references/PREFLIGHT.md +152 -0
  57. package/skills/execute-phase/references/UNIT_LOOP.md +88 -0
  58. package/skills/execute-phase/references/WORKFLOWS_FEATURE.md +37 -0
  59. package/skills/execute-phase/references/WORKFLOWS_FIX.md +43 -0
  60. package/skills/execute-phase/references/WORKFLOWS_LEGACY.md +26 -0
  61. package/skills/execute-phase/references/WORKFLOWS_SMALL_PHASED.md +38 -0
  62. package/skills/fold-findings/SKILL.md +141 -0
  63. package/skills/fold-findings/references/FOLD_POLICY.md +43 -0
  64. package/skills/fold-findings/references/FOLD_PROCESS.md +41 -0
  65. package/skills/generate-docs/SKILL.md +145 -0
  66. package/skills/generate-docs/references/ADAPTERS.md +15 -0
  67. package/skills/generate-docs/references/ADAPTER_DISCOVERY.md +33 -0
  68. package/skills/generate-docs/references/GENERATION_PROCESS.md +90 -0
  69. package/skills/init-workspace/SKILL.md +152 -0
  70. package/skills/init-workspace/references/BOOTSTRAP_DISCOVERY.md +70 -0
  71. package/skills/init-workspace/references/BOOTSTRAP_WRITE.md +58 -0
  72. package/skills/init-workspace/references/PORTABILITY.md +16 -0
  73. package/skills/init-workspace/references/UPGRADE.md +83 -0
  74. package/skills/log-session/SKILL.md +159 -0
  75. package/skills/loop-review-fold/SKILL.md +168 -0
  76. package/skills/orchestration-envelope/SKILL.md +81 -0
  77. package/skills/orchestration-envelope/references/TURN_CONTRACT.md +21 -0
  78. package/skills/phase-contract/SKILL.md +82 -0
  79. package/skills/plan-feature/SKILL.md +179 -0
  80. package/skills/plan-feature/references/ROUTING.md +83 -0
  81. package/skills/plan-feature-from-issue/SKILL.md +140 -0
  82. package/skills/plan-feature-scaffold/SKILL.md +93 -0
  83. package/skills/plan-feature-scaffold/references/SCAFFOLD_PROCESS.md +76 -0
  84. package/skills/plan-fix/SKILL.md +115 -0
  85. package/skills/plan-fix/references/PLANNING_PROCESS.md +118 -0
  86. package/skills/plan-fix/references/SPEC_CONTRACT.md +22 -0
  87. package/skills/planning-preflight/SKILL.md +130 -0
  88. package/skills/product-audit/SKILL.md +225 -0
  89. package/skills/product-audit/references/AUDIT_DIMENSIONS.md +24 -0
  90. package/skills/product-audit/references/AUDIT_PROCESS.md +85 -0
  91. package/skills/resolve-repository-state/SKILL.md +79 -0
  92. package/skills/review-a11y/SKILL.md +60 -0
  93. package/skills/review-brand/SKILL.md +65 -0
  94. package/skills/review-change/SKILL.md +156 -0
  95. package/skills/review-change/references/ADVERSARIAL_RECOMMENDATION.md +31 -0
  96. package/skills/review-change/references/ADVERSARIAL_SETUP.md +86 -0
  97. package/skills/review-change/references/ADVERSARIAL_SYNTHESIS.md +53 -0
  98. package/skills/review-change/references/OUTPUT_AND_GUARDRAILS.md +85 -0
  99. package/skills/review-change/references/PERSIST_AND_DECIDE.md +151 -0
  100. package/skills/review-change/references/PORTABILITY.md +74 -0
  101. package/skills/review-change/references/REVIEW_PROCESS.md +86 -0
  102. package/skills/review-code/SKILL.md +69 -0
  103. package/skills/review-debt/SKILL.md +72 -0
  104. package/skills/review-design/SKILL.md +60 -0
  105. package/skills/review-implementation/SKILL.md +108 -0
  106. package/skills/review-implementation/references/CLASSIFY.md +96 -0
  107. package/skills/review-implementation/references/FIND.md +35 -0
  108. package/skills/review-perf/SKILL.md +79 -0
  109. package/skills/review-security/SKILL.md +65 -0
  110. package/skills/review-seo/SKILL.md +65 -0
  111. package/skills/review-verify/SKILL.md +66 -0
  112. package/skills/ship-roadmap/SKILL.md +120 -0
  113. package/skills/ship-roadmap/references/ADVANCE.md +90 -0
  114. package/skills/ship-roadmap/references/AUDIT_AND_MERGE.md +64 -0
  115. package/skills/ship-roadmap/references/CLOSEOUT_AND_LOG.md +26 -0
  116. package/skills/ship-roadmap/references/FOUNDING.md +101 -0
  117. package/skills/ship-roadmap/references/GUARDRAILS.md +56 -0
  118. package/skills/ship-roadmap/references/MODEL_ROUTING.md +14 -0
  119. package/skills/ship-roadmap/references/PORTABILITY.md +34 -0
  120. package/skills/ship-roadmap/references/RECOVERY_AND_SELECTION.md +93 -0
  121. package/skills/ship-roadmap/references/STOP_CONDITIONS.md +9 -0
  122. package/skills/ship-roadmap/references/TERMINAL_REPORT.md +34 -0
  123. package/skills/triage-issue/SKILL.md +148 -0
  124. package/skills/triage-issue/references/AUDIT_FINDINGS.md +35 -0
  125. package/skills/triage-issue/references/FOLD_LEDGER.md +32 -0
  126. package/skills/triage-issue/references/ISSUE_PROCESS.md +101 -0
  127. package/skills/triage-issue/references/LABELS.md +83 -0
  128. package/skills/triage-issue/references/REVIEW_FINDING_PROCESS.md +41 -0
  129. package/skills/verification-contract/SKILL.md +77 -0
  130. package/skills/workflow-status/SKILL.md +143 -0
  131. package/skills/workflow-status/references/CRASH_RECOVERY.md +66 -0
  132. package/skills/workflow-status/references/ENVELOPE_CORE.md +107 -0
  133. package/skills/workflow-status/references/ENVELOPE_FIELDS.md +77 -0
  134. package/skills/workflow-status/references/GUARDRAILS.md +17 -0
  135. package/skills/workflow-status/references/PORTABILITY.md +14 -0
  136. package/skills/workflow-status/references/SENSOR_CORE.md +95 -0
  137. package/skills/workflow-status/references/SENSOR_SIGNALS.md +128 -0
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The configuration vocabulary shared by every layer of the package (SPEC S5–S8).
3
+ *
4
+ * Two distinct shapes exist on purpose:
5
+ * - `ConfigFile` is what an operator WRITES: everything optional, exactly the
6
+ * three keys the SPEC's config schema names, nothing more.
7
+ * - `EffectiveConfig` is what the extension READS after merge: every route
8
+ * fully resolved, so no downstream code has to reason about optionality.
9
+ */
10
+ /** Pi's thinking levels, mirrored here so the domain layer stays Pi-free. */
11
+ export declare const THINKING_LEVELS: readonly ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
12
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
13
+ /** A thinking level, or `inherit` = "whatever the session already uses". */
14
+ export type ThinkingSetting = ThinkingLevel | "inherit";
15
+ /** `"inherit"` or an exact `provider/modelId` reference (split at the first slash). */
16
+ export type ModelRef = `${string}/${string}`;
17
+ export type ModelSetting = "inherit" | ModelRef;
18
+ export interface Route {
19
+ model: ModelSetting;
20
+ thinking: ThinkingSetting;
21
+ }
22
+ /** What one config file may declare (SPEC "Config schema"). */
23
+ export interface RouteFile {
24
+ model?: ModelSetting;
25
+ thinking?: ThinkingSetting;
26
+ }
27
+ export interface ConfigFile {
28
+ default?: RouteFile;
29
+ commands?: Record<string, RouteFile>;
30
+ onUnavailableRoute?: UnavailableRoutePolicy;
31
+ }
32
+ export type UnavailableRoutePolicy = "stop" | "inherit";
33
+ /** One rejected field inside one config file, addressed by a JSON-path-ish string. */
34
+ export interface ConfigIssue {
35
+ path: string;
36
+ message: string;
37
+ }
38
+ export declare const UNAVAILABLE_ROUTE_POLICIES: readonly UnavailableRoutePolicy[];
39
+ export interface EffectiveConfig {
40
+ default: Route;
41
+ commands: Record<string, Route>;
42
+ onUnavailableRoute: UnavailableRoutePolicy;
43
+ }
44
+ export interface ConfigProblem {
45
+ scope: "global" | "project";
46
+ path: string;
47
+ message: string;
48
+ }
49
+ /** Absolute paths of the two dedicated JSON files (never Pi `settings.json`). */
50
+ export interface ConfigPaths {
51
+ global: string;
52
+ project: string;
53
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The configuration vocabulary shared by every layer of the package (SPEC S5–S8).
3
+ *
4
+ * Two distinct shapes exist on purpose:
5
+ * - `ConfigFile` is what an operator WRITES: everything optional, exactly the
6
+ * three keys the SPEC's config schema names, nothing more.
7
+ * - `EffectiveConfig` is what the extension READS after merge: every route
8
+ * fully resolved, so no downstream code has to reason about optionality.
9
+ */
10
+ /** Pi's thinking levels, mirrored here so the domain layer stays Pi-free. */
11
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
12
+ export const UNAVAILABLE_ROUTE_POLICIES = ["stop", "inherit"];
@@ -0,0 +1,47 @@
1
+ import type { LoadedConfig } from "../config/load.js";
2
+ import type { Catalogue } from "../routing/catalogue.js";
3
+ import type { Router } from "../routing/dispatch.js";
4
+ import type { ExtensionSurface, InvocationContext, ModelRef, RoutingControls } from "../routing/types.js";
5
+ import type { HintStore } from "../routing/state.js";
6
+ /**
7
+ * The Pi-free half of the extension entry (SPEC "Command surface (api)").
8
+ *
9
+ * Given something that can register commands and a surface shaped like
10
+ * `ExtensionAPI`, this wires one command per bundled public skill plus the
11
+ * settings console, and returns the router that owns model lifecycle. Every
12
+ * behaviour in AC3–AC12 is reachable from here, so `index.ts` stays a thin
13
+ * adapter over Pi's real objects and nothing has to be tested against a live
14
+ * session. `M` is the session model type — Pi's `Model` in production, a plain
15
+ * `{provider, id}` in tests.
16
+ */
17
+ export interface CommandRegistrar<M extends ModelRef = ModelRef> {
18
+ registerCommand(name: string, options: {
19
+ description?: string;
20
+ handler: (args: string, ctx: InvocationContext<M>) => void | Promise<void>;
21
+ }): void;
22
+ }
23
+ /** What the settings command presents (SPEC S4): the console, over the same files. */
24
+ export type SettingsHandler<M extends ModelRef = ModelRef> = (input: {
25
+ catalogue: Catalogue;
26
+ ctx: InvocationContext<M>;
27
+ /** Lets the console show and release a latch the operator can otherwise wait on. */
28
+ routing: RoutingControls;
29
+ }) => unknown;
30
+ export interface ExtensionDeps<M extends ModelRef = ModelRef> {
31
+ registrar: CommandRegistrar<M>;
32
+ /** Pi's API is session-bound, so the router resolves the surface per call. */
33
+ surface: (ctx: InvocationContext<M>) => ExtensionSurface<M>;
34
+ /** Directory holding the bundled `skills/` tree. */
35
+ skillsDir: string;
36
+ /** Global agent directory, from Pi's `getAgentDir()`. */
37
+ agentDir: string;
38
+ hint: HintStore;
39
+ settings: SettingsHandler<M>;
40
+ /** Overridable so a test can hand the router an in-memory configuration. */
41
+ loadConfig?: (ctx: InvocationContext<M>) => LoadedConfig;
42
+ }
43
+ export interface ExtensionHandle<M extends ModelRef = ModelRef> {
44
+ router: Router<M>;
45
+ catalogue: Catalogue;
46
+ }
47
+ export declare function createExtension<M extends ModelRef = ModelRef>(deps: ExtensionDeps<M>): ExtensionHandle<M>;
@@ -0,0 +1,49 @@
1
+ import { loadConfig } from "../config/load.js";
2
+ import { readCatalogue } from "../routing/catalogue.js";
3
+ import { createRouter } from "../routing/dispatch.js";
4
+ import { SETTINGS_COMMAND } from "../routing/types.js";
5
+ export function createExtension(deps) {
6
+ const { registrar, surface, skillsDir, agentDir, hint, settings } = deps;
7
+ const catalogue = readCatalogue(skillsDir);
8
+ const read = (ctx) => deps.loadConfig?.(ctx) ?? loadConfig({ agentDir, cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() });
9
+ // A skill that cannot become a command is a packaging fact the operator must
10
+ // see, and Pi exposes notifications only through the invocation context — so
11
+ // the first command run in the session carries the report, once.
12
+ let reported = catalogue.issues.length === 0;
13
+ const reportCatalogueIssues = (ctx) => {
14
+ if (reported)
15
+ return;
16
+ reported = true;
17
+ for (const issue of catalogue.issues) {
18
+ ctx.notify(`pi-agentic-workflow: skills/${issue.dir}: ${issue.message}`, "warning");
19
+ }
20
+ };
21
+ const knownCommands = new Set([...catalogue.commands.map((entry) => entry.name), SETTINGS_COMMAND]);
22
+ const router = createRouter({ surface, loadConfig: read, hint, settingsCommand: SETTINGS_COMMAND, knownCommands });
23
+ for (const command of catalogue.commands) {
24
+ registrar.registerCommand(command.name, {
25
+ ...(command.description ? { description: command.description } : {}),
26
+ handler: async (args, ctx) => {
27
+ reportCatalogueIssues(ctx);
28
+ await router.dispatch(command, args, ctx);
29
+ },
30
+ });
31
+ }
32
+ registrar.registerCommand(SETTINGS_COMMAND, {
33
+ description: "Show and configure per-command model routing",
34
+ handler: async (_args, ctx) => {
35
+ reportCatalogueIssues(ctx);
36
+ // Bound for the console: two verbs and the session they act on, so it cannot
37
+ // reach for `settle` or `dispatch` by accident.
38
+ const routing = { inFlight: () => router.inFlight(), undoInFlight: () => router.undoInFlight(ctx) };
39
+ try {
40
+ await settings({ catalogue, ctx, routing });
41
+ }
42
+ catch (error) {
43
+ // A console that dies mid-question must say so, not take the session down.
44
+ ctx.notify(`Settings could not be opened: ${error.message}`, "error");
45
+ }
46
+ },
47
+ });
48
+ return { router, catalogue };
49
+ }
@@ -0,0 +1,4 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { SETTINGS_COMMAND } from "../routing/types.js";
3
+ export default function extension(pi: ExtensionAPI): void;
4
+ export { SETTINGS_COMMAND };
@@ -0,0 +1,84 @@
1
+ import { dirname, resolve } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { loadConfig } from "../config/load.js";
5
+ import { createExtension } from "./factory.js";
6
+ import { createHintStore, stateFilePath } from "../routing/state.js";
7
+ import { runSettingsConsole } from "../settings/console.js";
8
+ import { readConfigFile, writeConfigFile } from "../settings/store.js";
9
+ import { SETTINGS_COMMAND } from "../routing/types.js";
10
+ /**
11
+ * Pi adapter — the only file in the package that imports Pi values (SPEC
12
+ * "Package layout": `dist/extension/index.js`).
13
+ *
14
+ * Its whole job is translation between Pi's context and the narrow view the
15
+ * router understands, with no casts: `M` is bound to Pi's own `Model`, so every
16
+ * model reference the router stores and hands back is the exact object
17
+ * `ctx.modelRegistry` produced. Routing, configuration, and lifecycle live in
18
+ * the Pi-free modules this wires — which is what lets AC3–AC12 be tested
19
+ * without a session.
20
+ */
21
+ const skillsDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
22
+ const thinkingLevelsInSyncWithPi = true;
23
+ function toInvocationContext(ctx) {
24
+ return {
25
+ cwd: ctx.cwd,
26
+ get model() {
27
+ return ctx.model;
28
+ },
29
+ isIdle: () => ctx.isIdle(),
30
+ isProjectTrusted: () => ctx.isProjectTrusted(),
31
+ notify: (message, kind) => ctx.ui.notify(message, kind),
32
+ ui: ctx.ui,
33
+ availableModels: () => ctx.modelRegistry.getAll(),
34
+ find: (provider, modelId) => ctx.modelRegistry.find(provider, modelId),
35
+ hasConfiguredAuth: (model) => ctx.modelRegistry.hasConfiguredAuth(model),
36
+ };
37
+ }
38
+ function toRegistrar(pi) {
39
+ return {
40
+ registerCommand: (name, options) => pi.registerCommand(name, {
41
+ ...(options.description ? { description: options.description } : {}),
42
+ handler: async (args, ctx) => {
43
+ await options.handler(args, toInvocationContext(ctx));
44
+ },
45
+ }),
46
+ };
47
+ }
48
+ export default function extension(pi) {
49
+ const agentDir = getAgentDir();
50
+ const hint = createHintStore({ path: stateFilePath(agentDir) });
51
+ const { router } = createExtension({
52
+ registrar: toRegistrar(pi),
53
+ // Resolved per call: the router must never hold a session-bound object
54
+ // between turns, because Pi can hand a new session to the same extension.
55
+ surface: () => ({
56
+ sendUserMessage: (content, options) => pi.sendUserMessage(content, options),
57
+ setModel: (model) => pi.setModel(model),
58
+ getThinkingLevel: () => pi.getThinkingLevel(),
59
+ setThinkingLevel: (level) => pi.setThinkingLevel(level),
60
+ }),
61
+ skillsDir,
62
+ agentDir,
63
+ hint,
64
+ loadConfig: (ctx) => loadConfig({ agentDir, cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() }),
65
+ // The console edits the same files the dispatcher reads (SPEC S4, AC10).
66
+ settings: ({ ctx, catalogue: routed, routing }) => runSettingsConsole({
67
+ routing,
68
+ ui: ctx.ui,
69
+ agentDir,
70
+ cwd: ctx.cwd,
71
+ projectTrusted: ctx.isProjectTrusted(),
72
+ commands: routed.commands.map((command) => command.name),
73
+ models: ctx.availableModels().map((model) => `${model.provider}/${model.id}`),
74
+ readFile: readConfigFile,
75
+ writeFile: writeConfigFile,
76
+ }),
77
+ });
78
+ pi.on("model_select", (event) => router.noteModelSelect(event.model));
79
+ pi.on("thinking_level_select", (event) => router.noteThinkingLevelSelect(event.level));
80
+ pi.on("agent_settled", (_event, ctx) => void router.settle(toInvocationContext(ctx)));
81
+ }
82
+ // Exported so the settings console (P4) names the same command without relisting it.
83
+ export { SETTINGS_COMMAND };
84
+ void thinkingLevelsInSyncWithPi;
@@ -0,0 +1,35 @@
1
+ import type { WorkflowCommand } from "./types.js";
2
+ export interface SkillMeta {
3
+ /** Directory name inside the bundle — the fallback when `name:` is absent. */
4
+ dir: string;
5
+ name: string;
6
+ description?: string;
7
+ userInvocable: boolean;
8
+ }
9
+ /**
10
+ * Minimal frontmatter reader: `name`, `description`, `user-invocable` only.
11
+ *
12
+ * `user-invocable` must say `true` to count: this repository's own rule
13
+ * (CLAUDE.md — the key "REQUIRED for it to appear in the agent's /command menu")
14
+ * makes absence mean internal, and `scripts/bundle-skills.mjs` reads it the same
15
+ * way. The two scanners agree because the rule is stated once per scanner and
16
+ * pinned by `test/alias-coverage.test.mjs`, not because either default is safe.
17
+ */
18
+ export declare function readSkillMeta(text: string, dir: string): SkillMeta;
19
+ export interface CatalogueIssue {
20
+ dir: string;
21
+ message: string;
22
+ }
23
+ export interface Catalogue {
24
+ commands: WorkflowCommand[];
25
+ /** Skills present but unusable as commands (missing file, duplicate name). */
26
+ issues: CatalogueIssue[];
27
+ /** Every scanned skill that is callable, used by the alias-coverage assertions. */
28
+ invocable: SkillMeta[];
29
+ }
30
+ /**
31
+ * Read a bundled skills directory. A duplicate `name:` is reported instead of
32
+ * silently shadowing an alias: two skills claiming one command is a packaging
33
+ * bug the operator must see.
34
+ */
35
+ export declare function readCatalogue(skillsDir: string, readFile?: (path: string) => string): Catalogue;
@@ -0,0 +1,79 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * The command catalogue: one entry per bundled `user-invocable: true` skill,
5
+ * named exactly after that skill's frontmatter `name:` (SPEC S3, D-P9).
6
+ *
7
+ * The rule is intentionally the bundler's rule, read from the OTHER side:
8
+ * `scripts/bundle-skills.mjs` decides what ships at build time, this module
9
+ * decides what is callable at runtime. Both scan the same three frontmatter
10
+ * fields, and `test/skill-parity.test.mjs` + `test/alias-coverage.test.mjs`
11
+ * pin the two views against the live tree, so a skill that stops being
12
+ * user-invocable loses its command in the same commit.
13
+ */
14
+ const SKILL_FILE = "SKILL.md";
15
+ /**
16
+ * Minimal frontmatter reader: `name`, `description`, `user-invocable` only.
17
+ *
18
+ * `user-invocable` must say `true` to count: this repository's own rule
19
+ * (CLAUDE.md — the key "REQUIRED for it to appear in the agent's /command menu")
20
+ * makes absence mean internal, and `scripts/bundle-skills.mjs` reads it the same
21
+ * way. The two scanners agree because the rule is stated once per scanner and
22
+ * pinned by `test/alias-coverage.test.mjs`, not because either default is safe.
23
+ */
24
+ export function readSkillMeta(text, dir) {
25
+ const lines = text.split(/\r?\n/u);
26
+ const meta = { dir, name: dir, userInvocable: false };
27
+ if (lines[0]?.trim() !== "---")
28
+ return meta;
29
+ for (let index = 1; index < lines.length; index += 1) {
30
+ const line = lines[index];
31
+ if (line.trim() === "---")
32
+ break;
33
+ const colon = line.indexOf(":");
34
+ if (colon === -1)
35
+ continue;
36
+ const key = line.slice(0, colon).trim();
37
+ const value = line.slice(colon + 1).trim().replace(/^["']|["']$/gu, "");
38
+ if (key === "name" && value !== "")
39
+ meta.name = value;
40
+ else if (key === "description" && value !== "")
41
+ meta.description = value;
42
+ else if (key === "user-invocable")
43
+ meta.userInvocable = value === "true";
44
+ }
45
+ return meta;
46
+ }
47
+ /**
48
+ * Read a bundled skills directory. A duplicate `name:` is reported instead of
49
+ * silently shadowing an alias: two skills claiming one command is a packaging
50
+ * bug the operator must see.
51
+ */
52
+ export function readCatalogue(skillsDir, readFile = (p) => readFileSync(p, "utf8")) {
53
+ const commands = [];
54
+ const invocable = [];
55
+ const issues = [];
56
+ const taken = new Map();
57
+ for (const dir of readdirSync(skillsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory())) {
58
+ let meta;
59
+ try {
60
+ meta = readSkillMeta(readFile(join(skillsDir, dir.name, SKILL_FILE)), dir.name);
61
+ }
62
+ catch {
63
+ issues.push({ dir: dir.name, message: "no readable SKILL.md" });
64
+ continue;
65
+ }
66
+ if (!meta.userInvocable)
67
+ continue;
68
+ const owner = taken.get(meta.name);
69
+ if (owner) {
70
+ issues.push({ dir: dir.name, message: `command name "${meta.name}" already claimed by ${owner}/` });
71
+ continue;
72
+ }
73
+ taken.set(meta.name, dir.name);
74
+ invocable.push(meta);
75
+ commands.push({ name: meta.name, skill: dir.name, ...(meta.description ? { description: meta.description } : {}) });
76
+ }
77
+ commands.sort((left, right) => left.name.localeCompare(right.name));
78
+ return { commands, issues, invocable };
79
+ }
@@ -0,0 +1,34 @@
1
+ import type { LoadedConfig } from "../config/load.js";
2
+ import type { ThinkingLevel } from "../config/types.js";
3
+ import type { DispatchOutcome, ExtensionSurface, InvocationContext, ModelRef, WorkflowCommand } from "./types.js";
4
+ export interface RouterDeps<M extends ModelRef = ModelRef> {
5
+ /** Pi's API is session-bound, so the surface is resolved per call. */
6
+ surface: (ctx: InvocationContext<M>) => ExtensionSurface<M>;
7
+ /** Fresh read per dispatch: project trust and file validity change between commands. */
8
+ loadConfig: (ctx: InvocationContext<M>) => LoadedConfig;
9
+ hint: {
10
+ pending(): boolean;
11
+ acknowledge(now?: string): boolean;
12
+ };
13
+ /** Slash name of the settings console, quoted in every refusal. */
14
+ settingsCommand: string;
15
+ /** Command names that actually exist — a route for anything else is a typo. */
16
+ knownCommands: ReadonlySet<string>;
17
+ }
18
+ export interface Router<M extends ModelRef = ModelRef> {
19
+ dispatch(command: WorkflowCommand, args: string, ctx: InvocationContext<M>): Promise<DispatchOutcome>;
20
+ /** Pi `model_select` — distinguishes our own switch from the operator's. */
21
+ noteModelSelect(model: M): void;
22
+ /** Pi `thinking_level_select` — same distinction for the thinking level. */
23
+ noteThinkingLevelSelect(level: ThinkingLevel): void;
24
+ /** Pi `agent_settled` — restores whatever this turn still owns. */
25
+ settle(ctx: InvocationContext<M>): Promise<void>;
26
+ /** True while a routed turn owns the session. The console shows and clears it. */
27
+ inFlight(): boolean;
28
+ /**
29
+ * Undo a routed turn the operator can see will never settle, putting the session
30
+ * back exactly as `settle()` would. Returns false when nothing was in flight.
31
+ */
32
+ undoInFlight(ctx: InvocationContext<M>): Promise<boolean>;
33
+ }
34
+ export declare function createRouter<M extends ModelRef = ModelRef>({ surface, loadConfig, hint, settingsCommand, knownCommands, }: RouterDeps<M>): Router<M>;
@@ -0,0 +1,191 @@
1
+ import { effectiveRoute } from "../config/merge.js";
2
+ import { parseModelReference } from "../config/schema.js";
3
+ import { modelRefKey } from "./types.js";
4
+ /**
5
+ * Put a routed turn's session back (AC8). Shared by `settle()` and the console's
6
+ * release so the two can never disagree about what "restore" means; `why` is only
7
+ * the operator's word for what happened to the turn.
8
+ */
9
+ async function restore(turn, session, ctx, why) {
10
+ if (turn.userChangedModel) {
11
+ if (turn.applied.model || turn.applied.thinking) {
12
+ ctx.notify(`/${turn.command}: ${why}; leaving the model you chose in place.`, "info");
13
+ }
14
+ return;
15
+ }
16
+ const touched = Boolean(turn.applied.model || turn.applied.thinking);
17
+ if (turn.applied.model) {
18
+ if (turn.snapshot.model)
19
+ await session.setModel(turn.snapshot.model);
20
+ else
21
+ ctx.notify(`/${turn.command} switched a session that had no model; nothing to restore.`, "warning");
22
+ }
23
+ if (!touched)
24
+ return;
25
+ // Thinking last, and always: selecting a model re-derives the level inside Pi, so
26
+ // restoring the model alone leaves a model-only route with the operator's level
27
+ // moved (AC8 asks for the session to equal its start). An operator who moved the
28
+ // level themselves keeps it — including over the model restore's side effect.
29
+ const wanted = turn.userChangedThinking && turn.operatorThinking ? turn.operatorThinking : turn.snapshot.thinking;
30
+ if (session.getThinkingLevel() !== wanted)
31
+ session.setThinkingLevel(wanted);
32
+ }
33
+ export function createRouter({ surface, loadConfig, hint, settingsCommand, knownCommands, }) {
34
+ let pending;
35
+ // Reported once per session: an operator who ignores it once does not need it
36
+ // on every command, and a route that matches nothing must not fail silently.
37
+ let unknownRoutesReported = false;
38
+ const refuse = (ctx, reason, message) => {
39
+ ctx.notify(message, "error");
40
+ return { status: "refused", reason, message };
41
+ };
42
+ const configureHint = `Configure routes with /${settingsCommand} or the pi-agentic-workflow.json files.`;
43
+ return {
44
+ noteModelSelect(model) {
45
+ if (!pending)
46
+ return;
47
+ const applied = pending.applied.model;
48
+ // Pi fires `model_select` for our own switch too. Anything else is the
49
+ // operator taking the model, and then we back off entirely — restoring on
50
+ // top of their choice would fight the session.
51
+ if (applied && modelRefKey(applied) === modelRefKey(model))
52
+ return;
53
+ pending.userChangedModel = true;
54
+ },
55
+ noteThinkingLevelSelect(level) {
56
+ if (!pending)
57
+ return;
58
+ // A level we just applied ourselves is not an operator change. A model
59
+ // switch counts as "ours" too: Pi re-derives thinking inside `setModel`,
60
+ // and this router performs that switch.
61
+ if (pending.applied.thinking === level || pending.applied.modelThinking === level)
62
+ return;
63
+ pending.userChangedThinking = true;
64
+ pending.operatorThinking = level;
65
+ },
66
+ inFlight: () => pending !== undefined,
67
+ async undoInFlight(ctx) {
68
+ const turn = pending;
69
+ if (!turn)
70
+ return false;
71
+ pending = undefined;
72
+ await restore(turn, surface(ctx), ctx, `undo: nothing was dispatched by /${turn.command}`);
73
+ return true;
74
+ },
75
+ async settle(ctx) {
76
+ const turn = pending;
77
+ if (!turn)
78
+ return;
79
+ // Cleared first: the restore fires its own select events, and a turn that is
80
+ // already over must not read them as operator changes.
81
+ pending = undefined;
82
+ await restore(turn, surface(ctx), ctx, "finished");
83
+ },
84
+ async dispatch(command, args, ctx) {
85
+ if (pending) {
86
+ // Refuse even when `ctx.isIdle()` reads true: idleness says the agent loop
87
+ // is quiet, not that the routed turn is over, and guessing here is how a
88
+ // session gets restored mid-turn. Pi starts a routed turn inside an action
89
+ // that swallows failures and `prompt()` can throw before the loop runs, so a
90
+ // latch with no turn behind it is real — and the operator releases it through
91
+ // the console (`undoInFlight`), which is why the refusal points there (N-4).
92
+ return refuse(ctx, "routed-turn-in-flight", `/${command.name} refused: /${pending.command} is still routed. Wait for it to settle, or undo it with /${settingsCommand}.`);
93
+ }
94
+ if (!ctx.isIdle()) {
95
+ return refuse(ctx, "busy", `/${command.name} refused: the agent is busy, and routing changes the session model. Run it when the agent is idle.`);
96
+ }
97
+ const loaded = loadConfig(ctx);
98
+ if (!loaded.ok) {
99
+ const detail = loaded.problems
100
+ .map((problem) => `${problem.scope} config, ${problem.path}: ${problem.message}`)
101
+ .join(" | ");
102
+ return refuse(ctx, "invalid-config", `/${command.name} refused: invalid configuration (${detail}). Nothing was dispatched and no model was changed.`);
103
+ }
104
+ if (!unknownRoutesReported) {
105
+ unknownRoutesReported = true;
106
+ const typos = Object.keys(loaded.config.commands).filter((name) => !knownCommands.has(name));
107
+ if (typos.length > 0) {
108
+ ctx.notify(`/${command.name}: these configured routes match no command and do nothing: ${typos.join(", ")}. Fix the spelling or remove them with /${settingsCommand}.`, "warning");
109
+ }
110
+ }
111
+ const session = surface(ctx);
112
+ const route = effectiveRoute(loaded.config, command.name);
113
+ let target;
114
+ if (route.model !== "inherit") {
115
+ const reference = parseModelReference(route.model);
116
+ const found = reference ? ctx.find(reference.provider, reference.id) : undefined;
117
+ const blocker = !found
118
+ ? "is not in the model registry"
119
+ : !ctx.hasConfiguredAuth(found)
120
+ ? "has no configured credentials"
121
+ : undefined;
122
+ if (blocker) {
123
+ if (loaded.config.onUnavailableRoute !== "inherit") {
124
+ return refuse(ctx, "unavailable-route", `/${command.name} stopped: the configured model ${route.model} ${blocker}. ${configureHint}`);
125
+ }
126
+ ctx.notify(`/${command.name}: ${route.model} ${blocker}, so it runs on the current session model. ${configureHint}`, "warning");
127
+ }
128
+ else {
129
+ target = found;
130
+ }
131
+ }
132
+ const snapshot = { model: ctx.model, thinking: session.getThinkingLevel() };
133
+ const applied = { model: undefined, thinking: undefined };
134
+ if (target) {
135
+ const selected = await session.setModel(target);
136
+ if (!selected) {
137
+ if (loaded.config.onUnavailableRoute !== "inherit") {
138
+ return refuse(ctx, "unavailable-route", `/${command.name} stopped: ${route.model} could not be selected. ${configureHint}`);
139
+ }
140
+ ctx.notify(`/${command.name}: ${route.model} could not be selected, so it runs on the current session model. ${configureHint}`, "warning");
141
+ }
142
+ else {
143
+ applied.model = target;
144
+ // Pi re-derives thinking inside `setModel`; whatever level the session
145
+ // holds now came from us, not from the operator.
146
+ applied.modelThinking = session.getThinkingLevel();
147
+ }
148
+ }
149
+ if (route.thinking !== "inherit") {
150
+ session.setThinkingLevel(route.thinking);
151
+ // The *effective* level, never the requested one: Pi clamps a level the model
152
+ // cannot run (`_modelSupportsThinking` → `clampThinkingLevel`) and announces
153
+ // that one a microtask later. Bookkeeping the request made our own clamped
154
+ // write read as an operator move, so the restore preserved the clamp instead
155
+ // of the operator's level (N-3).
156
+ applied.thinking = session.getThinkingLevel() ?? route.thinking;
157
+ }
158
+ let hintShown = false;
159
+ if (hint.pending()) {
160
+ hintShown = true;
161
+ hint.acknowledge();
162
+ ctx.notify(`/${command.name} is running. Per-command models are optional: configure them with /${settingsCommand} or the pi-agentic-workflow.json files. This hint appears once.`, "info");
163
+ }
164
+ if (applied.model || applied.thinking) {
165
+ pending = { command: command.name, snapshot, applied, userChangedModel: false, userChangedThinking: false };
166
+ }
167
+ // Pi expands `/skill:<x>` by the skill's frontmatter `name:`, and passes an
168
+ // unknown key through as literal text — so the name is the only correct
169
+ // wire value. The bundled directory (`command.skill`) is not it: it happens
170
+ // to match today and would silently stop expanding if a skill ever renamed.
171
+ const invocation = args === "" ? `/skill:${command.name}` : `/skill:${command.name} ${args}`;
172
+ // The send is where Pi's own path can throw first (`prompt()` refuses while
173
+ // compaction is in progress, with no model, or without credentials — before
174
+ // the agent loop ever runs). That throw is proof the turn never started, so
175
+ // the routing applied a moment ago is undone here and now: leaving it would
176
+ // wedge the latch behind a refusal that names no cause (N-4).
177
+ try {
178
+ session.sendUserMessage(invocation, { expandPromptTemplates: true });
179
+ }
180
+ catch (error) {
181
+ pending = undefined;
182
+ const rolledBack = Boolean(applied.model || applied.thinking);
183
+ if (rolledBack) {
184
+ await restore({ command: command.name, snapshot, applied, userChangedModel: false, userChangedThinking: false }, session, ctx, `dispatch failed (${error.message}); the session was put back`);
185
+ }
186
+ return refuse(ctx, "dispatch-failed", `/${command.name} was not dispatched: ${error.message}. Nothing was sent${rolledBack ? ", and the session model was put back" : ""}.`);
187
+ }
188
+ return { status: "dispatched", routed: Boolean(applied.model || applied.thinking), hintShown };
189
+ },
190
+ };
191
+ }
@@ -0,0 +1,13 @@
1
+ export declare const stateFilePath: (agentDir: string) => string;
2
+ export interface HintStore {
3
+ /** Whether the hint still needs showing. Cached after the first read. */
4
+ pending(): boolean;
5
+ /** Record that the hint was shown. Returns false when persistence failed. */
6
+ acknowledge(now?: string): boolean;
7
+ }
8
+ export interface HintStoreOptions {
9
+ path: string;
10
+ readFile?: (path: string) => string | null;
11
+ writeFile?: (path: string, text: string) => void;
12
+ }
13
+ export declare function createHintStore({ path, readFile, writeFile, }: HintStoreOptions): HintStore;