@bluewombat/runtime 0.9.0 → 0.10.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.
Files changed (69) hide show
  1. package/README.md +122 -52
  2. package/SPECS.md +36 -14
  3. package/dist/cli.js +4 -4
  4. package/dist/config/agent-shorthand.d.ts +24 -3
  5. package/dist/config/agent-shorthand.d.ts.map +1 -1
  6. package/dist/config/agent-shorthand.js +64 -19
  7. package/dist/config/agent-shorthand.js.map +1 -1
  8. package/dist/config/home.d.ts +2 -0
  9. package/dist/config/home.d.ts.map +1 -1
  10. package/dist/config/home.js +2 -0
  11. package/dist/config/home.js.map +1 -1
  12. package/dist/config/load-config.d.ts.map +1 -1
  13. package/dist/config/load-config.js +298 -46
  14. package/dist/config/load-config.js.map +1 -1
  15. package/dist/config/parse-args.d.ts +1 -1
  16. package/dist/config/parse-args.d.ts.map +1 -1
  17. package/dist/config/parse-args.js +83 -56
  18. package/dist/config/parse-args.js.map +1 -1
  19. package/dist/config/types.d.ts +104 -16
  20. package/dist/config/types.d.ts.map +1 -1
  21. package/dist/config/types.js +13 -1
  22. package/dist/config/types.js.map +1 -1
  23. package/dist/loop/authority.d.ts +38 -1
  24. package/dist/loop/authority.d.ts.map +1 -1
  25. package/dist/loop/authority.js +41 -2
  26. package/dist/loop/authority.js.map +1 -1
  27. package/dist/loop/context.d.ts +16 -1
  28. package/dist/loop/context.d.ts.map +1 -1
  29. package/dist/loop/drive.d.ts +9 -3
  30. package/dist/loop/drive.d.ts.map +1 -1
  31. package/dist/loop/drive.js +17 -7
  32. package/dist/loop/drive.js.map +1 -1
  33. package/dist/loop/features.d.ts.map +1 -1
  34. package/dist/loop/features.js +322 -59
  35. package/dist/loop/features.js.map +1 -1
  36. package/dist/loop/open-host.d.ts.map +1 -1
  37. package/dist/loop/open-host.js +109 -7
  38. package/dist/loop/open-host.js.map +1 -1
  39. package/dist/loop/report.d.ts.map +1 -1
  40. package/dist/loop/report.js +21 -9
  41. package/dist/loop/report.js.map +1 -1
  42. package/dist/loop/trace.js +1 -1
  43. package/dist/loop/trace.js.map +1 -1
  44. package/dist/loop/transformers.d.ts +16 -2
  45. package/dist/loop/transformers.d.ts.map +1 -1
  46. package/dist/loop/transformers.js +137 -22
  47. package/dist/loop/transformers.js.map +1 -1
  48. package/dist/loop/warm.js +1 -1
  49. package/dist/loop/warm.js.map +1 -1
  50. package/dist/operator/agent-plugins.d.ts.map +1 -1
  51. package/dist/operator/agent-plugins.js +14 -8
  52. package/dist/operator/agent-plugins.js.map +1 -1
  53. package/dist/operator/doctor.d.ts +1 -1
  54. package/dist/operator/doctor.d.ts.map +1 -1
  55. package/dist/operator/doctor.js +79 -29
  56. package/dist/operator/doctor.js.map +1 -1
  57. package/dist/operator/explain.d.ts.map +1 -1
  58. package/dist/operator/explain.js +37 -8
  59. package/dist/operator/explain.js.map +1 -1
  60. package/dist/operator/film.js +0 -3
  61. package/dist/operator/film.js.map +1 -1
  62. package/dist/operator/init.js +13 -8
  63. package/dist/operator/init.js.map +1 -1
  64. package/dist/operator/log.js +9 -2
  65. package/dist/operator/log.js.map +1 -1
  66. package/dist/operator/setup.d.ts.map +1 -1
  67. package/dist/operator/setup.js +2 -0
  68. package/dist/operator/setup.js.map +1 -1
  69. package/package.json +14 -14
package/README.md CHANGED
@@ -151,13 +151,17 @@ manager with no `probe`), stop `run` if it holds the ledger lock, then:
151
151
  mason cancel github:owner/name#19
152
152
  ```
153
153
 
154
- After a crash left a unit `running` with nobody on it, stop `run` if it
155
- holds the lock, then:
154
+ A unit that was stopped (Ctrl-C, a crash) is picked up where it was by the next
155
+ `mason run`: its workspace is kept, the Attempts it finished still count and the
156
+ numbering goes on. To start a unit over instead (the line moved on, the work took
157
+ a wrong turn), stop `run` if it holds the lock, then:
156
158
 
157
159
  ```bash
158
160
  mason release github:owner/name#19
159
161
  ```
160
162
 
163
+ `release` destroys the workspace and forgets it; the next `run` isolates a fresh one.
164
+
161
165
  The film is `<ledger>/events.jsonl`, next to `cursor`. It is not truth after a
162
166
  crash — the ledger is. The file can grow; there is no rotation yet.
163
167
 
@@ -194,13 +198,12 @@ builder:
194
198
  producer:
195
199
  cmd: [node, ./mason-builder.mjs]
196
200
  timeoutMs: 600000
197
- gates: { defaultTimeoutMs: 120000, gates: [] }
198
201
  repair:
199
202
  cmd: [node, ./mason-repair.mjs]
200
203
  timeoutMs: 600000
201
- gates: { defaultTimeoutMs: 120000, gates: [] }
204
+ gates: { defaultTimeoutMs: 120000, gates: [] }
202
205
  maxAttempts: 3
203
- assembly:
206
+ ticket:
204
207
  gates: { defaultTimeoutMs: 900000, gates: [] }
205
208
  authority:
206
209
  enabled: false
@@ -213,6 +216,7 @@ pollIntervalMs: 10000
213
216
  | `featureTracker` | `package` (a name or path that exports `createManager`) and `options`: where Features are written and signalled |
214
217
  | `ticketTracker` | Same shape: where the Tickets of a Feature are, and the Authority that holds the lines. May name the same package as `featureTracker`, or another |
215
218
  | `workLine.stable` | Existing directory the work folds into. Optional when the manager names a reference work line: Host then keeps its own copy at `<home>/work-line` |
219
+ | `workLine.targetStable` | A second copy of the work line, on the branch a Feature is folded into: what is merged into the Feature when it is brought up to date. Host keeps its own at `<home>/target-line` when the manager names a reference; required with an Authority otherwise |
216
220
  | `workLine.isolation` | Package name or path exporting `strategy` — required. Host loads it like a manager; Isolator/Integrator do not sniff |
217
221
  | `workLine.isolationOptions` | What that strategy is told, unread by Host — like a manager's `options`. `isolation-git` reads `exclude`, the paths a Child never gets (default `.env`, `.env.*`). A strategy without `createStrategy` refuses any |
218
222
  | `workLine.warm` | Optional `cmd` / `timeoutMs`. Runs in the workspace after Isolation, before the Builder — typically `pnpm install --frozen-lockfile`. Absent: the Builder gets a cold tree |
@@ -223,26 +227,31 @@ pollIntervalMs: 10000
223
227
  | `ledger` | WorkLedger root (created). The Cursor is a file inside it. Default `<home>/ledger` |
224
228
  | `persist` | Package name or path exporting `openPersist({ ledgerRoot })`: how the ledger is stored under that root. Default `@bluewombat/persist-fs`; `@bluewombat/persist-sqlite` ships too. `watch` reads through the same backend |
225
229
  | `agents` | Agents a pass names instead of writing `cmd` (§ Agent passes written short) |
226
- | `builder.producer` | `cmd` / `timeoutMs` / `gates`. Spawned in the workspace. Exit 0 runs its own `gates` |
227
- | `builder.repair` | The same, for a pass a Gate refused. **Required**, and never inherited — including its `gates`: a repair with none of its own runs ungated, even when `builder.producer.gates` is not empty |
230
+ | `builder.tests` | Required. The tests of the task, written before its code by an agent that is not the producer: `{ agent, prompt, homePlugins, appDeckProfile, timeoutMs, gates }`, or `{ agent: false }` for none. Its `gates` check them (`tests-red`: they must fail, for the right reason). What it wrote is committed as a checkpoint titled `Tests: …`, which `builder.gates` can keep intact (`tests-frozen`). A ticket the tracker labels as having no tests first (`no-tests` with the GitHub manager) runs no agent and no `tests-red` |
231
+ | `builder.e2e` | Optional. Declares the e2e acceptance check (see § The e2e acceptance check): the pass that makes the Ticket holding the suite, `{ agent, prompt, homePlugins, appDeckProfile, timeoutMs, gates }` or `{ cmd, timeoutMs, gates }`. One command for the first pass and for a pass a Gate refused; its own `gates` (they check the form: compiles, one test per manifest id, no application file touched). Nothing is borrowed from `builder.producer`, `builder.repair` or `builder.gates`. No tests are written first for that Ticket. Needs a Feature Gate with `skipWhen: skip-test-e2e`, and the other way round |
232
+ | `builder.producer` | `cmd` / `timeoutMs`. Spawned in the workspace. Exit 0 runs `builder.gates` |
233
+ | `builder.repair` | The same, for a pass a Gate refused. **Required**, and never inherited: it names its own command. It is judged by the same `builder.gates` as the producer |
234
+ | `builder.gates` | `defaultTimeoutMs` and `gates`: the one Gate list for `builder.producer` and `builder.repair`. `gates` written on either pass is refused, naming this key. Empty: the unit runs ungated |
228
235
  | `builder.maxAttempts` | Attempts one Task may start. Default 3 |
229
- | `assembly.fix` | `cmd` / `timeoutMs` / `gates`. Spawned when a judgement of the whole refused it — by an Authority, by `assembly.validate`, or by both. Judged by its own `gates` |
230
- | `assembly.validate` | `cmd` / `timeoutMs` / `gates`. Optional. A local, read-only judge of the assembled ticket, run after align, with or without an Authority. A refusal is repaired by `assembly.fix` the same as an Authority's. Judged by its own `gates`, never `builder`'s or `assembly.fix`'s |
231
- | `assembly.maxAttempts` | Attempts that one fix may start. Default 3 |
232
- | `assembly.gates` | `defaultTimeoutMs` and `gates`: the produce:false judgement-only pass — no producer of its own, re-checking the assembled ticket. Empty: the Authority judges alone. Independent of `assembly.fix.gates` / `assembly.validate.gates` — never shared with either |
236
+ | `ticket.fix` | `cmd` / `timeoutMs` / `gates`. Spawned when a judgement of the whole refused it — by an Authority, by `ticket.validate`, or by both. Judged by its own `gates` |
237
+ | `ticket.validate` | `cmd` / `timeoutMs` / `gates`. A local, read-only judge of the assembled ticket, run after align, with or without an Authority. A refusal is repaired by `ticket.fix` the same as an Authority's. Judged by its own `gates`, never `builder`'s or `ticket.fix`'s. `validate` is required when a `ticket` block is written, and so is `feature.validate`: `{ agent: false, gates }` turns the agent off and keeps the gates, and `{ agent: false }` with no gates turns the review off — a bare `gates:` counts as none. |
238
+ | `ticket.maxAttempts` | Attempts that one fix may start. Default 3 |
239
+ | `ticket.gates` | `defaultTimeoutMs` and `gates`: the produce:false judgement-only pass — no producer of its own, re-checking the assembled ticket. Empty: the Authority judges alone. Independent of `ticket.fix.gates` / `ticket.validate.gates` — never shared with either |
233
240
  | `timeoutMs` | How long a child **outside** a Task may run: manager, isolations |
234
- | `maxRefusals` | Times the work may be sent back before it escalates, by an Authority, by `assembly.validate`, or by both — one shared budget. Default 3 |
241
+ | `ticket.maxRefusals` | Times the work may be sent back before it escalates, by an Authority, by `ticket.validate`, or by both — one shared budget. Default 3 |
242
+ | `ticket.maxParallel` | How many Tickets of a Project are worked on at once. Default 1: one after the other. Above 1 needs `authority.enabled`; a Ticket waiting on a check or a person holds a place, the others go on. Tickets that wait on another (*Blocked by*) still start only once it is `done` |
243
+ | `feature` | Required with `authority.enabled`. How a Feature's line is delivered to the line it is folded into, by the same steps as a Ticket: the final line is merged into the Feature's work (`workLine.targetStable`, or Host's own copy of the reference), `validate` (same shape as `ticket.validate`) reviews the whole against the Feature's request and its `gates` (at least one) are where the project's checks on the whole line run, a refusal goes to `fix`, the line is published, and `gates` (at least one, the CI) judge it. `maxAttempts` and `maxRefusals` (default 3, same rule as a Ticket's) are its own. Nothing falls back to `ticket`. The merge itself is automatic only with `merge-when-ready`; the delivery to the Authority is for every Feature |
235
244
  | `pollIntervalMs` | Set it and the process keeps draining until SIGINT. Absent: one tick. Pick it with § Choosing `pollIntervalMs` — 10000 is the floor worth having |
236
245
  | `observability.streams` | `enabled`, `dir`, `keep`. Films what every child says, as it says it — one file per child under `<dir>/<ticket>/`, named in the journal by a `stream-opened` line. `dir` resolves against the config file; default `<home>/streams`. `keep` is `stdout` / `stderr`, default both. Off unless `enabled` is true: a stream is the Project's own code and prompts in the clear |
237
246
 
238
247
  Every `cmd` is named where it is used, and nothing falls back to a neighbour: a
239
248
  `repair` with no command of its own is refused rather than quietly running the
240
- first-pass agent again. `builder` makes the unit of work. `assembly` judges the whole
241
- and, when that judgement refuses it, runs `assembly.fix` — not a second first-pass.
242
- `assembly.validate`, when declared, is a second, local judge of the whole: it
249
+ first-pass agent again. `builder` makes the unit of work. `ticket` judges the whole
250
+ and, when that judgement refuses it, runs `ticket.fix` — not a second first-pass.
251
+ `ticket.validate`, when declared, is a second, local judge of the whole: it
243
252
  runs after align, before the Authority sees the work (or before the final
244
- fold, with none). Its refusal is repaired by `assembly.fix` exactly as an
245
- Authority's is, and shares the same `maxRefusals` budget.
253
+ fold, with none). Its refusal is repaired by `ticket.fix` exactly as an
254
+ Authority's is, and shares the same `ticket.maxRefusals` budget.
246
255
 
247
256
  Paths a slot **writes** — `--transcript-dir`, `--agent-home` and
248
257
  `--plugins-out` — always resolve against the config file's directory, even
@@ -270,32 +279,35 @@ builder:
270
279
  producer:
271
280
  agent: cursor
272
281
  prompt: ./prompts/builder.md
273
- plugins: [./ai-plugins/core/builder]
274
- profile: builder
282
+ homePlugins: [./ai-plugins/core/builder]
283
+ appDeckProfile: .ai/profiles/builder
275
284
  args: [--mode, agent] # the vendor CLI's own flags
276
285
  timeoutMs: 1200000
277
286
  gates: { defaultTimeoutMs: 120000, gates: [] }
278
287
  ```
279
288
 
280
289
  Host writes the `cmd` it stands for — `node <slots>/<role>.mjs --prompt-file …
281
- -- node <slots>/agents/cursor.mjs --transcript-dir … --plugin-dir … --profile
290
+ -- node <slots>/agents/cursor.mjs --transcript-dir … --plugin-dir … --app-deck-profile
282
291
  … --plugins-out … --agent-arg …` — with `<slots>` the
283
292
  `node_modules/@bluewombat/slots` next to the config. Nothing past the config
284
293
  knows a pass was written short. The roles: `builder.producer`,
285
- `builder.repair`, `assembly.fix`, `assembly.validate`, and `authority.describe`
286
- (an argv, or `{ agent, prompt?, plugins?, profile?, args? }`).
294
+ `builder.repair`, `ticket.fix`, `ticket.validate`, and `authority.describe`
295
+ (an argv, or `{ agent, prompt?, homePlugins?, appDeckProfile?, args? }`).
287
296
 
288
297
  | Key | Meaning |
289
298
  | --------- | --------------------------------------------------------------------------------------------------------- |
290
299
  | `agent` | A name `agents` declares. With `cmd` beside it, the pass is refused |
291
300
  | `prompt` | `--prompt-file` of the role slot. Absent: the role's built-in template |
292
301
  | `rules` | `--rules-file` of the role slot |
293
- | `plugins` | Plugin folders of the Mason home, each a `--plugin-dir` |
294
- | `profile` | The project's deck profile for this role. Needs the agent's `pluginsOut`. See the slots' § Agents/Plugins |
302
+ | `homePlugins` | Plugin folders of the Mason home, each a `--plugin-dir` |
303
+ | `appDeckProfile` | The app's deck profile for this role, as a path: `[<app dir>/].ai/profiles/<name>`. Needs the agent's `pluginsOut`. See the slots' § Agents/Plugins |
295
304
  | `args` | Appended to the agent's own `args`, each an `--agent-arg` |
296
305
 
297
- A `profile` is read in the project, not in the Mason home: the role gets the
298
- base plugins and that profile, as the project's committed deck locks pin them.
306
+ An `appDeckProfile` is read in the app, not in the Mason home: the role gets the
307
+ app's base plugins, that profile's plugins and its `prompt.md`, as the app's
308
+ committed deck locks pin them. deck keeps profiles in `<app>/.ai/profiles/<name>`,
309
+ so the path has that shape; `<app dir>` is where the app's `.ai/` is in the
310
+ checkout (`.` by default, `apps/web` in a monorepo). It stays inside the checkout.
299
311
  `mason doctor` checks those locks in the work line without the network.
300
312
 
301
313
  Every tracker field lives in the `options` of its side. Host does not read them.
@@ -406,15 +418,15 @@ The config always names a producer (and a repair). Implementer can still run a
406
418
  Task as its Gate sequence alone — that is the first judgement of the assembled
407
419
  ticket, which makes nothing.
408
420
 
409
- ### Assembly fix
421
+ ### Ticket fix
410
422
 
411
423
  Spawned in the ticket workspace when a judgement of the whole refused it
412
424
  (`--report`, `--report-from`). Same argv as a Builder otherwise. Host hands
413
- Implementer `assembly.fix` as that pass's only agent: there is no first-pass
425
+ Implementer `ticket.fix` as that pass's only agent: there is no first-pass
414
426
  producer at this moment. The refusal it repairs may have come from an
415
- Authority or from `assembly.validate`.
427
+ Authority or from `ticket.validate`.
416
428
 
417
- ### Assembly validate
429
+ ### Ticket validate
418
430
 
419
431
  Spawned in the ticket workspace after align, with or without an Authority —
420
432
  `--intention`, `--id`, `--attempt`, `--context`. No `--report`: it is a
@@ -425,17 +437,18 @@ that completes cleanly but finds a problem is still a refusal.
425
437
 
426
438
  ### Gates
427
439
 
428
- Four independent sequences, one per role — `builder.producer`,
429
- `builder.repair`, `assembly.fix`, `assembly.validate` — plus `assembly.gates`
430
- for the one judgement-only pass with no producer of its own (re-checking the
431
- assembled ticket; `ci-green` reads the checks the work line ran on what the
432
- Publisher placed, so it belongs there). No sharing, no dispatch choosing
433
- between two buckets: a check right for one pass is not necessarily right for
434
- another, even within the same stage. `builder.repair` is the sharpest edge —
435
- it does not inherit `builder.producer.gates`, so a repair Attempt with no
436
- Gate of its own runs ungated.
437
-
438
- Implementer's Gates are told `--stage unit` or `--stage assembly` alongside
440
+ Four sequences: `builder.gates`, shared by `builder.producer` and
441
+ `builder.repair` (a repair is judged by exactly what judged the first try),
442
+ then one per assembly role — `ticket.fix`, `ticket.validate` — plus
443
+ `ticket.gates` for the one judgement-only pass with no producer of its own
444
+ (re-checking the assembled ticket; `ci-green` reads the checks the work line
445
+ ran on what the Publisher placed, so it belongs there). The assembly lists are
446
+ not shared with each other or with the builder, and no dispatch chooses between
447
+ two buckets: a check right for one pass is not necessarily right for another,
448
+ even within the same stage. `gates` written on `builder.producer` or
449
+ `builder.repair` is refused, and the message names `builder.gates`.
450
+
451
+ Implementer's Gates are told `--stage unit` or `--stage ticket` alongside
439
452
  `--id`, `--attempt`, `--gate-id` and `--intention`.
440
453
  Most have no use for it. `workspace-changed` does: "nothing changed" means
441
454
  nothing was done in a unit, and is the correct outcome for an assembly that
@@ -451,11 +464,11 @@ from what another child left behind, so a slow producer cannot leave its
451
464
  Gates a millisecond to answer in.
452
465
 
453
466
  What bounds the unit is therefore what you wrote:
454
- `builder.maxAttempts × (builder.producer.timeoutMs + Σ builder.producer.gates timeouts)`
467
+ `builder.maxAttempts × (builder.producer.timeoutMs + Σ builder.gates timeouts)`
455
468
  for a first pass, and the same shape with `repair` in place of `producer` once
456
469
  a Gate has refused one.
457
470
  A repair and an assembly fix each carry their own `timeoutMs`. An assembly fix
458
- is bounded the same way from `assembly.maxAttempts` and `assembly.fix`.
471
+ is bounded the same way from `ticket.maxAttempts` and `ticket.fix`.
459
472
 
460
473
  ```json
461
474
  {
@@ -471,6 +484,60 @@ is bounded the same way from `assembly.maxAttempts` and `assembly.fix`.
471
484
  }
472
485
  ```
473
486
 
487
+ ### The e2e acceptance check
488
+
489
+ A Project proves each Feature by an acceptance suite, run once on the assembled Feature
490
+ line, when its config says so, by a pair:
491
+
492
+ ```yaml
493
+ builder:
494
+ e2e: # makes the Ticket that holds the suite (label `e2e` on the tracker)
495
+ agent: claude
496
+ prompt: ./prompts/e2e.md # {{task}} is the scenarios; {{report}}, {{refused_by}}, {{refusal}} on a refused pass
497
+ timeoutMs: 1800000
498
+ gates: # form only: it compiles, one test per manifest id, no application file touched
499
+ gates:
500
+ - { id: e2e-form, argv: [...], timeoutMs: 120000 }
501
+ feature:
502
+ validate:
503
+ agent: false
504
+ gates:
505
+ defaultTimeoutMs: 120000
506
+ gates:
507
+ - id: e2e
508
+ skipWhen: skip-test-e2e # Host does not run this Gate for a Feature that carries the label
509
+ timeoutMs: 720000
510
+ argv:
511
+ - node
512
+ - ./node_modules/@bluewombat/slots/gates/e2e.mjs
513
+ - --up
514
+ - pnpm e2e:up
515
+ - --ready
516
+ - pnpm e2e:ready
517
+ - --run
518
+ - pnpm e2e
519
+ - --down
520
+ - pnpm e2e:down
521
+ ```
522
+
523
+ `skipWhen` is the one condition Host reads for a Gate, on a Feature's Gates only
524
+ (`feature.validate`, `feature.gates`, `feature.fix`); its one value today is
525
+ `skip-test-e2e`, whatever the tracker calls that label. A Gate left out is not a Gate that
526
+ passed: the journal gets `gate-skipped` and the trace says "`e2e` not run: the Feature carries
527
+ skip-test-e2e". The Gate knows nothing of the skip and no environment variable carries it.
528
+
529
+ The tracker side: a Ticket carrying the e2e label (`e2eLabel` of the TicketTracker manager) is
530
+ made by `builder.e2e` and has no tests written first; a Feature carrying the skip label
531
+ (`skipTestE2eLabel` of the FeatureTracker manager) has that Ticket cancelled, with a line in
532
+ the trace. The label is read again until the Feature is submitted. A Project that declares the
533
+ check refuses a Feature that holds neither an e2e Ticket nor the skip label, or whose e2e
534
+ Ticket is not blocked by every other Ticket, as `invalid`, with the reason as a comment.
535
+
536
+ What the Gate proves is a red/green verdict; what the operator reads is its text in the
537
+ journal (`gate-finished`) — a red suite is also in the reason of the refusal the fix is
538
+ given and in the escalation. A passing suite's text is not shown on the pull request: see
539
+ RESERVATIONS I17.
540
+
474
541
  ### Describer
475
542
 
476
543
  How a delivered ticket describes itself — the words the manager puts on the
@@ -543,25 +610,28 @@ it each stop `mason run` at boot, and `mason doctor` reports the same offline
543
610
  and is the operator's directory.
544
611
 
545
612
  ```
546
- assembled → align → assembly.validate (optional, local)
547
- refused → assembly.fix, then align again, then validate again
613
+ assembled → align → recheck (builder.gates again, on the aligned work)
614
+ conflict or failed → ticket.fix, then align again
615
+ → ticket.validate (optional, local)
616
+ refused → ticket.fix, then align again, recheck, validate again
548
617
  accepted → Publisher places the work → manager opens the Submission
549
618
  → submitted, and the run comes back to it on each poll
550
- assembly.gates judge what was published
551
- refused → assembly.fix, then the same Submission is offered again
552
- accepted → the Authority folds it → done
619
+ ticket.gates judge what was published
620
+ refused → ticket.fix, then the same Submission is offered again
621
+ accepted → has the line moved on since the align? yes: align again
622
+ no: the Authority folds it → done (a refused fold also aligns again)
553
623
  ```
554
624
 
555
- Neither half judges: that is what `assembly.gates` are for. The Authority places
556
- and folds; a Gate says whether the fold may happen. `assembly.validate`, when
625
+ Neither half judges: that is what `ticket.gates` are for. The Authority places
626
+ and folds; a Gate says whether the fold may happen. `ticket.validate`, when
557
627
  declared, judges before either half — the same verdict has the same fate
558
628
  whether or not there is an Authority to offer the work to afterwards. After the
559
629
  Authority folds, Host brings its copy of the work line up to date with a
560
630
  fast-forward — the work line moved where the Authority is, and the next
561
631
  Isolation must start from that version, not from a copy left behind.
562
632
 
563
- A refusal is not a second failure path: its report reaches `assembly.fix`
564
- exactly as a Gate's report reaches `builder.repair`. After `maxRefusals`
633
+ A refusal is not a second failure path: its report reaches `ticket.fix`
634
+ exactly as a Gate's report reaches `builder.repair`. After `ticket.maxRefusals`
565
635
  refusals the ticket escalates with that report as its Trace, and `done` names
566
636
  the Submission rather than a directory on the machine that ran.
567
637
 
package/SPECS.md CHANGED
@@ -103,6 +103,7 @@ The FeatureTracker's `listen` delivers Features whose specification is final.
103
103
  | `invalid` (new, or the intention changed) | emit `invalid` on the Feature, keyed by fingerprint |
104
104
  | `invalid` (same fingerprint as recorded) | leave it; the tracker was told, and telling it is what delivered it again |
105
105
  | `upsert` (absent or `waiting`) | `admitFeature`, emit `accepted`. Nothing is driven here: the sweep does it |
106
+ | `upsert` (`open` or `escalated`) whose `skip_e2e` differs from the ledger's | `setFeatureSkipE2e`; journal `skip-e2e`; no other change |
106
107
  | `upsert` (any other state) | leave it; journal `skipped`. An echo of a Host report is this row |
107
108
  | `upsert` + `signalsResume` while `escalated` | same as `resume` — a poll snapshot has no `labeled` edge |
108
109
  | `resume` | resume the Feature, emit `resumed` |
@@ -139,6 +140,22 @@ advanced (`advanceProject`):
139
140
  4. A Feature whose Tickets are all blocked escalates; one that cannot be
140
141
  settled because the Authority lacks `settled` waits.
141
142
 
143
+ The e2e acceptance check, when the Project declares it (`builder.e2e`):
144
+
145
+ - A Feature does not open (and is reported `invalid`, once per reason) unless it holds exactly
146
+ one e2e Ticket (`TicketEntry.e2e`), blocked by every other live Ticket, or carries `skip_e2e`.
147
+ - A Ticket with `e2e` is admitted with `e2e` and `no_tests`: no tests are written first, and
148
+ Transformers makes it with `builder.e2e` (its command, its Gates, for the first pass, a refused
149
+ pass and the recheck) instead of the producer and the repair.
150
+ - A Feature with `skip_e2e` has its e2e Ticket cancelled by the sweep (journal
151
+ `e2e-ticket-cancelled`, a trace line), and Transformers leaves out, for that Feature, every
152
+ Gate of `feature.validate`, `feature.gates` and `feature.fix` that has `skipWhen:
153
+ skip-test-e2e`: the journal gets `gate-skipped` with the Gate id and the reason, and the trace
154
+ says "`<id>` not run: the Feature carries skip-test-e2e". The mark is read from the ledger as
155
+ each pass starts (`TransformerSlots.marks`).
156
+ - A Feature that is not marked and no longer holds a live e2e Ticket (it was cancelled while the
157
+ mark was on) escalates when it closes.
158
+
142
159
  Host never drives an `escalated` unit: that state is frozen until a human `resume`.
143
160
 
144
161
  Every Event Host emits is a write to the tracker, and a tracker that lists by
@@ -212,22 +229,22 @@ and the Gate sequence starts empty. Example Gates live in
212
229
  `@bluewombat/slots/gates/` (`parent-clean`, `sensitive-path`,
213
230
  `workspace-changed`, `ci-green`).
214
231
 
215
- Four independent Gate sequences, one per role — `builder.producer.gates`, `builder.repair.gates`, `assembly.fix.gates`,
216
- `assembly.validate.gates` — plus `assembly.gates` for the one judgement-only
217
- pass with no producer of its own. No sequence is shared and nothing is
218
- dispatched between buckets: a check that is right for one pass can be wrong
232
+ Four Gate sequences: `builder.gates`, shared by `builder.producer` and
233
+ `builder.repair`, then `ticket.fix.gates`, `ticket.validate.gates` and
234
+ `ticket.gates` for the one judgement-only pass with no producer of its own.
235
+ The assembly sequences are not shared and nothing is dispatched between buckets: a check that is right for one pass can be wrong
219
236
  for another, even within the same stage — `workspace-changed` says an Attempt
220
237
  that changed nothing did nothing, which is true of `builder.producer`/`repair`
221
- and false of `assembly.gates`'s pass, whose correct outcome is that nothing
222
- was left to do. `builder.repair` does not inherit `builder.producer.gates`: a
223
- repair Attempt with no Gate of its own runs ungated. The assembly's
238
+ and false of `ticket.gates`'s pass, whose correct outcome is that nothing
239
+ was left to do. A repair Attempt is judged by `builder.gates`, the same list as the
240
+ producer's (Host hands Implementer that list as both `gates` and `repairGates`). The assembly's
224
241
  judgement-only pass produces nothing unless something refused it first: it is
225
- a judgement, and a refusal is `assembly.fix`, not a second first-pass. Two
226
- things may judge it: `assembly.gates` (and an Authority's own checks on what
227
- was published) and, optionally, `assembly.validate` — a local, read-only pass
228
- that runs before either, judged by its own `assembly.validate.gates`, not
229
- `assembly.gates`. Both send a refusal to the same `fix` and spend the same
230
- `maxRefusals` budget.
242
+ a judgement, and a refusal is `ticket.fix`, not a second first-pass. Two
243
+ things may judge it: `ticket.gates` (and an Authority's own checks on what
244
+ was published) and, optionally, `ticket.validate` — a local, read-only pass
245
+ that runs before either, judged by its own `ticket.validate.gates`, not
246
+ `ticket.gates`. Both send a refusal to the same `fix` and spend the same
247
+ `ticket.maxRefusals` budget.
231
248
 
232
249
  Every child of a Task carries its own ceiling and no other. Every `cmd` names
233
250
  its own `timeoutMs`, and none is inherited from a neighbour — nothing else
@@ -236,7 +253,7 @@ or its sequence's `defaultTimeoutMs`, and a Gate that would end up with neither
236
253
  is refused when the config loads. Nothing is derived from what another child
237
254
  left behind, so what bounds a whole Task is arithmetic the Project can do:
238
255
  the builder's `maxAttempts` times its producer's ceiling plus the sum of its Gates'.
239
- An assembly fix is bounded the same way from `assembly.maxAttempts` and `assembly.fix`.
256
+ An assembly fix is bounded the same way from `ticket.maxAttempts` and `ticket.fix`.
240
257
  Root `timeoutMs` bounds what is outside a Task — the manager, the
241
258
  isolations. Host has no flag named after a tracker (`--repo` is not a Host
242
259
  flag): tracker fields come from that package.
@@ -406,6 +423,11 @@ positional: `release github:owner/name#19`.
406
423
  open, escalated or submitted. A Feature blocked by another does not open
407
424
  until it is `done`. A Feature whose Tickets are not all `task-ready` does
408
425
  not open.
426
+ 16b. With the e2e check declared: a Feature without an e2e Ticket nor `skip_e2e` does not open and
427
+ is reported `invalid`; with `skip_e2e` its e2e Ticket is cancelled and the e2e Gate is not run,
428
+ the journal and the trace saying so; with an e2e Ticket the line is judged once by the suite,
429
+ red goes to `feature.fix` and green lets the line be offered. A Project without `builder.e2e`
430
+ is unchanged.
409
431
  17. After an escalation, a `probe` of `gone` cancels that Ticket (or Feature)
410
432
  and the sweep goes on. `unavailable` leaves the
411
433
  escalation. A listen that missed the source does not probe. GitHub: a 404
package/dist/cli.js CHANGED
@@ -35,15 +35,15 @@ Shared flags: --config FILE --feature-tracker NAME --feature-tracker-option KEY=
35
35
  --builder -- CMD --builder-timeout-ms N --builder-max-attempts N
36
36
  --builder-repair -- CMD --builder-repair-timeout-ms N
37
37
  --builder-gate ID -- CMD --builder-gate-timeout-ms N
38
- --assembly-fix -- CMD --assembly-fix-timeout-ms N --assembly-max-attempts N
39
- --assembly-validate -- CMD --assembly-validate-timeout-ms N
40
- --assembly-gate ID -- CMD --assembly-gate-timeout-ms N
38
+ --ticket-fix -- CMD --ticket-fix-timeout-ms N --ticket-max-attempts N
39
+ --ticket-validate -- CMD --ticket-validate-timeout-ms N
40
+ --ticket-gate ID -- CMD --ticket-gate-timeout-ms N
41
41
  --timeout-ms N --poll-interval-ms N
42
42
 
43
43
  A gate's --*-gate-timeout-ms comes before the --*-gate it bounds.
44
44
 
45
45
  watch / status also take --json (snapshot as JSON). explain takes an optional role
46
- (builder.producer, builder.repair, assembly.fix, assembly.validate, authority.describe) and --json.
46
+ (builder.producer, builder.repair, ticket.fix, ticket.validate, authority.describe) and --json.
47
47
 
48
48
  log also takes --problems, --all, and --json (every kept line).
49
49
  --tail N limits the text film (0 = no limit). With --json it names kept and shown when it drops lines.
@@ -2,8 +2,12 @@
2
2
  export declare const ROLE_SCRIPTS: {
3
3
  readonly "builder.producer": "builders/producer.mjs";
4
4
  readonly "builder.repair": "builders/repair.mjs";
5
- readonly "assembly.fix": "assembly/fix.mjs";
6
- readonly "assembly.validate": "assembly/validate.mjs";
5
+ readonly "builder.tests": "builders/tests.mjs";
6
+ readonly "builder.e2e": "builders/e2e.mjs";
7
+ readonly "ticket.fix": "ticket/fix.mjs";
8
+ readonly "ticket.validate": "ticket/validate.mjs";
9
+ readonly "feature.fix": "feature/fix.mjs";
10
+ readonly "feature.validate": "feature/validate.mjs";
7
11
  readonly "authority.describe": "messages/git-producer.mjs";
8
12
  };
9
13
  export type AgentRole = keyof typeof ROLE_SCRIPTS;
@@ -36,9 +40,26 @@ export declare function readAgents(value: unknown, configDir: string): Read<Map<
36
40
  * mapping; its keys were checked by the caller.
37
41
  */
38
42
  export declare function expandAgentPass(object: Record<string, unknown>, role: AgentRole, agents: Agents, configDir: string, key: string): Read<string[]>;
43
+ /**
44
+ * Where an app's deck profile is, from the path the config gives it: the app
45
+ * folder that holds `.ai/` (`.` for the checkout itself) and the profile's name.
46
+ * deck keeps its profiles in `<app>/.ai/profiles/<name>`, so that is the shape;
47
+ * the app folder is what a Project may place elsewhere in its checkout.
48
+ *
49
+ * The slot that builds the profile reads the path by the same rule
50
+ * (`parseAppDeckProfile` in `agent-plugins.mjs`); neither depends on the other.
51
+ */
52
+ export declare function parseAppDeckProfile(given: string): {
53
+ ok: true;
54
+ root: string;
55
+ name: string;
56
+ } | {
57
+ ok: false;
58
+ reason: string;
59
+ };
39
60
  /** The agent and profile a pass's argv names, written short or not; for `doctor`. */
40
61
  export declare function agentPartOf(argv: readonly string[]): {
41
- profile?: string;
62
+ appDeckProfile?: string;
42
63
  pluginsOut?: string;
43
64
  pluginDirs: string[];
44
65
  };
@@ -1 +1 @@
1
- {"version":3,"file":"agent-shorthand.d.ts","sourceRoot":"","sources":["../../src/config/agent-shorthand.ts"],"names":[],"mappings":"AA2BA,mEAAmE;AACnE,eAAO,MAAM,YAAY;;;;;;CAMf,CAAC;AAEX,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,YAAY,CAAC;AAgBlD,uFAAuF;AACvF,eAAO,MAAM,eAAe,aAAsE,CAAC;AAEnG,wEAAwE;AACxE,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,MAAM,GAAG,WAAW,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;AAEpD,KAAK,IAAI,CAAC,CAAC,IAAI;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtE,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAwG1F;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,IAAI,CAAC,MAAM,EAAE,CAAC,CA8EhB;AAED,qFAAqF;AACrF,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG;IACpD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB,CAoBA"}
1
+ {"version":3,"file":"agent-shorthand.d.ts","sourceRoot":"","sources":["../../src/config/agent-shorthand.ts"],"names":[],"mappings":"AA4BA,mEAAmE;AACnE,eAAO,MAAM,YAAY;;;;;;;;;;CAUf,CAAC;AAEX,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,YAAY,CAAC;AAgBlD,uFAAuF;AACvF,eAAO,MAAM,eAAe,aAO1B,CAAC;AAEH,wEAAwE;AACxE,MAAM,MAAM,SAAS,GAAG;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,EAAE,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,MAAM,GAAG,WAAW,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;AAEpD,KAAK,IAAI,CAAC,CAAC,IAAI;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtE,wBAAgB,UAAU,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAwG1F;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAC7B,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,MAAM,EACd,SAAS,EAAE,MAAM,EACjB,GAAG,EAAE,MAAM,GACV,IAAI,CAAC,MAAM,EAAE,CAAC,CAoFhB;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,MAAM,GACZ;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAmB1E;AAED,qFAAqF;AACrF,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG;IACpD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB,CAoBA"}
@@ -1,4 +1,4 @@
1
- import { resolve } from "node:path";
1
+ import { isAbsolute, resolve } from "node:path";
2
2
  /**
3
3
  * The short way to write an agent pass: which agent, which prompt, which
4
4
  * plugins — and Host writes the argv a pass has always been.
@@ -8,13 +8,14 @@ import { resolve } from "node:path";
8
8
  * transcripts: ./.mason/transcripts, pluginsOut: ./.mason/plugins }
9
9
  * builder:
10
10
  * producer: { agent: cursor, prompt: ./prompts/builder.md,
11
- * plugins: [./ai-plugins/core/builder], profile: builder, timeoutMs: … }
11
+ * homePlugins: [./ai-plugins/core/builder],
12
+ * appDeckProfile: .ai/profiles/builder, timeoutMs: … }
12
13
  *
13
14
  * expands to
14
15
  *
15
16
  * node <slots>/builders/producer.mjs --prompt-file <prompt> --
16
17
  * node <slots>/agents/cursor.mjs --transcript-dir … --agent-home … --api-key-env …
17
- * --plugin-dir <core> --profile builder --plugins-out …
18
+ * --plugin-dir <core> --app-deck-profile .ai/profiles/builder --plugins-out …
18
19
  *
19
20
  * The expansion is the whole ticket: nothing downstream knows a pass was
20
21
  * written short, so `cmd` stays the one shape the rest of Host reads, and a
@@ -26,8 +27,12 @@ const SLOTS_PACKAGE = "node_modules/@bluewombat/slots";
26
27
  export const ROLE_SCRIPTS = {
27
28
  "builder.producer": "builders/producer.mjs",
28
29
  "builder.repair": "builders/repair.mjs",
29
- "assembly.fix": "assembly/fix.mjs",
30
- "assembly.validate": "assembly/validate.mjs",
30
+ "builder.tests": "builders/tests.mjs",
31
+ "builder.e2e": "builders/e2e.mjs",
32
+ "ticket.fix": "ticket/fix.mjs",
33
+ "ticket.validate": "ticket/validate.mjs",
34
+ "feature.fix": "feature/fix.mjs",
35
+ "feature.validate": "feature/validate.mjs",
31
36
  "authority.describe": "messages/git-producer.mjs",
32
37
  };
33
38
  const ADAPTERS = new Set(["cursor", "claude"]);
@@ -44,7 +49,14 @@ const AGENT_KEYS = new Set([
44
49
  "args",
45
50
  ]);
46
51
  /** The keys a pass written short may carry, besides its own (`timeoutMs`, `gates`). */
47
- export const SHORT_PASS_KEYS = new Set(["agent", "prompt", "rules", "plugins", "profile", "args"]);
52
+ export const SHORT_PASS_KEYS = new Set([
53
+ "agent",
54
+ "prompt",
55
+ "rules",
56
+ "homePlugins",
57
+ "appDeckProfile",
58
+ "args",
59
+ ]);
48
60
  export function readAgents(value, configDir) {
49
61
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
50
62
  return { ok: false, reason: 'Config "agents" must be a YAML mapping of agent names.' };
@@ -176,19 +188,25 @@ export function expandAgentPass(object, role, agents, configDir, key) {
176
188
  if (rules.ok === false) {
177
189
  return rules;
178
190
  }
179
- const plugins = stringList(object.plugins, `${key}.plugins`);
191
+ const plugins = stringList(object.homePlugins, `${key}.homePlugins`);
180
192
  if (plugins.ok === false) {
181
193
  return plugins;
182
194
  }
183
- const profile = object.profile;
184
- if (profile !== undefined && (typeof profile !== "string" || profile.trim().length === 0)) {
185
- return { ok: false, reason: `Config "${key}.profile" must be a deck profile name.` };
186
- }
187
- if (profile !== undefined && agent.pluginsOut === undefined) {
188
- return {
189
- ok: false,
190
- reason: `Config "${key}" names a deck profile, so "agents.${name}.pluginsOut" must say where it is built.`,
191
- };
195
+ const profile = object.appDeckProfile;
196
+ if (profile !== undefined) {
197
+ if (typeof profile !== "string") {
198
+ return { ok: false, reason: `Config "${key}.appDeckProfile" must be a path.` };
199
+ }
200
+ const located = parseAppDeckProfile(profile);
201
+ if (!located.ok) {
202
+ return { ok: false, reason: `Config "${key}.appDeckProfile": ${located.reason}` };
203
+ }
204
+ if (agent.pluginsOut === undefined) {
205
+ return {
206
+ ok: false,
207
+ reason: `Config "${key}" names an app deck profile, so "agents.${name}.pluginsOut" must say where it is built.`,
208
+ };
209
+ }
192
210
  }
193
211
  const args = stringList(object.args, `${key}.args`);
194
212
  if (args.ok === false) {
@@ -224,13 +242,40 @@ export function expandAgentPass(object, role, agents, configDir, key) {
224
242
  argv.push("--plugin-dir", resolve(configDir, dir));
225
243
  }
226
244
  if (profile !== undefined && agent.pluginsOut !== undefined) {
227
- argv.push("--profile", profile, "--plugins-out", agent.pluginsOut);
245
+ argv.push("--app-deck-profile", profile, "--plugins-out", agent.pluginsOut);
228
246
  }
229
247
  for (const arg of [...agent.args, ...args.value]) {
230
248
  argv.push("--agent-arg", arg);
231
249
  }
232
250
  return { ok: true, value: argv };
233
251
  }
252
+ /**
253
+ * Where an app's deck profile is, from the path the config gives it: the app
254
+ * folder that holds `.ai/` (`.` for the checkout itself) and the profile's name.
255
+ * deck keeps its profiles in `<app>/.ai/profiles/<name>`, so that is the shape;
256
+ * the app folder is what a Project may place elsewhere in its checkout.
257
+ *
258
+ * The slot that builds the profile reads the path by the same rule
259
+ * (`parseAppDeckProfile` in `agent-plugins.mjs`); neither depends on the other.
260
+ */
261
+ export function parseAppDeckProfile(given) {
262
+ const matched = /^(?:(.*?)\/)?\.ai\/profiles\/([^/]+)\/?$/.exec(given.trim());
263
+ const name = matched?.[2];
264
+ if (matched === null ||
265
+ name === undefined ||
266
+ !/^[A-Za-z0-9_-][A-Za-z0-9._-]*$/.test(name) ||
267
+ name === "current") {
268
+ return {
269
+ ok: false,
270
+ reason: `"${given}" is not a deck profile path: [<app dir>/].ai/profiles/<name>.`,
271
+ };
272
+ }
273
+ const root = matched[1] ?? ".";
274
+ if (isAbsolute(root) || root.split("/").includes("..")) {
275
+ return { ok: false, reason: `"${given}" must stay inside the checkout.` };
276
+ }
277
+ return { ok: true, root, name };
278
+ }
234
279
  /** The agent and profile a pass's argv names, written short or not; for `doctor`. */
235
280
  export function agentPartOf(argv) {
236
281
  const split = argv.indexOf("--");
@@ -243,8 +288,8 @@ export function agentPartOf(argv) {
243
288
  if (value === undefined) {
244
289
  return;
245
290
  }
246
- if (token === "--profile") {
247
- found.profile = value;
291
+ if (token === "--app-deck-profile") {
292
+ found.appDeckProfile = value;
248
293
  }
249
294
  else if (token === "--plugins-out") {
250
295
  found.pluginsOut = value;