@brainervirus/workit-core 8.13.0 → 8.14.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/package.json +1 -1
- package/scripts/rewrite-workspace-deps.ts +3 -3
- package/skills/workit-fanout/SKILL.md +4 -4
- package/skills/workit-fanout/references/orchestration.md +8 -5
- package/skills/workit-review/SKILL.md +1 -1
- package/src/core/hygiene.ts +37 -1
- package/src/core/live-session.ts +333 -0
- package/src/core/store-lock.ts +9 -1
- package/src/core/workspaces.ts +135 -15
- package/src/fanout-brief.ts +8 -0
- package/src/fanout-status.ts +18 -2
- package/src/forge/pr-ops.ts +84 -18
- package/src/forge/redact.ts +6 -2
- package/src/forge/resolve.ts +15 -0
- package/src/git/ops.ts +20 -0
- package/src/hooks/handle.ts +13 -9
- package/src/hooks/index.ts +2 -1
- package/src/hooks/stop.ts +4 -2
- package/src/hooks/write-gate.ts +303 -3
- package/src/host-session.ts +137 -27
- package/src/ledger.ts +280 -24
- package/src/stack.ts +2 -2
package/src/fanout-brief.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// the integration tip before reporting, and nothing else. It only reads, apart
|
|
18
18
|
// from hiding `.workit-scratch/` in the repository's info/exclude.
|
|
19
19
|
import { createHash } from "node:crypto";
|
|
20
|
+
import { workspaceTierModel } from "./core/workspaces";
|
|
20
21
|
import { activeStanding, readLedger, type LedgerActor } from "./ledger";
|
|
21
22
|
import { branchRef, fanoutFail, planLeads, type FanoutFile, type FanoutResult } from "./fanout";
|
|
22
23
|
import {
|
|
@@ -38,6 +39,8 @@ export type BriefOutcome = {
|
|
|
38
39
|
branch: string;
|
|
39
40
|
base: string;
|
|
40
41
|
tier: string;
|
|
42
|
+
/** The model `fanout.tierModels` names for the tier on the lead's host, else null. */
|
|
43
|
+
model: string | null;
|
|
41
44
|
/** Absolute when the slice's worktree exists, else relative to the worker's worktree root. */
|
|
42
45
|
scratch: string;
|
|
43
46
|
/** The id a worker exports, or null when its host issues or derives it. */
|
|
@@ -171,6 +174,7 @@ export function renderBrief(
|
|
|
171
174
|
what: String(row.what),
|
|
172
175
|
}));
|
|
173
176
|
const identity = identityOf(input.actor.host);
|
|
177
|
+
const model = workspaceTierModel(cwd, slice.tier, input.actor.host);
|
|
174
178
|
const minted = workerSession(sessionSafe(input.actor.session), slice.id, input.attempt);
|
|
175
179
|
const session = identity === "declared" ? minted : null;
|
|
176
180
|
const fanIn =
|
|
@@ -200,6 +204,9 @@ export function renderBrief(
|
|
|
200
204
|
...slice.acceptance.map((line) => ` - ${line}`),
|
|
201
205
|
`VERIFY: ${slice.verify.join("; ")}`,
|
|
202
206
|
`TIER: ${slice.tier}`,
|
|
207
|
+
...(model
|
|
208
|
+
? [` model: ${model.model} (fanout.tierModels in workspace ${model.workspace})`]
|
|
209
|
+
: []),
|
|
203
210
|
`TIMEBOX: ${slice.timebox ?? DEFAULT_TIMEBOX}; past it without a new commit you will be replaced`,
|
|
204
211
|
scratchLine,
|
|
205
212
|
`FORBIDDEN: ${slice.forbidden.join("; ")}`,
|
|
@@ -222,6 +229,7 @@ export function renderBrief(
|
|
|
222
229
|
branch,
|
|
223
230
|
base: slice.base,
|
|
224
231
|
tier: slice.tier,
|
|
232
|
+
model: model?.model ?? null,
|
|
225
233
|
scratch,
|
|
226
234
|
session,
|
|
227
235
|
standing,
|
package/src/fanout-status.ts
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
import { parseDuration } from "./duration";
|
|
19
19
|
import { checksState, gatingChecks, missingRequired, type ChecksState } from "./forge/report";
|
|
20
20
|
import type { ResolvedForge } from "./forge/resolve";
|
|
21
|
-
import {
|
|
21
|
+
import { workspaceTierModel } from "./core/workspaces";
|
|
22
|
+
import { actorFromEnv, checkVerdicts, readLedger, type ReadRow, type VerdictBasis } from "./ledger";
|
|
22
23
|
import { sliceLandings } from "./fanout-check";
|
|
23
24
|
import { forgeBreaker, inSliceRun } from "./fanout-landed";
|
|
24
25
|
import {
|
|
@@ -74,6 +75,8 @@ export type StatusOutcome = {
|
|
|
74
75
|
landingOrder: string[];
|
|
75
76
|
/** Not started; every dependency landed (or, stacked, its parent has an accepted verdict). */
|
|
76
77
|
spawnable: string[];
|
|
78
|
+
/** The model `fanout.tierModels` names for each spawnable slice's TIER on this host. */
|
|
79
|
+
spawnModels: Record<string, string>;
|
|
77
80
|
stuck: string[];
|
|
78
81
|
next: string;
|
|
79
82
|
notes: string[];
|
|
@@ -85,6 +88,8 @@ export type StatusOptions = {
|
|
|
85
88
|
forgeError?: string | null;
|
|
86
89
|
stuckAfterMs?: number | null;
|
|
87
90
|
now?: Date;
|
|
91
|
+
/** The lead's host, for `fanout.tierModels`; default: the environment's (WORKIT_HOST…). */
|
|
92
|
+
host?: string | null;
|
|
88
93
|
};
|
|
89
94
|
|
|
90
95
|
/** A TIMEBOX such as `45 minutes`, `2h` or `1h30m`, in ms; null without a unit or otherwise. */
|
|
@@ -279,6 +284,16 @@ export function fanoutStatus(
|
|
|
279
284
|
if (row.waitsFor.length && !suggested.includes(id))
|
|
280
285
|
row.reasons.push(`waits for ${row.waitsFor.join(", ")}`);
|
|
281
286
|
}
|
|
287
|
+
const host = options.host === undefined ? actorFromEnv(process.env).host : options.host;
|
|
288
|
+
const spawnModels: Record<string, string> = {};
|
|
289
|
+
for (const id of spawnable) {
|
|
290
|
+
const tier = plan.slices.find((slice) => slice.id === id)?.tier;
|
|
291
|
+
const model = tier ? workspaceTierModel(cwd, tier, host) : null;
|
|
292
|
+
if (model) spawnModels[id] = model.model;
|
|
293
|
+
}
|
|
294
|
+
const spawnList = spawnable
|
|
295
|
+
.map((id) => (spawnModels[id] ? `${id} (model ${spawnModels[id]})` : id))
|
|
296
|
+
.join(", ");
|
|
282
297
|
const stuck = order.filter((id) => out.get(id)?.stuck);
|
|
283
298
|
const down = breaker.down();
|
|
284
299
|
if (down)
|
|
@@ -300,7 +315,7 @@ export function fanoutStatus(
|
|
|
300
315
|
: noPr.length
|
|
301
316
|
? `open PRs for ${noPr.join(", ")}: workit git push && workit pr create --fill on each branch`
|
|
302
317
|
: spawnable.length
|
|
303
|
-
? `spawn ${
|
|
318
|
+
? `spawn ${spawnList} (workit fanout worktree create <slice> on hosts without native worktrees)`
|
|
304
319
|
: order.every((id) => out.get(id)?.landed)
|
|
305
320
|
? "every slice has landed"
|
|
306
321
|
: "wait for workers, then workit fanout status again";
|
|
@@ -317,6 +332,7 @@ export function fanoutStatus(
|
|
|
317
332
|
slices: order.map((id) => out.get(id) as SliceStatusRow),
|
|
318
333
|
landingOrder: suggested,
|
|
319
334
|
spawnable,
|
|
335
|
+
spawnModels,
|
|
320
336
|
stuck,
|
|
321
337
|
next,
|
|
322
338
|
notes,
|
package/src/forge/pr-ops.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { currentBranch, fetchRefs, GIT_TIMEOUTS, remoteRefTip, resolveRef } from
|
|
|
35
35
|
import {
|
|
36
36
|
appendObserved,
|
|
37
37
|
checkVerdicts,
|
|
38
|
+
tamperedUnblock,
|
|
38
39
|
readLedger,
|
|
39
40
|
type LedgerActor,
|
|
40
41
|
type VerdictCheck,
|
|
@@ -366,6 +367,8 @@ export type MergeOutcome = {
|
|
|
366
367
|
grant: { source: AutonomySource };
|
|
367
368
|
deletedBranch: boolean | { error: string };
|
|
368
369
|
recorded: { id: string } | { error: string };
|
|
370
|
+
/** The `pr.merge.verified` row confirming `recorded`, or why it was not written. */
|
|
371
|
+
confirmed: { id: string } | { error: string };
|
|
369
372
|
/**
|
|
370
373
|
* Release tracks: when the PR landed on a track's production branch, the
|
|
371
374
|
* branches it must flow back into (mergeBackBranches); empty otherwise.
|
|
@@ -420,7 +423,9 @@ export type MergeRefusal = {
|
|
|
420
423
|
| "already_merged"
|
|
421
424
|
| "closed"
|
|
422
425
|
| "protected_branch"
|
|
423
|
-
| "base_mismatch"
|
|
426
|
+
| "base_mismatch"
|
|
427
|
+
| "ledger_tampered"
|
|
428
|
+
| "intent_unrecorded";
|
|
424
429
|
next?: string;
|
|
425
430
|
blockers?: string[];
|
|
426
431
|
verdict?: Pick<VerdictCheck, "head" | "accepted" | "authors">;
|
|
@@ -434,6 +439,8 @@ export type MergeResult =
|
|
|
434
439
|
error: string;
|
|
435
440
|
unblock?: string;
|
|
436
441
|
refusal?: MergeRefusal;
|
|
442
|
+
/** merge_unverified: the `pr.merged` row (verified:false) written when the forge accepted it. */
|
|
443
|
+
recorded?: { id: string } | { error: string };
|
|
437
444
|
};
|
|
438
445
|
|
|
439
446
|
const refuse = (error: string, unblock: string, refusal: MergeRefusal): MergeResult => ({
|
|
@@ -635,7 +642,7 @@ export async function mergePullRequest(
|
|
|
635
642
|
{ reason: "protected_branch" },
|
|
636
643
|
);
|
|
637
644
|
}
|
|
638
|
-
const ledger = readLedger(cwd);
|
|
645
|
+
const ledger = readLedger(cwd, { integrity: true });
|
|
639
646
|
if (!ledger.ok) return ledger;
|
|
640
647
|
const check = checkVerdicts(cwd, branch, ledger.value.rows);
|
|
641
648
|
const summary = { head: check.head, accepted: check.accepted, authors: check.authors };
|
|
@@ -680,6 +687,12 @@ export async function mergePullRequest(
|
|
|
680
687
|
`git fetch && git switch ${branch} && git merge --ff-only @{u} # then re-verify that head`,
|
|
681
688
|
{ reason: "head_mismatch", verdict: summary },
|
|
682
689
|
);
|
|
690
|
+
else if (!covered && check.accepted.reasons.includes("ledger_tampered"))
|
|
691
|
+
return refuse(
|
|
692
|
+
`ledger_tampered: a ledger row was edited, removed or moved, so no verdict for ${label} is accepted until it is repaired`,
|
|
693
|
+
tamperedUnblock(cwd),
|
|
694
|
+
{ reason: "ledger_tampered", verdict: summary },
|
|
695
|
+
);
|
|
683
696
|
else if (!covered)
|
|
684
697
|
return refuse(
|
|
685
698
|
`NEEDS_VERDICT: ${label} has no accepted independent verdict for ${head.slice(0, 12)} (${check.accepted.reasons.join(", ")})`,
|
|
@@ -689,12 +702,57 @@ export async function mergePullRequest(
|
|
|
689
702
|
|
|
690
703
|
const squashMessage = squashMessageFor(cwd, resolved, input, doc);
|
|
691
704
|
if (squashMessage && !squashMessage.ok) return squashMessage;
|
|
705
|
+
// The intent goes in before the forge write: a merge call that errors after
|
|
706
|
+
// the forge did merge still leaves a record `pr status` reads. A merge that
|
|
707
|
+
// cannot be recorded never starts.
|
|
708
|
+
const intent = appendObserved(cwd, {
|
|
709
|
+
type: "pr.merge.intent",
|
|
710
|
+
actor: input.actor,
|
|
711
|
+
branch,
|
|
712
|
+
head,
|
|
713
|
+
pr: doc.number,
|
|
714
|
+
base: doc.base,
|
|
715
|
+
method: input.method,
|
|
716
|
+
verdictId: verdict.verdictId,
|
|
717
|
+
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
718
|
+
grant: grant.source,
|
|
719
|
+
});
|
|
720
|
+
if (!intent.ok)
|
|
721
|
+
return refuse(
|
|
722
|
+
`intent_unrecorded: the merge intent for ${label} could not be recorded (${intent.error}); nothing was merged`,
|
|
723
|
+
"fix the ledger (workit ledger check), then merge again",
|
|
724
|
+
{ reason: "intent_unrecorded" },
|
|
725
|
+
);
|
|
726
|
+
const intentId = String(intent.value.id);
|
|
692
727
|
const merged = resolved.forge.merge(doc.number, {
|
|
693
728
|
sha: head,
|
|
694
729
|
method: input.method,
|
|
695
730
|
...(squashMessage ? { squashMessage: squashMessage.data } : {}),
|
|
696
731
|
});
|
|
697
|
-
if (!merged.ok)
|
|
732
|
+
if (!merged.ok)
|
|
733
|
+
return {
|
|
734
|
+
...merged,
|
|
735
|
+
error: `${merged.error} (merge intent ${intentId} is recorded: if the forge merged anyway, workit pr status reads it)`,
|
|
736
|
+
};
|
|
737
|
+
// Recorded as soon as the forge accepts it (N3): a failed read below must
|
|
738
|
+
// not leave a merge that `pr status`, the stop hook and `verify-delivery
|
|
739
|
+
// merged` cannot see. The post-verify then confirms it in its own row.
|
|
740
|
+
const row = appendObserved(cwd, {
|
|
741
|
+
type: "pr.merged",
|
|
742
|
+
actor: input.actor,
|
|
743
|
+
branch,
|
|
744
|
+
head,
|
|
745
|
+
pr: doc.number,
|
|
746
|
+
base: doc.base,
|
|
747
|
+
mergeSha: merged.data.mergeSha,
|
|
748
|
+
method: input.method,
|
|
749
|
+
verdictId: verdict.verdictId,
|
|
750
|
+
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
751
|
+
grant: grant.source,
|
|
752
|
+
intent: intentId,
|
|
753
|
+
verified: false,
|
|
754
|
+
});
|
|
755
|
+
const recorded = row.ok ? { id: String(row.value.id) } : { error: row.error };
|
|
698
756
|
// Post-verify: the forge must report the PR merged.
|
|
699
757
|
let state = "open";
|
|
700
758
|
for (const delay of [0, ...VERIFY_DELAYS_MS]) {
|
|
@@ -707,9 +765,29 @@ export async function mergePullRequest(
|
|
|
707
765
|
return {
|
|
708
766
|
ok: false,
|
|
709
767
|
code: "failed",
|
|
710
|
-
error: `merge_unverified: the forge accepted the merge of ${label} but still reports it ${state}`,
|
|
768
|
+
error: `merge_unverified: the forge accepted the merge of ${label} but still reports it ${state}${"id" in recorded ? `; recorded as pr.merged ${recorded.id} (verified: false)` : `; the pr.merged row could not be written (${recorded.error})`}`,
|
|
711
769
|
unblock: `workit verify-delivery merged --pr ${doc.number}`,
|
|
770
|
+
recorded,
|
|
712
771
|
};
|
|
772
|
+
const confirmation =
|
|
773
|
+
"id" in recorded
|
|
774
|
+
? appendObserved(cwd, {
|
|
775
|
+
type: "pr.merge.verified",
|
|
776
|
+
actor: input.actor,
|
|
777
|
+
branch,
|
|
778
|
+
head,
|
|
779
|
+
pr: doc.number,
|
|
780
|
+
base: doc.base,
|
|
781
|
+
merged: recorded.id,
|
|
782
|
+
mergeSha: merged.data.mergeSha,
|
|
783
|
+
})
|
|
784
|
+
: null;
|
|
785
|
+
const confirmed: MergeOutcome["confirmed"] =
|
|
786
|
+
confirmation === null
|
|
787
|
+
? { error: "no pr.merged row to confirm" }
|
|
788
|
+
: confirmation.ok
|
|
789
|
+
? { id: String(confirmation.value.id) }
|
|
790
|
+
: { error: confirmation.error };
|
|
713
791
|
|
|
714
792
|
let deletedBranch: MergeOutcome["deletedBranch"] = false;
|
|
715
793
|
if (input.deleteBranch) {
|
|
@@ -720,19 +798,6 @@ export async function mergePullRequest(
|
|
|
720
798
|
deleted.ok || (gone && gone.ok && gone.sha === null) ? true : { error: deleted.error };
|
|
721
799
|
}
|
|
722
800
|
const mergeBack = mergeBackFor(cwd, doc.base, branch);
|
|
723
|
-
const row = appendObserved(cwd, {
|
|
724
|
-
type: "pr.merged",
|
|
725
|
-
actor: input.actor,
|
|
726
|
-
branch,
|
|
727
|
-
head,
|
|
728
|
-
pr: doc.number,
|
|
729
|
-
base: doc.base,
|
|
730
|
-
mergeSha: merged.data.mergeSha,
|
|
731
|
-
method: input.method,
|
|
732
|
-
verdictId: verdict.verdictId,
|
|
733
|
-
...(unverified ? { unverified: unverified.recorded } : {}),
|
|
734
|
-
grant: grant.source,
|
|
735
|
-
});
|
|
736
801
|
return {
|
|
737
802
|
ok: true,
|
|
738
803
|
data: {
|
|
@@ -747,7 +812,8 @@ export async function mergePullRequest(
|
|
|
747
812
|
unverified,
|
|
748
813
|
grant: { source: grant.source },
|
|
749
814
|
deletedBranch,
|
|
750
|
-
recorded
|
|
815
|
+
recorded,
|
|
816
|
+
confirmed,
|
|
751
817
|
mergeBack: mergeBack.branches,
|
|
752
818
|
mergeBackTrack: mergeBack.track,
|
|
753
819
|
},
|
package/src/forge/redact.ts
CHANGED
|
@@ -8,8 +8,12 @@ const R = "[REDACTED]";
|
|
|
8
8
|
const TOKEN_PATTERNS: readonly RegExp[] = [
|
|
9
9
|
// GitHub tokens (classic, fine-grained, app, OAuth, refresh).
|
|
10
10
|
/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/gu,
|
|
11
|
-
// GitLab tokens
|
|
12
|
-
|
|
11
|
+
// GitLab tokens, by every prefix GitLab documents
|
|
12
|
+
// (https://docs.gitlab.com/security/tokens/#token-prefixes) and only those:
|
|
13
|
+
// a bare `gl…-` also matches words such as `global-setup-teardown-hook`.
|
|
14
|
+
/\b(?:glpat|gloas|gldt|glrtr|glrt|glcbt|glptt|glft|glimt|glagent|glwt|glsoat|glffct)-[A-Za-z0-9_-]{20,}/gu,
|
|
15
|
+
// GitLab's session cookie.
|
|
16
|
+
/\b_gitlab_session=[A-Za-z0-9%_-]{16,}/gu,
|
|
13
17
|
/\bnpm_[A-Za-z0-9]{30,}/gu,
|
|
14
18
|
/\bxox[abposr]-[A-Za-z0-9-]{10,}/gu,
|
|
15
19
|
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu,
|
package/src/forge/resolve.ts
CHANGED
|
@@ -181,6 +181,21 @@ function resolveCredential(
|
|
|
181
181
|
`token_host_unverified: push host ${derived.host} only looks like ${kind}; workspace ${workspace?.name ?? "?"} vcs.tokenFile is not sent to it`,
|
|
182
182
|
`if ${apiHost} is your ${kind} server, set ${kind}.host in ~/.config/workit/vcs.json (or vcs.host for the workspace); otherwise fix the push remote`,
|
|
183
183
|
);
|
|
184
|
+
// Like youtrack.token: a token other users can read is refused (N14).
|
|
185
|
+
if (process.platform !== "win32") {
|
|
186
|
+
let mode = 0;
|
|
187
|
+
try {
|
|
188
|
+
mode = fs.statSync(tokenFile.trim()).mode;
|
|
189
|
+
} catch {
|
|
190
|
+
// unreadable: reported below
|
|
191
|
+
}
|
|
192
|
+
if ((mode & 0o077) !== 0)
|
|
193
|
+
return failure(
|
|
194
|
+
"blocked",
|
|
195
|
+
`workspace ${workspace?.name ?? "?"} vcs.tokenFile is readable by group or other (mode ${(mode & 0o777).toString(8).padStart(4, "0")})`,
|
|
196
|
+
`chmod 600 ${tokenFile.trim()}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
184
199
|
let token = "";
|
|
185
200
|
try {
|
|
186
201
|
token = fs.readFileSync(tokenFile.trim(), "utf8").trim();
|
package/src/git/ops.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
} from "../core/workspaces";
|
|
50
50
|
import { redactText } from "../forge/redact";
|
|
51
51
|
import { failure, success, type ForgeResult } from "../forge/types";
|
|
52
|
+
import { issuedBinding, sessionClaimRefusal } from "../host-session";
|
|
52
53
|
import { appendObserved, readLedger, type LedgerActor } from "../ledger";
|
|
53
54
|
import {
|
|
54
55
|
GIT_TIMEOUTS,
|
|
@@ -649,6 +650,25 @@ export function gitCommit(
|
|
|
649
650
|
"invalid_input",
|
|
650
651
|
"WORKIT_SESSION_ID must be 1-128 characters of [A-Za-z0-9_.:@/+-]",
|
|
651
652
|
);
|
|
653
|
+
// A commit names its author session (commit.recorded, the trailer): the
|
|
654
|
+
// same claim check as a verdict, and an id issued to a verifier or
|
|
655
|
+
// reviewer never authors code (audit mv83zyg6 P2b).
|
|
656
|
+
if (session) {
|
|
657
|
+
const env = input.env ?? process.env;
|
|
658
|
+
if (issuedBinding(cwd, session) !== null)
|
|
659
|
+
return failure(
|
|
660
|
+
"blocked",
|
|
661
|
+
`judge_session: ${session} was issued to a verifier or reviewer subagent; it records verdicts and never authors commits`,
|
|
662
|
+
"commit from the implementer's or the lead's own session (unset WORKIT_SESSION_ID back to the shell's)",
|
|
663
|
+
);
|
|
664
|
+
const refusal = sessionClaimRefusal(env, session, cwd);
|
|
665
|
+
if (refusal)
|
|
666
|
+
return failure(
|
|
667
|
+
"blocked",
|
|
668
|
+
refusal.error,
|
|
669
|
+
"commit from the session this shell really is: never set WORKIT_SESSION_ID by hand for a commit (unset it, or use the id the host put in this shell)",
|
|
670
|
+
);
|
|
671
|
+
}
|
|
652
672
|
// A commit that only rewords (amend) or is empty on purpose needs no change.
|
|
653
673
|
const changeRequired = !input.amend && !input.allowEmpty;
|
|
654
674
|
|
package/src/hooks/handle.ts
CHANGED
|
@@ -26,7 +26,8 @@ import {
|
|
|
26
26
|
withContextLine,
|
|
27
27
|
} from "./skill-nudge";
|
|
28
28
|
import { stopDecision } from "./stop";
|
|
29
|
-
import {
|
|
29
|
+
import { liveOwner } from "../core/live-session";
|
|
30
|
+
import { shellGate, writeGate } from "./write-gate";
|
|
30
31
|
import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
|
|
31
32
|
|
|
32
33
|
export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
|
|
@@ -108,10 +109,13 @@ const subagentStartText = (
|
|
|
108
109
|
const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
|
|
109
110
|
if (role !== "judge" || !event.agentId) return readOnly;
|
|
110
111
|
const own = subagentSession(session, event.agentId);
|
|
111
|
-
// `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it
|
|
112
|
+
// `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it,
|
|
113
|
+
// and only with the token it hands to this subagent alone.
|
|
112
114
|
// (An isolated judge's text is rebuilt with cwd "": the hook's own cwd then.)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
// Issued once: a replayed SubagentStart for the same id gets no token.
|
|
116
|
+
const token = session ? recordIssuedSession(input.cwd || process.cwd(), session, own) : null;
|
|
117
|
+
const flags = token ? `--session ${own} --token ${token}` : `--session ${own}`;
|
|
118
|
+
return `${readOnly} Its own Workit session is ${own}: record verdicts with \`workit ledger verdict <result> ${flags} ...\` so the ledger tells it apart from the author.${token ? " The token is this subagent's alone: never write it into a report, a file or a commit. --token needs a workit CLI at least as new as this plugin; if the CLI answers that --token is an unknown option, update the workit CLI (workit upgrade --cli) and record again." : ""}`;
|
|
115
119
|
};
|
|
116
120
|
|
|
117
121
|
export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
@@ -153,10 +157,10 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
153
157
|
if (!postTool) settlePendingCommit(input);
|
|
154
158
|
const raw = canDeny ? rawGitPre(input, event.command, { nudge: false }) : NONE;
|
|
155
159
|
if (raw.kind === "deny") return raw;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
// The before-write gate and the live-session guard (#274).
|
|
161
|
+
const gate = usable(descriptor.events["write.pre"].support)
|
|
162
|
+
? shellGate(input.cwd, event.command, liveOwner(input, descriptor.transport), event.dialect)
|
|
163
|
+
: NONE;
|
|
160
164
|
if (gate.kind === "deny") return gate;
|
|
161
165
|
// The command will run: note HEAD so the post-tool hook (or the next
|
|
162
166
|
// command, without one) can tell whether a raw commit moved it.
|
|
@@ -179,7 +183,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
179
183
|
return NONE;
|
|
180
184
|
case "write.pre":
|
|
181
185
|
return usable(descriptor.events["write.pre"].support)
|
|
182
|
-
? writeGate(input.cwd, event.paths)
|
|
186
|
+
? writeGate(input.cwd, event.paths, liveOwner(input, descriptor.transport))
|
|
183
187
|
: NONE;
|
|
184
188
|
case "subagent.start":
|
|
185
189
|
return {
|
package/src/hooks/index.ts
CHANGED
|
@@ -19,7 +19,8 @@ export {
|
|
|
19
19
|
type SessionHandle,
|
|
20
20
|
} from "./context";
|
|
21
21
|
export { shellPolicy } from "./policy";
|
|
22
|
-
export { shellWrites, writeGate } from "./write-gate";
|
|
22
|
+
export { shellGate, shellWrites, writeGate } from "./write-gate";
|
|
23
|
+
export { liveOwner } from "../core/live-session";
|
|
23
24
|
export { asksUser, stopDecision, stopObligation, type StopObligation } from "./stop";
|
|
24
25
|
export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
|
|
25
26
|
export { promptNudge, recordSkillLoad, shellNudge, skillFileIn } from "./skill-nudge";
|
package/src/hooks/stop.ts
CHANGED
|
@@ -253,9 +253,11 @@ export function stopObligation(
|
|
|
253
253
|
if (!session || input.session.agentId || !inWorkitWorkspace(input.cwd)) return null;
|
|
254
254
|
const branch = currentBranch(input.cwd);
|
|
255
255
|
if (!branch || guardedBranch(input.cwd, branch)) return null;
|
|
256
|
-
|
|
256
|
+
// Read with the hash chain: a hand-appended row neither creates nor
|
|
257
|
+
// clears an obligation, and verdicts are judged with integrity (N1).
|
|
258
|
+
const ledger = readLedger(input.cwd, { integrity: true });
|
|
257
259
|
if (!ledger.ok) return null;
|
|
258
|
-
const all = ledger.value.rows;
|
|
260
|
+
const all = ledger.value.rows.filter((row) => row.integrity !== "unverified");
|
|
259
261
|
const rows = all.filter((row) => row.branch === branch);
|
|
260
262
|
const mine = rows.filter((row) => sessionOf(row) === session);
|
|
261
263
|
const commits = new Set(
|