@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 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
@@ -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 },
@@ -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),
@@ -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;
@@ -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: {} };
@@ -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 = {