@brainervirus/workit-core 2.5.0 → 2.7.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/analyze-release-scope.ts +99 -8
- package/scripts/bundled-deps.json +133 -0
- package/scripts/publish-changed-packages.ts +28 -16
- package/scripts/rewrite-workspace-deps.ts +11 -5
- package/scripts/sync-release-manifests.ts +5 -1
- package/skills/workit-behavioral-tdd/SKILL.md +15 -9
- package/skills/workit-debug/SKILL.md +2 -1
- package/src/check-config.ts +294 -0
- package/src/checks.ts +480 -0
- package/src/core/detect-hosts.ts +9 -3
- package/src/core/doctor.ts +61 -0
- package/src/core/host-install.ts +144 -1
- package/src/core/methods.ts +5 -2
- package/src/core/setup.ts +27 -15
- package/src/core/support-matrix.ts +4 -0
- package/src/core/task-context.ts +6 -2
- package/src/core/task-contract.ts +70 -12
- package/src/core/task-engine.ts +120 -10
- package/src/core/task-evaluation.ts +176 -1
- package/src/core/uninstall.ts +107 -4
- package/src/git/rev.ts +50 -0
- package/src/hooks/context.ts +44 -5
- package/src/hooks/handle.ts +15 -4
package/src/core/host-install.ts
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
import os from "node:os";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
|
|
14
|
-
export type HostId = "opencode" | "cursor" | "codex" | "pi";
|
|
14
|
+
export type HostId = "opencode" | "cursor" | "codex" | "pi" | "claude-code";
|
|
15
15
|
|
|
16
16
|
/** A reviewed argv vector. Arguments are never composed into a shell string. */
|
|
17
17
|
export type HostInstallCommand = {
|
|
@@ -89,6 +89,12 @@ export type HostInstallOptions = {
|
|
|
89
89
|
|
|
90
90
|
const MARKETPLACE = "https://github.com/BrainerVirus/workit.git";
|
|
91
91
|
const CODEX_MARKETPLACE_NAME = "workflow-toolkit";
|
|
92
|
+
/** Claude Code reads the git-hosted marketplace at the repo root
|
|
93
|
+
* (`.claude-plugin/marketplace.json`, name `workit`); its entry installs the
|
|
94
|
+
* published npm package. */
|
|
95
|
+
export const CLAUDE_MARKETPLACE_REPO = "BrainerVirus/workit";
|
|
96
|
+
export const CLAUDE_MARKETPLACE_NAME = "workit";
|
|
97
|
+
export const CLAUDE_PLUGIN_ID = `workit@${CLAUDE_MARKETPLACE_NAME}`;
|
|
92
98
|
const validPackageVersion = (value: string): boolean =>
|
|
93
99
|
value === "latest" || /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value);
|
|
94
100
|
|
|
@@ -224,6 +230,109 @@ export const isCodexWorkitInstalled = (
|
|
|
224
230
|
return false;
|
|
225
231
|
};
|
|
226
232
|
|
|
233
|
+
/** Claude Code's config root (`CLAUDE_CONFIG_DIR`, default `~/.claude`). */
|
|
234
|
+
export const claudeConfigDir = (home: string, env: NodeJS.ProcessEnv = process.env): string =>
|
|
235
|
+
env.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude");
|
|
236
|
+
|
|
237
|
+
export type ClaudeInstallScope = "user" | "project" | "local";
|
|
238
|
+
|
|
239
|
+
export type ClaudeWorkitInstall = {
|
|
240
|
+
id: string;
|
|
241
|
+
version: string | null;
|
|
242
|
+
installPath: string;
|
|
243
|
+
scope: ClaudeInstallScope;
|
|
244
|
+
/** The project a project/local-scope install belongs to; null for user scope. */
|
|
245
|
+
projectPath: string | null;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const CLAUDE_SCOPES = new Set<string>(["user", "project", "local"]);
|
|
249
|
+
|
|
250
|
+
/** A project/local install applies to `cwd` when cwd is inside its project. */
|
|
251
|
+
const appliesTo = (install: ClaudeWorkitInstall, cwd: string | null): boolean => {
|
|
252
|
+
if (install.scope === "user") return true;
|
|
253
|
+
if (cwd === null || install.projectPath === null) return false;
|
|
254
|
+
const relative = path.relative(path.resolve(install.projectPath), path.resolve(cwd));
|
|
255
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const isCanonicalClaudePlugin = (root: string): boolean => {
|
|
259
|
+
try {
|
|
260
|
+
const manifest = JSON.parse(
|
|
261
|
+
readFileSync(path.join(root, ".claude-plugin", "plugin.json"), "utf8"),
|
|
262
|
+
) as { name?: string; repository?: string | { url?: string } };
|
|
263
|
+
const repository =
|
|
264
|
+
typeof manifest.repository === "string" ? manifest.repository : manifest.repository?.url;
|
|
265
|
+
return (
|
|
266
|
+
manifest.name === "workit" &&
|
|
267
|
+
repository?.replace(/\.git$/, "").toLowerCase() === "https://github.com/brainervirus/workit"
|
|
268
|
+
);
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Workit installs recorded in Claude Code's plugin registry
|
|
276
|
+
* (`<config>/plugins/installed_plugins.json`, v2: `{plugins: {"name@market":
|
|
277
|
+
* [{installPath, version}]}}`). Only entries whose install directory carries
|
|
278
|
+
* the canonical Workit manifest count, whatever marketplace they came from.
|
|
279
|
+
* Only installs that apply here count: user scope always, project/local
|
|
280
|
+
* scope only when `cwd` is inside the recorded project (`cwd: null` keeps
|
|
281
|
+
* user scope only). A `--plugin-dir` pin is per-session and never recorded.
|
|
282
|
+
*/
|
|
283
|
+
export const claudeWorkitInstalls = (
|
|
284
|
+
home: string,
|
|
285
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
286
|
+
cwd: string | null = process.cwd(),
|
|
287
|
+
): ClaudeWorkitInstall[] => {
|
|
288
|
+
const file = path.join(claudeConfigDir(home, env), "plugins", "installed_plugins.json");
|
|
289
|
+
let registry: unknown;
|
|
290
|
+
try {
|
|
291
|
+
registry = JSON.parse(readFileSync(file, "utf8"));
|
|
292
|
+
} catch {
|
|
293
|
+
return [];
|
|
294
|
+
}
|
|
295
|
+
const plugins =
|
|
296
|
+
registry && typeof registry === "object" && "plugins" in registry ? registry.plugins : null;
|
|
297
|
+
if (!plugins || typeof plugins !== "object") return [];
|
|
298
|
+
const found: ClaudeWorkitInstall[] = [];
|
|
299
|
+
for (const [id, entries] of Object.entries(plugins as Record<string, unknown>)) {
|
|
300
|
+
if (!id.startsWith("workit@") || !Array.isArray(entries)) continue;
|
|
301
|
+
for (const entry of entries as Array<{
|
|
302
|
+
installPath?: unknown;
|
|
303
|
+
version?: unknown;
|
|
304
|
+
scope?: unknown;
|
|
305
|
+
projectPath?: unknown;
|
|
306
|
+
}>) {
|
|
307
|
+
if (typeof entry?.installPath !== "string" || !isCanonicalClaudePlugin(entry.installPath))
|
|
308
|
+
continue;
|
|
309
|
+
// Older registries omit scope; Claude's default install scope is user.
|
|
310
|
+
const scope = entry.scope === undefined ? "user" : String(entry.scope);
|
|
311
|
+
if (!CLAUDE_SCOPES.has(scope)) continue; // e.g. managed: not ours to change
|
|
312
|
+
const install: ClaudeWorkitInstall = {
|
|
313
|
+
id,
|
|
314
|
+
installPath: entry.installPath,
|
|
315
|
+
version:
|
|
316
|
+
typeof entry.version === "string" && /^\d+\.\d+\.\d+/.test(entry.version)
|
|
317
|
+
? entry.version
|
|
318
|
+
: null,
|
|
319
|
+
scope: scope as ClaudeInstallScope,
|
|
320
|
+
projectPath:
|
|
321
|
+
scope !== "user" && typeof entry.projectPath === "string" ? entry.projectPath : null,
|
|
322
|
+
};
|
|
323
|
+
if (appliesTo(install, cwd)) found.push(install);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return found;
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
/** A Workit install Claude Code loads for this user (any project). */
|
|
330
|
+
export const isClaudeWorkitInstalled = (
|
|
331
|
+
home: string,
|
|
332
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
333
|
+
cwd: string | null = process.cwd(),
|
|
334
|
+
): boolean => claudeWorkitInstalls(home, env, cwd).length > 0;
|
|
335
|
+
|
|
227
336
|
export function installedHostApp(
|
|
228
337
|
host: HostId,
|
|
229
338
|
home: string,
|
|
@@ -297,6 +406,39 @@ export function planHostInstall(
|
|
|
297
406
|
},
|
|
298
407
|
];
|
|
299
408
|
}
|
|
409
|
+
if (host === "claude-code") {
|
|
410
|
+
if (!upgrading && isClaudeWorkitInstalled(home, env, null)) return [];
|
|
411
|
+
const base = hostCommand("claude", [], { home, cwd, env });
|
|
412
|
+
return [
|
|
413
|
+
nodePrerequisite({ home, cwd, env }),
|
|
414
|
+
{
|
|
415
|
+
...base,
|
|
416
|
+
args: [...base.args, "plugin", "marketplace", "list", "--json"],
|
|
417
|
+
purpose: "Check whether the Workit Claude Code marketplace is already registered",
|
|
418
|
+
},
|
|
419
|
+
upgrading
|
|
420
|
+
? {
|
|
421
|
+
...base,
|
|
422
|
+
args: [...base.args, "plugin", "marketplace", "update", CLAUDE_MARKETPLACE_NAME],
|
|
423
|
+
purpose: "Refresh the Workit Claude Code marketplace",
|
|
424
|
+
}
|
|
425
|
+
: {
|
|
426
|
+
...base,
|
|
427
|
+
args: [...base.args, "plugin", "marketplace", "add", CLAUDE_MARKETPLACE_REPO],
|
|
428
|
+
purpose: "Register the Workit Claude Code marketplace",
|
|
429
|
+
skipWhenOutputIncludes: `"name": "${CLAUDE_MARKETPLACE_NAME}"`,
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
...base,
|
|
433
|
+
args: upgrading
|
|
434
|
+
? [...base.args, "plugin", "update", CLAUDE_PLUGIN_ID]
|
|
435
|
+
: [...base.args, "plugin", "install", CLAUDE_PLUGIN_ID, "--scope", "user"],
|
|
436
|
+
purpose: upgrading
|
|
437
|
+
? "Update the Workit Claude Code plugin"
|
|
438
|
+
: "Install the Workit Claude Code plugin",
|
|
439
|
+
},
|
|
440
|
+
];
|
|
441
|
+
}
|
|
300
442
|
if (host === "pi") {
|
|
301
443
|
if (!upgrading && isPiWorkitInstalled(home, env)) return [];
|
|
302
444
|
const base = hostCommand("pi", [], { home, cwd, env });
|
|
@@ -440,6 +582,7 @@ export function runHostCommand(
|
|
|
440
582
|
const env: NodeJS.ProcessEnv = { ...process.env, ...context.env, HOME: home };
|
|
441
583
|
env.CODEX_HOME = context.env?.CODEX_HOME ?? path.join(home, ".codex");
|
|
442
584
|
env.PI_CODING_AGENT_DIR = context.env?.PI_CODING_AGENT_DIR ?? path.join(home, ".pi", "agent");
|
|
585
|
+
env.CLAUDE_CONFIG_DIR = context.env?.CLAUDE_CONFIG_DIR ?? path.join(home, ".claude");
|
|
443
586
|
env.XDG_CONFIG_HOME = context.env?.XDG_CONFIG_HOME ?? path.join(home, ".config");
|
|
444
587
|
return env;
|
|
445
588
|
})(),
|
package/src/core/methods.ts
CHANGED
|
@@ -118,8 +118,11 @@ policy, and reapplies the call, returning busy under persistent contention. A
|
|
|
118
118
|
revision_conflict means a revision you passed is stale: re-read the record
|
|
119
119
|
before deciding whether to retry.
|
|
120
120
|
A solo edit does not need writer acquisition; use it when concurrent checkout
|
|
121
|
-
writers need coordination. Record only observed facts and checks.
|
|
122
|
-
|
|
121
|
+
writers need coordination. Record only observed facts and checks. Close-time
|
|
122
|
+
testing and verification gates accept only a configured check the CLI ran:
|
|
123
|
+
\`workit check <name>\` (\`npx -y @brainervirus/workit-cli check <name>\` when
|
|
124
|
+
\`workit\` is not on PATH); a recorded check result is a note and an ad-hoc
|
|
125
|
+
\`workit check -- <cmd>\` never satisfies a gate. Evidence can become stale when its bound candidate or tree changes; reconcile findings against the
|
|
123
126
|
current candidate before recording completion.
|
|
124
127
|
|
|
125
128
|
Workit validates domain policy against the actual action target, configured
|
package/src/core/setup.ts
CHANGED
|
@@ -52,6 +52,8 @@ import { writeFileExclusive } from "./safe-write";
|
|
|
52
52
|
import {
|
|
53
53
|
isCodexWorkitInstalled,
|
|
54
54
|
isPiWorkitInstalled,
|
|
55
|
+
isClaudeWorkitInstalled,
|
|
56
|
+
claudeConfigDir,
|
|
55
57
|
planHostInstall,
|
|
56
58
|
runHostInstall,
|
|
57
59
|
type HostCommandRunner,
|
|
@@ -385,25 +387,27 @@ export function buildSetupPreview(
|
|
|
385
387
|
mutations.push({ type: "install-adapter", platform, path: paths.cursorPluginDir });
|
|
386
388
|
mutations.push({ type: "register-platform", platform, path: paths.cursorSettings });
|
|
387
389
|
mutations.push({ type: "register-platform", platform, path: paths.cursorMcp });
|
|
388
|
-
} else if (platform === "codex" || platform === "pi") {
|
|
390
|
+
} else if (platform === "codex" || platform === "pi" || platform === "claude-code") {
|
|
389
391
|
const commands = planHostInstall(platform, { home: paths.home, cwd: paths.cwd, env });
|
|
390
392
|
if (commands.length > 0) {
|
|
391
393
|
mutations.push({
|
|
392
394
|
type: "install-host",
|
|
393
395
|
platform,
|
|
394
396
|
path:
|
|
395
|
-
platform === "
|
|
396
|
-
?
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
397
|
+
platform === "claude-code"
|
|
398
|
+
? claudeInstallRecord(paths.home, env)
|
|
399
|
+
: platform === "codex"
|
|
400
|
+
? path.join(
|
|
401
|
+
env.CODEX_HOME ?? path.join(paths.home, ".codex"),
|
|
402
|
+
"plugins",
|
|
403
|
+
"cache",
|
|
404
|
+
"workflow-toolkit",
|
|
405
|
+
"workit",
|
|
406
|
+
)
|
|
407
|
+
: path.join(
|
|
408
|
+
env.PI_CODING_AGENT_DIR ?? path.join(paths.home, ".pi", "agent"),
|
|
409
|
+
"settings.json",
|
|
410
|
+
),
|
|
407
411
|
commands,
|
|
408
412
|
});
|
|
409
413
|
}
|
|
@@ -446,6 +450,10 @@ export function buildSetupPreview(
|
|
|
446
450
|
// Apply (Task 14: WZ-09, WZ-10, WZ-13-WZ-15; CA-08, CA-13, CA-14, CA-31).
|
|
447
451
|
// ---------------------------------------------------------------------------
|
|
448
452
|
|
|
453
|
+
/** The Claude Code plugin registry a Workit install is recorded in. */
|
|
454
|
+
const claudeInstallRecord = (home: string, env: NodeJS.ProcessEnv): string =>
|
|
455
|
+
path.join(claudeConfigDir(home, env), "plugins", "installed_plugins.json");
|
|
456
|
+
|
|
449
457
|
export type Platform = "opencode" | "cursor";
|
|
450
458
|
|
|
451
459
|
export type SetupResultStatus = "Installed" | "Configured" | "Skipped" | "Failed";
|
|
@@ -1250,7 +1258,9 @@ export function applySetupPreview(
|
|
|
1250
1258
|
res.env.PI_CODING_AGENT_DIR ?? path.join(res.home, ".pi", "agent"),
|
|
1251
1259
|
"settings.json",
|
|
1252
1260
|
)
|
|
1253
|
-
: ""
|
|
1261
|
+
: mutation.platform === "claude-code"
|
|
1262
|
+
? claudeInstallRecord(res.home, res.env)
|
|
1263
|
+
: "";
|
|
1254
1264
|
if (mutation.path !== expectedPath) {
|
|
1255
1265
|
entries.push({
|
|
1256
1266
|
platform: mutation.platform,
|
|
@@ -1292,7 +1302,9 @@ export function applySetupPreview(
|
|
|
1292
1302
|
? isCodexWorkitInstalled(res.home, res.env)
|
|
1293
1303
|
: mutation.platform === "pi"
|
|
1294
1304
|
? isPiWorkitInstalled(res.home, res.env)
|
|
1295
|
-
:
|
|
1305
|
+
: mutation.platform === "claude-code"
|
|
1306
|
+
? isClaudeWorkitInstalled(res.home, res.env, null)
|
|
1307
|
+
: true;
|
|
1296
1308
|
if (!verified) {
|
|
1297
1309
|
entries.push({
|
|
1298
1310
|
platform: mutation.platform,
|
|
@@ -16,5 +16,9 @@ export const SUPPORT_MATRIX = {
|
|
|
16
16
|
// an installed CLI drifts ahead so a fresh install never silently outruns
|
|
17
17
|
// the qualification pin.
|
|
18
18
|
codex: { cli: "0.153.4", desktopPackage: "26.901.20858", bundledCodexCli: "0.153.0-alpha.5" },
|
|
19
|
+
// Claude Code is a qualification host too: CI runs `claude plugin validate
|
|
20
|
+
// --strict` with exactly this CLI, and the hook output schema fixture
|
|
21
|
+
// (test/fixtures/claude-code-schemas) was transcribed from this binary.
|
|
22
|
+
claudeCode: { cli: "2.1.288" },
|
|
19
23
|
os: ["ubuntu-latest", "macos-latest", "windows-latest"],
|
|
20
24
|
} as const;
|
package/src/core/task-context.ts
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type TaskView,
|
|
14
14
|
type Utc,
|
|
15
15
|
} from "./task-contract";
|
|
16
|
-
import { captureCandidate, evaluateEvidence } from "./task-evaluation";
|
|
16
|
+
import { captureCandidate, evaluateEvidence, treeFreshness } from "./task-evaluation";
|
|
17
17
|
import { selectMethods, type SelectedMethod } from "./methods";
|
|
18
18
|
import type { NativeWorkerObservation } from "./workers";
|
|
19
19
|
|
|
@@ -39,7 +39,11 @@ export function reconcileResume(
|
|
|
39
39
|
return failure("permission_denied", "worker observations require native host verification");
|
|
40
40
|
const candidate = captureCandidate(view.workspace.root, view.task.intent.data.scope, []);
|
|
41
41
|
if (!candidate.ok) return candidate;
|
|
42
|
-
const staleEvidenceIds = evaluateEvidence(
|
|
42
|
+
const staleEvidenceIds = evaluateEvidence(
|
|
43
|
+
view.task,
|
|
44
|
+
candidate.data,
|
|
45
|
+
treeFreshness(view.workspace.root),
|
|
46
|
+
)
|
|
43
47
|
.filter((entry) => entry.status === "stale")
|
|
44
48
|
.map((entry) => entry.evidenceId);
|
|
45
49
|
const blockers = [...view.task.progress.blockers];
|
|
@@ -541,20 +541,78 @@ export const candidateSchema = z
|
|
|
541
541
|
});
|
|
542
542
|
});
|
|
543
543
|
export type Candidate = z.infer<typeof candidateSchema>;
|
|
544
|
-
|
|
544
|
+
/**
|
|
545
|
+
* What `workit check` observed (design §2.1 S9, §2.2). Only the CLI's
|
|
546
|
+
* observing path writes it (WorkitCore.observeCheck); the agent-facing
|
|
547
|
+
* `evidence.record` operation rejects it. A task that holds one lists
|
|
548
|
+
* `evidence.*.data.observation` in `critical`, so an older reader fails
|
|
549
|
+
* closed instead of reading the check without it.
|
|
550
|
+
*/
|
|
551
|
+
export const checkObservationSchema = z
|
|
545
552
|
.object({
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
553
|
+
observer: z.literal("workit_cli"),
|
|
554
|
+
/** The check name (`--name` or `workit check <name>`); null for an unnamed ad-hoc run. */
|
|
555
|
+
name: nonEmpty.nullable(),
|
|
556
|
+
/** The name is configured and argv is exactly its configured command, run from the repo top. */
|
|
557
|
+
configured: z.boolean(),
|
|
558
|
+
argv: z.array(text).min(1),
|
|
559
|
+
shell: z.boolean(),
|
|
560
|
+
/** Working directory relative to the repository top (posix), `.` at the top. */
|
|
561
|
+
cwd: nonEmpty,
|
|
562
|
+
exitCode: safeInteger,
|
|
563
|
+
durationMs: safeInteger.min(0),
|
|
564
|
+
timedOut: z.boolean(),
|
|
565
|
+
head: text.nullable(),
|
|
566
|
+
/** Worktree tree key before the run; evidence is fresh while the tree key is unchanged. */
|
|
567
|
+
tree: text.nullable(),
|
|
568
|
+
dirty: z.boolean().nullable(),
|
|
569
|
+
/** Cheap stat-cached worktree signal before the run (per-turn freshness; see worktreeSignal). */
|
|
570
|
+
signal: text.nullable(),
|
|
571
|
+
/** Tree key after the run; differs from `tree` when the command changed the worktree. */
|
|
572
|
+
treeAfter: text.nullable(),
|
|
573
|
+
/** The command changed the worktree: the evidence is stale (fail safe). */
|
|
574
|
+
modifiedWorktree: z.boolean(),
|
|
575
|
+
base: text.nullable(),
|
|
576
|
+
patchId: text.nullable(),
|
|
577
|
+
/** A minimal environment fingerprint: platform, arch and allowlisted variables. */
|
|
578
|
+
environment: z
|
|
579
|
+
.object({
|
|
580
|
+
platform: nonEmpty,
|
|
581
|
+
arch: nonEmpty,
|
|
582
|
+
vars: z.record(nonEmpty, text.nullable()),
|
|
583
|
+
})
|
|
584
|
+
.strict(),
|
|
585
|
+
logDigest: text.nullable(),
|
|
586
|
+
logRef: text.nullable(),
|
|
587
|
+
logTail: z.array(text).max(40),
|
|
588
|
+
ledgerRowId: text.nullable(),
|
|
589
|
+
/** Set only when a host hook later attests the run (design §2.1 attestation). */
|
|
590
|
+
attestation: z
|
|
591
|
+
.object({ host: nonEmpty, session: text.nullable(), agentId: text.nullable() })
|
|
592
|
+
.strict()
|
|
593
|
+
.nullable(),
|
|
556
594
|
})
|
|
557
595
|
.strict();
|
|
596
|
+
export type CheckObservation = z.infer<typeof checkObservationSchema>;
|
|
597
|
+
/** The record path a task lists in `critical` once it stores a check observation. */
|
|
598
|
+
export const CHECK_OBSERVATION_PATH = "evidence.*.data.observation";
|
|
599
|
+
const evidenceFields = {
|
|
600
|
+
kind: z.enum(["check", "review", "investigation", "artifact"]),
|
|
601
|
+
claim: text,
|
|
602
|
+
requirementIds: z.array(digest),
|
|
603
|
+
beforeCandidateId: nullableDigest.optional(),
|
|
604
|
+
candidateId: nullableDigest.optional(),
|
|
605
|
+
result: z.enum(["passed", "failed", "missing", "skipped"]),
|
|
606
|
+
summary: text,
|
|
607
|
+
refs: z.array(refSchema),
|
|
608
|
+
exitCode: safeInteger.nullable(),
|
|
609
|
+
reviewContext: refSchema.nullable(),
|
|
610
|
+
};
|
|
611
|
+
export const evidenceSchema = z
|
|
612
|
+
.object({ ...evidenceFields, observation: checkObservationSchema.optional() })
|
|
613
|
+
.strict();
|
|
614
|
+
/** Evidence as an agent may submit it: no CLI observation. */
|
|
615
|
+
const reportedEvidenceSchema = z.object(evidenceFields).strict();
|
|
558
616
|
export type Evidence = z.infer<typeof evidenceSchema>;
|
|
559
617
|
export const evidenceEvaluationSchema = z
|
|
560
618
|
.object({
|
|
@@ -1015,7 +1073,7 @@ const evidenceOperations = {
|
|
|
1015
1073
|
action: z.literal("record"),
|
|
1016
1074
|
...taskId,
|
|
1017
1075
|
expectedRevision: revision.optional(),
|
|
1018
|
-
evidence:
|
|
1076
|
+
evidence: reportedEvidenceSchema,
|
|
1019
1077
|
}),
|
|
1020
1078
|
};
|
|
1021
1079
|
const findingOperations = {
|
package/src/core/task-engine.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
|
+
CHECK_OBSERVATION_PATH,
|
|
3
4
|
POLICY_VERSION,
|
|
4
5
|
canonicalJson,
|
|
6
|
+
checkObservationSchema,
|
|
5
7
|
failure,
|
|
6
8
|
decisionDigest,
|
|
7
9
|
exportBundleSchema,
|
|
@@ -18,6 +20,7 @@ import {
|
|
|
18
20
|
type Candidate,
|
|
19
21
|
type Capability,
|
|
20
22
|
type Caller,
|
|
23
|
+
type CheckObservation,
|
|
21
24
|
type Constraint,
|
|
22
25
|
type Decision,
|
|
23
26
|
type Entry,
|
|
@@ -83,6 +86,9 @@ import {
|
|
|
83
86
|
import {
|
|
84
87
|
captureCandidate,
|
|
85
88
|
checkPin,
|
|
89
|
+
signalFreshness,
|
|
90
|
+
treeFreshness,
|
|
91
|
+
type Freshness,
|
|
86
92
|
evaluateClosure,
|
|
87
93
|
evaluateEvidence,
|
|
88
94
|
evaluateRequirements,
|
|
@@ -111,6 +117,13 @@ export type OperationContext = {
|
|
|
111
117
|
workerId?: string | null;
|
|
112
118
|
};
|
|
113
119
|
|
|
120
|
+
export type ObserveCheckRequest = {
|
|
121
|
+
taskId: string;
|
|
122
|
+
/** Omit to retry over concurrent writers (S2b); a value is strict compare-and-swap. */
|
|
123
|
+
expectedRevision?: string;
|
|
124
|
+
observation: CheckObservation;
|
|
125
|
+
};
|
|
126
|
+
|
|
114
127
|
const provenance = (
|
|
115
128
|
context: OperationContext,
|
|
116
129
|
kind: "host_observed" | "agent_reported" = context.provenanceKind ?? "host_observed",
|
|
@@ -1210,10 +1223,11 @@ export class WorkitCore {
|
|
|
1210
1223
|
// dispositions never auto-reopen.
|
|
1211
1224
|
const withEntry = { ...current, candidates, evidence: [...current.evidence, entry] };
|
|
1212
1225
|
const statuses = new Map(
|
|
1213
|
-
evaluateEvidence(
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1226
|
+
evaluateEvidence(
|
|
1227
|
+
withEntry,
|
|
1228
|
+
currentCandidate.data,
|
|
1229
|
+
treeFreshness(this.store.root),
|
|
1230
|
+
).map((item) => [item.evidenceId, item.status]),
|
|
1217
1231
|
);
|
|
1218
1232
|
const verified = withEntry.evidence.some((item) =>
|
|
1219
1233
|
findingVerificationPasses(finding.data.candidateId, {
|
|
@@ -1239,6 +1253,91 @@ export class WorkitCore {
|
|
|
1239
1253
|
return success(changed.data.revision, null, changed.data.evidence.at(-1)!);
|
|
1240
1254
|
}
|
|
1241
1255
|
|
|
1256
|
+
/**
|
|
1257
|
+
* Record a check the CLI itself ran and observed (`workit check`, design
|
|
1258
|
+
* §2.1 S9). Host-only, like observeDecision: the caller must be the workit
|
|
1259
|
+
* CLI (never a helper), and agents reach evidence only through
|
|
1260
|
+
* `evidence.record`, which stays agent_reported and cannot carry an
|
|
1261
|
+
* observation. The entry is `host_observed` by `workit_cli`, keyed to the
|
|
1262
|
+
* worktree tree in the observation, and the task lists the observation
|
|
1263
|
+
* path as critical so an older reader fails closed instead of dropping it.
|
|
1264
|
+
* Outside git (no tree key) it is bound to the current candidate instead.
|
|
1265
|
+
*/
|
|
1266
|
+
observeCheck(request: ObserveCheckRequest): Result<Entry<Evidence>> {
|
|
1267
|
+
return this.retryOmittedRevisions(request, () => this.observeCheckOnce(request));
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
private observeCheckOnce(request: ObserveCheckRequest): Result<Entry<Evidence>> {
|
|
1271
|
+
const root = this.contextRootError();
|
|
1272
|
+
if (!root.ok) return root;
|
|
1273
|
+
if (this.context.caller.host !== "workit_cli" || (this.context.workerId ?? null) !== null)
|
|
1274
|
+
return failure("permission_denied", "only the workit CLI records observed checks");
|
|
1275
|
+
const observation = checkObservationSchema.safeParse(request?.observation);
|
|
1276
|
+
if (!observation.success)
|
|
1277
|
+
return failure("invalid_input", "check observation is invalid", {
|
|
1278
|
+
fields: observation.error.issues.map((issue) => ({
|
|
1279
|
+
path: issue.path.join("."),
|
|
1280
|
+
reason: issue.message,
|
|
1281
|
+
})),
|
|
1282
|
+
});
|
|
1283
|
+
const task = this.store.readTask(request.taskId);
|
|
1284
|
+
if (!task.ok) return task;
|
|
1285
|
+
if (task.data.status === "closed")
|
|
1286
|
+
return failure("invalid_transition", "closed task cannot record evidence");
|
|
1287
|
+
const observed: CheckObservation = observation.data;
|
|
1288
|
+
let candidate: Candidate | null = null;
|
|
1289
|
+
if (observed.tree === null) {
|
|
1290
|
+
const captured = captureCandidate(
|
|
1291
|
+
this.store.root,
|
|
1292
|
+
task.data.intent.data.scope,
|
|
1293
|
+
environment(),
|
|
1294
|
+
);
|
|
1295
|
+
if (!captured.ok) return captured;
|
|
1296
|
+
candidate = captured.data;
|
|
1297
|
+
}
|
|
1298
|
+
const label = observed.name ?? observed.argv.join(" ");
|
|
1299
|
+
const data: Evidence = {
|
|
1300
|
+
kind: "check",
|
|
1301
|
+
claim: `workit check ${label}`.slice(0, 500),
|
|
1302
|
+
requirementIds: [],
|
|
1303
|
+
...(candidate ? { beforeCandidateId: candidate.id, candidateId: candidate.id } : {}),
|
|
1304
|
+
result: observed.exitCode === 0 ? "passed" : "failed",
|
|
1305
|
+
summary: `${observed.configured ? "configured" : "ad-hoc"} check exited ${observed.exitCode}${observed.timedOut ? " (timed out)" : ""} in ${observed.durationMs} ms`,
|
|
1306
|
+
refs: [],
|
|
1307
|
+
exitCode: observed.exitCode,
|
|
1308
|
+
reviewContext: null,
|
|
1309
|
+
observation: observed,
|
|
1310
|
+
};
|
|
1311
|
+
const changed = this.store.mutateTask(
|
|
1312
|
+
task.data.id,
|
|
1313
|
+
request.expectedRevision ?? task.data.revision,
|
|
1314
|
+
(current, mutation) => {
|
|
1315
|
+
const candidates =
|
|
1316
|
+
candidate === null || current.candidates.some((item) => item.id === candidate.id)
|
|
1317
|
+
? current.candidates
|
|
1318
|
+
: [...current.candidates, candidate];
|
|
1319
|
+
const critical = [...new Set([...(current.critical ?? []), CHECK_OBSERVATION_PATH])];
|
|
1320
|
+
return success(mutation.revision, null, {
|
|
1321
|
+
...current,
|
|
1322
|
+
critical,
|
|
1323
|
+
candidates,
|
|
1324
|
+
evidence: [
|
|
1325
|
+
...current.evidence,
|
|
1326
|
+
{
|
|
1327
|
+
id: newId(),
|
|
1328
|
+
recordedAt: mutation.now,
|
|
1329
|
+
provenance: provenance(this.context, "host_observed"),
|
|
1330
|
+
data,
|
|
1331
|
+
},
|
|
1332
|
+
],
|
|
1333
|
+
});
|
|
1334
|
+
},
|
|
1335
|
+
trustedNow(this.context),
|
|
1336
|
+
);
|
|
1337
|
+
if (!changed.ok) return changed;
|
|
1338
|
+
return success(changed.data.revision, null, changed.data.evidence.at(-1)!);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1242
1341
|
decision(request: unknown): Result<Entry<Decision>> {
|
|
1243
1342
|
return this.recordDecision(request);
|
|
1244
1343
|
}
|
|
@@ -1554,7 +1653,7 @@ export class WorkitCore {
|
|
|
1554
1653
|
if (input.disposition === "fixed") {
|
|
1555
1654
|
const current = captureCandidate(this.store.root, task.data.intent.data.scope, environment());
|
|
1556
1655
|
if (!current.ok) return current;
|
|
1557
|
-
const evaluations = evaluateEvidence(task.data, current.data);
|
|
1656
|
+
const evaluations = evaluateEvidence(task.data, current.data, treeFreshness(this.store.root));
|
|
1558
1657
|
const verified = evidence.some((entry) => {
|
|
1559
1658
|
const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
|
|
1560
1659
|
return (
|
|
@@ -2456,6 +2555,7 @@ export class WorkitCore {
|
|
|
2456
2555
|
if (!current.ok) return current;
|
|
2457
2556
|
const evaluationWorkspace =
|
|
2458
2557
|
task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
|
|
2558
|
+
const tree = treeFreshness(this.store.root);
|
|
2459
2559
|
const requirements = evaluateRequirements(
|
|
2460
2560
|
task,
|
|
2461
2561
|
evaluationWorkspace,
|
|
@@ -2463,6 +2563,7 @@ export class WorkitCore {
|
|
|
2463
2563
|
current.data,
|
|
2464
2564
|
this.store.root,
|
|
2465
2565
|
this.context.caller,
|
|
2566
|
+
tree,
|
|
2466
2567
|
);
|
|
2467
2568
|
return success(task.revision, workspace.data.revision, {
|
|
2468
2569
|
id: task.id,
|
|
@@ -2487,19 +2588,24 @@ export class WorkitCore {
|
|
|
2487
2588
|
* judged against the last recorded candidate, and live staleness is
|
|
2488
2589
|
* detected by evidence, close, resume, and full inspection, which capture.
|
|
2489
2590
|
*/
|
|
2490
|
-
compactContext(taskId: string): Result<string> {
|
|
2591
|
+
compactContext(taskId: string, freshness?: Freshness): Result<string> {
|
|
2491
2592
|
const root = this.contextRootError();
|
|
2492
2593
|
if (!root.ok) return root;
|
|
2493
2594
|
const task = this.store.readTask(taskId);
|
|
2494
2595
|
if (!task.ok) return task;
|
|
2495
2596
|
const helper = this.helperTaskGuard(task.data, true);
|
|
2496
2597
|
if (!helper.ok) return helper;
|
|
2497
|
-
|
|
2598
|
+
// Per-turn path: the cheap worktree signal, never the object-writing tree hash.
|
|
2599
|
+
const view = this.view(task.data, false, freshness ?? signalFreshness(this.store.root));
|
|
2498
2600
|
if (!view.ok) return view;
|
|
2499
2601
|
return success(view.revision, view.workspaceRevision, compactTaskContext(view.data));
|
|
2500
2602
|
}
|
|
2501
2603
|
|
|
2502
|
-
private view(
|
|
2604
|
+
private view(
|
|
2605
|
+
task: TaskRecord,
|
|
2606
|
+
capture = true,
|
|
2607
|
+
freshness: Freshness = treeFreshness(this.store.root),
|
|
2608
|
+
): Result<TaskView> {
|
|
2503
2609
|
const workspace = this.store.readWorkspace();
|
|
2504
2610
|
if (!workspace.ok) return workspace;
|
|
2505
2611
|
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
@@ -2512,6 +2618,7 @@ export class WorkitCore {
|
|
|
2512
2618
|
if (!current.ok) return current;
|
|
2513
2619
|
const evaluationWorkspace =
|
|
2514
2620
|
task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
|
|
2621
|
+
const tree = freshness;
|
|
2515
2622
|
const requirements = evaluateRequirements(
|
|
2516
2623
|
task,
|
|
2517
2624
|
evaluationWorkspace,
|
|
@@ -2519,13 +2626,14 @@ export class WorkitCore {
|
|
|
2519
2626
|
current.data,
|
|
2520
2627
|
this.store.root,
|
|
2521
2628
|
this.context.caller,
|
|
2629
|
+
tree,
|
|
2522
2630
|
);
|
|
2523
2631
|
return success(task.revision, workspace.data.revision, {
|
|
2524
2632
|
task,
|
|
2525
2633
|
workspace: evaluationWorkspace,
|
|
2526
2634
|
capabilities: this.context.capabilities,
|
|
2527
2635
|
requirements,
|
|
2528
|
-
evidence: evaluateEvidence(task, current.data),
|
|
2636
|
+
evidence: evaluateEvidence(task, current.data, tree),
|
|
2529
2637
|
});
|
|
2530
2638
|
}
|
|
2531
2639
|
|
|
@@ -2797,6 +2905,7 @@ export class WorkitCore {
|
|
|
2797
2905
|
withCandidate.id === task.data.candidates.at(-1)?.id
|
|
2798
2906
|
? task.data
|
|
2799
2907
|
: { ...task.data, candidates: [...task.data.candidates, withCandidate] };
|
|
2908
|
+
const tree = treeFreshness(this.store.root);
|
|
2800
2909
|
const requirements = evaluateRequirements(
|
|
2801
2910
|
taskForView,
|
|
2802
2911
|
workspace.data,
|
|
@@ -2804,13 +2913,14 @@ export class WorkitCore {
|
|
|
2804
2913
|
withCandidate,
|
|
2805
2914
|
this.store.root,
|
|
2806
2915
|
this.context.caller,
|
|
2916
|
+
tree,
|
|
2807
2917
|
);
|
|
2808
2918
|
const view = {
|
|
2809
2919
|
task: taskForView,
|
|
2810
2920
|
workspace: workspace.data,
|
|
2811
2921
|
capabilities: this.context.capabilities,
|
|
2812
2922
|
requirements,
|
|
2813
|
-
evidence: evaluateEvidence(taskForView, withCandidate),
|
|
2923
|
+
evidence: evaluateEvidence(taskForView, withCandidate, tree),
|
|
2814
2924
|
} satisfies TaskView;
|
|
2815
2925
|
const closure = evaluateClosure(input.outcome, view);
|
|
2816
2926
|
if (!closure.ok) return closure;
|