@mgiles/perk 3.1.0 → 3.3.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 +5 -0
- package/extension/authoring/gist/draft.ts +198 -0
- package/extension/authoring/gist/prose.ts +46 -0
- package/extension/authoring/gist/review.ts +133 -0
- package/extension/authoring/gist/save.ts +118 -0
- package/extension/authoring/objective/draft.ts +345 -0
- package/extension/{factories/objectiveDreamReport.ts → authoring/objective/dreamReportGate.ts} +74 -131
- package/extension/authoring/objective/planning.ts +124 -0
- package/extension/authoring/objective/prose.ts +103 -0
- package/extension/authoring/objective/review.ts +128 -0
- package/extension/authoring/objective/save.ts +224 -0
- package/extension/authoring/plan/draft.ts +84 -0
- package/extension/authoring/plan/prose.ts +41 -0
- package/extension/authoring/plan/review.ts +269 -0
- package/extension/authoring/plan/save.ts +256 -0
- package/extension/authoring/plan/source.ts +82 -0
- package/extension/authoring/refinement/context.ts +468 -0
- package/extension/authoring/refinement/draft.ts +261 -0
- package/extension/authoring/refinement/prose.ts +79 -0
- package/extension/authoring/refinement/review.ts +111 -0
- package/extension/authoring/refinement/save.ts +119 -0
- package/extension/authoring/review/approvalGate.ts +34 -0
- package/extension/authoring/review/draftContext.ts +68 -0
- package/extension/codeReview/automated.ts +352 -0
- package/extension/codeReview/submission.ts +229 -0
- package/extension/delivery/address.ts +295 -0
- package/extension/delivery/ci.ts +355 -0
- package/extension/delivery/commitCompact.ts +93 -0
- package/extension/delivery/conflictResolution.ts +247 -0
- package/extension/delivery/ready.ts +193 -0
- package/extension/delivery/stackConflict.ts +361 -0
- package/extension/delivery/stackObjective.ts +16 -0
- package/extension/delivery/stackReconcile.ts +165 -0
- package/extension/delivery/submit.ts +171 -0
- package/extension/index.ts +369 -359
- package/extension/learning/analystWave.ts +324 -0
- package/extension/learning/audit.ts +667 -0
- package/extension/learning/capture.ts +92 -0
- package/extension/learning/containment.ts +104 -0
- package/extension/{waves/dreamWave.ts → learning/dream.ts} +112 -94
- package/extension/learning/dreamAnalysis.ts +435 -0
- package/extension/{waves/dreamReducerWave.ts → learning/dreamReducer.ts} +46 -41
- package/extension/{waves → learning}/dreamReport.ts +35 -31
- package/extension/learning/harvest.ts +491 -0
- package/extension/learning/prose.ts +66 -0
- package/extension/learning/routing.ts +79 -0
- package/extension/pi/v1/bashScanTimeout.ts +64 -0
- package/extension/{doors/prReview.ts → pi/v1/codeReview/automated.ts} +215 -310
- package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +125 -57
- package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
- package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +169 -116
- package/extension/pi/v1/codeReview/stack.ts +606 -0
- package/extension/pi/v1/codeReview/submit.ts +354 -0
- package/extension/{doors/prReviewTerminal.ts → pi/v1/codeReview/terminal.ts} +32 -27
- package/extension/pi/v1/contextEvidence.ts +80 -0
- package/extension/pi/v1/contextInjection.ts +207 -0
- package/extension/{doors → pi/v1/delivery}/address.ts +163 -265
- package/extension/pi/v1/delivery/ci.ts +570 -0
- package/extension/pi/v1/delivery/commitCompact.ts +201 -0
- package/extension/pi/v1/delivery/conflictResolverEngine.ts +425 -0
- package/extension/{doors → pi/v1/delivery}/land.ts +128 -60
- package/extension/pi/v1/delivery/ready.ts +322 -0
- package/extension/pi/v1/delivery/stackConflictResolver.ts +172 -0
- package/extension/pi/v1/delivery/stackDrive.ts +120 -0
- package/extension/pi/v1/delivery/stackLand.ts +223 -0
- package/extension/pi/v1/delivery/stackRecover.ts +265 -0
- package/extension/pi/v1/delivery/stackStatus.ts +237 -0
- package/extension/pi/v1/delivery/stackSync.ts +658 -0
- package/extension/pi/v1/delivery/submit.ts +389 -0
- package/extension/pi/v1/delivery/submitConflict.ts +186 -0
- package/extension/pi/v1/draftReview.ts +431 -0
- package/extension/{doors → pi/v1}/draftReviewWaveTools.ts +141 -151
- package/extension/pi/v1/gist.ts +794 -0
- package/extension/pi/v1/learning/audit.ts +186 -0
- package/extension/pi/v1/learning/dream.ts +207 -0
- package/extension/{doors/learnFactory.ts → pi/v1/learning/factory.ts} +18 -65
- package/extension/{doors/harvestWaveTools.ts → pi/v1/learning/harvest.ts} +46 -100
- package/extension/pi/v1/learning/learn.ts +585 -0
- package/extension/{doors → pi/v1}/lifecycleGates.ts +14 -59
- package/extension/{factories → pi/v1}/objective.ts +53 -33
- package/extension/pi/v1/objectiveAuthoring.ts +672 -0
- package/extension/pi/v1/objectiveDreamGate.ts +160 -0
- package/extension/pi/v1/objectivePlanning.ts +776 -0
- package/extension/pi/v1/objectiveRefinement.ts +1320 -0
- package/extension/pi/v1/objectiveReview.ts +451 -0
- package/extension/{doors → pi/v1}/objectiveReviewBrowser.ts +259 -172
- package/extension/pi/v1/plan.ts +812 -0
- package/extension/pi/v1/planReview.ts +820 -0
- package/extension/{doors → pi/v1}/planReviewBrowser.ts +228 -152
- package/extension/{doors/annotationPush.ts → pi/v1/providers/annotations.ts} +158 -89
- package/extension/pi/v1/providers/plannotator.ts +487 -0
- package/extension/{doors → pi/v1/providers}/plannotatorHandoff.ts +152 -34
- package/extension/pi/v1/providers/selection.ts +43 -0
- package/extension/{adapters/planAdapterTombell.ts → pi/v1/providers/tombell.ts} +43 -72
- package/extension/pi/v1/review.ts +538 -0
- package/extension/pi/v1/reviewOutcome.ts +9 -0
- package/extension/pi/v1/scoutWave.ts +318 -0
- package/extension/{doors → pi/v1}/selfcheck.ts +4 -4
- package/extension/session/branchWorkflowSession.ts +60 -0
- package/extension/session/lifecycle.ts +644 -0
- package/extension/session/lifecycleGates.ts +64 -0
- package/extension/session/saveDestination.ts +87 -0
- package/extension/session/workflowSession.ts +971 -0
- package/extension/substrate/agentScratch.ts +27 -54
- package/extension/substrate/bashScanTimeout.ts +181 -0
- package/extension/substrate/bindingDelivery.ts +38 -30
- package/extension/substrate/bindings.ts +4 -5
- package/extension/substrate/cache.ts +64 -12
- package/extension/substrate/childRestrictions.ts +39 -0
- package/extension/substrate/coldDoor.ts +17 -1
- package/extension/substrate/config.ts +157 -19
- package/extension/substrate/git.ts +88 -6
- package/extension/substrate/modelVisible.ts +53 -0
- package/extension/substrate/paths.ts +2 -7
- package/extension/substrate/prompts.ts +22 -0
- package/extension/substrate/registry.ts +2 -0
- package/extension/substrate/resolverLease.ts +364 -0
- package/extension/substrate/sessionData.ts +85 -152
- package/extension/substrate/toolGating.ts +279 -84
- package/extension/substrate/unifiedDiff.ts +1 -1
- package/extension/substrate/workflowState.ts +191 -166
- package/extension/substrate/worktreeResolverLock.ts +261 -0
- package/extension/surfaces/surfaces.ts +79 -27
- package/extension/waves/adversarialReviewWave.ts +103 -48
- package/extension/waves/blockedReports.ts +59 -0
- package/extension/waves/draftReviewWave.ts +42 -42
- package/extension/waves/laneIdentity.ts +77 -0
- package/extension/waves/objectiveExplorerWave.ts +24 -24
- package/extension/waves/prReviewWave.ts +89 -77
- package/extension/waves/reportWave.ts +438 -578
- package/extension/waves/reviewClassifierWave.ts +22 -22
- package/extension/waves/rpcAdapter.ts +100 -15
- package/extension/waves/scoutWave.ts +192 -0
- package/extension/waves/transport.ts +480 -0
- package/extension/worker/sdkAdapter.ts +494 -0
- package/extension/worker/stageExecution.ts +679 -0
- package/extension/workerMain.ts +18 -19
- package/package.json +6 -4
- package/prompts/_fixtures/live.yaml +98 -10
- package/prompts/contexts/adapters/plannotator-gist.md +6 -0
- package/prompts/contexts/adapters/plannotator-objective.md +6 -0
- package/prompts/contexts/adapters/plannotator-plan.md +8 -1
- package/prompts/contexts/adapters/plannotator-refinement.md +22 -0
- package/prompts/contexts/adapters/tombell-plan.md +4 -0
- package/prompts/contexts/objective-refinement.md +17 -0
- package/prompts/contexts/plan-authoring.md +6 -5
- package/prompts/contexts/read-only.md +1 -1
- package/prompts/stages/conflict-resolution-continuation.md +9 -0
- package/prompts/stages/conflict-resolution.md +4 -4
- package/prompts/stages/objective-author/adopt.md +1 -1
- package/prompts/stages/objective-author/file.md +1 -1
- package/prompts/stages/objective-author/seed.md +1 -1
- package/prompts/stages/objective-plan/guidance.md +2 -2
- package/prompts/stages/objective-plan/seed.md +9 -1
- package/prompts/stages/objective-reconcile-ready.md +7 -0
- package/prompts/stages/objective-reconcile.md +1 -1
- package/prompts/stages/objective-refine/seed.md +18 -0
- package/prompts/stages/objective-review-browser.md +4 -4
- package/prompts/stages/objective-sync.md +1 -1
- package/prompts/stages/plan-review-browser.md +4 -4
- package/prompts/stages/pr-review-browser/active.md +3 -4
- package/prompts/stages/pr-review-browser/foreign.md +3 -4
- package/prompts/stages/pr-review-terminal/active.md +3 -3
- package/prompts/stages/pr-review-terminal/foreign.md +3 -3
- package/prompts/stages/pr-review.md +3 -3
- package/prompts/stages/stack-review/cold.md +1 -0
- package/prompts/stages/stack-review-browser/stack.md +22 -0
- package/shared/README.md +8 -3
- package/shared/bindings.yaml +6 -3
- package/shared/contracts.md +4499 -2147
- package/shared/fixtures/issues-table.json +130 -0
- package/shared/registry.yaml +29 -1
- package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +178 -2
- package/shared/schemas/outputs/pr-ready.schema.json +110 -2
- package/shared/schemas/outputs/pr-review-context.schema.json +54 -9
- package/shared/schemas/outputs/pr-review-stack-context.schema.json +196 -0
- package/extension/adapters/planAdapterPlannotator.ts +0 -362
- package/extension/doors/auditWaveTools.ts +0 -352
- package/extension/doors/ciExecutor.ts +0 -756
- package/extension/doors/commitCompact.ts +0 -251
- package/extension/doors/dreamWaveTools.ts +0 -475
- package/extension/doors/learn.ts +0 -655
- package/extension/doors/objectiveStack.ts +0 -1143
- package/extension/doors/prReviewDynamic.ts +0 -276
- package/extension/doors/ready.ts +0 -87
- package/extension/doors/submit.ts +0 -347
- package/extension/doors/submitPrReview.ts +0 -408
- package/extension/factories/gistAuthor.ts +0 -94
- package/extension/factories/gistDraft.ts +0 -265
- package/extension/factories/gistSave.ts +0 -251
- package/extension/factories/implementHere.ts +0 -116
- package/extension/factories/objectiveAuthor.ts +0 -98
- package/extension/factories/objectiveDraft.ts +0 -466
- package/extension/factories/objectivePlan.ts +0 -975
- package/extension/factories/objectiveSave.ts +0 -363
- package/extension/factories/planDraft.ts +0 -140
- package/extension/factories/planMode.ts +0 -205
- package/extension/factories/planReview.ts +0 -1237
- package/extension/factories/planSave.ts +0 -604
- package/extension/factories/planTitle.ts +0 -141
- package/extension/substrate/structuredOutput.ts +0 -202
- package/extension/waves/auditWave.ts +0 -312
- package/extension/waves/harvestWave.ts +0 -399
- package/extension/waves/learnWave.ts +0 -155
- package/extension/waves/memoryAdapter.ts +0 -139
- package/extension/waves/prReviewDynamicWave.ts +0 -777
- package/extension/worker/readOnlySession.ts +0 -294
- package/extension/worker/worker.ts +0 -899
- package/prompts/stages/pr-review-dynamic.md +0 -7
- package/shared/contracts-history.md +0 -605
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The plan feature's model-facing prose units: the plan-authoring context content builder and
|
|
2
|
+
// the injection marker/customType constants. Prose-unit MEANING lives here (the feature); the
|
|
3
|
+
// v1 adapter only PLACES these units in Pi context payloads (module-contracts.md's prose
|
|
4
|
+
// split). The tool registration prose (descriptions + promptGuidelines) deliberately stays
|
|
5
|
+
// INLINE at the `pi/v1/plan.ts` registration sites — the prose-review workbench edits
|
|
6
|
+
// registration prose through the TypeScript source adapter, which needs literal in-place
|
|
7
|
+
// arrays (an identifier indirection is an unsupported source shape there). Prompting, NOT
|
|
8
|
+
// enforcement — the read-only tool gate is the enforcement.
|
|
9
|
+
//
|
|
10
|
+
// Carve-out doctrine (mirrors `authoring/gist/`): this home is Pi-free (guard Rule D) — the
|
|
11
|
+
// adapter passes config-derived values (the `[workflow] plan_authoring` addendum) in as plain
|
|
12
|
+
// strings; nothing here reads Pi, the config loader, or the surfaces seam.
|
|
13
|
+
|
|
14
|
+
import { render } from "../../substrate/prompts.ts";
|
|
15
|
+
|
|
16
|
+
/** The plan-authoring context customType (distinct from the gate's `perk:mode-context`). */
|
|
17
|
+
export const PLAN_CONTEXT_TYPE = "perk:plan-context";
|
|
18
|
+
|
|
19
|
+
/** The injected plan-authoring context's identity marker (the strip + dedup key). */
|
|
20
|
+
export const PLAN_MARKER = "[PLAN AUTHORING]";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The cooperative gather-then-plan contract. This is prompting, NOT enforcement (the gate is the
|
|
24
|
+
* enforcement). It never leaks internal policy text — it tells the model how to materialize a
|
|
25
|
+
* decision-complete plan an executor with zero prior context can follow. Per contracts.md §8.57
|
|
26
|
+
* this mode context is the plan stage's DESIGNATED FLOW CARRIER in every plan-stage session
|
|
27
|
+
* shape (seeded doors carry launch-shape deltas only; the `perk-plan` skill is the detail tier).
|
|
28
|
+
* Durable anchors only, no line numbers.
|
|
29
|
+
*/
|
|
30
|
+
export const PLAN_AUTHORING_CONTEXT = render("contexts/plan-authoring.md", {
|
|
31
|
+
marker: PLAN_MARKER,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build the full plan-authoring injection, appending the project-config authoring addendum when
|
|
36
|
+
* present. Pure over the addendum — the ADAPTER loads `[workflow] plan_authoring` per event and
|
|
37
|
+
* passes the value in (the narrow-views doctrine).
|
|
38
|
+
*/
|
|
39
|
+
export function planAuthoringContextContent(addendum: string | undefined): string {
|
|
40
|
+
return addendum ? `${PLAN_AUTHORING_CONTEXT}\n\n${addendum.trim()}` : PLAN_AUTHORING_CONTEXT;
|
|
41
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// The plan review feature: the `PlanDraftReviewer` role (one production adapter per review
|
|
2
|
+
// backend — plannotator bridge or first-party editor, both built in pi/v1 — plus a scripted fake
|
|
3
|
+
// in the tests), the reviewer-edits mechanical-apply ladder, and the one-entry `reviewPlanDraft`
|
|
4
|
+
// operation.
|
|
5
|
+
//
|
|
6
|
+
// Ordering is the review door's exact discipline: resolve FIRST (artifact → param ONLY — the
|
|
7
|
+
// review surface never sees a transcript tier; none ⇒ `noPlan`) → review → the abort checkpoint
|
|
8
|
+
// → route the verdict. Provider vocabulary (plannotator's `# Direct Edits` prose) is translated
|
|
9
|
+
// INTO `PlanReviewOutcome` at the adapter — the feature sees typed variants only.
|
|
10
|
+
//
|
|
11
|
+
// Cancellation ownership: `signal?.aborted` is checked at entry AND re-checked immediately
|
|
12
|
+
// after `reviewer.review` resolves, BEFORE any effect (apply ladder, save, gate exit) — abort ⇒
|
|
13
|
+
// the `aborted` arm with nothing saved and the gate untouched. The backend save itself is not
|
|
14
|
+
// abortable mid-flight (today's behavior, stated in save.ts).
|
|
15
|
+
//
|
|
16
|
+
// The gate-safety invariant is FEATURE-OWNED: an `implementHere` verdict under
|
|
17
|
+
// `allowImplementHere: false` (an objective-node planning session — a node-linked plan must
|
|
18
|
+
// save) REFUSES with the distinct `implementHereRefused` arm — nothing saved, gate untouched.
|
|
19
|
+
// The adapter's verdict suppression under a node claim remains the UX layer (the option is
|
|
20
|
+
// never offered); this arm is the defensive backstop.
|
|
21
|
+
|
|
22
|
+
import type { WorkflowSession } from "../../session/workflowSession.ts";
|
|
23
|
+
import { applyUnifiedDiff } from "../../substrate/unifiedDiff.ts";
|
|
24
|
+
import type { ApprovalGate } from "../review/approvalGate.ts";
|
|
25
|
+
import { PLAN_DRAFT_ARTIFACT, resumePlanDraft } from "./draft.ts";
|
|
26
|
+
import {
|
|
27
|
+
type PlanApprovalSaveOutcome,
|
|
28
|
+
type PlanBackend,
|
|
29
|
+
type PlanSaveDeps,
|
|
30
|
+
planApprovalSave,
|
|
31
|
+
} from "./save.ts";
|
|
32
|
+
import { resolvePlanSource } from "./source.ts";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The reviewer's verdict on the plan bytes. `approvedDirectEdits` carries the reviewer's
|
|
36
|
+
* extracted unified diff (`remainder` is the annotation feedback surviving after the applied
|
|
37
|
+
* section is stripped; `rawFeedback` the FULL original — the fail-open ladder saves verbatim
|
|
38
|
+
* with it). `approvedEditsUnparseable` is the seen-heading/unextractable-diff arm: the section
|
|
39
|
+
* was seen but cannot be honored — verbatim save of the ORIGINAL bytes with the FULL feedback
|
|
40
|
+
* preserved and the loud `directEditsFailed` flag.
|
|
41
|
+
*/
|
|
42
|
+
export type PlanReviewOutcome =
|
|
43
|
+
| { status: "approved"; feedback?: string; reviewId?: string }
|
|
44
|
+
| {
|
|
45
|
+
status: "approvedDirectEdits";
|
|
46
|
+
diff: string;
|
|
47
|
+
remainder?: string;
|
|
48
|
+
rawFeedback: string;
|
|
49
|
+
reviewId?: string;
|
|
50
|
+
}
|
|
51
|
+
| { status: "approvedEditsUnparseable"; rawFeedback: string; reviewId?: string }
|
|
52
|
+
| { status: "denied"; feedback?: string; reviewId?: string }
|
|
53
|
+
| { status: "implementHere"; reviewId: string }
|
|
54
|
+
| { status: "dismissed" }
|
|
55
|
+
| { status: "aborted" }
|
|
56
|
+
| { status: "unavailable"; warning: string };
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What one review round produced: the typed outcome, the FINAL reviewed bytes (`plan`), and
|
|
60
|
+
* whether the human edited them. The first-party reviewer adapter writes human edits back to
|
|
61
|
+
* the draft INSIDE the reviewer (before the verdict — a failed write-back is the `unavailable`
|
|
62
|
+
* abort), so `plan` is the final reviewed bytes and `edited` the differ bit; the plannotator
|
|
63
|
+
* reviewer returns the input unchanged (`edited: false` — browser edits arrive as the Direct
|
|
64
|
+
* Edits diff on the outcome, applied feature-side).
|
|
65
|
+
*/
|
|
66
|
+
export type PlanDraftReviewResult = { outcome: PlanReviewOutcome; plan: string; edited: boolean };
|
|
67
|
+
|
|
68
|
+
/** The reviewer role: judge the resolved plan bytes. */
|
|
69
|
+
export interface PlanDraftReviewer {
|
|
70
|
+
review(plan: string, signal?: AbortSignal): Promise<PlanDraftReviewResult>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The plan-arm mechanical-apply ladder for reviewer edits: strict `applyUnifiedDiff` → draft
|
|
75
|
+
* write-back through the session (reviewed bytes == artifact bytes == saved bytes). Every rung
|
|
76
|
+
* fails open — the caller renders the loud warning and saves verbatim. Never throws.
|
|
77
|
+
*/
|
|
78
|
+
export function applyReviewerEdits(
|
|
79
|
+
session: WorkflowSession,
|
|
80
|
+
basePlan: string,
|
|
81
|
+
edits: { diff: string },
|
|
82
|
+
): { status: "applied"; plan: string } | { status: "failed" } {
|
|
83
|
+
const patched = applyUnifiedDiff(basePlan, edits.diff);
|
|
84
|
+
if (patched === null) return { status: "failed" };
|
|
85
|
+
const written = session.writeArtifact(PLAN_DRAFT_ARTIFACT, patched);
|
|
86
|
+
if (written.status !== "applied" && written.status !== "unchanged") return { status: "failed" };
|
|
87
|
+
return { status: "applied", plan: patched };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The review-operation dependency bag (the adapter composes production values). */
|
|
91
|
+
export interface ReviewPlanDraftDeps extends PlanSaveDeps {
|
|
92
|
+
reviewer: PlanDraftReviewer;
|
|
93
|
+
backend: PlanBackend;
|
|
94
|
+
gate: ApprovalGate;
|
|
95
|
+
/** The `plan` param fallback (the artifact wins; review mode never sees a transcript). */
|
|
96
|
+
explicit?: string;
|
|
97
|
+
/** False in an objective-node planning session — the no-save exit refuses there. */
|
|
98
|
+
allowImplementHere: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The plan-arm save flags every approved arm carries (the adapter's rendering opts). */
|
|
102
|
+
interface ApprovedFlags {
|
|
103
|
+
feedback?: string;
|
|
104
|
+
reviewId?: string;
|
|
105
|
+
paramMismatch: boolean;
|
|
106
|
+
edited: boolean;
|
|
107
|
+
directEditsFailed: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The one-entry review outcome — each arm carries exactly what its caller renders. */
|
|
111
|
+
export type ReviewPlanDraftResult =
|
|
112
|
+
| { status: "noPlan" }
|
|
113
|
+
| ({
|
|
114
|
+
status: "approvedSaved";
|
|
115
|
+
save: Extract<PlanApprovalSaveOutcome, { status: "saved" }>;
|
|
116
|
+
} & ApprovedFlags)
|
|
117
|
+
| ({
|
|
118
|
+
status: "approvedSaveFailed";
|
|
119
|
+
save: Extract<PlanApprovalSaveOutcome, { status: "save-failed" }>;
|
|
120
|
+
} & ApprovedFlags)
|
|
121
|
+
| ({ status: "approvedNoPlan" } & ApprovedFlags)
|
|
122
|
+
| {
|
|
123
|
+
status: "implementHere";
|
|
124
|
+
reviewId: string;
|
|
125
|
+
gateExited: boolean;
|
|
126
|
+
plan: string;
|
|
127
|
+
edited: boolean;
|
|
128
|
+
}
|
|
129
|
+
| { status: "implementHereRefused" }
|
|
130
|
+
| { status: "denied"; feedback?: string; reviewId?: string }
|
|
131
|
+
| { status: "dismissed" }
|
|
132
|
+
| { status: "aborted" }
|
|
133
|
+
| { status: "unavailable"; warning: string };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Review the working plan draft end-to-end: resolve (artifact → param ONLY; none ⇒ `noPlan`) →
|
|
137
|
+
* review → the abort checkpoint → route. Routing: `approvedDirectEdits` ⇒ the apply ladder
|
|
138
|
+
* (success ⇒ save the EDITED bytes with `edited: true` + remainder-only feedback; failure ⇒
|
|
139
|
+
* verbatim save + `directEditsFailed`) · `approvedEditsUnparseable` ⇒ verbatim save + the FULL
|
|
140
|
+
* raw feedback + `directEditsFailed` (fail-open-but-loud) · `approved` ⇒ `planApprovalSave`
|
|
141
|
+
* with the reviewed bytes (`edited` rides into the flags) · `implementHere` ⇒ gate exit WITHOUT
|
|
142
|
+
* save when allowed, else the `implementHereRefused` refusal · everything else passes through.
|
|
143
|
+
* Never throws.
|
|
144
|
+
*/
|
|
145
|
+
export async function reviewPlanDraft(
|
|
146
|
+
deps: ReviewPlanDraftDeps,
|
|
147
|
+
signal?: AbortSignal,
|
|
148
|
+
): Promise<ReviewPlanDraftResult> {
|
|
149
|
+
if (signal?.aborted) return { status: "aborted" };
|
|
150
|
+
const src = resolvePlanSource(
|
|
151
|
+
{ draft: resumePlanDraft(deps.session), explicit: deps.explicit },
|
|
152
|
+
"review",
|
|
153
|
+
);
|
|
154
|
+
if (src === null) return { status: "noPlan" };
|
|
155
|
+
|
|
156
|
+
const result = await deps.reviewer.review(src.plan, signal);
|
|
157
|
+
// The abort checkpoint: a turn interrupted while the reviewer ran must produce NO effect —
|
|
158
|
+
// no apply ladder, no save, no gate exit (the aborted arm wins over any verdict).
|
|
159
|
+
if (signal?.aborted) return { status: "aborted" };
|
|
160
|
+
return completePlanReview(deps, result, {
|
|
161
|
+
source: src.source === "plan-draft" ? "plan-draft" : "param",
|
|
162
|
+
paramMismatch: src.paramMismatch,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Subject policy only: callers authorize effects before entering this completion seam. */
|
|
167
|
+
export async function completePlanReview(
|
|
168
|
+
deps: Omit<ReviewPlanDraftDeps, "reviewer" | "explicit">,
|
|
169
|
+
result: PlanDraftReviewResult,
|
|
170
|
+
source: { source: "plan-draft" | "param"; paramMismatch: boolean },
|
|
171
|
+
): Promise<ReviewPlanDraftResult> {
|
|
172
|
+
const outcome = result.outcome;
|
|
173
|
+
const approvalSave = (reviewedPlan: string): Promise<PlanApprovalSaveOutcome> =>
|
|
174
|
+
planApprovalSave(deps, { boundSource: { plan: reviewedPlan, ...source } });
|
|
175
|
+
const flags = (
|
|
176
|
+
partial: Pick<ApprovedFlags, "feedback" | "reviewId"> &
|
|
177
|
+
Partial<Pick<ApprovedFlags, "edited" | "directEditsFailed">>,
|
|
178
|
+
): ApprovedFlags => ({
|
|
179
|
+
...(partial.feedback !== undefined ? { feedback: partial.feedback } : {}),
|
|
180
|
+
...(partial.reviewId !== undefined ? { reviewId: partial.reviewId } : {}),
|
|
181
|
+
paramMismatch: source.paramMismatch,
|
|
182
|
+
edited: partial.edited ?? false,
|
|
183
|
+
directEditsFailed: partial.directEditsFailed ?? false,
|
|
184
|
+
});
|
|
185
|
+
const savedResult = (save: PlanApprovalSaveOutcome, f: ApprovedFlags): ReviewPlanDraftResult => {
|
|
186
|
+
switch (save.status) {
|
|
187
|
+
case "saved":
|
|
188
|
+
return { status: "approvedSaved", save, ...f };
|
|
189
|
+
case "save-failed":
|
|
190
|
+
return { status: "approvedSaveFailed", save, ...f };
|
|
191
|
+
case "no-plan":
|
|
192
|
+
return { status: "approvedNoPlan", ...f };
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
switch (outcome.status) {
|
|
197
|
+
case "approvedDirectEdits": {
|
|
198
|
+
// Mechanically apply the reviewer's diff to the exact bytes reviewed, write the patched
|
|
199
|
+
// bytes back to the draft, and save THOSE (only the annotation remainder survives as
|
|
200
|
+
// feedback — the applied diff must never render as "apply these exact changes" guidance).
|
|
201
|
+
// Any rung failing falls open to the verbatim save + the loud directEditsFailed flag.
|
|
202
|
+
const applied = applyReviewerEdits(deps.session, result.plan, { diff: outcome.diff });
|
|
203
|
+
if (applied.status === "applied") {
|
|
204
|
+
return savedResult(
|
|
205
|
+
await approvalSave(applied.plan),
|
|
206
|
+
flags({
|
|
207
|
+
...(outcome.remainder !== undefined ? { feedback: outcome.remainder } : {}),
|
|
208
|
+
...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
|
|
209
|
+
edited: true,
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return savedResult(
|
|
214
|
+
await approvalSave(result.plan),
|
|
215
|
+
flags({
|
|
216
|
+
feedback: outcome.rawFeedback,
|
|
217
|
+
...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
|
|
218
|
+
directEditsFailed: true,
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
case "approvedEditsUnparseable":
|
|
223
|
+
// The seen-heading/unextractable-diff arm: verbatim save of the ORIGINAL bytes, the FULL
|
|
224
|
+
// feedback preserved, plus the loud warning flag (fail-open-but-loud).
|
|
225
|
+
return savedResult(
|
|
226
|
+
await approvalSave(result.plan),
|
|
227
|
+
flags({
|
|
228
|
+
feedback: outcome.rawFeedback,
|
|
229
|
+
...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
|
|
230
|
+
directEditsFailed: true,
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
case "approved":
|
|
234
|
+
return savedResult(
|
|
235
|
+
await approvalSave(result.plan),
|
|
236
|
+
flags({
|
|
237
|
+
...(outcome.feedback !== undefined ? { feedback: outcome.feedback } : {}),
|
|
238
|
+
...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
|
|
239
|
+
edited: result.edited,
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
case "implementHere": {
|
|
243
|
+
if (!deps.allowImplementHere) return { status: "implementHereRefused" };
|
|
244
|
+
// The sanctioned no-save exit (contracts §8.23): gate off WITHOUT saving; the draft
|
|
245
|
+
// artifact stays intact so /plan-save can still create the canonical issue later.
|
|
246
|
+
const wasActive = deps.gate.isActive();
|
|
247
|
+
if (wasActive) deps.gate.exit();
|
|
248
|
+
return {
|
|
249
|
+
status: "implementHere",
|
|
250
|
+
reviewId: outcome.reviewId,
|
|
251
|
+
gateExited: wasActive,
|
|
252
|
+
plan: result.plan,
|
|
253
|
+
edited: result.edited,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
case "denied":
|
|
257
|
+
return {
|
|
258
|
+
status: "denied",
|
|
259
|
+
...(outcome.feedback !== undefined ? { feedback: outcome.feedback } : {}),
|
|
260
|
+
...(outcome.reviewId !== undefined ? { reviewId: outcome.reviewId } : {}),
|
|
261
|
+
};
|
|
262
|
+
case "dismissed":
|
|
263
|
+
return { status: "dismissed" };
|
|
264
|
+
case "aborted":
|
|
265
|
+
return { status: "aborted" };
|
|
266
|
+
case "unavailable":
|
|
267
|
+
return { status: "unavailable", warning: outcome.warning };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// The plan save feature: the narrow exterior `PlanBackend` port (one production adapter — the
|
|
2
|
+
// `perk plan save` cold door in pi/v1 — plus one deterministic fake in the tests: the port
|
|
3
|
+
// admission rule), the `savePlan` operation, and the shared APPROVED-review → save orchestration
|
|
4
|
+
// `planApprovalSave` (the exported name contracts.md §8.23 pins — the `gistApprovalSave`
|
|
5
|
+
// mirror).
|
|
6
|
+
//
|
|
7
|
+
// Identity-less saves stay legal and fully specified: `session.runId === null` ⇒ the backend
|
|
8
|
+
// sees `runId: null` (the adapter omits `--run-id`), pointer capture no-ops in the adapter, and
|
|
9
|
+
// the LINKAGE + CLAIM ops still run — workflow-state appends are branch-backed and
|
|
10
|
+
// identity-independent (today's behavior, preserved). Backend saves are not abortable
|
|
11
|
+
// mid-flight (today's behavior — stated, not changed).
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
PlanRef,
|
|
15
|
+
WorkflowChangeResult,
|
|
16
|
+
WorkflowSession,
|
|
17
|
+
} from "../../session/workflowSession.ts";
|
|
18
|
+
import { type ApprovalGate, saveThroughApprovalGate } from "../review/approvalGate.ts";
|
|
19
|
+
import { resumePlanDraft } from "./draft.ts";
|
|
20
|
+
import { type PlanSource, resolvePlanSource } from "./source.ts";
|
|
21
|
+
|
|
22
|
+
/** The atomic objective node→plan commit surfaced by `perk plan save`. */
|
|
23
|
+
export interface ObjectiveNodeLink {
|
|
24
|
+
linked: boolean;
|
|
25
|
+
node: string | null;
|
|
26
|
+
status: string | null;
|
|
27
|
+
error: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The backend save facts (`ref.pr_id` is the opaque string issue id — contracts §8.21). */
|
|
31
|
+
export type PlanBackendSaveResult =
|
|
32
|
+
| {
|
|
33
|
+
status: "saved";
|
|
34
|
+
ref: PlanRef;
|
|
35
|
+
existed: boolean | null;
|
|
36
|
+
updated: boolean;
|
|
37
|
+
cached: boolean;
|
|
38
|
+
nodeLink: ObjectiveNodeLink | null;
|
|
39
|
+
}
|
|
40
|
+
| { status: "failed"; message: string; errorType: string };
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The narrow exterior port the save operation writes through. `runId: null` means the caller
|
|
44
|
+
* has no session identity — the backend omits its run linkage (an identity-less save keeps
|
|
45
|
+
* working).
|
|
46
|
+
*/
|
|
47
|
+
export interface PlanBackend {
|
|
48
|
+
save(req: {
|
|
49
|
+
plan: string;
|
|
50
|
+
title?: string;
|
|
51
|
+
runId: string | null;
|
|
52
|
+
objectiveId?: string;
|
|
53
|
+
nodeId?: string;
|
|
54
|
+
consumedLearn?: string[];
|
|
55
|
+
}): Promise<PlanBackendSaveResult>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The save outcome. The saved arm carries the backend facts PLUS the resolution facts
|
|
60
|
+
* (`source`/`paramMismatch` ride through for the adapter's message assembly) and the two
|
|
61
|
+
* workflow-state results — the session seam's own `WorkflowChangeResult` values passed through
|
|
62
|
+
* VERBATIM (lossless by construction; `null` means not attempted: `linkage` when the save
|
|
63
|
+
* failed before it, `claimClear` when there was no matching claim / no node link). The
|
|
64
|
+
* production adapter's rendering ignores both fields (byte-stable output); they exist so the
|
|
65
|
+
* feature outcome is honest and the direct feature tests can pin every arm — the seam's type,
|
|
66
|
+
* not new API surface.
|
|
67
|
+
*/
|
|
68
|
+
export type SavePlanOutcome =
|
|
69
|
+
| {
|
|
70
|
+
status: "saved";
|
|
71
|
+
ref: PlanRef;
|
|
72
|
+
existed: boolean | null;
|
|
73
|
+
updated: boolean;
|
|
74
|
+
cached: boolean;
|
|
75
|
+
nodeLink: ObjectiveNodeLink | null;
|
|
76
|
+
source: PlanSource | null;
|
|
77
|
+
paramMismatch: boolean;
|
|
78
|
+
linkage: WorkflowChangeResult | null;
|
|
79
|
+
claimClear: WorkflowChangeResult | null;
|
|
80
|
+
}
|
|
81
|
+
| { status: "failed"; message: string; errorType: string };
|
|
82
|
+
|
|
83
|
+
/** The dependency bag the save operation runs over (the adapter composes production values). */
|
|
84
|
+
export interface PlanSaveDeps {
|
|
85
|
+
session: WorkflowSession;
|
|
86
|
+
backend: PlanBackend;
|
|
87
|
+
/** Best-effort planning-pointer capture (contracts §8.35; no-ops on absent identity). */
|
|
88
|
+
capturePlanningPointer(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The single save operation every plan-save surface calls. Ordering preserved exactly: validate
|
|
93
|
+
* the non-blank plan → resolve the title (an explicit title is trimmed and forwarded; else omitted
|
|
94
|
+
* so the cold door derives it) → warm node-claim recovery (BOTH link params absent ⇒ fill both from
|
|
95
|
+
* `session.nodeClaim()`; any explicit value — even one — wins outright, never mixed) →
|
|
96
|
+
* `backend.save` → `capturePlanningPointer()` (best-effort thunk) → link the live session
|
|
97
|
+
* (`apply({kind:"link-plan-ref"})` — append iff the ref differs) → on a node-linked save whose
|
|
98
|
+
* FULL claim identity matches (resolved objective + linked node), clear the claim
|
|
99
|
+
* (`apply({kind:"clear-node-claim"})` — an unrelated claim is never clobbered). Never throws.
|
|
100
|
+
*/
|
|
101
|
+
export async function savePlan(
|
|
102
|
+
input: {
|
|
103
|
+
plan: string;
|
|
104
|
+
title?: string;
|
|
105
|
+
objectiveId?: string;
|
|
106
|
+
nodeId?: string;
|
|
107
|
+
consumedLearn?: string[];
|
|
108
|
+
/** The resolved plan source — surfaced in the adapter's message when non-param. */
|
|
109
|
+
source?: PlanSource;
|
|
110
|
+
/** A differing explicit param was ignored in favor of the artifact (visibly flagged). */
|
|
111
|
+
paramMismatch?: boolean;
|
|
112
|
+
},
|
|
113
|
+
deps: PlanSaveDeps,
|
|
114
|
+
): Promise<SavePlanOutcome> {
|
|
115
|
+
const plan = input.plan.trim();
|
|
116
|
+
if (!plan) {
|
|
117
|
+
return {
|
|
118
|
+
status: "failed",
|
|
119
|
+
message: "no plan markdown to save (propose a plan first)",
|
|
120
|
+
errorType: "invalid_input",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Forward an explicit (trimmed, non-blank) title; when absent the flag is omitted and the cold
|
|
125
|
+
// door derives one from the plan's first `# ` heading (`plan.derive_title`).
|
|
126
|
+
const title = input.title?.trim() || undefined;
|
|
127
|
+
|
|
128
|
+
// Warm node-link recovery. When BOTH link params are absent (an approval-triggered save
|
|
129
|
+
// carries no model params), fill both-or-neither from the rebuilt `objective_node_claim`. Any
|
|
130
|
+
// explicit value (even one) wins outright — a half-specified link is the caller's, never
|
|
131
|
+
// mixed with the claim. Fail-open: a malformed/missing claim never blocks a save
|
|
132
|
+
// (`nodeClaim()` returns null). Mirrors the cold `_link_from_handoff`.
|
|
133
|
+
let objectiveId = input.objectiveId;
|
|
134
|
+
let nodeId = input.nodeId;
|
|
135
|
+
if (objectiveId === undefined && nodeId === undefined) {
|
|
136
|
+
const claim = deps.session.nodeClaim();
|
|
137
|
+
if (claim !== null) {
|
|
138
|
+
objectiveId = claim.objective;
|
|
139
|
+
nodeId = claim.node;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const saved = await deps.backend.save({
|
|
144
|
+
plan,
|
|
145
|
+
...(title !== undefined ? { title } : {}),
|
|
146
|
+
runId: deps.session.runId,
|
|
147
|
+
...(objectiveId !== undefined ? { objectiveId } : {}),
|
|
148
|
+
...(nodeId !== undefined ? { nodeId } : {}),
|
|
149
|
+
...(input.consumedLearn !== undefined ? { consumedLearn: input.consumedLearn } : {}),
|
|
150
|
+
});
|
|
151
|
+
if (saved.status === "failed") return saved;
|
|
152
|
+
|
|
153
|
+
// Capture the planning session pointer (contracts.md §8.35): best-effort + non-fatal (the
|
|
154
|
+
// carrier warns + no-ops on absent identity; a successful save must stand).
|
|
155
|
+
deps.capturePlanningPointer();
|
|
156
|
+
|
|
157
|
+
// Link the live session: the seam appends iff the rebuilt ref differs, with a strict
|
|
158
|
+
// read-back — verbatim into the outcome (the adapter's rendering ignores it; the append
|
|
159
|
+
// helper's report() stays the loudness channel).
|
|
160
|
+
const linkage = deps.session.apply({ kind: "link-plan-ref", ref: saved.ref });
|
|
161
|
+
|
|
162
|
+
// A successful node-linked save clears the matching claim (best-effort — failure only risks
|
|
163
|
+
// a stale claim silently linking a later, unrelated save; surfaced via the seam's loud
|
|
164
|
+
// report()). The FULL claim identity must match — the resolved objective AND the linked node
|
|
165
|
+
// — so a save linked to objective B node 1.1 never clears objective A's standing 1.1 claim.
|
|
166
|
+
let claimClear: WorkflowChangeResult | null = null;
|
|
167
|
+
if (saved.nodeLink?.linked === true) {
|
|
168
|
+
const linkedNode = saved.nodeLink.node ?? nodeId ?? null;
|
|
169
|
+
const claim = deps.session.nodeClaim();
|
|
170
|
+
if (
|
|
171
|
+
linkedNode !== null &&
|
|
172
|
+
claim !== null &&
|
|
173
|
+
claim.node === linkedNode &&
|
|
174
|
+
claim.objective === objectiveId
|
|
175
|
+
) {
|
|
176
|
+
claimClear = deps.session.apply({
|
|
177
|
+
kind: "clear-node-claim",
|
|
178
|
+
claim: { objective: claim.objective, node: linkedNode },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
status: "saved",
|
|
185
|
+
ref: saved.ref,
|
|
186
|
+
existed: saved.existed,
|
|
187
|
+
updated: saved.updated,
|
|
188
|
+
cached: saved.cached,
|
|
189
|
+
nodeLink: saved.nodeLink,
|
|
190
|
+
source: input.source ?? null,
|
|
191
|
+
paramMismatch: input.paramMismatch ?? false,
|
|
192
|
+
linkage,
|
|
193
|
+
claimClear,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The approval→save orchestration outcome (the plan `GistApprovalSaveOutcome` mirror). */
|
|
198
|
+
export type PlanApprovalSaveOutcome =
|
|
199
|
+
| { status: "no-plan" }
|
|
200
|
+
| { status: "saved"; result: Extract<SavePlanOutcome, { status: "saved" }>; gateExited: boolean }
|
|
201
|
+
| {
|
|
202
|
+
status: "save-failed";
|
|
203
|
+
result: Extract<SavePlanOutcome, { status: "failed" }>;
|
|
204
|
+
gateExited: false;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** The approval→save dependency bag: the save deps + the gate + the save-mode transcript tier. */
|
|
208
|
+
export interface PlanApprovalSaveDeps extends PlanSaveDeps {
|
|
209
|
+
gate: ApprovalGate;
|
|
210
|
+
/** The transcript-scrape thunk (save-mode last resort); omit where the tier cannot apply. */
|
|
211
|
+
transcript?: () => string | null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The shared APPROVED-review → save orchestration (an APPROVED `plan_review` outcome and the
|
|
216
|
+
* manual `/plan-save` failsafe both run THIS — contracts §8.23 pins the name). Flow:
|
|
217
|
+
* explicit `boundSource` for review completion, otherwise artifact-first resolution
|
|
218
|
+
* (`resolvePlanSource` — `reviewedPlan` is the explicit fallback, the transcript scrape last) → `savePlan` (warm node-claim recovery happens inside) through
|
|
219
|
+
* `saveThroughApprovalGate` (the D1a invariant: snapshot before the save; exit only after a
|
|
220
|
+
* successful save while read-only; a failed save leaves the gate ON). No resolvable plan
|
|
221
|
+
* source → `no-plan` (nothing saved, the gate untouched); callers render their own fallback.
|
|
222
|
+
*/
|
|
223
|
+
export async function planApprovalSave(
|
|
224
|
+
deps: PlanApprovalSaveDeps,
|
|
225
|
+
opts: {
|
|
226
|
+
reviewedPlan?: string;
|
|
227
|
+
title?: string;
|
|
228
|
+
/** Explicit reviewed bytes; never re-resolve a partially written patch from the artifact. */
|
|
229
|
+
boundSource?: { plan: string; source: "plan-draft" | "param"; paramMismatch: boolean };
|
|
230
|
+
} = {},
|
|
231
|
+
): Promise<PlanApprovalSaveOutcome> {
|
|
232
|
+
const src =
|
|
233
|
+
opts.boundSource ??
|
|
234
|
+
resolvePlanSource(
|
|
235
|
+
{
|
|
236
|
+
draft: resumePlanDraft(deps.session),
|
|
237
|
+
...(opts.reviewedPlan !== undefined ? { explicit: opts.reviewedPlan } : {}),
|
|
238
|
+
...(deps.transcript !== undefined ? { transcript: deps.transcript } : {}),
|
|
239
|
+
},
|
|
240
|
+
"save",
|
|
241
|
+
);
|
|
242
|
+
if (src === null) return { status: "no-plan" };
|
|
243
|
+
const { outcome: result, gateExited } = await saveThroughApprovalGate(deps.gate, () =>
|
|
244
|
+
savePlan(
|
|
245
|
+
{
|
|
246
|
+
plan: src.plan,
|
|
247
|
+
source: src.source,
|
|
248
|
+
paramMismatch: src.paramMismatch,
|
|
249
|
+
...(opts.title !== undefined ? { title: opts.title } : {}),
|
|
250
|
+
},
|
|
251
|
+
deps,
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
if (result.status === "failed") return { status: "save-failed", result, gateExited: false };
|
|
255
|
+
return { status: "saved", result, gateExited };
|
|
256
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The plan-source feature: where saved/reviewed plan bytes come from. The FILE-FIRST resolution
|
|
2
|
+
// law both save surfaces and the review door share: the validated `plan-draft.md` artifact wins;
|
|
3
|
+
// the explicit `plan` param is the fallback; the transcript scrape is the universal last resort
|
|
4
|
+
// FOR SAVES ONLY — the review surface never sees a transcript tier (an approval auto-saves the
|
|
5
|
+
// reviewed bytes, and scraped conversation bytes must never be those).
|
|
6
|
+
//
|
|
7
|
+
// Pi-free (guard Rule D): the tiers arrive as plain values/thunks — the ADAPTER reads the draft
|
|
8
|
+
// through the session seam and binds the transcript scrape over its branch.
|
|
9
|
+
|
|
10
|
+
/** Where the saved plan bytes came from (the file-first resolution order). */
|
|
11
|
+
export type PlanSource = "plan-draft" | "param" | "transcript";
|
|
12
|
+
|
|
13
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
14
|
+
return typeof value === "object" && value !== null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The joined text of a message's content blocks (fail-open: malformed blocks contribute ""). */
|
|
18
|
+
function textOf(content: unknown): string {
|
|
19
|
+
if (typeof content === "string") return content;
|
|
20
|
+
if (!Array.isArray(content)) return "";
|
|
21
|
+
return content
|
|
22
|
+
.map((block) =>
|
|
23
|
+
isRecord(block) && block.type === "text" && typeof block.text === "string" ? block.text : "",
|
|
24
|
+
)
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.join("\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Best-effort, deterministic: the whole text of the latest assistant message, or null. This is
|
|
31
|
+
* the universal fail-open transcript FALLBACK behind the validated plan-draft artifact (see
|
|
32
|
+
* `resolvePlanSource`) — never a review source. Inherently fragile (it cannot tell a clean plan
|
|
33
|
+
* from conversation): keep the working draft current with `plan_draft` so the validated
|
|
34
|
+
* artifact wins. (There is no tag/marker convention to extract — the borrowed plan-mode package
|
|
35
|
+
* emits no structured plan, only free-form prose.) Fail-open by construction: entries are
|
|
36
|
+
* UNTRUSTED session history, so every field is proven before dereference — a null/sparse entry,
|
|
37
|
+
* a primitive, or a malformed content block is skipped, never thrown on.
|
|
38
|
+
*/
|
|
39
|
+
export function extractPlanMarkdown(entries: readonly unknown[]): string | null {
|
|
40
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
41
|
+
const entry = entries[i];
|
|
42
|
+
if (!isRecord(entry) || entry.type !== "message") continue;
|
|
43
|
+
const message = entry.message;
|
|
44
|
+
if (!isRecord(message) || message.role !== "assistant") continue;
|
|
45
|
+
const text = textOf(message.content).trim();
|
|
46
|
+
if (!text) continue;
|
|
47
|
+
return text;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The shared plan-source resolver every plan surface uses. Resolution order: (1) a non-blank
|
|
54
|
+
* validated draft (the caller has already collapsed absent/invalid reads to `null`); (2) a
|
|
55
|
+
* non-blank explicit param; (3) — save mode only — the transcript scrape thunk; else null.
|
|
56
|
+
*
|
|
57
|
+
* `paramMismatch` is true iff the artifact won AND a non-blank explicit param was passed whose
|
|
58
|
+
* trimmed bytes differ from the artifact's — surfaced by the save rendering, never silently
|
|
59
|
+
* dropped and never a hard-fail. Review mode NEVER sees a transcript tier (the review-surface
|
|
60
|
+
* law): a caller that passes a transcript thunk in review mode still resolves null.
|
|
61
|
+
*/
|
|
62
|
+
export function resolvePlanSource(
|
|
63
|
+
tiers: { draft: string | null; explicit?: string; transcript?: () => string | null },
|
|
64
|
+
mode: "save" | "review",
|
|
65
|
+
): { plan: string; source: PlanSource; paramMismatch: boolean } | null {
|
|
66
|
+
if (tiers.draft !== null && tiers.draft.trim().length > 0) {
|
|
67
|
+
const param = tiers.explicit?.trim() ?? "";
|
|
68
|
+
return {
|
|
69
|
+
plan: tiers.draft,
|
|
70
|
+
source: "plan-draft",
|
|
71
|
+
paramMismatch: param.length > 0 && param !== tiers.draft.trim(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (tiers.explicit !== undefined && tiers.explicit.trim().length > 0) {
|
|
75
|
+
return { plan: tiers.explicit, source: "param", paramMismatch: false };
|
|
76
|
+
}
|
|
77
|
+
if (mode === "save" && tiers.transcript !== undefined) {
|
|
78
|
+
const scraped = tiers.transcript();
|
|
79
|
+
if (scraped !== null) return { plan: scraped, source: "transcript", paramMismatch: false };
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|