@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.
- package/LICENSE +21 -0
- package/README.es.md +154 -0
- package/README.md +148 -0
- package/dist/config/defaults.d.ts +8 -0
- package/dist/config/defaults.js +11 -0
- package/dist/config/load.d.ts +19 -0
- package/dist/config/load.js +76 -0
- package/dist/config/merge.d.ts +4 -0
- package/dist/config/merge.js +43 -0
- package/dist/config/schema.d.ts +21 -0
- package/dist/config/schema.js +144 -0
- package/dist/config/types.d.ts +53 -0
- package/dist/config/types.js +12 -0
- package/dist/extension/factory.d.ts +47 -0
- package/dist/extension/factory.js +49 -0
- package/dist/extension/index.d.ts +4 -0
- package/dist/extension/index.js +84 -0
- package/dist/routing/catalogue.d.ts +35 -0
- package/dist/routing/catalogue.js +79 -0
- package/dist/routing/dispatch.d.ts +34 -0
- package/dist/routing/dispatch.js +191 -0
- package/dist/routing/state.d.ts +13 -0
- package/dist/routing/state.js +65 -0
- package/dist/routing/types.d.ts +90 -0
- package/dist/routing/types.js +3 -0
- package/dist/settings/console.d.ts +55 -0
- package/dist/settings/console.js +266 -0
- package/dist/settings/store.d.ts +3 -0
- package/dist/settings/store.js +24 -0
- package/dist/settings/view.d.ts +11 -0
- package/dist/settings/view.js +46 -0
- package/package.json +59 -0
- package/skills/audit-docs/SKILL.md +171 -0
- package/skills/audit-pr/SKILL.md +201 -0
- package/skills/audit-pr/references/01_MERGE_GATES.md +24 -0
- package/skills/audit-pr/references/02_CLOSURE_AND_SCOPE_GATES.md +83 -0
- package/skills/audit-pr/references/03_AUDIT_PROCESS.md +69 -0
- package/skills/audit-pr/references/04_VERDICT.md +69 -0
- package/skills/audit-pr/references/05_ROUTING_AND_GUARDRAILS.md +58 -0
- package/skills/audit-pr/references/PORTABILITY.md +17 -0
- package/skills/design-feature/SKILL.md +162 -0
- package/skills/design-feature/references/INTERVIEW.md +52 -0
- package/skills/design-feature/references/PORTABILITY.md +15 -0
- package/skills/design-feature/references/UPSERT_EXAMPLE.md +8 -0
- package/skills/design-feature/references/WRITE_AND_UPSERT.md +92 -0
- package/skills/discover-repository-state/SKILL.md +83 -0
- package/skills/execute-phase/SKILL.md +115 -0
- package/skills/execute-phase/references/BATCH_AND_PORTABILITY.md +54 -0
- package/skills/execute-phase/references/CLOSEOUT.md +74 -0
- package/skills/execute-phase/references/DESCOPE.md +35 -0
- package/skills/execute-phase/references/EXECUTION_CONTRACT.md +147 -0
- package/skills/execute-phase/references/FOLDING.md +73 -0
- package/skills/execute-phase/references/FORGE_BODY.md +24 -0
- package/skills/execute-phase/references/HANDOFF.md +21 -0
- package/skills/execute-phase/references/OPPORTUNISTIC_FINDING.md +45 -0
- package/skills/execute-phase/references/PREFLIGHT.md +152 -0
- package/skills/execute-phase/references/UNIT_LOOP.md +88 -0
- package/skills/execute-phase/references/WORKFLOWS_FEATURE.md +37 -0
- package/skills/execute-phase/references/WORKFLOWS_FIX.md +43 -0
- package/skills/execute-phase/references/WORKFLOWS_LEGACY.md +26 -0
- package/skills/execute-phase/references/WORKFLOWS_SMALL_PHASED.md +38 -0
- package/skills/fold-findings/SKILL.md +141 -0
- package/skills/fold-findings/references/FOLD_POLICY.md +43 -0
- package/skills/fold-findings/references/FOLD_PROCESS.md +41 -0
- package/skills/generate-docs/SKILL.md +145 -0
- package/skills/generate-docs/references/ADAPTERS.md +15 -0
- package/skills/generate-docs/references/ADAPTER_DISCOVERY.md +33 -0
- package/skills/generate-docs/references/GENERATION_PROCESS.md +90 -0
- package/skills/init-workspace/SKILL.md +152 -0
- package/skills/init-workspace/references/BOOTSTRAP_DISCOVERY.md +70 -0
- package/skills/init-workspace/references/BOOTSTRAP_WRITE.md +58 -0
- package/skills/init-workspace/references/PORTABILITY.md +16 -0
- package/skills/init-workspace/references/UPGRADE.md +83 -0
- package/skills/log-session/SKILL.md +159 -0
- package/skills/loop-review-fold/SKILL.md +168 -0
- package/skills/orchestration-envelope/SKILL.md +81 -0
- package/skills/orchestration-envelope/references/TURN_CONTRACT.md +21 -0
- package/skills/phase-contract/SKILL.md +82 -0
- package/skills/plan-feature/SKILL.md +179 -0
- package/skills/plan-feature/references/ROUTING.md +83 -0
- package/skills/plan-feature-from-issue/SKILL.md +140 -0
- package/skills/plan-feature-scaffold/SKILL.md +93 -0
- package/skills/plan-feature-scaffold/references/SCAFFOLD_PROCESS.md +76 -0
- package/skills/plan-fix/SKILL.md +115 -0
- package/skills/plan-fix/references/PLANNING_PROCESS.md +118 -0
- package/skills/plan-fix/references/SPEC_CONTRACT.md +22 -0
- package/skills/planning-preflight/SKILL.md +130 -0
- package/skills/product-audit/SKILL.md +225 -0
- package/skills/product-audit/references/AUDIT_DIMENSIONS.md +24 -0
- package/skills/product-audit/references/AUDIT_PROCESS.md +85 -0
- package/skills/resolve-repository-state/SKILL.md +79 -0
- package/skills/review-a11y/SKILL.md +60 -0
- package/skills/review-brand/SKILL.md +65 -0
- package/skills/review-change/SKILL.md +156 -0
- package/skills/review-change/references/ADVERSARIAL_RECOMMENDATION.md +31 -0
- package/skills/review-change/references/ADVERSARIAL_SETUP.md +86 -0
- package/skills/review-change/references/ADVERSARIAL_SYNTHESIS.md +53 -0
- package/skills/review-change/references/OUTPUT_AND_GUARDRAILS.md +85 -0
- package/skills/review-change/references/PERSIST_AND_DECIDE.md +151 -0
- package/skills/review-change/references/PORTABILITY.md +74 -0
- package/skills/review-change/references/REVIEW_PROCESS.md +86 -0
- package/skills/review-code/SKILL.md +69 -0
- package/skills/review-debt/SKILL.md +72 -0
- package/skills/review-design/SKILL.md +60 -0
- package/skills/review-implementation/SKILL.md +108 -0
- package/skills/review-implementation/references/CLASSIFY.md +96 -0
- package/skills/review-implementation/references/FIND.md +35 -0
- package/skills/review-perf/SKILL.md +79 -0
- package/skills/review-security/SKILL.md +65 -0
- package/skills/review-seo/SKILL.md +65 -0
- package/skills/review-verify/SKILL.md +66 -0
- package/skills/ship-roadmap/SKILL.md +120 -0
- package/skills/ship-roadmap/references/ADVANCE.md +90 -0
- package/skills/ship-roadmap/references/AUDIT_AND_MERGE.md +64 -0
- package/skills/ship-roadmap/references/CLOSEOUT_AND_LOG.md +26 -0
- package/skills/ship-roadmap/references/FOUNDING.md +101 -0
- package/skills/ship-roadmap/references/GUARDRAILS.md +56 -0
- package/skills/ship-roadmap/references/MODEL_ROUTING.md +14 -0
- package/skills/ship-roadmap/references/PORTABILITY.md +34 -0
- package/skills/ship-roadmap/references/RECOVERY_AND_SELECTION.md +93 -0
- package/skills/ship-roadmap/references/STOP_CONDITIONS.md +9 -0
- package/skills/ship-roadmap/references/TERMINAL_REPORT.md +34 -0
- package/skills/triage-issue/SKILL.md +148 -0
- package/skills/triage-issue/references/AUDIT_FINDINGS.md +35 -0
- package/skills/triage-issue/references/FOLD_LEDGER.md +32 -0
- package/skills/triage-issue/references/ISSUE_PROCESS.md +101 -0
- package/skills/triage-issue/references/LABELS.md +83 -0
- package/skills/triage-issue/references/REVIEW_FINDING_PROCESS.md +41 -0
- package/skills/verification-contract/SKILL.md +77 -0
- package/skills/workflow-status/SKILL.md +143 -0
- package/skills/workflow-status/references/CRASH_RECOVERY.md +66 -0
- package/skills/workflow-status/references/ENVELOPE_CORE.md +107 -0
- package/skills/workflow-status/references/ENVELOPE_FIELDS.md +77 -0
- package/skills/workflow-status/references/GUARDRAILS.md +17 -0
- package/skills/workflow-status/references/PORTABILITY.md +14 -0
- package/skills/workflow-status/references/SENSOR_CORE.md +95 -0
- package/skills/workflow-status/references/SENSOR_SIGNALS.md +128 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { mkdirSync } from "node:fs";
|
|
4
|
+
/**
|
|
5
|
+
* Global package state (SPEC S9, D-E7): the first-run hint acknowledgement.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately a file of its own next to the config, never inside it — writing
|
|
8
|
+
* state must not rewrite configuration, and reading config must not depend on
|
|
9
|
+
* state. Every failure here is soft: a hint that cannot be persisted is shown
|
|
10
|
+
* again next session, which is a nuisance, not a broken command.
|
|
11
|
+
*/
|
|
12
|
+
const STATE_FILE_NAME = "pi-agentic-workflow-state.json";
|
|
13
|
+
export const stateFilePath = (agentDir) => join(agentDir, STATE_FILE_NAME);
|
|
14
|
+
const readOrNull = (path) => {
|
|
15
|
+
try {
|
|
16
|
+
return readFileSync(path, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const writeThrough = (path, text) => {
|
|
23
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
24
|
+
writeFileSync(path, text);
|
|
25
|
+
};
|
|
26
|
+
export function createHintStore({ path, readFile = readOrNull, writeFile = writeThrough, }) {
|
|
27
|
+
let acknowledged = false;
|
|
28
|
+
const storedAcknowledged = () => {
|
|
29
|
+
const text = readFile(path);
|
|
30
|
+
if (text === null || text.trim() === "")
|
|
31
|
+
return false;
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(text);
|
|
34
|
+
return typeof parsed === "object" && parsed !== null && typeof parsed.firstRunHintShownAt === "string";
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Corrupt state is treated as "never shown", so the operator sees the hint
|
|
38
|
+
// again rather than losing the only pointer to the settings command.
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
pending() {
|
|
44
|
+
if (acknowledged)
|
|
45
|
+
return false;
|
|
46
|
+
if (storedAcknowledged()) {
|
|
47
|
+
acknowledged = true;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
},
|
|
52
|
+
acknowledge(now = new Date().toISOString()) {
|
|
53
|
+
// Latch in memory first: a failing write must not re-show the hint on the
|
|
54
|
+
// next command in this same session.
|
|
55
|
+
acknowledged = true;
|
|
56
|
+
try {
|
|
57
|
+
writeFile(path, `${JSON.stringify({ firstRunHintShownAt: now }, null, 2)}\n`);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "../config/types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The command-surface vocabulary (SPEC "Command surface (api)").
|
|
4
|
+
*
|
|
5
|
+
* Each type here is a NARROWER view of Pi's own API, declared structurally so
|
|
6
|
+
* the routing layer can be tested with plain objects instead of a live session.
|
|
7
|
+
* The one Pi type the router must never touch is the model: Pi's `Model` carries
|
|
8
|
+
* credentials and capability data the package has no business rebuilding, so the
|
|
9
|
+
* router holds it as an opaque `M` and only ever passes back what it received —
|
|
10
|
+
* which is why `setModel` needs no cast in the adapter.
|
|
11
|
+
*/
|
|
12
|
+
/** A routed slash command: the friendly name plus the canonical skill it runs. */
|
|
13
|
+
export interface WorkflowCommand {
|
|
14
|
+
/** Slash name users type, without the leading slash. Equals the skill `name:`. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** Bundled skill directory whose `SKILL.md` is expanded on dispatch. */
|
|
17
|
+
skill: string;
|
|
18
|
+
/** Frontmatter description, shown in Pi's command list. */
|
|
19
|
+
description?: string;
|
|
20
|
+
}
|
|
21
|
+
/** The identity of a model — all this package reads off Pi's `Model`. */
|
|
22
|
+
export interface ModelRef {
|
|
23
|
+
provider: string;
|
|
24
|
+
id: string;
|
|
25
|
+
}
|
|
26
|
+
export declare const modelRefKey: (model: ModelRef) => string;
|
|
27
|
+
/** Model availability: the two questions AC9 asks before a route is applied. */
|
|
28
|
+
export interface ModelLookup<M extends ModelRef = ModelRef> {
|
|
29
|
+
find(provider: string, modelId: string): M | undefined;
|
|
30
|
+
hasConfiguredAuth(model: M): boolean;
|
|
31
|
+
}
|
|
32
|
+
/** The `ExtensionAPI` members that change or run the session. */
|
|
33
|
+
export interface ExtensionSurface<M extends ModelRef = ModelRef> {
|
|
34
|
+
sendUserMessage(content: string, options?: {
|
|
35
|
+
expandPromptTemplates?: boolean;
|
|
36
|
+
}): void;
|
|
37
|
+
/** Resolve a model reference; false when it cannot be used or selected. */
|
|
38
|
+
setModel(model: M): Promise<boolean>;
|
|
39
|
+
getThinkingLevel(): ThinkingLevel;
|
|
40
|
+
setThinkingLevel(level: ThinkingLevel): void;
|
|
41
|
+
}
|
|
42
|
+
/** The interactive slice of `ctx.ui` the settings console uses (AC10). */
|
|
43
|
+
export interface SettingsUi {
|
|
44
|
+
select(title: string, options: readonly string[]): Promise<string | undefined> | string | undefined;
|
|
45
|
+
input(title: string, placeholder?: string): Promise<string | undefined> | string | undefined;
|
|
46
|
+
confirm(title: string, message: string): Promise<boolean> | boolean;
|
|
47
|
+
notify(message: string, kind?: "info" | "warning" | "error"): void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Everything a command handler needs from the invocation, reduced to what we
|
|
51
|
+
* read. `ModelLookup` is part of it because Pi exposes the registry through the
|
|
52
|
+
* context, not through the API object.
|
|
53
|
+
*/
|
|
54
|
+
export interface InvocationContext<M extends ModelRef = ModelRef> extends ModelLookup<M> {
|
|
55
|
+
readonly cwd: string;
|
|
56
|
+
/** The session's current model, or undefined when none is selected. */
|
|
57
|
+
readonly model: M | undefined;
|
|
58
|
+
isIdle(): boolean;
|
|
59
|
+
isProjectTrusted(): boolean;
|
|
60
|
+
/** Pi's `ctx.ui.notify` — the only channel an extension has to the operator. */
|
|
61
|
+
notify(message: string, kind?: "info" | "warning" | "error"): void;
|
|
62
|
+
/** Pi's `ctx.ui`. Routing only notifies through it; the console also asks. */
|
|
63
|
+
readonly ui: SettingsUi;
|
|
64
|
+
/** Pi's `ctx.modelRegistry.getAll()` — what the console can offer to pick from. */
|
|
65
|
+
availableModels(): readonly M[];
|
|
66
|
+
}
|
|
67
|
+
export type RefusalReason = "invalid-config" | "busy" | "routed-turn-in-flight" | "unavailable-route"
|
|
68
|
+
/** `sendUserMessage` itself threw: the turn never started, so the routing is undone. */
|
|
69
|
+
| "dispatch-failed";
|
|
70
|
+
export type DispatchOutcome = {
|
|
71
|
+
status: "dispatched";
|
|
72
|
+
routed: boolean;
|
|
73
|
+
hintShown: boolean;
|
|
74
|
+
} | {
|
|
75
|
+
status: "refused";
|
|
76
|
+
reason: RefusalReason;
|
|
77
|
+
message: string;
|
|
78
|
+
};
|
|
79
|
+
/** Slash name of the settings console (SPEC S4, AC3, AC10). */
|
|
80
|
+
export declare const SETTINGS_COMMAND = "agentic-workflow-settings";
|
|
81
|
+
/**
|
|
82
|
+
* The router, as the settings console needs it: two verbs, already bound to the
|
|
83
|
+
* session they act on. Declared once because both the console's deps and the
|
|
84
|
+
* factory's handler type speak it, and a console that could call `settle` or
|
|
85
|
+
* `dispatch` would be a second owner of the latch.
|
|
86
|
+
*/
|
|
87
|
+
export interface RoutingControls {
|
|
88
|
+
inFlight(): boolean;
|
|
89
|
+
undoInFlight(): Promise<boolean>;
|
|
90
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { RoutingControls, SettingsUi } from "../routing/types.js";
|
|
2
|
+
import type { ConfigFile } from "../config/types.js";
|
|
3
|
+
export interface SettingsDeps {
|
|
4
|
+
ui: SettingsUi;
|
|
5
|
+
agentDir: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
projectTrusted: boolean;
|
|
8
|
+
/** Routed command names — what the operator may attach an override to. */
|
|
9
|
+
commands: readonly string[];
|
|
10
|
+
/** `provider/modelId` references from the live registry, when one is reachable. */
|
|
11
|
+
models?: readonly string[];
|
|
12
|
+
readFile(path: string): string | null;
|
|
13
|
+
writeFile(path: string, text: string): void;
|
|
14
|
+
/**
|
|
15
|
+
* The router's live state, when the console was opened from a session that owns
|
|
16
|
+
* one. A routed turn that never settled — Pi starts it inside an action that
|
|
17
|
+
* swallows failures — leaves the latch held with no `agent_settled` coming, and
|
|
18
|
+
* every later command refuses. The operator needs a way out that is not
|
|
19
|
+
* "restart Pi", and the console is the only surface with room to explain it.
|
|
20
|
+
*/
|
|
21
|
+
routing?: RoutingControls;
|
|
22
|
+
}
|
|
23
|
+
export type ConsoleOutcome = {
|
|
24
|
+
status: "saved";
|
|
25
|
+
scope: "global" | "project";
|
|
26
|
+
path: string;
|
|
27
|
+
file: ConfigFile;
|
|
28
|
+
} | {
|
|
29
|
+
status: "cancelled";
|
|
30
|
+
edited: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** The console's questions. `test/settings-console.test.mjs` drives the flow
|
|
33
|
+
* through these strings, so renaming one fails the tests that use it rather than
|
|
34
|
+
* silently re-sequencing them. */
|
|
35
|
+
export declare const prompts: {
|
|
36
|
+
readonly scope: "Which file should the console edit?";
|
|
37
|
+
readonly menu: "What do you want to change?";
|
|
38
|
+
readonly inFlight: (command: string) => string;
|
|
39
|
+
readonly undoInFlight: "Undo a routing that never started";
|
|
40
|
+
readonly undone: "The routing was undone and the session put back.";
|
|
41
|
+
readonly command: "Which command?";
|
|
42
|
+
readonly policyChoice: "What should happen when a configured model is unavailable?";
|
|
43
|
+
readonly saveTo: (path: string) => string;
|
|
44
|
+
readonly discard: "Discard the draft?";
|
|
45
|
+
readonly setDefaultRoute: "Set the default route";
|
|
46
|
+
readonly setOverride: "Set a command override";
|
|
47
|
+
readonly clearOverride: "Clear a command override";
|
|
48
|
+
readonly policy: "Set the unavailable-route policy";
|
|
49
|
+
readonly save: "Save";
|
|
50
|
+
readonly cancel: "Cancel";
|
|
51
|
+
readonly model: (target: string) => string;
|
|
52
|
+
readonly modelPicked: (target: string) => string;
|
|
53
|
+
readonly thinking: (target: string) => string;
|
|
54
|
+
};
|
|
55
|
+
export declare function runSettingsConsole(deps: SettingsDeps): Promise<ConsoleOutcome>;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// src/settings/console.ts
|
|
2
|
+
// `/agentic-workflow-settings` (SPEC S4, S11, AC10, D-P8).
|
|
3
|
+
//
|
|
4
|
+
// The console edits **one file at a time** but always opens on the **merged**
|
|
5
|
+
// view, because "what will this command actually run on?" is the only question an
|
|
6
|
+
// operator has, and the answer depends on both scopes. Two rules keep it honest:
|
|
7
|
+
// a scope whose file does not parse cannot be overwritten (that file is evidence
|
|
8
|
+
// the operator must fix, not noise to clobber), and the project scope is refused
|
|
9
|
+
// while the project is untrusted — the same gate the loader applies (AC13).
|
|
10
|
+
import { loadConfig, configFilePaths } from "../config/load.js";
|
|
11
|
+
import { parseConfigFile, parseModelReference } from "../config/schema.js";
|
|
12
|
+
import { THINKING_LEVELS, UNAVAILABLE_ROUTE_POLICIES } from "../config/types.js";
|
|
13
|
+
import { renderMergedConfig, routePath, DEFAULT_ROUTE } from "./view.js";
|
|
14
|
+
/** The console's questions. `test/settings-console.test.mjs` drives the flow
|
|
15
|
+
* through these strings, so renaming one fails the tests that use it rather than
|
|
16
|
+
* silently re-sequencing them. */
|
|
17
|
+
export const prompts = {
|
|
18
|
+
scope: "Which file should the console edit?",
|
|
19
|
+
menu: "What do you want to change?",
|
|
20
|
+
inFlight: (command) => `a routed /${command} is still held`,
|
|
21
|
+
undoInFlight: "Undo a routing that never started",
|
|
22
|
+
undone: "The routing was undone and the session put back.",
|
|
23
|
+
command: "Which command?",
|
|
24
|
+
policyChoice: "What should happen when a configured model is unavailable?",
|
|
25
|
+
saveTo: (path) => `Save the draft to ${path}?`,
|
|
26
|
+
discard: "Discard the draft?",
|
|
27
|
+
setDefaultRoute: "Set the default route",
|
|
28
|
+
setOverride: "Set a command override",
|
|
29
|
+
clearOverride: "Clear a command override",
|
|
30
|
+
policy: "Set the unavailable-route policy",
|
|
31
|
+
save: "Save",
|
|
32
|
+
cancel: "Cancel",
|
|
33
|
+
model: (target) => `Model for ${target}?`,
|
|
34
|
+
modelPicked: (target) => `Which model for ${target}?`,
|
|
35
|
+
thinking: (target) => `Thinking level for ${target}?`,
|
|
36
|
+
};
|
|
37
|
+
const GLOBAL_LABEL = "Global";
|
|
38
|
+
const PROJECT_LABEL = "Project";
|
|
39
|
+
const TYPED = "Type another reference…";
|
|
40
|
+
const INHERIT = "inherit";
|
|
41
|
+
export async function runSettingsConsole(deps) {
|
|
42
|
+
const paths = configFilePaths(deps.agentDir, deps.cwd);
|
|
43
|
+
deps.ui.notify(renderMergedConfig(loadConfig({ agentDir: deps.agentDir, cwd: deps.cwd, projectTrusted: deps.projectTrusted, readFile: deps.readFile }), deps.commands).join("\n"), "info");
|
|
44
|
+
const opened = await openAScope(deps, paths.global, paths.project);
|
|
45
|
+
if (!opened)
|
|
46
|
+
return { status: "cancelled", edited: false };
|
|
47
|
+
const { scope, path, original } = opened;
|
|
48
|
+
let draft = original;
|
|
49
|
+
for (;;) {
|
|
50
|
+
// Only offered while a turn actually holds the latch: an option that does
|
|
51
|
+
// nothing is how a console becomes noise.
|
|
52
|
+
const choice = await deps.ui.select(prompts.menu, [
|
|
53
|
+
prompts.setDefaultRoute,
|
|
54
|
+
prompts.setOverride,
|
|
55
|
+
prompts.clearOverride,
|
|
56
|
+
prompts.policy,
|
|
57
|
+
...(deps.routing?.inFlight() ? [prompts.undoInFlight] : []),
|
|
58
|
+
prompts.save,
|
|
59
|
+
prompts.cancel,
|
|
60
|
+
]);
|
|
61
|
+
if (choice === prompts.undoInFlight) {
|
|
62
|
+
const undone = (await deps.routing?.undoInFlight()) ?? false;
|
|
63
|
+
deps.ui.notify(undone ? prompts.undone : "Nothing was in flight.", undone ? "info" : "warning");
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (choice === undefined || choice === prompts.cancel) {
|
|
67
|
+
return { status: "cancelled", edited: dirty(draft, original) ? await discard(deps.ui) : false };
|
|
68
|
+
}
|
|
69
|
+
if (choice === prompts.setDefaultRoute) {
|
|
70
|
+
const edited = await editRoute(deps, DEFAULT_ROUTE);
|
|
71
|
+
if (edited)
|
|
72
|
+
draft = { ...draft, default: edited };
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (choice === prompts.setOverride) {
|
|
76
|
+
const name = await pickCommand(deps, commandChoices(deps, draft));
|
|
77
|
+
if (name === undefined)
|
|
78
|
+
continue;
|
|
79
|
+
const route = await editRoute(deps, name);
|
|
80
|
+
if (route)
|
|
81
|
+
draft = { ...draft, commands: { ...draft.commands, [name]: route } };
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (choice === prompts.clearOverride) {
|
|
85
|
+
const name = await pickCommand(deps, Object.keys(draft.commands ?? {}));
|
|
86
|
+
if (name === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
const rest = { ...draft.commands };
|
|
89
|
+
delete rest[name];
|
|
90
|
+
draft = { ...draft, commands: rest };
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (choice === prompts.policy) {
|
|
94
|
+
const picked = await deps.ui.select(prompts.policyChoice, [...UNAVAILABLE_ROUTE_POLICIES]);
|
|
95
|
+
if (isPolicy(picked))
|
|
96
|
+
draft = { ...draft, onUnavailableRoute: picked };
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (choice === prompts.save) {
|
|
100
|
+
const saved = await saveScope(deps, scope, path, draft);
|
|
101
|
+
if (saved)
|
|
102
|
+
return { status: "saved", scope, path, file: clean(draft) };
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
deps.ui.notify(`Unknown choice: ${String(choice)}`, "warning");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Ask for a scope until one can be edited: an untrusted project is refused
|
|
110
|
+
* without being read, and a scope whose file does not parse is refused so the
|
|
111
|
+
* operator's own file survives (AC10, AC13).
|
|
112
|
+
*/
|
|
113
|
+
async function openAScope(deps, globalPath, projectPath) {
|
|
114
|
+
for (;;) {
|
|
115
|
+
const label = await deps.ui.select(prompts.scope, [GLOBAL_LABEL, PROJECT_LABEL]);
|
|
116
|
+
if (!label)
|
|
117
|
+
return undefined;
|
|
118
|
+
const scope = label === PROJECT_LABEL ? "project" : "global";
|
|
119
|
+
const path = scope === "project" ? projectPath : globalPath;
|
|
120
|
+
if (scope === "project" && !deps.projectTrusted) {
|
|
121
|
+
deps.ui.notify(`${path} is not editable while this project is untrusted — trust the project, or edit the global file instead.`, "warning");
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const text = deps.readFile(path);
|
|
125
|
+
if (text === null)
|
|
126
|
+
return { scope, path, original: {} };
|
|
127
|
+
const parsed = parseFile(text, scope);
|
|
128
|
+
if (parsed.problems.length > 0) {
|
|
129
|
+
deps.ui.notify([`Nothing can be saved over an invalid ${scope} file (${path}):`, ...describe(parsed.problems)].join("\n"), "error");
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
return { scope, path, original: parsed.file };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Ask for a model and a thinking level; `undefined` means nothing changed. */
|
|
136
|
+
async function editRoute(deps, target) {
|
|
137
|
+
const model = await askModel(deps, target);
|
|
138
|
+
if (model === undefined)
|
|
139
|
+
return undefined;
|
|
140
|
+
const thinking = await askThinking(deps, target);
|
|
141
|
+
if (thinking === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
return { model, thinking };
|
|
144
|
+
}
|
|
145
|
+
async function askModel(deps, target) {
|
|
146
|
+
let answer;
|
|
147
|
+
if (deps.models && deps.models.length > 0) {
|
|
148
|
+
answer = await deps.ui.select(prompts.modelPicked(target), [...deps.models, TYPED]);
|
|
149
|
+
if (answer === TYPED || answer === undefined)
|
|
150
|
+
answer = await deps.ui.input(prompts.model(target), "provider/modelId or inherit");
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
answer = await deps.ui.input(prompts.model(target), "provider/modelId or inherit");
|
|
154
|
+
}
|
|
155
|
+
if (answer === undefined)
|
|
156
|
+
return undefined;
|
|
157
|
+
const value = answer.trim();
|
|
158
|
+
if (value === INHERIT)
|
|
159
|
+
return INHERIT;
|
|
160
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(value)) {
|
|
161
|
+
// Rejected in the operator's terms and in the schema's: the value, and the
|
|
162
|
+
// field path the loader would name for the same mistake in a file (AC5).
|
|
163
|
+
deps.ui.notify(`Rejected: model must be "provider/modelId" or "inherit" — nothing was changed (${routePath(target)}.model).`, "error");
|
|
164
|
+
return undefined;
|
|
165
|
+
}
|
|
166
|
+
const parts = parseModelReference(value);
|
|
167
|
+
return parts ? `${parts.provider}/${parts.id}` : undefined;
|
|
168
|
+
}
|
|
169
|
+
async function askThinking(deps, target) {
|
|
170
|
+
const answer = await deps.ui.select(prompts.thinking(target), [...THINKING_LEVELS, INHERIT]);
|
|
171
|
+
if (answer === undefined)
|
|
172
|
+
return undefined;
|
|
173
|
+
if (answer === INHERIT || isThinkingLevel(answer))
|
|
174
|
+
return answer;
|
|
175
|
+
deps.ui.notify(`Rejected: thinking must be one of ${THINKING_LEVELS.join(", ")}, or "inherit" (${routePath(target)}.thinking).`, "error");
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
async function pickCommand(deps, options) {
|
|
179
|
+
if (options.length === 0) {
|
|
180
|
+
deps.ui.notify("There is no command to pick here.", "warning");
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
return deps.ui.select(prompts.command, [...options].sort((a, b) => a.localeCompare(b)));
|
|
184
|
+
}
|
|
185
|
+
function commandChoices(deps, draft) {
|
|
186
|
+
return [...new Set([...deps.commands, ...Object.keys(draft.commands ?? {})])];
|
|
187
|
+
}
|
|
188
|
+
async function saveScope(deps, scope, path, draft) {
|
|
189
|
+
const file = clean(draft);
|
|
190
|
+
const text = `${JSON.stringify(file, null, 2)}\n`;
|
|
191
|
+
const problems = problemsFor(file, scope);
|
|
192
|
+
if (problems.length > 0) {
|
|
193
|
+
deps.ui.notify(["Nothing saved — the draft is invalid:", ...describe(problems)].join("\n"), "error");
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
if (!(await deps.ui.confirm(prompts.saveTo(path), describeRouting(file)))) {
|
|
197
|
+
deps.ui.notify("Nothing saved.", "info");
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
deps.writeFile(path, text);
|
|
201
|
+
deps.ui.notify(`${scope === "project" ? "Project" : "Global"} routing saved to ${path}`, "info");
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
async function discard(ui) {
|
|
205
|
+
const dropped = await ui.confirm(prompts.discard, "Your edits are not in any config file yet.");
|
|
206
|
+
if (!dropped)
|
|
207
|
+
ui.notify("Draft kept — nothing was saved.", "info");
|
|
208
|
+
return Boolean(dropped);
|
|
209
|
+
}
|
|
210
|
+
function problemsFor(file, scope) {
|
|
211
|
+
// The draft is judged exactly as the file it would become — one validator, no
|
|
212
|
+
// second opinion that could disagree with the loader (P2).
|
|
213
|
+
return parseFile(JSON.stringify(file), scope).problems;
|
|
214
|
+
}
|
|
215
|
+
function withScope(issue, scope) {
|
|
216
|
+
return { scope, path: issue.path, message: issue.message };
|
|
217
|
+
}
|
|
218
|
+
/** The line the save confirmation repeats back, so the operator signs what they see. */
|
|
219
|
+
function describeRouting(file) {
|
|
220
|
+
const routes = [
|
|
221
|
+
file.default ? `default: ${file.default.model ?? "inherit"} / ${file.default.thinking ?? "inherit"}` : "default: inherit / inherit",
|
|
222
|
+
...Object.entries(file.commands ?? {}).map(([name, route]) => `${name}: ${route.model ?? "inherit"} / ${route.thinking ?? "inherit"}`),
|
|
223
|
+
`unavailable: ${file.onUnavailableRoute ?? "stop"}`,
|
|
224
|
+
];
|
|
225
|
+
return routes.join(" · ");
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Keep everything the file held or the operator set; drop only what says nothing
|
|
229
|
+
* (an empty route). Eliding by VALUE — treating an explicit `inherit` or `stop` as
|
|
230
|
+
* absent because a shipped default happens to agree — silently discards the only
|
|
231
|
+
* two moves that matter at project scope: switching a command off under a global
|
|
232
|
+
* route, and re-arming fail-closed over a global `inherit` (F4).
|
|
233
|
+
*/
|
|
234
|
+
function clean(draft) {
|
|
235
|
+
const file = {};
|
|
236
|
+
if (draft.default && Object.keys(draft.default).length > 0)
|
|
237
|
+
file.default = { ...draft.default };
|
|
238
|
+
const commands = {};
|
|
239
|
+
for (const [name, route] of Object.entries(draft.commands ?? {})) {
|
|
240
|
+
if (Object.keys(route).length > 0)
|
|
241
|
+
commands[name] = { ...route };
|
|
242
|
+
}
|
|
243
|
+
if (Object.keys(commands).length > 0)
|
|
244
|
+
file.commands = commands;
|
|
245
|
+
if (draft.onUnavailableRoute)
|
|
246
|
+
file.onUnavailableRoute = draft.onUnavailableRoute;
|
|
247
|
+
return file;
|
|
248
|
+
}
|
|
249
|
+
function parseFile(text, scope) {
|
|
250
|
+
const result = parseConfigFile(text);
|
|
251
|
+
return result.ok
|
|
252
|
+
? { file: result.config, problems: [] }
|
|
253
|
+
: { file: {}, problems: result.issues.map((issue) => withScope(issue, scope)) };
|
|
254
|
+
}
|
|
255
|
+
function describe(problems) {
|
|
256
|
+
return problems.map((problem) => ` ${problem.path}: ${problem.message}`);
|
|
257
|
+
}
|
|
258
|
+
function dirty(a, b) {
|
|
259
|
+
return JSON.stringify(clean(a)) !== JSON.stringify(clean(b));
|
|
260
|
+
}
|
|
261
|
+
function isThinkingLevel(value) {
|
|
262
|
+
return THINKING_LEVELS.includes(value);
|
|
263
|
+
}
|
|
264
|
+
function isPolicy(value) {
|
|
265
|
+
return value === "stop" || value === "inherit";
|
|
266
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// src/settings/store.ts
|
|
2
|
+
// The real filesystem behind the settings console's save (SPEC S4, AC10).
|
|
3
|
+
//
|
|
4
|
+
// Synchronous, like the loader: these are two small JSON files, and a read that
|
|
5
|
+
// could not block would make the console's "what is in effect right now" view
|
|
6
|
+
// race the dispatcher. Config is written 0600 with its parent created, because
|
|
7
|
+
// the project scope's `.pi/` may not exist yet in a fresh clone.
|
|
8
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
/** `null` when the file is absent, which the console reads as "nothing configured here". */
|
|
11
|
+
export function readConfigFile(path) {
|
|
12
|
+
try {
|
|
13
|
+
return readFileSync(path, "utf8");
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error.code === "ENOENT")
|
|
17
|
+
return null;
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function writeConfigFile(path, text) {
|
|
22
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
23
|
+
writeFileSync(path, text, { mode: 0o600 });
|
|
24
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { LoadedConfig } from "../config/load.js";
|
|
2
|
+
/** The label for the unqualified route, so `default` is never mistaken for a command name. */
|
|
3
|
+
export declare const DEFAULT_ROUTE = "the default route";
|
|
4
|
+
/**
|
|
5
|
+
* The field path the schema would report for a route target, e.g.
|
|
6
|
+
* `$.commands.plan-feature.model` — the same shape a config problem uses (AC5),
|
|
7
|
+
* so a rejection and a file error read identically to the operator.
|
|
8
|
+
*/
|
|
9
|
+
export declare function routePath(target: string): string;
|
|
10
|
+
/** What the operator reads when the console opens. */
|
|
11
|
+
export declare function renderMergedConfig(loaded: LoadedConfig, commands: readonly string[]): string[];
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// src/settings/view.ts
|
|
2
|
+
// The settings console's display half (AC10, SPEC S4): what is in effect right
|
|
3
|
+
// now, and which files the values came from.
|
|
4
|
+
/** The label for the unqualified route, so `default` is never mistaken for a command name. */
|
|
5
|
+
export const DEFAULT_ROUTE = "the default route";
|
|
6
|
+
/**
|
|
7
|
+
* The field path the schema would report for a route target, e.g.
|
|
8
|
+
* `$.commands.plan-feature.model` — the same shape a config problem uses (AC5),
|
|
9
|
+
* so a rejection and a file error read identically to the operator.
|
|
10
|
+
*/
|
|
11
|
+
export function routePath(target) {
|
|
12
|
+
return target === DEFAULT_ROUTE ? "$.default" : `$.commands.${target}`;
|
|
13
|
+
}
|
|
14
|
+
/** What the operator reads when the console opens. */
|
|
15
|
+
export function renderMergedConfig(loaded, commands) {
|
|
16
|
+
return [
|
|
17
|
+
"agentic-workflow routing — what each command runs on right now",
|
|
18
|
+
...configLines(loaded.config, commands),
|
|
19
|
+
...problems(loaded.problems),
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
function configLines(config, commands) {
|
|
23
|
+
const overrides = Object.entries(config.commands).sort(([a], [b]) => a.localeCompare(b));
|
|
24
|
+
const total = Math.max(commands.length, overrides.length);
|
|
25
|
+
return [
|
|
26
|
+
` default: ${route(config.default)}`,
|
|
27
|
+
...(overrides.length === 0
|
|
28
|
+
? [" no per-command overrides — every command uses the default route"]
|
|
29
|
+
: [
|
|
30
|
+
...overrides.map(([name, value]) => ` ${name}: ${route(value)}`),
|
|
31
|
+
` ${overrides.length} of ${total} commands override the default route`,
|
|
32
|
+
]),
|
|
33
|
+
` when a configured model is unavailable: ${config.onUnavailableRoute}`,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
function route(value) {
|
|
37
|
+
return `${value.model} / ${value.thinking}`;
|
|
38
|
+
}
|
|
39
|
+
function problems(list) {
|
|
40
|
+
if (list.length === 0)
|
|
41
|
+
return [];
|
|
42
|
+
return [
|
|
43
|
+
" files with problems — the values above fall back to the shipped defaults:",
|
|
44
|
+
...list.map((issue) => ` ${issue.scope} config, ${issue.path}: ${issue.message}`),
|
|
45
|
+
];
|
|
46
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gtrabanco/pi-agentic-workflow",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi package: canonical agentic-workflow skills, friendly slash commands, and per-command model routing.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"agentic-workflow",
|
|
8
|
+
"pi",
|
|
9
|
+
"skills",
|
|
10
|
+
"workflow",
|
|
11
|
+
"model-routing"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Gabriel Trabanco <gtrabanco@users.noreply.github.com>",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/gtrabanco/agentic-workflow.git",
|
|
18
|
+
"directory": "packages/pi-agentic-workflow"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/gtrabanco/agentic-workflow/tree/main/packages/pi-agentic-workflow#readme",
|
|
21
|
+
"type": "module",
|
|
22
|
+
"pi": {
|
|
23
|
+
"extensions": [
|
|
24
|
+
"./dist/extension/index.js"
|
|
25
|
+
],
|
|
26
|
+
"skills": [
|
|
27
|
+
"./skills"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"skills",
|
|
33
|
+
"package.json",
|
|
34
|
+
"README.md",
|
|
35
|
+
"README.es.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=18"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc",
|
|
46
|
+
"bundle:skills": "node scripts/bundle-skills.mjs",
|
|
47
|
+
"test": "tsc && node --test test/*.test.mjs",
|
|
48
|
+
"prepublishOnly": "npm run build && npm test",
|
|
49
|
+
"mutation": "node scripts/mutation-check.mjs",
|
|
50
|
+
"prepare": "npm run build"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@types/node": "^24",
|
|
57
|
+
"typescript": "6"
|
|
58
|
+
}
|
|
59
|
+
}
|