@openprd/cli 0.1.18 → 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 (118) 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/openprd-generated-change/.openprd.yaml +2 -0
  9. package/.openprd/changes/openprd-generated-change/design.md +50 -0
  10. package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
  11. package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
  12. package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
  13. package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
  14. package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
  15. package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
  16. package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
  17. package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
  18. package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
  19. package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
  20. package/.openprd/design/active/asset-spec.md +19 -14
  21. package/.openprd/design/active/direction-plan.md +19 -3
  22. package/.openprd/design/active/facts-sheet.md +16 -7
  23. package/.openprd/design/active/image-preflight.md +6 -5
  24. package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
  25. package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
  26. package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
  27. package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
  28. package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
  29. package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
  30. package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
  31. package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
  32. package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
  33. package/.openprd/design/active/selected-direction.md +23 -9
  34. package/.openprd/engagements/active/control-plane-architecture.json +203 -0
  35. package/.openprd/engagements/active/control-plane-intake.json +418 -0
  36. package/.openprd/engagements/active/prd.md +186 -100
  37. package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
  38. package/.openprd/i18n-config.json +12 -0
  39. package/.openprd/ledger/events.jsonl +6 -0
  40. package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
  41. package/.openprd/manifest.json +21 -0
  42. package/AGENTS.md +1 -0
  43. package/README.md +22 -0
  44. package/README_EN.md +27 -0
  45. package/package.json +1 -1
  46. package/skills/openprd-frontend-design/SKILL.md +16 -0
  47. package/skills/openprd-harness/SKILL.md +1 -1
  48. package/skills/openprd-quality/SKILL.md +1 -1
  49. package/skills/openprd-requirement-intake/SKILL.md +1 -1
  50. package/skills/openprd-shared/SKILL.md +1 -1
  51. package/src/adapters/adapter-spi.js +193 -0
  52. package/src/adapters/capability-envelope.js +98 -0
  53. package/src/adapters/event-normalizer.js +55 -0
  54. package/src/adapters/index.js +4 -0
  55. package/src/adapters/install-safety.js +249 -0
  56. package/src/agent-canonical-content.js +6 -2
  57. package/src/agent-integration.js +147 -42
  58. package/src/canvas-app-client-app.js +838 -0
  59. package/src/canvas-app-client-helpers.js +1138 -0
  60. package/src/canvas-app-styles.js +898 -0
  61. package/src/canvas-app.html.js +6 -2781
  62. package/src/canvas-binding.js +414 -0
  63. package/src/canvas-codex-relay.js +430 -0
  64. package/src/canvas-i18n.js +8 -0
  65. package/src/canvas-scene.js +377 -0
  66. package/src/canvas-session-store.js +148 -0
  67. package/src/canvas-workspace.js +59 -1066
  68. package/src/cli/args.js +63 -4
  69. package/src/cli/gate-print.js +17 -0
  70. package/src/cli/quality-commands.js +18 -0
  71. package/src/cli/quality-print.js +10 -0
  72. package/src/cli/runtime-print.js +24 -0
  73. package/src/codex-hook-runner-template.mjs +87 -77
  74. package/src/codex-runtime.js +48 -5
  75. package/src/context/cache.js +245 -0
  76. package/src/context/compiler.js +438 -0
  77. package/src/context/constants.js +30 -0
  78. package/src/context/index.js +39 -0
  79. package/src/context/redaction.js +84 -0
  80. package/src/context/stable.js +69 -0
  81. package/src/context/telemetry.js +42 -0
  82. package/src/dev-standards.js +57 -0
  83. package/src/fleet.js +112 -95
  84. package/src/gates/index.js +2 -0
  85. package/src/gates/scoped-gates.js +256 -0
  86. package/src/gates/store.js +126 -0
  87. package/src/gates/workspace.js +41 -0
  88. package/src/html-artifacts.js +725 -28
  89. package/src/kernel/atomic-store.js +299 -0
  90. package/src/kernel/event-envelope.js +166 -0
  91. package/src/kernel/index.js +4 -0
  92. package/src/kernel/project-ledger.js +467 -0
  93. package/src/kernel/project-manifest.js +205 -0
  94. package/src/knowledge-v3/index.js +1 -0
  95. package/src/knowledge-v3/lifecycle.js +290 -0
  96. package/src/knowledge.js +14 -7
  97. package/src/openprd.js +71 -2
  98. package/src/review-model.js +413 -0
  99. package/src/review-presentation.js +1 -1
  100. package/src/run-harness.js +431 -37
  101. package/src/runtime/cli_runtime_README.md +28 -0
  102. package/src/runtime/errors.js +66 -0
  103. package/src/runtime/index.js +44 -0
  104. package/src/runtime/lane-schema.js +141 -0
  105. package/src/runtime/lane-store.js +279 -0
  106. package/src/runtime/task-runtime.js +449 -0
  107. package/src/runtime/workspace.js +179 -0
  108. package/src/runtime/write-set.js +206 -0
  109. package/src/session-binding.js +16 -3
  110. package/src/session-registry.js +59 -1
  111. package/src/upgrade/fleet-mutation.js +166 -0
  112. package/src/upgrade/fleet-transaction.js +398 -0
  113. package/src/upgrade/transaction-store.js +416 -0
  114. package/src/visual-compare-core.js +66 -27
  115. package/src/visual-compare.js +18 -12
  116. package/src/workspace-core.js +109 -7
  117. package/src/workspace-registry.js +39 -1
  118. package/src/workspace-workflow.js +18 -15
@@ -0,0 +1,256 @@
1
+ /*
2
+ * 核心功能
3
+ * 定义 project/change/work-unit/task/lane/operation 分级门禁,并把证据与明确 approval ID
4
+ * 绑定到同一作用域和操作,避免一句泛化确认越权放行其他任务。
5
+ *
6
+ * 输入
7
+ * 接收 Gate 定义、Evidence 记录、Approval 记录、待执行操作摘要与可注入时钟。
8
+ *
9
+ * 输出
10
+ * 返回稳定、可审计的门禁决策;不执行写入,也不把评估行为混入项目事实。
11
+ *
12
+ * 定位
13
+ * 位于控制平面的纯策略层,可由 CLI、Hook、任务运行时和升级器共同复用。
14
+ *
15
+ * 维护规则
16
+ * 新增 scope 或决策码时必须保持旧码含义不变,并补跨作用域、过期和撤销负向测试。
17
+ */
18
+
19
+ import { createHash } from 'node:crypto';
20
+ import { canonicalJson } from '../kernel/event-envelope.js';
21
+
22
+ const GATE_SCHEMA_VERSION = 1;
23
+ const SCOPE_TYPES = Object.freeze(['project', 'change', 'work-unit', 'task', 'lane', 'operation']);
24
+ const EVIDENCE_STATUSES = Object.freeze(['valid', 'revoked', 'superseded']);
25
+ const APPROVAL_STATUSES = Object.freeze(['active', 'revoked', 'consumed']);
26
+
27
+ class ScopedGateValidationError extends Error {
28
+ constructor(message, details = {}) {
29
+ super(message);
30
+ this.name = 'ScopedGateValidationError';
31
+ this.code = 'OPENPRD_SCOPED_GATE_INVALID';
32
+ Object.assign(this, details);
33
+ }
34
+ }
35
+
36
+ function stableId(prefix, value) {
37
+ return `${prefix}_${createHash('sha256').update(canonicalJson(value)).digest('hex').slice(0, 24)}`;
38
+ }
39
+
40
+ function requiredString(value, label) {
41
+ const normalized = String(value ?? '').trim();
42
+ if (!normalized || /[\0\r\n]/.test(normalized)) {
43
+ throw new ScopedGateValidationError(`${label} 必须是安全的非空字符串。`, { field: label });
44
+ }
45
+ return normalized;
46
+ }
47
+
48
+ function normalizeTime(value, label) {
49
+ const date = value instanceof Date ? value : new Date(value);
50
+ if (Number.isNaN(date.getTime())) {
51
+ throw new ScopedGateValidationError(`${label} 必须是有效时间。`, { field: label });
52
+ }
53
+ return date.toISOString();
54
+ }
55
+
56
+ function normalizeScope(scope) {
57
+ const type = requiredString(scope?.type, 'scope.type');
58
+ if (!SCOPE_TYPES.includes(type)) {
59
+ throw new ScopedGateValidationError(`不支持的门禁作用域:${type}。`, { field: 'scope.type', type });
60
+ }
61
+ return { type, id: requiredString(scope?.id, 'scope.id') };
62
+ }
63
+
64
+ function sameScope(left, right) {
65
+ return left?.type === right?.type && left?.id === right?.id;
66
+ }
67
+
68
+ function createGateDefinition(input) {
69
+ const scope = normalizeScope(input?.scope);
70
+ const operation = requiredString(input?.operation, 'operation');
71
+ const requiredEvidence = (input?.requiredEvidence ?? []).map((requirement, index) => ({
72
+ kind: requiredString(requirement?.kind, `requiredEvidence[${index}].kind`),
73
+ minCount: Math.max(1, Number(requirement?.minCount ?? 1)),
74
+ maxAgeMs: requirement?.maxAgeMs === null || requirement?.maxAgeMs === undefined
75
+ ? null
76
+ : Math.max(0, Number(requirement.maxAgeMs)),
77
+ }));
78
+ for (const requirement of requiredEvidence) {
79
+ if (!Number.isSafeInteger(requirement.minCount)) {
80
+ throw new ScopedGateValidationError('requiredEvidence.minCount 必须是正整数。');
81
+ }
82
+ if (requirement.maxAgeMs !== null && !Number.isFinite(requirement.maxAgeMs)) {
83
+ throw new ScopedGateValidationError('requiredEvidence.maxAgeMs 必须是非负数或 null。');
84
+ }
85
+ }
86
+ const approval = {
87
+ required: Boolean(input?.approval?.required),
88
+ subjectDigest: input?.approval?.subjectDigest
89
+ ? requiredString(input.approval.subjectDigest, 'approval.subjectDigest')
90
+ : null,
91
+ consumeOnPass: Boolean(input?.approval?.consumeOnPass),
92
+ };
93
+ const body = {
94
+ schemaVersion: GATE_SCHEMA_VERSION,
95
+ gateId: input?.gateId ?? null,
96
+ scope,
97
+ operation,
98
+ risk: input?.risk ?? 'normal',
99
+ requiredEvidence,
100
+ approval,
101
+ metadata: structuredClone(input?.metadata ?? {}),
102
+ };
103
+ body.gateId = body.gateId
104
+ ? requiredString(body.gateId, 'gateId')
105
+ : stableId('gate', { ...body, gateId: null });
106
+ return body;
107
+ }
108
+
109
+ function createEvidenceRecord(input, options = {}) {
110
+ const collectedAt = normalizeTime(input?.collectedAt ?? (options.clock?.() ?? new Date()), 'collectedAt');
111
+ const scope = normalizeScope(input?.scope);
112
+ const kind = requiredString(input?.kind, 'kind');
113
+ const contentDigest = requiredString(input?.contentDigest, 'contentDigest');
114
+ const status = input?.status ?? 'valid';
115
+ if (!EVIDENCE_STATUSES.includes(status)) {
116
+ throw new ScopedGateValidationError(`不支持的证据状态:${status}。`);
117
+ }
118
+ const body = {
119
+ schemaVersion: GATE_SCHEMA_VERSION,
120
+ evidenceId: input?.evidenceId ?? null,
121
+ scope,
122
+ operation: input?.operation ? requiredString(input.operation, 'operation') : null,
123
+ kind,
124
+ source: requiredString(input?.source ?? 'unknown', 'source'),
125
+ contentDigest,
126
+ collectedAt,
127
+ validUntil: input?.validUntil ? normalizeTime(input.validUntil, 'validUntil') : null,
128
+ status,
129
+ metadata: structuredClone(input?.metadata ?? {}),
130
+ };
131
+ body.evidenceId = body.evidenceId
132
+ ? requiredString(body.evidenceId, 'evidenceId')
133
+ : stableId('evidence', { ...body, evidenceId: null });
134
+ return body;
135
+ }
136
+
137
+ function createApprovalRecord(input, options = {}) {
138
+ const approvalId = requiredString(input?.approvalId, 'approvalId');
139
+ const issuedAt = normalizeTime(input?.issuedAt ?? (options.clock?.() ?? new Date()), 'issuedAt');
140
+ const status = input?.status ?? 'active';
141
+ if (!APPROVAL_STATUSES.includes(status)) {
142
+ throw new ScopedGateValidationError(`不支持的审批状态:${status}。`);
143
+ }
144
+ return {
145
+ schemaVersion: GATE_SCHEMA_VERSION,
146
+ approvalId,
147
+ scope: normalizeScope(input?.scope),
148
+ operation: requiredString(input?.operation, 'operation'),
149
+ subjectDigest: input?.subjectDigest ? requiredString(input.subjectDigest, 'subjectDigest') : null,
150
+ actor: {
151
+ type: requiredString(input?.actor?.type ?? 'user', 'actor.type'),
152
+ id: input?.actor?.id ? requiredString(input.actor.id, 'actor.id') : null,
153
+ },
154
+ issuedAt,
155
+ expiresAt: input?.expiresAt ? normalizeTime(input.expiresAt, 'expiresAt') : null,
156
+ status,
157
+ metadata: structuredClone(input?.metadata ?? {}),
158
+ };
159
+ }
160
+
161
+ function evidenceUsability(record, gate, now) {
162
+ if (record?.schemaVersion !== GATE_SCHEMA_VERSION) return { ok: false, code: 'evidence-schema-mismatch' };
163
+ if (!sameScope(record.scope, gate.scope)) return { ok: false, code: 'evidence-scope-mismatch' };
164
+ if (record.operation && record.operation !== gate.operation) return { ok: false, code: 'evidence-operation-mismatch' };
165
+ if (record.status !== 'valid') return { ok: false, code: `evidence-${record.status ?? 'invalid'}` };
166
+ const collectedAt = Date.parse(record.collectedAt);
167
+ if (Number.isNaN(collectedAt)) return { ok: false, code: 'evidence-time-invalid' };
168
+ if (record.validUntil && Date.parse(record.validUntil) <= now.getTime()) return { ok: false, code: 'evidence-expired' };
169
+ return { ok: true, collectedAt };
170
+ }
171
+
172
+ function approvalUsability(record, gate, approvalId, now) {
173
+ if (!approvalId) return { ok: false, code: 'approval-id-required' };
174
+ if (!record || record.approvalId !== approvalId) return { ok: false, code: 'approval-id-not-found' };
175
+ if (record.schemaVersion !== GATE_SCHEMA_VERSION) return { ok: false, code: 'approval-schema-mismatch' };
176
+ if (!sameScope(record.scope, gate.scope)) return { ok: false, code: 'approval-scope-mismatch' };
177
+ if (record.operation !== gate.operation) return { ok: false, code: 'approval-operation-mismatch' };
178
+ if (record.status !== 'active') return { ok: false, code: `approval-${record.status ?? 'invalid'}` };
179
+ if (record.expiresAt && Date.parse(record.expiresAt) <= now.getTime()) return { ok: false, code: 'approval-expired' };
180
+ if (gate.approval.subjectDigest && record.subjectDigest !== gate.approval.subjectDigest) {
181
+ return { ok: false, code: 'approval-subject-mismatch' };
182
+ }
183
+ return { ok: true };
184
+ }
185
+
186
+ function evaluateScopedGate(input, options = {}) {
187
+ const gate = createGateDefinition(input?.gate);
188
+ const now = options.clock?.() ?? new Date();
189
+ const nowDate = now instanceof Date ? now : new Date(now);
190
+ if (Number.isNaN(nowDate.getTime())) throw new ScopedGateValidationError('clock 返回了无效时间。');
191
+ const reasons = [];
192
+ const acceptedEvidence = [];
193
+ const rejectedEvidence = [];
194
+
195
+ for (const evidence of input?.evidence ?? []) {
196
+ const usability = evidenceUsability(evidence, gate, nowDate);
197
+ if (usability.ok) acceptedEvidence.push({ record: evidence, collectedAt: usability.collectedAt });
198
+ else rejectedEvidence.push({ evidenceId: evidence?.evidenceId ?? null, code: usability.code });
199
+ }
200
+
201
+ for (const requirement of gate.requiredEvidence) {
202
+ const matches = acceptedEvidence.filter(({ record, collectedAt }) => (
203
+ record.kind === requirement.kind
204
+ && (requirement.maxAgeMs === null || nowDate.getTime() - collectedAt <= requirement.maxAgeMs)
205
+ ));
206
+ if (matches.length < requirement.minCount) {
207
+ reasons.push({
208
+ code: 'evidence-required',
209
+ kind: requirement.kind,
210
+ required: requirement.minCount,
211
+ found: matches.length,
212
+ });
213
+ }
214
+ }
215
+
216
+ let approval = null;
217
+ if (gate.approval.required) {
218
+ approval = (input?.approvals ?? []).find((record) => record?.approvalId === input?.approvalId) ?? null;
219
+ const usability = approvalUsability(approval, gate, input?.approvalId, nowDate);
220
+ if (!usability.ok) reasons.push({ code: usability.code, approvalId: input?.approvalId ?? null });
221
+ }
222
+
223
+ const evaluatedAt = nowDate.toISOString();
224
+ const result = {
225
+ schemaVersion: GATE_SCHEMA_VERSION,
226
+ gateId: gate.gateId,
227
+ scope: gate.scope,
228
+ operation: gate.operation,
229
+ status: reasons.length === 0 ? 'pass' : 'blocked',
230
+ evaluatedAt,
231
+ approvalId: approval?.approvalId ?? null,
232
+ approvalConsumption: reasons.length === 0 && gate.approval.consumeOnPass && approval
233
+ ? { approvalId: approval.approvalId, nextStatus: 'consumed' }
234
+ : null,
235
+ acceptedEvidenceIds: acceptedEvidence.map(({ record }) => record.evidenceId).sort(),
236
+ rejectedEvidence,
237
+ reasons,
238
+ };
239
+ return {
240
+ ...result,
241
+ decisionId: stableId('decision', result),
242
+ };
243
+ }
244
+
245
+ export {
246
+ APPROVAL_STATUSES,
247
+ EVIDENCE_STATUSES,
248
+ GATE_SCHEMA_VERSION,
249
+ SCOPE_TYPES,
250
+ ScopedGateValidationError,
251
+ createApprovalRecord,
252
+ createEvidenceRecord,
253
+ createGateDefinition,
254
+ evaluateScopedGate,
255
+ normalizeScope,
256
+ };
@@ -0,0 +1,126 @@
1
+ /*
2
+ * 核心功能
3
+ * 原子持久化 scoped Gate、Evidence、Approval 与 Decision,并在同一锁内消费一次性审批。
4
+ *
5
+ * 输入
6
+ * 接收已规范化的门禁记录和 evaluate 请求。
7
+ *
8
+ * 输出
9
+ * 提供 record/list/evaluate API;所有状态保存在项目 `.openprd/gates/state.json`。
10
+ *
11
+ * 定位
12
+ * 位于纯策略与 CLI/高风险操作之间,负责本地单项目并发一致性。
13
+ *
14
+ * 维护规则
15
+ * 相同 ID 不同内容必须冲突;消费审批和写入 decision 必须在同一文件锁事务中完成。
16
+ */
17
+
18
+ import path from 'node:path';
19
+ import { atomicWriteJson, readJsonIfExists, withFileLock } from '../kernel/atomic-store.js';
20
+ import { canonicalJson } from '../kernel/event-envelope.js';
21
+ import {
22
+ createApprovalRecord,
23
+ createEvidenceRecord,
24
+ createGateDefinition,
25
+ evaluateScopedGate,
26
+ } from './scoped-gates.js';
27
+
28
+ const GATE_STORE_SCHEMA_VERSION = 1;
29
+
30
+ function gateStorePath(projectRoot) {
31
+ return path.join(path.resolve(projectRoot), '.openprd', 'gates', 'state.json');
32
+ }
33
+
34
+ function emptyState() {
35
+ return {
36
+ schemaVersion: GATE_STORE_SCHEMA_VERSION,
37
+ revision: 0,
38
+ gates: {},
39
+ evidence: {},
40
+ approvals: {},
41
+ decisions: [],
42
+ };
43
+ }
44
+
45
+ function same(left, right) {
46
+ return canonicalJson(left) === canonicalJson(right);
47
+ }
48
+
49
+ function openGateStore(projectRoot, options = {}) {
50
+ const filePath = gateStorePath(projectRoot);
51
+ const lockPath = `${filePath}.lock`;
52
+
53
+ async function read() {
54
+ return { ...emptyState(), ...(await readJsonIfExists(filePath, null) ?? {}) };
55
+ }
56
+
57
+ async function transact(operation) {
58
+ return withFileLock(lockPath, async () => {
59
+ const state = await read();
60
+ const result = await operation(state);
61
+ if (result?.changed !== false) {
62
+ state.revision += 1;
63
+ await atomicWriteJson(filePath, state);
64
+ }
65
+ return { state, result: result?.value ?? null };
66
+ }, options);
67
+ }
68
+
69
+ async function upsert(collection, idField, record) {
70
+ return transact((state) => {
71
+ const id = record[idField];
72
+ const existing = state[collection][id];
73
+ if (existing && !same(existing, record)) {
74
+ const error = new Error(`${collection} ID ${id} 已存在且内容不同。`);
75
+ error.code = 'OPENPRD_GATE_ID_CONFLICT';
76
+ throw error;
77
+ }
78
+ if (existing) return { changed: false, value: existing };
79
+ state[collection][id] = record;
80
+ return { value: record };
81
+ }).then(({ result }) => result);
82
+ }
83
+
84
+ return {
85
+ filePath,
86
+ read,
87
+ recordGate(input) {
88
+ const gate = createGateDefinition(input);
89
+ return upsert('gates', 'gateId', gate);
90
+ },
91
+ recordEvidence(input) {
92
+ const evidence = createEvidenceRecord(input, options);
93
+ return upsert('evidence', 'evidenceId', evidence);
94
+ },
95
+ recordApproval(input) {
96
+ const approval = createApprovalRecord(input, options);
97
+ return upsert('approvals', 'approvalId', approval);
98
+ },
99
+ async evaluate(input) {
100
+ return transact((state) => {
101
+ const gate = state.gates[input.gateId];
102
+ if (!gate) {
103
+ const error = new Error(`Gate 不存在:${input.gateId}。`);
104
+ error.code = 'OPENPRD_GATE_NOT_FOUND';
105
+ throw error;
106
+ }
107
+ const decision = evaluateScopedGate({
108
+ gate,
109
+ evidence: Object.values(state.evidence),
110
+ approvals: Object.values(state.approvals),
111
+ approvalId: input.approvalId ?? null,
112
+ }, options);
113
+ const existing = state.decisions.find((item) => item.decisionId === decision.decisionId);
114
+ if (existing) return { changed: false, value: existing };
115
+ state.decisions.push(decision);
116
+ if (decision.approvalConsumption) {
117
+ const approval = state.approvals[decision.approvalConsumption.approvalId];
118
+ if (approval) state.approvals[approval.approvalId] = { ...approval, status: 'consumed' };
119
+ }
120
+ return { value: decision };
121
+ }).then(({ result }) => result);
122
+ },
123
+ };
124
+ }
125
+
126
+ export { GATE_STORE_SCHEMA_VERSION, gateStorePath, openGateStore };
@@ -0,0 +1,41 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ensureProjectManifest, readProjectManifest } from '../kernel/index.js';
4
+ import { openGateStore } from './store.js';
5
+
6
+ async function readPayload(projectRoot, jsonFile) {
7
+ if (!jsonFile) return {};
8
+ const filePath = path.isAbsolute(jsonFile) ? jsonFile : path.resolve(projectRoot, jsonFile);
9
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
10
+ }
11
+
12
+ async function gateWorkspace(projectRoot, options = {}) {
13
+ const action = options.action ?? 'status';
14
+ try {
15
+ if (action === 'status') await readProjectManifest(projectRoot, { mode: 'read' });
16
+ else await ensureProjectManifest(projectRoot);
17
+ const payload = await readPayload(projectRoot, options.jsonFile);
18
+ const store = openGateStore(projectRoot);
19
+ let result;
20
+ if (action === 'status') result = await store.read();
21
+ else if (action === 'define') result = await store.recordGate(payload.gate ?? payload);
22
+ else if (action === 'evidence') result = await store.recordEvidence(payload.evidence ?? payload);
23
+ else if (action === 'approve') result = await store.recordApproval(payload.approval ?? payload);
24
+ else if (action === 'evaluate') {
25
+ result = await store.evaluate({
26
+ gateId: options.gateId ?? payload.gateId,
27
+ approvalId: options.approvalId ?? payload.approvalId ?? null,
28
+ });
29
+ } else throw new TypeError(`Unsupported gate action: ${action}`);
30
+ return { ok: true, action: `gate-${action}`, projectRoot: path.resolve(projectRoot), result, file: store.filePath };
31
+ } catch (error) {
32
+ return {
33
+ ok: false,
34
+ action: `gate-${action}`,
35
+ projectRoot: path.resolve(projectRoot),
36
+ error: { code: error?.code ?? 'OPENPRD_GATE_ERROR', message: error instanceof Error ? error.message : String(error) },
37
+ };
38
+ }
39
+ }
40
+
41
+ export { gateWorkspace };