SLOPSHOPPER

foundry

Palantir Foundry for Claude Code: Orthanc tools, an approval ladder for Foundry mutations, scoped Foundry context, one slash command per Foundry resource and a…

newpanebandguardcommandtoast
v0.4.0MITupdated 2026-10-14zaycruz/claude-foundry
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · foundry
│ ┃ Foundry context ✕ › fix the failing auth test and add an audit log call │ ┃ unknown │ ┃ ~259 tokens in the system prompt · approval ⏺ Read(src/auth.ts) │ ┃ auto · branch none ⎿ Read 6 lines │ ┃ Nothing selected. /open sets the stack, ⏺ Update(src/auth.ts) │ ┃ ontology and projects. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ [ Close ] ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /foundry │ ⎿ foundry: Foundry context is open (Esc closes it). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Foundry context
unknown ~259 tokens in the system prompt · approval auto · branch none Nothing selected. /open sets the stack, ontology and projects. [ Close ]
Pane · foundry-picker
Nothing to choose.
README

foundry: Palantir Foundry for Claude Code

A Claude Code mod that lets Claude work in Palantir Foundry through the Orthanc CLI, with the operator in control of every change. Orthanc is the only path into Foundry: when it can't do something, the mod says so instead of falling back to REST.

  • Two tools. orthanc_help discovers Orthanc's command surface; orthanc_cli runs a command in agent mode and returns its foundry-agent-v1 envelope. Output over 20,000 chars is cut for the model, with row names, counts and the page token kept above the cut and the whole envelope written to a file.
  • An approval ladder on every Foundry mutation, through the tools, Bash or a Palantir MCP server: ask-always, allow-edits, auto (a Haiku guard judges each mutation) and yolo. Destructive changes always ask; protected branches ask unless yolo; branch-scoped work needs an agent branch; calls naming the Compass root are denied; a call repeated three times is blocked.
  • A branch rule that no approval mode can turn off: agent work happens on branches, never on a production main. Ontology writes must pass --branch <global branch RID>; dataset, stream and build writes must name a work branch; code goes to a work branch through orthanc repository push and a pull request. Branchless writes, writes to main/master, proposal merges and approvals, release tags, default-branch pushes, raw git push to a Foundry repository and Foundry writes outside Orthanc are blocked. Orthanc writes run only through orthanc_cli: Bash may read through Orthanc but not write, however the shell wraps the call.
  • FDE mode (/fde on, or /fde <goal>): binds the session to one agent global branch. Ontology writes are pinned to it, each --apply needs a dry run of the same call first, and /fde end hands the branch back to the operator, still open.
  • Foundry context in the system prompt (stack, ontology, projects, types, resources), and in the mod's own subagents.
  • Slash commands for choosing that context, and /foundry, a live view of what is in context, like /context for Foundry.
  • Tracing to Langfuse or any OTLP/HTTP collector: one trace per turn that touched Foundry, with each tool call, the gate's decision and token usage, redacted before it leaves the machine.
  • Agents (foundry-scout, foundry-builder, foundry-reviewer, foundry-oracle) and skills for the Foundry delivery lifecycle.

Install

1. Orthanc (required)

The mod does all of its Foundry work through the orthanc binary. Install the latest release and set up a profile for your stack:

curl -fsSL https://github.com/zaycruz/orthanc/releases/latest/download/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
orthanc --version
orthanc onboarding        # skipped by the installer if a profile already exists

The mod runs orthanc from PATH (or ORTHANC_BIN). Add more stacks with orthanc configure profile create. Tokens stay in the macOS Keychain and never pass through Claude: /auth prints the command for you to run in your own terminal.

2. The mod

/plugin marketplace add zaycruz/claude-foundry
/plugin install foundry@claude-foundry

Or load a checkout for one session: claude --plugin-dir ./claude-foundry. Function hooks must be enabled (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1). /onboard checks that Orthanc, the profile and the credentials all work.

Commands

CommandDoes
/foundryThe Foundry resources in context: counts, items, clear buttons, prompt size
/openStack, then ontology, then projects
/onboard, /mcp-statusCheck orthanc, profiles, stack, credentials, context
/auth [profile]How to store credentials, and whether they work
/profile, /ontology, /projectChoose the stack, ontology, projects
/object-type, /action-typeOntology types in scope
/folder /dataset /media-set /repository /contour /source /developer-console /aip-chatbotCompass resources in scope
/media-item, /objects, /function, /language-model, /permission, /searchLookups added to context
`/scope [show\clear\ontology], /approval [mode\rules], /recap`Context, approval mode and guard rules, a two-paragraph recap
/fde on, /fde <goal>, /fde goal <text>, /fde --branch <rid> [goal], /fde, /fde resume, /fde endFDE mode: start on a new agent branch (with or without a goal) or adopt an open one, set the goal, show status, rebind after a restart, hand off
/traceTrace export status, and whether the last Foundry trace reached Langfuse

Every command has one name; there are no aliases. Every list command takes a filter, and clear. /link-type, /interface, /egress-policy, /certificate and /automation wait on Orthanc list commands and say so; the mod never falls back to Foundry REST.

FDE mode

/fde on puts the session in FDE mode: it creates agent/fde-<timestamp> as a global branch on the selected ontology and binds the session, subagents included, to it. /fde <goal> does the same with a goal, which names the branch (agent/fde-<goal>-<timestamp>) and heads the prompt and the handoff; /fde goal <text> sets or changes it later. /fde --branch <rid> [goal] adopts an open agent branch instead. While bound:

  • Ontology writes go to that branch. A write that needs a branch and names none gets it; a call that names any other global branch, another ontology, --profile or an abbreviated branch flag is refused. Dataset, stream and build writes keep the branch rule (a work branch, never main) but are not pinned to the global branch.
  • The agent cannot create, close or merge global branches, or touch proposals, while bound; global-branch add-repo runs on the bound branch only.
  • Plan, then apply: an --apply runs only after a clean dry run of the same call. The plan pins every file the call reads (--*-file values, @file, and any path-like argument that reads as a file) by SHA-256, checked again just before the call runs, and a file naming another global branch or ontology is refused. Each dry run licenses one apply: the apply takes it before running, so two identical applies in parallel cannot share it, and a refused apply, or one Orthanc rejects as a usage error, gives it back; any other failure keeps it spent, since Foundry may have changed. Directory arguments are not hashed, and a file over 20 MB is refused.
  • The goal and branch go into the system prompt and a band above the prompt, and every turn is traced (not only the ones that touch Foundry).

/fde end prints a handoff (branch, applied writes, how to close it) and leaves the branch open: merging or closing it is the operator's call. If a session ends while bound, the next one offers /fde resume. The mode only adds checks. The branch rule, the approval ladder and the destructive rule judge every bound call as before, in every approval mode.

Operator rules and auto mode

In auto, a Haiku guard judges each Foundry write. Give it your own rules in ~/.claude/foundry/rules.md, one per line. A project can add .claude/foundry-rules.md; since a cloned repository can carry that file, its rules may only make the guard stricter. Claude cannot edit either file, nor read ~/.claude/foundry/; /approval rules shows the rules. For example:

Approve writes in the Sandbox project on agent/ branches.
Ask before any change to the Person or Organization object types.

/approval rules shows what the guard reads. The rules steer the guard only: the branch rule and the destructive rule run first and cannot be loosened.

Claude Code's own auto-mode classifier judges tool calls before the mod sees them, and by default it can refuse Foundry writes as changes to shared resources. To let branch-scoped Foundry work run in auto mode, add an allow rule under autoMode.allow in ~/.claude/settings.json (keep the defaults from claude auto-mode defaults, since a custom list replaces them):

Foundry Branch Work: Palantir Foundry writes made through the foundry Claude Code mod, and the
mod's foundry-* subagents doing them, when every write targets an agent work branch (an agent/
global branch or its ri.branch. RID, an agent- dataset branch, a non-default code branch, or a
resource this session created). The mod denies writes on main/master, merges and
default-branch pushes itself. Does NOT cover writes on main, master or the production
ontology, merges or approvals, permission changes, or deleting resources the session did
not create.

Run claude auto-mode critique after editing.

Tracing

Each turn that touches Foundry (Orthanc tools, Orthanc in Bash, a gated call, a Palantir MCP tool) becomes one OTLP trace: the prompt and answer, a span per tool call with the gate's outcome and reason, and a generation span with the model and token usage. Only Foundry calls carry their input and output; other tools in the turn are named, not shown. Turns in one Claude Code session share a Langfuse session. Nothing is exported until you configure an endpoint, in the environment or in ~/.claude/foundry/otel.env. The mod reads that file once per session as KEY=value lines (it never runs it), and Claude can neither read nor edit it. A value can be a 1Password reference, resolved with op read, so the file holds no keys:

# Langfuse
LANGFUSE_BASE_URL=https://cloud.langfuse.com
LANGFUSE_PUBLIC_KEY="op://Vault/Langfuse/public_key"
LANGFUSE_SECRET_KEY="op://Vault/Langfuse/secret_key"
# or any OTLP/HTTP collector
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=https://collector.example.com/v1/traces
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Basic%20...

Prefer the file to exported variables: keys in the session's environment are visible to any shell command Claude runs. FOUNDRY_OTEL=off turns tracing off, FOUNDRY_OTEL=all exports every turn, FOUNDRY_OTEL_TOOL_IO=off drops tool input and output, and OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES set the resource. Before export, values under sensitive keys (tokens, RIDs, hosts, names), inside Orthanc envelopes too, are replaced, and tokens, credentials, RIDs, emails, phone numbers, private IPs and internal hostnames are redacted; add your own domains with FOUNDRY_OTEL_REDACT_DOMAINS=.... /trace shows the collector's host, never a header. Export is fire-and-forget and never changes what a tool call does.

Develop

claude plugin validate .
claude plugin test .

hooks/*.test.ts cover the ladder, parity, the context view and trace export. For an E2E run against your own stack, record real Orthanc output and replay it:

FOUNDRY_E2E_PROFILE=<profile> FOUNDRY_E2E_PROJECT=<project rid> \
FOUNDRY_E2E_ONTOLOGY=<ontology rid> ./scripts/record.sh

That writes hooks/fixtures.local.ts, which hooks/e2e.local.test.ts replays. Both stay out of git: they hold your stack's data.

License

MIT

Source 7 files
hooks/register.tsx 1595 lines
1// foundry: a governed Palantir Foundry lane as a Claude Code mod.
2// Orthanc tools, the approval ladder, Foundry context in the system prompt,
3// one slash command per Foundry resource, and /foundry, the visual view of what is in context.
4// Orthanc is the only path to Foundry: what it cannot do waits on an orthanc bead.
5
6import { atom, read, update } from 'claude-code'
7import type { Args, EngineInterface, EventResult, Next, Register } from 'claude-code'
8
9import type { FdeSession, FoundryPicker, FoundryPickerKind, FoundryPickItem, FoundryResource, FoundryResourceKind, FoundryScope } from '../types'
10import {
11  applyClear,
12  blockedText,
13  type ClearTarget,
14  COMMAND_TABLE,
15  type CommandEntry,
16  COMPASS_KINDS,
17  type CompassKind,
18  contextLines,
19  emptyScope,
20  foundryTree,
21  hasFocus,
22  mergeSelections,
23  normalizeScope,
24  OBJECT_QUERY_BEAD,
25  objectsQuery,
26  ontologyChanged,
27  RESOURCE_LABELS,
28  resourceArgs,
29  resourceRows,
30} from './catalog'
31import {
32  adoptable,
33  branchCreateArgs,
34  foreignRids,
35  goalLabel,
36  takePlan,
37  branchRidFrom,
38  contentHash,
39  fdeBind,
40  fdeBranchName,
41  fdeContext,
42  fdeHandoff,
43  fdeStatus,
44  fileCandidates,
45  hasApply,
46  parseFdeArgs,
47  planGate,
48  planSignature,
49  recordApply,
50  recordPlan,
51} from './fde'
52import {
53  type Envelope,
54  failure,
55  guideEnvelope,
56  isCredentialCommand,
57  isErrorEnvelope,
58  isOversized,
59  isUsageError,
60  listItems,
61  manifestFrom,
62  normalizeCommand,
63  parseEnvelope,
64  renderCommand,
65  renderListing,
66  rowsOf,
67  serialize,
68} from './orthanc'
69import {
70  APPROVAL_MODES,
71  type ApprovalMode,
72  type ManifestCommand,
73  classifyCall,
74  classifyOrthancCommand,
75  compassRootReason,
76  DEFAULT_APPROVAL_MODE,
77  type GateClassification,
78  type GuardVerdict,
79  FOUNDRY_GIT_PUSH_REASON,
80  gitPushDirectories,
81  gitPushNamesFoundry,
82  ladderDecision,
83  loopStep,
84  modeGuidance,
85  ORTHANC_CLI,
86  ORTHANC_HELP,
87  parseApprovalMode,
88  resolveOrthancCommand,
89  rulesWriteReason,
90} from './policy'
91import {
92  domainPatterns,
93  langfuseApiBase,
94  otlpBody,
95  parseEnvFile,
96  randomHex,
97  redactString,
98  resourceAttributes,
99  serializePayload,
100  type SpanRecord,
101  type TraceConfig,
102  type TraceEnv,
103  traceConfig,
104} from './telemetry'
105
106const SCOPE = { plugin: 'foundry', key: 'scope' } as const
107const PICKER = { plugin: 'foundry', key: 'picker' } as const
108const BRANCH = { plugin: 'foundry', key: 'workingBranch' } as const
109const FDE = { plugin: 'foundry', key: 'fde' } as const
110const PANE = 'foundry-picker'
111const CONTEXT_PANE = 'foundry-context'
112const pickerAtom = atom(PICKER, null)
113const scopeAtom = atom(SCOPE, emptyScope('unknown', ''))
114const fdeAtom = atom(FDE, null)
115const GUARD_MODEL = 'claude-haiku-5-5'
116const VISIBLE_ROWS = 12
117const TREE_ROWS = 8
118const ROOT_CONCURRENCY = 6
119const CLI_TIMEOUT_MS = 300_000
120
121let loop: { identity: string; streak: number } | null = null
122let manifestCache: readonly ManifestCommand[] | undefined
123const helpCache = new Map<string, string>()
124const refusedCommands: string[] = []
125
126const asString = (value: unknown) => (typeof value === 'string' ? value : '')
127const errorsOf = (envelope: Envelope) => JSON.stringify(envelope.errors).slice(0, 400)
128
129async function orthancBin($: EngineInterface): Promise<string> {
130  return (await $.env.get('ORTHANC_BIN'))?.trim() || 'orthanc'
131}
132
133// orthanc in agent mode, argv only, no shell; any failure comes back as an
134// envelope so callers handle one shape.
135async function runOrthanc(
136  $: EngineInterface,
137  argv: readonly string[],
138  profile?: string,
139  timeoutMs = CLI_TIMEOUT_MS,
140): Promise<Envelope> {
141  const full = ['--agent', '--non-interactive', ...(profile ? ['--profile', profile] : []), ...argv]
142  try {
143    const ran = await $.process.run([await orthancBin($), ...full], { timeoutMs })
144    return (
145      parseEnvelope(ran.stdout) ??
146      failure('orthanc-launch-error', `orthanc exited ${ran.exitCode} with no foundry-agent-v1 envelope`, argv.join(' '), {
147        exit_code: ran.exitCode,
148        stderr: ran.stderr.slice(0, 4000),
149      })
150    )
151  } catch (error) {
152    return failure('orthanc-launch-error', error instanceof Error ? error.message : String(error), argv.join(' '))
153  }
154}
155
156async function loadManifest($: EngineInterface): Promise<readonly ManifestCommand[]> {
157  if (manifestCache !== undefined) return manifestCache
158  const loaded = manifestFrom(await runOrthanc($, ['agent-manifest'], undefined, 30_000))
159  if (loaded.length === 0) throw new Error('orthanc agent-manifest returned no commands')
160  manifestCache = loaded
161  return loaded
162}
163
164async function helpText($: EngineInterface, path: string): Promise<string | null> {
165  const cached = helpCache.get(path)
166  if (cached !== undefined) return cached
167  try {
168    const ran = await $.process.run([await orthancBin($), ...path.split(' '), '--help'], { timeoutMs: 15_000 })
169    const text = `${ran.stdout}${ran.stderr}`.trim()
170    if (text === '') return null
171    helpCache.set(path, text)
172    return text
173  } catch {
174    return null
175  }
176}
177
178async function renderHelp($: EngineInterface, input: { group?: string; command?: string; query?: string }): Promise<string> {
179  const commands = await loadManifest($)
180  if (!input.command) return renderListing(commands, input)
181  const path = normalizeCommand(input.command)
182  const command = commands.find(entry => entry.path === path)
183  if (command === undefined) return `Unknown Orthanc command '${path}'. Use orthanc_help with query to search commands.`
184  return renderCommand(command, await helpText($, command.path))
185}
186
187async function executeCli($: EngineInterface, input: { command: string; args: string[]; profile?: string }): Promise<Envelope> {
188  const commands = await loadManifest($)
189  // The same resolution the guard classified, so what runs is what was judged.
190  const resolved = resolveOrthancCommand(input.command, input.args, commands)
191  const command = commands.find(entry => entry.path === resolved.path)
192  if (command === undefined) {
193    return failure('unknown-command', `Unknown Orthanc command '${input.command}'. Call orthanc_help first.`, input.command)
194  }
195  if (isCredentialCommand(command)) {
196    return failure(
197      'credential-command-denied',
198      `Orthanc command '${command.path}' requires credential input and is not available through orthanc_cli. The operator runs it in a terminal.`,
199      command.path,
200    )
201  }
202  const envelope = await runOrthanc($, [...command.path.split(' '), ...resolved.args], input.profile)
203  return guideEnvelope(envelope, command, isUsageError(envelope) ? await helpText($, command.path) : null)
204}
205
206async function storedScopes($: EngineInterface): Promise<Record<string, unknown>> {
207  const value = await $.store.get('scopes')
208  return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
209}
210
211async function saveScope($: EngineInterface, scope: FoundryScope): Promise<void> {
212  const next = { ...scope, updatedAt: new Date().toISOString() }
213  await $.state.set(SCOPE, next)
214  await $.store.set('scopes', { ...(await storedScopes($)), [next.profile]: next })
215}
216
217async function approvalMode($: EngineInterface): Promise<ApprovalMode> {
218  return parseApprovalMode(asString(await $.store.get('approvalMode'))) ?? DEFAULT_APPROVAL_MODE
219}
220
221type Profile = { name: string; host: string; isDefault: boolean; status: string }
222
223async function listProfiles($: EngineInterface): Promise<Profile[]> {
224  const envelope = await runOrthanc($, ['configure', 'list'], undefined, 30_000)
225  return listItems(envelope, 'profiles').map(entry => ({
226    name: asString(entry.name),
227    host: asString(entry.host),
228    isDefault: entry.is_default === true,
229    status: asString(entry.status),
230  }))
231}
232
233async function activateProfile($: EngineInterface, profile: Profile): Promise<FoundryScope> {
234  const next = normalizeScope((await storedScopes($))[profile.name], profile.name, profile.host)
235  await $.env.set('FOUNDRY_PROFILE', profile.name)
236  await $.store.set('defaultProfile', profile.name)
237  await $.state.set(BRANCH, null)
238  await saveScope($, { ...next, host: profile.host || next.host })
239  return next
240}
241
242async function startupScope($: EngineInterface): Promise<void> {
243  const profiles = await listProfiles($)
244  const wanted = (await $.env.get('FOUNDRY_PROFILE')) || asString(await $.store.get('defaultProfile'))
245  const chosen = profiles.find(profile => profile.name === wanted) ?? profiles.find(profile => profile.isDefault)
246  if (chosen === undefined) {
247    await $.state.set(SCOPE, emptyScope(wanted || 'unknown', ''))
248    return
249  }
250  const scope = normalizeScope((await storedScopes($))[chosen.name], chosen.name, chosen.host)
251  await $.env.set('FOUNDRY_PROFILE', chosen.name)
252  await $.state.set(SCOPE, { ...scope, host: chosen.host || scope.host })
253}
254
255async function currentScope($: EngineInterface): Promise<FoundryScope> {
256  return normalizeScope((await $.state.get(SCOPE)).value, 'unknown', '')
257}
258
259const profileArg = (scope: FoundryScope) => (scope.profile === 'unknown' ? undefined : scope.profile)
260
261function contextText(scope: FoundryScope, mode: ApprovalMode): string {
262  const lines = [
263    '# Palantir Foundry',
264    'Tools: `mcp__foundry__orthanc_help` (orthanc_help in the skills) discovers the Orthanc command surface; ' +
265      '`mcp__foundry__orthanc_cli` (orthanc_cli) runs Orthanc in agent mode. Prefer them over raw `orthanc` in Bash ' +
266      'and over any Palantir MCP server, which is a read-only fallback for documentation.',
267    `Foundry stack: ${scope.profile}${scope.host ? ` (${scope.host})` : ''}. The operator switches it with /profile; do not pass --profile.`,
268    ...contextLines(scope),
269  ]
270  if (hasFocus(scope)) {
271    lines.push(
272      'Start Foundry resource discovery from the selected ontology, projects and resources.',
273      'For a bounded change: inspect the target, make the smallest reversible change, then run focused verification.',
274      'Read documentation only for a concrete unknown, a tool error, or a high-risk irreversible operation.',
275    )
276  }
277  lines.push(modeGuidance(mode))
278  return lines.join('\n')
279}
280
281const RULES_LIMIT = 4000
282
283// Operator rules for the Haiku guard: ~/.claude/foundry/rules.md (the
284// operator's), then the project's .claude/foundry-rules.md, which a cloned
285// repository can carry and so may only make the guard stricter. They steer the
286// guard only; the branch rule and the destructive rule run before it.
287type OperatorRule = { path: string; text: string; isProject: boolean }
288// Read once per session: an edit made mid-session (by anyone) waits for the next.
289let rulesSnapshot: OperatorRule[] | undefined
290
291async function operatorRules($: EngineInterface): Promise<OperatorRule[]> {
292  if (rulesSnapshot !== undefined) return rulesSnapshot
293  const home = (await $.env.get('HOME'))?.trim()
294  const paths = [...(home ? [{ path: `${home}/.claude/foundry/rules.md`, isProject: false }] : []), { path: '.claude/foundry-rules.md', isProject: true }]
295  const found: { path: string; text: string; isProject: boolean }[] = []
296  for (const { path, isProject } of paths) {
297    const text = await $.fs.read(path).then(String).catch(() => '')
298    if (text.trim() !== '') found.push({ path, text: text.trim().slice(0, RULES_LIMIT), isProject })
299  }
300  rulesSnapshot = found
301  return found
302}
303
304// The auto tier's classifier: a Haiku call.
305// Anything but a clean JSON verdict is null, which the ladder turns into ask.
306// Project rules can come with a cloned repository, so they only tighten: the
307// guard runs with and without them and the stricter verdict stands.
308async function guardVerdict(
309  $: EngineInterface,
310  tool: string,
311  input: unknown,
312  classification: GateClassification,
313): Promise<GuardVerdict | null> {
314  const rules = await operatorRules($)
315  const operator = rules.filter(rule => !rule.isProject).map(rule => rule.text).join('\n\n')
316  const project = rules.filter(rule => rule.isProject).map(rule => rule.text).join('\n\n')
317  const base = await guardOnce($, tool, input, classification, operator, '')
318  if (project === '' || base === null || base.action === 'deny') return base
319  const withProject = await guardOnce($, tool, input, classification, operator, project)
320  if (withProject === null) return null
321  return GUARD_RANK[withProject.action] > GUARD_RANK[base.action] ? withProject : base
322}
323
324const GUARD_RANK = { approve: 0, ask: 1, deny: 2 } as const
325
326async function guardOnce(
327  $: EngineInterface,
328  tool: string,
329  input: unknown,
330  classification: GateClassification,
331  operator: string,
332  project: string,
333): Promise<GuardVerdict | null> {
334  const answer = await $.model.complete({
335    model: GUARD_MODEL,
336    maxTokens: 200,
337    timeoutMs: 15_000,
338    system:
339      'You review one Palantir Foundry mutation an agent wants to run. Answer only JSON: ' +
340      '{"action":"approve"|"ask"|"deny","reason":"<one sentence>"}. approve: routine, reversible, ' +
341      'scoped work on an agent branch. ask: unclear blast radius, shared or production resources, ' +
342      'permissions, schema or ontology changes. deny: clearly destructive or targeting main/master or production.' +
343      (operator === '' ? '' : `\nThe operator's rules, which take precedence over the defaults above:\n${operator}`) +
344      (project === ''
345        ? ''
346        : `\nThe project's rules, which may only make you more cautious:\n${project}`),
347    prompt: JSON.stringify({ tool, input, classification }).slice(0, 8000),
348  })
349  if (!answer.isAnswered) return null
350  try {
351    const parsed: unknown = JSON.parse(answer.text.replace(/^```(json)?|```$/g, '').trim())
352    if (typeof parsed !== 'object' || parsed === null) return null
353    const { action, reason } = parsed as Record<string, unknown>
354    if (action !== 'approve' && action !== 'ask' && action !== 'deny') return null
355    return { action, reason: `guard: ${asString(reason) || action}` }
356  } catch {
357    return null
358  }
359}
360
361// The session's directory; '/' where the host has none (the test kit).
362async function sessionCwd($: EngineInterface): Promise<string> {
363  try {
364    return await $.session.cwd()
365  } catch {
366    return '/'
367  }
368}
369
370async function homeDir($: EngineInterface): Promise<string> {
371  try {
372    return (await $.env.get('HOME'))?.trim() ?? ''
373  } catch {
374    return ''
375  }
376}
377
378// Tracing: one OTLP trace per turn that touched Foundry, sent to Langfuse or
379// any OTLP/HTTP collector. Config comes from the environment, else from an env
380// file (~/.claude/foundry/otel.env, which Claude cannot write) sourced in a
381// subprocess; keys and headers stay in the mod's memory and are never shown.
382// Tracing never changes a gate outcome and never blocks a turn.
383type Tracing = { config: TraceConfig; resource: Record<string, string>; redact: RegExp[]; everyTurn: boolean; source: string }
384type TurnTrace = { traceId: string; rootSpanId: string; startMs: number; prompt: string; spans: SpanRecord[]; touched: boolean; dropped: number }
385type GateNote = { outcome?: string; reason?: string; command?: string; foundry: boolean }
386let tracing: Tracing | null = null
387let literalNote = ''
388let tracingNote = 'off: no OTLP endpoint or Langfuse keys configured'
389let lastExport: { traceId: string; status: string; atMs: number } | null = null
390const turnTraces = new Map<string, TurnTrace>()
391const ENV_FILE_TIMEOUT_MS = 10_000
392const MAX_SPANS = 200
393const STALE_TURN_MS = 3_600_000
394
395// Read as data (parseEnvFile), never sourced. 1Password references resolve
396// through `op read` by argv, in-process; the values never reach the model.
397async function traceEnvFromFile($: EngineInterface, path: string): Promise<TraceEnv | null> {
398  const text = await $.fs.read(path).then(String).catch(() => null)
399  if (text === null) return null
400  const { env, refs, literalSecrets } = parseEnvFile(text)
401  if (literalSecrets.length > 0) literalNote = ` (ignored ${literalSecrets.join(', ')}: write them as op:// references)`
402  // Together, so a hung `op` costs one timeout, not one per reference.
403  await Promise.all(
404    Object.entries(refs).map(async ([key, ref]) => {
405      const ran = await $.process.run(['op', 'read', '--no-newline', ref], { timeoutMs: ENV_FILE_TIMEOUT_MS }).catch(() => null)
406      if (ran && ran.exitCode === 0 && ran.stdout.trim() !== '') env[key] = ran.stdout.trim()
407    }),
408  )
409  return env
410}
411
412async function loadTracing($: EngineInterface, profile: string | null): Promise<void> {
413  tracing = null
414  literalNote = ''
415  // $.env.get takes literal names, so the variables the mod reads stay listable.
416  const read = (value: string | undefined) => value || undefined
417  const env: TraceEnv = {
418    OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: read(await $.env.get('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')),
419    OTEL_EXPORTER_OTLP_ENDPOINT: read(await $.env.get('OTEL_EXPORTER_OTLP_ENDPOINT')),
420    OTEL_EXPORTER_OTLP_HEADERS: read(await $.env.get('OTEL_EXPORTER_OTLP_HEADERS')),
421    LANGFUSE_BASE_URL: read(await $.env.get('LANGFUSE_BASE_URL')),
422    LANGFUSE_HOST: read(await $.env.get('LANGFUSE_HOST')),
423    LANGFUSE_PUBLIC_KEY: read(await $.env.get('LANGFUSE_PUBLIC_KEY')),
424    LANGFUSE_SECRET_KEY: read(await $.env.get('LANGFUSE_SECRET_KEY')),
425    FOUNDRY_OTEL: read(await $.env.get('FOUNDRY_OTEL')),
426    FOUNDRY_OTEL_TOOL_IO: read(await $.env.get('FOUNDRY_OTEL_TOOL_IO')),
427    PALANTIR_AGENT_OTEL_TOOL_IO: read(await $.env.get('PALANTIR_AGENT_OTEL_TOOL_IO')),
428    OTEL_SERVICE_NAME: read(await $.env.get('OTEL_SERVICE_NAME')),
429    OTEL_RESOURCE_ATTRIBUTES: read(await $.env.get('OTEL_RESOURCE_ATTRIBUTES')),
430    FOUNDRY_OTEL_REDACT_DOMAINS: read(await $.env.get('FOUNDRY_OTEL_REDACT_DOMAINS')),
431  }
432  for (const key of Object.keys(env)) if (env[key] === undefined) delete env[key]
433  let source = 'environment'
434  if ((env.FOUNDRY_OTEL ?? '').toLowerCase() === 'off') {
435    tracingNote = 'off: FOUNDRY_OTEL=off'
436    return
437  }
438  if (traceConfig(env) === null) {
439    const home = await homeDir($)
440    // Only the operator's own file: a path from the environment could come with a project.
441    if (home === '') return
442    const path = `${home}/.claude/foundry/otel.env`
443    const fromFile = await traceEnvFromFile($, path).catch(() => null)
444    if (fromFile === null) return
445    Object.assign(env, fromFile)
446    source = '~/.claude/foundry/otel.env'
447  }
448  const config = traceConfig(env)
449  if (config === null) {
450    tracingNote = (env.FOUNDRY_OTEL ?? '').toLowerCase() === 'off' ? 'off: FOUNDRY_OTEL=off' : `off: ${source} has no OTLP endpoint or Langfuse keys (or a key reference did not resolve)`
451    return
452  }
453  tracing = {
454    config,
455    resource: resourceAttributes(env, profile),
456    redact: domainPatterns(env.FOUNDRY_OTEL_REDACT_DOMAINS),
457    everyTurn: (env.FOUNDRY_OTEL ?? '').toLowerCase() === 'all',
458    source,
459  }
460  $.ui.log(`foundry: tracing to ${config.host}`, { to: 'debug' })
461  tracingNote = `on: ${config.host} (from ${source}${config.toolIo ? '' : ', tool input and output off'}${tracing.everyTurn ? ', every turn' : ', Foundry turns'})`
462}
463
464function startTurn(key: string, prompt: string, nowMs: number): TurnTrace {
465  for (const [stale, turn] of turnTraces) if (stale !== 'main' && nowMs - turn.startMs > STALE_TURN_MS) turnTraces.delete(stale)
466  const turn = { traceId: randomHex(32), rootSpanId: randomHex(16), startMs: nowMs, prompt, spans: [], touched: false, dropped: 0 }
467  turnTraces.set(key, turn)
468  return turn
469}
470
471function recordTool(key: string, tool: string, input: unknown, ran: unknown, note: GateNote, startMs: number, endMs: number): void {
472  if (tracing === null) return
473  const turn = turnTraces.get(key) ?? startTurn(key, '', startMs)
474  turn.touched ||= note.foundry
475  if (turn.spans.length >= MAX_SPANS) {
476    turn.dropped += 1
477    return
478  }
479  const result = (ran ?? {}) as Record<string, unknown>
480  const denied = typeof result.deny === 'string' ? result.deny : undefined
481  const output = denied ?? result.text ?? result.result
482  // Only Foundry calls carry their input and output; other tools in the turn are named, not shown.
483  const io = tracing.config.toolIo && note.foundry
484  const attributes: SpanRecord['attributes'] = {
485    'langfuse.observation.type': 'tool',
486    'gen_ai.tool.name': tool,
487    ...(note.command ? { 'foundry.command': redactString(note.command, tracing.redact) } : {}),
488    ...(note.outcome ? { 'foundry.gate.outcome': note.outcome } : {}),
489    ...(note.reason ? { 'foundry.gate.reason': serializePayload(note.reason, tracing.redact) ?? '' } : {}),
490  }
491  const inputJson = io ? serializePayload(input, tracing.redact) : undefined
492  const outputJson = io ? serializePayload(output, tracing.redact) : undefined
493  if (inputJson) attributes['langfuse.observation.input'] = inputJson
494  if (outputJson) attributes['langfuse.observation.output'] = outputJson
495  turn.spans.push({
496    spanId: randomHex(16),
497    parentSpanId: turn.rootSpanId,
498    name: `tool ${tool}${note.command ? ` ${redactString(note.command, tracing.redact)}` : ''}`,
499    startMs,
500    endMs,
501    attributes,
502    ...(denied ? { error: `denied: ${denied}` } : result.isError === true ? { error: 'tool error' } : {}),
503  })
504}
505
506async function exportTurn($: EngineInterface, key: string, answer: string, usage: unknown, reason: string, endMs: number): Promise<void> {
507  const turn = turnTraces.get(key)
508  turnTraces.delete(key)
509  // In FDE mode every turn is part of the engagement, so every turn is exported.
510  const fde = await fdeState($).catch(() => null)
511  if (tracing === null || turn === undefined || !(turn.touched || tracing.everyTurn || fde !== null)) return
512  const sessionId = await $.session.id().catch(() => 'unknown')
513  const redact = tracing.redact
514  const root: SpanRecord = {
515    spanId: turn.rootSpanId,
516    name: key === 'main' ? 'claude-foundry turn' : 'claude-foundry subagent turn',
517    startMs: turn.startMs,
518    endMs,
519    attributes: {
520      'langfuse.observation.type': 'agent',
521      'langfuse.trace.name': key === 'main' ? 'claude-foundry turn' : 'claude-foundry subagent turn',
522      'langfuse.session.id': sessionId,
523      'session.id': sessionId,
524      'foundry.turn.reason': reason,
525      'foundry.tool.count': turn.spans.length,
526      ...(turn.dropped > 0 ? { 'foundry.spans.dropped': turn.dropped } : {}),
527      ...(key === 'main' ? {} : { 'foundry.agent.id': key }),
528      ...(fde ? { 'foundry.fde.branch': redactString(fde.branchRid, redact), 'foundry.fde.goal': redactString(fde.goal, redact) } : {}),
529    },
530    ...(reason === 'error' || reason === 'refusal' ? { error: reason } : {}),
531  }
532  const prompt = serializePayload(turn.prompt || undefined, redact)
533  const output = serializePayload(answer || undefined, redact)
534  if (prompt) root.attributes['langfuse.observation.input'] = prompt
535  if (output) root.attributes['langfuse.observation.output'] = output
536  const spans = [root, ...turn.spans]
537  const counted = usage as { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number; model?: string } | undefined
538  if (counted?.model) {
539    spans.push({
540      spanId: randomHex(16),
541      parentSpanId: turn.rootSpanId,
542      name: 'model',
543      startMs: turn.startMs,
544      endMs,
545      attributes: {
546        'langfuse.observation.type': 'generation',
547        'gen_ai.request.model': counted.model,
548        'langfuse.observation.model.name': counted.model,
549        'langfuse.observation.usage_details': JSON.stringify({
550          input: counted.input_tokens ?? 0,
551          output: counted.output_tokens ?? 0,
552          cache_read_input_tokens: counted.cache_read_input_tokens ?? 0,
553          cache_creation_input_tokens: counted.cache_creation_input_tokens ?? 0,
554        }),
555      },
556    })
557  }
558  const body = otlpBody(turn.traceId, tracing.resource, spans)
559  const sent = { traceId: turn.traceId, status: 'sending', atMs: endMs }
560  lastExport = sent
561  try {
562    const response = await $.http.fetch(tracing.config.url, { method: 'POST', headers: tracing.config.headers, body })
563    sent.status = `HTTP ${response.status}`
564  } catch (error) {
565    sent.status = `failed: ${redactString(error instanceof Error ? error.message : 'fetch error').slice(0, 120)}`
566  }
567}
568
569function touchesFoundry(tool: string, input: Record<string, unknown>, classified: boolean): boolean {
570  if (classified || tool === ORTHANC_CLI || tool === ORTHANC_HELP) return true
571  if (tool === 'Bash') return /\borthanc\b/.test(asString(input.command))
572  return /^mcp__.*(palantir|foundry)/i.test(tool)
573}
574
575async function traceText($: EngineInterface): Promise<string> {
576  const lines = [`Tracing ${tracingNote}${literalNote}.`]
577  if (tracing === null) {
578    lines.push('Set OTEL_EXPORTER_OTLP_TRACES_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS, or LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY, in the environment or in ~/.claude/foundry/otel.env (KEY=value lines; keys as op:// references; read once per session).')
579    return lines.join('\n')
580  }
581  if (lastExport === null) {
582    lines.push('No Foundry turn exported yet this session.')
583    return lines.join('\n')
584  }
585  lines.push(`Last trace ${lastExport.traceId}: ${lastExport.status}.`)
586  const api = langfuseApiBase(tracing.config)
587  if (api === null || !lastExport.status.startsWith('HTTP 2')) return lines.join('\n')
588  const headers = Object.fromEntries(Object.entries(tracing.config.headers).filter(([name]) => name.toLowerCase() !== 'content-type'))
589  const found = await $.http.fetch(`${api}/traces/${lastExport.traceId}`, { headers }).catch(() => null)
590  if (found?.ok) {
591    let count = '?'
592    try {
593      const parsed = JSON.parse(found.text) as { observations?: unknown[] }
594      count = String(parsed.observations?.length ?? '?')
595    } catch {}
596    lines.push(`Langfuse has it: ${count} observations.`)
597  } else {
598    lines.push(`Langfuse lookup: ${found ? `HTTP ${found.status}` : 'failed'} (ingestion can take a few seconds; run /trace again).`)
599  }
600  return lines.join('\n')
601}
602
603async function askOperator($: EngineInterface, reason: string): Promise<boolean> {
604  try {
605    const answer = await $.ui.ask(`Approve Foundry mutation? ${reason}`, {
606      header: 'Foundry',
607      options: ['Approve', 'Decline'],
608    })
609    return answer === 'Approve'
610  } catch {
611    return false
612  }
613}
614
615// `notRun` is a failure Orthanc refused before reaching Foundry (a usage error);
616// any other failure may have changed something.
617async function serveTool($: EngineInterface, tool: string, input: Record<string, unknown>): Promise<{ out: EventResult<'tool.call'>; failed: boolean; notRun: boolean }> {
618  if (tool === ORTHANC_HELP) {
619    return { failed: false, notRun: false, out: {
620      result: await renderHelp($, {
621        group: asString(input.group).trim() || undefined,
622        command: asString(input.command).trim() || undefined,
623        query: asString(input.query).trim() || undefined,
624      }),
625    } }
626  }
627  const scope = await currentScope($)
628  const envelope = await executeCli($, {
629    command: normalizeCommand(asString(input.command)),
630    args: Array.isArray(input.args) ? input.args.map(String) : [],
631    profile: profileArg(scope),
632  })
633  const failed = isErrorEnvelope(envelope)
634  return { failed, notRun: failed && isUsageError(envelope), out: { result: serialize(envelope, await spill($, input.command, envelope)), ...(failed ? { context: ['The Orthanc call returned errors; read them before retrying.'] } : {}) } }
635}
636
637// Writes an oversized envelope where the model can read it whole; undefined when
638// it fits inline or the write fails (the cut text still carries the page token).
639async function spill($: EngineInterface, command: unknown, envelope: Envelope): Promise<string | undefined> {
640  if (!isOversized(envelope)) return undefined
641  const base = ((await $.env.get('TMPDIR')) || '/tmp').replace(/\/$/, '')
642  const path = `${base}/foundry-orthanc/${Date.now()}-${asString(command).trim().replace(/\W+/g, '-') || 'output'}.json`
643  try {
644    await $.fs.write(path, JSON.stringify(envelope, null, 2))
645    return path
646  } catch {
647    return undefined
648  }
649}
650
651// ---------- /fde ----------
652
653async function fdeState($: EngineInterface): Promise<FdeSession | null> {
654  return (await $.state.get(FDE)).value ?? null
655}
656
657// The session's binding, mirrored to the store so the next session can offer /fde resume.
658async function setFde($: EngineInterface, fde: FdeSession | null): Promise<void> {
659  await $.state.set(FDE, fde)
660  await $.state.set(BRANCH, fde?.branchRid ?? null)
661  await $.store.set('fdeOpen', fde)
662}
663
664async function fdeFromEarlier($: EngineInterface): Promise<FdeSession | null> {
665  const stored = await $.store.get('fdeOpen').catch(() => null)
666  return typeof stored === 'object' && stored !== null && typeof (stored as FdeSession).branchRid === 'string' ? (stored as FdeSession) : null
667}
668
669/**
670 * The global branch's name when it is an OPEN agent/ branch of the ontology.
671 * Otherwise a reason, with `transient` set when the lookup failed rather than
672 * the branch being unfit, so a resume can keep the binding and try again.
673 */
674async function openBranch($: EngineInterface, rid: string, profile: string, ontologyRid: string): Promise<{ name: string } | { reason: string; transient: boolean }> {
675  const envelope = await runOrthanc($, ['global-branch', 'get', rid], profile, 60_000)
676  if (isErrorEnvelope(envelope)) return { reason: `global-branch get ${rid} failed: ${errorsOf(envelope)}`, transient: true }
677  const fit = adoptable(envelope.data, ontologyRid)
678  return typeof fit === 'string' ? { reason: fit, transient: false } : fit
679}
680
681// FDE mode: pin the call to the session's branch and hold each --apply to its
682// dry run. It only adds checks; the branch rule and the ladder still judge the
683// bound call. Returns the bound input and its plan, or why it is refused.
684const MAX_PINNED_FILE = 20_000_000
685type FdePlan = { signature: string; isApply: boolean; command: string; files: string[] }
686
687async function filesSignature(
688  $: EngineInterface,
689  fde: FdeSession,
690  command: string,
691  args: string[],
692  files: { required: string[]; optional: string[] },
693): Promise<string | { deny: string }> {
694  const hashes: Record<string, string> = {}
695  for (const file of [...files.required, ...files.optional]) {
696    // Only regular files are read, and only up to a size, so a device or FIFO
697    // argument cannot hang the gate.
698    const stat = await $.fs.stat(file).catch(() => null)
699    if (stat !== null && stat.kind === 'file' && stat.size > MAX_PINNED_FILE) {
700      return { deny: `FDE mode: ${file} is over ${MAX_PINNED_FILE / 1_000_000} MB, too large to check against its dry run, so the call was not run.` }
701    }
702    const text = stat?.kind === 'file' ? await $.fs.read(file).then(String).catch(() => null) : null
703    if (text === null && files.required.includes(file)) return { deny: `FDE mode: could not read ${file} to check it against its dry run, so the call was not run.` }
704    if (text === null) continue
705    const foreign = foreignRids(fde, text)
706    if (foreign) return { deny: `FDE mode: ${foreign}` }
707    hashes[file] = await contentHash(text)
708  }
709  return planSignature(command, args, hashes)
710}
711
712async function fdeGate(
713  $: EngineInterface,
714  fde: FdeSession,
715  input: Record<string, unknown>,
716  manifest: readonly ManifestCommand[],
717): Promise<{ deny: string } | { input: Record<string, unknown>; plan: FdePlan | null }> {
718  const resolved = resolveOrthancCommand(asString(input.command), Array.isArray(input.args) ? input.args.map(String) : [], manifest)
719  const isMutation = resolved.path !== '' && resolved.path !== 'repository clone' && classifyOrthancCommand(resolved.path, manifest) === 'mutation'
720  const bound = fdeBind(fde, resolved.path, resolved.args, (await currentScope($)).profile, isMutation)
721  if ('deny' in bound) return bound
722  const next = { ...input, command: resolved.path, args: bound.args }
723  if (!isMutation) return { input: next, plan: null }
724  const files = fileCandidates(bound.args, await sessionCwd($), await homeDir($))
725  const signature = await filesSignature($, fde, resolved.path, bound.args, files)
726  if (typeof signature !== 'string') return signature
727  const isApply = hasApply(bound.args)
728  const gated = planGate(fde, signature, isApply)
729  if (gated) return { deny: gated }
730  return { input: next, plan: { signature, isApply, command: resolved.path, files: [...files.required, ...files.optional] } }
731}
732
733async function fdeCommand($: EngineInterface, args: string): Promise<string> {
734  const parsed = parseFdeArgs(args)
735  const active = await fdeState($)
736  const earlier = active ? null : await fdeFromEarlier($)
737  if (parsed.kind === 'error') return parsed.reason
738  if (parsed.kind === 'status') {
739    if (active) return fdeStatus(active)
740    return [
741      'FDE mode is off. /fde on (or /fde <goal>) creates an agent global branch on the selected ontology and binds this session to it; /fde --branch <rid> [goal] adopts an open one.',
742      ...(earlier ? [`An earlier session left ${earlier.branchName} (${goalLabel(earlier.goal)}) bound: /fde resume rebinds it, /fde end hands it off.`] : []),
743    ].join('\n')
744  }
745  if (parsed.kind === 'end') {
746    const ending = active ?? earlier
747    if (ending === null) return 'FDE mode is off.'
748    await setFde($, null)
749    const history = await $.store.get('fdeHandoffs').catch(() => null)
750    await $.store.set('fdeHandoffs', [...(Array.isArray(history) ? history : []), { ...ending, endedAt: new Date().toISOString() }].slice(-10))
751    return fdeHandoff(ending)
752  }
753  if (parsed.kind === 'goal') {
754    if (!active) return 'FDE mode is off, so there is no goal to set. /fde on starts it.'
755    const next = { ...active, goal: parsed.goal }
756    await setFde($, next)
757    return `Goal set.\n${fdeStatus(next)}`
758  }
759  if (parsed.kind === 'resume') {
760    if (active) return fdeStatus(active)
761    if (earlier === null) return 'No FDE branch to resume. /fde on starts one.'
762    const open = await openBranch($, earlier.branchRid, earlier.profile, earlier.ontologyRid)
763    if ('reason' in open) {
764      if (open.transient) return `${open.reason} Not resumed; the earlier binding is kept, so /fde resume can try again.`
765      await $.store.set('fdeOpen', null)
766      return `${open.reason} Not resumed; the earlier binding is forgotten.`
767    }
768    await setFde($, earlier)
769    return `Resumed.\n${fdeStatus(earlier)}`
770  }
771  if (active) return `Already in FDE mode:\n${fdeStatus(active)}\n/fde end first.`
772  const scope = await currentScope($)
773  if (scope.profile === 'unknown') return 'No Foundry stack is active. Run /profile first.'
774  if (scope.ontology === null) return 'Choose the ontology first (/ontology): the branch is made on it.'
775  let branchRid: string
776  let branchName: string
777  if (parsed.kind === 'start') {
778    branchName = fdeBranchName(parsed.goal, new Date())
779    const envelope = await runOrthanc($, branchCreateArgs(branchName, scope.ontology.rid, parsed.goal), scope.profile, 120_000)
780    const rid = branchRidFrom(envelope)
781    if (rid === null) return `global-branch create ${branchName} failed, so FDE mode stays off: ${errorsOf(envelope)}`
782    branchRid = rid
783  } else {
784    const open = await openBranch($, parsed.rid, scope.profile, scope.ontology.rid)
785    if ('reason' in open) return `${open.reason} FDE mode stays off.`
786    branchRid = parsed.rid
787    branchName = open.name
788  }
789  const fde: FdeSession = {
790    goal: parsed.goal,
791    profile: scope.profile,
792    ontologyRid: scope.ontology.rid,
793    branchRid,
794    branchName,
795    startedAt: new Date().toISOString(),
796    plans: [],
797    applied: [],
798  }
799  await setFde($, fde)
800  return `${fdeStatus(fde)}\nOntology writes in this session now go to this branch only, and each --apply needs its dry run first.`
801}
802
803// ---------- pickers ----------
804
805type Source = { title: string; isMulti: boolean; items: FoundryPickItem[]; resourceKind?: FoundryResourceKind }
806type PickerOptions = { resourceKind?: FoundryResourceKind; chain?: FoundryPickerKind[] }
807
808const toItems = (resources: readonly FoundryResource[]): FoundryPickItem[] =>
809  resources.map(entry => ({ key: entry.rid, label: entry.name, hint: entry.hint }))
810
811async function inBatches<T, R>(values: readonly T[], size: number, run: (value: T) => Promise<R>): Promise<R[]> {
812  const out: R[] = []
813  for (let index = 0; index < values.length; index += size) out.push(...(await Promise.all(values.slice(index, index + size).map(run))))
814  return out
815}
816
817/** Compass resources of one kind under the selected projects, or every visible project. */
818async function compassSource($: EngineInterface, scope: FoundryScope, kind: CompassKind, query: string): Promise<Source | string> {
819  const def = COMPASS_KINDS[kind]
820  let roots = scope.projects.map(project => project.rid)
821  if (roots.length === 0) {
822    const projects = await runOrthanc($, ['project', 'list'], scope.profile, 120_000)
823    if (isErrorEnvelope(projects)) return `project list failed: ${errorsOf(projects)}`
824    roots = listItems(projects, 'projects').map(entry => asString(entry.rid)).filter(Boolean)
825  }
826  if (roots.length === 0) return 'No projects are visible on this stack. No selection was saved.'
827  const search = kind === 'folders' && !query.trim() ? 'ri.compass.main.folder.' : query
828  const envelopes = await inBatches(roots, ROOT_CONCURRENCY, root =>
829    runOrthanc($, resourceArgs(search, def.typeId, root), scope.profile, 180_000),
830  )
831  const failed = envelopes.filter(isErrorEnvelope)
832  if (failed.length === envelopes.length && failed[0]) return `resource listing failed: ${errorsOf(failed[0])}`
833  const rows = resourceRows(envelopes.flatMap(envelope => rowsOf(envelope, ['resources'])))
834  return {
835    title: `${def.label}${scope.projects.length > 0 ? ' in selected projects' : ''}`,
836    isMulti: true,
837    items: toItems(rows),
838    resourceKind: kind,
839  }
840}
841
842async function pickerSource(
843  $: EngineInterface,
844  kind: FoundryPickerKind,
845  scope: FoundryScope,
846  query: string,
847  resourceKind?: FoundryResourceKind,
848): Promise<Source | string> {
849  if (kind === 'profile') {
850    const profiles = await listProfiles($)
851    return {
852      title: 'Foundry stack',
853      isMulti: false,
854      items: profiles.map(profile => ({
855        key: profile.name,
856        label: profile.name,
857        hint: [profile.host, profile.status, profile.name === scope.profile ? 'active' : ''].filter(Boolean).join(' · '),
858      })),
859    }
860  }
861  if (scope.profile === 'unknown') return 'No Foundry stack is active. Run /profile first.'
862  if (kind === 'resource') {
863    if (resourceKind && resourceKind in COMPASS_KINDS) return compassSource($, scope, resourceKind as CompassKind, query)
864    return 'This resource kind has no listing; use its lookup command.'
865  }
866  if (kind === 'ontology') {
867    const envelope = await runOrthanc($, ['ontology', 'list'], scope.profile, 60_000)
868    if (isErrorEnvelope(envelope)) return `ontology list failed: ${errorsOf(envelope)}`
869    return {
870      title: 'Ontology',
871      isMulti: false,
872      items: listItems(envelope, 'data').map(entry => ({
873        key: asString(entry.rid),
874        label: asString(entry.display_name) || asString(entry.api_name),
875        hint: asString(entry.rid),
876      })),
877    }
878  }
879  if (kind === 'project') {
880    const envelope = await runOrthanc($, ['project', 'list'], scope.profile, 120_000)
881    if (isErrorEnvelope(envelope)) return `project list failed: ${errorsOf(envelope)}`
882    return {
883      title: 'Projects',
884      isMulti: true,
885      items: listItems(envelope, 'projects').map(entry => ({
886        key: asString(entry.rid),
887        label: asString(entry.display_name) || asString(entry.name),
888        hint: asString(entry.path),
889      })),
890    }
891  }
892  if (scope.ontology === null) return 'No ontology is selected. Run /ontology first.'
893  const isAction = kind === 'action-type'
894  const envelope = await runOrthanc(
895    $,
896    ['ontology', isAction ? 'action-type-list' : 'object-type-list', scope.ontology.rid],
897    scope.profile,
898    120_000,
899  )
900  if (isErrorEnvelope(envelope)) return `${isAction ? 'action' : 'object'}-type-list failed: ${errorsOf(envelope)}`
901  return {
902    title: isAction ? 'Action types' : 'Object types',
903    isMulti: true,
904    items: listItems(envelope, isAction ? 'action_types' : 'object_types').map(entry => ({
905      key: asString(entry.api_name),
906      label: asString(entry.display_name) || asString(entry.api_name),
907      hint: asString(entry.api_name),
908    })),
909  }
910}
911
912async function applyPick($: EngineInterface, picker: FoundryPicker, keys: readonly string[]): Promise<string> {
913  const scope = await currentScope($)
914  const chosen = picker.items.filter(item => keys.includes(item.key))
915  if (picker.kind === 'profile') {
916    const profile = (await listProfiles($)).find(entry => entry.name === chosen[0]?.key)
917    if (profile === undefined) return 'No stack selected.'
918    const next = await activateProfile($, profile)
919    return `Foundry stack: ${next.profile}${next.ontology ? `, ontology ${next.ontology.name}` : ''}.`
920  }
921  if (picker.kind === 'ontology') {
922    const pick = chosen[0]
923    if (pick === undefined) return 'No ontology selected.'
924    await saveScope($, ontologyChanged(scope, { rid: pick.key, name: pick.label }))
925    return `Ontology: ${pick.label}. Projects, types, functions and object instances were cleared.`
926  }
927  if (picker.kind === 'project') {
928    await saveScope($, { ...scope, projects: chosen.map(item => ({ rid: item.key, name: item.label })) })
929    return `Projects: ${chosen.map(item => item.label).join(', ') || 'none'}.`
930  }
931  if (picker.kind === 'resource' && picker.resourceKind) {
932    const kind = picker.resourceKind
933    const asResource = (item: FoundryPickItem): FoundryResource => ({ rid: item.key, name: item.label, hint: item.hint })
934    const merged = mergeSelections(scope.resources[kind] ?? [], chosen.map(asResource), picker.items.map(asResource))
935    await saveScope($, { ...scope, resources: { ...scope.resources, [kind]: merged } })
936    return `${RESOURCE_LABELS[kind]}: ${merged.map(entry => entry.name).join(', ') || 'none'}.`
937  }
938  const key = picker.kind === 'action-type' ? 'actionTypes' : 'objectTypes'
939  await saveScope($, { ...scope, [key]: chosen.map(item => ({ apiName: item.key, displayName: item.label })) })
940  return `${picker.title}: ${chosen.map(item => item.label).join(', ') || 'none'}.`
941}
942
943const matches = (item: FoundryPickItem, filter: string) => {
944  const haystack = `${item.label} ${item.hint} ${item.key}`.toLowerCase()
945  return filter.toLowerCase().split(/\s+/).filter(Boolean).every(term => haystack.includes(term))
946}
947
948function preselected(scope: FoundryScope, kind: FoundryPickerKind, resourceKind?: FoundryResourceKind): string[] {
949  if (kind === 'project') return scope.projects.map(project => project.rid)
950  if (kind === 'object-type') return scope.objectTypes.map(type => type.apiName)
951  if (kind === 'action-type') return scope.actionTypes.map(type => type.apiName)
952  if (kind === 'resource' && resourceKind) return (scope.resources[resourceKind] ?? []).map(entry => entry.rid)
953  return []
954}
955
956/** Opens the picker pane on `source`, or answers in text where no pane can be placed. */
957async function openPicker($: EngineInterface, kind: FoundryPickerKind, source: Source, query: string, chain: FoundryPickerKind[]): Promise<string> {
958  const scope = await currentScope($)
959  if (source.items.length === 0) return `No ${source.title.toLowerCase()} found. No selection was saved.`
960  const picker: FoundryPicker = {
961    kind,
962    ...(source.resourceKind ? { resourceKind: source.resourceKind } : {}),
963    title: source.title,
964    isMulti: source.isMulti,
965    items: source.items,
966    chosen: preselected(scope, kind, source.resourceKind),
967    filter: kind === 'resource' ? '' : query,
968    chain,
969  }
970  const filtered = source.items.filter(item => matches(item, picker.filter))
971  const only = filtered.length === 1 ? filtered[0] : undefined
972  if (query !== '' && only) return applyPick($, picker, [...new Set([...picker.chosen, only.key])])
973  await $.state.set(PICKER, picker)
974  const opened = await $.ui.open({ id: PANE, title: source.title, focus: true, closeOnEscape: true })
975  if (!opened.isPlaced) {
976    await $.state.set(PICKER, null)
977    const lines = filtered.slice(0, 20).map(item => `- ${item.label}  (${item.hint || item.key})`)
978    return [`${filtered.length} ${source.title.toLowerCase()}; widen the terminal for the picker, or narrow the command with a filter.`, ...lines].join('\n')
979  }
980  return `Choose ${source.title.toLowerCase()} in the picker (type to filter, Enter or a click selects, Esc cancels).`
981}
982
983async function runPicker($: EngineInterface, kind: FoundryPickerKind, args: string, options: PickerOptions = {}): Promise<string> {
984  const scope = await currentScope($)
985  const query = args.trim()
986  if (query === 'clear' && kind !== 'profile') {
987    if (kind === 'ontology') await saveScope($, ontologyChanged(scope, null))
988    if (kind === 'project') await saveScope($, { ...scope, projects: [] })
989    if (kind === 'object-type') await saveScope($, { ...scope, objectTypes: [] })
990    if (kind === 'action-type') await saveScope($, { ...scope, actionTypes: [] })
991    if (kind === 'resource' && options.resourceKind) {
992      await saveScope($, { ...scope, resources: { ...scope.resources, [options.resourceKind]: [] } })
993    }
994    return `Cleared ${options.resourceKind ? RESOURCE_LABELS[options.resourceKind].toLowerCase() : kind} selection.`
995  }
996  const source = await pickerSource($, kind, scope, query, options.resourceKind)
997  if (typeof source === 'string') return source
998  return openPicker($, kind, source, query, options.chain ?? [])
999}
1000
1001// ---------- lookups ----------
1002
1003async function lookupSource($: EngineInterface, entry: CommandEntry, args: string, scope: FoundryScope): Promise<Source | string> {
1004  if (entry.action.type !== 'lookup') return 'Not a lookup.'
1005  const { lookup, kind } = entry.action
1006  const query = args.trim()
1007  const usage = `Usage: /${entry.name} ${entry.argumentHint ?? ''}`.trim()
1008  const source = (title: string, rows: FoundryResource[]): Source => ({ title, isMulti: true, items: toItems(rows), resourceKind: kind })
1009  if (lookup === 'objects') {
1010    const parsed = objectsQuery(query)
1011    if (parsed === null) {
1012      return query
1013        ? `Free-text object search needs 'ontology object-search --query' in orthanc (bead ${OBJECT_QUERY_BEAD}). ` +
1014            `For now name the property: /${entry.name} <property>=<text>.`
1015        : usage
1016    }
1017    if (scope.ontology === null || scope.objectTypes.length === 0) return 'Choose an ontology and at least one object type first (/ontology, /object-type).'
1018    const where = JSON.stringify({ type: 'containsAllTerms', field: parsed.property, value: parsed.text })
1019    const ontology = scope.ontology
1020    const envelopes = await Promise.all(
1021      scope.objectTypes.map(type =>
1022        runOrthanc($, ['ontology', 'object-search', '--where-json', where, '--select', parsed.property, ontology.rid, type.apiName], scope.profile, 120_000).then(
1023          envelope => ({ type, envelope }),
1024        ),
1025      ),
1026    )
1027    const failed = envelopes.find(({ envelope }) => isErrorEnvelope(envelope))
1028    const rows = envelopes.flatMap(({ type, envelope }) =>
1029      rowsOf(envelope, ['data']).map(row => {
1030        const key = asString(row.__rid) || `${type.apiName}:${asString(row.__primaryKey) || String(row[parsed.property] ?? '')}`
1031        return { rid: key, name: String(row.__title ?? row[parsed.property] ?? key), hint: type.displayName }
1032      }),
1033    )
1034    if (rows.length === 0 && failed) return `object-search failed: ${errorsOf(failed.envelope)}`
1035    return source(`Object instances matching ${parsed.property}=${parsed.text}`, resourceRows(rows.map(row => ({ rid: row.rid, name: row.name, path: row.hint }))))
1036  }
1037  if (lookup === 'language-model') {
1038    const envelope = await runOrthanc($, ['language-models', 'list'], scope.profile, 60_000)
1039    if (isErrorEnvelope(envelope)) return `language-models list failed: ${errorsOf(envelope)}`
1040    return source('Language models', resourceRows(rowsOf(envelope, ['models', 'language_models', 'data']), ['provider', 'type']))
1041  }
1042  if (!query) return usage
1043  if (lookup === 'function') {
1044    const envelope = await runOrthanc($, ['functions', 'search', query, '--limit', '100'], scope.profile, 60_000)
1045    if (isErrorEnvelope(envelope)) return `functions search failed: ${errorsOf(envelope)}`
1046    return source(`Functions matching '${query}'`, resourceRows(rowsOf(envelope, ['results']), ['api_name', 'version', 'type']))
1047  }
1048  if (lookup === 'search') {
1049    const envelope = await runOrthanc($, ['search', query, '--limit', '100'], scope.profile, 60_000)
1050    if (isErrorEnvelope(envelope)) return `search failed: ${errorsOf(envelope)}`
1051    return source(`Resources matching '${query}'`, resourceRows(rowsOf(envelope, ['results'])))
1052  }
1053  if (lookup === 'permission') {
1054    const envelope = await runOrthanc($, ['resource-role', 'list', query], scope.profile, 60_000)
1055    if (isErrorEnvelope(envelope)) return `resource-role list failed: ${errorsOf(envelope)}`
1056    const rows = rowsOf(envelope, ['roles']).map(row => ({
1057      rid: `${query}#${asString(row.principal_id)}:${asString(row.role_name)}`,
1058      name: `${asString(row.principal_type).toLowerCase()} ${asString(row.principal_id)} → ${asString(row.role_name)}`,
1059      path: query,
1060    }))
1061    return source(`Role grants on ${query}`, resourceRows(rows))
1062  }
1063  // media-item: one item, added directly.
1064  const [setRid, ...rest] = query.split(/\s+/)
1065  const item = rest.join(' ')
1066  if (!setRid || !item) return usage
1067  const envelope = await runOrthanc(
1068    $,
1069    /^ri\./i.test(item) ? ['media-sets', 'get', setRid, item] : ['media-sets', 'get-by-path', setRid, item],
1070    scope.profile,
1071    60_000,
1072  )
1073  if (isErrorEnvelope(envelope)) return `media item lookup failed: ${errorsOf(envelope)}`
1074  const data = typeof envelope.data === 'object' && envelope.data !== null && !Array.isArray(envelope.data) ? (envelope.data as Record<string, unknown>) : {}
1075  const rid = asString(data.rid) || asString(data.media_item_rid) || asString(data.mediaItemRid) || (/^ri\./i.test(item) ? item : '')
1076  if (!rid) return 'The media item response did not include an RID. No selection was saved.'
1077  const selected = { rid, name: asString(data.path) || item, hint: setRid }
1078  const next = mergeSelections(scope.resources.mediaItems ?? [], [selected], [selected])
1079  await saveScope($, { ...scope, resources: { ...scope.resources, mediaItems: next } })
1080  return `Media items: ${next.map(entry => entry.name).join(', ')}.`
1081}
1082
1083async function runLookup($: EngineInterface, entry: CommandEntry, args: string): Promise<string> {
1084  if (entry.action.type !== 'lookup') return 'Not a lookup.'
1085  const kind = entry.action.kind
1086  if (args.trim() === 'clear') return runPicker($, 'resource', 'clear', { resourceKind: kind })
1087  // Parse-only answers (usage, waits-on-bead) come before the stack check.
1088  if (entry.action.lookup === 'objects' && objectsQuery(args) === null) return (await lookupSource($, entry, args, emptyScope('unknown', ''))) as string
1089  const scope = await currentScope($)
1090  if (scope.profile === 'unknown') return 'No Foundry stack is active. Run /profile first.'
1091  const source = await lookupSource($, entry, args, scope)
1092  if (typeof source === 'string') return source
1093  return openPicker($, 'resource', source, '', [])
1094}
1095
1096// ---------- status, onboarding, auth ----------
1097
1098type Check = { label: string; ok: boolean; detail: string; fix?: string }
1099
1100async function setupChecks($: EngineInterface): Promise<{ checks: Check[]; scope: FoundryScope }> {
1101  const checks: Check[] = []
1102  const version = await runOrthanc($, ['version'], undefined, 15_000)
1103  const versionText = JSON.stringify(version.data ?? '').replace(/[{}"]/g, '').slice(0, 120)
1104  checks.push({
1105    label: 'orthanc',
1106    ok: !isErrorEnvelope(version),
1107    detail: isErrorEnvelope(version) ? errorsOf(version) : `${await orthancBin($)} ${versionText}`,
1108    fix: 'install Orthanc in your terminal, then run /onboard again:\n  curl -fsSL https://github.com/zaycruz/orthanc/releases/latest/download/install.sh | sh\n  orthanc onboarding\n(or set ORTHANC_BIN to an orthanc you already have)',
1109  })
1110  const profiles = await listProfiles($)
1111  checks.push({ label: 'profiles', ok: profiles.length > 0, detail: profiles.map(profile => `${profile.name} (${profile.status || '?'})`).join(', ') || 'none', fix: '/auth <name> to create one.' })
1112  const scope = await currentScope($)
1113  const active = profiles.find(profile => profile.name === scope.profile)
1114  checks.push({ label: 'stack', ok: active !== undefined, detail: active ? `${active.name} · ${active.host}` : 'none active', fix: '/profile' })
1115  if (active) {
1116    const probe = await runOrthanc($, ['ontology', 'list'], active.name, 60_000)
1117    checks.push({
1118      label: 'credentials',
1119      ok: !isErrorEnvelope(probe),
1120      detail: isErrorEnvelope(probe) ? errorsOf(probe) : 'ontology list works',
1121      fix: `/auth ${active.name}`,
1122    })
1123  }
1124  checks.push({ label: 'context', ok: hasFocus(scope), detail: hasFocus(scope) ? 'ontology or projects selected' : 'nothing selected', fix: '/open' })
1125  return { checks, scope }
1126}
1127
1128const renderChecks = (checks: readonly Check[]) =>
1129  checks.map(check => `${check.ok ? '✓' : '✗'} ${check.label}: ${check.detail}${check.ok || !check.fix ? '' : `  → ${check.fix}`}`).join('\n')
1130
1131async function authText($: EngineInterface, args: string): Promise<string> {
1132  const scope = await currentScope($)
1133  const target = args.trim() || scope.profile
1134  const profile = (await listProfiles($)).find(entry => entry.name === target)
1135  const host = profile?.host || '<https://your-stack.palantirfoundry.com>'
1136  const lines = [
1137    `Credentials never pass through Claude. Run one of these in your own terminal:`,
1138    profile
1139      ? `  orthanc auth store --profile ${target} --host ${host}    # paste the token on stdin`
1140      : `  orthanc configure profile create    # new profile '${target}': host and token`,
1141    `Then run /onboard (or /profile ${target}) here to verify.`,
1142  ]
1143  if (profile) {
1144    const probe = await runOrthanc($, ['ontology', 'list'], target, 60_000)
1145    lines.unshift(isErrorEnvelope(probe) ? `${target}: credentials do not work (${errorsOf(probe).slice(0, 160)}).` : `${target}: credentials already work.`)
1146  }
1147  return lines.join('\n')
1148}
1149
1150// ---------- /foundry ----------
1151
1152async function treeText($: EngineInterface): Promise<string> {
1153  const scope = await currentScope($)
1154  const nodes = foundryTree(scope)
1155  const lines = [`Foundry context · ${scope.profile}${scope.host ? ` (${scope.host})` : ''} · ~${Math.ceil(contextText(scope, await approvalMode($)).length / 4)} tokens`]
1156  if (nodes.length === 0) lines.push('  nothing selected: /open sets the stack, ontology and projects')
1157  nodes.forEach((node, index) => {
1158    const last = index === nodes.length - 1
1159    lines.push(`${last ? '└─' : '├─'} ${node.label}${node.detail ? `: ${node.detail}` : ` (${node.count})`}`)
1160    node.children.slice(0, TREE_ROWS).forEach((child, childIndex) => {
1161      lines.push(`${last ? '   ' : '│  '}${childIndex === node.children.length - 1 ? '└─' : '├─'} ${child.label}  ${child.detail}`)
1162    })
1163    if (node.children.length > TREE_ROWS) lines.push(`${last ? '   ' : '│  '}   … ${node.children.length - TREE_ROWS} more`)
1164  })
1165  return lines.join('\n')
1166}
1167
1168async function clearFromTree($: EngineInterface, target: ClearTarget): Promise<void> {
1169  await saveScope($, applyClear(await currentScope($), target))
1170}
1171
1172// ---------- registration ----------
1173
1174async function runEntry($: EngineInterface, entry: CommandEntry, args: string): Promise<string> {
1175  const action = entry.action
1176  switch (action.type) {
1177    case 'picker':
1178      return runPicker($, action.kind, args)
1179    case 'resource':
1180      return runPicker($, 'resource', args, { resourceKind: action.kind })
1181    case 'blocked':
1182      return blockedText(action.what, action.bead)
1183    case 'lookup':
1184      return runLookup($, entry, args)
1185    case 'open': {
1186      const scope = await currentScope($)
1187      if (scope.profile === 'unknown') return runPicker($, 'profile', '', { chain: ['ontology', 'project'] })
1188      return runPicker($, 'ontology', '', { chain: ['project'] })
1189    }
1190    case 'onboard': {
1191      const { checks } = await setupChecks($)
1192      const failing = checks.filter(check => !check.ok)
1193      return `${renderChecks(checks)}\n${failing.length === 0 ? 'Setup is complete.' : `Next: ${failing[0]?.fix ?? ''}`}`
1194    }
1195    case 'auth':
1196      return authText($, args)
1197    case 'mcp-status': {
1198      const { checks } = await setupChecks($)
1199      const manifest = await loadManifest($).catch(() => [])
1200      return [
hooks/catalog.ts 359 lines
1// Pure: the slash-command table (one name per resource), scope shape and edits,
2// resource rows, the prompt block and the /foundry context tree.
3// Everything that runs orthanc stays in register.tsx, where `$` lives.
4
5import type {
6  FoundryNamed,
7  FoundryObjectType,
8  FoundryPickerKind,
9  FoundryResource,
10  FoundryResourceKind,
11  FoundryScope,
12} from '../types'
13import { isRecord } from './orthanc'
14
15/** Compass resource kinds orthanc lists with `resource list|search --type`. */
16export const COMPASS_KINDS = {
17  folders: { label: 'Folders', typeId: 'COMPASS_FOLDER' },
18  datasets: { label: 'Datasets', typeId: 'FOUNDRY_DATASET' },
19  mediaSets: { label: 'Media sets', typeId: 'MIO_MEDIA_SET' },
20  repositories: { label: 'Repositories', typeId: 'STEMMA_REPOSITORY' },
21  contourAnalyses: { label: 'Contour analyses', typeId: 'CONTOUR_ANALYSIS' },
22  sources: { label: 'Sources', typeId: 'MAGRITTE_SOURCE' },
23  developerConsoles: { label: 'Developer Console', typeId: 'THIRD_PARTY_APPLICATIONS_APPLICATION' },
24  aipChatbots: { label: 'AIP chatbots', typeId: 'AIP_AGENTS_AGENT' },
25} as const
26export type CompassKind = keyof typeof COMPASS_KINDS
27
28export const RESOURCE_LABELS: Record<FoundryResourceKind, string> = {
29  ...Object.fromEntries(Object.entries(COMPASS_KINDS).map(([kind, def]) => [kind, def.label])),
30  mediaItems: 'Media items',
31  objectInstances: 'Object instances',
32  functions: 'Functions',
33  languageModels: 'Language models',
34  permissions: 'Permissions',
35  searchResults: 'Search results',
36} as Record<FoundryResourceKind, string>
37
38/** Resource kinds that belong to the ontology and go when it changes. */
39const ONTOLOGY_KINDS: readonly FoundryResourceKind[] = ['objectInstances', 'functions']
40
41export type Lookup = 'media-item' | 'objects' | 'function' | 'language-model' | 'permission' | 'search'
42
43export type CommandAction =
44  | { type: 'picker'; kind: Exclude<FoundryPickerKind, 'resource'> }
45  | { type: 'resource'; kind: CompassKind }
46  | { type: 'lookup'; lookup: Lookup; kind: FoundryResourceKind }
47  | { type: 'blocked'; bead: string; what: string }
48  | { type: 'open' | 'onboard' | 'auth' | 'scope' | 'approval' | 'mcp-status' | 'foundry' | 'trace' | 'fde' }
49
50export type CommandEntry = { name: string; description: string; argumentHint?: string; action: CommandAction }
51
52const CLEARABLE = '[filter | clear]'
53const LINK_BEAD = 'orthanc-ra6'
54const LISTING_BEAD = 'orthanc-qm5'
55export const OBJECT_QUERY_BEAD = 'orthanc-4f7'
56
57const picker = (name: string, kind: Exclude<FoundryPickerKind, 'resource'>, description: string): CommandEntry => ({
58  name,
59  description,
60  argumentHint: kind === 'profile' ? '[name]' : CLEARABLE,
61  action: { type: 'picker', kind },
62})
63const resource = (name: string, kind: CompassKind): CommandEntry => ({
64  name,
65  description: `Choose ${COMPASS_KINDS[kind].label.toLowerCase()} in scope`,
66  argumentHint: CLEARABLE,
67  action: { type: 'resource', kind },
68})
69const blocked = (name: string, what: string, bead: string): CommandEntry =>
70  ({ name, description: `Choose ${what} (waits on orthanc ${bead})`, action: { type: 'blocked', bead, what } })
71
72export const COMMAND_TABLE: readonly CommandEntry[] = [
73  { name: 'foundry', description: 'Show the Foundry resources in context (like /context, for Palantir)', action: { type: 'foundry' } },
74  { name: 'open', description: 'Set working context: stack, then ontology and projects', action: { type: 'open' } },
75  { name: 'onboard', description: 'Check Foundry setup: orthanc, stack, credentials, context', action: { type: 'onboard' } },
76  { name: 'auth', description: 'Show how to authenticate a Foundry stack', argumentHint: '[profile]', action: { type: 'auth' } },
77  picker('profile', 'profile', 'Choose the Foundry stack (orthanc profile)'),
78  picker('ontology', 'ontology', 'Choose the active Foundry ontology'),
79  picker('project', 'project', 'Choose the Foundry projects in scope'),
80  picker('object-type', 'object-type', 'Choose the ontology object types in scope'),
81  picker('action-type', 'action-type', 'Choose the ontology action types in scope'),
82  blocked('link-type', 'ontology link types', LINK_BEAD),
83  blocked('interface', 'ontology interfaces', LINK_BEAD),
84  resource('folder', 'folders'),
85  resource('dataset', 'datasets'),
86  resource('media-set', 'mediaSets'),
87  resource('repository', 'repositories'),
88  resource('contour', 'contourAnalyses'),
89  resource('source', 'sources'),
90  resource('developer-console', 'developerConsoles'),
91  resource('aip-chatbot', 'aipChatbots'),
92  blocked('egress-policy', 'egress policies', LISTING_BEAD),
93  blocked('certificate', 'certificates', LISTING_BEAD),
94  blocked('automation', 'automations', LISTING_BEAD),
95  {
96    name: 'media-item',
97    description: 'Add a media item by media-set RID and item path or RID',
98    argumentHint: '<media-set-rid> <path|rid> | clear',
99    action: { type: 'lookup', lookup: 'media-item', kind: 'mediaItems' },
100  },
101  {
102    name: 'objects',
103    description: 'Search object instances of the selected object types',
104    argumentHint: '<property>=<text> | clear',
105    action: { type: 'lookup', lookup: 'objects', kind: 'objectInstances' },
106  },
107  {
108    name: 'function',
109    description: 'Search Foundry functions and add them to context',
110    argumentHint: '<query> | clear',
111    action: { type: 'lookup', lookup: 'function', kind: 'functions' },
112  },
113  {
114    name: 'language-model',
115    description: 'Choose the stack language models in context',
116    argumentHint: CLEARABLE,
117    action: { type: 'lookup', lookup: 'language-model', kind: 'languageModels' },
118  },
119  {
120    name: 'permission',
121    description: 'Inspect role grants on a resource and add them to context',
122    argumentHint: '<resource-rid> | clear',
123    action: { type: 'lookup', lookup: 'permission', kind: 'permissions' },
124  },
125  {
126    name: 'search',
127    description: 'Search Foundry resource titles across the stack',
128    argumentHint: '<text> | clear',
129    action: { type: 'lookup', lookup: 'search', kind: 'searchResults' },
130  },
131  { name: 'scope', description: 'Show the Foundry context; `clear` resets it, `ontology` re-picks it', argumentHint: '[show | clear | ontology]', action: { type: 'scope' } },
132  { name: 'approval', description: 'Show or set the Foundry approval mode, or show the guard rules', argumentHint: '[mode|rules]', action: { type: 'approval' } },
133  {
134    name: 'fde',
135    description: 'FDE mode: bind this session to one agent branch; writes plan before they apply',
136    argumentHint: 'on | <goal> | goal <text> | --branch <rid> [goal] | resume | end',
137    action: { type: 'fde' },
138  },
139  { name: 'trace', description: 'Show trace export status (Langfuse/OTLP) and check the last Foundry trace landed', action: { type: 'trace' } },
140  { name: 'mcp-status', description: 'Show Orthanc and Foundry stack status', action: { type: 'mcp-status' } },
141]
142
143export const blockedText = (what: string, bead: string) =>
144  `Orthanc cannot list ${what} yet, so this command is waiting on bead ${bead} ` +
145  `in the orthanc tracker. It does not fall back to Foundry REST.`
146
147// ---------- scope ----------
148
149const asString = (value: unknown) => (typeof value === 'string' ? value : '')
150
151const named = (value: unknown): FoundryNamed | null =>
152  isRecord(value) && typeof value.rid === 'string' ? { rid: value.rid, name: asString(value.name) || value.rid } : null
153
154const types = (value: unknown): FoundryObjectType[] =>
155  Array.isArray(value)
156    ? value.filter(isRecord).flatMap(entry =>
157        typeof entry.apiName === 'string'
158          ? [{ apiName: entry.apiName, displayName: asString(entry.displayName) || entry.apiName, ...(typeof entry.rid === 'string' ? { rid: entry.rid } : {}) }]
159          : [],
160      )
161    : []
162
163const resourcesOf = (value: unknown): FoundryResource[] =>
164  Array.isArray(value)
165    ? value.filter(isRecord).flatMap(entry =>
166        typeof entry.rid === 'string' ? [{ rid: entry.rid, name: asString(entry.name) || entry.rid, hint: asString(entry.hint) }] : [],
167      )
168    : []
169
170export const emptyScope = (profile: string, host: string): FoundryScope => ({
171  profile,
172  host,
173  ontology: null,
174  projects: [],
175  objectTypes: [],
176  actionTypes: [],
177  resources: {},
178  updatedAt: new Date().toISOString(),
179})
180
181/** Any stored value (v0.1 scopes included) as a whole FoundryScope. */
182export function normalizeScope(value: unknown, profile: string, host: string): FoundryScope {
183  if (!isRecord(value)) return emptyScope(profile, host)
184  const resources: FoundryScope['resources'] = {}
185  if (isRecord(value.resources)) {
186    for (const kind of Object.keys(RESOURCE_LABELS) as FoundryResourceKind[]) {
187      const list = resourcesOf(value.resources[kind])
188      if (list.length > 0) resources[kind] = list
189    }
190  }
191  return {
192    profile: asString(value.profile) || profile,
193    host: asString(value.host) || host,
194    ontology: named(value.ontology),
195    projects: Array.isArray(value.projects) ? value.projects.map(named).filter((entry): entry is FoundryNamed => entry !== null) : [],
196    objectTypes: types(value.objectTypes),
197    actionTypes: types(value.actionTypes),
198    resources,
199    updatedAt: asString(value.updatedAt) || new Date().toISOString(),
200  }
201}
202
203export function ontologyChanged(scope: FoundryScope, ontology: FoundryNamed | null): FoundryScope {
204  const resources = { ...scope.resources }
205  for (const kind of ONTOLOGY_KINDS) resources[kind] = []
206  return { ...scope, ontology, projects: [], objectTypes: [], actionTypes: [], resources }
207}
208
209// ---------- orthanc rows ----------
210
211export function resourceArgs(query: string, typeId: string, root: string): string[] {
212  const trimmed = query.trim()
213  return [...(trimmed ? ['resource', 'search', trimmed] : ['resource', 'list']), '--type', typeId, '--folder-rid', root]
214}
215
216/** orthanc rows as resources: RID required, first RID wins. */
217export function resourceRows(rows: readonly Record<string, unknown>[], hintKeys: readonly string[] = ['path', 'type']): FoundryResource[] {
218  const seen = new Set<string>()
219  return rows.flatMap(row => {
220    const rid = asString(row.rid) || asString(row.__rid) || asString(row.api_name) || asString(row.apiName)
221    if (!rid || seen.has(rid)) return []
222    seen.add(rid)
223    const name =
224      asString(row.display_name) || asString(row.displayName) || asString(row.name) || asString(row.__title) || asString(row.api_name) || rid
225    const hint = hintKeys.map(key => row[key]).map(value => (isRecord(value) ? asString(value.name) : asString(value))).find(Boolean) ?? ''
226    return [{ rid, name, hint }]
227  })
228}
229
230/** The new selection, plus earlier selections the listing did not show. */
231export function mergeSelections(
232  existing: readonly FoundryResource[],
233  selected: readonly FoundryResource[],
234  visible: readonly FoundryResource[],
235): FoundryResource[] {
236  const shown = new Set(visible.map(entry => entry.rid))
237  return [...existing.filter(entry => !shown.has(entry.rid)), ...selected]
238}
239
240export function objectsQuery(args: string): { property: string; text: string } | null {
241  const match = /^\s*([A-Za-z_][\w.]*)\s*=\s*(.+?)\s*$/.exec(args)
242  return match?.[1] && match[2] ? { property: match[1], text: match[2] } : null
243}
244
245// ---------- prompt block ----------
246
247const list = (entries: readonly FoundryResource[]) => entries.map(entry => `${entry.name} (${entry.rid})`).join(', ')
248
249export function contextLines(scope: FoundryScope): string[] {
250  const lines: string[] = []
251  if (scope.ontology) lines.push(`Ontology: ${scope.ontology.name} (${scope.ontology.rid}).`)
252  if (scope.projects.length > 0) {
253    lines.push(`Projects: ${scope.projects.map(project => `${project.name} (${project.rid})`).join(', ')}.`)
254    lines.push('Keep project discovery within the selected project subtrees.')
255  }
256  if (scope.objectTypes.length > 0) {
257    lines.push(`Object types: ${scope.objectTypes.map(type => `${type.displayName} (${type.apiName})`).join(', ')}.`)
258  }
259  if (scope.actionTypes.length > 0) {
260    lines.push(`Action types: ${scope.actionTypes.map(type => `${type.displayName} (${type.apiName})`).join(', ')}.`)
261  }
262  for (const [kind, label] of Object.entries(RESOURCE_LABELS) as [FoundryResourceKind, string][]) {
263    const entries = scope.resources[kind] ?? []
264    if (entries.length > 0) lines.push(`${label}: ${list(entries)}.`)
265  }
266  return lines
267}
268
269export const hasFocus = (scope: FoundryScope) =>
270  scope.ontology !== null || scope.projects.length > 0 || Object.values(scope.resources).some(entries => (entries?.length ?? 0) > 0)
271
272// ---------- /foundry tree ----------
273
274export type ClearTarget =
275  | { type: 'ontology' }
276  | { type: 'projects' | 'objectTypes' | 'actionTypes' }
277  | { type: 'resource'; kind: FoundryResourceKind }
278  | { type: 'item'; section: 'projects' | 'objectTypes' | 'actionTypes'; key: string }
279  | { type: 'resource-item'; kind: FoundryResourceKind; rid: string }
280
281export type TreeNode = { id: string; label: string; detail: string; count: number; command: string; clear?: ClearTarget; children: TreeNode[] }
282
283const leaf = (id: string, label: string, detail: string, clear: ClearTarget): TreeNode => ({ id, label, detail, count: 0, command: '', clear, children: [] })
284
285const COMMAND_FOR: Record<FoundryResourceKind, string> = {
286  folders: '/folder',
287  datasets: '/dataset',
288  mediaSets: '/media-set',
289  mediaItems: '/media-item',
290  repositories: '/repository',
291  contourAnalyses: '/contour',
292  sources: '/source',
293  developerConsoles: '/developer-console',
294  aipChatbots: '/aip-chatbot',
295  objectInstances: '/objects',
296  functions: '/function',
297  languageModels: '/language-model',
298  permissions: '/permission',
299  searchResults: '/search',
300}
301
302/** The sections that hold something, top-level, in reading order. */
303export function foundryTree(scope: FoundryScope): TreeNode[] {
304  const nodes: TreeNode[] = []
305  if (scope.ontology) {
306    nodes.push({ id: 'ontology', label: 'Ontology', detail: scope.ontology.name, count: 1, command: '/ontology', clear: { type: 'ontology' }, children: [] })
307  }
308  const typed = (id: 'projects' | 'objectTypes' | 'actionTypes', label: string, command: string, entries: { key: string; label: string; detail: string }[]) => {
309    if (entries.length === 0) return
310    nodes.push({
311      id,
312      label,
313      detail: '',
314      count: entries.length,
315      command,
316      clear: { type: id },
317      children: entries.map(entry => leaf(`${id}:${entry.key}`, entry.label, entry.detail, { type: 'item', section: id, key: entry.key })),
318    })
319  }
320  typed('projects', 'Projects', '/project', scope.projects.map(project => ({ key: project.rid, label: project.name, detail: project.rid })))
321  typed('objectTypes', 'Object types', '/object-type', scope.objectTypes.map(type => ({ key: type.apiName, label: type.displayName, detail: type.apiName })))
322  typed('actionTypes', 'Action types', '/action-type', scope.actionTypes.map(type => ({ key: type.apiName, label: type.displayName, detail: type.apiName })))
323  for (const [kind, label] of Object.entries(RESOURCE_LABELS) as [FoundryResourceKind, string][]) {
324    const entries = scope.resources[kind] ?? []
325    if (entries.length === 0) continue
326    nodes.push({
327      id: `resource:${kind}`,
328      label,
329      detail: '',
330      count: entries.length,
331      command: COMMAND_FOR[kind],
332      clear: { type: 'resource', kind },
333      children: entries.map(entry => leaf(`${kind}:${entry.rid}`, entry.name, entry.hint || entry.rid, { type: 'resource-item', kind, rid: entry.rid })),
334    })
335  }
336  return nodes
337}
338
339export function applyClear(scope: FoundryScope, target: ClearTarget): FoundryScope {
340  switch (target.type) {
341    case 'ontology':
342      return ontologyChanged(scope, null)
343    case 'projects':
344    case 'objectTypes':
345    case 'actionTypes':
346      return { ...scope, [target.type]: [] }
347    case 'resource':
348      return { ...scope, resources: { ...scope.resources, [target.kind]: [] } }
349    case 'resource-item':
350      return {
351        ...scope,
352        resources: { ...scope.resources, [target.kind]: (scope.resources[target.kind] ?? []).filter(entry => entry.rid !== target.rid) },
353      }
354    case 'item':
355      if (target.section === 'projects') return { ...scope, projects: scope.projects.filter(entry => entry.rid !== target.key) }
356      return { ...scope, [target.section]: scope[target.section].filter(entry => entry.apiName !== target.key) }
357  }
358}
359
hooks/fde.ts 277 lines
1// FDE mode (/fde): one session, one goal, one agent global branch. Pure
2// functions, shared by the hooks and the tests. The mode only adds checks: the
3// branch rule, the ladder and the destructive rule run as they do without it.
4//
5// While bound:
6//  - every ontology write is pinned to the session's global branch (injected
7//    when the call names none, refused when it names another);
8//  - no global-branch or proposal changes (but add-repo on the bound branch):
9//    closing or merging is the operator's;
10//  - --apply runs only after a clean dry run of the same call, with every file
11//    it reads pinned by SHA-256; each dry run licenses one apply.
12
13import type { FdeSession } from '../types'
14import { BRANCH_FLAGS, flagValues, requiresBranch, unquote, validateBranchName } from './policy'
15
16const PREFIX = 'agent/fde-'
17const NAME_MAX = 64
18const GOAL_MAX = 200
19const RECORD_MAX = 50
20const GLOBAL_BRANCH = /^ri\.branch\.\.branch\.[A-Za-z0-9-]+$/
21const GLOBAL_BRANCH_ANY = /ri\.branch\.\.branch\.[A-Za-z0-9-]+/g
22const ONTOLOGY_RID = /ri\.ontology\.main\.ontology\.[A-Za-z0-9-]+/g
23// Flags that could be read as a branch flag by a lenient parser: a --br… prefix, or -b inside a bundle.
24const AMBIGUOUS_BRANCH_FLAG = /^(--br(?!anch(=|$))[a-z-]*(=.*)?|-[a-z]*b[a-z]+|-[a-z]+b)$/i
25const PROFILE_FLAG = /^--profile(=|$)/
26// While bound, only this branch command runs, and only on the bound branch.
27const GLOBAL_BRANCH_ALLOWED = new Set(['global-branch add-repo'])
28
29/** One line, no control characters, at most 200 characters. */
30const cleanGoal = (text: string) =>
31  text
32    .replace(/[\u0000-\u001f\u007f]+/g, ' ')
33    .replace(/\s+/g, ' ')
34    .trim()
35    .slice(0, GOAL_MAX)
36
37export type FdeArgs =
38  | { kind: 'status' }
39  | { kind: 'end' }
40  | { kind: 'resume' }
41  | { kind: 'start'; goal: string }
42  | { kind: 'adopt'; rid: string; goal: string }
43  | { kind: 'goal'; goal: string }
44  | { kind: 'error'; reason: string }
45
46/** The goal as shown; a session started with /fde on has none until /fde goal sets one. */
47export const goalLabel = (goal: string) => (goal === '' ? 'no goal set' : goal)
48
49export function parseFdeArgs(text: string): FdeArgs {
50  const trimmed = text.trim()
51  if (trimmed === '' || trimmed === 'status') return { kind: 'status' }
52  if (trimmed === 'end') return { kind: 'end' }
53  if (trimmed === 'resume') return { kind: 'resume' }
54  if (trimmed.toLowerCase() === 'on') return { kind: 'start', goal: '' }
55  const setGoal = /^goal(?:\s+([\s\S]*))?$/.exec(trimmed)
56  if (setGoal) {
57    const goal = cleanGoal(setGoal[1] ?? '')
58    return goal === '' ? { kind: 'error', reason: 'Name the goal: /fde goal <text>.' } : { kind: 'goal', goal }
59  }
60  const adopt = /^--branch(?:=|\s+)(\S+)\s*([\s\S]*)$/.exec(trimmed)
61  if (adopt) {
62    const rid = adopt[1]!
63    const goal = cleanGoal(adopt[2] ?? '')
64    if (!GLOBAL_BRANCH.test(rid)) return { kind: 'error', reason: `'${rid}' is not a global branch RID (ri.branch..branch.…).` }
65    return { kind: 'adopt', rid, goal }
66  }
67  return { kind: 'start', goal: cleanGoal(trimmed) }
68}
69
70const stamp = (now: Date) => now.toISOString().replace(/\D/g, '').slice(0, 12)
71
72/** agent/fde-<goal slug>-<yyyymmddhhmm>, at most 64 characters, valid for global-branch create. */
73export function fdeBranchName(goal: string, now: Date): string {
74  const suffix = `-${stamp(now)}`
75  const room = NAME_MAX - PREFIX.length - suffix.length
76  let slug = goal
77    .normalize('NFKD')
78    .toLowerCase()
79    .replace(/[^a-z0-9]+/g, '-')
80    .replace(/^-+|-+$/g, '')
81  if (slug.length > room) slug = slug.slice(0, room).replace(/-[^-]*$/, '') || slug.slice(0, room)
82  slug = slug.replace(/-+$/, '')
83  const name = slug === '' ? `${PREFIX}${stamp(now)}` : `${PREFIX}${slug}${suffix}`
84  return validateBranchName('global-branch create', name).ok ? name : `${PREFIX}work${suffix}`
85}
86
87export function branchCreateArgs(name: string, ontologyRid: string, goal: string): string[] {
88  return ['global-branch', 'create', name, '--ontology-rid', ontologyRid, '--description', (goal === '' ? 'claude-foundry FDE session' : `claude-foundry FDE: ${goal}`).slice(0, GOAL_MAX), '--apply']
89}
90
91/** The global branch RID in a clean orthanc envelope; null on errors or none. */
92export function branchRidFrom(envelope: { data?: unknown; errors?: unknown }): string | null {
93  if (Array.isArray(envelope.errors) && envelope.errors.length > 0) return null
94  const found = /ri\.branch\.\.branch\.[A-Za-z0-9-]+/.exec(JSON.stringify(envelope.data ?? null))
95  return found ? found[0] : null
96}
97
98export const hasApply = (args: readonly string[]) => args.some(arg => /^--apply(=.*)?$/.test(unquote(arg)))
99
100const FILE_FLAG = /^--(?:[a-z0-9-]+-)?file$/
101const absolute = (path: string, cwd: string, home: string) => {
102  const expanded = home !== '' ? path.replace(/^~(?=\/|$)/, home) : path
103  return expanded.startsWith('/') ? expanded : `${cwd.replace(/\/$/, '')}/${expanded}`
104}
105// Any word that could name a file: not a flag, RID, URL or number. A word that
106// is not a file simply does not read, and is left out of the plan.
107const looksLikePath = (value: string) =>
108  value !== '' && !value.startsWith('-') && !/^ri\./.test(value) && !/^[a-z]+:\/\//i.test(value) && !/^[0-9.:]+$/.test(value) && !/[\n{}]/.test(value)
109
110/** Files a call may read, absolute against `cwd`: `required` (values of --file
111 * and --*-file flags, and @name references) must be readable; `optional`
112 * (every other word that could be a path) is hashed when it reads. Directories
113 * do not read, so their contents are not pinned. */
114export function fileCandidates(args: readonly string[], cwd: string, home = ''): { required: string[]; optional: string[] } {
115  const required: string[] = []
116  const optional: string[] = []
117  for (let index = 0; index < args.length; index += 1) {
118    const arg = unquote(args[index] ?? '')
119    if (FILE_FLAG.test(arg) && args[index + 1] !== undefined) {
120      required.push(absolute(unquote(args[index + 1]!).replace(/^@/, ''), cwd, home))
121      index += 1
122      continue
123    }
124    const joined = /^(--[a-z0-9-]+)=(.+)$/i.exec(arg)
125    const raw = joined ? unquote(joined[2]!) : arg
126    const value = raw.replace(/^@/, '')
127    if ((joined && FILE_FLAG.test(joined[1]!)) || (raw.startsWith('@') && value !== '')) required.push(absolute(value, cwd, home))
128    else if (looksLikePath(value)) optional.push(absolute(value, cwd, home))
129  }
130  return { required, optional }
131}
132
133/** SHA-256 of the text, with its length: what a plan pins a file to. */
134export async function contentHash(text: string): Promise<string> {
135  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
136  return `${text.length}:${Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('')}`
137}
138
139/** One call, --apply removed, with the hash of every file it reads. */
140export function planSignature(path: string, args: readonly string[], fileHashes: Record<string, string>): string {
141  const kept = args.filter(arg => !/^--apply(=.*)?$/.test(unquote(arg))).map(unquote)
142  const files = Object.keys(fileHashes)
143    .sort()
144    .map(file => [file, fileHashes[file]])
145  return JSON.stringify([path, kept, files])
146}
147
148export function planGate(fde: FdeSession, signature: string, isApply: boolean): string | null {
149  if (!isApply || fde.plans.includes(signature)) return null
150  return (
151    'FDE mode: --apply runs only after a clean dry run of the same call. Run it without --apply first, ' +
152    'read the plan, then apply with identical arguments. Each dry run licenses one apply, and editing a file the call reads voids it.'
153  )
154}
155
156export const recordPlan = (fde: FdeSession, signature: string): FdeSession =>
157  fde.plans.includes(signature) ? fde : { ...fde, plans: [...fde.plans, signature].slice(-RECORD_MAX) }
158
159/** Takes the plan for one apply, so a second identical apply (even in parallel) finds it gone. */
160export function takePlan(fde: FdeSession, signature: string): { fde: FdeSession; taken: boolean } {
161  if (!fde.plans.includes(signature)) return { fde, taken: false }
162  return { fde: { ...fde, plans: fde.plans.filter(plan => plan !== signature) }, taken: true }
163}
164
165/** Why a file the call reads is refused: it names another global branch or ontology. */
166export function foreignRids(fde: FdeSession, text: string): string | null {
167  const branches = new Set(text.match(GLOBAL_BRANCH_ANY) ?? [])
168  branches.delete(fde.branchRid)
169  const ontologies = new Set(text.match(ONTOLOGY_RID) ?? [])
170  ontologies.delete(fde.ontologyRid)
171  const found = [...branches, ...ontologies]
172  return found.length === 0 ? null : `a file this call reads names ${found.join(', ')}; the session works on ${fde.branchRid} in ${fde.ontologyRid} only.`
173}
174
175/** An apply consumes its dry run: the next apply needs a new one. */
176export const recordApply = (fde: FdeSession, command: string, signature: string, now: Date): FdeSession => ({
177  ...fde,
178  plans: fde.plans.filter(plan => plan !== signature),
179  applied: [...fde.applied, { command, at: now.toISOString() }].slice(-RECORD_MAX),
180})
181
182/** Binds one orthanc_cli call to the session: the (possibly rewritten) args, or why it is refused. */
183export function fdeBind(
184  fde: FdeSession,
185  path: string,
186  args: readonly string[],
187  profile: string,
188  isMutation: boolean,
189): { args: string[] } | { deny: string } {
190  const tag = `FDE mode (${fde.branchName})`
191  const words = args.map(unquote)
192  // The stack is the session's: no call may switch it, read or write.
193  if (words.some(word => PROFILE_FLAG.test(word))) return { deny: `${tag}: --profile is not allowed while bound; the session runs on stack '${fde.profile}'.` }
194  if (!isMutation) return { args: [...args] }
195  if (profile !== fde.profile) {
196    return { deny: `${tag}: the session's branch is on stack '${fde.profile}', but the active stack is '${profile}'. Switch back with /profile ${fde.profile}, or /fde end.` }
197  }
198  if (path === 'global-branch create') {
199    return { deny: `${tag}: this session works on ${fde.branchRid} only. Do not create another global branch; the operator ends the mode with /fde end.` }
200  }
201  if ((path.startsWith('global-branch ') && !GLOBAL_BRANCH_ALLOWED.has(path)) || path.startsWith('proposal ')) {
202    return { deny: `${tag}: '${path}' changes branches or proposals, which is the operator's decision at handoff. Report that the work is ready instead.` }
203  }
204  const foreign = new Set(words.flatMap(word => word.match(GLOBAL_BRANCH_ANY) ?? []))
205  foreign.delete(fde.branchRid)
206  if (foreign.size > 0) return { deny: `${tag}: this call names ${[...foreign].join(', ')}; the session works on ${fde.branchRid} only.` }
207  if (path.startsWith('global-branch ') && !words.includes(fde.branchRid)) {
208    return { deny: `${tag}: '${path}' must name the session's branch ${fde.branchRid}.` }
209  }
210  const ontologies = new Set(words.flatMap(word => word.match(ONTOLOGY_RID) ?? []))
211  ontologies.delete(fde.ontologyRid)
212  if (ontologies.size > 0) {
213    return { deny: `${tag}: the branch belongs to ontology ${fde.ontologyRid}; this call names ${[...ontologies].join(', ')}.` }
214  }
215  if (!path.startsWith('ontology ')) return { args: [...args] }
216  if (words.some(word => AMBIGUOUS_BRANCH_FLAG.test(word))) {
217    return { deny: `${tag}: spell the branch flag as --branch (or leave it out and the mode supplies it).` }
218  }
219  const branches = flagValues(args, BRANCH_FLAGS)
220  if (branches.length === 0) return { args: requiresBranch(path) ? [...args, '--branch', fde.branchRid] : [...args] }
221  if (branches.length === 1 && branches[0] === fde.branchRid) return { args: [...args] }
222  const named = branches.filter(Boolean).join(', ') || '(no value)'
223  return { deny: `${tag}: ontology writes go to ${fde.branchRid}; this call names --branch ${named}. Drop --branch and the mode supplies it.` }
224}
225
226/** The branch's name when `data` (global-branch get) is an OPEN agent/ branch of `ontologyRid`; why not otherwise. */
227export function adoptable(data: unknown, ontologyRid: string): { name: string } | string {
228  const record = (data as { branch_record?: Record<string, unknown> } | null)?.branch_record
229  if (typeof record !== 'object' || record === null) return 'global-branch get returned no branch record.'
230  const name = typeof record.display_name === 'string' ? cleanGoal(record.display_name) : ''
231  if (record.branch_status !== 'OPEN') return `The branch is ${String(record.branch_status ?? 'unknown')}, not OPEN.`
232  if (record.ontology_rid !== ontologyRid) return `The branch belongs to ontology ${String(record.ontology_rid ?? 'unknown')}, not the selected ${ontologyRid}.`
233  if (!name.startsWith('agent/') || !validateBranchName('global-branch create', name).ok) return `The branch '${name.slice(0, 80) || '?'}' is not a valid agent/ branch name.`
234  return { name }
235}
236
237const appliedLines = (fde: FdeSession) =>
238  fde.applied.length === 0 ? ['- nothing applied'] : fde.applied.map(entry => `- ${entry.at} ${entry.command}`)
239
240export function fdeContext(fde: FdeSession): string {
241  return [
242    '# FDE mode',
243    `Goal: ${goalLabel(fde.goal)}${fde.goal === '' ? ' (the operator works with you turn by turn; /fde goal sets one)' : ''}`,
244    `This session works on one global branch: ${fde.branchName} (${fde.branchRid}) on ontology ${fde.ontologyRid}, stack ${fde.profile}.`,
245    'Ontology writes are pinned to it: omit --branch and the mode adds it; any other branch, --profile or a new branch is refused. ' +
246      'Pass --branch with that RID on reads you mean to see the branch (`ontology definition --object-type X --branch <rid>`); reads without it see main.',
247    'Plan, then apply: run each write as a dry run (no --apply), read the plan, then repeat it with --apply and identical arguments. ' +
248      'Each dry run licenses one apply, and editing a file the call reads voids it. Keep request files outside ~/.claude.',
249    'Work as an FDE: scout what exists, make the smallest change on the branch, verify it on the branch, then report. ' +
250      'Never create, close or merge branches: the operator ends the mode with /fde end and decides what lands.',
251  ].join('\n')
252}
253
254export function fdeStatus(fde: FdeSession): string {
255  return [
256    `FDE mode on · ${goalLabel(fde.goal)}`,
257    `branch ${fde.branchName} (${fde.branchRid})`,
258    `stack ${fde.profile} · ontology ${fde.ontologyRid} · since ${fde.startedAt}`,
259    `${fde.applied.length} applied · ${fde.plans.length} planned`,
260    '/fde end hands off and leaves the branch open.',
261  ].join('\n')
262}
263
264export function fdeHandoff(fde: FdeSession): string {
265  return [
266    `# FDE handoff: ${goalLabel(fde.goal)}`,
267    '',
268    `- Branch: ${fde.branchName} (${fde.branchRid}), still OPEN and not merged.`,
269    `- Stack ${fde.profile}, ontology ${fde.ontologyRid}, started ${fde.startedAt}.`,
270    `- Applied writes (${fde.applied.length}):`,
271    ...appliedLines(fde).map(line => `  ${line}`),
272    '',
273    'The operator reviews the branch, then merges it through a proposal or closes it: ' +
274      `orthanc global-branch close ${fde.branchRid} --apply --yes.`,
275  ].join('\n')
276}
277
hooks/orthanc.ts 196 lines
1// The orthanc_help and orthanc_cli tools' pure half.
2// Pure: the envelope, the manifest and the help text. Running orthanc stays
3// in register.tsx, where `$` lives.
4
5import { DENIED_COMMANDS, type ManifestCommand } from './policy'
6
7export type Envelope = {
8  schema_version: string
9  data?: unknown
10  errors?: unknown[]
11  meta?: Record<string, unknown>
12  warnings?: unknown[]
13  [key: string]: unknown
14}
15
16const INLINE_LIMIT = 20_000
17const ROW_NAMES_LIMIT = 8_000
18
19/** A row's identity for a cut listing: api name, else display name, else RID. */
20const rowName = (row: Record<string, unknown>) =>
21  [row.api_name, row.apiName, row.display_name, row.name, row.rid].find((value): value is string => typeof value === 'string' && value !== '') ?? ''
22const USAGE_ERRORS = ['required arguments were not provided', 'unexpected argument', 'usage: orthanc', 'invalid value']
23
24export const isRecord = (value: unknown): value is Record<string, unknown> =>
25  typeof value === 'object' && value !== null && !Array.isArray(value)
26
27export function parseEnvelope(stdout: string): Envelope | null {
28  try {
29    const parsed: unknown = JSON.parse(stdout)
30    return isRecord(parsed) && parsed.schema_version === 'foundry-agent-v1' ? (parsed as Envelope) : null
31  } catch {
32    return null
33  }
34}
35
36export const failure = (type: string, message: string, operation: string, extra: Record<string, unknown> = {}): Envelope => ({
37  schema_version: 'foundry-agent-v1',
38  data: null,
39  errors: [{ type, message, ...extra }],
40  meta: { operation },
41  warnings: [],
42})
43
44export function manifestFrom(envelope: Envelope): ManifestCommand[] {
45  const commands = isRecord(envelope.data) && Array.isArray(envelope.data.commands) ? envelope.data.commands : []
46  return commands.filter(isRecord).flatMap(entry =>
47    typeof entry.path === 'string'
48      ? [{
49          path: entry.path,
50          kind: typeof entry.kind === 'string' ? entry.kind : undefined,
51          auth: typeof entry.auth === 'string' ? entry.auth : undefined,
52          input: typeof entry.input === 'string' ? entry.input : undefined,
53        }]
54      : [],
55  )
56}
57
58export const normalizeCommand = (command: string) => command.trim().replace(/\s+/g, ' ')
59
60const groupOf = (path: string) => path.split(' ')[0] ?? path
61
62function commandLine(command: ManifestCommand): string {
63  const traits = [command.kind, command.auth && `auth: ${command.auth}`, command.input && `input: ${command.input}`]
64    .filter(Boolean)
65    .join(', ')
66  return `${command.path}${traits ? ` — ${traits}` : ''}`
67}
68
69export const isCredentialCommand = (command: ManifestCommand) =>
70  DENIED_COMMANDS.has(command.path) || command.input === 'token-on-stdin'
71
72export function renderCommand(command: ManifestCommand, help: string | null): string {
73  const lines = [commandLine(command)]
74  if (help) {
75    lines.push('', help.slice(0, 4000), '')
76    lines.push(
77      'For orthanc_cli, put positionals first in args, then exact flags. Do not invent flags. ' +
78        'Agent/non-interactive flags and the profile are supplied by the tool.',
79    )
80  }
81  if (isCredentialCommand(command)) {
82    lines.push('This credential-input command is not executable through orthanc_cli; the operator runs it in a terminal.')
83  }
84  return lines.join('\n')
85}
86
87export function renderListing(
88  commands: readonly ManifestCommand[],
89  input: { group?: string; query?: string },
90): string {
91  if (input.query) {
92    const terms = input.query.toLowerCase().split(/\s+/).filter(Boolean)
93    const hits = commands
94      .map(command => ({ command, score: terms.filter(term => command.path.toLowerCase().includes(term)).length }))
95      .filter(hit => hit.score > 0)
96      .sort((left, right) => right.score - left.score || left.command.path.localeCompare(right.command.path))
97      .slice(0, 20)
98    if (hits.length === 0) return `No Orthanc commands matched '${input.query}'.`
99    return [`Orthanc search '${input.query}': ${hits.length} hit${hits.length === 1 ? '' : 's'}.`, '', ...hits.map(hit => commandLine(hit.command))].join('\n')
100  }
101  if (input.group) {
102    const matches = commands.filter(command => groupOf(command.path) === input.group)
103    if (matches.length === 0) {
104      return `Unknown Orthanc group '${input.group}'. Groups: ${[...new Set(commands.map(command => groupOf(command.path)))].sort().join(', ')}.`
105    }
106    return [`Orthanc group '${input.group}': ${matches.length} command${matches.length === 1 ? '' : 's'}.`, '', ...matches.map(commandLine)].join('\n')
107  }
108  const counts = new Map<string, number>()
109  for (const command of commands) counts.set(groupOf(command.path), (counts.get(groupOf(command.path)) ?? 0) + 1)
110  return [
111    `Orthanc command surface: ${commands.length} commands in ${counts.size} groups.`,
112    'Call orthanc_help with a group to list commands, with a command path to inspect it, or with query to search.',
113    '',
114    ...[...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([group, count]) => `${group} (${count})`),
115  ].join('\n')
116}
117
118function verificationHint(path: string): string {
119  if (path.startsWith('global-branch create')) return "verify with 'global-branch get <branch_rid>' before reporting success"
120  if (path.startsWith('dataset branches create')) return "verify with 'dataset branches list <dataset_rid>' before reporting success"
121  if (path.startsWith('dataset create')) return "verify with 'dataset get <dataset_rid>' before reporting success"
122  if (path.startsWith('ontology ')) return 'verify with the matching ontology read for the changed type before reporting success'
123  return 'verify the change with the matching read operation before reporting success'
124}
125
126export const isErrorEnvelope = (envelope: Envelope) => Array.isArray(envelope.errors) && envelope.errors.length > 0
127
128export function isUsageError(envelope: Envelope): boolean {
129  const reason = JSON.stringify(envelope.errors ?? []).toLowerCase()
130  return USAGE_ERRORS.some(pattern => reason.includes(pattern))
131}
132
133/** Usage hint on usage errors; verification hint or uncertain-mutation flag on mutations. */
134export function guideEnvelope(envelope: Envelope, command: ManifestCommand, help: string | null): Envelope {
135  const usage = isUsageError(envelope)
136  let guided = usage && help ? { ...envelope, usage_hint: help.slice(0, 4000) } : envelope
137  if (command.kind !== 'mutation') return guided
138  if (!isErrorEnvelope(guided)) return { ...guided, verification_hint: verificationHint(command.path) }
139  if (!usage && JSON.stringify(guided.errors).includes('orthanc-launch-error')) {
140    guided = { ...guided, uncertain_mutation: true, guidance: 'reconcile observable Foundry state before any retry' }
141  }
142  return guided
143}
144
145/**
146 * The envelope as text for the model. Over the inline limit it is cut, with the
147 * page token and list sizes hoisted above the cut (they sit in `data`, which the
148 * cut can hide) and, when given, the path the whole envelope was written to.
149 */
150export function serialize(value: unknown, spillPath?: string): string {
151  const text = JSON.stringify(value, null, 2) ?? 'null'
152  if (text.length <= INLINE_LIMIT) return text
153  const header = [`orthanc output is ${text.length} chars; showing the first ${INLINE_LIMIT}.`]
154  if (spillPath) header.push(`The whole envelope is in ${spillPath}: read it with the Read tool (offset/limit) or jq.`)
155  const data = isRecord(value) ? value.data : undefined
156  if (isRecord(data)) {
157    const token = data.next_page_token ?? data.nextPageToken
158    if (typeof token === 'string' && token) header.push(`next_page_token: ${token} (pass --page-token to read the next page).`)
159    const sizes = Object.entries(data).filter(([, entry]) => Array.isArray(entry)).map(([key, entry]) => `${key}: ${(entry as unknown[]).length}`)
160    if (sizes.length > 0) header.push(`rows on this page: ${sizes.join(', ')}.`)
161    for (const [key, entry] of Object.entries(data)) {
162      if (!Array.isArray(entry)) continue
163      const names = entry.filter(isRecord).map(rowName).filter(Boolean)
164      if (names.length === 0) continue
165      const joined = names.join(', ')
166      header.push(`${key}: ${joined.length > ROW_NAMES_LIMIT ? `${joined.slice(0, ROW_NAMES_LIMIT)} …` : joined}`)
167    }
168  }
169  if (!spillPath) header.push('Narrow the call (--page-size, a get command) for less output.')
170  return `${header.join('\n')}\n\n${text.slice(0, INLINE_LIMIT)}\n... truncated`
171}
172
173export const isOversized = (value: unknown) => (JSON.stringify(value, null, 2)?.length ?? 0) > INLINE_LIMIT
174
175/** The list inside an orthanc list envelope, wherever the envelope keeps it. */
176export function listItems(envelope: Envelope, key: string): Record<string, unknown>[] {
177  const data = envelope.data
178  if (Array.isArray(data)) return data.filter(isRecord)
179  if (!isRecord(data)) return []
180  const list = data[key] ?? data.data
181  return Array.isArray(list) ? list.filter(isRecord) : []
182}
183
184/** The rows of an envelope whose list key varies by command: the named keys, then the first list in data. */
185export function rowsOf(envelope: Envelope, keys: readonly string[]): Record<string, unknown>[] {
186  const data = envelope.data
187  if (Array.isArray(data)) return data.filter(isRecord)
188  if (!isRecord(data)) return []
189  for (const key of [...keys, 'data']) {
190    const list = data[key]
191    if (Array.isArray(list)) return list.filter(isRecord)
192  }
193  const first = Object.values(data).find(Array.isArray)
194  return Array.isArray(first) ? first.filter(isRecord) : []
195}
196
hooks/policy.ts 729 lines
1// The approval ladder, the branch gate and the approval modes. Pure functions:
2// no `$`, so the test kit and the hooks share them. Read/mutation comes from
3// orthanc's own agent-manifest `kind`.
4
5export type ManifestCommand = { path: string; kind?: string; auth?: string; input?: string }
6
7export const APPROVAL_MODES = ['ask-always', 'allow-edits', 'auto', 'yolo'] as const
8export type ApprovalMode = (typeof APPROVAL_MODES)[number]
9export const DEFAULT_APPROVAL_MODE: ApprovalMode = 'auto'
10
11export function parseApprovalMode(value: string): ApprovalMode | null {
12  const normalized = value.trim().toLowerCase()
13  return APPROVAL_MODES.find(mode => mode === normalized) ?? null
14}
15
16export const ORTHANC_CLI = 'mcp__foundry__orthanc_cli'
17export const ORTHANC_HELP = 'mcp__foundry__orthanc_help'
18
19const PROTECTED_BRANCHES = ['main', 'master']
20const BRANCH_CREATION_PATHS = ['global-branch create', 'dataset branches create']
21// The branch rule: agent work happens on branches, never on a production main.
22// Each of these writes must name a non-protected --branch; ontology writes must
23// name a global branch RID, the one branch shape that is provably not main.
24const BRANCH_REQUIRED_PATHS = new Set([
25  'compute manage',
26  'compute execute',
27  'dataset files upload',
28  'dataset files delete',
29  'dataset schema set',
30  'dataset schema apply',
31  'dataset schema update',
32  'dataset transactions start',
33  'ontology action-apply',
34  'ontology object-type-create',
35  'ontology object-type-upsert',
36  'ontology object-type-guarded-upsert',
37  'ontology object-type-add-property',
38  'ontology object-type-delete',
39  'ontology object-type-guarded-delete',
40  'ontology link-type-create',
41  'ontology link-type-upsert',
42  'ontology link-type-delete',
43  'ontology action-type-upsert',
44  'ontology action-type-update',
45  'ontology action-type-set-function-version',
46  'ontology action-type-delete',
47  'streams dataset create',
48  'streams stream create',
49  'streams stream publish',
50  'streams stream publish-batch',
51  'streams stream reset',
52  'orchestration builds create',
53  'cp',
54])
55export const requiresBranch = (path: string) => BRANCH_REQUIRED_PATHS.has(path)
56// Ontology writes Orthanc cannot branch: they change live data on main.
57const MAIN_ONLY_WRITES = new Set(['ontology object-upsert', 'ontology object-upsert-batch', 'ontology action-apply-with-overrides'])
58// Promotion into main is the operator's decision, not the agent's.
59// A tag publishes a release from a commit, so it is promotion too.
60const PROMOTIONS = new Set(['proposal merge', 'proposal accept', 'proposal approve', 'repository tag create'])
61export const BRANCH_FLAGS = ['--branch', '-b']
62const BRANCH_VALUE = /^[A-Za-z0-9._/-]+$/
63// Orthanc flags that take no value, so the word after one is a positional.
64const BOOLEAN_FLAGS = new Set([
65  '--agent', '--apply', '--allow-default-branch', '--non-interactive', '--dry-run', '--force',
66  '--recursive', '-r', '--schema', '--fail-fast', '--debug', '--help', '-h',
67])
68const GLOBAL_BRANCH_RID = 'ri.branch.'
69const GLOBAL_BRANCH_PREFIX = 'agent/'
70const DATASET_BRANCH_PREFIX = 'agent-'
71const BRANCH_NAME_MAX_LEN = 64
72const READ_VERBS = new Set([
73  'get', 'list', 'search', 'describe', 'check', 'health', 'inspect',
74  'preview', 'query', 'read', 'view', 'help', 'export', 'status', 'download',
75])
76export const DENIED_COMMANDS = new Set(['auth store', 'onboarding'])
77// Orthanc `local` commands that only read local state.
78const LOCAL_READS = new Set([
79  'version', 'hello', 'agent-manifest', 'capabilities', 'completion show',
80  'configure list', 'alias list', 'alias show', 'alias resolve', 'alias export',
81])
82
83export type GateClassification = {
84  command: string | null
85  kind: 'mutation' | 'branch-creation'
86  isBranchScoped: boolean
87  hasWorkingBranch: boolean
88  targetsProtected: boolean
89  isDestructive: boolean
90  proposedName?: string
91  error?: string
92}
93
94export type LadderDecision = { outcome: 'run' | 'ask' | 'block'; reason: string }
95
96export type GuardVerdict = { action: 'approve' | 'ask' | 'deny'; reason: string }
97
98const lastWord = (text: string) => text.split(/\s+/).at(-1) ?? ''
99
100export function classifyOrthancCommand(
101  command: string,
102  manifest: readonly ManifestCommand[],
103): 'read' | 'mutation' {
104  const normalized = command.trim().toLowerCase()
105  const declared = manifest.find(entry => entry.path === normalized)
106  if (declared?.kind === 'read-only') return 'read'
107  if (declared?.kind === 'local' && LOCAL_READS.has(normalized)) return 'read'
108  if (declared === undefined && READ_VERBS.has(lastWord(normalized))) return 'read'
109  return 'mutation'
110}
111
112function isDestructiveCommand(command: string): boolean {
113  return lastWord(command.trim().toLowerCase()).split('-').at(-1) === 'delete'
114}
115
116function argsHaveForce(args: readonly string[]): boolean {
117  return args.some(arg => {
118    const flag = arg.trim().toLowerCase().replace(/=.*$/, '')
119    return flag === '--force' || flag === '-f'
120  })
121}
122
123// `main`, `refs/heads/main`, `+refs/heads/main`, `:main`, `./main/` all name main.
124const branchCore = (value: string) =>
125  value
126    .trim()
127    .toLowerCase()
128    .replace(/^[+:]+|:+$/g, '')
129    .replace(/\/{2,}/g, '/')
130    .replace(/^(\.\/)+/, '')
131    .replace(/^(refs\/)?heads\//, '')
132    .replace(/[/.]+$/, '')
133const isProtectedValue = (value: string) => PROTECTED_BRANCHES.includes(branchCore(value)) || branchCore(value) === 'head'
134
135export function branchIsWorking(branch: string | null | undefined): boolean {
136  return typeof branch === 'string' && branch.trim() !== '' && !isProtectedValue(branch)
137}
138
139export function validateBranchName(
140  path: string,
141  name: string,
142): { ok: true; name: string } | { ok: false; reason: string } {
143  const trimmed = name.trim()
144  if (trimmed.length > BRANCH_NAME_MAX_LEN) {
145    return { ok: false, reason: `branch name exceeds ${BRANCH_NAME_MAX_LEN} characters` }
146  }
147  const prefix = path === 'global-branch create' ? GLOBAL_BRANCH_PREFIX : DATASET_BRANCH_PREFIX
148  if (!trimmed.startsWith(prefix)) {
149    const kind = prefix === GLOBAL_BRANCH_PREFIX ? 'global' : 'dataset'
150    return {
151      ok: false,
152      reason: `${kind} branch name must start with '${prefix}', e.g. '${prefix}foundry-fix-12'`,
153    }
154  }
155  const body = trimmed.slice(prefix.length)
156  const isKebab = body !== '' && body.split('-').every(part => /^[a-z0-9]+$/.test(part))
157  if (!isKebab) {
158    return {
159      ok: false,
160      reason: 'branch name body must be lowercase alphanumeric segments separated by single hyphens',
161    }
162  }
163  return { ok: true, name: trimmed }
164}
165
166export function proposedBranchName(args: readonly string[]): string | null {
167  const candidate = args.find(item => {
168    const trimmed = item.trim()
169    return trimmed !== '' && !trimmed.startsWith('--') && !trimmed.startsWith('ri.')
170  })
171  return candidate ? candidate.trim() : null
172}
173
174const isBranchFlag = (value: unknown) =>
175  typeof value === 'string' && value.replace(/^-+/, '').toLowerCase() === 'branch'
176
177function targetsProtected(input: unknown): boolean {
178  if (typeof input === 'string') return false
179  if (Array.isArray(input)) {
180    const flagPair = input.some((flag, index) => {
181      const value = input[index + 1]
182      return isBranchFlag(flag) && typeof value === 'string' && isProtectedValue(value)
183    })
184    return flagPair || input.some(item => targetsProtected(item))
185  }
186  if (typeof input !== 'object' || input === null) return false
187  return Object.entries(input).some(([key, value]) => {
188    const field = key.replace(/^-+/, '').toLowerCase()
189    const isBranchField = field === 'branch' || field === 'branchname' || field === 'branch_name'
190    return (
191      (isBranchField && typeof value === 'string' && isProtectedValue(value)) ||
192      targetsProtected(value)
193    )
194  })
195}
196
197export const unquote = (value: string) => value.trim().replace(/^(['"])(.*)\1$/, '$2')
198
199/** Every value of the named flags (`--name value`, `--name=value`, `-bvalue`), unquoted; '' for a flag with no value. */
200export function flagValues(args: readonly string[], names: readonly string[]): string[] {
201  const found: string[] = []
202  for (let index = 0; index < args.length; index += 1) {
203    const arg = unquote(args[index] ?? '')
204    for (const name of names) {
205      if (arg === name) {
206        const value = unquote(args[index + 1] ?? '')
207        if (value === '' || value.startsWith('-')) found.push('')
208        else {
209          found.push(value)
210          index += 1
211        }
212        break
213      }
214      if (arg.startsWith(`${name}=`)) {
215        found.push(unquote(arg.slice(name.length + 1)))
216        break
217      }
218      if (/^-[a-z]$/.test(name) && arg.startsWith(name) && arg.length > 2) {
219        found.push(unquote(arg.slice(2)))
220        break
221      }
222    }
223  }
224  return found
225}
226
227/** The first value of `--name value` or `--name=value`, unquoted; '' when the flag has no value. */
228export function flagValue(args: readonly string[], name: string): string | null {
229  return flagValues(args, [name])[0] ?? null
230}
231
232const positionals = (args: readonly string[]) => {
233  const found: string[] = []
234  for (let index = 0; index < args.length; index += 1) {
235    const arg = unquote(args[index] ?? '')
236    if (arg.startsWith('-')) {
237      const takesValue = !arg.includes('=') && !BOOLEAN_FLAGS.has(arg)
238      if (takesValue && args[index + 1] !== undefined && !unquote(args[index + 1] ?? '').startsWith('-')) index += 1
239      continue
240    }
241    found.push(arg)
242  }
243  return found
244}
245
246/** Why the branch rule refuses this Orthanc write, or null. Applies in every approval mode. */
247export function branchRuleReason(command: string, args: readonly string[]): string | null {
248  if (MAIN_ONLY_WRITES.has(command)) {
249    return `'${command}' writes live data on the main ontology branch and cannot take --branch. Agents do not run it: use 'ontology action-apply --branch <global branch RID>' or hand it to the operator.`
250  }
251  if (PROMOTIONS.has(command)) {
252    return `'${command}' promotes work (a merge, an approval or a release). The operator does it; the agent does not.`
253  }
254  if (command === 'repository push') {
255    if (args.some(arg => unquote(arg).replace(/=.*$/, '').toLowerCase() === '--allow-default-branch')) {
256      return "'repository push --allow-default-branch' pushes to the default branch. Push to a work branch and open a pull request."
257    }
258    const found = positionals(args)
259    if (found.length !== 3) {
260      return `'repository push' takes exactly <repository rid> <local ref> <destination ref>; found ${found.length} positionals, so the destination cannot be checked.`
261    }
262    const destination = found[2]!
263    if (isProtectedValue(destination)) {
264      return `'repository push' to ${destination} targets the default branch. Push to a work branch and open a pull request.`
265    }
266    return null
267  }
268  if (!BRANCH_REQUIRED_PATHS.has(command)) return null
269  const branches = flagValues(args, BRANCH_FLAGS)
270  if (branches.length > 1) return `'${command}' names --branch ${branches.length} times. Name one work branch.`
271  const branch = branches[0] ?? null
272  if (branch === null || branch === '') {
273    return command.startsWith('ontology ')
274      ? `'${command}' names no --branch, so it would change the main ontology. Pass --branch <global branch RID> (ri.branch..branch.…); create one with 'global-branch create ${GLOBAL_BRANCH_PREFIX}<name>'.`
275      : `'${command}' names no --branch, so it would write the default branch (master). Pass --branch <work branch>; create one with 'dataset branches create <dataset> ${DATASET_BRANCH_PREFIX}<name>' or use a global branch.`
276  }
277  if (!BRANCH_VALUE.test(branch)) return `'${command}' branch '${branch}' has characters no branch name uses.`
278  if (isProtectedValue(branch)) return `'${command}' targets ${branch}. Agent work happens on branches, never on main or master.`
279  if (command.startsWith('ontology ') && !branch.startsWith(GLOBAL_BRANCH_RID)) {
280    return `'${command}' must target a global branch RID (ri.branch..branch.…), not '${branch}': only a global branch is provably not the main ontology branch.`
281  }
282  return null
283}
284
285function orthancClassification(
286  command: string,
287  args: readonly string[],
288  rawInput: unknown,
289  workingBranch: string | null,
290): GateClassification {
291  const base = {
292    command,
293    hasWorkingBranch: branchIsWorking(workingBranch),
294    targetsProtected: targetsProtected(rawInput),
295  }
296  if (!BRANCH_CREATION_PATHS.includes(command)) {
297    const refused = branchRuleReason(command, args)
298    return {
299      ...base,
300      kind: 'mutation',
301      isBranchScoped: BRANCH_REQUIRED_PATHS.has(command),
302      isDestructive: isDestructiveCommand(command) || argsHaveForce(args),
303      ...(refused ? { error: refused } : {}),
304    }
305  }
306  const creation = { ...base, kind: 'branch-creation' as const, isBranchScoped: false, isDestructive: false }
307  const proposed = proposedBranchName(args)
308  if (proposed === null) {
309    return {
310      ...creation,
311      error: `branch creation blocked: the call proposes no branch name; name branches '${GLOBAL_BRANCH_PREFIX}<kebab>' for global branches`,
312    }
313  }
314  const validated = validateBranchName(command, proposed)
315  return validated.ok
316    ? { ...creation, targetsProtected: false, proposedName: proposed }
317    : { ...creation, proposedName: proposed, error: `branch name '${proposed}' rejected: ${validated.reason}` }
318}
319
320/** An orthanc_cli command as the manifest path it names (lowercased, longest
321 * prefix) and its args; words past the path lead the args. Classifier and
322 * executor both resolve through here, so they see the same command. */
323export function resolveOrthancCommand(
324  command: string,
325  args: readonly string[],
326  manifest: readonly ManifestCommand[],
327): { path: string; args: string[] } {
328  const words = command.trim().split(/\s+/).filter(word => word !== '')
329  const paths = new Set(manifest.map(entry => entry.path))
330  for (let width = words.length; width >= 1; width -= 1) {
331    const path = words.slice(0, width).join(' ').toLowerCase()
332    if (paths.has(path)) return { path, args: [...words.slice(width), ...args] }
333  }
334  return { path: words.join(' ').toLowerCase(), args: [...args] }
335}
336
337const SHELL_OPERATORS = ';&|()`<>'
338
339/** A shell command as segments of words: quotes kept together, split at
340 * ; & | ( ) ` < > $( and newlines. A quoted word that holds a command
341 * (`bash -c "…"`) is split again and its segments added. */
342export function shellSegments(command: string, depth = 0): string[][] {
343  const segments: string[][] = []
344  let words: string[] = []
345  let word = ''
346  let quote: string | null = null
347  let quoted = false
348  const endWord = () => {
349    if (word !== '' || quoted) words.push(word)
350    word = ''
351    quoted = false
352  }
353  const endSegment = () => {
354    endWord()
355    if (words.length > 0) segments.push(words)
356    words = []
357  }
358  for (let index = 0; index < command.length; index += 1) {
359    const char = command[index]!
360    if (quote !== null) {
361      if (char === quote) quote = null
362      else if (char === '\\' && quote === '"' && index + 1 < command.length) word += command[++index]
363      else word += char
364    } else if (char === "'" || char === '"') {
365      quote = char
366      quoted = true
367    } else if (char === '\\' && index + 1 < command.length) word += command[++index]
368    else if (char === '\n') endSegment()
369    else if (/\s/.test(char)) endWord()
370    else if (char === '$' && command[index + 1] === '(') {
371      endSegment()
372      index += 1
373    } else if (SHELL_OPERATORS.includes(char)) endSegment()
374    else word += char
375  }
376  endSegment()
377  if (depth >= 3) return segments
378  const nested = segments.flatMap(found =>
379    found.filter(item => /[\s;&|()`<>]|\$\(/.test(item)).flatMap(item => shellSegments(item, depth + 1)),
380  )
381  return [...segments, ...nested]
382}
383
384const basename = (word: string | undefined) => (word ?? '').split('/').at(-1)?.replace(/^!/, '') ?? ''
385const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
386const MENTIONS_ORTHANC = /(^|[^\w/.-])orthanc([^\w.-]|$)|ORTHANC_BIN/i
387const isOrthancWord = (word: string) => basename(word).toLowerCase() === 'orthanc' || /\$\{?ORTHANC_BIN\}?/.test(word)
388const WRAPPERS = new Set([
389  'env', 'command', 'exec', 'time', 'nohup', 'sudo', 'doas', 'xargs', 'nice', 'timeout', 'caffeinate',
390  'builtin', 'stdbuf', 'then', 'do', 'else', '!', '{',
391])
392const INDIRECT = new Set(['eval', 'source', '.'])
393const CODE_RUNNERS = /^(python[0-9.]*|node|deno|bun|ruby|perl|osascript|awk)$/
394
395// Heads that name orthanc without running it (`grep orthanc`, `cd orthanc`).
396const NON_EXECUTING = new Set([
397  'grep', 'egrep', 'fgrep', 'rg', 'ag', 'cat', 'bat', 'less', 'more', 'head', 'tail', 'wc', 'ls', 'cd', 'pushd',
398  'man', 'which', 'whereis', 'type', 'echo', 'printf', 'git', 'make', 'file', 'stat', 'open', 'code', 'vim',
399  'nano', 'brew', 'mkdir', 'rm', 'cp', 'mv', 'touch', 'tree', 'du', 'diff', 'cargo', 'sed', 'awk',
400])
401
402/** Where Orthanc starts in a segment: the command word, or the first orthanc
403 * word after any head that can run another program (env, sudo, xargs, find
404 * -exec, npx, ssh, `!`, …). -1 when the segment does not run Orthanc. */
405function orthancStart(words: readonly string[]): number {
406  let index = 0
407  while (index < words.length && ASSIGNMENT.test(words[index]!)) index += 1
408  if (index >= words.length) return -1
409  if (isOrthancWord(words[index]!)) return index
410  if (NON_EXECUTING.has(basename(words[index]))) return -1
411  return words.findIndex((word, position) => position > index && isOrthancWord(word))
412}
413
414/** True when a segment runs Orthanc in a form the parser cannot read: a
415 * variable or eval as the command, code passed to an interpreter, or an
416 * Orthanc command stored in a variable. `command` is the whole shell line. */
417function hidesOrthanc(words: readonly string[], command: string): boolean {
418  if (!MENTIONS_ORTHANC.test(command)) return false
419  let index = 0
420  while (index < words.length && (ASSIGNMENT.test(words[index]!) || WRAPPERS.has(basename(words[index])) || words[index]!.startsWith('-'))) {
421    if (ASSIGNMENT.test(words[index]!) && MENTIONS_ORTHANC.test(words[index]!.replace(ASSIGNMENT, ''))) return true
422    index += 1
423  }
424  const head = words[index]
425  if (head === undefined) return false
426  if (head.startsWith('$')) return true
427  const rest = words.slice(index + 1)
428  if (INDIRECT.has(head)) return rest.some(word => MENTIONS_ORTHANC.test(word))
429  // Interpreter code inline (`python3 -c "…orthanc…"`): a word holding code, or the one after -c/-e.
430  return CODE_RUNNERS.test(basename(head)) &&
431    rest.some((word, position) => MENTIONS_ORTHANC.test(word) && (/[\s()'"\[\];]/.test(word) || /^-(c|e|-eval)$/.test(rest[position - 1] ?? '')))
432}
433
434/** The Orthanc command a Bash segment runs: global flags skipped, then the longest manifest path. */
435function orthancShellCall(
436  words: readonly string[],
437  start: number,
438  manifest: readonly ManifestCommand[],
439): { path: string; known: boolean; help: boolean } {
440  const rest = words.slice(start + 1)
441  let index = 0
442  while (index < rest.length && rest[index]!.startsWith('-')) index += rest[index] === '--profile' ? 2 : 1
443  const tail = rest.slice(index)
444  const ended = rest.indexOf('--')
445  const flags = ended < 0 ? rest : rest.slice(0, ended)
446  const help = flags.some(word => ['-h', '--help', '-V', '--version'].includes(word)) || tail[0] === 'help'
447  const leading: string[] = []
448  for (const word of tail) {
449    if (word.startsWith('-')) break
450    leading.push(word)
451  }
452  const paths = new Set(manifest.map(entry => entry.path))
453  for (let width = leading.length; width >= 1; width -= 1) {
454    const path = leading.slice(0, width).join(' ').toLowerCase()
455    if (paths.has(path)) return { path, known: true, help }
456  }
457  return { path: leading.join(' ').toLowerCase(), known: false, help }
458}
459
460// Bash may read through Orthanc; every Orthanc write goes through orthanc_cli,
461// where the branch rule and the ladder see its arguments as data, not shell.
462function shellClassification(
463  command: string,
464  manifest: readonly ManifestCommand[],
465  workingBranch: string | null,
466): GateClassification | null {
467  const refuse = (path: string | null, error: string): GateClassification => ({
468    command: path,
469    kind: 'mutation',
470    isBranchScoped: false,
471    hasWorkingBranch: branchIsWorking(workingBranch),
472    targetsProtected: false,
473    isDestructive: false,
474    error,
475  })
476  for (const words of shellSegments(command)) {
477    if (hidesOrthanc(words, command)) {
478      return refuse(null, 'This shell command runs Orthanc in a form the Foundry guard cannot read (a variable, eval or interpreter code). Run Orthanc reads as plain `orthanc …` and writes with orthanc_cli.')
479    }
480    const start = orthancStart(words)
481    if (start < 0) continue
482    const call = orthancShellCall(words, start, manifest)
483    if (call.path === '' && !call.help && /\$|`/.test(command)) {
484      return refuse(null, 'This shell command builds an Orthanc command from a substitution or variable, which the Foundry guard cannot read. Run Orthanc reads as plain `orthanc …` and writes with orthanc_cli.')
485    }
486    if (call.help || call.path === '' || call.path === 'repository clone') continue
487    if (!call.known) {
488      return refuse(call.path, `Bash runs 'orthanc ${call.path}', which is not a command in Orthanc's agent manifest. Look it up with orthanc_help, then run it with orthanc_cli.`)
489    }
490    if (classifyOrthancCommand(call.path, manifest) === 'read') continue
491    return refuse(call.path, `Orthanc writes run through orthanc_cli, where the branch rule and approval ladder apply, not Bash. Run '${call.path}' with orthanc_cli.`)
492  }
493  return null
494}
495
496const PALANTIR_MCP = /^mcp__palantir(-mcp)?__/
497const WRITE_WORDS = new Set(['add', 'apply', 'build', 'create', 'delete', 'deploy', 'edit', 'install', 'post', 'publish', 'put', 'remove', 'rename', 'set', 'submit', 'update', 'write'])
498const AMBIGUOUS_WORDS = new Set(['call', 'execute', 'invoke', 'run', 'trigger'])
499const READ_WORDS = new Set(['check', 'describe', 'get', 'health', 'inspect', 'list', 'preview', 'query', 'read', 'search', 'view'])
500
501export function classifyMcpIntent(inner: string): 'read' | 'write' | 'unknown' {
502  const words = inner.toLowerCase().split(/[^a-z]+/)
503  if (words.some(word => WRITE_WORDS.has(word))) return 'write'
504  if (words.some(word => AMBIGUOUS_WORDS.has(word))) return 'unknown'
505  if (words.some(word => READ_WORDS.has(word))) return 'read'
506  return 'unknown'
507}
508
509/** Null: a read, or a tool outside the Foundry lane. Never reaches the ladder. */
510export function classifyCall(
511  tool: string,
512  input: Record<string, unknown>,
513  manifest: readonly ManifestCommand[],
514  workingBranch: string | null,
515): GateClassification | null {
516  if (tool === ORTHANC_CLI) {
517    const raw = typeof input.command === 'string' ? input.command : ''
518    const resolved = resolveOrthancCommand(raw, Array.isArray(input.args) ? input.args.map(String) : [], manifest)
519    if (resolved.path === '' || resolved.path === 'repository clone') return null
520    if (classifyOrthancCommand(resolved.path, manifest) === 'read') return null
521    return orthancClassification(resolved.path, resolved.args, { ...input, args: resolved.args }, workingBranch)
522  }
523  if (tool === 'Bash' && typeof input.command === 'string') return shellClassification(input.command, manifest, workingBranch)
524  if (PALANTIR_MCP.test(tool)) {
525    const inner = tool.replace(PALANTIR_MCP, '')
526    if (classifyMcpIntent(inner) === 'read') return null
527    return {
528      command: null,
529      kind: 'mutation',
530      isBranchScoped: false,
531      hasWorkingBranch: branchIsWorking(workingBranch),
532      targetsProtected: targetsProtected(input),
533      isDestructive: inner.toLowerCase().split(/[^a-z]+/).includes('delete'),
534      error: `${tool} writes to Foundry outside Orthanc, with no branch. Orthanc is the only write path: use orthanc_cli with --branch.`,
535    }
536  }
537  return null
538}
539
540export function ladderDecision(
541  mode: ApprovalMode,
542  classification: GateClassification,
543  verdict?: GuardVerdict | null,
544): LadderDecision {
545  if (classification.error) return { outcome: 'block', reason: classification.error }
546  if (classification.kind === 'branch-creation') {
547    if (mode === 'ask-always') {
548      return {
549        outcome: 'ask',
550        reason: `ask-always mode: branch creation '${classification.proposedName ?? '?'}' waits for approval`,
551      }
552    }
553    return { outcome: 'run', reason: 'branch creation is pre-authorized and reversible' }
554  }
555  const command = classification.command ?? '(foundry mcp write)'
556  const note = verdict ? ` ${verdict.reason}` : ''
557  if (classification.isDestructive) {
558    return { outcome: 'ask', reason: `destructive mutation '${command}' asks in every mode${note}` }
559  }
560  if (classification.targetsProtected && mode !== 'yolo') {
561    return {
562      outcome: 'ask',
563      reason: `'${command}' targets a protected baseline (main/master branch or the production ontology)${note}`,
564    }
565  }
566  if (mode === 'yolo') return { outcome: 'run', reason: 'yolo mode: no guardrails outside destructive paths' }
567  if (mode === 'ask-always') return { outcome: 'ask', reason: `ask-always mode: mutation '${command}' waits for approval` }
568  if (mode === 'allow-edits') return { outcome: 'run', reason: 'allow-edits mode: non-protected mutations run' }
569  if (!verdict) return { outcome: 'ask', reason: `'${command}': guard unavailable; failing closed to a human decision` }
570  if (verdict.action === 'approve') return { outcome: 'run', reason: verdict.reason }
571  return { outcome: 'ask', reason: `'${command}': ${verdict.reason}` }
572}
573
574export function modeGuidance(mode: ApprovalMode): string {
575  return (
576    'Branch rule: agent work happens on branches, never on a production main. Every ontology write ' +
577    'passes --branch <global branch RID> (ri.branch..branch.…; create one with global-branch create agent/<name>); ' +
578    'dataset, stream and build writes pass --branch <work branch>; code goes to a work branch and a pull request. ' +
579    'Writes without a branch, merges and default-branch pushes are blocked in every mode. ' +
580    `Foundry approval mode: ${mode}. Foundry mutations may surface an approval dialog for the ` +
581    'operator: the call continues once approved; if the operator declines or no one can be asked, ' +
582    'the call is blocked — report it, do not retry blindly.'
583  )
584}
585
586const COMPASS_ROOT = 'ri.compass.main.folder.0'
587
588function containsCompassRoot(value: unknown): boolean {
589  if (typeof value === 'string') return value.trim().toLowerCase() === COMPASS_ROOT
590  if (Array.isArray(value)) return value.some(containsCompassRoot)
591  if (typeof value === 'object' && value !== null) return Object.values(value).some(containsCompassRoot)
592  return false
593}
594
595export function compassRootReason(tool: string, input: unknown): string | null {
596  if (!containsCompassRoot(input)) return null
597  return `Compass root folder ${COMPASS_ROOT} is not a valid scope for ${tool}. Provide a verified child folder RID; root resource search is not supported.`
598}
599
600function stable(value: unknown): unknown {
601  if (Array.isArray(value)) return value.map(stable)
602  if (typeof value !== 'object' || value === null) return value
603  return Object.fromEntries(
604    Object.entries(value).sort(([left], [right]) => left.localeCompare(right)),
605  )
606}
607
608/** Third consecutive identical call (tool + top-level-sorted input) is blocked. */
609export function loopStep(
610  previous: { identity: string; streak: number } | null,
611  tool: string,
612  input: unknown,
613): { next: { identity: string; streak: number }; reason: string | null } {
614  const identity = JSON.stringify({ input: stable(input), tool })
615  const streak = previous?.identity === identity ? previous.streak + 1 : 1
616  const reason =
617    streak > 2
618      ? 'Loop guard blocks a third identical tool call. Use existing evidence or change the experiment.'
619      : null
620  return { next: { identity, streak }, reason }
621}
622
623// Raw `git push` from a Foundry clone skips Orthanc's default-branch refusal.
624// Every directory a `git … push` in a shell command could run in: '' (the
625// session's), each cd/pushd target, each -C, --git-dir and --work-tree value.
626// Over-counting only costs a remote check. Null when nothing pushes.
627export function gitPushDirectories(command: string): string[] | null {
628  const directories = new Set<string>([''])
629  let pushes = false
630  for (const words of shellSegments(command)) {
631    words.forEach((word, index) => {
632      if (word === 'cd' || word === 'pushd') {
633        const target = words.slice(index + 1).find(next => !next.startsWith('-'))
634        if (target !== undefined) directories.add(target)
635      }
636      const env = /^(?:GIT_DIR|GIT_WORK_TREE)=(.+)$/.exec(word)
637      if (env) directories.add(env[1]!)
638    })
639    const git = words.findIndex(word => basename(word) === 'git')
640    if (git < 0) continue
641    const rest = words.slice(git + 1)
642    for (let index = 0; index < rest.length; index += 1) {
643      const word = rest[index]!
644      if (word === '-C' || word === '--git-dir' || word === '--work-tree') {
645        if (rest[index + 1] !== undefined) directories.add(rest[index + 1]!)
646        index += 1
647        continue
648      }
649      if (word === '-c' || word === '--namespace' || word === '--config-env') {
650        // An alias set on the command line can be push under another name.
651        if (/^alias\./i.test(rest[index + 1] ?? '')) pushes = true
652        index += 1
653        continue
654      }
655      const joined = /^--(git-dir|work-tree)=(.+)$/.exec(word)
656      if (joined) directories.add(joined[2]!)
657      if (word.startsWith('-')) continue
658      if (word === 'push') pushes = true
659      break
660    }
661  }
662  return pushes ? [...directories] : null
663}
664
665/** A push that names a Foundry repository URL outright, from any directory. */
666export function gitPushNamesFoundry(command: string): boolean {
667  return gitPushDirectories(command) !== null && command.includes('ri.stemma.')
668}
669
670export const FOUNDRY_GIT_PUSH_REASON =
671  "Raw 'git push' to a Foundry code repository bypasses Orthanc. Push with " +
672  "'orthanc repository push <repository rid> refs/heads/<work branch> refs/heads/<work branch>' and open a pull request; it refuses the default branch."
673
674// The operator's files steer the write guard and the trace export, so only the
675// operator edits them: ~/.claude/foundry/ (rules.md, otel.env) and a project's
676// .claude/foundry-rules.md. otel.env can name a collector and key references,
677// so Claude does not read the directory either. The mod reads them once at
678// session start, so a change made mid-session waits for the next one.
679const FILE_WRITERS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit'])
680const FILE_READERS = new Set(['Read', 'Grep', 'Glob', 'LS'])
681const PROTECTED_FILE = /(^|\/)\.claude\/(foundry-rules\.md|foundry(\/|$))/
682const SECRET_DIR = /(^|\/)\.claude\/foundry(\/|$)/
683// Any shell mention of these is denied, read or write.
684const SHELL_PROTECTED = /\.claude\/+(\.\/+)*foundry\b|foundry-rules|otel\.env|\.claude\/*\*/i
685// A loose mention (a relative foundry/…md path) is denied with a writer.
686const SHELL_MENTION = /\bfound\S*\/\S*\.md/i
687const SHELL_WRITES =
688  /(>|\btee\b|\bsed\b|\bmv\b|\bcp\b|\brm\b|\bln\b|\btruncate\b|\binstall\b|\bdd\b|\btouch\b|\bperl\b|\bpython[0-9.]*\b|\bnode\b|\bruby\b|\bed\b|\bex\b|\bvim?\b|\bnvim\b|\bcurl\b|\bwget\b|\bchmod\b|\bunlink\b|\brsync\b|\bbun\b|\bdeno\b|\bphp\b|\bawk\b|\blua\b|\bswift\b|\bosascript\b|\btar\b|\bunzip\b|\bcpio\b|\bditto\b|\bpatch\b|\bgit\b|\b(ba|z|da|k|c|tc|fi)?sh\b)/
689// Removing or moving ~/.claude itself removes the operator's rules with it.
690const CLAUDE_DIR_REMOVAL = /\b(rm|mv|rsync|unlink)\b[^;&|]*\.claude\/?(?=["'\s;&|)]|$)/
691export const RULES_WRITE_REASON =
692  'Foundry operator files (~/.claude/foundry/: rules.md, otel.env; .claude/foundry-rules.md) steer the write guard and tracing, so only the operator reads and edits them. Suggest the change and let the operator make it.'
693
694/** A file path as the filesystem sees it: absolute against `cwd`, ~ expanded, . and // collapsed, lowercased (macOS). */
695export function normalizedPath(path: string, cwd: string, home: string): string {
696  const expanded = path.trim().replace(/^~(?=\/|$)/, home)
697  const absolute = expanded.startsWith('/') ? expanded : `${cwd.replace(/\/$/, '')}/${expanded}`
698  const parts: string[] = []
699  for (const part of absolute.split('/')) {
700    if (part === '' || part === '.') continue
701    if (part === '..') parts.pop()
702    else parts.push(part)
703  }
704  return `/${parts.join('/')}`.toLowerCase()
705}
706
707export function rulesWriteReason(tool: string, input: Record<string, unknown>, cwd = '/', home = ''): string | null {
708  const paths = [input.file_path, input.notebook_path, input.path].filter((value): value is string => typeof value === 'string')
709  if (FILE_WRITERS.has(tool) && paths.some(path => PROTECTED_FILE.test(normalizedPath(path, cwd, home)))) return RULES_WRITE_REASON
710  if (FILE_READERS.has(tool)) {
711    const base = paths[0] ?? cwd
712    const targets = [base, ...(typeof input.pattern === 'string' && tool === 'Glob' ? [`${base.replace(/\/$/, '')}/${input.pattern}`] : [])]
713    const NAMES_OPERATOR_FILES = /otel|foundry-rules|\.claude/i
714    if (typeof input.glob === 'string' && NAMES_OPERATOR_FILES.test(input.glob)) return RULES_WRITE_REASON
715    if (tool === 'Glob' && typeof input.pattern === 'string' && NAMES_OPERATOR_FILES.test(input.pattern)) return RULES_WRITE_REASON
716    // A search rooted at ~/.claude reaches the directory; one rooted at ~ is left
717    // alone (it would stop every home-directory session), and otel.env holds only references.
718    if (tool !== 'Read' && home !== '' && normalizedPath(base, cwd, home) === normalizedPath('~/.claude', '/', home)) return RULES_WRITE_REASON
719    if (targets.some(target => SECRET_DIR.test(normalizedPath(target, cwd, home)))) return RULES_WRITE_REASON
720  }
721  if (tool === 'Bash' && typeof input.command === 'string') {
722    const command = input.command
723    if (SHELL_PROTECTED.test(command) || CLAUDE_DIR_REMOVAL.test(command)) return RULES_WRITE_REASON
724    if (SHELL_MENTION.test(command) && SHELL_WRITES.test(command)) return RULES_WRITE_REASON
725    if (PROTECTED_FILE.test(normalizedPath(cwd, '/', home))) return RULES_WRITE_REASON
726  }
727  return null
728}
729
hooks/telemetry.ts 306 lines
1// Trace export: one OTLP trace per Foundry turn, sent to any OTLP/HTTP
2// collector (Langfuse's /api/public/otel among them). Pure functions: the
3// hooks hold the state and do the fetch. Every exported value is redacted at
4// source; a value that cannot be serialized is dropped, never sent raw.
5
6export type TraceConfig = { url: string; headers: Record<string, string>; host: string; toolIo: boolean }
7
8export type TraceEnv = Partial<Record<string, string>>
9
10export const TRACE_ENV_KEYS: readonly string[] = [
11  'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT',
12  'OTEL_EXPORTER_OTLP_ENDPOINT',
13  'OTEL_EXPORTER_OTLP_HEADERS',
14  'LANGFUSE_BASE_URL',
15  'LANGFUSE_HOST',
16  'LANGFUSE_PUBLIC_KEY',
17  'LANGFUSE_SECRET_KEY',
18  'FOUNDRY_OTEL',
19  'FOUNDRY_OTEL_TOOL_IO',
20  'PALANTIR_AGENT_OTEL_TOOL_IO',
21  'OTEL_SERVICE_NAME',
22  'OTEL_RESOURCE_ATTRIBUTES',
23  'FOUNDRY_OTEL_REDACT_DOMAINS',
24]
25
26/**
27 * An env file read as data, never run: `[export ]KEY=VALUE` lines for the
28 * TRACE_ENV_KEYS only, quotes stripped. A value that is a 1Password reference
29 * (op://vault/item/field) is returned in `refs` for the caller to resolve
30 * through `op read` by argv; anything that looks like shell ($, `) is dropped,
31 * and a credential written literally is ignored and named in `literalSecrets`.
32 */
33// Keys that carry credentials: in the file they must be op:// references, so
34// the file itself holds nothing secret even if something reads it.
35const SECRET_ENV_KEYS = new Set(['LANGFUSE_PUBLIC_KEY', 'LANGFUSE_SECRET_KEY', 'OTEL_EXPORTER_OTLP_HEADERS'])
36// A URL with userinfo or a query string can carry a credential too.
37const URL_ENV_KEYS = new Set(['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', 'OTEL_EXPORTER_OTLP_ENDPOINT', 'LANGFUSE_BASE_URL', 'LANGFUSE_HOST'])
38
39export function parseEnvFile(text: string): { env: TraceEnv; refs: Record<string, string>; literalSecrets: string[] } {
40  const env: TraceEnv = {}
41  const refs: Record<string, string> = {}
42  const literalSecrets: string[] = []
43  for (const raw of text.split(/\r?\n/)) {
44    const match = /^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)=(.*)$/.exec(raw)
45    if (!match || !TRACE_ENV_KEYS.includes(match[1]!)) continue
46    let value = match[2]!.trim()
47    const quoted = /^(["'])(.*)\1$/.exec(value)
48    if (quoted) value = quoted[2]!
49    else value = value.replace(/\s+#.*$/, '')
50    if (value === '' || /[`$]/.test(value)) continue
51    if (/^op:\/\/.+/.test(value)) refs[match[1]!] = value
52    else if (SECRET_ENV_KEYS.has(match[1]!)) literalSecrets.push(match[1]!)
53    else if (URL_ENV_KEYS.has(match[1]!) && /^[a-z]+:\/\/[^/?#]*@|\?/i.test(value)) literalSecrets.push(match[1]!)
54    else env[match[1]!] = value
55  }
56  return { env, refs, literalSecrets }
57}
58
59const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
60
61function utf8(text: string): number[] {
62  const out: number[] = []
63  for (const char of text) {
64    const code = char.codePointAt(0)!
65    if (code < 0x80) out.push(code)
66    else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 63))
67    else if (code < 0x10000) out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63))
68    else out.push(0xf0 | (code >> 18), 0x80 | ((code >> 12) & 63), 0x80 | ((code >> 6) & 63), 0x80 | (code & 63))
69  }
70  return out
71}
72
73export function base64(text: string): string {
74  const bytes = utf8(text)
75  let out = ''
76  for (let index = 0; index < bytes.length; index += 3) {
77    const [a, b, c] = [bytes[index]!, bytes[index + 1], bytes[index + 2]]
78    out += BASE64[a >> 2]! + BASE64[((a & 3) << 4) | ((b ?? 0) >> 4)]!
79    out += b === undefined ? '=' : BASE64[((b & 15) << 2) | ((c ?? 0) >> 6)]!
80    out += c === undefined ? '=' : BASE64[c & 63]!
81  }
82  return out
83}
84
85/** `k=v,k2=v2`, values percent-decoded, as the OTel spec writes OTEL_EXPORTER_OTLP_HEADERS. */
86export function parseOtlpHeaders(raw: string | undefined): Record<string, string> {
87  const headers: Record<string, string> = {}
88  for (const pair of (raw ?? '').split(',')) {
89    const at = pair.indexOf('=')
90    if (at <= 0) continue
91    try {
92      headers[pair.slice(0, at).trim()] = decodeURIComponent(pair.slice(at + 1).trim())
93    } catch {
94      headers[pair.slice(0, at).trim()] = pair.slice(at + 1).trim()
95    }
96  }
97  return headers
98}
99
100/** Where traces go, or null when tracing is off or unconfigured. OTEL_* first, then LANGFUSE_*. */
101export function traceConfig(env: TraceEnv): TraceConfig | null {
102  if ((env.FOUNDRY_OTEL ?? '').toLowerCase() === 'off') return null
103  const toolIo = ![env.FOUNDRY_OTEL_TOOL_IO, env.PALANTIR_AGENT_OTEL_TOOL_IO].some(value => (value ?? '').toLowerCase() === 'off')
104  const exact = env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT?.trim()
105  const base = env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim()
106  let url = exact || (base ? `${base.replace(/\/$/, '')}/v1/traces` : '')
107  let headers = parseOtlpHeaders(env.OTEL_EXPORTER_OTLP_HEADERS)
108  const langfuse = (env.LANGFUSE_BASE_URL || env.LANGFUSE_HOST)?.trim()
109  if (url === '' && langfuse && env.LANGFUSE_PUBLIC_KEY && env.LANGFUSE_SECRET_KEY) {
110    url = `${langfuse.replace(/\/$/, '')}/api/public/otel/v1/traces`
111    headers = { Authorization: `Basic ${base64(`${env.LANGFUSE_PUBLIC_KEY}:${env.LANGFUSE_SECRET_KEY}`)}` }
112  }
113  if (!/^https?:\/\//.test(url)) return null
114  const host = /^https?:\/\/([^/?#]+)/.exec(url)?.[1]?.replace(/^[^@]*@/, '') ?? ''
115  return { url, headers: { ...headers, 'Content-Type': 'application/json' }, host, toolIo }
116}
117
118/** The OTel resource: service name and OTEL_RESOURCE_ATTRIBUTES (k=v,…), plus the mod's own. */
119export function resourceAttributes(env: TraceEnv, profile: string | null): Record<string, string> {
120  return {
121    'service.name': env.OTEL_SERVICE_NAME?.trim() || 'claude-foundry',
122    ...parseOtlpHeaders(env.OTEL_RESOURCE_ATTRIBUTES),
123    'agent.harness': 'claude-code',
124    ...(profile ? { 'palantir.agent.profile': profile } : {}),
125  }
126}
127
128/** The Langfuse public API base for a trace URL that ends in /api/public/otel/v1/traces; null for other collectors. */
129export function langfuseApiBase(config: TraceConfig): string | null {
130  const match = /^(.*\/api\/public)\/otel\/v1\/traces\/?$/.exec(config.url)
131  return match ? match[1]! : null
132}
133
134// Redaction: field names, then patterns, then URLs, before the 64 KiB cap,
135// so a secret can never straddle the cut.
136const FIELD_MAX_CHARS = 64 * 1024
137const REDACTED = '[REDACTED]'
138const SUBSTRING_KEY_TERMS = [
139  'token', 'secret', 'password', 'apikey', 'api_key', 'api-key', 'authorization', 'bearer', 'cookie',
140  'credential', 'privatekey', 'private_key', 'private-key', 'ssn', 'cardnumber', 'card_number', 'card-number',
141  'rid', 'org', 'tenant', 'space', 'host', 'cluster', 'node', 'vmid', 'ctid', 'amount', 'balance', 'price',
142]
143const EXACT_KEY_TERMS = new Set(['name', 'address', 'street', 'city'])
144const KEY_REDACTION_EXEMPT = new Set(['userid', 'user_id', 'sessionid', 'session_id'])
145
146export function isSensitiveKey(key: string): boolean {
147  const lowered = key.toLowerCase()
148  if (KEY_REDACTION_EXEMPT.has(lowered)) return false
149  return EXACT_KEY_TERMS.has(lowered) || SUBSTRING_KEY_TERMS.some(term => lowered.includes(term))
150}
151
152function redactUrls(value: string): string {
153  return value.replace(/(?<![a-z0-9+.-])([a-z][a-z0-9+.-]{0,30}:\/\/)([^\s"')\]]+)/gi, (_whole, scheme: string, rest: string) => {
154    let out = rest.replace(/^[^:@/?#]+(:[^@/?#]*)?@/, `${REDACTED}@`)
155    out = out.replace(/\?[^#\s]*/, `?${REDACTED}`)
156    out = out.replace(/(hooks\.slack\.com\/services\/)[^?\s]*/i, `$1${REDACTED}`)
157    out = out.replace(/(\/)[A-Za-z0-9_-]{20,}(?=\/|$)/g, `$1${REDACTED}`)
158    return scheme + out
159  })
160}
161
162// Token prefixes match without a leading boundary: a token glued to other text still goes.
163const REDACTION_PATTERNS: RegExp[] = [
164  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g,
165  /sk-(proj|ant|svcacct)-[A-Za-z0-9_-]{20,}/g,
166  /[sr]k_live_[A-Za-z0-9]{20,}\b/g,
167  /github_pat_[A-Za-z0-9_]{22,}\b/g,
168  /gh[pousr]_[A-Za-z0-9]{36,}\b/g,
169  /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/g,
170  /A[KS]IA[0-9A-Z]{16}\b/g,
171  /xox[baprs]-[A-Za-z0-9-]{10,}\b/g,
172  /Bearer\s+[A-Za-z0-9._~+/-]+=*/gi,
173  /(?<![A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,24}/g,
174  /\b\d{3}-\d{2}-\d{4}\b/g,
175  /\b[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{4}[- ]?[0-9]{4}\b/g,
176  /\b(?:10|100)\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g,
177  /\b(?:172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b/g,
178  /(?<![A-Za-z0-9.-])[A-Za-z0-9][A-Za-z0-9.-]{0,252}\.(?:local|internal|lan|corp)\b/gi,
179  /\+?[0-9][0-9 .-]{7,}[0-9]/g,
180  /\bBasic\s+[A-Za-z0-9+/=]{8,}\b/gi,
181  /npm_[A-Za-z0-9]{30,}\b/g,
182  /\bsk-[A-Za-z0-9_-]{16,}\b/g,
183  /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{0,40}(password|passwd|pwd|token|secret|api[-_]?key|private[-_]?key|credential)[A-Za-z0-9_-]{0,40}["']?\s*[=:]\s*["']?[^\s&"',}]{4,}/gi,
184  /\b(pk|sk)-lf-[A-Za-z0-9-]{6,}/g,
185  /Authorization["']?\s*[=:]\s*["']?(Basic|Bearer)?(\s|%20)*[A-Za-z0-9._~+/=%-]{6,}/gi,
186  /Basic%20[A-Za-z0-9+/=%]{8,}/gi,
187  /(\s-u\s+|--user[=\s]+)\S+:\S+/g,
188  /\bri\.[a-z0-9-]+\.[a-z0-9-]*\.[A-Za-z0-9._-]+/g,
189]
190
191const SECRET_FLAG = /^--?(password|passwd|token|secret|api-?key|key|auth|credential|private-key)$/i
192
193/** An args array with the value after a secret-bearing flag (and --flag=value) redacted. */
194export function redactArgs(args: readonly unknown[]): unknown[] {
195  return args.map((arg, index) => {
196    if (typeof arg !== 'string') return arg
197    const joined = /^(--?[A-Za-z-]+)=/.exec(arg)
198    if (joined && SECRET_FLAG.test(joined[1]!)) return `${joined[1]}=${REDACTED}`
199    const previous = args[index - 1]
200    return typeof previous === 'string' && SECRET_FLAG.test(previous) ? REDACTED : arg
201  })
202}
203
204/** Extra hostname suffixes to redact (FOUNDRY_OTEL_REDACT_DOMAINS, comma-separated). */
205export function domainPatterns(raw: string | undefined): RegExp[] {
206  return (raw ?? '')
207    .split(',')
208    .map(domain => domain.trim().replace(/^\.+/, ''))
209    .filter(domain => /^[A-Za-z0-9.-]+$/.test(domain))
210    .map(domain => new RegExp(`(?<![A-Za-z0-9.-])[A-Za-z0-9][A-Za-z0-9.-]{0,252}\\.${domain.replace(/\./g, '\\.')}\\b`, 'gi'))
211}
212
213export function redactString(value: string, extra: readonly RegExp[] = []): string {
214  let out = redactUrls(value)
215  for (const pattern of [...REDACTION_PATTERNS, ...extra]) out = out.replace(pattern, REDACTED)
216  return out
217}
218
219const cap = (value: string) => (value.length > FIELD_MAX_CHARS ? `${value.slice(0, FIELD_MAX_CHARS)}[TRUNCATED]` : value)
220
221/** Redacted, capped JSON for one attribute; undefined when the value cannot be serialized. */
222export function serializePayload(value: unknown, extra: readonly RegExp[] = []): string | undefined {
223  if (value === undefined || value === null) return undefined
224  try {
225    const json = JSON.stringify(value, (key, item: unknown) => {
226      if (key && isSensitiveKey(key)) return REDACTED
227      if (Array.isArray(item)) return redactArgs(item)
228      if (typeof item !== 'string') return item
229      // A JSON document carried as a string (an Orthanc envelope) is redacted by key too.
230      if (/^\s*[[{]/.test(item)) {
231        let parsed: unknown
232        try {
233          parsed = JSON.parse(item)
234        } catch {}
235        if (typeof parsed === 'object' && parsed !== null) {
236          const nested = serializePayload(parsed, extra)
237          try {
238            return JSON.parse(nested ?? 'null')
239          } catch {
240            return nested
241          }
242        }
243      }
244      return redactString(item, extra)
245    })
246    return json === undefined ? undefined : cap(json)
247  } catch {
248    return undefined
249  }
250}
251
252export type SpanRecord = {
253  spanId: string
254  parentSpanId?: string
255  name: string
256  startMs: number
257  endMs: number
258  attributes: Record<string, string | number | boolean | string[]>
259  error?: string
260}
261
262export function randomHex(length: number): string {
263  let out = ''
264  while (out.length < length) out += Math.floor(Math.random() * 0x100000000).toString(16).padStart(8, '0')
265  return out.slice(0, length)
266}
267
268function attributeValue(value: string | number | boolean | string[]) {
269  if (Array.isArray(value)) return { arrayValue: { values: value.map(item => ({ stringValue: item })) } }
270  if (typeof value === 'number') return Number.isInteger(value) ? { intValue: value } : { doubleValue: value }
271  if (typeof value === 'boolean') return { boolValue: value }
272  return { stringValue: value }
273}
274
275const attributes = (record: Record<string, string | number | boolean | string[]>) =>
276  Object.entries(record).map(([key, value]) => ({ key, value: attributeValue(value) }))
277
278const nanos = (ms: number) => `${Math.round(ms)}000000`
279
280/** One trace as an OTLP/JSON ExportTraceServiceRequest body. */
281export function otlpBody(traceId: string, resource: Record<string, string>, spans: readonly SpanRecord[]): string {
282  return JSON.stringify({
283    resourceSpans: [
284      {
285        resource: { attributes: attributes(resource) },
286        scopeSpans: [
287          {
288            scope: { name: 'claude-foundry' },
289            spans: spans.map(span => ({
290              traceId,
291              spanId: span.spanId,
292              ...(span.parentSpanId ? { parentSpanId: span.parentSpanId } : {}),
293              name: span.name,
294              kind: 1,
295              startTimeUnixNano: nanos(span.startMs),
296              endTimeUnixNano: nanos(Math.max(span.endMs, span.startMs)),
297              attributes: attributes(span.attributes),
298              status: span.error ? { code: 2, message: span.error.slice(0, 500) } : { code: 1 },
299            })),
300          },
301        ],
302      },
303    ],
304  })
305}
306
types/index.d.ts 76 lines
1export type FoundryNamed = { rid: string; name: string }
2
3export type FoundryObjectType = { apiName: string; displayName: string; rid?: string }
4
5/** The scope keys that hold selected Foundry resources. */
6export type FoundryResourceKind =
7  | 'folders'
8  | 'datasets'
9  | 'mediaSets'
10  | 'mediaItems'
11  | 'repositories'
12  | 'contourAnalyses'
13  | 'sources'
14  | 'developerConsoles'
15  | 'aipChatbots'
16  | 'objectInstances'
17  | 'functions'
18  | 'languageModels'
19  | 'permissions'
20  | 'searchResults'
21
22/** One selected resource: its RID (or key), display name and a path or type hint. */
23export type FoundryResource = { rid: string; name: string; hint: string }
24
25export type FoundryScope = {
26  profile: string
27  host: string
28  ontology: FoundryNamed | null
29  projects: FoundryNamed[]
30  objectTypes: FoundryObjectType[]
31  actionTypes: FoundryObjectType[]
32  resources: Partial<Record<FoundryResourceKind, FoundryResource[]>>
33  updatedAt: string
34}
35
36export type FoundryPickItem = { key: string; label: string; hint: string }
37
38export type FoundryPickerKind = 'profile' | 'ontology' | 'project' | 'object-type' | 'action-type' | 'resource'
39
40export type FoundryPicker = {
41  kind: FoundryPickerKind
42  /** The scope key a 'resource' picker writes. */
43  resourceKind?: FoundryResourceKind
44  title: string
45  isMulti: boolean
46  items: FoundryPickItem[]
47  chosen: string[]
48  filter: string
49  /** Pickers to open after this one, in order (the /open guided flow). */
50  chain: FoundryPickerKind[]
51}
52
53/** /fde: a session bound to one agent global branch. */
54export type FdeSession = {
55  goal: string
56  profile: string
57  ontologyRid: string
58  branchRid: string
59  branchName: string
60  startedAt: string
61  /** Signatures of clean dry runs; an --apply must match one. */
62  plans: string[]
63  applied: { command: string; at: string }[]
64}
65
66declare module 'claude-code' {
67  interface PluginState {
68    foundry: {
69      scope: FoundryScope
70      picker: FoundryPicker | null
71      workingBranch: string | null
72      fde: FdeSession | null
73    }
74  }
75}
76