@sweny-ai/core 0.1.170 → 0.1.172

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 (64) hide show
  1. package/dist/agent-env.d.ts +2 -1
  2. package/dist/agent-env.js +9 -3
  3. package/dist/browser.d.ts +2 -0
  4. package/dist/browser.js +3 -0
  5. package/dist/budget.js +5 -1
  6. package/dist/cli/check.js +12 -12
  7. package/dist/cli/comment-output.d.ts +4 -2
  8. package/dist/cli/comment-output.js +16 -8
  9. package/dist/cli/config.js +24 -24
  10. package/dist/cli/e2e.js +17 -17
  11. package/dist/cli/main.js +42 -26
  12. package/dist/cli/marketplace.js +3 -3
  13. package/dist/cli/new.js +11 -8
  14. package/dist/cli/output.d.ts +3 -2
  15. package/dist/cli/output.js +39 -37
  16. package/dist/cli/progress.d.ts +33 -0
  17. package/dist/cli/progress.js +69 -0
  18. package/dist/cli/publish.js +2 -2
  19. package/dist/cli/record.d.ts +66 -0
  20. package/dist/cli/record.js +344 -0
  21. package/dist/cli/renderer.d.ts +4 -0
  22. package/dist/cli/renderer.js +20 -13
  23. package/dist/cli/run-output.d.ts +13 -6
  24. package/dist/cli/run-output.js +36 -26
  25. package/dist/cli/runs.js +17 -11
  26. package/dist/cli/setup.js +7 -7
  27. package/dist/cli/style.d.ts +26 -0
  28. package/dist/cli/style.js +48 -0
  29. package/dist/cli/terminal.d.ts +47 -0
  30. package/dist/cli/terminal.js +123 -0
  31. package/dist/cli/ticket.d.ts +89 -0
  32. package/dist/cli/ticket.js +218 -0
  33. package/dist/cli/try.d.ts +19 -8
  34. package/dist/cli/try.js +97 -65
  35. package/dist/cli/upgrade.js +1 -1
  36. package/dist/executor.d.ts +8 -1
  37. package/dist/executor.js +20 -2
  38. package/dist/git-credentials.d.ts +106 -0
  39. package/dist/git-credentials.js +322 -0
  40. package/dist/harness/acp.js +3 -0
  41. package/dist/harness/claude-code.d.ts +7 -0
  42. package/dist/harness/claude-code.js +44 -3
  43. package/dist/harness/codex.js +3 -0
  44. package/dist/harness/pi.js +3 -0
  45. package/dist/harness/policy.d.ts +5 -0
  46. package/dist/harness/policy.js +14 -0
  47. package/dist/harness/sandbox-wrapper.d.ts +9 -2
  48. package/dist/harness/sandbox-wrapper.js +14 -4
  49. package/dist/harness/types.d.ts +10 -0
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.js +2 -0
  52. package/dist/mermaid.d.ts +10 -5
  53. package/dist/mermaid.js +11 -13
  54. package/dist/safe-outputs.js +5 -0
  55. package/dist/skills/git-push.d.ts +62 -0
  56. package/dist/skills/git-push.js +132 -0
  57. package/dist/skills/github.d.ts +17 -0
  58. package/dist/skills/github.js +23 -0
  59. package/dist/theme.d.ts +185 -0
  60. package/dist/theme.js +183 -0
  61. package/dist/workflows/browser.js +2 -2
  62. package/dist/workflows/implement.yml +2 -1
  63. package/dist/workflows/triage.yml +2 -1
  64. package/package.json +6 -1
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The receipt ticket: the run receipt as a small perforated card with a tear
3
+ * line and a policy stamp. Shown at the end of a run on a TTY, and at the top
4
+ * of the PR comment. CI logs, pipes and `--json` keep the plain receipt line.
5
+ *
6
+ * METADATA ONLY, like the receipt line: counts, durations, tokens, cost,
7
+ * policy facts. Never prompts, node outputs, tool inputs, or model prose.
8
+ */
9
+ import { formatBudgetAmount, formatCost, formatDeciderCounts, formatReceiptDuration, formatTokenCount, policyParts, renderReceiptLine, } from "./run-output.js";
10
+ import { createPaint } from "./style.js";
11
+ import { colorEnabled, glyphsFor, richOutput, supportsUnicode, terminalColumns, ticketBoxFor } from "./terminal.js";
12
+ /** Widest ticket, in columns, frame included. */
13
+ export const TICKET_MAX_WIDTH = 48;
14
+ /** Narrowest ticket. Below this the fields stop being readable. */
15
+ export const TICKET_MIN_WIDTH = 32;
16
+ /** Left indent the CLI prints the ticket at. */
17
+ export const TICKET_INDENT = 2;
18
+ /** How long the stub sits empty before the stamp lands (the one animation frame). */
19
+ export const STAMP_DELAY_MS = 140;
20
+ const LABEL_WIDTH = 12;
21
+ /** `20260930-110000-3f9a2c` shows as `3f9a2c`; any other id as its first 8 characters. */
22
+ export function shortRunId(runId) {
23
+ const m = /-([0-9a-f]{6,})$/i.exec(runId);
24
+ return m ? m[1] : runId.slice(0, 8);
25
+ }
26
+ /** The ticket's status word. */
27
+ export function ticketStatus(data) {
28
+ if (data.crashed)
29
+ return "crashed";
30
+ if (data.summary.budget)
31
+ return "budget stop";
32
+ return data.summary.ok ? "passed" : "failed";
33
+ }
34
+ /**
35
+ * The policy stamp words, from what the run reported: `ENV SCOPED`,
36
+ * `SANDBOXED`. Empty when no node reported env scope or sandbox facts.
37
+ * `strong` is false when any fact is weaker than the default (an unscoped
38
+ * env, no sandbox), which the stamp shows in the warning color.
39
+ */
40
+ export function stampWords(summary) {
41
+ const p = summary.policy;
42
+ const words = [];
43
+ let strong = true;
44
+ if (p?.envScope !== undefined) {
45
+ words.push(p.envScope ? "ENV SCOPED" : "ENV UNSCOPED");
46
+ strong &&= p.envScope;
47
+ }
48
+ if (p?.sandbox) {
49
+ const on = p.sandbox.mode !== "off" && p.sandbox.started;
50
+ words.push(on ? "SANDBOXED" : "UNSANDBOXED");
51
+ strong &&= on;
52
+ }
53
+ return { words, strong };
54
+ }
55
+ /** Field rows, label then value, metadata only. */
56
+ function ticketFields(s) {
57
+ const failed = Math.max(0, s.nodesTotal - s.nodesOk - s.nodesSkipped);
58
+ const nodes = [
59
+ `${s.nodesOk}/${s.nodesTotal}`,
60
+ ...(failed > 0 ? [`${failed} failed`] : []),
61
+ ...(s.nodesSkipped > 0 ? [`${s.nodesSkipped} skipped`] : []),
62
+ ].join(", ");
63
+ const policy = policyParts(s.policy);
64
+ return [
65
+ ["nodes", nodes],
66
+ ["tool calls", String(s.toolCalls)],
67
+ ["duration", formatReceiptDuration(s.durationMs)],
68
+ ...(s.tokens !== undefined ? [["tokens", formatTokenCount(s.tokens)]] : []),
69
+ ...(s.costUsd !== undefined ? [["cost", formatCost(s.costUsd)]] : []),
70
+ ...(s.harness !== undefined && s.harness !== "claude-code" ? [["harness", s.harness]] : []),
71
+ ...(s.degraded && s.degraded.length > 0 ? [["degraded", s.degraded.join(", ")]] : []),
72
+ ...(s.decider ? [["decider", formatDeciderCounts(s.decider)]] : []),
73
+ ...(s.budget
74
+ ? [
75
+ ["budget", `${formatBudgetAmount(s.budget)} (${s.budget.scope})`],
76
+ ["stopped at", s.budget.node],
77
+ ]
78
+ : []),
79
+ ...(policy.length > 0 ? [["policy", policy.join(", ")]] : []),
80
+ ];
81
+ }
82
+ /** Word-wrap to `width`, hard-breaking any word longer than the line. */
83
+ function wrap(text, width) {
84
+ const out = [];
85
+ let line = "";
86
+ for (let word of text.split(" ")) {
87
+ while (word.length > width) {
88
+ if (line) {
89
+ out.push(line);
90
+ line = "";
91
+ }
92
+ out.push(word.slice(0, width));
93
+ word = word.slice(width);
94
+ }
95
+ if (!line)
96
+ line = word;
97
+ else if (line.length + 1 + word.length <= width)
98
+ line += ` ${word}`;
99
+ else {
100
+ out.push(line);
101
+ line = word;
102
+ }
103
+ }
104
+ if (line || out.length === 0)
105
+ out.push(line);
106
+ return out;
107
+ }
108
+ function clip(text, width, ellipsis) {
109
+ if (text.length <= width)
110
+ return text;
111
+ return text.slice(0, Math.max(0, width - ellipsis.length)) + ellipsis;
112
+ }
113
+ /** Ticket width for a terminal of `columns`: fits beside the indent, within the min and max. */
114
+ export function ticketWidth(columns) {
115
+ return Math.max(TICKET_MIN_WIDTH, Math.min(TICKET_MAX_WIDTH, columns - TICKET_INDENT));
116
+ }
117
+ /**
118
+ * Render the ticket. Pure: same input, same rows. Every row is exactly the
119
+ * ticket width in visible columns.
120
+ */
121
+ export function renderTicket(data, opts = {}) {
122
+ const unicode = opts.unicode ?? true;
123
+ const paint = opts.paint ?? createPaint(false);
124
+ const box = ticketBoxFor(unicode);
125
+ const g = glyphsFor(unicode);
126
+ const width = ticketWidth(opts.columns ?? 80);
127
+ const inner = width - 4;
128
+ const valueWidth = inner - LABEL_WIDTH;
129
+ const s = data.summary;
130
+ const frame = paint.muted;
131
+ const edge = (left, fill, right) => {
132
+ const run = fill.repeat(Math.ceil((width - 2) / fill.length)).slice(0, width - 2);
133
+ return frame(left + run + right);
134
+ };
135
+ /** One content row: `raw` sets the width, `painted` is what prints. */
136
+ const row = (raw, painted = raw) => `${frame(box.vertical)} ${painted}${" ".repeat(Math.max(0, inner - raw.length))} ${frame(box.vertical)}`;
137
+ const lines = [edge(box.topLeft, box.horizontal, box.topRight)];
138
+ // Status and run id.
139
+ const status = ticketStatus(data);
140
+ const good = status === "passed";
141
+ const tone = good ? paint.success : paint.error;
142
+ const left = `${good ? g.success : g.failure} ${status}`;
143
+ const right = data.runId ? `run ${shortRunId(data.runId)}` : "";
144
+ const gap = inner - left.length - right.length;
145
+ if (right && gap >= 2) {
146
+ lines.push(row(left + " ".repeat(gap) + right, paint.strong(tone(left)) + " ".repeat(gap) + paint.muted(right)));
147
+ }
148
+ else {
149
+ lines.push(row(left, paint.strong(tone(left))));
150
+ }
151
+ const name = clip(data.workflow, inner, g.ellipsis);
152
+ lines.push(row(name));
153
+ lines.push(row(""));
154
+ // Fields, aligned: label column then value column; long values wrap under the value.
155
+ for (const [label, value] of ticketFields(s)) {
156
+ wrap(value, valueWidth).forEach((part, i) => {
157
+ const lab = (i === 0 ? label : "").padEnd(LABEL_WIDTH);
158
+ lines.push(row(lab + part, paint.muted(lab) + part));
159
+ });
160
+ }
161
+ // Tear line and stub with the policy stamp.
162
+ let stampRow = -1;
163
+ const stamp = stampWords(s);
164
+ if (stamp.words.length > 0) {
165
+ lines.push(edge(box.tearLeft, box.tear, box.tearRight));
166
+ let text = `[ ${stamp.words.join(` ${g.sep} `)} ]`;
167
+ if (text.length > inner)
168
+ text = clip(stamp.words.join(` ${g.sep} `), inner, g.ellipsis);
169
+ const padL = Math.floor((inner - text.length) / 2);
170
+ const raw = " ".repeat(padL) + text;
171
+ stampRow = lines.length;
172
+ if (opts.stamp === false)
173
+ lines.push(row(""));
174
+ else
175
+ lines.push(row(raw, " ".repeat(padL) + paint.strong((stamp.strong ? paint.brand : paint.warning)(text))));
176
+ }
177
+ lines.push(edge(box.bottomLeft, box.horizontal, box.bottomRight));
178
+ return { lines, stampRow };
179
+ }
180
+ /** The ticket as plain text (no color, Unicode): what the PR comment carries. */
181
+ export function formatTicketText(data, columns = TICKET_MAX_WIDTH + TICKET_INDENT) {
182
+ return renderTicket(data, { columns }).lines.join("\n");
183
+ }
184
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
185
+ /**
186
+ * Print the receipt. A TTY (not CI) gets the ticket, its stamp landing one
187
+ * frame after the card; anything else gets the plain line. Both end with a
188
+ * blank line.
189
+ */
190
+ export async function writeReceipt(summary, opts) {
191
+ const env = opts.env ?? process.env;
192
+ const { stream } = opts;
193
+ const color = opts.color ?? colorEnabled(stream, env);
194
+ const rich = opts.rich ?? richOutput(stream, env);
195
+ if (!rich) {
196
+ stream.write(` ${renderReceiptLine(summary, color)}\n\n`);
197
+ return;
198
+ }
199
+ const data = { summary, workflow: opts.workflow, runId: opts.runId, crashed: opts.crashed };
200
+ const base = {
201
+ columns: terminalColumns(stream, env),
202
+ unicode: opts.unicode ?? supportsUnicode(env),
203
+ paint: createPaint(color),
204
+ };
205
+ const pad = " ".repeat(TICKET_INDENT);
206
+ const full = renderTicket(data, base);
207
+ const animate = (opts.animate ?? true) && full.stampRow >= 0;
208
+ if (!animate) {
209
+ stream.write(full.lines.map((l) => pad + l).join("\n") + "\n\n");
210
+ return;
211
+ }
212
+ const blank = renderTicket(data, { ...base, stamp: false });
213
+ stream.write(blank.lines.map((l) => pad + l).join("\n") + "\n");
214
+ await sleep(STAMP_DELAY_MS);
215
+ // Up to the stub row, redraw it with the stamp, back down below the ticket.
216
+ const up = full.lines.length - full.stampRow;
217
+ stream.write(`\x1B[${up}A\r\x1B[2K${pad}${full.lines[full.stampRow]}\x1B[${up}B\r\n`);
218
+ }
package/dist/cli/try.d.ts CHANGED
@@ -2,8 +2,8 @@
2
2
  * `sweny try`: a zero-credential demo (#475).
3
3
  *
4
4
  * Replays a recorded explain-repo run through the REAL executor, with a replay
5
- * harness standing in for the agent, so the real progress line, answer block
6
- * (#460), receipt, policy segment and PR-comment markdown (#396) are what a
5
+ * harness standing in for the agent, so the real progress lines, answer block
6
+ * (#460), receipt ticket, policy stamp and PR comment (#396, #479) are what a
7
7
  * user sees. No agent, no model, no network, no env credentials, no files
8
8
  * written (no `.sweny/runs`, no journal). The environment handed to the
9
9
  * executor is empty by construction.
@@ -14,6 +14,7 @@
14
14
  import type { Command } from "commander";
15
15
  import type { AgentHarness } from "../harness/types.js";
16
16
  import type { Workflow } from "../types.js";
17
+ import { type Paint } from "./style.js";
17
18
  export interface TryFixtureNode {
18
19
  /** Recorded wall-clock time of the node. */
19
20
  duration_ms: number;
@@ -64,14 +65,16 @@ export declare function createReplayHarness(fixture: TryFixture, opts: {
64
65
  paceMs: number;
65
66
  }): AgentHarness;
66
67
  export declare const TRY_BANNER_TITLE = "Recorded demo";
67
- /** The real commands, same as the README quickstart. */
68
- export declare const TRY_NEXT_COMMANDS: readonly ["npm install -g @sweny-ai/core", "sweny new --template explain-repo --yes", "sweny workflow run .sweny/workflows/explain-repo.yml"];
68
+ /** The one command to run next: scaffolds the same workflow on the user's repo and says how to run it. */
69
+ export declare const TRY_NEXT_COMMAND = "npx @sweny-ai/core new --template explain-repo";
69
70
  /** Appended to `sweny try --help`. */
70
71
  export declare const TRY_HELP_NOTE: string;
71
- export declare function formatTryBanner(fixture: TryFixture): string;
72
- export declare function formatTryNext(): string;
72
+ /** The tagline, then the demo notice as one dim line. */
73
+ export declare function formatTryBanner(fixture: TryFixture, paint?: Paint, unicode?: boolean): string;
74
+ /** The closing line: one command. */
75
+ export declare function formatTryNext(paint?: Paint): string;
73
76
  export interface TryOptions {
74
- /** Replay instantly. */
77
+ /** Replay instantly (and land the stamp without the one-frame pause). */
75
78
  fast?: boolean;
76
79
  /** Milliseconds each node takes to replay. Default 1200. Ignored with `fast`. */
77
80
  paceMs?: number;
@@ -79,8 +82,16 @@ export interface TryOptions {
79
82
  commentFile?: string;
80
83
  /** Output sink. Default stdout. */
81
84
  write?: (s: string) => void;
82
- /** Rewrite progress lines in place. Default: stdout is a TTY. */
85
+ /** Rich output: spinner, ticket, color. Default: stdout is a TTY. */
83
86
  tty?: boolean;
87
+ /** Draw with Unicode. Default: what the terminal supports. */
88
+ unicode?: boolean;
89
+ /** Force ANSI color on or off. Default: on for a TTY that allows color. */
90
+ color?: boolean;
91
+ /** Force the receipt ticket (true) or the plain receipt line (false). Default: `tty`. */
92
+ rich?: boolean;
93
+ /** Terminal width. Default: stdout's. */
94
+ columns?: number;
84
95
  fixture?: TryFixture;
85
96
  }
86
97
  export declare const DEFAULT_TRY_PACE_MS = 1200;
package/dist/cli/try.js CHANGED
@@ -2,8 +2,8 @@
2
2
  * `sweny try`: a zero-credential demo (#475).
3
3
  *
4
4
  * Replays a recorded explain-repo run through the REAL executor, with a replay
5
- * harness standing in for the agent, so the real progress line, answer block
6
- * (#460), receipt, policy segment and PR-comment markdown (#396) are what a
5
+ * harness standing in for the agent, so the real progress lines, answer block
6
+ * (#460), receipt ticket, policy stamp and PR comment (#396, #479) are what a
7
7
  * user sees. No agent, no model, no network, no env credentials, no files
8
8
  * written (no `.sweny/runs`, no journal). The environment handed to the
9
9
  * executor is empty by construction.
@@ -14,15 +14,19 @@
14
14
  import fs from "node:fs";
15
15
  import path from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
- import chalk from "chalk";
18
17
  import { parse as parseYaml } from "yaml";
19
18
  import { execute } from "../executor.js";
20
19
  import { validateParsed } from "../loader.js";
21
20
  import { WORKFLOW_TEMPLATES } from "./templates.js";
22
21
  import { formatFinalOutput, resolveFinalOutput } from "./final-output.js";
23
22
  import { formatRunComment } from "./comment-output.js";
24
- import { formatReceiptDuration, renderReceiptLine, summarizeRun } from "./run-output.js";
25
- import { c } from "./output.js";
23
+ import { formatReceiptDuration, summarizeRun } from "./run-output.js";
24
+ import { DagRenderer } from "./renderer.js";
25
+ import { createNodeProgress, toolCallsText } from "./progress.js";
26
+ import { createPaint } from "./style.js";
27
+ import { colorEnabled, glyphsFor, supportsUnicode, terminalColumns } from "./terminal.js";
28
+ import { writeReceipt } from "./ticket.js";
29
+ import { SWENY_TAGLINE } from "../theme.js";
26
30
  const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
27
31
  /** Shape check with a message naming the first bad field. Throws on a malformed fixture. */
28
32
  export function parseTryFixture(raw) {
@@ -152,34 +156,24 @@ export function createReplayHarness(fixture, opts) {
152
156
  }
153
157
  // ── Output ──────────────────────────────────────────────────────
154
158
  export const TRY_BANNER_TITLE = "Recorded demo";
155
- /** The real commands, same as the README quickstart. */
156
- export const TRY_NEXT_COMMANDS = [
157
- "npm install -g @sweny-ai/core",
158
- "sweny new --template explain-repo --yes",
159
- "sweny workflow run .sweny/workflows/explain-repo.yml",
160
- ];
159
+ /** The one command to run next: scaffolds the same workflow on the user's repo and says how to run it. */
160
+ export const TRY_NEXT_COMMAND = "npx @sweny-ai/core new --template explain-repo";
161
161
  /** Appended to `sweny try --help`. */
162
162
  export const TRY_HELP_NOTE = "The recording is ILLUSTRATIVE SAMPLE DATA (a fictional repo, hand-written timings and token counts), not output from a real run. " +
163
163
  "Nothing runs: no agent, no network, no credentials read, no files written.";
164
- export function formatTryBanner(fixture) {
165
- const lines = [
166
- `${c.brand("▲")} ${chalk.bold("sweny try")} ${chalk.bold.yellow(TRY_BANNER_TITLE)}, not a live run`,
167
- chalk.dim(` A replay of an ${fixture.workflow} run on a sample repo (${fixture.sample_repo}).`),
168
- chalk.dim(fixture.illustrative
169
- ? " The sample data is illustrative, not output from your code. No agent, no network, no credentials."
170
- : " No agent, no network, no credentials."),
171
- ];
172
- return lines.map((l, i) => (i === 0 ? ` ${l}` : l)).join("\n");
173
- }
174
- export function formatTryNext() {
164
+ /** The tagline, then the demo notice as one dim line. */
165
+ export function formatTryBanner(fixture, paint = createPaint(false), unicode = true) {
166
+ const g = glyphsFor(unicode);
167
+ const data = fixture.illustrative ? ", illustrative data" : "";
175
168
  return [
176
- ` ${chalk.bold("Run it for real")} on your own repo:`,
177
- "",
178
- ...TRY_NEXT_COMMANDS.map((cmd) => ` ${cmd}`),
179
- "",
180
- chalk.dim(" Needs a Claude login (`claude` signed in) or ANTHROPIC_API_KEY. Your run ends with the same receipt."),
169
+ ` ${paint.brand(g.brand)} ${paint.strong("SWEny")} ${SWENY_TAGLINE}`,
170
+ paint.muted(` ${TRY_BANNER_TITLE} of ${fixture.workflow} on a sample repo (${fixture.sample_repo})${data}. No agent, network or credentials.`),
181
171
  ].join("\n");
182
172
  }
173
+ /** The closing line: one command. */
174
+ export function formatTryNext(paint = createPaint(false)) {
175
+ return ` ${paint.muted("Run it on your repo:")} ${paint.strong(TRY_NEXT_COMMAND)}`;
176
+ }
183
177
  export const DEFAULT_TRY_PACE_MS = 1200;
184
178
  const silentLogger = { info() { }, warn() { }, error() { }, debug() { } };
185
179
  /** Replay the recording. Returns the process exit code. */
@@ -193,72 +187,97 @@ export async function runTry(opts = {}) {
193
187
  if (!workflow.nodes[id])
194
188
  throw new Error(`try-fixture.json: node "${id}" is not in ${fixture.workflow}`);
195
189
  }
196
- write(`\n${formatTryBanner(fixture)}\n\n`);
197
- const ticks = new Map();
190
+ const env = process.env;
191
+ const unicode = opts.unicode ?? supportsUnicode(env);
192
+ const color = opts.color ?? (tty && colorEnabled({ isTTY: true }, env));
193
+ const paint = createPaint(color);
194
+ const g = glyphsFor(unicode);
195
+ const columns = opts.columns ?? terminalColumns(process.stdout, env);
196
+ write(`\n${formatTryBanner(fixture, paint, unicode)}\n\n`);
197
+ const progress = createNodeProgress({
198
+ write,
199
+ live: tty,
200
+ unicode,
201
+ paint,
202
+ idWidth: Math.max(...Object.keys(fixture.nodes).map((id) => id.length)),
203
+ });
204
+ // The same events drive the DAG in the comment preview.
205
+ const dag = new DagRenderer(workflow, { legend: false, unicode });
198
206
  const observer = (event) => {
207
+ dag.update(event);
199
208
  switch (event.type) {
200
- case "workflow:start":
201
- write(` ${c.brand("▲")} ${chalk.bold(event.workflow)}\n\n`);
202
- break;
203
209
  case "node:enter":
204
- ticks.set(event.node, 0);
205
- if (tty)
206
- write(` ${c.subtle("○")} ${chalk.dim(event.node)}…`);
210
+ progress.enter(event.node);
207
211
  break;
208
- case "node:progress": {
209
- const n = (ticks.get(event.node) ?? 0) + 1;
210
- ticks.set(event.node, n);
211
- if (tty) {
212
- write(`\r\x1B[2K ${c.subtle("○")} ${chalk.dim(event.node)}… ${chalk.dim(`${n} tool ${n === 1 ? "call" : "calls"}`)}`);
213
- }
212
+ case "node:progress":
213
+ progress.tick(event.node);
214
214
  break;
215
- }
216
215
  case "node:exit": {
217
216
  const rec = fixture.nodes[event.node];
218
- const ok = event.result.status === "success";
219
- const icon = ok ? c.ok("✓") : c.fail("✗");
220
217
  const calls = event.result.toolCalls.length;
221
- const detail = [formatReceiptDuration(rec?.duration_ms ?? 0), ...(calls > 0 ? [`${calls} tool calls`] : [])];
222
- write(`${tty ? "\r\x1B[2K" : ""} ${icon} ${event.node} ${chalk.dim(detail.join(" · "))}\n`);
218
+ // The recording's timing, not the replay's wall clock.
219
+ const detail = [formatReceiptDuration(rec?.duration_ms ?? 0), ...(calls > 0 ? [toolCallsText(calls)] : [])];
220
+ const status = event.result.status === "success" ? "success" : event.result.status === "failed" ? "failed" : "skipped";
221
+ progress.exit(event.node, status, detail.join(` ${g.sep} `));
223
222
  break;
224
223
  }
225
224
  }
226
225
  };
227
- const { results, trace } = await execute(workflow, {}, {
228
- skills: new Map(),
229
- harness: createReplayHarness(fixture, { paceMs }),
230
- observer,
231
- logger: silentLogger,
232
- // Nothing for the executor to read: no credentials, no PATH, no config.
233
- env: {},
234
- offline: true,
235
- harnessPolicy: "warn",
236
- });
226
+ let executed;
227
+ try {
228
+ executed = await execute(workflow, {}, {
229
+ skills: new Map(),
230
+ harness: createReplayHarness(fixture, { paceMs }),
231
+ observer,
232
+ logger: silentLogger,
233
+ // Nothing for the executor to read: no credentials, no PATH, no config.
234
+ env: {},
235
+ offline: true,
236
+ harnessPolicy: "warn",
237
+ });
238
+ }
239
+ finally {
240
+ progress.stop();
241
+ }
242
+ const { results, trace } = executed;
237
243
  write("\n");
238
- // The recording's timings, not the replay's wall clock.
239
244
  const durationsMs = Object.fromEntries(Object.entries(fixture.nodes).map(([id, n]) => [id, n.duration_ms]));
240
245
  const totalMs = Object.values(durationsMs).reduce((a, b) => a + b, 0);
241
246
  const receipt = summarizeRun(results, totalMs, false, trace);
242
247
  const answer = resolveFinalOutput(workflow, results);
243
248
  if (answer)
244
249
  write(`${formatFinalOutput(answer)}\n\n`);
245
- write(` ${renderReceiptLine(receipt, tty)}\n\n`);
250
+ // Compact preview of the PR comment: its heading and DAG here, its ticket (the receipt) below.
246
251
  const comment = formatRunComment(workflow, results, receipt, { trace, durationsMs });
247
- write(` ${chalk.dim("What CI posts on a pull request (sweny workflow run --comment-file):")}\n\n`);
248
- write(`${comment
252
+ write(` ${paint.muted("On a pull request, CI posts this heading, this DAG and the receipt below:")}\n\n`);
253
+ const tone = receipt.ok ? paint.success : paint.error;
254
+ write(` ${tone(receipt.ok ? g.success : g.failure)} ${paint.strong(workflow.name)}\n\n`);
255
+ write(`${dag
256
+ .renderToString()
249
257
  .split("\n")
250
- .map((l) => (l === "" ? l : ` ${l}`))
251
- .join("\n")}\n`);
258
+ .slice(2)
259
+ .map((l) => (l === "" ? l : ` ${l}`))
260
+ .join("\n")}\n\n`);
261
+ await writeReceipt(receipt, {
262
+ workflow: workflow.id,
263
+ runId: "demo",
264
+ stream: { write, isTTY: tty, columns },
265
+ env,
266
+ rich: opts.rich ?? tty,
267
+ color,
268
+ unicode,
269
+ animate: !opts.fast,
270
+ });
252
271
  if (opts.commentFile) {
253
272
  try {
254
273
  fs.mkdirSync(path.dirname(path.resolve(opts.commentFile)), { recursive: true });
255
274
  fs.writeFileSync(opts.commentFile, comment);
256
275
  }
257
276
  catch (err) {
258
- process.stderr.write(` ⚠ could not write comment file: ${err instanceof Error ? err.message : err}\n`);
277
+ process.stderr.write(` ${g.warning} could not write comment file: ${err instanceof Error ? err.message : err}\n`);
259
278
  }
260
279
  }
261
- write(`${formatTryNext()}\n\n`);
280
+ write(`${formatTryNext(paint)}\n\n`);
262
281
  return receipt.ok ? 0 : 1;
263
282
  }
264
283
  export function registerTryCommand(program) {
@@ -268,13 +287,26 @@ export function registerTryCommand(program) {
268
287
  .option("--fast", "Replay instantly instead of pacing the progress")
269
288
  .option("--pace <ms>", `Milliseconds each node takes to replay (default ${DEFAULT_TRY_PACE_MS})`)
270
289
  .option("--comment-file <path>", "Also write the PR-comment markdown to <path>")
290
+ .option("--record <file.svg>", "Write the demo as an animated, self-contained SVG terminal recording to <file.svg>")
271
291
  .addHelpText("after", `\n${TRY_HELP_NOTE}\n`)
272
292
  .action(async (options) => {
293
+ if (options.record) {
294
+ try {
295
+ const { recordTrySvgToFile } = await import("./record.js");
296
+ const bytes = await recordTrySvgToFile(options.record);
297
+ process.stdout.write(` wrote ${options.record} (${bytes} bytes)\n`);
298
+ }
299
+ catch (err) {
300
+ console.error(`\n could not record the demo: ${err instanceof Error ? err.message : err}\n`);
301
+ process.exitCode = 1;
302
+ }
303
+ return;
304
+ }
273
305
  let paceMs;
274
306
  if (options.pace !== undefined) {
275
307
  paceMs = Number(options.pace);
276
308
  if (!Number.isFinite(paceMs) || paceMs < 0) {
277
- console.error(chalk.red(`\n --pace must be a non-negative number of milliseconds, got "${options.pace}"\n`));
309
+ console.error(createPaint(colorEnabled(process.stderr)).error(`\n --pace must be a non-negative number of milliseconds, got "${options.pace}"\n`));
278
310
  process.exitCode = 1;
279
311
  return;
280
312
  }
@@ -154,7 +154,7 @@ export async function runUpgrade(options, deps) {
154
154
  latest = await deps.fetchLatestVersion(tag);
155
155
  }
156
156
  catch (err) {
157
- stderr.write(chalk.red(` Error: couldn't reach the npm registry — ${err instanceof Error ? err.message : String(err)}`) +
157
+ stderr.write(chalk.red(` Error: couldn't reach the npm registry - ${err instanceof Error ? err.message : String(err)}`) +
158
158
  "\n");
159
159
  stderr.write(chalk.dim(" Check your network connection and try again.") + "\n");
160
160
  exit(1);
@@ -12,7 +12,8 @@
12
12
  *
13
13
  * This replaces ~8k lines of engine + recipe step code.
14
14
  */
15
- import type { Workflow, Skill, Tool, Claude, Observer, Logger, ExecutionResult } from "./types.js";
15
+ import type { Workflow, Skill, Tool, Claude, Observer, NodeResult, Logger, ExecutionResult } from "./types.js";
16
+ import "./skills/git-push.js";
16
17
  import type { HarnessPolicyMode } from "./harness/policy.js";
17
18
  import type { AgentHarness } from "./harness/types.js";
18
19
  import type { DeciderMode } from "./decider.js";
@@ -138,6 +139,12 @@ export declare class RouteEvaluationError extends Error {
138
139
  readonly node: string;
139
140
  constructor(node: string, message: string);
140
141
  }
142
+ /**
143
+ * The bounded prompt context for one node: `input` plus its dependencies' entries.
144
+ * Exported for the property tests only; not part of the package API (index.ts does not re-export it).
145
+ * @internal
146
+ */
147
+ export declare function buildBoundedContext(workflow: Workflow, nodeId: string, results: Map<string, NodeResult>, input: unknown, instruction: string): Record<string, unknown>;
141
148
  /**
142
149
  * Dry-run tool gate (#380). Only an explicit `access: "read"` passes; a tool
143
150
  * with no `access` is treated as a write so new tools fail safe.
package/dist/executor.js CHANGED
@@ -22,6 +22,9 @@ import { resolveExecutionModel } from "./model.js";
22
22
  import { buildToolAliases } from "./skills/index.js";
23
23
  import { validateWorkflow } from "./schema.js";
24
24
  import { grantedAgentEnv, resolveAgentAccess } from "./agent-env.js";
25
+ import { gitCredentialWarning, scanGitCredentials } from "./git-credentials.js";
26
+ // #473: arms the sweny-side branch push in `github_create_pr` (Node only; skills/ stays browser-safe).
27
+ import "./skills/git-push.js";
25
28
  import { fenceUntrusted } from "./untrusted.js";
26
29
  import { asClaude } from "./harness/compat.js";
27
30
  import { budgetGate, isToolClass, policyGate, resolveHarnessPolicy } from "./harness/policy.js";
@@ -89,6 +92,17 @@ export async function execute(workflow, input, options) {
89
92
  // `budget` and the caller's (`--max-tokens`, `--max-cost`).
90
93
  const budgetGuard = new BudgetGuard(minLimits(toLimits(workflow.budget), toLimits(options.budget)));
91
94
  const harnessPolicy = options.harnessPolicy ?? resolveHarnessPolicy(runEnv);
95
+ // #473: a checkout that persisted a git credential (actions/checkout's
96
+ // default). Each harness masks it for read-only and staged nodes, or reports
97
+ // the node degraded; this says so once per run. Paths and keys, never values.
98
+ try {
99
+ const credentialWarning = gitCredentialWarning(scanGitCredentials(process.cwd(), { env: runEnv }));
100
+ if (credentialWarning)
101
+ logger.warn(credentialWarning);
102
+ }
103
+ catch {
104
+ // A scan failure never stops a run; each node scans again before it starts.
105
+ }
92
106
  // Decision model (#357): shadow only. Null (the default) means no HTTP at all.
93
107
  const shadow = createShadowDecider(workflow.decider, options.decider, runEnv, (m) => logger.warn(m));
94
108
  if (shadow)
@@ -911,8 +925,12 @@ function contextDependencies(workflow, nodeId, instruction = "") {
911
925
  }
912
926
  return deps;
913
927
  }
914
- /** The bounded prompt context for one node: `input` plus its dependencies' entries. */
915
- function buildBoundedContext(workflow, nodeId, results, input, instruction) {
928
+ /**
929
+ * The bounded prompt context for one node: `input` plus its dependencies' entries.
930
+ * Exported for the property tests only; not part of the package API (index.ts does not re-export it).
931
+ * @internal
932
+ */
933
+ export function buildBoundedContext(workflow, nodeId, results, input, instruction) {
916
934
  const deps = contextDependencies(workflow, nodeId, instruction);
917
935
  return {
918
936
  input,