@brainervirus/workit-core 1.0.9 → 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/skills/workit-plan/SKILL.md +14 -2
- package/src/core/authority.ts +21 -2
- package/src/core/branch.ts +45 -14
- package/src/core/external-action-effects.ts +205 -9
- package/src/core/external-action.ts +338 -11
- package/src/core/methods.ts +13 -2
- package/src/core/policy-resolver.ts +2 -2
- package/src/core/route-intent.ts +28 -0
- package/src/core/task-context.ts +2 -5
- package/src/core/task-contract.ts +58 -3
- package/src/core/task-engine.ts +60 -45
- package/src/core/task-evaluation.ts +36 -7
- package/src/core/task-store.ts +97 -17
- package/src/core/workers.ts +6 -0
- package/src/core.ts +11 -2
|
@@ -7,12 +7,15 @@ import {
|
|
|
7
7
|
type Result,
|
|
8
8
|
type Revision,
|
|
9
9
|
type Decision,
|
|
10
|
+
type Entry,
|
|
11
|
+
type TaskRecord,
|
|
10
12
|
refSchema,
|
|
11
13
|
sha256,
|
|
12
14
|
} from "./task-contract";
|
|
13
15
|
import { WorkitCore } from "./task-engine";
|
|
14
16
|
import { TaskStore } from "./task-store";
|
|
15
17
|
import * as z from "zod";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
16
19
|
|
|
17
20
|
export type AuthorizedActionInput = {
|
|
18
21
|
core: WorkitCore;
|
|
@@ -53,7 +56,13 @@ const externalActionSchema = z.discriminatedUnion("operation", [
|
|
|
53
56
|
z
|
|
54
57
|
.object({
|
|
55
58
|
operation: z.literal("git.commit"),
|
|
56
|
-
payload: z
|
|
59
|
+
payload: z
|
|
60
|
+
.object({
|
|
61
|
+
message: z.string().min(1).optional(),
|
|
62
|
+
plan_steps: z.array(z.string().min(1)).optional(),
|
|
63
|
+
plan_branch: z.string().min(1).optional(),
|
|
64
|
+
})
|
|
65
|
+
.strict(),
|
|
57
66
|
})
|
|
58
67
|
.strict(),
|
|
59
68
|
z
|
|
@@ -206,7 +215,7 @@ export const externalActionDescriptor = (operation: string, payload: unknown): s
|
|
|
206
215
|
|
|
207
216
|
/** Compact host-facing help for the fixed optional-action surface. */
|
|
208
217
|
export const externalActionHelp =
|
|
209
|
-
"Fixed actions: git.branch_setup {action?,sdd_dir?,target_branch
|
|
218
|
+
"Fixed actions: git.branch_setup {action?,sdd_dir?,target_branch(required unless reapply_stash; the working branch to create or switch to),stash?}; git.commit {message}; git.push {branch?}; hosting.pull_request {title,body?,draft?,target_branch?,babysit?}; youtrack.update {issueId,markdown,minutes?}; youtrack.time {issueId,minutes,text?,dateMs?}; youtrack.meeting {issueId,minutes,text}; changelog.apply {entries?,path?,normalize_only?}; context.read {kind,range?,issueId?,issueUrl?,issueRef?,mode?,specPath?,planPath?}. context.read is read-only and needs no approval; all other operations require native approval (CLI uses a TTY; caller-unattested MCP cannot mutate).";
|
|
210
219
|
|
|
211
220
|
export const externalActionRef = (
|
|
212
221
|
host: "opencode" | "pi" | "workit_cli",
|
|
@@ -311,6 +320,151 @@ export const priorExternalAction = (
|
|
|
311
320
|
return success(workspace.data.revision, null, { ...candidates[0], workspace: workspace.data });
|
|
312
321
|
};
|
|
313
322
|
|
|
323
|
+
/** Parse a plan-commit authorization descriptor (one approved list per plan). */
|
|
324
|
+
export const planCommitDescriptor = (
|
|
325
|
+
content: string,
|
|
326
|
+
): { steps: string[]; branch: string } | null => {
|
|
327
|
+
try {
|
|
328
|
+
const value = JSON.parse(content) as {
|
|
329
|
+
operation?: unknown;
|
|
330
|
+
payload?: { plan_steps?: unknown; plan_branch?: unknown };
|
|
331
|
+
};
|
|
332
|
+
if (value.operation !== "git.commit" || !value.payload) return null;
|
|
333
|
+
const steps = value.payload.plan_steps;
|
|
334
|
+
const branch = value.payload.plan_branch;
|
|
335
|
+
if (
|
|
336
|
+
!Array.isArray(steps) ||
|
|
337
|
+
steps.some((step) => typeof step !== "string" || !step) ||
|
|
338
|
+
typeof branch !== "string" ||
|
|
339
|
+
!branch
|
|
340
|
+
)
|
|
341
|
+
return null;
|
|
342
|
+
return { steps: steps as string[], branch };
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
export type PlanCommitAuthorization = {
|
|
349
|
+
task: TaskRecord;
|
|
350
|
+
entry: Entry<Decision>;
|
|
351
|
+
steps: string[];
|
|
352
|
+
branch: string;
|
|
353
|
+
completed: string[];
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Find the single active plan-commit authorization whose next unconsumed step
|
|
358
|
+
* is this exact commit message. Consumption order is enforced so each listed
|
|
359
|
+
* commit executes once, in plan order; unlisted or replayed messages find no
|
|
360
|
+
* authorization and require a fresh exact approval.
|
|
361
|
+
*/
|
|
362
|
+
export const approvedPlanCommit = (
|
|
363
|
+
store: TaskStore,
|
|
364
|
+
host: string,
|
|
365
|
+
actor: string,
|
|
366
|
+
message: string,
|
|
367
|
+
): Result<PlanCommitAuthorization> => {
|
|
368
|
+
if (typeof message !== "string" || !message)
|
|
369
|
+
return failure("invalid_input", "commit message is required");
|
|
370
|
+
const listed = store.listTasks();
|
|
371
|
+
const workspace = store.readWorkspace();
|
|
372
|
+
if (!listed.ok || !workspace.ok)
|
|
373
|
+
return failure("storage_error", "external action state is unavailable");
|
|
374
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
375
|
+
const matches = listed.data.flatMap((task) => {
|
|
376
|
+
if (task.status !== "active" || task.workspaceId !== workspace.data!.id) return [];
|
|
377
|
+
if (
|
|
378
|
+
task.intent.provenance.session?.kind !== "host" ||
|
|
379
|
+
task.intent.provenance.session.host !== host ||
|
|
380
|
+
task.intent.provenance.session.handle !== actor
|
|
381
|
+
)
|
|
382
|
+
return [];
|
|
383
|
+
return task.decisions.flatMap((entry) => {
|
|
384
|
+
const decision = entry.data;
|
|
385
|
+
if (
|
|
386
|
+
decision.purpose !== "action" ||
|
|
387
|
+
decision.response !== "approved" ||
|
|
388
|
+
decision.revoked !== null
|
|
389
|
+
)
|
|
390
|
+
return [];
|
|
391
|
+
if (entry.provenance.kind !== "host_observed" || entry.provenance.receipts.length === 0)
|
|
392
|
+
return [];
|
|
393
|
+
const plan = planCommitDescriptor(decision.binding.approvedContent);
|
|
394
|
+
if (!plan) return [];
|
|
395
|
+
const completed =
|
|
396
|
+
task.actionProgress?.find((progress) => progress.decisionId === entry.id)?.completedSteps ??
|
|
397
|
+
[];
|
|
398
|
+
if (plan.steps[completed.length] !== message) return [];
|
|
399
|
+
return [{ task, entry, steps: plan.steps, branch: plan.branch, completed: [...completed] }];
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
if (matches.length !== 1)
|
|
403
|
+
return failure(
|
|
404
|
+
"permission_denied",
|
|
405
|
+
matches.length === 0
|
|
406
|
+
? "no plan commit authorization matches this message"
|
|
407
|
+
: "multiple plan authorizations match this message",
|
|
408
|
+
);
|
|
409
|
+
return success(workspace.data.revision, null, matches[0]);
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
export const commitMessageFromDescriptor = (operation: string): string | undefined => {
|
|
413
|
+
try {
|
|
414
|
+
const descriptor = JSON.parse(operation) as {
|
|
415
|
+
operation?: unknown;
|
|
416
|
+
payload?: { message?: unknown };
|
|
417
|
+
};
|
|
418
|
+
return descriptor.operation === "git.commit" && typeof descriptor.payload?.message === "string"
|
|
419
|
+
? descriptor.payload.message
|
|
420
|
+
: undefined;
|
|
421
|
+
} catch {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
/** Resolve a plan-commit authorization into the exact binding an adapter needs
|
|
427
|
+
* to run one listed commit under the approved list. */
|
|
428
|
+
export const planCommitBinding = (
|
|
429
|
+
store: TaskStore,
|
|
430
|
+
host: string,
|
|
431
|
+
actor: string,
|
|
432
|
+
operation: string,
|
|
433
|
+
): {
|
|
434
|
+
taskId: Id;
|
|
435
|
+
decisionId: Id;
|
|
436
|
+
binding: Decision["binding"];
|
|
437
|
+
expectedRevision: Revision;
|
|
438
|
+
expectedWorkspaceRevision: Revision;
|
|
439
|
+
step: string;
|
|
440
|
+
} | null => {
|
|
441
|
+
const message = commitMessageFromDescriptor(operation);
|
|
442
|
+
if (!message) return null;
|
|
443
|
+
const plan = approvedPlanCommit(store, host, actor, message);
|
|
444
|
+
if (!plan.ok) return null;
|
|
445
|
+
let branch: string;
|
|
446
|
+
try {
|
|
447
|
+
branch = execFileSync("git", ["branch", "--show-current"], {
|
|
448
|
+
cwd: store.root,
|
|
449
|
+
encoding: "utf8",
|
|
450
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
451
|
+
}).trim();
|
|
452
|
+
} catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
if (!branch || branch !== plan.data.branch) return null;
|
|
456
|
+
const workspace = store.readWorkspace();
|
|
457
|
+
if (!workspace.ok || !workspace.data) return null;
|
|
458
|
+
return {
|
|
459
|
+
taskId: plan.data.task.id,
|
|
460
|
+
decisionId: plan.data.entry.id,
|
|
461
|
+
binding: plan.data.entry.data.binding,
|
|
462
|
+
expectedRevision: plan.data.task.revision,
|
|
463
|
+
expectedWorkspaceRevision: workspace.data.revision,
|
|
464
|
+
step: message,
|
|
465
|
+
};
|
|
466
|
+
};
|
|
467
|
+
|
|
314
468
|
export const approvedExternalAction = (
|
|
315
469
|
store: TaskStore,
|
|
316
470
|
host: string,
|
|
@@ -318,17 +472,190 @@ export const approvedExternalAction = (
|
|
|
318
472
|
operation: string,
|
|
319
473
|
) => {
|
|
320
474
|
const selected = externalActionState(store, host, actor, operation);
|
|
321
|
-
if (
|
|
322
|
-
|
|
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)
|
|
323
584
|
return failure(
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
? "previous external action outcome is unknown"
|
|
329
|
-
: "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",
|
|
330
589
|
);
|
|
331
|
-
return
|
|
590
|
+
return success(workspace.data.revision, null, { ...matches[0], workspace: workspace.data });
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Re-resolve guard: when a prior approval exists for the same requested
|
|
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.
|
|
600
|
+
*/
|
|
601
|
+
export const priorResolvedDrift = (
|
|
602
|
+
store: TaskStore,
|
|
603
|
+
host: string,
|
|
604
|
+
actor: string,
|
|
605
|
+
operation: string,
|
|
606
|
+
payload: unknown,
|
|
607
|
+
currentResolved: unknown,
|
|
608
|
+
): Result<null> => {
|
|
609
|
+
const prior = priorExternalAction(store, host, actor, operation, payload);
|
|
610
|
+
if (!prior.ok) return success(null, null, null);
|
|
611
|
+
let baseline: unknown;
|
|
612
|
+
try {
|
|
613
|
+
const parsed = JSON.parse(prior.data.entry.data.binding.approvedContent) as {
|
|
614
|
+
payload?: { resolved?: unknown };
|
|
615
|
+
};
|
|
616
|
+
baseline = parsed.payload?.resolved;
|
|
617
|
+
} catch {
|
|
618
|
+
return success(null, null, null);
|
|
619
|
+
}
|
|
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
|
+
}
|
|
653
|
+
if (canonicalJson(baseline) === canonicalJson(currentResolved)) return success(null, null, null);
|
|
654
|
+
return failure(
|
|
655
|
+
"invalid_input",
|
|
656
|
+
"repository state changed since approval; show the current state and approve the action again",
|
|
657
|
+
{ outcome: "not_started", operation },
|
|
658
|
+
);
|
|
332
659
|
};
|
|
333
660
|
|
|
334
661
|
export const createAuthorizedExternalActionRunner =
|
package/src/core/methods.ts
CHANGED
|
@@ -18,7 +18,7 @@ type MethodDefinition = {
|
|
|
18
18
|
export const METHODS: Readonly<Record<MethodId, MethodDefinition>> = {
|
|
19
19
|
"workit-challenge": { dimensions: ["challenge", "decisions"] },
|
|
20
20
|
"workit-behavioral-tdd": { dimensions: ["testing"] },
|
|
21
|
-
"workit-review": { dimensions: ["review"] },
|
|
21
|
+
"workit-review": { dimensions: ["review"], ruleIds: ["fresh-context-review"] },
|
|
22
22
|
"workit-plan": { dimensions: ["artifacts", "continuity"] },
|
|
23
23
|
"workit-implement": { dimensions: ["delegation"] },
|
|
24
24
|
"workit-debug": { ruleIds: ["root-cause-investigation"] },
|
|
@@ -107,7 +107,18 @@ decision.record {binding with taskId/workspaceId from inspect plus presented and
|
|
|
107
107
|
approvedContent} only through a native approval question asked receipt-shaped:
|
|
108
108
|
header Workit decision: <purpose>, the same label repeated in the question
|
|
109
109
|
text (some host UIs do not render headers), exactly two options approved and
|
|
110
|
-
rejected; ask once, never re-ask to mint a receipt.
|
|
110
|
+
rejected; ask once, never re-ask to mint a receipt. Before asking, present the
|
|
111
|
+
item in the conversation (durable artifacts: complete digest plus exact path;
|
|
112
|
+
inline plans: the content) and keep the question to one short scoped sentence;
|
|
113
|
+
never ask to approve something unseen. An answer with custom text is steering,
|
|
114
|
+
not approval: adjust, re-present, then ask a new scoped question. After the user
|
|
115
|
+
approves a spec or plan, execute the remaining tasks continuously with one
|
|
116
|
+
atomic commit per task and do not ask whether to continue; stop only for a new
|
|
117
|
+
product decision, a failed safety or verification gate, a conflicting edit, or
|
|
118
|
+
missing authority. Record the plan's commit list once through the plan-scoped
|
|
119
|
+
action approval (git.commit with plan_steps and plan_branch) so each listed
|
|
120
|
+
commit executes without a new question; an unlisted message needs a fresh
|
|
121
|
+
approval. task.close {outcome,
|
|
111
122
|
summary, decisionIds}. A completed native subagent run stops its bound worker
|
|
112
123
|
by itself; if a worker strands in cancelling with its run verifiably over,
|
|
113
124
|
repeat worker.cancel {reason} to confirm the stop. Check and review evidence
|
|
@@ -306,7 +306,7 @@ function resolveRequirements(input: NormalizedResolverInput): Requirement[] {
|
|
|
306
306
|
satisfaction:
|
|
307
307
|
"An observed or inferred fact resolves the uncertainty and records its supporting references.",
|
|
308
308
|
before: "dependent_action",
|
|
309
|
-
dependentAction:
|
|
309
|
+
dependentAction: null,
|
|
310
310
|
acceptanceAllowed: false,
|
|
311
311
|
}),
|
|
312
312
|
);
|
|
@@ -321,7 +321,7 @@ function resolveRequirements(input: NormalizedResolverInput): Requirement[] {
|
|
|
321
321
|
satisfaction:
|
|
322
322
|
"Present the material alternatives and record the user's decision before the dependent action.",
|
|
323
323
|
before: "dependent_action",
|
|
324
|
-
dependentAction:
|
|
324
|
+
dependentAction: null,
|
|
325
325
|
acceptanceAllowed: false,
|
|
326
326
|
}),
|
|
327
327
|
);
|
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
|
+
}
|
package/src/core/task-context.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUncertainWorker } from "./workers";
|
|
1
2
|
import {
|
|
2
3
|
POLICY_VERSION,
|
|
3
4
|
failure,
|
|
@@ -42,11 +43,7 @@ export function reconcileResume(
|
|
|
42
43
|
.filter((entry) => entry.status === "stale")
|
|
43
44
|
.map((entry) => entry.evidenceId);
|
|
44
45
|
const blockers = [...view.task.progress.blockers];
|
|
45
|
-
if (
|
|
46
|
-
view.task.workers.some((entry) =>
|
|
47
|
-
["dispatching", "running", "cancelling", "unknown"].includes(entry.data.state),
|
|
48
|
-
)
|
|
49
|
-
)
|
|
46
|
+
if (view.task.workers.some((entry) => isUncertainWorker(entry.data.state)))
|
|
50
47
|
blockers.push({
|
|
51
48
|
reason: "worker state requires reconciliation",
|
|
52
49
|
dependentAction: "resume",
|
|
@@ -453,11 +453,13 @@ export const decisionSchema = z
|
|
|
453
453
|
scope: scopeSchema,
|
|
454
454
|
presented: text,
|
|
455
455
|
approvedContent: text,
|
|
456
|
+
displayed: text.optional(),
|
|
456
457
|
contentRefs: z.array(refSchema),
|
|
458
|
+
statedChoice: z.object({ ref: text, text: text }).strict().optional(),
|
|
457
459
|
})
|
|
458
460
|
.strict(),
|
|
459
461
|
digest,
|
|
460
|
-
response: z.enum(["approved", "rejected"]),
|
|
462
|
+
response: z.enum(["approved", "rejected", "stated"]),
|
|
461
463
|
requirementIds: z.array(digest),
|
|
462
464
|
revoked: z.object({ at: utc, reason: text }).strict().nullable(),
|
|
463
465
|
consumption: z
|
|
@@ -471,6 +473,29 @@ export const decisionSchema = z
|
|
|
471
473
|
})
|
|
472
474
|
.strict();
|
|
473
475
|
export type Decision = z.infer<typeof decisionSchema>;
|
|
476
|
+
|
|
477
|
+
/** Show-before-ask guard: binding questions are single-sentence and never
|
|
478
|
+
* carry the artifact, descriptor, or payload body in the question UI. */
|
|
479
|
+
export const BINDING_QUESTION_BUDGET = 300;
|
|
480
|
+
const bindingQuestionHeader = /^Workit decision: (design|action|limitation|preference)$/;
|
|
481
|
+
export const workitBindingQuestionIssue = (questions: unknown): string | null => {
|
|
482
|
+
if (!Array.isArray(questions) || questions.length !== 1) return null;
|
|
483
|
+
const question = questions[0] as { header?: unknown; question?: unknown; options?: unknown };
|
|
484
|
+
if (!question || typeof question !== "object") return null;
|
|
485
|
+
if (typeof question.header !== "string" || !bindingQuestionHeader.test(question.header.trim()))
|
|
486
|
+
return null;
|
|
487
|
+
const over = (label: string, value: string) =>
|
|
488
|
+
`invalid_input: ${label} is ${value.length} characters; Workit binding questions must stay within ${BINDING_QUESTION_BUDGET}. present the item in the conversation first, then ask a short scoped question.`;
|
|
489
|
+
if (typeof question.question === "string" && question.question.length > BINDING_QUESTION_BUDGET)
|
|
490
|
+
return over("the question text", question.question);
|
|
491
|
+
if (Array.isArray(question.options))
|
|
492
|
+
for (const option of question.options) {
|
|
493
|
+
const description = (option as { description?: unknown }).description;
|
|
494
|
+
if (typeof description === "string" && description.length > BINDING_QUESTION_BUDGET)
|
|
495
|
+
return over("an option description", description);
|
|
496
|
+
}
|
|
497
|
+
return null;
|
|
498
|
+
};
|
|
474
499
|
export const findingSchema = z
|
|
475
500
|
.object({
|
|
476
501
|
claim: text,
|
|
@@ -565,6 +590,13 @@ export const actionProgressListSchema = z.array(actionProgressSchema).check((ctx
|
|
|
565
590
|
path: ["decisionId"],
|
|
566
591
|
});
|
|
567
592
|
});
|
|
593
|
+
export const runtimeSchema = z
|
|
594
|
+
.object({
|
|
595
|
+
createdWith: text.nullable(),
|
|
596
|
+
updatedWith: text,
|
|
597
|
+
})
|
|
598
|
+
.strict();
|
|
599
|
+
export type RuntimeInfo = z.infer<typeof runtimeSchema>;
|
|
568
600
|
export const taskRecordSchema = z
|
|
569
601
|
.object({
|
|
570
602
|
schemaVersion: z.literal(1),
|
|
@@ -579,6 +611,8 @@ export const taskRecordSchema = z
|
|
|
579
611
|
status: z.enum(["active", "paused", "closed"]),
|
|
580
612
|
closure: closureSchema.nullable(),
|
|
581
613
|
progress: progressSchema,
|
|
614
|
+
pauseReason: text.nullable().optional(),
|
|
615
|
+
runtime: runtimeSchema.optional(),
|
|
582
616
|
assessments: z.array(entrySchema(assessmentSchema)),
|
|
583
617
|
policy: policySchema.nullable(),
|
|
584
618
|
policyChanges: z.array(policyChangeSchema),
|
|
@@ -597,6 +631,7 @@ export const workspaceRecordSchema = z
|
|
|
597
631
|
id,
|
|
598
632
|
revision,
|
|
599
633
|
root: nonEmpty,
|
|
634
|
+
runtime: runtimeSchema.optional(),
|
|
600
635
|
writer: z
|
|
601
636
|
.object({ state: z.enum(["held", "uncertain"]), owner: ownerSchema, acquiredAt: utc })
|
|
602
637
|
.strict()
|
|
@@ -639,6 +674,9 @@ export const taskSummarySchema = z
|
|
|
639
674
|
id,
|
|
640
675
|
revision,
|
|
641
676
|
workspaceRevision: revision,
|
|
677
|
+
createdAt: utc,
|
|
678
|
+
updatedAt: utc,
|
|
679
|
+
runtime: runtimeSchema.nullable(),
|
|
642
680
|
objective: text,
|
|
643
681
|
status: z.enum(["active", "paused", "closed"]),
|
|
644
682
|
closure: closureSchema.nullable(),
|
|
@@ -720,7 +758,7 @@ const taskOperations = {
|
|
|
720
758
|
expectedWorkspaceRevision: revision.optional(),
|
|
721
759
|
outcome: outcomeSchema,
|
|
722
760
|
summary: text,
|
|
723
|
-
decisionIds: z.array(id),
|
|
761
|
+
decisionIds: z.array(id).optional(),
|
|
724
762
|
}),
|
|
725
763
|
};
|
|
726
764
|
const policyOperations = {
|
|
@@ -813,13 +851,14 @@ const writerOperations = {
|
|
|
813
851
|
expectedRevision: revision.optional(),
|
|
814
852
|
expectedWorkspaceRevision: revision.optional(),
|
|
815
853
|
workerId: nullableId.optional(),
|
|
854
|
+
reason: text.optional(),
|
|
816
855
|
}),
|
|
817
856
|
release: operation({
|
|
818
857
|
action: z.literal("release"),
|
|
819
858
|
...taskId,
|
|
820
859
|
expectedRevision: revision.optional(),
|
|
821
860
|
expectedWorkspaceRevision: revision.optional(),
|
|
822
|
-
reason: text,
|
|
861
|
+
reason: text.optional(),
|
|
823
862
|
}),
|
|
824
863
|
};
|
|
825
864
|
const stateOperations = {
|
|
@@ -891,6 +930,22 @@ export type ErrorDetails = {
|
|
|
891
930
|
path?: string;
|
|
892
931
|
operation?: string;
|
|
893
932
|
outcome?: "not_started" | "pending" | "unknown";
|
|
933
|
+
/** Present when a native approval must be asked from a concise proposal. */
|
|
934
|
+
proposal?: {
|
|
935
|
+
presented: string;
|
|
936
|
+
approvedContent: string;
|
|
937
|
+
descriptorDigest: string;
|
|
938
|
+
};
|
|
939
|
+
guidance?: string;
|
|
940
|
+
/** Structured remedy for unsatisfied requirements: rule, reason, and
|
|
941
|
+
* satisfaction text instead of opaque requirement hashes alone. */
|
|
942
|
+
requirements?: {
|
|
943
|
+
requirementId: string;
|
|
944
|
+
ruleId: string;
|
|
945
|
+
reason: string;
|
|
946
|
+
satisfaction: string;
|
|
947
|
+
dependentAction: string | null;
|
|
948
|
+
}[];
|
|
894
949
|
};
|
|
895
950
|
export type Result<T> =
|
|
896
951
|
| {
|