@dieulc/workflow 0.9.1 → 0.10.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 -15
- package/edit-policy.ts +11 -64
- package/index.ts +1136 -200
- package/package.json +10 -6
- package/permissions.ts +393 -93
- package/provider-retarget.ts +258 -0
- package/review-handoff.ts +125 -0
- package/review-recovery.ts +65 -0
- package/review-runtime.ts +318 -48
- package/review-tools.ts +303 -6
- package/review.ts +321 -4
- package/shell-text.ts +542 -0
- package/utils.ts +42 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — provider retarget for profiles.json / roles.json.
|
|
3
|
+
*
|
|
4
|
+
* The maintained Command Code provider registers as `commandcode`; the
|
|
5
|
+
* hand-written workaround often registers as `command-code`. Migrating a user
|
|
6
|
+
* should not mean hand-editing two JSON files and hoping every other key
|
|
7
|
+
* survives. This module rewrites only `{ provider, id }` model refs, preserves
|
|
8
|
+
* every other field, and backs up the original before writing.
|
|
9
|
+
*
|
|
10
|
+
* Pure parsing/formatting is separated from the filesystem so the tests can
|
|
11
|
+
* exercise both a dry run and an apply without touching a real profile.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
export type RetargetFileStatus =
|
|
18
|
+
"changed" | "would-change" | "unchanged" | "missing" | "invalid" | "blocked";
|
|
19
|
+
|
|
20
|
+
export interface RetargetFileResult {
|
|
21
|
+
path: string;
|
|
22
|
+
status: RetargetFileStatus;
|
|
23
|
+
changes: number;
|
|
24
|
+
backup?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RetargetFilesResult {
|
|
29
|
+
oldProvider: string;
|
|
30
|
+
newProvider: string;
|
|
31
|
+
dryRun: boolean;
|
|
32
|
+
profiles: RetargetFileResult;
|
|
33
|
+
roles: RetargetFileResult;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RetargetFilesInput {
|
|
37
|
+
profilesPath: string;
|
|
38
|
+
rolesPath: string;
|
|
39
|
+
oldProvider: string;
|
|
40
|
+
newProvider: string;
|
|
41
|
+
/** When true, compute only; never write. */
|
|
42
|
+
dryRun?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RetargetDocumentResult =
|
|
46
|
+
{ ok: true; text: string; changes: number } | { ok: false; error: string };
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Rewrite every `{ provider: oldProvider, id: <string> }` object in a parsed
|
|
54
|
+
* profile/role document. Unknown keys and their order are preserved because the
|
|
55
|
+
* input is mutated in place and serialized with the same 2-space layout.
|
|
56
|
+
*/
|
|
57
|
+
function retargetValue(
|
|
58
|
+
value: unknown,
|
|
59
|
+
oldProvider: string,
|
|
60
|
+
newProvider: string,
|
|
61
|
+
counter: { changes: number },
|
|
62
|
+
depth: number,
|
|
63
|
+
): void {
|
|
64
|
+
if (depth > 64) return;
|
|
65
|
+
if (Array.isArray(value)) {
|
|
66
|
+
for (const entry of value) {
|
|
67
|
+
retargetValue(entry, oldProvider, newProvider, counter, depth + 1);
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!isRecord(value)) return;
|
|
72
|
+
if (value.provider === oldProvider && typeof value.id === "string") {
|
|
73
|
+
value.provider = newProvider;
|
|
74
|
+
counter.changes += 1;
|
|
75
|
+
}
|
|
76
|
+
for (const entry of Object.values(value)) {
|
|
77
|
+
retargetValue(entry, oldProvider, newProvider, counter, depth + 1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Retarget one JSON document. Malformed JSON or a non-object root is an error;
|
|
83
|
+
* the caller must not write in that case. A same-provider request is a no-op.
|
|
84
|
+
*/
|
|
85
|
+
export function retargetProviderDocument(
|
|
86
|
+
text: string,
|
|
87
|
+
oldProvider: string,
|
|
88
|
+
newProvider: string,
|
|
89
|
+
): RetargetDocumentResult {
|
|
90
|
+
let parsed: unknown;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(text);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
ok: false,
|
|
96
|
+
error: `not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (!isRecord(parsed)) {
|
|
100
|
+
return { ok: false, error: "the document root is not a JSON object" };
|
|
101
|
+
}
|
|
102
|
+
if (oldProvider === newProvider) {
|
|
103
|
+
return { ok: true, text, changes: 0 };
|
|
104
|
+
}
|
|
105
|
+
const counter = { changes: 0 };
|
|
106
|
+
retargetValue(parsed, oldProvider, newProvider, counter, 0);
|
|
107
|
+
if (counter.changes === 0) return { ok: true, text, changes: 0 };
|
|
108
|
+
const trailing = text.endsWith("\n") ? "\n" : "";
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
text: `${JSON.stringify(parsed, null, 2)}${trailing}`,
|
|
112
|
+
changes: counter.changes,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface FilePlan {
|
|
117
|
+
result: RetargetFileResult;
|
|
118
|
+
nextText?: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readPlan(
|
|
122
|
+
filePath: string,
|
|
123
|
+
oldProvider: string,
|
|
124
|
+
newProvider: string,
|
|
125
|
+
): FilePlan {
|
|
126
|
+
if (!fs.existsSync(filePath)) {
|
|
127
|
+
return { result: { path: filePath, status: "missing", changes: 0 } };
|
|
128
|
+
}
|
|
129
|
+
let text: string;
|
|
130
|
+
try {
|
|
131
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
result: {
|
|
135
|
+
path: filePath,
|
|
136
|
+
status: "invalid",
|
|
137
|
+
changes: 0,
|
|
138
|
+
error: error instanceof Error ? error.message : String(error),
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const rewritten = retargetProviderDocument(text, oldProvider, newProvider);
|
|
143
|
+
if (!rewritten.ok) {
|
|
144
|
+
return {
|
|
145
|
+
result: {
|
|
146
|
+
path: filePath,
|
|
147
|
+
status: "invalid",
|
|
148
|
+
changes: 0,
|
|
149
|
+
error: rewritten.error,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
if (rewritten.changes === 0) {
|
|
154
|
+
return { result: { path: filePath, status: "unchanged", changes: 0 } };
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
result: {
|
|
158
|
+
path: filePath,
|
|
159
|
+
status: "would-change",
|
|
160
|
+
changes: rewritten.changes,
|
|
161
|
+
},
|
|
162
|
+
nextText: rewritten.text,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function applyPlan(plan: FilePlan): RetargetFileResult {
|
|
167
|
+
if (plan.result.status !== "would-change" || plan.nextText === undefined) {
|
|
168
|
+
return plan.result;
|
|
169
|
+
}
|
|
170
|
+
const filePath = plan.result.path;
|
|
171
|
+
const backup = `${filePath}.bak`;
|
|
172
|
+
const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
173
|
+
try {
|
|
174
|
+
fs.copyFileSync(filePath, backup);
|
|
175
|
+
fs.writeFileSync(temporary, plan.nextText, "utf8");
|
|
176
|
+
fs.renameSync(temporary, filePath);
|
|
177
|
+
return { ...plan.result, status: "changed", backup };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
try {
|
|
180
|
+
fs.rmSync(temporary, { force: true });
|
|
181
|
+
} catch {
|
|
182
|
+
// Best-effort cleanup must not replace the write error.
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
...plan.result,
|
|
186
|
+
status: "invalid",
|
|
187
|
+
error: error instanceof Error ? error.message : String(error),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Retarget both files. Two-phase: if either document is invalid, neither file
|
|
194
|
+
* is written. A dry run never writes or creates a backup.
|
|
195
|
+
*/
|
|
196
|
+
export function retargetProviderFiles(
|
|
197
|
+
input: RetargetFilesInput,
|
|
198
|
+
): RetargetFilesResult {
|
|
199
|
+
const oldProvider = String(input.oldProvider ?? "").trim();
|
|
200
|
+
const newProvider = String(input.newProvider ?? "").trim();
|
|
201
|
+
const dryRun = input.dryRun === true;
|
|
202
|
+
const profiles = readPlan(input.profilesPath, oldProvider, newProvider);
|
|
203
|
+
const roles = readPlan(input.rolesPath, oldProvider, newProvider);
|
|
204
|
+
const invalid =
|
|
205
|
+
profiles.result.status === "invalid" || roles.result.status === "invalid";
|
|
206
|
+
const finish = (plan: FilePlan): RetargetFileResult => {
|
|
207
|
+
if (invalid) {
|
|
208
|
+
return plan.result.status === "invalid"
|
|
209
|
+
? plan.result
|
|
210
|
+
: plan.result.status === "would-change"
|
|
211
|
+
? {
|
|
212
|
+
...plan.result,
|
|
213
|
+
status: "blocked",
|
|
214
|
+
error: "not written because the other document is invalid",
|
|
215
|
+
}
|
|
216
|
+
: plan.result;
|
|
217
|
+
}
|
|
218
|
+
if (dryRun) return plan.result;
|
|
219
|
+
return applyPlan(plan);
|
|
220
|
+
};
|
|
221
|
+
return {
|
|
222
|
+
oldProvider,
|
|
223
|
+
newProvider,
|
|
224
|
+
dryRun,
|
|
225
|
+
profiles: finish(profiles),
|
|
226
|
+
roles: finish(roles),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function statusLabel(result: RetargetFileResult): string {
|
|
231
|
+
switch (result.status) {
|
|
232
|
+
case "changed":
|
|
233
|
+
return `changed ${result.changes} model ref(s)${result.backup ? `; backup ${path.basename(result.backup)}` : ""}`;
|
|
234
|
+
case "would-change":
|
|
235
|
+
return `would change ${result.changes} model ref(s)`;
|
|
236
|
+
case "unchanged":
|
|
237
|
+
return "no model refs to change";
|
|
238
|
+
case "missing":
|
|
239
|
+
return "not present";
|
|
240
|
+
case "invalid":
|
|
241
|
+
return `not changed — ${result.error ?? "invalid document"}`;
|
|
242
|
+
case "blocked":
|
|
243
|
+
return `not written — ${result.error ?? "blocked by another invalid file"}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Human-readable command result; safe for `ctx.ui.notify`. */
|
|
248
|
+
export function formatRetargetResult(result: RetargetFilesResult): string {
|
|
249
|
+
const verb = result.dryRun ? "Dry run" : "Retarget";
|
|
250
|
+
return [
|
|
251
|
+
`${verb}: provider ${result.oldProvider} → ${result.newProvider}`,
|
|
252
|
+
` profiles.json ${statusLabel(result.profiles)}`,
|
|
253
|
+
` roles.json ${statusLabel(result.roles)}`,
|
|
254
|
+
...(result.dryRun
|
|
255
|
+
? ["Add --apply to write; each changed file keeps a .bak."]
|
|
256
|
+
: []),
|
|
257
|
+
].join("\n");
|
|
258
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review Mode — Plannotator handoff decisions.
|
|
3
|
+
*
|
|
4
|
+
* The browser request itself is orchestrated in `index.ts` because it needs the
|
|
5
|
+
* Pi event bus, the current session context and the plan file. Everything that
|
|
6
|
+
* can be decided without those live objects lives here:
|
|
7
|
+
*
|
|
8
|
+
* - whether an open browser review still belongs to the current plan;
|
|
9
|
+
* - whether a pending gate must be superseded by a new round;
|
|
10
|
+
* - whether a settled manual round should hand off at all.
|
|
11
|
+
*
|
|
12
|
+
* Keeping these pure is what lets the extension tests cover the stale-gate and
|
|
13
|
+
* reload-recovery matrix without a host or a real browser.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ReviewExecutionResult } from "./review.ts";
|
|
17
|
+
|
|
18
|
+
/** What opened the browser review. */
|
|
19
|
+
export type ReviewHandoffSource = "auto" | "manual";
|
|
20
|
+
|
|
21
|
+
/** The correlation persisted with `awaitingDecision`. */
|
|
22
|
+
export interface PendingReviewHandoff {
|
|
23
|
+
reviewId: string;
|
|
24
|
+
planPath: string;
|
|
25
|
+
planHash: string;
|
|
26
|
+
source: ReviewHandoffSource;
|
|
27
|
+
requestId?: string;
|
|
28
|
+
reviewUrl?: string;
|
|
29
|
+
cycleId: number;
|
|
30
|
+
at: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ReviewHandoffRecovery =
|
|
34
|
+
| { action: "keep"; reason: "pending" }
|
|
35
|
+
| {
|
|
36
|
+
action: "apply";
|
|
37
|
+
approved: boolean;
|
|
38
|
+
feedback?: string;
|
|
39
|
+
savedPath?: string;
|
|
40
|
+
}
|
|
41
|
+
| { action: "clear"; reason: string };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decide what to do with a pending handoff after a reload.
|
|
45
|
+
*
|
|
46
|
+
* Plannotator's `review-status` is authoritative for whether the review is
|
|
47
|
+
* still open, but not for whether it still describes the current plan. A plan
|
|
48
|
+
* changed while the app was away makes both a pending tab and a completed
|
|
49
|
+
* decision stale, so the hash is checked before either is honoured.
|
|
50
|
+
*/
|
|
51
|
+
export function reviewHandoffRecovery(input: {
|
|
52
|
+
status: "pending" | "completed" | "missing";
|
|
53
|
+
approved?: boolean;
|
|
54
|
+
feedback?: string;
|
|
55
|
+
savedPath?: string;
|
|
56
|
+
pendingHash?: string;
|
|
57
|
+
currentHash?: string;
|
|
58
|
+
}): ReviewHandoffRecovery {
|
|
59
|
+
if (input.status === "missing") {
|
|
60
|
+
return {
|
|
61
|
+
action: "clear",
|
|
62
|
+
reason: "the browser review is no longer available",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
input.pendingHash !== undefined &&
|
|
67
|
+
input.currentHash !== undefined &&
|
|
68
|
+
input.pendingHash !== input.currentHash
|
|
69
|
+
) {
|
|
70
|
+
return {
|
|
71
|
+
action: "clear",
|
|
72
|
+
reason: "the plan changed since the review opened",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (input.status === "pending") {
|
|
76
|
+
return { action: "keep", reason: "pending" };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
action: "apply",
|
|
80
|
+
approved: input.approved === true,
|
|
81
|
+
...(input.feedback !== undefined ? { feedback: input.feedback } : {}),
|
|
82
|
+
...(input.savedPath !== undefined ? { savedPath: input.savedPath } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when a pending browser gate cannot belong to the round about to start.
|
|
88
|
+
*
|
|
89
|
+
* `force` is for an automatic round start (a new round must never inherit an
|
|
90
|
+
* older gate) and for `/review`: both are an explicit supersede even when the
|
|
91
|
+
* plan hash happens to match.
|
|
92
|
+
*/
|
|
93
|
+
export function pendingHandoffIsStale(input: {
|
|
94
|
+
awaiting: boolean;
|
|
95
|
+
force?: boolean;
|
|
96
|
+
pendingCycleId?: number;
|
|
97
|
+
currentCycleId?: number;
|
|
98
|
+
pendingPlanHash?: string;
|
|
99
|
+
nextPlanHash?: string;
|
|
100
|
+
}): boolean {
|
|
101
|
+
if (input.awaiting !== true) return false;
|
|
102
|
+
if (input.force === true) return true;
|
|
103
|
+
if (
|
|
104
|
+
input.pendingCycleId !== undefined &&
|
|
105
|
+
input.currentCycleId !== undefined &&
|
|
106
|
+
input.pendingCycleId !== input.currentCycleId
|
|
107
|
+
) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return (
|
|
111
|
+
input.pendingPlanHash !== undefined &&
|
|
112
|
+
input.nextPlanHash !== undefined &&
|
|
113
|
+
input.pendingPlanHash !== input.nextPlanHash
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A manual `/review` hands off when the round settled one way or another.
|
|
119
|
+
* A pause is resumed later; an abort is an explicit exit.
|
|
120
|
+
*/
|
|
121
|
+
export function shouldHandoffManualResult(
|
|
122
|
+
result: Pick<ReviewExecutionResult, "outcome">,
|
|
123
|
+
): boolean {
|
|
124
|
+
return result.outcome !== "paused" && result.outcome !== "aborted";
|
|
125
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Review Mode — provider recovery classification.
|
|
3
|
+
*
|
|
4
|
+
* The SDK already owns the retryable-provider-error table
|
|
5
|
+
* (`isRetryableAssistantError`). Reusing it keeps Review Mode aligned with the
|
|
6
|
+
* agent's own retry policy instead of maintaining a second regex that drifts
|
|
7
|
+
* when a gateway adds a new transient-error spelling.
|
|
8
|
+
*
|
|
9
|
+
* A few failures are explicitly not recoverable by switching models:
|
|
10
|
+
* user aborts, missing auth, model-not-found, invalid requests and context
|
|
11
|
+
* overflow (the latter can be a different, larger-context model, but it is not
|
|
12
|
+
* a stream cut and must not loop through the fallback chain).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
isRetryableAssistantError,
|
|
17
|
+
type AssistantMessage,
|
|
18
|
+
} from "@earendil-works/pi-ai";
|
|
19
|
+
|
|
20
|
+
const NON_RECOVERABLE_PATTERNS: readonly RegExp[] = [
|
|
21
|
+
/^request was aborted$/i,
|
|
22
|
+
/^aborted$/i,
|
|
23
|
+
/\bno api key\b/i,
|
|
24
|
+
/\bmodel\b.*\bnot\b.*\bfound\b/i,
|
|
25
|
+
/\binvalid[_\s-]?request\b/i,
|
|
26
|
+
/\bcontext\b.*\b(?:overflow|too long|window exceeded)\b/i,
|
|
27
|
+
/\bprompt\b.*\btoo long\b/i,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function asAssistantError(message: string): AssistantMessage {
|
|
31
|
+
return {
|
|
32
|
+
role: "assistant",
|
|
33
|
+
content: [],
|
|
34
|
+
api: "openai-completions",
|
|
35
|
+
provider: "reviewer",
|
|
36
|
+
model: "reviewer",
|
|
37
|
+
usage: {
|
|
38
|
+
input: 0,
|
|
39
|
+
output: 0,
|
|
40
|
+
cacheRead: 0,
|
|
41
|
+
cacheWrite: 0,
|
|
42
|
+
totalTokens: 0,
|
|
43
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
44
|
+
},
|
|
45
|
+
stopReason: "error",
|
|
46
|
+
errorMessage: message,
|
|
47
|
+
timestamp: Date.now(),
|
|
48
|
+
} as unknown as AssistantMessage;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when the reviewer's failed run is a transient provider/transport failure
|
|
53
|
+
* worth another model. Delegates the transient table to the SDK; only the
|
|
54
|
+
* deterministic non-recoverable shapes above short-circuit it.
|
|
55
|
+
*/
|
|
56
|
+
export function isRecoverableProviderFailure(message: string | undefined): boolean {
|
|
57
|
+
const text = String(message ?? "").trim();
|
|
58
|
+
if (!text) return false;
|
|
59
|
+
if (NON_RECOVERABLE_PATTERNS.some((pattern) => pattern.test(text))) return false;
|
|
60
|
+
try {
|
|
61
|
+
return isRetryableAssistantError(asAssistantError(text));
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|