@brainervirus/workit-core 6.0.1 → 7.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/package.json +1 -1
- package/skills/workit-deslop/SKILL.md +1 -1
- package/skills/workit-implement/SKILL.md +9 -4
- package/skills/workit-review/SKILL.md +2 -0
- package/skills/workit-shape/SKILL.md +1 -1
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/autonomy.ts +24 -0
- package/src/core/methods.ts +7 -5
- package/src/core/operation-input.ts +506 -0
- package/src/core/policy/derive.ts +338 -0
- package/src/core/policy/judgment.ts +192 -0
- package/src/core/policy-resolver.ts +32 -427
- package/src/core/task-contract.ts +40 -229
- package/src/core/task-engine.ts +51 -58
- package/src/core/task-evaluation.ts +35 -1
- package/src/core/workspaces.ts +2 -0
- package/src/core.ts +8 -6
- package/src/hooks/handle.ts +19 -4
- package/src/hooks/hosts/claude-code.ts +20 -1
- package/src/hooks/hosts/codex.ts +3 -0
- package/src/hooks/hosts/cursor.ts +11 -2
- package/src/hooks/hosts/fields.ts +35 -0
- package/src/hooks/hosts/opencode.ts +1 -0
- package/src/hooks/hosts/pi.ts +1 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/protocol.ts +3 -0
- package/src/hooks/write-gate.ts +303 -0
- package/src/ledger.ts +20 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// S17 slim policy: requirements derived deterministically from the four
|
|
2
|
+
// judgments plus project constraints (design §4.3), and the status of the
|
|
3
|
+
// rules that read the ledger or the plan instead of task evidence.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { currentBranch } from "../../git/rev";
|
|
7
|
+
import { STRONG_RESULTS, checkVerdicts, readLedger, type ReadRow } from "../../ledger";
|
|
8
|
+
import type { VerificationMode } from "../../autonomy";
|
|
9
|
+
import {
|
|
10
|
+
requirementId,
|
|
11
|
+
sha256,
|
|
12
|
+
type Constraint,
|
|
13
|
+
type Judgment,
|
|
14
|
+
type Requirement,
|
|
15
|
+
type Scope,
|
|
16
|
+
type TaskRecord,
|
|
17
|
+
} from "../task-contract";
|
|
18
|
+
|
|
19
|
+
export const RULES = {
|
|
20
|
+
test: "check:test",
|
|
21
|
+
self: "verdict:self",
|
|
22
|
+
nonAuthor: "verdict:non-author",
|
|
23
|
+
verified: "verdict:verified",
|
|
24
|
+
product: "decision:product",
|
|
25
|
+
plan: "plan",
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
const derived = (spec: Omit<Requirement, "id">): Requirement => ({
|
|
29
|
+
...spec,
|
|
30
|
+
id: requirementId({
|
|
31
|
+
ruleId: spec.ruleId,
|
|
32
|
+
scope: spec.scope,
|
|
33
|
+
satisfaction: spec.satisfaction,
|
|
34
|
+
before: spec.before,
|
|
35
|
+
dependentAction: spec.dependentAction,
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const sorted = (values: string[]) => [...new Set(values)].toSorted();
|
|
40
|
+
const normalizedScope = (scope: Scope): Scope => ({
|
|
41
|
+
description: scope.description,
|
|
42
|
+
paths: sorted(scope.paths),
|
|
43
|
+
exclusions: sorted(scope.exclusions),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* | judgment | requirement |
|
|
48
|
+
* | behaviorChange | check:test (configured, fresh tree), close |
|
|
49
|
+
* | behaviorChange && risk = normal | verdict:self (default) or verdict:non-author |
|
|
50
|
+
* | | (workspace verification: independent), close |
|
|
51
|
+
* | risk = high | verdict:verified (ledger), close |
|
|
52
|
+
* | productChoiceOpen | decision:product, before write |
|
|
53
|
+
* | needsPlan or risk = high | plan (doc ref, soft), before write |
|
|
54
|
+
* | always | project constraints |
|
|
55
|
+
*/
|
|
56
|
+
export function deriveRequirements(
|
|
57
|
+
judgment: Judgment,
|
|
58
|
+
scope: Scope,
|
|
59
|
+
constraints: Constraint[],
|
|
60
|
+
/** The workspace's user-config verification mode (autonomy.ts). */
|
|
61
|
+
verification: VerificationMode = "self",
|
|
62
|
+
): Requirement[] {
|
|
63
|
+
const out: Requirement[] = [];
|
|
64
|
+
const base = { scope, dependentAction: null };
|
|
65
|
+
if (judgment.behaviorChange)
|
|
66
|
+
out.push(
|
|
67
|
+
derived({
|
|
68
|
+
...base,
|
|
69
|
+
ruleId: RULES.test,
|
|
70
|
+
dimension: "testing",
|
|
71
|
+
reason: "Behavior changes: the configured test check must pass on the final tree.",
|
|
72
|
+
satisfaction: "Run `workit check test` on the final tree.",
|
|
73
|
+
before: "close",
|
|
74
|
+
acceptanceAllowed: false,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
if (judgment.behaviorChange && judgment.riskTier === "normal")
|
|
78
|
+
out.push(
|
|
79
|
+
verification === "independent"
|
|
80
|
+
? derived({
|
|
81
|
+
...base,
|
|
82
|
+
ruleId: RULES.nonAuthor,
|
|
83
|
+
dimension: "review",
|
|
84
|
+
reason:
|
|
85
|
+
"This workspace requires independent verification: a session that did not author the change records the verdict.",
|
|
86
|
+
satisfaction:
|
|
87
|
+
"An independent session records a passing verdict: `workit ledger verdict verified --how <what it exercised>`.",
|
|
88
|
+
before: "close",
|
|
89
|
+
acceptanceAllowed: false,
|
|
90
|
+
})
|
|
91
|
+
: derived({
|
|
92
|
+
...base,
|
|
93
|
+
ruleId: RULES.self,
|
|
94
|
+
dimension: "review",
|
|
95
|
+
reason:
|
|
96
|
+
"A behavior change needs a recorded verdict; the author's own is allowed and is shown as self-reviewed, never verified.",
|
|
97
|
+
satisfaction:
|
|
98
|
+
"After an observed passing `workit check test`, record `workit ledger verdict tests-verified --self --how <what you ran>` (or have a verifier record one).",
|
|
99
|
+
before: "close",
|
|
100
|
+
acceptanceAllowed: false,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
if (judgment.riskTier === "high")
|
|
104
|
+
out.push(
|
|
105
|
+
derived({
|
|
106
|
+
...base,
|
|
107
|
+
ruleId: RULES.verified,
|
|
108
|
+
dimension: "review",
|
|
109
|
+
reason: "High risk: an independent verifier must exercise the live surface.",
|
|
110
|
+
satisfaction:
|
|
111
|
+
"An independent verifier runs the real surface and records `workit ledger verdict verified --kind live --how <what it exercised>`.",
|
|
112
|
+
before: "close",
|
|
113
|
+
acceptanceAllowed: false,
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
if (judgment.productChoiceOpen)
|
|
117
|
+
out.push(
|
|
118
|
+
derived({
|
|
119
|
+
...base,
|
|
120
|
+
ruleId: RULES.product,
|
|
121
|
+
dimension: "decisions",
|
|
122
|
+
reason:
|
|
123
|
+
"A product or preference choice is open; the user decides it before code is written.",
|
|
124
|
+
satisfaction:
|
|
125
|
+
'Ask the user (with your recommendation), then record the answer: `workit ledger decision "<choice>" --why "<reason>"`.',
|
|
126
|
+
before: "write",
|
|
127
|
+
acceptanceAllowed: false,
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
if (judgment.needsPlan || judgment.riskTier === "high")
|
|
131
|
+
out.push(
|
|
132
|
+
derived({
|
|
133
|
+
...base,
|
|
134
|
+
ruleId: RULES.plan,
|
|
135
|
+
dimension: "artifacts",
|
|
136
|
+
reason: judgment.needsPlan
|
|
137
|
+
? "The work needs a written plan or spec before implementation."
|
|
138
|
+
: "High-risk work needs a written plan before implementation.",
|
|
139
|
+
satisfaction:
|
|
140
|
+
"Write the plan or spec (e.g. docs/plans/<slug>.md; docs and Markdown writes stay allowed), then record it: `workit policy assess --ref <path>`.",
|
|
141
|
+
before: "write",
|
|
142
|
+
acceptanceAllowed: true,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
for (const constraint of constraints)
|
|
146
|
+
for (const obligation of constraint.requires)
|
|
147
|
+
out.push(
|
|
148
|
+
derived({
|
|
149
|
+
ruleId: `project-constraint:${constraint.id}:${obligation.kind}:${sha256({
|
|
150
|
+
kind: obligation.kind,
|
|
151
|
+
scope: normalizedScope(obligation.scope),
|
|
152
|
+
refs: obligation.refs.map((ref) => JSON.stringify(ref)).toSorted(),
|
|
153
|
+
method: obligation.method,
|
|
154
|
+
before: obligation.before,
|
|
155
|
+
dependentAction: obligation.dependentAction,
|
|
156
|
+
})}`,
|
|
157
|
+
dimension:
|
|
158
|
+
obligation.method ??
|
|
159
|
+
(obligation.kind === "check"
|
|
160
|
+
? "verification"
|
|
161
|
+
: obligation.kind === "decision"
|
|
162
|
+
? "decisions"
|
|
163
|
+
: "challenge"),
|
|
164
|
+
scope: obligation.scope,
|
|
165
|
+
reason: `${constraint.kind} constraint requires ${obligation.kind}: ${constraint.statement}`,
|
|
166
|
+
satisfaction: `Satisfy the constraint obligation using its referenced ${obligation.kind}.`,
|
|
167
|
+
before: obligation.before,
|
|
168
|
+
dependentAction: obligation.dependentAction,
|
|
169
|
+
acceptanceAllowed: constraint.acceptanceAllowed && obligation.kind !== "decision",
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
const seen = new Set<string>();
|
|
173
|
+
return out.filter((item) => (seen.has(item.id) ? false : (seen.add(item.id), true)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const latestJudgment = (task: TaskRecord): Judgment | null =>
|
|
177
|
+
task.judgments?.at(-1)?.data ?? null;
|
|
178
|
+
|
|
179
|
+
/** Lazily read ledger rows and the checked-out branch, once per evaluation. */
|
|
180
|
+
export type LedgerView = { branch: () => string | null; rows: () => readonly ReadRow[] };
|
|
181
|
+
export const ledgerView = (root: string): LedgerView => {
|
|
182
|
+
let branch: string | null | undefined;
|
|
183
|
+
let rows: readonly ReadRow[] | undefined;
|
|
184
|
+
return {
|
|
185
|
+
branch: () => (branch === undefined ? (branch = currentBranch(root)) : branch),
|
|
186
|
+
rows: () => {
|
|
187
|
+
if (rows === undefined) {
|
|
188
|
+
const read = readLedger(root);
|
|
189
|
+
rows = read.ok ? read.value.rows : [];
|
|
190
|
+
}
|
|
191
|
+
return rows;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export type RuleStatus = { met: boolean; reason: string; decisionIds: string[] };
|
|
197
|
+
|
|
198
|
+
/** A user decision recorded on the task, or a ledger decision on this branch since the task began. */
|
|
199
|
+
const productDecision = (task: TaskRecord, ledger: LedgerView): RuleStatus => {
|
|
200
|
+
const decisions = task.decisions.filter(
|
|
201
|
+
(entry) =>
|
|
202
|
+
entry.data.revoked === null &&
|
|
203
|
+
(entry.data.response === "approved" || entry.data.response === "stated") &&
|
|
204
|
+
entry.data.purpose !== "limitation",
|
|
205
|
+
);
|
|
206
|
+
if (decisions.length)
|
|
207
|
+
return {
|
|
208
|
+
met: true,
|
|
209
|
+
reason: "the user's decision is recorded on the task",
|
|
210
|
+
decisionIds: decisions.map((entry) => entry.id),
|
|
211
|
+
};
|
|
212
|
+
const since = Date.parse(task.createdAt);
|
|
213
|
+
const branch = ledger.branch();
|
|
214
|
+
// Outside git (no branch) the checkout's own ledger rows carry no branch either.
|
|
215
|
+
const row = ledger
|
|
216
|
+
.rows()
|
|
217
|
+
.findLast(
|
|
218
|
+
(item) =>
|
|
219
|
+
item.type === "decision" &&
|
|
220
|
+
!item.superseded &&
|
|
221
|
+
(item.branch ?? null) === branch &&
|
|
222
|
+
Date.parse(item.at) >= since,
|
|
223
|
+
);
|
|
224
|
+
return row
|
|
225
|
+
? { met: true, reason: `ledger decision ${row.id} records the choice`, decisionIds: [] }
|
|
226
|
+
: {
|
|
227
|
+
met: false,
|
|
228
|
+
reason:
|
|
229
|
+
'open product choice: ask the user, then `workit ledger decision "<choice>" --why "<reason>"` (or re-judge productChoiceOpen=false if it is not open)',
|
|
230
|
+
decisionIds: [],
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** The latest judgment cites an existing doc or a URL, or an artifact passed. */
|
|
235
|
+
const planRecorded = (task: TaskRecord, root: string): RuleStatus => {
|
|
236
|
+
const cited = (latestJudgment(task)?.refs ?? []).find(
|
|
237
|
+
(ref) =>
|
|
238
|
+
ref.kind === "external" || (ref.kind === "file" && existsSync(path.resolve(root, ref.path))),
|
|
239
|
+
);
|
|
240
|
+
if (cited)
|
|
241
|
+
return {
|
|
242
|
+
met: true,
|
|
243
|
+
reason: `plan recorded: ${cited.kind === "file" ? cited.path : cited.kind === "external" ? cited.url : ""}`,
|
|
244
|
+
decisionIds: [],
|
|
245
|
+
};
|
|
246
|
+
const artifact = task.evidence.some(
|
|
247
|
+
(entry) => entry.data.kind === "artifact" && entry.data.result === "passed",
|
|
248
|
+
);
|
|
249
|
+
return artifact
|
|
250
|
+
? { met: true, reason: "a passing artifact records the plan", decisionIds: [] }
|
|
251
|
+
: {
|
|
252
|
+
met: false,
|
|
253
|
+
reason:
|
|
254
|
+
"no plan recorded: write it (docs/ and Markdown writes stay allowed), then `workit policy assess --ref <path>`",
|
|
255
|
+
decisionIds: [],
|
|
256
|
+
};
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Verdict rules over the D18 ledger. `type-check-only` never proves a
|
|
261
|
+
* behavior change. `verdict:self` takes the author's own current `--self`
|
|
262
|
+
* verdict (shown as self-reviewed) or an independent one; `verdict:non-author`
|
|
263
|
+
* an accepted independent one; `verdict:verified` an accepted independent
|
|
264
|
+
* `verified` verdict of kind `live`.
|
|
265
|
+
*/
|
|
266
|
+
const verdict = (requirement: Requirement, root: string, ledger: LedgerView): RuleStatus => {
|
|
267
|
+
const branch = ledger.branch();
|
|
268
|
+
if (!branch)
|
|
269
|
+
return { met: false, reason: "no branch is checked out to hold a verdict", decisionIds: [] };
|
|
270
|
+
const check = checkVerdicts(root, branch, ledger.rows());
|
|
271
|
+
const failing = check.accepted.reasons.includes("failing_verdict");
|
|
272
|
+
const strong = (entry: (typeof check.verdicts)[number]) =>
|
|
273
|
+
STRONG_RESULTS.has(String(entry.verdict.result));
|
|
274
|
+
const met =
|
|
275
|
+
!failing &&
|
|
276
|
+
(requirement.ruleId === RULES.verified
|
|
277
|
+
? check.verdicts.find(
|
|
278
|
+
(entry) =>
|
|
279
|
+
entry.accepted && entry.kind === "live" && String(entry.verdict.result) === "verified",
|
|
280
|
+
)
|
|
281
|
+
: requirement.ruleId === RULES.nonAuthor
|
|
282
|
+
? check.verdicts.find((entry) => entry.accepted && strong(entry))
|
|
283
|
+
: check.verdicts.find((entry) => entry.current && strong(entry)));
|
|
284
|
+
if (met)
|
|
285
|
+
return {
|
|
286
|
+
met: true,
|
|
287
|
+
reason: `${met.accepted ? "accepted independent" : "self-reviewed (author's own)"} ${String(met.verdict.result)} verdict ${met.verdict.id}`,
|
|
288
|
+
decisionIds: [],
|
|
289
|
+
};
|
|
290
|
+
const why = failing
|
|
291
|
+
? "a current independent verdict fails"
|
|
292
|
+
: check.verdicts.some((entry) => entry.current && entry.verdict.result === "type-check-only")
|
|
293
|
+
? "type-check-only does not prove a behavior change"
|
|
294
|
+
: (check.accepted.reasons.join(", ") || "no_verdict").replace(/_/g, " ");
|
|
295
|
+
const unblock =
|
|
296
|
+
requirement.ruleId === RULES.self
|
|
297
|
+
? "record your own verdict after an observed `workit check test`: `workit ledger verdict tests-verified --self --how <what you ran>` (shown as self-reviewed), or have a verifier record one"
|
|
298
|
+
: `an independent session records \`workit ledger verdict verified${requirement.ruleId === RULES.verified ? " --kind live" : ""} --how <what it exercised>\``;
|
|
299
|
+
return { met: false, reason: `${why}: ${unblock}`, decisionIds: [] };
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/** Status of a derived rule that is not decided by task evidence; null for every other rule. */
|
|
303
|
+
export function derivedRuleStatus(
|
|
304
|
+
task: TaskRecord,
|
|
305
|
+
requirement: Requirement,
|
|
306
|
+
root: string,
|
|
307
|
+
ledger: LedgerView,
|
|
308
|
+
): RuleStatus | null {
|
|
309
|
+
switch (requirement.ruleId) {
|
|
310
|
+
case RULES.product:
|
|
311
|
+
return productDecision(task, ledger);
|
|
312
|
+
case RULES.plan:
|
|
313
|
+
return planRecorded(task, root);
|
|
314
|
+
case RULES.self:
|
|
315
|
+
case RULES.nonAuthor:
|
|
316
|
+
case RULES.verified:
|
|
317
|
+
return verdict(requirement, root, ledger);
|
|
318
|
+
default:
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type WriteBlocker = { ruleId: string; reason: string };
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Unmet before-write requirements of a task (S17 write gate). Only derived
|
|
327
|
+
* rules are decided here, cheaply and without capturing a candidate;
|
|
328
|
+
* constraint write obligations stay advisory.
|
|
329
|
+
*/
|
|
330
|
+
export function writeBlockers(task: TaskRecord, root: string): WriteBlocker[] {
|
|
331
|
+
if (task.status === "closed" || !task.policy) return [];
|
|
332
|
+
const ledger = ledgerView(root);
|
|
333
|
+
return task.policy.requirements.flatMap((requirement) => {
|
|
334
|
+
if (requirement.before !== "write") return [];
|
|
335
|
+
const status = derivedRuleStatus(task, requirement, root, ledger);
|
|
336
|
+
return status && !status.met ? [{ ruleId: requirement.ruleId, reason: status.reason }] : [];
|
|
337
|
+
});
|
|
338
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The agent's policy judgment as sent: four flat calls with aliases, or a
|
|
2
|
+
// ≤6.x assessment (facts/signals/…), mapped leniently for one major (D17).
|
|
3
|
+
import { refOf } from "../operation-input";
|
|
4
|
+
import {
|
|
5
|
+
failure,
|
|
6
|
+
judgmentSchema,
|
|
7
|
+
success,
|
|
8
|
+
type Judgment,
|
|
9
|
+
type Result,
|
|
10
|
+
type RiskTier,
|
|
11
|
+
} from "../task-contract";
|
|
12
|
+
|
|
13
|
+
type Json = Record<string, unknown>;
|
|
14
|
+
const isObject = (value: unknown): value is Json =>
|
|
15
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
|
|
17
|
+
/** Keys compared without case, `_` or `-`: `needs_plan`, `Needs-Plan` → `needsplan`. */
|
|
18
|
+
const squash = (key: string) => key.toLowerCase().replace(/[_-]/g, "");
|
|
19
|
+
const ALIASES: Record<keyof Omit<Judgment, "refs" | "note"> | "note" | "refs", string[]> = {
|
|
20
|
+
riskTier: ["risktier", "risk", "risklevel", "tier"],
|
|
21
|
+
behaviorChange: ["behaviorchange", "behavior", "behaviour", "behaviourchange"],
|
|
22
|
+
productChoiceOpen: ["productchoiceopen", "openproductchoice", "productchoice", "openchoice"],
|
|
23
|
+
needsPlan: ["needsplan", "plan", "needsspec", "spec"],
|
|
24
|
+
note: ["note", "notes", "reason", "why"],
|
|
25
|
+
refs: ["refs", "ref", "references"],
|
|
26
|
+
};
|
|
27
|
+
const LEGACY_KEYS = new Set(["assessment", "facts", "signals", "consequences", "verification"]);
|
|
28
|
+
|
|
29
|
+
const RISK: Record<string, RiskTier> = {
|
|
30
|
+
trivial: "trivial",
|
|
31
|
+
low: "trivial",
|
|
32
|
+
minor: "trivial",
|
|
33
|
+
mechanical: "trivial",
|
|
34
|
+
none: "trivial",
|
|
35
|
+
normal: "normal",
|
|
36
|
+
medium: "normal",
|
|
37
|
+
med: "normal",
|
|
38
|
+
moderate: "normal",
|
|
39
|
+
high: "high",
|
|
40
|
+
critical: "high",
|
|
41
|
+
major: "high",
|
|
42
|
+
};
|
|
43
|
+
const YES = new Set(["true", "yes", "y", "1", "on"]);
|
|
44
|
+
const NO = new Set(["false", "no", "n", "0", "off", "none"]);
|
|
45
|
+
|
|
46
|
+
type Field = { ok: true; value: boolean } | { ok: false };
|
|
47
|
+
const flag = (value: unknown, unknownAs: boolean): Field => {
|
|
48
|
+
if (typeof value === "boolean") return { ok: true, value };
|
|
49
|
+
if (typeof value === "number" && (value === 0 || value === 1))
|
|
50
|
+
return { ok: true, value: value === 1 };
|
|
51
|
+
if (typeof value !== "string") return { ok: false };
|
|
52
|
+
const text = value.trim().toLowerCase();
|
|
53
|
+
if (YES.has(text)) return { ok: true, value: true };
|
|
54
|
+
if (NO.has(text)) return { ok: true, value: false };
|
|
55
|
+
if (text === "unknown" || text === "maybe") return { ok: true, value: unknownAs };
|
|
56
|
+
return { ok: false };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A ≤6.x assessment's signals mapped onto the four judgments, conservatively:
|
|
61
|
+
* risk is capped at normal and is high only where the 6.x resolver itself
|
|
62
|
+
* demanded fresh-context review (an explicit behavior change with a
|
|
63
|
+
* security, data, public-contract or operations consequence). An `unknown`
|
|
64
|
+
* signal keeps the previous judgment (else false).
|
|
65
|
+
*/
|
|
66
|
+
const fromLegacy = (raw: Json, previous: Judgment | null): Partial<Judgment> => {
|
|
67
|
+
const source = isObject(raw.assessment) ? raw.assessment : raw;
|
|
68
|
+
const signals = isObject(source.signals) ? source.signals : {};
|
|
69
|
+
const value = (name: string): unknown =>
|
|
70
|
+
isObject(signals[name]) ? signals[name].value : undefined;
|
|
71
|
+
const known = (name: string, fallback: boolean): boolean =>
|
|
72
|
+
typeof value(name) === "boolean" ? (value(name) as boolean) : fallback;
|
|
73
|
+
const behaviorChange = known("behaviorChange", previous?.behaviorChange ?? false);
|
|
74
|
+
const consequences = Array.isArray(source.consequences) ? source.consequences : [];
|
|
75
|
+
const freshReview =
|
|
76
|
+
value("behaviorChange") === true &&
|
|
77
|
+
consequences.some(
|
|
78
|
+
(item) =>
|
|
79
|
+
isObject(item) &&
|
|
80
|
+
["security", "data", "public_contract", "operations"].includes(String(item.area)),
|
|
81
|
+
);
|
|
82
|
+
return {
|
|
83
|
+
riskTier: freshReview
|
|
84
|
+
? "high"
|
|
85
|
+
: value("mechanicalLowRisk") === true && !behaviorChange
|
|
86
|
+
? "trivial"
|
|
87
|
+
: "normal",
|
|
88
|
+
behaviorChange,
|
|
89
|
+
productChoiceOpen: known("productChoiceOpen", previous?.productChoiceOpen ?? false),
|
|
90
|
+
needsPlan:
|
|
91
|
+
known("durableAgreementNeeded", previous?.needsPlan ?? false) ||
|
|
92
|
+
value("coordinationPlanNeeded") === true,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const DEFAULT: Judgment = {
|
|
97
|
+
riskTier: "normal",
|
|
98
|
+
behaviorChange: false,
|
|
99
|
+
productChoiceOpen: false,
|
|
100
|
+
needsPlan: false,
|
|
101
|
+
note: null,
|
|
102
|
+
refs: [],
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type NormalizedJudgment = { judgment: Judgment; ignored: string[]; legacy: boolean };
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Map a judgment as sent onto the stored shape. Omitted judgments keep the
|
|
109
|
+
* `previous` values and refs accumulate (a re-assessment can add only a plan ref), else the
|
|
110
|
+
* defaults (normal risk, no behavior change, no open choice, no plan). A
|
|
111
|
+
* ≤6.x assessment maps what it can and ignores the rest. Only an
|
|
112
|
+
* unrecognizable value for a known judgment is invalid input.
|
|
113
|
+
*/
|
|
114
|
+
export function normalizeJudgment(
|
|
115
|
+
raw: unknown,
|
|
116
|
+
previous: Judgment | null,
|
|
117
|
+
): Result<NormalizedJudgment> {
|
|
118
|
+
const input = isObject(raw) ? raw : {};
|
|
119
|
+
const legacy = Object.keys(input).some((key) => LEGACY_KEYS.has(key));
|
|
120
|
+
// Refs accumulate across re-assessments; a new note replaces the old one.
|
|
121
|
+
const base: Judgment = { ...DEFAULT, ...previous, refs: [...(previous?.refs ?? [])] };
|
|
122
|
+
const judgment: Judgment = legacy ? { ...base, ...fromLegacy(input, previous) } : { ...base };
|
|
123
|
+
const ignored: string[] = [];
|
|
124
|
+
const fields: { path: string; reason: string }[] = [];
|
|
125
|
+
for (const [key, value] of Object.entries(input)) {
|
|
126
|
+
if (LEGACY_KEYS.has(key)) continue;
|
|
127
|
+
const name = (Object.keys(ALIASES) as (keyof typeof ALIASES)[]).find((field) =>
|
|
128
|
+
ALIASES[field].includes(squash(key)),
|
|
129
|
+
);
|
|
130
|
+
if (!name) {
|
|
131
|
+
ignored.push(key);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (name === "riskTier") {
|
|
135
|
+
const tier = typeof value === "string" ? RISK[value.trim().toLowerCase()] : undefined;
|
|
136
|
+
if (tier) judgment.riskTier = tier;
|
|
137
|
+
else fields.push({ path: key, reason: "expected trivial|normal|high (or low|medium)" });
|
|
138
|
+
} else if (name === "note") {
|
|
139
|
+
judgment.note = typeof value === "string" && value.trim() ? value.trim() : null;
|
|
140
|
+
} else if (name === "refs") {
|
|
141
|
+
const items = Array.isArray(value) ? value : [value];
|
|
142
|
+
judgment.refs.push(
|
|
143
|
+
...(items.filter((item) => item !== null && item !== "").map(refOf) as Judgment["refs"]),
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
// `plan: docs/plans/x.md` both flags the plan and names it.
|
|
147
|
+
if (
|
|
148
|
+
name === "needsPlan" &&
|
|
149
|
+
typeof value === "string" &&
|
|
150
|
+
!flag(value, false).ok &&
|
|
151
|
+
value.trim()
|
|
152
|
+
) {
|
|
153
|
+
judgment.needsPlan = true;
|
|
154
|
+
judgment.refs.push(refOf(value.trim()) as Judgment["refs"][number]);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const parsed = flag(value, name === "behaviorChange");
|
|
158
|
+
if (parsed.ok) judgment[name] = parsed.value;
|
|
159
|
+
else fields.push({ path: key, reason: "expected true|false (yes|no accepted)" });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (fields.length) return failure("invalid_input", "judgment values are invalid", { fields });
|
|
163
|
+
const seen = new Set<string>();
|
|
164
|
+
judgment.refs = judgment.refs.filter((ref) => {
|
|
165
|
+
const key = JSON.stringify(ref);
|
|
166
|
+
return seen.has(key) ? false : (seen.add(key), true);
|
|
167
|
+
});
|
|
168
|
+
const parsed = judgmentSchema.safeParse(judgment);
|
|
169
|
+
if (!parsed.success)
|
|
170
|
+
return failure("invalid_input", "judgment is invalid", {
|
|
171
|
+
fields: parsed.error.issues.map((issue) => ({
|
|
172
|
+
path: issue.path.join("."),
|
|
173
|
+
reason: issue.message,
|
|
174
|
+
})),
|
|
175
|
+
});
|
|
176
|
+
return success(null, null, { judgment: parsed.data, ignored, legacy });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Parse `key=value` tokens (`workit policy assess --judge behavior=yes risk=normal`). */
|
|
180
|
+
export const judgeTokens = (tokens: string[]): Json => {
|
|
181
|
+
const out: Json = {};
|
|
182
|
+
for (const token of tokens) {
|
|
183
|
+
const at = token.indexOf("=");
|
|
184
|
+
const key = at < 0 ? token : token.slice(0, at);
|
|
185
|
+
const value = at < 0 ? "true" : token.slice(at + 1);
|
|
186
|
+
if (!key) continue;
|
|
187
|
+
if (ALIASES.refs.includes(squash(key)))
|
|
188
|
+
out.refs = [...(Array.isArray(out.refs) ? out.refs : []), value];
|
|
189
|
+
else out[key] = value;
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
};
|