openplanr 1.19.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 (145) 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 +144 -166
  53. package/dist/services/operate/advisors.d.ts.map +1 -1
  54. package/dist/services/operate/advisors.js +178 -543
  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 +18 -52
  60. package/dist/services/operate/config.d.ts.map +1 -1
  61. package/dist/services/operate/config.js +24 -206
  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 -78
  68. package/dist/services/operate/engine.d.ts.map +1 -1
  69. package/dist/services/operate/engine.js +269 -202
  70. package/dist/services/operate/engine.js.map +1 -1
  71. package/dist/services/operate/evidence.d.ts +9 -107
  72. package/dist/services/operate/evidence.d.ts.map +1 -1
  73. package/dist/services/operate/evidence.js +0 -1403
  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 +10 -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/maintenance.d.ts +1 -11
  100. package/dist/services/operate/maintenance.d.ts.map +1 -1
  101. package/dist/services/operate/maintenance.js +94 -342
  102. package/dist/services/operate/maintenance.js.map +1 -1
  103. package/dist/services/operate/mission-dispatch.d.ts +73 -55
  104. package/dist/services/operate/mission-dispatch.d.ts.map +1 -1
  105. package/dist/services/operate/mission-dispatch.js +91 -67
  106. package/dist/services/operate/mission-dispatch.js.map +1 -1
  107. package/dist/services/operate/projection-persistence.d.ts.map +1 -1
  108. package/dist/services/operate/projection-persistence.js +22 -2
  109. package/dist/services/operate/projection-persistence.js.map +1 -1
  110. package/dist/services/operate/projection.d.ts.map +1 -1
  111. package/dist/services/operate/projection.js +10 -3
  112. package/dist/services/operate/projection.js.map +1 -1
  113. package/dist/services/operate/protocol.d.ts +1 -41
  114. package/dist/services/operate/protocol.d.ts.map +1 -1
  115. package/dist/services/operate/protocol.js +7 -45
  116. package/dist/services/operate/protocol.js.map +1 -1
  117. package/dist/services/operate/redaction.d.ts +3 -3
  118. package/dist/services/operate/redaction.js +4 -4
  119. package/dist/services/operate/redaction.js.map +1 -1
  120. package/dist/services/operate/reports.d.ts +11 -1
  121. package/dist/services/operate/reports.d.ts.map +1 -1
  122. package/dist/services/operate/reports.js +88 -3
  123. package/dist/services/operate/reports.js.map +1 -1
  124. package/dist/services/operate/types.d.ts +42 -128
  125. package/dist/services/operate/types.d.ts.map +1 -1
  126. package/dist/services/operate/types.js.map +1 -1
  127. package/dist/services/operate/workspace.d.ts +0 -1
  128. package/dist/services/operate/workspace.d.ts.map +1 -1
  129. package/dist/services/operate/workspace.js +0 -2
  130. package/dist/services/operate/workspace.js.map +1 -1
  131. package/dist/services/runtime-manager-service.js +2 -2
  132. package/dist/services/runtime-manager-service.js.map +1 -1
  133. package/package.json +3 -3
  134. package/dist/services/operate/evidence-classifications.d.ts +0 -59
  135. package/dist/services/operate/evidence-classifications.d.ts.map +0 -1
  136. package/dist/services/operate/evidence-classifications.js +0 -132
  137. package/dist/services/operate/evidence-classifications.js.map +0 -1
  138. package/dist/services/operate/evidence-diagnostics.d.ts +0 -23
  139. package/dist/services/operate/evidence-diagnostics.d.ts.map +0 -1
  140. package/dist/services/operate/evidence-diagnostics.js +0 -152
  141. package/dist/services/operate/evidence-diagnostics.js.map +0 -1
  142. package/dist/services/operate/evidence-readiness.d.ts +0 -18
  143. package/dist/services/operate/evidence-readiness.d.ts.map +0 -1
  144. package/dist/services/operate/evidence-readiness.js +0 -94
  145. 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,1219 +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
- /**
75
- * The first path segment of a repository-relative path (its top-level
76
- * directory), or null for a root-level file. Drives per-directory round-robin
77
- * fairness and the truncation diagnostics.
78
- */
79
- function topLevelSegment(relativePath) {
80
- const slash = relativePath.indexOf('/');
81
- if (slash < 0)
82
- return null;
83
- const top = relativePath.slice(0, slash);
84
- return top.length > 0 ? top : null;
85
- }
86
- /**
87
- * Map each tracked path to the Unix-second timestamp of the most recent commit
88
- * that touched it, from a single `git log --name-only` walk (newest-first, so a
89
- * path's first occurrence is its latest change). A leading NUL marks the
90
- * timestamp header line so a filename can never be mistaken for a timestamp.
91
- * Missing or empty history yields an empty map; callers then fall back to a
92
- * path-stable order, keeping the evidence digest reproducible.
93
- */
94
- async function gitRecencyMap(root, deadline) {
95
- const remaining = deadline - Date.now();
96
- if (remaining <= 0)
97
- return new Map();
98
- const output = await executeGitReadOnly(root, ['-c', 'core.quotePath=false', 'log', '--no-renames', '--pretty=format:%x00%ct', '--name-only'], { timeoutMs: remaining }).catch(() => '');
99
- const recency = new Map();
100
- let current = 0;
101
- for (const line of output.split('\n')) {
102
- if (line.startsWith('\0')) {
103
- current = Number.parseInt(line.slice(1), 10) || 0;
104
- continue;
105
- }
106
- if (line.length === 0)
107
- continue;
108
- if (!recency.has(line))
109
- recency.set(line, current);
110
- }
111
- return recency;
112
- }
113
- /**
114
- * FR3: order candidate paths for a fair, prioritized capped walk. Files are
115
- * grouped by top-level directory; within a group the most recently changed come
116
- * first (git recency, stable path tiebreak); product (non-dot) directories are
117
- * round-robined to exhaustion BEFORE any dot-prefixed/planning tree, so a capped
118
- * walk samples every product directory and dot-config never consumes the cap
119
- * before product code. Deterministic: identical inputs yield an identical order.
120
- */
121
- function orderCandidatesFairly(names, recency) {
122
- const groups = new Map();
123
- for (const name of names) {
124
- const key = topLevelSegment(name) ?? '';
125
- const bucket = groups.get(key);
126
- if (bucket)
127
- bucket.push(name);
128
- else
129
- groups.set(key, [name]);
130
- }
131
- for (const bucket of groups.values()) {
132
- bucket.sort((left, right) => (recency.get(right) ?? 0) - (recency.get(left) ?? 0) ||
133
- (left < right ? -1 : left > right ? 1 : 0));
134
- }
135
- const isPlanningGroup = (key) => key.startsWith('.');
136
- const productGroups = [...groups.keys()].filter((key) => !isPlanningGroup(key)).sort();
137
- const planningGroups = [...groups.keys()].filter(isPlanningGroup).sort();
138
- const roundRobin = (keys) => {
139
- const ordered = [];
140
- const cursors = new Map(keys.map((key) => [key, 0]));
141
- for (let active = keys.length; active > 0;) {
142
- active = 0;
143
- for (const key of keys) {
144
- const bucket = groups.get(key) ?? [];
145
- const cursor = cursors.get(key) ?? 0;
146
- if (cursor < bucket.length) {
147
- ordered.push(bucket[cursor]);
148
- cursors.set(key, cursor + 1);
149
- active += 1;
150
- }
151
- }
152
- }
153
- return ordered;
154
- };
155
- return [...roundRobin(productGroups), ...roundRobin(planningGroups)];
156
- }
157
- async function repositoryItems(roots, workspace, budgets, now, options) {
158
- const descriptors = descriptorById(workspace);
159
- const results = [];
160
- // FR3: the repository (product code) and planr (planning tree) providers no
161
- // longer share one undifferentiated `maxFiles` counter. Product code draws the
162
- // full file budget; the `.planr/` tree draws an independent, smaller allotment
163
- // so a large planning tree can never consume the cap before product code.
164
- const repositoryFileBudget = budgets.maxFiles;
165
- const planrFileBudget = Math.max(1, Math.floor(budgets.maxFiles / 4));
166
- let repositoryFiles = 0;
167
- let planrFiles = 0;
168
- let bytes = 0;
169
- let truncated = false;
170
- let lastPathReached = null;
171
- const unscannedTopLevel = new Set();
172
- const componentIds = Object.keys(roots.roots).sort();
173
- for (let componentIndex = 0; componentIndex < componentIds.length; componentIndex += 1) {
174
- const componentId = componentIds[componentIndex];
175
- const root = roots.roots[componentId];
176
- if (Date.now() >= options.deadline) {
177
- truncated = true;
178
- for (const remaining of componentIds.slice(componentIndex))
179
- unscannedTopLevel.add(remaining);
180
- break;
181
- }
182
- const descriptor = descriptors.get(componentId);
183
- if (!descriptor)
184
- continue;
185
- const canonicalRoot = await realpath(root);
186
- const names = (await executeGitReadOnly(canonicalRoot, ['ls-files', '-z']))
187
- .split('\0')
188
- .filter(Boolean)
189
- .filter((name) => TEXT_EXTENSIONS.has(path.extname(name).toLowerCase()));
190
- const recency = await gitRecencyMap(canonicalRoot, options.deadline);
191
- const ordered = orderCandidatesFairly(names, recency);
192
- const pendingTop = new Set(ordered.map((name) => topLevelSegment(name)).filter((top) => top !== null));
193
- let stopped = false;
194
- for (const relativePath of ordered) {
195
- if (Date.now() >= options.deadline || results.length >= budgets.maxItems) {
196
- truncated = true;
197
- stopped = true;
198
- for (const top of pendingTop)
199
- unscannedTopLevel.add(top);
200
- break;
201
- }
202
- const reachedTop = topLevelSegment(relativePath);
203
- if (reachedTop)
204
- pendingTop.delete(reachedTop);
205
- const isPlanr = relativePath.startsWith('.planr/');
206
- if ((!options.includeRepository && !isPlanr) ||
207
- (isPlanr && !options.includePlanr && !options.includeRepository)) {
208
- continue;
209
- }
210
- // FR3: enforce the split file allotments. A planr file over the planr
211
- // allotment, or a product file over the repository allotment, is skipped
212
- // (and the walk marked truncated) rather than consuming the other
213
- // provider's budget. Product code is ordered first, so it claims its cap
214
- // before any planning tree is reached.
215
- if (isPlanr) {
216
- if (planrFiles >= planrFileBudget) {
217
- truncated = true;
218
- continue;
219
- }
220
- }
221
- else if (repositoryFiles >= repositoryFileBudget) {
222
- truncated = true;
223
- continue;
224
- }
225
- const target = path.join(canonicalRoot, relativePath);
226
- const directInfo = await lstat(target).catch(() => null);
227
- if (!directInfo)
228
- continue;
229
- const resolvedTarget = await realpath(target).catch(() => null);
230
- if (!resolvedTarget)
231
- continue;
232
- if (!isPathInside(canonicalRoot, resolvedTarget)) {
233
- throw new OperateError('E_OPERATE_PATH_ESCAPE', `Tracked evidence path follows a symlink outside its repository: ${componentId}/${relativePath}.`);
234
- }
235
- if (directInfo.isSymbolicLink())
236
- continue;
237
- const info = await stat(resolvedTarget).catch(() => null);
238
- if (!info?.isFile() || info.size > budgets.maxItemBytes)
239
- continue;
240
- if (bytes + info.size > budgets.maxBytes) {
241
- truncated = true;
242
- stopped = true;
243
- for (const top of pendingTop)
244
- unscannedTopLevel.add(top);
245
- break;
246
- }
247
- const content = await readFile(resolvedTarget, 'utf8').catch(() => null);
248
- if (content === null || content.includes('\0'))
249
- continue;
250
- if (isPlanr)
251
- planrFiles += 1;
252
- else
253
- repositoryFiles += 1;
254
- bytes += Buffer.byteLength(content);
255
- lastPathReached = relativePath;
256
- const collectedAt = now.toISOString();
257
- const repository = {
258
- componentId,
259
- canonicalRemote: `https://${descriptor.canonicalRemote}`,
260
- revision: descriptor.pinnedRevision,
261
- configuredBranch: descriptor.configuredBranch,
262
- dirtyFingerprint: descriptor.dirtyFingerprint,
263
- relativePath,
264
- digest: canonicalDigest(content),
265
- freshness: ageFreshness(collectedAt, now),
266
- sensitivity: 'internal',
267
- collectedAt,
268
- };
269
- if (options.includeRepository && results.length < budgets.maxItems) {
270
- results.push({
271
- id: evidenceId('repository', `${componentId}:${relativePath}:${repository.digest}`),
272
- source: 'repository',
273
- location: `${componentId}/${relativePath}`,
274
- content,
275
- collectedAt,
276
- observedFrom: null,
277
- observedTo: collectedAt,
278
- freshness: repository.freshness,
279
- sensitivity: repository.sensitivity,
280
- claimTypes: ['code', 'architecture'],
281
- quality: 'observed',
282
- coverage: 'partial',
283
- repository,
284
- });
285
- }
286
- if (isPlanr && options.includePlanr && results.length < budgets.maxItems) {
287
- const planrClaims = [
288
- 'planning',
289
- ...(relativePath.includes('/stories/') || relativePath.includes('/specs/')
290
- ? ['user-journey']
291
- : []),
292
- ...(relativePath.includes('outcome') || relativePath.includes('provenance')
293
- ? ['outcome']
294
- : []),
295
- ...(relativePath.includes('/support') ? ['support', 'operations'] : []),
296
- ];
297
- results.push({
298
- id: evidenceId('planr', `${componentId}:${relativePath}:${repository.digest}`),
299
- source: 'planr',
300
- location: `${componentId}/${relativePath}`,
301
- content,
302
- collectedAt,
303
- observedFrom: null,
304
- observedTo: collectedAt,
305
- freshness: repository.freshness,
306
- sensitivity: repository.sensitivity,
307
- claimTypes: planrClaims,
308
- quality: 'verified',
309
- coverage: 'partial',
310
- repository,
311
- });
312
- }
313
- }
314
- if (stopped)
315
- break;
316
- }
317
- return {
318
- items: results,
319
- truncated,
320
- lastPathReached,
321
- unscannedTopLevel: [...unscannedTopLevel].sort(),
322
- };
323
- }
324
- async function gitItems(roots, workspace, now, deadline) {
325
- const descriptors = descriptorById(workspace);
326
- const results = [];
327
- for (const [componentId, root] of Object.entries(roots.roots).sort()) {
328
- const remaining = deadline - Date.now();
329
- if (remaining <= 0)
330
- return { items: results, truncated: true };
331
- const descriptor = descriptors.get(componentId);
332
- if (!descriptor)
333
- continue;
334
- // An unborn Git branch has no history to cite. The workspace manifest uses
335
- // Git's null OID for that state; do not manufacture a change-history item
336
- // that could incorrectly satisfy an advisor readiness requirement.
337
- if (/^0{40,64}$/u.test(descriptor.pinnedRevision))
338
- continue;
339
- const content = await executeGitReadOnly(root, [
340
- 'log',
341
- '--since=30.days',
342
- '--max-count=200',
343
- '--date=iso-strict',
344
- '--pretty=format:%H%x09%ad%x09%s',
345
- ], { timeoutMs: remaining });
346
- const collectedAt = now.toISOString();
347
- results.push({
348
- id: evidenceId('git', `${componentId}:${descriptor.pinnedRevision}:${content}`),
349
- source: 'git',
350
- location: `${componentId}/history`,
351
- content,
352
- collectedAt,
353
- observedFrom: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1_000).toISOString(),
354
- observedTo: collectedAt,
355
- freshness: 'fresh',
356
- sensitivity: 'internal',
357
- claimTypes: ['change-history'],
358
- quality: 'verified',
359
- coverage: 'partial',
360
- repository: {
361
- componentId,
362
- canonicalRemote: `https://${descriptor.canonicalRemote}`,
363
- revision: descriptor.pinnedRevision,
364
- configuredBranch: descriptor.configuredBranch,
365
- dirtyFingerprint: descriptor.dirtyFingerprint,
366
- relativePath: '.git',
367
- digest: canonicalDigest(content),
368
- freshness: 'fresh',
369
- sensitivity: 'internal',
370
- collectedAt,
371
- },
372
- });
373
- }
374
- return { items: results, truncated: false };
375
- }
376
- function boundedRecordsContent(key, records, maxBytes) {
377
- const selected = [...records];
378
- let content = canonicalize({ [key]: selected });
379
- while (selected.length > 0 && Buffer.byteLength(content) > maxBytes) {
380
- selected.pop();
381
- content = canonicalize({ [key]: selected });
382
- }
383
- if (Buffer.byteLength(content) > maxBytes) {
384
- throw new OperateError('E_OPERATE_EVIDENCE_REJECTED', `${key} evidence cannot fit inside the configured item limit.`);
385
- }
386
- return {
387
- content,
388
- records: selected.length,
389
- truncated: selected.length < records.length,
390
- };
391
- }
392
- function githubIdentity(canonicalRemote) {
393
- let url;
394
- try {
395
- url = new URL(canonicalRemote.includes('://') ? canonicalRemote : `https://${canonicalRemote}`);
396
- }
397
- catch {
398
- return null;
399
- }
400
- if (url.protocol !== 'https:' ||
401
- url.hostname.toLowerCase() !== 'github.com' ||
402
- url.username ||
403
- url.password ||
404
- (url.port && url.port !== '443')) {
405
- return null;
406
- }
407
- const segments = url.pathname
408
- .replace(/\.git$/i, '')
409
- .split('/')
410
- .filter(Boolean);
411
- if (segments.length !== 2 || segments.some((segment) => !/^[A-Za-z0-9_.-]+$/.test(segment))) {
412
- return null;
413
- }
414
- return { owner: segments[0], repository: segments[1] };
415
- }
416
- async function githubPages(input) {
417
- const results = [];
418
- const pageSize = Math.min(100, Math.max(1, input.maxRecords));
419
- const pageLimit = Math.min(5, Math.ceil(input.maxRecords / pageSize));
420
- if (input.maxBytes < 1_024 || pageLimit < 1)
421
- return results;
422
- const perPageBytes = Math.max(1_024, Math.floor(input.maxBytes / pageLimit));
423
- for (let page = 1; page <= pageLimit && results.length < input.maxRecords; page += 1) {
424
- const remaining = input.deadline - Date.now();
425
- if (remaining <= 0)
426
- break;
427
- const endpoint = new URL(input.endpoint);
428
- endpoint.searchParams.set('per_page', String(pageSize));
429
- endpoint.searchParams.set('page', String(page));
430
- const response = await executeRestReadOnlyJson(endpoint, {
431
- method: 'GET',
432
- allowedHosts: ['api.github.com'],
433
- timeoutMs: remaining,
434
- maxBytes: Math.min(perPageBytes, 5 * 1024 * 1024),
435
- fetchImpl: input.fetchImpl,
436
- headers: {
437
- accept: 'application/vnd.github+json',
438
- authorization: `Bearer ${input.token}`,
439
- 'x-github-api-version': '2022-11-28',
440
- },
441
- });
442
- const pageRecords = input.select(response);
443
- const rawPageRecords = Array.isArray(response)
444
- ? response.length
445
- : response &&
446
- typeof response === 'object' &&
447
- Array.isArray(response.check_runs)
448
- ? response.check_runs.length
449
- : pageRecords.length;
450
- results.push(...pageRecords.slice(0, input.maxRecords - results.length));
451
- if (rawPageRecords < pageSize)
452
- break;
453
- }
454
- return results;
455
- }
456
- export async function collectGitHubEvidence(input) {
457
- const token = input.token?.trim() ||
458
- process.env.GH_TOKEN?.trim() ||
459
- process.env.GITHUB_TOKEN?.trim() ||
460
- (await executeGitHubReadOnly(input.projectRoot, ['auth', 'token', '--hostname', 'github.com'], { allowedHosts: ['github.com'], timeoutMs: input.deadline - Date.now() })).trim();
461
- if (!token) {
462
- throw new OperateError('E_OPERATE_EVIDENCE_REJECTED', 'GitHub credentials are unavailable.');
463
- }
464
- const descriptors = [input.workspace.controlRepository, ...input.workspace.components];
465
- const items = [];
466
- let truncated = false;
467
- let remainingBytes = input.budgets.maxBytes;
468
- const maxRecords = Math.min(100, Math.max(1, Math.floor(input.budgets.maxItems / 4)));
469
- const categoryResponseBytes = Math.max(1_024, Math.floor(input.budgets.maxBytes / 4));
470
- for (const descriptor of descriptors) {
471
- const identity = githubIdentity(descriptor.canonicalRemote);
472
- if (!identity)
473
- continue;
474
- if (Date.now() >= input.deadline || items.length >= input.budgets.maxItems) {
475
- truncated = true;
476
- break;
477
- }
478
- const base = `https://api.github.com/repos/${encodeURIComponent(identity.owner)}/${encodeURIComponent(identity.repository)}`;
479
- const categories = [
480
- {
481
- id: 'issues',
482
- endpoint: `${base}/issues?state=all`,
483
- claims: ['customer-signal', 'delivery'],
484
- select: (value) => (Array.isArray(value) ? value : [])
485
- .filter((record) => Boolean(record) && typeof record === 'object' && !('pull_request' in record))
486
- .map((record) => ({
487
- number: record.number ?? null,
488
- title: record.title ?? null,
489
- state: record.state ?? null,
490
- createdAt: record.created_at ?? null,
491
- updatedAt: record.updated_at ?? null,
492
- labels: Array.isArray(record.labels)
493
- ? record.labels
494
- .map((label) => label && typeof label === 'object'
495
- ? label.name
496
- : label)
497
- .filter((label) => typeof label === 'string')
498
- .sort()
499
- : [],
500
- })),
501
- },
502
- {
503
- id: 'pull-requests',
504
- endpoint: `${base}/pulls?state=all`,
505
- claims: ['delivery', 'change-history'],
506
- select: (value) => (Array.isArray(value) ? value : [])
507
- .filter((record) => Boolean(record) && typeof record === 'object')
508
- .map((record) => ({
509
- number: record.number ?? null,
510
- title: record.title ?? null,
511
- state: record.state ?? null,
512
- draft: record.draft ?? null,
513
- createdAt: record.created_at ?? null,
514
- updatedAt: record.updated_at ?? null,
515
- mergedAt: record.merged_at ?? null,
516
- })),
517
- },
518
- {
519
- id: 'releases',
520
- endpoint: `${base}/releases`,
521
- claims: ['delivery', 'change-history'],
522
- select: (value) => (Array.isArray(value) ? value : [])
523
- .filter((record) => Boolean(record) && typeof record === 'object')
524
- .map((record) => ({
525
- id: record.id ?? null,
526
- tag: record.tag_name ?? null,
527
- name: record.name ?? null,
528
- draft: record.draft ?? null,
529
- prerelease: record.prerelease ?? null,
530
- createdAt: record.created_at ?? null,
531
- publishedAt: record.published_at ?? null,
532
- })),
533
- },
534
- {
535
- id: 'checks',
536
- endpoint: `${base}/commits/${encodeURIComponent(descriptor.pinnedRevision)}/check-runs`,
537
- claims: ['delivery', 'reliability'],
538
- select: (value) => {
539
- if (!value || typeof value !== 'object')
540
- return [];
541
- const records = value.check_runs;
542
- return (Array.isArray(records) ? records : [])
543
- .filter((record) => Boolean(record) && typeof record === 'object')
544
- .map((record) => ({
545
- id: record.id ?? null,
546
- name: record.name ?? null,
547
- status: record.status ?? null,
548
- conclusion: record.conclusion ?? null,
549
- startedAt: record.started_at ?? null,
550
- completedAt: record.completed_at ?? null,
551
- }));
552
- },
553
- },
554
- ];
555
- for (const category of categories) {
556
- if (Date.now() >= input.deadline ||
557
- items.length >= input.budgets.maxItems ||
558
- remainingBytes < 1_024) {
559
- truncated = true;
560
- break;
561
- }
562
- const records = (await githubPages({
563
- token,
564
- endpoint: category.endpoint,
565
- deadline: input.deadline,
566
- maxRecords,
567
- maxBytes: Math.min(remainingBytes, categoryResponseBytes),
568
- fetchImpl: input.fetchImpl,
569
- select: category.select,
570
- }));
571
- const bounded = boundedRecordsContent(category.id, records, Math.min(input.budgets.maxItemBytes, remainingBytes));
572
- truncated ||= bounded.truncated || records.length >= maxRecords;
573
- remainingBytes -= Buffer.byteLength(bounded.content);
574
- if (bounded.records === 0)
575
- continue;
576
- const collectedAt = input.now.toISOString();
577
- items.push({
578
- id: evidenceId('github', `${descriptor.componentId}:${category.id}:${bounded.content}`),
579
- source: 'github',
580
- location: `${descriptor.componentId}/${category.id}`,
581
- content: bounded.content,
582
- collectedAt,
583
- observedFrom: null,
584
- observedTo: collectedAt,
585
- freshness: 'fresh',
586
- sensitivity: 'confidential',
587
- claimTypes: category.claims,
588
- quality: 'observed',
589
- coverage: bounded.truncated ? 'partial' : 'complete',
590
- });
591
- }
592
- }
593
- return { items, truncated };
594
- }
595
- async function linearConnectionPages(input) {
596
- const records = [];
597
- let after = null;
598
- const pageSize = Math.min(100, Math.max(1, input.maxRecords));
599
- const pageLimit = Math.min(5, Math.ceil(input.maxRecords / pageSize));
600
- const perPageBytes = Math.max(1_024, Math.floor(input.maxBytes / Math.max(1, pageLimit)));
601
- for (let page = 0; page < pageLimit && records.length < input.maxRecords; page += 1) {
602
- const remaining = input.deadline - Date.now();
603
- if (remaining <= 0)
604
- break;
605
- const data = await executeLinearReadOnlyQuery({
606
- token: input.token,
607
- query: input.query,
608
- variables: {
609
- ...input.variables,
610
- first: pageSize,
611
- after,
612
- },
613
- timeoutMs: remaining,
614
- maxBytes: Math.min(perPageBytes, 5 * 1024 * 1024),
615
- allowedHosts: ['api.linear.app'],
616
- fetchImpl: input.fetchImpl,
617
- });
618
- const connection = data[input.connection];
619
- const nodes = Array.isArray(connection?.nodes) ? connection.nodes : [];
620
- records.push(...nodes.slice(0, input.maxRecords - records.length));
621
- if (!connection?.pageInfo?.hasNextPage || !connection.pageInfo.endCursor)
622
- break;
623
- after = connection.pageInfo.endCursor;
624
- }
625
- return records;
626
- }
627
- export async function collectLinearEvidence(input) {
628
- const token = input.token?.trim() || (await resolveApiKey('linear'))?.trim();
629
- if (!token) {
630
- throw new OperateError('E_OPERATE_EVIDENCE_REJECTED', 'Linear credentials are unavailable.');
631
- }
632
- const config = input.teamIds ? null : await loadConfig(input.projectRoot).catch(() => null);
633
- const teamIds = [
634
- ...new Set((input.teamIds ?? [
635
- config?.linear?.teamId,
636
- ...(config?.linear?.teams ?? []).map((team) => team.id),
637
- ]).filter((value) => Boolean(value))),
638
- ];
639
- const maxRecords = Math.min(100, Math.max(1, Math.floor(input.budgets.maxItems / 3)));
640
- const connectionBudget = Math.max(1_024, Math.floor(input.budgets.maxBytes / 3));
641
- const teams = await linearConnectionPages({
642
- token,
643
- query: `query OperatingTeams($first: Int!, $after: String) {
644
- teams(first: $first, after: $after) {
645
- nodes { id key name }
646
- pageInfo { hasNextPage endCursor }
647
- }
648
- }`,
649
- connection: 'teams',
650
- deadline: input.deadline,
651
- maxRecords,
652
- maxBytes: connectionBudget,
653
- fetchImpl: input.fetchImpl,
654
- });
655
- const issues = await linearConnectionPages({
656
- token,
657
- query: teamIds.length > 0
658
- ? `query OperatingIssues($first: Int!, $after: String, $teamIds: [ID!]) {
659
- issues(first: $first, after: $after, filter: { team: { id: { in: $teamIds } } }) {
660
- nodes {
661
- id identifier title updatedAt priority
662
- team { id key }
663
- state { name }
664
- labels { nodes { name } }
665
- }
666
- pageInfo { hasNextPage endCursor }
667
- }
668
- }`
669
- : `query OperatingIssues($first: Int!, $after: String) {
670
- issues(first: $first, after: $after) {
671
- nodes {
672
- id identifier title updatedAt priority
673
- team { id key }
674
- state { name }
675
- labels { nodes { name } }
676
- }
677
- pageInfo { hasNextPage endCursor }
678
- }
679
- }`,
680
- connection: 'issues',
681
- variables: teamIds.length > 0 ? { teamIds } : {},
682
- deadline: input.deadline,
683
- maxRecords,
684
- maxBytes: connectionBudget,
685
- fetchImpl: input.fetchImpl,
686
- });
687
- const projects = await linearConnectionPages({
688
- token,
689
- query: `query OperatingProjects($first: Int!, $after: String) {
690
- projects(first: $first, after: $after) {
691
- nodes {
692
- id name updatedAt
693
- status { name }
694
- teams { nodes { id key name } }
695
- }
696
- pageInfo { hasNextPage endCursor }
697
- }
698
- }`,
699
- connection: 'projects',
700
- deadline: input.deadline,
701
- maxRecords,
702
- maxBytes: connectionBudget,
703
- fetchImpl: input.fetchImpl,
704
- });
705
- const selectedProjects = teamIds.length === 0
706
- ? projects
707
- : projects.filter((project) => (project.teams?.nodes ?? []).some((team) => team.id && teamIds.includes(team.id)));
708
- const records = [
709
- {
710
- id: 'teams',
711
- values: teams.map((team) => ({
712
- id: team.id ?? null,
713
- key: team.key ?? null,
714
- name: team.name ?? null,
715
- })),
716
- claims: ['planning', 'operations'],
717
- },
718
- {
719
- id: 'issues',
720
- values: issues.map((issue) => ({
721
- id: issue.id ?? null,
722
- identifier: issue.identifier ?? null,
723
- title: issue.title ?? null,
724
- updatedAt: issue.updatedAt ?? null,
725
- priority: issue.priority ?? null,
726
- team: issue.team ? { id: issue.team.id ?? null, key: issue.team.key ?? null } : null,
727
- state: issue.state?.name ?? null,
728
- labels: (issue.labels?.nodes ?? [])
729
- .map((label) => label.name)
730
- .filter((label) => Boolean(label))
731
- .sort(),
732
- })),
733
- claims: ['customer-signal', 'delivery', 'planning'],
734
- },
735
- {
736
- id: 'projects',
737
- values: selectedProjects.map((project) => ({
738
- id: project.id ?? null,
739
- name: project.name ?? null,
740
- updatedAt: project.updatedAt ?? null,
741
- status: project.status?.name ?? null,
742
- teams: (project.teams?.nodes ?? [])
743
- .map((team) => ({
744
- id: team.id ?? null,
745
- key: team.key ?? null,
746
- name: team.name ?? null,
747
- }))
748
- .sort((left, right) => String(left.id).localeCompare(String(right.id))),
749
- })),
750
- claims: ['planning', 'operations', 'delivery'],
751
- },
752
- ];
753
- const items = [];
754
- let truncated = false;
755
- let remainingBytes = input.budgets.maxBytes;
756
- for (const record of records) {
757
- if (remainingBytes < 1_024 || items.length >= input.budgets.maxItems) {
758
- truncated = true;
759
- break;
760
- }
761
- const bounded = boundedRecordsContent(record.id, record.values, Math.min(input.budgets.maxItemBytes, remainingBytes));
762
- truncated ||= bounded.truncated || record.values.length >= maxRecords;
763
- remainingBytes -= Buffer.byteLength(bounded.content);
764
- if (bounded.records === 0)
765
- continue;
766
- const collectedAt = input.now.toISOString();
767
- items.push({
768
- id: evidenceId('linear', `${record.id}:${bounded.content}`),
769
- source: 'linear',
770
- location: record.id,
771
- content: bounded.content,
772
- collectedAt,
773
- observedFrom: null,
774
- observedTo: collectedAt,
775
- freshness: 'fresh',
776
- sensitivity: 'confidential',
777
- claimTypes: record.claims,
778
- quality: 'observed',
779
- coverage: bounded.truncated ? 'partial' : 'complete',
780
- });
781
- }
782
- return { items, truncated };
783
- }
784
- async function fileImportItems(projectRoot, roots, budgets, now, deadline, localRoot) {
785
- const preferencesPath = path.join(resolveOperatingPaths(projectRoot, { localRoot }).localRoot, 'preferences.json');
786
- const preferences = JSON.parse(await readFile(preferencesPath, 'utf8'));
787
- const importPaths = Array.isArray(preferences.importPaths)
788
- ? preferences.importPaths.filter((entry) => typeof entry === 'string')
789
- : [];
790
- if (importPaths.length === 0) {
791
- throw new OperateError('E_OPERATE_CONFIG_INVALID', 'The file-import source has no configured evidence paths; rerun `planr operate init --evidence-file <path>`.');
792
- }
793
- const allowedRoots = Object.entries(roots.roots).map(([componentId, root]) => ({
794
- componentId,
795
- root,
796
- }));
797
- const items = [];
798
- let bytes = 0;
799
- let truncated = false;
800
- for (const configuredPath of importPaths) {
801
- if (Date.now() >= deadline ||
802
- items.length >= budgets.maxItems ||
803
- items.length >= budgets.maxFiles) {
804
- truncated = true;
805
- break;
806
- }
807
- const remainingBytes = budgets.maxBytes - bytes;
808
- if (remainingBytes < 1) {
809
- truncated = true;
810
- break;
811
- }
812
- const imported = await readImportedEvidenceFile({
813
- projectRoot,
814
- configuredPath,
815
- roots: allowedRoots,
816
- maxBytes: Math.min(budgets.maxItemBytes, remainingBytes),
817
- });
818
- const commercialMetric = inferImportedCommercialMetric(imported.content, imported.format);
819
- bytes += imported.byteCount;
820
- const collectedAt = now.toISOString();
821
- items.push({
822
- id: evidenceId('file-import', `${imported.location}:${canonicalDigest(imported.content)}`),
823
- source: 'file-import',
824
- location: imported.location,
825
- content: imported.content,
826
- collectedAt,
827
- observedFrom: null,
828
- observedTo: collectedAt,
829
- freshness: 'fresh',
830
- sensitivity: 'internal',
831
- claimTypes: [
832
- 'structured-data',
833
- imported.format,
834
- ...(commercialMetric.commercial ? ['commercial-metric'] : []),
835
- ],
836
- quality: 'self-reported',
837
- coverage: 'complete',
838
- ...(commercialMetric.metric ? { metric: commercialMetric.metric } : {}),
839
- });
840
- }
841
- return { items, truncated };
842
- }
843
- 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;
844
- const NUMERIC_CLAIM = /(?:[$€£]\s*)?\b\d+(?:[.,]\d+)?\s*(?:%|percent|usd|eur|gbp|customers?|users?)?\b/i;
845
- function validIsoWindow(from, to) {
846
- return (typeof from === 'string' &&
847
- typeof to === 'string' &&
848
- Number.isFinite(Date.parse(from)) &&
849
- Number.isFinite(Date.parse(to)) &&
850
- Date.parse(to) >= Date.parse(from));
851
- }
852
- function inferImportedCommercialMetric(content, format) {
853
- const commercial = COMMERCIAL_METRIC_TERMS.test(content) && NUMERIC_CLAIM.test(content);
854
- if (!commercial || format !== 'json')
855
- return { commercial };
856
- try {
857
- const parsed = JSON.parse(content);
858
- const candidate = parsed.metric && typeof parsed.metric === 'object'
859
- ? parsed.metric
860
- : null;
861
- if (!candidate ||
862
- typeof candidate.identity !== 'string' ||
863
- !candidate.identity.trim() ||
864
- typeof candidate.query !== 'string' ||
865
- !candidate.query.trim() ||
866
- !validIsoWindow(candidate.observedFrom, candidate.observedTo)) {
867
- return { commercial };
868
- }
869
- return {
870
- commercial,
871
- metric: {
872
- identity: candidate.identity.trim(),
873
- query: candidate.query.trim(),
874
- observedFrom: candidate.observedFrom,
875
- observedTo: candidate.observedTo,
876
- },
877
- };
878
- }
879
- catch {
880
- return { commercial };
881
- }
882
- }
883
- function incrementalEvidencePath(input, key) {
884
- return path.join(resolveOperatingPaths(input.projectRoot, { localRoot: input.localRoot }).evidence, 'incremental', `${key.slice('sha256:'.length)}.json`);
885
- }
886
- /**
887
- * FR4: the board's identity is the hash of its event-chain genesis event. The
888
- * incremental cache key incorporates it so a board re-inited at the same path —
889
- * whose workspace components (remote/branch) are unchanged and would otherwise
890
- * compute an identical key — never reuses a prior generation's baseline. Returns
891
- * '' when no committed chain exists yet or it cannot be read (the collection then
892
- * behaves exactly as before this change).
893
- */
894
- async function evidenceBoardIdentity(input) {
895
- try {
896
- const { events } = await new OperatingEventStore(input.projectRoot, {
897
- localRoot: input.localRoot,
898
- }).replay();
899
- const genesis = events.find((event) => event.previousEventHash === null) ?? events[0];
900
- return genesis?.eventHash ?? '';
901
- }
902
- catch {
903
- return '';
904
- }
905
- }
906
- async function readIncrementalEvidence(input, key) {
907
- if (!input.incremental)
908
- return null;
909
- try {
910
- const record = JSON.parse(await readFile(incrementalEvidencePath(input, key), 'utf8'));
911
- if (record.implementation !== 'openplanr-operate-incremental-evidence' || record.key !== key) {
912
- return null;
913
- }
914
- // FR4: honor the baseline's own already-written workspaceDigest. A record
915
- // whose captured workspace no longer matches the current one is treated as
916
- // no-baseline so the cycle recollects deep, rather than trusting a snapshot
917
- // bound to a superseded workspace (for example, a prior board generation).
918
- if (record.workspaceDigest !== input.workspace.workspaceDigest) {
919
- return null;
920
- }
921
- await assertOperatingArtifact('operating-evidence', record.evidence);
922
- return record;
923
- }
924
- catch {
925
- return null;
926
- }
927
- }
928
- async function writeIncrementalEvidence(input, key, evidence) {
929
- if (!input.persistIncremental ||
930
- input.providers.some((provider) => ['file-import', 'github', 'linear'].includes(provider))) {
931
- return;
932
- }
933
- const target = incrementalEvidencePath(input, key);
934
- await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
935
- const temporary = `${target}.${process.pid}.tmp`;
936
- const record = {
937
- implementation: 'openplanr-operate-incremental-evidence',
938
- key,
939
- workspaceDigest: input.workspace.workspaceDigest,
940
- evidence,
941
- };
942
- await writeFile(temporary, `${canonicalize(record)}\n`, { mode: 0o600 });
943
- await rename(temporary, target);
944
- }
945
- function evidenceLocationIdentity(item) {
946
- return `${item.source}\0${item.location}`;
947
- }
948
- function buildEvidenceDelta(input) {
949
- const baselineItems = input.baseline?.items ?? [];
950
- const baselineByLocation = new Map(baselineItems.map((item) => [evidenceLocationIdentity(item), item]));
951
- const currentByLocation = new Map(input.current.map((item) => [evidenceLocationIdentity(item), item]));
952
- const currentById = new Map(input.current.map((item) => [item.id, item]));
953
- const baselineById = new Map(baselineItems.map((item) => [item.id, item]));
954
- const changedEvidenceRefs = input.current
955
- .filter((item) => {
956
- const prior = baselineByLocation.get(evidenceLocationIdentity(item));
957
- return (!prior ||
958
- prior.digest !== item.digest ||
959
- prior.sensitivity !== item.sensitivity ||
960
- canonicalDigest(prior.metric ?? null) !== canonicalDigest(item.metric ?? null));
961
- })
962
- .map((item) => item.id)
963
- .sort();
964
- const removedEvidenceRefs = baselineItems
965
- .filter((item) => !currentByLocation.has(evidenceLocationIdentity(item)))
966
- .map((item) => item.id)
967
- .sort();
968
- const requiredEvidenceRefs = [
969
- ...new Set(input.requiredEvidenceRefs.flatMap((reference) => {
970
- const current = currentById.get(reference);
971
- if (current)
972
- return [current.id];
973
- const prior = baselineById.get(reference);
974
- if (!prior)
975
- return [];
976
- return [currentByLocation.get(evidenceLocationIdentity(prior))?.id ?? prior.id];
977
- })),
978
- ].sort();
979
- return {
980
- mode: input.incremental ? (input.baseline ? 'standard' : 'baseline') : 'deep',
981
- baselineFingerprint: input.baseline?.fingerprint ?? null,
982
- changedEvidenceRefs,
983
- requiredEvidenceRefs,
984
- selectedEvidenceRefs: [...new Set([...changedEvidenceRefs, ...requiredEvidenceRefs])].sort(),
985
- removedEvidenceRefs,
986
- };
987
- }
988
- export async function collectOperatingEvidence(input) {
989
- const started = Date.now();
990
- const now = input.now ?? new Date();
991
- const deadline = started + input.budgets.maxDurationMs;
992
- const incrementalKey = canonicalDigest({
993
- boardIdentity: await evidenceBoardIdentity(input),
994
- workspaceComponents: [input.workspace.controlRepository, ...input.workspace.components]
995
- .map((component) => ({
996
- componentId: component.componentId,
997
- canonicalRemote: component.canonicalRemote,
998
- configuredBranch: component.configuredBranch,
999
- }))
1000
- .sort((left, right) => left.componentId.localeCompare(right.componentId)),
1001
- providers: [...new Set(input.providers)].sort(),
1002
- sensitivityCeiling: input.sensitivityCeiling,
1003
- budgets: input.budgets,
1004
- });
1005
- const baselineRecord = await readIncrementalEvidence(input, incrementalKey);
1006
- const roots = await readRoots(input.projectRoot, input.localRoot);
1007
- const raw = [];
1008
- const warnings = [];
1009
- let truncated = false;
1010
- if (input.providers.includes('repository') || input.providers.includes('planr')) {
1011
- const repository = await repositoryItems(roots, input.workspace, input.budgets, now, {
1012
- includeRepository: input.providers.includes('repository'),
1013
- includePlanr: input.providers.includes('planr'),
1014
- deadline,
1015
- });
1016
- raw.push(...repository.items);
1017
- truncated ||= repository.truncated;
1018
- // FR2: a `maxFiles`/duration/byte truncation is no longer a silent boolean.
1019
- // Name the last path reached and the top-level directories the capped walk
1020
- // never scanned so starvation is visible from the cycle warnings alone.
1021
- if (repository.truncated &&
1022
- (repository.lastPathReached || repository.unscannedTopLevel.length > 0)) {
1023
- warnings.push(`Repository evidence collection was truncated at the configured caps; last path reached: ${repository.lastPathReached ?? 'none'}; top-level directories left unscanned: ${repository.unscannedTopLevel.length > 0 ? repository.unscannedTopLevel.join(', ') : 'none'}.`);
1024
- }
1025
- }
1026
- if (input.providers.includes('git') && Date.now() < deadline) {
1027
- const git = await gitItems(roots, input.workspace, now, deadline);
1028
- raw.push(...git.items);
1029
- truncated ||= git.truncated;
1030
- }
1031
- if (input.providers.includes('github') && Date.now() < deadline) {
1032
- try {
1033
- const github = await collectGitHubEvidence({
1034
- projectRoot: input.projectRoot,
1035
- workspace: input.workspace,
1036
- now,
1037
- deadline,
1038
- budgets: input.budgets,
1039
- token: input.remote?.githubToken,
1040
- fetchImpl: input.remote?.fetchImpl,
1041
- });
1042
- raw.push(...github.items);
1043
- truncated ||= github.truncated;
1044
- if (github.items.length === 0) {
1045
- warnings.push('GitHub evidence found no supported repositories or records.');
1046
- }
1047
- }
1048
- catch {
1049
- warnings.push('GitHub evidence is unavailable or not authenticated.');
1050
- }
1051
- }
1052
- if (input.providers.includes('linear') && Date.now() < deadline) {
1053
- try {
1054
- const linear = await collectLinearEvidence({
1055
- projectRoot: input.projectRoot,
1056
- now,
1057
- deadline,
1058
- budgets: input.budgets,
1059
- token: input.remote?.linearToken,
1060
- fetchImpl: input.remote?.fetchImpl,
1061
- });
1062
- raw.push(...linear.items);
1063
- truncated ||= linear.truncated;
1064
- if (linear.items.length === 0) {
1065
- warnings.push('Linear evidence returned no configured teams, issues, or projects.');
1066
- }
1067
- }
1068
- catch {
1069
- warnings.push('Linear evidence is unavailable, not configured, or not authenticated.');
1070
- }
1071
- }
1072
- if (input.providers.includes('file-import') && Date.now() < deadline) {
1073
- const imported = await fileImportItems(input.projectRoot, roots, input.budgets, now, deadline, input.localRoot);
1074
- raw.push(...imported.items);
1075
- truncated ||= imported.truncated;
1076
- }
1077
- const rejected = raw.filter((item) => compareSensitivity(item.sensitivity, input.sensitivityCeiling) > 0);
1078
- if (rejected.length > 0) {
1079
- warnings.push(`${rejected.length} evidence item(s) exceeded the configured sensitivity ceiling.`);
1080
- }
1081
- const flattened = [];
1082
- for (const item of raw
1083
- .filter((candidate) => compareSensitivity(candidate.sensitivity, input.sensitivityCeiling) <= 0)
1084
- .slice(0, input.budgets.maxItems)) {
1085
- try {
1086
- flattened.push(sanitizeEvidenceItem(item));
1087
- }
1088
- catch (error) {
1089
- if (!(error instanceof OperateError) || error.code !== 'E_OPERATE_SECRET_DETECTED') {
1090
- throw error;
1091
- }
1092
- const details = error.details ?? {};
1093
- const fallback = detectSecretMetadata(item.content)[0];
1094
- const ruleId = typeof details.ruleId === 'string' ? details.ruleId : (fallback?.ruleId ?? 'unknown.v1');
1095
- const category = typeof details.category === 'string'
1096
- ? details.category
1097
- : (fallback?.category ?? 'structured-secret');
1098
- const diagnostic = await createEvidenceDiagnostic({
1099
- projectRoot: input.projectRoot,
1100
- item,
1101
- localRoot: input.localRoot,
1102
- detection: {
1103
- ruleId,
1104
- category,
1105
- line: typeof details.line === 'number' && Number.isInteger(details.line)
1106
- ? details.line
1107
- : (fallback?.line ?? 1),
1108
- hardBlock: typeof details.hardBlock === 'boolean'
1109
- ? details.hardBlock
1110
- : (fallback?.hardBlock ?? false),
1111
- },
1112
- });
1113
- if (diagnostic.classification?.status === 'false-positive') {
1114
- warnings.push(`Evidence candidate ${diagnostic.candidateId} was omitted under its exact false-positive classification.`);
1115
- continue;
1116
- }
1117
- // Quarantine the item instead of aborting the entire evidence snapshot.
1118
- // The immutable role-filtered packs never receive its bytes. Readiness
1119
- // evaluation below decides whether enough eligible evidence remains for
1120
- // each lens; an affected lens becomes not_evaluated with a governed gap
1121
- // rather than preventing unrelated lenses from producing useful work.
1122
- warnings.push([
1123
- `Evidence candidate ${diagnostic.candidateId} was quarantined`,
1124
- `(${diagnostic.category}); its value was not persisted or dispatched.`,
1125
- `Inspect with: planr operate evidence diagnose ${diagnostic.candidateId} --json`,
1126
- ].join(' '));
1127
- }
1128
- }
1129
- if (Date.now() >= deadline) {
1130
- truncated = true;
1131
- warnings.push('Evidence collection reached the configured duration budget.');
1132
- }
1133
- // FR2: report the mission-index path-pattern loss up front. The mission
1134
- // evidence index forbids dot-prefixed/pattern-rejected paths, so those items —
1135
- // though retained in the full evidence set — can never be referenced by a
1136
- // bounded mission packet. Counting the loss here (rather than dropping it
1137
- // silently inside `buildOperatingEvidenceIndex`) is what makes a starved
1138
- // mission collection observable from the cycle warnings.
1139
- const missionIndexDrops = missionEvidenceIndexDrops(flattened, {
1140
- sensitivityCeiling: input.sensitivityCeiling,
1141
- });
1142
- if (missionIndexDrops.pathPattern > 0) {
1143
- warnings.push(`${missionIndexDrops.pathPattern} evidence item(s) cannot be represented in the mission evidence index because their paths are dot-prefixed or pattern-rejected; they remain in the full evidence set but no bounded mission packet can reference them.`);
1144
- }
1145
- const sourceIds = [...new Set(input.providers)].sort();
1146
- const delta = buildEvidenceDelta({
1147
- incremental: Boolean(input.incremental),
1148
- current: flattened,
1149
- baseline: baselineRecord?.evidence ?? null,
1150
- requiredEvidenceRefs: input.requiredEvidenceRefs ?? [],
1151
- });
1152
- const baselineSources = new Map((baselineRecord?.evidence.sources ?? []).map((source) => [source.id, source]));
1153
- const sources = sourceIds.map((id) => {
1154
- const items = flattened.filter((item) => item.source === id);
1155
- const fingerprint = canonicalDigest(evidenceFingerprintItems(items));
1156
- const prior = baselineSources.get(id);
1157
- return {
1158
- id,
1159
- fingerprint,
1160
- status: items.length === 0
1161
- ? 'unavailable'
1162
- : delta.mode === 'standard' && prior?.fingerprint === fingerprint
1163
- ? 'unchanged'
1164
- : delta.mode === 'standard'
1165
- ? 'partial'
1166
- : 'collected',
1167
- itemCount: items.length,
1168
- byteCount: items.reduce((sum, item) => sum + Buffer.byteLength(item.summary ?? ''), 0),
1169
- };
1170
- });
1171
- if (delta.mode === 'standard') {
1172
- warnings.push(`Standard evidence delta selected ${delta.changedEvidenceRefs.length} changed and ${delta.requiredEvidenceRefs.length} open-item-required evidence item(s).`);
1173
- }
1174
- const evidence = {
1175
- kind: 'operating-evidence',
1176
- schemaVersion: OPERATE_SCHEMA_VERSION,
1177
- protocolVersion: OPERATE_PROTOCOL_VERSION,
1178
- cycleId: input.cycleId,
1179
- fingerprint: canonicalDigest({
1180
- workspaceDigest: input.workspace.workspaceDigest,
1181
- items: evidenceFingerprintItems(flattened),
1182
- sources,
1183
- delta,
1184
- }),
1185
- collectedAt: now.toISOString(),
1186
- truncated: truncated ||
1187
- flattened.length >= input.budgets.maxItems ||
1188
- sources.reduce((sum, source) => sum + source.byteCount, 0) >= input.budgets.maxBytes,
1189
- items: flattened,
1190
- delta,
1191
- sources,
1192
- warnings,
1193
- };
1194
- const validated = await assertOperatingArtifact('operating-evidence', evidence);
1195
- await writeIncrementalEvidence(input, incrementalKey, validated);
1196
- return validated;
1197
- }
1198
- /**
1199
- * Convert commercial numbers without reproducible query/window identity into
1200
- * governed gaps before any advisor can turn them into recommendations.
1201
- */
1202
- export async function unqualifiedCommercialEvidenceGaps(input) {
1203
- const unqualified = input.evidence.items
1204
- .filter((item) => item.claimTypes.includes('commercial-metric') &&
1205
- (!item.metric?.identity.trim() ||
1206
- !item.metric.query.trim() ||
1207
- !validIsoWindow(item.metric.observedFrom, item.metric.observedTo)))
1208
- .sort((left, right) => left.source.localeCompare(right.source) ||
1209
- left.location.localeCompare(right.location) ||
1210
- left.id.localeCompare(right.id));
1211
- const gaps = unqualified.map((item, index) => ({
1212
- kind: 'operating-data-gap',
1213
- schemaVersion: OPERATE_SCHEMA_VERSION,
1214
- protocolVersion: OPERATE_PROTOCOL_VERSION,
1215
- id: `GAP-${String(index + 1).padStart(3, '0')}`,
1216
- cycleId: input.cycleId,
1217
- question: `What reproducible metric query and observation window qualify ${item.location}?`,
1218
- reason: 'Numeric commercial evidence requires a metric identity, query identity, and bounded observation window before it can support an operating recommendation.',
1219
- unblocks: [],
1220
- affectedRoles: [
1221
- 'strategy-finance',
1222
- 'product-activation',
1223
- 'growth-market',
1224
- 'operations-customer',
1225
- ],
1226
- status: 'open',
1227
- owner: input.owner,
1228
- evidenceRefs: [item.id],
1229
- createdAt: input.now,
1230
- updatedAt: input.now,
1231
- }));
1232
- await Promise.all(gaps.map((gap) => assertOperatingArtifact('operating-data-gap', gap)));
1233
- return gaps;
1234
- }
1235
- /**
1236
- * FR2: open a governed evidence gap for every mission role whose POST-index
1237
- * evidence retained zero `source: 'repository'` items. A role that requires
1238
- * repository code must never dispatch "ready" on an empty evidence base — the
1239
- * pre-index readiness gate can pass while the mission index built moments later
1240
- * loses every repository item to the dot-prefixed/pattern filter. Each gap names
1241
- * the affected role and the reason so the loss is visible instead of silent.
1242
- */
1243
- export async function starvedRoleEvidenceGaps(input) {
1244
- const roleIds = [...new Set(input.roleIds)].sort();
1245
- const start = input.startingGapNumber ?? 1;
1246
- const gaps = roleIds.map((roleId, offset) => ({
1247
- kind: 'operating-data-gap',
1248
- schemaVersion: OPERATE_SCHEMA_VERSION,
1249
- protocolVersion: OPERATE_PROTOCOL_VERSION,
1250
- id: `GAP-${String(start + offset).padStart(3, '0')}`,
1251
- cycleId: input.cycleId,
1252
- question: `What repository evidence can ${roleId} read after mission-index filtering removed every repository item?`,
1253
- reason: 'The mission evidence index retained zero repository items after dot-prefixed/pattern and sensitivity filtering, so this repository-dependent role was gated not-ready instead of dispatching on an empty evidence base.',
1254
- unblocks: [],
1255
- affectedRoles: [roleId],
1256
- status: 'open',
1257
- owner: input.owner,
1258
- evidenceRefs: [],
1259
- createdAt: input.now,
1260
- updatedAt: input.now,
1261
- }));
1262
- await Promise.all(gaps.map((gap) => assertOperatingArtifact('operating-data-gap', gap)));
1263
- return gaps;
1264
- }
1265
8
  export function evidenceProjectionSources(evidence) {
1266
9
  return evidence.sources.map((source) => ({
1267
10
  id: source.id,
@@ -1274,150 +17,4 @@ export function evidenceProjectionSources(evidence) {
1274
17
  itemCount: source.itemCount,
1275
18
  }));
1276
19
  }
1277
- // The Protocol v1.3 index-item schema pins these patterns; every index item we
1278
- // emit must satisfy them or the pipeline's mission-packet validator fails
1279
- // closed. Building here — rather than trusting the caller — keeps a malformed
1280
- // path or revision out of a dispatched packet.
1281
- const INDEX_PATH_PATTERN = /^(?!.*\.\.)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
1282
- const INDEX_REVISION_PATTERN = /^[a-f0-9]{7,64}$/;
1283
- const INDEX_SOURCE_CLASSIFICATION = {
1284
- repository: 'source-code',
1285
- planr: 'planning-artifact',
1286
- git: 'change-history',
1287
- 'file-import': 'imported-record',
1288
- github: 'integration-record',
1289
- linear: 'integration-record',
1290
- };
1291
- /**
1292
- * Derive a stable, schema-conformant index-item id (`EVX-…`) from the source
1293
- * evidence id. The evidence ids are unique, so the derived ids are too.
1294
- */
1295
- function evidenceIndexItemId(evidenceId) {
1296
- return `EVX-${canonicalDigest(evidenceId).slice('sha256:'.length, 27)}`;
1297
- }
1298
- /**
1299
- * Strip the leading component id from an evidence `location`
1300
- * (`<componentId>/<relativePath>`) to recover the repository-relative path, or
1301
- * return null when the result cannot be a schema-valid index path (for example
1302
- * dot-prefixed `.planr/…` paths, which the pattern forbids).
1303
- */
1304
- function evidenceIndexPath(location) {
1305
- const relative = location.split('/').slice(1).join('/');
1306
- return relative && INDEX_PATH_PATTERN.test(relative) ? relative : null;
1307
- }
1308
- function evidenceIndexSignals(item) {
1309
- const signals = new Set();
1310
- for (const claim of item.claimTypes) {
1311
- const trimmed = claim.trim();
1312
- if (trimmed)
1313
- signals.add(trimmed.slice(0, 256));
1314
- }
1315
- if (item.freshness === 'stale')
1316
- signals.add('stale-evidence');
1317
- if (item.repository?.dirtyFingerprint)
1318
- signals.add('uncommitted-working-tree');
1319
- return [...signals].sort();
1320
- }
1321
- /**
1322
- * Build Protocol v1.3 evidence index items (`operating-evidence-index-item@1.3.0`)
1323
- * from a collected evidence snapshot for mission-mode dispatch. Each item is a
1324
- * pointer only — path OR revision, content hash, source, classification,
1325
- * freshness, sensitivity, and detected signals — and carries NO file body. Git
1326
- * evidence is referenced by its pinned revision; repository/planr/file-import
1327
- * evidence by its repository-relative path. Items that cannot be represented as
1328
- * a schema-valid path or revision pointer (github/linear records, dot-prefixed
1329
- * planr paths) are omitted from the index rather than emitted malformed.
1330
- */
1331
- export function buildOperatingEvidenceIndex(evidence, options = {}) {
1332
- const seen = new Set();
1333
- const items = [];
1334
- for (const item of evidence.items) {
1335
- if (options.sensitivityCeiling &&
1336
- compareSensitivity(item.sensitivity, options.sensitivityCeiling) > 0) {
1337
- continue;
1338
- }
1339
- const source = item.source;
1340
- const classification = INDEX_SOURCE_CLASSIFICATION[source];
1341
- if (!classification)
1342
- continue;
1343
- const base = {
1344
- id: evidenceIndexItemId(item.id),
1345
- contentHash: item.digest,
1346
- source,
1347
- classification,
1348
- freshness: item.freshness,
1349
- sensitivity: item.sensitivity,
1350
- signals: evidenceIndexSignals(item),
1351
- };
1352
- if (seen.has(base.id))
1353
- continue;
1354
- if (source === 'git') {
1355
- const revision = item.repository?.revision;
1356
- if (revision && INDEX_REVISION_PATTERN.test(revision)) {
1357
- seen.add(base.id);
1358
- items.push({ ...base, revision });
1359
- }
1360
- continue;
1361
- }
1362
- if (source === 'repository' || source === 'planr' || source === 'file-import') {
1363
- const relativePath = evidenceIndexPath(item.location);
1364
- if (relativePath) {
1365
- seen.add(base.id);
1366
- items.push({ ...base, path: relativePath });
1367
- }
1368
- }
1369
- }
1370
- return items.sort((left, right) => {
1371
- if (left.source !== right.source)
1372
- return left.source < right.source ? -1 : 1;
1373
- const leftRef = left.path ?? left.revision ?? '';
1374
- const rightRef = right.path ?? right.revision ?? '';
1375
- if (leftRef !== rightRef)
1376
- return leftRef < rightRef ? -1 : 1;
1377
- return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
1378
- });
1379
- }
1380
- /**
1381
- * FR2: count — using the exact predicates `buildOperatingEvidenceIndex` filters
1382
- * on — the path-referenced evidence items a mission evidence index would drop:
1383
- * `pathPattern` for dot-prefixed / pattern-rejected paths, and `sensitivity` for
1384
- * items above a supplied ceiling. Integration records (github/linear) and
1385
- * git-by-revision items are never a "loss" (they are not path pointers), so they
1386
- * are not counted. The caller surfaces the counts as evidence-bundle warnings.
1387
- */
1388
- export function missionEvidenceIndexDrops(items, options = {}) {
1389
- let pathPattern = 0;
1390
- let sensitivity = 0;
1391
- for (const item of items) {
1392
- if (options.sensitivityCeiling &&
1393
- compareSensitivity(item.sensitivity, options.sensitivityCeiling) > 0) {
1394
- sensitivity += 1;
1395
- continue;
1396
- }
1397
- const source = item.source;
1398
- if (!INDEX_SOURCE_CLASSIFICATION[source])
1399
- continue;
1400
- if (source === 'repository' || source === 'planr' || source === 'file-import') {
1401
- if (evidenceIndexPath(item.location) === null)
1402
- pathPattern += 1;
1403
- }
1404
- }
1405
- return { pathPattern, sensitivity };
1406
- }
1407
- /**
1408
- * The read roots a mission agent may traverse, derived from the top-level
1409
- * directory segment of every path-referenced index item. Schema-valid and
1410
- * deduplicated; empty when the index carries only revision pointers.
1411
- */
1412
- export function deriveOperatingDeclaredRoots(index) {
1413
- const roots = new Set();
1414
- for (const item of index) {
1415
- if (!item.path)
1416
- continue;
1417
- const top = item.path.split('/')[0];
1418
- if (top && INDEX_PATH_PATTERN.test(top))
1419
- roots.add(top);
1420
- }
1421
- return [...roots].sort();
1422
- }
1423
20
  //# sourceMappingURL=evidence.js.map