pumuki 6.3.26 → 6.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +3 -1
  2. package/bin/pumuki-mcp-enterprise-stdio.js +5 -0
  3. package/bin/pumuki-mcp-evidence-stdio.js +5 -0
  4. package/core/gate/conditionMatches.ts +1 -21
  5. package/core/gate/evaluateGate.js +5 -0
  6. package/core/gate/evaluateRules.js +5 -0
  7. package/core/gate/evaluateRules.ts +1 -24
  8. package/core/gate/scopeMatcher.ts +84 -0
  9. package/docs/EXECUTION_BOARD.md +749 -376
  10. package/docs/MCP_SERVERS.md +41 -2
  11. package/docs/README.md +6 -2
  12. package/docs/REFRACTOR_PROGRESS.md +374 -6
  13. package/docs/validation/README.md +11 -1
  14. package/docs/validation/p9-ruralgo-bug-registry.md +607 -0
  15. package/docs/validation/p9-ruralgo-fork-validation-tracking.md +904 -0
  16. package/docs/validation/real-repo-manual-e2e-ruralgo-fork.md +372 -0
  17. package/integrations/config/skillsCompliance.ts +212 -0
  18. package/integrations/evidence/integrity.ts +352 -0
  19. package/integrations/evidence/rulesCoverage.ts +94 -0
  20. package/integrations/evidence/schema.test.ts +16 -0
  21. package/integrations/evidence/schema.ts +41 -0
  22. package/integrations/evidence/writeEvidence.test.ts +68 -0
  23. package/integrations/evidence/writeEvidence.ts +23 -2
  24. package/integrations/gate/evaluateAiGate.ts +382 -15
  25. package/integrations/gate/stagePolicies.ts +70 -15
  26. package/integrations/gate/waivers.ts +209 -0
  27. package/integrations/git/findingTraceability.ts +3 -23
  28. package/integrations/git/index.js +5 -0
  29. package/integrations/git/runCliCommand.ts +16 -0
  30. package/integrations/git/runPlatformGate.ts +53 -1
  31. package/integrations/git/runPlatformGateEvaluation.ts +13 -0
  32. package/integrations/git/stageRunners.ts +168 -5
  33. package/integrations/lifecycle/adapter.templates.json +72 -5
  34. package/integrations/lifecycle/adapter.ts +78 -4
  35. package/integrations/lifecycle/cli.ts +384 -14
  36. package/integrations/lifecycle/doctor.ts +534 -0
  37. package/integrations/lifecycle/hookBlock.ts +2 -1
  38. package/integrations/lifecycle/index.js +5 -0
  39. package/integrations/lifecycle/install.ts +115 -3
  40. package/integrations/lifecycle/openSpecBootstrap.ts +68 -8
  41. package/integrations/lifecycle/preWriteAutomation.ts +142 -0
  42. package/integrations/mcp/aiGateCheck.ts +6 -0
  43. package/integrations/mcp/aiGateReceipt.ts +188 -0
  44. package/integrations/mcp/enterpriseServer.ts +14 -1
  45. package/integrations/mcp/enterpriseStdioServer.cli.ts +315 -0
  46. package/integrations/mcp/evidenceStdioServer.cli.ts +342 -0
  47. package/integrations/mcp/index.js +5 -0
  48. package/integrations/sdd/index.js +5 -0
  49. package/integrations/sdd/index.ts +2 -0
  50. package/integrations/sdd/policy.ts +191 -2
  51. package/integrations/sdd/sessionStore.ts +139 -19
  52. package/integrations/sdd/syncDocs.ts +180 -0
  53. package/integrations/sdd/types.ts +4 -1
  54. package/integrations/telemetry/structuredTelemetry.ts +197 -0
  55. package/package.json +27 -8
  56. package/scripts/build-p9-validation-manifests.ts +53 -0
  57. package/scripts/check-p9-ruralgo-baseline-clean.ts +200 -0
  58. package/scripts/check-p9-ruralgo-baseline-versioned.ts +198 -0
  59. package/scripts/check-p9-ruralgo-branch-ready.ts +215 -0
  60. package/scripts/check-p9-ruralgo-install-health.ts +288 -0
  61. package/scripts/check-p9-ruralgo-runtime-ready.ts +188 -0
  62. package/scripts/check-package-manifest.ts +49 -0
  63. package/scripts/check-tracking-single-active.sh +40 -0
  64. package/scripts/framework-menu-consumer-preflight-lib.ts +31 -0
  65. package/scripts/framework-menu-consumer-runtime-lib.ts +3 -3
  66. package/scripts/framework-menu-legacy-audit-lib.ts +35 -7
  67. package/scripts/framework-menu-matrix-evidence-lib.ts +6 -2
  68. package/scripts/manage-library.sh +1 -1
  69. package/scripts/p9-ruralgo-baseline-clean-lib.ts +117 -0
  70. package/scripts/p9-ruralgo-baseline-versioned-lib.ts +119 -0
  71. package/scripts/p9-ruralgo-branch-ready-lib.ts +128 -0
  72. package/scripts/p9-ruralgo-install-health-lib.ts +121 -0
  73. package/scripts/p9-ruralgo-runtime-ready-lib.ts +149 -0
  74. package/scripts/p9-validation-manifests-lib.ts +366 -0
  75. package/scripts/package-manifest-lib.ts +9 -0
  76. package/skills.lock.json +1 -1
@@ -0,0 +1,352 @@
1
+ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
2
+ import type { AiEvidenceV2_1, EvidenceIntegrity } from './schema';
3
+
4
+ const SHA256_HEX_PATTERN = /^[A-Fa-f0-9]{64}$/;
5
+ const INTEGRITY_SCHEMA = 'pumuki-evidence-integrity-v1' as const;
6
+ const HASH_ALGORITHM = 'SHA256' as const;
7
+ const SIGNATURE_ALGORITHM = 'HMAC-SHA256' as const;
8
+ const CANONICALIZATION = 'json-stable-v1' as const;
9
+ const GENESIS_PREVIOUS_CHAIN_HASH = 'GENESIS';
10
+
11
+ export type EvidenceSigningConfig = {
12
+ key: string;
13
+ keyId: string;
14
+ };
15
+
16
+ export type EvidenceIntegrityVerification = {
17
+ ok: boolean;
18
+ status: 'valid' | 'missing' | 'invalid';
19
+ code: string | null;
20
+ message: string | null;
21
+ payloadHash: string | null;
22
+ previousChainHash: string | null;
23
+ chainHash: string | null;
24
+ signature: {
25
+ present: boolean;
26
+ keyId: string | null;
27
+ verified: boolean | null;
28
+ };
29
+ };
30
+
31
+ const toCanonicalJsonValue = (value: unknown): unknown => {
32
+ if (Array.isArray(value)) {
33
+ return value.map((item) => toCanonicalJsonValue(item));
34
+ }
35
+ if (value && typeof value === 'object') {
36
+ const record = value as Record<string, unknown>;
37
+ const ordered: Record<string, unknown> = {};
38
+ for (const key of Object.keys(record).sort((left, right) => left.localeCompare(right))) {
39
+ ordered[key] = toCanonicalJsonValue(record[key]);
40
+ }
41
+ return ordered;
42
+ }
43
+ return value;
44
+ };
45
+
46
+ const toCanonicalJsonString = (value: unknown): string =>
47
+ JSON.stringify(toCanonicalJsonValue(value));
48
+
49
+ const toSha256Hex = (value: string): string =>
50
+ createHash('sha256').update(value).digest('hex');
51
+
52
+ const toIntegritySignatureValue = (params: {
53
+ chainHash: string;
54
+ payloadHash: string;
55
+ timestamp: string;
56
+ key: string;
57
+ }): string =>
58
+ createHmac('sha256', params.key)
59
+ .update(`${params.chainHash}:${params.payloadHash}:${params.timestamp}`)
60
+ .digest('hex');
61
+
62
+ const cloneWithoutIntegrity = (evidence: AiEvidenceV2_1): Omit<AiEvidenceV2_1, 'integrity'> => {
63
+ const { integrity: _integrity, ...withoutIntegrity } = evidence;
64
+ return withoutIntegrity;
65
+ };
66
+
67
+ const toSignedIntegrity = (
68
+ base: Omit<EvidenceIntegrity, 'signature'>,
69
+ signatureConfig: EvidenceSigningConfig | null,
70
+ timestamp: string
71
+ ): EvidenceIntegrity => {
72
+ if (!signatureConfig) {
73
+ return base;
74
+ }
75
+ return {
76
+ ...base,
77
+ signature: {
78
+ algorithm: SIGNATURE_ALGORITHM,
79
+ key_id: signatureConfig.keyId,
80
+ value: toIntegritySignatureValue({
81
+ chainHash: base.chain_hash,
82
+ payloadHash: base.payload_hash,
83
+ timestamp,
84
+ key: signatureConfig.key,
85
+ }),
86
+ },
87
+ };
88
+ };
89
+
90
+ export const resolveEvidenceSigningConfig = (
91
+ env: NodeJS.ProcessEnv = process.env
92
+ ): EvidenceSigningConfig | null => {
93
+ const key = env.PUMUKI_EVIDENCE_SIGNING_KEY?.trim();
94
+ if (!key) {
95
+ return null;
96
+ }
97
+ const keyId = env.PUMUKI_EVIDENCE_SIGNING_KEY_ID?.trim() || 'local';
98
+ return {
99
+ key,
100
+ keyId,
101
+ };
102
+ };
103
+
104
+ export const applyEvidenceIntegrity = (params: {
105
+ evidence: AiEvidenceV2_1;
106
+ previousChainHash?: string | null;
107
+ signatureConfig?: EvidenceSigningConfig | null;
108
+ }): AiEvidenceV2_1 => {
109
+ const withoutIntegrity = cloneWithoutIntegrity(params.evidence);
110
+ const payloadHash = toSha256Hex(toCanonicalJsonString(withoutIntegrity));
111
+ const previousChainHash = params.previousChainHash ?? null;
112
+ const chainHash = toSha256Hex(`${previousChainHash ?? GENESIS_PREVIOUS_CHAIN_HASH}:${payloadHash}`);
113
+ const integrity: Omit<EvidenceIntegrity, 'signature'> = {
114
+ schema: INTEGRITY_SCHEMA,
115
+ hash_algorithm: HASH_ALGORITHM,
116
+ canonicalization: CANONICALIZATION,
117
+ payload_hash: payloadHash,
118
+ previous_chain_hash: previousChainHash,
119
+ chain_hash: chainHash,
120
+ generated_at: withoutIntegrity.timestamp,
121
+ };
122
+
123
+ return {
124
+ ...withoutIntegrity,
125
+ integrity: toSignedIntegrity(integrity, params.signatureConfig ?? null, withoutIntegrity.timestamp),
126
+ };
127
+ };
128
+
129
+ const buildIntegrityInvalidResult = (params: {
130
+ code: string;
131
+ message: string;
132
+ integrity?: EvidenceIntegrity;
133
+ signaturePresent?: boolean;
134
+ signatureKeyId?: string | null;
135
+ signatureVerified?: boolean | null;
136
+ }): EvidenceIntegrityVerification => ({
137
+ ok: false,
138
+ status: 'invalid',
139
+ code: params.code,
140
+ message: params.message,
141
+ payloadHash: params.integrity?.payload_hash ?? null,
142
+ previousChainHash: params.integrity?.previous_chain_hash ?? null,
143
+ chainHash: params.integrity?.chain_hash ?? null,
144
+ signature: {
145
+ present: params.signaturePresent ?? false,
146
+ keyId: params.signatureKeyId ?? null,
147
+ verified: params.signatureVerified ?? null,
148
+ },
149
+ });
150
+
151
+ export const verifyEvidenceIntegrity = (
152
+ evidence: AiEvidenceV2_1,
153
+ options?: {
154
+ signatureConfig?: EvidenceSigningConfig | null;
155
+ enforceSignature?: boolean;
156
+ }
157
+ ): EvidenceIntegrityVerification => {
158
+ const integrity = evidence.integrity;
159
+ if (!integrity) {
160
+ return {
161
+ ok: false,
162
+ status: 'missing',
163
+ code: 'EVIDENCE_INTEGRITY_MISSING',
164
+ message: 'Evidence integrity metadata is missing.',
165
+ payloadHash: null,
166
+ previousChainHash: null,
167
+ chainHash: null,
168
+ signature: {
169
+ present: false,
170
+ keyId: null,
171
+ verified: null,
172
+ },
173
+ };
174
+ }
175
+
176
+ if (
177
+ integrity.schema !== INTEGRITY_SCHEMA ||
178
+ integrity.hash_algorithm !== HASH_ALGORITHM ||
179
+ integrity.canonicalization !== CANONICALIZATION
180
+ ) {
181
+ return buildIntegrityInvalidResult({
182
+ code: 'EVIDENCE_INTEGRITY_SCHEMA_INVALID',
183
+ message: 'Evidence integrity schema metadata is invalid.',
184
+ integrity,
185
+ });
186
+ }
187
+
188
+ if (
189
+ !SHA256_HEX_PATTERN.test(integrity.payload_hash) ||
190
+ !SHA256_HEX_PATTERN.test(integrity.chain_hash)
191
+ ) {
192
+ return buildIntegrityInvalidResult({
193
+ code: 'EVIDENCE_INTEGRITY_HASH_FORMAT_INVALID',
194
+ message: 'Evidence integrity hash format is invalid.',
195
+ integrity,
196
+ });
197
+ }
198
+
199
+ if (
200
+ integrity.previous_chain_hash !== null &&
201
+ !SHA256_HEX_PATTERN.test(integrity.previous_chain_hash)
202
+ ) {
203
+ return buildIntegrityInvalidResult({
204
+ code: 'EVIDENCE_INTEGRITY_PREVIOUS_CHAIN_HASH_INVALID',
205
+ message: 'Evidence integrity previous chain hash format is invalid.',
206
+ integrity,
207
+ });
208
+ }
209
+
210
+ if (integrity.generated_at !== evidence.timestamp) {
211
+ return buildIntegrityInvalidResult({
212
+ code: 'EVIDENCE_INTEGRITY_TIMESTAMP_MISMATCH',
213
+ message: 'Evidence integrity timestamp does not match evidence timestamp.',
214
+ integrity,
215
+ });
216
+ }
217
+
218
+ const withoutIntegrity = cloneWithoutIntegrity(evidence);
219
+ const expectedPayloadHash = toSha256Hex(toCanonicalJsonString(withoutIntegrity));
220
+ if (integrity.payload_hash !== expectedPayloadHash) {
221
+ return buildIntegrityInvalidResult({
222
+ code: 'EVIDENCE_INTEGRITY_PAYLOAD_HASH_MISMATCH',
223
+ message: 'Evidence integrity payload hash mismatch.',
224
+ integrity,
225
+ });
226
+ }
227
+
228
+ const expectedChainHash = toSha256Hex(
229
+ `${integrity.previous_chain_hash ?? GENESIS_PREVIOUS_CHAIN_HASH}:${expectedPayloadHash}`
230
+ );
231
+ if (integrity.chain_hash !== expectedChainHash) {
232
+ return buildIntegrityInvalidResult({
233
+ code: 'EVIDENCE_INTEGRITY_CHAIN_HASH_MISMATCH',
234
+ message: 'Evidence integrity chain hash mismatch.',
235
+ integrity,
236
+ });
237
+ }
238
+
239
+ const signature = integrity.signature;
240
+ const signatureConfig = options?.signatureConfig ?? null;
241
+ if (!signature) {
242
+ if (options?.enforceSignature && signatureConfig) {
243
+ return buildIntegrityInvalidResult({
244
+ code: 'EVIDENCE_INTEGRITY_SIGNATURE_REQUIRED',
245
+ message: 'Evidence integrity signature is required but missing.',
246
+ integrity,
247
+ });
248
+ }
249
+ return {
250
+ ok: true,
251
+ status: 'valid',
252
+ code: null,
253
+ message: null,
254
+ payloadHash: integrity.payload_hash,
255
+ previousChainHash: integrity.previous_chain_hash,
256
+ chainHash: integrity.chain_hash,
257
+ signature: {
258
+ present: false,
259
+ keyId: null,
260
+ verified: null,
261
+ },
262
+ };
263
+ }
264
+
265
+ if (
266
+ signature.algorithm !== SIGNATURE_ALGORITHM ||
267
+ !SHA256_HEX_PATTERN.test(signature.value) ||
268
+ typeof signature.key_id !== 'string' ||
269
+ signature.key_id.trim().length === 0
270
+ ) {
271
+ return buildIntegrityInvalidResult({
272
+ code: 'EVIDENCE_INTEGRITY_SIGNATURE_FORMAT_INVALID',
273
+ message: 'Evidence integrity signature metadata is invalid.',
274
+ integrity,
275
+ signaturePresent: true,
276
+ signatureKeyId: signature.key_id ?? null,
277
+ signatureVerified: false,
278
+ });
279
+ }
280
+
281
+ if (!signatureConfig) {
282
+ if (options?.enforceSignature) {
283
+ return buildIntegrityInvalidResult({
284
+ code: 'EVIDENCE_INTEGRITY_SIGNATURE_KEY_MISSING',
285
+ message: 'Evidence signature present but signing key is missing locally.',
286
+ integrity,
287
+ signaturePresent: true,
288
+ signatureKeyId: signature.key_id,
289
+ signatureVerified: null,
290
+ });
291
+ }
292
+ return {
293
+ ok: true,
294
+ status: 'valid',
295
+ code: null,
296
+ message: null,
297
+ payloadHash: integrity.payload_hash,
298
+ previousChainHash: integrity.previous_chain_hash,
299
+ chainHash: integrity.chain_hash,
300
+ signature: {
301
+ present: true,
302
+ keyId: signature.key_id,
303
+ verified: null,
304
+ },
305
+ };
306
+ }
307
+
308
+ if (signature.key_id !== signatureConfig.keyId) {
309
+ return buildIntegrityInvalidResult({
310
+ code: 'EVIDENCE_INTEGRITY_SIGNATURE_KEY_MISMATCH',
311
+ message: `Evidence signature key_id mismatch (${signature.key_id} != ${signatureConfig.keyId}).`,
312
+ integrity,
313
+ signaturePresent: true,
314
+ signatureKeyId: signature.key_id,
315
+ signatureVerified: false,
316
+ });
317
+ }
318
+
319
+ const expectedSignature = toIntegritySignatureValue({
320
+ chainHash: integrity.chain_hash,
321
+ payloadHash: integrity.payload_hash,
322
+ timestamp: evidence.timestamp,
323
+ key: signatureConfig.key,
324
+ });
325
+ const provided = Buffer.from(signature.value, 'hex');
326
+ const expected = Buffer.from(expectedSignature, 'hex');
327
+ if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
328
+ return buildIntegrityInvalidResult({
329
+ code: 'EVIDENCE_INTEGRITY_SIGNATURE_INVALID',
330
+ message: 'Evidence integrity signature mismatch.',
331
+ integrity,
332
+ signaturePresent: true,
333
+ signatureKeyId: signature.key_id,
334
+ signatureVerified: false,
335
+ });
336
+ }
337
+
338
+ return {
339
+ ok: true,
340
+ status: 'valid',
341
+ code: null,
342
+ message: null,
343
+ payloadHash: integrity.payload_hash,
344
+ previousChainHash: integrity.previous_chain_hash,
345
+ chainHash: integrity.chain_hash,
346
+ signature: {
347
+ present: true,
348
+ keyId: signature.key_id,
349
+ verified: true,
350
+ },
351
+ };
352
+ };
@@ -30,6 +30,95 @@ const createCoverageRatio = (active: number, evaluated: number): number => {
30
30
  return normalizeCoverageRatio(evaluated / active);
31
31
  };
32
32
 
33
+ const normalizeSkillsCompliance = (
34
+ value: SnapshotRulesCoverage['skills_compliance']
35
+ ): SnapshotRulesCoverage['skills_compliance'] => {
36
+ if (!value) {
37
+ return undefined;
38
+ }
39
+
40
+ const normalizedByFile = value.by_file
41
+ .map((item) => {
42
+ const requiredRuleIds = normalizeStringArray(item.required_rule_ids ?? []);
43
+ const appliedRuleIds = normalizeStringArray(item.applied_rule_ids ?? []);
44
+ const evidenceRuleIds = normalizeStringArray(item.evidence_rule_ids ?? []);
45
+ const missingRuleIds = normalizeStringArray(item.missing_rule_ids ?? []);
46
+ if (requiredRuleIds.length === 0) {
47
+ return undefined;
48
+ }
49
+ return {
50
+ file_path: item.file_path,
51
+ platform: item.platform,
52
+ required_rule_ids: requiredRuleIds,
53
+ applied_rule_ids: appliedRuleIds,
54
+ evidence_rule_ids: evidenceRuleIds,
55
+ missing_rule_ids: missingRuleIds,
56
+ status: missingRuleIds.length > 0 ? 'INCOMPLETE' : 'OK',
57
+ };
58
+ })
59
+ .filter(
60
+ (
61
+ item
62
+ ): item is {
63
+ file_path: string;
64
+ platform: 'ios' | 'android' | 'backend' | 'frontend';
65
+ required_rule_ids: string[];
66
+ applied_rule_ids: string[];
67
+ evidence_rule_ids: string[];
68
+ missing_rule_ids: string[];
69
+ status: 'OK' | 'INCOMPLETE';
70
+ } => item !== undefined
71
+ )
72
+ .sort((left, right) => left.file_path.localeCompare(right.file_path));
73
+
74
+ if (normalizedByFile.length === 0) {
75
+ return undefined;
76
+ }
77
+
78
+ const requiredRuleIds = normalizeStringArray(
79
+ value.required_rule_ids?.length > 0
80
+ ? value.required_rule_ids
81
+ : normalizedByFile.flatMap((item) => item.required_rule_ids)
82
+ );
83
+ const appliedRuleIds = normalizeStringArray(
84
+ value.applied_rule_ids?.length > 0
85
+ ? value.applied_rule_ids
86
+ : normalizedByFile.flatMap((item) => item.applied_rule_ids)
87
+ );
88
+ const evidenceRuleIds = normalizeStringArray(
89
+ value.evidence_rule_ids?.length > 0
90
+ ? value.evidence_rule_ids
91
+ : normalizedByFile.flatMap((item) => item.evidence_rule_ids)
92
+ );
93
+ const missingRuleIds = normalizeStringArray(
94
+ value.missing_rule_ids?.length > 0
95
+ ? value.missing_rule_ids
96
+ : normalizedByFile.flatMap((item) => item.missing_rule_ids)
97
+ );
98
+
99
+ return {
100
+ required_rule_ids: requiredRuleIds,
101
+ applied_rule_ids: appliedRuleIds,
102
+ evidence_rule_ids: evidenceRuleIds,
103
+ missing_rule_ids: missingRuleIds,
104
+ counts: {
105
+ files_in_scope: Math.max(
106
+ normalizedByFile.length,
107
+ normalizeCount(value.counts?.files_in_scope ?? 0)
108
+ ),
109
+ files_with_missing: Math.max(
110
+ normalizedByFile.filter((item) => item.missing_rule_ids.length > 0).length,
111
+ normalizeCount(value.counts?.files_with_missing ?? 0)
112
+ ),
113
+ required: Math.max(requiredRuleIds.length, normalizeCount(value.counts?.required ?? 0)),
114
+ applied: Math.max(appliedRuleIds.length, normalizeCount(value.counts?.applied ?? 0)),
115
+ evidence: Math.max(evidenceRuleIds.length, normalizeCount(value.counts?.evidence ?? 0)),
116
+ missing: Math.max(missingRuleIds.length, normalizeCount(value.counts?.missing ?? 0)),
117
+ },
118
+ by_file: normalizedByFile,
119
+ };
120
+ };
121
+
33
122
  export const createEmptySnapshotRulesCoverage = (
34
123
  stage: GateStage
35
124
  ): SnapshotRulesCoverage => ({
@@ -106,5 +195,10 @@ export const normalizeSnapshotRulesCoverage = (
106
195
  normalized.unsupported_auto_rule_ids = unsupportedAutoRuleIds;
107
196
  }
108
197
 
198
+ const skillsCompliance = normalizeSkillsCompliance(value.skills_compliance);
199
+ if (skillsCompliance) {
200
+ normalized.skills_compliance = skillsCompliance;
201
+ }
202
+
109
203
  return normalized;
110
204
  };
@@ -39,6 +39,20 @@ test('AiEvidenceV2_1 soporta snapshot/ledger/platforms/rulesets con contrato 2.1
39
39
  const evidence: AiEvidenceV2_1 = {
40
40
  version: '2.1',
41
41
  timestamp: '2026-02-17T09:00:00.000Z',
42
+ integrity: {
43
+ schema: 'pumuki-evidence-integrity-v1',
44
+ hash_algorithm: 'SHA256',
45
+ canonicalization: 'json-stable-v1',
46
+ payload_hash: 'a'.repeat(64),
47
+ previous_chain_hash: null,
48
+ chain_hash: 'b'.repeat(64),
49
+ generated_at: '2026-02-17T09:00:00.000Z',
50
+ signature: {
51
+ algorithm: 'HMAC-SHA256',
52
+ key_id: 'local',
53
+ value: 'c'.repeat(64),
54
+ },
55
+ },
42
56
  snapshot: {
43
57
  stage: 'PRE_PUSH',
44
58
  outcome: 'BLOCK',
@@ -133,6 +147,8 @@ test('AiEvidenceV2_1 soporta snapshot/ledger/platforms/rulesets con contrato 2.1
133
147
  };
134
148
 
135
149
  assert.equal(evidence.version, '2.1');
150
+ assert.equal(evidence.integrity?.schema, 'pumuki-evidence-integrity-v1');
151
+ assert.equal(evidence.integrity?.signature?.algorithm, 'HMAC-SHA256');
136
152
  assert.equal(evidence.snapshot.stage, 'PRE_PUSH');
137
153
  assert.equal(evidence.snapshot.files_scanned, 911);
138
154
  assert.equal(evidence.snapshot.files_affected, 1);
@@ -40,6 +40,29 @@ export type SnapshotRulesCoverage = {
40
40
  matched_rule_ids: string[];
41
41
  unevaluated_rule_ids: string[];
42
42
  unsupported_auto_rule_ids?: string[];
43
+ skills_compliance?: {
44
+ required_rule_ids: string[];
45
+ applied_rule_ids: string[];
46
+ evidence_rule_ids: string[];
47
+ missing_rule_ids: string[];
48
+ counts: {
49
+ files_in_scope: number;
50
+ files_with_missing: number;
51
+ required: number;
52
+ applied: number;
53
+ evidence: number;
54
+ missing: number;
55
+ };
56
+ by_file: Array<{
57
+ file_path: string;
58
+ platform: 'ios' | 'android' | 'backend' | 'frontend';
59
+ required_rule_ids: string[];
60
+ applied_rule_ids: string[];
61
+ evidence_rule_ids: string[];
62
+ missing_rule_ids: string[];
63
+ status: 'OK' | 'INCOMPLETE';
64
+ }>;
65
+ };
43
66
  counts: {
44
67
  active: number;
45
68
  evaluated: number;
@@ -88,6 +111,23 @@ export type RulesetState = {
88
111
  hash: string;
89
112
  };
90
113
 
114
+ export type EvidenceIntegritySignature = {
115
+ algorithm: 'HMAC-SHA256';
116
+ key_id: string;
117
+ value: string;
118
+ };
119
+
120
+ export type EvidenceIntegrity = {
121
+ schema: 'pumuki-evidence-integrity-v1';
122
+ hash_algorithm: 'SHA256';
123
+ canonicalization: 'json-stable-v1';
124
+ payload_hash: string;
125
+ previous_chain_hash: string | null;
126
+ chain_hash: string;
127
+ generated_at: string;
128
+ signature?: EvidenceIntegritySignature;
129
+ };
130
+
91
131
  export type HumanIntentConfidence = 'high' | 'medium' | 'low' | 'unset';
92
132
 
93
133
  export type HumanIntentState = {
@@ -170,6 +210,7 @@ export type RepoState = {
170
210
  export type AiEvidenceV2_1 = {
171
211
  version: '2.1';
172
212
  timestamp: string;
213
+ integrity?: EvidenceIntegrity;
173
214
  snapshot: Snapshot;
174
215
  ledger: LedgerEntry[];
175
216
  platforms: Record<string, PlatformState>;
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import test from 'node:test';
6
6
  import { withTempDir } from '../__tests__/helpers/tempDir';
7
7
  import type { AiEvidenceV2_1 } from './schema';
8
+ import { verifyEvidenceIntegrity } from './integrity';
8
9
  import { writeEvidence } from './writeEvidence';
9
10
 
10
11
  const withCwd = async <T>(cwd: string, callback: () => Promise<T> | T): Promise<T> => {
@@ -242,6 +243,12 @@ test('writeEvidence escribe archivo estable y normaliza paths/orden/lineas', asy
242
243
  });
243
244
  assert.equal(written.repo_state?.git.branch, 'feature/write-evidence');
244
245
  assert.equal(written.repo_state?.lifecycle.hooks.pre_commit, 'managed');
246
+ assert.equal(typeof written.integrity?.schema, 'string');
247
+ assert.match(written.integrity?.payload_hash ?? '', /^[A-Fa-f0-9]{64}$/);
248
+ assert.match(written.integrity?.chain_hash ?? '', /^[A-Fa-f0-9]{64}$/);
249
+ assert.equal(written.integrity?.previous_chain_hash, null);
250
+ const integrityCheck = verifyEvidenceIntegrity(written);
251
+ assert.equal(integrityCheck.ok, true);
245
252
  });
246
253
  });
247
254
  });
@@ -315,6 +322,67 @@ test('writeEvidence conserva paths externos y elimina lines no finitas', async (
315
322
  });
316
323
  });
317
324
 
325
+ test('writeEvidence encadena previous_chain_hash entre escrituras consecutivas', async () => {
326
+ await withTempDir('pumuki-write-evidence-chain-', async (tempRoot) => {
327
+ initGitRepo(tempRoot);
328
+ await withCwd(tempRoot, async () => {
329
+ const firstEvidence = sampleEvidence(tempRoot);
330
+ const firstWrite = writeEvidence(firstEvidence);
331
+ assert.equal(firstWrite.ok, true);
332
+ const firstWritten = JSON.parse(readFileSync(firstWrite.path, 'utf8')) as AiEvidenceV2_1;
333
+ const firstChainHash = firstWritten.integrity?.chain_hash ?? null;
334
+ assert.match(firstChainHash ?? '', /^[A-Fa-f0-9]{64}$/);
335
+
336
+ const secondEvidence = sampleEvidence(tempRoot);
337
+ secondEvidence.timestamp = '2026-02-17T00:10:00.000Z';
338
+ secondEvidence.snapshot.findings.push({
339
+ ruleId: 'b.rule',
340
+ severity: 'WARN',
341
+ code: 'B_RULE',
342
+ message: 'b finding',
343
+ file: 'apps/backend/B.ts',
344
+ });
345
+ secondEvidence.severity_metrics.total_violations = 3;
346
+ secondEvidence.severity_metrics.by_severity.WARN = 2;
347
+
348
+ const secondWrite = writeEvidence(secondEvidence);
349
+ assert.equal(secondWrite.ok, true);
350
+ const secondWritten = JSON.parse(readFileSync(secondWrite.path, 'utf8')) as AiEvidenceV2_1;
351
+ assert.equal(secondWritten.integrity?.previous_chain_hash, firstChainHash);
352
+ const secondIntegrityCheck = verifyEvidenceIntegrity(secondWritten);
353
+ assert.equal(secondIntegrityCheck.ok, true);
354
+ });
355
+ });
356
+ });
357
+
358
+ test('writeEvidence añade firma opcional cuando hay key de firmado en entorno', async () => {
359
+ await withTempDir('pumuki-write-evidence-signature-', async (tempRoot) => {
360
+ initGitRepo(tempRoot);
361
+ await withCwd(tempRoot, async () => {
362
+ const result = writeEvidence(sampleEvidence(tempRoot), {
363
+ env: {
364
+ ...process.env,
365
+ PUMUKI_EVIDENCE_SIGNING_KEY: 'secret-test-key',
366
+ PUMUKI_EVIDENCE_SIGNING_KEY_ID: 'test-key-id',
367
+ },
368
+ });
369
+ assert.equal(result.ok, true);
370
+ const written = JSON.parse(readFileSync(result.path, 'utf8')) as AiEvidenceV2_1;
371
+ assert.equal(written.integrity?.signature?.key_id, 'test-key-id');
372
+ assert.match(written.integrity?.signature?.value ?? '', /^[A-Fa-f0-9]{64}$/);
373
+ const integrityCheck = verifyEvidenceIntegrity(written, {
374
+ signatureConfig: {
375
+ key: 'secret-test-key',
376
+ keyId: 'test-key-id',
377
+ },
378
+ enforceSignature: false,
379
+ });
380
+ assert.equal(integrityCheck.ok, true);
381
+ assert.equal(integrityCheck.signature.verified, true);
382
+ });
383
+ });
384
+ });
385
+
318
386
  test('writeEvidence preserva snapshot.tdd_bdd cuando viene en evidencia', async () => {
319
387
  await withTempDir('pumuki-write-evidence-tdd-bdd-', async (tempRoot) => {
320
388
  initGitRepo(tempRoot);
@@ -14,6 +14,12 @@ import { buildSnapshotPlatformSummaries } from './platformSummary';
14
14
  import { normalizeHumanIntent } from './humanIntent';
15
15
  import { normalizeSnapshotEvaluationMetrics } from './evaluationMetrics';
16
16
  import { normalizeSnapshotRulesCoverage } from './rulesCoverage';
17
+ import { readEvidenceResult } from './readEvidence';
18
+ import {
19
+ applyEvidenceIntegrity,
20
+ resolveEvidenceSigningConfig,
21
+ verifyEvidenceIntegrity,
22
+ } from './integrity';
17
23
 
18
24
  export type WriteEvidenceResult = {
19
25
  ok: boolean;
@@ -343,14 +349,29 @@ const resolveRepoRoot = (): string => {
343
349
 
344
350
  export function writeEvidence(
345
351
  evidence: AiEvidenceV2_1,
346
- options?: { repoRoot?: string }
352
+ options?: { repoRoot?: string; env?: NodeJS.ProcessEnv }
347
353
  ): WriteEvidenceResult {
348
354
  const repoRoot = options?.repoRoot ?? resolveRepoRoot();
349
355
  const outputPath = join(repoRoot, EVIDENCE_FILE_NAME);
350
356
 
351
357
  try {
352
358
  const stableEvidence = toStableEvidence(evidence, repoRoot);
353
- writeFileSync(outputPath, `${JSON.stringify(stableEvidence, null, 2)}\n`, 'utf8');
359
+ const previousResult = readEvidenceResult(repoRoot);
360
+ const previousChainHash =
361
+ previousResult.kind === 'valid'
362
+ ? (() => {
363
+ const verification = verifyEvidenceIntegrity(previousResult.evidence, {
364
+ enforceSignature: false,
365
+ });
366
+ return verification.ok ? verification.chainHash : null;
367
+ })()
368
+ : null;
369
+ const signedEvidence = applyEvidenceIntegrity({
370
+ evidence: stableEvidence,
371
+ previousChainHash,
372
+ signatureConfig: resolveEvidenceSigningConfig(options?.env ?? process.env),
373
+ });
374
+ writeFileSync(outputPath, `${JSON.stringify(signedEvidence, null, 2)}\n`, 'utf8');
354
375
  return {
355
376
  ok: true,
356
377
  path: outputPath,