@gtrabanco/pi-agentic-workflow 0.26.0 → 0.31.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/README.md +82 -0
- package/dist/config/defaults.js +3 -0
- package/dist/config/merge.js +5 -0
- package/dist/config/schema.d.ts +0 -5
- package/dist/config/schema.js +31 -1
- package/dist/config/types.d.ts +15 -0
- package/dist/extension/engram.d.ts +119 -0
- package/dist/extension/engram.js +187 -0
- package/dist/extension/factory.d.ts +6 -0
- package/dist/extension/factory.js +9 -0
- package/dist/extension/index.js +86 -1
- package/dist/routing/serena-readiness.d.ts +77 -0
- package/dist/routing/serena-readiness.js +142 -0
- package/dist/routing/state.d.ts +32 -0
- package/dist/routing/state.js +51 -0
- package/dist/settings/console.js +68 -22
- package/package.json +5 -3
- package/skills/execute-phase/SKILL.md +11 -2
- package/skills/init-workspace/SKILL.md +30 -11
- package/skills/init-workspace/references/BOOTSTRAP_DISCOVERY.md +6 -0
- package/skills/init-workspace/references/BOOTSTRAP_WRITE.md +21 -12
- package/skills/init-workspace/references/ENGRAM.md +107 -0
- package/skills/init-workspace/references/SERENA.md +92 -6
- package/skills/init-workspace/references/UPGRADE.md +9 -7
- package/skills/log-session/SKILL.md +17 -1
- package/skills/unit-lane/SKILL.md +17 -1
- package/skills/unit-lane/references/MEMORY.md +179 -0
package/README.md
CHANGED
|
@@ -146,6 +146,47 @@ set of routes, and you can define as many as you like under `profiles`:
|
|
|
146
146
|
The built-in `nan` profile is shown but not editable — create a profile to
|
|
147
147
|
override it.
|
|
148
148
|
|
|
149
|
+
## Serena (ships by default)
|
|
150
|
+
|
|
151
|
+
Installing this package also gives you **Serena's language-server tools** —
|
|
152
|
+
`serena_find_symbol`, `serena_find_referencing_symbols`,
|
|
153
|
+
`serena_get_symbols_overview`, `serena_find_declaration`, diagnostics and the
|
|
154
|
+
rest. Two declarations make that work, and they ship together: the exact-pinned
|
|
155
|
+
`@bacnh85/pi-serena` dependency, and a second `pi.extensions` entry pointing at
|
|
156
|
+
that dependency's own extension. Pi installs a package's dependencies but
|
|
157
|
+
activates only the package's own `pi.extensions` entries, so the pin alone would
|
|
158
|
+
install a toolset nothing ever loaded.
|
|
159
|
+
|
|
160
|
+
**You must run `/init-workspace` once per project.** This package never writes or
|
|
161
|
+
repairs `.serena/project.yml` — `init-workspace` is its only writer, so there is
|
|
162
|
+
one implementation of that contract and no drift. A project whose config is
|
|
163
|
+
missing, lists no `language_servers`, or was provisioned by an older bundled
|
|
164
|
+
`init-workspace` gets **one** notification per session state pointing at
|
|
165
|
+
`/init-workspace`; the latch lives in this package's state file, so a restart does
|
|
166
|
+
not re-nag.
|
|
167
|
+
|
|
168
|
+
Serena itself is a local tool: install the machine half with `uv tool install -p
|
|
169
|
+
3.13 serena-agent` (or `/init-workspace`, which offers it). Nothing is uploaded
|
|
170
|
+
and no key is configured.
|
|
171
|
+
|
|
172
|
+
**Switching it off.** pi owns package resources, so the off switch is pi's, not a
|
|
173
|
+
key of ours: `pi config` opens the resource picker (tab switches scope) and lets
|
|
174
|
+
you disable the package's resources, and the same filter is expressible in
|
|
175
|
+
settings under `packages`:
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"packages": [
|
|
180
|
+
{ "source": "npm:@gtrabanco/pi-agentic-workflow", "extensions": ["./dist/extension/index.js"] }
|
|
181
|
+
]
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
That keeps the workflow's own extension and commands and drops the Serena
|
|
186
|
+
extension entry, so the `serena_*` tools are never registered. There is no
|
|
187
|
+
settings key inside this package for it: the entry is declared in the manifest,
|
|
188
|
+
and a config key cannot gate a manifest-declared extension.
|
|
189
|
+
|
|
149
190
|
## Path protection (optional)
|
|
150
191
|
|
|
151
192
|
The Tier 2 preventive guard blocks a `write` / `edit` tool call to an existing
|
|
@@ -172,6 +213,47 @@ notification. `requirements` is keyed by phase state (`pre-freeze`, `post-freeze
|
|
|
172
213
|
matching `path-protection-records@1` `justification` row in the unit's
|
|
173
214
|
`decisions.md`.
|
|
174
215
|
|
|
216
|
+
## Memory substrate — Engram (optional, ask-first)
|
|
217
|
+
|
|
218
|
+
The workflow's units save and recall their decisions through Engram, the memory
|
|
219
|
+
layer its skills document. This package can register Engram's MCP server for the
|
|
220
|
+
session, and it does so **only when you say so**:
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"memory": { "engram": true }
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Registration goes through Pi's own `registerMcpServer`, which is
|
|
229
|
+
**session-scoped and not persisted** — the package registers on every session
|
|
230
|
+
start, so nothing outlives the session and nothing is written to your `mcp.json`
|
|
231
|
+
behind your back. Five rules decide the outcome, and the package reports it
|
|
232
|
+
verbatim:
|
|
233
|
+
|
|
234
|
+
| Situation | What happens |
|
|
235
|
+
|---|---|
|
|
236
|
+
| no `memory.engram` opt-in (the shipped default is `false`) | nothing is registered and nothing is said (the opt-out stays quiet) |
|
|
237
|
+
| no readable opt-in — the file exists but does not validate | nothing is registered; you are told the config is invalid, because unreadable consent is never read as a refusal you made |
|
|
238
|
+
| no `engram` binary on `PATH` | nothing is registered; you are told the install route |
|
|
239
|
+
| you already declared `engram` in a `mcp.json` Pi reads — your user file, or `<cwd>/.pi/mcp.json` in a trusted project | nothing is registered — your declaration takes precedence |
|
|
240
|
+
| binary present, opted in | the server is registered and the notification states exactly what was written |
|
|
241
|
+
|
|
242
|
+
Install Engram with:
|
|
243
|
+
|
|
244
|
+
```sh
|
|
245
|
+
pi install npm:gentle-engram@latest
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
That is the only documented route on purpose: the CLI's own setup subcommand
|
|
249
|
+
pins whatever version ran it and keeps serving that version until it is run
|
|
250
|
+
again, so an updated binary can sit unused behind a stale registration.
|
|
251
|
+
|
|
252
|
+
An older Pi host whose `ExtensionAPI` has no `registerMcpServer` is reported
|
|
253
|
+
instead of failing the session. The workflow-side protocol — when units save,
|
|
254
|
+
the recall order, the degraded path — lives in `unit-lane`'s memory reference,
|
|
255
|
+
not here.
|
|
256
|
+
|
|
175
257
|
## When a configured model is unavailable
|
|
176
258
|
|
|
177
259
|
Default: the command **refuses to start** and tells you why — the model is not in
|
package/dist/config/defaults.js
CHANGED
|
@@ -17,6 +17,9 @@ export const DEFAULT_CONFIG = {
|
|
|
17
17
|
// Feature 63 — model profiles: recommended built-in profiles, empty
|
|
18
18
|
// named profiles, empty order (default-only), and default demotion policy.
|
|
19
19
|
recommendedModels: true,
|
|
20
|
+
// Feature 66 — the memory substrate is ask-first: nothing is provisioned
|
|
21
|
+
// until the operator opts in.
|
|
22
|
+
memory: { engram: false },
|
|
20
23
|
profiles: {},
|
|
21
24
|
profileOrder: [],
|
|
22
25
|
profileFallback: { ...DEFAULT_PROFILE_FALLBACK },
|
package/dist/config/merge.js
CHANGED
|
@@ -127,6 +127,11 @@ export function mergeConfigs(globalFile = {}, projectFile = {}) {
|
|
|
127
127
|
pathProtection: intersectPathPolicy(SHIPPED_PATH_POLICY, mergePathProtectionOverrides(globalFile.pathProtection, projectFile.pathProtection)),
|
|
128
128
|
// Feature 63 — model profiles.
|
|
129
129
|
recommendedModels: pick(true, projectFile.recommendedModels, globalFile.recommendedModels),
|
|
130
|
+
// Feature 66 — the consent switch resolves like any other boolean knob:
|
|
131
|
+
// project wins, then global, then the shipped "no".
|
|
132
|
+
memory: {
|
|
133
|
+
engram: pick(false, projectFile.memory?.engram, globalFile.memory?.engram) === true,
|
|
134
|
+
},
|
|
130
135
|
profiles: mergeProfiles(globalFile.profiles, projectFile.profiles),
|
|
131
136
|
profileOrder: pick([], projectFile.profileOrder, globalFile.profileOrder),
|
|
132
137
|
profileFallback: mergeProfileFallback(globalFile.profileFallback, projectFile.profileFallback),
|
package/dist/config/schema.d.ts
CHANGED
|
@@ -13,9 +13,4 @@ export type ParseResult = {
|
|
|
13
13
|
ok: false;
|
|
14
14
|
issues: ConfigIssue[];
|
|
15
15
|
};
|
|
16
|
-
/**
|
|
17
|
-
* Parse and validate one config file's text. A blank file is an empty config so
|
|
18
|
-
* the loader can treat "nothing declared" like "nothing present" without a
|
|
19
|
-
* second read.
|
|
20
|
-
*/
|
|
21
16
|
export declare function parseConfigFile(text: string): ParseResult;
|
package/dist/config/schema.js
CHANGED
|
@@ -20,6 +20,7 @@ const ROOT_KEYS = new Set([
|
|
|
20
20
|
"profiles",
|
|
21
21
|
"profileOrder",
|
|
22
22
|
"profileFallback",
|
|
23
|
+
"memory",
|
|
23
24
|
]);
|
|
24
25
|
const ROUTE_KEYS = new Set(["model", "thinking"]);
|
|
25
26
|
const COMMAND_NAME = /^[a-z0-9][a-z0-9._-]*$/u;
|
|
@@ -331,7 +332,7 @@ function validateConfig(value) {
|
|
|
331
332
|
if (!ROOT_KEYS.has(key)) {
|
|
332
333
|
issues.push({
|
|
333
334
|
path: `$.${displayKey(key)}`,
|
|
334
|
-
message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection, advance, recommendedModels, profiles, profileOrder, profileFallback)`,
|
|
335
|
+
message: `unknown config key "${key}" (allowed: default, commands, onUnavailableRoute, onSettle, pathProtection, advance, recommendedModels, profiles, profileOrder, profileFallback, memory)`,
|
|
335
336
|
});
|
|
336
337
|
}
|
|
337
338
|
}
|
|
@@ -401,6 +402,11 @@ function validateConfig(value) {
|
|
|
401
402
|
config.recommendedModels = value.recommendedModels;
|
|
402
403
|
}
|
|
403
404
|
}
|
|
405
|
+
if (value.memory !== undefined) {
|
|
406
|
+
const memory = checkMemory(value.memory, "$.memory", issues);
|
|
407
|
+
if (memory)
|
|
408
|
+
config.memory = memory;
|
|
409
|
+
}
|
|
404
410
|
if (value.profiles !== undefined) {
|
|
405
411
|
if (!isRecord(value.profiles)) {
|
|
406
412
|
issues.push({ path: "$.profiles", message: "must be an object mapping profile names to profile definitions" });
|
|
@@ -436,6 +442,30 @@ function validateConfig(value) {
|
|
|
436
442
|
* the loader can treat "nothing declared" like "nothing present" without a
|
|
437
443
|
* second read.
|
|
438
444
|
*/
|
|
445
|
+
/** Strict validator for the `memory` consent block (feature 66, AC3). */
|
|
446
|
+
function checkMemory(value, path, issues) {
|
|
447
|
+
if (!isRecord(value)) {
|
|
448
|
+
issues.push({ path, message: `must be a JSON object, got ${describe(value)}` });
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
let unknown = false;
|
|
452
|
+
for (const key of Object.keys(value)) {
|
|
453
|
+
if (key !== "engram") {
|
|
454
|
+
unknown = true;
|
|
455
|
+
issues.push({ path: `${path}.${displayKey(key)}`, message: `unknown memory key "${key}" (allowed: engram)` });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const out = {};
|
|
459
|
+
if (value.engram !== undefined) {
|
|
460
|
+
if (typeof value.engram !== "boolean") {
|
|
461
|
+
issues.push({ path: `${path}.engram`, message: `must be a boolean, got ${describe(value.engram)}` });
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
out.engram = value.engram;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return unknown ? undefined : out;
|
|
468
|
+
}
|
|
439
469
|
export function parseConfigFile(text) {
|
|
440
470
|
if (text.trim() === "")
|
|
441
471
|
return { ok: true, config: {} };
|
package/dist/config/types.d.ts
CHANGED
|
@@ -75,6 +75,9 @@ export interface ConfigFile {
|
|
|
75
75
|
/** When true (default), the built-in provider profiles are included in the
|
|
76
76
|
* resolution chain (feature 63, AC1). */
|
|
77
77
|
recommendedModels?: boolean;
|
|
78
|
+
/** Feature 66 (AC3/D3): the memory-substrate consent surface — Engram's MCP
|
|
79
|
+
* registration is ask-first, so nothing is provisioned unless this says so. */
|
|
80
|
+
memory?: MemoryConfigFile;
|
|
78
81
|
/** Named user profiles keyed by profile name (feature 63, AC5). */
|
|
79
82
|
profiles?: Record<string, ProfileFile>;
|
|
80
83
|
/** Ordered list of profile names to probe; empty means ["default"] only
|
|
@@ -92,6 +95,16 @@ export type UnavailableRoutePolicy = "stop" | "inherit";
|
|
|
92
95
|
*/
|
|
93
96
|
export type SettlePolicy = "keep" | "restore";
|
|
94
97
|
/** One rejected field inside one config file, addressed by a JSON-path-ish string. */
|
|
98
|
+
/** What an operator WRITES under `memory` (feature 66). */
|
|
99
|
+
export interface MemoryConfigFile {
|
|
100
|
+
/** Opt in to the package registering the Engram MCP server when its binary
|
|
101
|
+
* resolves. Absent or `false` registers nothing. */
|
|
102
|
+
engram?: boolean;
|
|
103
|
+
}
|
|
104
|
+
/** What the extension READS: every `memory` knob resolved (feature 66). */
|
|
105
|
+
export interface MemoryConfig {
|
|
106
|
+
engram: boolean;
|
|
107
|
+
}
|
|
95
108
|
export interface ConfigIssue {
|
|
96
109
|
path: string;
|
|
97
110
|
message: string;
|
|
@@ -118,6 +131,8 @@ export interface EffectiveConfig {
|
|
|
118
131
|
profileOrder: string[];
|
|
119
132
|
/** The effective profile-fallback demotion policy (feature 63). */
|
|
120
133
|
profileFallback: ProfileFallbackConfig;
|
|
134
|
+
/** Feature 66: the memory-substrate consent surface. */
|
|
135
|
+
memory: MemoryConfig;
|
|
121
136
|
/** Feature 63: declaredness of the implicit `default` profile. */
|
|
122
137
|
declared: {
|
|
123
138
|
default: RouteDeclaration;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engram MCP provisioning (feature 66, P4 — AC3, decision D3).
|
|
3
|
+
*
|
|
4
|
+
* The memory substrate's registration is **ask-first and reversible**, which is
|
|
5
|
+
* why it goes through Pi's own consent surface instead of writing a
|
|
6
|
+
* machine-global `mcp.json` behind the operator's back:
|
|
7
|
+
*
|
|
8
|
+
* - `pi.registerMcpServer(name, config)` is session-scoped and NOT persisted
|
|
9
|
+
* (Pi's contract), so the package registers on every session start and nothing
|
|
10
|
+
* outlives the session unless the operator asks for it.
|
|
11
|
+
* - Registration happens only when the `engram` binary actually resolves — a
|
|
12
|
+
* registered-but-broken server would surface as an extension error on every
|
|
13
|
+
* session, which is a worse default than not registering.
|
|
14
|
+
* - A server the operator already declared in an `mcp.json` Pi reads — the
|
|
15
|
+
* user file or, for a trusted project, `<cwd>/.pi/mcp.json` — wins (Pi gives
|
|
16
|
+
* it precedence), so the package reports that and does not shadow it.
|
|
17
|
+
* - The operator's `memory.engram` config key is the consent switch: absent or
|
|
18
|
+
* `false` registers nothing. This module never provisions without it.
|
|
19
|
+
*
|
|
20
|
+
* Every outcome is reported verbatim — "exactly what was written" is the rule
|
|
21
|
+
* the sibling `init-workspace` consent flow already follows, and a
|
|
22
|
+
* provisioning step the operator cannot read back is one they cannot trust.
|
|
23
|
+
*
|
|
24
|
+
* The install route is `pi install npm:gentle-engram@latest`. The CLI's own
|
|
25
|
+
* setup subcommand is deliberately NOT the documented route: it pins whatever
|
|
26
|
+
* version it ran with and keeps serving it until it is run again, so an updated
|
|
27
|
+
* binary can sit unused behind a stale registration.
|
|
28
|
+
*
|
|
29
|
+
* Pi-free by construction — the binary lookup, the environment, the "already
|
|
30
|
+
* declared" names and the register callback are all injected, so every branch
|
|
31
|
+
* is tested with no Pi session, no PATH and no real binary.
|
|
32
|
+
*/
|
|
33
|
+
/** The name the server is registered under, and what `mcp.json` would declare. */
|
|
34
|
+
export declare const ENGRAM_SERVER_NAME = "engram";
|
|
35
|
+
/** The argv the workflow's runners use (pi's own registration, measured). */
|
|
36
|
+
export declare const ENGRAM_ARGS: readonly ["mcp", "--tools=agent"];
|
|
37
|
+
/** The one documented install route (D3). */
|
|
38
|
+
export declare const ENGRAM_INSTALL_ROUTE = "pi install npm:gentle-engram@latest";
|
|
39
|
+
export type EngramStatus = "registered" | "not-consented" | "config-invalid" | "binary-missing" | "already-declared" | "unsupported-host";
|
|
40
|
+
export interface EngramServerConfig {
|
|
41
|
+
command: string;
|
|
42
|
+
args: string[];
|
|
43
|
+
}
|
|
44
|
+
export interface EngramProvisionInput {
|
|
45
|
+
/** The operator's consent: `config.memory.engram`. Absent means no. */
|
|
46
|
+
consented: boolean;
|
|
47
|
+
/** True when the package's config file exists but does not parse or validate.
|
|
48
|
+
* Consent is then UNKNOWN, not refused: `loadConfig` fails closed to the
|
|
49
|
+
* shipped default, so reading `consented` off that default would tell an
|
|
50
|
+
* opted-in operator to opt in — `config/load.ts` calls a present-but-invalid
|
|
51
|
+
* file a problem, never a silent default, and `dispatch.ts` refuses on the
|
|
52
|
+
* same flag. Checked before consent. */
|
|
53
|
+
configInvalid?: boolean;
|
|
54
|
+
/** Absolute path of the resolved `engram` binary, or null when not found. */
|
|
55
|
+
binaryPath: string | null;
|
|
56
|
+
/** Server names the operator already declared (a `mcp.json` name wins), read
|
|
57
|
+
* from every scope Pi itself reads — see `declaredMcpServerPaths`. */
|
|
58
|
+
declaredNames: readonly string[];
|
|
59
|
+
/** Pi's registrar, injected so this module never imports Pi. `null` when the
|
|
60
|
+
* host's ExtensionAPI predates `registerMcpServer` — the package's peer floor
|
|
61
|
+
* is a range, so an older host degrades instead of throwing. */
|
|
62
|
+
register: ((name: string, config: EngramServerConfig) => void) | null;
|
|
63
|
+
}
|
|
64
|
+
export interface EngramReport {
|
|
65
|
+
status: EngramStatus;
|
|
66
|
+
/** Present only when `status === "registered"`: exactly what was written. */
|
|
67
|
+
server?: {
|
|
68
|
+
name: string;
|
|
69
|
+
} & EngramServerConfig;
|
|
70
|
+
/** One line an operator can read back; states what was written or why not. */
|
|
71
|
+
message: string;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* The server names an operator already declared in an `mcp.json` file. Pi gives
|
|
75
|
+
* a declared server precedence over an extension registration, so the caller
|
|
76
|
+
* passes this in and the module reports rather than shadows. Unreadable or
|
|
77
|
+
* malformed JSON yields `[]`: a file this module cannot parse is not evidence
|
|
78
|
+
* of a declaration, and refusing to register on it would be a silent gap.
|
|
79
|
+
*/
|
|
80
|
+
export declare function readDeclaredServerNames(path: string): string[];
|
|
81
|
+
/**
|
|
82
|
+
* The union of the names declared across the `mcp.json` files Pi reads. One
|
|
83
|
+
* file's declaration is not evidence about the other's.
|
|
84
|
+
*/
|
|
85
|
+
export declare function readDeclaredServerNamesFrom(paths: readonly string[]): string[];
|
|
86
|
+
/**
|
|
87
|
+
* The `mcp.json` files Pi reads for one session: the user-level file, and — for
|
|
88
|
+
* a trusted project only — `<cwd>/.pi/mcp.json`, whose entry replaces a
|
|
89
|
+
* same-named user entry (Pi's `mcp.md`: project configuration is read only after
|
|
90
|
+
* trust is granted, and "a project entry replaces a user-level entry with the
|
|
91
|
+
* same name"). The anti-shadow guard has to consult the same surface the host
|
|
92
|
+
* applies: reading only the user file let a project declaration slip past it, so
|
|
93
|
+
* the package registered anyway and then reported precedence over a file it had
|
|
94
|
+
* never opened.
|
|
95
|
+
*/
|
|
96
|
+
export declare function declaredMcpServerPaths({ agentDir, cwd, projectTrusted, }: {
|
|
97
|
+
agentDir: string;
|
|
98
|
+
cwd: string;
|
|
99
|
+
projectTrusted: boolean;
|
|
100
|
+
}): string[];
|
|
101
|
+
/**
|
|
102
|
+
* Resolve `engram` on `PATH` without a shell, honouring `PATHEXT` on Windows.
|
|
103
|
+
* Injected `exists`/`env` keep the lookup testable; the production caller takes
|
|
104
|
+
* the defaults.
|
|
105
|
+
*/
|
|
106
|
+
export declare function resolveEngramBinary({ env, exists, platform, }?: {
|
|
107
|
+
env?: Record<string, string | undefined>;
|
|
108
|
+
exists?: (path: string) => boolean;
|
|
109
|
+
platform?: NodeJS.Platform;
|
|
110
|
+
}): string | null;
|
|
111
|
+
/**
|
|
112
|
+
* Decide and perform the registration, in the order that makes the outcome
|
|
113
|
+
* truthful: a config that cannot be read first (its consent is unknown, never
|
|
114
|
+
* refused), then consent (never provision unasked), then the binary (never
|
|
115
|
+
* register a server that cannot start), then the operator's own declaration
|
|
116
|
+
* (never shadow it). Returns the report; it never throws and never mutates
|
|
117
|
+
* anything beyond the injected `register` call.
|
|
118
|
+
*/
|
|
119
|
+
export declare function provisionEngram(input: EngramProvisionInput): EngramReport;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engram MCP provisioning (feature 66, P4 — AC3, decision D3).
|
|
3
|
+
*
|
|
4
|
+
* The memory substrate's registration is **ask-first and reversible**, which is
|
|
5
|
+
* why it goes through Pi's own consent surface instead of writing a
|
|
6
|
+
* machine-global `mcp.json` behind the operator's back:
|
|
7
|
+
*
|
|
8
|
+
* - `pi.registerMcpServer(name, config)` is session-scoped and NOT persisted
|
|
9
|
+
* (Pi's contract), so the package registers on every session start and nothing
|
|
10
|
+
* outlives the session unless the operator asks for it.
|
|
11
|
+
* - Registration happens only when the `engram` binary actually resolves — a
|
|
12
|
+
* registered-but-broken server would surface as an extension error on every
|
|
13
|
+
* session, which is a worse default than not registering.
|
|
14
|
+
* - A server the operator already declared in an `mcp.json` Pi reads — the
|
|
15
|
+
* user file or, for a trusted project, `<cwd>/.pi/mcp.json` — wins (Pi gives
|
|
16
|
+
* it precedence), so the package reports that and does not shadow it.
|
|
17
|
+
* - The operator's `memory.engram` config key is the consent switch: absent or
|
|
18
|
+
* `false` registers nothing. This module never provisions without it.
|
|
19
|
+
*
|
|
20
|
+
* Every outcome is reported verbatim — "exactly what was written" is the rule
|
|
21
|
+
* the sibling `init-workspace` consent flow already follows, and a
|
|
22
|
+
* provisioning step the operator cannot read back is one they cannot trust.
|
|
23
|
+
*
|
|
24
|
+
* The install route is `pi install npm:gentle-engram@latest`. The CLI's own
|
|
25
|
+
* setup subcommand is deliberately NOT the documented route: it pins whatever
|
|
26
|
+
* version it ran with and keeps serving it until it is run again, so an updated
|
|
27
|
+
* binary can sit unused behind a stale registration.
|
|
28
|
+
*
|
|
29
|
+
* Pi-free by construction — the binary lookup, the environment, the "already
|
|
30
|
+
* declared" names and the register callback are all injected, so every branch
|
|
31
|
+
* is tested with no Pi session, no PATH and no real binary.
|
|
32
|
+
*/
|
|
33
|
+
import { accessSync, constants, readFileSync } from "node:fs";
|
|
34
|
+
import { delimiter, join } from "node:path";
|
|
35
|
+
/** The directory a project keeps its own agent config in — Pi's `.pi/`. */
|
|
36
|
+
const PROJECT_DIR = ".pi";
|
|
37
|
+
/** The name the server is registered under, and what `mcp.json` would declare. */
|
|
38
|
+
export const ENGRAM_SERVER_NAME = "engram";
|
|
39
|
+
/** The argv the workflow's runners use (pi's own registration, measured). */
|
|
40
|
+
export const ENGRAM_ARGS = ["mcp", "--tools=agent"];
|
|
41
|
+
/** The one documented install route (D3). */
|
|
42
|
+
export const ENGRAM_INSTALL_ROUTE = "pi install npm:gentle-engram@latest";
|
|
43
|
+
/**
|
|
44
|
+
* The server names an operator already declared in an `mcp.json` file. Pi gives
|
|
45
|
+
* a declared server precedence over an extension registration, so the caller
|
|
46
|
+
* passes this in and the module reports rather than shadows. Unreadable or
|
|
47
|
+
* malformed JSON yields `[]`: a file this module cannot parse is not evidence
|
|
48
|
+
* of a declaration, and refusing to register on it would be a silent gap.
|
|
49
|
+
*/
|
|
50
|
+
export function readDeclaredServerNames(path) {
|
|
51
|
+
let text;
|
|
52
|
+
try {
|
|
53
|
+
text = readFileSync(path, "utf8");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(text);
|
|
60
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
61
|
+
return [];
|
|
62
|
+
const servers = parsed.mcpServers;
|
|
63
|
+
if (typeof servers !== "object" || servers === null)
|
|
64
|
+
return [];
|
|
65
|
+
return Object.keys(servers);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The union of the names declared across the `mcp.json` files Pi reads. One
|
|
73
|
+
* file's declaration is not evidence about the other's.
|
|
74
|
+
*/
|
|
75
|
+
export function readDeclaredServerNamesFrom(paths) {
|
|
76
|
+
const names = new Set();
|
|
77
|
+
for (const path of paths) {
|
|
78
|
+
for (const name of readDeclaredServerNames(path))
|
|
79
|
+
names.add(name);
|
|
80
|
+
}
|
|
81
|
+
return [...names];
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The `mcp.json` files Pi reads for one session: the user-level file, and — for
|
|
85
|
+
* a trusted project only — `<cwd>/.pi/mcp.json`, whose entry replaces a
|
|
86
|
+
* same-named user entry (Pi's `mcp.md`: project configuration is read only after
|
|
87
|
+
* trust is granted, and "a project entry replaces a user-level entry with the
|
|
88
|
+
* same name"). The anti-shadow guard has to consult the same surface the host
|
|
89
|
+
* applies: reading only the user file let a project declaration slip past it, so
|
|
90
|
+
* the package registered anyway and then reported precedence over a file it had
|
|
91
|
+
* never opened.
|
|
92
|
+
*/
|
|
93
|
+
export function declaredMcpServerPaths({ agentDir, cwd, projectTrusted, }) {
|
|
94
|
+
const paths = [join(agentDir, "mcp.json")];
|
|
95
|
+
if (projectTrusted)
|
|
96
|
+
paths.push(join(cwd, PROJECT_DIR, "mcp.json"));
|
|
97
|
+
return paths;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Resolve `engram` on `PATH` without a shell, honouring `PATHEXT` on Windows.
|
|
101
|
+
* Injected `exists`/`env` keep the lookup testable; the production caller takes
|
|
102
|
+
* the defaults.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveEngramBinary({ env = process.env, exists = isExecutable, platform = process.platform, } = {}) {
|
|
105
|
+
const pathValue = env.PATH ?? env.Path ?? "";
|
|
106
|
+
if (pathValue === "")
|
|
107
|
+
return null;
|
|
108
|
+
const extensions = platform === "win32" ? splitExtensions(env.PATHEXT) : [""];
|
|
109
|
+
for (const dir of pathValue.split(delimiter)) {
|
|
110
|
+
if (dir === "")
|
|
111
|
+
continue;
|
|
112
|
+
for (const extension of extensions) {
|
|
113
|
+
const candidate = join(dir, `${ENGRAM_SERVER_NAME}${extension}`);
|
|
114
|
+
if (exists(candidate))
|
|
115
|
+
return candidate;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
function splitExtensions(pathext) {
|
|
121
|
+
const raw = (pathext ?? ".EXE;.CMD;.BAT;.COM").split(";").filter((value) => value !== "");
|
|
122
|
+
return [...raw, ""];
|
|
123
|
+
}
|
|
124
|
+
function isExecutable(path) {
|
|
125
|
+
try {
|
|
126
|
+
accessSync(path, constants.X_OK);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Decide and perform the registration, in the order that makes the outcome
|
|
135
|
+
* truthful: a config that cannot be read first (its consent is unknown, never
|
|
136
|
+
* refused), then consent (never provision unasked), then the binary (never
|
|
137
|
+
* register a server that cannot start), then the operator's own declaration
|
|
138
|
+
* (never shadow it). Returns the report; it never throws and never mutates
|
|
139
|
+
* anything beyond the injected `register` call.
|
|
140
|
+
*/
|
|
141
|
+
export function provisionEngram(input) {
|
|
142
|
+
const { consented, configInvalid, binaryPath, declaredNames, register } = input;
|
|
143
|
+
if (configInvalid === true) {
|
|
144
|
+
return {
|
|
145
|
+
status: "config-invalid",
|
|
146
|
+
message: `pi-agentic-workflow: Engram not registered — the package's config file is invalid, so ` +
|
|
147
|
+
`its consent could not be read and nothing was written (fix the reported problems and ` +
|
|
148
|
+
`the next session reads the switch again).`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
if (!consented) {
|
|
152
|
+
return {
|
|
153
|
+
status: "not-consented",
|
|
154
|
+
message: `pi-agentic-workflow: Engram not registered — set "memory": { "engram": true } in ` +
|
|
155
|
+
`pi-agentic-workflow.json to opt in (nothing was written).`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (binaryPath === null) {
|
|
159
|
+
return {
|
|
160
|
+
status: "binary-missing",
|
|
161
|
+
message: `pi-agentic-workflow: Engram not registered — no "engram" binary on PATH, so ` +
|
|
162
|
+
`nothing was written. Install it with: ${ENGRAM_INSTALL_ROUTE}`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (register === null) {
|
|
166
|
+
return {
|
|
167
|
+
status: "unsupported-host",
|
|
168
|
+
message: `pi-agentic-workflow: Engram not registered — this Pi host exposes no ` +
|
|
169
|
+
`registerMcpServer, so the server was not added (nothing was written).`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (declaredNames.includes(ENGRAM_SERVER_NAME)) {
|
|
173
|
+
return {
|
|
174
|
+
status: "already-declared",
|
|
175
|
+
message: `pi-agentic-workflow: Engram not registered — "${ENGRAM_SERVER_NAME}" is already ` +
|
|
176
|
+
`declared in an mcp.json Pi reads, which takes precedence (nothing was written).`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const config = { command: binaryPath, args: [...ENGRAM_ARGS] };
|
|
180
|
+
register(ENGRAM_SERVER_NAME, config);
|
|
181
|
+
return {
|
|
182
|
+
status: "registered",
|
|
183
|
+
server: { name: ENGRAM_SERVER_NAME, ...config },
|
|
184
|
+
message: `pi-agentic-workflow: registered MCP server "${ENGRAM_SERVER_NAME}" → ` +
|
|
185
|
+
`${binaryPath} ${ENGRAM_ARGS.join(" ")} (session-scoped; Pi does not persist it)`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -43,6 +43,12 @@ export interface ExtensionDeps<M extends ModelRef = ModelRef> {
|
|
|
43
43
|
loadConfig?: (ctx: InvocationContext<M>) => LoadedConfig;
|
|
44
44
|
/** P4: profile demotion state store (optional — defaults to file-backed). */
|
|
45
45
|
profileState?: import("../routing/state.js").ProfileStateStore;
|
|
46
|
+
/**
|
|
47
|
+
* Feature 67 D5/AC9: the Serena readiness nudge. Consulted at the first
|
|
48
|
+
* workflow-command dispatch, so a session that never starts through
|
|
49
|
+
* `session_start` still hears it once.
|
|
50
|
+
*/
|
|
51
|
+
serenaReadiness?: import("../routing/serena-readiness.js").SerenaReadinessGate;
|
|
46
52
|
}
|
|
47
53
|
export interface ExtensionHandle<M extends ModelRef = ModelRef> {
|
|
48
54
|
router: Router<M>;
|
|
@@ -31,11 +31,19 @@ export function createExtension(deps) {
|
|
|
31
31
|
// P4: build the profile state store when not provided.
|
|
32
32
|
const profileState = deps.profileState ?? createProfileStateStore({ path: stateFilePath(agentDir) });
|
|
33
33
|
const router = createRouter({ surface, loadConfig: read, hint, settingsCommand: SETTINGS_COMMAND, knownCommands, profileState });
|
|
34
|
+
// Said once per state, not once per command: the gate latches in memory for
|
|
35
|
+
// this session and in the package's state file for later ones.
|
|
36
|
+
const reportSerenaReadiness = (ctx) => {
|
|
37
|
+
const message = deps.serenaReadiness?.check(ctx.cwd);
|
|
38
|
+
if (message)
|
|
39
|
+
ctx.notify(message, "warning");
|
|
40
|
+
};
|
|
34
41
|
for (const command of catalogue.commands) {
|
|
35
42
|
registrar.registerCommand(command.name, {
|
|
36
43
|
...(command.description ? { description: command.description } : {}),
|
|
37
44
|
handler: async (args, ctx) => {
|
|
38
45
|
reportCatalogueIssues(ctx);
|
|
46
|
+
reportSerenaReadiness(ctx);
|
|
39
47
|
await router.dispatch(command, args, ctx);
|
|
40
48
|
},
|
|
41
49
|
});
|
|
@@ -44,6 +52,7 @@ export function createExtension(deps) {
|
|
|
44
52
|
// and the same two config files, never a separate route surface (OB-5, AC6).
|
|
45
53
|
const settingsHandler = async (_args, ctx) => {
|
|
46
54
|
reportCatalogueIssues(ctx);
|
|
55
|
+
reportSerenaReadiness(ctx);
|
|
47
56
|
// Bound for the console: two verbs and the session they act on, so it cannot
|
|
48
57
|
// reach for `settle` or `dispatch` by accident.
|
|
49
58
|
const routing = {
|