@pugi/cli 0.1.0-beta.90 → 0.1.0-beta.91
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.
|
@@ -11,6 +11,7 @@ import { dispatchTodoWrite, todoWriteJsonSchema, } from '../../tools/todo-write.
|
|
|
11
11
|
// JSON-schema fragment + a sentinel-returning dispatcher, matching the
|
|
12
12
|
// `todo_write` / `ask_user_question` conventions.
|
|
13
13
|
import { briefJsonSchema, dispatchBrief } from '../../tools/brief.js';
|
|
14
|
+
import { cronCreateJsonSchema, cronDeleteJsonSchema, cronListJsonSchema, dispatchCronCreate, dispatchCronDelete, dispatchCronList, } from '../../tools/cron.js';
|
|
14
15
|
import { dispatchVerifyPlanExecution, verifyPlanExecutionJsonSchema, } from '../../tools/verify-plan-execution.js';
|
|
15
16
|
import { dispatchSleep, sleepJsonSchema } from '../../tools/sleep.js';
|
|
16
17
|
import { dispatchSyntheticOutput, syntheticOutputJsonSchema, } from '../../tools/synthetic-output.js';
|
|
@@ -91,6 +92,17 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
91
92
|
// audit log (metadata, not source). Safe in plan mode — a planning
|
|
92
93
|
// loop needs to verify its plan-capture steps before any writes.
|
|
93
94
|
'verify_plan_execution',
|
|
95
|
+
// Backlog PUGI-7: cron_* tool family persists to `.pugi/cron/<name>.json`
|
|
96
|
+
// — metadata, not source. Plan mode keeps these available because
|
|
97
|
+
// planning a recurring workflow ("every Monday morning run the triple-
|
|
98
|
+
// review") is a configuration step the model should be able to take
|
|
99
|
+
// before any source mutation. The actual scheduler runner is gated by
|
|
100
|
+
// an explicit `pugi routines run` opt-in OUTSIDE the model surface, so
|
|
101
|
+
// even an aggressive plan-mode loop can only EDIT the routine registry
|
|
102
|
+
// here, never spawn a tick.
|
|
103
|
+
'cron_create',
|
|
104
|
+
'cron_delete',
|
|
105
|
+
'cron_list',
|
|
94
106
|
// Tool gap pack : `sleep` is a no-op as far as the
|
|
95
107
|
// workspace is concerned (wall-clock delay only). Plan mode keeps it
|
|
96
108
|
// available so a planning loop can throttle its own polling.
|
|
@@ -157,6 +169,10 @@ const WIRED_TOOLS = new Set([
|
|
|
157
169
|
'brief',
|
|
158
170
|
// Backlog #5 P0 : verify_plan_execution anti-fake-dispatch gate.
|
|
159
171
|
'verify_plan_execution',
|
|
172
|
+
// Backlog PUGI-7 : cron_* tool family (see READ_ONLY_TOOLS rationale).
|
|
173
|
+
'cron_create',
|
|
174
|
+
'cron_delete',
|
|
175
|
+
'cron_list',
|
|
160
176
|
'sleep',
|
|
161
177
|
// Tool gap pack: scratch-worktree primitives. Not in
|
|
162
178
|
// READ_ONLY_TOOLS — they mutate workspace state (a new git worktree
|
|
@@ -325,6 +341,34 @@ export function buildToolsSchema(kind, options = { allowFetch: false, allowSearc
|
|
|
325
341
|
'Optional: detail (<=2000 chars).',
|
|
326
342
|
parameters: briefJsonSchema,
|
|
327
343
|
});
|
|
344
|
+
// Backlog PUGI-7: cron_* tool family. Three tools that expose the
|
|
345
|
+
// local routine registry to the persona:
|
|
346
|
+
// - cron_create — register a recurring routine. Writes one JSON
|
|
347
|
+
// document per routine to `.pugi/cron/<name>.json` via atomic
|
|
348
|
+
// tmp+rename. Replacing an existing name is intentional (the
|
|
349
|
+
// name is the idempotency key).
|
|
350
|
+
// - cron_delete — remove a routine by name. Idempotent.
|
|
351
|
+
// - cron_list — list every registered routine, sorted by name.
|
|
352
|
+
// The scheduler runner (CronScheduler in core/cron/scheduler.ts) is
|
|
353
|
+
// OUT of this surface — these tools only EDIT the registry; ticking
|
|
354
|
+
// is gated by an explicit `pugi routines run` opt-in.
|
|
355
|
+
toolDefs.push({
|
|
356
|
+
name: 'cron_create',
|
|
357
|
+
description: 'Register a recurring routine. Required: name (slug), cronExpression (5-field), command (shell string). ' +
|
|
358
|
+
'Optional: args (string[]), description. Re-registering the same name REPLACES the prior routine. ' +
|
|
359
|
+
'Persisted atomically to .pugi/cron/<name>.json. Returns {ok, routine, replaced}.',
|
|
360
|
+
parameters: cronCreateJsonSchema,
|
|
361
|
+
}, {
|
|
362
|
+
name: 'cron_delete',
|
|
363
|
+
description: 'Remove a routine by name. Idempotent — deleting an unknown name returns ok:true with removed:false. ' +
|
|
364
|
+
'Returns {ok, removed, name}.',
|
|
365
|
+
parameters: cronDeleteJsonSchema,
|
|
366
|
+
}, {
|
|
367
|
+
name: 'cron_list',
|
|
368
|
+
description: 'List every registered routine, sorted by name. Zero routines returns {routines:[]} — never an error. ' +
|
|
369
|
+
'No arguments.',
|
|
370
|
+
parameters: cronListJsonSchema,
|
|
371
|
+
});
|
|
328
372
|
// Backlog #5 P0 : verify_plan_execution — anti-fake-dispatch gate.
|
|
329
373
|
// Reads the session audit log (metadata only, no source mutation). Plan-mode
|
|
330
374
|
// safe: a plan-loop frequently needs к verify its plan-capture steps before
|
|
@@ -1137,6 +1181,23 @@ export function buildExecutor(input) {
|
|
|
1137
1181
|
// earlier turns in the same engine loop invocation).
|
|
1138
1182
|
return dispatchVerifyPlanExecution(ctx.session, args);
|
|
1139
1183
|
}
|
|
1184
|
+
if (name === 'cron_create') {
|
|
1185
|
+
// Backlog PUGI-7: ScheduleCronTool — register a routine. The
|
|
1186
|
+
// dispatcher returns a JSON string envelope (success) or a
|
|
1187
|
+
// CRON_INVALID_ARGS / CRON_PERSIST_FAILED sentinel (recoverable
|
|
1188
|
+
// failures). Sentinels surface as plain tool results so the
|
|
1189
|
+
// model can self-correct.
|
|
1190
|
+
return dispatchCronCreate({ workspaceRoot }, args);
|
|
1191
|
+
}
|
|
1192
|
+
if (name === 'cron_delete') {
|
|
1193
|
+
// Backlog PUGI-7: idempotent routine removal.
|
|
1194
|
+
return dispatchCronDelete({ workspaceRoot }, args);
|
|
1195
|
+
}
|
|
1196
|
+
if (name === 'cron_list') {
|
|
1197
|
+
// Backlog PUGI-7: routine registry snapshot. Read-only; safe
|
|
1198
|
+
// for parallel dispatch.
|
|
1199
|
+
return dispatchCronList({ workspaceRoot }, args);
|
|
1200
|
+
}
|
|
1140
1201
|
if (name === 'sleep') {
|
|
1141
1202
|
return dispatchSleep({}, args);
|
|
1142
1203
|
}
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.91');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cron_* tool family — ScheduleCronTool / CronList / CronDelete (PUGI-7).
|
|
3
|
+
*
|
|
4
|
+
* Three tools that expose the local cron-routine surface to the persona:
|
|
5
|
+
*
|
|
6
|
+
* - `cron_create` — register a recurring routine. Writes one JSON
|
|
7
|
+
* document per routine to `.pugi/cron/<name>.json` via the atomic
|
|
8
|
+
* tmp+rename pattern.
|
|
9
|
+
* - `cron_delete` — remove a routine by name. Idempotent: deleting an
|
|
10
|
+
* unknown name returns `{ ok: true, removed: false }` instead of
|
|
11
|
+
* throwing, so the model never wedges on a stale routine list.
|
|
12
|
+
* - `cron_list` — return every registered routine, sorted by name.
|
|
13
|
+
* An empty registry returns `{ routines: [] }`, NOT an error.
|
|
14
|
+
*
|
|
15
|
+
* Why one JSON document per routine instead of a single `routines.json`
|
|
16
|
+
* board: routines are independent — there is no cross-routine invariant
|
|
17
|
+
* (unlike the `todo_write` single-in-progress rule). Per-file storage
|
|
18
|
+
* means a corrupt or partially-written routine cannot poison the
|
|
19
|
+
* others, and the atomic tmp+rename pattern is a one-file-at-a-time
|
|
20
|
+
* primitive that maps cleanly to per-file persistence.
|
|
21
|
+
*
|
|
22
|
+
* Why hand-rolled JSON-Schema instead of zod-to-json-schema: matches
|
|
23
|
+
* the project-wide convention (see brief.ts §note) — we have not
|
|
24
|
+
* greenlit the runtime dependency, and the schemas here are small
|
|
25
|
+
* enough to author by hand without drift risk.
|
|
26
|
+
*
|
|
27
|
+
* Cron expression validation: delegates to node-cron's `validate()` via
|
|
28
|
+
* `isValidCronExpression()` in core/cron/scheduler.ts. node-cron handles
|
|
29
|
+
* 5-field standard expressions plus the common shortcuts. We do NOT
|
|
30
|
+
* roll our own regex because cron has too many edge cases (step values,
|
|
31
|
+
* ranges with step, named months/days) and a partial regex silently
|
|
32
|
+
* accepts garbage the scheduler later rejects.
|
|
33
|
+
*
|
|
34
|
+
* On duplicate name during cron_create: the second call REPLACES the
|
|
35
|
+
* first, mirroring `CronScheduler.schedule()` which also replaces. The
|
|
36
|
+
* dispatcher returns `{ ok: true, replaced: true }` so the model and
|
|
37
|
+
* the operator can see the swap happened. Rationale: a routine name is
|
|
38
|
+
* an idempotency key; if the model re-registers the same logical
|
|
39
|
+
* routine with a tweaked expression we want the new shape to win
|
|
40
|
+
* rather than failing the call and leaving stale state.
|
|
41
|
+
*
|
|
42
|
+
* Brand voice: English only, no emoji, no banned words.
|
|
43
|
+
*/
|
|
44
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
45
|
+
import { join, resolve } from 'node:path';
|
|
46
|
+
import { isValidCronExpression } from '../core/cron/scheduler.js';
|
|
47
|
+
/** Maximum routine name length. Kept small so it renders cleanly in
|
|
48
|
+
* the operator-facing `pugi routines list` table. */
|
|
49
|
+
export const CRON_NAME_MAX = 64;
|
|
50
|
+
/** Maximum command length. The routine executes a shell command, so
|
|
51
|
+
* the cap mirrors the operator-facing brief detail cap — long enough
|
|
52
|
+
* for a realistic invocation, short enough to log without truncation. */
|
|
53
|
+
export const CRON_COMMAND_MAX = 2_000;
|
|
54
|
+
/** Maximum description length. Free-form prose, capped to fit one
|
|
55
|
+
* paragraph in the routines table. */
|
|
56
|
+
export const CRON_DESCRIPTION_MAX = 500;
|
|
57
|
+
/** Maximum number of positional args. A large arg list is a smell —
|
|
58
|
+
* the model should wrap into a single script invocation instead. */
|
|
59
|
+
export const CRON_ARGS_MAX = 32;
|
|
60
|
+
/** Maximum per-arg length. Same rationale as CRON_COMMAND_MAX. */
|
|
61
|
+
export const CRON_ARG_LEN_MAX = 500;
|
|
62
|
+
/** Sentinel returned when input fails schema validation. Mirrors
|
|
63
|
+
* `BRIEF_INVALID_ARGS` / `VERIFY_PLAN_INVALID_ARGS` so the dispatcher
|
|
64
|
+
* pattern-matches the prefix for retry-budget bookkeeping. */
|
|
65
|
+
export const CRON_INVALID_ARGS = 'CRON_INVALID_ARGS';
|
|
66
|
+
/** Sentinel returned when a registry write fails for an
|
|
67
|
+
* environment-level reason (filesystem full, permission denied). */
|
|
68
|
+
export const CRON_PERSIST_FAILED = 'CRON_PERSIST_FAILED';
|
|
69
|
+
/** Allowed routine name pattern. Slug-shaped so the name maps 1:1 to
|
|
70
|
+
* a safe filesystem basename — no separators, no shell metacharacters,
|
|
71
|
+
* no leading dot. */
|
|
72
|
+
const CRON_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
73
|
+
/* -------------------------------------------------------------------------- */
|
|
74
|
+
/* parse helpers */
|
|
75
|
+
/* -------------------------------------------------------------------------- */
|
|
76
|
+
export function parseCronCreateArgs(raw) {
|
|
77
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
78
|
+
return `${CRON_INVALID_ARGS}: arguments must be a JSON object`;
|
|
79
|
+
}
|
|
80
|
+
const obj = raw;
|
|
81
|
+
const issues = [];
|
|
82
|
+
const name = obj['name'];
|
|
83
|
+
if (typeof name !== 'string') {
|
|
84
|
+
issues.push('name: must be a string');
|
|
85
|
+
}
|
|
86
|
+
else if (name.length === 0) {
|
|
87
|
+
issues.push('name: must be non-empty');
|
|
88
|
+
}
|
|
89
|
+
else if (name.length > CRON_NAME_MAX) {
|
|
90
|
+
issues.push(`name: must be <= ${CRON_NAME_MAX} chars`);
|
|
91
|
+
}
|
|
92
|
+
else if (!CRON_NAME_RE.test(name)) {
|
|
93
|
+
issues.push('name: must match /^[a-z][a-z0-9_-]{0,63}$/ (lowercase, no separators, no leading digit)');
|
|
94
|
+
}
|
|
95
|
+
const cronExpression = obj['cronExpression'];
|
|
96
|
+
if (typeof cronExpression !== 'string') {
|
|
97
|
+
issues.push('cronExpression: must be a string');
|
|
98
|
+
}
|
|
99
|
+
else if (cronExpression.trim().length === 0) {
|
|
100
|
+
issues.push('cronExpression: must be non-empty');
|
|
101
|
+
}
|
|
102
|
+
else if (!isValidCronExpression(cronExpression)) {
|
|
103
|
+
issues.push('cronExpression: must be a valid 5-field cron expression (see https://crontab.guru)');
|
|
104
|
+
}
|
|
105
|
+
const command = obj['command'];
|
|
106
|
+
if (typeof command !== 'string') {
|
|
107
|
+
issues.push('command: must be a string');
|
|
108
|
+
}
|
|
109
|
+
else if (command.trim().length === 0) {
|
|
110
|
+
issues.push('command: must be non-empty');
|
|
111
|
+
}
|
|
112
|
+
else if (command.length > CRON_COMMAND_MAX) {
|
|
113
|
+
issues.push(`command: must be <= ${CRON_COMMAND_MAX} chars`);
|
|
114
|
+
}
|
|
115
|
+
let args;
|
|
116
|
+
if (obj['args'] !== undefined && obj['args'] !== null) {
|
|
117
|
+
if (!Array.isArray(obj['args'])) {
|
|
118
|
+
issues.push('args: must be an array of strings when present');
|
|
119
|
+
}
|
|
120
|
+
else if (obj['args'].length > CRON_ARGS_MAX) {
|
|
121
|
+
issues.push(`args: must have <= ${CRON_ARGS_MAX} entries`);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
args = [];
|
|
125
|
+
for (let i = 0; i < obj['args'].length; i++) {
|
|
126
|
+
const a = obj['args'][i];
|
|
127
|
+
if (typeof a !== 'string') {
|
|
128
|
+
issues.push(`args[${i}]: must be a string`);
|
|
129
|
+
}
|
|
130
|
+
else if (a.length > CRON_ARG_LEN_MAX) {
|
|
131
|
+
issues.push(`args[${i}]: must be <= ${CRON_ARG_LEN_MAX} chars`);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
args.push(a);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let description;
|
|
140
|
+
if (obj['description'] !== undefined && obj['description'] !== null) {
|
|
141
|
+
if (typeof obj['description'] !== 'string') {
|
|
142
|
+
issues.push('description: must be a string when present');
|
|
143
|
+
}
|
|
144
|
+
else if (obj['description'].length > CRON_DESCRIPTION_MAX) {
|
|
145
|
+
issues.push(`description: must be <= ${CRON_DESCRIPTION_MAX} chars`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
description = obj['description'];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (issues.length > 0) {
|
|
152
|
+
return `${CRON_INVALID_ARGS}: ${issues.join('; ')}`;
|
|
153
|
+
}
|
|
154
|
+
const result = {
|
|
155
|
+
name: name,
|
|
156
|
+
cronExpression: cronExpression,
|
|
157
|
+
command: command,
|
|
158
|
+
...(args !== undefined ? { args } : {}),
|
|
159
|
+
...(description !== undefined ? { description } : {}),
|
|
160
|
+
};
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
export function parseCronDeleteArgs(raw) {
|
|
164
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
165
|
+
return `${CRON_INVALID_ARGS}: arguments must be a JSON object`;
|
|
166
|
+
}
|
|
167
|
+
const obj = raw;
|
|
168
|
+
const name = obj['name'];
|
|
169
|
+
if (typeof name !== 'string') {
|
|
170
|
+
return `${CRON_INVALID_ARGS}: name: must be a string`;
|
|
171
|
+
}
|
|
172
|
+
if (name.length === 0) {
|
|
173
|
+
return `${CRON_INVALID_ARGS}: name: must be non-empty`;
|
|
174
|
+
}
|
|
175
|
+
if (name.length > CRON_NAME_MAX) {
|
|
176
|
+
return `${CRON_INVALID_ARGS}: name: must be <= ${CRON_NAME_MAX} chars`;
|
|
177
|
+
}
|
|
178
|
+
if (!CRON_NAME_RE.test(name)) {
|
|
179
|
+
return `${CRON_INVALID_ARGS}: name: must match /^[a-z][a-z0-9_-]{0,63}$/`;
|
|
180
|
+
}
|
|
181
|
+
return { name };
|
|
182
|
+
}
|
|
183
|
+
/* -------------------------------------------------------------------------- */
|
|
184
|
+
/* path resolution */
|
|
185
|
+
/* -------------------------------------------------------------------------- */
|
|
186
|
+
/**
|
|
187
|
+
* Compute the on-disk path for a routine's JSON document. Public so
|
|
188
|
+
* future surfaces (`pugi routines show <name>`) can share the layout
|
|
189
|
+
* rules without duplicating string composition.
|
|
190
|
+
*/
|
|
191
|
+
export function cronRoutinePath(ctx, name) {
|
|
192
|
+
// Defense in depth: the name is already validated by the parse layer,
|
|
193
|
+
// but a direct programmatic caller (tests, future internal hooks)
|
|
194
|
+
// might skip parsing. Re-check the slug shape here so we never compose
|
|
195
|
+
// a path that escapes the cron directory.
|
|
196
|
+
if (!CRON_NAME_RE.test(name)) {
|
|
197
|
+
throw new Error(`cron: invalid routine name shape: "${name}"`);
|
|
198
|
+
}
|
|
199
|
+
return join(resolve(ctx.workspaceRoot), '.pugi', 'cron', `${name}.json`);
|
|
200
|
+
}
|
|
201
|
+
function cronDir(ctx) {
|
|
202
|
+
return join(resolve(ctx.workspaceRoot), '.pugi', 'cron');
|
|
203
|
+
}
|
|
204
|
+
/* -------------------------------------------------------------------------- */
|
|
205
|
+
/* persistence */
|
|
206
|
+
/* -------------------------------------------------------------------------- */
|
|
207
|
+
let cronSequence = 0;
|
|
208
|
+
/**
|
|
209
|
+
* Atomic write: serialise -> sibling tmp file -> rename. POSIX-portable
|
|
210
|
+
* and atomic on the same filesystem so a concurrent `cron_list` reader
|
|
211
|
+
* never observes a half-written routine document.
|
|
212
|
+
*/
|
|
213
|
+
function persistRoutine(ctx, routine) {
|
|
214
|
+
const dir = cronDir(ctx);
|
|
215
|
+
if (!existsSync(dir)) {
|
|
216
|
+
mkdirSync(dir, { recursive: true });
|
|
217
|
+
}
|
|
218
|
+
const finalPath = cronRoutinePath(ctx, routine.name);
|
|
219
|
+
const body = `${JSON.stringify(routine, null, 2)}\n`;
|
|
220
|
+
const tmpPath = `${finalPath}.tmp-${process.pid}-${cronSequence++}`;
|
|
221
|
+
writeFileSync(tmpPath, body, { encoding: 'utf8', mode: 0o600 });
|
|
222
|
+
renameSync(tmpPath, finalPath);
|
|
223
|
+
}
|
|
224
|
+
function loadRoutine(ctx, name) {
|
|
225
|
+
const path = cronRoutinePath(ctx, name);
|
|
226
|
+
if (!existsSync(path))
|
|
227
|
+
return null;
|
|
228
|
+
try {
|
|
229
|
+
const raw = readFileSync(path, 'utf8');
|
|
230
|
+
const parsed = JSON.parse(raw);
|
|
231
|
+
if (!isValidLoadedRoutine(parsed))
|
|
232
|
+
return null;
|
|
233
|
+
return parsed;
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// A corrupt or partially-written file is treated as "no routine
|
|
237
|
+
// here" — the next cron_create call replaces it cleanly. We do not
|
|
238
|
+
// surface the parse error because the model cannot act on it.
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function isValidLoadedRoutine(value) {
|
|
243
|
+
return (typeof value.name === 'string' &&
|
|
244
|
+
typeof value.cronExpression === 'string' &&
|
|
245
|
+
typeof value.command === 'string' &&
|
|
246
|
+
Array.isArray(value.args) &&
|
|
247
|
+
typeof value.createdAt === 'string' &&
|
|
248
|
+
typeof value.updatedAt === 'string');
|
|
249
|
+
}
|
|
250
|
+
/* -------------------------------------------------------------------------- */
|
|
251
|
+
/* dispatchers */
|
|
252
|
+
/* -------------------------------------------------------------------------- */
|
|
253
|
+
function nowIso(ctx) {
|
|
254
|
+
return (ctx.now ? ctx.now() : new Date()).toISOString();
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Dispatch entry for `cron_create`. Returns a JSON-string envelope so
|
|
258
|
+
* the engine adapter can surface structured data to the persona model
|
|
259
|
+
* without bespoke parsing. Mirrors the brief/todo_write dispatcher
|
|
260
|
+
* shape (string return for sentinel routing, JSON-string for success).
|
|
261
|
+
*/
|
|
262
|
+
export function dispatchCronCreate(ctx, raw) {
|
|
263
|
+
const parsed = parseCronCreateArgs(raw);
|
|
264
|
+
if (typeof parsed === 'string') {
|
|
265
|
+
return parsed;
|
|
266
|
+
}
|
|
267
|
+
const at = nowIso(ctx);
|
|
268
|
+
const existing = loadRoutine(ctx, parsed.name);
|
|
269
|
+
const routine = {
|
|
270
|
+
name: parsed.name,
|
|
271
|
+
cronExpression: parsed.cronExpression,
|
|
272
|
+
command: parsed.command,
|
|
273
|
+
args: parsed.args ?? [],
|
|
274
|
+
...(parsed.description !== undefined ? { description: parsed.description } : {}),
|
|
275
|
+
createdAt: existing ? existing.createdAt : at,
|
|
276
|
+
updatedAt: at,
|
|
277
|
+
};
|
|
278
|
+
try {
|
|
279
|
+
persistRoutine(ctx, routine);
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
return `${CRON_PERSIST_FAILED}: ${error.message}`;
|
|
283
|
+
}
|
|
284
|
+
const result = {
|
|
285
|
+
ok: true,
|
|
286
|
+
routine,
|
|
287
|
+
replaced: existing !== null,
|
|
288
|
+
};
|
|
289
|
+
return JSON.stringify(result);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Dispatch entry for `cron_delete`. Idempotent: a delete of an unknown
|
|
293
|
+
* routine returns `{ ok: true, removed: false }` rather than a 404-
|
|
294
|
+
* shaped sentinel. Rationale: the persona's mental model of "the
|
|
295
|
+
* routine is gone" is satisfied either way, and treating delete as
|
|
296
|
+
* idempotent avoids the model spinning on a stale routine that was
|
|
297
|
+
* already cleaned up by a parallel operator action.
|
|
298
|
+
*/
|
|
299
|
+
export function dispatchCronDelete(ctx, raw) {
|
|
300
|
+
const parsed = parseCronDeleteArgs(raw);
|
|
301
|
+
if (typeof parsed === 'string') {
|
|
302
|
+
return parsed;
|
|
303
|
+
}
|
|
304
|
+
const path = cronRoutinePath(ctx, parsed.name);
|
|
305
|
+
let removed = false;
|
|
306
|
+
if (existsSync(path)) {
|
|
307
|
+
try {
|
|
308
|
+
unlinkSync(path);
|
|
309
|
+
removed = true;
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
return `${CRON_PERSIST_FAILED}: ${error.message}`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const result = {
|
|
316
|
+
ok: true,
|
|
317
|
+
removed,
|
|
318
|
+
name: parsed.name,
|
|
319
|
+
};
|
|
320
|
+
return JSON.stringify(result);
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Dispatch entry for `cron_list`. Returns every registered routine,
|
|
324
|
+
* sorted by name. Zero routines returns an empty array (`{ routines:
|
|
325
|
+
* [] }`), never an error — an empty registry is the steady state on a
|
|
326
|
+
* fresh workspace and the model should not have to special-case it.
|
|
327
|
+
*
|
|
328
|
+
* `cron_list` accepts no arguments. We still parse the input so that a
|
|
329
|
+
* model that sends a stray object (because its tool grammar emits
|
|
330
|
+
* `{}` by default) does not crash; only an explicit non-object value
|
|
331
|
+
* is rejected with `CRON_INVALID_ARGS`.
|
|
332
|
+
*/
|
|
333
|
+
export function dispatchCronList(ctx, raw) {
|
|
334
|
+
if (raw !== undefined && raw !== null) {
|
|
335
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
336
|
+
return `${CRON_INVALID_ARGS}: arguments must be a JSON object or omitted`;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
const dir = cronDir(ctx);
|
|
340
|
+
const routines = [];
|
|
341
|
+
if (existsSync(dir)) {
|
|
342
|
+
const entries = readdirSync(dir).filter((e) => e.endsWith('.json'));
|
|
343
|
+
for (const entry of entries) {
|
|
344
|
+
const name = entry.slice(0, -'.json'.length);
|
|
345
|
+
// The persistence layer only writes files with slug-safe names,
|
|
346
|
+
// but a manual edit (operator hand-rolled a file) might violate
|
|
347
|
+
// the shape. Skip such entries instead of crashing the list call.
|
|
348
|
+
if (!CRON_NAME_RE.test(name))
|
|
349
|
+
continue;
|
|
350
|
+
const routine = loadRoutine(ctx, name);
|
|
351
|
+
if (routine)
|
|
352
|
+
routines.push(routine);
|
|
353
|
+
}
|
|
354
|
+
routines.sort((a, b) => a.name.localeCompare(b.name));
|
|
355
|
+
}
|
|
356
|
+
const result = { routines };
|
|
357
|
+
return JSON.stringify(result);
|
|
358
|
+
}
|
|
359
|
+
/* -------------------------------------------------------------------------- */
|
|
360
|
+
/* JSON-Schema fragments (engine-side tool definitions) */
|
|
361
|
+
/* -------------------------------------------------------------------------- */
|
|
362
|
+
/**
|
|
363
|
+
* JSON-Schema for cron_create. Mirrors `parseCronCreateArgs` checks
|
|
364
|
+
* 1:1. Hand-rolled per the project convention (see brief.ts §note on
|
|
365
|
+
* zod-to-json-schema).
|
|
366
|
+
*/
|
|
367
|
+
export const cronCreateJsonSchema = {
|
|
368
|
+
type: 'object',
|
|
369
|
+
additionalProperties: false,
|
|
370
|
+
required: ['name', 'cronExpression', 'command'],
|
|
371
|
+
properties: {
|
|
372
|
+
name: {
|
|
373
|
+
type: 'string',
|
|
374
|
+
minLength: 1,
|
|
375
|
+
maxLength: CRON_NAME_MAX,
|
|
376
|
+
pattern: '^[a-z][a-z0-9_-]{0,63}$',
|
|
377
|
+
description: 'Routine name (slug-shaped, lowercase). Doubles as the on-disk basename and the idempotency key — re-registering with the same name REPLACES the prior routine.',
|
|
378
|
+
},
|
|
379
|
+
cronExpression: {
|
|
380
|
+
type: 'string',
|
|
381
|
+
minLength: 1,
|
|
382
|
+
description: 'Standard 5-field cron expression (minute hour day-of-month month day-of-week). Example: "0 9 * * 1-5" runs at 09:00 on weekdays. Validated by node-cron.',
|
|
383
|
+
},
|
|
384
|
+
command: {
|
|
385
|
+
type: 'string',
|
|
386
|
+
minLength: 1,
|
|
387
|
+
maxLength: CRON_COMMAND_MAX,
|
|
388
|
+
description: 'Shell command to execute on each tick. Example: "pugi review --triple --remote". The command is stored verbatim; the runner spawns it without word-splitting.',
|
|
389
|
+
},
|
|
390
|
+
args: {
|
|
391
|
+
type: 'array',
|
|
392
|
+
maxItems: CRON_ARGS_MAX,
|
|
393
|
+
items: {
|
|
394
|
+
type: 'string',
|
|
395
|
+
maxLength: CRON_ARG_LEN_MAX,
|
|
396
|
+
},
|
|
397
|
+
description: 'Optional positional arguments passed to <command> as a separate argv array. Prefer this over embedding shell metacharacters in <command>.',
|
|
398
|
+
},
|
|
399
|
+
description: {
|
|
400
|
+
type: 'string',
|
|
401
|
+
maxLength: CRON_DESCRIPTION_MAX,
|
|
402
|
+
description: 'Optional one-paragraph description. Surfaces in `pugi routines list` so an operator can audit unfamiliar routines.',
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
/**
|
|
407
|
+
* JSON-Schema for cron_delete. Single-field shape — name only.
|
|
408
|
+
*/
|
|
409
|
+
export const cronDeleteJsonSchema = {
|
|
410
|
+
type: 'object',
|
|
411
|
+
additionalProperties: false,
|
|
412
|
+
required: ['name'],
|
|
413
|
+
properties: {
|
|
414
|
+
name: {
|
|
415
|
+
type: 'string',
|
|
416
|
+
minLength: 1,
|
|
417
|
+
maxLength: CRON_NAME_MAX,
|
|
418
|
+
pattern: '^[a-z][a-z0-9_-]{0,63}$',
|
|
419
|
+
description: 'Routine name to delete. Idempotent: an unknown name returns ok:true with removed:false instead of erroring.',
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
/**
|
|
424
|
+
* JSON-Schema for cron_list. Zero-field shape — every list returns the
|
|
425
|
+
* full registry. We expose an empty `additionalProperties: false`
|
|
426
|
+
* object so the schema-aware engine knows the tool takes no input.
|
|
427
|
+
*/
|
|
428
|
+
export const cronListJsonSchema = {
|
|
429
|
+
type: 'object',
|
|
430
|
+
additionalProperties: false,
|
|
431
|
+
properties: {},
|
|
432
|
+
};
|
|
433
|
+
//# sourceMappingURL=cron.js.map
|
package/dist/tools/registry.js
CHANGED
|
@@ -21,6 +21,19 @@ const registry = [
|
|
|
21
21
|
// Backlog #5 P0 : verify_plan_execution anti-fake-dispatch gate.
|
|
22
22
|
// Reads session audit events only; safe для parallel dispatches.
|
|
23
23
|
{ name: 'verify_plan_execution', permission: 'none', risk: 'low', concurrencySafe: true, m1: false },
|
|
24
|
+
// Backlog PUGI-7 : cron_* tool family. Persists routine registry to
|
|
25
|
+
// `.pugi/cron/<name>.json` (one file per routine, atomic tmp+rename).
|
|
26
|
+
// Permission = none because the writes land in metadata, not source —
|
|
27
|
+
// mirrors the brief / todo_write posture. concurrencySafe = false for
|
|
28
|
+
// create + delete because per-file persistence is atomic individually
|
|
29
|
+
// but two parallel creates of the SAME name race on the rename and
|
|
30
|
+
// the loser's body is dropped silently; cron_list is read-only and
|
|
31
|
+
// safe for concurrent dispatch. Risk = low across the board: routines
|
|
32
|
+
// are configuration objects, the actual scheduler runner lives behind
|
|
33
|
+
// an explicit `pugi routines run` opt-in and is OUT of this surface.
|
|
34
|
+
{ name: 'cron_create', permission: 'none', risk: 'low', concurrencySafe: false, m1: false },
|
|
35
|
+
{ name: 'cron_delete', permission: 'none', risk: 'low', concurrencySafe: false, m1: false },
|
|
36
|
+
{ name: 'cron_list', permission: 'none', risk: 'low', concurrencySafe: true, m1: false },
|
|
24
37
|
{ name: 'edit', permission: 'edit', risk: 'medium', concurrencySafe: false, m1: true },
|
|
25
38
|
// Tool gap pack : scratch worktree open. Spawns
|
|
26
39
|
// `git worktree add` under `.pugi/worktrees/<taskId>/`. Permission =
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* MultiFileDiffApproval — side-by-side multi-file diff approval modal
|
|
4
|
+
* (PUGI-68).
|
|
5
|
+
*
|
|
6
|
+
* When the engine proposes a batch of edits that touches more than one
|
|
7
|
+
* file, the operator needs to review every diff in a single coherent
|
|
8
|
+
* view, then accept-all / reject-all / per-file approve before the
|
|
9
|
+
* dispatcher applies anything. The single-file approval surfaces in
|
|
10
|
+
* the AskModal / PlanReviewModal family are insufficient — a per-file
|
|
11
|
+
* back-to-back prompt loses the cross-file context the operator needs
|
|
12
|
+
* to spot a regression that spans modules.
|
|
13
|
+
*
|
|
14
|
+
* Layout (matches the AskUserQuestionChips PUGI-130 side-by-side
|
|
15
|
+
* precedent, see `ask-user-question-chips.tsx`):
|
|
16
|
+
*
|
|
17
|
+
* ┌─ Multi-file diff review ───────────────────────────────────────┐
|
|
18
|
+
* │ ┌─ Files ─────────────┐ ┌─ Diff: src/foo/bar.ts ─────────────┐ │
|
|
19
|
+
* │ │ ▸ • src/foo/bar.ts │ │ --- src/foo/bar.ts │ │
|
|
20
|
+
* │ │ ✓ src/baz.ts │ │ +++ src/foo/bar.ts │ │
|
|
21
|
+
* │ │ ✗ src/qux.ts │ │ @@ -1,4 +1,4 @@ │ │
|
|
22
|
+
* │ │ • src/x/y.ts │ │ -const x = 1; │ │
|
|
23
|
+
* │ │ │ │ +const x = 2; │ │
|
|
24
|
+
* │ └─────────────────────┘ └────────────────────────────────────┘ │
|
|
25
|
+
* │ 1/4 approved · 1/4 rejected · 2 remaining · Enter when done │
|
|
26
|
+
* └────────────────────────────────────────────────────────────────┘
|
|
27
|
+
*
|
|
28
|
+
* Key bindings:
|
|
29
|
+
* - ↑ / ↓ ............ navigate the file list
|
|
30
|
+
* - a ................ approve the highlighted file
|
|
31
|
+
* - r ................ reject the highlighted file
|
|
32
|
+
* - A (shift+a) ...... approve every file in the batch
|
|
33
|
+
* - R (shift+r) ...... reject every file in the batch
|
|
34
|
+
* - PgUp / PgDn ...... scroll the diff pane
|
|
35
|
+
* - Enter ............ finalise — emit per-file verdicts
|
|
36
|
+
* - Esc .............. cancel the whole batch (every file → rejected)
|
|
37
|
+
*
|
|
38
|
+
* Contract notes:
|
|
39
|
+
* - The component is PURE (Ink-style). It mounts useState for cursor,
|
|
40
|
+
* per-file verdict map, and diff-pane scroll offset. Emits exactly
|
|
41
|
+
* one `onResolve` callback when the operator presses Enter (or Esc
|
|
42
|
+
* for a cancel).
|
|
43
|
+
* - Per-file diff bodies are rendered verbatim from the provided
|
|
44
|
+
* unified-diff text — the component does NOT compute diffs from raw
|
|
45
|
+
* file contents. Callers (typically the dispatcher result transcript)
|
|
46
|
+
* own the diff text generation.
|
|
47
|
+
* - Long file paths are truncated middle-style so prefix + suffix stay
|
|
48
|
+
* visible (the parts the operator scans for module scope + filename).
|
|
49
|
+
* - Long diff bodies scroll via a single-axis offset; horizontal
|
|
50
|
+
* overflow is left to Ink's text wrapping per line. No mouse support.
|
|
51
|
+
*
|
|
52
|
+
* Brand voice gate: ASCII glyphs only (✓ / ✗ kept for status — already
|
|
53
|
+
* shipped в other TUI components like agent-progress-card). No banned
|
|
54
|
+
* brand words, no em-dashes, no attribution to external AIs.
|
|
55
|
+
*/
|
|
56
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
57
|
+
import { Box, Text, useInput } from 'ink';
|
|
58
|
+
/* ------------------------------------------------------------------ */
|
|
59
|
+
/* Defensive caps */
|
|
60
|
+
/* ------------------------------------------------------------------ */
|
|
61
|
+
/** Maximum chars rendered per file path in the left pane. */
|
|
62
|
+
export const MULTI_FILE_DIFF_PATH_CAP = 48;
|
|
63
|
+
/** Visible rows in the diff pane viewport. */
|
|
64
|
+
export const MULTI_FILE_DIFF_PANE_HEIGHT = 18;
|
|
65
|
+
/** Width hint for the left file-list column (used for path truncation). */
|
|
66
|
+
export const MULTI_FILE_DIFF_LEFT_WIDTH = 32;
|
|
67
|
+
/** Hard cap on diff body length (chars) — defends against runaway model output. */
|
|
68
|
+
export const MULTI_FILE_DIFF_BODY_CHAR_CAP = 200_000;
|
|
69
|
+
/* ------------------------------------------------------------------ */
|
|
70
|
+
/* Helpers */
|
|
71
|
+
/* ------------------------------------------------------------------ */
|
|
72
|
+
/**
|
|
73
|
+
* Middle-truncate a long file path so both prefix (module scope) and
|
|
74
|
+
* suffix (filename) stay visible. Mirrors the macOS `truncate=middle`
|
|
75
|
+
* convention. Appends `…` in the middle when truncation happens.
|
|
76
|
+
*
|
|
77
|
+
* Examples (cap = 24):
|
|
78
|
+
* "src/foo/bar/baz/qux.ts" → "src/foo/bar/baz/qux.ts" (fits)
|
|
79
|
+
* "src/a-long-module/b/c/d.ts" → "src/a-long-mo…/b/c/d.ts" (cut middle)
|
|
80
|
+
*
|
|
81
|
+
* Exported so the spec can assert the contract directly.
|
|
82
|
+
*/
|
|
83
|
+
export function truncateMiddle(raw, cap) {
|
|
84
|
+
if (cap <= 1)
|
|
85
|
+
return '…';
|
|
86
|
+
if (raw.length <= cap)
|
|
87
|
+
return raw;
|
|
88
|
+
// Reserve one slot for the ellipsis; balance prefix/suffix around it.
|
|
89
|
+
// The suffix gets one extra char on odd budgets so the filename stays
|
|
90
|
+
// longer (operators scan filenames more than the path prefix).
|
|
91
|
+
const remaining = cap - 1;
|
|
92
|
+
const prefixLen = Math.floor(remaining / 2);
|
|
93
|
+
const suffixLen = remaining - prefixLen;
|
|
94
|
+
return `${raw.slice(0, prefixLen)}…${raw.slice(raw.length - suffixLen)}`;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Defensive char cap on the diff body. Schema-level caps live upstream;
|
|
98
|
+
* this is belt + braces against a malformed dispatcher transcript that
|
|
99
|
+
* could otherwise exhaust the terminal scrollback.
|
|
100
|
+
*/
|
|
101
|
+
export function clampDiffBody(raw) {
|
|
102
|
+
if (raw.length <= MULTI_FILE_DIFF_BODY_CHAR_CAP)
|
|
103
|
+
return raw;
|
|
104
|
+
return `${raw.slice(0, MULTI_FILE_DIFF_BODY_CHAR_CAP - 1)}…`;
|
|
105
|
+
}
|
|
106
|
+
export function classifyDiffLine(line) {
|
|
107
|
+
if (line.startsWith('+++') || line.startsWith('---'))
|
|
108
|
+
return 'file-header';
|
|
109
|
+
if (line.startsWith('@@'))
|
|
110
|
+
return 'hunk-header';
|
|
111
|
+
if (line.startsWith('+'))
|
|
112
|
+
return 'addition';
|
|
113
|
+
if (line.startsWith('-'))
|
|
114
|
+
return 'deletion';
|
|
115
|
+
return 'context';
|
|
116
|
+
}
|
|
117
|
+
/** Inline helper — return per-line render props for a diff line. */
|
|
118
|
+
function renderProps(kind) {
|
|
119
|
+
switch (kind) {
|
|
120
|
+
case 'file-header':
|
|
121
|
+
return { bold: true };
|
|
122
|
+
case 'hunk-header':
|
|
123
|
+
return { color: 'blue', bold: true };
|
|
124
|
+
case 'addition':
|
|
125
|
+
return { color: 'green' };
|
|
126
|
+
case 'deletion':
|
|
127
|
+
return { color: 'red' };
|
|
128
|
+
case 'context':
|
|
129
|
+
return { dimColor: true };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Public verdict-encoder mirror of the AskModal / PlanReviewModal
|
|
134
|
+
* encoders. Surfaces a single human-readable summary string the caller
|
|
135
|
+
* can inject as the next operator turn or log to a session journal.
|
|
136
|
+
*
|
|
137
|
+
* - `cancelled` → "[MULTI-DIFF-VERDICT:cancelled]"
|
|
138
|
+
* - all approved → "[MULTI-DIFF-VERDICT:all-approved] file1; file2"
|
|
139
|
+
* - all rejected → "[MULTI-DIFF-VERDICT:all-rejected] file1; file2"
|
|
140
|
+
* - mixed → "[MULTI-DIFF-VERDICT:mixed] +file1; -file2; ?file3"
|
|
141
|
+
*
|
|
142
|
+
* Each per-file token in mixed mode is prefixed `+` (approved), `-`
|
|
143
|
+
* (rejected), or `?` (still pending — operator hit Enter без deciding).
|
|
144
|
+
*/
|
|
145
|
+
export function encodeMultiFileDiffVerdict(result) {
|
|
146
|
+
if (result.cancelled)
|
|
147
|
+
return '[MULTI-DIFF-VERDICT:cancelled]';
|
|
148
|
+
const total = result.verdicts.length;
|
|
149
|
+
if (total === 0)
|
|
150
|
+
return '[MULTI-DIFF-VERDICT:empty]';
|
|
151
|
+
const allApproved = result.verdicts.every((v) => v.verdict === 'approved');
|
|
152
|
+
const allRejected = result.verdicts.every((v) => v.verdict === 'rejected');
|
|
153
|
+
if (allApproved) {
|
|
154
|
+
return `[MULTI-DIFF-VERDICT:all-approved] ${result.verdicts
|
|
155
|
+
.map((v) => v.path)
|
|
156
|
+
.join('; ')}`;
|
|
157
|
+
}
|
|
158
|
+
if (allRejected) {
|
|
159
|
+
return `[MULTI-DIFF-VERDICT:all-rejected] ${result.verdicts
|
|
160
|
+
.map((v) => v.path)
|
|
161
|
+
.join('; ')}`;
|
|
162
|
+
}
|
|
163
|
+
const tokens = result.verdicts.map((v) => {
|
|
164
|
+
const prefix = v.verdict === 'approved' ? '+' : v.verdict === 'rejected' ? '-' : '?';
|
|
165
|
+
return `${prefix}${v.path}`;
|
|
166
|
+
});
|
|
167
|
+
return `[MULTI-DIFF-VERDICT:mixed] ${tokens.join('; ')}`;
|
|
168
|
+
}
|
|
169
|
+
/* ------------------------------------------------------------------ */
|
|
170
|
+
/* Component */
|
|
171
|
+
/* ------------------------------------------------------------------ */
|
|
172
|
+
/**
|
|
173
|
+
* Build the result object for a given verdict map + entries vector.
|
|
174
|
+
* Hoisted because both the commit (Enter) and cancel (Esc) paths need
|
|
175
|
+
* to project the same shape — cancel just overrides every verdict to
|
|
176
|
+
* `rejected` and stamps `cancelled: true`.
|
|
177
|
+
*/
|
|
178
|
+
function buildResult(entries, verdicts, cancelled) {
|
|
179
|
+
const projected = entries.map((entry, idx) => ({
|
|
180
|
+
path: entry.path,
|
|
181
|
+
verdict: verdicts[idx] ?? 'pending',
|
|
182
|
+
}));
|
|
183
|
+
return {
|
|
184
|
+
verdicts: projected,
|
|
185
|
+
approvedPaths: projected
|
|
186
|
+
.filter((v) => v.verdict === 'approved')
|
|
187
|
+
.map((v) => v.path),
|
|
188
|
+
rejectedPaths: projected
|
|
189
|
+
.filter((v) => v.verdict === 'rejected')
|
|
190
|
+
.map((v) => v.path),
|
|
191
|
+
cancelled,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export function MultiFileDiffApproval(props) {
|
|
195
|
+
// FIX (PR #876 triple-review): the previous `useMemo(() => props.entries,
|
|
196
|
+
// [props.entries])` was a no-op — it returned the same reference it
|
|
197
|
+
// depended on, so the memo never produced a stable identity gain. We
|
|
198
|
+
// reference `props.entries` directly now; downstream useEffect /
|
|
199
|
+
// useMemo hooks key on the real prop, not a redundant wrapper.
|
|
200
|
+
const entries = props.entries;
|
|
201
|
+
const paneHeight = props.paneHeight ?? MULTI_FILE_DIFF_PANE_HEIGHT;
|
|
202
|
+
const [cursor, setCursor] = useState(0);
|
|
203
|
+
const [verdicts, setVerdicts] = useState(() => entries.map(() => 'pending'));
|
|
204
|
+
const [scrollOffset, setScrollOffset] = useState(0);
|
|
205
|
+
// FIX (PR #876 triple-review, P1): the lazy useState initialiser
|
|
206
|
+
// captures `entries.length` ONCE at mount. If the caller swaps in
|
|
207
|
+
// a different entries array (length mismatch), the verdicts vector
|
|
208
|
+
// would drift — `setVerdictAt` could index out of range or
|
|
209
|
+
// `buildResult` could project a `pending` for a real entry whose
|
|
210
|
+
// verdict the operator already set. Re-sync on length change while
|
|
211
|
+
// preserving existing per-index verdicts (forgiving variant A).
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
setVerdicts((prev) => {
|
|
214
|
+
if (prev.length === entries.length)
|
|
215
|
+
return prev;
|
|
216
|
+
return entries.map((_, i) => prev[i] ?? 'pending');
|
|
217
|
+
});
|
|
218
|
+
// Also clamp the cursor so it never points past the new end.
|
|
219
|
+
setCursor((c) => {
|
|
220
|
+
if (entries.length === 0)
|
|
221
|
+
return 0;
|
|
222
|
+
return Math.min(c, entries.length - 1);
|
|
223
|
+
});
|
|
224
|
+
}, [entries.length, entries]);
|
|
225
|
+
// FIX (PR #876 triple-review, P1): `commit()` is called from inside
|
|
226
|
+
// the useInput closure, which captures the `verdicts` value at the
|
|
227
|
+
// render time the closure was created. With rapid keypresses (e.g.
|
|
228
|
+
// `a` then Enter on the same React tick), React has scheduled the
|
|
229
|
+
// setVerdicts update but the closure still sees the previous array.
|
|
230
|
+
// The emitted MultiFileDiffResult would drop the latest verdict.
|
|
231
|
+
// Fix: track verdicts via a ref synced in a useEffect — the commit
|
|
232
|
+
// path reads `verdictsRef.current` so it always sees the latest map.
|
|
233
|
+
const verdictsRef = useRef(verdicts);
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
verdictsRef.current = verdicts;
|
|
236
|
+
}, [verdicts]);
|
|
237
|
+
// Pre-split the active diff body into lines for the right pane. The
|
|
238
|
+
// body is clamped defensively before splitting so a runaway model
|
|
239
|
+
// payload cannot exhaust the terminal scrollback. useMemo here keeps
|
|
240
|
+
// the line array stable across navigation re-renders on the SAME
|
|
241
|
+
// file — only the cursor changes when the operator presses ↑/↓ on
|
|
242
|
+
// the same diff body.
|
|
243
|
+
const activeEntry = entries[cursor];
|
|
244
|
+
const activeLines = useMemo(() => {
|
|
245
|
+
const body = clampDiffBody(activeEntry?.diff ?? '');
|
|
246
|
+
if (body.length === 0)
|
|
247
|
+
return [];
|
|
248
|
+
return body.split('\n');
|
|
249
|
+
}, [activeEntry?.diff]);
|
|
250
|
+
function commit(cancelled = false) {
|
|
251
|
+
// Read the freshest verdict map via ref — the closure-captured
|
|
252
|
+
// `verdicts` state could be one render behind on rapid keypresses.
|
|
253
|
+
const latest = verdictsRef.current;
|
|
254
|
+
const finalVerdicts = cancelled
|
|
255
|
+
? entries.map(() => 'rejected')
|
|
256
|
+
: latest;
|
|
257
|
+
props.onResolve(buildResult(entries, finalVerdicts, cancelled));
|
|
258
|
+
}
|
|
259
|
+
function setVerdictAt(idx, verdict) {
|
|
260
|
+
setVerdicts((prev) => {
|
|
261
|
+
const next = prev.slice();
|
|
262
|
+
next[idx] = verdict;
|
|
263
|
+
return next;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function setAllVerdicts(verdict) {
|
|
267
|
+
setVerdicts(() => entries.map(() => verdict));
|
|
268
|
+
}
|
|
269
|
+
useInput((input, key) => {
|
|
270
|
+
// Esc cancels the whole batch (every file → rejected). Mirrors the
|
|
271
|
+
// AskModal cancel contract so operator muscle memory transfers.
|
|
272
|
+
if (key.escape) {
|
|
273
|
+
commit(true);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
// Enter commits the current verdict map. Pending files stay
|
|
277
|
+
// pending — the caller decides whether `pending` is treated as
|
|
278
|
+
// approve or reject (dispatcher policy lives upstream).
|
|
279
|
+
if (key.return) {
|
|
280
|
+
commit(false);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// ↑ / ↓ navigate file list. Reset scroll on file change so the
|
|
284
|
+
// operator sees the diff header again, not a stale offset from
|
|
285
|
+
// the previous file.
|
|
286
|
+
if (key.upArrow) {
|
|
287
|
+
if (entries.length === 0)
|
|
288
|
+
return;
|
|
289
|
+
setCursor((c) => (c - 1 + entries.length) % entries.length);
|
|
290
|
+
setScrollOffset(0);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (key.downArrow) {
|
|
294
|
+
if (entries.length === 0)
|
|
295
|
+
return;
|
|
296
|
+
setCursor((c) => (c + 1) % entries.length);
|
|
297
|
+
setScrollOffset(0);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
// PgUp / PgDn scroll the diff pane by a near-full viewport. We
|
|
301
|
+
// keep one row of overlap so the operator's eye anchors across
|
|
302
|
+
// pages. Clamped to [0, max] so over-scroll stops at the bottom
|
|
303
|
+
// line instead of producing an empty pane.
|
|
304
|
+
if (key.pageUp) {
|
|
305
|
+
setScrollOffset((o) => Math.max(0, o - Math.max(1, paneHeight - 1)));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (key.pageDown) {
|
|
309
|
+
setScrollOffset((o) => {
|
|
310
|
+
const maxOffset = Math.max(0, activeLines.length - paneHeight);
|
|
311
|
+
return Math.min(maxOffset, o + Math.max(1, paneHeight - 1));
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// Capital A / R = bulk verdict. Lowercase a / r = per-file. The
|
|
316
|
+
// order matters: Ink delivers SHIFTed keys as the upper-case
|
|
317
|
+
// glyph in `input`, so we can match с a direct equality.
|
|
318
|
+
if (input === 'A') {
|
|
319
|
+
setAllVerdicts('approved');
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (input === 'R') {
|
|
323
|
+
setAllVerdicts('rejected');
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (input === 'a') {
|
|
327
|
+
setVerdictAt(cursor, 'approved');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (input === 'r') {
|
|
331
|
+
setVerdictAt(cursor, 'rejected');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
}, { isActive: props.inert !== true });
|
|
335
|
+
const approvedCount = verdicts.filter((v) => v === 'approved').length;
|
|
336
|
+
const rejectedCount = verdicts.filter((v) => v === 'rejected').length;
|
|
337
|
+
const pendingCount = verdicts.filter((v) => v === 'pending').length;
|
|
338
|
+
const total = entries.length;
|
|
339
|
+
// Diff pane viewport: a window of `paneHeight` lines starting at
|
|
340
|
+
// scrollOffset. Empty entries render с a placeholder so the operator
|
|
341
|
+
// sees the file is intentionally empty (vs the modal being broken).
|
|
342
|
+
const visibleLines = activeLines.slice(scrollOffset, scrollOffset + paneHeight);
|
|
343
|
+
const diffPaneTitle = activeEntry
|
|
344
|
+
? `Diff: ${truncateMiddle(activeEntry.path, MULTI_FILE_DIFF_PATH_CAP)}`
|
|
345
|
+
: 'Diff';
|
|
346
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: '? ' }), _jsx(Text, { bold: true, children: `Multi-file diff review (${total} ${total === 1 ? 'file' : 'files'})` })] }), _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, marginRight: 1, minWidth: MULTI_FILE_DIFF_LEFT_WIDTH, children: [_jsx(Box, { children: _jsx(Text, { bold: true, dimColor: true, children: 'Files' }) }), entries.map((entry, idx) => {
|
|
347
|
+
const isHighlighted = idx === cursor;
|
|
348
|
+
const verdict = verdicts[idx] ?? 'pending';
|
|
349
|
+
// Badge: ✓ approved (green), ✗ rejected (red), • pending (dim).
|
|
350
|
+
// The badge sits BEFORE the cursor arrow so the operator can
|
|
351
|
+
// skim status without scanning the cursor column.
|
|
352
|
+
let badgeGlyph = '•';
|
|
353
|
+
let badgeColor;
|
|
354
|
+
let badgeDim = true;
|
|
355
|
+
if (verdict === 'approved') {
|
|
356
|
+
badgeGlyph = '✓';
|
|
357
|
+
badgeColor = 'green';
|
|
358
|
+
badgeDim = false;
|
|
359
|
+
}
|
|
360
|
+
else if (verdict === 'rejected') {
|
|
361
|
+
badgeGlyph = '✗';
|
|
362
|
+
badgeColor = 'red';
|
|
363
|
+
badgeDim = false;
|
|
364
|
+
}
|
|
365
|
+
// Path budget = left width minus borders, cursor arrow (2),
|
|
366
|
+
// badge + space (2), padding (2). Defensive floor at 8 chars.
|
|
367
|
+
const pathBudget = Math.max(8, MULTI_FILE_DIFF_LEFT_WIDTH - 4 - 2 - 2);
|
|
368
|
+
return (_jsxs(Box, { children: [_jsx(Text, { color: isHighlighted ? 'cyan' : undefined, bold: isHighlighted, children: isHighlighted ? '▸ ' : ' ' }), _jsx(Text, { color: badgeColor, dimColor: badgeDim, bold: !badgeDim, children: `${badgeGlyph} ` }), _jsx(Text, { color: isHighlighted ? 'cyan' : undefined, bold: isHighlighted, children: truncateMiddle(entry.path, pathBudget) }), entry.hint !== undefined && entry.hint.length > 0 ? (_jsx(Text, { dimColor: true, italic: true, children: ` (${entry.hint})` })) : null] }, `file-${idx}-${entry.path}`));
|
|
369
|
+
})] }), _jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, flexGrow: 1, children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, dimColor: true, children: diffPaneTitle }), activeLines.length > paneHeight ? (_jsx(Text, { dimColor: true, children: ` · lines ${scrollOffset + 1}-${Math.min(scrollOffset + paneHeight, activeLines.length)}/${activeLines.length}` })) : null] }), visibleLines.length === 0 ? (_jsx(Text, { dimColor: true, italic: true, children: '(empty diff)' })) : (visibleLines.map((line, idx) => {
|
|
370
|
+
const kind = classifyDiffLine(line);
|
|
371
|
+
const rp = renderProps(kind);
|
|
372
|
+
return (_jsx(Text, { color: rp.color, bold: rp.bold, dimColor: rp.dimColor, children: line.length > 0 ? line : ' ' }, `diff-${cursor}-${scrollOffset + idx}`));
|
|
373
|
+
}))] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: "green", children: `${approvedCount}/${total} approved` }), _jsx(Text, { dimColor: true, children: ' · ' }), _jsx(Text, { color: "red", children: `${rejectedCount}/${total} rejected` }), _jsx(Text, { dimColor: true, children: ' · ' }), _jsx(Text, { children: `${pendingCount} remaining` }), _jsx(Text, { dimColor: true, children: ' · Enter when done' })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: '[↑↓] navigate · [a] approve · [r] reject · [A] all · [R] none · [PgUp/PgDn] scroll · [Esc] cancel' }) })] })] }));
|
|
374
|
+
}
|
|
375
|
+
//# sourceMappingURL=multi-file-diff-approval.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.91",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"which": "^6.0.0",
|
|
64
64
|
"zod": "^3.23.0",
|
|
65
65
|
"@pugi/personas": "0.1.2",
|
|
66
|
-
"@pugi/sdk": "0.1.0-beta.
|
|
66
|
+
"@pugi/sdk": "0.1.0-beta.91"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@types/node": "^22.0.0",
|