@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 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 (RESERVATIONS F32). `claude.mjs`
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, and
164
- `--plugins-out` holds one folder per profile: both rely on a Mason run starting
165
- one agent at a time, which it does.
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`, or the other git worktrees of this Child).
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. The verdict:
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 — and `--target`, the work line it is offered to.
602
-
603
- `git.mjs` pushes that name to the work line's own remote. A plain push, no force
604
- of any kind: Integrator only ever adds history, so republishing after a refusal
605
- is a fast-forward. A rejection means the name moved under us, which is the one
606
- case worth refusing rather than overwriting.
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.
@@ -1,5 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { existsSync, readFileSync, statSync } from "node:fs";
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
- // profile. Required with --app-deck-profile: never the
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 one at a time,
24
- // which is why one folder per profile is enough.
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
- resolve(cwd, out, profile),
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
- const run = spawnSync(process.execPath, args, { cwd, encoding: "utf8" });
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
- const run = await runAgent({ file: bin, args, cwd });
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);
@@ -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). Other git worktrees of this Child are included.
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
- emitVerdict("fail-blocking", `parent-clean does not take "${token}". Use --parent <dir>.`);
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
- /** Other worktrees of this repository, minus the ones git has marked prunable. */
71
- /** @param {string} workspace @returns {string[]} */
72
- function otherWorktrees(workspace) {
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
- for (const record of result.stdout.split("\n\n")) {
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.0",
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.0",
55
+ "@bluewombat/slot-kit": "^0.11.1",
56
56
  "@pinkpuma/deck": "1.1.0"
57
57
  }
58
58
  }
@@ -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
- // A plain push, and no force of any kind. Integrator only ever adds history, so
18
- // republishing after a refusal is a fast-forward of what was already there. A
19
- // rejection therefore means the name moved under us — someone else's commit, or
20
- // a rewrite — and that is exactly the case worth refusing rather than
21
- // overwriting.
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
- // Runs in the work line. mason appends --id, --ref and --target.
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 pushed = spawnSync("git", ["-C", cwd, "push", remote, `${ref}:${ref}`], {
52
- encoding: "utf8",
53
- env: { ...process.env, LC_ALL: "C" },
54
- });
55
- if (pushed.error !== undefined) {
56
- writeDiagnostic(`git publisher cannot run git: ${pushed.error.message}\n`);
57
- process.exit(1);
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
- if (pushed.status !== 0) {
60
- const detail = `${pushed.stderr ?? ""}${pushed.stdout ?? ""}`.trim().slice(-1000);
61
- emitPublishRefusal(`Could not publish ${ref}: ${detail}`);
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);