@harborppm/cli 0.59.0 → 0.61.0
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 +8 -1
- package/dist/app.mjs +3009 -2497
- package/lib/cli.mjs +134 -13
- package/lib/format.mjs +76 -3
- package/lib/mcp.mjs +9 -2
- package/package.json +1 -1
package/lib/cli.mjs
CHANGED
|
@@ -39,9 +39,12 @@ import {
|
|
|
39
39
|
lifeboatsText,
|
|
40
40
|
followedText,
|
|
41
41
|
soundingsText,
|
|
42
|
+
thanksText,
|
|
42
43
|
ashoreText,
|
|
43
44
|
voyageText,
|
|
44
45
|
whyText,
|
|
46
|
+
bridgeText,
|
|
47
|
+
barnaclesText,
|
|
45
48
|
handoverText,
|
|
46
49
|
fogText,
|
|
47
50
|
agentWorkText,
|
|
@@ -97,6 +100,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
97
100
|
changes without --apply
|
|
98
101
|
harbor why PROJECT|TASK|MILESTONE what's holding it up: the line of
|
|
99
102
|
task waits that sets its date
|
|
103
|
+
harbor barnacles PROJECT work added since the plan was set:
|
|
104
|
+
by week, by who, and the days of
|
|
105
|
+
slip it explains
|
|
100
106
|
harbor hill [--program P] where each project is on its hill
|
|
101
107
|
harbor hill PROJECT --at 0-100|top|done move it: up to 50 is figuring it
|
|
102
108
|
out, past 50 is getting it done
|
|
@@ -184,10 +190,17 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
184
190
|
the review: programs worst first,
|
|
185
191
|
each with its next move (reads
|
|
186
192
|
only; harbor submit moves a task)
|
|
193
|
+
harbor thanks [--all] [--given] thanks you were given this month
|
|
194
|
+
harbor thanks TASK "LINE" [--to NAME] a one-line thanks for a task
|
|
195
|
+
harbor thanks --milestone NAME|--decision TITLE "LINE" [--to NAME]
|
|
196
|
+
the same for a milestone or a
|
|
197
|
+
decision
|
|
187
198
|
harbor bridge [--harbor H] run the review as a meeting: one
|
|
188
199
|
project a screen, what changed
|
|
189
200
|
since the last one, decisions,
|
|
190
201
|
actions and risks logged live
|
|
202
|
+
harbor bridge --list [--program P] what changed since the last
|
|
203
|
+
review, as text (or --json)
|
|
191
204
|
harbor next-move PROGRAM "TEXT" write a program's next move
|
|
192
205
|
harbor health --program P|--project P|--portfolio P STATE [--note WHY]
|
|
193
206
|
set its health by hand, or auto
|
|
@@ -235,6 +248,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
235
248
|
harbor fog keep TASK it's still needed: out of the fog
|
|
236
249
|
harbor watch [--project P] [--bell] a live ticker of the harbor's
|
|
237
250
|
changes; yours bright, q stops
|
|
251
|
+
harbor watch --once [--since TIME] [--json] the latest changes (or all since
|
|
252
|
+
TIME), printed once, for scripts
|
|
238
253
|
harbor lifeboats [--program P] work only one person knows, each
|
|
239
254
|
with a second pair of hands
|
|
240
255
|
harbor lifeboats N [--person NAME] adds that second pair of hands
|
|
@@ -813,10 +828,18 @@ const DETAIL = {
|
|
|
813
828
|
" cards moving, comments, health changes",
|
|
814
829
|
" --project P one project's changes only",
|
|
815
830
|
" --bell ring the terminal's bell when something of yours changes",
|
|
831
|
+
" --since TIME start with every change since TIME (14:30 today, or",
|
|
832
|
+
" 2026-10-21, or 2026-10-21T14:30) instead of the last 5",
|
|
833
|
+
" --once print those and stop instead of listening",
|
|
834
|
+
" --json one JSON object a line (at, text, quote, mine, tone;",
|
|
835
|
+
" earlier: true for the ones from before it started)",
|
|
816
836
|
"",
|
|
817
|
-
"
|
|
818
|
-
"
|
|
819
|
-
"
|
|
837
|
+
" It starts with the latest few changes and a \"since HH:MM\" line, then",
|
|
838
|
+
" prints each new one as it lands. Lines about your own work (your tasks,",
|
|
839
|
+
" mentions of you, what you lead) are bright; the rest are dim. It runs",
|
|
840
|
+
" until q or Ctrl+C (or until it's killed, when piped or with --json);",
|
|
841
|
+
" --once returns at once. Made for a narrow split or a tmux pane; it needs",
|
|
842
|
+
" full access (harbor login without --read-only).",
|
|
820
843
|
],
|
|
821
844
|
lifeboats: [
|
|
822
845
|
" harbor lifeboats [--program P] reads: work only one person has",
|
|
@@ -873,9 +896,23 @@ const DETAIL = {
|
|
|
873
896
|
" Tasks have due dates, not lengths, so a task can finish on its due date",
|
|
874
897
|
" at the soonest, never before what it waits on, and never in the past.",
|
|
875
898
|
" Each link's slack is the days it can slip before it moves the next one",
|
|
876
|
-
" (or the end); a negative one already does.
|
|
899
|
+
" (or the end); a negative one already does. Open tasks with no due date",
|
|
900
|
+
" can't hold a date, so they're left out and counted. In --json, endDate",
|
|
901
|
+
" (dueDate for a task or milestone) is the date held and daysSpare the",
|
|
902
|
+
" days before it (negative: that many over). PROJECT is a name, a key",
|
|
877
903
|
" like RR, or an id.",
|
|
878
904
|
],
|
|
905
|
+
barnacles: [
|
|
906
|
+
" harbor barnacles PROJECT reads how much the plan has grown",
|
|
907
|
+
"",
|
|
908
|
+
" Counts the tasks made after the baseline was set (without a baseline",
|
|
909
|
+
" time, after the project's first week of tasks), as a",
|
|
910
|
+
" share of what was planned, by week and by who added them. At the",
|
|
911
|
+
" crew's pace (tasks finished a week over the last six) it says how many",
|
|
912
|
+
" days of work that is, and how much of the slip against the planned end",
|
|
913
|
+
" it explains. The weekly brief names projects that grew a lot since",
|
|
914
|
+
" their baseline.",
|
|
915
|
+
],
|
|
879
916
|
voyage: [
|
|
880
917
|
" harbor voyage PROJECT reads a Done project's voyage log",
|
|
881
918
|
' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
|
|
@@ -909,19 +946,40 @@ const DETAIL = {
|
|
|
909
946
|
" enough of it to pick one out; harbor risks lists them. Changes several",
|
|
910
947
|
" at once, as one change.",
|
|
911
948
|
],
|
|
949
|
+
thanks: [
|
|
950
|
+
" harbor thanks reads the thanks you were given in",
|
|
951
|
+
" the last 30 days, newest first",
|
|
952
|
+
" harbor thanks --all everyone's; --given: the ones you gave",
|
|
953
|
+
' harbor thanks RR-42 "LINE" writes: thanks whoever has the task',
|
|
954
|
+
' harbor thanks --milestone NAME "LINE" writes: thanks for a milestone (its',
|
|
955
|
+
" sign-off person unless --to says)",
|
|
956
|
+
' harbor thanks --decision TITLE "LINE" writes: thanks whoever made the call',
|
|
957
|
+
" --to NAME someone else instead",
|
|
958
|
+
"",
|
|
959
|
+
" Fair winds: a thanks shows on the work, on the person's card on People,",
|
|
960
|
+
" in the next brief and in the project's voyage log. Nobody can thank",
|
|
961
|
+
" themselves, and nothing counts or ranks them.",
|
|
962
|
+
],
|
|
912
963
|
bridge: [
|
|
913
964
|
" harbor bridge opens the full-screen app on the",
|
|
914
965
|
" review meeting (b on the Programs",
|
|
915
966
|
" screen, or : Run the review meeting)",
|
|
967
|
+
" harbor bridge --list [--program P] prints the agenda instead: what",
|
|
968
|
+
" changed on each project since its",
|
|
969
|
+
" program's last review, every item",
|
|
970
|
+
" (--json for scripts and agents; the",
|
|
971
|
+
" same when piped). Reading it doesn't",
|
|
972
|
+
" count as holding the meeting",
|
|
916
973
|
"",
|
|
917
974
|
" One open project a screen, with only what changed since its program was",
|
|
918
975
|
" last reviewed (a week back the first time): health moves, work finished,",
|
|
919
976
|
" newly late or added, risks raised, decisions made. ←→ move between",
|
|
920
977
|
" projects; d logs a decision, a an action (who takes it, due in a week),",
|
|
921
|
-
" r a risk, m the program's next move
|
|
922
|
-
"
|
|
923
|
-
"
|
|
924
|
-
"
|
|
978
|
+
" r a risk, m the program's next move, v shows every item behind an",
|
|
979
|
+
" \"and N more\" (↑↓ scroll); e ends it, Esc or q leaves without ending",
|
|
980
|
+
" it. The summary lists what was logged; p posts it to each program's",
|
|
981
|
+
" chat channel where one is set (Ship to shore, set on the website), y",
|
|
982
|
+
" copies it. The next meeting counts from when this one ended.",
|
|
925
983
|
],
|
|
926
984
|
review: [
|
|
927
985
|
" harbor review reads the review: every program,",
|
|
@@ -1146,8 +1204,14 @@ export async function run(argv, io = {}) {
|
|
|
1146
1204
|
}
|
|
1147
1205
|
|
|
1148
1206
|
// On its own in a terminal: the full-screen app. Piped or scripted: the help.
|
|
1149
|
-
|
|
1150
|
-
|
|
1207
|
+
const inTerminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
1208
|
+
// harbor bridge --list (or --json, or piped) prints the agenda through the commands below.
|
|
1209
|
+
const bridgeApp =
|
|
1210
|
+
command === "bridge" &&
|
|
1211
|
+
inTerminal &&
|
|
1212
|
+
!rest.some((a) => a === "--list" || a === "--json" || a.startsWith("--program"));
|
|
1213
|
+
if (!command || command === "app" || bridgeApp) {
|
|
1214
|
+
const terminal = inTerminal;
|
|
1151
1215
|
if (!command && !terminal) {
|
|
1152
1216
|
out(HELP);
|
|
1153
1217
|
return 0;
|
|
@@ -2624,6 +2688,12 @@ export async function run(argv, io = {}) {
|
|
|
2624
2688
|
if (!of) throw new HarborError("Say what: a project, a task key like RR-42, or a milestone.");
|
|
2625
2689
|
return call("long_pole", { harbor, of }, whyText);
|
|
2626
2690
|
}
|
|
2691
|
+
case "barnacles": {
|
|
2692
|
+
const { positionals, harbor } = parse(spec());
|
|
2693
|
+
const project = positionals.join(" ").trim();
|
|
2694
|
+
if (!project) throw new HarborError("Say which project: harbor barnacles PROJECT.");
|
|
2695
|
+
return call("scope_growth", { harbor, project }, barnaclesText);
|
|
2696
|
+
}
|
|
2627
2697
|
case "voyage": {
|
|
2628
2698
|
const { positionals, harbor } = parse(spec());
|
|
2629
2699
|
const [project, ...words] = positionals;
|
|
@@ -2639,13 +2709,18 @@ export async function run(argv, io = {}) {
|
|
|
2639
2709
|
return call("get_voyage_log", { harbor, project }, voyageText, voyageForTerminal);
|
|
2640
2710
|
}
|
|
2641
2711
|
case "watch": {
|
|
2642
|
-
const { positionals, values, harbor } = parse(spec(["bell"], ["project"]));
|
|
2712
|
+
const { positionals, values, harbor } = parse(spec(["bell", "once"], ["project", "since"]));
|
|
2643
2713
|
none(positionals);
|
|
2644
2714
|
if (!hasFullAccess(creds))
|
|
2645
2715
|
throw new HarborError(
|
|
2646
2716
|
"Watching needs full access, as the full-screen app does. Run harbor login (without --read-only), then try again.",
|
|
2647
2717
|
);
|
|
2648
|
-
const { watchHere } = await loadApp();
|
|
2718
|
+
const { watchHere, watchSince } = await loadApp();
|
|
2719
|
+
const since = values.since ? watchSince(values.since, new Date()) : null;
|
|
2720
|
+
if (values.since && !since)
|
|
2721
|
+
throw new HarborError(
|
|
2722
|
+
`--since takes a time today (14:30) or a date (2026-10-21, or 2026-10-21T14:30), not "${values.since}".`,
|
|
2723
|
+
);
|
|
2649
2724
|
await watchHere({
|
|
2650
2725
|
source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
|
|
2651
2726
|
fetcher,
|
|
@@ -2653,7 +2728,10 @@ export async function run(argv, io = {}) {
|
|
|
2653
2728
|
saved: creds.harbor ?? null,
|
|
2654
2729
|
project: values.project,
|
|
2655
2730
|
bell: Boolean(values.bell),
|
|
2656
|
-
colour: colourDepth(io.stdout ?? process.stdout, env) !== "none",
|
|
2731
|
+
colour: !asJson && colourDepth(io.stdout ?? process.stdout, env) !== "none",
|
|
2732
|
+
since,
|
|
2733
|
+
once: Boolean(values.once),
|
|
2734
|
+
json: asJson,
|
|
2657
2735
|
});
|
|
2658
2736
|
return 0;
|
|
2659
2737
|
}
|
|
@@ -2692,6 +2770,49 @@ export async function run(argv, io = {}) {
|
|
|
2692
2770
|
}
|
|
2693
2771
|
return call("fog", { harbor, project: values.project, limit: 100 }, (r) => fogText(r));
|
|
2694
2772
|
}
|
|
2773
|
+
case "bridge": {
|
|
2774
|
+
const { positionals, values, harbor } = parse(spec(["list"], ["program"]));
|
|
2775
|
+
none(positionals);
|
|
2776
|
+
return call("review_meeting", { harbor, program: values.program }, bridgeText);
|
|
2777
|
+
}
|
|
2778
|
+
case "thanks": {
|
|
2779
|
+
const { positionals, values, harbor } = parse(
|
|
2780
|
+
spec(["all", "given"], ["to", "milestone", "decision"]),
|
|
2781
|
+
);
|
|
2782
|
+
const forWork = values.milestone || values.decision;
|
|
2783
|
+
if (!forWork && positionals.length === 0) {
|
|
2784
|
+
return call(
|
|
2785
|
+
"list_thanks",
|
|
2786
|
+
{
|
|
2787
|
+
harbor,
|
|
2788
|
+
person: values.all ? "all" : "me",
|
|
2789
|
+
given: Boolean(values.given),
|
|
2790
|
+
},
|
|
2791
|
+
thanksText,
|
|
2792
|
+
);
|
|
2793
|
+
}
|
|
2794
|
+
const [first, ...rest] = positionals;
|
|
2795
|
+
const task = forWork ? undefined : first;
|
|
2796
|
+
const line = (forWork ? positionals : rest).join(" ").trim();
|
|
2797
|
+
if (task && !TASK_KEY.test(task) && !line) {
|
|
2798
|
+
throw new HarborError(
|
|
2799
|
+
'Say what for and the line: harbor thanks RR-42 "Caught it before launch" (or --milestone NAME, --decision TITLE).',
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
if (!line) throw new HarborError('Add the line of thanks in quotes: harbor thanks RR-42 "Thank you".');
|
|
2803
|
+
return call(
|
|
2804
|
+
"give_thanks",
|
|
2805
|
+
{
|
|
2806
|
+
harbor,
|
|
2807
|
+
task,
|
|
2808
|
+
milestone: values.milestone,
|
|
2809
|
+
decision: values.decision,
|
|
2810
|
+
to: values.to,
|
|
2811
|
+
line,
|
|
2812
|
+
},
|
|
2813
|
+
(r) => `Fair winds: thanked ${r.thanked.to ?? "them"} for ${r.thanked.for}.`,
|
|
2814
|
+
);
|
|
2815
|
+
}
|
|
2695
2816
|
case "handover": {
|
|
2696
2817
|
const { positionals, values, harbor } = parse(
|
|
2697
2818
|
spec(["confirm"], ["to", "until", "note", "keep"]),
|
package/lib/format.mjs
CHANGED
|
@@ -526,15 +526,66 @@ export function premortemText(r, project = r.project) {
|
|
|
526
526
|
}
|
|
527
527
|
|
|
528
528
|
/** A project's voyage log, as get_voyage_log answers it. */
|
|
529
|
+
/** `harbor thanks`: each thanks on two lines, the words first, no counts. */
|
|
530
|
+
export function thanksText(r) {
|
|
531
|
+
if (r.note) return r.note;
|
|
532
|
+
if (!r.thanks.length) {
|
|
533
|
+
return r.person
|
|
534
|
+
? `No thanks in the last ${r.days} days yet. harbor thanks TASK "LINE" gives one.`
|
|
535
|
+
: `Nobody has thanked anyone in the last ${r.days} days. harbor thanks TASK "LINE" gives one.`;
|
|
536
|
+
}
|
|
537
|
+
return r.thanks
|
|
538
|
+
.map((t) => `“${t.line}”\n ${t.from} to ${t.to}, for ${t.for} · ${t.at.slice(0, 10)}`)
|
|
539
|
+
.join("\n");
|
|
540
|
+
}
|
|
541
|
+
|
|
529
542
|
export function voyageText(r) {
|
|
530
543
|
const head = [`Voyage log · ${r.project}${r.done ? ` · Done ${r.finished}` : " · not Done yet"}`];
|
|
531
544
|
if (r.note) head.push(r.note);
|
|
532
545
|
head.push((r.stats ?? []).join(" · "));
|
|
533
546
|
const story = (r.story ?? []).map((s) => ` - ${s}`);
|
|
534
547
|
const crew = (r.crew ?? []).map((c) => ` - ${c.who ?? "Someone"}: ${c.line}`);
|
|
535
|
-
|
|
536
|
-
"
|
|
548
|
+
const thanks = (r.thanks ?? []).map(
|
|
549
|
+
(t) => ` - ${t.from ?? "Someone"} to ${t.to ?? "someone"}: “${t.line}”`,
|
|
550
|
+
);
|
|
551
|
+
return [
|
|
552
|
+
...head,
|
|
553
|
+
"",
|
|
554
|
+
...story,
|
|
555
|
+
...(thanks.length ? ["", "Fair winds along the way:", ...thanks] : []),
|
|
556
|
+
...(crew.length ? ["", "From the crew:", ...crew] : []),
|
|
557
|
+
].join("\n");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The review meeting's agenda, as review_meeting answers it: each project that moved, with every item. */
|
|
561
|
+
export function bridgeText(r) {
|
|
562
|
+
const programs = r.programs ?? [];
|
|
563
|
+
if (programs.length === 0) return "No open programs to review.";
|
|
564
|
+
const out = programs.map(
|
|
565
|
+
(p) =>
|
|
566
|
+
`${p.name}: changes since ${p.lastReviewed ? "the last review" : "last week"}, ${p.since.slice(0, 10)}`,
|
|
537
567
|
);
|
|
568
|
+
for (const p of r.projects ?? []) {
|
|
569
|
+
const facts = [
|
|
570
|
+
p.health,
|
|
571
|
+
p.lead,
|
|
572
|
+
p.ends ? `ends ${p.ends}` : null,
|
|
573
|
+
`${p.late} late of ${p.open} open`,
|
|
574
|
+
p.highRisks ? `${p.highRisks} high risk${p.highRisks === 1 ? "" : "s"}` : null,
|
|
575
|
+
p.nextMilestone ? `next milestone ${p.nextMilestone.name}, ${p.nextMilestone.due}` : null,
|
|
576
|
+
].filter(Boolean);
|
|
577
|
+
out.push("", `${p.project} (${p.program})`, ` ${facts.join(" · ")}`);
|
|
578
|
+
for (const c of p.changes) {
|
|
579
|
+
if (c.items) {
|
|
580
|
+
out.push(` - ${c.text.split(":")[0]} (${c.items.length}):`);
|
|
581
|
+
for (const item of c.items) out.push(` ${item}`);
|
|
582
|
+
} else out.push(` - ${c.text}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if ((r.projects ?? []).length === 0) out.push("", "Nothing moved since the last review.");
|
|
586
|
+
const quiet = (r.quiet ?? []).map((q) => q.project);
|
|
587
|
+
if (quiet.length) out.push("", `No change on ${quiet.join(", ")}.`);
|
|
588
|
+
return out.join("\n");
|
|
538
589
|
}
|
|
539
590
|
|
|
540
591
|
/** What's holding something up, as long_pole answers it: the headline, then the line of waits, first blocker at the top. */
|
|
@@ -571,7 +622,29 @@ export function whyText(r) {
|
|
|
571
622
|
.join(", ")}.`,
|
|
572
623
|
]
|
|
573
624
|
: [];
|
|
574
|
-
|
|
625
|
+
const note = r.note ? ["", r.note] : [];
|
|
626
|
+
return [...head, "", ...via, ...lines, ...others, ...note].join("\n");
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** A project's barnacles, as scope_growth answers it: the growth, its cost in days, by week and by who. */
|
|
630
|
+
export function barnaclesText(r) {
|
|
631
|
+
const head = [`Barnacles · ${r.project}`];
|
|
632
|
+
if (!r.added) {
|
|
633
|
+
head.push(`Nothing added since the plan was set (${r.since.slice(0, 10)}): ${r.planned} task${r.planned === 1 ? "" : "s"}, as planned.`);
|
|
634
|
+
return head.join("\n");
|
|
635
|
+
}
|
|
636
|
+
head.push(`${r.headline} (${r.planned} planned, ${r.addedThisWeek} added this week).`);
|
|
637
|
+
if (r.daysLine) head.push(r.daysLine);
|
|
638
|
+
const top = Math.max(1, ...r.byWeek.map((w) => w.added));
|
|
639
|
+
const weeks = r.byWeek.map(
|
|
640
|
+
(w) => ` w/c ${w.weekOf} ${"█".repeat(Math.max(1, Math.round((w.added / top) * 20)))} ${w.added}`,
|
|
641
|
+
);
|
|
642
|
+
const who = r.byWho.map((w) => `${w.who ?? "not recorded"} ${w.added}`).join(", ");
|
|
643
|
+
const from =
|
|
644
|
+
r.measuredFrom === "when the baseline was set"
|
|
645
|
+
? `Counted from when the baseline was set, ${r.since.slice(0, 10)}.`
|
|
646
|
+
: `Counted from a week after its first task was made, ${r.since.slice(0, 10)}; setting a baseline counts from then instead.`;
|
|
647
|
+
return [...head, "", "Added by week:", ...weeks, "", `By who: ${who}`, "", from].join("\n");
|
|
575
648
|
}
|
|
576
649
|
|
|
577
650
|
/** A position on a 21-wide track, the top of the hill marked in the middle. */
|
package/lib/mcp.mjs
CHANGED
|
@@ -53,8 +53,15 @@ export function createClient({ creds, save, fetcher = fetch, now = Date.now }) {
|
|
|
53
53
|
const msg = await res.json();
|
|
54
54
|
if (msg.error) throw new HarborError(msg.error.message);
|
|
55
55
|
const result = msg.result ?? {};
|
|
56
|
-
if (result.isError)
|
|
57
|
-
|
|
56
|
+
if (result.isError) {
|
|
57
|
+
const said = result.content?.map((c) => c.text).join("\n") || "Harbor said no.";
|
|
58
|
+
// A command newer than the site it talks to: the tool ships with the next deploy.
|
|
59
|
+
if (/^No tool named "/.test(said))
|
|
60
|
+
throw new HarborError(
|
|
61
|
+
`${new URL(current.server).host} doesn't have this yet. Try again after the next site update.`,
|
|
62
|
+
);
|
|
63
|
+
throw new HarborError(said);
|
|
64
|
+
}
|
|
58
65
|
return result.structuredContent ?? JSON.parse(result.content?.[0]?.text ?? "{}");
|
|
59
66
|
},
|
|
60
67
|
|