taskplane 0.24.19 → 0.24.21
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/README.md +4 -2
- package/bin/taskplane.mjs +581 -36
- package/extensions/task-runner.ts +18 -18
- package/extensions/taskplane/config-loader.ts +1221 -1161
- package/extensions/taskplane/config-schema.ts +20 -2
- package/extensions/taskplane/merge.ts +12 -1
- package/extensions/taskplane/settings-tui.ts +167 -16
- package/package.json +1 -1
- package/templates/config/task-runner.yaml +1 -1
|
@@ -507,7 +507,23 @@ export interface TaskplaneConfig {
|
|
|
507
507
|
* | mergeModel | orchestrator.merge.model | string |
|
|
508
508
|
* | supervisorModel | orchestrator.supervisor.model | string |
|
|
509
509
|
* | dashboardPort | (preferences-only; not yet in schema)| number |
|
|
510
|
+
* | initAgentDefaults | (preferences-only; used by init UX) | object |
|
|
510
511
|
*/
|
|
512
|
+
export interface InitAgentDefaultsPreferences {
|
|
513
|
+
/** Worker model default for `taskplane init` prompts (empty = inherit) */
|
|
514
|
+
workerModel?: string;
|
|
515
|
+
/** Reviewer model default for `taskplane init` prompts (empty = inherit) */
|
|
516
|
+
reviewerModel?: string;
|
|
517
|
+
/** Merger model default for `taskplane init` prompts (empty = inherit) */
|
|
518
|
+
mergeModel?: string;
|
|
519
|
+
/** Worker thinking default for `taskplane init` prompts (`""`/`on`/`off`) */
|
|
520
|
+
workerThinking?: string;
|
|
521
|
+
/** Reviewer thinking default for `taskplane init` prompts (`""`/`on`/`off`) */
|
|
522
|
+
reviewerThinking?: string;
|
|
523
|
+
/** Merger thinking default for `taskplane init` prompts (`""`/`on`/`off`) */
|
|
524
|
+
mergeThinking?: string;
|
|
525
|
+
}
|
|
526
|
+
|
|
511
527
|
export interface UserPreferences {
|
|
512
528
|
/** Operator identifier (overrides orchestrator.orchestrator.operatorId) */
|
|
513
529
|
operatorId?: string;
|
|
@@ -527,6 +543,8 @@ export interface UserPreferences {
|
|
|
527
543
|
supervisorModel?: string;
|
|
528
544
|
/** Dashboard port (preferences-only; not yet wired into config schema) */
|
|
529
545
|
dashboardPort?: number;
|
|
546
|
+
/** Saved defaults used to pre-populate `taskplane init` model/thinking prompts */
|
|
547
|
+
initAgentDefaults?: InitAgentDefaultsPreferences;
|
|
530
548
|
}
|
|
531
549
|
|
|
532
550
|
/** Default (empty) user preferences — all fields undefined means "no override". */
|
|
@@ -553,8 +571,8 @@ export const DEFAULT_TASK_RUNNER_SECTION: TaskRunnerSection = {
|
|
|
553
571
|
testing: { commands: {} },
|
|
554
572
|
standards: { docs: [], rules: [] },
|
|
555
573
|
standardsOverrides: {},
|
|
556
|
-
worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "
|
|
557
|
-
reviewer: { model: "
|
|
574
|
+
worker: { model: "", tools: "read,write,edit,bash,grep,find,ls", thinking: "" },
|
|
575
|
+
reviewer: { model: "", tools: "read,bash,grep,find,ls", thinking: "on" },
|
|
558
576
|
context: {
|
|
559
577
|
workerContextWindow: 0,
|
|
560
578
|
warnPercent: 85,
|
|
@@ -609,9 +609,20 @@ export async function spawnMergeAgentV2(
|
|
|
609
609
|
timeoutMs: (config.merge.timeout_minutes ?? 10) * 60 * 1000,
|
|
610
610
|
stateRoot: stateRoot ?? repoRoot,
|
|
611
611
|
packet: null,
|
|
612
|
-
env: {
|
|
612
|
+
env: {
|
|
613
|
+
ORCH_BATCH_ID: bid,
|
|
614
|
+
// Isolate merge agent from operator's user preferences to prevent
|
|
615
|
+
// verification test contamination (e.g., stale reviewerModel pref
|
|
616
|
+
// overriding schema defaults in deep-equal assertions).
|
|
617
|
+
PI_CODING_AGENT_DIR: join(sidecarRoot, "runtime", bid, "merge-agent-env"),
|
|
618
|
+
},
|
|
613
619
|
};
|
|
614
620
|
|
|
621
|
+
// Ensure isolated agent dir exists with empty preferences
|
|
622
|
+
const isolatedAgentDir = join(sidecarRoot, "runtime", bid, "merge-agent-env", "taskplane");
|
|
623
|
+
mkdirSync(isolatedAgentDir, { recursive: true });
|
|
624
|
+
try { writeFileSync(join(isolatedAgentDir, "preferences.json"), "{}\n", { flag: "wx" }); } catch { /* already exists */ }
|
|
625
|
+
|
|
615
626
|
const { promise, kill } = spawnAgent(opts);
|
|
616
627
|
|
|
617
628
|
// Store the kill handle for external cleanup (pause/abort).
|
|
@@ -49,7 +49,7 @@ export type FieldSource = "default" | "project" | "user";
|
|
|
49
49
|
export type FieldLayer = "L1" | "L2" | "L1+L2";
|
|
50
50
|
|
|
51
51
|
/** UI control type for a field */
|
|
52
|
-
export type FieldControl = "toggle" | "input";
|
|
52
|
+
export type FieldControl = "toggle" | "input" | "picker";
|
|
53
53
|
|
|
54
54
|
/** Field definition for the settings TUI */
|
|
55
55
|
export interface FieldDef {
|
|
@@ -102,7 +102,7 @@ export const SECTIONS: SectionDef[] = [
|
|
|
102
102
|
// The user-facing spawn mode setting is under Worker (controls /task behavior).
|
|
103
103
|
{ configPath: "orchestrator.orchestrator.sessionPrefix", label: "Session Prefix", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "sessionPrefix", description: "Prefix for orchestrator session names" },
|
|
104
104
|
{ configPath: "orchestrator.orchestrator.operatorId", label: "Operator ID", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "operatorId", description: "Operator identifier (empty = auto-detect)" },
|
|
105
|
-
{ configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "
|
|
105
|
+
{ configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "picker", layer: "L1", fieldType: "enum", values: ["manual", "supervised", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. supervised = supervisor proposes plan, asks confirmation. auto = supervisor executes without asking." },
|
|
106
106
|
],
|
|
107
107
|
},
|
|
108
108
|
{
|
|
@@ -129,7 +129,7 @@ export const SECTIONS: SectionDef[] = [
|
|
|
129
129
|
fields: [
|
|
130
130
|
{ configPath: "orchestrator.merge.model", label: "Merge Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "mergeModel", description: "Merge-agent model (inherit = use session model)" },
|
|
131
131
|
{ configPath: "orchestrator.merge.tools", label: "Merge Tools", control: "input", layer: "L1", fieldType: "string", description: "Merge-agent tool allowlist" },
|
|
132
|
-
{ configPath: "orchestrator.merge.thinking", label: "Merge Thinking", control: "
|
|
132
|
+
{ configPath: "orchestrator.merge.thinking", label: "Merge Thinking", control: "picker", layer: "L1+L2", fieldType: "string", prefsKey: "mergeThinking", description: "Merge-agent thinking mode" },
|
|
133
133
|
{ configPath: "orchestrator.merge.order", label: "Merge Order", control: "toggle", layer: "L1", fieldType: "enum", values: ["fewest-files-first", "sequential"], description: "Lane merge ordering policy" },
|
|
134
134
|
{ configPath: "orchestrator.merge.timeoutMinutes", label: "Merge Timeout (minutes)", control: "input", layer: "L1", fieldType: "number", description: "Max time for merge agent to complete. Increase for large batches (default: 10)" },
|
|
135
135
|
],
|
|
@@ -154,7 +154,7 @@ export const SECTIONS: SectionDef[] = [
|
|
|
154
154
|
name: "Supervisor",
|
|
155
155
|
fields: [
|
|
156
156
|
{ configPath: "orchestrator.supervisor.model", label: "Supervisor Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "supervisorModel", description: "Supervisor model (inherit = use session model)" },
|
|
157
|
-
{ configPath: "orchestrator.supervisor.autonomy", label: "Autonomy Level", control: "
|
|
157
|
+
{ configPath: "orchestrator.supervisor.autonomy", label: "Autonomy Level", control: "picker", layer: "L1", fieldType: "enum", values: ["interactive", "supervised", "autonomous"], description: "Recovery action confirmation behavior" },
|
|
158
158
|
],
|
|
159
159
|
},
|
|
160
160
|
{
|
|
@@ -162,7 +162,7 @@ export const SECTIONS: SectionDef[] = [
|
|
|
162
162
|
fields: [
|
|
163
163
|
{ configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (inherit = use session model)" },
|
|
164
164
|
{ configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
|
|
165
|
-
{ configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "
|
|
165
|
+
{ configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
|
|
166
166
|
{ configPath: "taskRunner.worker.spawnMode", label: "Spawn Mode", control: "toggle", layer: "L1", fieldType: "enum", values: ["subprocess"], optional: true, description: "How /task spawns workers and reviewers. Runtime V2 supports subprocess only." },
|
|
167
167
|
],
|
|
168
168
|
},
|
|
@@ -171,7 +171,7 @@ export const SECTIONS: SectionDef[] = [
|
|
|
171
171
|
fields: [
|
|
172
172
|
{ configPath: "taskRunner.reviewer.model", label: "Reviewer Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "reviewerModel", description: "Reviewer model (inherit = use session model)" },
|
|
173
173
|
{ configPath: "taskRunner.reviewer.tools", label: "Reviewer Tools", control: "input", layer: "L1", fieldType: "string", description: "Reviewer tool allowlist" },
|
|
174
|
-
{ configPath: "taskRunner.reviewer.thinking", label: "Reviewer Thinking", control: "
|
|
174
|
+
{ configPath: "taskRunner.reviewer.thinking", label: "Reviewer Thinking", control: "picker", layer: "L1", fieldType: "string", description: "Reviewer thinking mode" },
|
|
175
175
|
],
|
|
176
176
|
},
|
|
177
177
|
{
|
|
@@ -1003,6 +1003,131 @@ async function pickModel(ctx: ExtensionContext, currentModel: string): Promise<s
|
|
|
1003
1003
|
}
|
|
1004
1004
|
}
|
|
1005
1005
|
|
|
1006
|
+
type ThinkingModeValue = "" | "on" | "off";
|
|
1007
|
+
|
|
1008
|
+
const THINKING_MODE_OPTIONS: Array<{ value: ThinkingModeValue; label: string }> = [
|
|
1009
|
+
{ value: "", label: "inherit (use session thinking)" },
|
|
1010
|
+
{ value: "on", label: "on" },
|
|
1011
|
+
{ value: "off", label: "off" },
|
|
1012
|
+
];
|
|
1013
|
+
|
|
1014
|
+
function normalizeThinkingMode(value: unknown): ThinkingModeValue {
|
|
1015
|
+
const cleaned = String(value ?? "").trim().toLowerCase();
|
|
1016
|
+
if (cleaned === "on") return "on";
|
|
1017
|
+
if (cleaned === "off") return "off";
|
|
1018
|
+
return "";
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function pickThinkingMode(
|
|
1022
|
+
ctx: ExtensionContext,
|
|
1023
|
+
currentThinking: string,
|
|
1024
|
+
): Promise<ThinkingModeValue | undefined> {
|
|
1025
|
+
const current = normalizeThinkingMode(currentThinking);
|
|
1026
|
+
const optionToValue = new Map<string, ThinkingModeValue>();
|
|
1027
|
+
const optionLabels: string[] = [];
|
|
1028
|
+
|
|
1029
|
+
for (const option of THINKING_MODE_OPTIONS) {
|
|
1030
|
+
const label = `${option.label}${option.value === current ? " ✓ current" : ""}`;
|
|
1031
|
+
optionLabels.push(label);
|
|
1032
|
+
optionToValue.set(label, option.value);
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const selected = await selectScrollable(ctx, "Choose thinking mode", optionLabels, 8);
|
|
1036
|
+
if (!selected) return undefined;
|
|
1037
|
+
return optionToValue.get(selected);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const MODEL_THINKING_PATH_MAP: Record<string, { thinkingPath: string; label: string }> = {
|
|
1041
|
+
"taskRunner.worker.model": { thinkingPath: "taskRunner.worker.thinking", label: "Worker" },
|
|
1042
|
+
"taskRunner.reviewer.model": { thinkingPath: "taskRunner.reviewer.thinking", label: "Reviewer" },
|
|
1043
|
+
"orchestrator.merge.model": { thinkingPath: "orchestrator.merge.thinking", label: "Merge" },
|
|
1044
|
+
};
|
|
1045
|
+
|
|
1046
|
+
function resolveModelRecord(ctx: ExtensionContext, modelRef: string): any | undefined {
|
|
1047
|
+
const trimmed = modelRef.trim();
|
|
1048
|
+
if (!trimmed) return undefined;
|
|
1049
|
+
|
|
1050
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
1051
|
+
const lower = trimmed.toLowerCase();
|
|
1052
|
+
const slashIdx = trimmed.indexOf("/");
|
|
1053
|
+
|
|
1054
|
+
if (slashIdx > 0) {
|
|
1055
|
+
const provider = trimmed.slice(0, slashIdx).toLowerCase();
|
|
1056
|
+
const id = trimmed.slice(slashIdx + 1).toLowerCase();
|
|
1057
|
+
return available.find((m: any) =>
|
|
1058
|
+
String(m?.provider ?? "").toLowerCase() === provider
|
|
1059
|
+
&& String(m?.id ?? "").toLowerCase() === id,
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
return available.find((m: any) =>
|
|
1064
|
+
String(m?.id ?? "").toLowerCase() === lower
|
|
1065
|
+
|| `${String(m?.provider ?? "").toLowerCase()}/${String(m?.id ?? "").toLowerCase()}` === lower,
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
export function modelSupportsThinking(model: any): boolean {
|
|
1070
|
+
if (!model || typeof model !== "object") return false;
|
|
1071
|
+
|
|
1072
|
+
const boolFlags = [
|
|
1073
|
+
"supportsThinking",
|
|
1074
|
+
"thinking",
|
|
1075
|
+
"supportsReasoning",
|
|
1076
|
+
"supportsReasoningEffort",
|
|
1077
|
+
"supportsReasoningTokens",
|
|
1078
|
+
"reasoning",
|
|
1079
|
+
];
|
|
1080
|
+
const capabilityKeys = [
|
|
1081
|
+
"reasoningEffort",
|
|
1082
|
+
"reasoningTokens",
|
|
1083
|
+
"thinkingModes",
|
|
1084
|
+
"thinkingMode",
|
|
1085
|
+
"reasoning_effort",
|
|
1086
|
+
"reasoning_tokens",
|
|
1087
|
+
];
|
|
1088
|
+
|
|
1089
|
+
const candidateObjects = [
|
|
1090
|
+
model,
|
|
1091
|
+
model.capabilities,
|
|
1092
|
+
model.features,
|
|
1093
|
+
model.metadata,
|
|
1094
|
+
].filter((entry) => entry && typeof entry === "object");
|
|
1095
|
+
|
|
1096
|
+
for (const candidate of candidateObjects) {
|
|
1097
|
+
for (const key of boolFlags) {
|
|
1098
|
+
if (typeof candidate[key] === "boolean" && candidate[key]) return true;
|
|
1099
|
+
}
|
|
1100
|
+
for (const key of capabilityKeys) {
|
|
1101
|
+
if (candidate[key] !== undefined && candidate[key] !== null) return true;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
export function buildThinkingSuggestionForModelChange(
|
|
1109
|
+
ctx: ExtensionContext,
|
|
1110
|
+
field: FieldDef,
|
|
1111
|
+
previousModelValue: string,
|
|
1112
|
+
nextModelValue: string,
|
|
1113
|
+
mergedConfig: TaskplaneConfig,
|
|
1114
|
+
): string | null {
|
|
1115
|
+
const mapping = MODEL_THINKING_PATH_MAP[field.configPath];
|
|
1116
|
+
if (!mapping) return null;
|
|
1117
|
+
|
|
1118
|
+
const previousNormalized = previousModelValue.trim().toLowerCase();
|
|
1119
|
+
const nextNormalized = nextModelValue.trim().toLowerCase();
|
|
1120
|
+
if (!nextNormalized || previousNormalized === nextNormalized) return null;
|
|
1121
|
+
|
|
1122
|
+
const modelRecord = resolveModelRecord(ctx, nextModelValue);
|
|
1123
|
+
if (!modelRecord || !modelSupportsThinking(modelRecord)) return null;
|
|
1124
|
+
|
|
1125
|
+
const currentThinking = normalizeThinkingMode(getNestedValue(mergedConfig, mapping.thinkingPath));
|
|
1126
|
+
if (currentThinking === "on") return null;
|
|
1127
|
+
|
|
1128
|
+
return `${mapping.label} model supports thinking. Consider setting ${mapping.label} Thinking to \"on\".`;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1006
1131
|
/**
|
|
1007
1132
|
* Scrollable select list for model/provider picking.
|
|
1008
1133
|
* Uses pi's TUI custom widget API.
|
|
@@ -1247,20 +1372,34 @@ async function showSectionSettingsLoop(
|
|
|
1247
1372
|
const field = section.fields.find((f) => f.configPath === result.fieldId);
|
|
1248
1373
|
if (!field) continue; // Safety: field not found
|
|
1249
1374
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1375
|
+
let previousModelValue = "";
|
|
1376
|
+
|
|
1377
|
+
// Input/picker fields: the submenu returned a sentinel — open the editor picker.
|
|
1378
|
+
if (result.rawValue === "__EDIT_REQUESTED__" && (field.control === "input" || field.control === "picker")) {
|
|
1379
|
+
const state = loadConfigState(configRoot, pointerConfigRoot);
|
|
1380
|
+
const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
|
|
1381
|
+
const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
|
|
1382
|
+
const normalizedCurrent = currentClean === "(inherit)" ? "" : currentClean;
|
|
1383
|
+
|
|
1252
1384
|
// Model fields: use interactive provider → model picker instead of free-text
|
|
1253
1385
|
if (field.configPath.endsWith(".model")) {
|
|
1254
|
-
|
|
1255
|
-
const
|
|
1256
|
-
|
|
1257
|
-
|
|
1386
|
+
previousModelValue = normalizedCurrent;
|
|
1387
|
+
const selected = await pickModel(ctx, normalizedCurrent);
|
|
1388
|
+
if (selected === undefined) continue; // Cancelled
|
|
1389
|
+
result.rawValue = selected;
|
|
1390
|
+
} else if (field.control === "picker" && field.configPath.endsWith(".thinking")) {
|
|
1391
|
+
const selected = await pickThinkingMode(ctx, normalizedCurrent);
|
|
1258
1392
|
if (selected === undefined) continue; // Cancelled
|
|
1259
1393
|
result.rawValue = selected;
|
|
1394
|
+
} else if (field.control === "picker" && field.values && field.values.length > 0) {
|
|
1395
|
+
// Enum picker: show scrollable list of allowed values
|
|
1396
|
+
const options = field.values.map((v) =>
|
|
1397
|
+
`${v}${v === normalizedCurrent ? " ✓ current" : ""}`
|
|
1398
|
+
);
|
|
1399
|
+
const selected = await selectScrollable(ctx, field.label, options);
|
|
1400
|
+
if (!selected) continue; // Cancelled
|
|
1401
|
+
result.rawValue = selected.replace(/\s+✓ current$/, "");
|
|
1260
1402
|
} else {
|
|
1261
|
-
const state = loadConfigState(configRoot, pointerConfigRoot);
|
|
1262
|
-
const currentDisplay = getFieldDisplayValue(field, state.mergedConfig, state.prefs);
|
|
1263
|
-
const currentClean = String(currentDisplay).replace(/\s+\((?:default|project|user)\)$/, "");
|
|
1264
1403
|
const placeholder = currentClean === "(not set)" || currentClean === "(inherit)" ? "" : currentClean;
|
|
1265
1404
|
|
|
1266
1405
|
const newValue = await ctx.ui.input(
|
|
@@ -1327,6 +1466,18 @@ async function showSectionSettingsLoop(
|
|
|
1327
1466
|
`ℹ Restart session to apply changes.`,
|
|
1328
1467
|
"info",
|
|
1329
1468
|
);
|
|
1469
|
+
|
|
1470
|
+
const refreshedState = loadConfigState(configRoot, pointerConfigRoot);
|
|
1471
|
+
const suggestion = buildThinkingSuggestionForModelChange(
|
|
1472
|
+
ctx,
|
|
1473
|
+
field,
|
|
1474
|
+
previousModelValue,
|
|
1475
|
+
String(result.rawValue ?? ""),
|
|
1476
|
+
refreshedState.mergedConfig,
|
|
1477
|
+
);
|
|
1478
|
+
if (suggestion) {
|
|
1479
|
+
ctx.ui.notify(`💡 ${suggestion}`, "info");
|
|
1480
|
+
}
|
|
1330
1481
|
} catch (err: any) {
|
|
1331
1482
|
ctx.ui.notify(`❌ Failed to save: ${err.message}`, "error");
|
|
1332
1483
|
}
|
|
@@ -1375,7 +1526,7 @@ async function showSectionSettingsOnce(
|
|
|
1375
1526
|
// The inline submenu approach freezes on Windows/tmux (issue #57).
|
|
1376
1527
|
// We set a single sentinel value so pressing Enter/Space triggers onChange,
|
|
1377
1528
|
// which exits the TUI. The caller then uses ctx.ui.input() for actual editing.
|
|
1378
|
-
if (field.control === "input") {
|
|
1529
|
+
if (field.control === "input" || field.control === "picker") {
|
|
1379
1530
|
item.values = [`__EDIT_REQUESTED__`];
|
|
1380
1531
|
}
|
|
1381
1532
|
|
package/package.json
CHANGED
|
@@ -47,7 +47,7 @@ standards_overrides: {}
|
|
|
47
47
|
worker:
|
|
48
48
|
model: "" # empty = inherit from parent pi session
|
|
49
49
|
tools: "read,write,edit,bash,grep,find,ls"
|
|
50
|
-
thinking: "
|
|
50
|
+
thinking: "" # empty = inherit from parent pi session
|
|
51
51
|
# spawn_mode: "subprocess" # currently supported runtime mode
|
|
52
52
|
|
|
53
53
|
reviewer:
|