@sweny-ai/core 0.1.166 → 0.1.168

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/main.js CHANGED
@@ -34,6 +34,7 @@ import { nonInteractiveUsage, runNew } from "./new.js";
34
34
  import { formatFinalMarkdown, formatFinalOutput, resolveFinalOutput, writeFinalOutput } from "./final-output.js";
35
35
  import { buildRunRecord, createNodeTimer, historyDisabled, newRunId, recordRun } from "./run-history.js";
36
36
  import { registerRunsCommand } from "./runs.js";
37
+ import { registerTryCommand } from "./try.js";
37
38
  import { JournalLockedError, JournalMismatchError, RunJournal } from "../journal.js";
38
39
  import { journalDisabled, prepareResume, RESUME_SHARED_RUN_FLAGS, WORKFLOW_RESUME_OPTIONS, } from "./resume.js";
39
40
  import { runE2eRun, runWithWallClockBudget, DEFAULT_WORKFLOW_TIMEOUT_MS } from "./e2e.js";
@@ -76,9 +77,12 @@ function composeObservers(...observers) {
76
77
  };
77
78
  }
78
79
  // Auto-load .env before Commander parses (so env vars are available for defaults)
79
- loadDotenv();
80
- // Agent sandbox / env-passthrough keys from .sweny.yml -> SWENY_* env (#360).
81
- applyAgentFileConfig(loadConfigFile());
80
+ // `sweny try` (#475) reads no .env and no config: it is a recording replay with no credentials.
81
+ if (process.argv[2] !== "try") {
82
+ loadDotenv();
83
+ // Agent sandbox / env-passthrough keys from .sweny.yml -> SWENY_* env (#360).
84
+ applyAgentFileConfig(loadConfigFile());
85
+ }
82
86
  const program = new Command()
83
87
  .name("sweny")
84
88
  .description("Workflows for coding agents. One set of rules, a receipt for every run.")
@@ -89,6 +93,7 @@ program
89
93
  .description("Create a new workflow. With no id, opens the interactive picker. With an id, uses that built-in template.")
90
94
  .option("--template <id>", "Use a built-in template without the picker")
91
95
  .option("-y, --yes", "Skip every prompt (never overwrites existing files)")
96
+ .option("--no-ci", "Do not write a pack's GitHub Actions trigger to .github/workflows/")
92
97
  .action(async (id, options) => {
93
98
  // Prompts need a terminal. Without one (CI, pipes), a prompt never
94
99
  // settles, so print usage and exit 2 instead of hanging.
@@ -96,8 +101,10 @@ program
96
101
  console.error(nonInteractiveUsage());
97
102
  process.exit(2);
98
103
  }
99
- await runNew({ marketplaceId: id, template: options.template, yes: options.yes });
104
+ await runNew({ marketplaceId: id, template: options.template, yes: options.yes, ci: options.ci });
100
105
  });
106
+ // ── sweny try ─────────────────────────────────────────────────────────
107
+ registerTryCommand(program);
101
108
  // ── sweny check ───────────────────────────────────────────────────────
102
109
  /** validateInputs' generic agent-auth lines, replaced by the harness preflight reason (#339). */
103
110
  const AGENT_AUTH_ERROR_PREFIXES = ["Missing: ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN", "Missing: CODEX_API_KEY"];
package/dist/cli/new.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * Pure functions for file generation + thin @clack/prompts interactive layer.
8
8
  * Tests cover the pure functions; the interactive wizard is thin glue.
9
9
  */
10
+ import { type WorkflowTemplate } from "./templates.js";
10
11
  import { type Skill } from "../types.js";
11
12
  export interface Credential {
12
13
  key: string;
@@ -113,6 +114,18 @@ export declare function writeSwenyYmlIfMissing(cwd: string, sourceControl: strin
113
114
  /** Append credential keys to .env that aren't already present. Returns count appended. */
114
115
  export declare function appendMissingEnvKeys(cwd: string, credentials: Credential[]): number;
115
116
  /** Write workflow YAML into .sweny/workflows/<id>.yml. Returns info about what happened. */
117
+ /** Where a pack's GitHub Actions trigger is written: `.github/workflows/sweny-<pack>.yml`. */
118
+ export declare function packTriggerPath(id: string): string;
119
+ /**
120
+ * Write a pack's Actions trigger (#474). Never overwrites: an existing file is
121
+ * left exactly as it is. Returns `skipped` for a template that is not a pack.
122
+ */
123
+ export declare function writePackTrigger(cwd: string, template: WorkflowTemplate): {
124
+ written: boolean;
125
+ exists?: boolean;
126
+ skipped?: boolean;
127
+ path: string;
128
+ };
116
129
  export declare function writeWorkflowFile(cwd: string, id: string, yaml: string, options?: {
117
130
  overwrite?: boolean;
118
131
  }): {
@@ -153,6 +166,8 @@ export declare function runNew(options?: {
153
166
  template?: string;
154
167
  /** Skip every confirmation prompt (never overwrites existing files). */
155
168
  yes?: boolean;
169
+ /** `false` (`--no-ci`) skips writing a pack's GitHub Actions trigger file. */
170
+ ci?: boolean;
156
171
  /** Internal: the id came from the marketplace picker, skip the built-in shortcut. */
157
172
  forceMarketplace?: boolean;
158
173
  }): Promise<void>;
package/dist/cli/new.js CHANGED
@@ -431,6 +431,24 @@ export function appendMissingEnvKeys(cwd, credentials) {
431
431
  return newKeys.length;
432
432
  }
433
433
  /** Write workflow YAML into .sweny/workflows/<id>.yml. Returns info about what happened. */
434
+ /** Where a pack's GitHub Actions trigger is written: `.github/workflows/sweny-<pack>.yml`. */
435
+ export function packTriggerPath(id) {
436
+ return path.join(".github", "workflows", `sweny-${id}.yml`);
437
+ }
438
+ /**
439
+ * Write a pack's Actions trigger (#474). Never overwrites: an existing file is
440
+ * left exactly as it is. Returns `skipped` for a template that is not a pack.
441
+ */
442
+ export function writePackTrigger(cwd, template) {
443
+ const target = path.join(cwd, packTriggerPath(template.id));
444
+ if (!template.pack)
445
+ return { written: false, skipped: true, path: target };
446
+ if (fs.existsSync(target))
447
+ return { written: false, exists: true, path: target };
448
+ fs.mkdirSync(path.dirname(target), { recursive: true });
449
+ fs.writeFileSync(target, template.pack.trigger, "utf-8");
450
+ return { written: true, path: target };
451
+ }
434
452
  export function writeWorkflowFile(cwd, id, yaml, options) {
435
453
  const dir = path.join(cwd, ".sweny", "workflows");
436
454
  fs.mkdirSync(dir, { recursive: true });
@@ -522,7 +540,12 @@ export async function runNew(options) {
522
540
  // so it works with no network. Only other ids go to the marketplace.
523
541
  if (options?.marketplaceId && options.marketplaceId !== "e2e" && !options.forceMarketplace) {
524
542
  if (WORKFLOW_TEMPLATES.some((t) => t.id === options.marketplaceId)) {
525
- return runNew({ template: options.marketplaceId, skipIntro: options.skipIntro, yes: options.yes });
543
+ return runNew({
544
+ template: options.marketplaceId,
545
+ skipIntro: options.skipIntro,
546
+ yes: options.yes,
547
+ ci: options.ci,
548
+ });
526
549
  }
527
550
  }
528
551
  if (options?.template && !WORKFLOW_TEMPLATES.some((t) => t.id === options.template)) {
@@ -700,6 +723,9 @@ export async function runNew(options) {
700
723
  files.push(".sweny.yml", ".env");
701
724
  if (template)
702
725
  files.push(`.sweny/workflows/${template.id}.yml`);
726
+ const writeTrigger = !!template?.pack && options?.ci !== false;
727
+ if (template && writeTrigger)
728
+ files.push(packTriggerPath(template.id));
703
729
  const inferred = [
704
730
  ` Source control: ${sourceControl}${gitInfo ? " (detected)" : ""}`,
705
731
  ` Issue tracker: ${issueTracker}`,
@@ -759,11 +785,22 @@ export async function runNew(options) {
759
785
  else {
760
786
  p.log.info(".env already contains all required keys — skipped");
761
787
  }
788
+ // The pack's Actions trigger (#474): never overwritten, skipped with --no-ci.
789
+ const installTrigger = () => {
790
+ if (!template || !writeTrigger)
791
+ return;
792
+ const r = writePackTrigger(cwd, template);
793
+ if (r.written)
794
+ p.log.success(`Created ${packTriggerPath(template.id)}`);
795
+ else if (r.exists)
796
+ p.log.info(`${packTriggerPath(template.id)} already exists, not overwritten`);
797
+ };
762
798
  // 3. Workflow template
763
799
  if (template) {
764
800
  const firstAttempt = writeWorkflowFile(cwd, template.id, template.yaml, { overwrite: false });
765
801
  if (firstAttempt.exists && options?.yes) {
766
802
  p.log.info(`.sweny/workflows/${template.id}.yml already exists, not overwritten`);
803
+ installTrigger();
767
804
  p.outro("Done.");
768
805
  return;
769
806
  }
@@ -782,6 +819,7 @@ export async function runNew(options) {
782
819
  writeWorkflowFile(cwd, template.id, template.yaml, { overwrite: true });
783
820
  }
784
821
  p.log.success(`Created .sweny/workflows/${template.id}.yml`);
822
+ installTrigger();
785
823
  }
786
824
  // 4. Keep .env out of version control
787
825
  const ignoreResult = ensureGitignoreEnv(cwd);
@@ -801,7 +839,10 @@ export async function runNew(options) {
801
839
  }
802
840
  if (template) {
803
841
  steps.push(`${stepNum++}. Run your workflow:`, ` ${cli} workflow run .sweny/workflows/${template.id}.yml`);
804
- if (template.pack) {
842
+ if (template.pack && writeTrigger) {
843
+ steps.push("", `${stepNum++}. Run it automatically: add the CLAUDE_CODE_OAUTH_TOKEN repo secret, then commit`, ` .sweny/workflows/${template.id}.yml and ${packTriggerPath(template.id)}`, ` https://docs.sweny.ai/workflows/packs/#${template.id}`);
844
+ }
845
+ else if (template.pack) {
805
846
  steps.push("", `${stepNum++}. Run it automatically (GitHub Actions trigger and permissions):`, ` https://docs.sweny.ai/workflows/packs/#${template.id}`);
806
847
  }
807
848
  }
package/dist/cli/packs.js CHANGED
@@ -336,11 +336,45 @@ nodes:
336
336
  - github_add_comment
337
337
  - github_create_issue
338
338
 
339
+
340
+ quiet:
341
+ name: Nothing to Report
342
+ instruction: |
343
+ The week had no commits, merged PRs, or issues opened or closed, so there is nothing worth a digest. Do not call any tool and deliver nothing. Return
344
+ delivered false and one short sentence saying why.
345
+ permissions: read
346
+ max_turns: 2
347
+ output:
348
+ type: object
349
+ properties:
350
+ delivered:
351
+ type: boolean
352
+ reason:
353
+ type: string
354
+ required: [delivered, reason]
355
+ eval:
356
+ - name: quiet_delivers_nothing
357
+ kind: function
358
+ rule:
359
+ no_tool_called:
360
+ - github_create_issue
361
+ - github_add_comment
362
+ - github_create_pr
363
+ - slack_send_message
364
+ - notify_webhook
365
+
366
+ # Quiet weeks (#474): sweny decides on the counts, with no model call. A week
367
+ # with any activity is delivered; an empty one routes to quiet and nothing is
368
+ # sent, so the digest is never noise.
339
369
  edges:
340
370
  - from: collect
341
371
  to: analyze
342
372
  - from: analyze
343
373
  to: publish
374
+ when:
375
+ expr: "analyze.stats.commits > 0 || analyze.stats.prs_merged > 0 || analyze.stats.issues_opened > 0 || analyze.stats.issues_closed > 0"
376
+ - from: analyze
377
+ to: quiet
344
378
  `;
345
379
  const DIGEST_TRIGGER = `name: SWEny weekly digest
346
380
  on:
@@ -350,19 +384,21 @@ on:
350
384
 
351
385
  permissions:
352
386
  contents: read
353
- issues: write # only needed for deliver: issue
387
+ issues: write # deliver: issue, and the failure alert
354
388
 
355
389
  jobs:
356
390
  digest:
357
391
  runs-on: ubuntu-latest
358
392
  timeout-minutes: 15
359
393
  steps:
360
- - uses: actions/checkout@v4
394
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
361
395
  with:
362
396
  fetch-depth: 0
397
+ persist-credentials: false
363
398
  - uses: swenyai/sweny@v5
364
399
  with:
365
400
  workflow: .sweny/workflows/weekly-digest.yml
401
+ notify-on-failure: issue # one sticky issue, only when a run fails
366
402
  claude-oauth-token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
367
403
  input: '{"repo": "\${{ github.repository }}"}'
368
404
  # input: '{"repo": "\${{ github.repository }}", "days": 7, "deliver": "slack"}'
@@ -680,6 +716,35 @@ nodes:
680
716
  - path: result
681
717
  in: [created, updated, unchanged, none]
682
718
 
719
+
720
+ quiet:
721
+ name: Nothing to Report
722
+ instruction: |
723
+ Nothing actionable: no advisory at or above the severity floor and no lockfile drift. Do not call any tool and deliver nothing. Return
724
+ delivered false and one short sentence saying why.
725
+ permissions: read
726
+ max_turns: 2
727
+ output:
728
+ type: object
729
+ properties:
730
+ delivered:
731
+ type: boolean
732
+ reason:
733
+ type: string
734
+ required: [delivered, reason]
735
+ eval:
736
+ - name: quiet_delivers_nothing
737
+ kind: function
738
+ rule:
739
+ no_tool_called:
740
+ - github_create_issue
741
+ - github_add_comment
742
+ - github_create_pr
743
+ - slack_send_message
744
+ - notify_webhook
745
+
746
+ # Quiet weeks (#474): sweny routes on the action, with no model call. Nothing
747
+ # actionable skips the issue step, so a clean week files and posts nothing.
683
748
  edges:
684
749
  - from: inventory
685
750
  to: advisories
@@ -687,6 +752,10 @@ edges:
687
752
  to: assess
688
753
  - from: assess
689
754
  to: file-issue
755
+ when:
756
+ expr: "assess.action == 'file'"
757
+ - from: assess
758
+ to: quiet
690
759
  `;
691
760
  const DRIFT_TRIGGER = `name: SWEny dependency drift
692
761
  on:
@@ -703,10 +772,13 @@ jobs:
703
772
  runs-on: ubuntu-latest
704
773
  timeout-minutes: 15
705
774
  steps:
706
- - uses: actions/checkout@v4
775
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
776
+ with:
777
+ persist-credentials: false
707
778
  - uses: swenyai/sweny@v5
708
779
  with:
709
780
  workflow: .sweny/workflows/dependency-drift.yml
781
+ notify-on-failure: issue # one sticky issue, only when a run fails
710
782
  claude-oauth-token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
711
783
  input: '{"repo": "\${{ github.repository }}"}'
712
784
  env:
@@ -994,7 +1066,9 @@ jobs:
994
1066
  runs-on: ubuntu-latest
995
1067
  timeout-minutes: 10
996
1068
  steps:
997
- - uses: actions/checkout@v4
1069
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1070
+ with:
1071
+ persist-credentials: false
998
1072
  - uses: swenyai/sweny@v5
999
1073
  with:
1000
1074
  workflow: .sweny/workflows/pr-risk-review.yml
@@ -0,0 +1,35 @@
1
+ {
2
+ "schema_version": 1,
3
+ "illustrative": true,
4
+ "notice": "ILLUSTRATIVE SAMPLE DATA. This is not output from a real run and not about your code. The repo (acme-notes-api) is fictional; timings, tool-call counts, token usage and cost are plausible numbers for a small repo, written by hand. Replace this file with a recorded real run (metadata plus the sample answer only) and set illustrative to false.",
5
+ "workflow": "explain-repo",
6
+ "sample_repo": "acme-notes-api",
7
+ "harness": { "id": "claude-code", "version": "sample" },
8
+ "policy": { "envScope": true, "sandbox": "auto", "sandboxStarted": true },
9
+ "nodes": {
10
+ "survey": {
11
+ "duration_ms": 18400,
12
+ "tool_calls": { "Glob": 3, "Read": 7, "Grep": 2 },
13
+ "usage": { "inputTokens": 21300, "outputTokens": 1150, "costUsd": 0.09, "numTurns": 8 }
14
+ },
15
+ "explain": {
16
+ "duration_ms": 9100,
17
+ "tool_calls": {},
18
+ "usage": { "inputTokens": 6800, "outputTokens": 640, "costUsd": 0.04, "numTurns": 1 },
19
+ "output": {
20
+ "purpose": "A small REST API for storing and searching notes, built with Express and TypeScript on top of SQLite. Meant as the backend for a notes app.",
21
+ "how_to_run": ["npm install", "npm run build", "npm test", "npm start (serves on http://localhost:3000)"],
22
+ "key_files": [
23
+ "src/server.ts: the entry point, wires routes and the database",
24
+ "src/routes/notes.ts: the CRUD and search endpoints",
25
+ "src/db/schema.sql: the tables, applied on first start",
26
+ "test/notes.test.ts: the API tests, a good map of expected behavior"
27
+ ],
28
+ "risks": [
29
+ "No auth on any route: fine locally, not safe to expose as is.",
30
+ "The README does not mention the PORT or DB_PATH environment variables that src/server.ts reads."
31
+ ]
32
+ }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `sweny try`: a zero-credential demo (#475).
3
+ *
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
7
+ * user sees. No agent, no model, no network, no env credentials, no files
8
+ * written (no `.sweny/runs`, no journal). The environment handed to the
9
+ * executor is empty by construction.
10
+ *
11
+ * The recording lives in `try-fixture.json` (metadata plus the sample answer
12
+ * only). Until a real run is recorded it is labelled illustrative.
13
+ */
14
+ import type { Command } from "commander";
15
+ import type { AgentHarness } from "../harness/types.js";
16
+ import type { Workflow } from "../types.js";
17
+ export interface TryFixtureNode {
18
+ /** Recorded wall-clock time of the node. */
19
+ duration_ms: number;
20
+ /** Tool name to number of calls. Counts only: no inputs or outputs are recorded. */
21
+ tool_calls: Record<string, number>;
22
+ usage?: {
23
+ inputTokens?: number;
24
+ outputTokens?: number;
25
+ costUsd?: number;
26
+ numTurns?: number;
27
+ };
28
+ /** The node's declared output fields (the sample answer). */
29
+ output?: Record<string, unknown>;
30
+ }
31
+ export interface TryFixture {
32
+ schema_version: 1;
33
+ /** True until the owner replaces the file with a recording of a real run. */
34
+ illustrative: boolean;
35
+ notice: string;
36
+ /** Built-in template id the recording is of. */
37
+ workflow: string;
38
+ sample_repo: string;
39
+ harness: {
40
+ id: string;
41
+ version: string;
42
+ };
43
+ policy?: {
44
+ envScope: boolean;
45
+ sandbox: "off" | "auto" | "strict";
46
+ sandboxStarted: boolean;
47
+ };
48
+ /** Node id to recording, in execution order. */
49
+ nodes: Record<string, TryFixtureNode>;
50
+ }
51
+ /** Shape check with a message naming the first bad field. Throws on a malformed fixture. */
52
+ export declare function parseTryFixture(raw: unknown): TryFixture;
53
+ /** The shipped fixture: `try-fixture.json` next to this module (src/cli or dist/cli). */
54
+ export declare function loadTryFixture(file?: string): TryFixture;
55
+ /** The real explain-repo template, parsed and validated like any workflow file. */
56
+ export declare function loadTryWorkflow(templateId: string): Workflow;
57
+ /**
58
+ * An AgentHarness that plays the recording back: node N of the run gets
59
+ * recording N. It never spawns a process, reads env, or touches the network.
60
+ * `paceMs` is how long each node takes to replay (0 = instant); tool calls are
61
+ * reported through `onProgress` spread across that time so progress feels live.
62
+ */
63
+ export declare function createReplayHarness(fixture: TryFixture, opts: {
64
+ paceMs: number;
65
+ }): AgentHarness;
66
+ 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"];
69
+ /** Appended to `sweny try --help`. */
70
+ export declare const TRY_HELP_NOTE: string;
71
+ export declare function formatTryBanner(fixture: TryFixture): string;
72
+ export declare function formatTryNext(): string;
73
+ export interface TryOptions {
74
+ /** Replay instantly. */
75
+ fast?: boolean;
76
+ /** Milliseconds each node takes to replay. Default 1200. Ignored with `fast`. */
77
+ paceMs?: number;
78
+ /** Also write the PR-comment markdown here. */
79
+ commentFile?: string;
80
+ /** Output sink. Default stdout. */
81
+ write?: (s: string) => void;
82
+ /** Rewrite progress lines in place. Default: stdout is a TTY. */
83
+ tty?: boolean;
84
+ fixture?: TryFixture;
85
+ }
86
+ export declare const DEFAULT_TRY_PACE_MS = 1200;
87
+ /** Replay the recording. Returns the process exit code. */
88
+ export declare function runTry(opts?: TryOptions): Promise<number>;
89
+ export declare function registerTryCommand(program: Command): Command;
@@ -0,0 +1,284 @@
1
+ /**
2
+ * `sweny try`: a zero-credential demo (#475).
3
+ *
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
7
+ * user sees. No agent, no model, no network, no env credentials, no files
8
+ * written (no `.sweny/runs`, no journal). The environment handed to the
9
+ * executor is empty by construction.
10
+ *
11
+ * The recording lives in `try-fixture.json` (metadata plus the sample answer
12
+ * only). Until a real run is recorded it is labelled illustrative.
13
+ */
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import chalk from "chalk";
18
+ import { parse as parseYaml } from "yaml";
19
+ import { execute } from "../executor.js";
20
+ import { validateParsed } from "../loader.js";
21
+ import { WORKFLOW_TEMPLATES } from "./templates.js";
22
+ import { formatFinalOutput, resolveFinalOutput } from "./final-output.js";
23
+ import { formatRunComment } from "./comment-output.js";
24
+ import { formatReceiptDuration, renderReceiptLine, summarizeRun } from "./run-output.js";
25
+ import { c } from "./output.js";
26
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
27
+ /** Shape check with a message naming the first bad field. Throws on a malformed fixture. */
28
+ export function parseTryFixture(raw) {
29
+ const bad = (what) => {
30
+ throw new Error(`try-fixture.json: ${what}`);
31
+ };
32
+ if (!isObj(raw))
33
+ return bad("expected an object");
34
+ if (raw.schema_version !== 1)
35
+ bad("schema_version must be 1");
36
+ for (const key of ["notice", "workflow", "sample_repo"]) {
37
+ if (typeof raw[key] !== "string" || raw[key] === "")
38
+ bad(`${key} must be a non-empty string`);
39
+ }
40
+ if (typeof raw.illustrative !== "boolean")
41
+ bad("illustrative must be a boolean");
42
+ if (!isObj(raw.harness) || typeof raw.harness.id !== "string" || typeof raw.harness.version !== "string") {
43
+ bad("harness must be { id, version }");
44
+ }
45
+ if (!isObj(raw.nodes) || Object.keys(raw.nodes).length === 0)
46
+ return bad("nodes must be a non-empty object");
47
+ for (const [id, n] of Object.entries(raw.nodes)) {
48
+ if (!isObj(n) || typeof n.duration_ms !== "number" || !isObj(n.tool_calls)) {
49
+ bad(`nodes.${id} needs duration_ms and tool_calls`);
50
+ }
51
+ for (const [tool, count] of Object.entries(n.tool_calls)) {
52
+ if (!Number.isInteger(count) || count < 0)
53
+ bad(`nodes.${id}.tool_calls.${tool} must be a count`);
54
+ }
55
+ }
56
+ return raw;
57
+ }
58
+ /** The shipped fixture: `try-fixture.json` next to this module (src/cli or dist/cli). */
59
+ export function loadTryFixture(file) {
60
+ const p = file ?? fileURLToPath(new URL("./try-fixture.json", import.meta.url));
61
+ return parseTryFixture(JSON.parse(fs.readFileSync(p, "utf-8")));
62
+ }
63
+ /** The real explain-repo template, parsed and validated like any workflow file. */
64
+ export function loadTryWorkflow(templateId) {
65
+ const tpl = WORKFLOW_TEMPLATES.find((t) => t.id === templateId);
66
+ if (!tpl)
67
+ throw new Error(`try-fixture.json: unknown template "${templateId}"`);
68
+ const loaded = validateParsed(parseYaml(tpl.yaml));
69
+ if (!loaded.ok)
70
+ throw new Error(`template ${templateId} is invalid: ${loaded.errors.map((e) => e.message).join("; ")}`);
71
+ return loaded.workflow;
72
+ }
73
+ // ── Replay harness ──────────────────────────────────────────────
74
+ const REPLAY_CAPABILITIES = {
75
+ structuredOutput: "prompt",
76
+ toolTrace: "skill-only",
77
+ builtinDeny: "none",
78
+ mcp: { inject: false, exclusive: "none" },
79
+ sandbox: { fs: false, network: false },
80
+ readOnly: "none",
81
+ turnLimit: "none",
82
+ usage: { tokens: true, costUsd: true, live: false },
83
+ cancel: "signal",
84
+ resume: false,
85
+ };
86
+ function sleep(ms, signal) {
87
+ if (ms <= 0)
88
+ return Promise.resolve();
89
+ return new Promise((resolve) => {
90
+ const t = setTimeout(resolve, ms);
91
+ signal?.addEventListener("abort", () => {
92
+ clearTimeout(t);
93
+ resolve();
94
+ }, { once: true });
95
+ });
96
+ }
97
+ /**
98
+ * An AgentHarness that plays the recording back: node N of the run gets
99
+ * recording N. It never spawns a process, reads env, or touches the network.
100
+ * `paceMs` is how long each node takes to replay (0 = instant); tool calls are
101
+ * reported through `onProgress` spread across that time so progress feels live.
102
+ */
103
+ export function createReplayHarness(fixture, opts) {
104
+ const order = Object.keys(fixture.nodes);
105
+ let next = 0;
106
+ return {
107
+ id: "mock",
108
+ capabilities: REPLAY_CAPABILITIES,
109
+ async preflight() {
110
+ return { ok: true, version: "replay" };
111
+ },
112
+ // Routing and judging never run in a linear recorded workflow; fail closed if asked.
113
+ async complete() {
114
+ return null;
115
+ },
116
+ async run(req) {
117
+ const id = order[next++];
118
+ const rec = id ? fixture.nodes[id] : undefined;
119
+ const harness = { id: fixture.harness.id, version: fixture.harness.version };
120
+ if (!rec) {
121
+ return {
122
+ status: "failed",
123
+ data: { error: "recording has no more nodes" },
124
+ toolCalls: [],
125
+ harness,
126
+ degraded: [],
127
+ };
128
+ }
129
+ const toolCalls = [];
130
+ for (const [tool, count] of Object.entries(rec.tool_calls)) {
131
+ for (let i = 0; i < count; i++)
132
+ toolCalls.push({ tool, input: {} });
133
+ }
134
+ const step = toolCalls.length > 0 ? opts.paceMs / toolCalls.length : opts.paceMs;
135
+ for (const tc of toolCalls) {
136
+ await sleep(step, req.signal);
137
+ req.onProgress?.(tc.tool);
138
+ }
139
+ if (toolCalls.length === 0)
140
+ await sleep(step, req.signal);
141
+ return {
142
+ status: "success",
143
+ data: { ...(rec.output ?? {}) },
144
+ toolCalls,
145
+ ...(rec.usage ? { usage: rec.usage } : {}),
146
+ ...(fixture.policy ? { policy: fixture.policy } : {}),
147
+ harness,
148
+ degraded: [],
149
+ };
150
+ },
151
+ };
152
+ }
153
+ // ── Output ──────────────────────────────────────────────────────
154
+ 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
+ ];
161
+ /** Appended to `sweny try --help`. */
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
+ "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() {
175
+ 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."),
181
+ ].join("\n");
182
+ }
183
+ export const DEFAULT_TRY_PACE_MS = 1200;
184
+ const silentLogger = { info() { }, warn() { }, error() { }, debug() { } };
185
+ /** Replay the recording. Returns the process exit code. */
186
+ export async function runTry(opts = {}) {
187
+ const write = opts.write ?? ((s) => void process.stdout.write(s));
188
+ const tty = opts.tty ?? Boolean(process.stdout.isTTY);
189
+ const paceMs = opts.fast ? 0 : Math.max(0, opts.paceMs ?? DEFAULT_TRY_PACE_MS);
190
+ const fixture = opts.fixture ?? loadTryFixture();
191
+ const workflow = loadTryWorkflow(fixture.workflow);
192
+ for (const id of Object.keys(fixture.nodes)) {
193
+ if (!workflow.nodes[id])
194
+ throw new Error(`try-fixture.json: node "${id}" is not in ${fixture.workflow}`);
195
+ }
196
+ write(`\n${formatTryBanner(fixture)}\n\n`);
197
+ const ticks = new Map();
198
+ const observer = (event) => {
199
+ switch (event.type) {
200
+ case "workflow:start":
201
+ write(` ${c.brand("▲")} ${chalk.bold(event.workflow)}\n\n`);
202
+ break;
203
+ case "node:enter":
204
+ ticks.set(event.node, 0);
205
+ if (tty)
206
+ write(` ${c.subtle("○")} ${chalk.dim(event.node)}…`);
207
+ 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
+ }
214
+ break;
215
+ }
216
+ case "node:exit": {
217
+ const rec = fixture.nodes[event.node];
218
+ const ok = event.result.status === "success";
219
+ const icon = ok ? c.ok("✓") : c.fail("✗");
220
+ 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`);
223
+ break;
224
+ }
225
+ }
226
+ };
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
+ });
237
+ write("\n");
238
+ // The recording's timings, not the replay's wall clock.
239
+ const durationsMs = Object.fromEntries(Object.entries(fixture.nodes).map(([id, n]) => [id, n.duration_ms]));
240
+ const totalMs = Object.values(durationsMs).reduce((a, b) => a + b, 0);
241
+ const receipt = summarizeRun(results, totalMs, false, trace);
242
+ const answer = resolveFinalOutput(workflow, results);
243
+ if (answer)
244
+ write(`${formatFinalOutput(answer)}\n\n`);
245
+ write(` ${renderReceiptLine(receipt, tty)}\n\n`);
246
+ 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
249
+ .split("\n")
250
+ .map((l) => (l === "" ? l : ` ${l}`))
251
+ .join("\n")}\n`);
252
+ if (opts.commentFile) {
253
+ try {
254
+ fs.mkdirSync(path.dirname(path.resolve(opts.commentFile)), { recursive: true });
255
+ fs.writeFileSync(opts.commentFile, comment);
256
+ }
257
+ catch (err) {
258
+ process.stderr.write(` ⚠ could not write comment file: ${err instanceof Error ? err.message : err}\n`);
259
+ }
260
+ }
261
+ write(`${formatTryNext()}\n\n`);
262
+ return receipt.ok ? 0 : 1;
263
+ }
264
+ export function registerTryCommand(program) {
265
+ return program
266
+ .command("try")
267
+ .description("Replay a recorded demo run: no credentials, no network, no model calls (illustrative sample data)")
268
+ .option("--fast", "Replay instantly instead of pacing the progress")
269
+ .option("--pace <ms>", `Milliseconds each node takes to replay (default ${DEFAULT_TRY_PACE_MS})`)
270
+ .option("--comment-file <path>", "Also write the PR-comment markdown to <path>")
271
+ .addHelpText("after", `\n${TRY_HELP_NOTE}\n`)
272
+ .action(async (options) => {
273
+ let paceMs;
274
+ if (options.pace !== undefined) {
275
+ paceMs = Number(options.pace);
276
+ 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`));
278
+ process.exitCode = 1;
279
+ return;
280
+ }
281
+ }
282
+ process.exitCode = await runTry({ fast: options.fast, paceMs, commentFile: options.commentFile });
283
+ });
284
+ }
@@ -38,6 +38,9 @@ export function shouldSkip(env, isTty, commandName) {
38
38
  return true;
39
39
  if (commandName === "upgrade" || commandName === "update")
40
40
  return true;
41
+ // `sweny try` makes no network calls, the version check included.
42
+ if (commandName === "try")
43
+ return true;
41
44
  return false;
42
45
  }
43
46
  export function readCache(cachePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.166",
3
+ "version": "0.1.168",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"
@@ -50,7 +50,7 @@
50
50
  "dist"
51
51
  ],
52
52
  "scripts": {
53
- "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/workflows/*.yml dist/workflows/ && node scripts/build-workflow-browser.mjs && node scripts/write-public-schema.mjs",
53
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/workflows/*.yml dist/workflows/ && cp src/cli/try-fixture.json dist/cli/ && node scripts/build-workflow-browser.mjs && node scripts/write-public-schema.mjs",
54
54
  "typecheck": "tsc --noEmit",
55
55
  "test": "vitest run --exclude 'src/__tests__/integration/**' --exclude 'dist/**'",
56
56
  "test:integration": "vitest run --config vitest.integration.config.ts",