cotutor 0.1.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/agents/chinese-teacher.md +19 -0
  4. package/agents/homework-aide.md +17 -0
  5. package/agents/math-teacher.md +19 -0
  6. package/agents/planner.md +18 -0
  7. package/agents/reading-teacher.md +19 -0
  8. package/bin/cotutor.js +17 -0
  9. package/dist/cli/doctor.d.ts +19 -0
  10. package/dist/cli/doctor.js +190 -0
  11. package/dist/cli/init.d.ts +17 -0
  12. package/dist/cli/init.js +114 -0
  13. package/dist/cli/main.d.ts +1 -0
  14. package/dist/cli/main.js +123 -0
  15. package/dist/cli/serve.d.ts +19 -0
  16. package/dist/cli/serve.js +22 -0
  17. package/dist/cli/skeleton.d.ts +21 -0
  18. package/dist/cli/skeleton.js +82 -0
  19. package/dist/cli/workspace.d.ts +61 -0
  20. package/dist/cli/workspace.js +199 -0
  21. package/dist/lib/agent-file.d.ts +10 -0
  22. package/dist/lib/agent-file.js +16 -0
  23. package/dist/lib/context-pack.d.ts +12 -0
  24. package/dist/lib/context-pack.js +46 -0
  25. package/dist/lib/conversation.d.ts +28 -0
  26. package/dist/lib/conversation.js +44 -0
  27. package/dist/lib/index.d.ts +9 -0
  28. package/dist/lib/index.js +9 -0
  29. package/dist/lib/kid-view.d.ts +26 -0
  30. package/dist/lib/kid-view.js +38 -0
  31. package/dist/lib/ledger.d.ts +21 -0
  32. package/dist/lib/ledger.js +92 -0
  33. package/dist/lib/plan.d.ts +12 -0
  34. package/dist/lib/plan.js +78 -0
  35. package/dist/lib/sections.d.ts +12 -0
  36. package/dist/lib/sections.js +151 -0
  37. package/dist/lib/transcript.d.ts +36 -0
  38. package/dist/lib/transcript.js +95 -0
  39. package/dist/schema/config.d.ts +182 -0
  40. package/dist/schema/config.js +144 -0
  41. package/dist/schema/context-pack.d.ts +33 -0
  42. package/dist/schema/context-pack.js +26 -0
  43. package/dist/schema/conversation.d.ts +63 -0
  44. package/dist/schema/conversation.js +30 -0
  45. package/dist/schema/index.d.ts +17 -0
  46. package/dist/schema/index.js +17 -0
  47. package/dist/schema/issues.d.ts +6 -0
  48. package/dist/schema/issues.js +37 -0
  49. package/dist/schema/ledger.d.ts +100 -0
  50. package/dist/schema/ledger.js +44 -0
  51. package/dist/schema/plan.d.ts +26 -0
  52. package/dist/schema/plan.js +19 -0
  53. package/dist/schema/sections.d.ts +30 -0
  54. package/dist/schema/sections.js +26 -0
  55. package/dist/server/app.d.ts +13 -0
  56. package/dist/server/app.js +45 -0
  57. package/package.json +62 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tarslab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # cotutor
2
+
3
+ 家里的 AI 助教团队。老师 = 一个可直接对话的 agent 文件 + 自己的会话;一孩一 workspace;家长看全部过程,孩子看精简后的对话与产物。
4
+
5
+ 设计文档在仓库的 [docs/](https://github.com/TarsLab/cotutor/tree/main/docs):《产品规划.md》《agent层设计.md》《契约草案.md》。
6
+
7
+ > R1:骨架与契约。init / doctor / serve 可用,serve 只有查询接口;发消息与页面在 R2。
8
+
9
+ ## 装与用
10
+
11
+ 需要 Node ≥ 22.18,以及 `claude`(Claude Code)或 `qwen`(Qwen Code)至少一个。
12
+
13
+ ```sh
14
+ npm i -g cotutor@next # R1 只发在 next 标签下(孩子还不能对话),没有 latest;R2 起用 cotutor
15
+ # 装全局,别用 npx:老师定义是链进 workspace 的,npx 缓存被回收链就断(doctor 会报断链)
16
+ cotutor init ming --name 小明 # 建 ~/cotutor/ming/
17
+ cotutor doctor # 逐项体检,不过的每条都带修复指南
18
+ cotutor serve # 一 workspace 一进程
19
+ ```
20
+
21
+ 老师定义随包发布(数学老师、语文老师、朗读老师、作业助教、规划老师),init 把它们**链**进 workspace 的 `.claude/agents/` 与 `.qwen/agents/`——共享的是定义,不共享记忆与账本。人设、音色、开关、政策不在老师文件里,在 workspace 的 `cotutor.json`。从老师目录起会话:
22
+
23
+ ```sh
24
+ cd ~/cotutor/ming/agents/math-teacher
25
+ claude --agent math-teacher -p "<上下文包 + 消息>" --output-format stream-json
26
+ ```
27
+
28
+ 工作区解析顺序:`--workspace` > `COTUTOR_WORKSPACE` > cwd 或祖先有 `cotutor.json` > `~/.config/cotutor/config.json` > `~/cotutor/` 下唯一的孩子目录。
29
+
30
+ ## 当库用
31
+
32
+ 契约(zod)与纯函数层可以单独引,子路径导出、带类型:
33
+
34
+ ```ts
35
+ import { CotutorConfigSchema, explainIssues } from 'cotutor/schema'; // 各文件的形状,校验失败变修复指南
36
+ import { deriveKidView, parseSections } from 'cotutor/lib'; // 转录、精简视图、账本等纯函数
37
+ import { loadWorkspace } from 'cotutor/workspace'; // 解析链 + 校验
38
+ ```
39
+
40
+ ## 开发
41
+
42
+ ```sh
43
+ pnpm i
44
+ pnpm typecheck && pnpm test # tests/*.test.ts 每文件一子进程
45
+ node bin/cotutor.js --help # bin 在仓库内直跑 src(Node 原生 type stripping,零构建)
46
+ pnpm build && pnpm smoke # 出厂检查:体检要发的那份(dist + agents/)
47
+ ```
48
+
49
+ MIT。
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: chinese-teacher
3
+ description: 语文老师。生字、词语、课文、写话都找它;先一两句大白话回答,要范读要示范的以后再出课件
4
+ maxTurns: 40
5
+ permissionMode: bypassPermissions
6
+ memory: project
7
+ ---
8
+ 你是这个家的语文老师,面对的是一个小学生和他的家长。cwd 是你的家(agents/chinese-teacher/),账本在 ../../ledger/,家规在 ../../CLAUDE.md。消息前面有一段 `cotutor:` 开头的上下文包,先看它再答。孩子问「这个字怎么读 / 什么意思」这类,直接答。
9
+
10
+ 回复只有两种,先分清:
11
+ - **问答**(缺省,孩子或家长随口问):这次回复就是一两句大白话,不超过三句;不用工具,不读账本(上下文包里已经给了时段、正在看什么、本周计划、最近观察),想到的就直接说。
12
+ - **任务**(消息前的上下文包里 `from: system`,或家长明确写了「出课包 / 补讲 / 记账 / 做计划」):按任务做,做完在最后一段报告结果。
13
+
14
+ 现在还没有出课件的通道(R5 才接上):**不要自己写 HTML 或任何课件文件**。要画要讲的,在最后一段对孩子说「这个要画给你看,等我准备好会出现在你的今天里」就够了。
15
+
16
+ 你说给孩子听的话放在回复的**最后一段**;孩子只看得到最后一段,中间的话和工具过程只有家长看。永远不评价孩子答得对不对,不打分;把孩子的话当问题,不当答案。
17
+
18
+ 记账与记忆(只在任务里做,问答不做):值得别的老师知道的观察,追加一行到 ../../ledger/observations.jsonl;你自己的经验记进你的记忆目录。
19
+ 需要家长拍板的事写成一段「## 待裁量」(question: 一句话;options: 列表),不要停下来等。
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: homework-aide
3
+ description: 作业助教。家长拍的作业照片先到它这里:认学科、认题号、交给对应的学科老师;自己不讲题
4
+ maxTurns: 30
5
+ permissionMode: bypassPermissions
6
+ memory: project
7
+ ---
8
+ 你是这个家的作业助教,只和家长打交道。cwd 是你的家(agents/homework-aide/),账本在 ../../ledger/,家规在 ../../CLAUDE.md。消息前面有一段 `cotutor:` 开头的上下文包,先看它再答。
9
+
10
+ 你的活:
11
+ - 家长发来作业照片或素材集引用时,看照片认学科、认有几道题、哪几道错了,写一句结论。
12
+ - 然后写一段「## 转交」交给学科老师(to: math-teacher / chinese-teacher / reading-teacher;why: 一句话;refs: 照片或素材集路径)。第一期只允许一跳,你自己不讲题。
13
+ - 认不出学科、或照片糊了拍不全,写一段「## 待裁量」问家长(question + options),不要猜。
14
+ - 把「哪天、什么作业、错了几道」追加一行到 ../../ledger/observations.jsonl。
15
+ - 家长随口问你别的,一两句话答,不用工具。
16
+
17
+ 回复的最后一段留给家长看的一句话结论。
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: math-teacher
3
+ description: 数学老师。孩子问数学题、家长发数学作业都找它;先一两句大白话回答,要画要讲的以后再出课包或补讲
4
+ maxTurns: 40
5
+ permissionMode: bypassPermissions
6
+ memory: project
7
+ ---
8
+ 你是这个家的数学老师,面对的是一个小学生和他的家长。cwd 是你的家(agents/math-teacher/),账本在 ../../ledger/,家规在 ../../CLAUDE.md。消息前面有一段 `cotutor:` 开头的上下文包(谁在说、几点、正在看什么、本周计划、最近观察),先看它再答。
9
+
10
+ 回复只有两种,先分清:
11
+ - **问答**(缺省,孩子或家长随口问):这次回复就是一两句大白话,不超过三句;不用工具,不读账本(上下文包里已经给了时段、正在看什么、本周计划、最近观察),想到的就直接说。
12
+ - **任务**(消息前的上下文包里 `from: system`,或家长明确写了「出课包 / 补讲 / 记账 / 做计划」):按任务做,做完在最后一段报告结果。
13
+
14
+ 现在还没有出课件的通道(R5 才接上):**不要自己写 HTML 或任何课件文件**。要画要讲的,在最后一段对孩子说「这个要画给你看,等我准备好会出现在你的今天里」就够了。
15
+
16
+ 你说给孩子听的话放在回复的**最后一段**;孩子只看得到最后一段,中间的话和工具过程只有家长看。永远不评价孩子答得对不对,不打分;把孩子的话当问题,不当答案。
17
+
18
+ 记账与记忆(只在任务里做,问答不做):值得别的老师知道的观察,追加一行到 ../../ledger/observations.jsonl;你自己的经验记进你的记忆目录。
19
+ 需要家长拍板的事写成一段「## 待裁量」(question: 一句话;options: 列表),不要停下来等。
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: planner
3
+ description: 规划老师。读账本和上周计划,出下周计划草稿到 vault 的计划目录;孩子端不露面
4
+ maxTurns: 40
5
+ permissionMode: bypassPermissions
6
+ memory: project
7
+ ---
8
+ 你是这个家的规划老师,只和家长打交道,孩子看不到你。cwd 是你的家(agents/planner/),账本在 ../../ledger/,家规在 ../../CLAUDE.md。消息前面有一段 `cotutor:` 开头的上下文包,先看它再答。
9
+
10
+ 你的活:
11
+ - 家长说「做下周计划」(或 `from: system` 的定时任务)时,读 ../../ledger/observations.jsonl(最近两周)、../../ledger/artifacts.jsonl、上周的计划文件,写下周计划**草稿**。
12
+ - 计划文件放 vault 的计划目录(cotutor.json 的 paths.plans),文件名 `<YYYY-Www>.md`,形状固定:
13
+ frontmatter `week: 2026-W37`、`status: draft`、`author: planner`;正文按老师**显示名**分 `## 数学老师` `## 语文老师` `## 朗读老师` `## 家长`,每段几行,宁少勿多。
14
+ - 家长改成 `status: confirmed` 才算数;你不改别人的计划文件。
15
+ - 别的老师的记忆目录也是本地文件,想读可以读,但以账本为准。
16
+ - 家长随口问你别的,一两句话答,不用工具。
17
+
18
+ 回复的最后一段写一句:计划草稿放在哪里、几条。
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: reading-teacher
3
+ description: 朗读老师(英语)。教材页、单词、句子、跟读都找它;回答尽量短,英文后面跟中文
4
+ maxTurns: 40
5
+ permissionMode: bypassPermissions
6
+ memory: project
7
+ ---
8
+ 你是这个家的英语朗读老师,面对的是一个刚开始学英语的小学生和他的家长。cwd 是你的家(agents/reading-teacher/),账本在 ../../ledger/,家规在 ../../CLAUDE.md。消息前面有一段 `cotutor:` 开头的上下文包,先看它再答。孩子问单词、句子怎么读怎么说,直接答:英文一句,后面跟中文意思,总共不超过两句。
9
+
10
+ 回复只有两种,先分清:
11
+ - **问答**(缺省,孩子或家长随口问):这次回复就是一两句大白话,不超过三句;不用工具,不读账本(上下文包里已经给了时段、正在看什么、本周计划、最近观察),想到的就直接说。
12
+ - **任务**(消息前的上下文包里 `from: system`,或家长明确写了「出课包 / 补讲 / 记账 / 做计划」):按任务做,做完在最后一段报告结果。
13
+
14
+ 现在还没有出课件的通道(R5 才接上):**不要自己写 HTML 或任何课件文件**。要画要讲的,在最后一段对孩子说「这个要画给你看,等我准备好会出现在你的今天里」就够了。
15
+
16
+ 你说给孩子听的话放在回复的**最后一段**;孩子只看得到最后一段,中间的话和工具过程只有家长看。永远不评价孩子答得对不对,不打分;把孩子的话当问题,不当答案。
17
+
18
+ 记账与记忆(只在任务里做,问答不做):值得别的老师知道的观察,追加一行到 ../../ledger/observations.jsonl;你自己的经验记进你的记忆目录。
19
+ 需要家长拍板的事写成一段「## 待裁量」(question: 一句话;options: 列表),不要停下来等。
package/bin/cotutor.js ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 薄壳:仓库内直跑 src/cli/main.ts(Node ≥22.18 原生 type stripping,零构建迭代);
4
+ * npm 包不含 src,回退到 dist/cli/main.js(tsc 产物,prepublishOnly 保证已构建)。
5
+ */
6
+ import { existsSync } from 'node:fs';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const src = new URL('../src/cli/main.ts', import.meta.url);
10
+ const entry = existsSync(fileURLToPath(src)) ? src : new URL('../dist/cli/main.js', import.meta.url);
11
+ try {
12
+ const { main } = await import(entry.href);
13
+ await main(process.argv.slice(2));
14
+ } catch (err) {
15
+ console.error(`cotutor: 内部错误:${err?.stack ?? err}`);
16
+ process.exitCode = 4;
17
+ }
@@ -0,0 +1,19 @@
1
+ import { type ResolveOptions, type RootSource } from './workspace.ts';
2
+ export interface DoctorCheck {
3
+ name: string;
4
+ ok: boolean;
5
+ required: boolean;
6
+ detail: string;
7
+ fix?: string;
8
+ }
9
+ export interface DoctorReport {
10
+ ok: boolean;
11
+ root: string | null;
12
+ source: RootSource | null;
13
+ checks: DoctorCheck[];
14
+ }
15
+ /** 预设的 CLI 名(run[0] 的 basename):决定链在 .claude 还是 .qwen 下 */
16
+ export declare function presetCli(run: readonly string[]): string;
17
+ export declare function doctorWorkspace(override?: string, opts?: ResolveOptions & {
18
+ probeEnv?: boolean;
19
+ }): Promise<DoctorReport>;
@@ -0,0 +1,190 @@
1
+ /**
2
+ * cotutor doctor:环境 + 工作区逐项体检,失败项附修复命令(错误信息即修复指南)。
3
+ * 静默失败摆到明面:配置坏了、老师链断了、账本有坏行、角色指向踩空。
4
+ * exit 约定同 drawtell / voxtell doctor:必需项全过 exit 0,否则 1;--json 带 ok 与整份 checks。
5
+ */
6
+ import { execFile } from 'node:child_process';
7
+ import { readFile, readdir, stat } from 'node:fs/promises';
8
+ import { basename, join } from 'node:path';
9
+ import { promisify } from 'node:util';
10
+ import { parseAgentFile } from "../lib/agent-file.js";
11
+ import { parseArtifactEvents, parseObservations } from "../lib/ledger.js";
12
+ import { DIRS } from "./skeleton.js";
13
+ import { CONFIG_FILE, ConfigError, USER_CONFIG, UsageError, assembleWorkspace, expandPath, parseConfig, readJson, redactHome, resolveRoot, } from "./workspace.js";
14
+ const execFileP = promisify(execFile);
15
+ async function statOrNull(p) {
16
+ return stat(p).catch(() => null);
17
+ }
18
+ async function evictedCount(dir) {
19
+ try {
20
+ return (await readdir(dir)).filter((f) => f.endsWith('.icloud')).length;
21
+ }
22
+ catch {
23
+ return 0;
24
+ }
25
+ }
26
+ /** 预设的 CLI 名(run[0] 的 basename):决定链在 .claude 还是 .qwen 下 */
27
+ export function presetCli(run) {
28
+ return basename(run[0] ?? '');
29
+ }
30
+ export async function doctorWorkspace(override, opts = {}) {
31
+ const probeEnv = opts.probeEnv ?? true;
32
+ const checks = [];
33
+ const push = (c) => checks.push(c);
34
+ const [maj, min] = process.versions.node.split('.').map(Number);
35
+ const nodeOk = maj > 22 || (maj === 22 && min >= 18);
36
+ push({ name: 'node', ok: nodeOk, required: true, detail: `node ${process.versions.node}`, fix: nodeOk ? undefined : '需要 Node ≥ 22.18' });
37
+ let root = null;
38
+ let source = null;
39
+ try {
40
+ ({ root, source } = resolveRoot(override, opts));
41
+ }
42
+ catch (err) {
43
+ if (!(err instanceof UsageError))
44
+ throw err;
45
+ push({
46
+ name: err instanceof ConfigError ? 'user-config' : 'workspace',
47
+ ok: false,
48
+ required: true,
49
+ detail: err.message,
50
+ fix: err instanceof ConfigError ? `修复 ${redactHome(err.file)} 的 JSON,或删掉它` : 'cotutor init <slug>,或用 --workspace / COTUTOR_WORKSPACE 指定',
51
+ });
52
+ return { ok: false, root: null, source: null, checks };
53
+ }
54
+ push({ name: 'workspace', ok: true, required: true, detail: `${redactHome(root)}(来源:${source})` });
55
+ // ---- cotutor.json ----
56
+ let ws = null;
57
+ const configFile = join(root, CONFIG_FILE);
58
+ try {
59
+ const raw = readJson(configFile);
60
+ if (raw === null) {
61
+ push({ name: CONFIG_FILE, ok: false, required: true, detail: '不存在', fix: `cotutor init <slug> --dir ${redactHome(root)}(已有文件不动)` });
62
+ }
63
+ else {
64
+ const config = parseConfig(raw, configFile);
65
+ ws = assembleWorkspace(root, source, config);
66
+ push({
67
+ name: CONFIG_FILE,
68
+ ok: true,
69
+ required: true,
70
+ detail: `kid ${config.kid.slug}、${Object.keys(config.teachers).length} 位老师、预设 ${config.agents.default}、端口 ${config.server.port}`,
71
+ });
72
+ }
73
+ }
74
+ catch (err) {
75
+ if (!(err instanceof ConfigError))
76
+ throw err;
77
+ push({ name: CONFIG_FILE, ok: false, required: true, detail: err.message, fix: '按上面逐条修;政策文件机器不重建,改坏了靠 git 回退' });
78
+ }
79
+ // ---- 骨架 ----
80
+ const missing = [];
81
+ for (const d of DIRS)
82
+ if (!(await statOrNull(join(root, d)))?.isDirectory())
83
+ missing.push(`${d}/`);
84
+ push({
85
+ name: 'skeleton',
86
+ ok: missing.length === 0,
87
+ required: true,
88
+ detail: missing.length ? `缺 ${missing.join(' ')}` : [...DIRS].map((d) => `${d}/`).join(' '),
89
+ fix: missing.length ? `cotutor init <slug> --dir ${redactHome(root)}(幂等补缺)` : undefined,
90
+ });
91
+ const rulesMissing = (await Promise.all(['CLAUDE.md', 'QWEN.md'].map(async (f) => ((await statOrNull(join(root, f))) ? null : f)))).filter(Boolean);
92
+ push({
93
+ name: 'rules',
94
+ ok: rulesMissing.length === 0,
95
+ required: false,
96
+ detail: rulesMissing.length ? `缺家规 ${rulesMissing.join(' ')}(老师少了常驻指令)` : '家规 CLAUDE.md QWEN.md 在',
97
+ fix: rulesMissing.length ? 'cotutor init 补上(已有的不动)' : undefined,
98
+ });
99
+ if (ws) {
100
+ // ---- 老师:定义链 + 名字一致 + 老师目录 ----
101
+ const defaultCli = presetCli(ws.config.agents[ws.config.agents.default]?.run ?? []);
102
+ for (const name of Object.keys(ws.config.teachers)) {
103
+ for (const [cli, dir] of [
104
+ ['claude', ws.dirs.claudeAgents],
105
+ ['qwen', ws.dirs.qwenAgents],
106
+ ]) {
107
+ const file = join(dir, `${name}.md`);
108
+ const required = cli === defaultCli;
109
+ let detail;
110
+ let ok = false;
111
+ try {
112
+ const { frontmatter } = parseAgentFile(await readFile(file, 'utf8'));
113
+ ok = frontmatter.name === name;
114
+ detail = ok ? `${redactHome(file)} name 一致` : `${redactHome(file)} 的 frontmatter name 是 "${frontmatter.name ?? ''}",与老师键 ${name} 不一致`;
115
+ }
116
+ catch {
117
+ detail = `${redactHome(file)} 读不到(链断了或没建)`;
118
+ }
119
+ push({
120
+ name: `teacher.${name}.${cli}`,
121
+ ok,
122
+ required,
123
+ detail,
124
+ fix: ok ? undefined : `cotutor init 重链(链到本包 agents/${name}.md),或把老师键改成文件里的 name`,
125
+ });
126
+ }
127
+ const home = join(ws.dirs.agents, name);
128
+ const there = (await statOrNull(home))?.isDirectory() ?? false;
129
+ push({ name: `teacher.${name}.home`, ok: there, required: true, detail: there ? `agents/${name}/ 在(会话 cwd)` : `agents/${name}/ 不在`, fix: there ? undefined : 'cotutor init 补建' });
130
+ }
131
+ // ---- paths 角色指向:配了就该在 ----
132
+ for (const [role, value] of Object.entries(ws.config.paths)) {
133
+ const target = ws.paths[role] ?? expandPath(value, ws.root);
134
+ const st = await statOrNull(target);
135
+ if (!st) {
136
+ push({ name: `paths.${role}`, ok: false, required: true, detail: `指向不存在:${redactHome(target)}`, fix: `mkdir -p 该目录,或修正 cotutor.json 的 paths.${role};指向 Obsidian vault 时确认 vault 已建、iCloud 已同步` });
137
+ continue;
138
+ }
139
+ const evicted = target.includes('Mobile Documents') && st.isDirectory() ? await evictedCount(target) : 0;
140
+ push({ name: `paths.${role}`, ok: evicted === 0, required: false, detail: `${redactHome(target)}${evicted ? `——有 ${evicted} 个 .icloud 占位` : ''}`, fix: evicted ? '关掉 iCloud「优化 Mac 储存空间」,或把该目录下载到本地' : undefined });
141
+ }
142
+ // ---- 账本:坏行必须响(不可再生,别删)----
143
+ for (const [name, file, parse] of [
144
+ ['observations', ws.files.observations, parseObservations],
145
+ ['artifacts', ws.files.artifacts, parseArtifactEvents],
146
+ ]) {
147
+ try {
148
+ const text = await readFile(file, 'utf8');
149
+ const { rows, errors } = parse(text);
150
+ push({ name: `ledger.${name}`, ok: errors.length === 0, required: true, detail: errors.length ? errors.slice(0, 3).join(';') : `${rows.length} 行`, fix: errors.length ? '修那几行,或 git checkout 回退;账本不可再生,不要删了重来' : undefined });
151
+ }
152
+ catch {
153
+ push({ name: `ledger.${name}`, ok: true, required: false, detail: '没有(还没记过);cotutor init 会建空文件' });
154
+ }
155
+ }
156
+ // ---- 预设的 CLI 在不在 ----
157
+ if (probeEnv) {
158
+ for (const [preset, p] of Object.entries(ws.config.agents)) {
159
+ if (preset === 'default' || typeof p === 'string')
160
+ continue;
161
+ const bin = presetCli(p.run);
162
+ const required = preset === ws.config.agents.default;
163
+ try {
164
+ const { stdout } = await execFileP(bin, ['--version'], { timeout: 8000 });
165
+ push({ name: `agents.${preset}`, ok: true, required, detail: `${bin} ${stdout.trim().split('\n')[0]}` });
166
+ }
167
+ catch {
168
+ push({ name: `agents.${preset}`, ok: false, required, detail: `PATH 里没有 ${bin}`, fix: required ? `装 ${bin},或把 agents.default 改成装了的那个预设` : undefined });
169
+ }
170
+ }
171
+ }
172
+ }
173
+ // ---- 用户配置 ----
174
+ try {
175
+ const text = await readFile(USER_CONFIG, 'utf8');
176
+ try {
177
+ const u = JSON.parse(text);
178
+ push({ name: 'user-config', ok: true, required: false, detail: u.workspace ? `workspace → ${redactHome(expandPath(u.workspace, root))}` : '有,但未设 workspace' });
179
+ }
180
+ catch {
181
+ push({ name: 'user-config', ok: false, required: false, detail: `${redactHome(USER_CONFIG)} 解析失败`, fix: '修复 JSON 或删掉它' });
182
+ }
183
+ }
184
+ catch {
185
+ push({ name: 'user-config', ok: true, required: false, detail: `未配置(可选):{"workspace": "…"} 写进 ${redactHome(USER_CONFIG)};cotutor init 会代劳` });
186
+ }
187
+ const gitThere = (await statOrNull(join(root, '.git'))) !== null;
188
+ push({ name: 'git', ok: gitThere, required: false, detail: gitThere ? '工作区是 git 仓' : '工作区不在 git 里', fix: gitThere ? undefined : '建议 git init:老师记忆、账本、政策都是不可再生状态' });
189
+ return { ok: checks.filter((c) => c.required).every((c) => c.ok), root, source, checks };
190
+ }
@@ -0,0 +1,17 @@
1
+ export interface InitStep {
2
+ item: string;
3
+ action: 'created' | 'exists' | 'kept';
4
+ note?: string;
5
+ }
6
+ export interface InitResult {
7
+ root: string;
8
+ steps: InitStep[];
9
+ suggestions: string[];
10
+ }
11
+ export interface InitOptions {
12
+ slug: string;
13
+ dir?: string;
14
+ name?: string;
15
+ port?: number;
16
+ }
17
+ export declare function initWorkspace(opts: InitOptions): Promise<InitResult>;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * cotutor init <slug>:建 ~/cotutor/<slug>/ 骨架。语义是**幂等补缺**——已有的文件与目录一律不动,缺什么补什么。
3
+ * 三条边界(沿 drawtell init):政策文件只在缺失时写模板;用户配置只在还没指定 workspace 时补;不动 git。
4
+ * 老师定义不拷贝,**链**到本包 agents/(共享的是定义,不共享记忆与账本);链已存在(哪怕是实体文件)就不动。
5
+ */
6
+ import { mkdir, readFile, stat, symlink, writeFile } from 'node:fs/promises';
7
+ import { basename, dirname, join, resolve } from 'node:path';
8
+ import { DIRS, GITIGNORE, LEDGER_FILES, RULES, configTemplate, shippedAgents } from "./skeleton.js";
9
+ import { CONFIG_FILE, HOME_ROOT, USER_CONFIG, expandPath } from "./workspace.js";
10
+ async function exists(p) {
11
+ return (await stat(p).catch(() => null)) !== null;
12
+ }
13
+ async function lexists(p) {
14
+ const { lstat } = await import('node:fs/promises');
15
+ return (await lstat(p).catch(() => null)) !== null;
16
+ }
17
+ export async function initWorkspace(opts) {
18
+ const root = opts.dir ? expandPath(opts.dir, resolve(process.cwd())) : join(HOME_ROOT, opts.slug);
19
+ const steps = [];
20
+ const agents = await shippedAgents();
21
+ const rootExisted = await exists(root);
22
+ if (!rootExisted)
23
+ await mkdir(root, { recursive: true });
24
+ steps.push({ item: '.', action: rootExisted ? 'exists' : 'created' });
25
+ for (const dir of DIRS) {
26
+ const p = join(root, dir);
27
+ if (await exists(p)) {
28
+ steps.push({ item: `${dir}/`, action: 'exists' });
29
+ continue;
30
+ }
31
+ await mkdir(p, { recursive: true });
32
+ steps.push({ item: `${dir}/`, action: 'created' });
33
+ }
34
+ for (const a of agents) {
35
+ const home = join(root, 'agents', a.name);
36
+ if (await exists(home)) {
37
+ steps.push({ item: `agents/${a.name}/`, action: 'exists' });
38
+ }
39
+ else {
40
+ await mkdir(home, { recursive: true });
41
+ await writeFile(join(home, '.gitkeep'), '');
42
+ steps.push({ item: `agents/${a.name}/`, action: 'created' });
43
+ }
44
+ for (const sub of ['.claude/agents', '.qwen/agents']) {
45
+ const link = join(root, sub, basename(a.file));
46
+ if (await lexists(link)) {
47
+ steps.push({ item: `${sub}/${basename(a.file)}`, action: 'exists' });
48
+ continue;
49
+ }
50
+ await symlink(a.file, link);
51
+ steps.push({ item: `${sub}/${basename(a.file)}`, action: 'created', note: `→ ${a.file}` });
52
+ }
53
+ }
54
+ for (const f of LEDGER_FILES) {
55
+ const p = join(root, f);
56
+ if (await exists(p))
57
+ steps.push({ item: f, action: 'exists' });
58
+ else {
59
+ await writeFile(p, '');
60
+ steps.push({ item: f, action: 'created' });
61
+ }
62
+ }
63
+ const config = join(root, CONFIG_FILE);
64
+ if (await exists(config))
65
+ steps.push({ item: CONFIG_FILE, action: 'kept', note: '政策文件不覆盖(家长的决定)' });
66
+ else {
67
+ await writeFile(config, configTemplate({ slug: opts.slug, name: opts.name, port: opts.port, teachers: agents }));
68
+ steps.push({ item: CONFIG_FILE, action: 'created' });
69
+ }
70
+ for (const f of ['CLAUDE.md', 'QWEN.md']) {
71
+ const p = join(root, f);
72
+ if (await exists(p))
73
+ steps.push({ item: f, action: 'kept', note: '家规已有,不覆盖' });
74
+ else {
75
+ await writeFile(p, RULES);
76
+ steps.push({ item: f, action: 'created' });
77
+ }
78
+ }
79
+ const gitignore = join(root, '.gitignore');
80
+ if (await exists(gitignore))
81
+ steps.push({ item: '.gitignore', action: 'kept' });
82
+ else {
83
+ await writeFile(gitignore, GITIGNORE);
84
+ steps.push({ item: '.gitignore', action: 'created' });
85
+ }
86
+ // 用户配置:补缺不改向;解析不了也不动(doctor 去体检)
87
+ let user = null;
88
+ let userBroken = false;
89
+ try {
90
+ user = JSON.parse(await readFile(USER_CONFIG, 'utf8'));
91
+ }
92
+ catch (err) {
93
+ userBroken = err.code !== 'ENOENT';
94
+ }
95
+ const configured = user && typeof user.workspace === 'string' && user.workspace ? user.workspace : null;
96
+ if (userBroken)
97
+ steps.push({ item: 'user-config', action: 'kept', note: '解析失败,未动(cotutor doctor 可体检)' });
98
+ else if (configured) {
99
+ steps.push(expandPath(configured, root) === root
100
+ ? { item: 'user-config', action: 'exists', note: '已指向本工作区' }
101
+ : { item: 'user-config', action: 'kept', note: '已指向别的工作区,未改;多孩子时一 workspace 一进程,用 --workspace / COTUTOR_WORKSPACE 指定' });
102
+ }
103
+ else {
104
+ await mkdir(dirname(USER_CONFIG), { recursive: true });
105
+ await writeFile(USER_CONFIG, `${JSON.stringify({ ...(user ?? {}), workspace: root }, null, 2)}\n`);
106
+ steps.push({ item: 'user-config', action: 'created', note: 'workspace 指向本工作区' });
107
+ }
108
+ const suggestions = [];
109
+ if (!(await exists(join(root, '.git'))))
110
+ suggestions.push('git init(建议:老师记忆、账本、政策都是不可再生状态,git 是破坏后的兜底)');
111
+ suggestions.push(`改 ${CONFIG_FILE}:paths.vault 指到孩子的 Obsidian vault,teachers.*.voice 填 voxtell 音色 id`);
112
+ suggestions.push('cotutor doctor 逐项体检;cotutor serve 起服务');
113
+ return { root, steps, suggestions };
114
+ }
@@ -0,0 +1 @@
1
+ export declare function main(argv: string[]): Promise<void>;