@brainervirus/workit-core 1.0.11 → 1.0.12
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/package.json +1 -1
- package/src/core/authority.ts +53 -11
- package/src/core/branch.ts +39 -2
- package/src/core/doctor.ts +25 -12
- package/src/core/external-action-effects.ts +86 -16
- package/src/core/external-action.ts +192 -15
- package/src/core/methods.ts +8 -8
- package/src/core/runtime-identity.ts +33 -0
- package/src/core/task-contract.ts +107 -0
- package/src/core/task-engine.ts +178 -220
- package/src/core/task-evaluation.ts +127 -22
- package/src/core/task-store.ts +4 -6
- package/src/core/workers.ts +11 -1
- package/src/core.ts +12 -0
package/package.json
CHANGED
package/src/core/authority.ts
CHANGED
|
@@ -181,12 +181,57 @@ export type ReconcileActionInput = {
|
|
|
181
181
|
|
|
182
182
|
type Workflow = { steps: string[]; completed: string[] };
|
|
183
183
|
|
|
184
|
+
/** One typed step of a chain reservation. Plain strings are commit
|
|
185
|
+
* messages (the historical plan_steps shape); branch and pr steps carry
|
|
186
|
+
* their match key. Keys are the canonical consumption alphabet. */
|
|
187
|
+
export type ChainStep =
|
|
188
|
+
| { kind: "commit"; message: string }
|
|
189
|
+
| { kind: "branch"; target: string }
|
|
190
|
+
| { kind: "pr" };
|
|
191
|
+
|
|
192
|
+
export const chainStepKey = (step: ChainStep): string =>
|
|
193
|
+
step.kind === "commit" ? step.message : step.kind === "branch" ? `branch:${step.target}` : "pr";
|
|
194
|
+
|
|
195
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
196
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
197
|
+
|
|
198
|
+
/** Normalize mixed plan steps to typed entries. Returns null for empty,
|
|
199
|
+
* over-long, malformed, or key-colliding lists (a commit message that
|
|
200
|
+
* spells exactly like a typed key is rejected rather than misrouted). */
|
|
201
|
+
export const normalizeChainSteps = (steps: unknown): ChainStep[] | null => {
|
|
202
|
+
if (!Array.isArray(steps) || steps.length === 0 || steps.length > 32) return null;
|
|
203
|
+
const normalized: ChainStep[] = [];
|
|
204
|
+
for (const step of steps) {
|
|
205
|
+
if (typeof step === "string") {
|
|
206
|
+
if (!step.trim()) return null;
|
|
207
|
+
normalized.push({ kind: "commit", message: step });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (!isRecord(step)) return null;
|
|
211
|
+
if (typeof step.branch === "string" && step.branch.trim() && Object.keys(step).length === 1) {
|
|
212
|
+
normalized.push({ kind: "branch", target: step.branch });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
if (step.pr === true && Object.keys(step).length === 1) {
|
|
216
|
+
normalized.push({ kind: "pr" });
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const keys = normalized.map(chainStepKey);
|
|
222
|
+
if (new Set(keys).size !== keys.length) return null;
|
|
223
|
+
return normalized;
|
|
224
|
+
};
|
|
225
|
+
|
|
184
226
|
const scopeEqual = (
|
|
185
227
|
left: Decision["binding"]["scope"],
|
|
186
228
|
right: Decision["binding"]["scope"],
|
|
187
229
|
): boolean => canonicalJson(left) === canonicalJson(right);
|
|
188
230
|
|
|
189
|
-
|
|
231
|
+
/** Parse the durable approved text into typed chain steps. Single commits
|
|
232
|
+
* and branch/PR operations carry no step list (empty, not an invalid
|
|
233
|
+
* chain); an invalid list is null and rejected by callers. */
|
|
234
|
+
const parseSteps = (content: string): ChainStep[] | null => {
|
|
190
235
|
let value: unknown;
|
|
191
236
|
try {
|
|
192
237
|
value = JSON.parse(content) as unknown;
|
|
@@ -195,7 +240,9 @@ const parseSteps = (content: string): string[] | null => {
|
|
|
195
240
|
}
|
|
196
241
|
if (typeof value !== "object" || value === null || !("steps" in value)) return [];
|
|
197
242
|
const steps = (value as { steps?: unknown }).steps;
|
|
198
|
-
|
|
243
|
+
if (!Array.isArray(steps)) return null;
|
|
244
|
+
const normalized = normalizeChainSteps(steps);
|
|
245
|
+
return normalized;
|
|
199
246
|
};
|
|
200
247
|
|
|
201
248
|
const decisionMatches = (
|
|
@@ -543,22 +590,17 @@ const validateAction = (
|
|
|
543
590
|
if (!content.ok) return content as Result<never>;
|
|
544
591
|
const requestedSteps = parseSteps(decision.binding.approvedContent);
|
|
545
592
|
if (requestedSteps === null)
|
|
546
|
-
return failure("invalid_input", "bounded action steps must be a
|
|
547
|
-
if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps))
|
|
593
|
+
return failure("invalid_input", "bounded action steps must be a unique non-empty array");
|
|
594
|
+
if (input.steps && canonicalJson(input.steps) !== canonicalJson(requestedSteps.map(chainStepKey)))
|
|
548
595
|
return failure("permission_denied", "bounded action steps do not match approved content");
|
|
549
|
-
if (
|
|
550
|
-
requestedSteps.some((step) => !step || typeof step !== "string") ||
|
|
551
|
-
new Set(requestedSteps).size !== requestedSteps.length
|
|
552
|
-
)
|
|
553
|
-
return failure("invalid_input", "bounded action steps must be unique and non-empty");
|
|
554
596
|
const stored = task.actionProgress?.find((item) => item.decisionId === input.decisionId);
|
|
555
597
|
const workflow = stored
|
|
556
598
|
? { steps: stored.steps, completed: stored.completedSteps }
|
|
557
|
-
: { steps: requestedSteps, completed: [] };
|
|
599
|
+
: { steps: requestedSteps.map(chainStepKey), completed: [] };
|
|
558
600
|
if (
|
|
559
601
|
workflow.steps.length &&
|
|
560
602
|
requestedSteps.length &&
|
|
561
|
-
canonicalJson(workflow.steps) !== canonicalJson(requestedSteps)
|
|
603
|
+
canonicalJson(workflow.steps) !== canonicalJson(requestedSteps.map(chainStepKey))
|
|
562
604
|
)
|
|
563
605
|
return failure("permission_denied", "bounded action scope changed");
|
|
564
606
|
return success(null, null, { entry, workflow });
|
package/src/core/branch.ts
CHANGED
|
@@ -281,6 +281,43 @@ export type BranchSetupResult =
|
|
|
281
281
|
| { error: string; phase: "preflight" | "post_mutation" };
|
|
282
282
|
|
|
283
283
|
// Port of scripts/branch/setup-branch.sh
|
|
284
|
+
export type BranchDirt = "clean" | "carry" | "stash-required";
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Classify working-tree dirt for branch setup. Untracked files ride along
|
|
288
|
+
* (`git switch -c` never touches them), and tracked modifications confined
|
|
289
|
+
* to `docs/` are branch-owned by construction in this workflow — both
|
|
290
|
+
* auto-carry with no stash question. Anything else keeps the stash-bound
|
|
291
|
+
* proposal, since the base-checkout sequence could disturb it. Any
|
|
292
|
+
* inspection failure reads as clean so a broken git surfaces at its own
|
|
293
|
+
* error instead of inventing a stash demand.
|
|
294
|
+
*/
|
|
295
|
+
export const classifyBranchDirt = (workspaceRoot: string): BranchDirt => {
|
|
296
|
+
let raw: string;
|
|
297
|
+
try {
|
|
298
|
+
raw = execFileSync("git", ["status", "--porcelain=v1"], {
|
|
299
|
+
cwd: path.resolve(workspaceRoot),
|
|
300
|
+
encoding: "utf8",
|
|
301
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
302
|
+
});
|
|
303
|
+
} catch {
|
|
304
|
+
return "clean";
|
|
305
|
+
}
|
|
306
|
+
let dirty = false;
|
|
307
|
+
for (const line of raw.split("\n")) {
|
|
308
|
+
if (!line.trim()) continue;
|
|
309
|
+
dirty = true;
|
|
310
|
+
const code = line.slice(0, 2);
|
|
311
|
+
if (code === "??") continue;
|
|
312
|
+
let entry = line.slice(3);
|
|
313
|
+
const arrow = entry.indexOf(" -> ");
|
|
314
|
+
if (arrow >= 0) entry = entry.slice(arrow + 4);
|
|
315
|
+
const unquoted = entry.startsWith('"') && entry.endsWith('"') ? entry.slice(1, -1) : entry;
|
|
316
|
+
if (unquoted !== "docs" && !unquoted.startsWith("docs/")) return "stash-required";
|
|
317
|
+
}
|
|
318
|
+
return dirty ? "carry" : "clean";
|
|
319
|
+
};
|
|
320
|
+
|
|
284
321
|
export const branchSetup = ({
|
|
285
322
|
action,
|
|
286
323
|
sdd_dir,
|
|
@@ -398,7 +435,7 @@ export const branchSetup = ({
|
|
|
398
435
|
};
|
|
399
436
|
if (current !== target) {
|
|
400
437
|
const dirty = Boolean(gitContext(cwd).status_short.trim());
|
|
401
|
-
if (
|
|
438
|
+
if (classifyBranchDirt(cwd) === "stash-required" && stash !== "yes") {
|
|
402
439
|
return {
|
|
403
440
|
error:
|
|
404
441
|
"dirty working tree — ask with native question, then call workit_branch_setup with stash=yes",
|
|
@@ -416,7 +453,7 @@ export const branchSetup = ({
|
|
|
416
453
|
return { error: ready.error ?? "ensure-base-branch failed", phase: "preflight" };
|
|
417
454
|
validatedBase = base;
|
|
418
455
|
}
|
|
419
|
-
if (dirty) {
|
|
456
|
+
if (dirty && stash === "yes") {
|
|
420
457
|
try {
|
|
421
458
|
exec(["stash", "push", "-u", "-m", `workit: pre-checkout ${target}`]);
|
|
422
459
|
} catch (error) {
|
package/src/core/doctor.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import os from "node:os";
|
|
23
23
|
import path from "node:path";
|
|
24
24
|
import { SUPPORT_MATRIX } from "./support-matrix";
|
|
25
|
+
import { bundleHashOfFile, isEphemeralCachePath } from "./runtime-identity";
|
|
25
26
|
import { EVENT } from "./boundary";
|
|
26
27
|
import { getDiagnosticLogger, isConfigObject } from "./config";
|
|
27
28
|
import { packageRoot } from "./package-root";
|
|
@@ -682,12 +683,7 @@ const checkUtility = (res: Resolved): DoctorCheck => {
|
|
|
682
683
|
const isFragileCachePin = (entry: string): boolean => {
|
|
683
684
|
const n = entry.replaceAll("\\", "/").toLowerCase();
|
|
684
685
|
if (!n.startsWith("file:")) return false;
|
|
685
|
-
return (
|
|
686
|
-
n.includes("/caches/pnpm/dlx/") ||
|
|
687
|
-
n.includes("/_npx/") ||
|
|
688
|
-
n.includes("/.pacquet/") ||
|
|
689
|
-
n.includes("/library/caches/pnpm/")
|
|
690
|
-
);
|
|
686
|
+
return isEphemeralCachePath(entry);
|
|
691
687
|
};
|
|
692
688
|
|
|
693
689
|
const staleEntry = (entry: string): "ok" | "stale" | "missing-file" | "fragile-cache" => {
|
|
@@ -936,12 +932,7 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
|
|
|
936
932
|
} catch {
|
|
937
933
|
target = "(unreadable)";
|
|
938
934
|
}
|
|
939
|
-
const
|
|
940
|
-
const fragile =
|
|
941
|
-
n.includes("/caches/pnpm/dlx/") ||
|
|
942
|
-
n.includes("/_npx/") ||
|
|
943
|
-
n.includes("/.pacquet/") ||
|
|
944
|
-
n.includes("/library/caches/pnpm/");
|
|
935
|
+
const fragile = isEphemeralCachePath(target);
|
|
945
936
|
return {
|
|
946
937
|
id: "stale_install",
|
|
947
938
|
status: "fail",
|
|
@@ -1013,6 +1004,28 @@ const checkStaleInstall = (res: Resolved): DoctorCheck & { registryProbed?: bool
|
|
|
1013
1004
|
fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory and rewrites the workit MCP/hook entries",
|
|
1014
1005
|
};
|
|
1015
1006
|
}
|
|
1007
|
+
// Same version, different bytes: the install was hand-edited or built from
|
|
1008
|
+
// a dirty tree. Version strings cannot see that; the bundle hash can. The
|
|
1009
|
+
// dev dist is byte-deterministic, so a mismatch heals with reinstall.
|
|
1010
|
+
if (localDist && installed !== null && source !== null && res.dev) {
|
|
1011
|
+
const entry = /^node\s+(.+)$/.exec((hookCmd ?? "").trim())?.[1]?.trim() ?? "";
|
|
1012
|
+
const sourceBundle =
|
|
1013
|
+
entry && path.isAbsolute(entry)
|
|
1014
|
+
? path.join(res.dev, "packages", "workit-cursor", "dist", path.basename(entry))
|
|
1015
|
+
: "";
|
|
1016
|
+
if (entry && sourceBundle) {
|
|
1017
|
+
const installedHash = bundleHashOfFile(entry);
|
|
1018
|
+
const sourceHash = bundleHashOfFile(sourceBundle);
|
|
1019
|
+
if (installedHash !== null && sourceHash !== null && installedHash !== sourceHash) {
|
|
1020
|
+
return {
|
|
1021
|
+
id: "stale_install",
|
|
1022
|
+
status: "fail",
|
|
1023
|
+
detail: `stale_install: installed workit-cursor ${installed} runs different bytes than the current runtime build (${path.basename(entry)} hash mismatch)`,
|
|
1024
|
+
fix: "Re-run install-cursor-plugin.sh — it refreshes the plugin directory with the current build",
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1016
1029
|
if (installed !== null && localDist) {
|
|
1017
1030
|
const expected = registryLatestVersion(res);
|
|
1018
1031
|
if (expected === null) {
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
branchSetup,
|
|
3
|
+
classifyBranchDirt,
|
|
4
|
+
resolveBranchPolicyFor,
|
|
5
|
+
resolveCommitPolicyFor,
|
|
6
|
+
} from "./branch";
|
|
2
7
|
import { hostingCliAvailable, prCreate } from "./pr-create";
|
|
3
8
|
import {
|
|
4
9
|
changelogContext,
|
|
@@ -24,6 +29,7 @@ import { failure, success, sha256, type Caller, type Result, type Ref } from "./
|
|
|
24
29
|
import { changelogApply, changelogApplyPreview } from "./changelog";
|
|
25
30
|
import type { ExternalActionRequest } from "./external-action";
|
|
26
31
|
import { externalActionDescriptor, externalActionRequest } from "./external-action";
|
|
32
|
+
import { normalizeChainSteps } from "./authority";
|
|
27
33
|
import { resolveInside, run as coreRun } from "../core";
|
|
28
34
|
import { vcsConfig } from "./vcs-config";
|
|
29
35
|
import { detectCommitFlavor, matchCommitFlavor, type CommitFlavor } from "./commit-flavors";
|
|
@@ -271,10 +277,33 @@ export const actionProposalQuestion = (
|
|
|
271
277
|
const resolved = (payload.resolved ?? {}) as Record<string, unknown>;
|
|
272
278
|
const payloadTarget = String(payload.target_branch ?? "");
|
|
273
279
|
if (request.operation === "git.commit" && Array.isArray(payload.plan_steps)) {
|
|
274
|
-
const steps = payload.plan_steps as
|
|
280
|
+
const steps = payload.plan_steps as unknown[];
|
|
281
|
+
const chain = normalizeChainSteps(steps);
|
|
282
|
+
if (!chain)
|
|
283
|
+
return {
|
|
284
|
+
presented: `Workit decision: action — Approve committing the plan's listed tasks?`,
|
|
285
|
+
approvedText: `Approve the listed plan commits.`,
|
|
286
|
+
};
|
|
287
|
+
const kinds = chain.map((step) => step.kind);
|
|
288
|
+
if (kinds.every((kind) => kind === "commit"))
|
|
289
|
+
return {
|
|
290
|
+
presented: `Workit decision: action — Approve committing the plan's ${steps.length} listed tasks on \`${String(payload.plan_branch ?? "")}\`?`,
|
|
291
|
+
approvedText: `Approve the ${steps.length} listed plan commits.`,
|
|
292
|
+
};
|
|
293
|
+
const summary = [
|
|
294
|
+
kinds.filter((kind) => kind === "branch").length
|
|
295
|
+
? `${kinds.filter((kind) => kind === "branch").length} branch`
|
|
296
|
+
: null,
|
|
297
|
+
kinds.filter((kind) => kind === "commit").length
|
|
298
|
+
? `${kinds.filter((kind) => kind === "commit").length} commits`
|
|
299
|
+
: null,
|
|
300
|
+
kinds.includes("pr") ? "PR" : null,
|
|
301
|
+
]
|
|
302
|
+
.filter(Boolean)
|
|
303
|
+
.join(", ");
|
|
275
304
|
return {
|
|
276
|
-
presented: `Workit decision: action — Approve
|
|
277
|
-
approvedText: `Approve the ${steps.length}
|
|
305
|
+
presented: `Workit decision: action — Approve the ${steps.length}-step chain on \`${String(payload.plan_branch ?? "")}\` (${summary})?`,
|
|
306
|
+
approvedText: `Approve the ${steps.length}-step chain (${summary}).`,
|
|
278
307
|
};
|
|
279
308
|
}
|
|
280
309
|
switch (request.operation) {
|
|
@@ -806,6 +835,32 @@ const youTrackWritePreflight = (): Result<null> => {
|
|
|
806
835
|
return success(null, null, null);
|
|
807
836
|
};
|
|
808
837
|
|
|
838
|
+
/**
|
|
839
|
+
* A dirty tree binds stash up front so the proposal names it and one
|
|
840
|
+
* approval carries the whole effect instead of failing at preflight.
|
|
841
|
+
* Carry-class dirt (untracked or docs-confined) rides along silently.
|
|
842
|
+
* Returns the original resolution untouched unless an upgrade applies.
|
|
843
|
+
*/
|
|
844
|
+
export const upgradeBranchSetupForStash = (
|
|
845
|
+
root: string,
|
|
846
|
+
parsed: ExternalActionRequest,
|
|
847
|
+
resolved: Result<ResolvedExternalAction>,
|
|
848
|
+
): Result<ResolvedExternalAction> => {
|
|
849
|
+
if (!resolved.ok || resolved.data.request.operation !== "git.branch_setup") return resolved;
|
|
850
|
+
if (
|
|
851
|
+
(resolved.data.descriptorPayload as { resolved?: { dirty?: unknown } }).resolved?.dirty !==
|
|
852
|
+
true ||
|
|
853
|
+
(resolved.data.descriptorPayload as { stash?: unknown }).stash === "yes" ||
|
|
854
|
+
classifyBranchDirt(root) !== "stash-required"
|
|
855
|
+
)
|
|
856
|
+
return resolved;
|
|
857
|
+
const upgraded = resolveExternalActionRequest(root, {
|
|
858
|
+
...parsed,
|
|
859
|
+
payload: { ...(parsed.payload as Record<string, unknown>), stash: "yes" },
|
|
860
|
+
} as typeof parsed);
|
|
861
|
+
return upgraded.ok ? upgraded : resolved;
|
|
862
|
+
};
|
|
863
|
+
|
|
809
864
|
export const resolveExternalActionRequest = (
|
|
810
865
|
root: string,
|
|
811
866
|
request: ExternalActionRequest,
|
|
@@ -872,26 +927,41 @@ export const resolveExternalActionRequest = (
|
|
|
872
927
|
if (!head) return failure("storage_error", "Git state could not be resolved");
|
|
873
928
|
const branch = gitValue(root, ["branch", "--show-current"]);
|
|
874
929
|
if (planSteps !== undefined || planBranch !== undefined) {
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
planSteps.some((step) => typeof step !== "string" || !step.trim()) ||
|
|
878
|
-
new Set(planSteps).size !== planSteps.length ||
|
|
879
|
-
planSteps.length === 0 ||
|
|
880
|
-
planSteps.length > 32
|
|
881
|
-
)
|
|
930
|
+
const chain = Array.isArray(planSteps) ? normalizeChainSteps(planSteps) : null;
|
|
931
|
+
if (!chain)
|
|
882
932
|
return failure(
|
|
883
933
|
"invalid_input",
|
|
884
|
-
"plan_steps must be 1-32 unique non-empty commit messages",
|
|
934
|
+
"plan_steps must be 1-32 unique non-empty commit messages, branch targets, or pull_request markers",
|
|
885
935
|
{
|
|
886
936
|
outcome: "not_started",
|
|
887
|
-
fields: [{ path: "plan_steps", reason: "unique
|
|
937
|
+
fields: [{ path: "plan_steps", reason: "unique steps" }],
|
|
888
938
|
},
|
|
889
939
|
);
|
|
890
|
-
if (typeof planBranch !== "string" || !planBranch.trim()
|
|
891
|
-
return failure("invalid_input", "plan_branch must name the
|
|
940
|
+
if (typeof planBranch !== "string" || !planBranch.trim())
|
|
941
|
+
return failure("invalid_input", "plan_branch must name the working branch", {
|
|
892
942
|
outcome: "not_started",
|
|
893
|
-
fields: [{ path: "plan_branch", reason: "
|
|
943
|
+
fields: [{ path: "plan_branch", reason: "required" }],
|
|
894
944
|
});
|
|
945
|
+
// A chain that creates its own branch names that branch; otherwise
|
|
946
|
+
// the plan runs on the branch already checked out.
|
|
947
|
+
const first = chain[0];
|
|
948
|
+
const createsBranch = first?.kind === "branch";
|
|
949
|
+
if (createsBranch ? planBranch !== first.target : planBranch !== branch)
|
|
950
|
+
return failure(
|
|
951
|
+
"invalid_input",
|
|
952
|
+
createsBranch
|
|
953
|
+
? "plan_branch must name the branch the chain creates"
|
|
954
|
+
: "plan_branch must name the current working branch",
|
|
955
|
+
{
|
|
956
|
+
outcome: "not_started",
|
|
957
|
+
fields: [
|
|
958
|
+
{
|
|
959
|
+
path: "plan_branch",
|
|
960
|
+
reason: createsBranch ? "chain branch" : "current branch",
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
},
|
|
964
|
+
);
|
|
895
965
|
if (isProtectedBranch(root, planBranch))
|
|
896
966
|
return failure("invalid_input", "plan_branch must not be a protected branch", {
|
|
897
967
|
outcome: "not_started",
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from "./task-contract";
|
|
15
15
|
import { WorkitCore } from "./task-engine";
|
|
16
16
|
import { TaskStore } from "./task-store";
|
|
17
|
+
import { chainStepKey, normalizeChainSteps, type ChainStep } from "./authority";
|
|
17
18
|
import * as z from "zod";
|
|
18
19
|
import { execFileSync } from "node:child_process";
|
|
19
20
|
|
|
@@ -323,33 +324,58 @@ export const priorExternalAction = (
|
|
|
323
324
|
/** Parse a plan-commit authorization descriptor (one approved list per plan). */
|
|
324
325
|
export const planCommitDescriptor = (
|
|
325
326
|
content: string,
|
|
326
|
-
): { steps:
|
|
327
|
+
): { steps: ChainStep[]; branch: string; snapshotHead: string | null } | null => {
|
|
327
328
|
try {
|
|
328
329
|
const value = JSON.parse(content) as {
|
|
329
330
|
operation?: unknown;
|
|
330
|
-
payload?: {
|
|
331
|
+
payload?: {
|
|
332
|
+
plan_steps?: unknown;
|
|
333
|
+
plan_branch?: unknown;
|
|
334
|
+
resolved?: { head?: unknown; branch?: unknown };
|
|
335
|
+
};
|
|
331
336
|
};
|
|
332
337
|
if (value.operation !== "git.commit" || !value.payload) return null;
|
|
333
|
-
const steps = value.payload.plan_steps;
|
|
338
|
+
const steps = normalizeChainSteps(value.payload.plan_steps);
|
|
334
339
|
const branch = value.payload.plan_branch;
|
|
335
|
-
if (
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
typeof branch !== "string" ||
|
|
339
|
-
!branch
|
|
340
|
-
)
|
|
341
|
-
return null;
|
|
342
|
-
return { steps: steps as string[], branch };
|
|
340
|
+
if (!steps || typeof branch !== "string" || !branch) return null;
|
|
341
|
+
const head = value.payload.resolved?.head;
|
|
342
|
+
return { steps, branch, snapshotHead: typeof head === "string" ? head : null };
|
|
343
343
|
} catch {
|
|
344
344
|
return null;
|
|
345
345
|
}
|
|
346
346
|
};
|
|
347
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Chain lease: the recorded head must still be an ancestor of the current
|
|
350
|
+
* HEAD — history only moved forward. Rebase/reset underneath an approved
|
|
351
|
+
* chain invalidates it instead of executing on rewritten history.
|
|
352
|
+
*/
|
|
353
|
+
export const chainLeaseValid = (store: TaskStore, snapshotHead: string | null): boolean => {
|
|
354
|
+
if (!snapshotHead) return true;
|
|
355
|
+
try {
|
|
356
|
+
const head = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
357
|
+
cwd: store.root,
|
|
358
|
+
encoding: "utf8",
|
|
359
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
360
|
+
}).trim();
|
|
361
|
+
if (head === snapshotHead) return true;
|
|
362
|
+
execFileSync("git", ["merge-base", "--is-ancestor", snapshotHead, head], {
|
|
363
|
+
cwd: store.root,
|
|
364
|
+
encoding: "utf8",
|
|
365
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
366
|
+
});
|
|
367
|
+
return true;
|
|
368
|
+
} catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
348
373
|
export type PlanCommitAuthorization = {
|
|
349
374
|
task: TaskRecord;
|
|
350
375
|
entry: Entry<Decision>;
|
|
351
|
-
steps:
|
|
376
|
+
steps: ChainStep[];
|
|
352
377
|
branch: string;
|
|
378
|
+
snapshotHead: string | null;
|
|
353
379
|
completed: string[];
|
|
354
380
|
};
|
|
355
381
|
|
|
@@ -357,7 +383,10 @@ export type PlanCommitAuthorization = {
|
|
|
357
383
|
* Find the single active plan-commit authorization whose next unconsumed step
|
|
358
384
|
* is this exact commit message. Consumption order is enforced so each listed
|
|
359
385
|
* commit executes once, in plan order; unlisted or replayed messages find no
|
|
360
|
-
* authorization and require a fresh exact approval.
|
|
386
|
+
* authorization and require a fresh exact approval. Typed chain positions
|
|
387
|
+
* never match messages (a pathological message falls through to a fresh
|
|
388
|
+
* approval instead of consuming the wrong step), and a rewritten history
|
|
389
|
+
* breaks the lease.
|
|
361
390
|
*/
|
|
362
391
|
export const approvedPlanCommit = (
|
|
363
392
|
store: TaskStore,
|
|
@@ -395,8 +424,19 @@ export const approvedPlanCommit = (
|
|
|
395
424
|
const completed =
|
|
396
425
|
task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
|
|
397
426
|
[];
|
|
398
|
-
|
|
399
|
-
|
|
427
|
+
const next = plan.steps[completed.length];
|
|
428
|
+
if (!next || next.kind !== "commit" || next.message !== message) return [];
|
|
429
|
+
if (!chainLeaseValid(store, plan.snapshotHead)) return [];
|
|
430
|
+
return [
|
|
431
|
+
{
|
|
432
|
+
task,
|
|
433
|
+
entry,
|
|
434
|
+
steps: plan.steps,
|
|
435
|
+
branch: plan.branch,
|
|
436
|
+
snapshotHead: plan.snapshotHead,
|
|
437
|
+
completed: [...completed],
|
|
438
|
+
},
|
|
439
|
+
];
|
|
400
440
|
});
|
|
401
441
|
});
|
|
402
442
|
if (matches.length !== 1)
|
|
@@ -409,6 +449,80 @@ export const approvedPlanCommit = (
|
|
|
409
449
|
return success(workspace.data.revision, null, matches[0]);
|
|
410
450
|
};
|
|
411
451
|
|
|
452
|
+
export type ChainStepQuery =
|
|
453
|
+
| { operation: "git.branch_setup"; target: string }
|
|
454
|
+
| { operation: "hosting.pull_request" };
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Match one branch or PR step of a chain authorization: same session-bound
|
|
458
|
+
* search as plan commits, next unconsumed step of the right kind, lease
|
|
459
|
+
* intact. Commit steps stay on the plan path; this covers the steps plans
|
|
460
|
+
* could never express.
|
|
461
|
+
*/
|
|
462
|
+
export const approvedChainStep = (
|
|
463
|
+
store: TaskStore,
|
|
464
|
+
host: string,
|
|
465
|
+
actor: string,
|
|
466
|
+
query: ChainStepQuery,
|
|
467
|
+
): Result<PlanCommitAuthorization> => {
|
|
468
|
+
const listed = store.listTasks();
|
|
469
|
+
const workspace = store.readWorkspace();
|
|
470
|
+
if (!listed.ok || !workspace.ok)
|
|
471
|
+
return failure("storage_error", "external action state is unavailable");
|
|
472
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
473
|
+
const matches = listed.data.flatMap((task) => {
|
|
474
|
+
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
475
|
+
if (
|
|
476
|
+
task.intent.provenance.session?.kind !== "host" ||
|
|
477
|
+
task.intent.provenance.session.host !== host ||
|
|
478
|
+
task.intent.provenance.session.handle !== actor
|
|
479
|
+
)
|
|
480
|
+
return [];
|
|
481
|
+
return task.decisions.flatMap((entry) => {
|
|
482
|
+
const decision = entry.data;
|
|
483
|
+
if (
|
|
484
|
+
decision.purpose !== "action" ||
|
|
485
|
+
decision.response !== "approved" ||
|
|
486
|
+
decision.revoked !== null
|
|
487
|
+
)
|
|
488
|
+
return [];
|
|
489
|
+
if (entry.provenance.kind !== "host_observed" || entry.provenance.receipts.length === 0)
|
|
490
|
+
return [];
|
|
491
|
+
const plan = planCommitDescriptor(decision.binding.approvedContent);
|
|
492
|
+
if (!plan) return [];
|
|
493
|
+
const completed =
|
|
494
|
+
task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
|
|
495
|
+
[];
|
|
496
|
+
const next = plan.steps[completed.length];
|
|
497
|
+
if (!next) return [];
|
|
498
|
+
if (query.operation === "git.branch_setup") {
|
|
499
|
+
if (next.kind !== "branch" || next.target !== query.target) return [];
|
|
500
|
+
} else if (next.kind !== "pr") {
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
if (!chainLeaseValid(store, plan.snapshotHead)) return [];
|
|
504
|
+
return [
|
|
505
|
+
{
|
|
506
|
+
task,
|
|
507
|
+
entry,
|
|
508
|
+
steps: plan.steps,
|
|
509
|
+
branch: plan.branch,
|
|
510
|
+
snapshotHead: plan.snapshotHead,
|
|
511
|
+
completed: [...completed],
|
|
512
|
+
},
|
|
513
|
+
];
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
if (matches.length !== 1)
|
|
517
|
+
return failure(
|
|
518
|
+
"permission_denied",
|
|
519
|
+
matches.length === 0
|
|
520
|
+
? "no chain authorization matches this step"
|
|
521
|
+
: "multiple chain authorizations match this step",
|
|
522
|
+
);
|
|
523
|
+
return success(workspace.data.revision, null, matches[0]);
|
|
524
|
+
};
|
|
525
|
+
|
|
412
526
|
export const commitMessageFromDescriptor = (operation: string): string | undefined => {
|
|
413
527
|
try {
|
|
414
528
|
const descriptor = JSON.parse(operation) as {
|
|
@@ -423,6 +537,69 @@ export const commitMessageFromDescriptor = (operation: string): string | undefin
|
|
|
423
537
|
}
|
|
424
538
|
};
|
|
425
539
|
|
|
540
|
+
/** Resolve a chain authorization into the exact binding an adapter needs
|
|
541
|
+
* to run one branch or PR step under the approved chain. Commit steps stay
|
|
542
|
+
* on the plan path; this covers the steps plans could never express. */
|
|
543
|
+
export const chainStepBinding = (
|
|
544
|
+
store: TaskStore,
|
|
545
|
+
host: string,
|
|
546
|
+
actor: string,
|
|
547
|
+
operation: string,
|
|
548
|
+
): {
|
|
549
|
+
taskId: Id;
|
|
550
|
+
decisionId: Id;
|
|
551
|
+
binding: Decision["binding"];
|
|
552
|
+
expectedRevision: Revision;
|
|
553
|
+
expectedWorkspaceRevision: Revision;
|
|
554
|
+
step: string;
|
|
555
|
+
} | null => {
|
|
556
|
+
let descriptor: { operation?: unknown; payload?: Record<string, unknown> };
|
|
557
|
+
try {
|
|
558
|
+
descriptor = JSON.parse(operation) as {
|
|
559
|
+
operation?: unknown;
|
|
560
|
+
payload?: Record<string, unknown>;
|
|
561
|
+
};
|
|
562
|
+
} catch {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
const query: ChainStepQuery | null =
|
|
566
|
+
descriptor.operation === "git.branch_setup" &&
|
|
567
|
+
typeof descriptor.payload?.target_branch === "string"
|
|
568
|
+
? { operation: "git.branch_setup", target: descriptor.payload.target_branch as string }
|
|
569
|
+
: descriptor.operation === "hosting.pull_request"
|
|
570
|
+
? { operation: "hosting.pull_request" }
|
|
571
|
+
: null;
|
|
572
|
+
if (!query) return null;
|
|
573
|
+
const chain = approvedChainStep(store, host, actor, query);
|
|
574
|
+
if (!chain.ok) return null;
|
|
575
|
+
// PR steps run on the chain branch; the branch step itself creates it.
|
|
576
|
+
if (query.operation === "hosting.pull_request") {
|
|
577
|
+
let branch: string;
|
|
578
|
+
try {
|
|
579
|
+
branch = execFileSync("git", ["branch", "--show-current"], {
|
|
580
|
+
cwd: store.root,
|
|
581
|
+
encoding: "utf8",
|
|
582
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
583
|
+
}).trim();
|
|
584
|
+
} catch {
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
if (!branch || branch !== chain.data.branch) return null;
|
|
588
|
+
}
|
|
589
|
+
const workspace = store.readWorkspace();
|
|
590
|
+
if (!workspace.ok || !workspace.data) return null;
|
|
591
|
+
const next = chain.data.steps[chain.data.completed.length];
|
|
592
|
+
if (!next) return null;
|
|
593
|
+
return {
|
|
594
|
+
taskId: chain.data.task.id,
|
|
595
|
+
decisionId: chain.data.entry.id,
|
|
596
|
+
binding: chain.data.entry.data.binding,
|
|
597
|
+
expectedRevision: chain.data.task.revision,
|
|
598
|
+
expectedWorkspaceRevision: workspace.data.revision,
|
|
599
|
+
step: chainStepKey(next),
|
|
600
|
+
};
|
|
601
|
+
};
|
|
602
|
+
|
|
426
603
|
/** Resolve a plan-commit authorization into the exact binding an adapter needs
|
|
427
604
|
* to run one listed commit under the approved list. */
|
|
428
605
|
export const planCommitBinding = (
|
package/src/core/methods.ts
CHANGED
|
@@ -17,12 +17,14 @@ type MethodDefinition = {
|
|
|
17
17
|
|
|
18
18
|
export const METHODS: Readonly<Record<MethodId, MethodDefinition>> = {
|
|
19
19
|
"workit-challenge": { dimensions: ["challenge", "decisions"] },
|
|
20
|
-
"workit-behavioral-tdd": { dimensions: ["testing"] },
|
|
21
|
-
"workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review"] },
|
|
20
|
+
"workit-behavioral-tdd": { dimensions: ["testing"], ruleIds: ["mechanical-existing-checks"] },
|
|
21
|
+
"workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review", "self-review"] },
|
|
22
22
|
"workit-plan": { dimensions: ["artifacts", "continuity"] },
|
|
23
23
|
"workit-implement": { dimensions: ["delegation"] },
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
// workit-debug and workit-handoff stay slash-invoked: no generated rule
|
|
25
|
+
// routes to them, so they never appear spuriously in method selection.
|
|
26
|
+
"workit-debug": {},
|
|
27
|
+
"workit-handoff": {},
|
|
26
28
|
"workit-deslop": { ruleIds: ["pre-pr-cleanup"] },
|
|
27
29
|
};
|
|
28
30
|
|
|
@@ -33,12 +35,10 @@ export type SelectedMethod = {
|
|
|
33
35
|
};
|
|
34
36
|
|
|
35
37
|
const methodMatches = (
|
|
36
|
-
id: MethodId,
|
|
37
38
|
definition: MethodDefinition,
|
|
38
39
|
requirement: Policy["requirements"][number],
|
|
39
40
|
): boolean =>
|
|
40
|
-
|
|
41
|
-
!(id === "workit-review" && requirement.ruleId === "self-review")) ||
|
|
41
|
+
definition.dimensions?.includes(requirement.dimension) === true ||
|
|
42
42
|
definition.ruleIds?.includes(requirement.ruleId) === true;
|
|
43
43
|
|
|
44
44
|
const assuranceFor = (
|
|
@@ -71,7 +71,7 @@ const assuranceFor = (
|
|
|
71
71
|
export function selectMethods(policy: Policy, capabilities: Capability[]): SelectedMethod[] {
|
|
72
72
|
return (Object.entries(METHODS) as [MethodId, MethodDefinition][]).flatMap(([id, definition]) => {
|
|
73
73
|
const requirements = policy.requirements.filter((requirement) =>
|
|
74
|
-
methodMatches(
|
|
74
|
+
methodMatches(definition, requirement),
|
|
75
75
|
);
|
|
76
76
|
if (requirements.length === 0) return [];
|
|
77
77
|
const capability = assuranceFor(id, definition, capabilities);
|