bmad-method-test-architecture-enterprise 2.0.4-next.3 → 2.0.4-next.4
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/.claude-plugin/marketplace.json +1 -1
- package/README.md +1 -0
- package/cli/lib/resolve-tea-config.js +12 -4
- package/cli/lib/teach-turn.js +558 -0
- package/cli/teach.js +433 -0
- package/package.json +5 -3
- package/skills/bmad-teach-me-testing/steps-c/step-01-init.md +1 -2
- package/skills/bmad-teach-me-testing/steps-c/step-01b-continue.md +1 -1
- package/skills/bmad-teach-me-testing/steps-c/step-04-session-01.md +1 -1
- package/skills/bmad-teach-me-testing/steps-c/step-04-session-02.md +1 -1
- package/skills/bmod-tea/bmod.toml +1 -1
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"name": "bmad-method-test-architecture-enterprise",
|
|
32
32
|
"source": "./",
|
|
33
33
|
"description": "Master Test Architect module for quality strategy, test generation in red and expand modes with automatic run-and-heal, combined framework and CI setup, CI/CD quality gates, and structured testing education. The CI and ATDD commands remain available with existing customizations. Part of the BMad Method ecosystem.",
|
|
34
|
-
"version": "2.0.4-next.
|
|
34
|
+
"version": "2.0.4-next.4",
|
|
35
35
|
"author": {
|
|
36
36
|
"name": "Murat K Ozcan (TEA Creator) & Brian (BMad) Madison"
|
|
37
37
|
},
|
package/README.md
CHANGED
|
@@ -113,6 +113,7 @@ It requires Node.js 22.20.0 or later:
|
|
|
113
113
|
npm install --save-dev bmad-method-test-architecture-enterprise
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
- [`tea-teach`](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/reference/tea-teach-cli/) teaches one learner turn and keeps conversation and session progress.
|
|
116
117
|
- [`tea-test-review`](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/reference/tea-test-review-cli/) runs the review skill against changed tests and returns a gate verdict.
|
|
117
118
|
- [`tea-framework`](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/reference/tea-framework-cli/) creates, resumes, validates, and edits test framework and CI setup with retained run evidence.
|
|
118
119
|
- [`tea-trace`](https://bmad-code-org.github.io/bmad-method-test-architecture-enterprise/reference/tea-trace-cli/) maps requirements to existing evidence and returns a coverage gate.
|
|
@@ -78,18 +78,22 @@ function configError(message) {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** Read configuration from one pinned base commit, or from the working tree. */
|
|
81
|
-
function configSource(projectRoot, baseRef) {
|
|
81
|
+
function configSource(projectRoot, baseRef, inputPaths, inputPatterns) {
|
|
82
82
|
if (baseRef === undefined || baseRef === null) {
|
|
83
83
|
return {
|
|
84
|
+
recordInput: (file) => inputPaths?.add(file),
|
|
84
85
|
label: (relativePath) => path.join(projectRoot, relativePath),
|
|
85
86
|
list(pattern) {
|
|
86
87
|
try {
|
|
87
|
-
|
|
88
|
+
const matches = globSync(pattern, { cwd: projectRoot, dot: true, nodir: true, follow: false });
|
|
89
|
+
inputPatterns?.set(pattern, [...matches].sort());
|
|
90
|
+
return matches;
|
|
88
91
|
} catch (error) {
|
|
89
92
|
throw configError(`Failed to list policy files in ${projectRoot}: ${error.message}`);
|
|
90
93
|
}
|
|
91
94
|
},
|
|
92
95
|
read(relativePath) {
|
|
96
|
+
inputPaths?.add(path.join(projectRoot, relativePath));
|
|
93
97
|
try {
|
|
94
98
|
return fs.readFileSync(path.join(projectRoot, relativePath), 'utf8');
|
|
95
99
|
} catch (error) {
|
|
@@ -284,6 +288,7 @@ function parseToml(content, label) {
|
|
|
284
288
|
function readWorkflowCustomization(source, skillRoot, projectRoot, skillName = 'bmad-testarch-test-review') {
|
|
285
289
|
if (!skillRoot) return {};
|
|
286
290
|
const defaultsPath = path.join(skillRoot, 'customize.toml');
|
|
291
|
+
source.recordInput?.(defaultsPath);
|
|
287
292
|
let merged = {};
|
|
288
293
|
try {
|
|
289
294
|
merged = parseToml(fs.readFileSync(defaultsPath, 'utf8'), defaultsPath);
|
|
@@ -490,13 +495,16 @@ const KEY_TO_INSTALLED_FIELD = {
|
|
|
490
495
|
* @param {string} [options.skillRoot] - Trusted skill defaults for workflow customization.
|
|
491
496
|
* @param {object} [options.flags] - Parsed CLI options; only the keys in
|
|
492
497
|
* FLAG_TO_KEY are read, and only when not undefined.
|
|
498
|
+
* @param {Set<string>} [options.inputPaths] - Receives working-tree inputs, including missing optional layers.
|
|
499
|
+
* @param {Map<string, string[]>} [options.inputPatterns] - Receives policy glob membership used during resolution.
|
|
493
500
|
* @returns {{values: object, sources: object, installed: object, configSnapshot: object, workflowCustomization: object, configCommit: string|null, configPath: string, configPresent: boolean, configFormat: string|null}}
|
|
494
501
|
* `installed` carries one boolean per library gate, read from the project
|
|
495
502
|
* manifest rather than left to the agent.
|
|
496
503
|
* @throws {Error} With code TEA_CONFIG_INVALID on unusable config content.
|
|
497
504
|
*/
|
|
498
|
-
function resolveTeaConfig({ projectRoot, flags = {}, baseRef, skillRoot, skillName }) {
|
|
499
|
-
const source = configSource(projectRoot, baseRef);
|
|
505
|
+
function resolveTeaConfig({ projectRoot, flags = {}, baseRef, skillRoot, skillName, inputPaths, inputPatterns }) {
|
|
506
|
+
const source = configSource(projectRoot, baseRef, inputPaths, inputPatterns);
|
|
507
|
+
inputPaths?.add(path.join(projectRoot, 'package.json'));
|
|
500
508
|
const file = readTeaConfigFile(projectRoot, baseRef, source);
|
|
501
509
|
const workflowCustomization = readWorkflowCustomization(source, skillRoot, projectRoot, skillName);
|
|
502
510
|
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/** Durable learner state and evidence checks for one teaching turn. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const yaml = require('js-yaml');
|
|
7
|
+
function isDeepStrictEqual(a, b) {
|
|
8
|
+
if (Object.is(a, b)) return true;
|
|
9
|
+
if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) return false;
|
|
10
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
11
|
+
const keysA = Object.keys(a);
|
|
12
|
+
const keysB = Object.keys(b);
|
|
13
|
+
if (keysA.length !== keysB.length) return false;
|
|
14
|
+
for (const key of keysA) {
|
|
15
|
+
if (!Object.prototype.hasOwnProperty.call(b, key) || !isDeepStrictEqual(a[key], b[key])) return false;
|
|
16
|
+
}
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
const { projectPath, WorkflowError } = require('./workflow-cli');
|
|
20
|
+
const SESSION_IDS = [
|
|
21
|
+
'session-01-quickstart',
|
|
22
|
+
'session-02-concepts',
|
|
23
|
+
'session-03-architecture',
|
|
24
|
+
'session-04-test-design',
|
|
25
|
+
'session-05-atdd-automate',
|
|
26
|
+
'session-06-quality-trace',
|
|
27
|
+
'session-07-advanced',
|
|
28
|
+
];
|
|
29
|
+
const FACTS = ['role', 'experience_level', 'learning_goals', 'pain_points'];
|
|
30
|
+
const hash = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
31
|
+
const fail = (message) => {
|
|
32
|
+
throw new WorkflowError('environment-parser', message);
|
|
33
|
+
};
|
|
34
|
+
const table = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date);
|
|
35
|
+
function parseProgress(text, { legacy = false } = {}) {
|
|
36
|
+
const documents = [];
|
|
37
|
+
yaml.loadAll(text, (value) => documents.push(value), { schema: yaml.JSON_SCHEMA });
|
|
38
|
+
if (documents.length !== 1 && !(legacy && documents.length === 2 && documents[1] === null))
|
|
39
|
+
fail('progress must contain one YAML document');
|
|
40
|
+
if (!table(documents[0])) fail('progress must be a YAML table');
|
|
41
|
+
return { progress: documents[0], legacyNormalized: documents.length === 2 };
|
|
42
|
+
}
|
|
43
|
+
function checkProgress(progress, learner) {
|
|
44
|
+
if (progress.user !== learner) fail('progress belongs to another learner');
|
|
45
|
+
if (
|
|
46
|
+
!Array.isArray(progress.sessions) ||
|
|
47
|
+
progress.sessions.length !== SESSION_IDS.length ||
|
|
48
|
+
progress.sessions.some((session, index) => !table(session) || session.id !== SESSION_IDS[index])
|
|
49
|
+
)
|
|
50
|
+
fail('progress must retain all seven canonical sessions in order');
|
|
51
|
+
for (const session of progress.sessions) {
|
|
52
|
+
if (!['not-started', 'in-progress', 'completed'].includes(session.status)) fail(`invalid status for ${session.id}`);
|
|
53
|
+
if (
|
|
54
|
+
session.score !== null &&
|
|
55
|
+
(typeof session.score !== 'number' || !Number.isFinite(session.score) || session.score < 0 || session.score > 100)
|
|
56
|
+
)
|
|
57
|
+
fail(`invalid score for ${session.id}`);
|
|
58
|
+
if (
|
|
59
|
+
session.status === 'completed' &&
|
|
60
|
+
(session.score === null ||
|
|
61
|
+
typeof session.completed_date !== 'string' ||
|
|
62
|
+
!session.completed_date.trim() ||
|
|
63
|
+
typeof session.notes_artifact !== 'string' ||
|
|
64
|
+
!session.notes_artifact.trim())
|
|
65
|
+
)
|
|
66
|
+
fail(`completed session lacks score, date or notes: ${session.id}`);
|
|
67
|
+
}
|
|
68
|
+
const completed = progress.sessions.filter((session) => session.status === 'completed').length;
|
|
69
|
+
if (
|
|
70
|
+
progress.sessions_completed !== completed ||
|
|
71
|
+
progress.total_sessions !== 7 ||
|
|
72
|
+
typeof progress.completion_percentage !== 'number' ||
|
|
73
|
+
Math.abs(progress.completion_percentage - (completed / 7) * 100) > 0.51
|
|
74
|
+
)
|
|
75
|
+
fail('progress completion counters contradict its sessions');
|
|
76
|
+
if (
|
|
77
|
+
!(
|
|
78
|
+
SESSION_IDS.includes(progress.next_recommended) ||
|
|
79
|
+
(completed === 7 && [null, 'completion', 'completed', 'none'].includes(progress.next_recommended))
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
fail('progress has an invalid next recommendation');
|
|
83
|
+
if (completed < 7 && progress.sessions.find((session) => session.id === progress.next_recommended)?.status === 'completed')
|
|
84
|
+
fail('next recommendation names an already completed session');
|
|
85
|
+
if (progress.role !== null && (typeof progress.role !== 'string' || !['qa', 'dev', 'lead', 'vp'].includes(progress.role.toLowerCase())))
|
|
86
|
+
fail('progress has an invalid role');
|
|
87
|
+
if (
|
|
88
|
+
progress.experience_level !== null &&
|
|
89
|
+
(typeof progress.experience_level !== 'string' ||
|
|
90
|
+
!['beginner', 'intermediate', 'experienced'].includes(progress.experience_level.toLowerCase()))
|
|
91
|
+
)
|
|
92
|
+
fail('progress has an invalid experience level');
|
|
93
|
+
for (const field of ['learning_goals', 'pain_points'])
|
|
94
|
+
if (progress[field] !== null && (typeof progress[field] !== 'string' || !progress[field].trim())) fail(`progress has invalid ${field}`);
|
|
95
|
+
if (
|
|
96
|
+
!Array.isArray(progress.stepsCompleted) ||
|
|
97
|
+
progress.stepsCompleted.some((step) => typeof step !== 'string') ||
|
|
98
|
+
typeof progress.lastStep !== 'string'
|
|
99
|
+
)
|
|
100
|
+
fail('progress has invalid continuation tracking');
|
|
101
|
+
if (typeof progress.summary_generated !== 'boolean') fail('progress has an invalid summary flag');
|
|
102
|
+
if (
|
|
103
|
+
progress.summary_generated &&
|
|
104
|
+
(completed !== 7 ||
|
|
105
|
+
typeof progress.summary_path !== 'string' ||
|
|
106
|
+
!progress.summary_path.trim() ||
|
|
107
|
+
typeof progress.completion_date !== 'string' ||
|
|
108
|
+
!progress.completion_date.trim())
|
|
109
|
+
)
|
|
110
|
+
fail('completed summary lacks its path, completion date or all seven sessions');
|
|
111
|
+
}
|
|
112
|
+
/** Every read is a fresh, singly linked regular file inside the attempt. */
|
|
113
|
+
function regularArtifact(root, value) {
|
|
114
|
+
const lexical = path.resolve(root, value);
|
|
115
|
+
let resolved;
|
|
116
|
+
try {
|
|
117
|
+
resolved = projectPath(root, value, 'generated artifact');
|
|
118
|
+
} catch (error) {
|
|
119
|
+
fail(`generated artifact is outside the attempt: ${error.message}`);
|
|
120
|
+
}
|
|
121
|
+
if (resolved !== lexical) fail('generated artifact traverses a symbolic link');
|
|
122
|
+
let stat;
|
|
123
|
+
try {
|
|
124
|
+
stat = fs.lstatSync(resolved);
|
|
125
|
+
} catch {
|
|
126
|
+
fail(`generated artifact is missing: ${value}`);
|
|
127
|
+
}
|
|
128
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) fail(`generated artifact must be a separate regular file: ${value}`);
|
|
129
|
+
return { path: resolved, bytes: fs.readFileSync(resolved) };
|
|
130
|
+
}
|
|
131
|
+
/** Snapshot the entire CLI-owned directory. Symbolic and inode aliases are rejected. */
|
|
132
|
+
function snapshot(root) {
|
|
133
|
+
const files = new Map();
|
|
134
|
+
if (!fs.existsSync(root)) return files;
|
|
135
|
+
const walk = (directory) => {
|
|
136
|
+
if (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink())
|
|
137
|
+
throw new WorkflowError('usage', 'learner state must be a regular directory');
|
|
138
|
+
for (const entry of fs.readdirSync(directory)) {
|
|
139
|
+
const file = path.join(directory, entry),
|
|
140
|
+
stat = fs.lstatSync(file);
|
|
141
|
+
if (stat.isDirectory() && !stat.isSymbolicLink()) walk(file);
|
|
142
|
+
else if (stat.isFile() && stat.nlink === 1) files.set(path.relative(root, file), fs.readFileSync(file));
|
|
143
|
+
else throw new WorkflowError('usage', 'learner state contains a symbolic link or inode alias');
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
walk(root);
|
|
147
|
+
return files;
|
|
148
|
+
}
|
|
149
|
+
function sameSnapshot(left, right) {
|
|
150
|
+
return left.size === right.size && [...left].every(([file, bytes]) => right.get(file)?.equals(bytes));
|
|
151
|
+
}
|
|
152
|
+
/** Install the directory as a unit; retain recoverable backups if rollback fails. */
|
|
153
|
+
function publishState(root, files, io = fs) {
|
|
154
|
+
const parent = path.dirname(root);
|
|
155
|
+
io.mkdirSync(parent, { recursive: true });
|
|
156
|
+
const stage = io.mkdtempSync(path.join(parent, `.tea-teach-publish-${hash(root).slice(0, 16)}-`));
|
|
157
|
+
const next = path.join(stage, 'next'),
|
|
158
|
+
previous = path.join(stage, 'previous');
|
|
159
|
+
io.mkdirSync(next);
|
|
160
|
+
let moved = false,
|
|
161
|
+
installed = false,
|
|
162
|
+
keep = false;
|
|
163
|
+
try {
|
|
164
|
+
for (const [file, bytes] of files) {
|
|
165
|
+
const destination = path.join(next, file);
|
|
166
|
+
io.mkdirSync(path.dirname(destination), { recursive: true });
|
|
167
|
+
io.writeFileSync(destination, bytes, { flag: 'wx' });
|
|
168
|
+
}
|
|
169
|
+
if (io.existsSync(root)) {
|
|
170
|
+
io.renameSync(root, previous);
|
|
171
|
+
moved = true;
|
|
172
|
+
}
|
|
173
|
+
io.renameSync(next, root);
|
|
174
|
+
installed = true;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (moved && !installed) {
|
|
177
|
+
try {
|
|
178
|
+
io.renameSync(previous, root);
|
|
179
|
+
} catch (error_) {
|
|
180
|
+
keep = true;
|
|
181
|
+
throw new WorkflowError(
|
|
182
|
+
'environment-configuration',
|
|
183
|
+
`state publication failed: ${error.message}; recovery backup: ${previous}; rollback: ${error_.message}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
throw new WorkflowError('environment-configuration', `state publication failed: ${error.message}; previous state preserved`);
|
|
188
|
+
} finally {
|
|
189
|
+
if (!keep) io.rmSync(stage, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Restore a prior learner directory if publication stopped between its two renames. */
|
|
193
|
+
function recoverPublishedState(root) {
|
|
194
|
+
if (fs.existsSync(root)) return;
|
|
195
|
+
const parent = path.dirname(root);
|
|
196
|
+
const prefix = `.tea-teach-publish-${hash(root).slice(0, 16)}-`;
|
|
197
|
+
const stages = fs
|
|
198
|
+
.readdirSync(parent)
|
|
199
|
+
.filter((name) => name.startsWith(prefix))
|
|
200
|
+
.map((name) => path.join(parent, name));
|
|
201
|
+
for (const stage of stages) {
|
|
202
|
+
const stat = fs.lstatSync(stage);
|
|
203
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
204
|
+
throw new WorkflowError('usage', `interrupted learner publication has an invalid stage: ${stage}`);
|
|
205
|
+
}
|
|
206
|
+
if (stages.length === 0) return;
|
|
207
|
+
if (stages.length !== 1)
|
|
208
|
+
throw new WorkflowError('usage', `multiple interrupted learner publications need manual recovery: ${stages.join(', ')}`);
|
|
209
|
+
const previous = path.join(stages[0], 'previous');
|
|
210
|
+
if (!fs.existsSync(previous) || !fs.lstatSync(previous).isDirectory() || fs.lstatSync(previous).isSymbolicLink())
|
|
211
|
+
throw new WorkflowError('usage', `interrupted learner publication has no retained state: ${stages[0]}`);
|
|
212
|
+
fs.renameSync(previous, root);
|
|
213
|
+
fs.rmSync(stages[0], { recursive: true, force: true });
|
|
214
|
+
}
|
|
215
|
+
function checkConversation(value, learner, bank) {
|
|
216
|
+
if (!table(value) || value.schema_version !== '0.1.0' || value.learner !== learner || !Array.isArray(value.turns) || !table(value.quiz))
|
|
217
|
+
fail('conversation has invalid learner identity or schema');
|
|
218
|
+
if (value.activeQuiz !== undefined && !table(value.activeQuiz)) fail('conversation contains an invalid active quiz ledger');
|
|
219
|
+
const ids = new Set();
|
|
220
|
+
for (const turn of value.turns) {
|
|
221
|
+
if (
|
|
222
|
+
!table(turn) ||
|
|
223
|
+
typeof turn.id !== 'string' ||
|
|
224
|
+
ids.has(turn.id) ||
|
|
225
|
+
typeof turn.learnerMessage !== 'string' ||
|
|
226
|
+
!turn.learnerMessage.trim() ||
|
|
227
|
+
typeof turn.tutorReply !== 'string' ||
|
|
228
|
+
!turn.tutorReply.trim() ||
|
|
229
|
+
!table(turn.waiting)
|
|
230
|
+
)
|
|
231
|
+
fail('conversation contains an invalid or repeated turn');
|
|
232
|
+
ids.add(turn.id);
|
|
233
|
+
}
|
|
234
|
+
for (const [sessionId, round] of Object.entries(value.quiz)) checkQuizRound(value, bank, sessionId, round);
|
|
235
|
+
for (const [sessionId, round] of Object.entries(value.activeQuiz ?? {})) {
|
|
236
|
+
if (value.quiz[sessionId] && value.activeQuiz?.[sessionId] && value.quiz[sessionId].id === value.activeQuiz[sessionId].id)
|
|
237
|
+
fail('recorded and active quiz rounds must differ');
|
|
238
|
+
checkQuizRound(value, bank, sessionId, round);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/** Bind one recorded quiz round to its exact preceding caller questions and answers. */
|
|
242
|
+
function checkQuizRound(value, bank, sessionId, round) {
|
|
243
|
+
if (!SESSION_IDS.includes(sessionId) || !table(round) || typeof round.id !== 'string' || !Array.isArray(round.answers))
|
|
244
|
+
fail('conversation contains an invalid quiz ledger');
|
|
245
|
+
for (const [index, answer] of round.answers.entries()) {
|
|
246
|
+
const turnIndex = value.turns.findIndex((item) => item.id === answer.messageId);
|
|
247
|
+
const turn = value.turns[turnIndex];
|
|
248
|
+
const pending = value.turns[turnIndex - 1]?.waiting;
|
|
249
|
+
const question = bank[sessionId]?.questions[index];
|
|
250
|
+
if (
|
|
251
|
+
!turn ||
|
|
252
|
+
turn.learnerMessage.trim().toUpperCase() !== answer.answer ||
|
|
253
|
+
!/^[ABCD]$/.test(answer.answer) ||
|
|
254
|
+
!question ||
|
|
255
|
+
answer.questionId !== question.id ||
|
|
256
|
+
answer.correct !== (answer.answer === question.correct) ||
|
|
257
|
+
pending?.kind !== 'quiz' ||
|
|
258
|
+
pending.sessionId !== sessionId ||
|
|
259
|
+
pending.questionId !== question.id ||
|
|
260
|
+
pending.roundId !== round.id
|
|
261
|
+
)
|
|
262
|
+
fail('quiz answer lacks its actual learner message');
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function quizForTurn(conversation, incoming, bank) {
|
|
266
|
+
const quiz = structuredClone(conversation.quiz);
|
|
267
|
+
const activeQuiz = structuredClone(conversation.activeQuiz ?? {});
|
|
268
|
+
const pending = conversation.turns.at(-1)?.waiting;
|
|
269
|
+
if (pending?.kind === 'quiz') {
|
|
270
|
+
const item = bank[pending.sessionId]?.questions.find((question) => question.id === pending.questionId);
|
|
271
|
+
if (!item) fail('saved pending quiz question is unknown');
|
|
272
|
+
const answer = incoming.learnerMessage.trim().toUpperCase();
|
|
273
|
+
if (/^[ABCD]$/.test(answer)) {
|
|
274
|
+
const round = activeQuiz[pending.sessionId]?.id === pending.roundId ? activeQuiz[pending.sessionId] : quiz[pending.sessionId];
|
|
275
|
+
if (!round || round.id !== pending.roundId || round.answers.some((entry) => entry.questionId === item.id))
|
|
276
|
+
fail('saved quiz round is inconsistent');
|
|
277
|
+
round.answers.push({ questionId: item.id, messageId: incoming.id, answer, correct: answer === item.correct });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return { quiz, activeQuiz };
|
|
281
|
+
}
|
|
282
|
+
/** Accept only a caller assertion that answers the field's assessment question. */
|
|
283
|
+
function supportsLearnerFact(turns, cited, field, value) {
|
|
284
|
+
const index = turns.findIndex((turn) => turn.id === cited);
|
|
285
|
+
if (index === -1) return false;
|
|
286
|
+
const pending = turns[index - 1]?.waiting;
|
|
287
|
+
if (pending?.kind === 'assessment' && pending.field !== field) return false;
|
|
288
|
+
const message = turns[index].learnerMessage.toLowerCase();
|
|
289
|
+
const target = value.toLowerCase();
|
|
290
|
+
const at = message.indexOf(target);
|
|
291
|
+
if (at === -1) return false;
|
|
292
|
+
if (['role', 'experience_level'].includes(field) && /[a-z0-9_]/.test(message[at - 1] ?? '')) return false;
|
|
293
|
+
if (['role', 'experience_level'].includes(field) && /[a-z0-9_]/.test(message[at + target.length] ?? '')) return false;
|
|
294
|
+
const prefix = message
|
|
295
|
+
.slice(0, at)
|
|
296
|
+
.split(/[.!?;,]/)
|
|
297
|
+
.at(-1);
|
|
298
|
+
if (/\b(?:not|never|no|without|don't|dont|cannot|can't)\b(?:\W+\w+){0,4}\W*$/.test(prefix)) return false;
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
/** Match displayed choice letters and text to the canonical quiz bank. */
|
|
302
|
+
function hasCanonicalOptions(reply, question) {
|
|
303
|
+
const shown = new Map();
|
|
304
|
+
for (const line of reply.replaceAll(/\*\*|`/g, '').split(/\r?\n/)) {
|
|
305
|
+
const match = line.match(/^\s*(?:[-*]\s*)?([ABCD])\s*[).:-]\s*(.*?)\s*$/);
|
|
306
|
+
if (!match) continue;
|
|
307
|
+
if (shown.has(match[1]) || match[2] !== question.options[match[1]]) return false;
|
|
308
|
+
shown.set(match[1], match[2]);
|
|
309
|
+
}
|
|
310
|
+
return Object.keys(question.options).every((letter) => shown.has(letter));
|
|
311
|
+
}
|
|
312
|
+
function scoredRound(round, questions) {
|
|
313
|
+
if (
|
|
314
|
+
!round ||
|
|
315
|
+
round.answers.length !== questions.length ||
|
|
316
|
+
questions.some((question, index) => round.answers[index]?.questionId !== question.id)
|
|
317
|
+
)
|
|
318
|
+
return null;
|
|
319
|
+
const correct = round.answers.filter((answer) => answer.correct).length;
|
|
320
|
+
return { score: Math.round((correct / questions.length) * 10_000) / 100, passed: (correct / questions.length) * 100 >= 70 };
|
|
321
|
+
}
|
|
322
|
+
/** Bind trusted imported completions to specific session facts; publication relocates notes. */
|
|
323
|
+
function trustedSessionFingerprint(session) {
|
|
324
|
+
const facts = { ...session };
|
|
325
|
+
delete facts.notes_artifact;
|
|
326
|
+
return hash(JSON.stringify(Object.fromEntries(Object.entries(facts).sort(([left], [right]) => left.localeCompare(right)))));
|
|
327
|
+
}
|
|
328
|
+
/** Cross-check saved progress against caller-owned history before invoking a tutor or publishing. */
|
|
329
|
+
function checkHistory(progress, conversation, bank) {
|
|
330
|
+
for (const session of progress.sessions) {
|
|
331
|
+
if (session.status !== 'completed') continue;
|
|
332
|
+
const imported = conversation.importedProgress;
|
|
333
|
+
if (
|
|
334
|
+
imported?.completedSessions?.[session.id] === trustedSessionFingerprint(session) &&
|
|
335
|
+
typeof imported.path === 'string' &&
|
|
336
|
+
typeof imported.sha256 === 'string' &&
|
|
337
|
+
/^[a-f0-9]{64}$/.test(imported.sha256)
|
|
338
|
+
)
|
|
339
|
+
continue;
|
|
340
|
+
const questions = bank[session.id]?.questions;
|
|
341
|
+
if (!Array.isArray(questions)) fail('completed session has no canonical quiz bank');
|
|
342
|
+
let result;
|
|
343
|
+
if (questions.length === 0) {
|
|
344
|
+
const completed = conversation.turns.some((turn, index) => {
|
|
345
|
+
const pending = conversation.turns[index - 1]?.waiting;
|
|
346
|
+
return (
|
|
347
|
+
pending?.kind === 'completion' && pending.sessionId === session.id && /^(?:c|complete|done)$/i.test(turn.learnerMessage.trim())
|
|
348
|
+
);
|
|
349
|
+
});
|
|
350
|
+
if (!completed) fail('saved exploratory completion lacks its caller choice');
|
|
351
|
+
result = { score: 100, passed: null };
|
|
352
|
+
} else {
|
|
353
|
+
result = scoredRound(conversation.quiz[session.id], questions);
|
|
354
|
+
if (!result) fail(`saved completion lacks caller quiz evidence: ${session.id}`);
|
|
355
|
+
if (!result.passed) {
|
|
356
|
+
const lastAnswer = conversation.quiz[session.id].answers.at(-1).messageId;
|
|
357
|
+
const answerIndex = conversation.turns.findIndex((turn) => turn.id === lastAnswer);
|
|
358
|
+
if (
|
|
359
|
+
!conversation.turns.some(
|
|
360
|
+
(turn, index) =>
|
|
361
|
+
index > answerIndex &&
|
|
362
|
+
conversation.turns[index - 1]?.waiting.kind === 'review' &&
|
|
363
|
+
conversation.turns[index - 1].waiting.sessionId === session.id &&
|
|
364
|
+
/^\[?c\]?$/i.test(turn.learnerMessage.trim()),
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
fail('saved below-pass completion lacks its caller Continue choice');
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (
|
|
371
|
+
Math.abs(session.score - result.score) > 0.011 ||
|
|
372
|
+
session.quiz_passed !== result.passed ||
|
|
373
|
+
['passed', 'mastered'].some((field) => session[field] !== undefined && session[field] !== result.passed)
|
|
374
|
+
)
|
|
375
|
+
fail('saved completion contradicts its caller quiz grade');
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** Verify the tutor output against host-owned incoming messages and quiz results. */
|
|
379
|
+
function validateTurn({
|
|
380
|
+
request,
|
|
381
|
+
conversation,
|
|
382
|
+
before,
|
|
383
|
+
bank,
|
|
384
|
+
attemptDir,
|
|
385
|
+
stagedRoot,
|
|
386
|
+
progressFile,
|
|
387
|
+
responseFile,
|
|
388
|
+
inputDigests,
|
|
389
|
+
assertInputsUnchanged,
|
|
390
|
+
}) {
|
|
391
|
+
assertInputsUnchanged();
|
|
392
|
+
for (const [file, digest] of inputDigests) if (hash(fs.readFileSync(file)) !== digest) fail('the agent changed a supplied input');
|
|
393
|
+
const progressArtifact = regularArtifact(attemptDir, progressFile);
|
|
394
|
+
const progress = parseProgress(progressArtifact.bytes.toString('utf8')).progress;
|
|
395
|
+
checkProgress(progress, request.learner);
|
|
396
|
+
const response = JSON.parse(regularArtifact(attemptDir, responseFile).bytes);
|
|
397
|
+
if (
|
|
398
|
+
!table(response) ||
|
|
399
|
+
response.requestId !== request.requestId ||
|
|
400
|
+
response.learner !== request.learner ||
|
|
401
|
+
typeof response.reply !== 'string' ||
|
|
402
|
+
!response.reply.trim() ||
|
|
403
|
+
!table(response.waiting) ||
|
|
404
|
+
!table(response.factEvidence)
|
|
405
|
+
)
|
|
406
|
+
fail('response does not identify this learner turn');
|
|
407
|
+
if (Object.keys(response).some((key) => !['requestId', 'learner', 'reply', 'waiting', 'factEvidence'].includes(key)))
|
|
408
|
+
fail('response includes unsupported fields; learner messages are host-owned');
|
|
409
|
+
const incoming = { id: request.requestId, learnerMessage: request.message, tutorReply: response.reply, waiting: response.waiting };
|
|
410
|
+
const { quiz, activeQuiz } = quizForTurn(conversation, incoming, bank);
|
|
411
|
+
const messages = [...conversation.turns, incoming];
|
|
412
|
+
for (const field of FACTS) {
|
|
413
|
+
if (before?.[field] !== null && before?.[field] !== undefined && !isDeepStrictEqual(before[field], progress[field]))
|
|
414
|
+
fail(`the tutor changed established learner ${field}`);
|
|
415
|
+
if (
|
|
416
|
+
progress[field] !== null &&
|
|
417
|
+
(before?.[field] === null || before?.[field] === undefined) &&
|
|
418
|
+
!supportsLearnerFact(messages, response.factEvidence[field], field, progress[field])
|
|
419
|
+
)
|
|
420
|
+
fail(`learner ${field} lacks caller-message evidence`);
|
|
421
|
+
}
|
|
422
|
+
const artifacts = new Map();
|
|
423
|
+
const collect = (file) => {
|
|
424
|
+
const artifact = regularArtifact(stagedRoot, file);
|
|
425
|
+
artifacts.set(path.relative(stagedRoot, artifact.path), artifact.bytes);
|
|
426
|
+
return artifact;
|
|
427
|
+
};
|
|
428
|
+
for (const [index, session] of progress.sessions.entries()) {
|
|
429
|
+
const previous = before?.sessions[index];
|
|
430
|
+
if (session.score === null && ['quiz_passed', 'passed', 'mastered'].some((field) => session[field] === true))
|
|
431
|
+
fail('session claims mastery without quiz evidence');
|
|
432
|
+
if (previous?.status === 'completed' && !isDeepStrictEqual(previous, session))
|
|
433
|
+
fail(`the tutor changed completed session ${session.id}`);
|
|
434
|
+
if (session.status === 'completed') {
|
|
435
|
+
const notes = collect(session.notes_artifact);
|
|
436
|
+
const notesText = notes.bytes.toString('utf8');
|
|
437
|
+
const metadata = notesText.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
438
|
+
if (!metadata || !notesText.slice(metadata[0].length).trim()) fail('completed session notes lack metadata or content');
|
|
439
|
+
const noteState = yaml.load(metadata[1], { schema: yaml.JSON_SCHEMA });
|
|
440
|
+
if (
|
|
441
|
+
noteState?.session_id !== session.id ||
|
|
442
|
+
noteState.user !== request.learner ||
|
|
443
|
+
Math.abs(noteState.score - session.score) > 0.011 ||
|
|
444
|
+
typeof noteState.score !== 'number'
|
|
445
|
+
)
|
|
446
|
+
fail('session notes disagree with learner, session or score');
|
|
447
|
+
if (previous?.status !== 'completed') {
|
|
448
|
+
const questions = bank[session.id]?.questions;
|
|
449
|
+
if (!Array.isArray(questions)) fail('skill quiz bank is missing this session');
|
|
450
|
+
let result;
|
|
451
|
+
if (questions.length === 0) {
|
|
452
|
+
const pending = conversation.turns.at(-1)?.waiting;
|
|
453
|
+
if (
|
|
454
|
+
pending?.kind !== 'completion' ||
|
|
455
|
+
pending.sessionId !== session.id ||
|
|
456
|
+
previous?.status !== 'in-progress' ||
|
|
457
|
+
!/^(?:c|complete|done)$/i.test(request.message.trim())
|
|
458
|
+
)
|
|
459
|
+
fail('exploratory completion requires the learner’s explicit completion choice');
|
|
460
|
+
result = { score: 100, passed: null };
|
|
461
|
+
} else {
|
|
462
|
+
result = scoredRound(activeQuiz[session.id] ?? quiz[session.id], questions);
|
|
463
|
+
if (!result) fail('session completion lacks all three caller-owned quiz answers');
|
|
464
|
+
const pending = conversation.turns.at(-1)?.waiting;
|
|
465
|
+
if (
|
|
466
|
+
!result.passed &&
|
|
467
|
+
!(pending?.kind === 'review' && pending.sessionId === session.id && /^\[?c\]?$/i.test(request.message.trim()))
|
|
468
|
+
)
|
|
469
|
+
fail('below-pass completion requires the learner’s explicit Continue choice');
|
|
470
|
+
}
|
|
471
|
+
if (Math.abs(session.score - result.score) > 0.011) fail('session score disagrees with its actual quiz evidence');
|
|
472
|
+
for (const field of ['quiz_passed', 'passed', 'mastered'])
|
|
473
|
+
if (session[field] !== undefined && session[field] !== result.passed) fail('session claims unearned mastery');
|
|
474
|
+
session.quiz_passed = result.passed;
|
|
475
|
+
if (activeQuiz[session.id]) {
|
|
476
|
+
quiz[session.id] = activeQuiz[session.id];
|
|
477
|
+
delete activeQuiz[session.id];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} else if (session.score !== null) {
|
|
481
|
+
const activeResult = scoredRound(activeQuiz[session.id], bank[session.id]?.questions ?? []);
|
|
482
|
+
const result = activeResult ?? scoredRound(quiz[session.id], bank[session.id]?.questions ?? []);
|
|
483
|
+
if (!result || Math.abs(session.score - result.score) > 0.011) fail('session score lacks complete quiz evidence');
|
|
484
|
+
for (const field of ['quiz_passed', 'passed', 'mastered'])
|
|
485
|
+
if (session[field] !== undefined && session[field] !== result.passed) fail('session claims unearned mastery');
|
|
486
|
+
if (activeResult) {
|
|
487
|
+
quiz[session.id] = activeQuiz[session.id];
|
|
488
|
+
delete activeQuiz[session.id];
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
const waiting = response.waiting;
|
|
493
|
+
if (
|
|
494
|
+
['lesson', 'quiz', 'review', 'completion'].includes(waiting.kind) &&
|
|
495
|
+
['role', 'experience_level', 'learning_goals'].some((field) => progress[field] === null)
|
|
496
|
+
)
|
|
497
|
+
fail('teaching requires the caller-supported learner assessment');
|
|
498
|
+
if (!['assessment', 'menu', 'lesson', 'quiz', 'review', 'completion'].includes(waiting.kind))
|
|
499
|
+
fail('response has an invalid learner stop point');
|
|
500
|
+
if (['lesson', 'quiz', 'review', 'completion'].includes(waiting.kind) && !SESSION_IDS.includes(waiting.sessionId))
|
|
501
|
+
fail('response stop point has no canonical session');
|
|
502
|
+
if (waiting.kind === 'assessment' && !FACTS.includes(waiting.field)) fail('assessment stop point has no known field');
|
|
503
|
+
if (waiting.kind === 'quiz') {
|
|
504
|
+
const questions = bank[waiting.sessionId].questions;
|
|
505
|
+
const question = questions.find((item) => item.id === waiting.questionId);
|
|
506
|
+
if (!question || !response.reply.includes(question.question) || !hasCanonicalOptions(response.reply, question))
|
|
507
|
+
fail('pending quiz reply omits its canonical question or options');
|
|
508
|
+
let round = activeQuiz[waiting.sessionId] ?? quiz[waiting.sessionId];
|
|
509
|
+
if (
|
|
510
|
+
waiting.questionId === questions[0].id &&
|
|
511
|
+
(conversation.turns.at(-1)?.waiting.kind !== 'quiz' || round?.answers.length === questions.length)
|
|
512
|
+
)
|
|
513
|
+
round = (scoredRound(quiz[waiting.sessionId], questions) ? activeQuiz : quiz)[waiting.sessionId] = {
|
|
514
|
+
id: crypto.randomUUID(),
|
|
515
|
+
answers: [],
|
|
516
|
+
};
|
|
517
|
+
if (!round || questions[round.answers.length]?.id !== waiting.questionId) fail('pending quiz skips unanswered questions');
|
|
518
|
+
waiting.roundId = round.id;
|
|
519
|
+
}
|
|
520
|
+
if (
|
|
521
|
+
waiting.kind === 'review' &&
|
|
522
|
+
!scoredRound(activeQuiz[waiting.sessionId] ?? quiz[waiting.sessionId], bank[waiting.sessionId].questions)
|
|
523
|
+
)
|
|
524
|
+
fail('review stop point lacks a completed quiz');
|
|
525
|
+
if (progress.summary_generated) {
|
|
526
|
+
if (progress.sessions_completed !== 7 || typeof progress.summary_path !== 'string')
|
|
527
|
+
fail('completion summary lacks all seven completed sessions');
|
|
528
|
+
collect(progress.summary_path);
|
|
529
|
+
}
|
|
530
|
+
if (before?.summary_generated && (!progress.summary_generated || progress.summary_path !== before.summary_path))
|
|
531
|
+
fail('the tutor changed the established completion summary');
|
|
532
|
+
if (before?.completion_date != null && progress.completion_date !== before.completion_date)
|
|
533
|
+
fail('the tutor changed the established completion date');
|
|
534
|
+
if (before && before.stepsCompleted.some((step) => !progress.stepsCompleted.includes(step)))
|
|
535
|
+
fail('the tutor discarded saved workflow steps');
|
|
536
|
+
const nextConversation = { ...conversation, turns: messages, quiz, activeQuiz };
|
|
537
|
+
checkConversation(nextConversation, request.learner, bank);
|
|
538
|
+
checkHistory(progress, nextConversation, bank);
|
|
539
|
+
return { progress, conversation: nextConversation, artifacts };
|
|
540
|
+
}
|
|
541
|
+
module.exports = {
|
|
542
|
+
SESSION_IDS,
|
|
543
|
+
FACTS,
|
|
544
|
+
hash,
|
|
545
|
+
parseProgress,
|
|
546
|
+
checkProgress,
|
|
547
|
+
regularArtifact,
|
|
548
|
+
snapshot,
|
|
549
|
+
sameSnapshot,
|
|
550
|
+
publishState,
|
|
551
|
+
recoverPublishedState,
|
|
552
|
+
checkConversation,
|
|
553
|
+
trustedSessionFingerprint,
|
|
554
|
+
checkHistory,
|
|
555
|
+
quizForTurn,
|
|
556
|
+
scoredRound,
|
|
557
|
+
validateTurn,
|
|
558
|
+
};
|
package/cli/teach.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Run one explicit learner message through the packaged teaching skill. */
|
|
3
|
+
'use strict';
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const yaml = require('js-yaml');
|
|
8
|
+
const { globSync } = require('glob');
|
|
9
|
+
const { Command } = require('commander');
|
|
10
|
+
const { resolveTeaConfig } = require('./lib/resolve-tea-config');
|
|
11
|
+
const { protectSources } = require('./lib/workflow-publication');
|
|
12
|
+
const {
|
|
13
|
+
addAgentOptions,
|
|
14
|
+
agentOptions,
|
|
15
|
+
readInput,
|
|
16
|
+
projectPath,
|
|
17
|
+
resolveWorkflowSkill,
|
|
18
|
+
runWithEvidence,
|
|
19
|
+
WorkflowError,
|
|
20
|
+
mainError,
|
|
21
|
+
} = require('./lib/workflow-cli');
|
|
22
|
+
const {
|
|
23
|
+
hash,
|
|
24
|
+
parseProgress,
|
|
25
|
+
checkProgress,
|
|
26
|
+
snapshot,
|
|
27
|
+
sameSnapshot,
|
|
28
|
+
publishState,
|
|
29
|
+
recoverPublishedState,
|
|
30
|
+
checkConversation,
|
|
31
|
+
checkHistory,
|
|
32
|
+
trustedSessionFingerprint,
|
|
33
|
+
validateTurn,
|
|
34
|
+
} = require('./lib/teach-turn');
|
|
35
|
+
const NAME = 'tea-teach';
|
|
36
|
+
const serialize = (value) => Buffer.from(`${JSON.stringify(value, null, 2)}\n`);
|
|
37
|
+
function within(root, file) {
|
|
38
|
+
const relative = path.relative(root, file);
|
|
39
|
+
return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
40
|
+
}
|
|
41
|
+
function aliases(left, right) {
|
|
42
|
+
if (left === right) return true;
|
|
43
|
+
if (!fs.existsSync(left) || !fs.existsSync(right)) return false;
|
|
44
|
+
const a = fs.statSync(left),
|
|
45
|
+
b = fs.statSync(right);
|
|
46
|
+
return a.dev === b.dev && a.ino === b.ino;
|
|
47
|
+
}
|
|
48
|
+
function promptFor(request, config, conversation, bank) {
|
|
49
|
+
return [
|
|
50
|
+
`Run the actual TEA teaching skill at ${JSON.stringify(request.skillRoot)}. Read its SKILL.md completely and follow its teaching steps in order.`,
|
|
51
|
+
`Project root: ${JSON.stringify(request.projectRoot)}. Knowledge root: ${JSON.stringify(path.resolve(request.skillRoot, '..', 'bmod-tea', 'knowledge'))}.`,
|
|
52
|
+
'This is one learner turn in create/continue mode. The explicit learner name overrides config.user_name. Read only the supplied history and incoming learner message as learner responses.',
|
|
53
|
+
'Ask the next teaching question or show the next menu in the reply artifact, then stop. Questions to the learner belong in that reply. Do not ask outer-agent permission questions, invent learner answers, choose menu options for the learner, or continue past unanswered questions.',
|
|
54
|
+
'The supplied configuration replaces activation and later resolver calls, including packaged defaults when setup files are absent. Execute applicable customization hooks in their declared order.',
|
|
55
|
+
`Resolved configuration: ${JSON.stringify(config.configSnapshot)}`,
|
|
56
|
+
`Resolved workflow customization: ${JSON.stringify(config.workflowCustomization)}`,
|
|
57
|
+
`Teach turn request: ${JSON.stringify(request)}`,
|
|
58
|
+
`Host-owned conversation: ${JSON.stringify(conversation)}`,
|
|
59
|
+
'Use only the current attempt artifacts root for writes. Published state and supplied input files are read-only. Preserve prior learner facts and completed sessions. Resume the saved unanswered question or step position from the conversation; initialization dashboards may precede it.',
|
|
60
|
+
'Use the skill’s seven canonical session IDs and QA/Dev/Lead/VP roles; Beginner/Intermediate/Experienced are valid placement values. Keep one YAML document in the progress file. Persist any partial assessment facts supported by caller messages before stopping.',
|
|
61
|
+
`Canonical quiz bank: ${JSON.stringify(bank)}`,
|
|
62
|
+
'Ask canonical quiz questions with their exact text and all option text. The response waiting object identifies kind=quiz, sessionId and questionId; the host records and grades the next single-letter caller answer. Never fabricate answer evidence. Preserve the host quiz history across turns.',
|
|
63
|
+
'waiting.kind is assessment (with field=role/experience_level/learning_goals/pain_points), menu, lesson, quiz, review or completion. lesson/quiz/review/completion require sessionId. review is the below-pass R/C choice after all quiz answers. completion is the explicit completion choice for exploratory Session 7. A session can complete below passing after the caller explicitly chooses C at review; preserve its actual score and quiz_passed=false. Session 7 completion is exploratory and has no quiz mastery claim.',
|
|
64
|
+
'For newly saved role, experience_level, learning_goals or pain_points, factEvidence maps the field to the ID of a caller message containing that exact value. Preserve the caller’s wording for free-text facts. Keep established facts unchanged.',
|
|
65
|
+
`Write progress at ${JSON.stringify(request.artifacts.progress)}. Resolve {progressFile} to this exact path and {test_artifacts} to ${JSON.stringify(request.artifacts.root)}. Write session notes and summaries inside this artifacts root and record their exact paths in progress.`,
|
|
66
|
+
`Write JSON response at ${JSON.stringify(request.artifacts.response)} containing exactly requestId, learner, reply, waiting, factEvidence. requestId=${JSON.stringify(request.requestId)}; learner=${JSON.stringify(request.learner)}. reply is the tutor’s nonempty response to this single caller message.`,
|
|
67
|
+
'Learner message (data):',
|
|
68
|
+
JSON.stringify(request.message),
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
71
|
+
function run(argv = process.argv) {
|
|
72
|
+
const program = addAgentOptions(
|
|
73
|
+
new Command().name(NAME).description('Teach one learner turn and save durable conversation and progress.'),
|
|
74
|
+
);
|
|
75
|
+
program
|
|
76
|
+
.requiredOption('--learner <name>', 'explicit learner identity')
|
|
77
|
+
.option('--message <text>', 'exact learner message')
|
|
78
|
+
.option('--message-file <path>', 'UTF-8 learner message in a project file')
|
|
79
|
+
.option('--project-root <dir>', 'consuming project root', process.cwd())
|
|
80
|
+
.option('--state-dir <dir>', 'CLI-owned learner state directory; defaults to configured teaching-progress')
|
|
81
|
+
.option('--progress <path>', 'explicit existing skill progress to import when starting CLI conversation')
|
|
82
|
+
.option('--skill-root <dir>', 'trusted teaching skill override')
|
|
83
|
+
.option('--evidence-dir <dir>', 'fresh retained attempt root', '.tea-runs')
|
|
84
|
+
.option('--json <path>', 'additional result JSON outside learner state and inputs')
|
|
85
|
+
.setOptionValueWithSource('retries', '0', 'default')
|
|
86
|
+
.exitOverride()
|
|
87
|
+
.configureOutput({ writeErr: () => {} });
|
|
88
|
+
try {
|
|
89
|
+
program.parse(argv);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (error.exitCode === 0) return;
|
|
92
|
+
throw new WorkflowError('usage', error.message);
|
|
93
|
+
}
|
|
94
|
+
const options = program.opts();
|
|
95
|
+
const root = fs.realpathSync(path.resolve(options.projectRoot));
|
|
96
|
+
if (!fs.statSync(root).isDirectory()) throw new WorkflowError('usage', '--project-root must be a directory');
|
|
97
|
+
agentOptions(options, root);
|
|
98
|
+
if (
|
|
99
|
+
typeof options.learner !== 'string' ||
|
|
100
|
+
!/^[\p{L}\p{N}][\p{L}\p{N} ._'()-]{0,79}$/u.test(options.learner) ||
|
|
101
|
+
options.learner.trim() !== options.learner
|
|
102
|
+
)
|
|
103
|
+
throw new WorkflowError('usage', '--learner must be a name of 1 to 80 letters, digits, spaces or safe punctuation');
|
|
104
|
+
if ((options.message === undefined) === (options.messageFile === undefined))
|
|
105
|
+
throw new WorkflowError('usage', 'supply exactly one --message or --message-file');
|
|
106
|
+
const inputFile = options.messageFile ? readInput(root, options.messageFile, '--message-file') : null;
|
|
107
|
+
const message = inputFile ? new TextDecoder('utf-8', { fatal: true }).decode(fs.readFileSync(inputFile)) : options.message;
|
|
108
|
+
if (!message.trim()) throw new WorkflowError('usage', 'learner message must be nonempty');
|
|
109
|
+
const skillRoot = resolveWorkflowSkill({ projectRoot: root, skillName: 'bmad-teach-me-testing', skillRoot: options.skillRoot });
|
|
110
|
+
const consumedInputs = new Set();
|
|
111
|
+
const inputPatterns = new Map();
|
|
112
|
+
const config = resolveTeaConfig({
|
|
113
|
+
projectRoot: root,
|
|
114
|
+
skillRoot,
|
|
115
|
+
skillName: 'bmad-teach-me-testing',
|
|
116
|
+
inputPaths: consumedInputs,
|
|
117
|
+
inputPatterns,
|
|
118
|
+
});
|
|
119
|
+
config.configSnapshot.core.user_name = options.learner;
|
|
120
|
+
const configuredArtifacts = config.configSnapshot.modules.tea.test_artifacts;
|
|
121
|
+
if (typeof configuredArtifacts !== 'string' || !configuredArtifacts.trim())
|
|
122
|
+
throw new WorkflowError('environment-configuration', 'modules.tea.test_artifacts must be a nonempty path string');
|
|
123
|
+
const artifactRoot = configuredArtifacts.replaceAll('{project-root}', root);
|
|
124
|
+
const slug =
|
|
125
|
+
options.learner
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
128
|
+
.replaceAll(/^-|-$/g, '') || 'learner';
|
|
129
|
+
const key = `${slug}-${hash(options.learner).slice(0, 12)}`;
|
|
130
|
+
const stateDir = projectPath(root, options.stateDir ?? path.join(artifactRoot, 'teaching-progress', key), '--state-dir');
|
|
131
|
+
const evidenceDir = projectPath(root, options.evidenceDir, '--evidence-dir');
|
|
132
|
+
if (stateDir === root || within(stateDir, evidenceDir) || within(evidenceDir, stateDir))
|
|
133
|
+
throw new WorkflowError('usage', 'learner state and evidence must be separate project directories');
|
|
134
|
+
if (inputFile && within(stateDir, inputFile)) throw new WorkflowError('usage', 'message input must be outside learner state');
|
|
135
|
+
const imported = options.progress ? readInput(root, options.progress, '--progress') : null;
|
|
136
|
+
const progressName = `${options.learner}-tea-progress.yaml`;
|
|
137
|
+
const progressPath = path.join(stateDir, progressName),
|
|
138
|
+
conversationPath = path.join(stateDir, 'conversation.json');
|
|
139
|
+
const jsonPath = options.json ? projectPath(root, options.json, '--json') : null;
|
|
140
|
+
const inputs = [
|
|
141
|
+
inputFile,
|
|
142
|
+
imported,
|
|
143
|
+
config.configPath,
|
|
144
|
+
...consumedInputs,
|
|
145
|
+
...[
|
|
146
|
+
'_bmad/config.toml',
|
|
147
|
+
'_bmad/config.user.toml',
|
|
148
|
+
'_bmad/custom/config.toml',
|
|
149
|
+
'_bmad/custom/config.user.toml',
|
|
150
|
+
'package.json',
|
|
151
|
+
'package-lock.json',
|
|
152
|
+
'_bmad/custom/bmad-teach-me-testing.toml',
|
|
153
|
+
'_bmad/custom/bmad-teach-me-testing.user.toml',
|
|
154
|
+
].map((file) => path.join(root, file)),
|
|
155
|
+
].filter(Boolean);
|
|
156
|
+
const guard = () => {
|
|
157
|
+
if (projectPath(root, stateDir, '--state-dir') !== stateDir || projectPath(root, evidenceDir, '--evidence-dir') !== evidenceDir)
|
|
158
|
+
throw new WorkflowError('usage', 'state or evidence path changed through an alias');
|
|
159
|
+
if (inputs.some((file) => within(stateDir, file) || within(evidenceDir, file)))
|
|
160
|
+
throw new WorkflowError('usage', 'state and evidence must be separate from configuration and supplied inputs');
|
|
161
|
+
if (!jsonPath) return;
|
|
162
|
+
if (
|
|
163
|
+
projectPath(root, jsonPath, '--json') !== jsonPath ||
|
|
164
|
+
within(stateDir, jsonPath) ||
|
|
165
|
+
within(evidenceDir, jsonPath) ||
|
|
166
|
+
inputs.some((file) => aliases(file, jsonPath))
|
|
167
|
+
)
|
|
168
|
+
throw new WorkflowError('usage', '--json aliases protected state, input or evidence');
|
|
169
|
+
if (path.extname(jsonPath) !== '.json' || (fs.existsSync(jsonPath) && !fs.statSync(jsonPath).isFile()))
|
|
170
|
+
throw new WorkflowError('usage', '--json must name a separate regular .json file');
|
|
171
|
+
};
|
|
172
|
+
guard();
|
|
173
|
+
fs.mkdirSync(path.dirname(stateDir), { recursive: true });
|
|
174
|
+
const lock = path.join(path.dirname(stateDir), `.tea-teach-lock-${hash(stateDir).slice(0, 16)}`);
|
|
175
|
+
try {
|
|
176
|
+
fs.mkdirSync(lock);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
throw new WorkflowError(
|
|
179
|
+
'usage',
|
|
180
|
+
`learner state is locked by another invocation at ${lock}; remove this lock only after confirming that invocation has ended`,
|
|
181
|
+
{ cause: error },
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
let original;
|
|
185
|
+
let originalExisted;
|
|
186
|
+
let execution;
|
|
187
|
+
try {
|
|
188
|
+
recoverPublishedState(stateDir);
|
|
189
|
+
originalExisted = fs.existsSync(stateDir);
|
|
190
|
+
original = snapshot(stateDir);
|
|
191
|
+
if (imported && original.size > 0) throw new WorkflowError('usage', '--progress import requires an empty CLI learner state');
|
|
192
|
+
if (original.has('conversation.json') && !original.has(progressName))
|
|
193
|
+
throw new WorkflowError('usage', 'saved conversation requires its paired learner progress');
|
|
194
|
+
let before = null,
|
|
195
|
+
legacyNormalized = false;
|
|
196
|
+
if (original.has(progressName) || imported) {
|
|
197
|
+
try {
|
|
198
|
+
const parsed = parseProgress(imported ? fs.readFileSync(imported, 'utf8') : original.get(progressName).toString('utf8'), {
|
|
199
|
+
legacy: Boolean(imported),
|
|
200
|
+
});
|
|
201
|
+
before = parsed.progress;
|
|
202
|
+
legacyNormalized = parsed.legacyNormalized;
|
|
203
|
+
checkProgress(before, options.learner);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
throw new WorkflowError('usage', `invalid saved progress: ${error.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
let conversation = {
|
|
209
|
+
schema_version: '0.1.0',
|
|
210
|
+
learner: options.learner,
|
|
211
|
+
turns: [],
|
|
212
|
+
quiz: {},
|
|
213
|
+
importedProgress: imported
|
|
214
|
+
? {
|
|
215
|
+
path: imported,
|
|
216
|
+
sha256: hash(fs.readFileSync(imported)),
|
|
217
|
+
legacyNormalized,
|
|
218
|
+
trustedPriorCompletions: before.sessions_completed,
|
|
219
|
+
completedSessions: Object.fromEntries(
|
|
220
|
+
before.sessions
|
|
221
|
+
.filter((session) => session.status === 'completed')
|
|
222
|
+
.map((session) => [session.id, trustedSessionFingerprint(session)]),
|
|
223
|
+
),
|
|
224
|
+
}
|
|
225
|
+
: null,
|
|
226
|
+
};
|
|
227
|
+
const bank = yaml.load(fs.readFileSync(path.join(skillRoot, 'data/quiz-questions.yaml'), 'utf8'), { schema: yaml.JSON_SCHEMA });
|
|
228
|
+
try {
|
|
229
|
+
if (original.has('conversation.json')) conversation = JSON.parse(original.get('conversation.json'));
|
|
230
|
+
else if (before && !imported) throw new Error('existing progress requires its saved conversation or explicit --progress import');
|
|
231
|
+
checkConversation(conversation, options.learner, bank);
|
|
232
|
+
if (before) checkHistory(before, conversation, bank);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
throw new WorkflowError('usage', `invalid saved conversation: ${error.message}`);
|
|
235
|
+
}
|
|
236
|
+
const heldInputs = [...new Set(inputs)];
|
|
237
|
+
const importedArtifacts = new Map();
|
|
238
|
+
if (imported)
|
|
239
|
+
for (const file of [
|
|
240
|
+
...before.sessions.map((session) => session.notes_artifact).filter(Boolean),
|
|
241
|
+
...(before.summary_generated ? [before.summary_path] : []),
|
|
242
|
+
]) {
|
|
243
|
+
const source = readInput(root, file, 'imported teaching artifact');
|
|
244
|
+
heldInputs.push(source);
|
|
245
|
+
importedArtifacts.set(source, fs.readFileSync(source));
|
|
246
|
+
}
|
|
247
|
+
inputs.push(...heldInputs);
|
|
248
|
+
guard();
|
|
249
|
+
// Skill defaults may live outside the consuming project. Freeze them under their own root.
|
|
250
|
+
const projectInputs = heldInputs.filter((file) => within(root, file));
|
|
251
|
+
const absentInputs = heldInputs.filter((file) => !fs.existsSync(file));
|
|
252
|
+
const protections = [
|
|
253
|
+
protectSources(
|
|
254
|
+
root,
|
|
255
|
+
projectInputs.filter((file) => fs.existsSync(file)),
|
|
256
|
+
),
|
|
257
|
+
protectSources(
|
|
258
|
+
skillRoot,
|
|
259
|
+
[...consumedInputs].filter((file) => within(skillRoot, file) && fs.existsSync(file)),
|
|
260
|
+
),
|
|
261
|
+
];
|
|
262
|
+
const assertInputsUnchanged = () => {
|
|
263
|
+
for (const protection of protections) protection.assertUnchanged();
|
|
264
|
+
if (absentInputs.some((file) => fs.existsSync(file)))
|
|
265
|
+
throw new WorkflowError('environment-parser', 'the agent created a previously absent configuration input');
|
|
266
|
+
for (const [pattern, matches] of inputPatterns) {
|
|
267
|
+
const current = globSync(pattern, { cwd: root, dot: true, nodir: true, follow: false }).sort();
|
|
268
|
+
if (JSON.stringify(current) !== JSON.stringify(matches))
|
|
269
|
+
throw new WorkflowError('environment-parser', 'the agent changed customization policy membership');
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
assertInputsUnchanged();
|
|
273
|
+
const requestId = crypto.randomUUID();
|
|
274
|
+
execution = runWithEvidence({
|
|
275
|
+
name: NAME,
|
|
276
|
+
projectRoot: root,
|
|
277
|
+
evidenceRoot: evidenceDir,
|
|
278
|
+
options,
|
|
279
|
+
prepare({ attemptDir }) {
|
|
280
|
+
assertInputsUnchanged();
|
|
281
|
+
const stagedRoot = path.join(attemptDir, 'artifacts');
|
|
282
|
+
fs.mkdirSync(stagedRoot);
|
|
283
|
+
for (const [file, bytes] of original)
|
|
284
|
+
if (file.startsWith(`artifacts${path.sep}`)) {
|
|
285
|
+
const target = path.join(stagedRoot, path.relative('artifacts', file));
|
|
286
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
287
|
+
fs.writeFileSync(target, bytes, { flag: 'wx' });
|
|
288
|
+
}
|
|
289
|
+
const stagedBefore = before ? structuredClone(before) : null;
|
|
290
|
+
if (stagedBefore) {
|
|
291
|
+
for (const session of stagedBefore.sessions)
|
|
292
|
+
if (session.notes_artifact) {
|
|
293
|
+
if (imported) {
|
|
294
|
+
const source = readInput(root, session.notes_artifact, 'imported notes');
|
|
295
|
+
const target = path.join(stagedRoot, 'imported-notes', `${session.id}.md`);
|
|
296
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
297
|
+
fs.writeFileSync(target, importedArtifacts.get(source));
|
|
298
|
+
session.notes_artifact = target;
|
|
299
|
+
} else {
|
|
300
|
+
if (!within(path.join(stateDir, 'artifacts'), session.notes_artifact))
|
|
301
|
+
throw new WorkflowError('usage', 'saved notes escape CLI-owned artifacts');
|
|
302
|
+
session.notes_artifact = path.join(stagedRoot, path.relative(path.join(stateDir, 'artifacts'), session.notes_artifact));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (stagedBefore.summary_path) {
|
|
306
|
+
if (imported) {
|
|
307
|
+
const source = readInput(root, stagedBefore.summary_path, 'imported summary');
|
|
308
|
+
const target = path.join(stagedRoot, 'imported-summary.md');
|
|
309
|
+
fs.writeFileSync(target, importedArtifacts.get(source));
|
|
310
|
+
stagedBefore.summary_path = target;
|
|
311
|
+
} else {
|
|
312
|
+
if (!within(path.join(stateDir, 'artifacts'), stagedBefore.summary_path))
|
|
313
|
+
throw new WorkflowError('usage', 'saved summary escapes CLI-owned artifacts');
|
|
314
|
+
stagedBefore.summary_path = path.join(stagedRoot, path.relative(path.join(stateDir, 'artifacts'), stagedBefore.summary_path));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const progressFile = path.join(stagedRoot, 'teaching-progress', progressName),
|
|
319
|
+
responseFile = path.join(attemptDir, 'response.json');
|
|
320
|
+
fs.mkdirSync(path.dirname(progressFile), { recursive: true });
|
|
321
|
+
if (stagedBefore) fs.writeFileSync(progressFile, yaml.dump(stagedBefore));
|
|
322
|
+
const request = {
|
|
323
|
+
requestId,
|
|
324
|
+
learner: options.learner,
|
|
325
|
+
message,
|
|
326
|
+
projectRoot: root,
|
|
327
|
+
skillRoot,
|
|
328
|
+
previous: Boolean(before),
|
|
329
|
+
artifacts: { root: stagedRoot, progress: progressFile, response: responseFile },
|
|
330
|
+
};
|
|
331
|
+
const stagedConfig = structuredClone(config);
|
|
332
|
+
stagedConfig.configSnapshot.modules.tea.test_artifacts = stagedRoot;
|
|
333
|
+
return {
|
|
334
|
+
prompt: promptFor(request, stagedConfig, conversation, bank),
|
|
335
|
+
request,
|
|
336
|
+
conversation,
|
|
337
|
+
before: stagedBefore,
|
|
338
|
+
bank,
|
|
339
|
+
stagedRoot,
|
|
340
|
+
progressFile,
|
|
341
|
+
responseFile,
|
|
342
|
+
assertInputsUnchanged,
|
|
343
|
+
inputDigests: [
|
|
344
|
+
...(stagedBefore?.sessions
|
|
345
|
+
.filter((session) => session.status === 'completed')
|
|
346
|
+
.map((session) => [session.notes_artifact, hash(fs.readFileSync(session.notes_artifact))]) ?? []),
|
|
347
|
+
...(stagedBefore?.summary_generated ? [[stagedBefore.summary_path, hash(fs.readFileSync(stagedBefore.summary_path))]] : []),
|
|
348
|
+
],
|
|
349
|
+
};
|
|
350
|
+
},
|
|
351
|
+
validate: validateTurn,
|
|
352
|
+
});
|
|
353
|
+
guard();
|
|
354
|
+
assertInputsUnchanged();
|
|
355
|
+
if (!sameSnapshot(original, snapshot(stateDir)))
|
|
356
|
+
throw new WorkflowError('environment-parser', 'the agent changed published learner state');
|
|
357
|
+
if (execution.dryRun) {
|
|
358
|
+
process.stdout.write(
|
|
359
|
+
`${JSON.stringify({ mode: 'prompt-only', evidence: execution.runDir, prompt: path.join(execution.attemptDir, 'prompt.txt') })}\n`,
|
|
360
|
+
);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const value = execution.value;
|
|
364
|
+
const next = new Map(original);
|
|
365
|
+
for (const [file, bytes] of value.artifacts) next.set(path.join('artifacts', file), bytes);
|
|
366
|
+
for (const session of value.progress.sessions)
|
|
367
|
+
if (session.notes_artifact)
|
|
368
|
+
session.notes_artifact = path.join(stateDir, 'artifacts', path.relative(execution.context.stagedRoot, session.notes_artifact));
|
|
369
|
+
if (value.progress.summary_path)
|
|
370
|
+
value.progress.summary_path = path.join(
|
|
371
|
+
stateDir,
|
|
372
|
+
'artifacts',
|
|
373
|
+
path.relative(execution.context.stagedRoot, value.progress.summary_path),
|
|
374
|
+
);
|
|
375
|
+
next.set(progressName, Buffer.from(yaml.dump(value.progress)));
|
|
376
|
+
next.set('conversation.json', serialize(value.conversation));
|
|
377
|
+
checkHistory(value.progress, value.conversation, bank);
|
|
378
|
+
assertInputsUnchanged();
|
|
379
|
+
publishState(stateDir, next);
|
|
380
|
+
const payload = {
|
|
381
|
+
schema_version: '0.1.0',
|
|
382
|
+
mode: 'live',
|
|
383
|
+
learner: options.learner,
|
|
384
|
+
reply: value.conversation.turns.at(-1).tutorReply,
|
|
385
|
+
waiting: value.conversation.turns.at(-1).waiting,
|
|
386
|
+
progress: progressPath,
|
|
387
|
+
conversation: conversationPath,
|
|
388
|
+
sessions_completed: value.progress.sessions_completed,
|
|
389
|
+
evidence: execution.runDir,
|
|
390
|
+
};
|
|
391
|
+
guard();
|
|
392
|
+
if (jsonPath) {
|
|
393
|
+
fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
|
|
394
|
+
fs.writeFileSync(jsonPath, serialize(payload));
|
|
395
|
+
}
|
|
396
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (original) {
|
|
399
|
+
let changed;
|
|
400
|
+
try {
|
|
401
|
+
changed = !sameSnapshot(original, snapshot(stateDir));
|
|
402
|
+
} catch {
|
|
403
|
+
changed = true;
|
|
404
|
+
}
|
|
405
|
+
if (changed && projectPath(root, path.dirname(stateDir)) === path.dirname(stateDir)) {
|
|
406
|
+
try {
|
|
407
|
+
publishState(stateDir, original);
|
|
408
|
+
if (!originalExisted) fs.rmdirSync(stateDir);
|
|
409
|
+
} catch (error_) {
|
|
410
|
+
error.message += `; state recovery failed: ${error_.message}`;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (execution?.runDir && !error.runDir) error.runDir = execution.runDir;
|
|
415
|
+
// Failure payloads stay on stdout and retained stderr; never publish failed state or overwrite --json.
|
|
416
|
+
process.stdout.write(
|
|
417
|
+
`${JSON.stringify({ schema_version: '0.1.0', status: 'failed', reason: error.message, evidence: error.runDir ?? null })}\n`,
|
|
418
|
+
);
|
|
419
|
+
throw error;
|
|
420
|
+
} finally {
|
|
421
|
+
fs.rmdirSync(lock);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
function main(argv = process.argv) {
|
|
425
|
+
try {
|
|
426
|
+
run(argv);
|
|
427
|
+
return 0;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
return mainError(NAME, error);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (require.main === module) process.exitCode = main();
|
|
433
|
+
module.exports = { main, run, promptFor };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "bmad-method-test-architecture-enterprise",
|
|
4
|
-
"version": "2.0.4-next.
|
|
4
|
+
"version": "2.0.4-next.4",
|
|
5
5
|
"description": "Master Test Architect for quality strategy, test automation, and release gates",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"bmad",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"tea-nfr-runner": "cli/nfr-runner.js",
|
|
43
43
|
"tea-routing-runner": "cli/routing-runner.js",
|
|
44
44
|
"tea-skill-runner": "cli/skill-runner.js",
|
|
45
|
+
"tea-teach": "cli/teach.js",
|
|
45
46
|
"tea-test-design": "cli/test-design.js",
|
|
46
47
|
"tea-test-design-runner": "cli/test-design-runner.js",
|
|
47
48
|
"tea-test-review": "cli/test-review.js",
|
|
@@ -93,7 +94,7 @@
|
|
|
93
94
|
"release:minor": "gh workflow run publish.yaml -f channel=latest -f bump=minor",
|
|
94
95
|
"release:next": "gh workflow run publish.yaml -f channel=next",
|
|
95
96
|
"release:patch": "gh workflow run publish.yaml -f channel=latest -f bump=patch",
|
|
96
|
-
"test": "npm run test:conflict-markers && npm run test:schemas && npm run test:install && npm run test:evaluate-guidance && npm run test:evaluate-authoring && npm run test:evaluate-gap-loop && npm run test:evaluate-dogfood && npm run test:evaluate-check && npm run test:evaluate-compare && npm run test:evaluate-before-state && npm run test:evaluate-ci-gates && npm run test:evaluate-ci-tiers && npm run test:evaluate-ci-repositories:tagged-release-pr && npm run test:evaluate-ci-repositories:tagged-release-merge && npm run test:evaluate-ci-repositories:tagged-release-release && npm run test:evaluate-ci-repositories:nightly-deploy-pr && npm run test:evaluate-ci-repositories:nightly-deploy-merge && npm run test:evaluate-ci-repositories:nightly-deploy-scheduled && npm run test:evaluate-ci-repositories:nightly-deploy-release && npm run test:evaluate-ci-render && npm run test:evaluate-pr-mcp && npm run test:evaluate-pr-api && npm run test:evaluate-pr-suite && npm run test:evaluate-pr-workflow && npm run test:evaluate-pr-tool-use && npm run test:evaluate-pr-promptfoo && npm run test:evaluate-pr-ai-feature && npm run test:evaluate-pr-test-review && npm run test:evaluate-pr-gap-loop && npm run test:evaluate-pr-learn && npm run test:evaluate-pr-tutorial && npm run test:evaluate-partitions && npm run test:evaluate-partition-plans-views-and-check && npm run test:evaluate-partition-plans-preflight-and-run && npm run test:evaluate-partition-plans-score && npm run test:evaluate-partition-plans-attempts-seal-and-mappings && npm run test:evaluate-partition-plans-attempts-records && npm run test:evaluate-partition-plans-attempts-gameability && npm run test:evaluate-interpret && npm run test:evaluate-calibration && npm run test:evaluate-boundaries && npm run test:evaluate-preflight && npm run test:evaluate-isolation-conformance && npm run test:evaluate-mutation:qualification-basics && npm run test:evaluate-mutation:qualification-guards && npm run test:evaluate-mutation:recovery && npm run test:evaluate-mutation:journals && npm run test:evaluate-run && npm run test:evaluate-progress && npm run test:evaluate-aggregate && npm run test:evaluate-confinement && npm run test:evaluate-confinement-audit && npm run test:evaluate-confinement-history && npm run test:evaluate-confinement-git-state && npm run test:evaluate-confinement-network && npm run test:evaluate-held-inputs && npm run test:evaluate-arms-historical && npm run test:evaluate-arms-deployments && npm run test:evaluate-evaluators && npm run test:evaluate-evaluators-agent-version && npm run test:evaluate-agents && npm run test:evaluate-agents-importers && npm run test:evaluate-agents-sealed-brief && npm run test:evaluate-held-attempts && npm run test:evaluate-private && npm run test:evaluate-records && npm run test:evaluate-mcp && npm run test:evaluate-api && npm run test:evaluate-workflow && npm run test:evaluate-tool-use && npm run test:evaluate-promptfoo && npm run test:evaluate-learned-framework && npm run test:docs-tutorial && npm run test:knowledge && npm run test:criteria-fragments && npm run test:doc-count-sources && npm run test:planning-doc-sources && npm run test:doc-counts && npm run test:doc-claim-sources && npm run test:doc-claims && npm run test:doc-invocation-entry && npm run test:doc-invocations && npm run test:doc-link-anchors && npm run test:docs-build-names && npm run test:docs-evaluate-sidebar && npm run test:docs-gap-reader && npm run test:enforce-hook && npm run test:framework-scaffold && npm run test:isolation-primitives && npm run test:atdd-isolation && npm run test:atdd-net-guard && npm run test:atdd-scoring && npm run test:atdd-workflow-guidance && npm run test:generation-healing && npm run test:framework-scaffold-install-isolation && npm run test:transcript-harness && npm run test:suite-manifest && npm run test:teach-me-testing-scoring && npm run test:eval-data && npm run test:eval-atdd-data && npm run test:eval-ci-data && npm run test:eval-framework-scaffold-data && npm run test:eval-nfr-data && npm run test:nfr-validate-corpus && npm run test:nfr-evidence-guidance && npm run test:nfr-cli && npm run test:eval-test-design-data && npm run test:eval-trace-data && npm run test:eval-routing-data && npm run test:eval-routing-boundaries && npm run test:eval-routing-evidence && npm run test:eval-transcript-data && npm run test:eval-teach-me-testing-data && npm run test:eval-schemas && npm run test:eval-diagnostics && npm run test:eval-replay && npm run test:eval-quality-corpus && npm run test:schema-versions && npm run test:contract-sources && npm run test:contracts && npm run test:contract-oracles && npm run test:probe-sources && npm run test:test-design-qualification && npm run test:test-review-qualification && npm run test:trace-qualification && npm run test:nfr-qualification && npm run test:ci-qualification && npm run test:probe-corpus && npm run test:compare-dominance && npm run test:compare-eval-runs && npm run test:trial-set-scoring && npm run test:automate-cli && npm run test:automate-generation-evidence && npm run test:automate-eval-fixture && npm run test:eval-automate-data && npm run test:clock-port && npm run test:probe-conformance && npm run test:corpus-conformance && npm run test:file-system-conformance && npm run test:file-system-port && npm run test:probe-targets && npm run test:port-totality && npm run test:ci-coverage && npm run test:ci-coverage-filters && npm run test:install-actionlint && npm run test:workflows-lint && npm run test:install-linux-sandbox && npm run test:shards && npm run test:groups && npm run test:cli && npm run test:workflow-cli && npm run test:framework-cli && npm run test:test-review-pr-scope && npm run test:test-review-report && npm run test:test-review-rubric && npm run test:bmad-output-gated && npm run test:release-metadata && npm run test:release-notes && npm run test:lockfile-age && npm run test:lockfile-age-cache && npm run test:licences && npm run test:supply-chain && npm run test:boundary && npm run test:direction && npm run test:lineage && npm run test:guard-publish && npm run test:layering-boundary-lineage && npm run test:changelog && npm run test:tea-workflow-descriptions && npm run test:docs-mermaid && npm run test:llms-headroom && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
|
|
97
|
+
"test": "npm run test:conflict-markers && npm run test:schemas && npm run test:install && npm run test:evaluate-guidance && npm run test:evaluate-authoring && npm run test:evaluate-gap-loop && npm run test:evaluate-dogfood && npm run test:evaluate-check && npm run test:evaluate-compare && npm run test:evaluate-before-state && npm run test:evaluate-ci-gates && npm run test:evaluate-ci-tiers && npm run test:evaluate-ci-repositories:tagged-release-pr && npm run test:evaluate-ci-repositories:tagged-release-merge && npm run test:evaluate-ci-repositories:tagged-release-release && npm run test:evaluate-ci-repositories:nightly-deploy-pr && npm run test:evaluate-ci-repositories:nightly-deploy-merge && npm run test:evaluate-ci-repositories:nightly-deploy-scheduled && npm run test:evaluate-ci-repositories:nightly-deploy-release && npm run test:evaluate-ci-render && npm run test:evaluate-pr-mcp && npm run test:evaluate-pr-api && npm run test:evaluate-pr-suite && npm run test:evaluate-pr-workflow && npm run test:evaluate-pr-tool-use && npm run test:evaluate-pr-promptfoo && npm run test:evaluate-pr-ai-feature && npm run test:evaluate-pr-test-review && npm run test:evaluate-pr-gap-loop && npm run test:evaluate-pr-learn && npm run test:evaluate-pr-tutorial && npm run test:evaluate-partitions && npm run test:evaluate-partition-plans-views-and-check && npm run test:evaluate-partition-plans-preflight-and-run && npm run test:evaluate-partition-plans-score && npm run test:evaluate-partition-plans-attempts-seal-and-mappings && npm run test:evaluate-partition-plans-attempts-records && npm run test:evaluate-partition-plans-attempts-gameability && npm run test:evaluate-interpret && npm run test:evaluate-calibration && npm run test:evaluate-boundaries && npm run test:evaluate-preflight && npm run test:evaluate-isolation-conformance && npm run test:evaluate-mutation:qualification-basics && npm run test:evaluate-mutation:qualification-guards && npm run test:evaluate-mutation:recovery && npm run test:evaluate-mutation:journals && npm run test:evaluate-run && npm run test:evaluate-progress && npm run test:evaluate-aggregate && npm run test:evaluate-confinement && npm run test:evaluate-confinement-audit && npm run test:evaluate-confinement-history && npm run test:evaluate-confinement-git-state && npm run test:evaluate-confinement-network && npm run test:evaluate-held-inputs && npm run test:evaluate-arms-historical && npm run test:evaluate-arms-deployments && npm run test:evaluate-evaluators && npm run test:evaluate-evaluators-agent-version && npm run test:evaluate-agents && npm run test:evaluate-agents-importers && npm run test:evaluate-agents-sealed-brief && npm run test:evaluate-held-attempts && npm run test:evaluate-private && npm run test:evaluate-records && npm run test:evaluate-mcp && npm run test:evaluate-api && npm run test:evaluate-workflow && npm run test:evaluate-tool-use && npm run test:evaluate-promptfoo && npm run test:evaluate-learned-framework && npm run test:docs-tutorial && npm run test:knowledge && npm run test:criteria-fragments && npm run test:doc-count-sources && npm run test:planning-doc-sources && npm run test:doc-counts && npm run test:doc-claim-sources && npm run test:doc-claims && npm run test:doc-invocation-entry && npm run test:doc-invocations && npm run test:doc-link-anchors && npm run test:docs-build-names && npm run test:docs-evaluate-sidebar && npm run test:docs-gap-reader && npm run test:enforce-hook && npm run test:framework-scaffold && npm run test:isolation-primitives && npm run test:atdd-isolation && npm run test:atdd-net-guard && npm run test:atdd-scoring && npm run test:atdd-workflow-guidance && npm run test:generation-healing && npm run test:framework-scaffold-install-isolation && npm run test:transcript-harness && npm run test:suite-manifest && npm run test:teach-me-testing-scoring && npm run test:teach-cli && npm run test:eval-data && npm run test:eval-atdd-data && npm run test:eval-ci-data && npm run test:eval-framework-scaffold-data && npm run test:eval-nfr-data && npm run test:nfr-validate-corpus && npm run test:nfr-evidence-guidance && npm run test:nfr-cli && npm run test:eval-test-design-data && npm run test:eval-trace-data && npm run test:eval-routing-data && npm run test:eval-routing-boundaries && npm run test:eval-routing-evidence && npm run test:eval-transcript-data && npm run test:eval-teach-me-testing-data && npm run test:eval-schemas && npm run test:eval-diagnostics && npm run test:eval-replay && npm run test:eval-quality-corpus && npm run test:schema-versions && npm run test:contract-sources && npm run test:contracts && npm run test:contract-oracles && npm run test:probe-sources && npm run test:test-design-qualification && npm run test:test-review-qualification && npm run test:trace-qualification && npm run test:nfr-qualification && npm run test:ci-qualification && npm run test:probe-corpus && npm run test:compare-dominance && npm run test:compare-eval-runs && npm run test:trial-set-scoring && npm run test:automate-cli && npm run test:automate-generation-evidence && npm run test:automate-eval-fixture && npm run test:eval-automate-data && npm run test:clock-port && npm run test:probe-conformance && npm run test:corpus-conformance && npm run test:file-system-conformance && npm run test:file-system-port && npm run test:probe-targets && npm run test:port-totality && npm run test:ci-coverage && npm run test:ci-coverage-filters && npm run test:install-actionlint && npm run test:workflows-lint && npm run test:install-linux-sandbox && npm run test:shards && npm run test:groups && npm run test:cli && npm run test:workflow-cli && npm run test:framework-cli && npm run test:test-review-pr-scope && npm run test:test-review-report && npm run test:test-review-rubric && npm run test:bmad-output-gated && npm run test:release-metadata && npm run test:release-notes && npm run test:lockfile-age && npm run test:lockfile-age-cache && npm run test:licences && npm run test:supply-chain && npm run test:boundary && npm run test:direction && npm run test:lineage && npm run test:guard-publish && npm run test:layering-boundary-lineage && npm run test:changelog && npm run test:tea-workflow-descriptions && npm run test:docs-mermaid && npm run test:llms-headroom && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check",
|
|
97
98
|
"test:atdd-isolation": "node test/test-atdd-isolation.js",
|
|
98
99
|
"test:atdd-net-guard": "node test/test-atdd-net-guard.js",
|
|
99
100
|
"test:atdd-scoring": "node test/test-atdd-scoring.js",
|
|
@@ -258,7 +259,8 @@
|
|
|
258
259
|
"test:suite-manifest": "node test/test-suite-manifest.js",
|
|
259
260
|
"test:supply-chain": "node test/test-supply-chain.js",
|
|
260
261
|
"test:tea-workflow-descriptions": "node test/test-tea-workflow-descriptions.js",
|
|
261
|
-
"test:teach-
|
|
262
|
+
"test:teach-cli": "node --test test/test-teach-cli.js",
|
|
263
|
+
"test:teach-me-testing-scoring": "node test/test-teach-me-testing-scoring.js && node test/test-teach-progress-format.js",
|
|
262
264
|
"test:test-design-qualification": "node test/test-test-design-qualification.js",
|
|
263
265
|
"test:test-review-cli": "node test/test-test-review-cli.js",
|
|
264
266
|
"test:test-review-pr-scope": "node test/test-test-review-pr-scope.js",
|
|
@@ -103,7 +103,7 @@ You can pause and resume anytime - your progress will be saved automatically aft
|
|
|
103
103
|
|
|
104
104
|
### 4. Create Initial Progress File (New Learner Only)
|
|
105
105
|
|
|
106
|
-
Load {progressTemplate} and create {progressFile}
|
|
106
|
+
Load {progressTemplate} and create {progressFile} as one YAML document. A leading `---` is optional; omit a trailing `---`, which starts another document. Write the following fields:
|
|
107
107
|
|
|
108
108
|
```yaml
|
|
109
109
|
---
|
|
@@ -193,7 +193,6 @@ lastContinued: { current_date }
|
|
|
193
193
|
summary_generated: false
|
|
194
194
|
summary_path: null
|
|
195
195
|
completion_date: null
|
|
196
|
-
---
|
|
197
196
|
```
|
|
198
197
|
|
|
199
198
|
### 5. Proceed to Assessment (New Learner Only)
|
|
@@ -71,7 +71,7 @@ Read {progressFile} completely and extract:
|
|
|
71
71
|
|
|
72
72
|
### 2. Update Last Continued Timestamp
|
|
73
73
|
|
|
74
|
-
Update {progressFile}
|
|
74
|
+
Update the fields in {progressFile} and preserve one YAML document. Use the full-file progress schema; omit a trailing `---` document delimiter:
|
|
75
75
|
|
|
76
76
|
- Set `lastContinued: {current_date}`
|
|
77
77
|
- Keep all other fields unchanged
|
|
@@ -188,7 +188,7 @@ Display:
|
|
|
188
188
|
|
|
189
189
|
"### ✅ Quick Knowledge Check
|
|
190
190
|
|
|
191
|
-
Let me ask you 3 questions to validate your understanding. Passing score: ≥70% (2 of 3
|
|
191
|
+
Let me ask you 3 questions to validate your understanding. Passing score: ≥70% (3 of 3 correct; 2 of 3 is 66.67%)."
|
|
192
192
|
|
|
193
193
|
**Question 1:**
|
|
194
194
|
|