@gtrabanco/pi-agentic-workflow 0.30.0 → 0.34.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 +41 -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/index.js +70 -3
- package/dist/settings/console.js +68 -22
- package/package.json +1 -1
- package/skills/audit-pr/SKILL.md +1 -1
- package/skills/audit-pr/references/03_AUDIT_PROCESS.md +3 -2
- package/skills/execute-phase/SKILL.md +22 -8
- package/skills/execute-phase/references/EXECUTION_CONTRACT.md +15 -9
- package/skills/execute-phase/references/FOLDING.md +11 -8
- package/skills/execute-phase/references/PREFLIGHT.md +7 -3
- package/skills/fold-findings/SKILL.md +28 -17
- package/skills/fold-findings/references/FOLD_PROCESS.md +5 -3
- package/skills/init-workspace/SKILL.md +13 -2
- package/skills/init-workspace/references/BOOTSTRAP_WRITE.md +11 -5
- package/skills/init-workspace/references/ENGRAM.md +107 -0
- package/skills/log-session/SKILL.md +17 -1
- package/skills/orchestration-envelope/references/TURN_CONTRACT.md +2 -1
- package/skills/pre-execution-review/SKILL.md +1 -1
- package/skills/pre-execution-review/references/LEDGERS.md +30 -2
- package/skills/review-change/SKILL.md +1 -1
- package/skills/review-change/references/OUTPUT_AND_GUARDRAILS.md +2 -1
- package/skills/review-change/references/PERSIST_AND_DECIDE.md +30 -9
- package/skills/review-change/references/REVIEW_PROCESS.md +14 -6
- package/skills/review-implementation/SKILL.md +10 -2
- package/skills/review-implementation/references/CLASSIFY.md +21 -4
- package/skills/triage-issue/SKILL.md +18 -12
- package/skills/triage-issue/references/FOLD_LEDGER.md +11 -3
- package/skills/triage-issue/references/ISSUE_PROCESS.md +3 -2
- package/skills/triage-issue/references/REVIEW_FINDING_PROCESS.md +17 -8
- package/skills/unit-lane/SKILL.md +23 -5
- package/skills/unit-lane/references/DESIGN.md +1 -1
- package/skills/unit-lane/references/EVIDENCE.md +7 -3
- package/skills/unit-lane/references/MEMORY.md +179 -0
- package/skills/unit-lane/references/PLAN.md +11 -1
- package/skills/unit-lane/references/RESEARCH.md +3 -3
- package/skills/verification-contract/SKILL.md +9 -7
- package/skills/workflow-status/SKILL.md +4 -2
- package/skills/workflow-status/references/ENVELOPE_FIELDS.md +3 -1
package/README.md
CHANGED
|
@@ -213,6 +213,47 @@ notification. `requirements` is keyed by phase state (`pre-freeze`, `post-freeze
|
|
|
213
213
|
matching `path-protection-records@1` `justification` row in the unit's
|
|
214
214
|
`decisions.md`.
|
|
215
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
|
+
|
|
216
257
|
## When a configured model is unavailable
|
|
217
258
|
|
|
218
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
|
+
}
|
package/dist/extension/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import { readGitStatusBounded } from "./receipt-guard.js";
|
|
|
9
9
|
import { createPickerComponent, PICKER_MAX_VISIBLE, pagedSelect } from "../settings/picker.js";
|
|
10
10
|
import { createHintStore, createSerenaReadinessStore, stateFilePath } from "../routing/state.js";
|
|
11
11
|
import { createSerenaReadinessGate, readInitWorkspaceRevision } from "../routing/serena-readiness.js";
|
|
12
|
+
import { provisionEngram, readDeclaredServerNamesFrom, resolveEngramBinary, declaredMcpServerPaths } from "./engram.js";
|
|
12
13
|
import { runSettingsConsole } from "../settings/console.js";
|
|
13
14
|
import { readConfigFile, writeConfigFile } from "../settings/store.js";
|
|
14
15
|
import { SETTINGS_COMMAND } from "../routing/types.js";
|
|
@@ -172,6 +173,48 @@ function resolveExistingPrefix(target) {
|
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Pi's MCP registrar, read structurally rather than imported: the package's
|
|
178
|
+
* peer floor is a range, and a host older than `registerMcpServer` must degrade
|
|
179
|
+
* (the provisioning module reports `unsupported-host`) instead of throwing at
|
|
180
|
+
* load.
|
|
181
|
+
*/
|
|
182
|
+
function mcpRegistrar(pi) {
|
|
183
|
+
const candidate = pi.registerMcpServer;
|
|
184
|
+
if (typeof candidate !== "function")
|
|
185
|
+
return null;
|
|
186
|
+
return (name, config) => {
|
|
187
|
+
candidate.call(pi, name, config);
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The server names the operator already has declared — the set the anti-shadow
|
|
192
|
+
* guard is judged against. Both surfaces are unioned: Pi's own `getMcpServers()`
|
|
193
|
+
* (read structurally for the same peer-floor reason as the registrar; it is the
|
|
194
|
+
* merged view the host applies), and the `mcp.json` files Pi reads for this
|
|
195
|
+
* session, user file and trusted project file alike. Either one answering is
|
|
196
|
+
* enough to refuse; reading only the user file was the F6 defect, which
|
|
197
|
+
* registered over a project declaration and then reported precedence over a
|
|
198
|
+
* file it had never opened.
|
|
199
|
+
*/
|
|
200
|
+
function declaredServerNames(pi, agentDir, ctx) {
|
|
201
|
+
const names = new Set(readDeclaredServerNamesFrom(declaredMcpServerPaths({ agentDir, cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() })));
|
|
202
|
+
const candidate = pi.getMcpServers;
|
|
203
|
+
if (typeof candidate === "function") {
|
|
204
|
+
try {
|
|
205
|
+
const listed = candidate.call(pi);
|
|
206
|
+
if (Array.isArray(listed)) {
|
|
207
|
+
for (const server of listed)
|
|
208
|
+
if (typeof server?.name === "string")
|
|
209
|
+
names.add(server.name);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// A host that cannot answer is not a declaration: the file reads stand.
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return [...names];
|
|
217
|
+
}
|
|
175
218
|
export default function extension(pi) {
|
|
176
219
|
const agentDir = getAgentDir();
|
|
177
220
|
const hint = createHintStore({ path: stateFilePath(agentDir) });
|
|
@@ -214,13 +257,37 @@ export default function extension(pi) {
|
|
|
214
257
|
writeFile: writeConfigFile,
|
|
215
258
|
}),
|
|
216
259
|
});
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
260
|
+
// One `session_start` listener carries both first chances, in this order.
|
|
261
|
+
//
|
|
262
|
+
// The readiness nudge (AC9) hears the session here, and the gate latches so
|
|
263
|
+
// the command path never repeats it — soft by construction, because the gate
|
|
264
|
+
// swallows a broken read and a failed latch.
|
|
265
|
+
//
|
|
266
|
+
// Memory substrate (feature 66, AC3) comes second: ask-first,
|
|
267
|
+
// registrable-not-persisted. Registration runs on the session rather than at
|
|
268
|
+
// load so an older Pi host is reported instead of throwing, and so the
|
|
269
|
+
// operator reads back exactly what was written. Nothing is provisioned
|
|
270
|
+
// without the `memory.engram` opt-in, and a config file that does not parse
|
|
271
|
+
// is never read as one: its consent is unknown, and the refusal says so
|
|
272
|
+
// instead of telling an opted-in operator to opt in.
|
|
220
273
|
pi.on("session_start", (_event, ctx) => {
|
|
221
274
|
const message = serenaReadiness.check(ctx.cwd);
|
|
222
275
|
if (message)
|
|
223
276
|
ctx.ui.notify(message, "warning");
|
|
277
|
+
const projectTrusted = ctx.isProjectTrusted();
|
|
278
|
+
const loaded = loadConfig({ agentDir, cwd: ctx.cwd, projectTrusted });
|
|
279
|
+
const report = provisionEngram({
|
|
280
|
+
consented: loaded.config.memory?.engram === true,
|
|
281
|
+
configInvalid: !loaded.ok,
|
|
282
|
+
binaryPath: resolveEngramBinary(),
|
|
283
|
+
declaredNames: declaredServerNames(pi, agentDir, ctx),
|
|
284
|
+
register: mcpRegistrar(pi),
|
|
285
|
+
});
|
|
286
|
+
// The opt-out stays silent; every other outcome is stated once, in the
|
|
287
|
+
// module's own words (it already says what was written, or why not).
|
|
288
|
+
if (report.status !== "not-consented") {
|
|
289
|
+
ctx.ui.notify(report.message, report.status === "registered" ? "info" : "warning");
|
|
290
|
+
}
|
|
224
291
|
});
|
|
225
292
|
pi.on("model_select", (event) => router.noteModelSelect(event.model));
|
|
226
293
|
pi.on("thinking_level_select", (event) => router.noteThinkingLevelSelect(event.level));
|