portable-agent-layer 0.110.0 → 0.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -88,6 +88,7 @@ pal cli status # check your setup
88
88
  | `pal cli cost` | What each month was paid against its API-equivalent cost, per agent |
89
89
  | `pal cli wrapped [--year <yyyy>]` | A year of your work in numbers, read from the event log |
90
90
  | `pal cli patterns` | How you work across projects, read from the event log |
91
+ | `pal cli work [show\|list\|open\|enter\|park\|wait\|done\|stopped\|decide\|attach\|follow-up]` | The work tree: what you are on in this project, where it stopped, what is parked or waiting, and the decisions and artifacts on each node. Detours are child nodes and `done` returns to the parent. With `dynamicContext.workTree` opted into, the active node rides along in every session and prompt, and your own commits attach to it at turn end |
91
92
  | `pal cli content preserve` | Copy every Claude Code transcript, gzipped, into `memory/content/` before Claude Code deletes it (30 days by default). Runs on its own whenever a session closes; the content store never leaves the machine, so `export` skips it |
92
93
  | `pal cli events discord-nag <sent\|answered>` | For a bot outside PAL: record a nag it sent (`--kind <kind>`), or the answer to one and how long it took (`--after-sec <n>`), in the local event log |
93
94
  | `pal cli infer` | One model call for scripts and bots, on the active agent's route: prompt on stdin, reply on stdout. `--tier small\|medium\|large\|flagship` (on Claude: Haiku, Sonnet, Opus, Fable; on Codex: Luna, Sol, Sol at high effort, Astra; on Antigravity: Flash at low, medium and high effort, and no flagship; an agent without the tier asked refuses it), `--system <file>`, `--schema <file>` for a JSON reply checked against it, `--timeout <seconds>`. The call loads no PAL hooks or context; exits 1 with the reason when no route answers |
@@ -74,6 +74,14 @@
74
74
  "type": "boolean",
75
75
  "description": "One line with every prompt saying whether the work in the current directory is committed, pushed and released; a registered project without git is named as such."
76
76
  },
77
+ "workTree": {
78
+ "type": "boolean",
79
+ "description": "Opt-in, off by default until 1.0. The work tree (pal cli work): the active node, the detours it sits inside, parked work and what waits on the user at session start and after compaction, one line of it with every prompt, and new commits and branches attached to the active node at the end of a turn."
80
+ },
81
+ "workTreeGate": {
82
+ "type": "boolean",
83
+ "description": "Opt-in, off by default until 1.0. Send a turn back when it committed, made a branch, opened a PR or published an artifact without recording it in the work tree (pal cli work). Needs workTree."
84
+ },
77
85
  "dueReminders": {
78
86
  "type": "boolean",
79
87
  "description": "Opt-in, off by default until 1.0. Due maintenance reminders (learning analysis, relationship reflect) with every prompt until a reply names their command; then quiet until the next day."
@@ -23,6 +23,7 @@ Research the company or person named in $ARGUMENTS and show a sourced profile. T
23
23
  1. Work out from $ARGUMENTS and the conversation:
24
24
  - the exact name;
25
25
  - whether it is a company or a person;
26
+ - for a company, its registered name with the legal form, not a brand or short name. The legal form is what routes it to its country's company register;
26
27
  - for a person, where they work;
27
28
  - one sentence of what the user said about them.
28
29
  If you cannot tell a company from a person, ask.
@@ -34,6 +35,11 @@ Research the company or person named in $ARGUMENTS and show a sourced profile. T
34
35
  ```bash
35
36
  pal cli knowledge research "<name>" --person --organization "<employer>" --context "<what the user said>"
36
37
  ```
38
+ When the legal form places the company in a country PAL has a register for, the command first reads that register directly, because web search does not index it. An exact registered-name match fills in the registration number, tax number, seat, main activity, managing directors and status, and the profile is kept even when the web finds nothing else. A brand name, a similar name, or a company from a country without a register goes through web search alone.
39
+
40
+ | Country | Legal forms | Register read |
41
+ | ------- | ----------- | ------------- |
42
+ | Hungary | Kft., Zrt., Nyrt., Bt., Kkt., or spelled out | ceginfo.hu for the registration number, nemzeticegtar.hu for the record |
37
43
  It takes one to several minutes. Add `--queue` when the user wants the profile saved; it then waits in the review queue.
38
44
  3. If the command exits 1, tell the user the reason it printed. Usual reasons:
39
45
  - the model found only namesakes (`match: unsure`): ask for something that tells them apart, such as an employer, a city or a website, and run it again with that in `--context` or `--organization`;
@@ -56,6 +56,7 @@ Start your response with the following header in this mode:
56
56
  - **Mandatory output format** — Every response MUST use exactly one of the output formats above (ALGORITHM, NATIVE, or MINIMAL). No freeform output.
57
57
  - **Response format before questions** — Always complete the current response format output FIRST, then invoke AskUserQuestion at the end.
58
58
  - **Named project → resume first** — Whenever the user references a project by name in ANY mode, run `pal cli project resume <slug>` before any file search or task execution. The project record has paths, context, and handoff notes that make manual searching unnecessary.
59
+ {{IF workTree}}- **Keep the work tree true** — Record work in the tree with `pal cli work` as it happens, not at the end: `open` a node for new work (`--root` when it is unrelated), update `stopped` when the state moves, `decide` when a choice is settled, `attach` what the work produced, `done` when it is finished. When the user switches topic mid-work, `park --stopped "…"` the current node before starting the new one. Once a scope has work, the `Work:` line in each prompt shows where you are.
59
60
 
60
61
  ---
61
62
 
@@ -27,6 +27,8 @@
27
27
  "skillMatching": true,
28
28
  "wallClock": true,
29
29
  "repoState": true,
30
+ "workTree": false,
31
+ "workTreeGate": false,
30
32
  "dueReminders": false,
31
33
  "interactionAwareness": true,
32
34
  "reactionSampling": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.110.0",
3
+ "version": "0.112.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,6 +3,7 @@
3
3
  * the active agent, print the profile, and optionally queue it for review.
4
4
  */
5
5
 
6
+ import { lookupCompanyRegister } from "../hooks/lib/company-register-router";
6
7
  import { loadKnownEntities, loadNameIndex } from "../hooks/lib/entity-cards";
7
8
  import { queueForReview } from "../hooks/lib/entity-extraction";
8
9
  import { lookupName } from "../hooks/lib/entity-names";
@@ -46,7 +47,11 @@ function targetFrom(name: string, values: ResearchValues): ResearchTarget {
46
47
  export async function researchFromCli(
47
48
  name: string | undefined,
48
49
  values: ResearchValues,
49
- deps: ResearchDeps = { principal: identity().principal.name, infer: inference }
50
+ deps: ResearchDeps = {
51
+ principal: identity().principal.name,
52
+ infer: inference,
53
+ registry: lookupCompanyRegister,
54
+ }
50
55
  ): Promise<number> {
51
56
  if (!name?.trim()) throw new UsageError("name the person or company to research");
52
57
  const target = targetFrom(name.trim(), values);
package/src/cli/tree.ts CHANGED
@@ -30,6 +30,7 @@ import { serverCommand } from "./server";
30
30
  import { skillCommand } from "./skill";
31
31
  import { subagentCommand } from "./subagent";
32
32
  import { usageCommand } from "./usage";
33
+ import { workCommand } from "./work";
33
34
  import { wrappedCommand } from "./wrapped";
34
35
 
35
36
  type Handler = Leaf["run"];
@@ -171,6 +172,7 @@ export function cliTree(admin: AdminHandlers): Group {
171
172
  ledger: ledgerCommand,
172
173
  rule: ruleCommand,
173
174
  correction: correctionCommand,
175
+ work: workCommand,
174
176
  server: serverCommand,
175
177
  skill: skillCommand,
176
178
  subagent: subagentCommand,
@@ -0,0 +1,344 @@
1
+ /**
2
+ * pal cli work — the work tree: what is active, what is parked and where it
3
+ * stopped, the decisions and artifacts of each piece of work.
4
+ */
5
+
6
+ import {
7
+ ARTIFACT_KINDS,
8
+ type ArtifactKind,
9
+ appendEvent,
10
+ findNode,
11
+ newNodeId,
12
+ readTree,
13
+ type ScopeTree,
14
+ scopeOf,
15
+ type WorkNode,
16
+ } from "../hooks/lib/work-tree";
17
+ import {
18
+ renderNodeDetail,
19
+ renderSessionContext,
20
+ renderTreeList,
21
+ suffix,
22
+ } from "../hooks/lib/work-tree-render";
23
+ import { group, leaf, runCommand, UsageError } from "../tools/lib/command";
24
+
25
+ const SCOPE = {
26
+ type: "string",
27
+ description: "Project or folder the work belongs to (default: the current one)",
28
+ } as const;
29
+ const NODE = {
30
+ type: "string",
31
+ description: "Node id or unambiguous prefix (default: the active node)",
32
+ } as const;
33
+
34
+ class NodeError extends Error {}
35
+
36
+ function scopeFrom(values: { scope?: string }): string {
37
+ return values.scope ?? scopeOf(process.cwd());
38
+ }
39
+
40
+ function requireText(text: string | undefined, what: string): string {
41
+ if (!text?.trim()) throw new UsageError(`${what} is required`);
42
+ return text.trim();
43
+ }
44
+
45
+ function targetNode(tree: ScopeTree, idOrPrefix: string | undefined): WorkNode {
46
+ if (idOrPrefix) {
47
+ const node = findNode(tree, idOrPrefix);
48
+ if (!node)
49
+ throw new NodeError(`No single node matches "${idOrPrefix}" in ${tree.scope}`);
50
+ return node;
51
+ }
52
+ const active = tree.active ? tree.nodes.get(tree.active) : undefined;
53
+ if (!active)
54
+ throw new NodeError(
55
+ `No active node in ${tree.scope}; pass --node, or open or enter one first`
56
+ );
57
+ return active;
58
+ }
59
+
60
+ function guarded(run: () => void): number {
61
+ try {
62
+ run();
63
+ return 0;
64
+ } catch (err) {
65
+ if (!(err instanceof NodeError)) throw err;
66
+ console.error(err.message);
67
+ return 1;
68
+ }
69
+ }
70
+
71
+ function record<T extends Parameters<typeof appendEvent>[2]>(
72
+ values: { scope?: string; node?: string },
73
+ type: T,
74
+ payload: Parameters<typeof appendEvent<T>>[3],
75
+ done: (node: WorkNode) => string
76
+ ): number {
77
+ return guarded(() => {
78
+ const scope = scopeFrom(values);
79
+ const node = targetNode(readTree(scope), values.node);
80
+ appendEvent(scope, node.id, type, payload);
81
+ console.log(done(node));
82
+ });
83
+ }
84
+
85
+ function parentFor(tree: ScopeTree, under: string | undefined, root: boolean) {
86
+ if (root) return null;
87
+ if (under) return targetNode(tree, under).id;
88
+ return tree.active;
89
+ }
90
+
91
+ function optionalIsc(isc: string | undefined): number | undefined {
92
+ if (isc === undefined) return undefined;
93
+ const parsed = Number(isc);
94
+ if (!Number.isInteger(parsed) || parsed < 1)
95
+ throw new UsageError(`--isc must be an ISC number, got "${isc}"`);
96
+ return parsed;
97
+ }
98
+
99
+ function cmdOpen(
100
+ title: string | undefined,
101
+ values: {
102
+ scope?: string;
103
+ under?: string;
104
+ root?: boolean;
105
+ isc?: string;
106
+ commitment?: string;
107
+ }
108
+ ): number {
109
+ const text = requireText(title, "A title");
110
+ const isc = optionalIsc(values.isc);
111
+ return guarded(() => {
112
+ const scope = scopeFrom(values);
113
+ const tree = readTree(scope);
114
+ const parent = parentFor(tree, values.under, values.root === true);
115
+ const id = newNodeId();
116
+ const links = {
117
+ ...(isc !== undefined && { isc }),
118
+ ...(values.commitment && { commitment: values.commitment }),
119
+ };
120
+ appendEvent(scope, id, "open", { title: text, parent, links });
121
+ const under = parent ? ` under ${tree.nodes.get(parent)?.title ?? parent}` : "";
122
+ console.log(`Opened ${text} [${id}]${under}; it is now active.`);
123
+ });
124
+ }
125
+
126
+ function backTo(scope: string): string {
127
+ const tree = readTree(scope);
128
+ const active = tree.active ? tree.nodes.get(tree.active) : undefined;
129
+ if (!active) return "No node is active now.";
130
+ return `Back to ${active.title} [${active.id}]${suffix(", stopped at: ", active.stoppedAt)}.`;
131
+ }
132
+
133
+ function cmdDone(values: { scope?: string; node?: string; summary?: string }): number {
134
+ return guarded(() => {
135
+ const scope = scopeFrom(values);
136
+ const node = targetNode(readTree(scope), values.node);
137
+ appendEvent(
138
+ scope,
139
+ node.id,
140
+ "done",
141
+ values.summary ? { summary: values.summary } : {}
142
+ );
143
+ console.log(`Done: ${node.title} [${node.id}]. ${backTo(scope)}`);
144
+ });
145
+ }
146
+
147
+ function artifactKind(kind: string | undefined): ArtifactKind {
148
+ if (kind && (ARTIFACT_KINDS as readonly string[]).includes(kind))
149
+ return kind as ArtifactKind;
150
+ throw new UsageError(
151
+ `kind must be one of ${ARTIFACT_KINDS.join(", ")}, got "${kind ?? ""}"`
152
+ );
153
+ }
154
+
155
+ function cmdShow(values: { scope?: string; node?: string; json?: boolean }): number {
156
+ return guarded(() => {
157
+ const tree = readTree(scopeFrom(values));
158
+ if (!values.node && !tree.active) {
159
+ console.log(
160
+ renderSessionContext(tree) || `No work recorded for ${tree.scope} yet.`
161
+ );
162
+ return;
163
+ }
164
+ const node = targetNode(tree, values.node);
165
+ console.log(
166
+ values.json ? JSON.stringify(node, null, 2) : renderNodeDetail(tree, node)
167
+ );
168
+ });
169
+ }
170
+
171
+ function cmdList(values: { scope?: string; all?: boolean; json?: boolean }): number {
172
+ const tree = readTree(scopeFrom(values));
173
+ if (values.json) {
174
+ console.log(JSON.stringify({ ...tree, nodes: [...tree.nodes.values()] }, null, 2));
175
+ return 0;
176
+ }
177
+ console.log(renderTreeList(tree, values.all === true));
178
+ return 0;
179
+ }
180
+
181
+ export const workCommand = group({
182
+ summary: "The work tree: what is active, parked, waiting, and where each piece stopped",
183
+ fallback: "show",
184
+ commands: {
185
+ show: leaf({
186
+ summary: "The active node in full, or the whole picture when none is active",
187
+ options: {
188
+ scope: SCOPE,
189
+ node: NODE,
190
+ json: { type: "boolean", description: "As JSON" },
191
+ },
192
+ run: ({ values }) => cmdShow(values),
193
+ }),
194
+ list: leaf({
195
+ summary: "Every open node as a tree",
196
+ options: {
197
+ scope: SCOPE,
198
+ all: { type: "boolean", description: "Include finished nodes" },
199
+ json: { type: "boolean", description: "As JSON" },
200
+ },
201
+ run: ({ values }) => cmdList(values),
202
+ }),
203
+ open: leaf({
204
+ summary: "Open a node and make it active (a child of the active node by default)",
205
+ args: "<title...>",
206
+ options: {
207
+ scope: SCOPE,
208
+ under: { type: "string", description: "Parent node id or prefix" },
209
+ root: {
210
+ type: "boolean",
211
+ description: "Open at the top level, not under the active node",
212
+ },
213
+ isc: { type: "string", description: "The project ISC this node works on" },
214
+ commitment: {
215
+ type: "string",
216
+ description: "The commitment id this node fulfils",
217
+ },
218
+ },
219
+ run: ({ positionals, values }) => cmdOpen(positionals.join(" "), values),
220
+ }),
221
+ enter: leaf({
222
+ summary: "Make a node active, parking the one that was",
223
+ args: "<node>",
224
+ options: { scope: SCOPE },
225
+ run: ({ positionals, values }) =>
226
+ record(
227
+ { ...values, node: requireText(positionals[0], "A node") },
228
+ "enter",
229
+ {},
230
+ (n) => `Entered ${n.title} [${n.id}]${suffix(", stopped at: ", n.stoppedAt)}.`
231
+ ),
232
+ }),
233
+ park: leaf({
234
+ summary: "Park a node, saying where it stopped",
235
+ options: {
236
+ scope: SCOPE,
237
+ node: NODE,
238
+ stopped: { type: "string", description: "Where it stopped, to resume from" },
239
+ },
240
+ run: ({ values }) =>
241
+ record(
242
+ values,
243
+ "park",
244
+ values.stopped ? { stoppedAt: values.stopped } : {},
245
+ (n) => `Parked ${n.title} [${n.id}].`
246
+ ),
247
+ }),
248
+ wait: leaf({
249
+ summary: "Mark a node as waiting on the user",
250
+ args: "<waits-on...>",
251
+ options: { scope: SCOPE, node: NODE },
252
+ run: ({ positionals, values }) =>
253
+ record(
254
+ values,
255
+ "wait",
256
+ { on: requireText(positionals.join(" "), "What it waits on") },
257
+ (n) => `${n.title} [${n.id}] is waiting on the user.`
258
+ ),
259
+ }),
260
+ done: leaf({
261
+ summary: "Finish a node; a finished detour hands the work back to its parent",
262
+ options: {
263
+ scope: SCOPE,
264
+ node: NODE,
265
+ summary: { type: "string", description: "One line on what it ended with" },
266
+ },
267
+ run: ({ values }) => cmdDone(values),
268
+ }),
269
+ stopped: leaf({
270
+ summary: "Record where the work stands now",
271
+ args: "<text...>",
272
+ options: { scope: SCOPE, node: NODE },
273
+ run: ({ positionals, values }) =>
274
+ record(
275
+ values,
276
+ "stopped",
277
+ { text: requireText(positionals.join(" "), "Where it stopped") },
278
+ (n) => `Stopped-at recorded on ${n.title} [${n.id}].`
279
+ ),
280
+ }),
281
+ decide: leaf({
282
+ summary: "Record a decision on a node",
283
+ args: "<decision...>",
284
+ options: {
285
+ scope: SCOPE,
286
+ node: NODE,
287
+ why: { type: "string", description: "The reason" },
288
+ },
289
+ run: ({ positionals, values }) =>
290
+ record(
291
+ values,
292
+ "decide",
293
+ {
294
+ text: requireText(positionals.join(" "), "A decision"),
295
+ ...(values.why && { why: values.why }),
296
+ },
297
+ (n) => `Decision recorded on ${n.title} [${n.id}].`
298
+ ),
299
+ }),
300
+ attach: leaf({
301
+ summary: `Attach an artifact to a node (${ARTIFACT_KINDS.join(", ")})`,
302
+ args: "<kind> <ref...>",
303
+ options: {
304
+ scope: SCOPE,
305
+ node: NODE,
306
+ label: { type: "string", description: "What it is" },
307
+ },
308
+ run: ({ positionals, values }) =>
309
+ record(
310
+ values,
311
+ "attach",
312
+ {
313
+ kind: artifactKind(positionals[0]),
314
+ ref: requireText(positionals.slice(1).join(" "), "A ref"),
315
+ ...(values.label && { label: values.label }),
316
+ },
317
+ (n) => `Attached to ${n.title} [${n.id}].`
318
+ ),
319
+ }),
320
+ "follow-up": leaf({
321
+ summary: "Record a follow-up on a node",
322
+ args: "<text...>",
323
+ options: {
324
+ scope: SCOPE,
325
+ node: NODE,
326
+ due: { type: "string", description: "When it is due, such as 2026-10-25" },
327
+ },
328
+ run: ({ positionals, values }) =>
329
+ record(
330
+ values,
331
+ "follow-up",
332
+ {
333
+ text: requireText(positionals.join(" "), "A follow-up"),
334
+ ...(values.due && { due: values.due }),
335
+ },
336
+ (n) => `Follow-up recorded on ${n.title} [${n.id}].`
337
+ ),
338
+ }),
339
+ },
340
+ });
341
+
342
+ export function runWork(args: string[]): Promise<number> {
343
+ return runCommand(workCommand, args, ["pal", "cli", "work"]);
344
+ }
@@ -20,6 +20,7 @@ import { logError } from "./lib/log";
20
20
  import { isPalSpawnedInference } from "./lib/spawn-guard";
21
21
  import { readStdinJSON } from "./lib/stdin";
22
22
  import { type StopTurnPayload, stopTurn } from "./lib/stop";
23
+ import { stopWorkTree } from "./lib/work-tree-gate";
23
24
 
24
25
  // Recursion guard — spawned inference subprocesses must not record session
25
26
  // learning, ratings, or handoffs from their throwaway transcript.
@@ -62,4 +63,13 @@ try {
62
63
  } catch (err) {
63
64
  logError("StopOrchestrator:adaptation-rules", err);
64
65
  }
66
+ try {
67
+ const sendBack = stopWorkTree(payload);
68
+ if (sendBack) {
69
+ process.stdout.write(stopBlockResponse(sendBack));
70
+ process.exit(0);
71
+ }
72
+ } catch (err) {
73
+ logError("StopOrchestrator:work-tree", err);
74
+ }
65
75
  await stopTurn(payload);
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { ingestEntities } from "../../tools/knowledge/ingest";
9
+ import { lookupCompanyRegister } from "../lib/company-register-router";
9
10
  import { excludedNames, loadKnownEntities, loadNameIndex } from "../lib/entity-cards";
10
11
  import {
11
12
  extractionSchema,
@@ -69,6 +70,7 @@ export async function extractSessionEntities(
69
70
  await researchNewEntities(newResearchTargets(plan.ingest, written), {
70
71
  principal: id.principal.name,
71
72
  infer: inference,
73
+ registry: lookupCompanyRegister,
72
74
  });
73
75
  }
74
76
 
@@ -22,6 +22,7 @@ import { isEnabled } from "../lib/settings";
22
22
  import { getSkillReminder } from "../lib/skill-match";
23
23
  import { getSteeringReminder } from "../lib/steering";
24
24
  import { getWallClockReminder } from "../lib/wall-clock";
25
+ import { workTreePromptLine } from "../lib/work-tree-context";
25
26
 
26
27
  const BUDGET_MS = 250;
27
28
 
@@ -88,6 +89,7 @@ export async function getPromptContext(
88
89
  getWallClockReminder(),
89
90
  userText && withinBudget(() => promptRulesReminder(userText, sessionId), BUDGET_MS),
90
91
  withinBudget(() => getRepoStateReminder(), BUDGET_MS),
92
+ withinBudget(() => workTreePromptLine(userText), BUDGET_MS),
91
93
  userText && withinBudget(() => observeTurn(userText, sessionId), BUDGET_MS),
92
94
  withinBudget(() => dueNudgeReminder(), BUDGET_MS),
93
95
  userText && getSteeringReminder(userText),
@@ -139,21 +139,22 @@ function isUserPrompt(entry: TranscriptLine): boolean {
139
139
 
140
140
  const ran = (input: unknown) => JSON.stringify(input ?? {});
141
141
 
142
- function commandsIn(entry: TranscriptLine): string[] {
142
+ function callsIn(entry: TranscriptLine, tools: ReadonlySet<string>): string[] {
143
143
  if (entry.type === "PLANNER_RESPONSE")
144
144
  return toolCallsOf(entry)
145
- .filter((call) => COMMAND_TOOLS.has(call.name ?? ""))
145
+ .filter((call) => tools.has(call.name ?? ""))
146
146
  .map((call) => ran(call.args));
147
147
  if (entry.type === "assistant")
148
148
  return blocks(entry.message?.content)
149
- .filter((b) => b.type === "tool_use" && COMMAND_TOOLS.has(b.name ?? ""))
149
+ .filter((b) => b.type === "tool_use" && tools.has(b.name ?? ""))
150
150
  .map((b) => ran(b.input));
151
151
  const call = entry.payload;
152
152
  if (entry.type !== "response_item" || !call) return [];
153
153
  const input = call.input ?? call.arguments ?? call.action;
154
- if (call.type === "local_shell_call") return [ran(input)];
154
+ if (call.type === "local_shell_call")
155
+ return tools === COMMAND_TOOLS ? [ran(input)] : [];
155
156
  const isCall = call.type === "function_call" || call.type === "custom_tool_call";
156
- return isCall && COMMAND_TOOLS.has(call.name ?? "") ? [ran(input)] : [];
157
+ return isCall && tools.has(call.name ?? "") ? [ran(input)] : [];
157
158
  }
158
159
 
159
160
  function isTurnEntry(entry: TranscriptLine): boolean {
@@ -162,17 +163,24 @@ function isTurnEntry(entry: TranscriptLine): boolean {
162
163
  );
163
164
  }
164
165
 
165
- /** What each command this turn ran. Null when no line reads as a turn, so an
166
- * unknown transcript format never counts as none. */
167
- export function commandsThisTurn(lines: string[]): string[] | null {
166
+ /** The input of each call this turn made to one of the tools. Null when no line reads
167
+ * as a turn, so an unknown transcript format never counts as none. */
168
+ export function callsThisTurn(
169
+ lines: string[],
170
+ tools: ReadonlySet<string>
171
+ ): string[] | null {
168
172
  const entries = lines.map(parsed).filter((e): e is TranscriptLine => e !== null);
169
173
  if (!entries.some(isTurnEntry)) return null;
170
- let commands: string[] = [];
174
+ let calls: string[] = [];
171
175
  for (const entry of entries) {
172
- if (isUserPrompt(entry)) commands = [];
173
- commands.push(...commandsIn(entry));
176
+ if (isUserPrompt(entry)) calls = [];
177
+ calls.push(...callsIn(entry, tools));
174
178
  }
175
- return commands;
179
+ return calls;
180
+ }
181
+
182
+ export function commandsThisTurn(lines: string[]): string[] | null {
183
+ return callsThisTurn(lines, COMMAND_TOOLS);
176
184
  }
177
185
 
178
186
  function isBacked(claim: string, commands: string[]): boolean {
@@ -40,7 +40,7 @@ function monthFile(date: Date): string {
40
40
  return resolve(claimsDir(), `${date.toISOString().slice(0, 7)}.jsonl`);
41
41
  }
42
42
 
43
- function transcriptLines(payload: StopTurnPayload): string[] {
43
+ export function transcriptLines(payload: StopTurnPayload): string[] {
44
44
  const path = payload.transcript_path ?? payload.transcriptPath;
45
45
  if (!path || !existsSync(path)) return [];
46
46
  return readFileSync(path, "utf-8").split("\n").filter(Boolean);
@@ -110,7 +110,16 @@ export function needsRebuild(): boolean {
110
110
  return latestMtime(...sources) > outputMtime;
111
111
  }
112
112
 
113
- import { identity } from "./settings";
113
+ import { identity, isOptedIn } from "./settings";
114
+
115
+ const OPT_IN_LINE_RE = /^\{\{IF (\w+)\}\}(.*\n?)/gm;
116
+
117
+ /** A `{{IF key}}` line stays, without its prefix, only when that setting is opted into. */
118
+ function withOptedInLines(template: string): string {
119
+ return template.replace(OPT_IN_LINE_RE, (_, key: string, line: string) =>
120
+ isOptedIn(key) ? line : ""
121
+ );
122
+ }
114
123
 
115
124
  /** Render AGENTS.md from the template using current state */
116
125
  export function buildClaudeMd(): string {
@@ -120,7 +129,7 @@ export function buildClaudeMd(): string {
120
129
 
121
130
  const id = identity();
122
131
 
123
- return template
132
+ return withOptedInLines(template)
124
133
  .replaceAll("{{IDENTITY_NAME}}", id.ai.name)
125
134
  .replaceAll("{{IDENTITY_DISPLAY}}", id.ai.displayName)
126
135
  .replaceAll("{{IDENTITY_CATCHPHRASE}}", id.ai.catchphrase)
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Official company registers, routed by country. A company is looked up only in the
3
+ * register of the country its name places it in; any other name goes to web search
4
+ * alone. To add a country, implement CompanyRegister in its own module and add it to
5
+ * REGISTERS.
6
+ */
7
+
8
+ import type { CompanyRegister, Fetch, RegisterEntry } from "./company-register";
9
+ import { hungarianRegister } from "./hungarian-registry";
10
+
11
+ const REGISTERS: CompanyRegister[] = [hungarianRegister];
12
+
13
+ /** Null when no register recognizes the name, and when its register has no exact match. */
14
+ export async function lookupCompanyRegister(
15
+ name: string,
16
+ fetchImpl: Fetch = fetch,
17
+ registers: CompanyRegister[] = REGISTERS
18
+ ): Promise<RegisterEntry | null> {
19
+ const wanted = name.trim();
20
+ const register = registers.find((r) => r.recognizes(wanted));
21
+ if (!register) return null;
22
+ const company = await register.lookup(wanted, fetchImpl);
23
+ return company && { ...company, country: register.country };
24
+ }
@@ -0,0 +1,25 @@
1
+ /** What a country's company register implements to join the routing table in company-register-router.ts. */
2
+
3
+ export interface RegisteredCompany {
4
+ name: string;
5
+ fullName: string;
6
+ number: string;
7
+ taxNumber: string;
8
+ seat: string;
9
+ status: string;
10
+ activity: string;
11
+ founded: string;
12
+ managers: string[];
13
+ url: string;
14
+ sources: string[];
15
+ }
16
+
17
+ export type RegisterEntry = RegisteredCompany & { country: string };
18
+
19
+ export type Fetch = (input: string, init?: RequestInit) => Promise<Response>;
20
+
21
+ export interface CompanyRegister {
22
+ country: string;
23
+ recognizes: (name: string) => boolean;
24
+ lookup: (name: string, fetchImpl: Fetch) => Promise<RegisteredCompany | null>;
25
+ }