@gr8ful/spf 0.14.0 → 0.16.0

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 (77) hide show
  1. package/README.md +51 -9
  2. package/assets/skill/references/config.md +12 -5
  3. package/assets/skill/references/observability.md +57 -12
  4. package/assets/templates/ts-opencode.spf.config.yaml +54 -0
  5. package/dist/chains/index.js +1 -1
  6. package/dist/chains/simple_sdlc.d.ts +2 -2
  7. package/dist/chains/simple_sdlc.js +13 -13
  8. package/dist/chains/steps.d.ts +2 -2
  9. package/dist/chains/steps.js +35 -19
  10. package/dist/cli/commands/abort.d.ts +1 -1
  11. package/dist/cli/commands/abort.js +30 -3
  12. package/dist/cli/commands/doctor.js +121 -8
  13. package/dist/cli/commands/estimate.js +3 -3
  14. package/dist/cli/commands/events.js +4 -4
  15. package/dist/cli/commands/fanout.js +93 -21
  16. package/dist/cli/commands/loop.js +31 -32
  17. package/dist/cli/commands/migrate.js +8 -1
  18. package/dist/cli/commands/phases.js +2 -2
  19. package/dist/cli/commands/sessions.js +2 -2
  20. package/dist/cli/commands/trace.d.ts +28 -8
  21. package/dist/cli/commands/trace.js +28 -15
  22. package/dist/cli/commands/ui.js +15 -5
  23. package/dist/cli/commands/watch.js +91 -30
  24. package/dist/cli/index.js +3 -1
  25. package/dist/cli/interview.d.ts +1 -0
  26. package/dist/cli/interview.js +95 -5
  27. package/dist/core/agent_opencode.d.ts +247 -0
  28. package/dist/core/agent_opencode.js +590 -0
  29. package/dist/core/agents.d.ts +12 -12
  30. package/dist/core/agents.js +113 -46
  31. package/dist/core/console.d.ts +12 -12
  32. package/dist/core/console.js +25 -25
  33. package/dist/core/data_types.d.ts +356 -15
  34. package/dist/core/data_types.js +180 -7
  35. package/dist/core/fanout.d.ts +1 -1
  36. package/dist/core/fanout.js +1 -1
  37. package/dist/core/gates.js +14 -1
  38. package/dist/core/issues/github_provider.d.ts +66 -2
  39. package/dist/core/issues/github_provider.js +161 -2
  40. package/dist/core/issues/jira_provider.d.ts +50 -9
  41. package/dist/core/issues/jira_provider.js +62 -2
  42. package/dist/core/paths.d.ts +41 -4
  43. package/dist/core/paths.js +32 -3
  44. package/dist/core/quality.d.ts +7 -7
  45. package/dist/core/quality.js +16 -10
  46. package/dist/core/refine.js +2 -2
  47. package/dist/core/runner.d.ts +9 -3
  48. package/dist/core/runner.js +39 -27
  49. package/dist/core/session.d.ts +2 -2
  50. package/dist/core/session.js +39 -18
  51. package/dist/core/sqlite.d.ts +14 -7
  52. package/dist/core/sqlite.js +14 -7
  53. package/dist/core/trace_db.d.ts +118 -0
  54. package/dist/core/trace_db.js +278 -0
  55. package/dist/core/tracer.d.ts +64 -34
  56. package/dist/core/tracer.js +141 -69
  57. package/dist/core/watch.d.ts +4 -4
  58. package/dist/core/watch.js +2 -2
  59. package/dist/ui/server/app.js +10 -10
  60. package/dist/ui/server/db.d.ts +89 -21
  61. package/dist/ui/server/db.js +235 -99
  62. package/dist/ui/server/serve.d.ts +5 -1
  63. package/dist/ui/server/serve.js +4 -5
  64. package/package.json +1 -1
  65. package/web/assets/index-CQ3k1Y1-.css +1 -0
  66. package/web/assets/index-CU8tom6S.js +21 -0
  67. package/web/assets/overpass-latin-400-normal-BpeLJ0bs.woff2 +0 -0
  68. package/web/assets/overpass-latin-600-normal-25RhTNCi.woff2 +0 -0
  69. package/web/assets/overpass-latin-700-normal-CQX2QTgM.woff2 +0 -0
  70. package/web/assets/overpass-mono-latin-400-normal-VINZG6Js.woff2 +0 -0
  71. package/web/assets/overpass-mono-latin-700-normal-D6nRBrbd.woff2 +0 -0
  72. package/web/index.html +33 -2
  73. package/web/logo.svg +4 -4
  74. package/web/assets/index-C7nF068F.css +0 -1
  75. package/web/assets/index-mzSArcnQ.js +0 -11
  76. package/web/assets/play-latin-400-normal-GKW-4YV7.woff2 +0 -0
  77. package/web/assets/play-latin-700-normal-DyPlLDbb.woff2 +0 -0
@@ -13,6 +13,7 @@ import { parse as parseYaml } from "yaml";
13
13
  import * as v from "valibot";
14
14
  import * as agentCc from "./agent_cc.js";
15
15
  import * as agentFlue from "./agent_flue.js";
16
+ import * as agentOpencode from "./agent_opencode.js";
16
17
  import * as paths from "./paths.js";
17
18
  import * as permissions from "./permissions.js";
18
19
  import * as prompts from "./prompts.js";
@@ -309,11 +310,13 @@ export function validate(cfg, required, requiredSuites = [], cwd) {
309
310
  problems.push(`agent ${JSON.stringify(name)}: ${label} ${error.message}`);
310
311
  }
311
312
  }
312
- // Model shape depends on the backend: Flue needs provider/model-id (no
313
- // catalog to check against, only the static shape); claude_code takes
314
- // its own bare alias/full-name vocabulary (see agent_cc.ts), so only a
315
- // non-empty check applies.
316
- if (agent.coding_agent === "flue") {
313
+ // Model shape depends on the backend: Flue and opencode both need
314
+ // provider/model-id (no catalog to check against for either opencode's
315
+ // own provider set differs from Flue's only the static shape, so
316
+ // `agentFlue.resolveModel`'s shape-only check is reused as-is); claude_code
317
+ // takes its own bare alias/full-name vocabulary (see agent_cc.ts), so only
318
+ // a non-empty check applies.
319
+ if (agent.coding_agent === "flue" || agent.coding_agent === "opencode") {
317
320
  try {
318
321
  agentFlue.resolveModel(agent.model);
319
322
  }
@@ -333,7 +336,7 @@ export function validate(cfg, required, requiredSuites = [], cwd) {
333
336
  problems.push(`agent ${JSON.stringify(name)}: harness_engineering is not supported (${JSON.stringify(agent.harness_engineering)}) — ` +
334
337
  `use useSubagent()/defineSkill() (flue) or an MCP server/plugin (claude_code) in a custom tool instead`);
335
338
  }
336
- const isKnownToolName = agent.coding_agent === "claude_code" ? agentCc.isKnownToolName : agentFlue.isKnownToolName;
339
+ const isKnownToolName = agent.coding_agent === "claude_code" ? agentCc.isKnownToolName : agent.coding_agent === "opencode" ? agentOpencode.isKnownToolName : agentFlue.isKnownToolName;
337
340
  for (const toolName of agent.tools ?? []) {
338
341
  if (!isKnownToolName(toolName)) {
339
342
  problems.push(`agent ${JSON.stringify(name)}: unknown tool ${JSON.stringify(toolName)} — known: read, write, edit, bash, grep, glob, find (alias for glob), ls (dropped, covered by bash/glob)`);
@@ -360,7 +363,7 @@ export function validate(cfg, required, requiredSuites = [], cwd) {
360
363
  // declared backend — the same branch this function already runs for
361
364
  // agent.model, just keyed off the tier's coding_agent instead.
362
365
  for (const tier of cfg.tiering.tiers) {
363
- if (tier.coding_agent === "flue") {
366
+ if (tier.coding_agent === "flue" || tier.coding_agent === "opencode") {
364
367
  try {
365
368
  agentFlue.resolveModel(tier.model);
366
369
  }
@@ -468,12 +471,13 @@ export function validateSandboxConfig(cfg, agent) {
468
471
  const problems = [];
469
472
  const sb = cfg.sandbox;
470
473
  const backend = agent.sandbox ?? sb.backend;
471
- // claude_code spawns a host process (agent_cc.ts's spawn()) with no
472
- // sandbox seam at all — sandboxing it would mean running the `claude` CLI
473
- // INSIDE the container, a different feature, not this config flag.
474
- if (agent.coding_agent === "claude_code" && backend !== "local") {
475
- problems.push(`agent ${JSON.stringify(agent.name)}: coding_agent "claude_code" cannot use sandbox backend ${JSON.stringify(backend)} ` +
476
- `claude_code always spawns a host process with no sandbox seam; set agent.sandbox: local (or sandbox.backend: local) for this agent`);
474
+ // claude_code and opencode both spawn a host process (agent_cc.ts's /
475
+ // agent_opencode.ts's own spawn()) with no sandbox seam at all —
476
+ // sandboxing either would mean running the CLI INSIDE the container, a
477
+ // different feature, not this config flag.
478
+ if ((agent.coding_agent === "claude_code" || agent.coding_agent === "opencode") && backend !== "local") {
479
+ problems.push(`agent ${JSON.stringify(agent.name)}: coding_agent ${JSON.stringify(agent.coding_agent)} cannot use sandbox backend ${JSON.stringify(backend)} ` +
480
+ `${agent.coding_agent} always spawns a host process with no sandbox seam; set agent.sandbox: local (or sandbox.backend: local) for this agent`);
477
481
  }
478
482
  if (backend === "opensandbox") {
479
483
  if (!sb.opensandbox.base_url.trim()) {
@@ -738,13 +742,15 @@ export async function execute(run, phase, call) {
738
742
  // translation need the spec baked into the rendered prompt text.
739
743
  const spec = sandboxSpecFor(run, agent);
740
744
  if (spec) {
741
- sandbox.registerRunLog(spec.adw_id, (e) => run.tracer.event(makeEventRecord({
745
+ sandbox.registerRunLog(spec.adw_id, (e) => void run.tracer
746
+ .event(makeEventRecord({
742
747
  adw_id: run.adw_id,
743
748
  phase_id: phase.phase_id,
744
749
  type: "log",
745
750
  name: "sandbox",
746
751
  payload: { level: e.level, msg: e.msg, ...(e.data ? { data: e.data } : {}) },
747
- })));
752
+ }))
753
+ .catch(() => { }));
748
754
  }
749
755
  const variables = {
750
756
  prompt: call.prompt,
@@ -762,8 +768,18 @@ export async function execute(run, phase, call) {
762
768
  const userText = prompts.render(paths.resolvePromptRef(run, agent.prompt_engineering.user), variables);
763
769
  prompts.save(path.join(agentDir, "prompts"), "system.md", systemText);
764
770
  prompts.save(path.join(agentDir, "prompts"), "user.md", userText);
765
- const { session_id: sessionId, is_new: isNewSession } = agentSessionId(run, agent);
766
- run.tracer.event(makeEventRecord({
771
+ // `sessionId` is reassigned by `send()` below (see its comment), ONLY for
772
+ // opencode, whenever its result carries the real captured id — its
773
+ // first-contact call starts from a cosmetic placeholder (see
774
+ // agent_opencode.ts's `pendingSessionLabel()`) and must swap in the real
775
+ // one before any later send() in this same phase (a JSON-repair retry, a
776
+ // gate correction). Deliberately scoped to opencode only, not a blanket
777
+ // reassignment from every backend's result — see `send()`'s comment for
778
+ // why that would NOT be a no-op for claude_code.
779
+ const initialSession = agentSessionId(run, agent);
780
+ const isNewSession = initialSession.is_new;
781
+ let sessionId = initialSession.session_id;
782
+ await run.tracer.event(makeEventRecord({
767
783
  adw_id: run.adw_id,
768
784
  phase_id: phase.phase_id,
769
785
  type: "agent_start",
@@ -779,7 +795,7 @@ export async function execute(run, phase, call) {
779
795
  harness_engineering: agent.harness_engineering,
780
796
  },
781
797
  }));
782
- run.console.agentStarted(agent.name, agent.model, sessionId);
798
+ await run.console.agentStarted(agent.name, agent.model, sessionId);
783
799
  // Parse retries and gate corrections re-enter the SAME Flue conversation,
784
800
  // so the last send is the one whose context occupancy is current — while
785
801
  // spend is the opposite: every send costs, so usage accumulates across all.
@@ -808,17 +824,46 @@ export async function execute(run, phase, call) {
808
824
  sandbox: spec,
809
825
  };
810
826
  const forward = eventForwarder(run, phase, agent.name, agent.coding_agent);
811
- const onSpawn = (pid) => run.tracer.processStart(run.adw_id, "agent", agent.name, pid, `${agent.coding_agent} ${agent.name} ${agent.model}`);
812
- const onExit = (pid) => run.tracer.processEnd(run.adw_id, pid);
827
+ // Best-effort, fire-and-forget: these fire from a plain process-lifecycle
828
+ // callback (agent_cc.ts/agent_flue.ts's onSpawn/onExit hooks), not from
829
+ // this function's own async control flow, so there is nothing useful to
830
+ // await here — same swallow-and-log discipline as tracer.ts's otel fanOut.
831
+ const onSpawn = (pid) => void run.tracer.processStart(run.adw_id, "agent", agent.name, pid, `${agent.coding_agent} ${agent.name} ${agent.model}`).catch(() => { });
832
+ const onExit = (pid) => void run.tracer.processEnd(run.adw_id, pid).catch(() => { });
813
833
  if (spec)
814
834
  await sandbox.reconcileWorkspace(spec);
815
- const result = agent.coding_agent === "claude_code"
816
- ? await agentCc.run(request, forward, onSpawn, onExit)
817
- : await agentFlue.run(request, forward, onSpawn, onExit);
835
+ let result;
836
+ if (agent.coding_agent === "claude_code") {
837
+ result = await agentCc.run(request, forward, onSpawn, onExit);
838
+ }
839
+ else if (agent.coding_agent === "opencode") {
840
+ result = await agentOpencode.run(request, forward, onSpawn, onExit);
841
+ }
842
+ else {
843
+ result = await agentFlue.run(request, forward, onSpawn, onExit);
844
+ }
818
845
  // SPEND IS RECORDED BEFORE THE EXTRACT CAN THROW: a failed extract must
819
846
  // not also lose this call's tokens/cost off the Run's ledger.
820
- run.addUsage(result.tokens, result.cost);
847
+ await run.addUsage(result.tokens, result.cost);
821
848
  spent.merge(result.usage);
849
+ // opencode-ONLY: every subsequent send() in THIS phase (JSON-repair
850
+ // retries, gate corrections) must target the real captured session, not
851
+ // the placeholder this phase started with (see `initialSession`'s
852
+ // comment above and agent_opencode.ts's session-id lifecycle notes).
853
+ // Deliberately NOT applied to flue/claude_code — agent_cc.ts returns
854
+ // `final.session_id ?? request.session_id`, which PREFERS Claude Code's
855
+ // own reported id over the requested one when the two differ, so
856
+ // reassigning unconditionally here would let a claude_code run's
857
+ // recorded session id (agent_map.json, the agent_sessions trace table)
858
+ // drift from the one actually passed to `--session-id`/`--resume` —
859
+ // scoping this to opencode keeps flue/claude_code byte-identical to
860
+ // before this backend existed. `result.session_id` is also guarded
861
+ // against a falsy value here: a still-unresolved [SOURCE] NDJSON field
862
+ // name in agent_opencode.ts's `run()` should surface as a thrown error
863
+ // there (see its "sawStop but no captured session id" check), never as
864
+ // a silently-persisted empty/placeholder id.
865
+ if (agent.coding_agent === "opencode" && result.session_id)
866
+ sessionId = result.session_id;
822
867
  latest = result;
823
868
  if (spec)
824
869
  await sandbox.extractWorkspace(spec);
@@ -837,15 +882,15 @@ export async function execute(run, phase, call) {
837
882
  for (const gate of call.gates || []) {
838
883
  const report = asReport(gate(envelope, run));
839
884
  const found = report.violations;
840
- run.tracer.gateRow(phase, gate.name, report, gateAttempt);
841
- run.tracer.event(makeEventRecord({
885
+ await run.tracer.gateRow(phase, gate.name, report, gateAttempt);
886
+ await run.tracer.event(makeEventRecord({
842
887
  adw_id: run.adw_id,
843
888
  phase_id: phase.phase_id,
844
889
  type: found.length > 0 ? "gate_fail" : "gate_pass",
845
890
  name: gate.name,
846
891
  payload: { attempt: gateAttempt, violations: found, checks: report.checks },
847
892
  }));
848
- run.console.gateResult(gate.name, report);
893
+ await run.console.gateResult(gate.name, report);
849
894
  violations.push(...found);
850
895
  }
851
896
  if (violations.length === 0)
@@ -854,7 +899,7 @@ export async function execute(run, phase, call) {
854
899
  throw new GateFailure(`${agent.name} failed gates after ${gateAttempt} attempt(s):\n- ` + violations.join("\n- "));
855
900
  }
856
901
  phase.attempt = gateAttempt;
857
- run.console.retry(agent.name, gateAttempt, phase.params.retries, `${violations.length} gate violation(s)`);
902
+ await run.console.retry(agent.name, gateAttempt, phase.params.retries, `${violations.length} gate violation(s)`);
858
903
  const correction = "Your previous response failed validation:\n- " +
859
904
  violations.join("\n- ") +
860
905
  "\n\nFix these problems, then re-emit ONLY your Report JSON.";
@@ -870,7 +915,7 @@ export async function execute(run, phase, call) {
870
915
  touched = permissions.enforce(run, phase, agent, treeBefore);
871
916
  }
872
917
  catch (breach) {
873
- run.tracer.event(makeEventRecord({
918
+ await run.tracer.event(makeEventRecord({
874
919
  adw_id: run.adw_id,
875
920
  phase_id: phase.phase_id,
876
921
  type: "error",
@@ -885,7 +930,7 @@ export async function execute(run, phase, call) {
885
930
  throw breach;
886
931
  }
887
932
  if (touched.length > 0) {
888
- run.tracer.event(makeEventRecord({
933
+ await run.tracer.event(makeEventRecord({
889
934
  adw_id: run.adw_id,
890
935
  phase_id: phase.phase_id,
891
936
  type: "log",
@@ -893,19 +938,19 @@ export async function execute(run, phase, call) {
893
938
  payload: { agent: agent.name, paths: touched },
894
939
  }));
895
940
  }
896
- persistEnvelope(run, phase, agent.name, call, envelope, parsed.attempt, true);
897
- run.console.envelopeSummary(envelope, call.output_type.name);
941
+ await persistEnvelope(run, phase, agent.name, call, envelope, parsed.attempt, true);
942
+ await run.console.envelopeSummary(envelope, call.output_type.name);
898
943
  const context = latest ?? result;
899
- run.tracer.agentSessionRow(run.adw_id, agent, sessionId, context.context_tokens, context.context_window);
944
+ await run.tracer.agentSessionRow(run.adw_id, agent, sessionId, context.context_tokens, context.context_window);
900
945
  run.saveAgentMap(agent.name, { session_id: sessionId, model: agent.model, coding_agent: agent.coding_agent });
901
- run.tracer.event(makeEventRecord({
946
+ await run.tracer.event(makeEventRecord({
902
947
  adw_id: run.adw_id,
903
948
  phase_id: phase.phase_id,
904
949
  type: "handoff",
905
950
  name: agent.name,
906
951
  payload: { artifacts: envelope.artifacts, summary: envelope.summary },
907
952
  }));
908
- run.tracer.event(makeEventRecord({
953
+ await run.tracer.event(makeEventRecord({
909
954
  adw_id: run.adw_id,
910
955
  phase_id: phase.phase_id,
911
956
  type: "agent_end",
@@ -919,7 +964,7 @@ export async function execute(run, phase, call) {
919
964
  context_window: context.context_window,
920
965
  },
921
966
  }));
922
- run.console.agentFinished(agent.name, spent.total_tokens, spent.total_cost);
967
+ await run.console.agentFinished(agent.name, spent.total_tokens, spent.total_cost);
923
968
  if (envelope.status !== "success") {
924
969
  throw new Error(`${agent.name} reported status=${JSON.stringify(envelope.status)}: ${envelope.summary}`);
925
970
  }
@@ -943,9 +988,26 @@ function asReport(result) {
943
988
  */
944
989
  function agentSessionId(run, agent) {
945
990
  const entry = run.agent_map[agent.name];
946
- if (entry && entry.model === agent.model)
991
+ // A session handle from one backend is meaningless to another — flue and
992
+ // opencode share the identical provider/model-id vocabulary, so a
993
+ // coding_agent flip on an existing agent_map.json entry can otherwise
994
+ // leave `entry.model === agent.model` true while the stored session_id's
995
+ // SHAPE no longer matches the new backend (e.g. a flue `spf-...` id handed
996
+ // to opencode's `--session`, which requires its own `ses_<ULID>` shape).
997
+ if (entry && entry.model === agent.model && entry.coding_agent === agent.coding_agent) {
947
998
  return { session_id: entry.session_id, is_new: false }; // rejoin the existing context window
948
- const session_id = agent.coding_agent === "claude_code" ? agentCc.newSessionId() : `spf-${run.adw_id}-${agent.name}-${newId(4)}`;
999
+ }
1000
+ // opencode assigns its OWN real session id on first contact (never
1001
+ // client-choosable — see agent_opencode.ts's module doc comment); what's
1002
+ // minted here is a purely cosmetic placeholder for the pre-call trace
1003
+ // event / console log, replaced by the real captured id the moment
1004
+ // send() gets its first result back (see execute()'s `sessionId` handling
1005
+ // below).
1006
+ const session_id = agent.coding_agent === "claude_code"
1007
+ ? agentCc.newSessionId()
1008
+ : agent.coding_agent === "opencode"
1009
+ ? agentOpencode.pendingSessionLabel()
1010
+ : `spf-${run.adw_id}-${agent.name}-${newId(4)}`;
949
1011
  return { session_id, is_new: true };
950
1012
  }
951
1013
  /**
@@ -955,15 +1017,19 @@ function agentSessionId(run, agent) {
955
1017
  * this stays a plain pass-through regardless of which one it's given.
956
1018
  */
957
1019
  function eventForwarder(run, phase, agentName, codingAgent) {
958
- const tracker = codingAgent === "claude_code" ? new agentCc.CcToolCallTracker() : new agentFlue.ToolCallTracker();
1020
+ const tracker = codingAgent === "claude_code" ? new agentCc.CcToolCallTracker() : codingAgent === "opencode" ? new agentOpencode.OcToolCallTracker() : new agentFlue.ToolCallTracker();
959
1021
  return (chunk) => {
960
1022
  const record = tracker.observe(chunk);
961
1023
  if (record === null)
962
1024
  return;
963
1025
  // The call's span rides the columns; duration_ms stays in the payload as
964
- // Flue's own authoritative number.
1026
+ // Flue's own authoritative number. Fire-and-forget, same reasoning as
1027
+ // onSpawn/onExit above: this callback fires from a synchronous stream
1028
+ // event handler (agent_cc.ts's readline `line` event / agent_flue.ts's
1029
+ // chunk stream), never from this function's own async control flow.
965
1030
  const { label, started_at, ended_at, ...rest } = record;
966
- run.tracer.event(makeEventRecord({
1031
+ void run.tracer
1032
+ .event(makeEventRecord({
967
1033
  adw_id: run.adw_id,
968
1034
  phase_id: phase.phase_id,
969
1035
  type: "tool_call",
@@ -971,7 +1037,8 @@ function eventForwarder(run, phase, agentName, codingAgent) {
971
1037
  started_at: started_at ?? null,
972
1038
  ended_at: ended_at ?? null,
973
1039
  payload: { ...rest, agent: agentName },
974
- }));
1040
+ }))
1041
+ .catch(() => { });
975
1042
  };
976
1043
  }
977
1044
  function extractJson(text) {
@@ -1016,11 +1083,11 @@ async function parseWithRetries(run, phase, call, result, send, spec) {
1016
1083
  }
1017
1084
  catch (error) {
1018
1085
  const message = describeParseError(error);
1019
- persistEnvelope(run, phase, phase.params.owner, call, null, attempt, false, current.text);
1086
+ await persistEnvelope(run, phase, phase.params.owner, call, null, attempt, false, current.text);
1020
1087
  if (attempt > JSON_FIX_ATTEMPTS) {
1021
1088
  throw new Error(`${phase.params.owner} never produced valid ${call.output_type.name} JSON: ${message}`);
1022
1089
  }
1023
- run.console.retry(phase.params.owner, attempt, JSON_FIX_ATTEMPTS, `invalid ${call.output_type.name} JSON: ${message}`);
1090
+ await run.console.retry(phase.params.owner, attempt, JSON_FIX_ATTEMPTS, `invalid ${call.output_type.name} JSON: ${message}`);
1024
1091
  const fields = call.output_type.fields.join(", ");
1025
1092
  current = await send(`Your response was not valid JSON for the required structure ` +
1026
1093
  `(${message}). Respond again with ONLY a JSON object with these ` +
@@ -1029,9 +1096,9 @@ async function parseWithRetries(run, phase, call, result, send, spec) {
1029
1096
  }
1030
1097
  throw new Error("unreachable");
1031
1098
  }
1032
- function persistEnvelope(run, phase, agentName, call, envelope, attempt, valid, raw = "") {
1099
+ async function persistEnvelope(run, phase, agentName, call, envelope, attempt, valid, raw = "") {
1033
1100
  const payloadJson = envelope ? JSON.stringify(envelope, null, 2) : JSON.stringify({ raw: raw.slice(-2000) });
1034
- run.tracer.envelopeRow(phase, agentName, call.output_type.name, payloadJson, valid, attempt);
1101
+ await run.tracer.envelopeRow(phase, agentName, call.output_type.name, payloadJson, valid, attempt);
1035
1102
  if (envelope) {
1036
1103
  const record = {
1037
1104
  agent_name: agentName,
@@ -13,7 +13,7 @@ interface Notifier {
13
13
  }
14
14
  export declare function paint(style: string, text: string): string;
15
15
  interface Tracer {
16
- event(record: EventRecord): string;
16
+ event(record: EventRecord): Promise<string>;
17
17
  }
18
18
  /**
19
19
  * A run-scoped hook for a live TTY view (`cli/ui/run_dashboard.tsx`) to
@@ -70,19 +70,19 @@ export declare class Console {
70
70
  /** See `RunObserver`'s own doc comment. `null` outside an interactive `cli/commands/run.ts` dispatch. */
71
71
  observer?: RunObserver | null);
72
72
  private emit;
73
- sessionStarted(adwId: string, engineer: string): void;
74
- sessionFinished(ok: boolean, tokens: number, cost: number, dbPath: string): void;
75
- phaseStarted(phase: Phase): void;
76
- phaseEnded(phase: Phase, seconds: number): void;
73
+ sessionStarted(adwId: string, engineer: string): Promise<void>;
74
+ sessionFinished(ok: boolean, tokens: number, cost: number, dbPath: string): Promise<void>;
75
+ phaseStarted(phase: Phase): Promise<void>;
76
+ phaseEnded(phase: Phase, seconds: number): Promise<void>;
77
77
  /** Free-form detail inside the current phase — what `ph.log()` recorded. */
78
- note(message: string): void;
78
+ note(message: string): Promise<void>;
79
79
  /** `Run.addUsage()`'s only hook into `Console` — the running total lives on `Run`, not here, so this just forwards it to the observer. No line prints for this on its own; the totals already show up in `sessionFinished`'s panel. */
80
- notifyUsage(tokens: number, cost: number): void;
81
- agentStarted(name: string, model: string, sessionId: string): void;
82
- agentFinished(name: string, tokens: number, cost: number): void;
83
- retry(name: string, attempt: number, limit: number, reason: string): void;
80
+ notifyUsage(tokens: number, cost: number): Promise<void>;
81
+ agentStarted(name: string, model: string, sessionId: string): Promise<void>;
82
+ agentFinished(name: string, tokens: number, cost: number): Promise<void>;
83
+ retry(name: string, attempt: number, limit: number, reason: string): Promise<void>;
84
84
  /** A gate reports WHAT it checked, not just whether it passed. */
85
- gateResult(name: string, report: GateReport): void;
86
- envelopeSummary(envelope: EnvelopeBase, typeName: string): void;
85
+ gateResult(name: string, report: GateReport): Promise<void>;
86
+ envelopeSummary(envelope: EnvelopeBase, typeName: string): Promise<void>;
87
87
  }
88
88
  export {};
@@ -77,9 +77,9 @@ export class Console {
77
77
  this.observer = observer;
78
78
  }
79
79
  // ── the one helper: print AND trace, always together ────────────────────
80
- emit(line, level = "info") {
80
+ async emit(line, level = "info") {
81
81
  this.sink(line);
82
- this.tracer.event(makeEventRecord({
82
+ await this.tracer.event(makeEventRecord({
83
83
  adw_id: this.adwId,
84
84
  phase_id: this.phaseId,
85
85
  type: "log",
@@ -88,8 +88,8 @@ export class Console {
88
88
  }));
89
89
  }
90
90
  // ── session ─────────────────────────────────────────────────────────────
91
- sessionStarted(adwId, engineer) {
92
- this.emit(`${paint("bold cyan", "adw_id:")} ${paint("bold", adwId)} ${paint("dim", "engineer")} ${engineer}`);
91
+ async sessionStarted(adwId, engineer) {
92
+ await this.emit(`${paint("bold cyan", "adw_id:")} ${paint("bold", adwId)} ${paint("dim", "engineer")} ${engineer}`);
93
93
  this.notifier?.send({
94
94
  kind: "run_started",
95
95
  level: "info",
@@ -100,7 +100,7 @@ export class Console {
100
100
  ],
101
101
  });
102
102
  }
103
- sessionFinished(ok, tokens, cost, dbPath) {
103
+ async sessionFinished(ok, tokens, cost, dbPath) {
104
104
  if (this.finished)
105
105
  return;
106
106
  this.finished = true;
@@ -119,7 +119,7 @@ export class Console {
119
119
  this.sink(rendered);
120
120
  this.observer?.onSessionEnd?.(ok);
121
121
  const plain = `session ${this.adwId} ${ok ? "success" : "fail"} · ${passed}/${this.results.length} phases · ${tokens.toLocaleString()} tokens · $${cost.toFixed(4)}`;
122
- this.tracer.event(makeEventRecord({
122
+ await this.tracer.event(makeEventRecord({
123
123
  adw_id: this.adwId,
124
124
  phase_id: this.phaseId,
125
125
  type: "log",
@@ -139,7 +139,7 @@ export class Console {
139
139
  });
140
140
  }
141
141
  // ── phases ──────────────────────────────────────────────────────────────
142
- phaseStarted(phase) {
142
+ async phaseStarted(phase) {
143
143
  this.phaseId = phase.phase_id;
144
144
  this.phaseName = phase.params.name;
145
145
  const p = phase.params;
@@ -148,17 +148,17 @@ export class Console {
148
148
  ` ${paint(color, p.kind)} ${paint("dim", `· ${p.owner}`)}`;
149
149
  if (p.description)
150
150
  line += ` ${paint("dim", clip(p.description))}`;
151
- this.emit(line);
151
+ await this.emit(line);
152
152
  this.observer?.onPhaseStart?.(phase);
153
153
  }
154
- phaseEnded(phase, seconds) {
154
+ async phaseEnded(phase, seconds) {
155
155
  const ok = phase.status === "success";
156
156
  this.results.push(phase.status);
157
157
  this.observer?.onPhaseEnd?.(phase, seconds);
158
158
  let line = ` ${ok ? paint("green", "✓") : paint("red", "✗")} ${phase.params.name} ${paint("dim", `${seconds.toFixed(1)}s`)}`;
159
159
  if (!ok && phase.error)
160
160
  line += ` ${paint("red", clip(phase.error))}`;
161
- this.emit(line, ok ? "info" : "error");
161
+ await this.emit(line, ok ? "info" : "error");
162
162
  if (!ok) {
163
163
  this.notifier?.send({
164
164
  kind: "phase_failed",
@@ -176,22 +176,22 @@ export class Console {
176
176
  this.phaseName = "";
177
177
  }
178
178
  /** Free-form detail inside the current phase — what `ph.log()` recorded. */
179
- note(message) {
180
- this.emit(` ${paint("dim", `· ${clip(message)}`)}`);
179
+ async note(message) {
180
+ await this.emit(` ${paint("dim", `· ${clip(message)}`)}`);
181
181
  }
182
182
  /** `Run.addUsage()`'s only hook into `Console` — the running total lives on `Run`, not here, so this just forwards it to the observer. No line prints for this on its own; the totals already show up in `sessionFinished`'s panel. */
183
- notifyUsage(tokens, cost) {
183
+ async notifyUsage(tokens, cost) {
184
184
  this.observer?.onUsage?.(tokens, cost);
185
185
  }
186
186
  // ── agents ──────────────────────────────────────────────────────────────
187
- agentStarted(name, model, sessionId) {
188
- this.emit(` ${paint("magenta", "▸")} ${name} ${paint("dim", model)} ${paint("dim", `session ${sessionId}`)}`);
187
+ async agentStarted(name, model, sessionId) {
188
+ await this.emit(` ${paint("magenta", "▸")} ${name} ${paint("dim", model)} ${paint("dim", `session ${sessionId}`)}`);
189
189
  }
190
- agentFinished(name, tokens, cost) {
191
- this.emit(` ${paint("dim", `└ ${name} used ${tokens.toLocaleString()} tokens · $${cost.toFixed(4)}`)}`);
190
+ async agentFinished(name, tokens, cost) {
191
+ await this.emit(` ${paint("dim", `└ ${name} used ${tokens.toLocaleString()} tokens · $${cost.toFixed(4)}`)}`);
192
192
  }
193
- retry(name, attempt, limit, reason) {
194
- this.emit(` ${paint("yellow", "⟳")} ${name} retry ${attempt}/${limit} ${paint("dim", `— same session · ${clip(reason)}`)}`, "warn");
193
+ async retry(name, attempt, limit, reason) {
194
+ await this.emit(` ${paint("yellow", "⟳")} ${name} retry ${attempt}/${limit} ${paint("dim", `— same session · ${clip(reason)}`)}`, "warn");
195
195
  // info-level: routine self-healing, same as watch's own untracked
196
196
  // orphan retries — visible under `events: all`, silent under `errors`.
197
197
  this.notifier?.send({
@@ -204,25 +204,25 @@ export class Console {
204
204
  }
205
205
  // ── verification ────────────────────────────────────────────────────────
206
206
  /** A gate reports WHAT it checked, not just whether it passed. */
207
- gateResult(name, report) {
207
+ async gateResult(name, report) {
208
208
  const ok = report.passed;
209
209
  const mark = ok ? paint("green", "✓") : paint("red", "✗");
210
210
  const summary = ok
211
211
  ? `${report.checks.length} checked`
212
212
  : paint("red", `${report.violations.length} of ${report.checks.length} failed`);
213
- this.emit(` ${mark} gate ${paint("dim", name)} ${paint("dim", summary)}`, ok ? "info" : "error");
213
+ await this.emit(` ${mark} gate ${paint("dim", name)} ${paint("dim", summary)}`, ok ? "info" : "error");
214
214
  for (const check of report.checks) {
215
215
  const style = check.ok ? "dim" : "dim red";
216
216
  const detail = check.note ? ` — ${clip(check.note)}` : "";
217
- this.emit(` ${paint(style, `${check.ok ? "·" : "✗"} ${clip(check.item)}${detail}`)}`, check.ok ? "info" : "error");
217
+ await this.emit(` ${paint(style, `${check.ok ? "·" : "✗"} ${clip(check.item)}${detail}`)}`, check.ok ? "info" : "error");
218
218
  }
219
219
  }
220
- envelopeSummary(envelope, typeName) {
220
+ async envelopeSummary(envelope, typeName) {
221
221
  const ok = envelope.status === "success";
222
222
  const line = ` ${ok ? paint("green", "✓") : paint("red", "✗")} ${typeName} ${paint("dim", clip(envelope.summary))}`;
223
- this.emit(line, ok ? "info" : "error");
223
+ await this.emit(line, ok ? "info" : "error");
224
224
  if (envelope.artifacts.length > 0) {
225
- this.emit(` ${paint("dim", `artifacts: ${clip(envelope.artifacts.join(", "))}`)}`);
225
+ await this.emit(` ${paint("dim", `artifacts: ${clip(envelope.artifacts.join(", "))}`)}`);
226
226
  }
227
227
  }
228
228
  }