@phyzess/pappra 0.0.0-stage → 0.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/integrations/skills/pappra/SKILL.md +68 -0
- package/assets/integrations/skills/pappra/references/hosts.md +30 -0
- package/assets/integrations/skills/pappra/references/lifecycle.md +37 -0
- package/assets/integrations/skills/pappra/references/operations.md +14 -0
- package/assets/manifest.json +4 -0
- package/dist/app/collaboration.d.ts +170 -0
- package/dist/app/collaboration.js +1650 -0
- package/dist/app/doctor.d.ts +9 -0
- package/dist/app/doctor.js +1030 -0
- package/dist/app/init.d.ts +143 -0
- package/dist/app/init.js +1111 -0
- package/dist/cli/commands/code.d.ts +16 -0
- package/dist/cli/commands/code.js +127 -0
- package/dist/cli/commands/collaboration.d.ts +13 -0
- package/dist/cli/commands/collaboration.js +186 -0
- package/dist/cli/commands/continuity.d.ts +29 -0
- package/dist/cli/commands/continuity.js +133 -0
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +133 -0
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +57 -0
- package/dist/cli/commands/host.d.ts +13 -0
- package/dist/cli/commands/host.js +125 -0
- package/dist/cli/commands/init.d.ts +51 -0
- package/dist/cli/commands/init.js +668 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +10 -0
- package/dist/cli/output.d.ts +4 -0
- package/dist/cli/output.js +1 -0
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.js +597 -0
- package/dist/code/budget.d.ts +14 -0
- package/dist/code/budget.js +39 -0
- package/dist/code/exec.d.ts +23 -0
- package/dist/code/exec.js +133 -0
- package/dist/code/identity.d.ts +13 -0
- package/dist/code/identity.js +214 -0
- package/dist/code/index.d.ts +6 -0
- package/dist/code/index.js +3 -0
- package/dist/code/providers/codegraph.d.ts +59 -0
- package/dist/code/providers/codegraph.js +567 -0
- package/dist/code/registration.d.ts +9 -0
- package/dist/code/registration.js +293 -0
- package/dist/code/service.d.ts +53 -0
- package/dist/code/service.js +633 -0
- package/dist/code/text.d.ts +3 -0
- package/dist/code/text.js +202 -0
- package/dist/collab/admission.d.ts +450 -0
- package/dist/collab/admission.js +797 -0
- package/dist/collab/canonical.d.ts +26 -0
- package/dist/collab/canonical.js +136 -0
- package/dist/collab/contracts.d.ts +73 -0
- package/dist/collab/contracts.js +76 -0
- package/dist/collab/envelope.d.ts +52 -0
- package/dist/collab/envelope.js +241 -0
- package/dist/collab/fingerprint.d.ts +7 -0
- package/dist/collab/fingerprint.js +58 -0
- package/dist/collab/handoff.d.ts +350 -0
- package/dist/collab/handoff.js +513 -0
- package/dist/collab/index.d.ts +9 -0
- package/dist/collab/index.js +8 -0
- package/dist/collab/lifecycle.d.ts +449 -0
- package/dist/collab/lifecycle.js +534 -0
- package/dist/collab/messages.d.ts +148 -0
- package/dist/collab/messages.js +583 -0
- package/dist/collab/notify.d.ts +47 -0
- package/dist/collab/notify.js +140 -0
- package/dist/collab/preflight.d.ts +31 -0
- package/dist/collab/preflight.js +271 -0
- package/dist/collab/resources.d.ts +119 -0
- package/dist/collab/resources.js +451 -0
- package/dist/collab/store.d.ts +143 -0
- package/dist/collab/store.js +606 -0
- package/dist/collab/types.d.ts +354 -0
- package/dist/collab/types.js +1 -0
- package/dist/collab/validation.d.ts +10 -0
- package/dist/collab/validation.js +303 -0
- package/dist/continuity/context.d.ts +24 -0
- package/dist/continuity/context.js +179 -0
- package/dist/continuity/index.d.ts +3 -0
- package/dist/continuity/index.js +1 -0
- package/dist/continuity/paths.d.ts +37 -0
- package/dist/continuity/paths.js +258 -0
- package/dist/continuity/service.d.ts +59 -0
- package/dist/continuity/service.js +1258 -0
- package/dist/hosts/claude/native.d.ts +79 -0
- package/dist/hosts/claude/native.js +853 -0
- package/dist/hosts/codex/native.d.ts +345 -0
- package/dist/hosts/codex/native.js +1528 -0
- package/dist/hosts/codex/protocol.d.ts +51 -0
- package/dist/hosts/codex/protocol.js +42 -0
- package/dist/hosts/detect.d.ts +28 -0
- package/dist/hosts/detect.js +73 -0
- package/dist/hosts/index.d.ts +10 -0
- package/dist/hosts/index.js +10 -0
- package/dist/hosts/integration/bundle.d.ts +16 -0
- package/dist/hosts/integration/bundle.js +81 -0
- package/dist/hosts/integration/hook-entry.d.ts +1 -0
- package/dist/hosts/integration/hook-entry.js +68 -0
- package/dist/hosts/integration/hook.d.ts +32 -0
- package/dist/hosts/integration/hook.js +781 -0
- package/dist/hosts/integration/pi-extension-runtime.d.ts +18 -0
- package/dist/hosts/integration/pi-extension-runtime.js +89 -0
- package/dist/hosts/integration/pi-template.d.ts +3 -0
- package/dist/hosts/integration/pi-template.js +5 -0
- package/dist/hosts/integration/service.d.ts +41 -0
- package/dist/hosts/integration/service.js +1381 -0
- package/dist/hosts/integration/support.d.ts +36 -0
- package/dist/hosts/integration/support.js +41 -0
- package/dist/hosts/integration/templates.d.ts +22 -0
- package/dist/hosts/integration/templates.js +70 -0
- package/dist/hosts/pi/protocol.d.ts +22 -0
- package/dist/hosts/pi/protocol.js +28 -0
- package/dist/hosts/pi/query-result.d.ts +22 -0
- package/dist/hosts/pi/query-result.js +710 -0
- package/dist/hosts/pi/rpc.d.ts +937 -0
- package/dist/hosts/pi/rpc.js +1621 -0
- package/dist/hosts/pilot.d.ts +485 -0
- package/dist/hosts/pilot.js +593 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/infra/fs.d.ts +27 -0
- package/dist/infra/fs.js +130 -0
- package/dist/infra/git.d.ts +60 -0
- package/dist/infra/git.js +358 -0
- package/dist/infra/owned-process-group.d.ts +173 -0
- package/dist/infra/owned-process-group.js +571 -0
- package/dist/install/apply.d.ts +65 -0
- package/dist/install/apply.js +663 -0
- package/dist/install/index.d.ts +5 -0
- package/dist/install/index.js +5 -0
- package/dist/install/ownership.d.ts +85 -0
- package/dist/install/ownership.js +430 -0
- package/dist/install/plan.d.ts +39 -0
- package/dist/install/plan.js +97 -0
- package/dist/install/rollback.d.ts +18 -0
- package/dist/install/rollback.js +544 -0
- package/dist/install/runtime.d.ts +76 -0
- package/dist/install/runtime.js +1145 -0
- package/dist/knowledge/catalog.d.ts +47 -0
- package/dist/knowledge/catalog.js +308 -0
- package/dist/knowledge/context.d.ts +28 -0
- package/dist/knowledge/context.js +61 -0
- package/dist/knowledge/index.d.ts +3 -0
- package/dist/knowledge/index.js +1 -0
- package/dist/knowledge/journal.d.ts +16 -0
- package/dist/knowledge/journal.js +109 -0
- package/dist/knowledge/lifecycle.d.ts +8 -0
- package/dist/knowledge/lifecycle.js +1001 -0
- package/dist/knowledge/paths.d.ts +37 -0
- package/dist/knowledge/paths.js +236 -0
- package/dist/knowledge/refs.d.ts +36 -0
- package/dist/knowledge/refs.js +267 -0
- package/dist/knowledge/service.d.ts +21 -0
- package/dist/knowledge/service.js +393 -0
- package/dist/knowledge/types.d.ts +171 -0
- package/dist/knowledge/types.js +1 -0
- package/dist/knowledge/write.d.ts +8 -0
- package/dist/knowledge/write.js +1223 -0
- package/dist/package-info.d.ts +6 -0
- package/dist/package-info.js +19 -0
- package/dist/schemas/code.d.ts +377 -0
- package/dist/schemas/code.js +145 -0
- package/dist/schemas/collaboration.d.ts +115 -0
- package/dist/schemas/collaboration.js +106 -0
- package/dist/schemas/continuity.d.ts +315 -0
- package/dist/schemas/continuity.js +199 -0
- package/dist/schemas/diagnostics.d.ts +28 -0
- package/dist/schemas/diagnostics.js +27 -0
- package/dist/schemas/doctor.d.ts +157 -0
- package/dist/schemas/doctor.js +75 -0
- package/dist/schemas/document.d.ts +644 -0
- package/dist/schemas/document.js +296 -0
- package/dist/schemas/errors.d.ts +177 -0
- package/dist/schemas/errors.js +176 -0
- package/dist/schemas/evidence.d.ts +24 -0
- package/dist/schemas/evidence.js +16 -0
- package/dist/schemas/host.d.ts +436 -0
- package/dist/schemas/host.js +201 -0
- package/dist/schemas/index.d.ts +15 -0
- package/dist/schemas/index.js +15 -0
- package/dist/schemas/init.d.ts +121 -0
- package/dist/schemas/init.js +94 -0
- package/dist/schemas/install.d.ts +213 -0
- package/dist/schemas/install.js +139 -0
- package/dist/schemas/machine.d.ts +43 -0
- package/dist/schemas/machine.js +96 -0
- package/dist/schemas/outcome.d.ts +23 -0
- package/dist/schemas/outcome.js +17 -0
- package/dist/schemas/paths.d.ts +19 -0
- package/dist/schemas/paths.js +63 -0
- package/dist/schemas/workspace.d.ts +120 -0
- package/dist/schemas/workspace.js +201 -0
- package/dist/workspace/config-file.d.ts +36 -0
- package/dist/workspace/config-file.js +237 -0
- package/dist/workspace/config.d.ts +28 -0
- package/dist/workspace/config.js +79 -0
- package/dist/workspace/discover.d.ts +34 -0
- package/dist/workspace/discover.js +195 -0
- package/dist/workspace/documents.d.ts +21 -0
- package/dist/workspace/documents.js +395 -0
- package/dist/workspace/ignore.d.ts +10 -0
- package/dist/workspace/ignore.js +48 -0
- package/dist/workspace/index.d.ts +7 -0
- package/dist/workspace/index.js +3 -0
- package/dist/workspace/repositories.d.ts +24 -0
- package/dist/workspace/repositories.js +411 -0
- package/dist/workspace/resolve.d.ts +24 -0
- package/dist/workspace/resolve.js +268 -0
- package/dist/workspace/types.d.ts +71 -0
- package/dist/workspace/types.js +1 -0
- package/dist/workspace/validation.d.ts +18 -0
- package/dist/workspace/validation.js +197 -0
- package/package.json +105 -4
- package/skills/pappra/SKILL.md +68 -0
- package/skills/pappra/references/hosts.md +30 -0
- package/skills/pappra/references/lifecycle.md +37 -0
- package/skills/pappra/references/operations.md +14 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Resource, ResourceInput } from './types.js';
|
|
2
|
+
export declare const RESOURCE_KINDS: readonly string[];
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the longest existing ancestor with realpath and append the not-yet-existing
|
|
5
|
+
* suffix. Symlink aliases therefore collapse to one key. The suffix keeps the caller's
|
|
6
|
+
* spelling, so a differently-cased non-existent target is not normalised.
|
|
7
|
+
*/
|
|
8
|
+
export declare function canonicalizeTarget(input: string): {
|
|
9
|
+
path: string;
|
|
10
|
+
existing: boolean;
|
|
11
|
+
nearest_ancestor: string;
|
|
12
|
+
};
|
|
13
|
+
/** `same`, `ancestor`, `descendant` or `disjoint` on path-segment boundaries. */
|
|
14
|
+
export declare function pathRelation(left: string, right: string): "ancestor" | "descendant" | "disjoint" | "same";
|
|
15
|
+
export declare function pathsOverlap(left: string, right: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Build the canonical descriptor for one requested resource.
|
|
18
|
+
* Input: {kind?, path?, port?, value?}. Unknown kinds are rejected rather than guessed.
|
|
19
|
+
*/
|
|
20
|
+
export declare function describeResource(input: ResourceInput): Resource;
|
|
21
|
+
/**
|
|
22
|
+
* Overlap between two claims. Path-like kinds compare on canonical paths; opaque kinds
|
|
23
|
+
* (ports, processes) only on exact keys. `port:` and `path:` never overlap.
|
|
24
|
+
*/
|
|
25
|
+
export declare function claimsOverlap(left: Resource, right: Resource): boolean;
|
|
26
|
+
export declare function opaqueKey(prefix: string, value: unknown): string;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { errorCode } from './validation.js';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
// Resource identity and path canonicalisation. Two claims overlap only when their
|
|
6
|
+
// canonical paths are equal or one contains the other on a path-segment boundary,
|
|
7
|
+
// so `repo-a` and `repo-ab` are never treated as nested.
|
|
8
|
+
const PATH_KINDS = new Set([
|
|
9
|
+
'path',
|
|
10
|
+
'git-index',
|
|
11
|
+
'git-head',
|
|
12
|
+
'git-common-dir',
|
|
13
|
+
'build-cache',
|
|
14
|
+
'file',
|
|
15
|
+
]);
|
|
16
|
+
const OPAQUE_KINDS = new Set(['port', 'opaque', 'process']);
|
|
17
|
+
export const RESOURCE_KINDS = Object.freeze([...PATH_KINDS, ...OPAQUE_KINDS].sort());
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the longest existing ancestor with realpath and append the not-yet-existing
|
|
20
|
+
* suffix. Symlink aliases therefore collapse to one key. The suffix keeps the caller's
|
|
21
|
+
* spelling, so a differently-cased non-existent target is not normalised.
|
|
22
|
+
*/
|
|
23
|
+
export function canonicalizeTarget(input) {
|
|
24
|
+
if (typeof input !== 'string' || input.trim().length === 0)
|
|
25
|
+
throw new Error('Resource path must be a non-empty string');
|
|
26
|
+
if (!path.isAbsolute(input))
|
|
27
|
+
throw new Error('Resource path must be absolute');
|
|
28
|
+
const missing = [];
|
|
29
|
+
let probe = path.normalize(input);
|
|
30
|
+
for (;;) {
|
|
31
|
+
try {
|
|
32
|
+
const real = fs.realpathSync(probe);
|
|
33
|
+
return {
|
|
34
|
+
path: path.join(real, ...missing.slice().reverse()),
|
|
35
|
+
existing: missing.length === 0,
|
|
36
|
+
nearest_ancestor: real,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (errorCode(error) !== 'ENOENT' && errorCode(error) !== 'ENOTDIR')
|
|
41
|
+
throw new Error(`Cannot resolve resource path: ${errorCode(error)}`);
|
|
42
|
+
const parent = path.dirname(probe);
|
|
43
|
+
if (parent === probe)
|
|
44
|
+
throw new Error('Resource path has no existing ancestor');
|
|
45
|
+
missing.push(path.basename(probe));
|
|
46
|
+
probe = parent;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** `same`, `ancestor`, `descendant` or `disjoint` on path-segment boundaries. */
|
|
51
|
+
export function pathRelation(left, right) {
|
|
52
|
+
if (left === right)
|
|
53
|
+
return 'same';
|
|
54
|
+
const a = left.split(path.sep).filter(Boolean);
|
|
55
|
+
const b = right.split(path.sep).filter(Boolean);
|
|
56
|
+
const shared = Math.min(a.length, b.length);
|
|
57
|
+
for (let index = 0; index < shared; index += 1) {
|
|
58
|
+
if (a[index] !== b[index])
|
|
59
|
+
return 'disjoint';
|
|
60
|
+
}
|
|
61
|
+
return a.length < b.length ? 'ancestor' : 'descendant';
|
|
62
|
+
}
|
|
63
|
+
export function pathsOverlap(left, right) {
|
|
64
|
+
return pathRelation(left, right) !== 'disjoint';
|
|
65
|
+
}
|
|
66
|
+
function opaque(value) {
|
|
67
|
+
return `sha256:${crypto.createHash('sha256').update(String(value)).digest('hex')}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Build the canonical descriptor for one requested resource.
|
|
71
|
+
* Input: {kind?, path?, port?, value?}. Unknown kinds are rejected rather than guessed.
|
|
72
|
+
*/
|
|
73
|
+
export function describeResource(input) {
|
|
74
|
+
if (input === null || typeof input !== 'object' || Array.isArray(input))
|
|
75
|
+
throw new Error('Resource must be an object');
|
|
76
|
+
const kind = input.kind ?? 'path';
|
|
77
|
+
if (!RESOURCE_KINDS.includes(kind))
|
|
78
|
+
throw new Error(`Unsupported resource kind: ${kind}`);
|
|
79
|
+
if (OPAQUE_KINDS.has(kind)) {
|
|
80
|
+
if (kind === 'port') {
|
|
81
|
+
if (typeof input.port !== 'number' ||
|
|
82
|
+
!Number.isSafeInteger(input.port) ||
|
|
83
|
+
input.port <= 0 ||
|
|
84
|
+
input.port > 65535)
|
|
85
|
+
throw new Error('Port must be an integer between 1 and 65535');
|
|
86
|
+
return {
|
|
87
|
+
kind,
|
|
88
|
+
scope: 'opaque',
|
|
89
|
+
key: `port:${input.port}`,
|
|
90
|
+
port: input.port,
|
|
91
|
+
label: input.label ?? `port ${input.port}`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const value = input.value;
|
|
95
|
+
if (typeof value !== 'string' || value.trim().length === 0)
|
|
96
|
+
throw new Error('Opaque resources require a non-empty value');
|
|
97
|
+
return {
|
|
98
|
+
kind,
|
|
99
|
+
scope: 'opaque',
|
|
100
|
+
key: `${kind}:${value}`,
|
|
101
|
+
label: input.label ?? `${kind} ${value}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (typeof input.path !== 'string')
|
|
105
|
+
throw new Error('Resource path must be a non-empty string');
|
|
106
|
+
const resolved = canonicalizeTarget(input.path);
|
|
107
|
+
// A not-yet-existing target is treated conservatively as a tree: claiming it also
|
|
108
|
+
// reserves anything created underneath it.
|
|
109
|
+
const scope = input.scope === 'file' ? 'file' : 'tree';
|
|
110
|
+
return {
|
|
111
|
+
kind,
|
|
112
|
+
scope,
|
|
113
|
+
key: `${kind}:${resolved.path}`,
|
|
114
|
+
path: resolved.path,
|
|
115
|
+
existing: resolved.existing,
|
|
116
|
+
label: input.label ?? `${kind} ${resolved.path}`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Overlap between two claims. Path-like kinds compare on canonical paths; opaque kinds
|
|
121
|
+
* (ports, processes) only on exact keys. `port:` and `path:` never overlap.
|
|
122
|
+
*/
|
|
123
|
+
export function claimsOverlap(left, right) {
|
|
124
|
+
const leftOpaque = OPAQUE_KINDS.has(left.kind);
|
|
125
|
+
const rightOpaque = OPAQUE_KINDS.has(right.kind);
|
|
126
|
+
if (leftOpaque || rightOpaque)
|
|
127
|
+
return leftOpaque && rightOpaque && left.key === right.key;
|
|
128
|
+
if (!PATH_KINDS.has(left.kind) || !PATH_KINDS.has(right.kind))
|
|
129
|
+
return left.key === right.key;
|
|
130
|
+
if (typeof left.path !== 'string' || typeof right.path !== 'string')
|
|
131
|
+
throw new Error('Path resource requires a canonical path');
|
|
132
|
+
return pathsOverlap(left.path, right.path);
|
|
133
|
+
}
|
|
134
|
+
export function opaqueKey(prefix, value) {
|
|
135
|
+
return `${prefix}:${opaque(value)}`;
|
|
136
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Identity, ResourceInput } from './types.js';
|
|
2
|
+
export interface SkillRequirement {
|
|
3
|
+
id: string;
|
|
4
|
+
bundle_digest: string;
|
|
5
|
+
}
|
|
6
|
+
export interface Requirements {
|
|
7
|
+
skills: SkillRequirement[];
|
|
8
|
+
tools: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface Assignment {
|
|
11
|
+
schema: string;
|
|
12
|
+
task_id: string;
|
|
13
|
+
assignment_id: string;
|
|
14
|
+
objective: string;
|
|
15
|
+
attempt: number;
|
|
16
|
+
roles: string[];
|
|
17
|
+
acceptance: string[];
|
|
18
|
+
coordinator: Pick<Identity, 'agent_id' | 'session_id'> & {
|
|
19
|
+
owner_epoch: number;
|
|
20
|
+
harness?: string | null;
|
|
21
|
+
};
|
|
22
|
+
assignee: Identity & {
|
|
23
|
+
harness: string;
|
|
24
|
+
cwd: string;
|
|
25
|
+
surface: string;
|
|
26
|
+
configuration_id: string;
|
|
27
|
+
};
|
|
28
|
+
inputs: {
|
|
29
|
+
ref: string;
|
|
30
|
+
digest: string;
|
|
31
|
+
}[];
|
|
32
|
+
resources: (ResourceInput & {
|
|
33
|
+
path: string;
|
|
34
|
+
access: string;
|
|
35
|
+
resource_epoch?: number;
|
|
36
|
+
})[];
|
|
37
|
+
runner: {
|
|
38
|
+
name: string;
|
|
39
|
+
mode: string;
|
|
40
|
+
profile?: string;
|
|
41
|
+
omitted_capabilities: string[];
|
|
42
|
+
};
|
|
43
|
+
required: Requirements;
|
|
44
|
+
preferred: Requirements;
|
|
45
|
+
[key: string]: unknown;
|
|
46
|
+
}
|
|
47
|
+
export interface Capability {
|
|
48
|
+
id: string;
|
|
49
|
+
state: string;
|
|
50
|
+
evidence_ref?: string;
|
|
51
|
+
path?: string;
|
|
52
|
+
bundle_digest?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface CapabilityCard {
|
|
55
|
+
schema: string;
|
|
56
|
+
agent_id: string;
|
|
57
|
+
session_id: string;
|
|
58
|
+
harness: string;
|
|
59
|
+
surface: string;
|
|
60
|
+
configuration_id: string;
|
|
61
|
+
cwd: string;
|
|
62
|
+
observed_at: string;
|
|
63
|
+
evidence_level: string;
|
|
64
|
+
evidence_records?: {
|
|
65
|
+
ref: string;
|
|
66
|
+
digest: string;
|
|
67
|
+
}[];
|
|
68
|
+
skills: Capability[];
|
|
69
|
+
tools: Capability[];
|
|
70
|
+
[key: string]: unknown;
|
|
71
|
+
}
|
|
72
|
+
export declare function isAssignment(value: unknown): value is Assignment;
|
|
73
|
+
export declare function isCard(value: unknown): value is CapabilityCard;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { object } from './validation.js';
|
|
2
|
+
const string = (value) => typeof value === 'string';
|
|
3
|
+
const strings = (value) => Array.isArray(value) && value.every(string);
|
|
4
|
+
const number = (value) => typeof value === 'number' && Number.isSafeInteger(value);
|
|
5
|
+
function requirements(value) {
|
|
6
|
+
return (object(value) &&
|
|
7
|
+
strings(value['tools']) &&
|
|
8
|
+
Array.isArray(value['skills']) &&
|
|
9
|
+
value['skills'].every((item) => object(item) && string(item['id']) && string(item['bundle_digest'])));
|
|
10
|
+
}
|
|
11
|
+
const refs = (value) => Array.isArray(value) &&
|
|
12
|
+
value.every((item) => object(item) && string(item['ref']) && string(item['digest']));
|
|
13
|
+
export function isAssignment(value) {
|
|
14
|
+
if (!object(value) ||
|
|
15
|
+
!['schema', 'task_id', 'assignment_id', 'objective'].every((key) => string(value[key])))
|
|
16
|
+
return false;
|
|
17
|
+
const coordinator = value['coordinator'];
|
|
18
|
+
const assignee = value['assignee'];
|
|
19
|
+
const runner = value['runner'];
|
|
20
|
+
return (number(value['attempt']) &&
|
|
21
|
+
strings(value['roles']) &&
|
|
22
|
+
strings(value['acceptance']) &&
|
|
23
|
+
object(coordinator) &&
|
|
24
|
+
['agent_id', 'session_id'].every((key) => string(coordinator[key])) &&
|
|
25
|
+
number(coordinator['owner_epoch']) &&
|
|
26
|
+
object(assignee) &&
|
|
27
|
+
[
|
|
28
|
+
'agent_id',
|
|
29
|
+
'session_id',
|
|
30
|
+
'harness',
|
|
31
|
+
'cwd',
|
|
32
|
+
'surface',
|
|
33
|
+
'configuration_id',
|
|
34
|
+
].every((key) => string(assignee[key])) &&
|
|
35
|
+
refs(value['inputs']) &&
|
|
36
|
+
Array.isArray(value['resources']) &&
|
|
37
|
+
value['resources'].every((item) => object(item) &&
|
|
38
|
+
string(item['path']) &&
|
|
39
|
+
string(item['access']) &&
|
|
40
|
+
(item['kind'] === undefined || string(item['kind'])) &&
|
|
41
|
+
(item['port'] === undefined || number(item['port'])) &&
|
|
42
|
+
(item['resource_epoch'] === undefined ||
|
|
43
|
+
number(item['resource_epoch']))) &&
|
|
44
|
+
object(runner) &&
|
|
45
|
+
string(runner['name']) &&
|
|
46
|
+
string(runner['mode']) &&
|
|
47
|
+
strings(runner['omitted_capabilities']) &&
|
|
48
|
+
(runner['profile'] === undefined || string(runner['profile'])) &&
|
|
49
|
+
requirements(value['required']) &&
|
|
50
|
+
requirements(value['preferred']));
|
|
51
|
+
}
|
|
52
|
+
export function isCard(value) {
|
|
53
|
+
if (!object(value) ||
|
|
54
|
+
![
|
|
55
|
+
'schema',
|
|
56
|
+
'agent_id',
|
|
57
|
+
'session_id',
|
|
58
|
+
'harness',
|
|
59
|
+
'surface',
|
|
60
|
+
'configuration_id',
|
|
61
|
+
'cwd',
|
|
62
|
+
'observed_at',
|
|
63
|
+
'evidence_level',
|
|
64
|
+
].every((key) => string(value[key])))
|
|
65
|
+
return false;
|
|
66
|
+
return ((value['evidence_records'] === undefined ||
|
|
67
|
+
refs(value['evidence_records'])) &&
|
|
68
|
+
['skills', 'tools'].every((kind) => {
|
|
69
|
+
const entries = value[kind];
|
|
70
|
+
return (Array.isArray(entries) &&
|
|
71
|
+
entries.every((item) => object(item) &&
|
|
72
|
+
string(item['id']) &&
|
|
73
|
+
string(item['state']) &&
|
|
74
|
+
['evidence_ref', 'path', 'bundle_digest'].every((key) => item[key] === undefined || string(item[key]))));
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Message, MessageInput } from './types.js';
|
|
2
|
+
export declare const MESSAGE_SCHEMA = "harness-collab/message/1";
|
|
3
|
+
export declare const MESSAGE_TYPES: readonly string[];
|
|
4
|
+
export declare const REPORT_STATUSES: readonly string[];
|
|
5
|
+
export declare const BLOCK_KINDS: readonly string[];
|
|
6
|
+
export declare function validateEnvelope(message: unknown): Message;
|
|
7
|
+
/** Stable JSON with sorted object keys, used for content-identity digests. */
|
|
8
|
+
export declare function canonicalJson(value: unknown): string;
|
|
9
|
+
/**
|
|
10
|
+
* Content identity of a message: everything that decides what the message means.
|
|
11
|
+
* `created_at` and `content_digest` are excluded so an honest resend is still the
|
|
12
|
+
* same message.
|
|
13
|
+
*/
|
|
14
|
+
export declare function contentDigest(message: Message): string;
|
|
15
|
+
export declare function buildMessage(input: MessageInput, { clock }?: {
|
|
16
|
+
clock?: number | undefined;
|
|
17
|
+
}): Message;
|
|
18
|
+
export declare function messageFile(taskRoot: string, messageId: string): string;
|
|
19
|
+
export declare function readRegisteredMessage(taskRoot: string, messageId: string): Message | null;
|
|
20
|
+
/**
|
|
21
|
+
* Register a message exactly once. Returns:
|
|
22
|
+
* registered first registration; the caller may now apply it
|
|
23
|
+
* duplicate same id and same content; no new side effect
|
|
24
|
+
* content_conflict same id, different content; rejected
|
|
25
|
+
*/
|
|
26
|
+
export declare function registerMessage(taskRoot: string, message: Message, { clock }?: {
|
|
27
|
+
clock?: number | undefined;
|
|
28
|
+
}): {
|
|
29
|
+
status: string;
|
|
30
|
+
message_id: string;
|
|
31
|
+
stored_digest: string;
|
|
32
|
+
actual_digest: string;
|
|
33
|
+
content_digest?: never;
|
|
34
|
+
file?: never;
|
|
35
|
+
} | {
|
|
36
|
+
status: string;
|
|
37
|
+
message_id: string;
|
|
38
|
+
content_digest: string;
|
|
39
|
+
file: string;
|
|
40
|
+
stored_digest?: never;
|
|
41
|
+
actual_digest?: never;
|
|
42
|
+
} | {
|
|
43
|
+
content_digest?: never;
|
|
44
|
+
status: string;
|
|
45
|
+
message_id: string;
|
|
46
|
+
stored_digest: string | null;
|
|
47
|
+
actual_digest: string;
|
|
48
|
+
file: string;
|
|
49
|
+
};
|
|
50
|
+
export declare function listRegisteredMessages(taskRoot: string): string[];
|
|
51
|
+
export declare function sha256File(file: string): string;
|
|
52
|
+
export declare function uniqueNonce(): string;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { errorCode, parseJson, object, text, isMessage } from './validation.js';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createNonce, digestText, messagesDir, nowIso } from './store.js';
|
|
5
|
+
// Message envelopes and their idempotency rules.
|
|
6
|
+
//
|
|
7
|
+
// Registration is an exclusive create of `messages/<message_id>.json`, which is atomic,
|
|
8
|
+
// so two senders racing with the same id cannot both create it. Applying a registered
|
|
9
|
+
// message to the record is a separate transaction: a crash between the two steps leaves
|
|
10
|
+
// a `message_registered_not_applied` finding instead of a silent half-effect. The two
|
|
11
|
+
// steps are never presented as one cross-system transaction.
|
|
12
|
+
export const MESSAGE_SCHEMA = 'harness-collab/message/1';
|
|
13
|
+
export const MESSAGE_TYPES = Object.freeze([
|
|
14
|
+
'ASSIGN',
|
|
15
|
+
'ACK',
|
|
16
|
+
'ASK',
|
|
17
|
+
'ANSWER',
|
|
18
|
+
'REPORT',
|
|
19
|
+
'STATUS_QUERY',
|
|
20
|
+
'STATUS',
|
|
21
|
+
'HANDOFF',
|
|
22
|
+
'BLOCK',
|
|
23
|
+
'CANCEL',
|
|
24
|
+
'CANCELLED',
|
|
25
|
+
]);
|
|
26
|
+
export const REPORT_STATUSES = Object.freeze([
|
|
27
|
+
'running',
|
|
28
|
+
'completed',
|
|
29
|
+
'needs_input',
|
|
30
|
+
'failed',
|
|
31
|
+
'cancelled',
|
|
32
|
+
'unknown',
|
|
33
|
+
]);
|
|
34
|
+
export const BLOCK_KINDS = Object.freeze(['clarification', 'approval']);
|
|
35
|
+
const REQUIRE_IDENTITY = ['agent_id', 'session_id', 'harness'];
|
|
36
|
+
const TYPE_RULES = {
|
|
37
|
+
ASSIGN: {
|
|
38
|
+
to: true,
|
|
39
|
+
subtask: true,
|
|
40
|
+
attempt: true,
|
|
41
|
+
body: ['assignment_digest'],
|
|
42
|
+
},
|
|
43
|
+
ACK: { to: true, reply_to: true },
|
|
44
|
+
ASK: { to: true },
|
|
45
|
+
ANSWER: { to: true, reply_to: true },
|
|
46
|
+
REPORT: { to: true, subtask: true, attempt: true, body: ['status'] },
|
|
47
|
+
STATUS_QUERY: { to: true },
|
|
48
|
+
STATUS: { to: true, reply_to: true, body: ['status'] },
|
|
49
|
+
HANDOFF: { to: true, subtask: true, body: ['handoff_id', 'phase'] },
|
|
50
|
+
BLOCK: { to: true, body: ['kind'] },
|
|
51
|
+
CANCEL: { to: true, subtask: true, attempt: true },
|
|
52
|
+
CANCELLED: { to: true, reply_to: true, subtask: true, attempt: true },
|
|
53
|
+
};
|
|
54
|
+
function assert(condition, field) {
|
|
55
|
+
if (!condition)
|
|
56
|
+
throw new Error(`Invalid message field: ${field}`);
|
|
57
|
+
}
|
|
58
|
+
function identity(value, field) {
|
|
59
|
+
assert(object(value), field);
|
|
60
|
+
for (const key of REQUIRE_IDENTITY)
|
|
61
|
+
assert(text(value[key]), `${field}.${key}`);
|
|
62
|
+
}
|
|
63
|
+
export function validateEnvelope(message) {
|
|
64
|
+
assert(isMessage(message), 'message');
|
|
65
|
+
assert(message.schema === MESSAGE_SCHEMA, 'message.schema');
|
|
66
|
+
assert(text(message.message_id) && message.message_id.length <= 128, 'message.message_id');
|
|
67
|
+
assert(MESSAGE_TYPES.includes(message.type), 'message.type');
|
|
68
|
+
assert(text(message.task_id), 'message.task_id');
|
|
69
|
+
identity(message.from, 'message.from');
|
|
70
|
+
const rule = TYPE_RULES[message.type];
|
|
71
|
+
assert(rule, 'message.type');
|
|
72
|
+
if (rule.to)
|
|
73
|
+
identity(message.to, 'message.to');
|
|
74
|
+
if (rule.reply_to)
|
|
75
|
+
assert(text(message.reply_to), 'message.reply_to');
|
|
76
|
+
if (rule.subtask)
|
|
77
|
+
assert(text(message.subtask_id), 'message.subtask_id');
|
|
78
|
+
if (rule.attempt)
|
|
79
|
+
assert(Number.isSafeInteger(message.attempt) && (message.attempt ?? 0) > 0, 'message.attempt');
|
|
80
|
+
if (message.owner_epoch !== undefined && message.owner_epoch !== null) {
|
|
81
|
+
assert(Number.isSafeInteger(message.owner_epoch) && message.owner_epoch > 0, 'message.owner_epoch');
|
|
82
|
+
}
|
|
83
|
+
if (message.body !== undefined)
|
|
84
|
+
assert(object(message.body), 'message.body');
|
|
85
|
+
for (const key of rule.body ?? [])
|
|
86
|
+
assert(message.body !== undefined && message.body[key] !== undefined, `message.body.${key}`);
|
|
87
|
+
if (message.body?.status !== undefined)
|
|
88
|
+
assert(REPORT_STATUSES.includes(message.body.status), 'message.body.status');
|
|
89
|
+
// `body.kind` is scoped per message type; the same field name means different things.
|
|
90
|
+
if (message.type === 'BLOCK' && message.body?.kind !== undefined)
|
|
91
|
+
assert(BLOCK_KINDS.includes(message.body.kind), 'message.body.kind');
|
|
92
|
+
if (message.type === 'HANDOFF' && message.body?.kind !== undefined)
|
|
93
|
+
assert(['writer', 'coordinator'].includes(message.body.kind), 'message.body.kind');
|
|
94
|
+
if (message.body?.approval !== undefined) {
|
|
95
|
+
assert(object(message.body.approval) &&
|
|
96
|
+
text(message.body.approval.approved_by) &&
|
|
97
|
+
text(message.body.approval.reason), 'message.body.approval');
|
|
98
|
+
}
|
|
99
|
+
// Unknown extension fields are tolerated on purpose; unknown semantics are not inferred.
|
|
100
|
+
return message;
|
|
101
|
+
}
|
|
102
|
+
/** Stable JSON with sorted object keys, used for content-identity digests. */
|
|
103
|
+
export function canonicalJson(value) {
|
|
104
|
+
if (value === null || typeof value !== 'object')
|
|
105
|
+
return JSON.stringify(value ?? null);
|
|
106
|
+
if (Array.isArray(value))
|
|
107
|
+
return `[${value.map((item) => canonicalJson(item)).join(',')}]`;
|
|
108
|
+
if (!object(value))
|
|
109
|
+
throw new Error('Canonical JSON requires an object');
|
|
110
|
+
const keys = Object.keys(value).sort();
|
|
111
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Content identity of a message: everything that decides what the message means.
|
|
115
|
+
* `created_at` and `content_digest` are excluded so an honest resend is still the
|
|
116
|
+
* same message.
|
|
117
|
+
*/
|
|
118
|
+
export function contentDigest(message) {
|
|
119
|
+
const content = {
|
|
120
|
+
type: message.type,
|
|
121
|
+
task_id: message.task_id,
|
|
122
|
+
subtask_id: message.subtask_id ?? null,
|
|
123
|
+
attempt: message.attempt ?? null,
|
|
124
|
+
from: message.from,
|
|
125
|
+
to: message.to ?? null,
|
|
126
|
+
reply_to: message.reply_to ?? null,
|
|
127
|
+
owner_epoch: message.owner_epoch ?? null,
|
|
128
|
+
body: message.body ?? null,
|
|
129
|
+
};
|
|
130
|
+
return digestText(canonicalJson(content));
|
|
131
|
+
}
|
|
132
|
+
export function buildMessage(input, { clock = Date.now() } = {}) {
|
|
133
|
+
const message = {
|
|
134
|
+
schema: MESSAGE_SCHEMA,
|
|
135
|
+
message_id: input.message_id ?? `msg-${createNonce()}`,
|
|
136
|
+
type: input.type,
|
|
137
|
+
task_id: input.task_id,
|
|
138
|
+
from: input.from,
|
|
139
|
+
to: input.to,
|
|
140
|
+
reply_to: input.reply_to ?? null,
|
|
141
|
+
subtask_id: input.subtask_id ?? null,
|
|
142
|
+
attempt: input.attempt ?? null,
|
|
143
|
+
owner_epoch: input.owner_epoch ?? null,
|
|
144
|
+
created_at: input.created_at ?? nowIso(clock),
|
|
145
|
+
content_digest: null,
|
|
146
|
+
body: input.body ?? {},
|
|
147
|
+
};
|
|
148
|
+
validateEnvelope(message);
|
|
149
|
+
message.content_digest = contentDigest(message);
|
|
150
|
+
return message;
|
|
151
|
+
}
|
|
152
|
+
export function messageFile(taskRoot, messageId) {
|
|
153
|
+
if (/[\\/\0]/.test(messageId))
|
|
154
|
+
throw new Error('Invalid message field: message.message_id');
|
|
155
|
+
return path.join(messagesDir(taskRoot), `${messageId}.json`);
|
|
156
|
+
}
|
|
157
|
+
export function readRegisteredMessage(taskRoot, messageId) {
|
|
158
|
+
const file = messageFile(taskRoot, messageId);
|
|
159
|
+
try {
|
|
160
|
+
return validateEnvelope(parseJson(fs.readFileSync(file, 'utf8')));
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Register a message exactly once. Returns:
|
|
168
|
+
* registered first registration; the caller may now apply it
|
|
169
|
+
* duplicate same id and same content; no new side effect
|
|
170
|
+
* content_conflict same id, different content; rejected
|
|
171
|
+
*/
|
|
172
|
+
export function registerMessage(taskRoot, message, { clock = Date.now() } = {}) {
|
|
173
|
+
validateEnvelope(message);
|
|
174
|
+
const digest = contentDigest(message);
|
|
175
|
+
if (message.content_digest && message.content_digest !== digest) {
|
|
176
|
+
return {
|
|
177
|
+
status: 'content_conflict',
|
|
178
|
+
message_id: message.message_id,
|
|
179
|
+
stored_digest: message.content_digest,
|
|
180
|
+
actual_digest: digest,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
fs.mkdirSync(messagesDir(taskRoot), { recursive: true, mode: 0o700 });
|
|
184
|
+
const stored = {
|
|
185
|
+
...message,
|
|
186
|
+
content_digest: digest,
|
|
187
|
+
created_at: message.created_at ?? nowIso(clock),
|
|
188
|
+
};
|
|
189
|
+
const file = messageFile(taskRoot, message.message_id);
|
|
190
|
+
try {
|
|
191
|
+
fs.writeFileSync(file, `${JSON.stringify(stored, null, 2)}\n`, {
|
|
192
|
+
flag: 'wx',
|
|
193
|
+
mode: 0o600,
|
|
194
|
+
});
|
|
195
|
+
return {
|
|
196
|
+
status: 'registered',
|
|
197
|
+
message_id: message.message_id,
|
|
198
|
+
content_digest: digest,
|
|
199
|
+
file,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (errorCode(error) !== 'EEXIST')
|
|
204
|
+
throw error;
|
|
205
|
+
const existing = readRegisteredMessage(taskRoot, message.message_id);
|
|
206
|
+
const existingDigest = existing ? contentDigest(existing) : null;
|
|
207
|
+
if (existing && existingDigest === digest) {
|
|
208
|
+
return {
|
|
209
|
+
status: 'duplicate',
|
|
210
|
+
message_id: message.message_id,
|
|
211
|
+
content_digest: digest,
|
|
212
|
+
file,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
status: 'content_conflict',
|
|
217
|
+
message_id: message.message_id,
|
|
218
|
+
stored_digest: existingDigest,
|
|
219
|
+
actual_digest: digest,
|
|
220
|
+
file,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
export function listRegisteredMessages(taskRoot) {
|
|
225
|
+
try {
|
|
226
|
+
return fs
|
|
227
|
+
.readdirSync(messagesDir(taskRoot))
|
|
228
|
+
.filter((name) => name.endsWith('.json'))
|
|
229
|
+
.map((name) => name.slice(0, -'.json'.length))
|
|
230
|
+
.sort();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export function sha256File(file) {
|
|
237
|
+
return digestText(fs.readFileSync(file));
|
|
238
|
+
}
|
|
239
|
+
export function uniqueNonce() {
|
|
240
|
+
return createNonce();
|
|
241
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const sha256 = (value) => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
5
|
+
/** Fingerprint one explicitly selected skill, including its supporting files. */
|
|
6
|
+
export function fingerprintSkill(input) {
|
|
7
|
+
if (!path.isAbsolute(input))
|
|
8
|
+
throw new Error('Skill path must be absolute');
|
|
9
|
+
const resolved = fs.realpathSync(input);
|
|
10
|
+
const root = fs.statSync(resolved).isDirectory()
|
|
11
|
+
? resolved
|
|
12
|
+
: path.dirname(resolved);
|
|
13
|
+
if (!fs.statSync(resolved).isDirectory() &&
|
|
14
|
+
path.basename(resolved) !== 'SKILL.md') {
|
|
15
|
+
throw new Error('Select a skill directory or SKILL.md');
|
|
16
|
+
}
|
|
17
|
+
const entry = path.join(root, 'SKILL.md');
|
|
18
|
+
const files = [];
|
|
19
|
+
let totalBytes = 0;
|
|
20
|
+
let directoryCount = 0;
|
|
21
|
+
function visit(directory) {
|
|
22
|
+
if (++directoryCount > 1000)
|
|
23
|
+
throw new Error('Skill exceeds the directory limit');
|
|
24
|
+
for (const name of fs.readdirSync(directory).sort()) {
|
|
25
|
+
const file = path.join(directory, name);
|
|
26
|
+
const stat = fs.lstatSync(file);
|
|
27
|
+
if (stat.isSymbolicLink())
|
|
28
|
+
throw new Error('Supporting symlinks require an explicitly packaged skill');
|
|
29
|
+
if (stat.isDirectory()) {
|
|
30
|
+
visit(file);
|
|
31
|
+
}
|
|
32
|
+
else if (stat.isFile()) {
|
|
33
|
+
totalBytes += stat.size;
|
|
34
|
+
if (files.length >= 1000 || totalBytes > 16 * 1024 * 1024) {
|
|
35
|
+
throw new Error('Skill exceeds the fingerprint size limit');
|
|
36
|
+
}
|
|
37
|
+
files.push([
|
|
38
|
+
path.relative(root, file).split(path.sep).join('/'),
|
|
39
|
+
sha256(fs.readFileSync(file)),
|
|
40
|
+
]);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
throw new Error('Skill contains an unsupported file type');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
visit(root);
|
|
48
|
+
files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
49
|
+
const entryRow = files.find(([name]) => name === 'SKILL.md');
|
|
50
|
+
if (!entryRow)
|
|
51
|
+
throw new Error('Skill entry is missing');
|
|
52
|
+
return {
|
|
53
|
+
path: entry,
|
|
54
|
+
entry_digest: entryRow[1],
|
|
55
|
+
bundle_digest: sha256(JSON.stringify(files)),
|
|
56
|
+
file_count: files.length,
|
|
57
|
+
};
|
|
58
|
+
}
|