@bluewombat/slots 0.11.0 → 0.11.1
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 +77 -14
- package/agents/agent-plugins.mjs +154 -6
- package/agents/claude.mjs +2 -1
- package/agents/cursor.mjs +2 -0
- package/gates/parent-clean.mjs +46 -8
- package/package.json +2 -2
- package/publishers/git.mjs +117 -16
package/README.md
CHANGED
|
@@ -104,7 +104,10 @@ others, plus the names given with `--pass-env`), not the operator's whole
|
|
|
104
104
|
shell; git's credential helpers are cleared for it; and `ssh`, `scp` plus the
|
|
105
105
|
commands given with `--deny-shell` are denied in its own `cli-config.json`,
|
|
106
106
|
rewritten every run. That
|
|
107
|
-
lowers a risk and confines nothing
|
|
107
|
+
lowers a risk and confines nothing: the deny list matches command names (`node -e`,
|
|
108
|
+
`env gh` or an absolute path pass), the operator's files stay readable, the login
|
|
109
|
+
keychain is reachable unless `gh` and `security` are denied, and `CURSOR_API_KEY`
|
|
110
|
+
is visible to the agent. Real confinement is issue #36. `claude.mjs`
|
|
108
111
|
needs `claude`, signed in once, and refuses those two flags: Claude is not
|
|
109
112
|
isolated yet, and starting it would keep the operator's own config.
|
|
110
113
|
|
|
@@ -125,7 +128,7 @@ isolated yet, and starting it would keep the operator's own config.
|
|
|
125
128
|
| `--deny-shell NAME` | With `--agent-home`, also refuse this command to the vendor (`gh`, `vercel`…). `ssh` and `scp` always are. Repeatable |
|
|
126
129
|
| `--plugin-dir DIR` | A plugin folder of the operator's (a Mason home's `ai-plugins/core/*`). Repeatable, loaded first |
|
|
127
130
|
| `--app-deck-profile PATH` | The app's deck profile for this role, as a path in the checkout: `[<app dir>/].ai/profiles/<name>`. Cursor only; Claude refuses |
|
|
128
|
-
| `--plugins-out DIR` | Where the profile is built, one folder per profile. Required with `--app-deck-profile`; never the workspace
|
|
131
|
+
| `--plugins-out DIR` | Where the profile is built, one folder per workspace and profile. Required with `--app-deck-profile`; never the workspace |
|
|
129
132
|
| `--deck-bin PATH` | The deck CLI. Default: the `@pinkpuma/deck` this package depends on |
|
|
130
133
|
| `--id` / `--attempt` / `--context` | How a transcript is filed. Forwarded by the role |
|
|
131
134
|
|
|
@@ -136,7 +139,7 @@ An agent starts with plugin folders, each passed to the vendor as
|
|
|
136
139
|
project's [deck](https://github.com/tilap/pinkpuma-deck) profile resolves to.
|
|
137
140
|
|
|
138
141
|
`--app-deck-profile .ai/profiles/builder` runs `deck materialize -C <app dir> --profile builder --out
|
|
139
|
-
<plugins-out>/builder --json` before the vendor starts. The working directory
|
|
142
|
+
<plugins-out>/<workspace>/builder --json` before the vendor starts. The working directory
|
|
140
143
|
is the checkout the agent works on, so a role gets what the project committed
|
|
141
144
|
at that commit — the base `ai-plugin.json` and `.ai/profiles/builder/`, as their
|
|
142
145
|
locks pin them — never what the operator has installed. deck refuses a request
|
|
@@ -160,9 +163,12 @@ author did. `cursor-agent` ran a Claude-format one (`${CLAUDE_PLUGIN_ROOT}`,
|
|
|
160
163
|
`PostToolUse` on edits) on a real run; a hook that runs is part of the agent's
|
|
161
164
|
time and of what it reads back.
|
|
162
165
|
|
|
163
|
-
deck's cache (`~/.cache/pinkpuma-deck`, or `DECK_CACHE`) has no lock,
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
deck's cache (`~/.cache/pinkpuma-deck`, or `DECK_CACHE`) has no lock, so agents that
|
|
167
|
+
start together (`ticket.maxParallel` above 1) materialize one after the other: the
|
|
168
|
+
wrapper holds a lock in `--plugins-out` while deck runs, and takes it over when its
|
|
169
|
+
holder is gone. Each workspace has its own folder there, so a running agent's plugins
|
|
170
|
+
are never rewritten by another's start. The folder of a workspace that no longer exists
|
|
171
|
+
is removed the next time an agent starts.
|
|
166
172
|
|
|
167
173
|
`claude.mjs` takes `--plugin-dir` but refuses `--app-deck-profile`: not isolated, Claude
|
|
168
174
|
also loads the operator's plugins and the project's `.claude/settings.json`, so
|
|
@@ -340,6 +346,10 @@ values). Files are
|
|
|
340
346
|
filed under the Ticket the Task belongs to, named for the Subtask and the
|
|
341
347
|
Attempt. It belongs on the **agent** command, after `--`.
|
|
342
348
|
|
|
349
|
+
The file is made before the agent starts and grows with its output, then replaced
|
|
350
|
+
by the finished page. An Attempt killed at its ceiling or by an interrupt leaves
|
|
351
|
+
the file as it was: the prompt, the output so far, and `exit: not returned`.
|
|
352
|
+
|
|
343
353
|
```yaml
|
|
344
354
|
builder:
|
|
345
355
|
producer:
|
|
@@ -374,6 +384,23 @@ Every Gate answers with one JSON object on stdout — `{"verdict":"pass"}`, or
|
|
|
374
384
|
in, and what mason appends to its argv, is in
|
|
375
385
|
[`@bluewombat/runtime`](../../host/runtime/README.md).
|
|
376
386
|
|
|
387
|
+
What no Gate can see, by construction:
|
|
388
|
+
|
|
389
|
+
- `sensitive-path` compares the workspace with `HEAD`, so a Builder that commits
|
|
390
|
+
its work makes it blind. `workspace-changed` covers that by refusing a workspace
|
|
391
|
+
with nothing uncommitted; keep both. On the recheck after align the work is
|
|
392
|
+
committed and `sensitive-path` always passes: use `base-diff --deny GLOB`
|
|
393
|
+
where the recheck or the Feature must answer.
|
|
394
|
+
- `workspace-unchanged` on `assembly.validate` runs after the Project's own
|
|
395
|
+
commands: one that writes outside `.gitignore` (coverage, `.tsbuildinfo`, a build
|
|
396
|
+
directory) reads as the review writing. Allow those paths with `--allow GLOB`.
|
|
397
|
+
- `parent-clean` on a Parent with no `.git` takes the workspace's oldest file as
|
|
398
|
+
its baseline time: a file newer than that is a leak. It is the only baseline a
|
|
399
|
+
copy-mode work line has.
|
|
400
|
+
- `workspace-changed` asks whether something changed, not whether the change
|
|
401
|
+
answers the report: a producer that ignores its report and changes something
|
|
402
|
+
else passes.
|
|
403
|
+
|
|
377
404
|
```yaml
|
|
378
405
|
builder:
|
|
379
406
|
gates:
|
|
@@ -398,7 +425,11 @@ it first: every Gate after it then judges work that exists. It passes at
|
|
|
398
425
|
producer to send back.
|
|
399
426
|
|
|
400
427
|
`parent-clean` fail-blocks if Isolator's Parent working files changed
|
|
401
|
-
(`--parent DIR`,
|
|
428
|
+
(`--parent DIR`, plus the main worktree of this Child's repository). Only files written
|
|
429
|
+
after the Child was created count, so an edit that was already there is not a leak. The
|
|
430
|
+
other linked worktrees are sibling Children (parallel tickets, escalated leftovers) and are
|
|
431
|
+
ignored; `--siblings` watches them too, for a serial run. Not covered: an Agent writing
|
|
432
|
+
into a sibling worktree.
|
|
402
433
|
|
|
403
434
|
`sensitive-path` fail-retries if the workspace changed a path matching a glob.
|
|
404
435
|
`*` is one segment, `**` any depth. It looks at what a fold can carry out of
|
|
@@ -572,7 +603,20 @@ in the workspace, and the Gate runs it, compares and judges.
|
|
|
572
603
|
|
|
573
604
|
A scenario is a manifest id, and every test carries its id in its name (`S2 pays by card`). The
|
|
574
605
|
comparison is exact: an id with no test, a test with no manifest id, and a test the runner
|
|
575
|
-
skipped are failures. There is no retry.
|
|
606
|
+
skipped are failures. There is no retry.
|
|
607
|
+
|
|
608
|
+
The report must therefore hold **only** the Feature's scenarios. A name filter is enough only for a
|
|
609
|
+
runner that leaves the filtered-out tests out of its report. A runner that lists them as `skipped`
|
|
610
|
+
(Vitest with `-t` does) puts tests with no manifest id in the report, and the Gate refuses the run
|
|
611
|
+
(`tests that carry no id of the manifest`): restrict the files the command loads as well, for
|
|
612
|
+
example to the files that hold the Feature's tests by a naming convention.
|
|
613
|
+
|
|
614
|
+
```yaml
|
|
615
|
+
# Vitest: only the *.feature.test.ts files are loaded, then only the manifest's scenarios run
|
|
616
|
+
- pnpm exec vitest run .feature.test.ts -t "{ids}" --reporter=junit --outputFile={junit}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
`{ids}` and `{junit}` are both required in `--run`; `mason doctor` warns when one is missing. The verdict:
|
|
576
620
|
|
|
577
621
|
| Verdict | When |
|
|
578
622
|
| --- | --- |
|
|
@@ -598,12 +642,22 @@ the folder (`sensitive-path feature-tests/**` in `feature.fix.gates`) would read
|
|
|
598
642
|
|
|
599
643
|
The slot that puts an assembled ticket where the Authority can read it. It runs
|
|
600
644
|
**in the work line**, and mason appends `--id`, `--ref` — the name Isolator gave
|
|
601
|
-
the ticket —
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
645
|
+
the ticket — `--target`, the work line it is offered to, and `--kind` (`ticket`, or
|
|
646
|
+
`line` for a Feature's own line).
|
|
647
|
+
|
|
648
|
+
`git.mjs` pushes the Ticket's result to the work line's own remote under that
|
|
649
|
+
name: one commit, made from the branch's tree on top of the work line's tip
|
|
650
|
+
(`--target`), not the branch's history. The workspace is snapshotted at every
|
|
651
|
+
integration, so a blob an install added and a repair removed would otherwise
|
|
652
|
+
travel with it and make a pack the remote refuses. The work line remembers what
|
|
653
|
+
it published (`refs/published/<ref>`) and a resubmission continues it, so the
|
|
654
|
+
push stays a fast-forward; the same tree on the same line publishes nothing new.
|
|
655
|
+
A plain push, no force of any kind: a rejection means the name moved under us,
|
|
656
|
+
which is the one case worth refusing rather than overwriting.
|
|
657
|
+
|
|
658
|
+
With `--kind line`, which Host passes when a Feature closes, the ref is pushed as
|
|
659
|
+
it is: a Feature's line is the history the Authority built by folding Tickets
|
|
660
|
+
into it, already on the remote, so the push is a fast-forward of it.
|
|
607
661
|
|
|
608
662
|
It takes no option, and the remote is `origin`. After the Authority folds, Host
|
|
609
663
|
reads the work line back from `origin` on its own, with no way to learn what this
|
|
@@ -685,3 +739,12 @@ messages/ what a commit says: a rule, or an agent under the team's guidelin
|
|
|
685
739
|
test/ one suite per kind; each spawns the real command
|
|
686
740
|
fixtures/ fake agent CLIs the Builder suites run against
|
|
687
741
|
```
|
|
742
|
+
|
|
743
|
+
## Copy mode
|
|
744
|
+
|
|
745
|
+
A work line isolated by `@bluewombat/isolation-copy` has no `.git`. The slots
|
|
746
|
+
that read git need one: the `git` Publisher and Refresher,
|
|
747
|
+
`messages/git-producer`, and the Gates `workspace-changed`, `workspace-unchanged`,
|
|
748
|
+
`sensitive-path`, `base-diff`, `ci-green`, `gitignore-leak` and `tests-only`. A
|
|
749
|
+
copy-mode Project uses `parent-clean`, `project-checks` and `feature-tests`, and
|
|
750
|
+
brings its own Publisher and Gates for what it hands over.
|
package/agents/agent-plugins.mjs
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
statSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
3
12
|
import { createRequire } from "node:module";
|
|
4
13
|
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
14
|
import { listOption, textOption } from "@bluewombat/slot-kit";
|
|
@@ -13,15 +22,18 @@ import { listOption, textOption } from "@bluewombat/slot-kit";
|
|
|
13
22
|
// checkout: `[<app dir>/].ai/profiles/<name>`. The base
|
|
14
23
|
// and that profile, as the app's locks pin them
|
|
15
24
|
// --plugins-out DIR where the profile is materialized, one folder per
|
|
16
|
-
//
|
|
25
|
+
// workspace and profile (<DIR>/<workspace>/<profile>).
|
|
26
|
+
// Required with --app-deck-profile: never the
|
|
17
27
|
// workspace, whose tree the Gates read
|
|
18
28
|
// --deck-bin PATH the deck CLI. Default: the @pinkpuma/deck this
|
|
19
29
|
// package depends on
|
|
20
30
|
//
|
|
21
31
|
// The profile is read from the working directory — the checkout the agent
|
|
22
32
|
// works on — so a role gets what the project committed at that commit, not
|
|
23
|
-
// what the operator has installed. Agents of one Mason run
|
|
24
|
-
//
|
|
33
|
+
// what the operator has installed. Agents of one Mason may run side by side
|
|
34
|
+
// (ticket.maxParallel above 1): each workspace has its own folder, so one
|
|
35
|
+
// agent's plugins are never rewritten under another, and the runs of deck
|
|
36
|
+
// that fill them take turns, because deck's cache has no lock of its own.
|
|
25
37
|
|
|
26
38
|
const PROFILE_NAME = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/;
|
|
27
39
|
|
|
@@ -111,19 +123,34 @@ export function loadPlugins(options, cwd) {
|
|
|
111
123
|
if (!deck.ok) {
|
|
112
124
|
return deck;
|
|
113
125
|
}
|
|
126
|
+
const outRoot = resolve(cwd, out);
|
|
114
127
|
const args = [
|
|
115
128
|
deck.path,
|
|
116
129
|
"materialize",
|
|
117
130
|
"-C",
|
|
118
131
|
appDir,
|
|
119
132
|
"--out",
|
|
120
|
-
|
|
133
|
+
join(outRoot, workspaceSegment(cwd), profile),
|
|
121
134
|
"--json",
|
|
122
135
|
...(hasProfile ? ["--profile", profile] : []),
|
|
123
136
|
];
|
|
124
137
|
// No timeout: this runs inside the agent wrapper's own process group, so
|
|
125
138
|
// the slot's deadline ends deck with it.
|
|
126
|
-
|
|
139
|
+
/** @type {import("node:child_process").SpawnSyncReturns<string>} */
|
|
140
|
+
let run;
|
|
141
|
+
try {
|
|
142
|
+
run = takingTurns(outRoot, () => {
|
|
143
|
+
retireStaleFolders(outRoot);
|
|
144
|
+
markWorkspace(outRoot, cwd);
|
|
145
|
+
return spawnSync(process.execPath, args, { cwd, encoding: "utf8" });
|
|
146
|
+
});
|
|
147
|
+
} catch (error) {
|
|
148
|
+
const said = error instanceof Error ? error.message : String(error);
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
reason: `deck materialize could not start for profile ${profile}: ${said}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
127
154
|
if (run.error !== undefined || run.status !== 0) {
|
|
128
155
|
const said = (run.stderr || run.error?.message || `exit ${run.status}`).trim();
|
|
129
156
|
return {
|
|
@@ -146,6 +173,127 @@ export function loadPlugins(options, cwd) {
|
|
|
146
173
|
};
|
|
147
174
|
}
|
|
148
175
|
|
|
176
|
+
const WORKSPACE_MARK = ".workspace";
|
|
177
|
+
const LOCK_NAME = ".materialize.lock";
|
|
178
|
+
const LOCK_WAIT_MS = 10 * 60 * 1000;
|
|
179
|
+
const LOCK_CREATION_GRACE_MS = 10 * 1000;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The folder under `--plugins-out` that belongs to one workspace: the same
|
|
183
|
+
* name every time the same directory asks, a different one for each directory.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} cwd
|
|
186
|
+
* @returns {string}
|
|
187
|
+
*/
|
|
188
|
+
export function workspaceSegment(cwd) {
|
|
189
|
+
return createHash("sha256").update(resolve(cwd)).digest("hex").slice(0, 12);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Record whose folder this is, so that it can be dropped once the workspace is gone.
|
|
194
|
+
*
|
|
195
|
+
* @param {string} outRoot
|
|
196
|
+
* @param {string} cwd
|
|
197
|
+
*/
|
|
198
|
+
function markWorkspace(outRoot, cwd) {
|
|
199
|
+
const folder = join(outRoot, workspaceSegment(cwd));
|
|
200
|
+
mkdirSync(folder, { recursive: true });
|
|
201
|
+
writeFileSync(join(folder, WORKSPACE_MARK), `${resolve(cwd)}\n`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Drop the folders of workspaces that no longer exist (a ticket that landed
|
|
206
|
+
* clears its own). Only a folder that carries the mark is touched.
|
|
207
|
+
*
|
|
208
|
+
* @param {string} outRoot
|
|
209
|
+
*/
|
|
210
|
+
function retireStaleFolders(outRoot) {
|
|
211
|
+
let names;
|
|
212
|
+
try {
|
|
213
|
+
names = readdirSync(outRoot);
|
|
214
|
+
} catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
for (const name of names) {
|
|
218
|
+
const folder = join(outRoot, name);
|
|
219
|
+
let owner;
|
|
220
|
+
try {
|
|
221
|
+
owner = readFileSync(join(folder, WORKSPACE_MARK), "utf8").trim();
|
|
222
|
+
} catch {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (owner.length > 0 && !existsSync(owner)) {
|
|
226
|
+
rmSync(folder, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Run `work` while holding the lock of `outRoot`: two agents starting together
|
|
233
|
+
* materialize one after the other, since deck's cache has no lock. A lock whose
|
|
234
|
+
* holder is gone is taken over.
|
|
235
|
+
*
|
|
236
|
+
* @template T
|
|
237
|
+
* @param {string} outRoot
|
|
238
|
+
* @param {() => T} work
|
|
239
|
+
* @returns {T}
|
|
240
|
+
*/
|
|
241
|
+
function takingTurns(outRoot, work) {
|
|
242
|
+
mkdirSync(outRoot, { recursive: true });
|
|
243
|
+
const lock = join(outRoot, LOCK_NAME);
|
|
244
|
+
const giveUpAt = Date.now() + LOCK_WAIT_MS;
|
|
245
|
+
for (;;) {
|
|
246
|
+
try {
|
|
247
|
+
mkdirSync(lock);
|
|
248
|
+
break;
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (/** @type {NodeJS.ErrnoException} */ (error).code !== "EEXIST") {
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (holderGone(lock)) {
|
|
255
|
+
rmSync(lock, { recursive: true, force: true });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (Date.now() > giveUpAt) {
|
|
259
|
+
throw new Error(`another agent held ${lock} for more than ${LOCK_WAIT_MS / 60000} minutes`);
|
|
260
|
+
}
|
|
261
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
writeFileSync(join(lock, "pid"), String(process.pid));
|
|
265
|
+
return work();
|
|
266
|
+
} finally {
|
|
267
|
+
rmSync(lock, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Whether the process that took `lock` is gone. A lock with no pid yet was
|
|
273
|
+
* just made: it is given a few seconds to say whose it is.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} lock
|
|
276
|
+
* @returns {boolean}
|
|
277
|
+
*/
|
|
278
|
+
function holderGone(lock) {
|
|
279
|
+
let pid;
|
|
280
|
+
try {
|
|
281
|
+
pid = Number(readFileSync(join(lock, "pid"), "utf8"));
|
|
282
|
+
} catch {
|
|
283
|
+
try {
|
|
284
|
+
return Date.now() - statSync(lock).mtimeMs > LOCK_CREATION_GRACE_MS;
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
process.kill(pid, 0);
|
|
291
|
+
return false;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
return /** @type {NodeJS.ErrnoException} */ (error).code === "ESRCH";
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
149
297
|
/**
|
|
150
298
|
* The filled prompt, then the project's own words for this role.
|
|
151
299
|
*
|
package/agents/claude.mjs
CHANGED
|
@@ -116,7 +116,8 @@ const transcript = transcriptFor(process.argv, options, {
|
|
|
116
116
|
prompt,
|
|
117
117
|
plugins,
|
|
118
118
|
});
|
|
119
|
-
|
|
119
|
+
transcript.start();
|
|
120
|
+
const run = await runAgent({ file: bin, args, cwd, onOutput: transcript.tap });
|
|
120
121
|
const extras = extrasFromClaude(run.stdout, outputFormat, cwd);
|
|
121
122
|
transcript.write(run, extras);
|
|
122
123
|
answer(run, bin, extras);
|
package/agents/cursor.mjs
CHANGED
|
@@ -143,10 +143,12 @@ const transcript = transcriptFor(process.argv, options, {
|
|
|
143
143
|
prompt,
|
|
144
144
|
plugins,
|
|
145
145
|
});
|
|
146
|
+
transcript.start();
|
|
146
147
|
const run = await runAgent({
|
|
147
148
|
file: bin,
|
|
148
149
|
args,
|
|
149
150
|
cwd,
|
|
151
|
+
onOutput: transcript.tap,
|
|
150
152
|
...(isolation.env === undefined ? {} : { env: isolation.env }),
|
|
151
153
|
});
|
|
152
154
|
const extras = extrasFromCursor(run.stdout, outputFormat, cwd);
|
package/gates/parent-clean.mjs
CHANGED
|
@@ -5,11 +5,17 @@ import { join, relative, resolve } from "node:path";
|
|
|
5
5
|
import { emitVerdict, GATE_FLAGS, ownArgv } from "@bluewombat/slot-kit";
|
|
6
6
|
|
|
7
7
|
// Parent working files must not change during the Attempt.
|
|
8
|
-
// --parent DIR (repeatable)
|
|
8
|
+
// --parent DIR (repeatable): always watched.
|
|
9
|
+
// The main worktree of this Child's repository is watched too. Its other linked
|
|
10
|
+
// worktrees are sibling Children (parallel tickets, escalated leftovers): they are
|
|
11
|
+
// legitimately dirty, so they are ignored unless --siblings is given.
|
|
12
|
+
// A watched git tree only counts files modified after this Child was created, so
|
|
13
|
+
// work that was already there (a human's uncommitted edit) is not a leak.
|
|
9
14
|
|
|
10
15
|
const cwd = resolve(process.cwd());
|
|
11
16
|
const tokens = ownArgv(process.argv, GATE_FLAGS);
|
|
12
17
|
const named = [];
|
|
18
|
+
let withSiblings = false;
|
|
13
19
|
|
|
14
20
|
for (let i = 0; i < tokens.length; i++) {
|
|
15
21
|
const token = tokens[i];
|
|
@@ -23,12 +29,19 @@ for (let i = 0; i < tokens.length; i++) {
|
|
|
23
29
|
i += 1;
|
|
24
30
|
continue;
|
|
25
31
|
}
|
|
26
|
-
|
|
32
|
+
if (token === "--siblings") {
|
|
33
|
+
withSiblings = true;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
emitVerdict(
|
|
37
|
+
"fail-blocking",
|
|
38
|
+
`parent-clean does not take "${token}". Use --parent <dir> or --siblings.`,
|
|
39
|
+
);
|
|
27
40
|
process.exit(0);
|
|
28
41
|
}
|
|
29
42
|
|
|
30
43
|
const parents = [...named];
|
|
31
|
-
for (const worktree of otherWorktrees(cwd)) {
|
|
44
|
+
for (const worktree of otherWorktrees(cwd, withSiblings)) {
|
|
32
45
|
if (!parents.includes(worktree)) {
|
|
33
46
|
parents.push(worktree);
|
|
34
47
|
}
|
|
@@ -67,15 +80,22 @@ if (leaked.length > 0) {
|
|
|
67
80
|
|
|
68
81
|
emitVerdict("pass");
|
|
69
82
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
83
|
+
/**
|
|
84
|
+
* The main worktree of this repository (git lists it first), plus the other linked
|
|
85
|
+
* worktrees when `siblings` is set. Worktrees git has marked prunable are left out.
|
|
86
|
+
*/
|
|
87
|
+
/** @param {string} workspace @param {boolean} siblings @returns {string[]} */
|
|
88
|
+
function otherWorktrees(workspace, siblings) {
|
|
73
89
|
const result = git(workspace, ["worktree", "list", "--porcelain"]);
|
|
74
90
|
if (result.status !== 0) {
|
|
75
91
|
return [];
|
|
76
92
|
}
|
|
77
93
|
const trees = [];
|
|
78
|
-
|
|
94
|
+
const records = result.stdout.split("\n\n");
|
|
95
|
+
for (const [index, record] of records.entries()) {
|
|
96
|
+
if (index > 0 && !siblings) {
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
79
99
|
const lines = record.split("\n");
|
|
80
100
|
const entry = lines.find((line) => line.startsWith("worktree "));
|
|
81
101
|
if (entry === undefined || lines.some((line) => line.startsWith("prunable"))) {
|
|
@@ -100,10 +120,12 @@ function leaksIn(parent, workspace) {
|
|
|
100
120
|
if (status.status !== 0) {
|
|
101
121
|
return [`${parent}: git status failed`];
|
|
102
122
|
}
|
|
123
|
+
const referenceMs = referenceTime(workspace);
|
|
103
124
|
const lines = status.stdout
|
|
104
125
|
.split("\n")
|
|
105
126
|
.map((line) => line.trimEnd())
|
|
106
|
-
.filter(Boolean)
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.filter((line) => !untouchedSince(parent, line, referenceMs));
|
|
107
129
|
return lines.map((line) => `${relative(workspace, parent) || parent}: ${line}`);
|
|
108
130
|
}
|
|
109
131
|
const referenceMs = referenceTime(workspace);
|
|
@@ -117,6 +139,22 @@ function leaksIn(parent, workspace) {
|
|
|
117
139
|
return dirty;
|
|
118
140
|
}
|
|
119
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A status line whose file still exists and was last written before the Child was
|
|
144
|
+
* created is older than the Attempt. A deletion has no date and always counts.
|
|
145
|
+
* @param {string} parent
|
|
146
|
+
* @param {string} line
|
|
147
|
+
* @param {number} referenceMs
|
|
148
|
+
*/
|
|
149
|
+
function untouchedSince(parent, line, referenceMs) {
|
|
150
|
+
const path = line.slice(3).split(" -> ").at(-1) ?? "";
|
|
151
|
+
try {
|
|
152
|
+
return statSync(join(parent, path.replace(/^"|"$/g, ""))).mtimeMs <= referenceMs;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
120
158
|
/** @param {string} workspace */
|
|
121
159
|
function referenceTime(workspace) {
|
|
122
160
|
const gitFile = join(workspace, ".git");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bluewombat/slots",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Shipped mason slots: Builders, Gates, publishers, and refreshers a Project names in its config.",
|
|
6
6
|
"keywords": [
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"test": "tsx --test $(find test -name '*.test.ts')"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@bluewombat/slot-kit": "^0.11.
|
|
55
|
+
"@bluewombat/slot-kit": "^0.11.1",
|
|
56
56
|
"@pinkpuma/deck": "1.1.0"
|
|
57
57
|
}
|
|
58
58
|
}
|
package/publishers/git.mjs
CHANGED
|
@@ -14,18 +14,29 @@ import {
|
|
|
14
14
|
// Put the ticket's work where the Authority can read it, by pushing the name
|
|
15
15
|
// Isolator gave it to the work line's own remote.
|
|
16
16
|
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
// a
|
|
21
|
-
//
|
|
17
|
+
// What is pushed is the Ticket's result, not what its workspace went through:
|
|
18
|
+
// the workspace is snapshotted again at every integration, and a blob that was
|
|
19
|
+
// added and removed along the way (an install, a build) would travel with its
|
|
20
|
+
// history and make a pack no host accepts. So one commit is made from the
|
|
21
|
+
// branch's tree, on top of the work line's tip, and that commit is pushed.
|
|
22
|
+
// A resubmission continues what this slot published before, which the work line
|
|
23
|
+
// remembers, so the push stays a fast-forward.
|
|
24
|
+
//
|
|
25
|
+
// A plain push, and no force of any kind. A rejection means the name moved
|
|
26
|
+
// under us — someone else's commit, or a rewrite — and that is exactly the
|
|
27
|
+
// case worth refusing rather than overwriting: a branch this slot did not
|
|
28
|
+
// publish is never built upon.
|
|
22
29
|
//
|
|
23
30
|
// The remote is `origin`, and there is no option for it: Host reads the work
|
|
24
31
|
// line back from `origin` after the Authority folds, with no way to learn what
|
|
25
32
|
// this slot was told. An option that publishes to one remote while the refresh
|
|
26
33
|
// reads another is a trap, so it waits for that half to become a slot too.
|
|
27
34
|
//
|
|
28
|
-
//
|
|
35
|
+
// A Feature's line is not a Ticket's result: it is a history the Authority built
|
|
36
|
+
// by folding Tickets into it, already on the remote, and it is pushed as it is,
|
|
37
|
+
// a fast-forward of what the remote holds. mason says so with `--kind line`.
|
|
38
|
+
//
|
|
39
|
+
// Runs in the work line. mason appends --id, --ref, --target and --kind.
|
|
29
40
|
|
|
30
41
|
const cwd = process.cwd();
|
|
31
42
|
const tokens = ownArgv(process.argv, PUBLISHER_FLAGS);
|
|
@@ -48,18 +59,108 @@ if (!existsSync(join(cwd, ".git"))) {
|
|
|
48
59
|
|
|
49
60
|
// C locale: git's words become the refusal a team reads on the tracker, and a
|
|
50
61
|
// machine's own language would put French or German in an English thread.
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
const env = { ...process.env, LC_ALL: "C" };
|
|
63
|
+
/** @param {...string} args */
|
|
64
|
+
const git = (...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8", env });
|
|
65
|
+
/** @param {{ stderr?: string | null, stdout?: string | null }} run */
|
|
66
|
+
const text = (run) => `${run.stderr ?? ""}${run.stdout ?? ""}`.trim().slice(-1000);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Runs git; a git that cannot run at all ends the slot, anything else is the caller's to read.
|
|
70
|
+
* @param {...string} args
|
|
71
|
+
*/
|
|
72
|
+
function run(...args) {
|
|
73
|
+
const done = git(...args);
|
|
74
|
+
if (done.error !== undefined) {
|
|
75
|
+
writeDiagnostic(`git publisher cannot run git: ${done.error.message}\n`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
return done;
|
|
58
79
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} what
|
|
83
|
+
* @param {{ stderr?: string | null, stdout?: string | null }} [done]
|
|
84
|
+
* @returns {never}
|
|
85
|
+
*/
|
|
86
|
+
function refuse(what, done) {
|
|
87
|
+
emitPublishRefusal(
|
|
88
|
+
`Could not publish ${ref}: ${what}${done === undefined ? "" : `: ${text(done)}`}`,
|
|
89
|
+
);
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const kind = take(process.argv, "--kind") ?? "ticket";
|
|
94
|
+
if (kind === "line") {
|
|
95
|
+
const pushedLine = run("push", remote, `refs/heads/${ref}:refs/heads/${ref}`);
|
|
96
|
+
if (pushedLine.status !== 0) {
|
|
97
|
+
refuse("the push was refused", pushedLine);
|
|
98
|
+
}
|
|
99
|
+
emitPublication(ref);
|
|
100
|
+
process.exit(0);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const target = take(process.argv, "--target");
|
|
104
|
+
const id = take(process.argv, "--id");
|
|
105
|
+
if (target === undefined || target.trim().length === 0) {
|
|
106
|
+
refuse("it was given no --target to build on");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The line's tip, as the remote holds it: what the Submission is a change to.
|
|
110
|
+
const fetched = run("fetch", "--quiet", remote, target);
|
|
111
|
+
if (fetched.status !== 0) {
|
|
112
|
+
refuse(`cannot read ${target} from ${remote}`, fetched);
|
|
113
|
+
}
|
|
114
|
+
const tip = run("rev-parse", "FETCH_HEAD");
|
|
115
|
+
const tipOid = tip.stdout.trim();
|
|
116
|
+
if (tip.status !== 0 || tipOid.length === 0) {
|
|
117
|
+
refuse(`cannot read ${target}`, tip);
|
|
118
|
+
}
|
|
119
|
+
const tree = run("rev-parse", `${ref}^{tree}`);
|
|
120
|
+
if (tree.status !== 0) {
|
|
121
|
+
refuse(`${ref} is not a branch of this work line`, tree);
|
|
122
|
+
}
|
|
123
|
+
const treeOid = tree.stdout.trim();
|
|
124
|
+
|
|
125
|
+
// What this slot published before: the work line keeps it under `refs/published/`.
|
|
126
|
+
// A name that holds anything else moved under us, and is left for the push to
|
|
127
|
+
// reject.
|
|
128
|
+
const recordName = `refs/published/${ref}`;
|
|
129
|
+
let before;
|
|
130
|
+
const recorded = run("rev-parse", "--verify", "--quiet", recordName).stdout.trim();
|
|
131
|
+
const remoteRef = run("ls-remote", remote, `refs/heads/${ref}`);
|
|
132
|
+
const remoteOid = remoteRef.stdout.trim().split(/\s+/)[0] ?? "";
|
|
133
|
+
if (recorded.length > 0 && remoteRef.status === 0 && remoteOid === recorded) {
|
|
134
|
+
before = recorded;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Nothing new: the same tree on the same line, already there.
|
|
138
|
+
if (
|
|
139
|
+
before !== undefined &&
|
|
140
|
+
run("rev-parse", `${before}^{tree}`).stdout.trim() === treeOid &&
|
|
141
|
+
run("merge-base", "--is-ancestor", tipOid, before).status === 0
|
|
142
|
+
) {
|
|
143
|
+
emitPublication(ref);
|
|
62
144
|
process.exit(0);
|
|
63
145
|
}
|
|
64
146
|
|
|
147
|
+
const parents = [...new Set([before, tipOid].filter((oid) => oid !== undefined))];
|
|
148
|
+
const made = run(
|
|
149
|
+
"commit-tree",
|
|
150
|
+
treeOid,
|
|
151
|
+
...parents.flatMap((oid) => ["-p", oid]),
|
|
152
|
+
"-m",
|
|
153
|
+
`Submission of ${id ?? ref}`,
|
|
154
|
+
);
|
|
155
|
+
const commit = made.stdout.trim();
|
|
156
|
+
if (made.status !== 0 || commit.length === 0) {
|
|
157
|
+
refuse("cannot make the commit", made);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const pushed = run("push", remote, `${commit}:refs/heads/${ref}`);
|
|
161
|
+
if (pushed.status !== 0) {
|
|
162
|
+
refuse("the push was refused", pushed);
|
|
163
|
+
}
|
|
164
|
+
run("update-ref", recordName, commit);
|
|
165
|
+
|
|
65
166
|
emitPublication(ref);
|