feat(agents): darkwing host-dev launcher + entry-point consolidation

scripts/agent.sh --host-dev delegates to scripts/agent-host-dev.sh;
agents/darkwing/launch.sh provides the native development TUI
(context, skills, coding tools, /goal). Root SOUL.md is the M14-era
default-collaborator persona captured by the ACT-1 context work.
Launcher regression checks pass (test-darkwing-launch.mjs).
This commit is contained in:
2026-09-07 14:06:54 -05:00
parent 8ebddd6f93
commit 7c580a5625
10 changed files with 548 additions and 2 deletions
+30
View File
@@ -0,0 +1,30 @@
# Mosaic Stack
You are the default collaborator for Mosaic Stack: a practical engineering
partner helping people build, inspect, and operate a trustworthy foundation
for delegated work.
Mosaic Stack is deliberately small, file-based, and evidence-oriented. Its
purpose is not to perform confidence; it is to make useful work attributable,
bounded, reproducible, and reviewable. Treat the system's contracts, policies,
and run records as part of the product, not paperwork around it.
Work with calm precision. Start from what the user is trying to accomplish,
make the next useful step clear, and explain results in plain language. Be
decisive when the evidence supports a decision; be explicit about uncertainty
when it does not. Never claim a test, command, integration, or outcome that
you have not actually verified.
Respect boundaries. Ask before expanding scope, changing authority, touching
credentials, or taking an irreversible external action. Prefer the least
privileged path, preserve user work, and stop on a policy or validation
refusal rather than working around it. A clean refusal with a useful diagnosis
is better than a superficially successful but untrustworthy result.
Leave a legible trail. Make changes intentional, keep records honest, and
report what changed, how it was checked, and what remains unresolved. When
coordinating other workers, give each one a bounded objective and review their
evidence instead of treating their confidence as proof.
The aim is dependable progress: small enough to understand, safe enough to
trust, and concrete enough for a person to verify.
+38
View File
@@ -0,0 +1,38 @@
===== DARKWING NATIVE DEVELOPMENT CONTEXT =====
Your identity is Darkwing. This launch runs Pi directly on the host, in the
Mosaic Stack development repository. The injected SOUL defines your persona;
CONSTITUTION and STANDARDS supply governance, USER supplies user context,
and AGENTS.md supplies repository instructions.
You have host read, bash, edit, write, grep, find, and ls tools. This is a
development TUI with the operator's OS access, not a sandbox or a registered
managed fleet seat. Use repository scripts for Mosaic operations and inspect
their effects before running them. Container paths in skills describe worker
deployments, not your current workspace. A tool's presence is not authority
to change unrelated files, other agents' work, or the live fleet.
For an assigned improvement, inspect the implementation, reproduce the issue,
make the smallest useful change, verify it, and continue through the authorized
outcome. Read docs/plans/CURRENT.md to reconcile ownership and existing gates;
a new user assignment does not silently resume unrelated queued work.
The local /goal extension is loaded and owns any operator-set goal lifecycle.
Use ms-proactive-agent for work selection and ms-goal for recovery guidance;
do not create a competing goal loop. Follow goal_report's actual schema and
reporting instructions. Its text format is Just Completed / Next Step /
Blocked, with '* none' for empty sections. No external reporting skill is
needed to discover that format. Native development packaging supersedes
older skill statements that this extension is unavailable.
For relocation recovery, read agents/darkwing/work/RESTART.md after the root
AGENTS.md and docs/plans/CURRENT.md. It records verified checkpoints and limits,
not a new assignment. The canonical checkout is /mnt/storage/src/mosaic-stack;
v1/ is archived legacy source. Reconcile newer owner direction before acting.
Conversation history persists across launcher restarts. Goals belong to a
single process incarnation; recover the assignment from verified records and
the operator's direction after a restart. No goal is started by this launcher.
Context is captured anew at launch; source edits do not update this process's
injected snapshot. Relaunch to load approved context changes.
+64
View File
@@ -0,0 +1,64 @@
# Darkwing development TUI
From any terminal, run:
```sh
/home/jwoltje/src/mosaic-stack-dev-test/agents/darkwing/launch.sh
```
The agent launcher is a thin shim to `scripts/agent.sh --host-dev darkwing`,
forwarding all arguments unchanged. `scripts/agent.sh` is the common entry
point; `scripts/agent-host-dev.sh` implements its native development mode.
The host launcher opens the repository as Darkwing's workspace.
It uses the repository-pinned Pi, the configured Mosaic provider/model, and
native Pi authentication (normal `~/.pi/agent`, or `PI_CODING_AGENT_DIR` if
explicitly set). It never copies credentials. Install dependencies with
`npm ci --ignore-scripts --no-audit --no-fund` if needed.
`--check` validates configuration and required inputs without opening Pi or
calling a model. `--fresh` starts a new conversation without deleting earlier
ones. Normal launches continue the latest conversation under
`.pi/state/darkwing/sessions/`; the first launch creates one. A launcher lock
rejects simultaneous launches through this script. It does not exclude Pi
processes started another way. Damaged JSONL history refuses automatic resume;
`--fresh` is an explicit escape hatch that preserves the damaged evidence.
The current files are combined into a private launch snapshot under
`.pi/state/darkwing/launches/`:
- `contracts/CONSTITUTION.md` and `contracts/STANDARDS.md`
- `agents/darkwing/SOUL.md`
- `<configured dataRoot>/user/USER.md`, the deployment's live user profile
- the repository's `AGENTS.md` and Darkwing's `CONTEXT.md`
Use `--soul FILE`, `--constitution FILE`, or `--user FILE` to select alternate
inputs, including a future `contracts/USER.md`. Relative paths resolve from
the repository root. Missing or empty inputs refuse launch. Snapshots can
contain personal context and remain local, with private file permissions.
Context edits take effect on relaunch, including when resuming a conversation.
The launcher enables coding/search tools, `goal_report`, ten explicit local
skills, and the canonical goal extension through `scripts/sync-dev-extensions.sh`.
Ambient context, skills, extensions, templates, and themes are disabled.
The normal Pi coding prompt is retained with the Mosaic context appended.
Enter `/goal <assignment and acceptance criteria>` to start continuing work;
`/goal stop`, `/goal resume`, and `/goal` pause, resume, and inspect it. A new
process does not automatically adopt a previous process's goal.
This TUI has the operator's host access, including repository edits and host
commands. Its tool list is not OS isolation. It creates no managed role or
fleet registration. Worker dispatch still uses the governed Mosaic task runner.
The user supplies the assignment; launch alone does not start self-modification.
## Deployment findings
The existing `scripts/agent.sh` launches a Docker container, defaults to the
`agent-<name>` session directory, and asks Pi to continue when that directory
is nonempty. Its default workspace is `<dataRoot>/workspaces/<name>`, not this
checkout. `src/load-contracts.sh` loads image-baked governance, an optional
seat SOUL override, live user Markdown, and mission context into a shared
prompt path. A seat override requires `agent.json`; a standalone SOUL is not
discovered. `adapters/pi/adapter.sh` disables extensions. The temporary host
launcher follows the existing native development path to provide repository
access and `/goal`, and keeps its conversations separate from container and
live fleet sessions. It does not invoke release alignment on startup.
+20
View File
@@ -0,0 +1,20 @@
# SOUL — Darkwing
You are Darkwing, Mosaic Stack's hands-on engineering collaborator. Your job
is to help Jason make the system dependable by using it, finding where it
falls short, and carrying authorized improvements through verification.
Be curious, direct, and resourceful. Have a technical opinion and explain
the evidence behind it. Investigate before guessing. Distinguish a design
claim, a passing test, and behavior you have observed in the running system.
Use Mosaic's own tools and workflows where they fit. Turn a failure into a
reproducible case, make a focused correction, and test the behavior again.
Let each verified improvement inform the next one within the assignment.
Keep the human informed when the result, scope, or next decision changes.
Own the outcome while respecting other agents' work. Preserve their changes
and records, give delegated work clear boundaries, and seek independent
review where required. Self-improvement never grants new authority: changing
your instructions, permissions, or a live deployment follows the same review
and authorization rules as any other system change.
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
# Darkwing's native development mode through the Mosaic agent entry point.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
exec "$REPO/scripts/agent.sh" --host-dev darkwing "$@"
+21
View File
@@ -0,0 +1,21 @@
// Refuse damaged history before Pi's --continue can silently skip it.
import { readFileSync, lstatSync } from 'node:fs';
try {
for (const file of process.argv.slice(2)) {
if (!lstatSync(file).isFile()) throw new Error(`not a regular session file: ${file}`);
const lines = readFileSync(file, 'utf8').trim().split('\n');
const entries = lines.map((line) => JSON.parse(line));
const header = entries[0];
if (header?.type !== 'session' || typeof header.id !== 'string' || !header.id ||
typeof header.version !== 'number' || typeof header.cwd !== 'string' ||
!Number.isFinite(Date.parse(header.timestamp)) ||
entries.slice(1).some((entry) => !entry || typeof entry.type !== 'string')) {
throw new Error(`invalid session structure: ${file}`);
}
if (header.cwd !== process.cwd()) throw new Error(`session belongs to another workspace: ${file}`);
}
} catch (error) {
console.error(`darkwing: cannot safely resume: ${error.message}; inspect history or explicitly use --fresh`);
process.exit(1);
}
+108
View File
@@ -0,0 +1,108 @@
# Darkwing — relocation handoff
Recorded 2026-09-07 17:43 UTC. Jason intends to relaunch with
`/mnt/storage/src/mosaic-stack/agents/darkwing/launch.sh`.
This is a recovery note, not a new assignment or automatic goal resumption.
## Read first
1. Root `AGENTS.md` and `docs/plans/CURRENT.md`.
2. This note, then `git status --short` and `git log --oneline -5`.
3. Reconcile current owner direction and any newer declared artifacts before acting.
## Repository conversion is completed locally
Jason explicitly ordered the conversion and confirmed no work was active.
- Canonical checkout: `/mnt/storage/src/mosaic-stack`.
- Origin: `https://git.mosaicstack.dev/mosaicstack/stack`.
- Branch: `refactor`.
- Conversion commit: `127a54fdff1fe6ae56c3197edddf957481465db4`.
- New foundation is at root. `v1/` is legacy archival source, NOT current code.
- Old `/home/jwoltje/src/mosaic-stack-dev-test` is a compatibility symlink to this
same checkout. Do not recreate a second working copy there.
- Both histories retained: merge parents v2 `9a5fbdbda74b16adf488fe28138b2ba69ea5e669`
and v1 `5d2770002612a09ae0cadc129b4ea30619133e8a`.
- Exact 3,507-file v1 tracked tree imported; v1 refs under `refs/archive/v1/`.
- Original v2 refs retained; `stack-v2-archive` remote has a disabled push URL.
- Only legacy tracked tree and four conversion docs committed. All earlier
uncommitted/untracked/ignored work preserved. Index was verified clean.
- Issue https://git.mosaicstack.dev/mosaicstack/stack/issues/1495 closed explicitly
for local conversion. No push, PR/trunk merge or live-service change occurred.
Record: `docs/plans/2026-09-07_repository-consolidation-completed.md`.
Receipts: `docs/plans/reviews/2026-09-07_repository-conversion-verification.json`
and `2026-09-07_repository-conversion-postcommit-verification.json`.
Verified rollback copies, NOT development roots:
- `/mnt/storage/src/.mosaic-stack-conversion-20260907T172430Z/`
- `/home/jwoltje/src/.mosaic-stack-dev-test.pre-conversion-20260907T172430Z`
Do not delete them, launch from them or restore over newer work.
## Current unfinished foundation gate
Jason's A9 acceptance of the first offline synthetic scope/permission inspector
is pending. Code is independently approved by Filbert; no blocking code finding
remains at the reviewed r6 candidate. Owner acceptance is not inferred from tests.
- Manifest: `docs/plans/reviews/2026-09-07_foundation-inspector-rocko-build-manifest-r6.json`
SHA-256 `a4a4493000aff5905337a643886ca36e7c5377d52deed77b8aeab7174ca73dcf`.
- Report: `docs/plans/reviews/2026-09-07_foundation-inspector-rocko-build-r6.md`
SHA-256 `ee0e83efd7c71eddecf5e26f939e9a34ba85b184cfcd1cffac9ff9e56ea13c37`.
- APPROVED verdict: `docs/plans/reviews/2026-09-07_foundation-inspector-code-verdict-r6.md`
SHA-256 `ab9dd5e5c3cad5c9263e873ff82cac444da2d36040e907e4798b208fa1c08b13`.
- Guide: `docs/plans/reviews/2026-09-07_foundation-inspector-demo.md`.
All 382 approved inspector files and pinned inputs survived conversion unchanged.
Actual offline checks: Node 80/0, selftests 43/0, oracle 1,568 records / zero
schema disagreements, foundation checker PASS, config/auth/conductor 24/15/17.
Postcommit conductor 17/0 and four CLI demos passed: allowed read, allowed change
PREVIEW (no mutation), missing-registration refusal, unresolved reassignment with
original selection retained. Demo inputs are separate synthetic scenarios.
`test-task.sh` and `test-release.sh` remain NOT RUN / DEFERRED under Jason's bounded
offline-demo ruling. No deployment/native/live/provider/security certification.
Reviewer qualifications: ordering equality means structural equality, not byte
identity; auxiliary native-parser warm-run anomalies remain separate unresolved
observations, not a passing universal parser-equivalence claim. Preserve all earlier
NOT APPROVED reviews and the historical correction that r3 ran unauthorized live
branches; later deferral did not retroactively authorize them.
## Ownership and limits
- Rocko authored inspector code; Filbert independently reviewed; Darkwing coordinates
and verifies. Keep the approved candidate frozen unless a new fix is authorized.
- No automatic permission to push, merge to next/main, deploy, change live config,
grant permissions, access credentials, investigate ~/.mosaic, or start new runtime
work. Local conversion authority is not authority for those activities.
- Preserve unrelated pending work. In particular `scripts/agent.sh`, `docs/TOOLS.md`,
host launcher/context files and other untracked concepts/skills belong to existing
work. Do not blanket-stage/reset/clean. Root logs and CURRENT remain uncommitted.
- Foundation #53 in the old stack-v2 project remains a separate open issue; do not
silently close or renumber it. Accepted historical SHA/path citations remain valid.
- Rocko's Archify C1 remains HELD for owner T2/T3 decisions. No lane reassignment.
- Future durability/workflow/evidence/federation/onboarding topics are notes, not
authorization to expand the inspector.
## Communications
Use only `tools/tmux/agent-send.sh`; sender `dragon-lin:darkwing`.
Rocko: `-L mosaic-fleet -s '=rocko'`; Filbert/Dewey:
`-L default -s '=filbert'` / `'=dewey'`.
Conversion notice delivered to Rocko. Filbert/Dewey sends were unconfirmed
(input boxes not locatable); no retries, no acknowledgement claimed. Check declared
artifact paths as well as direct messages; completed reviews have existed without
transported replies. Do not inspect private panes or blindly resend.
## Relaunch and goal recovery
The project launcher continues its own latest `.pi/state/darkwing/sessions/`
conversation by default. Do NOT assume this pre-launch conversation is already in
that store or that the next launch resumes this exact conversation. This handoff
is the durable bridge. No session-tree migration or launch was performed here.
The goal extension owns lifecycle. The earlier extension goal had been paused;
no restart automatically resumes it. Reconcile the actual new process state and
Jason's direction rather than reporting progress against a guessed old goal or
creating a second goal loop. Launch alone grants no new assignment.
This handoff and its CONTEXT pointer are documentation-only. Launcher scripts,
private sessions, credentials and runtime configuration were not modified.
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# Host-development implementation for scripts/agent.sh --host-dev <name>.
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO"
usage() {
echo 'Usage: scripts/agent.sh --host-dev AGENT [--fresh] [--check] [--soul FILE] [--constitution FILE] [--user FILE]'
echo 'Default: resume the latest agent development conversation; first launch starts one.'
}
fail() { echo "${AGENT:-tui}: $*" >&2; exit 1; }
case "${1:-}" in
--help|-h) usage; exit 0 ;;
"") usage >&2; exit 1 ;;
esac
AGENT="$1"
shift
[[ "$AGENT" =~ ^[a-z0-9][a-z0-9._-]{0,63}$ ]] || fail "invalid agent name"
[ -d "$REPO/agents/$AGENT" ] || fail "unknown agent: $AGENT"
SOUL="$REPO/agents/$AGENT/SOUL.md"
CONSTITUTION="$REPO/contracts/CONSTITUTION.md"
USER_FILE=""
FRESH=false
CHECK=false
while [ "$#" -gt 0 ]; do
case "$1" in
--fresh) FRESH=true; shift ;;
--check) CHECK=true; shift ;;
--soul|--constitution|--user)
[ "$#" -ge 2 ] && [ -n "$2" ] || fail "$1 needs a file"
case "$1" in
--soul) SOUL="$2" ;;
--constitution) CONSTITUTION="$2" ;;
--user) USER_FILE="$2" ;;
esac
shift 2 ;;
--help|-h) usage; exit 0 ;;
*) usage >&2; fail "unknown argument: $1" ;;
esac
done
# Reuse the deployment's validated provider/model and live user profile.
source "$REPO/scripts/common.sh"
load_config
[ "$MOSAIC_ADAPTER" = pi ] || fail 'native launcher requires the pi adapter'
USER_FILE="${USER_FILE:-$MOSAIC_DATA_ROOT/user/USER.md}"
PI="$REPO/node_modules/.bin/pi"
[ -x "$PI" ] || fail 'run npm ci --ignore-scripts --no-audit --no-fund in the repository first'
PIN="$(node -p "require('./package.json').dependencies['@earendil-works/pi-coding-agent']")"
[ "$("$PI" --version)" = "$PIN" ] || fail "local Pi must match package.json ($PIN); run npm ci"
command -v flock >/dev/null || fail 'flock is required'
CONTEXT_FILES=("$CONSTITUTION" "$REPO/contracts/STANDARDS.md" "$SOUL" "$USER_FILE" "$REPO/AGENTS.md" "$REPO/agents/$AGENT/CONTEXT.md")
for file in "${CONTEXT_FILES[@]}"; do
[ -f "$file" ] && [ -r "$file" ] && [ -s "$file" ] || fail "missing, unreadable or empty context: $file"
done
SKILLS=(ms-tools ms-file-read ms-file-write ms-conductor ms-communications
ms-agent-watch ms-sdlc ms-proactive-agent ms-goal ms-unslop)
SKILL_ARGS=()
for skill in "${SKILLS[@]}"; do
[ -s "$REPO/skills/$skill/SKILL.md" ] || fail "missing skill: $skill"
SKILL_ARGS+=(--skill "$REPO/skills/$skill")
done
[ -s "$REPO/extensions/goal/index.ts" ] || fail 'missing canonical goal extension'
STATE="$REPO/.pi/state/$AGENT"
echo "$AGENT: native host TUI | Pi $PIN | $MOSAIC_PROVIDER/$MOSAIC_MODEL"
echo "$AGENT: workspace $REPO"
echo "$AGENT: SOUL=$SOUL | CONSTITUTION=$CONSTITUTION | USER=$USER_FILE"
echo "$AGENT: sessions $STATE/sessions | skills: ${SKILLS[*]}"
if "$CHECK"; then
echo "$AGENT: configuration checks passed (no TUI, authentication request or model call)"
exit 0
fi
[ -t 0 ] && [ -t 1 ] || fail 'launch from an interactive terminal (or use --check)'
umask 077
mkdir -p "$STATE/sessions" "$STATE/launches"
exec 8>"$STATE/launch.lock"
flock -n 8 || fail "another $AGENT TUI from this launcher is active"
# The native packaging path verifies source copies and refuses installation drift.
scripts/sync-dev-extensions.sh
SESSION_ARGS=(--session-dir "$STATE/sessions")
shopt -s nullglob dotglob
SESSION_FILES=("$STATE/sessions/"*.jsonl)
SESSION_ENTRIES=("$STATE/sessions/"*)
if ! "$FRESH"; then
if [ "${#SESSION_FILES[@]}" -gt 0 ]; then
# Pi --continue otherwise silently skips broken files and may start fresh.
node "$REPO/agents/$AGENT/validate-sessions.mjs" "${SESSION_FILES[@]}"
SESSION_ARGS+=(--continue)
echo "$AGENT: resuming latest development conversation with current launch context"
elif [ "${#SESSION_ENTRIES[@]}" -gt 0 ]; then
fail 'session directory has state but no JSONL conversation; inspect it before using --fresh'
else
echo "$AGENT: starting first development conversation"
fi
else
echo "$AGENT: starting a fresh conversation; earlier conversations are preserved"
fi
# A private snapshot per launch avoids the container loader's shared prompt file.
LAUNCH="$(mktemp -d "$STATE/launches/launch.XXXXXXXX")"
PROMPT="$LAUNCH/context.md"
for file in "${CONTEXT_FILES[@]}"; do
printf '\n===== %s (%s) =====\n' "$(basename "$file")" "$file" >> "$PROMPT"
cat "$file" >> "$PROMPT"
printf '\n' >> "$PROMPT"
done
sha256sum "$PROMPT" > "$LAUNCH/context.sha256"
echo "$AGENT: context snapshot $PROMPT"
unset MOSAIC_LAUNCH_INCARNATION
export MOSAIC_AGENT_NAME="$AGENT"
# Explicit resources only; preserve Pi's built-in coding prompt and tool guidance.
# goal_report must be in the tool allowlist as well as the extension being loaded.
exec "$PI" --approve --offline --no-context-files --no-extensions --no-skills \
--no-prompt-templates --no-themes \
--extension "$REPO/.pi/extensions/goal/index.ts" \
"${SKILL_ARGS[@]}" \
--tools read,bash,edit,write,grep,find,ls,goal_report \
--provider "$MOSAIC_PROVIDER" --model "$MOSAIC_MODEL" \
--append-system-prompt "$PROMPT" "${SESSION_ARGS[@]}"
+10 -2
View File
@@ -1,9 +1,11 @@
#!/usr/bin/env bash
# Launch an interactive (TUI) Mosaic agent in its container.
# Launch an interactive Mosaic agent (container by default).
#
# Usage:
# scripts/agent.sh <name> [--mission <file>] [--workspace <ws>]
# [--session <name>] [--tools <comma,list>] [--auth <account>]
# scripts/agent.sh --host-dev <name> [--fresh] [--check]
# [--soul <file>] [--constitution <file>] [--user <file>]
#
# The agent receives the four immutable contracts (constitution, standards,
# SOUL, USER) plus its own identity and optional mission directives as its
@@ -14,6 +16,12 @@
# governed context.
set -euo pipefail
cd "$(dirname "$0")/.."
# Host development is an explicit leading mode, never a fallback from a
# container/config/policy refusal. Its helper owns its distinct native setup.
if [ "${1:-}" = "--host-dev" ]; then
shift
exec "$PWD/scripts/agent-host-dev.sh" "$@"
fi
# shellcheck source=common.sh
source scripts/common.sh
@@ -33,7 +41,7 @@ while [ $# -gt 0 ]; do
--tools) TOOLS="${2:?}"; shift 2 ;;
--auth) AUTH_ACCOUNT="${2:?}"; shift 2 ;;
--skills) SKILLS="${2:?}"; shift 2 ;;
--help|-h) sed -n '2,12p' "$0"; exit 0 ;;
--help|-h) sed -n '2,15p' "$0"; exit 0 ;;
*) NAME="$1"; shift ;;
esac
done
+132
View File
@@ -0,0 +1,132 @@
// Offline launcher checks. Fake Pi captures argv; script(1) supplies a real PTY.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, cpSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const source = resolve(import.meta.dirname, '..');
test('Darkwing launch context, resources, session recovery, and refusals', () => {
const root = mkdtempSync(join(tmpdir(), 'darkwing launch '));
try {
for (const dir of ['agents', 'scripts', 'contracts', 'user', 'node_modules/.bin', 'extensions/goal']) {
mkdirSync(join(root, dir), { recursive: true });
}
cpSync(join(source, 'agents/darkwing'), join(root, 'agents/darkwing'), { recursive: true });
cpSync(join(source, 'scripts/agent-host-dev.sh'), join(root, 'scripts/agent-host-dev.sh'));
cpSync(join(source, 'scripts/agent.sh'), join(root, 'scripts/agent.sh'));
cpSync(join(source, 'skills'), join(root, 'skills'), { recursive: true });
writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { '@earendil-works/pi-coding-agent': '0.84.4' } }));
for (const file of ['contracts/CONSTITUTION.md', 'contracts/STANDARDS.md', 'user/USER.md', 'AGENTS.md']) {
writeFileSync(join(root, file), `fixture ${file}\n`);
}
writeFileSync(join(root, 'extensions/goal/index.ts'), '// fixture\n');
writeFileSync(join(root, 'scripts/common.sh'), 'load_config() { MOSAIC_DATA_ROOT="$PWD"; MOSAIC_PROVIDER=test; MOSAIC_MODEL=test-model; MOSAIC_ADAPTER=pi; }\n');
writeFileSync(join(root, 'scripts/sync-dev-extensions.sh'), '#!/bin/sh\nexit 0\n', { mode: 0o755 });
writeFileSync(join(root, 'node_modules/.bin/pi'), `#!/usr/bin/env node
const fs = require('node:fs');
if (process.argv[2] === '--version') { console.log('0.84.4'); process.exit(0); }
fs.writeFileSync('capture.json', JSON.stringify({args: process.argv.slice(2), incarnation: process.env.MOSAIC_LAUNCH_INCARNATION}));
`, { mode: 0o755 });
const launch = (args = '', tty = true) => spawnSync(tty ? 'script' : 'bash', tty
? ['-q', '-e', '-c', `bash agents/darkwing/launch.sh ${args}`, '/dev/null']
: ['agents/darkwing/launch.sh', ...args.split(' ').filter(Boolean)],
{ cwd: root, env: { ...process.env, MOSAIC_LAUNCH_INCARNATION: 'must-not-inherit' }, encoding: 'utf8', timeout: 10000 });
const ok = (result) => assert.equal(result.status, 0, result.stdout + result.stderr);
ok(launch('--check', false));
const direct = spawnSync('bash', [join(root, 'scripts/agent.sh'), '--host-dev', 'darkwing', '--check'],
{ cwd: tmpdir(), encoding: 'utf8', timeout: 10000 });
ok(direct);
assert.match(direct.stdout, /configuration checks passed/);
assert.notEqual(launch('--auth account --check', false).status, 0);
assert.equal(existsSync(join(root, '.pi/state')), false);
ok(launch());
let captured = JSON.parse(readFileSync(join(root, 'capture.json')));
assert.equal(captured.incarnation, undefined);
assert.equal(captured.args.includes('--continue'), false);
assert.ok(captured.args.includes('--no-context-files'));
assert.ok(captured.args.includes('--no-skills'));
assert.equal(captured.args.filter(arg => arg === '--skill').length, 10);
assert.ok(captured.args[captured.args.indexOf('--tools') + 1].includes('goal_report'));
const firstPrompt = captured.args[captured.args.indexOf('--append-system-prompt') + 1];
const context = readFileSync(firstPrompt, 'utf8');
assert.match(context, /fixture contracts\/CONSTITUTION.md/);
assert.match(context, /fixture user\/USER.md/);
assert.match(context, /fixture AGENTS.md/);
assert.match(context, /You are Darkwing/);
const sessions = join(root, '.pi/state/darkwing/sessions');
const session = join(sessions, 'history.jsonl');
const history = JSON.stringify({ type: 'session', version: 3, id: 'test', cwd: root, timestamp: new Date().toISOString() }) + '\n';
writeFileSync(session, history);
ok(launch());
captured = JSON.parse(readFileSync(join(root, 'capture.json')));
assert.ok(captured.args.includes('--continue'));
assert.notEqual(captured.args[captured.args.indexOf('--append-system-prompt') + 1], firstPrompt);
ok(launch('--fresh'));
assert.equal(JSON.parse(readFileSync(join(root, 'capture.json'))).args.includes('--continue'), false);
assert.equal(readFileSync(session, 'utf8'), history);
writeFileSync(session, 'broken JSON\n');
assert.notEqual(launch().status, 0);
ok(launch('--fresh'));
assert.notEqual(launch('--user missing.md --check', false).status, 0);
assert.notEqual(launch('--unexpected', false).status, 0);
assert.notEqual(launch('', false).status, 0);
const locked = spawnSync('script', ['-q', '-e', '-c',
'flock .pi/state/darkwing/launch.lock bash agents/darkwing/launch.sh --fresh', '/dev/null'],
{ cwd: root, encoding: 'utf8', timeout: 10000 });
assert.notEqual(locked.status, 0);
assert.match(locked.stdout, /another darkwing TUI/);
writeFileSync(join(root, 'alternate.md'), 'alternate user context\n');
ok(launch('--fresh --user alternate.md'));
captured = JSON.parse(readFileSync(join(root, 'capture.json')));
assert.match(readFileSync(captured.args[captured.args.indexOf('--append-system-prompt') + 1], 'utf8'), /alternate user context/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('default agent entry retains container setup and refuses missing seat/auth', () => {
const root = mkdtempSync(join(tmpdir(), 'mosaic container entry '));
try {
for (const dir of ['scripts', 'bin', 'runtime/user']) mkdirSync(join(root, dir), { recursive: true });
cpSync(join(source, 'scripts/agent.sh'), join(root, 'scripts/agent.sh'));
// All runtime effects remain in this fixture; Docker and release helpers are doubles.
writeFileSync(join(root, 'scripts/common.sh'), `
load_config() { MOSAIC_DEV_DIR="$PWD/runtime"; MOSAIC_ADAPTER=pi; MOSAIC_MODEL=test; echo config >> trace; }
load_release() { echo release >> trace; }
bootstrap_runtime_dir() { echo bootstrap >> trace; }
ensure_release_aligned() { echo alignment >> trace; }
`);
writeFileSync(join(root, 'runtime/user/USER.md'), 'fixture user\n');
writeFileSync(join(root, 'bin/docker'), `#!/usr/bin/env node
require('node:fs').writeFileSync('docker.json', JSON.stringify({
args: process.argv.slice(2), session: process.env.MOSAIC_SESSION_DIR,
workspace: process.env.MOSAIC_WORKSPACE, tools: process.env.MOSAIC_TOOLS,
name: process.env.MOSAIC_AGENT_NAME, interactive: process.env.MOSAIC_INTERACTIVE
}));
`, { mode: 0o755 });
const env = { ...process.env, PATH: join(root, 'bin') + ':' + process.env.PATH };
for (const name of ['MOSAIC_AGENTS_DIR', 'MOSAIC_ROLES_DIR', 'MOSAIC_SKILLS', 'MOSAIC_AGENT_ROLE']) delete env[name];
const run = (args, overrides = {}) => spawnSync('bash', [join(root, 'scripts/agent.sh'), ...args],
{ cwd: tmpdir(), env: { ...env, ...overrides }, encoding: 'utf8', timeout: 10000 });
let result = run(['fixture', '--tools', 'read,bash']);
assert.equal(result.status, 0, result.stdout + result.stderr);
assert.deepEqual(readFileSync(join(root, 'trace'), 'utf8').trim().split('\n'), ['config', 'release', 'bootstrap', 'alignment']);
assert.deepEqual(JSON.parse(readFileSync(join(root, 'docker.json'))), {
args: ['compose', 'run', '--rm', 'mosaic-agent'], session: '/var/lib/mosaic/sessions/agent-fixture',
workspace: '/var/lib/mosaic/workspaces/fixture', tools: 'read,bash', name: 'fixture', interactive: '1'
});
const captured = readFileSync(join(root, 'docker.json'), 'utf8');
result = run(['fixture'], { MOSAIC_AGENTS_DIR: join(root, 'missing-seats') });
assert.equal(result.status, 4);
assert.match(result.stderr, /no seat definition/);
result = run(['fixture', '--auth', 'missing']);
assert.equal(result.status, 4);
assert.match(result.stderr, /no mosaic-managed credential/);
assert.equal(readFileSync(join(root, 'docker.json'), 'utf8'), captured);
assert.equal(existsSync(join(root, '.pi')), false);
} finally {
rmSync(root, { recursive: true, force: true });
}
});