@brainervirus/workit-core 0.10.0 → 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.
Files changed (150) hide show
  1. package/README.md +5 -3
  2. package/package.json +8 -3
  3. package/scripts/analyze-release-scope.ts +4 -1
  4. package/scripts/doctor-check.ts +3 -1
  5. package/scripts/install-codex-plugin.sh +28 -0
  6. package/scripts/install-cursor-plugin.sh +1 -0
  7. package/scripts/install-opencode-plugin.sh +1 -0
  8. package/scripts/install-pi-package.sh +56 -0
  9. package/scripts/rewrite-workspace-deps.ts +22 -5
  10. package/scripts/sync-release-manifests.ts +8 -5
  11. package/scripts/sync-runtime.sh +7 -3
  12. package/scripts/validate-cursor-marketplace.ts +16 -62
  13. package/skills/workit-babysit/SKILL.md +33 -0
  14. package/skills/workit-behavioral-tdd/SKILL.md +53 -0
  15. package/skills/workit-blast-radius/SKILL.md +31 -0
  16. package/skills/workit-challenge/SKILL.md +62 -0
  17. package/skills/workit-debug/SKILL.md +61 -0
  18. package/skills/workit-deslop/SKILL.md +36 -0
  19. package/skills/workit-diagram/SKILL.md +32 -0
  20. package/skills/workit-green-run/SKILL.md +29 -0
  21. package/skills/workit-handoff/SKILL.md +43 -0
  22. package/skills/workit-implement/SKILL.md +46 -0
  23. package/skills/workit-mockup/SKILL.md +28 -0
  24. package/skills/workit-plan/SKILL.md +66 -0
  25. package/skills/workit-review/SKILL.md +60 -0
  26. package/skills/workit-steer/SKILL.md +32 -0
  27. package/src/core/authority.ts +918 -0
  28. package/src/core/boundary.ts +29 -0
  29. package/src/core/branch.ts +26 -255
  30. package/src/core/changelog.ts +95 -18
  31. package/src/core/commit-flavors.ts +65 -0
  32. package/src/core/config-conversion.ts +183 -0
  33. package/src/core/config.ts +42 -0
  34. package/src/core/cutover.ts +721 -0
  35. package/src/core/detect-hosts.ts +107 -0
  36. package/src/core/doctor.ts +428 -18
  37. package/src/core/external-action-effects.ts +1171 -0
  38. package/src/core/external-action.ts +541 -0
  39. package/src/core/init.ts +83 -32
  40. package/src/core/methods.ts +125 -0
  41. package/src/core/policy-resolver.ts +523 -0
  42. package/src/core/pr-create.ts +7 -1
  43. package/src/core/registration.ts +69 -4
  44. package/src/core/repo-context.ts +59 -1
  45. package/src/core/setup.ts +28 -0
  46. package/src/core/skill-manifests.ts +40 -42
  47. package/src/core/support-matrix.ts +8 -3
  48. package/src/core/sync-runtime.ts +15 -3
  49. package/src/core/task-context.ts +179 -0
  50. package/src/core/task-contract.ts +1136 -0
  51. package/src/core/task-engine.ts +2562 -0
  52. package/src/core/task-evaluation.ts +679 -0
  53. package/src/core/task-store.ts +1071 -0
  54. package/src/core/tracker-issues.ts +237 -0
  55. package/src/core/uninstall.ts +67 -3
  56. package/src/core/vcs-config.ts +53 -22
  57. package/src/core/workers.ts +166 -0
  58. package/src/core/workspaces.ts +3 -1
  59. package/src/core/youtrack-tools.ts +2 -0
  60. package/src/core/youtrack.ts +145 -10
  61. package/src/core.ts +126 -0
  62. package/templates/execution-contract.md +17 -50
  63. package/templates/plan-template.md +3 -3
  64. package/templates/spec-template.md +15 -3
  65. package/templates/workit-contract.md +12 -0
  66. package/commands/wk-changelog.md +0 -2
  67. package/commands/wk-commit.md +0 -2
  68. package/commands/wk-docs-refresh.md +0 -2
  69. package/commands/wk-handoff.md +0 -2
  70. package/commands/wk-implement.md +0 -2
  71. package/commands/wk-init.md +0 -2
  72. package/commands/wk-issue-update.md +0 -2
  73. package/commands/wk-meetings.md +0 -2
  74. package/commands/wk-pr.md +0 -2
  75. package/commands/wk-release-notes.md +0 -2
  76. package/commands/wk-status.md +0 -2
  77. package/commands/wk-verify.md +0 -2
  78. package/scripts/update-superpowers.sh +0 -82
  79. package/scripts/vendor-assets.ts +0 -37
  80. package/skills/wk-changelog/SKILL.md +0 -15
  81. package/skills/wk-commit/SKILL.md +0 -16
  82. package/skills/wk-docs-refresh/SKILL.md +0 -15
  83. package/skills/wk-handoff/SKILL.md +0 -20
  84. package/skills/wk-implement/SKILL.md +0 -47
  85. package/skills/wk-init/SKILL.md +0 -31
  86. package/skills/wk-issue-update/SKILL.md +0 -27
  87. package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
  88. package/skills/wk-meetings/SKILL.md +0 -17
  89. package/skills/wk-pr/SKILL.md +0 -27
  90. package/skills/wk-release-notes/SKILL.md +0 -15
  91. package/skills/wk-status/SKILL.md +0 -16
  92. package/skills/wk-verify/SKILL.md +0 -16
  93. package/src/core/detector.ts +0 -239
  94. package/src/core/flow-state.ts +0 -3048
  95. package/src/core/handoff-context.ts +0 -136
  96. package/src/core/handoff-tools.ts +0 -133
  97. package/src/core/menu.ts +0 -70
  98. package/src/core/plan-tasks.ts +0 -43
  99. package/src/core/reminder.ts +0 -124
  100. package/src/core/sdd.ts +0 -353
  101. package/src/state.ts +0 -22
  102. package/templates/superpowers-doc-contract.md +0 -75
  103. package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
  104. package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
  105. package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
  106. package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
  107. package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
  108. package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
  109. package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
  110. package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
  111. package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
  112. package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
  113. package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
  114. package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
  115. package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
  116. package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
  117. package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -426
  118. package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
  119. package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
  120. package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
  121. package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
  122. package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
  123. package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
  124. package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
  125. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
  126. package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
  127. package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
  128. package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
  129. package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
  130. package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
  131. package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
  132. package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
  133. package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
  134. package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
  135. package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
  136. package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
  137. package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
  138. package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
  139. package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
  140. package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
  141. package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
  142. package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
  143. package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
  144. package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
  145. package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
  146. package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
  147. package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
  148. package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
  149. package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
  150. package/vendor/superpowers/skills/writing-skills/testing-skills-with-subagents.md +0 -384
@@ -0,0 +1,679 @@
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
+ decisionDigest,
10
+ failure,
11
+ scopeCovers as contractScopeCovers,
12
+ success,
13
+ type Candidate,
14
+ type Capability,
15
+ type Caller,
16
+ type Decision,
17
+ type EvidenceEvaluation,
18
+ type Outcome,
19
+ type Requirement,
20
+ type Result,
21
+ type Scope,
22
+ type TaskRecord,
23
+ type TaskView,
24
+ type WorkspaceRecord,
25
+ } from "./task-contract";
26
+ import { verifyDecisionContentAtRoot } from "./authority";
27
+
28
+ export type CandidateEnvironment =
29
+ | readonly (string | { name: string; value?: string | null })[]
30
+ | Record<string, string | null | undefined>;
31
+
32
+ const digestBytes = (value: string | Buffer): string =>
33
+ createHash("sha256").update(value).digest("hex");
34
+ const inside = (root: string, candidate: string): boolean =>
35
+ candidate === root || candidate.startsWith(`${root}${path.sep}`);
36
+ const relative = (root: string, candidate: string): string =>
37
+ path.relative(root, candidate).split(path.sep).join("/") || ".";
38
+ const canonicalPath = (value: string): string | null => {
39
+ if (typeof value !== "string" || !value) return null;
40
+ if (value.includes("\\") || value.split("/").includes("..")) return null;
41
+ const normalized = path.posix.normalize(value);
42
+ if (normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/"))
43
+ return null;
44
+ return normalized === "." ? "." : normalized.replace(/\/$/, "");
45
+ };
46
+ const canonicalScope = (scope: Scope): Scope | null => {
47
+ const paths = scope.paths.length ? scope.paths : ["."];
48
+ const normalizedPaths = paths.map(canonicalPath);
49
+ const normalizedExclusions = scope.exclusions.map(canonicalPath);
50
+ if (
51
+ normalizedPaths.some((value) => value === null) ||
52
+ normalizedExclusions.some((value) => value === null)
53
+ )
54
+ return null;
55
+ return {
56
+ description: scope.description,
57
+ paths: [...new Set(normalizedPaths as string[])],
58
+ exclusions: [...new Set(normalizedExclusions as string[])],
59
+ };
60
+ };
61
+ const excluded = (value: string, exclusions: string[]): boolean =>
62
+ exclusions.some((item) => item === value || (item !== "." && value.startsWith(`${item}/`)));
63
+ const scopeMatches = (value: string, scope: Scope): boolean => {
64
+ const normalized = canonicalScope(scope);
65
+ if (!normalized) return false;
66
+ const paths = normalized.paths;
67
+ return (
68
+ paths.some((item) => item === "." || value === item || value.startsWith(`${item}/`)) &&
69
+ !excluded(value, normalized.exclusions)
70
+ );
71
+ };
72
+ const pathRelevant = (value: string, scope: Scope): boolean => scopeMatches(value, scope);
73
+ const compareCodeUnits = (left: string, right: string): number => {
74
+ const length = Math.min(left.length, right.length);
75
+ for (let index = 0; index < length; index += 1) {
76
+ const difference = left.charCodeAt(index) - right.charCodeAt(index);
77
+ if (difference !== 0) return difference;
78
+ }
79
+ return left.length - right.length;
80
+ };
81
+
82
+ type Inventory = { paths: string[]; uncertain: boolean; git: boolean };
83
+ const gitPaths = (root: string): Inventory => {
84
+ const result = spawnSync(
85
+ "git",
86
+ ["ls-files", "-z", "--cached", "--others", "--exclude-standard"],
87
+ {
88
+ cwd: root,
89
+ encoding: "buffer",
90
+ },
91
+ );
92
+ if (result.status !== 0) {
93
+ const stderr = result.stderr?.toString("utf8") ?? "";
94
+ return {
95
+ paths: [],
96
+ uncertain: !/not a git repository/i.test(stderr),
97
+ git: /not a git repository/i.test(stderr) ? false : true,
98
+ };
99
+ }
100
+ const paths = result.stdout
101
+ ? result.stdout
102
+ .toString("utf8")
103
+ .split("\0")
104
+ .filter(Boolean)
105
+ .map((item) => item.split(path.sep).join("/"))
106
+ : [];
107
+ const stagedDeleted = spawnSync(
108
+ "git",
109
+ ["diff", "--cached", "--name-only", "--diff-filter=D", "-z"],
110
+ { cwd: root, encoding: "buffer" },
111
+ );
112
+ if (stagedDeleted.status !== 0) {
113
+ const stderr = stagedDeleted.stderr?.toString("utf8") ?? "";
114
+ if (!/not a git repository/i.test(stderr)) return { paths, uncertain: true, git: true };
115
+ } else if (stagedDeleted.stdout) {
116
+ paths.push(
117
+ ...stagedDeleted.stdout
118
+ .toString("utf8")
119
+ .split("\0")
120
+ .filter(Boolean)
121
+ .map((item) => item.split(path.sep).join("/")),
122
+ );
123
+ }
124
+ return { paths: [...new Set(paths)], uncertain: false, git: true };
125
+ };
126
+
127
+ const walk = (root: string, directory: string, output: Set<string>): boolean => {
128
+ let names: string[];
129
+ try {
130
+ names = fs.readdirSync(directory);
131
+ } catch {
132
+ return true;
133
+ }
134
+ let uncertain = false;
135
+ for (const name of names) {
136
+ if (name === ".git" || name === ".workit") continue;
137
+ const target = path.join(directory, name);
138
+ let stat: fs.Stats;
139
+ try {
140
+ stat = fs.lstatSync(target);
141
+ } catch {
142
+ uncertain = true;
143
+ continue;
144
+ }
145
+ const item = relative(root, target);
146
+ output.add(item);
147
+ if (stat.isDirectory() && !stat.isSymbolicLink())
148
+ uncertain = walk(root, target, output) || uncertain;
149
+ }
150
+ return uncertain;
151
+ };
152
+
153
+ const scopeRoots = (root: string, scope: Scope): Result<string[]> => {
154
+ const roots: string[] = [];
155
+ for (const item of scope.paths.length ? scope.paths : ["."]) {
156
+ const target = path.resolve(root, item);
157
+ if (!inside(root, target)) return failure("invalid_input", "candidate scope escapes checkout");
158
+ let ancestor = target;
159
+ while (!fs.existsSync(ancestor) && ancestor !== root) ancestor = path.dirname(ancestor);
160
+ try {
161
+ if (!inside(root, fs.realpathSync(ancestor)))
162
+ return failure("invalid_input", "candidate scope escapes checkout");
163
+ } catch {
164
+ return failure("invalid_input", "candidate scope cannot be inspected");
165
+ }
166
+ roots.push(target);
167
+ }
168
+ return success(null, null, roots);
169
+ };
170
+
171
+ export function captureCandidate(
172
+ root: string,
173
+ scope: Scope,
174
+ environment: CandidateEnvironment = [],
175
+ ): Result<Candidate> {
176
+ let checkout: string;
177
+ try {
178
+ checkout = fs.realpathSync(root);
179
+ } catch {
180
+ return failure("invalid_input", "candidate root does not exist");
181
+ }
182
+ const normalizedScope = canonicalScope(scope);
183
+ if (!normalizedScope) return failure("invalid_input", "candidate scope is invalid");
184
+ const roots = scopeRoots(checkout, normalizedScope);
185
+ if (!roots.ok) return roots;
186
+ const git = gitPaths(checkout);
187
+ const names = new Set<string>();
188
+ let uncertain = git.uncertain;
189
+ for (const target of roots.data) {
190
+ const item = relative(checkout, target);
191
+ if (item === ".git" || item === ".workit") continue;
192
+ names.add(item);
193
+ if (!fs.existsSync(target)) continue;
194
+ let stat: fs.Stats;
195
+ try {
196
+ stat = fs.lstatSync(target);
197
+ } catch {
198
+ uncertain = true;
199
+ continue;
200
+ }
201
+ if (!git.git && stat.isDirectory() && !stat.isSymbolicLink())
202
+ uncertain = walk(checkout, target, names) || uncertain;
203
+ }
204
+ for (const item of git.paths) if (scopeMatches(item, normalizedScope)) names.add(item);
205
+
206
+ const files: Candidate["files"] = [];
207
+ let completeness: Candidate["completeness"] = uncertain ? "uncertain" : "known";
208
+ for (const item of [...names].sort()) {
209
+ if (!scopeMatches(item, normalizedScope)) continue;
210
+ const target = path.join(checkout, item);
211
+ try {
212
+ const stat = fs.lstatSync(target);
213
+ if (stat.isSymbolicLink()) {
214
+ const link = fs.readlinkSync(target);
215
+ files.push({ path: item, kind: "symlink", digest: digestBytes(link), executable: null });
216
+ } else if (stat.isFile()) {
217
+ files.push({
218
+ path: item,
219
+ kind: "file",
220
+ digest: digestBytes(fs.readFileSync(target)),
221
+ executable: (stat.mode & 0o111) !== 0,
222
+ });
223
+ } else if (!stat.isDirectory()) {
224
+ completeness = "uncertain";
225
+ } else if (git.git && git.paths.includes(item)) {
226
+ // A Git link/submodule is an inventory boundary; its contents are not
227
+ // represented by the parent repository's path list.
228
+ completeness = "uncertain";
229
+ }
230
+ } catch (error: any) {
231
+ if (error?.code === "ENOENT") {
232
+ files.push({ path: item, kind: "absent", digest: null, executable: null });
233
+ } else completeness = "uncertain";
234
+ }
235
+ }
236
+ const supplied = Array.isArray(environment)
237
+ ? environment.map((value) =>
238
+ typeof value === "string"
239
+ ? { name: value, value: process.env[value] ?? null }
240
+ : { name: value.name, value: value.value ?? null },
241
+ )
242
+ : Object.keys(environment as Record<string, string | null | undefined>).map((name) => ({
243
+ name,
244
+ value: (environment as Record<string, string | null | undefined>)[name] ?? null,
245
+ }));
246
+ const namesByEnvironment = supplied.map(({ name }) => name);
247
+ if (
248
+ new Set(namesByEnvironment).size !== namesByEnvironment.length ||
249
+ namesByEnvironment.some((name) => typeof name !== "string" || !name)
250
+ )
251
+ return failure("invalid_input", "candidate environment names must be unique and non-empty");
252
+ const values = supplied
253
+ .sort((left, right) => compareCodeUnits(left.name, right.name))
254
+ .map(({ name, value }) => ({ name, value, refs: [] }));
255
+ if (values.some(({ value }) => value === null)) completeness = "uncertain";
256
+ const headResult = spawnSync("git", ["rev-parse", "HEAD"], { cwd: checkout, encoding: "utf8" });
257
+ if (headResult.status !== 0 && !/not a git repository/i.test(headResult.stderr ?? ""))
258
+ completeness = "uncertain";
259
+ const head = headResult.status === 0 ? headResult.stdout.trim() : null;
260
+ const partial: Candidate = {
261
+ id: "0".repeat(64),
262
+ scope: normalizedScope,
263
+ completeness,
264
+ files,
265
+ environment: values,
266
+ head,
267
+ };
268
+ const candidate = { ...partial, id: candidateDigest(partial) };
269
+ const parsed = candidateSchema.safeParse(candidate);
270
+ return parsed.success
271
+ ? success(null, null, parsed.data)
272
+ : failure("invalid_input", "captured candidate is invalid");
273
+ }
274
+
275
+ const fileMap = (candidate: Candidate): Map<string, Candidate["files"][number]> =>
276
+ new Map(candidate.files.map((file) => [file.path, file]));
277
+ const changedPaths = (before: Candidate, after: Candidate): string[] => {
278
+ const paths = new Set([...before.files, ...after.files].map((file) => file.path));
279
+ const left = fileMap(before);
280
+ const right = fileMap(after);
281
+ const changed = [...paths].filter(
282
+ (item) => JSON.stringify(left.get(item) ?? null) !== JSON.stringify(right.get(item) ?? null),
283
+ );
284
+ if (before.head !== after.head) changed.push(".");
285
+ if (JSON.stringify(before.environment) !== JSON.stringify(after.environment)) changed.push(".");
286
+ return changed;
287
+ };
288
+
289
+ const sessionFromRef = (value: unknown): { host: string; handle: string } | null => {
290
+ if (typeof value !== "object" || value === null || (value as any).kind !== "host") return null;
291
+ return typeof (value as any).host === "string" && typeof (value as any).handle === "string"
292
+ ? { host: (value as any).host, handle: (value as any).handle }
293
+ : null;
294
+ };
295
+ const sameSession = (left: { host: string; handle: string } | null, right: unknown): boolean => {
296
+ const other = sessionFromRef(right);
297
+ return (
298
+ left !== null && other !== null && left.host === other.host && left.handle === other.handle
299
+ );
300
+ };
301
+
302
+ const evidenceScopes = (task: TaskRecord, ids: string[]): Scope[] =>
303
+ (task.policy?.requirements ?? [])
304
+ .filter((requirement) => ids.includes(requirement.id))
305
+ .map((requirement) => requirement.scope);
306
+
307
+ export function evaluateEvidence(
308
+ task: TaskRecord,
309
+ candidate: Candidate | null,
310
+ ): EvidenceEvaluation[] {
311
+ const current = candidate ?? task.candidates.at(-1) ?? null;
312
+ return task.evidence.map((entry) => {
313
+ const evidence = entry.data;
314
+ if (evidence.result === "failed")
315
+ return {
316
+ evidenceId: entry.id,
317
+ status: "failed" as const,
318
+ reason: "failed evidence remains historical",
319
+ };
320
+ if (evidence.kind === "check" || evidence.kind === "review") {
321
+ if (!evidence.beforeCandidateId || !evidence.candidateId)
322
+ return {
323
+ evidenceId: entry.id,
324
+ status: "stale",
325
+ reason: "check or review evidence lacks start and end candidates",
326
+ };
327
+ if (evidence.beforeCandidateId !== evidence.candidateId)
328
+ return {
329
+ evidenceId: entry.id,
330
+ status: "stale",
331
+ reason: "candidate changed during the check or review",
332
+ };
333
+ }
334
+ if (!evidence.candidateId || !current)
335
+ return {
336
+ evidenceId: entry.id,
337
+ status: evidence.result,
338
+ reason: "evidence has no candidate binding",
339
+ };
340
+ const observed =
341
+ task.candidates.find((item) => item.id === evidence.candidateId) ??
342
+ (current.id === evidence.candidateId ? current : undefined);
343
+ if (!observed || observed.completeness === "uncertain" || current.completeness === "uncertain")
344
+ return { evidenceId: entry.id, status: "stale", reason: "candidate is missing or uncertain" };
345
+ if (observed.id === current.id)
346
+ return { evidenceId: entry.id, status: evidence.result, reason: "candidate is unchanged" };
347
+ const scopes = evidenceScopes(task, evidence.requirementIds);
348
+ const changed = changedPaths(observed, current);
349
+ const environmentChanged =
350
+ JSON.stringify(observed.environment) !== JSON.stringify(current.environment);
351
+ if (
352
+ !scopes.length ||
353
+ environmentChanged ||
354
+ changed.some((item) => scopes.some((scope) => pathRelevant(item, scope)))
355
+ )
356
+ return { evidenceId: entry.id, status: "stale", reason: "relevant candidate state changed" };
357
+ return {
358
+ evidenceId: entry.id,
359
+ status: evidence.result,
360
+ reason: "candidate changed outside evidence scope",
361
+ };
362
+ });
363
+ }
364
+
365
+ export const scopeCovers = contractScopeCovers;
366
+ const applicableDecision = (
367
+ task: TaskRecord,
368
+ workspace: WorkspaceRecord,
369
+ requirement: Requirement,
370
+ checkoutRoot?: string,
371
+ ): Decision[] =>
372
+ task.decisions
373
+ .filter((entry) => entry.provenance.kind !== "imported")
374
+ .map((entry) => entry.data)
375
+ .filter(
376
+ (decision) =>
377
+ decision.purpose === "limitation" &&
378
+ decision.response === "approved" &&
379
+ decision.revoked === null &&
380
+ decision.binding.taskId === task.id &&
381
+ decision.binding.workspaceId === workspace.id &&
382
+ decision.requirementIds.includes(requirement.id) &&
383
+ decision.binding.scope &&
384
+ scopeCovers(decision.binding.scope, requirement.scope) &&
385
+ decision.digest === decisionDigest(decision) &&
386
+ (checkoutRoot
387
+ ? verifyDecisionContentAtRoot(checkoutRoot, decision.binding).ok
388
+ : decision.binding.contentRefs.every((reference) => reference.kind !== "file")),
389
+ );
390
+
391
+ const applicableRequirementDecision = (
392
+ task: TaskRecord,
393
+ workspace: WorkspaceRecord,
394
+ requirement: Requirement,
395
+ checkoutRoot?: string,
396
+ ): { id: string }[] =>
397
+ task.decisions
398
+ .filter(
399
+ ({ data, provenance }) =>
400
+ provenance.kind !== "imported" &&
401
+ data.purpose !== "limitation" &&
402
+ data.response === "approved" &&
403
+ data.revoked === null &&
404
+ data.binding.taskId === task.id &&
405
+ data.binding.workspaceId === workspace.id &&
406
+ data.requirementIds.includes(requirement.id) &&
407
+ scopeCovers(data.binding.scope, requirement.scope) &&
408
+ data.digest === decisionDigest(data) &&
409
+ (checkoutRoot
410
+ ? verifyDecisionContentAtRoot(checkoutRoot, data.binding).ok
411
+ : data.binding.contentRefs.every((reference) => reference.kind !== "file")),
412
+ )
413
+ .map(({ id }) => ({ id }));
414
+
415
+ const evidenceMatchesRequirement = (
416
+ kind: EvidenceEvaluation["status"],
417
+ evidenceKind: string,
418
+ dimension: Requirement["dimension"],
419
+ ): boolean => {
420
+ if (kind !== "passed") return false;
421
+ if (dimension === "testing" || dimension === "verification") return evidenceKind === "check";
422
+ if (dimension === "review") return evidenceKind === "review";
423
+ if (dimension === "investigation" || dimension === "challenge")
424
+ return evidenceKind === "investigation";
425
+ if (dimension === "artifacts" || dimension === "continuity") return evidenceKind === "artifact";
426
+ return false;
427
+ };
428
+
429
+ export function evaluateRequirements(
430
+ task: TaskRecord,
431
+ workspace: WorkspaceRecord,
432
+ capabilities: Capability[],
433
+ candidate: Candidate | null,
434
+ checkoutRoot: string,
435
+ _caller?: Caller,
436
+ ) {
437
+ const evidence = evaluateEvidence(task, candidate);
438
+ if (task.policy && task.policy.policyVersion !== POLICY_VERSION)
439
+ return task.policy.requirements.map((requirement) => ({
440
+ requirementId: requirement.id,
441
+ status: "unsatisfied" as const,
442
+ evidenceIds: [],
443
+ decisionIds: [],
444
+ reason: "stored policy version is unsupported; reassessment is required",
445
+ }));
446
+ return (task.policy?.requirements ?? []).map((requirement) => {
447
+ const related = task.evidence
448
+ .map((entry, index) => ({ entry, evaluation: evidence[index] }))
449
+ .filter(({ entry }) => entry.data.requirementIds.includes(requirement.id));
450
+ const passed = related.filter(({ entry, evaluation }) => {
451
+ if (!evidenceMatchesRequirement(evaluation.status, entry.data.kind, requirement.dimension))
452
+ return false;
453
+ if (
454
+ (entry.data.kind === "check" || entry.data.kind === "review") &&
455
+ (!entry.data.beforeCandidateId ||
456
+ !entry.data.candidateId ||
457
+ entry.data.beforeCandidateId !== entry.data.candidateId)
458
+ )
459
+ return false;
460
+ if (requirement.dimension !== "review") return true;
461
+ const reviewSession = sessionFromRef(entry.data.reviewContext);
462
+ const implementationSession = sessionFromRef(task.intent.provenance.session);
463
+ const sameImplementation = sameSession(reviewSession, implementationSession);
464
+ const sameEvidenceSession = task.evidence.some(
465
+ (other) => other.id !== entry.id && sameSession(reviewSession, other.provenance.session),
466
+ );
467
+ return (
468
+ entry.data.kind === "review" &&
469
+ reviewSession !== null &&
470
+ sameSession(reviewSession, entry.provenance.session) &&
471
+ !sameImplementation &&
472
+ !sameEvidenceSession
473
+ );
474
+ });
475
+ if (passed.length) {
476
+ // RED-first: a testing requirement is only satisfied by a pass that was
477
+ // preceded by a recorded failure on the same requirement. A GREEN with
478
+ // no RED is indistinguishable from code-then-tests.
479
+ const withRed =
480
+ requirement.dimension === "testing"
481
+ ? passed.filter(({ entry }) =>
482
+ related.some(
483
+ (other) =>
484
+ other.entry.data.kind === "check" &&
485
+ other.evaluation.status === "failed" &&
486
+ other.entry.data.requirementIds.includes(requirement.id) &&
487
+ other.entry.recordedAt <= entry.recordedAt,
488
+ ),
489
+ )
490
+ : passed;
491
+ if (withRed.length)
492
+ return {
493
+ requirementId: requirement.id,
494
+ status: "satisfied" as const,
495
+ evidenceIds: withRed.map(({ entry }) => entry.id),
496
+ decisionIds: [],
497
+ reason: "fresh applicable evidence passed",
498
+ };
499
+ if (requirement.dimension === "testing")
500
+ return {
501
+ requirementId: requirement.id,
502
+ status: "unsatisfied" as const,
503
+ evidenceIds: [],
504
+ decisionIds: [],
505
+ reason: "testing requirement has GREEN evidence but no preceding RED",
506
+ };
507
+ }
508
+ if (requirement.dimension === "delegation") {
509
+ // A bounded helper used for the requirement whose completed report is
510
+ // recorded reconciles the delegation: the lead closes on that result.
511
+ // Empty scope paths mean the whole checkout on both sides. Explicit
512
+ // requirement linkage wins for narrow requirements; scope coverage is
513
+ // the usual path since assignments live inside the requirement scope.
514
+ const dot = (scope: Scope): Scope =>
515
+ scope.paths.length > 0 ? scope : { ...scope, paths: ["."] };
516
+ const reconciled = task.workers.some(
517
+ (worker) =>
518
+ worker.data.report?.outcome === "completed" &&
519
+ (worker.data.assignment.requirementIds.includes(requirement.id) ||
520
+ scopeCovers(dot(requirement.scope), dot(worker.data.assignment.scope))),
521
+ );
522
+ if (reconciled)
523
+ return {
524
+ requirementId: requirement.id,
525
+ status: "satisfied" as const,
526
+ evidenceIds: [],
527
+ decisionIds: [],
528
+ reason: "bounded helper completed and its result is recorded",
529
+ };
530
+ }
531
+ const decisions =
532
+ requirement.dimension === "decisions"
533
+ ? applicableRequirementDecision(task, workspace, requirement, checkoutRoot)
534
+ : [];
535
+ if (decisions.length)
536
+ return {
537
+ requirementId: requirement.id,
538
+ status: "satisfied" as const,
539
+ evidenceIds: [],
540
+ decisionIds: decisions.map((decision) => decision.id),
541
+ reason: "an applicable approved decision satisfies the requirement",
542
+ };
543
+ const limitations = requirement.acceptanceAllowed
544
+ ? applicableDecision(task, workspace, requirement, checkoutRoot)
545
+ : [];
546
+ if (limitations.length)
547
+ return {
548
+ requirementId: requirement.id,
549
+ status: "accepted_limitation" as const,
550
+ evidenceIds: [],
551
+ decisionIds: limitations.map(
552
+ (decision) => task.decisions.find((entry) => entry.data === decision)!.id,
553
+ ),
554
+ reason: "an applicable approved limitation permits the missing evidence",
555
+ };
556
+ const unavailable = capabilities.some(
557
+ (capability) =>
558
+ capability.assurance === "unavailable" &&
559
+ (capability.name === requirement.dimension || capability.surface === requirement.dimension),
560
+ );
561
+ return {
562
+ requirementId: requirement.id,
563
+ status: unavailable ? ("unavailable" as const) : ("unsatisfied" as const),
564
+ evidenceIds: related.map(({ entry }) => entry.id),
565
+ decisionIds: [],
566
+ reason: unavailable ? "required capability is unavailable" : "no fresh passing evidence",
567
+ };
568
+ });
569
+ }
570
+
571
+ export type ClosureEvaluation = {
572
+ outcome: Outcome;
573
+ evidenceIds: string[];
574
+ decisionIds: string[];
575
+ requirementIds: string[];
576
+ };
577
+
578
+ export function evaluateClosure(
579
+ requestedOutcome: Outcome,
580
+ view: TaskView,
581
+ ): Result<ClosureEvaluation> {
582
+ if (requestedOutcome !== "stopped" && !view.task.policy)
583
+ return failure("requirements_unsatisfied", "task has not been assessed");
584
+ if (
585
+ view.task.policy &&
586
+ (view.task.policy as { policyVersion: string }).policyVersion !== POLICY_VERSION
587
+ )
588
+ return failure(
589
+ "requirements_unsatisfied",
590
+ "stored policy version is unsupported; reassessment is required",
591
+ );
592
+ const evaluations = view.requirements;
593
+ const openFindings = view.task.findings.filter((entry) => entry.data.disposition === "open");
594
+ if (requestedOutcome !== "stopped" && openFindings.length)
595
+ return failure("requirements_unsatisfied", "open findings must be resolved before closure");
596
+ if (requestedOutcome !== "stopped") {
597
+ const evidenceById = new Map(view.evidence.map((entry) => [entry.evidenceId, entry.status]));
598
+ for (const entry of view.task.findings) {
599
+ if (entry.data.disposition === "fixed") {
600
+ const verified = entry.data.resolution?.evidenceIds.some((id) => {
601
+ const evidence = view.task.evidence.find((item) => item.id === id);
602
+ return (
603
+ evidenceById.get(id) === "passed" &&
604
+ (evidence?.data.kind === "check" || evidence?.data.kind === "review")
605
+ );
606
+ });
607
+ if (!verified)
608
+ return failure("requirements_unsatisfied", "fixed findings require current verification");
609
+ }
610
+ if (entry.data.disposition === "deferred") {
611
+ const valid = entry.data.resolution?.decisionIds.some((id) => {
612
+ const decisionEntry = view.task.decisions.find((item) => item.id === id);
613
+ const decision = decisionEntry?.data;
614
+ return Boolean(
615
+ decision &&
616
+ decisionEntry?.provenance.kind !== "imported" &&
617
+ decision.purpose === "limitation" &&
618
+ decision.response === "approved" &&
619
+ decision.revoked === null &&
620
+ decision.digest === decisionDigest(decision) &&
621
+ verifyDecisionContentAtRoot(view.workspace.root, decision.binding).ok &&
622
+ decision.binding.taskId === view.task.id &&
623
+ decision.binding.workspaceId === view.workspace.id &&
624
+ scopeCovers(decision.binding.scope, entry.data.scope) &&
625
+ decision.requirementIds.some((requirementId) =>
626
+ view.task.policy?.requirements.some(
627
+ (requirement) => requirement.id === requirementId && requirement.acceptanceAllowed,
628
+ ),
629
+ ),
630
+ );
631
+ });
632
+ if (!valid)
633
+ return failure(
634
+ "requirements_unsatisfied",
635
+ "deferred findings require an approved limitation decision that references an acceptanceAllowed requirement and covers the finding scope",
636
+ );
637
+ }
638
+ }
639
+ }
640
+ const blocking = evaluations.filter(
641
+ (item) => item.status === "unsatisfied" || item.status === "unavailable",
642
+ );
643
+ if (requestedOutcome !== "stopped" && blocking.length)
644
+ return failure("requirements_unsatisfied", "applicable requirements are unsatisfied", {
645
+ requirementIds: blocking.map((item) => item.requirementId),
646
+ });
647
+ const accepted = evaluations.filter((item) => item.status === "accepted_limitation");
648
+ if (requestedOutcome === "verified" && accepted.length)
649
+ return failure(
650
+ "requirements_unsatisfied",
651
+ "accepted limitations cannot be reported as verified",
652
+ {
653
+ requirementIds: accepted.map((item) => item.requirementId),
654
+ },
655
+ );
656
+ const evidenceIds = [
657
+ ...new Set([
658
+ ...evaluations.flatMap((item) => item.evidenceIds),
659
+ ...view.task.findings.flatMap((item) => item.data.resolution?.evidenceIds ?? []),
660
+ ]),
661
+ ];
662
+ const decisionIds = [
663
+ ...new Set([
664
+ ...evaluations.flatMap((item) => item.decisionIds),
665
+ ...view.task.findings.flatMap((item) => item.data.resolution?.decisionIds ?? []),
666
+ ]),
667
+ ];
668
+ return success(null, null, {
669
+ outcome:
670
+ requestedOutcome === "stopped"
671
+ ? "stopped"
672
+ : accepted.length
673
+ ? "accepted_limitations"
674
+ : "verified",
675
+ evidenceIds,
676
+ decisionIds,
677
+ requirementIds: evaluations.map((item) => item.requirementId),
678
+ });
679
+ }