Compare commits

..
Author SHA1 Message Date
goals 3af594590a fix(#1264): preserve portable standalone launch
ci/woodpecker/pr/ci Pipeline failed
2026-08-16 19:01:26 -05:00
goals 9dc90be7e1 fix(#1264): harden unattended identity bootstrap
ci/woodpecker/pr/ci Pipeline failed
2026-08-16 18:33:39 -05:00
goals 43fa047787 fix(#1264): bootstrap fleet identity without a TTY
ci/woodpecker/pr/ci Pipeline failed
2026-08-16 17:37:32 -05:00
37 changed files with 1722 additions and 1414 deletions
+9 -9
View File
@@ -9,19 +9,19 @@ This book is the canonical home for installation, configuration, deployment, rou
- [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries. - [Documentation atlas](../README.md) — placement rules and source-of-truth boundaries.
- [Documentation sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary. - [Documentation sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary.
- [Product requirements](../PRD.md) — normative requirements, currently marked draft. - [Product requirements](../PRD.md) — normative requirements, currently marked draft.
- [Operations index](operations/README.md) — current local procedures and explicitly held operational outlines. - [Operations index](operations/README.md) — current local procedures, unattended fleet first-start handling, and explicitly held operational outlines.
- [Security index](security/README.md) — current SSO provider configuration. - [Security index](security/README.md) — current SSO provider configuration.
## Chapter map ## Chapter map
| Chapter | Scope | Status | | Chapter | Scope | Status |
| ------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------- | | ------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. | | `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. |
| `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. | | `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. |
| `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. | | `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. |
| [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery is current; connector lease operations are held. | | [`operations/`](operations/README.md) | Health, observability, routine operation, and maintenance. | Local upgrade/recovery and fleet first start are current; connector lease operations are held. |
| [`security/`](security/README.md) | Authentication, authorization, SSO, secrets, and security controls. | SSO provider guide is current; other pages are planned. | | [`security/`](security/README.md) | Authentication, authorization, SSO, secrets, and security controls. | SSO provider guide is current; other pages are planned. |
| `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. | | `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. |
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice. Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
+1
View File
@@ -5,6 +5,7 @@
## Current procedures ## Current procedures
- [Upgrade safety and recovery](upgrade-safety-and-recovery.md) — installed-CLI and local-PGlite upgrade, rollback, and framework-configuration recovery. - [Upgrade safety and recovery](upgrade-safety-and-recovery.md) — installed-CLI and local-PGlite upgrade, rollback, and framework-configuration recovery.
- [Fleet unattended first start](fleet-unattended-first-start.md) — systemd/no-TTY identity initialization, failure handling, and isolated verification.
## Held procedures ## Held procedures
@@ -0,0 +1,70 @@
# Fleet Unattended First-Start Operations
> **Status:** Current after issue #1264 lands. This runbook covers only Mosaic's first-run identity
> gate; it does not install runtimes or credentials.
## Operational contract
A systemd fleet unit launches under a sanitized environment with no TTY. The generated environment
sets `MOSAIC_AGENT_NAME`; Mosaic resolves that exact value against the canonical installed roster
before writing identity files.
If top-level identity contracts are missing, Mosaic atomically seeds them from the shipped generic
sources:
| Destination | Source | New-file mode |
| ---------------------- | ------------------------------- | ------------- |
| `$MOSAIC_HOME/SOUL.md` | `$MOSAIC_HOME/defaults/SOUL.md` | `0600` |
| `$MOSAIC_HOME/USER.md` | `$MOSAIC_HOME/defaults/USER.md` | `0600` |
Creation is no-clobber and safe under concurrent seat starts. Existing regular files remain
byte-for-byte and mode-for-mode unchanged. The runtime composer then injects the exact roster name
and class; the generic source files grant no seat authority.
## Failure handling
The fleet path never falls back to an interactive wizard. It exits nonzero before runtime execution
when:
- `MOSAIC_AGENT_NAME` is not an exact roster member;
- a defined ambient `MOSAIC_AGENT_CLASS` is blank/whitespace or disagrees with that member's
canonical class;
- a missing destination has no safe regular default source;
- a source or existing destination is a symlink (including dangling), directory, unavailable, or over the bounded size;
- the fleet communications helper/roster cannot be validated; or
- `USER.md` cannot be securely re-read at the point where its content is composed.
Diagnostics begin with:
```text
[mosaic] ERROR: unattended fleet identity initialization failed: ...
```
Repair the exact named source, destination, roster, or helper and retry only that roster member. Do
not delete or replace an existing personalized `SOUL.md`/`USER.md` merely to clear the check.
## Verification without a live seat
The source gate is:
```bash
pnpm --filter @mosaicstack/mosaic... build && \
pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/launch-first-start.spec.ts
```
The build leg is load-bearing: `dist/` is ignored, so a direct Vitest invocation could otherwise run
absent or stale CLI output. The gate runs the exact-source built CLI in subprocesses with piped stdin,
temporary homes, a canonical fixture roster, fake runtime/broker executables, and no provider call. It
covers no-TTY launch, exact identity,
private modes, no-clobber, missing/symlink defaults, unknown members, blank/mismatched class,
portable standalone composition/wizard preservation, and concurrent first start.
Do not use this fixture as proof that a real provider credential is present or that a package has
been deployed. Those require separate environment-specific evidence.
## Related
- [User workflow](../../USER-GUIDE/workflows/fleet-unattended-first-start.md)
- [Developer architecture](../../DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md)
- [Verification report](../../reports/qa/2026-08-16-1264-unattended-first-start.md)
+1
View File
@@ -26,6 +26,7 @@ This book is the canonical home for architecture, package and application guides
- [Lease-broker operations and verification](testing/lease-broker-operations.md) — safe static/test commands plus explicitly held live operations. - [Lease-broker operations and verification](testing/lease-broker-operations.md) — safe static/test commands plus explicitly held live operations.
- [Channel adapters](integrations/channel-adapters.md) — current shared contracts and Discord reference boundary; future adapter parity is draft. - [Channel adapters](integrations/channel-adapters.md) — current shared contracts and Discord reference boundary; future adapter parity is draft.
- [Fleet first-start identity](architecture/fleet-first-start-identity.md) — no-TTY launch boundary, roster authority, and no-clobber filesystem design.
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice. Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
@@ -11,6 +11,7 @@ This chapter is the canonical home for Mosaic Stack's system model, component bo
- [`mutator-class-gate.md`](mutator-class-gate.md) — default-deny tool authorization, runtime adapters, launch choke point, and parser assurance boundary. - [`mutator-class-gate.md`](mutator-class-gate.md) — default-deny tool authorization, runtime adapters, launch choke point, and parser assurance boundary.
- [`compaction-revocation.md`](compaction-revocation.md) — Claude/Pi observer lifecycle, runtime generations, revocation, and the bounded residual stale window. - [`compaction-revocation.md`](compaction-revocation.md) — Claude/Pi observer lifecycle, runtime generations, revocation, and the bounded residual stale window.
- [`channel-protocol.md`](channel-protocol.md) — current shared channel DTOs and Discord compatibility baseline, with unimplemented adapter work explicitly marked draft. - [`channel-protocol.md`](channel-protocol.md) — current shared channel DTOs and Discord compatibility baseline, with unimplemented adapter work explicitly marked draft.
- [`fleet-first-start-identity.md`](fleet-first-start-identity.md) — roster-owned identity bootstrap for concurrent no-TTY fleet launches.
- [`decisions/mos-runtime-portability-m1.md`](decisions/mos-runtime-portability-m1.md) — current logical identity, connector lease, grant, audit, and fencing decision; connector activation remains held. - [`decisions/mos-runtime-portability-m1.md`](decisions/mos-runtime-portability-m1.md) — current logical identity, connector lease, grant, audit, and fencing decision; connector activation remains held.
These pages are current security-contract references and are consumed by the lease-broker acceptance suites. Their live deployment gaps remain explicitly labeled in the pages; this migration does not change runtime behavior. These pages are current security-contract references and are consumed by the lease-broker acceptance suites. Their live deployment gaps remain explicitly labeled in the pages; this migration does not change runtime behavior.
@@ -0,0 +1,81 @@
# Fleet First-Start Identity Boundary
> **Status:** Implemented by issue #1264. Requirements: `FCM-REQ-12`, `AC-FCM-10`.
## Problem
`launchRuntime()` called `checkSoul()` before runtime execution. A missing top-level `SOUL.md`
caused `checkSoul()` to spawn a child `mosaic wizard` with inherited stdio. Under a systemd-created
fleet pane with no TTY, that child blocked or failed before the runtime boundary even though generic
`defaults/SOUL.md` and `defaults/USER.md` already shipped in the same `MOSAIC_HOME`.
## Chosen boundary
The fix remains at `checkSoul()` and does not add flags to `yolo`, fleet commands, systemd units, or
`start-agent-session.sh`:
1. A present, nonblank, whitespace-exact `MOSAIC_AGENT_NAME` selects the fleet path.
2. `resolveFleetIdentity()` must resolve that exact member through the existing roster/helper
boundary, and any defined `MOSAIC_AGENT_CLASS` (including blank/whitespace) must canonicalize to
the roster class, before any identity seed. Only undefined means absent.
3. `lstatSync()` preflights every destination directory entry without following links, so a dangling
link is rejected before its counterpart can be published.
4. Safe bounded snapshots are read from only the missing contracts under `defaults/`.
5. Each snapshot is written to a random owner-private temporary file in `MOSAIC_HOME`.
6. `linkSync()` publishes the complete file without overwriting an existing path. `EEXIST` means a
concurrent seat or operator won; the existing path is preserved and revalidated.
7. Temporary files are removed, and both installed contracts are re-opened through the no-symlink
secure-file reader before launch continues.
8. `composeContract()` independently re-resolves the roster, securely reads fleet `USER.md` through
a Linux descriptor at the point of use, and injects exact member identity and communications data.
A standalone launch with no `MOSAIC_AGENT_NAME` retains the portable tolerant USER read and the
interactive wizard. Fleet-only no-follow enforcement must not make supported standalone macOS
composition depend on Linux `/proc` descriptor traversal.
## Identity and authority
The copied defaults deliberately say “Mosaic agent”; they are a generic behavioral base. They are
not the source of a fleet seat's identity. The canonical roster controls:
- exact agent/session name;
- canonical role/class and persona;
- peer rows and point of contact;
- tmux socket and helper target; and
- communications generation.
An unknown/padded ambient name, mismatched class, or explicitly blank/whitespace class fails before
any file is seeded. This avoids replacing the interactive wall with a fleet of indistinguishable or
ambiently invented identities.
## Concurrency and filesystem properties
- Sources and final destinations are bounded regular files beneath `MOSAIC_HOME`; target and dangling
symlinks are not followed.
- New files have mode `0600`.
- Hard-link publication is same-filesystem, atomic, and no-clobber.
- A temporary path is removed only when this process successfully created it.
- All required source snapshots are validated before the first destination is published, preventing
a missing second default from leaving a partial seed.
- Existing operator files are never chmodded or rewritten.
## Verification
`src/commands/launch-first-start.spec.ts` uses the production-kind boundary: the real built CLI in a
no-TTY subprocess, not a direct wizard test. The package `test:vitest` gate builds Mosaic before
Vitest, while the clean-checkout command builds its workspace dependencies first, so ignored
`dist/cli.js` cannot be absent or stale. A fake lease launcher records whether execution reached the
runtime boundary and captures the composed prompt.
Positive and negative cases prove the check can both proceed and refuse. Fleet composition coverage
replaces a previously validated `USER.md` with an external symlink and proves point-of-use refusal;
a standalone unreadable-optional-USER case proves the portable tolerant branch remains separate.
Real Pi authentication and provider task execution remain environment tests, not claims of this
fixture.
## Non-goals
- Runtime installation or pane-PATH resolution (#1256/#1258).
- The held `~/.mosaic` launch-composition layer in PR #1213.
- Personalizing the operator's standalone identity without a wizard.
- Changing fleet systemd or shell launcher code.
+23
View File
@@ -146,6 +146,29 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR. are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
### Unattended fleet first-start amendment (#1264)
`FCM-REQ-11` is reserved by #1256's concurrent runtime-preflight delivery. This amendment therefore
uses the next non-colliding identifiers.
1. `FCM-REQ-12`: A roster-owned fleet launch SHALL NOT invoke an interactive identity wizard when
top-level `SOUL.md` or `USER.md` is absent. It SHALL initialize only missing top-level identity
contracts from the shipped generic `defaults/` contracts without overwriting operator-owned
bytes. The canonical roster member remains the sole source of the seat's exact name and class;
generic defaults grant no fleet identity or authority. Missing or unsafe defaults SHALL fail
closed with actionable diagnostics before runtime execution. Non-fleet launches retain the
interactive identity flow.
2. `AC-FCM-10`: A systemd-equivalent no-TTY test with a clean temporary Mosaic home SHALL prove a
named fleet seat reaches the runtime boundary without starting `mosaic wizard`, creates
byte-equal owner-private `SOUL.md` and `USER.md` seeds, and receives its exact roster name/class in
composed context. Tests SHALL also prove no-clobber behavior, concurrent/idempotent first start,
fail-closed invalid defaults, and preservation of the standalone interactive path.
`ASSUMPTION:` `MOSAIC_AGENT_NAME` is the existing launch discriminator for roster-owned fleet
processes. This amendment does not add a second fleet flag because generated fleet environments
already set that value and the runtime composer independently resolves it against the canonical
roster before execution.
--- ---
## Exact Cross-Harness Fleet Communications Contract (#766) ## Exact Cross-Harness Fleet Communications Contract (#766)
+4
View File
@@ -38,11 +38,13 @@ These paths remain canonical because current source/tests consume them or becaus
- [Quickstart](USER-GUIDE/getting-started/quickstart.md) — installed-CLI first-use route with local PGlite safety boundaries. - [Quickstart](USER-GUIDE/getting-started/quickstart.md) — installed-CLI first-use route with local PGlite safety boundaries.
- [Web dashboard](USER-GUIDE/product/web-dashboard.md) — current routes, views, chat persistence, settings, and admin behavior. - [Web dashboard](USER-GUIDE/product/web-dashboard.md) — current routes, views, chat persistence, settings, and admin behavior.
- [Discord conversations](USER-GUIDE/workflows/discord-conversations.md) — current authorized parent-channel, thread, attachment, and control workflow. - [Discord conversations](USER-GUIDE/workflows/discord-conversations.md) — current authorized parent-channel, thread, attachment, and control workflow.
- [Fleet unattended first start](USER-GUIDE/workflows/fleet-unattended-first-start.md) — no-TTY identity bootstrap and exact roster identity.
## Administrator documentation ## Administrator documentation
- [Administrator operations](ADMIN-GUIDE/operations/README.md) — current local procedures and explicitly held outlines. - [Administrator operations](ADMIN-GUIDE/operations/README.md) — current local procedures and explicitly held outlines.
- [Upgrade safety and recovery](ADMIN-GUIDE/operations/upgrade-safety-and-recovery.md) — installed-CLI/local-PGlite upgrade and framework recovery. - [Upgrade safety and recovery](ADMIN-GUIDE/operations/upgrade-safety-and-recovery.md) — installed-CLI/local-PGlite upgrade and framework recovery.
- [Fleet unattended first-start operations](ADMIN-GUIDE/operations/fleet-unattended-first-start.md) — systemd identity initialization, refusal paths, and isolated verification.
- [Mos connector lease operations](ADMIN-GUIDE/operations/mos-connector-lease-operations.md) — held/non-operative M1 outline while policy remains deny-all. - [Mos connector lease operations](ADMIN-GUIDE/operations/mos-connector-lease-operations.md) — held/non-operative M1 outline while policy remains deny-all.
- [Administrator security](ADMIN-GUIDE/security/README.md) — current security chapter index. - [Administrator security](ADMIN-GUIDE/security/README.md) — current security chapter index.
- [SSO providers](ADMIN-GUIDE/security/sso-providers.md) — Authentik, WorkOS, and Keycloak configuration and discovery. - [SSO providers](ADMIN-GUIDE/security/sso-providers.md) — Authentik, WorkOS, and Keycloak configuration and discovery.
@@ -56,6 +58,7 @@ These paths remain canonical because current source/tests consume them or becaus
- [Lease-broker security](DEVELOPER-GUIDE/architecture/lease-broker-security.md) — identity, ancestry, filesystem, observer, and residual boundaries. - [Lease-broker security](DEVELOPER-GUIDE/architecture/lease-broker-security.md) — identity, ancestry, filesystem, observer, and residual boundaries.
- [Whole mutator-class gate](DEVELOPER-GUIDE/architecture/mutator-class-gate.md) — default-deny tool authorization and launch choke point. - [Whole mutator-class gate](DEVELOPER-GUIDE/architecture/mutator-class-gate.md) — default-deny tool authorization and launch choke point.
- [Compaction revocation](DEVELOPER-GUIDE/architecture/compaction-revocation.md) — lifecycle observers, generation fencing, and residual stale window. - [Compaction revocation](DEVELOPER-GUIDE/architecture/compaction-revocation.md) — lifecycle observers, generation fencing, and residual stale window.
- [Fleet first-start identity](DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md) — roster authority and atomic no-clobber identity seeding.
- [Architecture decisions](DEVELOPER-GUIDE/architecture/decisions/README.md) — implemented and accepted boundaries. - [Architecture decisions](DEVELOPER-GUIDE/architecture/decisions/README.md) — implemented and accepted boundaries.
- [Mos runtime portability M1](DEVELOPER-GUIDE/architecture/decisions/mos-runtime-portability-m1.md) — logical identity, connector lease, grants, audit, and fencing. - [Mos runtime portability M1](DEVELOPER-GUIDE/architecture/decisions/mos-runtime-portability-m1.md) — logical identity, connector lease, grants, audit, and fencing.
- [Architecture RFCs](DEVELOPER-GUIDE/architecture/rfcs/README.md) — draft proposals without operational authority. - [Architecture RFCs](DEVELOPER-GUIDE/architecture/rfcs/README.md) — draft proposals without operational authority.
@@ -75,6 +78,7 @@ These paths remain canonical because current source/tests consume them or becaus
- [Archived planning](archive/planning/README.md) — historical briefs, board reviews, and work-package specifications. - [Archived planning](archive/planning/README.md) — historical briefs, board reviews, and work-package specifications.
- [Archived work records](archive/work-records/README.md) — historical task scratchpads without live consumers. - [Archived work records](archive/work-records/README.md) — historical task scratchpads without live consumers.
- [P8-003 performance report](reports/qa/p8-003-performance-optimization.md) — historical implementation evidence, not a current SLO. - [P8-003 performance report](reports/qa/p8-003-performance-optimization.md) — historical implementation evidence, not a current SLO.
- [Issue #1264 unattended fleet first-start verification](reports/qa/2026-08-16-1264-unattended-first-start.md) — RED/GREEN no-TTY CLI evidence and explicit untested bounds.
- [Plans index](plans/README.md) — approved intent and implementation/audit plans. - [Plans index](plans/README.md) — approved intent and implementation/audit plans.
- [Documentation information-architecture design](plans/2026-08-10-docs-information-architecture-design.md) — approved documentation structure decision. - [Documentation information-architecture design](plans/2026-08-10-docs-information-architecture-design.md) — approved documentation structure decision.
- [Documentation catalog-audit plan](plans/2026-08-10-docs-catalog-audit.md) — evidence method and migration acceptance criteria. - [Documentation catalog-audit plan](plans/2026-08-10-docs-catalog-audit.md) — evidence method and migration acceptance criteria.
+3 -1
View File
@@ -11,6 +11,7 @@ This book is the canonical home for end-user workflows, user-visible behavior, p
- [Quickstart](getting-started/quickstart.md) — install Mosaic, complete setup, and launch a session. - [Quickstart](getting-started/quickstart.md) — install Mosaic, complete setup, and launch a session.
- [Web dashboard](product/web-dashboard.md) — current routes, navigation, chat persistence, projects/tasks views, settings, and admin behavior. - [Web dashboard](product/web-dashboard.md) — current routes, navigation, chat persistence, projects/tasks views, settings, and admin behavior.
- [Discord conversations](workflows/discord-conversations.md) — current authorized parent-channel, thread, attachment, and control workflow. - [Discord conversations](workflows/discord-conversations.md) — current authorized parent-channel, thread, attachment, and control workflow.
- [Fleet unattended first start](workflows/fleet-unattended-first-start.md) — no-TTY identity bootstrap, exact roster identity, and separate runtime prerequisites.
## Chapter map ## Chapter map
@@ -18,7 +19,7 @@ This book is the canonical home for end-user workflows, user-visible behavior, p
| ------------------ | ------------------------------------------------------------- | ---------------------------------------------------- | | ------------------ | ------------------------------------------------------------- | ---------------------------------------------------- |
| `getting-started/` | First-use setup, orientation, and quickstarts. | Quickstart is current; additional pages are planned. | | `getting-started/` | First-use setup, orientation, and quickstarts. | Quickstart is current; additional pages are planned. |
| `concepts/` | User-facing terminology, product concepts, and mental models. | Scaffold only. | | `concepts/` | User-facing terminology, product concepts, and mental models. | Scaffold only. |
| `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord conversation workflow is current. | | `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord and fleet first-start workflows are current. |
| `product/` | Current product surfaces and visible behavior. | Web dashboard reference is current. | | `product/` | Current product surfaces and visible behavior. | Web dashboard reference is current. |
| `troubleshooting/` | User-visible failures, diagnostics, and fixes. | Scaffold only. | | `troubleshooting/` | User-visible failures, diagnostics, and fixes. | Scaffold only. |
@@ -27,6 +28,7 @@ This book is the canonical home for end-user workflows, user-visible behavior, p
- [Quickstart](getting-started/quickstart.md) — the verified installed-CLI first-use path. - [Quickstart](getting-started/quickstart.md) — the verified installed-CLI first-use path.
- [Web dashboard](product/web-dashboard.md) — verified current Next.js dashboard behavior and limitations. - [Web dashboard](product/web-dashboard.md) — verified current Next.js dashboard behavior and limitations.
- [Discord conversations](workflows/discord-conversations.md) — verified current Discord user workflow. - [Discord conversations](workflows/discord-conversations.md) — verified current Discord user workflow.
- [Fleet unattended first start](workflows/fleet-unattended-first-start.md) — verified no-TTY first-start behavior and prerequisite boundaries.
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice. Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
@@ -0,0 +1,61 @@
# Fleet Unattended First Start
> **Status:** Current for roster-owned local fleet launches after issue #1264 lands. Runtime
> installation and provider authentication remain separate prerequisites.
A fleet seat started by systemd has no operator at its pane. On its first launch, Mosaic must not
stop at the interactive identity wizard.
## What happens on first start
When `MOSAIC_AGENT_NAME` names an exact member of the installed fleet roster and top-level identity
contracts are absent, the launcher:
1. validates the exact roster member, its canonical class, and the installed fleet communications
helper;
2. reads the shipped generic contracts from
`~/.config/mosaic/defaults/SOUL.md` and `defaults/USER.md`;
3. creates only the missing top-level `SOUL.md` and `USER.md` as owner-private files;
4. preserves any existing top-level identity file byte-for-byte; and
5. launches the runtime with the roster member's exact agent/session name and role/class in composed
context.
The generic defaults do **not** make every seat the same identity. They provide a shared behavioral
base. The canonical roster row supplies each seat's exact name, class, peers, socket, and authority.
## Operator behavior
A normal standalone launch without a fleet identity retains its portable configuration path and still
uses the interactive wizard when `SOUL.md` is absent:
```bash
mosaic pi
```
A roster-owned seat may be started without attaching to its pane:
```bash
mosaic fleet start <exact-roster-name>
```
Mosaic refuses before runtime execution if the requested member is absent, its explicitly supplied
ambient class is blank or conflicts with the roster, a required default is missing or unsafe, or an existing identity contract
is not a safe regular file. Repair the named component and retry the same exact roster member; do not
copy another seat's personalized identity.
## Separate prerequisites
This behavior clears the Mosaic identity-wizard wall only. A clean host still needs:
- the declared runtime installed on the pane PATH;
- the fleet transport and generated unit assets; and
- runtime/provider authentication appropriate to that seat.
Those checks are separate so a successful identity bootstrap is not reported as a fully authenticated
agent session.
## Related
- [Administrator runbook](../../ADMIN-GUIDE/operations/fleet-unattended-first-start.md)
- [Developer architecture](../../DEVELOPER-GUIDE/architecture/fleet-first-start-identity.md)
- [Verification report](../../reports/qa/2026-08-16-1264-unattended-first-start.md)
+3
View File
@@ -12,11 +12,13 @@ Use the canonical guide, API contract, source, and tests to determine current be
- [Issue #756 documentation checklist](documentation/756-discord-plugin-checklist.md) — historical completion checklist for the official Discord plugin workstream. - [Issue #756 documentation checklist](documentation/756-discord-plugin-checklist.md) — historical completion checklist for the official Discord plugin workstream.
- [Framework consistency audit — 2026-02-17](documentation/AUDIT-2026-02-17-framework-consistency.md) — historical framework consistency and remediation snapshot. - [Framework consistency audit — 2026-02-17](documentation/AUDIT-2026-02-17-framework-consistency.md) — historical framework consistency and remediation snapshot.
- [Compaction-refresh #830 checklist](compaction-refresh/830-documentation-checklist.md) — historical incomplete-at-snapshot documentation checklist. - [Compaction-refresh #830 checklist](compaction-refresh/830-documentation-checklist.md) — historical incomplete-at-snapshot documentation checklist.
- [Issue #1264 documentation checklist](documentation/1264-documentation-checklist.md) — current in-repo user/admin/developer/report coverage and review gate.
## Code-review evidence ## Code-review evidence
- [Issue #756 independent code review](code-review/756-code-review.md) — historical exact-scope review of the official Discord plugin workstream. - [Issue #756 independent code review](code-review/756-code-review.md) — historical exact-scope review of the official Discord plugin workstream.
- [Gateway security-hardening code review — 2026-03-13](code-review/gateway-security-20260313.md) — historical no-blocker review snapshot. - [Gateway security-hardening code review — 2026-03-13](code-review/gateway-security-20260313.md) — historical no-blocker review snapshot.
- [Issue #1264 independent code and security review](code-review/1264-code-review.md) — initial finding, remediation, clean re-review, and remaining formal PR-review gate.
## Security evidence ## Security evidence
@@ -26,6 +28,7 @@ Use the canonical guide, API contract, source, and tests to determine current be
- [P8-003 performance optimization report](qa/p8-003-performance-optimization.md) — historical implementation evidence; not a current SLO or production benchmark. - [P8-003 performance optimization report](qa/p8-003-performance-optimization.md) — historical implementation evidence; not a current SLO or production benchmark.
- [Gateway security-hardening QA report — 2026-03-13](qa/gateway-security-20260313.md) — historical test report with its original live-smoke-test limitation. - [Gateway security-hardening QA report — 2026-03-13](qa/gateway-security-20260313.md) — historical test report with its original live-smoke-test limitation.
- [Issue #1264 unattended fleet first-start verification](qa/2026-08-16-1264-unattended-first-start.md) — RED/GREEN no-TTY CLI evidence, baseline gates, and explicit real-provider limitation.
## Native Kanban/SOT evidence ## Native Kanban/SOT evidence
@@ -0,0 +1,97 @@
# Issue #1264 Code and Security Review
> Branch: `fix/1264-fleet-unattended-first-start` | Base:
> `origin/next@476db12b92971634b67fd2057b7577ee5894e449`
## Initial automated review
Codex reviewed the pre-PR uncommitted delta with:
```bash
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted \
-o /tmp/1264-codex-code-review.json
```
Result: `request-changes`, confidence `0.93`, 20 files, one should-fix. `checkSoul()` trimmed
`MOSAIC_AGENT_NAME` for pre-seed resolution while composition used the original value, so a padded
name could seed files before later refusal.
Remediation rejected blank/leading/trailing-whitespace values before roster lookup or writes and
added three built-CLI no-side-effect regressions. Automated re-review approved that delta with no
findings (confidence `0.86`). Initial security review reported risk `none` (confidence `0.91`).
## Formal exact-head review
Daphne reviewed PR #1268 at exact head `43fa0477877e0d0f110da8d11c3033b40ddeb191` and filed Gitea
review ID 168 as `REQUEST_CHANGES`. The review was source/PR-only; the canary remained untouched.
Blocking groups:
1. class mismatch was validated after first-start mutation;
2. secure `USER.md` validation was discarded before ordinary path-following composition;
3. `existsSync()` treated a dangling destination symlink as missing, allowing counterpart partial
publication; and
4. the built-CLI/evidence chain allowed stale ignored `dist/`, cited an unshipped canary object, and
carried conflicting test totals/pane wording.
The diagnostic's defaults-only repair advice was also inaccurate for roster/class/destination
failures.
## Formal-review remediation
All four blocking groups received regressions before production changes. The RED run produced four
failures while 1,568 existing tests passed. Remediation then:
- validates canonical name and class before seeding;
- preflights destination directory entries with `lstatSync()` so target and dangling symlinks fail
before publication;
- securely reads `USER.md` through an `O_NOFOLLOW` descriptor at composition time;
- adds a Mosaic build before package Vitest and a dependency build in the clean-checkout command;
- replaces defaults-only advice with neutral named-component repair guidance; and
- reconciles shipping canary provenance, pane chronology, commands, and totals.
Remediation code review:
```bash
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted \
-o /tmp/1264-remediation-code-review.json
```
Result: `approve`, confidence `0.88`, 6 files, no findings. Summary: the fail-closed destination
checks, class-validation order, secure composition, and build-before-Vitest path are coherent.
Remediation security review:
```bash
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted \
-o /tmp/1264-remediation-security-review.json
```
Result: risk `none`, confidence `0.93`, 9 files, no critical/high/medium/low findings. The sandbox
could not run Vitest because Vite attempted to create a temporary config artifact on its read-only
mount (`EROFS`); executor-owned focused and full results are recorded in the QA report.
## Second exact-head review
Daphne reviewed exact head `9dc90be7e13b1cd609f6df97d43d890ef5392ca0` and filed Gitea review
ID 169 as `REQUEST_CHANGES`. Review 169 confirmed all review-168 closures, then found:
1. the new point-of-use reader was Linux-only but had been applied to every standalone USER read,
breaking supported non-fleet macOS composition; and
2. explicit blank/whitespace `MOSAIC_AGENT_CLASS` was treated as absent and could seed before
runtime, while only undefined should mean absent.
Red-first remediation preserves legacy `readOptional()` for standalone composition, keeps descriptor
no-follow consumption fleet-only, moves the replacement-symlink case under a valid fleet identity,
and rejects defined blank/whitespace classes before seeding. Three blank-class CLI cases and one
tolerant standalone composition case failed before the source change and pass after it.
Review-169 remediation code review approved at confidence `0.90` (4 files, no findings). Security
review reported risk `none` at confidence `0.90` (4 files, no findings). The review sandbox retained
its known Vite `EROFS` limitation; executor-owned tests are in the QA report.
## Remaining review gate
Daphne must re-review the next exact pushed head. This report cannot record that future verdict
without changing the reviewed head, so the authoritative terminal verdict belongs to PR #1268's
Gitea review record. Fred and goals are excluded as reviewers.
@@ -0,0 +1,32 @@
# #1264 Documentation Completion Checklist
## Required artifacts
- [x] `docs/PRD.md` updated with `FCM-REQ-12` and `AC-FCM-10`.
- [x] User workflow documents unattended fleet first start and separate prerequisites.
- [x] Administrator operations page documents source/destination ownership, failure handling, and an
exact-source build-before-Vitest verification gate.
- [x] Developer architecture page documents control flow, identity authority, concurrency, and non-goals.
- [x] `docs/SITEMAP.md` and book indexes updated.
- [x] QA evidence is under `docs/reports/qa/`; working notes are under `docs/scratchpads/`.
- [x] Framework defaults README reflects fleet-versus-standalone behavior.
## API coverage
- [x] No HTTP/API endpoint or DTO changed; OpenAPI and endpoint indexes are not applicable.
## Structural standards
- [x] User, administrator, developer, report, and sitemap indexes link the new pages.
- [x] No noncanonical file was added at the `docs/` root.
- [x] Canonical documentation remains in-repo; no external publication was requested or performed.
## Review gate
- [x] Initial padded-name finding remediated and automated re-review approved.
- [x] Daphne formal review ID 168 completed on exact first head `43fa0477` and requested changes.
- [x] Four review-168 groups reproduced red and remediated; automated reviews are clean.
- [x] Daphne review ID 169 completed on exact head `9dc90be7` and confirmed review-168 closures.
- [x] Review-169 standalone-portability and blank-class blockers reproduced red and remediated;
automated reviews are clean.
- [ ] Daphne exact-second-remediation-head re-review completed after push (Fred/goals excluded).
@@ -0,0 +1,220 @@
# #1264 Unattended Fleet First-Start Verification
> Status: **IN PROGRESS — review-169 remediation complete locally; push/re-review pending** |
> Executor: goals | Date: 2026-08-16 | Target: isolated local fixtures only
## Objective
Verify that a named fleet seat launched through a systemd-equivalent, no-TTY environment on a clean
host reaches its runtime boundary without an interactive Mosaic identity wizard. Preserve standalone
wizard behavior and canonical-roster ownership of exact seat identity.
## Source evidence accepted for local verification
Daphne's canary Run-7 report is reachable from jarvis-brain `origin/main` at
`8bf94afeb8c7d5df96cdd4a4508e75a1d2999710`,
`docs/reports/2026-08-16_sbx-canary-greenfield-e2e.md`. The earlier local object
`6c0b6fc70ae6a179a1b7ff9dedfc54e9adccd19a` is not reachable from an origin ref and is not used as
shipping provenance. Run 7 measured:
```text
systemd -> start-agent-session.sh -> mosaic yolo pi (PID 3726)
-> child mosaic wizard (PID 3762)
```
The pane was preserved when Run 7 was captured. Formal review ID 168 records that an authorized
rollback occurred later. This task never accessed or altered the canary VM, pane, snapshot, or
rollback state. Product behavior is independently tested here with temporary roots and fake runtime
executables.
## Controls
- Original base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449`.
- PR: #1268, first pushed head `43fa0477877e0d0f110da8d11c3033b40ddeb191`.
- `DATABASE_URL` remains unset for local tests.
- No runtime/provider credential or token value, VM, installed Mosaic tree, unit, timer, PATH profile,
or live tmux session is read or mutated. Standard Gitea/Woodpecker wrappers authenticate metadata
reads/writes without exposing credential values.
- Tiny's runtime-preflight and `start-agent-session.sh` PATH work remain out of scope.
- Held PR #1213 is not a dependency.
## Requirements-to-evidence map
| Acceptance criterion | Method | Evidence |
| ---------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------- |
| No-TTY fleet first start avoids wizard and reaches runtime | Exact-source built CLI with piped stdin | CLI GREEN |
| Missing top-level identity files are initialized from shipped defaults | Exact-byte and `0600` assertions | CLI + filesystem GREEN |
| Exact seat identity remains roster-owned | Captured argv; mismatched/blank class no-side-effect refusals | CLI GREEN |
| Existing operator identity is never overwritten | Custom bytes/mode with defaults removed | CLI + filesystem GREEN |
| Concurrent/repeated first start is safe | Four parallel CLIs plus repeated launch | CLI GREEN |
| Missing/unsafe defaults and destinations fail before partial mutation | Missing, target/dangling symlink, oversized, invalid-root cases | Filesystem/CLI GREEN |
| Validated `USER.md` cannot be replaced by an external symlink | Seed, replace, compose at point of use | Composition GREEN |
| Standalone launch retains wizard | Same built CLI without fleet identity | CLI GREEN |
| Built-CLI evidence cannot use stale ignored `dist/` | Build-with-dependencies gate before Vitest | Package script + command gate |
## Initial RED
Production source remained unchanged after adding the first reproducer. The CLI was built from
`origin/next@476db12` before the test.
```bash
env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/launch-first-start.spec.ts
```
Exit `1`; one file and one test failed. Output included:
```text
[mosaic] SOUL.md not found. Running setup wizard...
◆ What would you like to do?
[mosaic] Setup failed. Run: mosaic wizard
AssertionError: expected 1 to be +0
```
The fake runtime-boundary capture was not created. Complete stdout/stderr was retained at
`/tmp/1264-red.out` during that work session.
## Formal-review remediation RED
Daphne's exact-head review ID 168 requested changes at `43fa0477`. Before changing production code,
new regressions were run against an exact-source build. Four tests failed while the existing 1,568
passed:
1. valid roster name plus mismatched ambient class seeded both files before refusal;
2. dangling `SOUL.md` allowed `USER.md` to be published before refusal;
3. dangling `USER.md` allowed `SOUL.md` to be published before refusal; and
4. replacing a securely validated `USER.md` with an external symlink was followed by composition.
This establishes that all four review-168 findings were observable on the pushed implementation.
Daphne's review ID 169 then found two more exact-head failures at `9dc90be7`. Before production
changes, four new assertions failed:
1. standalone composition routed an unreadable optional `USER.md` through the Linux-only descriptor
reader instead of the legacy portable tolerant path; and
2. explicit `MOSAIC_AGENT_CLASS` values `""`, `" "`, and tab were treated as absent, seeded both
identity files, and reached runtime.
The replacement-symlink case was also moved under a valid roster identity so it tests the fleet-only
security boundary rather than standalone behavior.
## Final GREEN
The production-kind command builds Mosaic and all workspace dependencies before invoking Vitest,
because `dist/` is ignored and may otherwise be absent or stale:
```bash
env -u DATABASE_URL sh -c '
pnpm --filter @mosaicstack/mosaic... build &&
pnpm --filter @mosaicstack/mosaic exec vitest run \
src/commands/fleet-first-start-identity.spec.ts \
src/commands/launch-first-start.spec.ts \
src/commands/launch.spec.ts \
src/commands/compose-contract.spec.ts \
src/config/file-adapter.test.ts \
src/cli-smoke.spec.ts
'
```
Exit `0`: `6/6` files, `128/128` tests.
- 15 real-CLI/no-TTY tests cover exact roster name/class, byte-equal `0600` seeds, no-clobber,
partial seed, missing/symlink defaults, unknown/padded/blank name, mismatched or explicitly blank
class, standalone wizard preservation, and four concurrent starts.
- 12 direct filesystem tests cover complete publication, existing operators, idempotence, source
prevalidation, target and dangling destination links, invalid roots, oversized input, and
unexpected link errors.
- Composition coverage deterministically replaces a valid fleet seat's validated `USER.md` with an
external symlink and requires refusal at point of use. A separate standalone case proves tolerant
optional composition remains outside the Linux-only fleet reader.
Full package gate (which rebuilds Mosaic itself after the clean-checkout dependency build):
```bash
env -u DATABASE_URL pnpm --filter @mosaicstack/mosaic run test:vitest
```
Exit `0`: `88/88` files, `1,577/1,577` tests.
Focused helper + point-of-use coverage:
```text
2 files, 53/53 tests
Statements 97.84% | Branches 91.66% | Functions 100% | Lines 97.84%
Exit 0
```
Final repository gates after remediation:
```text
pnpm preflight exit 0
pnpm typecheck 45/45 tasks, exit 0
pnpm lint 25/25 tasks, exit 0
pnpm build 25/25 tasks, exit 0
pnpm format:check exit 0
git diff --check exit 0
```
Pre-PR targeted shell runs on the unchanged shell surfaces also passed:
```text
bash framework/tools/fleet/test-start-agent-session.sh exit 0 locally
bash framework/tools/quality/scripts/test-install-migration.sh 21 passed, 0 failed
bash framework/tools/_scripts/test-mosaic-init-rce.sh PASS
```
The aggregate local `test:framework-shell` run stopped at `invariant_r_unittest.py`: installed
operator-global Pi is `0.84.2`, while the invariant is measured for `0.84.1`. Later aggregate stages
remain unmeasured except the targeted suites above. Root `pnpm test` remains locally **UNTESTED**
because this checkout prohibits the PostgreSQL-dependent gateway isolation path.
## Review and security evidence
- Initial Codex review found padded-name mutation-before-refusal; it was fixed with three
no-side-effect regressions.
- Codex review of the formal-review remediation: `approve`, confidence `0.88`, 6 files, no findings.
- Codex security review of the remediation: risk `none`, confidence `0.93`, 9 files, no findings.
Its sandbox could not execute Vitest because Vite attempted a write on a read-only mount; the
executor-owned results above are the test evidence.
- Daphne formal review ID 168 at exact head `43fa0477`: `REQUEST_CHANGES`, four blocking groups; all
closed by review 169.
- Daphne formal review ID 169 at exact head `9dc90be7`: `REQUEST_CHANGES`, two blocking groups
(standalone portability and explicit blank class). Both now have red-first regressions and local
green remediation.
- Codex review of review-169 remediation: `approve`, confidence `0.90`, 4 files, no findings.
- Codex security review of review-169 remediation: risk `none`, confidence `0.90`, 4 files, no
findings. Exact-new-head Daphne re-review is pending until that head is pushed.
## CI evidence and external blocker
Pipeline 2445 ran against exact first head `43fa0477`:
- install, sanitization, upgrade guard, typecheck, lint, and format passed;
- Mosaic Vitest passed `88/88`, `1,568/1,568`; and
- the test step emitted exactly one `FAIL:` line:
```text
FAIL: host provides 'pi' in the system path; missing-binary cases are not measurable here
```
That line comes from the inherited `test-start-agent-session.sh` CI-fit guard, not #1264. Fred filed
the correction as PR #1270. Its pipeline 2448 is terminal green and proves the four formerly masked
suites execute, but #1270 is not merged, so `next` still carries the failing chain. A new #1268
pipeline 2449 at `9dc90be7` reproduced the same single inherited `FAIL:` after Mosaic passed
`1,573/1,573`. A new pipeline is pending the review-169 remediation push. Terminal-green #1268 CI is
not claimed.
PR #1268's envelope was read back as `user.login=mos-dt-0`; its commit is explicitly authored and
committed by `goals <[email protected]>`. No goals Gitea login exists on this host, and no
other principal was borrowed. The cross-wrapper principal defect is tracked in #1272.
## Explicitly untested
- Canary VM remediation/restart: **UNTESTED and prohibited**.
- Real Pi authentication/provider prompt and task execution: **UNTESTED**.
- PR #1213 composition layer: **UNTESTED and not required**.
- Deployment/published npm behavior: **UNTESTED until merge/release**.
- Local PostgreSQL execution/migration: **UNTESTED and prohibited**.
The local gate proves Mosaic crosses its identity boundary and reaches a fake lease-runtime boundary;
it does not claim provider readiness, deployment, or a currently running canary seat.
@@ -0,0 +1,104 @@
# #1264 — Unattended fleet first start
## Tracking
- Issue: `mosaicstack/stack#1264`
- PR: `mosaicstack/stack#1268`
- Branch: `fix/1264-fleet-unattended-first-start`
- Base: `origin/next@476db12b92971634b67fd2057b7577ee5894e449`
- First pushed head: `43fa0477877e0d0f110da8d11c3033b40ddeb191`
- Current remediation worktree: `/var/home/jason.woltje/agent-work/1264-review2-remediation`
- Coordinator: Fred; reviewer must be neither Fred nor this implementation seat.
- `docs/TASKS.md` is orchestrator-owned and is not modified by this worker.
The original `/var/home/jason.woltje/agent-work/1264-unattended-first-start` and first remediation
worktrees were removed without force after each pushed head and clean state were verified. The
Fred-authorized plain-Git worktree exception was reused for exact-head review remediation because
`/src` remains unavailable.
## Objective
A roster-owned fleet seat launched from systemd on a clean host must cross Mosaic's first-run identity
gate without a human or TTY, while retaining exact name/class from the canonical roster and
preserving the standalone interactive wizard.
## Intake and boundaries
- Shipping canary provenance is jarvis-brain `origin/main` commit
`8bf94afeb8c7d5df96cdd4a4508e75a1d2999710`. The earlier local `6c0b6fc...` object is not used.
- The Run-7 pane was preserved when evidence was captured; formal review records a later authorized
rollback. This task never accessed or altered the canary.
- Tiny's concurrent runtime-preflight, `start-agent-session.sh`, and #1258 PATH seam remain untouched.
- Held PR #1213 is not a dependency.
- No runtime/provider credential values or provider calls, installed-host changes, PostgreSQL, unit,
timer, or profile mutation. Tests use temporary roots and fake executables only; Gitea/Woodpecker
metadata operations use standard wrappers without exposing credentials.
## Requirements and design
- PRD IDs: `FCM-REQ-12`, `AC-FCM-10`; `FCM-REQ-11` is reserved by #1256.
- A present fleet name must be nonblank, whitespace-exact, and resolve through the canonical roster.
- Any defined ambient class, including blank/whitespace, must canonicalize to the roster class before
mutation; only undefined means absent.
- Preflight all destination directory entries with no-follow existence semantics so dangling links
fail before counterpart publication.
- Seed only missing top-level files from bounded regular defaults with owner-private, atomic,
no-clobber hard links.
- Generic defaults are behavior, not identity or authority.
- Securely consume fleet `USER.md` through a Linux descriptor at composition time.
- Standalone composition retains the portable tolerant USER read and missing identity retains the
wizard.
## Progress
- [x] Issue, canary report, Tiny collision state, and PRD read/amended.
- [x] Initial production-kind RED captured with a real built CLI and no TTY.
- [x] Implementation, tests, user/admin/developer docs, QA, and indexes delivered.
- [x] Initial automated review finding (padded name before write) remediated.
- [x] Commit `43fa0477` pushed; PR #1268 opened against `next`; original worktree removed cleanly.
- [x] Daphne formal review ID 168 completed on exact first head: `REQUEST_CHANGES` with four groups.
- [x] All four review-168 groups reproduced red before remediation and passed at `9dc90be7`.
- [x] Daphne review ID 169 completed on `9dc90be7`: review-168 closures confirmed; two new blockers.
- [x] Review-169 portability and blank-class blockers reproduced red and now pass locally.
- [x] Review-169 Codex review approved; security review risk `none`.
- [ ] Commit/push second remediation with explicit goals author/committer; verify remote object/content.
- [ ] Daphne exact-new-head re-review.
- [ ] Terminal #1268 CI. Pipeline 2445's only `FAIL:` was the inherited Pi-PATH CI-fit guard; PR
#1270's pipeline 2448 is green, but #1270 is not merged.
- [ ] Remove the clean remediation worktree after push.
## Test evidence
### Initial RED
The built `origin/next` CLI entered `mosaic wizard`, rendered `What would you like to do?`, exited 1,
and never created the fake runtime-boundary capture.
### Formal-review RED
Against exact first-head production code, four new tests failed while 1,568 existing tests passed:
class mismatch mutated before refusal; each dangling destination left its counterpart; and a
replacement `USER.md` symlink was consumed by composition. Review-169 RED then proved standalone
composition hit the Linux-only reader and three explicit blank/whitespace class cases seeded and
launched.
### Final local GREEN
- Exact-source focused gate: `6/6` files, `128/128` tests.
- Full exact-source Mosaic Vitest: `88/88` files, `1,577/1,577` tests.
- Helper + point-of-use coverage: `53/53`; 97.84% statements/lines, 91.66% branches, 100% functions.
- Root preflight passed; typecheck `45/45`, lint `25/25`, build `25/25`.
- Initial targeted shell gates passed: start-agent-session, install migration `21/21`, init-RCE.
- Local aggregate framework shell stops at operator-global Pi `0.84.2` versus measured `0.84.1`.
- Local root `pnpm test` remains unrun because the checkout prohibits its PostgreSQL-dependent path.
The full evidence and command boundaries are in
`docs/reports/qa/2026-08-16-1264-unattended-first-start.md`.
## Review / delivery notes
- Review-168 remediation Codex review: approve `0.88`; security risk `none` `0.93`.
- Review-169 remediation Codex review: approve `0.90`; security risk `none` `0.90`.
- PR envelope reads `mos-dt-0`; the commit reads goals/goals. No goals Gitea principal exists on this
host, so no other principal will be borrowed. Tracked in #1272.
- PR #1270 is pushed, not merged. Do not represent `next` or #1268 CI as green until measured.
+4
View File
@@ -7,6 +7,10 @@
- [DOCS-IA-001 — information architecture](DOCS-IA-001.md) — completed structure-design and documentation-contract record. - [DOCS-IA-001 — information architecture](DOCS-IA-001.md) — completed structure-design and documentation-contract record.
- [DOCS-IA-002 — catalog audit and migration](DOCS-IA-002-catalog-audit.md) — active coordinator progress, autonomous lane state, verification evidence, and authority blockers. - [DOCS-IA-002 — catalog audit and migration](DOCS-IA-002-catalog-audit.md) — active coordinator progress, autonomous lane state, verification evidence, and authority blockers.
## Active implementation records
- [Issue #1264 — unattended fleet first start](1264-unattended-first-start.md) — plan, RED/GREEN evidence, collision boundaries, and PR lifecycle state.
Completed scratchpads may remain here when they provide useful delivery provenance. Their conclusions must be reflected in the owning canonical page before the scratchpad is treated as complete. Completed scratchpads may remain here when they provide useful delivery provenance. Their conclusions must be reflected in the owning canonical page before the scratchpad is treated as complete.
## Related ## Related
-1
View File
@@ -34,7 +34,6 @@ export default tseslint.config(
'packages/storage/vitest.config.ts', 'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts', 'packages/mosaic/vitest.config.ts',
'packages/mosaic/__tests__/*.ts', 'packages/mosaic/__tests__/*.ts',
'packages/forge/__tests__/*.ts',
'tools/federation-harness/*.ts', 'tools/federation-harness/*.ts',
], ],
}, },
-40
View File
@@ -539,43 +539,3 @@ Not every brief needs full Board of Directors review. The classification system
### Backward compatibility ### Backward compatibility
Existing briefs without a `class` field are auto-classified. The default (no matching keywords) is `strategic`, so all existing runs get the full pipeline unless keywords trigger `technical`. Existing briefs without a `class` field are auto-classified. The default (no matching keywords) is `strategic`, so all existing runs get the full pipeline unless keywords trigger `technical`.
---
## Fail-Closed Execution & Explicit Simulation (SDLC-D-035)
**Added:** 2026-08-17
Forge fails closed when a required capability is missing. It never runs a
pipeline with a stub executor and reports success.
### Normal mode (default)
- No task executor wired → the CLI exits nonzero with the typed capability
error `FORGE_NO_EXECUTOR`. No run is created.
- A stage whose gate is approval-based (board approval, planning approvals,
remediation re-review, discovery/analysis attestations) records a typed
`waiting-for-authority` stage result and raises `FORGE_AUTHORITY_REQUIRED`.
It never passes vacuously.
- A stage whose gate requires an unwired provider (AI reviewer, CI pipeline)
records a typed `blocked` stage result and raises `FORGE_NO_REVIEWER` /
`FORGE_NO_CI_PIPELINE`. The synthetic echo-review approval in `06-review`
and all vacuous `true` gates were removed.
### Explicit simulation (`--simulate`)
Opts into stub/synthetic execution. Every stage result, every gate result, and
the run manifest carry the distinct typed status `simulated` (manifest also
records `mode: "simulated"`). `simulated` is a non-satisfying outcome:
`isSatisfyingOutcome()` and all completion/gate consumers treat only `passed`
as satisfying. The CLI exits 0 for a simulated run only because the caller
explicitly passed `--simulate`, and prints a loud SIMULATED banner.
### Typed outcome model
Every gate/task outcome is one of the closed set
`passed | failed | blocked | error | waiting-for-authority | simulated |
not-applicable`, with the reason recorded on the stage status and each gate
result in `manifest.json`. Missing implementations, missing gate evidence,
unknown stages, process errors, and timeouts map to fail-closed members —
never to `passed`.
@@ -1,319 +0,0 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { generateBoardTasks } from '../src/board-tasks.js';
import { STAGE_SPECS } from '../src/constants.js';
import { ForgeCapabilityError } from '../src/errors.js';
import {
evaluateStageGates,
gateLabel,
isCommandGate,
isSatisfyingOutcome,
} from '../src/outcomes.js';
import { loadManifest, runPipeline } from '../src/pipeline-runner.js';
import type { ForgeTask, ForgeTaskResult, TaskExecutor } from '../src/types.js';
/**
* Mock real executor that returns typed results.
*
* Command gates are "verified" by the mock so normal-mode runs can pass
* mechanically gated stages; authority/provider gates are never reported
* because they have no mechanical implementation.
*/
function createTypedExecutor(options?: {
failStage?: string;
gateOutcomes?: Record<string, 'passed' | 'failed' | 'simulated' | 'error' | 'blocked'>;
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
const submittedTasks: ForgeTask[] = [];
return {
submittedTasks,
async submitTask(task: ForgeTask) {
submittedTasks.push(task);
},
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
const task = submittedTasks.find((t) => t.id === taskId);
const stageName = task?.metadata?.['stageName'] as string | undefined;
if (options?.failStage && stageName === options.failStage) {
return {
task_id: taskId,
outcome: 'failed',
reason: 'mock task failure',
completed_at: new Date().toISOString(),
exit_code: 1,
gate_results: [],
};
}
const gateResults = (task?.qualityGates ?? [])
.filter((gate) => isCommandGate(gate))
.map((gate) => {
const label = gateLabel(gate);
const outcome = options?.gateOutcomes?.[label] ?? 'passed';
return {
gate: label,
outcome,
reason: outcome === 'passed' ? 'mock verified' : `mock gate outcome: ${outcome}`,
};
});
return {
task_id: taskId,
outcome: 'passed',
reason: 'mock verified',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: gateResults,
};
},
async getTaskStatus() {
return 'completed' as const;
},
};
}
describe('fail-closed: no executor wired', () => {
let tmpDir: string;
let briefPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-failclosed-'));
briefPath = path.join(tmpDir, 'brief.md');
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('throws a typed FORGE_NO_EXECUTOR capability error without --simulate', async () => {
await expect(
runPipeline(briefPath, tmpDir, {
// no executor, no simulate — must fail closed, never run with a stub
stages: ['00-intake'],
}),
).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_NO_EXECUTOR',
capability: 'task-executor',
});
});
it('does not create a run directory when failing closed on a missing executor', async () => {
try {
await runPipeline(briefPath, tmpDir, { stages: ['00-intake'] });
} catch {
// expected
}
expect(fs.existsSync(path.join(tmpDir, '.forge', 'runs'))).toBe(false);
});
it('completes with every result typed simulated when simulate is set', async () => {
const result = await runPipeline(briefPath, tmpDir, {
simulate: true,
stages: ['00-intake', '00b-discovery', '02-planning-1', '06-review'],
});
expect(result.manifest.mode).toBe('simulated');
expect(result.manifest.status).toBe('simulated');
for (const stage of result.stages) {
const stageStatus = result.manifest.stages[stage];
expect(stageStatus?.status, `stage ${stage}`).toBe('simulated');
expect(stageStatus?.status, `stage ${stage}`).not.toBe('passed');
expect(stageStatus?.reason, `stage ${stage}`).toBeTruthy();
for (const gateResult of stageStatus?.gateResults ?? []) {
expect(gateResult.outcome, `gate ${gateResult.gate} of ${stage}`).toBe('simulated');
expect(gateResult.outcome, `gate ${gateResult.gate} of ${stage}`).not.toBe('passed');
}
}
// The persisted manifest agrees.
const persisted = loadManifest(result.runDir);
expect(persisted.mode).toBe('simulated');
expect(persisted.status).toBe('simulated');
expect(persisted.stages['02-planning-1']?.status).toBe('simulated');
});
});
describe('fail-closed: typed outcome model', () => {
it('only passed satisfies the gate/dependency predicate', () => {
expect(isSatisfyingOutcome('passed')).toBe(true);
expect(isSatisfyingOutcome('failed')).toBe(false);
expect(isSatisfyingOutcome('blocked')).toBe(false);
expect(isSatisfyingOutcome('error')).toBe(false);
expect(isSatisfyingOutcome('waiting-for-authority')).toBe(false);
expect(isSatisfyingOutcome('simulated')).toBe(false);
expect(isSatisfyingOutcome('not-applicable')).toBe(false);
});
it('a simulated gate result cannot satisfy the stage gate evaluation', () => {
const evaluation = evaluateStageGates('05-coding', STAGE_SPECS['05-coding']!.qualityGates, {
task_id: 'FORGE-x-05',
outcome: 'passed',
reason: 'executor claims success',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [{ gate: 'pnpm lint', outcome: 'simulated', reason: 'simulated gate' }],
});
expect(isSatisfyingOutcome(evaluation.outcome)).toBe(false);
expect(evaluation.outcome).toBe('error');
});
it('a simulated task outcome cannot satisfy evaluation in normal mode', () => {
const evaluation = evaluateStageGates('00-intake', [], {
task_id: 'FORGE-x-00',
outcome: 'simulated',
reason: 'executor reported simulated',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
});
expect(isSatisfyingOutcome(evaluation.outcome)).toBe(false);
});
it('a missing gate result blocks the stage instead of passing vacuously', () => {
const evaluation = evaluateStageGates('05-coding', STAGE_SPECS['05-coding']!.qualityGates, {
task_id: 'FORGE-x-05',
outcome: 'passed',
reason: 'executor claims success',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
});
expect(evaluation.outcome).toBe('blocked');
});
});
describe('fail-closed: authority and provider gates', () => {
let tmpDir: string;
let briefPath: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-authority-'));
briefPath = path.join(tmpDir, 'brief.md');
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it.each(['02-planning-1', '03-planning-2', '04-planning-3', '07-remediate'])(
'planning/remediation stage %s yields waiting-for-authority (not passed) in normal mode',
async (stage) => {
const executor = createTypedExecutor();
let runDir: string | undefined;
try {
await runPipeline(briefPath, tmpDir, {
executor,
stages: [stage as string],
});
expect.unreachable('runPipeline should have failed closed');
} catch (err) {
expect(err).toBeInstanceOf(ForgeCapabilityError);
expect((err as ForgeCapabilityError).code).toBe('FORGE_AUTHORITY_REQUIRED');
runDir = path.join(tmpDir, '.forge', 'runs');
}
const runIds = fs.readdirSync(runDir!);
expect(runIds).toHaveLength(1);
const manifest = loadManifest(path.join(runDir!, runIds[0]!));
expect(manifest.stages[stage]?.status).toBe('waiting-for-authority');
expect(manifest.stages[stage]?.status).not.toBe('passed');
expect(manifest.status).toBe('waiting-for-authority');
},
);
it('review stage fails closed with a typed FORGE_NO_REVIEWER error in normal mode', async () => {
const executor = createTypedExecutor();
try {
await runPipeline(briefPath, tmpDir, {
executor,
stages: ['06-review'],
});
expect.unreachable('runPipeline should have failed closed');
} catch (err) {
expect(err).toBeInstanceOf(ForgeCapabilityError);
expect((err as ForgeCapabilityError).code).toBe('FORGE_NO_REVIEWER');
expect((err as ForgeCapabilityError).capability).toBe('reviewer');
}
const runsDir = path.join(tmpDir, '.forge', 'runs');
const runIds = fs.readdirSync(runsDir);
const manifest = loadManifest(path.join(runsDir, runIds[0]!));
expect(manifest.stages['06-review']?.status).toBe('blocked');
expect(manifest.stages['06-review']?.status).not.toBe('passed');
expect(manifest.status).toBe('failed');
});
it('review stage produces simulated results under --simulate', async () => {
const result = await runPipeline(briefPath, tmpDir, {
simulate: true,
stages: ['06-review'],
});
expect(result.manifest.mode).toBe('simulated');
expect(result.manifest.stages['06-review']?.status).toBe('simulated');
for (const gateResult of result.manifest.stages['06-review']?.gateResults ?? []) {
expect(gateResult.outcome).toBe('simulated');
}
});
it('deploy stage fails closed without a wired ci-pipeline provider in normal mode', async () => {
const executor = createTypedExecutor();
await expect(
runPipeline(briefPath, tmpDir, {
executor,
stages: ['09-deploy'],
}),
).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_NO_CI_PIPELINE',
});
});
});
describe('fail-closed: no vacuous gate commands remain', () => {
it('stage constants contain no echo/synthetic-approval, vacuous true, or empty gate commands', () => {
for (const [stageName, spec] of Object.entries(STAGE_SPECS)) {
for (const gate of spec.qualityGates) {
const serialized = JSON.stringify(gate);
// The echo-review synthetic approval must be gone.
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toContain('echo');
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toMatch(/"verdict"\s*:/);
expect(serialized, `stage ${stageName} gate ${serialized}`).not.toMatch(
/"summary"\s*:\s*"review-pass"/,
);
// No vacuous literal `true` gate.
expect(gate, `stage ${stageName}`).not.toBe('true');
// Command gates must carry a real, non-empty command.
if (isCommandGate(gate)) {
const command = typeof gate === 'string' ? gate : gate.command;
expect(command.trim().length, `stage ${stageName} gate ${serialized}`).toBeGreaterThan(0);
}
}
}
});
it('board tasks contain no vacuous true gates', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-board-gates-'));
try {
const tasks = generateBoardTasks('# Brief', [], tmpDir, 'BOARD-TEST');
for (const task of tasks) {
for (const gate of task.qualityGates) {
expect(gate, `task ${task.id}`).not.toBe('true');
const serialized = JSON.stringify(gate);
expect(serialized, `task ${task.id} gate ${serialized}`).not.toContain('echo');
}
}
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
+34 -161
View File
@@ -12,10 +12,10 @@ import {
resumePipeline, resumePipeline,
getPipelineStatus, getPipelineStatus,
} from '../src/pipeline-runner.js'; } from '../src/pipeline-runner.js';
import type { ForgeTask, ForgeTaskResult, RunManifest, TaskExecutor } from '../src/types.js'; import type { ForgeTask, RunManifest, TaskExecutor } from '../src/types.js';
import { gateLabel, isCommandGate } from '../src/outcomes.js'; import type { TaskResult } from '@mosaicstack/macp';
/** Mock TaskExecutor that records submitted tasks and returns typed results. */ /** Mock TaskExecutor that records submitted tasks and returns success. */
function createMockExecutor(options?: { function createMockExecutor(options?: {
failStage?: string; failStage?: string;
}): TaskExecutor & { submittedTasks: ForgeTask[] } { }): TaskExecutor & { submittedTasks: ForgeTask[] } {
@@ -25,7 +25,7 @@ function createMockExecutor(options?: {
async submitTask(task: ForgeTask) { async submitTask(task: ForgeTask) {
submittedTasks.push(task); submittedTasks.push(task);
}, },
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> { async waitForCompletion(taskId: string): Promise<TaskResult> {
const failStage = options?.failStage; const failStage = options?.failStage;
const task = submittedTasks.find((t) => t.id === taskId); const task = submittedTasks.find((t) => t.id === taskId);
const stageName = task?.metadata?.['stageName'] as string | undefined; const stageName = task?.metadata?.['stageName'] as string | undefined;
@@ -33,8 +33,7 @@ function createMockExecutor(options?: {
if (failStage && stageName === failStage) { if (failStage && stageName === failStage) {
return { return {
task_id: taskId, task_id: taskId,
outcome: 'failed', status: 'failed',
reason: 'mock task failure',
completed_at: new Date().toISOString(), completed_at: new Date().toISOString(),
exit_code: 1, exit_code: 1,
gate_results: [], gate_results: [],
@@ -42,17 +41,10 @@ function createMockExecutor(options?: {
} }
return { return {
task_id: taskId, task_id: taskId,
outcome: 'passed', status: 'completed',
reason: 'mock verified',
completed_at: new Date().toISOString(), completed_at: new Date().toISOString(),
exit_code: 0, exit_code: 0,
gate_results: (task?.qualityGates ?? []) gate_results: [],
.filter((gate) => isCommandGate(gate))
.map((gate) => ({
gate: gateLabel(gate),
outcome: 'passed' as const,
reason: 'mock verified',
})),
}; };
}, },
async getTaskStatus() { async getTaskStatus() {
@@ -164,13 +156,12 @@ describe('runPipeline', () => {
const executor = createMockExecutor(); const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, { const result = await runPipeline(briefPath, tmpDir, {
executor, executor,
stages: ['00-intake', '05-coding'], stages: ['00-intake', '00b-discovery'],
}); });
expect(result.runId).toMatch(/^\d{8}-\d{6}$/); expect(result.runId).toMatch(/^\d{8}-\d{6}$/);
expect(result.stages).toEqual(['00-intake', '05-coding']); expect(result.stages).toEqual(['00-intake', '00b-discovery']);
expect(result.manifest.status).toBe('completed'); expect(result.manifest.status).toBe('completed');
expect(result.manifest.mode).toBe('normal');
expect(executor.submittedTasks).toHaveLength(2); expect(executor.submittedTasks).toHaveLength(2);
}); });
@@ -189,17 +180,12 @@ describe('runPipeline', () => {
const executor = createMockExecutor(); const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, { const result = await runPipeline(briefPath, tmpDir, {
executor, executor,
stages: ['00-intake', '05-coding'], stages: ['00-intake', '00b-discovery'],
}); });
const manifest = loadManifest(result.runDir); const manifest = loadManifest(result.runDir);
expect(manifest.stages['00-intake']?.status).toBe('passed'); expect(manifest.stages['00-intake']?.status).toBe('passed');
expect(manifest.stages['05-coding']?.status).toBe('passed'); expect(manifest.stages['00b-discovery']?.status).toBe('passed');
expect(manifest.stages['05-coding']?.gateResults?.map((g) => g.outcome)).toEqual([
'passed',
'passed',
'passed',
]);
}); });
it('respects CLI class override', async () => { it('respects CLI class override', async () => {
@@ -229,7 +215,7 @@ describe('runPipeline', () => {
const executor = createMockExecutor(); const executor = createMockExecutor();
await runPipeline(briefPath, tmpDir, { await runPipeline(briefPath, tmpDir, {
executor, executor,
stages: ['00-intake', '05-coding', '08-test'], stages: ['00-intake', '00b-discovery', '02-planning-1'],
}); });
expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined(); expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined();
@@ -238,14 +224,14 @@ describe('runPipeline', () => {
}); });
it('handles stage failure', async () => { it('handles stage failure', async () => {
const executor = createMockExecutor({ failStage: '05-coding' }); const executor = createMockExecutor({ failStage: '00b-discovery' });
await expect( await expect(
runPipeline(briefPath, tmpDir, { runPipeline(briefPath, tmpDir, {
executor, executor,
stages: ['00-intake', '05-coding'], stages: ['00-intake', '00b-discovery'],
}), }),
).rejects.toThrow('Stage 05-coding failed'); ).rejects.toThrow('Stage 00b-discovery failed');
}); });
it('marks manifest as failed on stage failure', async () => { it('marks manifest as failed on stage failure', async () => {
@@ -284,143 +270,30 @@ describe('resumePipeline', () => {
fs.rmSync(tmpDir, { recursive: true, force: true }); fs.rmSync(tmpDir, { recursive: true, force: true });
}); });
it('resumes from first incomplete stage and fails closed at the next provider gate', async () => { it('resumes from first incomplete stage', async () => {
// Simulate a run whose authority stages were approved out-of-band // First run fails on discovery
// (recorded as passed) and whose coding stage failed mechanically. const executor1 = createMockExecutor({ failStage: '00b-discovery' });
const runId = '20260101-000000'; let runDir: string;
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' };
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '05-coding',
status: 'failed',
stages: {
'00-intake': passed,
'00b-discovery': passed,
'02-planning-1': passed,
'03-planning-2': passed,
'04-planning-3': passed,
'05-coding': { status: 'failed', reason: 'gate failed' },
},
});
// Resume re-runs 05-coding (the first non-passed stage), then fails try {
// closed at 06-review because no reviewer provider is wired. await runPipeline(briefPath, tmpDir, {
const executor = createMockExecutor(); executor: executor1,
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({ stages: ['00-intake', '00b-discovery', '02-planning-1'],
name: 'ForgeCapabilityError', });
code: 'FORGE_NO_REVIEWER', } catch {
}); // expected
const manifest = loadManifest(runDir);
expect(manifest.stages['05-coding']?.status).toBe('passed');
expect(manifest.stages['06-review']?.status).toBe('blocked');
expect(manifest.status).toBe('failed');
});
it('resumes to completion as simulated under explicit simulate', async () => {
const runId = '20260101-000003';
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
const passed = { status: 'passed' as const, startedAt: '2026-01-01T00:00:00Z' };
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '05-coding',
status: 'failed',
stages: {
'00-intake': passed,
'00b-discovery': passed,
'02-planning-1': passed,
'03-planning-2': passed,
'04-planning-3': passed,
'05-coding': { status: 'failed', reason: 'gate failed' },
},
});
const result = await resumePipeline(runDir, undefined, { simulate: true });
expect(result.manifest.status).toBe('simulated');
expect(result.manifest.mode).toBe('simulated');
expect(result.stages[0]).toBe('05-coding');
for (const stage of result.stages) {
expect(result.manifest.stages[stage]?.status).toBe('simulated');
} }
});
it('fails closed on resume when the next stage needs authority sign-off', async () => { const runsDir = path.join(tmpDir, '.forge', 'runs');
const runId = '20260101-000001'; runDir = path.join(runsDir, fs.readdirSync(runsDir)[0]!);
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {
'00-intake': { status: 'passed' },
},
});
const executor = createMockExecutor(); // Resume should pick up from 00b-discovery
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({ const executor2 = createMockExecutor();
name: 'ForgeCapabilityError', const result = await resumePipeline(runDir, executor2);
code: 'FORGE_AUTHORITY_REQUIRED',
});
const manifest = loadManifest(runDir); expect(result.manifest.status).toBe('completed');
expect(manifest.stages['00b-discovery']?.status).toBe('waiting-for-authority'); // Should have re-run from 00b-discovery onward
expect(manifest.status).toBe('waiting-for-authority'); expect(result.stages[0]).toBe('00b-discovery');
});
it('fails closed on resume without an executor or --simulate', async () => {
const runId = '20260101-000002';
const runDir = path.join(tmpDir, '.forge', 'runs', runId);
fs.mkdirSync(runDir, { recursive: true });
saveManifest(runDir, {
runId,
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
mode: 'normal',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: {
'00-intake': { status: 'passed' },
},
});
await expect(resumePipeline(runDir)).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_NO_EXECUTOR',
});
}); });
}); });
+2 -15
View File
@@ -95,14 +95,7 @@ export function generateBoardTasks(
briefPath, briefPath,
resultPath: resultRelPath, resultPath: resultRelPath,
timeoutSeconds: 120, timeoutSeconds: 120,
qualityGates: [ qualityGates: ['true'],
{
kind: 'authority',
capability: 'board-approval',
reason:
'persona evaluation is judged by board synthesis (authority review); no mechanical gate exists',
},
],
metadata: { metadata: {
personaName: persona.name, personaName: persona.name,
personaSlug: persona.slug, personaSlug: persona.slug,
@@ -128,13 +121,7 @@ export function generateBoardTasks(
timeoutSeconds: 120, timeoutSeconds: 120,
dependsOn: personaTaskIds, dependsOn: personaTaskIds,
dependsOnPolicy: 'all_terminal', dependsOnPolicy: 'all_terminal',
qualityGates: [ qualityGates: ['true'],
{
kind: 'authority',
capability: 'board-approval',
reason: 'board synthesis is an authority decision; no mechanical gate exists',
},
],
metadata: { metadata: {
resultOutputPath: synthesisResult, resultOutputPath: synthesisResult,
inputResultPaths: personaResultPaths, inputResultPaths: personaResultPaths,
+1 -96
View File
@@ -1,11 +1,7 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander'; import { Command } from 'commander';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { describe, expect, it } from 'vitest';
import { registerForgeCommand } from './cli.js'; import { registerForgeCommand } from './cli.js';
import { loadManifest } from './pipeline-runner.js';
describe('registerForgeCommand', () => { describe('registerForgeCommand', () => {
it('registers a "forge" command on the parent program', () => { it('registers a "forge" command on the parent program', () => {
@@ -59,94 +55,3 @@ describe('registerForgeCommand', () => {
}).not.toThrow(); }).not.toThrow();
}); });
}); });
describe('forge run fail-closed behavior (SDLC-D-035)', () => {
let tmpDir: string;
let briefPath: string;
let errSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let prevExitCode: string | number | null | undefined;
const parse = (args: string[]) => {
const program = new Command();
registerForgeCommand(program);
return program.parseAsync(['forge', ...args], { from: 'user' });
};
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-cli-failclosed-'));
briefPath = path.join(tmpDir, 'brief.md');
fs.writeFileSync(briefPath, '# Fix bug\n\nA bugfix for lint cleanup.');
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
prevExitCode = process.exitCode;
});
afterEach(() => {
errSpy.mockRestore();
logSpy.mockRestore();
process.exitCode = prevExitCode;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('exits nonzero with a typed FORGE_NO_EXECUTOR error when no executor is wired and --simulate is absent', async () => {
await parse(['run', '--brief', briefPath, '--codebase', tmpDir]);
expect(process.exitCode).toBe(1);
const errText = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(errText).toContain('FORGE_NO_EXECUTOR');
// It must never run the pipeline with a stub and report success.
expect(fs.existsSync(path.join(tmpDir, '.forge', 'runs'))).toBe(false);
});
it('completes with typed simulated results and exit 0 under explicit --simulate', async () => {
await parse(['run', '--brief', briefPath, '--codebase', tmpDir, '--simulate']);
expect(process.exitCode).toBeUndefined();
// Loud simulated-mode summary.
const logText = logSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(logText).toContain('SIMULATED');
// Manifest records the mode and simulated per-result statuses.
const runsDir = path.join(tmpDir, '.forge', 'runs');
const runIds = fs.readdirSync(runsDir);
expect(runIds).toHaveLength(1);
const manifest = loadManifest(path.join(runsDir, runIds[0]!));
expect(manifest.mode).toBe('simulated');
expect(manifest.status).toBe('simulated');
for (const stageStatus of Object.values(manifest.stages)) {
expect(stageStatus?.status).toBe('simulated');
for (const gateResult of stageStatus?.gateResults ?? []) {
expect(gateResult.outcome).toBe('simulated');
}
}
});
it('resume exits nonzero with a typed FORGE_NO_EXECUTOR error without --simulate', async () => {
const runDir = path.join(tmpDir, '.forge', 'runs', '20260101-000000');
fs.mkdirSync(runDir, { recursive: true });
fs.writeFileSync(
path.join(runDir, 'manifest.json'),
JSON.stringify({
runId: '20260101-000000',
brief: briefPath,
codebase: tmpDir,
briefClass: 'hotfix',
classSource: 'frontmatter',
forceBoard: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
currentStage: '00-intake',
status: 'in_progress',
stages: { '00-intake': { status: 'passed' } },
}),
);
await parse(['resume', '20260101-000000', '--project', tmpDir]);
expect(process.exitCode).toBe(1);
const errText = errSpy.mock.calls.map((c) => c.join(' ')).join('\n');
expect(errText).toContain('FORGE_NO_EXECUTOR');
});
});
+48 -122
View File
@@ -5,47 +5,37 @@ import type { Command } from 'commander';
import { classifyBrief } from './brief-classifier.js'; import { classifyBrief } from './brief-classifier.js';
import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js'; import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js';
import { ForgeCapabilityError } from './errors.js';
import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js'; import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js';
import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js'; import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js';
import { createSimulatedExecutor } from './simulated-executor.js'; import type { PipelineOptions, RunManifest, TaskExecutor } from './types.js';
import type { PipelineOptions, RunManifest, RunMode } from './types.js';
// ---------------------------------------------------------------------------
// Stub executor — used when no real executor is wired at CLI invocation time.
// ---------------------------------------------------------------------------
const stubExecutor: TaskExecutor = {
async submitTask(task) {
console.log(` [forge] stage submitted: ${task.id} (${task.title})`);
},
async waitForCompletion(taskId, _timeoutMs) {
console.log(` [forge] stage complete: ${taskId}`);
return {
task_id: taskId,
status: 'completed' as const,
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
};
},
async getTaskStatus(_taskId) {
return 'completed' as const;
},
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** Resolve a run's effective mode, defaulting legacy manifests to normal. */
function runModeOf(manifest: RunManifest): RunMode {
return manifest.mode ?? 'normal';
}
/** Print a loud banner so a simulated run can never be misread as verified. */
function printSimulatedBanner(): void {
console.log('');
console.log('[forge] ===============================================================');
console.log('[forge] MODE: SIMULATED — no stage or gate was really executed.');
console.log('[forge] All results are synthetic and MUST NOT be read as verified');
console.log('[forge] success. Wire a real executor/providers and re-run to verify.');
console.log('[forge] ===============================================================');
}
/** Print a typed error line for fail-closed capability errors. */
function printCapabilityError(err: ForgeCapabilityError): void {
console.error(`[forge] error ${err.code}: ${err.message}`);
console.error(`[forge] missing capability: ${err.capability}`);
}
/** Handle a pipeline error uniformly: typed capability errors get their code. */
function handlePipelineError(err: unknown): void {
if (err instanceof ForgeCapabilityError) {
printCapabilityError(err);
} else {
console.error(`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`);
}
process.exitCode = 1;
}
function formatDuration(startedAt?: string, completedAt?: string): string { function formatDuration(startedAt?: string, completedAt?: string): string {
if (!startedAt || !completedAt) return '-'; if (!startedAt || !completedAt) return '-';
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime(); const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
@@ -54,24 +44,19 @@ function formatDuration(startedAt?: string, completedAt?: string): string {
} }
function printManifestTable(manifest: RunManifest): void { function printManifestTable(manifest: RunManifest): void {
const mode = runModeOf(manifest);
console.log(`\nRun ID : ${manifest.runId}`); console.log(`\nRun ID : ${manifest.runId}`);
console.log(`Status : ${manifest.status}`); console.log(`Status : ${manifest.status}`);
console.log(`Mode : ${mode}`);
if (mode === 'simulated') {
console.log('WARNING: SIMULATED RUN — results are synthetic, not verified success.');
}
console.log(`Brief : ${manifest.brief}`); console.log(`Brief : ${manifest.brief}`);
console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`); console.log(`Class : ${manifest.briefClass} (${manifest.classSource})`);
console.log(`Updated: ${manifest.updatedAt}`); console.log(`Updated: ${manifest.updatedAt}`);
console.log(''); console.log('');
console.log('Stage'.padEnd(22) + 'Status'.padEnd(24) + 'Duration'); console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration');
console.log('-'.repeat(60)); console.log('-'.repeat(50));
for (const stage of STAGE_SEQUENCE) { for (const stage of STAGE_SEQUENCE) {
const s = manifest.stages[stage]; const s = manifest.stages[stage];
if (!s) continue; if (!s) continue;
const label = (STAGE_LABELS[stage] ?? stage).padEnd(22); const label = (STAGE_LABELS[stage] ?? stage).padEnd(22);
const status = s.status.padEnd(24); const status = s.status.padEnd(14);
const dur = formatDuration(s.startedAt, s.completedAt); const dur = formatDuration(s.startedAt, s.completedAt);
console.log(`${label}${status}${dur}`); console.log(`${label}${status}${dur}`);
} }
@@ -105,58 +90,23 @@ function listRecentRuns(projectRoot?: string): void {
} }
console.log('\nRecent runs:'); console.log('\nRecent runs:');
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(24) + 'Mode'.padEnd(12) + 'Brief'); console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief');
console.log('-'.repeat(80)); console.log('-'.repeat(70));
for (const runId of entries) { for (const runId of entries) {
const runDir = path.join(runsDir, runId); const runDir = path.join(runsDir, runId);
try { try {
const manifest = loadManifest(runDir); const manifest = loadManifest(runDir);
const status = manifest.status.padEnd(24); const status = manifest.status.padEnd(14);
const mode = runModeOf(manifest).padEnd(12);
const brief = path.basename(manifest.brief); const brief = path.basename(manifest.brief);
console.log(`${runId.padEnd(22)}${status}${mode}${brief}`); console.log(`${runId.padEnd(22)}${status}${brief}`);
} catch { } catch {
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(24)}`); console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`);
} }
} }
console.log(''); console.log('');
} }
/**
* Apply the exit-code policy for a finished pipeline run (SDLC-D-035):
*
* - exit 0 only for a verified `completed` normal run, or for an overall
* `simulated` run when the caller explicitly passed --simulate;
* - anything else exits nonzero so it can never be read as success.
*/
function applyRunExitPolicy(result: { manifest: RunManifest; runDir: string }, simulate: boolean) {
const { manifest } = result;
if (runModeOf(manifest) === 'simulated') {
if (!simulate || manifest.status !== 'simulated') {
console.error(
'[forge] error FORGE_MODE_MISMATCH: run reports simulated results without an explicit, ' +
'consistent --simulate request; refusing to report success.',
);
process.exitCode = 1;
return;
}
printSimulatedBanner();
console.log(`[forge] run directory: ${result.runDir}`);
return; // exit 0 — the caller explicitly opted into simulation
}
if (manifest.status !== 'completed') {
console.error(`[forge] run did not complete: terminal status '${manifest.status}'`);
process.exitCode = 1;
return;
}
console.log(`[forge] pipeline complete (mode: normal): ${manifest.runId}`);
console.log(`[forge] run directory: ${result.runDir}`);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Register function // Register function
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -179,11 +129,6 @@ export function registerForgeCommand(parent: Command): void {
.option('--config <path>', 'Path to forge config file (.forge/config.yaml)') .option('--config <path>', 'Path to forge config file (.forge/config.yaml)')
.option('--codebase <path>', 'Codebase root to pass to the pipeline', process.cwd()) .option('--codebase <path>', 'Codebase root to pass to the pipeline', process.cwd())
.option('--dry-run', 'Print planned stages without executing', false) .option('--dry-run', 'Print planned stages without executing', false)
.option(
'--simulate',
'Simulate execution without real providers (every result is typed simulated, never verified)',
false,
)
.action( .action(
async (opts: { async (opts: {
brief: string; brief: string;
@@ -192,7 +137,6 @@ export function registerForgeCommand(parent: Command): void {
config?: string; config?: string;
codebase: string; codebase: string;
dryRun: boolean; dryRun: boolean;
simulate: boolean;
}) => { }) => {
const briefPath = path.resolve(opts.brief); const briefPath = path.resolve(opts.brief);
@@ -205,22 +149,14 @@ export function registerForgeCommand(parent: Command): void {
const briefContent = fs.readFileSync(briefPath, 'utf-8'); const briefContent = fs.readFileSync(briefPath, 'utf-8');
const briefClass = classifyBrief(briefContent); const briefClass = classifyBrief(briefContent);
const projectRoot = opts.codebase; const projectRoot = opts.codebase;
// A real executor is never wired at CLI invocation time today, so the
// only executor we may construct is the explicitly-requested simulated
// one. Normal mode fails closed with FORGE_NO_EXECUTOR.
const executor = opts.simulate ? createSimulatedExecutor() : undefined;
if (opts.resume) { if (opts.resume) {
const runId = opts.runId ?? generateRunId(); const runId = opts.runId ?? generateRunId();
const runDir = resolveRunDir(runId, projectRoot); const runDir = resolveRunDir(runId, projectRoot);
console.log(`[forge] resuming run: ${runId}`); console.log(`[forge] resuming run: ${runId}`);
try { const { resumePipeline } = await import('./pipeline-runner.js');
const { resumePipeline } = await import('./pipeline-runner.js'); const result = await resumePipeline(runDir, stubExecutor);
const result = await resumePipeline(runDir, executor, { simulate: opts.simulate }); console.log(`[forge] pipeline complete: ${result.runId}`);
applyRunExitPolicy(result, opts.simulate);
} catch (err) {
handlePipelineError(err);
}
return; return;
} }
@@ -228,8 +164,7 @@ export function registerForgeCommand(parent: Command): void {
briefClass, briefClass,
codebase: projectRoot, codebase: projectRoot,
dryRun: opts.dryRun, dryRun: opts.dryRun,
executor, executor: stubExecutor,
simulate: opts.simulate,
}; };
if (opts.dryRun) { if (opts.dryRun) {
@@ -245,15 +180,16 @@ export function registerForgeCommand(parent: Command): void {
console.log(`[forge] starting pipeline for brief: ${briefPath}`); console.log(`[forge] starting pipeline for brief: ${briefPath}`);
console.log(`[forge] classified as: ${briefClass}`); console.log(`[forge] classified as: ${briefClass}`);
if (opts.simulate) {
console.log('[forge] mode: SIMULATED (explicit --simulate)');
}
try { try {
const result = await runPipeline(briefPath, projectRoot, pipelineOptions); const result = await runPipeline(briefPath, projectRoot, pipelineOptions);
applyRunExitPolicy(result, opts.simulate); console.log(`[forge] pipeline complete: ${result.runId}`);
console.log(`[forge] run directory: ${result.runDir}`);
} catch (err) { } catch (err) {
handlePipelineError(err); console.error(
`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
} }
}, },
); );
@@ -288,12 +224,7 @@ export function registerForgeCommand(parent: Command): void {
.command('resume <runId>') .command('resume <runId>')
.description('Resume a stopped or failed pipeline run') .description('Resume a stopped or failed pipeline run')
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd()) .option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
.option( .action(async (runId: string, opts: { project: string }) => {
'--simulate',
'Simulate execution without real providers (every result is typed simulated, never verified)',
false,
)
.action(async (runId: string, opts: { project: string; simulate: boolean }) => {
const runDir = resolveRunDir(runId, opts.project); const runDir = resolveRunDir(runId, opts.project);
if (!fs.existsSync(runDir)) { if (!fs.existsSync(runDir)) {
@@ -303,20 +234,15 @@ export function registerForgeCommand(parent: Command): void {
} }
console.log(`[forge] resuming run: ${runId}`); console.log(`[forge] resuming run: ${runId}`);
if (opts.simulate) {
console.log('[forge] mode: SIMULATED (explicit --simulate)');
}
// No real executor is wired at CLI invocation time; only the explicitly
// requested simulated executor may be constructed (fail closed otherwise).
const executor = opts.simulate ? createSimulatedExecutor() : undefined;
try { try {
const { resumePipeline } = await import('./pipeline-runner.js'); const { resumePipeline } = await import('./pipeline-runner.js');
const result = await resumePipeline(runDir, executor, { simulate: opts.simulate }); const result = await resumePipeline(runDir, stubExecutor);
applyRunExitPolicy(result, opts.simulate); console.log(`[forge] pipeline complete: ${result.runId}`);
console.log(`[forge] run directory: ${result.runDir}`);
} catch (err) { } catch (err) {
handlePipelineError(err); console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
} }
}); });
+12 -72
View File
@@ -9,16 +9,7 @@ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.
/** Pipeline asset directory (stages, agents, rails, gates, templates). */ /** Pipeline asset directory (stages, agents, rails, gates, templates). */
export const PIPELINE_DIR = path.join(PACKAGE_ROOT, 'pipeline'); export const PIPELINE_DIR = path.join(PACKAGE_ROOT, 'pipeline');
/** Stage specifications defines every pipeline stage. /** Stage specifications — defines every pipeline stage. */
*\n * Gate semantics (SDLC-D-035): every gate is one of
* - a real command string / GateEntry a mechanical runner can execute,
* - an `authority` gate (human/board sign-off; produces waiting-for-authority),
* - a `provider` gate (requires a wired provider such as a reviewer or CI pipeline).
*
* Vacuous gates (`true`, echo'd synthetic approvals, placeholder ci-pipeline
* commands) are forbidden: a stage whose gate has no real implementation
* fails closed instead of passing.
*/
export const STAGE_SPECS: Record<string, StageSpec> = { export const STAGE_SPECS: Record<string, StageSpec> = {
'00-intake': { '00-intake': {
number: '00', number: '00',
@@ -36,13 +27,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research', type: 'research',
gate: 'discovery-complete', gate: 'discovery-complete',
promptFile: '00b-discovery.md', promptFile: '00b-discovery.md',
qualityGates: [ qualityGates: ['true'],
{
kind: 'authority',
capability: 'discovery-complete',
reason: 'discovery completion is attested by an authority; no mechanical check exists',
},
],
}, },
'01-board': { '01-board': {
number: '01', number: '01',
@@ -51,13 +36,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'review', type: 'review',
gate: 'board-approval', gate: 'board-approval',
promptFile: '01-board.md', promptFile: '01-board.md',
qualityGates: [ qualityGates: [{ type: 'ci-pipeline', command: 'board-approval (via board-tasks)' }],
{
kind: 'authority',
capability: 'board-approval',
reason: 'board approval is a board/human decision; no mechanical gate exists',
},
],
}, },
'01b-brief-analyzer': { '01b-brief-analyzer': {
number: '01b', number: '01b',
@@ -66,13 +45,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research', type: 'research',
gate: 'brief-analysis-complete', gate: 'brief-analysis-complete',
promptFile: '01-board.md', promptFile: '01-board.md',
qualityGates: [ qualityGates: ['true'],
{
kind: 'authority',
capability: 'brief-analysis-complete',
reason: 'brief analysis completion is attested by an authority; no mechanical check exists',
},
],
}, },
'02-planning-1': { '02-planning-1': {
number: '02', number: '02',
@@ -81,13 +54,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research', type: 'research',
gate: 'architecture-approval', gate: 'architecture-approval',
promptFile: '02-planning-1-architecture.md', promptFile: '02-planning-1-architecture.md',
qualityGates: [ qualityGates: ['true'],
{
kind: 'authority',
capability: 'architecture-approval',
reason: 'ADR approval requires authority sign-off; no mechanical check exists',
},
],
}, },
'03-planning-2': { '03-planning-2': {
number: '03', number: '03',
@@ -96,14 +63,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research', type: 'research',
gate: 'implementation-approval', gate: 'implementation-approval',
promptFile: '03-planning-2-implementation.md', promptFile: '03-planning-2-implementation.md',
qualityGates: [ qualityGates: ['true'],
{
kind: 'authority',
capability: 'implementation-approval',
reason:
'implementation spec approval requires authority sign-off; no mechanical check exists',
},
],
}, },
'04-planning-3': { '04-planning-3': {
number: '04', number: '04',
@@ -112,14 +72,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research', type: 'research',
gate: 'decomposition-approval', gate: 'decomposition-approval',
promptFile: '04-planning-3-decomposition.md', promptFile: '04-planning-3-decomposition.md',
qualityGates: [ qualityGates: ['true'],
{
kind: 'authority',
capability: 'decomposition-approval',
reason:
'task decomposition approval requires authority sign-off; no mechanical check exists',
},
],
}, },
'05-coding': { '05-coding': {
number: '05', number: '05',
@@ -139,10 +92,9 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
promptFile: '06-review.md', promptFile: '06-review.md',
qualityGates: [ qualityGates: [
{ {
kind: 'provider', type: 'ai-review',
capability: 'reviewer', command:
reason: 'echo \'{"summary":"review-pass","verdict":"approve","findings":[],"stats":{"blockers":0,"should_fix":0,"suggestions":0}}\'',
'review verdicts require a wired reviewer provider; synthetic approvals are not permitted',
}, },
], ],
}, },
@@ -153,13 +105,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'coding', type: 'coding',
gate: 're-review', gate: 're-review',
promptFile: '07-remediate.md', promptFile: '07-remediate.md',
qualityGates: [ qualityGates: ['true'],
{
kind: 'authority',
capability: 're-review',
reason: 'remediation re-review is an approval-based gate; no mechanical check exists',
},
],
}, },
'08-test': { '08-test': {
number: '08', number: '08',
@@ -177,13 +123,7 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'deploy', type: 'deploy',
gate: 'deploy-verification', gate: 'deploy-verification',
promptFile: '09-deploy.md', promptFile: '09-deploy.md',
qualityGates: [ qualityGates: [{ type: 'ci-pipeline', command: 'deploy-verification' }],
{
kind: 'provider',
capability: 'ci-pipeline',
reason: 'deploy verification requires a wired CI pipeline provider',
},
],
}, },
}; };
-46
View File
@@ -1,46 +0,0 @@
/**
* Typed fail-closed capability errors (SDLC-D-035).
*
* A Forge run must fail closed when a required capability (executor, reviewer
* provider, CI pipeline, authority sign-off) is missing. These typed errors
* name the missing capability so callers can distinguish "not wired" from
* ordinary execution failures.
*/
/** Closed set of typed Forge capability error codes. */
export const FORGE_ERROR_CODES = [
'FORGE_NO_EXECUTOR',
'FORGE_NO_REVIEWER',
'FORGE_NO_CI_PIPELINE',
'FORGE_NO_PROVIDER',
'FORGE_AUTHORITY_REQUIRED',
] as const;
export type ForgeErrorCode = (typeof FORGE_ERROR_CODES)[number];
/** Raised when a required capability is missing and the pipeline must fail closed. */
export class ForgeCapabilityError extends Error {
/** Typed error code from the closed FORGE_ERROR_CODES set. */
readonly code: ForgeErrorCode;
/** The missing capability, e.g. `task-executor`, `reviewer`, `board-approval`. */
readonly capability: string;
constructor(code: ForgeErrorCode, capability: string, message: string) {
super(message);
this.name = 'ForgeCapabilityError';
this.code = code;
this.capability = capability;
}
}
/** Map a provider gate capability to its typed error code. */
export function providerErrorCode(capability: string): ForgeErrorCode {
switch (capability) {
case 'reviewer':
return 'FORGE_NO_REVIEWER';
case 'ci-pipeline':
return 'FORGE_NO_CI_PIPELINE';
default:
return 'FORGE_NO_PROVIDER';
}
}
-26
View File
@@ -5,13 +5,6 @@ export type {
StageSpec, StageSpec,
BriefClass, BriefClass,
ClassSource, ClassSource,
ForgeOutcome,
AuthorityGate,
ProviderGate,
ForgeGate,
ForgeGateResult,
ForgeTaskResult,
RunMode,
StageStatus, StageStatus,
RunManifest, RunManifest,
ForgeTaskStatus, ForgeTaskStatus,
@@ -88,24 +81,5 @@ export {
getPipelineStatus, getPipelineStatus,
} from './pipeline-runner.js'; } from './pipeline-runner.js';
// Fail-closed errors and typed outcome model (SDLC-D-035)
export { FORGE_ERROR_CODES, ForgeCapabilityError, providerErrorCode } from './errors.js';
export type { ForgeErrorCode } from './errors.js';
export {
isSatisfyingOutcome,
isCapabilityGate,
isCommandGate,
gateLabel,
uniformGateResults,
simulatedGateResults,
waitingGateResults,
blockedGateResults,
evaluateStageGates,
} from './outcomes.js';
export type { StageEvaluation } from './outcomes.js';
// Simulated executor (explicit --simulate only)
export { createSimulatedExecutor } from './simulated-executor.js';
// CLI // CLI
export { registerForgeCommand } from './cli.js'; export { registerForgeCommand } from './cli.js';
-147
View File
@@ -1,147 +0,0 @@
import type { GateEntry } from '@mosaicstack/macp';
import type {
AuthorityGate,
ForgeGate,
ForgeGateResult,
ForgeOutcome,
ForgeTaskResult,
ProviderGate,
} from './types.js';
/**
* Gate and dependency satisfaction predicate (SDLC-D-035).
*
* ONLY a verified `passed` outcome satisfies. Every other member of the closed
* outcome set including `simulated` is non-satisfying, so a simulated or
* authority-blocked result can never be read as success-by-verification.
*/
export function isSatisfyingOutcome(outcome: ForgeOutcome): boolean {
return outcome === 'passed';
}
/** Whether a gate is an authority or provider gate (capability-based, command-less). */
export function isCapabilityGate(gate: ForgeGate): gate is AuthorityGate | ProviderGate {
if (typeof gate !== 'object' || gate === null) return false;
const kind = (gate as Record<string, unknown>)['kind'];
return kind === 'authority' || kind === 'provider';
}
/** Whether a gate definition carries a real command a mechanical runner can execute. */
export function isCommandGate(gate: ForgeGate): gate is string | GateEntry {
if (typeof gate === 'string') {
return gate.trim().length > 0;
}
if (isCapabilityGate(gate)) {
// Authority and provider gates are satisfied by a capability, not a command.
return false;
}
return typeof gate.command === 'string' && gate.command.trim().length > 0;
}
/** Typed label identifying a gate in results and logs. */
export function gateLabel(gate: ForgeGate): string {
if (typeof gate === 'string') return gate;
if (isCapabilityGate(gate)) return `${gate.kind}:${gate.capability}`;
return gate.command || gate.type || 'unnamed-gate';
}
/** Reason string stamped on every simulated gate result. */
export const SIMULATED_GATE_REASON =
'simulated execution (--simulate): gate was not evaluated by a real implementation';
/** Build typed gate results with a uniform outcome for a stage's declared gates. */
export function uniformGateResults(
gates: ForgeGate[],
outcome: ForgeOutcome,
reason: string,
): ForgeGateResult[] {
return gates.map((gate) => ({ gate: gateLabel(gate), outcome, reason }));
}
/** Typed simulated gate results — used exclusively in `--simulate` runs. */
export function simulatedGateResults(gates: ForgeGate[]): ForgeGateResult[] {
return uniformGateResults(gates, 'simulated', SIMULATED_GATE_REASON);
}
/** Typed waiting-for-authority gate results for approval-based stages. */
export function waitingGateResults(gates: ForgeGate[], reason: string): ForgeGateResult[] {
return uniformGateResults(gates, 'waiting-for-authority', reason);
}
/** Typed blocked gate results for stages whose provider capability is not wired. */
export function blockedGateResults(gates: ForgeGate[], reason: string): ForgeGateResult[] {
return uniformGateResults(gates, 'blocked', reason);
}
/** Outcome of evaluating a completed stage in normal mode. */
export interface StageEvaluation {
outcome: ForgeOutcome;
reason: string;
gateResults: ForgeGateResult[];
}
/**
* Evaluate a stage's declared gates against the executor's typed result.
*
* Fail-closed mapping:
* - a `simulated` task or gate outcome in normal mode maps to `error`
* - a missing gate result for a required command gate maps to `blocked`
* - a non-passing task outcome propagates as the stage outcome
* - only verified `passed` task and gate outcomes yield a `passed` stage
*/
export function evaluateStageGates(
stageName: string,
gates: ForgeGate[],
result: ForgeTaskResult,
): StageEvaluation {
const gateResults = result.gate_results ?? [];
if (result.outcome === 'simulated') {
return {
outcome: 'error',
reason: `executor reported a simulated outcome for stage '${stageName}' in normal mode — refusing to treat simulated results as verified`,
gateResults,
};
}
if (!isSatisfyingOutcome(result.outcome)) {
return {
outcome: result.outcome,
reason: `task outcome is '${result.outcome}': ${result.reason}`,
gateResults,
};
}
for (const gate of gates) {
// Authority and provider gates are pre-flighted before execution; they have
// no mechanical result to verify here.
if (!isCommandGate(gate)) continue;
const label = gateLabel(gate);
const gateResult = gateResults.find((r) => r.gate === label);
if (!gateResult) {
return {
outcome: 'blocked',
reason: `no gate result was reported for required gate '${label}' (stage '${stageName}')`,
gateResults,
};
}
if (!isSatisfyingOutcome(gateResult.outcome)) {
return {
outcome: gateResult.outcome === 'simulated' ? 'error' : gateResult.outcome,
reason: `gate '${label}' outcome is '${gateResult.outcome}': ${gateResult.reason}`,
gateResults,
};
}
}
return {
outcome: 'passed',
reason:
gates.length === 0
? "stage declares no gates; task outcome 'passed' accepted"
: 'all declared gates verified passed',
gateResults,
};
}
+99 -227
View File
@@ -1,33 +1,18 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { STAGE_SEQUENCE, STAGE_SPECS } from './constants.js'; import { STAGE_SEQUENCE } from './constants.js';
import { determineBriefClass, stagesForClass } from './brief-classifier.js'; import { determineBriefClass, stagesForClass } from './brief-classifier.js';
import { ForgeCapabilityError, providerErrorCode } from './errors.js';
import {
blockedGateResults,
evaluateStageGates,
isCapabilityGate,
simulatedGateResults,
waitingGateResults,
} from './outcomes.js';
import { mapStageToTask } from './stage-adapter.js'; import { mapStageToTask } from './stage-adapter.js';
import { createSimulatedExecutor } from './simulated-executor.js';
import type { import type {
ForgeTask, ForgeTask,
ForgeTaskResult,
PipelineOptions, PipelineOptions,
PipelineResult, PipelineResult,
RunManifest, RunManifest,
RunMode,
StageStatus, StageStatus,
TaskExecutor, TaskExecutor,
} from './types.js'; } from './types.js';
/** Reason stamped on stages that complete under explicit simulation. */
const SIMULATED_STAGE_REASON =
'simulated execution (--simulate): stage was not executed by a real executor';
/** /**
* Generate a timestamp-based run ID. * Generate a timestamp-based run ID.
*/ */
@@ -62,7 +47,6 @@ function createManifest(opts: {
briefClass: RunManifest['briefClass']; briefClass: RunManifest['briefClass'];
classSource: RunManifest['classSource']; classSource: RunManifest['classSource'];
forceBoard: boolean; forceBoard: boolean;
mode: RunMode;
runDir: string; runDir: string;
}): RunManifest { }): RunManifest {
const ts = nowISO(); const ts = nowISO();
@@ -73,7 +57,6 @@ function createManifest(opts: {
briefClass: opts.briefClass, briefClass: opts.briefClass,
classSource: opts.classSource, classSource: opts.classSource,
forceBoard: opts.forceBoard, forceBoard: opts.forceBoard,
mode: opts.mode,
createdAt: ts, createdAt: ts,
updatedAt: ts, updatedAt: ts,
currentStage: '', currentStage: '',
@@ -125,199 +108,20 @@ export function selectStages(stages?: string[], skipTo?: string): string[] {
return selected.slice(skipIndex); return selected.slice(skipIndex);
} }
/**
* Fail closed when the required executor capability is missing (SDLC-D-035).
*/
function requireExecutor(executor: TaskExecutor | undefined, simulate: boolean): TaskExecutor {
if (executor) return executor;
if (simulate) return createSimulatedExecutor({ log: false });
throw new ForgeCapabilityError(
'FORGE_NO_EXECUTOR',
'task-executor',
'no task executor is wired; refusing to run the pipeline with a stub executor (fail closed). ' +
'Pass --simulate to opt into explicitly simulated execution.',
);
}
/**
* Pre-flight a stage's gates in normal mode (fail closed, SDLC-D-035).
*
* - authority gates: record a typed `waiting-for-authority` stage result and
* raise FORGE_AUTHORITY_REQUIRED approval-based gates never pass vacuously.
* - provider gates: record a typed `blocked` stage result and raise the typed
* capability error for the missing provider.
*
* Returns the stage status to record when the pre-flight blocks, or undefined
* when the stage may proceed.
*/
function preflightStageGates(
stageName: string,
manifest: RunManifest,
): { status: StageStatus; error: ForgeCapabilityError } | undefined {
const spec = STAGE_SPECS[stageName];
if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`);
for (const gate of spec.qualityGates) {
if (!isCapabilityGate(gate)) continue;
const startedAt = manifest.stages[stageName]?.startedAt;
const completedAt = nowISO();
if (gate.kind === 'authority') {
const reason = `gate '${gate.capability}' requires authority sign-off; no mechanical implementation exists (${gate.reason})`;
return {
status: {
status: 'waiting-for-authority',
reason,
startedAt,
completedAt,
gateResults: waitingGateResults(spec.qualityGates, reason),
},
error: new ForgeCapabilityError(
'FORGE_AUTHORITY_REQUIRED',
gate.capability,
`stage '${stageName}' is blocked on authority gate '${gate.capability}': ${gate.reason}. ` +
'The pipeline fails closed instead of passing vacuously. Record the approval out-of-band ' +
'or run with --simulate for explicitly simulated execution.',
),
};
}
const reason = `gate '${gate.capability}' requires provider '${gate.capability}' and none is wired (${gate.reason})`;
return {
status: {
status: 'blocked',
reason,
startedAt,
completedAt,
gateResults: blockedGateResults(spec.qualityGates, reason),
},
error: new ForgeCapabilityError(
providerErrorCode(gate.capability),
gate.capability,
`stage '${stageName}' requires provider '${gate.capability}' which is not wired: ${gate.reason}. ` +
'The pipeline fails closed instead of passing vacuously.',
),
};
}
return undefined;
}
/**
* Execute the given stage tasks sequentially, updating the manifest.
*
* Normal mode requires a real executor and evaluates every declared command
* gate through the typed outcome model; any non-verified result fails closed.
* Simulate mode types every stage and gate result as `simulated`.
*/
async function executeStages(opts: {
manifest: RunManifest;
runDir: string;
tasks: ForgeTask[];
stageNames: string[];
executor: TaskExecutor;
simulate: boolean;
}): Promise<void> {
const { manifest, runDir, tasks, stageNames, executor, simulate } = opts;
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i]!;
const stageName = stageNames[i]!;
const spec = STAGE_SPECS[stageName];
if (!spec) throw new Error(`Unknown Forge stage: ${stageName}`);
// Update manifest: stage in progress
manifest.currentStage = stageName;
manifest.stages[stageName] = {
status: 'in_progress',
startedAt: nowISO(),
};
saveManifest(runDir, manifest);
// Fail-closed pre-flight (normal mode only): authority/provider gates have
// no mechanical implementation and must never pass vacuously.
if (!simulate) {
const blocked = preflightStageGates(stageName, manifest);
if (blocked) {
manifest.stages[stageName] = blocked.status;
manifest.status =
blocked.status.status === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed';
saveManifest(runDir, manifest);
throw blocked.error;
}
}
let result: ForgeTaskResult;
try {
await executor.submitTask(task);
result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
} catch (error) {
// Process errors (including timeouts) map to the fail-closed `error` outcome.
const reason = error instanceof Error ? error.message : String(error);
manifest.stages[stageName] = {
status: 'error',
reason: `executor error: ${reason}`,
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
gateResults: [],
};
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw error instanceof Error ? error : new Error(reason);
}
if (simulate) {
manifest.stages[stageName] = {
status: 'simulated',
reason: SIMULATED_STAGE_REASON,
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
gateResults: simulatedGateResults(spec.qualityGates),
};
saveManifest(runDir, manifest);
continue;
}
const evaluation = evaluateStageGates(stageName, spec.qualityGates, result);
manifest.stages[stageName] = {
status: evaluation.outcome,
reason: evaluation.reason,
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
gateResults: evaluation.gateResults,
};
if (evaluation.outcome !== 'passed') {
manifest.status =
evaluation.outcome === 'waiting-for-authority' ? 'waiting-for-authority' : 'failed';
saveManifest(runDir, manifest);
throw new Error(`Stage ${stageName} ${evaluation.outcome}: ${evaluation.reason}`);
}
saveManifest(runDir, manifest);
}
}
/** /**
* Run the Forge pipeline. * Run the Forge pipeline.
* *
* 1. Fail closed unless a real executor is wired or simulation is explicit * 1. Classify the brief
* 2. Classify the brief * 2. Generate a run ID and create run directory
* 3. Generate a run ID and create run directory * 3. Map stages to tasks and submit to TaskExecutor
* 4. Map stages to tasks and submit to TaskExecutor * 4. Track manifest with stage statuses
* 5. Track manifest with typed stage outcomes * 5. Return pipeline result
* 6. Return pipeline result
*/ */
export async function runPipeline( export async function runPipeline(
briefPath: string, briefPath: string,
projectRoot: string, projectRoot: string,
options: PipelineOptions, options: PipelineOptions,
): Promise<PipelineResult> { ): Promise<PipelineResult> {
const simulate = options.simulate ?? false;
const executor = requireExecutor(options.executor, simulate);
const mode: RunMode = simulate ? 'simulated' : 'normal';
const resolvedRoot = path.resolve(projectRoot); const resolvedRoot = path.resolve(projectRoot);
const resolvedBrief = path.resolve(briefPath); const resolvedBrief = path.resolve(briefPath);
const briefContent = fs.readFileSync(resolvedBrief, 'utf-8'); const briefContent = fs.readFileSync(resolvedBrief, 'utf-8');
@@ -342,7 +146,6 @@ export async function runPipeline(
briefClass, briefClass,
classSource, classSource,
forceBoard: options.forceBoard ?? false, forceBoard: options.forceBoard ?? false,
mode,
runDir, runDir,
}); });
@@ -369,10 +172,54 @@ export async function runPipeline(
} }
// Execute stages // Execute stages
await executeStages({ manifest, runDir, tasks, stageNames: selectedStages, executor, simulate }); const { executor } = options;
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i]!;
const stageName = selectedStages[i]!;
// All stages reached a terminal state for this mode // Update manifest: stage in progress
manifest.status = simulate ? 'simulated' : 'completed'; manifest.currentStage = stageName;
manifest.stages[stageName] = {
status: 'in_progress',
startedAt: nowISO(),
};
saveManifest(runDir, manifest);
try {
await executor.submitTask(task);
const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
// Update manifest: stage completed or failed
const stageStatus: StageStatus = {
status: result.status === 'completed' ? 'passed' : 'failed',
startedAt: manifest.stages[stageName]!.startedAt,
completedAt: nowISO(),
};
manifest.stages[stageName] = stageStatus;
if (result.status !== 'completed') {
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw new Error(`Stage ${stageName} failed with status: ${result.status}`);
}
saveManifest(runDir, manifest);
} catch (error) {
if (!manifest.stages[stageName]?.completedAt) {
manifest.stages[stageName] = {
status: 'failed',
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
};
}
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw error;
}
}
// All stages passed
manifest.status = 'completed';
saveManifest(runDir, manifest); saveManifest(runDir, manifest);
return { return {
@@ -387,30 +234,22 @@ export async function runPipeline(
} }
/** /**
* Resume a pipeline from the last non-passed stage. * Resume a pipeline from the last incomplete stage.
*/ */
export async function resumePipeline( export async function resumePipeline(
runDir: string, runDir: string,
executor?: TaskExecutor, executor: TaskExecutor,
options?: { simulate?: boolean },
): Promise<PipelineResult> { ): Promise<PipelineResult> {
const simulate = options?.simulate ?? false;
const wiredExecutor = requireExecutor(executor, simulate);
const mode: RunMode = simulate ? 'simulated' : 'normal';
const manifest = loadManifest(runDir); const manifest = loadManifest(runDir);
const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root
const briefContent = fs.readFileSync(manifest.brief, 'utf-8'); const briefContent = fs.readFileSync(manifest.brief, 'utf-8');
const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard); const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard);
manifest.mode = mode; // Find first non-passed stage
// Find first non-satisfying stage (only a verified `passed` counts as done;
// simulated and waiting-for-authority stages are re-run).
const resumeFrom = allStages.find((s) => manifest.stages[s]?.status !== 'passed'); const resumeFrom = allStages.find((s) => manifest.stages[s]?.status !== 'passed');
if (!resumeFrom) { if (!resumeFrom) {
manifest.status = mode === 'simulated' ? 'simulated' : 'completed'; manifest.status = 'completed';
saveManifest(runDir, manifest); saveManifest(runDir, manifest);
return { return {
runId: manifest.runId, runId: manifest.runId,
@@ -445,16 +284,49 @@ export async function resumePipeline(
tasks.push(task); tasks.push(task);
} }
await executeStages({ for (let i = 0; i < tasks.length; i++) {
manifest, const task = tasks[i]!;
runDir, const stageName = remainingStages[i]!;
tasks,
stageNames: remainingStages,
executor: wiredExecutor,
simulate,
});
manifest.status = simulate ? 'simulated' : 'completed'; manifest.currentStage = stageName;
manifest.stages[stageName] = {
status: 'in_progress',
startedAt: nowISO(),
};
saveManifest(runDir, manifest);
try {
await executor.submitTask(task);
const result = await executor.waitForCompletion(task.id, task.timeoutSeconds * 1000);
manifest.stages[stageName] = {
status: result.status === 'completed' ? 'passed' : 'failed',
startedAt: manifest.stages[stageName]!.startedAt,
completedAt: nowISO(),
};
if (result.status !== 'completed') {
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw new Error(`Stage ${stageName} failed with status: ${result.status}`);
}
saveManifest(runDir, manifest);
} catch (error) {
if (!manifest.stages[stageName]?.completedAt) {
manifest.stages[stageName] = {
status: 'failed',
startedAt: manifest.stages[stageName]?.startedAt,
completedAt: nowISO(),
};
}
manifest.status = 'failed';
saveManifest(runDir, manifest);
throw error;
}
}
manifest.status = 'completed';
saveManifest(runDir, manifest); saveManifest(runDir, manifest);
return { return {
-32
View File
@@ -1,32 +0,0 @@
import type { ForgeTask, ForgeTaskResult, TaskExecutor } from './types.js';
/**
* Simulated executor used ONLY when the caller explicitly passes --simulate.
*
* It submits no real work and returns typed `simulated` results so a simulated
* run can never be confused with a verified one. In normal mode (no --simulate)
* the CLI refuses to run at all with FORGE_NO_EXECUTOR instead of wiring this
* stub (fail closed, SDLC-D-035).
*/
export function createSimulatedExecutor(options?: { log?: boolean }): TaskExecutor {
const log = options?.log ?? true;
return {
async submitTask(task: ForgeTask) {
if (log) console.log(` [forge:simulated] stage submitted: ${task.id} (${task.title})`);
},
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
if (log) console.log(` [forge:simulated] stage complete: ${taskId}`);
return {
task_id: taskId,
outcome: 'simulated',
reason: 'no executor wired; simulated execution requested via --simulate',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
};
},
async getTaskStatus() {
return 'completed' as const;
},
};
}
+7 -88
View File
@@ -1,4 +1,4 @@
import type { GateEntry } from '@mosaicstack/macp'; import type { GateEntry, TaskResult } from '@mosaicstack/macp';
/** Stage dispatch mode. */ /** Stage dispatch mode. */
export type StageDispatch = 'exec' | 'yolo' | 'pi'; export type StageDispatch = 'exec' | 'yolo' | 'pi';
@@ -6,58 +6,6 @@ export type StageDispatch = 'exec' | 'yolo' | 'pi';
/** Stage type — determines agent selection and gate requirements. */ /** Stage type — determines agent selection and gate requirements. */
export type StageType = 'research' | 'review' | 'coding' | 'deploy'; export type StageType = 'research' | 'review' | 'coding' | 'deploy';
/**
* Typed outcome for every gate and stage evaluation closed set (SDLC-D-035).
*
* Only `passed` means "verified by a real implementation". `simulated` is
* produced exclusively in explicit `--simulate` runs and is never satisfying.
*/
export type ForgeOutcome =
| 'passed'
| 'failed'
| 'blocked'
| 'error'
| 'waiting-for-authority'
| 'simulated'
| 'not-applicable';
/** A gate that requires authority (human/board) sign-off; no mechanical command can satisfy it. */
export interface AuthorityGate {
kind: 'authority';
capability: string;
reason: string;
}
/** A gate that requires a wired provider (e.g. an AI reviewer, CI pipeline) to evaluate. */
export interface ProviderGate {
kind: 'provider';
capability: string;
reason: string;
}
/** Forge quality gate: a real command, an authority sign-off, or a provider-backed check. */
export type ForgeGate = string | GateEntry | AuthorityGate | ProviderGate;
/** Typed result of evaluating a single quality gate. */
export interface ForgeGateResult {
gate: string;
outcome: ForgeOutcome;
reason: string;
exitCode?: number;
output?: string;
timedOut?: boolean;
}
/** Typed result of a task/stage execution returned by a TaskExecutor. */
export interface ForgeTaskResult {
task_id: string;
outcome: ForgeOutcome;
reason: string;
completed_at: string;
exit_code: number;
gate_results: ForgeGateResult[];
}
/** Stage specification — defines a single pipeline stage. */ /** Stage specification — defines a single pipeline stage. */
export interface StageSpec { export interface StageSpec {
number: string; number: string;
@@ -66,7 +14,7 @@ export interface StageSpec {
type: StageType; type: StageType;
gate: string; gate: string;
promptFile: string; promptFile: string;
qualityGates: ForgeGate[]; qualityGates: (string | GateEntry)[];
} }
/** Brief classification. */ /** Brief classification. */
@@ -77,18 +25,11 @@ export type ClassSource = 'cli' | 'frontmatter' | 'auto';
/** Per-stage status within a run manifest. */ /** Per-stage status within a run manifest. */
export interface StageStatus { export interface StageStatus {
status: 'pending' | 'in_progress' | ForgeOutcome; status: 'pending' | 'in_progress' | 'passed' | 'failed';
/** Why the stage reached its current (terminal) outcome, when applicable. */
reason?: string;
startedAt?: string; startedAt?: string;
completedAt?: string; completedAt?: string;
/** Typed per-gate results recorded alongside the stage outcome. */
gateResults?: ForgeGateResult[];
} }
/** Execution mode of a run. */
export type RunMode = 'normal' | 'simulated';
/** Run manifest — persisted to disk as manifest.json. */ /** Run manifest — persisted to disk as manifest.json. */
export interface RunManifest { export interface RunManifest {
runId: string; runId: string;
@@ -97,23 +38,10 @@ export interface RunManifest {
briefClass: BriefClass; briefClass: BriefClass;
classSource: ClassSource; classSource: ClassSource;
forceBoard: boolean; forceBoard: boolean;
/**
* Execution mode. `simulated` runs stub execution; their results are typed
* `simulated` and must never be read as verified success. Optional because
* manifests written before this field existed default to `normal`.
*/
mode?: RunMode;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
currentStage: string; currentStage: string;
status: status: 'in_progress' | 'completed' | 'failed' | 'interrupted' | 'rejected';
| 'in_progress'
| 'completed'
| 'failed'
| 'interrupted'
| 'rejected'
| 'simulated'
| 'waiting-for-authority';
stages: Record<string, StageStatus>; stages: Record<string, StageStatus>;
} }
@@ -137,7 +65,7 @@ export interface ForgeTask {
briefPath: string; briefPath: string;
resultPath: string; resultPath: string;
timeoutSeconds: number; timeoutSeconds: number;
qualityGates: ForgeGate[]; qualityGates: (string | GateEntry)[];
worktree?: string; worktree?: string;
command?: string; command?: string;
dependsOn?: string[]; dependsOn?: string[];
@@ -148,7 +76,7 @@ export interface ForgeTask {
/** Abstract task executor — decouples from packages/coord. */ /** Abstract task executor — decouples from packages/coord. */
export interface TaskExecutor { export interface TaskExecutor {
submitTask(task: ForgeTask): Promise<void>; submitTask(task: ForgeTask): Promise<void>;
waitForCompletion(taskId: string, timeoutMs: number): Promise<ForgeTaskResult>; waitForCompletion(taskId: string, timeoutMs: number): Promise<TaskResult>;
getTaskStatus(taskId: string): Promise<ForgeTaskStatus>; getTaskStatus(taskId: string): Promise<ForgeTaskStatus>;
} }
@@ -194,16 +122,7 @@ export interface PipelineOptions {
stages?: string[]; stages?: string[];
skipTo?: string; skipTo?: string;
dryRun?: boolean; dryRun?: boolean;
/** executor: TaskExecutor;
* Real task executor. Required in normal mode: the pipeline fails closed
* with FORGE_NO_EXECUTOR when it is absent.
*/
executor?: TaskExecutor;
/**
* Explicit opt-in to simulated execution. Every stage and gate result is
* typed `simulated` and is never satisfying.
*/
simulate?: boolean;
} }
/** Pipeline run result. */ /** Pipeline run result. */
+1 -1
View File
@@ -102,7 +102,7 @@ mosaic yolo pi # Launch Pi in yolo mode
The launcher: The launcher:
1. Verifies `~/.config/mosaic` exists 1. Verifies `~/.config/mosaic` exists
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing) 2. Resolves identity: standalone launches auto-run `mosaic init` when `SOUL.md` is missing; exact roster-owned fleet launches validate name/class, atomically seed only missing `SOUL.md`/`USER.md` from generic `defaults/`, securely consume `USER.md`, and never prompt
3. Injects `AGENTS.md` into the runtime 3. Injects `AGENTS.md` into the runtime
4. Forwards all arguments to the runtime CLI 4. Forwards all arguments to the runtime CLI
+2 -1
View File
@@ -24,7 +24,8 @@
"build": "tsc", "build": "tsc",
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests && pnpm run test:framework-shell", "test": "pnpm run test:vitest && pnpm run test:framework-shell",
"test:vitest": "pnpm run build && vitest run --passWithNoTests",
"test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh" "test:framework-shell": "bash framework/tools/quality/scripts/check-test-enumeration.sh && bash framework/tools/quality/scripts/test-check-test-enumeration.sh && python3 src/lease-broker/daemon_deadline_unittest.py && python3 src/lease-broker/normative_fragments_unittest.py && python3 src/lease-broker/promotion_binding_unittest.py && python3 src/lease-broker/promotion_trigger_unittest.py && python3 src/lease-broker/receipt_challenge_unittest.py && python3 src/lease-broker/context_recovery_unittest.py && python3 src/lease-broker/recovery_runtime_unittest.py && python3 src/lease-broker/recovery_b1_adversarial_unittest.py && python3 src/lease-broker/receipt_observer_client_unittest.py && python3 src/lease-broker/invariant_r_unittest.py && python3 src/lease-broker/framework_skill_portability_unittest.py && python3 src/mutator-gate/runtime_tools_unittest.py && python3 src/mutator-gate/runtime_launch_guard_unittest.py && python3 src/mutator-gate/version_coupling_unittest.py && python3 framework/tools/lease-broker/check-runtime-launches.py --root ../.. && bash framework/tools/codex/test-pr-diff-context.sh && bash framework/tools/qa/test-deps-preflight.sh && bash framework/tools/git/test-pr-review-gitea-comment.sh && bash framework/tools/git/test-pr-review-repo-host-override.sh && bash framework/tools/git/test-ci-queue-wait-branch-absent.sh && bash framework/tools/git/test-ci-queue-wait-tristate.sh && bash framework/tools/git/test-ci-queue-wait-github-checks.sh && bash framework/tools/git/test-pr-merge-queue-branch.sh && bash framework/tools/git/test-pr-merge-head-pin.sh && bash framework/tools/git/test-pr-merge-message-field.sh && bash framework/tools/git/test-git-credential-mosaic.sh && bash framework/tools/git/test-gitea-token-identity.sh && bash framework/tools/woodpecker/test-terminal-green-contract.sh && bash framework/tools/_scripts/test-install-ordering-guard.sh && bash framework/tools/_scripts/test-mosaic-init-rce.sh && bash framework/tools/tmux/agent-send.test.sh && bash framework/tools/wake/test-wake-store-ack.sh && bash framework/tools/wake/test-wake-store-enqueue-race.sh && bash framework/tools/wake/test-wake-digest-hmac.sh && bash framework/tools/wake/test-wake-digest-quarantine.sh && bash framework/tools/wake/test-wake-detector.sh && bash framework/tools/wake/test-wake-fn-oracle.sh && bash framework/tools/wake/test-wake-reconcile.sh && bash framework/tools/wake/test-wake-beacon.sh && bash framework/tools/wake/test-wake-preimage.sh && bash framework/tools/wake/test-wake-install.sh && bash framework/tools/fleet/test-start-agent-session.sh && bash framework/tools/glpi/test-list-http-status.sh && bash framework/tools/orchestrator/test-board-roll.sh && bash framework/tools/woodpecker/test-ci-wait-exit-matrix.sh && bash framework/tools/_scripts/test-fleet-transport-check.sh"
}, },
"dependencies": { "dependencies": {
@@ -15,6 +15,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { FileConfigAdapter } from '../config/file-adapter.js'; import { FileConfigAdapter } from '../config/file-adapter.js';
import { seedFleetIdentityDefaults } from './fleet-first-start-identity.js';
import { composeContract } from './launch.js'; import { composeContract } from './launch.js';
/** /**
@@ -319,6 +320,7 @@ describe('composeContract — overlay composer', () => {
].join('\n'), ].join('\n'),
); );
process.env['MOSAIC_AGENT_NAME'] = 'exact-self'; process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
expect(seedFleetIdentityDefaults(installedHome)).toEqual(['SOUL.md', 'USER.md']);
const composed = composeContract('pi', installedHome); const composed = composeContract('pi', installedHome);
expect(composed).toContain(sourceTools); expect(composed).toContain(sourceTools);
@@ -332,6 +334,49 @@ describe('composeContract — overlay composer', () => {
} }
}); });
it('refuses a fleet USER.md replacement symlink at the point of composition', () => {
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'version: 1',
'transport: tmux',
'agents:',
' - name: exact-user-seat',
' runtime: pi',
' class: worker',
'',
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-user-seat';
process.env['MOSAIC_AGENT_CLASS'] = 'worker';
writeFileSync(join(fixture.home, 'defaults', 'SOUL.md'), '# Generic soul\n');
writeFileSync(join(fixture.home, 'defaults', 'USER.md'), '# Generic user\n');
expect(seedFleetIdentityDefaults(fixture.home)).toEqual(['SOUL.md']);
const userPath = join(fixture.home, 'USER.md');
const external = join(fixture.root, 'attacker-user.md');
writeFileSync(external, 'UNSAFE-REPLACEMENT-USER-CONTENT\n');
rmSync(userPath);
symlinkSync(external, userPath);
expect(() => composeContract('pi', fixture.home)).toThrow(
`fleet identity installed is unavailable or unsafe: ${userPath}`,
);
expect(readFileSync(external, 'utf8')).toBe('UNSAFE-REPLACEMENT-USER-CONTENT\n');
});
it('preserves tolerant standalone composition when optional USER.md is unreadable', () => {
const userPath = join(fixture.home, 'USER.md');
rmSync(userPath);
mkdirSync(userPath);
const out = composeContract('pi', fixture.home);
expect(out).toContain(AGENTS);
expect(out).not.toContain('# User Profile');
});
it.each(['claude', 'codex', 'opencode', 'pi'] as const)( it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
'never injects installed TOOLS.md through a target symlink for %s', 'never injects installed TOOLS.md through a target symlink for %s',
(runtime) => { (runtime) => {
@@ -0,0 +1,182 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
lstatSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
linkIdentityContractNoClobber,
seedFleetIdentityDefaults,
} from './fleet-first-start-identity.js';
const roots: string[] = [];
const DEFAULT_SOUL = '# Generic soul\n';
const DEFAULT_USER = '# Generic user\n';
function writeFixture(path: string, content: string | Buffer, mode: number = 0o600): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, content, { mode });
chmodSync(path, mode);
}
function createMosaicHome(): string {
const root = mkdtempSync(join(tmpdir(), 'mosaic-identity-seed-'));
roots.push(root);
const mosaicHome = join(root, 'home', '.config', 'mosaic');
writeFixture(join(mosaicHome, 'defaults', 'SOUL.md'), DEFAULT_SOUL);
writeFixture(join(mosaicHome, 'defaults', 'USER.md'), DEFAULT_USER);
return mosaicHome;
}
function temporarySeeds(mosaicHome: string): string[] {
return readdirSync(mosaicHome).filter((entry) => entry.includes('.fleet-seed-'));
}
afterEach((): void => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
describe('linkIdentityContractNoClobber', () => {
it('returns false and preserves a destination that already exists', () => {
const mosaicHome = createMosaicHome();
const source = join(mosaicHome, 'source.tmp');
const destination = join(mosaicHome, 'destination.md');
writeFixture(source, 'candidate\n');
writeFixture(destination, 'operator\n');
expect(linkIdentityContractNoClobber(source, destination)).toBe(false);
expect(readFileSync(destination, 'utf8')).toBe('operator\n');
});
it('does not misclassify an unexpected link failure as a concurrent winner', () => {
const mosaicHome = createMosaicHome();
const missingSource = join(mosaicHome, 'missing.tmp');
expect(() =>
linkIdentityContractNoClobber(missingSource, join(mosaicHome, 'destination.md')),
).toThrow();
});
});
describe('seedFleetIdentityDefaults', () => {
it('publishes complete owner-private default snapshots', () => {
const mosaicHome = createMosaicHome();
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual(['SOUL.md', 'USER.md']);
for (const [entry, expected] of [
['SOUL.md', DEFAULT_SOUL],
['USER.md', DEFAULT_USER],
] as const) {
const path = join(mosaicHome, entry);
expect(readFileSync(path, 'utf8')).toBe(expected);
expect(statSync(path).mode & 0o777).toBe(0o600);
}
expect(temporarySeeds(mosaicHome)).toEqual([]);
});
it('preserves an existing regular contract byte-for-byte and mode-for-mode', () => {
const mosaicHome = createMosaicHome();
const customSoul = '# Operator-owned soul\n';
writeFixture(join(mosaicHome, 'SOUL.md'), customSoul, 0o640);
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual(['USER.md']);
expect(readFileSync(join(mosaicHome, 'SOUL.md'), 'utf8')).toBe(customSoul);
expect(statSync(join(mosaicHome, 'SOUL.md')).mode & 0o777).toBe(0o640);
});
it('is idempotent after both installed contracts exist', () => {
const mosaicHome = createMosaicHome();
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual(['SOUL.md', 'USER.md']);
expect(seedFleetIdentityDefaults(mosaicHome)).toEqual([]);
expect(temporarySeeds(mosaicHome)).toEqual([]);
});
it('validates every required source before publishing any destination', () => {
const mosaicHome = createMosaicHome();
const missing = join(mosaicHome, 'defaults', 'USER.md');
rmSync(missing);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity default is unavailable or unsafe: ${missing}`,
);
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
});
it('fails closed when the configured Mosaic home is not a directory', () => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-identity-invalid-home-'));
roots.push(root);
const mosaicHome = join(root, 'mosaic-home');
writeFixture(mosaicHome, 'not a directory\n');
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity installed is unavailable or unsafe: ${join(mosaicHome, 'SOUL.md')}`,
);
});
it('refuses a symlinked default instead of following it', () => {
const mosaicHome = createMosaicHome();
const source = join(mosaicHome, 'defaults', 'SOUL.md');
rmSync(source);
symlinkSync(join(mosaicHome, 'defaults', 'USER.md'), source);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity default is unavailable or unsafe: ${source}`,
);
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(false);
});
it('refuses an existing symlinked destination without replacing it', () => {
const mosaicHome = createMosaicHome();
const destination = join(mosaicHome, 'SOUL.md');
symlinkSync(join(mosaicHome, 'defaults', 'SOUL.md'), destination);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity installed is unavailable or unsafe: ${destination}`,
);
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
});
it.each(['SOUL.md', 'USER.md'] as const)(
'rejects a dangling %s destination before publishing its counterpart',
(entry) => {
const mosaicHome = createMosaicHome();
const destination = join(mosaicHome, entry);
const counterpart = join(mosaicHome, entry === 'SOUL.md' ? 'USER.md' : 'SOUL.md');
symlinkSync(join(mosaicHome, 'missing-identity-target'), destination);
expect(existsSync(destination)).toBe(false);
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity installed is unavailable or unsafe: ${destination}`,
);
expect(lstatSync(destination).isSymbolicLink()).toBe(true);
expect(existsSync(counterpart)).toBe(false);
},
);
it('rejects an oversized source before publishing a partial identity', () => {
const mosaicHome = createMosaicHome();
const source = join(mosaicHome, 'defaults', 'USER.md');
writeFixture(source, Buffer.alloc(256 * 1024 + 1, 0x61));
expect(() => seedFleetIdentityDefaults(mosaicHome)).toThrow(
`fleet identity default is unavailable or unsafe: ${source}`,
);
expect(existsSync(join(mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(mosaicHome, 'USER.md'))).toBe(false);
});
});
@@ -0,0 +1,120 @@
import { randomBytes } from 'node:crypto';
import { linkSync, lstatSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { readRegularFileSecure } from '../fleet/secure-file.js';
const MAX_IDENTITY_CONTRACT_BYTES = 256 * 1024;
export const FLEET_IDENTITY_DEFAULTS = ['SOUL.md', 'USER.md'] as const;
function isFilesystemError(error: unknown, code: string): boolean {
return error instanceof Error && 'code' in error && error.code === code;
}
/** @internal Publish a complete temporary file without replacing any path. */
export function linkIdentityContractNoClobber(source: string, destination: string): boolean {
try {
linkSync(source, destination);
return true;
} catch (error: unknown) {
if (isFilesystemError(error, 'EEXIST')) return false;
throw error;
}
}
function unsafeIdentityError(kind: 'default' | 'installed', path: string, error: unknown): Error {
const reason = error instanceof Error ? error.message : String(error);
return new Error(`fleet identity ${kind} is unavailable or unsafe: ${path} (${reason})`);
}
function readIdentityContract(
mosaicHome: string,
path: string,
kind: 'default' | 'installed',
): Buffer {
try {
return readRegularFileSecure(path, {
root: mosaicHome,
maxBytes: MAX_IDENTITY_CONTRACT_BYTES,
}).content;
} catch (error: unknown) {
throw unsafeIdentityError(kind, path, error);
}
}
function installedEntryExists(path: string): boolean {
try {
lstatSync(path);
return true;
} catch (error: unknown) {
if (isFilesystemError(error, 'ENOENT')) return false;
throw unsafeIdentityError('installed', path, error);
}
}
/** Secure fleet point-of-use read for a top-level identity contract. */
export function readInstalledIdentityContractAtPointOfUse(
mosaicHome: string,
entry: (typeof FLEET_IDENTITY_DEFAULTS)[number],
): Buffer {
const configuredPath = join(mosaicHome, entry);
try {
// Preserve the launcher's established support for a symlinked Mosaic home,
// while pinning this read to the resolved directory. O_NOFOLLOW still
// rejects replacement of the identity file itself (or any child ancestor).
const canonicalHome = realpathSync(mosaicHome);
return readRegularFileSecure(join(canonicalHome, entry), {
root: canonicalHome,
maxBytes: MAX_IDENTITY_CONTRACT_BYTES,
}).content;
} catch (error: unknown) {
throw unsafeIdentityError('installed', configuredPath, error);
}
}
/**
* Seed the generic identity base required by unattended fleet launches.
*
* Exact seat identity remains roster-owned and is injected later by the
* runtime composer. Each destination appears atomically through a hard link to
* a complete owner-private temporary file; a concurrent first seat may win the
* link without allowing either process to overwrite operator content.
*/
export function seedFleetIdentityDefaults(mosaicHome: string): string[] {
const snapshots = new Map<(typeof FLEET_IDENTITY_DEFAULTS)[number], Buffer>();
for (const entry of FLEET_IDENTITY_DEFAULTS) {
const destination = join(mosaicHome, entry);
if (installedEntryExists(destination)) {
readIdentityContract(mosaicHome, destination, 'installed');
continue;
}
const source = join(mosaicHome, 'defaults', entry);
snapshots.set(entry, readIdentityContract(mosaicHome, source, 'default'));
}
const seeded: string[] = [];
for (const [entry, content] of snapshots) {
const destination = join(mosaicHome, entry);
const temporary = join(
mosaicHome,
`.${entry}.fleet-seed-${process.pid.toString()}-${randomBytes(6).toString('hex')}`,
);
let temporaryCreated = false;
try {
writeFileSync(temporary, content, { flag: 'wx', mode: 0o600 });
temporaryCreated = true;
if (linkIdentityContractNoClobber(temporary, destination)) {
seeded.push(entry);
} else {
readIdentityContract(mosaicHome, destination, 'installed');
}
} finally {
if (temporaryCreated) rmSync(temporary, { force: true });
}
}
for (const entry of FLEET_IDENTITY_DEFAULTS) {
readIdentityContract(mosaicHome, join(mosaicHome, entry), 'installed');
}
return seeded;
}
@@ -0,0 +1,393 @@
import { spawn, spawnSync, type SpawnSyncReturns } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
const CLI_PATH = fileURLToPath(new URL('../../dist/cli.js', import.meta.url));
const DEFAULT_SOUL_PATH = fileURLToPath(
new URL('../../framework/defaults/SOUL.md', import.meta.url),
);
const DEFAULT_USER_PATH = fileURLToPath(
new URL('../../framework/defaults/USER.md', import.meta.url),
);
interface GreenfieldFixture {
readonly root: string;
readonly home: string;
readonly mosaicHome: string;
readonly binDir: string;
readonly capturePath: string;
}
interface AsyncLaunchResult {
readonly status: number | null;
readonly signal: NodeJS.Signals | null;
readonly stdout: string;
readonly stderr: string;
}
const fixtures: string[] = [];
function writeFixture(path: string, content: string, mode: number = 0o600): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, content, { encoding: 'utf8', mode });
chmodSync(path, mode);
}
function createGreenfieldFixture(): GreenfieldFixture {
const root = mkdtempSync(join(tmpdir(), 'mosaic-first-start-'));
fixtures.push(root);
const home = join(root, 'home');
const mosaicHome = join(home, '.config', 'mosaic');
const binDir = join(root, 'bin');
const capturePath = join(root, 'runtime-boundary.json');
mkdirSync(binDir, { recursive: true, mode: 0o700 });
writeFixture(join(mosaicHome, 'AGENTS.md'), '# Agent dispatcher\n');
writeFixture(join(mosaicHome, 'runtime', 'pi', 'RUNTIME.md'), '# Pi runtime\n');
writeFixture(join(mosaicHome, 'defaults', 'SOUL.md'), readFileSync(DEFAULT_SOUL_PATH, 'utf8'));
writeFixture(join(mosaicHome, 'defaults', 'USER.md'), readFileSync(DEFAULT_USER_PATH, 'utf8'));
writeFixture(
join(mosaicHome, 'fleet', 'roster.yaml'),
`version: 1
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~
runtimes:
pi:
reset_command: /new
agents:
- name: unattended-seat
runtime: pi
class: worker
`,
);
writeFixture(
join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'),
'#!/usr/bin/env bash\nexit 0\n',
0o755,
);
writeFixture(
join(mosaicHome, 'tools', 'lease-broker', 'launch-runtime.py'),
`#!/usr/bin/env python3
import json
import os
import pathlib
import sys
pathlib.Path(os.environ["MOSAIC_TEST_RUNTIME_CAPTURE"]).write_text(
json.dumps({"argv": sys.argv[1:]}), encoding="utf-8"
)
`,
0o755,
);
// checkRuntime() must find Pi, while the fake broker boundary prevents this
// executable from running or making a provider call.
writeFixture(join(binDir, 'pi'), '#!/usr/bin/env bash\nexit 97\n', 0o755);
return { root, home, mosaicHome, binDir, capturePath };
}
function launchEnvironment(
fixture: GreenfieldFixture,
capturePath: string,
fleet: boolean = true,
agentName: string = 'unattended-seat',
agentClass: string = 'worker',
): NodeJS.ProcessEnv {
return {
HOME: fixture.home,
MOSAIC_HOME: fixture.mosaicHome,
...(fleet
? {
MOSAIC_AGENT_NAME: agentName,
MOSAIC_AGENT_CLASS: agentClass,
}
: {}),
MOSAIC_TEST_RUNTIME_CAPTURE: capturePath,
PATH: `${fixture.binDir}:/usr/bin:/bin`,
};
}
function launchSync(
fixture: GreenfieldFixture,
options: {
readonly capturePath?: string;
readonly fleet?: boolean;
readonly agentName?: string;
readonly agentClass?: string;
} = {},
): SpawnSyncReturns<string> {
const capturePath = options.capturePath ?? fixture.capturePath;
return spawnSync(process.execPath, [CLI_PATH, 'yolo', 'pi'], {
cwd: fixture.root,
encoding: 'utf8',
input: '',
timeout: 10_000,
env: launchEnvironment(
fixture,
capturePath,
options.fleet ?? true,
options.agentName ?? 'unattended-seat',
options.agentClass ?? 'worker',
),
});
}
function launchAsync(fixture: GreenfieldFixture, capturePath: string): Promise<AsyncLaunchResult> {
return new Promise<AsyncLaunchResult>((resolve, reject): void => {
const child = spawn(process.execPath, [CLI_PATH, 'yolo', 'pi'], {
cwd: fixture.root,
env: launchEnvironment(fixture, capturePath),
stdio: ['pipe', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk: string): void => {
stdout += chunk;
});
child.stderr.on('data', (chunk: string): void => {
stderr += chunk;
});
child.on('error', reject);
child.on('close', (status: number | null, signal: NodeJS.Signals | null): void => {
resolve({ status, signal, stdout, stderr });
});
child.stdin.end();
});
}
function outputOf(result: { readonly stdout: string; readonly stderr: string }): string {
return `${result.stdout}${result.stderr}`;
}
function assertPrivateDefaultSeeds(fixture: GreenfieldFixture): void {
const soul = join(fixture.mosaicHome, 'SOUL.md');
const user = join(fixture.mosaicHome, 'USER.md');
expect(readFileSync(soul, 'utf8')).toBe(readFileSync(DEFAULT_SOUL_PATH, 'utf8'));
expect(readFileSync(user, 'utf8')).toBe(readFileSync(DEFAULT_USER_PATH, 'utf8'));
expect(statSync(soul).mode & 0o777).toBe(0o600);
expect(statSync(user).mode & 0o777).toBe(0o600);
}
function capturedArguments(path: string): string[] {
const capture = JSON.parse(readFileSync(path, 'utf8')) as { argv: string[] };
return capture.argv;
}
afterEach((): void => {
for (const root of fixtures.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
describe('fleet unattended first start (#1264)', () => {
it('reaches the runtime boundary without a TTY or identity wizard on a clean install', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture);
const output = outputOf(result);
expect(result.error, output).toBeUndefined();
expect(result.status, output).toBe(0);
expect(output).toContain('Initialized unattended fleet identity defaults: SOUL.md, USER.md');
expect(output).not.toContain('Running setup wizard');
expect(output).not.toContain('What would you like to do?');
expect(existsSync(fixture.capturePath), output).toBe(true);
assertPrivateDefaultSeeds(fixture);
const argv = capturedArguments(fixture.capturePath);
expect(argv).toContain('--runtime');
expect(argv.join('\n')).toContain('Agent/session: `unattended-seat`');
expect(argv.join('\n')).toContain('Role/class: `worker`');
});
it('preserves existing operator identity bytes without requiring defaults', () => {
const fixture = createGreenfieldFixture();
const customSoul = '# Operator soul\nNever replace this.\n';
const customUser = '# Operator user\nNever replace this either.\n';
writeFixture(join(fixture.mosaicHome, 'SOUL.md'), customSoul, 0o640);
writeFixture(join(fixture.mosaicHome, 'USER.md'), customUser, 0o600);
rmSync(join(fixture.mosaicHome, 'defaults'), { recursive: true, force: true });
const first = launchSync(fixture);
const secondCapture = join(fixture.root, 'runtime-boundary-second.json');
const second = launchSync(fixture, { capturePath: secondCapture });
expect(first.status, outputOf(first)).toBe(0);
expect(second.status, outputOf(second)).toBe(0);
expect(readFileSync(join(fixture.mosaicHome, 'SOUL.md'), 'utf8')).toBe(customSoul);
expect(readFileSync(join(fixture.mosaicHome, 'USER.md'), 'utf8')).toBe(customUser);
expect(statSync(join(fixture.mosaicHome, 'SOUL.md')).mode & 0o777).toBe(0o640);
expect(existsSync(fixture.capturePath)).toBe(true);
expect(existsSync(secondCapture)).toBe(true);
});
it('seeds only the missing identity contract and leaves a custom SOUL byte-exact', () => {
const fixture = createGreenfieldFixture();
const customSoul = '# Exact custom soul bytes\n';
writeFixture(join(fixture.mosaicHome, 'SOUL.md'), customSoul, 0o640);
const result = launchSync(fixture);
expect(result.status, outputOf(result)).toBe(0);
expect(outputOf(result)).toContain('Initialized unattended fleet identity defaults: USER.md');
expect(readFileSync(join(fixture.mosaicHome, 'SOUL.md'), 'utf8')).toBe(customSoul);
expect(statSync(join(fixture.mosaicHome, 'SOUL.md')).mode & 0o777).toBe(0o640);
expect(readFileSync(join(fixture.mosaicHome, 'USER.md'), 'utf8')).toBe(
readFileSync(DEFAULT_USER_PATH, 'utf8'),
);
});
it('fails closed without a wizard or partial seed when a required default is missing', () => {
const fixture = createGreenfieldFixture();
const missingDefault = join(fixture.mosaicHome, 'defaults', 'USER.md');
rmSync(missingDefault);
const result = launchSync(fixture);
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('unattended fleet identity initialization failed');
expect(output).toContain(missingDefault);
expect(output).not.toContain('Running setup wizard');
expect(output).not.toContain('What would you like to do?');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
});
it('rejects a symlinked identity default without following it or prompting', () => {
const fixture = createGreenfieldFixture();
const soulDefault = join(fixture.mosaicHome, 'defaults', 'SOUL.md');
rmSync(soulDefault);
symlinkSync(DEFAULT_SOUL_PATH, soulDefault);
const result = launchSync(fixture);
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain(`fleet identity default is unavailable or unsafe: ${soulDefault}`);
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
});
it('refuses an unknown ambient fleet name before seeding or prompting', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentName: 'not-in-the-roster' });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('canonical fleet identity is unavailable');
expect(output).toContain('Agent "not-in-the-roster" is not in the fleet roster');
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
});
it('refuses a mismatched ambient fleet class before seeding or prompting', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentClass: 'reviewer' });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('Refusing split identity authority');
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
});
it.each(['', ' ', '\t'])(
'refuses explicit blank ambient fleet class %j before seeding',
(agentClass: string) => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentClass });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('Refusing split identity authority');
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
},
);
it.each([' unattended-seat', 'unattended-seat ', ''])(
'refuses non-exact ambient fleet name %j before seeding',
(agentName: string) => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { agentName });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain(
'MOSAIC_AGENT_NAME must be a non-empty exact roster name with no surrounding whitespace',
);
expect(output).not.toContain('Running setup wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
},
);
it('keeps the interactive wizard path for a standalone launch', () => {
const fixture = createGreenfieldFixture();
const result = launchSync(fixture, { fleet: false });
const output = outputOf(result);
expect(result.status, output).toBe(1);
expect(output).toContain('[mosaic] SOUL.md not found. Running setup wizard...');
expect(output).toContain('What would you like to do?');
expect(output).toContain('[mosaic] Setup failed. Run: mosaic wizard');
expect(existsSync(fixture.capturePath)).toBe(false);
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
});
it('allows concurrent no-TTY seats to initialize the same defaults without clobber or residue', async () => {
const fixture = createGreenfieldFixture();
const captures = Array.from({ length: 4 }, (_, index) =>
join(fixture.root, `runtime-boundary-${index.toString()}.json`),
);
const results = await Promise.all(
captures.map(
async (capturePath): Promise<AsyncLaunchResult> => launchAsync(fixture, capturePath),
),
);
for (const result of results) {
expect(result.status, outputOf(result)).toBe(0);
expect(result.signal, outputOf(result)).toBeNull();
expect(outputOf(result)).not.toContain('Running setup wizard');
}
assertPrivateDefaultSeeds(fixture);
expect(captures.every((capturePath) => existsSync(capturePath))).toBe(true);
expect(
readdirSync(fixture.mosaicHome).filter((entry) => entry.includes('.fleet-seed-')),
).toEqual([]);
});
});
+62 -10
View File
@@ -30,6 +30,10 @@ import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js'; import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js'; import { canonicalizeRoleClass } from './fleet-personas.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js'; import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
import {
readInstalledIdentityContractAtPointOfUse,
seedFleetIdentityDefaults,
} from './fleet-first-start-identity.js';
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js'; import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -230,8 +234,55 @@ function checkRuntime(cmd: string): void {
} }
} }
function assertAmbientFleetClassMatches(canonicalName: string, canonicalClass: string): void {
const configuredClass = process.env['MOSAIC_AGENT_CLASS'];
if (configuredClass === undefined) return;
const ambientClass = canonicalizeRoleClass(configuredClass).canonicalClass;
if (ambientClass !== canonicalClass) {
throw new Error(
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalName}" resolves to "${canonicalClass}". Refusing split identity authority.`,
);
}
}
function checkSoul(): void { function checkSoul(): void {
const soulPath = join(MOSAIC_HOME, 'SOUL.md'); const soulPath = join(MOSAIC_HOME, 'SOUL.md');
const fleetAgentName = process.env['MOSAIC_AGENT_NAME'];
if (fleetAgentName !== undefined) {
try {
if (fleetAgentName.length === 0 || fleetAgentName !== fleetAgentName.trim()) {
throw new Error(
'MOSAIC_AGENT_NAME must be a non-empty exact roster name with no surrounding whitespace',
);
}
const fleetIdentity = resolveFleetIdentity(MOSAIC_HOME, fleetAgentName);
if (!fleetIdentity.ok || !fleetIdentity.identity) {
throw new Error(
`canonical fleet identity is unavailable: ${fleetIdentity.error ?? 'exact roster member was not resolved'}`,
);
}
assertAmbientFleetClassMatches(
fleetIdentity.identity.member.name,
fleetIdentity.identity.member.className,
);
const seeded = seedFleetIdentityDefaults(MOSAIC_HOME);
if (seeded.length > 0) {
console.log(
`[mosaic] Initialized unattended fleet identity defaults: ${seeded.join(', ')}. Exact seat identity remains roster-owned.`,
);
}
return;
} catch (error: unknown) {
const reason = error instanceof Error ? error.message : String(error);
console.error(`[mosaic] ERROR: unattended fleet identity initialization failed: ${reason}`);
console.error(
'[mosaic] Repair the named fleet roster, launch identity, installed contract, or shipped default, then retry.',
);
process.exit(1);
}
}
if (!existsSync(soulPath)) { if (!existsSync(soulPath)) {
console.log('[mosaic] SOUL.md not found. Running setup wizard...'); console.log('[mosaic] SOUL.md not found. Running setup wizard...');
@@ -532,26 +583,27 @@ For required push/merge/issue-close/release actions, execute without routine con
parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8')); parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));
// USER.md (+ USER.local.md operator overlay, appended directly under the // USER.md (+ USER.local.md operator overlay, appended directly under the
// profile its base owns). // profile its base owns). Fleet first start is Linux/systemd-owned and uses
const user = readOptional(join(mosaicHome, 'USER.md')); // the no-follow reader at point of use. Standalone launches retain the
// portable tolerant path used on macOS and other supported hosts.
const fleetAgentName = process.env['MOSAIC_AGENT_NAME'];
const user =
fleetAgentName === undefined
? readOptional(join(mosaicHome, 'USER.md'))
: readInstalledIdentityContractAtPointOfUse(mosaicHome, 'USER.md').toString('utf8');
if (user) parts.push('\n\n# User Profile\n\n' + user); if (user) parts.push('\n\n# User Profile\n\n' + user);
const userLocal = readOptional(join(mosaicHome, 'USER.local.md')); const userLocal = readOptional(join(mosaicHome, 'USER.local.md'));
if (userLocal.trim()) { if (userLocal.trim()) {
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal); parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
} }
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']); const fleetIdentity = resolveFleetIdentity(mosaicHome, fleetAgentName);
if (!fleetIdentity.ok) { if (!fleetIdentity.ok) {
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`); throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
} }
const canonicalMember = fleetIdentity.identity?.member; const canonicalMember = fleetIdentity.identity?.member;
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) { if (canonicalMember) {
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass; assertAmbientFleetClassMatches(canonicalMember.name, canonicalMember.className);
if (ambientClass !== canonicalMember.className) {
throw new Error(
`Ambient MOSAIC_AGENT_CLASS resolves to "${ambientClass}" but canonical roster member "${canonicalMember.name}" resolves to "${canonicalMember.className}". Refusing split identity authority.`,
);
}
} }
// TOOLS.md // TOOLS.md