openplanr 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/dist/cli/commands/backlog.d.ts.map +1 -1
  2. package/dist/cli/commands/backlog.js +3 -0
  3. package/dist/cli/commands/backlog.js.map +1 -1
  4. package/dist/cli/commands/config.d.ts.map +1 -1
  5. package/dist/cli/commands/config.js +3 -0
  6. package/dist/cli/commands/config.js.map +1 -1
  7. package/dist/cli/commands/epic.d.ts.map +1 -1
  8. package/dist/cli/commands/epic.js +2 -0
  9. package/dist/cli/commands/epic.js.map +1 -1
  10. package/dist/cli/commands/feature.d.ts.map +1 -1
  11. package/dist/cli/commands/feature.js +2 -0
  12. package/dist/cli/commands/feature.js.map +1 -1
  13. package/dist/cli/commands/operate.d.ts +5 -2
  14. package/dist/cli/commands/operate.d.ts.map +1 -1
  15. package/dist/cli/commands/operate.js +10 -101
  16. package/dist/cli/commands/operate.js.map +1 -1
  17. package/dist/cli/commands/plan.d.ts.map +1 -1
  18. package/dist/cli/commands/plan.js +2 -0
  19. package/dist/cli/commands/plan.js.map +1 -1
  20. package/dist/cli/commands/quick.d.ts.map +1 -1
  21. package/dist/cli/commands/quick.js +2 -0
  22. package/dist/cli/commands/quick.js.map +1 -1
  23. package/dist/cli/commands/refine.d.ts.map +1 -1
  24. package/dist/cli/commands/refine.js +2 -0
  25. package/dist/cli/commands/refine.js.map +1 -1
  26. package/dist/cli/commands/revise.d.ts.map +1 -1
  27. package/dist/cli/commands/revise.js +2 -0
  28. package/dist/cli/commands/revise.js.map +1 -1
  29. package/dist/cli/commands/spec.d.ts.map +1 -1
  30. package/dist/cli/commands/spec.js +2 -0
  31. package/dist/cli/commands/spec.js.map +1 -1
  32. package/dist/cli/commands/sprint.d.ts.map +1 -1
  33. package/dist/cli/commands/sprint.js +2 -0
  34. package/dist/cli/commands/sprint.js.map +1 -1
  35. package/dist/cli/commands/story.d.ts.map +1 -1
  36. package/dist/cli/commands/story.js +3 -0
  37. package/dist/cli/commands/story.js.map +1 -1
  38. package/dist/cli/commands/task.d.ts.map +1 -1
  39. package/dist/cli/commands/task.js +3 -0
  40. package/dist/cli/commands/task.js.map +1 -1
  41. package/dist/services/ai-service.d.ts +3 -5
  42. package/dist/services/ai-service.d.ts.map +1 -1
  43. package/dist/services/ai-service.js +7 -1
  44. package/dist/services/ai-service.js.map +1 -1
  45. package/dist/services/credentials-service.d.ts.map +1 -1
  46. package/dist/services/credentials-service.js +2 -0
  47. package/dist/services/credentials-service.js.map +1 -1
  48. package/dist/services/deprecation-notices.d.ts +5 -0
  49. package/dist/services/deprecation-notices.d.ts.map +1 -0
  50. package/dist/services/deprecation-notices.js +6 -0
  51. package/dist/services/deprecation-notices.js.map +1 -0
  52. package/dist/services/operate/advisors.d.ts +146 -108
  53. package/dist/services/operate/advisors.d.ts.map +1 -1
  54. package/dist/services/operate/advisors.js +178 -448
  55. package/dist/services/operate/advisors.js.map +1 -1
  56. package/dist/services/operate/citation-resolution.d.ts.map +1 -1
  57. package/dist/services/operate/citation-resolution.js +50 -1
  58. package/dist/services/operate/citation-resolution.js.map +1 -1
  59. package/dist/services/operate/config.d.ts +28 -52
  60. package/dist/services/operate/config.d.ts.map +1 -1
  61. package/dist/services/operate/config.js +68 -203
  62. package/dist/services/operate/config.js.map +1 -1
  63. package/dist/services/operate/doctor.d.ts +15 -0
  64. package/dist/services/operate/doctor.d.ts.map +1 -1
  65. package/dist/services/operate/doctor.js +142 -32
  66. package/dist/services/operate/doctor.js.map +1 -1
  67. package/dist/services/operate/engine.d.ts +22 -62
  68. package/dist/services/operate/engine.d.ts.map +1 -1
  69. package/dist/services/operate/engine.js +269 -191
  70. package/dist/services/operate/engine.js.map +1 -1
  71. package/dist/services/operate/evidence.d.ts +9 -78
  72. package/dist/services/operate/evidence.d.ts.map +1 -1
  73. package/dist/services/operate/evidence.js +0 -1194
  74. package/dist/services/operate/evidence.js.map +1 -1
  75. package/dist/services/operate/index.d.ts.map +1 -1
  76. package/dist/services/operate/index.js +15 -231
  77. package/dist/services/operate/index.js.map +1 -1
  78. package/dist/services/operate/integrity.d.ts +81 -0
  79. package/dist/services/operate/integrity.d.ts.map +1 -0
  80. package/dist/services/operate/integrity.js +152 -0
  81. package/dist/services/operate/integrity.js.map +1 -0
  82. package/dist/services/operate/interaction/answer-service.d.ts +0 -10
  83. package/dist/services/operate/interaction/answer-service.d.ts.map +1 -1
  84. package/dist/services/operate/interaction/answer-service.js +63 -43
  85. package/dist/services/operate/interaction/answer-service.js.map +1 -1
  86. package/dist/services/operate/interaction/question-engine.d.ts.map +1 -1
  87. package/dist/services/operate/interaction/question-engine.js +105 -21
  88. package/dist/services/operate/interaction/question-engine.js.map +1 -1
  89. package/dist/services/operate/interaction/question-registry.d.ts +16 -1
  90. package/dist/services/operate/interaction/question-registry.d.ts.map +1 -1
  91. package/dist/services/operate/interaction/question-registry.js +45 -39
  92. package/dist/services/operate/interaction/question-registry.js.map +1 -1
  93. package/dist/services/operate/interaction/session-service.d.ts.map +1 -1
  94. package/dist/services/operate/interaction/session-service.js +16 -11
  95. package/dist/services/operate/interaction/session-service.js.map +1 -1
  96. package/dist/services/operate/interaction/terminal-renderer.d.ts.map +1 -1
  97. package/dist/services/operate/interaction/terminal-renderer.js +16 -6
  98. package/dist/services/operate/interaction/terminal-renderer.js.map +1 -1
  99. package/dist/services/operate/lifecycle.d.ts.map +1 -1
  100. package/dist/services/operate/lifecycle.js +17 -1
  101. package/dist/services/operate/lifecycle.js.map +1 -1
  102. package/dist/services/operate/maintenance.d.ts.map +1 -1
  103. package/dist/services/operate/maintenance.js +95 -276
  104. package/dist/services/operate/maintenance.js.map +1 -1
  105. package/dist/services/operate/mission-dispatch.d.ts +73 -55
  106. package/dist/services/operate/mission-dispatch.d.ts.map +1 -1
  107. package/dist/services/operate/mission-dispatch.js +91 -67
  108. package/dist/services/operate/mission-dispatch.js.map +1 -1
  109. package/dist/services/operate/projection-persistence.d.ts +35 -7
  110. package/dist/services/operate/projection-persistence.d.ts.map +1 -1
  111. package/dist/services/operate/projection-persistence.js +142 -53
  112. package/dist/services/operate/projection-persistence.js.map +1 -1
  113. package/dist/services/operate/projection.d.ts +9 -1
  114. package/dist/services/operate/projection.d.ts.map +1 -1
  115. package/dist/services/operate/projection.js +22 -12
  116. package/dist/services/operate/projection.js.map +1 -1
  117. package/dist/services/operate/protocol.d.ts +1 -41
  118. package/dist/services/operate/protocol.d.ts.map +1 -1
  119. package/dist/services/operate/protocol.js +7 -45
  120. package/dist/services/operate/protocol.js.map +1 -1
  121. package/dist/services/operate/redaction.d.ts +3 -3
  122. package/dist/services/operate/redaction.js +4 -4
  123. package/dist/services/operate/redaction.js.map +1 -1
  124. package/dist/services/operate/reports.d.ts +20 -1
  125. package/dist/services/operate/reports.d.ts.map +1 -1
  126. package/dist/services/operate/reports.js +108 -4
  127. package/dist/services/operate/reports.js.map +1 -1
  128. package/dist/services/operate/routes.d.ts +84 -0
  129. package/dist/services/operate/routes.d.ts.map +1 -1
  130. package/dist/services/operate/routes.js +519 -10
  131. package/dist/services/operate/routes.js.map +1 -1
  132. package/dist/services/operate/types.d.ts +48 -133
  133. package/dist/services/operate/types.d.ts.map +1 -1
  134. package/dist/services/operate/types.js.map +1 -1
  135. package/dist/services/operate/workspace.d.ts +0 -1
  136. package/dist/services/operate/workspace.d.ts.map +1 -1
  137. package/dist/services/operate/workspace.js +0 -2
  138. package/dist/services/operate/workspace.js.map +1 -1
  139. package/dist/services/runtime-manager-service.js +2 -2
  140. package/dist/services/runtime-manager-service.js.map +1 -1
  141. package/package.json +3 -3
  142. package/dist/services/operate/evidence-classifications.d.ts +0 -59
  143. package/dist/services/operate/evidence-classifications.d.ts.map +0 -1
  144. package/dist/services/operate/evidence-classifications.js +0 -132
  145. package/dist/services/operate/evidence-classifications.js.map +0 -1
  146. package/dist/services/operate/evidence-diagnostics.d.ts +0 -23
  147. package/dist/services/operate/evidence-diagnostics.d.ts.map +0 -1
  148. package/dist/services/operate/evidence-diagnostics.js +0 -152
  149. package/dist/services/operate/evidence-diagnostics.js.map +0 -1
  150. package/dist/services/operate/evidence-readiness.d.ts +0 -8
  151. package/dist/services/operate/evidence-readiness.d.ts.map +0 -1
  152. package/dist/services/operate/evidence-readiness.js +0 -84
  153. package/dist/services/operate/evidence-readiness.js.map +0 -1
@@ -1,47 +1,3 @@
1
- import { lstat, mkdir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { loadConfig } from '../config-service.js';
4
- import { resolveApiKey } from '../credentials-service.js';
5
- import { canonicalDigest, canonicalize } from './canonical.js';
6
- import { OperatingEventStore } from './event-store.js';
7
- import { createEvidenceDiagnostic } from './evidence-diagnostics.js';
8
- import { readImportedEvidenceFile } from './evidence-import.js';
9
- import { assertOperatingArtifact } from './protocol.js';
10
- import { executeGitHubReadOnly, executeGitReadOnly, executeLinearReadOnlyQuery, executeRestReadOnlyJson, } from './read-only-providers.js';
11
- import { compareSensitivity, detectSecretMetadata, sanitizeEvidenceItem } from './redaction.js';
12
- import { OPERATE_PROTOCOL_VERSION, OPERATE_SCHEMA_VERSION, OperateError, } from './types.js';
13
- import { isPathInside, resolveOperatingPaths } from './workspace.js';
14
- const TEXT_EXTENSIONS = new Set([
15
- '.c',
16
- '.cc',
17
- '.css',
18
- '.csv',
19
- '.go',
20
- '.graphql',
21
- '.h',
22
- '.html',
23
- '.java',
24
- '.js',
25
- '.json',
26
- '.jsx',
27
- '.kt',
28
- '.md',
29
- '.mjs',
30
- '.py',
31
- '.rb',
32
- '.rs',
33
- '.sh',
34
- '.sql',
35
- '.swift',
36
- '.toml',
37
- '.ts',
38
- '.tsx',
39
- '.yaml',
40
- '.yml',
41
- ]);
42
- function evidenceId(source, identity) {
43
- return `EVD-${source}-${canonicalDigest(identity).slice('sha256:'.length, 19)}`;
44
- }
45
1
  export function evidenceFingerprintItems(items) {
46
2
  return items
47
3
  .map(({ id, digest, sensitivity }) => ({ id, digest, sensitivity }))
@@ -49,1037 +5,6 @@ export function evidenceFingerprintItems(items) {
49
5
  left.digest.localeCompare(right.digest) ||
50
6
  left.sensitivity.localeCompare(right.sensitivity));
51
7
  }
52
- function ageFreshness(collectedAt, now, ttlHours = 168) {
53
- return now.getTime() - Date.parse(collectedAt) <= ttlHours * 60 * 60 * 1_000 ? 'fresh' : 'stale';
54
- }
55
- async function readRoots(projectRoot, localRoot) {
56
- const candidates = [projectRoot, await realpath(projectRoot).catch(() => projectRoot)];
57
- for (const candidate of [...new Set(candidates)]) {
58
- try {
59
- return JSON.parse(await readFile(resolveOperatingPaths(candidate, { localRoot }).roots, 'utf8'));
60
- }
61
- catch {
62
- // Invocation paths can differ from their canonical real path on systems
63
- // such as macOS where /var resolves through /private/var.
64
- }
65
- }
66
- throw new OperateError('E_OPERATE_NOT_INITIALIZED', 'Machine-local workspace roots are missing; run `planr operate init` again.');
67
- }
68
- function descriptorById(workspace) {
69
- return new Map([workspace.controlRepository, ...workspace.components].map((entry) => [
70
- entry.componentId,
71
- entry,
72
- ]));
73
- }
74
- async function repositoryItems(roots, workspace, budgets, now, options) {
75
- const descriptors = descriptorById(workspace);
76
- const results = [];
77
- let files = 0;
78
- let bytes = 0;
79
- let truncated = false;
80
- for (const [componentId, root] of Object.entries(roots.roots).sort()) {
81
- if (Date.now() >= options.deadline) {
82
- truncated = true;
83
- break;
84
- }
85
- const descriptor = descriptors.get(componentId);
86
- if (!descriptor)
87
- continue;
88
- const canonicalRoot = await realpath(root);
89
- const names = (await executeGitReadOnly(canonicalRoot, ['ls-files', '-z']))
90
- .split('\0')
91
- .filter(Boolean)
92
- .filter((name) => TEXT_EXTENSIONS.has(path.extname(name).toLowerCase()))
93
- .sort();
94
- for (const relativePath of names) {
95
- if (Date.now() >= options.deadline ||
96
- files >= budgets.maxFiles ||
97
- results.length >= budgets.maxItems) {
98
- truncated = true;
99
- break;
100
- }
101
- const isPlanr = relativePath.startsWith('.planr/');
102
- if ((!options.includeRepository && !isPlanr) ||
103
- (isPlanr && !options.includePlanr && !options.includeRepository)) {
104
- continue;
105
- }
106
- const target = path.join(canonicalRoot, relativePath);
107
- const directInfo = await lstat(target).catch(() => null);
108
- if (!directInfo)
109
- continue;
110
- const resolvedTarget = await realpath(target).catch(() => null);
111
- if (!resolvedTarget)
112
- continue;
113
- if (!isPathInside(canonicalRoot, resolvedTarget)) {
114
- throw new OperateError('E_OPERATE_PATH_ESCAPE', `Tracked evidence path follows a symlink outside its repository: ${componentId}/${relativePath}.`);
115
- }
116
- if (directInfo.isSymbolicLink())
117
- continue;
118
- const info = await stat(resolvedTarget).catch(() => null);
119
- if (!info?.isFile() || info.size > budgets.maxItemBytes)
120
- continue;
121
- if (bytes + info.size > budgets.maxBytes) {
122
- truncated = true;
123
- break;
124
- }
125
- const content = await readFile(resolvedTarget, 'utf8').catch(() => null);
126
- if (content === null || content.includes('\0'))
127
- continue;
128
- files += 1;
129
- bytes += Buffer.byteLength(content);
130
- const collectedAt = now.toISOString();
131
- const repository = {
132
- componentId,
133
- canonicalRemote: `https://${descriptor.canonicalRemote}`,
134
- revision: descriptor.pinnedRevision,
135
- configuredBranch: descriptor.configuredBranch,
136
- dirtyFingerprint: descriptor.dirtyFingerprint,
137
- relativePath,
138
- digest: canonicalDigest(content),
139
- freshness: ageFreshness(collectedAt, now),
140
- sensitivity: 'internal',
141
- collectedAt,
142
- };
143
- if (options.includeRepository && results.length < budgets.maxItems) {
144
- results.push({
145
- id: evidenceId('repository', `${componentId}:${relativePath}:${repository.digest}`),
146
- source: 'repository',
147
- location: `${componentId}/${relativePath}`,
148
- content,
149
- collectedAt,
150
- observedFrom: null,
151
- observedTo: collectedAt,
152
- freshness: repository.freshness,
153
- sensitivity: repository.sensitivity,
154
- claimTypes: ['code', 'architecture'],
155
- quality: 'observed',
156
- coverage: 'partial',
157
- repository,
158
- });
159
- }
160
- if (isPlanr && options.includePlanr && results.length < budgets.maxItems) {
161
- const planrClaims = [
162
- 'planning',
163
- ...(relativePath.includes('/stories/') || relativePath.includes('/specs/')
164
- ? ['user-journey']
165
- : []),
166
- ...(relativePath.includes('outcome') || relativePath.includes('provenance')
167
- ? ['outcome']
168
- : []),
169
- ...(relativePath.includes('/support') ? ['support', 'operations'] : []),
170
- ];
171
- results.push({
172
- id: evidenceId('planr', `${componentId}:${relativePath}:${repository.digest}`),
173
- source: 'planr',
174
- location: `${componentId}/${relativePath}`,
175
- content,
176
- collectedAt,
177
- observedFrom: null,
178
- observedTo: collectedAt,
179
- freshness: repository.freshness,
180
- sensitivity: repository.sensitivity,
181
- claimTypes: planrClaims,
182
- quality: 'verified',
183
- coverage: 'partial',
184
- repository,
185
- });
186
- }
187
- }
188
- }
189
- return { items: results, truncated };
190
- }
191
- async function gitItems(roots, workspace, now, deadline) {
192
- const descriptors = descriptorById(workspace);
193
- const results = [];
194
- for (const [componentId, root] of Object.entries(roots.roots).sort()) {
195
- const remaining = deadline - Date.now();
196
- if (remaining <= 0)
197
- return { items: results, truncated: true };
198
- const descriptor = descriptors.get(componentId);
199
- if (!descriptor)
200
- continue;
201
- // An unborn Git branch has no history to cite. The workspace manifest uses
202
- // Git's null OID for that state; do not manufacture a change-history item
203
- // that could incorrectly satisfy an advisor readiness requirement.
204
- if (/^0{40,64}$/u.test(descriptor.pinnedRevision))
205
- continue;
206
- const content = await executeGitReadOnly(root, [
207
- 'log',
208
- '--since=30.days',
209
- '--max-count=200',
210
- '--date=iso-strict',
211
- '--pretty=format:%H%x09%ad%x09%s',
212
- ], { timeoutMs: remaining });
213
- const collectedAt = now.toISOString();
214
- results.push({
215
- id: evidenceId('git', `${componentId}:${descriptor.pinnedRevision}:${content}`),
216
- source: 'git',
217
- location: `${componentId}/history`,
218
- content,
219
- collectedAt,
220
- observedFrom: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1_000).toISOString(),
221
- observedTo: collectedAt,
222
- freshness: 'fresh',
223
- sensitivity: 'internal',
224
- claimTypes: ['change-history'],
225
- quality: 'verified',
226
- coverage: 'partial',
227
- repository: {
228
- componentId,
229
- canonicalRemote: `https://${descriptor.canonicalRemote}`,
230
- revision: descriptor.pinnedRevision,
231
- configuredBranch: descriptor.configuredBranch,
232
- dirtyFingerprint: descriptor.dirtyFingerprint,
233
- relativePath: '.git',
234
- digest: canonicalDigest(content),
235
- freshness: 'fresh',
236
- sensitivity: 'internal',
237
- collectedAt,
238
- },
239
- });
240
- }
241
- return { items: results, truncated: false };
242
- }
243
- function boundedRecordsContent(key, records, maxBytes) {
244
- const selected = [...records];
245
- let content = canonicalize({ [key]: selected });
246
- while (selected.length > 0 && Buffer.byteLength(content) > maxBytes) {
247
- selected.pop();
248
- content = canonicalize({ [key]: selected });
249
- }
250
- if (Buffer.byteLength(content) > maxBytes) {
251
- throw new OperateError('E_OPERATE_EVIDENCE_REJECTED', `${key} evidence cannot fit inside the configured item limit.`);
252
- }
253
- return {
254
- content,
255
- records: selected.length,
256
- truncated: selected.length < records.length,
257
- };
258
- }
259
- function githubIdentity(canonicalRemote) {
260
- let url;
261
- try {
262
- url = new URL(canonicalRemote.includes('://') ? canonicalRemote : `https://${canonicalRemote}`);
263
- }
264
- catch {
265
- return null;
266
- }
267
- if (url.protocol !== 'https:' ||
268
- url.hostname.toLowerCase() !== 'github.com' ||
269
- url.username ||
270
- url.password ||
271
- (url.port && url.port !== '443')) {
272
- return null;
273
- }
274
- const segments = url.pathname
275
- .replace(/\.git$/i, '')
276
- .split('/')
277
- .filter(Boolean);
278
- if (segments.length !== 2 || segments.some((segment) => !/^[A-Za-z0-9_.-]+$/.test(segment))) {
279
- return null;
280
- }
281
- return { owner: segments[0], repository: segments[1] };
282
- }
283
- async function githubPages(input) {
284
- const results = [];
285
- const pageSize = Math.min(100, Math.max(1, input.maxRecords));
286
- const pageLimit = Math.min(5, Math.ceil(input.maxRecords / pageSize));
287
- if (input.maxBytes < 1_024 || pageLimit < 1)
288
- return results;
289
- const perPageBytes = Math.max(1_024, Math.floor(input.maxBytes / pageLimit));
290
- for (let page = 1; page <= pageLimit && results.length < input.maxRecords; page += 1) {
291
- const remaining = input.deadline - Date.now();
292
- if (remaining <= 0)
293
- break;
294
- const endpoint = new URL(input.endpoint);
295
- endpoint.searchParams.set('per_page', String(pageSize));
296
- endpoint.searchParams.set('page', String(page));
297
- const response = await executeRestReadOnlyJson(endpoint, {
298
- method: 'GET',
299
- allowedHosts: ['api.github.com'],
300
- timeoutMs: remaining,
301
- maxBytes: Math.min(perPageBytes, 5 * 1024 * 1024),
302
- fetchImpl: input.fetchImpl,
303
- headers: {
304
- accept: 'application/vnd.github+json',
305
- authorization: `Bearer ${input.token}`,
306
- 'x-github-api-version': '2022-11-28',
307
- },
308
- });
309
- const pageRecords = input.select(response);
310
- const rawPageRecords = Array.isArray(response)
311
- ? response.length
312
- : response &&
313
- typeof response === 'object' &&
314
- Array.isArray(response.check_runs)
315
- ? response.check_runs.length
316
- : pageRecords.length;
317
- results.push(...pageRecords.slice(0, input.maxRecords - results.length));
318
- if (rawPageRecords < pageSize)
319
- break;
320
- }
321
- return results;
322
- }
323
- export async function collectGitHubEvidence(input) {
324
- const token = input.token?.trim() ||
325
- process.env.GH_TOKEN?.trim() ||
326
- process.env.GITHUB_TOKEN?.trim() ||
327
- (await executeGitHubReadOnly(input.projectRoot, ['auth', 'token', '--hostname', 'github.com'], { allowedHosts: ['github.com'], timeoutMs: input.deadline - Date.now() })).trim();
328
- if (!token) {
329
- throw new OperateError('E_OPERATE_EVIDENCE_REJECTED', 'GitHub credentials are unavailable.');
330
- }
331
- const descriptors = [input.workspace.controlRepository, ...input.workspace.components];
332
- const items = [];
333
- let truncated = false;
334
- let remainingBytes = input.budgets.maxBytes;
335
- const maxRecords = Math.min(100, Math.max(1, Math.floor(input.budgets.maxItems / 4)));
336
- const categoryResponseBytes = Math.max(1_024, Math.floor(input.budgets.maxBytes / 4));
337
- for (const descriptor of descriptors) {
338
- const identity = githubIdentity(descriptor.canonicalRemote);
339
- if (!identity)
340
- continue;
341
- if (Date.now() >= input.deadline || items.length >= input.budgets.maxItems) {
342
- truncated = true;
343
- break;
344
- }
345
- const base = `https://api.github.com/repos/${encodeURIComponent(identity.owner)}/${encodeURIComponent(identity.repository)}`;
346
- const categories = [
347
- {
348
- id: 'issues',
349
- endpoint: `${base}/issues?state=all`,
350
- claims: ['customer-signal', 'delivery'],
351
- select: (value) => (Array.isArray(value) ? value : [])
352
- .filter((record) => Boolean(record) && typeof record === 'object' && !('pull_request' in record))
353
- .map((record) => ({
354
- number: record.number ?? null,
355
- title: record.title ?? null,
356
- state: record.state ?? null,
357
- createdAt: record.created_at ?? null,
358
- updatedAt: record.updated_at ?? null,
359
- labels: Array.isArray(record.labels)
360
- ? record.labels
361
- .map((label) => label && typeof label === 'object'
362
- ? label.name
363
- : label)
364
- .filter((label) => typeof label === 'string')
365
- .sort()
366
- : [],
367
- })),
368
- },
369
- {
370
- id: 'pull-requests',
371
- endpoint: `${base}/pulls?state=all`,
372
- claims: ['delivery', 'change-history'],
373
- select: (value) => (Array.isArray(value) ? value : [])
374
- .filter((record) => Boolean(record) && typeof record === 'object')
375
- .map((record) => ({
376
- number: record.number ?? null,
377
- title: record.title ?? null,
378
- state: record.state ?? null,
379
- draft: record.draft ?? null,
380
- createdAt: record.created_at ?? null,
381
- updatedAt: record.updated_at ?? null,
382
- mergedAt: record.merged_at ?? null,
383
- })),
384
- },
385
- {
386
- id: 'releases',
387
- endpoint: `${base}/releases`,
388
- claims: ['delivery', 'change-history'],
389
- select: (value) => (Array.isArray(value) ? value : [])
390
- .filter((record) => Boolean(record) && typeof record === 'object')
391
- .map((record) => ({
392
- id: record.id ?? null,
393
- tag: record.tag_name ?? null,
394
- name: record.name ?? null,
395
- draft: record.draft ?? null,
396
- prerelease: record.prerelease ?? null,
397
- createdAt: record.created_at ?? null,
398
- publishedAt: record.published_at ?? null,
399
- })),
400
- },
401
- {
402
- id: 'checks',
403
- endpoint: `${base}/commits/${encodeURIComponent(descriptor.pinnedRevision)}/check-runs`,
404
- claims: ['delivery', 'reliability'],
405
- select: (value) => {
406
- if (!value || typeof value !== 'object')
407
- return [];
408
- const records = value.check_runs;
409
- return (Array.isArray(records) ? records : [])
410
- .filter((record) => Boolean(record) && typeof record === 'object')
411
- .map((record) => ({
412
- id: record.id ?? null,
413
- name: record.name ?? null,
414
- status: record.status ?? null,
415
- conclusion: record.conclusion ?? null,
416
- startedAt: record.started_at ?? null,
417
- completedAt: record.completed_at ?? null,
418
- }));
419
- },
420
- },
421
- ];
422
- for (const category of categories) {
423
- if (Date.now() >= input.deadline ||
424
- items.length >= input.budgets.maxItems ||
425
- remainingBytes < 1_024) {
426
- truncated = true;
427
- break;
428
- }
429
- const records = (await githubPages({
430
- token,
431
- endpoint: category.endpoint,
432
- deadline: input.deadline,
433
- maxRecords,
434
- maxBytes: Math.min(remainingBytes, categoryResponseBytes),
435
- fetchImpl: input.fetchImpl,
436
- select: category.select,
437
- }));
438
- const bounded = boundedRecordsContent(category.id, records, Math.min(input.budgets.maxItemBytes, remainingBytes));
439
- truncated ||= bounded.truncated || records.length >= maxRecords;
440
- remainingBytes -= Buffer.byteLength(bounded.content);
441
- if (bounded.records === 0)
442
- continue;
443
- const collectedAt = input.now.toISOString();
444
- items.push({
445
- id: evidenceId('github', `${descriptor.componentId}:${category.id}:${bounded.content}`),
446
- source: 'github',
447
- location: `${descriptor.componentId}/${category.id}`,
448
- content: bounded.content,
449
- collectedAt,
450
- observedFrom: null,
451
- observedTo: collectedAt,
452
- freshness: 'fresh',
453
- sensitivity: 'confidential',
454
- claimTypes: category.claims,
455
- quality: 'observed',
456
- coverage: bounded.truncated ? 'partial' : 'complete',
457
- });
458
- }
459
- }
460
- return { items, truncated };
461
- }
462
- async function linearConnectionPages(input) {
463
- const records = [];
464
- let after = null;
465
- const pageSize = Math.min(100, Math.max(1, input.maxRecords));
466
- const pageLimit = Math.min(5, Math.ceil(input.maxRecords / pageSize));
467
- const perPageBytes = Math.max(1_024, Math.floor(input.maxBytes / Math.max(1, pageLimit)));
468
- for (let page = 0; page < pageLimit && records.length < input.maxRecords; page += 1) {
469
- const remaining = input.deadline - Date.now();
470
- if (remaining <= 0)
471
- break;
472
- const data = await executeLinearReadOnlyQuery({
473
- token: input.token,
474
- query: input.query,
475
- variables: {
476
- ...input.variables,
477
- first: pageSize,
478
- after,
479
- },
480
- timeoutMs: remaining,
481
- maxBytes: Math.min(perPageBytes, 5 * 1024 * 1024),
482
- allowedHosts: ['api.linear.app'],
483
- fetchImpl: input.fetchImpl,
484
- });
485
- const connection = data[input.connection];
486
- const nodes = Array.isArray(connection?.nodes) ? connection.nodes : [];
487
- records.push(...nodes.slice(0, input.maxRecords - records.length));
488
- if (!connection?.pageInfo?.hasNextPage || !connection.pageInfo.endCursor)
489
- break;
490
- after = connection.pageInfo.endCursor;
491
- }
492
- return records;
493
- }
494
- export async function collectLinearEvidence(input) {
495
- const token = input.token?.trim() || (await resolveApiKey('linear'))?.trim();
496
- if (!token) {
497
- throw new OperateError('E_OPERATE_EVIDENCE_REJECTED', 'Linear credentials are unavailable.');
498
- }
499
- const config = input.teamIds ? null : await loadConfig(input.projectRoot).catch(() => null);
500
- const teamIds = [
501
- ...new Set((input.teamIds ?? [
502
- config?.linear?.teamId,
503
- ...(config?.linear?.teams ?? []).map((team) => team.id),
504
- ]).filter((value) => Boolean(value))),
505
- ];
506
- const maxRecords = Math.min(100, Math.max(1, Math.floor(input.budgets.maxItems / 3)));
507
- const connectionBudget = Math.max(1_024, Math.floor(input.budgets.maxBytes / 3));
508
- const teams = await linearConnectionPages({
509
- token,
510
- query: `query OperatingTeams($first: Int!, $after: String) {
511
- teams(first: $first, after: $after) {
512
- nodes { id key name }
513
- pageInfo { hasNextPage endCursor }
514
- }
515
- }`,
516
- connection: 'teams',
517
- deadline: input.deadline,
518
- maxRecords,
519
- maxBytes: connectionBudget,
520
- fetchImpl: input.fetchImpl,
521
- });
522
- const issues = await linearConnectionPages({
523
- token,
524
- query: teamIds.length > 0
525
- ? `query OperatingIssues($first: Int!, $after: String, $teamIds: [ID!]) {
526
- issues(first: $first, after: $after, filter: { team: { id: { in: $teamIds } } }) {
527
- nodes {
528
- id identifier title updatedAt priority
529
- team { id key }
530
- state { name }
531
- labels { nodes { name } }
532
- }
533
- pageInfo { hasNextPage endCursor }
534
- }
535
- }`
536
- : `query OperatingIssues($first: Int!, $after: String) {
537
- issues(first: $first, after: $after) {
538
- nodes {
539
- id identifier title updatedAt priority
540
- team { id key }
541
- state { name }
542
- labels { nodes { name } }
543
- }
544
- pageInfo { hasNextPage endCursor }
545
- }
546
- }`,
547
- connection: 'issues',
548
- variables: teamIds.length > 0 ? { teamIds } : {},
549
- deadline: input.deadline,
550
- maxRecords,
551
- maxBytes: connectionBudget,
552
- fetchImpl: input.fetchImpl,
553
- });
554
- const projects = await linearConnectionPages({
555
- token,
556
- query: `query OperatingProjects($first: Int!, $after: String) {
557
- projects(first: $first, after: $after) {
558
- nodes {
559
- id name updatedAt
560
- status { name }
561
- teams { nodes { id key name } }
562
- }
563
- pageInfo { hasNextPage endCursor }
564
- }
565
- }`,
566
- connection: 'projects',
567
- deadline: input.deadline,
568
- maxRecords,
569
- maxBytes: connectionBudget,
570
- fetchImpl: input.fetchImpl,
571
- });
572
- const selectedProjects = teamIds.length === 0
573
- ? projects
574
- : projects.filter((project) => (project.teams?.nodes ?? []).some((team) => team.id && teamIds.includes(team.id)));
575
- const records = [
576
- {
577
- id: 'teams',
578
- values: teams.map((team) => ({
579
- id: team.id ?? null,
580
- key: team.key ?? null,
581
- name: team.name ?? null,
582
- })),
583
- claims: ['planning', 'operations'],
584
- },
585
- {
586
- id: 'issues',
587
- values: issues.map((issue) => ({
588
- id: issue.id ?? null,
589
- identifier: issue.identifier ?? null,
590
- title: issue.title ?? null,
591
- updatedAt: issue.updatedAt ?? null,
592
- priority: issue.priority ?? null,
593
- team: issue.team ? { id: issue.team.id ?? null, key: issue.team.key ?? null } : null,
594
- state: issue.state?.name ?? null,
595
- labels: (issue.labels?.nodes ?? [])
596
- .map((label) => label.name)
597
- .filter((label) => Boolean(label))
598
- .sort(),
599
- })),
600
- claims: ['customer-signal', 'delivery', 'planning'],
601
- },
602
- {
603
- id: 'projects',
604
- values: selectedProjects.map((project) => ({
605
- id: project.id ?? null,
606
- name: project.name ?? null,
607
- updatedAt: project.updatedAt ?? null,
608
- status: project.status?.name ?? null,
609
- teams: (project.teams?.nodes ?? [])
610
- .map((team) => ({
611
- id: team.id ?? null,
612
- key: team.key ?? null,
613
- name: team.name ?? null,
614
- }))
615
- .sort((left, right) => String(left.id).localeCompare(String(right.id))),
616
- })),
617
- claims: ['planning', 'operations', 'delivery'],
618
- },
619
- ];
620
- const items = [];
621
- let truncated = false;
622
- let remainingBytes = input.budgets.maxBytes;
623
- for (const record of records) {
624
- if (remainingBytes < 1_024 || items.length >= input.budgets.maxItems) {
625
- truncated = true;
626
- break;
627
- }
628
- const bounded = boundedRecordsContent(record.id, record.values, Math.min(input.budgets.maxItemBytes, remainingBytes));
629
- truncated ||= bounded.truncated || record.values.length >= maxRecords;
630
- remainingBytes -= Buffer.byteLength(bounded.content);
631
- if (bounded.records === 0)
632
- continue;
633
- const collectedAt = input.now.toISOString();
634
- items.push({
635
- id: evidenceId('linear', `${record.id}:${bounded.content}`),
636
- source: 'linear',
637
- location: record.id,
638
- content: bounded.content,
639
- collectedAt,
640
- observedFrom: null,
641
- observedTo: collectedAt,
642
- freshness: 'fresh',
643
- sensitivity: 'confidential',
644
- claimTypes: record.claims,
645
- quality: 'observed',
646
- coverage: bounded.truncated ? 'partial' : 'complete',
647
- });
648
- }
649
- return { items, truncated };
650
- }
651
- async function fileImportItems(projectRoot, roots, budgets, now, deadline, localRoot) {
652
- const preferencesPath = path.join(resolveOperatingPaths(projectRoot, { localRoot }).localRoot, 'preferences.json');
653
- const preferences = JSON.parse(await readFile(preferencesPath, 'utf8'));
654
- const importPaths = Array.isArray(preferences.importPaths)
655
- ? preferences.importPaths.filter((entry) => typeof entry === 'string')
656
- : [];
657
- if (importPaths.length === 0) {
658
- throw new OperateError('E_OPERATE_CONFIG_INVALID', 'The file-import source has no configured evidence paths; rerun `planr operate init --evidence-file <path>`.');
659
- }
660
- const allowedRoots = Object.entries(roots.roots).map(([componentId, root]) => ({
661
- componentId,
662
- root,
663
- }));
664
- const items = [];
665
- let bytes = 0;
666
- let truncated = false;
667
- for (const configuredPath of importPaths) {
668
- if (Date.now() >= deadline ||
669
- items.length >= budgets.maxItems ||
670
- items.length >= budgets.maxFiles) {
671
- truncated = true;
672
- break;
673
- }
674
- const remainingBytes = budgets.maxBytes - bytes;
675
- if (remainingBytes < 1) {
676
- truncated = true;
677
- break;
678
- }
679
- const imported = await readImportedEvidenceFile({
680
- projectRoot,
681
- configuredPath,
682
- roots: allowedRoots,
683
- maxBytes: Math.min(budgets.maxItemBytes, remainingBytes),
684
- });
685
- const commercialMetric = inferImportedCommercialMetric(imported.content, imported.format);
686
- bytes += imported.byteCount;
687
- const collectedAt = now.toISOString();
688
- items.push({
689
- id: evidenceId('file-import', `${imported.location}:${canonicalDigest(imported.content)}`),
690
- source: 'file-import',
691
- location: imported.location,
692
- content: imported.content,
693
- collectedAt,
694
- observedFrom: null,
695
- observedTo: collectedAt,
696
- freshness: 'fresh',
697
- sensitivity: 'internal',
698
- claimTypes: [
699
- 'structured-data',
700
- imported.format,
701
- ...(commercialMetric.commercial ? ['commercial-metric'] : []),
702
- ],
703
- quality: 'self-reported',
704
- coverage: 'complete',
705
- ...(commercialMetric.metric ? { metric: commercialMetric.metric } : {}),
706
- });
707
- }
708
- return { items, truncated };
709
- }
710
- const COMMERCIAL_METRIC_TERMS = /\b(?:activation|arr|arpu|cac|churn|conversion|customers?|gmv|ltv|margin|mrr|price|pricing|retention|revenue|sales|spend|subscriptions?|users?)\b/i;
711
- const NUMERIC_CLAIM = /(?:[$€£]\s*)?\b\d+(?:[.,]\d+)?\s*(?:%|percent|usd|eur|gbp|customers?|users?)?\b/i;
712
- function validIsoWindow(from, to) {
713
- return (typeof from === 'string' &&
714
- typeof to === 'string' &&
715
- Number.isFinite(Date.parse(from)) &&
716
- Number.isFinite(Date.parse(to)) &&
717
- Date.parse(to) >= Date.parse(from));
718
- }
719
- function inferImportedCommercialMetric(content, format) {
720
- const commercial = COMMERCIAL_METRIC_TERMS.test(content) && NUMERIC_CLAIM.test(content);
721
- if (!commercial || format !== 'json')
722
- return { commercial };
723
- try {
724
- const parsed = JSON.parse(content);
725
- const candidate = parsed.metric && typeof parsed.metric === 'object'
726
- ? parsed.metric
727
- : null;
728
- if (!candidate ||
729
- typeof candidate.identity !== 'string' ||
730
- !candidate.identity.trim() ||
731
- typeof candidate.query !== 'string' ||
732
- !candidate.query.trim() ||
733
- !validIsoWindow(candidate.observedFrom, candidate.observedTo)) {
734
- return { commercial };
735
- }
736
- return {
737
- commercial,
738
- metric: {
739
- identity: candidate.identity.trim(),
740
- query: candidate.query.trim(),
741
- observedFrom: candidate.observedFrom,
742
- observedTo: candidate.observedTo,
743
- },
744
- };
745
- }
746
- catch {
747
- return { commercial };
748
- }
749
- }
750
- function incrementalEvidencePath(input, key) {
751
- return path.join(resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot }).evidence, 'incremental', `${key.slice('sha256:'.length)}.json`);
752
- }
753
- /**
754
- * FR4: the board's identity is the hash of its event-chain genesis event. The
755
- * incremental cache key incorporates it so a board re-inited at the same path —
756
- * whose workspace components (remote/branch) are unchanged and would otherwise
757
- * compute an identical key — never reuses a prior generation's baseline. Returns
758
- * '' when no committed chain exists yet or it cannot be read (the collection then
759
- * behaves exactly as before this change).
760
- */
761
- async function evidenceBoardIdentity(input) {
762
- try {
763
- const { events } = await new OperatingEventStore(input.projectRoot, {
764
- localRoot: input.localRoot,
765
- }).replay();
766
- const genesis = events.find((event) => event.previousEventHash === null) ?? events[0];
767
- return genesis?.eventHash ?? '';
768
- }
769
- catch {
770
- return '';
771
- }
772
- }
773
- async function readIncrementalEvidence(input, key) {
774
- if (!input.incremental)
775
- return null;
776
- try {
777
- const record = JSON.parse(await readFile(incrementalEvidencePath(input, key), 'utf8'));
778
- if (record.implementation !== 'openplanr-operate-incremental-evidence' || record.key !== key) {
779
- return null;
780
- }
781
- // FR4: honor the baseline's own already-written workspaceDigest. A record
782
- // whose captured workspace no longer matches the current one is treated as
783
- // no-baseline so the cycle recollects deep, rather than trusting a snapshot
784
- // bound to a superseded workspace (for example, a prior board generation).
785
- if (record.workspaceDigest !== input.workspace.workspaceDigest) {
786
- return null;
787
- }
788
- await assertOperatingArtifact('operating-evidence', record.evidence);
789
- return record;
790
- }
791
- catch {
792
- return null;
793
- }
794
- }
795
- async function writeIncrementalEvidence(input, key, evidence) {
796
- if (!input.persistIncremental ||
797
- input.providers.some((provider) => ['file-import', 'github', 'linear'].includes(provider))) {
798
- return;
799
- }
800
- const target = incrementalEvidencePath(input, key);
801
- await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
802
- const temporary = `${target}.${process.pid}.tmp`;
803
- const record = {
804
- implementation: 'openplanr-operate-incremental-evidence',
805
- key,
806
- workspaceDigest: input.workspace.workspaceDigest,
807
- evidence,
808
- };
809
- await writeFile(temporary, `${canonicalize(record)}\n`, { mode: 0o600 });
810
- await rename(temporary, target);
811
- }
812
- function evidenceLocationIdentity(item) {
813
- return `${item.source}\0${item.location}`;
814
- }
815
- function buildEvidenceDelta(input) {
816
- const baselineItems = input.baseline?.items ?? [];
817
- const baselineByLocation = new Map(baselineItems.map((item) => [evidenceLocationIdentity(item), item]));
818
- const currentByLocation = new Map(input.current.map((item) => [evidenceLocationIdentity(item), item]));
819
- const currentById = new Map(input.current.map((item) => [item.id, item]));
820
- const baselineById = new Map(baselineItems.map((item) => [item.id, item]));
821
- const changedEvidenceRefs = input.current
822
- .filter((item) => {
823
- const prior = baselineByLocation.get(evidenceLocationIdentity(item));
824
- return (!prior ||
825
- prior.digest !== item.digest ||
826
- prior.sensitivity !== item.sensitivity ||
827
- canonicalDigest(prior.metric ?? null) !== canonicalDigest(item.metric ?? null));
828
- })
829
- .map((item) => item.id)
830
- .sort();
831
- const removedEvidenceRefs = baselineItems
832
- .filter((item) => !currentByLocation.has(evidenceLocationIdentity(item)))
833
- .map((item) => item.id)
834
- .sort();
835
- const requiredEvidenceRefs = [
836
- ...new Set(input.requiredEvidenceRefs.flatMap((reference) => {
837
- const current = currentById.get(reference);
838
- if (current)
839
- return [current.id];
840
- const prior = baselineById.get(reference);
841
- if (!prior)
842
- return [];
843
- return [currentByLocation.get(evidenceLocationIdentity(prior))?.id ?? prior.id];
844
- })),
845
- ].sort();
846
- return {
847
- mode: input.incremental ? (input.baseline ? 'standard' : 'baseline') : 'deep',
848
- baselineFingerprint: input.baseline?.fingerprint ?? null,
849
- changedEvidenceRefs,
850
- requiredEvidenceRefs,
851
- selectedEvidenceRefs: [...new Set([...changedEvidenceRefs, ...requiredEvidenceRefs])].sort(),
852
- removedEvidenceRefs,
853
- };
854
- }
855
- export async function collectOperatingEvidence(input) {
856
- const started = Date.now();
857
- const now = input.now ?? new Date();
858
- const deadline = started + input.budgets.maxDurationMs;
859
- const incrementalKey = canonicalDigest({
860
- boardIdentity: await evidenceBoardIdentity(input),
861
- workspaceComponents: [input.workspace.controlRepository, ...input.workspace.components]
862
- .map((component) => ({
863
- componentId: component.componentId,
864
- canonicalRemote: component.canonicalRemote,
865
- configuredBranch: component.configuredBranch,
866
- }))
867
- .sort((left, right) => left.componentId.localeCompare(right.componentId)),
868
- providers: [...new Set(input.providers)].sort(),
869
- sensitivityCeiling: input.sensitivityCeiling,
870
- budgets: input.budgets,
871
- });
872
- const baselineRecord = await readIncrementalEvidence(input, incrementalKey);
873
- const roots = await readRoots(input.projectRoot, input.localRoot);
874
- const raw = [];
875
- const warnings = [];
876
- let truncated = false;
877
- if (input.providers.includes('repository') || input.providers.includes('planr')) {
878
- const repository = await repositoryItems(roots, input.workspace, input.budgets, now, {
879
- includeRepository: input.providers.includes('repository'),
880
- includePlanr: input.providers.includes('planr'),
881
- deadline,
882
- });
883
- raw.push(...repository.items);
884
- truncated ||= repository.truncated;
885
- }
886
- if (input.providers.includes('git') && Date.now() < deadline) {
887
- const git = await gitItems(roots, input.workspace, now, deadline);
888
- raw.push(...git.items);
889
- truncated ||= git.truncated;
890
- }
891
- if (input.providers.includes('github') && Date.now() < deadline) {
892
- try {
893
- const github = await collectGitHubEvidence({
894
- projectRoot: input.projectRoot,
895
- workspace: input.workspace,
896
- now,
897
- deadline,
898
- budgets: input.budgets,
899
- token: input.remote?.githubToken,
900
- fetchImpl: input.remote?.fetchImpl,
901
- });
902
- raw.push(...github.items);
903
- truncated ||= github.truncated;
904
- if (github.items.length === 0) {
905
- warnings.push('GitHub evidence found no supported repositories or records.');
906
- }
907
- }
908
- catch {
909
- warnings.push('GitHub evidence is unavailable or not authenticated.');
910
- }
911
- }
912
- if (input.providers.includes('linear') && Date.now() < deadline) {
913
- try {
914
- const linear = await collectLinearEvidence({
915
- projectRoot: input.projectRoot,
916
- now,
917
- deadline,
918
- budgets: input.budgets,
919
- token: input.remote?.linearToken,
920
- fetchImpl: input.remote?.fetchImpl,
921
- });
922
- raw.push(...linear.items);
923
- truncated ||= linear.truncated;
924
- if (linear.items.length === 0) {
925
- warnings.push('Linear evidence returned no configured teams, issues, or projects.');
926
- }
927
- }
928
- catch {
929
- warnings.push('Linear evidence is unavailable, not configured, or not authenticated.');
930
- }
931
- }
932
- if (input.providers.includes('file-import') && Date.now() < deadline) {
933
- const imported = await fileImportItems(input.projectRoot, roots, input.budgets, now, deadline, input.localRoot);
934
- raw.push(...imported.items);
935
- truncated ||= imported.truncated;
936
- }
937
- const rejected = raw.filter((item) => compareSensitivity(item.sensitivity, input.sensitivityCeiling) > 0);
938
- if (rejected.length > 0) {
939
- warnings.push(`${rejected.length} evidence item(s) exceeded the configured sensitivity ceiling.`);
940
- }
941
- const flattened = [];
942
- for (const item of raw
943
- .filter((candidate) => compareSensitivity(candidate.sensitivity, input.sensitivityCeiling) <= 0)
944
- .slice(0, input.budgets.maxItems)) {
945
- try {
946
- flattened.push(sanitizeEvidenceItem(item));
947
- }
948
- catch (error) {
949
- if (!(error instanceof OperateError) || error.code !== 'E_OPERATE_SECRET_DETECTED') {
950
- throw error;
951
- }
952
- const details = error.details ?? {};
953
- const fallback = detectSecretMetadata(item.content)[0];
954
- const ruleId = typeof details.ruleId === 'string' ? details.ruleId : (fallback?.ruleId ?? 'unknown.v1');
955
- const category = typeof details.category === 'string'
956
- ? details.category
957
- : (fallback?.category ?? 'structured-secret');
958
- const diagnostic = await createEvidenceDiagnostic({
959
- projectRoot: input.projectRoot,
960
- item,
961
- localRoot: input.localRoot,
962
- detection: {
963
- ruleId,
964
- category,
965
- line: typeof details.line === 'number' && Number.isInteger(details.line)
966
- ? details.line
967
- : (fallback?.line ?? 1),
968
- hardBlock: typeof details.hardBlock === 'boolean'
969
- ? details.hardBlock
970
- : (fallback?.hardBlock ?? false),
971
- },
972
- });
973
- if (diagnostic.classification?.status === 'false-positive') {
974
- warnings.push(`Evidence candidate ${diagnostic.candidateId} was omitted under its exact false-positive classification.`);
975
- continue;
976
- }
977
- // Quarantine the item instead of aborting the entire evidence snapshot.
978
- // The immutable role-filtered packs never receive its bytes. Readiness
979
- // evaluation below decides whether enough eligible evidence remains for
980
- // each lens; an affected lens becomes not_evaluated with a governed gap
981
- // rather than preventing unrelated lenses from producing useful work.
982
- warnings.push([
983
- `Evidence candidate ${diagnostic.candidateId} was quarantined`,
984
- `(${diagnostic.category}); its value was not persisted or dispatched.`,
985
- `Inspect with: planr operate evidence diagnose ${diagnostic.candidateId} --json`,
986
- ].join(' '));
987
- }
988
- }
989
- if (Date.now() >= deadline) {
990
- truncated = true;
991
- warnings.push('Evidence collection reached the configured duration budget.');
992
- }
993
- const sourceIds = [...new Set(input.providers)].sort();
994
- const delta = buildEvidenceDelta({
995
- incremental: Boolean(input.incremental),
996
- current: flattened,
997
- baseline: baselineRecord?.evidence ?? null,
998
- requiredEvidenceRefs: input.requiredEvidenceRefs ?? [],
999
- });
1000
- const baselineSources = new Map((baselineRecord?.evidence.sources ?? []).map((source) => [source.id, source]));
1001
- const sources = sourceIds.map((id) => {
1002
- const items = flattened.filter((item) => item.source === id);
1003
- const fingerprint = canonicalDigest(evidenceFingerprintItems(items));
1004
- const prior = baselineSources.get(id);
1005
- return {
1006
- id,
1007
- fingerprint,
1008
- status: items.length === 0
1009
- ? 'unavailable'
1010
- : delta.mode === 'standard' && prior?.fingerprint === fingerprint
1011
- ? 'unchanged'
1012
- : delta.mode === 'standard'
1013
- ? 'partial'
1014
- : 'collected',
1015
- itemCount: items.length,
1016
- byteCount: items.reduce((sum, item) => sum + Buffer.byteLength(item.summary ?? ''), 0),
1017
- };
1018
- });
1019
- if (delta.mode === 'standard') {
1020
- warnings.push(`Standard evidence delta selected ${delta.changedEvidenceRefs.length} changed and ${delta.requiredEvidenceRefs.length} open-item-required evidence item(s).`);
1021
- }
1022
- const evidence = {
1023
- kind: 'operating-evidence',
1024
- schemaVersion: OPERATE_SCHEMA_VERSION,
1025
- protocolVersion: OPERATE_PROTOCOL_VERSION,
1026
- cycleId: input.cycleId,
1027
- fingerprint: canonicalDigest({
1028
- workspaceDigest: input.workspace.workspaceDigest,
1029
- items: evidenceFingerprintItems(flattened),
1030
- sources,
1031
- delta,
1032
- }),
1033
- collectedAt: now.toISOString(),
1034
- truncated: truncated ||
1035
- flattened.length >= input.budgets.maxItems ||
1036
- sources.reduce((sum, source) => sum + source.byteCount, 0) >= input.budgets.maxBytes,
1037
- items: flattened,
1038
- delta,
1039
- sources,
1040
- warnings,
1041
- };
1042
- const validated = await assertOperatingArtifact('operating-evidence', evidence);
1043
- await writeIncrementalEvidence(input, incrementalKey, validated);
1044
- return validated;
1045
- }
1046
- /**
1047
- * Convert commercial numbers without reproducible query/window identity into
1048
- * governed gaps before any advisor can turn them into recommendations.
1049
- */
1050
- export async function unqualifiedCommercialEvidenceGaps(input) {
1051
- const unqualified = input.evidence.items
1052
- .filter((item) => item.claimTypes.includes('commercial-metric') &&
1053
- (!item.metric?.identity.trim() ||
1054
- !item.metric.query.trim() ||
1055
- !validIsoWindow(item.metric.observedFrom, item.metric.observedTo)))
1056
- .sort((left, right) => left.source.localeCompare(right.source) ||
1057
- left.location.localeCompare(right.location) ||
1058
- left.id.localeCompare(right.id));
1059
- const gaps = unqualified.map((item, index) => ({
1060
- kind: 'operating-data-gap',
1061
- schemaVersion: OPERATE_SCHEMA_VERSION,
1062
- protocolVersion: OPERATE_PROTOCOL_VERSION,
1063
- id: `GAP-${String(index + 1).padStart(3, '0')}`,
1064
- cycleId: input.cycleId,
1065
- question: `What reproducible metric query and observation window qualify ${item.location}?`,
1066
- reason: 'Numeric commercial evidence requires a metric identity, query identity, and bounded observation window before it can support an operating recommendation.',
1067
- unblocks: [],
1068
- affectedRoles: [
1069
- 'strategy-finance',
1070
- 'product-activation',
1071
- 'growth-market',
1072
- 'operations-customer',
1073
- ],
1074
- status: 'open',
1075
- owner: input.owner,
1076
- evidenceRefs: [item.id],
1077
- createdAt: input.now,
1078
- updatedAt: input.now,
1079
- }));
1080
- await Promise.all(gaps.map((gap) => assertOperatingArtifact('operating-data-gap', gap)));
1081
- return gaps;
1082
- }
1083
8
  export function evidenceProjectionSources(evidence) {
1084
9
  return evidence.sources.map((source) => ({
1085
10
  id: source.id,
@@ -1092,123 +17,4 @@ export function evidenceProjectionSources(evidence) {
1092
17
  itemCount: source.itemCount,
1093
18
  }));
1094
19
  }
1095
- // The Protocol v1.3 index-item schema pins these patterns; every index item we
1096
- // emit must satisfy them or the pipeline's mission-packet validator fails
1097
- // closed. Building here — rather than trusting the caller — keeps a malformed
1098
- // path or revision out of a dispatched packet.
1099
- const INDEX_PATH_PATTERN = /^(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
1100
- const INDEX_REVISION_PATTERN = /^[a-f0-9]{7,64}$/;
1101
- const INDEX_SOURCE_CLASSIFICATION = {
1102
- repository: 'source-code',
1103
- planr: 'planning-artifact',
1104
- git: 'change-history',
1105
- 'file-import': 'imported-record',
1106
- github: 'integration-record',
1107
- linear: 'integration-record',
1108
- };
1109
- /**
1110
- * Derive a stable, schema-conformant index-item id (`EVX-…`) from the source
1111
- * evidence id. The evidence ids are unique, so the derived ids are too.
1112
- */
1113
- function evidenceIndexItemId(evidenceId) {
1114
- return `EVX-${canonicalDigest(evidenceId).slice('sha256:'.length, 27)}`;
1115
- }
1116
- /**
1117
- * Strip the leading component id from an evidence `location`
1118
- * (`<componentId>/<relativePath>`) to recover the repository-relative path, or
1119
- * return null when the result cannot be a schema-valid index path (for example
1120
- * dot-prefixed `.planr/…` paths, which the pattern forbids).
1121
- */
1122
- function evidenceIndexPath(location) {
1123
- const relative = location.split('/').slice(1).join('/');
1124
- return relative && INDEX_PATH_PATTERN.test(relative) ? relative : null;
1125
- }
1126
- function evidenceIndexSignals(item) {
1127
- const signals = new Set();
1128
- for (const claim of item.claimTypes) {
1129
- const trimmed = claim.trim();
1130
- if (trimmed)
1131
- signals.add(trimmed.slice(0, 256));
1132
- }
1133
- if (item.freshness === 'stale')
1134
- signals.add('stale-evidence');
1135
- if (item.repository?.dirtyFingerprint)
1136
- signals.add('uncommitted-working-tree');
1137
- return [...signals].sort();
1138
- }
1139
- /**
1140
- * Build Protocol v1.3 evidence index items (`operating-evidence-index-item@1.3.0`)
1141
- * from a collected evidence snapshot for mission-mode dispatch. Each item is a
1142
- * pointer only — path OR revision, content hash, source, classification,
1143
- * freshness, sensitivity, and detected signals — and carries NO file body. Git
1144
- * evidence is referenced by its pinned revision; repository/planr/file-import
1145
- * evidence by its repository-relative path. Items that cannot be represented as
1146
- * a schema-valid path or revision pointer (github/linear records, dot-prefixed
1147
- * planr paths) are omitted from the index rather than emitted malformed.
1148
- */
1149
- export function buildOperatingEvidenceIndex(evidence, options = {}) {
1150
- const seen = new Set();
1151
- const items = [];
1152
- for (const item of evidence.items) {
1153
- if (options.sensitivityCeiling &&
1154
- compareSensitivity(item.sensitivity, options.sensitivityCeiling) > 0) {
1155
- continue;
1156
- }
1157
- const source = item.source;
1158
- const classification = INDEX_SOURCE_CLASSIFICATION[source];
1159
- if (!classification)
1160
- continue;
1161
- const base = {
1162
- id: evidenceIndexItemId(item.id),
1163
- contentHash: item.digest,
1164
- source,
1165
- classification,
1166
- freshness: item.freshness,
1167
- sensitivity: item.sensitivity,
1168
- signals: evidenceIndexSignals(item),
1169
- };
1170
- if (seen.has(base.id))
1171
- continue;
1172
- if (source === 'git') {
1173
- const revision = item.repository?.revision;
1174
- if (revision && INDEX_REVISION_PATTERN.test(revision)) {
1175
- seen.add(base.id);
1176
- items.push({ ...base, revision });
1177
- }
1178
- continue;
1179
- }
1180
- if (source === 'repository' || source === 'planr' || source === 'file-import') {
1181
- const relativePath = evidenceIndexPath(item.location);
1182
- if (relativePath) {
1183
- seen.add(base.id);
1184
- items.push({ ...base, path: relativePath });
1185
- }
1186
- }
1187
- }
1188
- return items.sort((left, right) => {
1189
- if (left.source !== right.source)
1190
- return left.source < right.source ? -1 : 1;
1191
- const leftRef = left.path ?? left.revision ?? '';
1192
- const rightRef = right.path ?? right.revision ?? '';
1193
- if (leftRef !== rightRef)
1194
- return leftRef < rightRef ? -1 : 1;
1195
- return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
1196
- });
1197
- }
1198
- /**
1199
- * The read roots a mission agent may traverse, derived from the top-level
1200
- * directory segment of every path-referenced index item. Schema-valid and
1201
- * deduplicated; empty when the index carries only revision pointers.
1202
- */
1203
- export function deriveOperatingDeclaredRoots(index) {
1204
- const roots = new Set();
1205
- for (const item of index) {
1206
- if (!item.path)
1207
- continue;
1208
- const top = item.path.split('/')[0];
1209
- if (top && INDEX_PATH_PATTERN.test(top))
1210
- roots.add(top);
1211
- }
1212
- return [...roots].sort();
1213
- }
1214
20
  //# sourceMappingURL=evidence.js.map