@openshain/core 0.3.1 → 0.4.1

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.
@@ -1,8 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { isNode, LineCounter, parseDocument } from "yaml";
4
3
  import { OpenshainError } from "../errors.ts";
5
4
  import { type Config, ConfigFileSchema, toConfig } from "./schema.ts";
5
+ import { parseYamlFile } from "./yaml.ts";
6
6
 
7
7
  export const CONFIG_FILE_NAME = "openshain.yaml";
8
8
 
@@ -31,43 +31,8 @@ export async function loadConfig(
31
31
 
32
32
  export function parseConfig(text: string, options: ParseConfigOptions = {}): Config {
33
33
  const fileName = options.fileName ?? CONFIG_FILE_NAME;
34
- const lineCounter = new LineCounter();
35
- let doc: ReturnType<typeof parseDocument>;
36
- let data: unknown;
37
- try {
38
- doc = parseDocument(text, { lineCounter });
39
- data = doc.errors.length > 0 ? undefined : doc.toJS();
40
- } catch (cause) {
41
- // yaml refuses resource-exhaustion documents (alias bombs) with a plain error
42
- throw new OpenshainError("config", `${fileName}: ${(cause as Error).message}`, { cause });
43
- }
44
-
45
- if (doc.errors.length > 0) {
46
- const lines = doc.errors.map((error) => {
47
- const pos = error.linePos?.[0] ?? { line: 0, col: 0 };
48
- return `${fileName}:${pos.line}:${pos.col} ${firstLine(error.message)}`;
49
- });
50
- throw new OpenshainError("config", lines.join("\n"));
51
- }
52
-
53
- const locate = (path: readonly PropertyKey[]): { line: number; col: number } => {
54
- for (let i = path.length; i >= 0; i--) {
55
- const node = i === 0 ? doc.contents : doc.getIn(path.slice(0, i), true);
56
- if (isNode(node) && node.range) return lineCounter.linePos(node.range[0]);
57
- }
58
- return { line: 1, col: 1 };
59
- };
60
- const problem = (path: readonly PropertyKey[], message: string): string => {
61
- const { line, col } = locate(path);
62
- const where = path.length === 0 ? "<root>" : path.map(String).join(".");
63
- return `${fileName}:${line}:${col} ${where}: ${message}`;
64
- };
65
-
66
- const result = ConfigFileSchema.safeParse(data);
67
- if (!result.success) {
68
- const problems = result.error.issues.map((issue) => problem(issue.path, issue.message));
69
- throw new OpenshainError("config", problems.join("\n"));
70
- }
34
+ const { data, problem } = parseYamlFile(text, ConfigFileSchema, fileName);
35
+ const result = { data };
71
36
 
72
37
  const known = options.modelProviders;
73
38
  if (known && result.data.model && !known.includes(result.data.model.provider)) {
@@ -82,7 +47,3 @@ export function parseConfig(text: string, options: ParseConfigOptions = {}): Con
82
47
 
83
48
  return toConfig(result.data);
84
49
  }
85
-
86
- function firstLine(message: string): string {
87
- return message.split("\n", 1)[0] ?? message;
88
- }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { hostTimezone, isTimezone } from "../time.ts";
2
3
 
3
4
  const identifier = z
4
5
  .string()
@@ -43,6 +44,16 @@ export const ConfigFileSchema = z.strictObject({
43
44
  company: z.strictObject({
44
45
  name: z.string().min(1).max(200),
45
46
  language: z.enum(LANGUAGES).default("ja"),
47
+ // The company's own clock decides every business date, so a workspace answers the same
48
+ // whether it runs on a laptop in Tokyo or in a container set to UTC.
49
+ // No default in the schema: it would bake the machine that generated it into the published
50
+ // JSON Schema. The fallback is applied when the file is turned into a Config.
51
+ timezone: z
52
+ .string()
53
+ .min(1)
54
+ .max(100)
55
+ .refine(isTimezone, "not a timezone name, such as Asia/Tokyo")
56
+ .optional(),
46
57
  }),
47
58
  principal: z.strictObject({ id: identifier, name: z.string().min(1).max(200) }),
48
59
  profession: z.strictObject({ id: identifier, instructions: z.string().min(1).max(100_000) }),
@@ -96,7 +107,7 @@ export interface ModelConfig {
96
107
 
97
108
  export interface Config {
98
109
  version: 1;
99
- company: { name: string; language: Language };
110
+ company: { name: string; language: Language; timezone: string };
100
111
  principal: { id: string; name: string };
101
112
  profession: { id: string; instructions: string };
102
113
  /** The model the interactive CLI runs on. Absent when the workspace is used from other agents only. */
@@ -109,7 +120,11 @@ export interface Config {
109
120
  export function toConfig(file: ConfigFile): Config {
110
121
  return {
111
122
  version: file.version,
112
- company: { name: file.company.name, language: file.company.language },
123
+ company: {
124
+ name: file.company.name,
125
+ language: file.company.language,
126
+ timezone: file.company.timezone ?? hostTimezone(),
127
+ },
113
128
  principal: { id: file.principal.id, name: file.principal.name },
114
129
  profession: { id: file.profession.id, instructions: file.profession.instructions },
115
130
  ...(file.model && {
@@ -0,0 +1,60 @@
1
+ import { isNode, LineCounter, parseDocument } from "yaml";
2
+ import type { z } from "zod";
3
+ import { OpenshainError } from "../errors.ts";
4
+
5
+ /** Where a problem in a YAML file is, as `file:line:col path: message`. */
6
+ export type Problem = (path: readonly PropertyKey[], message: string) => string;
7
+
8
+ /**
9
+ * Parses a YAML file against a zod schema. Every problem is reported with its line and column
10
+ * and the path of the field, so that a person can fix the file. Returns the data together with
11
+ * `problem`, for checks the caller adds after parsing.
12
+ */
13
+ export function parseYamlFile<T extends z.ZodType>(
14
+ text: string,
15
+ schema: T,
16
+ fileName: string,
17
+ ): { data: z.output<T>; problem: Problem } {
18
+ const lineCounter = new LineCounter();
19
+ let doc: ReturnType<typeof parseDocument>;
20
+ let data: unknown;
21
+ try {
22
+ doc = parseDocument(text, { lineCounter });
23
+ data = doc.errors.length > 0 ? undefined : doc.toJS();
24
+ } catch (cause) {
25
+ // yaml refuses resource-exhaustion documents (alias bombs) with a plain error
26
+ throw new OpenshainError("config", `${fileName}: ${(cause as Error).message}`, { cause });
27
+ }
28
+
29
+ if (doc.errors.length > 0) {
30
+ const lines = doc.errors.map((error) => {
31
+ const pos = error.linePos?.[0] ?? { line: 0, col: 0 };
32
+ return `${fileName}:${pos.line}:${pos.col} ${firstLine(error.message)}`;
33
+ });
34
+ throw new OpenshainError("config", lines.join("\n"));
35
+ }
36
+
37
+ const locate = (path: readonly PropertyKey[]): { line: number; col: number } => {
38
+ for (let i = path.length; i >= 0; i--) {
39
+ const node = i === 0 ? doc.contents : doc.getIn(path.slice(0, i), true);
40
+ if (isNode(node) && node.range) return lineCounter.linePos(node.range[0]);
41
+ }
42
+ return { line: 1, col: 1 };
43
+ };
44
+ const problem: Problem = (path, message) => {
45
+ const { line, col } = locate(path);
46
+ const where = path.length === 0 ? "<root>" : path.map(String).join(".");
47
+ return `${fileName}:${line}:${col} ${where}: ${message}`;
48
+ };
49
+
50
+ const result = schema.safeParse(data);
51
+ if (!result.success) {
52
+ const problems = result.error.issues.map((issue) => problem(issue.path, issue.message));
53
+ throw new OpenshainError("config", problems.join("\n"));
54
+ }
55
+ return { data: result.data, problem };
56
+ }
57
+
58
+ function firstLine(message: string): string {
59
+ return message.split("\n", 1)[0] ?? message;
60
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,28 @@
1
1
  // @openshain/core: Contracts (provider interfaces), fundamental objects, and the work runtime
2
+
3
+ export {
4
+ AUTHORITY_DIR_NAME,
5
+ type Authority,
6
+ type AuthorityRequest,
7
+ DECISION_KINDS,
8
+ DECISIONS_DIR_NAME,
9
+ DELEGATIONS_FILE_NAME,
10
+ type Decision,
11
+ DecisionFileSchema,
12
+ type DecisionKind,
13
+ type DecisionRecord,
14
+ type Delegation,
15
+ DelegationsFileSchema,
16
+ evaluate,
17
+ loadAuthority,
18
+ matchGlob,
19
+ OPEN_AUTHORITY,
20
+ POLICY_FILE_NAME,
21
+ type PolicyFile,
22
+ PolicyFileSchema,
23
+ type Rule,
24
+ writeDecision,
25
+ } from "./authority/policy.ts";
2
26
  export {
3
27
  CONFIG_FILE_NAME,
4
28
  loadConfig,
@@ -25,16 +49,20 @@ export type {
25
49
  UserPart,
26
50
  } from "./model/types.ts";
27
51
  export {
52
+ type CallOptions,
28
53
  type CreateRuntimeOptions,
29
54
  createRuntime,
30
55
  createToolCaller,
31
56
  createToolRegistry,
32
57
  MAX_TOOL_TEXT_CHARS,
58
+ type PendingApprovalResult,
59
+ REVIEW_DIR_NAME,
33
60
  type Runtime,
34
61
  type RuntimeProviders,
35
62
  type ToolSummary,
36
63
  } from "./runtime.ts";
37
64
  export { jsonSchemas, type SchemaName } from "./schemas.ts";
65
+ export { businessDate, companyTime, hostTimezone, isTimezone } from "./time.ts";
38
66
  export { ASK_USER, RUNTIME_PROVIDER_ID } from "./tool/ask-user.ts";
39
67
  export { loadToolModule } from "./tool/load-module.ts";
40
68
  export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.ts";
@@ -76,6 +104,7 @@ export {
76
104
  type ModelUsage,
77
105
  parsePayloadFile,
78
106
  payloadFileSchemas,
107
+ type ReviewPackage,
79
108
  type StopReason,
80
109
  TOOL_REJECTION_CODES,
81
110
  type ToolContent,
@@ -86,7 +115,9 @@ export {
86
115
  countToolCalls,
87
116
  type FailureReason,
88
117
  type HistoryCall,
118
+ type PendingApproval,
89
119
  type PendingQuestion,
120
+ pendingApprovals,
90
121
  pendingQuestions,
91
122
  type WorkHistory,
92
123
  workHistory,
package/src/runtime.ts CHANGED
@@ -1,14 +1,25 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ type Authority,
5
+ evaluate,
6
+ loadAuthority,
7
+ OPEN_AUTHORITY,
8
+ type Rule,
9
+ } from "./authority/policy.ts";
1
10
  import { loadConfig } from "./config/load.ts";
2
11
  import type { Config, ModelConfig } from "./config/schema.ts";
3
12
  import { isOpenshainError, OpenshainError } from "./errors.ts";
4
13
  import type { ModelProvider } from "./model/types.ts";
14
+ import { businessDate } from "./time.ts";
5
15
  import { loadToolModule } from "./tool/load-module.ts";
6
16
  import type { HiddenTool } from "./tool/registry.ts";
7
17
  import { type RegisteredTool, ToolRegistry } from "./tool/registry.ts";
8
18
  import type { ToolCall, ToolDefinition, ToolProvider, ToolResult } from "./tool/types.ts";
9
- import type { ToolContent } from "./work/events.ts";
19
+ import { uuidv7 } from "./uuid.ts";
20
+ import type { Event, ReviewPackage, ToolContent } from "./work/events.ts";
10
21
  import { TOOL_REJECTION_CODES, type ToolRejectionCode } from "./work/events.ts";
11
- import { type WorkHandle, WorkStore } from "./work/store.ts";
22
+ import { WORK_DIR_NAME, type WorkHandle, WorkStore } from "./work/store.ts";
12
23
 
13
24
  export interface RuntimeProviders {
14
25
  /** Model providers by the id used in openshain.yaml. */
@@ -31,6 +42,9 @@ export interface ToolSummary {
31
42
  /** Longer tool output is cut here so that one tool cannot flood the model's context. */
32
43
  export const MAX_TOOL_TEXT_CHARS = 50_000;
33
44
 
45
+ /** Where a work keeps the review packages a person sends to a reviewer. */
46
+ export const REVIEW_DIR_NAME = "review";
47
+
34
48
  export interface Runtime {
35
49
  readonly workspaceRoot: string;
36
50
  readonly config: Config;
@@ -72,6 +86,7 @@ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runt
72
86
  }
73
87
 
74
88
  const registry = await createToolRegistry(workspaceRoot, config, providers.tools);
89
+ const authority = await loadAuthority(workspaceRoot);
75
90
  const works = new WorkStore(workspaceRoot);
76
91
  return {
77
92
  workspaceRoot,
@@ -81,7 +96,7 @@ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runt
81
96
  tools: {
82
97
  list: () => registry.list().map(({ definition, providerId }) => ({ definition, providerId })),
83
98
  hidden: () => registry.hiddenTools(),
84
- call: createToolCaller({ registry, config, workspaceRoot }),
99
+ call: createToolCaller({ registry, config, workspaceRoot, authority }),
85
100
  },
86
101
  };
87
102
  }
@@ -91,8 +106,32 @@ export function createToolCaller(input: {
91
106
  registry: ToolRegistry;
92
107
  config: Config;
93
108
  workspaceRoot: string;
94
- }): (work: WorkHandle, call: ToolCall) => Promise<ToolResult> {
95
- return (work, call) => callTool({ ...input, work, call });
109
+ /**
110
+ * Who may do what. Pass a function when it can change while the server runs, as it does when
111
+ * a reviewer writes a decision. Omitted: the workspace is open, as one without authority/ is.
112
+ */
113
+ authority?: Authority | (() => Authority);
114
+ }): (work: WorkHandle, call: ToolCall, options?: CallOptions) => Promise<ToolResult> {
115
+ const given = input.authority;
116
+ const current = typeof given === "function" ? given : () => given ?? OPEN_AUTHORITY;
117
+ return (work, call, options) =>
118
+ callTool({ ...input, authority: current(), work, call, ...options });
119
+ }
120
+
121
+ export interface CallOptions {
122
+ /** The approval that lets this call run: the policy is not consulted again. */
123
+ approvedBy?: string;
124
+ }
125
+
126
+ /** What a held call answers with: the client shows it to the person and the turn stops there. */
127
+ export interface PendingApprovalResult {
128
+ pending: "approval" | "review";
129
+ approval_id: string;
130
+ rule_id: string;
131
+ approvers?: string[];
132
+ reviewer?: { role: string; name?: string };
133
+ /** For a review: why the policy asks for one, when a cited decision did not cover the call. */
134
+ why?: string;
96
135
  }
97
136
 
98
137
  /** Registers the tool providers the config names: the caller's factories by id, and modules from the workspace. Needs no model. */
@@ -121,8 +160,8 @@ export async function createToolRegistry(
121
160
  }
122
161
 
123
162
  /**
124
- * The one place that allows or refuses a call before it runs. At this stage it knows only
125
- * the allow lists; a later authority engine plugs in here.
163
+ * The one place that allows or refuses a call before it runs by name alone. What the workspace
164
+ * allows a call to do is decided after this, by the policy in `authority/`.
126
165
  */
127
166
  function authorize(
128
167
  registry: ToolRegistry,
@@ -143,10 +182,12 @@ async function callTool(input: {
143
182
  registry: ToolRegistry;
144
183
  config: Config;
145
184
  workspaceRoot: string;
185
+ authority: Authority;
146
186
  work: WorkHandle;
147
187
  call: ToolCall;
188
+ approvedBy?: string;
148
189
  }): Promise<ToolResult> {
149
- const { registry, config, workspaceRoot, work, call } = input;
190
+ const { registry, config, workspaceRoot, authority, work, call } = input;
150
191
  const reject = async (code: ToolRejectionCode, reason: string): Promise<ToolResult> => {
151
192
  await work.append({
152
193
  type: "tool.rejected",
@@ -165,6 +206,72 @@ async function callTool(input: {
165
206
  `input does not match the schema of ${call.name}: ${validation.reason}`,
166
207
  );
167
208
  }
209
+ // The policy judges after the allow list, unless a person already approved this very call.
210
+ if (input.approvedBy === undefined) {
211
+ const path = pathOf(call.input);
212
+ const judged = evaluate(authority, {
213
+ tool: call.name,
214
+ effect: tool.definition.effect,
215
+ ...(path !== undefined && { path }),
216
+ principal: config.principal.id,
217
+ profession: config.profession.id,
218
+ workType: (await work.current()).type,
219
+ businessDate: businessDate(config.company.timezone),
220
+ });
221
+ if (judged.kind === "deny") return reject("denied", judged.reason);
222
+ if (judged.kind === "approval_required" || judged.kind === "review_required") {
223
+ const review = judged.kind === "review_required";
224
+ const approvalId = `apr_${uuidv7()}`;
225
+ const approvers = judged.rule.approvers ?? [config.principal.id];
226
+ const declared = judged.rule.reviewer;
227
+ const reviewer = declared
228
+ ? { role: declared.role, ...(declared.name !== undefined && { name: declared.name }) }
229
+ : undefined;
230
+ await work.append({
231
+ type: "approval.requested",
232
+ payload: {
233
+ approvalId,
234
+ call: { callId: call.id, name: call.name, input: call.input },
235
+ ruleId: judged.rule.id,
236
+ kind: review ? "review" : "approval",
237
+ ...(review ? reviewer && { reviewer } : { approvers }),
238
+ },
239
+ });
240
+ if (review) {
241
+ const built = await reviewPackage(work, {
242
+ approvalId,
243
+ call,
244
+ rule: judged.rule,
245
+ principal: config.principal.id,
246
+ });
247
+ await work.append({ type: "review.requested", payload: { approvalId, package: built } });
248
+ // A copy the person can send to the reviewer, next to the work's own record.
249
+ const dir = join(workspaceRoot, WORK_DIR_NAME, work.id, REVIEW_DIR_NAME);
250
+ await mkdir(dir, { recursive: true });
251
+ await writeFile(join(dir, `${approvalId}.json`), `${JSON.stringify(built, null, 2)}\n`, {
252
+ flag: "wx",
253
+ });
254
+ }
255
+ await work.transition(
256
+ "waiting_approval",
257
+ `rule ${judged.rule.id} needs ${review ? "a review" : "approval"}`,
258
+ );
259
+ const held: PendingApprovalResult = {
260
+ pending: review ? "review" : "approval",
261
+ approval_id: approvalId,
262
+ rule_id: judged.rule.id,
263
+ ...(review ? reviewer && { reviewer } : { approvers }),
264
+ ...(review && judged.why !== undefined && { why: judged.why }),
265
+ };
266
+ return { content: [{ type: "json", value: held }] };
267
+ }
268
+ if (judged.kind === "allow" && judged.decision) {
269
+ await work.append({
270
+ type: "decision.applied",
271
+ payload: { callId: call.id, decisionId: judged.decision.id },
272
+ });
273
+ }
274
+ }
168
275
 
169
276
  await work.append({
170
277
  type: "tool.called",
@@ -203,6 +310,68 @@ async function callTool(input: {
203
310
  return result;
204
311
  }
205
312
 
313
+ /**
314
+ * What the reviewer is asked to decide on, from the work's own record: the call, the tool calls
315
+ * that came before it, and the agent's last words as the proposal. Sources and company rules stay
316
+ * empty until knowledge is in.
317
+ */
318
+ async function reviewPackage(
319
+ work: WorkHandle,
320
+ input: { approvalId: string; call: ToolCall; rule: Rule; principal: string },
321
+ ): Promise<ReviewPackage> {
322
+ const events = await work.events();
323
+ const facts: string[] = [];
324
+ let proposal = "";
325
+ for (const event of events) {
326
+ if (event.type === "tool.called") {
327
+ const { name, input: called } = (event as Event<"tool.called">).payload;
328
+ const path = pathOf(called);
329
+ facts.push(path === undefined ? name : `${name} ${path}`);
330
+ } else if (event.type === "model.completed") {
331
+ const text = (event as Event<"model.completed">).payload.content
332
+ .filter((part) => part.type === "text")
333
+ .map((part) => (part as { text: string }).text)
334
+ .join("\n")
335
+ .trim();
336
+ if (text !== "") proposal = text;
337
+ }
338
+ }
339
+ const current = await work.current();
340
+ return {
341
+ approvalId: input.approvalId,
342
+ workId: work.id,
343
+ action: {
344
+ name: input.rule.match.action?.toString() ?? input.call.name,
345
+ tool: input.call.name,
346
+ input: input.call.input,
347
+ },
348
+ facts,
349
+ sources: [],
350
+ companyRules: [],
351
+ proposal,
352
+ question:
353
+ input.rule.reason ??
354
+ `${current.objective} のために ${input.call.name} を実行してよいか、判断をお願いします。`,
355
+ requestedBy: input.principal,
356
+ requestedAt: new Date().toISOString(),
357
+ };
358
+ }
359
+
360
+ /** The path a call names, normalized to a workspace-relative posix path, when its input has one. */
361
+ function pathOf(input: unknown): string | undefined {
362
+ const path = (input as { path?: unknown } | null)?.path;
363
+ if (typeof path !== "string" || path === "") return undefined;
364
+ const segments: string[] = [];
365
+ for (const segment of path.replaceAll("\\", "/").split("/")) {
366
+ if (segment === "" || segment === ".") continue;
367
+ if (segment === "..") segments.pop();
368
+ else segments.push(segment);
369
+ }
370
+ return segments.join("/");
371
+ }
372
+
373
+ /** Today's date on this machine's clock, YYYY-MM-DD. */
374
+
206
375
  function isRejectionCode(code: string): code is ToolRejectionCode {
207
376
  return (TOOL_REJECTION_CODES as readonly string[]).includes(code);
208
377
  }
package/src/schemas.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  import { z } from "zod";
2
+ import { DelegationsFileSchema, PolicyFileSchema } from "./authority/policy.ts";
2
3
  import { ConfigFileSchema } from "./config/schema.ts";
3
4
  import type { JsonSchema } from "./tool/types.ts";
4
5
  import { EventFileSchema, payloadFileSchemas } from "./work/events.ts";
5
6
  import { WorkFileSchema } from "./work/work.ts";
6
7
 
7
- export type SchemaName = "config.v1" | "events.v1" | "work.v1";
8
+ export type SchemaName =
9
+ | "config.v1"
10
+ | "events.v1"
11
+ | "work.v1"
12
+ | "authority-policy.v1"
13
+ | "authority-delegations.v1";
8
14
 
9
15
  /**
10
16
  * The JSON Schemas (draft 2020-12) of the files openshain reads and writes, derived from the zod
@@ -25,6 +31,16 @@ export function jsonSchemas(): Record<SchemaName, JsonSchema> {
25
31
  "work.json",
26
32
  "The state of a work as projected from its event log. Never the source of truth.",
27
33
  ),
34
+ "authority-policy.v1": describe(
35
+ PolicyFileSchema,
36
+ "authority/policy.yaml",
37
+ "The rules that judge tool calls: the first matching rule decides, else the default.",
38
+ ),
39
+ "authority-delegations.v1": describe(
40
+ DelegationsFileSchema,
41
+ "authority/delegations.yaml",
42
+ "Who the agent may act for, as which profession, and when.",
43
+ ),
28
44
  };
29
45
  }
30
46
 
package/src/time.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The company's clock. Every date the runtime judges against — a decision's effective days, a
3
+ * delegation's validity — is a date in the company's timezone, not in the host's. A workspace
4
+ * carried between a laptop in Tokyo and a container in UTC has to answer the same question the
5
+ * same way, so the timezone is part of the configuration rather than the environment.
6
+ */
7
+
8
+ /** True when the name is a timezone this runtime knows (`Asia/Tokyo`, `UTC`, ...). */
9
+ export function isTimezone(name: string): boolean {
10
+ try {
11
+ new Intl.DateTimeFormat("en-US", { timeZone: name });
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ /** The timezone of the machine, used when the configuration names none. */
19
+ export function hostTimezone(): string {
20
+ const name = Intl.DateTimeFormat().resolvedOptions().timeZone;
21
+ return name && isTimezone(name) ? name : "UTC";
22
+ }
23
+
24
+ /** The business date (`YYYY-MM-DD`) in the company's timezone. */
25
+ export function businessDate(timezone: string, at: Date = new Date()): string {
26
+ // en-CA writes a date as YYYY-MM-DD.
27
+ return new Intl.DateTimeFormat("en-CA", {
28
+ timeZone: timezone,
29
+ year: "numeric",
30
+ month: "2-digit",
31
+ day: "2-digit",
32
+ }).format(at);
33
+ }
34
+
35
+ /** The moment as the company reads it: `2026-09-09T14:03:11+09:00`. */
36
+ export function companyTime(timezone: string, at: Date = new Date()): string {
37
+ const parts = new Intl.DateTimeFormat("en-CA", {
38
+ timeZone: timezone,
39
+ hour12: false,
40
+ year: "numeric",
41
+ month: "2-digit",
42
+ day: "2-digit",
43
+ hour: "2-digit",
44
+ minute: "2-digit",
45
+ second: "2-digit",
46
+ timeZoneName: "longOffset",
47
+ }).formatToParts(at);
48
+ const of = (type: Intl.DateTimeFormatPartTypes) =>
49
+ parts.find((part) => part.type === type)?.value ?? "";
50
+ // "GMT+09:00" for a zone with an offset, "GMT" for UTC itself.
51
+ const zone = of("timeZoneName").replace("GMT", "");
52
+ const hour = of("hour") === "24" ? "00" : of("hour");
53
+ return `${of("year")}-${of("month")}-${of("day")}T${hour}:${of("minute")}:${of("second")}${zone === "" ? "+00:00" : zone}`;
54
+ }
package/src/tool/paths.ts CHANGED
@@ -3,7 +3,7 @@ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "no
3
3
  import { OpenshainError } from "../errors.ts";
4
4
 
5
5
  /** Paths the runtime keeps for itself. Tools may not read or write them. */
6
- export const RESERVED_PATHS = ["openshain.yaml", "work"] as const;
6
+ export const RESERVED_PATHS = ["openshain.yaml", "work", "principals", "authority"] as const;
7
7
 
8
8
  const MAX_SYMLINK_HOPS = 32;
9
9
 
package/src/tool/types.ts CHANGED
@@ -22,6 +22,10 @@ export const RESERVED_TOOL_NAMES: readonly string[] = [
22
22
  "work_fail",
23
23
  "work_answer",
24
24
  "work_record",
25
+ "context",
26
+ "approval_list",
27
+ "approval_decide",
28
+ "review_decide",
25
29
  "work_run",
26
30
  "work_show",
27
31
  ];