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
package/src/delivery.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
6
|
import {
|
|
5
7
|
assertVaultPathSafe, mkdirVaultPath, writeVaultFileSync,
|
|
6
8
|
} from '../hooks/vault-path-safety.mjs';
|
|
@@ -13,12 +15,30 @@ import {
|
|
|
13
15
|
setActiveContextDelivery,
|
|
14
16
|
} from '../hooks/active-context-store.mjs';
|
|
15
17
|
import { resolveCommandActiveContext } from './active-context-runtime.mjs';
|
|
18
|
+
import {
|
|
19
|
+
evaluateProvenanceGate,
|
|
20
|
+
evaluateReleaseChain,
|
|
21
|
+
} from './provenance-gate.mjs';
|
|
22
|
+
import {
|
|
23
|
+
collectCiObservation,
|
|
24
|
+
collectGitHubReleaseObservation,
|
|
25
|
+
collectGitSubject,
|
|
26
|
+
collectNpmObservation,
|
|
27
|
+
collectTagObservation,
|
|
28
|
+
normalizeRepository,
|
|
29
|
+
} from './provenance-sources.mjs';
|
|
30
|
+
import {
|
|
31
|
+
appendReceipt as appendLedgerReceipt,
|
|
32
|
+
createFileReceiptStore,
|
|
33
|
+
readReceiptLedger,
|
|
34
|
+
} from './receipt-ledger.mjs';
|
|
35
|
+
import { collectArtifactAtCommit } from './release-provenance.mjs';
|
|
16
36
|
|
|
17
37
|
export const DELIVERY_HELP = `wendkeep delivery <subcommand>
|
|
18
38
|
|
|
19
39
|
start [id] --allow <capability> [--source-change <slug>] [--source-commit <sha>]
|
|
20
40
|
status [id]
|
|
21
|
-
finish [id] [--target <
|
|
41
|
+
finish [id] [--target <remote>/<branch>] [--ci-url <url>] [--version <x.y.z>]
|
|
22
42
|
[--npm-integrity <sha512-...>] [--release-url <url>]
|
|
23
43
|
abandon [id] --reason <text>
|
|
24
44
|
|
|
@@ -31,6 +51,9 @@ export const DELIVERY_CAPABILITIES = Object.freeze([
|
|
|
31
51
|
'git:merge', 'git:pull', 'git:push', 'git:tag', 'publish',
|
|
32
52
|
]);
|
|
33
53
|
|
|
54
|
+
const REMOTE_TARGET_CAPABILITIES = Object.freeze(['git:merge', 'git:push']);
|
|
55
|
+
const REMOTE_BOUND_CAPABILITIES = Object.freeze([...REMOTE_TARGET_CAPABILITIES, 'publish']);
|
|
56
|
+
|
|
34
57
|
const VALUE_OPTIONS = new Set([
|
|
35
58
|
'--project', '--vault', '--allow', '--source-change', '--source-commit', '--target',
|
|
36
59
|
'--ci-url', '--version', '--npm-integrity', '--release-url', '--reason', '--session',
|
|
@@ -64,6 +87,41 @@ function parseArgv(argv) {
|
|
|
64
87
|
};
|
|
65
88
|
}
|
|
66
89
|
|
|
90
|
+
function sanitizeDeliveryText(value, maximum = 240) {
|
|
91
|
+
const clean = String(value || '')
|
|
92
|
+
.replace(/\b(?:ghp|github_pat|npm_|sk-|xox[baprs]-)[A-Za-z0-9_-]+/gi, '[redacted-token]')
|
|
93
|
+
.replace(/\b(?:token|authorization|bearer|password|secret|api[_-]?key)\s*[=:]\s*[^\s,;]+/gi, '[redacted-token]')
|
|
94
|
+
.replace(/\bBearer\s+[^\s,;]+/gi, '[redacted-token]')
|
|
95
|
+
.replace(/[A-Za-z]:\\[^\r\n"'`;|&]+/g, '[redacted-path]')
|
|
96
|
+
.replace(/(?:^|\s)\/[^\s"'`;|&]+/g, ' [redacted-path]')
|
|
97
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
98
|
+
.trim();
|
|
99
|
+
return clean.length > maximum ? `${clean.slice(0, maximum - 3)}...` : clean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sanitizedDeliveryValue(value, depth = 0) {
|
|
103
|
+
if (depth > 4 || value === undefined) return undefined;
|
|
104
|
+
if (value === null || typeof value === 'boolean' || typeof value === 'number') return value;
|
|
105
|
+
if (typeof value === 'string') return sanitizeDeliveryText(value);
|
|
106
|
+
if (Array.isArray(value)) return value.slice(0, 20).map((item) => sanitizedDeliveryValue(item, depth + 1));
|
|
107
|
+
if (typeof value === 'object') {
|
|
108
|
+
return Object.fromEntries(Object.entries(value).slice(0, 30)
|
|
109
|
+
.map(([key, item]) => [sanitizeDeliveryText(key, 80), sanitizedDeliveryValue(item, depth + 1)]));
|
|
110
|
+
}
|
|
111
|
+
return sanitizeDeliveryText(value);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function gitFailure(error) {
|
|
115
|
+
const failure = new Error('Falha ao consultar o repositório Git para a delivery.');
|
|
116
|
+
failure.code = 'WENDKEEP_DELIVERY_GIT_FAILED';
|
|
117
|
+
failure.operation = 'delivery.git';
|
|
118
|
+
failure.state = 'unproven';
|
|
119
|
+
failure.blocker = failure.code;
|
|
120
|
+
failure.recovery = 'wendkeep delivery status --json';
|
|
121
|
+
if (Number.isSafeInteger(Number(error?.status))) failure.status = Number(error.status);
|
|
122
|
+
return failure;
|
|
123
|
+
}
|
|
124
|
+
|
|
67
125
|
function git(projectRoot, args, optional = false) {
|
|
68
126
|
try {
|
|
69
127
|
return execFileSync('git', args, {
|
|
@@ -71,7 +129,18 @@ function git(projectRoot, args, optional = false) {
|
|
|
71
129
|
}).trim();
|
|
72
130
|
} catch (error) {
|
|
73
131
|
if (optional) return '';
|
|
74
|
-
throw
|
|
132
|
+
throw gitFailure(error);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function gitWith(projectRoot, args, execute = execFileSync, optional = false) {
|
|
137
|
+
try {
|
|
138
|
+
return String(execute('git', args, {
|
|
139
|
+
cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: false, timeout: 15_000,
|
|
140
|
+
}) || '').trim();
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (optional) return '';
|
|
143
|
+
throw gitFailure(error);
|
|
75
144
|
}
|
|
76
145
|
}
|
|
77
146
|
|
|
@@ -82,7 +151,10 @@ function deliveryPaths(vaultBase, id = '') {
|
|
|
82
151
|
runtime,
|
|
83
152
|
deliveries,
|
|
84
153
|
pointer: join(runtime, 'CURRENT_DELIVERY'),
|
|
85
|
-
|
|
154
|
+
legacyReceipts: join(runtime, 'delivery-receipts.jsonl'),
|
|
155
|
+
receipts: join(runtime, 'delivery-receipts-v2.jsonl'),
|
|
156
|
+
receiptsCheckpoint: join(runtime, 'delivery-receipts-v2.checkpoint.json'),
|
|
157
|
+
receiptsLock: join(runtime, 'delivery-receipts-v2.lock'),
|
|
86
158
|
state: id ? join(deliveries, `${id}.json`) : '',
|
|
87
159
|
};
|
|
88
160
|
}
|
|
@@ -105,6 +177,10 @@ function readPointer(vaultBase) {
|
|
|
105
177
|
function deliveryContextError(message) {
|
|
106
178
|
const error = new Error(message);
|
|
107
179
|
error.code = 'WENDKEEP_DELIVERY_CONTEXT_MISMATCH';
|
|
180
|
+
error.operation = 'delivery';
|
|
181
|
+
error.state = 'conflict';
|
|
182
|
+
error.blocker = error.code;
|
|
183
|
+
error.recovery = 'wendkeep delivery status --json';
|
|
108
184
|
return error;
|
|
109
185
|
}
|
|
110
186
|
|
|
@@ -123,13 +199,57 @@ function contextualBinding(vaultBase, context, expectedId = '') {
|
|
|
123
199
|
return { binding, id, key: activeContextKey(context) };
|
|
124
200
|
}
|
|
125
201
|
|
|
202
|
+
function contextIdentityBinding(context, authority = {}) {
|
|
203
|
+
return {
|
|
204
|
+
project_id: String(authority.project_id ?? context?.projectId ?? context?.project_id ?? ''),
|
|
205
|
+
repository_id: String(authority.repository_id ?? context?.repositoryId ?? context?.repository_id ?? ''),
|
|
206
|
+
worktree_id: String(authority.worktree_id ?? context?.worktreeId ?? context?.worktree_id ?? ''),
|
|
207
|
+
work_session_id: String(authority.work_session_id ?? context?.workSessionId ?? context?.work_session_id ?? ''),
|
|
208
|
+
change_slug: String(authority.change_slug || ''),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
126
212
|
function assertStateContext(state, binding) {
|
|
213
|
+
if (state.context_key && !binding) {
|
|
214
|
+
throw deliveryContextError(`delivery ${state.id} exige o active context proprietário`);
|
|
215
|
+
}
|
|
127
216
|
if (!binding) return;
|
|
128
217
|
if (state.context_key !== binding.key) {
|
|
129
218
|
throw deliveryContextError(`delivery ${state.id} pertence a outro active context`);
|
|
130
219
|
}
|
|
131
220
|
}
|
|
132
221
|
|
|
222
|
+
function normalizedCheckoutPath(value) {
|
|
223
|
+
let canonical = resolve(String(value || ''));
|
|
224
|
+
try { canonical = realpathSync.native(canonical); } catch { /* mismatch stays fail-closed below */ }
|
|
225
|
+
const normalized = canonical.replace(/\\/g, '/').replace(/\/$/, '');
|
|
226
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function deliveryScopeError(details) {
|
|
230
|
+
const error = new Error('delivery não pertence ao repository, worktree ou branch atuais');
|
|
231
|
+
error.code = 'WENDKEEP_DELIVERY_SCOPE_MISMATCH';
|
|
232
|
+
error.details = details;
|
|
233
|
+
error.operation = 'delivery.finish';
|
|
234
|
+
error.state = 'conflict';
|
|
235
|
+
error.blocker = error.code;
|
|
236
|
+
error.recovery = 'wendkeep delivery status --json';
|
|
237
|
+
return error;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function assertStateCheckout(state, repoRoot, execute) {
|
|
241
|
+
const currentWorktree = gitWith(repoRoot, ['rev-parse', '--show-toplevel'], execute, true);
|
|
242
|
+
const currentBranch = gitWith(repoRoot, ['branch', '--show-current'], execute, true);
|
|
243
|
+
const sameRepository = Boolean(typeof state.repository === 'string' && state.repository
|
|
244
|
+
&& normalizedCheckoutPath(repoRoot) === normalizedCheckoutPath(state.repository));
|
|
245
|
+
const sameWorktree = Boolean(typeof state.worktree === 'string' && state.worktree && currentWorktree
|
|
246
|
+
&& normalizedCheckoutPath(currentWorktree) === normalizedCheckoutPath(state.worktree));
|
|
247
|
+
const sameBranch = typeof state.branch === 'string' && currentBranch === state.branch;
|
|
248
|
+
if (!sameRepository || !sameWorktree || !sameBranch) {
|
|
249
|
+
throw deliveryScopeError({ same_repository: sameRepository, same_worktree: sameWorktree, same_branch: sameBranch });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
133
253
|
export function activeDelivery(vaultBase, { context = null } = {}) {
|
|
134
254
|
let binding = null;
|
|
135
255
|
let id = readPointer(vaultBase);
|
|
@@ -176,18 +296,24 @@ function setPointer(vaultBase, id = '') {
|
|
|
176
296
|
});
|
|
177
297
|
}
|
|
178
298
|
|
|
179
|
-
function
|
|
299
|
+
function receiptStore(vaultBase) {
|
|
180
300
|
const paths = deliveryPaths(vaultBase);
|
|
181
301
|
mkdirVaultPath(vaultBase, paths.runtime, { label: 'runtime de delivery' });
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
label
|
|
302
|
+
for (const [path, label] of [
|
|
303
|
+
[paths.receipts, 'ledger v2 de receipts de delivery'],
|
|
304
|
+
[paths.receiptsCheckpoint, 'checkpoint do ledger de delivery'],
|
|
305
|
+
[paths.receiptsLock, 'lock do ledger de delivery'],
|
|
306
|
+
[paths.legacyReceipts, 'ledger legado de delivery'],
|
|
307
|
+
]) assertVaultPathSafe(vaultBase, path, {
|
|
308
|
+
allowMissing: true,
|
|
309
|
+
expectedType: 'file',
|
|
310
|
+
label,
|
|
311
|
+
});
|
|
312
|
+
return createFileReceiptStore({
|
|
313
|
+
ledgerPath: paths.receipts,
|
|
314
|
+
checkpointPath: paths.receiptsCheckpoint,
|
|
315
|
+
legacyPath: paths.legacyReceipts,
|
|
316
|
+
lockPath: paths.receiptsLock,
|
|
191
317
|
});
|
|
192
318
|
}
|
|
193
319
|
|
|
@@ -234,36 +360,51 @@ export function startDelivery({
|
|
|
234
360
|
const deliveryId = safeId(id || generatedId(now));
|
|
235
361
|
const paths = deliveryPaths(vaultBase, deliveryId);
|
|
236
362
|
if (existsSync(paths.state)) throw new Error(`delivery já existe: ${deliveryId}`);
|
|
363
|
+
let existingContext = null;
|
|
237
364
|
if (context) {
|
|
238
365
|
try {
|
|
239
366
|
const current = contextualBinding(vaultBase, context);
|
|
240
367
|
if (current.id) throw deliveryContextBusy(current.id);
|
|
368
|
+
existingContext = current.binding;
|
|
241
369
|
} catch (error) {
|
|
242
370
|
if (error?.code !== 'WENDKEEP_ACTIVE_CONTEXT_NOT_FOUND') throw error;
|
|
243
371
|
}
|
|
244
372
|
}
|
|
245
|
-
const commit = sourceCommit || git(repoRoot, ['rev-parse', 'HEAD']);
|
|
246
|
-
git(repoRoot, ['cat-file', '-e', `${commit}^{commit}`]);
|
|
247
373
|
const requestedCapabilities = [...new Set((capabilities || []).map((item) => String(item).trim()).filter(Boolean))];
|
|
248
374
|
const invalidCapabilities = requestedCapabilities.filter((item) => !DELIVERY_CAPABILITIES.includes(item));
|
|
249
375
|
if (invalidCapabilities.length) {
|
|
250
376
|
throw new Error(`capability inválida: ${invalidCapabilities.join(', ')}. Use ${DELIVERY_CAPABILITIES.join(', ')}.`);
|
|
251
377
|
}
|
|
378
|
+
if (!requestedCapabilities.length) throw new Error('delivery start requer ao menos um --allow <capability>');
|
|
379
|
+
const commit = git(repoRoot, [
|
|
380
|
+
'rev-parse', '--verify', '--end-of-options', `${sourceCommit || 'HEAD'}^{commit}`,
|
|
381
|
+
]);
|
|
382
|
+
const remoteName = 'origin';
|
|
383
|
+
const remoteRepository = normalizeRepository(git(repoRoot, ['remote', 'get-url', remoteName], true));
|
|
384
|
+
if (requestedCapabilities.some((capability) => REMOTE_BOUND_CAPABILITIES.includes(capability)) && !remoteRepository) {
|
|
385
|
+
const error = new Error('delivery externa requer remote origin GitHub identificável no start.');
|
|
386
|
+
error.code = 'WENDKEEP_DELIVERY_REPOSITORY_REQUIRED';
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
252
389
|
const route = createWorkRoute({
|
|
253
390
|
workKind: 'delivery', profile: 'ASSURE', contractImpact: 'none',
|
|
254
391
|
operationRisk: requestedCapabilities, sourceChange, sourceCommit: commit,
|
|
255
392
|
});
|
|
256
|
-
if (!route.operation_risk.length) throw new Error('delivery start requer ao menos um --allow <capability>');
|
|
257
393
|
const state = {
|
|
258
394
|
schema_version: 1,
|
|
259
395
|
id: deliveryId,
|
|
260
396
|
state: 'active',
|
|
261
397
|
route,
|
|
262
398
|
repository: repoRoot,
|
|
399
|
+
remote_name: remoteName,
|
|
400
|
+
remote_repository: remoteRepository,
|
|
263
401
|
worktree: repoRoot,
|
|
264
402
|
branch: git(repoRoot, ['branch', '--show-current'], true),
|
|
265
403
|
source_commit: commit,
|
|
266
|
-
...(context ? {
|
|
404
|
+
...(context ? {
|
|
405
|
+
context_key: activeContextKey(context),
|
|
406
|
+
...contextIdentityBinding(context, existingContext || {}),
|
|
407
|
+
} : {}),
|
|
267
408
|
started_at: now.toISOString(),
|
|
268
409
|
};
|
|
269
410
|
writeState(vaultBase, state);
|
|
@@ -279,77 +420,553 @@ export function startDelivery({
|
|
|
279
420
|
return state;
|
|
280
421
|
}
|
|
281
422
|
|
|
423
|
+
const DEFAULT_PROVENANCE_COLLECTORS = Object.freeze({
|
|
424
|
+
collectGitSubject,
|
|
425
|
+
collectArtifactObservation: collectArtifactAtCommit,
|
|
426
|
+
collectTagObservation,
|
|
427
|
+
collectCiObservation,
|
|
428
|
+
collectNpmObservation,
|
|
429
|
+
collectGitHubReleaseObservation,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
function observationAssessment(kind, observation) {
|
|
433
|
+
const state = String(observation?.state || 'unproven');
|
|
434
|
+
return {
|
|
435
|
+
kind,
|
|
436
|
+
state,
|
|
437
|
+
reasonCodes: observation?.reasonCodes || (state === 'verified' ? [] : ['PROV_REQUIRED_ASSESSMENT_MISSING']),
|
|
438
|
+
diagnostics: observation?.diagnostics || [{ kind, state }],
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function verifiedAssessment(kind, diagnostics = []) {
|
|
443
|
+
return { kind, state: 'verified', reasonCodes: [], diagnostics };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function conflictAssessment(kind, code, diagnostics = []) {
|
|
447
|
+
return { kind, state: 'conflict', reasonCodes: [code], diagnostics };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function provenanceBlocked(gate, message = 'delivery bloqueada por evidência de proveniência insuficiente') {
|
|
451
|
+
const error = new Error(message);
|
|
452
|
+
error.code = 'WENDKEEP_PROVENANCE_GATE_BLOCKED';
|
|
453
|
+
error.provenance = gate;
|
|
454
|
+
error.operation = 'delivery.finish';
|
|
455
|
+
error.state = gate?.state || 'unproven';
|
|
456
|
+
error.blocker = gate?.reasonCodes?.[0] || 'PROV_REQUIRED_ASSESSMENT_MISSING';
|
|
457
|
+
error.recovery = gate?.repair?.command || 'wendkeep delivery status --json';
|
|
458
|
+
const first = gate?.diagnostics?.[0] || {};
|
|
459
|
+
error.expected = first.expected ?? null;
|
|
460
|
+
error.observed = first.observed ?? null;
|
|
461
|
+
return error;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function remoteBindingObservation({ repoRoot, state, remote = state.remote_name || 'origin', execute }) {
|
|
465
|
+
const expectedRepository = normalizeRepository(state.remote_repository || '');
|
|
466
|
+
const remoteUrl = gitWith(repoRoot, ['remote', 'get-url', remote], execute, true);
|
|
467
|
+
const repository = normalizeRepository(remoteUrl);
|
|
468
|
+
if (!expectedRepository) {
|
|
469
|
+
return {
|
|
470
|
+
state: 'unproven', reasonCodes: ['PROVENANCE_REPOSITORY_UNBOUND'], remote, repository,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (!repository) {
|
|
474
|
+
return {
|
|
475
|
+
state: 'unproven', reasonCodes: ['PROVENANCE_REPOSITORY_UNOBSERVED'], remote, repository,
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
if (repository !== expectedRepository) {
|
|
479
|
+
return {
|
|
480
|
+
state: 'conflict', reasonCodes: ['PROVENANCE_REPOSITORY_MISMATCH'],
|
|
481
|
+
remote, repository, expectedRepository,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
return {
|
|
485
|
+
state: 'verified', reasonCodes: [], remote, repository, expectedRepository,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function parseRemoteTarget(target) {
|
|
490
|
+
const value = String(target || '').trim();
|
|
491
|
+
const match = value.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)\/(.+)$/);
|
|
492
|
+
if (!match) return null;
|
|
493
|
+
const remote = match[1];
|
|
494
|
+
const branch = match[2].replace(/^refs\/heads\//, '');
|
|
495
|
+
if (!branch || branch.startsWith('-') || branch.endsWith('/') || branch.endsWith('.')
|
|
496
|
+
|| branch.includes('..') || branch.includes('@{') || branch.includes('\\')
|
|
497
|
+
|| /[\u0000-\u0020~^:?*[\u007f]/.test(branch)
|
|
498
|
+
|| branch.split('/').some((part) => !part || part.endsWith('.lock'))) return null;
|
|
499
|
+
return { remote, branch, ref: `refs/heads/${branch}` };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function remoteTargetObservation({ repoRoot, state, target, execute }) {
|
|
503
|
+
const parsed = parseRemoteTarget(target);
|
|
504
|
+
if (!parsed) {
|
|
505
|
+
return { state: 'unproven', reasonCodes: ['PROVENANCE_REMOTE_TARGET_REQUIRED'] };
|
|
506
|
+
}
|
|
507
|
+
const expectedRemote = String(state.remote_name || 'origin');
|
|
508
|
+
if (parsed.remote !== expectedRemote) {
|
|
509
|
+
return {
|
|
510
|
+
state: 'conflict', reasonCodes: ['PROVENANCE_REMOTE_TARGET_MISMATCH'],
|
|
511
|
+
remote: parsed.remote, expectedRemote, ref: parsed.ref,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
const binding = remoteBindingObservation({ repoRoot, state, remote: parsed.remote, execute });
|
|
515
|
+
if (binding.state !== 'verified') return { ...binding, ref: parsed.ref };
|
|
516
|
+
let output;
|
|
517
|
+
try {
|
|
518
|
+
output = gitWith(repoRoot, [
|
|
519
|
+
'ls-remote', '--exit-code', parsed.remote, parsed.ref,
|
|
520
|
+
], execute);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
return {
|
|
523
|
+
state: Number(error?.status) === 2 ? 'unproven' : 'reported',
|
|
524
|
+
reasonCodes: [Number(error?.status) === 2
|
|
525
|
+
? 'PROVENANCE_REMOTE_TARGET_UNOBSERVED'
|
|
526
|
+
: 'PROVENANCE_SOURCE_UNAVAILABLE'],
|
|
527
|
+
remote: parsed.remote,
|
|
528
|
+
ref: parsed.ref,
|
|
529
|
+
repository: binding.repository,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
const matches = String(output || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
|
|
533
|
+
.map((line) => line.match(/^([0-9a-f]{40})\s+(.+)$/i)).filter(Boolean)
|
|
534
|
+
.filter((match) => match[2] === parsed.ref);
|
|
535
|
+
if (matches.length !== 1) {
|
|
536
|
+
return {
|
|
537
|
+
state: 'unproven', reasonCodes: ['PROVENANCE_REMOTE_TARGET_UNOBSERVED'],
|
|
538
|
+
remote: parsed.remote, ref: parsed.ref, repository: binding.repository,
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
return {
|
|
542
|
+
state: 'verified', reasonCodes: [], commit: matches[0][1], remote: parsed.remote,
|
|
543
|
+
ref: parsed.ref, repository: binding.repository, locator: `${parsed.remote}/${parsed.branch}`,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function completedReceipt(vaultBase, state) {
|
|
548
|
+
const receipt = state.receipt;
|
|
549
|
+
if (!receipt || receipt.schema_version !== 2) {
|
|
550
|
+
const error = new Error('State completed não contém receipt v2 verificável.');
|
|
551
|
+
error.code = 'WENDKEEP_RECEIPT_LEDGER_CORRUPT';
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
const ledger = readReceiptLedger({ store: receiptStore(vaultBase) });
|
|
555
|
+
const record = ledger.records.find((candidate) => candidate.receipt_id === receipt.receipt_id);
|
|
556
|
+
const binding = deliveryBinding(state);
|
|
557
|
+
const recordMatches = record
|
|
558
|
+
&& record.receipt_hash === receipt.receipt_hash
|
|
559
|
+
&& record.sequence === receipt.sequence
|
|
560
|
+
&& record.kind === 'delivery.completed'
|
|
561
|
+
&& record.subject?.delivery_id === state.id
|
|
562
|
+
&& record.subject?.source_commit === state.source_commit
|
|
563
|
+
&& record.subject?.target_commit === state.target_commit
|
|
564
|
+
&& receipt.delivery_id === state.id
|
|
565
|
+
&& receipt.source_commit === state.source_commit
|
|
566
|
+
&& receipt.target_commit === state.target_commit
|
|
567
|
+
&& receipt.target === state.target
|
|
568
|
+
&& Object.entries(binding).every(([key, value]) => record.subject?.[key] === value && receipt[key] === value)
|
|
569
|
+
&& Object.keys(record).every((key) => isDeepStrictEqual(receipt[key], record[key]));
|
|
570
|
+
if (!recordMatches) {
|
|
571
|
+
const error = new Error('Receipt do state completed diverge do ledger v2 verificado.');
|
|
572
|
+
error.code = 'WENDKEEP_RECEIPT_LEDGER_CORRUPT';
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
return receipt;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function sanitizedLocator(value) {
|
|
579
|
+
try {
|
|
580
|
+
const url = new URL(String(value || ''));
|
|
581
|
+
if (url.protocol !== 'https:') return '';
|
|
582
|
+
return `${url.origin}${url.pathname}`;
|
|
583
|
+
} catch {
|
|
584
|
+
return '';
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function sanitizedEvidence(evidence = {}) {
|
|
589
|
+
return {
|
|
590
|
+
version: String(evidence.version || ''),
|
|
591
|
+
npm_integrity: String(evidence.npm_integrity || ''),
|
|
592
|
+
ci_url: sanitizedLocator(evidence.ci_url),
|
|
593
|
+
release_url: sanitizedLocator(evidence.release_url),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function sha256Text(value) {
|
|
598
|
+
return `sha256:${createHash('sha256').update(String(value), 'utf8').digest('hex')}`;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function privateReason(reason) {
|
|
602
|
+
return { reason: 'operator-provided', reason_digest: sha256Text(String(reason).trim()) };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function deliveryBinding(state) {
|
|
606
|
+
return {
|
|
607
|
+
project_id: String(state.project_id || ''),
|
|
608
|
+
repository_id: String(state.repository_id || ''),
|
|
609
|
+
repository: String(state.remote_repository || ''),
|
|
610
|
+
worktree_id: String(state.worktree_id || ''),
|
|
611
|
+
work_session_id: String(state.work_session_id || ''),
|
|
612
|
+
change_slug: String(state.route?.source_change || state.change_slug || ''),
|
|
613
|
+
branch: String(state.branch || ''),
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function releaseLocatorMatches(locator, repository, tag) {
|
|
618
|
+
try {
|
|
619
|
+
const url = new URL(String(locator || ''));
|
|
620
|
+
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
|
621
|
+
return url.protocol === 'https:'
|
|
622
|
+
&& url.hostname.toLowerCase() === 'github.com'
|
|
623
|
+
&& normalizeRepository(`${parts[0] || ''}/${parts[1] || ''}`) === repository
|
|
624
|
+
&& parts[2] === 'releases'
|
|
625
|
+
&& parts[3] === 'tag'
|
|
626
|
+
&& parts.slice(4).join('/') === tag;
|
|
627
|
+
} catch {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function sanitizedObservation(value = {}) {
|
|
633
|
+
const clean = {};
|
|
634
|
+
for (const key of [
|
|
635
|
+
'state', 'kind', 'commit', 'sourceCommit', 'targetCommit', 'name', 'version', 'tag',
|
|
636
|
+
'integrity', 'repository', 'locator', 'remote', 'ref', 'conclusion', 'status',
|
|
637
|
+
'expectedCommit', 'expectedIntegrity', 'expectedRepository',
|
|
638
|
+
]) if (value[key] !== undefined) clean[key] = value[key];
|
|
639
|
+
if (Array.isArray(value.reasonCodes)) clean.reasonCodes = value.reasonCodes.map(String);
|
|
640
|
+
if (value.package && typeof value.package === 'object') {
|
|
641
|
+
clean.package = { name: String(value.package.name || ''), version: String(value.package.version || '') };
|
|
642
|
+
}
|
|
643
|
+
return clean;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function sanitizedObservations(observations = {}) {
|
|
647
|
+
return Object.fromEntries(Object.entries(observations).map(([kind, value]) => [kind, sanitizedObservation(value)]));
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function deliveryReceiptDraft({ state, target, targetCommit, capabilities, evidence, observations, now }) {
|
|
651
|
+
return {
|
|
652
|
+
kind: 'delivery.completed',
|
|
653
|
+
subject: {
|
|
654
|
+
delivery_id: state.id,
|
|
655
|
+
source_commit: state.source_commit,
|
|
656
|
+
target,
|
|
657
|
+
target_commit: targetCommit,
|
|
658
|
+
...deliveryBinding(state),
|
|
659
|
+
},
|
|
660
|
+
claims: {
|
|
661
|
+
work_kind: 'delivery',
|
|
662
|
+
source_change: state.route.source_change || '',
|
|
663
|
+
capabilities,
|
|
664
|
+
evidence: sanitizedEvidence(evidence),
|
|
665
|
+
...(state.context_key ? { context_key: state.context_key } : {}),
|
|
666
|
+
},
|
|
667
|
+
observations: sanitizedObservations(observations),
|
|
668
|
+
recorded_at: now.toISOString(),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function publicDeliveryReceipt(record, state, target, targetCommit, capabilities, evidence) {
|
|
673
|
+
return {
|
|
674
|
+
...record,
|
|
675
|
+
delivery_id: state.id,
|
|
676
|
+
outcome: 'completed',
|
|
677
|
+
work_kind: 'delivery',
|
|
678
|
+
source_change: state.route.source_change || '',
|
|
679
|
+
source_commit: state.source_commit,
|
|
680
|
+
...deliveryBinding(state),
|
|
681
|
+
...(state.context_key ? { context_key: state.context_key } : {}),
|
|
682
|
+
target,
|
|
683
|
+
target_commit: targetCommit,
|
|
684
|
+
capabilities,
|
|
685
|
+
evidence: sanitizedEvidence(evidence),
|
|
686
|
+
finished_at: record.recorded_at,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
282
690
|
export function finishDelivery({
|
|
283
|
-
vaultBase,
|
|
691
|
+
vaultBase,
|
|
692
|
+
repoRoot,
|
|
693
|
+
id,
|
|
694
|
+
target = 'HEAD',
|
|
695
|
+
evidence = {},
|
|
696
|
+
context = null,
|
|
697
|
+
now = new Date(),
|
|
698
|
+
collectors = DEFAULT_PROVENANCE_COLLECTORS,
|
|
699
|
+
execute = execFileSync,
|
|
700
|
+
appendLedger = appendLedgerReceipt,
|
|
701
|
+
persistState = writeState,
|
|
702
|
+
clearContextDelivery = clearActiveContextDelivery,
|
|
284
703
|
}) {
|
|
285
|
-
ensureClean(repoRoot);
|
|
286
704
|
const state = readState(vaultBase, safeId(id));
|
|
287
|
-
const binding = context
|
|
705
|
+
const binding = context
|
|
706
|
+
? contextualBinding(vaultBase, context, state.state === 'active' ? state.id : '')
|
|
707
|
+
: null;
|
|
288
708
|
assertStateContext(state, binding);
|
|
709
|
+
assertStateCheckout(state, repoRoot, execute);
|
|
710
|
+
ensureClean(repoRoot);
|
|
711
|
+
if (state.state === 'completed') {
|
|
712
|
+
const receipt = completedReceipt(vaultBase, state);
|
|
713
|
+
if (context && binding.id === state.id) {
|
|
714
|
+
clearContextDelivery(vaultBase, context, { expectedRevision: binding.binding.revision });
|
|
715
|
+
}
|
|
716
|
+
if (!context && readPointer(vaultBase) === state.id) setPointer(vaultBase);
|
|
717
|
+
return receipt;
|
|
718
|
+
}
|
|
289
719
|
if (state.state !== 'active') throw new Error(`delivery ${id} não está ativa`);
|
|
290
|
-
|
|
291
|
-
git(repoRoot, ['merge-base', '--is-ancestor', state.source_commit, targetCommit]);
|
|
720
|
+
|
|
292
721
|
const capabilities = state.route?.operation_risk || [];
|
|
722
|
+
const needsRemoteTarget = capabilities.some((capability) => REMOTE_TARGET_CAPABILITIES.includes(capability));
|
|
723
|
+
let remoteTarget = null;
|
|
724
|
+
if (needsRemoteTarget) {
|
|
725
|
+
remoteTarget = remoteTargetObservation({ repoRoot, state, target, execute });
|
|
726
|
+
const remoteGate = evaluateProvenanceGate({
|
|
727
|
+
purpose: 'delivery', assessments: [observationAssessment('remote-target', remoteTarget)], requiredKinds: ['remote-target'],
|
|
728
|
+
});
|
|
729
|
+
if (!remoteGate.ok) throw provenanceBlocked(remoteGate);
|
|
730
|
+
}
|
|
731
|
+
const subject = collectors.collectGitSubject?.({
|
|
732
|
+
repoRoot,
|
|
733
|
+
sourceRef: state.source_commit,
|
|
734
|
+
targetRef: remoteTarget?.commit || target,
|
|
735
|
+
execute,
|
|
736
|
+
}) || { state: 'unproven', reasonCodes: ['PROVENANCE_GIT_SUBJECT_UNRESOLVED'] };
|
|
737
|
+
const targetCommit = String(subject.targetCommit || remoteTarget?.commit
|
|
738
|
+
|| gitWith(repoRoot, ['rev-parse', `${target}^{commit}`], execute, true));
|
|
739
|
+
const assessments = [
|
|
740
|
+
...(remoteTarget ? [observationAssessment('remote-target', remoteTarget)] : []),
|
|
741
|
+
observationAssessment('git-subject', subject),
|
|
742
|
+
];
|
|
743
|
+
let ancestor = false;
|
|
744
|
+
if (subject.state === 'verified' && subject.sourceCommit === state.source_commit && targetCommit) {
|
|
745
|
+
try {
|
|
746
|
+
gitWith(repoRoot, ['merge-base', '--is-ancestor', state.source_commit, targetCommit], execute);
|
|
747
|
+
ancestor = true;
|
|
748
|
+
} catch { /* represented by the assessment below */ }
|
|
749
|
+
}
|
|
750
|
+
assessments.push(ancestor
|
|
751
|
+
? verifiedAssessment('ancestry', [{ source_commit: state.source_commit, target_commit: targetCommit }])
|
|
752
|
+
: conflictAssessment('ancestry', 'PROVENANCE_SOURCE_NOT_ANCESTOR', [{ source_commit: state.source_commit, target_commit: targetCommit }]));
|
|
753
|
+
const observations = {
|
|
754
|
+
...(remoteTarget ? { remote_target: remoteTarget } : {}),
|
|
755
|
+
git_subject: subject,
|
|
756
|
+
ancestry: { state: ancestor ? 'verified' : 'conflict' },
|
|
757
|
+
};
|
|
758
|
+
const requiredKinds = [...(remoteTarget ? ['remote-target'] : []), 'git-subject', 'ancestry'];
|
|
759
|
+
let tagObservation = null;
|
|
760
|
+
let artifactObservation = null;
|
|
761
|
+
let ciObservation = null;
|
|
762
|
+
let npmObservation = null;
|
|
763
|
+
let releaseObservation = null;
|
|
293
764
|
if (capabilities.includes('git:tag') || capabilities.includes('publish')) {
|
|
294
765
|
if (!evidence.version) throw new Error('delivery com tag/publicação requer --version');
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
766
|
+
const packageMatches = subject.state === 'verified' && subject.package?.version === evidence.version;
|
|
767
|
+
assessments.push(packageMatches
|
|
768
|
+
? verifiedAssessment('package', [{ version: subject.package.version, target_commit: targetCommit }])
|
|
769
|
+
: conflictAssessment('package', 'PROVENANCE_VERSION_MISMATCH', [{ expected: evidence.version, observed: subject.package?.version || '' }]));
|
|
770
|
+
requiredKinds.push('package', 'tag');
|
|
771
|
+
tagObservation = collectors.collectTagObservation?.({
|
|
772
|
+
repoRoot,
|
|
773
|
+
tag: `v${evidence.version}`,
|
|
774
|
+
expectedCommit: targetCommit,
|
|
775
|
+
execute,
|
|
776
|
+
}) || { state: 'unproven', reasonCodes: ['PROVENANCE_TAG_UNRESOLVED'] };
|
|
777
|
+
assessments.push(observationAssessment('tag', tagObservation));
|
|
778
|
+
observations.tag = tagObservation;
|
|
299
779
|
}
|
|
300
780
|
if (capabilities.includes('publish')) {
|
|
301
781
|
for (const [key, label] of [
|
|
302
782
|
['ci_url', '--ci-url'], ['npm_integrity', '--npm-integrity'], ['release_url', '--release-url'],
|
|
303
783
|
]) if (!evidence[key]) throw new Error(`delivery com publish requer ${label}`);
|
|
784
|
+
const repositoryBinding = remoteBindingObservation({ repoRoot, state, execute });
|
|
785
|
+
assessments.push(observationAssessment('repository', repositoryBinding));
|
|
786
|
+
requiredKinds.push('repository');
|
|
787
|
+
observations.repository = repositoryBinding;
|
|
788
|
+
const repositoryGate = evaluateProvenanceGate({
|
|
789
|
+
purpose: 'delivery',
|
|
790
|
+
assessments: [observationAssessment('repository', repositoryBinding)],
|
|
791
|
+
requiredKinds: ['repository'],
|
|
792
|
+
});
|
|
793
|
+
if (!repositoryGate.ok) throw provenanceBlocked(repositoryGate);
|
|
794
|
+
const repository = repositoryBinding.repository || state.remote_repository || '';
|
|
795
|
+
artifactObservation = collectors.collectArtifactObservation?.({
|
|
796
|
+
repoRoot,
|
|
797
|
+
targetCommit,
|
|
798
|
+
execute,
|
|
799
|
+
}) || { state: 'unproven', reasonCodes: ['PROVENANCE_INTEGRITY_MISSING'] };
|
|
800
|
+
ciObservation = collectors.collectCiObservation?.({
|
|
801
|
+
locator: evidence.ci_url,
|
|
802
|
+
repository,
|
|
803
|
+
expectedCommit: targetCommit,
|
|
804
|
+
execute,
|
|
805
|
+
}) || { state: 'unproven', reasonCodes: ['PROVENANCE_SOURCE_UNAVAILABLE'] };
|
|
806
|
+
npmObservation = collectors.collectNpmObservation?.({
|
|
807
|
+
name: subject.package?.name,
|
|
808
|
+
version: subject.package?.version,
|
|
809
|
+
expectedIntegrity: artifactObservation.integrity,
|
|
810
|
+
expectedCommit: targetCommit,
|
|
811
|
+
repository,
|
|
812
|
+
execute,
|
|
813
|
+
}) || { state: 'unproven', reasonCodes: ['PROVENANCE_SOURCE_UNAVAILABLE'] };
|
|
814
|
+
releaseObservation = collectors.collectGitHubReleaseObservation?.({
|
|
815
|
+
repository,
|
|
816
|
+
tag: `v${evidence.version}`,
|
|
817
|
+
expectedCommit: targetCommit,
|
|
818
|
+
expectedVersion: evidence.version,
|
|
819
|
+
expectedNotes: subject.notes,
|
|
820
|
+
locator: evidence.release_url,
|
|
821
|
+
execute,
|
|
822
|
+
}) || { state: 'unproven', reasonCodes: ['PROVENANCE_SOURCE_UNAVAILABLE'] };
|
|
823
|
+
observations.artifact = artifactObservation;
|
|
824
|
+
observations.ci = ciObservation;
|
|
825
|
+
observations.npm = npmObservation;
|
|
826
|
+
observations.release = releaseObservation;
|
|
827
|
+
for (const [kind, observation] of [
|
|
828
|
+
['artifact', artifactObservation], ['ci', ciObservation], ['npm', npmObservation], ['release', releaseObservation],
|
|
829
|
+
]) assessments.push(observationAssessment(kind, observation));
|
|
830
|
+
const integrityClaimMatches = artifactObservation.state === 'verified'
|
|
831
|
+
&& evidence.npm_integrity === artifactObservation.integrity;
|
|
832
|
+
assessments.push(integrityClaimMatches
|
|
833
|
+
? verifiedAssessment('integrity-claim', [{ integrity: artifactObservation.integrity }])
|
|
834
|
+
: conflictAssessment('integrity-claim', 'PROVENANCE_INTEGRITY_MISMATCH', [{
|
|
835
|
+
expected: artifactObservation.integrity || '', observed: evidence.npm_integrity,
|
|
836
|
+
}]));
|
|
837
|
+
const releaseClaimMatches = releaseLocatorMatches(evidence.release_url, repository, `v${evidence.version}`);
|
|
838
|
+
assessments.push(releaseClaimMatches
|
|
839
|
+
? verifiedAssessment('release-locator')
|
|
840
|
+
: conflictAssessment('release-locator', 'PROVENANCE_REPOSITORY_MISMATCH'));
|
|
841
|
+
requiredKinds.push('artifact', 'ci', 'npm', 'release', 'integrity-claim', 'release-locator', 'release-chain');
|
|
842
|
+
const releaseParts = [tagObservation, artifactObservation, ciObservation, npmObservation, releaseObservation];
|
|
843
|
+
const releaseChain = releaseParts.every((item) => item?.state === 'verified')
|
|
844
|
+
? evaluateReleaseChain({
|
|
845
|
+
chain: {
|
|
846
|
+
commit: { sha: targetCommit },
|
|
847
|
+
tag: { name: tagObservation?.tag || tagObservation?.name || `v${evidence.version}`, commit: tagObservation?.commit },
|
|
848
|
+
package: { ...subject.package, commit: targetCommit },
|
|
849
|
+
artifact: artifactObservation,
|
|
850
|
+
npm: npmObservation,
|
|
851
|
+
ci: ciObservation,
|
|
852
|
+
release: releaseObservation,
|
|
853
|
+
},
|
|
854
|
+
context: {
|
|
855
|
+
repository,
|
|
856
|
+
target_commit: targetCommit,
|
|
857
|
+
package_name: subject.package?.name,
|
|
858
|
+
package_version: subject.package?.version,
|
|
859
|
+
tag: `v${evidence.version}`,
|
|
860
|
+
},
|
|
861
|
+
})
|
|
862
|
+
: {
|
|
863
|
+
kind: 'release-chain',
|
|
864
|
+
state: releaseParts.some((item) => item?.state === 'conflict')
|
|
865
|
+
? 'conflict'
|
|
866
|
+
: (releaseParts.some((item) => item?.state === 'reported') ? 'reported' : 'unproven'),
|
|
867
|
+
reasonCodes: [...new Set(releaseParts.flatMap((item) => item?.reasonCodes || []))],
|
|
868
|
+
diagnostics: releaseParts.flatMap((item) => item?.diagnostics || []),
|
|
869
|
+
};
|
|
870
|
+
assessments.push({ ...releaseChain, kind: 'release-chain' });
|
|
304
871
|
}
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
target,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
finished_at: now.toISOString(),
|
|
318
|
-
};
|
|
319
|
-
if (context) clearActiveContextDelivery(vaultBase, context, { expectedRevision: binding.binding.revision });
|
|
320
|
-
try {
|
|
321
|
-
appendReceipt(vaultBase, receipt);
|
|
322
|
-
writeState(vaultBase, { ...state, state: 'completed', target, target_commit: targetCommit, finished_at: receipt.finished_at, receipt });
|
|
323
|
-
} catch (error) {
|
|
324
|
-
if (context) {
|
|
325
|
-
try { setActiveContextDelivery(vaultBase, context, state.id); } catch { /* rollback best-effort */ }
|
|
326
|
-
}
|
|
327
|
-
throw error;
|
|
872
|
+
const gate = evaluateProvenanceGate({ purpose: 'delivery', assessments, requiredKinds });
|
|
873
|
+
if (!gate.ok) throw provenanceBlocked(gate);
|
|
874
|
+
const appended = appendLedger({
|
|
875
|
+
store: receiptStore(vaultBase),
|
|
876
|
+
draft: deliveryReceiptDraft({ state, target, targetCommit, capabilities, evidence, observations, now }),
|
|
877
|
+
});
|
|
878
|
+
const receipt = publicDeliveryReceipt(appended.record, state, target, targetCommit, capabilities, evidence);
|
|
879
|
+
persistState(vaultBase, {
|
|
880
|
+
...state, state: 'completed', target, target_commit: targetCommit, finished_at: receipt.finished_at, receipt,
|
|
881
|
+
});
|
|
882
|
+
if (context) {
|
|
883
|
+
clearContextDelivery(vaultBase, context, { expectedRevision: binding.binding.revision });
|
|
328
884
|
}
|
|
329
885
|
if (!context && readPointer(vaultBase) === state.id) setPointer(vaultBase);
|
|
330
886
|
return receipt;
|
|
331
887
|
}
|
|
332
888
|
|
|
333
|
-
|
|
889
|
+
function abandonedReceipt(vaultBase, state) {
|
|
890
|
+
const receipt = state.receipt;
|
|
891
|
+
if (!receipt || receipt.schema_version !== 2 || !receipt.reason_digest) {
|
|
892
|
+
const error = new Error('State abandoned não contém receipt v2 verificável.');
|
|
893
|
+
error.code = 'WENDKEEP_RECEIPT_LEDGER_CORRUPT';
|
|
894
|
+
throw error;
|
|
895
|
+
}
|
|
896
|
+
const ledger = readReceiptLedger({ store: receiptStore(vaultBase) });
|
|
897
|
+
const record = ledger.records.find((candidate) => candidate.receipt_id === receipt.receipt_id);
|
|
898
|
+
const binding = deliveryBinding(state);
|
|
899
|
+
const matches = record
|
|
900
|
+
&& record.receipt_hash === receipt.receipt_hash
|
|
901
|
+
&& record.sequence === receipt.sequence
|
|
902
|
+
&& record.kind === 'delivery.abandoned'
|
|
903
|
+
&& record.subject?.delivery_id === state.id
|
|
904
|
+
&& record.subject?.source_commit === state.source_commit
|
|
905
|
+
&& Object.entries(binding).every(([key, value]) => record.subject?.[key] === value && receipt[key] === value)
|
|
906
|
+
&& record.claims?.reason_digest === receipt.reason_digest
|
|
907
|
+
&& Object.keys(record).every((key) => isDeepStrictEqual(receipt[key], record[key]));
|
|
908
|
+
if (!matches) {
|
|
909
|
+
const error = new Error('Receipt de abandono diverge do ledger v2 verificado.');
|
|
910
|
+
error.code = 'WENDKEEP_RECEIPT_LEDGER_CORRUPT';
|
|
911
|
+
throw error;
|
|
912
|
+
}
|
|
913
|
+
return receipt;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
export function abandonDelivery({
|
|
917
|
+
vaultBase,
|
|
918
|
+
id,
|
|
919
|
+
reason,
|
|
920
|
+
context = null,
|
|
921
|
+
now = new Date(),
|
|
922
|
+
appendLedger = appendLedgerReceipt,
|
|
923
|
+
persistState = writeState,
|
|
924
|
+
clearContextDelivery = clearActiveContextDelivery,
|
|
925
|
+
}) {
|
|
334
926
|
const state = readState(vaultBase, safeId(id));
|
|
335
|
-
const binding = context
|
|
927
|
+
const binding = context
|
|
928
|
+
? contextualBinding(vaultBase, context, state.state === 'active' ? state.id : '')
|
|
929
|
+
: null;
|
|
336
930
|
assertStateContext(state, binding);
|
|
931
|
+
if (state.state === 'abandoned') {
|
|
932
|
+
const receipt = abandonedReceipt(vaultBase, state);
|
|
933
|
+
if (context && binding.id === state.id) {
|
|
934
|
+
clearContextDelivery(vaultBase, context, { expectedRevision: binding.binding.revision });
|
|
935
|
+
}
|
|
936
|
+
if (!context && readPointer(vaultBase) === state.id) setPointer(vaultBase);
|
|
937
|
+
return receipt;
|
|
938
|
+
}
|
|
337
939
|
if (state.state !== 'active') throw new Error(`delivery ${id} não está ativa`);
|
|
338
940
|
if (!String(reason || '').trim()) throw new Error('delivery abandon requer --reason <text>');
|
|
941
|
+
const reasonBinding = privateReason(reason);
|
|
942
|
+
const appended = appendLedger({
|
|
943
|
+
store: receiptStore(vaultBase),
|
|
944
|
+
draft: {
|
|
945
|
+
kind: 'delivery.abandoned',
|
|
946
|
+
subject: { delivery_id: state.id, source_commit: state.source_commit, ...deliveryBinding(state) },
|
|
947
|
+
claims: {
|
|
948
|
+
outcome: 'abandoned',
|
|
949
|
+
...reasonBinding,
|
|
950
|
+
...(state.context_key ? { context_key: state.context_key } : {}),
|
|
951
|
+
},
|
|
952
|
+
observations: { local_state: { state: 'verified', delivery_state: state.state } },
|
|
953
|
+
recorded_at: now.toISOString(),
|
|
954
|
+
},
|
|
955
|
+
});
|
|
339
956
|
const receipt = {
|
|
340
|
-
|
|
957
|
+
...appended.record,
|
|
958
|
+
delivery_id: state.id,
|
|
959
|
+
outcome: 'abandoned',
|
|
960
|
+
...deliveryBinding(state),
|
|
341
961
|
...(state.context_key ? { context_key: state.context_key } : {}),
|
|
342
|
-
|
|
962
|
+
...reasonBinding,
|
|
963
|
+
abandoned_at: now.toISOString(),
|
|
343
964
|
};
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (context) {
|
|
350
|
-
try { setActiveContextDelivery(vaultBase, context, state.id); } catch { /* rollback best-effort */ }
|
|
351
|
-
}
|
|
352
|
-
throw error;
|
|
965
|
+
persistState(vaultBase, {
|
|
966
|
+
...state, state: 'abandoned', ...reasonBinding, abandoned_at: receipt.abandoned_at, receipt,
|
|
967
|
+
});
|
|
968
|
+
if (context) {
|
|
969
|
+
clearContextDelivery(vaultBase, context, { expectedRevision: binding.binding.revision });
|
|
353
970
|
}
|
|
354
971
|
if (!context && readPointer(vaultBase) === state.id) setPointer(vaultBase);
|
|
355
972
|
return receipt;
|
|
@@ -360,10 +977,49 @@ function emit(payload, json) {
|
|
|
360
977
|
else process.stdout.write(`${payload.id || payload.delivery_id}: ${payload.state || payload.outcome}\n`);
|
|
361
978
|
}
|
|
362
979
|
|
|
980
|
+
function deliveryFailurePayload(error, operation) {
|
|
981
|
+
const provenance = error?.provenance || null;
|
|
982
|
+
const code = /^[A-Z0-9_]+$/.test(String(error?.code || ''))
|
|
983
|
+
? String(error.code)
|
|
984
|
+
: 'WENDKEEP_DELIVERY_FAILED';
|
|
985
|
+
const state = sanitizeDeliveryText(error?.state || provenance?.state || 'unproven', 40) || 'unproven';
|
|
986
|
+
const blocker = sanitizeDeliveryText(
|
|
987
|
+
error?.blocker || provenance?.reasonCodes?.[0] || code,
|
|
988
|
+
120,
|
|
989
|
+
) || code;
|
|
990
|
+
const recovery = sanitizeDeliveryText(
|
|
991
|
+
error?.recovery?.command || error?.recovery || provenance?.repair?.command
|
|
992
|
+
|| 'wendkeep delivery status --json',
|
|
993
|
+
);
|
|
994
|
+
return {
|
|
995
|
+
ok: false,
|
|
996
|
+
code,
|
|
997
|
+
error: code === 'WENDKEEP_PROVENANCE_GATE_BLOCKED'
|
|
998
|
+
? 'Delivery bloqueada pelo gate de proveniência.'
|
|
999
|
+
: (code === 'WENDKEEP_DELIVERY_GIT_FAILED'
|
|
1000
|
+
? 'Falha ao consultar o repositório Git para a delivery.'
|
|
1001
|
+
: 'Falha no comando delivery.'),
|
|
1002
|
+
operation: sanitizeDeliveryText(error?.operation || operation, 80) || 'delivery',
|
|
1003
|
+
state,
|
|
1004
|
+
blocker,
|
|
1005
|
+
expected: sanitizedDeliveryValue(error?.expected ?? provenance?.diagnostics?.[0]?.expected ?? null),
|
|
1006
|
+
observed: sanitizedDeliveryValue(error?.observed ?? provenance?.diagnostics?.[0]?.observed ?? null),
|
|
1007
|
+
recovery,
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function renderDeliveryFailure(error, operation, json) {
|
|
1012
|
+
const payload = deliveryFailurePayload(error, operation);
|
|
1013
|
+
if (json) return JSON.stringify(payload);
|
|
1014
|
+
return `${payload.code}: ${payload.error} operation=${payload.operation} state=${payload.state} blocker=${payload.blocker} expected=${JSON.stringify(payload.expected)} observed=${JSON.stringify(payload.observed)} recovery=${payload.recovery}`;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
363
1017
|
export function runDelivery(argv = []) {
|
|
1018
|
+
let parsed = null;
|
|
1019
|
+
let sub = 'status';
|
|
364
1020
|
try {
|
|
365
|
-
|
|
366
|
-
|
|
1021
|
+
parsed = parseArgv(argv);
|
|
1022
|
+
sub = parsed.positionals[0] || 'status';
|
|
367
1023
|
const { projectRoot, repoRoot, vaultBase } = context(parsed);
|
|
368
1024
|
const commandContext = resolveCommandActiveContext({
|
|
369
1025
|
vaultBase,
|
|
@@ -409,7 +1065,8 @@ export function runDelivery(argv = []) {
|
|
|
409
1065
|
}
|
|
410
1066
|
throw new Error(`subcomando desconhecido: ${sub}`);
|
|
411
1067
|
} catch (error) {
|
|
412
|
-
|
|
1068
|
+
const json = Boolean(parsed?.json || argv.includes('--json'));
|
|
1069
|
+
process.stderr.write(`${renderDeliveryFailure(error, `delivery.${sub}`, json)}\n`);
|
|
413
1070
|
return 2;
|
|
414
1071
|
}
|
|
415
1072
|
}
|