@mmerterden/multi-agent-pipeline 20.23.2 → 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 (72) hide show
  1. package/CHANGELOG.md +22 -0
  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 +75 -54
  10. package/package.json +1 -1
  11. package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -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 +70 -0
  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 +42 -0
  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/help/en.md +1 -0
  32. package/pipeline/multi-agent-refs/help/tr.md +1 -0
  33. package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
  34. package/pipeline/multi-agent-refs/phases/phase-2-dev.md +5 -7
  35. package/pipeline/multi-agent-refs/phases/phase-3-review.md +13 -11
  36. package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
  37. package/pipeline/multi-agent-refs/picker-contract.md +3 -2
  38. package/pipeline/multi-agent-refs/response-shape.md +65 -0
  39. package/pipeline/rules/response-shape.md +18 -0
  40. package/pipeline/schemas/agent-state.schema.json +31 -1
  41. package/pipeline/schemas/plan-todos.schema.json +101 -0
  42. package/pipeline/schemas/planning-output.schema.json +46 -0
  43. package/pipeline/schemas/prefs.schema.json +96 -0
  44. package/pipeline/scripts/_jsonl.mjs +151 -0
  45. package/pipeline/scripts/_plan-overlap.mjs +231 -0
  46. package/pipeline/scripts/_redact.mjs +124 -10
  47. package/pipeline/scripts/analysis-story-tree.mjs +153 -8
  48. package/pipeline/scripts/autopilot-publish.mjs +12 -1
  49. package/pipeline/scripts/cost-analyze.mjs +6 -24
  50. package/pipeline/scripts/decisions-log.mjs +118 -0
  51. package/pipeline/scripts/edit-syntax-gate.mjs +240 -0
  52. package/pipeline/scripts/edit-syntax-gate.sh +29 -0
  53. package/pipeline/scripts/find-polluter.mjs +281 -0
  54. package/pipeline/scripts/gate-ledger.mjs +46 -13
  55. package/pipeline/scripts/mutation-probe.mjs +484 -0
  56. package/pipeline/scripts/plan-complexity.mjs +259 -0
  57. package/pipeline/scripts/review-decision-gate.mjs +33 -1
  58. package/pipeline/scripts/rework-escalation.mjs +264 -0
  59. package/pipeline/scripts/test-strength.mjs +5 -3
  60. package/pipeline/scripts/transcript-index.mjs +950 -0
  61. package/pipeline/scripts/validate-planning.mjs +48 -6
  62. package/pipeline/skills/.skill-manifest.json +11 -7
  63. package/pipeline/skills/.skills-index.json +12 -1
  64. package/pipeline/skills/shared/README.md +4 -3
  65. package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
  66. package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +66 -0
  67. package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -1
  68. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
  69. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
  70. package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
  71. package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
  72. package/pipeline/skills/skills-index.md +2 -1
@@ -0,0 +1,231 @@
1
+ /**
2
+ * @file _plan-overlap.mjs - where two plan tasks meet, and whether they agree.
3
+ *
4
+ * Phase 1 hands Phase 2 a list of tasks that may run in parallel. Two tasks
5
+ * that touch the same file, or name the same interface, are where a plan
6
+ * contradicts itself without any single task being wrong: both create the
7
+ * same new file, one deletes or renames what the other still uses, or a task
8
+ * consumes something its producer is not ordered before.
9
+ *
10
+ * Every pair that shares a name becomes an anchored row:
11
+ *
12
+ * { a, b, kind: "file"|"symbol", name, aRole, bRole, order, finding }
13
+ *
14
+ * `a` precedes `b` in plan order. A role is what the task does to the name:
15
+ *
16
+ * file creates (in `creates`) | removes (in `removes`) | edits (in `files`)
17
+ * symbol produces (in `produces`) | removes (in `removes`) | consumes (in `consumes`)
18
+ *
19
+ * `removes` covers deletion and the old side of a rename, so one field holds
20
+ * files and symbols alike. A name is a file when it contains a `/`, ends in a
21
+ * known file extension, is a well-known extensionless file (Makefile,
22
+ * Dockerfile, Podfile, ...) or is a dotfile; anything else, `UserService.fetch`
23
+ * included, is a symbol. File names are compared normalised, so `./a` and `a`
24
+ * are one file. A task that names something in more than one field takes its
25
+ * strongest role: removes, then creates / produces, then edits / consumes.
26
+ *
27
+ * `order` comes from `dependsOn`, transitively: `a-first`, `b-first`, or
28
+ * `unordered` when neither task waits for the other.
29
+ *
30
+ * Findings:
31
+ *
32
+ * double-create two tasks create the same file. Contradiction:
33
+ * the second one overwrites the first.
34
+ * removes-used one task removes what the other edits,
35
+ * consumes, creates or produces. Warning.
36
+ * consumer-not-after-producer the user of a created file or produced symbol
37
+ * is not ordered after its producer. Warning.
38
+ * unordered-edit two tasks edit one file with no order between
39
+ * them, so parallel workers collide. Warning.
40
+ *
41
+ * Only the roles the plan states are judged. A plan without `creates`,
42
+ * `removes`, `produces` or `consumes` still gets its shared-file rows, and the
43
+ * unordered-edit finding, from `files` alone.
44
+ *
45
+ * Zero runtime dependencies (ADR-0004).
46
+ *
47
+ * @module pipeline/scripts/_plan-overlap
48
+ */
49
+
50
+ const RANK = { removes: 3, creates: 2, produces: 2, edits: 1, consumes: 1 };
51
+
52
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string" && x) : []);
53
+
54
+ const FILE_EXTENSIONS = new Set(
55
+ (
56
+ "swift m mm h hh hpp c cc cpp cxx kt kts java gradle groovy scala js mjs cjs jsx ts tsx mts cts " +
57
+ "vue svelte py pyi rb go rs php cs dart lua pl sh bash zsh fish ps1 bat cmd sql graphql gql " +
58
+ "proto json jsonc json5 yaml yml toml ini cfg conf xml plist html htm css scss sass less md " +
59
+ "mdx markdown txt rst adoc csv tsv lock resolved xcconfig xcstrings strings stringsdict " +
60
+ "storyboard xib entitlements pbxproj xcscheme xcworkspacedata xcassets xcodeproj xcworkspace " +
61
+ "xcframework xctestplan png jpg jpeg gif svg webp heic pdf ico ttf otf woff woff2 mp3 mp4 mov " +
62
+ "wav zip gz tgz tar jar aar apk aab ipa dylib so tf hcl cmake mk patch diff pem p12 mobileprovision"
63
+ ).split(" "),
64
+ );
65
+ const FILE_NAMES = new Set([
66
+ "Makefile",
67
+ "GNUmakefile",
68
+ "Dockerfile",
69
+ "Containerfile",
70
+ "Podfile",
71
+ "Gemfile",
72
+ "Rakefile",
73
+ "Fastfile",
74
+ "Appfile",
75
+ "Matchfile",
76
+ "Pluginfile",
77
+ "Brewfile",
78
+ "Cartfile",
79
+ "Mintfile",
80
+ "Procfile",
81
+ "Jenkinsfile",
82
+ "Vagrantfile",
83
+ "Pipfile",
84
+ "Justfile",
85
+ "Package.swift",
86
+ "Package.resolved",
87
+ "Podfile.lock",
88
+ "Gemfile.lock",
89
+ "go.mod",
90
+ "go.sum",
91
+ "gradlew",
92
+ "mvnw",
93
+ "BUILD",
94
+ "WORKSPACE",
95
+ "LICENSE",
96
+ "README",
97
+ "CHANGELOG",
98
+ "CODEOWNERS",
99
+ ]);
100
+
101
+ /** `./a`, `a` and `a//b/.` name the same file. */
102
+ export function normalizeFile(name) {
103
+ const parts = [];
104
+ for (const seg of name.split("/")) {
105
+ if (seg === "" || seg === ".") continue;
106
+ if (seg === ".." && parts.length && parts.at(-1) !== "..") parts.pop();
107
+ else parts.push(seg);
108
+ }
109
+ const joined = parts.join("/");
110
+ return name.startsWith("/") ? `/${joined}` : joined || name;
111
+ }
112
+
113
+ export const isFileName = (name) => {
114
+ if (name.includes("/")) return true;
115
+ if (FILE_NAMES.has(name) || name.startsWith(".")) return true;
116
+ const dot = name.lastIndexOf(".");
117
+ return dot > 0 && FILE_EXTENSIONS.has(name.slice(dot + 1).toLowerCase());
118
+ };
119
+
120
+ /** name -> role for one task, per kind, keeping the strongest role. */
121
+ function rolesOf(task) {
122
+ const files = new Map();
123
+ const symbols = new Map();
124
+ const put = (map, name, role) => {
125
+ const cur = map.get(name);
126
+ if (!cur || RANK[role] > RANK[cur]) map.set(name, role);
127
+ };
128
+ for (const f of list(task.files)) put(files, normalizeFile(f), "edits");
129
+ for (const f of list(task.creates)) put(files, normalizeFile(f), "creates");
130
+ for (const s of list(task.consumes)) put(symbols, s, "consumes");
131
+ for (const s of list(task.produces)) put(symbols, s, "produces");
132
+ for (const n of list(task.removes)) {
133
+ if (isFileName(n)) put(files, normalizeFile(n), "removes");
134
+ else put(symbols, n, "removes");
135
+ }
136
+ return { file: files, symbol: symbols };
137
+ }
138
+
139
+ /** id -> set of every id it waits for, through dependsOn. */
140
+ function ancestors(tasks) {
141
+ const deps = new Map(tasks.map((t) => [String(t.id), list(t.dependsOn)]));
142
+ const memo = new Map();
143
+ const walk = (id, trail = new Set()) => {
144
+ if (memo.has(id)) return memo.get(id);
145
+ const out = new Set();
146
+ if (trail.has(id)) return out;
147
+ trail.add(id);
148
+ for (const d of deps.get(id) || []) {
149
+ out.add(d);
150
+ for (const x of walk(d, trail)) out.add(x);
151
+ }
152
+ trail.delete(id);
153
+ memo.set(id, out);
154
+ return out;
155
+ };
156
+ return (id) => walk(id);
157
+ }
158
+
159
+ const PRODUCER = new Set(["creates", "produces"]);
160
+
161
+ function judge(aRole, bRole, order) {
162
+ if (aRole === "creates" && bRole === "creates") return "double-create";
163
+ if ((aRole === "removes") !== (bRole === "removes")) return "removes-used";
164
+ if (aRole === "removes") return null;
165
+ const aMakes = PRODUCER.has(aRole);
166
+ const bMakes = PRODUCER.has(bRole);
167
+ if (aMakes && !bMakes && order !== "a-first") return "consumer-not-after-producer";
168
+ if (bMakes && !aMakes && order !== "b-first") return "consumer-not-after-producer";
169
+ if (aRole === "edits" && bRole === "edits" && order === "unordered") return "unordered-edit";
170
+ return null;
171
+ }
172
+
173
+ function sentence(row) {
174
+ const { a, b, name, aRole, bRole, order, finding } = row;
175
+ switch (finding) {
176
+ case "double-create":
177
+ return order === "unordered"
178
+ ? `${a} and ${b} both create ${name}; one task must create it and the other depend on it`
179
+ : `${a} and ${b} both create ${name}; they are already ordered, so drop ${name} from the creates of one (the later task edits it)`;
180
+ case "removes-used": {
181
+ const [rm, user, role] = aRole === "removes" ? [a, b, bRole] : [b, a, aRole];
182
+ return `${a} and ${b}: ${rm} removes ${name}, which ${user} ${role}`;
183
+ }
184
+ case "consumer-not-after-producer": {
185
+ const [maker, user, role] = PRODUCER.has(aRole) ? [a, b, bRole] : [b, a, aRole];
186
+ return `${a} and ${b}: ${user} ${role} ${name} but is not ordered after ${maker}, which ${PRODUCER.has(aRole) ? aRole : bRole} it`;
187
+ }
188
+ case "unordered-edit":
189
+ return `${a} and ${b} both edit ${name} with no dependsOn between them`;
190
+ default:
191
+ return null;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * @param {Array<{id: string, files?: string[], creates?: string[], removes?: string[],
197
+ * produces?: string[], consumes?: string[], dependsOn?: string[]}>} tasks
198
+ * @returns {{
199
+ * rows: Array<{a: string, b: string, kind: "file"|"symbol", name: string, aRole: string,
200
+ * bRole: string, order: "a-first"|"b-first"|"unordered", finding: string|null}>,
201
+ * contradictions: string[],
202
+ * warnings: string[],
203
+ * }}
204
+ */
205
+ export function planOverlaps(tasks) {
206
+ const ts = (Array.isArray(tasks) ? tasks : []).filter((t) => t && t.id != null);
207
+ const roles = ts.map(rolesOf);
208
+ const before = ancestors(ts);
209
+ const rows = [];
210
+ for (const kind of ["file", "symbol"]) {
211
+ for (let i = 0; i < ts.length; i++) {
212
+ for (let j = i + 1; j < ts.length; j++) {
213
+ const a = String(ts[i].id);
214
+ const b = String(ts[j].id);
215
+ const order = before(b).has(a) ? "a-first" : before(a).has(b) ? "b-first" : "unordered";
216
+ for (const [name, aRole] of roles[i][kind]) {
217
+ const bRole = roles[j][kind].get(name);
218
+ if (!bRole) continue;
219
+ rows.push({ a, b, kind, name, aRole, bRole, order, finding: judge(aRole, bRole, order) });
220
+ }
221
+ }
222
+ }
223
+ }
224
+ const contradictions = [];
225
+ const warnings = [];
226
+ for (const row of rows) {
227
+ if (!row.finding) continue;
228
+ (row.finding === "double-create" ? contradictions : warnings).push(sentence(row));
229
+ }
230
+ return { rows, contradictions, warnings };
231
+ }
@@ -51,22 +51,136 @@ const SYSTEM_PATH = /(?<![\w.~\-/])\/(?:Volumes|private|var|opt|tmp)(?![\w.-])\S
51
51
  /** Any user's home directory, reduced to ~ so the rest of the path still reads. */
52
52
  const HOME_PATH = /(?<![\w.~\-/])\/(?:Users|home)(?:\/[^\s/)"'\]]+)?(?![\w.-])/g;
53
53
 
54
+ /** The replacement chain, in order, with the kind each step counts as. */
55
+ const STEPS = [
56
+ [SCHEME_LINK, "[link]", "link"],
57
+ [WWW_LINK, "[link]", "link"],
58
+ [EMAIL, "[email]", "email"],
59
+ [BARE_LINK, "[link]", "link"],
60
+ [IPV4, "[host]", "host"],
61
+ [DOTTED_NAME, hostOrSelf, "host"],
62
+ [SYSTEM_PATH, "[path]", "path"],
63
+ [HOME_PATH, "~", "home-path"],
64
+ ];
65
+
66
+ /**
67
+ * scrub, plus how many replacements of each kind it made. A dotted name that
68
+ * hostOrSelf leaves alone is not counted.
69
+ *
70
+ * @returns {{text: string, counts: Record<string, number>}}
71
+ */
72
+ export function scrubCounted(text) {
73
+ const counts = {};
74
+ let out = String(text ?? "");
75
+ for (const [re, replacement, kind] of STEPS) {
76
+ out = out.replace(re, (match, ...groups) => {
77
+ const next = typeof replacement === "function" ? replacement(match, ...groups) : replacement;
78
+ if (next !== match) counts[kind] = (counts[kind] || 0) + 1;
79
+ return next;
80
+ });
81
+ }
82
+ return { text: out.trim(), counts };
83
+ }
84
+
54
85
  /**
55
86
  * Free text with URLs (with or without a scheme), e-mail addresses, IPv4
56
87
  * addresses, host names, absolute machine paths and home directories replaced
57
88
  * by markers.
58
89
  */
59
90
  export function scrub(text) {
60
- return String(text ?? "")
61
- .replace(SCHEME_LINK, "[link]")
62
- .replace(WWW_LINK, "[link]")
63
- .replace(EMAIL, "[email]")
64
- .replace(BARE_LINK, "[link]")
65
- .replace(IPV4, "[host]")
66
- .replace(DOTTED_NAME, hostOrSelf)
67
- .replace(SYSTEM_PATH, "[path]")
68
- .replace(HOME_PATH, "~")
69
- .trim();
91
+ return scrubCounted(text).text;
92
+ }
93
+
94
+ // --- credentials the provider rules (lib/redact.mjs) have no prefix for ------
95
+
96
+ /**
97
+ * A variable, flag or header name that holds a credential: any word ending in
98
+ * TOKEN, SECRET, PASSWORD, PASSWD, PWD or API_KEY (PGPASSWORD, MYSQL_PWD,
99
+ * myAppToken, db-password, x-api-key), or a separated word ending in KEY.
100
+ */
101
+ const SECRET_NAME = String.raw`[A-Za-z0-9_-]*(?:TOKEN|SECRET|PASSWORD|PASSWD|PWD|API[_-]?KEY)|(?:[A-Za-z0-9]+[_-])+KEY`;
102
+ const NOT_MARKER = String.raw`(?!\[redacted:)`;
103
+ const PEM_END = String.raw`-----END [A-Z ]*PRIVATE KEY-----`;
104
+ const JSON_KEY = String.raw`[A-Za-z0-9_-]*(?:password|passwd|secret|token|api[_-]?key|private[_-]?key|credentials?)`;
105
+
106
+ /**
107
+ * Credential shapes the provider rules miss, as [pattern, replacer, kind].
108
+ * Each keeps the name or header in front of the value so the line still says
109
+ * what was configured, and drops only the value. They run after
110
+ * lib/redact.mjs, so they also finish what its markers leave behind: the body
111
+ * of a private key whose BEGIN line it replaced, and the host and port after
112
+ * a credential URL.
113
+ */
114
+ const CREDENTIAL_STEPS = [
115
+ [
116
+ new RegExp(
117
+ String.raw`(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|\[redacted:private-key-block\])[\s\S]*?(?:${PEM_END}|$(?![\s\S]))`,
118
+ "g",
119
+ ),
120
+ () => "[redacted:private-key-block]",
121
+ "private-key-block",
122
+ ],
123
+ [
124
+ /\b[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:[^\s/@]+@\S*|\[redacted:url-with-credentials\]@\S*/gi,
125
+ () => "[redacted:url-with-credentials]",
126
+ "url-with-credentials",
127
+ ],
128
+ [/\bAT(?:ATT|CTT)[A-Za-z0-9_=-]{20,}/g, () => "[redacted:atlassian-token]", "atlassian-token"],
129
+ [
130
+ new RegExp(
131
+ String.raw`\b(Authorization\s*:\s*)${NOT_MARKER}(?:(?:Bearer|Basic|Digest|token)\s+)?${NOT_MARKER}[^\s"',;]+`,
132
+ "gi",
133
+ ),
134
+ (_, head) => `${head}[redacted:authorization-header]`,
135
+ "authorization-header",
136
+ ],
137
+ [
138
+ /\b(Bearer\s+)(?!\[redacted:)(?=[A-Za-z0-9._~+/=-]*\d)([A-Za-z0-9._~+/=-]{12,})/g,
139
+ (_, head) => `${head}[redacted:bearer-token]`,
140
+ "bearer-token",
141
+ ],
142
+ [
143
+ new RegExp(
144
+ String.raw`(\\?")(${JSON_KEY})\1(\s*:\s*)\1${NOT_MARKER}(?:\\[^"]|[^"\\])+?\1`,
145
+ "gi",
146
+ ),
147
+ (_, q, key, sep) => `${q}${key}${q}${sep}${q}[redacted:json-secret]${q}`,
148
+ "json-secret",
149
+ ],
150
+ [
151
+ new RegExp(
152
+ String.raw`(?<![\w-])(${SECRET_NAME})(\s*[=:]\s*)${NOT_MARKER}("[^"\n]+"|'[^'\n]+'|[^\s"',;]+)`,
153
+ "gi",
154
+ ),
155
+ (_, name, sep) => `${name}${sep}[redacted:env-secret]`,
156
+ "env-secret",
157
+ ],
158
+ [
159
+ /(\bcurl\b[^\n]*?\s(?:-u\s*|--user[=\s]\s*)(["']?)[^\s:"']+:)(?!\[redacted:)[^\s"']+\2/g,
160
+ (_, head, q) => `${head}[redacted:basic-auth]${q}`,
161
+ "basic-auth",
162
+ ],
163
+ [
164
+ /\b((?:mysql|mysqldump|mysqladmin|mariadb|mariadb-dump)\b[^\n]*?\s-p)(?!\s)(?!\[redacted:)\S+/g,
165
+ (_, head) => `${head}[redacted:password-flag]`,
166
+ "password-flag",
167
+ ],
168
+ [
169
+ /(--password[= ])(?!\[redacted:)\S+/g,
170
+ (_, head) => `${head}[redacted:password-flag]`,
171
+ "password-flag",
172
+ ],
173
+ ];
174
+
175
+ /**
176
+ * `text` with the credentials CREDENTIAL_STEPS recognise replaced by
177
+ * `[redacted:<kind>]` markers. Meant to run after lib/redact.mjs; markers it
178
+ * already wrote are never redacted a second time.
179
+ */
180
+ export function redactCredentials(text) {
181
+ let out = String(text ?? "");
182
+ for (const [re, replacer] of CREDENTIAL_STEPS) out = out.replace(re, replacer);
183
+ return out;
70
184
  }
71
185
 
72
186
  /** A copy of `obj` without `keys`. */
@@ -62,6 +62,7 @@ import { homedir } from "node:os";
62
62
  import { dirname, join } from "node:path";
63
63
  import { hostCommand } from "../lib/user-facing.mjs";
64
64
  import { lockTimeoutFrom, updateJsonFileSync } from "../lib/json-file-lock.mjs";
65
+ import { analyzeGraph, cyclicComponentOf } from "../lib/task-graph.mjs";
65
66
  import { resolveConstitution } from "./constitution.mjs";
66
67
  import { gateContext, recordQuietly } from "./gate-ledger.mjs";
67
68
  import {
@@ -170,6 +171,7 @@ export function identityLabel(prefix, docId, sourceIds) {
170
171
  * issueTree.cloneLinkType the link type between a story and its clones
171
172
  * issueTree.storyPanels story section panel colours, or false for none
172
173
  * issueTree.subtaskRolesDeclined the person chose no sub-tasks; do not ask again
174
+ * issueTree.dependencyLinkType the link type written for a story dependency
173
175
  */
174
176
  const ISSUE_TREE_SETTINGS = [
175
177
  "issueTree.enabled",
@@ -187,6 +189,7 @@ const ISSUE_TREE_SETTINGS = [
187
189
  "issueTree.cloneLinkType",
188
190
  "issueTree.storyPanels",
189
191
  "issueTree.subtaskRolesDeclined",
192
+ "issueTree.dependencyLinkType",
190
193
  ];
191
194
 
192
195
  function readPrefs() {
@@ -216,6 +219,100 @@ export function subtaskTypeFor(setting, role) {
216
219
  return null;
217
220
  }
218
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
+
219
316
  /** Every open placeholder that makes a document unfit to become work. */
220
317
  export function openItems(text) {
221
318
  const found = [];
@@ -268,10 +365,11 @@ export function coverageOf(defined, nodes, corporate = false) {
268
365
  };
269
366
  }
270
367
 
271
- export function plan(text, prefs, { pageUrl = null } = {}) {
272
- const fm = frontMatter(text);
368
+ export function plan(source, prefs, { pageUrl = null } = {}) {
369
+ const fm = frontMatter(source);
273
370
  const profile = (fm.profile || "global").toLowerCase();
274
371
  const corporate = profile === "corporate";
372
+ const { deps: reqDeps, stripped: text } = dependencyClauses(source, corporate);
275
373
  const secs = sections(text);
276
374
 
277
375
  const atomRe = corporate ? ATOM_CORP : ATOM_GLOBAL;
@@ -423,6 +521,30 @@ export function plan(text, prefs, { pageUrl = null } = {}) {
423
521
  }
424
522
 
425
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";
426
548
 
427
549
  return {
428
550
  document: {
@@ -447,12 +569,15 @@ export function plan(text, prefs, { pageUrl = null } = {}) {
447
569
  cloneLinkType: prefs.cloneLinkType ?? "Relates",
448
570
  storyPanels: prefs.storyPanels ?? null,
449
571
  subtaskRolesDeclined: prefs.subtaskRolesDeclined === true,
572
+ ...(dependencies || prefs.dependencyLinkType !== undefined
573
+ ? { dependencyLinkType: linkType }
574
+ : {}),
450
575
  },
451
576
  nodes: stories,
452
- writeCount: stories.reduce(
453
- (n, s) => n + 1 + s.subtasks.length + 2 * (s.clones?.length || 0),
454
- 0,
455
- ),
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),
456
581
  };
457
582
  }
458
583
 
@@ -461,7 +586,9 @@ export function plan(text, prefs, { pageUrl = null } = {}) {
461
586
  *
462
587
  * @returns {object} a spec-consistency-gate result
463
588
  */
464
- function consistencyOf(text, nodes) {
589
+ function consistencyOf(source, nodes) {
590
+ const corporate = (frontMatter(source).profile || "").toLowerCase() === "corporate";
591
+ const text = dependencyClauses(source, corporate).stripped;
465
592
  const constitution = resolveConstitution({
466
593
  repo: dirname(file),
467
594
  feature: frontMatter(text).feature || null,
@@ -606,12 +733,18 @@ async function main() {
606
733
  );
607
734
  if (c.uncovered.length) process.stdout.write(` not in any story: ${c.uncovered.join(", ")}\n`);
608
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`);
609
737
  const k = result.consistency;
610
738
  process.stdout.write(
611
739
  `consistency: ${k.verdict} (${k.advisory ? "advisory, " : ""}${consistencyDetail(k)})\n`,
612
740
  );
613
741
  for (const line of describe(k)) process.stdout.write(`${line}\n`);
614
- 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
+ );
615
748
  for (const s of result.nodes) {
616
749
  process.stdout.write(` ${result.prefsUsed.storyIssueType} ${s.title}\n`);
617
750
  process.stdout.write(
@@ -635,6 +768,18 @@ async function main() {
635
768
  );
636
769
  }
637
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
+ }
638
783
  // Every field's provenance, so a wrong setting is visible here rather than in
639
784
  // Jira after the writes have happened.
640
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)