@gtrabanco/pi-agentic-workflow 0.30.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
@@ -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
@@ -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
+ }
@@ -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
- // The readiness nudge's first chance (AC9): a session that opens here hears
218
- // it, and the gate latches so the command path never repeats it. Soft by
219
- // construction — the gate swallows a broken read and a failed latch.
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));
@@ -650,38 +650,84 @@ function describeRouting(file) {
650
650
  ];
651
651
  return routes.join(" · ");
652
652
  }
653
+ /**
654
+ * The canonical write order for a config file. `dirty()` compares the cleaned
655
+ * draft against the cleaned file as JSON text, so the order is part of the
656
+ * contract: the same keys written in a different order would read as an edit.
657
+ * Keys outside this list are appended in the draft's own order, so a key the
658
+ * console never prompts for still survives the save (F4).
659
+ */
660
+ const KEY_ORDER = [
661
+ "default",
662
+ "commands",
663
+ "onUnavailableRoute",
664
+ "onSettle",
665
+ "pathProtection",
666
+ "advance",
667
+ "recommendedModels",
668
+ "memory",
669
+ "profiles",
670
+ "profileOrder",
671
+ "profileFallback",
672
+ ];
653
673
  /**
654
674
  * Keep everything the file held or the operator set; drop only what says nothing
655
675
  * (an empty route). Eliding by VALUE — treating an explicit `inherit` or `stop` as
656
676
  * absent because a shipped default happens to agree — silently discards the only
657
677
  * two moves that matter at project scope: switching a command off under a global
658
678
  * route, and re-arming fail-closed over a global `inherit` (F4).
679
+ *
680
+ * The copy is driven by the draft's OWN keys, not by a list of the keys this
681
+ * console edits. Enumerating them silently deleted every valid key the console
682
+ * never prompts for — `memory` (the Engram consent switch, feature 66 AC3),
683
+ * `pathProtection` and `advance` — so an opt-in written by hand vanished on the
684
+ * next save with no message and no error (F4/VF-4). A key the parser accepted is
685
+ * never a key this function may drop.
659
686
  */
660
687
  function clean(draft) {
661
- const file = {};
662
- if (draft.default && Object.keys(draft.default).length > 0)
663
- file.default = { ...draft.default };
664
- const commands = {};
665
- for (const [name, route] of Object.entries(draft.commands ?? {})) {
666
- if (Object.keys(route).length > 0)
667
- commands[name] = { ...route };
668
- }
669
- if (Object.keys(commands).length > 0)
670
- file.commands = commands;
671
- if (draft.onUnavailableRoute)
672
- file.onUnavailableRoute = draft.onUnavailableRoute;
673
- if (draft.onSettle)
674
- file.onSettle = draft.onSettle;
675
- if (draft.recommendedModels !== undefined)
676
- file.recommendedModels = draft.recommendedModels;
677
- if (draft.profiles && Object.keys(draft.profiles).length > 0) {
678
- file.profiles = structuredClone(draft.profiles);
688
+ const kept = {};
689
+ for (const [key, value] of Object.entries(draft)) {
690
+ if (value === undefined)
691
+ continue;
692
+ if (key === "commands") {
693
+ const commands = {};
694
+ for (const [name, route] of Object.entries((value ?? {}))) {
695
+ if (route && Object.keys(route).length > 0)
696
+ commands[name] = { ...route };
697
+ }
698
+ if (Object.keys(commands).length > 0)
699
+ kept.commands = commands;
700
+ continue;
701
+ }
702
+ if (key === "profiles") {
703
+ const profiles = {};
704
+ for (const [name, profile] of Object.entries((value ?? {}))) {
705
+ if (profile && Object.keys(profile).length > 0)
706
+ profiles[name] = structuredClone(profile);
707
+ }
708
+ if (Object.keys(profiles).length > 0)
709
+ kept.profiles = profiles;
710
+ continue;
711
+ }
712
+ if (Array.isArray(value)) {
713
+ // `profileOrder` is the only list at this level: an empty order says
714
+ // nothing, so it is elided exactly as the empty route was.
715
+ if (value.length > 0)
716
+ kept[key] = [...value];
717
+ continue;
718
+ }
719
+ if (typeof value === "object" && value !== null && Object.keys(value).length === 0)
720
+ continue;
721
+ kept[key] = typeof value === "object" && value !== null ? structuredClone(value) : value;
679
722
  }
680
- if (draft.profileOrder && draft.profileOrder.length > 0) {
681
- file.profileOrder = [...draft.profileOrder];
723
+ const file = {};
724
+ for (const key of KEY_ORDER) {
725
+ if (key in kept)
726
+ file[key] = kept[key];
682
727
  }
683
- if (draft.profileFallback && Object.keys(draft.profileFallback).length > 0) {
684
- file.profileFallback = { ...draft.profileFallback };
728
+ for (const key of Object.keys(kept)) {
729
+ if (!(key in file))
730
+ file[key] = kept[key];
685
731
  }
686
732
  return file;
687
733
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-agentic-workflow",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Pi package: canonical agentic-workflow skills, friendly slash commands, and per-command model routing.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: execute-phase
3
3
  user-invocable: true
4
- version: 6.1.0
4
+ version: 6.2.1
5
5
  argument-hint: <NN-slug> [P<k>|step] [--max-attempts N] [--force]
6
6
  allowed-tools: [Bash, Read, Edit, Write, MultiEdit]
7
7
  author: "Gabriel Trabanco <1969593+gtrabanco@users.noreply.github.com>"
@@ -44,6 +44,8 @@ done:
44
44
  ✓ Evidence rows updated (command → exit/digest → output → verified-by)
45
45
  ✓ Progress log updated with a dated `YYYY-MM-DD HH:MM` entry that names the
46
46
  phase id (what, commit, next)
47
+ ✓ Memory boundary ran — the phase's outcome saved under `unit/<NN-slug>/doc`,
48
+ or the mirror marked pending when the layer is degraded
47
49
  ✓ Diff guard passed (on implement steps) — see `diff-guard` in PREFLIGHT.md
48
50
  ✓ → Next: block printed as the absolute last output
49
51
  ```
@@ -136,7 +138,14 @@ names before implementation), then green. UI/adapter glue may test after.
136
138
  output (≤2 lines) | verified-by |`. Append to the Evidence section.
137
139
  3. **Progress log**: append a dated entry —
138
140
  `YYYY-MM-DD HH:MM — <what was done> → <commit sha> — next: <what is next>`.
139
- 4. **Commit**: `git add <changed files>` then `git commit -m "<type>(<scope>):
141
+ 4. **Memory boundary** — this step is a phase transition. Follow
142
+ [the memory protocol](../unit-lane/references/MEMORY.md): before the first
143
+ memory read run `agentic-workflow memory probe` once and record its verdict
144
+ in Evidence; after the step, save the phase id, what it changed, its Evidence
145
+ outcome and any discovery under `topic_key: "unit/<NN-slug>/doc"`, an explicit
146
+ `project` and explicit session attribution (§5). A `degraded` verdict is
147
+ recorded and the mirror marked pending — it never blocks the step.
148
+ 5. **Commit**: `git add <changed files>` then `git commit -m "<type>(<scope>):
140
149
  <step-summary>"` — run it, paste the SHA.
141
150
 
142
151
  ### 6. Red gate (repair loop)
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: init-workspace
3
3
  user-invocable: true
4
- version: 4.1.0
4
+ version: 4.2.0
5
5
  argument-hint: <target-dir>
6
6
  author: "Gabriel Trabanco <1969593+gtrabanco@users.noreply.github.com>"
7
7
  license: MIT
@@ -78,7 +78,7 @@ Inspect the target dir (`[target-dir]`, default cwd) before touching anything:
78
78
 
79
79
  ## Progressive loading — bootstrap or upgrade, never both
80
80
 
81
- The reference allowlist is exactly the five linked paths below. Never invent or
81
+ The reference allowlist is exactly the six linked paths below. Never invent or
82
82
  read another `references/` path. Step 0 selects exactly one route:
83
83
 
84
84
  **Hard rule for an existing Claude Code, Cursor, Copilot, or OpenCode
@@ -95,6 +95,10 @@ or portability unless the runtime platform has no named adapter at all.
95
95
  only. Its additive-only rule is absolute; do not replay bootstrap.
96
96
  The upgrade route loads [SERENA language servers](references/SERENA.md)
97
97
  for the additive provisioning step.
98
+ - Provisioning Engram on a host with no pi package: read
99
+ [Engram provisioning](references/ENGRAM.md) — detect, ask, register or
100
+ instruct, report. Loaded by the bootstrap write route only (the pi package
101
+ provisions itself, feature 66).
98
102
  - No named adapter exists for the detected runtime platform: add
99
103
  [portability](references/PORTABILITY.md) only for that fallback. Claude Code,
100
104
  Cursor, Copilot, and OpenCode are named supported adapters; a missing adapter
@@ -133,6 +137,13 @@ loads exactly upgrade and skips bootstrap plus portability.
133
137
  auto-detected), a listed server that fails to start must be removed (one
134
138
  failure disables every server in the project), and LSP evidence stays
135
139
  TypeScript/JavaScript-only. Document verification is a script's job.
140
+ - **Engram provisioning is ask-first, never assumed (feature 66).** Detect the
141
+ `engram` binary, ask before writing any host MCP config, write the absolute
142
+ path with the exact `mcp --tools=agent` argv, and report exactly what was
143
+ written. An existing `engram` declaration is never overwritten, and an absent
144
+ binary is a recorded residual — the documented install route is
145
+ `pi install npm:gentle-engram@latest`, never the CLI's own setup subcommand,
146
+ which keeps serving whatever version ran it until it is re-run.
136
147
  - **Retrieval index scaffold and git hooks are consent-gated (AC22).** The
137
148
  index surface — the committed `.agentic-workflow/index.json` config plus the
138
149
  three freshness hooks (`post-merge`, `post-checkout`, `post-rewrite`, all
@@ -34,7 +34,13 @@
34
34
  health check and render the fixed provisioning report. When no installer is
35
35
  available, offer only what the machine can perform and record the residual
36
36
  with its reason — the run completes either way.
37
- 9. **Install accepted agent safety hooks.** Keep the canonical policy and
37
+ 9. **Offer Engram provisioning.** Load [ENGRAM](ENGRAM.md), detect the binary
38
+ and ask consent: register the `engram` MCP server (absolute path, exact
39
+ `mcp --tools=agent` argv) when the host has one, leave an existing
40
+ declaration untouched, and report exactly what was written. An absent binary
41
+ becomes a residual with the documented install route — never a failed run.
42
+ Skip this step when the pi package provisions the host itself.
43
+ 10. **Install accepted agent safety hooks.** Keep the canonical policy and
38
44
  fullauto wrapper at `.agentic-workflow/hooks/`. Seed the shipped
39
45
  path-protection policy at `.agentic-workflow/path-policy.json` and its doc
40
46
  page at `.agentic-workflow/path-protection.md`, beside the hooks — the
@@ -47,10 +53,10 @@
47
53
  an unknown payload, or an existing customized hook file becomes a residual,
48
54
  never a silent overwrite. Direct merges remain blocked; do not create a
49
55
  `.automerge` exception.
50
- 10. **Offer the workflow skills.** Propose installing them:
56
+ 11. **Offer the workflow skills.** Propose installing them:
51
57
  `npx skills add gtrabanco/agentic-workflow` (note the SSH/local-path variant if
52
58
  the source is private). Don't install without a yes.
53
- 11. **State that reviews are self-contained; offer optional extras.** The
59
+ 12. **State that reviews are self-contained; offer optional extras.** The
54
60
  workflow ships its **own internal review pack** (`review-code`,
55
61
  `review-security`, `review-verify`, `review-debt`, `review-design`,
56
62
  `review-a11y`, `review-brand`, `review-perf`, `review-seo`) — it installs
@@ -60,7 +66,7 @@
60
66
  under a short "Optional review extras" note so `review-change` and
61
67
  `product-audit` run them **in addition** — never as a dependency. Don't
62
68
  install anything without a yes.
63
- 12. **Seed the urgency labels (feature 15, injection-safe urgency).** Create the
69
+ 13. **Seed the urgency labels (feature 15, injection-safe urgency).** Create the
64
70
  two capability-gated GitHub labels `triage-issue` owns and applies
65
71
  (`skills/triage-issue/SKILL.md` is the sole owner of the name/color
66
72
  vocabulary — this step only seeds it, never redefines it):
@@ -73,7 +79,7 @@
73
79
  forge is unavailable or the user declines forge setup, skip this step and
74
80
  list the two labels as a residual for the user to create manually later
75
81
  (never fail the whole scaffold on it).
76
- 13. **Report.** List what was created, which placeholders still need human input,
82
+ 14. **Report.** List what was created, which placeholders still need human input,
77
83
  the companion skills recorded/installed, the urgency labels seeded (or
78
84
  skipped, with reason), and the next step:
79
85
  `discover-repository-state` → `/unit-lane` (or `workflow-status`) → catalog
@@ -0,0 +1,107 @@
1
+ ## Why this step exists
2
+
3
+ The workflow's units save what they learned into Engram, the cross-session
4
+ memory layer its memory protocol documents. On a host whose agent has no `pi`
5
+ package to provision it (the pi package registers the server itself, feature
6
+ 66 P4), the target project has no memory layer at all unless this step offers
7
+ one — so Engram provisioning is a **parity obligation**, not an optional extra:
8
+ feature 66's approval is that the pi path registers it and every other host is
9
+ offered the same thing ask-first.
10
+
11
+ ## What this file owns
12
+
13
+ The **non-pi** half of Engram provisioning. It owns exactly four moves —
14
+ detect, ask, provision-or-instruct, report — and nothing else. The workflow-side
15
+ protocol (when units save, the recall order, the degraded path) is **not** here;
16
+ it lives in `unit-lane`'s memory reference, which the skills that run units
17
+ already load.
18
+
19
+ ## 1. Detect
20
+
21
+ Look for the `engram` binary on `PATH` (`command -v engram`) and read its
22
+ version (`engram --version`). Two outcomes matter:
23
+
24
+ - **Present** — record the resolved path and the version fingerprint.
25
+ - **Absent** — record it as a residual, and go to step 2 with the install
26
+ guidance below rather than silently skipping the step.
27
+
28
+ Never write an MCP registration file for a binary that does not resolve: a
29
+ registered-but-unstartable server fails every session after it, which is a worse
30
+ default than an honest gap.
31
+
32
+ ## 2. Ask, then do exactly what was agreed
33
+
34
+ This is a **consent-gated install** like every other in this skill. Ask one
35
+ question — "register Engram as the project's memory layer?" — and act on the
36
+ answer:
37
+
38
+ | Answer | What you do |
39
+ |---|---|
40
+ | yes | write the MCP registration named in step 3 and report it verbatim |
41
+ | no | write nothing, record it as a residual, finish the run normally |
42
+ | not installed | offer the install route below, and provision **only** if the user then says yes |
43
+
44
+ **The install route is `pi install npm:gentle-engram@latest`.** The CLI's own
45
+ setup subcommand is **not** the documented route and must never be printed as an
46
+ instruction: it pins whatever version ran it and keeps serving that version
47
+ until it is re-run, so an updated binary can sit unused behind a stale
48
+ registration (decision D3, 2026-10-21).
49
+
50
+ ## 3. What "register" means on a non-pi host
51
+
52
+ There is no `pi.registerMcpServer` here, so the registration is a **declared
53
+ file** the host agent reads. Write the server into that host's own MCP config
54
+ (the agent's documented MCP surface), never into a file the host does not read:
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "engram": { "command": "<absolute path to engram>", "args": ["mcp", "--tools=agent"] }
60
+ }
61
+ }
62
+ ```
63
+
64
+ **Rules:**
65
+
66
+ - **Absolute path, never a bare name** — the host's `PATH` at load time is not
67
+ the shell's.
68
+ - **Args are exactly** `mcp` `--tools=agent`. A different argv changes which
69
+ tools the server exposes.
70
+ - **Never overwrite a server the user already declared** under the name
71
+ `engram` — a declared server wins over anything this step would add. Report
72
+ that you found it and left it alone.
73
+ - **Additive and idempotent** — a second run with the same content writes
74
+ nothing and says so.
75
+
76
+ ## 4. Report exactly what was written
77
+
78
+ Every outcome ends in a report the user can read back:
79
+
80
+ - **Registered** — the file path, the server name, and the exact `command` +
81
+ `args` written.
82
+ - **Declined** — that nothing was written, and that the run continued.
83
+ - **Binary missing** — that nothing was written, the install route verbatim, and
84
+ that it is recorded as a residual.
85
+ - **Already declared** — the file that already carries it, and that it was left
86
+ untouched.
87
+
88
+ "Report exactly what was written" is the same contract the Serena step follows;
89
+ a provisioning step the user cannot read back is one they cannot trust.
90
+
91
+ ## Checklist (pass only if)
92
+
93
+ - [ ] `engram` presence **and** version were detected (or absence recorded)
94
+ - [ ] Consent was asked before any file was written — never assumed
95
+ - [ ] The written server uses the absolute binary path and the exact argv
96
+ - [ ] An existing `engram` declaration was left untouched and reported
97
+ - [ ] A second run is a no-op
98
+ - [ ] The closing report states what was written — or that nothing was
99
+
100
+ ## Forbidden
101
+
102
+ - Never print the CLI's own setup subcommand as the install route
103
+ - Never write an MCP config for an unresolved binary
104
+ - Never overwrite an operator's existing `engram` declaration
105
+ - Never let a memory gap fail the run — record it as a residual and continue
106
+ - Never restate the unit-boundary save/recall protocol here; that is the lane's
107
+ memory reference
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: log-session
3
3
  user-invocable: true
4
- version: 2.2.1
4
+ version: 2.3.0
5
5
  argument-hint: "[note to prepend to the entry]"
6
6
  author: "Gabriel Trabanco <1969593+gtrabanco@users.noreply.github.com>"
7
7
  license: MIT
@@ -124,6 +124,22 @@ the HEAD sha and start time at session open.
124
124
  open a PR just for a log entry, and never `git add -A` here — a concurrent
125
125
  conversation's work must not be swept into a log commit.
126
126
 
127
+ ## The canonical split — this skill's half (feature 66, D2)
128
+
129
+ Two writers exist on purpose; neither mirrors the other:
130
+
131
+ - **`docs/LOGS.md` (this skill)** is the **human/team journal** — what a session
132
+ set out to do, what it decided, where to resume. It travels with git, so a
133
+ teammate or a fresh clone reads it with no local state.
134
+ - **Engram (`mem_session_summary`, and the lane's `unit/<NN-slug>/doc` topic)**
135
+ is **local decision and gotcha recall keyed by unit id**, written by
136
+ `unit-lane` and `execute-phase` at unit boundaries. It stays on the machine.
137
+
138
+ Engram never mirrors this log, and this log never mirrors Engram: team memory
139
+ travels with git, agent recall stays local. Write the journal half here; the
140
+ protocol half (boundaries, recall order, degradation) lives in the lane's
141
+ memory reference, `../unit-lane/references/MEMORY.md`.
142
+
127
143
  ## Guardrails
128
144
 
129
145
  - **Never use an expensive model for this.** It's summarization; `qwen3.6`
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: unit-lane
3
3
  user-invocable: true
4
- version: 2.3.0
4
+ version: 2.4.1
5
5
  argument-hint: <NN-slug | "<idea>"> [--retriage] | --fix <issue-number> | --from-issue <issue-number>
6
6
  description: >
7
7
  One-shot lane conductor for a delivery unit: triage, implement, evidence, and
@@ -39,6 +39,8 @@ done:
39
39
  on BREACH: re-triage or record exception, never shrink the diff
40
40
  ✓ Unit doc's Evidence rows updated (command → exit/digest → output → verified-by)
41
41
  and Progress log updated (dated entry) before every commit
42
+ ✓ Memory boundaries ran — probe verdict once, saves at unit start / each phase
43
+ transition / close under `unit/<NN-slug>/doc`, degradation recorded as pending
42
44
  ✓ → Next: block printed as the absolute last output
43
45
  ```
44
46
 
@@ -167,6 +169,20 @@ Before every commit:
167
169
  - **Progress log**: `YYYY-MM-DD HH:MM — <what was done> → <commit sha> — next:
168
170
  <what is next>`
169
171
 
172
+ ### 5b. Memory boundaries
173
+
174
+ Engram is the required memory substrate; a down layer never blocks a unit —
175
+ follow [the memory protocol](references/MEMORY.md).
176
+
177
+ - **Probe once**, before the first memory read: `agentic-workflow memory probe`
178
+ → paste the verdict into Evidence; `degraded` is recorded and said once.
179
+ - **Save at unit start, each phase transition, and close** under
180
+ `topic_key: "unit/<NN-slug>/doc"`, with explicit `project` and explicit
181
+ session attribution (§5 names what carries it on each write surface).
182
+ - **Recall** `mem_context` → scoped search → one explicit `all_projects: true`
183
+ retry → `mem_get_observation` → the unit doc wins. A miss is keyword-bound,
184
+ never proof nothing was saved; an unavailable mirror is marked pending.
185
+
170
186
  ### 6. Close the unit — the close-out ladder
171
187
 
172
188
  When all triaged steps are done, in order:
@@ -0,0 +1,179 @@
1
+ # Memory protocol — save and recall at unit boundaries
2
+
3
+ Engram is this workflow's required cross-session memory substrate (owner
4
+ decision 2026-09-24, feature 66). **Required substrate is not a hard
5
+ dependency:** the committed docs stay authoritative, and a memory layer that is
6
+ down is reported once and recorded — it never blocks a unit.
7
+
8
+ This file is the protocol's single home. `execute-phase` and `log-session` carry
9
+ a thin instruction plus a pointer here; they never restate it.
10
+
11
+ ## 1. Probe before the first memory read
12
+
13
+ Run the bounded health probe once per session, before the first read:
14
+
15
+ ```sh
16
+ agentic-workflow memory probe # human block
17
+ agentic-workflow memory probe --json # memory-probe@1 verdict
18
+ ```
19
+
20
+ It is bounded (4 s / 10 s per check, 30 s ceiling), writes nothing, issues no
21
+ MCP tool call, and **always exits 0**. Paste its verdict into the unit doc's
22
+ Evidence section — one row, `verified-by` naming the session:
23
+
24
+ - `ok` → memory is trustworthy for this session.
25
+ - `degraded` → say **once** which checks failed, record the verdict, and keep
26
+ working. Never re-probe in a loop, never ask the user to fix it first.
27
+
28
+ A `degraded` verdict is an answer, not a blocker: the degraded path in §7 is the
29
+ documented response.
30
+
31
+ ## 2. Boundary saves — what, when, and under which key
32
+
33
+ Save at **three boundaries**, always under a stable per-unit topic key:
34
+
35
+ | Boundary | topic_key | Payload |
36
+ |---|---|---|
37
+ | unit start (after triage) | `unit/<NN-slug>/doc` | goal, the numbered acceptance criteria verbatim, the triaged step list |
38
+ | each phase transition | `unit/<NN-slug>/doc` | the phase id, what it changed, its Evidence outcome, new discoveries |
39
+ | unit close | `unit/<NN-slug>/doc` | final Evidence outcomes, decisions taken, the gotchas worth recalling |
40
+
41
+ One payload, four parts — the same four a later session needs to resume without
42
+ re-reading the docs:
43
+
44
+ ```text
45
+ memory-payload@1
46
+ goal: <one line — the unit's objective>
47
+ ac: 1. <numbered AC verbatim, one per line>
48
+ evidence: <phase> <command> → <exit/digest> → <outcome>
49
+ learned: <gotcha, edge case, or decision a fresh session would get wrong>
50
+ ```
51
+
52
+ Call `mem_save` with `topic_key: "unit/<NN-slug>/doc"` and an explicit
53
+ `project`; on a write surface that exposes `session_id`, pass it too (§5). Later
54
+ boundaries reuse the same `topic_key` so the record **upserts** instead of
55
+ fragmenting into one row per phase.
56
+
57
+ **What is saved is a summary for recall, never the artifact.** The unit doc, its
58
+ Evidence rows and `docs/LOGS.md` stay authoritative; a memory record that
59
+ disagrees with them is wrong by definition.
60
+
61
+ ## 3. Recall — the fixed order
62
+
63
+ Resume in this order, and stop as soon as the unit is reconstructable:
64
+
65
+ 1. **`mem_context`** — scoped explicitly (`project: "<name>"`, and `scope` when
66
+ you mean one).
67
+ 2. **`mem_search`** — a scoped search with an **explicit `project`**. Never rely
68
+ on an omitted selector for a global read; a default is not a decision.
69
+ 3. **One retry** — if the project-scoped search misses, retry **once** with
70
+ **`all_projects: true`** passed explicitly. A second miss is a miss.
71
+ 4. **`mem_get_observation`** — fetch the full untruncated body of the hit that
72
+ matters.
73
+ 5. **Read the unit doc** and reconcile: the file wins on every conflict, and the
74
+ difference is worth recording as a discovery (§2 `learned:`).
75
+
76
+ Only when `mem_context` and both searches miss does a fresh session fall back to
77
+ reading the docs cold — and that fallback is recorded, because it means the
78
+ recall path failed, not that nothing was ever saved.
79
+
80
+ When the cwd is ambiguous (several repos under it), call **`mem_current_project`**
81
+ and navigate by its `available_projects`: pick the matching project explicitly
82
+ rather than letting an ambiguous cwd decide. An ambiguous answer is never
83
+ resolved by guessing.
84
+
85
+ ## 4. Keyword-bound recall — the disclosed ceiling
86
+
87
+ Engram's recall is **keyword-bound** (FTS5 over stored text, no vectors). Two
88
+ consequences are obligations, not trivia:
89
+
90
+ - **A miss is not evidence that knowledge was never saved.** It means the query
91
+ did not hit the stored terms. Rephrase with the unit's own vocabulary (slug,
92
+ AC wording, file paths) before concluding anything is absent.
93
+ - **What you save must be searchable later.** Save the words a future session
94
+ will search for — the unit slug, the phase id, the failing command, the file
95
+ path — not a paraphrase.
96
+
97
+ Hybrid FTS5+vector recall is Engram's own direction (#192), never built here.
98
+
99
+ ## 5. Session attribution — explicit on every write, on the surface that carries it
100
+
101
+ Every write must be **attributed to one known session**, never to whichever
102
+ session a machine happens to resolve. On a box with several active runtime
103
+ sessions an unresolved attribution fails closed and the write is lost — the one
104
+ outcome this protocol exists to prevent (the health probe's
105
+ `session-ambiguity` check reads exactly this signal, §1).
106
+
107
+ Which parameter carries that attribution depends on the surface in front of you:
108
+
109
+ | Write surface | How the session is carried | What you do |
110
+ |---|---|---|
111
+ | Engram's own MCP server (`engram mcp --tools=agent`) | its `mem_save`, `mem_save_prompt`, `mem_session_summary` and `mem_capture_passive` take a `session_id` argument | open the session (`mem_session_start`) and pass that id on **every** write |
112
+ | a host whose memory tools expose no `session_id` (Pi's Engram provider binds the runtime session itself) | the provider stamps the session from the live runtime session and **refuses** when that identity is ambiguous or disagrees with the project | never write before the session is open, always pass an explicit `project` (and `cwd` when the directory could match more than one project), and treat an attribution refusal as a failed write — record it (§7), do not retry blind |
113
+
114
+ On both surfaces the obligation is the same and the check is the same: **a write
115
+ that cannot say which session it belongs to is a write that did not happen**.
116
+
117
+ - `mem_session_start` opens the id (or the runtime session the provider binds to)
118
+ at unit start; the boundaries in §2 reuse it, and nothing is written before it.
119
+ - **`mem_session_end` runs only on confirmed-stale ids** — ids you opened yourself
120
+ and know are finished. Never end an id a live session may still hold.
121
+ - When `mem_session_summary` cannot resolve the project, the explicit
122
+ **project fallback** is the documented escape hatch: pass `project` by name
123
+ and the summary lands instead of being refused. Record the fallback when you
124
+ use it.
125
+ - Passing a `session_id` to a tool that does not declare it is not "explicit
126
+ attribution", it is an invalid call. Read the tool's own parameter list when
127
+ the host offers more than one memory surface, and record which surface you used.
128
+
129
+ ## 6. The canonical split (D2)
130
+
131
+ Both writers exist on purpose; neither mirrors the other.
132
+
133
+ | Writer | Owns | Travels |
134
+ |---|---|---|
135
+ | `docs/LOGS.md` (`log-session`) | the human/team journal: what happened, for people | with git |
136
+ | `mem_session_summary` / `unit/<NN-slug>/doc` | local decision and gotcha recall keyed by unit id | local only |
137
+
138
+ Team memory travels with git; agent recall stays local. A double-write on
139
+ purpose, recorded here so it is never mistaken for an accident.
140
+
141
+ ## 7. Degradation — recorded, never silent
142
+
143
+ When the probe says `degraded` or a write fails:
144
+
145
+ - **Local progress is preserved.** The unit doc, Evidence rows and commit are
146
+ the record; they never depend on memory.
147
+ - **The mirror is marked pending explicitly** — one line in the Progress entry
148
+ or Evidence row saying the memory write is pending, so a later session knows
149
+ the gap exists and is not a "nothing was saved" conclusion.
150
+ - **Unrelated safe work continues.** Never stop a unit for a mirror failure.
151
+ - **Sync resumes when the layer answers** — retry the pending write at the next
152
+ boundary, not in a loop.
153
+ - **Read both sides back.** After a write, read the file and the mirror; when
154
+ the versions are irreconcilable, keep both and surface the difference rather
155
+ than silently picking one.
156
+
157
+ ## Checklist (pass only if)
158
+
159
+ - [ ] The probe ran once and its verdict is in Evidence (§1)
160
+ - [ ] A save happened at unit start, at each phase transition, and at close (§2)
161
+ - [ ] Every save was explicitly attributed: a `session_id` on a surface that
162
+ accepts one, otherwise an open session plus an explicit `project` — and it
163
+ named the unit's `topic_key` (§2, §5)
164
+ - [ ] Recall followed the fixed order, with an explicit `project` and at most
165
+ one explicit `all_projects: true` retry (§3)
166
+ - [ ] Any miss was treated as keyword-bound, not as absence (§4)
167
+ - [ ] Any degradation was recorded as pending — never silent (§7)
168
+
169
+ ## Forbidden
170
+
171
+ - Never let a memory verdict block a unit, or re-probe in a loop
172
+ - Never rely on an omitted selector for a global read
173
+ - Never write with unresolved session attribution — pass the `session_id` where
174
+ the surface takes one, and where it does not, never write before the session is
175
+ open (§5)
176
+ - Never call `mem_session_end` on an id you did not open or cannot confirm stale
177
+ - Never mirror `docs/LOGS.md` verbatim — the split is deliberate (§6)
178
+ - Never treat a recall miss as proof nothing was saved
179
+ - Never let a memory record override the committed artifact