@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.
- package/CHANGELOG.md +22 -0
- package/README.md +7 -6
- package/README.tr.md +6 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +3 -3
- package/install/catalog-history.json +1 -1
- package/install/templates/claude-hooks.json +14 -1
- package/manifest.json +75 -54
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/analysis-jira/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/postmortem/SKILL.md +78 -0
- package/pipeline/commands/multi-agent/setup/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/lib/analysis-jira-write.sh +70 -0
- package/pipeline/lib/decisions.mjs +130 -0
- package/pipeline/lib/plan-todos.sh +156 -33
- package/pipeline/lib/task-graph.mjs +446 -0
- package/pipeline/lib/tdd-evidence.mjs +579 -0
- package/pipeline/multi-agent-refs/analysis-template-corporate.md +2 -0
- package/pipeline/multi-agent-refs/analysis-template.md +4 -2
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
- package/pipeline/multi-agent-refs/features/analysis-jira.md +42 -0
- package/pipeline/multi-agent-refs/features/autopilot-decisions.md +34 -0
- package/pipeline/multi-agent-refs/features/model-fallback.md +4 -2
- package/pipeline/multi-agent-refs/features/mutation-probe.md +58 -0
- package/pipeline/multi-agent-refs/features/plan-checks.md +67 -0
- package/pipeline/multi-agent-refs/features/plan-todos.md +40 -1
- package/pipeline/multi-agent-refs/features/rework-escalation.md +49 -0
- package/pipeline/multi-agent-refs/features/scope-check.md +1 -0
- package/pipeline/multi-agent-refs/help/en.md +1 -0
- package/pipeline/multi-agent-refs/help/tr.md +1 -0
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -3
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +5 -7
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +13 -11
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +3 -3
- package/pipeline/multi-agent-refs/picker-contract.md +3 -2
- package/pipeline/multi-agent-refs/response-shape.md +65 -0
- package/pipeline/rules/response-shape.md +18 -0
- package/pipeline/schemas/agent-state.schema.json +31 -1
- package/pipeline/schemas/plan-todos.schema.json +101 -0
- package/pipeline/schemas/planning-output.schema.json +46 -0
- package/pipeline/schemas/prefs.schema.json +96 -0
- package/pipeline/scripts/_jsonl.mjs +151 -0
- package/pipeline/scripts/_plan-overlap.mjs +231 -0
- package/pipeline/scripts/_redact.mjs +124 -10
- package/pipeline/scripts/analysis-story-tree.mjs +153 -8
- package/pipeline/scripts/autopilot-publish.mjs +12 -1
- package/pipeline/scripts/cost-analyze.mjs +6 -24
- package/pipeline/scripts/decisions-log.mjs +118 -0
- package/pipeline/scripts/edit-syntax-gate.mjs +240 -0
- package/pipeline/scripts/edit-syntax-gate.sh +29 -0
- package/pipeline/scripts/find-polluter.mjs +281 -0
- package/pipeline/scripts/gate-ledger.mjs +46 -13
- package/pipeline/scripts/mutation-probe.mjs +484 -0
- package/pipeline/scripts/plan-complexity.mjs +259 -0
- package/pipeline/scripts/review-decision-gate.mjs +33 -1
- package/pipeline/scripts/rework-escalation.mjs +264 -0
- package/pipeline/scripts/test-strength.mjs +5 -3
- package/pipeline/scripts/transcript-index.mjs +950 -0
- package/pipeline/scripts/validate-planning.mjs +48 -6
- package/pipeline/skills/.skill-manifest.json +11 -7
- package/pipeline/skills/.skills-index.json +12 -1
- package/pipeline/skills/shared/README.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-analysis-jira/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-postmortem/SKILL.md +66 -0
- package/pipeline/skills/shared/core/multi-agent-setup/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +15 -0
- package/pipeline/skills/shared/external/app-store-optimization/evals/evals.json +13 -0
- package/pipeline/skills/shared/external/debugging-strategies/SKILL.md +8 -0
- 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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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(
|
|
272
|
-
const fm = frontMatter(
|
|
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
|
-
|
|
453
|
-
|
|
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(
|
|
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
|
-
|
|
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)
|