@mgiles/perk 2.3.0 → 3.0.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 +1 -1
- package/extension/adapters/planAdapterPlannotator.ts +132 -91
- package/extension/adapters/planAdapterTombell.ts +5 -3
- package/extension/doors/address.ts +235 -37
- package/extension/doors/annotationPush.ts +924 -0
- package/extension/doors/auditWaveTools.ts +352 -0
- package/extension/doors/ciExecutor.ts +220 -55
- package/extension/doors/draftReviewWaveTools.ts +384 -0
- package/extension/doors/harvestWaveTools.ts +279 -0
- package/extension/doors/land.ts +1 -0
- package/extension/doors/learn.ts +24 -6
- package/extension/doors/learnFactory.ts +33 -7
- package/extension/doors/lifecycleGates.ts +1 -1
- package/extension/doors/objectiveReviewBrowser.ts +514 -0
- package/extension/doors/objectiveStack.ts +1143 -0
- package/extension/doors/planReviewBrowser.ts +453 -0
- package/extension/doors/plannotatorHandoff.ts +120 -49
- package/extension/doors/prReview.ts +26 -16
- package/extension/doors/prReviewBrowser.ts +31 -23
- package/extension/doors/prReviewDynamic.ts +25 -18
- package/extension/doors/prReviewTerminal.ts +6 -11
- package/extension/doors/reviewWaveTools.ts +374 -0
- package/extension/doors/selfcheck.ts +1 -1
- package/extension/doors/submit.ts +82 -7
- package/extension/factories/gistAuthor.ts +4 -4
- package/extension/factories/gistDraft.ts +1 -1
- package/extension/factories/gistSave.ts +2 -2
- package/extension/factories/objective.ts +7 -7
- package/extension/factories/objectiveAuthor.ts +5 -4
- package/extension/factories/objectiveDraft.ts +61 -6
- package/extension/factories/objectivePlan.ts +163 -9
- package/extension/factories/objectiveSave.ts +16 -1
- package/extension/factories/planMode.ts +5 -3
- package/extension/factories/planReview.ts +131 -36
- package/extension/factories/planTitle.ts +27 -9
- package/extension/hunkFeedback/inbox.ts +389 -0
- package/extension/hunkFeedback/perkFeedback.ts +364 -0
- package/extension/hunkFeedback/receiver.ts +296 -0
- package/extension/hunkFeedback/store.ts +533 -0
- package/extension/index.ts +109 -44
- package/extension/substrate/cache.ts +75 -22
- package/extension/substrate/coldDoor.ts +2 -3
- package/extension/substrate/config.ts +36 -12
- package/extension/substrate/git.ts +10 -0
- package/extension/substrate/providers.ts +4 -33
- package/extension/substrate/resources.ts +11 -0
- package/extension/substrate/result.ts +13 -5
- package/extension/substrate/sessionData.ts +3 -3
- package/extension/substrate/sessionPointers.ts +3 -3
- package/extension/substrate/structuredOutput.ts +78 -19
- package/extension/substrate/toolGating.ts +170 -23
- package/extension/substrate/workflowState.ts +6 -0
- package/extension/surfaces/footerProvider.ts +6 -6
- package/extension/surfaces/surfaces.ts +98 -282
- package/extension/vendor/btw/btw.ts +57 -12
- package/extension/waves/adversarialReviewWave.ts +164 -0
- package/extension/waves/auditWave.ts +312 -0
- package/extension/waves/draftReviewWave.ts +189 -0
- package/extension/waves/harvestWave.ts +399 -0
- package/extension/waves/memoryAdapter.ts +14 -1
- package/extension/waves/objectiveExplorerWave.ts +120 -0
- package/extension/waves/prReviewDynamicWave.ts +291 -61
- package/extension/waves/prReviewWave.ts +58 -13
- package/extension/waves/reportWave.ts +406 -106
- package/extension/waves/reviewClassifierWave.ts +109 -0
- package/extension/waves/rpcAdapter.ts +65 -2
- package/extension/worker/readOnlySession.ts +3 -3
- package/extension/worker/worker.ts +71 -93
- package/extension/workerMain.ts +4 -6
- package/package.json +15 -5
- package/prompts/README.md +19 -0
- package/prompts/_fixtures/live.yaml +66 -65
- package/prompts/common/resume-advisory.md +1 -0
- package/prompts/contexts/adapters/plannotator-gist.md +13 -0
- package/prompts/contexts/adapters/plannotator-objective.md +1 -2
- package/prompts/contexts/adapters/plannotator-plan.md +1 -2
- package/prompts/contexts/gist-authoring.md +6 -20
- package/prompts/contexts/objective-authoring.md +6 -18
- package/prompts/stages/address/action.md +3 -14
- package/prompts/stages/address/preview.md +2 -13
- package/prompts/stages/audit.md +18 -0
- package/prompts/stages/gist-author/seed.md +4 -4
- package/prompts/stages/gist-save.md +1 -1
- package/prompts/stages/implement.md +3 -1
- package/prompts/stages/learn-code.md +1 -1
- package/prompts/stages/learn-docs.md +2 -2
- package/prompts/stages/learn-harvest.md +15 -0
- package/prompts/stages/learn-orchestrate.md +1 -1
- package/prompts/stages/objective-author/adopt.md +5 -3
- package/prompts/stages/objective-author/file.md +5 -4
- package/prompts/stages/objective-author/seed.md +5 -4
- package/prompts/stages/objective-land.md +7 -0
- package/prompts/stages/objective-plan/guidance.md +2 -13
- package/prompts/stages/objective-plan/seed.md +5 -12
- package/prompts/stages/objective-recover.md +8 -0
- package/prompts/stages/objective-replan.md +4 -2
- package/prompts/stages/objective-review-browser.md +9 -0
- package/prompts/stages/objective-sync.md +6 -0
- package/prompts/stages/plan-from/adopt.md +2 -2
- package/prompts/stages/plan-from/file.md +2 -2
- package/prompts/stages/plan-review-browser.md +9 -0
- package/prompts/stages/pr-review-browser/active.md +6 -14
- package/prompts/stages/pr-review-browser/foreign.md +6 -14
- package/prompts/stages/pr-review-dynamic.md +3 -3
- package/prompts/stages/pr-review-terminal/active.md +7 -15
- package/prompts/stages/pr-review-terminal/foreign.md +7 -15
- package/prompts/stages/pr-review.md +2 -2
- package/prompts/stages/replan.md +3 -3
- package/shared/README.md +2 -2
- package/shared/bindings.yaml +12 -0
- package/shared/contracts-history.md +11 -0
- package/shared/contracts.md +3652 -236
- package/shared/providers.yaml +27 -60
- package/shared/registry.yaml +32 -8
- package/shared/schemas/outputs/objective-doctor.schema.json +449 -0
- package/shared/schemas/outputs/objective-stack-land.schema.json +732 -0
- package/shared/schemas/outputs/objective-stack-recover.schema.json +397 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +577 -0
- package/shared/schemas/outputs/objective-stack-sync.schema.json +184 -0
- package/shared/schemas/outputs/plan-save.schema.json +12 -0
- package/shared/schemas/outputs/pr-submit.schema.json +177 -1
- package/extension/adapters/todoAdapterJuicesharp.ts +0 -102
- package/extension/checkpoints/checkpoints.ts +0 -550
- package/extension/checkpoints/planSteps.ts +0 -108
- package/extension/doors/askUser.ts +0 -198
- package/prompts/common/output-schemas/objective-explorer.md +0 -36
- package/prompts/common/output-schemas/review-classifier.md +0 -47
- package/prompts/contexts/adapters/juicesharp-todo.md +0 -7
|
@@ -34,8 +34,9 @@
|
|
|
34
34
|
// discarded (the aborted arm wins). Enter submits in the editor dialog (Shift+Enter = newline),
|
|
35
35
|
// so the dialog titles carry the key hints — pi renders no other affordance.
|
|
36
36
|
//
|
|
37
|
-
// THE OBJECTIVE ARM: an objective-
|
|
38
|
-
// `objective-
|
|
37
|
+
// THE OBJECTIVE ARM: an objective-authoring session (read-only, stage `objective-author` or
|
|
38
|
+
// `objective-save` — the two stages whose working draft IS the objective draft; neither carries
|
|
39
|
+
// `plan_draft`) routes through `executeObjectiveReview` instead of the plan path — the
|
|
39
40
|
// reviewed bytes are the RENDERED objective draft (`readObjectiveDraft` + `renderObjectiveDraft`,
|
|
40
41
|
// objectiveDraft.ts — never raw JSON, never the `plan` param, never the transcript; no draft
|
|
41
42
|
// soft-skips with `reason: "no_objective_draft"`). Dispatch mirrors the plan path (plannotator
|
|
@@ -49,7 +50,11 @@
|
|
|
49
50
|
// `executeGistReview` the same way — the reviewed bytes are the RENDERED gist draft
|
|
50
51
|
// (`readGistDraft` + `renderGistDraft`, gistDraft.ts), first-party VIEW-ONLY, implement-here
|
|
51
52
|
// never offered, APPROVED → the `gistApprovalSave` seam (gistSave.ts), no draft soft-skips with
|
|
52
|
-
// `reason: "no_gist_draft"`.
|
|
53
|
+
// `reason: "no_gist_draft"`. Plannotator approve-with-Direct-Edits mirrors the objective arm:
|
|
54
|
+
// the save seam re-reads the STRUCTURED gist artifact, so rendered edits cannot be folded back
|
|
55
|
+
// mechanically — the arm SKIPS the save and returns one model-mediated revise round (fold each
|
|
56
|
+
// hunk into the matching `gist_draft` field — title heading → `title`, `Scope:` line → `scope`,
|
|
57
|
+
// prose → `prose` — then re-review to confirm).
|
|
53
58
|
//
|
|
54
59
|
// INVARIANTS HELD: never calls `setActiveTools`, never registers a `tool_call` handler, never
|
|
55
60
|
// restamps `cache.plan-ref.provider`. The door composes the gate AND the save EXCLUSIVELY
|
|
@@ -75,7 +80,11 @@ import { implementHereExit, implementHereGuidance } from "./implementHere.ts";
|
|
|
75
80
|
import { OBJECTIVE_AUTHOR_STAGE } from "./objectiveAuthor.ts";
|
|
76
81
|
import { readObjectiveDraft, renderObjectiveDraft } from "./objectiveDraft.ts";
|
|
77
82
|
import { readNodeClaim } from "./objectivePlan.ts";
|
|
78
|
-
import {
|
|
83
|
+
import {
|
|
84
|
+
OBJECTIVE_SAVE_STAGE,
|
|
85
|
+
type ObjectiveApprovalSaveOutcome,
|
|
86
|
+
objectiveApprovalSave,
|
|
87
|
+
} from "./objectiveSave.ts";
|
|
79
88
|
import { writePlanDraft } from "./planDraft.ts";
|
|
80
89
|
import { type ApprovalSaveOutcome, approvalSave, resolvePlanSource } from "./planSave.ts";
|
|
81
90
|
|
|
@@ -410,9 +419,62 @@ export function implementHereResult(
|
|
|
410
419
|
};
|
|
411
420
|
}
|
|
412
421
|
|
|
422
|
+
// ------------------------------------------------------ the plannotator Direct-Edits apply
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The shared plannotator APPROVE mechanical-apply path (contracts.md §8.23): inspect an
|
|
426
|
+
* APPROVED outcome's feedback for a `# Direct Edits` section and mechanically apply the
|
|
427
|
+
* reviewer's diff to the exact bytes reviewed (`basePlan`), writing the patched bytes back to
|
|
428
|
+
* the draft (reviewed bytes == artifact bytes == saved bytes). Consumed by `executePlanReview`'s
|
|
429
|
+
* plannotator arm AND the `/plan-review-browser` door — one apply path, byte-identical
|
|
430
|
+
* semantics:
|
|
431
|
+
*
|
|
432
|
+
* - only an `approved` outcome WITH feedback is inspected (anything else passes through
|
|
433
|
+
* verbatim);
|
|
434
|
+
* - a clean extract + apply + write-back swaps `reviewedPlan` to the patched bytes, sets
|
|
435
|
+
* `edited: true`, and strips the applied section from the returned outcome's feedback (only
|
|
436
|
+
* the annotation remainder survives — the applied diff must never render as "apply these
|
|
437
|
+
* exact changes" guidance);
|
|
438
|
+
* - a seen-but-unhonorable heading (or a failed apply / write-back) sets
|
|
439
|
+
* `directEditsFailed: true` with the plan left verbatim (the caller renders the loud warning;
|
|
440
|
+
* the diff stays in the surfaced feedback for a manual follow-up).
|
|
441
|
+
*/
|
|
442
|
+
export function applyPlannotatorDirectEdits(
|
|
443
|
+
pi: ExtensionAPI,
|
|
444
|
+
ctx: ExtensionContext,
|
|
445
|
+
outcome: Extract<ReviewOutcome, { status: "completed" }>,
|
|
446
|
+
basePlan: string,
|
|
447
|
+
): {
|
|
448
|
+
outcome: Extract<ReviewOutcome, { status: "completed" }>;
|
|
449
|
+
reviewedPlan: string;
|
|
450
|
+
edited: boolean;
|
|
451
|
+
directEditsFailed: boolean;
|
|
452
|
+
} {
|
|
453
|
+
if (!outcome.approved || outcome.feedback === undefined) {
|
|
454
|
+
return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: false };
|
|
455
|
+
}
|
|
456
|
+
const section = extractDirectEdits(outcome.feedback);
|
|
457
|
+
if (section !== null) {
|
|
458
|
+
const patched = applyUnifiedDiff(basePlan, section.diff);
|
|
459
|
+
if (patched !== null && writePlanDraft(pi, ctx, patched).details.ok) {
|
|
460
|
+
return {
|
|
461
|
+
outcome: { ...outcome, feedback: section.remainder },
|
|
462
|
+
reviewedPlan: patched,
|
|
463
|
+
edited: true,
|
|
464
|
+
directEditsFailed: false,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: true };
|
|
468
|
+
}
|
|
469
|
+
if (hasDirectEditsHeading(outcome.feedback)) {
|
|
470
|
+
return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: true };
|
|
471
|
+
}
|
|
472
|
+
return { outcome, reviewedPlan: basePlan, edited: false, directEditsFailed: false };
|
|
473
|
+
}
|
|
474
|
+
|
|
413
475
|
// ----------------------------------------------------------------- the first-party review core
|
|
414
476
|
|
|
415
|
-
/** The minimal structural `ctx.ui` subset the first-party review needs (the
|
|
477
|
+
/** The minimal structural `ctx.ui` subset the first-party review needs (the ciExecutor.ts pure-core + injected-fakes recipe). */
|
|
416
478
|
export interface PlanReviewUI {
|
|
417
479
|
editor(title: string, prefill?: string): Promise<string | undefined>;
|
|
418
480
|
select(
|
|
@@ -444,7 +506,7 @@ const REVIEW_EDITOR_TITLE =
|
|
|
444
506
|
const DENY_FEEDBACK_TITLE = "Deny feedback (optional) — Enter to send";
|
|
445
507
|
|
|
446
508
|
/**
|
|
447
|
-
* The first-party in-TUI review core, pure over injected seams (the
|
|
509
|
+
* The first-party in-TUI review core, pure over injected seams (the ciExecutor.ts pure-core + injected-fakes recipe) — fully
|
|
448
510
|
* offline-testable. Flow: (1) display the plan in the editor dialog (Esc = dismissed; the human
|
|
449
511
|
* may edit, incl. via Ctrl+G/$EDITOR); (2) a non-blank edit differing from the displayed plan is
|
|
450
512
|
* written back to the draft BEFORE the verdict (reviewed bytes == artifact bytes == saved bytes;
|
|
@@ -721,7 +783,12 @@ export function approvedGistSaveResult(
|
|
|
721
783
|
* run VIEW-ONLY (edits are never written back; deny+feedback is the change channel); the
|
|
722
784
|
* implement-here verdict is never offered (a gist is not implementable — it has no strategy).
|
|
723
785
|
* An APPROVED outcome wires into the `gistApprovalSave` seam (re-read the artifact → `saveGist`
|
|
724
|
-
* → D1a gate exit → terminating); every other outcome maps via `gistReviewOutcomeResult`.
|
|
786
|
+
* → D1a gate exit → terminating); every other outcome maps via `gistReviewOutcomeResult`. ONE
|
|
787
|
+
* carve-out (plannotator only, mirroring the objective arm): an approval whose feedback opens a
|
|
788
|
+
* Direct Edits section SKIPS the save — rendered edits cannot be folded back into the
|
|
789
|
+
* structured draft mechanically — and returns a NON-terminating revise round with the gate
|
|
790
|
+
* untouched (fold each hunk into the matching `gist_draft` field, re-review to confirm); perk
|
|
791
|
+
* never saves a gist the reviewer explicitly edited away from.
|
|
725
792
|
*/
|
|
726
793
|
export async function executeGistReview(
|
|
727
794
|
pi: ExtensionAPI,
|
|
@@ -762,6 +829,42 @@ export async function executeGistReview(
|
|
|
762
829
|
let outcome: ReviewOutcome;
|
|
763
830
|
if (isPlannotatorPlanSelected(ctx.cwd)) {
|
|
764
831
|
outcome = await bridge.review(rendered, sig);
|
|
832
|
+
// APPROVE + Direct Edits (browser edits of the RENDERED markdown), checked BEFORE the
|
|
833
|
+
// approved-save routing (the approved-first discipline; mirrors the objective arm): the
|
|
834
|
+
// save seam re-reads the STRUCTURED artifact, so rendered edits cannot be folded back
|
|
835
|
+
// without model judgment. Skip the save, keep the gate read-only, and route ONE revise
|
|
836
|
+
// round: the model folds each hunk into the matching `gist_draft` field, then re-reviews
|
|
837
|
+
// to confirm. The heading check suffices (extraction success is irrelevant here — the diff
|
|
838
|
+
// goes to the model verbatim either way).
|
|
839
|
+
if (
|
|
840
|
+
outcome.status === "completed" &&
|
|
841
|
+
outcome.approved &&
|
|
842
|
+
outcome.feedback !== undefined &&
|
|
843
|
+
hasDirectEditsHeading(outcome.feedback)
|
|
844
|
+
) {
|
|
845
|
+
return {
|
|
846
|
+
content: [
|
|
847
|
+
{
|
|
848
|
+
type: "text",
|
|
849
|
+
text:
|
|
850
|
+
"gist APPROVED with direct browser edits — these cannot be auto-applied to the " +
|
|
851
|
+
"structured draft, so nothing was saved. Fold each Direct Edits hunk below into " +
|
|
852
|
+
"the matching gist_draft field (a `# <title>` heading hunk → title, a `Scope:` " +
|
|
853
|
+
"line hunk → scope, prose hunks → prose), then call plan_review again to " +
|
|
854
|
+
`confirm.\n\nReviewer feedback:\n${outcome.feedback}`,
|
|
855
|
+
},
|
|
856
|
+
],
|
|
857
|
+
details: {
|
|
858
|
+
ok: true,
|
|
859
|
+
status: "revise",
|
|
860
|
+
reason: "direct_edits",
|
|
861
|
+
approved: true,
|
|
862
|
+
feedback: outcome.feedback,
|
|
863
|
+
reviewId: outcome.reviewId,
|
|
864
|
+
subject: "gist",
|
|
865
|
+
},
|
|
866
|
+
};
|
|
867
|
+
}
|
|
765
868
|
} else {
|
|
766
869
|
const fp = await runFirstPartyReview({
|
|
767
870
|
ui: ctx.ui,
|
|
@@ -790,8 +893,9 @@ export async function executeGistReview(
|
|
|
790
893
|
/**
|
|
791
894
|
* The `plan_review` execute core, extracted pure-over-its-seams (the bridge, the gating, the
|
|
792
895
|
* ctx) so the resolution + dispatch + approved-save paths are unit-testable offline. Arm order:
|
|
793
|
-
* param decode → the objective
|
|
794
|
-
*
|
|
896
|
+
* param decode → the objective arm (`executeObjectiveReview` — the rendered objective draft is
|
|
897
|
+
* the review subject in BOTH objective-authoring stages) → headless skip → file-first
|
|
898
|
+
* resolution → backend
|
|
795
899
|
* dispatch (plannotator-selected → the event-bus bridge; ANY other selection → the first-party
|
|
796
900
|
* in-TUI editor review) → approved → `approvalSave`.
|
|
797
901
|
*/
|
|
@@ -825,11 +929,14 @@ export async function executePlanReview(
|
|
|
825
929
|
},
|
|
826
930
|
};
|
|
827
931
|
}
|
|
828
|
-
// 1.
|
|
829
|
-
//
|
|
830
|
-
//
|
|
932
|
+
// 1. An objective-authoring session (objective-author OR objective-save — both stages'
|
|
933
|
+
// working draft is the objective draft, and neither carries `plan_draft`) → the objective
|
|
934
|
+
// review arm: the rendered objective draft is the sole review source; a well-typed `plan`
|
|
935
|
+
// param is ignored here — the plan-arm fallthrough could otherwise review/save an
|
|
936
|
+
// unrelated plan param from an objective session. A gist-author session likewise routes to
|
|
937
|
+
// the gist arm (the rendered gist draft).
|
|
831
938
|
const launchedStage = rebuildWorkflowState(branchOf(ctx)).stage;
|
|
832
|
-
if (launchedStage === OBJECTIVE_AUTHOR_STAGE) {
|
|
939
|
+
if (launchedStage === OBJECTIVE_AUTHOR_STAGE || launchedStage === OBJECTIVE_SAVE_STAGE) {
|
|
833
940
|
return executeObjectiveReview(pi, ctx, gating, bridge, signal ?? ctx.signal);
|
|
834
941
|
}
|
|
835
942
|
if (launchedStage === GIST_AUTHOR_STAGE) {
|
|
@@ -869,29 +976,17 @@ export async function executePlanReview(
|
|
|
869
976
|
if (isPlannotatorPlanSelected(ctx.cwd)) {
|
|
870
977
|
outcome = await bridge.review(src.plan, sig);
|
|
871
978
|
// APPROVE + Direct Edits (browser plan edits, contracts.md §8.23): mechanically apply the
|
|
872
|
-
// reviewer's diff
|
|
873
|
-
//
|
|
874
|
-
//
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
if (patched !== null && writePlanDraft(pi, ctx, patched).details.ok) {
|
|
884
|
-
reviewedPlan = patched;
|
|
885
|
-
edited = true;
|
|
886
|
-
// The applied diff must NOT survive into the result as "apply these exact changes"
|
|
887
|
-
// guidance — only the annotation remainder (when any) stays reviewer feedback.
|
|
888
|
-
outcome = { ...outcome, feedback: section.remainder };
|
|
889
|
-
} else {
|
|
890
|
-
directEditsFailed = true;
|
|
891
|
-
}
|
|
892
|
-
} else if (hasDirectEditsHeading(outcome.feedback)) {
|
|
893
|
-
directEditsFailed = true;
|
|
894
|
-
}
|
|
979
|
+
// reviewer's diff via the shared helper (the first-party pre-verdict write-back, replayed
|
|
980
|
+
// here post-verdict because the bridge only reports the diff), and save the EDITED bytes.
|
|
981
|
+
// Every rung fails open to the verbatim path (never save bytes the artifact doesn't carry).
|
|
982
|
+
// DENY stays model-mediated — the feedback (diff included) passes through for the
|
|
983
|
+
// plan_draft rewrite.
|
|
984
|
+
if (outcome.status === "completed") {
|
|
985
|
+
const applied = applyPlannotatorDirectEdits(pi, ctx, outcome, src.plan);
|
|
986
|
+
outcome = applied.outcome;
|
|
987
|
+
reviewedPlan = applied.reviewedPlan;
|
|
988
|
+
edited = applied.edited;
|
|
989
|
+
directEditsFailed = applied.directEditsFailed;
|
|
895
990
|
}
|
|
896
991
|
} else {
|
|
897
992
|
// The 4th verdict (implement-here, the no-save exit) is offered UNLESS this is an
|
|
@@ -92,16 +92,35 @@ export async function generatePlanTitle(
|
|
|
92
92
|
): Promise<string | null> {
|
|
93
93
|
if (!llmTitlesEnabled()) return null;
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
const model = ctx.model;
|
|
96
|
+
if (!model) return null; // silent: no model configured (fail-safe).
|
|
97
|
+
|
|
98
|
+
// Primary path (pi ≥ 0.84): registry dispatch — pi owns final request assembly (resolved auth,
|
|
99
|
+
// nullable headers, credential-resolved baseUrl, provider env). Feature-detected: the method is
|
|
100
|
+
// absent on older hosts, where the widened resolveModelAuth fallback keeps the old shape.
|
|
101
|
+
const registry = ctx.modelRegistry;
|
|
102
|
+
const registryComplete = registry.complete;
|
|
103
|
+
let authOptions: Pick<
|
|
104
|
+
Parameters<typeof completeStructured>[0],
|
|
105
|
+
"dispatch" | "apiKey" | "headers" | "env"
|
|
106
|
+
>;
|
|
107
|
+
if (typeof registryComplete === "function") {
|
|
108
|
+
authOptions = {
|
|
109
|
+
dispatch: (m, context, options) => registryComplete.call(registry, m, context, options),
|
|
110
|
+
};
|
|
111
|
+
} else {
|
|
112
|
+
let auth: Awaited<ReturnType<typeof resolveModelAuth>>;
|
|
113
|
+
try {
|
|
114
|
+
auth = await resolveModelAuth(ctx);
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (!auth.ok) return null; // silent: no auth configured (fail-safe).
|
|
119
|
+
authOptions = { apiKey: auth.apiKey, headers: auth.headers, env: auth.env };
|
|
100
120
|
}
|
|
101
|
-
if (!auth.ok) return null; // silent: no model / no auth configured (fail-safe).
|
|
102
121
|
|
|
103
122
|
const outcome = await completeStructured({
|
|
104
|
-
model
|
|
123
|
+
model,
|
|
105
124
|
schema: PlanTitleSchema,
|
|
106
125
|
toolName: "set_plan_title",
|
|
107
126
|
toolDescription: "Provide the chosen title and category for the plan.",
|
|
@@ -110,8 +129,7 @@ export async function generatePlanTitle(
|
|
|
110
129
|
"Read this implementation plan and choose a title and category. The title must be a concise, " +
|
|
111
130
|
"imperative phrase, at most ~70 characters, with no trailing period and no markdown.",
|
|
112
131
|
input: planMarkdown.slice(0, TITLE_INPUT_CHAR_CAP),
|
|
113
|
-
|
|
114
|
-
headers: auth.headers,
|
|
132
|
+
...authOptions,
|
|
115
133
|
signal,
|
|
116
134
|
});
|
|
117
135
|
if (!outcome.ok || !outcome.value) {
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// The hunk watch feedback inbox (contracts.md §8.58): the single-flight delivery machine that
|
|
2
|
+
// drains the worktree outbox into the one eligible implement session.
|
|
3
|
+
//
|
|
4
|
+
// idle → dispatching → awaiting-observation → acknowledged → idle
|
|
5
|
+
// └→ backoff → dispatching
|
|
6
|
+
//
|
|
7
|
+
// Exactly ONE unacknowledged batch exists between injection and acknowledgement; while it awaits
|
|
8
|
+
// observation no new batch is injected — triggers (watch events, poll ticks, overflow) only mark
|
|
9
|
+
// the inbox dirty. Acknowledgement requires transcript OBSERVATION (`transport.isInjected`), not
|
|
10
|
+
// call-return: Pi's `sendUserMessage` is a void wrapper over in-memory queues an abort discards.
|
|
11
|
+
// Backoff resets ONLY on observation, so persistent asynchronous rejection backs off
|
|
12
|
+
// exponentially instead of retrying every poll tick — and unconfirmed messages can never
|
|
13
|
+
// accumulate (one in flight, ever). Duplicates possible, silent loss not.
|
|
14
|
+
//
|
|
15
|
+
// Failure containment: every background callback is wrapped — nothing ever escapes into the
|
|
16
|
+
// host session. Watcher failure degrades permanently to poll-only (polling is the correctness
|
|
17
|
+
// path); a failed lease verification closes the inbox fail-closed (misdelivery is never the
|
|
18
|
+
// fallback). Diagnostics route through `deps.report`, never into the model conversation.
|
|
19
|
+
//
|
|
20
|
+
// All effects arrive through `deps` (clock, timers, watch factory, report sink) — deterministic
|
|
21
|
+
// under test, no hidden globals.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
hunkConsumerLockDir,
|
|
25
|
+
hunkDeliveredPath,
|
|
26
|
+
hunkOutboxPath,
|
|
27
|
+
hunkWatchDir,
|
|
28
|
+
} from "../substrate/cache.ts";
|
|
29
|
+
import { lsFiles } from "../substrate/git.ts";
|
|
30
|
+
import {
|
|
31
|
+
acquireLease,
|
|
32
|
+
appendAcks,
|
|
33
|
+
type DeliveryAck,
|
|
34
|
+
type FeedbackRecord,
|
|
35
|
+
HEARTBEAT_MS,
|
|
36
|
+
readDeliveredIds,
|
|
37
|
+
readOutbox,
|
|
38
|
+
releaseLease,
|
|
39
|
+
renewHeartbeat,
|
|
40
|
+
sweepQuarantine,
|
|
41
|
+
verifyLease,
|
|
42
|
+
} from "./store.ts";
|
|
43
|
+
|
|
44
|
+
// Implementation constants (§8.58) — code constants, deliberately not config.
|
|
45
|
+
export const DEBOUNCE_MS = 500;
|
|
46
|
+
export const POLL_MS = 10_000;
|
|
47
|
+
export const BATCH_MAX_RECORDS = 10;
|
|
48
|
+
export const BATCH_MAX_BYTES = 49_152;
|
|
49
|
+
export const BACKOFF_BASE_MS = 1_000;
|
|
50
|
+
export const BACKOFF_CAP_MS = 60_000;
|
|
51
|
+
/** An in-flight batch is only demotable once it has had ≥ one poll interval to land. */
|
|
52
|
+
export const IN_FLIGHT_MIN_AGE_MS = POLL_MS;
|
|
53
|
+
|
|
54
|
+
export interface ConsumerIdentity {
|
|
55
|
+
cwd: string;
|
|
56
|
+
runId: string;
|
|
57
|
+
piSessionId: string;
|
|
58
|
+
/** The worktree plan-ref's `pr_id` — records for any other plan are held, never delivered. */
|
|
59
|
+
planId: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FeedbackTransport {
|
|
63
|
+
/** Render + sendUserMessage (idle vs steer). A synchronous throw = refusal (records stay pending). */
|
|
64
|
+
inject(batch: readonly FeedbackRecord[]): void;
|
|
65
|
+
/** True when the batch's injected message is observed as a persisted user-message entry on the branch. */
|
|
66
|
+
isInjected(batch: readonly FeedbackRecord[]): boolean;
|
|
67
|
+
isIdle(): boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface FeedbackInboxHandle {
|
|
71
|
+
close(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The lease was foreign and fresh — this session stays passive (and says so, once). */
|
|
75
|
+
export interface PassiveClaim {
|
|
76
|
+
passive: true;
|
|
77
|
+
reason: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface InboxTimers {
|
|
81
|
+
setTimeout(fn: () => void, ms: number): unknown;
|
|
82
|
+
clearTimeout(handle: unknown): void;
|
|
83
|
+
setInterval(fn: () => void, ms: number): unknown;
|
|
84
|
+
clearInterval(handle: unknown): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface InboxWatcher {
|
|
88
|
+
close(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** An `fs.watch` factory seam; construction may throw (degrades to poll-only). */
|
|
92
|
+
export type WatchFactory = (
|
|
93
|
+
dir: string,
|
|
94
|
+
onChange: () => void,
|
|
95
|
+
onError: (error: unknown) => void,
|
|
96
|
+
) => InboxWatcher;
|
|
97
|
+
|
|
98
|
+
export interface InboxDeps {
|
|
99
|
+
now(): number;
|
|
100
|
+
timers: InboxTimers;
|
|
101
|
+
watch: WatchFactory;
|
|
102
|
+
report(severity: "info" | "warning" | "error", message: string): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface HunkFeedbackInbox {
|
|
106
|
+
open(
|
|
107
|
+
identity: ConsumerIdentity,
|
|
108
|
+
transport: FeedbackTransport,
|
|
109
|
+
): FeedbackInboxHandle | PassiveClaim;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function createHunkFeedbackInbox(deps: InboxDeps): HunkFeedbackInbox {
|
|
113
|
+
return {
|
|
114
|
+
open(identity, transport) {
|
|
115
|
+
const lockDir = hunkConsumerLockDir(identity.cwd);
|
|
116
|
+
const outboxPath = hunkOutboxPath(identity.cwd);
|
|
117
|
+
const deliveredPath = hunkDeliveredPath(identity.cwd);
|
|
118
|
+
const watchDir = hunkWatchDir(identity.cwd);
|
|
119
|
+
const { now, timers, report } = deps;
|
|
120
|
+
|
|
121
|
+
// Provenance fence (§8.58): the family is DISPOSABLE LOCAL state — a git-TRACKED entry
|
|
122
|
+
// under it means checkout-supplied bytes (a force-added outbox/symlink) are posing as
|
|
123
|
+
// live watch feedback. Refuse to open, loudly; nothing under the family is read.
|
|
124
|
+
const tracked = lsFiles(identity.cwd, watchDir);
|
|
125
|
+
if (tracked.length > 0) {
|
|
126
|
+
const reason =
|
|
127
|
+
"tracked file(s) under .perk/workflow/hunk-watch — repository-supplied feedback is " +
|
|
128
|
+
`refused (untrack them to re-enable the bridge): ${tracked.join(", ")}`;
|
|
129
|
+
report("error", reason);
|
|
130
|
+
return { passive: true, reason };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Quarantine sweep first (leftovers from a crashed reclaimer are harmless but dirty),
|
|
134
|
+
// then the lease: a fresh foreign holder means this session never inspects the stream.
|
|
135
|
+
for (const warning of sweepQuarantine(lockDir)) report("warning", warning);
|
|
136
|
+
const lease = acquireLease(
|
|
137
|
+
lockDir,
|
|
138
|
+
{
|
|
139
|
+
runId: identity.runId,
|
|
140
|
+
piSessionId: identity.piSessionId,
|
|
141
|
+
},
|
|
142
|
+
now,
|
|
143
|
+
);
|
|
144
|
+
if (!lease.owned) return { passive: true, reason: lease.reason };
|
|
145
|
+
const token = lease.token;
|
|
146
|
+
|
|
147
|
+
// Accepted-but-unacknowledged suppression lives here too: an id whose ack append failed
|
|
148
|
+
// stays in this set for the rest of the session (may redeliver in a later one — §8.58).
|
|
149
|
+
const deliveredRead = readDeliveredIds(deliveredPath);
|
|
150
|
+
const delivered = deliveredRead.ids;
|
|
151
|
+
|
|
152
|
+
type Phase = "idle" | "awaiting" | "backoff";
|
|
153
|
+
let phase: Phase = "idle";
|
|
154
|
+
let dirty = false;
|
|
155
|
+
let closed = false;
|
|
156
|
+
let inFlight: { batch: readonly FeedbackRecord[]; injectedAt: number } | null = null;
|
|
157
|
+
/** The CURRENT backoff delay; 0 = no failures since the last observation (the only reset). */
|
|
158
|
+
let backoffMs = 0;
|
|
159
|
+
let backoffTimer: unknown = null;
|
|
160
|
+
let debounceTimer: unknown = null;
|
|
161
|
+
let pollHandle: unknown = null;
|
|
162
|
+
let heartbeatHandle: unknown = null;
|
|
163
|
+
let watcher: InboxWatcher | null = null;
|
|
164
|
+
const reportedOnce = new Set<string>();
|
|
165
|
+
const heldPlanIds = new Set<string>();
|
|
166
|
+
|
|
167
|
+
const reportOnce = (severity: "warning" | "error", message: string): void => {
|
|
168
|
+
if (reportedOnce.has(message)) return;
|
|
169
|
+
reportedOnce.add(message);
|
|
170
|
+
report(severity, message);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const close = (): void => {
|
|
174
|
+
if (closed) return;
|
|
175
|
+
closed = true;
|
|
176
|
+
if (debounceTimer !== null) timers.clearTimeout(debounceTimer);
|
|
177
|
+
if (backoffTimer !== null) timers.clearTimeout(backoffTimer);
|
|
178
|
+
if (pollHandle !== null) timers.clearInterval(pollHandle);
|
|
179
|
+
if (heartbeatHandle !== null) timers.clearInterval(heartbeatHandle);
|
|
180
|
+
try {
|
|
181
|
+
watcher?.close();
|
|
182
|
+
} catch {
|
|
183
|
+
// disposal is best-effort
|
|
184
|
+
}
|
|
185
|
+
watcher = null;
|
|
186
|
+
inFlight = null;
|
|
187
|
+
releaseLease(lockDir, token); // removes only on token match
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** Fail-closed shutdown: the lease is no longer provably ours — stop, loudly, once. */
|
|
191
|
+
const closeFailClosed = (): void => {
|
|
192
|
+
reportOnce(
|
|
193
|
+
"error",
|
|
194
|
+
"feedback lease verification failed — closing the hunk feedback inbox (records stay queued for the next eligible session)",
|
|
195
|
+
);
|
|
196
|
+
close();
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const enterBackoff = (): void => {
|
|
200
|
+
// Never resets on dispatch: consecutive failures double toward the cap.
|
|
201
|
+
backoffMs = backoffMs === 0 ? BACKOFF_BASE_MS : Math.min(backoffMs * 2, BACKOFF_CAP_MS);
|
|
202
|
+
phase = "backoff";
|
|
203
|
+
dirty = true;
|
|
204
|
+
backoffTimer = timers.setTimeout(() => {
|
|
205
|
+
backoffTimer = null;
|
|
206
|
+
if (closed) return;
|
|
207
|
+
phase = "idle";
|
|
208
|
+
guard(dispatch);
|
|
209
|
+
}, backoffMs);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const acknowledge = (batch: readonly FeedbackRecord[]): void => {
|
|
213
|
+
const at = new Date(now()).toISOString();
|
|
214
|
+
const acks: DeliveryAck[] = batch.map((record) => ({
|
|
215
|
+
schema: 1,
|
|
216
|
+
feedback_id: record.feedback_id,
|
|
217
|
+
delivered_at: at,
|
|
218
|
+
run_id: identity.runId,
|
|
219
|
+
pi_session_id: identity.piSessionId,
|
|
220
|
+
}));
|
|
221
|
+
try {
|
|
222
|
+
appendAcks(identity.cwd, acks);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
// The message IS on the transcript — suppress same-session redelivery in memory; a
|
|
225
|
+
// later session may redeliver (at-least-once, stated plainly).
|
|
226
|
+
report(
|
|
227
|
+
"warning",
|
|
228
|
+
`could not append feedback acknowledgements (${error}) — delivery stands; the records may redeliver in a later session`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
for (const record of batch) delivered.add(record.feedback_id);
|
|
232
|
+
backoffMs = 0; // the ONLY reset site: transcript observation
|
|
233
|
+
inFlight = null;
|
|
234
|
+
phase = "idle";
|
|
235
|
+
if (dirty) dispatch();
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** One dispatch pass — only ever entered from `idle`. */
|
|
239
|
+
const dispatch = (): void => {
|
|
240
|
+
if (closed || phase !== "idle") return;
|
|
241
|
+
dirty = false;
|
|
242
|
+
const read = readOutbox(outboxPath);
|
|
243
|
+
for (const warning of read.warnings) reportOnce("warning", warning);
|
|
244
|
+
const pending: FeedbackRecord[] = [];
|
|
245
|
+
for (const record of read.records) {
|
|
246
|
+
if (delivered.has(record.feedback_id)) continue;
|
|
247
|
+
if (record.plan_id !== identity.planId) {
|
|
248
|
+
if (!heldPlanIds.has(record.feedback_id)) {
|
|
249
|
+
heldPlanIds.add(record.feedback_id);
|
|
250
|
+
report(
|
|
251
|
+
"warning",
|
|
252
|
+
`holding feedback ${record.feedback_id} addressed to plan ${record.plan_id} — this session implements plan ${identity.planId}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
continue; // held: never delivered, never acked
|
|
256
|
+
}
|
|
257
|
+
pending.push(record);
|
|
258
|
+
}
|
|
259
|
+
if (pending.length === 0) return;
|
|
260
|
+
|
|
261
|
+
// Bounded batch, append order retained; the remainder re-marks dirty.
|
|
262
|
+
const batch: FeedbackRecord[] = [];
|
|
263
|
+
let bytes = 0;
|
|
264
|
+
for (const record of pending) {
|
|
265
|
+
const size = Buffer.byteLength(record.body, "utf8");
|
|
266
|
+
if (
|
|
267
|
+
batch.length > 0 &&
|
|
268
|
+
(batch.length >= BATCH_MAX_RECORDS || bytes + size > BATCH_MAX_BYTES)
|
|
269
|
+
) {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
batch.push(record);
|
|
273
|
+
bytes += size;
|
|
274
|
+
}
|
|
275
|
+
if (batch.length < pending.length) dirty = true;
|
|
276
|
+
|
|
277
|
+
// The delivery fence: verify the lease immediately before every injection.
|
|
278
|
+
if (!verifyLease(lockDir, token)) {
|
|
279
|
+
closeFailClosed();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
if (transport.isInjected(batch)) {
|
|
283
|
+
// A prior injection survives on this branch — acknowledge without re-injecting.
|
|
284
|
+
acknowledge(batch);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
try {
|
|
288
|
+
transport.inject(batch);
|
|
289
|
+
} catch (error) {
|
|
290
|
+
report("warning", `feedback injection refused synchronously (${error}) — backing off`);
|
|
291
|
+
enterBackoff();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
inFlight = { batch, injectedAt: now() };
|
|
295
|
+
phase = "awaiting";
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/** Wrap a background callback: report, never throw into the host session. */
|
|
299
|
+
const guard = (fn: () => void): void => {
|
|
300
|
+
try {
|
|
301
|
+
fn();
|
|
302
|
+
} catch (error) {
|
|
303
|
+
reportOnce("error", `hunk feedback inbox error: ${error}`);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const pollTick = (): void => {
|
|
308
|
+
if (closed) return;
|
|
309
|
+
if (phase === "awaiting" && inFlight !== null) {
|
|
310
|
+
const flight = inFlight;
|
|
311
|
+
if (transport.isInjected(flight.batch)) {
|
|
312
|
+
acknowledge(flight.batch);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (transport.isIdle() && now() - flight.injectedAt >= IN_FLIGHT_MIN_AGE_MS) {
|
|
316
|
+
// The session went idle again without the message landing (an abort-discarded
|
|
317
|
+
// steer queue or a failed turn) — demote: the records return to pending (they are
|
|
318
|
+
// still in the outbox and not delivered) and backoff owns the next dispatch.
|
|
319
|
+
inFlight = null;
|
|
320
|
+
enterBackoff();
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (phase === "idle") {
|
|
325
|
+
dispatch(); // polling is the correctness path — the watcher may be dead
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
dirty = true; // backoff owns the next dispatch; triggers only mark dirty
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const onWatchEvent = (): void => {
|
|
332
|
+
if (closed) return;
|
|
333
|
+
dirty = true;
|
|
334
|
+
if (debounceTimer !== null) timers.clearTimeout(debounceTimer);
|
|
335
|
+
debounceTimer = timers.setTimeout(() => {
|
|
336
|
+
debounceTimer = null;
|
|
337
|
+
if (closed) return;
|
|
338
|
+
if (phase === "idle") guard(dispatch);
|
|
339
|
+
}, DEBOUNCE_MS);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const heartbeatTick = (): void => {
|
|
343
|
+
if (closed) return;
|
|
344
|
+
try {
|
|
345
|
+
renewHeartbeat(lockDir, token, now);
|
|
346
|
+
} catch (error) {
|
|
347
|
+
// The pre-injection verifyLease remains the delivery fence — report, keep going.
|
|
348
|
+
reportOnce("warning", `feedback lease heartbeat failed: ${error}`);
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
for (const warning of deliveredRead.warnings) reportOnce("warning", warning);
|
|
353
|
+
|
|
354
|
+
// The immediate initial drain, then low-latency watch + poll fallback + heartbeat.
|
|
355
|
+
guard(dispatch);
|
|
356
|
+
if (closed) return { close }; // the drain closed fail-closed — install nothing
|
|
357
|
+
try {
|
|
358
|
+
watcher = deps.watch(
|
|
359
|
+
watchDir,
|
|
360
|
+
() => guard(onWatchEvent),
|
|
361
|
+
(error) => {
|
|
362
|
+
guard(() => {
|
|
363
|
+
reportOnce(
|
|
364
|
+
"warning",
|
|
365
|
+
`hunk feedback watcher failed (${error}) — continuing on the polling fallback`,
|
|
366
|
+
);
|
|
367
|
+
try {
|
|
368
|
+
watcher?.close();
|
|
369
|
+
} catch {
|
|
370
|
+
// best-effort
|
|
371
|
+
}
|
|
372
|
+
watcher = null;
|
|
373
|
+
});
|
|
374
|
+
},
|
|
375
|
+
);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
reportOnce(
|
|
378
|
+
"warning",
|
|
379
|
+
`could not watch the feedback outbox (${error}) — continuing on the polling fallback`,
|
|
380
|
+
);
|
|
381
|
+
watcher = null;
|
|
382
|
+
}
|
|
383
|
+
pollHandle = timers.setInterval(() => guard(pollTick), POLL_MS);
|
|
384
|
+
heartbeatHandle = timers.setInterval(() => guard(heartbeatTick), HEARTBEAT_MS);
|
|
385
|
+
|
|
386
|
+
return { close };
|
|
387
|
+
},
|
|
388
|
+
};
|
|
389
|
+
}
|