specpi 0.10.0 → 0.11.2

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.
@@ -1,5 +1,8 @@
1
+ import { markdownPathLabel } from "./task-contract.mjs";
2
+
1
3
  const MAX_REQUIREMENTS = 16;
2
4
  const MAX_LIST = 12;
5
+ const MAX_REQUIREMENT_ID = 120;
3
6
 
4
7
  function compact(value, maximum = 360) {
5
8
  return String(value ?? "")
@@ -18,11 +21,78 @@ function textList(value, name) {
18
21
  return value.map((item) => compact(item)).filter(Boolean);
19
22
  }
20
23
 
24
+ function boundedRequirementId(value) {
25
+ return compact(value, MAX_REQUIREMENT_ID);
26
+ }
27
+
28
+ function boundedPath(value) {
29
+ return typeof value === "string" ? value.slice(0, 240) : "";
30
+ }
31
+
32
+ function boundedTaskContract(value) {
33
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
34
+ return undefined;
35
+ }
36
+
37
+ const requirements = Array.isArray(value.requirements)
38
+ ? value.requirements
39
+ .map((item) => ({
40
+ id: boundedRequirementId(item?.id),
41
+ description: compact(item?.description, 360),
42
+ acceptance: compact(item?.acceptance, 600),
43
+ }))
44
+ .filter((item) => item.id && item.description && item.acceptance)
45
+ .slice(0, MAX_REQUIREMENTS)
46
+ : [];
47
+ if (requirements.length === 0) {
48
+ return undefined;
49
+ }
50
+
51
+ return {
52
+ id: compact(value.id, 120),
53
+ digest: compact(value.digest, 64),
54
+ origin: compact(value.origin, 24),
55
+ objective: compact(value.objective, 600),
56
+ hypothesis: compact(value.hypothesis, 600),
57
+ requirements,
58
+ paths: Array.isArray(value.paths)
59
+ ? value.paths
60
+ .map((item) => compact(item, 240))
61
+ .filter(Boolean)
62
+ .slice(0, 40)
63
+ : [],
64
+ rollback: compact(value.rollback, 600),
65
+ nonGoals: Array.isArray(value.nonGoals)
66
+ ? value.nonGoals
67
+ .map((item) => compact(item, 360))
68
+ .filter(Boolean)
69
+ .slice(0, 16)
70
+ : [],
71
+ };
72
+ }
73
+
74
+ function contractRequirements(facts) {
75
+ const contract = boundedTaskContract(facts?.taskContract);
76
+ if (!contract) {
77
+ return undefined;
78
+ }
79
+
80
+ return contract.requirements;
81
+ }
82
+
21
83
  export function validateChallengeSubmission(value, facts = {}) {
22
84
  if (!value || typeof value !== "object" || Array.isArray(value)) {
23
85
  throw new Error("Completion challenge submission is malformed");
24
86
  }
25
87
 
88
+ if (facts.challengeGeneration && value.generation !== facts.challengeGeneration) {
89
+ throw new Error("Completion challenge generation is stale");
90
+ }
91
+
92
+ if (facts.taskContractDigest && value.taskContractDigest !== facts.taskContractDigest) {
93
+ throw new Error("Task contract digest is stale");
94
+ }
95
+
26
96
  const verdicts = new Set(["ready-for-human-review", "incomplete", "blocked"]);
27
97
  if (!verdicts.has(value.verdict)) {
28
98
  throw new Error("Completion challenge verdict is invalid");
@@ -36,19 +106,55 @@ export function validateChallengeSubmission(value, facts = {}) {
36
106
  throw new Error(`Completion challenge requires 1-${MAX_REQUIREMENTS} requirement assessments`);
37
107
  }
38
108
 
109
+ const expectedRequirements = contractRequirements(facts);
39
110
  const requirements = value.requirements.map((item) => {
40
- const requirement = compact(item?.requirement);
111
+ const id = boundedRequirementId(item?.id);
112
+ const expected = expectedRequirements?.find((candidate) => candidate.id === id);
113
+ if (expectedRequirements) {
114
+ if (!id) {
115
+ throw new Error("Task contract requirement assessment must include its original ID");
116
+ }
117
+
118
+ if (!expected) {
119
+ throw new Error(`Unknown task contract requirement ID: ${id}`);
120
+ }
121
+ }
122
+
123
+ const requirement = compact(item?.requirement) || expected?.description || "";
41
124
  const evidence = compact(item?.evidence, 600);
42
125
  if (!requirement || !["proven", "partial", "unproven"].includes(item?.status)) {
43
126
  throw new Error("Completion challenge requirement assessment is invalid");
44
127
  }
45
128
 
129
+ if (expected && requirement !== expected.description) {
130
+ throw new Error(`Task contract requirement ${id} text was altered`);
131
+ }
132
+
46
133
  if (item.status === "proven" && !evidence) {
47
134
  throw new Error("A proven requirement must cite concise evidence");
48
135
  }
49
136
 
50
- return { requirement, status: item.status, evidence };
137
+ return {
138
+ ...(expectedRequirements ? { id, acceptance: expected.acceptance } : {}),
139
+ requirement,
140
+ status: item.status,
141
+ evidence,
142
+ };
51
143
  });
144
+ if (expectedRequirements) {
145
+ const submittedIds = requirements.map((item) => item.id);
146
+ if (new Set(submittedIds).size !== submittedIds.length) {
147
+ throw new Error("Task contract requirement IDs must be unique");
148
+ }
149
+
150
+ const expectedIds = expectedRequirements.map((item) => item.id);
151
+ if (submittedIds.length !== expectedIds.length || expectedIds.some((id) => !submittedIds.includes(id))) {
152
+ throw new Error("Task contract requirement assessments must cover the exact original IDs");
153
+ }
154
+
155
+ requirements.sort((left, right) => expectedIds.indexOf(left.id) - expectedIds.indexOf(right.id));
156
+ }
157
+
52
158
  const contradictions = textList(value.contradictions, "contradictions");
53
159
  const falsePositiveChecks = textList(value.falsePositiveChecks, "falsePositiveChecks");
54
160
  const scopeFindings = textList(value.scopeFindings, "scopeFindings");
@@ -78,6 +184,10 @@ export function validateChallengeSubmission(value, facts = {}) {
78
184
  throw new Error("Ready verdict rejected: scope drift remains pending");
79
185
  }
80
186
 
187
+ if (facts.scopeTaskStale === true) {
188
+ throw new Error("Ready verdict rejected: task-bound scope is stale");
189
+ }
190
+
81
191
  // An indeterminate snapshot means the facts the review was handed may be incomplete. It is a permanent
82
192
  // condition outside a Git worktree, so rejecting outright would make a ready verdict unreachable there;
83
193
  // instead the limitation must be disclosed rather than silently dropped.
@@ -101,13 +211,11 @@ export function validateChallengeSubmission(value, facts = {}) {
101
211
  }
102
212
 
103
213
  export function boundedChallengeFacts(value = {}) {
104
- const paths = (items) =>
105
- Array.isArray(items)
106
- ? items
107
- .map((item) => compact(item, 240))
108
- .filter(Boolean)
109
- .slice(0, 40)
110
- : [];
214
+ const paths = (items) => (Array.isArray(items) ? items.map(boundedPath).filter(Boolean).slice(0, 40) : []);
215
+
216
+ const taskContract = boundedTaskContract(value.taskContract);
217
+ const taskContractDigest = value.taskContractDigest ?? taskContract?.digest;
218
+ const challengeGeneration = value.challengeGeneration;
111
219
 
112
220
  return {
113
221
  changedPaths: paths(value.changedPaths),
@@ -123,6 +231,10 @@ export function boundedChallengeFacts(value = {}) {
123
231
  : undefined,
124
232
  observedToolFailures: Math.max(0, Math.min(99, Number(value.observedToolFailures) || 0)),
125
233
  snapshotIndeterminate: Boolean(value.snapshotIndeterminate),
234
+ taskContract,
235
+ taskContractDigest: taskContractDigest ? compact(taskContractDigest, 64) : undefined,
236
+ challengeGeneration: challengeGeneration ? compact(challengeGeneration, 64) : undefined,
237
+ scopeTaskStale: Boolean(value.scopeTaskStale),
126
238
  };
127
239
  }
128
240
 
@@ -140,12 +252,25 @@ export function challengePrompt(generation, facts) {
140
252
  "5. Was runtime, visual, or platform validation required but omitted?",
141
253
  "6. What residual risk must be disclosed?",
142
254
  "",
143
- `Changed paths: ${bounded.changedPaths.join(", ") || "none observed"}`,
144
- `Declared scope: ${bounded.scopeEntries.join(", ") || "inactive"}`,
145
- `Pending scope drift: ${bounded.pendingScope.join(", ") || "none"}`,
255
+ `Changed paths: ${bounded.changedPaths.map(markdownPathLabel).join(", ") || "none observed"}`,
256
+ `Declared scope: ${bounded.scopeEntries.map(markdownPathLabel).join(", ") || "inactive"}`,
257
+ `Pending scope drift: ${bounded.pendingScope.map(markdownPathLabel).join(", ") || "none"}`,
146
258
  `Observed tool failures: ${bounded.observedToolFailures}`,
147
259
  `Snapshot indeterminate: ${bounded.snapshotIndeterminate ? "yes" : "no"}`,
260
+ `Task-bound scope: ${bounded.scopeTaskStale ? "stale" : "not stale or not bound"}`,
148
261
  ];
262
+ if (bounded.taskContract) {
263
+ lines.push(
264
+ `Task contract: ${bounded.taskContract.id} (digest ${bounded.taskContract.digest})`,
265
+ "Include this exact task contract digest as taskContractDigest in the submission.",
266
+ "The task contract requirements are fixed. Use every original requirement ID exactly once and do not rewrite requirement text.",
267
+ "Fixed requirements:",
268
+ ...bounded.taskContract.requirements.map(
269
+ (item) => `- ${item.id}: ${item.description} (acceptance: ${item.acceptance})`,
270
+ ),
271
+ );
272
+ }
273
+
149
274
  if (bounded.experiment) {
150
275
  lines.push(
151
276
  `Experiment: ${bounded.experiment.name} (${bounded.experiment.id.slice(0, 8)})`,
@@ -171,7 +296,8 @@ export function renderChallengeMarkdown(result, metadata = {}) {
171
296
  const lines = [`## Completion Challenge — ${heading}`, "", `Generation: \`${metadata.generation ?? "unknown"}\``];
172
297
  lines.push("", "### Requirements");
173
298
  for (const item of result.requirements) {
174
- lines.push(`- **${item.status}** — ${item.requirement}${item.evidence ? ` — ${item.evidence}` : ""}`);
299
+ const id = item.id ? ` ${item.id}` : "";
300
+ lines.push(`- **${item.status}**${id} — ${item.requirement}${item.evidence ? ` — ${item.evidence}` : ""}`);
175
301
  }
176
302
 
177
303
  const sections = [