@namewta/speculo 1.0.10 → 1.0.12

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 (23) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/template/workflows/learning/G-goal/G-goal.md +74 -0
  4. package/template/workflows/learning/G-goal/chain-template.md +34 -0
  5. package/template/workflows/learning/G-goal/coverage-matrix-template.md +40 -0
  6. package/template/workflows/learning/G-goal/goal-plan-template.md +310 -0
  7. package/template/workflows/learning/G-goal/orchestration-protocol.md +26 -0
  8. package/template/workflows/learning/G-goal/planning-modes.md +19 -0
  9. package/template/workflows/learning/G-goal/probe-template.md +62 -0
  10. package/template/workflows/learning/G-goal/progress-template.md +19 -0
  11. package/template/workflows/learning/G-goal/references/coverage-bar.md +32 -0
  12. package/template/workflows/learning/G-goal/references/external-goal-runner.md +50 -0
  13. package/template/workflows/learning/G-goal/references/lead-orchestration.md +52 -0
  14. package/template/workflows/learning/G-goal/references/mine-unit.md +49 -0
  15. package/template/workflows/learning/G-goal/references/split-rules.md +54 -0
  16. package/template/workflows/learning/G-goal/references/stop-rules.md +27 -0
  17. package/template/workflows/learning/INDEX.md +2 -2
  18. package/template/workflows/learning/README.md +20 -5
  19. package/template/workflows/learning/common/rules/artifact-contract.md +3 -2
  20. package/template/workflows/learning/common/rules/questioning-policy.md +7 -0
  21. package/template/workflows/learning/common/rules/teaching-policy.md +1 -1
  22. package/template/workflows/learning/common/skills/socratic-questioning/SKILL.md +9 -7
  23. package/template/workflows/learning/common/tools/validate-learning.mjs +216 -1
@@ -9,11 +9,18 @@ const CHANGE_NAME = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
9
9
  const DOMAIN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
10
10
  const WORK_ID = /^learning\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
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"]);
12
+ const EXPECTED_WORKS = new Set(["A-archive", "A-assess-and-plan", "C-consolidate", "G-goal", "H-homework", "I-init-setup", "L-lesson", "Q-question", "R-review"]);
13
13
  const OLD_WORKS = new Set(["A-archive-and-consolidate", "E-eli5", "P-practice", "Q-quiz"]);
14
14
  const PHASE = new Set(["planning", "teaching", "homework", "review", "consolidating", "closed", "archived"]);
15
15
  const LIFECYCLE = new Set(["active", "blocked", "closed", "archived"]);
16
16
  const EVIDENCE = new Set(["not_attempted", "passed", "needs_review"]);
17
+ const MINE_UNIT_CAP = 15;
18
+ const MAX_LESSON_QUESTIONS = 10;
19
+ const PROBE_BATCH = /^GP-(L-\d{3}(?:-[a-z0-9]+)*)-b0(\d+)\.md$/;
20
+ const QUESTION_HEADING = /^##\s+Q(\d+)\b/gm;
21
+ const OLD_INTERLEAVE = /同一时间只推进一个 chain 节点|L\s*→\s*mine\s*→\s*L|先 L 写课,再 mine 探针/;
22
+ const LEARNER_PROBE = /(?:^|\n)\s*Response:|(?:^|\n)\s*Submission:|\binquiry\//;
23
+ const SOCRATIC_SKILL_PATH = "{roots.workflows}/learning/common/skills/socratic-questioning/SKILL.md";
17
24
 
18
25
  function options(argv) {
19
26
  const result = { workflowRoot: null, stateRoot: null, stage: null, change: null, selfCheck: false };
@@ -96,7 +103,24 @@ function validateWorkflowRoot(root, errors) {
96
103
  ["L-lesson/lesson-template.md", ["lesson_id", "source_ids", "反例与边界", "文字等价物"]],
97
104
  ["H-homework/H-homework.md", ["Submission: ready", "Explain (English)"]],
98
105
  ["H-homework/homework-template.md", ["Q1", "A1", "Submission: pending"]],
106
+ ["Q-question/Q-question.md", ["Response: pending", "Inquiry Lesson", "inquiry/"]],
107
+ ["Q-question/inquiry-template.md", ["Response: pending", "Q1", "A1"]],
108
+ ["G-goal/G-goal.md", ["ready_for_execution", "goal/goal-plan.md", "covered-by-parent", "mine unit", "mine_unit_cap"]],
109
+ ["G-goal/goal-plan-template.md", ["ready_for_execution: false", "covered-by-parent", "goal/probes", "mine_unit: project", "mine_unit_cap: 15", "learning_agent_limit: 4", "split-lesson", "teach-then-mine"]],
110
+ ["G-goal/chain-template.md", ["Units", "mined", "split_proposed", "split"]],
111
+ ["G-goal/probe-template.md", ["b01", "b02", "question_budget_used", "split-proposal", "split-lesson"]],
112
+ ["G-goal/progress-template.md", ["mine_unit", "question_budget_used", "active_subagents"]],
113
+ ["G-goal/orchestration-protocol.md", ["lead-orchestration.md", "mine-unit.md", "audience=mine"]],
114
+ ["G-goal/planning-modes.md", ["mine unit", "T(写完全部课)"]],
115
+ ["G-goal/references/external-goal-runner.md", ["audience=mine", "Q-quiz", "inquiry/", "socratic-questioning/SKILL.md", "mine unit"]],
116
+ ["G-goal/references/lead-orchestration.md", ["learning_agent_limit", "写集", "只有 Lead 可写", SOCRATIC_SKILL_PATH]],
117
+ ["G-goal/references/mine-unit.md", ["mine_unit_cap", "不是配额", "15"]],
118
+ ["G-goal/references/split-rules.md", ["split-lesson", "question_budget", "C-consolidate", "relocate-learning.mjs"]],
119
+ ["G-goal/references/stop-rules.md", ["第 11 问", "交错 L/mine", "15 节"]],
120
+ ["G-goal/references/coverage-bar.md", ["mine_unit_cap=15", "不是必须凑满"]],
99
121
  ["common/rules/teaching-policy.md", ["coverage_depth", "不以字符数"]],
122
+ ["common/rules/questioning-policy.md", ["audience=mine", "goal/probes", "最多两批 10 问"]],
123
+ ["common/skills/socratic-questioning/SKILL.md", ["audience=mine", "b02", "split-proposal"]],
100
124
  ];
101
125
  for (const [file, markers] of contracts) {
102
126
  const path = join(root, file);
@@ -104,6 +128,196 @@ function validateWorkflowRoot(root, errors) {
104
128
  const text = readFileSync(path, "utf8");
105
129
  for (const marker of markers) if (!text.includes(marker)) errors.push(`${file}: missing '${marker}'`);
106
130
  }
131
+ const goalPlan = readFileSync(join(root, "G-goal/goal-plan-template.md"), "utf8");
132
+ if (OLD_INTERLEAVE.test(goalPlan) && !goalPlan.includes("禁止 L → mine → L → mine")) {
133
+ errors.push("G-goal/goal-plan-template.md: old interleave iteration policy remains");
134
+ }
135
+ const orch = readFileSync(join(root, "G-goal/orchestration-protocol.md"), "utf8");
136
+ if (orch.includes("默认 current、严格串行") || (orch.includes("同一时间只推进一个 chain 节点") && !orch.includes("禁止"))) {
137
+ errors.push("G-goal/orchestration-protocol.md: old serial one-node recipe remains");
138
+ }
139
+ }
140
+
141
+ function markdownTables(text) {
142
+ const tables = [];
143
+ const lines = String(text).split(/\r?\n/);
144
+ let i = 0;
145
+ while (i < lines.length) {
146
+ if (/^\|.+\|$/.test(lines[i]) && i + 1 < lines.length && /^\|\s*[-:| ]+\|$/.test(lines[i + 1])) {
147
+ const header = lines[i].split("|").slice(1, -1).map((cell) => cell.trim());
148
+ i += 2;
149
+ const rows = [];
150
+ while (i < lines.length && /^\|.+\|$/.test(lines[i]) && !/^\|\s*[-:| ]+\|$/.test(lines[i])) {
151
+ rows.push(lines[i].split("|").slice(1, -1).map((cell) => cell.trim()));
152
+ i += 1;
153
+ }
154
+ tables.push({ header, rows });
155
+ continue;
156
+ }
157
+ i += 1;
158
+ }
159
+ return tables;
160
+ }
161
+
162
+ function tableColumn(table, names) {
163
+ const index = table.header.findIndex((cell) => names.some((name) => cell.toLowerCase() === name.toLowerCase()));
164
+ return index;
165
+ }
166
+
167
+ function lessonIdsIn(text) {
168
+ return [...String(text).matchAll(/\bL-\d{3}(?:-[a-z0-9]+)*\b/g)].map((match) => match[0]);
169
+ }
170
+
171
+ function filledSplitProposal(text) {
172
+ const section = /##\s+split-proposal\b([\s\S]*?)(?:\n##\s+|$)/i.exec(text);
173
+ if (!section) return false;
174
+ const body = section[1];
175
+ if (/^\s*none\s*$/im.test(body.trim())) return false;
176
+ const tables = markdownTables(body);
177
+ for (const table of tables) {
178
+ for (const row of table.rows) {
179
+ if (row.some((cell) => cell && cell !== "—" && cell !== "-" && cell !== "none")) return true;
180
+ }
181
+ }
182
+ return /L-\d{3}/.test(body);
183
+ }
184
+
185
+ function parseChainLessons(chainText) {
186
+ const lessons = new Map();
187
+ for (const table of markdownTables(chainText)) {
188
+ const idCol = tableColumn(table, ["ID", "id", "lesson_id"]);
189
+ const statusCol = tableColumn(table, ["状态", "status"]);
190
+ if (idCol < 0 || statusCol < 0) continue;
191
+ for (const row of table.rows) {
192
+ const id = lessonIdsIn(row[idCol] ?? "")[0];
193
+ if (id) lessons.set(id, { status: (row[statusCol] ?? "").trim(), row });
194
+ }
195
+ }
196
+ return lessons;
197
+ }
198
+
199
+ function parseChainUnits(chainText) {
200
+ const units = [];
201
+ for (const table of markdownTables(chainText)) {
202
+ const unitCol = tableColumn(table, ["unit_id", "unit"]);
203
+ const lessonsCol = tableColumn(table, ["Lessons", "lessons", "课列表"]);
204
+ if (unitCol < 0 || lessonsCol < 0) continue;
205
+ for (const row of table.rows) {
206
+ const unitId = (row[unitCol] ?? "").trim();
207
+ if (!unitId || unitId === "unit_id") continue;
208
+ units.push({ unitId, lessons: lessonIdsIn(row[lessonsCol] ?? "") });
209
+ }
210
+ }
211
+ return units;
212
+ }
213
+
214
+ function validateGoal(changeRoot, errors) {
215
+ const goalRoot = join(changeRoot, "goal");
216
+ if (!isDir(goalRoot)) return;
217
+ const rel = (path) => relative(changeRoot, path).split(sep).join("/");
218
+ const chainPath = join(goalRoot, "chain.md");
219
+ const planPath = join(goalRoot, "goal-plan.md");
220
+ const progressPath = join(goalRoot, "progress.md");
221
+ const matrixPath = join(goalRoot, "coverage-matrix.md");
222
+ const probesRoot = join(goalRoot, "probes");
223
+ const verifyPath = join(goalRoot, "verify.md");
224
+ const chainText = isFile(chainPath) ? readFileSync(chainPath, "utf8") : "";
225
+ const planText = isFile(planPath) ? readFileSync(planPath, "utf8") : "";
226
+ const progressText = isFile(progressPath) ? readFileSync(progressPath, "utf8") : "";
227
+ const matrixText = isFile(matrixPath) ? readFileSync(matrixPath, "utf8") : "";
228
+ const verifyText = isFile(verifyPath) ? readFileSync(verifyPath, "utf8") : "";
229
+
230
+ if (planText && OLD_INTERLEAVE.test(planText) && !planText.includes("禁止 L → mine → L → mine")) {
231
+ errors.push(`${rel(planPath)}: interleaved L/mine iteration policy is forbidden`);
232
+ }
233
+ if (progressText && OLD_INTERLEAVE.test(progressText)) {
234
+ errors.push(`${rel(progressPath)}: interleaved L/mine progress is forbidden`);
235
+ }
236
+
237
+ const units = parseChainUnits(chainText);
238
+ const seenLesson = new Set();
239
+ for (const unit of units) {
240
+ if (unit.lessons.length > MINE_UNIT_CAP) {
241
+ errors.push(`${rel(chainPath)}: mine unit ${unit.unitId} has ${unit.lessons.length} lessons; mine_unit_cap=${MINE_UNIT_CAP}`);
242
+ }
243
+ for (const id of unit.lessons) {
244
+ if (seenLesson.has(id)) errors.push(`${rel(chainPath)}: lesson ${id} belongs to more than one mine unit`);
245
+ seenLesson.add(id);
246
+ }
247
+ }
248
+
249
+ const lessons = parseChainLessons(chainText);
250
+ const probeFiles = isDir(probesRoot)
251
+ ? files(probesRoot).filter((path) => path.endsWith(".md") && !path.endsWith("INDEX.md"))
252
+ : [];
253
+ const byLesson = new Map();
254
+ for (const path of probeFiles) {
255
+ const name = path.split(sep).pop() ?? "";
256
+ const label = rel(path);
257
+ const text = readFileSync(path, "utf8");
258
+ const meta = frontmatter(text);
259
+ const match = PROBE_BATCH.exec(name);
260
+ if (!match) {
261
+ errors.push(`${label}: probe filename must be GP-<lesson>-b0N.md`);
262
+ continue;
263
+ }
264
+ const fileLesson = match[1];
265
+ const batch = Number(match[2]);
266
+ if (batch > 2) errors.push(`${label}: b03+ is forbidden; max two batches per lesson`);
267
+ if (meta.audience && meta.audience !== "mine") errors.push(`${label}: audience must be mine`);
268
+ if (meta.lesson_id && meta.lesson_id !== fileLesson && !String(meta.lesson_id).startsWith(fileLesson) && !fileLesson.startsWith(String(meta.lesson_id))) {
269
+ errors.push(`${label}: miner write-set isolation; lesson_id ${meta.lesson_id} does not match filename ${fileLesson}`);
270
+ }
271
+ if (LEARNER_PROBE.test(text)) errors.push(`${label}: mine probe must not contain Response:, Submission:, or inquiry/`);
272
+ const questions = [...text.matchAll(QUESTION_HEADING)].map((item) => Number(item[1]));
273
+ const record = byLesson.get(fileLesson) ?? { questions: [], batches: new Set(), files: [], texts: [], budgets: [], split: false, mineMore: false, splitDepth: 0 };
274
+ record.questions.push(...questions);
275
+ record.batches.add(batch);
276
+ record.files.push(label);
277
+ record.texts.push(text);
278
+ if (Number.isInteger(meta.question_budget_used)) record.budgets.push(meta.question_budget_used);
279
+ if (filledSplitProposal(text)) record.split = true;
280
+ if (/\bmine-more\b/.test(text)) record.mineMore = true;
281
+ if (Number.isInteger(meta.split_depth)) record.splitDepth = Math.max(record.splitDepth, meta.split_depth);
282
+ byLesson.set(fileLesson, record);
283
+ }
284
+
285
+ const minedLessons = new Set(byLesson.keys());
286
+ for (const unit of units) {
287
+ const statuses = unit.lessons.map((id) => lessons.get(id)?.status ?? "");
288
+ const unitHasProbe = unit.lessons.some((id) => minedLessons.has(id));
289
+ const unitStillWriting = unit.lessons.some((id) => {
290
+ const status = lessons.get(id)?.status ?? "";
291
+ return status === "planned" || status === "writing";
292
+ });
293
+ if (unitHasProbe && unitStillWriting) {
294
+ errors.push(`${rel(chainPath)}: mine unit ${unit.unitId} interleaved L/mine (probes exist while a lesson is still planned/writing)`);
295
+ }
296
+ void statuses;
297
+ }
298
+
299
+ const matrixUncovered = /\buncovered\b/.test(matrixText);
300
+ for (const [lessonId, record] of byLesson) {
301
+ const uniqueQs = new Set(record.questions);
302
+ if (uniqueQs.size > MAX_LESSON_QUESTIONS || record.questions.some((number) => number > MAX_LESSON_QUESTIONS)) {
303
+ errors.push(`${record.files[0]}: lesson ${lessonId} has more than ${MAX_LESSON_QUESTIONS} questions`);
304
+ }
305
+ const budget = record.budgets.length ? Math.max(...record.budgets) : uniqueQs.size;
306
+ const status = lessons.get(lessonId)?.status ?? "";
307
+ if (status === "revised" && uniqueQs.size > 0 && record.budgets.some((value) => value === 0)) {
308
+ errors.push(`${record.files[0]}: re-dispatch-L must not reset question_budget_used for ${lessonId}`);
309
+ }
310
+ if (uniqueQs.size >= MAX_LESSON_QUESTIONS && record.mineMore && !record.split) {
311
+ errors.push(`${record.files[0]}: 10 questions with remaining uncovered/mine-more requires split-proposal, not b03`);
312
+ }
313
+ if (uniqueQs.size >= MAX_LESSON_QUESTIONS && matrixUncovered && !record.split && record.mineMore) {
314
+ errors.push(`${record.files[0]}: 10 questions still uncovered requires split-proposal`);
315
+ }
316
+ if (record.splitDepth >= 2 && matrixUncovered && !/needs-replan/.test(`${progressText}\n${verifyText}\n${record.texts.join("\n")}`)) {
317
+ errors.push(`${record.files[0]}: split depth=2 still unclear must defer(needs-replan)`);
318
+ }
319
+ void budget;
320
+ }
107
321
  }
108
322
 
109
323
  function validateEntry(entry, label, errors, archived) {
@@ -223,6 +437,7 @@ function validateStateRoot(root, opts, errors) {
223
437
  const changeRoot = join(root, ...String(entry.locator).split("/"));
224
438
  validateLessons(changeRoot, errors);
225
439
  validateHomework(changeRoot, errors);
440
+ validateGoal(changeRoot, errors);
226
441
  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
442
  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
443
  }