@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/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, listBuiltInPersonas, personaCatalog, resolveSidecar, routingProfiles, useSelection } from '@agentworkforce/workload-router';
9
- import { buildInteractiveSpec, detectHarnesses, formatDropWarnings, MissingPersonaInputError, renderPersonaInputs, resolvePersonaInputs, resolveMcpServersLenient, resolveStringMapLenient } from '@agentworkforce/harness-kit';
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
- const res = spawnSync(bin, args, {
345
- stdio: ['ignore', 'pipe', 'pipe'],
346
- shell: false,
347
- encoding: 'utf8',
348
- // Default is 1 MiB; verbose `npx prpm install` / `npx skills add` runs
349
- // can blow past it, which would have spawnSync kill the child with
350
- // ENOBUFS and report a spurious failure. 100 MiB is well past anything
351
- // these installers print in practice.
352
- maxBuffer: 100 * 1024 * 1024,
353
- ...(cwd ? { cwd } : {})
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
- * (harness-kit's opencode branch) emits exactly one pair, so both behaviors
462
- * are equivalent today, but "remove all" is idempotent and safer if a future
463
- * caller ever appends a second `--agent` for any reason. A trailing `--agent`
464
- * with no following value is preserved so the malformed argv surfaces at the
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: harness-kit translation. buildInteractiveSpec validates
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 ctx = useSelection(effectiveSelection, installRoot !== undefined ? { installRoot } : {});
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
- process.stderr.write(`• sandbox mount ${mountDir}\n`);
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
- // • While the child is running: Ctrl-C reaches the harness directly via
1051
- // the controlling TTY's foreground process group (the child is
1052
- // spawned with `stdio: 'inherit'` and inherits the parent's pgid). We
1053
- // register a no-op handler here purely to suppress Node's default
1054
- // exit-on-SIGINT forwarding via child.kill('SIGINT') would deliver
1055
- // a *second* SIGINT and break harnesses that escalate on repeated
1056
- // interrupts (e.g. claude treats 1st = cancel, 2nd = quit).
1057
- // While syncing: 1st press aborts the shutdownSignal (relayfile then
1058
- // skips autosync's draining reconcile and returns the partial count
1059
- // from the final syncBack). 2nd press hard-exits and rms the session
1060
- // dir so no mount is left behind.
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
- return;
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
- const handle = createMount(process.cwd(), mountDir, {
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
- runInstallOrThrow(install.command, installLabel, handle.mountDir);
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: handle.mountDir,
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.cleanup();
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