@dieulc/workflow 0.9.1 → 0.9.2
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 +14 -9
- package/edit-policy.ts +11 -64
- package/index.ts +309 -45
- package/package.json +3 -2
- package/permissions.ts +393 -93
- package/review.ts +80 -3
- package/shell-text.ts +542 -0
- package/utils.ts +42 -0
package/review.ts
CHANGED
|
@@ -1460,14 +1460,91 @@ export function reviewStatusVisible(input: {
|
|
|
1460
1460
|
roundInFlight?: boolean;
|
|
1461
1461
|
roundActive?: boolean;
|
|
1462
1462
|
paused?: boolean;
|
|
1463
|
+
/**
|
|
1464
|
+
* The retained round state's phase, when one exists. A retained *terminal*
|
|
1465
|
+
* phase must not make the line visible during a new round's cold boot
|
|
1466
|
+
* (`roundActive` flips before the first live phase event).
|
|
1467
|
+
*/
|
|
1468
|
+
phase?: ReviewPhase;
|
|
1469
|
+
}): boolean {
|
|
1470
|
+
if (input.paused === true) return true;
|
|
1471
|
+
if (input.roundInFlight !== true && input.roundActive !== true) return false;
|
|
1472
|
+
// `undefined` means the round has not emitted a state yet; there is nothing
|
|
1473
|
+
// stale to paint and the lock/working message still describe the round.
|
|
1474
|
+
return input.phase === undefined || reviewIsActive(input.phase);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/**
|
|
1478
|
+
* The text for the live review status surface, or `""` when nothing is live.
|
|
1479
|
+
*
|
|
1480
|
+
* Single source for the footer segment and the review widget: a retained
|
|
1481
|
+
* `reviewState` is used for `/review-status` and the summary band, but it is
|
|
1482
|
+
* never painted as a live line once its round is over. While paused it shows
|
|
1483
|
+
* the paused form (never the retained terminal summary).
|
|
1484
|
+
*/
|
|
1485
|
+
export function reviewStatusText(input: {
|
|
1486
|
+
state: ReviewState | undefined;
|
|
1487
|
+
roundInFlight?: boolean;
|
|
1488
|
+
roundActive?: boolean;
|
|
1489
|
+
paused?: boolean;
|
|
1490
|
+
}): string {
|
|
1491
|
+
const visible = reviewStatusVisible({
|
|
1492
|
+
roundInFlight: input.roundInFlight,
|
|
1493
|
+
roundActive: input.roundActive,
|
|
1494
|
+
paused: input.paused,
|
|
1495
|
+
phase: input.state?.phase,
|
|
1496
|
+
});
|
|
1497
|
+
return visible ? reviewFooterSegment(input.state, input.paused === true) : "";
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Whether a pause snapshot belongs to the current review cycle.
|
|
1502
|
+
*
|
|
1503
|
+
* Pauses are in-memory and cycle-tagged. A snapshot from an older cycle must
|
|
1504
|
+
* never keep the review line/lock alive or be resumed after new work started.
|
|
1505
|
+
*/
|
|
1506
|
+
export function reviewPauseIsCurrent(input: {
|
|
1507
|
+
pauseCycle: number | undefined;
|
|
1508
|
+
currentCycle: number;
|
|
1463
1509
|
}): boolean {
|
|
1464
1510
|
return (
|
|
1465
|
-
input.
|
|
1466
|
-
input.
|
|
1467
|
-
input.paused === true
|
|
1511
|
+
typeof input.pauseCycle === "number" &&
|
|
1512
|
+
input.pauseCycle === input.currentCycle
|
|
1468
1513
|
);
|
|
1469
1514
|
}
|
|
1470
1515
|
|
|
1516
|
+
/** Result of consuming one review round from the cycle budget. */
|
|
1517
|
+
export interface ReviewRoundBudget {
|
|
1518
|
+
/** The round number this attempt would use (1-based). */
|
|
1519
|
+
round: number;
|
|
1520
|
+
/** False when the cycle's `rounds` cap has already been consumed. */
|
|
1521
|
+
allowed: boolean;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
/**
|
|
1525
|
+
* Consume one round from a cycle's budget.
|
|
1526
|
+
*
|
|
1527
|
+
* A new cycle starts at round 1 regardless of the previous cycle's count; this
|
|
1528
|
+
* is the fix for the session-wide counter that made a second plan skip review
|
|
1529
|
+
* with `round limit (1) reached`. Within a cycle the existing cap applies.
|
|
1530
|
+
*/
|
|
1531
|
+
export function nextReviewRound(input: {
|
|
1532
|
+
round: number;
|
|
1533
|
+
maxRounds: number;
|
|
1534
|
+
newCycle?: boolean;
|
|
1535
|
+
}): ReviewRoundBudget {
|
|
1536
|
+
const maxRaw = Number.isFinite(input.maxRounds)
|
|
1537
|
+
? Math.trunc(input.maxRounds)
|
|
1538
|
+
: 1;
|
|
1539
|
+
const max = Math.max(1, maxRaw);
|
|
1540
|
+
const roundRaw = Number.isFinite(input.round)
|
|
1541
|
+
? Math.trunc(input.round)
|
|
1542
|
+
: 0;
|
|
1543
|
+
const current = Math.max(0, roundRaw);
|
|
1544
|
+
const round = (input.newCycle === true ? 0 : current) + 1;
|
|
1545
|
+
return { round, allowed: round <= max };
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1471
1548
|
// ── Mode-switch lock (plan 2026-10-25) ───────────────────────────────
|
|
1472
1549
|
//
|
|
1473
1550
|
// While a review owns the turn, mode transitions are refused: switching to
|
package/shell-text.ts
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Extension — shell text analysis
|
|
3
|
+
*
|
|
4
|
+
* One quote-aware view of a shell command, shared by every policy check.
|
|
5
|
+
*
|
|
6
|
+
* WHY THIS EXISTS: the command policy used to answer its questions with four
|
|
7
|
+
* different scanners that disagreed about quotes. `splitCommandSegments` was
|
|
8
|
+
* quote-aware, `wordsOf` was quote-aware but not escape-aware, and both
|
|
9
|
+
* `forbiddenSyntaxReason` and the destructive-keyword denylist scanned the
|
|
10
|
+
* RAW text. The result was the wrong answer in both directions: `rg -n
|
|
11
|
+
* "rm -rf" src` was refused in Plan mode although it only searches for a
|
|
12
|
+
* string, and `rg -n 'a > b' docs` was classified as a *file write* although
|
|
13
|
+
* the `>` is inside single quotes.
|
|
14
|
+
*
|
|
15
|
+
* The fix is to ask each question against the view that can answer it. A
|
|
16
|
+
* shell command is three things at once:
|
|
17
|
+
*
|
|
18
|
+
* structural — what the shell will treat as an OPERATOR (`| & ; < > ( )`
|
|
19
|
+
* and a bare `&`). Quoted and backslash-escaped characters
|
|
20
|
+
* are masked, because they are literal text: `rg 'a > b'`
|
|
21
|
+
* writes nothing, `rg 'a' > out` does.
|
|
22
|
+
* expandable — what the shell will EXPAND. Single quotes and escapes are
|
|
23
|
+
* masked; double quotes are kept, because `$(…)`, `${…}` and
|
|
24
|
+
* backticks DO expand inside double quotes.
|
|
25
|
+
* words — what argv the process will receive, after quote removal.
|
|
26
|
+
* Flags must be read from here: `find . "-delete"` really
|
|
27
|
+
* deletes, because quote removal makes it a flag.
|
|
28
|
+
*
|
|
29
|
+
* Both masked views are the same length as the input and use a sentinel for
|
|
30
|
+
* masked characters, so an offset maps back to the original text. That is
|
|
31
|
+
* what lets a refusal name the offending construct without re-scanning.
|
|
32
|
+
*
|
|
33
|
+
* CONTRACT — `ShellSegment.words` is ARGV ONLY. Shell operators (`|`, `;`,
|
|
34
|
+
* `&&`, `||`, a newline, and redirects such as `>`, `>>`, `<`, `<<`, `2>&1`,
|
|
35
|
+
* `&>/dev/null`) are NOT words; redirects are reported separately in
|
|
36
|
+
* `redirects`. So `words[0]` is the command name and `words[1]` is its first
|
|
37
|
+
* argument, exactly what `firstWord` / `wordAt(s, 1)` meant before — and a
|
|
38
|
+
* leading `2>/dev/null` no longer hides the command.
|
|
39
|
+
*
|
|
40
|
+
* SEGMENTATION IS DELIBERATELY *NOT* POSIX. `splitSegments` reproduces the
|
|
41
|
+
* extension's original splitter bit-for-bit, because `gitCommitPolicyHit` and
|
|
42
|
+
* the write gate consume its exact output: a backslash consumes the next
|
|
43
|
+
* character even inside single quotes, segments are trimmed, empty segments
|
|
44
|
+
* are dropped, and a lone `&` does not split while `&&` does. `tokenizeWords`
|
|
45
|
+
* follows POSIX quoting (`\` inside double quotes escapes only `$`, `` ` ``,
|
|
46
|
+
* `"`, `\` and newline) — which is why `"C:\Program Files\Git\cmd\git.exe"`
|
|
47
|
+
* keeps its backslashes. The two passes coexist on purpose.
|
|
48
|
+
*
|
|
49
|
+
* Dependency-free (no Pi / pi-tui imports), like `utils.ts`, so it stays
|
|
50
|
+
* loadable in plain Node tests.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sentinel that replaces a masked character. `\u0001` cannot appear in a
|
|
55
|
+
* shell command a terminal or a model produces, and unlike a space it never
|
|
56
|
+
* matches `\s`, so masking never invents a token boundary.
|
|
57
|
+
*/
|
|
58
|
+
const SENTINEL = "\u0001";
|
|
59
|
+
|
|
60
|
+
/** How a word was quoted, for diagnostics and for policy decisions. */
|
|
61
|
+
export type QuoteKind = "none" | "single" | "double" | "mixed";
|
|
62
|
+
|
|
63
|
+
/** One argv word after quote removal. */
|
|
64
|
+
export interface ShellWord {
|
|
65
|
+
/** Exact source slice, quotes and escapes included. */
|
|
66
|
+
raw: string;
|
|
67
|
+
/** The value the process receives (quotes removed, POSIX unescaping). */
|
|
68
|
+
value: string;
|
|
69
|
+
/** Offset of `raw` within `ShellSegment.raw`. */
|
|
70
|
+
start: number;
|
|
71
|
+
/** Offset just past `raw` within `ShellSegment.raw`. */
|
|
72
|
+
end: number;
|
|
73
|
+
quoting: QuoteKind;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A redirection, which is not an argv word. */
|
|
77
|
+
export interface ShellRedirect {
|
|
78
|
+
/** Operator as written: `>`, `>>`, `<`, `<<`, `2>`, `&>`, `2>&`, `&>>`, … */
|
|
79
|
+
op: string;
|
|
80
|
+
/** Quote-removed target (`/dev/null`, `out.txt`, `1`), when there is one. */
|
|
81
|
+
target?: string;
|
|
82
|
+
/** Offsets within `ShellSegment.raw`. */
|
|
83
|
+
start: number;
|
|
84
|
+
end: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** One top-level command of a compound command. */
|
|
88
|
+
export interface ShellSegment {
|
|
89
|
+
/** Trimmed source slice, exactly what `splitSegments` returns. */
|
|
90
|
+
raw: string;
|
|
91
|
+
/** Offset of `raw` within `ShellScan.raw` (absolute). */
|
|
92
|
+
start: number;
|
|
93
|
+
/** Offset just past `raw` within `ShellScan.raw` (absolute). */
|
|
94
|
+
end: number;
|
|
95
|
+
/** ARGV only: operators and redirects are absent (see the module doc). */
|
|
96
|
+
words: ShellWord[];
|
|
97
|
+
/** Redirections, in source order. */
|
|
98
|
+
redirects: ShellRedirect[];
|
|
99
|
+
/** The operator that ended this segment, if any. */
|
|
100
|
+
next?: "|" | ";" | "&&" | "||" | "\n";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The full analysis of one command string. */
|
|
104
|
+
export interface ShellScan {
|
|
105
|
+
raw: string;
|
|
106
|
+
segments: ShellSegment[];
|
|
107
|
+
/** Same length as `raw`: operator characters that are literal are masked. */
|
|
108
|
+
structural: string;
|
|
109
|
+
/** Same length as `raw`: text the shell will expand (`$(…)`, backticks). */
|
|
110
|
+
expandable: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** One segment range, before tokenisation. */
|
|
114
|
+
interface SegmentRange {
|
|
115
|
+
raw: string;
|
|
116
|
+
start: number;
|
|
117
|
+
end: number;
|
|
118
|
+
next?: "|" | ";" | "&&" | "||" | "\n";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Split a compound command on top-level `|`, `;`, `&&`, `||` and newlines,
|
|
123
|
+
* returning absolute ranges.
|
|
124
|
+
*
|
|
125
|
+
* This is a faithful copy of the extension's original splitter (including its
|
|
126
|
+
* escape-before-quote ordering, which makes a backslash consume the next
|
|
127
|
+
* character even inside single quotes). Do not "fix" it to POSIX here: the
|
|
128
|
+
* git commit classifier and the write gate depend on the exact boundaries.
|
|
129
|
+
*/
|
|
130
|
+
function splitSegmentRanges(command: string): SegmentRange[] {
|
|
131
|
+
const out: SegmentRange[] = [];
|
|
132
|
+
let current = "";
|
|
133
|
+
let currentStart = 0;
|
|
134
|
+
let quote: '"' | "'" | undefined;
|
|
135
|
+
let escaped = false;
|
|
136
|
+
const push = (next?: SegmentRange["next"]): void => {
|
|
137
|
+
const trimmed = current.trim();
|
|
138
|
+
if (trimmed) {
|
|
139
|
+
const lead = current.length - current.trimStart().length;
|
|
140
|
+
const start = currentStart + lead;
|
|
141
|
+
out.push({ raw: trimmed, start, end: start + trimmed.length, next });
|
|
142
|
+
}
|
|
143
|
+
current = "";
|
|
144
|
+
currentStart = 0;
|
|
145
|
+
};
|
|
146
|
+
for (let i = 0; i < command.length; i++) {
|
|
147
|
+
const ch = command[i];
|
|
148
|
+
if (!current && !escaped) currentStart = i;
|
|
149
|
+
if (escaped) {
|
|
150
|
+
current += ch;
|
|
151
|
+
escaped = false;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === "\\") {
|
|
155
|
+
current += ch;
|
|
156
|
+
escaped = true;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (quote) {
|
|
160
|
+
current += ch;
|
|
161
|
+
if (ch === quote) quote = undefined;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (ch === '"' || ch === "'") {
|
|
165
|
+
quote = ch;
|
|
166
|
+
current += ch;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (ch === "\n" || ch === ";") {
|
|
170
|
+
push(ch);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (ch === "|") {
|
|
174
|
+
const next = command[i + 1] === "|" ? "||" : "|";
|
|
175
|
+
push(next);
|
|
176
|
+
if (command[i + 1] === "|") i++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ch === "&" && command[i + 1] === "&") {
|
|
180
|
+
push("&&");
|
|
181
|
+
i++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
current += ch;
|
|
185
|
+
}
|
|
186
|
+
push(undefined);
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Split a compound command into trimmed segments.
|
|
192
|
+
*
|
|
193
|
+
* Quote-aware: `grep -E 'a|b' f` stays one segment. See
|
|
194
|
+
* `splitSegmentRanges` for the deliberate legacy quirks.
|
|
195
|
+
*/
|
|
196
|
+
export function splitSegments(command: unknown): string[] {
|
|
197
|
+
const raw = typeof command === "string" ? command : "";
|
|
198
|
+
if (!raw) return [];
|
|
199
|
+
return splitSegmentRanges(raw).map((r) => r.raw);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Build the two masked views of a command (both same length as `raw`). */
|
|
203
|
+
function maskViews(command: string): {
|
|
204
|
+
structural: string;
|
|
205
|
+
expandable: string;
|
|
206
|
+
} {
|
|
207
|
+
const structural: string[] = [];
|
|
208
|
+
const expandable: string[] = [];
|
|
209
|
+
let quote: '"' | "'" | undefined;
|
|
210
|
+
let i = 0;
|
|
211
|
+
while (i < command.length) {
|
|
212
|
+
const ch = command[i];
|
|
213
|
+
// Single quotes: nothing is an operator and nothing expands.
|
|
214
|
+
if (quote === "'") {
|
|
215
|
+
structural.push(SENTINEL);
|
|
216
|
+
expandable.push(SENTINEL);
|
|
217
|
+
if (ch === "'") quote = undefined;
|
|
218
|
+
i++;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// Double quotes: operators are literal, but expansion still happens.
|
|
222
|
+
if (quote === '"') {
|
|
223
|
+
if (ch === "\\") {
|
|
224
|
+
const nx = command[i + 1];
|
|
225
|
+
if (nx !== undefined && '$`"\\\n'.includes(nx)) {
|
|
226
|
+
structural.push(SENTINEL);
|
|
227
|
+
expandable.push(SENTINEL);
|
|
228
|
+
structural.push(SENTINEL);
|
|
229
|
+
expandable.push(SENTINEL);
|
|
230
|
+
i += 2;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const isOperator = "|&;<>()\n\r".includes(ch);
|
|
235
|
+
structural.push(isOperator || ch === '"' ? SENTINEL : ch);
|
|
236
|
+
expandable.push(ch === '"' ? SENTINEL : ch);
|
|
237
|
+
if (ch === '"') quote = undefined;
|
|
238
|
+
i++;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
// Unquoted.
|
|
242
|
+
if (ch === "'" || ch === '"') {
|
|
243
|
+
quote = ch;
|
|
244
|
+
structural.push(SENTINEL);
|
|
245
|
+
expandable.push(SENTINEL);
|
|
246
|
+
i++;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (ch === "\\") {
|
|
250
|
+
structural.push(SENTINEL);
|
|
251
|
+
expandable.push(SENTINEL);
|
|
252
|
+
if (i + 1 < command.length) {
|
|
253
|
+
structural.push(SENTINEL);
|
|
254
|
+
expandable.push(SENTINEL);
|
|
255
|
+
i += 2;
|
|
256
|
+
} else {
|
|
257
|
+
i++;
|
|
258
|
+
}
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
structural.push(ch);
|
|
262
|
+
expandable.push(ch);
|
|
263
|
+
i++;
|
|
264
|
+
}
|
|
265
|
+
return { structural: structural.join(""), expandable: expandable.join("") };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Read a redirect target starting at `k`, honouring simple quoting. */
|
|
269
|
+
function readRedirectTarget(
|
|
270
|
+
segment: string,
|
|
271
|
+
k: number,
|
|
272
|
+
): { value: string; end: number } | undefined {
|
|
273
|
+
let i = k;
|
|
274
|
+
while (i < segment.length && (segment[i] === " " || segment[i] === "\t")) {
|
|
275
|
+
i++;
|
|
276
|
+
}
|
|
277
|
+
if (i >= segment.length) return undefined;
|
|
278
|
+
const ch = segment[i];
|
|
279
|
+
if (ch === "'" || ch === '"') {
|
|
280
|
+
let j = i + 1;
|
|
281
|
+
let value = "";
|
|
282
|
+
while (j < segment.length && segment[j] !== ch) {
|
|
283
|
+
const c = segment[j];
|
|
284
|
+
if (ch === '"' && c === "\\") {
|
|
285
|
+
const nx = segment[j + 1];
|
|
286
|
+
if (nx !== undefined && '$`"\\\n'.includes(nx)) {
|
|
287
|
+
value += nx;
|
|
288
|
+
j += 2;
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
value += c;
|
|
293
|
+
j++;
|
|
294
|
+
}
|
|
295
|
+
return { value, end: j < segment.length ? j + 1 : j };
|
|
296
|
+
}
|
|
297
|
+
if (/[\s|&;<>()]/.test(ch)) return undefined;
|
|
298
|
+
let j = i;
|
|
299
|
+
let value = "";
|
|
300
|
+
while (j < segment.length && !/[\s|&;<>()]/.test(segment[j])) {
|
|
301
|
+
value += segment[j];
|
|
302
|
+
j++;
|
|
303
|
+
}
|
|
304
|
+
return { value, end: j };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The mutable word being assembled by `tokenizeWords`. */
|
|
308
|
+
interface WordBuilder {
|
|
309
|
+
start: number;
|
|
310
|
+
raw: string;
|
|
311
|
+
value: string;
|
|
312
|
+
sawNone: boolean;
|
|
313
|
+
sawSingle: boolean;
|
|
314
|
+
sawDouble: boolean;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function quoteKindOf(b: WordBuilder): QuoteKind {
|
|
318
|
+
const kinds = [b.sawNone, b.sawSingle, b.sawDouble].filter(Boolean).length;
|
|
319
|
+
if (kinds > 1) return "mixed";
|
|
320
|
+
if (b.sawSingle) return "single";
|
|
321
|
+
if (b.sawDouble) return "double";
|
|
322
|
+
return "none";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Tokenise one segment into argv words plus redirects.
|
|
327
|
+
*
|
|
328
|
+
* POSIX quoting: `\` outside quotes escapes the next character; inside single
|
|
329
|
+
* quotes it is literal; inside double quotes it escapes only `$`, `` ` ``,
|
|
330
|
+
* `"`, `\` and newline (so a Windows path keeps its backslashes). Operators
|
|
331
|
+
* and redirects never appear in `words` — that is what makes `words[0]` the
|
|
332
|
+
* command name even when the segment starts with `2>/dev/null`.
|
|
333
|
+
*/
|
|
334
|
+
export function tokenizeWords(segment: unknown): {
|
|
335
|
+
words: ShellWord[];
|
|
336
|
+
redirects: ShellRedirect[];
|
|
337
|
+
} {
|
|
338
|
+
const s = typeof segment === "string" ? segment : "";
|
|
339
|
+
const words: ShellWord[] = [];
|
|
340
|
+
const redirects: ShellRedirect[] = [];
|
|
341
|
+
let word: WordBuilder | null = null;
|
|
342
|
+
const flush = (): void => {
|
|
343
|
+
if (!word) return;
|
|
344
|
+
const b = word;
|
|
345
|
+
words.push({
|
|
346
|
+
raw: b.raw,
|
|
347
|
+
value: b.value,
|
|
348
|
+
start: b.start,
|
|
349
|
+
end: b.start + b.raw.length,
|
|
350
|
+
quoting: quoteKindOf(b),
|
|
351
|
+
});
|
|
352
|
+
word = null;
|
|
353
|
+
};
|
|
354
|
+
const begin = (i: number): WordBuilder => ({
|
|
355
|
+
start: i,
|
|
356
|
+
raw: "",
|
|
357
|
+
value: "",
|
|
358
|
+
sawNone: false,
|
|
359
|
+
sawSingle: false,
|
|
360
|
+
sawDouble: false,
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
let i = 0;
|
|
364
|
+
while (i < s.length) {
|
|
365
|
+
const ch = s[i];
|
|
366
|
+
|
|
367
|
+
if (!word && /\s/.test(ch)) {
|
|
368
|
+
i++;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Unquoted backslash escape.
|
|
373
|
+
if (ch === "\\") {
|
|
374
|
+
if (!word) word = begin(i);
|
|
375
|
+
const nx = s[i + 1];
|
|
376
|
+
word.raw += ch;
|
|
377
|
+
if (nx !== undefined) {
|
|
378
|
+
word.raw += nx;
|
|
379
|
+
word.value += nx;
|
|
380
|
+
i += 2;
|
|
381
|
+
} else {
|
|
382
|
+
word.value += ch;
|
|
383
|
+
i += 1;
|
|
384
|
+
}
|
|
385
|
+
word.sawNone = true;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Single-quoted span: fully literal.
|
|
390
|
+
if (ch === "'") {
|
|
391
|
+
if (!word) word = begin(i);
|
|
392
|
+
word.sawSingle = true;
|
|
393
|
+
word.raw += ch;
|
|
394
|
+
i++;
|
|
395
|
+
while (i < s.length && s[i] !== "'") {
|
|
396
|
+
word.raw += s[i];
|
|
397
|
+
word.value += s[i];
|
|
398
|
+
i++;
|
|
399
|
+
}
|
|
400
|
+
if (i < s.length) {
|
|
401
|
+
word.raw += "'";
|
|
402
|
+
i++;
|
|
403
|
+
}
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Double-quoted span: expansion happens, backslash is narrow (POSIX).
|
|
408
|
+
if (ch === '"') {
|
|
409
|
+
if (!word) word = begin(i);
|
|
410
|
+
word.sawDouble = true;
|
|
411
|
+
word.raw += ch;
|
|
412
|
+
i++;
|
|
413
|
+
while (i < s.length && s[i] !== '"') {
|
|
414
|
+
const c = s[i];
|
|
415
|
+
if (c === "\\") {
|
|
416
|
+
const nx = s[i + 1];
|
|
417
|
+
if (nx !== undefined && '$`"\\\n'.includes(nx)) {
|
|
418
|
+
word.raw += c + nx;
|
|
419
|
+
word.value += nx;
|
|
420
|
+
i += 2;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
word.raw += c;
|
|
424
|
+
word.value += c;
|
|
425
|
+
i++;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
word.raw += c;
|
|
429
|
+
word.value += c;
|
|
430
|
+
i++;
|
|
431
|
+
}
|
|
432
|
+
if (i < s.length) {
|
|
433
|
+
word.raw += '"';
|
|
434
|
+
i++;
|
|
435
|
+
}
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// `&>` / `&>>` — a redirect written with the ampersand first.
|
|
440
|
+
if (ch === "&" && s[i + 1] === ">") {
|
|
441
|
+
flush();
|
|
442
|
+
let op = "&>";
|
|
443
|
+
let j = i + 2;
|
|
444
|
+
if (s[j] === ">") {
|
|
445
|
+
op = "&>>";
|
|
446
|
+
j++;
|
|
447
|
+
}
|
|
448
|
+
const target = readRedirectTarget(s, j);
|
|
449
|
+
redirects.push({ op, target: target?.value, start: i, end: target?.end ?? j });
|
|
450
|
+
i = target?.end ?? j;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// `>` / `>>` / `<` / `<<`, with an optional fd prefix and `>&`/`<&`.
|
|
455
|
+
if (ch === ">" || ch === "<") {
|
|
456
|
+
let fdPrefix = "";
|
|
457
|
+
if (word && word.sawNone && /^\d+$/.test(word.value)) {
|
|
458
|
+
fdPrefix = word.value;
|
|
459
|
+
word = null;
|
|
460
|
+
} else {
|
|
461
|
+
flush();
|
|
462
|
+
}
|
|
463
|
+
let op = ch;
|
|
464
|
+
let j = i + 1;
|
|
465
|
+
if (s[j] === ch) {
|
|
466
|
+
op += ch;
|
|
467
|
+
j++;
|
|
468
|
+
}
|
|
469
|
+
if (s[j] === "&") {
|
|
470
|
+
op += "&";
|
|
471
|
+
j++;
|
|
472
|
+
let target = "";
|
|
473
|
+
while (j < s.length && /[0-9-]/.test(s[j])) {
|
|
474
|
+
target += s[j];
|
|
475
|
+
j++;
|
|
476
|
+
}
|
|
477
|
+
redirects.push({ op: fdPrefix + op, target, start: i, end: j });
|
|
478
|
+
i = j;
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const target = readRedirectTarget(s, j);
|
|
482
|
+
redirects.push({
|
|
483
|
+
op: fdPrefix + op,
|
|
484
|
+
target: target?.value,
|
|
485
|
+
start: i,
|
|
486
|
+
end: target?.end ?? j,
|
|
487
|
+
});
|
|
488
|
+
i = target?.end ?? j;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Whitespace ends a word. A lone `&` is a control operator, not a word.
|
|
493
|
+
if (/\s/.test(ch)) {
|
|
494
|
+
flush();
|
|
495
|
+
i++;
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (!word) word = begin(i);
|
|
500
|
+
word.raw += ch;
|
|
501
|
+
word.value += ch;
|
|
502
|
+
word.sawNone = true;
|
|
503
|
+
i++;
|
|
504
|
+
}
|
|
505
|
+
flush();
|
|
506
|
+
return { words, redirects };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Analyse a command once: segments, argv words, redirects and the two masked
|
|
511
|
+
* views. Total and never throws — non-strings analyse as empty.
|
|
512
|
+
*/
|
|
513
|
+
export function scanShell(command: unknown): ShellScan {
|
|
514
|
+
const raw = typeof command === "string" ? command : "";
|
|
515
|
+
const { structural, expandable } = maskViews(raw);
|
|
516
|
+
const segments: ShellSegment[] = splitSegmentRanges(raw).map((r) => {
|
|
517
|
+
const { words, redirects } = tokenizeWords(r.raw);
|
|
518
|
+
const seg: ShellSegment = {
|
|
519
|
+
raw: r.raw,
|
|
520
|
+
start: r.start,
|
|
521
|
+
end: r.end,
|
|
522
|
+
words,
|
|
523
|
+
redirects,
|
|
524
|
+
};
|
|
525
|
+
if (r.next) seg.next = r.next;
|
|
526
|
+
return seg;
|
|
527
|
+
});
|
|
528
|
+
return { raw, segments, structural, expandable };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** The sentinel used by the masked views (exported for tests). */
|
|
532
|
+
export const SHELL_MASK_SENTINEL = SENTINEL;
|
|
533
|
+
|
|
534
|
+
/** True when `text` contains only spaces before `index` (command position). */
|
|
535
|
+
export function atCommandPosition(text: string, index: number): boolean {
|
|
536
|
+
return text.slice(0, index).trim() === "";
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** A segment's argv joined by single spaces, for flag/keyword matching. */
|
|
540
|
+
export function wordsText(words: readonly ShellWord[]): string {
|
|
541
|
+
return words.map((w) => w.value).join(" ");
|
|
542
|
+
}
|
package/utils.ts
CHANGED
|
@@ -931,6 +931,48 @@ export function isPlanWritePath(p: string, cwd: string): boolean {
|
|
|
931
931
|
return norm.includes(".pi/plans/") || norm.startsWith(cwdNorm + "/.pi/plans");
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
+
/**
|
|
935
|
+
* Whether two plan paths identify the same file.
|
|
936
|
+
*
|
|
937
|
+
* Plan targets arrive in several shapes: absolute Windows paths from a
|
|
938
|
+
* `tool_result`, relative paths from `/review <path>`, forward slashes from a
|
|
939
|
+
* handoff, and case differences from the host. A review-cycle boundary must
|
|
940
|
+
* not fire for a spelling difference, and must fire for a real move. Kept in
|
|
941
|
+
* this dependency-free module so the comparison is unit-testable; callers pass
|
|
942
|
+
* the session cwd for relative paths.
|
|
943
|
+
*/
|
|
944
|
+
export function samePlanPath(
|
|
945
|
+
a: string | null | undefined,
|
|
946
|
+
b: string | null | undefined,
|
|
947
|
+
cwd: string = process.cwd(),
|
|
948
|
+
): boolean {
|
|
949
|
+
const normalize = (raw: string | null | undefined): string => {
|
|
950
|
+
let value = String(raw ?? "").trim();
|
|
951
|
+
if (!value) return "";
|
|
952
|
+
value = value.replace(/\\/g, "/");
|
|
953
|
+
const absolute =
|
|
954
|
+
value.startsWith("/") || /^[A-Za-z]:\//.test(value);
|
|
955
|
+
if (!absolute) {
|
|
956
|
+
value = `${String(cwd ?? "").replace(/\\/g, "/").replace(/\/+$/, "")}/${value}`;
|
|
957
|
+
}
|
|
958
|
+
const parts: string[] = [];
|
|
959
|
+
for (const part of value.split("/")) {
|
|
960
|
+
if (!part || part === ".") continue;
|
|
961
|
+
if (part === "..") {
|
|
962
|
+
if (parts.length > 0) parts.pop();
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
parts.push(part);
|
|
966
|
+
}
|
|
967
|
+
let out = parts.join("/");
|
|
968
|
+
if (value.startsWith("/")) out = `/${out}`;
|
|
969
|
+
// Windows drive letters are case-insensitive; keep POSIX case-sensitive.
|
|
970
|
+
out = out.replace(/^([A-Za-z]):/, (_m, drive: string) => `${drive.toUpperCase()}:`);
|
|
971
|
+
return process.platform === "win32" ? out.toLowerCase() : out;
|
|
972
|
+
};
|
|
973
|
+
return normalize(a) === normalize(b);
|
|
974
|
+
}
|
|
975
|
+
|
|
934
976
|
/** Inputs for deciding whether this run may hand a plan off. */
|
|
935
977
|
export interface PlanHandoffEligibility {
|
|
936
978
|
mode: "plan" | "build" | null;
|