Compare commits

..
Author SHA1 Message Date
fargo 68279d61a1 feat(verify-release): wire quality-rails evaluator stage into canonical verification (#1275)
ci/woodpecker/pr/ci Pipeline was successful
- new canonical-only quality-rails stage (after build) invokes the evaluator
  CLI on the repo root — QC-19 monorepo subject — instead of duplicating
  presence logic; no ci.yml mirror (same shape as the build stage)
- parity spec updated: stage-name list, evaluator-delegation assertions, and
  negative controls for inline duplication / dropped command
- quality-rails README points at the probe inventory (input doc)
2026-08-18 12:35:03 -05:00
fargo 771127d3cd test(quality-rails): evaluator contract, parity oracle, and negative-control specs (#1275)
- CLI evaluate/check --json vs programmatic evaluateSubject: same subject,
  same typed report
- QC-19 parity vs a verbatim copy of the absorbed presence loop (positive and
  negative fixtures, all scaffold kinds) and QC-20 parity vs the real
  framework verify.sh output contract
- negative controls: unknown check id, absent subject, missing probePath,
  spawn error, timeout, nonzero/unexpected exit, malformed output, throwing
  check, unqualified skip — all never passed
2026-08-18 11:17:25 -05:00
fargo ae95e7b853 feat(quality-rails): absorb QC-19 presence loop into evaluator; add typed evaluate/check/doctor (#1275)
- check (QC-19) is now implemented by the evaluator's typed
  qc-19-rails-files-present definition; keeps fail-closed exit and gains --json
- doctor stays advisory but reports typed states
- new evaluate subcommand is the canonical CLI entry point (--probe-path feeds
  QC-20; shell probes remain thin adapters with TS-owned verdict parsing)
2026-08-18 11:17:23 -05:00
fargo 367cb27591 feat(quality-rails): typed evaluator core — statuses, digested definitions, per-subject sets (#1275) 2026-08-18 11:17:15 -05:00
fargo 18905d69e6 docs(ri-050): RI-3-001 done — review 187 + merge evidence recorded (#1275) 2026-08-18 11:01:20 -05:00
jarvisandfargo ff45f7b5d0 docs(ri-050): forge fail-closed docs + TASKS status catch-up (#1275) (#1299)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jarvis <[email protected]>
2026-08-18 16:00:36 +00:00
jarvisandfargo 64350892e7 docs(ri-050): RI-3-001 complete quality-rails probe inventory (#1275) (#1302)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: jarvis <[email protected]>
2026-08-18 15:59:34 +00:00
jason.woltjeandjarvis 6e9df3c640 fix(doctor): greenfield brain lock-in note (fred's #1301 follow-up, #1288) (#1307)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
2026-08-18 07:57:37 +00:00
jarvis f5ba042dfa test(ri-050): RI-1-002 publish-gate negative controls (#1275) (#1305)
ci/woodpecker/push/publish Pipeline failed
2026-08-18 05:57:06 +00:00
jarvis 7c7dab3898 feat(ri-050): RI-5-001 typed freshness states and stale-safe Mission Control surfaces (#1275) (#1300)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-18 05:56:58 +00:00
fargoandjarvis d92de53399 feat(prd): one transitional PRD authority — RI-4-001 (#1275) (#1294)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: fargo <[email protected]>
2026-08-18 05:56:51 +00:00
mos-dt-0andjarvis d7e303d3c0 fix(macp): fail-closed typed gate states — RI-2-002 (#1275) (#1293)
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: mos-dt-0 <[email protected]>
2026-08-18 05:56:38 +00:00
jarvis 726d2ad3a2 fix(ri-050): forge fails closed without providers; explicit typed simulation (#1275) (#1278)
ci/woodpecker/push/publish Pipeline was canceled
2026-08-18 05:52:45 +00:00
jason.woltjeandjarvis e4ee1acf24 feat(doctor): brain-home fleet-state check (#1298 follow-up) (#1301)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
2026-08-18 05:26:01 +00:00
jarvis 5c5a25e4de fix(ci): image pushes read the registry secrets that exist (#1275) (#1306)
ci/woodpecker/push/publish Pipeline failed
Squash-merged by topher (jarvis principal) via break-glass API path (wrapper main-only gap, documented). Gates: CI 2492 green at head e19013ed, review 181 APPROVED (fred) at pinned head. Diagnostic merge: the push pipeline now exercises kaniko with the REGISTRY_* secrets - valid creds yield the first fully green gated publish; invalid yield an explicit 401.

Co-authored-by: Jarvis <[email protected]>
2026-08-18 05:02:23 +00:00
jarvis 7669321ea2 test(gateway): cross-user-isolation cleanup honors dbAvailable — unblocks gated publish verify (#1275) (#1304)
ci/woodpecker/push/publish Pipeline failed
Squash-merged by topher (jarvis principal) via break-glass API path (wrapper main-only gap, documented in #1275 log). Gates: CI 2487 green at head 81f500bd, review 180 APPROVED (fred) at pinned head. Unblocks gated publish: verify's no-DB path now skips cleanly.

Co-authored-by: Jarvis <[email protected]>
2026-08-18 04:24:48 +00:00
jarvis d8e0aec950 feat(ri-050): bind next publication to exact-commit terminal verification — RI-1-001 (#1275) (#1277)
ci/woodpecker/push/publish Pipeline failed
Squash-merged by topher (jarvis principal) via break-glass: pr-merge.sh hard-codes main-only merge targets and cannot express this repo's next trunk. Gates: CI 2476 green at head 46784c8d, review 177 APPROVED (fred) at pinned head. First gated publish: every publish step now depends on verify-release at the exact commit.
2026-08-18 03:57:43 +00:00
jarvis 49d6136b02 docs(ri-050): bootstrap release-integrity workstream for 0.0.50 (#1275) (#1276)
ci/woodpecker/push/publish Pipeline was canceled
Squash-merged by topher (jarvis principal) via break-glass: pr-merge.sh hard-codes main-only merge targets and cannot express this repo's next trunk. Gates: CI 2475 green at head 758659dd, review 176 APPROVED (fred) at pinned head.
2026-08-18 03:57:27 +00:00
jason.woltjeandjarvis a80bae950d feat(fleet): brain-home split — fleet state under ~/.mosaic, templates stay config-home (#1298)
ci/woodpecker/push/publish Pipeline failed
Co-authored-by: Jason Woltje <[email protected]>
2026-08-18 03:23:15 +00:00
jason.woltje 8199261caa Merge pull request 'fix(ci): unwire test-start-agent-session.sh, restore its signed exclusion — unblocks every PR on next' (#1270) from fix/1269-ci-chain-unblock into next
ci/woodpecker/push/publish Pipeline failed
Reviewed-on: #1270
2026-08-17 20:44:59 +00:00
fred 57a2f2b40e docs(ci): point the exclusion at tracking issue #1271, not the closed first filing
ci/woodpecker/pr/ci Pipeline was successful
The first PR for this change was filed under the retired mos-dt-0 principal
(pr-create.sh has no --login flag and find_tea_login_for_host returns the first
host match) and was closed and refiled as #1270. That left in-tree references
pointing at a closed duplicate PR rather than at the burn-down issue, which is
the wrong target for them anyway: the open design question belongs on #1271.
2026-08-16 18:03:02 -05:00
fred 93c1de51e1 fix(ci): unwire test-start-agent-session.sh, restore its signed exclusion (#1269)
ci/woodpecker/pr/ci Pipeline was canceled
The `test` step has failed on every `next` pipeline since #1017 on exactly one
assertion, and it is the same one on unrelated PRs:

    FAIL: host provides 'pi' in the system path; missing-binary cases are not
    measurable here            (framework/tools/fleet/test-start-agent-session.sh:103)

Measured 2026-08-16 across pipelines 2444 (#1256), 2438 (#1240) and 2441
(#1017-quality): exactly one FAIL line in each full log, identical, this line.
Control `zzz-not-present-zzz` -> 0 on all three.

Cause. #1241 (5c35a250) added the guard: the suite shims fake mosaic/pi/npm into
$FAKE_BIN, but the constructed PANE_PATH always ends in the real system path, so
on a host that installs those binaries the missing-binary cases cannot be
measured and a green run would mean nothing. The guard says so instead of
passing. Its own pipeline 2430 was green only because the suite was CI-excluded
at the time, so the guard had never run in CI. #1017 (c56483eb) then enumerated
it and dropped the exclusion. The CI image installs
@earendil-works/[email protected].1 on purpose, so the precondition is
unsatisfiable there. Both commits are mine.

The guard is correct and is not being softened. A check that cannot measure its
property and reports success is the failure mode this repo has been cataloguing
all week; the error was wiring the suite into an image that violates its
precondition, so the wiring is what gets reverted.

Second effect, which is the reason this cost a day rather than an hour:
test:framework-shell is one && chain and this sat at position 44 of 48, so
glpi/test-list-http-status.sh, orchestrator/test-board-roll.sh,
woodpecker/test-ci-wait-exit-matrix.sh and _scripts/test-fleet-transport-check.sh
have not run at all since the merge. The pipeline reported one failure, never
"one failure plus four unrun". All four are green when run directly on
sb-it-1-dt, so the mask hid nothing broken -- but that is a local result on one
host, not a CI-image result.

Verification, with controls:
- enumeration guard OK (population 52, enumerated 36, signed-excluded 16).
- control A, exclusion line removed while unwired -> FAIL UNENUMERATED.
- control B, exclusion line kept while rewired -> FAIL CONTRADICTORY EXCLUSION.
  The gate discriminates in both directions, so its OK is load-bearing.
- the four formerly-masked suites: rc=0 each, run directly.
- the full chain cannot be run to completion on sb-it-1-dt: it stops earlier, at
  the lease-broker Invariant R test, because this host carries the quarantined
  operator-global pi 0.84.2 against a measured 0.84.1. That is host-specific and
  out of scope here -- CI pins 0.84.1, and the single FAIL line in those three
  pipelines proves positions 1-43 passed there.

Burn-down is to control the tail of PANE_PATH inside the test, not to remove pi
from the image. Recorded in the exclusion reason and in #1269.
2026-08-16 17:58:49 -05:00
114 changed files with 10265 additions and 2362 deletions
+2 -2
View File
@@ -22,9 +22,9 @@ steps:
image: gcr.io/kaniko-project/executor:debug
environment:
REGISTRY_USER:
from_secret: gitea_username
from_secret: REGISTRY_USERNAME
REGISTRY_PASS:
from_secret: gitea_password
from_secret: REGISTRY_PASSWORD
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
+24 -1
View File
@@ -30,6 +30,19 @@ steps:
# the baked pnpm store.
- pnpm install --frozen-lockfile --prefer-offline
# ---------------------------------------------------------------------------
# The steps below (sanitization, upgrade-guard, typecheck, lint, format,
# test) are the COMPLETE mandatory verification set. SDLC-D-034 mirrors them
# one-for-one in the canonical terminal verification command — root
# `pnpm verify:release` (scripts/verify-release.mjs) — which the publish
# pipeline (.woodpecker/publish.yml `verify` step) runs before ANY publish
# effect. These lines stay direct (not routed through the runner) because the
# #1017 test-enumeration guard audits framework tool paths through THIS
# surface; scripts/verify-release.test.mjs enforces that the runner's stage
# table keeps matching these commands exactly, so the two cannot drift.
# ---------------------------------------------------------------------------
# Canonical verify:release stage `sanitization`.
# Blocking gate: public framework package must contain no operator-specific
# personal data or private $HOME defaults. Runs early (no node_modules needed).
sanitization:
@@ -47,6 +60,7 @@ steps:
# with everything it guards; this direct line keeps one instrument running.
- bash packages/mosaic/framework/tools/quality/scripts/check-test-enumeration.sh
# Canonical verify:release stage `upgrade-guard`.
# Blocking gate (#791): a framework upgrade must never write or delete an
# operator-owned path. The HARD GATE proves an unanticipated operator sentinel
# survives a keep-mode reseed byte-identical (with rsync present AND absent —
@@ -68,6 +82,8 @@ steps:
- bash packages/mosaic/framework/tools/quality/scripts/test-upgrade-durable-snapshot.sh
- bash packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
# Canonical verify:release stage `typecheck` — the same `pnpm typecheck`
# invocation (which runs the checkout preflight first, then turbo).
typecheck:
image: *node_image
commands:
@@ -78,7 +94,8 @@ steps:
- sanitization
- upgrade-guard
# lint, format, and test are independent — run in parallel after typecheck
# lint, format, and test are independent — run in parallel after typecheck.
# Each runs exactly its canonical verify:release stage command.
lint:
image: *node_image
commands:
@@ -95,6 +112,12 @@ steps:
depends_on:
- typecheck
# Canonical verify:release stage `test` — the `pnpm test` line below is the
# shared command; everything else in this step is PIPELINE-LEVEL
# prerequisite the canonical command expects its caller to provide (SDLC-D-034):
# the ci-postgres service + pg_isready wait + db:migrate (postgres path),
# `apk add openssl`, and the pinned pi install. None of those can move into
# the runner (it must also work locally on the PGlite path with no database).
test:
image: *node_image
environment:
+66 -6
View File
@@ -1,5 +1,19 @@
# Build, publish npm packages, and push Docker images
# Runs on main for stable publishes and on next for integration-line prereleases/images
#
# SDLC-D-034 publish gate: every publish effect (publish-npm, publish-next-npm,
# and every image build/push step) depends DIRECTLY on the `verify` step below.
# `verify` (a) asserts the provider's commit identity matches the actual
# checkout (CI_COMMIT_SHA == git rev-parse HEAD, fail closed on mismatch or
# emptiness) and (b) runs the canonical terminal verification command
# (`pnpm verify:release`), which mirrors the PR CI pipeline's complete
# mandatory set (sanitization, upgrade-guard, preflight+typecheck, lint,
# format:check, test, build) — see scripts/verify-release.mjs. A missing,
# failed, skipped, cancelled, or inconclusive verification therefore skips the
# dependent publish effects (fail closed). Path-filtered short-circuits may
# skip publish EFFECTS (e.g. docs-only merges) but never bypass `verify` for a
# publish that does run: `verify` itself carries no path filter.
# scripts/verify-release.test.mjs enforces this DAG invariant at checkout time.
variables:
# Pre-baked CI base (see .woodpecker/ci-image.yml): node:24-alpine +
@@ -48,6 +62,45 @@ steps:
# Resolve from the baked pnpm store instead of a cold network fetch.
- pnpm install --frozen-lockfile --prefer-offline
# SDLC-D-034 exact-commit publish gate. No `when`/path filter on purpose: it
# runs for every event this pipeline serves so no publish effect can ever
# start without it. Fails closed on commit-identity mismatch (or either SHA
# being empty) and on any incomplete verification.
verify:
image: *node_image
commands:
- *enable_pnpm
# (a) Commit identity: the provider's claimed SHA must equal the actual
# checkout HEAD — verification of anything else must never authorize a
# publish of this commit.
- |
if [ -z "$CI_COMMIT_SHA" ]; then
echo "[verify] FATAL: CI_COMMIT_SHA is empty — cannot certify commit identity" >&2
exit 1
fi
CHECKOUT_SHA="$(git rev-parse HEAD 2>/dev/null || true)"
if [ -z "$CHECKOUT_SHA" ]; then
echo "[verify] FATAL: git rev-parse HEAD returned nothing — cannot certify commit identity" >&2
exit 1
fi
if [ "$CI_COMMIT_SHA" != "$CHECKOUT_SHA" ]; then
echo "[verify] FATAL: provider commit ($CI_COMMIT_SHA) != checkout HEAD ($CHECKOUT_SHA)" >&2
exit 1
fi
echo "[verify] commit identity confirmed: $CHECKOUT_SHA"
# (b) Canonical terminal verification. Caller-provided prerequisites the
# runner expects (see .woodpecker/ci.yml comments): bash/rsync for the
# guard stages, openssl + the pinned pi binary for the test stage. git is
# baked into ci-base but re-asserted here so the identity check above can
# never silently depend on a stale baked image. DATABASE_URL is
# deliberately NOT set: the canonical command must hold on the PGlite
# path too and never sets or requires a database itself.
- apk add --no-cache bash rsync openssl git
- npm install -g @earendil-works/[email protected]
- pnpm verify:release
depends_on:
- install
build:
image: *node_image
commands:
@@ -55,6 +108,7 @@ steps:
- pnpm build
depends_on:
- install
- verify
publish-npm:
image: *node_image
@@ -114,6 +168,7 @@ steps:
exit 1
depends_on:
- build
- verify
publish-next-npm:
image: *node_image
@@ -192,6 +247,7 @@ steps:
echo "[publish-next] @mosaicstack/mosaic@next resolves to $RESOLVED_VERSION"
depends_on:
- build
- verify
# TODO: Uncomment when ready to publish to npmjs.org
# publish-npmjs:
@@ -205,6 +261,7 @@ steps:
# - bash scripts/publish-npmjs.sh
# depends_on:
# - build
# - verify
# when:
# - event: [tag]
@@ -213,9 +270,9 @@ steps:
when: *image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
from_secret: REGISTRY_USERNAME
REGISTRY_PASS:
from_secret: gitea_password
from_secret: REGISTRY_PASSWORD
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
@@ -242,15 +299,16 @@ steps:
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
depends_on:
- build
- verify
build-appservice:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
from_secret: REGISTRY_USERNAME
REGISTRY_PASS:
from_secret: gitea_password
from_secret: REGISTRY_PASSWORD
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
@@ -268,15 +326,16 @@ steps:
/kaniko/executor --context . --dockerfile docker/appservice.Dockerfile $DESTINATIONS
depends_on:
- build
- verify
build-web:
image: gcr.io/kaniko-project/executor:debug
when: *main_image_build_when
environment:
REGISTRY_USER:
from_secret: gitea_username
from_secret: REGISTRY_USERNAME
REGISTRY_PASS:
from_secret: gitea_password
from_secret: REGISTRY_PASSWORD
CI_COMMIT_BRANCH: ${CI_COMMIT_BRANCH}
CI_COMMIT_TAG: ${CI_COMMIT_TAG}
CI_COMMIT_SHA: ${CI_COMMIT_SHA}
@@ -294,3 +353,4 @@ steps:
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
depends_on:
- build
- verify
+2 -2
View File
@@ -138,9 +138,9 @@ mosaic brain tasks
mosaic brain conversations
# Agent forge pipeline
mosaic forge run
mosaic forge run [--simulate] # fails closed (FORGE_NO_EXECUTOR) with no executor wired; --simulate for typed simulated runs
mosaic forge status
mosaic forge resume
mosaic forge resume [--simulate] # same fail-closed rule as forge run
mosaic forge personas
# Structured logging
@@ -190,7 +190,13 @@ beforeEach((ctx) => {
});
afterAll(async () => {
if (!handle) return;
// Cleanup only when the fixture actually installed rows. `handle` is set
// before the first query (createDb connects lazily), so on an unreachable
// database `handle` is truthy while nothing was inserted — cleanup must
// honor `dbAvailable` or the skip path fails the file with ECONNREFUSED in
// afterAll (caught live by the publish pipeline's no-DATABASE_URL verify
// step, pipeline 2486).
if (!handle || !dbAvailable) return;
const db = handle.db;
// Delete in dependency order (FK constraints)
@@ -0,0 +1,110 @@
'use client';
import type { ReactElement } from 'react';
import { formatAge, type FreshnessLabel } from '@/lib/freshness/model';
/**
* Rendering rules for non-current freshness states (RI-5-001).
*
* - `unavailable` renders an explicit failure panel — never an empty
* healthy collection.
* - `stale` may render last-known data, but only under a visible label
* carrying source identity, snapshot version, and age.
* - `partial` renders the verified parts plus an explicit list of what is
* missing.
*/
interface RetryableNoticeProps {
readonly onRetry?: () => void;
readonly retryLabel?: string;
}
function RetryButton({ onRetry, retryLabel }: RetryableNoticeProps): ReactElement | null {
if (!onRetry) return null;
return (
<button
type="button"
onClick={onRetry}
className="mt-2 rounded-lg border border-surface-border px-3 py-1.5 text-xs transition-colors hover:border-gray-500"
>
{retryLabel ?? 'Retry'}
</button>
);
}
export interface UnavailableDataNoticeProps extends RetryableNoticeProps {
/** What is unavailable, e.g. "Tasks". */
readonly title: string;
/** Optional underlying failure detail (network message, invalidation reason). */
readonly detail?: string | null;
}
/** Explicit `unavailable` state. Never renders as an empty healthy collection. */
export function UnavailableDataNotice({
title,
detail,
onRetry,
retryLabel,
}: UnavailableDataNoticeProps): ReactElement {
return (
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
<p className="font-medium text-text-primary">{title} are unavailable</p>
<p className="mt-1 text-text-muted">
This is not an empty result the data could not be verified from the gateway.
{detail ? ` ${detail}` : ''}
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel} />
</div>
);
}
export interface StaleDataNoticeProps extends RetryableNoticeProps {
/** Provenance of the last-known snapshot being displayed. */
readonly label: FreshnessLabel;
}
/**
* Situational-awareness banner for `stale` data: last-known data may render,
* but visibly labeled with source identity, snapshot version, and age.
*/
export function StaleDataNotice({
label,
onRetry,
retryLabel,
}: StaleDataNoticeProps): ReactElement {
return (
<div role="status" className="rounded-lg border border-warning/40 px-4 py-3 text-sm">
<p className="font-medium text-warning">Showing last-known data it may be out of date</p>
<p className="mt-1 text-xs text-text-muted">
Source {label.source} · snapshot v{label.version} · fetched{' '}
{formatAge(label.fetchedAt, Date.now())}. Verdicts derived from this data are unknown and
changes are disabled until it is revalidated.
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel ?? 'Revalidate'} />
</div>
);
}
export interface PartialDataNoticeProps extends RetryableNoticeProps {
/** Display names of the sections whose collections are unavailable. */
readonly missing: readonly string[];
}
/** `partial` surface banner: verified parts render, missing parts are explicit. */
export function PartialDataNotice({
missing,
onRetry,
retryLabel,
}: PartialDataNoticeProps): ReactElement {
return (
<div role="status" className="rounded-lg border border-warning/40 px-4 py-3 text-sm">
<p className="font-medium text-warning">Some data could not be loaded</p>
<p className="mt-1 text-xs text-text-muted">
{missing.join(', ')} {missing.length === 1 ? 'is' : 'are'} unavailable sections below show
an explicit unavailable state instead of an empty list. Derived verdicts remain unknown
until every collection is revalidated.
</p>
<RetryButton onRetry={onRetry} retryLabel={retryLabel ?? 'Revalidate'} />
</div>
);
}
+324
View File
@@ -0,0 +1,324 @@
import { describe, expect, it } from 'vitest';
import type { Task } from '@/lib/types';
import {
acceptSnapshot,
assertMutable,
canMutate,
combineFreshness,
computeDigest,
computeFreshness,
DEFAULT_FRESHNESS_POLICY,
formatAge,
type FreshSnapshot,
invalidationReasonLabels,
StaleMutationError,
UNKNOWN_VERDICT,
verdictValue,
} from './model';
import { validateProjectCollection, validateTaskCollection } from './validators';
const NOW = 1_800_000_000_000;
const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };
const taskPayload: Task[] = [
{
id: 'task-1',
title: 'T1',
description: null,
status: 'not-started',
priority: 'high',
projectId: 'project-1',
missionId: null,
assignee: null,
tags: null,
dueDate: null,
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
];
function acceptedTaskSnapshot(
overrides: Partial<FreshSnapshot<typeof taskPayload>> = {},
): FreshSnapshot<typeof taskPayload> {
const result = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') {
throw new Error(`fixture setup failed: ${result.reason}`);
}
return { ...result.snapshot, ...overrides };
}
describe('computeFreshness', () => {
it('treats a missing snapshot as unavailable, never as an empty healthy collection', () => {
expect(computeFreshness({ snapshot: null, policy, now: NOW })).toBe('unavailable');
});
it('returns current for a fresh verified snapshot regardless of data emptiness', () => {
const empty = acceptSnapshot({
value: [],
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (empty.outcome !== 'accepted') throw new Error('expected acceptance');
expect(computeFreshness({ snapshot: empty.snapshot, policy, now: NOW })).toBe('current');
});
it('degrades to stale once the snapshot ages past staleAfterMs', () => {
const snapshot = acceptedTaskSnapshot();
expect(computeFreshness({ snapshot, policy, now: NOW + 60_001 })).toBe('stale');
expect(computeFreshness({ snapshot, policy, now: NOW + 59_999 })).toBe('current');
});
it('degrades to stale when the latest revalidation failed', () => {
const snapshot = acceptedTaskSnapshot();
expect(computeFreshness({ snapshot, policy, now: NOW, degraded: true })).toBe('stale');
});
});
describe('mutation guard', () => {
it('permits mutations only on current data', () => {
expect(canMutate('current')).toBe(true);
for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
expect(canMutate(state)).toBe(false);
}
});
it('refuses mutations on non-current data via assertMutable', () => {
expect(() => assertMutable('current')).not.toThrow();
for (const state of ['stale', 'partial', 'unknown', 'unavailable'] as const) {
let thrown: unknown;
try {
assertMutable(state);
} catch (caught) {
thrown = caught;
}
expect(thrown).toBeInstanceOf(StaleMutationError);
expect(thrown).toBeInstanceOf(Error);
if (thrown instanceof StaleMutationError) {
expect(thrown.name).toBe('StaleMutationError');
expect(thrown.freshness).toBe(state);
expect(thrown.message).toContain(state);
expect(thrown.message).toContain('revalidat');
}
}
});
});
describe('acceptSnapshot', () => {
it('accepts a valid payload with provenance', () => {
const result = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(result.outcome).toBe('accepted');
if (result.outcome !== 'accepted') return;
expect(result.snapshot.source).toBe('gateway:/api/tasks');
expect(result.snapshot.version).toBe(1);
expect(result.snapshot.fetchedAt).toBe(NOW);
expect(result.snapshot.data).toEqual(taskPayload);
});
it('invalidates a schema-mismatched payload instead of rendering it', () => {
const result = acceptSnapshot({
value: { not: 'an array' },
validate: validateTaskCollection,
previous: acceptedTaskSnapshot(),
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(result).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
expect(invalidationReasonLabels['schema-mismatch']).toContain('schema');
});
it('invalidates cross-workspace payloads', () => {
const userOne = acceptSnapshot({
value: [
{
id: 'p1',
name: 'P1',
description: null,
status: 'active',
userId: 'user-1',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');
const switched = acceptSnapshot({
value: [
{
id: 'p9',
name: 'P9',
description: null,
status: 'active',
userId: 'user-2',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: userOne.snapshot,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
expect(switched).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
it('keeps the previous workspace for collections with no intrinsic identity', () => {
const userOne = acceptSnapshot({
value: [
{
id: 'p1',
name: 'P1',
description: null,
status: 'active',
userId: 'user-1',
metadata: null,
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
},
],
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (userOne.outcome !== 'accepted') throw new Error('expected acceptance');
// Empty list after the user deleted every project: no identity to check,
// so the verified scope is retained and the empty state stays healthy.
const emptied = acceptSnapshot({
value: [],
validate: validateProjectCollection,
previous: userOne.snapshot,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
expect(emptied.outcome).toBe('accepted');
if (emptied.outcome === 'accepted') {
expect(emptied.snapshot.data).toEqual([]);
expect(emptied.snapshot.workspace).toBe('user-1');
}
});
it('invalidates version regressions', () => {
const previous = acceptedTaskSnapshot({ version: 7 });
const regressed = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous,
policy,
source: 'gateway:/api/tasks',
now: NOW,
incomingVersion: 3,
});
expect(regressed).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
const newerSchema = acceptedTaskSnapshot({ schemaVersion: 4 });
const downgradedClient = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: newerSchema,
policy: { ...policy, schemaVersion: 2 },
source: 'gateway:/api/tasks',
now: NOW,
});
expect(downgradedClient).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
});
it('increments the version monotonically across accepted snapshots', () => {
const first = acceptedTaskSnapshot();
const second = acceptSnapshot({
value: taskPayload,
validate: validateTaskCollection,
previous: first,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
expect(second.outcome).toBe('accepted');
if (second.outcome === 'accepted') {
expect(second.snapshot.version).toBe(first.version + 1);
}
});
});
describe('combineFreshness', () => {
it('gates the surface on the primary collection', () => {
expect(combineFreshness('unavailable', ['current'])).toBe('unavailable');
expect(combineFreshness('unknown', ['current'])).toBe('unknown');
expect(combineFreshness('current', [])).toBe('current');
});
it('degrades to partial when a secondary is unavailable', () => {
expect(combineFreshness('current', ['current', 'unavailable'])).toBe('partial');
});
it('degrades to unknown while a secondary is still loading', () => {
expect(combineFreshness('current', ['unknown'])).toBe('unknown');
});
it('degrades to stale when any collection is stale', () => {
expect(combineFreshness('current', ['stale'])).toBe('stale');
expect(combineFreshness('stale', ['current'])).toBe('stale');
});
it('propagates partial secondaries', () => {
expect(combineFreshness('current', ['partial'])).toBe('partial');
});
});
describe('computeDigest', () => {
it('is stable across key order and changes with data', () => {
const a = computeDigest({ x: 1, y: [1, 2] });
const b = computeDigest({ y: [1, 2], x: 1 });
expect(a).toBe(b);
expect(computeDigest({ x: 1, y: [1, 3] })).not.toBe(a);
});
});
describe('verdictValue', () => {
it('returns the value only for verified inputs', () => {
expect(verdictValue(true, '5')).toBe('5');
expect(verdictValue(false, '5')).toBe(UNKNOWN_VERDICT);
expect(verdictValue(false, '5')).not.toBe('5');
});
});
describe('formatAge', () => {
it('labels age in human terms', () => {
expect(formatAge(NOW, NOW)).toBe('just now');
expect(formatAge(NOW, NOW + 15_000)).toBe('under a minute ago');
expect(formatAge(NOW, NOW + 120_000)).toBe('2m ago');
expect(formatAge(NOW, NOW + 3 * 3_600_000)).toBe('3h ago');
expect(formatAge(NOW, NOW + 2 * 86_400_000)).toBe('2d ago');
});
});
+261
View File
@@ -0,0 +1,261 @@
/**
* Typed freshness model for gateway-fetched collections (RI-5-001).
*
* A failed or stale fetch must never be indistinguishable from an empty
* healthy collection. Every fetched surface carries an explicit freshness
* state, a verified snapshot identity (source, workspace, version, age), and
* a mutation guard that refuses state-changing operations unless the data is
* verified current.
*/
/** Freshness states for fetched data. Never inferred from emptiness. */
export type FreshnessState = 'current' | 'stale' | 'partial' | 'unknown' | 'unavailable';
/**
* Reasons a snapshot is invalidated. An invalidated snapshot is treated as
* unavailable and is never rendered as current.
*/
export type InvalidationReason =
| 'cache-corruption'
| 'cross-workspace'
| 'schema-mismatch'
| 'version-regression';
/** Human-readable labels for invalidation reasons (UI + error messages). */
export const invalidationReasonLabels: Record<InvalidationReason, string> = {
'cache-corruption': 'cached snapshot failed integrity checks',
'cross-workspace': 'data belongs to a different workspace',
'schema-mismatch': 'response did not match the expected schema',
'version-regression': 'snapshot version regressed below the accepted version',
};
/** A verified snapshot of fetched data with full provenance. */
export interface FreshSnapshot<T> {
readonly data: T;
/** Source identity of the fetch, e.g. `gateway:/api/tasks`. */
readonly source: string;
/** Workspace scope the data belongs to. */
readonly workspace: string;
/** Monotonic snapshot sequence number for this surface. */
readonly version: number;
/** Schema version of the validator that accepted this snapshot. */
readonly schemaVersion: number;
/** Epoch ms at which the data was verified. */
readonly fetchedAt: number;
/** Integrity digest of `data`, used to detect cache corruption. */
readonly digest: string;
}
/** Provenance label rendered next to last-known data. */
export interface FreshnessLabel {
readonly source: string;
readonly version: number;
readonly fetchedAt: number;
}
/** Policy governing freshness for a surface. */
export interface FreshnessPolicy {
/** Active workspace scope. Snapshots from other scopes are invalidated. */
readonly workspace: string;
/** Schema version of the current validator. */
readonly schemaVersion: number;
/** Age after which a verified snapshot degrades from current to stale. */
readonly staleAfterMs: number;
}
export const DEFAULT_FRESHNESS_POLICY: FreshnessPolicy = {
workspace: 'default',
schemaVersion: 1,
staleAfterMs: 60_000,
};
/** Payload returned by a successful schema validation. */
export interface FreshPayload<T> {
readonly data: T;
/**
* Workspace identity extracted from the payload itself when the collection
* carries one (e.g. a uniform `userId` on projects). `null` when the
* collection has no intrinsic workspace identity.
*/
readonly workspace: string | null;
}
/** Error thrown when a mutation is attempted on non-current data. */
export class StaleMutationError extends Error {
readonly freshness: FreshnessState;
constructor(freshness: FreshnessState) {
super(`Refused mutation on ${freshness} data: revalidation is required before mutating.`);
this.name = 'StaleMutationError';
this.freshness = freshness;
}
}
/** Stable JSON digest used for snapshot integrity checks. */
export function computeDigest(value: unknown): string {
// FNV-1a 32-bit over the stable JSON serialization. This is an integrity
// check against corruption, not a cryptographic guarantee.
let hash = 0x811c9dc5;
for (const byte of stableStringify(value)) {
hash ^= byte.charCodeAt(0);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function stableStringify(value: unknown): string {
return serialize(value);
}
function serialize(value: unknown): string {
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
if (Array.isArray(value)) return `[${value.map(serialize).join(',')}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
.map(([key, item]) => `${JSON.stringify(key)}:${serialize(item)}`);
return `{${entries.join(',')}}`;
}
export type AcceptSnapshotResult<T> =
| { readonly outcome: 'accepted'; readonly snapshot: FreshSnapshot<T> }
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
export interface AcceptSnapshotOptions<T> {
/** Raw fetched value (untrusted JSON). */
readonly value: unknown;
/** Schema validator; returns `null` when the value does not match. */
readonly validate: (value: unknown) => FreshPayload<T> | null;
/** Previously accepted snapshot for this surface, if any. */
readonly previous: FreshSnapshot<T> | null;
readonly policy: FreshnessPolicy;
readonly source: string;
/**
* Version carried by the incoming payload when the transport exposes one.
* Must not regress below the accepted snapshot's version.
*/
readonly incomingVersion?: number;
readonly now: number;
}
/**
* Validate and accept a fetched value as a snapshot, or invalidate it.
*
* Invalidation rules (each treated as unavailable, never rendered current):
* - schema mismatch: the payload fails validation
* - cross-workspace: the payload's workspace differs from the verified one
* - version regression: payload/schema version is below the accepted one
*/
export function acceptSnapshot<T>(options: AcceptSnapshotOptions<T>): AcceptSnapshotResult<T> {
const payload = options.validate(options.value);
if (payload === null) {
return { outcome: 'invalidated', reason: 'schema-mismatch' };
}
// Workspace identity: the payload's own scope wins; a collection with no
// intrinsic identity (e.g. an empty list after every project was deleted)
// keeps the previously verified scope rather than resetting to the policy
// default, so a legitimately empty response is not mistaken for a scope
// change.
const workspace = payload.workspace ?? options.previous?.workspace ?? options.policy.workspace;
if (options.previous !== null && options.previous.workspace !== workspace) {
return { outcome: 'invalidated', reason: 'cross-workspace' };
}
if (options.previous !== null && options.policy.schemaVersion < options.previous.schemaVersion) {
return { outcome: 'invalidated', reason: 'version-regression' };
}
if (
options.incomingVersion !== undefined &&
options.previous !== null &&
options.incomingVersion < options.previous.version
) {
return { outcome: 'invalidated', reason: 'version-regression' };
}
const snapshot: FreshSnapshot<T> = {
data: payload.data,
source: options.source,
workspace,
version: options.incomingVersion ?? (options.previous?.version ?? 0) + 1,
schemaVersion: options.policy.schemaVersion,
fetchedAt: options.now,
digest: computeDigest(payload.data),
};
return { outcome: 'accepted', snapshot };
}
export interface ComputeFreshnessOptions {
readonly snapshot: FreshSnapshot<unknown> | null;
readonly policy: FreshnessPolicy;
readonly now: number;
/**
* True when the snapshot cannot be trusted as current regardless of age:
* the latest revalidation failed, or the snapshot was restored from cache
* and has not been verified by a fetch in this session.
*/
readonly degraded?: boolean;
}
/**
* Compute the freshness state of a snapshot. A missing snapshot is
* `unavailable` (never "empty and healthy"); a degraded or aged snapshot is
* `stale` (situational awareness only).
*/
export function computeFreshness(options: ComputeFreshnessOptions): FreshnessState {
const { snapshot, policy, now, degraded = false } = options;
if (snapshot === null) return 'unavailable';
if (degraded) return 'stale';
if (now - snapshot.fetchedAt > policy.staleAfterMs) return 'stale';
return 'current';
}
/** Only verified-current data may back a state-changing action. */
export function canMutate(state: FreshnessState): boolean {
return state === 'current';
}
/** Defense in depth: reject the mutation call itself on non-current data. */
export function assertMutable(state: FreshnessState): void {
if (!canMutate(state)) {
throw new StaleMutationError(state);
}
}
/**
* Combine freshness across a multi-collection surface (primary + secondaries).
* The primary collection gates the surface: unknown while it loads,
* unavailable when it fails. Missing secondaries degrade the surface to
* `partial`; aged collections degrade it to `stale`.
*/
export function combineFreshness(
primary: FreshnessState,
secondaries: readonly FreshnessState[],
): FreshnessState {
if (primary === 'unavailable') return 'unavailable';
if (primary === 'unknown') return 'unknown';
if (secondaries.includes('unavailable')) return 'partial';
if (secondaries.includes('unknown')) return 'unknown';
if (secondaries.includes('stale') || primary === 'stale') return 'stale';
if (secondaries.includes('partial')) return 'partial';
return 'current';
}
/** Render-safe age label for snapshot provenance. */
export function formatAge(fetchedAt: number, now: number): string {
const ageMs = Math.max(0, now - fetchedAt);
if (ageMs < 10_000) return 'just now';
const minutes = Math.floor(ageMs / 60_000);
if (minutes < 1) return 'under a minute ago';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
/** Derived verdict placeholder for non-current inputs — never a green value. */
export const UNKNOWN_VERDICT = '?';
export function verdictValue(verified: boolean, value: string): string {
return verified ? value : UNKNOWN_VERDICT;
}
@@ -0,0 +1,197 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from './model';
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';
import { validateProjectCollection, validateTaskCollection } from './validators';
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
import type { Project, Task } from '@/lib/types';
const KEY = 'test:tasks';
const NOW = 1_800_000_000_000;
const policy = { ...DEFAULT_FRESHNESS_POLICY, staleAfterMs: 60_000 };
function storedTaskSnapshot() {
const result = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
return result.snapshot;
}
function storedProjectSnapshot() {
const result = acceptSnapshot({
value: projectFixtures,
validate: validateProjectCollection,
previous: null,
policy,
source: 'gateway:/api/projects',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
return result.snapshot;
}
function readTasks() {
return readSnapshotCache({
key: KEY,
workspace: policy.workspace,
policy,
validate: validateTaskCollection,
});
}
/** Write an arbitrary value directly at the raw cache slot. */
function writeRaw(key: string, value: unknown): void {
sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify(value));
}
/** Parse and re-write the stored entry (for tampering with internals). */
function tamperStored<T>(key: string, mutate: (stored: T) => void): void {
const parsed = JSON.parse(sessionStorage.getItem(`mosaic:freshness:v1:${key}`) ?? '{}') as T;
mutate(parsed);
writeRaw(key, parsed);
}
beforeEach(() => {
sessionStorage.clear();
});
afterEach(() => {
sessionStorage.clear();
});
describe('readSnapshotCache', () => {
it('misses when nothing is stored', () => {
expect(readTasks()).toEqual({ outcome: 'miss' });
});
it('hits for a well-formed entry and preserves provenance', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, snapshot);
const result = readTasks();
expect(result.outcome).toBe('hit');
if (result.outcome === 'hit') {
expect(result.snapshot.data).toEqual(taskFixtures);
expect(result.snapshot.source).toBe('gateway:/api/tasks');
expect(result.snapshot.version).toBe(snapshot.version);
expect(result.snapshot.fetchedAt).toBe(snapshot.fetchedAt);
expect(result.snapshot.workspace).toBe(snapshot.workspace);
}
});
it('invalidates unparsable entries as cache corruption', () => {
sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, '{not json');
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
});
it('invalidates structurally wrong entries as cache corruption', () => {
const malformed: unknown[] = [
'nested but not a snapshot',
{ data: taskFixtures }, // missing provenance fields
{
data: taskFixtures,
source: 1,
workspace: 'w',
version: 1,
schemaVersion: 1,
fetchedAt: 1,
digest: 'x',
},
null,
17,
];
for (const entry of malformed) {
writeRaw(KEY, entry);
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
}
});
it('invalidates digest mismatches as cache corruption (tampered data)', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
tamperStored<{ data: Task[] }>(KEY, (stored) => {
stored.data = [...stored.data, { ...stored.data[0]!, id: 'injected-task' }];
});
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cache-corruption' });
});
it('invalidates entries scoped to another workspace', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, { ...snapshot, workspace: 'someone-else' });
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
it('invalidates entries written by a newer schema as a version regression', () => {
const snapshot = storedTaskSnapshot();
writeSnapshotCache(KEY, { ...snapshot, schemaVersion: policy.schemaVersion + 1 });
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'version-regression' });
});
it('invalidates entries whose data no longer validates (schema mismatch)', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
tamperStored<{ data: unknown }>(KEY, (stored) => {
stored.data = { malformed: true };
});
expect(readTasks()).toEqual({ outcome: 'invalidated', reason: 'schema-mismatch' });
});
it('never reports a corrupted raw entry as a hit (negative control)', () => {
for (const raw of ['{oops', 'null', '"string"', '[]', '12']) {
sessionStorage.setItem(`mosaic:freshness:v1:${KEY}`, raw);
const result = readTasks();
expect(result.outcome).not.toBe('hit');
expect(result.outcome).toBe('invalidated');
}
});
it('scopes project collections by their workspace identity', () => {
const snapshot = storedProjectSnapshot();
writeSnapshotCache('test:projects', snapshot);
const sameScope = readSnapshotCache({
key: 'test:projects',
workspace: 'user-1',
policy,
validate: validateProjectCollection,
});
expect(sameScope.outcome).toBe('hit');
const foreignScope = readSnapshotCache({
key: 'test:projects',
workspace: 'user-2',
policy,
validate: validateProjectCollection,
});
expect(foreignScope).toEqual({ outcome: 'invalidated', reason: 'cross-workspace' });
});
});
describe('writeSnapshotCache round-trip', () => {
it('round-trips an accepted project snapshot', () => {
const snapshot = storedProjectSnapshot();
writeSnapshotCache('test:projects', snapshot);
const result = readSnapshotCache({
key: 'test:projects',
workspace: snapshot.workspace,
policy,
validate: validateProjectCollection,
});
expect(result.outcome).toBe('hit');
if (result.outcome === 'hit') {
expect(result.snapshot.data).toEqual(projectFixtures as Project[]);
}
});
});
describe('clearSnapshotCache', () => {
it('drops the entry so the next read misses', () => {
writeSnapshotCache(KEY, storedTaskSnapshot());
expect(readTasks().outcome).toBe('hit');
clearSnapshotCache(KEY);
expect(readTasks()).toEqual({ outcome: 'miss' });
});
});
@@ -0,0 +1,154 @@
import {
computeDigest,
type FreshPayload,
type FreshSnapshot,
type FreshnessPolicy,
type InvalidationReason,
} from './model';
/**
* Session-scoped last-known snapshot cache (RI-5-001).
*
* Restored snapshots are situational awareness only: they surface as `stale`
* until a fetch re-verifies them. A cache entry that is corrupted, belongs to
* another workspace, was written by a newer schema, or no longer validates is
* invalidated (treated as unavailable, never rendered as current).
*/
const CACHE_PREFIX = 'mosaic:freshness:v1';
interface StoredSnapshot {
data: unknown;
source: string;
workspace: string;
version: number;
schemaVersion: number;
fetchedAt: number;
digest: string;
}
export type SnapshotCacheRead<T> =
| { readonly outcome: 'hit'; readonly snapshot: FreshSnapshot<T> }
| { readonly outcome: 'miss' }
| { readonly outcome: 'invalidated'; readonly reason: InvalidationReason };
export interface ReadSnapshotCacheOptions<T> {
readonly key: string;
readonly workspace: string;
readonly policy: FreshnessPolicy;
readonly validate: (value: unknown) => FreshPayload<T> | null;
}
function cacheKey(key: string): string {
return `${CACHE_PREFIX}:${key}`;
}
function isStoredSnapshot(value: unknown): value is StoredSnapshot {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Record<string, unknown>;
return (
typeof candidate['data'] === 'object' &&
candidate['data'] !== null &&
typeof candidate['source'] === 'string' &&
typeof candidate['workspace'] === 'string' &&
typeof candidate['version'] === 'number' &&
typeof candidate['schemaVersion'] === 'number' &&
typeof candidate['fetchedAt'] === 'number' &&
typeof candidate['digest'] === 'string'
);
}
function getStorage(): Storage | null {
try {
return globalThis.sessionStorage ?? null;
} catch {
return null;
}
}
/**
* Restore a cached snapshot under the active workspace scope. Every failure
* mode maps to an explicit invalidation reason or a miss — never to data
* that renders as current.
*/
export function readSnapshotCache<T>(options: ReadSnapshotCacheOptions<T>): SnapshotCacheRead<T> {
const storage = getStorage();
if (storage === null) return { outcome: 'miss' };
let raw: string | null;
try {
raw = storage.getItem(cacheKey(options.key));
} catch {
return { outcome: 'miss' };
}
if (raw === null) return { outcome: 'miss' };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
if (!isStoredSnapshot(parsed)) {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
if (parsed.workspace !== options.workspace) {
return { outcome: 'invalidated', reason: 'cross-workspace' };
}
if (parsed.schemaVersion > options.policy.schemaVersion) {
// Written by a newer build than the running client: version regression.
return { outcome: 'invalidated', reason: 'version-regression' };
}
const payload = options.validate(parsed.data);
if (payload === null) {
return { outcome: 'invalidated', reason: 'schema-mismatch' };
}
if (computeDigest(payload.data) !== parsed.digest) {
return { outcome: 'invalidated', reason: 'cache-corruption' };
}
return {
outcome: 'hit',
snapshot: {
data: payload.data,
source: parsed.source,
workspace: parsed.workspace,
version: parsed.version,
schemaVersion: parsed.schemaVersion,
fetchedAt: parsed.fetchedAt,
digest: parsed.digest,
},
};
}
/** Persist a verified snapshot. Failures are non-fatal (cache is best-effort). */
export function writeSnapshotCache<T>(key: string, snapshot: FreshSnapshot<T>): void {
const storage = getStorage();
if (storage === null) return;
const stored: StoredSnapshot = {
data: snapshot.data,
source: snapshot.source,
workspace: snapshot.workspace,
version: snapshot.version,
schemaVersion: snapshot.schemaVersion,
fetchedAt: snapshot.fetchedAt,
digest: snapshot.digest,
};
try {
storage.setItem(cacheKey(key), JSON.stringify(stored));
} catch {
// Quota or serialization failures simply skip caching.
}
}
/** Drop a cached snapshot (used when a surface invalidates its cache entry). */
export function clearSnapshotCache(key: string): void {
const storage = getStorage();
if (storage === null) return;
try {
storage.removeItem(cacheKey(key));
} catch {
// Ignorable: a wedged storage entry is detected as corruption on read.
}
}
@@ -0,0 +1,372 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Task } from '@/lib/types';
import { acceptSnapshot, StaleMutationError, DEFAULT_FRESHNESS_POLICY } from './model';
import type { FreshnessFailure } from './use-fresh-collection';
import {
describeFailure,
useFreshCollection,
type FreshCollection,
type UseFreshCollectionOptions,
} from './use-fresh-collection';
import { validateProjectCollection, validateTaskCollection } from './validators';
import { projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
/**
* Failure-matrix coverage for the freshness seam (RI-5-001): network failure,
* auth failure, malformed response, cache corruption, stale age, schema
* mismatch, cross-workspace, recovery, and stale-action rejection — with
* negative controls proving no case yields current data or an enabled
* mutation.
*/
const NOW = 1_800_000_000_000;
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
let root: Root | null = null;
let container: HTMLDivElement;
let latest: FreshCollection<Task[]> | null = null;
function Probe({
options,
}: {
options: UseFreshCollectionOptions<Task[]>;
}): React.ReactElement | null {
latest = useFreshCollection<Task[]>(options);
return null;
}
beforeAll(() => {
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', {
configurable: true,
value: true,
});
});
beforeEach(() => {
sessionStorage.clear();
});
afterEach(async () => {
await act(async () => {
root?.unmount();
});
document.body.replaceChildren();
root = null;
latest = null;
sessionStorage.clear();
vi.restoreAllMocks();
});
async function renderCollection(
options: UseFreshCollectionOptions<Task[]>,
): Promise<FreshCollection<Task[]>> {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () => {
root?.render(<Probe options={options} />);
});
if (latest === null) throw new Error('hook did not run');
return latest;
}
function taskOptions(
overrides: Partial<UseFreshCollectionOptions<Task[]>> = {},
): UseFreshCollectionOptions<Task[]> {
return {
source: 'gateway:/api/tasks',
fetcher: () => Promise.resolve(taskFixtures),
validate: validateTaskCollection,
cacheKey: 'tasks',
clock: () => NOW,
...overrides,
};
}
function authError(statusCode: number): Error & { statusCode: number } {
return Object.assign(new Error(`Request failed with ${statusCode}`), { statusCode });
}
function seedCache(key: string): number {
const result = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy: DEFAULT_FRESHNESS_POLICY,
source: 'gateway:/api/tasks',
now: NOW,
});
if (result.outcome !== 'accepted') throw new Error('fixture setup failed');
sessionStorage.setItem(`mosaic:freshness:v1:${key}`, JSON.stringify({ ...result.snapshot }));
return result.snapshot.version;
}
describe('useFreshCollection failure matrix', () => {
it('is unknown (not empty) while the first validation is in flight', async () => {
const deferred = createDeferred<Task[]>();
const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
expect(collection.freshness).toBe('unknown');
expect(collection.validating).toBe(true);
expect(collection.data).toBeNull();
expect(collection.canMutate).toBe(false);
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
});
it('becomes current with provenance after a verified fetch', async () => {
const collection = await renderCollection(taskOptions());
expect(collection.freshness).toBe('current');
expect(collection.data).toEqual(taskFixtures);
expect(collection.snapshot?.source).toBe('gateway:/api/tasks');
expect(collection.snapshot?.version).toBe(1);
expect(collection.failure).toBeNull();
expect(collection.canMutate).toBe(true);
// Verified snapshot is persisted for last-known restore.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeTruthy();
});
it('treats a network failure as unavailable — never an empty healthy collection', async () => {
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.reject(new Error('network down')) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.failure).toEqual({ kind: 'fetch', message: 'network down' });
expect(collection.canMutate).toBe(false);
expect(describeFailure(collection.failure)).toBe('network down');
});
it('treats an auth failure as unavailable and drops the last-known snapshot', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1 ? Promise.resolve(taskFixtures) : Promise.reject(authError(401));
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('unavailable');
expect(latest?.data).toBeNull();
expect(latest?.failure?.kind).toBe('fetch');
// The previous user's data must not linger in the session cache.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
});
it('invalidates a malformed response as a schema mismatch', async () => {
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.resolve({ malformed: true }) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
expect(collection.canMutate).toBe(false);
});
it('keeps the previous snapshot as labeled stale when a later payload mismatches', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1 ? Promise.resolve(taskFixtures) : Promise.resolve('garbage');
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'schema-mismatch' });
expect(latest?.canMutate).toBe(false);
});
it('drops the snapshot when the workspace changes under it (cross-workspace)', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return Promise.resolve(
call === 1 ? projectFixtures : [{ ...projectFixtures[0], userId: 'user-2' }],
);
},
validate: validateProjectCollection as unknown as (value: unknown) => {
data: Task[];
workspace: string | null;
},
source: 'gateway:/api/projects',
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('unavailable');
expect(latest?.data).toBeNull();
expect(latest?.failure).toEqual({ kind: 'invalidated', reason: 'cross-workspace' });
});
it('ages from current to stale and refuses mutations on stale data', async () => {
let fakeNow = NOW;
const collection = await renderCollection(
taskOptions({
clock: () => fakeNow,
policy: { staleAfterMs: 40 },
tickMs: 10,
}),
);
expect(collection.freshness).toBe('current');
// Age the snapshot past the policy and let the tick recompute.
fakeNow = NOW + 60;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
expect(latest?.canMutate).toBe(false);
const operation = vi.fn(async () => 'result');
await expect(latest?.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
});
it('recovers to current after a successful revalidation', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1
? Promise.reject(new Error('first attempt failed'))
: Promise.resolve(taskFixtures);
},
}),
);
expect(collection.freshness).toBe('unavailable');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('current');
expect(latest?.failure).toBeNull();
const operation = vi.fn(async (data: Task[]) => data.length);
await expect(latest?.mutate(operation)).resolves.toBe(taskFixtures.length);
expect(operation).toHaveBeenCalledOnce();
});
it('restores a cached snapshot as unverified stale data, then verifies it', async () => {
const seededVersion = seedCache('tasks');
const deferred = createDeferred<Task[]>();
const collection = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
// Restored data is situational awareness only: labeled stale, never
// current, and mutations are refused before verification.
expect(collection.freshness).toBe('stale');
expect(collection.data).toEqual(taskFixtures);
expect(collection.canMutate).toBe(false);
await expect(collection.mutate(vi.fn())).rejects.toBeInstanceOf(StaleMutationError);
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
expect(latest?.freshness).toBe('current');
expect(latest?.snapshot?.version).toBe(seededVersion + 1);
});
it('never promotes corrupted cache data to current (cache corruption)', async () => {
sessionStorage.setItem('mosaic:freshness:v1:tasks', '{"data":');
const collection = await renderCollection(
taskOptions({ fetcher: () => Promise.reject(new Error('still down')) }),
);
expect(collection.freshness).toBe('unavailable');
expect(collection.data).toBeNull();
expect(collection.canMutate).toBe(false);
// The corrupted entry is dropped so it cannot come back.
expect(sessionStorage.getItem('mosaic:freshness:v1:tasks')).toBeNull();
});
it('refuses mutations while unknown or unavailable — the call itself, not just the button', async () => {
const deferred = createDeferred<Task[]>();
const unknown = await renderCollection(taskOptions({ fetcher: () => deferred.promise }));
const operation = vi.fn(async () => 'result');
await expect(unknown.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
await act(async () => {
deferred.reject(new Error('failed'));
await deferred.promise.catch(() => undefined);
});
const unavailable = latest!;
await expect(unavailable.mutate(operation)).rejects.toBeInstanceOf(StaleMutationError);
expect(operation).not.toHaveBeenCalled();
expect(unavailable.canMutate).toBe(false);
});
it('degrades to stale with last-known data when a revalidation fails after success', async () => {
let call = 0;
const collection = await renderCollection(
taskOptions({
fetcher: () => {
call += 1;
return call === 1
? Promise.resolve(taskFixtures)
: Promise.reject(new Error('connection lost'));
},
}),
);
expect(collection.freshness).toBe('current');
await act(async () => {
await collection.revalidate();
});
expect(latest?.freshness).toBe('stale');
expect(latest?.data).toEqual(taskFixtures);
const failure: FreshnessFailure | null = latest?.failure ?? null;
expect(failure).toEqual({ kind: 'fetch', message: 'connection lost' });
});
});
@@ -0,0 +1,281 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
acceptSnapshot,
assertMutable,
computeFreshness,
DEFAULT_FRESHNESS_POLICY,
invalidationReasonLabels,
type FreshPayload,
type FreshSnapshot,
type FreshnessPolicy,
type FreshnessState,
type InvalidationReason,
StaleMutationError,
} from './model';
import { clearSnapshotCache, readSnapshotCache, writeSnapshotCache } from './snapshot-cache';
/**
* Freshness-aware collection fetch hook (RI-5-001).
*
* One hook owns one gateway collection end to end: fetch, schema validation,
* snapshot acceptance with provenance, session-scoped last-known caching,
* aging, and the mutation guard. Pages consume `freshness` and never infer
* health from emptiness.
*/
/** Why the latest validation did not produce a current snapshot. */
export type FreshnessFailure =
| { readonly kind: 'fetch'; readonly message: string }
| { readonly kind: 'invalidated'; readonly reason: InvalidationReason };
export interface UseFreshCollectionOptions<T> {
/** Source identity for provenance labels, e.g. `gateway:/api/tasks`. */
readonly source: string;
/** Performs the unvalidated fetch. The hook owns abort and verification. */
readonly fetcher: (signal: AbortSignal) => Promise<unknown>;
/**
* Runtime schema validator. Returning `null` invalidates the payload
* (`schema-mismatch`) instead of letting malformed JSON flow into render.
*/
readonly validate: (value: unknown) => FreshPayload<T> | null;
/** Overrides of the default freshness policy. */
readonly policy?: Partial<FreshnessPolicy>;
/**
* Session cache key for last-known snapshots. `null`/omitted disables
* restore. Restored snapshots are unverified: they render only as
* labeled `stale` data until a fetch re-verifies them.
*/
readonly cacheKey?: string | null;
/** Injectable clock for deterministic age transitions in tests. */
readonly clock?: () => number;
/** Aging tick interval override (default derived from `staleAfterMs`). */
readonly tickMs?: number;
/** When false, no fetch runs (surfaces stay `unavailable`/`unknown`). */
readonly enabled?: boolean;
}
export interface FreshCollection<T> {
/** Last verified (or restored-unverified) snapshot, or `null`. */
readonly snapshot: FreshSnapshot<T> | null;
/** Snapshot data or `null` — never a fabricated empty collection. */
readonly data: T | null;
readonly freshness: FreshnessState;
/** True while a validation request is in flight. */
readonly validating: boolean;
/** Outcome of the latest failed validation, `null` when healthy. */
readonly failure: FreshnessFailure | null;
/** False unless freshness is `current`; drives disabled UI affordances. */
readonly canMutate: boolean;
/** Re-run the fetch and re-verify. Always allowed (it is a read). */
readonly revalidate: () => Promise<void>;
/**
* Run a state-changing operation against verified-current data only.
* Rejects with `StaleMutationError` on any other state — the guard fires
* even if a disabled button was bypassed (defense in depth).
*/
readonly mutate: <R>(operation: (data: T) => Promise<R>) => Promise<R>;
}
const defaultClock = (): number => Date.now();
function resolveTickMs(policy: FreshnessPolicy, override?: number): number {
if (override !== undefined && override > 0) return override;
return Math.min(5_000, Math.max(250, Math.floor(policy.staleAfterMs / 4)));
}
function isAuthFailure(caught: unknown): boolean {
return (
typeof caught === 'object' &&
caught !== null &&
'statusCode' in caught &&
((caught as { statusCode?: unknown }).statusCode === 401 ||
(caught as { statusCode?: unknown }).statusCode === 403)
);
}
function fetchFailureMessage(caught: unknown): string {
if (caught instanceof Error && caught.message.trim().length > 0) return caught.message;
return 'The request failed.';
}
/** Human-readable summary of a failure for unavailable/stale notices. */
export function describeFailure(failure: FreshnessFailure | null): string | null {
if (failure === null) return null;
if (failure.kind === 'fetch') return failure.message;
return `The snapshot was invalidated: ${invalidationReasonLabels[failure.reason]}.`;
}
export function useFreshCollection<T>(options: UseFreshCollectionOptions<T>): FreshCollection<T> {
const optionsRef = useRef(options);
optionsRef.current = options;
const policy = useMemo<FreshnessPolicy>(
() => ({ ...DEFAULT_FRESHNESS_POLICY, ...options.policy }),
[options.policy],
);
const policyRef = useRef(policy);
policyRef.current = policy;
const clockRef = useRef(options.clock ?? defaultClock);
clockRef.current = options.clock ?? defaultClock;
const [snapshot, setSnapshot] = useState<FreshSnapshot<T> | null>(null);
const [failure, setFailure] = useState<FreshnessFailure | null>(null);
const [unverified, setUnverified] = useState(false);
const [validating, setValidating] = useState(options.enabled !== false);
const [now, setNow] = useState(() => (options.clock ?? defaultClock)());
const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot;
const failureRef = useRef(failure);
failureRef.current = failure;
const unverifiedRef = useRef(unverified);
unverifiedRef.current = unverified;
const runRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const revalidate = useCallback(async (): Promise<void> => {
const current = optionsRef.current;
if (current.enabled === false) {
setValidating(false);
return;
}
const runId = ++runRef.current;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setValidating(true);
let value: unknown;
try {
value = await current.fetcher(controller.signal);
} catch (caught) {
if (runRef.current !== runId || controller.signal.aborted) return;
if (isAuthFailure(caught)) {
// An unauthenticated viewer must not keep (or be served) the
// previous user's last-known data.
setSnapshot(null);
setUnverified(false);
if (current.cacheKey) clearSnapshotCache(current.cacheKey);
}
setFailure({ kind: 'fetch', message: fetchFailureMessage(caught) });
setValidating(false);
return;
}
if (runRef.current !== runId) return;
const result = acceptSnapshot({
value,
validate: current.validate,
previous: snapshotRef.current,
policy: policyRef.current,
source: current.source,
now: clockRef.current(),
});
if (result.outcome === 'accepted') {
setSnapshot(result.snapshot);
setUnverified(false);
setFailure(null);
if (current.cacheKey) writeSnapshotCache(current.cacheKey, result.snapshot);
} else {
if (result.reason === 'cross-workspace') {
// Data verified for a different workspace must not linger as
// last-known situational awareness either.
setSnapshot(null);
setUnverified(false);
}
if (current.cacheKey) clearSnapshotCache(current.cacheKey);
setFailure({ kind: 'invalidated', reason: result.reason });
}
setValidating(false);
}, []);
// Restore the last-known snapshot (unverified) and run the first fetch.
useEffect(() => {
if (optionsRef.current.enabled === false) {
setValidating(false);
return;
}
const cacheKey = optionsRef.current.cacheKey;
if (cacheKey) {
const restored = readSnapshotCache<T>({
key: cacheKey,
workspace: policyRef.current.workspace,
policy: policyRef.current,
validate: optionsRef.current.validate,
});
if (restored.outcome === 'hit') {
setSnapshot(restored.snapshot);
setUnverified(true);
} else if (restored.outcome === 'invalidated') {
// A corrupted/foreign/regressed entry is dropped immediately; it must
// never surface as data. The fetch decides the visible state.
clearSnapshotCache(cacheKey);
}
}
void revalidate();
return () => {
abortRef.current?.abort();
};
// Mount-once by design: `revalidate` is stable and reads live options
// through refs, so it never needs to re-run when options change.
// Route-param pages remount this hook via an identity `key` instead.
}, [revalidate]);
// Aging tick: recomputes freshness as the snapshot ages past the policy.
useEffect(() => {
const interval = setInterval(
() => {
setNow(clockRef.current());
},
resolveTickMs(policyRef.current, optionsRef.current.tickMs),
);
return () => clearInterval(interval);
}, []);
const freshness = useMemo<FreshnessState>(() => {
if (snapshot === null) return validating ? 'unknown' : 'unavailable';
return computeFreshness({
snapshot,
policy,
now,
degraded: failure !== null || unverified,
});
// `now` from state covers age; refs inside computeFreshness are pure.
}, [snapshot, validating, failure, unverified, now, policy]);
const canMutate = freshness === 'current';
const mutate = useCallback(async <R>(operation: (data: T) => Promise<R>): Promise<R> => {
const currentSnapshot = snapshotRef.current;
// No verified snapshot at all: with nothing verified there is nothing
// current to mutate, regardless of the recorded failure.
if (currentSnapshot === null) throw new StaleMutationError('unavailable');
const state = computeFreshness({
snapshot: currentSnapshot,
policy: policyRef.current,
now: clockRef.current(),
degraded: failureRef.current !== null || unverifiedRef.current,
});
assertMutable(state);
return operation(currentSnapshot.data);
}, []);
return {
snapshot,
data: snapshot === null ? null : snapshot.data,
freshness,
validating,
failure,
canMutate,
revalidate,
mutate,
};
}
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';
import type { Mission, Project, Task } from '@/lib/types';
import {
validateMissionCollection,
validateProjectCollection,
validateProjectEntity,
validateTaskCollection,
} from './validators';
import { missionFixtures, projectFixtures, taskFixtures } from '@/spa/pages/page-fixtures';
describe('validateTaskCollection', () => {
it('accepts a well-formed task collection', () => {
expect(validateTaskCollection(taskFixtures)).toEqual({
data: taskFixtures,
workspace: null,
});
});
it('accepts an empty collection (a healthy empty state is a valid payload)', () => {
expect(validateTaskCollection([])).toEqual({ data: [], workspace: null });
});
it.each([
['not an array', { items: [] }],
['item is not an object', ['nope']],
['missing id', [{ ...(taskFixtures[0] as Task), id: undefined }]],
['missing title', [{ ...(taskFixtures[0] as Task), title: undefined }]],
['unknown status enum', [{ ...(taskFixtures[0] as Task), status: 'finished' }]],
['unknown priority enum', [{ ...(taskFixtures[0] as Task), priority: 'urgent' }]],
['tags of the wrong type', [{ ...(taskFixtures[0] as Task), tags: 'spa' }]],
['metadata of the wrong type', [{ ...(taskFixtures[0] as Task), metadata: 'notes' }]],
['createdAt of the wrong type', [{ ...(taskFixtures[0] as Task), createdAt: 1234 }]],
['null sneaks past a required string', [{ ...(taskFixtures[0] as Task), title: null }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateTaskCollection(value)).toBeNull();
});
});
describe('validateMissionCollection', () => {
it('accepts a well-formed mission collection', () => {
expect(validateMissionCollection(missionFixtures)).toEqual({
data: missionFixtures,
workspace: null,
});
});
it.each([
['not an array', null],
['item missing name', [{ ...(missionFixtures[0] as Mission), name: 42 }]],
['unknown status enum', [{ ...(missionFixtures[0] as Mission), status: 'canceled' }]],
['projectId of the wrong type', [{ ...(missionFixtures[0] as Mission), projectId: 7 }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateMissionCollection(value)).toBeNull();
});
});
describe('validateProjectCollection', () => {
it('accepts a uniform workspace-scoped collection and reports its workspace', () => {
expect(validateProjectCollection(projectFixtures)).toEqual({
data: projectFixtures,
workspace: 'user-1',
});
});
it('accepts an empty collection with no workspace identity', () => {
expect(validateProjectCollection([])).toEqual({ data: [], workspace: null });
});
it.each([
['not an array', 42],
['item missing userId', [{ ...(projectFixtures[0] as Project), userId: undefined }]],
['unknown status enum', [{ ...(projectFixtures[0] as Project), status: 'live' }]],
['description of the wrong type', [{ ...(projectFixtures[0] as Project), description: 1 }]],
])('rejects a malformed payload: %s', (_label, value) => {
expect(validateProjectCollection(value)).toBeNull();
});
it('rejects a collection mixing workspace identities (cross-workspace leak)', () => {
const mixed = [
projectFixtures[0] as Project,
{ ...(projectFixtures[1] as Project), userId: 'user-2' },
];
expect(validateProjectCollection(mixed)).toBeNull();
});
});
describe('validateProjectEntity', () => {
it('accepts a well-formed project and reports its workspace', () => {
expect(validateProjectEntity(projectFixtures[0])).toEqual({
data: projectFixtures[0],
workspace: 'user-1',
});
});
it.each([
['not an object', 'project-1'],
['null', null],
['array', [projectFixtures[0]]],
['missing userId', [{ ...(projectFixtures[0] as Project), userId: null }]],
])('rejects a malformed entity: %s', (_label, value) => {
expect(validateProjectEntity(value)).toBeNull();
});
});
+135
View File
@@ -0,0 +1,135 @@
import type { Mission, Project, Task, MissionStatus, TaskPriority, TaskStatus } from '@/lib/types';
import type { FreshPayload } from './model';
/**
* Runtime schema validators for gateway collections (RI-5-001).
*
* `api<T>()` returns untrusted JSON cast to `T`; these validators are the
* seam where a malformed response becomes an explicit schema mismatch
* instead of flowing into the render path as if it were healthy data.
*/
const taskStatuses: readonly TaskStatus[] = [
'not-started',
'in-progress',
'blocked',
'done',
'cancelled',
];
const taskPriorities: readonly TaskPriority[] = ['critical', 'high', 'medium', 'low'];
const missionStatuses: readonly MissionStatus[] = [
'planning',
'active',
'paused',
'completed',
'failed',
];
const projectStatuses: readonly Project['status'][] = ['active', 'paused', 'completed', 'archived'];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isString(value: unknown): value is string {
return typeof value === 'string';
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}
function isOneOf<T extends string>(value: unknown, allowed: readonly T[]): value is T {
return typeof value === 'string' && (allowed as readonly string[]).includes(value);
}
function isNullableRecord(value: unknown): value is Record<string, unknown> | null {
return value === null || isRecord(value);
}
function isNullableStringArray(value: unknown): value is string[] | null {
if (value === null) return true;
if (!Array.isArray(value)) return false;
return value.every((item) => typeof item === 'string');
}
function isIsoLike(value: unknown): value is string {
return typeof value === 'string' && value.length > 0;
}
function isTask(value: unknown): value is Task {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['title']) &&
isOneOf(value['status'], taskStatuses) &&
isOneOf(value['priority'], taskPriorities) &&
isNullableString(value['projectId']) &&
isNullableString(value['missionId']) &&
isNullableString(value['assignee']) &&
isNullableStringArray(value['tags']) &&
isNullableRecord(value['metadata']) &&
isNullableString(value['dueDate']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/** Tasks carry no workspace identity; scope falls back to the policy. */
export function validateTaskCollection(value: unknown): FreshPayload<Task[]> | null {
if (!Array.isArray(value) || !value.every(isTask)) return null;
return { data: value as Task[], workspace: null };
}
function isMission(value: unknown): value is Mission {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['name']) &&
isOneOf(value['status'], missionStatuses) &&
isNullableString(value['projectId']) &&
isNullableString(value['description']) &&
isNullableRecord(value['metadata']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/** Missions carry no workspace identity; scope falls back to the policy. */
export function validateMissionCollection(value: unknown): FreshPayload<Mission[]> | null {
if (!Array.isArray(value) || !value.every(isMission)) return null;
return { data: value as Mission[], workspace: null };
}
function isProject(value: unknown): value is Project {
if (!isRecord(value)) return false;
return (
isString(value['id']) &&
isString(value['name']) &&
isOneOf(value['status'], projectStatuses) &&
isString(value['userId']) &&
isNullableString(value['description']) &&
isNullableRecord(value['metadata']) &&
isIsoLike(value['createdAt']) &&
isIsoLike(value['updatedAt'])
);
}
/**
* Projects are workspace-scoped: every item must carry the same `userId`.
* A collection mixing identities (cross-workspace leak) is a schema
* mismatch; the uniform `userId` becomes the snapshot workspace.
*/
export function validateProjectCollection(value: unknown): FreshPayload<Project[]> | null {
if (!Array.isArray(value) || !value.every(isProject)) return null;
const projects = value as Project[];
const workspaces = new Set(projects.map((project) => project.userId));
if (workspaces.size > 1) return null;
return { data: projects, workspace: projects.length > 0 ? projects[0]!.userId : null };
}
/** Single project entity (project detail primary collection). */
export function validateProjectEntity(value: unknown): FreshPayload<Project> | null {
if (!isProject(value)) return null;
const project = value as Project;
return { data: project, workspace: project.userId };
}
+171 -27
View File
@@ -35,6 +35,7 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderProjectDetailPage(): Promise<ReturnType<typeof createMemoryRouter>> {
@@ -64,21 +65,49 @@ function clickButtonByText(text: string): void {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
const projectOneTasks = taskFixtures.filter((task) => task.projectId === 'project-1');
function mockHealthyLoad(): void {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
}
describe('ProjectDetailPage', () => {
it('loads the project, tasks, missions, and optional PRD content for the active project', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
mockHealthyLoad();
await renderProjectDetailPage();
expect(apiMock.mock.calls).toEqual([
['/api/projects/project-1'],
['/api/missions'],
['/api/tasks?projectId=project-1'],
expect(apiMock.mock.calls.map((call) => call[0])).toEqual([
'/api/projects/project-1',
'/api/missions',
'/api/tasks?projectId=project-1',
]);
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Route /projects/:id');
expect(container.textContent).toContain('Tasks');
@@ -101,10 +130,7 @@ describe('ProjectDetailPage', () => {
});
it('opens and closes the existing read-only task modal from the tasks tab', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
mockHealthyLoad();
await renderProjectDetailPage();
@@ -134,35 +160,153 @@ describe('ProjectDetailPage', () => {
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it('renders the project with an empty missions tab when the missions request fails', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockRejectedValueOnce(new Error('Missions request failed'))
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
it('shows verified completion verdicts when the task collection is current', async () => {
mockHealthyLoad();
await renderProjectDetailPage();
expect(container.textContent).toContain('Mosaic Stack');
expect(container.querySelector('[role="alert"]')).toBeNull();
await act(async () => {
clickButtonByText('Missions (0)');
});
expect(container.textContent).toContain('No missions for this project');
const doneCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === 'Done1',
);
expect(doneCard).toBeTruthy();
const inProgressCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === 'In Progress1',
);
expect(inProgressCard).toBeTruthy();
});
it('renders a visible alert when the project request fails and lets the user navigate back', async () => {
it('renders an explicit unavailable missions tab when the missions request fails (partial, not empty)', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockRejectedValueOnce(new Error('Missions request failed'))
.mockResolvedValueOnce(projectOneTasks);
await renderProjectDetailPage();
// Secondary failure degrades the surface to partial; the project itself
// still renders.
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
expect(container.textContent).toContain('Mosaic Stack');
const partial = container.querySelector('[role="status"]');
expect(partial?.textContent).toContain('Missions');
expect(partial?.textContent).toContain('unavailable');
await act(async () => {
clickButtonByText('Missions (?)');
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Missions request failed');
// Negative control: a failed fetch must not look like an empty list.
expect(container.textContent).not.toContain('No missions for this project');
});
it('marks derived verdicts unknown when the tasks collection is unavailable', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderProjectDetailPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
// Completion verdicts become unknown ('?') — never green counts.
for (const label of ['Done', 'In Progress', 'Blocked', 'Tasks']) {
const unknownCard = [...container.querySelectorAll('div')].find(
(candidate) => candidate.textContent === `${label}?`,
);
expect(unknownCard, `expected ${label} card to render ?`).toBeTruthy();
}
// Negative control: no green "Done 1" verdict anywhere.
expect(
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
).toBe(false);
await act(async () => {
clickButtonByText('Tasks (?)');
});
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Tasks request failed');
// Negative control: no healthy empty task list from a failed fetch.
expect(container.textContent).not.toContain('No tasks found');
expect(container.querySelector('table')).toBeNull();
});
it('recovers a partial surface to current after revalidation', async () => {
apiMock
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockRejectedValueOnce(new Error('Tasks request failed'))
.mockResolvedValueOnce(projectFixtures[0])
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(projectOneTasks);
await renderProjectDetailPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'partial',
);
await act(async () => {
clickButtonByText('Revalidate');
});
await flushAct();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
expect(
[...container.querySelectorAll('div')].some((candidate) => candidate.textContent === 'Done1'),
).toBe(true);
});
it("never shows one project's data on another project's route after navigation", async () => {
mockHealthyLoad();
const router = await renderProjectDetailPage();
expect(container.textContent).toContain('Mosaic Stack');
const deferred = createDeferred<(typeof projectFixtures)[number]>();
apiMock
.mockResolvedValueOnce(deferred.promise)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await act(async () => {
await router.navigate('/projects/project-2');
});
// While project-2 loads, nothing from project-1 may render on its route.
expect(container.textContent).toContain('Loading project...');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.textContent).not.toContain('Route /projects/:id');
await act(async () => {
deferred.resolve(projectFixtures[1]!);
await deferred.promise;
});
expect(container.textContent).toContain('Agent Runtime');
expect(apiMock.mock.calls[3]?.[0]).toBe('/api/projects/project-2');
});
it('renders a visible unavailable state when the project request fails and lets the user navigate back', async () => {
apiMock
.mockRejectedValueOnce(new Error('Project request failed'))
.mockResolvedValueOnce(missionFixtures)
.mockResolvedValueOnce(taskFixtures.filter((task) => task.projectId === 'project-1'));
.mockResolvedValueOnce(projectOneTasks);
const router = await renderProjectDetailPage();
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Project request failed');
expect(alert?.textContent).toContain('not an empty result');
expect(container.textContent).not.toContain('Mosaic Stack');
await act(async () => {
+203 -90
View File
@@ -1,14 +1,30 @@
import { useEffect, useState, type ReactElement } from 'react';
import { useState, type ReactElement } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { MissionTimeline } from '@/components/projects/mission-timeline';
import { PrdViewer } from '@/components/projects/prd-viewer';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { TaskStatusSummary } from '@/components/tasks/task-status-summary';
import {
PartialDataNotice,
StaleDataNotice,
UnavailableDataNotice,
} from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Mission, Project, Task, TaskStatus } from '@/lib/types';
import { getErrorMessage } from './page-errors';
import {
combineFreshness,
UNKNOWN_VERDICT,
verdictValue,
type FreshSnapshot,
} from '@/lib/freshness/model';
import { describeFailure, useFreshCollection } from '@/lib/freshness/use-fresh-collection';
import {
validateMissionCollection,
validateProjectEntity,
validateTaskCollection,
} from '@/lib/freshness/validators';
type Tab = 'overview' | 'tasks' | 'missions' | 'prd';
@@ -51,73 +67,62 @@ function TabButton({ id, label, activeTab, onClick }: TabButtonProps): ReactElem
);
}
/** Remounts per project id so no state from one project renders for another. */
export function ProjectDetailPage(): ReactElement {
const { id = '' } = useParams();
return <ProjectDetail id={id} key={id} />;
}
function ProjectDetail({ id }: { id: string }): ReactElement {
const navigate = useNavigate();
const [project, setProject] = useState<Project | null>(null);
const [missions, setMissions] = useState<Mission[]>([]);
const [tasks, setTasks] = useState<Task[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const enabled = id.length > 0;
// Primary collection gates the surface; missions and tasks are secondaries
// whose failures degrade the surface to `partial` instead of rendering
// empty healthy lists.
const project = useFreshCollection<Project>({
source: `gateway:/api/projects/${id}`,
fetcher: (signal) => api<unknown>(`/api/projects/${id}`, { signal }),
validate: validateProjectEntity,
// No last-known restore: the entity carries workspace identity that
// cannot be scope-checked before display (see ProjectsPage note).
enabled,
});
const missions = useFreshCollection<Mission[]>({
source: 'gateway:/api/missions',
fetcher: (signal) => api<unknown>('/api/missions', { signal }),
validate: validateMissionCollection,
cacheKey: enabled ? 'missions' : null,
enabled,
});
const tasks = useFreshCollection<Task[]>({
source: `gateway:/api/tasks?projectId=${id}`,
fetcher: (signal) => api<unknown>(`/api/tasks?projectId=${id}`, { signal }),
validate: validateTaskCollection,
cacheKey: enabled ? `project-tasks:${id}` : null,
enabled,
});
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [taskFilter, setTaskFilter] = useState<TaskStatus | 'all'>('all');
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
useEffect(() => {
if (!id) {
setError('Project id is missing.');
setLoading(false);
return;
}
const surface = combineFreshness(project.freshness, [missions.freshness, tasks.freshness]);
const tasksVerified = tasks.freshness === 'current';
const projectMissions = missions.data?.filter((mission) => mission.projectId === id) ?? null;
let cancelled = false;
setLoading(true);
setError(null);
const retryAll = (): void => {
void Promise.all([project.revalidate(), missions.revalidate(), tasks.revalidate()]);
};
void Promise.all([
api<Project>('/api/projects/' + id),
api<Mission[]>('/api/missions').catch(() => [] as Mission[]),
api<Task[]>('/api/tasks?projectId=' + id).catch(() => [] as Task[]),
])
.then(([loadedProject, allMissions, loadedTasks]) => {
if (cancelled) return;
setProject(loadedProject);
setMissions(allMissions.filter((mission) => mission.projectId === id));
setTasks(loadedTasks);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load project.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, [id]);
if (loading) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
</div>
);
}
if (error || !project) {
if (!enabled) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<div role="alert" className="rounded-lg border border-error/40 px-4 py-3 text-sm">
{error ?? 'Project not found.'}
Project id is missing.
</div>
<button
type="button"
@@ -130,18 +135,81 @@ export function ProjectDetailPage(): ReactElement {
);
}
if (project.freshness === 'unknown') {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<p className="py-16 text-center text-sm text-text-muted">Loading project...</p>
</div>
);
}
if (project.freshness === 'unavailable' || project.data === null) {
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Project</h1>
</header>
<UnavailableDataNotice
title="This project"
detail={describeFailure(project.failure)}
onRetry={retryAll}
/>
<button
type="button"
onClick={() => navigate('/projects')}
className="mt-4 w-fit text-sm underline"
>
Back to projects
</button>
</div>
);
}
const projectTasks = tasks.data ?? null;
const filteredTasks =
taskFilter === 'all' ? tasks : tasks.filter((task) => task.status === taskFilter);
const prdContent = getPrdContent(project);
projectTasks === null
? []
: taskFilter === 'all'
? projectTasks
: projectTasks.filter((task) => task.status === taskFilter);
// Derived completion verdicts: unknown (never green) unless the task
// collection is verified current.
const doneCount = projectTasks?.filter((task) => task.status === 'done').length ?? 0;
const inProgressCount = projectTasks?.filter((task) => task.status === 'in-progress').length ?? 0;
const blockedCount = projectTasks?.filter((task) => task.status === 'blocked').length ?? 0;
const prdContent = getPrdContent(project.data);
const tabs: Array<{ id: Tab; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'tasks', label: `Tasks (${tasks.length})` },
{ id: 'missions', label: `Missions (${missions.length})` },
{
id: 'tasks',
label: `Tasks (${projectTasks === null ? UNKNOWN_VERDICT : projectTasks.length})`,
},
{
id: 'missions',
label: `Missions (${projectMissions === null ? UNKNOWN_VERDICT : projectMissions.length})`,
},
...(prdContent ? [{ id: 'prd' as const, label: 'PRD' }] : []),
];
const staleSnapshot: FreshSnapshot<unknown> | null =
project.freshness === 'stale'
? project.snapshot
: missions.freshness === 'stale'
? missions.snapshot
: tasks.freshness === 'stale'
? tasks.snapshot
: null;
const missingSections: string[] = [];
if (missions.freshness === 'unavailable') missingSections.push('Missions');
if (tasks.freshness === 'unavailable') missingSections.push('Tasks');
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<div data-freshness={surface} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 border-b px-1 pb-3">
<nav className="mb-4 flex items-center gap-2 text-sm text-text-muted">
<button
@@ -152,49 +220,64 @@ export function ProjectDetailPage(): ReactElement {
Projects
</button>
<span>/</span>
<span className="text-text-primary">{project.name}</span>
<span className="text-text-primary">{project.data.name}</span>
</nav>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-semibold text-text-primary">{project.name}</h1>
<h1 className="text-2xl font-semibold text-text-primary">{project.data.name}</h1>
<span
className={cn(
'rounded-full px-2 py-0.5 text-xs',
projectStatusColors[project.status] ?? 'bg-gray-600/20 text-gray-400',
projectStatusColors[project.data.status] ?? 'bg-gray-600/20 text-gray-400',
)}
>
{project.status}
{project.data.status}
</span>
</div>
{project.description ? (
<p className="mt-1 text-sm text-text-muted">{project.description}</p>
{project.data.description ? (
<p className="mt-1 text-sm text-text-muted">{project.data.description}</p>
) : null}
<p className="mt-2 text-xs text-text-muted">
Created {new Date(project.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.updatedAt).toLocaleDateString()}
Created {new Date(project.data.createdAt).toLocaleDateString()} · Updated{' '}
{new Date(project.data.updatedAt).toLocaleDateString()}
</p>
</div>
</div>
</header>
{staleSnapshot !== null ? (
<div className="mb-6">
<StaleDataNotice label={staleSnapshot} onRetry={retryAll} />
</div>
) : null}
{missingSections.length > 0 ? (
<div className="mb-6">
<PartialDataNotice missing={missingSections} onRetry={retryAll} />
</div>
) : null}
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Tasks" value={String(tasks.length)} />
<StatCard
label="Tasks"
value={projectTasks === null ? UNKNOWN_VERDICT : String(projectTasks.length)}
/>
<StatCard
label="Done"
value={String(tasks.filter((task) => task.status === 'done').length)}
valueClass="text-success"
value={verdictValue(tasksVerified, String(doneCount))}
valueClass={tasksVerified ? 'text-success' : undefined}
/>
<StatCard
label="In Progress"
value={String(tasks.filter((task) => task.status === 'in-progress').length)}
valueClass="text-blue-400"
value={verdictValue(tasksVerified, String(inProgressCount))}
valueClass={tasksVerified ? 'text-blue-400' : undefined}
/>
<StatCard
label="Blocked"
value={String(tasks.filter((task) => task.status === 'blocked').length)}
valueClass={tasks.some((task) => task.status === 'blocked') ? 'text-error' : undefined}
value={verdictValue(tasksVerified, String(blockedCount))}
valueClass={tasksVerified && blockedCount > 0 ? 'text-error' : undefined}
/>
</div>
@@ -211,23 +294,43 @@ export function ProjectDetailPage(): ReactElement {
</div>
{activeTab === 'overview' ? (
<OverviewTab project={project} missions={missions} tasks={tasks} />
<OverviewTab project={project.data} missions={projectMissions} tasks={projectTasks} />
) : null}
{activeTab === 'tasks' ? (
<div>
<div className="mb-4">
<TaskStatusSummary
tasks={tasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
{projectTasks === null ? (
<UnavailableDataNotice
title="Tasks"
detail={describeFailure(tasks.failure)}
onRetry={retryAll}
/>
</div>
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
) : (
<>
<div className="mb-4">
<TaskStatusSummary
tasks={projectTasks}
activeFilter={taskFilter}
onFilterChange={setTaskFilter}
/>
</div>
<TaskListView tasks={filteredTasks} onTaskClick={setSelectedTask} />
</>
)}
</div>
) : null}
{activeTab === 'missions' ? <MissionTimeline missions={missions} /> : null}
{activeTab === 'missions' ? (
projectMissions === null ? (
<UnavailableDataNotice
title="Missions"
detail={describeFailure(missions.failure)}
onRetry={retryAll}
/>
) : (
<MissionTimeline missions={projectMissions} />
)
) : null}
{activeTab === 'prd' && prdContent ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-6">
@@ -248,18 +351,26 @@ function OverviewTab({
tasks,
}: {
project: Project;
missions: Mission[];
tasks: Task[];
missions: Mission[] | null;
tasks: Task[] | null;
}): ReactElement {
const recentTasks = [...tasks]
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime())
.slice(0, 5);
const recentTasks =
tasks === null
? null
: [...tasks]
.sort(
(left, right) =>
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
)
.slice(0, 5);
return (
<div className="grid gap-6 lg:grid-cols-2">
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Recent Tasks</h2>
{recentTasks.length === 0 ? (
{recentTasks === null ? (
<UnavailableDataNotice title="Tasks" />
) : recentTasks.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No tasks yet</p>
</div>
@@ -287,7 +398,9 @@ function OverviewTab({
<section>
<h2 className="mb-3 text-sm font-semibold text-text-secondary">Missions</h2>
{missions.length === 0 ? (
{missions === null ? (
<UnavailableDataNotice title="Missions" />
) : missions.length === 0 ? (
<div className="rounded-lg border border-surface-border bg-surface-card p-4 text-center">
<p className="text-sm text-text-muted">No missions yet</p>
</div>
+69 -3
View File
@@ -51,6 +51,7 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRouter>> {
@@ -71,6 +72,22 @@ async function renderProjectsPage(): Promise<ReturnType<typeof createMemoryRoute
return router;
}
function clickButtonByText(text: string): void {
const button = [...container.querySelectorAll('button')].find((candidate) =>
candidate.textContent?.includes(text),
);
if (!button) {
throw new Error(`Button containing "${text}" not found`);
}
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe('ProjectsPage', () => {
it('shows a visible loading state while the project request is in flight', async () => {
const deferred = createDeferred<typeof projectFixtures>();
@@ -91,7 +108,7 @@ describe('ProjectsPage', () => {
const router = await renderProjectsPage();
expect(apiMock).toHaveBeenCalledWith('/api/projects');
expect(apiMock.mock.calls[0]?.[0]).toBe('/api/projects');
expect(container.textContent).toContain('Mosaic Stack');
expect(container.textContent).toContain('Agent Runtime');
@@ -108,7 +125,7 @@ describe('ProjectsPage', () => {
expect(container.textContent).toContain('Project detail target');
});
it('renders the empty state when the API returns no projects', async () => {
it('renders the empty state only for a verified empty collection', async () => {
apiMock.mockResolvedValueOnce([]);
await renderProjectsPage();
@@ -117,9 +134,12 @@ describe('ProjectsPage', () => {
expect(container.textContent).toContain(
'Projects will appear here when created via the gateway API',
);
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('renders a visible alert when the projects request fails', async () => {
it('renders a failed fetch as an explicit unavailable state, never an empty collection', async () => {
apiMock.mockRejectedValueOnce(new Error('Projects are unavailable'));
await renderProjectsPage();
@@ -127,5 +147,51 @@ describe('ProjectsPage', () => {
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Projects are unavailable');
expect(alert?.textContent).toContain('not an empty result');
// Negative controls: no healthy empty state and no project cards render
// from a failed fetch.
expect(container.textContent).not.toContain('No projects yet');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
it('renders an auth failure as unavailable and recovers after retry', async () => {
apiMock
.mockRejectedValueOnce(Object.assign(new Error('Unauthorized'), { statusCode: 401 }))
.mockResolvedValueOnce(projectFixtures);
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('Unauthorized');
expect(container.textContent).not.toContain('No projects yet');
await act(async () => {
clickButtonByText('Retry');
});
await flushAct();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).toContain('Mosaic Stack');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('renders a schema-mismatched response as unavailable, never as data', async () => {
apiMock.mockResolvedValueOnce({ results: projectFixtures });
await renderProjectsPage();
const alert = container.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('not an empty result');
expect(container.textContent).not.toContain('Mosaic Stack');
expect(container.textContent).not.toContain('No projects yet');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
});
+32 -34
View File
@@ -1,53 +1,51 @@
import { useEffect, useState, type ReactElement } from 'react';
import { type ReactElement } from 'react';
import { useNavigate } from 'react-router-dom';
import { ProjectCard } from '@/components/projects/project-card';
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import type { Project } from '@/lib/types';
import { getErrorMessage } from './page-errors';
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
import { validateProjectCollection } from '@/lib/freshness/validators';
export function ProjectsPage(): ReactElement {
const navigate = useNavigate();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void api<Project[]>('/api/projects')
.then((response) => {
if (cancelled) return;
setProjects(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load projects.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const projects = useFreshCollection<Project[]>({
source: 'gateway:/api/projects',
fetcher: (signal) => api<unknown>('/api/projects', { signal }),
validate: validateProjectCollection,
// Projects carry workspace identity (userId) that is only knowable from
// the payload itself, so a restored entry cannot be scope-checked before
// display. Conservative choice: no last-known restore for this surface;
// cross-workspace switching is still invalidated at verification time.
});
const retry = (): void => {
void projects.revalidate();
};
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<div
data-freshness={projects.freshness}
className="flex min-h-screen flex-col px-4 py-6 sm:px-6"
>
<header className="mb-6 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Projects</h1>
</header>
{error ? (
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
{projects.freshness === 'stale' && projects.snapshot ? (
<div className="mb-6">
<StaleDataNotice label={projects.snapshot} onRetry={retry} />
</div>
) : null}
{loading ? (
{projects.freshness === 'unknown' ? (
<p className="py-8 text-center text-sm text-text-muted">Loading projects...</p>
) : projects.length === 0 ? (
) : projects.freshness === 'unavailable' ? (
<UnavailableDataNotice
title="Projects"
detail={describeFailure(projects.failure)}
onRetry={retry}
/>
) : projects.data !== null && projects.data.length === 0 ? (
<div className="py-12 text-center">
<h2 className="text-lg font-medium text-text-secondary">No projects yet</h2>
<p className="mt-1 text-sm text-text-muted">
@@ -56,7 +54,7 @@ export function ProjectsPage(): ReactElement {
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{projects.map((project) => (
{(projects.data ?? []).map((project) => (
<ProjectCard
key={project.id}
project={project}
+87 -1
View File
@@ -3,6 +3,9 @@ import { createRoot, type Root } from 'react-dom/client';
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { taskFixtures } from './page-fixtures';
import { acceptSnapshot, DEFAULT_FRESHNESS_POLICY } from '@/lib/freshness/model';
import { writeSnapshotCache } from '@/lib/freshness/snapshot-cache';
import { validateTaskCollection } from '@/lib/freshness/validators';
const { apiMock } = vi.hoisted(() => ({
apiMock: vi.fn(),
@@ -48,6 +51,7 @@ afterEach(async () => {
document.body.replaceChildren();
root = null;
apiMock.mockReset();
sessionStorage.clear();
});
async function renderTasksPage(): Promise<void> {
@@ -72,6 +76,13 @@ function clickButtonByText(text: string): void {
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
}
/** Flush pending promise callbacks inside the act environment. */
async function flushAct(): Promise<void> {
await act(async () => {
await Promise.resolve();
});
}
describe('TasksPage', () => {
it('shows a visible loading state before the tasks request settles', async () => {
const deferred = createDeferred<typeof taskFixtures>();
@@ -132,7 +143,7 @@ describe('TasksPage', () => {
expect(container.textContent).toContain('Wire list and kanban modal interactions');
});
it('renders a visible alert when the tasks request fails', async () => {
it('renders a failed fetch as an explicit unavailable state, never an empty healthy board', async () => {
apiMock.mockRejectedValueOnce(new Error('Tasks request failed'));
await renderTasksPage();
@@ -140,5 +151,80 @@ describe('TasksPage', () => {
const alert = container.querySelector('[role="alert"]');
expect(alert).toBeTruthy();
expect(alert?.textContent).toContain('Tasks request failed');
expect(alert?.textContent).toContain('not an empty result');
// Negative controls: no board, no healthy empty-state markers, and the
// surface is marked unavailable rather than current.
expect(container.textContent).not.toContain('Not Started');
expect(container.textContent).not.toContain('No tasks');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'unavailable',
);
});
it('recovers to a current board after retrying a failed fetch', async () => {
apiMock
.mockRejectedValueOnce(new Error('Tasks request failed'))
.mockResolvedValueOnce(taskFixtures);
await renderTasksPage();
expect(container.querySelector('[role="alert"]')).toBeTruthy();
await act(async () => {
clickButtonByText('Retry');
});
await flushAct();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).toContain('Not Started');
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
it('labels restored last-known data as stale with source, version, and age until verified', async () => {
// Seed a last-known snapshot fetched five minutes ago; the page must
// render it only under an explicit staleness label while the fetch is
// still in flight.
const restored = acceptSnapshot({
value: taskFixtures,
validate: validateTaskCollection,
previous: null,
policy: DEFAULT_FRESHNESS_POLICY,
source: 'gateway:/api/tasks',
now: Date.now() - 5 * 60_000,
});
if (restored.outcome !== 'accepted') throw new Error('fixture setup failed');
writeSnapshotCache('tasks', restored.snapshot);
const deferred = createDeferred<typeof taskFixtures>();
apiMock.mockReturnValueOnce(deferred.promise);
await renderTasksPage();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'stale',
);
const banner = container.querySelector('[role="status"]');
expect(banner?.textContent).toContain('last-known');
expect(banner?.textContent).toContain('may be out of date');
expect(banner?.textContent).toContain('gateway:/api/tasks');
expect(banner?.textContent).toContain('snapshot v1');
expect(banner?.textContent).toContain('5m ago');
// Last-known data still renders as situational awareness under the label.
expect(container.textContent).toContain('Route /tasks');
expect(container.textContent).not.toContain('Loading tasks...');
// Verification lands: the banner clears and the surface becomes current.
await act(async () => {
deferred.resolve(taskFixtures);
await deferred.promise;
});
expect(container.querySelector('[role="status"]')).toBeNull();
expect(container.querySelector('[data-freshness]')?.getAttribute('data-freshness')).toBe(
'current',
);
});
});
+26 -33
View File
@@ -1,45 +1,32 @@
import { useEffect, useState, type ReactElement } from 'react';
import { useState, type ReactElement } from 'react';
import { KanbanBoard } from '@/components/tasks/kanban-board';
import { TaskDetailModal } from '@/components/tasks/task-detail-modal';
import { TaskListView } from '@/components/tasks/task-list-view';
import { StaleDataNotice, UnavailableDataNotice } from '@/components/freshness/freshness-notices';
import { api } from '@/lib/api';
import { cn } from '@/lib/cn';
import type { Task } from '@/lib/types';
import { getErrorMessage } from './page-errors';
import { useFreshCollection, describeFailure } from '@/lib/freshness/use-fresh-collection';
import { validateTaskCollection } from '@/lib/freshness/validators';
type ViewMode = 'list' | 'kanban';
export function TasksPage(): ReactElement {
const [tasks, setTasks] = useState<Task[]>([]);
const tasks = useFreshCollection<Task[]>({
source: 'gateway:/api/tasks',
fetcher: (signal) => api<unknown>('/api/tasks', { signal }),
validate: validateTaskCollection,
cacheKey: 'tasks',
});
const [view, setView] = useState<ViewMode>('kanban');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
useEffect(() => {
let cancelled = false;
void api<Task[]>('/api/tasks')
.then((response) => {
if (cancelled) return;
setTasks(response);
})
.catch((caught: unknown) => {
if (cancelled) return;
setError(getErrorMessage(caught, 'Failed to load tasks.'));
})
.finally(() => {
if (cancelled) return;
setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
const retry = (): void => {
void tasks.revalidate();
};
return (
<div className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<div data-freshness={tasks.freshness} className="flex min-h-screen flex-col px-4 py-6 sm:px-6">
<header className="mb-6 flex items-center justify-between gap-4 border-b px-1 pb-3">
<h1 className="text-2xl font-semibold">Tasks</h1>
<div className="flex rounded-lg border border-surface-border">
@@ -70,18 +57,24 @@ export function TasksPage(): ReactElement {
</div>
</header>
{error ? (
<div role="alert" className="mb-6 rounded-lg border border-error/40 px-4 py-3 text-sm">
{error}
{tasks.freshness === 'stale' && tasks.snapshot ? (
<div className="mb-6">
<StaleDataNotice label={tasks.snapshot} onRetry={retry} />
</div>
) : null}
{loading ? (
{tasks.freshness === 'unknown' ? (
<p className="py-8 text-center text-sm text-text-muted">Loading tasks...</p>
) : tasks.freshness === 'unavailable' ? (
<UnavailableDataNotice
title="Tasks"
detail={describeFailure(tasks.failure)}
onRetry={retry}
/>
) : view === 'kanban' ? (
<KanbanBoard tasks={tasks} onTaskClick={setSelectedTask} />
<KanbanBoard tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
) : (
<TaskListView tasks={tasks} onTaskClick={setSelectedTask} />
<TaskListView tasks={tasks.data ?? []} onTaskClick={setSelectedTask} />
)}
{selectedTask ? (
+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 sitemap](../SITEMAP.md) — resolvable current navigation and authority-gated migration summary.
- [Product requirements](../PRD.md) — normative requirements, currently marked draft.
- [Operations index](operations/README.md) — current local procedures, unattended fleet first-start handling, and explicitly held operational outlines.
- [Operations index](operations/README.md) — current local procedures and explicitly held operational outlines.
- [Security index](security/README.md) — current SSO provider configuration.
## Chapter map
| Chapter | Scope | Status |
| ------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. |
| `configuration/` | Environment, provider, tier, and runtime configuration. | Scaffold only. |
| `deployment/` | Topologies, rollout, migration, and upgrade procedures. | Scaffold only. |
| [`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. |
| `recovery/` | Incident response, backup, rollback, and recovery. | Scaffold only. |
| Chapter | Scope | Status |
| ------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `installation/` | Prerequisites, installation, and first deployment. | Scaffold only. |
| `configuration/` | Environment, provider, tier, and runtime configuration. | 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. |
| [`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. |
Every promoted page must be added to this index and to [`SITEMAP.md`](../SITEMAP.md) in the same migration slice.
-1
View File
@@ -5,7 +5,6 @@
## Current procedures
- [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
@@ -1,70 +0,0 @@
# 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,7 +26,6 @@ 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.
- [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.
@@ -11,7 +11,6 @@ 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.
- [`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.
- [`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.
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.
@@ -1,81 +0,0 @@
# 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.
+35 -23
View File
@@ -146,29 +146,6 @@ lands. M0 consists only of these normative requirements, the complete task DAG,
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.
### 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)
@@ -1391,3 +1368,38 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release.
10. ASSUMPTION: **Conversations and messages get their own PG tables** (not stored in brain's entity model). They follow a chat-specific schema with proper foreign keys to users and projects. Rationale: Chat has different access patterns (streaming, pagination, search) than brain entities.
11. RESOLVED: **Pi handles all target LLM providers natively.** Anthropic, OpenAI/Codex, Z.ai, Ollama, LM Studio, and llama.cpp are all supported via Pi's built-in providers or `models.json` configuration with `openai-completions` API type. No custom provider adapters needed in @mosaicstack/agent — only configuration management.
---
## Release Integrity Workstream (RI, #1275)
### Problem and objective
At `next` 476db12b (review of 2026-08-17), publication from `next` is not bound to the full verification pipeline for the same commit: the publish pipeline's publish steps depend on `build` only, while ordinary push CI excludes `next`. Public Forge/MACP paths contain false-success placeholders: a stub executor that reports `completed` with exit zero, planning/remediation gates that execute literal `true`, a review gate that echoes an approving verdict, and a gate runner that treats empty commands and unimplemented CI-provider gates as passing. Shipping UI surfaces can render a failed fetch as an empty, healthy collection.
Objective: for alpha 0.0.50, the release cannot publish, report, or display work state that the repository has not actually verified. Decisions SDLC-D-033 through SDLC-D-038 (Jason, 2026-08-17) scope this floor; full decision text and required-behavior lists live in jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` and `data/decisions/mosaic-stack-sdlc-protocol.json`. This section restates only the normative requirements.
### Normative requirements
1. **RI-N1 Exact-commit publication verification (SDLC-D-034).** One canonical terminal verification command performs self-contained re-verification in the publish pipeline against the job's checked-out commit before any external publication effect. The command contains or invokes the complete mandatory verification set (semantic parity with the PR merge gate, including sanitization, upgrade-guard, typecheck, lint, format check, tests, and build); CI and publication do not maintain separate semantic checklists. Every publish step depends on the verification step in the executable pipeline DAG. Provider commit identity and `git rev-parse HEAD` must identify the same commit. Missing, skipped, cancelled, stale, or inconclusive checks fail closed. Documentation-only runs may skip publication but cannot bypass verification when a publication effect will occur. A negative control must prove that a broken check blocks every publish step.
2. **RI-N2 Fail-closed Forge/MACP with explicit simulation (SDLC-D-035).** Simulation requires explicit caller intent (e.g. `--simulate`) and produces a distinct typed `simulated` state that can never satisfy dependencies, acceptance criteria, gates, merge, or release. Normal execution exits nonzero with a typed capability failure when a required executor, reviewer, command, or CI provider is absent — no stub completion, no literal-`true` gates, no synthetic approvals, no empty-command passes. A manual gate with no automation enters a waiting state; it does not pass. Positive tests prove explicit simulation still works; negative controls prove simulation and every missing-provider case cannot advance lifecycle state.
3. **RI-N3 One transitional PRD authority (SDLC-D-036).** `@mosaicstack/prdy` structured storage under `docs/prdy/`, driven by `mosaic mission --plan`, is the authoritative PRD representation for the alpha. `mosaic prdy` either routes through the same application service or operates only as an explicit, named Markdown import/export adapter; `docs/PRD.md` is not a peer authority. `mission --plan` must persist the mission↔PRD linkage (mission id/version, PRD id/version, selected requirements). Markdown output is a generated view carrying source identity; editing it cannot mutate authority silently. Import is explicit, validated, and conflict-aware (proposed successor, never overwrite). Structural validity is separate from approval.
4. **RI-N4 One quality-rails evaluator (SDLC-D-037).** The TypeScript quality-rails package is the sole authoritative evaluator. A complete probe inventory maps every current TypeScript and shell check to one canonical check with disposition (preserve/strengthen/retire, each named). Effective shell enforcement probes are absorbed before their independent paths retire; expected-file presence alone is not parity. The evaluator returns typed results (`passed`/`failed`/`blocked`/`error`/`not-applicable`) with check version, subject, and reason; missing implementation, missing input, unknown check, process error, timeout, or malformed output can never become `passed` or an unqualified skip. Check definitions and policy are versioned and digested. Shell commands become thin adapters with no separate verdict logic. The canonical terminal verification command (RI-N1) invokes this evaluator rather than duplicating its logic. Contract, parity, and negative-control tests are required, plus independent review of probe equivalence.
5. **RI-N5 Consequence-aware stale UI (SDLC-D-038).** Mission Control distinguishes typed freshness states (`current`, `stale`, `partial`, `unknown`, `unavailable`) rather than inferring from empty arrays or null. A failed fetch never renders as an empty healthy collection. Last-known data may display for situational awareness only with source identity, version, and age visibly labeled; any derived completion/assurance/release verdict whose inputs are stale becomes `unknown`; all state-changing actions are disabled until fresh state loads and is revalidated. With no verified snapshot, surfaces show an explicit unavailable state. Cache corruption, cross-workspace data, schema mismatch, and version regression invalidate the snapshot. Tests cover the failure matrix (network, auth, malformed, partial, corruption, stale age, schema mismatch, recovery, stale-action rejection) with negative controls proving no case yields a current green verdict or enabled mutation.
### Acceptance criteria
- AC-RI-1: A push to `next` that fails any mandatory verification step publishes nothing (no npm package, no image), demonstrated by a checked-in negative control and by pipeline evidence on a real `next` publish run where the verification step is green and every publish step depends on it.
- AC-RI-2: With no executor/reviewer/CI provider wired, Forge and MACP normal runs exit nonzero with typed capability failures; with `--simulate`, runs complete but every result is typed `simulated` and cannot satisfy any gate, dependency, or completion state — proven by unit tests including negative controls.
- AC-RI-3: A PRD created or revised through either `mosaic mission --plan` or `mosaic prdy` resolves to one authority under `docs/prdy/` with stable identities and versions; the mission↔PRD linkage survives restart; a Markdown export is labeled as generated and cannot silently become a second writer; divergent legacy content blocks baseline claims until explicitly resolved — proven by contract tests.
- AC-RI-4: `quality-rails check` through any entry point (TS CLI, framework shell adapter) returns the same typed verdict for the same subject; the probe inventory names every legacy check's disposition; a deliberately broken probe fails closed — proven by contract/parity/negative-control tests and independent review of probe equivalence.
- AC-RI-5: No shipping surface renders a failed fetch as an empty healthy state; stale/partial/unavailable states are typed, labeled, and mutation-disabled — proven by the failure-matrix tests.
- AC-RI-6: All cards merged to `next` via squash PR with terminal-green CI; release evidence for 0.0.50 records commit, verification run, and published artifacts.
### Out of scope
The canonical dispatcher/control-plane vertical slice (work graph, execution attempts, fenced leases, typed check-in, independent verifier dispatch) is decided post-alpha (SDLC-D-033, option B). Multi-pipeline verification certificates (SDLC-D-034 option B) are post-alpha. Full AF-1..AF-4 objective matrices and Mission Control portfolio surfaces are post-alpha.
-4
View File
@@ -38,13 +38,11 @@ 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.
- [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.
- [Fleet unattended first start](USER-GUIDE/workflows/fleet-unattended-first-start.md) — no-TTY identity bootstrap and exact roster identity.
## Administrator documentation
- [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.
- [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.
- [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.
@@ -58,7 +56,6 @@ 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.
- [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.
- [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.
- [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.
@@ -78,7 +75,6 @@ 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 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.
- [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.
- [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.
+1 -3
View File
@@ -11,7 +11,6 @@ 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.
- [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.
- [Fleet unattended first start](workflows/fleet-unattended-first-start.md) — no-TTY identity bootstrap, exact roster identity, and separate runtime prerequisites.
## Chapter map
@@ -19,7 +18,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. |
| `concepts/` | User-facing terminology, product concepts, and mental models. | Scaffold only. |
| `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord and fleet first-start workflows are current. |
| `workflows/` | Task-oriented procedures for using Mosaic Stack. | Discord conversation workflow is current. |
| `product/` | Current product surfaces and visible behavior. | Web dashboard reference is current. |
| `troubleshooting/` | User-visible failures, diagnostics, and fixes. | Scaffold only. |
@@ -28,7 +27,6 @@ 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.
- [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.
- [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.
@@ -1,61 +0,0 @@
# 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)
+42
View File
@@ -0,0 +1,42 @@
# Tasks — Release Integrity Workstream (RI-050, #1275)
> Single-writer: the RI-050 orchestrator (jarvis, dragon-lin) only. Workers read but never modify.
>
> **Mission:** alpha 0.0.50 release-integrity floor (decisions SDLC-D-033..038).
> **PRD:** [docs/PRD.md § Release Integrity Workstream](../PRD.md#release-integrity-workstream-ri-1275)
> **Issue:** #1275 (remains open until RI-V-001 closes)
> **Base branch:** `next` (all cards branch from `origin/next`, squash-merge via PR)
>
> **Execution note:** the `agent` column uses `pi-glm-5.3` — outside the pipeline-cron model
> table on purpose. This workstream is executed by jarvis on dragon-lin with local pi workers
> (`pi --model zai/glm-5.3:high`); pipeline crons must not auto-claim these rows.
>
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
> `done` requires: repo quality gates green, independent review recorded, terminal-green CI on
> the PR head, squash merge to `next`, and acceptance evidence in notes.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| -------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ---------- | ----------------- | --------------------------------- | ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| RI-0-001 | done | Bootstrap: issue #1275, PRD section, this DAG, scratchpad (docs only) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-mission-bootstrap | — | 6K | PR #1276 (head 758659dd): docs-only, CI green (2475). Review requested from fargo. Merges first (no publish run). |
| RI-1-001 | done | RI-N1: canonical terminal verification command + publish-pipeline exact-commit gate (every publish step depends on verify; commit identity check; fail closed) | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-publish-gate | RI-0-001 | 25K | PR #1277 (head 46784c8d): CI GREEN at head after serialized retry (pipeline 2476, 2026-08-18) - earlier red was CI-agent contention (web SPA timeouts under concurrent pipelines), not code. Review requested from fargo at pinned head (comms 20260818T021025Z). |
| RI-1-002 | done | RI-N1 negative control: checked-in tests proving a broken mandatory check blocks every publish step and that DAG edges cannot be bypassed | #1275 | pi-glm-5.3 | mosaicstack/stack | test/ri-050-publish-gate-negative | RI-1-001 | 12K | |
| RI-2-001 | done | RI-N2 (Forge): remove stub-executor false success; `--simulate` typed `simulated` results that satisfy nothing; literal-`true` gates and echo-review replaced with real gates or typed waiting-for-authority | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-forge-fail-closed | RI-0-001 | 20K | Independent review APPROVED 2026-08-17 (Gitea review 172 on PR #1278, head 99b8f6ea; reviewing seat fargo — recorded under shared host principal mos-dt-0, provenance correction posted by fred; wrapper gap filed by fred). Executed at head: forge tests 116/116, lint green, typecheck green after building macp dist (minimal-install artifact, not a defect), workspace typecheck 45/45, no external type consumers of the changed interfaces. CI red = known lane-wide fleet-test failure only, carries no information about this change (fred, log-content analysis, pipelines 2456-2458). Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare forge run/resume, which now fails closed — fast-follow docs touch. Merge queued behind #1270. UPDATE 2026-08-18: #1270 merged; CI GREEN at head 4917df1f via serialized retry (pipeline 2477) - root cause of prior reds was CI-agent contention (web SPA timeouts under concurrent pipelines), superseding the fleet-test-failure theory. |
| RI-2-002 | done | RI-N2 (MACP): gate runner fails closed on empty commands, stub executors, and unimplemented CI-provider gates unless explicit simulate; typed capability failures | #1275 | pi-glm-5.3 | mosaicstack/stack | fix/ri-050-macp-fail-closed | RI-0-001 | 15K | PR #1293 (head 2097379e): CI green (pipeline 2465), independent review APPROVED (Gitea review 173, jarvis seat, 2026-08-17) - macp 109/109 verified at head. Merge queued behind #1276/#1277/#1278. |
| RI-3-001 | done | RI-N4: complete probe inventory mapping every TS and shell quality-rail check to one canonical check with disposition (preserve/strengthen/retire, each named) | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-qr-probe-inventory | RI-0-001 | 12K | PR #1302 (head e06a47fac591): CI green (2484), independent review APPROVED (Gitea review 187, fargo seat, 2026-08-18) — 54 rows / 21 canonical checks / dispositions 43-2-9-0 verified by row-count and code spot-checks. Merged by fargo at pinned head. |
| RI-3-002 | not-started | RI-N4: TS evaluator absorbs effective shell probes; typed results (passed/failed/blocked/error/not-applicable) with versioned digested check definitions; shell commands become thin adapters; contract/parity/negative-control tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-qr-evaluator | RI-3-001 | 30K | |
| RI-4-001 | in-progress | RI-N3: one PRD application service — `mission --plan` persists mission↔PRD linkage (ids/versions/selected requirements); `mosaic prdy` routes through the service or becomes a named import/export adapter; Markdown is a labeled generated view; explicit conflict-aware import | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-prd-authority | RI-0-001 | 35K | PR #1294 (head 8d258e1d): CI green (pipeline 2466), independent review APPROVED (Gitea review 174, jarvis seat, 2026-08-17) - prdy 20/20 + command specs 9/9 at head. Merge queued behind #1276/#1277/#1278. |
| RI-5-001 | done | RI-N5: typed freshness states (current/stale/partial/unknown/unavailable); no failed-fetch-renders-empty; stale derived verdicts → unknown; mutations disabled when stale; failure-matrix tests | #1275 | pi-glm-5.3 | mosaicstack/stack | feat/ri-050-web-stale-safety | RI-0-001 | 25K | |
| RI-V-001 | not-started | Final verification + release evidence: all cards verified merged, negative controls demonstrated, real `next` publish run green on exact commit, evidence pack recorded | #1275 | pi-glm-5.3 | mosaicstack/stack | docs/ri-050-release-evidence | RI-1-002, RI-2-001, RI-2-002, RI-3-002, RI-4-001, RI-5-001 | 10K | |
## Dispatch waves (max 2 parallel workers)
1. RI-1-001 + RI-2-001
2. RI-2-002 + RI-4-001
3. RI-3-001 + RI-5-001
4. RI-1-002 + RI-3-002
5. RI-V-001
## Budget
Derived soft cap: 250K tokens (no explicit cap given). Projected total: 190K.
Conservative mode (1 worker) above 70% projected; freeze above 90%.
+186
View File
@@ -0,0 +1,186 @@
# Quality-Rails Probe Inventory — RI-3-001
- **Task:** RI-3-001 (SDLC-D-037 first half; PRD § Release Integrity Workstream, RI-N4)
- **Date:** 2026-08-18
- **Base:** `origin/next` @ `8199261c` (branch `docs/ri-050-qr-probe-inventory`)
- **Follow-up:** RI-3-002 consumes the dispositions here when building the single TS evaluator.
## 0. Scope and method
Every mechanism in this repository that verifies a quality, integrity, safety, or release
property — TypeScript checks, shell probes, pipeline steps, git hooks, and installer-side
assertions — gets one row. Each row's "what it actually verifies" was written from the
probe's **code**, not its name or docs. Framework tool unit/regression suites (git wrappers,
wake, tmux, orchestrator, …) are treated as one enforcement surface (`test:framework-shell`)
because they test tool behavior rather than repo quality; their wiring integrity is itself
guarded by `check-test-enumeration.sh`, and the quality-relevant members are rowed
individually.
**Kinds:** `ts` (TypeScript/Node check), `shell` (bash/python probe), `pipeline-step`
(exists only inside a Woodpecker pipeline).
**Enforcement points:** `local` (operator-invoked), `pre-commit`, `pre-push`,
`CI ci.yml#<step>`, `publish.yml#<step>` (CI on push to main/next), `turbo <task>`,
`agent-runtime` (framework hooks on an agent host), `installer` (host install path),
`unwired`.
**Dispositions** (recommendations for RI-3-002): `preserve` (keep as-is; already the
canonical or a correct guard-of-the-guard), `strengthen` (keep, but a concrete gap must
close — usually absorption into the TS evaluator), `strengthen (review)` (viable retirement
candidate once the evaluator absorbs it; do not retire yet). Note: RI-N4 requires that
effective shell probes be **absorbed before** their independent paths retire — no row here
is marked `retire` because no absorption exists yet.
## 1. Inventory
### 1.1 Repo-level gate tasks (pnpm / turbo)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ------------------------------------- | ------------------------------------------------------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pnpm preflight` (checkout preflight) | `scripts/preflight.mjs` | ts | Six gate binaries (eslint, husky, prettier, tsc, turbo, vitest) exist and are executable in `node_modules/.bin` (exit 42 if not); no stale `.mosaic-test-work/web-build.lock` (exit 43); `apps/web/.next` is a real directory (not a symlink), every entry owned by the current uid, and its `.mosaic-source-hash` fingerprint + `.mosaic-symlink-manifest` hash match the certified build written by `scripts/build-web.mjs` | `pre-push`; inside `pnpm typecheck` (→ `CI ci.yml#typecheck`, verify-release `typecheck` stage) | QC-1 Checkout integrity | preserve | Blocks a poisoned/stale generated `.next` from faking a green typecheck (the five-month-stale-`.next` class); trust chain is self-contained per-checkout. |
| `pnpm typecheck` | root `package.json``turbo run typecheck` | ts | Per-package `tsc --noEmit` (all 20 packages); turbo `typecheck` depends on `^build`, so package builds must succeed first; prefixed by checkout preflight | `CI ci.yml#typecheck`; `pre-push`; verify-release `typecheck` stage; `turbo typecheck` | QC-2 Workspace typecheck | preserve | The single workspace-wide type gate; CI and hooks invoke the same task, no divergent checklist. |
| `pnpm lint` | root `package.json``turbo run lint` | ts | Per-package `eslint src` under root `eslint.config.mjs` (ignores `dist`, `.next`, `framework/**`, etc.) | `CI ci.yml#lint`; `pre-push`; verify-release `lint` stage; `turbo lint` | QC-3 Workspace lint | preserve | Same-task invocation from every surface; no second lint definition. |
| `pnpm format:check` | root `package.json``prettier --check` | ts | Prettier parse/format equality over `**/*.{ts,tsx,js,jsx,json,md}` minus `.prettierignore` (generated trees, `docs/scratchpads/`, venvs, …) | `CI ci.yml#format`; `pre-push`; verify-release `format` stage | QC-4 Format check | preserve | Single formatter, single ignore list, enforced identically everywhere. |
| `pnpm test` | root `package.json` `test` = `test:checkout` && `turbo run test` && `test:installer` | ts | (a) `node --test scripts/*.test.mjs` — checkout-tool units; (b) per-package `vitest run` (mosaic appends the 47-command `test:framework-shell` chain); (c) `tools/install-next-lane.test.sh`; turbo `test` declares DB env vars and depends on `^build` | `CI ci.yml#test` (with `DATABASE_URL` + `db:migrate` first); verify-release `test` stage; `turbo test` | QC-5 Test suite execution | preserve | One composed test command; the chain property (any link red ⇒ step red) is the gate. |
| `pnpm build` | root `package.json``turbo run build` | ts | Per-package build (`tsc`/Next) with `^build` dependency and `dist/**` outputs | `publish.yml#build`; verify-release `build` stage; `turbo build` | QC-6 Workspace build | preserve | Publish artifacts derive from the same build task CI verifies. |
### 1.2 Framework quality shell probes (`packages/mosaic/framework/tools/quality/`)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ------------------------------------------- | ----------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Sanitization gate | `scripts/verify-sanitized.sh` | shell | Built-in self-test first (planted identity/structural/YAML+service fixtures; exit 2 if the regexes or extension coverage break), then: (1) identity denylist grep (`jarvis\|jason\|woltje\|brain.woltje.com\|/home/jwoltje\|\bPDA\b`) over all shipped text files **including** `examples/`; (2) structural grep for private `$HOME/src` defaults in shipped scripts **excluding** `examples/`. Any hit ⇒ exit 1 | `CI ci.yml#sanitization`; verify-release `sanitization` stage | QC-7 Framework sanitization | preserve | Labeled one-time regression guard with a self-test that prevents silent no-op; correctly scoped (identity vs structural) and documented as not a general PII detector. |
| Resident-context budget | `scripts/check-resident-budget.sh` (+ `--self-test`) | shell | Self-test of the comparator, then `wc -l` vs per-file ceilings (CONSTITUTION 120, AGENTS 120, each RUNTIME.md 90); missing file ⇒ fail; over ceiling ⇒ exit 1 | `CI ci.yml#sanitization` (both modes); verify-release `sanitization` stage | QC-8 Resident-context budget | preserve | Caps the container (lines), never the wording — the deliberate anti-drift design (DESIGN §7); CI-enforceable half only, by design. |
| Test-membership enumeration guard (#1017) | `scripts/check-test-enumeration.sh` + `test-enumeration-exclusions.txt` | shell | Parses surface S1 (`packages/mosaic` `test:framework-shell` via JSON+shlex) and S2 (every `framework/tools/\*.sh | .py`token in`ci.yml`, comment lines stripped); population = `_test_.sh`under`framework/tools`; FAILS on: suite-shaped file on disk neither enumerated nor signed-excluded; surface naming a path missing on disk (both directions); exclusion without reason / stale / outside population / contradicting enumeration. Proves **naming, not reachability** (stated in-file) | `CI ci.yml#sanitization` (direct line); link [0] of `test:framework-shell` (thus `CI ci.yml#test`); verify-release `sanitization` stage | QC-9 Test-membership enumeration | preserve | Makes silent under-run impossible; invoked from both surfaces it audits so severing the chain cannot silence it. |
| Enumeration-guard needles | `scripts/test-check-test-enumeration.sh` | shell | Needle/control fixtures driven through `--root`: every promised failure mode must trip the guard **on its own words**, plus controls that must pass (null-case defense); covers commented-out ci.yml lines (F1) and line-range parsing (n2b) | `test:framework-shell``CI ci.yml#test`; verify-release `test` stage | QC-9 Test-membership enumeration | preserve | Guard-of-the-guard with both polarities; same canonical check by design. |
| Upgrade manifest guard (#791 HARD GATE) | `scripts/test-upgrade-manifest-guard.sh` | shell | Keep-mode `install.sh` upgrade against seeded throwaway `MOSAIC_HOME`: every operator sentinel — including an **unanticipated** one — survives byte-identical with unchanged mtime; framework files still update; retired framework files pruned; matrix run with rsync present AND absent (keep path must be rsync-independent); fail-closed matrix (empty/operator-only/malformed/missing manifest aborts loudly, operator files untouched); operator secret never appears in installer output | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | The operator-data hard gate for the `mosaic update` path; negative controls are load-bearing and documented. |
| Upgrade rollback gate (#791 B1) | `scripts/test-upgrade-rollback.sh` | shell | Mid-sync failure (PATH-shadowing `cp` shim) must trigger snapshot restore: restore message fires, corrupted file restored, target byte-identical to pre-upgrade; control installer with `set -E` stripped must NOT roll back (proves errtrace is load-bearing); plus signal/exit-guard controls | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Proves the rollback trap actually fires; the `-E`-stripped control keeps Part A honest. |
| Durable-snapshot gate (#791 PR2) | `scripts/test-upgrade-durable-snapshot.sh` | shell | Pre-update snapshot taken before any mutation (0700/0600 perms, secret never logged, retention-pruned); post-sync verify net restores operator files a manifest bug lets the sync touch; CWE-59 symlink-leaf guard proven with a portable cp shim in both polarities (write-through-link must not happen); v1→v2 migration semantics (intended `bin/` removal not healed) | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Covers tampering and leak vectors the manifest guard cannot see; the shim rationale (busybox vs GNU cp) is documented in-file. |
| Install migration matrix (v2→v3) | `scripts/test-install-migration.sh` | shell | Fixture matrix running the real installer with `MOSAIC_SYNC_ONLY=1`: fresh install seeds + stamps version 3; legacy user-edited AGENTS overwritten with `.pre-constitution.bak` preserved (and idempotent); tuned STANDARDS overwritten; operator files (SOUL, credentials) preserved. Mirrors the TS suite `packages/mosaic/src/config/file-adapter.test.ts` — both installers must behave identically | `CI ci.yml#upgrade-guard`; verify-release `upgrade-guard` stage | QC-10 Upgrade/install safety | preserve | Pins the shell/TS installer parity contract; removal would orphan that parity requirement. |
| Enforcement verification probe (bash) | `scripts/verify.sh` | shell | Attempts **real commits** in the target repo: planted type error must produce a commit blocked with `error`; planted `any` must trip `no-explicit-any`; planted lint error must trip `prettier`; gitleaks binary must exist (3a) and detect a planted AWS key via `gitleaks git --pre-commit --staged --redact` (3b). Verdicts are output-grep matches on hook stderr | `local` via installed `mosaic-quality-verify` on scaffolded target projects; **not run in this repo's CI** | QC-20 Downstream enforcement verification | strengthen (review) | Mechanism is genuinely behavioral (stronger than file presence) but verdict logic is grep-on-output and it is unwired here; absorb as the evaluator's enforcement-probe check (the RI-N4 evaluator invokes it or reimplements it) before retiring the shell path. |
| Enforcement verification probe (PowerShell) | `scripts/verify.ps1` | shell | Windows port of `verify.sh`: same planted-commit tests with `$output -match` matching; no gitleaks self-test parity beyond the same checks | `local` (Windows operator); no Windows CI runner exists | QC-20 Downstream enforcement verification | strengthen (review) | A hand-maintained twin of `verify.sh` with no CI coverage — exactly the drift shape the single evaluator removes; retire after the TS evaluator owns the probe. |
| Quality template installer (bash) | `scripts/install.sh` | shell | Copies template files (`.husky/pre-commit` incl. mandatory gitleaks, `.lintstagedrc.js`, `.eslintrc.js`, `tsconfig.json`, `.woodpecker.yml`, `.gitleaks.toml`) into a target project; **warns** (does not verify) about `package.json` snippet merge; no post-condition check | `local` / via `mosaic-quality-apply` | QC-21 Downstream rails scaffolding | strengthen (review) | Duplicates the TS `quality-rails init` scaffolder for a different template set; converging on one scaffolder (with post-scaffold verification) is prerequisite to retiring this path. |
| Quality template installer (PowerShell) | `scripts/install.ps1` | shell | Windows twin of the template copy above | `local` (Windows operator) | QC-21 Downstream rails scaffolding | strengthen (review) | Same twin-drift risk as `verify.ps1`; no runner exercises it. |
| `mosaic-quality-verify` adapter | `framework/tools/_scripts/mosaic-quality-verify` | shell | Thin adapter: validates target dir exists, asserts `verify.sh` present+executable, `cd` target, exec it. No verdict logic of its own | `local` (installed framework bin) | QC-20 Downstream enforcement verification | preserve | Already the thin-adapter shape RI-N4 prescribes for shell surfaces. |
| `mosaic-quality-apply` adapter | `framework/tools/_scripts/mosaic-quality-apply` | shell | Thin adapter: arg validation then exec of quality `install.sh --template … --target …` | `local` (installed framework bin) | QC-21 Downstream rails scaffolding | preserve | Thin adapter, no separate verdict; disposition follows its target script's convergence. |
| Roster schema regression | `scripts/test-roster-schema.py` | shell | jsonschema `Draft202012Validator` over `fleet/roster.schema.json` with valid/invalid connector-kind fixtures (tmux/discord/matrix conditional fields) | **unwired** — not on S1 or S2, not signed-excluded; also outside the enumeration guard's `*.sh` population, so the guard cannot see it | QC-5 Test suite execution | strengthen (review) | A real regression suite that currently runs nowhere; wire it into a CI surface or sign an exclusion — leaving it invisible re-arms the exact gap #1017 closed. |
| Framework shell chain (S1) | `packages/mosaic/package.json` `test:framework-shell` | shell | 47-command `&&` chain: enumeration guard + needles, 14 lease-broker/mutator-gate python unitests, `check-runtime-launches.py`, and ~30 framework-tool shell suites (git wrappers, wake, woodpecker, tmux, glpi, orchestrator, `_scripts`). Quality-relevant members rowed separately below | `turbo test``CI ci.yml#test`; verify-release `test` stage | QC-5 Test suite execution | preserve | The chain is the execution surface the enumeration guard audits; known residuals: a failing link stops later suites (measured in #1270 — suites after position 44 had not run), and the guard proves naming, not reachability. |
### 1.3 Framework runtime hooks and their harnesses (agent-host enforcement)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ------------------------------------- | ----------------------------------------------------------------------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | --------------------------------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| QA edit hook seam | `framework/tools/qa/qa-hook-stdin.sh` (+ `qa-hook-handler.sh`) | shell | PostToolUse stdin hook: extracts edited file from the tool JSON (jq or grep fallback), skips non-JS/TS, then the deps-preflight gate — exits 1 with the legible sentinel `deps not installed — run pnpm install` when `node_modules/.bin` is missing/empty (the #856 false-red class); the downstream handler only files QA remediation **report templates** (no verification logic) | `agent-runtime` (framework `runtime/claude/settings.json` PostToolUse); never CI | QC-16 Agent-runtime edit-time checks | strengthen (review) | The sentinel gate is real enforcement; the handler's report-filing adds no verdict and its name promises more than the code does — evaluator absorption should keep the sentinel, drop the report theater. |
| Typecheck-on-edit hook | `framework/tools/qa/typecheck-hook.sh` | shell | PostToolUse: for edited `.ts/.tsx`, finds nearest `tsconfig.json` and runs `tsc --noEmit`, surfacing errors nonzero to the agent immediately | `agent-runtime` (framework `runtime/claude/settings.json` PostToolUse) | QC-16 Agent-runtime edit-time checks | strengthen (review) | Edit-time duplicate of QC-2 with independent invocation logic; keep behavior, converge invocation through the evaluator adapter. |
| Deps-preflight harness | `framework/tools/qa/test-deps-preflight.sh` | shell | Five assertions against the seam incl. a documented RED control (raw `not found`), sentinel behavior for missing and empty `.bin`, and no-false-positive once populated | `test:framework-shell``CI ci.yml#test` | QC-16 Agent-runtime edit-time checks | preserve | Guard-of-the-check with a red control; keeps the sentinel from regressing. |
| Prompt-helper RCE regression | `framework/tools/_scripts/test-mosaic-init-rce.sh` | shell | Sources the prompt helpers and proves a literal `$(touch /tmp/pwned)` answer round-trips verbatim and never executes (no `/tmp/pwned` created) | `test:framework-shell``CI ci.yml#test` | QC-5 Test suite execution | preserve | Cheap, load-bearing security regression on the installer's input path. |
| Install-ordering harness (#869 C2) | `framework/tools/_scripts/test-install-ordering-guard.sh` | shell | Drives `mosaic-link-runtime-assets` with a fake `mosaic` on PATH: probe ok ⇒ settings copied + exit 0; probe fail ⇒ exit 1 with degraded outcome but all other runtime files still copied; `--allow-inactive-enforcement` forwarded; no-mosaic-on-PATH ⇒ python3 fallback strips enforcement hooks and exits 1; fallback + flag ⇒ wires as-is, exit 0 | `test:framework-shell``CI ci.yml#test` | QC-17 Lease-enforcement wiring safety | preserve | Exercises the shell wiring seam independently of the TS guard's own spec suite (complementary coverage, by design). |
| Fleet-transport harness (#1240) | `framework/tools/_scripts/test-fleet-transport-check.sh` | shell | Extracts the shipped `check_fleet_transport`/`fleet_declared_transport` functions **from the shipped scripts** (fails loud if extraction yields nothing) and drives both implementations (mosaic-doctor + `tools/install.sh`) from one case table | `test:framework-shell``CI ci.yml#test` | QC-18 Operator-host drift audit | preserve | The anti-drift harness for the one rule shipped twice; extraction-from-source keeps it from testing a stale copy. |
| Terminal-green contract (RM-61/#1000) | `framework/tools/woodpecker/test-terminal-green-contract.sh` + `verify-terminal-green.py` | shell | Red-first fixtures: pipeline JSON variants (service failure, step failure, cancelled, etc.) must produce the correct terminal-green verdict; controls must pass | `test:framework-shell``CI ci.yml#test` | QC-5 Test suite execution | preserve | Keeps the CI-wait wrapper's green-detection honest; a false green here would poison every merge gate that trusts `pr-ci-wait.sh`. |
| Lease-gate launch invariant | `framework/tools/lease-broker/check-runtime-launches.py` | shell | Scans production roots (`packages/`, `apps/`, `plugins/`, `tools/`) across sh/py/ts/yaml suffixes for Claude/Pi process launches **outside** the lease gate; allowlist-based; fails CI on violation | `test:framework-shell``CI ci.yml#test` | QC-15 Lease-gate architecture invariant | preserve | The only architectural "no ungated launches" rail; grep+allowlist is the right cost/benefit for this invariant. |
### 1.4 TypeScript quality logic (`@mosaicstack/quality-rails` + mosaic CLI)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `quality-rails check` | `packages/quality-rails/src/cli.ts` (`mosaic quality-rails check --project`) | ts | **Expected-file presence only**: loops `expectedFilesForKind` (node: `.eslintrc`, `biome.json`, `.githooks/pre-commit`, `PR-CHECKLIST.md`; python: `pyproject.toml`+hooks+checklist; rust: `rustfmt.toml`+…) and exits 1 listing missing paths. Does not execute any linter, formatter, hook, or scanner | `local` (operator CLI); **no CI wiring in this repo** | QC-19 Downstream rails presence check | strengthen | This is the RI-N4 evaluator seed. Today presence ≠ parity (explicitly called out by RI-N4): it must grow typed verdicts (`passed/failed/blocked/error/not-applicable`), check versioning/subject/reason, digested definitions, and absorb the effective shell probes (QC-20 first). |
| `quality-rails doctor` | `packages/quality-rails/src/cli.ts` | ts | Same presence data as `check`, printed with ok/missing lines; **cannot fail** (no nonzero exit on missing files) | `local` (operator CLI) | QC-19 Downstream rails presence check | strengthen | A doctor that cannot fail is advisory; fold into `check` (or return typed states) when the evaluator lands. |
| `quality-rails init` | `packages/quality-rails/src/cli.ts` + `scaffolder.ts`/`templates.ts` | ts | Scaffolds rails files per detected kind/profile (linters/formatters lists are advisory strings; hooks flag always true); writes files, prints follow-ups — no post-condition verification | `local` (operator CLI) | QC-21 Downstream rails scaffolding | strengthen (review) | Second scaffolding path alongside quality `install.sh` (§1.2); converge on one with post-scaffold verification before retiring either. |
| Lease activation probe (#869 C1, hidden) | `packages/mosaic/src/commands/lease-activation-probe.ts` | ts | Real capability probe, not file presence: resolves the installed mosaic CLI and requires it to advertise the exact `{name, version}` activation contract; all deps injectable; registered as hidden CLI command and consumed by C2/C5 | `local` (hidden CLI + consumed by C2/C5); spec-tested via `lease-activation-probe.spec.ts` in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | The versioned-contract probe is precisely the fail-closed capability check RI-N2 generalizes; already typed and injectable. |
| Install-ordering guard (#869 C2, hidden) | `packages/mosaic/src/commands/install-ordering-guard.ts` | ts | Decides whether enforcement hook entries are written into the `~/.claude/settings.json` the framework reseed ships: not activatable ⇒ strip hooks + nonzero loud outcome (default); explicit per-invocation `--allow-inactive-enforcement` opt-out wires-with-warning. Never touches the runtime gate's own fail-closed behavior | `installer` (framework reseed via `mosaic-link-runtime-assets`); spec + shell harness coverage in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | Correct default-deny with an explicit, non-env opt-out; test-locked from both the TS and shell sides. |
| Lease doctor check (#869 C5) | `packages/mosaic/src/commands/lease-doctor-check.ts` | ts | Combines hook-wiring detection in `~/.claude/settings.json` with C1 activatable and C3 broker-supervisor health: wired ∧ (¬activatable ¬healthy) ⇒ loud `[ERROR]` that forces `mosaic doctor` exit 1 regardless of the bash audit's own exit | `local` (inside `mosaic doctor`); spec coverage in `turbo test` | QC-17 Lease-enforcement wiring safety | preserve | Closes the "bricked host looks green" hole; cannot be masked by the bash script — that composition is the point. |
| `mosaic doctor` (framework drift audit) | `packages/mosaic/src/commands/launch.ts` (`doctor`) + `framework/tools/_scripts/mosaic-doctor` | shell+ts | Bash audit of the installed framework home: ~40 expected files/dirs present; runtime files are copies (not symlinks) matching source (`cmp`) or composed runtime-contract markers; hard-gates block present in AGENTS.md; sequential-thinking MCP configured; fleet transport binary present per roster (warn); legacy symlink trees gone; skills synced — **warn-based, exit 1 only with `--fail-on-warn`**, plus C5's forced error | `local` (operator audit) | QC-18 Operator-host drift audit | preserve | Host-state audit CI cannot see (user files by design, DESIGN §7); advisory exit is the documented contract — do not silently change it. |
| `mosaic gateway doctor` | `packages/mosaic/src/commands/gateway-doctor.ts` | ts | Probes per-service health (PostgreSQL, Valkey, pgvector) via `@mosaicstack/storage`, reports tier and JSON; exit 1 only when at least one **required** service fails (yellow stays 0) | `local` (operator) | QC-18 Operator-host drift audit | preserve | Service health with correct red/yellow exit semantics; JSON mode exists for scripting. |
| `mosaic gateway verify` | `packages/mosaic/src/commands/gateway/verify.ts` | ts | Post-install liveness: daemon meta via HTTP with retries, admin token on file, bootstrap endpoint reachable; aggregated pass/fail | `local`; consumed by `tools/e2e-install-test.sh` | QC-18 Operator-host drift audit | preserve | The first-run proof the installer E2E relies on; retry-aware so startup races don't false-red. |
| `mosaic fleet doctor` | `packages/mosaic/src/commands/fleet-reconciler-command.ts` | ts | Classifies local roster-owned drift (no mutation) from the parsed v2 roster | `local` (operator) | QC-18 Operator-host drift audit | preserve | Dry-run classification is the correct non-mutating audit shape. |
### 1.5 Git hooks (developer machine)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ------------------------- | --------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
| Pre-commit staged hygiene | `.husky/pre-commit``npx lint-staged` (`.lintstagedrc`) | shell | On staged files only: `prettier --write` + `eslint --fix` for ts/tsx/js/jsx; `prettier --write` for json/md/yaml/yml. **Mutating** (fixes and re-stages); commit blocks only if a fixer itself fails | `pre-commit` (every local commit; hooks activated by `install-hooks.mjs` via `core.hooksPath .husky/_`) | QC-13 Staged-change hygiene | preserve | Correct scoped fast gate; note it auto-fixes rather than rejects (deliberate). Gap: no secret scan here — see §3. |
| Pre-push gate | `.husky/pre-push` | shell | `pnpm preflight && pnpm typecheck && pnpm lint && pnpm format:check` (no test run — documented in AGENTS.md) | `pre-push` | QC-14 Pre-push gate | preserve | Composes QC-1..4 exactly as specified in AGENTS.md; tests intentionally left to CI. |
| Hook installer | `scripts/install-hooks.mjs` (`pnpm prepare`) | ts | Stages husky hooks into a scratch repo first, asserts husky produced its `h` shim, quarantines incomplete previous sets, verifies idempotence via full directory snapshot comparison, then sets `core.hooksPath`; skips cleanly with `HUSKY=0` or no git | `installer` (runs on `pnpm install`) | QC-13 Staged-change hygiene | preserve | Self-verifying wiring for the hook gates — a corrupted half-install cannot silently disable them. |
### 1.6 CI pipeline steps (`.woodpecker/`)
Step-to-probe mapping for container steps: `ci.yml#sanitization` = QC-7+QC-8+QC-9 (rows §1.2, plus `apk add bash` env prep); `ci.yml#upgrade-guard` = QC-10 (rows §1.2, plus `apk add rsync`); `ci.yml#typecheck`/`#lint`/`#format`/`#test` = QC-2/3/4/5 (rows §1.1). Rows below are mechanisms that exist only in a pipeline.
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| -------------------------------------- | -------------------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------- |
| Frozen install | `ci.yml#install` | pipeline-step | `pnpm install --frozen-lockfile --prefer-offline` against the baked ci-base store — lockfile supply integrity; a drifted lockfile fails the build before any gate runs | `CI ci.yml#install` | QC-1 Checkout integrity | preserve | Lockfile-pinned dep resolution is the supply-chain floor under every later gate. |
| Test-step readiness prelude | `ci.yml#test` prologue | pipeline-step | Installs pinned `@earendil-works/[email protected]` (Invariant R suite requires the real binary) + openssl; waits up to 60×1s on `pg_isready` for the `ci-postgres` service and fails fast if it never comes up; runs `db:migrate` before tests | `CI ci.yml#test` | QC-5 Test suite execution | preserve | Fail-fast environment preconditions — a missing service produces a legible failure, not a wall of red tests. |
| Publish verify step (pending RI-1-001) | `publish.yml#verify` (branch `feat/ri-050-publish-gate` @ `46784c8d`, not yet on next) | pipeline-step | (a) Commit identity: fails closed if `CI_COMMIT_SHA` empty, `git rev-parse HEAD` empty, or the two differ; (b) runs the canonical `pnpm verify:release`. **Every publish effect depends on this step; it carries no path filter** | `publish.yml#verify` | QC-11 Terminal release verification | preserve | The RI-N1 exact-commit binding; until it merges, publish steps on next depend on `build` only (see §3 gap 1). |
| Publish error classification | `publish.yml#publish-npm` | pipeline-step | Publishes `@mosaicstack/*` (minus web) and classifies outcome: success, or the **only tolerated failure** = already-published (EPUBLISHCONFLICT / "cannot publish over" / "previously published"); explicit fatal on npm `E404/E401/ENEEDAUTH/ECONNREFUSED/ETIMEDOUT/ENOTFOUND` and on any unrecognized failure (replacing the old ` | | echo` that hid a registry 404) | `publish.yml#publish-npm` (main/tags, path-filtered on `packages/**`) | QC-12 Publish-effect integrity | preserve | Converts silent publish fall-on-floor into loud failure; allowlist-of-one error tolerance is the right shape. |
| Next-lane publish assertions | `publish.yml#publish-next-npm` | pipeline-step | Guards: branch must be `next`, `CI_PIPELINE_NUMBER` required; registry dist-tags JSON must be usable; walks all manifests, strictly parses stable semver, rewrites `X.Y.(Z+1)-next.<N>`; publishes with `--tag next` (never latest); post-publish asserts `npm view @mosaicstack/mosaic@next` resolves to the exact expected version | `publish.yml#publish-next-npm` (push/manual on next) | QC-12 Publish-effect integrity | preserve | Durable prerelease lane with end-to-end resolution proof — the published artifact is verified, not assumed. |
| Image destination policy | `publish.yml#build-gateway` / `#build-appservice` / `#build-web` | pipeline-step | Kaniko builds with destination policy: `next` ⇒ sha-tag only (fatal if a tag event sneaks in); `main` ⇒ sha + `latest`; tag events ⇒ sha + `<tag>`; anything else fatal. Path filters only skip **effects**, never the verify step | `publish.yml#build-*` | QC-12 Publish-effect integrity | preserve | Fail-closed tagging matrix; the exclude-list default-safe design keeps stale images impossible. |
Adjacent pipeline surface (not a probe): `.woodpecker/ci-image.yml` rebuilds the ci-base image on `pnpm-lock.yaml`/`Dockerfile.ci` change with an immutable `lock-<hash>` tag; pipelines consume `:latest`. Recorded for completeness — no code-quality property is checked.
### 1.7 Root installer tooling (`tools/`)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| --------------------------- | --------------------------------------------------------- | ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Next-lane installer test | `tools/install-next-lane.test.sh` (`pnpm test:installer`) | shell | Drives `tools/install.sh --next` with faked `node`/`npm` binaries (no network): Node 20 must be rejected; installs must pin **exact** versions (mutable `@next` forbidden); fast path must not unexpectedly fall back to source; gateway-install failure takes the documented fallback | `turbo`-external tail of `pnpm test``CI ci.yml#test` | QC-5 Test suite execution | preserve | Hermetic (shimmed) regression net for the installer lane; runs as part of the standard test command. |
| Clean-container install E2E | `tools/e2e-install-test.sh` | shell | Full first-run flow in a node:22-alpine container: `install.sh --yes``mosaic wizard` (non-interactive) → `mosaic gateway install``mosaic gateway verify` exit check (with EXPECTED-SKIP if the installed CLI predates `gateway verify`); skips gracefully without Docker | `local` (manual; requires Docker); **not wired in CI** | QC-5 Test suite execution | strengthen (review) | The only end-to-end proof of the install→verify path; currently operator-initiated only — wire into a periodic/manual CI lane or sign its exclusion explicitly. |
| Host installer advisories | `tools/install.sh` (`--check`; `check_fleet_transport`) | shell | `--check` = version comparison only, no install; `check_fleet_transport` warns (non-blocking, by design — tmux is the fleet's dependency, not mosaic's) when the roster-declared transport binary is absent, naming exactly what it blocks; PATH-persistence warnings | `installer` (operator-run) | QC-18 Operator-host drift audit | preserve | Advisory-by-design warnings; the parallel doctor check is drift-tested by §1.3's harness. |
### 1.8 Pending workstream additions (branch `feat/ri-050-publish-gate` @ `46784c8d`)
| check | location | kind | what it actually verifies | enforcement point | canonical check | disposition | rationale |
| ------------------------------- | ---------------------------------------------------- | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- |
| Canonical terminal verification | `scripts/verify-release.mjs` (`pnpm verify:release`) | ts | One command replaying the full mandatory set as stages — sanitization, upgrade-guard, typecheck (incl. preflight), lint, format, test, build — mirroring `ci.yml` step-for-step; fail-fast on first failing command; requires `bash`+`rsync` on PATH; `--stage <name>` for wiring smoke-tests only | `publish.yml#verify` (pending); `local` (`pnpm verify:release`) | QC-11 Terminal release verification | preserve | The RI-N1 canonical command — CI and publication share one semantic checklist by construction. |
| Verify-parity contract test | `scripts/verify-release.test.mjs` | ts | Parses the real `ci.yml`/`publish.yml`: stage table must match ci.yml step-for-step; every publish-effect step (name `publish*` or image-pushing) must transitively depend on `verify`; commit-identity assertion must be present; `verify` must carry no path filter | `test:checkout``CI ci.yml#test` (once merged) | QC-11 Terminal release verification | preserve | Guard-of-the-guard at checkout time — the two surfaces cannot drift apart silently. |
## 2. Canonical check set
The deduplicated checks every row above maps onto. IDs are stable for RI-3-002 to consume.
- **QC-1 Checkout integrity.** Owns: the checkout can run its gates — frozen-lockfile dependency resolution, required gate binaries present, no stale build lock, and the `apps/web/.next` generated-state trust chain (real directory, uid ownership, certified source fingerprint, certified symlink manifest). Implemented by `scripts/preflight.mjs` + frozen install steps.
- **QC-2 Workspace typecheck.** Owns workspace-wide TypeScript soundness: per-package `tsc --noEmit` over built dependencies (`turbo typecheck`). The single definition invoked by CI, pre-push, and terminal verification.
- **QC-3 Workspace lint.** Owns static-analysis policy: per-package ESLint under the root config. One config, one task, every surface.
- **QC-4 Format check.** Owns formatting uniformity: Prettier check with the repo ignore list. (The pre-commit variant additionally fixes; the verdict form is this check.)
- **QC-5 Test suite execution.** Owns execution of all test surfaces: checkout script units (`node --test`), per-package Vitest suites (including the framework shell chain and its python unitests), the installer-lane shim test, and — once wired — `test-roster-schema.py` and container E2E. Also owns guards-of-the-gate that live inside the chain (terminal-green contract, RCE regression).
- **QC-6 Workspace build.** Owns artifact buildability: `turbo build` producing the artifacts publication consumes.
- **QC-7 Framework sanitization.** Owns the open-source guarantee for the shipped framework package: no operator-identity tokens anywhere (examples included), no private `$HOME` defaults in shipped scripts, with a self-test that keeps the regexes honest.
- **QC-8 Resident-context budget.** Owns the line-count ceilings on framework files injected into every agent's context (Constitution, dispatcher, RUNTIME.md slices) — the CI-enforceable half of the resident-prompt budget.
- **QC-9 Test-membership enumeration.** Owns the property that no test suite can silently fall out of CI: disk population vs parsed enumeration surfaces, both-directions staleness, and signed exclusions with reasons. Includes its needle/control harness.
- **QC-10 Upgrade/install safety.** Owns the #791 family: operator-path byte-identity across keep-mode upgrades (manifest guard), mid-failure rollback (errtrace-proven), durable pre-update snapshot + verify net + CWE-59 leaf guard, and the v2→v3 migration matrix with shell/TS parity.
- **QC-11 Terminal release verification.** Owns the RI-N1 exact-commit binding: commit-identity assertion plus one canonical command (`pnpm verify:release`) replaying the complete mandatory set, with every publish effect depending on it; plus the checkout-time parity/DAG contract test that keeps pipeline and command in sync.
- **QC-12 Publish-effect integrity.** Owns publication correctness: npm publish error classification (only already-published tolerated), next-lane versioning and post-publish resolution proof, and image destination/tag policy.
- **QC-13 Staged-change hygiene.** Owns commit-time hygiene on staged files (prettier/eslint fix-and-restage) and the self-verifying hook wiring that guarantees the gates are actually installed.
- **QC-14 Pre-push gate.** Owns the local push composition: preflight + typecheck + lint + format:check (tests deliberately deferred to CI).
- **QC-15 Lease-gate architecture invariant.** Owns "no ungated runtime launches in production code": the scan + allowlist over `packages/`, `apps/`, `plugins/`, `tools/`.
- **QC-16 Agent-runtime edit-time checks.** Owns edit-time feedback on agent hosts: the deps-preflight legibility sentinel and typecheck-on-edit, plus their regression harnesses.
- **QC-17 Lease-enforcement wiring safety.** Owns the #869 C1/C2/C5 trio: activation capability probe (versioned contract), enforcement-hook wiring gate (default-deny with explicit opt-out), and the doctor check that surfaces a bricked host — with their shell/TS harnesses.
- **QC-18 Operator-host drift audit.** Owns host-state health CI cannot see: `mosaic doctor` drift audit (+ fleet transport, both implementations), `fleet doctor` roster classification, `gateway doctor`/`gateway verify` service health, and installer advisories. Advisory exits are part of the contract.
- **QC-19 Downstream rails presence check.** Owns "does a scaffolded project still carry its rails files" — today the TS `quality-rails check/doctor` presence loop; per RI-N4 this is the seed that must become the typed evaluator (presence alone is explicitly not parity).
- **QC-20 Downstream enforcement verification.** Owns "do the rails actually block" on scaffolded projects: the behavioral planted-commit probe (type error, `any`, lint, gitleaks secret) currently in `verify.sh`/`verify.ps1` behind the `mosaic-quality-verify` adapter.
- **QC-21 Downstream rails scaffolding.** Owns putting rails files into a target project: the shell template installer (+ PowerShell twin) and the TS `quality-rails init` scaffolder — currently two paths that must converge.
## 3. Coverage gaps
Enforced nowhere but implied, or named in docs/tooling but not wired:
1. **Publication not yet bound to verification on `next`.** At this base (`8199261c`), `publish.yml` publish steps depend on `build` only; the `verify` step and `scripts/verify-release.mjs` exist on `feat/ri-050-publish-gate` (`46784c8d`) but are not merged. Until RI-1-001 lands, AC-RI-1's negative control cannot hold on the real pipeline.
2. **Playwright E2E unwired.** `apps/web` ships `test:e2e` (`playwright test`) with real suites (`admin/auth/chat/navigation.spec.ts`); neither `pnpm test` nor any CI step invokes it. The web UI's user flows are verified only when an operator runs them manually.
3. **No secret scanning on this repo.** The framework's own template pre-commit makes gitleaks **required**, and `verify.sh` proves detection with a planted key — but this repository's `.husky/pre-commit` (lint-staged only) and CI run no secret scan. The repo ships the control it does not use.
4. **No dependency audit.** The quality `.woodpecker.yml` templates and `docs/CI-SETUP.md` specify `npm audit --audit-level=high` as a pipeline stage; nothing equivalent runs for this repo.
5. **No coverage thresholds.** Templates enforce 80% Jest coverage thresholds; this repo's Vitest configs collect coverage with no thresholds — coverage is measured nowhere and enforced nowhere.
6. **`test-roster-schema.py` invisible.** A real jsonschema regression suite wired to no surface and invisible to the enumeration guard (its population is `*.sh`; the suite is `.py`). Either enumerate it or sign an exclusion — silence here is the #1017 defect shape.
7. **Presence-checker expectations ≠ this repo.** `quality-rails check` expects `.eslintrc`, `biome.json`, `.githooks/pre-commit`, `PR-CHECKLIST.md` for node projects — none describe this monorepo (husky, flat eslint config, no biome, no PR-CHECKLIST.md). The evaluator's check set must be per-subject (versioned, digested), not one global file list.
8. **Chain-ordering residual (documented).** `test:framework-shell` is one `&&` chain: a failing link skips every later suite while the step still fails (measured in #1270 — four suites after position 44 had not run since a prior merge). The enumeration guard proves naming, not reachability; both residuals are in-file documented but structurally unfixed.
9. **Signed-exclusion burndown open.** 16 signed exclusions remain in `test-enumeration-exclusions.txt`; several are "unmeasured in CI image" or blocked on missing CI tooling (tmux, setsid) — tracked under #1017/#1271. Each is an enforcement promise deferred, not delivered.
10. **Windows twins unexercised.** `verify.ps1`, `install.ps1`, `mosaic-doctor.ps1` have no runner anywhere (no Windows CI); behavioral drift from their bash twins is undetectable by construction.
11. **QA hook name vs behavior.** `qa-hook-handler.sh` files remediation report templates but performs no verification; the seam's actual gate value is only the deps-preflight sentinel. Anything relying on "QA automation hook" as a check is relying on report-filing.
12. **Two test paths, one gated.** CI runs tests against ci-postgres (`DATABASE_URL` set); the local PGlite path is the documented default (AGENTS.md) until KBN-101-02/101-05. Only the CI path is enforced by pipeline.
## 4. Disposition summary
| disposition | rows | checks |
| ------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| preserve | 43 | Every canonical owner (QC-1..QC-18) plus correct guards-of-the-guard and thin adapters: all of §1.1, the CI-invoked framework probes and adapters in §1.2, all of §1.3, the C1/C2/C5 trio and doctors in §1.4, all of §1.5, all pipeline-only steps in §1.6, §1.7 rows 1 and 3, and §1.8. |
| strengthen | 2 | `quality-rails check` and `quality-rails doctor` (QC-19) — the RI-N4 evaluator seed: typed verdicts, versioned/digested check definitions, per-subject check sets. |
| strengthen (review) | 9 | `verify.sh` + `verify.ps1` (QC-20), quality `install.sh`/`install.ps1` + `quality-rails init` (QC-21 — scaffold-path convergence), `test-roster-schema.py` (QC-5 — wire or sign), `qa-hook-stdin.sh` seam + `typecheck-hook.sh` (QC-16), `tools/e2e-install-test.sh` (QC-5 — CI lane). |
| retire | 0 | None meet the bar: RI-N4 requires effective shell probes be **absorbed before** their paths retire, and no absorption exists yet. The `strengthen (review)` rows are the retirement candidates for RI-3-002 once the evaluator owns their behavior. |
Row total: 54. Canonical checks: 21 (QC-1..QC-21).
-3
View File
@@ -12,13 +12,11 @@ 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.
- [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.
- [Issue #1264 documentation checklist](documentation/1264-documentation-checklist.md) — current in-repo user/admin/developer/report coverage and review gate.
## Code-review evidence
- [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.
- [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
@@ -28,7 +26,6 @@ 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.
- [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
@@ -1,97 +0,0 @@
# 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.
@@ -1,32 +0,0 @@
# #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).
@@ -1,220 +0,0 @@
# #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.
@@ -1,104 +0,0 @@
# #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,10 +7,6 @@
- [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.
## 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.
## Related
+242
View File
@@ -0,0 +1,242 @@
# Scratchpad — RI-050 orchestrator (jarvis, dragon-lin)
Mission: alpha 0.0.50 release-integrity floor. Issue #1275. Base `next` @ 476db12b.
Design SSOT: jarvis-brain `docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` (SDLC-D-033..038).
## Mode (Jason's directives)
- Orchestrator: jarvis (this session, dragon-lin). NOT mos-claude; work stays on this host.
- Workers: local pi headless — `pi --model zai/glm-5.3:high -p` in the card's worktree, tools read,bash,edit,write.
- Delegation override of stack AGENTS.md `agent` column: rows carry `pi-glm-5.3` (outside cron table so no auto-claim).
- Target branch: `next`. Cards branch from `origin/next`, squash-merge via PR.
## Operational constraints (measured this session)
- Main checkout at `/home/jwoltje/src/mosaic-stack` is a dirty diverged `main` (ahead 1139/behind 711) — NEVER touched. All work in `/home/jwoltje/src/mosaic-stack-worktrees/<branch>`.
- Disk: /home 187G free. /tmp only 8.7G — keep pnpm stores/node_modules under /home.
- `main` and `next` have DIVERGED; PRs target `next`.
- Identity: pin `GITEA_LOGIN=mosaicstack-jarvis` for all wrapper ops. Issue #1275 verified authored by @jarvis.
- `ci-queue-wait.sh` on this host is fail-open (board: fix #1032 not installed) — substitute SHA-status checks via `/commits/{sha}/status` and diff failing step names.
- CI on PRs runs `pull_request` pipelines (any branch) incl. ci-postgres service. Push CI runs on main only; publish runs on push/tag to next + manual.
- Wrapper gaps on this host per board (7 gaps; e.g. no pr-review-list, issue-assign broken, pr-merge makes no trailers): verify outcomes by reading back provider state, never trust rc alone.
- Publish pipeline currently: install → build → publish-npm/publish-next-npm (+image). No verify. CI steps: install, sanitization, upgrade-guard, typecheck, lint, format, test, ci-postgres.
## Budget
Soft cap 250K. Projected 190K across 10 cards. Track per-card used vs estimate in TASKS.md notes.
## Progress log
- 2026-08-16 23:52 — Issue #1275 created (@jarvis verified).
- 2026-08-16 23:5x — Bootstrap branch `docs/ri-050-mission-bootstrap` from origin/next@476db12b; PRD section + TASKS.md + this scratchpad written. RI-0-001 in-progress.
## Wave 1 dispatched (2026-08-17 00:35)
- RI-1-001 worker: pi glm-5.3:high, pid 2322125, worktree ri-1-001, log /var/tmp/ri-050/ri-1-001-run.log
- RI-2-001 worker: pi glm-5.3:high, pid 2322126, worktree ri-2-001, log /var/tmp/ri-050/ri-2-001-run.log
- Gotcha recorded: pi has no -f flag (that's pi-do.sh); pass brief as positional message. First launch died "Unknown option: -f" — relaunched.
- CI lane: PR #1276 (bootstrap) fails `test` at base like every next PR — fred's green #1270 unblocks (comms sent 2026-08-17T05:21Z, `comms/20260817T052148Z__from-jarvis__650fe8.md`). Merge gate for all RI PRs queues behind #1270.
- Live RI-N1 evidence posted to #1275 (comment 22915): pipeline 2439 publish-next-npm SUCCESS beside build-gateway FAILURE.
---
# HANDOFF — RI-050 continuation (written 2026-08-17 ~08:45 UTC, jarvis/dragon-lin)
You are taking over the alpha 0.0.50 release-integrity workstream in place. Everything you
need is on the remote. Read this whole file, then `docs/release-integrity/TASKS.md` (same
branch), then the PRD section (`docs/PRD.md` § Release Integrity Workstream, same branch).
## Identity / mode
- Orchestrator identity: `jarvis` (dragon-lin). You continue as the RI-050 orchestrator under
whatever identity Jason gives you — if you are NOT jarvis, say so in comms and PR bodies.
- Jason's standing directives for this mission: work happens on THIS repo (mosaicstack/stack),
PRs target `next` (NOT main), workers are local pi headless sessions on
`zai/glm-5.3:high`. Do not hand this to mos-claude. Do not borrow other seats' lanes.
- All wrapper ops: pin `GITEA_LOGIN=mosaicstack-jarvis` (issue #1275 was verified authored by
@jarvis; keep identity consistent or verify yours with issue-view and READ BACK user.login).
- CI substitution rule (this host's ci-queue-wait.sh is fail-open; fix #1032 not installed):
judge CI by SHA-status via `/api/v1/repos/mosaicstack/stack/commits/{sha}/status` or the
woodpecker API (`pipeline-status.sh -r mosaicstack/stack -n N -f json`), and DIFF THE
FAILING STEP NAMES rather than trusting rc.
## Mission state at handoff
Mission: alpha 0.0.50 release-integrity floor. Issue #1275 (open, has live-evidence comment).
Decisions SDLC-D-033..038 live in jarvis-brain
`docs/plans/2026-08-16_mosaic-stack-sdlc-protocol.md` (normative text also mirrored in the
PRD section on this branch, so this repo is self-sufficient).
Base: `origin/next` @ 476db12b. NOTE: `main` and `next` have DIVERGED — never base on main.
Branches (all pushed, all clean trees):
- `docs/ri-050-mission-bootstrap` @ 5114faa2 → PR #1276 (open, mergeable) — bootstrap docs +
this scratchpad + TASKS.md DAG. STATUS: CI red on `test` only, which is the known lane-wide
failure (see blocker below); own prettier issue already fixed.
- `feat/ri-050-publish-gate` @ 0aa5ed35 → PR #1277 (open, mergeable) — RI-1-001 COMPLETE
(worker reported success, orchestrator review PASSED: verify step asserts CI_COMMIT_SHA ==
git rev-parse HEAD then runs canonical `pnpm verify:release`; every publish/image step
depends_on verify directly, confirmed by parsing the DAG: publish-npm, publish-next-npm,
build-gateway/appservice/web all -> [build, verify]; invariant test
scripts/verify-release.test.mjs passes 7/7 locally with negative fixtures). CI: same known
lane-red `test` step only.
- `fix/ri-050-forge-fail-closed` @ 99b8f6ea → PR #1278 (open, mergeable) — RI-2-001 worker
reported success (typed `FORGE_*` capability errors, --simulate typed simulated everywhere,
vacuous true/echo gates replaced, closed ForgeOutcome set, 116 tests green incl. 16 new).
ORCHESTRATOR REVIEW NOT YET DONE — your first job. Review the diff
(1391 insertions across forge src), check the fail-closed paths and that simulated
results cannot satisfy any consumer, run `pnpm --filter @mosaicstack/forge test`.
## The one blocker
Every `next` PR pipeline is red on ONE assertion:
`packages/mosaic/framework/tools/fleet/test-start-agent-session.sh:103` ("host provides 'pi'
in the system path"). Pre-existing at base; affects PRs #1276/#1277/#1278 identically.
fred's PR #1270 ("unblocks every PR on next") is green and open — it is HIS to merge; do not
merge it yourself. jarvis sent comms (`comms/20260817T052148Z__from-jarvis__650fe8.md` in
jarvis-brain) asking merge timing; no reply yet as of handoff. Merge gates for ALL RI PRs
queue behind #1270 landing. Until then: review/develop freely, merge nothing that needs the
green gate (docs-only #1276 arguably could merge red-lane with Jason's explicit call — ask,
don't assume).
## Remaining DAG (docs/release-integrity/TASKS.md is canonical)
Wave 2 (next): RI-2-002 MACP fail-closed (brief pattern: mirror RI-2-001 for
packages/macp/src/gate-runner.ts — empty commands, stub executors, unimplemented CI-provider
gates fail closed; explicit simulate) and RI-4-001 PRD authority (one PRD service;
@mosaicstack/prdy docs/prdy authoritative via `mosaic mission --plan`; `mosaic prdy` routes
or becomes named Markdown adapter; mission<->PRD linkage persists — see PRD RI-N3).
Wave 3: RI-3-001 probe inventory (docs), RI-5-001 web stale-safety.
Wave 4: RI-1-002 negative-control tests, RI-3-002 TS evaluator absorbs shell probes.
Final: RI-V-001 evidence pack (real green next publish run post-gate + all cards verified).
## Worker mechanics (measured, reuse)
- Dispatch: create worktree `git -C /home/jwoltje/src/mosaic-stack worktree add
/home/jwoltje/src/mosaic-stack-worktrees/<id> -b <branch> origin/next`, write a brief to
/var/tmp/ri-050/, then run from INSIDE the worktree:
`pi -p --no-session --model zai/glm-5.3:high --tools read,bash,edit,write "$(cat brief.md)"`
(pi has NO -f flag — pass the brief as a positional message; first dispatch died on that).
- Briefs for 1-001/2-001 are at /var/tmp/ri-050/ on dragon-lin (may not survive; the
pattern is fully described above and in TASKS.md).
- Briefs must carry: worktree path, branch, base, requirements, known base-red list (so the
worker doesn't chase it), gates to run, PR creation command with GITEA_LOGIN pin, "do NOT
merge, do NOT touch docs/TASKS.md", and the JSON report format.
- Verify worker claims: read the PR, run their tests yourself, parse pipeline step names.
## Do-not-touch
- Main checkout at /home/jwoltje/src/mosaic-stack (dirty diverged main) — never touch.
- fred's open PRs (#1270 and others) — review evidence welcome, merging his is not yours.
- Other RI PRs' authors' lanes: #1277/#1278 are yours to gate and merge ONCE lane is green
and review is recorded.
- Never `--no-verify`; never bypass the wrapper-fails-closed rule (wrapper failure ⇒
`blocked + report exact command + stop`).
## Session-restore command sequence
1. `git -C /home/jwoltje/src/mosaic-stack-worktrees/ri-050 fetch origin --prune`
2. Read this file + `docs/release-integrity/TASKS.md` + PRD section.
3. Check PR states (#1270, #1276, #1277, #1278) and lane CI (SHA-status per above).
4. Review RI-2-001 (PR #1278) if not yet done; then dispatch wave 2.
— jarvis, 2026-08-17
---
# CONTINUATION — fargo (sb-it-1-dt)
Orchestrator seat is now **fargo** on sb-it-1-dt (Jason, 2026-08-17): Claude seat, worktree discipline
per fred's ruling (`~/agent-work/<slug>`, create → work → commit → push → remove as one act; the
helper's `/src` refusal is a web1 convention, does not bind here). fred supports; lane rulings are
his. Workers remain local pi `zai/glm-5.3:high` + limited Claude per Jason.
## 2026-08-17 — RI-2-001 independent review DONE
- **PR #1278 APPROVED** (Gitea review 172, pinned to head 99b8f6ea). Executed evidence, not read-only:
forge suite 116/116 at head (matches PR claim), forge lint green, forge typecheck green after
building `@mosaicstack/macp` dist (TS2307 on bare `pnpm install --frozen-lockfile` is a
minimal-install build-order artifact — the macp import is type-only, vitest passes unbuilt; CI
installs build workspace deps, hence green there), **workspace typecheck 45/45 at head**,
consumer sweep: no external type consumers of RunManifest/StageStatus/ForgeTaskResult/
TaskExecutor; only importer of the package is packages/mosaic via registerForgeCommand
(smoke test asserts registration/help only — cannot break). Digest gate (shaggy's) before==after
with both-arm reactivity controls.
- CI red on #1276/#1277/#1278: lane-wide `test` failure only
(test-start-agent-session.sh:103, fred's guard mis-wired; #1270 unwires it). Fred measured log
content: one real byte-identical failure per pipeline (2456/2457/2458); 13 of ~14 `FAIL` grep
hits are passing fail-loud test NAMES. **The red carries no information about the RI changes.**
- Non-blocking finding: README L141-143 + skills/mosaic-forge/SKILL.md document bare
`mosaic forge run`/`resume`, which now exits 1 FORGE_NO_EXECUTOR — fast-follow docs touch.
- **Identity incident, ruled on by fred:** review 172 recorded under shared host principal
mos-dt-0, not fargo. Mechanism (measured, wrapper source): pr-review.sh resolves its acting login
from the tea login list only; no fargo tea login on this host → silent host-default fallback;
MOSAIC_GIT_IDENTITY is only read in detect-platform.sh get_gitea_token's fallback arm, never
reached. Exact-id read-back verifies against the writing token, so it passed while attribution
was wrong — durable-provenance machinery proves the write, not the seat. Fred's ruling: review
172 stands (substance/verdict/pin correct; label wrong); NO re-approval (one approval,
annotated, is the stronger record); fred posts the provenance correction under @fred with
--login fred-ms (hard-fail path); no fargo tea login ever (freeze + Jason's to authorize);
tooling gap filed by fred. Also explains (does not reopen) #1228's mos-dt-0 attribution.
- Merge gate: all RI PRs queue behind fred's green #1270 (Jason's call).
## Next
1. Wave 2 dispatch: RI-2-002 (MACP fail-closed, mirror RI-2-001 pattern for
packages/macp/src/gate-runner.ts) + RI-4-001 (PRD authority). Two parallel workers max.
2. Docs fast-follow (README + mosaic-forge skill) — fold into #1276 or a tiny docs card.
3. RI-V-001 evidence at the end.
— fargo, 2026-08-17
---
# RESUMPTION + DAILY-HANDOFF PROTOCOL (Jason, 2026-08-17)
Orchestrator seat is back with **jarvis** (dragon-lin). Expect daily handoff between jarvis
and fargo. Protocol (both seats, every handoff):
1. **This file is the shared mission log.** Append a dated section per session: state
measured, actions taken, PR/review states, next actions. Never rewrite prior sections.
2. **TASKS.md stays current within one session** — status, PR number in notes, review
evidence. Stale rows are handoff debt.
3. **Cross-review rule (SDLC-D-011 in practice):** the reviewing seat must differ from the
producing seat. jarvis reviews fargo-dispatched PRs, fargo reviews jarvis-dispatched
PRs. Producers are always pi workers; dispatching seats verify before push; the other
seat records the Gitea review.
4. Handoff = append here + push + (optional) issue #1275 comment if a decision changed.
## RESUMED — jarvis/dragon-lin, 2026-08-17 (afternoon)
- Measured: next = 8199261c (#1270 merged — lane unblocked for new PRs). #1293/#1294
(fargo, wave 2) CI-green, mergeable, no recorded reviews. #1276/#1277/#1278 still based
on 476db12b with stale red CI → need rebase onto 8199261c. #1278 review pinned to old
head 99b8f6ea by @mos-dt-0 (fargo's, mis-attributed per his note) — rebase will dismiss
it; re-approval must come from fargo/fred (author is @jarvis, cannot self-approve).
- Live evidence #2: push pipeline 2462 (the #1270 merge itself) ran publish-next-npm
SUCCESS beside build-gateway FAILURE again.
- Plan: rebase the three original branches; independently review #1293/#1294; merge order
once green+reviewed: #1276 (docs) → #1277 (publish gate) → #1278/#1293/#1294 (code).
After #1277 merges, watch the next push pipeline prove the verify gate live.
- fargo's non-RI PRs (#1291/#1296/#1297/#1281) stay strictly his lane.
## jarvis session 2026-08-17 (evening) — reviews, rebases, merge plan
- Rebased #1276/#1277/#1278 onto 8199261c (heads 59e2c460 / 46784c8d / 4917df1f);
invariant tests 7/7 and forge 116/116 re-run green at new heads. #1270 touched
test-enumeration-exclusions.txt + package.json, NOT ci.yml — no semantic overlap with
#1277's ci.yml changes (checked, was a real concern).
- Independent reviews recorded: #1293 APPROVED (review 173; macp 109/109; fail-closed paths
+ aggregate state machine verified), #1294 APPROVED (review 174; prdy 20/20 + command
specs 9/9; single-writer + linkage persistence + labeled export + conflict-aware import
verified). Note: 19 unrelated mosaic suites fail on bare minimal install (known workspace
build-order artifact, documented by fargo) — not this change.
- Measured: `next` has NO branch protection (API: only main listed). Cross-seat review
discipline is protocol-enforced, not Gitea-enforced. Flagged to fargo for Jason: direct
pushes to next trigger ungated publishes; protection is Jason's call (#1231 adjacent).
- Merge order planned: #1276 (docs-only — no publish run) -> #1277 (first gated publish)
-> #1278 -> #1293 -> #1294. Sent fargo review requests with pinned head SHAs
(comms/20260818T011932Z__from-jarvis__a9c02b.md). Not merging #1293/#1294 before my three
clear fargo's review — order optimality beats speed; every pre-#1277 merge publishes ungated.
- CI on the three rebased heads: pending at time of this entry.
+1
View File
@@ -34,6 +34,7 @@ export default tseslint.config(
'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts',
'packages/mosaic/__tests__/*.ts',
'packages/forge/__tests__/*.ts',
'tools/federation-harness/*.ts',
],
},
+1
View File
@@ -9,6 +9,7 @@
"preflight": "node scripts/preflight.mjs",
"clean:generated": "node scripts/clean-generated.mjs",
"typecheck": "pnpm preflight && turbo run typecheck",
"verify:release": "node scripts/verify-release.mjs",
"test:checkout": "node --test scripts/*.test.mjs",
"test": "pnpm test:checkout && turbo run test && pnpm run test:installer",
"test:installer": "bash tools/install-next-lane.test.sh",
+40
View File
@@ -539,3 +539,43 @@ Not every brief needs full Board of Directors review. The classification system
### 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`.
---
## 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`.
@@ -0,0 +1,319 @@
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 });
}
});
});
+161 -34
View File
@@ -12,10 +12,10 @@ import {
resumePipeline,
getPipelineStatus,
} from '../src/pipeline-runner.js';
import type { ForgeTask, RunManifest, TaskExecutor } from '../src/types.js';
import type { TaskResult } from '@mosaicstack/macp';
import type { ForgeTask, ForgeTaskResult, RunManifest, TaskExecutor } from '../src/types.js';
import { gateLabel, isCommandGate } from '../src/outcomes.js';
/** Mock TaskExecutor that records submitted tasks and returns success. */
/** Mock TaskExecutor that records submitted tasks and returns typed results. */
function createMockExecutor(options?: {
failStage?: string;
}): TaskExecutor & { submittedTasks: ForgeTask[] } {
@@ -25,7 +25,7 @@ function createMockExecutor(options?: {
async submitTask(task: ForgeTask) {
submittedTasks.push(task);
},
async waitForCompletion(taskId: string): Promise<TaskResult> {
async waitForCompletion(taskId: string): Promise<ForgeTaskResult> {
const failStage = options?.failStage;
const task = submittedTasks.find((t) => t.id === taskId);
const stageName = task?.metadata?.['stageName'] as string | undefined;
@@ -33,7 +33,8 @@ function createMockExecutor(options?: {
if (failStage && stageName === failStage) {
return {
task_id: taskId,
status: 'failed',
outcome: 'failed',
reason: 'mock task failure',
completed_at: new Date().toISOString(),
exit_code: 1,
gate_results: [],
@@ -41,10 +42,17 @@ function createMockExecutor(options?: {
}
return {
task_id: taskId,
status: 'completed',
outcome: 'passed',
reason: 'mock verified',
completed_at: new Date().toISOString(),
exit_code: 0,
gate_results: [],
gate_results: (task?.qualityGates ?? [])
.filter((gate) => isCommandGate(gate))
.map((gate) => ({
gate: gateLabel(gate),
outcome: 'passed' as const,
reason: 'mock verified',
})),
};
},
async getTaskStatus() {
@@ -156,12 +164,13 @@ describe('runPipeline', () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery'],
stages: ['00-intake', '05-coding'],
});
expect(result.runId).toMatch(/^\d{8}-\d{6}$/);
expect(result.stages).toEqual(['00-intake', '00b-discovery']);
expect(result.stages).toEqual(['00-intake', '05-coding']);
expect(result.manifest.status).toBe('completed');
expect(result.manifest.mode).toBe('normal');
expect(executor.submittedTasks).toHaveLength(2);
});
@@ -180,12 +189,17 @@ describe('runPipeline', () => {
const executor = createMockExecutor();
const result = await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery'],
stages: ['00-intake', '05-coding'],
});
const manifest = loadManifest(result.runDir);
expect(manifest.stages['00-intake']?.status).toBe('passed');
expect(manifest.stages['00b-discovery']?.status).toBe('passed');
expect(manifest.stages['05-coding']?.status).toBe('passed');
expect(manifest.stages['05-coding']?.gateResults?.map((g) => g.outcome)).toEqual([
'passed',
'passed',
'passed',
]);
});
it('respects CLI class override', async () => {
@@ -215,7 +229,7 @@ describe('runPipeline', () => {
const executor = createMockExecutor();
await runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery', '02-planning-1'],
stages: ['00-intake', '05-coding', '08-test'],
});
expect(executor.submittedTasks[0]!.dependsOn).toBeUndefined();
@@ -224,14 +238,14 @@ describe('runPipeline', () => {
});
it('handles stage failure', async () => {
const executor = createMockExecutor({ failStage: '00b-discovery' });
const executor = createMockExecutor({ failStage: '05-coding' });
await expect(
runPipeline(briefPath, tmpDir, {
executor,
stages: ['00-intake', '00b-discovery'],
stages: ['00-intake', '05-coding'],
}),
).rejects.toThrow('Stage 00b-discovery failed');
).rejects.toThrow('Stage 05-coding failed');
});
it('marks manifest as failed on stage failure', async () => {
@@ -270,30 +284,143 @@ describe('resumePipeline', () => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('resumes from first incomplete stage', async () => {
// First run fails on discovery
const executor1 = createMockExecutor({ failStage: '00b-discovery' });
let runDir: string;
it('resumes from first incomplete stage and fails closed at the next provider gate', async () => {
// Simulate a run whose authority stages were approved out-of-band
// (recorded as passed) and whose coding stage failed mechanically.
const runId = '20260101-000000';
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' },
},
});
try {
await runPipeline(briefPath, tmpDir, {
executor: executor1,
stages: ['00-intake', '00b-discovery', '02-planning-1'],
});
} catch {
// expected
// Resume re-runs 05-coding (the first non-passed stage), then fails
// closed at 06-review because no reviewer provider is wired.
const executor = createMockExecutor();
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_NO_REVIEWER',
});
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');
}
});
const runsDir = path.join(tmpDir, '.forge', 'runs');
runDir = path.join(runsDir, fs.readdirSync(runsDir)[0]!);
it('fails closed on resume when the next stage needs authority sign-off', async () => {
const runId = '20260101-000001';
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' },
},
});
// Resume should pick up from 00b-discovery
const executor2 = createMockExecutor();
const result = await resumePipeline(runDir, executor2);
const executor = createMockExecutor();
await expect(resumePipeline(runDir, executor)).rejects.toMatchObject({
name: 'ForgeCapabilityError',
code: 'FORGE_AUTHORITY_REQUIRED',
});
expect(result.manifest.status).toBe('completed');
// Should have re-run from 00b-discovery onward
expect(result.stages[0]).toBe('00b-discovery');
const manifest = loadManifest(runDir);
expect(manifest.stages['00b-discovery']?.status).toBe('waiting-for-authority');
expect(manifest.status).toBe('waiting-for-authority');
});
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',
});
});
});
+15 -2
View File
@@ -95,7 +95,14 @@ export function generateBoardTasks(
briefPath,
resultPath: resultRelPath,
timeoutSeconds: 120,
qualityGates: ['true'],
qualityGates: [
{
kind: 'authority',
capability: 'board-approval',
reason:
'persona evaluation is judged by board synthesis (authority review); no mechanical gate exists',
},
],
metadata: {
personaName: persona.name,
personaSlug: persona.slug,
@@ -121,7 +128,13 @@ export function generateBoardTasks(
timeoutSeconds: 120,
dependsOn: personaTaskIds,
dependsOnPolicy: 'all_terminal',
qualityGates: ['true'],
qualityGates: [
{
kind: 'authority',
capability: 'board-approval',
reason: 'board synthesis is an authority decision; no mechanical gate exists',
},
],
metadata: {
resultOutputPath: synthesisResult,
inputResultPaths: personaResultPaths,
+96 -1
View File
@@ -1,7 +1,11 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Command } from 'commander';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { registerForgeCommand } from './cli.js';
import { loadManifest } from './pipeline-runner.js';
describe('registerForgeCommand', () => {
it('registers a "forge" command on the parent program', () => {
@@ -55,3 +59,94 @@ describe('registerForgeCommand', () => {
}).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');
});
});
+122 -48
View File
@@ -5,37 +5,47 @@ import type { Command } from 'commander';
import { classifyBrief } from './brief-classifier.js';
import { STAGE_LABELS, STAGE_SEQUENCE } from './constants.js';
import { ForgeCapabilityError } from './errors.js';
import { getEffectivePersonas, loadBoardPersonas } from './persona-loader.js';
import { generateRunId, getPipelineStatus, loadManifest, runPipeline } from './pipeline-runner.js';
import type { PipelineOptions, RunManifest, TaskExecutor } 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;
},
};
import { createSimulatedExecutor } from './simulated-executor.js';
import type { PipelineOptions, RunManifest, RunMode } from './types.js';
// ---------------------------------------------------------------------------
// 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 {
if (!startedAt || !completedAt) return '-';
const ms = new Date(completedAt).getTime() - new Date(startedAt).getTime();
@@ -44,19 +54,24 @@ function formatDuration(startedAt?: string, completedAt?: string): string {
}
function printManifestTable(manifest: RunManifest): void {
const mode = runModeOf(manifest);
console.log(`\nRun ID : ${manifest.runId}`);
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(`Class : ${manifest.briefClass} (${manifest.classSource})`);
console.log(`Updated: ${manifest.updatedAt}`);
console.log('');
console.log('Stage'.padEnd(22) + 'Status'.padEnd(14) + 'Duration');
console.log('-'.repeat(50));
console.log('Stage'.padEnd(22) + 'Status'.padEnd(24) + 'Duration');
console.log('-'.repeat(60));
for (const stage of STAGE_SEQUENCE) {
const s = manifest.stages[stage];
if (!s) continue;
const label = (STAGE_LABELS[stage] ?? stage).padEnd(22);
const status = s.status.padEnd(14);
const status = s.status.padEnd(24);
const dur = formatDuration(s.startedAt, s.completedAt);
console.log(`${label}${status}${dur}`);
}
@@ -90,23 +105,58 @@ function listRecentRuns(projectRoot?: string): void {
}
console.log('\nRecent runs:');
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(14) + 'Brief');
console.log('-'.repeat(70));
console.log('Run ID'.padEnd(22) + 'Status'.padEnd(24) + 'Mode'.padEnd(12) + 'Brief');
console.log('-'.repeat(80));
for (const runId of entries) {
const runDir = path.join(runsDir, runId);
try {
const manifest = loadManifest(runDir);
const status = manifest.status.padEnd(14);
const status = manifest.status.padEnd(24);
const mode = runModeOf(manifest).padEnd(12);
const brief = path.basename(manifest.brief);
console.log(`${runId.padEnd(22)}${status}${brief}`);
console.log(`${runId.padEnd(22)}${status}${mode}${brief}`);
} catch {
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(14)}`);
console.log(`${runId.padEnd(22)}${'(unreadable)'.padEnd(24)}`);
}
}
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
// ---------------------------------------------------------------------------
@@ -129,6 +179,11 @@ export function registerForgeCommand(parent: Command): void {
.option('--config <path>', 'Path to forge config file (.forge/config.yaml)')
.option('--codebase <path>', 'Codebase root to pass to the pipeline', process.cwd())
.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(
async (opts: {
brief: string;
@@ -137,6 +192,7 @@ export function registerForgeCommand(parent: Command): void {
config?: string;
codebase: string;
dryRun: boolean;
simulate: boolean;
}) => {
const briefPath = path.resolve(opts.brief);
@@ -149,14 +205,22 @@ export function registerForgeCommand(parent: Command): void {
const briefContent = fs.readFileSync(briefPath, 'utf-8');
const briefClass = classifyBrief(briefContent);
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) {
const runId = opts.runId ?? generateRunId();
const runDir = resolveRunDir(runId, projectRoot);
console.log(`[forge] resuming run: ${runId}`);
const { resumePipeline } = await import('./pipeline-runner.js');
const result = await resumePipeline(runDir, stubExecutor);
console.log(`[forge] pipeline complete: ${result.runId}`);
try {
const { resumePipeline } = await import('./pipeline-runner.js');
const result = await resumePipeline(runDir, executor, { simulate: opts.simulate });
applyRunExitPolicy(result, opts.simulate);
} catch (err) {
handlePipelineError(err);
}
return;
}
@@ -164,7 +228,8 @@ export function registerForgeCommand(parent: Command): void {
briefClass,
codebase: projectRoot,
dryRun: opts.dryRun,
executor: stubExecutor,
executor,
simulate: opts.simulate,
};
if (opts.dryRun) {
@@ -180,16 +245,15 @@ export function registerForgeCommand(parent: Command): void {
console.log(`[forge] starting pipeline for brief: ${briefPath}`);
console.log(`[forge] classified as: ${briefClass}`);
if (opts.simulate) {
console.log('[forge] mode: SIMULATED (explicit --simulate)');
}
try {
const result = await runPipeline(briefPath, projectRoot, pipelineOptions);
console.log(`[forge] pipeline complete: ${result.runId}`);
console.log(`[forge] run directory: ${result.runDir}`);
applyRunExitPolicy(result, opts.simulate);
} catch (err) {
console.error(
`[forge] pipeline failed: ${err instanceof Error ? err.message : String(err)}`,
);
process.exitCode = 1;
handlePipelineError(err);
}
},
);
@@ -224,7 +288,12 @@ export function registerForgeCommand(parent: Command): void {
.command('resume <runId>')
.description('Resume a stopped or failed pipeline run')
.option('--project <path>', 'Project root (defaults to cwd)', process.cwd())
.action(async (runId: string, opts: { project: string }) => {
.option(
'--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);
if (!fs.existsSync(runDir)) {
@@ -234,15 +303,20 @@ export function registerForgeCommand(parent: Command): void {
}
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 {
const { resumePipeline } = await import('./pipeline-runner.js');
const result = await resumePipeline(runDir, stubExecutor);
console.log(`[forge] pipeline complete: ${result.runId}`);
console.log(`[forge] run directory: ${result.runDir}`);
const result = await resumePipeline(runDir, executor, { simulate: opts.simulate });
applyRunExitPolicy(result, opts.simulate);
} catch (err) {
console.error(`[forge] resume failed: ${err instanceof Error ? err.message : String(err)}`);
process.exitCode = 1;
handlePipelineError(err);
}
});
+72 -12
View File
@@ -9,7 +9,16 @@ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.
/** Pipeline asset directory (stages, agents, rails, gates, templates). */
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> = {
'00-intake': {
number: '00',
@@ -27,7 +36,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research',
gate: 'discovery-complete',
promptFile: '00b-discovery.md',
qualityGates: ['true'],
qualityGates: [
{
kind: 'authority',
capability: 'discovery-complete',
reason: 'discovery completion is attested by an authority; no mechanical check exists',
},
],
},
'01-board': {
number: '01',
@@ -36,7 +51,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'review',
gate: 'board-approval',
promptFile: '01-board.md',
qualityGates: [{ type: 'ci-pipeline', command: 'board-approval (via board-tasks)' }],
qualityGates: [
{
kind: 'authority',
capability: 'board-approval',
reason: 'board approval is a board/human decision; no mechanical gate exists',
},
],
},
'01b-brief-analyzer': {
number: '01b',
@@ -45,7 +66,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research',
gate: 'brief-analysis-complete',
promptFile: '01-board.md',
qualityGates: ['true'],
qualityGates: [
{
kind: 'authority',
capability: 'brief-analysis-complete',
reason: 'brief analysis completion is attested by an authority; no mechanical check exists',
},
],
},
'02-planning-1': {
number: '02',
@@ -54,7 +81,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research',
gate: 'architecture-approval',
promptFile: '02-planning-1-architecture.md',
qualityGates: ['true'],
qualityGates: [
{
kind: 'authority',
capability: 'architecture-approval',
reason: 'ADR approval requires authority sign-off; no mechanical check exists',
},
],
},
'03-planning-2': {
number: '03',
@@ -63,7 +96,14 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research',
gate: 'implementation-approval',
promptFile: '03-planning-2-implementation.md',
qualityGates: ['true'],
qualityGates: [
{
kind: 'authority',
capability: 'implementation-approval',
reason:
'implementation spec approval requires authority sign-off; no mechanical check exists',
},
],
},
'04-planning-3': {
number: '04',
@@ -72,7 +112,14 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'research',
gate: 'decomposition-approval',
promptFile: '04-planning-3-decomposition.md',
qualityGates: ['true'],
qualityGates: [
{
kind: 'authority',
capability: 'decomposition-approval',
reason:
'task decomposition approval requires authority sign-off; no mechanical check exists',
},
],
},
'05-coding': {
number: '05',
@@ -92,9 +139,10 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
promptFile: '06-review.md',
qualityGates: [
{
type: 'ai-review',
command:
'echo \'{"summary":"review-pass","verdict":"approve","findings":[],"stats":{"blockers":0,"should_fix":0,"suggestions":0}}\'',
kind: 'provider',
capability: 'reviewer',
reason:
'review verdicts require a wired reviewer provider; synthetic approvals are not permitted',
},
],
},
@@ -105,7 +153,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'coding',
gate: 're-review',
promptFile: '07-remediate.md',
qualityGates: ['true'],
qualityGates: [
{
kind: 'authority',
capability: 're-review',
reason: 'remediation re-review is an approval-based gate; no mechanical check exists',
},
],
},
'08-test': {
number: '08',
@@ -123,7 +177,13 @@ export const STAGE_SPECS: Record<string, StageSpec> = {
type: 'deploy',
gate: 'deploy-verification',
promptFile: '09-deploy.md',
qualityGates: [{ type: 'ci-pipeline', command: 'deploy-verification' }],
qualityGates: [
{
kind: 'provider',
capability: 'ci-pipeline',
reason: 'deploy verification requires a wired CI pipeline provider',
},
],
},
};
+46
View File
@@ -0,0 +1,46 @@
/**
* 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,6 +5,13 @@ export type {
StageSpec,
BriefClass,
ClassSource,
ForgeOutcome,
AuthorityGate,
ProviderGate,
ForgeGate,
ForgeGateResult,
ForgeTaskResult,
RunMode,
StageStatus,
RunManifest,
ForgeTaskStatus,
@@ -81,5 +88,24 @@ export {
getPipelineStatus,
} 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
export { registerForgeCommand } from './cli.js';
+147
View File
@@ -0,0 +1,147 @@
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,
};
}
+227 -99
View File
@@ -1,18 +1,33 @@
import fs from 'node:fs';
import path from 'node:path';
import { STAGE_SEQUENCE } from './constants.js';
import { STAGE_SEQUENCE, STAGE_SPECS } from './constants.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 { createSimulatedExecutor } from './simulated-executor.js';
import type {
ForgeTask,
ForgeTaskResult,
PipelineOptions,
PipelineResult,
RunManifest,
RunMode,
StageStatus,
TaskExecutor,
} 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.
*/
@@ -47,6 +62,7 @@ function createManifest(opts: {
briefClass: RunManifest['briefClass'];
classSource: RunManifest['classSource'];
forceBoard: boolean;
mode: RunMode;
runDir: string;
}): RunManifest {
const ts = nowISO();
@@ -57,6 +73,7 @@ function createManifest(opts: {
briefClass: opts.briefClass,
classSource: opts.classSource,
forceBoard: opts.forceBoard,
mode: opts.mode,
createdAt: ts,
updatedAt: ts,
currentStage: '',
@@ -108,20 +125,199 @@ export function selectStages(stages?: string[], skipTo?: string): string[] {
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.
*
* 1. Classify the brief
* 2. Generate a run ID and create run directory
* 3. Map stages to tasks and submit to TaskExecutor
* 4. Track manifest with stage statuses
* 5. Return pipeline result
* 1. Fail closed unless a real executor is wired or simulation is explicit
* 2. Classify the brief
* 3. Generate a run ID and create run directory
* 4. Map stages to tasks and submit to TaskExecutor
* 5. Track manifest with typed stage outcomes
* 6. Return pipeline result
*/
export async function runPipeline(
briefPath: string,
projectRoot: string,
options: PipelineOptions,
): 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 resolvedBrief = path.resolve(briefPath);
const briefContent = fs.readFileSync(resolvedBrief, 'utf-8');
@@ -146,6 +342,7 @@ export async function runPipeline(
briefClass,
classSource,
forceBoard: options.forceBoard ?? false,
mode,
runDir,
});
@@ -172,54 +369,10 @@ export async function runPipeline(
}
// Execute stages
const { executor } = options;
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i]!;
const stageName = selectedStages[i]!;
await executeStages({ manifest, runDir, tasks, stageNames: selectedStages, executor, simulate });
// Update manifest: stage in progress
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';
// All stages reached a terminal state for this mode
manifest.status = simulate ? 'simulated' : 'completed';
saveManifest(runDir, manifest);
return {
@@ -234,22 +387,30 @@ export async function runPipeline(
}
/**
* Resume a pipeline from the last incomplete stage.
* Resume a pipeline from the last non-passed stage.
*/
export async function resumePipeline(
runDir: string,
executor: TaskExecutor,
executor?: TaskExecutor,
options?: { simulate?: boolean },
): Promise<PipelineResult> {
const simulate = options?.simulate ?? false;
const wiredExecutor = requireExecutor(executor, simulate);
const mode: RunMode = simulate ? 'simulated' : 'normal';
const manifest = loadManifest(runDir);
const resolvedRoot = path.dirname(path.dirname(path.dirname(runDir))); // .forge/runs/{id} → project root
const briefContent = fs.readFileSync(manifest.brief, 'utf-8');
const allStages = stagesForClass(manifest.briefClass, manifest.forceBoard);
// Find first non-passed stage
manifest.mode = mode;
// 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');
if (!resumeFrom) {
manifest.status = 'completed';
manifest.status = mode === 'simulated' ? 'simulated' : 'completed';
saveManifest(runDir, manifest);
return {
runId: manifest.runId,
@@ -284,49 +445,16 @@ export async function resumePipeline(
tasks.push(task);
}
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i]!;
const stageName = remainingStages[i]!;
await executeStages({
manifest,
runDir,
tasks,
stageNames: remainingStages,
executor: wiredExecutor,
simulate,
});
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';
manifest.status = simulate ? 'simulated' : 'completed';
saveManifest(runDir, manifest);
return {
+32
View File
@@ -0,0 +1,32 @@
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;
},
};
}
+88 -7
View File
@@ -1,4 +1,4 @@
import type { GateEntry, TaskResult } from '@mosaicstack/macp';
import type { GateEntry } from '@mosaicstack/macp';
/** Stage dispatch mode. */
export type StageDispatch = 'exec' | 'yolo' | 'pi';
@@ -6,6 +6,58 @@ export type StageDispatch = 'exec' | 'yolo' | 'pi';
/** Stage type — determines agent selection and gate requirements. */
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. */
export interface StageSpec {
number: string;
@@ -14,7 +66,7 @@ export interface StageSpec {
type: StageType;
gate: string;
promptFile: string;
qualityGates: (string | GateEntry)[];
qualityGates: ForgeGate[];
}
/** Brief classification. */
@@ -25,11 +77,18 @@ export type ClassSource = 'cli' | 'frontmatter' | 'auto';
/** Per-stage status within a run manifest. */
export interface StageStatus {
status: 'pending' | 'in_progress' | 'passed' | 'failed';
status: 'pending' | 'in_progress' | ForgeOutcome;
/** Why the stage reached its current (terminal) outcome, when applicable. */
reason?: string;
startedAt?: 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. */
export interface RunManifest {
runId: string;
@@ -38,10 +97,23 @@ export interface RunManifest {
briefClass: BriefClass;
classSource: ClassSource;
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;
updatedAt: string;
currentStage: string;
status: 'in_progress' | 'completed' | 'failed' | 'interrupted' | 'rejected';
status:
| 'in_progress'
| 'completed'
| 'failed'
| 'interrupted'
| 'rejected'
| 'simulated'
| 'waiting-for-authority';
stages: Record<string, StageStatus>;
}
@@ -65,7 +137,7 @@ export interface ForgeTask {
briefPath: string;
resultPath: string;
timeoutSeconds: number;
qualityGates: (string | GateEntry)[];
qualityGates: ForgeGate[];
worktree?: string;
command?: string;
dependsOn?: string[];
@@ -76,7 +148,7 @@ export interface ForgeTask {
/** Abstract task executor — decouples from packages/coord. */
export interface TaskExecutor {
submitTask(task: ForgeTask): Promise<void>;
waitForCompletion(taskId: string, timeoutMs: number): Promise<TaskResult>;
waitForCompletion(taskId: string, timeoutMs: number): Promise<ForgeTaskResult>;
getTaskStatus(taskId: string): Promise<ForgeTaskStatus>;
}
@@ -122,7 +194,16 @@ export interface PipelineOptions {
stages?: string[];
skipTo?: string;
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. */
-253
View File
@@ -1,253 +0,0 @@
import { mkdirSync, readFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { normalizeGate, countAIFindings, runGate, runGates } from '../src/gate-runner.js';
function makeTmpDir(): string {
const dir = join(tmpdir(), `macp-gate-${randomUUID()}`);
mkdirSync(dir, { recursive: true });
return dir;
}
describe('normalizeGate', () => {
it('normalizes a string to mechanical gate', () => {
expect(normalizeGate('echo test')).toEqual({
command: 'echo test',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('normalizes an object gate with defaults', () => {
expect(normalizeGate({ command: 'lint' })).toEqual({
command: 'lint',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('preserves explicit type and fail_on', () => {
expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({
command: 'review',
type: 'ai-review',
fail_on: 'any',
});
});
it('handles non-string/non-object input', () => {
expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
});
});
describe('countAIFindings', () => {
it('returns zeros for non-object', () => {
expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 });
});
it('counts from stats block', () => {
const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } };
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 });
});
it('counts from findings array when stats has no blockers', () => {
const output = {
stats: { blockers: 0 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 });
});
it('uses stats blockers over findings array when stats has blockers', () => {
const output = {
stats: { blockers: 5 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }],
};
// stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero
expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 });
});
it('counts findings length as total when stats has zero total', () => {
const output = {
findings: [{ severity: 'warning' }, { severity: 'info' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 });
});
});
describe('runGate', () => {
let tmp: string;
let logPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = join(tmp, 'gate.log');
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('passes mechanical gate on exit 0', () => {
const result = runGate('echo hello', tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.exit_code).toBe(0);
expect(result.type).toBe('mechanical');
expect(result.output).toContain('hello');
});
it('fails mechanical gate on non-zero exit', () => {
const result = runGate('exit 1', tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(1);
});
it('ci-pipeline always passes', () => {
const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.type).toBe('ci-pipeline');
expect(result.output).toBe('CI pipeline gate placeholder');
});
it('empty command passes', () => {
const result = runGate({ command: '' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
});
it('ai-review gate parses JSON output', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.blockers).toBe(0);
expect(result.findings).toBe(1);
});
it('ai-review gate fails on blockers', () => {
const json = JSON.stringify({ stats: { blockers: 2 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.blockers).toBe(2);
});
it('ai-review gate with fail_on=any fails on any findings', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate(
{ command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' },
tmp,
logPath,
30,
);
expect(result.passed).toBe(false);
expect(result.fail_on).toBe('any');
});
it('ai-review gate fails on invalid JSON output', () => {
const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.parse_error).toBeDefined();
});
it('writes to log file', () => {
runGate('echo logged', tmp, logPath, 30);
const log = readFileSync(logPath, 'utf-8');
expect(log).toContain('COMMAND: echo logged');
expect(log).toContain('logged');
expect(log).toContain('EXIT:');
});
});
describe('runGates', () => {
let tmp: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = join(tmp, 'gates.log');
eventsPath = join(tmp, 'events.ndjson');
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
it('runs multiple gates and returns results', () => {
const { allPassed, gateResults } = runGates(
['echo one', 'echo two'],
tmp,
logPath,
30,
eventsPath,
'task-1',
);
expect(allPassed).toBe(true);
expect(gateResults).toHaveLength(2);
});
it('reports failure when any gate fails', () => {
const { allPassed, gateResults } = runGates(
['echo ok', 'exit 1'],
tmp,
logPath,
30,
eventsPath,
'task-2',
);
expect(allPassed).toBe(false);
expect(gateResults[0]!.passed).toBe(true);
expect(gateResults[1]!.passed).toBe(false);
});
it('emits events for each gate', () => {
runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3');
const events = readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
expect(events).toHaveLength(2); // started + passed
expect(events[0].event_type).toBe('rail.check.started');
expect(events[1].event_type).toBe('rail.check.passed');
});
it('skips gates with empty command (non ci-pipeline)', () => {
const { gateResults } = runGates(
[{ command: '', type: 'mechanical' }, 'echo real'],
tmp,
logPath,
30,
eventsPath,
'task-4',
);
expect(gateResults).toHaveLength(1);
});
it('does not skip ci-pipeline even with empty command', () => {
const { gateResults } = runGates(
[{ command: '', type: 'ci-pipeline' }],
tmp,
logPath,
30,
eventsPath,
'task-5',
);
expect(gateResults).toHaveLength(1);
expect(gateResults[0]!.passed).toBe(true);
});
it('emits failed event with correct message', () => {
runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6');
const events = readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
const failEvent = events.find(
(e: Record<string, unknown>) => e.event_type === 'rail.check.failed',
);
expect(failEvent).toBeDefined();
expect(failEvent.message).toContain('Gate failed (');
});
});
+163 -1
View File
@@ -1,5 +1,8 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import { Command } from 'commander';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { registerMacpCommand } from './cli.js';
describe('registerMacpCommand', () => {
@@ -75,3 +78,162 @@ describe('registerMacpCommand', () => {
expect(topLevel).toContain('events');
});
});
/**
* RI-N2 fail-closed CLI behavior: an unimplemented capability is a failure,
* never a success. Every stub exits nonzero with a typed message, and the
* implemented `macp gate` mirrors the typed gate-runner states.
*/
describe('registerMacpCommand fail-closed (RI-N2)', () => {
let tmpDir: string;
function buildProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({ writeErr: () => {} });
registerMacpCommand(program);
return program;
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'macp-cli-failclosed-'));
process.exitCode = 0;
});
afterEach(() => {
process.exitCode = 0;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('macp tasks list exits nonzero (unimplemented capability)', async () => {
const program = buildProgram();
await program.parseAsync(['macp', 'tasks', 'list'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
});
it('macp submit exits nonzero with a typed MACP_NOT_IMPLEMENTED message', async () => {
const program = buildProgram();
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
await program.parseAsync(['macp', 'submit', 'spec.json'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
const errText = errSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(errText).toContain('MACP_NOT_IMPLEMENTED');
} finally {
errSpy.mockRestore();
}
});
it('macp events tail exits nonzero (unimplemented capability)', async () => {
const program = buildProgram();
await program.parseAsync(['macp', 'events', 'tail'], { from: 'user' });
expect(process.exitCode).not.toBe(0);
});
it('macp gate runs a green inline command and exits 0', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
'exit 0',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).toBe(0);
});
it('macp gate exits nonzero on a failing command', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
'exit 9',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
it('macp gate with an unimplemented ci-pipeline capability exits nonzero', async () => {
const program = buildProgram();
const specPath = path.join(tmpDir, 'gates.json');
fs.writeFileSync(specPath, JSON.stringify([{ type: 'ci-pipeline' }]));
await program.parseAsync(
[
'macp',
'gate',
specPath,
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
it('macp gate --simulate completes (exit 0) but reports simulated results', async () => {
const program = buildProgram();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
await program.parseAsync(
[
'macp',
'gate',
'exit 0',
'--simulate',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
// completes only because the caller explicitly asked to simulate
expect(process.exitCode).toBe(0);
const outText = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
expect(outText).toContain('simulated');
expect(outText).toContain('SIMULATED');
} finally {
logSpy.mockRestore();
}
});
it('macp gate with an empty spec exits nonzero with a typed error', async () => {
const program = buildProgram();
await program.parseAsync(
[
'macp',
'gate',
' ',
'--cwd',
tmpDir,
'--log',
path.join(tmpDir, 'g.log'),
'--timeout',
'10',
],
{ from: 'user' },
);
expect(process.exitCode).not.toBe(0);
});
});
+129 -19
View File
@@ -1,5 +1,73 @@
import { existsSync, readFileSync } from 'node:fs';
import type { Command } from 'commander';
import { runGates } from './gate-runner.js';
import { MACPCapabilityError, type MacpErrorCode } from './errors.js';
/**
* Load gates from a spec: an existing file (JSON gates array, a JSON object
* with `quality_gates`, a JSON gate object, or one command per line) or an
* inline command string. Fails closed with a typed capability error when the
* spec contains no executable gate definition.
*/
function loadGateSpec(spec: string): unknown[] {
if (existsSync(spec)) {
const raw = readFileSync(spec, 'utf-8');
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
if (parsed.length === 0) {
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' contains an empty gates array`,
);
}
return parsed;
}
if (typeof parsed === 'object' && parsed !== null) {
const obj = parsed as Record<string, unknown>;
if (Array.isArray(obj['quality_gates'])) {
return obj['quality_gates'];
}
return [parsed];
}
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' parsed to ${typeof parsed} — expected a gates array, a task with quality_gates, or a gate object`,
);
} catch (exc) {
if (exc instanceof MACPCapabilityError) throw exc;
// Not JSON — treat each non-empty line as a command gate.
const lines = raw
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length > 0) return lines;
throw new MACPCapabilityError(
'MACP_NO_COMMAND',
'gate-spec',
`gate spec file '${spec}' contains no gates`,
);
}
}
if (spec.trim().length > 0) return [spec];
throw new MACPCapabilityError('MACP_NO_COMMAND', 'gate-spec', 'gate spec is empty');
}
/** Print a typed not-implemented failure and exit nonzero (RI-N2 fail-closed). */
function notImplemented(subcommand: string, capability: string, hint: string): void {
const err = new MACPCapabilityError(
'MACP_NOT_IMPLEMENTED',
capability,
`${subcommand} is not implemented in @mosaicstack/macp yet (${capability} capability absent) — ${hint}`,
);
console.error(`[macp] ${subcommand}: ${err.message} [${err.code}]`);
process.exitCode = 1;
}
/**
* Register macp subcommands on an existing Commander program.
* This avoids cross-package Commander version mismatches by using the
@@ -24,15 +92,14 @@ export function registerMacpCommand(parent: Command): void {
'Filter by task type (coding|deploy|research|review|documentation|infrastructure)',
)
.action((opts: { status?: string; type?: string }) => {
// not yet wired — task persistence layer is not present in @mosaicstack/macp
console.log('[macp] tasks list: not yet wired — use macp package programmatically');
// unimplemented capability — a failure, never a success (RI-N2)
if (opts.status) {
console.log(` status filter: ${opts.status}`);
}
if (opts.type) {
console.log(` type filter: ${opts.type}`);
}
process.exitCode = 0;
notImplemented('tasks list', 'task-persistence', 'use the macp package programmatically');
});
// ─── submit ──────────────────────────────────────────────────────────────
@@ -41,12 +108,11 @@ export function registerMacpCommand(parent: Command): void {
.command('submit <path>')
.description('Submit a task from a JSON/YAML spec file')
.action((specPath: string) => {
// not yet wired — task submission requires a running MACP server
console.log('[macp] submit: not yet wired — use macp package programmatically');
// unimplemented capability — a failure, never a success (RI-N2)
console.log(` spec path: ${specPath}`);
console.log(' task id: (unavailable — no MACP server connected)');
console.log(' status: (unavailable — no MACP server connected)');
process.exitCode = 0;
notImplemented('submit', 'macp-server', 'use the macp package programmatically');
});
// ─── gate ────────────────────────────────────────────────────────────────
@@ -58,16 +124,58 @@ export function registerMacpCommand(parent: Command): void {
.option('--cwd <path>', 'Working directory for gate execution', process.cwd())
.option('--log <path>', 'Path to write gate log output', '/tmp/macp-gate.log')
.option('--timeout <seconds>', 'Gate timeout in seconds', '60')
.action((spec: string, opts: { failOn: string; cwd: string; log: string; timeout: string }) => {
// not yet wired — gate execution requires a task context and event sink
console.log('[macp] gate: not yet wired — use macp package programmatically');
console.log(` spec: ${spec}`);
console.log(` fail-on: ${opts.failOn}`);
console.log(` cwd: ${opts.cwd}`);
console.log(` log: ${opts.log}`);
console.log(` timeout: ${opts.timeout}s`);
process.exitCode = 0;
});
.option(
'--simulate',
'Simulate gates instead of executing them; results are typed simulated and never satisfy a check',
)
.action(
(
spec: string,
opts: { failOn: string; cwd: string; log: string; timeout: string; simulate?: boolean },
) => {
let gates: unknown[];
try {
gates = loadGateSpec(spec);
} catch (exc) {
if (exc instanceof MACPCapabilityError) {
console.error(`[macp] gate: ${exc.message} [${exc.code}]`);
} else {
console.error(`[macp] gate: ${String(exc)}`);
}
process.exitCode = 1;
return;
}
const timeoutSec = Number.parseInt(opts.timeout, 10) || 60;
const eventsPath = `${opts.log}.events.ndjson`;
const { state, gateResults } = runGates(
gates,
opts.cwd,
opts.log,
timeoutSec,
eventsPath,
'macp-cli-gate',
{
simulate: opts.simulate,
},
);
for (const r of gateResults) {
const label = r.command || r.type;
const reason = r.reason ? `${r.reason}` : '';
console.log(`[macp] gate ${r.status}: ${label}${reason}`);
}
if (opts.simulate) {
console.log(
'[macp] SIMULATED run — every result is typed simulated and can never satisfy a gate, dependency, or release check',
);
}
// Simulated runs may complete (exit 0) only because the caller
// explicitly passed --simulate; the typed state stays 'simulated'.
process.exitCode = state === 'passed' || state === 'simulated' ? 0 : 1;
},
);
// ─── events ──────────────────────────────────────────────────────────────
@@ -79,14 +187,16 @@ export function registerMacpCommand(parent: Command): void {
.option('--file <path>', 'Path to the MACP events NDJSON file')
.option('--follow', 'Follow the file for new events (like tail -f)')
.action((opts: { file?: string; follow?: boolean }) => {
// not yet wired — event streaming requires a live event source
console.log('[macp] events tail: not yet wired — use macp package programmatically');
// unimplemented capability — a failure, never a success (RI-N2)
if (opts.file) {
console.log(` file: ${opts.file}`);
}
if (opts.follow) {
console.log(' mode: follow');
}
process.exitCode = 0;
notImplemented('events tail', 'event-source', 'use the macp package programmatically');
});
}
// Re-export so CLI consumers can surface typed capability codes.
export type { MacpErrorCode };
+35
View File
@@ -0,0 +1,35 @@
/** Typed error code from the closed MACP_ERROR_CODES set. */
export type MacpErrorCode = (typeof MACP_ERROR_CODES)[number];
/**
* Typed fail-closed capability errors (RI-N2, SDLC-D-035).
*
* MACP must fail closed when a required capability (executor, reviewer,
* command, CI provider, human authority) is absent. These typed codes mirror
* the Forge failure vocabulary (FORGE_NO_*) so both packages speak the same
* language: an unimplemented capability is a failure, never a stub success.
*/
/** Closed set of typed MACP capability error codes. */
export const MACP_ERROR_CODES = [
'MACP_NOT_IMPLEMENTED',
'MACP_NO_COMMAND',
'MACP_NO_REVIEWER',
'MACP_NO_CI_PIPELINE',
'MACP_NO_PROVIDER',
'MACP_AUTHORITY_REQUIRED',
] as const;
/** Raised when a required capability is missing and execution must fail closed. */
export class MACPCapabilityError extends Error {
/** Typed error code from the closed MACP_ERROR_CODES set. */
readonly code: MacpErrorCode;
/** The missing capability, e.g. `ci-provider`, `task-persistence`, `command`. */
readonly capability: string;
constructor(code: MacpErrorCode, capability: string, message: string) {
super(message);
this.name = 'MACPCapabilityError';
this.code = code;
this.capability = capability;
}
}
+429
View File
@@ -0,0 +1,429 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { countAIFindings, normalizeGate, runGate, runGates } from './gate-runner.js';
function makeTmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'macp-gate-'));
}
describe('normalizeGate', () => {
it('normalizes a string to mechanical gate', () => {
expect(normalizeGate('echo test')).toEqual({
command: 'echo test',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('normalizes an object gate with defaults', () => {
expect(normalizeGate({ command: 'lint' })).toEqual({
command: 'lint',
type: 'mechanical',
fail_on: 'blocker',
});
});
it('preserves explicit type and fail_on', () => {
expect(normalizeGate({ command: 'review', type: 'ai-review', fail_on: 'any' })).toEqual({
command: 'review',
type: 'ai-review',
fail_on: 'any',
});
});
it('handles non-string/non-object input', () => {
expect(normalizeGate(42)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
expect(normalizeGate(null)).toEqual({ command: '', type: 'mechanical', fail_on: 'blocker' });
});
});
describe('countAIFindings', () => {
it('returns zeros for non-object', () => {
expect(countAIFindings(null)).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings('string')).toEqual({ blockers: 0, total: 0 });
expect(countAIFindings([])).toEqual({ blockers: 0, total: 0 });
});
it('counts from stats block', () => {
const output = { stats: { blockers: 2, should_fix: 3, suggestions: 1 } };
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 6 });
});
it('counts from findings array when stats has no blockers', () => {
const output = {
stats: { blockers: 0 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }, { severity: 'blocker' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 2, total: 3 });
});
it('uses stats blockers over findings array when stats has blockers', () => {
const output = {
stats: { blockers: 5 },
findings: [{ severity: 'blocker' }, { severity: 'warning' }],
};
// stats.blockers = 5, total from stats = 5+0+0 = 5, findings not used for total since stats total is non-zero
expect(countAIFindings(output)).toEqual({ blockers: 5, total: 5 });
});
it('counts findings length as total when stats has zero total', () => {
const output = {
findings: [{ severity: 'warning' }, { severity: 'info' }],
};
expect(countAIFindings(output)).toEqual({ blockers: 0, total: 2 });
});
});
describe('runGate', () => {
let tmp: string;
let logPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = path.join(tmp, 'gate.log');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it('passes mechanical gate on exit 0', () => {
const result = runGate('echo hello', tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.exit_code).toBe(0);
expect(result.type).toBe('mechanical');
expect(result.output).toContain('hello');
});
it('fails mechanical gate on non-zero exit', () => {
const result = runGate('exit 1', tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(1);
});
it('ci-pipeline fails closed without a CI provider (no placeholder pass)', () => {
const result = runGate({ command: 'anything', type: 'ci-pipeline' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.status).toBe('capability_failure');
expect(result.capability_code).toBe('MACP_NO_CI_PIPELINE');
expect(result.type).toBe('ci-pipeline');
expect(result.output).not.toBe('CI pipeline gate placeholder');
});
it('empty command is a typed capability failure, never a pass', () => {
const result = runGate({ command: '' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.status).toBe('capability_failure');
expect(result.capability_code).toBe('MACP_NO_COMMAND');
});
it('ai-review gate parses JSON output', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(true);
expect(result.blockers).toBe(0);
expect(result.findings).toBe(1);
});
it('ai-review gate fails on blockers', () => {
const json = JSON.stringify({ stats: { blockers: 2 } });
const result = runGate({ command: `echo '${json}'`, type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.blockers).toBe(2);
});
it('ai-review gate with fail_on=any fails on any findings', () => {
const json = JSON.stringify({ stats: { blockers: 0, should_fix: 1 } });
const result = runGate(
{ command: `echo '${json}'`, type: 'ai-review', fail_on: 'any' },
tmp,
logPath,
30,
);
expect(result.passed).toBe(false);
expect(result.fail_on).toBe('any');
});
it('ai-review gate fails on invalid JSON output', () => {
const result = runGate({ command: 'echo "not json"', type: 'ai-review' }, tmp, logPath, 30);
expect(result.passed).toBe(false);
expect(result.parse_error).toBeDefined();
});
it('writes to log file', () => {
runGate('echo logged', tmp, logPath, 30);
const log = fs.readFileSync(logPath, 'utf-8');
expect(log).toContain('COMMAND: echo logged');
expect(log).toContain('logged');
expect(log).toContain('EXIT:');
});
});
describe('runGates', () => {
let tmp: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmp = makeTmpDir();
logPath = path.join(tmp, 'gates.log');
eventsPath = path.join(tmp, 'events.ndjson');
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it('runs multiple gates and returns results', () => {
const { allPassed, gateResults } = runGates(
['echo one', 'echo two'],
tmp,
logPath,
30,
eventsPath,
'task-1',
);
expect(allPassed).toBe(true);
expect(gateResults).toHaveLength(2);
});
it('reports failure when any gate fails', () => {
const { allPassed, gateResults } = runGates(
['echo ok', 'exit 1'],
tmp,
logPath,
30,
eventsPath,
'task-2',
);
expect(allPassed).toBe(false);
expect(gateResults[0]!.passed).toBe(true);
expect(gateResults[1]!.passed).toBe(false);
});
it('emits events for each gate', () => {
runGates(['echo test'], tmp, logPath, 30, eventsPath, 'task-3');
const events = fs
.readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
expect(events).toHaveLength(2); // started + passed
expect(events[0].event_type).toBe('rail.check.started');
expect(events[1].event_type).toBe('rail.check.passed');
});
it('does not silently skip gates with empty command — they become capability failures', () => {
const { gateResults, allPassed, state } = runGates(
[{ command: '', type: 'mechanical' }, 'echo real'],
tmp,
logPath,
30,
eventsPath,
'task-4',
);
expect(gateResults).toHaveLength(2);
expect(gateResults[0]!.status).toBe('capability_failure');
expect(gateResults[1]!.status).toBe('passed');
expect(allPassed).toBe(false);
expect(state).toBe('capability_failure');
});
it('does not skip ci-pipeline even with empty command — typed capability failure', () => {
const { gateResults, allPassed, state } = runGates(
[{ command: '', type: 'ci-pipeline' }],
tmp,
logPath,
30,
eventsPath,
'task-5',
);
expect(gateResults).toHaveLength(1);
expect(gateResults[0]!.passed).toBe(false);
expect(gateResults[0]!.status).toBe('capability_failure');
expect(allPassed).toBe(false);
expect(state).toBe('capability_failure');
});
it('emits failed event with correct message', () => {
runGates(['exit 42'], tmp, logPath, 30, eventsPath, 'task-6');
const events = fs
.readFileSync(eventsPath, 'utf-8')
.trim()
.split('\n')
.map((l) => JSON.parse(l));
const failEvent = events.find(
(e: Record<string, unknown>) => e.event_type === 'rail.check.failed',
);
expect(failEvent).toBeDefined();
expect(failEvent.message).toContain('Gate failed (');
});
});
/**
* RI-N2 / SDLC-D-035 fail-closed controls for the MACP gate runner.
*
* Invariant under test: `passed: true` occurs ONLY when a gate really executed
* and really exited green (`status === 'passed'`). Absent capabilities,
* manual sign-offs, and simulated runs are typed distinctly and can never
* make the aggregate `passed`.
*/
describe('gate-runner fail-closed (RI-N2)', () => {
let tmpDir: string;
let logPath: string;
let eventsPath: string;
beforeEach(() => {
tmpDir = makeTmpDir();
logPath = path.join(tmpDir, 'gate.log');
eventsPath = path.join(tmpDir, 'events.ndjson');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function run(gates: unknown[], options?: { simulate?: boolean }) {
return runGates(gates, tmpDir, logPath, 10, eventsPath, 'spec-task', options);
}
// ─── positive controls ───────────────────────────────────────────────────
it('a really-executed green command gate still passes', () => {
const result = run([{ command: 'exit 0', type: 'mechanical' }]);
expect(result.gateResults[0]!.status).toBe('passed');
expect(result.gateResults[0]!.passed).toBe(true);
expect(result.allPassed).toBe(true);
expect(result.state).toBe('passed');
});
it('explicit simulate completes and types every result simulated', () => {
const result = run([{ command: 'exit 0', type: 'mechanical' }, 'echo hello'], {
simulate: true,
});
expect(result.gateResults).toHaveLength(2);
for (const gate of result.gateResults) {
expect(gate.status).toBe('simulated');
expect(gate.passed).toBe(false);
}
expect(result.state).toBe('simulated');
});
it('a really-executed red command gate fails with typed status failed', () => {
const result = run([{ command: 'exit 3', type: 'mechanical' }]);
expect(result.gateResults[0]!.status).toBe('failed');
expect(result.gateResults[0]!.passed).toBe(false);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('failed');
});
// ─── negative controls — each asserts typed status AND aggregate not passed ──
it('an empty-command gate is a capability_failure, not skipped and not passed', () => {
const result = run([{ command: '', type: 'mechanical' }]);
// runGates must not silently skip it — it produces a typed result
expect(result.gateResults).toHaveLength(1);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('capability_failure');
expect(gate.capability_code).toBe('MACP_NO_COMMAND');
expect(gate.passed).toBe(false);
// aggregate is not passed
expect(result.allPassed).toBe(false);
expect(result.state).toBe('capability_failure');
expect(result.state).not.toBe('passed');
});
it('a commandless ai-review gate is a typed MACP_NO_REVIEWER capability_failure', () => {
const result = run([{ command: '', type: 'ai-review' }]);
expect(result.gateResults[0]!.status).toBe('capability_failure');
expect(result.gateResults[0]!.capability_code).toBe('MACP_NO_REVIEWER');
expect(result.allPassed).toBe(false);
expect(result.state).not.toBe('passed');
});
it('a ci-pipeline gate without a provider implementation is a capability_failure, never a placeholder pass', () => {
const result = run([{ command: '', type: 'ci-pipeline' }]);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('capability_failure');
expect(gate.capability_code).toBe('MACP_NO_CI_PIPELINE');
expect(gate.passed).toBe(false);
// the old false-success placeholder must be gone
expect(gate.output).not.toBe('CI pipeline gate placeholder');
expect(result.allPassed).toBe(false);
expect(result.state).not.toBe('passed');
});
it('a ci-pipeline gate fails closed even alongside an otherwise green run', () => {
const result = run(['exit 0', { type: 'ci-pipeline', command: 'fake-ci' }]);
expect(result.gateResults[1]!.status).toBe('capability_failure');
expect(result.gateResults[0]!.status).toBe('passed');
expect(result.allPassed).toBe(false);
expect(result.state).toBe('capability_failure');
});
it('a manual gate with no automation enters typed waiting — neither pass nor fail', () => {
const result = run([{ type: 'manual' }]);
const gate = result.gateResults[0]!;
expect(gate.status).toBe('waiting');
expect(gate.passed).toBe(false);
expect(gate.exit_code).toBe(0);
// aggregate is not passed while any gate is waiting
expect(result.allPassed).toBe(false);
expect(result.state).toBe('waiting');
expect(result.state).not.toBe('passed');
});
it('a simulated result can never make the aggregate passed', () => {
const result = run(['exit 0', 'exit 0'], { simulate: true });
expect(result.gateResults.every((g) => g.status === 'simulated')).toBe(true);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('simulated');
expect(result.state).not.toBe('passed');
});
it('waiting dominates an otherwise green aggregate', () => {
const result = run(['exit 0', { type: 'manual' }]);
expect(result.allPassed).toBe(false);
expect(result.state).toBe('waiting');
});
});
describe('runGate fail-closed (RI-N2)', () => {
let tmpDir: string;
let logPath: string;
beforeEach(() => {
tmpDir = makeTmpDir();
logPath = path.join(tmpDir, 'gate.log');
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('simulate: true returns a typed simulated result without executing', () => {
const result = runGate('this-command-does-not-exist-xyz', tmpDir, logPath, 10, {
simulate: true,
});
expect(result.status).toBe('simulated');
expect(result.passed).toBe(false);
expect(result.exit_code).toBe(0);
});
it('normal mode executes for real and types a green gate passed', () => {
const result = runGate('echo ok', tmpDir, logPath, 10);
expect(result.status).toBe('passed');
expect(result.passed).toBe(true);
expect(result.output).toContain('ok');
});
it('a bare string gate normalizes to mechanical and executes', () => {
const result = runGate('exit 7', tmpDir, logPath, 10);
expect(result.type).toBe('mechanical');
expect(result.status).toBe('failed');
expect(result.passed).toBe(false);
});
});
+148 -25
View File
@@ -4,7 +4,20 @@ import { dirname } from 'node:path';
import { emitEvent } from './event-emitter.js';
import { nowISO } from './event-emitter.js';
import type { GateResult } from './types.js';
import type { GateResult, GateStatus, RunGatesResult } from './types.js';
/** Typed reason stamped on every simulated gate result. */
export const SIMULATED_GATE_REASON =
'simulated execution (explicit simulate opt-in): gate was not evaluated by a real implementation';
/** Options for gate execution (RI-N2 fail-closed / explicit simulation). */
export interface RunGateOptions {
/**
* Explicit caller opt-in to simulation. Simulated gates are NOT executed;
* every result is typed `simulated` and never satisfies anything.
*/
simulate?: boolean;
}
export interface NormalizedGate {
command: string;
@@ -103,36 +116,91 @@ export function countAIFindings(parsedOutput: unknown): { blockers: number; tota
return { blockers, total };
}
function simulatedResult(gateEntry: NormalizedGate): GateResult {
return {
command: gateEntry.command,
exit_code: 0,
type: gateEntry.type,
output: SIMULATED_GATE_REASON,
timed_out: false,
passed: false,
status: 'simulated',
reason: SIMULATED_GATE_REASON,
};
}
function capabilityFailureResult(
gateEntry: NormalizedGate,
code: GateResult['capability_code'],
reason: string,
): GateResult {
return {
command: gateEntry.command,
exit_code: 1,
type: gateEntry.type,
output: '',
timed_out: false,
passed: false,
status: 'capability_failure',
capability_code: code,
reason,
};
}
function waitingResult(gateEntry: NormalizedGate, reason: string): GateResult {
return {
command: gateEntry.command,
exit_code: 0,
type: gateEntry.type,
output: '',
timed_out: false,
passed: false,
status: 'waiting',
capability_code: 'MACP_AUTHORITY_REQUIRED',
reason,
};
}
export function runGate(
gate: unknown,
cwd: string,
logPath: string,
timeoutSec: number,
options: RunGateOptions = {},
): GateResult {
const gateEntry = normalizeGate(gate);
const gateType = gateEntry.type;
const command = gateEntry.command;
// Explicit simulation only: never executes, typed simulated, never satisfying.
if (options.simulate) {
return simulatedResult(gateEntry);
}
// Fail closed: no CI provider implementation exists in @mosaicstack/macp,
// so a ci-pipeline gate is an absent capability — never a placeholder pass.
if (gateType === 'ci-pipeline') {
return {
command,
exit_code: 0,
type: gateType,
output: 'CI pipeline gate placeholder',
timed_out: false,
passed: true,
};
return capabilityFailureResult(
gateEntry,
'MACP_NO_CI_PIPELINE',
`ci-pipeline gate '${gateEntry.command || gateType}' has no CI provider implementation wired — refusing placeholder pass`,
);
}
if (!command) {
return {
command: '',
exit_code: 0,
type: gateType,
output: '',
timed_out: false,
passed: true,
};
// A manual gate with no automation waits for human sign-off: not pass, not fail.
if (gateType === 'manual') {
return waitingResult(
gateEntry,
`manual gate has no automation — waiting for human sign-off (type: ${gateType})`,
);
}
// Any other commandless gate is an absent capability — never a vacuous pass.
return capabilityFailureResult(
gateEntry,
gateType === 'ai-review' ? 'MACP_NO_REVIEWER' : 'MACP_NO_COMMAND',
`gate of type '${gateType}' has no command to execute — refusing empty-command pass`,
);
}
const { exitCode, output, timedOut } = runShell(command, cwd, logPath, timeoutSec);
@@ -143,10 +211,12 @@ export function runGate(
output,
timed_out: timedOut,
passed: false,
status: 'failed',
};
if (gateType !== 'ai-review') {
result.passed = exitCode === 0;
result.status = result.passed ? 'passed' : 'failed';
return result;
}
@@ -170,6 +240,7 @@ export function runGate(
} else {
result.passed = exitCode === 0 && blockers === 0 && !timedOut && parseError === undefined;
}
result.status = result.passed ? 'passed' : 'failed';
result.fail_on = failOn;
result.blockers = blockers;
@@ -191,16 +262,19 @@ export function runGates(
timeoutSec: number,
eventsPath: string,
taskId: string,
): { allPassed: boolean; gateResults: GateResult[] } {
let allPassed = true;
options: RunGateOptions = {},
): RunGatesResult {
const gateResults: GateResult[] = [];
let hasCapabilityFailure = false;
let hasSimulated = false;
let hasFailed = false;
let hasWaiting = false;
for (const gate of gates) {
const gateEntry = normalizeGate(gate);
const gateCmd = gateEntry.command;
if (!gateCmd && gateEntry.type !== 'ci-pipeline') continue;
const label = gateCmd || gateEntry.type;
// NOTE: no silent skip — every gate produces a typed result (RI-N2).
emitEvent(
eventsPath,
'rail.check.started',
@@ -209,10 +283,10 @@ export function runGates(
'quality-gate',
`Running gate: ${label}`,
);
const result = runGate(gate, cwd, logPath, timeoutSec);
const result = runGate(gate, cwd, logPath, timeoutSec, options);
gateResults.push(result);
if (result.passed) {
if (result.status === 'passed') {
emitEvent(
eventsPath,
'rail.check.passed',
@@ -224,7 +298,46 @@ export function runGates(
continue;
}
allPassed = false;
if (result.status === 'waiting') {
hasWaiting = true;
emitEvent(
eventsPath,
'rail.check.waiting',
taskId,
'gated',
'quality-gate',
`Gate waiting: ${label}${result.reason ?? 'manual gate awaits sign-off'}`,
);
continue;
}
if (result.status === 'simulated') {
hasSimulated = true;
emitEvent(
eventsPath,
'rail.check.simulated',
taskId,
'gated',
'quality-gate',
`Gate simulated (non-satisfying): ${label}`,
);
continue;
}
if (result.status === 'capability_failure') {
hasCapabilityFailure = true;
emitEvent(
eventsPath,
'rail.check.failed',
taskId,
'gated',
'quality-gate',
`Gate capability failure (${result.capability_code ?? 'MACP_NO_PROVIDER'}): ${label}${result.reason ?? 'required capability is absent'}`,
);
continue;
}
hasFailed = true;
let message: string;
if (result.timed_out) {
message = `Gate timed out after ${timeoutSec}s: ${label}`;
@@ -236,5 +349,15 @@ export function runGates(
emitEvent(eventsPath, 'rail.check.failed', taskId, 'gated', 'quality-gate', message);
}
return { allPassed, gateResults };
const state: GateStatus = hasCapabilityFailure
? 'capability_failure'
: hasSimulated
? 'simulated'
: hasFailed
? 'failed'
: hasWaiting
? 'waiting'
: 'passed';
return { allPassed: state === 'passed', gateResults, state };
}
+16 -2
View File
@@ -6,11 +6,13 @@ export type {
DependsOnPolicy,
GateType,
GateFailOn,
GateStatus,
GateEntry,
Task,
EventType,
MACPEvent,
GateResult,
RunGatesResult,
TaskResult,
ProviderMeta,
ProviderRegistry,
@@ -18,6 +20,11 @@ export type {
export { CredentialError } from './types.js';
// Typed fail-closed capability errors (RI-N2, SDLC-D-035)
export { MACP_ERROR_CODES, MACPCapabilityError } from './errors.js';
export type { MacpErrorCode } from './errors.js';
// Credential resolver
export {
DEFAULT_CREDENTIALS_DIR,
@@ -35,9 +42,16 @@ export {
export type { ResolveCredentialsOptions } from './credential-resolver.js';
// Gate runner
export { normalizeGate, runShell, countAIFindings, runGate, runGates } from './gate-runner.js';
export {
normalizeGate,
runShell,
countAIFindings,
runGate,
runGates,
SIMULATED_GATE_REASON,
} from './gate-runner.js';
export type { NormalizedGate } from './gate-runner.js';
export type { NormalizedGate, RunGateOptions } from './gate-runner.js';
// Risk-floor (agent reflection loop — diff review classifier)
export { evaluateRiskFloor, DEFAULT_RISK_THRESHOLD } from './risk-floor.js';
+39 -2
View File
@@ -1,3 +1,5 @@
import type { MacpErrorCode } from './errors.js';
/** Task status values. */
export type TaskStatus = 'pending' | 'running' | 'gated' | 'completed' | 'failed' | 'escalated';
@@ -17,7 +19,17 @@ export type DispatchMode = 'yolo' | 'acp' | 'exec';
export type DependsOnPolicy = 'all' | 'any' | 'all_terminal';
/** Quality gate type. */
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline';
export type GateType = 'mechanical' | 'ai-review' | 'ci-pipeline' | 'manual';
/**
* Typed execution state of a gate closed set (RI-N2, SDLC-D-035).
*
* Only `passed` means "really executed and green". `simulated` is produced
* exclusively under an explicit simulate opt-in and never satisfies anything.
* `capability_failure` means a required executor/provider/command was absent.
* `waiting` means a manual gate awaits human sign-off (neither pass nor fail).
*/
export type GateStatus = 'passed' | 'failed' | 'simulated' | 'waiting' | 'capability_failure';
/** Gate fail_on mode. */
export type GateFailOn = 'blocker' | 'any';
@@ -67,7 +79,9 @@ export type EventType =
| 'task.retry.scheduled'
| 'rail.check.started'
| 'rail.check.passed'
| 'rail.check.failed';
| 'rail.check.failed'
| 'rail.check.waiting'
| 'rail.check.simulated';
/** Structured event record. */
export interface MACPEvent {
@@ -88,7 +102,14 @@ export interface GateResult {
type: string;
output: string;
timed_out: boolean;
/** Back-compat boolean view — true ONLY when `status === 'passed'`. */
passed: boolean;
/** Typed discriminator — the authoritative gate outcome (RI-N2). */
status: GateStatus;
/** Typed capability error code, set when `status === 'capability_failure'`. */
capability_code?: MacpErrorCode;
/** Why a non-executed state (simulated/waiting/capability_failure) was reached. */
reason?: string;
fail_on?: string;
blockers?: number;
findings?: number;
@@ -96,6 +117,22 @@ export interface GateResult {
parse_error?: string;
}
/**
* Aggregate outcome of `runGates` (RI-N2).
*
* `state` is the typed aggregate: it is `passed` only when every gate really
* executed green. A `simulated` result makes the aggregate `simulated` (never
* `passed`); a `waiting` manual gate keeps the aggregate `waiting`; a missing
* capability makes it `capability_failure`. `allPassed` is exactly
* `state === 'passed'`, so a simulated or waiting result can never satisfy a
* dependency, acceptance criterion, gate, merge, or release check.
*/
export interface RunGatesResult {
allPassed: boolean;
gateResults: GateResult[];
state: GateStatus;
}
/** Result from a completed task. */
export interface TaskResult {
task_id: string;
+1 -1
View File
@@ -102,7 +102,7 @@ mosaic yolo pi # Launch Pi in yolo mode
The launcher:
1. Verifies `~/.config/mosaic` exists
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
2. Verifies `SOUL.md` exists (auto-runs `mosaic init` if missing)
3. Injects `AGENTS.md` into the runtime
4. Forwards all arguments to the runtime CLI
+27
View File
@@ -12,6 +12,33 @@ The default tmux socket is `mosaic-fleet` so fleet commands do not touch the
default tmux server. The roster is the desired-state authority; generated environment files are
rebuildable projections, never a second source of configuration.
## Brain-home split (fleet state vs framework templates)
When a mosaic-brain clone is present, fleet **state** resolves from the brain
home while framework templates and dispatch state stay in the config home
(three-tree model, canon `docs/STRUCTURE-CANON.md` §2):
| Path | Without brain (legacy) | With brain |
| ------------------------------------------------------------------------------- | ------------------------------------- | ------------------------------ |
| `fleet/agents/<seat>.env.*` | `~/.config/mosaic/fleet/agents/` | `~/.mosaic/fleet/agents/` |
| `fleet/roles.local/` (overrides) | `~/.config/mosaic/fleet/roles.local/` | `~/.mosaic/fleet/roles.local/` |
| `fleet/profiles/` (working copies) | `~/.config/mosaic/fleet/profiles/` | `~/.mosaic/fleet/profiles/` |
| `fleet/roster.yaml`, `fleet/roles/` (baseline), `fleet/run/`, `fleet/services/` | `~/.config/mosaic/fleet/…` | unchanged (config home) |
Activation (`packages/mosaic/src/fleet/brain-home.ts`, mirrored in
`tools/fleet/start-agent-session.sh`):
1. `MOSAIC_BRAIN_HOME` env var — explicit, always wins.
2. Canonical `~/.mosaic` — adopted only when `MOSAIC_HOME` is the default
`~/.config/mosaic` AND `~/.mosaic/fleet/agents` exists. Custom
`--mosaic-home` values (tests, sandboxes, canaries) never adopt, keeping
them hermetic.
3. Otherwise the config home (legacy single-tree behavior).
Seat env dirs under a brain are subject to the same privacy boundary (0700
dirs, 0600 files); `.env.generated` files are structure-valuable and tracked
in the brain repo, hand-maintained `.env`/`.env.local` stay ignored and private.
## Examples
- `examples/minimal.yaml` starts one local canary slot.
@@ -51,6 +51,8 @@ fix() { fix_count=$((fix_count + 1)); echo "[FIX] $*"; }
warn_count=0
warn() { warn_count=$((warn_count + 1)); echo "[WARN] $*"; }
note() { echo "[NOTE] $*"; return 0; }
pass() {
if [[ $VERBOSE -eq 1 ]]; then
echo "[OK] $*"
@@ -255,6 +257,80 @@ fleet_declared_transport() {
printf '%s\n' "${declared:-tmux}"
}
# Brain-home fleet-state resolution (#1298; canon STRUCTURE-CANON §2).
#
# Seat launch envs, roles.local overrides, and profile working copies resolve
# from the brain home when one is active; roster, baseline roles, run/, and
# services stay under MOSAIC_HOME. This check surfaces which tree fleet state
# resolves from and the drift a launch would otherwise hit at runtime:
#
# - a stale MOSAIC_BRAIN_HOME pointing at a directory with no fleet/agents is a
# misconfiguration the resolver honors (explicit wins) — warn, don't pass;
# - a symlinked brain or agents dir defeats the managed-directory boundary;
# - a group/world-readable agents dir violates the 0700 projection boundary;
# - env files left in the config-home tree while a brain is active are split
# state — the write path rejects NEW split writes, but nothing would ever
# tell the operator the old files are stranded.
resolve_brain_home() {
local explicit="${MOSAIC_BRAIN_HOME:-}"
if [[ -n "$(printf '%s' "$explicit" | tr -d '[:space:]')" ]]; then
printf '%s' "$explicit"
return
fi
if [[ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" == "$HOME/.config/mosaic" \
&& -d "$HOME/.mosaic/fleet/agents" ]]; then
printf '%s' "$HOME/.mosaic"
return
fi
printf '%s' "$MOSAIC_HOME"
}
check_brain_home() {
local brain agents mode
brain="$(resolve_brain_home)"
if [[ "$brain" == "$MOSAIC_HOME" ]]; then
# Implicit-path greenfield case (#1288 comment 23133, fred's trace): nothing
# in product code creates ~/.mosaic/fleet/agents — the first fleet write
# resolves legacy (generated-env-boundary resolves before creating) and
# then manufactures the evidence that keeps the host legacy. On a host with
# ~/.mosaic but no fleet/agents, the three operator checks all agree and all
# point the wrong way; this doctor is the only one that can disagree, so it
# must say it — as a note, not a warn: nothing is broken yet.
if [[ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" == "$HOME/.config/mosaic" \
&& -d "$HOME/.mosaic" && ! -d "$HOME/.mosaic/fleet/agents" ]]; then
note "Fleet state home: $MOSAIC_HOME (legacy). NOTE: ~/.mosaic exists but carries no fleet/agents — the first 'mosaic fleet regen' on this host locks in the legacy tree. Create ~/.mosaic/fleet/agents first to adopt the brain."
return
fi
pass "Fleet state home: $MOSAIC_HOME (legacy single-tree; no brain adopted)"
return
fi
agents="$brain/fleet/agents"
if [[ ! -d "$agents" ]]; then
warn "Brain home '$brain' has no fleet/agents — seat envs will not resolve from it. Point MOSAIC_BRAIN_HOME at a brain carrying fleet/agents, or unset it."
return
fi
if [[ -L "$brain" || -L "$agents" ]]; then
warn "Brain fleet-state path resolves through a symlink ($brain) — the managed-directory boundary requires regular directories."
return
fi
mode="$(stat -c '%a' -- "$agents" 2>/dev/null)" || mode=""
if [[ -n "$mode" ]] && (( (8#$mode & 8#077) != 0 )); then
warn "Brain agents dir '$agents' is group/world-accessible (mode $mode) — the projection boundary requires 0700."
return
fi
if [[ -d "$MOSAIC_HOME/fleet/agents" ]] \
&& ls "$MOSAIC_HOME/fleet/agents/"*.env* >/dev/null 2>&1; then
warn "Fleet env files exist in BOTH trees — brain '$brain' is active but '$MOSAIC_HOME/fleet/agents' still carries env files (split state). Migrate them (mosaic fleet regen) and remove the config-home copies."
return
fi
pass "Fleet state home: $brain (brain active); roster + templates: $MOSAIC_HOME"
}
check_fleet_transport() {
local transport
transport="$(fleet_declared_transport)"
@@ -273,6 +349,8 @@ check_fleet_transport() {
check_fleet_transport
check_brain_home
# Legacy migration surfaces should no longer contain symlink trees.
legacy_paths=(
"$HOME/.claude/agent-guides"
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Covers the brain-home fleet-state check in `mosaic-doctor` (#1298 follow-up).
#
# The functions are extracted from the shipped script rather than copied here
# (same discipline as test-fleet-transport-check.sh): a test that carries its
# own copy of the logic keeps passing after the shipped copy changes.
# Extraction is by exact function header and a closing brace in column one.
set -euo pipefail
SCRIPT_DIR=$(cd -- "$(dirname "$0")" && pwd)
DOCTOR="$SCRIPT_DIR/mosaic-doctor"
fail() {
echo "FAIL: $*" >&2
exit 1
}
[ -f "$DOCTOR" ] || fail "missing mosaic-doctor at $DOCTOR"
extract_function() {
local name="$1"
local extracted
extracted=$(sed -n "/^${name}() {/,/^}/p" "$DOCTOR")
[ -n "$extracted" ] || fail "could not extract ${name}() from mosaic-doctor — script reshaped?"
printf '%s\n' "$extracted"
}
for fn in resolve_brain_home check_brain_home; do
extract_function "$fn" >/dev/null
done
warn_count=0
warn() { warn_count=$((warn_count + 1)); echo "[WARN] $*"; }
pass() { echo "[OK] $*"; return 0; }
eval "$(extract_function resolve_brain_home)"
eval "$(extract_function check_brain_home)"
ROOT=$(mktemp -d)
trap 'rm -rf "$ROOT"' EXIT
# note output is neither [OK] nor [WARN] — assert it directly in the case below.
run_case() {
# label, expect (ok|warn|note), then env assignments as arguments.
# The check runs under `env` in a subshell, so its warn() also prints a
# sentinel the parent counts — a subshell counter would never be visible.
local label="$1" expect="$2"
shift 2
local out warns notes
out=$(env "$@" bash -c "warn() { echo \"[WARN] \$*\"; }; note() { echo \"[NOTE] \$*\"; return 0; }; pass() { echo \"[OK] \$*\"; return 0; }; $(extract_function resolve_brain_home); $(extract_function check_brain_home); check_brain_home" 2>&1)
warns=$(printf '%s\n' "$out" | grep -c '^\[WARN\]' || true)
notes=$(printf '%s\n' "$out" | grep -c '^\[NOTE\]' || true)
if [[ "$expect" == ok && "$warns" -eq 0 && "$notes" -eq 0 ]]; then
echo "ok - $label"
elif [[ "$expect" == warn && "$warns" -gt 0 ]]; then
echo "ok - $label (warned)"
elif [[ "$expect" == note && "$notes" -gt 0 ]]; then
echo "ok - $label (noted)"
else
echo "output: $out" >&2
fail "$label: expected $expect (warns=$warns notes=$notes)"
fi
}
# ── legacy: no brain, custom home never adopts ─────────────────────────────
mkdir -p "$ROOT/legacy-mosaic/fleet/agents"
run_case "custom home without brain stays legacy" ok \
MOSAIC_HOME="$ROOT/legacy-mosaic" HOME="$ROOT"
# ── healthy brain at the default config home ───────────────────────────────
mkdir -p "$ROOT/home/.config/mosaic" "$ROOT/home/.mosaic/fleet/agents"
chmod 700 "$ROOT/home/.mosaic/fleet/agents"
run_case "default home adopts healthy brain" ok \
MOSAIC_HOME="$ROOT/home/.config/mosaic" HOME="$ROOT/home"
# ── explicit MOSAIC_BRAIN_HOME to a brain without fleet/agents → warn ──────
mkdir -p "$ROOT/brain-noagents/fleet" "$ROOT/config"
run_case "explicit brain without agents warns" warn \
MOSAIC_HOME="$ROOT/config" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-noagents"
# ── explicit MOSAIC_BRAIN_HOME to a healthy brain → ok ─────────────────────
mkdir -p "$ROOT/brain-ok/fleet/agents" "$ROOT/config2"
chmod 700 "$ROOT/brain-ok/fleet/agents"
run_case "explicit healthy brain passes" ok \
MOSAIC_HOME="$ROOT/config2" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-ok"
# ── group-readable agents dir → warn (0700 boundary) ───────────────────────
mkdir -p "$ROOT/brain-loose/fleet/agents" "$ROOT/config3"
chmod 750 "$ROOT/brain-loose/fleet/agents"
run_case "group-readable brain agents warns" warn \
MOSAIC_HOME="$ROOT/config3" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-loose"
# ── symlinked agents dir → warn (managed-directory boundary) ───────────────
mkdir -p "$ROOT/brain-link/real-agents" "$ROOT/brain-link/fleet" "$ROOT/config4"
ln -s "$ROOT/brain-link/real-agents" "$ROOT/brain-link/fleet/agents"
run_case "symlinked brain agents warns" warn \
MOSAIC_HOME="$ROOT/config4" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-link"
# ── split state: envs in BOTH trees → warn ─────────────────────────────────
mkdir -p "$ROOT/brain-split/fleet/agents" "$ROOT/config5/fleet/agents"
chmod 700 "$ROOT/brain-split/fleet/agents" "$ROOT/config5/fleet/agents"
touch "$ROOT/config5/fleet/agents/coder0.env.generated"
run_case "env files in both trees warns (split state)" warn \
MOSAIC_HOME="$ROOT/config5" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-split"
# ── config-home agents dir WITHOUT env files alongside a brain → ok ────────
mkdir -p "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
chmod 700 "$ROOT/brain-clean/fleet/agents" "$ROOT/config6/fleet/agents"
run_case "empty config-home agents dir alongside brain passes" ok \
MOSAIC_HOME="$ROOT/config6" HOME="$ROOT" MOSAIC_BRAIN_HOME="$ROOT/brain-clean"
# ── greenfield brain-without-agents at the default home → note (#1288) ─────
mkdir -p "$ROOT/gf-home/.config/mosaic/fleet" "$ROOT/gf-home/.mosaic"
run_case "~/.mosaic without fleet/agents at default home notes the lock-in" note \
MOSAIC_HOME="$ROOT/gf-home/.config/mosaic" HOME="$ROOT/gf-home"
# ── no ~/.mosaic at all at the default home → clean pass ─────────────────
mkdir -p "$ROOT/plain-home/.config/mosaic/fleet"
run_case "no ~/.mosaic at default home passes silently" ok \
MOSAIC_HOME="$ROOT/plain-home/.config/mosaic" HOME="$ROOT/plain-home"
# ── custom (non-default) home with a stray ~/.mosaic → still silent ──────
mkdir -p "$ROOT/custom-home/fleet/agents" "$ROOT/custom-home/.mosaic"
run_case "custom home with stray ~/.mosaic stays silent" ok \
MOSAIC_HOME="$ROOT/custom-home" HOME="$ROOT/custom-home"
echo "ok - mosaic-doctor brain-home check"
@@ -80,6 +80,26 @@ safe_path "$MOSAIC_HOME" || fail_env unsafe-path MOSAIC_HOME "$MOSAIC_HOME"
FLEET_DIR="$MOSAIC_HOME/fleet"
AGENT_ENV_DIR="$FLEET_DIR/agents"
# Brain-home split (canon docs/STRUCTURE-CANON.md §2): seat launch envs live
# under the brain home's fleet/agents when a brain is active; roster, roles
# baseline, and runtime state (fleet/run) stay under MOSAIC_HOME.
# Resolution mirrors packages/mosaic/src/fleet/brain-home.ts:
# 1. MOSAIC_BRAIN_HOME env (explicit, always wins)
# 2. ~/.mosaic — adopted only when MOSAIC_HOME is the default config home AND
# ~/.mosaic/fleet/agents exists
# 3. MOSAIC_HOME (legacy single-tree)
BRAIN_HOME="${MOSAIC_BRAIN_HOME:-}"
if [ -z "$BRAIN_HOME" ]; then
BRAIN_HOME="$MOSAIC_HOME"
if [ "$(cd "$MOSAIC_HOME" 2>/dev/null && pwd -P)" = "$HOME/.config/mosaic" ] \
&& [ -d "$HOME/.mosaic/fleet/agents" ]; then
BRAIN_HOME="$HOME/.mosaic"
fi
fi
if [ "$BRAIN_HOME" != "$MOSAIC_HOME" ]; then
AGENT_ENV_DIR="$BRAIN_HOME/fleet/agents"
fi
assert_managed_directory "$MOSAIC_HOME"
assert_managed_directory "$FLEET_DIR"
assert_private_directory "$AGENT_ENV_DIR"
@@ -167,6 +167,54 @@ if echo "$valid_args" | grep -qF 'bash -c'; then
fail "launcher constructed a shell command payload"
fi
# ── Brain-home split (canon §2) ─────────────────────────────────────────
# When MOSAIC_HOME is the default config home under $HOME and the host carries
# $HOME/.mosaic/fleet/agents, seat envs resolve from the brain tree; the config
# home still owns fleet/run (holder-owner) and remains a managed boundary.
: > "$TMUX_CALLS"
HOME_BRAIN="$ROOT/brain-home"
CONFIG_HOME="$HOME_BRAIN/.config/mosaic"
BRAIN="$HOME_BRAIN/.mosaic"
mkdir -p "$CONFIG_HOME/fleet/run" "$BRAIN/fleet/agents" "$HOME_BRAIN/work"
chmod 700 "$CONFIG_HOME" "$CONFIG_HOME/fleet" "$CONFIG_HOME/fleet/run" \
"$BRAIN/fleet/agents" "$HOME_BRAIN/work"
printf '123e4567-e89b-12d3-a456-426614174000\n' > "$CONFIG_HOME/fleet/run/holder-owner"
chmod 600 "$CONFIG_HOME/fleet/run/holder-owner"
cat > "$BRAIN/fleet/agents/coder-brain.env.generated" <<EOF
MOSAIC_AGENT_NAME=coder-brain
MOSAIC_AGENT_CLASS=code
MOSAIC_AGENT_RUNTIME=pi
MOSAIC_AGENT_MODEL=openai-codex/gpt-5.6-sol
MOSAIC_AGENT_REASONING=high
MOSAIC_AGENT_TOOL_POLICY=code
MOSAIC_AGENT_WORKDIR=$HOME_BRAIN/work
MOSAIC_TMUX_SOCKET=mosaic-test
EOF
chmod 600 "$BRAIN/fleet/agents/coder-brain.env.generated"
install_pane_binaries "$HOME_BRAIN"
HOME="$HOME_BRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_BRAIN" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$CONFIG_HOME" "$START" coder-brain
brain_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$brain_args" | grep -qF new-session || fail "brain-home generated projection did not reach tmux"
echo "$brain_args" | grep -qF 'coder-brain' || fail "brain-home agent env was not the launch source"
[ -f "$BRAIN/fleet/agents/coder-brain.env.generated" ] || fail "brain generated env vanished"
# Negative control: the SAME default-config-home shape but without
# ~/.mosaic/fleet/agents — the config-home env tree is used directly (legacy).
: > "$TMUX_CALLS"
HOME_NOBRAIN="$ROOT/brainless-home"
CONFIG_HOME_NOBRAIN="$HOME_NOBRAIN/.config/mosaic"
write_generated "$CONFIG_HOME_NOBRAIN" "coder-legacy"
install_pane_binaries "$HOME_NOBRAIN"
HOME="$HOME_NOBRAIN" PATH="$FAKE_BIN:$PATH" MOSAIC_TEST_TMUX_CALLS="$TMUX_CALLS" \
MOSAIC_TEST_PANE_PID=$$ MOSAIC_TEST_HOME="$HOME_NOBRAIN" \
MOSAIC_TEST_FLEET_OWNER=123e4567-e89b-12d3-a456-426614174000 \
MOSAIC_HOME="$CONFIG_HOME_NOBRAIN" "$START" coder-legacy
legacy_args=$(tr '\0' '\n' < "$TMUX_CALLS")
echo "$legacy_args" | grep -qF new-session || fail "legacy single-tree launch regressed"
# The pane must start through an absolute clean-environment boundary. Its
# runtime command remains an argv vector, but no holder/session environment
# control variable can pass through the pane command.
@@ -39,3 +39,20 @@ packages/mosaic/framework/tools/tmux/test-send-message-verdict.sh | requires rea
# recorded judgement. These lines ARE that judgement, signed.)
packages/mosaic/framework/tools/orchestrator/smoke-test.sh | behavior smoke checks for coord continue/run workflows, run manually by orchestrator seats; unmeasured in CI; #1017 burndown
packages/mosaic/framework/tools/wake/validate-973/microtest-wake-assert.sh | #973 instrument self-test, run as a precondition of the validate-973 evidence procedure rather than as a standing CI suite; #1017 burndown candidate
# --- tools/fleet: precondition is unsatisfiable in the CI image (#1271) ---
# Signed by fred (sb-it-1-dt, 2026-08-16) at origin/next 476db12.
# This suite asserts the launcher's behaviour when `mosaic` and `pi` are MISSING.
# It shims fakes into $FAKE_BIN, but the constructed PANE_PATH always ends in the
# real system path, so on a host that installs those binaries the missing-binary
# cases cannot be measured at all. The suite's own guard (line 103) says so and
# fails rather than reporting a pass it cannot back. That guard is correct.
# The error was wiring the suite into CI: #1017 (c56483eb) enumerated it and
# dropped this exclusion, and the CI image provides `pi` in the system path, so
# it has failed on every pipeline since. Measured 2026-08-16 across pipelines
# 2444 (#1256), 2438 (#1240) and 2441 (#1017-quality): exactly one FAIL line in
# each full log, identical, this assertion; control `zzz-not-present-zzz` -> 0.
# Burn-down and the full measurement are tracked in #1271; unwired by PR #1270.
# Because test:framework-shell is one && chain and this sat at position 44 of 48,
# the four suites after it had not run at all since the merge.
packages/mosaic/framework/tools/fleet/test-start-agent-session.sh | precondition unsatisfiable in the CI image: asserts missing-binary behaviour, but PANE_PATH always ends in the system path and the image provides `pi` there; guard at line 103 fails by design rather than passing unmeasured. Burn down by controlling the tail of PANE_PATH inside the test. NOT by removing `pi` from the image: the CI image installs @earendil-works/[email protected] deliberately (measured in pipeline 2444's test-step log), and other suites depend on that pin. Burn-down tracked in #1271
+2 -3
View File
@@ -24,9 +24,8 @@
"build": "tsc",
"lint": "eslint src",
"typecheck": "tsc --noEmit",
"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": "vitest run --passWithNoTests && pnpm run test:framework-shell",
"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/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 && bash framework/tools/_scripts/test-brain-home-check.sh"
},
"dependencies": {
"@mosaicstack/brain": "workspace:*",
@@ -15,7 +15,6 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { FileConfigAdapter } from '../config/file-adapter.js';
import { seedFleetIdentityDefaults } from './fleet-first-start-identity.js';
import { composeContract } from './launch.js';
/**
@@ -320,7 +319,6 @@ describe('composeContract — overlay composer', () => {
].join('\n'),
);
process.env['MOSAIC_AGENT_NAME'] = 'exact-self';
expect(seedFleetIdentityDefaults(installedHome)).toEqual(['SOUL.md', 'USER.md']);
const composed = composeContract('pi', installedHome);
expect(composed).toContain(sourceTools);
@@ -334,49 +332,6 @@ 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)(
'never injects installed TOOLS.md through a target symlink for %s',
(runtime) => {
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { fleetAgentEnvDir, fleetRolesLocalDir } from '../fleet/brain-home.js';
import type { Command } from 'commander';
import {
executeFleetAgentMutation,
@@ -149,9 +150,9 @@ async function executeCommand(
request,
mosaicHome,
rosterPath,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentEnvDir: fleetAgentEnvDir(mosaicHome),
rolesDir: join(mosaicHome, 'fleet', 'roles'),
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
overrideDir: fleetRolesLocalDir(mosaicHome),
dryRun: forceDryRun || opts.dryRun === true,
...(deps.projectionApplier === undefined ? {} : { projectionApplier: deps.projectionApplier }),
});
@@ -1,182 +0,0 @@
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);
});
});
@@ -1,120 +0,0 @@
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;
}
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fleetAgentEnvDir, fleetRolesLocalDir } from '../fleet/brain-home.js';
import type { Command } from 'commander';
import {
parseV1MigrationObservations,
@@ -120,11 +121,11 @@ export function registerFleetMigrationCommand(
observations,
personaDirs: {
rolesDir: deps.rolesDir ?? join(mosaicHome, 'fleet', 'roles'),
overrideDir: deps.overrideDir ?? join(mosaicHome, 'fleet', 'roles.local'),
overrideDir: deps.overrideDir ?? fleetRolesLocalDir(mosaicHome),
},
environment: {
mosaicHome,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentEnvDir: fleetAgentEnvDir(mosaicHome),
},
});
printJson(preview);
@@ -30,19 +30,21 @@ import { lstat, readFile, readdir, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, isAbsolute, join, sep } from 'node:path';
import type { Command } from 'commander';
import { fleetRolesLocalDir } from '../fleet/brain-home.js';
function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
/** Baseline persona role contracts (reseeded on update). */
/** Baseline persona role contracts (reseeded on update; config home — framework). */
export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'roles');
}
/** PRESERVE-protected override layer (survives update; wins on merge). */
/** PRESERVE-protected override layer (survives update; wins on merge).
* Brain home (`~/.mosaic/fleet/roles.local`) when a brain is active. */
export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'roles.local');
return fleetRolesLocalDir(mosaicHome);
}
/**
@@ -25,6 +25,7 @@ import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import type { Command } from 'commander';
import YAML from 'yaml';
import { fleetProfilesDir } from '../fleet/brain-home.js';
import {
defaultOverrideDir,
extractClassesFromDir,
@@ -36,9 +37,10 @@ function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
/** Directory holding the seeded profile yaml files. */
/** Directory holding the seeded profile yaml files brain home when active
* (user working copies, committed), else the config home seed. */
export function defaultProfilesDir(mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'profiles');
return fleetProfilesDir(mosaicHome);
}
/** Directory holding the persona role contracts. */
@@ -3,6 +3,7 @@ import { homedir } from 'node:os';
import { join, relative, resolve } from 'node:path';
import type { Command } from 'commander';
import type { CommandRunner } from './fleet.js';
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
import {
applyPreparedGeneratedAgentEnvironmentProjection,
prepareGeneratedAgentEnvironmentProjection,
@@ -153,7 +154,7 @@ export async function executeFleetRegen(
options: FleetRegenOptions,
): Promise<FleetRegenResult> {
const mosaicHome = defaultMosaicHome(deps);
const agentEnvDir = join(mosaicHome, 'fleet', 'agents');
const agentEnvDir = fleetAgentEnvDir(mosaicHome);
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
const readRoster = deps.readRoster ?? defaultReadRoster(deps, mosaicHome);
const prepare = deps.prepareProjection ?? prepareGeneratedAgentEnvironmentProjection;
+2 -1
View File
@@ -13,6 +13,7 @@ import {
import { randomUUID } from 'node:crypto';
import { homedir, hostname, userInfo } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fleetAgentEnvDir } from '../fleet/brain-home.js';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import * as readline from 'node:readline';
@@ -158,7 +159,7 @@ export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths
fleetToolsDir: join(mosaicHome, 'tools', 'fleet'),
tmuxToolsDir: join(mosaicHome, 'tools', 'tmux'),
systemdUserDir: join(homedir(), '.config', 'systemd', 'user'),
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentEnvDir: fleetAgentEnvDir(mosaicHome),
};
}
@@ -1,393 +0,0 @@
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([]);
});
});
@@ -349,3 +349,90 @@ describe('registerRuntimeLaunchers — claudex (EXPERIMENTAL overlay)', () => {
expect(mockExit).not.toHaveBeenCalled();
});
});
// ─── Seat harness homes (MOSAIC-D-002, brain-home split) ────────────────────
import { activeSeatDir, seatPersonaOverlay } from './launch.js';
describe('activeSeatDir — per-agent harness home resolution', () => {
let root: string;
const savedAgentName = process.env['MOSAIC_AGENT_NAME'];
const savedBrainHome = process.env['MOSAIC_BRAIN_HOME'];
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'mosaic-seat-home-'));
delete process.env['MOSAIC_BRAIN_HOME'];
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
if (savedAgentName === undefined) {
delete process.env['MOSAIC_AGENT_NAME'];
} else {
process.env['MOSAIC_AGENT_NAME'] = savedAgentName;
}
if (savedBrainHome !== undefined) {
process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome;
} else {
delete process.env['MOSAIC_BRAIN_HOME'];
}
});
it('resolves the seat dir when MOSAIC_BRAIN_HOME carries the seat', () => {
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
mkdirSync(seat, { recursive: true });
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBe(seat);
});
it('returns undefined without an agent name (bare launches stay shared)', () => {
delete process.env['MOSAIC_AGENT_NAME'];
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
});
it('returns undefined when the seat dir does not exist in the brain', () => {
process.env['MOSAIC_AGENT_NAME'] = 'ghost';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
mkdirSync(join(root, 'brain', 'fleet', 'agents'), { recursive: true });
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
});
it.each(['../escape', 'a/b', '.hidden-start', '', 'spaced name'])(
'rejects unsafe agent name %j (path traversal cannot leave the seat store)',
(name: string) => {
process.env['MOSAIC_AGENT_NAME'] = name;
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
expect(activeSeatDir(join(root, 'config', 'mosaic'))).toBeUndefined();
},
);
it('seatPersonaOverlay renders the seat SOUL.md as an overlay block', () => {
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
mkdirSync(seat, { recursive: true });
writeFileSync(join(seat, 'SOUL.md'), '# coder0 — code seat persona\n\nShips tested code.\n');
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
const overlay = seatPersonaOverlay(join(root, 'config', 'mosaic'));
expect(overlay).toContain('## Seat Persona');
expect(overlay).toContain('coder0 — code seat persona');
});
it('seatPersonaOverlay is empty when the seat carries no SOUL.md', () => {
const seat = join(root, 'brain', 'fleet', 'agents', 'coder0');
mkdirSync(seat, { recursive: true });
process.env['MOSAIC_AGENT_NAME'] = 'coder0';
process.env['MOSAIC_BRAIN_HOME'] = join(root, 'brain');
expect(seatPersonaOverlay(join(root, 'config', 'mosaic'))).toBe('');
});
it('seatPersonaOverlay is empty when no agent name is set', () => {
delete process.env['MOSAIC_AGENT_NAME'];
expect(seatPersonaOverlay(join(root, 'config', 'mosaic'))).toBe('');
});
});
+59 -66
View File
@@ -19,7 +19,7 @@ import {
import { createHash, randomBytes } from 'node:crypto';
import { createRequire } from 'node:module';
import { homedir, hostname } from 'node:os';
import { join, dirname } from 'node:path';
import { join, dirname, resolve } from 'node:path';
import type { Command } from 'commander';
import {
buildResolvedFleetCommsBlock,
@@ -29,11 +29,8 @@ import {
import { readRegularFileSecure } from '../fleet/secure-file.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
import { canonicalizeRoleClass } from './fleet-personas.js';
import { resolveBrainHome } from '../fleet/brain-home.js';
import { launchClaudex, type ClaudexHarnessAdapter } from './claudex.js';
import {
readInstalledIdentityContractAtPointOfUse,
seedFleetIdentityDefaults,
} from './fleet-first-start-identity.js';
import { runLeaseEnforcementDoctorCheck } from './lease-doctor-check.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -68,9 +65,46 @@ const HARNESS_HOME_ENV: Record<RuntimeName, string> = {
opencode: 'XDG_CONFIG_HOME',
};
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime> */
function harnessHome(runtime: RuntimeName): string {
return join(MOSAIC_HOME, `.${runtime}`);
/** Dedicated mosaic-owned home for a runtime: ~/.config/mosaic/.<runtime>.
* With an active brain seat (MOSAIC_AGENT_NAME + seat dir in the brain home)
* the home is per-agent instead: <brainHome>/fleet/agents/<seat>/.<runtime>
* per-agent sessions, settings, and auth inside the seat dir (canon §2,
* MOSAIC-D-002). Seat runtime dirs are dot-named so the brain's ignore policy
* (per-seat .pi/.claude/.codex dirs) keeps credential material untracked. */
const SEAT_AGENT_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
export function activeSeatDir(mosaicHome: string = MOSAIC_HOME): string | undefined {
const agent = process.env['MOSAIC_AGENT_NAME']?.trim();
if (
agent === undefined ||
agent === '' ||
!SEAT_AGENT_NAME_RE.test(agent) ||
agent.includes('..')
) {
return undefined;
}
const brain = resolveBrainHome(mosaicHome);
if (resolve(brain) === resolve(mosaicHome)) return undefined; // no brain
const seat = join(brain, 'fleet', 'agents', agent);
return existsSync(seat) ? seat : undefined;
}
function harnessHome(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
const seat = activeSeatDir(mosaicHome);
if (seat !== undefined) return join(seat, `.${runtime}`);
return join(mosaicHome, `.${runtime}`);
}
/** Seat persona block: with an active brain seat, <seat>/SOUL.md layers
* persona on the root generic base (canon invariant; MOSAIC-D-002). The base
* SOUL stays load-on-demand only the seat delta is injected by value.
* Empty string when no seat is active or the seat carries no SOUL.md. */
export function seatPersonaOverlay(mosaicHome: string = MOSAIC_HOME): string {
const seatDir = activeSeatDir(mosaicHome);
if (seatDir === undefined) return '';
const seatSoul = readOptional(join(seatDir, 'SOUL.md'));
if (!seatSoul.trim()) return '';
return '## Seat Persona\n\n' + seatSoul.trim();
}
/**
@@ -186,6 +220,8 @@ function recordLaunch(runtime: RuntimeName, cliArgs: string[], yolo: boolean): v
cli_version: CLI_VERSION,
config_home: harnessHome(runtime),
config_home_isolated: true,
config_home_kind: activeSeatDir() !== undefined ? 'seat' : 'runtime-shared',
agent_name: process.env['MOSAIC_AGENT_NAME']?.trim() || null,
config_home_env: HARNESS_HOME_ENV[runtime] ?? null,
argv: redactArgv(cliArgs),
normative_fragments: normativeFragmentDigests(runtime),
@@ -234,55 +270,8 @@ 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 {
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)) {
console.log('[mosaic] SOUL.md not found. Running setup wizard...');
@@ -583,27 +572,26 @@ For required push/merge/issue-close/release actions, execute without routine con
parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));
// USER.md (+ USER.local.md operator overlay, appended directly under the
// profile its base owns). Fleet first start is Linux/systemd-owned and uses
// 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');
// profile its base owns).
const user = readOptional(join(mosaicHome, 'USER.md'));
if (user) parts.push('\n\n# User Profile\n\n' + user);
const userLocal = readOptional(join(mosaicHome, 'USER.local.md'));
if (userLocal.trim()) {
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
}
const fleetIdentity = resolveFleetIdentity(mosaicHome, fleetAgentName);
const fleetIdentity = resolveFleetIdentity(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
if (!fleetIdentity.ok) {
throw new Error(`Fleet communications contract unavailable: ${fleetIdentity.error}`);
}
const canonicalMember = fleetIdentity.identity?.member;
if (canonicalMember) {
assertAmbientFleetClassMatches(canonicalMember.name, canonicalMember.className);
if (canonicalMember && process.env['MOSAIC_AGENT_CLASS']?.trim()) {
const ambientClass = canonicalizeRoleClass(process.env['MOSAIC_AGENT_CLASS']).canonicalClass;
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
@@ -621,6 +609,11 @@ For required push/merge/issue-close/release actions, execute without routine con
if (soulLocal.trim()) {
overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim());
}
// Seat persona (MOSAIC-D-002): per-seat SOUL.md layers on the generic base.
const seatPersona = seatPersonaOverlay(mosaicHome);
if (seatPersona !== '') {
overlayBlocks.push(seatPersona);
}
const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
if (standardsLocal.trim()) {
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
@@ -0,0 +1,149 @@
import { mkdtemp, readFile, readdir } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { parse as parseYaml } from 'yaml';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerMissionCommand } from './mission.js';
import { PrdService } from '@mosaicstack/prdy';
import type { MissionInfo } from '../tui/gateway-api.js';
// ── Mocks: the gateway is not available in adapter tests ──────────────────────
// vi.hoisted: the mock factory is hoisted above imports, so the fixture must
// be initialized there too.
const MISSION = vi.hoisted(
(): MissionInfo => ({
id: 'mission-plan-1',
name: 'Plan Mission Alpha',
description: null,
status: 'planning',
projectId: null,
userId: null,
phase: null,
milestones: null,
config: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-03-04T05:06:07.000Z',
}),
);
vi.mock('./with-auth.js', () => ({
withAuth: vi.fn().mockResolvedValue({
gateway: 'http://localhost:14242',
cookie: 'better-auth.session_token=test',
session: {},
}),
}));
vi.mock('../tui/gateway-api.js', () => ({
fetchMissions: vi.fn().mockResolvedValue([MISSION]),
fetchMission: vi.fn(),
createMission: vi.fn(),
updateMission: vi.fn(),
fetchMissionTasks: vi.fn().mockResolvedValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
fetchProjects: vi.fn().mockResolvedValue([]),
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
const originalCwd = process.cwd();
let projectDir: string;
let logSpy: ReturnType<typeof vi.spyOn>;
let consoleStub: ReturnType<typeof vi.spyOn>[] = [];
function buildTestProgram(): Command {
const program = new Command('mosaic').exitOverride();
registerMissionCommand(program);
return program;
}
beforeEach(async () => {
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-mission-plan-'));
process.chdir(projectDir);
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
consoleStub.push(logSpy);
});
afterEach(() => {
// Restore only the per-test spies; module factory mocks keep their
// implementations across tests.
for (const stub of consoleStub) stub.mockRestore();
consoleStub = [];
process.chdir(originalCwd);
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('mosaic mission --plan (thin adapter over PrdService)', () => {
it('creates the PRD in the shared docs/prdy authority store and persists the mission linkage', async () => {
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
from: 'user',
});
// PRD landed in the same store `mosaic prdy` uses.
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/\.yaml$/);
// Fresh service instance (new-process equivalent) reads the linkage back.
const service = new PrdService({ projectPath: projectDir });
const docs = await service.list();
expect(docs).toHaveLength(1);
const prd = docs[0]!;
expect(prd.title).toBe('Plan Mission Alpha');
expect(prd.version).toBe(1);
const links = await service.listMissionLinks(prd.id);
expect(links).toHaveLength(1);
expect(links[0]).toMatchObject({
missionId: MISSION.id,
missionVersion: MISSION.updatedAt, // mission version marker
prdVersion: 1,
});
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created and linked'));
});
it('linkage is persisted in the YAML authority document itself (survives restart)', async () => {
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
from: 'user',
});
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
const raw = await readFile(path.join(projectDir, 'docs', 'prdy', files[0]!), 'utf8');
const persisted = parseYaml(raw) as { missions: Array<Record<string, unknown>> };
expect(persisted.missions).toHaveLength(1);
expect(persisted.missions[0]).toMatchObject({ missionId: 'mission-plan-1' });
});
it('the mission path and the prdy path resolve to the same store with stable ids/versions', async () => {
// Mission path.
await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], {
from: 'user',
});
// prdy path (service, non-interactive entry).
const service = new PrdService({ projectPath: projectDir });
const direct = await service.create({ name: 'Directly Created' });
const all = await service.list();
expect(all.map((doc) => doc.id).sort()).toEqual([...all.map((doc) => doc.id)].sort());
expect(all).toHaveLength(2);
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
expect(files).toContain(`${direct.id}.yaml`);
// Both are v1 in the same store with distinct stable ids.
for (const doc of all) {
expect(doc.version).toBe(1);
expect(files).toContain(`${doc.id}.yaml`);
}
});
});
+32 -5
View File
@@ -256,14 +256,41 @@ async function planMission(
console.log(`Planning mission: ${mission.name}\n`);
try {
const { runPrdWizard } = await import('@mosaicstack/prdy');
await runPrdWizard({
// Thin adapter: the PRD authority (create + mission↔PRD linkage) lives in
// PrdService — no second writer path. The mission's updatedAt serves as
// its version marker (the gateway exposes no numeric mission version).
const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy');
const service = new PrdService({ projectPath: process.cwd() });
if (process.stdout.isTTY) {
const created = await runPrdWizard({
name: mission.name,
projectPath: process.cwd(),
interactive: true,
});
const linked = await service.linkMission({
prdId: created.id,
missionId: mission.id,
missionVersion: mission.updatedAt,
requirementIds: [],
});
console.log(
`\nMission ${mission.id} linked to PRD ${linked.id} v${linked.version} (docs/prdy/).`,
);
return;
}
const doc = await service.planForMission({
name: mission.name,
projectPath: process.cwd(),
interactive: true,
missionId: mission.id,
missionVersion: mission.updatedAt,
requirementIds: [],
});
console.log(
`PRD created and linked: ${doc.id} v${doc.version} — mission ${mission.id} (docs/prdy/).`,
);
} catch (err) {
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
console.error(`PRD planning failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
}
+204
View File
@@ -0,0 +1,204 @@
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { stringify as stringifyYaml } from 'yaml';
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerPrdyCommand } from './prdy.js';
import { PrdService } from '@mosaicstack/prdy';
// ── Mocks: keep the adapter test offline (no gateway, no disk side effects
// outside the tmp project dir) ──────────────────────────────────────────────
vi.mock('./with-auth.js', () => ({
withAuth: vi.fn().mockResolvedValue({
gateway: 'http://localhost:14242',
cookie: 'better-auth.session_token=test',
session: {},
}),
}));
vi.mock('../tui/gateway-api.js', () => ({
fetchProjects: vi.fn().mockResolvedValue([]),
}));
// ── Helpers ──────────────────────────────────────────────────────────────────
class ProcessExitError extends Error {
constructor(readonly code: number) {
super(`process.exit(${code})`);
}
}
function stubProcessExit() {
return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
throw new ProcessExitError(code ?? 0);
}) as never);
}
const originalCwd = process.cwd();
let projectDir: string;
let errorSpy: ReturnType<typeof vi.spyOn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let exitStub: ReturnType<typeof stubProcessExit>;
function buildTestProgram(): Command {
const program = new Command('mosaic').exitOverride();
registerPrdyCommand(program);
return program;
}
function runPrdy(args: string[]): Promise<unknown> {
return buildTestProgram().parseAsync(['prdy', ...args], { from: 'user' });
}
function importableDocument(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'cmd-import-prd',
title: 'Command Import PRD',
status: 'approved', // must be forced to draft: validity is not approval
projectPath: '/tmp/elsewhere',
template: 'software',
version: 1,
sections: [
{ id: 'introduction', title: 'Introduction', fields: { context: 'x', objective: 'y' } },
],
missions: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
beforeEach(async () => {
projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-prdy-'));
process.chdir(projectDir);
exitStub = stubProcessExit();
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
// Restore only the per-test spies: module factory mocks must keep their
// implementations for the next test.
exitStub.mockRestore();
errorSpy.mockRestore();
logSpy.mockRestore();
process.chdir(originalCwd);
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('mosaic prdy (thin adapter over PrdService)', () => {
it('non-interactive --init creates a PRD in the docs/prdy authority store', async () => {
await runPrdy(['--init', 'Adapter Created']);
const files = await readdir(path.join(projectDir, 'docs', 'prdy'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/\.yaml$/);
const docs = await new PrdService({ projectPath: projectDir }).list();
expect(docs).toHaveLength(1);
expect(docs[0]?.title).toBe('Adapter Created');
expect(docs[0]?.version).toBe(1);
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created'));
});
it('--import <file> creates a valid import through the service', async () => {
const filePath = path.join(projectDir, 'incoming.yaml');
await writeFile(filePath, stringifyYaml(importableDocument()), 'utf8');
await runPrdy(['--import', filePath]);
const docs = await new PrdService({ projectPath: projectDir }).list();
expect(docs).toHaveLength(1);
expect(docs[0]?.id).toBe('cmd-import-prd');
expect(docs[0]?.status).toBe('draft'); // import ≠ approval
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Imported PRD cmd-import-prd'));
});
it('--import of a structurally-invalid file is a typed refusal that creates nothing', async () => {
const filePath = path.join(projectDir, 'broken.yaml');
await writeFile(filePath, stringifyYaml({ id: 'incomplete', no: 'structure' }), 'utf8');
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
// Typed refusal surfaced to the user, nothing created.
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('PRD wizard failed'));
await expect(readdir(path.join(projectDir, 'docs'))).rejects.toMatchObject({ code: 'ENOENT' });
});
it('--import on conflict refuses with a successor proposal and leaves bytes untouched', async () => {
const service = new PrdService({ projectPath: projectDir });
const existing = await service.create({ name: 'Conflict Target' });
const storeFile = path.join(projectDir, 'docs', 'prdy', `${existing.id}.yaml`);
const beforeBytes = await readFile(storeFile, 'utf8');
const filePath = path.join(projectDir, 'divergent.yaml');
await writeFile(
filePath,
stringifyYaml(
importableDocument({
...existing,
title: 'Divergent Command Import',
}),
),
'utf8',
);
await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError);
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('refusing to overwrite'));
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--accept-successor'));
// Original authority document is byte-identical on disk.
expect(await readFile(storeFile, 'utf8')).toBe(beforeBytes);
});
it('--import --accept-successor persists the successor version explicitly', async () => {
const service = new PrdService({ projectPath: projectDir });
const existing = await service.create({ name: 'Successor Target' });
const filePath = path.join(projectDir, 'divergent2.yaml');
await writeFile(
filePath,
stringifyYaml(
importableDocument({
...existing,
title: 'Accepted Via CLI',
}),
),
'utf8',
);
await runPrdy(['--import', filePath, '--accept-successor']);
const doc = await service.get(existing.id);
expect(doc.version).toBe(2);
expect(doc.title).toBe('Accepted Via CLI');
expect(doc.status).toBe('draft');
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('successor'));
});
it('--export writes a labeled generated view and never touches authority', async () => {
const service = new PrdService({ projectPath: projectDir });
const created = await service.create({ name: 'Export Via CLI' });
const before = await service.get(created.id);
await runPrdy(['--export', created.id]);
const mdPath = path.join(projectDir, 'docs', 'prdy', `${created.id}.md`);
const md = await readFile(mdPath, 'utf8');
expect(md).toContain('generated view — do not edit');
expect(md).toContain(`prd-id: ${created.id}`);
expect(md).toContain('prd-version: 1');
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining(`Generated view written: ${mdPath}`),
);
// Authority unchanged by the export.
expect(await service.get(created.id)).toEqual(before);
});
});
+65 -6
View File
@@ -2,6 +2,10 @@ import type { Command } from 'commander';
import { withAuth } from './with-auth.js';
import { fetchProjects } from '../tui/gateway-api.js';
/**
* `mosaic prdy` thin adapter over PrdService (@mosaicstack/prdy).
* All reads/writes go through the service; there is no local writer path.
*/
export function registerPrdyCommand(program: Command) {
const cmd = program
.command('prdy')
@@ -9,12 +13,18 @@ export function registerPrdyCommand(program: Command) {
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
.option('--init [name]', 'Create a new PRD')
.option('--update [name]', 'Update an existing PRD')
.option('--import <file>', 'Import a YAML PRD document (validated, conflict-aware)')
.option('--accept-successor', 'With --import: accept a conflicted import as next version')
.option('--export [id]', 'Export a PRD as a labeled generated-view Markdown file')
.option('--project <idOrName>', 'Scope to project')
.action(
async (opts: {
gateway: string;
init?: string | boolean;
update?: string | boolean;
import?: string;
acceptSuccessor?: boolean;
export?: string | boolean;
project?: string;
}) => {
// Detect project context when --project flag is provided
@@ -31,20 +41,69 @@ export function registerPrdyCommand(program: Command) {
}
}
const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy');
const service = new PrdService({ projectPath: process.cwd() });
try {
const { runPrdWizard } = await import('@mosaicstack/prdy');
if (opts.import !== undefined) {
const input = { filePath: opts.import };
if (opts.acceptSuccessor) {
const successor = await service.acceptSuccessor(input);
console.log(
`Import accepted as successor: ${successor.id} v${successor.version} (status: ${successor.status})`,
);
return;
}
const result = await service.importDocument(input);
console.log(
result.kind === 'created'
? `Imported PRD ${result.document.id} v${result.document.version} (status: ${result.document.status})`
: `PRD ${result.document.id} already present with identical content — nothing to do.`,
);
return;
}
if (opts.export !== undefined) {
const id =
typeof opts.export === 'string' && opts.export.length > 0 ? opts.export : undefined;
const result = await service.exportMarkdown({ id });
console.log(
`Generated view written: ${result.filePath} (source authority: YAML under docs/prdy/ — do not edit the Markdown)`,
);
return;
}
const name =
typeof opts.init === 'string'
? opts.init
: typeof opts.update === 'string'
? opts.update
: 'untitled';
await runPrdWizard({
name,
projectPath: process.cwd(),
interactive: true,
});
if (process.stdout.isTTY) {
await runPrdWizard({
name,
projectPath: process.cwd(),
interactive: true,
});
return;
}
// Non-interactive fallback routes through the service directly.
const doc = await service.create({ name });
console.log(`PRD created: ${doc.id} v${doc.version} (status: ${doc.status})`);
} catch (err) {
if (err instanceof Error && err.name === 'PrdImportConflictError') {
const conflict = err as { proposal?: { version?: number } };
console.error(`${err.message}`);
console.error(
`Original PRD left untouched. To accept the proposed successor (v${conflict.proposal?.version}), re-run with --accept-successor.`,
);
process.exit(1);
}
console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
}
@@ -0,0 +1,114 @@
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
brainHomeIsActive,
fleetAgentEnvDir,
fleetProfilesDir,
fleetRolesLocalDir,
fleetStateDir,
resolveBrainHome,
type BrainHomeOptions,
} from './brain-home.js';
describe('fleet brain-home resolution', (): void => {
let cleanup: string | undefined;
const savedBrainEnv = process.env['MOSAIC_BRAIN_HOME'];
beforeEach((): void => {
delete process.env['MOSAIC_BRAIN_HOME'];
});
afterEach(async (): Promise<void> => {
if (savedBrainEnv === undefined) {
delete process.env['MOSAIC_BRAIN_HOME'];
} else {
process.env['MOSAIC_BRAIN_HOME'] = savedBrainEnv;
}
if (cleanup !== undefined) {
await rm(cleanup, { recursive: true, force: true });
cleanup = undefined;
}
});
async function makeTmp(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'mosaic-brain-home-'));
cleanup = root;
return root;
}
it('MOSAIC_BRAIN_HOME env wins over every other signal', (): void => {
process.env['MOSAIC_BRAIN_HOME'] = '/explicit/brain';
expect(resolveBrainHome('/any/mosaic-home')).toBe('/explicit/brain');
expect(fleetAgentEnvDir('/any/mosaic-home')).toBe('/explicit/brain/fleet/agents');
expect(brainHomeIsActive('/any/mosaic-home')).toBe(true);
});
it('injected envBrainHome wins identically (test seam)', (): void => {
const opts: BrainHomeOptions = { envBrainHome: '/injected/brain' };
expect(resolveBrainHome('/any/mosaic-home', opts)).toBe('/injected/brain');
expect(fleetAgentEnvDir('/any/mosaic-home', opts)).toBe('/injected/brain/fleet/agents');
});
it('a non-default mosaicHome never adopts the canonical brain (hermetic legacy)', (): void => {
const mosaicHome = '/tmp/not-the-default-config-home';
expect(resolveBrainHome(mosaicHome)).toBe(mosaicHome);
expect(brainHomeIsActive(mosaicHome)).toBe(false);
expect(fleetAgentEnvDir(mosaicHome)).toBe(join(mosaicHome, 'fleet', 'agents'));
});
it('the default config home adopts the brain when it carries fleet/agents', async (): Promise<void> => {
const root = await makeTmp();
const brain = join(root, 'brain');
await mkdir(join(brain, 'fleet', 'agents'), { recursive: true });
const configHome = join(root, 'config', 'mosaic');
const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } };
expect(resolveBrainHome(configHome, opts)).toBe(brain);
expect(fleetAgentEnvDir(configHome, opts)).toBe(join(brain, 'fleet', 'agents'));
expect(fleetRolesLocalDir(configHome, opts)).toBe(join(brain, 'fleet', 'roles.local'));
expect(fleetProfilesDir(configHome, opts)).toBe(join(brain, 'fleet', 'profiles'));
expect(fleetStateDir(configHome, opts)).toBe(join(brain, 'fleet'));
expect(brainHomeIsActive(configHome, opts)).toBe(true);
});
it('the default config home stays legacy when no brain exists', async (): Promise<void> => {
const root = await makeTmp();
const configHome = join(root, 'config', 'mosaic');
const opts: BrainHomeOptions = {
homes: { brain: join(root, 'brain'), configDefault: configHome },
};
expect(resolveBrainHome(configHome, opts)).toBe(configHome);
expect(brainHomeIsActive(configHome, opts)).toBe(false);
});
it('an empty MOSAIC_BRAIN_HOME is ignored, not treated as set', (): void => {
process.env['MOSAIC_BRAIN_HOME'] = ' ';
expect(resolveBrainHome('/tmp/legacy-home')).toBe('/tmp/legacy-home');
});
it('adoption requires fleet/agents specifically, not any brain content', async (): Promise<void> => {
const root = await makeTmp();
const brain = join(root, 'brain');
await mkdir(join(brain, 'fleet'), { recursive: true }); // fleet without agents
const configHome = join(root, 'config', 'mosaic');
const opts: BrainHomeOptions = { homes: { brain, configDefault: configHome } };
expect(resolveBrainHome(configHome, opts)).toBe(configHome);
});
it('real-home control: a host brain is adopted only through the default home', (): void => {
// Control on the un-injected path: this host carries ~/.mosaic/fleet/agents,
// so the default config home resolves to the brain or legacy — both valid
// canonical endpoints — while a non-default home never adopts.
const defaultHome = join(homedir(), '.config', 'mosaic');
const resolved = resolveBrainHome(defaultHome);
expect([defaultHome, join(homedir(), '.mosaic')]).toContain(resolved);
expect(resolveBrainHome(join(homedir(), 'elsewhere', 'mosaic'))).toBe(
join(homedir(), 'elsewhere', 'mosaic'),
);
});
});
+76
View File
@@ -0,0 +1,76 @@
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
/**
* Overridable resolution inputs (tests inject tmp homes; production reads
* the environment and the real home directory).
*/
export interface BrainHomeOptions {
/** Explicit brain home; defaults to `MOSAIC_BRAIN_HOME`. */
readonly envBrainHome?: string;
/**
* Canonical homes used for adoption. Defaults derive from the real
* `homedir()`: `{ brain: ~/.mosaic, configDefault: ~/.config/mosaic }`.
*/
readonly homes?: { readonly brain: string; readonly configDefault: string };
}
/**
* Brain-home resolution the three-tree fleet split (stack canon
* `docs/STRUCTURE-CANON.md` §2, first carried by the USC estate brain):
*
* config home (~/.config/mosaic) framework templates + dispatch state:
* fleet/roles (baseline), fleet/roster.yaml,
* fleet/run (heartbeats), fleet/services
* brain home (~/.mosaic) user-owned fleet state, committed:
* fleet/agents/<seat>.env.*, fleet/roles.local,
* fleet/profiles working copies
*
* Resolution order:
* 1. `MOSAIC_BRAIN_HOME` env (explicit, always wins)
* 2. canonical `~/.mosaic` adopted ONLY when mosaicHome is the real
* default config home AND `~/.mosaic/fleet/agents` exists. Custom
* `--mosaic-home` values (tests, sandboxes, canaries) never trigger
* adoption, keeping them hermetic and deterministic.
* 3. mosaicHome itself (legacy single-tree behavior).
*/
export function resolveBrainHome(mosaicHome: string, options: BrainHomeOptions = {}): string {
const explicit = options.envBrainHome ?? process.env['MOSAIC_BRAIN_HOME'];
if (explicit !== undefined && explicit.trim() !== '') {
return explicit;
}
const homes = options.homes ?? {
brain: join(homedir(), '.mosaic'),
configDefault: join(homedir(), '.config', 'mosaic'),
};
if (resolve(mosaicHome) !== resolve(homes.configDefault)) {
return mosaicHome;
}
return existsSync(join(homes.brain, 'fleet', 'agents')) ? homes.brain : mosaicHome;
}
/** True when fleet state resolves somewhere other than the config home. */
export function brainHomeIsActive(mosaicHome: string, options: BrainHomeOptions = {}): boolean {
return resolve(resolveBrainHome(mosaicHome, options)) !== resolve(mosaicHome);
}
/** Fleet state root (brain home when active, else the config home). */
export function fleetStateDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(resolveBrainHome(mosaicHome, options), 'fleet');
}
/** Seat launch envs — `<brainHome>/fleet/agents` when a brain is active. */
export function fleetAgentEnvDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'agents');
}
/** PRESERVE-protected persona override layer — `<brainHome>/fleet/roles.local`. */
export function fleetRolesLocalDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'roles.local');
}
/** System-type profiles (user working copies) — `<brainHome>/fleet/profiles`. */
export function fleetProfilesDir(mosaicHome: string, options: BrainHomeOptions = {}): string {
return join(fleetStateDir(mosaicHome, options), 'profiles');
}
@@ -3,6 +3,7 @@ import { lstat, open, readFile, unlink, type FileHandle } from 'node:fs/promises
import { randomUUID } from 'node:crypto';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { fleetAgentEnvDir } from './brain-home.js';
import {
applyPreparedAgentEnvironmentProjection,
prepareAgentEnvironmentProjection,
@@ -617,7 +618,7 @@ function defaultPrepareProjections(
(agent: FleetRosterV2Agent): Promise<PreparedAgentEnvironmentProjection> =>
prepareAgentEnvironmentProjection({
mosaicHome,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentEnvDir: fleetAgentEnvDir(mosaicHome),
agentName: agent.name,
generated: projectRosterV2AgentGeneratedEnv(roster, agent),
}),
@@ -176,6 +176,52 @@ describe('generated fleet agent environment boundary', (): void => {
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
});
it('brain home: accepts and writes projections under MOSAIC_BRAIN_HOME/fleet/agents', async (): Promise<void> => {
const savedBrainHome = process.env['MOSAIC_BRAIN_HOME'];
try {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'config-home');
const brainHome = join(cleanup, 'brain');
const agentEnvDir = join(brainHome, 'fleet', 'agents');
process.env['MOSAIC_BRAIN_HOME'] = brainHome;
const result = await writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir,
agentName: 'coder0',
generated: generatedValues,
});
// Projection landed in the brain tree, not under the config home.
expect(result.generatedPath).toBe(join(agentEnvDir, 'coder0.env.generated'));
expect((await stat(join(brainHome, 'fleet'))).mode & 0o777).toBe(0o700);
expect((await stat(agentEnvDir)).mode & 0o777).toBe(0o700);
expect((await stat(result.generatedPath)).mode & 0o777).toBe(0o600);
await expect(stat(join(mosaicHome, 'fleet'))).rejects.toThrow();
// A config-home agentEnvDir is now REJECTED while the brain is active —
// the boundary must not silently split state across two trees.
let rejected: unknown;
try {
await writeAgentEnvironmentProjection({
mosaicHome,
agentEnvDir: join(mosaicHome, 'fleet', 'agents'),
agentName: 'coder1',
generated: { ...generatedValues, MOSAIC_AGENT_NAME: 'coder1' },
});
} catch (caught: unknown) {
rejected = caught;
}
expect(rejected).toBeInstanceOf(AgentEnvBoundaryError);
} finally {
if (savedBrainHome === undefined) {
delete process.env['MOSAIC_BRAIN_HOME'];
} else {
process.env['MOSAIC_BRAIN_HOME'] = savedBrainHome;
}
}
});
it('regenerates desired keys, relocates safe legacy local data, and quarantines forbidden legacy input', async (): Promise<void> => {
cleanup = await mkdtemp(join(tmpdir(), 'mosaic-generated-env-'));
const mosaicHome = join(cleanup, 'mosaic');
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto';
import { chmod, lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fleetAgentEnvDir, resolveBrainHome } from './brain-home.js';
import { compareCodePoints } from './deterministic-order.js';
export type AgentEnvironmentKind = 'generated' | 'local';
@@ -528,12 +529,15 @@ async function validatePrivateProjectionDirectory(
mosaicHome: string,
agentEnvDir: string,
): Promise<void> {
const fleetDir = join(mosaicHome, 'fleet');
const expectedAgentEnvDir = join(fleetDir, 'agents');
// Brain-home split (canon §2): seat envs live under the brain home's
// fleet/agents when a brain is active; roster + templates stay config-home.
const expectedAgentEnvDir = fleetAgentEnvDir(mosaicHome);
if (resolve(agentEnvDir) !== resolve(expectedAgentEnvDir)) {
throw new AgentEnvBoundaryError('unsafe-directory', '(directory)', agentEnvDir);
}
await assertManagedDirectoryIfPresent(mosaicHome, false);
const stateHome = resolveBrainHome(mosaicHome);
const fleetDir = join(stateHome, 'fleet');
await assertManagedDirectoryIfPresent(stateHome, false);
await assertManagedDirectoryIfPresent(fleetDir, false);
await assertManagedDirectoryIfPresent(agentEnvDir, true);
}
@@ -543,8 +547,9 @@ async function ensurePrivateProjectionDirectory(
agentEnvDir: string,
): Promise<void> {
await validatePrivateProjectionDirectory(mosaicHome, agentEnvDir);
const fleetDir = join(mosaicHome, 'fleet');
await ensureManagedDirectory(mosaicHome, false);
const stateHome = resolveBrainHome(mosaicHome);
const fleetDir = join(stateHome, 'fleet');
await ensureManagedDirectory(stateHome, false);
await ensureManagedDirectory(fleetDir, false);
await ensureManagedDirectory(agentEnvDir, true);
}
+74 -14
View File
@@ -1,6 +1,6 @@
import { Command } from 'commander';
import { createPrd, listPrds, loadPrd } from './prd.js';
import { PrdService } from './service.js';
import { runPrdWizard } from './wizard.js';
interface InitCommandOptions {
@@ -18,6 +18,22 @@ interface ShowCommandOptions {
readonly id?: string;
}
interface ImportCommandOptions {
readonly project: string;
readonly file: string;
readonly acceptSuccessor?: boolean;
}
interface ExportCommandOptions {
readonly project: string;
readonly id?: string;
readonly out?: string;
}
function serviceFor(project: string): PrdService {
return new PrdService({ projectPath: project });
}
export function buildPrdyCli(): Command {
const program = new Command();
program.name('mosaic').description('Mosaic CLI').exitOverride();
@@ -38,11 +54,9 @@ export function buildPrdyCli(): Command {
template: options.template,
interactive: true,
})
: await createPrd({
: await serviceFor(options.project).create({
name: options.name,
projectPath: options.project,
template: options.template,
interactive: false,
});
console.log(
@@ -52,6 +66,7 @@ export function buildPrdyCli(): Command {
id: doc.id,
title: doc.title,
status: doc.status,
version: doc.version,
projectPath: doc.projectPath,
},
null,
@@ -65,7 +80,7 @@ export function buildPrdyCli(): Command {
.description('List PRD documents for a project')
.requiredOption('--project <path>', 'Project path')
.action(async (options: ListCommandOptions) => {
const docs = await listPrds(options.project);
const docs = await serviceFor(options.project).list();
console.log(JSON.stringify(docs, null, 2));
});
@@ -75,20 +90,65 @@ export function buildPrdyCli(): Command {
.requiredOption('--project <path>', 'Project path')
.option('--id <id>', 'PRD document id')
.action(async (options: ShowCommandOptions) => {
if (options.id !== undefined) {
const docs = await listPrds(options.project);
const match = docs.find((doc) => doc.id === options.id);
const doc = await serviceFor(options.project).get(options.id);
console.log(JSON.stringify(doc, null, 2));
});
if (match === undefined) {
throw new Error(`PRD id not found: ${options.id}`);
}
prdy
.command('import')
.description('Import a YAML PRD document (validated; conflicts propose a successor)')
.requiredOption('--project <path>', 'Project path')
.requiredOption('--file <file>', 'Path to YAML PRD document')
.option('--accept-successor', 'Accept a conflicted import as the next version')
.action(async (options: ImportCommandOptions) => {
const service = serviceFor(options.project);
const input = { filePath: options.file };
console.log(JSON.stringify(match, null, 2));
if (options.acceptSuccessor) {
const successor = await service.acceptSuccessor(input);
console.log(
JSON.stringify(
{
ok: true,
outcome: 'successor-accepted',
id: successor.id,
version: successor.version,
},
null,
2,
),
);
return;
}
const doc = await loadPrd(options.project);
console.log(JSON.stringify(doc, null, 2));
const result = await service.importDocument(input);
console.log(
JSON.stringify(
{
ok: true,
outcome: result.kind,
id: result.document.id,
version: result.document.version,
status: result.document.status,
},
null,
2,
),
);
});
prdy
.command('export')
.description('Render a PRD to a labeled generated-view Markdown file')
.requiredOption('--project <path>', 'Project path')
.option('--id <id>', 'PRD document id')
.option('--out <path>', 'Output path (default docs/prdy/<id>.md)')
.action(async (options: ExportCommandOptions) => {
const result = await serviceFor(options.project).exportMarkdown({
id: options.id,
outPath: options.out,
});
console.log(JSON.stringify({ ok: true, filePath: result.filePath }, null, 2));
});
return program;
+24 -1
View File
@@ -1,12 +1,35 @@
export { createPrd, loadPrd, savePrd, listPrds } from './prd.js';
// PrdService is the single authority surface for PRD documents. The raw store
// writers (createPrd/savePrd) are deliberately NOT exported: every mutation
// goes through the service so there is no second writer path.
export { loadPrd, listPrds, parsePrdDocument } from './prd.js';
export { runPrdWizard } from './wizard.js';
export { buildPrdyCli, runPrdyCli } from './cli.js';
export { BUILTIN_PRD_TEMPLATES, resolveTemplate } from './templates.js';
export {
PrdService,
PRD_GENERATED_VIEW_LABEL,
PrdError,
PrdNotFoundError,
PrdUpdateError,
PrdImportInvalidError,
PrdImportConflictError,
} from './service.js';
export type {
PrdStatus,
PrdTemplate,
PrdTemplateSection,
PrdSection,
PrdMissionLinkage,
PrdDocument,
CreatePrdOptions,
PrdServiceOptions,
PrdCreateInput,
PrdSectionPatch,
PrdUpdateInput,
PrdLinkMissionInput,
PrdPlanForMissionInput,
PrdExportInput,
PrdExportResult,
PrdImportInput,
PrdImportResult,
} from './types.js';
+37 -1
View File
@@ -17,17 +17,49 @@ const prdSectionSchema = z.object({
fields: z.record(z.string(), z.string()),
});
const prdMissionLinkageSchema = z.object({
missionId: z.string().min(1),
missionVersion: z.string().min(1),
prdVersion: z.number().int().min(1),
requirementIds: z.array(z.string()),
linkedAt: z.string().datetime(),
});
const prdDocumentSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
status: z.enum(['draft', 'review', 'approved', 'archived']),
projectPath: z.string().min(1),
template: z.string().min(1),
// Defaults keep documents written by older prdy versions loadable.
version: z.number().int().min(1).default(1),
sections: z.array(prdSectionSchema),
missions: z.array(prdMissionLinkageSchema).default([]),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
/** YAML timestamp scalars are parsed as Date by some emitters — normalize to ISO strings. */
function coerceTimestamps(value: unknown): unknown {
if (value instanceof Date) {
return value.toISOString();
}
if (Array.isArray(value)) {
return value.map(coerceTimestamps);
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key, coerceTimestamps(entry)]),
);
}
return value;
}
/** Validate an unknown value as a PRD document (throws zod errors on failure). */
export function parsePrdDocument(value: unknown): PrdDocument {
return prdDocumentSchema.parse(coerceTimestamps(value)) as PrdDocument;
}
function expandHome(projectPath: string): string {
if (!projectPath.startsWith('~')) {
return projectPath;
@@ -74,6 +106,8 @@ function prdDirectory(projectPath: string): string {
return path.join(projectPath, PRD_DIRECTORY);
}
export { prdDirectory };
function prdFilePath(projectPath: string, id: string): string {
return path.join(prdDirectory(projectPath), `${id}.yaml`);
}
@@ -113,11 +147,13 @@ export async function createPrd(options: CreatePrdOptions): Promise<PrdDocument>
status: 'draft',
projectPath: resolvedProjectPath,
template: template.id,
version: 1,
sections: template.sections.map((section) => ({
id: section.id,
title: section.title,
fields: Object.fromEntries(section.fields.map((field) => [field, ''])),
})),
missions: [],
createdAt: now,
updatedAt: now,
};
@@ -190,7 +226,7 @@ export async function listPrds(projectPath: string): Promise<PrdDocument[]> {
throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`);
}
const document = prdDocumentSchema.parse(parsed);
const document = parsePrdDocument(parsed);
documents.push(document);
}
+433
View File
@@ -0,0 +1,433 @@
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
import { beforeEach, describe, expect, it } from 'vitest';
import {
PRD_GENERATED_VIEW_LABEL,
PrdImportConflictError,
PrdImportInvalidError,
PrdNotFoundError,
PrdService,
PrdUpdateError,
} from './index.js';
import type { PrdDocument } from './index.js';
// ── Helpers ──────────────────────────────────────────────────────────────────
let projectDir: string;
async function makeProject(): Promise<string> {
return mkdtemp(path.join(os.tmpdir(), 'prdy-service-'));
}
function service(): PrdService {
return new PrdService({ projectPath: projectDir });
}
function storeDir(): string {
return path.join(projectDir, 'docs', 'prdy');
}
/** Handcraft a full, schema-valid PRD document for import scenarios. */
function importFixture(overrides: Partial<PrdDocument> = {}): PrdDocument {
return {
id: 'imported-prd-20260101-000000',
title: 'Imported PRD',
status: 'draft',
projectPath: '/tmp/elsewhere',
template: 'software',
version: 1,
sections: [
{ id: 'introduction', title: 'Introduction', fields: { context: '', objective: '' } },
{
id: 'scope-non-goals',
title: 'Scope / Non-Goals',
fields: { inScope: '', outOfScope: '' },
},
],
missions: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
async function writeImportFile(doc: PrdDocument): Promise<string> {
const filePath = path.join(projectDir, `${doc.id}.import.yaml`);
await writeFile(filePath, yaml.dump(doc), 'utf8');
return filePath;
}
beforeEach(async () => {
projectDir = await makeProject();
});
// ── Single authority store (AC: prdy path and mission path resolve to the
// SAME store under docs/prdy/ with stable ids/versions) ────────────────────
describe('PrdService single authority store', () => {
it('persists PRDs from the prdy path and the mission path into the same docs/prdy store', async () => {
const direct = await service().create({ name: 'Direct PRD' });
const viaMission = await service().planForMission({
name: 'Mission PRD',
missionId: 'mission-1',
missionVersion: '2026-01-01T00:00:00.000Z',
});
const files = await readdir(storeDir());
expect(files).toContain(`${direct.id}.yaml`);
expect(files).toContain(`${viaMission.id}.yaml`);
// A fresh service instance (new process equivalent) resolves both.
const all = await service().list();
expect(all.map((doc) => doc.id).sort()).toEqual([direct.id, viaMission.id].sort());
// Stable versions: creation is v1; linkage writes do not bump content version.
expect((await service().get(direct.id)).version).toBe(1);
expect((await service().get(viaMission.id)).version).toBe(1);
});
it('round-trips documents through the store with identity intact', async () => {
const created = await service().create({ name: 'Round Trip', template: 'feature' });
const fresh = await service().get(created.id);
expect(fresh).toEqual(created);
expect(fresh.id).toBe(created.id);
expect(fresh.template).toBe('feature');
expect(fresh.status).toBe('draft');
});
it('throws a typed error for unknown ids and empty stores', async () => {
await expect(service().get('nope')).rejects.toBeInstanceOf(PrdNotFoundError);
await expect(service().get()).rejects.toBeInstanceOf(PrdNotFoundError);
});
});
// ── Mission linkage persistence (AC: linkage survives restart via fresh
// service instances) ────────────────────────────────────────────────────────
describe('PrdService mission linkage', () => {
it('persists linkage and reads it back from a fresh service instance', async () => {
const created = await service().planForMission({
name: 'Linked PRD',
missionId: 'mission-42',
missionVersion: '2026-02-03T04:05:06.000Z',
requirementIds: ['FR-1', 'FR-2'],
});
// Fresh instance — nothing in memory from the creating call.
const links = await service().listMissionLinks(created.id);
expect(links).toHaveLength(1);
expect(links[0]).toMatchObject({
missionId: 'mission-42',
missionVersion: '2026-02-03T04:05:06.000Z',
prdVersion: 1,
requirementIds: ['FR-1', 'FR-2'],
});
// Linkage is carried in the YAML authority file itself.
const raw = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
const persisted = yaml.load(raw) as PrdDocument;
expect(persisted.missions[0]?.missionId).toBe('mission-42');
expect(persisted.missions[0]?.requirementIds).toEqual(['FR-1', 'FR-2']);
});
it('refreshes an existing linkage entry in place instead of duplicating', async () => {
const created = await service().planForMission({
name: 'Relink PRD',
missionId: 'mission-7',
missionVersion: 'v1',
});
await service().update({
id: created.id,
sections: [{ id: 'introduction', fields: { objective: 'Ship it' } }],
});
const relinked = await service().linkMission({
prdId: created.id,
missionId: 'mission-7',
missionVersion: 'v2',
requirementIds: ['NFR-1'],
});
expect(relinked.missions).toHaveLength(1);
expect(relinked.missions[0]).toMatchObject({ missionVersion: 'v2', prdVersion: 2 });
});
it('does not bump the content version when writing linkage', async () => {
const created = await service().create({ name: 'Stable Version' });
const linked = await service().linkMission({
prdId: created.id,
missionId: 'm',
missionVersion: 'v1',
});
expect(linked.version).toBe(1);
});
});
// ── Update semantics ──────────────────────────────────────────────────────────
describe('PrdService update', () => {
it('applies section patches and bumps the content version', async () => {
const created = await service().create({ name: 'Updatable' });
const updated = await service().update({
id: created.id,
sections: [{ id: 'introduction', fields: { context: 'Some context', objective: 'Goal' } }],
});
expect(updated.version).toBe(2);
expect(updated.sections[0]?.fields).toMatchObject({
context: 'Some context',
objective: 'Goal',
});
expect((await service().get(created.id)).version).toBe(2);
});
it('refuses unknown section ids with a typed error', async () => {
const created = await service().create({ name: 'Strict' });
await expect(
service().update({ id: created.id, sections: [{ id: 'nope', fields: {} }] }),
).rejects.toBeInstanceOf(PrdUpdateError);
});
});
// ── Markdown export is a labeled generated view, never authority ──────────────
describe('PrdService exportMarkdown', () => {
it('writes a generated view carrying the label and source identity', async () => {
const created = await service().create({ name: 'Exported PRD' });
const result = await service().exportMarkdown({ id: created.id });
expect(result.filePath).toBe(path.join(storeDir(), `${created.id}.md`));
expect(result.content).toContain(PRD_GENERATED_VIEW_LABEL);
expect(result.content).toContain(`prd-id: ${created.id}`);
expect(result.content).toContain('prd-version: 1');
expect(result.content).toContain(`source-of-truth: docs/prdy/${created.id}.yaml`);
});
it('reflects the current version after updates', async () => {
const created = await service().create({ name: 'Versioned Export' });
await service().update({
id: created.id,
sections: [{ id: 'introduction', fields: { objective: 'v2 goal' } }],
});
const result = await service().exportMarkdown({ id: created.id });
expect(result.content).toContain('prd-version: 2');
});
it('NEGATIVE CONTROL: mutating the exported Markdown cannot change the authority', async () => {
const created = await service().create({ name: 'Guarded PRD' });
const before = structuredClone(await service().get(created.id));
const result = await service().exportMarkdown({ id: created.id });
await writeFile(
result.filePath,
`<!-- ${PRD_GENERATED_VIEW_LABEL} -->\n# FAKE\nprd-id: fake-id\nprd-version: 99\n`,
'utf8',
);
const after = await service().get(created.id);
expect(after).toEqual(before);
expect(after.version).toBe(1);
expect(after.title).toBe(before.title);
});
it('never parses Markdown files that sit in the store directory', async () => {
const created = await service().create({ name: 'Decoy Guard' });
// A decoy .md file with invalid YAML must be invisible to the store.
await writeFile(path.join(storeDir(), 'decoy.md'), 'not: [valid: yaml', 'utf8');
// And a decoy .yaml-named Markdown body must not silently validate either.
await service().exportMarkdown({ id: created.id });
const listed = await service().list();
expect(listed.map((doc) => doc.id)).toEqual([created.id]);
await expect(service().get(created.id)).resolves.toBeTruthy();
});
});
// ── Import: validated, conflict-aware, never silently merging ─────────────────
describe('PrdService importDocument', () => {
it('creates a valid import through the service, as draft — validity is not approval', async () => {
const filePath = await writeImportFile(importFixture({ status: 'approved' }));
const result = await service().importDocument({ filePath });
expect(result.kind).toBe('created');
expect(result.document.id).toBe('imported-prd-20260101-000000');
expect(result.document.status).toBe('draft'); // structural validity ≠ approval
expect(result.document.version).toBe(1);
const persisted = await service().get('imported-prd-20260101-000000');
expect(persisted.status).toBe('draft');
const files = await readdir(storeDir());
expect(files).toContain('imported-prd-20260101-000000.yaml');
});
it('reports identical content as a no-op without writing', async () => {
const created = await service().create({ name: 'Existing PRD' });
const before = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
const filePath = await writeImportFile(importFixture({ ...created }));
const result = await service().importDocument({ filePath });
expect(result.kind).toBe('identical');
const after = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8');
expect(after).toBe(before);
});
it('refuses a conflicting import with a typed error, a proposed successor, and untouched bytes', async () => {
const existing = await service().create({ name: 'Authority PRD' });
await service().linkMission({
prdId: existing.id,
missionId: 'mission-keep',
missionVersion: 'v1',
requirementIds: ['FR-0'],
});
const beforeBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
const divergent = importFixture({
...existing,
title: 'Divergent Title',
sections: [
{
id: 'introduction',
title: 'Introduction',
fields: { context: 'changed', objective: '' },
},
],
});
const filePath = await writeImportFile(divergent);
const attempt = service().importDocument({ filePath });
let caught: unknown;
try {
await attempt;
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(PrdImportConflictError);
const error = caught as PrdImportConflictError;
expect(error.code).toBe('PRD_IMPORT_CONFLICT');
expect(error.existing.id).toBe(existing.id);
expect(error.proposal.version).toBe(existing.version + 1); // successor proposal
expect(error.proposal.status).toBe('draft');
// Original authority content untouched on disk.
const afterBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8');
expect(afterBytes).toBe(beforeBytes);
});
it('acceptSuccessor persists the proposal explicitly, carrying linkages forward', async () => {
const existing = await service().create({ name: 'Successor Base' });
await service().linkMission({
prdId: existing.id,
missionId: 'mission-keep',
missionVersion: 'v1',
});
const divergent = importFixture({
...existing,
title: 'Accepted Successor Title',
});
const filePath = await writeImportFile(divergent);
const successor = await service().acceptSuccessor({ filePath });
expect(successor.id).toBe(existing.id);
expect(successor.version).toBe(existing.version + 1);
expect(successor.title).toBe('Accepted Successor Title');
expect(successor.status).toBe('draft');
expect(successor.missions.map((m) => m.missionId)).toEqual(['mission-keep']);
// Persisted for a fresh reader.
const fresh = await service().get(existing.id);
expect(fresh.version).toBe(2);
expect(fresh.title).toBe('Accepted Successor Title');
});
it('refuses structurally-invalid imports with a typed error and creates nothing', async () => {
const cases: Array<{ name: string; body: string }> = [
{ name: 'missing-title.yaml', body: yaml.dump({ id: 'x', status: 'draft' }) },
{
name: 'bad-status.yaml',
body: yaml.dump(importFixture({ status: 'not-a-status' as PrdDocument['status'] })),
},
{
name: 'bad-version.yaml',
body: yaml.dump(importFixture({ version: 0 })),
},
{ name: 'not-yaml.yaml', body: '::: not yaml [\n - {' },
];
for (const fixture of cases) {
const filePath = path.join(projectDir, fixture.name);
await writeFile(filePath, fixture.body, 'utf8');
await expect(service().importDocument({ filePath })).rejects.toBeInstanceOf(
PrdImportInvalidError,
);
}
// Nothing was created: the authority store does not even exist yet.
await expect(readdir(storeDir())).rejects.toMatchObject({ code: 'ENOENT' });
});
it('acceptSuccessor refuses when there is no existing document to succeed', async () => {
const filePath = await writeImportFile(importFixture());
await expect(service().acceptSuccessor({ filePath })).rejects.toBeInstanceOf(PrdNotFoundError);
});
});
// ── No second writer: no code path reads exported Markdown back into authority ─
describe('no-second-writer invariant (source-level)', () => {
// Resolve the package source dir whether vitest runs from the package root
// (turbo/pnpm test) or from the worktree root.
function resolveSrcDir(): string {
const candidates = [path.resolve('src'), path.resolve('packages/prdy/src')];
return candidates.find((dir) => existsSync(path.join(dir, 'service.ts'))) ?? candidates[0]!;
}
const srcDir = resolveSrcDir();
const sourceFiles = [
'cli.ts',
'index.ts',
'prd.ts',
'service.ts',
'templates.ts',
'types.ts',
'wizard.ts',
];
it('no source file in @mosaicstack/prdy reads a .md file', async () => {
for (const file of sourceFiles) {
const text = await readFile(path.join(srcDir, file), 'utf8');
const readLines = text
.split('\n')
.map((line) => line.trim())
.filter((line) => /readFile|readFileSync|createReadStream/.test(line));
for (const line of readLines) {
expect(line.includes('.md'), `${file} reads a Markdown file: ${line}`).toBe(false);
}
}
});
it('the mosaic prdy/mission adapters never read a .md file', async () => {
const adapterDir = path.resolve(srcDir, '..', '..', 'mosaic', 'src', 'commands');
for (const file of ['prdy.ts', 'mission.ts']) {
const text = await readFile(path.join(adapterDir, file), 'utf8');
expect(text.includes("'.md'") || text.includes('.md`'), `${file} references a .md path`).toBe(
false,
);
}
});
});
+379
View File
@@ -0,0 +1,379 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import yaml from 'js-yaml';
import { createPrd, listPrds, parsePrdDocument, prdDirectory, savePrd } from './prd.js';
import type {
PrdCreateInput,
PrdDocument,
PrdExportInput,
PrdExportResult,
PrdImportInput,
PrdImportResult,
PrdLinkMissionInput,
PrdMissionLinkage,
PrdPlanForMissionInput,
PrdServiceOptions,
PrdUpdateInput,
} from './types.js';
/**
* PrdService is the SINGLE authority surface for PRD documents.
*
* Every mutation path (CLI wizard, `mosaic mission --plan`, import) routes
* through this service; the YAML store under `docs/prdy/` is the authority and
* exported Markdown is a generated view that no code path reads back.
*/
// ── Typed errors ───────────────────────────────────────────────────────────────
export class PrdError extends Error {
constructor(
message: string,
readonly code: string,
) {
super(message);
this.name = 'PrdError';
}
}
export class PrdNotFoundError extends PrdError {
constructor(message: string) {
super(message, 'PRD_NOT_FOUND');
this.name = 'PrdNotFoundError';
}
}
export class PrdUpdateError extends PrdError {
constructor(message: string) {
super(message, 'PRD_UPDATE_INVALID');
this.name = 'PrdUpdateError';
}
}
/** Structural refusal: the import payload failed schema validation. Nothing is written. */
export class PrdImportInvalidError extends PrdError {
constructor(
message: string,
readonly issues?: string,
) {
super(message, 'PRD_IMPORT_INVALID');
this.name = 'PrdImportInvalidError';
}
}
/**
* Conflict refusal: an existing PRD shares the imported id but the content
* diverges. Carries a PROPOSED successor (existing version + 1) that is only
* persisted via an explicit {@link PrdService.acceptSuccessor} call import
* never overwrites and never merges.
*/
export class PrdImportConflictError extends PrdError {
constructor(
message: string,
readonly existing: PrdDocument,
readonly proposal: PrdDocument,
) {
super(message, 'PRD_IMPORT_CONFLICT');
this.name = 'PrdImportConflictError';
}
}
// ── Service ────────────────────────────────────────────────────────────────────
/** The generated-view label carried by every Markdown export. */
export const PRD_GENERATED_VIEW_LABEL = 'generated view — do not edit';
export class PrdService {
private readonly projectPath: string;
constructor(options: PrdServiceOptions) {
this.projectPath = options.projectPath;
}
/** Create a new PRD (version 1, draft) in the authority store. */
async create(input: PrdCreateInput): Promise<PrdDocument> {
return createPrd({
name: input.name,
projectPath: this.projectPath,
template: input.template,
interactive: false,
});
}
/** Read a PRD by id, or the most recently updated one. */
async get(id?: string): Promise<PrdDocument> {
const documents = await listPrds(this.projectPath);
if (id === undefined) {
const latest = documents[0];
if (latest === undefined) {
throw new PrdNotFoundError(`No PRD documents found under docs/prdy/ for this project`);
}
return latest;
}
const match = documents.find((doc) => doc.id === id);
if (match === undefined) {
throw new PrdNotFoundError(`PRD id not found: ${id}`);
}
return match;
}
/** List all PRDs in the authority store (most recently updated first). */
async list(): Promise<PrdDocument[]> {
return listPrds(this.projectPath);
}
/**
* Apply section field patches and bump the content version.
* Linkage entries are preserved; linkage writes do NOT bump the version.
*/
async update(input: PrdUpdateInput): Promise<PrdDocument> {
const doc = await this.get(input.id);
for (const patch of input.sections) {
const section = doc.sections.find((candidate) => candidate.id === patch.id);
if (section === undefined) {
throw new PrdUpdateError(`Unknown section id: ${patch.id}`);
}
for (const [field, value] of Object.entries(patch.fields)) {
if (!(field in section.fields)) {
throw new PrdUpdateError(`Unknown field "${field}" on section "${patch.id}"`);
}
section.fields[field] = value;
}
}
doc.version += 1;
doc.updatedAt = new Date().toISOString();
await savePrd(doc);
return doc;
}
/**
* Record (or refresh) a mission PRD linkage on the PRD document.
* Persisted in the YAML authority, so it survives restarts.
*/
async linkMission(input: PrdLinkMissionInput): Promise<PrdDocument> {
const doc = await this.get(input.prdId);
return this.applyLinkage(doc, input);
}
/** Read back the mission linkages recorded on a PRD. */
async listMissionLinks(prdId?: string): Promise<PrdMissionLinkage[]> {
const doc = await this.get(prdId);
return doc.missions;
}
/**
* Mission planning path: create a PRD for a mission AND persist the
* missionPRD linkage in a single authority write.
*/
async planForMission(input: PrdPlanForMissionInput): Promise<PrdDocument> {
const doc = await this.create({ name: input.name, template: input.template });
return this.applyLinkage(doc, {
prdId: doc.id,
missionId: input.missionId,
missionVersion: input.missionVersion,
requirementIds: input.requirementIds,
});
}
/**
* Render the PRD to a Markdown GENERATED VIEW.
*
* The output carries source identity (PRD id + version + generated-view
* label). It is written under `docs/prdy/<id>.md` and is NEVER read back:
* the authority store only loads `.yaml`/`.yml` files, and no code path in
* this package parses the exported Markdown.
*/
async exportMarkdown(input?: PrdExportInput): Promise<PrdExportResult> {
const doc = await this.get(input?.id);
const content = renderMarkdown(doc);
const filePath = input?.outPath ?? path.join(prdDirectory(doc.projectPath), `${doc.id}.md`);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, 'utf8');
return { filePath, content };
}
/**
* Import a YAML PRD document.
*
* Structural validation (zod) happens BEFORE anything is proposed or
* written. A structurally-valid import is persisted as `draft` validity is
* NOT approval. If an existing PRD shares the id with divergent content, a
* typed {@link PrdImportConflictError} is thrown carrying a proposed
* successor; the original authority document is left byte-identical on disk.
*/
async importDocument(input: PrdImportInput): Promise<PrdImportResult> {
const incoming = await this.readImportFile(input.filePath);
const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
if (existing === undefined) {
const document = this.buildImportedDocument(incoming);
await savePrd(document);
return { kind: 'created', document };
}
if (canonicalCore(existing) === canonicalCore(incoming)) {
return { kind: 'identical', document: existing };
}
throw new PrdImportConflictError(
`PRD id "${incoming.id}" already exists with divergent content — refusing to overwrite. ` +
`Proposed successor: version ${existing.version + 1} (draft). ` +
`Accept explicitly with acceptSuccessor().`,
existing,
this.buildSuccessor(existing, incoming),
);
}
/**
* Explicitly accept a conflicted import as a successor version of the
* existing PRD. Re-validates the source file before writing; the successor
* is persisted with status `draft` (acceptance of the import is not approval
* of the PRD) and the existing mission linkages are carried forward.
*/
async acceptSuccessor(input: PrdImportInput): Promise<PrdDocument> {
const incoming = await this.readImportFile(input.filePath);
const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id);
if (existing === undefined) {
throw new PrdNotFoundError(
`No existing PRD with id "${incoming.id}" — use importDocument to create it`,
);
}
const successor = this.buildSuccessor(existing, incoming);
await savePrd(successor);
return successor;
}
// ── internals ──────────────────────────────────────────────────────────────
private async applyLinkage(doc: PrdDocument, input: PrdLinkMissionInput): Promise<PrdDocument> {
const entry: PrdMissionLinkage = {
missionId: input.missionId,
missionVersion: input.missionVersion,
prdVersion: doc.version,
requirementIds: input.requirementIds ?? [],
linkedAt: new Date().toISOString(),
};
// One entry per mission: refresh in place if the mission is already linked.
const index = doc.missions.findIndex((m) => m.missionId === entry.missionId);
if (index === -1) {
doc.missions.push(entry);
} else {
doc.missions[index] = entry;
}
// Linkage is mission-side metadata, not a content revision: bump the
// timestamp only so ids/versions stay stable for consumers.
doc.updatedAt = new Date().toISOString();
await savePrd(doc);
return doc;
}
private async readImportFile(filePath: string): Promise<PrdDocument> {
let raw: string;
try {
raw = await fs.readFile(filePath, 'utf8');
} catch (error) {
throw new PrdImportInvalidError(`Cannot read import file ${filePath}: ${String(error)}`);
}
let parsed: unknown;
try {
parsed = yaml.load(raw);
} catch (error) {
throw new PrdImportInvalidError(`Import file is not valid YAML: ${String(error)}`);
}
try {
return parsePrdDocument(parsed);
} catch (error) {
throw new PrdImportInvalidError(
`Import file failed PRD schema validation: ${filePath}`,
error instanceof Error ? error.message : String(error),
);
}
}
private buildImportedDocument(incoming: PrdDocument): PrdDocument {
const now = new Date().toISOString();
return {
...incoming,
// The import lands in THIS project's authority store.
projectPath: this.projectPath,
// A structurally-valid import is not thereby approved.
status: 'draft',
version: 1,
missions: [],
createdAt: now,
updatedAt: now,
};
}
private buildSuccessor(existing: PrdDocument, incoming: PrdDocument): PrdDocument {
return {
...incoming,
id: existing.id,
projectPath: existing.projectPath,
status: 'draft',
version: existing.version + 1,
missions: existing.missions,
createdAt: existing.createdAt,
updatedAt: new Date().toISOString(),
};
}
}
// ── Markdown rendering (generated view) ───────────────────────────────────────
function canonicalCore(doc: PrdDocument): string {
return JSON.stringify([doc.title, doc.template, doc.sections]);
}
function renderMarkdown(doc: PrdDocument): string {
const lines: string[] = [
'<!--',
`${PRD_GENERATED_VIEW_LABEL}`,
`source-of-truth: docs/prdy/${doc.id}.yaml (YAML authority)`,
`prd-id: ${doc.id}`,
`prd-version: ${doc.version}`,
`generated-at: ${new Date().toISOString()}`,
'-->',
'',
`# ${doc.title}`,
'',
`**Status:** ${doc.status} · **Version:** ${doc.version} · **Template:** ${doc.template}`,
'',
];
if (doc.missions.length > 0) {
lines.push('## Mission Linkage', '');
for (const mission of doc.missions) {
const requirements =
mission.requirementIds.length > 0 ? mission.requirementIds.join(', ') : 'none selected';
lines.push(
`- mission \`${mission.missionId}\` @ version \`${mission.missionVersion}\`` +
` (linked at PRD v${mission.prdVersion}) — requirements: ${requirements}`,
);
}
lines.push('');
}
for (const section of doc.sections) {
lines.push(`## ${section.title}`, '');
for (const [field, value] of Object.entries(section.fields)) {
lines.push(`### ${field}`, '', value.trim().length > 0 ? value : '_Not set_.', '');
}
}
lines.push('---', '', `_End of generated view for ${doc.id} v${doc.version}._`, '');
return lines.join('\n');
}
+75
View File
@@ -19,13 +19,31 @@ export interface PrdSection {
fields: Record<string, string>;
}
/**
* Mission PRD linkage recorded on the PRD document (the YAML authority).
*
* `missionVersion` is the mission-side revision marker available to the CLI
* (the gateway exposes `updatedAt` for missions there is no numeric mission
* version yet). `prdVersion` snapshots the PRD content version at link time.
*/
export interface PrdMissionLinkage {
missionId: string;
missionVersion: string;
prdVersion: number;
requirementIds: string[];
linkedAt: string;
}
export interface PrdDocument {
id: string;
title: string;
status: PrdStatus;
projectPath: string;
template: string;
/** Content revision counter. Bumped by updates and accepted imports. */
version: number;
sections: PrdSection[];
missions: PrdMissionLinkage[];
createdAt: string;
updatedAt: string;
}
@@ -36,3 +54,60 @@ export interface CreatePrdOptions {
template?: string;
interactive?: boolean;
}
// ── PrdService surface (single authority entry point) ─────────────────────────
export interface PrdServiceOptions {
projectPath: string;
}
export interface PrdCreateInput {
name: string;
template?: string;
}
export interface PrdSectionPatch {
id: string;
fields: Record<string, string>;
}
export interface PrdUpdateInput {
/** Defaults to the most recently updated PRD. */
id?: string;
sections: PrdSectionPatch[];
}
export interface PrdLinkMissionInput {
/** Defaults to the most recently updated PRD. */
prdId?: string;
missionId: string;
missionVersion: string;
requirementIds?: string[];
}
export interface PrdPlanForMissionInput extends PrdLinkMissionInput {
name: string;
template?: string;
}
export interface PrdExportInput {
/** Defaults to the most recently updated PRD. */
id?: string;
/** Override the generated-view output path. */
outPath?: string;
}
export interface PrdExportResult {
filePath: string;
content: string;
}
/** Discriminated result of a non-conflicting import. */
export type PrdImportResult =
| { kind: 'created'; document: PrdDocument }
| { kind: 'identical'; document: PrdDocument };
export interface PrdImportInput {
/** Path to a YAML-serialized PRD document (NOT the generated Markdown view). */
filePath: string;
}
+43 -32
View File
@@ -2,8 +2,8 @@ import path from 'node:path';
import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts';
import { createPrd, savePrd } from './prd.js';
import type { CreatePrdOptions, PrdDocument } from './types.js';
import { PrdService } from './service.js';
import type { CreatePrdOptions, PrdDocument, PrdSectionPatch } from './types.js';
interface WizardAnswers {
goals: string;
@@ -11,20 +11,41 @@ interface WizardAnswers {
milestones: string;
}
function updateSectionField(doc: PrdDocument, sectionKeyword: string, value: string): void {
const section = doc.sections.find((candidate) => candidate.id.includes(sectionKeyword));
/**
* Translate wizard answers into section patches using the same keyword
* matching the wizard always used (first section whose id contains the
* keyword, then first field whose name contains it, else first field).
*/
function buildWizardPatches(doc: PrdDocument, answers: WizardAnswers): PrdSectionPatch[] {
const bySection = new Map<string, PrdSectionPatch>();
if (section === undefined) {
return;
}
const add = (keyword: string, value: string): void => {
const section = doc.sections.find((candidate) => candidate.id.includes(keyword));
if (section === undefined) {
return;
}
const fieldName =
Object.keys(section.fields).find((field) => field.toLowerCase().includes(sectionKeyword)) ??
Object.keys(section.fields)[0];
const fieldName =
Object.keys(section.fields).find((field) => field.toLowerCase().includes(keyword)) ??
Object.keys(section.fields)[0];
if (fieldName !== undefined) {
section.fields[fieldName] = value;
}
if (fieldName === undefined || section.fields[fieldName] === value) {
return;
}
const existing = bySection.get(section.id);
if (existing === undefined) {
bySection.set(section.id, { id: section.id, fields: { [fieldName]: value } });
} else {
existing.fields[fieldName] = value;
}
};
add('goal', answers.goals);
add('constraint', answers.constraints);
add('milestone', answers.milestones);
return [...bySection.values()];
}
async function promptText(message: string, initialValue = ''): Promise<string> {
@@ -63,15 +84,10 @@ async function promptTemplate(template?: string): Promise<string> {
return choice;
}
function applyWizardAnswers(doc: PrdDocument, answers: WizardAnswers): PrdDocument {
updateSectionField(doc, 'goal', answers.goals);
updateSectionField(doc, 'constraint', answers.constraints);
updateSectionField(doc, 'milestone', answers.milestones);
doc.updatedAt = new Date().toISOString();
return doc;
}
/**
* Interactive PRD wizard. All writes go through PrdService the wizard is a
* prompt layer, never a second writer path.
*/
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
intro('Mosaic PRD wizard');
@@ -82,20 +98,15 @@ export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocume
const constraints = await promptText('Key constraints');
const milestones = await promptText('Planned milestones');
const doc = await createPrd({
...options,
const service = new PrdService({ projectPath: options.projectPath });
const doc = await service.create({
name,
template,
interactive: true,
});
const updated = applyWizardAnswers(doc, {
goals,
constraints,
milestones,
});
await savePrd(updated);
const patches = buildWizardPatches(doc, { goals, constraints, milestones });
const updated =
patches.length > 0 ? await service.update({ id: doc.id, sections: patches }) : doc;
outro(`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`);
+63
View File
@@ -0,0 +1,63 @@
# @mosaicstack/quality-rails
Quality-rails scaffolding and — since RI-3-002 (RI-N4) — the **typed
quality-rails evaluator**: the single authoritative producer of check verdicts
for the checks it owns.
## Evaluator (RI-N4)
Every verdict is typed and fail-closed:
```
{ status: 'passed' | 'failed' | 'blocked' | 'error' | 'not-applicable',
checkId, checkVersion, subject, reason }
```
Missing implementations, missing inputs, unknown check ids, process errors,
timeouts, and malformed probe output can never become `passed` or an
unqualified skip — they surface as `blocked`/`error` with a reason (vocabulary
mirrors MACP's `GateStatus` discipline).
- Check definitions live as **data** with a version and a sha256 content
digest (`definitionDigest`); every recorded verdict names the definition
version that produced it.
- Check sets are selected **per subject kind** (`node`, `python`, `rust`,
`monorepo`, `unknown`) via the versioned, digested check-set policy — this
repository (a `monorepo` subject) does not share the node template's file
list.
- Shell probes stay **thin adapters**: the TS evaluator invokes them and owns
the verdict parsing (e.g. QC-20's planted-commit probe).
### Owned checks
| check id | canonical check | mechanism |
| --------------------------- | --------------- | ---------------------------------------------------------------------- |
| `qc-19-rails-files-present` | QC-19 | typed absorption of the former presence-only `check`/`doctor` loop |
| `qc-20-enforcement-verify` | QC-20 | thin shell adapter (framework `verify.sh`); verdict parsing owned here |
The canonical check ids QC-1..QC-21 and their dispositions are defined in
`docs/release-integrity/probe-inventory.md` (the RI-3-001 inventory — the
evaluator's input, not its output).
### Usage
```sh
# CLI (same typed report as the programmatic API)
node dist/cli.js quality-rails evaluate --project <path> [--probe-path <verify.sh>] [--json]
node dist/cli.js quality-rails check --project <path> [--json] # QC-19 only, fail-closed exit
```
```ts
import { evaluateSubject } from '@mosaicstack/quality-rails';
const report = await evaluateSubject({ subjectPath: '/path/to/project' });
// report.state: 'passed' | 'failed' | 'blocked' | 'error'
```
`pnpm verify:release` invokes this evaluator as its `quality-rails` stage
(canonical-only stage, QC-19 on the monorepo subject).
## Scaffolding (pre-existing)
`init` scaffolds rails files per detected kind/profile; `doctor` is advisory
and reports typed states.
@@ -0,0 +1,194 @@
import { mkdir, mkdtemp, writeFile, chmod } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createQualityRailsCli } from './cli.js';
import { QC_19_RAILS_FILES_PRESENT } from './evaluator/definitions.js';
import { evaluateSubject } from './evaluator/runner.js';
import type { EvaluationReport } from './evaluator/types.js';
// CLI ↔ programmatic contract (RI-3-002): the same subject must produce the
// same typed verdicts through every entry point the card adds — the
// `evaluate`/`check` CLI surfaces and the `evaluateSubject` API.
async function makeTempDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'qr-cli-'));
}
async function scaffoldNodeFixture(skip: string[] = []): Promise<string> {
const dir = await makeTempDir();
await writeFile(join(dir, 'package.json'), '{}\n', 'utf8');
for (const relativePath of [
'.eslintrc',
'biome.json',
'.githooks/pre-commit',
'PR-CHECKLIST.md',
]) {
if (skip.includes(relativePath)) continue;
await mkdir(join(dir, relativePath, '..'), { recursive: true });
await writeFile(join(dir, relativePath), 'fixture\n', 'utf8');
}
return dir;
}
async function makePassingProbe(dir: string): Promise<string> {
const scriptPath = join(dir, 'probe-pass.sh');
await writeFile(
scriptPath,
[
'#!/bin/bash',
'echo "✅ PASS: Type errors blocked"',
'echo "✅ PASS: Lint errors blocked"',
'echo "Verification Summary"',
'exit 0',
].join('\n') + '\n',
'utf8',
);
await chmod(scriptPath, 0o755);
return scriptPath;
}
describe('CLI entry points vs the programmatic evaluator', () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let previousExitCode: string | number | undefined;
beforeEach(() => {
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
previousExitCode = process.exitCode ?? undefined;
});
afterEach(() => {
logSpy.mockRestore();
process.exitCode = previousExitCode;
});
it('evaluate --json produces the SAME typed report as evaluateSubject (full check set + probe)', async () => {
const dir = await scaffoldNodeFixture();
const probePath = await makePassingProbe(dir);
const programmatic = await evaluateSubject({
subjectPath: dir,
inputs: { 'qc-20-enforcement-verify': { probePath } },
});
const program = createQualityRailsCli();
await program.parseAsync([
'node',
'cli.js',
'quality-rails',
'evaluate',
'--project',
dir,
'--probe-path',
probePath,
'--json',
]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
const cliReport = JSON.parse(printed) as EvaluationReport;
expect(cliReport).toEqual(programmatic);
expect(cliReport.state).toBe('passed');
expect(process.exitCode).toBe(0);
});
it('check --json produces the SAME QC-19 verdict as evaluateSubject (absorbed loop)', async () => {
const dir = await scaffoldNodeFixture(['biome.json', '.githooks/pre-commit']);
const programmatic = await evaluateSubject({
subjectPath: dir,
checkIds: [QC_19_RAILS_FILES_PRESENT.id],
});
expect(programmatic.state).toBe('failed');
const program = createQualityRailsCli();
await program.parseAsync([
'node',
'cli.js',
'quality-rails',
'check',
'--project',
dir,
'--json',
]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
const cliReport = JSON.parse(printed) as EvaluationReport;
expect(cliReport).toEqual(programmatic);
expect(process.exitCode).toBe(1);
});
it('check on a complete subject exits 0 with a passed verdict', async () => {
const dir = await scaffoldNodeFixture();
const program = createQualityRailsCli();
await program.parseAsync([
'node',
'cli.js',
'quality-rails',
'check',
'--project',
dir,
'--json',
]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
const cliReport = JSON.parse(printed) as EvaluationReport;
expect(cliReport.state).toBe('passed');
expect(process.exitCode).toBe(0);
});
it('evaluate with an unknown check id exits 1 and reports error, never passed', async () => {
const dir = await scaffoldNodeFixture();
const program = createQualityRailsCli();
await program.parseAsync([
'node',
'cli.js',
'quality-rails',
'evaluate',
'--project',
dir,
'--check',
'qc-99-bogus',
'--json',
]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
const cliReport = JSON.parse(printed) as EvaluationReport;
expect(cliReport.results).toHaveLength(1);
const first = cliReport.results[0];
expect(first?.status).toBe('error');
expect(first?.reason).toContain('unknown check id');
expect(process.exitCode).toBe(1);
});
it('evaluate on a scaffold subject without --probe-path stays fail-closed (blocked, exit 1)', async () => {
const dir = await scaffoldNodeFixture();
const program = createQualityRailsCli();
await program.parseAsync([
'node',
'cli.js',
'quality-rails',
'evaluate',
'--project',
dir,
'--json',
]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
const cliReport = JSON.parse(printed) as EvaluationReport;
const qc20 = cliReport.results.find((r) => r.checkId === 'qc-20-enforcement-verify');
expect(qc20).toBeDefined();
expect(qc20?.status).toBe('blocked');
expect(qc20?.reason).toContain('probePath');
expect(cliReport.state).toBe('blocked');
expect(process.exitCode).toBe(1);
});
it('doctor stays advisory (no nonzero exit) but reports TYPED states, including blocked', async () => {
const dir = await scaffoldNodeFixture();
const program = createQualityRailsCli();
await program.parseAsync(['node', 'cli.js', 'quality-rails', 'doctor', '--project', dir]);
const printed = logSpy.mock.calls.map((call) => String(call[0])).join('\n');
expect(printed).toContain('blocked: qc-20-enforcement-verify');
expect(process.exitCode ?? 0).toBe(0);
});
});

Some files were not shown because too many files have changed in this diff Show More