@fitlab-ai/agent-infra 0.9.3 → 0.9.4

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 (175) hide show
  1. package/README.md +2 -0
  2. package/README.zh-CN.md +2 -0
  3. package/bin/cli.ts +12 -0
  4. package/dist/bin/cli.js +14 -0
  5. package/dist/bin/internal-cli.js +134 -123
  6. package/dist/lib/agent-clients/adapter.js +92 -2
  7. package/dist/lib/{sandbox/credentials.js → agent-clients/adapters/claude-code-credentials.js} +4 -14
  8. package/dist/lib/agent-clients/adapters/claude-code-refresh.js +85 -0
  9. package/dist/lib/agent-clients/adapters/claude-code-sandbox.js +247 -0
  10. package/dist/lib/agent-clients/adapters/claude-code.js +17 -3
  11. package/dist/lib/agent-clients/adapters/opencode-sandbox.js +108 -0
  12. package/dist/lib/agent-clients/adapters/opencode.js +15 -3
  13. package/dist/lib/agent-clients/adapters/runtimes/claude-code.dockerfile +65 -0
  14. package/dist/lib/agent-clients/registry.js +9 -1
  15. package/dist/lib/internal/sandbox-control.js +24 -0
  16. package/dist/lib/internal/task-activity.js +45 -13
  17. package/dist/lib/internal/task-ledger.js +12 -3
  18. package/dist/lib/internal/task-lifecycle.js +19 -1
  19. package/dist/lib/internal/task-orchestration.js +122 -18
  20. package/dist/lib/platform/github-client.js +19 -2
  21. package/dist/lib/platform/pr-checks.js +1 -1
  22. package/dist/lib/platform/pull-requests.js +47 -1
  23. package/dist/lib/platform/releases.js +13 -5
  24. package/dist/lib/process-data/index.js +239 -0
  25. package/dist/lib/process-data/normalize.js +198 -0
  26. package/dist/lib/process-data/privacy.js +33 -0
  27. package/dist/lib/process-data/reconcile.js +95 -0
  28. package/dist/lib/process-data/repair.js +58 -0
  29. package/dist/lib/process-data/sources.js +345 -0
  30. package/dist/lib/process-data/store.js +282 -0
  31. package/dist/lib/process-data/types.js +2 -0
  32. package/dist/lib/run/index.js +4 -3
  33. package/dist/lib/run/tui.js +7 -8
  34. package/dist/lib/sandbox/agent-client-reconciler.js +26 -2
  35. package/dist/lib/sandbox/capture.js +7 -2
  36. package/dist/lib/sandbox/commands/create.js +109 -228
  37. package/dist/lib/sandbox/commands/enter.js +15 -47
  38. package/dist/lib/sandbox/commands/list-running.js +16 -6
  39. package/dist/lib/sandbox/commands/ls.js +10 -6
  40. package/dist/lib/sandbox/commands/rebuild.js +4 -3
  41. package/dist/lib/sandbox/commands/refresh.js +4 -84
  42. package/dist/lib/sandbox/commands/rm.js +24 -3
  43. package/dist/lib/sandbox/commands/show.js +12 -3
  44. package/dist/lib/sandbox/commands/start.js +7 -4
  45. package/dist/lib/sandbox/config.js +2 -0
  46. package/dist/lib/sandbox/constants.js +6 -0
  47. package/dist/lib/sandbox/control/client.js +44 -0
  48. package/dist/lib/sandbox/control/protocol.js +35 -0
  49. package/dist/lib/sandbox/control/server.js +109 -0
  50. package/dist/lib/sandbox/dockerfile.js +36 -13
  51. package/dist/lib/sandbox/mounts.js +17 -4
  52. package/dist/lib/sandbox/recovery.js +128 -5
  53. package/dist/lib/sandbox/redaction.js +12 -0
  54. package/dist/lib/sandbox/runtimes/base.dockerfile +24 -74
  55. package/dist/lib/sandbox/workspace-identity.js +95 -0
  56. package/dist/lib/sandbox/workspace-view.js +103 -0
  57. package/dist/lib/task/activity-intent.js +257 -51
  58. package/dist/lib/task/commit-finalization.js +267 -0
  59. package/dist/lib/task/commit-intent.js +165 -0
  60. package/dist/lib/task/decision-intents.js +31 -17
  61. package/dist/lib/task/delegation-receipts.js +2 -2
  62. package/dist/lib/task/implementation-inputs.js +54 -7
  63. package/dist/lib/task/ledger-intents.js +35 -1
  64. package/dist/lib/task/orchestration.js +363 -28
  65. package/dist/lib/task/resolve-ref.js +41 -1
  66. package/lib/agent-clients/adapter.ts +126 -2
  67. package/lib/{sandbox/credentials.ts → agent-clients/adapters/claude-code-credentials.ts} +3 -18
  68. package/lib/agent-clients/adapters/claude-code-refresh.ts +128 -0
  69. package/lib/agent-clients/adapters/claude-code-sandbox.ts +295 -0
  70. package/lib/agent-clients/adapters/claude-code.ts +21 -3
  71. package/lib/agent-clients/adapters/opencode-sandbox.ts +136 -0
  72. package/lib/agent-clients/adapters/opencode.ts +18 -3
  73. package/lib/agent-clients/adapters/runtimes/claude-code.dockerfile +65 -0
  74. package/lib/agent-clients/registry.ts +9 -1
  75. package/lib/internal/sandbox-control.ts +24 -0
  76. package/lib/internal/task-activity.ts +50 -14
  77. package/lib/internal/task-ledger.ts +12 -3
  78. package/lib/internal/task-lifecycle.ts +22 -1
  79. package/lib/internal/task-orchestration.ts +81 -11
  80. package/lib/platform/github-client.ts +20 -2
  81. package/lib/platform/pr-checks.ts +1 -1
  82. package/lib/platform/pull-requests.ts +49 -1
  83. package/lib/platform/releases.ts +16 -4
  84. package/lib/process-data/index.ts +224 -0
  85. package/lib/process-data/normalize.ts +202 -0
  86. package/lib/process-data/privacy.ts +41 -0
  87. package/lib/process-data/reconcile.ts +110 -0
  88. package/lib/process-data/repair.ts +64 -0
  89. package/lib/process-data/sources.ts +354 -0
  90. package/lib/process-data/store.ts +315 -0
  91. package/lib/process-data/types.ts +135 -0
  92. package/lib/run/index.ts +5 -3
  93. package/lib/run/tui.ts +11 -8
  94. package/lib/sandbox/agent-client-reconciler.ts +44 -2
  95. package/lib/sandbox/capture.ts +9 -2
  96. package/lib/sandbox/commands/create.ts +126 -260
  97. package/lib/sandbox/commands/enter.ts +13 -59
  98. package/lib/sandbox/commands/list-running.ts +23 -6
  99. package/lib/sandbox/commands/ls.ts +22 -6
  100. package/lib/sandbox/commands/rebuild.ts +4 -3
  101. package/lib/sandbox/commands/refresh.ts +4 -126
  102. package/lib/sandbox/commands/rm.ts +24 -3
  103. package/lib/sandbox/commands/show.ts +29 -3
  104. package/lib/sandbox/commands/start.ts +6 -4
  105. package/lib/sandbox/config.ts +4 -0
  106. package/lib/sandbox/constants.ts +8 -0
  107. package/lib/sandbox/control/client.ts +55 -0
  108. package/lib/sandbox/control/protocol.ts +75 -0
  109. package/lib/sandbox/control/server.ts +117 -0
  110. package/lib/sandbox/dockerfile.ts +66 -13
  111. package/lib/sandbox/mounts.ts +24 -4
  112. package/lib/sandbox/recovery.ts +163 -6
  113. package/lib/sandbox/redaction.ts +14 -0
  114. package/lib/sandbox/runtimes/base.dockerfile +24 -74
  115. package/lib/sandbox/tool-types.ts +16 -0
  116. package/lib/sandbox/workspace-identity.ts +123 -0
  117. package/lib/sandbox/workspace-view.ts +143 -0
  118. package/lib/task/activity-intent.ts +350 -61
  119. package/lib/task/commit-finalization.ts +356 -0
  120. package/lib/task/commit-intent.ts +230 -0
  121. package/lib/task/decision-intents.ts +32 -12
  122. package/lib/task/delegation-receipts.ts +2 -2
  123. package/lib/task/implementation-inputs.ts +62 -8
  124. package/lib/task/ledger-intents.ts +43 -3
  125. package/lib/task/orchestration.ts +501 -33
  126. package/lib/task/resolve-ref.ts +46 -1
  127. package/package.json +1 -1
  128. package/templates/.agents/rules/README.en.md +1 -1
  129. package/templates/.agents/rules/README.zh-CN.md +1 -1
  130. package/templates/.agents/rules/human-decision-context.en.md +11 -2
  131. package/templates/.agents/rules/human-decision-context.zh-CN.md +11 -2
  132. package/templates/.agents/rules/pr-checks-commands.en.md +1 -1
  133. package/templates/.agents/rules/pr-checks-commands.github.en.md +1 -1
  134. package/templates/.agents/rules/pr-checks-commands.github.zh-CN.md +2 -2
  135. package/templates/.agents/rules/pr-checks-commands.zh-CN.md +1 -1
  136. package/templates/.agents/rules/review-handshake.zh-CN.md +1 -1
  137. package/templates/.agents/rules/task-management.en.md +2 -0
  138. package/templates/.agents/rules/task-management.zh-CN.md +2 -0
  139. package/templates/.agents/skills/code-task/SKILL.en.md +1 -1
  140. package/templates/.agents/skills/code-task/SKILL.zh-CN.md +1 -1
  141. package/templates/.agents/skills/commit/SKILL.en.md +21 -11
  142. package/templates/.agents/skills/commit/SKILL.zh-CN.md +27 -11
  143. package/templates/.agents/skills/commit/reference/commit-orchestration.en.md +49 -0
  144. package/templates/.agents/skills/commit/reference/commit-orchestration.zh-CN.md +55 -0
  145. package/templates/.agents/skills/commit/reference/task-status-update.en.md +4 -4
  146. package/templates/.agents/skills/commit/reference/task-status-update.zh-CN.md +4 -4
  147. package/templates/.agents/skills/complete-task/SKILL.en.md +1 -1
  148. package/templates/.agents/skills/complete-task/SKILL.zh-CN.md +1 -1
  149. package/templates/.agents/skills/create-pr/SKILL.en.md +4 -4
  150. package/templates/.agents/skills/create-pr/SKILL.zh-CN.md +4 -4
  151. package/templates/.agents/skills/create-task/reference/context-capture.en.md +34 -0
  152. package/templates/.agents/skills/create-task/reference/context-capture.zh-CN.md +34 -0
  153. package/templates/.agents/skills/review-code/SKILL.en.md +2 -2
  154. package/templates/.agents/skills/review-code/SKILL.zh-CN.md +2 -2
  155. package/templates/.agents/skills/review-code/reference/output-templates.en.md +1 -1
  156. package/templates/.agents/skills/review-code/reference/output-templates.zh-CN.md +1 -1
  157. package/templates/.agents/skills/review-pr/SKILL.en.md +36 -8
  158. package/templates/.agents/skills/review-pr/SKILL.zh-CN.md +36 -8
  159. package/templates/.agents/skills/review-pr/reference/report-template.en.md +1 -1
  160. package/templates/.agents/skills/review-pr/reference/report-template.zh-CN.md +1 -1
  161. package/templates/.agents/skills/update-agent-infra/scripts/sync-templates.js +67 -50
  162. package/templates/.agents/skills/watch-pr/SKILL.en.md +4 -4
  163. package/templates/.agents/skills/watch-pr/SKILL.zh-CN.md +4 -4
  164. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.en.md +2 -2
  165. package/templates/.agents/skills/watch-pr/reference/monitor-and-heal.zh-CN.md +2 -2
  166. package/templates/.agents/templates/task.en.md +1 -1
  167. package/templates/.agents/templates/task.zh-CN.md +1 -1
  168. package/templates/.claude/commands/watch-pr.en.md +1 -1
  169. package/templates/.claude/commands/watch-pr.zh-CN.md +1 -1
  170. package/templates/.claude/rules/tool-preferences.en.md +20 -0
  171. package/templates/.claude/rules/tool-preferences.zh-CN.md +20 -0
  172. package/templates/.opencode/commands/watch-pr.en.md +1 -1
  173. package/templates/.opencode/commands/watch-pr.zh-CN.md +1 -1
  174. package/templates/.gemini/commands/_project_/review-pr.en.toml +0 -10
  175. package/templates/.gemini/commands/_project_/review-pr.zh-CN.toml +0 -10
@@ -0,0 +1,110 @@
1
+ import { sha256 } from './store.ts';
2
+
3
+ import type { NormalizedRecord, QualityFinding, RepairAction, SnapshotManifest } from './types.ts';
4
+
5
+ function reconcileRecords(
6
+ records: NormalizedRecord[],
7
+ scope: SnapshotManifest['scope']
8
+ ): { findings: QualityFinding[]; repairs: RepairAction[] } {
9
+ const findings: QualityFinding[] = [];
10
+ const repairs: RepairAction[] = [];
11
+ const findingKeys = new Set<string>();
12
+ const addFinding = (
13
+ category: QualityFinding['category'],
14
+ severity: QualityFinding['severity'],
15
+ identities: string[],
16
+ repairable: boolean,
17
+ reason: string
18
+ ) => {
19
+ const sortedIdentities = [...identities].sort();
20
+ const key = `${category}:${sortedIdentities.join('\0')}`;
21
+ if (findingKeys.has(key)) return;
22
+ findingKeys.add(key);
23
+ findings.push({
24
+ findingId: `quality:${sha256(key).slice(0, 20)}`,
25
+ category,
26
+ severity,
27
+ identities: sortedIdentities,
28
+ repairable,
29
+ reason
30
+ });
31
+ };
32
+ const byIdentity = new Map<string, NormalizedRecord[]>();
33
+ for (const record of records) {
34
+ const group = byIdentity.get(record.sourceIdentity) ?? [];
35
+ group.push(record);
36
+ byIdentity.set(record.sourceIdentity, group);
37
+ if (
38
+ record.kind === 'missing' &&
39
+ record.data !== null &&
40
+ typeof record.data === 'object' &&
41
+ !Array.isArray(record.data) &&
42
+ record.data.reason === 'privacy-excluded'
43
+ ) {
44
+ addFinding('privacy-excluded', 'warning', [record.sourceIdentity], false, 'Sensitive content was excluded by policy');
45
+ } else if (record.kind === 'missing') {
46
+ addFinding('unrecoverable', 'info', [record.sourceIdentity], false, 'Source body is unavailable under the capture policy');
47
+ }
48
+ }
49
+
50
+ for (const [identity, group] of byIdentity) {
51
+ if (group.length < 2) continue;
52
+ addFinding('duplicate-identity', 'error', group.map((record) => record.recordId), false, 'Multiple records claim the same stable identity');
53
+ if (group.some((record) => record.kind === 'platform-resource') && new Set(group.map((record) => record.sourceSha256)).size > 1) {
54
+ addFinding('mutable-remote', 'warning', group.map((record) => record.recordId), false, `Remote identity changed content: ${identity}`);
55
+ }
56
+ }
57
+
58
+ const tasks = records.filter((record) => record.kind === 'task');
59
+ const resources = records.filter((record) => record.kind === 'platform-resource');
60
+ const taskByIdentity = new Map(tasks.map((task) => [task.sourceIdentity, task]));
61
+
62
+ for (const task of tasks) {
63
+ const data = task.data && typeof task.data === 'object' && !Array.isArray(task.data) ? task.data : null;
64
+ const version = data?.agentInfraVersion;
65
+ if (!version || typeof version !== 'object' || Array.isArray(version) || version.state !== 'known') {
66
+ addFinding('schema-difference', 'info', [task.recordId], false, 'Task uses a legacy schema without a known agent_infra_version');
67
+ }
68
+ if (scope !== 'all' || !task.binding) continue;
69
+ const identityCandidates = resources.filter((record) => record.sourceIdentity === task.binding);
70
+ if (identityCandidates.length === 0) {
71
+ addFinding('missing-remote', 'error', [task.recordId, task.binding], false, 'Task binding has no captured platform resource');
72
+ }
73
+ if (identityCandidates.length > 1) {
74
+ addFinding('binding-conflict', 'error', [task.recordId, ...identityCandidates.map((record) => record.recordId)], false, 'Multiple platform resources bind to one task');
75
+ }
76
+ for (const remote of identityCandidates) {
77
+ if (remote.binding && remote.binding !== task.sourceIdentity) {
78
+ addFinding('content-mismatch', 'warning', [task.recordId, remote.recordId], false, 'Local and remote binding identities disagree');
79
+ }
80
+ }
81
+ const candidates = identityCandidates.filter((record) => record.binding === task.sourceIdentity);
82
+ if (candidates.length === 1 && (byIdentity.get(candidates[0]!.sourceIdentity)?.length ?? 0) === 1) {
83
+ const target = candidates[0]!;
84
+ repairs.push({
85
+ repairId: `repair:${sha256(`${task.recordId}:${target.recordId}`).slice(0, 20)}`,
86
+ operation: 'link',
87
+ sourceRecordId: task.recordId,
88
+ targetRecordId: target.recordId,
89
+ preconditionSha256: sha256(`${task.sourceSha256}:${target.sourceSha256}`)
90
+ });
91
+ }
92
+ }
93
+
94
+ for (const resource of scope === 'all' ? resources : []) {
95
+ if (resource.binding?.startsWith('TASK-') && !taskByIdentity.has(resource.binding)) {
96
+ addFinding('missing-local', 'error', [resource.recordId, resource.binding], false, 'Platform resource binds to a task that is not captured locally');
97
+ }
98
+ const localClaims = tasks.filter((task) => task.binding === resource.sourceIdentity);
99
+ if (localClaims.length > 1 || (resource.binding && localClaims.length > 0 && !localClaims.some((task) => task.sourceIdentity === resource.binding))) {
100
+ addFinding('binding-conflict', 'error', [resource.recordId, ...localClaims.map((task) => task.recordId)], false, 'Local and remote records claim incompatible bindings');
101
+ }
102
+ }
103
+
104
+ return {
105
+ findings: findings.sort((left, right) => left.findingId.localeCompare(right.findingId)),
106
+ repairs: repairs.sort((left, right) => left.repairId.localeCompare(right.repairId))
107
+ };
108
+ }
109
+
110
+ export { reconcileRecords };
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { canonicalJsonBytes, sha256, verifySnapshot } from './store.ts';
5
+
6
+ import type { RepairAction } from './types.ts';
7
+ import type { NormalizedRecord } from './types.ts';
8
+
9
+ type RepairResult = { applied: boolean; repairId: string | null; actionCount: number; path?: string };
10
+
11
+ function readRepairActions(snapshotPath: string): RepairAction[] {
12
+ const filePath = path.join(snapshotPath, 'repair-manifest.jsonl');
13
+ if (!fs.existsSync(filePath)) return [];
14
+ return fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean).map((line) => {
15
+ const { schema: _schema, ...action } = JSON.parse(line) as { schema: string } & RepairAction;
16
+ return action;
17
+ });
18
+ }
19
+
20
+ function repairSnapshot(root: string, snapshotId: string, apply: boolean): RepairResult {
21
+ const verified = verifySnapshot(root, snapshotId);
22
+ if (!verified.ok || !verified.snapshotPath) throw new Error(`snapshot verification failed: ${verified.errors.join('; ')}`);
23
+ const actions = readRepairActions(verified.snapshotPath);
24
+ const records = new Map(
25
+ fs.readFileSync(path.join(verified.snapshotPath, 'normalized.jsonl'), 'utf8').split('\n').filter(Boolean).map((line) => {
26
+ const record = JSON.parse(line) as { schema: string } & NormalizedRecord;
27
+ return [record.recordId, record] as const;
28
+ })
29
+ );
30
+ for (const action of actions) {
31
+ const source = records.get(action.sourceRecordId);
32
+ const target = records.get(action.targetRecordId);
33
+ if (!source || !target) throw new Error(`repair references a missing record: ${action.repairId}`);
34
+ if (sha256(`${source.sourceSha256}:${target.sourceSha256}`) !== action.preconditionSha256) {
35
+ throw new Error(`repair precondition failed: ${action.repairId}`);
36
+ }
37
+ }
38
+ if (!apply || actions.length === 0) return { applied: false, repairId: null, actionCount: actions.length };
39
+
40
+ const digest = sha256(canonicalJsonBytes(actions));
41
+ const repairId = `repair-${digest.slice(0, 16)}`;
42
+ const repairDir = path.join(root, 'repairs', snapshotId, repairId);
43
+ const manifest = `${canonicalJsonBytes({ schema: 'repair-application/v1', snapshotId, repairId, actionCount: actions.length, overlaySha256: digest }).toString('utf8')}\n`;
44
+ const overlay = actions.map((action) => `${canonicalJsonBytes({ schema: 'normalized-overlay/v1', ...action }).toString('utf8')}\n`).join('');
45
+ if (fs.existsSync(repairDir)) {
46
+ if (fs.readFileSync(path.join(repairDir, 'manifest.json'), 'utf8') !== manifest) throw new Error(`repair collision: ${repairId}`);
47
+ return { applied: false, repairId, actionCount: actions.length, path: repairDir };
48
+ }
49
+ fs.mkdirSync(repairDir, { recursive: true, mode: 0o700 });
50
+ fs.writeFileSync(path.join(repairDir, 'manifest.json'), manifest, { mode: 0o600, flag: 'wx' });
51
+ fs.writeFileSync(path.join(repairDir, 'overlay.jsonl'), overlay, { mode: 0o600, flag: 'wx' });
52
+ return { applied: true, repairId, actionCount: actions.length, path: repairDir };
53
+ }
54
+
55
+ function readAppliedOverlays(root: string, snapshotId: string): string {
56
+ const base = path.join(root, 'repairs', snapshotId);
57
+ if (!fs.existsSync(base)) return '';
58
+ return fs.readdirSync(base).sort().map((entry) => {
59
+ const overlay = path.join(base, entry, 'overlay.jsonl');
60
+ return fs.existsSync(overlay) ? fs.readFileSync(overlay, 'utf8') : '';
61
+ }).join('');
62
+ }
63
+
64
+ export { readAppliedOverlays, repairSnapshot };
@@ -0,0 +1,354 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ import { createGitHubClient } from '../platform/github-client.ts';
5
+ import { enumerateAllTaskDirs } from '../task/resolve-ref.ts';
6
+ import { captureText, inspectPrivacy, redactExcerpt } from './privacy.ts';
7
+ import { canonicalJsonBytes, sha256 } from './store.ts';
8
+
9
+ import type { GitHubClient } from '../platform/github-client.ts';
10
+ import type {
11
+ CapturedObject,
12
+ ProcessResult,
13
+ RestCollectionEvidence,
14
+ RestPageEvidence
15
+ } from './types.ts';
16
+
17
+ type RestPage = { evidence: RestPageEvidence; bytes: Buffer };
18
+ type RestCollection<T> = { items: T[]; evidence: RestCollectionEvidence; pageObjects: CapturedObject[] };
19
+
20
+ type FetchRestCollectionOptions<T> = {
21
+ client: GitHubClient;
22
+ endpoint: string;
23
+ identify: (item: T) => string;
24
+ select?: (item: T) => T;
25
+ perPage?: number;
26
+ };
27
+
28
+ type GitHubCapture = {
29
+ repository: string;
30
+ objects: CapturedObject[];
31
+ endpoints: RestCollectionEvidence[];
32
+ };
33
+
34
+ function endpointForPage(endpoint: string, page: number, perPage: number): string {
35
+ const parsed = new URL(endpoint, 'https://api.github.invalid/');
36
+ if (parsed.searchParams.has('page') || parsed.searchParams.has('per_page')) {
37
+ throw new Error('endpoint must not predefine page or per_page');
38
+ }
39
+ parsed.searchParams.set('per_page', String(perPage));
40
+ parsed.searchParams.set('page', String(page));
41
+ return `${parsed.pathname.replace(/^\//, '')}${parsed.search}`;
42
+ }
43
+
44
+ function fetchRestCollection<T>(options: FetchRestCollectionOptions<T>): ProcessResult<RestCollection<T>> {
45
+ const perPage = options.perPage ?? 100;
46
+ if (!Number.isInteger(perPage) || perPage < 1 || perPage > 100) {
47
+ return { ok: false, error: { code: 'PAGINATION_INVALID', message: 'perPage must be between 1 and 100' } };
48
+ }
49
+ let pageIndex = 1;
50
+ let previousHash: string | null = null;
51
+ const identities = new Set<string>();
52
+ const items: T[] = [];
53
+ const pages: RestPage[] = [];
54
+
55
+ while (true) {
56
+ let url: string;
57
+ try {
58
+ url = endpointForPage(options.endpoint, pageIndex, perPage);
59
+ } catch (error) {
60
+ return { ok: false, error: { code: 'PAGINATION_INVALID', message: String(error) } };
61
+ }
62
+ const response = options.client.json<unknown>(['api', url]);
63
+ if (!response.ok) return { ok: false, error: response.error };
64
+ if (!Array.isArray(response.value) || response.value.length > perPage) {
65
+ return {
66
+ ok: false,
67
+ error: { code: 'INVALID_PLATFORM_RESPONSE', message: `GitHub page ${pageIndex} is not a valid array page` }
68
+ };
69
+ }
70
+ let selected: T[];
71
+ try {
72
+ selected = (response.value as T[]).map((entry) => options.select ? options.select(entry) : entry);
73
+ } catch (error) {
74
+ return { ok: false, error: { code: 'INVALID_PLATFORM_RESPONSE', message: String(error) } };
75
+ }
76
+ let bytes: Buffer;
77
+ try {
78
+ bytes = canonicalJsonBytes(selected);
79
+ } catch (error) {
80
+ return { ok: false, error: { code: 'INVALID_PLATFORM_RESPONSE', message: String(error) } };
81
+ }
82
+ const canonicalSha256 = sha256(bytes);
83
+ if (previousHash === canonicalSha256 && response.value.length > 0) {
84
+ return {
85
+ ok: false,
86
+ error: { code: 'PAGINATION_UNSTABLE', message: `GitHub page ${pageIndex} repeats the previous page` }
87
+ };
88
+ }
89
+ for (const entry of selected) {
90
+ const identity = options.identify(entry);
91
+ if (!identity || identities.has(identity)) {
92
+ return {
93
+ ok: false,
94
+ error: { code: 'PAGINATION_UNSTABLE', message: `duplicate or empty identity on page ${pageIndex}` }
95
+ };
96
+ }
97
+ identities.add(identity);
98
+ items.push(entry);
99
+ }
100
+ pages.push({ evidence: { index: pageIndex, itemCount: response.value.length, canonicalSha256 }, bytes });
101
+ previousHash = canonicalSha256;
102
+ if (response.value.length < perPage) break;
103
+ pageIndex += 1;
104
+ }
105
+
106
+ const evidence: RestCollectionEvidence = {
107
+ endpoint: options.endpoint,
108
+ requestCount: pages.length,
109
+ dataPageCount: pages.filter((page) => page.evidence.itemCount > 0).length,
110
+ itemCount: items.length,
111
+ termination: 'short-page',
112
+ pages: pages.map((page) => page.evidence)
113
+ };
114
+ return {
115
+ ok: true,
116
+ value: {
117
+ items,
118
+ evidence,
119
+ pageObjects: pages.map((page) => ({
120
+ sourceKind: 'github-rest',
121
+ sourceIdentity: `${options.endpoint}#page=${page.evidence.index}`,
122
+ sha256: page.evidence.canonicalSha256,
123
+ bytes: page.bytes.length,
124
+ content: page.bytes.toString('utf8'),
125
+ disposition: { state: 'included' }
126
+ }))
127
+ }
128
+ };
129
+ }
130
+
131
+ function relativeIdentity(repoRoot: string, filePath: string): string {
132
+ const relative = path.relative(repoRoot, filePath);
133
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error(`path escapes repository: ${filePath}`);
134
+ return relative.split(path.sep).join('/');
135
+ }
136
+
137
+ function readStableFile(repoRoot: string, filePath: string, sourceKind: CapturedObject['sourceKind']): CapturedObject {
138
+ const before = fs.lstatSync(filePath);
139
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error(`unsafe source file: ${filePath}`);
140
+ const content = fs.readFileSync(filePath, 'utf8');
141
+ const after = fs.lstatSync(filePath);
142
+ if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeMs !== after.mtimeMs) {
143
+ throw new Error(`source changed while reading: ${filePath}`);
144
+ }
145
+ return captureText(sourceKind, relativeIdentity(repoRoot, filePath), content);
146
+ }
147
+
148
+ function listFiles(directory: string): string[] {
149
+ if (!fs.existsSync(directory)) return [];
150
+ const output: string[] = [];
151
+ const walk = (current: string) => {
152
+ for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
153
+ const candidate = path.join(current, entry.name);
154
+ if (entry.isSymbolicLink()) throw new Error(`symlink source is not allowed: ${candidate}`);
155
+ if (entry.isDirectory()) walk(candidate);
156
+ else if (entry.isFile()) output.push(candidate);
157
+ }
158
+ };
159
+ walk(directory);
160
+ return output;
161
+ }
162
+
163
+ function structuredReceipt(repoRoot: string, filePath: string): CapturedObject {
164
+ const raw = readStableFile(repoRoot, filePath, 'structured-telemetry');
165
+ if (!raw.content) return raw;
166
+ try {
167
+ const parsed = JSON.parse(raw.content) as Record<string, unknown>;
168
+ const fields = ['client', 'model', 'reasoning', 'stage', 'toolCategory', 'time', 'status', 'retry', 'error', 'fallback'];
169
+ const selected = Object.fromEntries(
170
+ fields.filter((field) => parsed[field] !== undefined).map((field) => [field, jsonClone(parsed[field])])
171
+ );
172
+ selected.sourceSha256 = raw.sha256;
173
+ return captureText('structured-telemetry', raw.sourceIdentity, canonicalJsonBytes(selected).toString('utf8'));
174
+ } catch {
175
+ return { ...raw, content: undefined, disposition: { state: 'unavailable', reason: 'invalid receipt JSON' } };
176
+ }
177
+ }
178
+
179
+ function unavailableLog(repoRoot: string, filePath: string, includeExcerpts: boolean): CapturedObject {
180
+ const raw = readStableFile(repoRoot, filePath, 'local-file');
181
+ if (raw.disposition?.state === 'excluded-sensitive') return raw;
182
+ const recognizedBody = /\/(?:sessions?|tools?)(?:\/|[-_])/i.test(raw.sourceIdentity);
183
+ if (includeExcerpts && recognizedBody && raw.content) {
184
+ const excerpt = redactExcerpt(raw.content);
185
+ if (excerpt !== null) return captureText('local-file', `${raw.sourceIdentity}#excerpt`, excerpt);
186
+ }
187
+ return {
188
+ sourceKind: 'local-file',
189
+ sourceIdentity: raw.sourceIdentity,
190
+ sha256: raw.sha256,
191
+ bytes: raw.bytes,
192
+ disposition: { state: 'unavailable', reason: recognizedBody ? 'body-excerpts-disabled' : 'unknown-log-format' }
193
+ };
194
+ }
195
+
196
+ function collectLocalObjects(repoRoot: string, options: { includeExcerpts?: boolean } = {}): ProcessResult<CapturedObject[]> {
197
+ try {
198
+ const objects: CapturedObject[] = [];
199
+ for (const task of enumerateAllTaskDirs(repoRoot)) {
200
+ for (const filePath of listFiles(task.taskDir)) {
201
+ if (!/\.(?:md|json)$/i.test(filePath)) continue;
202
+ objects.push(readStableFile(repoRoot, filePath, 'local-file'));
203
+ }
204
+ }
205
+ const logsRoot = path.join(repoRoot, '.agents', 'workspace', 'logs');
206
+ for (const filePath of listFiles(logsRoot)) {
207
+ if (!/\.(?:md|json)$/i.test(filePath)) continue;
208
+ const identity = relativeIdentity(repoRoot, filePath);
209
+ if (/\/entropy-check\/.*\.md$/i.test(identity)) {
210
+ objects.push(readStableFile(repoRoot, filePath, 'operational-report'));
211
+ } else if (/\/(?:delegation|orchestration)(?:\/|[-_]).*\.json$/i.test(identity)) {
212
+ objects.push(structuredReceipt(repoRoot, filePath));
213
+ } else {
214
+ objects.push(unavailableLog(repoRoot, filePath, options.includeExcerpts === true));
215
+ }
216
+ }
217
+ return { ok: true, value: objects.sort((a, b) => a.sourceIdentity.localeCompare(b.sourceIdentity)) };
218
+ } catch (error) {
219
+ return { ok: false, error: { code: 'LOCAL_SOURCE_UNSTABLE', message: error instanceof Error ? error.message : String(error) } };
220
+ }
221
+ }
222
+
223
+ function githubIdentity(item: unknown): string {
224
+ if (!item || typeof item !== 'object') return '';
225
+ const record = item as Record<string, unknown>;
226
+ for (const key of ['id', 'node_id', 'sha', 'number']) {
227
+ if (typeof record[key] === 'string' || typeof record[key] === 'number') return `${key}:${String(record[key])}`;
228
+ }
229
+ return '';
230
+ }
231
+
232
+ function jsonClone(value: unknown): unknown {
233
+ return JSON.parse(JSON.stringify(value)) as unknown;
234
+ }
235
+
236
+ function projectFields(value: unknown, fields: string[]): Record<string, unknown> | undefined {
237
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
238
+ const record = value as Record<string, unknown>;
239
+ return Object.fromEntries(
240
+ fields.filter((field) => record[field] !== undefined).map((field) => [field, jsonClone(record[field])])
241
+ );
242
+ }
243
+
244
+ function projectSensitiveText(value: unknown): unknown {
245
+ if (typeof value !== 'string') return undefined;
246
+ const disposition = inspectPrivacy(value);
247
+ return disposition.state === 'included'
248
+ ? value
249
+ : { state: 'unavailable', reason: 'privacy-excluded', ruleId: disposition.ruleId, sha256: sha256(value) };
250
+ }
251
+
252
+ function projectGitHubItem(item: unknown): unknown {
253
+ if (!item || typeof item !== 'object' || Array.isArray(item)) throw new Error('GitHub item must be an object');
254
+ const source = item as Record<string, unknown>;
255
+ const output: Record<string, unknown> = {};
256
+ const scalarFields = [
257
+ 'id', 'node_id', 'number', 'html_url', 'url', 'state', 'title', 'created_at', 'updated_at', 'closed_at',
258
+ 'merged_at', 'review_state', 'commit_id', 'sha', 'merge_commit_sha', 'milestone'
259
+ ];
260
+ for (const field of scalarFields) {
261
+ if (source[field] !== undefined) output[field] = jsonClone(source[field]);
262
+ }
263
+ for (const field of ['user', 'head', 'base', 'author', 'committer']) {
264
+ const projected = projectFields(source[field], ['id', 'login', 'sha', 'ref']);
265
+ if (projected) output[field] = projected;
266
+ }
267
+ for (const field of ['labels', 'assignees']) {
268
+ if (Array.isArray(source[field])) output[field] = jsonClone(source[field]);
269
+ }
270
+ for (const field of ['body', 'message']) {
271
+ const projected = projectSensitiveText(source[field]);
272
+ if (projected !== undefined) output[field] = projected;
273
+ }
274
+ if (Array.isArray(source.parents)) {
275
+ output.parents = source.parents.flatMap((parent) => {
276
+ const projected = projectFields(parent, ['sha']);
277
+ return projected ? [projected] : [];
278
+ });
279
+ }
280
+ const commit = projectFields(source.commit, []);
281
+ if (commit) {
282
+ const sourceCommit = source.commit as Record<string, unknown>;
283
+ const projectedCommit: Record<string, unknown> = {};
284
+ const message = projectSensitiveText(sourceCommit.message);
285
+ if (message !== undefined) projectedCommit.message = message;
286
+ const tree = projectFields(sourceCommit.tree, ['sha']);
287
+ if (tree) projectedCommit.tree = tree;
288
+ for (const field of ['author', 'committer']) {
289
+ const actor = projectFields(sourceCommit[field], ['name', 'email', 'date']);
290
+ if (actor) projectedCommit[field] = actor;
291
+ }
292
+ output.commit = projectedCommit;
293
+ }
294
+ return output;
295
+ }
296
+
297
+ function collectGitHubObjects(repoRoot: string, client: GitHubClient = createGitHubClient()): ProcessResult<GitHubCapture> {
298
+ const repoResult = client.json<{ nameWithOwner?: string }>(['repo', 'view', '--json', 'nameWithOwner'], { cwd: repoRoot });
299
+ if (!repoResult.ok) return { ok: false, error: repoResult.error };
300
+ const repository = repoResult.value.nameWithOwner;
301
+ if (!repository || !/^[^/]+\/[^/]+$/.test(repository)) {
302
+ return { ok: false, error: { code: 'INVALID_PLATFORM_RESPONSE', message: 'GitHub repository identity is missing' } };
303
+ }
304
+ const objects: CapturedObject[] = [];
305
+ const evidence: RestCollectionEvidence[] = [];
306
+ const captureEndpoint = (endpoint: string) => {
307
+ const result = fetchRestCollection({
308
+ client,
309
+ endpoint,
310
+ identify: githubIdentity,
311
+ select: (item) => projectGitHubItem(item) as unknown
312
+ });
313
+ if (!result.ok) return result;
314
+ objects.push(...result.value.pageObjects);
315
+ evidence.push(result.value.evidence);
316
+ return result;
317
+ };
318
+
319
+ const issues = captureEndpoint(`repos/${repository}/issues?state=all&sort=created&direction=asc`);
320
+ if (!issues.ok) return issues;
321
+ const pulls = captureEndpoint(`repos/${repository}/pulls?state=all&sort=created&direction=asc`);
322
+ if (!pulls.ok) return pulls;
323
+
324
+ for (const issue of issues.value.items) {
325
+ const number = (issue as Record<string, unknown>).number;
326
+ if (typeof number !== 'number') return { ok: false, error: { code: 'INVALID_PLATFORM_RESPONSE', message: 'Issue number is missing' } };
327
+ const comments = captureEndpoint(`repos/${repository}/issues/${number}/comments`);
328
+ if (!comments.ok) return comments;
329
+ }
330
+ for (const pull of pulls.value.items) {
331
+ const record = pull as Record<string, unknown>;
332
+ const number = record.number;
333
+ const head = record.head as Record<string, unknown> | undefined;
334
+ const headSha = head?.sha;
335
+ if (typeof number !== 'number' || typeof headSha !== 'string') {
336
+ return { ok: false, error: { code: 'INVALID_PLATFORM_RESPONSE', message: 'Pull request identity or head SHA is missing' } };
337
+ }
338
+ for (const suffix of ['comments', 'reviews', 'commits']) {
339
+ const nested = captureEndpoint(`repos/${repository}/pulls/${number}/${suffix}`);
340
+ if (!nested.ok) return nested;
341
+ }
342
+ const current = client.json<unknown>(['api', `repos/${repository}/pulls/${number}`]);
343
+ if (!current.ok) return { ok: false, error: current.error };
344
+ const projected = projectGitHubItem(current.value) as Record<string, unknown>;
345
+ const currentHead = projected.head as Record<string, unknown> | undefined;
346
+ if (currentHead?.sha !== headSha) {
347
+ return { ok: false, error: { code: 'SOURCE_DRIFT', message: `Pull request ${number} head changed during capture` } };
348
+ }
349
+ }
350
+ return { ok: true, value: { repository, objects, endpoints: evidence } };
351
+ }
352
+
353
+ export { collectGitHubObjects, collectLocalObjects, fetchRestCollection, projectGitHubItem };
354
+ export type { FetchRestCollectionOptions, GitHubCapture, RestCollection };