@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,971 @@
|
|
|
1
|
+
// The feature-facing WorkflowSession seam AND its one deep engine (module-contracts.md's
|
|
2
|
+
// `session/` home): run identity + verified session-artifact operations + the named
|
|
3
|
+
// workflow-state reads (`nodeClaim`/`activeObjective`/`activeSessionPlanRef`/`reviewPosts`) and
|
|
4
|
+
// the closed change union, sized strictly to the callers that exist. No `stage`/`mode`/pointer-map
|
|
5
|
+
// snapshot — no feature caller consumes them through the seam yet (stage routing and the gate
|
|
6
|
+
// stay adapter-side; startup's lifecycle facts live in `session/lifecycle.ts`); the seam grows
|
|
7
|
+
// only from proven callers.
|
|
8
|
+
//
|
|
9
|
+
// ONE ENGINE, TWO NARROW PORTS. `openWorkflowSession(deps)` implements every WorkflowChange
|
|
10
|
+
// invariant, the full artifact discipline (name policy → identity refusal → unchanged
|
|
11
|
+
// short-circuit → store → read-back digest → pointer construction → merged-map strict append;
|
|
12
|
+
// the classified read tiers), and all error/problem text exactly once. Backings supply only
|
|
13
|
+
// mechanics: a `SessionStateStore` (session/lifecycle.ts — the same workflow-state store port
|
|
14
|
+
// the identity lifecycle uses) and an `ArtifactContentStore` (content I/O only, zero error
|
|
15
|
+
// prose). The production binding is `branchWorkflowSession.ts` (branch/file); the deterministic
|
|
16
|
+
// in-memory binding lives in `testing/memoryWorkflowSession.ts` (dev-only, outside the
|
|
17
|
+
// production corpus). Both are exercised by the shared interface suite in
|
|
18
|
+
// `workflowSession.test.ts`.
|
|
19
|
+
//
|
|
20
|
+
// Identity is OPTIONAL: `runId` is `string | null` and a session always opens — the plan-save
|
|
21
|
+
// surfaces prove the shape (workflow-state appends are branch-backed and identity-independent:
|
|
22
|
+
// an identity-less save still links `active_plan_ref`). The ARTIFACT ops classify no-identity as
|
|
23
|
+
// `rejected` (write) / `absent` (ordinary read) / `invalid` (strict read); the state ops work without
|
|
24
|
+
// identity. Identity is TRUST-NARROWED: a rebuilt `run_id` that is unsafe as a path component
|
|
25
|
+
// (`isSafeRunId`) degrades to no-identity before any path derivation — a hostile persisted id
|
|
26
|
+
// can never steer artifact reads outside the run root or reach a receipt.
|
|
27
|
+
//
|
|
28
|
+
// Results carry the session-owned `SessionArtifactReceipt` — validated or re-derived values
|
|
29
|
+
// ONLY (never the persisted pointer, whose rebuilt fields are unvalidated branch data). The
|
|
30
|
+
// full `SessionArtifactPointer` wire shape is still constructed here for the
|
|
31
|
+
// `session_artifacts` strict append (contracts §8.3's persistence format) — derived at the
|
|
32
|
+
// storage boundary, internal to the engine, never exposed through results.
|
|
33
|
+
//
|
|
34
|
+
// `apply(change)` is a CLOSED union admitted from proven callers (never a feature dispatcher);
|
|
35
|
+
// the first two variants come from the plan-save surfaces, the second two from the objective
|
|
36
|
+
// flows (`transitionObjectiveNode`'s planning arm records the claim; `saveObjective`'s
|
|
37
|
+
// post-save linkage sets `active_objective`). No snapshot payloads on the applied/unchanged
|
|
38
|
+
// arms — nothing consumes them (narrow until proven).
|
|
39
|
+
|
|
40
|
+
import { isSafeRunId, type PlanRef } from "../substrate/cache.ts";
|
|
41
|
+
import { digestSessionData, type SessionDataRead } from "../substrate/sessionData.ts";
|
|
42
|
+
import {
|
|
43
|
+
nodeClaimsEqual,
|
|
44
|
+
planRefsEqual,
|
|
45
|
+
type SessionArtifactPointer,
|
|
46
|
+
} from "../substrate/workflowState.ts";
|
|
47
|
+
import type { SessionStateStore } from "./lifecycle.ts";
|
|
48
|
+
|
|
49
|
+
/** Session-owned vocabulary: features import the plan-ref shape through the session seam. */
|
|
50
|
+
export type { PlanRef };
|
|
51
|
+
/** One digest convention, exposed through the session boundary for bound review consumers. */
|
|
52
|
+
export { digestSessionData };
|
|
53
|
+
|
|
54
|
+
/** A human-readable problem description (the backing has already warned where its tier is loud). */
|
|
55
|
+
export type SessionProblem = string;
|
|
56
|
+
|
|
57
|
+
/** The refinement stage id (the session keys its subject mapping + stage-only entry on it). */
|
|
58
|
+
const REFINE_STAGE_ID = "objective-refine";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The refinement grounding-context artifact name — session-owned vocabulary because the review
|
|
62
|
+
* binding fences the refinement subject on this artifact's digest (features import it here).
|
|
63
|
+
*/
|
|
64
|
+
export const REFINEMENT_CONTEXT_ARTIFACT = "objective-refinement-context.json";
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Session-owned artifact receipt: validated or re-derived values ONLY. `runId` is the
|
|
68
|
+
* safe-narrowed active run id (matches the persisted pointer's `run_id` by construction);
|
|
69
|
+
* `path` is re-derived from the content store (fs: repo-relative; memory: the name) — NEVER a
|
|
70
|
+
* persisted pointer field; `digest` is proven — computed from the bytes read back (applied) or
|
|
71
|
+
* from the stored bytes during the unchanged probe.
|
|
72
|
+
*/
|
|
73
|
+
export interface SessionArtifactReceipt {
|
|
74
|
+
runId: string;
|
|
75
|
+
path: string;
|
|
76
|
+
digest: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The artifact content port: content I/O only, mechanical results, zero error prose —
|
|
81
|
+
* classification, policy, and all problem text are engine-owned. Every operation receives the
|
|
82
|
+
* ENGINE-VALIDATED run id (the artifact namespace), so storage, pointer, and receipt share one
|
|
83
|
+
* identity by construction — a backing never derives its own.
|
|
84
|
+
*/
|
|
85
|
+
export interface ArtifactContentStore {
|
|
86
|
+
/** Persist bytes under the run; false = refusal (the port has already warned where its tier is loud). */
|
|
87
|
+
store(runId: string, name: string, content: string): boolean;
|
|
88
|
+
/** The run's current bytes; null = missing/unreadable. */
|
|
89
|
+
load(runId: string, name: string): string | null;
|
|
90
|
+
/** Strict reads distinguish genuine absence from I/O refusal; no fallback to load(). */
|
|
91
|
+
loadStrict(runId: string, name: string): SessionDataRead;
|
|
92
|
+
/** The receipt/warning display path, re-derived from the given identity — NEVER a persisted pointer field. */
|
|
93
|
+
displayPath(runId: string, name: string): string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The classified artifact read. `absent` is the silent, branchable tier (no identity, no
|
|
98
|
+
* pointer, or a cross-run fork pointer — designed isolation); `invalid` is the loud tier (a
|
|
99
|
+
* pointer whose file is missing or digest-mismatched — rewind/tamper). Opt-in strict reads also
|
|
100
|
+
* classify missing identity, malformed provenance, orphan bytes and I/O failure as `invalid`;
|
|
101
|
+
* they return problems to the authorizing caller rather than emitting ordinary read warnings.
|
|
102
|
+
*/
|
|
103
|
+
export type ReadArtifactResult =
|
|
104
|
+
| { status: "found"; content: string }
|
|
105
|
+
| { status: "absent" }
|
|
106
|
+
| { status: "invalid"; problem: SessionProblem };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The classified artifact write — the verified state op: `applied` proves the file AND the
|
|
110
|
+
* strict-appended `session_artifacts` pointer both landed and read back; `unchanged` is the
|
|
111
|
+
* byte-identical short-circuit (the recorded pointer already proves these bytes); `rejected`
|
|
112
|
+
* refused before any effect (including the no-identity refusal — artifacts need a run_id);
|
|
113
|
+
* `unverified` means an effect may have landed but the read-back proof failed — never
|
|
114
|
+
* consumable.
|
|
115
|
+
*/
|
|
116
|
+
export type WriteArtifactResult =
|
|
117
|
+
| { status: "applied"; receipt: SessionArtifactReceipt }
|
|
118
|
+
| { status: "unchanged"; receipt: SessionArtifactReceipt }
|
|
119
|
+
| { status: "unverified"; problem: SessionProblem }
|
|
120
|
+
| { status: "rejected"; problem: SessionProblem };
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The last automated `/pr-review` outcome (`last_pr_review`, contracts §8.3): exactly the
|
|
124
|
+
* record the `post_pr_review` post surface constructs on a real success. After a recorded wave,
|
|
125
|
+
* `angles` is the authoritative attempted manifest and `covered_angles` its schema-valid
|
|
126
|
+
* subset; standalone posts use the caller's angles for both.
|
|
127
|
+
*/
|
|
128
|
+
export interface PrReviewRecord {
|
|
129
|
+
pr: number;
|
|
130
|
+
verdict: "clean" | "actionable";
|
|
131
|
+
angles: readonly string[];
|
|
132
|
+
covered_angles: readonly string[];
|
|
133
|
+
comment_count: number | null;
|
|
134
|
+
mode: string | null;
|
|
135
|
+
at: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The last curated review-door outcome (`last_review`, contracts §8.3): exactly the record the
|
|
140
|
+
* `submit_pr_review` post surface constructs on a real success. `event` stays `string` in the
|
|
141
|
+
* record — the decode boundary already constrains it; the stored field is render-only.
|
|
142
|
+
*/
|
|
143
|
+
export interface ReviewSubmissionRecord {
|
|
144
|
+
pr: number;
|
|
145
|
+
event: string;
|
|
146
|
+
comment_count: number | null;
|
|
147
|
+
mode: string | null;
|
|
148
|
+
at: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The four known review-classification count keys the finalizer records (§8.3). */
|
|
152
|
+
export interface ReviewBatchCounts {
|
|
153
|
+
actionable?: number;
|
|
154
|
+
informational?: number;
|
|
155
|
+
praise?: number;
|
|
156
|
+
question?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The last finalized review batch (`last_review_batch`, contracts §8.3): exactly the record the
|
|
161
|
+
* address finalizer constructs after publication and corroborated full thread resolution.
|
|
162
|
+
*/
|
|
163
|
+
export interface ReviewBatchRecord {
|
|
164
|
+
pr: number | null;
|
|
165
|
+
counts: ReviewBatchCounts | null;
|
|
166
|
+
resolved_thread_ids: string[];
|
|
167
|
+
at: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One `review_posts` ledger row: a REAL curated submission that reached GitHub (§8.3). */
|
|
171
|
+
export interface ReviewPostRow {
|
|
172
|
+
pr: number;
|
|
173
|
+
event: string;
|
|
174
|
+
at: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Tolerant re-narrow of a rebuilt `review_posts` list (best-effort tier: a malformed row is
|
|
179
|
+
* dropped, never a refusal — the ledger only ever grows from the seam's own writes).
|
|
180
|
+
*/
|
|
181
|
+
export function reviewPostsOf(raw: unknown): ReviewPostRow[] {
|
|
182
|
+
if (!Array.isArray(raw)) return [];
|
|
183
|
+
const rows: ReviewPostRow[] = [];
|
|
184
|
+
for (const item of raw) {
|
|
185
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) continue;
|
|
186
|
+
const row = item as Record<string, unknown>;
|
|
187
|
+
if (typeof row.pr !== "number" || !Number.isInteger(row.pr)) continue;
|
|
188
|
+
if (typeof row.event !== "string" || typeof row.at !== "string") continue;
|
|
189
|
+
rows.push({ pr: row.pr, event: row.event, at: row.at });
|
|
190
|
+
}
|
|
191
|
+
return rows;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Ledger equality for the read-back verification (order-sensitive — posting order matters). */
|
|
195
|
+
export function reviewPostsEqual(rebuilt: unknown, expected: unknown): boolean {
|
|
196
|
+
const a = reviewPostsOf(rebuilt);
|
|
197
|
+
const b = reviewPostsOf(expected);
|
|
198
|
+
if (a.length !== b.length) return false;
|
|
199
|
+
return a.every(
|
|
200
|
+
(row, i) => row.pr === b[i]?.pr && row.event === b[i]?.event && row.at === b[i]?.at,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The closed workflow-state change union — admitted variant-by-variant from proven callers
|
|
206
|
+
* (the proven callers: the plan-save surfaces). Reads stay NAMED (`nodeClaim()`); only changes
|
|
207
|
+
* ride the union.
|
|
208
|
+
*/
|
|
209
|
+
export type WorkflowChange =
|
|
210
|
+
/** Link the live session to a saved plan: append `active_plan_ref` iff it differs. */
|
|
211
|
+
| { kind: "link-plan-ref"; ref: PlanRef }
|
|
212
|
+
/**
|
|
213
|
+
* Clear `objective_node_claim` iff the live claim matches BOTH fields (never clobbers an
|
|
214
|
+
* unrelated claim — a save linked to objective B node 1.1 must not clear objective A's 1.1).
|
|
215
|
+
*/
|
|
216
|
+
| { kind: "clear-node-claim"; claim: { objective: string; node: string } }
|
|
217
|
+
/**
|
|
218
|
+
* Record `objective_node_claim` iff the live claim differs (`nodeClaimsEqual`) — an
|
|
219
|
+
* idempotent re-claim short-circuits `unchanged` (the re-append "refresh" carries no
|
|
220
|
+
* semantic payload: the claim has no timestamp and rebuilds identically).
|
|
221
|
+
*/
|
|
222
|
+
| { kind: "record-node-claim"; claim: { objective: string; node: string } }
|
|
223
|
+
/** Link the live session to a saved objective: append `active_objective` iff it differs. */
|
|
224
|
+
| { kind: "link-objective"; objective: string }
|
|
225
|
+
/**
|
|
226
|
+
* Enter the refinement stage from a warm session: append `stage: "objective-refine"` iff the
|
|
227
|
+
* live stage differs — a STAGE-ONLY change (never a claim, plan-ref, objective or mode write;
|
|
228
|
+
* the warm `/objective-refine` entry sets the stage the cold handoff would have carried). An
|
|
229
|
+
* idempotent re-entry short-circuits `unchanged`.
|
|
230
|
+
*/
|
|
231
|
+
| { kind: "enter-refinement-stage" }
|
|
232
|
+
/**
|
|
233
|
+
* Record the last automated `/pr-review` outcome: ONE `last_pr_review` append (LWW), strict
|
|
234
|
+
* read-back. No pre-read, no dedupe (same runtime invariant as `record-review`:
|
|
235
|
+
* `applied`/`unverified`/`rejected` only).
|
|
236
|
+
*/
|
|
237
|
+
| { kind: "record-pr-review"; record: PrReviewRecord }
|
|
238
|
+
/**
|
|
239
|
+
* Record the last curated review-door outcome: ONE `last_review` append (LWW), strict
|
|
240
|
+
* read-back. No pre-read, no dedupe — the `already_posted` resume guard is feature-op policy
|
|
241
|
+
* upstream, so at runtime this yields `applied`/`unverified`/`rejected` only (a runtime
|
|
242
|
+
* invariant, not a type claim).
|
|
243
|
+
*/
|
|
244
|
+
| { kind: "record-review"; record: ReviewSubmissionRecord }
|
|
245
|
+
/**
|
|
246
|
+
* Append one `review_posts` ledger row: read-rebuild-append of the whole ordered list, with
|
|
247
|
+
* the order-sensitive `reviewPostsEqual` read-back. The pre-read is FAIL-CLOSED and
|
|
248
|
+
* STRICT-DECODED (an unrebuildable or malformed persisted ledger refuses the append — see the
|
|
249
|
+
* engine's arm); no dedupe (same invariant as `record-review`:
|
|
250
|
+
* `applied`/`unverified`/`rejected` only at runtime).
|
|
251
|
+
*/
|
|
252
|
+
| { kind: "append-review-post"; row: ReviewPostRow }
|
|
253
|
+
/**
|
|
254
|
+
* Record the last finalized review batch: ONE `last_review_batch` append (LWW), strict
|
|
255
|
+
* read-back. No pre-read, no dedupe — the corroborated-success-first ordering is feature-op
|
|
256
|
+
* policy upstream (`applied`/`unverified`/`rejected` only at runtime).
|
|
257
|
+
*/
|
|
258
|
+
| { kind: "record-review-batch"; record: ReviewBatchRecord };
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The classified change outcome: `applied` proves the append landed and read back; `unchanged`
|
|
262
|
+
* is the idempotent short-circuit (link: the rebuilt ref already equals; clear: no matching
|
|
263
|
+
* claim); `unverified` means the append may have landed but the read-back proof failed (the
|
|
264
|
+
* branch backing has already warned loudly); `rejected` refused before any effect.
|
|
265
|
+
*/
|
|
266
|
+
export type WorkflowChangeResult =
|
|
267
|
+
| { status: "applied" }
|
|
268
|
+
| { status: "unchanged" }
|
|
269
|
+
| { status: "unverified"; problem: SessionProblem }
|
|
270
|
+
| { status: "rejected"; problem: SessionProblem };
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The feature-facing session: optional identity + verified artifact ops + the named
|
|
274
|
+
* workflow-state reads and the closed change union. A session ALWAYS opens; `runId: null` is
|
|
275
|
+
* the identity-less arm (artifact ops classify it; state ops still work).
|
|
276
|
+
*/
|
|
277
|
+
export interface WorkflowSession {
|
|
278
|
+
readonly runId: string | null;
|
|
279
|
+
/** Strict live identity only; completion must not revalidate source or routing inputs. */
|
|
280
|
+
currentRunIdentity():
|
|
281
|
+
| { ok: true; runId: string }
|
|
282
|
+
| { ok: false; reason: "no-identity" | "invalid-state" };
|
|
283
|
+
/** Strict single-snapshot routing read; malformed relevant claims refuse, never disappear. */
|
|
284
|
+
draftReviewContext():
|
|
285
|
+
| {
|
|
286
|
+
ok: true;
|
|
287
|
+
runId: string;
|
|
288
|
+
subject: "plan" | "objective" | "gist" | "refinement";
|
|
289
|
+
warmNodeClaim: { objective: string; node: string } | null;
|
|
290
|
+
}
|
|
291
|
+
| { ok: false; reason: "no-identity" | "invalid-state" };
|
|
292
|
+
readArtifact(name: string, options?: { provenance: "strict" }): ReadArtifactResult;
|
|
293
|
+
/** Strict writes refuse broken prior provenance; callers still own exclusion and failure residue. */
|
|
294
|
+
writeArtifact(
|
|
295
|
+
name: string,
|
|
296
|
+
content: string,
|
|
297
|
+
options?: { provenance: "strict" },
|
|
298
|
+
): WriteArtifactResult;
|
|
299
|
+
/** Snapshot read of the rebuilt `objective_node_claim` (malformed ⇒ null). */
|
|
300
|
+
nodeClaim(): { objective: string; node: string } | null;
|
|
301
|
+
/** Snapshot read of the rebuilt `active_objective` (malformed/throwing ⇒ null). */
|
|
302
|
+
activeObjective(): string | null;
|
|
303
|
+
/**
|
|
304
|
+
* Shape-validated, fail-open read of the LIVE SESSION's rebuilt `active_plan_ref` — session
|
|
305
|
+
* linkage ONLY, deliberately with no checkout `cache.plan-ref` fallback (the checkout selector
|
|
306
|
+
* can name a future plan unrelated to this session; the checkout-first read is
|
|
307
|
+
* `substrate/workflowState.ts::activePlanRef`, a different authority). Absent, malformed, or
|
|
308
|
+
* unreadable linkage reads null. For continuation rendering, never permission, verified
|
|
309
|
+
* linkage, artifact validation, or review routing.
|
|
310
|
+
*/
|
|
311
|
+
activeSessionPlanRef(): PlanRef | null;
|
|
312
|
+
/** Fail-open read of the rebuilt `review_posts` ledger (malformed rows dropped, never a refusal). */
|
|
313
|
+
reviewPosts(): ReviewPostRow[];
|
|
314
|
+
apply(change: WorkflowChange): WorkflowChangeResult;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ------------------------------------------------------------------------------- the engine
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Validate a session-artifact name at the seam: non-empty, no path separators (the artifact
|
|
321
|
+
* name keys the pointer map and joins under the run's data dir — a separator would escape it).
|
|
322
|
+
* Returns the problem string, or `null` when the name is safe. Name policy is refusal policy —
|
|
323
|
+
* it lives with the engine, not the backings.
|
|
324
|
+
*/
|
|
325
|
+
export function sessionArtifactNameProblem(name: string): string | null {
|
|
326
|
+
if (name.trim() === "") return "session artifact name is empty";
|
|
327
|
+
if (name.includes("/") || name.includes("\\")) {
|
|
328
|
+
return `session artifact name ${JSON.stringify(name)} carries a path separator`;
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The ONE persisted-pointer decode: accept a rebuilt `session_artifacts` value only when it is
|
|
335
|
+
* SHAPE-SOUND, and narrow the return to exactly the two fields the engine dereferences —
|
|
336
|
+
* branch data is unvalidated (`rebuildWorkflowState` trusts entry data), so a malformed session
|
|
337
|
+
* entry can put `null` — or anything else — where a pointer belongs, and the OTHER persisted
|
|
338
|
+
* fields (`path`, `name`, `at`) stay untrusted `unknown` even on a sound value (`path` is always
|
|
339
|
+
* re-derived, `name` is the map key, `at` has no consumer). Anything unsound reads as "no
|
|
340
|
+
* pointer" and never throws. Tests asserting persisted-pointer facts narrow through this —
|
|
341
|
+
* never a cast.
|
|
342
|
+
*/
|
|
343
|
+
export function soundPointer(candidate: unknown): { run_id: string; digest: string } | null {
|
|
344
|
+
if (typeof candidate !== "object" || candidate === null) return null;
|
|
345
|
+
const pointer = candidate as Record<string, unknown>;
|
|
346
|
+
if (typeof pointer.run_id !== "string" || typeof pointer.digest !== "string") return null;
|
|
347
|
+
return { run_id: pointer.run_id, digest: pointer.digest };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Per-name pointer identity: same run_id + same digest (each side narrowed via the decode). */
|
|
351
|
+
function artifactMapsEqual(
|
|
352
|
+
rebuilt: Record<string, unknown> | null | undefined,
|
|
353
|
+
expected: Record<string, unknown> | null | undefined,
|
|
354
|
+
): boolean {
|
|
355
|
+
const a = rebuilt ?? {};
|
|
356
|
+
const b = expected ?? {};
|
|
357
|
+
const names = Object.keys(b);
|
|
358
|
+
if (Object.keys(a).length !== names.length) return false;
|
|
359
|
+
return names.every((name) => {
|
|
360
|
+
const ra = soundPointer(a[name]);
|
|
361
|
+
const rb = soundPointer(b[name]);
|
|
362
|
+
// Unsound on both sides (junk siblings carried forward by the merged-map spread) compares
|
|
363
|
+
// equal — the append is verified on the pointers it can vouch for, never on junk shape.
|
|
364
|
+
if (ra === null || rb === null) return ra === rb;
|
|
365
|
+
return ra.run_id === rb.run_id && ra.digest === rb.digest;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Strict `review_posts` decode for the append pre-read — the ONE row schema is `reviewPostsOf`,
|
|
371
|
+
* consumed strictly: a present-but-malformed persisted ledger (non-array, or any row the
|
|
372
|
+
* tolerant decode would DROP) refuses with a problem naming the malformed variant instead of
|
|
373
|
+
* silently narrowing (a tolerant pre-read would let the whole-list LWW re-append ERASE
|
|
374
|
+
* malformed-but-possibly-real rows, violating the ledger invariant that a confirmed post is
|
|
375
|
+
* never erased by a write). Extra row fields are narrowed out, never a refusal.
|
|
376
|
+
*/
|
|
377
|
+
function strictReviewPosts(raw: unknown): { rows: ReviewPostRow[] } | { malformed: string } {
|
|
378
|
+
if (!Array.isArray(raw)) return { malformed: `not a list (${JSON.stringify(raw)})` };
|
|
379
|
+
const rows = reviewPostsOf(raw);
|
|
380
|
+
if (rows.length !== raw.length) {
|
|
381
|
+
return {
|
|
382
|
+
malformed: `${raw.length - rows.length} malformed row(s) in ${JSON.stringify(raw)}`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
return { rows };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The two ports the engine runs over — the backings supply ONLY these. */
|
|
389
|
+
export interface WorkflowSessionDeps {
|
|
390
|
+
state: SessionStateStore;
|
|
391
|
+
artifacts: ArtifactContentStore;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The safe-narrowed active run id: the rebuilt `run_id`, non-empty AND safe as a path component
|
|
396
|
+
* (`isSafeRunId` — an unsafe persisted id degrades to no-identity BEFORE any path derivation);
|
|
397
|
+
* a throwing rebuild degrades to null (no resolvable identity, never a stamp).
|
|
398
|
+
*/
|
|
399
|
+
function activeRunId(state: SessionStateStore): string | null {
|
|
400
|
+
try {
|
|
401
|
+
const runId = state.rebuild().run_id;
|
|
402
|
+
if (typeof runId === "string" && isSafeRunId(runId)) return runId;
|
|
403
|
+
} catch {
|
|
404
|
+
// a throwing rebuild means no resolvable identity — degrade to null
|
|
405
|
+
}
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The rebuilt `objective_node_claim`, read fail-open (malformed/throwing rebuild → null). */
|
|
410
|
+
function readClaim(state: SessionStateStore): { objective: string; node: string } | null {
|
|
411
|
+
try {
|
|
412
|
+
const claim = state.rebuild().objective_node_claim ?? null;
|
|
413
|
+
if (
|
|
414
|
+
claim !== null &&
|
|
415
|
+
typeof claim.objective === "string" &&
|
|
416
|
+
claim.objective !== "" &&
|
|
417
|
+
typeof claim.node === "string" &&
|
|
418
|
+
claim.node !== ""
|
|
419
|
+
) {
|
|
420
|
+
return claim;
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
} catch {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** The rebuilt `active_objective`, read fail-open (malformed/throwing rebuild ⇒ null). */
|
|
429
|
+
function readActiveObjective(state: SessionStateStore): string | null {
|
|
430
|
+
try {
|
|
431
|
+
const value = state.rebuild().active_objective ?? null;
|
|
432
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** Non-blank string guard for the session plan-ref decode (bytes are preserved, never trimmed). */
|
|
439
|
+
const nonblank = (value: unknown): value is string =>
|
|
440
|
+
typeof value === "string" && value.trim() !== "";
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The ONE session-only plan-ref decode: the rebuilt `active_plan_ref` inspected as `unknown`
|
|
444
|
+
* (branch data is unvalidated) and accepted only in its persisted shape — non-blank string
|
|
445
|
+
* `provider`/`pr_id`/`url`, an all-string `labels` list, a REQUIRED `objective_id` that is null
|
|
446
|
+
* or a string, and `base` absent, null, or a string. The return is a reconstructed `PlanRef`
|
|
447
|
+
* carrying exactly those fields (extra persisted keys never escape; `base` omission is preserved
|
|
448
|
+
* versus an explicit null; no trimming, provider constraint, or URL parse). Fail-open: absent,
|
|
449
|
+
* malformed, or throwing rebuilds read null — the only consumer renders optional continuation
|
|
450
|
+
* guidance, so unreadability must degrade to "no plan named", never a thrown handler. One fresh
|
|
451
|
+
* rebuild per call; no checkout, artifact, append, or memoization.
|
|
452
|
+
*/
|
|
453
|
+
function readActiveSessionPlanRef(state: SessionStateStore): PlanRef | null {
|
|
454
|
+
try {
|
|
455
|
+
const ref: unknown = state.rebuild().active_plan_ref;
|
|
456
|
+
if (typeof ref !== "object" || ref === null) return null;
|
|
457
|
+
const { provider, pr_id, url, labels, objective_id, base } = ref as Record<string, unknown>;
|
|
458
|
+
if (!nonblank(provider) || !nonblank(pr_id) || !nonblank(url)) return null;
|
|
459
|
+
if (!Array.isArray(labels) || !labels.every((l): l is string => typeof l === "string")) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
if (objective_id !== null && typeof objective_id !== "string") return null;
|
|
463
|
+
if (base !== undefined && base !== null && typeof base !== "string") return null;
|
|
464
|
+
return {
|
|
465
|
+
provider,
|
|
466
|
+
pr_id,
|
|
467
|
+
url,
|
|
468
|
+
labels: [...labels],
|
|
469
|
+
objective_id,
|
|
470
|
+
...(base !== undefined ? { base } : {}),
|
|
471
|
+
};
|
|
472
|
+
} catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** The rebuilt `review_posts` ledger, read fail-open (malformed rows drop; a throwing rebuild ⇒ []). */
|
|
478
|
+
function readReviewPosts(state: SessionStateStore): ReviewPostRow[] {
|
|
479
|
+
try {
|
|
480
|
+
return reviewPostsOf(state.rebuild().review_posts);
|
|
481
|
+
} catch {
|
|
482
|
+
return [];
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function strictArtifactMap(raw: unknown): Record<string, SessionArtifactPointer> {
|
|
487
|
+
if (raw === undefined || raw === null) return {};
|
|
488
|
+
if (
|
|
489
|
+
typeof raw !== "object" ||
|
|
490
|
+
Array.isArray(raw) ||
|
|
491
|
+
(Object.getPrototypeOf(raw) !== Object.prototype && Object.getPrototypeOf(raw) !== null)
|
|
492
|
+
) {
|
|
493
|
+
throw new Error("session_artifacts map is malformed");
|
|
494
|
+
}
|
|
495
|
+
const entries: [string, SessionArtifactPointer][] = [];
|
|
496
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
497
|
+
if (
|
|
498
|
+
!isSafeRunId(key) ||
|
|
499
|
+
key.trim() === "" ||
|
|
500
|
+
typeof value !== "object" ||
|
|
501
|
+
value === null ||
|
|
502
|
+
Array.isArray(value)
|
|
503
|
+
)
|
|
504
|
+
throw new Error("session_artifacts pointer is malformed");
|
|
505
|
+
const p = value as Record<string, unknown>;
|
|
506
|
+
if (
|
|
507
|
+
typeof p.run_id !== "string" ||
|
|
508
|
+
!isSafeRunId(p.run_id) ||
|
|
509
|
+
typeof p.digest !== "string" ||
|
|
510
|
+
!/^sha256:[0-9a-f]{64}$/.test(p.digest) ||
|
|
511
|
+
p.name !== key ||
|
|
512
|
+
typeof p.path !== "string" ||
|
|
513
|
+
p.path.length === 0 ||
|
|
514
|
+
typeof p.at !== "string" ||
|
|
515
|
+
p.at.length === 0
|
|
516
|
+
) {
|
|
517
|
+
throw new Error("session_artifacts pointer is malformed");
|
|
518
|
+
}
|
|
519
|
+
entries.push([key, { run_id: p.run_id, name: key, path: p.path, digest: p.digest, at: p.at }]);
|
|
520
|
+
}
|
|
521
|
+
return Object.fromEntries(entries);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function strictArtifactRead(deps: WorkflowSessionDeps, name: string): ReadArtifactResult {
|
|
525
|
+
const invalid = (problem: string): ReadArtifactResult => ({ status: "invalid", problem });
|
|
526
|
+
if (!isSafeRunId(name) || sessionArtifactNameProblem(name) !== null) {
|
|
527
|
+
return invalid("unsafe session artifact name");
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
// One snapshot: a throwing/malformed identity read is never evidence of absence.
|
|
531
|
+
const snapshot = deps.state.rebuild();
|
|
532
|
+
if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot)) {
|
|
533
|
+
return invalid("session artifact state is malformed");
|
|
534
|
+
}
|
|
535
|
+
const runId = snapshot.run_id;
|
|
536
|
+
if (typeof runId !== "string" || !isSafeRunId(runId)) {
|
|
537
|
+
return invalid("session has no safe run_id — strict artifact reads need identity");
|
|
538
|
+
}
|
|
539
|
+
// Validate siblings too: the next whole-map append must not launder malformed provenance.
|
|
540
|
+
const map = strictArtifactMap(snapshot.session_artifacts);
|
|
541
|
+
const pointer = Object.hasOwn(map, name) ? map[name] : null;
|
|
542
|
+
// Never dereference inherited pointers. Inspect ONLY the active run's namespace, including
|
|
543
|
+
// when there is no pointer: bytes without current-run provenance are an orphan, not absent.
|
|
544
|
+
const loaded = deps.artifacts.loadStrict(runId, name);
|
|
545
|
+
if (loaded.status === "io-error") return invalid(`session artifact ${name} I/O failure`);
|
|
546
|
+
if (pointer == null || pointer.run_id !== runId) {
|
|
547
|
+
return loaded.status === "absent"
|
|
548
|
+
? { status: "absent" }
|
|
549
|
+
: invalid(`session artifact ${name} has no current-run provenance (orphan)`);
|
|
550
|
+
}
|
|
551
|
+
if (loaded.status === "absent")
|
|
552
|
+
return invalid(`session artifact ${name} has a pointer but no file`);
|
|
553
|
+
if (digestSessionData(loaded.content) !== pointer.digest) {
|
|
554
|
+
return invalid(`session artifact ${name} digest mismatch (rewound or modified)`);
|
|
555
|
+
}
|
|
556
|
+
return { status: "found", content: loaded.content };
|
|
557
|
+
} catch {
|
|
558
|
+
return invalid(`session artifact ${name} strict read failed`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Open a session over the two ports — the ONE deep implementation of the seam. ALWAYS opens;
|
|
564
|
+
* `runId: null` is the identity-less arm (artifact writes reject, reads read absent; the state
|
|
565
|
+
* ops are store-backed and identity-independent). `runId` is captured at open and re-derived
|
|
566
|
+
* per artifact call (a fork entry appended mid-session re-keys the artifact ops — today's
|
|
567
|
+
* behavior). Loudness: the strict-append port reports its own read-back failures (the
|
|
568
|
+
* classified-append seam's report() channel inside the production binding); the engine warns on
|
|
569
|
+
* stderr for the loud read tier (rewind/tamper) and the unreadable-after-write arm.
|
|
570
|
+
*/
|
|
571
|
+
export function openWorkflowSession(deps: WorkflowSessionDeps): WorkflowSession {
|
|
572
|
+
const { state, artifacts } = deps;
|
|
573
|
+
|
|
574
|
+
return {
|
|
575
|
+
runId: activeRunId(state),
|
|
576
|
+
currentRunIdentity() {
|
|
577
|
+
try {
|
|
578
|
+
const snapshot = state.rebuild();
|
|
579
|
+
if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot))
|
|
580
|
+
return { ok: false, reason: "invalid-state" };
|
|
581
|
+
const runId = snapshot.run_id;
|
|
582
|
+
return typeof runId === "string" && isSafeRunId(runId)
|
|
583
|
+
? { ok: true, runId }
|
|
584
|
+
: { ok: false, reason: "no-identity" };
|
|
585
|
+
} catch {
|
|
586
|
+
return { ok: false, reason: "invalid-state" };
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
draftReviewContext() {
|
|
590
|
+
try {
|
|
591
|
+
const snapshot = state.rebuild();
|
|
592
|
+
const runId = snapshot.run_id;
|
|
593
|
+
if (typeof runId !== "string" || !isSafeRunId(runId))
|
|
594
|
+
return { ok: false, reason: "no-identity" };
|
|
595
|
+
const stage = snapshot.stage;
|
|
596
|
+
if (stage != null && (typeof stage !== "string" || !stage.trim()))
|
|
597
|
+
return { ok: false, reason: "invalid-state" };
|
|
598
|
+
const subject =
|
|
599
|
+
stage === "objective-author" || stage === "objective-save"
|
|
600
|
+
? "objective"
|
|
601
|
+
: stage === "gist-author"
|
|
602
|
+
? "gist"
|
|
603
|
+
: stage === REFINE_STAGE_ID
|
|
604
|
+
? "refinement"
|
|
605
|
+
: "plan";
|
|
606
|
+
const raw: unknown = subject === "plan" ? snapshot.objective_node_claim : null;
|
|
607
|
+
let warmNodeClaim: { objective: string; node: string } | null = null;
|
|
608
|
+
if (raw != null) {
|
|
609
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
610
|
+
return { ok: false, reason: "invalid-state" };
|
|
611
|
+
const claim = raw as Record<string, unknown>;
|
|
612
|
+
if (
|
|
613
|
+
Object.keys(claim).length !== 2 ||
|
|
614
|
+
typeof claim.objective !== "string" ||
|
|
615
|
+
!claim.objective.trim() ||
|
|
616
|
+
typeof claim.node !== "string" ||
|
|
617
|
+
!claim.node.trim()
|
|
618
|
+
)
|
|
619
|
+
return { ok: false, reason: "invalid-state" };
|
|
620
|
+
warmNodeClaim = { objective: claim.objective, node: claim.node };
|
|
621
|
+
}
|
|
622
|
+
return { ok: true, runId, subject, warmNodeClaim };
|
|
623
|
+
} catch {
|
|
624
|
+
return { ok: false, reason: "invalid-state" };
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
readArtifact(name: string, options): ReadArtifactResult {
|
|
628
|
+
if (options?.provenance === "strict") return strictArtifactRead(deps, name);
|
|
629
|
+
const runId = activeRunId(state);
|
|
630
|
+
if (runId === null) return { status: "absent" }; // no identity — silent, branchable
|
|
631
|
+
let pointer: { run_id: string; digest: string } | null;
|
|
632
|
+
try {
|
|
633
|
+
pointer = soundPointer(state.rebuild().session_artifacts?.[name]);
|
|
634
|
+
} catch {
|
|
635
|
+
return { status: "absent" };
|
|
636
|
+
}
|
|
637
|
+
if (pointer === null) return { status: "absent" }; // no pointer — or a malformed one (no provenance)
|
|
638
|
+
if (pointer.run_id !== runId) return { status: "absent" }; // fork isolation — by design, silent
|
|
639
|
+
|
|
640
|
+
const content = artifacts.load(runId, name);
|
|
641
|
+
if (content === null) {
|
|
642
|
+
console.error(
|
|
643
|
+
`perk: warning: session artifact ${name} has a pointer but no file at ` +
|
|
644
|
+
artifacts.displayPath(runId, name),
|
|
645
|
+
);
|
|
646
|
+
return { status: "invalid", problem: `session artifact ${name} has a pointer but no file` };
|
|
647
|
+
}
|
|
648
|
+
if (digestSessionData(content) !== pointer.digest) {
|
|
649
|
+
console.error(
|
|
650
|
+
`perk: warning: session artifact ${artifacts.displayPath(runId, name)} digest mismatch ` +
|
|
651
|
+
"(rewound or modified) — refusing",
|
|
652
|
+
);
|
|
653
|
+
return {
|
|
654
|
+
status: "invalid",
|
|
655
|
+
problem: `session artifact ${name} digest mismatch (rewound or modified)`,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
return { status: "found", content };
|
|
659
|
+
},
|
|
660
|
+
writeArtifact(name: string, content: string, options): WriteArtifactResult {
|
|
661
|
+
const strict = options?.provenance === "strict";
|
|
662
|
+
if (strict) {
|
|
663
|
+
const prior = strictArtifactRead(deps, name);
|
|
664
|
+
if (prior.status === "invalid") return { status: "rejected", problem: prior.problem };
|
|
665
|
+
}
|
|
666
|
+
let effectAttempted = false;
|
|
667
|
+
try {
|
|
668
|
+
const nameProblem = sessionArtifactNameProblem(name);
|
|
669
|
+
if (nameProblem !== null) return { status: "rejected", problem: nameProblem };
|
|
670
|
+
|
|
671
|
+
const runId = activeRunId(state);
|
|
672
|
+
if (runId === null) {
|
|
673
|
+
return {
|
|
674
|
+
status: "rejected",
|
|
675
|
+
problem: "session has no run_id — session artifacts need identity",
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// The unchanged short-circuit: a byte-identical rewrite is a no-op (no store, no fresh
|
|
680
|
+
// pointer entry) — the recorded pointer already proves exactly these bytes. QUIET by
|
|
681
|
+
// design: a stale/broken/malformed pointer simply fails the probe and the write proceeds
|
|
682
|
+
// (the probe must never emit the read tier's rewind warnings). The receipt is fully
|
|
683
|
+
// re-derived — junk persisted fields are unobservable.
|
|
684
|
+
let current: { run_id: string; digest: string } | null;
|
|
685
|
+
try {
|
|
686
|
+
const snapshot = state.rebuild();
|
|
687
|
+
if (strict && snapshot.run_id !== runId) throw new Error("session identity changed");
|
|
688
|
+
current = strict
|
|
689
|
+
? (strictArtifactMap(snapshot.session_artifacts)[name] ?? null)
|
|
690
|
+
: soundPointer(snapshot.session_artifacts?.[name]);
|
|
691
|
+
} catch (error) {
|
|
692
|
+
if (strict) throw error;
|
|
693
|
+
current = null;
|
|
694
|
+
}
|
|
695
|
+
if (current !== null && current.run_id === runId) {
|
|
696
|
+
const checked = strict ? strictArtifactRead(deps, name) : null;
|
|
697
|
+
if (checked !== null && checked.status !== "found") {
|
|
698
|
+
return {
|
|
699
|
+
status: "rejected",
|
|
700
|
+
problem: `session artifact ${name} provenance changed before write`,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const stored =
|
|
704
|
+
checked?.status === "found" ? checked.content : artifacts.load(runId, name);
|
|
705
|
+
if (
|
|
706
|
+
stored !== null &&
|
|
707
|
+
digestSessionData(stored) === current.digest &&
|
|
708
|
+
current.digest === digestSessionData(content)
|
|
709
|
+
) {
|
|
710
|
+
return {
|
|
711
|
+
status: "unchanged",
|
|
712
|
+
receipt: {
|
|
713
|
+
runId,
|
|
714
|
+
path: artifacts.displayPath(runId, name),
|
|
715
|
+
digest: digestSessionData(stored),
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
effectAttempted = true;
|
|
722
|
+
if (!artifacts.store(runId, name, content)) {
|
|
723
|
+
// the port already warned; never point at an unwritten file
|
|
724
|
+
return {
|
|
725
|
+
status: "rejected",
|
|
726
|
+
problem: `could not write session data ${name} (see warnings)`,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Digest the bytes as read back from the store — catches encoding/disk surprises.
|
|
731
|
+
const strictReadBack = strict ? artifacts.loadStrict(runId, name) : null;
|
|
732
|
+
const readBack =
|
|
733
|
+
strictReadBack === null
|
|
734
|
+
? artifacts.load(runId, name)
|
|
735
|
+
: strictReadBack.status === "found"
|
|
736
|
+
? strictReadBack.content
|
|
737
|
+
: null;
|
|
738
|
+
if (readBack === null) {
|
|
739
|
+
const problem = `session artifact ${artifacts.displayPath(runId, name)} unreadable after write`;
|
|
740
|
+
console.error(`perk: warning: ${problem}`);
|
|
741
|
+
return { status: "unverified", problem };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (strict && readBack !== content) {
|
|
745
|
+
return {
|
|
746
|
+
status: "unverified",
|
|
747
|
+
problem: `session artifact ${name} write read-back differs`,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// The persisted wire shape (contracts §8.3) — constructed at the storage boundary,
|
|
752
|
+
// internal to the engine, never exposed through results.
|
|
753
|
+
const pointer: SessionArtifactPointer = {
|
|
754
|
+
run_id: runId,
|
|
755
|
+
name,
|
|
756
|
+
path: artifacts.displayPath(runId, name),
|
|
757
|
+
digest: digestSessionData(readBack),
|
|
758
|
+
at: new Date().toISOString(),
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
// Per-field LWW: each append must carry the WHOLE merged map so sibling artifacts survive
|
|
762
|
+
// (junk siblings carry forward unchanged — existing LWW behavior).
|
|
763
|
+
const latest = state.rebuild();
|
|
764
|
+
if (strict && latest.run_id !== runId) {
|
|
765
|
+
return { status: "unverified", problem: "session identity changed after artifact write" };
|
|
766
|
+
}
|
|
767
|
+
const merged: Record<string, unknown> = {
|
|
768
|
+
...(strict
|
|
769
|
+
? strictArtifactMap(latest.session_artifacts)
|
|
770
|
+
: (latest.session_artifacts ?? {})),
|
|
771
|
+
[name]: pointer,
|
|
772
|
+
};
|
|
773
|
+
const appended = state.appendVerified({
|
|
774
|
+
data: { session_artifacts: merged },
|
|
775
|
+
field: "session_artifacts",
|
|
776
|
+
expected: merged,
|
|
777
|
+
scope: "session-data",
|
|
778
|
+
failure: `session_artifacts pointer read-back failed for ${name}`,
|
|
779
|
+
equals: artifactMapsEqual,
|
|
780
|
+
});
|
|
781
|
+
if (appended.status !== "applied") {
|
|
782
|
+
// already reported through the strict-append port
|
|
783
|
+
return {
|
|
784
|
+
status: "unverified",
|
|
785
|
+
problem: `session_artifacts pointer read-back failed for ${name}`,
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
if (strict) {
|
|
789
|
+
const verified = strictArtifactRead(deps, name);
|
|
790
|
+
if (verified.status !== "found" || verified.content !== content) {
|
|
791
|
+
return {
|
|
792
|
+
status: "unverified",
|
|
793
|
+
problem: `session artifact ${name} strict write verification failed`,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return {
|
|
798
|
+
status: "applied",
|
|
799
|
+
receipt: { runId, path: pointer.path, digest: pointer.digest },
|
|
800
|
+
};
|
|
801
|
+
} catch (error) {
|
|
802
|
+
if (!strict) throw error;
|
|
803
|
+
return {
|
|
804
|
+
status: effectAttempted ? "unverified" : "rejected",
|
|
805
|
+
problem: `session artifact ${name} strict write failed`,
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
nodeClaim() {
|
|
810
|
+
return readClaim(state);
|
|
811
|
+
},
|
|
812
|
+
activeObjective() {
|
|
813
|
+
return readActiveObjective(state);
|
|
814
|
+
},
|
|
815
|
+
activeSessionPlanRef() {
|
|
816
|
+
return readActiveSessionPlanRef(state);
|
|
817
|
+
},
|
|
818
|
+
reviewPosts() {
|
|
819
|
+
return readReviewPosts(state);
|
|
820
|
+
},
|
|
821
|
+
apply(change: WorkflowChange): WorkflowChangeResult {
|
|
822
|
+
switch (change.kind) {
|
|
823
|
+
case "link-plan-ref": {
|
|
824
|
+
const ref = change.ref;
|
|
825
|
+
// The pre-read dedupe is deliberately NOT try/caught — a throwing rebuild propagates.
|
|
826
|
+
if (planRefsEqual(state.rebuild().active_plan_ref ?? null, ref)) {
|
|
827
|
+
return { status: "unchanged" };
|
|
828
|
+
}
|
|
829
|
+
// The classified strict-append distinguishes a PROVEN refusal-before-effect (the
|
|
830
|
+
// append threw and the rebuilt field never changed — `rejected`) from a read-back
|
|
831
|
+
// miss (`unverified`: an append may have landed unproven); its report() path stays
|
|
832
|
+
// the loudness channel. `ClassifiedAppend` IS the seam's change vocabulary.
|
|
833
|
+
return state.appendVerified({
|
|
834
|
+
data: { active_plan_ref: ref },
|
|
835
|
+
field: "active_plan_ref",
|
|
836
|
+
expected: ref,
|
|
837
|
+
scope: "plan-save",
|
|
838
|
+
failure: `plan-ref read-back failed for ${ref.provider}:${ref.pr_id}`,
|
|
839
|
+
equals: planRefsEqual,
|
|
840
|
+
});
|
|
841
|
+
}
|
|
842
|
+
case "clear-node-claim": {
|
|
843
|
+
const claim = change.claim;
|
|
844
|
+
// Never clobber an unrelated claim: clear only when the LIVE claim matches BOTH
|
|
845
|
+
// fields (same-node/different-objective stays untouched).
|
|
846
|
+
if (!nodeClaimsEqual(readClaim(state), claim)) return { status: "unchanged" };
|
|
847
|
+
return state.appendVerified({
|
|
848
|
+
data: { objective_node_claim: null },
|
|
849
|
+
field: "objective_node_claim",
|
|
850
|
+
expected: null,
|
|
851
|
+
scope: "plan-save",
|
|
852
|
+
failure: `objective_node_claim clear read-back failed for node ${claim.node}`,
|
|
853
|
+
equals: nodeClaimsEqual,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
case "record-node-claim": {
|
|
857
|
+
const claim = change.claim;
|
|
858
|
+
// The idempotent re-claim short-circuit: an equal live claim rebuilds identically, so
|
|
859
|
+
// a re-append would carry no semantic payload (the claim has no timestamp).
|
|
860
|
+
if (nodeClaimsEqual(readClaim(state), claim)) return { status: "unchanged" };
|
|
861
|
+
return state.appendVerified({
|
|
862
|
+
data: { objective_node_claim: claim },
|
|
863
|
+
field: "objective_node_claim",
|
|
864
|
+
expected: claim,
|
|
865
|
+
scope: "objective-plan",
|
|
866
|
+
failure: `objective_node_claim read-back failed for #${claim.objective} node ${claim.node}`,
|
|
867
|
+
equals: nodeClaimsEqual,
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
case "link-objective": {
|
|
871
|
+
const objective = change.objective;
|
|
872
|
+
if (readActiveObjective(state) === objective) return { status: "unchanged" };
|
|
873
|
+
return state.appendVerified({
|
|
874
|
+
data: { active_objective: objective },
|
|
875
|
+
field: "active_objective",
|
|
876
|
+
expected: objective,
|
|
877
|
+
scope: "objective-save",
|
|
878
|
+
failure: `active_objective read-back failed for #${objective}`,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
case "enter-refinement-stage": {
|
|
882
|
+
if (state.rebuild().stage === REFINE_STAGE_ID) return { status: "unchanged" };
|
|
883
|
+
return state.appendVerified({
|
|
884
|
+
data: { stage: REFINE_STAGE_ID },
|
|
885
|
+
field: "stage",
|
|
886
|
+
expected: REFINE_STAGE_ID,
|
|
887
|
+
scope: "objective-refine",
|
|
888
|
+
failure: `stage read-back failed for ${REFINE_STAGE_ID}`,
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
case "record-pr-review": {
|
|
892
|
+
// No pre-read/dedupe by design (the single-use wave state is feature-op policy
|
|
893
|
+
// upstream): at runtime this yields applied/unverified/rejected only.
|
|
894
|
+
return state.appendVerified({
|
|
895
|
+
data: { last_pr_review: change.record },
|
|
896
|
+
field: "last_pr_review",
|
|
897
|
+
expected: change.record,
|
|
898
|
+
scope: "pr-review",
|
|
899
|
+
failure: "last_pr_review read-back failed",
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
case "record-review": {
|
|
903
|
+
// No pre-read/dedupe by design (the resume guard is feature-op policy upstream): at
|
|
904
|
+
// runtime this yields applied/unverified/rejected only.
|
|
905
|
+
return state.appendVerified({
|
|
906
|
+
data: { last_review: change.record },
|
|
907
|
+
field: "last_review",
|
|
908
|
+
expected: change.record,
|
|
909
|
+
scope: "review",
|
|
910
|
+
failure: "last_review read-back failed",
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
case "record-review-batch": {
|
|
914
|
+
// No pre-read/dedupe by design (the corroborated-success ordering is feature-op
|
|
915
|
+
// policy upstream): at runtime this yields applied/unverified/rejected only.
|
|
916
|
+
return state.appendVerified({
|
|
917
|
+
data: { last_review_batch: change.record },
|
|
918
|
+
field: "last_review_batch",
|
|
919
|
+
expected: change.record,
|
|
920
|
+
scope: "address",
|
|
921
|
+
failure: "last_review_batch read-back failed",
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
case "append-review-post": {
|
|
925
|
+
// Read-rebuild-append: each write carries the whole ordered list (the resume reader
|
|
926
|
+
// sees every confirmed post); order-sensitive read-back. The pre-read is FAIL-CLOSED
|
|
927
|
+
// and STRICT — deliberately NOT the fail-open `reviewPosts()` read: appending over an
|
|
928
|
+
// unrebuildable OR malformed ledger would LWW-overwrite possibly-real earlier rows,
|
|
929
|
+
// and the resume guard would then permit duplicate GitHub reviews. Refusing before
|
|
930
|
+
// any effect keeps the asymmetric trust rule intact (a row may be MISSING spuriously,
|
|
931
|
+
// never PRESENT spuriously — and never erased by a write). An ABSENT field is the
|
|
932
|
+
// normal first append (the empty prior ledger).
|
|
933
|
+
let prior: ReviewPostRow[];
|
|
934
|
+
try {
|
|
935
|
+
const raw = state.rebuild().review_posts;
|
|
936
|
+
if (raw === undefined || raw === null) {
|
|
937
|
+
prior = [];
|
|
938
|
+
} else {
|
|
939
|
+
const decoded = strictReviewPosts(raw);
|
|
940
|
+
if ("malformed" in decoded) {
|
|
941
|
+
return {
|
|
942
|
+
status: "rejected",
|
|
943
|
+
problem:
|
|
944
|
+
"review_posts ledger is malformed — refusing to append over an unknown " +
|
|
945
|
+
`ledger: ${decoded.malformed}`,
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
prior = decoded.rows;
|
|
949
|
+
}
|
|
950
|
+
} catch (error) {
|
|
951
|
+
return {
|
|
952
|
+
status: "rejected",
|
|
953
|
+
problem:
|
|
954
|
+
"review_posts ledger rebuild failed — refusing to append over an unknown " +
|
|
955
|
+
`ledger: ${String(error)}`,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
const posts: ReviewPostRow[] = [...prior, change.row];
|
|
959
|
+
return state.appendVerified({
|
|
960
|
+
data: { review_posts: posts },
|
|
961
|
+
field: "review_posts",
|
|
962
|
+
expected: posts,
|
|
963
|
+
scope: "review",
|
|
964
|
+
failure: "review_posts read-back failed",
|
|
965
|
+
equals: reviewPostsEqual,
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
}
|