// Composes the `--append-system-prompt` block injected into every spawned
// `claude --verbose --remote-control` PTY. Sentinel-wrapped sections in
// documented order:
//
//   <host> ... </host>                    — hostname, LAN IPv4, admin URL,
//                                            tunnel URL (or "none")
//   <identity> ... </identity>            — verbatim contents of
//                                            <accountDir>/agents/<role>/IDENTITY.md
//   <soul> ... </soul>                    — verbatim contents of
//                                            <accountDir>/agents/<role>/SOUL.md
//   <about-owner> ... </about-owner>      — operator-profile body produced
//                                            by `formatProfileSummary` in
//                                            neo4j-store.ts. Two prose lines:
//                                            the first names what is known
//                                            (or the literal token `NOTHING`),
//                                            the second carries the standing
//                                            MAXIMISE imperative. On caller
//                                            ok:false the body keeps the same
//                                            shape with the reason inline-coded
//                                            inside line one. Omitted entirely
//                                            when the caller supplies nothing
//                                            (e.g. role=public flows that do
//                                            not load an owner profile).
//   <dormant-plugins> ... </dormant-plugins>
//                                          — plugins installed on disk but
//                                            absent from `account.enabledPlugins`.
//                                            One line per plugin: name + PLUGIN.md
//                                            `description`. Sentinel is omitted
//                                            when the set is empty so the agent
//                                            does not nudge against an empty list.
//
// The block is the only doctrine surface that survives Claude Code SDK
// upgrades. Per the prompt-architecture doctrine, no skill carries a copy;
// one edit here changes the ceiling or workaround for every agent in every
// session.
//
// Per-spawn read (no cache) is the contract for IDENTITY.md / SOUL.md:
// sidebar edits land on the next spawn without a manager restart. The owner
// profile and dormant-plugins inputs are pre-computed by the caller (the UI
// process, which already holds Neo4j credentials and the plugin manifest
// walker) and passed through the HTTP /spawn body as resolved values; the
// manager never queries Neo4j itself, keeping the service to its three-dep
// budget (hono, @hono/node-server, node-pty).
//
// Two failure modes for the file reads:
//   host-unresolved      — host.lanIPv4 is null (boot resolution failed).
//   identity-unresolved  — IDENTITY.md or SOUL.md is missing, empty, or
//                          unreadable under <accountDir>/agents/<role>/.
// No silent fallback: the caller surfaces the literal path so the operator
// can grep `[pty-spawn]` for the failed file. Neo4j unreachable does NOT
// fail the spawn; the loud-fail surface is the prose unresolved body inside
// `<about-owner>` — same two-line shape as the happy path, reason token
// inline-coded so an operator can grep for it.
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { resolveAgentIdentityDir } from './agent-identity-locator.js';
function readIdentityFile(path) {
    let content;
    try {
        content = readFileSync(path, 'utf-8');
    }
    catch (err) {
        const code = err.code ?? 'EUNKNOWN';
        return { ok: false, detail: `${path}:${code}` };
    }
    if (content.length === 0) {
        return { ok: false, detail: `${path}:empty` };
    }
    return { ok: true, content };
}
function renderAboutOwner(block) {
    if (!block)
        return { body: null, bytes: 0 };
    // Task 182: the failure branch matches the success branch's prose register
    // — same two-line shape, NOTHING surfaced as the known state, and the
    // reason token inline-coded so an operator can still grep for it. Reasons
    // are short tokens (neo4j-unreachable, profile-not-found, missing-user-id);
    // the surrounding sentence stays principled, not prescriptive.
    const inner = block.ok
        ? block.body
        : `This is what you know about the operator: NOTHING (operator-data source unavailable: \`${block.reason}\`).\nYou must seek to MAXIMISE what you know about the operator to do your job effectively.`;
    return { body: inner, bytes: Buffer.byteLength(inner, 'utf8') };
}
function renderDormantPlugins(plugins) {
    if (!plugins || plugins.length === 0)
        return { body: null, bytes: 0 };
    const lines = plugins.map((p) => `- ${p.name}: ${p.description}`);
    const inner = lines.join('\n');
    return { body: inner, bytes: Buffer.byteLength(inner, 'utf8') };
}
function renderPluginManifest(plugins) {
    if (!plugins || plugins.length === 0)
        return { body: null, bytes: 0 };
    const parts = [];
    for (const p of plugins) {
        parts.push(`## ${p.name}`);
        parts.push(p.description);
        if (p.tools.length > 0) {
            parts.push('Tools:');
            for (const t of p.tools) {
                parts.push(t.description ? `- ${t.name}: ${t.description}` : `- ${t.name}`);
            }
        }
        if (p.skills.length > 0) {
            parts.push('Skills:');
            for (const s of p.skills) {
                parts.push(s.description ? `- ${s.name}: ${s.description}` : `- ${s.name}`);
            }
        }
        parts.push('');
    }
    const inner = parts.join('\n').replace(/\n+$/, '');
    return { body: inner, bytes: Buffer.byteLength(inner, 'utf8') };
}
function renderSpecialistDomains(domains) {
    if (!domains || domains.length === 0)
        return { body: null, bytes: 0 };
    const parts = [];
    for (const d of domains) {
        parts.push(`## ${d.name}`);
        parts.push(d.description);
        if (d.tools.length > 0) {
            parts.push('Tools:');
            for (const t of d.tools) {
                parts.push(t.description ? `- ${t.name}: ${t.description}` : `- ${t.name}`);
            }
        }
        parts.push('');
    }
    const inner = parts.join('\n').replace(/\n+$/, '');
    return { body: inner, bytes: Buffer.byteLength(inner, 'utf8') };
}
/** Pure renderer — no file IO. The block layout is the source of truth for
 *  both the production `composeAppendSystemPrompt` (which reads files first)
 *  and the boot-time self-test (which feeds synthetic content). */
function renderAppendBlock(host, identityContent, soulContent, 
// Task 618 — verbatim KNOWLEDGE.md for role=public, rendered as
// `<knowledge>` immediately after `<soul>`. null on admin (and the
// boot self-test / drift assertion), which suppresses the block.
knowledgeContent, extras) {
    const aboutOwner = renderAboutOwner(extras.aboutOwner);
    const dormantPlugins = renderDormantPlugins(extras.dormantPlugins);
    const pluginManifest = renderPluginManifest(extras.activePlugins);
    const specialistDomains = renderSpecialistDomains(extras.specialistDomains);
    const chatSessionBody = extras.chatSessionKey ? `session_key: ${extras.chatSessionKey}` : null;
    const chatSessionBytes = chatSessionBody ? Buffer.byteLength(chatSessionBody, 'utf8') : 0;
    const tunnelLine = host.tunnelUrl ? host.tunnelUrl : 'none';
    const sections = [
        '<host>',
        `hostname: ${host.hostname}`,
        `lan-ipv4: ${host.lanIPv4}`,
        `admin-url: ${host.adminUrl}`,
        `tunnel-url: ${tunnelLine}`,
        '</host>',
        '<identity>',
        identityContent,
        '</identity>',
        '<soul>',
        soulContent,
        '</soul>',
    ];
    const knowledgeBytes = knowledgeContent !== null ? Buffer.byteLength(knowledgeContent, 'utf8') : 0;
    if (knowledgeContent !== null) {
        sections.push('<knowledge>', knowledgeContent, '</knowledge>');
    }
    if (aboutOwner.body !== null) {
        sections.push('<about-owner>', aboutOwner.body, '</about-owner>');
    }
    if (specialistDomains.body !== null) {
        sections.push('<specialist-domains>', specialistDomains.body, '</specialist-domains>');
    }
    if (pluginManifest.body !== null) {
        sections.push('<plugin-manifest>', pluginManifest.body, '</plugin-manifest>');
    }
    if (dormantPlugins.body !== null) {
        sections.push('<dormant-plugins>', dormantPlugins.body, '</dormant-plugins>');
    }
    if (chatSessionBody !== null) {
        sections.push('<chat-session>', chatSessionBody, '</chat-session>');
    }
    return {
        block: sections.join('\n'),
        knowledgeBytes,
        aboutOwnerBytes: aboutOwner.bytes,
        dormantPluginsBytes: dormantPlugins.bytes,
        pluginManifestBytes: pluginManifest.bytes,
        specialistDomainsBytes: specialistDomains.bytes,
        chatSessionBytes,
    };
}
export function composeAppendSystemPrompt(host, sources, extras = {}) {
    if (!host.lanIPv4) {
        return { ok: false, reason: 'host-unresolved', detail: 'host.lanIPv4 is null' };
    }
    const slug = sources.role === 'public' ? sources.agentSlug : 'admin';
    const roleDir = resolveAgentIdentityDir(sources.accountDir, sources.role, slug);
    const identityPath = join(roleDir, 'IDENTITY.md');
    const soulPath = join(roleDir, 'SOUL.md');
    const identity = readIdentityFile(identityPath);
    if (!identity.ok) {
        return { ok: false, reason: 'identity-unresolved', detail: identity.detail };
    }
    const soul = readIdentityFile(soulPath);
    if (!soul.ok) {
        // Task 618 — a public spawn with an empty/missing SOUL is a dud that must
        // never serve; surface it as `soul-empty` so creation/Test-Greet names the
        // file to author. Admin SOUL failure stays `identity-unresolved`.
        return {
            ok: false,
            reason: sources.role === 'public' ? 'soul-empty' : 'identity-unresolved',
            detail: soul.detail,
        };
    }
    // Task 618 — KNOWLEDGE.md is the toolless public agent's only knowledge
    // source, inlined as `<knowledge>`. Strict like IDENTITY/SOUL: empty/missing
    // refuses the spawn rather than serving an ignorant agent. Admin reaches
    // knowledge via tools and gets no `<knowledge>` block.
    let knowledgeContent = null;
    if (sources.role === 'public') {
        const knowledge = readIdentityFile(join(roleDir, 'KNOWLEDGE.md'));
        if (!knowledge.ok) {
            return {
                ok: false,
                reason: knowledge.detail.endsWith(':empty') ? 'knowledge-empty' : 'knowledge-missing',
                detail: knowledge.detail,
            };
        }
        knowledgeContent = knowledge.content;
    }
    const rendered = renderAppendBlock(host, identity.content, soul.content, knowledgeContent, extras);
    return {
        ok: true,
        block: rendered.block,
        identityBytes: Buffer.byteLength(identity.content, 'utf8'),
        soulBytes: Buffer.byteLength(soul.content, 'utf8'),
        knowledgeBytes: rendered.knowledgeBytes,
        aboutOwnerBytes: rendered.aboutOwnerBytes,
        dormantPluginsBytes: rendered.dormantPluginsBytes,
        pluginManifestBytes: rendered.pluginManifestBytes,
        specialistDomainsBytes: rendered.specialistDomainsBytes,
        chatSessionBytes: rendered.chatSessionBytes,
    };
}
/** Forbidden-token classes the admin IDENTITY.md must not contain in prose.
 *  See Task 437. The check is scoped past the "## How you sound" section
 *  because the voice rules name forbidden classes (e.g. "MCP prefixes")
 *  without being instances of them. */
const FORBIDDEN_COUNTER_TOKENS = [
    'learningsCount',
    'preferencesCount',
    'graphWriteCount',
    'typedEdgesCount',
    'nodesProcessed',
];
const FORBIDDEN_MCP_TOKENS = [
    'memory-search',
    'memory-write',
    'memory-update',
    'memory-report-list',
    'memory-report-read-latest',
    'memory-compiled-truth-history',
    'memory-typed-edge-pass',
    'profile-read',
    'database-operator',
    'session-retrospective-mark-complete',
    'pre-turn-graph-pass-skip',
    'per-turn-graph-pass-gate',
    'web-search',
    'WebSearch',
    'WebFetch',
];
const FORBIDDEN_HOOK_NAMES = [
    'UserPromptSubmit',
    'PreToolUse',
    'PostToolUse',
    'SessionStart',
    'SessionEnd',
    'SubagentStop',
    'Notification',
];
function stripVoiceSection(content) {
    // Scope past the "## How you sound" section through end of file. Everything
    // from that heading onward is voice/output/access prose that legitimately
    // names forbidden classes and skill-load invocations (the plain-English and
    // operator-voice passes) without being instances of them; only the
    // behavioural prose above it is scanned.
    const lines = content.split('\n');
    const start = lines.findIndex((l) => l.trim() === '## How you sound');
    if (start === -1)
        return content;
    return lines.slice(0, start).join('\n');
}
export function runIdentityForbiddenTokenCheck(identityContent) {
    const scanned = stripVoiceSection(identityContent);
    const violations = [];
    const seen = new Set();
    const push = (cls, token) => {
        const key = cls + ':' + token;
        if (seen.has(key))
            return;
        seen.add(key);
        violations.push({ class: cls, token });
    };
    let m;
    const reBacktick = /`[^`\n]+`/g;
    while ((m = reBacktick.exec(scanned)) !== null)
        push('backtick-token', m[0]);
    const reLabel = /:[A-Z][A-Za-z][A-Za-z0-9]*/g;
    while ((m = reLabel.exec(scanned)) !== null)
        push('node-label', m[0]);
    const reTask = /\bTasks?\s+\d+\b/g;
    while ((m = reTask.exec(scanned)) !== null)
        push('task-number', m[0]);
    const reCodePath = /\b[A-Za-z0-9_./-]*\.(?:ts|tsx|js|jsx|mjs|cjs|sh|py|json|yaml|yml|md)\b/g;
    while ((m = reCodePath.exec(scanned)) !== null)
        push('code-path', m[0]);
    const reSentinel = /<[a-z][a-z0-9-]*>/g;
    while ((m = reSentinel.exec(scanned)) !== null)
        push('angle-sentinel', m[0]);
    for (const tok of FORBIDDEN_COUNTER_TOKENS) {
        if (new RegExp('\\b' + tok + '\\b').test(scanned))
            push('counter-token', tok);
    }
    for (const tok of FORBIDDEN_MCP_TOKENS) {
        if (new RegExp('\\b' + tok + '\\b').test(scanned))
            push('mcp-prefix', tok);
    }
    for (const tok of FORBIDDEN_HOOK_NAMES) {
        if (new RegExp('\\b' + tok + '\\b').test(scanned))
            push('hook-name', tok);
    }
    return violations.length === 0 ? { ok: true } : { ok: false, violations };
}
/** Boot-time IDENTITY-vs-composer drift assertion. Parses IDENTITY.md at
 *  `<accountDir>/agents/admin/IDENTITY.md`, extracts every `<sentinel-name>`
 *  reference the prose makes, and confirms the composer would emit each one
 *  for a sample admin session. The synthetic inputs exercise every optional
 *  sentinel so a missing emit is visible regardless of operator state.
 *
 *  Returns ok:false with the first sentinel IDENTITY.md names that the
 *  composer does not emit. The caller LOUD-FAILs the manager start. */
export function runIdentityDriftAssertion(identityContent) {
    // IDENTITY.md references sentinels as backticked `<name>` tokens in prose,
    // e.g. "`<plugin-manifest>` lists each plugin …". Extract the inner names.
    const referenced = new Set();
    const re = /`<([a-z][a-z0-9-]*)>`/gi;
    let m;
    while ((m = re.exec(identityContent)) !== null) {
        referenced.add(m[1]);
    }
    // Render with maximally-populated synthetic inputs.
    const host = {
        hostname: 'drift-check',
        lanIPv4: '127.0.0.1',
        adminUrl: 'http://drift-check:0',
        tunnelUrl: null,
    };
    const rendered = renderAppendBlock(host, '# drift identity', '# drift soul', null, {
        aboutOwner: { ok: true, body: 'drift owner' },
        dormantPlugins: [{ name: 'p', description: 'd' }],
        activePlugins: [{ name: 'p', description: 'd', tools: [{ name: 't' }], skills: [{ name: 's' }] }],
        specialistDomains: [{ name: 'sp', description: 'd', tools: [{ name: 't' }] }],
    });
    const checked = Array.from(referenced).sort();
    for (const name of checked) {
        if (!rendered.block.includes(`<${name}>`)) {
            return { ok: false, missing: name, sentinelsChecked: checked };
        }
    }
    return { ok: true, sentinelsChecked: checked };
}
/** Boot-time self-test: render the block with synthetic inputs that exercise
 *  every optional sentinel, and assert the expected tags appear. Returns
 *  ok:true on success, ok:false with the first missing tag on drift. The
 *  caller logs `[startup-self-test] system-prompt-sentinels=ok` or LOUD-FAILs
 *  so future refactors that drop a sentinel surface at manager start, not at
 *  runtime against a real spawn. */
export function runSystemPromptSelfTest() {
    const host = {
        hostname: 'self-test',
        lanIPv4: '127.0.0.1',
        adminUrl: 'http://self-test:0',
        tunnelUrl: null,
    };
    const rendered = renderAppendBlock(host, '# self-test identity', '# self-test soul', null, {
        aboutOwner: {
            ok: true,
            body: 'This is what you know about the operator: name self-test owner.\n' +
                'You must seek to MAXIMISE what you know about the operator to do your job effectively.',
        },
        dormantPlugins: [{ name: 'cloudflare', description: 'self-test plugin description' }],
        activePlugins: [
            {
                name: 'self-test-plugin',
                description: 'self-test plugin description',
                tools: [{ name: 'self-test-tool', description: 'self-test tool description' }],
                skills: [{ name: 'self-test-skill', description: 'self-test skill description' }],
            },
        ],
        specialistDomains: [
            {
                name: 'self-test-specialist',
                description: 'self-test specialist description',
                tools: [{ name: 'self-test-specialist-tool', description: 'self-test specialist tool description' }],
            },
        ],
    });
    const required = [
        '<host>',
        '</host>',
        '<identity>',
        '</identity>',
        '<soul>',
        '</soul>',
        '<about-owner>',
        '</about-owner>',
        '<plugin-manifest>',
        '</plugin-manifest>',
        '<specialist-domains>',
        '</specialist-domains>',
        '<dormant-plugins>',
        '</dormant-plugins>',
    ];
    for (const tag of required) {
        if (!rendered.block.includes(tag)) {
            return { ok: false, missing: tag };
        }
    }
    // Also exercise the failure branch — a refactor that drops the prose
    // unresolved body would still pass the happy-path render but strand the
    // operator-visible failure signal. Task 182 doctrine: the reason token
    // stays inline-coded so an operator can grep for it; the surrounding
    // sentence holds the MAXIMISE imperative regardless of resolve state.
    const unresolved = renderAppendBlock(host, '# i', '# s', null, {
        aboutOwner: { ok: false, reason: 'self-test-reason' },
    });
    if (!unresolved.block.includes('operator-data source unavailable: `self-test-reason`')) {
        return { ok: false, missing: 'unresolved reason token' };
    }
    if (!unresolved.block.includes('You must seek to MAXIMISE what you know about the operator')) {
        return { ok: false, missing: 'unresolved MAXIMISE imperative' };
    }
    return { ok: true };
}
//# sourceMappingURL=system-prompt.js.map