@workit-dev/core 0.0.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,1147 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
POLICY_VERSION,
|
|
7
|
+
candidateDigest,
|
|
8
|
+
candidateSchema,
|
|
9
|
+
failure,
|
|
10
|
+
scopeCovers as contractScopeCovers,
|
|
11
|
+
success,
|
|
12
|
+
type Candidate,
|
|
13
|
+
type Capability,
|
|
14
|
+
type Caller,
|
|
15
|
+
type Decision,
|
|
16
|
+
type Entry,
|
|
17
|
+
type Evidence,
|
|
18
|
+
type EvidenceEvaluation,
|
|
19
|
+
type Outcome,
|
|
20
|
+
type Requirement,
|
|
21
|
+
type TaskBrief,
|
|
22
|
+
type TaskSummary,
|
|
23
|
+
type Result,
|
|
24
|
+
type Scope,
|
|
25
|
+
type TaskRecord,
|
|
26
|
+
type TaskView,
|
|
27
|
+
type WorkspaceRecord,
|
|
28
|
+
} from "./task-contract";
|
|
29
|
+
import {
|
|
30
|
+
CHECKS_FILE,
|
|
31
|
+
checkCommand,
|
|
32
|
+
gateCheckNames,
|
|
33
|
+
loadCheckConfig,
|
|
34
|
+
matchesNamedCheck,
|
|
35
|
+
type CheckConfig,
|
|
36
|
+
} from "../check-config";
|
|
37
|
+
import { worktreeSignal, worktreeTree } from "../git/rev";
|
|
38
|
+
import { runtimeVersion } from "./task-store";
|
|
39
|
+
import { derivedRuleStatus, ledgerView } from "./policy/derive";
|
|
40
|
+
|
|
41
|
+
export type CandidateEnvironment =
|
|
42
|
+
| readonly (string | { name: string; value?: string | null })[]
|
|
43
|
+
| Record<string, string | null | undefined>;
|
|
44
|
+
|
|
45
|
+
const digestBytes = (value: string | Buffer): string =>
|
|
46
|
+
createHash("sha256").update(value).digest("hex");
|
|
47
|
+
const inside = (root: string, candidate: string): boolean =>
|
|
48
|
+
candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
49
|
+
const relative = (root: string, candidate: string): string =>
|
|
50
|
+
path.relative(root, candidate).split(path.sep).join("/") || ".";
|
|
51
|
+
const canonicalPath = (value: string): string | null => {
|
|
52
|
+
if (typeof value !== "string" || !value) return null;
|
|
53
|
+
if (value.includes("\\") || value.split("/").includes("..")) return null;
|
|
54
|
+
const normalized = path.posix.normalize(value);
|
|
55
|
+
if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/"))
|
|
56
|
+
return null;
|
|
57
|
+
return normalized === "." ? "." : normalized.replace(/\/$/, "");
|
|
58
|
+
};
|
|
59
|
+
const canonicalScope = (scope: Scope): Scope | null => {
|
|
60
|
+
const paths = scope.paths.length ? scope.paths : ["."];
|
|
61
|
+
const normalizedPaths = paths.map(canonicalPath);
|
|
62
|
+
const normalizedExclusions = scope.exclusions.map(canonicalPath);
|
|
63
|
+
if (
|
|
64
|
+
normalizedPaths.some((value) => value === null) ||
|
|
65
|
+
normalizedExclusions.some((value) => value === null)
|
|
66
|
+
)
|
|
67
|
+
return null;
|
|
68
|
+
return {
|
|
69
|
+
description: scope.description,
|
|
70
|
+
paths: [...new Set(normalizedPaths as string[])],
|
|
71
|
+
exclusions: [...new Set(normalizedExclusions as string[])],
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
const excluded = (value: string, exclusions: string[]): boolean =>
|
|
75
|
+
exclusions.some((item) => item === value || (item !== "." && value.startsWith(`${item}/`)));
|
|
76
|
+
const scopeMatches = (value: string, scope: Scope): boolean => {
|
|
77
|
+
const normalized = canonicalScope(scope);
|
|
78
|
+
if (!normalized) return false;
|
|
79
|
+
const paths = normalized.paths;
|
|
80
|
+
return (
|
|
81
|
+
paths.some((item) => item === "." || value === item || value.startsWith(`${item}/`)) &&
|
|
82
|
+
!excluded(value, normalized.exclusions)
|
|
83
|
+
);
|
|
84
|
+
};
|
|
85
|
+
const pathRelevant = (value: string, scope: Scope): boolean => scopeMatches(value, scope);
|
|
86
|
+
const compareCodeUnits = (left: string, right: string): number => {
|
|
87
|
+
const length = Math.min(left.length, right.length);
|
|
88
|
+
for (let index = 0; index < length; index += 1) {
|
|
89
|
+
const difference = left.charCodeAt(index) - right.charCodeAt(index);
|
|
90
|
+
if (difference !== 0) return difference;
|
|
91
|
+
}
|
|
92
|
+
return left.length - right.length;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type Inventory = { paths: string[]; uncertain: boolean; git: boolean };
|
|
96
|
+
const gitPaths = (root: string): Inventory => {
|
|
97
|
+
const result = spawnSync(
|
|
98
|
+
"git",
|
|
99
|
+
["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
100
|
+
{
|
|
101
|
+
cwd: root,
|
|
102
|
+
encoding: "buffer",
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
if (result.status !== 0) {
|
|
106
|
+
const stderr = result.stderr?.toString("utf8") ?? "";
|
|
107
|
+
return {
|
|
108
|
+
paths: [],
|
|
109
|
+
uncertain: !/not a git repository/i.test(stderr),
|
|
110
|
+
git: !/not a git repository/i.test(stderr),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const paths = result.stdout
|
|
114
|
+
? result.stdout
|
|
115
|
+
.toString("utf8")
|
|
116
|
+
.split("\0")
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.map((item) => item.split(path.sep).join("/"))
|
|
119
|
+
: [];
|
|
120
|
+
const stagedDeleted = spawnSync(
|
|
121
|
+
"git",
|
|
122
|
+
["diff", "--cached", "--name-only", "--diff-filter=D", "-z"],
|
|
123
|
+
{ cwd: root, encoding: "buffer" },
|
|
124
|
+
);
|
|
125
|
+
if (stagedDeleted.status !== 0) {
|
|
126
|
+
const stderr = stagedDeleted.stderr?.toString("utf8") ?? "";
|
|
127
|
+
if (!/not a git repository/i.test(stderr)) return { paths, uncertain: true, git: true };
|
|
128
|
+
} else if (stagedDeleted.stdout) {
|
|
129
|
+
paths.push(
|
|
130
|
+
...stagedDeleted.stdout
|
|
131
|
+
.toString("utf8")
|
|
132
|
+
.split("\0")
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.map((item) => item.split(path.sep).join("/")),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return { paths: [...new Set(paths)], uncertain: false, git: true };
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const walk = (root: string, directory: string, output: Set<string>): boolean => {
|
|
141
|
+
let names: string[];
|
|
142
|
+
try {
|
|
143
|
+
names = fs.readdirSync(directory);
|
|
144
|
+
} catch {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
let uncertain = false;
|
|
148
|
+
for (const name of names) {
|
|
149
|
+
if (name === ".git" || name === ".workit") continue;
|
|
150
|
+
const target = path.join(directory, name);
|
|
151
|
+
let stat: fs.Stats;
|
|
152
|
+
try {
|
|
153
|
+
stat = fs.lstatSync(target);
|
|
154
|
+
} catch {
|
|
155
|
+
uncertain = true;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const item = relative(root, target);
|
|
159
|
+
output.add(item);
|
|
160
|
+
if (stat.isDirectory() && !stat.isSymbolicLink())
|
|
161
|
+
uncertain = walk(root, target, output) || uncertain;
|
|
162
|
+
}
|
|
163
|
+
return uncertain;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const scopeRoots = (root: string, scope: Scope): Result<string[]> => {
|
|
167
|
+
const roots: string[] = [];
|
|
168
|
+
for (const item of scope.paths.length ? scope.paths : ["."]) {
|
|
169
|
+
const target = path.resolve(root, item);
|
|
170
|
+
if (!inside(root, target))
|
|
171
|
+
return failure(
|
|
172
|
+
"invalid_input",
|
|
173
|
+
`candidate scope escapes checkout: ${item} — keep one task per repository and coordinate a separate linked task in the other checkout`,
|
|
174
|
+
{ fields: [{ path: "scope.paths", reason: `${item} is outside the checkout` }] },
|
|
175
|
+
);
|
|
176
|
+
let ancestor = target;
|
|
177
|
+
while (!fs.existsSync(ancestor) && ancestor !== root) ancestor = path.dirname(ancestor);
|
|
178
|
+
try {
|
|
179
|
+
if (!inside(root, fs.realpathSync(ancestor)))
|
|
180
|
+
return failure(
|
|
181
|
+
"invalid_input",
|
|
182
|
+
`candidate scope escapes checkout through a symlink: ${item} — keep one task per repository and coordinate a separate linked task in the other checkout`,
|
|
183
|
+
{ fields: [{ path: "scope.paths", reason: `${item} resolves outside the checkout` }] },
|
|
184
|
+
);
|
|
185
|
+
} catch {
|
|
186
|
+
return failure("invalid_input", `candidate scope cannot be inspected: ${item}`);
|
|
187
|
+
}
|
|
188
|
+
roots.push(target);
|
|
189
|
+
}
|
|
190
|
+
return success(null, null, roots);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export function captureCandidate(
|
|
194
|
+
root: string,
|
|
195
|
+
scope: Scope,
|
|
196
|
+
environment: CandidateEnvironment = [],
|
|
197
|
+
): Result<Candidate> {
|
|
198
|
+
let checkout: string;
|
|
199
|
+
try {
|
|
200
|
+
checkout = fs.realpathSync(root);
|
|
201
|
+
} catch {
|
|
202
|
+
return failure("invalid_input", "candidate root does not exist");
|
|
203
|
+
}
|
|
204
|
+
const normalizedScope = canonicalScope(scope);
|
|
205
|
+
if (!normalizedScope)
|
|
206
|
+
return failure(
|
|
207
|
+
"invalid_input",
|
|
208
|
+
"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",
|
|
209
|
+
{ fields: [{ path: "scope.paths", reason: "must be checkout-relative" }] },
|
|
210
|
+
);
|
|
211
|
+
const roots = scopeRoots(checkout, normalizedScope);
|
|
212
|
+
if (!roots.ok) return roots;
|
|
213
|
+
const git = gitPaths(checkout);
|
|
214
|
+
const names = new Set<string>();
|
|
215
|
+
let uncertain = git.uncertain;
|
|
216
|
+
for (const target of roots.data) {
|
|
217
|
+
const item = relative(checkout, target);
|
|
218
|
+
if (item === ".git" || item === ".workit") continue;
|
|
219
|
+
names.add(item);
|
|
220
|
+
if (!fs.existsSync(target)) continue;
|
|
221
|
+
let stat: fs.Stats;
|
|
222
|
+
try {
|
|
223
|
+
stat = fs.lstatSync(target);
|
|
224
|
+
} catch {
|
|
225
|
+
uncertain = true;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!git.git && stat.isDirectory() && !stat.isSymbolicLink())
|
|
229
|
+
uncertain = walk(checkout, target, names) || uncertain;
|
|
230
|
+
}
|
|
231
|
+
for (const item of git.paths) if (scopeMatches(item, normalizedScope)) names.add(item);
|
|
232
|
+
|
|
233
|
+
const files: Candidate["files"] = [];
|
|
234
|
+
let completeness: Candidate["completeness"] = uncertain ? "uncertain" : "known";
|
|
235
|
+
for (const item of [...names].toSorted()) {
|
|
236
|
+
if (!scopeMatches(item, normalizedScope)) continue;
|
|
237
|
+
const target = path.join(checkout, item);
|
|
238
|
+
try {
|
|
239
|
+
const stat = fs.lstatSync(target);
|
|
240
|
+
if (stat.isSymbolicLink()) {
|
|
241
|
+
const link = fs.readlinkSync(target);
|
|
242
|
+
files.push({ path: item, kind: "symlink", digest: digestBytes(link), executable: null });
|
|
243
|
+
} else if (stat.isFile()) {
|
|
244
|
+
files.push({
|
|
245
|
+
path: item,
|
|
246
|
+
kind: "file",
|
|
247
|
+
digest: digestBytes(fs.readFileSync(target)),
|
|
248
|
+
executable: (stat.mode & 0o111) !== 0,
|
|
249
|
+
});
|
|
250
|
+
} else if (!stat.isDirectory()) {
|
|
251
|
+
completeness = "uncertain";
|
|
252
|
+
} else if (git.git && git.paths.includes(item)) {
|
|
253
|
+
// A Git link/submodule is an inventory boundary; its contents are not
|
|
254
|
+
// represented by the parent repository's path list.
|
|
255
|
+
completeness = "uncertain";
|
|
256
|
+
}
|
|
257
|
+
} catch (error: any) {
|
|
258
|
+
if (error?.code === "ENOENT") {
|
|
259
|
+
files.push({ path: item, kind: "absent", digest: null, executable: null });
|
|
260
|
+
} else completeness = "uncertain";
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const supplied = Array.isArray(environment)
|
|
264
|
+
? environment.map((value) =>
|
|
265
|
+
typeof value === "string"
|
|
266
|
+
? { name: value, value: process.env[value] ?? null }
|
|
267
|
+
: { name: value.name, value: value.value ?? null },
|
|
268
|
+
)
|
|
269
|
+
: Object.keys(environment as Record<string, string | null | undefined>).map((name) => ({
|
|
270
|
+
name,
|
|
271
|
+
value: (environment as Record<string, string | null | undefined>)[name] ?? null,
|
|
272
|
+
}));
|
|
273
|
+
const namesByEnvironment = supplied.map(({ name }) => name);
|
|
274
|
+
if (
|
|
275
|
+
new Set(namesByEnvironment).size !== namesByEnvironment.length ||
|
|
276
|
+
namesByEnvironment.some((name) => typeof name !== "string" || !name)
|
|
277
|
+
)
|
|
278
|
+
return failure("invalid_input", "candidate environment names must be unique and non-empty");
|
|
279
|
+
const values = supplied
|
|
280
|
+
.toSorted((left, right) => compareCodeUnits(left.name, right.name))
|
|
281
|
+
.map(({ name, value }) => ({ name, value, refs: [] }));
|
|
282
|
+
if (values.some(({ value }) => value === null)) completeness = "uncertain";
|
|
283
|
+
const headResult = spawnSync("git", ["rev-parse", "HEAD"], { cwd: checkout, encoding: "utf8" });
|
|
284
|
+
if (headResult.status !== 0 && !/not a git repository/i.test(headResult.stderr ?? ""))
|
|
285
|
+
completeness = "uncertain";
|
|
286
|
+
const head = headResult.status === 0 ? headResult.stdout.trim() : null;
|
|
287
|
+
const partial: Candidate = {
|
|
288
|
+
id: "0".repeat(64),
|
|
289
|
+
scope: normalizedScope,
|
|
290
|
+
completeness,
|
|
291
|
+
files,
|
|
292
|
+
environment: values,
|
|
293
|
+
head,
|
|
294
|
+
};
|
|
295
|
+
const candidate = { ...partial, id: candidateDigest(partial) };
|
|
296
|
+
const parsed = candidateSchema.safeParse(candidate);
|
|
297
|
+
return parsed.success
|
|
298
|
+
? success(null, null, parsed.data)
|
|
299
|
+
: failure("invalid_input", "captured candidate is invalid");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const fileMap = (candidate: Candidate): Map<string, Candidate["files"][number]> =>
|
|
303
|
+
new Map(candidate.files.map((file) => [file.path, file]));
|
|
304
|
+
const changedPaths = (before: Candidate, after: Candidate): string[] => {
|
|
305
|
+
const paths = new Set([...before.files, ...after.files].map((file) => file.path));
|
|
306
|
+
const left = fileMap(before);
|
|
307
|
+
const right = fileMap(after);
|
|
308
|
+
const changed = [...paths].filter(
|
|
309
|
+
(item) => JSON.stringify(left.get(item) ?? null) !== JSON.stringify(right.get(item) ?? null),
|
|
310
|
+
);
|
|
311
|
+
if (before.head !== after.head) changed.push(".");
|
|
312
|
+
if (JSON.stringify(before.environment) !== JSON.stringify(after.environment)) changed.push(".");
|
|
313
|
+
return changed;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const sessionFromRef = (value: unknown): { host: string; handle: string } | null => {
|
|
317
|
+
if (typeof value !== "object" || value === null || (value as any).kind !== "host") return null;
|
|
318
|
+
return typeof (value as any).host === "string" && typeof (value as any).handle === "string"
|
|
319
|
+
? { host: (value as any).host, handle: (value as any).handle }
|
|
320
|
+
: null;
|
|
321
|
+
};
|
|
322
|
+
const sameSession = (left: { host: string; handle: string } | null, right: unknown): boolean => {
|
|
323
|
+
const other = sessionFromRef(right);
|
|
324
|
+
return (
|
|
325
|
+
left !== null && other !== null && left.host === other.host && left.handle === other.handle
|
|
326
|
+
);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const evidenceScopes = (task: TaskRecord, ids: string[]): Scope[] =>
|
|
330
|
+
(task.policy?.requirements ?? [])
|
|
331
|
+
.filter((requirement) => ids.includes(requirement.id))
|
|
332
|
+
.map((requirement) => requirement.scope);
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* How observed checks are judged fresh (§2.2). `tree`: the exact worktree tree
|
|
336
|
+
* key (worktreeTree hashes changed files into the object store, so it is only
|
|
337
|
+
* for paths that decide: close, inspect, evidence). `signal`: the cheap
|
|
338
|
+
* stat-cached worktreeSignal for per-turn context, which writes nothing.
|
|
339
|
+
* `current` is computed at most once and is null outside git.
|
|
340
|
+
*/
|
|
341
|
+
export type Freshness = { mode: "tree" | "signal"; current: () => string | null };
|
|
342
|
+
|
|
343
|
+
const memo = (compute: () => string | null): (() => string | null) => {
|
|
344
|
+
let value: string | null | undefined;
|
|
345
|
+
return () => {
|
|
346
|
+
if (value === undefined) value = compute();
|
|
347
|
+
return value;
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
export const treeFreshness = (root: string): Freshness => ({
|
|
351
|
+
mode: "tree",
|
|
352
|
+
current: memo(() => worktreeTree(root)?.key ?? null),
|
|
353
|
+
});
|
|
354
|
+
export const signalFreshness = (root: string): Freshness => ({
|
|
355
|
+
mode: "signal",
|
|
356
|
+
current: memo(() => worktreeSignal(root)),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
/** Evidence the workit CLI itself observed (`workit check`), as opposed to an agent's report. */
|
|
360
|
+
export const isObservedCheck = (entry: Entry<Evidence>): boolean =>
|
|
361
|
+
entry.data.kind === "check" &&
|
|
362
|
+
entry.data.observation?.observer === "workit_cli" &&
|
|
363
|
+
entry.provenance?.kind === "host_observed" &&
|
|
364
|
+
entry.provenance.host === "workit_cli";
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Evidence status against the current state. Observed checks keyed to a tree
|
|
368
|
+
* are fresh while the worktree is unchanged (§2.2), judged by `freshness`
|
|
369
|
+
* (exact tree key, or the cheap signal on per-turn paths); a check that
|
|
370
|
+
* changed the worktree itself is stale (fail safe), and a closed task keeps
|
|
371
|
+
* the recorded status. Without `freshness` they read stale.
|
|
372
|
+
*/
|
|
373
|
+
export function evaluateEvidence(
|
|
374
|
+
task: TaskRecord,
|
|
375
|
+
candidate: Candidate | null,
|
|
376
|
+
freshness?: Freshness,
|
|
377
|
+
): EvidenceEvaluation[] {
|
|
378
|
+
const current = candidate ?? task.candidates.at(-1) ?? null;
|
|
379
|
+
return task.evidence.map((entry) => {
|
|
380
|
+
const evidence = entry.data;
|
|
381
|
+
if (evidence.result === "failed")
|
|
382
|
+
return {
|
|
383
|
+
evidenceId: entry.id,
|
|
384
|
+
status: "failed" as const,
|
|
385
|
+
reason: "failed evidence remains historical",
|
|
386
|
+
};
|
|
387
|
+
const observation = isObservedCheck(entry) ? evidence.observation : undefined;
|
|
388
|
+
if (observation && observation.tree !== null) {
|
|
389
|
+
if (task.status === "closed")
|
|
390
|
+
return { evidenceId: entry.id, status: evidence.result, reason: "recorded before close" };
|
|
391
|
+
if (observation.modifiedWorktree)
|
|
392
|
+
return {
|
|
393
|
+
evidenceId: entry.id,
|
|
394
|
+
status: "stale" as const,
|
|
395
|
+
reason: "the check changed the worktree while it ran; ignore or clean its outputs",
|
|
396
|
+
};
|
|
397
|
+
const recorded = freshness?.mode === "signal" ? observation.signal : observation.tree;
|
|
398
|
+
if (recorded !== null && freshness && recorded === freshness.current())
|
|
399
|
+
return { evidenceId: entry.id, status: evidence.result, reason: "worktree is unchanged" };
|
|
400
|
+
return {
|
|
401
|
+
evidenceId: entry.id,
|
|
402
|
+
status: "stale" as const,
|
|
403
|
+
reason:
|
|
404
|
+
freshness?.mode === "signal"
|
|
405
|
+
? "the worktree may have changed since the check ran (quick signal; inspect re-checks exactly)"
|
|
406
|
+
: "the worktree changed since the check ran",
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
if (evidence.kind === "check" || evidence.kind === "review") {
|
|
410
|
+
if (!evidence.beforeCandidateId || !evidence.candidateId)
|
|
411
|
+
return {
|
|
412
|
+
evidenceId: entry.id,
|
|
413
|
+
status: "stale",
|
|
414
|
+
reason: "check or review evidence lacks start and end candidates",
|
|
415
|
+
};
|
|
416
|
+
if (evidence.beforeCandidateId !== evidence.candidateId)
|
|
417
|
+
return {
|
|
418
|
+
evidenceId: entry.id,
|
|
419
|
+
status: "stale",
|
|
420
|
+
reason: "candidate changed during the check or review",
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (!evidence.candidateId || !current)
|
|
424
|
+
return {
|
|
425
|
+
evidenceId: entry.id,
|
|
426
|
+
status: evidence.result,
|
|
427
|
+
reason: "evidence has no candidate binding",
|
|
428
|
+
};
|
|
429
|
+
const observed =
|
|
430
|
+
task.candidates.find((item) => item.id === evidence.candidateId) ??
|
|
431
|
+
(current.id === evidence.candidateId ? current : undefined);
|
|
432
|
+
if (!observed || observed.completeness === "uncertain" || current.completeness === "uncertain")
|
|
433
|
+
return { evidenceId: entry.id, status: "stale", reason: "candidate is missing or uncertain" };
|
|
434
|
+
if (observed.id === current.id)
|
|
435
|
+
return { evidenceId: entry.id, status: evidence.result, reason: "candidate is unchanged" };
|
|
436
|
+
const scopes = evidenceScopes(task, evidence.requirementIds);
|
|
437
|
+
const changed = changedPaths(observed, current);
|
|
438
|
+
const environmentChanged =
|
|
439
|
+
JSON.stringify(observed.environment) !== JSON.stringify(current.environment);
|
|
440
|
+
if (
|
|
441
|
+
!scopes.length ||
|
|
442
|
+
environmentChanged ||
|
|
443
|
+
changed.some((item) => scopes.some((scope) => pathRelevant(item, scope)))
|
|
444
|
+
)
|
|
445
|
+
return { evidenceId: entry.id, status: "stale", reason: "relevant candidate state changed" };
|
|
446
|
+
return {
|
|
447
|
+
evidenceId: entry.id,
|
|
448
|
+
status: evidence.result,
|
|
449
|
+
reason: "candidate changed outside evidence scope",
|
|
450
|
+
};
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export const scopeCovers = contractScopeCovers;
|
|
455
|
+
|
|
456
|
+
/** One shared pin check for evidence and finding records: an unpinned
|
|
457
|
+
* assignment accepts anything in scope; a pinned one accepts the pin on
|
|
458
|
+
* either candidate slot. Absent IDs behave as null. */
|
|
459
|
+
export const checkPin = (
|
|
460
|
+
assignment: { candidateId?: string | null },
|
|
461
|
+
beforeCandidateId: string | null | undefined,
|
|
462
|
+
candidateId: string | null | undefined,
|
|
463
|
+
): boolean => {
|
|
464
|
+
const pin = assignment.candidateId ?? null;
|
|
465
|
+
if (pin === null) return true;
|
|
466
|
+
return (beforeCandidateId ?? null) === pin || (candidateId ?? null) === pin;
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
/** Resolve effective evidence binding: explicit IDs win; otherwise a pinned
|
|
470
|
+
* worker defaults to its pin and everyone else to the current candidate
|
|
471
|
+
* (checks and reviews) or null. Callers validate and pin-check the resolved
|
|
472
|
+
* values — never the caller's possibly-empty fields. */
|
|
473
|
+
export const resolveBinding = (
|
|
474
|
+
evidence: {
|
|
475
|
+
beforeCandidateId?: string | null;
|
|
476
|
+
candidateId?: string | null;
|
|
477
|
+
kind: string;
|
|
478
|
+
},
|
|
479
|
+
pin: string | null | undefined,
|
|
480
|
+
currentCandidateId: string | null,
|
|
481
|
+
): { beforeCandidateId: string | null; candidateId: string | null } => {
|
|
482
|
+
const bound = evidence.kind === "check" || evidence.kind === "review";
|
|
483
|
+
const fallback = bound ? (pin ?? currentCandidateId) : null;
|
|
484
|
+
return {
|
|
485
|
+
beforeCandidateId: evidence.beforeCandidateId ?? fallback,
|
|
486
|
+
candidateId: evidence.candidateId ?? fallback,
|
|
487
|
+
};
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/** Evidence evaluation establishes freshness against the current tree;
|
|
491
|
+
* the finding's original candidate identifies where the defect was found. */
|
|
492
|
+
export const findingVerificationPasses = (
|
|
493
|
+
findingCandidateId: string | null | undefined,
|
|
494
|
+
evidence: { kind: string; candidateId: string | null | undefined; status: string },
|
|
495
|
+
): boolean =>
|
|
496
|
+
evidence.status === "passed" &&
|
|
497
|
+
(evidence.kind === "check" || evidence.kind === "review") &&
|
|
498
|
+
((findingCandidateId ?? null) === null || evidence.candidateId != null);
|
|
499
|
+
/** A decision about a document stays current only while its bytes are unchanged. */
|
|
500
|
+
export const verifyDecisionContentAtRoot = (
|
|
501
|
+
checkoutRoot: string,
|
|
502
|
+
binding: Decision["binding"],
|
|
503
|
+
): Result<null> => {
|
|
504
|
+
let root: string;
|
|
505
|
+
try {
|
|
506
|
+
root = fs.realpathSync(checkoutRoot);
|
|
507
|
+
} catch {
|
|
508
|
+
return failure("permission_denied", "checkout root is unavailable");
|
|
509
|
+
}
|
|
510
|
+
for (const reference of binding.contentRefs) {
|
|
511
|
+
if (reference.kind !== "file") continue;
|
|
512
|
+
if (!reference.digest)
|
|
513
|
+
return failure("invalid_input", "document references require a byte digest");
|
|
514
|
+
const target = path.resolve(root, reference.path);
|
|
515
|
+
if (target !== root && !target.startsWith(`${root}${path.sep}`))
|
|
516
|
+
return failure(
|
|
517
|
+
"invalid_input",
|
|
518
|
+
"document reference escapes checkout; cite it as an external file:// reference instead",
|
|
519
|
+
{
|
|
520
|
+
fields: [
|
|
521
|
+
{ path: "contentRefs.path", reason: `${reference.path} is outside the checkout` },
|
|
522
|
+
],
|
|
523
|
+
},
|
|
524
|
+
);
|
|
525
|
+
try {
|
|
526
|
+
const below = path.relative(root, target);
|
|
527
|
+
let current = root;
|
|
528
|
+
for (const segment of below.split(path.sep)) {
|
|
529
|
+
if (!segment) continue;
|
|
530
|
+
current = path.join(current, segment);
|
|
531
|
+
const stat = fs.lstatSync(current);
|
|
532
|
+
const real = fs.realpathSync(current);
|
|
533
|
+
if (real !== root && !real.startsWith(`${root}${path.sep}`))
|
|
534
|
+
return failure(
|
|
535
|
+
"invalid_input",
|
|
536
|
+
"document reference escapes checkout; cite it as an external file:// reference instead",
|
|
537
|
+
{
|
|
538
|
+
fields: [
|
|
539
|
+
{
|
|
540
|
+
path: "contentRefs.path",
|
|
541
|
+
reason: `${reference.path} resolves outside the checkout`,
|
|
542
|
+
},
|
|
543
|
+
],
|
|
544
|
+
},
|
|
545
|
+
);
|
|
546
|
+
if (stat.isSymbolicLink())
|
|
547
|
+
return failure("invalid_input", "document reference uses a symlink");
|
|
548
|
+
if (current === target && !stat.isFile())
|
|
549
|
+
return failure("invalid_input", "document reference is not a regular file");
|
|
550
|
+
if (current !== target && !stat.isDirectory())
|
|
551
|
+
return failure("invalid_input", "document reference ancestor is not a directory");
|
|
552
|
+
}
|
|
553
|
+
if (digestBytes(fs.readFileSync(target)) !== reference.digest)
|
|
554
|
+
return failure("permission_denied", "decision document bytes have changed");
|
|
555
|
+
} catch {
|
|
556
|
+
return failure("invalid_input", "decision document is unavailable");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return success(null, null, null);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
export const applicableDecision = (
|
|
563
|
+
task: TaskRecord,
|
|
564
|
+
workspace: WorkspaceRecord,
|
|
565
|
+
requirement: Requirement,
|
|
566
|
+
checkoutRoot?: string,
|
|
567
|
+
): Decision[] =>
|
|
568
|
+
task.decisions
|
|
569
|
+
.filter((entry) => entry.provenance.kind !== "imported")
|
|
570
|
+
.map((entry) => entry.data)
|
|
571
|
+
.filter(
|
|
572
|
+
(decision) =>
|
|
573
|
+
decision.purpose === "limitation" &&
|
|
574
|
+
decision.response === "approved" &&
|
|
575
|
+
decision.revoked === null &&
|
|
576
|
+
decision.binding.taskId === task.id &&
|
|
577
|
+
decision.binding.workspaceId === workspace.id &&
|
|
578
|
+
decision.requirementIds.includes(requirement.id) &&
|
|
579
|
+
decision.binding.scope &&
|
|
580
|
+
scopeCovers(decision.binding.scope, requirement.scope) &&
|
|
581
|
+
(checkoutRoot
|
|
582
|
+
? verifyDecisionContentAtRoot(checkoutRoot, decision.binding).ok
|
|
583
|
+
: decision.binding.contentRefs.every((reference) => reference.kind !== "file")),
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
const applicableRequirementDecision = (
|
|
587
|
+
task: TaskRecord,
|
|
588
|
+
workspace: WorkspaceRecord,
|
|
589
|
+
requirement: Requirement,
|
|
590
|
+
checkoutRoot?: string,
|
|
591
|
+
): { id: string }[] =>
|
|
592
|
+
task.decisions
|
|
593
|
+
.filter(
|
|
594
|
+
({ data, provenance }) =>
|
|
595
|
+
provenance.kind !== "imported" &&
|
|
596
|
+
data.purpose !== "limitation" &&
|
|
597
|
+
(data.response === "approved" || data.response === "stated") &&
|
|
598
|
+
data.revoked === null &&
|
|
599
|
+
data.binding.taskId === task.id &&
|
|
600
|
+
data.binding.workspaceId === workspace.id &&
|
|
601
|
+
data.requirementIds.includes(requirement.id) &&
|
|
602
|
+
scopeCovers(data.binding.scope, requirement.scope) &&
|
|
603
|
+
(checkoutRoot
|
|
604
|
+
? verifyDecisionContentAtRoot(checkoutRoot, data.binding).ok
|
|
605
|
+
: data.binding.contentRefs.every((reference) => reference.kind !== "file")),
|
|
606
|
+
)
|
|
607
|
+
.map(({ id }) => ({ id }));
|
|
608
|
+
|
|
609
|
+
const evidenceMatchesRequirement = (
|
|
610
|
+
kind: EvidenceEvaluation["status"],
|
|
611
|
+
evidenceKind: string,
|
|
612
|
+
dimension: Requirement["dimension"],
|
|
613
|
+
ruleId?: string,
|
|
614
|
+
): boolean => {
|
|
615
|
+
if (kind !== "passed") return false;
|
|
616
|
+
if (dimension === "testing" || dimension === "verification")
|
|
617
|
+
return evidenceKind === "check" || (ruleId === "pre-pr-cleanup" && evidenceKind === "artifact");
|
|
618
|
+
if (dimension === "review") return evidenceKind === "review";
|
|
619
|
+
if (dimension === "investigation" || dimension === "challenge")
|
|
620
|
+
return evidenceKind === "investigation";
|
|
621
|
+
if (dimension === "artifacts" || dimension === "continuity") return evidenceKind === "artifact";
|
|
622
|
+
return false;
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/** Human-readable evidence kinds each requirement dimension accepts, used
|
|
626
|
+
* so kind mismatches fail with the expected kind instead of silence. */
|
|
627
|
+
const expectedKinds = (dimension: Requirement["dimension"], ruleId?: string): string => {
|
|
628
|
+
if (dimension === "testing") return "check";
|
|
629
|
+
if (dimension === "verification")
|
|
630
|
+
return ruleId === "pre-pr-cleanup" ? "check or artifact" : "check";
|
|
631
|
+
if (dimension === "review") return "review";
|
|
632
|
+
if (dimension === "investigation" || dimension === "challenge") return "investigation";
|
|
633
|
+
if (dimension === "artifacts" || dimension === "continuity") return "artifact";
|
|
634
|
+
if (dimension === "decisions") return "decision";
|
|
635
|
+
return "worker report";
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
/** Close-time testing/verification gates accept only CLI-observed checks (D5, D14). */
|
|
639
|
+
const observedGate = (requirement: Requirement): boolean =>
|
|
640
|
+
(requirement.dimension === "testing" || requirement.dimension === "verification") &&
|
|
641
|
+
requirement.before === "close";
|
|
642
|
+
|
|
643
|
+
type GateResult = { satisfied: string[]; considered: string[]; reason: string };
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* A close gate over CLI-observed checks. Only a fresh passing run of one of
|
|
647
|
+
* the gate's named checks whose argv is still its configured command
|
|
648
|
+
* satisfies it; ad-hoc runs never do, and with no named check for the gate
|
|
649
|
+
* the reason says how to configure one. Agent-reported checks are notes and
|
|
650
|
+
* never count. A broken check config fails closed.
|
|
651
|
+
*/
|
|
652
|
+
function evaluateObservedGate(
|
|
653
|
+
task: TaskRecord,
|
|
654
|
+
requirement: Requirement,
|
|
655
|
+
statuses: Map<string, EvidenceEvaluation["status"]>,
|
|
656
|
+
config: CheckConfig,
|
|
657
|
+
): GateResult {
|
|
658
|
+
const names = gateCheckNames(config, requirement.dimension);
|
|
659
|
+
const observed = task.evidence.filter(isObservedCheck);
|
|
660
|
+
const fresh = observed.filter(
|
|
661
|
+
(entry) => statuses.get(entry.id) === "passed" && entry.data.observation?.exitCode === 0,
|
|
662
|
+
);
|
|
663
|
+
const named = (entry: Entry<Evidence>): boolean => {
|
|
664
|
+
const observation = entry.data.observation!;
|
|
665
|
+
return (
|
|
666
|
+
observation.configured &&
|
|
667
|
+
!observation.shell &&
|
|
668
|
+
names.includes(observation.name ?? "") &&
|
|
669
|
+
matchesNamedCheck(config, observation.name, observation.argv, observation.cwd)
|
|
670
|
+
);
|
|
671
|
+
};
|
|
672
|
+
const considered = observed.map((entry) => entry.id);
|
|
673
|
+
const run = checkCommand(names[0] ?? "<name>", runtimeVersion());
|
|
674
|
+
if (config.error)
|
|
675
|
+
return {
|
|
676
|
+
satisfied: [],
|
|
677
|
+
considered,
|
|
678
|
+
reason: `check config is invalid (${config.error}); fix it, then run ${run}`,
|
|
679
|
+
};
|
|
680
|
+
if (!names.length)
|
|
681
|
+
return {
|
|
682
|
+
satisfied: [],
|
|
683
|
+
considered,
|
|
684
|
+
reason:
|
|
685
|
+
requirement.dimension === "testing" && config.checks.length
|
|
686
|
+
? `no \`test\` check is configured; add one (or gates.testing) to ${CHECKS_FILE}, then run ${checkCommand("test", runtimeVersion())}`
|
|
687
|
+
: `no checks are configured or detected; add ${CHECKS_FILE}, e.g. {"checks":{"test":"<your test command>"}}, then run ${checkCommand("test", runtimeVersion())}`,
|
|
688
|
+
};
|
|
689
|
+
const satisfied = fresh.filter(named);
|
|
690
|
+
if (satisfied.length)
|
|
691
|
+
return {
|
|
692
|
+
satisfied: satisfied.map((entry) => entry.id),
|
|
693
|
+
considered,
|
|
694
|
+
reason: `a fresh passing configured check (${names.join("|")}) was observed by workit check`,
|
|
695
|
+
};
|
|
696
|
+
const reported = task.evidence.some(
|
|
697
|
+
(entry) =>
|
|
698
|
+
entry.data.kind === "check" &&
|
|
699
|
+
!isObservedCheck(entry) &&
|
|
700
|
+
entry.data.requirementIds.includes(requirement.id),
|
|
701
|
+
);
|
|
702
|
+
const reason = fresh.length
|
|
703
|
+
? `ad-hoc checks do not satisfy a configured gate; run ${run}`
|
|
704
|
+
: observed.some((entry) => statuses.get(entry.id) === "stale")
|
|
705
|
+
? `observed checks are stale (the worktree changed); run ${run}`
|
|
706
|
+
: observed.some((entry) => statuses.get(entry.id) === "failed")
|
|
707
|
+
? `the observed check failed; fix it and run ${run}`
|
|
708
|
+
: reported
|
|
709
|
+
? `agent-reported checks are notes, not evidence; run ${run}`
|
|
710
|
+
: `no CLI-observed check (needs kind:check observed by workit check); run ${run}`;
|
|
711
|
+
return { satisfied: [], considered, reason };
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function evaluateRequirements(
|
|
715
|
+
task: TaskRecord,
|
|
716
|
+
workspace: WorkspaceRecord,
|
|
717
|
+
capabilities: Capability[],
|
|
718
|
+
candidate: Candidate | null,
|
|
719
|
+
checkoutRoot: string,
|
|
720
|
+
_caller?: Caller,
|
|
721
|
+
freshness: Freshness = treeFreshness(checkoutRoot),
|
|
722
|
+
) {
|
|
723
|
+
const evidence = evaluateEvidence(task, candidate, freshness);
|
|
724
|
+
const statuses = new Map(evidence.map((item) => [item.evidenceId, item.status]));
|
|
725
|
+
let checkConfig: CheckConfig | undefined;
|
|
726
|
+
const configFor = (): CheckConfig => (checkConfig ??= loadCheckConfig(checkoutRoot));
|
|
727
|
+
if (task.policy && task.policy.policyVersion !== POLICY_VERSION)
|
|
728
|
+
return task.policy.requirements.map((requirement) => ({
|
|
729
|
+
requirementId: requirement.id,
|
|
730
|
+
status: "unsatisfied" as const,
|
|
731
|
+
evidenceIds: [],
|
|
732
|
+
decisionIds: [],
|
|
733
|
+
reason: "stored policy version is unsupported; reassessment is required",
|
|
734
|
+
}));
|
|
735
|
+
const ledger = ledgerView(checkoutRoot);
|
|
736
|
+
return (task.policy?.requirements ?? []).map((requirement) => {
|
|
737
|
+
// Verdict, product-decision and plan rules read the ledger or the plan (S17).
|
|
738
|
+
const derived = derivedRuleStatus(task, requirement, checkoutRoot, ledger);
|
|
739
|
+
if (derived) {
|
|
740
|
+
if (derived.met)
|
|
741
|
+
return {
|
|
742
|
+
requirementId: requirement.id,
|
|
743
|
+
status: "satisfied" as const,
|
|
744
|
+
evidenceIds: [],
|
|
745
|
+
decisionIds: derived.decisionIds,
|
|
746
|
+
reason: derived.reason,
|
|
747
|
+
};
|
|
748
|
+
const waived = requirement.acceptanceAllowed
|
|
749
|
+
? applicableDecision(task, workspace, requirement, checkoutRoot)
|
|
750
|
+
: [];
|
|
751
|
+
if (waived.length)
|
|
752
|
+
return {
|
|
753
|
+
requirementId: requirement.id,
|
|
754
|
+
status: "accepted_limitation" as const,
|
|
755
|
+
evidenceIds: [],
|
|
756
|
+
decisionIds: waived.map(
|
|
757
|
+
(decision) => task.decisions.find((entry) => entry.data === decision)!.id,
|
|
758
|
+
),
|
|
759
|
+
reason: "an applicable approved limitation permits the missing evidence",
|
|
760
|
+
};
|
|
761
|
+
return {
|
|
762
|
+
requirementId: requirement.id,
|
|
763
|
+
status: "unsatisfied" as const,
|
|
764
|
+
evidenceIds: [],
|
|
765
|
+
decisionIds: [],
|
|
766
|
+
reason: derived.reason,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
const gate = observedGate(requirement)
|
|
770
|
+
? evaluateObservedGate(task, requirement, statuses, configFor())
|
|
771
|
+
: null;
|
|
772
|
+
if (gate?.satisfied.length)
|
|
773
|
+
return {
|
|
774
|
+
requirementId: requirement.id,
|
|
775
|
+
status: "satisfied" as const,
|
|
776
|
+
evidenceIds: gate.satisfied,
|
|
777
|
+
decisionIds: [],
|
|
778
|
+
reason: gate.reason,
|
|
779
|
+
};
|
|
780
|
+
const related = task.evidence
|
|
781
|
+
.map((entry, index) => ({ entry, evaluation: evidence[index] }))
|
|
782
|
+
.filter(({ entry }) => entry.data.requirementIds.includes(requirement.id));
|
|
783
|
+
const passed = related.filter(({ entry, evaluation }) => {
|
|
784
|
+
// A gated requirement was decided above: reported checks are only notes.
|
|
785
|
+
if (gate) return false;
|
|
786
|
+
if (
|
|
787
|
+
!evidenceMatchesRequirement(
|
|
788
|
+
evaluation.status,
|
|
789
|
+
entry.data.kind,
|
|
790
|
+
requirement.dimension,
|
|
791
|
+
requirement.ruleId,
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
return false;
|
|
795
|
+
if (
|
|
796
|
+
(entry.data.kind === "check" || entry.data.kind === "review") &&
|
|
797
|
+
(!entry.data.beforeCandidateId ||
|
|
798
|
+
!entry.data.candidateId ||
|
|
799
|
+
entry.data.beforeCandidateId !== entry.data.candidateId)
|
|
800
|
+
)
|
|
801
|
+
return false;
|
|
802
|
+
if (requirement.dimension !== "review") return true;
|
|
803
|
+
const reviewSession = sessionFromRef(entry.data.reviewContext);
|
|
804
|
+
const sameImplementation = sameSession(reviewSession, task.intent.provenance.session);
|
|
805
|
+
// Reviewer-session uniqueness is per candidate, not per task: the
|
|
806
|
+
// same session may re-verify after the candidate changes, but never
|
|
807
|
+
// manufacture two reviews for one candidate.
|
|
808
|
+
const sameEvidenceSession = related.some(
|
|
809
|
+
({ entry: other }) =>
|
|
810
|
+
other.id !== entry.id &&
|
|
811
|
+
other.data.kind === "review" &&
|
|
812
|
+
other.data.candidateId !== null &&
|
|
813
|
+
other.data.candidateId === entry.data.candidateId &&
|
|
814
|
+
sameSession(reviewSession, other.provenance.session),
|
|
815
|
+
);
|
|
816
|
+
return (
|
|
817
|
+
entry.data.kind === "review" &&
|
|
818
|
+
reviewSession !== null &&
|
|
819
|
+
sameSession(reviewSession, entry.provenance.session) &&
|
|
820
|
+
(requirement.ruleId === "self-review" || (!sameImplementation && !sameEvidenceSession))
|
|
821
|
+
);
|
|
822
|
+
});
|
|
823
|
+
if (passed.length) {
|
|
824
|
+
// RED-first, scoped to fresh claims: a testing pass bound to the
|
|
825
|
+
// current candidate needs a preceding recorded failure, unless a
|
|
826
|
+
// passing baseline already exists from earlier work — a check that
|
|
827
|
+
// passed (when recorded) against a different candidate. Stale passes
|
|
828
|
+
// count as baselines: green on an older tree is exactly what "prior
|
|
829
|
+
// passing baseline" means. A GREEN with neither is indistinguishable
|
|
830
|
+
// from code-then-tests.
|
|
831
|
+
const currentId = candidate?.id ?? null;
|
|
832
|
+
const withRed =
|
|
833
|
+
requirement.dimension === "testing"
|
|
834
|
+
? passed.filter(
|
|
835
|
+
({ entry }) =>
|
|
836
|
+
related.some(
|
|
837
|
+
(other) =>
|
|
838
|
+
other.entry.data.kind === "check" &&
|
|
839
|
+
other.evaluation.status === "failed" &&
|
|
840
|
+
other.entry.data.requirementIds.includes(requirement.id) &&
|
|
841
|
+
other.entry.recordedAt <= entry.recordedAt,
|
|
842
|
+
) ||
|
|
843
|
+
related.some(
|
|
844
|
+
(baseline) =>
|
|
845
|
+
baseline.entry.id !== entry.id &&
|
|
846
|
+
baseline.entry.data.kind === "check" &&
|
|
847
|
+
baseline.entry.data.result === "passed" &&
|
|
848
|
+
baseline.entry.data.candidateId !== null &&
|
|
849
|
+
currentId !== null &&
|
|
850
|
+
baseline.entry.data.candidateId !== currentId,
|
|
851
|
+
),
|
|
852
|
+
)
|
|
853
|
+
: passed;
|
|
854
|
+
if (withRed.length)
|
|
855
|
+
return {
|
|
856
|
+
requirementId: requirement.id,
|
|
857
|
+
status: "satisfied" as const,
|
|
858
|
+
evidenceIds: withRed.map(({ entry }) => entry.id),
|
|
859
|
+
decisionIds: [],
|
|
860
|
+
reason: "fresh applicable evidence passed",
|
|
861
|
+
};
|
|
862
|
+
if (requirement.dimension === "testing")
|
|
863
|
+
return {
|
|
864
|
+
requirementId: requirement.id,
|
|
865
|
+
status: "unsatisfied" as const,
|
|
866
|
+
evidenceIds: [],
|
|
867
|
+
decisionIds: [],
|
|
868
|
+
reason: "testing requirement has GREEN evidence but no preceding RED",
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
if (requirement.dimension === "delegation") {
|
|
872
|
+
// A bounded helper used for the requirement whose completed report is
|
|
873
|
+
// recorded reconciles the delegation: the lead closes on that result.
|
|
874
|
+
// Empty scope paths mean the whole checkout on both sides. Explicit
|
|
875
|
+
// requirement linkage wins for narrow requirements; scope coverage is
|
|
876
|
+
// the usual path since assignments live inside the requirement scope.
|
|
877
|
+
const dot = (scope: Scope): Scope =>
|
|
878
|
+
scope.paths.length > 0 ? scope : { ...scope, paths: ["."] };
|
|
879
|
+
const reconciled = task.workers.some(
|
|
880
|
+
(worker) =>
|
|
881
|
+
worker.data.report?.outcome === "completed" &&
|
|
882
|
+
(worker.data.assignment.requirementIds.includes(requirement.id) ||
|
|
883
|
+
scopeCovers(dot(requirement.scope), dot(worker.data.assignment.scope))),
|
|
884
|
+
);
|
|
885
|
+
if (reconciled)
|
|
886
|
+
return {
|
|
887
|
+
requirementId: requirement.id,
|
|
888
|
+
status: "satisfied" as const,
|
|
889
|
+
evidenceIds: [],
|
|
890
|
+
decisionIds: [],
|
|
891
|
+
reason: "bounded helper completed and its result is recorded",
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
const decisions =
|
|
895
|
+
requirement.dimension === "decisions"
|
|
896
|
+
? applicableRequirementDecision(task, workspace, requirement, checkoutRoot)
|
|
897
|
+
: [];
|
|
898
|
+
if (decisions.length)
|
|
899
|
+
return {
|
|
900
|
+
requirementId: requirement.id,
|
|
901
|
+
status: "satisfied" as const,
|
|
902
|
+
evidenceIds: [],
|
|
903
|
+
decisionIds: decisions.map((decision) => decision.id),
|
|
904
|
+
reason: "an applicable approved or stated decision satisfies the requirement",
|
|
905
|
+
};
|
|
906
|
+
const limitations = requirement.acceptanceAllowed
|
|
907
|
+
? applicableDecision(task, workspace, requirement, checkoutRoot)
|
|
908
|
+
: [];
|
|
909
|
+
if (limitations.length)
|
|
910
|
+
return {
|
|
911
|
+
requirementId: requirement.id,
|
|
912
|
+
status: "accepted_limitation" as const,
|
|
913
|
+
evidenceIds: [],
|
|
914
|
+
decisionIds: limitations.map(
|
|
915
|
+
(decision) => task.decisions.find((entry) => entry.data === decision)!.id,
|
|
916
|
+
),
|
|
917
|
+
reason: "an applicable approved limitation permits the missing evidence",
|
|
918
|
+
};
|
|
919
|
+
const unavailable = capabilities.some(
|
|
920
|
+
(capability) =>
|
|
921
|
+
capability.assurance === "unavailable" &&
|
|
922
|
+
(capability.name === requirement.dimension || capability.surface === requirement.dimension),
|
|
923
|
+
);
|
|
924
|
+
if (gate)
|
|
925
|
+
return {
|
|
926
|
+
requirementId: requirement.id,
|
|
927
|
+
status: unavailable ? ("unavailable" as const) : ("unsatisfied" as const),
|
|
928
|
+
evidenceIds: [...new Set([...gate.considered, ...related.map(({ entry }) => entry.id)])],
|
|
929
|
+
decisionIds: [],
|
|
930
|
+
reason: unavailable ? "required capability is unavailable" : gate.reason,
|
|
931
|
+
};
|
|
932
|
+
if (!unavailable && related.length > 0 && !passed.length)
|
|
933
|
+
return {
|
|
934
|
+
requirementId: requirement.id,
|
|
935
|
+
status: "unsatisfied" as const,
|
|
936
|
+
evidenceIds: related.map(({ entry }) => entry.id),
|
|
937
|
+
decisionIds: [],
|
|
938
|
+
reason: `no passing evidence (needs kind:${expectedKinds(requirement.dimension, requirement.ruleId)})`,
|
|
939
|
+
};
|
|
940
|
+
return {
|
|
941
|
+
requirementId: requirement.id,
|
|
942
|
+
status: unavailable ? ("unavailable" as const) : ("unsatisfied" as const),
|
|
943
|
+
evidenceIds: related.map(({ entry }) => entry.id),
|
|
944
|
+
decisionIds: [],
|
|
945
|
+
reason: unavailable ? "required capability is unavailable" : "no fresh passing evidence",
|
|
946
|
+
};
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
export type ClosureEvaluation = {
|
|
951
|
+
outcome: Outcome;
|
|
952
|
+
evidenceIds: string[];
|
|
953
|
+
decisionIds: string[];
|
|
954
|
+
requirementIds: string[];
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
export function evaluateClosure(
|
|
958
|
+
requestedOutcome: Outcome,
|
|
959
|
+
view: TaskView,
|
|
960
|
+
): Result<ClosureEvaluation> {
|
|
961
|
+
if (requestedOutcome !== "stopped" && !view.task.policy)
|
|
962
|
+
return failure("requirements_unsatisfied", "task has not been assessed");
|
|
963
|
+
if (
|
|
964
|
+
view.task.policy &&
|
|
965
|
+
(view.task.policy as { policyVersion: string }).policyVersion !== POLICY_VERSION
|
|
966
|
+
)
|
|
967
|
+
return failure(
|
|
968
|
+
"requirements_unsatisfied",
|
|
969
|
+
"stored policy version is unsupported; reassessment is required",
|
|
970
|
+
);
|
|
971
|
+
const evaluations = view.requirements;
|
|
972
|
+
const openFindings = view.task.findings.filter((entry) => entry.data.disposition === "open");
|
|
973
|
+
if (requestedOutcome !== "stopped" && openFindings.length)
|
|
974
|
+
return failure("requirements_unsatisfied", "open findings must be resolved before closure");
|
|
975
|
+
if (requestedOutcome !== "stopped") {
|
|
976
|
+
const evidenceById = new Map(view.evidence.map((entry) => [entry.evidenceId, entry.status]));
|
|
977
|
+
for (const entry of view.task.findings) {
|
|
978
|
+
if (entry.data.disposition === "fixed") {
|
|
979
|
+
const verified = entry.data.resolution?.evidenceIds.some((id) => {
|
|
980
|
+
const evidence = view.task.evidence.find((item) => item.id === id);
|
|
981
|
+
if (!evidence) return false;
|
|
982
|
+
return findingVerificationPasses(entry.data.candidateId, {
|
|
983
|
+
kind: evidence.data.kind,
|
|
984
|
+
candidateId: evidence.data.candidateId,
|
|
985
|
+
status: evidenceById.get(id) ?? "missing",
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
if (!verified)
|
|
989
|
+
return failure("requirements_unsatisfied", "fixed findings require current verification");
|
|
990
|
+
}
|
|
991
|
+
if (entry.data.disposition === "deferred") {
|
|
992
|
+
const valid = entry.data.resolution?.decisionIds.some((id) => {
|
|
993
|
+
const decisionEntry = view.task.decisions.find((item) => item.id === id);
|
|
994
|
+
const decision = decisionEntry?.data;
|
|
995
|
+
return Boolean(
|
|
996
|
+
decision &&
|
|
997
|
+
decisionEntry?.provenance.kind !== "imported" &&
|
|
998
|
+
decision.purpose === "limitation" &&
|
|
999
|
+
decision.response === "approved" &&
|
|
1000
|
+
decision.revoked === null &&
|
|
1001
|
+
verifyDecisionContentAtRoot(view.workspace.root, decision.binding).ok &&
|
|
1002
|
+
decision.binding.taskId === view.task.id &&
|
|
1003
|
+
decision.binding.workspaceId === view.workspace.id &&
|
|
1004
|
+
scopeCovers(decision.binding.scope, entry.data.scope) &&
|
|
1005
|
+
decision.requirementIds.some((requirementId) =>
|
|
1006
|
+
view.task.policy?.requirements.some(
|
|
1007
|
+
(requirement) => requirement.id === requirementId && requirement.acceptanceAllowed,
|
|
1008
|
+
),
|
|
1009
|
+
),
|
|
1010
|
+
);
|
|
1011
|
+
});
|
|
1012
|
+
if (!valid)
|
|
1013
|
+
return failure(
|
|
1014
|
+
"requirements_unsatisfied",
|
|
1015
|
+
"deferred findings require an approved limitation decision that references an acceptanceAllowed requirement and covers the finding scope",
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
// Close is gated only by close-timed requirements. Dependent-action gates
|
|
1021
|
+
// (e.g. deslop before hosting.pull_request) block their named action at
|
|
1022
|
+
// reservation time, never the close itself.
|
|
1023
|
+
const blocking = evaluations.filter((item) => {
|
|
1024
|
+
if (item.status !== "unsatisfied" && item.status !== "unavailable") return false;
|
|
1025
|
+
const requirement = view.task.policy?.requirements.find(
|
|
1026
|
+
(candidate) => candidate.id === item.requirementId,
|
|
1027
|
+
);
|
|
1028
|
+
return !requirement || requirement.before === "close";
|
|
1029
|
+
});
|
|
1030
|
+
if (requestedOutcome !== "stopped" && blocking.length)
|
|
1031
|
+
return failure("requirements_unsatisfied", "applicable requirements are unsatisfied", {
|
|
1032
|
+
requirementIds: blocking.map((item) => item.requirementId),
|
|
1033
|
+
requirements: blocking.map((item) => {
|
|
1034
|
+
const requirement = view.task.policy?.requirements.find(
|
|
1035
|
+
(candidate) => candidate.id === item.requirementId,
|
|
1036
|
+
);
|
|
1037
|
+
return {
|
|
1038
|
+
requirementId: item.requirementId,
|
|
1039
|
+
ruleId: requirement?.ruleId ?? "unknown",
|
|
1040
|
+
reason: item.reason,
|
|
1041
|
+
satisfaction: requirement?.satisfaction ?? "",
|
|
1042
|
+
dependentAction: requirement?.dependentAction ?? null,
|
|
1043
|
+
};
|
|
1044
|
+
}),
|
|
1045
|
+
});
|
|
1046
|
+
const accepted = evaluations.filter((item) => item.status === "accepted_limitation");
|
|
1047
|
+
if (requestedOutcome === "verified" && accepted.length)
|
|
1048
|
+
return failure(
|
|
1049
|
+
"requirements_unsatisfied",
|
|
1050
|
+
"accepted limitations cannot be reported as verified",
|
|
1051
|
+
{
|
|
1052
|
+
requirementIds: accepted.map((item) => item.requirementId),
|
|
1053
|
+
},
|
|
1054
|
+
);
|
|
1055
|
+
const evidenceIds = [
|
|
1056
|
+
...new Set([
|
|
1057
|
+
...evaluations.flatMap((item) => item.evidenceIds),
|
|
1058
|
+
...view.task.findings.flatMap((item) => item.data.resolution?.evidenceIds ?? []),
|
|
1059
|
+
]),
|
|
1060
|
+
];
|
|
1061
|
+
const decisionIds = [
|
|
1062
|
+
...new Set([
|
|
1063
|
+
...evaluations.flatMap((item) => item.decisionIds),
|
|
1064
|
+
...view.task.findings.flatMap((item) => item.data.resolution?.decisionIds ?? []),
|
|
1065
|
+
]),
|
|
1066
|
+
];
|
|
1067
|
+
return success(null, null, {
|
|
1068
|
+
outcome:
|
|
1069
|
+
requestedOutcome === "stopped"
|
|
1070
|
+
? "stopped"
|
|
1071
|
+
: accepted.length
|
|
1072
|
+
? "accepted_limitations"
|
|
1073
|
+
: "verified",
|
|
1074
|
+
evidenceIds,
|
|
1075
|
+
decisionIds,
|
|
1076
|
+
requirementIds: evaluations.map((item) => item.requirementId),
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
export const TASK_BRIEF_MAX_BYTES = 4096;
|
|
1081
|
+
const BRIEF_TEXT = 250;
|
|
1082
|
+
const BRIEF_BLOCKERS = 3;
|
|
1083
|
+
const BRIEF_OPEN = 8;
|
|
1084
|
+
|
|
1085
|
+
/** Cut `value` until its JSON-escaped form fits `BRIEF_TEXT` bytes. */
|
|
1086
|
+
const clip = (value: string): string => {
|
|
1087
|
+
const fits = (text: string) => Buffer.byteLength(JSON.stringify(text)) <= BRIEF_TEXT;
|
|
1088
|
+
if (fits(value)) return value;
|
|
1089
|
+
let length = Math.min(value.length, BRIEF_TEXT);
|
|
1090
|
+
while (length > 0 && !fits(`${value.slice(0, length)}…`)) length -= 1;
|
|
1091
|
+
return `${value.slice(0, length)}…`;
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Bound a task summary for `inspect --view summary`: text is clipped, lists
|
|
1096
|
+
* capped and the policy reduced to the requirements still owed, so the JSON
|
|
1097
|
+
* stays under {@link TASK_BRIEF_MAX_BYTES} however long the task history is.
|
|
1098
|
+
*/
|
|
1099
|
+
export function boundTaskSummary(summary: TaskSummary): TaskBrief {
|
|
1100
|
+
const rules = new Map((summary.policy?.requirements ?? []).map((item) => [item.id, item]));
|
|
1101
|
+
const unmet = summary.requirements.filter(
|
|
1102
|
+
(item) => item.status === "unsatisfied" || item.status === "unavailable",
|
|
1103
|
+
);
|
|
1104
|
+
const { progress, closure } = summary;
|
|
1105
|
+
const next = progress.nextAction;
|
|
1106
|
+
const open = unmet.slice(0, BRIEF_OPEN).map((item) => ({
|
|
1107
|
+
dimension: rules.get(item.requirementId)?.dimension ?? "verification",
|
|
1108
|
+
before: rules.get(item.requirementId)?.before ?? "close",
|
|
1109
|
+
status: item.status,
|
|
1110
|
+
}));
|
|
1111
|
+
const brief: TaskBrief = {
|
|
1112
|
+
id: summary.id,
|
|
1113
|
+
revision: summary.revision,
|
|
1114
|
+
workspaceRevision: summary.workspaceRevision,
|
|
1115
|
+
updatedAt: summary.updatedAt,
|
|
1116
|
+
objective: clip(summary.objective),
|
|
1117
|
+
status: summary.status,
|
|
1118
|
+
closure: closure && {
|
|
1119
|
+
outcome: closure.outcome,
|
|
1120
|
+
at: closure.at,
|
|
1121
|
+
summary: clip(closure.summary),
|
|
1122
|
+
},
|
|
1123
|
+
progress: {
|
|
1124
|
+
summary: clip(progress.summary),
|
|
1125
|
+
nextAction: next && clip(next),
|
|
1126
|
+
blockers: progress.blockers.slice(0, BRIEF_BLOCKERS).map((item) => ({
|
|
1127
|
+
reason: clip(item.reason),
|
|
1128
|
+
dependentAction: clip(item.dependentAction),
|
|
1129
|
+
})),
|
|
1130
|
+
},
|
|
1131
|
+
requirements: { total: summary.requirements.length, unmet: unmet.length, open },
|
|
1132
|
+
truncated: false,
|
|
1133
|
+
};
|
|
1134
|
+
brief.truncated =
|
|
1135
|
+
JSON.stringify(brief).includes("…") ||
|
|
1136
|
+
progress.blockers.length > BRIEF_BLOCKERS ||
|
|
1137
|
+
unmet.length > BRIEF_OPEN;
|
|
1138
|
+
return brief;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
/** For a child or unattested caller: `task inspect` with the summary view returns the brief. */
|
|
1142
|
+
export function briefInspectResult(request: unknown, result: Result<unknown>): Result<unknown> {
|
|
1143
|
+
const { action, view } = (request ?? {}) as { action?: unknown; view?: unknown };
|
|
1144
|
+
return result.ok && action === "inspect" && (view ?? "summary") === "summary"
|
|
1145
|
+
? { ...result, data: boundTaskSummary(result.data as TaskSummary) }
|
|
1146
|
+
: result;
|
|
1147
|
+
}
|