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:
Executable
+120
@@ -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
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user