@brainervirus/workit-core 4.0.0 → 6.0.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 +1 -1
- package/package.json +1 -1
- package/skills/workit-bdd/SKILL.md +35 -29
- package/skills/workit-continue/SKILL.md +53 -0
- package/skills/workit-debug/SKILL.md +41 -36
- package/skills/workit-deslop/SKILL.md +35 -25
- package/skills/workit-fanout/SKILL.md +62 -0
- package/skills/workit-fanout/references/brief.md +56 -0
- package/skills/workit-implement/SKILL.md +48 -44
- package/skills/workit-review/SKILL.md +42 -51
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +58 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +42 -0
- package/skills/workit-ship/SKILL.md +52 -0
- package/skills/workit-test-audit/SKILL.md +10 -2
- package/skills/workit-verify-app/SKILL.md +63 -0
- package/skills/workit-verify-app/references/template.md +49 -0
- package/src/autonomy.ts +309 -44
- package/src/core/context-read.ts +144 -0
- package/src/core/methods.ts +71 -98
- package/src/core/skill-manifests.ts +49 -25
- package/src/core/store-lock.ts +1 -1
- package/src/core/task-context.ts +1 -1
- package/src/core/task-contract.ts +8 -130
- package/src/core/task-engine.ts +57 -879
- package/src/core/task-evaluation.ts +63 -5
- package/src/core/task-store.ts +2 -107
- package/src/core/workers.ts +12 -158
- package/src/core/workspaces.ts +15 -0
- package/src/core/youtrack-writes.ts +92 -0
- package/src/core.ts +2 -79
- package/src/forge/pr-ops.ts +2 -2
- package/src/hooks/handle.ts +29 -5
- package/src/hooks/hosts/claude-code.ts +0 -8
- package/src/hooks/hosts/codex.ts +2 -11
- package/src/hooks/hosts/cursor.ts +0 -8
- package/src/hooks/hosts/opencode.ts +0 -8
- package/src/hooks/hosts/pi.ts +0 -10
- package/src/ledger.ts +19 -10
- package/templates/plan-template.md +17 -18
- package/templates/spec-template.md +4 -3
- package/templates/workit-contract.md +3 -2
- package/skills/workit-babysit/SKILL.md +0 -37
- package/skills/workit-behavioral-tdd/SKILL.md +0 -56
- package/skills/workit-blast-radius/SKILL.md +0 -26
- package/skills/workit-challenge/SKILL.md +0 -47
- package/skills/workit-diagram/SKILL.md +0 -27
- package/skills/workit-green-run/SKILL.md +0 -24
- package/skills/workit-handoff/SKILL.md +0 -37
- package/skills/workit-mockup/SKILL.md +0 -23
- package/skills/workit-plan/SKILL.md +0 -45
- package/skills/workit-steer/SKILL.md +0 -39
- package/src/core/authority.ts +0 -1033
- package/src/core/auto-approval.ts +0 -338
- package/src/core/external-action-effects.ts +0 -2441
- package/src/core/external-action.ts +0 -1204
- package/templates/execution-contract.md +0 -40
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
POLICY_VERSION,
|
|
7
7
|
candidateDigest,
|
|
8
8
|
candidateSchema,
|
|
9
|
-
decisionDigest,
|
|
10
9
|
failure,
|
|
11
10
|
scopeCovers as contractScopeCovers,
|
|
12
11
|
success,
|
|
@@ -25,7 +24,6 @@ import {
|
|
|
25
24
|
type TaskView,
|
|
26
25
|
type WorkspaceRecord,
|
|
27
26
|
} from "./task-contract";
|
|
28
|
-
import { verifyDecisionContentAtRoot } from "./authority";
|
|
29
27
|
import {
|
|
30
28
|
CHECKS_FILE,
|
|
31
29
|
checkCommand,
|
|
@@ -495,6 +493,69 @@ export const findingVerificationPasses = (
|
|
|
495
493
|
evidence.status === "passed" &&
|
|
496
494
|
(evidence.kind === "check" || evidence.kind === "review") &&
|
|
497
495
|
((findingCandidateId ?? null) === null || evidence.candidateId != null);
|
|
496
|
+
/** A decision about a document stays current only while its bytes are unchanged. */
|
|
497
|
+
export const verifyDecisionContentAtRoot = (
|
|
498
|
+
checkoutRoot: string,
|
|
499
|
+
binding: Decision["binding"],
|
|
500
|
+
): Result<null> => {
|
|
501
|
+
let root: string;
|
|
502
|
+
try {
|
|
503
|
+
root = fs.realpathSync(checkoutRoot);
|
|
504
|
+
} catch {
|
|
505
|
+
return failure("permission_denied", "checkout root is unavailable");
|
|
506
|
+
}
|
|
507
|
+
for (const reference of binding.contentRefs) {
|
|
508
|
+
if (reference.kind !== "file") continue;
|
|
509
|
+
if (!reference.digest)
|
|
510
|
+
return failure("invalid_input", "document references require a byte digest");
|
|
511
|
+
const target = path.resolve(root, reference.path);
|
|
512
|
+
if (target !== root && !target.startsWith(`${root}${path.sep}`))
|
|
513
|
+
return failure(
|
|
514
|
+
"invalid_input",
|
|
515
|
+
"document reference escapes checkout; cite it as an external file:// reference instead",
|
|
516
|
+
{
|
|
517
|
+
fields: [
|
|
518
|
+
{ path: "contentRefs.path", reason: `${reference.path} is outside the checkout` },
|
|
519
|
+
],
|
|
520
|
+
},
|
|
521
|
+
);
|
|
522
|
+
try {
|
|
523
|
+
const below = path.relative(root, target);
|
|
524
|
+
let current = root;
|
|
525
|
+
for (const segment of below.split(path.sep)) {
|
|
526
|
+
if (!segment) continue;
|
|
527
|
+
current = path.join(current, segment);
|
|
528
|
+
const stat = fs.lstatSync(current);
|
|
529
|
+
const real = fs.realpathSync(current);
|
|
530
|
+
if (real !== root && !real.startsWith(`${root}${path.sep}`))
|
|
531
|
+
return failure(
|
|
532
|
+
"invalid_input",
|
|
533
|
+
"document reference escapes checkout; cite it as an external file:// reference instead",
|
|
534
|
+
{
|
|
535
|
+
fields: [
|
|
536
|
+
{
|
|
537
|
+
path: "contentRefs.path",
|
|
538
|
+
reason: `${reference.path} resolves outside the checkout`,
|
|
539
|
+
},
|
|
540
|
+
],
|
|
541
|
+
},
|
|
542
|
+
);
|
|
543
|
+
if (stat.isSymbolicLink())
|
|
544
|
+
return failure("invalid_input", "document reference uses a symlink");
|
|
545
|
+
if (current === target && !stat.isFile())
|
|
546
|
+
return failure("invalid_input", "document reference is not a regular file");
|
|
547
|
+
if (current !== target && !stat.isDirectory())
|
|
548
|
+
return failure("invalid_input", "document reference ancestor is not a directory");
|
|
549
|
+
}
|
|
550
|
+
if (digestBytes(fs.readFileSync(target)) !== reference.digest)
|
|
551
|
+
return failure("permission_denied", "decision document bytes have changed");
|
|
552
|
+
} catch {
|
|
553
|
+
return failure("invalid_input", "decision document is unavailable");
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return success(null, null, null);
|
|
557
|
+
};
|
|
558
|
+
|
|
498
559
|
const applicableDecision = (
|
|
499
560
|
task: TaskRecord,
|
|
500
561
|
workspace: WorkspaceRecord,
|
|
@@ -514,7 +575,6 @@ const applicableDecision = (
|
|
|
514
575
|
decision.requirementIds.includes(requirement.id) &&
|
|
515
576
|
decision.binding.scope &&
|
|
516
577
|
scopeCovers(decision.binding.scope, requirement.scope) &&
|
|
517
|
-
decision.digest === decisionDigest(decision) &&
|
|
518
578
|
(checkoutRoot
|
|
519
579
|
? verifyDecisionContentAtRoot(checkoutRoot, decision.binding).ok
|
|
520
580
|
: decision.binding.contentRefs.every((reference) => reference.kind !== "file")),
|
|
@@ -537,7 +597,6 @@ const applicableRequirementDecision = (
|
|
|
537
597
|
data.binding.workspaceId === workspace.id &&
|
|
538
598
|
data.requirementIds.includes(requirement.id) &&
|
|
539
599
|
scopeCovers(data.binding.scope, requirement.scope) &&
|
|
540
|
-
data.digest === decisionDigest(data) &&
|
|
541
600
|
(checkoutRoot
|
|
542
601
|
? verifyDecisionContentAtRoot(checkoutRoot, data.binding).ok
|
|
543
602
|
: data.binding.contentRefs.every((reference) => reference.kind !== "file")),
|
|
@@ -903,7 +962,6 @@ export function evaluateClosure(
|
|
|
903
962
|
decision.purpose === "limitation" &&
|
|
904
963
|
decision.response === "approved" &&
|
|
905
964
|
decision.revoked === null &&
|
|
906
|
-
decision.digest === decisionDigest(decision) &&
|
|
907
965
|
verifyDecisionContentAtRoot(view.workspace.root, decision.binding).ok &&
|
|
908
966
|
decision.binding.taskId === view.task.id &&
|
|
909
967
|
decision.binding.workspaceId === view.workspace.id &&
|
package/src/core/task-store.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
//
|
|
6
6
|
// store.json format marker (a newer format fails closed)
|
|
7
7
|
// checkouts/<slug>/workspace.json this checkout's workspace record
|
|
8
|
-
// checkouts/<slug>/metadata.lock checkout lock: workspace writes
|
|
9
|
-
// creation
|
|
8
|
+
// checkouts/<slug>/metadata.lock checkout lock: workspace writes and task
|
|
9
|
+
// creation
|
|
10
10
|
// tasks/<id>/events.jsonl the task's append-only event log
|
|
11
11
|
// tasks/<id>/snapshot.json rebuildable cache of the reduced state
|
|
12
12
|
// tasks/<id>/lock task lock: one append at a time
|
|
@@ -24,7 +24,6 @@
|
|
|
24
24
|
// A 2.x store in `<root>/.workit` is migrated on first use (idempotent, under
|
|
25
25
|
// the checkout lock, backup kept). The migrated `.workit/workspace.json` is
|
|
26
26
|
// replaced by a marker that 2.x readers reject with an upgrade message.
|
|
27
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
28
27
|
import { createHash, randomUUID } from "node:crypto";
|
|
29
28
|
import * as fs from "node:fs";
|
|
30
29
|
import path from "node:path";
|
|
@@ -341,7 +340,6 @@ export const sameDirectoryIdentity = (left: string, right: string): boolean => {
|
|
|
341
340
|
}
|
|
342
341
|
};
|
|
343
342
|
|
|
344
|
-
const externalActionLockRoots = new AsyncLocalStorage<ReadonlySet<string>>();
|
|
345
343
|
/** Locks this process holds; waiting on them can only time out. */
|
|
346
344
|
const heldInProcess = new Map<string, number>();
|
|
347
345
|
const hold = (lock: string) => heldInProcess.set(lock, (heldInProcess.get(lock) ?? 0) + 1);
|
|
@@ -854,7 +852,6 @@ export class TaskStore {
|
|
|
854
852
|
const reserved = this.writeWorkspace(nextWorkspace.data);
|
|
855
853
|
if (!reserved.ok) return reserved;
|
|
856
854
|
const uncertain = (message: string) => {
|
|
857
|
-
this.markUncertain(nextWorkspace.data);
|
|
858
855
|
return failure("external_outcome_unknown", message, {
|
|
859
856
|
operation: "coupled_mutation",
|
|
860
857
|
outcome: "unknown",
|
|
@@ -885,92 +882,6 @@ export class TaskStore {
|
|
|
885
882
|
return result;
|
|
886
883
|
}
|
|
887
884
|
|
|
888
|
-
/** Hold this checkout's lock across a managed effect. */
|
|
889
|
-
async withExternalActionLock<T>(
|
|
890
|
-
operation: () => Promise<Result<T>>,
|
|
891
|
-
reentrant = false,
|
|
892
|
-
): Promise<Result<T>> {
|
|
893
|
-
const activeRoots = externalActionLockRoots.getStore();
|
|
894
|
-
if (
|
|
895
|
-
activeRoots &&
|
|
896
|
-
[...activeRoots].some((root) => root === this.root || sameDirectoryIdentity(root, this.root))
|
|
897
|
-
)
|
|
898
|
-
return operation();
|
|
899
|
-
const ready = this.ready(true);
|
|
900
|
-
if (!ready.ok) return ready;
|
|
901
|
-
const lockPath = this.paths().checkoutLock;
|
|
902
|
-
try {
|
|
903
|
-
this.initializeStorage();
|
|
904
|
-
} catch (error) {
|
|
905
|
-
return failure("storage_error", `unable to initialize store: ${String(error)}`, {
|
|
906
|
-
path: this.paths().dir,
|
|
907
|
-
});
|
|
908
|
-
}
|
|
909
|
-
let handle: FileLockSyncHandle;
|
|
910
|
-
try {
|
|
911
|
-
const options = this.lockOptions(lockPath);
|
|
912
|
-
handle = this.acquire(lockPath, {
|
|
913
|
-
...options,
|
|
914
|
-
payload: () => ({ ...options.payload(), externalAction: true }),
|
|
915
|
-
});
|
|
916
|
-
} catch (error) {
|
|
917
|
-
return this.lockFailure(lockPath, error);
|
|
918
|
-
}
|
|
919
|
-
hold(lockPath);
|
|
920
|
-
let result: Result<T>;
|
|
921
|
-
try {
|
|
922
|
-
if (!handle.verifyStillHeld())
|
|
923
|
-
result = failure("recovery_required", "metadata lock was compromised", { path: lockPath });
|
|
924
|
-
else {
|
|
925
|
-
try {
|
|
926
|
-
const run = () => operation();
|
|
927
|
-
result = await (reentrant
|
|
928
|
-
? externalActionLockRoots.run(new Set([...(activeRoots ?? []), this.root]), run)
|
|
929
|
-
: run());
|
|
930
|
-
} catch {
|
|
931
|
-
result = failure("external_outcome_unknown", "external action outcome is unknown", {
|
|
932
|
-
outcome: "unknown",
|
|
933
|
-
});
|
|
934
|
-
}
|
|
935
|
-
try {
|
|
936
|
-
if (!handle.verifyStillHeld())
|
|
937
|
-
result = failure("external_outcome_unknown", "metadata lock was compromised", {
|
|
938
|
-
outcome: "unknown",
|
|
939
|
-
path: lockPath,
|
|
940
|
-
});
|
|
941
|
-
} catch {
|
|
942
|
-
result = failure("external_outcome_unknown", "metadata lock could not be verified", {
|
|
943
|
-
outcome: "unknown",
|
|
944
|
-
path: lockPath,
|
|
945
|
-
});
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
} catch {
|
|
949
|
-
result = failure("external_outcome_unknown", "external action lock operation failed", {
|
|
950
|
-
outcome: "unknown",
|
|
951
|
-
path: lockPath,
|
|
952
|
-
});
|
|
953
|
-
}
|
|
954
|
-
drop(lockPath);
|
|
955
|
-
try {
|
|
956
|
-
retryTransient(() => handle.release());
|
|
957
|
-
} catch (error) {
|
|
958
|
-
const released = this.lockFailure(lockPath, error);
|
|
959
|
-
const releaseError = released.ok ? "metadata lock release failed" : released.error;
|
|
960
|
-
return result.ok
|
|
961
|
-
? failure(
|
|
962
|
-
"external_outcome_unknown",
|
|
963
|
-
"external action metadata lock release is uncertain",
|
|
964
|
-
{
|
|
965
|
-
outcome: "unknown",
|
|
966
|
-
path: lockPath,
|
|
967
|
-
},
|
|
968
|
-
)
|
|
969
|
-
: failure("recovery_required", `${result.error}; ${releaseError}`, result.details);
|
|
970
|
-
}
|
|
971
|
-
return result;
|
|
972
|
-
}
|
|
973
|
-
|
|
974
885
|
/**
|
|
975
886
|
* `workit gc`: compact long task logs into a checkpoint plus their most
|
|
976
887
|
* recent events (the latest state is never lost), remove unreferenced
|
|
@@ -1700,7 +1611,6 @@ export class TaskStore {
|
|
|
1700
1611
|
revision: newRevision(),
|
|
1701
1612
|
root: this.root,
|
|
1702
1613
|
runtime: { createdWith: runtimeVersion(), updatedWith: runtimeVersion() },
|
|
1703
|
-
writer: null,
|
|
1704
1614
|
};
|
|
1705
1615
|
}
|
|
1706
1616
|
|
|
@@ -1747,16 +1657,6 @@ export class TaskStore {
|
|
|
1747
1657
|
}
|
|
1748
1658
|
}
|
|
1749
1659
|
|
|
1750
|
-
private markUncertain(workspace: WorkspaceRecord) {
|
|
1751
|
-
if (!workspace.writer || workspace.writer.state === "uncertain") return;
|
|
1752
|
-
this.writeWorkspace({
|
|
1753
|
-
...workspace,
|
|
1754
|
-
writer: { ...workspace.writer, state: "uncertain" as const },
|
|
1755
|
-
revision: newRevision(),
|
|
1756
|
-
runtime: stampNew(workspace),
|
|
1757
|
-
});
|
|
1758
|
-
}
|
|
1759
|
-
|
|
1760
1660
|
// -------------------------------------------------------------------------
|
|
1761
1661
|
// internals: index cache
|
|
1762
1662
|
|
|
@@ -2388,11 +2288,6 @@ export class TaskStore {
|
|
|
2388
2288
|
try {
|
|
2389
2289
|
lock = parseMetadataLockOrNull(fs.readFileSync(lockPath, "utf8"));
|
|
2390
2290
|
} catch {}
|
|
2391
|
-
if (lock?.externalAction)
|
|
2392
|
-
return failure("writer_conflict", "workspace is reserved by a managed external action", {
|
|
2393
|
-
outcome: "not_started",
|
|
2394
|
-
path: lockPath,
|
|
2395
|
-
});
|
|
2396
2291
|
return failure(
|
|
2397
2292
|
"busy",
|
|
2398
2293
|
`workit lock is held by another Workit call${lock ? ` (pid ${lock.pid} on ${lock.host})` : ""}; retry shortly`,
|
package/src/core/workers.ts
CHANGED
|
@@ -1,30 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
type Result,
|
|
11
|
-
type Revision,
|
|
12
|
-
type TaskRecord,
|
|
13
|
-
type Worker,
|
|
14
|
-
type WorkerReport,
|
|
15
|
-
type WorkspaceRecord,
|
|
1
|
+
import type {
|
|
2
|
+
Caller,
|
|
3
|
+
Id,
|
|
4
|
+
Provenance,
|
|
5
|
+
Ref,
|
|
6
|
+
Result,
|
|
7
|
+
Revision,
|
|
8
|
+
Worker,
|
|
9
|
+
WorkerReport,
|
|
16
10
|
} from "./task-contract";
|
|
17
|
-
import { TaskStore } from "./task-store";
|
|
18
11
|
|
|
19
12
|
export type WorkerState = Worker["state"];
|
|
20
13
|
export type HostSession = Extract<Ref, { kind: "host" }>;
|
|
21
14
|
|
|
22
|
-
/** The adapter-owned caller identity used by all writer checks. */
|
|
23
|
-
export type CallerContext = Caller & {
|
|
24
|
-
workerId?: Id | null;
|
|
25
|
-
session?: HostSession;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
15
|
export type NativeWorkerObservation = {
|
|
29
16
|
taskId: Id;
|
|
30
17
|
workerId: Id;
|
|
@@ -44,55 +31,9 @@ export type NativeWorkerVerification = {
|
|
|
44
31
|
caller: Caller;
|
|
45
32
|
};
|
|
46
33
|
|
|
47
|
-
/**
|
|
48
|
-
* Host attestation for a worker that has no session yet: `prepare` claims the launch
|
|
49
|
-
* slot, `not_started` claims that no child was ever created for that same reservation.
|
|
50
|
-
*/
|
|
51
|
-
export type WorkerDispatchStage = "prepare" | "not_started";
|
|
52
|
-
|
|
53
|
-
export type NativeWorkerDispatchVerification = {
|
|
54
|
-
observation: unknown;
|
|
55
|
-
expected: {
|
|
56
|
-
taskId: Id;
|
|
57
|
-
workspaceId: Id;
|
|
58
|
-
workerId: Id;
|
|
59
|
-
expectedRevision: Revision;
|
|
60
|
-
expectedWorkspaceRevision: Revision;
|
|
61
|
-
stage: WorkerDispatchStage;
|
|
62
|
-
};
|
|
63
|
-
caller: Caller;
|
|
64
|
-
};
|
|
65
|
-
|
|
34
|
+
/** A host that observes its own child sessions attests their lifecycle. */
|
|
66
35
|
export type NativeWorkerVerifier = {
|
|
67
36
|
verifyWorker: (input: NativeWorkerVerification) => Result<Provenance>;
|
|
68
|
-
/** Hosts that can observe their own launch surface also attest dispatch stages. */
|
|
69
|
-
verifyDispatch?: (input: NativeWorkerDispatchVerification) => Result<Provenance>;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
declare const workerDispatch: unique symbol;
|
|
73
|
-
/** Opaque in-process launch reservation. Adapters hold it; it is never serialized. */
|
|
74
|
-
export type WorkerDispatch = { readonly [workerDispatch]: true };
|
|
75
|
-
|
|
76
|
-
export type WorkerDispatchRequest = {
|
|
77
|
-
taskId: Id;
|
|
78
|
-
workerId: Id;
|
|
79
|
-
expectedRevision: Revision;
|
|
80
|
-
expectedWorkspaceRevision: Revision;
|
|
81
|
-
observation: unknown;
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export type WorkerDispatchCommit = WorkerDispatchRequest & {
|
|
85
|
-
dispatch: WorkerDispatch;
|
|
86
|
-
outcome: "started" | "not_started";
|
|
87
|
-
session: HostSession | null;
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
const same = (left: unknown, right: unknown): boolean => {
|
|
91
|
-
try {
|
|
92
|
-
return canonicalJson(left) === canonicalJson(right);
|
|
93
|
-
} catch {
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
37
|
};
|
|
97
38
|
|
|
98
39
|
/** The one shared definition of active or uncertain worker state used by
|
|
@@ -103,97 +44,10 @@ export const isUncertainWorker = (state: WorkerState): boolean =>
|
|
|
103
44
|
|
|
104
45
|
/**
|
|
105
46
|
* Lifecycle gate table: which worker states block each lead transition.
|
|
106
|
-
* Pause freezes, so live workers never block it
|
|
107
|
-
*
|
|
47
|
+
* Pause freezes, so live workers never block it; resume, revise, and close
|
|
48
|
+
* must reconcile first.
|
|
108
49
|
*/
|
|
109
50
|
export const workerBlocksTransition = (
|
|
110
51
|
transition: "pause" | "resume" | "revise" | "close",
|
|
111
52
|
state: WorkerState,
|
|
112
53
|
): boolean => (transition === "pause" ? false : isUncertainWorker(state));
|
|
113
|
-
|
|
114
|
-
const callerSession = (caller: CallerContext): HostSession =>
|
|
115
|
-
caller.session ?? { kind: "host", host: caller.host, handle: caller.actor };
|
|
116
|
-
|
|
117
|
-
const validPath = (value: unknown): value is string =>
|
|
118
|
-
typeof value === "string" &&
|
|
119
|
-
value.length > 0 &&
|
|
120
|
-
!Array.from(value).some((char) => {
|
|
121
|
-
const code = char.charCodeAt(0);
|
|
122
|
-
return code < 32 || code === 127;
|
|
123
|
-
}) &&
|
|
124
|
-
!value.split(/[\\/]/).includes("..");
|
|
125
|
-
|
|
126
|
-
const callerValue = (value: CallerContext): Caller => ({ host: value.host, actor: value.actor });
|
|
127
|
-
const workerIdOf = (value: CallerContext): Id | null => value.workerId ?? null;
|
|
128
|
-
|
|
129
|
-
export type ProductWriteInput = {
|
|
130
|
-
task: TaskRecord;
|
|
131
|
-
workspace: WorkspaceRecord;
|
|
132
|
-
caller: CallerContext;
|
|
133
|
-
paths: string[];
|
|
134
|
-
store?: TaskStore;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
export const currentWriterOwnsTask = (
|
|
138
|
-
task: TaskRecord,
|
|
139
|
-
workspace: WorkspaceRecord,
|
|
140
|
-
caller: { host: string; actor: string },
|
|
141
|
-
): boolean => {
|
|
142
|
-
const owner = workspace.writer?.owner;
|
|
143
|
-
return (
|
|
144
|
-
task.status === "active" &&
|
|
145
|
-
task.workspaceId === workspace.id &&
|
|
146
|
-
owner?.taskId === task.id &&
|
|
147
|
-
owner.session.host === caller.host &&
|
|
148
|
-
owner.session.handle === caller.actor
|
|
149
|
-
);
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
/** Check the authoritative workspace owner and the caller's role. File paths
|
|
153
|
-
* are host-policy territory: only malformed paths are rejected here, never
|
|
154
|
-
* out-of-scope ones. */
|
|
155
|
-
export function assertProductWriteAllowed(input: ProductWriteInput): Result<Owner> {
|
|
156
|
-
if (!Array.isArray(input.paths) || input.paths.some((path) => !validPath(path)))
|
|
157
|
-
return failure("invalid_input", "invalid product write path");
|
|
158
|
-
if (!input.store) return failure("permission_denied", "write authorization requires core state");
|
|
159
|
-
const task = input.store.readTask(input.task.id);
|
|
160
|
-
if (!task.ok) return task;
|
|
161
|
-
const workspace = input.store.readWorkspace();
|
|
162
|
-
if (!workspace.ok) return workspace;
|
|
163
|
-
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
164
|
-
const workspaceRecord = workspace.data;
|
|
165
|
-
if (task.data.status !== "active")
|
|
166
|
-
return failure("invalid_transition", "paused or closed tasks cannot own product writes");
|
|
167
|
-
if (workspaceRecord.id !== task.data.workspaceId || workspaceRecord.root.length === 0)
|
|
168
|
-
return failure("recovery_required", "task and workspace bindings are invalid");
|
|
169
|
-
const writer = workspaceRecord.writer;
|
|
170
|
-
if (!writer) return failure("permission_denied", "checkout has no writer owner");
|
|
171
|
-
if (writer.state === "uncertain")
|
|
172
|
-
return failure("recovery_required", "checkout writer requires recovery", {
|
|
173
|
-
owner: writer.owner,
|
|
174
|
-
});
|
|
175
|
-
const caller = callerValue(input.caller);
|
|
176
|
-
const workerId = workerIdOf(input.caller);
|
|
177
|
-
const owner = writer.owner;
|
|
178
|
-
if (
|
|
179
|
-
owner.taskId !== task.data.id ||
|
|
180
|
-
owner.workerId !== workerId ||
|
|
181
|
-
owner.session.host !== caller.host ||
|
|
182
|
-
owner.session.handle !== caller.actor
|
|
183
|
-
)
|
|
184
|
-
return failure("writer_conflict", "checkout is owned by another validated actor", { owner });
|
|
185
|
-
const worker =
|
|
186
|
-
workerId === null ? null : task.data.workers.find((entry) => entry.id === workerId);
|
|
187
|
-
if (workerId !== null && (!worker || worker.data.assignment.role !== "implementer"))
|
|
188
|
-
return failure("permission_denied", "only implementers can own product writes");
|
|
189
|
-
if (
|
|
190
|
-
workerId !== null &&
|
|
191
|
-
(!worker?.data.session ||
|
|
192
|
-
worker.data.state === "cancelling" ||
|
|
193
|
-
worker.data.state === "unknown" ||
|
|
194
|
-
worker.data.state === "stopped" ||
|
|
195
|
-
!same(worker.data.session, callerSession(input.caller)))
|
|
196
|
-
)
|
|
197
|
-
return failure("recovery_required", "worker session is not an active writer");
|
|
198
|
-
return success(null, workspaceRecord.revision, owner);
|
|
199
|
-
}
|
package/src/core/workspaces.ts
CHANGED
|
@@ -117,7 +117,22 @@ const workspaceConfigSchema = z
|
|
|
117
117
|
})
|
|
118
118
|
.passthrough()
|
|
119
119
|
.optional(),
|
|
120
|
+
/** Legacy (≤4.x) standing approvals; read once as autonomy grants (autonomy.ts). */
|
|
120
121
|
autoApprove: z.union([z.boolean(), z.array(nonBlank)]).optional(),
|
|
122
|
+
/** Where an unnamed request stops (skills read it); same rules as grants. */
|
|
123
|
+
defaultEndpoint: z.enum(["commit", "pr"]).optional(),
|
|
124
|
+
/** Autonomy grants (D15): only ever set here, by the user. */
|
|
125
|
+
autonomy: z
|
|
126
|
+
.object(
|
|
127
|
+
Object.fromEntries(
|
|
128
|
+
["push", "pr", "merge", "release", "rerun"].map((kind) => [
|
|
129
|
+
kind,
|
|
130
|
+
z.union([z.boolean(), z.literal("verified")]).optional(),
|
|
131
|
+
]),
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
.passthrough()
|
|
135
|
+
.optional(),
|
|
121
136
|
youtrack: z
|
|
122
137
|
.object({ baseUrl: nonBlank.optional(), link_issues: z.boolean().optional() })
|
|
123
138
|
.passthrough()
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Idempotent YouTrack writes for `workit youtrack note|time|meeting` (S16
|
|
2
|
+
// review M4). Each comment or work item carries a deterministic marker,
|
|
3
|
+
// `<!-- workit-action:<sha256> -->`, derived from the request. Before writing,
|
|
4
|
+
// the issue is read back and a write whose marker is already present is
|
|
5
|
+
// skipped (`already_done`); after a write with an unknown outcome (a timeout
|
|
6
|
+
// after the server applied it), the read-back settles it instead of a retry
|
|
7
|
+
// posting a duplicate.
|
|
8
|
+
import { sha256 } from "./task-contract";
|
|
9
|
+
import { youTrackRequest, youTrackToken } from "./youtrack";
|
|
10
|
+
|
|
11
|
+
export type MarkerWhere = "comments" | "workItems";
|
|
12
|
+
|
|
13
|
+
export const youTrackMarker = (request: Record<string, unknown>): string | null => {
|
|
14
|
+
const creds = youTrackToken();
|
|
15
|
+
if ("error" in creds) return null;
|
|
16
|
+
return `<!-- workit-action:${sha256({ ...request, baseUrl: creds.base })} -->`;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const withMarker = (text: string, marker: string): string =>
|
|
20
|
+
`${text}${text ? "\n\n" : ""}${marker}`;
|
|
21
|
+
|
|
22
|
+
const PAGE = 500;
|
|
23
|
+
|
|
24
|
+
/** true/false when the read-back is conclusive, null when it is not. */
|
|
25
|
+
export async function markerPresent(
|
|
26
|
+
issueId: string,
|
|
27
|
+
marker: string,
|
|
28
|
+
where: MarkerWhere,
|
|
29
|
+
): Promise<boolean | null> {
|
|
30
|
+
const creds = youTrackToken();
|
|
31
|
+
if ("error" in creds) return null;
|
|
32
|
+
const endpoint =
|
|
33
|
+
where === "comments"
|
|
34
|
+
? `/api/issues/${encodeURIComponent(issueId)}/comments?fields=id,text,deleted`
|
|
35
|
+
: `/api/issues/${encodeURIComponent(issueId)}/timeTracking/workItems?fields=id,text`;
|
|
36
|
+
const result = await youTrackRequest(`${creds.base}${endpoint}&$top=${PAGE}&$skip=0`, {
|
|
37
|
+
method: "GET",
|
|
38
|
+
token: creds.token,
|
|
39
|
+
});
|
|
40
|
+
if (result.status !== 0) return null;
|
|
41
|
+
try {
|
|
42
|
+
const items = JSON.parse(result.stdout) as unknown;
|
|
43
|
+
if (!Array.isArray(items)) return null;
|
|
44
|
+
const found = items.some(
|
|
45
|
+
(item) =>
|
|
46
|
+
item &&
|
|
47
|
+
typeof item === "object" &&
|
|
48
|
+
(item as { deleted?: unknown }).deleted !== true &&
|
|
49
|
+
typeof (item as { text?: unknown }).text === "string" &&
|
|
50
|
+
(item as { text: string }).text.includes(marker),
|
|
51
|
+
);
|
|
52
|
+
// A full page may hide an older match: only a hit is conclusive then.
|
|
53
|
+
return found ? true : items.length < PAGE ? false : null;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type WriteResult = { ok: boolean; error?: string | null; data?: unknown };
|
|
60
|
+
|
|
61
|
+
export type OnceOutcome =
|
|
62
|
+
| {
|
|
63
|
+
status: "done" | "already_done" | "settled";
|
|
64
|
+
data?: unknown;
|
|
65
|
+
/** The pre-write read-back could not rule out an earlier copy (>1 page). */
|
|
66
|
+
precheck?: "inconclusive";
|
|
67
|
+
}
|
|
68
|
+
| { status: "failed" | "unknown"; error: string; data?: unknown };
|
|
69
|
+
|
|
70
|
+
/** Write at most once: skip when the marker is there, read back an unknown outcome. */
|
|
71
|
+
export async function writeOnce(
|
|
72
|
+
issueId: string,
|
|
73
|
+
marker: string,
|
|
74
|
+
where: MarkerWhere,
|
|
75
|
+
write: () => Promise<WriteResult>,
|
|
76
|
+
): Promise<OnceOutcome> {
|
|
77
|
+
const before = await markerPresent(issueId, marker, where);
|
|
78
|
+
if (before === true) return { status: "already_done" };
|
|
79
|
+
const precheck = before === null ? { precheck: "inconclusive" as const } : {};
|
|
80
|
+
const result = await write();
|
|
81
|
+
if (result.ok) return { status: "done", data: result.data, ...precheck };
|
|
82
|
+
const outcome = (result.data as { outcome?: unknown } | undefined)?.outcome;
|
|
83
|
+
if (outcome === "not_applied")
|
|
84
|
+
return { status: "failed", error: result.error ?? "not applied", data: result.data };
|
|
85
|
+
const settled = await markerPresent(issueId, marker, where);
|
|
86
|
+
if (settled === true) return { status: "settled", ...precheck };
|
|
87
|
+
return {
|
|
88
|
+
status: settled === false ? "failed" : "unknown",
|
|
89
|
+
error: result.error ?? "YouTrack write failed",
|
|
90
|
+
data: result.data,
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/core.ts
CHANGED
|
@@ -24,8 +24,6 @@ export {
|
|
|
24
24
|
candidateDigest,
|
|
25
25
|
requirementId,
|
|
26
26
|
rewriteRecordRefs,
|
|
27
|
-
BINDING_QUESTION_BUDGET,
|
|
28
|
-
workitBindingQuestionIssue,
|
|
29
27
|
} from "./core/task-contract";
|
|
30
28
|
export type {
|
|
31
29
|
OperationFamily,
|
|
@@ -74,76 +72,7 @@ export type { MethodId, SelectedMethod } from "./core/methods";
|
|
|
74
72
|
export { shellBranchPolicyViolation, shellBranchTarget } from "./core/route-intent";
|
|
75
73
|
export { classifyBranchDirt } from "./core/branch";
|
|
76
74
|
export type { BranchDirt } from "./core/branch";
|
|
77
|
-
export {
|
|
78
|
-
AUTO_CLASSES,
|
|
79
|
-
autoApproves,
|
|
80
|
-
operationAutoClass,
|
|
81
|
-
resolveAutoApproval,
|
|
82
|
-
standingApprovalLive,
|
|
83
|
-
standingAutoApplies,
|
|
84
|
-
standingAutoBinding,
|
|
85
|
-
standingReceiptFor,
|
|
86
|
-
verifyStandingApproval,
|
|
87
|
-
} from "./core/auto-approval";
|
|
88
|
-
export type {
|
|
89
|
-
AutoApproval,
|
|
90
|
-
AutoClass,
|
|
91
|
-
StandingAutoBinding,
|
|
92
|
-
StandingReceipt,
|
|
93
|
-
} from "./core/auto-approval";
|
|
94
75
|
export { bundleHashOfFile, isEphemeralCachePath, sha256Hex } from "./core/runtime-identity";
|
|
95
|
-
export { applicableDecision, reserveAction, settleAction, reconcileAction } from "./core/authority";
|
|
96
|
-
export { normalizeChainSteps, chainStepKey } from "./core/authority";
|
|
97
|
-
export type { ChainStep } from "./core/authority";
|
|
98
|
-
export {
|
|
99
|
-
createAuthorizedExternalActionRunner,
|
|
100
|
-
runAuthorizedExternalAction,
|
|
101
|
-
} from "./core/external-action";
|
|
102
|
-
export type {
|
|
103
|
-
AuthorizedActionInput,
|
|
104
|
-
ExternalActionResult,
|
|
105
|
-
ExternalActionRunner,
|
|
106
|
-
ExternalActionEffect,
|
|
107
|
-
ExternalActionBinding,
|
|
108
|
-
NativeExternalActionObservation,
|
|
109
|
-
ExternalActionRequest,
|
|
110
|
-
ExternalActionOperation,
|
|
111
|
-
} from "./core/external-action";
|
|
112
|
-
export {
|
|
113
|
-
approvedBranchSetupIntent,
|
|
114
|
-
approvedChainStep,
|
|
115
|
-
approvedExternalAction,
|
|
116
|
-
approvedPlanCommit,
|
|
117
|
-
branchSetupIntent,
|
|
118
|
-
chainStepBinding,
|
|
119
|
-
planCommitDescriptor,
|
|
120
|
-
planCommitBinding,
|
|
121
|
-
commitMessageFromDescriptor,
|
|
122
|
-
externalActionState,
|
|
123
|
-
priorExternalAction,
|
|
124
|
-
priorResolvedDrift,
|
|
125
|
-
externalActionRef,
|
|
126
|
-
externalActionDescriptor,
|
|
127
|
-
externalActionHelp,
|
|
128
|
-
externalActionJsonSchema,
|
|
129
|
-
externalActionSchema,
|
|
130
|
-
planReservationLength,
|
|
131
|
-
externalActionRequest,
|
|
132
|
-
matchesNativeExternalAction,
|
|
133
|
-
nativeExternalActionObservation,
|
|
134
|
-
readNativeExternalActionObservation,
|
|
135
|
-
} from "./core/external-action";
|
|
136
|
-
export type {
|
|
137
|
-
ActionReservation,
|
|
138
|
-
NativeAuthorityContext,
|
|
139
|
-
NativeAuthorityVerifier,
|
|
140
|
-
NativeActionVerification,
|
|
141
|
-
NativeReconciliationVerification,
|
|
142
|
-
NativeDecisionVerification,
|
|
143
|
-
ReserveActionInput,
|
|
144
|
-
SettleActionInput,
|
|
145
|
-
ReconcileActionInput,
|
|
146
|
-
} from "./core/authority";
|
|
147
76
|
export {
|
|
148
77
|
captureCandidate,
|
|
149
78
|
checkPin,
|
|
@@ -156,20 +85,14 @@ export {
|
|
|
156
85
|
export type { CandidateEnvironment, ClosureEvaluation } from "./core/task-evaluation";
|
|
157
86
|
export { WorkitCore } from "./core/task-engine";
|
|
158
87
|
export type { OperationContext } from "./core/task-engine";
|
|
159
|
-
export { assertProductWriteAllowed } from "./core/workers";
|
|
160
88
|
export type {
|
|
161
|
-
CallerContext,
|
|
162
89
|
HostSession,
|
|
163
|
-
NativeWorkerDispatchVerification,
|
|
164
90
|
NativeWorkerObservation,
|
|
165
91
|
NativeWorkerVerification,
|
|
166
92
|
NativeWorkerVerifier,
|
|
167
|
-
ProductWriteInput,
|
|
168
|
-
WorkerDispatch,
|
|
169
|
-
WorkerDispatchCommit,
|
|
170
|
-
WorkerDispatchRequest,
|
|
171
|
-
WorkerDispatchStage,
|
|
172
93
|
} from "./core/workers";
|
|
94
|
+
export { contextReadJsonSchema, parseContextRead, readExternalContext } from "./core/context-read";
|
|
95
|
+
export type { ContextReadPayload } from "./core/context-read";
|
|
173
96
|
|
|
174
97
|
export type Result<T> =
|
|
175
98
|
| { ok: true; data: T; error: null }
|