@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 +11 -4
- package/dist/cli/new.d.ts +15 -0
- package/dist/cli/new.js +43 -2
- package/dist/cli/packs.js +78 -4
- package/dist/cli/try-fixture.json +35 -0
- package/dist/cli/try.d.ts +89 -0
- package/dist/cli/try.js +284 -0
- package/dist/cli/version-check.js +3 -0
- package/package.json +2 -2
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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({
|
|
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 #
|
|
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@
|
|
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@
|
|
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@
|
|
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;
|
package/dist/cli/try.js
ADDED
|
@@ -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.
|
|
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",
|