@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,44 @@
1
+ /*
2
+ * ## 核心功能
3
+ * 汇总 Lane/Task Runtime 的稳定公共 API,供后续 CLI、Hook 与多工具 Adapter 接线。
4
+ *
5
+ * ## 输入
6
+ * 无运行时输入;仅重导出 runtime 子模块的类、schema、错误和 write-set 函数。
7
+ *
8
+ * ## 输出
9
+ * 对外提供 TaskRuntime、LaneStore、结构化错误码、schema 与路径交集判断 API。
10
+ *
11
+ * ## 定位
12
+ * 位于 runtime 包入口,不承载业务逻辑或文件写入。
13
+ *
14
+ * ## 依赖
15
+ * 依赖同目录 task-runtime、lane-store、lane-schema、errors、write-set 模块。
16
+ *
17
+ * ## 维护规则
18
+ * 公共导出属于接线契约;重命名或删除前必须保留兼容别名并增加迁移测试。
19
+ */
20
+
21
+ export {
22
+ OPENPRD_REVISION_CONFLICT,
23
+ OPENPRD_TASK_ALREADY_CLAIMED,
24
+ OPENPRD_WRITE_SET_CONFLICT,
25
+ OpenPrdRuntimeError,
26
+ } from './errors.js';
27
+ export {
28
+ assertLaneState,
29
+ createLaneState,
30
+ createTaskState,
31
+ LANE_SCHEMA_VERSION,
32
+ LANE_STATUSES,
33
+ normalizeRuntimeId,
34
+ TASK_STATUSES,
35
+ } from './lane-schema.js';
36
+ export { LaneStore } from './lane-store.js';
37
+ export { TaskRuntime } from './task-runtime.js';
38
+ export {
39
+ BROAD_PATTERN,
40
+ explainWriteSetIntersection,
41
+ normalizeWritePattern,
42
+ normalizeWriteSet,
43
+ writeSetsIntersect,
44
+ } from './write-set.js';
@@ -0,0 +1,141 @@
1
+ /*
2
+ * ## 核心功能
3
+ * 定义可持久化的 Lane、Task、Claim 数据形状并统一做输入规范化。
4
+ *
5
+ * ## 输入
6
+ * 接收 lane 标识、任务清单、write-set、元数据与可注入的当前时间。
7
+ *
8
+ * ## 输出
9
+ * 导出 schema 常量、ID 校验、lane 初始状态构造与已存状态校验函数。
10
+ *
11
+ * ## 定位
12
+ * 位于 runtime 的纯数据层,不执行文件读写、锁竞争或租约续期。
13
+ *
14
+ * ## 依赖
15
+ * 依赖同目录 write-set 模块;其余均为 JavaScript 内建能力。
16
+ *
17
+ * ## 维护规则
18
+ * schemaVersion 升级必须提供向后读取策略;持久化字段不得静默改义或删除。
19
+ */
20
+
21
+ import { normalizeWriteSet } from './write-set.js';
22
+
23
+ export const LANE_SCHEMA_VERSION = 1;
24
+ export const LANE_STATUSES = Object.freeze(['active', 'paused', 'completed']);
25
+ export const TASK_STATUSES = Object.freeze(['pending', 'claimed', 'completed', 'failed', 'cancelled']);
26
+
27
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/;
28
+
29
+ function cloneJson(value, fallback) {
30
+ if (value === undefined) return fallback;
31
+ return structuredClone(value);
32
+ }
33
+
34
+ function isoTime(value = Date.now()) {
35
+ if (typeof value === 'string') {
36
+ const parsed = new Date(value);
37
+ if (!Number.isNaN(parsed.valueOf())) return parsed.toISOString();
38
+ }
39
+ const numeric = value instanceof Date ? value.valueOf() : Number(value);
40
+ if (!Number.isFinite(numeric)) throw new TypeError('OpenPrd runtime clock must return a Date, ISO string, or epoch milliseconds.');
41
+ return new Date(numeric).toISOString();
42
+ }
43
+
44
+ export function normalizeRuntimeId(value, label = 'id') {
45
+ const normalized = String(value ?? '').trim();
46
+ if (!ID_PATTERN.test(normalized) || ['__proto__', 'prototype', 'constructor'].includes(normalized)) {
47
+ throw new TypeError(`OpenPrd ${label} must match ${ID_PATTERN} and cannot use a reserved object key.`);
48
+ }
49
+ return normalized;
50
+ }
51
+
52
+ function normalizeTaskInput(value, taskIdHint, now) {
53
+ const source = typeof value === 'string' ? { taskId: value } : (value ?? {});
54
+ const taskId = normalizeRuntimeId(source.taskId ?? source.id ?? taskIdHint, 'taskId');
55
+ const status = source.status ?? 'pending';
56
+ if (!TASK_STATUSES.includes(status) || status === 'claimed') {
57
+ throw new TypeError(`OpenPrd task ${taskId} must start as pending, completed, failed, or cancelled.`);
58
+ }
59
+ return {
60
+ taskId,
61
+ title: String(source.title ?? taskId).trim() || taskId,
62
+ status,
63
+ revision: 0,
64
+ claimGeneration: 0,
65
+ attemptCount: 0,
66
+ writeSet: normalizeWriteSet(source.writeSet ?? source.writeScope ?? source.paths),
67
+ claim: null,
68
+ lastClaim: null,
69
+ result: cloneJson(source.result, null),
70
+ evidence: cloneJson(source.evidence, []),
71
+ metadata: cloneJson(source.metadata, {}),
72
+ createdAt: now,
73
+ updatedAt: now,
74
+ };
75
+ }
76
+
77
+ export function createTaskState(input, clockValue = Date.now(), taskIdHint = null) {
78
+ return normalizeTaskInput(input, taskIdHint, isoTime(clockValue));
79
+ }
80
+
81
+ function taskEntries(tasks) {
82
+ if (Array.isArray(tasks)) return tasks.map((task) => [null, task]);
83
+ if (tasks && typeof tasks === 'object') return Object.entries(tasks);
84
+ return [];
85
+ }
86
+
87
+ export function createLaneState(input, clockValue = Date.now()) {
88
+ const source = input ?? {};
89
+ const laneId = normalizeRuntimeId(source.laneId ?? source.id, 'laneId');
90
+ const now = isoTime(clockValue);
91
+ const tasks = {};
92
+ for (const [taskIdHint, taskInput] of taskEntries(source.tasks)) {
93
+ const task = normalizeTaskInput(taskInput, taskIdHint, now);
94
+ if (Object.hasOwn(tasks, task.taskId)) throw new TypeError(`Duplicate OpenPrd taskId: ${task.taskId}`);
95
+ tasks[task.taskId] = task;
96
+ }
97
+ const status = source.status ?? 'active';
98
+ if (!LANE_STATUSES.includes(status)) throw new TypeError(`Unsupported OpenPrd lane status: ${status}`);
99
+ return {
100
+ schemaVersion: LANE_SCHEMA_VERSION,
101
+ laneId,
102
+ title: String(source.title ?? laneId).trim() || laneId,
103
+ status,
104
+ revision: 0,
105
+ metadata: cloneJson(source.metadata, {}),
106
+ tasks,
107
+ createdAt: now,
108
+ updatedAt: now,
109
+ };
110
+ }
111
+
112
+ export function assertLaneState(value, expectedLaneId = null) {
113
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
114
+ throw new TypeError('OpenPrd lane state must be a JSON object.');
115
+ }
116
+ if (value.schemaVersion !== LANE_SCHEMA_VERSION) {
117
+ throw new TypeError(`Unsupported OpenPrd lane schemaVersion: ${value.schemaVersion}`);
118
+ }
119
+ const laneId = normalizeRuntimeId(value.laneId, 'laneId');
120
+ if (expectedLaneId !== null && laneId !== normalizeRuntimeId(expectedLaneId, 'laneId')) {
121
+ throw new TypeError(`OpenPrd lane file contains ${laneId}, expected ${expectedLaneId}.`);
122
+ }
123
+ if (!Number.isSafeInteger(value.revision) || value.revision < 0) {
124
+ throw new TypeError(`OpenPrd lane ${laneId} has an invalid revision.`);
125
+ }
126
+ if (!value.tasks || typeof value.tasks !== 'object' || Array.isArray(value.tasks)) {
127
+ throw new TypeError(`OpenPrd lane ${laneId} must contain a task object.`);
128
+ }
129
+ for (const [taskId, task] of Object.entries(value.tasks)) {
130
+ normalizeRuntimeId(taskId, 'taskId');
131
+ if (!task || task.taskId !== taskId || !TASK_STATUSES.includes(task.status)) {
132
+ throw new TypeError(`OpenPrd lane ${laneId} contains an invalid task ${taskId}.`);
133
+ }
134
+ if (!Number.isSafeInteger(task.revision) || task.revision < 0) {
135
+ throw new TypeError(`OpenPrd task ${taskId} has an invalid revision.`);
136
+ }
137
+ }
138
+ return value;
139
+ }
140
+
141
+ export { isoTime };
@@ -0,0 +1,279 @@
1
+ /*
2
+ * ## 核心功能
3
+ * 以原子文件替换和跨进程目录锁持久化 Lane,并提供 lane revision 的 CAS 事务。
4
+ *
5
+ * ## 输入
6
+ * 接收 runtime 根目录、lane schema、expectedRevision 与事务更新函数。
7
+ *
8
+ * ## 输出
9
+ * 导出 LaneStore,提供 create/ensure/read/list/save/transact 等零依赖存储 API。
10
+ *
11
+ * ## 定位
12
+ * 位于 runtime 持久化层;负责原子性与 lane 级串行化,不解释任务租约或 write-set 语义。
13
+ *
14
+ * ## 依赖
15
+ * 使用 Node.js fs/path/crypto 与同目录 lane-schema、errors 模块。
16
+ *
17
+ * ## 维护规则
18
+ * 禁止改回直接覆盖目标 JSON;任何 read-modify-write 必须持有同一 lane lock 并执行 revision 校验。
19
+ */
20
+
21
+ import crypto from 'node:crypto';
22
+ import fs from 'node:fs/promises';
23
+ import path from 'node:path';
24
+
25
+ import { OpenPrdRuntimeError, revisionConflictError } from './errors.js';
26
+ import { assertLaneState, createLaneState, isoTime, normalizeRuntimeId } from './lane-schema.js';
27
+
28
+ function clone(value) {
29
+ return structuredClone(value);
30
+ }
31
+
32
+ function sleep(milliseconds) {
33
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
34
+ }
35
+
36
+ function laneFileToken(laneId) {
37
+ return Buffer.from(laneId, 'utf8').toString('base64url');
38
+ }
39
+
40
+ async function fileExists(filePath) {
41
+ return fs.access(filePath).then(() => true).catch(() => false);
42
+ }
43
+
44
+ async function readJson(filePath) {
45
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
46
+ }
47
+
48
+ async function atomicWriteJson(filePath, value) {
49
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
50
+ const temporaryPath = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
51
+ const handle = await fs.open(temporaryPath, 'wx', 0o600);
52
+ try {
53
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
54
+ await handle.sync();
55
+ } finally {
56
+ await handle.close();
57
+ }
58
+ try {
59
+ await fs.rename(temporaryPath, filePath);
60
+ } catch (error) {
61
+ await fs.rm(temporaryPath, { force: true }).catch(() => {});
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ function pidIsAlive(pid) {
67
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch (error) {
72
+ return error?.code === 'EPERM';
73
+ }
74
+ }
75
+
76
+ export class LaneStore {
77
+ constructor(rootDir, options = {}) {
78
+ if (!rootDir) throw new TypeError('OpenPrd LaneStore requires a root directory.');
79
+ this.rootDir = path.resolve(rootDir);
80
+ this.lanesDir = path.join(this.rootDir, 'lanes');
81
+ this.clock = options.clock ?? { now: () => Date.now() };
82
+ this.lockTimeoutMs = Number(options.lockTimeoutMs ?? 15_000);
83
+ this.lockRetryMs = Math.max(1, Number(options.lockRetryMs ?? 2));
84
+ this.lockStaleMs = Math.max(this.lockTimeoutMs * 2, Number(options.lockStaleMs ?? 60_000));
85
+ }
86
+
87
+ lanePath(laneIdValue) {
88
+ const laneId = normalizeRuntimeId(laneIdValue, 'laneId');
89
+ return path.join(this.lanesDir, `${laneFileToken(laneId)}.json`);
90
+ }
91
+
92
+ lockPath(laneId) {
93
+ return `${this.lanePath(laneId)}.lock`;
94
+ }
95
+
96
+ now() {
97
+ return isoTime(this.clock.now());
98
+ }
99
+
100
+ async read(laneId) {
101
+ const normalizedId = normalizeRuntimeId(laneId, 'laneId');
102
+ const value = await readJson(this.lanePath(normalizedId));
103
+ return clone(assertLaneState(value, normalizedId));
104
+ }
105
+
106
+ async readOrNull(laneId) {
107
+ return this.read(laneId).catch((error) => {
108
+ if (error?.code === 'ENOENT') return null;
109
+ throw error;
110
+ });
111
+ }
112
+
113
+ async list() {
114
+ const entries = await fs.readdir(this.lanesDir, { withFileTypes: true }).catch((error) => {
115
+ if (error?.code === 'ENOENT') return [];
116
+ throw error;
117
+ });
118
+ const lanes = [];
119
+ for (const entry of entries) {
120
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
121
+ const value = await readJson(path.join(this.lanesDir, entry.name));
122
+ lanes.push(clone(assertLaneState(value)));
123
+ }
124
+ return lanes.sort((left, right) => left.laneId.localeCompare(right.laneId));
125
+ }
126
+
127
+ async create(input) {
128
+ const initial = createLaneState(input, this.clock.now());
129
+ return this.#withLock(initial.laneId, async () => {
130
+ const current = await this.#readUnlocked(initial.laneId);
131
+ if (current) {
132
+ throw revisionConflictError({
133
+ laneId: initial.laneId,
134
+ expectedRevision: -1,
135
+ actualRevision: current.revision,
136
+ operation: 'create-lane',
137
+ });
138
+ }
139
+ await atomicWriteJson(this.lanePath(initial.laneId), initial);
140
+ return clone(initial);
141
+ });
142
+ }
143
+
144
+ async ensure(input) {
145
+ const initial = createLaneState(input, this.clock.now());
146
+ return this.#withLock(initial.laneId, async () => {
147
+ const current = await this.#readUnlocked(initial.laneId);
148
+ if (current) return { created: false, lane: clone(current) };
149
+ await atomicWriteJson(this.lanePath(initial.laneId), initial);
150
+ return { created: true, lane: clone(initial) };
151
+ });
152
+ }
153
+
154
+ async save(laneValue, options = {}) {
155
+ const lane = clone(assertLaneState(laneValue));
156
+ return this.#withLock(lane.laneId, async () => {
157
+ const current = await this.#readUnlocked(lane.laneId);
158
+ const actualRevision = current?.revision ?? -1;
159
+ const expectedRevision = options.expectedRevision ?? lane.revision;
160
+ if (expectedRevision !== actualRevision) {
161
+ throw revisionConflictError({
162
+ laneId: lane.laneId,
163
+ expectedRevision,
164
+ actualRevision,
165
+ operation: 'save-lane',
166
+ });
167
+ }
168
+ lane.revision = actualRevision + 1;
169
+ lane.createdAt = current?.createdAt ?? lane.createdAt ?? this.now();
170
+ lane.updatedAt = this.now();
171
+ assertLaneState(lane, lane.laneId);
172
+ await atomicWriteJson(this.lanePath(lane.laneId), lane);
173
+ return clone(lane);
174
+ });
175
+ }
176
+
177
+ async transact(laneIdValue, options, mutate) {
178
+ const laneId = normalizeRuntimeId(laneIdValue, 'laneId');
179
+ const transactionOptions = typeof options === 'function' ? {} : (options ?? {});
180
+ const mutator = typeof options === 'function' ? options : mutate;
181
+ if (typeof mutator !== 'function') throw new TypeError('OpenPrd LaneStore transaction requires a mutator function.');
182
+
183
+ return this.#withLock(laneId, async () => {
184
+ const current = await this.#readUnlocked(laneId);
185
+ if (!current) {
186
+ const error = new Error(`OpenPrd lane not found: ${laneId}`);
187
+ error.code = 'ENOENT';
188
+ throw error;
189
+ }
190
+ if (transactionOptions.expectedRevision !== undefined
191
+ && transactionOptions.expectedRevision !== current.revision) {
192
+ throw revisionConflictError({
193
+ laneId,
194
+ expectedRevision: transactionOptions.expectedRevision,
195
+ actualRevision: current.revision,
196
+ operation: transactionOptions.operation ?? 'lane-transaction',
197
+ });
198
+ }
199
+
200
+ const draft = clone(current);
201
+ const outcome = await mutator(draft, clone(current));
202
+ if (outcome?.changed === false) {
203
+ return { changed: false, lane: clone(current), result: clone(outcome.result) };
204
+ }
205
+ draft.revision = current.revision + 1;
206
+ draft.updatedAt = this.now();
207
+ assertLaneState(draft, laneId);
208
+ await atomicWriteJson(this.lanePath(laneId), draft);
209
+ return {
210
+ changed: true,
211
+ lane: clone(draft),
212
+ result: clone(outcome?.result),
213
+ };
214
+ });
215
+ }
216
+
217
+ async #readUnlocked(laneId) {
218
+ const filePath = this.lanePath(laneId);
219
+ if (!await fileExists(filePath)) return null;
220
+ return assertLaneState(await readJson(filePath), laneId);
221
+ }
222
+
223
+ async #withLock(laneId, operation) {
224
+ await fs.mkdir(this.lanesDir, { recursive: true });
225
+ const lockPath = this.lockPath(laneId);
226
+ const owner = {
227
+ token: crypto.randomUUID(),
228
+ pid: process.pid,
229
+ createdAt: new Date().toISOString(),
230
+ };
231
+ const startedAt = Date.now();
232
+
233
+ while (true) {
234
+ try {
235
+ await fs.mkdir(lockPath);
236
+ await fs.writeFile(path.join(lockPath, 'owner.json'), `${JSON.stringify(owner)}\n`, 'utf8');
237
+ break;
238
+ } catch (error) {
239
+ if (error?.code !== 'EEXIST') throw error;
240
+ await this.#recoverAbandonedLock(lockPath);
241
+ if (Date.now() - startedAt >= this.lockTimeoutMs) {
242
+ throw new OpenPrdRuntimeError(
243
+ 'OPENPRD_RUNTIME_LOCK_TIMEOUT',
244
+ `Timed out waiting for OpenPrd lane lock ${laneId}.`,
245
+ { laneId, lockPath, timeoutMs: this.lockTimeoutMs },
246
+ );
247
+ }
248
+ await sleep(this.lockRetryMs + Math.floor(Math.random() * this.lockRetryMs));
249
+ }
250
+ }
251
+
252
+ try {
253
+ return await operation();
254
+ } finally {
255
+ const recordedOwner = await readJson(path.join(lockPath, 'owner.json')).catch(() => null);
256
+ if (recordedOwner?.token === owner.token) {
257
+ await fs.rm(lockPath, { recursive: true, force: true });
258
+ }
259
+ }
260
+ }
261
+
262
+ async #recoverAbandonedLock(lockPath) {
263
+ const stat = await fs.stat(lockPath).catch(() => null);
264
+ if (!stat || Date.now() - stat.mtimeMs < this.lockStaleMs) return false;
265
+ const owner = await readJson(path.join(lockPath, 'owner.json')).catch(() => null);
266
+ if (owner && pidIsAlive(owner.pid)) return false;
267
+ const quarantinePath = `${lockPath}.abandoned.${crypto.randomUUID()}`;
268
+ try {
269
+ await fs.rename(lockPath, quarantinePath);
270
+ } catch (error) {
271
+ if (error?.code === 'ENOENT') return false;
272
+ throw error;
273
+ }
274
+ await fs.rm(quarantinePath, { recursive: true, force: true });
275
+ return true;
276
+ }
277
+ }
278
+
279
+ export { atomicWriteJson };