wendkeep 0.79.0 → 0.80.1

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.
@@ -61,6 +61,10 @@ export function normalizeSharedHandoff(shared) {
61
61
  const value = sanitizeValue(shared[field]);
62
62
  if (hasMeaningfulValue(value)) normalized[field] = value;
63
63
  }
64
+ if (Object.hasOwn(shared, 'handoff_contract')) {
65
+ const contract = sanitizeValue(shared.handoff_contract);
66
+ if (hasMeaningfulValue(contract)) normalized.handoff_contract = contract;
67
+ }
64
68
 
65
69
  return Object.keys(normalized).length ? normalized : null;
66
70
  }
@@ -257,6 +261,17 @@ export function buildSessionMemoryEvents({
257
261
  const events = [];
258
262
 
259
263
  if (normalizedShared) {
264
+ if (normalizedShared.handoff_contract) {
265
+ const contract = normalizedShared.handoff_contract;
266
+ events.push(makeEvent(context, {
267
+ memoryKey: 'handoff.latest',
268
+ value: contract,
269
+ authority: contract.schema_version === 1 && contract.authority === 'verified'
270
+ ? 'verified' : 'reported',
271
+ evidence: Array.isArray(contract.evidence) && contract.evidence.length
272
+ ? contract.evidence : [noteRel],
273
+ }));
274
+ }
260
275
  for (const [field, memoryKey] of SHARED_HANDOFF_FIELDS) {
261
276
  if (!Object.hasOwn(normalizedShared, field)) continue;
262
277
  events.push(makeEvent(context, {
@@ -0,0 +1,35 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://wendkeep.dev/schema/artifact-manifest-v1.schema.json",
4
+ "title": "WendKeep Artifact Manifest v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["schema_version", "artifacts"],
8
+ "properties": {
9
+ "schema_version": { "const": 1 },
10
+ "artifacts": {
11
+ "type": "array",
12
+ "items": { "$ref": "#/$defs/artifact" }
13
+ }
14
+ },
15
+ "$defs": {
16
+ "artifact": {
17
+ "type": "object",
18
+ "additionalProperties": false,
19
+ "required": ["name", "type"],
20
+ "properties": {
21
+ "name": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
22
+ "type": { "enum": ["name", "path", "glob", "file-count"] },
23
+ "path": { "type": "string" },
24
+ "glob": { "type": "string" },
25
+ "min": { "type": "integer", "minimum": 0 },
26
+ "max": { "type": "integer", "minimum": 0 },
27
+ "fromFilesystem": { "type": "boolean" }
28
+ },
29
+ "allOf": [
30
+ { "if": { "properties": { "type": { "const": "path" } } }, "then": { "required": ["path"] } },
31
+ { "if": { "properties": { "type": { "enum": ["glob", "file-count"] } } }, "then": { "required": ["glob"] } }
32
+ ]
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://wendkeep.dev/schema/handoff-contract-v1.schema.json",
4
+ "title": "WendKeep Handoff Contract v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schema_version", "handoff_id", "from", "to", "active_context_id", "task_id",
9
+ "task_contract_id", "artifacts", "evidence", "decisions", "next_actions", "blockers",
10
+ "head_sha", "tasks_sha256", "spec_sha256", "authority"
11
+ ],
12
+ "properties": {
13
+ "schema_version": { "const": 1 },
14
+ "handoff_id": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
15
+ "from": { "type": "string", "minLength": 1 },
16
+ "to": { "type": "string", "minLength": 1 },
17
+ "active_context_id": { "type": "string", "minLength": 1 },
18
+ "task_id": { "type": "string" },
19
+ "task_contract_id": { "type": "string" },
20
+ "artifacts": { "$ref": "#/$defs/stringArray" },
21
+ "evidence": { "$ref": "#/$defs/stringArray" },
22
+ "decisions": { "$ref": "#/$defs/stringArray" },
23
+ "next_actions": { "$ref": "#/$defs/stringArray" },
24
+ "blockers": { "$ref": "#/$defs/stringArray" },
25
+ "head_sha": { "type": "string", "minLength": 1 },
26
+ "tasks_sha256": { "type": "string", "minLength": 1 },
27
+ "spec_sha256": { "type": "string", "minLength": 1 },
28
+ "authority": { "enum": ["verified", "reported"] }
29
+ },
30
+ "$defs": {
31
+ "stringArray": {
32
+ "type": "array",
33
+ "items": { "type": "string" },
34
+ "uniqueItems": true
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://wendkeep.dev/schema/task-contract-v1.schema.json",
4
+ "title": "WendKeep Task Contract v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schema_version", "contract_id", "task_id", "change_slug", "title", "phase", "status",
9
+ "inputs", "expected_outputs", "acceptance_criteria", "requirement_ids",
10
+ "required_sensors", "required_artifacts", "dependencies", "owner", "work_session_id",
11
+ "evidence_envelope_id", "checked", "authored_sha256", "binding"
12
+ ],
13
+ "properties": {
14
+ "schema_version": { "const": 1 },
15
+ "contract_id": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
16
+ "task_id": { "type": "string", "minLength": 1 },
17
+ "change_slug": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" },
18
+ "title": { "type": "string" },
19
+ "phase": { "enum": ["execute", "verify"] },
20
+ "status": { "enum": ["ready", "blocked", "pending-evaluation", "completed", "stale"] },
21
+ "inputs": { "$ref": "#/$defs/stringArray" },
22
+ "expected_outputs": { "$ref": "#/$defs/stringArray" },
23
+ "acceptance_criteria": { "$ref": "#/$defs/stringArray" },
24
+ "requirement_ids": { "$ref": "#/$defs/stringArray" },
25
+ "required_sensors": { "$ref": "#/$defs/stringArray" },
26
+ "required_artifacts": { "$ref": "#/$defs/stringArray" },
27
+ "dependencies": { "$ref": "#/$defs/stringArray" },
28
+ "owner": { "type": ["string", "null"] },
29
+ "work_session_id": { "type": ["string", "null"] },
30
+ "evidence_envelope_id": { "type": ["string", "null"] },
31
+ "checked": { "type": "boolean" },
32
+ "authored_sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" },
33
+ "binding": {
34
+ "type": "object",
35
+ "additionalProperties": false,
36
+ "required": [
37
+ "project_id", "active_context_id", "head_sha", "tasks_sha256",
38
+ "effective_spec_sha256", "artifact_manifest_sha256"
39
+ ],
40
+ "properties": {
41
+ "project_id": { "type": "string", "minLength": 1 },
42
+ "active_context_id": { "type": "string", "minLength": 1 },
43
+ "head_sha": { "type": "string", "minLength": 1 },
44
+ "tasks_sha256": { "type": "string", "minLength": 1 },
45
+ "effective_spec_sha256": { "type": "string", "minLength": 1 },
46
+ "artifact_manifest_sha256": { "type": "string", "minLength": 1 }
47
+ }
48
+ }
49
+ },
50
+ "$defs": {
51
+ "stringArray": {
52
+ "type": "array",
53
+ "items": { "type": "string" },
54
+ "uniqueItems": true
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,510 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ realpathSync,
8
+ } from 'node:fs';
9
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
10
+ import { parseTasks } from '../hooks/change-core.mjs';
11
+ import { getLocale } from '../hooks/locale.mjs';
12
+ import { buildEffectiveRequirementPackage, contentHashOf, tasksHashOf } from '../hooks/spec-core.mjs';
13
+ import { activeContextKey, resolveActiveContext } from '../hooks/active-context-store.mjs';
14
+
15
+ const IGNORED_DIRECTORIES = new Set(['.git', '.worktrees', 'node_modules', 'dist']);
16
+ const BINDING_FIELDS = [
17
+ ['active_context_id', 'TASK_CONTRACT_STALE_CONTEXT'],
18
+ ['head_sha', 'TASK_CONTRACT_STALE_HEAD'],
19
+ ['tasks_sha256', 'TASK_CONTRACT_STALE_TASKS'],
20
+ ['effective_spec_sha256', 'TASK_CONTRACT_STALE_SPEC'],
21
+ ['artifact_manifest_sha256', 'TASK_CONTRACT_STALE_ARTIFACT_MANIFEST'],
22
+ ];
23
+
24
+ function stableValue(value) {
25
+ if (Array.isArray(value)) return value.map(stableValue);
26
+ if (!value || typeof value !== 'object') return value;
27
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
28
+ }
29
+
30
+ function canonicalJson(value) {
31
+ return JSON.stringify(stableValue(value));
32
+ }
33
+
34
+ function sha256(value) {
35
+ return createHash('sha256').update(String(value), 'utf8').digest('hex');
36
+ }
37
+
38
+ function uniqueStrings(values) {
39
+ const list = Array.isArray(values) ? values : (values === undefined || values === null ? [] : [values]);
40
+ return [...new Set(list.map((value) => String(value).trim()).filter(Boolean))];
41
+ }
42
+
43
+ function bindingFrom(input) {
44
+ return {
45
+ project_id: String(input.projectId || ''),
46
+ active_context_id: String(input.activeContextId || ''),
47
+ head_sha: String(input.headSha || ''),
48
+ tasks_sha256: String(input.tasksSha256 || ''),
49
+ effective_spec_sha256: String(input.effectiveSpecSha256 || ''),
50
+ artifact_manifest_sha256: String(input.artifactManifestSha256 || ''),
51
+ };
52
+ }
53
+
54
+ export function deriveTaskContracts(input = {}) {
55
+ const changeSlug = String(input.changeSlug || '').trim();
56
+ const projectId = String(input.projectId || '').trim();
57
+ if (!projectId || !changeSlug) {
58
+ throw Object.assign(new Error('projectId and changeSlug are required'), { code: 'TASK_CONTRACT_IDENTITY_MISSING' });
59
+ }
60
+ const binding = bindingFrom(input);
61
+ const artifactSpecs = new Map((input.artifactSpecs ?? []).map((spec) => [String(spec.name || ''), spec]));
62
+ return (input.tasks ?? []).map((task) => {
63
+ const taskId = String(task.id || '').trim();
64
+ const phase = String(task.phase || 'execute').trim().toLowerCase();
65
+ if (!['execute', 'verify'].includes(phase)) {
66
+ throw Object.assign(new Error(`invalid task phase for ${taskId}: ${phase}`), { code: 'TASK_PHASE_INVALID' });
67
+ }
68
+ const lease = input.taskLeases?.[`${changeSlug}:${taskId}`];
69
+ const activeLease = lease?.state === 'active' && Date.parse(String(lease.expires_at || '')) > Date.now()
70
+ ? lease : null;
71
+ const dependencies = uniqueStrings(task.dependencies);
72
+ const requiredArtifacts = uniqueStrings(task.artifacts);
73
+ const authored = {
74
+ change_slug: changeSlug,
75
+ task_id: taskId,
76
+ title: String(task.text || '').trim(),
77
+ phase,
78
+ checked: task.done === true,
79
+ requirement_ids: uniqueStrings(task.reqs),
80
+ required_sensors: uniqueStrings(task.sensors ?? (task.sensor ? [task.sensor] : [])),
81
+ required_artifacts: requiredArtifacts,
82
+ dependencies,
83
+ binding,
84
+ artifact_specs: requiredArtifacts.map((name) => artifactSpecs.get(name) ?? { name }),
85
+ };
86
+ return {
87
+ schema_version: 1,
88
+ contract_id: sha256(`${projectId}\0${changeSlug}\0${taskId}`),
89
+ task_id: taskId,
90
+ change_slug: changeSlug,
91
+ title: authored.title,
92
+ phase,
93
+ status: task.done === true ? 'pending-evaluation' : (dependencies.length ? 'blocked' : 'ready'),
94
+ inputs: uniqueStrings(task.inputs),
95
+ expected_outputs: uniqueStrings(task.expectedOutputs),
96
+ acceptance_criteria: uniqueStrings(task.acceptanceCriteria ?? [authored.title]),
97
+ requirement_ids: authored.requirement_ids,
98
+ required_sensors: authored.required_sensors,
99
+ required_artifacts: authored.required_artifacts,
100
+ dependencies,
101
+ owner: activeLease?.owner_session_id ?? null,
102
+ work_session_id: activeLease?.owner_work_session_id ?? null,
103
+ evidence_envelope_id: input.evidenceEnvelopeId ?? null,
104
+ checked: authored.checked,
105
+ authored_sha256: sha256(canonicalJson(authored)),
106
+ binding,
107
+ };
108
+ });
109
+ }
110
+
111
+ function taskFinding(code, field, expected, observed) {
112
+ return {
113
+ code,
114
+ field,
115
+ expected: expected ?? null,
116
+ observed: observed ?? null,
117
+ recovery: 'rebuild and re-evaluate the task contract in the active context',
118
+ };
119
+ }
120
+
121
+ export function evaluateTaskContract(contract, options = {}) {
122
+ const currentBinding = options.currentBinding ?? contract.binding ?? {};
123
+ const blockingFindings = [];
124
+ for (const [field, code] of BINDING_FIELDS) {
125
+ if (String(contract.binding?.[field] ?? '') !== String(currentBinding?.[field] ?? '')) {
126
+ blockingFindings.push(taskFinding(code, field, contract.binding?.[field], currentBinding?.[field]));
127
+ }
128
+ }
129
+ if (contract.checked !== true) {
130
+ blockingFindings.push(taskFinding('TASK_CHECKBOX_OPEN', 'checked', true, contract.checked === true));
131
+ }
132
+
133
+ const availableRequirements = new Set(uniqueStrings(options.availableRequirementIds));
134
+ const missingRequirements = uniqueStrings(contract.requirement_ids).filter((id) => !availableRequirements.has(id));
135
+ const sensors = new Map((options.sensorResults ?? []).map((sensor) => [String(sensor.id || ''), sensor]));
136
+ const missingSensors = uniqueStrings(contract.required_sensors)
137
+ .filter((id) => sensors.get(id)?.status !== 'green');
138
+ const artifacts = new Map((options.artifactResults ?? []).map((artifact) => [String(artifact.name || ''), artifact]));
139
+ const missingArtifacts = uniqueStrings(contract.required_artifacts)
140
+ .filter((name) => artifacts.get(name)?.satisfied !== true);
141
+ const completedTasks = new Set(uniqueStrings(options.completedTaskIds));
142
+ const openDependencies = uniqueStrings(contract.dependencies).filter((id) => !completedTasks.has(id));
143
+
144
+ for (const id of missingRequirements) blockingFindings.push(taskFinding('TASK_REQUIREMENT_MISSING', 'requirement_ids', id, null));
145
+ for (const id of missingSensors) blockingFindings.push(taskFinding('TASK_SENSOR_MISSING_OR_RED', 'required_sensors', id, sensors.get(id)?.status ?? null));
146
+ for (const name of missingArtifacts) blockingFindings.push(taskFinding('TASK_ARTIFACT_MISSING', 'required_artifacts', name, null));
147
+ for (const id of openDependencies) blockingFindings.push(taskFinding('TASK_DEPENDENCY_OPEN', 'dependencies', id, null));
148
+
149
+ const canComplete = blockingFindings.length === 0;
150
+ return {
151
+ task_id: contract.task_id,
152
+ contract_id: contract.contract_id,
153
+ phase: contract.phase || 'execute',
154
+ can_complete: canComplete,
155
+ status: canComplete ? 'completed' : (blockingFindings.some((finding) => finding.code.startsWith('TASK_CONTRACT_STALE_')) ? 'stale' : 'blocked'),
156
+ missing_requirements: missingRequirements,
157
+ missing_sensors: missingSensors,
158
+ missing_artifacts: missingArtifacts,
159
+ open_dependencies: openDependencies,
160
+ blocking_findings: blockingFindings,
161
+ };
162
+ }
163
+
164
+ function artifactError(code, message, details = {}) {
165
+ return Object.assign(new Error(message), { code, ...details });
166
+ }
167
+
168
+ function insideRoot(root, target) {
169
+ const rel = relative(root, target);
170
+ return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
171
+ }
172
+
173
+ function safeRelativePath(projectRoot, value) {
174
+ const raw = String(value || '').replaceAll('\\', '/');
175
+ if (!raw || isAbsolute(raw) || raw.split('/').includes('..')) {
176
+ throw artifactError('TASK_ARTIFACT_PATH_ESCAPE', `artifact path escapes project: ${raw}`);
177
+ }
178
+ const root = realpathSync(projectRoot);
179
+ const target = resolve(root, raw);
180
+ if (!insideRoot(root, target)) throw artifactError('TASK_ARTIFACT_PATH_ESCAPE', `artifact path escapes project: ${raw}`);
181
+ if (existsSync(target)) {
182
+ const real = realpathSync(target);
183
+ if (!insideRoot(root, real)) throw artifactError('TASK_ARTIFACT_PATH_ESCAPE', `artifact target escapes project: ${raw}`);
184
+ }
185
+ return { raw, root, target };
186
+ }
187
+
188
+ function globRegExp(pattern) {
189
+ let source = '';
190
+ const normalized = String(pattern || '').replaceAll('\\', '/');
191
+ for (let index = 0; index < normalized.length; index += 1) {
192
+ const char = normalized[index];
193
+ if (char === '*' && normalized[index + 1] === '*') {
194
+ index += 1;
195
+ if (normalized[index + 1] === '/') {
196
+ index += 1;
197
+ source += '(?:.*/)?';
198
+ } else source += '.*';
199
+ } else if (char === '*') source += '[^/]*';
200
+ else if (char === '?') source += '[^/]';
201
+ else source += char.replace(/[|\\{}()[\]^$+?.]/g, '\\$&');
202
+ }
203
+ return new RegExp(`^${source}$`);
204
+ }
205
+
206
+ function scanProject(projectRoot, limits = {}) {
207
+ const root = realpathSync(projectRoot);
208
+ const maxEntries = Number.isSafeInteger(limits.maxEntries) ? limits.maxEntries : 10_000;
209
+ const timeoutMs = Number.isFinite(limits.timeoutMs) ? limits.timeoutMs : 2_000;
210
+ const started = Date.now();
211
+ const files = [];
212
+ const walk = (dir, prefix = '') => {
213
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
214
+ if (Date.now() - started > timeoutMs) throw artifactError('TASK_ARTIFACT_SCAN_TIMEOUT', 'artifact scan timed out');
215
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
216
+ if (entry.isDirectory() && IGNORED_DIRECTORIES.has(entry.name)) continue;
217
+ const absolute = resolve(dir, entry.name);
218
+ if (entry.isSymbolicLink()) {
219
+ const real = realpathSync(absolute);
220
+ if (!insideRoot(root, real)) throw artifactError('TASK_ARTIFACT_PATH_ESCAPE', `artifact symlink escapes project: ${rel}`);
221
+ continue;
222
+ }
223
+ if (entry.isDirectory()) walk(absolute, rel);
224
+ else {
225
+ files.push(rel.replaceAll('\\', '/'));
226
+ if (files.length > maxEntries) throw artifactError('TASK_ARTIFACT_SCAN_LIMIT', `artifact scan exceeded ${maxEntries} entries`);
227
+ }
228
+ }
229
+ };
230
+ walk(root);
231
+ return files;
232
+ }
233
+
234
+ export function evaluateArtifactSpecs({ projectRoot, specs = [], registeredArtifacts = [], limits = {} } = {}) {
235
+ const registered = new Map(registeredArtifacts.map((artifact) => [String(artifact.name || ''), artifact]));
236
+ let files = null;
237
+ const results = specs.map((spec) => {
238
+ const name = String(spec.name || '').trim();
239
+ const type = String(spec.type || '').trim();
240
+ if (!name || !['name', 'path', 'glob', 'file-count'].includes(type)) {
241
+ throw artifactError('TASK_ARTIFACT_SPEC_INVALID', `invalid artifact spec: ${name || '(unnamed)'}`);
242
+ }
243
+ if (registered.has(name)) return { name, type, satisfied: true, count: 1, source: 'registered' };
244
+ if (type === 'name') return { name, type, satisfied: false, count: 0, source: 'registry' };
245
+ if (spec.fromFilesystem !== true) return { name, type, satisfied: false, count: 0, source: 'filesystem-disabled' };
246
+
247
+ if (type === 'path') {
248
+ const checked = safeRelativePath(projectRoot, spec.path);
249
+ const satisfied = existsSync(checked.target) && !lstatSync(checked.target).isSymbolicLink();
250
+ return { name, type, satisfied, count: satisfied ? 1 : 0, source: 'filesystem' };
251
+ }
252
+
253
+ safeRelativePath(projectRoot, spec.glob);
254
+ files ??= scanProject(projectRoot, limits);
255
+ const matcher = globRegExp(spec.glob);
256
+ const count = files.filter((file) => matcher.test(file)).length;
257
+ if (type === 'glob') return { name, type, satisfied: count > 0, count, source: 'filesystem' };
258
+ const min = Number.isSafeInteger(spec.min) ? spec.min : 1;
259
+ const max = Number.isSafeInteger(spec.max) ? spec.max : Number.POSITIVE_INFINITY;
260
+ return { name, type, satisfied: count >= min && count <= max, count, source: 'filesystem' };
261
+ });
262
+ return { ok: results.every((result) => result.satisfied), results };
263
+ }
264
+
265
+ function readJson(path, fallback) {
266
+ try { return JSON.parse(readFileSync(path, 'utf8')); }
267
+ catch (error) {
268
+ if (error?.code === 'ENOENT') return fallback;
269
+ throw Object.assign(new Error(`invalid JSON: ${path}`), { code: 'TASK_CONTRACT_JSON_INVALID', cause: error });
270
+ }
271
+ }
272
+
273
+ function artifactManifest(changeDir) {
274
+ const path = join(changeDir, 'artifacts.json');
275
+ const raw = existsSync(path) ? readFileSync(path, 'utf8') : '';
276
+ const parsed = raw ? readJson(path, {}) : {};
277
+ if (raw && (parsed?.schema_version !== 1 || !Array.isArray(parsed?.artifacts))) {
278
+ throw Object.assign(new Error('artifacts.json must use schema_version 1 and an artifacts array'), {
279
+ code: 'TASK_ARTIFACT_MANIFEST_INVALID',
280
+ });
281
+ }
282
+ return { raw, specs: parsed?.artifacts ?? [], hash: contentHashOf(raw) };
283
+ }
284
+
285
+ export function buildTaskContractSnapshot({
286
+ vaultBase,
287
+ projectRoot,
288
+ changeSlug,
289
+ identity,
290
+ context = null,
291
+ registeredArtifacts = [],
292
+ artifactLimits,
293
+ } = {}) {
294
+ const slug = String(changeSlug || '').trim();
295
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(slug)) {
296
+ throw Object.assign(new Error(`invalid change slug: ${slug}`), { code: 'TASK_CHANGE_INVALID' });
297
+ }
298
+ const changeDir = join(vaultBase, getLocale(vaultBase).folders.changes, slug);
299
+ let tarefasMd;
300
+ try { tarefasMd = readFileSync(join(changeDir, 'tarefas.md'), 'utf8'); }
301
+ catch (error) {
302
+ throw Object.assign(new Error(`change not found: ${slug}`), { code: 'TASK_CHANGE_NOT_FOUND', cause: error });
303
+ }
304
+ const tasks = parseTasks(tarefasMd);
305
+ const reqIds = uniqueStrings(tasks.flatMap((task) => task.reqs ?? []));
306
+ const effective = buildEffectiveRequirementPackage(vaultBase, changeDir, reqIds);
307
+ const manifest = artifactManifest(changeDir);
308
+ const evidence = readJson(join(changeDir, 'evidencia.json'), null);
309
+ const causalContext = context || resolveActiveContext(vaultBase, identity);
310
+ const binding = {
311
+ projectId: identity.projectId,
312
+ activeContextId: activeContextKey(identity),
313
+ headSha: identity.headSha,
314
+ tasksSha256: tasksHashOf(tarefasMd),
315
+ effectiveSpecSha256: effective.hash,
316
+ artifactManifestSha256: manifest.hash,
317
+ };
318
+ const contracts = deriveTaskContracts({
319
+ ...binding,
320
+ changeSlug: slug,
321
+ tasks,
322
+ artifactSpecs: manifest.specs,
323
+ taskLeases: causalContext?.task_leases ?? {},
324
+ evidenceEnvelopeId: evidence?.envelope_id ?? null,
325
+ });
326
+ const artifactEvaluation = evaluateArtifactSpecs({
327
+ projectRoot,
328
+ specs: manifest.specs,
329
+ registeredArtifacts,
330
+ limits: artifactLimits,
331
+ });
332
+ return {
333
+ schema_version: 1,
334
+ change_slug: slug,
335
+ binding: bindingFrom(binding),
336
+ contracts,
337
+ requirement_ids: effective.requirements.map((requirement) => requirement.id).filter(Boolean),
338
+ missing_requirement_ids: effective.missing,
339
+ sensor_results: evidence?.sensors ?? [],
340
+ evidence_envelope_id: evidence?.envelope_id ?? null,
341
+ artifact_results: artifactEvaluation.results,
342
+ };
343
+ }
344
+
345
+ export function evaluateTaskContracts(snapshot) {
346
+ const completed = new Set();
347
+ let changed = true;
348
+ while (changed) {
349
+ changed = false;
350
+ for (const contract of snapshot.contracts ?? []) {
351
+ if (completed.has(contract.task_id)) continue;
352
+ const result = evaluateTaskContract(contract, {
353
+ currentBinding: snapshot.binding,
354
+ availableRequirementIds: snapshot.requirement_ids,
355
+ sensorResults: snapshot.sensor_results,
356
+ artifactResults: snapshot.artifact_results,
357
+ completedTaskIds: [...completed],
358
+ });
359
+ if (result.can_complete) {
360
+ completed.add(contract.task_id);
361
+ changed = true;
362
+ }
363
+ }
364
+ }
365
+ return (snapshot.contracts ?? []).map((contract) => evaluateTaskContract(contract, {
366
+ currentBinding: snapshot.binding,
367
+ availableRequirementIds: snapshot.requirement_ids,
368
+ sensorResults: snapshot.sensor_results,
369
+ artifactResults: snapshot.artifact_results,
370
+ completedTaskIds: [...completed].filter((id) => id !== contract.task_id),
371
+ }));
372
+ }
373
+
374
+ export function deriveHandoffContract(input = {}) {
375
+ const contract = {
376
+ schema_version: 1,
377
+ from: String(input.from || ''),
378
+ to: String(input.to || ''),
379
+ active_context_id: String(input.activeContextId || ''),
380
+ task_id: String(input.taskId || ''),
381
+ task_contract_id: String(input.taskContractId || ''),
382
+ artifacts: uniqueStrings(input.artifacts),
383
+ evidence: uniqueStrings(input.evidence),
384
+ decisions: uniqueStrings(input.decisions),
385
+ next_actions: uniqueStrings(input.nextActions),
386
+ blockers: uniqueStrings(input.blockers),
387
+ head_sha: String(input.headSha || ''),
388
+ tasks_sha256: String(input.tasksSha256 || ''),
389
+ spec_sha256: String(input.specSha256 || ''),
390
+ authority: 'verified',
391
+ };
392
+ for (const field of ['from', 'to', 'active_context_id', 'head_sha', 'tasks_sha256', 'spec_sha256']) {
393
+ if (!contract[field]) {
394
+ throw Object.assign(new Error(`handoff field is required: ${field}`), { code: 'HANDOFF_CONTRACT_INVALID' });
395
+ }
396
+ }
397
+ contract.handoff_id = sha256(canonicalJson(contract));
398
+ return contract;
399
+ }
400
+
401
+ export function normalizeHandoffContract(value) {
402
+ if (typeof value === 'string') {
403
+ const summary = value.trim();
404
+ return summary ? { schema_version: 0, authority: 'legacy-reported', summary } : null;
405
+ }
406
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
407
+ if (value.schema_version !== 1) {
408
+ const summary = String(value.summary || '').trim();
409
+ return summary ? { schema_version: 0, authority: 'legacy-reported', summary } : null;
410
+ }
411
+ const normalized = {
412
+ schema_version: 1,
413
+ handoff_id: String(value.handoff_id || ''),
414
+ from: String(value.from || ''),
415
+ to: String(value.to || ''),
416
+ active_context_id: String(value.active_context_id || ''),
417
+ task_id: String(value.task_id || ''),
418
+ task_contract_id: String(value.task_contract_id || ''),
419
+ artifacts: uniqueStrings(value.artifacts),
420
+ evidence: uniqueStrings(value.evidence),
421
+ decisions: uniqueStrings(value.decisions),
422
+ next_actions: uniqueStrings(value.next_actions),
423
+ blockers: uniqueStrings(value.blockers),
424
+ head_sha: String(value.head_sha || ''),
425
+ tasks_sha256: String(value.tasks_sha256 || ''),
426
+ spec_sha256: String(value.spec_sha256 || ''),
427
+ authority: value.authority === 'verified' ? 'verified' : 'reported',
428
+ };
429
+ if (!normalized.handoff_id || !normalized.active_context_id || !normalized.head_sha
430
+ || !normalized.tasks_sha256 || !normalized.spec_sha256) {
431
+ throw Object.assign(new Error('structured handoff is incomplete'), { code: 'HANDOFF_CONTRACT_INVALID' });
432
+ }
433
+ return normalized;
434
+ }
435
+
436
+ export function evaluateHandoffContract(contract, current = {}) {
437
+ if (!contract || contract.schema_version !== 1) {
438
+ return { state: 'legacy-reported', blocking_findings: [] };
439
+ }
440
+ const findings = [];
441
+ for (const [field, code] of [
442
+ ['head_sha', 'HANDOFF_STALE_HEAD'],
443
+ ['tasks_sha256', 'HANDOFF_STALE_TASKS'],
444
+ ['spec_sha256', 'HANDOFF_STALE_SPEC'],
445
+ ]) {
446
+ if (String(contract[field] || '') !== String(current[field] || '')) findings.push({ code, field });
447
+ }
448
+ return { state: findings.length ? 'stale' : 'verified', blocking_findings: findings };
449
+ }
450
+
451
+ export function assertStructuredHandoffForProfile(profile, contract) {
452
+ if (String(profile || '').toUpperCase() === 'ASSURE'
453
+ && (!contract || contract.schema_version !== 1 || contract.authority !== 'verified')) {
454
+ throw Object.assign(new Error('ASSURE requires a verified structured handoff'), {
455
+ code: 'HANDOFF_STRUCTURED_REQUIRED',
456
+ });
457
+ }
458
+ return contract;
459
+ }
460
+
461
+ export function buildStructuredTaskHandoff({
462
+ profile = 'GOVERN',
463
+ sessionId = '',
464
+ snapshot = null,
465
+ evaluations = [],
466
+ context = {},
467
+ shared = null,
468
+ } = {}) {
469
+ const base = shared && typeof shared === 'object' && !Array.isArray(shared) ? { ...shared } : {};
470
+ if (!snapshot) {
471
+ assertStructuredHandoffForProfile(profile, null);
472
+ return Object.keys(base).length ? base : null;
473
+ }
474
+ const activeLease = Object.values(context?.task_leases || {}).find((lease) => (
475
+ lease?.state === 'active' && lease.owner_session_id === String(sessionId)
476
+ ));
477
+ const selectedEvaluation = evaluations.find((item) => item.task_id === activeLease?.task_id)
478
+ || evaluations.find((item) => !item.can_complete)
479
+ || evaluations[0]
480
+ || null;
481
+ const selectedContract = snapshot.contracts?.find((item) => item.task_id === selectedEvaluation?.task_id)
482
+ || snapshot.contracts?.[0]
483
+ || null;
484
+ const blockers = uniqueStrings([
485
+ ...uniqueStrings(base.blockers),
486
+ ...(selectedEvaluation?.blocking_findings ?? []).map((finding) => finding.code),
487
+ ]);
488
+ const contract = deriveHandoffContract({
489
+ from: sessionId,
490
+ to: base.to || 'next-session',
491
+ activeContextId: snapshot.binding?.active_context_id,
492
+ taskId: selectedContract?.task_id || '',
493
+ taskContractId: selectedContract?.contract_id || '',
494
+ artifacts: (snapshot.artifact_results ?? []).filter((item) => item.satisfied).map((item) => item.name),
495
+ evidence: snapshot.evidence_envelope_id ? [snapshot.evidence_envelope_id] : [],
496
+ decisions: base.decisions,
497
+ nextActions: base.next_actions,
498
+ blockers,
499
+ headSha: snapshot.binding?.head_sha,
500
+ tasksSha256: snapshot.binding?.tasks_sha256,
501
+ specSha256: snapshot.binding?.effective_spec_sha256,
502
+ });
503
+ assertStructuredHandoffForProfile(profile, contract);
504
+ return {
505
+ ...base,
506
+ tasks_hash: snapshot.binding.tasks_sha256,
507
+ spec_hash: snapshot.binding.effective_spec_sha256,
508
+ handoff_contract: contract,
509
+ };
510
+ }