@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 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>/<run>.md the input, copied as it is (never changed)
52
- agiter/docs/<run>/<run>.draft.1.md round 1 of create-draft:3
53
- agiter/docs/<run>/<run>.draft.1.report.md its review: Block or Done, cumulative
54
- agiter/docs/<run>/<run>.draft.2.md round 2: input + draft.1 + its report
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,10 @@
1
1
  {
2
+ "name": "Pipeline",
3
+ "shared": true,
4
+ "record": true,
5
+ "aliases": [
6
+ "Check"
7
+ ],
2
8
  "title": "Checks",
3
9
  "summary": "The results of the project's required checks.",
4
10
  "sections": [
@@ -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
- "title": "Work",
3
- "summary": "The record of a change made to the code, tests included.",
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
+ }
@@ -1,4 +1,5 @@
1
1
  {
2
+ "record": true,
2
3
  "title": "Report",
3
4
  "summary": "The review of one version: its findings, cumulative over the rounds.",
4
5
  "statuses": [
@@ -6,8 +6,7 @@
6
6
  "spec",
7
7
  "idea"
8
8
  ],
9
- "output": "work",
10
- "name": "tests",
9
+ "output": "create-tests",
11
10
  "scope": [
12
11
  "**"
13
12
  ]
@@ -6,7 +6,7 @@
6
6
  "spec",
7
7
  "idea"
8
8
  ],
9
- "output": "work",
9
+ "output": "create-work",
10
10
  "scope": [
11
11
  "**"
12
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.14.1-alpha.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, model (args added when a model is set; {model}), message (args
6
- * with {message}, added last; or {message} inside command, where the CLI needs it earlier),
7
- * roles (extra args per role, "*" for the others), dropEnv } }.
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
- return readJson(own && fs.existsSync(own) ? own : path.join(PACKAGE_DEFS, AGENTS_FILE));
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 `timeoutMs`).
51
- * `onData(text, stream)` gets the output as it comes; `log` (a file) gets the command line at
52
- * once, then every piece of output as it comes (stderr lines marked "! "), then the exit.
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; write(d); try { onData?.(d, "stdout"); } catch {} });
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
- const r = await spawnAgent(argv, { cwd: root, env, timeoutMs: (res.profile.timeoutMinutes ?? 30) * 60000, log, onData: ctx.onOutput });
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
- Object.assign(meta, { model, log: tried.log, exit: r.exit });
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
- * <run>.md the run's input, copied as it is (never changed)
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
- export const inputFile = (runId) => `${runFolder(runId)}/${runId}.md`;
61
- export const versionFile = (runId, name, round) => `${runFolder(runId)}/${runId}.${name}.${round}.md`;
62
- /** An item `id` (E1, …) of the version file `file`: <file without .md>.<id>.md. */
63
- export const itemFile = (file, id) => file.replace(/\.md$/i, `.${id}.md`);
64
- /** The report of an artifact file: the same path with .report.md. */
65
- export const reportFile = (file) => file.replace(/\.md$/i, ".report.md");
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(String(type).toLowerCase());
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 = String(data?.type ?? "idea").toLowerCase();
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.replace(/\.md$/i, ".report.md");
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");