@bluewombat/slots 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -121
- package/agents/claude.mjs +3 -2
- package/agents/cursor.mjs +3 -2
- package/assembly/validate.mjs +204 -0
- package/gates/ci-green.mjs +39 -6
- package/gates/gitignore-leak.mjs +117 -0
- package/gates/sensitive-path.mjs +18 -33
- package/messages/conventional.mjs +2 -2
- package/messages/git-producer.mjs +2 -1
- package/package.json +2 -2
- package/planners/producer.mjs +2 -1
- package/publishers/git.mjs +5 -1
- package/refreshers/git.mjs +8 -2
package/README.md
CHANGED
|
@@ -3,37 +3,35 @@
|
|
|
3
3
|
The Planner, Builder, and Gate commands that ship with mason. A Project points
|
|
4
4
|
its config at one of these paths; nothing here is imported.
|
|
5
5
|
|
|
6
|
-
```
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
}
|
|
6
|
+
```yaml
|
|
7
|
+
planner:
|
|
8
|
+
cmd: [node, ./node_modules/@bluewombat/slots/planners/one-subtask.mjs]
|
|
9
|
+
builder:
|
|
10
|
+
producer:
|
|
11
|
+
cmd:
|
|
12
|
+
- node
|
|
13
|
+
- ./node_modules/@bluewombat/slots/builders/producer.mjs
|
|
14
|
+
- --prompt-file
|
|
15
|
+
- ./build.md
|
|
16
|
+
- --
|
|
17
|
+
- node
|
|
18
|
+
- ./node_modules/@bluewombat/slots/agents/claude.mjs
|
|
19
|
+
timeoutMs: 600000
|
|
20
|
+
repair:
|
|
21
|
+
cmd:
|
|
22
|
+
- node
|
|
23
|
+
- ./node_modules/@bluewombat/slots/builders/repair.mjs
|
|
24
|
+
- --prompt-file
|
|
25
|
+
- ./repair.md
|
|
26
|
+
- --
|
|
27
|
+
- node
|
|
28
|
+
- ./node_modules/@bluewombat/slots/agents/claude.mjs
|
|
29
|
+
timeoutMs: 600000
|
|
30
|
+
gates:
|
|
31
|
+
defaultTimeoutMs: 120000
|
|
32
|
+
gates:
|
|
33
|
+
- id: workspace-changed
|
|
34
|
+
argv: [node, ./node_modules/@bluewombat/slots/gates/workspace-changed.mjs]
|
|
37
35
|
```
|
|
38
36
|
|
|
39
37
|
A slot is an opaque command: it reads argv and writes one JSON line on stdout.
|
|
@@ -65,9 +63,11 @@ these all use it. How Host wires a slot into a run:
|
|
|
65
63
|
| `builders/producer.mjs` | Builder | First pass of a Subtask: fill `{{task}}`, then spawn the agent |
|
|
66
64
|
| `builders/repair.mjs` | Builder | A Gate refused the Subtask: fill `{{task}}` and `{{report}}` |
|
|
67
65
|
| `assembly/fix.mjs` | Builder | A judgement refused the assembled feature: fill `{{task}}` and `{{report}}` |
|
|
66
|
+
| `assembly/validate.mjs` | Builder | Read-only review of the assembled feature: fill `{{task}}`; answers `MASON_VERDICT: VALIDATED` or `MASON_VERDICT: REFUSED: …` |
|
|
68
67
|
| `gates/workspace-changed.mjs` | Gate | The Attempt left an uncommitted change |
|
|
69
68
|
| `gates/parent-clean.mjs` | Gate | Isolator's Parent working files did not move |
|
|
70
69
|
| `gates/sensitive-path.mjs` | Gate | No path matching a glob was touched |
|
|
70
|
+
| `gates/gitignore-leak.mjs` | Gate | The feature's commits add nothing its own `.gitignore` excludes |
|
|
71
71
|
| `gates/ci-green.mjs` | Gate | The work line's own checks came back green |
|
|
72
72
|
| `planners/one-subtask.mjs` | Planner | Bootstrap: one Subtask that is the FeatureStandard itself |
|
|
73
73
|
| `publishers/git.mjs` | Publisher | Pushes the feature's branch to the work line's own remote |
|
|
@@ -80,13 +80,16 @@ these all use it. How Host wires a slot into a run:
|
|
|
80
80
|
|
|
81
81
|
A vendor CLI is not a Builder. `agents/cursor.mjs` and `agents/claude.mjs` take
|
|
82
82
|
a prompt that is **already filled**, run the vendor, write a transcript if asked,
|
|
83
|
-
and print the serialized run on stdout
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
and print the serialized run on stdout with `writeContract` (so a large
|
|
84
|
+
stream-json transcript survives `process.exit`). They do not know `--intention`
|
|
85
|
+
or `--report`. A role slot after `--` is what names one:
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
- --
|
|
89
|
+
- node
|
|
90
|
+
- ./node_modules/@bluewombat/slots/agents/cursor.mjs
|
|
91
|
+
- --transcript-dir
|
|
92
|
+
- ./.mason/transcripts
|
|
90
93
|
```
|
|
91
94
|
|
|
92
95
|
`cursor.mjs` needs `cursor-agent` on PATH (`cursor-agent login`); `claude.mjs`
|
|
@@ -154,18 +157,18 @@ in: it names no part of mason, because none of it is visible from there. What
|
|
|
154
157
|
changes from one Project to the next is that text, not the agent, so it is a
|
|
155
158
|
template file on the role:
|
|
156
159
|
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
160
|
+
```yaml
|
|
161
|
+
builder:
|
|
162
|
+
producer:
|
|
163
|
+
cmd:
|
|
164
|
+
- node
|
|
165
|
+
- ./node_modules/@bluewombat/slots/builders/producer.mjs
|
|
166
|
+
- --prompt-file
|
|
167
|
+
- ./mason-prompt.md
|
|
168
|
+
- --
|
|
169
|
+
- node
|
|
170
|
+
- ./node_modules/@bluewombat/slots/agents/cursor.mjs
|
|
171
|
+
timeoutMs: 600000
|
|
169
172
|
```
|
|
170
173
|
|
|
171
174
|
A path that names a file next to the config resolves against the config; any
|
|
@@ -182,6 +185,7 @@ assembly, so `assembly/fix` does not fill it at all.
|
|
|
182
185
|
| `builders/producer` | `{{task}}`, `{{rules}}` | `{{done_when}}`, `{{id}}`, `{{attempt}}` |
|
|
183
186
|
| `builders/repair` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{done_when}}`, `{{id}}`, `{{attempt}}` |
|
|
184
187
|
| `assembly/fix` | `{{task}}`, `{{report}}`, `{{rules}}` | `{{refused_by}}`, `{{id}}`, `{{attempt}}` |
|
|
188
|
+
| `assembly/validate` | `{{task}}`, `{{rules}}` | `{{id}}`, `{{attempt}}` |
|
|
185
189
|
|
|
186
190
|
A first-pass command that receives `--report` is `fail-blocking`: that report
|
|
187
191
|
belongs to the repair command. A repair or fix command without `--report` is the same.
|
|
@@ -203,6 +207,27 @@ The units already passed. `assembly/fix.mjs` tells the agent to change what the
|
|
|
203
207
|
refusal names and leave the rest. Same placeholders as repair except
|
|
204
208
|
`{{done_when}}`, which an assembly has nothing to fill.
|
|
205
209
|
|
|
210
|
+
### The validate prompt
|
|
211
|
+
|
|
212
|
+
`assembly/validate.mjs` is a different kind of Builder: it never writes. It
|
|
213
|
+
fills only `{{task}}` and `{{rules}}` — there is nothing to fix yet, so no
|
|
214
|
+
`{{report}}` — and asks the agent to end its final message with one line:
|
|
215
|
+
`MASON_VERDICT: VALIDATED` or `MASON_VERDICT: REFUSED: <one paragraph>`. Its
|
|
216
|
+
own `{{rules}}` default is not `PROMPT_RULES`: a review has no work to leave
|
|
217
|
+
uncommitted and nothing to push, so it says "read-only" instead of "do not
|
|
218
|
+
commit, do not push".
|
|
219
|
+
|
|
220
|
+
The slot does not trust the CLI's exit code alone — a review can complete
|
|
221
|
+
cleanly and still find a problem. It reads the agent's own final answer
|
|
222
|
+
(`readResult(run.stdout)?.result`) for the **last** line starting with
|
|
223
|
+
`MASON_VERDICT:` — not a bare search for "VALIDATED" or "REFUSED" anywhere in
|
|
224
|
+
the text, which a model's own reasoning can contain by accident ("the units
|
|
225
|
+
were already validated on their own"). It maps a `REFUSED:` verdict through
|
|
226
|
+
`emitFailure("fail-retryable", …)` itself, the report being what followed
|
|
227
|
+
`REFUSED:` on that line. No `MASON_VERDICT:` line, or one that is neither
|
|
228
|
+
form, is treated the same way, naming what the agent said: a review that did
|
|
229
|
+
not answer as asked is not a silent pass.
|
|
230
|
+
|
|
206
231
|
### The rules
|
|
207
232
|
|
|
208
233
|
`{{rules}}` holds the bounds of the slot: one directory, work left uncommitted,
|
|
@@ -229,18 +254,18 @@ them — `skills` and `usage` (`unknown`, `(none)`, or the values). Files are
|
|
|
229
254
|
filed under the Feature the Task belongs to, named for the Subtask and the
|
|
230
255
|
Attempt. It belongs on the **agent** command, after `--`.
|
|
231
256
|
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
257
|
+
```yaml
|
|
258
|
+
builder:
|
|
259
|
+
producer:
|
|
260
|
+
cmd:
|
|
261
|
+
- node
|
|
262
|
+
- ./node_modules/@bluewombat/slots/builders/producer.mjs
|
|
263
|
+
- --
|
|
264
|
+
- node
|
|
265
|
+
- ./node_modules/@bluewombat/slots/agents/cursor.mjs
|
|
266
|
+
- --transcript-dir
|
|
267
|
+
- ./.mason/transcripts
|
|
268
|
+
timeoutMs: 600000
|
|
244
269
|
```
|
|
245
270
|
|
|
246
271
|
```
|
|
@@ -263,29 +288,21 @@ Every Gate answers with one JSON object on stdout — `{"verdict":"pass"}`, or
|
|
|
263
288
|
in, and what mason appends to its argv, is in
|
|
264
289
|
[`@bluewombat/runtime`](../../host/runtime/README.md).
|
|
265
290
|
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
"node",
|
|
282
|
-
"./node_modules/@bluewombat/slots/gates/sensitive-path.mjs",
|
|
283
|
-
"**/.env",
|
|
284
|
-
"**/secrets/**"
|
|
285
|
-
]
|
|
286
|
-
}
|
|
287
|
-
]
|
|
288
|
-
} }
|
|
291
|
+
```yaml
|
|
292
|
+
builder:
|
|
293
|
+
gates:
|
|
294
|
+
defaultTimeoutMs: 120000
|
|
295
|
+
gates:
|
|
296
|
+
- id: workspace-changed
|
|
297
|
+
argv: [node, ./node_modules/@bluewombat/slots/gates/workspace-changed.mjs]
|
|
298
|
+
- id: parent-clean
|
|
299
|
+
argv: [node, ./node_modules/@bluewombat/slots/gates/parent-clean.mjs]
|
|
300
|
+
- id: sensitive-path
|
|
301
|
+
argv:
|
|
302
|
+
- node
|
|
303
|
+
- ./node_modules/@bluewombat/slots/gates/sensitive-path.mjs
|
|
304
|
+
- "**/.env"
|
|
305
|
+
- "**/secrets/**"
|
|
289
306
|
```
|
|
290
307
|
|
|
291
308
|
`workspace-changed` fail-retries when the workspace has no uncommitted change —
|
|
@@ -298,31 +315,52 @@ producer to send back.
|
|
|
298
315
|
(`--parent DIR`, or the other git worktrees of this Child).
|
|
299
316
|
|
|
300
317
|
`sensitive-path` fail-retries if the workspace changed a path matching a glob.
|
|
301
|
-
`*` is one segment, `**` any depth.
|
|
318
|
+
`*` is one segment, `**` any depth. It looks at what a fold can carry out of
|
|
319
|
+
the workspace — tracked paths, and new ones `.gitignore` does not exclude — so
|
|
320
|
+
what an install left under an ignored directory (a dependency's own `.github/`)
|
|
321
|
+
is not a change to guard.
|
|
322
|
+
|
|
323
|
+
`gitignore-leak --base REF` fail-retries if the feature's commits add a path the
|
|
324
|
+
workspace's own `.gitignore` excludes — a Builder's `node_modules/`, a build's
|
|
325
|
+
`dist/`, a generated file. `REF` is the work line the feature is offered to
|
|
326
|
+
(`main`): only what the feature adds since it left that line is judged, and
|
|
327
|
+
only what is committed, so a path tracked on purpose before the feature began
|
|
328
|
+
is not blamed on it, and a removal staged but not committed does not pass. The
|
|
329
|
+
report is written for `assembly.fix`: untrack the paths, or change `.gitignore`
|
|
330
|
+
if tracking one is the point. It passes at `--stage unit`, where no commit
|
|
331
|
+
holds a Subtask's work yet.
|
|
332
|
+
|
|
333
|
+
It is a second line, not the first: the `isolation-git` fold already keeps an
|
|
334
|
+
ignored path out of the commits it makes. It catches a leak that came some
|
|
335
|
+
other way — another isolation strategy, an agent that committed. Like every
|
|
336
|
+
assembly Gate, with an Authority it judges what was published, so it stops the
|
|
337
|
+
fold into the work line, not the push.
|
|
338
|
+
|
|
339
|
+
```yaml
|
|
340
|
+
assembly:
|
|
341
|
+
gates:
|
|
342
|
+
defaultTimeoutMs: 900000
|
|
343
|
+
gates:
|
|
344
|
+
- id: gitignore-leak
|
|
345
|
+
argv: [node, ./node_modules/@bluewombat/slots/gates/gitignore-leak.mjs, --base, main]
|
|
346
|
+
- id: ci-green
|
|
347
|
+
argv:
|
|
348
|
+
- node
|
|
349
|
+
- ./node_modules/@bluewombat/slots/gates/ci-green.mjs
|
|
350
|
+
- --token-env
|
|
351
|
+
- MASON_GITHUB_TOKEN
|
|
352
|
+
- --require-checks
|
|
353
|
+
```
|
|
302
354
|
|
|
303
355
|
`ci-green` reads the work line's own checks on what this workspace published,
|
|
304
|
-
and answers on them
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
"gates": [
|
|
313
|
-
{
|
|
314
|
-
"id": "ci-green",
|
|
315
|
-
"argv": [
|
|
316
|
-
"node",
|
|
317
|
-
"./node_modules/@bluewombat/slots/gates/ci-green.mjs",
|
|
318
|
-
"--token-env",
|
|
319
|
-
"MASON_GITHUB_TOKEN",
|
|
320
|
-
"--require-checks"
|
|
321
|
-
]
|
|
322
|
-
}
|
|
323
|
-
]
|
|
324
|
-
} }
|
|
325
|
-
```
|
|
356
|
+
and answers on them: GitHub Actions' check runs and the commit statuses a CI
|
|
357
|
+
outside Actions reports (Vercel, Netlify, Jenkins…) — the two lists GitHub
|
|
358
|
+
shows on a pull request. A pending status is still running; a failed one's
|
|
359
|
+
report is its context, what it said, and its link. It reads and nothing else —
|
|
360
|
+
it does not push, does not open a pull request, does not merge. Whoever put
|
|
361
|
+
the work in front of the checks did that before the Gate ran; a workspace
|
|
362
|
+
with nothing published is `fail-blocking`, because no Attempt of that Task
|
|
363
|
+
can change it.
|
|
326
364
|
|
|
327
365
|
It waits while checks are running, so give it a ceiling of its own. On a red
|
|
328
366
|
check the report carries the failing job's log, cut at the error the runner
|
|
@@ -334,11 +372,11 @@ the variable holding a token that can read the checks — the same one the
|
|
|
334
372
|
manager's `tokenEnv` names. It has no default on purpose: `GITHUB_TOKEN` is
|
|
335
373
|
what `gh` reads ahead of its own login, so an operator must not export it,
|
|
336
374
|
and a Gate that fell back to it refused only at the first assembly of a real
|
|
337
|
-
run. `--remote` and `--
|
|
375
|
+
run. `--remote`, `--poll-ms` and `--api-base` (GitHub Enterprise) are there too. It talks to GitHub, and knows
|
|
338
376
|
nothing about `@bluewombat/manager-github`: a Project may run either one
|
|
339
377
|
without the other.
|
|
340
378
|
|
|
341
|
-
`sensitive-path`, `workspace-changed` and `ci-green` need a git workspace, which
|
|
379
|
+
`sensitive-path`, `workspace-changed`, `gitignore-leak` and `ci-green` need a git workspace, which
|
|
342
380
|
is what Isolator makes when WorkLineStable is a git tree. They fail-block on a
|
|
343
381
|
workspace Isolator had to copy.
|
|
344
382
|
|
|
@@ -352,16 +390,17 @@ it takes no option.
|
|
|
352
390
|
before answering and writes nothing into it. `--prompt-file` and `--read` belong
|
|
353
391
|
on the role; the vendor is named after `--`, the same way a Builder names it.
|
|
354
392
|
|
|
355
|
-
```
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
393
|
+
```yaml
|
|
394
|
+
planner:
|
|
395
|
+
cmd:
|
|
396
|
+
- node
|
|
397
|
+
- ./node_modules/@bluewombat/slots/planners/producer.mjs
|
|
398
|
+
- --read
|
|
399
|
+
- ./work-line-stable
|
|
400
|
+
- --
|
|
401
|
+
- node
|
|
402
|
+
- ./node_modules/@bluewombat/slots/agents/cursor.mjs
|
|
403
|
+
timeoutMs: 600000
|
|
365
404
|
```
|
|
366
405
|
|
|
367
406
|
| Option | Meaning |
|
|
@@ -406,6 +445,9 @@ that places its work some other way writes its own Publisher and changes nothing
|
|
|
406
445
|
else. A refusal stops the Submission and travels back to the tracker; a slot that
|
|
407
446
|
could not run at all exits non-zero and says why on stderr.
|
|
408
447
|
|
|
448
|
+
`git.mjs` runs git with `LC_ALL=C`: the refusal quotes git, and it is posted on a
|
|
449
|
+
tracker a team reads, whatever language the machine that ran it speaks.
|
|
450
|
+
|
|
409
451
|
## Refreshers
|
|
410
452
|
|
|
411
453
|
The other half of the Publisher's seam: what the Authority accepted has moved the
|
|
@@ -426,7 +468,8 @@ else, so it answers `ok` with nothing to do.
|
|
|
426
468
|
A refusal stops the pass: nothing starts, and the next one tries again. The
|
|
427
469
|
reason is written for the person who reads `mason run`'s trace: the copy is the
|
|
428
470
|
system's, nothing in it is worth keeping, and the reason says the one command
|
|
429
|
-
that puts it back.
|
|
471
|
+
that puts it back. Git runs with `LC_ALL=C` here too, for the same reason as the
|
|
472
|
+
Publisher.
|
|
430
473
|
|
|
431
474
|
## Stack
|
|
432
475
|
|
package/agents/claude.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
serializeRun,
|
|
12
12
|
textOption,
|
|
13
13
|
transcriptFor,
|
|
14
|
+
writeContract,
|
|
14
15
|
} from "@bluewombat/slot-kit";
|
|
15
16
|
import { extrasFromClaude } from "./claude-extras.mjs";
|
|
16
17
|
|
|
@@ -88,7 +89,7 @@ answer(run, bin, extras);
|
|
|
88
89
|
|
|
89
90
|
/** @param {string} reason @returns {never} */
|
|
90
91
|
function refuse(reason) {
|
|
91
|
-
|
|
92
|
+
writeContract(`${JSON.stringify({ error: reason })}\n`);
|
|
92
93
|
process.exit(1);
|
|
93
94
|
}
|
|
94
95
|
|
|
@@ -99,7 +100,7 @@ function refuse(reason) {
|
|
|
99
100
|
* @returns {never}
|
|
100
101
|
*/
|
|
101
102
|
function answer(run, binPath, extras) {
|
|
102
|
-
|
|
103
|
+
writeContract(
|
|
103
104
|
`${JSON.stringify(
|
|
104
105
|
serializeRun(run, { name: NAME, bin: binPath, skills: extras.skills, usage: extras.usage }),
|
|
105
106
|
)}\n`,
|
package/agents/cursor.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
serializeRun,
|
|
12
12
|
textOption,
|
|
13
13
|
transcriptFor,
|
|
14
|
+
writeContract,
|
|
14
15
|
} from "@bluewombat/slot-kit";
|
|
15
16
|
import { extrasFromCursor } from "./cursor-extras.mjs";
|
|
16
17
|
|
|
@@ -82,7 +83,7 @@ answer(run, bin, extras);
|
|
|
82
83
|
|
|
83
84
|
/** @param {string} reason @returns {never} */
|
|
84
85
|
function refuse(reason) {
|
|
85
|
-
|
|
86
|
+
writeContract(`${JSON.stringify({ error: reason })}\n`);
|
|
86
87
|
process.exit(1);
|
|
87
88
|
}
|
|
88
89
|
|
|
@@ -93,7 +94,7 @@ function refuse(reason) {
|
|
|
93
94
|
* @returns {never}
|
|
94
95
|
*/
|
|
95
96
|
function answer(run, binPath, extras) {
|
|
96
|
-
|
|
97
|
+
writeContract(
|
|
97
98
|
`${JSON.stringify(
|
|
98
99
|
serializeRun(run, { name: NAME, bin: binPath, skills: extras.skills, usage: extras.usage }),
|
|
99
100
|
)}\n`,
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
BUILDER_FLAGS,
|
|
4
|
+
emitFailure,
|
|
5
|
+
failureOf,
|
|
6
|
+
fillPrompt,
|
|
7
|
+
loadPrompt,
|
|
8
|
+
loadRules,
|
|
9
|
+
parseRole,
|
|
10
|
+
readResult,
|
|
11
|
+
spawnFilled,
|
|
12
|
+
take,
|
|
13
|
+
textOption,
|
|
14
|
+
transcriptArgs,
|
|
15
|
+
} from "@bluewombat/slot-kit";
|
|
16
|
+
import { attemptOf } from "../lib/fill.mjs";
|
|
17
|
+
|
|
18
|
+
const NAME = "assembly validate";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
|
|
21
|
+
|
|
22
|
+
This directory is the assembled feature. Every unit of work that makes it up
|
|
23
|
+
has already been validated on its own. Your job now is different: read the
|
|
24
|
+
diff this feature carries against the work line it will land on, and judge
|
|
25
|
+
whether the WHOLE meets what was asked. Do not redo the units. Do not change
|
|
26
|
+
anything — this is a read-only review, not a repair.
|
|
27
|
+
|
|
28
|
+
## What was asked
|
|
29
|
+
|
|
30
|
+
{{task}}
|
|
31
|
+
|
|
32
|
+
## Your answer
|
|
33
|
+
|
|
34
|
+
End your final message with exactly one line in this form, and nothing after
|
|
35
|
+
it:
|
|
36
|
+
|
|
37
|
+
- \`MASON_VERDICT: VALIDATED\` — the assembled feature meets what was asked.
|
|
38
|
+
- \`MASON_VERDICT: REFUSED: <one paragraph>\` — it does not, and the paragraph
|
|
39
|
+
says what is wrong, specifically enough for another agent to fix it without
|
|
40
|
+
redoing your review. Keep the whole paragraph on that one line.
|
|
41
|
+
|
|
42
|
+
Nothing else may start a line with \`MASON_VERDICT:\`. You may explain your
|
|
43
|
+
reasoning before it, but that line is what is read — write it last.
|
|
44
|
+
|
|
45
|
+
## Rules of this run
|
|
46
|
+
|
|
47
|
+
{{rules}}
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A reviewer's bounds: read-only, and answer in the one shape the caller
|
|
52
|
+
* parses. `PROMPT_RULES` tells a producer to leave work uncommitted and push
|
|
53
|
+
* nothing — obligations a Task that writes nothing does not have.
|
|
54
|
+
*/
|
|
55
|
+
const DEFAULT_RULES = [
|
|
56
|
+
"- Work only inside this directory. Do not read or edit anything outside it.",
|
|
57
|
+
"- This is a review, not a repair: do not change, stage, commit, or stash any file.",
|
|
58
|
+
"- Judge the diff against the intention above, not the style of the units that made it.",
|
|
59
|
+
"- Nobody is watching this run and nobody will answer a question. Where the intention is",
|
|
60
|
+
" ambiguous, take the reading most consistent with the existing code, and say which one",
|
|
61
|
+
" you took in your answer.",
|
|
62
|
+
"- End your final message with one line: MASON_VERDICT: VALIDATED, or",
|
|
63
|
+
" MASON_VERDICT: REFUSED: <one paragraph, on that one line>.",
|
|
64
|
+
].join("\n");
|
|
65
|
+
|
|
66
|
+
const report = (take(process.argv, "--report") ?? "").trim();
|
|
67
|
+
if (report.length > 0) {
|
|
68
|
+
emitFailure(
|
|
69
|
+
"fail-blocking",
|
|
70
|
+
"assembly.validate is a read-only review; it does not take --report — that is assembly.fix's job.",
|
|
71
|
+
);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const intention = (take(process.argv, "--intention") ?? "").trim();
|
|
76
|
+
if (intention.length === 0) {
|
|
77
|
+
emitFailure("fail-blocking", "The Builder received no --intention.");
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const parsed = parseRole(process.argv, BUILDER_FLAGS, NAME);
|
|
82
|
+
if (!parsed.ok) {
|
|
83
|
+
emitFailure("fail-blocking", parsed.reason);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const template = loadPrompt(parsed.options, DEFAULT_TEMPLATE);
|
|
88
|
+
if (!template.ok) {
|
|
89
|
+
emitFailure("fail-blocking", template.reason);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
const rules = loadRules(parsed.options, DEFAULT_RULES);
|
|
93
|
+
if (!rules.ok) {
|
|
94
|
+
emitFailure("fail-blocking", rules.reason);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const rendered = fillPrompt(
|
|
99
|
+
template.value,
|
|
100
|
+
{
|
|
101
|
+
task: intention,
|
|
102
|
+
rules: rules.value.trim(),
|
|
103
|
+
id: take(process.argv, "--id") ?? "",
|
|
104
|
+
attempt: String(attemptOf(process.argv)),
|
|
105
|
+
},
|
|
106
|
+
["task", "rules"],
|
|
107
|
+
textOption(parsed.options, "promptFile") ?? "built-in template",
|
|
108
|
+
);
|
|
109
|
+
if (!rendered.ok) {
|
|
110
|
+
emitFailure("fail-blocking", rendered.reason);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const spawned = await spawnFilled(parsed.runner, rendered.prompt, transcriptArgs(process.argv));
|
|
115
|
+
if (!spawned.ok) {
|
|
116
|
+
emitFailure("fail-blocking", spawned.reason);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
answer(spawned.run, spawned.about.bin, spawned.about.name);
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Read the agent's own verdict and map it through `emitFailure` — never
|
|
124
|
+
* through exit 0 alone. A review that completes cleanly still has to answer
|
|
125
|
+
* whether the work is right; the CLI exiting 0 only says the review ran.
|
|
126
|
+
*
|
|
127
|
+
* @param {import("@bluewombat/slot-kit").AgentRun} run
|
|
128
|
+
* @param {string} bin
|
|
129
|
+
* @param {string} name
|
|
130
|
+
* @returns {never}
|
|
131
|
+
*/
|
|
132
|
+
function answer(run, bin, name) {
|
|
133
|
+
if (run.error !== undefined || run.code !== 0) {
|
|
134
|
+
const failure = failureOf(run, bin, name);
|
|
135
|
+
emitFailure(failure.outcome, failure.report);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
const result = readResult(run.stdout);
|
|
139
|
+
if (result?.is_error === true) {
|
|
140
|
+
const failure = failureOf(run, bin, name);
|
|
141
|
+
emitFailure(failure.outcome, failure.report);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
const said = typeof result?.result === "string" ? result.result.trim() : "";
|
|
145
|
+
const verdict = lastVerdictLine(said);
|
|
146
|
+
if (verdict === undefined) {
|
|
147
|
+
// No `MASON_VERDICT:` line at all is not a pass on the strength of exit 0.
|
|
148
|
+
// Treated as retryable, the same as any other malformed Attempt — the
|
|
149
|
+
// next round gets the same prompt again.
|
|
150
|
+
emitFailure(
|
|
151
|
+
"fail-retryable",
|
|
152
|
+
`assembly.validate found no MASON_VERDICT: line in the answer: ${
|
|
153
|
+
said.length > 0 ? said.slice(0, 500) : "(no answer)"
|
|
154
|
+
}`,
|
|
155
|
+
);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const refused = /^REFUSED:?\s*/i.exec(verdict);
|
|
159
|
+
if (refused !== null) {
|
|
160
|
+
const why = verdict.slice(refused[0].length).trim();
|
|
161
|
+
emitFailure(
|
|
162
|
+
"fail-retryable",
|
|
163
|
+
why.length > 0
|
|
164
|
+
? why
|
|
165
|
+
: "assembly.validate refused the assembled feature, with no reason given.",
|
|
166
|
+
);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
if (/^VALIDATED\b/i.test(verdict)) {
|
|
170
|
+
process.exit(0);
|
|
171
|
+
}
|
|
172
|
+
emitFailure(
|
|
173
|
+
"fail-retryable",
|
|
174
|
+
`assembly.validate's MASON_VERDICT: line was neither VALIDATED nor REFUSED: ${verdict.slice(0, 500)}`,
|
|
175
|
+
);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The text after the last line that starts with `MASON_VERDICT:`, or
|
|
181
|
+
* `undefined` when no such line exists.
|
|
182
|
+
*
|
|
183
|
+
* A distinctive, unlikely-to-collide marker, anchored to the start of a line
|
|
184
|
+
* — not a bare search for "VALIDATED" or "REFUSED" anywhere in the answer,
|
|
185
|
+
* which a model's own reasoning can contain by accident ("the units were
|
|
186
|
+
* already validated on their own"). The *last* such line wins: a model that
|
|
187
|
+
* restates its conclusion, or that is shown an example in the prompt, still
|
|
188
|
+
* means its final one.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} text
|
|
191
|
+
* @returns {string | undefined}
|
|
192
|
+
*/
|
|
193
|
+
function lastVerdictLine(text) {
|
|
194
|
+
const marker = /^MASON_VERDICT:\s*/i;
|
|
195
|
+
const lines = text.split("\n");
|
|
196
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
197
|
+
const line = (lines[i] ?? "").trim();
|
|
198
|
+
const match = marker.exec(line);
|
|
199
|
+
if (match !== null) {
|
|
200
|
+
return line.slice(match[0].length).trim();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
package/gates/ci-green.mjs
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
|
|
4
4
|
|
|
5
|
-
// The work line's own checks, on the work this workspace published
|
|
5
|
+
// The work line's own checks, on the work this workspace published: GitHub
|
|
6
|
+
// Actions' check runs, and the commit statuses CI outside Actions reports
|
|
7
|
+
// (Vercel, Netlify, Jenkins…), the two lists GitHub itself shows on a PR.
|
|
6
8
|
//
|
|
7
9
|
// It reads and nothing else: it does not push, does not open anything, does not
|
|
8
10
|
// merge. Whoever put the work in front of the checks did that before this Gate
|
|
9
11
|
// ran; this Gate only says whether the answer came back green.
|
|
10
12
|
//
|
|
11
13
|
// --remote NAME default: origin
|
|
14
|
+
// --api-base URL default: https://api.github.com (GitHub Enterprise)
|
|
12
15
|
// --poll-ms N how often to ask again while checks are running
|
|
13
16
|
// --token-env NAME required: the variable holding a token that can read
|
|
14
17
|
// the checks. No default on purpose — GITHUB_TOKEN is the
|
|
@@ -18,7 +21,6 @@ import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
|
|
|
18
21
|
// --require-checks wait for a check to appear; without it, a commit with no
|
|
19
22
|
// check at all passes
|
|
20
23
|
|
|
21
|
-
const API = "https://api.github.com";
|
|
22
24
|
// What the runner writes at the top of every step it runs.
|
|
23
25
|
const STEP_OPEN = "##[group]Run ";
|
|
24
26
|
const COLOUR = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
@@ -29,6 +31,7 @@ if (!options.ok) {
|
|
|
29
31
|
emitVerdict("fail-blocking", options.reason);
|
|
30
32
|
process.exit(0);
|
|
31
33
|
}
|
|
34
|
+
const apiBase = options.apiBase;
|
|
32
35
|
|
|
33
36
|
if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
34
37
|
emitVerdict("fail-blocking", "ci-green needs a git workspace.");
|
|
@@ -88,10 +91,11 @@ for (;;) {
|
|
|
88
91
|
|
|
89
92
|
/**
|
|
90
93
|
* @param {readonly string[]} tokens
|
|
91
|
-
* @returns {{ ok: true; remote: string; pollMs: number; tokenEnv: string; requireChecks: boolean } | { ok: false; reason: string }}
|
|
94
|
+
* @returns {{ ok: true; remote: string; apiBase: string; pollMs: number; tokenEnv: string; requireChecks: boolean } | { ok: false; reason: string }}
|
|
92
95
|
*/
|
|
93
96
|
function parseOwnArgv(tokens) {
|
|
94
97
|
let remote = "origin";
|
|
98
|
+
let apiBase = "https://api.github.com";
|
|
95
99
|
let pollMs = 15_000;
|
|
96
100
|
let tokenEnv;
|
|
97
101
|
let requireChecks = false;
|
|
@@ -108,6 +112,8 @@ function parseOwnArgv(tokens) {
|
|
|
108
112
|
i += 1;
|
|
109
113
|
if (token === "--remote") {
|
|
110
114
|
remote = value;
|
|
115
|
+
} else if (token === "--api-base") {
|
|
116
|
+
apiBase = value.replace(/\/+$/, "");
|
|
111
117
|
} else if (token === "--token-env") {
|
|
112
118
|
tokenEnv = value;
|
|
113
119
|
} else if (token === "--poll-ms") {
|
|
@@ -127,7 +133,7 @@ function parseOwnArgv(tokens) {
|
|
|
127
133
|
"ci-green needs --token-env NAME: the environment variable holding a token that can read the checks (the same one @bluewombat/manager-github's tokenEnv names, never GITHUB_TOKEN).",
|
|
128
134
|
};
|
|
129
135
|
}
|
|
130
|
-
return { ok: true, remote, pollMs, tokenEnv, requireChecks };
|
|
136
|
+
return { ok: true, remote, apiBase, pollMs, tokenEnv, requireChecks };
|
|
131
137
|
}
|
|
132
138
|
|
|
133
139
|
/**
|
|
@@ -187,6 +193,12 @@ async function readChecks(sha) {
|
|
|
187
193
|
if (!Array.isArray(checkRuns)) {
|
|
188
194
|
return undefined;
|
|
189
195
|
}
|
|
196
|
+
// A token that may read check runs but not statuses reads as no status at
|
|
197
|
+
// all: what this Gate did before it read them.
|
|
198
|
+
const combined = record(
|
|
199
|
+
await api(`/repos/${repo.owner}/${repo.name}/commits/${sha}/status?per_page=100`),
|
|
200
|
+
);
|
|
201
|
+
const statuses = Array.isArray(combined?.statuses) ? combined.statuses : [];
|
|
190
202
|
/** @type {string[]} */
|
|
191
203
|
const failed = [];
|
|
192
204
|
// One workflow can run the same job for two events (push, pull_request)
|
|
@@ -213,7 +225,28 @@ async function readChecks(sha) {
|
|
|
213
225
|
seen.add(heading);
|
|
214
226
|
failed.push(`${heading}${typeof run.id === "number" ? await logOf(run.id) : ""}`);
|
|
215
227
|
}
|
|
216
|
-
|
|
228
|
+
// The latest status per context. No log to cut here: what the reporter said
|
|
229
|
+
// and where it said more is all there is.
|
|
230
|
+
for (const entry of statuses) {
|
|
231
|
+
const status = record(entry) ?? {};
|
|
232
|
+
if (status.state === "pending") {
|
|
233
|
+
running += 1;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (status.state === "success") {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const heading = `${status.context}: ${status.state ?? "failed"}`;
|
|
240
|
+
if (seen.has(heading)) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
seen.add(heading);
|
|
244
|
+
const more = [status.description, status.target_url].filter(
|
|
245
|
+
(text) => typeof text === "string" && text.length > 0,
|
|
246
|
+
);
|
|
247
|
+
failed.push([heading, ...more].join("\n"));
|
|
248
|
+
}
|
|
249
|
+
return { total: checkRuns.length + statuses.length, running, failed };
|
|
217
250
|
}
|
|
218
251
|
|
|
219
252
|
/**
|
|
@@ -279,7 +312,7 @@ async function logOf(jobId) {
|
|
|
279
312
|
*/
|
|
280
313
|
async function api(path, as = "json") {
|
|
281
314
|
try {
|
|
282
|
-
const response = await fetch(`${
|
|
315
|
+
const response = await fetch(`${apiBase}${path}`, {
|
|
283
316
|
headers: {
|
|
284
317
|
authorization: `Bearer ${token}`,
|
|
285
318
|
accept: "application/vnd.github+json",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { emitVerdict, GATE_FLAGS, ownArgv, stageOf } from "@bluewombat/slot-kit";
|
|
4
|
+
|
|
5
|
+
// Fail-retryable when the feature's commits add a path the workspace's own
|
|
6
|
+
// .gitignore excludes — a Builder's node_modules/, a build's dist/, a generated
|
|
7
|
+
// file — so it would ship in the Submission.
|
|
8
|
+
//
|
|
9
|
+
// --base REF (required): the work line the feature is offered to. Only what the
|
|
10
|
+
// feature adds since it left that line is judged, in what is committed: a path
|
|
11
|
+
// tracked on purpose before the feature began is not this feature's leak, and
|
|
12
|
+
// a removal staged but not committed has not removed anything yet.
|
|
13
|
+
//
|
|
14
|
+
// A unit only: nothing. A Subtask's work is uncommitted until a fold records it,
|
|
15
|
+
// so there is no commit of its own here to judge.
|
|
16
|
+
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
const tokens = ownArgv(process.argv, GATE_FLAGS);
|
|
19
|
+
|
|
20
|
+
let base;
|
|
21
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
22
|
+
const token = tokens[i];
|
|
23
|
+
if (token === "--base") {
|
|
24
|
+
const value = tokens[i + 1];
|
|
25
|
+
if (value === undefined || value.startsWith("-")) {
|
|
26
|
+
emitVerdict("fail-blocking", "gitignore-leak needs a ref after --base.");
|
|
27
|
+
process.exit(0);
|
|
28
|
+
}
|
|
29
|
+
base = value;
|
|
30
|
+
i += 1;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
emitVerdict("fail-blocking", `gitignore-leak does not take "${token}". Use --base <ref>.`);
|
|
34
|
+
process.exit(0);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (base === undefined) {
|
|
38
|
+
emitVerdict(
|
|
39
|
+
"fail-blocking",
|
|
40
|
+
"gitignore-leak needs --base <ref>: the work line the feature is offered to.",
|
|
41
|
+
);
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
46
|
+
emitVerdict("fail-blocking", "gitignore-leak needs a git workspace.");
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (stageOf(process.argv) !== "assembly") {
|
|
51
|
+
emitVerdict("pass");
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (git(["rev-parse", "--verify", "--quiet", `${base}^{commit}`]).status !== 0) {
|
|
56
|
+
emitVerdict("fail-blocking", `gitignore-leak cannot find --base "${base}" in this workspace.`);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --no-renames: a path moved under an ignored directory shows as added there.
|
|
61
|
+
const added = git([
|
|
62
|
+
"diff",
|
|
63
|
+
"--name-only",
|
|
64
|
+
"--no-renames",
|
|
65
|
+
"--diff-filter=A",
|
|
66
|
+
"-z",
|
|
67
|
+
`${base}...HEAD`,
|
|
68
|
+
]);
|
|
69
|
+
if (added.status !== 0) {
|
|
70
|
+
emitVerdict(
|
|
71
|
+
"fail-blocking",
|
|
72
|
+
`gitignore-leak cannot diff HEAD against "${base}": ${added.stderr.trim()}`,
|
|
73
|
+
);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
if (added.stdout.length === 0) {
|
|
77
|
+
emitVerdict("pass");
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --no-index: these paths are tracked, and the question is whether the
|
|
82
|
+
// patterns match them, not whether git would still pick them up.
|
|
83
|
+
const ignored = spawnSync("git", ["-C", cwd, "check-ignore", "--no-index", "--stdin", "-z"], {
|
|
84
|
+
input: added.stdout,
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
});
|
|
87
|
+
// 0: some match; 1: none does; anything else is git failing.
|
|
88
|
+
if (ignored.status === 1) {
|
|
89
|
+
emitVerdict("pass");
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
if (ignored.status !== 0) {
|
|
93
|
+
emitVerdict(
|
|
94
|
+
"fail-blocking",
|
|
95
|
+
`gitignore-leak cannot run git check-ignore: ${ignored.stderr.trim()}`,
|
|
96
|
+
);
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const leaked = ignored.stdout.split("\0").filter(Boolean);
|
|
101
|
+
const shown = leaked.slice(0, 50);
|
|
102
|
+
const rest = leaked.length - shown.length;
|
|
103
|
+
emitVerdict(
|
|
104
|
+
"fail-retryable",
|
|
105
|
+
[
|
|
106
|
+
`These paths are committed on top of ${base}, and this workspace's .gitignore excludes them.`,
|
|
107
|
+
"Take them out of the commit with git rm -r --cached <path> and keep the files. If the",
|
|
108
|
+
"feature does mean to track one, change .gitignore so it no longer excludes it:",
|
|
109
|
+
...shown,
|
|
110
|
+
...(rest > 0 ? [`… and ${rest} more`] : []),
|
|
111
|
+
].join("\n"),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
/** @param {string[]} args */
|
|
115
|
+
function git(args) {
|
|
116
|
+
return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
117
|
+
}
|
package/gates/sensitive-path.mjs
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
import { join
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
5
|
import { emitVerdict, GATE_FLAGS, ownArgv, pathMatchesGlob } from "@bluewombat/slot-kit";
|
|
6
6
|
|
|
7
7
|
// Fail-retryable when the workspace changed a path matching a glob.
|
|
8
8
|
// Remaining argv after Implementer's flags are the globs.
|
|
9
|
+
//
|
|
10
|
+
// Only paths a fold can carry out of the workspace: tracked ones, and new ones
|
|
11
|
+
// .gitignore does not exclude. What a Builder's own install left under an
|
|
12
|
+
// ignored directory (a dependency's own .github/) stays in the workspace, so
|
|
13
|
+
// changing it is not a change to guard.
|
|
9
14
|
|
|
10
15
|
const cwd = process.cwd();
|
|
11
16
|
const globs = ownArgv(process.argv, GATE_FLAGS).filter((token) => token.length > 0);
|
|
@@ -22,17 +27,23 @@ if (git(cwd, ["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
|
22
27
|
|
|
23
28
|
const hit = new Set();
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
const listed = git(cwd, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"]);
|
|
31
|
+
if (listed.status !== 0) {
|
|
32
|
+
emitVerdict("fail-blocking", `sensitive-path cannot list the workspace: ${listed.stderr.trim()}`);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
for (const posix of new Set(listed.stdout.split("\0").filter(Boolean))) {
|
|
36
|
+
const file = join(cwd, posix);
|
|
37
|
+
// A tracked path gone from disk is a deletion, read below.
|
|
38
|
+
if (!matchesAny(posix, globs) || !existsSync(file)) {
|
|
39
|
+
continue;
|
|
29
40
|
}
|
|
30
41
|
const onDisk = readFileSync(file);
|
|
31
42
|
const atHead = blobAtHead(cwd, posix);
|
|
32
43
|
if (atHead === null || Buffer.compare(onDisk, atHead) !== 0) {
|
|
33
44
|
hit.add(posix);
|
|
34
45
|
}
|
|
35
|
-
}
|
|
46
|
+
}
|
|
36
47
|
|
|
37
48
|
const deleted = git(cwd, ["diff", "--name-only", "--diff-filter=D", "HEAD"]);
|
|
38
49
|
if (deleted.status === 0) {
|
|
@@ -70,32 +81,6 @@ function blobAtHead(workspace, path) {
|
|
|
70
81
|
return result.stdout;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
|
-
/**
|
|
74
|
-
* @param {string} dir
|
|
75
|
-
* @param {(file: string) => void} visit
|
|
76
|
-
*/
|
|
77
|
-
function walkFiles(dir, visit) {
|
|
78
|
-
let entries;
|
|
79
|
-
try {
|
|
80
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
81
|
-
} catch {
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
for (const entry of entries) {
|
|
85
|
-
if (entry.name === ".git") {
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
const full = join(dir, entry.name);
|
|
89
|
-
if (entry.isDirectory()) {
|
|
90
|
-
walkFiles(full, visit);
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
if (entry.isFile()) {
|
|
94
|
-
visit(full);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
84
|
/**
|
|
100
85
|
* @param {string} workspace
|
|
101
86
|
* @param {string[]} args
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { emitMessage, MESSAGE_FLAGS, ownArgv, take } from "@bluewombat/slot-kit";
|
|
2
|
+
import { emitMessage, MESSAGE_FLAGS, ownArgv, take, writeDiagnostic } from "@bluewombat/slot-kit";
|
|
3
3
|
|
|
4
4
|
// The feature's commit in the Conventional Commits shape, from the issue's
|
|
5
5
|
// title and body and with no agent: `<type>: <title>`, the body as written.
|
|
@@ -27,7 +27,7 @@ const forcedType = take(own, "--type");
|
|
|
27
27
|
const title = (take(process.argv, "--title") ?? "").trim();
|
|
28
28
|
const intention = (take(process.argv, "--intention") ?? "").trim();
|
|
29
29
|
if (title.length === 0) {
|
|
30
|
-
|
|
30
|
+
writeDiagnostic("conventional: no --title.\n");
|
|
31
31
|
process.exit(1);
|
|
32
32
|
}
|
|
33
33
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
take,
|
|
14
14
|
textOption,
|
|
15
15
|
transcriptArgs,
|
|
16
|
+
writeDiagnostic,
|
|
16
17
|
} from "@bluewombat/slot-kit";
|
|
17
18
|
|
|
18
19
|
// The feature's commit message — and the pull request's description with it
|
|
@@ -146,6 +147,6 @@ function readAnswer(path) {
|
|
|
146
147
|
|
|
147
148
|
/** @param {string} reason @returns {never} */
|
|
148
149
|
function fail(reason) {
|
|
149
|
-
|
|
150
|
+
writeDiagnostic(`${reason}\n`);
|
|
150
151
|
process.exit(1);
|
|
151
152
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluewombat/slots",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shipped mason slots: Planners, Builders, Gates, publishers, and refreshers a Project names in its config.",
|
|
6
6
|
"keywords": [
|
|
@@ -53,6 +53,6 @@
|
|
|
53
53
|
"test": "tsx --test $(find test -name '*.test.ts')"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@bluewombat/slot-kit": "^0.
|
|
56
|
+
"@bluewombat/slot-kit": "^0.3.0"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/planners/producer.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
take,
|
|
15
15
|
textOption,
|
|
16
16
|
transcriptArgs,
|
|
17
|
+
writeDiagnostic,
|
|
17
18
|
} from "@bluewombat/slot-kit";
|
|
18
19
|
|
|
19
20
|
const NAME = "planner producer";
|
|
@@ -227,6 +228,6 @@ function tail(output) {
|
|
|
227
228
|
|
|
228
229
|
/** @param {string} reason @returns {never} */
|
|
229
230
|
function fail(reason) {
|
|
230
|
-
|
|
231
|
+
writeDiagnostic(`${reason}\n`);
|
|
231
232
|
process.exit(1);
|
|
232
233
|
}
|
package/publishers/git.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ownArgv,
|
|
9
9
|
PUBLISHER_FLAGS,
|
|
10
10
|
take,
|
|
11
|
+
writeDiagnostic,
|
|
11
12
|
} from "@bluewombat/slot-kit";
|
|
12
13
|
|
|
13
14
|
// Put the feature's work where the Authority can read it, by pushing the name
|
|
@@ -45,11 +46,14 @@ if (!existsSync(join(cwd, ".git"))) {
|
|
|
45
46
|
process.exit(0);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// C locale: git's words become the refusal a team reads on the tracker, and a
|
|
50
|
+
// machine's own language would put French or German in an English thread.
|
|
48
51
|
const pushed = spawnSync("git", ["-C", cwd, "push", remote, `${ref}:${ref}`], {
|
|
49
52
|
encoding: "utf8",
|
|
53
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
50
54
|
});
|
|
51
55
|
if (pushed.error !== undefined) {
|
|
52
|
-
|
|
56
|
+
writeDiagnostic(`git publisher cannot run git: ${pushed.error.message}\n`);
|
|
53
57
|
process.exit(1);
|
|
54
58
|
}
|
|
55
59
|
if (pushed.status !== 0) {
|
package/refreshers/git.mjs
CHANGED
|
@@ -69,7 +69,13 @@ if (merged.status !== 0) {
|
|
|
69
69
|
|
|
70
70
|
emitRefreshed();
|
|
71
71
|
|
|
72
|
-
/**
|
|
72
|
+
/**
|
|
73
|
+
* C locale, as in the Publisher: what git says here ends up in a report.
|
|
74
|
+
* @param {string[]} args
|
|
75
|
+
*/
|
|
73
76
|
function git(args) {
|
|
74
|
-
return spawnSync("git", ["-C", cwd, ...args], {
|
|
77
|
+
return spawnSync("git", ["-C", cwd, ...args], {
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
80
|
+
});
|
|
75
81
|
}
|