@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.
- package/README.md +5 -3
- package/package.json +8 -3
- package/scripts/analyze-release-scope.ts +4 -1
- package/scripts/doctor-check.ts +3 -1
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +1 -0
- package/scripts/install-opencode-plugin.sh +1 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/rewrite-workspace-deps.ts +22 -5
- package/scripts/sync-release-manifests.ts +8 -5
- package/scripts/sync-runtime.sh +7 -3
- package/scripts/validate-cursor-marketplace.ts +16 -62
- package/skills/workit-babysit/SKILL.md +33 -0
- package/skills/workit-behavioral-tdd/SKILL.md +53 -0
- package/skills/workit-blast-radius/SKILL.md +31 -0
- package/skills/workit-challenge/SKILL.md +62 -0
- package/skills/workit-debug/SKILL.md +61 -0
- package/skills/workit-deslop/SKILL.md +36 -0
- package/skills/workit-diagram/SKILL.md +32 -0
- package/skills/workit-green-run/SKILL.md +29 -0
- package/skills/workit-handoff/SKILL.md +43 -0
- package/skills/workit-implement/SKILL.md +46 -0
- package/skills/workit-mockup/SKILL.md +28 -0
- package/skills/workit-plan/SKILL.md +66 -0
- package/skills/workit-review/SKILL.md +60 -0
- package/skills/workit-steer/SKILL.md +32 -0
- package/src/core/authority.ts +918 -0
- package/src/core/boundary.ts +29 -0
- package/src/core/branch.ts +26 -255
- package/src/core/changelog.ts +95 -18
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-conversion.ts +183 -0
- package/src/core/config.ts +42 -0
- package/src/core/cutover.ts +721 -0
- package/src/core/detect-hosts.ts +107 -0
- package/src/core/doctor.ts +428 -18
- package/src/core/external-action-effects.ts +1171 -0
- package/src/core/external-action.ts +541 -0
- package/src/core/init.ts +83 -32
- package/src/core/methods.ts +125 -0
- package/src/core/policy-resolver.ts +523 -0
- package/src/core/pr-create.ts +7 -1
- package/src/core/registration.ts +69 -4
- package/src/core/repo-context.ts +59 -1
- package/src/core/setup.ts +28 -0
- package/src/core/skill-manifests.ts +40 -42
- package/src/core/support-matrix.ts +8 -3
- package/src/core/sync-runtime.ts +15 -3
- package/src/core/task-context.ts +179 -0
- package/src/core/task-contract.ts +1136 -0
- package/src/core/task-engine.ts +2562 -0
- package/src/core/task-evaluation.ts +679 -0
- package/src/core/task-store.ts +1071 -0
- package/src/core/tracker-issues.ts +237 -0
- package/src/core/uninstall.ts +67 -3
- package/src/core/vcs-config.ts +53 -22
- package/src/core/workers.ts +166 -0
- package/src/core/workspaces.ts +3 -1
- package/src/core/youtrack-tools.ts +2 -0
- package/src/core/youtrack.ts +145 -10
- package/src/core.ts +126 -0
- package/templates/execution-contract.md +17 -50
- package/templates/plan-template.md +3 -3
- package/templates/spec-template.md +15 -3
- package/templates/workit-contract.md +12 -0
- package/commands/wk-changelog.md +0 -2
- package/commands/wk-commit.md +0 -2
- package/commands/wk-docs-refresh.md +0 -2
- package/commands/wk-handoff.md +0 -2
- package/commands/wk-implement.md +0 -2
- package/commands/wk-init.md +0 -2
- package/commands/wk-issue-update.md +0 -2
- package/commands/wk-meetings.md +0 -2
- package/commands/wk-pr.md +0 -2
- package/commands/wk-release-notes.md +0 -2
- package/commands/wk-status.md +0 -2
- package/commands/wk-verify.md +0 -2
- package/scripts/update-superpowers.sh +0 -82
- package/scripts/vendor-assets.ts +0 -37
- package/skills/wk-changelog/SKILL.md +0 -15
- package/skills/wk-commit/SKILL.md +0 -16
- package/skills/wk-docs-refresh/SKILL.md +0 -15
- package/skills/wk-handoff/SKILL.md +0 -20
- package/skills/wk-implement/SKILL.md +0 -47
- package/skills/wk-init/SKILL.md +0 -31
- package/skills/wk-issue-update/SKILL.md +0 -27
- package/skills/wk-issue-update/references/youtrack-update-style.md +0 -81
- package/skills/wk-meetings/SKILL.md +0 -17
- package/skills/wk-pr/SKILL.md +0 -27
- package/skills/wk-release-notes/SKILL.md +0 -15
- package/skills/wk-status/SKILL.md +0 -16
- package/skills/wk-verify/SKILL.md +0 -16
- package/src/core/detector.ts +0 -239
- package/src/core/flow-state.ts +0 -3048
- package/src/core/handoff-context.ts +0 -136
- package/src/core/handoff-tools.ts +0 -133
- package/src/core/menu.ts +0 -70
- package/src/core/plan-tasks.ts +0 -43
- package/src/core/reminder.ts +0 -124
- package/src/core/sdd.ts +0 -353
- package/src/state.ts +0 -22
- package/templates/superpowers-doc-contract.md +0 -75
- package/vendor/superpowers/skills/brainstorming/SKILL.md +0 -159
- package/vendor/superpowers/skills/brainstorming/scripts/frame-template.html +0 -213
- package/vendor/superpowers/skills/brainstorming/scripts/helper.js +0 -167
- package/vendor/superpowers/skills/brainstorming/scripts/server.cjs +0 -723
- package/vendor/superpowers/skills/brainstorming/scripts/start-server.sh +0 -209
- package/vendor/superpowers/skills/brainstorming/scripts/stop-server.sh +0 -120
- package/vendor/superpowers/skills/brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/vendor/superpowers/skills/brainstorming/visual-companion.md +0 -291
- package/vendor/superpowers/skills/dispatching-parallel-agents/SKILL.md +0 -185
- package/vendor/superpowers/skills/executing-plans/SKILL.md +0 -70
- package/vendor/superpowers/skills/finishing-a-development-branch/SKILL.md +0 -241
- package/vendor/superpowers/skills/receiving-code-review/SKILL.md +0 -213
- package/vendor/superpowers/skills/requesting-code-review/SKILL.md +0 -103
- package/vendor/superpowers/skills/requesting-code-review/code-reviewer.md +0 -172
- package/vendor/superpowers/skills/subagent-driven-development/SKILL.md +0 -426
- package/vendor/superpowers/skills/subagent-driven-development/implementer-prompt.md +0 -139
- package/vendor/superpowers/skills/subagent-driven-development/scripts/review-package +0 -44
- package/vendor/superpowers/skills/subagent-driven-development/scripts/sdd-workspace +0 -22
- package/vendor/superpowers/skills/subagent-driven-development/scripts/task-brief +0 -40
- package/vendor/superpowers/skills/subagent-driven-development/task-reviewer-prompt.md +0 -188
- package/vendor/superpowers/skills/systematic-debugging/CREATION-LOG.md +0 -119
- package/vendor/superpowers/skills/systematic-debugging/SKILL.md +0 -296
- package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting-example.ts +0 -158
- package/vendor/superpowers/skills/systematic-debugging/condition-based-waiting.md +0 -115
- package/vendor/superpowers/skills/systematic-debugging/defense-in-depth.md +0 -122
- package/vendor/superpowers/skills/systematic-debugging/find-polluter.sh +0 -63
- package/vendor/superpowers/skills/systematic-debugging/root-cause-tracing.md +0 -169
- package/vendor/superpowers/skills/systematic-debugging/test-academic.md +0 -14
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-1.md +0 -58
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-2.md +0 -68
- package/vendor/superpowers/skills/systematic-debugging/test-pressure-3.md +0 -69
- package/vendor/superpowers/skills/test-driven-development/SKILL.md +0 -371
- package/vendor/superpowers/skills/test-driven-development/testing-anti-patterns.md +0 -299
- package/vendor/superpowers/skills/using-git-worktrees/SKILL.md +0 -202
- package/vendor/superpowers/skills/using-superpowers/SKILL.md +0 -62
- package/vendor/superpowers/skills/using-superpowers/references/antigravity-tools.md +0 -23
- package/vendor/superpowers/skills/using-superpowers/references/codex-tools.md +0 -39
- package/vendor/superpowers/skills/using-superpowers/references/pi-tools.md +0 -16
- package/vendor/superpowers/skills/verification-before-completion/SKILL.md +0 -139
- package/vendor/superpowers/skills/writing-plans/SKILL.md +0 -174
- package/vendor/superpowers/skills/writing-plans/plan-document-reviewer-prompt.md +0 -49
- package/vendor/superpowers/skills/writing-skills/SKILL.md +0 -689
- package/vendor/superpowers/skills/writing-skills/anthropic-best-practices.md +0 -1150
- package/vendor/superpowers/skills/writing-skills/examples/CLAUDE_MD_TESTING.md +0 -189
- package/vendor/superpowers/skills/writing-skills/graphviz-conventions.dot +0 -172
- package/vendor/superpowers/skills/writing-skills/persuasion-principles.md +0 -187
- package/vendor/superpowers/skills/writing-skills/render-graphs.js +0 -168
- 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
|
+
}
|