@plannotator/pi-extension 0.27.6 → 0.27.7
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/generated/ai/providers/child-io.ts +93 -0
- package/generated/ai/providers/codex-app-server.ts +38 -2
- package/generated/ai/providers/pi-sdk-node.ts +46 -4
- package/generated/ai/providers/pi-sdk.ts +54 -6
- package/generated/call-flow-types.ts +18 -5
- package/generated/config.ts +2 -1
- package/generated/jj-core.ts +51 -3
- package/generated/prompts.ts +1 -0
- package/generated/review-core.ts +11 -0
- package/package.json +6 -1
- package/plannotator.html +3 -3
- package/review-editor.html +3 -3
- package/skills/plannotator/SKILL.md +185 -0
- package/skills/plannotator/agents/openai.yaml +9 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @generated — DO NOT EDIT. Source: packages/ai/providers/child-io.ts
|
|
2
|
+
/**
|
|
3
|
+
* Stdio guards for the JSONL/JSON-RPC child processes the AI providers drive.
|
|
4
|
+
*
|
|
5
|
+
* Both `PiProcessNode` (pi-sdk-node.ts) and `CodexAppServerProcess`
|
|
6
|
+
* (codex-app-server.ts) talk to a nested agent over pipes, and a pipe can
|
|
7
|
+
* break at any instant — the child exits, is killed, or closes stdin while a
|
|
8
|
+
* command is in flight. Two Node behaviors turn that ordinary condition into
|
|
9
|
+
* a host-killing crash:
|
|
10
|
+
*
|
|
11
|
+
* 1. `stream.write()` on a broken pipe reports `EPIPE` either synchronously
|
|
12
|
+
* (throw) or asynchronously (an `error` event on the stream), and which
|
|
13
|
+
* one you get is a timing race. A `destroyed` check before the write
|
|
14
|
+
* cannot close that race: the child can close the pipe between the check
|
|
15
|
+
* and the write.
|
|
16
|
+
* 2. An `error` event on a Node stream (or on the `ChildProcess` itself)
|
|
17
|
+
* with NO listener is re-thrown as an `uncaughtException`. Inside an
|
|
18
|
+
* embedded extension that is not "the provider failed" — it terminates
|
|
19
|
+
* the HOST agent process.
|
|
20
|
+
*
|
|
21
|
+
* That is issue #1378: a Plannotator plan review opened from Pi on Windows
|
|
22
|
+
* took the whole Pi host down with `write EPIPE` out of `PiProcessNode.send()`.
|
|
23
|
+
* Windows only made it likelier to land on the async path during teardown;
|
|
24
|
+
* the mechanism is platform-independent.
|
|
25
|
+
*
|
|
26
|
+
* The contract these helpers enforce: a broken pipe is a PROVIDER failure.
|
|
27
|
+
* It never escalates past the provider, it is reported exactly like any other
|
|
28
|
+
* process end (in-flight requests reject, listeners see the process end), and
|
|
29
|
+
* the provider is left dead so the next query re-spawns it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { ChildProcess } from "node:child_process";
|
|
33
|
+
|
|
34
|
+
/** Normalize an unknown thrown/emitted value to an Error. */
|
|
35
|
+
export function toChildError(err: unknown): Error {
|
|
36
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Attach `error` listeners to the child process and every piped stream so a
|
|
41
|
+
* broken pipe can never reach `uncaughtException`.
|
|
42
|
+
*
|
|
43
|
+
* Call this immediately after `spawn()` returns — before awaiting the spawn
|
|
44
|
+
* handshake — so a stream that fails during startup is already covered. The
|
|
45
|
+
* spawn handshake may register its own one-shot `error` listener; Node allows
|
|
46
|
+
* several, and `onFailure` is expected to be idempotent.
|
|
47
|
+
*/
|
|
48
|
+
export function guardChildStreams(
|
|
49
|
+
proc: ChildProcess,
|
|
50
|
+
label: string,
|
|
51
|
+
onFailure: (error: Error) => void,
|
|
52
|
+
): void {
|
|
53
|
+
const report = (stream: string) => (err: unknown) => {
|
|
54
|
+
onFailure(new Error(`${label} ${stream} failed: ${toChildError(err).message}`));
|
|
55
|
+
};
|
|
56
|
+
proc.on("error", report("process"));
|
|
57
|
+
proc.stdin?.on("error", report("stdin"));
|
|
58
|
+
proc.stdout?.on("error", report("stdout"));
|
|
59
|
+
proc.stderr?.on("error", report("stderr"));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Write one already-newline-terminated line to a child's stdin.
|
|
64
|
+
*
|
|
65
|
+
* Returns the Error the write failed with, or `null` when the bytes were
|
|
66
|
+
* accepted by the stream. Failures that only surface later (the write
|
|
67
|
+
* callback, or an `error` event covered by {@link guardChildStreams}) are
|
|
68
|
+
* reported through `onAsyncFailure` instead, so BOTH the sync-throw and the
|
|
69
|
+
* async-event paths end up at the caller's failure handling.
|
|
70
|
+
*/
|
|
71
|
+
export function writeChildLine(
|
|
72
|
+
proc: ChildProcess | null,
|
|
73
|
+
line: string,
|
|
74
|
+
label: string,
|
|
75
|
+
onAsyncFailure: (error: Error) => void,
|
|
76
|
+
): Error | null {
|
|
77
|
+
const stdin = proc?.stdin;
|
|
78
|
+
if (!stdin || stdin.destroyed || stdin.writableEnded) {
|
|
79
|
+
return new Error(`${label} stdin is closed`);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
stdin.write(line, (err) => {
|
|
83
|
+
if (err) {
|
|
84
|
+
onAsyncFailure(
|
|
85
|
+
new Error(`${label} stdin write failed: ${toChildError(err).message}`),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return new Error(`${label} stdin write failed: ${toChildError(err).message}`);
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
@@ -44,8 +44,10 @@ import {
|
|
|
44
44
|
killWindowsProcessTree,
|
|
45
45
|
resolveWindowsCommandShim,
|
|
46
46
|
} from "./command-path.ts";
|
|
47
|
+
import { guardChildStreams, writeChildLine } from "./child-io.ts";
|
|
47
48
|
|
|
48
49
|
const PROVIDER_NAME = "codex-sdk";
|
|
50
|
+
const CODEX_PROCESS_LABEL = "Codex app-server";
|
|
49
51
|
const DEFAULT_MODEL = "gpt-5.6-sol";
|
|
50
52
|
const CLIENT_NAME = "plannotator";
|
|
51
53
|
/** Kill an idle app-server process after this long with no query. */
|
|
@@ -335,6 +337,10 @@ class CodexAppServerProcess {
|
|
|
335
337
|
}
|
|
336
338
|
|
|
337
339
|
this.proc = proc;
|
|
340
|
+
// Cover every pipe BEFORE the spawn handshake: an `error` event on a child
|
|
341
|
+
// stream with no listener becomes an uncaughtException and kills the host
|
|
342
|
+
// process, not just this provider (#1378).
|
|
343
|
+
guardChildStreams(proc, CODEX_PROCESS_LABEL, (error) => this.failProcess(error));
|
|
338
344
|
proc.once("exit", () => {
|
|
339
345
|
this.handleProcessEnd(new Error("Codex app-server exited unexpectedly"));
|
|
340
346
|
});
|
|
@@ -378,6 +384,23 @@ class CodexAppServerProcess {
|
|
|
378
384
|
this.send({ method: "initialized", params: {} });
|
|
379
385
|
}
|
|
380
386
|
|
|
387
|
+
/**
|
|
388
|
+
* A pipe to the child broke: resolve it as a provider failure and reap the
|
|
389
|
+
* child, leaving `alive` false so the next query re-spawns (#1378).
|
|
390
|
+
*/
|
|
391
|
+
private failProcess(error: Error): void {
|
|
392
|
+
const proc = this.proc;
|
|
393
|
+
this.startPromise = null;
|
|
394
|
+
this.handleProcessEnd(error);
|
|
395
|
+
if (proc) {
|
|
396
|
+
try {
|
|
397
|
+
if (!killWindowsProcessTree(proc.pid)) proc.kill();
|
|
398
|
+
} catch {
|
|
399
|
+
// Already gone.
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
381
404
|
private handleProcessEnd(error: Error): void {
|
|
382
405
|
if (!this.proc && this.pendingRequests.size === 0) return;
|
|
383
406
|
this._alive = false;
|
|
@@ -439,9 +462,22 @@ class CodexAppServerProcess {
|
|
|
439
462
|
}
|
|
440
463
|
}
|
|
441
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Send a JSON-RPC message without waiting for a response.
|
|
467
|
+
*
|
|
468
|
+
* A closed or broken stdin is a provider failure, never a throw at the
|
|
469
|
+
* caller and never an unhandled stream error: both the synchronous throw
|
|
470
|
+
* and the asynchronous `error`/write-callback paths land in failProcess,
|
|
471
|
+
* which rejects anything in flight.
|
|
472
|
+
*/
|
|
442
473
|
send(message: RpcMessage): void {
|
|
443
|
-
|
|
444
|
-
|
|
474
|
+
const error = writeChildLine(
|
|
475
|
+
this.proc,
|
|
476
|
+
`${JSON.stringify(message)}\n`,
|
|
477
|
+
CODEX_PROCESS_LABEL,
|
|
478
|
+
(err) => this.failProcess(err),
|
|
479
|
+
);
|
|
480
|
+
if (error) this.failProcess(error);
|
|
445
481
|
}
|
|
446
482
|
|
|
447
483
|
sendAndWait(message: RpcMessage, timeoutMs = RPC_TIMEOUT_MS): Promise<RpcMessage> {
|
|
@@ -25,11 +25,13 @@ import {
|
|
|
25
25
|
killWindowsProcessTree,
|
|
26
26
|
resolveWindowsCommandShim,
|
|
27
27
|
} from "./command-path.ts";
|
|
28
|
+
import { guardChildStreams, writeChildLine } from "./child-io.ts";
|
|
28
29
|
|
|
29
30
|
// Re-export mapPiEvent from shared (runtime-agnostic)
|
|
30
31
|
export { mapPiEvent } from "./pi-events.ts";
|
|
31
32
|
|
|
32
33
|
const PROVIDER_NAME = "pi-sdk";
|
|
34
|
+
const PI_PROCESS_LABEL = "Pi process";
|
|
33
35
|
|
|
34
36
|
// ---------------------------------------------------------------------------
|
|
35
37
|
// JSONL subprocess wrapper (Node.js)
|
|
@@ -37,7 +39,8 @@ const PROVIDER_NAME = "pi-sdk";
|
|
|
37
39
|
|
|
38
40
|
type EventListener = (event: Record<string, unknown>) => void;
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
/** Exported for the stdio-failure regression tests (#1378). */
|
|
43
|
+
export class PiProcessNode {
|
|
41
44
|
private proc: ChildProcess | null = null;
|
|
42
45
|
private listeners: EventListener[] = [];
|
|
43
46
|
private pendingRequests = new Map<
|
|
@@ -62,9 +65,12 @@ class PiProcessNode {
|
|
|
62
65
|
let proc: ChildProcess;
|
|
63
66
|
try {
|
|
64
67
|
const [file, ...args] = command;
|
|
68
|
+
// stderr is "ignore", not "pipe": we never read it, and an
|
|
69
|
+
// un-drained stderr pipe deadlocks the child once its buffer fills
|
|
70
|
+
// (same reasoning as codex-app-server.ts).
|
|
65
71
|
proc = spawn(file, args, {
|
|
66
72
|
cwd,
|
|
67
|
-
stdio: ["pipe", "pipe", "
|
|
73
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
68
74
|
});
|
|
69
75
|
} catch (err) {
|
|
70
76
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -73,6 +79,10 @@ class PiProcessNode {
|
|
|
73
79
|
}
|
|
74
80
|
|
|
75
81
|
this.proc = proc;
|
|
82
|
+
// Cover every pipe BEFORE the spawn handshake: an `error` event on a
|
|
83
|
+
// child stream with no listener becomes an uncaughtException and kills
|
|
84
|
+
// the host agent process, not just this provider (#1378).
|
|
85
|
+
guardChildStreams(proc, PI_PROCESS_LABEL, (error) => this.failProcess(error));
|
|
76
86
|
proc.once("exit", () => {
|
|
77
87
|
this.handleProcessEnd(new Error("Pi process exited unexpectedly"));
|
|
78
88
|
});
|
|
@@ -99,6 +109,24 @@ class PiProcessNode {
|
|
|
99
109
|
});
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
/**
|
|
113
|
+
* A pipe to the child broke. Resolve it as a provider failure: reject
|
|
114
|
+
* everything in flight, tell listeners the process ended, and reap the
|
|
115
|
+
* child so it cannot linger with an unusable RPC channel. `alive` flips
|
|
116
|
+
* false, so the next query re-spawns a fresh process.
|
|
117
|
+
*/
|
|
118
|
+
private failProcess(error: Error): void {
|
|
119
|
+
const proc = this.proc;
|
|
120
|
+
this.handleProcessEnd(error);
|
|
121
|
+
if (proc) {
|
|
122
|
+
try {
|
|
123
|
+
if (!killWindowsProcessTree(proc.pid)) proc.kill();
|
|
124
|
+
} catch {
|
|
125
|
+
// Already gone.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
102
130
|
private handleProcessEnd(error: Error): void {
|
|
103
131
|
if (!this.proc && this.pendingRequests.size === 0) return;
|
|
104
132
|
|
|
@@ -153,9 +181,23 @@ class PiProcessNode {
|
|
|
153
181
|
}
|
|
154
182
|
}
|
|
155
183
|
|
|
184
|
+
/**
|
|
185
|
+
* Send a command without waiting for a response.
|
|
186
|
+
*
|
|
187
|
+
* A closed or broken stdin is a provider failure, never a throw at the
|
|
188
|
+
* caller and never an unhandled stream error: both the synchronous throw
|
|
189
|
+
* and the asynchronous `error`/write-callback paths land in failProcess,
|
|
190
|
+
* which rejects anything in flight (including the `sendAndWait` request
|
|
191
|
+
* this call may be carrying).
|
|
192
|
+
*/
|
|
156
193
|
send(command: Record<string, unknown>): void {
|
|
157
|
-
|
|
158
|
-
|
|
194
|
+
const error = writeChildLine(
|
|
195
|
+
this.proc,
|
|
196
|
+
`${JSON.stringify(command)}\n`,
|
|
197
|
+
PI_PROCESS_LABEL,
|
|
198
|
+
(err) => this.failProcess(err),
|
|
199
|
+
);
|
|
200
|
+
if (error) this.failProcess(error);
|
|
159
201
|
}
|
|
160
202
|
|
|
161
203
|
sendAndWait(
|
|
@@ -58,11 +58,14 @@ class PiProcess {
|
|
|
58
58
|
"rpc",
|
|
59
59
|
];
|
|
60
60
|
try {
|
|
61
|
+
// stderr is "ignore", not "pipe": we never read it, and an
|
|
62
|
+
// un-drained stderr pipe deadlocks the child once its buffer fills
|
|
63
|
+
// (same reasoning as codex-app-server.ts).
|
|
61
64
|
this.proc = Bun.spawn(command, {
|
|
62
65
|
cwd,
|
|
63
66
|
stdin: "pipe",
|
|
64
67
|
stdout: "pipe",
|
|
65
|
-
stderr: "
|
|
68
|
+
stderr: "ignore",
|
|
66
69
|
});
|
|
67
70
|
} catch (err) {
|
|
68
71
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -78,6 +81,22 @@ class PiProcess {
|
|
|
78
81
|
});
|
|
79
82
|
}
|
|
80
83
|
|
|
84
|
+
/**
|
|
85
|
+
* A pipe to the child broke: resolve it as a provider failure and reap the
|
|
86
|
+
* child, leaving `alive` false so the next query re-spawns.
|
|
87
|
+
*/
|
|
88
|
+
private failProcess(error: Error): void {
|
|
89
|
+
const proc = this.proc;
|
|
90
|
+
this.handleProcessEnd(error);
|
|
91
|
+
if (proc) {
|
|
92
|
+
try {
|
|
93
|
+
if (!killWindowsProcessTree(proc.pid)) proc.kill();
|
|
94
|
+
} catch {
|
|
95
|
+
// Already gone.
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
private handleProcessEnd(error: Error): void {
|
|
82
101
|
if (!this.proc && this.pendingRequests.size === 0) return;
|
|
83
102
|
|
|
@@ -144,13 +163,42 @@ class PiProcess {
|
|
|
144
163
|
}
|
|
145
164
|
}
|
|
146
165
|
|
|
147
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* Send a command without waiting for a response.
|
|
168
|
+
*
|
|
169
|
+
* The write is guarded for the same reason the Node variant's is (#1378):
|
|
170
|
+
* the child can close the pipe between the liveness check and the write,
|
|
171
|
+
* and a raw EPIPE escaping here would surface as a synchronous throw at
|
|
172
|
+
* the caller (or an unhandled rejection out of flush()) instead of a
|
|
173
|
+
* provider failure. Failing the process rejects anything in flight.
|
|
174
|
+
*/
|
|
148
175
|
send(command: Record<string, unknown>): void {
|
|
149
|
-
|
|
176
|
+
const stdin = this.proc?.stdin;
|
|
177
|
+
if (!stdin || typeof stdin === "number") {
|
|
178
|
+
this.failProcess(new Error("Pi process stdin is closed"));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
150
181
|
// Bun.spawn stdin is a FileSink with .write(), not a WritableStream
|
|
151
|
-
const sink =
|
|
152
|
-
|
|
153
|
-
|
|
182
|
+
const sink = stdin as { write(data: string): void; flush(): unknown };
|
|
183
|
+
try {
|
|
184
|
+
sink.write(`${JSON.stringify(command)}\n`);
|
|
185
|
+
const flushed = sink.flush();
|
|
186
|
+
if (flushed && typeof (flushed as Promise<number>).then === "function") {
|
|
187
|
+
(flushed as Promise<number>).catch((err: unknown) => {
|
|
188
|
+
this.failProcess(
|
|
189
|
+
new Error(
|
|
190
|
+
`Pi process stdin write failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
} catch (err) {
|
|
196
|
+
this.failProcess(
|
|
197
|
+
new Error(
|
|
198
|
+
`Pi process stdin write failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
}
|
|
154
202
|
}
|
|
155
203
|
|
|
156
204
|
/** Send a command and wait for the correlated response. */
|
|
@@ -161,7 +161,7 @@ export interface ParsedCallDiffWorkerResult {
|
|
|
161
161
|
diagnostics: CallFlowDiagnostic[];
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
const MAX_TREES =
|
|
164
|
+
const MAX_TREES = 2_000;
|
|
165
165
|
const MAX_NODES = 5_000;
|
|
166
166
|
const MAX_TREE_DEPTH = 32;
|
|
167
167
|
const MAX_DIAGNOSTICS = 100;
|
|
@@ -218,9 +218,16 @@ export function parseCallDiffWorkerResult(value: unknown): ParsedCallDiffWorkerR
|
|
|
218
218
|
if (!isRecord(value.result) || !Array.isArray(value.result.trees)) {
|
|
219
219
|
throw new Error("CallDiff worker response is missing its diff trees.");
|
|
220
220
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
// A large-but-valid result (many small entry trees — common in Swift, TS,
|
|
222
|
+
// and other per-function languages) degrades instead of failing: keep the
|
|
223
|
+
// first MAX_TREES trees and say so in diagnostics (#1351). The caps that
|
|
224
|
+
// guard against genuinely unbounded worker output (total nodes, depth, raw
|
|
225
|
+
// length) still throw below.
|
|
226
|
+
const totalTreeCount = value.result.trees.length;
|
|
227
|
+
const truncatedTrees = totalTreeCount > MAX_TREES;
|
|
228
|
+
const boundedTreeCandidates = truncatedTrees
|
|
229
|
+
? value.result.trees.slice(0, MAX_TREES)
|
|
230
|
+
: value.result.trees;
|
|
224
231
|
|
|
225
232
|
let nodeCount = 0;
|
|
226
233
|
const parseNode = (candidate: unknown, depth: number): CallFlowNode => {
|
|
@@ -254,7 +261,7 @@ export function parseCallDiffWorkerResult(value: unknown): ParsedCallDiffWorkerR
|
|
|
254
261
|
};
|
|
255
262
|
|
|
256
263
|
const raw = boundedRaw(value.result.ascii);
|
|
257
|
-
const trees =
|
|
264
|
+
const trees = boundedTreeCandidates.map((candidate): CallFlowTree => {
|
|
258
265
|
if (!isRecord(candidate)) throw new Error("CallDiff worker returned an invalid tree.");
|
|
259
266
|
const entry = boundedString(candidate.entry, "tree entry");
|
|
260
267
|
const tree = parseNode(candidate.tree, 0);
|
|
@@ -287,6 +294,12 @@ export function parseCallDiffWorkerResult(value: unknown): ParsedCallDiffWorkerR
|
|
|
287
294
|
});
|
|
288
295
|
}
|
|
289
296
|
}
|
|
297
|
+
if (truncatedTrees) {
|
|
298
|
+
diagnostics.push({
|
|
299
|
+
level: "warning",
|
|
300
|
+
message: `Showing the first ${MAX_TREES.toLocaleString("en-US")} of ${totalTreeCount.toLocaleString("en-US")} call trees; the rest were truncated to bound the result.`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
290
303
|
|
|
291
304
|
return {
|
|
292
305
|
version: boundedString(value.version, "version", 100),
|
package/generated/config.ts
CHANGED
package/generated/jj-core.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type JjEvoLogEntry,
|
|
10
10
|
JJ_TRUNK_REVSET,
|
|
11
11
|
jjLineBaseRevset,
|
|
12
|
+
parseRemoteBookmark,
|
|
12
13
|
validateFilePath,
|
|
13
14
|
} from "./review-core.ts";
|
|
14
15
|
|
|
@@ -27,6 +28,31 @@ export interface ReviewJjRuntime {
|
|
|
27
28
|
) => Promise<GitCommandResult>;
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// `reachable(@, mutable())` is JJ's definition of the stack being worked on.
|
|
32
|
+
// Its root parents are where that line diverged from immutable history.
|
|
33
|
+
//
|
|
34
|
+
// `latest(..., 1)` is what keeps the query single-record. A criss-cross history
|
|
35
|
+
// can leave several fork points, and the parser below reads one record only, so
|
|
36
|
+
// the tie-break belongs in the revset where it is deliberate and testable
|
|
37
|
+
// rather than in a silent "first row wins" slice. It also matters for
|
|
38
|
+
// correctness: bookmark preference (remote before local) is only meaningful
|
|
39
|
+
// within one commit, so a multi-row answer could otherwise pick a remote
|
|
40
|
+
// bookmark from one commit over a local bookmark on a nearer one.
|
|
41
|
+
const JJ_LINE_BASE_REVSET = "latest(fork_point(roots(reachable(@, mutable()))-), 1)";
|
|
42
|
+
|
|
43
|
+
// `jj git push --change` mints bookmarks under `git.push-bookmark-prefix`
|
|
44
|
+
// (default `push-`). They name one change, not a line of work, so they are
|
|
45
|
+
// never a useful review base. They reach the fork point in practice: a
|
|
46
|
+
// colleague's pushed change bookmark arrives as an untracked remote bookmark,
|
|
47
|
+
// which makes its commit immutable and therefore a candidate base, and the
|
|
48
|
+
// reviewer would be told they are comparing against `push-vmopwunwxopv@origin`.
|
|
49
|
+
const JJ_GENERATED_PUSH_BOOKMARK_PREFIX = "push-";
|
|
50
|
+
|
|
51
|
+
// `commit_id` renders in full, so an all-zero id is the virtual root commit.
|
|
52
|
+
// A repo with no immutable history forks there, and a 40-zero hash means
|
|
53
|
+
// nothing to a reviewer, so keep the `trunk()` sentinel for that case.
|
|
54
|
+
const JJ_ROOT_COMMIT_ID = /^0+$/;
|
|
55
|
+
|
|
30
56
|
export async function detectJjWorkspace(
|
|
31
57
|
runtime: ReviewJjRuntime,
|
|
32
58
|
cwd?: string,
|
|
@@ -369,13 +395,35 @@ export async function selectDefaultJjCompareTarget(
|
|
|
369
395
|
"log",
|
|
370
396
|
"--no-graph",
|
|
371
397
|
"-r",
|
|
372
|
-
|
|
398
|
+
JJ_LINE_BASE_REVSET,
|
|
373
399
|
"-T",
|
|
374
|
-
|
|
400
|
+
'json(bookmarks) ++ "\\t" ++ commit_id ++ "\\n"',
|
|
375
401
|
], { cwd });
|
|
402
|
+
// Every unresolvable case falls back to `trunk()`, which is what this
|
|
403
|
+
// returned before the line-of-work base was inferred at all. The only live
|
|
404
|
+
// caller is `getJjContext`, which runs on the review startup path with no
|
|
405
|
+
// handler above it, so throwing here does not report a problem: it aborts
|
|
406
|
+
// `plannotator review` with a stack trace before the server is built. That
|
|
407
|
+
// also covers a `jj` too old for `fork_point`/`reachable`, where the revset
|
|
408
|
+
// itself fails and the previous default is still perfectly serviceable.
|
|
376
409
|
if (result.exitCode !== 0) return JJ_TRUNK_REVSET;
|
|
377
410
|
|
|
378
|
-
|
|
411
|
+
const [record] = splitJjTemplateRecords(result.stdout);
|
|
412
|
+
if (!record) return JJ_TRUNK_REVSET;
|
|
413
|
+
|
|
414
|
+
const fields = splitJjTemplateFields(record);
|
|
415
|
+
const bookmark = parseJjResolvedBookmarks(fields?.[0] ?? record)
|
|
416
|
+
.find((name) => !isGeneratedPushBookmark(name));
|
|
417
|
+
if (bookmark) return bookmark;
|
|
418
|
+
|
|
419
|
+
const commitId = fields?.[1]?.trim();
|
|
420
|
+
if (commitId && !JJ_ROOT_COMMIT_ID.test(commitId)) return commitId;
|
|
421
|
+
return JJ_TRUNK_REVSET;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function isGeneratedPushBookmark(target: string): boolean {
|
|
425
|
+
const name = parseRemoteBookmark(target)?.name ?? target;
|
|
426
|
+
return name.startsWith(JJ_GENERATED_PUSH_BOOKMARK_PREFIX);
|
|
379
427
|
}
|
|
380
428
|
|
|
381
429
|
function parseJjResolvedBookmarks(value: string): string[] {
|
package/generated/prompts.ts
CHANGED
|
@@ -25,6 +25,7 @@ export const PLAN_TOOL_NAMES: Record<PromptRuntime, string> = {
|
|
|
25
25
|
pi: "plannotator_submit_plan",
|
|
26
26
|
codex: "ExitPlanMode",
|
|
27
27
|
"gemini-cli": "exit_plan_mode",
|
|
28
|
+
"oh-my-pi": "ExitPlanMode",
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
export function getPlanToolName(runtime?: PromptRuntime | null): string {
|
package/generated/review-core.ts
CHANGED
|
@@ -315,12 +315,23 @@ export function parseRemoteBookmark(target: string): { name: string; remote: str
|
|
|
315
315
|
return { name: target.slice(0, at), remote: target.slice(at + 1) };
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
// A full `commit_id`: 40 hex digits for a SHA-1 repo, 64 for SHA-256. Matching
|
|
319
|
+
// the full length only is deliberate, so an ordinary bookmark whose name
|
|
320
|
+
// happens to be hex (`cafebabe`) is still treated as a bookmark.
|
|
321
|
+
const JJ_FULL_COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
322
|
+
|
|
318
323
|
export function jjCompareTargetRevset(target: string): string {
|
|
319
324
|
const remoteBookmark = parseRemoteBookmark(target);
|
|
320
325
|
if (remoteBookmark) {
|
|
321
326
|
return `remote_bookmarks(exact:${quoteJjString(remoteBookmark.name)}, exact:${quoteJjString(remoteBookmark.remote)})`;
|
|
322
327
|
}
|
|
323
328
|
|
|
329
|
+
// The resolved line base is a bare commit id whenever its fork point carries
|
|
330
|
+
// no usable bookmark. It has no separators, so it would otherwise read as a
|
|
331
|
+
// local bookmark name and build `bookmarks(exact:"<sha>")`, which resolves to
|
|
332
|
+
// no revisions at all and makes the whole Line of work diff fail.
|
|
333
|
+
if (JJ_FULL_COMMIT_ID.test(target)) return target;
|
|
334
|
+
|
|
324
335
|
const localBookmark = parseJjBookmarkName(target);
|
|
325
336
|
return localBookmark ? `bookmarks(exact:${quoteJjString(localBookmark)})` : target;
|
|
326
337
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plannotator/pi-extension",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
|
|
6
6
|
"author": "backnotprop",
|
|
@@ -21,9 +21,13 @@
|
|
|
21
21
|
"ai-agent",
|
|
22
22
|
"coding-agent"
|
|
23
23
|
],
|
|
24
|
+
"//pi": "skills/ is vendored by vendor.sh from apps/skills/core/plannotator and is gitignored, so the shipped copy cannot drift from the single source.",
|
|
24
25
|
"pi": {
|
|
25
26
|
"extensions": [
|
|
26
27
|
"./"
|
|
28
|
+
],
|
|
29
|
+
"skills": [
|
|
30
|
+
"skills/plannotator/SKILL.md"
|
|
27
31
|
]
|
|
28
32
|
},
|
|
29
33
|
"files": [
|
|
@@ -42,6 +46,7 @@
|
|
|
42
46
|
"server/",
|
|
43
47
|
"todo-providers/",
|
|
44
48
|
"generated/",
|
|
49
|
+
"skills/",
|
|
45
50
|
"README.md",
|
|
46
51
|
"plannotator.html",
|
|
47
52
|
"review-editor.html"
|