@brainervirus/workit-core 1.0.9 → 1.0.10
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 +192 -9
- package/src/core/external-action.ts +190 -2
- package/src/core/methods.ts +13 -2
- package/src/core/policy-resolver.ts +2 -2
- package/src/core/task-context.ts +2 -5
- package/src/core/task-contract.ts +56 -2
- package/src/core/task-engine.ts +31 -25
- package/src/core/task-evaluation.ts +34 -5
- package/src/core/task-store.ts +97 -17
- package/src/core/workers.ts +6 -0
- package/src/core.ts +8 -1
|
@@ -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,
|
|
@@ -331,6 +485,40 @@ export const approvedExternalAction = (
|
|
|
331
485
|
return selected;
|
|
332
486
|
};
|
|
333
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Re-resolve guard: when a prior approval exists for the same requested
|
|
490
|
+
* operation/payload, the freshly resolved baseline (source state and remote
|
|
491
|
+
* base) must still match the approved descriptor. Drift returns not_started so
|
|
492
|
+
* the agent re-presents the current state instead of executing a stale effect.
|
|
493
|
+
*/
|
|
494
|
+
export const priorResolvedDrift = (
|
|
495
|
+
store: TaskStore,
|
|
496
|
+
host: string,
|
|
497
|
+
actor: string,
|
|
498
|
+
operation: string,
|
|
499
|
+
payload: unknown,
|
|
500
|
+
currentResolved: unknown,
|
|
501
|
+
): Result<null> => {
|
|
502
|
+
const prior = priorExternalAction(store, host, actor, operation, payload);
|
|
503
|
+
if (!prior.ok) return success(null, null, null);
|
|
504
|
+
let baseline: unknown;
|
|
505
|
+
try {
|
|
506
|
+
const parsed = JSON.parse(prior.data.entry.data.binding.approvedContent) as {
|
|
507
|
+
payload?: { resolved?: unknown };
|
|
508
|
+
};
|
|
509
|
+
baseline = parsed.payload?.resolved;
|
|
510
|
+
} catch {
|
|
511
|
+
return success(null, null, null);
|
|
512
|
+
}
|
|
513
|
+
if (baseline === undefined || baseline === null) return success(null, null, null);
|
|
514
|
+
if (canonicalJson(baseline) === canonicalJson(currentResolved)) return success(null, null, null);
|
|
515
|
+
return failure(
|
|
516
|
+
"invalid_input",
|
|
517
|
+
"repository state changed since approval; show the current state and approve the action again",
|
|
518
|
+
{ outcome: "not_started", operation },
|
|
519
|
+
);
|
|
520
|
+
};
|
|
521
|
+
|
|
334
522
|
export const createAuthorizedExternalActionRunner =
|
|
335
523
|
(core: WorkitCore, bind: ExternalActionBinding): ExternalActionEffect =>
|
|
336
524
|
async <T>(
|
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/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,6 +453,7 @@ 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),
|
|
457
458
|
})
|
|
458
459
|
.strict(),
|
|
@@ -471,6 +472,29 @@ export const decisionSchema = z
|
|
|
471
472
|
})
|
|
472
473
|
.strict();
|
|
473
474
|
export type Decision = z.infer<typeof decisionSchema>;
|
|
475
|
+
|
|
476
|
+
/** Show-before-ask guard: binding questions are single-sentence and never
|
|
477
|
+
* carry the artifact, descriptor, or payload body in the question UI. */
|
|
478
|
+
export const BINDING_QUESTION_BUDGET = 300;
|
|
479
|
+
const bindingQuestionHeader = /^Workit decision: (design|action|limitation|preference)$/;
|
|
480
|
+
export const workitBindingQuestionIssue = (questions: unknown): string | null => {
|
|
481
|
+
if (!Array.isArray(questions) || questions.length !== 1) return null;
|
|
482
|
+
const question = questions[0] as { header?: unknown; question?: unknown; options?: unknown };
|
|
483
|
+
if (!question || typeof question !== "object") return null;
|
|
484
|
+
if (typeof question.header !== "string" || !bindingQuestionHeader.test(question.header.trim()))
|
|
485
|
+
return null;
|
|
486
|
+
const over = (label: string, value: string) =>
|
|
487
|
+
`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.`;
|
|
488
|
+
if (typeof question.question === "string" && question.question.length > BINDING_QUESTION_BUDGET)
|
|
489
|
+
return over("the question text", question.question);
|
|
490
|
+
if (Array.isArray(question.options))
|
|
491
|
+
for (const option of question.options) {
|
|
492
|
+
const description = (option as { description?: unknown }).description;
|
|
493
|
+
if (typeof description === "string" && description.length > BINDING_QUESTION_BUDGET)
|
|
494
|
+
return over("an option description", description);
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
};
|
|
474
498
|
export const findingSchema = z
|
|
475
499
|
.object({
|
|
476
500
|
claim: text,
|
|
@@ -565,6 +589,13 @@ export const actionProgressListSchema = z.array(actionProgressSchema).check((ctx
|
|
|
565
589
|
path: ["decisionId"],
|
|
566
590
|
});
|
|
567
591
|
});
|
|
592
|
+
export const runtimeSchema = z
|
|
593
|
+
.object({
|
|
594
|
+
createdWith: text.nullable(),
|
|
595
|
+
updatedWith: text,
|
|
596
|
+
})
|
|
597
|
+
.strict();
|
|
598
|
+
export type RuntimeInfo = z.infer<typeof runtimeSchema>;
|
|
568
599
|
export const taskRecordSchema = z
|
|
569
600
|
.object({
|
|
570
601
|
schemaVersion: z.literal(1),
|
|
@@ -579,6 +610,8 @@ export const taskRecordSchema = z
|
|
|
579
610
|
status: z.enum(["active", "paused", "closed"]),
|
|
580
611
|
closure: closureSchema.nullable(),
|
|
581
612
|
progress: progressSchema,
|
|
613
|
+
pauseReason: text.nullable().optional(),
|
|
614
|
+
runtime: runtimeSchema.optional(),
|
|
582
615
|
assessments: z.array(entrySchema(assessmentSchema)),
|
|
583
616
|
policy: policySchema.nullable(),
|
|
584
617
|
policyChanges: z.array(policyChangeSchema),
|
|
@@ -597,6 +630,7 @@ export const workspaceRecordSchema = z
|
|
|
597
630
|
id,
|
|
598
631
|
revision,
|
|
599
632
|
root: nonEmpty,
|
|
633
|
+
runtime: runtimeSchema.optional(),
|
|
600
634
|
writer: z
|
|
601
635
|
.object({ state: z.enum(["held", "uncertain"]), owner: ownerSchema, acquiredAt: utc })
|
|
602
636
|
.strict()
|
|
@@ -639,6 +673,9 @@ export const taskSummarySchema = z
|
|
|
639
673
|
id,
|
|
640
674
|
revision,
|
|
641
675
|
workspaceRevision: revision,
|
|
676
|
+
createdAt: utc,
|
|
677
|
+
updatedAt: utc,
|
|
678
|
+
runtime: runtimeSchema.nullable(),
|
|
642
679
|
objective: text,
|
|
643
680
|
status: z.enum(["active", "paused", "closed"]),
|
|
644
681
|
closure: closureSchema.nullable(),
|
|
@@ -720,7 +757,7 @@ const taskOperations = {
|
|
|
720
757
|
expectedWorkspaceRevision: revision.optional(),
|
|
721
758
|
outcome: outcomeSchema,
|
|
722
759
|
summary: text,
|
|
723
|
-
decisionIds: z.array(id),
|
|
760
|
+
decisionIds: z.array(id).optional(),
|
|
724
761
|
}),
|
|
725
762
|
};
|
|
726
763
|
const policyOperations = {
|
|
@@ -813,13 +850,14 @@ const writerOperations = {
|
|
|
813
850
|
expectedRevision: revision.optional(),
|
|
814
851
|
expectedWorkspaceRevision: revision.optional(),
|
|
815
852
|
workerId: nullableId.optional(),
|
|
853
|
+
reason: text.optional(),
|
|
816
854
|
}),
|
|
817
855
|
release: operation({
|
|
818
856
|
action: z.literal("release"),
|
|
819
857
|
...taskId,
|
|
820
858
|
expectedRevision: revision.optional(),
|
|
821
859
|
expectedWorkspaceRevision: revision.optional(),
|
|
822
|
-
reason: text,
|
|
860
|
+
reason: text.optional(),
|
|
823
861
|
}),
|
|
824
862
|
};
|
|
825
863
|
const stateOperations = {
|
|
@@ -891,6 +929,22 @@ export type ErrorDetails = {
|
|
|
891
929
|
path?: string;
|
|
892
930
|
operation?: string;
|
|
893
931
|
outcome?: "not_started" | "pending" | "unknown";
|
|
932
|
+
/** Present when a native approval must be asked from a concise proposal. */
|
|
933
|
+
proposal?: {
|
|
934
|
+
presented: string;
|
|
935
|
+
approvedContent: string;
|
|
936
|
+
descriptorDigest: string;
|
|
937
|
+
};
|
|
938
|
+
guidance?: string;
|
|
939
|
+
/** Structured remedy for unsatisfied requirements: rule, reason, and
|
|
940
|
+
* satisfaction text instead of opaque requirement hashes alone. */
|
|
941
|
+
requirements?: {
|
|
942
|
+
requirementId: string;
|
|
943
|
+
ruleId: string;
|
|
944
|
+
reason: string;
|
|
945
|
+
satisfaction: string;
|
|
946
|
+
dependentAction: string | null;
|
|
947
|
+
}[];
|
|
894
948
|
};
|
|
895
949
|
export type Result<T> =
|
|
896
950
|
| {
|
package/src/core/task-engine.ts
CHANGED
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
} from "./task-context";
|
|
59
59
|
import {
|
|
60
60
|
assertProductWriteAllowed,
|
|
61
|
+
isUncertainWorker,
|
|
61
62
|
type CallerContext,
|
|
62
63
|
type NativeWorkerObservation,
|
|
63
64
|
type NativeWorkerVerification,
|
|
@@ -360,11 +361,16 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
360
361
|
return failure("invalid_transition", "worker has not started");
|
|
361
362
|
if (input.state === "running" && entry.data.state === "stopped")
|
|
362
363
|
return failure("invalid_transition", "stopped worker cannot run again");
|
|
364
|
+
const terminalCancel = entry.data.state === "cancelling" && input.state === "running";
|
|
363
365
|
const nextEntry = {
|
|
364
366
|
...entry,
|
|
365
367
|
recordedAt: input.now ?? entry.recordedAt,
|
|
366
368
|
provenance: authority.provenance,
|
|
367
|
-
data: {
|
|
369
|
+
data: {
|
|
370
|
+
...entry.data,
|
|
371
|
+
state: terminalCancel ? ("cancelling" as const) : input.state,
|
|
372
|
+
session: input.session,
|
|
373
|
+
},
|
|
368
374
|
} satisfies Entry<Worker>;
|
|
369
375
|
const shouldClear =
|
|
370
376
|
input.state === "stopped" &&
|
|
@@ -381,7 +387,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
|
|
|
381
387
|
// revision bump per event livelocks worker sessions — each call would
|
|
382
388
|
// invalidate the revision the previous call returned.
|
|
383
389
|
if (
|
|
384
|
-
entry.data.state ===
|
|
390
|
+
entry.data.state === nextEntry.data.state &&
|
|
385
391
|
sameValue(entry.data.session, input.session) &&
|
|
386
392
|
!shouldClear &&
|
|
387
393
|
!shouldUncertain
|
|
@@ -823,11 +829,7 @@ export class WorkitCore {
|
|
|
823
829
|
) {
|
|
824
830
|
if (workspace.writer)
|
|
825
831
|
return failure("recovery_required", "writer ownership must be released first");
|
|
826
|
-
if (
|
|
827
|
-
task.workers.some((entry) =>
|
|
828
|
-
["dispatching", "running", "cancelling", "unknown"].includes(entry.data.state),
|
|
829
|
-
)
|
|
830
|
-
)
|
|
832
|
+
if (task.workers.some((entry) => isUncertainWorker(entry.data.state)))
|
|
831
833
|
return failure("recovery_required", "worker state requires reconciliation");
|
|
832
834
|
return null;
|
|
833
835
|
}
|
|
@@ -1063,18 +1065,6 @@ export class WorkitCore {
|
|
|
1063
1065
|
(this.context.workerId ?? null) === null ? null : this.helperEntry(task.data, true, true);
|
|
1064
1066
|
if (helper && !helper.ok) return helper as Result<never>;
|
|
1065
1067
|
const evidence = input.evidence as Evidence;
|
|
1066
|
-
if (helper?.ok) {
|
|
1067
|
-
const assignment = helper.data.data.assignment;
|
|
1068
|
-
if (
|
|
1069
|
-
evidence.requirementIds.some((id: string) => !assignment.requirementIds.includes(id)) ||
|
|
1070
|
-
(assignment.candidateId !== null &&
|
|
1071
|
-
evidence.candidateId !== assignment.candidateId &&
|
|
1072
|
-
evidence.beforeCandidateId !== assignment.candidateId) ||
|
|
1073
|
-
!refsWithinScope(evidence.refs, assignment.scope) ||
|
|
1074
|
-
!refsWithinScope(evidence.refs, task.data.intent.data.scope)
|
|
1075
|
-
)
|
|
1076
|
-
return failure("permission_denied", "evidence is outside the worker assignment");
|
|
1077
|
-
}
|
|
1078
1068
|
if (
|
|
1079
1069
|
(evidence.result === "missing" || evidence.result === "skipped") &&
|
|
1080
1070
|
!evidence.summary.trim()
|
|
@@ -1100,6 +1090,18 @@ export class WorkitCore {
|
|
|
1100
1090
|
evidence.beforeCandidateId ??= null;
|
|
1101
1091
|
evidence.candidateId ??= null;
|
|
1102
1092
|
}
|
|
1093
|
+
if (helper?.ok) {
|
|
1094
|
+
const assignment = helper.data.data.assignment;
|
|
1095
|
+
if (
|
|
1096
|
+
evidence.requirementIds.some((id: string) => !assignment.requirementIds.includes(id)) ||
|
|
1097
|
+
(assignment.candidateId !== null &&
|
|
1098
|
+
evidence.candidateId !== assignment.candidateId &&
|
|
1099
|
+
evidence.beforeCandidateId !== assignment.candidateId) ||
|
|
1100
|
+
!refsWithinScope(evidence.refs, assignment.scope) ||
|
|
1101
|
+
!refsWithinScope(evidence.refs, task.data.intent.data.scope)
|
|
1102
|
+
)
|
|
1103
|
+
return failure("permission_denied", "evidence is outside the worker assignment");
|
|
1104
|
+
}
|
|
1103
1105
|
const knownCandidates = new Set(task.data.candidates.map((candidate) => candidate.id));
|
|
1104
1106
|
for (const candidateId of [evidence.beforeCandidateId, evidence.candidateId]) {
|
|
1105
1107
|
if (
|
|
@@ -2242,6 +2244,9 @@ export class WorkitCore {
|
|
|
2242
2244
|
id: task.id,
|
|
2243
2245
|
revision: task.revision,
|
|
2244
2246
|
workspaceRevision: workspace.data.revision,
|
|
2247
|
+
createdAt: task.createdAt,
|
|
2248
|
+
updatedAt: task.updatedAt,
|
|
2249
|
+
runtime: task.runtime ?? null,
|
|
2245
2250
|
objective: task.intent.data.objective,
|
|
2246
2251
|
status: task.status,
|
|
2247
2252
|
closure: task.closure,
|
|
@@ -2344,9 +2349,7 @@ export class WorkitCore {
|
|
|
2344
2349
|
const base = reconcileResumeContext(effectiveView);
|
|
2345
2350
|
if (!base.ok) return base;
|
|
2346
2351
|
const needsReconciliation = view.task.workers.some((entry) =>
|
|
2347
|
-
|
|
2348
|
-
observedStates.get(entry.id) ?? entry.data.state,
|
|
2349
|
-
),
|
|
2352
|
+
isUncertainWorker(observedStates.get(entry.id) ?? entry.data.state),
|
|
2350
2353
|
);
|
|
2351
2354
|
const blockers = [...base.data.blockers];
|
|
2352
2355
|
if (
|
|
@@ -2438,8 +2441,8 @@ export class WorkitCore {
|
|
|
2438
2441
|
!current.candidates.some((candidate) => candidate.id === resumeCandidate!.id)
|
|
2439
2442
|
? [...current.candidates, resumeCandidate]
|
|
2440
2443
|
: current.candidates,
|
|
2441
|
-
progress:
|
|
2442
|
-
|
|
2444
|
+
progress: current.progress,
|
|
2445
|
+
pauseReason: status === "paused" ? (input.reason ?? null) : null,
|
|
2443
2446
|
}),
|
|
2444
2447
|
});
|
|
2445
2448
|
if (!changed.ok) return changed as Result<never>;
|
|
@@ -2475,7 +2478,10 @@ export class WorkitCore {
|
|
|
2475
2478
|
const workspace = this.store.readWorkspace();
|
|
2476
2479
|
if (!workspace.ok) return workspace as Result<never>;
|
|
2477
2480
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
2478
|
-
if (
|
|
2481
|
+
if (
|
|
2482
|
+
workspace.data.writer ||
|
|
2483
|
+
task.data.workers.some((entry) => isUncertainWorker(entry.data.state))
|
|
2484
|
+
)
|
|
2479
2485
|
return failure(
|
|
2480
2486
|
"recovery_required",
|
|
2481
2487
|
"scope revision requires worker ownership reconciliation",
|
|
@@ -154,14 +154,23 @@ const scopeRoots = (root: string, scope: Scope): Result<string[]> => {
|
|
|
154
154
|
const roots: string[] = [];
|
|
155
155
|
for (const item of scope.paths.length ? scope.paths : ["."]) {
|
|
156
156
|
const target = path.resolve(root, item);
|
|
157
|
-
if (!inside(root, target))
|
|
157
|
+
if (!inside(root, target))
|
|
158
|
+
return failure(
|
|
159
|
+
"invalid_input",
|
|
160
|
+
`candidate scope escapes checkout: ${item} — keep one task per repository and coordinate a separate linked task in the other checkout`,
|
|
161
|
+
{ fields: [{ path: "scope.paths", reason: `${item} is outside the checkout` }] },
|
|
162
|
+
);
|
|
158
163
|
let ancestor = target;
|
|
159
164
|
while (!fs.existsSync(ancestor) && ancestor !== root) ancestor = path.dirname(ancestor);
|
|
160
165
|
try {
|
|
161
166
|
if (!inside(root, fs.realpathSync(ancestor)))
|
|
162
|
-
return failure(
|
|
167
|
+
return failure(
|
|
168
|
+
"invalid_input",
|
|
169
|
+
`candidate scope escapes checkout through a symlink: ${item} — keep one task per repository and coordinate a separate linked task in the other checkout`,
|
|
170
|
+
{ fields: [{ path: "scope.paths", reason: `${item} resolves outside the checkout` }] },
|
|
171
|
+
);
|
|
163
172
|
} catch {
|
|
164
|
-
return failure("invalid_input",
|
|
173
|
+
return failure("invalid_input", `candidate scope cannot be inspected: ${item}`);
|
|
165
174
|
}
|
|
166
175
|
roots.push(target);
|
|
167
176
|
}
|
|
@@ -180,7 +189,12 @@ export function captureCandidate(
|
|
|
180
189
|
return failure("invalid_input", "candidate root does not exist");
|
|
181
190
|
}
|
|
182
191
|
const normalizedScope = canonicalScope(scope);
|
|
183
|
-
if (!normalizedScope)
|
|
192
|
+
if (!normalizedScope)
|
|
193
|
+
return failure(
|
|
194
|
+
"invalid_input",
|
|
195
|
+
"candidate scope is invalid: paths must be checkout-relative — to work in another repository, keep this task here and coordinate a separate linked task in that checkout",
|
|
196
|
+
{ fields: [{ path: "scope.paths", reason: "must be checkout-relative" }] },
|
|
197
|
+
);
|
|
184
198
|
const roots = scopeRoots(checkout, normalizedScope);
|
|
185
199
|
if (!roots.ok) return roots;
|
|
186
200
|
const git = gitPaths(checkout);
|
|
@@ -461,7 +475,10 @@ export function evaluateRequirements(
|
|
|
461
475
|
const reviewSession = sessionFromRef(entry.data.reviewContext);
|
|
462
476
|
const sameImplementation = sameSession(reviewSession, task.intent.provenance.session);
|
|
463
477
|
const sameEvidenceSession = task.evidence.some(
|
|
464
|
-
(other) =>
|
|
478
|
+
(other) =>
|
|
479
|
+
other.id !== entry.id &&
|
|
480
|
+
other.data.kind === "review" &&
|
|
481
|
+
sameSession(reviewSession, other.provenance.session),
|
|
465
482
|
);
|
|
466
483
|
return (
|
|
467
484
|
entry.data.kind === "review" &&
|
|
@@ -641,6 +658,18 @@ export function evaluateClosure(
|
|
|
641
658
|
if (requestedOutcome !== "stopped" && blocking.length)
|
|
642
659
|
return failure("requirements_unsatisfied", "applicable requirements are unsatisfied", {
|
|
643
660
|
requirementIds: blocking.map((item) => item.requirementId),
|
|
661
|
+
requirements: blocking.map((item) => {
|
|
662
|
+
const requirement = view.task.policy?.requirements.find(
|
|
663
|
+
(candidate) => candidate.id === item.requirementId,
|
|
664
|
+
);
|
|
665
|
+
return {
|
|
666
|
+
requirementId: item.requirementId,
|
|
667
|
+
ruleId: requirement?.ruleId ?? "unknown",
|
|
668
|
+
reason: item.reason,
|
|
669
|
+
satisfaction: requirement?.satisfaction ?? "",
|
|
670
|
+
dependentAction: requirement?.dependentAction ?? null,
|
|
671
|
+
};
|
|
672
|
+
}),
|
|
644
673
|
});
|
|
645
674
|
const accepted = evaluations.filter((item) => item.status === "accepted_limitation");
|
|
646
675
|
if (requestedOutcome === "verified" && accepted.length)
|