@openprd/cli 0.1.19 → 0.1.22

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 (102) hide show
  1. package/.openprd/changes/openprd-control-plane-v020/.openprd.yaml +2 -0
  2. package/.openprd/changes/openprd-control-plane-v020/design.md +78 -0
  3. package/.openprd/changes/openprd-control-plane-v020/proposal.md +54 -0
  4. package/.openprd/changes/openprd-control-plane-v020/specs/agent-requirements/spec.md +16 -0
  5. package/.openprd/changes/openprd-control-plane-v020/task-events.jsonl +27 -0
  6. package/.openprd/changes/openprd-control-plane-v020/tasks-002.md +35 -0
  7. package/.openprd/changes/openprd-control-plane-v020/tasks.md +427 -0
  8. package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
  9. package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
  10. package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
  11. package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
  12. package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
  13. package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
  14. package/.openprd/design/active/asset-spec.md +19 -14
  15. package/.openprd/design/active/direction-plan.md +19 -3
  16. package/.openprd/design/active/facts-sheet.md +16 -7
  17. package/.openprd/design/active/image-preflight.md +6 -5
  18. package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
  19. package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
  20. package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
  21. package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
  22. package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
  23. package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
  24. package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
  25. package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
  26. package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
  27. package/.openprd/design/active/selected-direction.md +23 -9
  28. package/.openprd/engagements/active/control-plane-architecture.json +203 -0
  29. package/.openprd/engagements/active/control-plane-intake.json +418 -0
  30. package/.openprd/engagements/active/prd.md +183 -119
  31. package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
  32. package/.openprd/i18n-config.json +12 -0
  33. package/.openprd/ledger/events.jsonl +6 -0
  34. package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
  35. package/.openprd/manifest.json +21 -0
  36. package/AGENTS.md +7 -6
  37. package/README.md +31 -29
  38. package/README_EN.md +36 -39
  39. package/package.json +1 -1
  40. package/skills/openprd-frontend-design/SKILL.md +16 -0
  41. package/skills/openprd-harness/SKILL.md +7 -7
  42. package/skills/openprd-quality/SKILL.md +1 -1
  43. package/skills/openprd-requirement-intake/SKILL.md +1 -1
  44. package/skills/openprd-shared/SKILL.md +6 -6
  45. package/src/adapters/adapter-spi.js +193 -0
  46. package/src/adapters/capability-envelope.js +98 -0
  47. package/src/adapters/event-normalizer.js +55 -0
  48. package/src/adapters/index.js +4 -0
  49. package/src/adapters/install-safety.js +249 -0
  50. package/src/agent-canonical-content.js +4 -2
  51. package/src/agent-integration.js +169 -46
  52. package/src/cli/args.js +63 -4
  53. package/src/cli/gate-print.js +17 -0
  54. package/src/cli/quality-commands.js +18 -0
  55. package/src/cli/quality-print.js +10 -0
  56. package/src/cli/runtime-print.js +24 -0
  57. package/src/codex-hook-runner-template.mjs +129 -199
  58. package/src/codex-runtime.js +48 -5
  59. package/src/context/cache.js +245 -0
  60. package/src/context/compiler.js +438 -0
  61. package/src/context/constants.js +30 -0
  62. package/src/context/index.js +39 -0
  63. package/src/context/redaction.js +84 -0
  64. package/src/context/stable.js +69 -0
  65. package/src/context/telemetry.js +42 -0
  66. package/src/dev-standards.js +57 -0
  67. package/src/fleet.js +112 -95
  68. package/src/gates/index.js +2 -0
  69. package/src/gates/scoped-gates.js +256 -0
  70. package/src/gates/store.js +126 -0
  71. package/src/gates/workspace.js +41 -0
  72. package/src/html-artifacts.js +725 -28
  73. package/src/kernel/atomic-store.js +299 -0
  74. package/src/kernel/event-envelope.js +166 -0
  75. package/src/kernel/index.js +4 -0
  76. package/src/kernel/project-ledger.js +467 -0
  77. package/src/kernel/project-manifest.js +205 -0
  78. package/src/knowledge-v3/index.js +1 -0
  79. package/src/knowledge-v3/lifecycle.js +290 -0
  80. package/src/knowledge.js +14 -7
  81. package/src/openprd.js +71 -2
  82. package/src/review-model.js +413 -0
  83. package/src/review-presentation.js +1 -1
  84. package/src/run-harness.js +432 -38
  85. package/src/runtime/cli_runtime_README.md +28 -0
  86. package/src/runtime/errors.js +66 -0
  87. package/src/runtime/index.js +44 -0
  88. package/src/runtime/lane-schema.js +141 -0
  89. package/src/runtime/lane-store.js +279 -0
  90. package/src/runtime/task-runtime.js +449 -0
  91. package/src/runtime/workspace.js +179 -0
  92. package/src/runtime/write-set.js +206 -0
  93. package/src/session-binding.js +16 -3
  94. package/src/session-registry.js +59 -1
  95. package/src/upgrade/fleet-mutation.js +166 -0
  96. package/src/upgrade/fleet-transaction.js +398 -0
  97. package/src/upgrade/transaction-store.js +416 -0
  98. package/src/visual-compare-core.js +66 -27
  99. package/src/visual-compare.js +18 -12
  100. package/src/workspace-core.js +109 -7
  101. package/src/workspace-registry.js +39 -1
  102. package/src/workspace-workflow.js +18 -15
@@ -0,0 +1,438 @@
1
+ import {
2
+ CONTEXT_AUTHORITY_SCORES,
3
+ CONTEXT_CAPSULE_SCHEMA_VERSION,
4
+ CONTEXT_COMPILER_VERSION,
5
+ CONTEXT_FRESHNESS_SCORES,
6
+ CONTEXT_REQUEST_SCHEMA_VERSION,
7
+ DEFAULT_CONTEXT_DELTA_TOKEN_BUDGET,
8
+ DEFAULT_CONTEXT_TOKEN_BUDGET,
9
+ } from './constants.js';
10
+ import { redactContextSecrets } from './redaction.js';
11
+ import {
12
+ cloneStable,
13
+ compareStableText,
14
+ digestStable,
15
+ digestText,
16
+ stableStringify,
17
+ } from './stable.js';
18
+
19
+ function positiveInteger(value, fallback) {
20
+ const parsed = Number(value);
21
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
22
+ return Math.max(1, Math.floor(parsed));
23
+ }
24
+
25
+ function finiteScore(value, fallback = 0) {
26
+ const parsed = Number(value);
27
+ return Number.isFinite(parsed) ? parsed : fallback;
28
+ }
29
+
30
+ function normalizeDelivery(value) {
31
+ return ['auto', 'full', 'delta'].includes(value) ? value : 'auto';
32
+ }
33
+
34
+ function normalizeFileContentPolicy(value) {
35
+ return value === 'inline' ? 'inline' : 'reference';
36
+ }
37
+
38
+ function canonicalCandidateOrder(left, right) {
39
+ return compareStableText(stableStringify(left), stableStringify(right));
40
+ }
41
+
42
+ /**
43
+ * Build a ContextRequest v1 without mutating the caller's input.
44
+ * A revision is mandatory so cache and delta behavior never depend on wall time.
45
+ */
46
+ export function createContextRequest(input = {}) {
47
+ if (input.schemaVersion && input.schemaVersion !== CONTEXT_REQUEST_SCHEMA_VERSION) {
48
+ throw new TypeError(`Unsupported context request schema: ${input.schemaVersion}`);
49
+ }
50
+ const revision = String(input.revision ?? '').trim();
51
+ if (!revision) {
52
+ throw new TypeError('ContextRequest v1 requires a non-empty revision.');
53
+ }
54
+ const rawCandidates = input.candidates ?? input.sources ?? [];
55
+ if (!Array.isArray(rawCandidates)) {
56
+ throw new TypeError('ContextRequest v1 candidates must be an array.');
57
+ }
58
+
59
+ const candidates = rawCandidates
60
+ .map((candidate) => cloneStable(candidate && typeof candidate === 'object' ? candidate : { value: candidate }))
61
+ .sort(canonicalCandidateOrder);
62
+ return {
63
+ schemaVersion: CONTEXT_REQUEST_SCHEMA_VERSION,
64
+ compilerVersion: CONTEXT_COMPILER_VERSION,
65
+ workspaceId: String(input.workspaceId ?? 'workspace'),
66
+ revision,
67
+ taskId: input.taskId == null ? null : String(input.taskId),
68
+ intent: input.intent == null ? null : String(input.intent),
69
+ delivery: normalizeDelivery(input.delivery),
70
+ tokenBudget: positiveInteger(input.tokenBudget, DEFAULT_CONTEXT_TOKEN_BUDGET),
71
+ deltaTokenBudget: positiveInteger(input.deltaTokenBudget, DEFAULT_CONTEXT_DELTA_TOKEN_BUDGET),
72
+ fileContentPolicy: normalizeFileContentPolicy(input.fileContentPolicy),
73
+ candidates,
74
+ };
75
+ }
76
+
77
+ function normalizeAuthority(value) {
78
+ if (typeof value === 'number') {
79
+ return { label: 'numeric', score: finiteScore(value) };
80
+ }
81
+ if (value && typeof value === 'object') {
82
+ const label = String(value.label ?? value.kind ?? value.name ?? 'custom');
83
+ const fallback = CONTEXT_AUTHORITY_SCORES[label] ?? 0;
84
+ return { label, score: finiteScore(value.score, fallback) };
85
+ }
86
+ const label = String(value ?? 'unknown').trim().toLowerCase() || 'unknown';
87
+ return { label, score: CONTEXT_AUTHORITY_SCORES[label] ?? CONTEXT_AUTHORITY_SCORES.unknown };
88
+ }
89
+
90
+ function normalizeFreshness(value) {
91
+ if (typeof value === 'number') {
92
+ return { value, score: finiteScore(value) };
93
+ }
94
+ if (value instanceof Date) {
95
+ const iso = value.toISOString();
96
+ return { value: iso, score: value.getTime() };
97
+ }
98
+ if (value && typeof value === 'object') {
99
+ const label = String(value.value ?? value.label ?? value.kind ?? 'unknown');
100
+ const parsedTimestamp = Date.parse(label);
101
+ const fallback = CONTEXT_FRESHNESS_SCORES[label]
102
+ ?? (Number.isFinite(parsedTimestamp) ? parsedTimestamp : 0);
103
+ return { value: label, score: finiteScore(value.score, fallback) };
104
+ }
105
+ const label = String(value ?? 'unknown').trim().toLowerCase() || 'unknown';
106
+ if (Object.hasOwn(CONTEXT_FRESHNESS_SCORES, label)) {
107
+ return { value: label, score: CONTEXT_FRESHNESS_SCORES[label] };
108
+ }
109
+ const timestamp = Date.parse(label);
110
+ return { value: label, score: Number.isFinite(timestamp) ? timestamp : 0 };
111
+ }
112
+
113
+ function normalizeRelevance(value) {
114
+ return finiteScore(value, 0);
115
+ }
116
+
117
+ function estimateTokens(value) {
118
+ return Math.max(1, Math.ceil(Buffer.byteLength(stableStringify(value), 'utf8') / 4));
119
+ }
120
+
121
+ function normalizedFileCandidate(candidate, request) {
122
+ const file = candidate.file ?? candidate.fileRef ?? candidate;
123
+ const pathResult = redactContextSecrets(String(file.path ?? candidate.path ?? ''));
124
+ const rawContent = file.content ?? candidate.content;
125
+ const contentString = rawContent == null
126
+ ? null
127
+ : typeof rawContent === 'string'
128
+ ? rawContent
129
+ : stableStringify(rawContent);
130
+ const fileDigest = file.digest
131
+ ? String(file.digest)
132
+ : contentString != null
133
+ ? digestText(contentString)
134
+ : digestText(`path:${pathResult.value}`);
135
+ const value = {
136
+ type: 'file-ref',
137
+ path: pathResult.value,
138
+ digest: fileDigest,
139
+ sizeBytes: file.sizeBytes == null
140
+ ? contentString == null ? null : Buffer.byteLength(contentString, 'utf8')
141
+ : Math.max(0, Math.floor(finiteScore(file.sizeBytes))),
142
+ };
143
+ const redactions = [...pathResult.redactions];
144
+ const reasonCodes = ['file-reference'];
145
+ if (request.fileContentPolicy === 'inline' && contentString != null) {
146
+ const contentResult = redactContextSecrets(contentString);
147
+ value.content = contentResult.value;
148
+ redactions.push(...contentResult.redactions);
149
+ reasonCodes.push('file-content-explicitly-inlined');
150
+ } else if (contentString != null) {
151
+ reasonCodes.push('file-content-omitted');
152
+ }
153
+ return { value, redactions, reasonCodes };
154
+ }
155
+
156
+ function candidateValue(candidate, request) {
157
+ const isFile = candidate.kind === 'file'
158
+ || candidate.file != null
159
+ || candidate.fileRef != null
160
+ || (candidate.path != null && candidate.content != null);
161
+ if (isFile) {
162
+ return normalizedFileCandidate(candidate, request);
163
+ }
164
+ const rawValue = candidate.data
165
+ ?? candidate.value
166
+ ?? candidate.content
167
+ ?? candidate.summary
168
+ ?? candidate.text
169
+ ?? null;
170
+ const result = redactContextSecrets(rawValue);
171
+ return {
172
+ value: result.value,
173
+ redactions: result.redactions,
174
+ reasonCodes: [],
175
+ };
176
+ }
177
+
178
+ function normalizeCandidate(candidate, request) {
179
+ const authority = normalizeAuthority(candidate.authority);
180
+ const freshness = normalizeFreshness(candidate.freshness);
181
+ const relevance = normalizeRelevance(candidate.relevance);
182
+ const kind = String(candidate.kind ?? (candidate.file || candidate.fileRef ? 'file' : 'fact'));
183
+ const normalizedValue = candidateValue(candidate, request);
184
+ const derivedIdentity = digestStable({ kind, value: normalizedValue.value });
185
+ const rawId = candidate.id ?? candidate.key ?? `${kind}:${derivedIdentity.slice('sha256:'.length, 'sha256:'.length + 20)}`;
186
+ const idResult = redactContextSecrets(String(rawId));
187
+ const redactions = [...idResult.redactions, ...normalizedValue.redactions];
188
+ const value = normalizedValue.value;
189
+ const digest = digestStable({ authority, freshness, id: idResult.value, kind, relevance, value });
190
+ const estimatedTokens = estimateTokens({ id: idResult.value, kind, value });
191
+ return {
192
+ id: idResult.value,
193
+ kind,
194
+ authority,
195
+ freshness,
196
+ relevance,
197
+ value,
198
+ digest,
199
+ estimatedTokens,
200
+ redactionCount: redactions.length,
201
+ redactionReasons: [...new Set(redactions.map((item) => item.reason))].sort(compareStableText),
202
+ reasonCodes: normalizedValue.reasonCodes,
203
+ empty: value == null || value === '',
204
+ };
205
+ }
206
+
207
+ function compareCandidates(left, right) {
208
+ if (left.authority.score !== right.authority.score) {
209
+ return right.authority.score - left.authority.score;
210
+ }
211
+ if (left.freshness.score !== right.freshness.score) {
212
+ return right.freshness.score - left.freshness.score;
213
+ }
214
+ if (left.relevance !== right.relevance) {
215
+ return right.relevance - left.relevance;
216
+ }
217
+ const idOrder = compareStableText(left.id, right.id);
218
+ if (idOrder !== 0) return idOrder;
219
+ return compareStableText(left.digest, right.digest);
220
+ }
221
+
222
+ function uniqueRankedCandidates(candidates) {
223
+ const ranked = [...candidates].sort(compareCandidates);
224
+ const seen = new Set();
225
+ const unique = [];
226
+ const duplicates = [];
227
+ for (const candidate of ranked) {
228
+ if (seen.has(candidate.id)) {
229
+ duplicates.push(candidate);
230
+ continue;
231
+ }
232
+ seen.add(candidate.id);
233
+ unique.push(candidate);
234
+ }
235
+ return { unique, duplicates };
236
+ }
237
+
238
+ function previousEntryMap(previousCapsule) {
239
+ const previous = new Map();
240
+ for (const entry of previousCapsule?.excluded ?? []) {
241
+ if (
242
+ entry?.id
243
+ && entry?.digest
244
+ && entry.excludedReason !== 'duplicate-lower-ranked-candidate'
245
+ && entry.excludedReason !== 'empty-candidate'
246
+ ) {
247
+ previous.set(String(entry.id), String(entry.digest));
248
+ }
249
+ }
250
+ // Delivered entries are authoritative when an older capsule contains both a
251
+ // selected candidate and a lower-ranked duplicate exclusion with the same id.
252
+ for (const entry of previousCapsule?.entries ?? []) {
253
+ if (entry?.id && entry?.digest) previous.set(String(entry.id), String(entry.digest));
254
+ }
255
+ return previous;
256
+ }
257
+
258
+ function resolveDelivery(request, previousCapsule) {
259
+ if (request.delivery === 'full') return 'full';
260
+ if (request.delivery === 'delta' && previousCapsule) return 'delta';
261
+ if (request.delivery === 'auto' && previousCapsule) return 'delta';
262
+ return 'full';
263
+ }
264
+
265
+ function contextRequestIdentity(request, normalizedCandidates, previousCapsule, delivery) {
266
+ return {
267
+ schemaVersion: request.schemaVersion,
268
+ compilerVersion: request.compilerVersion,
269
+ workspaceId: request.workspaceId,
270
+ revision: request.revision,
271
+ taskId: request.taskId,
272
+ intent: request.intent,
273
+ delivery,
274
+ tokenBudget: request.tokenBudget,
275
+ deltaTokenBudget: request.deltaTokenBudget,
276
+ fileContentPolicy: request.fileContentPolicy,
277
+ previousCapsuleDigest: delivery === 'delta' ? previousCapsule?.digest ?? null : null,
278
+ candidates: normalizedCandidates
279
+ .map((candidate) => ({ id: candidate.id, digest: candidate.digest }))
280
+ .sort((left, right) => compareStableText(left.id, right.id) || compareStableText(left.digest, right.digest)),
281
+ };
282
+ }
283
+
284
+ export function buildContextRequestFingerprint(input, { previousCapsule = null } = {}) {
285
+ const request = createContextRequest(input);
286
+ const normalized = request.candidates.map((candidate) => normalizeCandidate(candidate, request));
287
+ const { unique } = uniqueRankedCandidates(normalized);
288
+ const delivery = resolveDelivery(request, previousCapsule);
289
+ return digestStable(contextRequestIdentity(request, unique, previousCapsule, delivery));
290
+ }
291
+
292
+ function asIncludedEntry(candidate, rank) {
293
+ const reasonCodes = [
294
+ 'authority-freshness-relevance-ranked',
295
+ 'within-token-budget',
296
+ ...candidate.reasonCodes,
297
+ ];
298
+ if (candidate.redactionCount > 0) reasonCodes.push('secrets-redacted');
299
+ return {
300
+ id: candidate.id,
301
+ kind: candidate.kind,
302
+ authority: candidate.authority,
303
+ freshness: candidate.freshness,
304
+ relevance: candidate.relevance,
305
+ value: candidate.value,
306
+ digest: candidate.digest,
307
+ rank,
308
+ estimatedTokens: candidate.estimatedTokens,
309
+ includedReason: [...new Set(reasonCodes)],
310
+ redactionCount: candidate.redactionCount,
311
+ redactionReasons: candidate.redactionReasons,
312
+ };
313
+ }
314
+
315
+ function excludedEntry(candidate, rank, reason, extra = {}) {
316
+ return {
317
+ id: candidate.id,
318
+ digest: candidate.digest,
319
+ rank,
320
+ estimatedTokens: candidate.estimatedTokens,
321
+ excludedReason: reason,
322
+ ...extra,
323
+ };
324
+ }
325
+
326
+ function compareExcluded(left, right) {
327
+ if (left.rank !== right.rank) return left.rank - right.rank;
328
+ return compareStableText(left.id, right.id) || compareStableText(left.excludedReason, right.excludedReason);
329
+ }
330
+
331
+ export function computeContextCapsuleDigest(capsule) {
332
+ const { digest: _digest, ...core } = capsule;
333
+ return digestStable(core);
334
+ }
335
+
336
+ export function verifyContextCapsule(capsule) {
337
+ return Boolean(
338
+ capsule
339
+ && capsule.schemaVersion === CONTEXT_CAPSULE_SCHEMA_VERSION
340
+ && typeof capsule.digest === 'string'
341
+ && capsule.digest === computeContextCapsuleDigest(capsule),
342
+ );
343
+ }
344
+
345
+ /**
346
+ * Compile a deterministic ContextCapsule v1. This function performs no I/O and
347
+ * does not emit telemetry; cache and telemetry side effects live in separate APIs.
348
+ */
349
+ export function compileContextCapsule(input, { previousCapsule = null } = {}) {
350
+ const request = createContextRequest(input);
351
+ const normalized = request.candidates.map((candidate) => normalizeCandidate(candidate, request));
352
+ const { unique, duplicates } = uniqueRankedCandidates(normalized);
353
+ const delivery = resolveDelivery(request, previousCapsule);
354
+ const requestDigest = digestStable(contextRequestIdentity(request, unique, previousCapsule, delivery));
355
+ const previous = previousEntryMap(previousCapsule);
356
+ const currentIds = new Set(unique.map((candidate) => candidate.id));
357
+ const delta = {
358
+ previousCapsuleDigest: delivery === 'delta' ? previousCapsule?.digest ?? null : null,
359
+ added: [],
360
+ changed: [],
361
+ removed: delivery === 'delta'
362
+ ? [...previous.keys()].filter((id) => !currentIds.has(id)).sort(compareStableText)
363
+ : [],
364
+ unchanged: [],
365
+ };
366
+ const excluded = [];
367
+ duplicates.forEach((candidate) => {
368
+ const rank = unique.findIndex((item) => item.id === candidate.id) + 1;
369
+ excluded.push(excludedEntry(candidate, rank, 'duplicate-lower-ranked-candidate'));
370
+ });
371
+
372
+ const eligible = [];
373
+ unique.forEach((candidate, index) => {
374
+ const rank = index + 1;
375
+ if (candidate.empty) {
376
+ excluded.push(excludedEntry(candidate, rank, 'empty-candidate'));
377
+ return;
378
+ }
379
+ if (delivery !== 'delta') {
380
+ eligible.push({ candidate, rank });
381
+ return;
382
+ }
383
+ const previousDigest = previous.get(candidate.id);
384
+ if (previousDigest === candidate.digest) {
385
+ delta.unchanged.push(candidate.id);
386
+ excluded.push(excludedEntry(candidate, rank, 'unchanged-since-previous-capsule'));
387
+ return;
388
+ }
389
+ if (previousDigest) delta.changed.push(candidate.id);
390
+ else delta.added.push(candidate.id);
391
+ eligible.push({ candidate, rank });
392
+ });
393
+
394
+ const limitTokens = delivery === 'delta' ? request.deltaTokenBudget : request.tokenBudget;
395
+ let usedTokens = 0;
396
+ const entries = [];
397
+ for (const { candidate, rank } of eligible) {
398
+ if (usedTokens + candidate.estimatedTokens > limitTokens) {
399
+ excluded.push(excludedEntry(candidate, rank, 'token-budget-exceeded', {
400
+ remainingTokens: Math.max(0, limitTokens - usedTokens),
401
+ }));
402
+ continue;
403
+ }
404
+ entries.push(asIncludedEntry(candidate, rank));
405
+ usedTokens += candidate.estimatedTokens;
406
+ }
407
+
408
+ delta.added.sort(compareStableText);
409
+ delta.changed.sort(compareStableText);
410
+ delta.unchanged.sort(compareStableText);
411
+ excluded.sort(compareExcluded);
412
+
413
+ const capsule = {
414
+ schemaVersion: CONTEXT_CAPSULE_SCHEMA_VERSION,
415
+ compilerVersion: CONTEXT_COMPILER_VERSION,
416
+ requestDigest,
417
+ workspaceId: request.workspaceId,
418
+ revision: request.revision,
419
+ taskId: request.taskId,
420
+ delivery,
421
+ budget: {
422
+ limitTokens,
423
+ estimatedTokens: usedTokens,
424
+ remainingTokens: Math.max(0, limitTokens - usedTokens),
425
+ },
426
+ entries,
427
+ excluded,
428
+ delta,
429
+ summary: {
430
+ candidateCount: normalized.length,
431
+ includedCount: entries.length,
432
+ excludedCount: excluded.length,
433
+ redactionCount: entries.reduce((sum, entry) => sum + entry.redactionCount, 0),
434
+ },
435
+ };
436
+ capsule.digest = computeContextCapsuleDigest(capsule);
437
+ return capsule;
438
+ }
@@ -0,0 +1,30 @@
1
+ export const CONTEXT_REQUEST_SCHEMA_VERSION = 'openprd.context-request.v1';
2
+ export const CONTEXT_CAPSULE_SCHEMA_VERSION = 'openprd.context-capsule.v1';
3
+ export const CONTEXT_CACHE_SCHEMA_VERSION = 'openprd.context-cache-entry.v1';
4
+ export const CONTEXT_TELEMETRY_SCHEMA_VERSION = 'openprd.context-telemetry.v1';
5
+
6
+ export const CONTEXT_COMPILER_VERSION = '1.0.0';
7
+ export const DEFAULT_CONTEXT_TOKEN_BUDGET = 800;
8
+ export const DEFAULT_CONTEXT_DELTA_TOKEN_BUDGET = 250;
9
+
10
+ // Authority is intentionally explicit. Callers can still provide a numeric score,
11
+ // but named values keep cross-tool adapters aligned on the same precedence rules.
12
+ export const CONTEXT_AUTHORITY_SCORES = Object.freeze({
13
+ 'user-confirmed': 100,
14
+ 'project-canonical': 90,
15
+ 'runtime-observed': 80,
16
+ 'project-derived': 70,
17
+ 'tool-derived': 60,
18
+ 'agent-inferred': 40,
19
+ external: 30,
20
+ unknown: 0,
21
+ });
22
+
23
+ export const CONTEXT_FRESHNESS_SCORES = Object.freeze({
24
+ current: 4,
25
+ latest: 3,
26
+ recent: 2,
27
+ historical: 1,
28
+ stale: 0,
29
+ unknown: 0,
30
+ });
@@ -0,0 +1,39 @@
1
+ export {
2
+ CONTEXT_AUTHORITY_SCORES,
3
+ CONTEXT_CACHE_SCHEMA_VERSION,
4
+ CONTEXT_CAPSULE_SCHEMA_VERSION,
5
+ CONTEXT_COMPILER_VERSION,
6
+ CONTEXT_FRESHNESS_SCORES,
7
+ CONTEXT_REQUEST_SCHEMA_VERSION,
8
+ CONTEXT_TELEMETRY_SCHEMA_VERSION,
9
+ DEFAULT_CONTEXT_DELTA_TOKEN_BUDGET,
10
+ DEFAULT_CONTEXT_TOKEN_BUDGET,
11
+ } from './constants.js';
12
+
13
+ export {
14
+ buildContextRequestFingerprint,
15
+ compileContextCapsule,
16
+ computeContextCapsuleDigest,
17
+ createContextRequest,
18
+ verifyContextCapsule,
19
+ } from './compiler.js';
20
+
21
+ export {
22
+ buildContextCacheKey,
23
+ compileContextWithCache,
24
+ contextCachePath,
25
+ createFileContextCache,
26
+ createMemoryContextCache,
27
+ invalidateContextCache,
28
+ readContextCache,
29
+ runContextCacheBenchmark,
30
+ writeContextCache,
31
+ } from './cache.js';
32
+
33
+ export {
34
+ createContextTelemetryEvent,
35
+ emitContextTelemetry,
36
+ } from './telemetry.js';
37
+
38
+ export { redactContextSecrets } from './redaction.js';
39
+ export { digestStable, digestText, stableStringify } from './stable.js';
@@ -0,0 +1,84 @@
1
+ const SECRET_KEY_PATTERN = /^(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|auth(?:orization)?|client[_-]?secret|password|passwd|secret|private[_-]?key)$/i;
2
+
3
+ const TEXT_PATTERNS = [
4
+ {
5
+ reason: 'private-key',
6
+ pattern: /-----BEGIN(?: [A-Z0-9]+)? PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)? PRIVATE KEY-----/g,
7
+ replacement: '[REDACTED:PRIVATE_KEY]',
8
+ },
9
+ {
10
+ reason: 'bearer-token',
11
+ pattern: /(\bBearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi,
12
+ replacement: '$1[REDACTED]',
13
+ },
14
+ {
15
+ reason: 'credential-assignment',
16
+ pattern: /(\b(?:[a-z0-9]+[_-])*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|authorization|client[_-]?secret|password|passwd|secret)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
17
+ replacement: '$1[REDACTED]',
18
+ },
19
+ {
20
+ reason: 'provider-token',
21
+ pattern: /\b(?:sk-(?:proj-)?|gh[pousr]_|xox[baprs]-)[A-Za-z0-9_-]{8,}\b/g,
22
+ replacement: '[REDACTED:TOKEN]',
23
+ },
24
+ {
25
+ reason: 'jwt',
26
+ pattern: /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\b/g,
27
+ replacement: '[REDACTED:JWT]',
28
+ },
29
+ {
30
+ reason: 'url-credential',
31
+ pattern: /:\/\/[^/\s:@]+:[^@\s/]+@/g,
32
+ replacement: '://[REDACTED]@',
33
+ },
34
+ ];
35
+
36
+ function redactText(text, path, redactions) {
37
+ let output = String(text);
38
+ for (const item of TEXT_PATTERNS) {
39
+ output = output.replace(item.pattern, (...args) => {
40
+ redactions.push({ path, reason: item.reason });
41
+ if (typeof item.replacement === 'function') {
42
+ return item.replacement(...args);
43
+ }
44
+ return item.replacement.replace(/\$(\d+)/g, (_, index) => args[Number(index)] ?? '');
45
+ });
46
+ }
47
+ return output;
48
+ }
49
+
50
+ function redactValue(value, path, redactions, seen) {
51
+ if (typeof value === 'string') {
52
+ return redactText(value, path, redactions);
53
+ }
54
+ if (Array.isArray(value)) {
55
+ return value.map((item, index) => redactValue(item, `${path}[${index}]`, redactions, seen));
56
+ }
57
+ if (!value || typeof value !== 'object') {
58
+ return value;
59
+ }
60
+ if (seen.has(value)) {
61
+ throw new TypeError('Context values must not contain circular references.');
62
+ }
63
+ seen.add(value);
64
+ const output = {};
65
+ for (const key of Object.keys(value).sort()) {
66
+ const childPath = path ? `${path}.${key}` : key;
67
+ if (SECRET_KEY_PATTERN.test(key)) {
68
+ output[key] = '[REDACTED]';
69
+ redactions.push({ path: childPath, reason: 'secret-field' });
70
+ continue;
71
+ }
72
+ output[key] = redactValue(value[key], childPath, redactions, seen);
73
+ }
74
+ seen.delete(value);
75
+ return output;
76
+ }
77
+
78
+ export function redactContextSecrets(value) {
79
+ const redactions = [];
80
+ return {
81
+ value: redactValue(value, '$', redactions, new WeakSet()),
82
+ redactions,
83
+ };
84
+ }
@@ -0,0 +1,69 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ function normalizeStableValue(value, seen = new WeakSet()) {
4
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') {
5
+ return value;
6
+ }
7
+ if (typeof value === 'number') {
8
+ return Number.isFinite(value) ? value : String(value);
9
+ }
10
+ if (typeof value === 'bigint') {
11
+ return value.toString();
12
+ }
13
+ if (value instanceof Date) {
14
+ return value.toISOString();
15
+ }
16
+ if (Array.isArray(value)) {
17
+ if (seen.has(value)) {
18
+ throw new TypeError('Context values must not contain circular references.');
19
+ }
20
+ seen.add(value);
21
+ const normalized = value.map((item) => (
22
+ item === undefined || typeof item === 'function' || typeof item === 'symbol'
23
+ ? null
24
+ : normalizeStableValue(item, seen)
25
+ ));
26
+ seen.delete(value);
27
+ return normalized;
28
+ }
29
+ if (typeof value !== 'object' || value === undefined) {
30
+ return null;
31
+ }
32
+ if (seen.has(value)) {
33
+ throw new TypeError('Context values must not contain circular references.');
34
+ }
35
+ seen.add(value);
36
+ const normalized = {};
37
+ for (const key of Object.keys(value).sort()) {
38
+ const item = value[key];
39
+ if (item === undefined || typeof item === 'function' || typeof item === 'symbol') {
40
+ continue;
41
+ }
42
+ normalized[key] = normalizeStableValue(item, seen);
43
+ }
44
+ seen.delete(value);
45
+ return normalized;
46
+ }
47
+
48
+ export function stableStringify(value) {
49
+ return JSON.stringify(normalizeStableValue(value));
50
+ }
51
+
52
+ export function digestText(value) {
53
+ return `sha256:${createHash('sha256').update(String(value)).digest('hex')}`;
54
+ }
55
+
56
+ export function digestStable(value) {
57
+ return digestText(stableStringify(value));
58
+ }
59
+
60
+ export function compareStableText(left, right) {
61
+ const a = String(left ?? '');
62
+ const b = String(right ?? '');
63
+ if (a === b) return 0;
64
+ return a < b ? -1 : 1;
65
+ }
66
+
67
+ export function cloneStable(value) {
68
+ return JSON.parse(stableStringify(value));
69
+ }
@@ -0,0 +1,42 @@
1
+ import { CONTEXT_TELEMETRY_SCHEMA_VERSION } from './constants.js';
2
+
3
+ export function createContextTelemetryEvent({
4
+ capsule,
5
+ cacheStatus = 'not-used',
6
+ eventType = 'context.compiled',
7
+ } = {}) {
8
+ if (!capsule?.digest) {
9
+ throw new TypeError('Context telemetry requires a compiled capsule.');
10
+ }
11
+ return {
12
+ schemaVersion: CONTEXT_TELEMETRY_SCHEMA_VERSION,
13
+ eventType,
14
+ requestDigest: capsule.requestDigest,
15
+ capsuleDigest: capsule.digest,
16
+ workspaceId: capsule.workspaceId,
17
+ revision: capsule.revision,
18
+ taskId: capsule.taskId,
19
+ delivery: capsule.delivery,
20
+ cacheStatus,
21
+ metrics: {
22
+ candidateCount: capsule.summary.candidateCount,
23
+ includedCount: capsule.summary.includedCount,
24
+ excludedCount: capsule.summary.excludedCount,
25
+ redactionCount: capsule.summary.redactionCount,
26
+ estimatedTokens: capsule.budget.estimatedTokens,
27
+ limitTokens: capsule.budget.limitTokens,
28
+ },
29
+ };
30
+ }
31
+
32
+ export async function emitContextTelemetry(sink, event) {
33
+ if (typeof sink === 'function') {
34
+ await sink(event);
35
+ return;
36
+ }
37
+ if (sink && typeof sink.record === 'function') {
38
+ await sink.record(event);
39
+ return;
40
+ }
41
+ throw new TypeError('Context telemetry sink must be a function or expose record().');
42
+ }