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 +27 -4
- package/dist/commands/init.d.ts +6 -1
- package/dist/commands/init.js +15 -2
- package/dist/commands/mcp.d.ts +3 -1
- package/dist/commands/mcp.js +2 -2
- package/dist/commands/principal.d.ts +12 -0
- package/dist/commands/principal.js +136 -0
- package/dist/commands/work.d.ts +12 -2
- package/dist/commands/work.js +30 -5
- package/dist/labels.js +3 -0
- package/dist/tui/controller.d.ts +2 -0
- package/dist/tui/controller.js +23 -7
- package/dist/tui/index.d.ts +2 -0
- package/package.json +5 -5
- package/src/bin.ts +29 -5
- package/src/commands/init.ts +17 -2
- package/src/commands/mcp.ts +4 -2
- package/src/commands/principal.ts +151 -0
- package/src/commands/work.ts +41 -4
- package/src/labels.ts +3 -0
- package/src/tui/controller.ts +32 -7
- package/src/tui/index.ts +2 -0
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:
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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";
|
package/dist/commands/init.js
CHANGED
|
@@ -42,8 +42,20 @@ limits:
|
|
|
42
42
|
# persist_raw: true # provider の生の応答を記録に残す
|
|
43
43
|
`;
|
|
44
44
|
export const CONFIG_TEMPLATE = configTemplate("ja", "Asia/Tokyo");
|
|
45
|
-
/**
|
|
46
|
-
|
|
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.
|
package/dist/commands/mcp.d.ts
CHANGED
|
@@ -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>;
|
package/dist/commands/mcp.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/commands/work.d.ts
CHANGED
|
@@ -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;
|
package/dist/commands/work.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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 = {
|
package/dist/tui/controller.d.ts
CHANGED
|
@@ -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
|
package/dist/tui/controller.js
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
package/dist/tui/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
53
|
-
"@openshain/core": "0.
|
|
54
|
-
"@openshain/mcp": "0.
|
|
55
|
-
"@openshain/tools": "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:
|
package/src/commands/init.ts
CHANGED
|
@@ -45,9 +45,21 @@ limits:
|
|
|
45
45
|
|
|
46
46
|
export const CONFIG_TEMPLATE = configTemplate("ja", "Asia/Tokyo");
|
|
47
47
|
|
|
48
|
-
/**
|
|
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
|
-
{
|
|
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
|
/**
|
package/src/commands/mcp.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/work.ts
CHANGED
|
@@ -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
|
|
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. */
|
package/src/tui/controller.ts
CHANGED
|
@@ -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, {
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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) =>
|
|
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. */
|