@asrunjs/asrun 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/explore-case.md +2 -1
- package/.claude/commands/gen-spec.md +2 -1
- package/.claude/commands/heal-spec.md +2 -1
- package/.claude/commands/make-case.md +2 -1
- package/.claude/skills/exploring-cases/SKILL.md +14 -6
- package/.claude/skills/exploring-cases/references/case-rules.md +5 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +8 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +12 -8
- package/.claude/skills/generating-specs/SKILL.md +17 -10
- package/.claude/skills/generating-specs/references/conventions.md +6 -2
- package/.claude/skills/making-cases/SKILL.md +12 -5
- package/.claude/skills/making-cases/references/writes-and-email.md +5 -2
- package/AGENTS.md +20 -5
- package/README.md +10 -6
- package/dist/lib/case-doc.d.ts +1 -1
- package/dist/lib/config.d.ts +26 -0
- package/dist/lib/config.js +51 -0
- package/dist/lib/fixtures.d.ts +1 -1
- package/dist/lib/fixtures.js +7 -4
- package/dist/lib/records.d.ts +17 -2
- package/dist/lib/records.js +32 -2
- package/dist/lib/rules.d.ts +57 -0
- package/dist/lib/rules.js +140 -0
- package/dist/scripts/cleanup.js +10 -1
- package/dist/scripts/cli.js +9 -1
- package/dist/scripts/doctor.js +27 -48
- package/dist/scripts/lint-specs.js +23 -1
- package/dist/scripts/rules.d.ts +1 -0
- package/dist/scripts/rules.js +148 -0
- package/dist/ui/server/ai-stream.d.ts +2 -2
- package/dist/ui/server/ai-stream.js +190 -30
- package/dist/ui/shared/log-text.d.ts +13 -0
- package/dist/ui/shared/log-text.js +45 -0
- package/dist/ui/shared/types.d.ts +6 -1
- package/docs/agents/case-format.md +8 -5
- package/docs/ai-workflow.md +21 -12
- package/docs/cases.md +10 -6
- package/docs/commands.md +22 -4
- package/docs/install.md +2 -1
- package/docs/theme-guide.html +24 -3
- package/docs/troubleshooting.md +11 -2
- package/docs/web-ui.md +30 -9
- package/package.json +4 -1
- package/templates/.claude/commands/explore-case.md +3 -1
- package/templates/.claude/commands/gen-spec.md +3 -1
- package/templates/.claude/commands/heal-spec.md +3 -1
- package/templates/.claude/commands/make-case.md +3 -1
- package/templates/AGENTS.md +17 -8
- package/templates/cases/_template.md +3 -3
- package/templates/package.json +4 -0
- package/ui/dist/assets/{dist-BMmNFIUH.js → dist-BB2cF3XN.js} +1 -1
- package/ui/dist/assets/{dist-ByLbEOlF.js → dist-C46EmZuI.js} +1 -1
- package/ui/dist/assets/{dist-CQS0So-i.js → dist-D9GmSkjm.js} +1 -1
- package/ui/dist/assets/{dist-BXNaYHeh.js → dist-DGzg2gyA.js} +1 -1
- package/ui/dist/assets/{dist-DTYj2vUD.js → dist-DMiOs9Qj.js} +1 -1
- package/ui/dist/assets/{dist-mV0_nQZe.js → dist-Db_XhDui.js} +1 -1
- package/ui/dist/assets/{dist-CTrwRc3Q.js → dist-DrFzCwMc.js} +1 -1
- package/ui/dist/assets/{dist--BXJInTq.js → dist-Dy45nXf8.js} +1 -1
- package/ui/dist/assets/{dist-CK8mow8u.js → dist-P9roUi1O.js} +1 -1
- package/ui/dist/assets/{dist-C9ra6_BD.js → dist-_0g6iC9K.js} +1 -1
- package/ui/dist/assets/index-BN4ucmmn.js +376 -0
- package/ui/dist/assets/index-Bo4XKJvP.css +2 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BzMqkmQ9.js +0 -348
- package/ui/dist/assets/index-DcJR-8Mt.css +0 -2
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import { expandEscapedText } from '../shared/log-text.js';
|
|
1
2
|
const MAX_DETAIL = 180;
|
|
2
3
|
const MAX_ERROR = 300;
|
|
3
4
|
const MAX_REASONING = 2000;
|
|
4
|
-
/**
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/** The argument of a call is kept whole, within reason: the log opens it instead of a truncated summary. */
|
|
6
|
+
const MAX_INPUT = 4000;
|
|
7
|
+
/**
|
|
8
|
+
* An edit carries the snippet it replaces and the one it writes, or the whole content of a small file. Keeping more of
|
|
9
|
+
* it is what lets the log draw a diff instead of a JSON blob cut in the middle.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_INPUT_EDIT = 20_000;
|
|
12
|
+
/** Tools whose argument is a change to a file (the same family the log draws as a diff). */
|
|
13
|
+
const EDIT_TOOL = /^(edit|multiedit|write|notebookedit|patch|applypatch)$/;
|
|
14
|
+
/** What a call returned is kept up to this, so the log can show it all; a runaway dump still has an end. */
|
|
15
|
+
const MAX_RESULT_CHARS = 20_000;
|
|
16
|
+
const MAX_RESULT_LINES = 400;
|
|
17
|
+
/** One line longer than this is a minified document or a data URL: its tail says nothing more. */
|
|
18
|
+
const MAX_RESULT_LINE = 2000;
|
|
8
19
|
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
9
20
|
const SANDBOX_ERROR = /listen EPERM|outside of the sandbox|sandbox/i;
|
|
10
21
|
/** A saved login is a password (AGENTS.md F5): never copy what a call read from `.auth` into the log. */
|
|
@@ -14,8 +25,15 @@ const oneLine = (value, max) => {
|
|
|
14
25
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
15
26
|
};
|
|
16
27
|
const cap = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
28
|
+
/** Keeps a body whole up to `max`, and says how much it left out instead of cutting it silently. */
|
|
29
|
+
function capBody(text, max) {
|
|
30
|
+
if (text.length <= max)
|
|
31
|
+
return text;
|
|
32
|
+
const rest = text.length - max;
|
|
33
|
+
return `${text.slice(0, max)}\n… (${rest} more character${rest === 1 ? '' : 's'} not shown)`;
|
|
34
|
+
}
|
|
17
35
|
const seconds = (ms) => (ms < 90_000 ? `${Math.round(ms / 1000)} s` : `${(ms / 60_000).toFixed(1)} min`);
|
|
18
|
-
/** The first thing a piece of code does, for
|
|
36
|
+
/** The first thing a piece of code does, for code that makes no tool call of its own (`await page.goto(...)`). */
|
|
19
37
|
function firstStatement(code) {
|
|
20
38
|
const line = code
|
|
21
39
|
.split('\n')
|
|
@@ -23,12 +41,98 @@ function firstStatement(code) {
|
|
|
23
41
|
.find((text) => text !== '' && !text.startsWith('//') && !text.startsWith('#'));
|
|
24
42
|
return line ?? '';
|
|
25
43
|
}
|
|
44
|
+
/** `tools["playwright-test"]`, `tools.playwright.mcp`, `tools['x']['y']`: one step into the tools object. */
|
|
45
|
+
const ACCESSOR = /^\s*(?:\.\s*([A-Za-z0-9_$]+)|\[\s*(['"`])((?:\\.|(?!\2)[^\\])*)\2\s*\])/;
|
|
46
|
+
function stepInto(text) {
|
|
47
|
+
const match = ACCESSOR.exec(text);
|
|
48
|
+
if (!match)
|
|
49
|
+
return null;
|
|
50
|
+
return { value: match[1] ?? match[3], length: match[0].length };
|
|
51
|
+
}
|
|
52
|
+
/** How much of a call's arguments is read to name the thing it touched. */
|
|
53
|
+
const SUMMARY_ARGS = 600;
|
|
54
|
+
/** The argument that says most about a call, in the order a person would look for it: the place it touched, then the
|
|
55
|
+
* intent it was given for. */
|
|
56
|
+
const ARG_KEYS = [
|
|
57
|
+
'url',
|
|
58
|
+
'path',
|
|
59
|
+
'file_path',
|
|
60
|
+
'filePath',
|
|
61
|
+
'filename',
|
|
62
|
+
'element',
|
|
63
|
+
'selector',
|
|
64
|
+
'pattern',
|
|
65
|
+
'query',
|
|
66
|
+
'intent',
|
|
67
|
+
'description',
|
|
68
|
+
'name',
|
|
69
|
+
'text',
|
|
70
|
+
];
|
|
71
|
+
/**
|
|
72
|
+
* The calls a code-mode `execute` makes on the tools it is given
|
|
73
|
+
* (`tools["playwright-test"].browser_navigate({url: "..."})`). The code is the only thing that says what such a call
|
|
74
|
+
* did: the tool `execute` itself names nothing.
|
|
75
|
+
*/
|
|
76
|
+
function nestedCalls(code) {
|
|
77
|
+
const calls = [];
|
|
78
|
+
let from = 0;
|
|
79
|
+
for (;;) {
|
|
80
|
+
const at = code.indexOf('tools', from);
|
|
81
|
+
if (at < 0)
|
|
82
|
+
break;
|
|
83
|
+
from = at + 'tools'.length;
|
|
84
|
+
// `prototype.tools[...]` is somebody else's object, not the code mode's own.
|
|
85
|
+
if (at > 0 && /[A-Za-z0-9_$.]/.test(code[at - 1]))
|
|
86
|
+
continue;
|
|
87
|
+
const namespace = stepInto(code.slice(from));
|
|
88
|
+
if (!namespace)
|
|
89
|
+
continue;
|
|
90
|
+
const tool = stepInto(code.slice(from + namespace.length));
|
|
91
|
+
if (!tool)
|
|
92
|
+
continue;
|
|
93
|
+
const callAt = from + namespace.length + tool.length;
|
|
94
|
+
const open = /^\s*\(/.test(code.slice(callAt)) ? code.indexOf('(', callAt) : -1;
|
|
95
|
+
if (open < 0)
|
|
96
|
+
continue;
|
|
97
|
+
calls.push({ name: tool.value, args: code.slice(open + 1, open + 1 + SUMMARY_ARGS) });
|
|
98
|
+
from = open + 1;
|
|
99
|
+
}
|
|
100
|
+
return calls;
|
|
101
|
+
}
|
|
102
|
+
/** The first argument of a call that says what it touched: the URL, the element, the file, the intent. */
|
|
103
|
+
function firstArgument(args) {
|
|
104
|
+
const found = new Map();
|
|
105
|
+
for (const match of args.matchAll(/(['"]?)([A-Za-z_$][\w$]*)\1\s*:\s*(['"`])((?:\\.|(?!\3)[^\\])*?)\3/g)) {
|
|
106
|
+
if (!found.has(match[2]))
|
|
107
|
+
found.set(match[2], match[4].replace(/\\(['"`\\])/g, '$1'));
|
|
108
|
+
}
|
|
109
|
+
for (const key of ARG_KEYS) {
|
|
110
|
+
const value = found.get(key);
|
|
111
|
+
if (value && value.trim())
|
|
112
|
+
return oneLine(value, MAX_DETAIL);
|
|
113
|
+
}
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What a code-mode call does, in one line: `browser_navigate(http://…)`, or the code's first statement when it makes no
|
|
118
|
+
* tool call. The whole code opens from the call's card when the line is not enough.
|
|
119
|
+
*/
|
|
120
|
+
function codeSummary(code) {
|
|
121
|
+
const calls = nestedCalls(code);
|
|
122
|
+
const [call] = calls;
|
|
123
|
+
const summary = call ? `${call.name}(${firstArgument(call.args)})` : oneLine(firstStatement(code), MAX_DETAIL);
|
|
124
|
+
const extra = calls.length - 1;
|
|
125
|
+
if (extra > 0)
|
|
126
|
+
return cap(`${summary} +${extra} more call${extra === 1 ? '' : 's'}`, MAX_DETAIL);
|
|
127
|
+
return cap(summary, MAX_DETAIL);
|
|
128
|
+
}
|
|
26
129
|
/** `mcp__playwright-test__browser_click` -> `browser_click`. */
|
|
27
130
|
const toolName = (name) => name.replace(/^mcp__[^_]+(?:-[^_]+)*__/, '');
|
|
28
131
|
/**
|
|
29
132
|
* What the call is about, in a few words: the intent the agent gave, or the argument that names the thing it touched.
|
|
30
133
|
* The names differ per CLI and per tool (`file_path` for Claude, `path` for OpenCode; `pattern` for glob and grep;
|
|
31
|
-
* `code` for a code-mode `execute`), so every one of them is looked at.
|
|
134
|
+
* `code` for a code-mode `execute`), so every one of them is looked at. This is the log's one line; the whole argument
|
|
135
|
+
* travels next to it (`fullInput`) and is opened on demand.
|
|
32
136
|
*/
|
|
33
137
|
function describe(input) {
|
|
34
138
|
if (!input || typeof input !== 'object')
|
|
@@ -51,45 +155,100 @@ function describe(input) {
|
|
|
51
155
|
].find((name) => typeof args[name] === 'string' && args[name].trim() !== '');
|
|
52
156
|
if (key)
|
|
53
157
|
return oneLine(args[key], MAX_DETAIL);
|
|
158
|
+
// A patch names its file inside the text, and the text itself would fill the whole line with diff noise.
|
|
159
|
+
const patch = args.patchText ?? args.patch_text ?? args.patch ?? args.diff ?? args.input;
|
|
160
|
+
if (typeof patch === 'string') {
|
|
161
|
+
const file = /^\+\+\+ b\/(.+)$/m.exec(patch)?.[1] ?? /^\*\*\* (?:Update|Add|Delete) File: (.+)$/m.exec(patch)?.[1];
|
|
162
|
+
if (file)
|
|
163
|
+
return oneLine(file.trim(), MAX_DETAIL);
|
|
164
|
+
}
|
|
54
165
|
if (typeof args.code === 'string')
|
|
55
|
-
return
|
|
166
|
+
return codeSummary(args.code);
|
|
56
167
|
// Nothing known: the longest string still tells more than the bare tool name.
|
|
57
168
|
const longest = Object.values(args)
|
|
58
169
|
.filter((value) => typeof value === 'string')
|
|
59
170
|
.sort((a, b) => b.length - a.length)[0];
|
|
60
171
|
return longest ? oneLine(longest, MAX_DETAIL) : '';
|
|
61
172
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
173
|
+
/**
|
|
174
|
+
* The whole call, as the log opens it: the code or the command as written, else the arguments as JSON. One short
|
|
175
|
+
* argument that the summary already shows is not worth a second copy of the same words.
|
|
176
|
+
*/
|
|
177
|
+
function fullInput(input, detail, tool) {
|
|
178
|
+
if (!input || typeof input !== 'object')
|
|
179
|
+
return undefined;
|
|
180
|
+
const args = input;
|
|
181
|
+
const edit = EDIT_TOOL.test(tool.toLowerCase().replace(/[_-]/g, ''));
|
|
182
|
+
const max = edit ? MAX_INPUT_EDIT : MAX_INPUT;
|
|
183
|
+
if (typeof args.code === 'string' && args.code.trim())
|
|
184
|
+
return capBody(args.code.trimEnd(), max);
|
|
185
|
+
if (typeof args.command === 'string' && args.command.trim())
|
|
186
|
+
return capBody(args.command.trimEnd(), max);
|
|
187
|
+
// One short argument that the summary already shows is not worth a second copy of the same words; anything longer
|
|
188
|
+
// than the summary (a patch, a file's content) or anything more (a window, a flag) is worth opening. An edit always
|
|
189
|
+
// keeps its argument: that is what the log draws as a diff.
|
|
190
|
+
const named = Object.entries(args).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '');
|
|
191
|
+
const only = named.length === 1 ? named[0][1] : undefined;
|
|
192
|
+
if (!edit && typeof only === 'string' && only.trim().length <= MAX_DETAIL && oneLine(only, MAX_DETAIL) === detail) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const json = JSON.stringify(args, null, 2);
|
|
197
|
+
return json && json !== '{}' ? capBody(json, max) : undefined;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const callOf = (name, input) => {
|
|
204
|
+
const detail = describe(input);
|
|
205
|
+
const full = fullInput(input, detail, name);
|
|
206
|
+
return {
|
|
207
|
+
name: toolName(name),
|
|
208
|
+
detail,
|
|
209
|
+
...(full && full !== detail && { input: full }),
|
|
210
|
+
ok: true,
|
|
211
|
+
pending: true,
|
|
212
|
+
};
|
|
213
|
+
};
|
|
68
214
|
const labelText = (call) => (call.detail ? `${call.name}: ${call.detail}` : call.name);
|
|
69
215
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
216
|
+
* A JSON result is kept with the indentation a person reads: a minified document is one endless line, which no reader
|
|
217
|
+
* and no line cap can handle. Anything that is not JSON comes back as it was.
|
|
218
|
+
*/
|
|
219
|
+
function prettyJsonText(text) {
|
|
220
|
+
if (!text.startsWith('{') && !text.startsWith('['))
|
|
221
|
+
return text;
|
|
222
|
+
try {
|
|
223
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return text;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* What a call returned, whole: the log opens one call and reads all of it. With the colors the shell put in it. A
|
|
231
|
+
* runaway dump is still cut at a point a person can come back from, and the line left out says how much it was.
|
|
72
232
|
*/
|
|
73
|
-
function
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
.trim();
|
|
233
|
+
function resultText(value) {
|
|
234
|
+
const raw = (typeof value === 'string' ? value : value == null ? '' : String(value)).replace(/\r\n?/g, '\n');
|
|
235
|
+
// A result that is a JSON string of its own text is decoded first: nobody reads `\n` as a line break in their head.
|
|
236
|
+
const text = prettyJsonText(expandEscapedText(raw).replace(/\t/g, ' ').trim());
|
|
78
237
|
if (!text)
|
|
79
238
|
return null;
|
|
80
239
|
const lines = text.split('\n');
|
|
81
240
|
const kept = [];
|
|
82
241
|
let chars = 0;
|
|
83
242
|
for (const line of lines) {
|
|
84
|
-
if (kept.length >=
|
|
243
|
+
if (kept.length >= MAX_RESULT_LINES || chars >= MAX_RESULT_CHARS)
|
|
85
244
|
break;
|
|
86
245
|
// A cut line loses its colors, so a half escape sequence cannot bleed into the rest of the block.
|
|
87
|
-
const shown = line.length >
|
|
246
|
+
const shown = line.length > MAX_RESULT_LINE ? oneLine(line.replace(ANSI, ''), MAX_RESULT_LINE) : line;
|
|
88
247
|
kept.push(shown);
|
|
89
248
|
chars += shown.length;
|
|
90
249
|
}
|
|
91
250
|
const rest = lines.length - kept.length;
|
|
92
|
-
return kept.join('\n') + (rest > 0 ? `\n… (${rest} more line${rest === 1 ? '' : 's'})` : '');
|
|
251
|
+
return kept.join('\n') + (rest > 0 ? `\n… (${rest} more line${rest === 1 ? '' : 's'} not shown)` : '');
|
|
93
252
|
}
|
|
94
253
|
const HANDOFF_CFG = '__handoffCfg';
|
|
95
254
|
const HANDOFF_CARD = /handoff-card\.js/;
|
|
@@ -251,12 +410,12 @@ export class AiStreamFormatter {
|
|
|
251
410
|
/** A call the agent has started and not finished yet (Claude reports both ends). */
|
|
252
411
|
start(id, call) {
|
|
253
412
|
const now = Date.now();
|
|
254
|
-
this.pending.set(id, { name: call.name, detail: call.detail, startedAt: now });
|
|
413
|
+
this.pending.set(id, { name: call.name, detail: call.detail, input: call.input, startedAt: now });
|
|
255
414
|
return [{ kind: 'tool', at: now, data: `>> ${labelText(call)}`, tool: call }];
|
|
256
415
|
}
|
|
257
416
|
/**
|
|
258
|
-
* The end of a call, plus
|
|
259
|
-
*
|
|
417
|
+
* The end of a call, plus what it returned. `fallback` is for a CLI that reports a call only when it is over
|
|
418
|
+
* (OpenCode), which still knows when it started.
|
|
260
419
|
*/
|
|
261
420
|
finishCall(id, ok, output, fallback) {
|
|
262
421
|
const now = Date.now();
|
|
@@ -266,6 +425,7 @@ export class AiStreamFormatter {
|
|
|
266
425
|
this.waitingForUser = null;
|
|
267
426
|
const name = call?.name ?? fallback?.name ?? 'tool';
|
|
268
427
|
const detail = call?.detail ?? fallback?.detail ?? '';
|
|
428
|
+
const input = call?.input ?? fallback?.input;
|
|
269
429
|
const startedAt = call?.startedAt ?? fallback?.startedAt;
|
|
270
430
|
const took = startedAt ? seconds(now - startedAt) : undefined;
|
|
271
431
|
const label = labelText({ name, detail });
|
|
@@ -275,7 +435,7 @@ export class AiStreamFormatter {
|
|
|
275
435
|
kind: 'tool',
|
|
276
436
|
at: now,
|
|
277
437
|
data: `${ok ? 'ok' : 'FAILED'}: ${label}${took ? ` after ${took}` : ''}`,
|
|
278
|
-
tool: { name, detail, ok, ...(took && { took }) },
|
|
438
|
+
tool: { name, detail, ...(input && { input }), ok, ...(took && { took }) },
|
|
279
439
|
},
|
|
280
440
|
];
|
|
281
441
|
if (!ok) {
|
|
@@ -285,9 +445,9 @@ export class AiStreamFormatter {
|
|
|
285
445
|
return parts;
|
|
286
446
|
}
|
|
287
447
|
// A saved login is a password: what a call read from `.auth` never goes into the log.
|
|
288
|
-
const
|
|
289
|
-
if (
|
|
290
|
-
parts.push({ kind: 'result', at: now, data:
|
|
448
|
+
const result = SECRET_PATH.test(detail) ? null : resultText(output);
|
|
449
|
+
if (result)
|
|
450
|
+
parts.push({ kind: 'result', at: now, data: result });
|
|
291
451
|
return parts;
|
|
292
452
|
}
|
|
293
453
|
/** Said once per run, after the first tool call that Claude Code's sandbox refused (nobody is there to approve it). */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text of a tool call, as the log can show it. A tool that answers with a JSON string of its own text (`"…\n…"`, which
|
|
3
|
+
* is what an MCP result arrives as) is unreadable as escapes: the line is decoded here, once, for both the server that
|
|
4
|
+
* stores it and the page that renders what an older run already stored.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* One line of a result. If the whole line is a JSON string literal with escapes in it, the text it holds comes back —
|
|
8
|
+
* `"a\nb"` becomes two lines; a plain `"OK"` or a sentence in quotes keeps its quotes. Two rounds cover a value that
|
|
9
|
+
* was serialized twice, which is as deep as tools go.
|
|
10
|
+
*/
|
|
11
|
+
export declare function expandEscapedLine(line: string): string;
|
|
12
|
+
/** The whole result, line by line (`resultText` writes it this way on the server). */
|
|
13
|
+
export declare function expandEscapedText(text: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text of a tool call, as the log can show it. A tool that answers with a JSON string of its own text (`"…\n…"`, which
|
|
3
|
+
* is what an MCP result arrives as) is unreadable as escapes: the line is decoded here, once, for both the server that
|
|
4
|
+
* stores it and the page that renders what an older run already stored.
|
|
5
|
+
*/
|
|
6
|
+
/** A string literal that carries an escape: `\n`, `\t`, `\"`, `\\`, `\u001b`. Plain quotes are left alone. */
|
|
7
|
+
const ESCAPED = /\\(?:[nrtbfv"'\\/]|u[0-9a-fA-F]{4})/;
|
|
8
|
+
/**
|
|
9
|
+
* One line of a result. If the whole line is a JSON string literal with escapes in it, the text it holds comes back —
|
|
10
|
+
* `"a\nb"` becomes two lines; a plain `"OK"` or a sentence in quotes keeps its quotes. Two rounds cover a value that
|
|
11
|
+
* was serialized twice, which is as deep as tools go.
|
|
12
|
+
*/
|
|
13
|
+
export function expandEscapedLine(line) {
|
|
14
|
+
let text = line;
|
|
15
|
+
for (let depth = 0; depth < 3; depth += 1) {
|
|
16
|
+
const trimmed = text.trim();
|
|
17
|
+
if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"'))
|
|
18
|
+
break;
|
|
19
|
+
if (!ESCAPED.test(trimmed))
|
|
20
|
+
break;
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(trimmed);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
if (typeof parsed !== 'string')
|
|
29
|
+
break;
|
|
30
|
+
// The indentation a log line arrived with stays in front of what it turned into.
|
|
31
|
+
const indent = /^\s*/.exec(text)?.[0] ?? '';
|
|
32
|
+
text = parsed
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((part) => `${indent}${part}`)
|
|
35
|
+
.join('\n');
|
|
36
|
+
}
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
/** The whole result, line by line (`resultText` writes it this way on the server). */
|
|
40
|
+
export function expandEscapedText(text) {
|
|
41
|
+
return text
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map((line) => expandEscapedLine(line))
|
|
44
|
+
.join('\n');
|
|
45
|
+
}
|
|
@@ -67,7 +67,7 @@ export type ResultState = 'ready' | 'needs-input';
|
|
|
67
67
|
/**
|
|
68
68
|
* How one block of an AI job's log is presented. Absent for the plain output of every other job, which stays raw.
|
|
69
69
|
* `markdown`: what the agent said; `reasoning`: what it was thinking (not a result); `tool`: one call, started or
|
|
70
|
-
* finished; `result`:
|
|
70
|
+
* finished; `result`: what a call returned (whole, up to a cap); `error` / `hint`: what failed and what to do about it;
|
|
71
71
|
* `meta`: anything else a person should read (the waiting line, noise the CLI printed).
|
|
72
72
|
*/
|
|
73
73
|
export type JobChunkKind = 'tool' | 'result' | 'reasoning' | 'markdown' | 'error' | 'hint' | 'meta';
|
|
@@ -76,6 +76,11 @@ export interface JobChunkTool {
|
|
|
76
76
|
name: string;
|
|
77
77
|
/** What the call is about (the file, the command, the intent); empty when the CLI gave nothing telling. */
|
|
78
78
|
detail: string;
|
|
79
|
+
/**
|
|
80
|
+
* The whole argument the call was made with (its code, its command, the rest as JSON), so the log can open one call
|
|
81
|
+
* and show it in full. Absent when the call gave nothing beyond `detail`, which a saved login's path never is.
|
|
82
|
+
*/
|
|
83
|
+
input?: string;
|
|
79
84
|
ok: boolean;
|
|
80
85
|
/** Still running: Claude reports the start of a call, OpenCode only its end. */
|
|
81
86
|
pending?: boolean;
|
|
@@ -89,7 +89,7 @@ Wrong: `- Create a draft named ... and delete it at the end.` (a write → a tag
|
|
|
89
89
|
- `- Ready when:` is a sub-line of the step: the positive signal that what the step loads is there. The spec waits
|
|
90
90
|
for it before reading; it is not an assertion.
|
|
91
91
|
- `- Recipe (phase):` is a sub-line of an `[input]`/`[writes]` step: what a person did in the browser during an
|
|
92
|
-
attended exploration (positions,
|
|
92
|
+
attended exploration (positions, created names, control labels; no selectors). A tagged step with no recipe is a
|
|
93
93
|
`needs-input` stop for `/gen-spec`.
|
|
94
94
|
- A question block is indented under the sentence it belongs to: 4 spaces under a numbered step, 2 under a bullet.
|
|
95
95
|
The parent line is the suggestion, written as a finished sentence; the `TODO` line holds one question and optional
|
|
@@ -121,7 +121,8 @@ example-staging:
|
|
|
121
121
|
- A profile with no entry and no `default` fails with "Missing values ...". An empty value or one that starts with
|
|
122
122
|
`TODO` fails with "Incomplete values ...". Fill them before running; never make a spec read the YAML itself.
|
|
123
123
|
- Values hold literals only: names, labels, options, test-data names. The checks themselves stay in `## Steps` and
|
|
124
|
-
`## Expected`, and selectors come from `## Targets`.
|
|
124
|
+
`## Expected`, and selectors come from `## Targets`. A name the case creates is stored here **without** its run
|
|
125
|
+
stamp: the literal is the base, and the spec composes `<base> (<run stamp>)` with `testDataName()` (F11).
|
|
125
126
|
- No marker or question inside the yaml: it would become part of the expected value. Put it on the prose line above
|
|
126
127
|
the block and name the key there.
|
|
127
128
|
|
|
@@ -201,9 +202,11 @@ test.describe('<Case title>', {tag: caseTags('<id>')}, () => {
|
|
|
201
202
|
a case limited to some environments is skipped elsewhere; a case that writes elsewhere only is skipped with a
|
|
202
203
|
notice; a write in a non-staging environment needs the confirmation the CLI sets, never an environment variable
|
|
203
204
|
that was merely present.
|
|
204
|
-
- Write cases: name everything
|
|
205
|
-
|
|
206
|
-
`
|
|
205
|
+
- Write cases: name everything `<prefix> <base> (<run stamp>)` through `testDataName(base)`
|
|
206
|
+
(`@asrunjs/asrun/records`), where the prefix is `asrun.testDataPrefix` in `package.json` (default `[asrun]`, printed
|
|
207
|
+
by `pnpm asrun rules`) and the stamp is the run's UTC time; register it with `records.track` right after creating
|
|
208
|
+
it, remove it newest first (also after a failure), turn retries off with `configureCase`, and keep email steps
|
|
209
|
+
behind `emailStepAllowed(profile, '<step title>')`.
|
|
207
210
|
|
|
208
211
|
## Checks to run before reporting
|
|
209
212
|
|
package/docs/ai-workflow.md
CHANGED
|
@@ -93,10 +93,11 @@ opened and nothing is written about them, so a case can be explored in passes: A
|
|
|
93
93
|
case keeps what the earlier runs recorded. In the web UI the profile picker of the toolbar does the same: every profile
|
|
94
94
|
that is logged in in that environment is ticked, and the list you tick is remembered for the next run there.
|
|
95
95
|
|
|
96
|
-
**To
|
|
97
|
-
again. In the web UI
|
|
98
|
-
**Use the suggested defaults** and **Generate spec**. A continued
|
|
99
|
-
can be continued once per turn, only while the UI server has not
|
|
96
|
+
**To keep what the case says, delete the whole block.** **To answer, write after `Answer:`.** Then run the command
|
|
97
|
+
again. In the web UI the panel calls the first action **Keep what the case says**. The run's **What to do next** bar
|
|
98
|
+
offers **Answer**: the question list, an answer box, **Use the suggested defaults** and **Generate spec**. A continued
|
|
99
|
+
job keeps the same conversation and permissions, and can be continued once per turn, only while the UI server has not
|
|
100
|
+
restarted.
|
|
100
101
|
|
|
101
102
|
### Attended exploration (`--attended`)
|
|
102
103
|
|
|
@@ -109,9 +110,10 @@ the browser window opens and the agent waits while you do those steps.
|
|
|
109
110
|
- Recording starts and stops by itself. If Playwright's own recording toolbar shows at the top of the page, leave it
|
|
110
111
|
alone: it can stop the recording.
|
|
111
112
|
- The card records what you did, and the agent writes it into the case under the step as **general recipe lines**
|
|
112
|
-
(`- Recipe (phase): ...`): positions instead of names of changeable data,
|
|
113
|
-
labels but no selectors.
|
|
114
|
-
- Delete the
|
|
113
|
+
(`- Recipe (phase): ...`): positions instead of names of changeable data, created names (project prefix plus the
|
|
114
|
+
run's UTC stamp), control labels but no selectors.
|
|
115
|
+
- Delete the test items you created (they start with the project's test-data prefix and carry this run's timestamp)
|
|
116
|
+
before you press Done on the last step.
|
|
115
117
|
- Without `--attended` the agent never waits: a step that needs a person becomes a `TODO [blocking]`.
|
|
116
118
|
|
|
117
119
|
In the web UI the **Attended** box is ticked by default for Explore.
|
|
@@ -221,6 +223,10 @@ Several binding rules hold in exploring, generating and healing. They live in th
|
|
|
221
223
|
- **Case rules.** A `## Rules` bullet says how to run the case, never what it proves. The agent applies it while
|
|
222
224
|
exploring and generating, marks code that exists only because of a rule with `// rule: <short quote>`, and lists every
|
|
223
225
|
rule in its report. It never becomes an assertion and never authorizes a write or a person's step.
|
|
226
|
+
- **Test data is findable per run.** Everything a write case creates is named with the project's test-data prefix
|
|
227
|
+
(`package.json` `"asrun"`, default `[asrun]`) and the run's UTC stamp: `testDataName()` builds
|
|
228
|
+
`[autotest] Draft CMA (2026-11-09 12-34-56)`. Two runs never share a name, the stamp matches `reports/<run-id>`, and
|
|
229
|
+
a person can search the product for one run's data. The case's `## Values` holds the base without the stamp.
|
|
224
230
|
- **Targets.** A check on a logo, image, icon, file, PDF, screenshot or repeated text points to a `[T1]` card under
|
|
225
231
|
`## Targets`: what it is (and what it is not), where it is, how it is identified (never `.first()` or a bare `img`),
|
|
226
232
|
when it is ready, and a highlighted reference screenshot under `.explore/targets/`. When in doubt the agent does not
|
|
@@ -240,9 +246,10 @@ Checks you can run yourself:
|
|
|
240
246
|
|
|
241
247
|
| Command | What it checks |
|
|
242
248
|
|---|---|
|
|
243
|
-
| `pnpm spec:lint [case-id]` | fails on screenshots without a target, `.first()` on images, sleeps, `networkidle`, raw download events, a `%PDF` check on its own |
|
|
249
|
+
| `pnpm spec:lint [case-id]` | fails on screenshots without a target, `.first()` on images, sleeps, `networkidle`, raw download events, a `%PDF` check on its own; warns when a tracked test-data name is not built with `testDataName()` |
|
|
244
250
|
| `pnpm baselines:review [case-id] [--new]` | lists every baseline with the card that says what it must show, so you can look at each new image |
|
|
245
251
|
| `pnpm case:check [case-id]` | fails on an incomplete card, bad values, a marker inside a question or a question under a marked line; warns on an image check with no card and on a marker that does not say what to decide |
|
|
252
|
+
| `pnpm asrun rules [--json] [--case <id>]` | prints the effective rules (baseline, §2, project settings) the AI steps read; fails on an invalid §2 row or `testDataPrefix` |
|
|
246
253
|
| `pnpm markers:confirm --case <id> --env <env> [--report <run-id>]` | after a passing run, removes the `🔍 to be confirmed` markers whose line the spec checks with `// unconfirmed:`; `--dry-run` lists them |
|
|
247
254
|
| `pnpm pdf:render <file.pdf> --out <dir> [--pages all] [--corner top-left]` | renders the pages to images and prints the text, to look at a PDF by hand |
|
|
248
255
|
|
|
@@ -281,10 +288,12 @@ An override is plain Markdown; write only what differs:
|
|
|
281
288
|
- The staging account has no published documents: assert the empty state there instead.
|
|
282
289
|
```
|
|
283
290
|
|
|
284
|
-
The project's `AGENTS.md` §2 is the other place: a table of rule ids (`F7`–`
|
|
285
|
-
`R2` are fixed) with `narrows` (stricter) or `replaces` (a different default).
|
|
286
|
-
|
|
287
|
-
|
|
291
|
+
The project's `AGENTS.md` §2 is the other place: a table of rule ids (`F7`–`F11`, `R1`, `R3`, `R4`; `F1`–`F6` and
|
|
292
|
+
`R2` are fixed) with `narrows` (stricter) or `replaces` (a different default). A rule the baseline does not have gets
|
|
293
|
+
a project id (`P1`, `P2`, ...) and `adds`. For example, a project that writes cases in Simplified Chinese `replaces`
|
|
294
|
+
`F10`. `pnpm asrun doctor` fails on an unknown id, a missing `Effect`, a duplicate id, a non-negotiable rule or a
|
|
295
|
+
project id without `adds`, and `pnpm asrun rules` prints the effective set (the AI steps read it before they apply
|
|
296
|
+
anything). A project rule is applied as written: a difference from the baseline is never a question.
|
|
288
297
|
|
|
289
298
|
An update never overwrites `AGENTS.md`, `CLAUDE.md` or `.claude/skills/**`: `pnpm asrun init --force` refuses them
|
|
290
299
|
without `--reset-rules`. Never edit the framework package itself.
|
package/docs/cases.md
CHANGED
|
@@ -110,13 +110,14 @@ report and the trace read like the case.
|
|
|
110
110
|
choice (an existing document, items), `[email]` for a step that sends email. They are written at the end of the
|
|
111
111
|
step line.
|
|
112
112
|
- **`- Recipe (phase):`** under a tagged step records what a person did in the browser during an attended
|
|
113
|
-
exploration: positions instead of names of changeable data (`select items #1 to #3`),
|
|
114
|
-
|
|
115
|
-
generated: `/gen-spec` stops with `needs-input`.
|
|
113
|
+
exploration: positions instead of names of changeable data (`select items #1 to #3`), created names (project prefix
|
|
114
|
+
plus run stamp), control labels but no selectors. Several lines per step are normal. A tagged step with no recipe is
|
|
115
|
+
not generated: `/gen-spec` stops with `needs-input`.
|
|
116
116
|
- **Open questions** go in a `🚫 TODO [blocking]` or `❓ TODO [non-blocking]` block under the sentence they are about,
|
|
117
117
|
with the agent's suggestion written above and a last line `- ✍️ Answer:`. Accepting a question means deleting the
|
|
118
|
-
whole block; answering means writing after `Answer:`.
|
|
119
|
-
under
|
|
118
|
+
whole block; answering means writing after `Answer:`. In the web panel the two actions are **Keep what the case
|
|
119
|
+
says** and **Write your answer**. Hang the block under the sentence that makes the claim, not under a broader one
|
|
120
|
+
above it, and one claim gets one block.
|
|
120
121
|
- **What the AI saw** is marked by what happens if it is wrong: `(seen on <profile>)` is a plain note the run checks by
|
|
121
122
|
itself, and `(observed on <profile>, 🔍 to be confirmed: <what a person must decide>)` is a line that would pass even
|
|
122
123
|
if it were wrong, so a person settles it. A marker may name a screenshot (`evidence: .explore/.../x.png`) so nobody
|
|
@@ -173,7 +174,10 @@ anything.
|
|
|
173
174
|
|
|
174
175
|
A case may write only in the environments listed under `writes`, and only data of the test account:
|
|
175
176
|
|
|
176
|
-
- everything it creates is named
|
|
177
|
+
- everything it creates is named with the project's prefix and the run stamp, `<prefix> <base> (<run stamp>)`
|
|
178
|
+
(prefix: `asrun.testDataPrefix` in `package.json`, default `[asrun]`; stamp: the run's UTC time). The spec builds
|
|
179
|
+
the name with `testDataName()` (`records` refuses a name without the prefix), so two runs never collide and a
|
|
180
|
+
person can search the product for one run's data. `## Values` holds the base without the stamp;
|
|
177
181
|
- created data is registered with `records.track` and removed after the test, also after a failure;
|
|
178
182
|
- the case's own last step is normally the deletion; `track` is the safety net;
|
|
179
183
|
- staging needs no confirmation. In any other environment a person confirms every run (typing the environment name,
|
package/docs/commands.md
CHANGED
|
@@ -10,6 +10,7 @@ thin wrappers, so `pnpm asrun`, `pnpm signin` and the rest work as below. The sa
|
|
|
10
10
|
| Install the framework in a project | `pnpm dlx @asrunjs/asrun init --install` |
|
|
11
11
|
| Upgrade the framework and migrate the workspace | `pnpm asrun upgrade [--version <v>] [--dry-run]` |
|
|
12
12
|
| Prepare or check the workspace | `pnpm asrun init [--update]`, `pnpm asrun doctor` |
|
|
13
|
+
| See the effective rules and project settings | `pnpm asrun rules [--json] [--case <case-id>]` |
|
|
13
14
|
| See or apply the workspace migrations after a manual install | `pnpm asrun migrate [--list] [--dry-run]` |
|
|
14
15
|
| Start over with fresh dependencies | `pnpm asrun reset [--yes] [--state] [--lockfile]` |
|
|
15
16
|
| Start the web UI | `pnpm go`, `pnpm ui`, `pnpm ui:dev` |
|
|
@@ -160,6 +161,21 @@ covers) and `--json` prints the parsed notes for a run that reads them. A note i
|
|
|
160
161
|
case when it is missing or stale. `--check` fails on a malformed note and warns about a stale one (it prints the current
|
|
161
162
|
`caseSha256`); `--hash` prints that hash. Deleting `.explore/notes/` is always safe.
|
|
162
163
|
|
|
164
|
+
## Rules
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
pnpm asrun rules
|
|
168
|
+
pnpm asrun rules --json
|
|
169
|
+
pnpm asrun rules --case conduit/publish-article
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Prints the effective rules of the workspace: the framework baseline, the project's `AGENTS.md` §2 table (id, effect
|
|
173
|
+
and text), and the project settings the framework needs at run time (`testDataPrefix` and the resulting data-name
|
|
174
|
+
format). `--case <id>` adds that case's `## Rules`. `--json` is the machine-readable form the AI steps read before
|
|
175
|
+
they apply anything. A §2 row that is invalid — an unknown baseline id, a `[non-negotiable]` rule, a duplicate id, a
|
|
176
|
+
project id (`P1`, ...) without `adds` — is printed as an error and the command exits 1; `doctor` shows the same errors.
|
|
177
|
+
The point is that "§2 wins over the baseline" is a fact the tool states, not a judgment an agent has to make.
|
|
178
|
+
|
|
163
179
|
## Cleanup
|
|
164
180
|
|
|
165
181
|
```bash
|
|
@@ -167,10 +183,12 @@ pnpm cleanup --env staging
|
|
|
167
183
|
pnpm cleanup --env staging --forget
|
|
168
184
|
```
|
|
169
185
|
|
|
170
|
-
A write case registers everything it creates (named
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
186
|
+
A write case registers everything it creates (named with the project's prefix and a run stamp, for example
|
|
187
|
+
`[asrun] Draft CMA (2026-11-09 12-34-56)`) and removes it after the test, also after a failure. Whatever could not be
|
|
188
|
+
removed is listed in the report and by `cleanup`. Delete it by hand in the product, then run `--forget` so it is not
|
|
189
|
+
listed again. The run stamp matches the report folder and the ledger entry, so one run's leftovers are easy to find.
|
|
190
|
+
`--env` is required. `cleanup` is about test data in the product; `pnpm asrun reset` (below) is the command that
|
|
191
|
+
removes workspace files and dependencies.
|
|
174
192
|
|
|
175
193
|
## Web UI
|
|
176
194
|
|
package/docs/install.md
CHANGED
|
@@ -163,7 +163,8 @@ Run it after changing profiles, rules or overrides, and after an update. It chec
|
|
|
163
163
|
- that `.gitignore` ignores `.auth/` and `reports/`;
|
|
164
164
|
- whether the installed version is behind the package's templates, and whether `dist/` is out of date;
|
|
165
165
|
- the workspace migrations: the baseline in `.asrun.json`, what still applies, and the manual steps left open;
|
|
166
|
-
- the project rule table in `AGENTS.md` §2 (unknown ids, a missing `Effect`, a non-negotiable rule
|
|
166
|
+
- the project rule table in `AGENTS.md` §2 (unknown ids, a missing `Effect`, a duplicate id, a non-negotiable rule, a
|
|
167
|
+
project id without `adds`) and the project settings in `package.json` `"asrun"` (for example `testDataPrefix`);
|
|
167
168
|
- the framework skills and the project overrides under `.claude/skills/<skill>.md`, and lists the ones it sees.
|
|
168
169
|
|
|
169
170
|
## Which workspace a command runs against
|