@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
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// The §8.51 warm sync-conflict state machine (the stacked-delivery conflict half of the
|
|
2
|
+
// delivery family), Pi-free.
|
|
3
|
+
//
|
|
4
|
+
// The warm layer owns the DECISION tier only: fail-closed corroboration over the untrusted cold
|
|
5
|
+
// status projection, the bounded dispatch pipeline (`decideSyncResolution`), the auto-fire
|
|
6
|
+
// predicate, and the episode-settling reset rule. The real train state machine — journal,
|
|
7
|
+
// cascade, continuation manifests, recovery, landing — is Python-owned (contracts.md
|
|
8
|
+
// §8.42/§8.43/§8.49/§8.51); exterior effects reach TypeScript only as typed cold-door outcomes
|
|
9
|
+
// composed by the adapters in `pi/v1/delivery/`. No warm journal writes, no ref mutations, no
|
|
10
|
+
// readiness re-derivation.
|
|
11
|
+
//
|
|
12
|
+
// The warm transition table (contracts-grounded; the reconcile decision rows live in
|
|
13
|
+
// `delivery/stackReconcile.ts`):
|
|
14
|
+
//
|
|
15
|
+
// | Operation | Persisted facts (authority) | Warm gesture → cold effect | Consent | Typed arms | Recovery |
|
|
16
|
+
// |---|---|---|---|---|---|
|
|
17
|
+
// | SYNC cascade (§8.49) | Journal SYNC prepared/completed/abandoned; checkpoints; continuation manifest on conflict (Python-written) | `objective_stack_sync {}`/`{base}` → `sync --yes`; `{dry_run}` → no consent, no journal | human gesture = `--yes` | no-op (± base-advanced hint) / declined / synchronized N / dry-run preview; failure: `rebase_conflict` (continuation retained) et al. | `/objective-recover` classifies; conflict → continue/abort/resolve |
|
|
18
|
+
// | SYNC continue/abort (§8.49/§8.51) | Continuation manifest + temp refs + retained worktree | `{continue}`/`{abort}` → `--continue/--abort --yes` | human gesture | continued N / continuation declined / aborted-discarded | abandoned continuations self-clean; recover sweeps residue |
|
|
19
|
+
// | ADOPT (§8.49) | Same journal; adopted node's remote head | `objective_stack_adopt {node, confirm}`; dry-run previews | `confirm: true` + dry-run-first | adopted + cascade rendered | never enters this dispatch pipeline (adapter-pinned) |
|
|
20
|
+
// | Conflict dispatch (§8.51, warm-only) | Continuation manifest (containment-validated by the cold projection — `targets_contained`); fresh status projection; `conflict_resolution_attempts`; resolver lock dir | auto-fire on eligible refusal, or `{resolve: true}` | auto: the human's mutating gesture; resolve: explicit request | `dispatched(attempt/cap)` / `no_continuation` / `attempt_cap` / `resolver_busy` / `state_error` (total boundary) | increment verified BEFORE execution; unpersisted/thrown → withhold + release this call's claim |
|
|
21
|
+
//
|
|
22
|
+
// Progress is recorded only after verified effects: the counter reset fires only on a clean
|
|
23
|
+
// cold completion; the increment is persisted-and-verified before native execution.
|
|
24
|
+
|
|
25
|
+
import { basename, dirname } from "node:path";
|
|
26
|
+
import {
|
|
27
|
+
booleanField,
|
|
28
|
+
type ColdJson,
|
|
29
|
+
numberField,
|
|
30
|
+
objectField,
|
|
31
|
+
objectListField,
|
|
32
|
+
stringField,
|
|
33
|
+
} from "../substrate/coldDoor.ts";
|
|
34
|
+
import type { LeaseAcquisition } from "../substrate/resolverLease.ts";
|
|
35
|
+
import { type ConflictAttempts, inspectConflictBudget } from "./submit.ts";
|
|
36
|
+
|
|
37
|
+
/** The sync-tool invocation mode (which control flags the call carried) — decline wording and
|
|
38
|
+
* the completion verb depend on it, and the flags do not fully disambiguate the envelope.
|
|
39
|
+
* Deliberately narrow: adopt is NOT a `SyncMode` — its no-dispatch behavior is pinned at the
|
|
40
|
+
* adopt adapter, never through a widened predicate. */
|
|
41
|
+
export type SyncMode = "sync" | "continue" | "abort";
|
|
42
|
+
|
|
43
|
+
// --- the interpolation vocabularies (module-private, fail-closed) --------------------------------
|
|
44
|
+
// Containment (`targets_contained`) is the cold plane's filesystem-truth validation; these
|
|
45
|
+
// lexical vocabularies confine task/guidance facts independently of filesystem containment.
|
|
46
|
+
// Defensive shell quoting does not widen this deliberately narrow accepted vocabulary.
|
|
47
|
+
|
|
48
|
+
/** The identifier vocabulary for node/objective ids — whitelist validation doubles as
|
|
49
|
+
* control-character/line-break exclusion, so a poisoned projection string can never break out
|
|
50
|
+
* of task/guidance data. Alphanumeric-first excludes option-shaped identifiers. */
|
|
51
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
52
|
+
/** The ONE lineage predicate — the exact warm twin of the Python `_SAFE_LINEAGE_RE` vocabulary. */
|
|
53
|
+
const LINEAGE_RE = /^[0-9A-Za-z][0-9A-Za-z_-]{0,63}$/;
|
|
54
|
+
/** A canonical 26-char Crockford ULID operation id (`validated_targets`' shape, warm side). */
|
|
55
|
+
const OPERATION_ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
56
|
+
/**
|
|
57
|
+
* The shell-inert absolute-path vocabulary: no space or shell metacharacter. Task construction
|
|
58
|
+
* quotes defensively but does not relax this policy. Legitimate exotic roots (e.g. spaces)
|
|
59
|
+
* still degrade to report-only.
|
|
60
|
+
*/
|
|
61
|
+
const SHELL_INERT_ABS_PATH_RE = /^\/[A-Za-z0-9._/-]+$/;
|
|
62
|
+
/** Branch names interpolate as unquoted git arguments — alphanumeric-first (option-shaped
|
|
63
|
+
* `-`-leading refs are refused; git itself rejects them, so nothing legitimate is lost). */
|
|
64
|
+
const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$/;
|
|
65
|
+
|
|
66
|
+
function hasDotDotSegment(path: string): boolean {
|
|
67
|
+
return path.split("/").includes("..");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The sanitized dispatch facts — every string is whitelist-validated before it gets here. */
|
|
71
|
+
export interface SyncConflictDispatch {
|
|
72
|
+
operationId: string;
|
|
73
|
+
manifestPath: string;
|
|
74
|
+
objective: string;
|
|
75
|
+
node: string;
|
|
76
|
+
branch: string;
|
|
77
|
+
pr: number;
|
|
78
|
+
worktree: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Corroborate a retained sync conflict against the fresh status projection (§8.51): the
|
|
83
|
+
* continuation facts, the cold projection's containment verdict (`targets_contained` — the
|
|
84
|
+
* canonical Python `validated_targets` check relayed by §8.44; absent under version skew fails
|
|
85
|
+
* closed), the refusal-message freshness token, lineage/worktree containment, the conflicting
|
|
86
|
+
* layer's identity, and the interpolation vocabularies. Fail-closed: any miss is ineligible
|
|
87
|
+
* with the specific reason. `refusalMessage === null` skips ONLY the freshness-token clause
|
|
88
|
+
* (the explicit `resolve` path — there is no refusal; the human's request against the CURRENT
|
|
89
|
+
* projection is the trigger).
|
|
90
|
+
*/
|
|
91
|
+
export function corroborateSyncConflict(
|
|
92
|
+
payload: ColdJson,
|
|
93
|
+
refusalMessage: string | null,
|
|
94
|
+
): { eligible: true; dispatch: SyncConflictDispatch } | { eligible: false; reason: string } {
|
|
95
|
+
const ineligible = (reason: string) => ({ eligible: false as const, reason });
|
|
96
|
+
const continuation = objectField(payload, "continuation");
|
|
97
|
+
if (continuation === undefined) {
|
|
98
|
+
return ineligible(
|
|
99
|
+
"the status projection reports no pending continuation — nothing was retained (a failed " +
|
|
100
|
+
"manifest write cleans its residue); fix the underlying issue and rerun the sync",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (booleanField(continuation, "parseable") !== true) {
|
|
104
|
+
return ineligible(
|
|
105
|
+
"the pending continuation manifest is UNPARSEABLE — automated resolution cannot " +
|
|
106
|
+
"corroborate it; discard the retained continuation via objective_stack_sync " +
|
|
107
|
+
"{ abort: true } and rerun the sync",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
// The containment gate: the cold projection computes the canonical `validated_targets`
|
|
111
|
+
// containment (Python filesystem truth — symlink-safe). A poisoned or symlinked manifest
|
|
112
|
+
// worktree can never mint a dispatch; an older CLI omits the field and fails closed.
|
|
113
|
+
if (booleanField(continuation, "targets_contained") !== true) {
|
|
114
|
+
return ineligible(
|
|
115
|
+
"the pending continuation's targets are not containment-validated by the cold status " +
|
|
116
|
+
"projection — resolve the rebase by hand in the retained worktree, or discard via " +
|
|
117
|
+
"objective_stack_sync { abort: true } (an older perk CLI never validates; update so " +
|
|
118
|
+
"both planes match).",
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const operationId = stringField(continuation, "operation_id");
|
|
122
|
+
const node = stringField(continuation, "conflict_node_id");
|
|
123
|
+
const worktree = stringField(continuation, "worktree_path");
|
|
124
|
+
const manifestPath = stringField(continuation, "manifest_path");
|
|
125
|
+
if (
|
|
126
|
+
operationId === undefined ||
|
|
127
|
+
node === undefined ||
|
|
128
|
+
worktree === undefined ||
|
|
129
|
+
manifestPath === undefined
|
|
130
|
+
) {
|
|
131
|
+
return ineligible(
|
|
132
|
+
"the pending continuation is missing operation/layer/path facts — resolve the rebase by " +
|
|
133
|
+
"hand in the retained worktree, or discard via objective_stack_sync { abort: true }",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (!ID_RE.test(node)) {
|
|
137
|
+
return ineligible(
|
|
138
|
+
"the continuation's conflict node id falls outside the identifier vocabulary — refusing " +
|
|
139
|
+
"to dispatch; resolve by hand in the retained worktree",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
// Freshness: every `rebase_conflict` arm names the layer whose rebase actually STOPPED as
|
|
143
|
+
// `for layer <node_id> ` (trailing space — `2.2` never matches `2.22`). On the continue-time
|
|
144
|
+
// failed-rewrite arm the PRESERVED manifest names the OLD layer while the message names the
|
|
145
|
+
// NEW one — the mismatch keeps the drive report-only over stale layer facts.
|
|
146
|
+
if (refusalMessage !== null && !refusalMessage.includes(`for layer ${node} `)) {
|
|
147
|
+
return ineligible(
|
|
148
|
+
`the refusal does not name the manifest's conflict layer ${node} — the retained ` +
|
|
149
|
+
"manifest may be a stale snapshot (a failed progress rewrite preserves the previous " +
|
|
150
|
+
"one); resolve the in-progress rebase by hand in the retained worktree, or discard via " +
|
|
151
|
+
"objective_stack_sync { abort: true }",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const train = objectField(payload, "train") ?? {};
|
|
155
|
+
const lineage = stringField(train, "delivery_lineage");
|
|
156
|
+
if (lineage === undefined || !LINEAGE_RE.test(lineage)) {
|
|
157
|
+
return ineligible(
|
|
158
|
+
"the train reports no vocabulary-valid delivery lineage — refusing to derive the claim " +
|
|
159
|
+
"path; dispatch the resolution by hand",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
basename(manifestPath) !== `${lineage}.json` ||
|
|
164
|
+
basename(dirname(manifestPath)) !== "sync-continuations"
|
|
165
|
+
) {
|
|
166
|
+
return ineligible(
|
|
167
|
+
"the continuation manifest path is not sync-continuations/<lineage>.json — refusing to " +
|
|
168
|
+
"claim it; dispatch the resolution by hand",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (!OPERATION_ULID_RE.test(operationId)) {
|
|
172
|
+
return ineligible(
|
|
173
|
+
"the continuation's operation id is not a canonical ULID — refusing to dispatch; resolve " +
|
|
174
|
+
"by hand in the retained worktree",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
!SHELL_INERT_ABS_PATH_RE.test(worktree) ||
|
|
179
|
+
hasDotDotSegment(worktree) ||
|
|
180
|
+
basename(worktree) !== `sync-${operationId}`
|
|
181
|
+
) {
|
|
182
|
+
return ineligible(
|
|
183
|
+
"the retained worktree path falls outside the shell-inert containment vocabulary " +
|
|
184
|
+
"(absolute, sync-<operation-id>, no spaces or shell metacharacters) — dispatch the " +
|
|
185
|
+
"resolution by hand in the retained worktree the status names",
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const layer = objectListField(train, "layers").find(
|
|
189
|
+
(row) => stringField(row, "node_id") === node,
|
|
190
|
+
);
|
|
191
|
+
if (layer === undefined) {
|
|
192
|
+
return ineligible(
|
|
193
|
+
"the conflicting layer is missing from the train projection — refusing to dispatch; " +
|
|
194
|
+
"inspect the train and resolve by hand",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
const branch = stringField(layer, "branch");
|
|
198
|
+
const pr = numberField(layer, "pr_number");
|
|
199
|
+
if (branch === undefined || pr === undefined) {
|
|
200
|
+
return ineligible(
|
|
201
|
+
"the conflicting layer carries no branch/PR identity — the resolver's retained mode " +
|
|
202
|
+
"requires the PR; resolve by hand in the retained worktree",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (!BRANCH_RE.test(branch) || branch.startsWith("/") || hasDotDotSegment(branch)) {
|
|
206
|
+
return ineligible(
|
|
207
|
+
"the conflicting layer's branch falls outside the interpolation vocabulary — refusing " +
|
|
208
|
+
"to dispatch; resolve by hand in the retained worktree",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
// The redirect-resolved ACTIVE objective id — never the requested one (the
|
|
212
|
+
// driveStackReconcile rule): out-of-vocabulary → never drive.
|
|
213
|
+
const objective = stringField(objectField(payload, "objective") ?? {}, "id");
|
|
214
|
+
if (objective === undefined || !ID_RE.test(objective)) {
|
|
215
|
+
return ineligible(
|
|
216
|
+
"the projection's objective id falls outside the identifier vocabulary — refusing to " +
|
|
217
|
+
"dispatch; resolve by hand in the retained worktree",
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
eligible: true,
|
|
222
|
+
dispatch: { operationId, manifestPath, objective, node, branch, pr, worktree },
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The §8.51 auto-fire rule: a MUTATING sync/continue — never dry-run, never abort (adopt never
|
|
228
|
+
* reaches this predicate; its no-dispatch behavior is adapter-pinned) — refusing
|
|
229
|
+
* `rebase_conflict` is dispatch-eligible (the human's mutating gesture is the approval).
|
|
230
|
+
*/
|
|
231
|
+
export function autoDispatchEligible(
|
|
232
|
+
mode: SyncMode,
|
|
233
|
+
dryRun: boolean,
|
|
234
|
+
failure: { errorType: string } | null,
|
|
235
|
+
): boolean {
|
|
236
|
+
if (failure === null || failure.errorType !== "rebase_conflict") return false;
|
|
237
|
+
if (dryRun) return false;
|
|
238
|
+
return mode === "sync" || mode === "continue";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The episode-settling reset rule: any clean, non-declined MUTATING completion re-opens the
|
|
243
|
+
* shared bounded conflict budget (write 0, preserving the equal-value short-circuit — an
|
|
244
|
+
* already-zero counter appends nothing). Never throws: a thrown or `false` write returns
|
|
245
|
+
* `false`, and the ADAPTER reports the reset-failure warning — a stale counter must be visible,
|
|
246
|
+
* never fatal to an already-verified cold completion.
|
|
247
|
+
*/
|
|
248
|
+
export function settleSyncEpisode(
|
|
249
|
+
attempts: ConflictAttempts,
|
|
250
|
+
outcome: { mutating: boolean; declined: boolean },
|
|
251
|
+
): boolean {
|
|
252
|
+
if (!outcome.mutating || outcome.declined) return true;
|
|
253
|
+
try {
|
|
254
|
+
if (attempts.read() === 0) return true;
|
|
255
|
+
return attempts.write(0);
|
|
256
|
+
} catch {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The resolver-claim port — production = `acquireResolverLease`/`releaseResolverClaim`
|
|
262
|
+
* (substrate/resolverLease.ts), composed module-privately by the sync adapter. */
|
|
263
|
+
export interface ResolverClaim {
|
|
264
|
+
acquire(manifestPath: string, operationId: string): LeaseAcquisition;
|
|
265
|
+
release(manifestPath: string, token: string): void;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The dispatch pipeline's ports — action-specific, never a facade. `readProjection` is the
|
|
269
|
+
* corroborating `perk objective stack status --json` re-read (module-private in the adapter). */
|
|
270
|
+
export interface SyncResolutionDeps {
|
|
271
|
+
readProjection: () => Promise<{ ok: true; payload: ColdJson } | { ok: false; message: string }>;
|
|
272
|
+
claim: ResolverClaim;
|
|
273
|
+
attempts: ConflictAttempts;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The closed dispatch outcome — kinds map 1:1 onto the tool-surface `errorType`s. */
|
|
277
|
+
export type SyncResolutionOutcome =
|
|
278
|
+
| { kind: "dispatched"; dispatch: SyncConflictDispatch; attempt: number; cap: number }
|
|
279
|
+
| { kind: "no_continuation" | "attempt_cap" | "resolver_busy" | "state_error"; reason: string };
|
|
280
|
+
|
|
281
|
+
function releaseQuietly(claim: ResolverClaim, held: { manifestPath: string; token: string }): void {
|
|
282
|
+
try {
|
|
283
|
+
claim.release(held.manifestPath, held.token);
|
|
284
|
+
} catch {
|
|
285
|
+
// best-effort — leftover claim residue self-heals via the lease's reclaim rules
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The shared dispatch pipeline (auto-drive AND the explicit `resolve` request), in pinned
|
|
291
|
+
* order: re-read the status projection → corroborate (containment + vocabularies) → inspect the
|
|
292
|
+
* shared bounded budget → take the resolver claim → persist the verified increment (a
|
|
293
|
+
* precondition for dispatch — an unverifiable counter must never bypass the cap; a `false`
|
|
294
|
+
* commit withholds and releases THIS call's claim, token-fenced) → `dispatched`. Rendering and
|
|
295
|
+
* execution/delivery stay adapter-side. Resolve-and-stop: nothing here publishes — the native
|
|
296
|
+
* classifier owns the outcome gate and the human's `continue` stays the publication gesture.
|
|
297
|
+
*
|
|
298
|
+
* Total boundary: every thrown port failure — a throwing projection read, claim port, or
|
|
299
|
+
* counter read/write — is caught and translated to the typed `state_error` arm (reason prefixed
|
|
300
|
+
* `conflict-dispatch state failure:`), releasing this call's claim when one was acquired. The
|
|
301
|
+
* closed union is honest; nothing escapes as a rejection.
|
|
302
|
+
*/
|
|
303
|
+
export async function decideSyncResolution(
|
|
304
|
+
deps: SyncResolutionDeps,
|
|
305
|
+
refusalMessage: string | null,
|
|
306
|
+
): Promise<SyncResolutionOutcome> {
|
|
307
|
+
let held: { manifestPath: string; token: string } | null = null;
|
|
308
|
+
try {
|
|
309
|
+
const projection = await deps.readProjection();
|
|
310
|
+
if (!projection.ok) {
|
|
311
|
+
return {
|
|
312
|
+
kind: "no_continuation",
|
|
313
|
+
reason: `the corroborating status re-read failed — ${projection.message}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const corroborated = corroborateSyncConflict(projection.payload, refusalMessage);
|
|
317
|
+
if (!corroborated.eligible) {
|
|
318
|
+
return { kind: "no_continuation", reason: corroborated.reason };
|
|
319
|
+
}
|
|
320
|
+
const dispatch = corroborated.dispatch;
|
|
321
|
+
const budget = inspectConflictBudget(deps.attempts);
|
|
322
|
+
if (budget.kind === "exhausted") {
|
|
323
|
+
return {
|
|
324
|
+
kind: "attempt_cap",
|
|
325
|
+
reason:
|
|
326
|
+
`the rebase conflict persists after ${budget.attempts} resolution attempt(s) — ` +
|
|
327
|
+
`resolve manually in the retained worktree ${dispatch.worktree} ` +
|
|
328
|
+
"(`git rebase --continue`), then resume via objective_stack_sync { continue: true } " +
|
|
329
|
+
"or discard via { abort: true }.",
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const lease = deps.claim.acquire(dispatch.manifestPath, dispatch.operationId);
|
|
333
|
+
if (!lease.acquired) {
|
|
334
|
+
return {
|
|
335
|
+
kind: lease.kind === "busy" ? "resolver_busy" : "state_error",
|
|
336
|
+
reason: lease.reason,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
held = { manifestPath: dispatch.manifestPath, token: lease.token };
|
|
340
|
+
if (!deps.attempts.write(budget.next)) {
|
|
341
|
+
// The seam's strict read-back boolean passes through unsoftened: the verified increment
|
|
342
|
+
// is a precondition for dispatch — without it the cap is unenforceable. Release the
|
|
343
|
+
// claim acquired in THIS call so the withheld dispatch leaves no phantom holder —
|
|
344
|
+
// token-fenced, so a successor's raced-in claim is never deleted.
|
|
345
|
+
releaseQuietly(deps.claim, held);
|
|
346
|
+
held = null;
|
|
347
|
+
return {
|
|
348
|
+
kind: "state_error",
|
|
349
|
+
reason: "the attempt counter could not be persisted — dispatch withheld",
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
// Dispatched: the claim deliberately stays held (fire-and-forget dispatch; the lease
|
|
353
|
+
// self-heals — see substrate/resolverLease.ts).
|
|
354
|
+
held = null;
|
|
355
|
+
return { kind: "dispatched", dispatch, attempt: budget.next, cap: budget.cap };
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (held !== null) releaseQuietly(deps.claim, held);
|
|
358
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
359
|
+
return { kind: "state_error", reason: `conflict-dispatch state failure: ${message}` };
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The stack surfaces' shared command vocabulary: the objective-argument parse and the
|
|
2
|
+
// no-objective refusal prose. Stack-OWNED (not substrate): these are binding concerns — how a
|
|
3
|
+
// stack slash-command reads its argument and what its soft-fail says — so they live with the
|
|
4
|
+
// delivery feature family, imported by the status adapter and the mutating stack doors alike.
|
|
5
|
+
// The state-resolution primitive they pair with (`resolveStackObjective`) stays in
|
|
6
|
+
// `substrate/workflowState.ts` — that half IS workflow-state read-back. Pi-free.
|
|
7
|
+
|
|
8
|
+
/** The shared no-objective refusal every stack surface emits (soft fail / warning text). */
|
|
9
|
+
export const STACK_NO_OBJECTIVE_MESSAGE =
|
|
10
|
+
"no objective given and none active or linked — pass the objective explicitly.";
|
|
11
|
+
|
|
12
|
+
/** The first command-arg token as the explicit objective (leading `#` stripped); null if none. */
|
|
13
|
+
export function parseStackObjectiveArg(args: string): string | null {
|
|
14
|
+
const token = args.trim().split(/\s+/)[0]?.replace(/^#/, "") ?? "";
|
|
15
|
+
return token.length > 0 ? token : null;
|
|
16
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The §8.56 evidence-gated reconcile decision (the stacked-delivery close half), Pi-free.
|
|
2
|
+
//
|
|
3
|
+
// After a mutating stack land/recover whose envelope carries journal-assembled reconcile
|
|
4
|
+
// evidence (≥1 layer), the session is driven into the reconcile pass. This module owns the
|
|
5
|
+
// DECISION + the evidence mint; rendering and injection stay adapter-side
|
|
6
|
+
// (`pi/v1/delivery/stackDrive.ts`). The gate is EVIDENCE PRESENCE, never `objective_closed` or
|
|
7
|
+
// the invocation's action rows: the Python plane attaches evidence on a real close transition
|
|
8
|
+
// AND on recover's already-closed journal-complete re-emission (the death-after-close repair —
|
|
9
|
+
// an `objective_closed: false` envelope with evidence must still drive, or the crash window
|
|
10
|
+
// would suppress the drive permanently). At-least-once: duplicate cross-machine drives are
|
|
11
|
+
// possible and harmless — the reconcile pass is idempotent ("skip if nothing stale").
|
|
12
|
+
//
|
|
13
|
+
// The warm transition rows this decision serves (the conflict rows live in
|
|
14
|
+
// `delivery/stackConflict.ts`):
|
|
15
|
+
//
|
|
16
|
+
// | Operation | Persisted facts (authority) | Warm gesture → cold effect | Consent | Typed arms | Recovery |
|
|
17
|
+
// |---|---|---|---|---|---|
|
|
18
|
+
// | RECOVER (§8.51) | Journal fold + fresh product-state proof (Python) | `{dry_run}` classify-only; `{}` concludes all-after; `{abandon,confirm}`/`{accept_prefix,confirm}` | confirm for conclusions | classification rows / landed layers / sweep report / evidence (may drive) | recover IS the recovery surface; at-least-once evidence re-emission (reconcile idempotent) |
|
|
19
|
+
// | STACK LAND (§8.55/§8.56) | Readiness composed from the train (Python); LAND journal op | `{dry_run}` preview; `{confirm}` → `land --yes` | `confirm: true` + dry-run-first | merged (+close+evidence→drive) / pending / unexpected_enqueued (UNRESOLVED — report and STOP) / completed_without_merge closed/NOT-closed / declined | `/objective-recover` concludes against fresh authority |
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
booleanField,
|
|
23
|
+
type ColdJson,
|
|
24
|
+
numberField,
|
|
25
|
+
objectField,
|
|
26
|
+
objectListField,
|
|
27
|
+
stringField,
|
|
28
|
+
} from "../substrate/coldDoor.ts";
|
|
29
|
+
|
|
30
|
+
/** The identifier vocabulary for evidence interpolation (objective/node/plan ids) —
|
|
31
|
+
* whitelist validation doubles as control-character/line-break exclusion, so a poisoned
|
|
32
|
+
* journal string can never break out of its evidence row. Alphanumeric-first: the objective
|
|
33
|
+
* id renders as an unquoted CLI argument in the injected guidance, so an option-shaped
|
|
34
|
+
* `-`-leading id (or a dotfile-shaped one) never passes. */
|
|
35
|
+
const EVIDENCE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
36
|
+
const EVIDENCE_SHA_RE = /^[0-9a-fA-F]{4,64}$/;
|
|
37
|
+
/** The printable-https vocabulary the minted URL must reconstruct into. */
|
|
38
|
+
const PRINTABLE_HTTPS_RE = /^https:\/\/[\x21-\x7e]+$/;
|
|
39
|
+
|
|
40
|
+
/** One sanitized evidence row — every field is a validated primitive (failures render `"?"`). */
|
|
41
|
+
export interface StackReconcileEvidenceRow {
|
|
42
|
+
node: string;
|
|
43
|
+
plan: string;
|
|
44
|
+
/** The PR rendered for the row: a positive safe integer's decimal form, else `"?"`. */
|
|
45
|
+
pr: string;
|
|
46
|
+
baseSha: string;
|
|
47
|
+
headSha: string;
|
|
48
|
+
mergeSha: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The validated drive evidence — NOMINAL and mint-only (the ReadyDriveEvidence precedent): the
|
|
53
|
+
* `#private` field makes structural forgery impossible, the constructor is unreachable outside
|
|
54
|
+
* this module, and `decideStackReconcile` mints it ONLY after per-field sanitization. Every
|
|
55
|
+
* field is a validated primitive SNAPSHOTTED (frozen) at mint time — the evidence never aliases
|
|
56
|
+
* the caller-reachable payload, so post-decision payload mutation cannot reach the drive
|
|
57
|
+
* render. The drive template interpolates exclusively from this type.
|
|
58
|
+
*/
|
|
59
|
+
class StackReconcileEvidence {
|
|
60
|
+
// The ONE `#private` field supplies the nominal guarantee; the getter below reads it.
|
|
61
|
+
readonly #objective: string;
|
|
62
|
+
/** The validated https URL (`""` when the payload's url failed validation). */
|
|
63
|
+
readonly url: string;
|
|
64
|
+
readonly rows: readonly Readonly<StackReconcileEvidenceRow>[];
|
|
65
|
+
readonly finalBaseSha: string;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
objective: string,
|
|
69
|
+
url: string,
|
|
70
|
+
rows: StackReconcileEvidenceRow[],
|
|
71
|
+
finalBaseSha: string,
|
|
72
|
+
) {
|
|
73
|
+
this.#objective = objective;
|
|
74
|
+
this.url = url;
|
|
75
|
+
this.rows = Object.freeze(rows.map((row) => Object.freeze({ ...row })));
|
|
76
|
+
this.finalBaseSha = finalBaseSha;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
get objective(): string {
|
|
80
|
+
return this.#objective;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type { StackReconcileEvidence };
|
|
85
|
+
|
|
86
|
+
function evidenceToken(source: ColdJson, key: string): string {
|
|
87
|
+
const value = stringField(source, key);
|
|
88
|
+
return value !== undefined && EVIDENCE_ID_RE.test(value) ? value : "?";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function evidenceSha(source: ColdJson, key: string): string {
|
|
92
|
+
const value = stringField(source, key);
|
|
93
|
+
return value !== undefined && EVIDENCE_SHA_RE.test(value) ? value : "?";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The PR render vocabulary: only a positive safe integer renders (a float, a negative, an
|
|
97
|
+
* unsafe magnitude, or a non-number degrades to `"?"` — never an exotic numeric form). */
|
|
98
|
+
function evidencePr(source: ColdJson): string {
|
|
99
|
+
const value = numberField(source, "pr_number");
|
|
100
|
+
return value !== undefined && Number.isSafeInteger(value) && value > 0 ? String(value) : "?";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The URL mint: accepted only when `new URL(...)` parses with protocol `https:`, empty
|
|
105
|
+
* username/password, and the RECONSTRUCTED `href` inside the printable vocabulary — the minted
|
|
106
|
+
* value is the reconstruction, never the raw string; anything else is `""` (the drive renders
|
|
107
|
+
* without a link). The reconstruction must equal the raw input: a url the parser had to REPAIR
|
|
108
|
+
* (percent-encoding a space, a backtick, a control character) is refused rather than silently
|
|
109
|
+
* rewritten — the parser's escaping would otherwise launder interpolation-hostile bytes into a
|
|
110
|
+
* vocabulary-passing form.
|
|
111
|
+
*/
|
|
112
|
+
function evidenceUrl(raw: string | undefined): string {
|
|
113
|
+
if (raw === undefined) return "";
|
|
114
|
+
let parsed: URL;
|
|
115
|
+
try {
|
|
116
|
+
parsed = new URL(raw);
|
|
117
|
+
} catch {
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
if (parsed.protocol !== "https:") return "";
|
|
121
|
+
if (parsed.username !== "" || parsed.password !== "") return "";
|
|
122
|
+
if (!PRINTABLE_HTTPS_RE.test(parsed.href)) return "";
|
|
123
|
+
return parsed.href === raw ? parsed.href : "";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The reconcile decision: drive with minted evidence, or nothing. */
|
|
127
|
+
export type StackReconcileDecision =
|
|
128
|
+
| { drive: false }
|
|
129
|
+
| { drive: true; evidence: StackReconcileEvidence };
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Decide the §8.56 reconcile drive over a mutating land/recover envelope (untrusted DATA). The
|
|
133
|
+
* gate: never dry-run; `reconcile_evidence` present with ≥1 layer; the redirect-resolved ACTIVE
|
|
134
|
+
* objective id (never the requested one) inside the identifier vocabulary. On drive, the
|
|
135
|
+
* evidence is minted from per-field-sanitized primitives — journal-originated strings are
|
|
136
|
+
* untrusted DATA headed for a steering message, so every field is whitelist-validated against
|
|
137
|
+
* its vocabulary; anything else renders as `"?"` (ids/SHAs/PR) or `""` (url).
|
|
138
|
+
*/
|
|
139
|
+
export function decideStackReconcile(payload: ColdJson): StackReconcileDecision {
|
|
140
|
+
if (booleanField(payload, "dry_run") === true) return { drive: false };
|
|
141
|
+
const evidence = objectField(payload, "reconcile_evidence");
|
|
142
|
+
if (evidence === undefined) return { drive: false };
|
|
143
|
+
const layers = objectListField(evidence, "layers");
|
|
144
|
+
if (layers.length === 0) return { drive: false };
|
|
145
|
+
const obj = objectField(payload, "objective") ?? {};
|
|
146
|
+
const id = stringField(obj, "id");
|
|
147
|
+
if (id === undefined || !EVIDENCE_ID_RE.test(id)) return { drive: false };
|
|
148
|
+
const rows = layers.map((layer) => ({
|
|
149
|
+
node: evidenceToken(layer, "node_id"),
|
|
150
|
+
plan: evidenceToken(layer, "plan_id"),
|
|
151
|
+
pr: evidencePr(layer),
|
|
152
|
+
baseSha: evidenceSha(layer, "base_sha"),
|
|
153
|
+
headSha: evidenceSha(layer, "head_sha"),
|
|
154
|
+
mergeSha: evidenceSha(layer, "merge_commit_sha"),
|
|
155
|
+
}));
|
|
156
|
+
return {
|
|
157
|
+
drive: true,
|
|
158
|
+
evidence: new StackReconcileEvidence(
|
|
159
|
+
id,
|
|
160
|
+
evidenceUrl(stringField(obj, "url")),
|
|
161
|
+
rows,
|
|
162
|
+
evidenceSha(evidence, "final_base_sha"),
|
|
163
|
+
),
|
|
164
|
+
};
|
|
165
|
+
}
|