@pavel.sommer/agiter 0.14.1-alpha.0 → 0.27.0-alpha.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/README.md +51 -5
- package/bin/agiter.mjs +12 -2
- package/defs/.agents.json +12 -2
- package/defs/artifacts/checks.json +6 -0
- package/defs/artifacts/create-epics.json +7 -0
- package/defs/artifacts/create-tests.json +22 -0
- package/defs/artifacts/{work.json → create-work.json} +9 -2
- package/defs/artifacts/decisions.json +12 -0
- package/defs/artifacts/report.json +1 -0
- package/defs/tasks/create-tests.json +1 -2
- package/defs/tasks/create-work.json +1 -1
- package/index.mjs +2 -2
- package/package.json +2 -2
- package/src/agents.mjs +34 -16
- package/src/artifacts.mjs +44 -10
- package/src/defs.mjs +12 -0
- package/src/engine.mjs +544 -39
- package/src/fake.mjs +1 -1
- package/src/frontmatter.mjs +113 -62
- package/src/git.mjs +2 -2
- package/src/links.mjs +55 -0
- package/src/prompt.mjs +37 -8
- package/src/usage.mjs +102 -0
package/README.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
3
3
|
A client-agnostic harness for spec-driven agentic development. The design is in
|
|
4
4
|
[docs/design-recap.md](docs/design-recap.md).
|
|
5
5
|
|
|
6
|
+
## The key idea: diverse points of view
|
|
7
|
+
|
|
8
|
+
Every step of a run is done by separate agents, each starting with a fresh context: the author
|
|
9
|
+
writes, the reviewer judges what was written against the input and the criteria, without the
|
|
10
|
+
author's reasoning in view, so it has nothing to anchor on and no decision of its own to
|
|
11
|
+
defend. A next round's author gets its version and the review, not its earlier train of
|
|
12
|
+
thought. One long prompting session cannot give this: there a model judges its own work with
|
|
13
|
+
the whole path to it still in context, like a person proofreading what he wrote a minute ago.
|
|
14
|
+
|
|
15
|
+
Context diversity works with the same model too. Model diversity adds a second layer: a
|
|
16
|
+
model's blind spots come from its training, and a fresh context does not remove them, a
|
|
17
|
+
different model does. That is why each role has its own model profile
|
|
18
|
+
(`agiter/config/.models.<role>.json`): a cheap author and a stronger reviewer of another family
|
|
19
|
+
make a good pair.
|
|
20
|
+
|
|
21
|
+
## How it works
|
|
22
|
+
|
|
6
23
|
A run's pipeline is always given explicitly: a comma list of tokens, or a pipeline file
|
|
7
24
|
(`*.json`, `{ "steps": [...] }`). A token is `task` (one round, no review) or `task:N` (at most
|
|
8
25
|
N rounds; each round is a version and its review report).
|
|
@@ -36,7 +53,8 @@ project's `node_modules` linked in. The scope of a step is checked against what
|
|
|
36
53
|
| `defs/` | the package's artifact types, tasks, stack blocks and presets, default config |
|
|
37
54
|
| `test/` | `npm test` (node:test, no dependencies) |
|
|
38
55
|
|
|
39
|
-
The owner's documents stay his: any file a run did not write is an input in the owner's own form
|
|
56
|
+
The owner's documents stay his: any file a run did not write is an input in the owner's own form
|
|
57
|
+
(with a frontmatter `type` it runs only with status `Active`; without one it runs as it is);
|
|
40
58
|
the pipeline decides what it is (`create-epics`: an initiative), and its frontmatter, status and
|
|
41
59
|
structure are not checked. A run is named after the input file without its leading timestamp and
|
|
42
60
|
stamped in local time (`yyMMddHHmmss`); the titles of the outputs are the models' own.
|
|
@@ -48,13 +66,41 @@ A project keeps everything in `agiter/`: `docs/<run>/` (the chain of each run),
|
|
|
48
66
|
## A run's files
|
|
49
67
|
|
|
50
68
|
```text
|
|
51
|
-
agiter/docs/<run
|
|
52
|
-
agiter/docs/<run
|
|
53
|
-
agiter/docs/<run
|
|
54
|
-
agiter/docs/<run
|
|
69
|
+
agiter/docs/<run>/.<run>.md the input, copied as it is (never changed; a dot file)
|
|
70
|
+
agiter/docs/<run>/.<run>.draft.1.md round 1 of create-draft:3 (a dot file)
|
|
71
|
+
agiter/docs/<run>/.<run>.draft.1.report.md its review: Block or Done, cumulative (a dot file)
|
|
72
|
+
agiter/docs/<run>/.<run>.draft.2.md round 2: input + draft.1 + its report
|
|
73
|
+
agiter/docs/<run>/<run>.create-work.1.md the record of create-work
|
|
74
|
+
docs/<run>.draft.md the result of a step, published
|
|
75
|
+
docs/<run>.draft.report.md the step's last report, published
|
|
76
|
+
docs/<run>.decisions.md the decisions report, for the owner to answer
|
|
77
|
+
docs/<run>.epic.E1.md an epic of create-epics, published
|
|
55
78
|
...
|
|
56
79
|
```
|
|
57
80
|
|
|
81
|
+
Two kinds of output. Methodical artifacts (initiative, epic, draft, spec) are the documents a
|
|
82
|
+
next run builds on: the result of each step (no review, or its report Done) is published to
|
|
83
|
+
`docs/` (`agiter/config/.settings.json` `{ "docs": "<folder>" }` changes it), never over a
|
|
84
|
+
file there, and agents never change them. Task records (`create-epics`, `create-work`,
|
|
85
|
+
`create-tests`, `checks`) and the reports stay in `agiter/docs/<run>/` with every round; the versions of methodical
|
|
86
|
+
artifacts there are dot files, so a client like Tolaria shows each document once (in `docs/`).
|
|
87
|
+
Where the input does not say something, the models decide on their own and record it under
|
|
88
|
+
"Decisions" (`[owner]` or `[author]`); at the end of a run the harness collects them into
|
|
89
|
+
`docs/<run>.decisions.md` (beside the owner's documents), the owner's first, each with an `Answer:` line, so the
|
|
90
|
+
answered file can be the input of a next run.
|
|
91
|
+
A run on a document an earlier run wrote (an epic, a spec) gets that run's answered decisions in
|
|
92
|
+
its prompt by itself: they override the document where they differ.
|
|
93
|
+
Each document's frontmatter has the `model` that wrote it and the `cost` of that call.
|
|
94
|
+
Every model call's cost and tokens come from the agent's JSON output (opencode `--format json`,
|
|
95
|
+
claude `--output-format json`): `status` shows them per call and for the run, `run --list` per run.
|
|
96
|
+
At the end of a run the file names in its documents become wikilinks (`[[name]]`, as Tolaria
|
|
97
|
+
resolves them), to the notes in view: a version of the chain links to its published document.
|
|
98
|
+
Their frontmatter gets Tolaria's relationships too: `belongs_to` and `related_to` as wikilinks.
|
|
99
|
+
The owner's input file gets `has` (wikilinks to what runs made from it and keep), `model` (the
|
|
100
|
+
models they used) and `cost` (their total).
|
|
101
|
+
Frontmatter types are TitleCase and singular (`Spec`, `Epic`, `Report`, `Decision`); every task's
|
|
102
|
+
record is `Pipeline`, its `task` (`create-epics`, `create-work`, …) tells which.
|
|
103
|
+
|
|
58
104
|
A version is Active when another step follows and it has no review or its report is Done;
|
|
59
105
|
otherwise Draft. A step takes only prose or an Active artifact whose latest report is not
|
|
60
106
|
Block. The state of a run is its files: `agiter continue` walks the steps again and repeats
|
package/bin/agiter.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
|
+
import { money, tokens } from "../src/usage.mjs";
|
|
9
10
|
import os from "node:os";
|
|
10
11
|
import path from "node:path";
|
|
11
12
|
import { spawnSync } from "node:child_process";
|
|
@@ -23,7 +24,16 @@ function printRun(run) {
|
|
|
23
24
|
console.log(` pipeline ${run.pipeline.steps.map(tokenOf).join(",")}, input ${run.input.type}${run.lang ? `, output in ${run.lang}` : ""}`);
|
|
24
25
|
for (const s of run.steps) {
|
|
25
26
|
const what = s.outcome === "failed" ? `failed [${s.guard}] ${s.error}` : `${s.file ? path.posix.basename(s.file) : ""}${s.verdict ? ` ${s.verdict}` : ""}`;
|
|
26
|
-
console.log(` ${String(s.step).padEnd(3)} ${s.task.padEnd(13)} ${(s.round ? `r${s.round}` : "").padEnd(4)}${s.role.padEnd(9)} ${what}`);
|
|
27
|
+
console.log(` ${String(s.step).padEnd(3)} ${s.task.padEnd(13)} ${(s.round ? `r${s.round}` : "").padEnd(4)}${s.role.padEnd(9)} ${what}${s.usage ? ` ${money(s.usage)}` : ""}`);
|
|
28
|
+
}
|
|
29
|
+
if (run.steps.some((s) => s.executor === "model" || s.executor === "fake")) {
|
|
30
|
+
const calls = run.steps.filter((s) => s.executor === "model");
|
|
31
|
+
const missing = calls.filter((s) => !s.usage).length;
|
|
32
|
+
console.log(` cost: ${run.usage ? `${money(run.usage)} (${tokens(run.usage)})` : "no usage data"}${missing && run.usage ? `; ${missing} call(s) without usage data` : ""}`);
|
|
33
|
+
}
|
|
34
|
+
if (run.decisions) {
|
|
35
|
+
const d = run.decisions;
|
|
36
|
+
console.log(` decisions: ${d.owner ?? 0} for you${d.review ? `, ${d.review} open from the review` : ""}, ${(d.owner ?? 0) + (d.review ?? 0) + (d.unmarked ?? 0) + (d.author ?? 0)} in all -> ${d.file}`);
|
|
27
37
|
}
|
|
28
38
|
if (run.error) console.log(` ${run.status} at step ${run.error.step} ${run.error.task}: ${run.error.message}\n next: ${run.status === "blocked" ? "a new input with the answers, or " : ""}agiter continue ${run.id}`);
|
|
29
39
|
else if (run.status === "done" && run.git && (run.git.mode ?? "branch") !== "none" && !run.finished) console.log(` next: agiter finish ${run.id} --merge (or without --merge to keep the branch)`);
|
|
@@ -41,7 +51,7 @@ function patternOf(value, option, cmd) {
|
|
|
41
51
|
try { return new RegExp(value, "i"); } catch (e) { cmd.error(`error: --${option}: ${e.message}`, { exitCode: USAGE, code: "agiter.pattern" }); }
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
const runLine = (r) => `${local(r.created)} ${r.status.padEnd(8)} ${duration(r.seconds).padStart(12)} ${String(r.calls).padStart(3)} calls ${r.id} ${r.pipeline}${r.name ? ` "${r.name}"` : ""}${r.finished ? (r.finished.merged ? " merged" : " finished") : ""}${r.error ? ` [${r.error}]` : ""}`;
|
|
54
|
+
const runLine = (r) => `${local(r.created)} ${r.status.padEnd(8)} ${duration(r.seconds).padStart(12)} ${String(r.calls).padStart(3)} calls ${money(r.usage).padStart(9)} ${r.id} ${r.pipeline}${r.name ? ` "${r.name}"` : ""}${r.finished ? (r.finished.merged ? " merged" : " finished") : ""}${r.error ? ` [${r.error}]` : ""}`;
|
|
45
55
|
|
|
46
56
|
/** run --list [pattern]: the runs, one per line. */
|
|
47
57
|
function listRunsOf(opts, cmd) {
|
package/defs/.agents.json
CHANGED
|
@@ -13,7 +13,12 @@
|
|
|
13
13
|
],
|
|
14
14
|
"message": [
|
|
15
15
|
"{message}"
|
|
16
|
-
]
|
|
16
|
+
],
|
|
17
|
+
"format": [
|
|
18
|
+
"--format",
|
|
19
|
+
"json"
|
|
20
|
+
],
|
|
21
|
+
"output": "opencode-json"
|
|
17
22
|
},
|
|
18
23
|
"claude": {
|
|
19
24
|
"title": "Claude Code, print mode; the prompt comes right after -p (the tool options take several values); the reviewer reads only",
|
|
@@ -43,6 +48,11 @@
|
|
|
43
48
|
"ANTHROPIC_AUTH_TOKEN",
|
|
44
49
|
"ANTHROPIC_API_KEY",
|
|
45
50
|
"ANTHROPIC_MODEL"
|
|
46
|
-
]
|
|
51
|
+
],
|
|
52
|
+
"format": [
|
|
53
|
+
"--output-format",
|
|
54
|
+
"json"
|
|
55
|
+
],
|
|
56
|
+
"output": "claude-json"
|
|
47
57
|
}
|
|
48
58
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
+
"name": "Pipeline",
|
|
3
|
+
"shared": true,
|
|
4
|
+
"record": true,
|
|
5
|
+
"aliases": [
|
|
6
|
+
"epics",
|
|
7
|
+
"CreateEpic"
|
|
8
|
+
],
|
|
2
9
|
"title": "Breakdown into epics",
|
|
3
10
|
"summary": "The result of create-epics, not a methodical artifact: the input's functional areas and the epics that deliver them; each epic is in its own file (type epic).",
|
|
4
11
|
"sections": [
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Pipeline",
|
|
3
|
+
"shared": true,
|
|
4
|
+
"record": true,
|
|
5
|
+
"aliases": [
|
|
6
|
+
"CreateTest"
|
|
7
|
+
],
|
|
8
|
+
"title": "Tests record",
|
|
9
|
+
"summary": "The record of create-tests, not a methodical artifact: the tests written for a spec.",
|
|
10
|
+
"sections": [
|
|
11
|
+
"Changes"
|
|
12
|
+
],
|
|
13
|
+
"criteria": [
|
|
14
|
+
"The tests cover every acceptance criterion of the spec.",
|
|
15
|
+
"Nothing outside the spec's scope changed."
|
|
16
|
+
],
|
|
17
|
+
"statuses": [
|
|
18
|
+
"Draft",
|
|
19
|
+
"Active",
|
|
20
|
+
"Done"
|
|
21
|
+
]
|
|
22
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
2
|
+
"name": "Pipeline",
|
|
3
|
+
"shared": true,
|
|
4
|
+
"record": true,
|
|
5
|
+
"aliases": [
|
|
6
|
+
"work",
|
|
7
|
+
"CreateWork"
|
|
8
|
+
],
|
|
9
|
+
"title": "Work record",
|
|
10
|
+
"summary": "The record of create-work, not a methodical artifact: the change made to the code, tests included.",
|
|
4
11
|
"sections": [
|
|
5
12
|
"Changes"
|
|
6
13
|
],
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Decision",
|
|
3
|
+
"record": true,
|
|
4
|
+
"title": "Decisions",
|
|
5
|
+
"summary": "The record of a run's autonomous decisions: what the models decided that the input did not say, those that belong to the owner first, for him to answer.",
|
|
6
|
+
"sections": [],
|
|
7
|
+
"statuses": [
|
|
8
|
+
"Draft",
|
|
9
|
+
"Active",
|
|
10
|
+
"Done"
|
|
11
|
+
]
|
|
12
|
+
}
|
package/index.mjs
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
export { loadDefs, lintDefs, PACKAGE_DEFS } from "./src/defs.mjs";
|
|
7
7
|
export { resolvePipeline, planRun, parseToken } from "./src/pipeline.mjs";
|
|
8
|
-
export { readDoc, readInput, validateDoc, parseReport } from "./src/artifacts.mjs";
|
|
8
|
+
export { readDoc, readInput, validateDoc, parseReport, typeId, typeName } from "./src/artifacts.mjs";
|
|
9
9
|
export { parseFrontmatter, formatFrontmatter } from "./src/frontmatter.mjs";
|
|
10
|
-
export { planScenario, startRun, continueRun, finishRun, loadRun, listRuns, deleteRuns, readAudit, ISOLATION, modeOf } from "./src/engine.mjs";
|
|
10
|
+
export { planScenario, startRun, continueRun, finishRun, loadRun, listRuns, deleteRuns, readAudit, ISOLATION, modeOf, docsDir } from "./src/engine.mjs";
|
|
11
11
|
export { fakeExecutor } from "./src/fake.mjs";
|
|
12
12
|
export { listRoles } from "./src/roles.mjs";
|
|
13
13
|
export { initProject, findProjectRoot, isProject } from "./src/init.mjs";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pavel.sommer/agiter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0-alpha.0",
|
|
4
4
|
"description": "Client-agnostic harness for spec-driven agentic development.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agentic",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"license": "UNLICENSED",
|
|
38
38
|
"scripts": {
|
|
39
|
-
"test": "node --test test/*.test.mjs"
|
|
39
|
+
"test": "node --test --import ./test/setup.mjs test/*.test.mjs"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"commander": "^15.1.0"
|
package/src/agents.mjs
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
* The real model executor: runs an agent CLI for a step's author or reviewer.
|
|
3
3
|
*
|
|
4
4
|
* Agents are defined in the project's agiter/config/.agents.json (from the package's
|
|
5
|
-
* defs/.agents.json): { id: { command,
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* defs/.agents.json): { id: { command, format (args for a structured output, after the
|
|
6
|
+
* command), output (its parser: opencode-json, claude-json; src/usage.mjs), model (args added
|
|
7
|
+
* when a model is set; {model}), message (args with {message}, added last; or {message} inside
|
|
8
|
+
* command, where the CLI needs it earlier), roles (extra args per role, "*" for the others),
|
|
9
|
+
* dropEnv } }. The project's definition of an agent is merged over the package's, so a key the
|
|
10
|
+
* package adds later (format, output) reaches a project whose file is older.
|
|
8
11
|
*
|
|
9
12
|
* The step's instructions are written to a prompt file in agiter/runs/<run>/steps/; the agent
|
|
10
13
|
* gets one short message pointing to it. The author writes its result to the file the harness
|
|
@@ -21,6 +24,7 @@ import { AGENTS_FILE, CONFIG, resolveModels } from "./models.mjs";
|
|
|
21
24
|
import { parseFrontmatter } from "./frontmatter.mjs";
|
|
22
25
|
import { buildPrompt } from "./prompt.mjs";
|
|
23
26
|
import { composeProject } from "./stack.mjs";
|
|
27
|
+
import { addUsage, parserOf } from "./usage.mjs";
|
|
24
28
|
|
|
25
29
|
const readJson = (file) => (fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, "utf8").replace(/^/, "")) : {});
|
|
26
30
|
|
|
@@ -30,8 +34,12 @@ const readJson = (file) => (fs.existsSync(file) ? JSON.parse(fs.readFileSync(fil
|
|
|
30
34
|
* package's defs/.agents.json without a project or before the project has its file.
|
|
31
35
|
*/
|
|
32
36
|
export function loadAgents(root) {
|
|
37
|
+
const base = readJson(path.join(PACKAGE_DEFS, AGENTS_FILE));
|
|
33
38
|
const own = root && path.join(root, CONFIG, AGENTS_FILE);
|
|
34
|
-
|
|
39
|
+
if (!own || !fs.existsSync(own)) return base;
|
|
40
|
+
const project = readJson(own);
|
|
41
|
+
// The project's keys win; a key only the package has (a newer one) is added.
|
|
42
|
+
return Object.fromEntries(Object.entries(project).map(([id, def]) => [id, base[id] && def && typeof def === "object" ? { ...base[id], ...def } : def]));
|
|
35
43
|
}
|
|
36
44
|
|
|
37
45
|
const fill = (args, values) => args.map((a) => a.replace(/\{(\w+)\}/g, (all, k) => values[k] ?? all));
|
|
@@ -40,27 +48,33 @@ const fill = (args, values) => args.map((a) => a.replace(/\{(\w+)\}/g, (all, k)
|
|
|
40
48
|
export function argvOf(agent, role, model, message) {
|
|
41
49
|
const extra = agent.roles?.[role] ?? agent.roles?.["*"] ?? [];
|
|
42
50
|
const inCommand = agent.command.some((a) => a.includes("{message}"));
|
|
43
|
-
return [...fill(agent.command, { message }), ...(model ? fill(agent.model ?? [], { model }) : []), ...extra, ...(inCommand ? [] : fill(agent.message ?? ["{message}"], { message }))];
|
|
51
|
+
return [...fill(agent.command, { message }), ...(agent.format ?? []), ...(model ? fill(agent.model ?? [], { model }) : []), ...extra, ...(inCommand ? [] : fill(agent.message ?? ["{message}"], { message }))];
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
/** One argument for cmd.exe (spawn with shell on Windows, so .cmd shims run). */
|
|
47
55
|
const quoteWin = (a) => (/^[\w@%+=:,./\\-]+$/.test(a) ? a : `"${a.replace(/(\\*)"/g, "$1$1\\\"").replace(/(\\+)$/, "$1$1")}"`);
|
|
48
56
|
|
|
49
57
|
/**
|
|
50
|
-
* Run argv in `cwd`: { exit, stdout, stderr, error, seconds } (killed after
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
58
|
+
* Run argv in `cwd`: { exit, stdout, stderr, error, seconds, text, usage } (killed after
|
|
59
|
+
* `timeoutMs`). `parser` (src/usage.mjs) turns a structured stdout into the text shown and the
|
|
60
|
+
* call's usage; stdout stays raw (and goes to `raw`, a file, when given). `onData(text,
|
|
61
|
+
* stream)` gets the shown output as it comes; `log` (a file) gets the command line at once,
|
|
62
|
+
* then the shown output as it comes (stderr lines marked "! "), then the exit.
|
|
53
63
|
*/
|
|
54
|
-
export function spawnAgent(argv, { cwd, timeoutMs, env, onData, log }) {
|
|
64
|
+
export function spawnAgent(argv, { cwd, timeoutMs, env, onData, log, raw, parser = parserOf(null) }) {
|
|
55
65
|
return new Promise((resolve) => {
|
|
56
66
|
const started = Date.now();
|
|
57
67
|
const win = process.platform === "win32";
|
|
58
68
|
const write = (text) => { if (log) try { fs.appendFileSync(log, text); } catch {} };
|
|
59
69
|
if (log) fs.writeFileSync(log, `$ ${argv.join(" ")}\n\n`);
|
|
70
|
+
if (raw) try { fs.writeFileSync(raw, ""); } catch {}
|
|
71
|
+
const show = (text) => { if (!text) return; write(text); try { onData?.(text, "stdout"); } catch {} };
|
|
60
72
|
const finish = (exit, error) => {
|
|
61
73
|
const seconds = (Date.now() - started) / 1000;
|
|
74
|
+
const parsed = parser.end();
|
|
75
|
+
show(parsed.shown);
|
|
62
76
|
write(`\n--- exit ${exit} in ${seconds.toFixed(1)} s${error ? `, ${error}` : ""}\n`);
|
|
63
|
-
resolve({ exit, stdout, stderr, error, seconds });
|
|
77
|
+
resolve({ exit, stdout, stderr, error, seconds, text: parsed.text, usage: parsed.usage, model: parsed.model ?? null });
|
|
64
78
|
};
|
|
65
79
|
let stdout = "";
|
|
66
80
|
let stderr = "";
|
|
@@ -75,7 +89,7 @@ export function spawnAgent(argv, { cwd, timeoutMs, env, onData, log }) {
|
|
|
75
89
|
}
|
|
76
90
|
child.stdout.setEncoding("utf8");
|
|
77
91
|
child.stderr.setEncoding("utf8");
|
|
78
|
-
child.stdout.on("data", (d) => { stdout += d;
|
|
92
|
+
child.stdout.on("data", (d) => { stdout += d; if (raw) try { fs.appendFileSync(raw, d); } catch {} show(parser.feed(d)); });
|
|
79
93
|
child.stderr.on("data", (d) => { stderr += d; write(d.replace(/^(?=.)/gm, "! ")); try { onData?.(d, "stderr"); } catch {} });
|
|
80
94
|
const timer = setTimeout(() => { child.kill(); stderr += `\n[agiter: killed after ${timeoutMs / 60000} min]`; }, timeoutMs);
|
|
81
95
|
child.on("error", (e) => { clearTimeout(timer); finish(-1, e.message); });
|
|
@@ -119,7 +133,7 @@ export function agentExecutor(root) {
|
|
|
119
133
|
fs.mkdirSync(dir, { recursive: true });
|
|
120
134
|
const versionPath = ctx.role === "reviewer" ? ctx.version.file : ctx.output.file;
|
|
121
135
|
// Every call keeps its own files: the attempt is part of the name, nothing is overwritten.
|
|
122
|
-
const base = `${path.posix.basename(versionPath, ".md")}.${ctx.role}.${ctx.attempt}`;
|
|
136
|
+
const base = `${path.posix.basename(versionPath, ".md").replace(/^\./, "")}.${ctx.role}.${ctx.attempt}`;
|
|
123
137
|
const outFile = path.join(dir, `${base}.out.md`);
|
|
124
138
|
// An output with items (epics): the overview in outFile, each item in its own file in this folder.
|
|
125
139
|
const itemsDir = ctx.role !== "reviewer" && ctx.output?.items ? path.join(dir, `${base}.out`) : null;
|
|
@@ -137,14 +151,18 @@ export function agentExecutor(root) {
|
|
|
137
151
|
for (const model of models) {
|
|
138
152
|
const argv = argvOf(agent, ctx.role, model, message);
|
|
139
153
|
const log = path.join(dir, `${base}.${model ? model.replace(/[^\w.-]+/g, "_") : "default"}.log`);
|
|
140
|
-
|
|
154
|
+
// A structured output is kept raw next to the log (the log has the text shown).
|
|
155
|
+
const raw = agent.output ? log.replace(/\.log$/, ".jsonl") : null;
|
|
156
|
+
const r = await spawnAgent(argv, { cwd: root, env, timeoutMs: (res.profile.timeoutMinutes ?? 30) * 60000, log, raw, parser: parserOf(agent), onData: ctx.onOutput });
|
|
141
157
|
const label = `${res.profile.agent}${model ? ` ${model}` : ""}`;
|
|
142
|
-
const tried = { model, exit: r.exit, seconds: r.seconds, stdoutBytes: Buffer.byteLength(r.stdout), stderrBytes: Buffer.byteLength(r.stderr), log: rel(log) };
|
|
158
|
+
const tried = { model, exit: r.exit, seconds: r.seconds, stdoutBytes: Buffer.byteLength(r.stdout), stderrBytes: Buffer.byteLength(r.stderr), log: rel(log), usage: r.usage };
|
|
143
159
|
meta.tries.push(tried);
|
|
144
|
-
|
|
160
|
+
// What a call used counts also for the tries that failed: they were paid for.
|
|
161
|
+
// The profile's model, else the one the agent says answered (its default).
|
|
162
|
+
Object.assign(meta, { model: model ?? r.model, log: tried.log, exit: r.exit, usage: addUsage(meta.tries.map((t) => t.usage)) });
|
|
145
163
|
if (r.exit !== 0) { errors.push(`${label}: exit ${r.exit}${r.error ? ` (${r.error})` : ""}`); continue; }
|
|
146
164
|
if (ctx.role === "reviewer") {
|
|
147
|
-
const findings = findingsOf(r.stdout);
|
|
165
|
+
const findings = findingsOf(r.text ?? r.stdout);
|
|
148
166
|
if (findings) return { outcome: "ok", findings, meta: { ...meta, outputBytes: tried.stdoutBytes } };
|
|
149
167
|
errors.push(`${label}: no JSON findings in the answer`);
|
|
150
168
|
continue;
|
package/src/artifacts.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Process artifacts: Markdown files, with frontmatter (type, title, status, …) or without it
|
|
3
3
|
* (prose: type idea). A run keeps its whole chain in agiter/docs/<run>/:
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* .<run>.md the run's input, copied as it is (never changed; a dot file)
|
|
6
6
|
* <run>.<name>.<round>.md the version a step wrote in a round
|
|
7
7
|
* <run>.<name>.<round>.report.md its review report (steps with :N)
|
|
8
8
|
* <run>.<name>.<round>.<id>.md the items of a version whose type has items (the epics of
|
|
@@ -57,12 +57,19 @@ export function h1Of(body) {
|
|
|
57
57
|
// ── files of a run ──────────────────────────────────────────────────────────
|
|
58
58
|
|
|
59
59
|
export const runFolder = (runId) => `${DOCS}/${runId}`;
|
|
60
|
-
|
|
61
|
-
export const
|
|
62
|
-
/**
|
|
63
|
-
export const
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
/** The copy of the run's input: a dot file, so a client like Tolaria leaves it out (the owner has the original). */
|
|
61
|
+
export const inputFile = (runId) => `${runFolder(runId)}/.${runId}.md`;
|
|
62
|
+
/** A version a step wrote in a round; `hidden`: a dot file (a methodical artifact: its result is published to docs/). */
|
|
63
|
+
export const versionFile = (runId, name, round, hidden = false) => `${runFolder(runId)}/${hidden ? "." : ""}${runId}.${name}.${round}.md`;
|
|
64
|
+
const split = (file) => { const at = file.lastIndexOf("/") + 1; return [file.slice(0, at), file.slice(at).replace(/^\./, "").replace(/\.md$/i, "")]; };
|
|
65
|
+
/** An item `id` (E1, …) of the version file `file`: <file without .md>.<id>.md; `hidden`: a dot file. */
|
|
66
|
+
export const itemFile = (file, id, hidden = false) => { const [dir, base] = split(file); return `${dir}${hidden ? "." : ""}${base}.${id}.md`; };
|
|
67
|
+
/**
|
|
68
|
+
* The report of an artifact file: the same name with .report.md. `hidden`: a dot file (runs since
|
|
69
|
+
* 0.26.0: every report of the chain; the step's last one is published to docs/); else never one
|
|
70
|
+
* (older runs kept every report in view, and a published document's report is beside it).
|
|
71
|
+
*/
|
|
72
|
+
export const reportFile = (file, hidden = false) => { const [dir, base] = split(file); return `${dir}${hidden ? "." : ""}${base}.report.md`; };
|
|
66
73
|
|
|
67
74
|
/** { data, body, text } of the file at `rel` under `root` (data null for prose), or null. */
|
|
68
75
|
export function readDoc(root, rel) {
|
|
@@ -82,6 +89,33 @@ export function writeDoc(root, rel, data, body) {
|
|
|
82
89
|
/** The type of a document: its frontmatter type, idea for prose. */
|
|
83
90
|
export const typeOf = (doc) => String(doc.data?.type ?? "idea").toLowerCase();
|
|
84
91
|
|
|
92
|
+
// ── type names ──────────────────────────────────────────────────────────────
|
|
93
|
+
// A definition's id is its file name (spec, create-work); the frontmatter carries its name, in
|
|
94
|
+
// TitleCase and singular (Spec, Epic, Report: a client like Tolaria makes a view of
|
|
95
|
+
// it and adds the plural). Reading takes the name, the id or an alias, in any case. The records
|
|
96
|
+
// of tasks share one name, Pipeline ("shared": true); the frontmatter's `task` tells them apart.
|
|
97
|
+
|
|
98
|
+
const squash = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
99
|
+
const titleCase = (id) => String(id).split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join("");
|
|
100
|
+
|
|
101
|
+
/** The frontmatter type of the definition `id` (its name, else its id in TitleCase). */
|
|
102
|
+
export const typeName = (defs, id) => defs.artifacts.get(id)?.name ?? titleCase(id);
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The definition id of a frontmatter `type` (name, id or alias, any case), or its lower case
|
|
106
|
+
* when unknown. A name several definitions share (Pipeline) is resolved by `task`: the output
|
|
107
|
+
* of that task.
|
|
108
|
+
*/
|
|
109
|
+
export function typeId(defs, type, task = null) {
|
|
110
|
+
const key = squash(type);
|
|
111
|
+
const ids = [...defs.artifacts].filter(([id, def]) => [id, def.name, ...(def.aliases ?? [])].some((n) => n && squash(n) === key)).map(([id]) => id);
|
|
112
|
+
if (ids.length > 1 && task) {
|
|
113
|
+
const out = defs.tasks.get(String(task))?.output;
|
|
114
|
+
if (ids.includes(out)) return out;
|
|
115
|
+
}
|
|
116
|
+
return ids[0] ?? String(type ?? "").toLowerCase();
|
|
117
|
+
}
|
|
118
|
+
|
|
85
119
|
/** The status of the latest report of the artifact file `rel`, or null. */
|
|
86
120
|
export const reportStatus = (root, rel) => readDoc(root, reportFile(rel))?.data?.status ?? null;
|
|
87
121
|
|
|
@@ -106,7 +140,7 @@ export function sectionsOf(body) {
|
|
|
106
140
|
|
|
107
141
|
/** The problems of a document of `type` (`data` null for prose) against its definition. */
|
|
108
142
|
export function validateDoc(defs, type, data, body, where) {
|
|
109
|
-
const def = defs.artifacts.get(
|
|
143
|
+
const def = defs.artifacts.get(typeId(defs, type, data?.task));
|
|
110
144
|
if (!def) return [`${where}: unknown type "${type}"`];
|
|
111
145
|
const out = [];
|
|
112
146
|
if (data) {
|
|
@@ -226,9 +260,9 @@ export function readInput(defs, root, file) {
|
|
|
226
260
|
if (!text.trim()) return { error: `${file}: the file is empty` };
|
|
227
261
|
const { data, body } = parseFrontmatter(text);
|
|
228
262
|
if (!isHarnessDoc(data)) return { type: null, owner: true, data, body, text, status: null, blocked: false };
|
|
229
|
-
const type =
|
|
263
|
+
const type = typeId(defs, data?.type ?? "idea", data?.task);
|
|
230
264
|
if (!defs.artifacts.has(type)) return { error: `${file}: unknown artifact type "${type}"` };
|
|
231
|
-
const report = file
|
|
265
|
+
const report = reportFile(file);
|
|
232
266
|
const blocked = fs.existsSync(report) && parseFrontmatter(fs.readFileSync(report, "utf8")).data?.status === "Block";
|
|
233
267
|
return { type, data, body, text, status: data?.status ?? null, blocked };
|
|
234
268
|
}
|
package/src/defs.mjs
CHANGED
|
@@ -56,6 +56,7 @@ export function lintDefs(defs) {
|
|
|
56
56
|
for (const a of defs.artifacts.values()) {
|
|
57
57
|
if (!Array.isArray(a.statuses) || !a.statuses.length) out.push(`artifact ${a.id}: statuses missing`);
|
|
58
58
|
if (a.sections !== undefined && !Array.isArray(a.sections)) out.push(`artifact ${a.id}: sections must be a list`);
|
|
59
|
+
if (a.name !== undefined && !/^[A-Z][A-Za-z0-9]*$/.test(a.name)) out.push(`artifact ${a.id}: name "${a.name}" must be TitleCase, letters and digits only`);
|
|
59
60
|
if (a.items !== undefined) {
|
|
60
61
|
const i = a.items;
|
|
61
62
|
if (!isObject(i) || !i.type || !i.prefix || !i.section) out.push(`artifact ${a.id}: items needs type, prefix and section`);
|
|
@@ -65,6 +66,17 @@ export function lintDefs(defs) {
|
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
}
|
|
69
|
+
// A name is one definition's, unless every definition that has it is "shared" and the
|
|
70
|
+
// output of a task (Pipeline: the frontmatter's task tells them apart).
|
|
71
|
+
const outputs = new Set([...defs.tasks.values()].map((t) => t.output));
|
|
72
|
+
const names = new Map();
|
|
73
|
+
for (const a of defs.artifacts.values()) for (const n of [a.name ?? a.id, ...(a.aliases ?? [])]) {
|
|
74
|
+
const k = String(n).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
75
|
+
const other = names.get(k);
|
|
76
|
+
if (other && other.id !== a.id && !(other.shared && a.shared && n === a.name)) out.push(`artifact ${a.id}: the name "${n}" is taken by ${other.id}`);
|
|
77
|
+
names.set(k, a);
|
|
78
|
+
}
|
|
79
|
+
for (const a of defs.artifacts.values()) if (a.shared && !outputs.has(a.id)) out.push(`artifact ${a.id}: a shared name needs the artifact to be a task's output`);
|
|
68
80
|
for (const a of defs.artifacts.values()) if (isObject(a.items) && a.items.type && !defs.artifacts.has(a.items.type)) out.push(`artifact ${a.id}: items.type "${a.items.type}" is unknown`);
|
|
69
81
|
if (!defs.artifacts.has("idea")) out.push("artifact idea is missing: it is the type of prose input");
|
|
70
82
|
if (!defs.artifacts.has("report")) out.push("artifact report is missing: reviews write it");
|