opencode-workflow-guard 1.9.3 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lib/tool-outcomes.ts +13 -0
- package/src/policies/boundary.ts +35 -28
- package/src/policies/continuation.ts +8 -1
- package/src/workflow-guard.ts +22 -7
- package/test/test-e2e.mts +58 -3
- package/test/test.mts +24 -0
package/package.json
CHANGED
package/src/lib/tool-outcomes.ts
CHANGED
|
@@ -27,6 +27,7 @@ export class ToolOutcomeTracker {
|
|
|
27
27
|
private readonly terminalCalls = new Map<string, Set<string>>();
|
|
28
28
|
private readonly fallbackCalls = new Map<string, Set<string>>();
|
|
29
29
|
private readonly failures = new Map<string, { signature: string; count: number }>();
|
|
30
|
+
private readonly consecutiveFailures = new Map<string, number>();
|
|
30
31
|
|
|
31
32
|
record(part: ToolOutcomePart): ToolOutcome | undefined {
|
|
32
33
|
if (part.type !== "tool" || typeof part.sessionID !== "string" || typeof part.callID !== "string" || typeof part.tool !== "string") return undefined;
|
|
@@ -51,6 +52,7 @@ export class ToolOutcomeTracker {
|
|
|
51
52
|
const durationMs = typeof start === "number" && Number.isFinite(start) && typeof end === "number" && Number.isFinite(end) ? Math.max(0, end - start) : undefined;
|
|
52
53
|
if (status === "completed") {
|
|
53
54
|
this.failures.delete(part.sessionID);
|
|
55
|
+
this.consecutiveFailures.delete(part.sessionID);
|
|
54
56
|
return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs };
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -59,6 +61,10 @@ export class ToolOutcomeTracker {
|
|
|
59
61
|
const previous = this.failures.get(part.sessionID);
|
|
60
62
|
const count = previous?.signature === signature ? previous.count + 1 : 1;
|
|
61
63
|
this.failures.set(part.sessionID, { signature, count });
|
|
64
|
+
|
|
65
|
+
const consecutive = (this.consecutiveFailures.get(part.sessionID) ?? 0) + 1;
|
|
66
|
+
this.consecutiveFailures.set(part.sessionID, consecutive);
|
|
67
|
+
|
|
62
68
|
return { sessionID: part.sessionID, callID: part.callID, tool: part.tool, status, durationMs, repeatedFailureCount: count };
|
|
63
69
|
}
|
|
64
70
|
|
|
@@ -74,8 +80,15 @@ export class ToolOutcomeTracker {
|
|
|
74
80
|
return { sessionID, callID, tool, status: "completed", durationMs };
|
|
75
81
|
}
|
|
76
82
|
|
|
83
|
+
getFailureCount(sessionID: string): number {
|
|
84
|
+
const consecutive = this.consecutiveFailures.get(sessionID) ?? 0;
|
|
85
|
+
const equivalent = this.failures.get(sessionID)?.count ?? 0;
|
|
86
|
+
return Math.max(consecutive, equivalent);
|
|
87
|
+
}
|
|
88
|
+
|
|
77
89
|
clearSession(sessionID: string): void {
|
|
78
90
|
this.failures.delete(sessionID);
|
|
91
|
+
this.consecutiveFailures.delete(sessionID);
|
|
79
92
|
this.terminalCalls.delete(sessionID);
|
|
80
93
|
this.fallbackCalls.delete(sessionID);
|
|
81
94
|
}
|
package/src/policies/boundary.ts
CHANGED
|
@@ -158,13 +158,9 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
|
158
158
|
what: `tee to '${teeTargets[0]!}'`,
|
|
159
159
|
};
|
|
160
160
|
}
|
|
161
|
-
const
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
const target = operands.at(-1);
|
|
165
|
-
if (!target) return undefined;
|
|
166
|
-
return { kind: "command", target, what: `sed -i on '${target}'` };
|
|
167
|
-
}
|
|
161
|
+
const simple = simpleFilesystemMutations(segment);
|
|
162
|
+
if (simple.length > 0) return simple[0];
|
|
163
|
+
|
|
168
164
|
const transfer = filesystemTransferInfo(segment);
|
|
169
165
|
if (transfer?.destination) {
|
|
170
166
|
return {
|
|
@@ -173,34 +169,45 @@ export function shellMutationIn(segment: string): ShellMutation | undefined {
|
|
|
173
169
|
what: `copy/move/link to '${transfer.destination}'`,
|
|
174
170
|
};
|
|
175
171
|
}
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
172
|
+
const words = shellWords(unwrapShellCommand(segment));
|
|
173
|
+
const command = words[0];
|
|
174
|
+
if (!command) return undefined;
|
|
175
|
+
|
|
176
|
+
if (command === "curl") {
|
|
177
|
+
for (let i = 1; i < words.length; i++) {
|
|
178
|
+
const w = words[i]!;
|
|
179
|
+
if ((w === "-o" || w === "--output") && i + 1 < words.length) {
|
|
180
|
+
return { kind: "command", target: words[i + 1]!, what: `curl output to '${words[i + 1]!}'` };
|
|
181
|
+
}
|
|
182
|
+
if (w.startsWith("--output=")) {
|
|
183
|
+
const target = w.slice("--output=".length);
|
|
184
|
+
return { kind: "command", target, what: `curl output to '${target}'` };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
185
187
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
188
|
+
if (command === "wget") {
|
|
189
|
+
for (let i = 1; i < words.length; i++) {
|
|
190
|
+
const w = words[i]!;
|
|
191
|
+
if ((w === "-O" || w === "--output-document") && i + 1 < words.length) {
|
|
192
|
+
return { kind: "command", target: words[i + 1]!, what: `wget output to '${words[i + 1]!}'` };
|
|
193
|
+
}
|
|
194
|
+
if (w.startsWith("--output-document=")) {
|
|
195
|
+
const target = w.slice("--output-document=".length);
|
|
196
|
+
return { kind: "command", target, what: `wget output to '${target}'` };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
195
199
|
}
|
|
196
|
-
if (
|
|
197
|
-
|
|
200
|
+
if (command === "git") {
|
|
201
|
+
const sub = words.slice(1).find((w) => !w.startsWith("-"));
|
|
202
|
+
if (sub === "apply" || sub === "am") {
|
|
203
|
+
return { kind: "command", what: "git apply/am (patch via shell)" };
|
|
204
|
+
}
|
|
198
205
|
}
|
|
199
206
|
return undefined;
|
|
200
207
|
}
|
|
201
208
|
|
|
202
209
|
const SIMPLE_MUTATION_COMMANDS = new Set(["touch", "mkdir", "rm", "unlink", "rmdir", "truncate", "chmod", "chown", "chgrp"]);
|
|
203
|
-
const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln"]);
|
|
210
|
+
const TRANSFER_COMMANDS = new Set(["cp", "mv", "ln", "rsync", "install", "cpio", "scp"]);
|
|
204
211
|
|
|
205
212
|
export function simpleFilesystemMutations(segment: string): ShellMutation[] {
|
|
206
213
|
const words = shellWords(unwrapShellCommand(segment));
|
|
@@ -83,6 +83,13 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
|
|
|
83
83
|
}
|
|
84
84
|
generatedIDs.add(messageID);
|
|
85
85
|
state.generatedMessageIDs.set(sessionID, generatedIDs);
|
|
86
|
+
const activeTasks = (effective.todos ?? [])
|
|
87
|
+
.filter((t) => t.status === "in_progress" || t.status === "pending")
|
|
88
|
+
.slice(0, 10);
|
|
89
|
+
const tasksSummary = activeTasks.length > 0
|
|
90
|
+
? "\nRemaining active tasks:\n" + activeTasks.map((t) => `- [${t.status === "in_progress" ? "IN_PROGRESS" : "PENDING"}] ${t.content}`).join("\n")
|
|
91
|
+
: "";
|
|
92
|
+
|
|
86
93
|
try {
|
|
87
94
|
await session.promptAsync({
|
|
88
95
|
path: { id: sessionID },
|
|
@@ -90,7 +97,7 @@ export async function continueUnfinishedSession(sessionID: string, settleTitle =
|
|
|
90
97
|
messageID,
|
|
91
98
|
parts: [{
|
|
92
99
|
type: "text",
|
|
93
|
-
text:
|
|
100
|
+
text: `Workflow Guard: unfinished todos remain. Continue working through them.${tasksSummary}\nIf you need user input, use the question tool instead of ending the run.`,
|
|
94
101
|
synthetic: true,
|
|
95
102
|
}],
|
|
96
103
|
},
|
package/src/workflow-guard.ts
CHANGED
|
@@ -414,7 +414,11 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
414
414
|
});
|
|
415
415
|
if (reason !== undefined) {
|
|
416
416
|
await emitBlockFeedback(reason);
|
|
417
|
-
|
|
417
|
+
const failureCount = input.sessionID ? toolOutcomes.getFailureCount(input.sessionID) : 0;
|
|
418
|
+
const circuitBreakerSuffix = failureCount >= 2
|
|
419
|
+
? "\n\n[Workflow Guard Circuit Breaker: Repeated failures detected in this session. Stop attempting alternative workarounds or shell laundering. Address the required step above directly.]"
|
|
420
|
+
: "";
|
|
421
|
+
throw new Error(`[workflow-guard] ${reason}${circuitBreakerSuffix}`);
|
|
418
422
|
}
|
|
419
423
|
toolLifecycle.start(input.sessionID, input.callID);
|
|
420
424
|
if (input.tool === "read") {
|
|
@@ -606,13 +610,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
606
610
|
|
|
607
611
|
// Keep tool descriptions honest: todowrite's description reflects lifecycle
|
|
608
612
|
// and finalization gates so the model is not surprised by preventable blocks.
|
|
613
|
+
// Similarly, mutating tools reflect their active-todo, feature-branch, and prior-read requirements.
|
|
609
614
|
"tool.definition": async (input, output) => {
|
|
610
|
-
if (input.toolID
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
615
|
+
if (input.toolID === "todowrite") {
|
|
616
|
+
const description = typeof output.description === "string" ? output.description : "";
|
|
617
|
+
if (description.includes("verification evidence")) return;
|
|
618
|
+
output.description =
|
|
619
|
+
description +
|
|
620
|
+
"\n\nWorkflow Guard lifecycle: each todowrite call replaces the complete task list. Preserve every pending/in_progress task in subsequent updates until you explicitly mark it completed or cancelled; do not omit active tasks when adding new work. Marking every task completed triggers the finalization gate - fresh verification evidence (test run) is required after the last mutation, and protected-branch/conflict checks apply.";
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (EDIT_TOOL_NAMES.has(input.toolID)) {
|
|
624
|
+
const description = typeof output.description === "string" ? output.description : "";
|
|
625
|
+
if (description.includes("Workflow Guard requirement")) return;
|
|
626
|
+
output.description =
|
|
627
|
+
description +
|
|
628
|
+
"\n\nWorkflow Guard requirement: Modifications require an active task in todowrite (status pending or in_progress), a feature branch (edits on main/master are blocked), and a prior read of existing files in the current session.";
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
616
631
|
},
|
|
617
632
|
|
|
618
633
|
"chat.message": async (input) => {
|
package/test/test-e2e.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdtempSync, rmSync, existsSync, readFileSync, copyFileSync, cpSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
3
|
import { homedir, tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { parse as parseJsonc } from "jsonc-parser";
|
|
@@ -143,9 +143,30 @@ if (opencodeCheck.status !== 0) {
|
|
|
143
143
|
}
|
|
144
144
|
console.log(` Found OpenCode version: ${opencodeCheck.stdout.trim()}`);
|
|
145
145
|
|
|
146
|
-
const
|
|
146
|
+
const opencodeDir = join(testDir, ".opencode");
|
|
147
|
+
const pluginsDir = join(opencodeDir, "plugins");
|
|
147
148
|
mkdirSync(pluginsDir, { recursive: true });
|
|
148
149
|
|
|
150
|
+
// Pre-seed .opencode with dependencies already installed into testDir
|
|
151
|
+
// so opencode does not execute a cold network install at runtime startup.
|
|
152
|
+
const opencodeVersion = opencodeCheck.stdout.trim();
|
|
153
|
+
const lockPath = join(testDir, "package-lock.json");
|
|
154
|
+
if (existsSync(lockPath) && existsSync(join(testDir, "node_modules"))) {
|
|
155
|
+
try {
|
|
156
|
+
const lock = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
157
|
+
lock.name = ".opencode";
|
|
158
|
+
if (lock.packages?.["node_modules/@opencode-ai/plugin"]) {
|
|
159
|
+
lock.packages["node_modules/@opencode-ai/plugin"].version = opencodeVersion;
|
|
160
|
+
}
|
|
161
|
+
if (lock.packages?.[""]) {
|
|
162
|
+
lock.packages[""].dependencies = { "@opencode-ai/plugin": opencodeVersion };
|
|
163
|
+
}
|
|
164
|
+
writeFileSync(join(opencodeDir, "package-lock.json"), JSON.stringify(lock, null, 2) + "\n");
|
|
165
|
+
writeFileSync(join(opencodeDir, "package.json"), JSON.stringify({ name: ".opencode", dependencies: { "@opencode-ai/plugin": opencodeVersion } }, null, 2) + "\n");
|
|
166
|
+
cpSync(join(testDir, "node_modules"), join(opencodeDir, "node_modules"), { recursive: true });
|
|
167
|
+
} catch {}
|
|
168
|
+
}
|
|
169
|
+
|
|
149
170
|
const sourcePlugin = join(import.meta.dirname, "..", "src", "workflow-guard.ts");
|
|
150
171
|
const sourceDir = join(testDir, ".opencode", "workflow-guard-source");
|
|
151
172
|
mkdirSync(sourceDir, { recursive: true });
|
|
@@ -189,9 +210,43 @@ const runtimeEnv: NodeJS.ProcessEnv = {
|
|
|
189
210
|
};
|
|
190
211
|
delete runtimeEnv.OPENCODE_PID;
|
|
191
212
|
delete runtimeEnv.OPENCODE_PURE;
|
|
213
|
+
delete runtimeEnv.OPENCODE;
|
|
192
214
|
const runOpenCode = (args: string[], timeout: number) =>
|
|
193
215
|
spawnSync("opencode", ["run", "--dir", testDir, ...args], { cwd: testDir, encoding: "utf8", timeout, env: runtimeEnv });
|
|
194
|
-
const configProbe =
|
|
216
|
+
const configProbe = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolve) => {
|
|
217
|
+
const child = spawn("opencode", ["debug", "config"], { cwd: testDir, env: runtimeEnv });
|
|
218
|
+
let stdout = "";
|
|
219
|
+
let stderr = "";
|
|
220
|
+
let timer: NodeJS.Timeout | undefined;
|
|
221
|
+
let pollInterval: NodeJS.Timeout | undefined;
|
|
222
|
+
|
|
223
|
+
const done = (status: number | null) => {
|
|
224
|
+
if (timer) clearTimeout(timer);
|
|
225
|
+
if (pollInterval) clearInterval(pollInterval);
|
|
226
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
227
|
+
resolve({ status: status ?? 0, stdout, stderr });
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const checkReady = () => {
|
|
231
|
+
try {
|
|
232
|
+
if (JSON.parse(stdout)?.plugin_origins && existsSync(initializedMarker)) {
|
|
233
|
+
done(0);
|
|
234
|
+
}
|
|
235
|
+
} catch {}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
pollInterval = setInterval(checkReady, 50);
|
|
239
|
+
|
|
240
|
+
child.stdout?.on("data", (d) => {
|
|
241
|
+
stdout += d.toString();
|
|
242
|
+
checkReady();
|
|
243
|
+
});
|
|
244
|
+
child.stderr?.on("data", (d) => { stderr += d.toString(); });
|
|
245
|
+
child.on("close", (code) => done(code));
|
|
246
|
+
child.on("error", () => done(null));
|
|
247
|
+
|
|
248
|
+
timer = setTimeout(() => done(null), 30_000);
|
|
249
|
+
});
|
|
195
250
|
let configLoadsLocalPlugin = false;
|
|
196
251
|
try {
|
|
197
252
|
const config = JSON.parse(configProbe.stdout);
|
package/test/test.mts
CHANGED
|
@@ -497,6 +497,9 @@ check("sed -i on opencode.json blocked (tamper)", blocked(await call("bash", { c
|
|
|
497
497
|
check("git apply needs todos (patch via shell)", blocked(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-empty" })));
|
|
498
498
|
check("git apply allowed with todos", !(await call("bash", { command: "git apply patch.diff" }, { sessionID: "s-active" })));
|
|
499
499
|
check("non-mutating shell unaffected (ls, cat)", !(await call("bash", { command: "ls -la && cat file" }, { sessionID: "s-empty" })));
|
|
500
|
+
check("inspection command with mutation keywords in grep argument is not blocked", !(await call("bash", { command: 'strings /bin/opencode | grep -E "install plugin and update config" -C 10' }, { sessionID: "s-empty" })));
|
|
501
|
+
check("git log with mutation keywords in grep argument is not blocked", !(await call("bash", { command: 'git log --grep="rm old files"' }, { sessionID: "s-empty" })));
|
|
502
|
+
check("grep searching for mutation command name is not blocked", !(await call("bash", { command: 'grep -rn "mkdir" src/' }, { sessionID: "s-empty" })));
|
|
500
503
|
check("stderr redirect to /dev/null is not a file mutation", !(await call("bash", { command: "ls missing 2>/dev/null" }, { sessionID: "s-empty" })));
|
|
501
504
|
check("touch outside workspace is blocked", blocked(await call("bash", { command: "touch /tmp/wg-outside-touch" }, { sessionID: "s-active" })));
|
|
502
505
|
check("mkdir outside workspace is blocked", blocked(await call("bash", { command: "mkdir /tmp/wg-outside-dir" }, { sessionID: "s-active" })));
|
|
@@ -1096,6 +1099,14 @@ if (typeof cmdEvt.event === "function") {
|
|
|
1096
1099
|
}
|
|
1097
1100
|
check("tool.execute.after provides deduplicated fallback outcome telemetry", getRecentAuditEntries(10).filter((entry) => entry.callID === "after-only-outcome" && entry.phase === "outcome").length === 1);
|
|
1098
1101
|
|
|
1102
|
+
let circuitBreakerMessage = "";
|
|
1103
|
+
try {
|
|
1104
|
+
await cmdEvt["tool.execute.before"]?.({ tool: "edit", sessionID: "s-outcome", callID: "call-cb" } as any, { args: { filePath: join(root, "file.txt"), content: "hello" } } as any);
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
circuitBreakerMessage = (err as Error).message;
|
|
1107
|
+
}
|
|
1108
|
+
check("repeated failures trigger circuit breaker guidance in block error", circuitBreakerMessage.includes("Workflow Guard Circuit Breaker") && circuitBreakerMessage.includes("Repeated failures detected"));
|
|
1109
|
+
|
|
1099
1110
|
const outcomeTracker = new ToolOutcomeTracker();
|
|
1100
1111
|
const failedPart = (callID: string, error: string) => ({ type: "tool", sessionID: "s-tracker", callID, tool: "bash", state: { status: "error", error } } as const);
|
|
1101
1112
|
check("duplicate terminal tool updates are ignored", !!outcomeTracker.record(failedPart("duplicate", "same failure")) && outcomeTracker.record(failedPart("duplicate", "same failure")) === undefined);
|
|
@@ -1104,6 +1115,12 @@ outcomeTracker.record({ type: "tool", sessionID: "s-tracker", callID: "success",
|
|
|
1104
1115
|
check("successful tool outcomes reset equivalent failure tracking", outcomeTracker.record(failedPart("after-success", "same failure"))?.repeatedFailureCount === 1);
|
|
1105
1116
|
outcomeTracker.record(failedPart("different", "different failure"));
|
|
1106
1117
|
check("distinct failures reset equivalent failure tracking", outcomeTracker.record(failedPart("after-different", "same failure"))?.repeatedFailureCount === 1);
|
|
1118
|
+
const consecutiveTracker = new ToolOutcomeTracker();
|
|
1119
|
+
consecutiveTracker.record(failedPart("diverse-1", "failure A"));
|
|
1120
|
+
consecutiveTracker.record(failedPart("diverse-2", "failure B"));
|
|
1121
|
+
check("consecutive diverse failures track failure count for circuit breaker", consecutiveTracker.getFailureCount("s-tracker") === 2);
|
|
1122
|
+
consecutiveTracker.record({ type: "tool", sessionID: "s-tracker", callID: "diverse-success", tool: "bash", state: { status: "completed" } });
|
|
1123
|
+
check("successful tool outcome resets consecutive failure count", consecutiveTracker.getFailureCount("s-tracker") === 0);
|
|
1107
1124
|
const boundedTracker = new ToolOutcomeTracker();
|
|
1108
1125
|
for (let i = 0; i < 4096; i++) boundedTracker.record({ type: "tool", sessionID: "s-bounded", callID: `call-${i}`, tool: "bash", state: { status: "completed" } });
|
|
1109
1126
|
check("terminal call deduplication remains stable within its bounded window", boundedTracker.record({ type: "tool", sessionID: "s-bounded", callID: "call-0", tool: "bash", state: { status: "completed" } }) === undefined);
|
|
@@ -3141,6 +3158,13 @@ const idemDef = { description: todoDef.description, parameters: {} };
|
|
|
3141
3158
|
await defPlugin["tool.definition"]?.({ toolID: "todowrite" } as any, idemDef);
|
|
3142
3159
|
check("tool.definition enrichment is idempotent", idemDef.description === todoDef.description);
|
|
3143
3160
|
|
|
3161
|
+
const editDef = { description: "Edit a file.", parameters: {} };
|
|
3162
|
+
await defPlugin["tool.definition"]?.({ toolID: "edit" } as any, editDef);
|
|
3163
|
+
check("tool.definition enriches edit tool with preconditions", editDef.description.includes("Workflow Guard requirement") && editDef.description.includes("todowrite"));
|
|
3164
|
+
const editIdemDef = { description: editDef.description, parameters: {} };
|
|
3165
|
+
await defPlugin["tool.definition"]?.({ toolID: "edit" } as any, editIdemDef);
|
|
3166
|
+
check("tool.definition edit enrichment is idempotent", editIdemDef.description === editDef.description);
|
|
3167
|
+
|
|
3144
3168
|
// Socratic learning engine: evidence is explicit and interventions favor
|
|
3145
3169
|
// relevant gaps without repeatedly interrupting demonstrated knowledge.
|
|
3146
3170
|
const learner = createLearnerProfile();
|