lazycodex-ai 4.16.0 → 4.16.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 (112) hide show
  1. package/dist/cli/codex-ulw-loop.d.ts +8 -0
  2. package/dist/cli/get-local-version/types.d.ts +1 -1
  3. package/dist/cli/index.js +260 -207
  4. package/dist/cli-node/index.js +260 -207
  5. package/docs/reference/web-terminal-visual-qa.md +39 -45
  6. package/package.json +3 -2
  7. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  8. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  9. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  10. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  11. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  12. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  13. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  14. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  15. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  16. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  17. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  18. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  19. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  20. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  21. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +2 -2
  22. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  23. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  24. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  25. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  26. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  27. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  28. package/packages/omo-codex/plugin/components/ultrawork/directive.md +18 -10
  29. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  30. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  31. package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +18 -10
  32. package/packages/omo-codex/plugin/components/ultrawork/skills/ulw-plan/SKILL.md +11 -0
  33. package/packages/omo-codex/plugin/components/ultrawork/test/codex-hook.test.ts +22 -1
  34. package/packages/omo-codex/plugin/components/ulw-loop/CHANGELOG.md +4 -0
  35. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +18 -10
  36. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +15 -2
  37. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-steering.js +2 -1
  38. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +89 -27
  39. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.d.ts +6 -0
  40. package/packages/omo-codex/plugin/components/ulw-loop/dist/plan-io.js +55 -9
  41. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.d.ts +15 -0
  42. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-snapshot.js +33 -0
  43. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering-types.d.ts +10 -3
  44. package/packages/omo-codex/plugin/components/ulw-loop/dist/steering.js +15 -11
  45. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +2 -2
  46. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  47. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
  48. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +7 -7
  49. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +17 -2
  50. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-steering.ts +2 -1
  51. package/packages/omo-codex/plugin/components/ulw-loop/src/plan-io.ts +59 -11
  52. package/packages/omo-codex/plugin/components/ulw-loop/src/steering-snapshot.ts +38 -0
  53. package/packages/omo-codex/plugin/components/ulw-loop/src/steering-types.ts +11 -3
  54. package/packages/omo-codex/plugin/components/ulw-loop/src/steering.ts +15 -7
  55. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-create-goals.test.ts +16 -0
  56. package/packages/omo-codex/plugin/components/ulw-loop/test/plan-io.test.ts +260 -2
  57. package/packages/omo-codex/plugin/components/ulw-loop/test/steering-snapshot.test.ts +124 -0
  58. package/packages/omo-codex/plugin/components/ulw-loop/test/steering.test.ts +101 -2
  59. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  68. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  69. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  70. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  71. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  72. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  73. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  74. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  75. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
  76. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  77. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  78. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  79. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  80. package/packages/omo-codex/plugin/package-lock.json +13 -13
  81. package/packages/omo-codex/plugin/package.json +1 -1
  82. package/packages/omo-codex/plugin/scripts/hook-status-message.mjs +10 -6
  83. package/packages/omo-codex/plugin/skills/frontend/SKILL.md +9 -9
  84. package/packages/omo-codex/plugin/skills/frontend/references/design/README.md +7 -3
  85. package/packages/omo-codex/plugin/skills/frontend/references/design/_INDEX.md +1 -1
  86. package/packages/omo-codex/plugin/skills/frontend/references/design/design-system-architecture.md +24 -2
  87. package/packages/omo-codex/plugin/skills/frontend/references/designpowers/README.md +2 -2
  88. package/packages/omo-codex/plugin/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
  89. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -2
  90. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +18 -10
  91. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
  92. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +7 -7
  93. package/packages/omo-codex/plugin/skills/ulw-plan/SKILL.md +11 -0
  94. package/packages/omo-codex/plugin/skills/visual-qa/SKILL.md +13 -17
  95. package/packages/omo-codex/plugin/test/aggregate-hooks.test.mjs +4 -4
  96. package/packages/omo-codex/plugin/test/bootstrap-hooks.test.mjs +1 -1
  97. package/packages/omo-codex/plugin/test/hook-status-message.test.mjs +22 -9
  98. package/packages/omo-codex/plugin/test/sync-hook-status-messages.test.mjs +6 -6
  99. package/packages/omo-codex/plugin/test/ulw-plan-skill-contract.test.mjs +52 -0
  100. package/packages/omo-codex/scripts/install-dist/install-local.mjs +65 -39
  101. package/packages/shared-skills/skills/frontend/SKILL.md +9 -9
  102. package/packages/shared-skills/skills/frontend/references/design/README.md +7 -3
  103. package/packages/shared-skills/skills/frontend/references/design/_INDEX.md +1 -1
  104. package/packages/shared-skills/skills/frontend/references/design/design-system-architecture.md +24 -2
  105. package/packages/shared-skills/skills/frontend/references/designpowers/README.md +2 -2
  106. package/packages/shared-skills/skills/frontend/references/designpowers/lane-b-execution.md +1 -1
  107. package/packages/shared-skills/skills/start-work/SKILL.md +2 -2
  108. package/packages/shared-skills/skills/visual-qa/SKILL.md +13 -17
  109. package/script/qa/strip-ansi.mjs +10 -0
  110. package/script/qa/web-terminal-visual-qa.mjs +112 -195
  111. package/script/qa/xterm-live-terminal.mjs +180 -0
  112. package/script/qa/web-terminal-renderer.mjs +0 -218
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## [0.1.0] - unreleased
4
4
 
5
+ - **Memory:** steering ledger entries no longer embed the full plan four times (`before`/`after` at both the audit and entry level). Accepted steers now record a compact `UlwLoopSteeringPlanSnapshot` (plan counters + only the goals the mutation touched), shrinking a measured real-world entry from 189KB to 7.8KB (~24x) and ending quadratic `ledger.jsonl` growth over long runs.
6
+ - **Memory:** steering dedup (`--idempotency-key` / `promptSignature`) streams the ledger line-by-line with a substring pre-filter instead of `JSON.parse`-ing every entry into memory; dedup returns strip legacy full-plan `before`/`after` payloads from re-surfaced audits. `readSteeringLedgerEntries` streams too.
7
+ - **Memory:** `withUlwLoopMutationLock` no longer retains the mutation result (full plan/audit) per `(repo, scope)` in its module-level lock map; settled gates self-evict, so long-lived embedders stop accumulating entries.
8
+ - **Fix:** `omo ulw-loop steer --idempotency-key` was parsed but never forwarded into the proposal, so CLI steers never deduped. It is now wired through.
5
9
  - Standalone ultrawork injection (`--with-ultrawork`) now emits the same compact bootstrap pointer as the ultrawork component (opener mandate, `create_goal` with `objective` only, read the bundled `ultrawork` skill at a runtime-resolved absolute path), falling back to the full bundled directive when the plugin skills tree is absent. Keeps the injected payload below Codex App's hook-output truncation budget (code-yeongyu/oh-my-openagent#5828).
6
10
  - Initial scaffold of codex-ulw-loop plugin.
7
11
  - Per-Criterion Cycle: `EXECUTE` is now **EXECUTE-AS-SCENARIO** — the agent must run the Manual-QA channel scenario the criterion named (HTTP call / tmux / browser use / computer use; see new `## Manual-QA channels` section). Inserted a new **CLEAN (PAIRED, NEVER SKIP)** step that tears down every QA-spawned process / `tmux` session / browser context / container / port / temp dir before recording evidence; the cleanup receipt is embedded in the `--evidence` string. Missing receipt → record BLOCKED, not PASS. Added Constraint #13 and a Stop Rule for leftover state.
@@ -49,9 +49,10 @@ exercises the surface; capture the artifact.
49
49
  1. HTTP call — hit the live endpoint with `curl -i` (or a
50
50
  Playwright APIRequestContext); capture status line + headers +
51
51
  body.
52
- 2. tmux `tmux new-session -d -s ulw-qa-<criterion>`, drive with
53
- `send-keys`, dump via `tmux capture-pane -pS -E -`; transcript
54
- is the artifact.
52
+ 2. Terminal / TUI - drive a real pty and prove it through the
53
+ xterm.js web terminal (see the TUI visual QA note below). tmux
54
+ `send-keys` is fine for a boot smoke; NEVER `tmux capture-pane`
55
+ for color / layout / CJK evidence, which degrades truecolor.
55
56
  3. Browser use — in Codex, use `browser:control-in-app-browser`
56
57
  first when available and no authenticated/persistent user browser
57
58
  profile is required. Otherwise use Chrome to drive the REAL page;
@@ -79,13 +80,13 @@ channel scenario when the behavior is user-facing. `--dry-run`,
79
80
  printing the command, "should respond", and "looks correct" never
80
81
  count.
81
82
 
82
- For TUI visual QA, terminal transcripts alone are not enough when a
83
- visual surface is being evaluated. In this repo, prefer
84
- `node script/qa/web-terminal-visual-qa.mjs --title "<surface>" --from-file <capture.txt> --evidence-dir <dir>`
85
- or the helper's `--command` tmux-backed PTY connector when available.
86
- Outside this repo, capture equivalent browser/computer-use rendered
87
- terminal evidence: screenshot, plain transcript, rendered HTML or action
88
- log, and cleanup receipt.
83
+ For TUI visual QA, render the terminal through the real xterm.js web
84
+ terminal and screenshot it - never a `tmux capture-pane` dump, which
85
+ degrades color and wide-glyph width. In this repo:
86
+ `node script/qa/web-terminal-visual-qa.mjs --title "<surface>" --command "<cmd>" --input "{Enter}" --evidence-dir <dir>`
87
+ (live pty + xterm.js in Chrome; `--from-file <capture>` replays a raw
88
+ stream). Outside this repo, capture equivalent browser-rendered terminal
89
+ evidence: screenshot + plain transcript + cleanup receipt.
89
90
 
90
91
  # Bootstrap (DO ALL FOUR BEFORE ANY OTHER WORK — NO SKIPPING)
91
92
 
@@ -287,6 +288,13 @@ evidence for that step. Do not start dependent implementation until the
287
288
  audit, research, or review result is integrated or explicitly recorded
288
289
  as inconclusive. Do not generate a plan before spawned research lanes
289
290
  that feed the plan have returned or been closed as inconclusive.
291
+ Spawn every independent child for the current wave first. After the wave
292
+ is launched, run `multi_agent_v1.wait_agent` for each spawned child until
293
+ each reaches terminal status (`completed`, `failed`, `blocked`, or
294
+ explicitly recorded inconclusive) before any dependent `update_plan`
295
+ transition, `create_goal` continuation, implementation tool call, plan
296
+ drafting, approval-gate work, PR handoff, or final response. A timeout is
297
+ not terminal status.
290
298
  Do not write the final answer, PR handoff, or completion summary while
291
299
  active child agents remain open. Use short `multi_agent_v1.wait_agent` cycles.
292
300
  After two silent waits send `TASK STILL ACTIVE: return <deliverable> or
@@ -24,8 +24,8 @@ export async function ulwLoopCommand(argv) {
24
24
  const rest = argv.slice(1);
25
25
  const repoRoot = process.cwd();
26
26
  const json = hasFlag(rest, "--json");
27
- const scope = commandScope(rest);
28
27
  try {
28
+ const scope = commandScope(rest);
29
29
  if (!isUlwLoopSubcommand(command)) {
30
30
  if (json) {
31
31
  printJsonError(new UlwLoopError(`Unknown ulw-loop subcommand: ${command}.`, "ULW_LOOP_SUBCOMMAND_UNKNOWN", {
@@ -79,7 +79,20 @@ export async function ulwLoopCommand(argv) {
79
79
  function unhandledSubcommand(command) {
80
80
  throw new UlwLoopError(`Unhandled ulw-loop subcommand: ${String(command)}.`, "ULW_LOOP_SUBCOMMAND_UNHANDLED");
81
81
  }
82
+ const SESSION_ID_FLAG = "--session-id";
83
+ function sessionIdFlagPresent(argv) {
84
+ return hasFlag(argv, SESSION_ID_FLAG) || argv.some((arg) => arg.startsWith(`${SESSION_ID_FLAG}=`));
85
+ }
82
86
  function commandScope(argv) {
83
- const sessionId = readValue(argv, "--session-id") ?? resolveUlwLoopSessionIdFromEnv();
87
+ if (sessionIdFlagPresent(argv)) {
88
+ const sessionId = readValue(argv, SESSION_ID_FLAG)?.trim();
89
+ if (!sessionId) {
90
+ throw new UlwLoopError(`${SESSION_ID_FLAG} requires a non-empty value.`, "ULW_LOOP_SESSION_ID_REQUIRED", {
91
+ details: { flag: SESSION_ID_FLAG },
92
+ });
93
+ }
94
+ return { sessionId };
95
+ }
96
+ const sessionId = resolveUlwLoopSessionIdFromEnv();
84
97
  return sessionId === null ? undefined : { sessionId };
85
98
  }
@@ -83,7 +83,8 @@ function neverKind(kind) { return fail(`Unsupported steering kind: ${String(kind
83
83
  export async function parseSteeringProposal(argv) {
84
84
  const kind = parseSteeringKind(argv);
85
85
  const source = parseSteeringSource(argv);
86
- const base = { kind, source, evidence: required(argv, "--evidence"), rationale: required(argv, "--rationale") };
86
+ const idempotencyKey = text(readValue(argv, "--idempotency-key"), "--idempotency-key");
87
+ const base = { kind, source, evidence: required(argv, "--evidence"), rationale: required(argv, "--rationale"), ...(idempotencyKey === undefined ? {} : { idempotencyKey }) };
87
88
  switch (kind) {
88
89
  case "add_subgoal": return normalizeSteeringProposal({ ...base, title: required(argv, "--title"), objective: required(argv, "--objective") });
89
90
  case "split_subgoal": {
@@ -521,7 +521,9 @@ function formatCodexGoalReconciliation(reconciliation) {
521
521
  }
522
522
 
523
523
  // components/ulw-loop/src/plan-io.ts
524
+ import { createReadStream } from "node:fs";
524
525
  import { appendFile, mkdir, readFile as readFile4, rename, writeFile } from "node:fs/promises";
526
+ import { createInterface } from "node:readline";
525
527
  var LEGACY_OBJECTIVE_PREFIX = `Complete all ulw-loop stories in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}: `;
526
528
  var LEGACY_OBJECTIVE = `Complete all ulw-loop stories listed in ${ULW_LOOP_DIR}/${ULW_LOOP_GOALS}. Use ${ULW_LOOP_DIR}/${ULW_LOOP_LEDGER} as the durable audit trail.`;
527
529
  var locks = new Map;
@@ -540,11 +542,18 @@ async function withUlwLoopMutationLock(repoRoot, scopeOrFn, maybeFn) {
540
542
  if (fn === undefined)
541
543
  throw new UlwLoopError("Missing ulw-loop mutation body.", "ULW_LOOP_LOCK_BODY_MISSING");
542
544
  const lockKey = `${repoRoot}\x00${ulwLoopRelativeDir(scope)}`;
543
- const prior = locks.get(lockKey) ?? Promise.resolve();
545
+ const prior = locks.get(lockKey) ?? Promise.resolve(undefined);
544
546
  const run = prior.then(fn, fn);
545
- locks.set(lockKey, run.catch(() => {
547
+ const gate = run.then(() => {
546
548
  return;
547
- }));
549
+ }, () => {
550
+ return;
551
+ });
552
+ locks.set(lockKey, gate);
553
+ gate.then(() => {
554
+ if (locks.get(lockKey) === gate)
555
+ locks.delete(lockKey);
556
+ });
548
557
  return run;
549
558
  }
550
559
  async function readUlwLoopPlan(repoRoot, scope) {
@@ -591,22 +600,36 @@ async function appendLedger(repoRoot, entry, scope) {
591
600
  await appendFile(ulwLoopLedgerPath(repoRoot, scope), `${JSON.stringify(entry)}
592
601
  `, "utf8");
593
602
  }
594
- async function readSteeringLedgerEntries(repoRoot, scope) {
595
- let raw;
603
+ async function* ledgerLines(repoRoot, scope) {
604
+ const stream = createReadStream(ulwLoopLedgerPath(repoRoot, scope), { encoding: "utf8" });
605
+ const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
596
606
  try {
597
- raw = await readFile4(ulwLoopLedgerPath(repoRoot, scope), "utf8");
607
+ for await (const line of lines) {
608
+ if (line.trim().length > 0)
609
+ yield line;
610
+ }
598
611
  } catch (error) {
599
- if (hasCode(error, "ENOENT"))
600
- return [];
601
- throw error;
612
+ if (!hasCode(error, "ENOENT"))
613
+ throw error;
614
+ } finally {
615
+ lines.close();
616
+ stream.destroy();
602
617
  }
603
- const entries = [];
604
- for (const line of raw.split(/\r?\n/).filter(Boolean)) {
618
+ }
619
+ async function findAcceptedSteeringLedgerEntry(repoRoot, key, scope) {
620
+ const probe = JSON.stringify(key);
621
+ for await (const line of ledgerLines(repoRoot, scope)) {
622
+ if (!line.includes(probe))
623
+ continue;
605
624
  const entry = JSON.parse(line);
606
- if (isSteeringKind(entry.kind))
607
- entries.push(entry);
625
+ if (!isSteeringKind(entry.kind))
626
+ continue;
627
+ if (entry.steering?.invariant.accepted !== true)
628
+ continue;
629
+ if (entry.idempotencyKey === key || entry.steering.idempotencyKey === key || entry.steering.promptSignature === key)
630
+ return entry;
608
631
  }
609
- return entries;
632
+ return;
610
633
  }
611
634
 
612
635
  // components/ulw-loop/src/evidence.ts
@@ -1254,7 +1277,8 @@ function neverKind(kind) {
1254
1277
  async function parseSteeringProposal(argv) {
1255
1278
  const kind = parseSteeringKind(argv);
1256
1279
  const source = parseSteeringSource(argv);
1257
- const base = { kind, source, evidence: required2(argv, "--evidence"), rationale: required2(argv, "--rationale") };
1280
+ const idempotencyKey = text(readValue(argv, "--idempotency-key"), "--idempotency-key");
1281
+ const base = { kind, source, evidence: required2(argv, "--evidence"), rationale: required2(argv, "--rationale"), ...idempotencyKey === undefined ? {} : { idempotencyKey } };
1258
1282
  switch (kind) {
1259
1283
  case "add_subgoal":
1260
1284
  return normalizeSteeringProposal({ ...base, title: required2(argv, "--title"), objective: required2(argv, "--objective") });
@@ -1725,6 +1749,30 @@ async function recordFinalReviewBlockers(repoRoot, args, scope) {
1725
1749
  });
1726
1750
  }
1727
1751
 
1752
+ // components/ulw-loop/src/steering-snapshot.ts
1753
+ function buildSteeringPlanSnapshot(plan, changedGoalIds) {
1754
+ const snapshot = {
1755
+ updatedAt: plan.updatedAt,
1756
+ goalCount: plan.goals.length,
1757
+ goalIds: plan.goals.map((goal) => goal.id),
1758
+ goals: plan.goals.filter((goal) => changedGoalIds.has(goal.id))
1759
+ };
1760
+ return plan.activeGoalId === undefined ? snapshot : { ...snapshot, activeGoalId: plan.activeGoalId };
1761
+ }
1762
+ function changedGoalIdsBetween(before, after) {
1763
+ const beforeById = new Map(before.goals.map((goal) => [goal.id, goal]));
1764
+ const changed = new Set;
1765
+ for (const goal of after.goals) {
1766
+ const prior = beforeById.get(goal.id);
1767
+ if (prior === undefined || JSON.stringify(prior) !== JSON.stringify(goal))
1768
+ changed.add(goal.id);
1769
+ beforeById.delete(goal.id);
1770
+ }
1771
+ for (const id of beforeById.keys())
1772
+ changed.add(id);
1773
+ return changed;
1774
+ }
1775
+
1728
1776
  // components/ulw-loop/src/steering.ts
1729
1777
  var SOURCES2 = ["user_prompt_submit", "finding", "cli"];
1730
1778
  var PROTECTED = new Set(["aggregateCompletion", "codexObjective", "codexObjectiveAliases", "originalConstraints", "qualityGate", "status", "completedAt", "completionStatus"]);
@@ -1982,15 +2030,20 @@ async function steerUlwLoop(repoRoot, proposal, scope) {
1982
2030
  return withUlwLoopMutationLock(repoRoot, scope, async () => {
1983
2031
  const plan = await readUlwLoopPlan(repoRoot, scope);
1984
2032
  const key = proposal.idempotencyKey ?? proposal.promptSignature;
1985
- const prior = key === undefined ? undefined : (await readSteeringLedgerEntries(repoRoot, scope)).find((entry) => entry.steering?.invariant.accepted === true && (entry.idempotencyKey === key || entry.steering.idempotencyKey === key || entry.steering.promptSignature === key));
1986
- if (prior?.steering !== undefined)
1987
- return { plan, accepted: true, audit: { ...prior.steering, deduped: true }, rejectedReasons: [], deduped: true };
2033
+ const prior = key === undefined ? undefined : await findAcceptedSteeringLedgerEntry(repoRoot, key, scope);
2034
+ if (prior?.steering !== undefined) {
2035
+ const { before: _before, after: _after, ...compactPrior } = prior.steering;
2036
+ return { plan, accepted: true, audit: { ...compactPrior, deduped: true }, rejectedReasons: [], deduped: true };
2037
+ }
1988
2038
  const audit = validateUlwLoopSteeringProposal(plan, proposal);
1989
2039
  const accepted = audit.invariant.accepted;
1990
2040
  const next = accepted ? applySteeringMutation(plan, proposal, audit) : plan;
1991
- const finalAudit = { ...audit, before: plan };
1992
- if (accepted)
1993
- finalAudit.after = next;
2041
+ const finalAudit = { ...audit };
2042
+ if (accepted) {
2043
+ const changed = changedGoalIdsBetween(plan, next);
2044
+ finalAudit.before = buildSteeringPlanSnapshot(plan, changed);
2045
+ finalAudit.after = buildSteeringPlanSnapshot(next, changed);
2046
+ }
1994
2047
  if (accepted)
1995
2048
  await writePlan(repoRoot, next, scope);
1996
2049
  await appendLedger(repoRoot, ledgerEntry(proposal, finalAudit, proposal.now?.toISOString() ?? iso()), scope);
@@ -2006,10 +2059,6 @@ function ledgerEntry(proposal, audit, at) {
2006
2059
  entry.criterionId = proposal.criterionId;
2007
2060
  if (proposal.idempotencyKey !== undefined)
2008
2061
  entry.idempotencyKey = proposal.idempotencyKey;
2009
- if (audit.before !== undefined)
2010
- entry.before = audit.before;
2011
- if (audit.after !== undefined)
2012
- entry.after = audit.after;
2013
2062
  return entry;
2014
2063
  }
2015
2064
 
@@ -2205,8 +2254,8 @@ async function ulwLoopCommand(argv) {
2205
2254
  const rest = argv.slice(1);
2206
2255
  const repoRoot = process.cwd();
2207
2256
  const json = hasFlag(rest, "--json");
2208
- const scope = commandScope(rest);
2209
2257
  try {
2258
+ const scope = commandScope(rest);
2210
2259
  if (!isUlwLoopSubcommand(command)) {
2211
2260
  if (json) {
2212
2261
  printJsonError(new UlwLoopError(`Unknown ulw-loop subcommand: ${command}.`, "ULW_LOOP_SUBCOMMAND_UNKNOWN", {
@@ -2264,8 +2313,21 @@ async function ulwLoopCommand(argv) {
2264
2313
  function unhandledSubcommand(command) {
2265
2314
  throw new UlwLoopError(`Unhandled ulw-loop subcommand: ${String(command)}.`, "ULW_LOOP_SUBCOMMAND_UNHANDLED");
2266
2315
  }
2316
+ var SESSION_ID_FLAG = "--session-id";
2317
+ function sessionIdFlagPresent(argv) {
2318
+ return hasFlag(argv, SESSION_ID_FLAG) || argv.some((arg) => arg.startsWith(`${SESSION_ID_FLAG}=`));
2319
+ }
2267
2320
  function commandScope(argv) {
2268
- const sessionId = readValue(argv, "--session-id") ?? resolveUlwLoopSessionIdFromEnv();
2321
+ if (sessionIdFlagPresent(argv)) {
2322
+ const sessionId2 = readValue(argv, SESSION_ID_FLAG)?.trim();
2323
+ if (!sessionId2) {
2324
+ throw new UlwLoopError(`${SESSION_ID_FLAG} requires a non-empty value.`, "ULW_LOOP_SESSION_ID_REQUIRED", {
2325
+ details: { flag: SESSION_ID_FLAG }
2326
+ });
2327
+ }
2328
+ return { sessionId: sessionId2 };
2329
+ }
2330
+ const sessionId = resolveUlwLoopSessionIdFromEnv();
2269
2331
  return sessionId === null ? undefined : { sessionId };
2270
2332
  }
2271
2333
 
@@ -6,3 +6,9 @@ export declare function readUlwLoopPlan(repoRoot: string, scope?: UlwLoopScope):
6
6
  export declare function writePlan(repoRoot: string, plan: UlwLoopPlan, scope?: UlwLoopScope): Promise<void>;
7
7
  export declare function appendLedger(repoRoot: string, entry: UlwLoopLedgerEntry, scope?: UlwLoopScope): Promise<void>;
8
8
  export declare function readSteeringLedgerEntries(repoRoot: string, scope?: UlwLoopScope): Promise<UlwLoopLedgerEntry[]>;
9
+ /**
10
+ * First accepted steering entry matching an idempotency key/prompt signature.
11
+ * A cheap substring probe on the raw line skips JSON.parse for the vast
12
+ * majority of entries, so dedup stays flat even on legacy multi-MB ledgers.
13
+ */
14
+ export declare function findAcceptedSteeringLedgerEntry(repoRoot: string, key: string, scope?: UlwLoopScope): Promise<UlwLoopLedgerEntry | undefined>;
@@ -1,4 +1,6 @@
1
+ import { createReadStream } from "node:fs";
1
2
  import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { createInterface } from "node:readline";
2
4
  import { aggregateCodexObjectiveForScope } from "./goal-status.js";
3
5
  import { repoRelative, ulwLoopDir, ulwLoopGoalsPath, ulwLoopLedgerPath, ulwLoopRelativeDir, } from "./paths.js";
4
6
  import { iso, ULW_LOOP_DIR, ULW_LOOP_GOALS, ULW_LOOP_LEDGER, UlwLoopError } from "./types.js";
@@ -20,9 +22,17 @@ export async function withUlwLoopMutationLock(repoRoot, scopeOrFn, maybeFn) {
20
22
  if (fn === undefined)
21
23
  throw new UlwLoopError("Missing ulw-loop mutation body.", "ULW_LOOP_LOCK_BODY_MISSING");
22
24
  const lockKey = `${repoRoot}\0${ulwLoopRelativeDir(scope)}`;
23
- const prior = locks.get(lockKey) ?? Promise.resolve();
25
+ const prior = locks.get(lockKey) ?? Promise.resolve(undefined);
24
26
  const run = prior.then(fn, fn);
25
- locks.set(lockKey, run.catch(() => undefined));
27
+ // The stored gate resolves to undefined so the map never retains fn's result
28
+ // (plans/audits), and it removes itself once no newer waiter replaced it —
29
+ // otherwise a long-lived host leaks one entry per (repo, scope) forever.
30
+ const gate = run.then(() => undefined, () => undefined);
31
+ locks.set(lockKey, gate);
32
+ void gate.then(() => {
33
+ if (locks.get(lockKey) === gate)
34
+ locks.delete(lockKey);
35
+ });
26
36
  return run;
27
37
  }
28
38
  export async function readUlwLoopPlan(repoRoot, scope) {
@@ -69,21 +79,57 @@ export async function appendLedger(repoRoot, entry, scope) {
69
79
  await mkdir(ulwLoopDir(repoRoot, scope), { recursive: true });
70
80
  await appendFile(ulwLoopLedgerPath(repoRoot, scope), `${JSON.stringify(entry)}\n`, "utf8");
71
81
  }
72
- export async function readSteeringLedgerEntries(repoRoot, scope) {
73
- let raw;
82
+ /**
83
+ * Streams raw ledger lines without materializing the file. Real ledgers reach
84
+ * many MB (legacy entries embedded full-plan snapshots), so `readFile` here
85
+ * ballooned every steer/dedup path; line-at-a-time keeps memory O(longest line).
86
+ */
87
+ async function* ledgerLines(repoRoot, scope) {
88
+ const stream = createReadStream(ulwLoopLedgerPath(repoRoot, scope), { encoding: "utf8" });
89
+ const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
74
90
  try {
75
- raw = await readFile(ulwLoopLedgerPath(repoRoot, scope), "utf8");
91
+ for await (const line of lines) {
92
+ if (line.trim().length > 0)
93
+ yield line;
94
+ }
76
95
  }
77
96
  catch (error) {
78
- if (hasCode(error, "ENOENT"))
79
- return [];
80
- throw error;
97
+ if (!hasCode(error, "ENOENT"))
98
+ throw error;
99
+ }
100
+ finally {
101
+ lines.close();
102
+ stream.destroy();
81
103
  }
104
+ }
105
+ export async function readSteeringLedgerEntries(repoRoot, scope) {
82
106
  const entries = [];
83
- for (const line of raw.split(/\r?\n/).filter(Boolean)) {
107
+ for await (const line of ledgerLines(repoRoot, scope)) {
84
108
  const entry = JSON.parse(line);
85
109
  if (isSteeringKind(entry.kind))
86
110
  entries.push(entry);
87
111
  }
88
112
  return entries;
89
113
  }
114
+ /**
115
+ * First accepted steering entry matching an idempotency key/prompt signature.
116
+ * A cheap substring probe on the raw line skips JSON.parse for the vast
117
+ * majority of entries, so dedup stays flat even on legacy multi-MB ledgers.
118
+ */
119
+ export async function findAcceptedSteeringLedgerEntry(repoRoot, key, scope) {
120
+ const probe = JSON.stringify(key);
121
+ for await (const line of ledgerLines(repoRoot, scope)) {
122
+ if (!line.includes(probe))
123
+ continue;
124
+ const entry = JSON.parse(line);
125
+ if (!isSteeringKind(entry.kind))
126
+ continue;
127
+ if (entry.steering?.invariant.accepted !== true)
128
+ continue;
129
+ if (entry.idempotencyKey === key ||
130
+ entry.steering.idempotencyKey === key ||
131
+ entry.steering.promptSignature === key)
132
+ return entry;
133
+ }
134
+ return undefined;
135
+ }
@@ -0,0 +1,15 @@
1
+ import type { UlwLoopPlan } from "./domain-types.js";
2
+ import type { UlwLoopSteeringPlanSnapshot } from "./steering-types.js";
3
+ /**
4
+ * Compact before/after snapshots for steering ledger entries.
5
+ *
6
+ * Ledger entries used to embed the FULL plan (every goal, criterion, and
7
+ * evidence string) twice per audit and twice more at the entry top level.
8
+ * That made each accepted steer O(plan size) on disk, so the ledger grew
9
+ * quadratically over a run, and every dedup scan re-hydrated all of it into
10
+ * memory. A snapshot instead records plan-level counters plus only the goals
11
+ * the mutation actually touched, keeping each entry O(changed goals).
12
+ */
13
+ export declare function buildSteeringPlanSnapshot(plan: UlwLoopPlan, changedGoalIds: ReadonlySet<string>): UlwLoopSteeringPlanSnapshot;
14
+ /** Ids of goals that differ between two plans, including added or removed goals. */
15
+ export declare function changedGoalIdsBetween(before: UlwLoopPlan, after: UlwLoopPlan): Set<string>;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Compact before/after snapshots for steering ledger entries.
3
+ *
4
+ * Ledger entries used to embed the FULL plan (every goal, criterion, and
5
+ * evidence string) twice per audit and twice more at the entry top level.
6
+ * That made each accepted steer O(plan size) on disk, so the ledger grew
7
+ * quadratically over a run, and every dedup scan re-hydrated all of it into
8
+ * memory. A snapshot instead records plan-level counters plus only the goals
9
+ * the mutation actually touched, keeping each entry O(changed goals).
10
+ */
11
+ export function buildSteeringPlanSnapshot(plan, changedGoalIds) {
12
+ const snapshot = {
13
+ updatedAt: plan.updatedAt,
14
+ goalCount: plan.goals.length,
15
+ goalIds: plan.goals.map((goal) => goal.id),
16
+ goals: plan.goals.filter((goal) => changedGoalIds.has(goal.id)),
17
+ };
18
+ return plan.activeGoalId === undefined ? snapshot : { ...snapshot, activeGoalId: plan.activeGoalId };
19
+ }
20
+ /** Ids of goals that differ between two plans, including added or removed goals. */
21
+ export function changedGoalIdsBetween(before, after) {
22
+ const beforeById = new Map(before.goals.map((goal) => [goal.id, goal]));
23
+ const changed = new Set();
24
+ for (const goal of after.goals) {
25
+ const prior = beforeById.get(goal.id);
26
+ if (prior === undefined || JSON.stringify(prior) !== JSON.stringify(goal))
27
+ changed.add(goal.id);
28
+ beforeById.delete(goal.id);
29
+ }
30
+ for (const id of beforeById.keys())
31
+ changed.add(id);
32
+ return changed;
33
+ }
@@ -1,5 +1,5 @@
1
1
  import type { UlwLoopSteeringMutationKind, UlwLoopSteeringSource } from "./constants.js";
2
- import type { UlwLoopPlan } from "./domain-types.js";
2
+ import type { UlwLoopItem, UlwLoopPlan } from "./domain-types.js";
3
3
  export interface UlwLoopSteeringInvariantResult {
4
4
  accepted: boolean;
5
5
  structuralInvariantAccepted: boolean;
@@ -39,13 +39,20 @@ export interface UlwLoopSteeringProposal {
39
39
  idempotencyKey?: string;
40
40
  now?: Date;
41
41
  }
42
+ export interface UlwLoopSteeringPlanSnapshot {
43
+ readonly updatedAt: string;
44
+ readonly activeGoalId?: string;
45
+ readonly goalCount: number;
46
+ readonly goalIds: readonly string[];
47
+ readonly goals: readonly UlwLoopItem[];
48
+ }
42
49
  export interface UlwLoopSteeringAudit {
43
50
  kind: UlwLoopSteeringMutationKind;
44
51
  source: UlwLoopSteeringSource;
45
52
  targetGoalIds: string[];
46
53
  criterionId?: string;
47
- before?: unknown;
48
- after?: unknown;
54
+ before?: UlwLoopSteeringPlanSnapshot;
55
+ after?: UlwLoopSteeringPlanSnapshot;
49
56
  evidence: string;
50
57
  rationale: string;
51
58
  invariant: UlwLoopSteeringInvariantResult;
@@ -1,7 +1,8 @@
1
1
  // biome-ignore-all format: compact steering module must stay below the 240 pure-LOC budget
2
2
  import { isUlwLoopDone } from "./goal-status.js";
3
3
  import { seedDefaultSuccessCriteria } from "./plan-crud.js";
4
- import { appendLedger, readSteeringLedgerEntries, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js";
4
+ import { appendLedger, findAcceptedSteeringLedgerEntry, readUlwLoopPlan, withUlwLoopMutationLock, writePlan } from "./plan-io.js";
5
+ import { buildSteeringPlanSnapshot, changedGoalIdsBetween } from "./steering-snapshot.js";
5
6
  import { iso, ULW_LOOP_STEERING_MUTATION_KINDS, ULW_LOOP_SUCCESS_CRITERION_USER_MODELS } from "./types.js";
6
7
  const SOURCES = ["user_prompt_submit", "finding", "cli"];
7
8
  const PROTECTED = new Set(["aggregateCompletion", "codexObjective", "codexObjectiveAliases", "originalConstraints", "qualityGate", "status", "completedAt", "completionStatus"]);
@@ -260,15 +261,22 @@ export async function steerUlwLoop(repoRoot, proposal, scope) {
260
261
  return withUlwLoopMutationLock(repoRoot, scope, async () => {
261
262
  const plan = await readUlwLoopPlan(repoRoot, scope);
262
263
  const key = proposal.idempotencyKey ?? proposal.promptSignature;
263
- const prior = key === undefined ? undefined : (await readSteeringLedgerEntries(repoRoot, scope)).find((entry) => entry.steering?.invariant.accepted === true && (entry.idempotencyKey === key || entry.steering.idempotencyKey === key || entry.steering.promptSignature === key));
264
- if (prior?.steering !== undefined)
265
- return { plan, accepted: true, audit: { ...prior.steering, deduped: true }, rejectedReasons: [], deduped: true };
264
+ const prior = key === undefined ? undefined : await findAcceptedSteeringLedgerEntry(repoRoot, key, scope);
265
+ if (prior?.steering !== undefined) {
266
+ // Legacy entries embedded full-plan before/after snapshots; never
267
+ // re-surface those multi-MB payloads on the dedup path.
268
+ const { before: _before, after: _after, ...compactPrior } = prior.steering;
269
+ return { plan, accepted: true, audit: { ...compactPrior, deduped: true }, rejectedReasons: [], deduped: true };
270
+ }
266
271
  const audit = validateUlwLoopSteeringProposal(plan, proposal);
267
272
  const accepted = audit.invariant.accepted;
268
273
  const next = accepted ? applySteeringMutation(plan, proposal, audit) : plan;
269
- const finalAudit = { ...audit, before: plan };
270
- if (accepted)
271
- finalAudit.after = next;
274
+ const finalAudit = { ...audit };
275
+ if (accepted) {
276
+ const changed = changedGoalIdsBetween(plan, next);
277
+ finalAudit.before = buildSteeringPlanSnapshot(plan, changed);
278
+ finalAudit.after = buildSteeringPlanSnapshot(next, changed);
279
+ }
272
280
  if (accepted)
273
281
  await writePlan(repoRoot, next, scope);
274
282
  await appendLedger(repoRoot, ledgerEntry(proposal, finalAudit, proposal.now?.toISOString() ?? iso()), scope);
@@ -284,9 +292,5 @@ function ledgerEntry(proposal, audit, at) {
284
292
  entry.criterionId = proposal.criterionId;
285
293
  if (proposal.idempotencyKey !== undefined)
286
294
  entry.idempotencyKey = proposal.idempotencyKey;
287
- if (audit.before !== undefined)
288
- entry.before = audit.before;
289
- if (audit.after !== undefined)
290
- entry.after = audit.after;
291
295
  return entry;
292
296
  }
@@ -7,7 +7,7 @@
7
7
  "type": "command",
8
8
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit --with-ultrawork",
9
9
  "timeout": 10,
10
- "statusMessage": "(OmO) Checking Ulw-Loop Steering"
10
+ "statusMessage": "(OmO 4.16.1) Checking Ulw-Loop Steering"
11
11
  }
12
12
  ]
13
13
  }
@@ -20,7 +20,7 @@
20
20
  "type": "command",
21
21
  "command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook pre-tool-use",
22
22
  "timeout": 5,
23
- "statusMessage": "(OmO) Enforcing Unlimited Ulw-Loop Budget"
23
+ "statusMessage": "(OmO 4.16.1) Enforcing Unlimited Ulw-Loop Budget"
24
24
  }
25
25
  ]
26
26
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/codex-ulw-loop",
3
- "version": "4.16.0",
3
+ "version": "4.16.1",
4
4
  "description": "Codex plugin: durable repo-native multi-goal orchestration with embedded success criteria and observable evidence audit.",
5
5
  "type": "module",
6
6
  "packageManager": "npm@11.12.1",
@@ -22,7 +22,7 @@ This skill is intentionally compact. The full workflow lives in `references/full
22
22
  - Use the ulw-loop CLI state under `.omo/ulw-loop`; do not hand-edit goal state.
23
23
  - After any compaction or context loss, re-read brief + goals + ledger FIRST plus `omo ulw-loop status --json`, then resume; never re-plan from scratch.
24
24
  - If `omo ulw-loop create-goals` says the existing aggregate is already complete, start unrelated new work with a fresh `--session-id <new-id>` instead of steering or forcing the completed default state. Use `--force` only to intentionally overwrite completed evidence.
25
- - Every success criterion needs observable evidence from a real surface: a channel (tmux, HTTP, browser, computer-use) or, for CLI- or data-shaped criteria, an auxiliary surface (CLI stdout, DB diff, parsed config dump).
25
+ - Every success criterion needs observable evidence from a real surface: a channel (terminal/TUI via the xterm.js web terminal, HTTP, browser, computer-use) or, for CLI- or data-shaped criteria, an auxiliary surface (CLI stdout, DB diff, parsed config dump).
26
26
  - Record evidence through the CLI only after cleanup receipts are available.
27
27
  - Delegate code edits, test writes, fixes, and QA execution to right-sized Codex subagents when the workflow requires it.
28
28
  - Every `multi_agent_v1.spawn_agent` message starts with `TASK:`, then names `DELIVERABLE`, `SCOPE`, and `VERIFY`; put role and specialty instructions inside `message`; use `fork_context: false` unless full history is truly required.
@@ -19,16 +19,16 @@ Audit each pass, fail, block, steering change, and checkpoint in `.omo/ulw-loop/
19
19
  Run each criterion's real-surface proof yourself through the channel that faithfully exercises it; capture the artifact before recording PASS.
20
20
 
21
21
  1. **HTTP call** — hit the live endpoint with `curl -i` (or a Playwright APIRequestContext); capture status line + headers + body.
22
- 2. **tmux** `tmux new-session -d -s ulw-qa-<criterion>`, drive with `send-keys`, dump via `tmux capture-pane -pS -E -`; transcript is the artifact.
22
+ 2. **Terminal / TUI** - prove it through the xterm.js web terminal; tmux `send-keys` is fine for a boot smoke, but NEVER `tmux capture-pane` for color/layout/CJK evidence (it degrades truecolor).
23
23
  3. **Browser use** — in Codex, use `browser:control-in-app-browser` first when available and the scenario does not need an authenticated or persistent user browser profile. Otherwise use Chrome to drive the REAL page; if unavailable, use agent-browser. Capture action log + screenshot path. Never downgrade a browser-facing criterion.
24
24
  4. **Computer use** — for desktop/GUI apps, drive the running app via OS automation (computer-use, AppleScript, xdotool, etc.); capture action log + screenshot.
25
25
 
26
- For TUI visual QA, pair the tmux transcript with a browser-rendered terminal
27
- screenshot. In this repo run `node script/qa/web-terminal-visual-qa.mjs
28
- --from-file <capture.txt> --evidence-dir <dir>` and record `terminal.png`,
29
- `terminal.html`, `terminal.txt`, and `metadata.json` as the visual evidence
30
- bundle. This is mandatory when a PR or review needs to inspect the terminal
31
- screen, not just the text.
26
+ For TUI visual QA, render the terminal through the real xterm.js web terminal and
27
+ screenshot it - NEVER a `tmux capture-pane` dump (it degrades color and wide-glyph
28
+ width). In this repo run `node script/qa/web-terminal-visual-qa.mjs --command
29
+ "<cmd>" --input "{Enter}" --evidence-dir <dir>` (live pty + xterm.js in Chrome;
30
+ `--from-file` replays a raw stream) and record `terminal.png`, `terminal.txt`, and
31
+ `metadata.json`. Mandatory when a PR or review must inspect the terminal screen.
32
32
 
33
33
  Auxiliary surfaces (CLI stdout / DB state diff / parsed config dump) are first-class evidence for CLI- or data-shaped criteria; use a channel scenario when the behavior is user-facing. `--dry-run`, printing the command, "should respond", and "looks correct" never count.
34
34
 
@@ -39,8 +39,8 @@ export async function ulwLoopCommand(argv: readonly string[]): Promise<number> {
39
39
  const rest = argv.slice(1);
40
40
  const repoRoot = process.cwd();
41
41
  const json = hasFlag(rest, "--json");
42
- const scope = commandScope(rest);
43
42
  try {
43
+ const scope = commandScope(rest);
44
44
  if (!isUlwLoopSubcommand(command)) {
45
45
  if (json) {
46
46
  printJsonError(
@@ -94,7 +94,22 @@ function unhandledSubcommand(command: never): never {
94
94
  throw new UlwLoopError(`Unhandled ulw-loop subcommand: ${String(command)}.`, "ULW_LOOP_SUBCOMMAND_UNHANDLED");
95
95
  }
96
96
 
97
+ const SESSION_ID_FLAG = "--session-id";
98
+
99
+ function sessionIdFlagPresent(argv: readonly string[]): boolean {
100
+ return hasFlag(argv, SESSION_ID_FLAG) || argv.some((arg) => arg.startsWith(`${SESSION_ID_FLAG}=`));
101
+ }
102
+
97
103
  function commandScope(argv: readonly string[]): UlwLoopScope | undefined {
98
- const sessionId = readValue(argv, "--session-id") ?? resolveUlwLoopSessionIdFromEnv();
104
+ if (sessionIdFlagPresent(argv)) {
105
+ const sessionId = readValue(argv, SESSION_ID_FLAG)?.trim();
106
+ if (!sessionId) {
107
+ throw new UlwLoopError(`${SESSION_ID_FLAG} requires a non-empty value.`, "ULW_LOOP_SESSION_ID_REQUIRED", {
108
+ details: { flag: SESSION_ID_FLAG },
109
+ });
110
+ }
111
+ return { sessionId };
112
+ }
113
+ const sessionId = resolveUlwLoopSessionIdFromEnv();
99
114
  return sessionId === null ? undefined : { sessionId };
100
115
  }