@openprd/cli 0.1.19 → 0.1.21

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 +1 -0
  37. package/README.md +2 -0
  38. package/README_EN.md +2 -0
  39. package/package.json +1 -1
  40. package/skills/openprd-frontend-design/SKILL.md +16 -0
  41. package/skills/openprd-harness/SKILL.md +1 -1
  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 +1 -1
  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 +147 -42
  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 +65 -77
  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 +431 -37
  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
@@ -765,45 +765,22 @@ function detectExecutionSignals(eventName, payload) {
765
765
  };
766
766
  }
767
767
 
768
- function latestSessionRegistryEntry(root, sessionId) {
769
- if (!sessionId) {
770
- return null;
771
- }
772
- const filePath = sessionRegistryPath();
773
- if (!fs.existsSync(filePath)) {
774
- return null;
775
- }
776
- const workspaceRoot = path.resolve(root);
777
- let latest = null;
778
- for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
779
- if (!line.trim()) {
780
- continue;
781
- }
782
- try {
783
- const entry = JSON.parse(line);
784
- if (entry?.sessionId !== sessionId) {
785
- continue;
786
- }
787
- const entryRoot = entry?.workspaceRoot ? path.resolve(String(entry.workspaceRoot)) : null;
788
- if (entryRoot && entryRoot !== workspaceRoot) {
789
- continue;
790
- }
791
- latest = entry;
792
- } catch {}
793
- }
794
- return latest;
795
- }
796
-
797
768
  function rememberAutomationMode(root, sessionId, payload, signals, laneKind) {
798
769
  if (!sessionId) {
799
770
  return null;
800
771
  }
801
- return writeSessionRegistryEntry(root, sessionId, {
772
+ const gateStatus = laneKind === 'automation' ? 'automation-safe-mode' : 'openprd-opt-in';
773
+ const existing = readSessionBinding(root, sessionId);
774
+ // 每个 hook 事件都会经过这里;同一会话的 lane 记忆只需要落一次盘。
775
+ if (existing?.laneKind === laneKind && existing?.gateStatus === gateStatus) {
776
+ return existing;
777
+ }
778
+ return writeSessionBinding(root, sessionId, {
802
779
  laneKind,
803
780
  tool: 'openprd-hook',
804
781
  promptPreview: preview(promptText(payload) || payloadText(payload), 500),
805
782
  gateActive: false,
806
- gateStatus: laneKind === 'automation' ? 'automation-safe-mode' : 'openprd-opt-in',
783
+ gateStatus,
807
784
  title: laneKind === 'automation'
808
785
  ? 'Unattended automation bypassed OpenPrd'
809
786
  : 'Unattended automation explicitly enabled OpenPrd',
@@ -820,7 +797,7 @@ function automationBypassDecision(root, eventName, payload, sessionId) {
820
797
  if (signals.isAutomation) {
821
798
  return { bypass: true, optIn: false, remembered: false, signals };
822
799
  }
823
- const remembered = eventName === 'UserPromptSubmit' ? null : latestSessionRegistryEntry(root, sessionId);
800
+ const remembered = eventName === 'UserPromptSubmit' ? null : readSessionBinding(root, sessionId);
824
801
  if (remembered?.laneKind === 'automation' && remembered?.gateStatus === 'automation-safe-mode') {
825
802
  return { bypass: true, optIn: false, remembered: true, signals };
826
803
  }
@@ -912,44 +889,68 @@ function readSessionBinding(root, sessionId = null) {
912
889
  return readJsonSync(sessionBindingPath(root, sessionId), null);
913
890
  }
914
891
 
915
- function appendJsonLine(filePath, value) {
892
+ function appendJsonLineAtomic(filePath, value) {
916
893
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
917
- fs.appendFileSync(filePath, JSON.stringify(value) + '\n');
894
+ const fd = fs.openSync(filePath, 'a', 0o600);
895
+ try {
896
+ fs.writeSync(fd, `${JSON.stringify(value)}\n`);
897
+ fs.fsyncSync(fd);
898
+ } finally {
899
+ fs.closeSync(fd);
900
+ }
901
+ }
902
+
903
+ function sessionRegistryProjection(entry = {}) {
904
+ return {
905
+ sessionId: entry.sessionId ?? null,
906
+ workspaceRoot: entry.workspaceRoot ?? null,
907
+ laneKind: entry.laneKind ?? 'requirement',
908
+ tool: entry.tool ?? 'codex',
909
+ threadId: entry.threadId ?? null,
910
+ changeId: entry.changeId ?? null,
911
+ taskHandle: entry.taskHandle ?? null,
912
+ workUnitId: entry.workUnitId ?? null,
913
+ versionId: entry.versionId ?? null,
914
+ digest: entry.digest ?? null,
915
+ title: entry.title ?? null,
916
+ targetRoot: entry.targetRoot ?? null,
917
+ promptPreview: entry.promptPreview ?? null,
918
+ reviewStatus: entry.reviewStatus ?? null,
919
+ gateStatus: entry.gateStatus ?? null,
920
+ gateActive: entry.gateActive === true,
921
+ };
918
922
  }
919
923
 
920
- function writeSessionRegistryEntry(root, sessionId, patch = {}) {
924
+ function writeSessionRegistryEntry(root, sessionId, binding, previous = null) {
921
925
  if (!sessionId) {
922
926
  return null;
923
927
  }
924
928
  const workspaceRoot = path.resolve(root);
925
- const recordedAt = patch.recordedAt || now();
926
- const entry = {
927
- version: 1,
929
+ const projection = sessionRegistryProjection({
930
+ ...binding,
928
931
  sessionId,
929
932
  workspaceRoot,
933
+ });
934
+ const previousProjection = previous
935
+ ? sessionRegistryProjection({ ...previous, sessionId, workspaceRoot })
936
+ : null;
937
+ if (previousProjection && JSON.stringify(previousProjection) === JSON.stringify(projection)) {
938
+ return null;
939
+ }
940
+ const recordedAt = binding.updatedAt || now();
941
+ const entry = {
942
+ version: 1,
943
+ ...projection,
930
944
  realpath: workspaceRoot,
931
- laneKind: patch.laneKind || 'requirement',
932
- tool: patch.tool || 'codex',
933
- threadId: patch.threadId || null,
934
- changeId: patch.changeId || null,
935
- taskHandle: patch.taskHandle || null,
936
- workUnitId: patch.workUnitId || null,
937
- versionId: patch.versionId || null,
938
- digest: patch.digest || null,
939
- title: patch.title || null,
940
- targetRoot: patch.targetRoot ? path.resolve(patch.targetRoot) : null,
941
- promptPreview: patch.promptPreview || null,
942
- reviewStatus: patch.reviewStatus || null,
943
- gateStatus: patch.gateStatus || null,
944
- gateActive: patch.gateActive === true,
945
- statePath: patch.statePath || sessionStatePath(root, sessionId),
946
- bindingPath: patch.bindingPath || sessionBindingPath(root, sessionId),
947
- firstRegisteredAt: patch.firstRegisteredAt || recordedAt,
945
+ targetRoot: projection.targetRoot ? path.resolve(projection.targetRoot) : null,
946
+ statePath: sessionStatePath(root, sessionId),
947
+ bindingPath: sessionBindingPath(root, sessionId),
948
+ firstRegisteredAt: previous?.createdAt || binding.createdAt || recordedAt,
948
949
  lastRegisteredAt: recordedAt,
949
- lastUpdatedAt: patch.updatedAt || recordedAt,
950
+ lastUpdatedAt: recordedAt,
950
951
  recordedAt,
951
952
  };
952
- appendJsonLine(sessionRegistryPath(), entry);
953
+ appendJsonLineAtomic(sessionRegistryPath(), entry);
953
954
  return entry;
954
955
  }
955
956
 
@@ -968,25 +969,7 @@ function writeSessionBinding(root, sessionId, patch = {}) {
968
969
  updatedAt: patch.updatedAt ?? now(),
969
970
  };
970
971
  writeJsonSync(filePath, next);
971
- writeSessionRegistryEntry(root, sessionId, {
972
- laneKind: patch.laneKind ?? previous?.laneKind ?? 'requirement',
973
- tool: patch.tool ?? previous?.tool ?? 'codex',
974
- threadId: patch.threadId ?? previous?.threadId ?? null,
975
- changeId: patch.changeId ?? next.changeId ?? null,
976
- taskHandle: patch.taskHandle ?? next.taskHandle ?? null,
977
- workUnitId: patch.workUnitId ?? next.workUnitId ?? null,
978
- versionId: patch.versionId ?? next.versionId ?? null,
979
- digest: patch.digest ?? next.digest ?? null,
980
- title: patch.title ?? next.title ?? null,
981
- targetRoot: patch.targetRoot ?? next.targetRoot ?? null,
982
- promptPreview: patch.promptPreview ?? next.promptPreview ?? null,
983
- reviewStatus: patch.reviewStatus ?? next.reviewStatus ?? null,
984
- gateStatus: patch.gateStatus ?? next.gateStatus ?? null,
985
- gateActive: patch.gateActive ?? next.gateActive ?? false,
986
- bindingPath: filePath,
987
- statePath: sessionStatePath(root, sessionId),
988
- updatedAt: next.updatedAt,
989
- });
972
+ writeSessionRegistryEntry(root, sessionId, next, previous);
990
973
  return next;
991
974
  }
992
975
 
@@ -1561,6 +1544,9 @@ function knowledgeSkillContextLines(knowledgeSkills) {
1561
1544
  }
1562
1545
 
1563
1546
  function renderRunContextText(result) {
1547
+ if (String(result?.contextText || '').trim()) {
1548
+ return String(result.contextText).trim();
1549
+ }
1564
1550
  const lines = [
1565
1551
  '当前进展参考',
1566
1552
  '当前项目: ' + result.projectRoot,
@@ -3507,7 +3493,9 @@ function largeUiVisualDirectionMessage() {
3507
3493
  'OpenPrd 大界面改动视觉方案评审:',
3508
3494
  '位置: 需求分流之后、PRD 定稿或实现开工之前;它不同于 review.html,也不同于实现后的 visual-compare。',
3509
3495
  '判断: 先从用户目标、信息架构变化、视觉决策成本和验收风险判断是否需要方向评审,不用关键词触发。会决定首屏、核心布局、主视觉、关键路径、组件层级/密度,或用户需要先选设计方向时触发。',
3510
- '步骤: 已有界面时用 Codex Computer Use 进入产品内对应功能并截当前真实界面;冷启动没有现有界面时,基于已确认 PRD、用户画像、第一版切片和视觉目标写设计 brief。brief 必须写清用途、受众、气质端点、约束和记忆点,3 个方向要分别说明审美主张和主动避开的模板味。然后按生图路由调用内置生图工具(Codex=`imagegen`/Image 2,Cursor=`GenerateImage`;都不可用时按 `.openprd/harness/image-generation-preference.json` 的用户已确认偏好,没有偏好先问用户)生成至少 3 个不同设计思想方向;把效果图横向拼成一张带 1/2/3 序号的大图,先作为候选效果图给用户评审,不要默认写入 `.openprd/harness/visual-reviews/` 当验收参考。',
3496
+ '自动性: 用户不需要额外提出“请生图”。只要语义判断命中大界面改动或视觉决策成本高,就主动完成 3 方向生成;卡片宽度、间距、对齐、颜色、圆角、字号等轻量微调不触发。',
3497
+ '步骤: 已有界面时,Codex 原生 App 用 Computer Use,Codex 网页环境用 Chrome 插件的任务专用窗口,Cursor 用当前可用的浏览器或项目预览能力,进入对应功能并截当前真实界面。三个方向必须把同一张截图作为 image-to-image 参考;除非用户明确要求换风格,否则锁定现有字体角色、色彩系统、明暗模式、表面语言、圆角/边框/阴影、图标语言、平台框架和信息密度,只探索布局、信息层级、导航、关键路径和空间构图。冷启动没有现有界面时,基于已确认 PRD、用户画像、第一版切片和视觉目标写 design brief,不伪造修改前截图。brief 必须写清用途、受众、气质端点、约束和记忆点,3 个方向要分别说明审美主张和主动避开的模板味。然后按生图路由调用内置生图工具(Codex=`imagegen`/Image 2,Cursor=`GenerateImage`;都不可用时按 `.openprd/harness/image-generation-preference.json` 的用户已确认偏好,没有偏好先问用户)生成至少 3 个不同设计思想方向;把效果图横向拼成一张带 1/2/3 序号的大图,先作为候选效果图给用户评审,不要默认写入 `.openprd/harness/visual-reviews/` 当验收参考。',
3498
+ '质量: 每个方向都要从用途、用户、业务约束、核心动作和唯一记忆点出发,同时检查字体角色、色彩、空间构图、信息层级、表面/图标语言和动效含义;拒绝无依据蓝紫渐变、默认字体、等权白卡堆叠、过量大圆角、无语境装饰和只换颜色的伪方向。',
3511
3499
  '交互: 主动追问三件事:是否符合预期、是否纳入后续效果图/实现截图对比、是否按此继续后续实现。只有用户确认纳入后续对比或继续实现后,才把选定方向、整张图或其中子图整理成 reference-set 并写入 `.openprd/harness/visual-reviews/`;用户确认前,不进入大 UI 实现,也不要声称界面方案已定。',
3512
3500
  '多对象参考处理: 如果候选效果图是一张图里包含多个子图、网格、多对象或多方向,不要拿整板直接硬比;先运行 `openprd visual-prepare . --reference <效果图> --grid <列>x<行>` 或 `--boxes <plan.json>`,检查 contact sheet,确认 reference-set 后再逐项进入实现和验收。',
3513
3501
  ].join('\n');
@@ -89,6 +89,29 @@ function runProcess(command, args = [], options = {}) {
89
89
  });
90
90
  let stdout = '';
91
91
  let stderr = '';
92
+ let settled = false;
93
+ let timedOut = false;
94
+ const timeoutMs = Number.isFinite(options.timeoutMs)
95
+ ? Math.max(1, Number(options.timeoutMs))
96
+ : 15000;
97
+ const killGraceMs = Number.isFinite(options.killGraceMs)
98
+ ? Math.max(1, Number(options.killGraceMs))
99
+ : 1000;
100
+ let killTimer = null;
101
+ const timeoutTimer = setTimeout(() => {
102
+ timedOut = true;
103
+ child.kill('SIGTERM');
104
+ killTimer = setTimeout(() => child.kill('SIGKILL'), killGraceMs);
105
+ killTimer.unref?.();
106
+ }, timeoutMs);
107
+ timeoutTimer.unref?.();
108
+ const finish = (payload) => {
109
+ if (settled) return;
110
+ settled = true;
111
+ clearTimeout(timeoutTimer);
112
+ if (killTimer) clearTimeout(killTimer);
113
+ resolve(payload);
114
+ };
92
115
  child.stdout.on('data', (chunk) => {
93
116
  stdout = `${stdout}${chunk}`.slice(-64000);
94
117
  });
@@ -96,7 +119,7 @@ function runProcess(command, args = [], options = {}) {
96
119
  stderr = `${stderr}${chunk}`.slice(-64000);
97
120
  });
98
121
  child.on('error', (error) => {
99
- resolve({
122
+ finish({
100
123
  ok: false,
101
124
  command,
102
125
  args,
@@ -104,19 +127,22 @@ function runProcess(command, args = [], options = {}) {
104
127
  exitCode: null,
105
128
  stdout,
106
129
  stderr,
130
+ timedOut,
107
131
  error: error.message,
108
132
  });
109
133
  });
110
- child.on('close', (exitCode) => {
111
- resolve({
112
- ok: exitCode === 0,
134
+ child.on('close', (exitCode, signal) => {
135
+ finish({
136
+ ok: !timedOut && exitCode === 0,
113
137
  command,
114
138
  args,
115
139
  display: invocation.display,
116
140
  exitCode,
141
+ signal,
117
142
  stdout,
118
143
  stderr,
119
- error: null,
144
+ timedOut,
145
+ error: timedOut ? `Command timed out after ${timeoutMs}ms.` : null,
120
146
  });
121
147
  });
122
148
  });
@@ -131,6 +157,8 @@ function normalizeRunResult(command, args, result = {}) {
131
157
  exitCode: result.exitCode ?? result.status ?? null,
132
158
  stdout: String(result.stdout ?? ''),
133
159
  stderr: String(result.stderr ?? ''),
160
+ timedOut: Boolean(result.timedOut),
161
+ signal: result.signal ? String(result.signal) : null,
134
162
  error: result.error ? String(result.error) : null,
135
163
  };
136
164
  }
@@ -178,6 +206,17 @@ export function diagnoseCodexVersionFailure(result, options = {}) {
178
206
  };
179
207
  }
180
208
 
209
+ if (result.timedOut) {
210
+ return {
211
+ type: 'version-check-timeout',
212
+ summary: `Codex CLI 健康检查超时:codex --version 在 ${options.timeoutMs ?? 15000}ms 内没有返回。`,
213
+ missingPackage: null,
214
+ repairCommand,
215
+ manualCommand: repairCommand.display,
216
+ output,
217
+ };
218
+ }
219
+
181
220
  if (
182
221
  result.exitCode === null
183
222
  && /(ENOENT|not found|command not found|no such file|spawn .*enoent)/i.test(output)
@@ -212,6 +251,8 @@ export async function checkCodexCliHealth(options = {}) {
212
251
  await runner(command, args, {
213
252
  cwd: options.cwd,
214
253
  env: options.env ?? process.env,
254
+ timeoutMs: options.timeoutMs ?? 15000,
255
+ killGraceMs: options.killGraceMs,
215
256
  }),
216
257
  );
217
258
  const repairCommand = buildCodexRepairCommand(options);
@@ -257,6 +298,8 @@ export async function repairCodexCli(options = {}) {
257
298
  await runner(commandInfo.command, commandInfo.args, {
258
299
  cwd: options.cwd,
259
300
  env: options.env ?? process.env,
301
+ timeoutMs: options.repairTimeoutMs ?? 300000,
302
+ killGraceMs: options.killGraceMs,
260
303
  }),
261
304
  );
262
305
  const recheck = result.ok ? await checkCodexCliHealth(options) : null;
@@ -0,0 +1,245 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ import {
6
+ buildContextRequestFingerprint,
7
+ compileContextCapsule,
8
+ verifyContextCapsule,
9
+ } from './compiler.js';
10
+ import {
11
+ CONTEXT_CACHE_SCHEMA_VERSION,
12
+ CONTEXT_COMPILER_VERSION,
13
+ } from './constants.js';
14
+ import { cloneStable, compareStableText, digestStable } from './stable.js';
15
+
16
+ function assertCacheKey(key) {
17
+ const normalized = String(key ?? '');
18
+ if (!/^context-v1-[a-f0-9]{64}$/.test(normalized)) {
19
+ throw new TypeError(`Invalid context cache key: ${normalized}`);
20
+ }
21
+ return normalized;
22
+ }
23
+
24
+ export function buildContextCacheKey(input, { previousCapsule = null } = {}) {
25
+ const fingerprint = buildContextRequestFingerprint(input, { previousCapsule });
26
+ const hash = digestStable({
27
+ compilerVersion: CONTEXT_COMPILER_VERSION,
28
+ fingerprint,
29
+ }).slice('sha256:'.length);
30
+ return `context-v1-${hash}`;
31
+ }
32
+
33
+ export function contextCachePath(cacheDir, key) {
34
+ return path.join(cacheDir, `${assertCacheKey(key)}.json`);
35
+ }
36
+
37
+ function cacheEnvelope(key, capsule) {
38
+ if (!verifyContextCapsule(capsule)) {
39
+ throw new TypeError('Refusing to cache an invalid ContextCapsule.');
40
+ }
41
+ return {
42
+ schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
43
+ key: assertCacheKey(key),
44
+ revision: capsule.revision,
45
+ capsuleDigest: capsule.digest,
46
+ capsule,
47
+ };
48
+ }
49
+
50
+ function validEnvelope(value, key) {
51
+ return Boolean(
52
+ value
53
+ && value.schemaVersion === CONTEXT_CACHE_SCHEMA_VERSION
54
+ && value.key === key
55
+ && value.capsuleDigest === value.capsule?.digest
56
+ && verifyContextCapsule(value.capsule),
57
+ );
58
+ }
59
+
60
+ export async function readContextCache(cacheDir, key) {
61
+ const normalizedKey = assertCacheKey(key);
62
+ try {
63
+ const parsed = JSON.parse(await fs.readFile(contextCachePath(cacheDir, normalizedKey), 'utf8'));
64
+ return validEnvelope(parsed, normalizedKey) ? parsed.capsule : null;
65
+ } catch (error) {
66
+ if (error?.code === 'ENOENT' || error instanceof SyntaxError) return null;
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ export async function writeContextCache(cacheDir, key, capsule) {
72
+ const normalizedKey = assertCacheKey(key);
73
+ const target = contextCachePath(cacheDir, normalizedKey);
74
+ const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
75
+ const envelope = cacheEnvelope(normalizedKey, capsule);
76
+ await fs.mkdir(cacheDir, { recursive: true });
77
+ try {
78
+ await fs.writeFile(temporary, `${JSON.stringify(envelope)}\n`, 'utf8');
79
+ await fs.rename(temporary, target);
80
+ } finally {
81
+ await fs.rm(temporary, { force: true }).catch(() => {});
82
+ }
83
+ return target;
84
+ }
85
+
86
+ function normalizeInvalidationSelector(selector = {}) {
87
+ const keys = new Set();
88
+ if (selector.key) keys.add(assertCacheKey(selector.key));
89
+ for (const key of selector.keys ?? []) keys.add(assertCacheKey(key));
90
+ return {
91
+ keys,
92
+ revision: selector.revision == null ? null : String(selector.revision),
93
+ all: selector.all === true,
94
+ };
95
+ }
96
+
97
+ export async function invalidateContextCache(cacheDir, selector = {}) {
98
+ const normalized = normalizeInvalidationSelector(selector);
99
+ let names;
100
+ try {
101
+ names = await fs.readdir(cacheDir);
102
+ } catch (error) {
103
+ if (error?.code === 'ENOENT') return { removed: [], kept: [], errors: [] };
104
+ throw error;
105
+ }
106
+ const removed = [];
107
+ const kept = [];
108
+ const errors = [];
109
+ for (const name of names.filter((item) => /^context-v1-[a-f0-9]{64}\.json$/.test(item)).sort()) {
110
+ const key = name.slice(0, -'.json'.length);
111
+ let shouldRemove = normalized.all || normalized.keys.has(key);
112
+ if (!shouldRemove && normalized.revision != null) {
113
+ try {
114
+ const parsed = JSON.parse(await fs.readFile(path.join(cacheDir, name), 'utf8'));
115
+ shouldRemove = parsed.revision === normalized.revision;
116
+ } catch (error) {
117
+ errors.push({ key, error: error?.code ?? error?.name ?? 'read-error' });
118
+ }
119
+ }
120
+ if (!shouldRemove) {
121
+ kept.push(key);
122
+ continue;
123
+ }
124
+ try {
125
+ await fs.rm(path.join(cacheDir, name), { force: true });
126
+ removed.push(key);
127
+ } catch (error) {
128
+ errors.push({ key, error: error?.code ?? error?.name ?? 'remove-error' });
129
+ }
130
+ }
131
+ return { removed, kept, errors };
132
+ }
133
+
134
+ export function createMemoryContextCache() {
135
+ const records = new Map();
136
+ const counters = { reads: 0, writes: 0, hits: 0, misses: 0, invalidations: 0 };
137
+ return {
138
+ async read(key) {
139
+ const normalizedKey = assertCacheKey(key);
140
+ counters.reads += 1;
141
+ if (!records.has(normalizedKey)) {
142
+ counters.misses += 1;
143
+ return null;
144
+ }
145
+ counters.hits += 1;
146
+ return cloneStable(records.get(normalizedKey).capsule);
147
+ },
148
+ async write(key, capsule) {
149
+ const normalizedKey = assertCacheKey(key);
150
+ records.set(normalizedKey, cacheEnvelope(normalizedKey, cloneStable(capsule)));
151
+ counters.writes += 1;
152
+ },
153
+ async invalidate(selector = {}) {
154
+ const normalized = normalizeInvalidationSelector(selector);
155
+ const removed = [];
156
+ const kept = [];
157
+ for (const [key, envelope] of [...records.entries()].sort(([left], [right]) => compareStableText(left, right))) {
158
+ const shouldRemove = normalized.all
159
+ || normalized.keys.has(key)
160
+ || (normalized.revision != null && envelope.revision === normalized.revision);
161
+ if (shouldRemove) {
162
+ records.delete(key);
163
+ removed.push(key);
164
+ } else {
165
+ kept.push(key);
166
+ }
167
+ }
168
+ counters.invalidations += removed.length;
169
+ return { removed, kept, errors: [] };
170
+ },
171
+ stats() {
172
+ return { ...counters, size: records.size };
173
+ },
174
+ };
175
+ }
176
+
177
+ export function createFileContextCache(cacheDir) {
178
+ const counters = { reads: 0, writes: 0, hits: 0, misses: 0, invalidations: 0 };
179
+ return {
180
+ async read(key) {
181
+ counters.reads += 1;
182
+ const capsule = await readContextCache(cacheDir, key);
183
+ if (capsule) counters.hits += 1;
184
+ else counters.misses += 1;
185
+ return capsule;
186
+ },
187
+ async write(key, capsule) {
188
+ counters.writes += 1;
189
+ return writeContextCache(cacheDir, key, capsule);
190
+ },
191
+ async invalidate(selector = {}) {
192
+ const result = await invalidateContextCache(cacheDir, selector);
193
+ counters.invalidations += result.removed.length;
194
+ return result;
195
+ },
196
+ stats() {
197
+ return { ...counters };
198
+ },
199
+ };
200
+ }
201
+
202
+ export async function compileContextWithCache(input, {
203
+ cache,
204
+ previousCapsule = null,
205
+ } = {}) {
206
+ if (!cache || typeof cache.read !== 'function' || typeof cache.write !== 'function') {
207
+ throw new TypeError('compileContextWithCache requires a cache with read() and write().');
208
+ }
209
+ const cacheKey = buildContextCacheKey(input, { previousCapsule });
210
+ const cached = await cache.read(cacheKey);
211
+ if (cached && verifyContextCapsule(cached)) {
212
+ return { capsule: cached, cacheKey, cacheStatus: 'hit', compiled: false };
213
+ }
214
+ const capsule = compileContextCapsule(input, { previousCapsule });
215
+ await cache.write(cacheKey, capsule);
216
+ return { capsule, cacheKey, cacheStatus: 'miss', compiled: true };
217
+ }
218
+
219
+ /**
220
+ * A structural cold/warm benchmark. It deliberately reports operation counts
221
+ * rather than wall-clock thresholds, which keeps CI stable across machines.
222
+ */
223
+ export async function runContextCacheBenchmark(input, {
224
+ cache = createMemoryContextCache(),
225
+ previousCapsule = null,
226
+ } = {}) {
227
+ const cold = await compileContextWithCache(input, { cache, previousCapsule });
228
+ const warm = await compileContextWithCache(input, { cache, previousCapsule });
229
+ return {
230
+ cold: {
231
+ cacheStatus: cold.cacheStatus,
232
+ compiled: cold.compiled,
233
+ digest: cold.capsule.digest,
234
+ includedCount: cold.capsule.entries.length,
235
+ },
236
+ warm: {
237
+ cacheStatus: warm.cacheStatus,
238
+ compiled: warm.compiled,
239
+ digest: warm.capsule.digest,
240
+ includedCount: warm.capsule.entries.length,
241
+ },
242
+ sameDigest: cold.capsule.digest === warm.capsule.digest,
243
+ cacheStats: typeof cache.stats === 'function' ? cache.stats() : null,
244
+ };
245
+ }