pi-squad 0.16.4 → 0.16.5

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 CHANGED
@@ -52,8 +52,10 @@ Squad agents—including QA/reviewer agents—produce candidate work and evidenc
52
52
  - Persisted status becomes `review`, never directly `done`.
53
53
  - A persistent `<squad_review_required>` system reminder tells main Pi to re-read the original conversation contract, inspect the actual diff/source, rerun verification independently, and run integration/E2E where applicable.
54
54
  - Main Pi must call `squad_review` with requirement-by-requirement contract checks, diff review, actual command/result evidence, integration/E2E evidence, and issues.
55
- - Only `pass` or `pass_with_issues` changes the squad to `done`; `fail` leaves it review-blocked for fixes and re-review.
56
- - Pending review survives Pi restarts and is restored on the next session.
55
+ - Only `pass` or `pass_with_issues` changes the squad to `done`; `fail` leaves it review-blocked and cannot be overwritten by another verdict.
56
+ - Failed review is reworked in the **same authoritative squad**: use `squad_modify` with that `squadId` and `add_task` or `resume_task` (or `resume` when interrupted work exists). These operations reconstruct the scheduler after restart. `/squad resume <squad-id>` provides the same resume path.
57
+ - When rework begins, the failed attempt moves to `reviewHistory`, the squad returns to `running`, and its evidence remains auditable. After every rework task settles, a fresh pending review becomes the active gate and `squad_review` is required again.
58
+ - Pending and failed review gates survive Pi restarts and are restored on the next session. A separate squad never links to, remediates, or accepts the failed gate.
57
59
 
58
60
  The completion report is explicitly labeled **untrusted and not yet accepted**. Main Pi must never merely relay it or ask whether verification should be run.
59
61
 
@@ -188,6 +190,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
188
190
  | Command | Description |
189
191
  |---|---|
190
192
  | `/squad select` | Pick a squad to view |
193
+ | `/squad resume [squad-id]` | Reconstruct and resume an exact paused/failed/failed-review squad |
191
194
  | `/squad list` | List project squads |
192
195
  | `/squad all` | List all squads |
193
196
  | `/squad agents` | Manage agent definitions |
@@ -206,7 +209,7 @@ Full overlay with task list, live activity preview, and scrollable message view.
206
209
  | `squad` | Start a squad with goal + optional tasks/config |
207
210
  | `squad_status` | Check progress, costs, task states |
208
211
  | `squad_message` | Durably message an exact task; completed tasks reopen on their original session |
209
- | `squad_modify` | Add/cancel/complete/pause/resume tasks or squads |
212
+ | `squad_modify` | Add/cancel/complete/pause/resume tasks or squads; accepts `squadId` for exact same-squad failed-review rework |
210
213
 
211
214
  The main agent sees available agents in its system prompt and squad state when a squad is active.
212
215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squad",
3
- "version": "0.16.4",
3
+ "version": "0.16.5",
4
4
  "description": "Multi-agent collaboration extension for pi — task decomposition, dependency management, parallel execution, TUI panel",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -176,6 +176,38 @@ function getActiveScheduler(): Scheduler | null {
176
176
  return schedulers.get(activeSquadId) || null;
177
177
  }
178
178
 
179
+ /** Reconstruct and focus one exact persisted squad without creating/linking another. */
180
+ function ensureScheduler(pi: ExtensionAPI, squadId: string, skillPaths: string[]): Scheduler {
181
+ let scheduler = schedulers.get(squadId);
182
+ if (!scheduler) {
183
+ scheduler = new Scheduler(squadId, skillPaths, schedulerSpawnContext);
184
+ schedulers.set(squadId, scheduler);
185
+ wireSchedulerEvents(pi, scheduler, squadId);
186
+ }
187
+ activeSquadId = squadId;
188
+ widgetState.squadId = squadId;
189
+ widgetState.enabled = true;
190
+ widgetControls?.requestUpdate();
191
+ return scheduler;
192
+ }
193
+
194
+ function isResumeCandidate(squad: Squad): boolean {
195
+ return squad.status === "paused" || squad.status === "failed" ||
196
+ (squad.status === "review" && squad.review?.status === "failed") ||
197
+ store.loadAllTasks(squad.id).some((task) => task.status === "suspended" || task.status === "failed");
198
+ }
199
+
200
+ function resolveResumeSquad(cwd: string, explicitId?: string): Squad | null {
201
+ if (explicitId) return store.loadSquad(explicitId);
202
+ if (activeSquadId) {
203
+ const active = store.loadSquad(activeSquadId);
204
+ if (active?.cwd === cwd && isResumeCandidate(active)) return active;
205
+ }
206
+ return store.listSquadsForProject(cwd)
207
+ .filter(isResumeCandidate)
208
+ .sort((a, b) => b.created.localeCompare(a.created))[0] ?? null;
209
+ }
210
+
179
211
 
180
212
  // ============================================================================
181
213
  // Extension Entry
@@ -560,8 +592,9 @@ export default function (pi: ExtensionAPI) {
560
592
  pi.registerTool({
561
593
  name: "squad_modify",
562
594
  label: "Squad Modify",
563
- description: "Modify the running squad: add_task, cancel_task, complete_task (mark done + schedule dependents), pause, resume (also recovers failed squads), cancel (entire squad).",
595
+ description: "Modify one exact squad: add_task, cancel_task, complete_task (mark done + schedule dependents), pause, resume_task, resume (including failed-review rework), cancel. add_task/resume_task/resume reconstruct the persisted scheduler after restart.",
564
596
  parameters: Type.Object({
597
+ squadId: Type.Optional(Type.String({ description: "Exact squad to modify (recommended for failed-review rework; default: focused/recoverable project squad)" })),
565
598
  action: Type.Union(
566
599
  [
567
600
  Type.Literal("add_task"),
@@ -590,45 +623,37 @@ export default function (pi: ExtensionAPI) {
590
623
  }),
591
624
 
592
625
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
593
- // Resume can work without an active scheduler — it recreates one from disk
594
626
  if (params.action === "resume") {
595
- // Find a squad to resume: use activeSquadId or find the latest paused one
596
- const squadId = activeSquadId || store.findActiveSquads()
597
- .filter((s) => s.cwd === ctx.cwd && (s.status === "paused" || s.status === "failed"))
598
- .sort((a, b) => b.created.localeCompare(a.created))[0]?.id;
599
-
600
- if (!squadId) {
601
- return { content: [{ type: "text" as const, text: "No paused or failed squad found to resume." }], details: undefined };
627
+ const squad = resolveResumeSquad(ctx.cwd, params.squadId);
628
+ if (!squad) {
629
+ const text = params.squadId
630
+ ? `Squad '${params.squadId}' not found.`
631
+ : "No paused, failed, or failed-review squad found to resume.";
632
+ return { content: [{ type: "text" as const, text }], details: undefined };
602
633
  }
603
-
604
- // Create a fresh scheduler if needed
605
- if (!schedulers.has(squadId)) {
606
- const scheduler = new Scheduler(squadId, squadSkillPaths, schedulerSpawnContext);
607
- schedulers.set(squadId, scheduler);
608
- activeSquadId = squadId;
609
-
610
- // Activate widget
611
- widgetState.squadId = squadId;
612
- widgetState.enabled = true;
613
- widgetControls?.requestUpdate();
614
-
615
- wireSchedulerEvents(pi, scheduler, squadId);
634
+ const resumeSched = ensureScheduler(pi, squad.id, squadSkillPaths);
635
+ try {
636
+ await resumeSched.resume();
637
+ } catch (err) {
638
+ return { content: [{ type: "text" as const, text: `Resume failed: ${(err as Error).message}` }], details: undefined };
616
639
  }
617
-
618
- const resumeSched = schedulers.get(squadId)!;
619
- resumeSched.resume().catch((err) => {
620
- logError("squad", `Resume error: ${(err as Error).message}`);
621
- });
622
-
623
- const tasks = store.loadAllTasks(squadId);
624
- const done = tasks.filter(t => t.status === "done").length;
625
- return { content: [{ type: "text" as const, text: `Squad "${squadId}" resumed (${done}/${tasks.length} done). Agents restarting in background.` }], details: undefined };
640
+ const tasks = store.loadAllTasks(squad.id);
641
+ const done = tasks.filter((task) => task.status === "done").length;
642
+ return { content: [{ type: "text" as const, text: `Squad "${squad.id}" resumed (${done}/${tasks.length} done). Agents restarting in background.` }], details: undefined };
626
643
  }
627
644
 
628
- const activeScheduler = getActiveScheduler();
629
- if (!activeScheduler || !activeSquadId) {
630
- return { content: [{ type: "text" as const, text: "No active squad. Use squad_modify with action 'resume' to resume a paused squad, or start a new one with the squad tool." }], details: undefined };
645
+ const squadId = params.squadId || activeSquadId;
646
+ if (!squadId || !store.loadSquad(squadId)) {
647
+ return { content: [{ type: "text" as const, text: params.squadId ? `Squad '${params.squadId}' not found.` : "No active squad. Provide squadId, select the squad, or start a new one." }], details: undefined };
648
+ }
649
+ let activeScheduler = schedulers.get(squadId) || null;
650
+ if (!activeScheduler && (params.action === "add_task" || params.action === "resume_task")) {
651
+ activeScheduler = ensureScheduler(pi, squadId, squadSkillPaths);
652
+ }
653
+ if (!activeScheduler) {
654
+ return { content: [{ type: "text" as const, text: `Squad '${squadId}' has no active scheduler. Use resume, add_task, or resume_task to reconstruct it.` }], details: undefined };
631
655
  }
656
+ activeSquadId = squadId;
632
657
 
633
658
  switch (params.action) {
634
659
  case "add_task": {
@@ -645,17 +670,19 @@ export default function (pi: ExtensionAPI) {
645
670
  if (badDeps.length > 0) {
646
671
  return { content: [{ type: "text" as const, text: `Unknown dependency task(s): ${badDeps.join(", ")}. Existing tasks: ${[...existingIds].join(", ")}` }], details: undefined };
647
672
  }
648
- if (!store.loadAgentDef(params.task.agent, ctx.cwd)) {
649
- const available = store.loadAllAgentDefs(ctx.cwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
673
+ const targetCwd = store.loadSquad(squadId)!.cwd;
674
+ if (!store.loadAgentDef(params.task.agent, targetCwd)) {
675
+ const available = store.loadAllAgentDefs(targetCwd).filter((a) => !a.disabled).map((a) => a.name).join(", ");
650
676
  return { content: [{ type: "text" as const, text: `Unknown agent '${params.task.agent}'. Available: ${available}` }], details: undefined };
651
677
  }
678
+ const dependencies = params.task.depends || [];
652
679
  const task: Task = {
653
680
  id: params.task.id,
654
681
  title: params.task.title,
655
682
  description: params.task.description || "",
656
683
  agent: params.task.agent,
657
- status: "pending",
658
- depends: params.task.depends || [],
684
+ status: dependencies.every((dependency) => existing.find((candidate) => candidate.id === dependency)?.status === "done") ? "pending" : "blocked",
685
+ depends: dependencies,
659
686
  ...(params.task.inheritContext ? { inheritContext: true } : {}),
660
687
  created: store.now(),
661
688
  started: null,
@@ -664,9 +691,12 @@ export default function (pi: ExtensionAPI) {
664
691
  error: null,
665
692
  usage: { inputTokens: 0, outputTokens: 0, cost: 0, turns: 0 },
666
693
  };
667
- store.createTask(activeSquadId, task);
668
- activeScheduler.updateContext();
669
- return { content: [{ type: "text" as const, text: `Task '${task.id}' added.` }], details: undefined };
694
+ try {
695
+ await activeScheduler.addTask(task);
696
+ } catch (err) {
697
+ return { content: [{ type: "text" as const, text: `add_task failed: ${(err as Error).message}` }], details: undefined };
698
+ }
699
+ return { content: [{ type: "text" as const, text: `Task '${task.id}' added to squad '${squadId}'.` }], details: undefined };
670
700
  }
671
701
 
672
702
  case "cancel_task": {
@@ -683,10 +713,12 @@ export default function (pi: ExtensionAPI) {
683
713
 
684
714
  case "resume_task": {
685
715
  if (!params.taskId) return { content: [{ type: "text" as const, text: "Provide taskId." }], details: undefined };
686
- activeScheduler.resumeTask(params.taskId).catch((err) => {
687
- logError("squad", `Resume task error: ${(err as Error).message}`);
688
- });
689
- return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed.` }], details: undefined };
716
+ try {
717
+ await activeScheduler.resumeTask(params.taskId);
718
+ } catch (err) {
719
+ return { content: [{ type: "text" as const, text: `resume_task failed: ${(err as Error).message}` }], details: undefined };
720
+ }
721
+ return { content: [{ type: "text" as const, text: `Task '${params.taskId}' resumed in squad '${squadId}'.` }], details: undefined };
690
722
  }
691
723
 
692
724
  case "complete_task": {
@@ -866,12 +898,13 @@ export default function (pi: ExtensionAPI) {
866
898
  // =========================================================================
867
899
 
868
900
  pi.registerCommand("squad", {
869
- description: "Browse, select, and manage squads. Usage: /squad [list|all|select|agents|msg|widget|panel|cancel|clear]",
901
+ description: "Browse, select, and manage squads. Usage: /squad [list|all|select|resume|agents|msg|widget|panel|cancel|clear]",
870
902
  getArgumentCompletions: (prefix) => {
871
903
  const subs = [
872
904
  { value: "list", label: "list", description: "List squads for current project" },
873
905
  { value: "all", label: "all", description: "List all squads, select to activate" },
874
906
  { value: "select", label: "select", description: "Pick a squad to view (interactive)" },
907
+ { value: "resume", label: "resume", description: "Resume an exact paused/failed/failed-review squad" },
875
908
  { value: "agents", label: "agents", description: "List, view, or edit agent definitions" },
876
909
  { value: "defaults", label: "defaults", description: "Default model/thinking for agents (follow main session, pi default, or fixed)" },
877
910
  { value: "advisor", label: "advisor", description: "Advisor-first rescue for stuck agents (on/off, model, limits)" },
@@ -941,6 +974,25 @@ export default function (pi: ExtensionAPI) {
941
974
  return;
942
975
  }
943
976
 
977
+ case "resume": {
978
+ const squad = resolveResumeSquad(ctx.cwd, parts[1]);
979
+ if (!squad) {
980
+ ctx.ui.notify(parts[1] ? `Squad '${parts[1]}' not found` : "No paused, failed, or failed-review squad found", "warning");
981
+ return;
982
+ }
983
+ const scheduler = ensureScheduler(pi, squad.id, squadSkillPaths);
984
+ try {
985
+ await scheduler.resume();
986
+ } catch (error) {
987
+ ctx.ui.notify(`Resume failed: ${(error as Error).message}`, "error");
988
+ return;
989
+ }
990
+ const tasks = store.loadAllTasks(squad.id);
991
+ const done = tasks.filter((task) => task.status === "done").length;
992
+ ctx.ui.notify(`Resumed: ${squad.id} (${done}/${tasks.length} done)`, "info");
993
+ return;
994
+ }
995
+
944
996
  case "widget": {
945
997
  widgetState.enabled = !widgetState.enabled;
946
998
  if (widgetState.enabled) {
@@ -1398,7 +1450,7 @@ export default function (pi: ExtensionAPI) {
1398
1450
  activateSquadView(direct.id, ctx);
1399
1451
  return;
1400
1452
  }
1401
- ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, agents, defaults, msg, widget, panel, cancel, clear, cleanup`, "warning");
1453
+ ctx.ui.notify(`Unknown: /squad ${sub}. Try: list, all, select, resume, agents, defaults, msg, widget, panel, cancel, clear, cleanup`, "warning");
1402
1454
  }
1403
1455
  },
1404
1456
  });
package/src/review.ts CHANGED
@@ -11,6 +11,18 @@ export interface OrchestratorReviewInput {
11
11
  issues: string[];
12
12
  }
13
13
 
14
+ /**
15
+ * Start same-squad rework without discarding completed review evidence.
16
+ * The next all-tasks-done transition creates a new active pending review.
17
+ */
18
+ export function beginOrchestratorRework(squad: Squad): void {
19
+ if (squad.review && squad.review.status !== "pending") {
20
+ squad.reviewHistory = [...(squad.reviewHistory ?? []), { ...squad.review }];
21
+ }
22
+ delete squad.review;
23
+ squad.status = "running";
24
+ }
25
+
14
26
  /** Move a squad from agent execution into mandatory independent main-session review. */
15
27
  export function beginOrchestratorReview(squad: Squad): void {
16
28
  squad.status = "review";
@@ -35,6 +47,9 @@ export function recordOrchestratorReview(squad: Squad, input: OrchestratorReview
35
47
  if (squad.status !== "review" || !squad.review) {
36
48
  throw new Error(`Squad '${squad.id}' is not awaiting orchestrator review`);
37
49
  }
50
+ if (squad.review.status !== "pending") {
51
+ throw new Error(`Squad '${squad.id}' review attempt is already ${squad.review.status}; begin same-squad rework before submitting a fresh review`);
52
+ }
38
53
 
39
54
  const contractChecks = cleanList(input.contractChecks);
40
55
  const verificationEvidence = cleanList(input.verificationEvidence);
package/src/scheduler.ts CHANGED
@@ -19,7 +19,7 @@ import * as store from "./store.js";
19
19
  import { debug, logError } from "./logger.js";
20
20
  import { buildAgentSystemPrompt } from "./protocol.js";
21
21
  import { buildAdvisorConsultText, formatAdvisorSteerMessage, adviceNeedsHuman, type AdvisorConsultInput } from "./advisor.js";
22
- import { beginOrchestratorReview } from "./review.js";
22
+ import { beginOrchestratorReview, beginOrchestratorRework } from "./review.js";
23
23
 
24
24
  // ============================================================================
25
25
  // Types
@@ -170,6 +170,10 @@ export class Scheduler {
170
170
 
171
171
  /** Start the scheduler — begins scheduling ready tasks */
172
172
  async start(): Promise<void> {
173
+ if (this.running) {
174
+ await this.reconcile();
175
+ return;
176
+ }
173
177
  this.running = true;
174
178
  this.monitor.start();
175
179
  await this.reconcile();
@@ -200,13 +204,15 @@ export class Scheduler {
200
204
  await this.pool.killAll();
201
205
  }
202
206
 
203
- /** Resume from suspended OR failed state. Failure is never terminal:
204
- * failed tasks are reset to pending and a failed squad becomes running. */
207
+ /** Resume suspended/failed work. A failed review is archived only when
208
+ * resumable work actually exists; a bare resume cannot bypass the gate. */
205
209
  async resume(): Promise<void> {
206
210
  const tasks = store.loadAllTasks(this.squadId);
211
+ let resumedWork = false;
207
212
  for (const task of tasks) {
208
213
  if (task.status === "suspended") {
209
214
  store.updateTaskStatus(this.squadId, task.id, "pending");
215
+ resumedWork = true;
210
216
  } else if (task.status === "failed") {
211
217
  store.updateTaskStatus(this.squadId, task.id, "pending", { error: null });
212
218
  store.appendMessage(this.squadId, task.id, {
@@ -215,13 +221,19 @@ export class Scheduler {
215
221
  type: "status",
216
222
  text: "Reset failed → pending on squad resume",
217
223
  });
224
+ resumedWork = true;
218
225
  }
219
226
  }
220
227
 
221
228
  const squad = store.loadSquad(this.squadId);
222
- if (squad && (squad.status === "paused" || squad.status === "failed")) {
223
- squad.status = "running";
224
- store.saveSquad(squad);
229
+ if (squad) {
230
+ if (resumedWork && squad.status === "review" && squad.review?.status === "failed") {
231
+ beginOrchestratorRework(squad);
232
+ store.saveSquad(squad);
233
+ } else if (squad.status === "paused" || squad.status === "failed") {
234
+ squad.status = "running";
235
+ store.saveSquad(squad);
236
+ }
225
237
  }
226
238
 
227
239
  await this.start();
@@ -262,8 +274,7 @@ export class Scheduler {
262
274
  recoveredMailbox = true;
263
275
  }
264
276
  if (recoveredMailbox && squad.status !== "running") {
265
- squad.status = "running";
266
- delete squad.review;
277
+ beginOrchestratorRework(squad);
267
278
  store.saveSquad(squad);
268
279
  }
269
280
 
@@ -1301,6 +1312,17 @@ export class Scheduler {
1301
1312
  await Promise.all(kills);
1302
1313
  }
1303
1314
 
1315
+ private reopenSquadForWork(): void {
1316
+ const squad = store.loadSquad(this.squadId);
1317
+ if (!squad) throw new Error(`Squad not found: ${this.squadId}`);
1318
+ if (squad.status === "review" || squad.status === "done" || squad.review) {
1319
+ beginOrchestratorRework(squad);
1320
+ } else if (squad.status !== "running") {
1321
+ squad.status = "running";
1322
+ }
1323
+ store.saveSquad(squad);
1324
+ }
1325
+
1304
1326
  private async reopenTaskForMessage(task: Task): Promise<void> {
1305
1327
  if (task.status === "done") await this.invalidateDescendants(task.id);
1306
1328
  const tasks = store.loadAllTasks(this.squadId);
@@ -1313,14 +1335,7 @@ export class Scheduler {
1313
1335
  completed: null,
1314
1336
  });
1315
1337
 
1316
- const squad = store.loadSquad(this.squadId);
1317
- if (squad && squad.status !== "running") {
1318
- squad.status = "running";
1319
- // Any prior acceptance/review applies to the pre-resume result. The
1320
- // normal all-done path will require a fresh independent review.
1321
- delete squad.review;
1322
- store.saveSquad(squad);
1323
- }
1338
+ this.reopenSquadForWork();
1324
1339
  }
1325
1340
 
1326
1341
  /** Send a main-orchestrator request to one exact task and await its next reply. */
@@ -1381,10 +1396,32 @@ export class Scheduler {
1381
1396
  this.updateContext();
1382
1397
  }
1383
1398
 
1384
- /** Resume a suspended/failed task. Reconciles so a failed squad heals too. */
1399
+ /** Add work to this exact squad and schedule it, reconstructing safely after restart. */
1400
+ async addTask(task: Task): Promise<void> {
1401
+ if (store.loadTask(this.squadId, task.id)) throw new Error(`Task already exists: ${task.id}`);
1402
+ store.createTask(this.squadId, task);
1403
+ this.reopenSquadForWork();
1404
+ await this.start();
1405
+ this.updateContext();
1406
+ }
1407
+
1408
+ /** Resume one exact task. Reopening completed work invalidates descendants and
1409
+ * archives a completed active review before fresh scheduling begins. */
1385
1410
  async resumeTask(taskId: string): Promise<void> {
1386
- store.updateTaskStatus(this.squadId, taskId, "pending", { error: null });
1387
- await this.reconcile();
1411
+ const task = store.loadTask(this.squadId, taskId);
1412
+ if (!task) throw new Error(`Task not found: ${taskId}`);
1413
+ if (task.status === "done") await this.invalidateDescendants(taskId);
1414
+ const tasks = store.loadAllTasks(this.squadId);
1415
+ const dependenciesDone = task.depends.every(
1416
+ (depId) => tasks.find((candidate) => candidate.id === depId)?.status === "done",
1417
+ );
1418
+ store.updateTaskStatus(this.squadId, taskId, dependenciesDone ? "pending" : "blocked", {
1419
+ error: null,
1420
+ completed: null,
1421
+ });
1422
+ this.reopenSquadForWork();
1423
+ await this.start();
1424
+ this.updateContext();
1388
1425
  }
1389
1426
 
1390
1427
  /**
package/src/types.ts CHANGED
@@ -112,8 +112,10 @@ export interface Squad {
112
112
  /** Agent name → overrides. Keys must exist in .pi/squad/agents/ */
113
113
  agents: Record<string, SquadAgentEntry>;
114
114
  config: SquadConfig;
115
- /** Mandatory independent main-session review; absent on legacy/running squads. */
115
+ /** Mandatory independent main-session review; absent while rework is running. */
116
116
  review?: SquadReview;
117
+ /** Completed prior review attempts retained as same-squad audit evidence. */
118
+ reviewHistory?: SquadReview[];
117
119
  }
118
120
 
119
121
  // ============================================================================