openshain 0.6.0 → 0.8.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/dist/bin.js CHANGED
@@ -7,6 +7,7 @@ import { init } from "./commands/init.js";
7
7
  import { knowledgeBuild, knowledgeCheck } from "./commands/knowledge.js";
8
8
  import { knowledgeAdd } from "./commands/knowledge-add.js";
9
9
  import { mcp } from "./commands/mcp.js";
10
+ import { principalCheck } from "./commands/principal.js";
10
11
  import { toolsList } from "./commands/tools.js";
11
12
  import { workList, workShow } from "./commands/work.js";
12
13
  import { plain } from "./format.js";
@@ -22,8 +23,11 @@ const USAGE = `使い方:
22
23
  openshain knowledge build knowledge/ の決まりと資料を検証して索引を作る
23
24
  openshain knowledge check 同じ検証を、索引を書かずに行う(--stale で古い資料も報告)
24
25
  openshain knowledge add 決まりを 1 件、質問に答えて追加する
26
+ openshain principal check <id> その人の社員エージェントがどこで働き、何ができるかを確かめる
25
27
  openshain mcp MCP Server を stdio で起動する
26
28
 
29
+ --principal <id> この端末が誰として働くか。principals/ に書いた id
30
+ 環境変数 OPENSHAIN_PRINCIPAL でも指定できる。本人確認はしない
27
31
  --workspace <dir> 起点のディレクトリ。省略時はカレントディレクトリ
28
32
  init はそこに書き、他のコマンドはそこから上に openshain.yaml を探す`;
29
33
  /** The providers this CLI knows, by the ids used in openshain.yaml. */
@@ -34,6 +38,14 @@ const providers = {
34
38
  },
35
39
  tools: { standard: (workspaceRoot) => standardTools(workspaceRoot) },
36
40
  };
41
+ /**
42
+ * Who this terminal works for. The flag wins over the variable; neither is written into the
43
+ * company folder, because the folder is shared and the choice belongs to the machine.
44
+ */
45
+ function actingFor(flag) {
46
+ const named = flag ?? process.env.OPENSHAIN_PRINCIPAL;
47
+ return named === undefined || named === "" ? undefined : named;
48
+ }
37
49
  async function main(argv) {
38
50
  const write = (line) => console.log(plain(line));
39
51
  let values;
@@ -43,6 +55,7 @@ async function main(argv) {
43
55
  args: argv,
44
56
  options: {
45
57
  workspace: { type: "string" },
58
+ principal: { type: "string" },
46
59
  help: { type: "boolean", short: "h" },
47
60
  stale: { type: "boolean" },
48
61
  },
@@ -60,7 +73,7 @@ async function main(argv) {
60
73
  const [command, ...rest] = positionals;
61
74
  if (!command && !values.help && process.stdin.isTTY === true && process.stdout.isTTY === true) {
62
75
  const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
63
- return startTui({ workspaceRoot, providers });
76
+ return startTui({ workspaceRoot, providers, as: actingFor(values.principal) });
64
77
  }
65
78
  if (values.help || !command) {
66
79
  write(USAGE);
@@ -72,7 +85,7 @@ async function main(argv) {
72
85
  return 0;
73
86
  case "mcp": {
74
87
  const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
75
- await mcp({ workspaceRoot, providers });
88
+ await mcp({ workspaceRoot, providers, as: actingFor(values.principal) });
76
89
  return 0;
77
90
  }
78
91
  case "tools": {
@@ -96,6 +109,15 @@ async function main(argv) {
96
109
  const run = sub === "build" ? knowledgeBuild : knowledgeCheck;
97
110
  return await run({ workspaceRoot, write, ...(values.stale === true && { stale: true }) });
98
111
  }
112
+ case "principal": {
113
+ const id = rest[1] ?? "";
114
+ if (rest[0] !== "check" || !id) {
115
+ write(USAGE);
116
+ return 2;
117
+ }
118
+ const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
119
+ return await principalCheck({ workspaceRoot, id, write });
120
+ }
99
121
  case "work": {
100
122
  const sub = rest[0];
101
123
  const id = rest[1] ?? "";
@@ -104,11 +126,12 @@ async function main(argv) {
104
126
  return 2;
105
127
  }
106
128
  const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
129
+ const as = actingFor(values.principal);
107
130
  if (sub === "list") {
108
- await workList({ workspaceRoot, write });
131
+ await workList({ workspaceRoot, as, write });
109
132
  return 0;
110
133
  }
111
- await workShow({ workspaceRoot, id, write });
134
+ await workShow({ workspaceRoot, id, as, write });
112
135
  return 0;
113
136
  }
114
137
  default:
@@ -3,7 +3,12 @@ import { type Language } from "@openshain/core";
3
3
  export declare function detectLanguage(env: Record<string, string | undefined>): Language;
4
4
  export declare const configTemplate: (language: Language, timezone: string) => string;
5
5
  export declare const CONFIG_TEMPLATE: string;
6
- /** Registers the runtime as a project MCP server for Claude Code. `openshain` must be on PATH. */
6
+ /**
7
+ * Registers the runtime as a project MCP server for Claude Code. `openshain` must be on PATH.
8
+ * With more than one person in the company, each person adds `env` naming themselves to their own
9
+ * copy: Claude Code starts the server itself, so a flag on the terminal does not reach it. JSON
10
+ * carries no comments, so init says that in what it prints instead.
11
+ */
7
12
  export declare const MCP_TEMPLATE: string;
8
13
  /** What an outside agent reads before working in the folder. Codex reads AGENTS.md; Claude Code reads it through CLAUDE.md. */
9
14
  export declare const AGENTS_TEMPLATE = "# \u3053\u306E\u4F1A\u793E\u30D5\u30A9\u30EB\u30C0\u3067\u50CD\u304F\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u3078\n\n\u3053\u306E\u30D5\u30A9\u30EB\u30C0\u306F openshain \u306E Company Workspace \u3067\u3059\u3002\u3053\u306E\u6307\u793A\u306F\u3001Claude Code \u3084 Codex \u306E\u3088\u3046\u306A\u5916\u90E8\u306E\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8\u304C MCP \u7D4C\u7531\u3067\u3053\u306E\u30D5\u30A9\u30EB\u30C0\u3092\u6271\u3046\u3068\u304D\u306E\u3082\u306E\u3067\u3059\u3002openshain \u306E\u5BFE\u8A71\u578B CLI \u3082\u540C\u3058\u624B\u9806\u3067 Runtime \u3092\u4F7F\u3044\u307E\u3059\u3002\n\n\u4F1A\u793E\u306E\u30D5\u30A1\u30A4\u30EB\u306E\u8AAD\u307F\u66F8\u304D\u3068\u96C6\u8A08\u306F openshain \u306E MCP tool \u3067\u884C\u3044\u307E\u3059\u3002Claude Code \u3084 Codex \u81EA\u8EAB\u306E Read\u3001Write\u3001Bash \u306F\u4F1A\u793E\u306E\u30D5\u30A1\u30A4\u30EB\u306B\u306F\u4F7F\u3044\u307E\u305B\u3093\u3002Runtime \u3092\u901A\u3089\u306A\u304B\u3063\u305F\u64CD\u4F5C\u306F\u8A18\u9332\u306B\u6B8B\u3089\u306A\u3044\u305F\u3081\u3067\u3059\u3002\n\n- \u4F9D\u983C\u3092\u53D7\u3051\u305F\u3089\u3001\u307E\u305A `work_create` \u306B\u4F9D\u983C\u306E\u6587\u3092\u305D\u306E\u307E\u307E\u6E21\u3057\u3066 Work \u3092\u4F5C\u308B\n- \u30D5\u30A1\u30A4\u30EB\u306F `fs_list`\u3001`fs_search`\u3001`fs_read`\u3001`csv_read`\u3001`markdown_read` \u3067\u898B\u308B\u3002\u5408\u8A08\u3084\u4EF6\u6570\u306F `csv_aggregate` \u306B\u4EFB\u305B\u3001\u81EA\u5206\u3067\u5408\u8A08\u3057\u306A\u3044\n- \u66F8\u304F\u3068\u304D\u306F `fs_write` \u304B `csv_write`\n- \u7D42\u308F\u3063\u305F\u3089 `work_complete` \u306B\u3001\u4F55\u3092\u3057\u305F\u304B\u3068\u3001\u66F8\u3044\u305F\u30D5\u30A1\u30A4\u30EB\u3092\u6E21\u3059\u3002\u7D9A\u3051\u3089\u308C\u306A\u3044\u3068\u304D\u306F `work_fail`\n- `openshain.yaml`\u3001`work/`\u3001`principals/`\u3001`authority/` \u306F Runtime \u306E\u3082\u306E\u3002\u5909\u66F4\u3057\u306A\u3044\n- \u547C\u3073\u51FA\u3057\u306E\u7D50\u679C\u304C `pending` \u306A\u3089\u3001\u4F1A\u793E\u306E\u6A29\u9650\u306E\u898F\u5247\u304C\u305D\u306E\u547C\u3073\u51FA\u3057\u3092\u6B62\u3081\u3066\u3044\u308B\u3002\u627F\u8A8D\u306F\u4F1A\u793E\u306E\u4EBA\u304C openshain \u306E\u753B\u9762\u3067\u6C7A\u3081\u308B\u3002\u81EA\u5206\u3067 `approval_decide` \u3084 `review_decide` \u3092\u547C\u3093\u3067\u901A\u3055\u306A\u3044\u3002\u4EBA\u306B\u4F1D\u3048\u3066\u3001\u6C7A\u307E\u308B\u307E\u3067\u5225\u306E\u4F5C\u696D\u3092\u3059\u308B\n";
@@ -42,8 +42,20 @@ limits:
42
42
  # persist_raw: true # provider の生の応答を記録に残す
43
43
  `;
44
44
  export const CONFIG_TEMPLATE = configTemplate("ja", "Asia/Tokyo");
45
- /** Registers the runtime as a project MCP server for Claude Code. `openshain` must be on PATH. */
46
- export const MCP_TEMPLATE = `${JSON.stringify({ mcpServers: { openshain: { command: "openshain", args: ["mcp"] } } }, null, 2)}\n`;
45
+ /**
46
+ * Registers the runtime as a project MCP server for Claude Code. `openshain` must be on PATH.
47
+ * With more than one person in the company, each person adds `env` naming themselves to their own
48
+ * copy: Claude Code starts the server itself, so a flag on the terminal does not reach it. JSON
49
+ * carries no comments, so init says that in what it prints instead.
50
+ */
51
+ export const MCP_TEMPLATE = `${JSON.stringify({
52
+ mcpServers: {
53
+ openshain: {
54
+ command: "openshain",
55
+ args: ["mcp"],
56
+ },
57
+ },
58
+ }, null, 2)}\n`;
47
59
  /** What an outside agent reads before working in the folder. Codex reads AGENTS.md; Claude Code reads it through CLAUDE.md. */
48
60
  export const AGENTS_TEMPLATE = `# この会社フォルダで働くエージェントへ
49
61
 
@@ -103,6 +115,7 @@ export async function init({ workspaceRoot, write }) {
103
115
  }
104
116
  }
105
117
  write("company と principal を自分の会社に合わせ、api_key_env に書いた環境変数を設定してから openshain を実行してください。");
118
+ write('会社に人が複数いるときは principals/<id>.yaml に 1 人ずつ書き、各自の端末で openshain --principal <id> を実行します。Claude Code から使う人は、自分の .mcp.json の openshain に env: { OPENSHAIN_PRINCIPAL: "<id>" } を追加します。');
106
119
  }
107
120
  /**
108
121
  * Adds the openshain server to a .mcp.json that is already there, keeping every other server.
@@ -2,9 +2,11 @@ import type { RuntimeProviders } from "@openshain/core";
2
2
  export interface McpOptions {
3
3
  workspaceRoot: string;
4
4
  providers: RuntimeProviders;
5
+ /** Who this server works for, when the person said so. */
6
+ as?: string | undefined;
5
7
  }
6
8
  /**
7
9
  * Serves the workspace over MCP on stdin and stdout until the client hangs up. Nothing else may
8
10
  * be written to stdout while it runs; the protocol owns it.
9
11
  */
10
- export declare function mcp({ workspaceRoot, providers }: McpOptions): Promise<void>;
12
+ export declare function mcp({ workspaceRoot, providers, as }: McpOptions): Promise<void>;
@@ -4,8 +4,8 @@ import { createMcpServer } from "@openshain/mcp";
4
4
  * Serves the workspace over MCP on stdin and stdout until the client hangs up. Nothing else may
5
5
  * be written to stdout while it runs; the protocol owns it.
6
6
  */
7
- export async function mcp({ workspaceRoot, providers }) {
8
- const server = await createMcpServer({ workspaceRoot, tools: providers.tools });
7
+ export async function mcp({ workspaceRoot, providers, as }) {
8
+ const server = await createMcpServer({ workspaceRoot, tools: providers.tools, as });
9
9
  const transport = new StdioServerTransport();
10
10
  await new Promise((resolve) => {
11
11
  server.onclose = () => resolve();
@@ -0,0 +1,12 @@
1
+ export interface PrincipalOptions {
2
+ workspaceRoot: string;
3
+ id: string;
4
+ write: (line: string) => void;
5
+ }
6
+ /**
7
+ * What one person's employee agent works on, and what it may do. Written by hand, a range and a
8
+ * table of rules are easy to get subtly wrong: a glob that matches nothing, a delegation nobody
9
+ * wrote, a rule that allows a path the range does not cover. Each of those fails quietly, so this
10
+ * command says what the files actually add up to before somebody relies on them.
11
+ */
12
+ export declare function principalCheck(options: PrincipalOptions): Promise<number>;
@@ -0,0 +1,136 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { loadAuthority, matchGlob, mayReachInto, mayRead, PRINCIPALS_DIR_NAME, RESERVED_PATHS, reaches, } from "@openshain/core";
4
+ /** How many matching paths are printed before the rest are counted instead. */
5
+ const SHOWN = 20;
6
+ /**
7
+ * What one person's employee agent works on, and what it may do. Written by hand, a range and a
8
+ * table of rules are easy to get subtly wrong: a glob that matches nothing, a delegation nobody
9
+ * wrote, a rule that allows a path the range does not cover. Each of those fails quietly, so this
10
+ * command says what the files actually add up to before somebody relies on them.
11
+ */
12
+ export async function principalCheck(options) {
13
+ const { workspaceRoot, id, write } = options;
14
+ let authority;
15
+ try {
16
+ authority = await loadAuthority(workspaceRoot);
17
+ }
18
+ catch (err) {
19
+ write(err instanceof Error ? err.message : String(err));
20
+ return 1;
21
+ }
22
+ const person = authority.principals.get(id);
23
+ if (!person) {
24
+ const written = [...authority.principals.keys()];
25
+ write(written.length === 0
26
+ ? `${PRINCIPALS_DIR_NAME}/ に誰も書かれていません。1 人 1 ファイルで ${PRINCIPALS_DIR_NAME}/<id>.yaml に書きます。`
27
+ : `${id} は ${PRINCIPALS_DIR_NAME}/ にいません。書かれているのは ${written.join("、")} です。`);
28
+ return 1;
29
+ }
30
+ const problems = [];
31
+ write(`${person.name}(${person.id})`);
32
+ write(` 状態: ${person.status === "active" ? "在籍" : "退任(代理も承認もできません)"}`);
33
+ write(` 担当: ${person.roles.length === 0 ? "なし" : person.roles.join("、")}`);
34
+ const delegations = authority.delegations.filter((d) => d.principal === person.id);
35
+ if (!authority.present) {
36
+ write(" 委任: authority/ が無いので、すべて許可です");
37
+ }
38
+ else if (delegations.length === 0) {
39
+ write(" 委任: ありません。この人の代理では何も実行できません");
40
+ problems.push(`${person.id} の委任が authority/delegations.yaml にありません`);
41
+ }
42
+ else {
43
+ write(` 委任: ${delegations.map((d) => d.profession).join("、")} として`);
44
+ }
45
+ if (person.reads === undefined) {
46
+ write(" 働く範囲: 会社フォルダ全体(reads を書いていません)");
47
+ }
48
+ else {
49
+ write(` 働く範囲: ${person.reads.join("、")}`);
50
+ const matched = await matching(workspaceRoot, person);
51
+ if (matched.length === 0) {
52
+ write(" 一致するものがありません");
53
+ problems.push(`${person.id} の reads に一致するファイルもフォルダもありません`);
54
+ }
55
+ else {
56
+ write(` 一致するもの ${matched.length} 件`);
57
+ for (const path of matched.slice(0, SHOWN))
58
+ write(` ${path}`);
59
+ if (matched.length > SHOWN)
60
+ write(` ほか ${matched.length - SHOWN} 件`);
61
+ }
62
+ }
63
+ const byRole = authority.policy.rules.filter((rule) => named(rule.match.role, person.roles));
64
+ if (byRole.length > 0) {
65
+ write(" 担当で一致する規則:");
66
+ for (const rule of byRole)
67
+ write(` ${rule.id}(${rule.decision})`);
68
+ }
69
+ const outside = person.reads === undefined ? [] : allowedOutside(authority, person);
70
+ if (outside.length > 0) {
71
+ write(" 範囲の外を allow している規則:");
72
+ for (const rule of outside)
73
+ write(` ${rule}`);
74
+ problems.push(`${person.id} の範囲の外を allow している規則があります。規則は範囲を広げません`);
75
+ }
76
+ if (problems.length === 0) {
77
+ write("問題はありません。");
78
+ return 0;
79
+ }
80
+ for (const problem of problems)
81
+ write(problem);
82
+ return 1;
83
+ }
84
+ /** Everything in the company folder this person's agent works on, as workspace-relative paths. */
85
+ async function matching(workspaceRoot, person) {
86
+ const found = [];
87
+ const walk = async (dir) => {
88
+ let entries;
89
+ try {
90
+ entries = await readdir(dir, { withFileTypes: true });
91
+ }
92
+ catch {
93
+ return;
94
+ }
95
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
96
+ const full = join(dir, entry.name);
97
+ const path = relative(workspaceRoot, full).split(sep).join("/");
98
+ if (entry.name.startsWith("."))
99
+ continue;
100
+ if (dir === workspaceRoot && reserved(entry.name))
101
+ continue;
102
+ if (mayRead(person, path))
103
+ found.push(path);
104
+ if (entry.isDirectory() && mayReachInto(person, path))
105
+ await walk(full);
106
+ }
107
+ };
108
+ await walk(workspaceRoot);
109
+ return found;
110
+ }
111
+ /** Rules that allow a path the range does not cover: written in hope, and never effective. */
112
+ function allowedOutside(authority, person) {
113
+ const out = [];
114
+ for (const rule of authority.policy.rules) {
115
+ const path = rule.match.path;
116
+ if (rule.decision !== "allow" || path === undefined)
117
+ continue;
118
+ if (rule.match.principal !== undefined && !named(rule.match.principal, [person.id]))
119
+ continue;
120
+ if (rule.match.role !== undefined && !named(rule.match.role, person.roles))
121
+ continue;
122
+ const covered = (person.reads ?? []).some((range) => matchGlob(range, path) || reaches(range, path) || matchGlob(path, range));
123
+ if (!covered)
124
+ out.push(`${rule.id}(${path})`);
125
+ }
126
+ return out;
127
+ }
128
+ function named(expected, actual) {
129
+ if (expected === undefined)
130
+ return false;
131
+ const wanted = Array.isArray(expected) ? expected : [expected];
132
+ return wanted.some((one) => actual.includes(one));
133
+ }
134
+ function reserved(name) {
135
+ return RESERVED_PATHS.some((path) => path.toLowerCase() === name.toLowerCase());
136
+ }
@@ -1,15 +1,25 @@
1
1
  import { type AnyEvent, type Work } from "@openshain/core";
2
2
  export interface WorkListOptions {
3
3
  workspaceRoot: string;
4
+ /** Who this terminal works for, when the person said so. */
5
+ as?: string | undefined;
4
6
  write: (line: string) => void;
5
7
  }
6
8
  /** One line per work, oldest first. Works that cannot be read are reported, not hidden. */
7
- export declare function workList({ workspaceRoot, write }: WorkListOptions): Promise<void>;
9
+ export declare function workList({ workspaceRoot, as, write }: WorkListOptions): Promise<void>;
8
10
  export interface WorkShowOptions {
9
11
  workspaceRoot: string;
10
12
  id: string;
13
+ /** Who this terminal works for, when the person said so. */
14
+ as?: string | undefined;
11
15
  write: (line: string) => void;
12
16
  }
13
17
  /** Everything about one work: state, outcome, what the tools did, the usage, and who acts next. */
14
- export declare function workShow({ workspaceRoot, id, write }: WorkShowOptions): Promise<void>;
18
+ export declare function workShow({ workspaceRoot, id, as, write }: WorkShowOptions): Promise<void>;
15
19
  export declare function describeWork(work: Work, events: AnyEvent[]): string[];
20
+ /**
21
+ * How a reviewer is named on the screen. The role of a reviewer is a qualification the company
22
+ * names (tax-accountant), not one of the roles a person handles, so the word comes first and the
23
+ * name the company wrote stays with it.
24
+ */
25
+ export declare function qualifiedAs(role: string | undefined): string;
@@ -1,11 +1,25 @@
1
- import { parseWorkId, pendingApprovals, pendingQuestions, WorkStore, } from "@openshain/core";
1
+ import { join } from "node:path";
2
+ import { loadConfig, mayReadWork, noSuchWork, PRINCIPALS_DIR_NAME, parseWorkId, pendingApprovals, pendingQuestions, readPrincipals, WorkStore, } from "@openshain/core";
2
3
  import { describeInput, padDisplay } from "../format.js";
3
4
  import { errorLabel, failureLabel, rejectionLabel, statusLabel } from "../labels.js";
4
5
  import { nextActor } from "../report.js";
5
6
  import { formatUsage, summarizeUsage } from "../usage.js";
7
+ /**
8
+ * The person this terminal works for, out of the people the company has written. Nobody written:
9
+ * nobody to be, and every work is read, as before there was more than one person.
10
+ */
11
+ async function actingPerson(workspaceRoot, as) {
12
+ const config = await loadConfig(workspaceRoot, { ...(as !== undefined && { as }) });
13
+ const people = await readPrincipals(join(workspaceRoot, PRINCIPALS_DIR_NAME));
14
+ return people.get(config.principal.id);
15
+ }
6
16
  /** One line per work, oldest first. Works that cannot be read are reported, not hidden. */
7
- export async function workList({ workspaceRoot, write }) {
8
- const { works, problems } = await new WorkStore(workspaceRoot).list();
17
+ export async function workList({ workspaceRoot, as, write }) {
18
+ const me = await actingPerson(workspaceRoot, as);
19
+ const { works: all, problems: unreadable } = await new WorkStore(workspaceRoot).list();
20
+ const works = all.filter((w) => mayReadWork(me, w.principal));
21
+ // A work that cannot be read does not say whose it is, so a range leaves it out.
22
+ const problems = me?.reads === undefined ? unreadable : [];
9
23
  if (works.length === 0 && problems.length === 0) {
10
24
  write("Work はまだありません。openshain で社員エージェントに依頼すると始まります。");
11
25
  return;
@@ -18,10 +32,13 @@ export async function workList({ workspaceRoot, write }) {
18
32
  }
19
33
  }
20
34
  /** Everything about one work: state, outcome, what the tools did, the usage, and who acts next. */
21
- export async function workShow({ workspaceRoot, id, write }) {
35
+ export async function workShow({ workspaceRoot, id, as, write }) {
22
36
  const store = new WorkStore(workspaceRoot);
23
37
  const workId = parseWorkId(id);
24
38
  const work = await store.get(workId);
39
+ if (!mayReadWork(await actingPerson(workspaceRoot, as), work.principal)) {
40
+ throw noSuchWork(workspaceRoot, workId);
41
+ }
25
42
  const events = await store.events(workId);
26
43
  for (const line of describeWork(work, events))
27
44
  write(line);
@@ -53,7 +70,7 @@ export function describeWork(work, events) {
53
70
  }
54
71
  if (work.status === "waiting_approval") {
55
72
  for (const a of pendingApprovals(events)) {
56
- const who = a.kind === "review" ? `${a.reviewer?.role ?? "資格者"}の判断待ち` : "承認待ち";
73
+ const who = a.kind === "review" ? `${qualifiedAs(a.reviewer?.role)}の判断待ち` : "承認待ち";
57
74
  lines.push(`${who} ${a.call.name} ${describeInput(a.call.input)} (${a.approvalId})`);
58
75
  }
59
76
  }
@@ -67,6 +84,14 @@ export function describeWork(work, events) {
67
84
  lines.push(nextActor(work));
68
85
  return lines;
69
86
  }
87
+ /**
88
+ * How a reviewer is named on the screen. The role of a reviewer is a qualification the company
89
+ * names (tax-accountant), not one of the roles a person handles, so the word comes first and the
90
+ * name the company wrote stays with it.
91
+ */
92
+ export function qualifiedAs(role) {
93
+ return role === undefined ? "資格者" : `資格者(${role})`;
94
+ }
70
95
  /** One line per tool call, in log order, with its outcome when it was rejected or failed. */
71
96
  function toolLines(events) {
72
97
  const lines = new Map();
package/dist/labels.js CHANGED
@@ -27,6 +27,9 @@ export const REJECTION_LABELS = {
27
27
  denied: "権限の表で不許可",
28
28
  rejected_by_person: "承認されなかった",
29
29
  path_changed: "承認したときと書き込み先が変わった",
30
+ base_changed: "承認したときからファイルが変わった",
31
+ not_read: "読まずに置き換えようとした",
32
+ out_of_range: "範囲の外",
30
33
  };
31
34
  /** A heading for a runtime error, before the original message. */
32
35
  export const ERROR_LABELS = {
@@ -69,6 +69,8 @@ export interface Controller {
69
69
  export interface ControllerOptions {
70
70
  workspaceRoot: string;
71
71
  providers: RuntimeProviders;
72
+ /** Who this terminal works for, when the person said so. */
73
+ as?: string | undefined;
72
74
  }
73
75
  /**
74
76
  * What the screen says when the conversation was summarized. The person cannot read the summary
@@ -2,7 +2,7 @@ import { connectInMemory, createSession, } from "@openshain/agent";
2
2
  import { loadConfig, OpenshainError, WorkStore, } from "@openshain/core";
3
3
  import { createMcpServer } from "@openshain/mcp";
4
4
  import { toolsList } from "../commands/tools.js";
5
- import { workList, workShow } from "../commands/work.js";
5
+ import { qualifiedAs, workList, workShow } from "../commands/work.js";
6
6
  import { describeInput, plain } from "../format.js";
7
7
  import { statusLabel } from "../labels.js";
8
8
  import { previewCall } from "../preview.js";
@@ -86,7 +86,10 @@ const APPROVAL_WITHDRAWN = "the person left the approval undecided";
86
86
  */
87
87
  export async function createController(options) {
88
88
  const { workspaceRoot, providers } = options;
89
- const config = await loadConfig(workspaceRoot, { modelProviders: Object.keys(providers.models) });
89
+ const config = await loadConfig(workspaceRoot, {
90
+ modelProviders: Object.keys(providers.models),
91
+ as: options.as,
92
+ });
90
93
  if (!config.model) {
91
94
  throw new OpenshainError("config", "対話にはモデルが要ります。openshain.yaml に model を書いてください。Claude Code や Codex から使うだけなら要りません。");
92
95
  }
@@ -100,7 +103,13 @@ export async function createController(options) {
100
103
  if (!model.describe().capabilities.tools) {
101
104
  throw new OpenshainError("config", `model ${config.model.provider}/${config.model.model} cannot call tools; openshain needs a model with tool support`);
102
105
  }
103
- const server = await createMcpServer({ workspaceRoot, tools: providers.tools });
106
+ // The name the person gave on the command line reaches the runtime too: it is what the work is
107
+ // recorded under, and what the range is read from.
108
+ const server = await createMcpServer({
109
+ workspaceRoot,
110
+ tools: providers.tools,
111
+ ...(options.as !== undefined && { as: options.as }),
112
+ });
104
113
  const client = await connectInMemory(server);
105
114
  const store = new WorkStore(workspaceRoot);
106
115
  const listeners = new Set();
@@ -289,6 +298,10 @@ export async function createController(options) {
289
298
  push("logo", row);
290
299
  push("banner", `openshain ${VERSION}`);
291
300
  push("banner", workspaceRoot);
301
+ // Said where it is used, not only in the documentation: the name is a choice, not a check.
302
+ if (options.as) {
303
+ push("banner", `${config.principal.name}(${config.principal.id})として実行します。本人確認はしていません`);
304
+ }
292
305
  const stopped = (workId) => workId
293
306
  ? `止めました。${workId} は途中のまま残っています。/work resume ${workId} で続けられるようにします。`
294
307
  : "止めました。";
@@ -299,7 +312,10 @@ export async function createController(options) {
299
312
  case "aborted":
300
313
  return stopped(result.work);
301
314
  case "max_tokens":
302
- return "返答が長さの上限で切れました。";
315
+ // The answer ran into the limit mid-sentence, so a work it opened holds nothing yet.
316
+ return result.work
317
+ ? `返答が長さの上限で切れました。${result.work} は途中のまま残っています。/work resume ${result.work} で続けられるようにします。一度に扱う量を分けて依頼すると、上限に当たらずに終わります。`
318
+ : "返答が長さの上限で切れました。";
303
319
  case "refusal":
304
320
  return "社員エージェントが続けられないと言っています。";
305
321
  case "model_error":
@@ -368,11 +384,11 @@ export async function createController(options) {
368
384
  push(outcome.done ? "progress" : "notice", compactionLine(outcome));
369
385
  }
370
386
  else if (name === "work" && sub === "list")
371
- await capture((write) => workList({ workspaceRoot: options.workspaceRoot, write }));
387
+ await capture((write) => workList({ workspaceRoot: options.workspaceRoot, as: options.as, write }));
372
388
  else if (name === "work" && (sub === "show" || sub === "resume") && !args[1])
373
389
  push("notice", `/work ${sub} には Work の id が要ります。/work list で確かめてください。`);
374
390
  else if (name === "work" && sub === "show" && id)
375
- await capture((write) => workShow({ workspaceRoot: options.workspaceRoot, id, write }));
391
+ await capture((write) => workShow({ workspaceRoot: options.workspaceRoot, id, as: options.as, write }));
376
392
  else if (name === "work" && sub === "resume" && id) {
377
393
  try {
378
394
  const work = await session.select(id);
@@ -419,7 +435,7 @@ export async function createController(options) {
419
435
  throw new Error(`${sub} は人の承認待ちです。/approve か /reject で決めます。`);
420
436
  }
421
437
  const role = held.reviewer?.role ?? "reviewer";
422
- const who = await askLine(`${role} の名前と資格(例: 田中 太郎 / 税理士)。会社の申告として記録します`);
438
+ const who = await askLine(`${qualifiedAs(held.reviewer?.role)}の名前と資格(例: 田中 太郎 / 税理士)。会社の申告として記録します`);
423
439
  const [reviewerName, qualification] = who.split("/").map((part) => part.trim());
424
440
  const interpretation = await askLine(decision === "approve" ? "判断の本文(そのまま記録します)" : "認めない理由");
425
441
  const { text } = await session.review({
@@ -2,6 +2,8 @@ import type { RuntimeProviders } from "@openshain/core";
2
2
  export interface TuiOptions {
3
3
  workspaceRoot: string;
4
4
  providers: RuntimeProviders;
5
+ /** Who this terminal works for, when the person said so. */
6
+ as?: string | undefined;
5
7
  }
6
8
  /** Opens the conversation screen and returns when the person leaves it. */
7
9
  export declare function startTui(options: TuiOptions): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openshain",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Reference CLI of the openshain agent harness",
5
5
  "keywords": [
6
6
  "openshain",
@@ -49,10 +49,10 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@modelcontextprotocol/sdk": "1.30.0",
52
- "@openshain/agent": "0.6.0",
53
- "@openshain/core": "0.6.0",
54
- "@openshain/mcp": "0.6.0",
55
- "@openshain/tools": "0.6.0",
52
+ "@openshain/agent": "0.8.0",
53
+ "@openshain/core": "0.8.0",
54
+ "@openshain/mcp": "0.8.0",
55
+ "@openshain/tools": "0.8.0",
56
56
  "ink": "7.1.1",
57
57
  "marked": "18.0.12",
58
58
  "react": "19.2.8"
package/src/bin.ts CHANGED
@@ -7,6 +7,7 @@ import { init } from "./commands/init.ts";
7
7
  import { knowledgeBuild, knowledgeCheck } from "./commands/knowledge.ts";
8
8
  import { knowledgeAdd } from "./commands/knowledge-add.ts";
9
9
  import { mcp } from "./commands/mcp.ts";
10
+ import { principalCheck } from "./commands/principal.ts";
10
11
  import { toolsList } from "./commands/tools.ts";
11
12
  import { workList, workShow } from "./commands/work.ts";
12
13
  import { plain } from "./format.ts";
@@ -23,8 +24,11 @@ const USAGE = `使い方:
23
24
  openshain knowledge build knowledge/ の決まりと資料を検証して索引を作る
24
25
  openshain knowledge check 同じ検証を、索引を書かずに行う(--stale で古い資料も報告)
25
26
  openshain knowledge add 決まりを 1 件、質問に答えて追加する
27
+ openshain principal check <id> その人の社員エージェントがどこで働き、何ができるかを確かめる
26
28
  openshain mcp MCP Server を stdio で起動する
27
29
 
30
+ --principal <id> この端末が誰として働くか。principals/ に書いた id
31
+ 環境変数 OPENSHAIN_PRINCIPAL でも指定できる。本人確認はしない
28
32
  --workspace <dir> 起点のディレクトリ。省略時はカレントディレクトリ
29
33
  init はそこに書き、他のコマンドはそこから上に openshain.yaml を探す`;
30
34
 
@@ -37,15 +41,25 @@ const providers: RuntimeProviders = {
37
41
  tools: { standard: (workspaceRoot) => standardTools(workspaceRoot) },
38
42
  };
39
43
 
44
+ /**
45
+ * Who this terminal works for. The flag wins over the variable; neither is written into the
46
+ * company folder, because the folder is shared and the choice belongs to the machine.
47
+ */
48
+ function actingFor(flag: string | undefined): string | undefined {
49
+ const named = flag ?? process.env.OPENSHAIN_PRINCIPAL;
50
+ return named === undefined || named === "" ? undefined : named;
51
+ }
52
+
40
53
  async function main(argv: string[]): Promise<number> {
41
54
  const write = (line: string) => console.log(plain(line));
42
- let values: { workspace?: string; help?: boolean; stale?: boolean };
55
+ let values: { workspace?: string; principal?: string; help?: boolean; stale?: boolean };
43
56
  let positionals: string[];
44
57
  try {
45
58
  ({ values, positionals } = parseArgs({
46
59
  args: argv,
47
60
  options: {
48
61
  workspace: { type: "string" },
62
+ principal: { type: "string" },
49
63
  help: { type: "boolean", short: "h" },
50
64
  stale: { type: "boolean" },
51
65
  },
@@ -62,7 +76,7 @@ async function main(argv: string[]): Promise<number> {
62
76
  const [command, ...rest] = positionals;
63
77
  if (!command && !values.help && process.stdin.isTTY === true && process.stdout.isTTY === true) {
64
78
  const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
65
- return startTui({ workspaceRoot, providers });
79
+ return startTui({ workspaceRoot, providers, as: actingFor(values.principal) });
66
80
  }
67
81
  if (values.help || !command) {
68
82
  write(USAGE);
@@ -74,7 +88,7 @@ async function main(argv: string[]): Promise<number> {
74
88
  return 0;
75
89
  case "mcp": {
76
90
  const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
77
- await mcp({ workspaceRoot, providers });
91
+ await mcp({ workspaceRoot, providers, as: actingFor(values.principal) });
78
92
  return 0;
79
93
  }
80
94
  case "tools": {
@@ -97,6 +111,15 @@ async function main(argv: string[]): Promise<number> {
97
111
  const run = sub === "build" ? knowledgeBuild : knowledgeCheck;
98
112
  return await run({ workspaceRoot, write, ...(values.stale === true && { stale: true }) });
99
113
  }
114
+ case "principal": {
115
+ const id = rest[1] ?? "";
116
+ if (rest[0] !== "check" || !id) {
117
+ write(USAGE);
118
+ return 2;
119
+ }
120
+ const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
121
+ return await principalCheck({ workspaceRoot, id, write });
122
+ }
100
123
  case "work": {
101
124
  const sub = rest[0];
102
125
  const id = rest[1] ?? "";
@@ -105,11 +128,12 @@ async function main(argv: string[]): Promise<number> {
105
128
  return 2;
106
129
  }
107
130
  const workspaceRoot = await findWorkspace(values.workspace ?? process.cwd());
131
+ const as = actingFor(values.principal);
108
132
  if (sub === "list") {
109
- await workList({ workspaceRoot, write });
133
+ await workList({ workspaceRoot, as, write });
110
134
  return 0;
111
135
  }
112
- await workShow({ workspaceRoot, id, write });
136
+ await workShow({ workspaceRoot, id, as, write });
113
137
  return 0;
114
138
  }
115
139
  default:
@@ -45,9 +45,21 @@ limits:
45
45
 
46
46
  export const CONFIG_TEMPLATE = configTemplate("ja", "Asia/Tokyo");
47
47
 
48
- /** Registers the runtime as a project MCP server for Claude Code. `openshain` must be on PATH. */
48
+ /**
49
+ * Registers the runtime as a project MCP server for Claude Code. `openshain` must be on PATH.
50
+ * With more than one person in the company, each person adds `env` naming themselves to their own
51
+ * copy: Claude Code starts the server itself, so a flag on the terminal does not reach it. JSON
52
+ * carries no comments, so init says that in what it prints instead.
53
+ */
49
54
  export const MCP_TEMPLATE = `${JSON.stringify(
50
- { mcpServers: { openshain: { command: "openshain", args: ["mcp"] } } },
55
+ {
56
+ mcpServers: {
57
+ openshain: {
58
+ command: "openshain",
59
+ args: ["mcp"],
60
+ },
61
+ },
62
+ },
51
63
  null,
52
64
  2,
53
65
  )}\n`;
@@ -118,6 +130,9 @@ export async function init({ workspaceRoot, write }: InitOptions): Promise<void>
118
130
  write(
119
131
  "company と principal を自分の会社に合わせ、api_key_env に書いた環境変数を設定してから openshain を実行してください。",
120
132
  );
133
+ write(
134
+ '会社に人が複数いるときは principals/<id>.yaml に 1 人ずつ書き、各自の端末で openshain --principal <id> を実行します。Claude Code から使う人は、自分の .mcp.json の openshain に env: { OPENSHAIN_PRINCIPAL: "<id>" } を追加します。',
135
+ );
121
136
  }
122
137
 
123
138
  /**
@@ -5,14 +5,16 @@ import { createMcpServer } from "@openshain/mcp";
5
5
  export interface McpOptions {
6
6
  workspaceRoot: string;
7
7
  providers: RuntimeProviders;
8
+ /** Who this server works for, when the person said so. */
9
+ as?: string | undefined;
8
10
  }
9
11
 
10
12
  /**
11
13
  * Serves the workspace over MCP on stdin and stdout until the client hangs up. Nothing else may
12
14
  * be written to stdout while it runs; the protocol owns it.
13
15
  */
14
- export async function mcp({ workspaceRoot, providers }: McpOptions): Promise<void> {
15
- const server = await createMcpServer({ workspaceRoot, tools: providers.tools });
16
+ export async function mcp({ workspaceRoot, providers, as }: McpOptions): Promise<void> {
17
+ const server = await createMcpServer({ workspaceRoot, tools: providers.tools, as });
16
18
  const transport = new StdioServerTransport();
17
19
  await new Promise<void>((resolve) => {
18
20
  server.onclose = () => resolve();
@@ -0,0 +1,151 @@
1
+ import type { Dirent } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join, relative, sep } from "node:path";
4
+ import {
5
+ type Authority,
6
+ loadAuthority,
7
+ matchGlob,
8
+ mayReachInto,
9
+ mayRead,
10
+ PRINCIPALS_DIR_NAME,
11
+ type Principal,
12
+ RESERVED_PATHS,
13
+ reaches,
14
+ } from "@openshain/core";
15
+
16
+ export interface PrincipalOptions {
17
+ workspaceRoot: string;
18
+ id: string;
19
+ write: (line: string) => void;
20
+ }
21
+
22
+ /** How many matching paths are printed before the rest are counted instead. */
23
+ const SHOWN = 20;
24
+
25
+ /**
26
+ * What one person's employee agent works on, and what it may do. Written by hand, a range and a
27
+ * table of rules are easy to get subtly wrong: a glob that matches nothing, a delegation nobody
28
+ * wrote, a rule that allows a path the range does not cover. Each of those fails quietly, so this
29
+ * command says what the files actually add up to before somebody relies on them.
30
+ */
31
+ export async function principalCheck(options: PrincipalOptions): Promise<number> {
32
+ const { workspaceRoot, id, write } = options;
33
+ let authority: Authority;
34
+ try {
35
+ authority = await loadAuthority(workspaceRoot);
36
+ } catch (err) {
37
+ write(err instanceof Error ? err.message : String(err));
38
+ return 1;
39
+ }
40
+ const person = authority.principals.get(id);
41
+ if (!person) {
42
+ const written = [...authority.principals.keys()];
43
+ write(
44
+ written.length === 0
45
+ ? `${PRINCIPALS_DIR_NAME}/ に誰も書かれていません。1 人 1 ファイルで ${PRINCIPALS_DIR_NAME}/<id>.yaml に書きます。`
46
+ : `${id} は ${PRINCIPALS_DIR_NAME}/ にいません。書かれているのは ${written.join("、")} です。`,
47
+ );
48
+ return 1;
49
+ }
50
+
51
+ const problems: string[] = [];
52
+ write(`${person.name}(${person.id})`);
53
+ write(` 状態: ${person.status === "active" ? "在籍" : "退任(代理も承認もできません)"}`);
54
+ write(` 担当: ${person.roles.length === 0 ? "なし" : person.roles.join("、")}`);
55
+
56
+ const delegations = authority.delegations.filter((d) => d.principal === person.id);
57
+ if (!authority.present) {
58
+ write(" 委任: authority/ が無いので、すべて許可です");
59
+ } else if (delegations.length === 0) {
60
+ write(" 委任: ありません。この人の代理では何も実行できません");
61
+ problems.push(`${person.id} の委任が authority/delegations.yaml にありません`);
62
+ } else {
63
+ write(` 委任: ${delegations.map((d) => d.profession).join("、")} として`);
64
+ }
65
+
66
+ if (person.reads === undefined) {
67
+ write(" 働く範囲: 会社フォルダ全体(reads を書いていません)");
68
+ } else {
69
+ write(` 働く範囲: ${person.reads.join("、")}`);
70
+ const matched = await matching(workspaceRoot, person);
71
+ if (matched.length === 0) {
72
+ write(" 一致するものがありません");
73
+ problems.push(`${person.id} の reads に一致するファイルもフォルダもありません`);
74
+ } else {
75
+ write(` 一致するもの ${matched.length} 件`);
76
+ for (const path of matched.slice(0, SHOWN)) write(` ${path}`);
77
+ if (matched.length > SHOWN) write(` ほか ${matched.length - SHOWN} 件`);
78
+ }
79
+ }
80
+
81
+ const byRole = authority.policy.rules.filter((rule) => named(rule.match.role, person.roles));
82
+ if (byRole.length > 0) {
83
+ write(" 担当で一致する規則:");
84
+ for (const rule of byRole) write(` ${rule.id}(${rule.decision})`);
85
+ }
86
+
87
+ const outside = person.reads === undefined ? [] : allowedOutside(authority, person);
88
+ if (outside.length > 0) {
89
+ write(" 範囲の外を allow している規則:");
90
+ for (const rule of outside) write(` ${rule}`);
91
+ problems.push(`${person.id} の範囲の外を allow している規則があります。規則は範囲を広げません`);
92
+ }
93
+
94
+ if (problems.length === 0) {
95
+ write("問題はありません。");
96
+ return 0;
97
+ }
98
+ for (const problem of problems) write(problem);
99
+ return 1;
100
+ }
101
+
102
+ /** Everything in the company folder this person's agent works on, as workspace-relative paths. */
103
+ async function matching(workspaceRoot: string, person: Principal): Promise<string[]> {
104
+ const found: string[] = [];
105
+ const walk = async (dir: string): Promise<void> => {
106
+ let entries: Dirent[];
107
+ try {
108
+ entries = await readdir(dir, { withFileTypes: true });
109
+ } catch {
110
+ return;
111
+ }
112
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
113
+ const full = join(dir, entry.name);
114
+ const path = relative(workspaceRoot, full).split(sep).join("/");
115
+ if (entry.name.startsWith(".")) continue;
116
+ if (dir === workspaceRoot && reserved(entry.name)) continue;
117
+ if (mayRead(person, path)) found.push(path);
118
+ if (entry.isDirectory() && mayReachInto(person, path)) await walk(full);
119
+ }
120
+ };
121
+ await walk(workspaceRoot);
122
+ return found;
123
+ }
124
+
125
+ /** Rules that allow a path the range does not cover: written in hope, and never effective. */
126
+ function allowedOutside(authority: Authority, person: Principal): string[] {
127
+ const out: string[] = [];
128
+ for (const rule of authority.policy.rules) {
129
+ const path = rule.match.path;
130
+ if (rule.decision !== "allow" || path === undefined) continue;
131
+ if (rule.match.principal !== undefined && !named(rule.match.principal, [person.id])) continue;
132
+ if (rule.match.role !== undefined && !named(rule.match.role, person.roles)) continue;
133
+ const covered = (person.reads ?? []).some(
134
+ (range) => matchGlob(range, path) || reaches(range, path) || matchGlob(path, range),
135
+ );
136
+ if (!covered) out.push(`${rule.id}(${path})`);
137
+ }
138
+ return out;
139
+ }
140
+
141
+ function named(expected: string | string[] | undefined, actual: readonly string[]): boolean {
142
+ if (expected === undefined) return false;
143
+ const wanted = Array.isArray(expected) ? expected : [expected];
144
+ return wanted.some((one) => actual.includes(one));
145
+ }
146
+
147
+ function reserved(name: string): boolean {
148
+ return (RESERVED_PATHS as readonly string[]).some(
149
+ (path) => path.toLowerCase() === name.toLowerCase(),
150
+ );
151
+ }
@@ -1,9 +1,16 @@
1
+ import { join } from "node:path";
1
2
  import {
2
3
  type AnyEvent,
3
4
  type Event,
5
+ loadConfig,
6
+ mayReadWork,
7
+ noSuchWork,
8
+ PRINCIPALS_DIR_NAME,
9
+ type Principal,
4
10
  parseWorkId,
5
11
  pendingApprovals,
6
12
  pendingQuestions,
13
+ readPrincipals,
7
14
  type Work,
8
15
  WorkStore,
9
16
  } from "@openshain/core";
@@ -14,12 +21,28 @@ import { formatUsage, summarizeUsage } from "../usage.ts";
14
21
 
15
22
  export interface WorkListOptions {
16
23
  workspaceRoot: string;
24
+ /** Who this terminal works for, when the person said so. */
25
+ as?: string | undefined;
17
26
  write: (line: string) => void;
18
27
  }
19
28
 
29
+ /**
30
+ * The person this terminal works for, out of the people the company has written. Nobody written:
31
+ * nobody to be, and every work is read, as before there was more than one person.
32
+ */
33
+ async function actingPerson(workspaceRoot: string, as?: string): Promise<Principal | undefined> {
34
+ const config = await loadConfig(workspaceRoot, { ...(as !== undefined && { as }) });
35
+ const people = await readPrincipals(join(workspaceRoot, PRINCIPALS_DIR_NAME));
36
+ return people.get(config.principal.id);
37
+ }
38
+
20
39
  /** One line per work, oldest first. Works that cannot be read are reported, not hidden. */
21
- export async function workList({ workspaceRoot, write }: WorkListOptions): Promise<void> {
22
- const { works, problems } = await new WorkStore(workspaceRoot).list();
40
+ export async function workList({ workspaceRoot, as, write }: WorkListOptions): Promise<void> {
41
+ const me = await actingPerson(workspaceRoot, as);
42
+ const { works: all, problems: unreadable } = await new WorkStore(workspaceRoot).list();
43
+ const works = all.filter((w) => mayReadWork(me, w.principal));
44
+ // A work that cannot be read does not say whose it is, so a range leaves it out.
45
+ const problems = me?.reads === undefined ? unreadable : [];
23
46
  if (works.length === 0 && problems.length === 0) {
24
47
  write("Work はまだありません。openshain で社員エージェントに依頼すると始まります。");
25
48
  return;
@@ -37,14 +60,19 @@ export async function workList({ workspaceRoot, write }: WorkListOptions): Promi
37
60
  export interface WorkShowOptions {
38
61
  workspaceRoot: string;
39
62
  id: string;
63
+ /** Who this terminal works for, when the person said so. */
64
+ as?: string | undefined;
40
65
  write: (line: string) => void;
41
66
  }
42
67
 
43
68
  /** Everything about one work: state, outcome, what the tools did, the usage, and who acts next. */
44
- export async function workShow({ workspaceRoot, id, write }: WorkShowOptions): Promise<void> {
69
+ export async function workShow({ workspaceRoot, id, as, write }: WorkShowOptions): Promise<void> {
45
70
  const store = new WorkStore(workspaceRoot);
46
71
  const workId = parseWorkId(id);
47
72
  const work = await store.get(workId);
73
+ if (!mayReadWork(await actingPerson(workspaceRoot, as), work.principal)) {
74
+ throw noSuchWork(workspaceRoot, workId);
75
+ }
48
76
  const events = await store.events(workId);
49
77
  for (const line of describeWork(work, events)) write(line);
50
78
  }
@@ -74,7 +102,7 @@ export function describeWork(work: Work, events: AnyEvent[]): string[] {
74
102
  }
75
103
  if (work.status === "waiting_approval") {
76
104
  for (const a of pendingApprovals(events)) {
77
- const who = a.kind === "review" ? `${a.reviewer?.role ?? "資格者"}の判断待ち` : "承認待ち";
105
+ const who = a.kind === "review" ? `${qualifiedAs(a.reviewer?.role)}の判断待ち` : "承認待ち";
78
106
  lines.push(`${who} ${a.call.name} ${describeInput(a.call.input)} (${a.approvalId})`);
79
107
  }
80
108
  }
@@ -89,6 +117,15 @@ export function describeWork(work: Work, events: AnyEvent[]): string[] {
89
117
  return lines;
90
118
  }
91
119
 
120
+ /**
121
+ * How a reviewer is named on the screen. The role of a reviewer is a qualification the company
122
+ * names (tax-accountant), not one of the roles a person handles, so the word comes first and the
123
+ * name the company wrote stays with it.
124
+ */
125
+ export function qualifiedAs(role: string | undefined): string {
126
+ return role === undefined ? "資格者" : `資格者(${role})`;
127
+ }
128
+
92
129
  /** One line per tool call, in log order, with its outcome when it was rejected or failed. */
93
130
  function toolLines(events: AnyEvent[]): string[] {
94
131
  const lines = new Map<string, string>();
package/src/labels.ts CHANGED
@@ -31,6 +31,9 @@ export const REJECTION_LABELS: Record<ToolRejectionCode, string> = {
31
31
  denied: "権限の表で不許可",
32
32
  rejected_by_person: "承認されなかった",
33
33
  path_changed: "承認したときと書き込み先が変わった",
34
+ base_changed: "承認したときからファイルが変わった",
35
+ not_read: "読まずに置き換えようとした",
36
+ out_of_range: "範囲の外",
34
37
  };
35
38
 
36
39
  /** A heading for a runtime error, before the original message. */
@@ -18,7 +18,7 @@ import {
18
18
  } from "@openshain/core";
19
19
  import { createMcpServer } from "@openshain/mcp";
20
20
  import { toolsList } from "../commands/tools.ts";
21
- import { workList, workShow } from "../commands/work.ts";
21
+ import { qualifiedAs, workList, workShow } from "../commands/work.ts";
22
22
  import { describeInput, plain } from "../format.ts";
23
23
  import { statusLabel } from "../labels.ts";
24
24
  import { type PreviewLine, previewCall } from "../preview.ts";
@@ -94,6 +94,8 @@ export interface Controller {
94
94
  export interface ControllerOptions {
95
95
  workspaceRoot: string;
96
96
  providers: RuntimeProviders;
97
+ /** Who this terminal works for, when the person said so. */
98
+ as?: string | undefined;
97
99
  }
98
100
 
99
101
  /**
@@ -176,7 +178,10 @@ const APPROVAL_WITHDRAWN = "the person left the approval undecided";
176
178
  */
177
179
  export async function createController(options: ControllerOptions): Promise<Controller> {
178
180
  const { workspaceRoot, providers } = options;
179
- const config = await loadConfig(workspaceRoot, { modelProviders: Object.keys(providers.models) });
181
+ const config = await loadConfig(workspaceRoot, {
182
+ modelProviders: Object.keys(providers.models),
183
+ as: options.as,
184
+ });
180
185
  if (!config.model) {
181
186
  throw new OpenshainError(
182
187
  "config",
@@ -196,7 +201,13 @@ export async function createController(options: ControllerOptions): Promise<Cont
196
201
  `model ${config.model.provider}/${config.model.model} cannot call tools; openshain needs a model with tool support`,
197
202
  );
198
203
  }
199
- const server = await createMcpServer({ workspaceRoot, tools: providers.tools });
204
+ // The name the person gave on the command line reaches the runtime too: it is what the work is
205
+ // recorded under, and what the range is read from.
206
+ const server = await createMcpServer({
207
+ workspaceRoot,
208
+ tools: providers.tools,
209
+ ...(options.as !== undefined && { as: options.as }),
210
+ });
200
211
  const client = await connectInMemory(server);
201
212
  const store = new WorkStore(workspaceRoot);
202
213
  const listeners = new Set<() => void>();
@@ -386,6 +397,13 @@ export async function createController(options: ControllerOptions): Promise<Cont
386
397
  for (const row of LOGO_ROWS) push("logo", row);
387
398
  push("banner", `openshain ${VERSION}`);
388
399
  push("banner", workspaceRoot);
400
+ // Said where it is used, not only in the documentation: the name is a choice, not a check.
401
+ if (options.as) {
402
+ push(
403
+ "banner",
404
+ `${config.principal.name}(${config.principal.id})として実行します。本人確認はしていません`,
405
+ );
406
+ }
389
407
 
390
408
  const stopped = (workId: WorkId | undefined) =>
391
409
  workId
@@ -399,7 +417,10 @@ export async function createController(options: ControllerOptions): Promise<Cont
399
417
  case "aborted":
400
418
  return stopped(result.work);
401
419
  case "max_tokens":
402
- return "返答が長さの上限で切れました。";
420
+ // The answer ran into the limit mid-sentence, so a work it opened holds nothing yet.
421
+ return result.work
422
+ ? `返答が長さの上限で切れました。${result.work} は途中のまま残っています。/work resume ${result.work} で続けられるようにします。一度に扱う量を分けて依頼すると、上限に当たらずに終わります。`
423
+ : "返答が長さの上限で切れました。";
403
424
  case "refusal":
404
425
  return "社員エージェントが続けられないと言っています。";
405
426
  case "model_error":
@@ -465,11 +486,15 @@ export async function createController(options: ControllerOptions): Promise<Cont
465
486
  const outcome = await session.compact();
466
487
  push(outcome.done ? "progress" : "notice", compactionLine(outcome));
467
488
  } else if (name === "work" && sub === "list")
468
- await capture((write) => workList({ workspaceRoot: options.workspaceRoot, write }));
489
+ await capture((write) =>
490
+ workList({ workspaceRoot: options.workspaceRoot, as: options.as, write }),
491
+ );
469
492
  else if (name === "work" && (sub === "show" || sub === "resume") && !args[1])
470
493
  push("notice", `/work ${sub} には Work の id が要ります。/work list で確かめてください。`);
471
494
  else if (name === "work" && sub === "show" && id)
472
- await capture((write) => workShow({ workspaceRoot: options.workspaceRoot, id, write }));
495
+ await capture((write) =>
496
+ workShow({ workspaceRoot: options.workspaceRoot, id, as: options.as, write }),
497
+ );
473
498
  else if (name === "work" && sub === "resume" && id) {
474
499
  try {
475
500
  const work = await session.select(id as WorkId);
@@ -512,7 +537,7 @@ export async function createController(options: ControllerOptions): Promise<Cont
512
537
  }
513
538
  const role = held.reviewer?.role ?? "reviewer";
514
539
  const who = await askLine(
515
- `${role} の名前と資格(例: 田中 太郎 / 税理士)。会社の申告として記録します`,
540
+ `${qualifiedAs(held.reviewer?.role)}の名前と資格(例: 田中 太郎 / 税理士)。会社の申告として記録します`,
516
541
  );
517
542
  const [reviewerName, qualification] = who.split("/").map((part) => part.trim());
518
543
  const interpretation = await askLine(
package/src/tui/index.ts CHANGED
@@ -7,6 +7,8 @@ import { createController } from "./controller.ts";
7
7
  export interface TuiOptions {
8
8
  workspaceRoot: string;
9
9
  providers: RuntimeProviders;
10
+ /** Who this terminal works for, when the person said so. */
11
+ as?: string | undefined;
10
12
  }
11
13
 
12
14
  /** The alternate screen, cleared, with the terminal reporting the mouse (SGR) so the wheel reaches the screen. */