@brainervirus/workit-core 1.0.10 → 1.0.11
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/external-action-effects.ts +21 -8
- package/src/core/external-action.ts +151 -12
- package/src/core/route-intent.ts +28 -0
- package/src/core/task-contract.ts +2 -1
- package/src/core/task-engine.ts +29 -20
- package/src/core/task-evaluation.ts +2 -2
- package/src/core.ts +3 -1
package/package.json
CHANGED
|
@@ -286,16 +286,29 @@ export const actionProposalQuestion = (
|
|
|
286
286
|
approvedText: "Reapply the recorded stash.",
|
|
287
287
|
};
|
|
288
288
|
const target = String(payload.target_branch ?? "");
|
|
289
|
+
// A dirty tree binds stash: the approval carries the stash through,
|
|
290
|
+
// so the question names it instead of failing later at preflight.
|
|
291
|
+
const stashes = payload.stash === "yes" && resolved.dirty === true;
|
|
289
292
|
if (resolved.target_exists === true)
|
|
290
|
-
return
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
293
|
+
return stashes
|
|
294
|
+
? {
|
|
295
|
+
presented: `Workit decision: action — Stash the dirty working tree and switch to branch \`${target}\`?`,
|
|
296
|
+
approvedText: `Stash and switch to \`${target}\`.`,
|
|
297
|
+
}
|
|
298
|
+
: {
|
|
299
|
+
presented: `Workit decision: action — Switch to branch \`${target}\`?`,
|
|
300
|
+
approvedText: `Switch to \`${target}\`.`,
|
|
301
|
+
};
|
|
294
302
|
const base = String(resolved.base_branch ?? "the base branch");
|
|
295
|
-
return
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
303
|
+
return stashes
|
|
304
|
+
? {
|
|
305
|
+
presented: `Workit decision: action — Stash the dirty working tree and create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
|
|
306
|
+
approvedText: `Stash and create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
|
|
307
|
+
}
|
|
308
|
+
: {
|
|
309
|
+
presented: `Workit decision: action — Create branch \`${target}\` from \`${base}\` (currently ${shortSha(resolved.remote_base)})?`,
|
|
310
|
+
approvedText: `Create \`${target}\` from \`${base}\` at ${shortSha(resolved.remote_base)}.`,
|
|
311
|
+
};
|
|
299
312
|
}
|
|
300
313
|
case "git.commit": {
|
|
301
314
|
const paths = Array.isArray(resolved.paths) ? (resolved.paths as string[]) : [];
|
|
@@ -472,24 +472,131 @@ export const approvedExternalAction = (
|
|
|
472
472
|
operation: string,
|
|
473
473
|
) => {
|
|
474
474
|
const selected = externalActionState(store, host, actor, operation);
|
|
475
|
-
if (
|
|
476
|
-
|
|
475
|
+
if (selected.ok) {
|
|
476
|
+
if (selected.data.entry.data.consumption !== null)
|
|
477
|
+
return failure(
|
|
478
|
+
selected.data.entry.data.consumption.state === "uncertain"
|
|
479
|
+
? "external_outcome_unknown"
|
|
480
|
+
: "permission_denied",
|
|
481
|
+
selected.data.entry.data.consumption.state === "uncertain"
|
|
482
|
+
? "previous external action outcome is unknown"
|
|
483
|
+
: "external action was already settled",
|
|
484
|
+
);
|
|
485
|
+
return selected;
|
|
486
|
+
}
|
|
487
|
+
// Intent carry for branch setup: an approval binds (target, base) intent,
|
|
488
|
+
// so unrelated HEAD or dirt moves between approval and execution re-resolve
|
|
489
|
+
// instead of demanding re-approval. Remote, existence, and target drift
|
|
490
|
+
// still fail closed through priorResolvedDrift.
|
|
491
|
+
const intent = approvedBranchSetupIntent(store, host, actor, operation);
|
|
492
|
+
if (intent.ok) {
|
|
493
|
+
if (intent.data.entry.data.consumption !== null)
|
|
494
|
+
return failure(
|
|
495
|
+
intent.data.entry.data.consumption.state === "uncertain"
|
|
496
|
+
? "external_outcome_unknown"
|
|
497
|
+
: "permission_denied",
|
|
498
|
+
intent.data.entry.data.consumption.state === "uncertain"
|
|
499
|
+
? "previous external action outcome is unknown"
|
|
500
|
+
: "external action was already settled",
|
|
501
|
+
);
|
|
502
|
+
return intent;
|
|
503
|
+
}
|
|
504
|
+
return selected;
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
/** Intent identity for a branch-setup descriptor: target and base bind the
|
|
508
|
+
* effect; head and dirt are execution-time state that re-resolves. */
|
|
509
|
+
export const branchSetupIntent = (
|
|
510
|
+
descriptor: string,
|
|
511
|
+
): { target: string; base: string; remoteBase: string | null; targetExists: boolean } | null => {
|
|
512
|
+
try {
|
|
513
|
+
const value = JSON.parse(descriptor) as {
|
|
514
|
+
operation?: unknown;
|
|
515
|
+
payload?: {
|
|
516
|
+
target_branch?: unknown;
|
|
517
|
+
resolved?: {
|
|
518
|
+
base_branch?: unknown;
|
|
519
|
+
remote_base?: unknown;
|
|
520
|
+
target_exists?: unknown;
|
|
521
|
+
};
|
|
522
|
+
};
|
|
523
|
+
};
|
|
524
|
+
if (value.operation !== "git.branch_setup" || !value.payload) return null;
|
|
525
|
+
const { target_branch, resolved } = value.payload;
|
|
526
|
+
if (typeof target_branch !== "string" || !target_branch || !resolved) return null;
|
|
527
|
+
if (typeof resolved.base_branch !== "string" || typeof resolved.target_exists !== "boolean")
|
|
528
|
+
return null;
|
|
529
|
+
return {
|
|
530
|
+
target: target_branch,
|
|
531
|
+
base: resolved.base_branch,
|
|
532
|
+
remoteBase: typeof resolved.remote_base === "string" ? resolved.remote_base : null,
|
|
533
|
+
targetExists: resolved.target_exists,
|
|
534
|
+
};
|
|
535
|
+
} catch {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
/** Find the single unconsumed branch-setup approval with the same intent as
|
|
541
|
+
* the current descriptor, regardless of head or dirt moves since approval. */
|
|
542
|
+
export const approvedBranchSetupIntent = (
|
|
543
|
+
store: TaskStore,
|
|
544
|
+
host: string,
|
|
545
|
+
actor: string,
|
|
546
|
+
operation: string,
|
|
547
|
+
) => {
|
|
548
|
+
const current = branchSetupIntent(operation);
|
|
549
|
+
if (!current) return failure("permission_denied", "no approved action is bound to this session");
|
|
550
|
+
const listed = store.listTasks();
|
|
551
|
+
const workspace = store.readWorkspace();
|
|
552
|
+
if (!listed.ok || !workspace.ok)
|
|
553
|
+
return failure("storage_error", "external action state is unavailable");
|
|
554
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
555
|
+
const candidates = listed.data.flatMap((task) => {
|
|
556
|
+
if (
|
|
557
|
+
task.status !== "active" ||
|
|
558
|
+
task.workspaceId !== workspace.data!.id ||
|
|
559
|
+
task.intent.provenance.session?.kind !== "host" ||
|
|
560
|
+
task.intent.provenance.session.host !== host ||
|
|
561
|
+
task.intent.provenance.session.handle !== actor
|
|
562
|
+
)
|
|
563
|
+
return [];
|
|
564
|
+
return task.decisions
|
|
565
|
+
.filter(
|
|
566
|
+
(entry) =>
|
|
567
|
+
entry.data.purpose === "action" &&
|
|
568
|
+
entry.data.response === "approved" &&
|
|
569
|
+
entry.data.revoked === null,
|
|
570
|
+
)
|
|
571
|
+
.map((entry) => ({ task, entry }));
|
|
572
|
+
});
|
|
573
|
+
const matches = candidates.filter(({ entry }) => {
|
|
574
|
+
const intent = branchSetupIntent(entry.data.binding.approvedContent);
|
|
575
|
+
return (
|
|
576
|
+
intent !== null &&
|
|
577
|
+
intent.target === current.target &&
|
|
578
|
+
intent.base === current.base &&
|
|
579
|
+
intent.remoteBase === current.remoteBase &&
|
|
580
|
+
intent.targetExists === current.targetExists
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
if (matches.length !== 1)
|
|
477
584
|
return failure(
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
? "previous external action outcome is unknown"
|
|
483
|
-
: "external action was already settled",
|
|
585
|
+
"permission_denied",
|
|
586
|
+
matches.length === 0
|
|
587
|
+
? "no approved action is bound to this session"
|
|
588
|
+
: "multiple approved actions match this session",
|
|
484
589
|
);
|
|
485
|
-
return
|
|
590
|
+
return success(workspace.data.revision, null, { ...matches[0], workspace: workspace.data });
|
|
486
591
|
};
|
|
487
592
|
|
|
488
593
|
/**
|
|
489
594
|
* Re-resolve guard: when a prior approval exists for the same requested
|
|
490
|
-
* operation/payload, the
|
|
491
|
-
* base
|
|
492
|
-
*
|
|
595
|
+
* operation/payload, the intent baseline must still match. For branch setup
|
|
596
|
+
* only (target, base, remote base, existence) bind the effect — head and
|
|
597
|
+
* dirt moves re-resolve instead of demanding re-approval. Drift names the
|
|
598
|
+
* moved element and returns not_started so the agent re-presents the
|
|
599
|
+
* current state instead of executing a stale effect.
|
|
493
600
|
*/
|
|
494
601
|
export const priorResolvedDrift = (
|
|
495
602
|
store: TaskStore,
|
|
@@ -511,6 +618,38 @@ export const priorResolvedDrift = (
|
|
|
511
618
|
return success(null, null, null);
|
|
512
619
|
}
|
|
513
620
|
if (baseline === undefined || baseline === null) return success(null, null, null);
|
|
621
|
+
if (operation === "git.branch_setup") {
|
|
622
|
+
const before = branchSetupIntent(prior.data.entry.data.binding.approvedContent);
|
|
623
|
+
let after: ReturnType<typeof branchSetupIntent> = null;
|
|
624
|
+
try {
|
|
625
|
+
after = branchSetupIntent(
|
|
626
|
+
JSON.stringify({
|
|
627
|
+
operation,
|
|
628
|
+
payload: { ...(payload as Record<string, unknown>), resolved: currentResolved },
|
|
629
|
+
}),
|
|
630
|
+
);
|
|
631
|
+
} catch {
|
|
632
|
+
after = null;
|
|
633
|
+
}
|
|
634
|
+
if (before && after) {
|
|
635
|
+
const moved =
|
|
636
|
+
before.target !== after.target
|
|
637
|
+
? "target_branch"
|
|
638
|
+
: before.base !== after.base
|
|
639
|
+
? "base_branch"
|
|
640
|
+
: before.remoteBase !== after.remoteBase
|
|
641
|
+
? "remote_base"
|
|
642
|
+
: before.targetExists !== after.targetExists
|
|
643
|
+
? "target_exists"
|
|
644
|
+
: null;
|
|
645
|
+
if (moved === null) return success(null, null, null);
|
|
646
|
+
return failure(
|
|
647
|
+
"invalid_input",
|
|
648
|
+
`repository state changed since approval (${moved} moved); show the current state and approve the action again`,
|
|
649
|
+
{ outcome: "not_started", operation },
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
514
653
|
if (canonicalJson(baseline) === canonicalJson(currentResolved)) return success(null, null, null);
|
|
515
654
|
return failure(
|
|
516
655
|
"invalid_input",
|
package/src/core/route-intent.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TaskStore } from "./task-store";
|
|
2
|
+
|
|
1
3
|
export type ShellRouteIntent = {
|
|
2
4
|
route: "git.branch_setup" | "hosting.pull_request";
|
|
3
5
|
guidance: string;
|
|
@@ -49,3 +51,29 @@ export function shellRouteIntent(command: string): ShellRouteIntent | null {
|
|
|
49
51
|
}
|
|
50
52
|
return null;
|
|
51
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Session-scoped enforcement: the denial protects live Workit coordination,
|
|
57
|
+
* so it fires only where an active or paused task exists. Any filesystem or
|
|
58
|
+
* storage failure means "no live task" — never deny on uncertainty.
|
|
59
|
+
*/
|
|
60
|
+
export function hasLiveWorkitTask(dir: string): boolean {
|
|
61
|
+
try {
|
|
62
|
+
if (typeof dir !== "string" || dir === "") return false;
|
|
63
|
+
const listed = new TaskStore(dir).listTasks();
|
|
64
|
+
if (!listed.ok) return false;
|
|
65
|
+
return listed.data.some((task) => task.status === "active" || task.status === "paused");
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Single core gate for the shell route denial. Adapters pass their
|
|
73
|
+
* session/project directory and delete local deny duplication; outside
|
|
74
|
+
* attributed sessions the command is silently allowed.
|
|
75
|
+
*/
|
|
76
|
+
export function shouldDenyShellRoute(dir: string, command: string): ShellRouteIntent | null {
|
|
77
|
+
if (!hasLiveWorkitTask(dir)) return null;
|
|
78
|
+
return shellRouteIntent(command);
|
|
79
|
+
}
|
|
@@ -455,10 +455,11 @@ export const decisionSchema = z
|
|
|
455
455
|
approvedContent: text,
|
|
456
456
|
displayed: text.optional(),
|
|
457
457
|
contentRefs: z.array(refSchema),
|
|
458
|
+
statedChoice: z.object({ ref: text, text: text }).strict().optional(),
|
|
458
459
|
})
|
|
459
460
|
.strict(),
|
|
460
461
|
digest,
|
|
461
|
-
response: z.enum(["approved", "rejected"]),
|
|
462
|
+
response: z.enum(["approved", "rejected", "stated"]),
|
|
462
463
|
requirementIds: z.array(digest),
|
|
463
464
|
revoked: z.object({ at: utc, reason: text }).strict().nullable(),
|
|
464
465
|
consumption: z
|
package/src/core/task-engine.ts
CHANGED
|
@@ -1189,7 +1189,15 @@ export class WorkitCore {
|
|
|
1189
1189
|
"permission_denied",
|
|
1190
1190
|
"native observation is only valid for recording decisions",
|
|
1191
1191
|
);
|
|
1192
|
-
|
|
1192
|
+
// Stated choices carry user-settled decisions without a native receipt.
|
|
1193
|
+
// They never authorize mutating actions; native verification is skipped
|
|
1194
|
+
// and provenance stays agent-reported.
|
|
1195
|
+
const stated = input.action === "record" && input.response === "stated";
|
|
1196
|
+
if (stated && input.purpose === "action")
|
|
1197
|
+
return failure("permission_denied", "stated choices cannot authorize mutating actions");
|
|
1198
|
+
if (stated && !input.binding?.statedChoice)
|
|
1199
|
+
return failure("invalid_input", "stated choices require binding.statedChoice");
|
|
1200
|
+
if (nativeRequired && nativeObservation === undefined && !stated)
|
|
1193
1201
|
return failure("permission_denied", "native decision observation is required");
|
|
1194
1202
|
const task = this.store.readTask(input.taskId);
|
|
1195
1203
|
if (!task.ok) return task as Result<never>;
|
|
@@ -1220,26 +1228,27 @@ export class WorkitCore {
|
|
|
1220
1228
|
consumption: null,
|
|
1221
1229
|
} satisfies Omit<Decision, "digest">;
|
|
1222
1230
|
const data: Decision = { ...base, digest: decisionDigest(base) };
|
|
1223
|
-
const native =
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1231
|
+
const native =
|
|
1232
|
+
nativeRequired && !stated
|
|
1233
|
+
? verifyNativeDecision(
|
|
1234
|
+
this.context.nativeAuthority,
|
|
1235
|
+
{
|
|
1236
|
+
observation: nativeObservation,
|
|
1237
|
+
expected: {
|
|
1238
|
+
taskId: task.data.id,
|
|
1239
|
+
workspaceId: workspace.data.id,
|
|
1240
|
+
purpose: input.purpose,
|
|
1241
|
+
response: input.response,
|
|
1242
|
+
binding: input.binding,
|
|
1243
|
+
bindingBytes: canonicalJson(input.binding),
|
|
1244
|
+
digest: data.digest,
|
|
1245
|
+
requirementIds: [...input.requirementIds],
|
|
1246
|
+
},
|
|
1247
|
+
caller: this.context.caller,
|
|
1237
1248
|
},
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
)
|
|
1242
|
-
: null;
|
|
1249
|
+
{ owner: this.authorityOwner, store: this.store, root: this.store.root },
|
|
1250
|
+
)
|
|
1251
|
+
: null;
|
|
1243
1252
|
if (native && !native.ok) return native as Result<never>;
|
|
1244
1253
|
const nativeProvenance = native?.ok ? retireNativeAuthority(native.data) : null;
|
|
1245
1254
|
if (native?.ok && !nativeProvenance)
|
|
@@ -413,7 +413,7 @@ const applicableRequirementDecision = (
|
|
|
413
413
|
({ data, provenance }) =>
|
|
414
414
|
provenance.kind !== "imported" &&
|
|
415
415
|
data.purpose !== "limitation" &&
|
|
416
|
-
data.response === "approved" &&
|
|
416
|
+
(data.response === "approved" || data.response === "stated") &&
|
|
417
417
|
data.revoked === null &&
|
|
418
418
|
data.binding.taskId === task.id &&
|
|
419
419
|
data.binding.workspaceId === workspace.id &&
|
|
@@ -553,7 +553,7 @@ export function evaluateRequirements(
|
|
|
553
553
|
status: "satisfied" as const,
|
|
554
554
|
evidenceIds: [],
|
|
555
555
|
decisionIds: decisions.map((decision) => decision.id),
|
|
556
|
-
reason: "an applicable approved decision satisfies the requirement",
|
|
556
|
+
reason: "an applicable approved or stated decision satisfies the requirement",
|
|
557
557
|
};
|
|
558
558
|
const limitations = requirement.acceptanceAllowed
|
|
559
559
|
? applicableDecision(task, workspace, requirement, checkoutRoot)
|
package/src/core.ts
CHANGED
|
@@ -68,7 +68,7 @@ export type {
|
|
|
68
68
|
} from "./core/task-context";
|
|
69
69
|
export { METHODS, invariantBootstrap, selectMethods } from "./core/methods";
|
|
70
70
|
export type { MethodId, SelectedMethod } from "./core/methods";
|
|
71
|
-
export { shellRouteIntent } from "./core/route-intent";
|
|
71
|
+
export { hasLiveWorkitTask, shellRouteIntent, shouldDenyShellRoute } from "./core/route-intent";
|
|
72
72
|
export type { ShellRouteIntent } from "./core/route-intent";
|
|
73
73
|
export { applicableDecision, reserveAction, settleAction, reconcileAction } from "./core/authority";
|
|
74
74
|
export {
|
|
@@ -86,8 +86,10 @@ export type {
|
|
|
86
86
|
ExternalActionOperation,
|
|
87
87
|
} from "./core/external-action";
|
|
88
88
|
export {
|
|
89
|
+
approvedBranchSetupIntent,
|
|
89
90
|
approvedExternalAction,
|
|
90
91
|
approvedPlanCommit,
|
|
92
|
+
branchSetupIntent,
|
|
91
93
|
planCommitDescriptor,
|
|
92
94
|
planCommitBinding,
|
|
93
95
|
commitMessageFromDescriptor,
|