dev-loops 0.4.0 → 0.6.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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/commands/auto.md +7 -0
- package/.claude/commands/continue.md +15 -0
- package/.claude/commands/info.md +7 -0
- package/.claude/commands/start-spike.md +16 -0
- package/.claude/commands/start.md +7 -0
- package/.claude/commands/status.md +6 -0
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/skills/dev-loop/SKILL.md +21 -6
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +35 -0
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/.claude/skills/local-implementation/SKILL.md +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +21 -1
- package/agents/dev-loop.agent.md +8 -1
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +2 -0
- package/extension/index.ts +10 -1
- package/extension/presentation.ts +15 -0
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +8 -3
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/comment-issue.mjs +181 -0
- package/scripts/github/fetch-ci-logs.mjs +215 -0
- package/scripts/github/list-issues.mjs +191 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/copilot-pr-handoff.mjs +21 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/pr-runner-coordination.mjs +20 -4
- package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-conductor-cycle.mjs +5 -0
- package/scripts/loop/run-watch-cycle.mjs +77 -3
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +12 -2
- package/scripts/projects/list-queue-items.mjs +12 -2
- package/scripts/projects/move-queue-item.mjs +12 -2
- package/scripts/projects/resolve-active-board-item.mjs +193 -0
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/scaffold-spike-file.mjs +183 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/scripts/release/extract-changelog-section.mjs +111 -0
- package/skills/dev-loop/SKILL.md +24 -2
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +35 -0
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/skills/local-implementation/SKILL.md +1 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Shared --jq / --silent output helper for the JSON-emitting dev-loops scripts
|
|
2
|
+
// (issue #981, subsumes #963). One helper applied uniformly so the loop never
|
|
3
|
+
// falls back to `gh api | python3` or inline `node -e` to read tool JSON.
|
|
4
|
+
//
|
|
5
|
+
// Convention (also documented in skills/dev-loop/SKILL.md):
|
|
6
|
+
// prefer the dev-loops subcommand
|
|
7
|
+
// -> `--silent`/`-s` for a yes/no status check (zero output, exit code only)
|
|
8
|
+
// -> `--jq <filter>` to extract a field from the result
|
|
9
|
+
// -> `gh --jq` on a raw gh call
|
|
10
|
+
// -> NEVER `| python3` or `node -e`.
|
|
11
|
+
//
|
|
12
|
+
// jq subset (NOT full jq — fails closed clearly on anything outside this):
|
|
13
|
+
// . identity
|
|
14
|
+
// .field field access (also .a.b.c chains)
|
|
15
|
+
// .field[] iterate an array field
|
|
16
|
+
// .[] iterate an array / object values
|
|
17
|
+
// .[N] / .field[N] index access
|
|
18
|
+
// a | b pipe
|
|
19
|
+
// select(<pred>) filter the current value(s) by a predicate
|
|
20
|
+
// length count of array/string/object
|
|
21
|
+
// keys sorted object keys (or array indices)
|
|
22
|
+
// predicates inside select: .x == <lit>, !=, <, <=, >, >=, and a bare path
|
|
23
|
+
// (truthy test). <lit> is a JSON string/number/true/false/null.
|
|
24
|
+
// Anything else -> JqFilterError (fail closed).
|
|
25
|
+
|
|
26
|
+
export class JqFilterError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "JqFilterError";
|
|
30
|
+
this.isJqFilterError = true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A jq "stream" is an array of values (jq filters are many-valued). We thread an
|
|
35
|
+
// array through each pipe stage. Identity is [value].
|
|
36
|
+
|
|
37
|
+
function parseLiteral(token) {
|
|
38
|
+
const trimmed = token.trim();
|
|
39
|
+
if (trimmed === "true") return { ok: true, value: true };
|
|
40
|
+
if (trimmed === "false") return { ok: true, value: false };
|
|
41
|
+
if (trimmed === "null") return { ok: true, value: null };
|
|
42
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return { ok: true, value: Number(trimmed) };
|
|
43
|
+
if (
|
|
44
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) ||
|
|
45
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
|
46
|
+
) {
|
|
47
|
+
return { ok: true, value: trimmed.slice(1, -1) };
|
|
48
|
+
}
|
|
49
|
+
return { ok: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Resolve a single-value path expression like `.`, `.a.b`, `.a[0].b`, `.[2]`.
|
|
53
|
+
// Returns undefined for a missing path (jq yields null; we map missing->undefined
|
|
54
|
+
// then callers coerce). Throws JqFilterError on malformed path syntax.
|
|
55
|
+
function resolvePath(value, path) {
|
|
56
|
+
const trimmed = path.trim();
|
|
57
|
+
if (trimmed === "") {
|
|
58
|
+
// An empty path is never valid jq (no empty filter). Fail closed so
|
|
59
|
+
// malformed predicates like `select()` or `==5` don't pass as identity.
|
|
60
|
+
throw new JqFilterError("Empty path expression");
|
|
61
|
+
}
|
|
62
|
+
if (trimmed === ".") return value;
|
|
63
|
+
if (!trimmed.startsWith(".")) {
|
|
64
|
+
throw new JqFilterError(`Unsupported path (must start with '.'): ${path}`);
|
|
65
|
+
}
|
|
66
|
+
// Tokenize into .field and [index] steps.
|
|
67
|
+
const stepRe = /\.([A-Za-z_][A-Za-z0-9_]*)|\[(\d+)\]/g;
|
|
68
|
+
let cursor = value;
|
|
69
|
+
// Root index access (`.[N]`) starts with a leading '.' that is not part of any
|
|
70
|
+
// step token; consume it so the first `[N]` match aligns at the cursor.
|
|
71
|
+
let lastIndex = trimmed.startsWith(".[") ? 1 : 0;
|
|
72
|
+
stepRe.lastIndex = lastIndex;
|
|
73
|
+
let match;
|
|
74
|
+
let consumedAny = false;
|
|
75
|
+
while ((match = stepRe.exec(trimmed)) !== null) {
|
|
76
|
+
if (match.index !== lastIndex) {
|
|
77
|
+
throw new JqFilterError(`Unsupported path syntax near: ${trimmed.slice(lastIndex)}`);
|
|
78
|
+
}
|
|
79
|
+
lastIndex = stepRe.lastIndex;
|
|
80
|
+
consumedAny = true;
|
|
81
|
+
if (cursor === undefined || cursor === null) {
|
|
82
|
+
cursor = undefined;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (match[1] !== undefined) {
|
|
86
|
+
cursor = typeof cursor === "object" && !Array.isArray(cursor) ? cursor[match[1]] : undefined;
|
|
87
|
+
} else {
|
|
88
|
+
const idx = Number(match[2]);
|
|
89
|
+
cursor = Array.isArray(cursor) ? cursor[idx] : undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!consumedAny || lastIndex !== trimmed.length) {
|
|
93
|
+
throw new JqFilterError(`Unsupported path syntax: ${path}`);
|
|
94
|
+
}
|
|
95
|
+
return cursor;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function evaluatePredicate(value, predicate) {
|
|
99
|
+
const cmpRe = /^(.*?)(==|!=|<=|>=|<|>)(.*)$/;
|
|
100
|
+
const m = predicate.match(cmpRe);
|
|
101
|
+
if (!m) {
|
|
102
|
+
// Bare path => truthy test.
|
|
103
|
+
const resolved = resolvePath(value, predicate);
|
|
104
|
+
return resolved !== undefined && resolved !== null && resolved !== false;
|
|
105
|
+
}
|
|
106
|
+
const [, leftRaw, op, rightRaw] = m;
|
|
107
|
+
// jq maps a missing path to null; resolvePath yields undefined -> normalize.
|
|
108
|
+
const resolvedLeft = resolvePath(value, leftRaw);
|
|
109
|
+
const left = resolvedLeft === undefined ? null : resolvedLeft;
|
|
110
|
+
const lit = parseLiteral(rightRaw);
|
|
111
|
+
if (!lit.ok) {
|
|
112
|
+
throw new JqFilterError(`Unsupported predicate operand: ${rightRaw.trim()}`);
|
|
113
|
+
}
|
|
114
|
+
const right = lit.value;
|
|
115
|
+
switch (op) {
|
|
116
|
+
case "==":
|
|
117
|
+
return left === right;
|
|
118
|
+
case "!=":
|
|
119
|
+
return left !== right;
|
|
120
|
+
case "<":
|
|
121
|
+
case "<=":
|
|
122
|
+
case ">":
|
|
123
|
+
case ">=": {
|
|
124
|
+
// jq never coerces across types (number < string always); JS would. Fail
|
|
125
|
+
// closed when operand types differ so numeric-string fields don't misfire.
|
|
126
|
+
if (typeof left !== typeof right) {
|
|
127
|
+
throw new JqFilterError(`Cannot order-compare ${typeof left} and ${typeof right}`);
|
|
128
|
+
}
|
|
129
|
+
if (op === "<") return left < right;
|
|
130
|
+
if (op === "<=") return left <= right;
|
|
131
|
+
if (op === ">") return left > right;
|
|
132
|
+
return left >= right;
|
|
133
|
+
}
|
|
134
|
+
default:
|
|
135
|
+
throw new JqFilterError(`Unsupported operator: ${op}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Apply one pipe stage to a stream (array of values), returning a new stream.
|
|
140
|
+
function applyStage(stream, rawStage) {
|
|
141
|
+
const stage = rawStage.trim();
|
|
142
|
+
if (stage === "") {
|
|
143
|
+
throw new JqFilterError("Empty filter stage");
|
|
144
|
+
}
|
|
145
|
+
if (stage === "length") {
|
|
146
|
+
return stream.map((v) => {
|
|
147
|
+
if (Array.isArray(v) || typeof v === "string") return v.length;
|
|
148
|
+
if (v && typeof v === "object") return Object.keys(v).length;
|
|
149
|
+
throw new JqFilterError("length: input must be array, string, or object");
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (stage === "keys") {
|
|
153
|
+
return stream.map((v) => {
|
|
154
|
+
if (Array.isArray(v)) return v.map((_, i) => i);
|
|
155
|
+
if (v && typeof v === "object") return Object.keys(v).sort();
|
|
156
|
+
throw new JqFilterError("keys: input must be array or object");
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const selectMatch = stage.match(/^select\((.*)\)$/);
|
|
160
|
+
if (selectMatch) {
|
|
161
|
+
return stream.filter((v) => evaluatePredicate(v, selectMatch[1]));
|
|
162
|
+
}
|
|
163
|
+
// Top-level comparison expression yields a boolean per input (e.g. .x=="y").
|
|
164
|
+
if (/(==|!=|<=|>=|<|>)/.test(stage)) {
|
|
165
|
+
return stream.map((v) => evaluatePredicate(v, stage));
|
|
166
|
+
}
|
|
167
|
+
// Iteration: a path ending in [] (incl. bare .[]).
|
|
168
|
+
if (stage === ".[]") {
|
|
169
|
+
return stream.flatMap((v) => iterate(v));
|
|
170
|
+
}
|
|
171
|
+
if (stage.endsWith("[]")) {
|
|
172
|
+
const base = stage.slice(0, -2);
|
|
173
|
+
return stream.flatMap((v) => iterate(resolvePath(v, base)));
|
|
174
|
+
}
|
|
175
|
+
// Plain path access (single value per input).
|
|
176
|
+
return stream.map((v) => resolvePath(v, stage));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function iterate(v) {
|
|
180
|
+
if (Array.isArray(v)) return v;
|
|
181
|
+
if (v && typeof v === "object") return Object.values(v);
|
|
182
|
+
throw new JqFilterError("Cannot iterate (.[]) over a non-array/object value");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Split on top-level pipes, ignoring | inside quotes or parentheses.
|
|
186
|
+
function splitPipes(filter) {
|
|
187
|
+
const parts = [];
|
|
188
|
+
let depth = 0;
|
|
189
|
+
let quote = null;
|
|
190
|
+
let current = "";
|
|
191
|
+
for (const ch of filter) {
|
|
192
|
+
if (quote) {
|
|
193
|
+
current += ch;
|
|
194
|
+
if (ch === quote) quote = null;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (ch === '"' || ch === "'") {
|
|
198
|
+
quote = ch;
|
|
199
|
+
current += ch;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (ch === "(") depth += 1;
|
|
203
|
+
if (ch === ")") depth -= 1;
|
|
204
|
+
if (ch === "|" && depth === 0) {
|
|
205
|
+
parts.push(current);
|
|
206
|
+
current = "";
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
current += ch;
|
|
210
|
+
}
|
|
211
|
+
parts.push(current);
|
|
212
|
+
return parts;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Evaluate a jq-subset filter against a value. Returns the jq "stream" (array of
|
|
216
|
+
// result values). Throws JqFilterError on unsupported syntax.
|
|
217
|
+
export function evaluateJqFilter(value, filter) {
|
|
218
|
+
if (typeof filter !== "string" || filter.trim() === "") {
|
|
219
|
+
throw new JqFilterError("Empty --jq filter");
|
|
220
|
+
}
|
|
221
|
+
let stream = [value];
|
|
222
|
+
for (const stage of splitPipes(filter)) {
|
|
223
|
+
stream = applyStage(stream, stage);
|
|
224
|
+
}
|
|
225
|
+
return stream;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// jq truthiness for --silent: matches `jq -e` exit semantics — the status is
|
|
229
|
+
// based on the LAST output value (empty output is falsy; a value is truthy
|
|
230
|
+
// unless it is null or false).
|
|
231
|
+
function streamIsTruthy(stream) {
|
|
232
|
+
if (stream.length === 0) return false;
|
|
233
|
+
const last = stream[stream.length - 1];
|
|
234
|
+
return last !== null && last !== false && last !== undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderJqStream(stream) {
|
|
238
|
+
// jq prints each value on its own line; single value => just that value.
|
|
239
|
+
return stream
|
|
240
|
+
.map((v) => (typeof v === "string" ? v : JSON.stringify(v ?? null)))
|
|
241
|
+
.join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Standard option block to merge into a parseArgs `options` map so every script
|
|
245
|
+
// exposes the same flags.
|
|
246
|
+
export const JQ_OUTPUT_PARSE_OPTIONS = {
|
|
247
|
+
jq: { type: "string" },
|
|
248
|
+
silent: { type: "boolean", short: "s" },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Shared USAGE fragment so every script documents the flags identically.
|
|
252
|
+
export const JQ_OUTPUT_USAGE = `Output filtering:
|
|
253
|
+
--jq <filter> Apply a jq-subset filter to the result and print it
|
|
254
|
+
(field access, .[]/.[N], pipes, select(...), ==,!=,<,<=,>,>=, length, keys).
|
|
255
|
+
Invalid filter fails closed (stderr + exit 2).
|
|
256
|
+
--silent, -s Suppress stdout; map result to exit code only
|
|
257
|
+
(0 = pass/truthy, 1 = fail/falsy). Composes with --jq as a predicate.`;
|
|
258
|
+
|
|
259
|
+
// Apply --jq / --silent to a result object and emit. Returns the exit code the
|
|
260
|
+
// CLI should use (0 success / truthy, 1 falsy or non-ok, 2 invalid filter).
|
|
261
|
+
// Without jq/silent the result is printed verbatim as JSON (unchanged shape).
|
|
262
|
+
//
|
|
263
|
+
// ok: success of the underlying result (defaults to result.ok !== false).
|
|
264
|
+
export function emitResult(
|
|
265
|
+
result,
|
|
266
|
+
{
|
|
267
|
+
jq = undefined,
|
|
268
|
+
silent = false,
|
|
269
|
+
stdout = process.stdout,
|
|
270
|
+
stderr = process.stderr,
|
|
271
|
+
ok = result?.ok !== false,
|
|
272
|
+
} = {},
|
|
273
|
+
) {
|
|
274
|
+
if (jq !== undefined) {
|
|
275
|
+
let stream;
|
|
276
|
+
try {
|
|
277
|
+
stream = evaluateJqFilter(result, jq);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error instanceof JqFilterError) {
|
|
280
|
+
// Fail closed, distinct from a clean "predicate false". Exit 2.
|
|
281
|
+
stderr.write(`${JSON.stringify({ ok: false, error: `--jq: ${error.message}` })}\n`);
|
|
282
|
+
return 2;
|
|
283
|
+
}
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
if (silent) {
|
|
287
|
+
return streamIsTruthy(stream) ? 0 : 1;
|
|
288
|
+
}
|
|
289
|
+
stdout.write(`${renderJqStream(stream)}\n`);
|
|
290
|
+
return ok ? 0 : 1;
|
|
291
|
+
}
|
|
292
|
+
if (silent) {
|
|
293
|
+
return ok ? 0 : 1;
|
|
294
|
+
}
|
|
295
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
296
|
+
return ok ? 0 : 1;
|
|
297
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-retro-tooling
|
|
4
|
+
*
|
|
5
|
+
* Deterministic, dependency-free verifier for the internal-tooling-only rule
|
|
6
|
+
* (issue #982). Given a transcript of the shell commands the agent ran during a
|
|
7
|
+
* dev-loop run, it detects AGENT-LEVEL raw escape-hatch calls that should have
|
|
8
|
+
* gone through internal dev-loops tooling:
|
|
9
|
+
* - `gh ...` (incl. `gh api`, `gh ... --jq`)
|
|
10
|
+
* - `python` / `python3`
|
|
11
|
+
* - `node -e` / `node --eval`
|
|
12
|
+
*
|
|
13
|
+
* These three are the same breach: reaching past the dev-loops tooling to read
|
|
14
|
+
* or parse tool output with a raw call. The verifier returns the list of
|
|
15
|
+
* violations so the retrospective author can record them in
|
|
16
|
+
* `behavioralReview.rawCallViolations` and set `behavioralReview.internalToolingOnly`.
|
|
17
|
+
*
|
|
18
|
+
* Input source (how the harness feeds it):
|
|
19
|
+
* Newline-delimited shell commands — one top-level command per line — as the
|
|
20
|
+
* agent actually invoked them. Pass via a file (`--transcript <path>`) or pipe
|
|
21
|
+
* on stdin. Each line is one Bash-tool invocation's command string.
|
|
22
|
+
*
|
|
23
|
+
* ALLOWED (NOT a violation):
|
|
24
|
+
* - dev-loops subcommands and `node scripts/....mjs` invocations. Those scripts
|
|
25
|
+
* legitimately call gh/GraphQL/etc. internally — that IS the tooling.
|
|
26
|
+
* - A small explicit allowlist of write-ops that have no internal wrapper today:
|
|
27
|
+
* `gh pr merge`, `gh pr ready`, `gh issue create`, `gh issue edit`. These are
|
|
28
|
+
* recorded as `allowedWriteOps` rather than violations so the gate is not
|
|
29
|
+
* blocked forever on an unavoidable gap. Document/close the gap with a wrapper
|
|
30
|
+
* to remove them from the allowlist.
|
|
31
|
+
*
|
|
32
|
+
* VIOLATION (agent-level raw call):
|
|
33
|
+
* - `gh ...` at the start of a command segment (start of line, or after
|
|
34
|
+
* `&&`, `||`, `|`, `;`) that is not in the write-op allowlist.
|
|
35
|
+
* - `python` / `python3` at the start of a command segment.
|
|
36
|
+
* - `node -e` / `node --eval` (inline eval) at the start of a command segment.
|
|
37
|
+
*
|
|
38
|
+
* Head normalization (before classifying a segment, fail-closed):
|
|
39
|
+
* - strips leading `NAME=value ` env-assignment prefixes (`GH_TOKEN=x gh api`)
|
|
40
|
+
* - strips a leading wrapper binary from {sudo, env, xargs, time, nice, command}
|
|
41
|
+
* and re-classifies the remainder (`sudo gh api`, `xargs gh api`, `env gh api`)
|
|
42
|
+
* - reduces a path-prefixed binary to its basename (`./node_modules/.bin/gh`,
|
|
43
|
+
* `/usr/bin/python3`) so the real tool is matched.
|
|
44
|
+
*
|
|
45
|
+
* Known limitations (honest):
|
|
46
|
+
* - Segment splitting is a simple top-level split on `&&`, `||`, `|`, `;` and
|
|
47
|
+
* does NOT fully parse shell quoting/substitution. A `;`/`|` inside a quoted
|
|
48
|
+
* argument can over-report (flags a harmless inner token). It catches the
|
|
49
|
+
* common raw-call forms (incl. the env/wrapper/path-prefixed ones above), but
|
|
50
|
+
* deeply obfuscated calls — command substitution `$(...)`, aliases, `eval` —
|
|
51
|
+
* may evade it. Prefer single-line, single-purpose commands in transcripts.
|
|
52
|
+
*/
|
|
53
|
+
import { readFileSync } from "node:fs";
|
|
54
|
+
import process from "node:process";
|
|
55
|
+
import { parseArgs } from "node:util";
|
|
56
|
+
import { isDirectCliRun } from "@dev-loops/core/cli/helpers";
|
|
57
|
+
|
|
58
|
+
const USAGE = `Usage: node scripts/loop/check-retro-tooling.mjs [--transcript <path>] [--json]
|
|
59
|
+
|
|
60
|
+
Reads a newline-delimited transcript of agent shell commands (from --transcript
|
|
61
|
+
or stdin) and reports agent-level raw gh/python/node -e calls (internal-tooling-only
|
|
62
|
+
rule, issue #982).
|
|
63
|
+
|
|
64
|
+
Options:
|
|
65
|
+
--transcript <path> File of newline-delimited commands (default: read stdin)
|
|
66
|
+
--json Emit machine-readable JSON (default: human summary)
|
|
67
|
+
|
|
68
|
+
Exit codes:
|
|
69
|
+
0 No violations
|
|
70
|
+
1 One or more violations found
|
|
71
|
+
2 Argument/runtime error`;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Write-ops that currently have no internal dev-loops wrapper. Recorded
|
|
75
|
+
* distinctly so the gate is not blocked forever on an unavoidable gap.
|
|
76
|
+
* Keep this set SMALL and explicit; remove an entry once a wrapper exists.
|
|
77
|
+
* @type {ReadonlyArray<RegExp>}
|
|
78
|
+
*/
|
|
79
|
+
const ALLOWED_WRITE_OPS = Object.freeze([
|
|
80
|
+
/^gh\s+pr\s+merge\b/,
|
|
81
|
+
/^gh\s+pr\s+ready\b/,
|
|
82
|
+
/^gh\s+issue\s+create\b/,
|
|
83
|
+
/^gh\s+issue\s+edit\b/,
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/** Split a command line into top-level segments on &&, ||, |, ;. */
|
|
87
|
+
function splitSegments(line) {
|
|
88
|
+
return line
|
|
89
|
+
.split(/&&|\|\||\||;/g)
|
|
90
|
+
.map((s) => s.trim())
|
|
91
|
+
.filter((s) => s.length > 0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Wrapper binaries whose first argument is the real command to classify. */
|
|
95
|
+
const WRAPPER_BINARIES = new Set(["sudo", "env", "xargs", "time", "nice", "command"]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Strip prefixes that hide the real command head, fail-closed (over-report is
|
|
99
|
+
* fine, under-report is the bug):
|
|
100
|
+
* - leading `NAME=value ` env-assignment tokens (any number)
|
|
101
|
+
* - leading wrapper binaries (`sudo`, `env`, `xargs`, ...) — recurse on the rest
|
|
102
|
+
* - a path-prefixed binary (`./x/gh`, `/usr/bin/python3`) → reduce head to its basename
|
|
103
|
+
* Returns the segment with a bare, classifiable command head.
|
|
104
|
+
*/
|
|
105
|
+
function normalizeSegmentHead(segment) {
|
|
106
|
+
let s = segment.trim();
|
|
107
|
+
// (a) strip leading env-assignment tokens: NAME=value. The value may be
|
|
108
|
+
// unquoted (`X=1`) or a single/double-quoted string that itself contains
|
|
109
|
+
// spaces (`X="a b"`); match the quoted form first so the space inside the
|
|
110
|
+
// quotes is not mistaken for the token separator (would under-report).
|
|
111
|
+
const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+(?=\S)/;
|
|
112
|
+
while (ENV_ASSIGN.test(s)) {
|
|
113
|
+
s = s.replace(ENV_ASSIGN, "");
|
|
114
|
+
}
|
|
115
|
+
// (c) reduce a path-prefixed head to its basename so `.../gh` → `gh`
|
|
116
|
+
s = s.replace(/^(\S*\/)([^/\s]+)/, "$2");
|
|
117
|
+
// (b) strip a leading wrapper binary, then re-normalize the remainder
|
|
118
|
+
const head = s.split(/\s+/, 1)[0];
|
|
119
|
+
if (WRAPPER_BINARIES.has(head)) {
|
|
120
|
+
const rest = s.slice(head.length).trim();
|
|
121
|
+
if (rest.length > 0) return normalizeSegmentHead(rest);
|
|
122
|
+
}
|
|
123
|
+
return s;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Classify a single command segment.
|
|
128
|
+
* @returns {{ kind: "violation"|"allowedWriteOp"|"clean", tool?: string }}
|
|
129
|
+
*/
|
|
130
|
+
function classifySegment(rawSegment) {
|
|
131
|
+
const segment = normalizeSegmentHead(rawSegment);
|
|
132
|
+
// `node scripts/....mjs` (or any script path) is allowed tooling; only inline
|
|
133
|
+
// eval forms are violations. Check node first so script invocations pass.
|
|
134
|
+
if (/^node\b/.test(segment)) {
|
|
135
|
+
// Inline eval (`-e`/`--eval`) is a violation only before the script path:
|
|
136
|
+
// once a non-flag token (the script) appears, a later `--eval` is just a
|
|
137
|
+
// script argument, not Node's inline-eval mode (avoids false positives).
|
|
138
|
+
const tokens = segment.split(/\s+/).slice(1);
|
|
139
|
+
for (const tok of tokens) {
|
|
140
|
+
if (tok === "-e" || tok === "--eval" || /^--eval=/.test(tok)) {
|
|
141
|
+
return { kind: "violation", tool: "node -e" };
|
|
142
|
+
}
|
|
143
|
+
if (!tok.startsWith("-")) break; // script path reached
|
|
144
|
+
}
|
|
145
|
+
return { kind: "clean" };
|
|
146
|
+
}
|
|
147
|
+
if (/^gh\b/.test(segment)) {
|
|
148
|
+
if (ALLOWED_WRITE_OPS.some((re) => re.test(segment))) {
|
|
149
|
+
return { kind: "allowedWriteOp", tool: "gh" };
|
|
150
|
+
}
|
|
151
|
+
return { kind: "violation", tool: "gh" };
|
|
152
|
+
}
|
|
153
|
+
if (/^python3?\b/.test(segment)) {
|
|
154
|
+
return { kind: "violation", tool: /^python3\b/.test(segment) ? "python3" : "python" };
|
|
155
|
+
}
|
|
156
|
+
return { kind: "clean" };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Analyze a transcript of newline-delimited shell commands.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} transcript
|
|
163
|
+
* @returns {{ violations: string[], allowedWriteOps: string[], internalToolingOnly: boolean }}
|
|
164
|
+
*/
|
|
165
|
+
export function analyzeTranscript(transcript) {
|
|
166
|
+
const violations = [];
|
|
167
|
+
const allowedWriteOps = [];
|
|
168
|
+
const lines = String(transcript ?? "").split(/\r?\n/);
|
|
169
|
+
for (const raw of lines) {
|
|
170
|
+
const line = raw.trim();
|
|
171
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
172
|
+
for (const segment of splitSegments(line)) {
|
|
173
|
+
const result = classifySegment(segment);
|
|
174
|
+
if (result.kind === "violation") {
|
|
175
|
+
violations.push(`${result.tool}: ${segment}`);
|
|
176
|
+
} else if (result.kind === "allowedWriteOp") {
|
|
177
|
+
allowedWriteOps.push(segment);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { violations, allowedWriteOps, internalToolingOnly: violations.length === 0 };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function parseCliArgs(argv) {
|
|
185
|
+
let values;
|
|
186
|
+
try {
|
|
187
|
+
({ values } = parseArgs({
|
|
188
|
+
args: argv,
|
|
189
|
+
options: {
|
|
190
|
+
transcript: { type: "string" },
|
|
191
|
+
json: { type: "boolean" },
|
|
192
|
+
help: { type: "boolean", short: "h" },
|
|
193
|
+
},
|
|
194
|
+
strict: true,
|
|
195
|
+
allowPositionals: false,
|
|
196
|
+
}));
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw Object.assign(new Error(err instanceof Error ? err.message : String(err)), { usage: USAGE });
|
|
199
|
+
}
|
|
200
|
+
return values;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function readStdin() {
|
|
204
|
+
try {
|
|
205
|
+
return readFileSync(0, "utf8");
|
|
206
|
+
} catch {
|
|
207
|
+
return "";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function run(argv, { stdout, stderr }) {
|
|
212
|
+
const values = parseCliArgs(argv);
|
|
213
|
+
if (values.help) {
|
|
214
|
+
stdout.write(`${USAGE}\n`);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
const transcript = values.transcript
|
|
218
|
+
? readFileSync(values.transcript, "utf8")
|
|
219
|
+
: readStdin();
|
|
220
|
+
|
|
221
|
+
const { violations, allowedWriteOps, internalToolingOnly } = analyzeTranscript(transcript);
|
|
222
|
+
|
|
223
|
+
if (values.json) {
|
|
224
|
+
stdout.write(`${JSON.stringify({ ok: internalToolingOnly, internalToolingOnly, rawCallViolations: violations, allowedWriteOps })}\n`);
|
|
225
|
+
} else if (internalToolingOnly) {
|
|
226
|
+
stdout.write(`internalToolingOnly: true — no agent-level raw gh/python/node -e calls found.\n`);
|
|
227
|
+
if (allowedWriteOps.length > 0) {
|
|
228
|
+
stdout.write(`Allowed write-ops (no wrapper yet): ${allowedWriteOps.length}\n`);
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
stderr.write(`internalToolingOnly: false — ${violations.length} raw-call violation(s):\n`);
|
|
232
|
+
for (const v of violations) stderr.write(` - ${v}\n`);
|
|
233
|
+
}
|
|
234
|
+
return internalToolingOnly ? 0 : 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
238
|
+
run(process.argv.slice(2), { stdout: process.stdout, stderr: process.stderr }).then(
|
|
239
|
+
(code) => { process.exitCode = typeof code === "number" ? code : 0; },
|
|
240
|
+
(error) => {
|
|
241
|
+
const usage = error instanceof Error && typeof error.usage === "string" ? `\n${error.usage}` : "";
|
|
242
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}${usage}\n`);
|
|
243
|
+
process.exitCode = 2;
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
enforceExternalHealthyWaitTimeout,
|
|
18
18
|
} from "@dev-loops/core/loop/timeout-policy";
|
|
19
19
|
import { parseArgs } from "node:util";
|
|
20
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
20
21
|
import {
|
|
21
22
|
DEFAULT_POLL_INTERVAL_MS,
|
|
22
23
|
COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
@@ -69,9 +70,11 @@ Error output (stderr, JSON):
|
|
|
69
70
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
70
71
|
gh/runtime failures:
|
|
71
72
|
{ "ok": false, "error": "..." }
|
|
73
|
+
${JQ_OUTPUT_USAGE}
|
|
72
74
|
Exit codes:
|
|
73
75
|
0 Success
|
|
74
|
-
1 Argument error or gh failure
|
|
76
|
+
1 Argument error or gh failure
|
|
77
|
+
2 Invalid --jq filter`.trim();
|
|
75
78
|
const WATCH_STATES = new Set([
|
|
76
79
|
STATE.WAITING_FOR_COPILOT_REVIEW,
|
|
77
80
|
]);
|
|
@@ -134,6 +137,8 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
134
137
|
repo: undefined,
|
|
135
138
|
pr: undefined,
|
|
136
139
|
watchStatus: undefined,
|
|
140
|
+
jq: undefined,
|
|
141
|
+
silent: false,
|
|
137
142
|
};
|
|
138
143
|
const { tokens } = parseArgs({
|
|
139
144
|
args: [...argv],
|
|
@@ -142,6 +147,7 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
142
147
|
repo: { type: "string" },
|
|
143
148
|
pr: { type: "string" },
|
|
144
149
|
"watch-status": { type: "string" },
|
|
150
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
145
151
|
},
|
|
146
152
|
allowPositionals: true,
|
|
147
153
|
strict: false,
|
|
@@ -177,6 +183,14 @@ export function parseHandoffCliArgs(argv, { cwd = process.cwd() } = {}) {
|
|
|
177
183
|
options.watchStatus = watchStatus;
|
|
178
184
|
continue;
|
|
179
185
|
}
|
|
186
|
+
if (token.name === "jq") {
|
|
187
|
+
options.jq = requireTokenValue(token, parseError);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (token.name === "silent") {
|
|
191
|
+
options.silent = true;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
180
194
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
181
195
|
}
|
|
182
196
|
if (options.pr === undefined) {
|
|
@@ -519,10 +533,14 @@ export async function runCli(
|
|
|
519
533
|
return;
|
|
520
534
|
}
|
|
521
535
|
const result = await runHandoff(options, { env, ghCommand });
|
|
522
|
-
|
|
536
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout });
|
|
523
537
|
}
|
|
524
538
|
if (isDirectCliRun(import.meta.url)) {
|
|
525
|
-
runCli().
|
|
539
|
+
runCli().then((code) => {
|
|
540
|
+
if (typeof code === "number") {
|
|
541
|
+
process.exitCode = code;
|
|
542
|
+
}
|
|
543
|
+
}).catch((error) => {
|
|
526
544
|
process.stderr.write(`${formatCliError(error)}\n`);
|
|
527
545
|
process.exitCode = 1;
|
|
528
546
|
});
|