@workit-dev/core 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,387 @@
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 { branchLane, laneNeed, type BranchLane } from "./lane";
10
+ import {
11
+ requirementId,
12
+ sha256,
13
+ type Constraint,
14
+ type Judgment,
15
+ type Requirement,
16
+ type Scope,
17
+ type TaskRecord,
18
+ } from "../task-contract";
19
+
20
+ export const RULES = {
21
+ test: "check:test",
22
+ self: "verdict:self",
23
+ nonAuthor: "verdict:non-author",
24
+ verified: "verdict:verified",
25
+ lane: "verdict:lane",
26
+ product: "decision:product",
27
+ plan: "plan",
28
+ } as const;
29
+
30
+ const derived = (spec: Omit<Requirement, "id">): Requirement => ({
31
+ ...spec,
32
+ id: requirementId({
33
+ ruleId: spec.ruleId,
34
+ scope: spec.scope,
35
+ satisfaction: spec.satisfaction,
36
+ before: spec.before,
37
+ dependentAction: spec.dependentAction,
38
+ }),
39
+ });
40
+
41
+ const sorted = (values: string[]) => [...new Set(values)].toSorted();
42
+ const normalizedScope = (scope: Scope): Scope => ({
43
+ description: scope.description,
44
+ paths: sorted(scope.paths),
45
+ exclusions: sorted(scope.exclusions),
46
+ });
47
+
48
+ /**
49
+ * | judgment | requirement |
50
+ * | behaviorChange | check:test (configured, fresh tree), close |
51
+ * | risk = normal, | verdict:lane: the review lane from the diff |
52
+ * | verification: proportional | decides (core/policy/lane.ts; met under lane |
53
+ * | (whatever behaviorChange says) | none), close |
54
+ * | behaviorChange && risk = normal | verdict:self (verification: self) or |
55
+ * | | verdict:non-author (independent), close |
56
+ * | risk = high | verdict:verified (ledger), close |
57
+ * | productChoiceOpen | decision:product, before write |
58
+ * | needsPlan or risk = high | plan (doc ref, soft), before write |
59
+ * | always | project constraints |
60
+ */
61
+ export function deriveRequirements(
62
+ judgment: Judgment,
63
+ scope: Scope,
64
+ constraints: Constraint[],
65
+ /** The workspace's user-config verification mode (autonomy.ts). */
66
+ verification: VerificationMode = "self",
67
+ ): Requirement[] {
68
+ const out: Requirement[] = [];
69
+ const base = { scope, dependentAction: null };
70
+ const proportional = verification === "proportional";
71
+ if (judgment.behaviorChange)
72
+ out.push(
73
+ derived({
74
+ ...base,
75
+ ruleId: RULES.test,
76
+ dimension: "testing",
77
+ reason: "Behavior changes: the configured test check must pass on the final tree.",
78
+ satisfaction: "Run `workit check test` on the final tree.",
79
+ before: "close",
80
+ acceptanceAllowed: false,
81
+ }),
82
+ );
83
+ // The diff, not the behavior-change judgment, decides: the requirement is
84
+ // derived at normal risk whatever behaviorChange says and is met under lane
85
+ // none (review finding mvkcoxgb). Trivial work keeps needing nothing at
86
+ // close; the merge gate still reads its lane.
87
+ if (proportional && judgment.riskTier === "normal")
88
+ out.push(
89
+ derived({
90
+ ...base,
91
+ ruleId: RULES.lane,
92
+ dimension: "review",
93
+ reason:
94
+ "The review lane computed from the diff decides the verdict: none needs none, self the author's own (shown as self-reviewed), focused and full a non-author one.",
95
+ satisfaction:
96
+ "Read the lane in `workit policy assess` or `workit pr status`; record what it asks: `workit ledger verdict tests-verified --self --how <what you ran>` for self, or hand focused and full to a verifier.",
97
+ before: "close",
98
+ acceptanceAllowed: false,
99
+ }),
100
+ );
101
+ if (!proportional && judgment.behaviorChange && judgment.riskTier === "normal")
102
+ out.push(
103
+ verification === "independent"
104
+ ? derived({
105
+ ...base,
106
+ ruleId: RULES.nonAuthor,
107
+ dimension: "review",
108
+ reason:
109
+ "This workspace requires independent verification: a session that did not author the change records the verdict.",
110
+ satisfaction:
111
+ "An independent session records a passing verdict: `workit ledger verdict verified --how <what it exercised>`.",
112
+ before: "close",
113
+ acceptanceAllowed: false,
114
+ })
115
+ : derived({
116
+ ...base,
117
+ ruleId: RULES.self,
118
+ dimension: "review",
119
+ reason:
120
+ "A behavior change needs a recorded verdict; the author's own is allowed and is shown as self-reviewed, never verified.",
121
+ satisfaction:
122
+ "After an observed passing `workit check test`, record `workit ledger verdict tests-verified --self --how <what you ran>` (or have a verifier record one).",
123
+ before: "close",
124
+ acceptanceAllowed: false,
125
+ }),
126
+ );
127
+ if (judgment.riskTier === "high")
128
+ out.push(
129
+ derived({
130
+ ...base,
131
+ ruleId: RULES.verified,
132
+ dimension: "review",
133
+ reason: "High risk: an independent verifier must exercise the live surface.",
134
+ satisfaction:
135
+ "An independent verifier runs the real surface and records `workit ledger verdict verified --kind live --how <what it exercised>`.",
136
+ before: "close",
137
+ acceptanceAllowed: false,
138
+ }),
139
+ );
140
+ if (judgment.productChoiceOpen)
141
+ out.push(
142
+ derived({
143
+ ...base,
144
+ ruleId: RULES.product,
145
+ dimension: "decisions",
146
+ reason:
147
+ "A product or preference choice is open; the user decides it before code is written.",
148
+ satisfaction:
149
+ 'Ask the user (with your recommendation), then record the answer: `workit ledger decision "<choice>" --why "<reason>"`.',
150
+ before: "write",
151
+ acceptanceAllowed: false,
152
+ }),
153
+ );
154
+ if (judgment.needsPlan || judgment.riskTier === "high")
155
+ out.push(
156
+ derived({
157
+ ...base,
158
+ ruleId: RULES.plan,
159
+ dimension: "artifacts",
160
+ reason: judgment.needsPlan
161
+ ? "The work needs a written plan or spec before implementation."
162
+ : "High-risk work needs a written plan before implementation.",
163
+ satisfaction:
164
+ "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>`.",
165
+ before: "write",
166
+ acceptanceAllowed: true,
167
+ }),
168
+ );
169
+ for (const constraint of constraints)
170
+ for (const obligation of constraint.requires)
171
+ out.push(
172
+ derived({
173
+ ruleId: `project-constraint:${constraint.id}:${obligation.kind}:${sha256({
174
+ kind: obligation.kind,
175
+ scope: normalizedScope(obligation.scope),
176
+ refs: obligation.refs.map((ref) => JSON.stringify(ref)).toSorted(),
177
+ method: obligation.method,
178
+ before: obligation.before,
179
+ dependentAction: obligation.dependentAction,
180
+ })}`,
181
+ dimension:
182
+ obligation.method ??
183
+ (obligation.kind === "check"
184
+ ? "verification"
185
+ : obligation.kind === "decision"
186
+ ? "decisions"
187
+ : "challenge"),
188
+ scope: obligation.scope,
189
+ reason: `${constraint.kind} constraint requires ${obligation.kind}: ${constraint.statement}`,
190
+ satisfaction: `Satisfy the constraint obligation using its referenced ${obligation.kind}.`,
191
+ before: obligation.before,
192
+ dependentAction: obligation.dependentAction,
193
+ acceptanceAllowed: constraint.acceptanceAllowed && obligation.kind !== "decision",
194
+ }),
195
+ );
196
+ const seen = new Set<string>();
197
+ return out.filter((item) => (seen.has(item.id) ? false : (seen.add(item.id), true)));
198
+ }
199
+
200
+ export const latestJudgment = (task: TaskRecord): Judgment | null =>
201
+ task.judgments?.at(-1)?.data ?? null;
202
+
203
+ /** Lazily read ledger rows and the checked-out branch, once per evaluation. */
204
+ export type LedgerView = { branch: () => string | null; rows: () => readonly ReadRow[] };
205
+ export const ledgerView = (root: string): LedgerView => {
206
+ let branch: string | null | undefined;
207
+ let rows: readonly ReadRow[] | undefined;
208
+ return {
209
+ branch: () => (branch === undefined ? (branch = currentBranch(root)) : branch),
210
+ rows: () => {
211
+ if (rows === undefined) {
212
+ const read = readLedger(root);
213
+ rows = read.ok ? read.value.rows : [];
214
+ }
215
+ return rows;
216
+ },
217
+ };
218
+ };
219
+
220
+ export type RuleStatus = { met: boolean; reason: string; decisionIds: string[] };
221
+
222
+ /** A user decision recorded on the task, or a ledger decision on this branch since the task began. */
223
+ export const productDecision = (task: TaskRecord, ledger: LedgerView): RuleStatus => {
224
+ const decisions = task.decisions.filter(
225
+ (entry) =>
226
+ entry.data.revoked === null &&
227
+ (entry.data.response === "approved" || entry.data.response === "stated") &&
228
+ entry.data.purpose !== "limitation",
229
+ );
230
+ if (decisions.length)
231
+ return {
232
+ met: true,
233
+ reason: "the user's decision is recorded on the task",
234
+ decisionIds: decisions.map((entry) => entry.id),
235
+ };
236
+ const since = Date.parse(task.createdAt);
237
+ const branch = ledger.branch();
238
+ // Outside git (no branch) the checkout's own ledger rows carry no branch either.
239
+ const row = ledger
240
+ .rows()
241
+ .findLast(
242
+ (item) =>
243
+ item.type === "decision" &&
244
+ item.by !== "decider" &&
245
+ !item.superseded &&
246
+ (item.branch ?? null) === branch &&
247
+ Date.parse(item.at) >= since,
248
+ );
249
+ return row
250
+ ? { met: true, reason: `ledger decision ${row.id} records the choice`, decisionIds: [] }
251
+ : {
252
+ met: false,
253
+ reason:
254
+ 'open product choice: ask the user, then `workit ledger decision "<choice>" --why "<reason>"` (or, if it is not open, `workit policy assess --judge product-choice=no --why "<reason>"`)',
255
+ decisionIds: [],
256
+ };
257
+ };
258
+
259
+ /** The latest judgment cites an existing doc or a URL, or an artifact passed. */
260
+ const planRecorded = (task: TaskRecord, root: string): RuleStatus => {
261
+ const cited = (latestJudgment(task)?.refs ?? []).find(
262
+ (ref) =>
263
+ ref.kind === "external" || (ref.kind === "file" && existsSync(path.resolve(root, ref.path))),
264
+ );
265
+ if (cited)
266
+ return {
267
+ met: true,
268
+ reason: `plan recorded: ${cited.kind === "file" ? cited.path : cited.kind === "external" ? cited.url : ""}`,
269
+ decisionIds: [],
270
+ };
271
+ const artifact = task.evidence.some(
272
+ (entry) => entry.data.kind === "artifact" && entry.data.result === "passed",
273
+ );
274
+ return artifact
275
+ ? { met: true, reason: "a passing artifact records the plan", decisionIds: [] }
276
+ : {
277
+ met: false,
278
+ reason:
279
+ "no plan recorded: write it (docs/ and Markdown writes stay allowed), then `workit policy assess --ref <path>`",
280
+ decisionIds: [],
281
+ };
282
+ };
283
+
284
+ /**
285
+ * Verdict rules over the D18 ledger. `type-check-only` never proves a
286
+ * behavior change. `verdict:self` takes the author's own current `--self`
287
+ * verdict (shown as self-reviewed) or an independent one; `verdict:non-author`
288
+ * an accepted independent one; `verdict:verified` an accepted independent
289
+ * `verified` verdict of kind `live`.
290
+ */
291
+ /** The review lane of the checked-out work (the working tree against the default base). */
292
+ export const workingLane = (task: TaskRecord, root: string): BranchLane =>
293
+ branchLane(root, { head: null, workingTree: true, judgment: latestJudgment(task) });
294
+
295
+ /** `verdict:lane`: the rule the lane asks for, or met under lane none. */
296
+ const laneVerdict = (task: TaskRecord, root: string, ledger: LedgerView): RuleStatus => {
297
+ // With no branch (outside git) there is no diff to read and nothing could
298
+ // hold a verdict: a judgment of no behavior change owes none, as before lanes.
299
+ if (!ledger.branch() && latestJudgment(task)?.behaviorChange === false)
300
+ return {
301
+ met: true,
302
+ reason: "no branch to read a lane from, and the judgment records no behavior change",
303
+ decisionIds: [],
304
+ };
305
+ const lane = workingLane(task, root);
306
+ const label = `lane ${lane.lane} (${lane.reasons.join("; ")})`;
307
+ const need = laneNeed(lane.lane);
308
+ if (need === "none") return { met: true, reason: `${label}: no verdict needed`, decisionIds: [] };
309
+ const status = verdict(need === "self" ? RULES.self : RULES.nonAuthor, root, ledger);
310
+ return { ...status, reason: `${label}: ${status.reason}` };
311
+ };
312
+
313
+ const verdict = (ruleId: string, root: string, ledger: LedgerView): RuleStatus => {
314
+ const branch = ledger.branch();
315
+ if (!branch)
316
+ return { met: false, reason: "no branch is checked out to hold a verdict", decisionIds: [] };
317
+ const check = checkVerdicts(root, branch, ledger.rows());
318
+ const failing = check.accepted.reasons.includes("failing_verdict");
319
+ const strong = (entry: (typeof check.verdicts)[number]) =>
320
+ STRONG_RESULTS.has(String(entry.verdict.result));
321
+ const met =
322
+ !failing &&
323
+ (ruleId === RULES.verified
324
+ ? check.verdicts.find(
325
+ (entry) =>
326
+ entry.accepted && entry.kind === "live" && String(entry.verdict.result) === "verified",
327
+ )
328
+ : ruleId === RULES.nonAuthor
329
+ ? check.verdicts.find((entry) => entry.accepted && strong(entry))
330
+ : check.verdicts.find((entry) => entry.current && strong(entry)));
331
+ if (met)
332
+ return {
333
+ met: true,
334
+ reason: `${met.accepted ? "accepted independent" : "self-reviewed (author's own)"} ${String(met.verdict.result)} verdict ${met.verdict.id}`,
335
+ decisionIds: [],
336
+ };
337
+ const why = failing
338
+ ? "a current independent verdict fails"
339
+ : check.verdicts.some((entry) => entry.current && entry.verdict.result === "type-check-only")
340
+ ? "type-check-only does not prove a behavior change"
341
+ : (check.accepted.reasons.join(", ") || "no_verdict").replace(/_/g, " ");
342
+ const unblock =
343
+ ruleId === RULES.self
344
+ ? "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"
345
+ : `an independent session records \`workit ledger verdict verified${ruleId === RULES.verified ? " --kind live" : ""} --how <what it exercised>\``;
346
+ return { met: false, reason: `${why}: ${unblock}`, decisionIds: [] };
347
+ };
348
+
349
+ /** Status of a derived rule that is not decided by task evidence; null for every other rule. */
350
+ export function derivedRuleStatus(
351
+ task: TaskRecord,
352
+ requirement: Requirement,
353
+ root: string,
354
+ ledger: LedgerView,
355
+ ): RuleStatus | null {
356
+ switch (requirement.ruleId) {
357
+ case RULES.product:
358
+ return productDecision(task, ledger);
359
+ case RULES.plan:
360
+ return planRecorded(task, root);
361
+ case RULES.self:
362
+ case RULES.nonAuthor:
363
+ case RULES.verified:
364
+ return verdict(requirement.ruleId, root, ledger);
365
+ case RULES.lane:
366
+ return laneVerdict(task, root, ledger);
367
+ default:
368
+ return null;
369
+ }
370
+ }
371
+
372
+ export type WriteBlocker = { ruleId: string; reason: string };
373
+
374
+ /**
375
+ * Unmet before-write requirements of a task (S17 write gate). Only derived
376
+ * rules are decided here, cheaply and without capturing a candidate;
377
+ * constraint write obligations stay advisory.
378
+ */
379
+ export function writeBlockers(task: TaskRecord, root: string): WriteBlocker[] {
380
+ if (task.status === "closed" || !task.policy) return [];
381
+ const ledger = ledgerView(root);
382
+ return task.policy.requirements.flatMap((requirement) => {
383
+ if (requirement.before !== "write") return [];
384
+ const status = derivedRuleStatus(task, requirement, root, ledger);
385
+ return status && !status.met ? [{ ruleId: requirement.ruleId, reason: status.reason }] : [];
386
+ });
387
+ }
@@ -0,0 +1,246 @@
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
+ };
193
+
194
+ /** The judgments that, while true, block working-tree writes (S17). */
195
+ const BLOCKING = ["productChoiceOpen", "needsPlan"] as const;
196
+ type Blocking = (typeof BLOCKING)[number];
197
+
198
+ /** The before-write blockers a judgment lifts: true before, false now. */
199
+ export const liftedBlockers = (previous: Judgment | null, next: Judgment): Blocking[] =>
200
+ BLOCKING.filter((field) => previous?.[field] === true && next[field] === false);
201
+
202
+ /**
203
+ * Of the lifted blockers, those `session` itself judged true earlier on this
204
+ * task (M6): lifting your own blocker needs a recorded reason. Another
205
+ * session (a lead, the user's) may lift it; the ledger still records it.
206
+ */
207
+ export const ownLiftedBlockers = (
208
+ history: ReadonlyArray<{ provenance: { session: unknown }; data: Judgment }>,
209
+ lifted: readonly Blocking[],
210
+ session: string,
211
+ ): Blocking[] => {
212
+ const handle = (value: unknown): unknown => (isObject(value) ? value.handle : undefined);
213
+ return lifted.filter((field) =>
214
+ history.some((entry) => handle(entry.provenance.session) === session && entry.data[field]),
215
+ );
216
+ };
217
+
218
+ /** The reason sent with this judgment (`--why`, `note=`), not one kept from before. */
219
+ export const givenReason = (raw: unknown): string | null => {
220
+ if (!isObject(raw)) return null;
221
+ for (const [key, value] of Object.entries(raw))
222
+ if (ALIASES.note.includes(squash(key)) && typeof value === "string" && value.trim())
223
+ return value.trim();
224
+ return null;
225
+ };
226
+
227
+ /** Longest call value kept in a ledger summary of a judgment as sent. */
228
+ const SUMMARY_VALUE_CHARS = 80;
229
+
230
+ /**
231
+ * The judgment as sent, for the `policy.judged` ledger row: the calls only.
232
+ * The reason and refs are recorded elsewhere in the row, and long values are
233
+ * cut so the row fits one ledger line.
234
+ */
235
+ export const judgeInputSummary = (raw: unknown): Json => {
236
+ const out: Json = {};
237
+ if (!isObject(raw)) return out;
238
+ for (const [key, value] of Object.entries(raw)) {
239
+ const name = squash(key);
240
+ if (ALIASES.note.includes(name) || ALIASES.refs.includes(name) || LEGACY_KEYS.has(key))
241
+ continue;
242
+ if (typeof value === "string") out[key.slice(0, 40)] = value.slice(0, SUMMARY_VALUE_CHARS);
243
+ else if (typeof value === "boolean" || typeof value === "number") out[key.slice(0, 40)] = value;
244
+ }
245
+ return out;
246
+ };