wendkeep 0.78.0 → 0.80.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/CHANGELOG.md +67 -0
- package/README.en.md +58 -3
- package/README.md +58 -3
- package/docs/en/commands/changes-and-verification.md +116 -1
- package/docs/en/commands/operating-profiles.md +49 -5
- package/docs/en/commands/sessions-and-import.md +6 -0
- package/docs/en/commands/verify.md +54 -0
- package/docs/en/commands/worktrees.md +39 -4
- package/docs/pt-BR/commands/changes-and-verification.md +115 -1
- package/docs/pt-BR/commands/operating-profiles.md +51 -5
- package/docs/pt-BR/commands/sessions-and-import.md +7 -0
- package/docs/pt-BR/commands/verify.md +53 -0
- package/docs/pt-BR/commands/worktrees.md +38 -3
- package/hooks/active-context-store.mjs +530 -2
- package/hooks/change-core.mjs +220 -123
- package/hooks/obsidian-common.mjs +175 -9
- package/hooks/session-stop.mjs +40 -1
- package/hooks/spec-core.mjs +93 -29
- package/package.json +2 -2
- package/packages/cli/src/index.mjs +7 -0
- package/packages/vault/src/memory-handoff.mjs +15 -0
- package/schema/artifact-manifest-v1.schema.json +35 -0
- package/schema/handoff-contract-v1.schema.json +37 -0
- package/schema/task-contract-v1.schema.json +57 -0
- package/schema/wendkeep.provenance-receipt-v2.schema.json +66 -0
- package/src/archive-operation-lock.mjs +235 -0
- package/src/change.mjs +1780 -79
- package/src/delivery.mjs +724 -67
- package/src/memory.mjs +2 -1
- package/src/provenance-gate.mjs +575 -0
- package/src/provenance-sources.mjs +547 -0
- package/src/receipt-ledger.mjs +841 -0
- package/src/release-provenance.mjs +48 -0
- package/src/task-contracts.mjs +510 -0
- package/src/task-leases.mjs +105 -0
- package/src/task.mjs +115 -0
- package/src/verify.mjs +32 -0
- package/src/worktree-cleanup.mjs +1733 -118
- package/src/worktree.mjs +94 -5
|
@@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { cpSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
|
+
import { evaluateReleaseChain } from './provenance-gate.mjs';
|
|
5
6
|
|
|
6
7
|
const DEPENDENCY_FIELDS = Object.freeze([
|
|
7
8
|
'dependencies',
|
|
@@ -52,6 +53,50 @@ export function packIntegrityInIsolatedCopy(root, { execute = execFileSync } = {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Calculate the publishable tarball from an immutable target commit. A local,
|
|
58
|
+
* detached clone keeps lifecycle mutations and an incidental worktree out of
|
|
59
|
+
* the observation.
|
|
60
|
+
*/
|
|
61
|
+
export function collectArtifactAtCommit({
|
|
62
|
+
repoRoot,
|
|
63
|
+
targetCommit,
|
|
64
|
+
execute = execFileSync,
|
|
65
|
+
} = {}) {
|
|
66
|
+
if (!repoRoot || !/^[0-9a-f]{40}$/i.test(String(targetCommit || ''))) {
|
|
67
|
+
return { ok: false, state: 'unproven', reasonCodes: ['PROVENANCE_COMMIT_MISSING'] };
|
|
68
|
+
}
|
|
69
|
+
const tempRoot = mkdtempSync(join(tmpdir(), 'wendkeep-release-target-'));
|
|
70
|
+
const targetRoot = join(tempRoot, 'target');
|
|
71
|
+
try {
|
|
72
|
+
execute('git', ['clone', '--quiet', '--no-checkout', '--local', repoRoot, targetRoot], {
|
|
73
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: false,
|
|
74
|
+
});
|
|
75
|
+
execute('git', ['checkout', '--quiet', '--detach', targetCommit], {
|
|
76
|
+
cwd: targetRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: false,
|
|
77
|
+
});
|
|
78
|
+
const integrity = packIntegrityInIsolatedCopy(targetRoot, { execute });
|
|
79
|
+
if (!integrity) return {
|
|
80
|
+
ok: false, state: 'unproven', commit: targetCommit,
|
|
81
|
+
reasonCodes: ['PROVENANCE_INTEGRITY_UNOBSERVED'],
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
state: 'verified',
|
|
86
|
+
commit: targetCommit,
|
|
87
|
+
integrity,
|
|
88
|
+
reasonCodes: [],
|
|
89
|
+
};
|
|
90
|
+
} catch {
|
|
91
|
+
return {
|
|
92
|
+
ok: false, state: 'reported', commit: targetCommit,
|
|
93
|
+
reasonCodes: ['PROVENANCE_SOURCE_UNAVAILABLE'],
|
|
94
|
+
};
|
|
95
|
+
} finally {
|
|
96
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
55
100
|
export function packageHasSelfDependency(pkg = {}) {
|
|
56
101
|
const name = String(pkg.name || '');
|
|
57
102
|
if (!name) return false;
|
|
@@ -66,7 +111,10 @@ export function evaluateReleaseProvenance({
|
|
|
66
111
|
publishedIntegrity = '',
|
|
67
112
|
localIntegrity = '',
|
|
68
113
|
requirePublished = false,
|
|
114
|
+
chain = null,
|
|
115
|
+
context = null,
|
|
69
116
|
} = {}) {
|
|
117
|
+
if (chain) return evaluateReleaseChain({ chain, context: context || {} });
|
|
70
118
|
const tag = `v${version}`;
|
|
71
119
|
if (tagCommit && tagCommit !== headCommit) {
|
|
72
120
|
return {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { activeContextKey } from '../hooks/active-context-store.mjs';
|
|
3
|
+
import { mutateSessionRegistry } from '../hooks/obsidian-common.mjs';
|
|
4
|
+
|
|
5
|
+
function leaseError(code, message, details = {}) {
|
|
6
|
+
return Object.assign(new Error(message), { code, ...details });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function contextsOf(registry) {
|
|
10
|
+
return registry.active_contexts && typeof registry.active_contexts === 'object'
|
|
11
|
+
&& !Array.isArray(registry.active_contexts) ? registry.active_contexts : {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function leaseKey(changeSlug, taskId) {
|
|
15
|
+
return `${String(changeSlug)}:${String(taskId)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function expiresAt(lease) {
|
|
19
|
+
const value = Date.parse(String(lease?.expires_at || ''));
|
|
20
|
+
return Number.isFinite(value) ? value : 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function bumpContext(context, now) {
|
|
24
|
+
context.revision = Number.isSafeInteger(Number(context.revision)) ? Number(context.revision) + 1 : 1;
|
|
25
|
+
context.updated_at = now.toISOString();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function claimTaskLease({
|
|
29
|
+
vaultBase,
|
|
30
|
+
identity,
|
|
31
|
+
changeSlug,
|
|
32
|
+
taskId,
|
|
33
|
+
ownerSessionId,
|
|
34
|
+
leaseSeconds = 900,
|
|
35
|
+
now = new Date(),
|
|
36
|
+
} = {}) {
|
|
37
|
+
const key = leaseKey(changeSlug, taskId);
|
|
38
|
+
const contextKey = activeContextKey(identity);
|
|
39
|
+
const duration = Number(leaseSeconds);
|
|
40
|
+
if (!Number.isSafeInteger(duration) || duration < 1 || duration > 86_400) {
|
|
41
|
+
throw leaseError('TASK_LEASE_DURATION_INVALID', 'lease duration must be between 1 and 86400 seconds');
|
|
42
|
+
}
|
|
43
|
+
return mutateSessionRegistry(vaultBase, (registry) => {
|
|
44
|
+
const contexts = contextsOf(registry);
|
|
45
|
+
const target = contexts[contextKey];
|
|
46
|
+
if (!target || target.state !== 'active') throw leaseError('TASK_ACTIVE_CONTEXT_NOT_FOUND', 'active context not found');
|
|
47
|
+
if (String(target.change_slug || '') !== String(changeSlug || '')) {
|
|
48
|
+
throw leaseError('TASK_CHANGE_CONTEXT_MISMATCH', 'task change differs from active context');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
for (const context of Object.values(contexts)) {
|
|
52
|
+
const existing = context?.task_leases?.[key];
|
|
53
|
+
if (!existing || existing.state !== 'active') continue;
|
|
54
|
+
if (expiresAt(existing) <= now.getTime()) {
|
|
55
|
+
existing.state = 'expired';
|
|
56
|
+
existing.expired_at = now.toISOString();
|
|
57
|
+
bumpContext(context, now);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (existing.owner_session_id === String(ownerSessionId)
|
|
61
|
+
&& existing.owner_work_session_id === identity.workSessionId) return structuredClone(existing);
|
|
62
|
+
throw leaseError('TASK_LEASE_CONFLICT', `task ${taskId} is claimed by another active session`, {
|
|
63
|
+
owner_session_id: existing.owner_session_id,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const lease = {
|
|
68
|
+
schema_version: 1,
|
|
69
|
+
lease_id: randomUUID(),
|
|
70
|
+
change_slug: String(changeSlug),
|
|
71
|
+
task_id: String(taskId),
|
|
72
|
+
owner_session_id: String(ownerSessionId),
|
|
73
|
+
owner_work_session_id: String(identity.workSessionId),
|
|
74
|
+
state: 'active',
|
|
75
|
+
claimed_at: now.toISOString(),
|
|
76
|
+
expires_at: new Date(now.getTime() + duration * 1000).toISOString(),
|
|
77
|
+
};
|
|
78
|
+
target.task_leases = { ...(target.task_leases || {}), [key]: lease };
|
|
79
|
+
bumpContext(target, now);
|
|
80
|
+
registry.active_contexts_revision = Number(registry.active_contexts_revision || 0) + 1;
|
|
81
|
+
return structuredClone(lease);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function releaseTaskLease({ vaultBase, identity, changeSlug, taskId, ownerSessionId, now = new Date() } = {}) {
|
|
86
|
+
const key = leaseKey(changeSlug, taskId);
|
|
87
|
+
return mutateSessionRegistry(vaultBase, (registry) => {
|
|
88
|
+
const contexts = contextsOf(registry);
|
|
89
|
+
let found = null;
|
|
90
|
+
for (const context of Object.values(contexts)) {
|
|
91
|
+
const lease = context?.task_leases?.[key];
|
|
92
|
+
if (lease?.state === 'active') { found = { context, lease }; break; }
|
|
93
|
+
}
|
|
94
|
+
if (!found) throw leaseError('TASK_LEASE_NOT_FOUND', `no active lease for task ${taskId}`);
|
|
95
|
+
if (found.lease.owner_session_id !== String(ownerSessionId)
|
|
96
|
+
|| found.lease.owner_work_session_id !== identity.workSessionId) {
|
|
97
|
+
throw leaseError('TASK_LEASE_NOT_OWNER', `session does not own task ${taskId}`);
|
|
98
|
+
}
|
|
99
|
+
found.lease.state = 'released';
|
|
100
|
+
found.lease.released_at = now.toISOString();
|
|
101
|
+
bumpContext(found.context, now);
|
|
102
|
+
registry.active_contexts_revision = Number(registry.active_contexts_revision || 0) + 1;
|
|
103
|
+
return structuredClone(found.lease);
|
|
104
|
+
});
|
|
105
|
+
}
|