@brainervirus/workit-core 1.0.10 → 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 +107 -24
- package/src/core/external-action.ts +343 -27
- package/src/core/methods.ts +8 -8
- package/src/core/route-intent.ts +28 -0
- package/src/core/runtime-identity.ts +33 -0
- package/src/core/task-contract.ts +109 -1
- package/src/core/task-engine.ts +207 -240
- package/src/core/task-evaluation.ts +129 -24
- package/src/core/task-store.ts +4 -6
- package/src/core/workers.ts +11 -1
- package/src/core.ts +15 -1
|
@@ -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 = (
|
|
@@ -472,24 +649,131 @@ export const approvedExternalAction = (
|
|
|
472
649
|
operation: string,
|
|
473
650
|
) => {
|
|
474
651
|
const selected = externalActionState(store, host, actor, operation);
|
|
475
|
-
if (
|
|
476
|
-
|
|
652
|
+
if (selected.ok) {
|
|
653
|
+
if (selected.data.entry.data.consumption !== null)
|
|
654
|
+
return failure(
|
|
655
|
+
selected.data.entry.data.consumption.state === "uncertain"
|
|
656
|
+
? "external_outcome_unknown"
|
|
657
|
+
: "permission_denied",
|
|
658
|
+
selected.data.entry.data.consumption.state === "uncertain"
|
|
659
|
+
? "previous external action outcome is unknown"
|
|
660
|
+
: "external action was already settled",
|
|
661
|
+
);
|
|
662
|
+
return selected;
|
|
663
|
+
}
|
|
664
|
+
// Intent carry for branch setup: an approval binds (target, base) intent,
|
|
665
|
+
// so unrelated HEAD or dirt moves between approval and execution re-resolve
|
|
666
|
+
// instead of demanding re-approval. Remote, existence, and target drift
|
|
667
|
+
// still fail closed through priorResolvedDrift.
|
|
668
|
+
const intent = approvedBranchSetupIntent(store, host, actor, operation);
|
|
669
|
+
if (intent.ok) {
|
|
670
|
+
if (intent.data.entry.data.consumption !== null)
|
|
671
|
+
return failure(
|
|
672
|
+
intent.data.entry.data.consumption.state === "uncertain"
|
|
673
|
+
? "external_outcome_unknown"
|
|
674
|
+
: "permission_denied",
|
|
675
|
+
intent.data.entry.data.consumption.state === "uncertain"
|
|
676
|
+
? "previous external action outcome is unknown"
|
|
677
|
+
: "external action was already settled",
|
|
678
|
+
);
|
|
679
|
+
return intent;
|
|
680
|
+
}
|
|
681
|
+
return selected;
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
/** Intent identity for a branch-setup descriptor: target and base bind the
|
|
685
|
+
* effect; head and dirt are execution-time state that re-resolves. */
|
|
686
|
+
export const branchSetupIntent = (
|
|
687
|
+
descriptor: string,
|
|
688
|
+
): { target: string; base: string; remoteBase: string | null; targetExists: boolean } | null => {
|
|
689
|
+
try {
|
|
690
|
+
const value = JSON.parse(descriptor) as {
|
|
691
|
+
operation?: unknown;
|
|
692
|
+
payload?: {
|
|
693
|
+
target_branch?: unknown;
|
|
694
|
+
resolved?: {
|
|
695
|
+
base_branch?: unknown;
|
|
696
|
+
remote_base?: unknown;
|
|
697
|
+
target_exists?: unknown;
|
|
698
|
+
};
|
|
699
|
+
};
|
|
700
|
+
};
|
|
701
|
+
if (value.operation !== "git.branch_setup" || !value.payload) return null;
|
|
702
|
+
const { target_branch, resolved } = value.payload;
|
|
703
|
+
if (typeof target_branch !== "string" || !target_branch || !resolved) return null;
|
|
704
|
+
if (typeof resolved.base_branch !== "string" || typeof resolved.target_exists !== "boolean")
|
|
705
|
+
return null;
|
|
706
|
+
return {
|
|
707
|
+
target: target_branch,
|
|
708
|
+
base: resolved.base_branch,
|
|
709
|
+
remoteBase: typeof resolved.remote_base === "string" ? resolved.remote_base : null,
|
|
710
|
+
targetExists: resolved.target_exists,
|
|
711
|
+
};
|
|
712
|
+
} catch {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
/** Find the single unconsumed branch-setup approval with the same intent as
|
|
718
|
+
* the current descriptor, regardless of head or dirt moves since approval. */
|
|
719
|
+
export const approvedBranchSetupIntent = (
|
|
720
|
+
store: TaskStore,
|
|
721
|
+
host: string,
|
|
722
|
+
actor: string,
|
|
723
|
+
operation: string,
|
|
724
|
+
) => {
|
|
725
|
+
const current = branchSetupIntent(operation);
|
|
726
|
+
if (!current) return failure("permission_denied", "no approved action is bound to this session");
|
|
727
|
+
const listed = store.listTasks();
|
|
728
|
+
const workspace = store.readWorkspace();
|
|
729
|
+
if (!listed.ok || !workspace.ok)
|
|
730
|
+
return failure("storage_error", "external action state is unavailable");
|
|
731
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
732
|
+
const candidates = listed.data.flatMap((task) => {
|
|
733
|
+
if (
|
|
734
|
+
task.status !== "active" ||
|
|
735
|
+
task.workspaceId !== workspace.data!.id ||
|
|
736
|
+
task.intent.provenance.session?.kind !== "host" ||
|
|
737
|
+
task.intent.provenance.session.host !== host ||
|
|
738
|
+
task.intent.provenance.session.handle !== actor
|
|
739
|
+
)
|
|
740
|
+
return [];
|
|
741
|
+
return task.decisions
|
|
742
|
+
.filter(
|
|
743
|
+
(entry) =>
|
|
744
|
+
entry.data.purpose === "action" &&
|
|
745
|
+
entry.data.response === "approved" &&
|
|
746
|
+
entry.data.revoked === null,
|
|
747
|
+
)
|
|
748
|
+
.map((entry) => ({ task, entry }));
|
|
749
|
+
});
|
|
750
|
+
const matches = candidates.filter(({ entry }) => {
|
|
751
|
+
const intent = branchSetupIntent(entry.data.binding.approvedContent);
|
|
752
|
+
return (
|
|
753
|
+
intent !== null &&
|
|
754
|
+
intent.target === current.target &&
|
|
755
|
+
intent.base === current.base &&
|
|
756
|
+
intent.remoteBase === current.remoteBase &&
|
|
757
|
+
intent.targetExists === current.targetExists
|
|
758
|
+
);
|
|
759
|
+
});
|
|
760
|
+
if (matches.length !== 1)
|
|
477
761
|
return failure(
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
? "previous external action outcome is unknown"
|
|
483
|
-
: "external action was already settled",
|
|
762
|
+
"permission_denied",
|
|
763
|
+
matches.length === 0
|
|
764
|
+
? "no approved action is bound to this session"
|
|
765
|
+
: "multiple approved actions match this session",
|
|
484
766
|
);
|
|
485
|
-
return
|
|
767
|
+
return success(workspace.data.revision, null, { ...matches[0], workspace: workspace.data });
|
|
486
768
|
};
|
|
487
769
|
|
|
488
770
|
/**
|
|
489
771
|
* Re-resolve guard: when a prior approval exists for the same requested
|
|
490
|
-
* operation/payload, the
|
|
491
|
-
* base
|
|
492
|
-
*
|
|
772
|
+
* operation/payload, the intent baseline must still match. For branch setup
|
|
773
|
+
* only (target, base, remote base, existence) bind the effect — head and
|
|
774
|
+
* dirt moves re-resolve instead of demanding re-approval. Drift names the
|
|
775
|
+
* moved element and returns not_started so the agent re-presents the
|
|
776
|
+
* current state instead of executing a stale effect.
|
|
493
777
|
*/
|
|
494
778
|
export const priorResolvedDrift = (
|
|
495
779
|
store: TaskStore,
|
|
@@ -511,6 +795,38 @@ export const priorResolvedDrift = (
|
|
|
511
795
|
return success(null, null, null);
|
|
512
796
|
}
|
|
513
797
|
if (baseline === undefined || baseline === null) return success(null, null, null);
|
|
798
|
+
if (operation === "git.branch_setup") {
|
|
799
|
+
const before = branchSetupIntent(prior.data.entry.data.binding.approvedContent);
|
|
800
|
+
let after: ReturnType<typeof branchSetupIntent> = null;
|
|
801
|
+
try {
|
|
802
|
+
after = branchSetupIntent(
|
|
803
|
+
JSON.stringify({
|
|
804
|
+
operation,
|
|
805
|
+
payload: { ...(payload as Record<string, unknown>), resolved: currentResolved },
|
|
806
|
+
}),
|
|
807
|
+
);
|
|
808
|
+
} catch {
|
|
809
|
+
after = null;
|
|
810
|
+
}
|
|
811
|
+
if (before && after) {
|
|
812
|
+
const moved =
|
|
813
|
+
before.target !== after.target
|
|
814
|
+
? "target_branch"
|
|
815
|
+
: before.base !== after.base
|
|
816
|
+
? "base_branch"
|
|
817
|
+
: before.remoteBase !== after.remoteBase
|
|
818
|
+
? "remote_base"
|
|
819
|
+
: before.targetExists !== after.targetExists
|
|
820
|
+
? "target_exists"
|
|
821
|
+
: null;
|
|
822
|
+
if (moved === null) return success(null, null, null);
|
|
823
|
+
return failure(
|
|
824
|
+
"invalid_input",
|
|
825
|
+
`repository state changed since approval (${moved} moved); show the current state and approve the action again`,
|
|
826
|
+
{ outcome: "not_started", operation },
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
514
830
|
if (canonicalJson(baseline) === canonicalJson(currentResolved)) return success(null, null, null);
|
|
515
831
|
return failure(
|
|
516
832
|
"invalid_input",
|
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);
|
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/** sha256 of the exact bytes, lowercase hex. */
|
|
5
|
+
export const sha256Hex = (bytes: string | Uint8Array): string =>
|
|
6
|
+
createHash("sha256").update(bytes).digest("hex");
|
|
7
|
+
|
|
8
|
+
/** sha256 of a bundle file's bytes, or null when it cannot be read. */
|
|
9
|
+
export const bundleHashOfFile = (file: string): string | null => {
|
|
10
|
+
try {
|
|
11
|
+
return sha256Hex(readFileSync(file));
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Segments of package-manager ephemeral caches (pnpm dlx, npx, pacquet).
|
|
18
|
+
// Installs resolving through these break when the cache is cleared; one
|
|
19
|
+
// table here replaces the per-callsite substring lists.
|
|
20
|
+
const EPHEMERAL_CACHE_SEGMENTS = [
|
|
21
|
+
"/caches/pnpm/dlx/",
|
|
22
|
+
"/.cache/pnpm/dlx/",
|
|
23
|
+
"/_npx/",
|
|
24
|
+
"/.pacquet/",
|
|
25
|
+
"/library/caches/pnpm/",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** True when the entry resolves through a package-manager ephemeral cache. */
|
|
29
|
+
export const isEphemeralCachePath = (entry: string): boolean => {
|
|
30
|
+
if (!entry) return false;
|
|
31
|
+
const normalized = entry.replaceAll("\\", "/").toLowerCase();
|
|
32
|
+
return EPHEMERAL_CACHE_SEGMENTS.some((segment) => normalized.includes(segment));
|
|
33
|
+
};
|
|
@@ -147,6 +147,113 @@ export const refSchema = z.discriminatedUnion("kind", [
|
|
|
147
147
|
]);
|
|
148
148
|
export type Ref = z.infer<typeof refSchema>;
|
|
149
149
|
|
|
150
|
+
/** Maps one reference during record traversal. Returning null marks the
|
|
151
|
+
* reference unportable: arrays drop it, nullable fields null it, and
|
|
152
|
+
* non-nullable positions propagate the drop to their owner. */
|
|
153
|
+
export type RefMap = (ref: Ref) => Ref | null;
|
|
154
|
+
|
|
155
|
+
type RewriteOutcome = { drop: boolean; value?: unknown };
|
|
156
|
+
|
|
157
|
+
const rewriteUnknown = (value: unknown, map: RefMap): RewriteOutcome => {
|
|
158
|
+
if (Array.isArray(value)) {
|
|
159
|
+
const out: unknown[] = [];
|
|
160
|
+
for (const item of value) {
|
|
161
|
+
const rewritten = rewriteUnknown(item, map);
|
|
162
|
+
if (!rewritten.drop) out.push(rewritten.value);
|
|
163
|
+
}
|
|
164
|
+
return { drop: false, value: out };
|
|
165
|
+
}
|
|
166
|
+
if (typeof value === "object" && value !== null) {
|
|
167
|
+
if (refSchema.safeParse(value).success) {
|
|
168
|
+
const mapped = map(value as Ref);
|
|
169
|
+
return mapped === null ? { drop: true } : { drop: false, value: mapped };
|
|
170
|
+
}
|
|
171
|
+
const out: Record<string, unknown> = {};
|
|
172
|
+
for (const [key, item] of Object.entries(value)) {
|
|
173
|
+
const rewritten = rewriteUnknown(item, map);
|
|
174
|
+
out[key] = rewritten.drop ? null : rewritten.value;
|
|
175
|
+
}
|
|
176
|
+
return { drop: false, value: out };
|
|
177
|
+
}
|
|
178
|
+
return { drop: false, value };
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const rewriteNode = (value: unknown, schema: z.ZodType, map: RefMap): RewriteOutcome => {
|
|
182
|
+
// Note: this zod version's classic accessors return core types, hence the
|
|
183
|
+
// casts below. Runtime behavior is identical; only the annotations differ.
|
|
184
|
+
if (
|
|
185
|
+
typeof value === "object" &&
|
|
186
|
+
value !== null &&
|
|
187
|
+
!Array.isArray(value) &&
|
|
188
|
+
refSchema.safeParse(value).success
|
|
189
|
+
) {
|
|
190
|
+
const mapped = map(value as Ref);
|
|
191
|
+
return mapped === null ? { drop: true } : { drop: false, value: mapped };
|
|
192
|
+
}
|
|
193
|
+
let node: z.ZodType = schema;
|
|
194
|
+
while (
|
|
195
|
+
node instanceof z.ZodNullable ||
|
|
196
|
+
node instanceof z.ZodOptional ||
|
|
197
|
+
node instanceof z.ZodDefault
|
|
198
|
+
) {
|
|
199
|
+
node = node.unwrap() as z.ZodType;
|
|
200
|
+
}
|
|
201
|
+
if (node === refSchema) {
|
|
202
|
+
if (!refSchema.safeParse(value).success) return { drop: false, value };
|
|
203
|
+
const mapped = map(value as Ref);
|
|
204
|
+
return mapped === null ? { drop: true } : { drop: false, value: mapped };
|
|
205
|
+
}
|
|
206
|
+
if (node instanceof z.ZodArray) {
|
|
207
|
+
if (!Array.isArray(value)) return { drop: false, value };
|
|
208
|
+
const out: unknown[] = [];
|
|
209
|
+
for (const item of value) {
|
|
210
|
+
const rewritten = rewriteNode(item, node.element as z.ZodType, map);
|
|
211
|
+
if (!rewritten.drop) out.push(rewritten.value);
|
|
212
|
+
}
|
|
213
|
+
return { drop: false, value: out };
|
|
214
|
+
}
|
|
215
|
+
if (node instanceof z.ZodObject) {
|
|
216
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
217
|
+
return { drop: false, value };
|
|
218
|
+
const shape = node.shape as Record<string, z.ZodType>;
|
|
219
|
+
const record = value as Record<string, unknown>;
|
|
220
|
+
const out: Record<string, unknown> = {};
|
|
221
|
+
for (const [key, item] of Object.entries(record)) {
|
|
222
|
+
if (!(key in shape)) {
|
|
223
|
+
const scrubbed = rewriteUnknown(item, map);
|
|
224
|
+
out[key] = scrubbed.drop ? null : scrubbed.value;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const child = shape[key];
|
|
228
|
+
const rewritten = rewriteNode(item, child, map);
|
|
229
|
+
if (rewritten.drop) {
|
|
230
|
+
if (child instanceof z.ZodOptional || child instanceof z.ZodDefault) continue;
|
|
231
|
+
if (child instanceof z.ZodNullable) {
|
|
232
|
+
out[key] = null;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
return { drop: true };
|
|
236
|
+
}
|
|
237
|
+
out[key] = rewritten.value;
|
|
238
|
+
}
|
|
239
|
+
return { drop: false, value: out };
|
|
240
|
+
}
|
|
241
|
+
return { drop: false, value };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Schema-driven reference rewrite over an already-validated record subtree.
|
|
246
|
+
* Every `Ref` anywhere in the value is mapped; unportable references are
|
|
247
|
+
* dropped from arrays, nulled in nullable fields, and propagated upward
|
|
248
|
+
* anywhere else — so a new ref-bearing field can never silently slip
|
|
249
|
+
* through export or import. Unknown (schema-absent) keys are scrubbed the
|
|
250
|
+
* same way. Returns null only when the root itself is an unportable ref.
|
|
251
|
+
*/
|
|
252
|
+
export const rewriteRecordRefs = (value: unknown, schema: z.ZodType, map: RefMap): unknown => {
|
|
253
|
+
const rewritten = rewriteNode(value, schema, map);
|
|
254
|
+
return rewritten.drop ? null : rewritten.value;
|
|
255
|
+
};
|
|
256
|
+
|
|
150
257
|
export const provenanceSchema = z
|
|
151
258
|
.object({
|
|
152
259
|
kind: z.enum(["host_observed", "agent_reported", "imported"]),
|
|
@@ -455,10 +562,11 @@ export const decisionSchema = z
|
|
|
455
562
|
approvedContent: text,
|
|
456
563
|
displayed: text.optional(),
|
|
457
564
|
contentRefs: z.array(refSchema),
|
|
565
|
+
statedChoice: z.object({ ref: text, text: text }).strict().optional(),
|
|
458
566
|
})
|
|
459
567
|
.strict(),
|
|
460
568
|
digest,
|
|
461
|
-
response: z.enum(["approved", "rejected"]),
|
|
569
|
+
response: z.enum(["approved", "rejected", "stated"]),
|
|
462
570
|
requirementIds: z.array(digest),
|
|
463
571
|
revoked: z.object({ at: utc, reason: text }).strict().nullable(),
|
|
464
572
|
consumption: z
|