@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.
@@ -7,7 +7,9 @@ import { evaluateToolCall, isProtectedPath, normalizeTarget } from "../config/pa
7
7
  import { createExtension } from "./factory.js";
8
8
  import { readGitStatusBounded } from "./receipt-guard.js";
9
9
  import { createPickerComponent, PICKER_MAX_VISIBLE, pagedSelect } from "../settings/picker.js";
10
- import { createHintStore, stateFilePath } from "../routing/state.js";
10
+ import { createHintStore, createSerenaReadinessStore, stateFilePath } from "../routing/state.js";
11
+ import { createSerenaReadinessGate, readInitWorkspaceRevision } from "../routing/serena-readiness.js";
12
+ import { provisionEngram, readDeclaredServerNamesFrom, resolveEngramBinary, declaredMcpServerPaths } from "./engram.js";
11
13
  import { runSettingsConsole } from "../settings/console.js";
12
14
  import { readConfigFile, writeConfigFile } from "../settings/store.js";
13
15
  import { SETTINGS_COMMAND } from "../routing/types.js";
@@ -171,9 +173,59 @@ function resolveExistingPrefix(target) {
171
173
  }
172
174
  }
173
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
+ }
174
218
  export default function extension(pi) {
175
219
  const agentDir = getAgentDir();
176
220
  const hint = createHintStore({ path: stateFilePath(agentDir) });
221
+ // One gate for both entry points (AC9): session start says it first, and a
222
+ // session that begins by running a command says it there instead. The
223
+ // revision is the bundled init-workspace's own, so an upgrade that ships a
224
+ // newer provisioning contract can tell the operator the project predates it.
225
+ const serenaReadiness = createSerenaReadinessGate({
226
+ store: createSerenaReadinessStore({ path: stateFilePath(agentDir) }),
227
+ revision: readInitWorkspaceRevision(skillsDir),
228
+ });
177
229
  // Said once per session: an override that was ignored (or a malformed file)
178
230
  // is reported the first time the guard runs, never on every tool call.
179
231
  let reportedDegradations = false;
@@ -190,6 +242,7 @@ export default function extension(pi) {
190
242
  skillsDir,
191
243
  agentDir,
192
244
  hint,
245
+ serenaReadiness,
193
246
  loadConfig: (ctx) => loadConfig({ agentDir, cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted() }),
194
247
  // The console edits the same files the dispatcher reads (SPEC S4, AC10).
195
248
  settings: ({ ctx, catalogue: routed, routing }) => runSettingsConsole({
@@ -204,6 +257,38 @@ export default function extension(pi) {
204
257
  writeFile: writeConfigFile,
205
258
  }),
206
259
  });
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.
273
+ pi.on("session_start", (_event, ctx) => {
274
+ const message = serenaReadiness.check(ctx.cwd);
275
+ if (message)
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
+ }
291
+ });
207
292
  pi.on("model_select", (event) => router.noteModelSelect(event.model));
208
293
  pi.on("thinking_level_select", (event) => router.noteThinkingLevelSelect(event.level));
209
294
  // The inline-receipt path is impossible: a `gh pr comment` carrying a
@@ -0,0 +1,77 @@
1
+ import type { SerenaReadinessRecord, SerenaReadinessStore } from "./state.js";
2
+ /**
3
+ * Serena readiness nudge (feature 67 P5, decision D5 / AC9).
4
+ *
5
+ * A pi user who installs this package gets Serena's tools, but the project
6
+ * config is `init-workspace`'s to write (AC6: one writer, no drift). So this is
7
+ * the only thing the package may do about a project that was never provisioned:
8
+ * **say so, once**, and point at the command that fixes it.
9
+ *
10
+ * The contract, in four lines:
11
+ * - it reads `.serena/project.yml` and reports `missing`, `stale`, `outdated`
12
+ * (the bundled init-workspace moved on) or `ready`;
13
+ * - it announces **exactly one** message per recorded state, then latches —
14
+ * the latch lives in the package's own global state file, so a restart does
15
+ * not re-nag (the first-run hint's contract);
16
+ * - it writes nothing to the project and nothing to any configuration;
17
+ * - every failure is soft: a broken read is `missing`, a failed latch is a
18
+ * nuisance, and neither ever throws into a command.
19
+ */
20
+ /** The one command that owns Serena provisioning. */
21
+ export declare const INIT_WORKSPACE_COMMAND = "/init-workspace";
22
+ /** What a `.serena/project.yml` says about itself — only what this check reads. */
23
+ export interface SerenaConfig {
24
+ present: boolean;
25
+ /** The `language_servers` entries, in file order. Empty when absent or empty. */
26
+ languageServers: string[];
27
+ }
28
+ export type SerenaReadiness = {
29
+ kind: "missing";
30
+ } | {
31
+ kind: "stale";
32
+ detail: string;
33
+ } | {
34
+ kind: "outdated";
35
+ recorded: string;
36
+ revision: string;
37
+ } | {
38
+ kind: "ready";
39
+ };
40
+ /**
41
+ * Read the two facts this check needs out of the project config. A tolerant
42
+ * line scanner, not a YAML parser: the file is written by `init-workspace`, and
43
+ * the only key read here is the `language_servers` list. Anything it cannot
44
+ * understand reads as "no servers", which is the fail-closed direction — the
45
+ * nudge fires rather than a broken config passing silently.
46
+ */
47
+ export declare function inspectSerenaConfig(text: string | null | undefined): SerenaConfig;
48
+ /**
49
+ * Decide what this project needs. `recorded` is the latch's last announcement —
50
+ * a changed shipped revision is itself a trigger, so an upgraded package can
51
+ * tell the operator that the config predates it.
52
+ */
53
+ export declare function evaluateSerenaReadiness({ config, recorded, revision, }: {
54
+ config: SerenaConfig;
55
+ recorded?: SerenaReadinessRecord;
56
+ revision: string;
57
+ }): SerenaReadiness;
58
+ /** The one notification this module may produce, or null when there is nothing to say. */
59
+ export declare function serenaReadinessMessage(readiness: SerenaReadiness): string | null;
60
+ export interface SerenaReadinessGate {
61
+ /** The message to announce for this project, or null when nothing is due. */
62
+ check(projectKey: string): string | null;
63
+ }
64
+ /**
65
+ * The gate the two entry points share (session start and the first command
66
+ * dispatch). One latch per extension instance, so a single session announces at
67
+ * most once; the persisted record keeps later sessions quiet.
68
+ */
69
+ export declare function createSerenaReadinessGate({ store, revision, readProject, now, }: {
70
+ store: SerenaReadinessStore;
71
+ revision: string;
72
+ /** Injectable so the suite reads fixtures instead of a real project. */
73
+ readProject?: (projectKey: string) => string | null;
74
+ now?: () => string;
75
+ }): SerenaReadinessGate;
76
+ /** The bundled `init-workspace` revision, read from the shipped skill's frontmatter. */
77
+ export declare function readInitWorkspaceRevision(skillsDir: string): string;
@@ -0,0 +1,142 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Serena readiness nudge (feature 67 P5, decision D5 / AC9).
5
+ *
6
+ * A pi user who installs this package gets Serena's tools, but the project
7
+ * config is `init-workspace`'s to write (AC6: one writer, no drift). So this is
8
+ * the only thing the package may do about a project that was never provisioned:
9
+ * **say so, once**, and point at the command that fixes it.
10
+ *
11
+ * The contract, in four lines:
12
+ * - it reads `.serena/project.yml` and reports `missing`, `stale`, `outdated`
13
+ * (the bundled init-workspace moved on) or `ready`;
14
+ * - it announces **exactly one** message per recorded state, then latches —
15
+ * the latch lives in the package's own global state file, so a restart does
16
+ * not re-nag (the first-run hint's contract);
17
+ * - it writes nothing to the project and nothing to any configuration;
18
+ * - every failure is soft: a broken read is `missing`, a failed latch is a
19
+ * nuisance, and neither ever throws into a command.
20
+ */
21
+ /** The one command that owns Serena provisioning. */
22
+ export const INIT_WORKSPACE_COMMAND = "/init-workspace";
23
+ /**
24
+ * Read the two facts this check needs out of the project config. A tolerant
25
+ * line scanner, not a YAML parser: the file is written by `init-workspace`, and
26
+ * the only key read here is the `language_servers` list. Anything it cannot
27
+ * understand reads as "no servers", which is the fail-closed direction — the
28
+ * nudge fires rather than a broken config passing silently.
29
+ */
30
+ export function inspectSerenaConfig(text) {
31
+ if (typeof text !== "string")
32
+ return { present: false, languageServers: [] };
33
+ const lines = text.split(/\r?\n/u);
34
+ const start = lines.findIndex((line) => /^language_servers:/u.test(line));
35
+ if (start === -1)
36
+ return { present: true, languageServers: [] };
37
+ // Inline form: `language_servers: [markdown, json]`
38
+ const inline = /^language_servers:\s*\[(.*)\]\s*$/u.exec(lines[start]);
39
+ if (inline) {
40
+ return {
41
+ present: true,
42
+ languageServers: inline[1]
43
+ .split(",")
44
+ .map((entry) => entry.trim().replace(/^["']|["']$/gu, ""))
45
+ .filter(Boolean),
46
+ };
47
+ }
48
+ const languageServers = [];
49
+ for (const line of lines.slice(start + 1)) {
50
+ const item = /^\s+-\s*(.+?)\s*$/u.exec(line);
51
+ if (!item) {
52
+ // The list ended (a new top-level key) or the line is blank/comment.
53
+ if (line.trim() !== "" && !/^\s*#/u.test(line))
54
+ break;
55
+ continue;
56
+ }
57
+ languageServers.push(item[1].replace(/^["']|["']$/gu, ""));
58
+ }
59
+ return { present: true, languageServers };
60
+ }
61
+ /**
62
+ * Decide what this project needs. `recorded` is the latch's last announcement —
63
+ * a changed shipped revision is itself a trigger, so an upgraded package can
64
+ * tell the operator that the config predates it.
65
+ */
66
+ export function evaluateSerenaReadiness({ config, recorded, revision, }) {
67
+ if (!config.present)
68
+ return { kind: "missing" };
69
+ if (config.languageServers.length === 0) {
70
+ return {
71
+ kind: "stale",
72
+ detail: "the project config lists no language_servers, so no server will start for it",
73
+ };
74
+ }
75
+ if (recorded?.state === "ready" && recorded.revision !== revision) {
76
+ return { kind: "outdated", recorded: recorded.revision, revision };
77
+ }
78
+ return { kind: "ready" };
79
+ }
80
+ /** The one notification this module may produce, or null when there is nothing to say. */
81
+ export function serenaReadinessMessage(readiness) {
82
+ switch (readiness.kind) {
83
+ case "missing":
84
+ return (`pi-agentic-workflow: serena — this project has no .serena/project.yml, so no language server ` +
85
+ `will start for it. Run ${INIT_WORKSPACE_COMMAND} to provision it (this is said once).`);
86
+ case "stale":
87
+ return (`pi-agentic-workflow: serena — ${readiness.detail}. Run ${INIT_WORKSPACE_COMMAND} to rewrite it ` +
88
+ `(this is said once).`);
89
+ case "outdated":
90
+ return (`pi-agentic-workflow: serena — this project's config was provisioned by init-workspace ` +
91
+ `${readiness.recorded}, and this package ships ${readiness.revision}. Run ${INIT_WORKSPACE_COMMAND} ` +
92
+ `to bring it up to date (this is said once).`);
93
+ case "ready":
94
+ return null;
95
+ }
96
+ }
97
+ /**
98
+ * The gate the two entry points share (session start and the first command
99
+ * dispatch). One latch per extension instance, so a single session announces at
100
+ * most once; the persisted record keeps later sessions quiet.
101
+ */
102
+ export function createSerenaReadinessGate({ store, revision, readProject = (projectKey) => {
103
+ try {
104
+ return readFileSync(join(projectKey, ".serena", "project.yml"), "utf8");
105
+ }
106
+ catch {
107
+ return null;
108
+ }
109
+ }, now = () => new Date().toISOString(), }) {
110
+ let announced = false;
111
+ return {
112
+ check(projectKey) {
113
+ if (announced)
114
+ return null;
115
+ announced = true;
116
+ const config = inspectSerenaConfig(readProject(projectKey));
117
+ const recorded = store.record(projectKey);
118
+ const readiness = evaluateSerenaReadiness({ config, recorded, revision });
119
+ // The latch compares STATE KINDS, not just the presence of a record: a
120
+ // project that was ready and lost its config is a new thing to say, while
121
+ // a project still missing one is not.
122
+ const due = readiness.kind !== "ready" && recorded?.state !== readiness.kind;
123
+ // Latch second, and always: a ready project still stores its revision, so
124
+ // a later upgrade has a baseline to compare against.
125
+ store.latch(projectKey, { state: readiness.kind, revision, at: now() });
126
+ if (!due)
127
+ return null;
128
+ return serenaReadinessMessage(readiness);
129
+ },
130
+ };
131
+ }
132
+ /** The bundled `init-workspace` revision, read from the shipped skill's frontmatter. */
133
+ export function readInitWorkspaceRevision(skillsDir) {
134
+ try {
135
+ const text = readFileSync(join(skillsDir, "init-workspace", "SKILL.md"), "utf8");
136
+ const match = /^version:\s*(\S+)\s*$/mu.exec(text.split("\n---", 1)[0] ?? "");
137
+ return match ? match[1] : "unknown";
138
+ }
139
+ catch {
140
+ return "unknown";
141
+ }
142
+ }
@@ -37,3 +37,35 @@ export interface ProfileStateStoreOptions {
37
37
  writeFile?: (path: string, text: string) => void;
38
38
  }
39
39
  export declare function createProfileStateStore({ path, readFile, writeFile, }: ProfileStateStoreOptions): ProfileStateStore;
40
+ /**
41
+ * What the operator was last told about one project's Serena config, keyed by
42
+ * the project root. The record is the nudge's whole memory: the state kind it
43
+ * announced and the init-workspace revision that was current when it did.
44
+ */
45
+ export interface SerenaReadinessRecord {
46
+ /** The readiness kind announced (`missing`, `stale`, `outdated`, `ready`). */
47
+ state: string;
48
+ /** The bundled `init-workspace` revision in force at that moment. */
49
+ revision: string;
50
+ /** ISO-8601 timestamp of the announcement. */
51
+ at: string;
52
+ }
53
+ export interface SerenaReadinessStore {
54
+ /** The record for `projectKey`, or undefined when this project was never seen. */
55
+ record(projectKey: string): SerenaReadinessRecord | undefined;
56
+ /** Persist the record for `projectKey`. Returns false when the write failed. */
57
+ latch(projectKey: string, record: SerenaReadinessRecord): boolean;
58
+ }
59
+ export interface SerenaReadinessStoreOptions {
60
+ path: string;
61
+ readFile?: (path: string) => string | null;
62
+ writeFile?: (path: string, text: string) => void;
63
+ }
64
+ /**
65
+ * The nudge's latch, in the package's own global state file beside the
66
+ * first-run hint and the profile demotion — never inside the project, and never
67
+ * inside any configuration. Read-merge-write throughout, so no other key in
68
+ * that file is disturbed; every failure is soft (a latch that cannot be
69
+ * persisted re-shows the nudge next session, which is a nuisance, not a fault).
70
+ */
71
+ export declare function createSerenaReadinessStore({ path, readFile, writeFile, }: SerenaReadinessStoreOptions): SerenaReadinessStore;
@@ -129,3 +129,54 @@ export function createProfileStateStore({ path, readFile = readOrNull, writeFile
129
129
  },
130
130
  };
131
131
  }
132
+ /**
133
+ * The nudge's latch, in the package's own global state file beside the
134
+ * first-run hint and the profile demotion — never inside the project, and never
135
+ * inside any configuration. Read-merge-write throughout, so no other key in
136
+ * that file is disturbed; every failure is soft (a latch that cannot be
137
+ * persisted re-shows the nudge next session, which is a nuisance, not a fault).
138
+ */
139
+ export function createSerenaReadinessStore({ path, readFile = readOrNull, writeFile = writeThrough, }) {
140
+ const readState = () => {
141
+ const text = readFile(path);
142
+ if (text === null || text.trim() === "")
143
+ return {};
144
+ try {
145
+ const parsed = JSON.parse(text);
146
+ if (typeof parsed === "object" && parsed !== null)
147
+ return parsed;
148
+ }
149
+ catch {
150
+ // Corrupt state: treat as empty, so the nudge is shown rather than lost.
151
+ }
152
+ return {};
153
+ };
154
+ return {
155
+ record(projectKey) {
156
+ const raw = readState().serenaReadiness;
157
+ if (typeof raw !== "object" || raw === null)
158
+ return undefined;
159
+ const entry = raw[projectKey];
160
+ if (typeof entry !== "object" || entry === null)
161
+ return undefined;
162
+ const { state, revision, at } = entry;
163
+ if (typeof state !== "string" || typeof revision !== "string" || typeof at !== "string")
164
+ return undefined;
165
+ return { state, revision, at };
166
+ },
167
+ latch(projectKey, record) {
168
+ const state = readState();
169
+ const existing = state.serenaReadiness;
170
+ const perProject = typeof existing === "object" && existing !== null ? existing : {};
171
+ perProject[projectKey] = record;
172
+ state.serenaReadiness = perProject;
173
+ try {
174
+ writeFile(path, `${JSON.stringify(state, null, 2)}\n`);
175
+ return true;
176
+ }
177
+ catch {
178
+ return false;
179
+ }
180
+ },
181
+ };
182
+ }
@@ -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.26.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",
@@ -21,7 +21,8 @@
21
21
  "type": "module",
22
22
  "pi": {
23
23
  "extensions": [
24
- "./dist/extension/index.js"
24
+ "./dist/extension/index.js",
25
+ "./node_modules/@bacnh85/pi-serena/extensions/index.ts"
25
26
  ],
26
27
  "skills": [
27
28
  "./skills"
@@ -57,8 +58,9 @@
57
58
  "@earendil-works/pi-tui": "*"
58
59
  },
59
60
  "dependencies": {
61
+ "@bacnh85/pi-serena": "0.10.0",
60
62
  "@gtrabanco/agentic-workflow": "0.2.0",
61
- "@gtrabanco/agentic-workflow-schema": "4.6.0"
63
+ "@gtrabanco/agentic-workflow-schema": "4.7.0"
62
64
  },
63
65
  "devDependencies": {
64
66
  "@types/node": "24.13.3",
@@ -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)