@mgiles/perk 3.2.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 +365 -380
- 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 -311
- package/extension/{doors/prReviewBrowser.ts → pi/v1/codeReview/browser.ts} +53 -33
- package/extension/{doors/hunkHandoff.ts → pi/v1/codeReview/checkout.ts} +12 -8
- package/extension/{doors/reviewWaveTools.ts → pi/v1/codeReview/reviewWave.ts} +146 -114
- package/extension/{doors/stackReviewBrowser.ts → pi/v1/codeReview/stack.ts} +62 -29
- 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 +154 -267
- 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 +123 -61
- 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/pi/v1/lifecycleGates.ts +127 -0
- 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/{factories/objectivePlan.ts → pi/v1/objectivePlanning.ts} +328 -533
- 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 +73 -27
- 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 -21
- package/extension/substrate/git.ts +88 -6
- package/extension/substrate/modelVisible.ts +53 -0
- package/extension/substrate/prompts.ts +22 -0
- package/extension/substrate/registry.ts +2 -0
- package/extension/substrate/resolverLease.ts +5 -4
- package/extension/substrate/sessionData.ts +85 -152
- package/extension/substrate/toolGating.ts +263 -84
- package/extension/substrate/unifiedDiff.ts +1 -1
- package/extension/substrate/workflowState.ts +178 -163
- package/extension/substrate/worktreeResolverLock.ts +261 -0
- package/extension/surfaces/surfaces.ts +79 -27
- package/extension/waves/adversarialReviewWave.ts +87 -46
- 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 +43 -18
- 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/objective-refinement.md +17 -0
- package/prompts/contexts/read-only.md +1 -1
- package/prompts/stages/conflict-resolution-continuation.md +9 -6
- package/prompts/stages/conflict-resolution.md +4 -4
- 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 +1 -1
- 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-browser/stack.md +5 -6
- package/shared/README.md +8 -0
- package/shared/bindings.yaml +3 -3
- package/shared/contracts.md +2601 -506
- package/shared/fixtures/issues-table.json +130 -0
- package/shared/registry.yaml +13 -0
- package/shared/schemas/outputs/objective-node-engagement.schema.json +318 -0
- package/shared/schemas/outputs/objective-stack-status.schema.json +6 -1
- 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 -489
- package/extension/doors/learn.ts +0 -668
- package/extension/doors/lifecycleGates.ts +0 -207
- package/extension/doors/objectiveStack.ts +0 -1543
- package/extension/doors/prReviewDynamic.ts +0 -276
- package/extension/doors/ready.ts +0 -279
- package/extension/doors/submit.ts +0 -373
- package/extension/doors/submitPrReview.ts +0 -505
- 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/objectiveSave.ts +0 -366
- 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
|
@@ -1,604 +0,0 @@
|
|
|
1
|
-
// The warm `/plan-save` door. The in-session twin of the Python cold
|
|
2
|
-
// door (`perk plan save`): a deterministic, terminating tool + command that WRAP the existing
|
|
3
|
-
// storage — they do NOT reimplement the GitHub write. `savePlan()` delegates to `perk plan save
|
|
4
|
-
// --json` via the shared cold-door client (`runColdDoor` — the plan markdown rides the run-scratch
|
|
5
|
-
// stdin channel), then appends `active_plan_ref` so the live session is linked immediately
|
|
6
|
-
// (strict read-back, idempotent, headless-safe). Failures are loud-but-non-fatal — never throw.
|
|
7
|
-
//
|
|
8
|
-
// FILE-FIRST PLAN SOURCE. Both surfaces resolve their plan through
|
|
9
|
-
// `resolvePlanSource`: the validated `plan-draft.md` artifact (`readSessionArtifact`,
|
|
10
|
-
// digest-validated, fail-open) wins; the explicit `plan` param (tool only, now optional) is the
|
|
11
|
-
// fallback for sessions that never wrote a draft; `extractPlanMarkdown` (the transcript scrape)
|
|
12
|
-
// is the universal last resort. A differing ignored param is surfaced, never silent.
|
|
13
|
-
//
|
|
14
|
-
// APPROVAL→SAVE ORCHESTRATION. The exported `approvalSave` seam is the shared
|
|
15
|
-
// APPROVED-review → save flow: artifact-first resolution → `savePlan` (warm node-link recovery
|
|
16
|
-
// inside, from the `objective_node_claim` carrier) → gate exit on a successful save. The
|
|
17
|
-
// `/plan-save` command is the MANUAL FAILSAFE invocation of the same seam; the review backends
|
|
18
|
-
// (plannotator / first-party / tombell) wire their APPROVED outcome into it.
|
|
19
|
-
//
|
|
20
|
-
// SEAM-SHARED SUBSTRATE. `savePlan`/the `plan_save` tool/`/plan-save`/the read-only gate
|
|
21
|
-
// are the produced-contract landing for the PLAN seam (`adapter-architecture.md` Invariant 1) — the
|
|
22
|
-
// adapter bridges a foreign plan surface *to* `plan_save`/`cache.plan-ref`/the gate, so
|
|
23
|
-
// they must stay always-registered. They do NOT defer when a foreign `[providers] plan` is selected
|
|
24
|
-
// — only perk's own authoring surface (`extension/factories/planMode.ts`: `/plan`, `Ctrl+Alt+P`, `--plan`,
|
|
25
|
-
// the `perk:plan-context` injection) steps aside. Deferring this substrate would break the adapter.
|
|
26
|
-
|
|
27
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
28
|
-
import type { PlanRef } from "../substrate/cache.ts";
|
|
29
|
-
import {
|
|
30
|
-
booleanField,
|
|
31
|
-
type ColdJson,
|
|
32
|
-
nullableStringField,
|
|
33
|
-
objectField,
|
|
34
|
-
runColdDoor,
|
|
35
|
-
stringField,
|
|
36
|
-
} from "../substrate/coldDoor.ts";
|
|
37
|
-
import { registerPerkCommand } from "../substrate/command.ts";
|
|
38
|
-
import { failFor, ok, type Result } from "../substrate/result.ts";
|
|
39
|
-
import { readSessionArtifact, type SessionDataCtx } from "../substrate/sessionData.ts";
|
|
40
|
-
import { captureSessionPointer } from "../substrate/sessionPointers.ts";
|
|
41
|
-
import type { ToolGating } from "../substrate/toolGating.ts";
|
|
42
|
-
import { idArrayParam, paramsOf, stringParam } from "../substrate/toolParams.ts";
|
|
43
|
-
import {
|
|
44
|
-
appendWorkflowState,
|
|
45
|
-
type BranchEntry,
|
|
46
|
-
branchOf,
|
|
47
|
-
planRefsEqual,
|
|
48
|
-
rebuildWorkflowState,
|
|
49
|
-
} from "../substrate/workflowState.ts";
|
|
50
|
-
import { report, type Severity } from "../surfaces/report.ts";
|
|
51
|
-
import { nodeClaimsEqual, readNodeClaim } from "./objectivePlan.ts";
|
|
52
|
-
import { PLAN_DRAFT_ARTIFACT } from "./planDraft.ts";
|
|
53
|
-
import { generatePlanTitle } from "./planTitle.ts";
|
|
54
|
-
|
|
55
|
-
/** The ok-arm fields — the `details` surface doubles as branch-safe persisted state. */
|
|
56
|
-
export interface PlanSaveOk {
|
|
57
|
-
/** `issue.id` is the opaque string issue id (GitHub "42", Linear "ENG-123") — §8.21. */
|
|
58
|
-
issue: { id: string; url: string };
|
|
59
|
-
plan_ref: PlanRef;
|
|
60
|
-
cached: boolean;
|
|
61
|
-
existed: boolean | null;
|
|
62
|
-
updated: boolean;
|
|
63
|
-
objective_node: ObjectiveNodeLink | null;
|
|
64
|
-
plan_source: PlanSource | null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** The atomic objective node→plan commit surfaced by `perk plan save`. */
|
|
68
|
-
export interface ObjectiveNodeLink {
|
|
69
|
-
linked: boolean;
|
|
70
|
-
node: string | null;
|
|
71
|
-
status: string | null;
|
|
72
|
-
error: string | null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** A tool result patch (AgentToolResult has no `isError`; failure is signaled via details.ok). */
|
|
76
|
-
export type SaveResult = Result<PlanSaveOk>;
|
|
77
|
-
export type PlanSaveDetails = SaveResult["details"];
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* The decoded `perk plan save --json` payload slice the warm door consumes. Decode policy
|
|
81
|
-
* (`docs/learned/workflow/cold-door-client.md`: strict iff appended to workflow-state): only
|
|
82
|
-
* `plan_ref` is strict. The rendered `issue.id`/`url` are DERIVED from the strict ref — the cold
|
|
83
|
-
* door constructs the ref from the issue (`pr_id == issue.id`, `url == issue.url`), so they are
|
|
84
|
-
* byte-identical by construction; `existed` and `objective_node` are advisory.
|
|
85
|
-
*/
|
|
86
|
-
interface PlanSavePayload {
|
|
87
|
-
issue: { id: string; url: string; existed: boolean | undefined };
|
|
88
|
-
plan_ref: PlanRef;
|
|
89
|
-
cached?: boolean;
|
|
90
|
-
updated?: boolean;
|
|
91
|
-
objective_node: ObjectiveNodeLink | null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Fully strict `plan_ref` decode — a half-formed ref appended to workflow-state would poison
|
|
96
|
-
* `planRefsEqual` and every downstream consumer, so any miss → null → bad_output.
|
|
97
|
-
*/
|
|
98
|
-
function decodePlanRef(payload: ColdJson): PlanRef | null {
|
|
99
|
-
const ref = objectField(payload, "plan_ref");
|
|
100
|
-
if (ref === undefined) return null;
|
|
101
|
-
const provider = stringField(ref, "provider");
|
|
102
|
-
const prId = stringField(ref, "pr_id");
|
|
103
|
-
const url = stringField(ref, "url");
|
|
104
|
-
const labels = ref.labels;
|
|
105
|
-
const objectiveId = nullableStringField(ref, "objective_id");
|
|
106
|
-
if (
|
|
107
|
-
provider === undefined ||
|
|
108
|
-
prId === undefined ||
|
|
109
|
-
url === undefined ||
|
|
110
|
-
!Array.isArray(labels) ||
|
|
111
|
-
!labels.every((l) => typeof l === "string") ||
|
|
112
|
-
objectiveId === undefined
|
|
113
|
-
) {
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
// `base` stays Python-owned for all behavior; carrying it keeps the workflow-state
|
|
117
|
-
// `active_plan_ref` copy byte-consistent with the cold door's `--json` plan_ref. Parity-only +
|
|
118
|
-
// lenient: a present null/string is carried, an absent/mistyped value is simply omitted (never a
|
|
119
|
-
// decode failure — legacy plan-refs lack the field).
|
|
120
|
-
const base = nullableStringField(ref, "base");
|
|
121
|
-
return { provider, pr_id: prId, url, labels, objective_id: objectiveId, base };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Validate the optional `objective_node` sub-object; malformed → null (advisory, never fatal). */
|
|
125
|
-
function decodeObjectiveNode(payload: ColdJson): ObjectiveNodeLink | null {
|
|
126
|
-
const node = objectField(payload, "objective_node");
|
|
127
|
-
if (node === undefined) return null;
|
|
128
|
-
const linked = booleanField(node, "linked");
|
|
129
|
-
const name = nullableStringField(node, "node");
|
|
130
|
-
const status = nullableStringField(node, "status");
|
|
131
|
-
const error = nullableStringField(node, "error");
|
|
132
|
-
if (linked === undefined || name === undefined || status === undefined || error === undefined) {
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
return { linked, node: name, status, error };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Narrow the `perk plan save --json` success payload. Strict ONLY on `plan_ref` (malformed →
|
|
140
|
-
* bad_output — it is appended to workflow-state, where a half-formed ref would poison
|
|
141
|
-
* `planRefsEqual`). The rendered issue id/url are derived from the strict ref instead of decoded
|
|
142
|
-
* independently — the cold door builds the ref FROM the issue, so they are byte-identical by
|
|
143
|
-
* construction; this makes any `issue` sub-object shape change (e.g. a `number`→`id`
|
|
144
|
-
* rename under CLI↔extension version skew) skew-harmless. `existed` and
|
|
145
|
-
* `objective_node` are advisory — the plan genuinely saved, so the success report must survive
|
|
146
|
-
* them. With `plan_ref` the only strict field, `bad_output` is reachable only for a payload whose
|
|
147
|
-
* persistence would corrupt workflow-state.
|
|
148
|
-
*/
|
|
149
|
-
function decodePlanSave(payload: ColdJson): PlanSavePayload | null {
|
|
150
|
-
const ref = decodePlanRef(payload);
|
|
151
|
-
if (ref === null) return null;
|
|
152
|
-
const issue = objectField(payload, "issue");
|
|
153
|
-
const existed = issue === undefined ? undefined : booleanField(issue, "existed");
|
|
154
|
-
return {
|
|
155
|
-
issue: { id: ref.pr_id, url: ref.url, existed },
|
|
156
|
-
plan_ref: ref,
|
|
157
|
-
cached: booleanField(payload, "cached"),
|
|
158
|
-
updated: booleanField(payload, "updated"),
|
|
159
|
-
objective_node: decodeObjectiveNode(payload),
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Whether perk-owned plan mode is active — read from perk's OWN signal, the `read-only` `mode` of
|
|
165
|
-
* `perk:workflow-state` (the structural tool gate). This retires the soft coupling to
|
|
166
|
-
* `@tombell/pi-plan`'s `plan-mode-state` entry: perk now owns plan mode (`/plan` toggles
|
|
167
|
-
* `gating.enter`/`exit`, which append `mode`), so the gate's own field is the source of truth.
|
|
168
|
-
*/
|
|
169
|
-
export function isPlanModeActive(branch: readonly BranchEntry[]): boolean {
|
|
170
|
-
return rebuildWorkflowState(branch).mode === "read-only";
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function textOf(content: unknown): string {
|
|
174
|
-
if (typeof content === "string") return content;
|
|
175
|
-
if (!Array.isArray(content)) return "";
|
|
176
|
-
return content
|
|
177
|
-
.map((block) => {
|
|
178
|
-
const b = block as { type?: string; text?: string };
|
|
179
|
-
return b.type === "text" && typeof b.text === "string" ? b.text : "";
|
|
180
|
-
})
|
|
181
|
-
.filter(Boolean)
|
|
182
|
-
.join("\n");
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Best-effort, deterministic: the whole text of the latest assistant message, or null. This is
|
|
187
|
-
* the universal fail-open transcript FALLBACK behind the validated plan-draft
|
|
188
|
-
* artifact (see `resolvePlanSource`) — no longer the `/plan-save` command's primary plan source.
|
|
189
|
-
* Inherently fragile (it cannot tell a clean plan from conversation): keep the working draft
|
|
190
|
-
* current with `plan_draft` so the validated artifact wins. (There is no tag/marker convention to
|
|
191
|
-
* extract — the borrowed plan-mode package emits no structured plan, only free-form prose.)
|
|
192
|
-
*/
|
|
193
|
-
export function extractPlanMarkdown(entries: readonly unknown[]): string | null {
|
|
194
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
195
|
-
const entry = entries[i] as { type?: string; message?: { role?: string; content?: unknown } };
|
|
196
|
-
if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
|
|
197
|
-
const text = textOf(entry.message.content).trim();
|
|
198
|
-
if (!text) continue;
|
|
199
|
-
return text;
|
|
200
|
-
}
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Where the saved plan bytes came from (the file-first resolution order). */
|
|
205
|
-
export type PlanSource = "plan-draft" | "param" | "transcript";
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* The shared plan-source resolver both save surfaces use — and the
|
|
209
|
-
* approval→save orchestration. Resolution order: (1) the validated `plan-draft.md`
|
|
210
|
-
* artifact (`readSessionArtifact`: digest-validated, fail-open — null on no run_id / no pointer
|
|
211
|
-
* / fork run_id mismatch / missing file / digest mismatch); (2) a non-blank explicit param;
|
|
212
|
-
* (3) the transcript scrape (`extractPlanMarkdown`); else null.
|
|
213
|
-
*
|
|
214
|
-
* `paramMismatch` is true iff the artifact won AND a non-blank explicit param was passed whose
|
|
215
|
-
* trimmed bytes differ from the artifact's — surfaced by `savePlan`, never silently dropped and
|
|
216
|
-
* never a hard-fail.
|
|
217
|
-
*/
|
|
218
|
-
export function resolvePlanSource(
|
|
219
|
-
ctx: SessionDataCtx,
|
|
220
|
-
explicit?: string,
|
|
221
|
-
): { plan: string; source: PlanSource; paramMismatch: boolean } | null {
|
|
222
|
-
const artifact = readSessionArtifact(ctx, PLAN_DRAFT_ARTIFACT);
|
|
223
|
-
if (artifact !== null && artifact.content.trim().length > 0) {
|
|
224
|
-
const param = explicit?.trim() ?? "";
|
|
225
|
-
return {
|
|
226
|
-
plan: artifact.content,
|
|
227
|
-
source: "plan-draft",
|
|
228
|
-
paramMismatch: param.length > 0 && param !== artifact.content.trim(),
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
if (explicit !== undefined && explicit.trim().length > 0) {
|
|
232
|
-
return { plan: explicit, source: "param", paramMismatch: false };
|
|
233
|
-
}
|
|
234
|
-
const scraped = extractPlanMarkdown(branchOf(ctx));
|
|
235
|
-
if (scraped !== null) return { plan: scraped, source: "transcript", paramMismatch: false };
|
|
236
|
-
return null;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/**
|
|
240
|
-
* The single save implementation both surfaces call. Delegates the GitHub write to the Python
|
|
241
|
-
* cold door, then links the live session. Returns a soft result (never throws); failures set
|
|
242
|
-
* `details.ok = false` and append no linkage.
|
|
243
|
-
*/
|
|
244
|
-
export async function savePlan(
|
|
245
|
-
pi: ExtensionAPI,
|
|
246
|
-
ctx: ExtensionContext,
|
|
247
|
-
opts: {
|
|
248
|
-
plan: string;
|
|
249
|
-
title?: string;
|
|
250
|
-
objectiveId?: string;
|
|
251
|
-
nodeId?: string;
|
|
252
|
-
consumedLearn?: string[];
|
|
253
|
-
/** The resolved plan source — surfaced in the message + details when non-param. */
|
|
254
|
-
source?: PlanSource;
|
|
255
|
-
/** A differing explicit param was ignored in favor of the artifact (visibly flagged). */
|
|
256
|
-
paramMismatch?: boolean;
|
|
257
|
-
},
|
|
258
|
-
): Promise<SaveResult> {
|
|
259
|
-
const fail = failFor(ctx, "plan-save");
|
|
260
|
-
|
|
261
|
-
const plan = opts.plan.trim();
|
|
262
|
-
if (!plan) return fail("no plan markdown to save (propose a plan first)", "invalid_input");
|
|
263
|
-
|
|
264
|
-
// Forward an explicit title (previously accepted but DROPPED), else best-effort generate
|
|
265
|
-
// one via the session model. On any failure the cold door's `derive_title` fallback takes over.
|
|
266
|
-
const explicit = opts.title?.trim();
|
|
267
|
-
const title =
|
|
268
|
-
explicit && explicit.length > 0
|
|
269
|
-
? explicit
|
|
270
|
-
: ((await generatePlanTitle(ctx, plan, ctx.signal)) ?? undefined);
|
|
271
|
-
|
|
272
|
-
const branch = (): BranchEntry[] => branchOf(ctx);
|
|
273
|
-
// No read-only fail-fast here (D1a): the `plan_save` TOOL is structurally unreachable while
|
|
274
|
-
// read-only (the read-only allowlist excludes it), so reaching savePlan via the tool means the gate is
|
|
275
|
-
// already off; the `/plan-save` COMMAND is allowed to run while read-only and the command handler
|
|
276
|
-
// exits the gate on a successful save (the read-only → read-write boundary in one gesture).
|
|
277
|
-
const runId = rebuildWorkflowState(branch()).run_id ?? "";
|
|
278
|
-
|
|
279
|
-
// Warm node-link recovery. When BOTH link params are absent (an
|
|
280
|
-
// approval-triggered save carries no model params), fill both-or-neither from the rebuilt
|
|
281
|
-
// `objective_node_claim`. Any explicit value (even one) wins outright — a half-specified link
|
|
282
|
-
// is the caller's, never mixed with the claim. Fail-open: a malformed/missing claim never
|
|
283
|
-
// blocks a save (readNodeClaim returns null). Mirrors the cold `_link_from_handoff`.
|
|
284
|
-
let objectiveId = opts.objectiveId;
|
|
285
|
-
let nodeId = opts.nodeId;
|
|
286
|
-
if (objectiveId === undefined && nodeId === undefined) {
|
|
287
|
-
const claim = readNodeClaim(ctx);
|
|
288
|
-
if (claim !== null) {
|
|
289
|
-
objectiveId = claim.objective;
|
|
290
|
-
nodeId = claim.node;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
const args = ["plan", "save", "--json"];
|
|
295
|
-
if (runId) args.push("--run-id", runId);
|
|
296
|
-
// The resolved title (explicit or LLM-generated). When absent, the cold door derives it.
|
|
297
|
-
if (title) args.push("--title", title);
|
|
298
|
-
// The plan→objective link. The objective plan-factory passes the active objective
|
|
299
|
-
// number; non-objective plans omit it (unchanged behavior).
|
|
300
|
-
if (objectiveId) args.push("--objective-id", objectiveId);
|
|
301
|
-
// The objective plan factory passes the node id alongside the objective id; the cold
|
|
302
|
-
// door commits the node→plan backlink + `in_progress` advance atomically. Non-factory plans
|
|
303
|
-
// omit it (unchanged behavior).
|
|
304
|
-
if (nodeId) args.push("--node-id", nodeId);
|
|
305
|
-
// The learn-docs factory passes the consumed perk:learn issue numbers; docs plans land
|
|
306
|
-
// them (close + label perk:consolidated). Non-factory plans omit it (unchanged behavior).
|
|
307
|
-
if (opts.consumedLearn && opts.consumedLearn.length > 0) {
|
|
308
|
-
args.push("--consumed-learn", opts.consumedLearn.join(","));
|
|
309
|
-
}
|
|
310
|
-
const r = await runColdDoor<PlanSavePayload>(pi, ctx, args, {
|
|
311
|
-
label: "perk plan save",
|
|
312
|
-
decode: decodePlanSave,
|
|
313
|
-
stdin: { flag: "--plan-file", content: plan, filename: "plan.md" },
|
|
314
|
-
});
|
|
315
|
-
if (!r.ok) return fail(r.message, r.errorType);
|
|
316
|
-
|
|
317
|
-
// Capture the planning session pointer (contracts.md §8.35): this planning run self-keys by its
|
|
318
|
-
// own run_id into the shared main checkout, so a later/other session can resolve it cross-run.
|
|
319
|
-
// Best-effort + non-fatal (the carrier warns + returns false; a successful save must stand).
|
|
320
|
-
// Covers /plan-save, the plan_save tool, and approvalSave (all flow through savePlan).
|
|
321
|
-
captureSessionPointer({
|
|
322
|
-
cwd: ctx.cwd,
|
|
323
|
-
runId,
|
|
324
|
-
klass: "planning",
|
|
325
|
-
site: "main",
|
|
326
|
-
// Optional-chained: best-effort, and some side-session fakes have no getSessionFile.
|
|
327
|
-
sessionFile: ctx.sessionManager.getSessionFile?.(),
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
// Link the live session: append iff the rebuilt ref differs, with a strict read-back.
|
|
331
|
-
const ref = r.data.plan_ref;
|
|
332
|
-
if (!planRefsEqual(rebuildWorkflowState(branch()).active_plan_ref ?? null, ref)) {
|
|
333
|
-
appendWorkflowState(pi, ctx, {
|
|
334
|
-
data: { active_plan_ref: ref },
|
|
335
|
-
field: "active_plan_ref",
|
|
336
|
-
expected: ref,
|
|
337
|
-
scope: "plan-save",
|
|
338
|
-
failure: `plan-ref read-back failed for ${ref.provider}:${ref.pr_id}`,
|
|
339
|
-
equals: planRefsEqual,
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
const verb = r.data.issue.existed ? "Updated" : "Saved";
|
|
344
|
-
const nodeLink = r.data.objective_node;
|
|
345
|
-
// A successful node-linked save clears the matching claim (best-effort —
|
|
346
|
-
// failure only risks a stale claim silently linking a later, unrelated save; surfaced via
|
|
347
|
-
// appendWorkflowState's report()). An unrelated claim is never clobbered.
|
|
348
|
-
if (nodeLink?.linked === true) {
|
|
349
|
-
const linkedNode = nodeLink.node ?? nodeId ?? null;
|
|
350
|
-
const claim = readNodeClaim(ctx);
|
|
351
|
-
if (linkedNode !== null && claim !== null && claim.node === linkedNode) {
|
|
352
|
-
appendWorkflowState(pi, ctx, {
|
|
353
|
-
data: { objective_node_claim: null },
|
|
354
|
-
field: "objective_node_claim",
|
|
355
|
-
expected: null,
|
|
356
|
-
scope: "plan-save",
|
|
357
|
-
failure: `objective_node_claim clear read-back failed for node ${linkedNode}`,
|
|
358
|
-
equals: nodeClaimsEqual,
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
// Render all THREE node-link outcomes (the silent-partial-failure fix). A failed advance
|
|
363
|
-
// (`linked: false`) is a non-fatal sub-step — the plan genuinely saved — but it must be VISIBLE
|
|
364
|
-
// (the §8.3 "surfaced, never swallowed" intent), not swallowed. Both surfaces render content[0].text, so
|
|
365
|
-
// this one site fixes the tool path (the model relays it) and the command path (the user sees the
|
|
366
|
-
// notify) at once.
|
|
367
|
-
let linkSuffix = "";
|
|
368
|
-
if (nodeLink?.linked === true) {
|
|
369
|
-
linkSuffix = ` · linked objective node ${nodeLink.node} → in_progress`;
|
|
370
|
-
} else if (nodeLink && nodeLink.linked === false) {
|
|
371
|
-
linkSuffix = ` · ⚠ objective node ${nodeLink.node} NOT advanced — re-run /plan-save to retry${
|
|
372
|
-
nodeLink.error ? ` (${nodeLink.error})` : ""
|
|
373
|
-
}`;
|
|
374
|
-
}
|
|
375
|
-
// Surface NON-param sources in the message (param-path success messages stay
|
|
376
|
-
// byte-stable); a differing ignored param is visibly flagged, never silent.
|
|
377
|
-
let sourceSuffix = "";
|
|
378
|
-
if (opts.source === "plan-draft" || opts.source === "transcript") {
|
|
379
|
-
sourceSuffix =
|
|
380
|
-
opts.source === "plan-draft"
|
|
381
|
-
? " · plan source: plan-draft artifact"
|
|
382
|
-
: " · plan source: transcript";
|
|
383
|
-
if (opts.paramMismatch) {
|
|
384
|
-
sourceSuffix += " (⚠ differing plan param ignored — the validated artifact was saved)";
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
return ok(
|
|
388
|
-
`${verb} plan #${ref.pr_id} → ${ref.url}${sourceSuffix}${linkSuffix}`,
|
|
389
|
-
{
|
|
390
|
-
issue: { id: r.data.issue.id, url: r.data.issue.url },
|
|
391
|
-
plan_ref: ref,
|
|
392
|
-
cached: r.data.cached ?? false,
|
|
393
|
-
existed: r.data.issue.existed ?? null,
|
|
394
|
-
updated: r.data.updated ?? false,
|
|
395
|
-
objective_node: nodeLink,
|
|
396
|
-
plan_source: opts.source ?? null,
|
|
397
|
-
},
|
|
398
|
-
{ terminate: true },
|
|
399
|
-
);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** The approval→save orchestration outcome. */
|
|
403
|
-
export type ApprovalSaveOutcome =
|
|
404
|
-
| { status: "no-plan" }
|
|
405
|
-
| { status: "saved" | "save-failed"; result: SaveResult; gateExited: boolean };
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* The shared approval→save orchestration seam: an APPROVED review outcome
|
|
409
|
-
* (the `plan_review` door, planReview.ts — the plannotator bridge AND the first-party
|
|
410
|
-
* in-TUI editor review; tombell's re-aim is pending) and the manual `/plan-save`
|
|
411
|
-
* failsafe both run THIS. Flow: artifact-first plan resolution (`resolvePlanSource` — the reviewed plan text is
|
|
412
|
-
* the explicit fallback, the transcript scrape last) → `savePlan` (warm node-link recovery happens
|
|
413
|
-
* inside) → gate exit on a successful save while read-only (the D1a pattern: snapshot
|
|
414
|
-
* `gating.isActive()` before the save; a failed save leaves the gate ON). No resolvable plan
|
|
415
|
-
* source → `no-plan` (nothing saved, the gate untouched); callers render their own fallback.
|
|
416
|
-
* The returned `SaveResult` keeps `terminate: true` for tool-path callers (plannotator's
|
|
417
|
-
* approved arm propagates it).
|
|
418
|
-
*/
|
|
419
|
-
export async function approvalSave(
|
|
420
|
-
pi: ExtensionAPI,
|
|
421
|
-
ctx: ExtensionContext,
|
|
422
|
-
gating: ToolGating,
|
|
423
|
-
opts: { reviewedPlan?: string; title?: string } = {},
|
|
424
|
-
): Promise<ApprovalSaveOutcome> {
|
|
425
|
-
const src = resolvePlanSource(ctx, opts.reviewedPlan);
|
|
426
|
-
if (src === null) return { status: "no-plan" };
|
|
427
|
-
// D1a: snapshot the gate BEFORE the save; on success, exit it so save marks the read-only →
|
|
428
|
-
// read-write boundary in one gesture. A failed save leaves the gate on.
|
|
429
|
-
const wasReadOnly = gating.isActive();
|
|
430
|
-
const result = await savePlan(pi, ctx, {
|
|
431
|
-
plan: src.plan,
|
|
432
|
-
source: src.source,
|
|
433
|
-
paramMismatch: src.paramMismatch,
|
|
434
|
-
title: opts.title,
|
|
435
|
-
});
|
|
436
|
-
let gateExited = false;
|
|
437
|
-
if (result.details.ok && wasReadOnly) {
|
|
438
|
-
gating.exit(ctx);
|
|
439
|
-
gateExited = true;
|
|
440
|
-
}
|
|
441
|
-
return { status: result.details.ok ? "saved" : "save-failed", result, gateExited };
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/** The decoded `plan_save` tool params (snake_case, as the schema declares them). */
|
|
445
|
-
interface PlanSaveParams {
|
|
446
|
-
plan?: string;
|
|
447
|
-
title?: string;
|
|
448
|
-
objective_id?: string;
|
|
449
|
-
node_id?: string;
|
|
450
|
-
consumed_learn?: string[];
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Decode unknown `plan_save` tool-call params (the tool-boundary seam). `plan` is
|
|
455
|
-
* optional (the validated plan-draft artifact is preferred) — absent decodes to
|
|
456
|
-
* `undefined`, but present-but-mistyped → null (strict-fail); the optional fields likewise.
|
|
457
|
-
*/
|
|
458
|
-
export function decodePlanSaveParams(params: unknown): PlanSaveParams | null {
|
|
459
|
-
const p = paramsOf(params);
|
|
460
|
-
if (p === null) return null;
|
|
461
|
-
const plan = stringParam(p, "plan");
|
|
462
|
-
if (plan === null) return null;
|
|
463
|
-
const title = stringParam(p, "title");
|
|
464
|
-
const objectiveId = stringParam(p, "objective_id");
|
|
465
|
-
const nodeId = stringParam(p, "node_id");
|
|
466
|
-
// Opaque string ids (§8.21); numbers are coerced — the learn-docs guidance renders bare
|
|
467
|
-
// numeric ids on GitHub, so the model may echo them un-quoted.
|
|
468
|
-
const consumedLearn = idArrayParam(p, "consumed_learn");
|
|
469
|
-
if (title === null || objectiveId === null || nodeId === null || consumedLearn === null) {
|
|
470
|
-
return null;
|
|
471
|
-
}
|
|
472
|
-
return {
|
|
473
|
-
plan: plan ?? undefined,
|
|
474
|
-
title,
|
|
475
|
-
objective_id: objectiveId,
|
|
476
|
-
node_id: nodeId,
|
|
477
|
-
consumed_learn: consumedLearn,
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const TOOL_GUIDELINES = [
|
|
482
|
-
"Use plan_save only after the plan is decision-complete and the user has agreed; it creates the canonical GitHub plan and ends the turn.",
|
|
483
|
-
"Keep the working draft current with plan_draft — the validated plan-draft artifact is what plan_save saves; the `plan` parameter is only a fallback when no draft exists. Never reference line numbers — use durable anchors (function names, behavioral descriptions, structural locations).",
|
|
484
|
-
"Pass plan_save's consumed_learn (the gathered perk:learn issue ids) only from the learned-docs factory — it links the issues the docs plan consolidates so /land closes + labels them.",
|
|
485
|
-
"When saving an objective-factory plan, pass plan_save BOTH objective_id and node_id — this links the node to the plan and advances it planning → in_progress (no separate backlink call).",
|
|
486
|
-
];
|
|
487
|
-
|
|
488
|
-
/** Register the warm door: the `plan_save` tool (canonical) + the `/plan-save` command twin. */
|
|
489
|
-
export function registerPlanSave(pi: ExtensionAPI, gating: ToolGating): void {
|
|
490
|
-
pi.registerTool({
|
|
491
|
-
name: "plan_save",
|
|
492
|
-
label: "Save plan",
|
|
493
|
-
description:
|
|
494
|
-
"Persist the current plan to GitHub as the canonical perk plan and link this session to it. " +
|
|
495
|
-
"Terminating: ends the turn on save. Call only when the plan is decision-complete.",
|
|
496
|
-
promptSnippet: "Save the decision-complete plan to GitHub (terminates the turn)",
|
|
497
|
-
promptGuidelines: TOOL_GUIDELINES,
|
|
498
|
-
executionMode: "sequential",
|
|
499
|
-
parameters: {
|
|
500
|
-
type: "object",
|
|
501
|
-
additionalProperties: false,
|
|
502
|
-
properties: {
|
|
503
|
-
plan: {
|
|
504
|
-
type: "string",
|
|
505
|
-
description:
|
|
506
|
-
"Optional — the validated plan-draft.md artifact is preferred when present; this " +
|
|
507
|
-
"param is the fallback for sessions that never wrote a draft (no line-number " +
|
|
508
|
-
"references).",
|
|
509
|
-
},
|
|
510
|
-
title: {
|
|
511
|
-
type: "string",
|
|
512
|
-
description: "Optional issue title (defaults to the plan's first heading).",
|
|
513
|
-
},
|
|
514
|
-
objective_id: {
|
|
515
|
-
type: "string",
|
|
516
|
-
description:
|
|
517
|
-
"Optional objective issue number to link this plan to (the objective plan factory " +
|
|
518
|
-
"passes the active objective; omit for a standalone plan).",
|
|
519
|
-
},
|
|
520
|
-
node_id: {
|
|
521
|
-
type: "string",
|
|
522
|
-
description:
|
|
523
|
-
"Objective node id to commit on save — the objective plan factory passes it with " +
|
|
524
|
-
"`objective_id` (links the node and advances it to `in_progress`); omit for a " +
|
|
525
|
-
"standalone plan.",
|
|
526
|
-
},
|
|
527
|
-
consumed_learn: {
|
|
528
|
-
type: "array",
|
|
529
|
-
items: { type: ["string", "number"] },
|
|
530
|
-
description:
|
|
531
|
-
"Optional perk:learn issue ids this docs plan consumes (the learned-docs factory " +
|
|
532
|
-
"passes the gathered ids; omit for a standalone plan). /land closes + labels them.",
|
|
533
|
-
},
|
|
534
|
-
},
|
|
535
|
-
},
|
|
536
|
-
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
537
|
-
const decoded = decodePlanSaveParams(params);
|
|
538
|
-
if (decoded === null) {
|
|
539
|
-
// The `label` arg matters: this handler-level closure renders "plan_save failed: …" while
|
|
540
|
-
// savePlan's internal failFor(ctx, "plan-save") stays as-is.
|
|
541
|
-
return failFor(
|
|
542
|
-
ctx,
|
|
543
|
-
"plan-save",
|
|
544
|
-
"plan_save",
|
|
545
|
-
)("plan_save needs { plan: string, … } per the tool schema", "bad_input");
|
|
546
|
-
}
|
|
547
|
-
const src = resolvePlanSource(ctx, decoded.plan);
|
|
548
|
-
if (src === null) {
|
|
549
|
-
return failFor(
|
|
550
|
-
ctx,
|
|
551
|
-
"plan-save",
|
|
552
|
-
"plan_save",
|
|
553
|
-
)(
|
|
554
|
-
"no plan to save — write the working draft with plan_draft, or pass the plan parameter",
|
|
555
|
-
"invalid_input",
|
|
556
|
-
);
|
|
557
|
-
}
|
|
558
|
-
return savePlan(pi, ctx, {
|
|
559
|
-
plan: src.plan,
|
|
560
|
-
source: src.source,
|
|
561
|
-
paramMismatch: src.paramMismatch,
|
|
562
|
-
title: decoded.title,
|
|
563
|
-
objectiveId: decoded.objective_id,
|
|
564
|
-
nodeId: decoded.node_id,
|
|
565
|
-
consumedLearn: decoded.consumed_learn,
|
|
566
|
-
});
|
|
567
|
-
},
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
registerPerkCommand(pi, "plan-save", {
|
|
571
|
-
description:
|
|
572
|
-
"Save the latest proposed plan to GitHub — the manual failsafe for the approval→save flow " +
|
|
573
|
-
"(the read-only → read-write boundary).",
|
|
574
|
-
handler: async (args, ctx) => {
|
|
575
|
-
const title = args.trim() || undefined;
|
|
576
|
-
// The manual-failsafe invocation of the shared approval→save seam. Artifact-first
|
|
577
|
-
// (no explicit param on the command path ⇒ paramMismatch is always false); the D1a gate exit
|
|
578
|
-
// lives in the seam. (The tool path never exits the gate — it is structurally unreachable
|
|
579
|
-
// while read-only.)
|
|
580
|
-
const outcome = await approvalSave(pi, ctx, gating, { title });
|
|
581
|
-
if (outcome.status === "no-plan") {
|
|
582
|
-
report(
|
|
583
|
-
ctx,
|
|
584
|
-
"plan-save",
|
|
585
|
-
"warning",
|
|
586
|
-
"no plan to save; write a draft with plan_draft, propose a plan, or call the plan_save tool.",
|
|
587
|
-
{ alsoLog: true },
|
|
588
|
-
);
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
// Severity reflects a failed objective-node advance: not-ok → error; saved-but-link-failed →
|
|
592
|
-
// warning; otherwise info. A failed node-link never blocks the gate exit above (the plan was
|
|
593
|
-
// saved) — but it MUST surface (the silent-partial-failure fix), in headless runs too.
|
|
594
|
-
const result = outcome.result;
|
|
595
|
-
const message = result.content[0]?.text ?? "plan-save done";
|
|
596
|
-
const severity: Severity = !result.details.ok
|
|
597
|
-
? "error"
|
|
598
|
-
: result.details.objective_node?.linked === false
|
|
599
|
-
? "warning"
|
|
600
|
-
: "info";
|
|
601
|
-
report(ctx, "plan-save", severity, message);
|
|
602
|
-
},
|
|
603
|
-
});
|
|
604
|
-
}
|