okstra 0.96.1 → 0.97.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.
Files changed (121) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/bin/okstra +1 -1
  4. package/docs/contributor-change-matrix.md +1 -1
  5. package/docs/kr/architecture/storage-model.md +273 -0
  6. package/docs/kr/architecture.md +6 -277
  7. package/docs/kr/cli.md +1 -1
  8. package/docs/project-structure-overview.md +26 -22
  9. package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
  10. package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
  11. package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
  12. package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
  13. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
  14. package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
  15. package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
  16. package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
  17. package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
  18. package/docs/task-process/release-handoff.md +3 -3
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/bin/lib/okstra/cli.sh +1 -1
  22. package/runtime/bin/lib/okstra/interactive.sh +30 -4
  23. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
  24. package/runtime/bin/okstra-render-final-report.py +4 -15
  25. package/runtime/bin/okstra.sh +5 -2
  26. package/runtime/prompts/launch.template.md +1 -0
  27. package/runtime/prompts/lead/report-writer.md +1 -1
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/final-verification.md +1 -0
  30. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  31. package/runtime/prompts/profiles/release-handoff.md +10 -4
  32. package/runtime/python/okstra_ctl/__init__.py +3 -2
  33. package/runtime/python/okstra_ctl/backfill.py +12 -21
  34. package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
  35. package/runtime/python/okstra_ctl/conformance.py +21 -3
  36. package/runtime/python/okstra_ctl/consumers.py +7 -1
  37. package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
  38. package/runtime/python/okstra_ctl/doctor.py +29 -12
  39. package/runtime/python/okstra_ctl/error_log_core.py +72 -0
  40. package/runtime/python/okstra_ctl/error_report.py +8 -58
  41. package/runtime/python/okstra_ctl/error_zip.py +319 -0
  42. package/runtime/python/okstra_ctl/fanout.py +5 -2
  43. package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
  44. package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
  45. package/runtime/python/okstra_ctl/handoff.py +125 -26
  46. package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
  47. package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
  48. package/runtime/python/okstra_ctl/index.py +34 -91
  49. package/runtime/python/okstra_ctl/jsonl.py +29 -18
  50. package/runtime/python/okstra_ctl/listing.py +30 -5
  51. package/runtime/python/okstra_ctl/md_table.py +1 -1
  52. package/runtime/python/okstra_ctl/migrate.py +17 -7
  53. package/runtime/python/okstra_ctl/paths.py +1 -1
  54. package/runtime/python/okstra_ctl/recap.py +12 -3
  55. package/runtime/python/okstra_ctl/reconcile.py +13 -11
  56. package/runtime/python/okstra_ctl/render.py +49 -12
  57. package/runtime/python/okstra_ctl/render_final_report.py +4 -0
  58. package/runtime/python/okstra_ctl/report_views.py +47 -6
  59. package/runtime/python/okstra_ctl/run.py +128 -100
  60. package/runtime/python/okstra_ctl/run_index_row.py +118 -0
  61. package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
  62. package/runtime/python/okstra_ctl/sequence.py +2 -2
  63. package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
  64. package/runtime/python/okstra_ctl/stage_targets.py +2 -8
  65. package/runtime/python/okstra_ctl/task_target.py +4 -1
  66. package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
  67. package/runtime/python/okstra_ctl/wizard.py +27 -12
  68. package/runtime/python/okstra_ctl/workflow.py +9 -1
  69. package/runtime/python/okstra_ctl/worktree.py +93 -4
  70. package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
  71. package/runtime/python/okstra_token_usage/collect.py +43 -38
  72. package/runtime/skills/okstra-brief/SKILL.md +43 -12
  73. package/runtime/skills/okstra-inspect/SKILL.md +44 -1
  74. package/runtime/templates/reports/final-report.template.md +2 -2
  75. package/runtime/templates/reports/release-handoff-input.template.md +1 -1
  76. package/runtime/validators/forbidden_actions.py +8 -1
  77. package/runtime/validators/validate-brief.py +13 -2
  78. package/runtime/validators/validate-run.py +27 -6
  79. package/runtime/validators/validate-schedule.py +1 -2
  80. package/runtime/validators/validate_improvement_report.py +14 -0
  81. package/src/cli-registry.mjs +45 -31
  82. package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
  83. package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
  84. package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
  85. package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
  86. package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
  87. package/src/commands/execute/integrate-stages.mjs +25 -0
  88. package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
  89. package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
  90. package/src/{run.mjs → commands/execute/run.mjs} +4 -4
  91. package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
  92. package/src/{team.mjs → commands/execute/team.mjs} +3 -3
  93. package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
  94. package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
  95. package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
  96. package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
  97. package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
  98. package/src/commands/inspect/error-zip.mjs +25 -0
  99. package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
  100. package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
  101. package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
  102. package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
  103. package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
  104. package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
  105. package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
  106. package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
  107. package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
  108. package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
  109. package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
  110. package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
  111. package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
  112. package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
  113. package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
  114. package/src/lib/paths.mjs +60 -0
  115. package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
  116. package/src/{version.mjs → lib/version.mjs} +2 -2
  117. /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
  118. /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
  119. /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
  120. /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
  121. /package/src/{skill-catalog.mjs → lib/skill-catalog.mjs} +0 -0
@@ -1,7 +1,4 @@
1
- import { homedir } from "node:os";
2
- import { join } from "node:path";
3
- import { existsSync } from "node:fs";
4
- import { getPackageRoot, getPackageVersion } from "./version.mjs";
1
+ import { resolvePaths } from "../../lib/paths.mjs";
5
2
 
6
3
  const USAGE = `okstra paths — print runtime paths
7
4
 
@@ -52,62 +49,6 @@ const SHELL_EXPORTS = [
52
49
  ["OKSTRA_HOME", "home"],
53
50
  ];
54
51
 
55
- export async function resolvePaths() {
56
- const home = process.env.OKSTRA_HOME?.trim() || join(homedir(), ".okstra");
57
- const pkgRoot = getPackageRoot();
58
- const devLink = await readSimpleFile(join(home, "dev-link"));
59
- const runtimeRoot = devLink || join(pkgRoot, "runtime");
60
- return {
61
- agents: join(runtimeRoot, "agents"),
62
- workspace: runtimeRoot,
63
- pythonpath: join(home, "lib", "python"),
64
- bin: join(home, "bin"),
65
- home,
66
- package: await getPackageVersion(),
67
- version: await readSimpleFile(join(home, "version")),
68
- "dev-link": devLink || "",
69
- };
70
- }
71
-
72
- function workspacePythonpath(paths) {
73
- const candidates = [
74
- join(paths.workspace, "python"),
75
- join(paths.workspace, "scripts"),
76
- ];
77
- return candidates.find((candidate) => existsSync(candidate)) || "";
78
- }
79
-
80
- // Compose the PYTHONPATH spawned python3 subprocesses should see.
81
- //
82
- // Precedence (first wins):
83
- // 1. process.env.PYTHONPATH (user override — e.g. in-repo `PYTHONPATH=scripts`
84
- // for dev work without re-running `okstra install`).
85
- // 2. The package/dev-link workspace runtime selected by `okstra paths`.
86
- // 3. The installed user-runtime path (~/.okstra/lib/python).
87
- //
88
- // This is a colon-joined string suitable for direct assignment into the child
89
- // process env. Empty segments are dropped so a missing env var does not produce
90
- // a leading ':' (which Python treats as 'current directory').
91
- export function buildPythonpath(paths) {
92
- const fragments = [
93
- process.env.PYTHONPATH,
94
- workspacePythonpath(paths),
95
- paths.pythonpath,
96
- ].filter(
97
- (s) => typeof s === "string" && s.length > 0,
98
- );
99
- return fragments.join(":");
100
- }
101
-
102
- async function readSimpleFile(path) {
103
- try {
104
- const { readFile } = await import("node:fs/promises");
105
- return (await readFile(path, "utf8")).trim();
106
- } catch {
107
- return "";
108
- }
109
- }
110
-
111
52
  export async function run(args) {
112
53
  if (args.includes("--help") || args.includes("-h")) {
113
54
  process.stdout.write(USAGE);
@@ -2,8 +2,8 @@ import { promises as fs } from "node:fs";
2
2
  import { createInterface } from "node:readline";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve as resolvePath } from "node:path";
5
- import { resolvePaths } from "./paths.mjs";
6
- import { fileExists, runProcess } from "./_proc.mjs";
5
+ import { buildPythonpath, resolvePaths } from "../../lib/paths.mjs";
6
+ import { fileExists, runProcess } from "../../lib/proc.mjs";
7
7
 
8
8
  const USAGE = `okstra setup — register the current project with okstra
9
9
 
@@ -95,7 +95,7 @@ async function resolveProjectRoot(paths, explicit) {
95
95
  explicit || "",
96
96
  process.cwd(),
97
97
  ],
98
- { PYTHONPATH: paths.pythonpath },
98
+ { PYTHONPATH: buildPythonpath(paths) },
99
99
  );
100
100
  if (probe.code !== 0) {
101
101
  throw new Error(`python invocation failed: ${probe.stderr.trim() || probe.stdout.trim()}`);
@@ -136,7 +136,7 @@ async function upsert(paths, projectRoot, projectId) {
136
136
  projectRoot,
137
137
  projectId,
138
138
  ],
139
- { PYTHONPATH: paths.pythonpath },
139
+ { PYTHONPATH: buildPythonpath(paths) },
140
140
  );
141
141
  if (probe.code !== 0) {
142
142
  throw new Error(`python invocation failed: ${probe.stderr.trim() || probe.stdout.trim()}`);
@@ -1,26 +1,11 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { resolvePaths } from "./paths.mjs";
4
+ import { resolvePaths } from "../../lib/paths.mjs";
5
5
  import {
6
6
  RUNTIMES_MANIFEST_REL,
7
7
  runtimeManifestIncludesClaudeAssets,
8
- } from "./runtime-manifest.mjs";
9
-
10
- const BIN_ENTRYPOINTS = [
11
- "okstra.sh",
12
- "okstra-codex-exec.sh",
13
- "okstra-claude-exec.sh",
14
- "okstra-antigravity-exec.sh",
15
- "okstra-trace-cleanup.sh",
16
- "okstra-team-reconcile.sh",
17
- "okstra-central.sh",
18
- "okstra-token-usage.py",
19
- "okstra-error-log.py",
20
- "okstra-render-report-views.py",
21
- "okstra-render-final-report.py",
22
- "okstra-wrapper-status.py",
23
- ];
8
+ } from "../../lib/runtime-manifest.mjs";
24
9
 
25
10
  const FALLBACK_SKILL_NAMES = [
26
11
  "okstra",
@@ -71,7 +56,7 @@ const USAGE = `okstra uninstall — remove installed runtime and okstra skills
71
56
 
72
57
  Usage:
73
58
  okstra uninstall Remove ~/.okstra/{lib (python + validators),
74
- bin/<known>, schemas, templates, version,
59
+ bin, schemas, templates, version,
75
60
  dev-link, installed-runtimes.json,
76
61
  installed-skills.json, installed-agents.json} AND
77
62
  every skill target recorded in
@@ -184,21 +169,24 @@ export async function runUninstall(args) {
184
169
  // template's section schema (observed: a pre-renumber `## 4.5 Stage Map`
185
170
  // validator outliving every later install).
186
171
  await removePath(join(paths.home, "lib", "validators"), opts);
187
- for (const name of BIN_ENTRYPOINTS) {
188
- await removePath(join(paths.bin, name), opts);
189
- }
190
- // Clean now-empty parents (best-effort).
191
- for (const dir of [join(paths.home, "lib"), paths.bin]) {
192
- if (await pathExists(dir)) {
193
- try {
194
- const entries = await fs.readdir(dir);
195
- if (entries.length === 0) {
196
- if (!opts.dryRun) await fs.rmdir(dir);
197
- if (!opts.quiet) process.stdout.write(` removed empty: ${dir}\n`);
198
- }
199
- } catch {
200
- /* ignore */
172
+ // bin/ tree install copies the whole runtime/bin payload (entrypoints +
173
+ // bin/lib helpers) in copy mode, so a per-name list always drifts behind
174
+ // additions (e.g. okstra-inject-report-index.py, okstra-spawn-followups.sh).
175
+ // Remove the directory wholesale, matching how install populates it; nothing
176
+ // under ~/.okstra/bin is user data.
177
+ await removePath(paths.bin, opts);
178
+ // Clean now-empty lib parent (best-effort) — pythonpath + validators were the
179
+ // only okstra-owned children.
180
+ const libDir = join(paths.home, "lib");
181
+ if (await pathExists(libDir)) {
182
+ try {
183
+ const entries = await fs.readdir(libDir);
184
+ if (entries.length === 0) {
185
+ if (!opts.dryRun) await fs.rmdir(libDir);
186
+ if (!opts.quiet) process.stdout.write(` removed empty: ${libDir}\n`);
201
187
  }
188
+ } catch {
189
+ /* ignore */
202
190
  }
203
191
  }
204
192
 
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
+ import { randomBytes } from "node:crypto";
3
4
  import { promises as fs } from "node:fs";
4
5
  import { homedir } from "node:os";
5
6
  import { basename, dirname, join, relative, resolve } from "node:path";
@@ -227,12 +228,15 @@ function buildEntry(opts, content, now) {
227
228
  const title = inferTitle(content, opts.title, opts.file);
228
229
  const compact = now.toISOString().replace(/\D/g, "").slice(0, 17);
229
230
  const slug = slugify(title);
230
- const id = `mem_${compact}_${slug}`;
231
+ // Random suffix disambiguates two same-title adds within one millisecond,
232
+ // which would otherwise collide on both id and on-disk path (silent overwrite).
233
+ const nonce = randomBytes(3).toString("hex");
234
+ const id = `mem_${compact}_${slug}_${nonce}`;
231
235
  const relativePath = join(
232
236
  "entries",
233
237
  String(now.getUTCFullYear()),
234
238
  String(now.getUTCMonth() + 1).padStart(2, "0"),
235
- `${now.toISOString().replace(/[:.]/g, "")}-${slug}.md`,
239
+ `${now.toISOString().replace(/[:.]/g, "")}-${slug}-${nonce}.md`,
236
240
  );
237
241
  return {
238
242
  id,
@@ -298,10 +302,19 @@ async function readIndex() {
298
302
  if (err.code === "ENOENT") return [];
299
303
  throw err;
300
304
  }
301
- return text
302
- .split(/\r?\n/)
303
- .filter(Boolean)
304
- .map((line) => JSON.parse(line));
305
+ // Skip lines that fail to parse so one truncated row (e.g. an interrupted
306
+ // appendIndex) cannot make the whole Memory Book unreadable; other JSONL
307
+ // readers in okstra tolerate corruption the same way.
308
+ const entries = [];
309
+ for (const line of text.split(/\r?\n/)) {
310
+ if (!line) continue;
311
+ try {
312
+ entries.push(JSON.parse(line));
313
+ } catch {
314
+ // Drop the unparseable row; intact entries remain visible.
315
+ }
316
+ }
317
+ return entries;
305
318
  }
306
319
 
307
320
  async function writeIndex(entries) {
@@ -447,7 +460,19 @@ async function opArchive(args) {
447
460
  const updated = entries.map((candidate) =>
448
461
  candidate.id === entry.id ? archivedEntry : candidate,
449
462
  );
450
- await writeIndex(updated);
463
+ try {
464
+ await writeIndex(updated);
465
+ } catch (err) {
466
+ // Index rewrite failed after the file was already moved — move it back so
467
+ // the index keeps pointing at the file's real location (mirrors opAdd).
468
+ if (archivedEntry.path !== entry.path) {
469
+ await fs.rename(
470
+ join(memoryRoot(), archivedEntry.path),
471
+ join(memoryRoot(), entry.path),
472
+ );
473
+ }
474
+ throw err;
475
+ }
451
476
  if (json) emitJson({ ok: true, entry: archivedEntry });
452
477
  else process.stdout.write(`archived ${entry.id}\n`);
453
478
  return 0;
@@ -1,4 +1,4 @@
1
- import { runInstalledScript } from "./_python-helper.mjs";
1
+ import { runInstalledScript } from "../../lib/python-helper.mjs";
2
2
 
3
3
  const USAGE = `okstra inject-report-index — add the top-of-report Index + scroll anchors to a report
4
4
 
@@ -1,4 +1,4 @@
1
- import { runInstalledScript } from "./_python-helper.mjs";
1
+ import { runInstalledScript } from "../../lib/python-helper.mjs";
2
2
 
3
3
  const USAGE = `okstra render-final-report — render the markdown sibling of a final-report data.json
4
4
 
@@ -1,4 +1,4 @@
1
- import { runInstalledScript } from "./_python-helper.mjs";
1
+ import { runInstalledScript } from "../../lib/python-helper.mjs";
2
2
 
3
3
  const USAGE = `okstra render-views — render the self-contained HTML view of a final-report
4
4
 
@@ -0,0 +1,60 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+ import { getPackageRoot, getPackageVersion } from "./version.mjs";
5
+
6
+ export async function resolvePaths() {
7
+ const home = process.env.OKSTRA_HOME?.trim() || join(homedir(), ".okstra");
8
+ const pkgRoot = getPackageRoot();
9
+ const devLink = await readSimpleFile(join(home, "dev-link"));
10
+ const runtimeRoot = devLink || join(pkgRoot, "runtime");
11
+ return {
12
+ agents: join(runtimeRoot, "agents"),
13
+ workspace: runtimeRoot,
14
+ pythonpath: join(home, "lib", "python"),
15
+ bin: join(home, "bin"),
16
+ home,
17
+ package: await getPackageVersion(),
18
+ version: await readSimpleFile(join(home, "version")),
19
+ "dev-link": devLink || "",
20
+ };
21
+ }
22
+
23
+ function workspacePythonpath(paths) {
24
+ const candidates = [
25
+ join(paths.workspace, "python"),
26
+ join(paths.workspace, "scripts"),
27
+ ];
28
+ return candidates.find((candidate) => existsSync(candidate)) || "";
29
+ }
30
+
31
+ // Compose the PYTHONPATH spawned python3 subprocesses should see.
32
+ //
33
+ // Precedence (first wins):
34
+ // 1. process.env.PYTHONPATH (user override — e.g. in-repo `PYTHONPATH=scripts`
35
+ // for dev work without re-running `okstra install`).
36
+ // 2. The package/dev-link workspace runtime selected by `okstra paths`.
37
+ // 3. The installed user-runtime path (~/.okstra/lib/python).
38
+ //
39
+ // This is a colon-joined string suitable for direct assignment into the child
40
+ // process env. Empty segments are dropped so a missing env var does not produce
41
+ // a leading ':' (which Python treats as 'current directory').
42
+ export function buildPythonpath(paths) {
43
+ const fragments = [
44
+ process.env.PYTHONPATH,
45
+ workspacePythonpath(paths),
46
+ paths.pythonpath,
47
+ ].filter(
48
+ (s) => typeof s === "string" && s.length > 0,
49
+ );
50
+ return fragments.join(":");
51
+ }
52
+
53
+ async function readSimpleFile(path) {
54
+ try {
55
+ const { readFile } = await import("node:fs/promises");
56
+ return (await readFile(path, "utf8")).trim();
57
+ } catch {
58
+ return "";
59
+ }
60
+ }
@@ -10,7 +10,7 @@ function resolveInstalledScript(paths, scriptName) {
10
10
  // been installed (dev / CI).
11
11
  const installed = join(paths.bin, scriptName);
12
12
  if (existsSync(installed)) return installed;
13
- const repoRoot = fileURLToPath(new URL("..", import.meta.url));
13
+ const repoRoot = fileURLToPath(new URL("../..", import.meta.url));
14
14
  const dev = resolvePath(repoRoot, "scripts", scriptName);
15
15
  return existsSync(dev) ? dev : null;
16
16
  }
@@ -60,12 +60,22 @@ export async function runPythonSnippet({ script, args = [], extraEnv = {} }) {
60
60
  stdio: ["ignore", "pipe", "pipe"],
61
61
  env: { ...process.env, PYTHONPATH: buildPythonpath(paths), ...extraEnv },
62
62
  });
63
- let stdout = "";
64
- let stderr = "";
65
- child.stdout.on("data", (b) => (stdout += b.toString()));
66
- child.stderr.on("data", (b) => (stderr += b.toString()));
67
- child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
68
- child.on("close", (code) => resolve({ code, stdout, stderr }));
63
+ // Collect chunks and join once: repeated `s += chunk` is O(n^2) for the
64
+ // large rendered prompts some capture-mode callers stream through here.
65
+ const outChunks = [];
66
+ const errChunks = [];
67
+ child.stdout.on("data", (b) => outChunks.push(b));
68
+ child.stderr.on("data", (b) => errChunks.push(b));
69
+ child.on("error", (err) =>
70
+ resolve({ code: -1, stdout: Buffer.concat(outChunks).toString(), stderr: err.message }),
71
+ );
72
+ child.on("close", (code) =>
73
+ resolve({
74
+ code,
75
+ stdout: Buffer.concat(outChunks).toString(),
76
+ stderr: Buffer.concat(errChunks).toString(),
77
+ }),
78
+ );
69
79
  });
70
80
  }
71
81
 
@@ -76,14 +86,26 @@ export async function runPythonModule({ module, args = [], extraEnv = {}, stdio
76
86
  stdio: stdio === "capture" ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
77
87
  env: { ...process.env, PYTHONPATH: buildPythonpath(paths), ...extraEnv },
78
88
  });
79
- let stdout = "";
80
- let stderr = "";
89
+ // Collect chunks and join once: repeated `s += chunk` is O(n^2) for the
90
+ // large rendered prompts some capture-mode callers stream through here.
91
+ const outChunks = [];
92
+ const errChunks = [];
81
93
  if (stdio === "capture") {
82
- child.stdout.on("data", (b) => (stdout += b.toString()));
83
- child.stderr.on("data", (b) => (stderr += b.toString()));
94
+ child.stdout.on("data", (b) => outChunks.push(b));
95
+ child.stderr.on("data", (b) => errChunks.push(b));
84
96
  }
85
- child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
86
- child.on("close", (code) => resolve({ code, stdout, stderr }));
97
+ child.on("error", (err) =>
98
+ resolve({ code: -1, stdout: Buffer.concat(outChunks).toString(), stderr: err.message }),
99
+ );
100
+ // A signal-killed child reports code === null; normalize to a non-zero exit
101
+ // here so every consumer inherits correct failure semantics from one place.
102
+ child.on("close", (code) =>
103
+ resolve({
104
+ code: typeof code === "number" ? code : 1,
105
+ stdout: Buffer.concat(outChunks).toString(),
106
+ stderr: Buffer.concat(errChunks).toString(),
107
+ }),
108
+ );
87
109
  });
88
110
  }
89
111
 
@@ -3,7 +3,7 @@ import { fileURLToPath } from "node:url";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
5
  const here = dirname(fileURLToPath(import.meta.url));
6
- const packageJsonPath = join(here, "..", "package.json");
6
+ const packageJsonPath = join(here, "..", "..", "package.json");
7
7
 
8
8
  let cachedVersion = null;
9
9
 
@@ -16,5 +16,5 @@ export async function getPackageVersion() {
16
16
  }
17
17
 
18
18
  export function getPackageRoot() {
19
- return join(here, "..");
19
+ return join(here, "..", "..");
20
20
  }
File without changes
File without changes