@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
  }
@@ -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.90');
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
@@ -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.90",
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.90"
66
+ "@pugi/sdk": "0.1.0-beta.91"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@types/node": "^22.0.0",