@gtrabanco/pi-agentic-workflow 0.26.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/config/defaults.js +3 -0
- package/dist/config/merge.js +5 -0
- package/dist/config/schema.d.ts +0 -5
- package/dist/config/schema.js +31 -1
- package/dist/config/types.d.ts +15 -0
- package/dist/extension/engram.d.ts +119 -0
- package/dist/extension/engram.js +187 -0
- package/dist/extension/factory.d.ts +6 -0
- package/dist/extension/factory.js +9 -0
- package/dist/extension/index.js +86 -1
- package/dist/routing/serena-readiness.d.ts +77 -0
- package/dist/routing/serena-readiness.js +142 -0
- package/dist/routing/state.d.ts +32 -0
- package/dist/routing/state.js +51 -0
- package/dist/settings/console.js +68 -22
- package/package.json +5 -3
- package/skills/execute-phase/SKILL.md +11 -2
- package/skills/init-workspace/SKILL.md +30 -11
- package/skills/init-workspace/references/BOOTSTRAP_DISCOVERY.md +6 -0
- package/skills/init-workspace/references/BOOTSTRAP_WRITE.md +21 -12
- package/skills/init-workspace/references/ENGRAM.md +107 -0
- package/skills/init-workspace/references/SERENA.md +92 -6
- package/skills/init-workspace/references/UPGRADE.md +9 -7
- package/skills/log-session/SKILL.md +17 -1
- package/skills/unit-lane/SKILL.md +17 -1
- package/skills/unit-lane/references/MEMORY.md +179 -0
package/dist/extension/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/routing/state.d.ts
CHANGED
|
@@ -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;
|
package/dist/routing/state.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/settings/console.js
CHANGED
|
@@ -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
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
681
|
-
|
|
723
|
+
const file = {};
|
|
724
|
+
for (const key of KEY_ORDER) {
|
|
725
|
+
if (key in kept)
|
|
726
|
+
file[key] = kept[key];
|
|
682
727
|
}
|
|
683
|
-
|
|
684
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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. **
|
|
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)
|