@mmerterden/multi-agent-pipeline 20.23.1 → 20.24.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +7 -6
  3. package/README.tr.md +6 -5
  4. package/docs/architecture.md +3 -3
  5. package/docs/ecosystem.md +5 -5
  6. package/docs/facts.json +3 -3
  7. package/install/catalog-history.json +1 -1
  8. package/install/templates/claude-hooks.json +14 -1
  9. package/manifest.json +77 -56
  10. package/package.json +1 -1
  11. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +11 -2
  12. package/pipeline/commands/multi-agent/postmortem/SKILL.md +78 -0
  13. package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
  14. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  15. package/pipeline/lib/analysis-jira-write.sh +105 -22
  16. package/pipeline/lib/decisions.mjs +130 -0
  17. package/pipeline/lib/plan-todos.sh +156 -33
  18. package/pipeline/lib/task-graph.mjs +446 -0
  19. package/pipeline/lib/tdd-evidence.mjs +579 -0
  20. package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
  21. package/pipeline/multi-agent-refs/analysis-template.md +4 -2
  22. package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
  23. package/pipeline/multi-agent-refs/features/analysis-jira.md +51 -3
  24. package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
  25. package/pipeline/multi-agent-refs/features/model-fallback.md +4 -2
  26. package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
  27. package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
  28. package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
  29. package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
  30. package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
  31. package/pipeline/multi-agent-refs/generate-issue.md +1 -1
  32. package/pipeline/multi-agent-refs/help/en.md +1 -0
  33. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  34. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
  35. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +5 -7
  36. package/pipeline/multi-agent-refs/phases/phase-3-review.md +13 -11
  37. package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
  38. package/pipeline/multi-agent-refs/picker-contract.md +3 -2
  39. package/pipeline/multi-agent-refs/response-shape.md +65 -0
  40. package/pipeline/rules/response-shape.md +18 -0
  41. package/pipeline/schemas/agent-state.schema.json +31 -1
  42. package/pipeline/schemas/plan-todos.schema.json +101 -0
  43. package/pipeline/schemas/planning-output.schema.json +46 -0
  44. package/pipeline/schemas/prefs.schema.json +117 -1
  45. package/pipeline/scripts/_jsonl.mjs +151 -0
  46. package/pipeline/scripts/_plan-overlap.mjs +231 -0
  47. package/pipeline/scripts/_redact.mjs +124 -10
  48. package/pipeline/scripts/analysis-story-body.mjs +26 -10
  49. package/pipeline/scripts/analysis-story-tree.mjs +210 -9
  50. package/pipeline/scripts/autopilot-publish.mjs +12 -1
  51. package/pipeline/scripts/cost-analyze.mjs +6 -24
  52. package/pipeline/scripts/decisions-log.mjs +118 -0
  53. package/pipeline/scripts/edit-syntax-gate.mjs +240 -0
  54. package/pipeline/scripts/edit-syntax-gate.sh +29 -0
  55. package/pipeline/scripts/find-polluter.mjs +281 -0
  56. package/pipeline/scripts/gate-ledger.mjs +46 -13
  57. package/pipeline/scripts/mutation-probe.mjs +484 -0
  58. package/pipeline/scripts/plan-complexity.mjs +259 -0
  59. package/pipeline/scripts/review-decision-gate.mjs +33 -1
  60. package/pipeline/scripts/rework-escalation.mjs +264 -0
  61. package/pipeline/scripts/test-strength.mjs +5 -3
  62. package/pipeline/scripts/transcript-index.mjs +950 -0
  63. package/pipeline/scripts/validate-planning.mjs +48 -6
  64. package/pipeline/skills/.skill-manifest.json +11 -7
  65. package/pipeline/skills/.skills-index.json +12 -1
  66. package/pipeline/skills/shared/README.md +4 -3
  67. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +11 -2
  68. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +66 -0
  69. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -1
  70. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  71. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
  72. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
  73. package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
  74. package/pipeline/skills/skills-index.md +2 -1
@@ -50,6 +50,7 @@
50
50
  *
51
51
  * Usage:
52
52
  * analysis-story-tree.mjs <analysis.md> [--json] [--brief] [--prefs FILE] [--page-url URL]
53
+ * analysis-story-tree.mjs --save-subtasks "<type>[,<type>...]"|none [--prefs FILE]
53
54
  *
54
55
  * Exit: 0 planned, 2 nothing to plan, 3 usage, 4 the document is not issue-ready.
55
56
  */
@@ -60,6 +61,8 @@ import { createHash } from "node:crypto";
60
61
  import { homedir } from "node:os";
61
62
  import { dirname, join } from "node:path";
62
63
  import { hostCommand } from "../lib/user-facing.mjs";
64
+ import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
65
+ import { analyzeGraph, cyclicComponentOf } from "../lib/task-graph.mjs";
63
66
  import { resolveConstitution } from "./constitution.mjs";
64
67
  import { gateContext, recordQuietly } from "./gate-ledger.mjs";
65
68
  import {
@@ -81,7 +84,11 @@ const PREFS_PATH =
81
84
  : join(homedir(), ".claude", "multi-agent-preferences.json");
82
85
  const pageUrlFlag = argv.indexOf("--page-url");
83
86
  const PAGE_URL = pageUrlFlag !== -1 ? argv[pageUrlFlag + 1] : null;
84
- const valueIdx = new Set([prefsFlag, pageUrlFlag].filter((i) => i !== -1).map((i) => i + 1));
87
+ const saveFlag = argv.indexOf("--save-subtasks");
88
+ const SAVE_SUBTASKS = saveFlag !== -1 ? (argv[saveFlag + 1] ?? "") : null;
89
+ const valueIdx = new Set(
90
+ [prefsFlag, pageUrlFlag, saveFlag].filter((i) => i !== -1).map((i) => i + 1),
91
+ );
85
92
  const file = argv.find((a, i) => !a.startsWith("--") && !valueIdx.has(i));
86
93
 
87
94
  function die(msg, code) {
@@ -162,6 +169,9 @@ export function identityLabel(prefix, docId, sourceIds) {
162
169
  * issueTree.epicLinkFieldId the epic-link field, when not discovered
163
170
  * issueTree.cloneByChannel one linked story per channel in channelComponents
164
171
  * issueTree.cloneLinkType the link type between a story and its clones
172
+ * issueTree.storyPanels story section panel colours, or false for none
173
+ * issueTree.subtaskRolesDeclined the person chose no sub-tasks; do not ask again
174
+ * issueTree.dependencyLinkType the link type written for a story dependency
165
175
  */
166
176
  const ISSUE_TREE_SETTINGS = [
167
177
  "issueTree.enabled",
@@ -177,6 +187,9 @@ const ISSUE_TREE_SETTINGS = [
177
187
  "issueTree.epicLinkFieldId",
178
188
  "issueTree.cloneByChannel",
179
189
  "issueTree.cloneLinkType",
190
+ "issueTree.storyPanels",
191
+ "issueTree.subtaskRolesDeclined",
192
+ "issueTree.dependencyLinkType",
180
193
  ];
181
194
 
182
195
  function readPrefs() {
@@ -206,6 +219,100 @@ export function subtaskTypeFor(setting, role) {
206
219
  return null;
207
220
  }
208
221
 
222
+ const DEPENDS_ON = /\bdepends on:([^|\n]*)/gi;
223
+
224
+ /**
225
+ * The `Depends on: <ids>` clauses of a document's requirement rows.
226
+ *
227
+ * A clause belongs to the table row it sits in, in any cell, so a trailing
228
+ * column under any header works and so does a note appended to a cell. The
229
+ * row's subject is the first requirement id left once the clause is removed.
230
+ * Clauses outside a table row are not read: without a row there is no subject.
231
+ *
232
+ * `stripped` is the text with every read clause removed. Ids are defined by
233
+ * where they appear, so a reference left in place would define the id it
234
+ * points at, and a dependency on a requirement nobody wrote would plan a story
235
+ * for it instead of being reported.
236
+ *
237
+ * @returns {{deps: Map<string, Set<string>>, stripped: string}}
238
+ */
239
+ export function dependencyClauses(text, corporate) {
240
+ const atom = corporate ? ATOM_CORP : ATOM_GLOBAL;
241
+ const deps = new Map();
242
+ const lines = text.split("\n");
243
+ for (const [i, line] of lines.entries()) {
244
+ if (!/^\s*\|/.test(line) || !/\bdepends on:/i.test(line)) continue;
245
+ const refs = [];
246
+ const rest = line.replace(DEPENDS_ON, (clause, ids) => {
247
+ const found = ids.match(atom);
248
+ if (!found) return clause;
249
+ refs.push(...found.map((x) => x.toUpperCase()));
250
+ return "";
251
+ });
252
+ const subject = (rest.match(atom) || [])[0]?.toUpperCase();
253
+ if (!subject || !refs.length) continue;
254
+ lines[i] = rest;
255
+ for (const r of refs) {
256
+ if (r === subject) continue;
257
+ if (!deps.has(subject)) deps.set(subject, new Set());
258
+ deps.get(subject).add(r);
259
+ }
260
+ }
261
+ return { deps, stripped: lines.join("\n") };
262
+ }
263
+
264
+ /**
265
+ * Requirement dependencies, read as story-to-story edges and checked as a
266
+ * graph. A story depends on another when one of its source ids depends on a
267
+ * source id of the other; two ids of one story give no edge. A dependency on
268
+ * an id no story covers, and a cycle between stories, are problems: the plan
269
+ * reports them and is not `ok`. An edge whose two stories sit in one strongly
270
+ * connected component carries `onCycle: true`; such an edge has no order to
271
+ * record, so it is never linked and never counted as a write.
272
+ */
273
+ function storyDependencies(stories, reqDeps) {
274
+ const coverers = new Map();
275
+ for (const s of stories)
276
+ for (const id of s.sourceIds) {
277
+ if (!coverers.has(id)) coverers.set(id, []);
278
+ coverers.get(id).push(s);
279
+ }
280
+ const edges = new Map();
281
+ const missing = [];
282
+ for (const s of stories) {
283
+ for (const id of s.sourceIds) {
284
+ for (const dep of reqDeps.get(id) || []) {
285
+ const targets = coverers.get(dep);
286
+ if (!targets) {
287
+ missing.push({ id, dependsOn: dep });
288
+ continue;
289
+ }
290
+ for (const t of targets) {
291
+ if (t === s) continue;
292
+ const key = `${s.label}>${t.label}`;
293
+ if (!edges.has(key))
294
+ edges.set(key, { dependent: s.label, prerequisite: t.label, via: [] });
295
+ const via = `${dep} -> ${id}`;
296
+ if (!edges.get(key).via.includes(via)) edges.get(key).via.push(via);
297
+ }
298
+ }
299
+ }
300
+ }
301
+ for (const s of stories) {
302
+ const on = [...edges.values()].filter((e) => e.dependent === s.label);
303
+ if (on.length) s.dependsOn = on.map((e) => e.prerequisite);
304
+ }
305
+ const graph = stories.map((s) => ({ id: s.label, deps: s.dependsOn || [], status: "pending" }));
306
+ const { cycles } = analyzeGraph(graph);
307
+ const component = cyclicComponentOf(graph);
308
+ for (const e of edges.values()) {
309
+ const c = component.get(e.dependent);
310
+ if (c !== undefined && c === component.get(e.prerequisite)) e.onCycle = true;
311
+ }
312
+ const unique = [...new Map(missing.map((m) => [`${m.id}>${m.dependsOn}`, m])).values()];
313
+ return { edges: [...edges.values()], cycles, missing: unique };
314
+ }
315
+
209
316
  /** Every open placeholder that makes a document unfit to become work. */
210
317
  export function openItems(text) {
211
318
  const found = [];
@@ -258,10 +365,11 @@ export function coverageOf(defined, nodes, corporate = false) {
258
365
  };
259
366
  }
260
367
 
261
- export function plan(text, prefs, { pageUrl = null } = {}) {
262
- const fm = frontMatter(text);
368
+ export function plan(source, prefs, { pageUrl = null } = {}) {
369
+ const fm = frontMatter(source);
263
370
  const profile = (fm.profile || "global").toLowerCase();
264
371
  const corporate = profile === "corporate";
372
+ const { deps: reqDeps, stripped: text } = dependencyClauses(source, corporate);
265
373
  const secs = sections(text);
266
374
 
267
375
  const atomRe = corporate ? ATOM_CORP : ATOM_GLOBAL;
@@ -388,6 +496,7 @@ export function plan(text, prefs, { pageUrl = null } = {}) {
388
496
  profile,
389
497
  pageUrl: pageUrl || fm.confluence_url || null,
390
498
  redesign: String(fm.redesign || "").toLowerCase() === "true",
499
+ panels: prefs.storyPanels,
391
500
  };
392
501
  for (const story of stories) {
393
502
  const b = buildStoryBody(story, bodyCtx);
@@ -412,6 +521,30 @@ export function plan(text, prefs, { pageUrl = null } = {}) {
412
521
  }
413
522
 
414
523
  const coverage = coverageOf(defined, stories, corporate);
524
+ const dependencies = reqDeps.size ? storyDependencies(stories, reqDeps) : null;
525
+ if (dependencies) {
526
+ const titleOf = new Map(stories.map((s) => [s.label, s.group]));
527
+ const linked = new Set(dependencies.edges.map((e) => `${e.dependent}>${e.prerequisite}`));
528
+ const isLoop = (c) => c.every((l, i) => linked.has(`${l}>${c[(i + 1) % c.length]}`));
529
+ const problems = [
530
+ ...dependencies.cycles.map((c) =>
531
+ isLoop(c)
532
+ ? `dependency cycle: ${[c[0], ...[...c].reverse()].map((l) => titleOf.get(l)).join(" -> ")}`
533
+ : `dependency cycle among: ${c.map((l) => titleOf.get(l)).join(", ")}`,
534
+ ),
535
+ ...dependencies.missing.map(
536
+ (m) => `${m.id} depends on ${m.dependsOn}, which no story covers`,
537
+ ),
538
+ ];
539
+ if (problems.length) {
540
+ coverage.dependencyProblems = problems;
541
+ if (coverage.verdict === "ok") coverage.verdict = "incomplete";
542
+ if (coverage.verdict === "incomplete" && !coverage.reason) {
543
+ coverage.reason = `${problems.length} dependency problem(s): ${problems.join("; ")}`;
544
+ }
545
+ }
546
+ }
547
+ const linkType = prefs.dependencyLinkType ?? "Blocks";
415
548
 
416
549
  return {
417
550
  document: {
@@ -434,12 +567,17 @@ export function plan(text, prefs, { pageUrl = null } = {}) {
434
567
  epicLinkFieldId: prefs.epicLinkFieldId ?? null,
435
568
  cloneByChannel: prefs.cloneByChannel === true,
436
569
  cloneLinkType: prefs.cloneLinkType ?? "Relates",
570
+ storyPanels: prefs.storyPanels ?? null,
571
+ subtaskRolesDeclined: prefs.subtaskRolesDeclined === true,
572
+ ...(dependencies || prefs.dependencyLinkType !== undefined
573
+ ? { dependencyLinkType: linkType }
574
+ : {}),
437
575
  },
438
576
  nodes: stories,
439
- writeCount: stories.reduce(
440
- (n, s) => n + 1 + s.subtasks.length + 2 * (s.clones?.length || 0),
441
- 0,
442
- ),
577
+ ...(dependencies ? { dependencies } : {}),
578
+ writeCount:
579
+ stories.reduce((n, s) => n + 1 + s.subtasks.length + 2 * (s.clones?.length || 0), 0) +
580
+ (dependencies?.edges.filter((e) => !e.onCycle).length || 0),
443
581
  };
444
582
  }
445
583
 
@@ -448,7 +586,9 @@ export function plan(text, prefs, { pageUrl = null } = {}) {
448
586
  *
449
587
  * @returns {object} a spec-consistency-gate result
450
588
  */
451
- function consistencyOf(text, nodes) {
589
+ function consistencyOf(source, nodes) {
590
+ const corporate = (frontMatter(source).profile || "").toLowerCase() === "corporate";
591
+ const text = dependencyClauses(source, corporate).stripped;
452
592
  const constitution = resolveConstitution({
453
593
  repo: dirname(file),
454
594
  feature: frontMatter(text).feature || null,
@@ -464,7 +604,50 @@ function consistencyOf(text, nodes) {
464
604
  });
465
605
  }
466
606
 
607
+ /**
608
+ * Save the sub-tasks a person chose for every story: each chosen site type
609
+ * becomes a role named after it and mapped to it, or "none" records that the
610
+ * question was answered with no sub-tasks.
611
+ */
612
+ function saveSubtasks(choice) {
613
+ const names = String(choice)
614
+ .split(",")
615
+ .map((n) => n.trim())
616
+ .filter(Boolean);
617
+ const none = names.length === 1 && names[0].toLowerCase() === "none";
618
+ if (!names.length || names.some((n) => n.length > 100 || /[\n{}]/.test(n)))
619
+ return die("analysis-story-tree: --save-subtasks takes type names or none", 3);
620
+ try {
621
+ updateJsonFileSync(
622
+ PREFS_PATH,
623
+ (j) => {
624
+ if (!j || typeof j !== "object" || Array.isArray(j)) return undefined;
625
+ j.global = j.global || {};
626
+ const t = { ...(j.global.issueTree || {}) };
627
+ if (none) {
628
+ t.subtaskRoles = [];
629
+ t.subtaskRolesDeclined = true;
630
+ } else {
631
+ t.subtaskRoles = names;
632
+ t.subtaskIssueType = Object.fromEntries(names.map((n) => [n, n]));
633
+ delete t.subtaskRolesDeclined;
634
+ }
635
+ j.global.issueTree = t;
636
+ return j;
637
+ },
638
+ { mode: 0o600, followSymlink: true, timeoutMs: lockTimeoutFrom(process.env) },
639
+ );
640
+ } catch (e) {
641
+ return die(`analysis-story-tree: could not write ${PREFS_PATH}: ${e.message}`, 3);
642
+ }
643
+ process.stdout.write(
644
+ none ? "issueTree: no sub-tasks\n" : `issueTree: sub-tasks ${names.join(", ")}\n`,
645
+ );
646
+ return null;
647
+ }
648
+
467
649
  async function main() {
650
+ if (SAVE_SUBTASKS !== null) return saveSubtasks(SAVE_SUBTASKS);
468
651
  if (!file)
469
652
  return die(
470
653
  "usage: analysis-story-tree.mjs <analysis.md> [--json] [--brief] [--prefs FILE] [--page-url URL]",
@@ -550,12 +733,18 @@ async function main() {
550
733
  );
551
734
  if (c.uncovered.length) process.stdout.write(` not in any story: ${c.uncovered.join(", ")}\n`);
552
735
  if (c.invented.length) process.stdout.write(` cited but undefined: ${c.invented.join(", ")}\n`);
736
+ for (const p of c.dependencyProblems || []) process.stdout.write(` ${p}\n`);
553
737
  const k = result.consistency;
554
738
  process.stdout.write(
555
739
  `consistency: ${k.verdict} (${k.advisory ? "advisory, " : ""}${consistencyDetail(k)})\n`,
556
740
  );
557
741
  for (const line of describe(k)) process.stdout.write(`${line}\n`);
558
- process.stdout.write(`\n${result.writeCount} issue(s) would be created:\n`);
742
+ const linkCount =
743
+ result.nodes.reduce((n, s) => n + (s.clones?.length || 0), 0) +
744
+ (result.dependencies?.edges.filter((e) => !e.onCycle).length || 0);
745
+ process.stdout.write(
746
+ `\n${result.writeCount - linkCount} issue(s) and ${linkCount} link(s) would be created:\n`,
747
+ );
559
748
  for (const s of result.nodes) {
560
749
  process.stdout.write(` ${result.prefsUsed.storyIssueType} ${s.title}\n`);
561
750
  process.stdout.write(
@@ -579,6 +768,18 @@ async function main() {
579
768
  );
580
769
  }
581
770
  }
771
+ const d = result.dependencies;
772
+ if (d) {
773
+ const group = new Map(result.nodes.map((n) => [n.label, n.group]));
774
+ process.stdout.write(
775
+ `\ndependencies: ${d.edges.length} ${result.prefsUsed.dependencyLinkType} link(s), the prerequisite first:\n`,
776
+ );
777
+ for (const e of d.edges) {
778
+ process.stdout.write(
779
+ ` ${group.get(e.prerequisite)} -> ${group.get(e.dependent)} (${e.via.join(", ")}) [${e.prerequisite} -> ${e.dependent}]${e.onCycle ? " not linked: on a dependency cycle" : ""}\n`,
780
+ );
781
+ }
782
+ }
582
783
  // Every field's provenance, so a wrong setting is visible here rather than in
583
784
  // Jira after the writes have happened.
584
785
  process.stdout.write("\nfrom prefs.global.issueTree:\n");
@@ -13,7 +13,9 @@
13
13
  * 1. The request, the state and the test log are read as bounded regular
14
14
  * files (lib/regular-file.mjs): a FIFO, a device or an oversized file is
15
15
  * refused, never read. The request is read once and validated in process
16
- * (pr-request.mjs validatePrRequest) on those same bytes.
16
+ * (pr-request.mjs validatePrRequest) on those same bytes. The state's
17
+ * `decisions[]` is appended to the body as one redacted section
18
+ * (lib/decisions.mjs), so the PR lists what the run chose unasked.
17
19
  * 2. The repository comes from the runner's own config (the queue item's
18
20
  * `nameWithOwner`), never from the request. A request naming another
19
21
  * repository is refused, not redirected. The base is the configured
@@ -104,6 +106,7 @@ import {
104
106
  import { tmpdir } from "node:os";
105
107
  import { dirname, isAbsolute, join } from "node:path";
106
108
 
109
+ import { appendDecisionsSection } from "../lib/decisions.mjs";
107
110
  import { invokedDirectly } from "../lib/invoked-directly.mjs";
108
111
  import { scan } from "../lib/outbound-gate.mjs";
109
112
  import { prRequestPath, prRequestsDir, prVerdictPath } from "../lib/pr-request-location.mjs";
@@ -123,6 +126,7 @@ const GIT_TIMEOUT_MS = 60 * 1000;
123
126
  const NET_TIMEOUT_MS = 5 * 60 * 1000;
124
127
  // A valid request is at most a 256-character title and a 65536-character body.
125
128
  const REQUEST_MAX_BYTES = 1024 * 1024;
129
+ const BODY_MAX_CHARS = 65536;
126
130
  const STATE_MAX_BYTES = 16 * 1024 * 1024;
127
131
  const LOG_MAX_BYTES = 64 * 1024 * 1024;
128
132
  const VERIFY_TIMEOUT_SEC = 30 * 60;
@@ -1268,6 +1272,13 @@ export async function publish({ sessionId, statePath, item, config, env = proces
1268
1272
  }
1269
1273
 
1270
1274
  req = readRequest(reqPath);
1275
+ // What the run decided in place of a person goes into the PR it opens. The
1276
+ // section is redacted when rendered and passes the outbound gate below with
1277
+ // the rest of the body.
1278
+ req = {
1279
+ ...req,
1280
+ body: appendDecisionsSection(req.body, state.decisions, { maxChars: BODY_MAX_CHARS }),
1281
+ };
1271
1282
 
1272
1283
  const repoCfg = (config?.repos || []).find((r) => r && r.nameWithOwner === repo);
1273
1284
  if (!repo || !repoCfg)
@@ -46,20 +46,12 @@
46
46
  * 2 usage error
47
47
  */
48
48
 
49
- import {
50
- createReadStream,
51
- existsSync,
52
- mkdirSync,
53
- readdirSync,
54
- readFileSync,
55
- statSync,
56
- writeFileSync,
57
- } from "node:fs";
58
- import { createInterface } from "node:readline";
49
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
59
50
  import { homedir } from "node:os";
60
51
  import { dirname, join } from "node:path";
61
52
  import { fileURLToPath } from "node:url";
62
53
  import { costUsd } from "./_cost.mjs";
54
+ import { readJsonl } from "./_jsonl.mjs";
63
55
  import { runMain } from "../lib/fatal.mjs";
64
56
  import { messages, outputLanguage } from "../lib/user-facing.mjs";
65
57
 
@@ -263,20 +255,10 @@ async function readSeries({ days }) {
263
255
  const unpricedModels = new Set();
264
256
 
265
257
  for (const f of files) {
266
- let rl;
267
- try {
268
- rl = createInterface({ input: createReadStream(f.path), crlfDelay: Infinity });
269
- } catch {
270
- continue;
271
- }
272
- for await (const line of rl) {
273
- if (!line || line.charCodeAt(0) !== 123) continue;
274
- let d;
275
- try {
276
- d = JSON.parse(line);
277
- } catch {
278
- continue;
279
- }
258
+ // Uncapped: a cost total that silently stops at a byte cap under-reports.
259
+ for await (const row of readJsonl(f.path, { maxBytes: Infinity, maxLineBytes: Infinity })) {
260
+ const d = row.record;
261
+ if (!d) continue;
280
262
  const msg = d.message;
281
263
  const u = msg?.usage;
282
264
  if (!u) continue;
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * decisions-log.mjs - append to and render `state.decisions[]`, the list of
4
+ * choices an autopilot run made where an attended run would have asked.
5
+ *
6
+ * Writers: a picker resolved without asking (picker-contract.md, autopilot),
7
+ * a split consensus taken on the triage verdict, a model fallback taken
8
+ * without the date-gate question, a rework dispatched one rung up
9
+ * (rework-escalation.mjs). gate-ledger.mjs park and review-decision-gate.mjs
10
+ * write their own entries through lib/decisions.mjs.
11
+ *
12
+ * `add` writes only while the quality gates are active (lib/unattended.mjs
13
+ * gatesActive): in an attended run the person answered, so there is nothing to
14
+ * report back. Writes take the write-state lock through gate-ledger.mjs
15
+ * updateState.
16
+ *
17
+ * `render` prints the markdown section Phase 5 puts in its report, redacted,
18
+ * and nothing at all for a run with no entries.
19
+ *
20
+ * Usage:
21
+ * decisions-log.mjs add --what <text> --why <text> --cost-if-wrong <text> --phase <0-5>
22
+ * [--state <path>] [--repo <dir>]
23
+ * decisions-log.mjs render [--state <path>] [--repo <dir>]
24
+ *
25
+ * Exit: 0 written, skipped (attended) or rendered · 2 usage · 3 no state or I/O error
26
+ *
27
+ * @module pipeline/scripts/decisions-log
28
+ */
29
+
30
+ import { writeFileSync } from "node:fs";
31
+
32
+ import { decisionEntry, renderDecisions, withDecision } from "../lib/decisions.mjs";
33
+ import { invokedDirectly } from "../lib/invoked-directly.mjs";
34
+ import { readRegularJson } from "../lib/regular-file.mjs";
35
+ import { gateContext, resolveStatePath, updateState } from "./gate-ledger.mjs";
36
+
37
+ class UsageError extends Error {}
38
+
39
+ function parse(argv) {
40
+ const opts = {};
41
+ for (let i = 1; i < argv.length; i++) {
42
+ const a = argv[i];
43
+ if (!a.startsWith("--")) throw new UsageError(`unexpected argument ${a}`);
44
+ const v = argv[++i];
45
+ if (v === undefined) throw new UsageError(`${a} needs a value`);
46
+ opts[a.slice(2)] = v;
47
+ }
48
+ return opts;
49
+ }
50
+
51
+ /**
52
+ * Append one entry when the gates are active. Resolves to whether it wrote.
53
+ *
54
+ * @param {{what: string, why: string, costIfWrong: string, phase: number}} input
55
+ * @param {{statePath?: string|null, repo?: string|null, env?: Record<string,string|undefined>}} [opts]
56
+ */
57
+ export async function addDecision(
58
+ input,
59
+ { statePath = null, repo = null, env = process.env } = {},
60
+ ) {
61
+ const entry = decisionEntry(input);
62
+ const ctx = gateContext({ statePath, repo, env });
63
+ if (!ctx.active || !ctx.statePath) return { written: false, entry };
64
+ await updateState(ctx.statePath, (s) => withDecision(s, entry));
65
+ return { written: true, entry, statePath: ctx.statePath };
66
+ }
67
+
68
+ async function main(argv) {
69
+ const mode = argv[0];
70
+ const opts = parse(argv);
71
+ if (mode === "add") {
72
+ let input;
73
+ try {
74
+ input = decisionEntry({
75
+ what: opts.what,
76
+ why: opts.why,
77
+ costIfWrong: opts["cost-if-wrong"],
78
+ phase: opts.phase,
79
+ });
80
+ } catch (err) {
81
+ throw new UsageError(err.message);
82
+ }
83
+ await addDecision(input, { statePath: opts.state || null, repo: opts.repo || process.cwd() });
84
+ return 0;
85
+ }
86
+ if (mode === "render") {
87
+ const path = resolveStatePath({ statePath: opts.state, repo: opts.repo || process.cwd() });
88
+ if (!path) {
89
+ process.stderr.write("decisions-log: no run state found\n");
90
+ return 3;
91
+ }
92
+ const state = readRegularJson(path, null);
93
+ if (!state || typeof state !== "object") {
94
+ process.stderr.write(`decisions-log: cannot read ${path}\n`);
95
+ return 3;
96
+ }
97
+ const md = renderDecisions(state.decisions);
98
+ if (md) writeFileSync(1, md);
99
+ return 0;
100
+ }
101
+ throw new UsageError("mode must be add or render");
102
+ }
103
+
104
+ if (invokedDirectly(import.meta.url)) {
105
+ main(process.argv.slice(2)).then(
106
+ (code) => process.exit(code),
107
+ (err) => {
108
+ process.stderr.write(`decisions-log: ${err.message}\n`);
109
+ if (err instanceof UsageError) {
110
+ process.stderr.write(
111
+ "usage: decisions-log.mjs add --what t --why t --cost-if-wrong t --phase n [--state p] | render [--state p]\n",
112
+ );
113
+ process.exit(2);
114
+ }
115
+ process.exit(3);
116
+ },
117
+ );
118
+ }