@namewta/speculo 0.8.13 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +8 -5
  2. package/dist/src/cli.js +12 -1
  3. package/dist/src/cli.js.map +1 -1
  4. package/dist/src/doctor.d.ts +10 -0
  5. package/dist/src/doctor.js +70 -0
  6. package/dist/src/doctor.js.map +1 -0
  7. package/dist/src/index.js +105 -56
  8. package/dist/src/index.js.map +1 -1
  9. package/dist/src/kernel.d.ts +98 -0
  10. package/dist/src/kernel.js +29 -0
  11. package/dist/src/kernel.js.map +1 -0
  12. package/dist/src/refresh.js +55 -24
  13. package/dist/src/refresh.js.map +1 -1
  14. package/dist/src/structured.d.ts +2 -2
  15. package/dist/src/structured.js +114 -241
  16. package/dist/src/structured.js.map +1 -1
  17. package/package.json +3 -2
  18. package/template/.speculo/README.md +1 -1
  19. package/template/.speculo/capabilities.json +14 -0
  20. package/template/.speculo/kernel/README.md +10 -0
  21. package/template/.speculo/kernel/capability-profile.schema.json +14 -0
  22. package/template/.speculo/kernel/checkpoint.schema.json +7 -0
  23. package/template/.speculo/kernel/trace-event.schema.json +8 -0
  24. package/template/.speculo/kernel/workflow-manifest.schema.json +13 -0
  25. package/template/.speculo/kernel.json +10 -0
  26. package/template/.speculo/refresh-contract.json +4 -1
  27. package/template/AGENTS.md +0 -3
  28. package/template/commands/archive-and-consolidate.md +13 -40
  29. package/template/commands/status.md +3 -3
  30. package/template/workflows/learning/A-archive/A-archive.md +25 -0
  31. package/template/workflows/learning/A-assess-and-plan/A-assess-and-plan.md +17 -23
  32. package/template/workflows/learning/A-assess-and-plan/background-template.md +13 -0
  33. package/template/workflows/learning/A-assess-and-plan/change-status-template.json +21 -14
  34. package/template/workflows/learning/A-assess-and-plan/course-template.md +28 -0
  35. package/template/workflows/learning/C-consolidate/C-consolidate.md +33 -0
  36. package/template/workflows/learning/H-homework/H-homework.md +31 -0
  37. package/template/workflows/learning/H-homework/homework-template.md +63 -0
  38. package/template/workflows/learning/I-init-setup/I-init-setup.md +14 -19
  39. package/template/workflows/learning/I-init-setup/context-index-template.md +3 -3
  40. package/template/workflows/learning/I-init-setup/learner-profile-template.md +12 -10
  41. package/template/workflows/learning/I-init-setup/review-index-template.md +1 -1
  42. package/template/workflows/learning/INDEX.md +7 -9
  43. package/template/workflows/learning/L-lesson/L-lesson.md +32 -0
  44. package/template/workflows/learning/L-lesson/lesson-template.md +51 -0
  45. package/template/workflows/learning/R-review/R-review.md +13 -18
  46. package/template/workflows/learning/R-review/review-template.md +13 -6
  47. package/template/workflows/learning/README.md +86 -81
  48. package/template/workflows/learning/_state/status.json +1 -1
  49. package/template/workflows/learning/common/rules/artifact-contract.md +11 -25
  50. package/template/workflows/learning/common/rules/assessment-policy.md +9 -4
  51. package/template/workflows/learning/common/rules/knowledge-organization.md +4 -6
  52. package/template/workflows/learning/common/rules/mastery-policy.md +3 -19
  53. package/template/workflows/learning/common/rules/path-reference-contract.md +3 -5
  54. package/template/workflows/learning/common/rules/teaching-policy.md +9 -9
  55. package/template/workflows/learning/common/schemas/change-status.schema.json +46 -18
  56. package/template/workflows/learning/common/schemas/status.schema.json +25 -24
  57. package/template/workflows/learning/common/skills/topic-synthesis/SKILL.md +17 -0
  58. package/template/workflows/learning/common/skills/topic-synthesis/claim-template.md +15 -0
  59. package/template/workflows/learning/common/tools/relocate-learning.mjs +208 -0
  60. package/template/workflows/learning/common/tools/validate-learning.mjs +195 -297
  61. package/template/workflows/learning/manifest.json +1 -0
  62. package/template/workflows/learning/runtime-contract.json +3 -2
  63. package/template/workflows/ops/manifest.json +1 -0
  64. package/template/workflows/person/manifest.json +1 -0
  65. package/template/workflows/specdev/manifest.json +1 -0
  66. package/template/workflows/workflow-manifest.schema.json +14 -0
  67. package/template/workflows/learning/A-archive-and-consolidate/A-archive-and-consolidate.md +0 -38
  68. package/template/workflows/learning/A-archive-and-consolidate/promotion-plan-template.md +0 -23
  69. package/template/workflows/learning/A-assess-and-plan/learning-plan-template.md +0 -28
  70. package/template/workflows/learning/E-eli5/E-eli5.md +0 -37
  71. package/template/workflows/learning/E-eli5/lesson-template.md +0 -31
  72. package/template/workflows/learning/P-practice/P-practice.md +0 -34
  73. package/template/workflows/learning/P-practice/practice-template.md +0 -16
  74. package/template/workflows/learning/Q-quiz/Q-quiz.md +0 -34
  75. package/template/workflows/learning/Q-quiz/quiz-artifact-template.md +0 -16
  76. package/template/workflows/learning/common/skills/knowledge-promotion/SKILL.md +0 -46
  77. package/template/workflows/learning/common/skills/knowledge-promotion/domain-index-template.md +0 -6
  78. package/template/workflows/learning/common/skills/knowledge-promotion/domain-overview-template.md +0 -19
  79. package/template/workflows/learning/common/skills/knowledge-promotion/knowledge-template.md +0 -25
@@ -1,356 +1,254 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import {
4
- existsSync,
5
- readFileSync,
6
- readdirSync,
7
- statSync,
8
- } from "node:fs";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
9
5
  import { dirname, join, relative, resolve, sep } from "node:path";
10
6
  import { fileURLToPath } from "node:url";
11
7
 
12
8
  const CHANGE_NAME = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
9
  const DOMAIN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
14
10
  const WORK_ID = /^learning\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
15
- const CHANGE_STATUS = new Set(["active", "blocked", "awaiting_retention", "completed", "archived"]);
16
- const PHASE = new Set(["intake", "assessment", "teaching", "practice", "immediate_quiz", "retention", "ready_to_archive", "archived"]);
17
- const DOMAIN_TYPE = new Set(["project", "product", "subject", "language", "skill"]);
18
- const RESULT = new Set(["not_attempted", "failed", "passed", "needs_review"]);
19
- const KNOWLEDGE_STATUS = new Set(["mastered", "review_due", "needs_refresh", "superseded"]);
20
- const EXPECTED_WORKS = new Set([
21
- "A-archive-and-consolidate",
22
- "A-assess-and-plan",
23
- "E-eli5",
24
- "I-init-setup",
25
- "P-practice",
26
- "Q-quiz",
27
- "R-review",
28
- ]);
29
-
30
- function parseArgs(argv) {
11
+ const LOCATOR = /^(?:changes\/.+|archive\/[0-9]{4}-[0-9]{2}\/.+)$/;
12
+ const EXPECTED_WORKS = new Set(["A-archive", "A-assess-and-plan", "C-consolidate", "H-homework", "I-init-setup", "L-lesson", "R-review"]);
13
+ const OLD_WORKS = new Set(["A-archive-and-consolidate", "E-eli5", "P-practice", "Q-quiz"]);
14
+ const PHASE = new Set(["planning", "teaching", "homework", "review", "consolidating", "closed", "archived"]);
15
+ const LIFECYCLE = new Set(["active", "blocked", "closed", "archived"]);
16
+ const EVIDENCE = new Set(["not_attempted", "passed", "needs_review"]);
17
+
18
+ function options(argv) {
31
19
  const result = { workflowRoot: null, stateRoot: null, stage: null, change: null, selfCheck: false };
32
- for (let index = 0; index < argv.length; index += 1) {
33
- const arg = argv[index];
34
- if (arg === "--workflow-root") result.workflowRoot = resolve(argv[++index] ?? "");
35
- else if (arg === "--state-root") result.stateRoot = resolve(argv[++index] ?? "");
36
- else if (arg === "--stage") result.stage = argv[++index] ?? null;
37
- else if (arg === "--change") result.change = argv[++index] ?? null;
20
+ for (let i = 0; i < argv.length; i += 1) {
21
+ const arg = argv[i];
22
+ if (arg === "--workflow-root") result.workflowRoot = resolve(argv[++i] ?? "");
23
+ else if (arg === "--state-root") result.stateRoot = resolve(argv[++i] ?? "");
24
+ else if (arg === "--stage") result.stage = argv[++i] ?? null;
25
+ else if (arg === "--change") result.change = argv[++i] ?? null;
38
26
  else if (arg === "--self-check") result.selfCheck = true;
39
27
  else throw new Error(`unknown option: ${arg}`);
40
28
  }
41
- if (!result.workflowRoot && !result.stateRoot && !result.selfCheck) {
42
- throw new Error("use --workflow-root <path>, --state-root <path>, or --self-check");
43
- }
44
- if (result.stage && !new Set(["pre-archive", "complete"]).has(result.stage)) {
45
- throw new Error("--stage must be pre-archive or complete");
46
- }
29
+ if (!result.workflowRoot && !result.stateRoot && !result.selfCheck) throw new Error("use --workflow-root, --state-root, or --self-check");
30
+ if (result.stage && !new Set(["pre-archive", "complete"]).has(result.stage)) throw new Error("--stage must be pre-archive or complete");
47
31
  if (result.stage && !result.change) throw new Error("--stage requires --change");
48
32
  if (result.change && !CHANGE_NAME.test(result.change)) throw new Error("--change has an invalid name");
49
33
  return result;
50
34
  }
51
35
 
52
- function isFile(path) {
53
- return existsSync(path) && statSync(path).isFile();
54
- }
55
-
56
- function isDirectory(path) {
57
- return existsSync(path) && statSync(path).isDirectory();
58
- }
59
-
36
+ function isFile(path) { return existsSync(path) && statSync(path).isFile(); }
37
+ function isDir(path) { return existsSync(path) && statSync(path).isDirectory(); }
60
38
  function readJson(path, label, errors) {
61
- try {
62
- return JSON.parse(readFileSync(path, "utf8"));
63
- } catch (error) {
64
- errors.push(`${label}: invalid JSON (${error.message})`);
65
- return null;
66
- }
39
+ try { return JSON.parse(readFileSync(path, "utf8")); }
40
+ catch (error) { errors.push(`${label}: invalid JSON (${error.message})`); return null; }
67
41
  }
68
-
69
- function walk(root) {
70
- if (!isDirectory(root)) return [];
71
- const files = [];
42
+ function files(root) {
43
+ if (!isDir(root)) return [];
44
+ const result = [];
72
45
  for (const entry of readdirSync(root, { withFileTypes: true })) {
73
46
  const path = join(root, entry.name);
74
- if (entry.isDirectory()) files.push(...walk(path));
75
- else if (entry.isFile()) files.push(path);
47
+ if (entry.isDirectory()) result.push(...files(path));
48
+ else if (entry.isFile()) result.push(path);
76
49
  }
77
- return files;
78
- }
79
-
80
- function sameMembers(actual, expected) {
81
- return actual.size === expected.size && [...actual].every((item) => expected.has(item));
50
+ return result;
82
51
  }
83
-
84
52
  function duplicates(values) {
85
53
  const seen = new Set();
86
54
  return values.filter((value) => seen.has(value) || !seen.add(value));
87
55
  }
88
-
89
- function validDateTime(value) {
90
- return typeof value === "string" && !Number.isNaN(Date.parse(value));
56
+ function validDate(value) { return typeof value === "string" && !Number.isNaN(Date.parse(value)); }
57
+ function frontmatter(text) {
58
+ const match = /^---\s*\n([\s\S]*?)\n---\s*/.exec(text);
59
+ const result = {};
60
+ if (!match) return result;
61
+ for (const line of match[1].split(/\r?\n/)) {
62
+ const field = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(line);
63
+ if (!field) continue;
64
+ const value = field[2].trim();
65
+ if (/^\[.*\]$/.test(value)) result[field[1]] = value.slice(1, -1).split(",").map((item) => item.trim()).filter(Boolean);
66
+ else if (/^\d+$/.test(value)) result[field[1]] = Number(value);
67
+ else result[field[1]] = value.replace(/^['"]|['"]$/g, "");
68
+ }
69
+ return result;
91
70
  }
92
71
 
93
72
  function validateWorkflowRoot(root, errors) {
94
- const indexPath = join(root, "INDEX.md");
95
- const readmePath = join(root, "README.md");
96
- const seedPath = join(root, "_state", "status.json");
97
- const runtimePath = join(root, "runtime-contract.json");
98
- for (const path of [indexPath, readmePath, seedPath, runtimePath]) {
99
- if (!isFile(path)) errors.push(`${path}: required workflow file is missing`);
100
- }
101
- if (errors.length) return;
102
-
103
- const index = readFileSync(indexPath, "utf8");
104
- const readme = readFileSync(readmePath, "utf8");
105
- if (!/^id: learning$/m.test(index) || !/^type: workflow$/m.test(index) || !/^workflow: learning$/m.test(index)) {
106
- errors.push("INDEX.md: identity must be learning/type workflow");
107
- }
108
- for (const heading of ["## 永久知识", "## Work 激活"]) {
109
- if (!index.includes(heading)) errors.push(`INDEX.md: missing ${heading}`);
110
- }
111
- if (!index.includes("<Path>{roots.workflows}/learning/README.md</Path>")) errors.push("INDEX.md: missing activation pointer");
112
- if ((readme.match(/AUTO-INDEX-START/g) ?? []).length !== 1 || (readme.match(/AUTO-INDEX-END/g) ?? []).length !== 1) {
113
- errors.push("README.md: requires one AUTO-INDEX marker pair");
114
- }
115
-
116
- const works = new Set(readdirSync(root, { withFileTypes: true })
117
- .filter((entry) => entry.isDirectory() && /^[A-Z]-/.test(entry.name))
118
- .map((entry) => entry.name));
119
- if (!sameMembers(works, EXPECTED_WORKS)) {
120
- errors.push(`workflow works mismatch: expected ${[...EXPECTED_WORKS].sort().join(", ")}; found ${[...works].sort().join(", ")}`);
121
- }
73
+ const required = ["INDEX.md", "README.md", "_state/status.json", "runtime-contract.json"];
74
+ for (const file of required) if (!isFile(join(root, file))) errors.push(`${file}: required workflow file is missing`);
75
+ if (!isFile(join(root, "README.md"))) return;
76
+ const index = readFileSync(join(root, "INDEX.md"), "utf8");
77
+ const readme = readFileSync(join(root, "README.md"), "utf8");
78
+ if (!/^id: learning$/m.test(index) || !/^type: workflow$/m.test(index) || !/^workflow: learning$/m.test(index)) errors.push("INDEX.md: invalid identity");
79
+ if (!(index.includes("## 主题知识") || index.includes("## 永久知识")) || !index.includes("## Work 激活") || !index.includes("<Path>{roots.workflows}/learning/README.md</Path>")) errors.push("INDEX.md: missing passive/activation sections");
80
+ if ((readme.match(/AUTO-INDEX-START/g) ?? []).length !== 1 || (readme.match(/AUTO-INDEX-END/g) ?? []).length !== 1) errors.push("README.md: requires one AUTO-INDEX marker pair");
81
+ const works = new Set(readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory() && /^[A-Z]-/.test(entry.name) && isFile(join(root, entry.name, `${entry.name}.md`))).map((entry) => entry.name));
82
+ if (works.size !== EXPECTED_WORKS.size || [...EXPECTED_WORKS].some((work) => !works.has(work))) errors.push(`workflow works mismatch: expected ${[...EXPECTED_WORKS].sort().join(", ")}; found ${[...works].sort().join(", ")}`);
83
+ for (const old of OLD_WORKS) if (isDir(join(root, old)) && files(join(root, old)).length) errors.push(`${old}: obsolete v1 work is still shipped`);
122
84
  for (const work of works) {
123
- const path = join(root, work, `${work}.md`);
124
- if (!isFile(path)) {
125
- errors.push(`${work}: missing same-named entry`);
126
- continue;
127
- }
128
- const text = readFileSync(path, "utf8");
85
+ const text = readFileSync(join(root, work, `${work}.md`), "utf8");
129
86
  if (!text.includes("<Path>{roots.workflows}/learning/README.md</Path>")) errors.push(`${work}: missing activation contract reference`);
130
87
  if (!/^workflow: learning$/m.test(text) || !/^type: workflow-entry$/m.test(text)) errors.push(`${work}: invalid frontmatter identity`);
131
88
  }
132
-
133
- const seed = readJson(seedPath, "_state/status.json", errors);
134
- if (seed && (seed.schema_version !== 1 || seed.workflow !== "learning" || !Array.isArray(seed.active) || !Array.isArray(seed.archived))) {
135
- errors.push("_state/status.json: expected empty learning schema v1 status");
136
- }
137
- const runtime = readJson(runtimePath, "runtime-contract.json", errors);
138
- if (runtime && (runtime.schema_version !== 1 || runtime.workflow !== "learning" || runtime.config !== null || runtime.opaque_default !== "preserve-byte-for-byte")) {
139
- errors.push("runtime-contract.json: invalid learning contract");
140
- }
141
- for (const schema of ["status.schema.json", "change-status.schema.json"]) {
142
- readJson(join(root, "common", "schemas", schema), `common/schemas/${schema}`, errors);
143
- }
144
-
145
- for (const path of walk(root).filter((candidate) => candidate.toLowerCase().endsWith(".md"))) {
146
- if (/```mermaid\b/i.test(readFileSync(path, "utf8"))) {
147
- errors.push(`${relative(root, path)}: Learning diagrams must use fenced ASCII text, not Mermaid`);
148
- }
149
- }
150
-
151
- const teachingContracts = [
152
- [join(root, "E-eli5", "E-eli5.md"), ["5 岁的小孩", "大一新生", "ASCII", "不得生成 Mermaid"]],
153
- [join(root, "E-eli5", "lesson-template.md"), ["## 教学表达基线", "5 岁的小孩", "大一新生", "```text"]],
154
- [join(root, "common", "rules", "teaching-policy.md"), ["5 岁的小孩", "大一新生", "默认使用", "纯文本 ASCII"]],
155
- [join(root, "I-init-setup", "learner-profile-template.md"), ["教学表达基线", "5 岁的小孩", "仅纯文本 ASCII"]],
89
+ const seed = readJson(join(root, "_state/status.json"), "_state/status.json", errors);
90
+ if (seed && (seed.schema_version !== 2 || seed.workflow !== "learning" || !Array.isArray(seed.active) || !Array.isArray(seed.archived))) errors.push("_state/status.json: expected empty learning schema v2 status");
91
+ const runtime = readJson(join(root, "runtime-contract.json"), "runtime-contract.json", errors);
92
+ if (runtime && (runtime.schema_version !== 1 || runtime.workflow !== "learning" || runtime.config !== null || runtime.opaque_default !== "preserve-byte-for-byte" || !runtime.structured_state.includes(".speculo/learning/changes/**/.status.json"))) errors.push("runtime-contract.json: missing recursive v2 paths");
93
+ for (const schema of ["status.schema.json", "change-status.schema.json"]) readJson(join(root, "common", "schemas", schema), `common/schemas/${schema}`, errors);
94
+ const contracts = [
95
+ ["L-lesson/L-lesson.md", ["30–40", "estimated_minutes", "time_budget", "ELI5"]],
96
+ ["L-lesson/lesson-template.md", ["lesson_id", "source_ids", "反例与边界", "文字等价物"]],
97
+ ["H-homework/H-homework.md", ["Submission: ready", "Explain (English)"]],
98
+ ["H-homework/homework-template.md", ["Q1", "A1", "Submission: pending"]],
99
+ ["common/rules/teaching-policy.md", ["coverage_depth", "不以字符数"]],
156
100
  ];
157
- for (const [path, requiredMarkers] of teachingContracts) {
158
- if (!isFile(path)) {
159
- errors.push(`${relative(root, path)}: required teaching contract is missing`);
160
- continue;
161
- }
101
+ for (const [file, markers] of contracts) {
102
+ const path = join(root, file);
103
+ if (!isFile(path)) { errors.push(`${file}: required contract is missing`); continue; }
162
104
  const text = readFileSync(path, "utf8");
163
- for (const marker of requiredMarkers) {
164
- if (!text.includes(marker)) errors.push(`${relative(root, path)}: missing '${marker}'`);
165
- }
105
+ for (const marker of markers) if (!text.includes(marker)) errors.push(`${file}: missing '${marker}'`);
166
106
  }
167
107
  }
168
108
 
169
- function validateGlobalStatus(status, errors) {
170
- if (!status) return;
171
- if (status.schema_version !== 1 || status.workflow !== "learning" || !Array.isArray(status.active) || !Array.isArray(status.archived)) {
172
- errors.push("status.json: expected learning schema v1 with active and archived arrays");
173
- return;
174
- }
175
- const activeNames = [];
176
- for (const [index, entry] of status.active.entries()) {
177
- const label = `status.json active[${index}]`;
178
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
179
- errors.push(`${label}: must be an object`);
180
- continue;
181
- }
182
- if (!CHANGE_NAME.test(entry.change ?? "")) errors.push(`${label}: invalid change`);
183
- if (!DOMAIN.test(entry.domain ?? "")) errors.push(`${label}: invalid domain`);
184
- if (typeof entry.topic !== "string" || !entry.topic.trim()) errors.push(`${label}: topic is required`);
185
- if (!(entry.current_work === null || WORK_ID.test(entry.current_work ?? ""))) errors.push(`${label}: invalid current_work`);
186
- if (!Array.isArray(entry.works_run) || entry.works_run.some((item) => !WORK_ID.test(item)) || duplicates(entry.works_run ?? []).length) {
187
- errors.push(`${label}: works_run must contain unique learning work ids`);
188
- }
189
- activeNames.push(entry.change);
190
- }
191
- if (duplicates(activeNames).length || duplicates(status.archived).length) errors.push("status.json: duplicate change names");
192
- for (const name of status.archived) {
193
- if (!CHANGE_NAME.test(name)) errors.push(`status.json: invalid archived change ${name}`);
194
- if (activeNames.includes(name)) errors.push(`status.json: ${name} is both active and archived`);
195
- }
109
+ function validateEntry(entry, label, errors, archived) {
110
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) { errors.push(`${label}: must be an object`); return null; }
111
+ for (const key of ["change_id", "kind", "domain", "topic_id", "locator", "parent_change", "root_change", "current_work", "works_run"]) if (!(key in entry)) errors.push(`${label}: missing ${key}`);
112
+ if (!CHANGE_NAME.test(entry.change_id ?? "") || !["learning", "consolidation"].includes(entry.kind) || !DOMAIN.test(entry.domain ?? "") || !DOMAIN.test(entry.topic_id ?? "")) errors.push(`${label}: invalid identity`);
113
+ if (!LOCATOR.test(entry.locator ?? "") || String(entry.locator).split("/").includes("..")) errors.push(`${label}: invalid locator`);
114
+ if (!(entry.parent_change === null || CHANGE_NAME.test(entry.parent_change ?? "")) || !CHANGE_NAME.test(entry.root_change ?? "")) errors.push(`${label}: invalid parent/root`);
115
+ if (!(entry.current_work === null || WORK_ID.test(entry.current_work ?? ""))) errors.push(`${label}: invalid current_work`);
116
+ if (!Array.isArray(entry.works_run) || entry.works_run.some((item) => !WORK_ID.test(item)) || duplicates(entry.works_run).length) errors.push(`${label}: invalid works_run`);
117
+ if (entry.updated_at !== undefined && !validDate(entry.updated_at)) errors.push(`${label}: invalid updated_at`);
118
+ if (archived && !String(entry.locator).startsWith("archive/")) errors.push(`${label}: archived entry must use archive locator`);
119
+ if (!archived && String(entry.locator).startsWith("archive/")) errors.push(`${label}: active entry cannot use archive locator`);
120
+ return entry;
196
121
  }
197
122
 
198
- function validateMasteryGate(changeStatus, label, errors) {
199
- const mastery = changeStatus.mastery;
200
- if (!mastery || typeof mastery !== "object" || Array.isArray(mastery)) {
201
- errors.push(`${label}: mastery is required`);
202
- return;
203
- }
204
- if (!RESULT.has(mastery.immediate) || !RESULT.has(mastery.retention)) errors.push(`${label}: invalid mastery result`);
205
- if (mastery.score !== null && (typeof mastery.score !== "number" || mastery.score < 0 || mastery.score > 100)) errors.push(`${label}: invalid mastery score`);
206
- if (typeof mastery.critical_objectives_passed !== "boolean" || typeof mastery.transfer_passed !== "boolean") errors.push(`${label}: mastery booleans are required`);
123
+ function validateMastery(value, label, errors) {
124
+ const mastery = value.mastery;
125
+ if (!mastery || typeof mastery !== "object") { errors.push(`${label}: mastery is required`); return; }
126
+ if (!["unverified", "immediate", "retention_verified"].includes(mastery.overall) || !EVIDENCE.has(mastery.immediate) || !["not_scheduled", "due", "passed", "needs_review"].includes(mastery.retention) || !EVIDENCE.has(mastery.critical_objectives) || !EVIDENCE.has(mastery.transfer)) errors.push(`${label}: invalid mastery projection`);
127
+ if (mastery.overall === "retention_verified" && mastery.retention !== "passed") errors.push(`${label}: retention_verified requires retention passed`);
207
128
  if (!Array.isArray(mastery.blocking_misconceptions) || !Array.isArray(mastery.evidence)) errors.push(`${label}: mastery arrays are required`);
208
- if (!(mastery.next_review_at === null || validDateTime(mastery.next_review_at))) errors.push(`${label}: invalid next_review_at`);
209
-
210
- if (changeStatus.change_status === "awaiting_retention" && mastery.immediate !== "passed") {
211
- errors.push(`${label}: awaiting_retention requires immediate passed`);
212
- }
213
- if (["completed", "archived"].includes(changeStatus.change_status)) {
214
- if (mastery.immediate !== "passed" || mastery.retention !== "passed") errors.push(`${label}: completed knowledge requires both mastery gates passed`);
215
- if (typeof mastery.score !== "number" || mastery.score < 80) errors.push(`${label}: completed knowledge requires score >= 80`);
216
- if (!mastery.critical_objectives_passed) errors.push(`${label}: completed knowledge requires all critical objectives`);
217
- if (!mastery.transfer_passed) errors.push(`${label}: completed knowledge requires transfer evidence`);
218
- if (mastery.blocking_misconceptions.length) errors.push(`${label}: completed knowledge has blocking misconceptions`);
219
- if (mastery.evidence.length < 2 || !mastery.evidence.some((item) => /\/quiz\/immediate-/.test(item)) || !mastery.evidence.some((item) => /\/quiz\/retention-/.test(item))) {
220
- errors.push(`${label}: completed knowledge requires immediate and retention result evidence`);
221
- }
222
- }
223
- }
224
-
225
- function validateChangeStatus(value, expectedName, expectedArchived, errors) {
226
- const label = `${expectedName}/.status.json`;
227
- if (!value) return;
228
- if (value.schema_version !== 1 || value.artifact !== "learning-change-status") errors.push(`${label}: invalid identity/schema`);
229
- if (value.change !== expectedName) errors.push(`${label}: change must match directory`);
230
- if (!DOMAIN.test(value.domain ?? "") || !DOMAIN_TYPE.has(value.domain_type)) errors.push(`${label}: invalid domain or domain_type`);
231
- if (typeof value.topic !== "string" || !value.topic.trim()) errors.push(`${label}: topic is required`);
232
- if (!CHANGE_STATUS.has(value.change_status) || !PHASE.has(value.phase)) errors.push(`${label}: invalid status or phase`);
233
- if (!(value.current_work === null || WORK_ID.test(value.current_work ?? ""))) errors.push(`${label}: invalid current_work`);
234
- if (!Array.isArray(value.works_run) || value.works_run.some((item) => !WORK_ID.test(item)) || duplicates(value.works_run ?? []).length) errors.push(`${label}: invalid works_run`);
235
- if (!validDateTime(value.created_at) || !validDateTime(value.updated_at)) errors.push(`${label}: created_at and updated_at must be date-times`);
236
- if (!Array.isArray(value.blockers)) errors.push(`${label}: blockers must be an array`);
237
- if (expectedArchived && (value.change_status !== "archived" || value.phase !== "archived" || !validDateTime(value.archived_at) || typeof value.archive_path !== "string")) {
238
- errors.push(`${label}: archived index requires archived status, phase, time, and path`);
239
- }
240
- if (expectedArchived && value.archive_path !== `<Path>{roots.state}/learning/archive/${expectedName.slice(0, 7)}/${expectedName}</Path>`) {
241
- errors.push(`${label}: archive_path does not match the indexed archive location`);
129
+ if (!(mastery.next_review_at === null || validDate(mastery.next_review_at))) errors.push(`${label}: invalid next_review_at`);
130
+ }
131
+
132
+ function validateChange(value, expectedId, expectedLocator, errors) {
133
+ const label = `${expectedId}/.status.json`;
134
+ if (!value || typeof value !== "object") return;
135
+ if (value.schema_version === 1) { errors.push(`${label}: learning-reset-required; v1 is not auto-migrated`); return; }
136
+ if (value.schema_version !== 2 || value.artifact !== "learning-change-status" || value.change_id !== expectedId || value.locator !== expectedLocator) errors.push(`${label}: invalid identity/schema/locator`);
137
+ if (!CHANGE_NAME.test(value.change_id ?? "") || !["learning", "consolidation"].includes(value.kind) || !DOMAIN.test(value.domain ?? "") || !DOMAIN.test(value.topic_id ?? "")) errors.push(`${label}: invalid domain/kind/topic`);
138
+ if (!LIFECYCLE.has(value.lifecycle) || !PHASE.has(value.phase)) errors.push(`${label}: invalid lifecycle/phase`);
139
+ if (!(value.current_work === null || WORK_ID.test(value.current_work ?? "")) || !Array.isArray(value.works_run) || duplicates(value.works_run).length) errors.push(`${label}: invalid work projection`);
140
+ if (!validDate(value.created_at) || !validDate(value.updated_at)) errors.push(`${label}: invalid timestamps`);
141
+ if (!(value.closed_at === null || validDate(value.closed_at)) || !(value.archived_at === null || validDate(value.archived_at))) errors.push(`${label}: invalid close/archive timestamp`);
142
+ if (value.lifecycle === "archived" && (value.phase !== "archived" || value.current_work !== null || !String(value.locator).startsWith("archive/"))) errors.push(`${label}: archive state does not match locator`);
143
+ if (value.lifecycle !== "archived" && (value.archived_at !== null || value.archive_path !== null || String(value.locator).startsWith("archive/"))) errors.push(`${label}: active state contains archive fields`);
144
+ if (!value.homework || !["none", "pending", "reviewed", "needs_revision"].includes(value.homework.status) || !(value.homework.latest_id === null || typeof value.homework.latest_id === "string")) errors.push(`${label}: invalid homework projection`);
145
+ if (!Array.isArray(value.children) || !Array.isArray(value.blockers)) errors.push(`${label}: children/blockers must be arrays`);
146
+ validateMastery(value, label, errors);
147
+ }
148
+
149
+ function validateLessons(changeRoot, errors) {
150
+ const lessonRoot = join(changeRoot, "lessons");
151
+ if (!isDir(lessonRoot)) return;
152
+ for (const path of files(lessonRoot).filter((candidate) => /\.md$/.test(candidate) && !candidate.endsWith("INDEX.md"))) {
153
+ const text = readFileSync(path, "utf8");
154
+ const label = relative(changeRoot, path).split(sep).join("/");
155
+ const meta = frontmatter(text);
156
+ if (!/^L-[0-9]{3}(?:-[a-z0-9-]+)?$/.test(String(meta.lesson_id ?? ""))) errors.push(`${label}: lesson_id is required`);
157
+ if (!Number.isInteger(meta.estimated_minutes) || meta.estimated_minutes < 30 || meta.estimated_minutes > 40) errors.push(`${label}: estimated_minutes must be 30–40`);
158
+ if (!Array.isArray(meta.time_budget) && !text.includes("time_budget:")) errors.push(`${label}: time_budget is required`);
159
+ for (const heading of ["宏观", "机制", "精确定义", "English", "正例", "反例", "迁移", "误区", "总结", "来源"]) if (!text.includes(heading)) errors.push(`${label}: missing teaching coverage '${heading}'`);
160
+ if (!/(文字等价物|完整的文字|文字替代)/.test(text)) errors.push(`${label}: visual representation needs a text equivalent`);
161
+ if (!/source_ids:|\| Source ID \|/.test(text)) errors.push(`${label}: source_ids/source table is required`);
162
+ if (/\bverdict\b|\bmastered\b|^##\s*(?:Answers|Review)/mi.test(text)) errors.push(`${label}: lesson must not contain homework review/mastery fields`);
163
+ }
164
+ }
165
+
166
+ function validateHomework(changeRoot, errors) {
167
+ const homeworkRoot = join(changeRoot, "homework");
168
+ if (!isDir(homeworkRoot)) return;
169
+ for (const path of files(homeworkRoot).filter((candidate) => /^HW-.*\.md$/.test(candidate.split(sep).pop() ?? ""))) {
170
+ const text = readFileSync(path, "utf8");
171
+ const label = relative(changeRoot, path).split(sep).join("/");
172
+ const ready = /(^|\n)Submission:\s*ready\s*(?:\n|$)/i.test(text);
173
+ const reviewed = /^##\s+Review\s*$/mi.test(text);
174
+ if (!/(^|\n)Submission:\s*(?:pending|ready)\s*(?:\n|$)/i.test(text)) errors.push(`${label}: Submission marker is required`);
175
+ const questions = [...text.matchAll(/^###\s+Q(\d+)\b/gm)].map((match) => Number(match[1]));
176
+ const answers = [...text.matchAll(/^###\s+A(\d+)\b/gm)].map((match) => Number(match[1]));
177
+ if (!questions.length || questions.length !== answers.length || questions.some((number, index) => number !== index + 1) || answers.some((number, index) => number !== index + 1)) errors.push(`${label}: Q/A numbering must be contiguous and paired`);
178
+ if (reviewed && !ready) errors.push(`${label}: review cannot exist before Submission: ready`);
179
+ if (reviewed && !/correct|partial|incorrect|uncertain/i.test(text) || reviewed && !/Explain \(English\)/.test(text)) errors.push(`${label}: review needs verdict and Explain (English)`);
242
180
  }
243
- if (!expectedArchived && value.change_status === "archived") errors.push(`${label}: active index cannot point to archived change`);
244
- if (value.change_status === "completed" && (!validDateTime(value.completed_at) || value.phase !== "ready_to_archive")) errors.push(`${label}: completed requires completed_at and ready_to_archive phase`);
245
- validateMasteryGate(value, label, errors);
246
- }
247
-
248
- function statePathFromTag(stateRoot, taggedPath) {
249
- const match = /^<Path>\{roots\.state\}\/learning\/(.+)<\/Path>$/.exec(taggedPath);
250
- return match ? join(stateRoot, ...match[1].split("/")) : null;
251
181
  }
252
182
 
253
183
  function validateContext(stateRoot, errors) {
254
- const contextRoot = join(stateRoot, "context");
255
- if (!isDirectory(contextRoot)) return;
256
- for (const required of ["INDEX.md", "REVIEW.md"]) {
257
- if (!isFile(join(contextRoot, required))) errors.push(`context/${required}: missing`);
258
- }
259
- const ids = new Map();
260
- for (const file of walk(contextRoot).filter((path) => path.endsWith(".md"))) {
261
- const text = readFileSync(file, "utf8");
262
- const fileLabel = relative(stateRoot, file).split(sep).join("/");
263
- for (const match of text.matchAll(/\[[^\]]*\]\(([^)]+)\)/g)) {
264
- const target = match[1].split("#")[0].trim();
265
- if (!target || /^(?:https?:|mailto:|#)/.test(target) || target.includes("<")) continue;
266
- const resolved = resolve(dirname(file), decodeURIComponent(target));
267
- if (!resolved.startsWith(resolve(contextRoot) + sep) || !existsSync(resolved)) errors.push(`${fileLabel}: broken or escaping Markdown link ${target}`);
268
- }
269
- const id = /^\|\s*Knowledge ID\s*\|\s*([^|]+?)\s*\|\s*$/m.exec(text)?.[1];
270
- if (!id) continue;
271
- if (!/^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) errors.push(`${fileLabel}: invalid Knowledge ID ${id}`);
272
- if (ids.has(id)) errors.push(`${fileLabel}: duplicate Knowledge ID ${id} also in ${ids.get(id)}`);
273
- else ids.set(id, fileLabel);
274
- const state = /^\|\s*状态\s*\|\s*([^|]+?)\s*\|\s*$/m.exec(text)?.[1];
275
- if (!KNOWLEDGE_STATUS.has(state)) errors.push(`${fileLabel}: invalid or missing knowledge 状态`);
276
- for (const heading of ["## 当前理解", "## 心智模型", "## 示例与应用", "## 常见误区", "## 来源与证据"]) {
277
- if (!text.includes(heading)) errors.push(`${fileLabel}: missing ${heading}`);
278
- }
184
+ const context = join(stateRoot, "context");
185
+ if (!isDir(context)) return;
186
+ for (const required of ["INDEX.md", "REVIEW.md"]) if (!isFile(join(context, required))) errors.push(`context/${required}: missing`);
187
+ const ids = new Set();
188
+ for (const path of files(context).filter((candidate) => candidate.endsWith(".md"))) {
189
+ const text = readFileSync(path, "utf8");
190
+ const id = /^\|\s*topic_id\s*\|\s*([^|]+)\s*\|/mi.exec(text)?.[1]?.trim();
191
+ if (id) { if (ids.has(id)) errors.push(`${relative(context, path)}: duplicate topic_id ${id}`); ids.add(id); }
192
+ if (path.includes(`${sep}topics${sep}`) && !/provenance|source_change_id|claim_id/i.test(text)) errors.push(`${relative(context, path)}: topic view needs provenance`);
279
193
  }
280
194
  }
281
195
 
282
- function validateStateRoot(root, options, errors) {
196
+ function validateStateRoot(root, opts, errors) {
283
197
  const statusPath = join(root, "status.json");
284
- if (!isFile(statusPath)) {
285
- errors.push("status.json: missing");
286
- return;
287
- }
198
+ if (!isFile(statusPath)) { errors.push("status.json: missing"); return; }
288
199
  const status = readJson(statusPath, "status.json", errors);
289
- validateGlobalStatus(status, errors);
290
- if (!status || !Array.isArray(status.active) || !Array.isArray(status.archived)) return;
291
-
292
- for (const entry of status.active) {
293
- if (!entry || typeof entry.change !== "string") continue;
294
- const path = join(root, "changes", entry.change, ".status.json");
295
- if (!isFile(path)) {
296
- errors.push(`${entry.change}: indexed active change is missing .status.json`);
297
- continue;
298
- }
299
- const changeStatus = readJson(path, `${entry.change}/.status.json`, errors);
300
- validateChangeStatus(changeStatus, entry.change, false, errors);
301
- if (changeStatus && (changeStatus.domain !== entry.domain || changeStatus.topic !== entry.topic || changeStatus.current_work !== entry.current_work || JSON.stringify(changeStatus.works_run) !== JSON.stringify(entry.works_run))) {
302
- errors.push(`${entry.change}: global and change status projections differ`);
303
- }
304
- if (options.stage === "pre-archive" && (!options.change || options.change === entry.change) && changeStatus?.change_status !== "completed") {
305
- errors.push(`${entry.change}: pre-archive requires completed status`);
306
- }
307
- if (options.stage === "complete" && (!options.change || options.change === entry.change)) {
308
- errors.push(`${entry.change}: complete stage cannot leave the change active`);
309
- }
310
- for (const evidence of changeStatus?.mastery?.evidence ?? []) {
311
- const resolved = statePathFromTag(root, evidence);
312
- if (!resolved || !isFile(resolved)) errors.push(`${entry.change}: mastery evidence does not exist ${evidence}`);
313
- }
314
- }
315
-
316
- for (const name of status.archived) {
317
- const archiveRoot = join(root, "archive", name.slice(0, 7), name);
318
- const path = join(archiveRoot, ".status.json");
319
- if (!isFile(path)) {
320
- errors.push(`${name}: indexed archive is missing .status.json`);
321
- continue;
322
- }
323
- validateChangeStatus(readJson(path, `${name}/.status.json`, errors), name, true, errors);
324
- if (isDirectory(join(root, "changes", name))) errors.push(`${name}: archived change still exists under changes`);
325
- if (options.stage === "complete" && (!options.change || options.change === name) && !isFile(join(archiveRoot, "promotion-plan.md"))) {
326
- errors.push(`${name}: complete stage requires archived promotion-plan.md`);
327
- }
328
- }
329
- if (options.change && !status.active.some((entry) => entry.change === options.change) && !status.archived.includes(options.change)) errors.push(`${options.change}: change is not indexed`);
200
+ if (!status) return;
201
+ if (status.schema_version === 1) { errors.push("status.json: learning-reset-required; v1 is not auto-migrated"); return; }
202
+ if (status.schema_version !== 2 || status.workflow !== "learning" || !Array.isArray(status.active) || !Array.isArray(status.archived)) { errors.push("status.json: expected learning schema v2"); return; }
203
+ const active = status.active.map((entry, index) => validateEntry(entry, `status.active[${index}]`, errors, false)).filter(Boolean);
204
+ const archived = status.archived.map((entry, index) => validateEntry(entry, `status.archived[${index}]`, errors, true)).filter(Boolean);
205
+ const all = [...active, ...archived];
206
+ const ids = all.map((entry) => entry.change_id);
207
+ if (duplicates(ids).length) errors.push("status.json: duplicate or overlapping change ids");
208
+ const byId = new Map(all.map((entry) => [entry.change_id, entry]));
209
+ for (const entry of all) {
210
+ const path = join(root, ...String(entry.locator).split("/"), ".status.json");
211
+ if (!isFile(path)) { errors.push(`${entry.change_id}: indexed Change is missing ${path}`); continue; }
212
+ const value = readJson(path, `${entry.change_id}/.status.json`, errors);
213
+ validateChange(value, entry.change_id, entry.locator, errors);
214
+ if (value && (value.domain !== entry.domain || value.topic_id !== entry.topic_id || value.parent_change !== entry.parent_change || value.current_work !== entry.current_work || JSON.stringify(value.works_run) !== JSON.stringify(entry.works_run))) errors.push(`${entry.change_id}: global and change projections differ`);
215
+ const seen = new Set();
216
+ let parent = entry.parent_change;
217
+ while (parent !== null) {
218
+ if (seen.has(parent)) { errors.push(`${entry.change_id}: parent cycle detected`); break; }
219
+ seen.add(parent);
220
+ if (!byId.has(parent)) { errors.push(`${entry.change_id}: parent_change ${parent} is not indexed`); break; }
221
+ parent = byId.get(parent).parent_change;
222
+ }
223
+ const changeRoot = join(root, ...String(entry.locator).split("/"));
224
+ validateLessons(changeRoot, errors);
225
+ validateHomework(changeRoot, errors);
226
+ if (opts.stage === "pre-archive" && (!opts.change || opts.change === entry.change_id) && value?.lifecycle !== "closed") errors.push(`${entry.change_id}: pre-archive requires lifecycle closed`);
227
+ if (opts.stage === "complete" && (!opts.change || opts.change === entry.change_id) && !String(entry.locator).startsWith("archive/")) errors.push(`${entry.change_id}: complete stage requires archived locator`);
228
+ }
229
+ const locationsPath = join(root, "locations.json");
230
+ if (all.length && !isFile(locationsPath)) errors.push("locations.json: required when Changes exist");
231
+ if (isFile(locationsPath)) {
232
+ const locations = readJson(locationsPath, "locations.json", errors);
233
+ if (locations && (locations.schema_version !== 2 || locations.workflow !== "learning" || !Array.isArray(locations.entries))) errors.push("locations.json: expected schema v2");
234
+ else if (locations) {
235
+ const locationIds = locations.entries.map((entry) => entry.change_id);
236
+ if (duplicates(locationIds).length) errors.push("locations.json: duplicate change ids");
237
+ for (const entry of all) if (!locations.entries.some((candidate) => candidate.change_id === entry.change_id && candidate.locator === entry.locator)) errors.push(`${entry.change_id}: locations.json does not match current locator`);
238
+ }
239
+ }
240
+ if (opts.change && !all.some((entry) => entry.change_id === opts.change)) errors.push(`${opts.change}: Change is not indexed`);
330
241
  validateContext(root, errors);
331
242
  }
332
243
 
333
244
  function main() {
334
- const options = parseArgs(process.argv.slice(2));
245
+ const opts = options(process.argv.slice(2));
335
246
  const errors = [];
336
- if (options.selfCheck) {
337
- const ownRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
338
- validateWorkflowRoot(ownRoot, errors);
339
- }
340
- if (options.workflowRoot) validateWorkflowRoot(options.workflowRoot, errors);
341
- if (options.stateRoot) validateStateRoot(options.stateRoot, options, errors);
342
- if (errors.length) {
343
- console.error(`Learning validation failed (${errors.length})`);
344
- for (const error of errors) console.error(` - ${error}`);
345
- process.exitCode = 1;
346
- return;
347
- }
247
+ if (opts.selfCheck) validateWorkflowRoot(resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."), errors);
248
+ if (opts.workflowRoot) validateWorkflowRoot(opts.workflowRoot, errors);
249
+ if (opts.stateRoot) validateStateRoot(opts.stateRoot, opts, errors);
250
+ if (errors.length) { console.error(`Learning validation failed (${errors.length})`); for (const error of errors) console.error(` - ${error}`); process.exitCode = 1; return; }
348
251
  console.log("Learning validation: OK");
349
252
  }
350
253
 
351
- try {
352
- main();
353
- } catch (error) {
354
- console.error(`Learning validation failed: ${error.message}`);
355
- process.exitCode = 1;
356
- }
254
+ try { main(); } catch (error) { console.error(`Learning validation failed: ${error.message}`); process.exitCode = 1; }
@@ -0,0 +1 @@
1
+ {"schema_version":1,"id":"learning","version":"1.0.0","persistent_knowledge":["<Path>{roots.state}/learning/context/INDEX.md</Path>","<Path>{roots.state}/learning/context/REVIEW.md</Path>"],"stages":[{"id":"intake","after":[],"inputs":["goal"],"outputs":["course"],"risk":"read-only","context_budget":12000},{"id":"teach","after":["intake"],"inputs":["course"],"outputs":["lesson"],"risk":"read-only","context_budget":18000},{"id":"practice","after":["teach"],"inputs":["lesson"],"outputs":["homework"],"risk":"read-only","context_budget":12000},{"id":"review","after":["practice"],"inputs":["homework"],"outputs":["mastery"],"risk":"read-only","context_budget":12000},{"id":"consolidate","after":["review"],"inputs":["lesson","mastery"],"outputs":["knowledge"],"risk":"local-reversible","context_budget":14000}]}
@@ -4,8 +4,9 @@
4
4
  "config": null,
5
5
  "structured_state": [
6
6
  ".speculo/learning/status.json",
7
- ".speculo/learning/changes/*/.status.json",
8
- ".speculo/learning/archive/*/*/.status.json"
7
+ ".speculo/learning/locations.json",
8
+ ".speculo/learning/changes/**/.status.json",
9
+ ".speculo/learning/archive/**/.status.json"
9
10
  ],
10
11
  "opaque_default": "preserve-byte-for-byte"
11
12
  }
@@ -0,0 +1 @@
1
+ {"schema_version":1,"id":"ops","version":"1.0.0","persistent_knowledge":["<Path>{roots.state}/ops/context/</Path>","<Path>{roots.state}/ops/adr/</Path>","<Path>{roots.state}/ops/runbooks/</Path>","<Path>{roots.state}/ops/projects/{project_id}/context/</Path>","<Path>{roots.state}/ops/projects/{project_id}/adr/</Path>","<Path>{roots.state}/ops/projects/{project_id}/runbooks/</Path>"],"stages":[{"id":"intake","after":[],"inputs":["request","target"],"outputs":["dossier"],"risk":"read-only","context_budget":16000},{"id":"plan","after":["intake"],"inputs":["dossier"],"outputs":["plan","approval"],"risk":"external-mutation","context_budget":22000},{"id":"execute","after":["plan"],"inputs":["plan","approval"],"outputs":["attempt"],"risk":"production-critical","context_budget":20000},{"id":"verify","after":["execute"],"inputs":["attempt"],"outputs":["verification"],"risk":"read-only","context_budget":16000},{"id":"learn","after":["verify"],"inputs":["verification"],"outputs":["retrospective"],"risk":"local-reversible","context_budget":12000}]}
@@ -0,0 +1 @@
1
+ {"schema_version":1,"id":"person","version":"1.0.0","persistent_knowledge":[],"stages":[{"id":"frame","after":[],"inputs":["question"],"outputs":["dossier"],"risk":"read-only","context_budget":14000},{"id":"deliberate","after":["frame"],"inputs":["dossier"],"outputs":["decision"],"risk":"read-only","context_budget":18000},{"id":"act","after":["deliberate"],"inputs":["decision"],"outputs":["actions"],"risk":"local-reversible","context_budget":10000}]}