@agentworkforce/cli 0.18.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/dist/cli.d.ts +43 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +1056 -59
- package/dist/cli.js.map +1 -1
- package/dist/cli.test.js +303 -1
- package/dist/cli.test.js.map +1 -1
- package/dist/launch-metadata.d.ts +8 -1
- package/dist/launch-metadata.d.ts.map +1 -1
- package/dist/launch-metadata.js +6 -2
- package/dist/launch-metadata.js.map +1 -1
- package/dist/local-personas.d.ts +8 -1
- package/dist/local-personas.d.ts.map +1 -1
- package/dist/local-personas.js +11 -4
- package/dist/local-personas.js.map +1 -1
- package/package.json +5 -5
package/dist/cli.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn, spawnSync } from 'node:child_process';
|
|
3
3
|
import { randomBytes } from 'node:crypto';
|
|
4
|
-
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { constants, homedir, tmpdir } from 'node:os';
|
|
6
6
|
import { dirname, isAbsolute, join, resolve as resolvePath } from 'node:path';
|
|
7
7
|
import { pathToFileURL } from 'node:url';
|
|
8
|
-
import { HARNESS_VALUES, materializeSkills, PERSONA_TAGS, PERSONA_TIERS,
|
|
9
|
-
import {
|
|
8
|
+
import { buildCleanupArtifacts, buildInstallArtifacts, buildInteractiveSpec, buildNonInteractiveSpec, detectHarnesses, formatDropWarnings, HARNESS_VALUES, materializeSkills, MissingPersonaInputError, PERSONA_TAGS, PERSONA_TIERS, renderPersonaInputs, resolveMcpServersLenient, resolvePersonaInputs, resolveSidecar, resolveStringMapLenient } from '@agentworkforce/persona-kit';
|
|
9
|
+
import { listBuiltInPersonas, personaCatalog, routingProfiles } from '@agentworkforce/workload-router';
|
|
10
10
|
import { createMount, readAgentDotfiles } from '@relayfile/local-mount';
|
|
11
11
|
import ora from 'ora';
|
|
12
12
|
import { startLaunchMetadataRecording } from './launch-metadata.js';
|
|
@@ -332,28 +332,45 @@ function subprocessExitCode(res) {
|
|
|
332
332
|
* the buffered output is dumped after spinner.fail so the user sees what
|
|
333
333
|
* actually broke. stdin is ignored — the install commands don't prompt.
|
|
334
334
|
*
|
|
335
|
+
* Uses async `spawn` (not `spawnSync`) because ora's frame redraw runs on a
|
|
336
|
+
* setInterval — `spawnSync` blocks the event loop for the duration of the
|
|
337
|
+
* install, freezing the spinner on its first frame.
|
|
338
|
+
*
|
|
335
339
|
* The spinner text stays "Installing skills…" while running; the longer
|
|
336
340
|
* `label` (which includes target paths and skill ids) is shown on
|
|
337
341
|
* success/failure so the verbose detail is still discoverable in logs.
|
|
338
342
|
*/
|
|
339
|
-
function runInstallWithSpinner(command, label, cwd) {
|
|
343
|
+
async function runInstallWithSpinner(command, label, cwd) {
|
|
340
344
|
const [bin, ...args] = command;
|
|
341
345
|
if (!bin)
|
|
342
346
|
return { code: 0, output: '' };
|
|
343
347
|
const spinner = ora({ text: 'Installing skills…', stream: process.stderr }).start();
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
348
|
+
// Async spawn (not spawnSync) so ora's frame timer can fire during the
|
|
349
|
+
// install — spawnSync blocks the event loop and freezes the spinner on
|
|
350
|
+
// its first frame.
|
|
351
|
+
const { code, output } = await new Promise((resolve) => {
|
|
352
|
+
const child = spawn(bin, args, {
|
|
353
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
354
|
+
shell: false,
|
|
355
|
+
...(cwd ? { cwd } : {})
|
|
356
|
+
});
|
|
357
|
+
let buffered = '';
|
|
358
|
+
child.stdout?.setEncoding('utf8');
|
|
359
|
+
child.stderr?.setEncoding('utf8');
|
|
360
|
+
child.stdout?.on('data', (chunk) => {
|
|
361
|
+
buffered += chunk;
|
|
362
|
+
});
|
|
363
|
+
child.stderr?.on('data', (chunk) => {
|
|
364
|
+
buffered += chunk;
|
|
365
|
+
});
|
|
366
|
+
child.on('error', (err) => {
|
|
367
|
+
resolve({ code: 1, output: `${buffered}${err.message}\n` });
|
|
368
|
+
});
|
|
369
|
+
child.on('close', (status, signal) => {
|
|
370
|
+
const exit = typeof status === 'number' ? status : signal ? signalExitCode(signal) : 1;
|
|
371
|
+
resolve({ code: exit, output: buffered });
|
|
372
|
+
});
|
|
354
373
|
});
|
|
355
|
-
const output = `${res.stdout ?? ''}${res.stderr ?? ''}`;
|
|
356
|
-
const code = subprocessExitCode(res);
|
|
357
374
|
if (code === 0) {
|
|
358
375
|
spinner.succeed(label);
|
|
359
376
|
}
|
|
@@ -364,13 +381,13 @@ function runInstallWithSpinner(command, label, cwd) {
|
|
|
364
381
|
}
|
|
365
382
|
return { code, output };
|
|
366
383
|
}
|
|
367
|
-
function runInstall(command, label, cwd) {
|
|
384
|
+
async function runInstall(command, label, cwd) {
|
|
368
385
|
const [bin] = command;
|
|
369
386
|
if (!bin)
|
|
370
387
|
return;
|
|
371
388
|
// runInstallWithSpinner already prints the failure line via spinner.fail;
|
|
372
389
|
// the previous extra "${label} failed … Aborting." write would duplicate it.
|
|
373
|
-
const { code } = runInstallWithSpinner(command, label, cwd);
|
|
390
|
+
const { code } = await runInstallWithSpinner(command, label, cwd);
|
|
374
391
|
if (code !== 0)
|
|
375
392
|
process.exit(code);
|
|
376
393
|
}
|
|
@@ -392,15 +409,27 @@ class InstallCommandError extends Error {
|
|
|
392
409
|
* Used inside the mount branch's onBeforeLaunch step so mount teardown runs
|
|
393
410
|
* before the error surfaces.
|
|
394
411
|
*/
|
|
395
|
-
function runInstallOrThrow(command, label, cwd) {
|
|
412
|
+
async function runInstallOrThrow(command, label, cwd) {
|
|
396
413
|
const [bin] = command;
|
|
397
414
|
if (!bin)
|
|
398
415
|
return;
|
|
399
|
-
const { code } = runInstallWithSpinner(command, label, cwd);
|
|
416
|
+
const { code } = await runInstallWithSpinner(command, label, cwd);
|
|
400
417
|
if (code !== 0) {
|
|
401
418
|
throw new InstallCommandError(label, code);
|
|
402
419
|
}
|
|
403
420
|
}
|
|
421
|
+
function buildInstallContext(selection, options = {}) {
|
|
422
|
+
const plan = materializeSkills(selection.skills, selection.runtime.harness, options.installRoot !== undefined ? { installRoot: options.installRoot } : {});
|
|
423
|
+
const { installCommand, installCommandString } = buildInstallArtifacts(plan);
|
|
424
|
+
const { cleanupCommand, cleanupCommandString } = buildCleanupArtifacts(plan);
|
|
425
|
+
return {
|
|
426
|
+
plan,
|
|
427
|
+
command: installCommand,
|
|
428
|
+
commandString: installCommandString,
|
|
429
|
+
cleanupCommand,
|
|
430
|
+
cleanupCommandString
|
|
431
|
+
};
|
|
432
|
+
}
|
|
404
433
|
function runCleanup(command, commandString) {
|
|
405
434
|
if (commandString === ':')
|
|
406
435
|
return;
|
|
@@ -458,11 +487,11 @@ function sessionMountDir(sessionRoot) {
|
|
|
458
487
|
* launching opencode without a persona-specific agent selection.
|
|
459
488
|
*
|
|
460
489
|
* Strips all occurrences rather than just the first — the current producer
|
|
461
|
-
* (
|
|
462
|
-
* are equivalent today, but "remove all" is idempotent and safer if
|
|
463
|
-
* caller ever appends a second `--agent` for any reason. A trailing
|
|
464
|
-
* with no following value is preserved so the malformed argv
|
|
465
|
-
* harness rather than getting silently swallowed here.
|
|
490
|
+
* (the opencode branch in persona-kit) emits exactly one pair, so both
|
|
491
|
+
* behaviors are equivalent today, but "remove all" is idempotent and safer if
|
|
492
|
+
* a future caller ever appends a second `--agent` for any reason. A trailing
|
|
493
|
+
* `--agent` with no following value is preserved so the malformed argv
|
|
494
|
+
* surfaces at the harness rather than getting silently swallowed here.
|
|
466
495
|
*/
|
|
467
496
|
export function stripAgentFlag(args) {
|
|
468
497
|
const out = [];
|
|
@@ -789,7 +818,7 @@ function runDryRun(selection) {
|
|
|
789
818
|
return 1;
|
|
790
819
|
}
|
|
791
820
|
process.stderr.write(`✓ sidecar: ${sidecarLookup.sidecar ? sidecarLookup.sidecar.mountFile : '(none)'}\n`);
|
|
792
|
-
// Check 2:
|
|
821
|
+
// Check 2: persona-kit translation. buildInteractiveSpec validates
|
|
793
822
|
// permissions shape, mcpServers shape, and required runtime fields.
|
|
794
823
|
// We resolve env + mcp leniently (same as the live launch path) so
|
|
795
824
|
// the spec call sees the same inputs it would at runtime.
|
|
@@ -915,8 +944,7 @@ async function runInteractive(selection, options) {
|
|
|
915
944
|
const installRoot = sessionRoot && runtime.harness === 'claude'
|
|
916
945
|
? sessionInstallRoot(sessionRoot)
|
|
917
946
|
: undefined;
|
|
918
|
-
const
|
|
919
|
-
const { install } = ctx;
|
|
947
|
+
const install = buildInstallContext(effectiveSelection, installRoot !== undefined ? { installRoot } : {});
|
|
920
948
|
process.stderr.write(`→ ${personaId} [${tier}] via ${runtime.harness} (${runtime.model})\n`);
|
|
921
949
|
const startLaunchMetadataForLaunch = (cwd = process.cwd()) => startLaunchMetadataRecording({
|
|
922
950
|
selection: effectiveSelection,
|
|
@@ -949,7 +977,7 @@ async function runInteractive(selection, options) {
|
|
|
949
977
|
// `onBeforeLaunch` below instead of pre-running here.
|
|
950
978
|
const deferInstallToMount = useClean && runtime.harness !== 'claude' && install.commandString !== ':';
|
|
951
979
|
if (install.commandString !== ':' && !deferInstallToMount) {
|
|
952
|
-
runInstall(install.command, installLabel);
|
|
980
|
+
await runInstall(install.command, installLabel);
|
|
953
981
|
}
|
|
954
982
|
const spec = buildInteractiveSpec({
|
|
955
983
|
harness: runtime.harness,
|
|
@@ -1039,32 +1067,61 @@ async function runInteractive(selection, options) {
|
|
|
1039
1067
|
mount: effectiveSelection.mount,
|
|
1040
1068
|
configFilePaths: spec.configFiles.map((file) => file.path)
|
|
1041
1069
|
});
|
|
1042
|
-
|
|
1070
|
+
// Setup spinner covers createMount + git-config + (optional) in-mount
|
|
1071
|
+
// install + config-file writes + autosync start, so the multi-second
|
|
1072
|
+
// pause before the harness child appears is visibly live. createMount
|
|
1073
|
+
// is async in @relayfile/local-mount ≥0.7.0, which yields between
|
|
1074
|
+
// directory entries — so this spinner actually animates instead of
|
|
1075
|
+
// freezing on its first frame.
|
|
1076
|
+
let setupSpinner = ora({
|
|
1077
|
+
text: `Setting up sandbox mount → ${mountDir}…`,
|
|
1078
|
+
stream: process.stderr
|
|
1079
|
+
}).start();
|
|
1043
1080
|
// Inline mount lifecycle (formerly delegated to launchOnMount) so we can
|
|
1044
1081
|
// surface a spinner the moment the child exits — not just when the user
|
|
1045
1082
|
// presses Ctrl-C. The sync-back walks both trees and can take several
|
|
1046
1083
|
// seconds on a large repo; without an indicator, exiting the persona via
|
|
1047
1084
|
// /exit looked like a hang.
|
|
1048
1085
|
//
|
|
1049
|
-
// SIGINT semantics:
|
|
1050
|
-
// •
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1086
|
+
// SIGINT semantics — three phases:
|
|
1087
|
+
// • Pre-launch (setup): tear down the setup spinner, rm the session
|
|
1088
|
+
// dir, and exit(130). We must handle this ourselves because
|
|
1089
|
+
// registering any 'SIGINT' listener suppresses Node's default
|
|
1090
|
+
// exit-on-SIGINT, and createMount is now async (relayfile 0.7+) so
|
|
1091
|
+
// the handler actually fires during mount setup.
|
|
1092
|
+
// • Child running: Ctrl-C reaches the harness directly via the
|
|
1093
|
+
// controlling TTY's foreground process group (the child is spawned
|
|
1094
|
+
// with `stdio: 'inherit'` and inherits the parent's pgid). We
|
|
1095
|
+
// no-op purely to suppress Node's default exit — forwarding via
|
|
1096
|
+
// child.kill('SIGINT') would deliver a *second* SIGINT and break
|
|
1097
|
+
// harnesses that escalate on repeated interrupts (e.g. claude
|
|
1098
|
+
// treats 1st = cancel, 2nd = quit).
|
|
1099
|
+
// • Syncing (post-child): 1st press aborts the shutdownSignal
|
|
1100
|
+
// (relayfile then skips autosync's draining reconcile and returns
|
|
1101
|
+
// the partial count from the final syncBack). 2nd press hard-exits
|
|
1102
|
+
// and rms the session dir so no mount is left behind.
|
|
1061
1103
|
const shutdownController = new AbortController();
|
|
1062
1104
|
let syncSpinner;
|
|
1063
1105
|
let isSyncing = false;
|
|
1106
|
+
let childSpawned = false;
|
|
1064
1107
|
let abortPresses = 0;
|
|
1065
1108
|
const sigintHandler = () => {
|
|
1066
|
-
if (!isSyncing)
|
|
1067
|
-
|
|
1109
|
+
if (!isSyncing) {
|
|
1110
|
+
if (childSpawned)
|
|
1111
|
+
return;
|
|
1112
|
+
// Pre-launch teardown.
|
|
1113
|
+
if (setupSpinner) {
|
|
1114
|
+
setupSpinner.fail('Sandbox mount setup interrupted (Ctrl-C)');
|
|
1115
|
+
setupSpinner = undefined;
|
|
1116
|
+
}
|
|
1117
|
+
try {
|
|
1118
|
+
rmSync(sessionRoot, { recursive: true, force: true });
|
|
1119
|
+
}
|
|
1120
|
+
catch {
|
|
1121
|
+
/* swallow — we're exiting anyway */
|
|
1122
|
+
}
|
|
1123
|
+
process.exit(130);
|
|
1124
|
+
}
|
|
1068
1125
|
abortPresses += 1;
|
|
1069
1126
|
if (abortPresses === 1) {
|
|
1070
1127
|
if (syncSpinner) {
|
|
@@ -1090,27 +1147,35 @@ async function runInteractive(selection, options) {
|
|
|
1090
1147
|
process.exit(130);
|
|
1091
1148
|
};
|
|
1092
1149
|
process.on('SIGINT', sigintHandler);
|
|
1093
|
-
|
|
1094
|
-
ignoredPatterns: [...ignoredPatterns],
|
|
1095
|
-
readonlyPatterns: [...readonlyPatterns],
|
|
1096
|
-
excludeDirs: [],
|
|
1097
|
-
agentName: personaId,
|
|
1098
|
-
// Pull `.git` into the mount so git commands work inside the sandbox.
|
|
1099
|
-
// relayfile treats this as one-way project→mount: host-side `.git`
|
|
1100
|
-
// changes flow in, mount-side commits/refs stay sandboxed and are
|
|
1101
|
-
// discarded on cleanup. The agent must `git push` to persist work.
|
|
1102
|
-
includeGit: true
|
|
1103
|
-
});
|
|
1150
|
+
let handle;
|
|
1104
1151
|
let autoSync;
|
|
1105
1152
|
let exitCode = 0;
|
|
1106
1153
|
try {
|
|
1154
|
+
// createMount inside the try so its initial-mirror failures fall into
|
|
1155
|
+
// the catch path and clean up the setup spinner.
|
|
1156
|
+
handle = await createMount(process.cwd(), mountDir, {
|
|
1157
|
+
ignoredPatterns: [...ignoredPatterns],
|
|
1158
|
+
readonlyPatterns: [...readonlyPatterns],
|
|
1159
|
+
excludeDirs: [],
|
|
1160
|
+
agentName: personaId,
|
|
1161
|
+
// Pull `.git` into the mount so git commands work inside the
|
|
1162
|
+
// sandbox. relayfile treats this as one-way project→mount: host-side
|
|
1163
|
+
// `.git` changes flow in, mount-side commits/refs stay sandboxed and
|
|
1164
|
+
// are discarded on cleanup. The agent must `git push` to persist
|
|
1165
|
+
// work.
|
|
1166
|
+
includeGit: true
|
|
1167
|
+
});
|
|
1107
1168
|
// Run before install / configFile writes so the freshly written files
|
|
1108
1169
|
// (e.g. `.opencode/`, `opencode.json`) aren't yet present when we run
|
|
1109
1170
|
// `git ls-files` to pick skip-worktree candidates — we don't need them
|
|
1110
1171
|
// flagged in the index, just hidden via the `.git/info/exclude` block.
|
|
1111
1172
|
configureGitForMount(handle.mountDir, ignoredPatterns);
|
|
1112
1173
|
if (deferInstallToMount) {
|
|
1113
|
-
|
|
1174
|
+
// Hand the line off to the install spinner so the two don't fight
|
|
1175
|
+
// for the same stream, then resume the setup spinner afterwards.
|
|
1176
|
+
setupSpinner?.stop();
|
|
1177
|
+
await runInstallOrThrow(install.command, installLabel, handle.mountDir);
|
|
1178
|
+
setupSpinner?.start();
|
|
1114
1179
|
}
|
|
1115
1180
|
for (const file of spec.configFiles) {
|
|
1116
1181
|
assertSafeRelativePath(file.path);
|
|
@@ -1123,11 +1188,30 @@ async function runInteractive(selection, options) {
|
|
|
1123
1188
|
writeFileSync(join(handle.mountDir, resolvedSidecar.mountFile), body, 'utf8');
|
|
1124
1189
|
}
|
|
1125
1190
|
launchMetadata = await startLaunchMetadataForLaunch(handle.mountDir);
|
|
1191
|
+
if (options.capture) {
|
|
1192
|
+
options.capture.stampEnrichment = { ...launchMetadata.metadata };
|
|
1193
|
+
options.capture.stampingEnabled = launchMetadata.enabled;
|
|
1194
|
+
}
|
|
1126
1195
|
autoSync = handle.startAutoSync();
|
|
1196
|
+
// Stop the setup spinner before spawning the child — the child
|
|
1197
|
+
// inherits stdio and would otherwise interleave its output with
|
|
1198
|
+
// spinner frames.
|
|
1199
|
+
setupSpinner?.succeed(`Sandbox mount ready → ${mountDir}`);
|
|
1200
|
+
setupSpinner = undefined;
|
|
1127
1201
|
const childEnv = resolvedEnv ? { ...process.env, ...resolvedEnv } : process.env;
|
|
1202
|
+
const childCwd = handle.mountDir;
|
|
1203
|
+
if (options.capture) {
|
|
1204
|
+
options.capture.sessionCwd = childCwd;
|
|
1205
|
+
options.capture.harness = runtime.harness;
|
|
1206
|
+
options.capture.startedAt = Date.now();
|
|
1207
|
+
}
|
|
1208
|
+
// Flip the SIGINT phase flag before spawn so a Ctrl-C arriving during
|
|
1209
|
+
// the child's lifetime is treated as "child has the TTY" (no-op),
|
|
1210
|
+
// not as pre-launch teardown.
|
|
1211
|
+
childSpawned = true;
|
|
1128
1212
|
exitCode = await new Promise((resolve, reject) => {
|
|
1129
1213
|
const child = spawn(spec.bin, finalArgs, {
|
|
1130
|
-
cwd:
|
|
1214
|
+
cwd: childCwd,
|
|
1131
1215
|
stdio: 'inherit',
|
|
1132
1216
|
env: childEnv
|
|
1133
1217
|
});
|
|
@@ -1169,6 +1253,10 @@ async function runInteractive(selection, options) {
|
|
|
1169
1253
|
return exitCode;
|
|
1170
1254
|
}
|
|
1171
1255
|
catch (err) {
|
|
1256
|
+
if (setupSpinner) {
|
|
1257
|
+
setupSpinner.fail('Sandbox mount setup failed');
|
|
1258
|
+
setupSpinner = undefined;
|
|
1259
|
+
}
|
|
1172
1260
|
if (syncSpinner) {
|
|
1173
1261
|
syncSpinner.fail('Sync did not complete');
|
|
1174
1262
|
syncSpinner = undefined;
|
|
@@ -1190,6 +1278,10 @@ async function runInteractive(selection, options) {
|
|
|
1190
1278
|
return 1;
|
|
1191
1279
|
}
|
|
1192
1280
|
finally {
|
|
1281
|
+
if (setupSpinner) {
|
|
1282
|
+
setupSpinner.stop();
|
|
1283
|
+
setupSpinner = undefined;
|
|
1284
|
+
}
|
|
1193
1285
|
if (syncSpinner) {
|
|
1194
1286
|
syncSpinner.stop();
|
|
1195
1287
|
syncSpinner = undefined;
|
|
@@ -1204,7 +1296,7 @@ async function runInteractive(selection, options) {
|
|
|
1204
1296
|
/* ignore — we're tearing down anyway */
|
|
1205
1297
|
}
|
|
1206
1298
|
}
|
|
1207
|
-
handle
|
|
1299
|
+
handle?.cleanup();
|
|
1208
1300
|
await launchMetadata?.stop();
|
|
1209
1301
|
process.removeListener('SIGINT', sigintHandler);
|
|
1210
1302
|
// When the install ran inside the mount, its cleanup paths are
|
|
@@ -1220,6 +1312,13 @@ async function runInteractive(selection, options) {
|
|
|
1220
1312
|
}
|
|
1221
1313
|
}
|
|
1222
1314
|
const launchMetadata = await startLaunchMetadataForLaunch();
|
|
1315
|
+
if (options.capture) {
|
|
1316
|
+
options.capture.sessionCwd = process.cwd();
|
|
1317
|
+
options.capture.harness = runtime.harness;
|
|
1318
|
+
options.capture.startedAt = Date.now();
|
|
1319
|
+
options.capture.stampEnrichment = { ...launchMetadata.metadata };
|
|
1320
|
+
options.capture.stampingEnabled = launchMetadata.enabled;
|
|
1321
|
+
}
|
|
1223
1322
|
return new Promise((resolve) => {
|
|
1224
1323
|
let settled = false;
|
|
1225
1324
|
const finish = (code) => {
|
|
@@ -2042,14 +2141,912 @@ async function runAgentSelector(selector, flags, inputValues) {
|
|
|
2042
2141
|
const code = runDryRun(selection);
|
|
2043
2142
|
process.exit(code);
|
|
2044
2143
|
}
|
|
2144
|
+
const capture = {};
|
|
2045
2145
|
const code = await runInteractive(selection, {
|
|
2046
2146
|
installInRepo: flags.installInRepo,
|
|
2047
2147
|
noLaunchMetadata: flags.noLaunchMetadata,
|
|
2048
2148
|
personaSpec: target.spec,
|
|
2049
|
-
personaSource: target.source
|
|
2149
|
+
personaSource: target.source,
|
|
2150
|
+
capture
|
|
2151
|
+
});
|
|
2152
|
+
// Post-session learnings prompt: only for local personas (built-in
|
|
2153
|
+
// catalog and pack personas are read-only here), and only when stdin
|
|
2154
|
+
// is a TTY so we can read y/N. Improver failures never affect the
|
|
2155
|
+
// user-facing exit code — the original session's exit is what matters.
|
|
2156
|
+
await maybeOfferLearningsImprover({
|
|
2157
|
+
target,
|
|
2158
|
+
capture,
|
|
2159
|
+
flags
|
|
2050
2160
|
});
|
|
2051
2161
|
process.exit(code);
|
|
2052
2162
|
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Decide whether to offer post-session auto-improvement, run the improver,
|
|
2165
|
+
* walk the proposals interactively, and apply accepted patches. Silently
|
|
2166
|
+
* skips the prompt when the persona is built-in or stdin is not a TTY.
|
|
2167
|
+
*
|
|
2168
|
+
* Failures (improver crash, malformed proposals JSON, unwriteable persona
|
|
2169
|
+
* file) are surfaced as warnings on stderr; they never throw or change
|
|
2170
|
+
* the original session's exit code. The user already saw their session
|
|
2171
|
+
* complete — a flaky meta-step shouldn't mask that.
|
|
2172
|
+
*/
|
|
2173
|
+
async function maybeOfferLearningsImprover(ctx) {
|
|
2174
|
+
if (ctx.target.kind !== 'local')
|
|
2175
|
+
return;
|
|
2176
|
+
if (ctx.target.source === 'library')
|
|
2177
|
+
return;
|
|
2178
|
+
const personaFilePath = local.paths.get(ctx.target.spec.id);
|
|
2179
|
+
if (!personaFilePath) {
|
|
2180
|
+
// No on-disk path means we can't apply patches even if the user agrees.
|
|
2181
|
+
// Skip silently — local-personas would have warned at load time.
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
if (!process.stdin.isTTY || !process.stderr.isTTY)
|
|
2185
|
+
return;
|
|
2186
|
+
const personaId = ctx.target.spec.id;
|
|
2187
|
+
const wantsImprover = promptYesNoSync(`\nAuto-improve "${personaId}" from this session? [y/N] `);
|
|
2188
|
+
if (!wantsImprover)
|
|
2189
|
+
return;
|
|
2190
|
+
let transcriptPath = '';
|
|
2191
|
+
try {
|
|
2192
|
+
if (ctx.capture.stampingEnabled && ctx.capture.stampEnrichment) {
|
|
2193
|
+
transcriptPath =
|
|
2194
|
+
(await findSessionTranscriptViaStamps({
|
|
2195
|
+
harness: ctx.capture.harness,
|
|
2196
|
+
sessionCwd: ctx.capture.sessionCwd,
|
|
2197
|
+
enrichment: ctx.capture.stampEnrichment,
|
|
2198
|
+
startedAt: ctx.capture.startedAt
|
|
2199
|
+
})) ?? '';
|
|
2200
|
+
}
|
|
2201
|
+
if (!transcriptPath) {
|
|
2202
|
+
transcriptPath =
|
|
2203
|
+
findSessionTranscriptPath({
|
|
2204
|
+
harness: ctx.capture.harness,
|
|
2205
|
+
sessionCwd: ctx.capture.sessionCwd,
|
|
2206
|
+
startedAt: ctx.capture.startedAt
|
|
2207
|
+
}) ?? '';
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
catch (err) {
|
|
2211
|
+
process.stderr.write(`warning: could not locate session transcript: ${err.message}\n`);
|
|
2212
|
+
}
|
|
2213
|
+
if (!transcriptPath) {
|
|
2214
|
+
process.stderr.write(`note: session transcript not found for harness "${ctx.capture.harness ?? '?'}" — proceeding from persona file alone.\n`);
|
|
2215
|
+
}
|
|
2216
|
+
let proposals;
|
|
2217
|
+
const proposalsTempPath = join(tmpdir(), `agentworkforce-proposals-${randomBytes(6).toString('hex')}.json`);
|
|
2218
|
+
const spinner = ora({
|
|
2219
|
+
text: 'Extracting learnings via persona-improver…',
|
|
2220
|
+
stream: process.stderr
|
|
2221
|
+
}).start();
|
|
2222
|
+
try {
|
|
2223
|
+
proposals = await runPersonaImprover({
|
|
2224
|
+
personaFilePath,
|
|
2225
|
+
transcriptPath,
|
|
2226
|
+
proposalsOutputPath: proposalsTempPath
|
|
2227
|
+
});
|
|
2228
|
+
spinner.succeed(proposals.proposals.length === 0
|
|
2229
|
+
? 'persona-improver: no improvements to propose.'
|
|
2230
|
+
: `persona-improver: found ${proposals.proposals.length} proposed improvement${proposals.proposals.length === 1 ? '' : 's'}.`);
|
|
2231
|
+
}
|
|
2232
|
+
catch (err) {
|
|
2233
|
+
spinner.fail(`persona-improver failed: ${err.message}`);
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
finally {
|
|
2237
|
+
try {
|
|
2238
|
+
rmSync(proposalsTempPath, { force: true });
|
|
2239
|
+
}
|
|
2240
|
+
catch {
|
|
2241
|
+
/* swallow — temp file in $TMPDIR is harmless */
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
if (!proposals || proposals.proposals.length === 0)
|
|
2245
|
+
return;
|
|
2246
|
+
const accepted = walkProposalsInteractive(proposals);
|
|
2247
|
+
if (accepted.length === 0) {
|
|
2248
|
+
process.stderr.write('No improvements applied.\n');
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
try {
|
|
2252
|
+
applyAcceptedPatches(personaFilePath, accepted);
|
|
2253
|
+
process.stderr.write(`✓ Applied ${accepted.length} improvement${accepted.length === 1 ? '' : 's'} to ${personaFilePath}\n`);
|
|
2254
|
+
}
|
|
2255
|
+
catch (err) {
|
|
2256
|
+
process.stderr.write(`warning: failed to write updated persona to ${personaFilePath}: ${err.message}\n`);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Allowlist of dot-paths the improver may rewrite via `op: "set"`. Mirrors
|
|
2261
|
+
* the patch grammar advertised in the persona's AGENTS.md — anything else
|
|
2262
|
+
* is a defense-in-depth reject (the persona's anti-goals already say "no
|
|
2263
|
+
* changes to id/intent/harness/model/permissions", but we don't trust the
|
|
2264
|
+
* model alone for a flow that mutates the user's persona file in place).
|
|
2265
|
+
*/
|
|
2266
|
+
const ALLOWED_SET_PATHS = [
|
|
2267
|
+
'description',
|
|
2268
|
+
'agentsMdContent',
|
|
2269
|
+
'claudeMdContent',
|
|
2270
|
+
'tags',
|
|
2271
|
+
'tiers.best.systemPrompt',
|
|
2272
|
+
'tiers.best-value.systemPrompt',
|
|
2273
|
+
'tiers.minimum.systemPrompt'
|
|
2274
|
+
];
|
|
2275
|
+
/**
|
|
2276
|
+
* Allowlist of dot-paths the improver may rewrite via `op: "append"`.
|
|
2277
|
+
* Currently just `skills` — the only array the AGENTS.md grammar exposes
|
|
2278
|
+
* for append-style mutation.
|
|
2279
|
+
*/
|
|
2280
|
+
const ALLOWED_APPEND_PATHS = ['skills'];
|
|
2281
|
+
/**
|
|
2282
|
+
* Reserved JSON-object keys that must never appear as a path segment —
|
|
2283
|
+
* setting them would either pollute the prototype chain (`__proto__`,
|
|
2284
|
+
* `constructor`, `prototype`) for the running process or rewrite a
|
|
2285
|
+
* built-in property that downstream code relies on. Belt-and-braces
|
|
2286
|
+
* alongside the path allowlist; even an `inputs.<NAME>` segment can't
|
|
2287
|
+
* smuggle one of these in.
|
|
2288
|
+
*/
|
|
2289
|
+
const FORBIDDEN_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
2290
|
+
function assertSafePathSegments(path, context) {
|
|
2291
|
+
const segments = path.split('.').filter((s) => s.length > 0);
|
|
2292
|
+
if (segments.length === 0) {
|
|
2293
|
+
throw new Error(`${context}: path is empty`);
|
|
2294
|
+
}
|
|
2295
|
+
for (const seg of segments) {
|
|
2296
|
+
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) {
|
|
2297
|
+
throw new Error(`${context}: path "${path}" contains forbidden segment "${seg}"`);
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
return segments;
|
|
2301
|
+
}
|
|
2302
|
+
/**
|
|
2303
|
+
* Validate one improver patch against the path/op allowlist + the
|
|
2304
|
+
* prototype-segment guard. Throws a descriptive error rejected at parse
|
|
2305
|
+
* time so the CLI never offers a disallowed proposal to the user.
|
|
2306
|
+
*
|
|
2307
|
+
* Allowed set paths: see ALLOWED_SET_PATHS, plus any `inputs.<NAME>`
|
|
2308
|
+
* (NAME must be env-style, matching the persona-input naming rule).
|
|
2309
|
+
* Allowed append paths: see ALLOWED_APPEND_PATHS.
|
|
2310
|
+
*/
|
|
2311
|
+
function assertAllowedImproverPatch(patch, context) {
|
|
2312
|
+
assertSafePathSegments(patch.path, context);
|
|
2313
|
+
if (patch.op === 'set') {
|
|
2314
|
+
if (ALLOWED_SET_PATHS.includes(patch.path))
|
|
2315
|
+
return;
|
|
2316
|
+
if (patch.path.startsWith('inputs.')) {
|
|
2317
|
+
const after = patch.path.slice('inputs.'.length);
|
|
2318
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(after)) {
|
|
2319
|
+
throw new Error(`${context}: inputs path "${patch.path}" must use an env-style NAME (got "${after}")`);
|
|
2320
|
+
}
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
throw new Error(`${context}: set path "${patch.path}" is not in the allowlist`);
|
|
2324
|
+
}
|
|
2325
|
+
if (patch.op === 'append') {
|
|
2326
|
+
if (!ALLOWED_APPEND_PATHS.includes(patch.path)) {
|
|
2327
|
+
throw new Error(`${context}: append path "${patch.path}" is not in the allowlist`);
|
|
2328
|
+
}
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
throw new Error(`${context}: unknown patch op "${patch.op}"`);
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
2334
|
+
* Locate the just-ended session's transcript via the burn-stamp ledger.
|
|
2335
|
+
* Authoritative when stamping is wired: `launch-metadata.ts` writes a
|
|
2336
|
+
* pending stamp (with our `personaVersion` enrichment hash) before spawn
|
|
2337
|
+
* and runs `ingest` on a 1s tick + once at stop, so by the time we get
|
|
2338
|
+
* here the ledger already has a row whose `selector.sessionId` is the
|
|
2339
|
+
* harness's own session id. We filter by `persona` + `personaVersion`
|
|
2340
|
+
* (unique per persona spec hash) and `ts` near `startedAt` to avoid
|
|
2341
|
+
* picking up a sibling launch of the same persona, then resolve the
|
|
2342
|
+
* sessionId to a transcript file path per harness.
|
|
2343
|
+
*
|
|
2344
|
+
* Returns undefined when:
|
|
2345
|
+
* - the SDK call fails
|
|
2346
|
+
* - no row matches (ingest hasn't reconciled yet, or stamping is off)
|
|
2347
|
+
* - the resolved sessionId can't be located on disk
|
|
2348
|
+
* Caller falls back to `findSessionTranscriptPath` (cwd-content match).
|
|
2349
|
+
*/
|
|
2350
|
+
async function findSessionTranscriptViaStamps(input) {
|
|
2351
|
+
if (!input.harness || !input.sessionCwd)
|
|
2352
|
+
return undefined;
|
|
2353
|
+
const persona = input.enrichment.persona;
|
|
2354
|
+
const personaVersion = input.enrichment.personaVersion;
|
|
2355
|
+
if (!persona || !personaVersion)
|
|
2356
|
+
return undefined;
|
|
2357
|
+
let sdk;
|
|
2358
|
+
try {
|
|
2359
|
+
sdk = await import('@relayburn/sdk');
|
|
2360
|
+
}
|
|
2361
|
+
catch {
|
|
2362
|
+
return undefined;
|
|
2363
|
+
}
|
|
2364
|
+
if (typeof sdk.exportStamps !== 'function')
|
|
2365
|
+
return undefined;
|
|
2366
|
+
let rows;
|
|
2367
|
+
try {
|
|
2368
|
+
rows = await sdk.exportStamps();
|
|
2369
|
+
}
|
|
2370
|
+
catch {
|
|
2371
|
+
return undefined;
|
|
2372
|
+
}
|
|
2373
|
+
const startedAt = input.startedAt ?? 0;
|
|
2374
|
+
const spawnerPid = input.enrichment.spawnerPid;
|
|
2375
|
+
// Tight window around our session: stamps written before our spawn
|
|
2376
|
+
// (minus tolerance for clock skew) or after the prompt fires (plus
|
|
2377
|
+
// tolerance for ingest latency) can't be ours. The upper bound matters
|
|
2378
|
+
// when a sibling launch of the same persona starts AFTER ours but
|
|
2379
|
+
// before we get here — without it, max-ts wins picks the wrong row.
|
|
2380
|
+
const LOWER_TOLERANCE_MS = 5000;
|
|
2381
|
+
const UPPER_TOLERANCE_MS = 1000;
|
|
2382
|
+
const lowerMs = startedAt - LOWER_TOLERANCE_MS;
|
|
2383
|
+
const upperMs = Date.now() + UPPER_TOLERANCE_MS;
|
|
2384
|
+
let bestSessionId;
|
|
2385
|
+
// Prefer the stamp closest to our spawn time (smallest |ts - startedAt|),
|
|
2386
|
+
// not the most recent. Same-persona concurrent launches can both fall
|
|
2387
|
+
// inside the window; the one launched at our PID/time is the right one.
|
|
2388
|
+
let bestDelta = Number.POSITIVE_INFINITY;
|
|
2389
|
+
let pidMatched = false;
|
|
2390
|
+
for (const row of rows) {
|
|
2391
|
+
if (!row || typeof row !== 'object')
|
|
2392
|
+
continue;
|
|
2393
|
+
const r = row;
|
|
2394
|
+
const sessionId = r.selector?.sessionId;
|
|
2395
|
+
const enrichment = r.enrichment;
|
|
2396
|
+
const ts = r.ts;
|
|
2397
|
+
if (typeof sessionId !== 'string' || !enrichment || typeof ts !== 'string')
|
|
2398
|
+
continue;
|
|
2399
|
+
if (enrichment.persona !== persona)
|
|
2400
|
+
continue;
|
|
2401
|
+
if (enrichment.personaVersion !== personaVersion)
|
|
2402
|
+
continue;
|
|
2403
|
+
const tsMs = Date.parse(ts);
|
|
2404
|
+
if (!Number.isFinite(tsMs))
|
|
2405
|
+
continue;
|
|
2406
|
+
if (tsMs < lowerMs || tsMs > upperMs)
|
|
2407
|
+
continue;
|
|
2408
|
+
// spawnerPid is the strongest discriminator — folded into enrichment
|
|
2409
|
+
// by `buildLaunchMetadata` so it survives stamp ingest. When present
|
|
2410
|
+
// on both sides, treat a mismatch as a hard reject and a match as
|
|
2411
|
+
// sticky: once we've seen a pid-matched row, ignore unmatched ones
|
|
2412
|
+
// even if they're closer in time.
|
|
2413
|
+
const rowPid = enrichment.spawnerPid;
|
|
2414
|
+
if (spawnerPid && typeof rowPid === 'string') {
|
|
2415
|
+
if (rowPid !== spawnerPid)
|
|
2416
|
+
continue;
|
|
2417
|
+
if (!pidMatched) {
|
|
2418
|
+
pidMatched = true;
|
|
2419
|
+
bestDelta = Number.POSITIVE_INFINITY;
|
|
2420
|
+
bestSessionId = undefined;
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
else if (pidMatched) {
|
|
2424
|
+
// We already locked onto pid-matched candidates — skip non-pid rows.
|
|
2425
|
+
continue;
|
|
2426
|
+
}
|
|
2427
|
+
const delta = Math.abs(tsMs - startedAt);
|
|
2428
|
+
if (delta >= bestDelta)
|
|
2429
|
+
continue;
|
|
2430
|
+
bestDelta = delta;
|
|
2431
|
+
bestSessionId = sessionId;
|
|
2432
|
+
}
|
|
2433
|
+
if (!bestSessionId)
|
|
2434
|
+
return undefined;
|
|
2435
|
+
return resolveTranscriptForSessionId(input.harness, input.sessionCwd, bestSessionId);
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* Map a harness session id to its on-disk transcript file. The directory
|
|
2439
|
+
* is harness-conventional, but the filename pattern varies:
|
|
2440
|
+
* • claude → `<sessionId>.jsonl` directly under the cwd-encoded subdir
|
|
2441
|
+
* • codex → `rollout-<ts>-<sessionId>.jsonl` under a date-grouped subdir
|
|
2442
|
+
* • opencode → file or filename containing the sessionId under `<projectHash>/`
|
|
2443
|
+
*
|
|
2444
|
+
* For codex/opencode we scan once and match by filename substring (cheap;
|
|
2445
|
+
* the substring is a UUID-ish so collisions don't happen in practice).
|
|
2446
|
+
*/
|
|
2447
|
+
function resolveTranscriptForSessionId(harness, sessionCwd, sessionId) {
|
|
2448
|
+
const home = homedir();
|
|
2449
|
+
if (harness === 'claude') {
|
|
2450
|
+
const encoded = sessionCwd.replace(/[\\/]+/g, '-');
|
|
2451
|
+
const candidate = join(home, '.claude', 'projects', encoded, `${sessionId}.jsonl`);
|
|
2452
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
2453
|
+
}
|
|
2454
|
+
if (harness === 'codex') {
|
|
2455
|
+
return findFileByNameSubstring(join(home, '.codex', 'sessions'), sessionId, ['.jsonl']);
|
|
2456
|
+
}
|
|
2457
|
+
if (harness === 'opencode') {
|
|
2458
|
+
return findFileByNameSubstring(join(home, '.local', 'share', 'opencode', 'storage', 'session'), sessionId, ['.json']);
|
|
2459
|
+
}
|
|
2460
|
+
return undefined;
|
|
2461
|
+
}
|
|
2462
|
+
function findFileByNameSubstring(dir, needle, extensions) {
|
|
2463
|
+
const wantsExt = (name) => extensions.some((ext) => name.endsWith(ext));
|
|
2464
|
+
const visit = (cur, depth) => {
|
|
2465
|
+
let entries;
|
|
2466
|
+
try {
|
|
2467
|
+
entries = readdirSync(cur, { withFileTypes: true });
|
|
2468
|
+
}
|
|
2469
|
+
catch {
|
|
2470
|
+
return undefined;
|
|
2471
|
+
}
|
|
2472
|
+
for (const entry of entries) {
|
|
2473
|
+
const full = join(cur, entry.name);
|
|
2474
|
+
if (entry.isDirectory()) {
|
|
2475
|
+
if (depth < 3) {
|
|
2476
|
+
const found = visit(full, depth + 1);
|
|
2477
|
+
if (found)
|
|
2478
|
+
return found;
|
|
2479
|
+
}
|
|
2480
|
+
continue;
|
|
2481
|
+
}
|
|
2482
|
+
if (!entry.isFile())
|
|
2483
|
+
continue;
|
|
2484
|
+
if (!wantsExt(entry.name))
|
|
2485
|
+
continue;
|
|
2486
|
+
if (entry.name.includes(needle))
|
|
2487
|
+
return full;
|
|
2488
|
+
}
|
|
2489
|
+
return undefined;
|
|
2490
|
+
};
|
|
2491
|
+
return visit(dir, 0);
|
|
2492
|
+
}
|
|
2493
|
+
/**
|
|
2494
|
+
* Fallback locator when the burn-stamp ledger is unavailable or the
|
|
2495
|
+
* just-ended session hasn't reconciled yet. Walks the harness's
|
|
2496
|
+
* transcript dir and verifies each candidate's embedded cwd matches the
|
|
2497
|
+
* captured session cwd. Every harness embeds the session cwd:
|
|
2498
|
+
* • claude → `~/.claude/projects/<cwd-encoded>/<sessionId>.jsonl` —
|
|
2499
|
+
* each entry carries `"cwd"`. The dir-name encoding
|
|
2500
|
+
* replaces `/` with `-` and is itself a strong filter.
|
|
2501
|
+
* • codex → `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` — first
|
|
2502
|
+
* line is a `session_meta` event with `payload.cwd`.
|
|
2503
|
+
* • opencode → `~/.local/share/opencode/storage/session/<projectHash>/<sessionId>.json`
|
|
2504
|
+
* — top-level `directory` field on the session object.
|
|
2505
|
+
*
|
|
2506
|
+
* For each harness we walk the candidate dir, filter to files with
|
|
2507
|
+
* mtime ≥ sessionStart, and confirm the embedded cwd matches the captured
|
|
2508
|
+
* session cwd. Among matches we pick the most recently mtime'd. The cwd
|
|
2509
|
+
* confirmation makes this robust to concurrent harness sessions — the
|
|
2510
|
+
* caveat that previously applied to codex/opencode (most-recent-mtime
|
|
2511
|
+
* could pick a sibling) goes away when we read the file's own cwd.
|
|
2512
|
+
*
|
|
2513
|
+
* Returns undefined when nothing matches; callers handle gracefully (the
|
|
2514
|
+
* persona-improver accepts an empty transcript path).
|
|
2515
|
+
*/
|
|
2516
|
+
function findSessionTranscriptPath(input) {
|
|
2517
|
+
if (!input.harness || !input.sessionCwd)
|
|
2518
|
+
return undefined;
|
|
2519
|
+
const startedAt = input.startedAt ?? 0;
|
|
2520
|
+
const cwd = input.sessionCwd;
|
|
2521
|
+
const home = homedir();
|
|
2522
|
+
if (input.harness === 'claude') {
|
|
2523
|
+
const encoded = cwd.replace(/[\\/]+/g, '-');
|
|
2524
|
+
const projectDir = join(home, '.claude', 'projects', encoded);
|
|
2525
|
+
// Within the cwd-encoded dir, all candidates already share the cwd —
|
|
2526
|
+
// mtime is enough. We still verify the first-line `cwd` so a stale
|
|
2527
|
+
// dir-name match can't smuggle in a wrong file.
|
|
2528
|
+
return findFreshestMatchingTranscript({
|
|
2529
|
+
dir: projectDir,
|
|
2530
|
+
recursive: false,
|
|
2531
|
+
extensions: ['.jsonl'],
|
|
2532
|
+
sinceMs: startedAt,
|
|
2533
|
+
sessionCwd: cwd,
|
|
2534
|
+
readCwd: readCwdFromClaudeJsonl
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
if (input.harness === 'codex') {
|
|
2538
|
+
return findFreshestMatchingTranscript({
|
|
2539
|
+
dir: join(home, '.codex', 'sessions'),
|
|
2540
|
+
recursive: true,
|
|
2541
|
+
extensions: ['.jsonl'],
|
|
2542
|
+
sinceMs: startedAt,
|
|
2543
|
+
sessionCwd: cwd,
|
|
2544
|
+
readCwd: readCwdFromCodexJsonl
|
|
2545
|
+
});
|
|
2546
|
+
}
|
|
2547
|
+
if (input.harness === 'opencode') {
|
|
2548
|
+
return findFreshestMatchingTranscript({
|
|
2549
|
+
dir: join(home, '.local', 'share', 'opencode', 'storage', 'session'),
|
|
2550
|
+
recursive: true,
|
|
2551
|
+
extensions: ['.json'],
|
|
2552
|
+
sinceMs: startedAt,
|
|
2553
|
+
sessionCwd: cwd,
|
|
2554
|
+
readCwd: readCwdFromOpencodeSession
|
|
2555
|
+
});
|
|
2556
|
+
}
|
|
2557
|
+
return undefined;
|
|
2558
|
+
}
|
|
2559
|
+
/**
|
|
2560
|
+
* Walk a candidate directory and pick the most recently modified file
|
|
2561
|
+
* whose embedded cwd matches `sessionCwd`. Capped at depth 3 in
|
|
2562
|
+
* recursive mode — codex/opencode group by date or project hash, never
|
|
2563
|
+
* deeper. mtime gate eliminates files written before the session and
|
|
2564
|
+
* keeps the scan cheap on large session stores.
|
|
2565
|
+
*/
|
|
2566
|
+
function findFreshestMatchingTranscript(opts) {
|
|
2567
|
+
const wantsExt = (name) => opts.extensions.some((ext) => name.endsWith(ext));
|
|
2568
|
+
let bestPath;
|
|
2569
|
+
let bestMtime = -1;
|
|
2570
|
+
const visit = (cur, depth) => {
|
|
2571
|
+
let entries;
|
|
2572
|
+
try {
|
|
2573
|
+
entries = readdirSync(cur, { withFileTypes: true });
|
|
2574
|
+
}
|
|
2575
|
+
catch {
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
for (const entry of entries) {
|
|
2579
|
+
const full = join(cur, entry.name);
|
|
2580
|
+
if (entry.isDirectory()) {
|
|
2581
|
+
if (opts.recursive && depth < 3)
|
|
2582
|
+
visit(full, depth + 1);
|
|
2583
|
+
continue;
|
|
2584
|
+
}
|
|
2585
|
+
if (!entry.isFile())
|
|
2586
|
+
continue;
|
|
2587
|
+
if (!wantsExt(entry.name))
|
|
2588
|
+
continue;
|
|
2589
|
+
let s;
|
|
2590
|
+
try {
|
|
2591
|
+
s = statSync(full);
|
|
2592
|
+
}
|
|
2593
|
+
catch {
|
|
2594
|
+
continue;
|
|
2595
|
+
}
|
|
2596
|
+
const mtime = s.mtimeMs;
|
|
2597
|
+
if (mtime < opts.sinceMs)
|
|
2598
|
+
continue;
|
|
2599
|
+
if (mtime <= bestMtime)
|
|
2600
|
+
continue;
|
|
2601
|
+
const cwd = opts.readCwd(full);
|
|
2602
|
+
if (cwd !== opts.sessionCwd)
|
|
2603
|
+
continue;
|
|
2604
|
+
bestMtime = mtime;
|
|
2605
|
+
bestPath = full;
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
visit(opts.dir, 0);
|
|
2609
|
+
return bestPath;
|
|
2610
|
+
}
|
|
2611
|
+
/**
|
|
2612
|
+
* Read up to `maxBytes` from `path` and report whether the file was
|
|
2613
|
+
* larger. Callers that need to JSON.parse the whole file (opencode's
|
|
2614
|
+
* single-object session record) gate on `truncated === false`; callers
|
|
2615
|
+
* that scan line-by-line (claude/codex JSONL) ignore it.
|
|
2616
|
+
*/
|
|
2617
|
+
function readTranscriptHeader(path, maxBytes = 65536) {
|
|
2618
|
+
let fd;
|
|
2619
|
+
try {
|
|
2620
|
+
fd = openSync(path, 'r');
|
|
2621
|
+
const buf = Buffer.alloc(maxBytes);
|
|
2622
|
+
const n = readSync(fd, buf, 0, maxBytes, 0);
|
|
2623
|
+
return {
|
|
2624
|
+
text: buf.subarray(0, n).toString('utf8'),
|
|
2625
|
+
truncated: n >= maxBytes
|
|
2626
|
+
};
|
|
2627
|
+
}
|
|
2628
|
+
catch {
|
|
2629
|
+
return undefined;
|
|
2630
|
+
}
|
|
2631
|
+
finally {
|
|
2632
|
+
if (fd !== undefined) {
|
|
2633
|
+
try {
|
|
2634
|
+
closeSync(fd);
|
|
2635
|
+
}
|
|
2636
|
+
catch {
|
|
2637
|
+
/* swallow — fd already invalid */
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
/** Claude JSONL: `cwd` appears on most entries; the first line that has it wins. */
|
|
2643
|
+
function readCwdFromClaudeJsonl(path) {
|
|
2644
|
+
const header = readTranscriptHeader(path);
|
|
2645
|
+
if (!header)
|
|
2646
|
+
return undefined;
|
|
2647
|
+
for (const line of header.text.split('\n')) {
|
|
2648
|
+
if (!line.includes('"cwd"'))
|
|
2649
|
+
continue;
|
|
2650
|
+
try {
|
|
2651
|
+
const obj = JSON.parse(line);
|
|
2652
|
+
if (typeof obj.cwd === 'string')
|
|
2653
|
+
return obj.cwd;
|
|
2654
|
+
}
|
|
2655
|
+
catch {
|
|
2656
|
+
// partial last line — skip
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
return undefined;
|
|
2660
|
+
}
|
|
2661
|
+
/** Codex JSONL: line 1 is `session_meta` with `payload.cwd`. */
|
|
2662
|
+
function readCwdFromCodexJsonl(path) {
|
|
2663
|
+
const header = readTranscriptHeader(path);
|
|
2664
|
+
if (!header)
|
|
2665
|
+
return undefined;
|
|
2666
|
+
const firstNewline = header.text.indexOf('\n');
|
|
2667
|
+
const firstLine = firstNewline === -1 ? header.text : header.text.slice(0, firstNewline);
|
|
2668
|
+
try {
|
|
2669
|
+
const obj = JSON.parse(firstLine);
|
|
2670
|
+
const cwd = obj.payload?.cwd;
|
|
2671
|
+
return typeof cwd === 'string' ? cwd : undefined;
|
|
2672
|
+
}
|
|
2673
|
+
catch {
|
|
2674
|
+
return undefined;
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
/**
|
|
2678
|
+
* Opencode session JSON: top-level `directory` field. Opencode writes
|
|
2679
|
+
* the whole session as a single JSON object, so a truncated read can't
|
|
2680
|
+
* be parsed — the closing brace is missing. Re-read the full file when
|
|
2681
|
+
* `truncated` flips, since real opencode session records are typically
|
|
2682
|
+
* a few hundred bytes (summary, directory, ids) but we don't want to
|
|
2683
|
+
* silently miss a larger one.
|
|
2684
|
+
*/
|
|
2685
|
+
function readCwdFromOpencodeSession(path) {
|
|
2686
|
+
const header = readTranscriptHeader(path);
|
|
2687
|
+
if (!header)
|
|
2688
|
+
return undefined;
|
|
2689
|
+
let body = header.text;
|
|
2690
|
+
if (header.truncated) {
|
|
2691
|
+
try {
|
|
2692
|
+
body = readFileSync(path, 'utf8');
|
|
2693
|
+
}
|
|
2694
|
+
catch {
|
|
2695
|
+
return undefined;
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
try {
|
|
2699
|
+
const obj = JSON.parse(body);
|
|
2700
|
+
return typeof obj.directory === 'string' ? obj.directory : undefined;
|
|
2701
|
+
}
|
|
2702
|
+
catch {
|
|
2703
|
+
return undefined;
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Run the persona-improver in headless one-shot mode against the given
|
|
2708
|
+
* persona + transcript. Returns the parsed proposals file on success.
|
|
2709
|
+
*
|
|
2710
|
+
* Throws on: missing improver in catalog, harness binary not on PATH,
|
|
2711
|
+
* non-zero harness exit, or unparseable proposals JSON. Caller is expected
|
|
2712
|
+
* to surface the message and skip the apply step.
|
|
2713
|
+
*/
|
|
2714
|
+
async function runPersonaImprover(args) {
|
|
2715
|
+
const improverSpec = personaCatalog['persona-improvement'];
|
|
2716
|
+
if (!improverSpec) {
|
|
2717
|
+
throw new Error('built-in persona "persona-improver" is not registered in the catalog');
|
|
2718
|
+
}
|
|
2719
|
+
const tier = 'best-value';
|
|
2720
|
+
const selection = buildSelection(improverSpec, tier, 'repo');
|
|
2721
|
+
const inputValues = {
|
|
2722
|
+
PERSONA_FILE_PATH: args.personaFilePath,
|
|
2723
|
+
SESSION_TRANSCRIPT_PATH: args.transcriptPath,
|
|
2724
|
+
PROPOSALS_OUTPUT_PATH: args.proposalsOutputPath
|
|
2725
|
+
};
|
|
2726
|
+
const inputResolution = resolvePersonaInputs(selection.inputs, inputValues, process.env);
|
|
2727
|
+
const renderedSystemPrompt = renderPersonaInputs(selection.runtime.systemPrompt, inputResolution.values);
|
|
2728
|
+
const callerEnv = { ...process.env, ...inputResolution.values };
|
|
2729
|
+
const envResolution = resolveStringMapLenient(selection.env, callerEnv, 'env');
|
|
2730
|
+
const mcpResolution = resolveMcpServersLenient(selection.mcpServers, callerEnv);
|
|
2731
|
+
const taskBody = [
|
|
2732
|
+
'Improve this local persona from one finished session. The CLI will read your proposals JSON and walk the user through accept/deny.',
|
|
2733
|
+
`PERSONA_FILE_PATH=${args.personaFilePath}`,
|
|
2734
|
+
`SESSION_TRANSCRIPT_PATH=${args.transcriptPath}`,
|
|
2735
|
+
`PROPOSALS_OUTPUT_PATH=${args.proposalsOutputPath}`
|
|
2736
|
+
].join('\n');
|
|
2737
|
+
const task = `${taskBody}\n\nRun inputs:\n${JSON.stringify(inputValues, null, 2)}`;
|
|
2738
|
+
const spec = buildNonInteractiveSpec({
|
|
2739
|
+
harness: selection.runtime.harness,
|
|
2740
|
+
personaId: selection.personaId,
|
|
2741
|
+
model: selection.runtime.model,
|
|
2742
|
+
systemPrompt: renderedSystemPrompt,
|
|
2743
|
+
harnessSettings: selection.runtime.harnessSettings,
|
|
2744
|
+
mcpServers: mcpResolution.servers,
|
|
2745
|
+
permissions: selection.permissions,
|
|
2746
|
+
task
|
|
2747
|
+
});
|
|
2748
|
+
const childEnv = { ...callerEnv, ...(envResolution.value ?? {}), ...inputResolution.values };
|
|
2749
|
+
const cwd = process.cwd();
|
|
2750
|
+
const configWrites = [];
|
|
2751
|
+
for (const file of spec.configFiles) {
|
|
2752
|
+
assertSafeRelativePath(file.path);
|
|
2753
|
+
const target = join(cwd, file.path);
|
|
2754
|
+
const existed = existsSync(target);
|
|
2755
|
+
const previous = existed ? readFileSync(target, 'utf8') : undefined;
|
|
2756
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
2757
|
+
writeFileSync(target, file.contents, 'utf8');
|
|
2758
|
+
configWrites.push({ path: target, existed, ...(previous !== undefined ? { previous } : {}) });
|
|
2759
|
+
}
|
|
2760
|
+
const restoreConfigWrites = () => {
|
|
2761
|
+
for (const write of [...configWrites].reverse()) {
|
|
2762
|
+
if (write.existed) {
|
|
2763
|
+
writeFileSync(write.path, write.previous ?? '', 'utf8');
|
|
2764
|
+
}
|
|
2765
|
+
else {
|
|
2766
|
+
rmSync(write.path, { force: true });
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
};
|
|
2770
|
+
const timeoutMs = selection.runtime.harnessSettings.timeoutSeconds
|
|
2771
|
+
? selection.runtime.harnessSettings.timeoutSeconds * 1000
|
|
2772
|
+
: undefined;
|
|
2773
|
+
let captureResult;
|
|
2774
|
+
try {
|
|
2775
|
+
captureResult = await new Promise((resolveResult) => {
|
|
2776
|
+
const child = spawn(spec.bin, [...spec.args], {
|
|
2777
|
+
cwd,
|
|
2778
|
+
env: childEnv,
|
|
2779
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
2780
|
+
shell: false
|
|
2781
|
+
});
|
|
2782
|
+
let stderrBuf = '';
|
|
2783
|
+
let forceKillTimeout;
|
|
2784
|
+
child.stdout?.setEncoding('utf8');
|
|
2785
|
+
child.stderr?.setEncoding('utf8');
|
|
2786
|
+
child.stderr?.on('data', (chunk) => {
|
|
2787
|
+
stderrBuf += chunk;
|
|
2788
|
+
});
|
|
2789
|
+
// SIGTERM first; if the harness traps or ignores it, escalate to
|
|
2790
|
+
// SIGKILL after a 1s grace so the timeout is actually enforced.
|
|
2791
|
+
const timeout = timeoutMs !== undefined
|
|
2792
|
+
? setTimeout(() => {
|
|
2793
|
+
child.kill('SIGTERM');
|
|
2794
|
+
forceKillTimeout = setTimeout(() => {
|
|
2795
|
+
if (!child.killed)
|
|
2796
|
+
child.kill('SIGKILL');
|
|
2797
|
+
}, 1000);
|
|
2798
|
+
}, timeoutMs)
|
|
2799
|
+
: undefined;
|
|
2800
|
+
const clearTimers = () => {
|
|
2801
|
+
if (timeout)
|
|
2802
|
+
clearTimeout(timeout);
|
|
2803
|
+
if (forceKillTimeout)
|
|
2804
|
+
clearTimeout(forceKillTimeout);
|
|
2805
|
+
};
|
|
2806
|
+
child.on('error', (err) => {
|
|
2807
|
+
clearTimers();
|
|
2808
|
+
resolveResult({ exitCode: 1, stderr: `${stderrBuf}${err.message}\n` });
|
|
2809
|
+
});
|
|
2810
|
+
child.on('close', (code, signal) => {
|
|
2811
|
+
clearTimers();
|
|
2812
|
+
const exitCode = typeof code === 'number' ? code : signal ? signalExitCode(signal) : null;
|
|
2813
|
+
resolveResult({ exitCode, stderr: stderrBuf });
|
|
2814
|
+
});
|
|
2815
|
+
});
|
|
2816
|
+
}
|
|
2817
|
+
finally {
|
|
2818
|
+
// Always restore — a synchronous spawn() throw or unexpected promise
|
|
2819
|
+
// rejection must not leave orphaned `opencode.json` (or any other
|
|
2820
|
+
// configFile) sitting in the user's working directory.
|
|
2821
|
+
restoreConfigWrites();
|
|
2822
|
+
}
|
|
2823
|
+
if (captureResult.exitCode !== 0) {
|
|
2824
|
+
throw new Error(`improver exited with code=${captureResult.exitCode ?? 'null'}.${captureResult.stderr ? ` stderr: ${captureResult.stderr.slice(0, 400)}` : ''}`);
|
|
2825
|
+
}
|
|
2826
|
+
let raw;
|
|
2827
|
+
try {
|
|
2828
|
+
raw = readFileSync(args.proposalsOutputPath, 'utf8');
|
|
2829
|
+
}
|
|
2830
|
+
catch (err) {
|
|
2831
|
+
throw new Error(`improver did not write proposals file at ${args.proposalsOutputPath}: ${err.message}`);
|
|
2832
|
+
}
|
|
2833
|
+
return parseProposals(raw);
|
|
2834
|
+
}
|
|
2835
|
+
export function parseProposals(raw) {
|
|
2836
|
+
let parsed;
|
|
2837
|
+
try {
|
|
2838
|
+
parsed = JSON.parse(raw);
|
|
2839
|
+
}
|
|
2840
|
+
catch (err) {
|
|
2841
|
+
throw new Error(`proposals file is not valid JSON: ${err.message}`);
|
|
2842
|
+
}
|
|
2843
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
2844
|
+
throw new Error('proposals file must be a JSON object');
|
|
2845
|
+
}
|
|
2846
|
+
const obj = parsed;
|
|
2847
|
+
const proposalsArr = Array.isArray(obj.proposals) ? obj.proposals : [];
|
|
2848
|
+
const proposals = [];
|
|
2849
|
+
for (const [idx, item] of proposalsArr.entries()) {
|
|
2850
|
+
if (!item || typeof item !== 'object') {
|
|
2851
|
+
throw new Error(`proposals[${idx}] must be an object`);
|
|
2852
|
+
}
|
|
2853
|
+
const p = item;
|
|
2854
|
+
if (typeof p.id !== 'string' || !p.id.trim()) {
|
|
2855
|
+
throw new Error(`proposals[${idx}].id must be a non-empty string`);
|
|
2856
|
+
}
|
|
2857
|
+
if (typeof p.summary !== 'string' || !p.summary.trim()) {
|
|
2858
|
+
throw new Error(`proposals[${idx}].summary must be a non-empty string`);
|
|
2859
|
+
}
|
|
2860
|
+
if (typeof p.rationale !== 'string') {
|
|
2861
|
+
throw new Error(`proposals[${idx}].rationale must be a string`);
|
|
2862
|
+
}
|
|
2863
|
+
if (!Array.isArray(p.patches) || p.patches.length === 0) {
|
|
2864
|
+
throw new Error(`proposals[${idx}].patches must be a non-empty array`);
|
|
2865
|
+
}
|
|
2866
|
+
const patches = [];
|
|
2867
|
+
for (const [pidx, rawPatch] of p.patches.entries()) {
|
|
2868
|
+
if (!rawPatch || typeof rawPatch !== 'object') {
|
|
2869
|
+
throw new Error(`proposals[${idx}].patches[${pidx}] must be an object`);
|
|
2870
|
+
}
|
|
2871
|
+
const rp = rawPatch;
|
|
2872
|
+
if (typeof rp.path !== 'string' || !rp.path.trim()) {
|
|
2873
|
+
throw new Error(`proposals[${idx}].patches[${pidx}].path must be a non-empty string`);
|
|
2874
|
+
}
|
|
2875
|
+
if (rp.op !== 'set' && rp.op !== 'append') {
|
|
2876
|
+
throw new Error(`proposals[${idx}].patches[${pidx}].op must be "set" or "append"`);
|
|
2877
|
+
}
|
|
2878
|
+
const patch = { path: rp.path, op: rp.op, value: rp.value };
|
|
2879
|
+
assertAllowedImproverPatch(patch, `proposals[${idx}].patches[${pidx}]`);
|
|
2880
|
+
patches.push(patch);
|
|
2881
|
+
}
|
|
2882
|
+
proposals.push({
|
|
2883
|
+
id: p.id,
|
|
2884
|
+
summary: p.summary,
|
|
2885
|
+
rationale: p.rationale,
|
|
2886
|
+
patches
|
|
2887
|
+
});
|
|
2888
|
+
}
|
|
2889
|
+
return {
|
|
2890
|
+
personaId: typeof obj.personaId === 'string' ? obj.personaId : '',
|
|
2891
|
+
personaFilePath: typeof obj.personaFilePath === 'string' ? obj.personaFilePath : '',
|
|
2892
|
+
transcriptPath: typeof obj.transcriptPath === 'string' ? obj.transcriptPath : '',
|
|
2893
|
+
proposals
|
|
2894
|
+
};
|
|
2895
|
+
}
|
|
2896
|
+
/**
|
|
2897
|
+
* Walk improver proposals one-by-one over the TTY. Returns only the
|
|
2898
|
+
* accepted proposals; the caller applies the patches. Supports:
|
|
2899
|
+
* y / n — accept or skip the current proposal
|
|
2900
|
+
* a — accept this and all remaining proposals
|
|
2901
|
+
* q — quit without accepting any further proposals (already-accepted ones stay)
|
|
2902
|
+
*
|
|
2903
|
+
* On a non-TTY we shouldn't have reached this point (caller checks),
|
|
2904
|
+
* but if we do, return an empty list so nothing is auto-applied.
|
|
2905
|
+
*/
|
|
2906
|
+
function walkProposalsInteractive(file) {
|
|
2907
|
+
if (!process.stdin.isTTY)
|
|
2908
|
+
return [];
|
|
2909
|
+
const accepted = [];
|
|
2910
|
+
const total = file.proposals.length;
|
|
2911
|
+
let acceptAll = false;
|
|
2912
|
+
for (let i = 0; i < total; i++) {
|
|
2913
|
+
const proposal = file.proposals[i];
|
|
2914
|
+
process.stderr.write(`\n[${i + 1}/${total}] ${proposal.summary}\n`);
|
|
2915
|
+
if (proposal.rationale) {
|
|
2916
|
+
process.stderr.write(` why: ${proposal.rationale}\n`);
|
|
2917
|
+
}
|
|
2918
|
+
for (const patch of proposal.patches) {
|
|
2919
|
+
const preview = formatPatchPreview(patch);
|
|
2920
|
+
process.stderr.write(` ${preview}\n`);
|
|
2921
|
+
}
|
|
2922
|
+
if (acceptAll) {
|
|
2923
|
+
accepted.push(proposal);
|
|
2924
|
+
process.stderr.write(' → accepted (accept-all)\n');
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2927
|
+
// 'n' is first so empty Enter defaults to skip (matches the
|
|
2928
|
+
// [y/N] default-no convention used by promptYesNoSync at the
|
|
2929
|
+
// session-end "auto-improve?" prompt). If the user hammers Enter
|
|
2930
|
+
// through a stack of proposals they get a no-op outcome, not an
|
|
2931
|
+
// unintended file mutation.
|
|
2932
|
+
const choice = readSingleCharChoice(' accept? [y/N/a/q] ', ['n', 'y', 'a', 'q']);
|
|
2933
|
+
if (choice === 'y') {
|
|
2934
|
+
accepted.push(proposal);
|
|
2935
|
+
}
|
|
2936
|
+
else if (choice === 'a') {
|
|
2937
|
+
accepted.push(proposal);
|
|
2938
|
+
acceptAll = true;
|
|
2939
|
+
}
|
|
2940
|
+
else if (choice === 'q') {
|
|
2941
|
+
process.stderr.write(' → quit; no further proposals will be reviewed.\n');
|
|
2942
|
+
break;
|
|
2943
|
+
}
|
|
2944
|
+
// 'n' (and bare Enter) falls through with no accept.
|
|
2945
|
+
}
|
|
2946
|
+
return accepted;
|
|
2947
|
+
}
|
|
2948
|
+
/**
|
|
2949
|
+
* Render a one-line patch preview. Truncates long string values so a
|
|
2950
|
+
* multi-paragraph systemPrompt rewrite doesn't dominate the screen.
|
|
2951
|
+
*/
|
|
2952
|
+
function formatPatchPreview(patch) {
|
|
2953
|
+
const op = patch.op === 'append' ? '+= ' : '= ';
|
|
2954
|
+
const valueStr = formatPatchValue(patch.value);
|
|
2955
|
+
return `${patch.path} ${op}${valueStr}`;
|
|
2956
|
+
}
|
|
2957
|
+
function formatPatchValue(value) {
|
|
2958
|
+
if (typeof value === 'string') {
|
|
2959
|
+
const condensed = value.replace(/\s+/g, ' ').trim();
|
|
2960
|
+
return condensed.length > 100 ? `"${condensed.slice(0, 97)}..."` : `"${condensed}"`;
|
|
2961
|
+
}
|
|
2962
|
+
try {
|
|
2963
|
+
const json = JSON.stringify(value);
|
|
2964
|
+
if (json === undefined)
|
|
2965
|
+
return '<undefined>';
|
|
2966
|
+
return json.length > 120 ? `${json.slice(0, 117)}...` : json;
|
|
2967
|
+
}
|
|
2968
|
+
catch {
|
|
2969
|
+
return '<unserializable>';
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
/**
|
|
2973
|
+
* Read a single-character choice from stdin synchronously, looping on
|
|
2974
|
+
* invalid input. Empty Enter (no character) returns the first option in
|
|
2975
|
+
* `valid` — callers should put the safe / default-no answer first.
|
|
2976
|
+
*
|
|
2977
|
+
* Test seam: callers can inject `read` so the prompt is exercisable
|
|
2978
|
+
* without a real TTY (mirrors `promptYesNoSync`).
|
|
2979
|
+
*/
|
|
2980
|
+
export function readSingleCharChoice(prompt, valid, opts = {}) {
|
|
2981
|
+
const write = opts.write ?? ((chunk) => {
|
|
2982
|
+
process.stderr.write(chunk);
|
|
2983
|
+
});
|
|
2984
|
+
for (;;) {
|
|
2985
|
+
write(prompt);
|
|
2986
|
+
const line = opts.read ? opts.read() : readLineFromStdinSync();
|
|
2987
|
+
const trimmed = (line ?? '').trim().toLowerCase();
|
|
2988
|
+
if (trimmed.length === 0)
|
|
2989
|
+
return valid[0];
|
|
2990
|
+
const ch = trimmed[0];
|
|
2991
|
+
if (valid.includes(ch))
|
|
2992
|
+
return ch;
|
|
2993
|
+
write(` invalid choice; expected one of: ${valid.join(', ')}\n`);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
/**
|
|
2997
|
+
* Apply accepted patches to the persona JSON on disk. Reads, mutates the
|
|
2998
|
+
* parsed object, writes back with two-space indent + trailing newline
|
|
2999
|
+
* (matches existing /personas style). Throws on unwriteable file or
|
|
3000
|
+
* unsupported patch op/path resolution.
|
|
3001
|
+
*/
|
|
3002
|
+
export function applyAcceptedPatches(personaFilePath, accepted) {
|
|
3003
|
+
const raw = readFileSync(personaFilePath, 'utf8');
|
|
3004
|
+
const json = JSON.parse(raw);
|
|
3005
|
+
for (const proposal of accepted) {
|
|
3006
|
+
for (const patch of proposal.patches) {
|
|
3007
|
+
applyPatchInPlace(json, patch);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
writeFileSync(personaFilePath, JSON.stringify(json, null, 2) + '\n', 'utf8');
|
|
3011
|
+
}
|
|
3012
|
+
function applyPatchInPlace(root, patch) {
|
|
3013
|
+
// Re-run the allowlist + prototype-segment guard at apply time, not
|
|
3014
|
+
// just at parse time. Belt-and-braces: a patch list constructed by a
|
|
3015
|
+
// future caller that bypasses parseProposals can't smuggle a
|
|
3016
|
+
// disallowed path past this point either.
|
|
3017
|
+
assertAllowedImproverPatch(patch, `applyPatchInPlace`);
|
|
3018
|
+
const segments = patch.path.split('.').filter((s) => s.length > 0);
|
|
3019
|
+
let cursor = root;
|
|
3020
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
3021
|
+
const seg = segments[i];
|
|
3022
|
+
const next = cursor[seg];
|
|
3023
|
+
if (next === undefined || next === null) {
|
|
3024
|
+
const created = {};
|
|
3025
|
+
cursor[seg] = created;
|
|
3026
|
+
cursor = created;
|
|
3027
|
+
continue;
|
|
3028
|
+
}
|
|
3029
|
+
if (typeof next !== 'object' || Array.isArray(next)) {
|
|
3030
|
+
throw new Error(`patch path "${patch.path}": "${seg}" is not an object`);
|
|
3031
|
+
}
|
|
3032
|
+
cursor = next;
|
|
3033
|
+
}
|
|
3034
|
+
const finalSeg = segments[segments.length - 1];
|
|
3035
|
+
if (patch.op === 'append') {
|
|
3036
|
+
const existing = cursor[finalSeg];
|
|
3037
|
+
if (existing === undefined) {
|
|
3038
|
+
cursor[finalSeg] = [patch.value];
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
if (!Array.isArray(existing)) {
|
|
3042
|
+
throw new Error(`patch path "${patch.path}": cannot append to non-array`);
|
|
3043
|
+
}
|
|
3044
|
+
existing.push(patch.value);
|
|
3045
|
+
return;
|
|
3046
|
+
}
|
|
3047
|
+
// op === 'set'
|
|
3048
|
+
cursor[finalSeg] = patch.value;
|
|
3049
|
+
}
|
|
2053
3050
|
/**
|
|
2054
3051
|
* Enumerate persona candidates for the picker. Local overrides win over the
|
|
2055
3052
|
* built-in catalog when ids collide; the picker only needs the projection
|