chati-dev 4.5.16 → 4.5.28

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 (122) hide show
  1. package/README.md +8 -3
  2. package/bin/chati.js +124 -66
  3. package/framework/agents/build/dev.md +5 -5
  4. package/framework/agents/deploy/devops.md +7 -7
  5. package/framework/agents/discover/brief.md +4 -4
  6. package/framework/agents/discover/brownfield-wu.md +3 -3
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +4 -4
  10. package/framework/agents/plan/phases.md +2 -2
  11. package/framework/agents/plan/tasks.md +2 -2
  12. package/framework/agents/plan/ux.md +2 -2
  13. package/framework/agents/quality/qa-implementation.md +11 -10
  14. package/framework/agents/quality/qa-planning.md +3 -3
  15. package/framework/agents/quality/qa-visual.md +1 -1
  16. package/framework/config.yaml +3 -3
  17. package/framework/constitution.md +18 -18
  18. package/framework/context/protocols.md +1 -1
  19. package/framework/context/quality.md +1 -1
  20. package/framework/context/root.md +4 -4
  21. package/framework/data/entity-registry.yaml +1 -1
  22. package/framework/domains/agents/orchestrator.yaml +2 -2
  23. package/framework/domains/constitution.yaml +1 -1
  24. package/framework/hooks/advance-trigger.js +4 -6
  25. package/framework/hooks/git-push-authority.js +45 -37
  26. package/framework/hooks/mode-governance.js +149 -40
  27. package/framework/hooks/model-governance.js +13 -20
  28. package/framework/hooks/prism-engine.js +74 -92
  29. package/framework/hooks/reasoning-escalator.js +23 -40
  30. package/framework/hooks/session-digest.js +12 -13
  31. package/framework/hooks/session-reader.js +224 -0
  32. package/framework/hooks/session-writer.js +195 -0
  33. package/framework/hooks/team-quality-gate.js +34 -24
  34. package/framework/i18n/en.yaml +2 -2
  35. package/framework/i18n/es.yaml +2 -2
  36. package/framework/i18n/fr.yaml +2 -2
  37. package/framework/i18n/pt.yaml +2 -2
  38. package/framework/intelligence/context-engine.md +4 -4
  39. package/framework/intelligence/memory-layer.md +1 -1
  40. package/framework/manifest.json +129 -119
  41. package/framework/manifest.sig +1 -1
  42. package/framework/orchestrator/chati-router.js +252 -25
  43. package/framework/orchestrator/chati.md +238 -70
  44. package/framework/schemas/session.schema.json +1 -1
  45. package/framework/tasks/orchestrator-deviation.md +1 -1
  46. package/framework/tasks/orchestrator-escalate.md +1 -1
  47. package/framework/tasks/orchestrator-handoff.md +6 -6
  48. package/framework/tasks/orchestrator-health.md +5 -9
  49. package/framework/tasks/orchestrator-mode-switch.md +3 -7
  50. package/framework/tasks/orchestrator-resume.md +10 -14
  51. package/framework/tasks/orchestrator-route.md +3 -3
  52. package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
  53. package/framework/tasks/orchestrator-status.md +9 -9
  54. package/framework/tasks/orchestrator-suggest-mode.md +1 -1
  55. package/framework/tasks/qa-impl-consolidate.md +2 -2
  56. package/framework/tasks/qa-impl-performance-test.md +4 -4
  57. package/framework/tasks/qa-impl-regression-check.md +4 -4
  58. package/framework/tasks/qa-impl-sast-scan.md +1 -1
  59. package/framework/tasks/qa-impl-test-execute.md +1 -1
  60. package/framework/tasks/qa-impl-verdict.md +2 -2
  61. package/framework/tasks/qa-planning-consolidate.md +3 -3
  62. package/framework/tasks/qa-planning-coverage-plan.md +2 -2
  63. package/framework/tasks/qa-planning-gate-define.md +4 -4
  64. package/framework/tasks/qa-planning-risk-matrix.md +4 -4
  65. package/framework/tasks/qa-planning-test-strategy.md +2 -2
  66. package/node_modules/@chati/browser-capability/src/index.js +12 -2
  67. package/node_modules/@chati/planning/src/index.js +24 -4
  68. package/node_modules/@chati/provider-registry/src/index.js +11 -0
  69. package/node_modules/@chati/rail/src/index.js +1967 -83
  70. package/node_modules/@chati/release-lane/README.md +12 -8
  71. package/node_modules/@chati/release-lane/package.json +1 -1
  72. package/node_modules/@chati/release-lane/src/index.js +1426 -58
  73. package/node_modules/@chati/review-council/src/index.js +63 -0
  74. package/node_modules/@chati/tracking-clickup/README.md +13 -0
  75. package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
  76. package/package-artifact-manifest.json +1 -0
  77. package/package-artifact-manifest.sig +1 -0
  78. package/package.json +16 -7
  79. package/scripts/verify-real-harness-e2e.js +1581 -0
  80. package/src/config/framework-adapter.js +4 -4
  81. package/src/installer/core.js +148 -53
  82. package/src/installer/manifest.js +140 -9
  83. package/src/installer/package-artifact.js +249 -0
  84. package/src/installer/templates.js +65 -20
  85. package/src/installer-v2/catalog-client.js +531 -23
  86. package/src/installer-v2/index.js +91 -34
  87. package/src/installer-v2/installation-authority.js +327 -0
  88. package/src/installer-v2/provider-executable.js +247 -0
  89. package/src/installer-v2/wizard-installation.js +1 -1
  90. package/src/orchestrator/browser-runtime.js +44 -13
  91. package/src/orchestrator/cli.js +1545 -174
  92. package/src/orchestrator/clickup-projection.js +43 -1
  93. package/src/orchestrator/clickup-runtime.js +355 -39
  94. package/src/orchestrator/doctor.js +16 -3
  95. package/src/orchestrator/index.js +16 -0
  96. package/src/orchestrator/planning-runtime.js +20 -2
  97. package/src/orchestrator/rail-adjudication-evidence.js +234 -0
  98. package/src/orchestrator/rail-evidence-authority.js +147 -0
  99. package/src/orchestrator/rail-execution-evidence.js +45 -0
  100. package/src/orchestrator/rail-runtime.js +836 -56
  101. package/src/orchestrator/release-runtime.js +65 -6
  102. package/src/orchestrator/review-runtime.js +186 -41
  103. package/src/orchestrator/runtime-installation-v2.js +236 -20
  104. package/src/orchestrator/session-manager.js +1167 -53
  105. package/src/terminal/adapters/claude-adapter.js +3 -1
  106. package/src/terminal/adapters/codex-adapter.js +2 -0
  107. package/src/terminal/adapters/grok-adapter.js +7 -4
  108. package/src/terminal/handoff-parser.js +19 -1
  109. package/src/terminal/prompt-builder.js +9 -1
  110. package/src/terminal/provider-preflight.js +36 -3
  111. package/src/terminal/rail-execution-worktree.js +213 -0
  112. package/src/terminal/rail-prompts.js +169 -0
  113. package/src/terminal/rail-readonly-workspace.js +324 -0
  114. package/src/terminal/run-agent.js +384 -24
  115. package/src/terminal/run-parallel.js +5 -0
  116. package/src/terminal/run-rail-adjudication.js +323 -0
  117. package/src/terminal/run-rail-review.js +291 -0
  118. package/src/terminal/run-rail-rework.js +325 -0
  119. package/src/terminal/run-rail-task.js +380 -0
  120. package/src/terminal/run-team.js +5 -0
  121. package/src/terminal/spawner.js +1225 -77
  122. package/src/wizard/index.js +3 -2
@@ -0,0 +1,324 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ closeSync, constants, fstatSync, lstatSync, openSync, readSync, readdirSync, readlinkSync,
5
+ realpathSync, mkdirSync, mkdtempSync, rmSync, writeFileSync,
6
+ } from 'node:fs';
7
+ import { tmpdir } from 'node:os';
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
+ import { sha256 } from '@chati/core';
10
+
11
+ const CANONICAL_RAIL_MUTABLE_PATHS = new Set([
12
+ '.chati/v2/rail/journal.jsonl',
13
+ '.chati/v2/rail/journal.jsonl.checkpoint',
14
+ '.chati/v2/rail/journal.jsonl.lock',
15
+ ]);
16
+ const CANONICAL_RAIL_CHECKPOINT_TEMP = /^\.chati\/v2\/rail\/journal\.jsonl\.checkpoint\.\d+\.[a-f0-9-]+\.tmp$/;
17
+ const RAIL_READ_ONLY_WORKSPACE_TEST_HOOKS = Symbol.for('chati.rail.readonly-workspace-test-hooks');
18
+
19
+ function runReadOnlyWorkspaceTestHook(name, detail) {
20
+ const hook = globalThis[RAIL_READ_ONLY_WORKSPACE_TEST_HOOKS]?.[name];
21
+ if (typeof hook === 'function') hook(detail);
22
+ }
23
+
24
+ function fail(code, message, details = {}) {
25
+ const error = new Error(message);
26
+ error.code = code;
27
+ error.details = details;
28
+ throw error;
29
+ }
30
+
31
+ function trustedGitEnvironment(source = process.env) {
32
+ const environment = Object.fromEntries(Object.entries(source).filter(([key]) => !key.startsWith('GIT_')));
33
+ return {
34
+ ...environment,
35
+ GIT_CONFIG_NOSYSTEM: '1',
36
+ GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
37
+ GIT_OPTIONAL_LOCKS: '0',
38
+ GIT_TERMINAL_PROMPT: '0',
39
+ };
40
+ }
41
+
42
+ function git(projectDir, args, options = {}) {
43
+ try {
44
+ return execFileSync('git', ['-C', projectDir, ...args], {
45
+ ...options,
46
+ env: trustedGitEnvironment(options.env || process.env),
47
+ });
48
+ } catch (error) {
49
+ return fail('RAIL_READ_ONLY_GIT_STATE_UNAVAILABLE', 'cannot capture the Git state required for a read-only RAIL action', { cause: error.message });
50
+ }
51
+ }
52
+
53
+ function portableRelative(projectDir, path) {
54
+ return relative(projectDir, path).split(sep).join('/');
55
+ }
56
+
57
+ function remainsInside(projectDir, path) {
58
+ const boundary = relative(projectDir, path);
59
+ return boundary === '' || (!boundary.startsWith(`..${sep}`) && boundary !== '..' && !isAbsolute(boundary));
60
+ }
61
+
62
+ function assertInternalSymlink(projectDir, path, target) {
63
+ const lexicalTarget = resolve(dirname(path), target);
64
+ if (!remainsInside(projectDir, lexicalTarget)) {
65
+ fail('RAIL_READ_ONLY_EXTERNAL_SYMLINK', 'read-only RAIL actions do not accept project symlinks that target external material', { path, target });
66
+ }
67
+ try {
68
+ const physicalTarget = realpathSync(path);
69
+ if (!remainsInside(projectDir, physicalTarget)) {
70
+ fail('RAIL_READ_ONLY_EXTERNAL_SYMLINK', 'read-only RAIL actions do not accept symlink chains that escape the project', { path, target });
71
+ }
72
+ } catch (error) {
73
+ if (error?.code?.startsWith?.('RAIL_')) throw error;
74
+ if (error?.code !== 'ENOENT') {
75
+ fail('RAIL_READ_ONLY_WORKSPACE_UNAVAILABLE', 'cannot resolve a project symlink for the read-only baseline', { path, cause: error.message });
76
+ }
77
+ }
78
+ }
79
+
80
+ function isCanonicalRailMutablePath(relativePath) {
81
+ return CANONICAL_RAIL_MUTABLE_PATHS.has(relativePath)
82
+ || CANONICAL_RAIL_CHECKPOINT_TEMP.test(relativePath);
83
+ }
84
+
85
+ function stableStat(stat) {
86
+ return {
87
+ dev: stat.dev,
88
+ ino: stat.ino,
89
+ mode: stat.mode,
90
+ nlink: stat.nlink,
91
+ size: stat.size,
92
+ mtime_ms: stat.mtimeMs,
93
+ ctime_ms: stat.ctimeMs,
94
+ };
95
+ }
96
+
97
+ function sameFileState(left, right) {
98
+ return sha256(stableStat(left)) === sha256(stableStat(right));
99
+ }
100
+
101
+ function hashRegularFile(path, initialStat) {
102
+ const hash = createHash('sha256');
103
+ let descriptor;
104
+ try {
105
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
106
+ const opened = fstatSync(descriptor);
107
+ if (!opened.isFile() || !sameFileState(opened, initialStat)) {
108
+ fail('RAIL_READ_ONLY_WORKSPACE_UNSTABLE', 'project file changed while the read-only baseline was captured', { path });
109
+ }
110
+ const buffer = Buffer.allocUnsafe(64 * 1024);
111
+ for (;;) {
112
+ const bytes = readSync(descriptor, buffer, 0, buffer.length, null);
113
+ if (bytes === 0) break;
114
+ hash.update(buffer.subarray(0, bytes));
115
+ }
116
+ const finished = fstatSync(descriptor);
117
+ if (!sameFileState(opened, finished)) {
118
+ fail('RAIL_READ_ONLY_WORKSPACE_UNSTABLE', 'project file changed while it was hashed', { path });
119
+ }
120
+ } catch (error) {
121
+ if (error?.code?.startsWith?.('RAIL_')) throw error;
122
+ fail('RAIL_READ_ONLY_WORKSPACE_UNAVAILABLE', 'cannot safely hash project material for a read-only RAIL action', { path, cause: error.message });
123
+ } finally {
124
+ if (descriptor !== undefined) closeSync(descriptor);
125
+ }
126
+ return hash.digest('hex');
127
+ }
128
+
129
+ function filesystemManifest(projectDir) {
130
+ const entries = [];
131
+ const visit = (directory) => {
132
+ let names;
133
+ try { names = readdirSync(directory).sort(); }
134
+ catch (error) {
135
+ fail('RAIL_READ_ONLY_WORKSPACE_UNAVAILABLE', 'cannot enumerate project material for a read-only RAIL action', { path: directory, cause: error.message });
136
+ }
137
+ for (const name of names) {
138
+ const path = join(directory, name);
139
+ const relativePath = portableRelative(projectDir, path);
140
+ if (isCanonicalRailMutablePath(relativePath)) continue;
141
+ let stat;
142
+ try { stat = lstatSync(path); }
143
+ catch (error) {
144
+ fail('RAIL_READ_ONLY_WORKSPACE_UNSTABLE', 'project material changed while the read-only baseline was enumerated', { path, cause: error.message });
145
+ }
146
+ if (stat.isDirectory()) {
147
+ entries.push({ path: relativePath, type: 'directory', mode: stat.mode });
148
+ visit(path);
149
+ } else if (stat.isFile()) {
150
+ entries.push({ path: relativePath, type: 'file', ...stableStat(stat), digest: hashRegularFile(path, stat) });
151
+ } else if (stat.isSymbolicLink()) {
152
+ const target = readlinkSync(path);
153
+ assertInternalSymlink(projectDir, path, target);
154
+ entries.push({ path: relativePath, type: 'symlink', mode: stat.mode, target });
155
+ } else {
156
+ fail('RAIL_READ_ONLY_SPECIAL_FILE', 'read-only RAIL actions do not accept sockets, FIFOs or device nodes in the project tree', { path: relativePath });
157
+ }
158
+ }
159
+ };
160
+ visit(projectDir);
161
+ return entries;
162
+ }
163
+
164
+ export function captureRailReadOnlyWorkspace({ projectDir } = {}) {
165
+ const root = resolve(projectDir);
166
+ const head = git(root, ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
167
+ const manifest = filesystemManifest(root);
168
+ return Object.freeze({
169
+ schema_version: 2,
170
+ project_dir: root,
171
+ head,
172
+ filesystem_entry_count: manifest.length,
173
+ filesystem_digest: sha256(manifest),
174
+ });
175
+ }
176
+
177
+ export function assertRailReadOnlyWorkspaceUnchanged({ projectDir, before, label } = {}) {
178
+ const after = captureRailReadOnlyWorkspace({ projectDir });
179
+ if (sha256(after) !== sha256(before)) {
180
+ fail('RAIL_READ_ONLY_WORKSPACE_MUTATED', `${label || 'RAIL read-only action'} changed the project worktree`, { before, after });
181
+ }
182
+ return after;
183
+ }
184
+
185
+ function captureCommitSubject(subjectDir, resultCommitRef, baseCommitRef) {
186
+ const manifest = filesystemManifest(subjectDir);
187
+ return Object.freeze({
188
+ schema_version: 3,
189
+ base_commit_ref: baseCommitRef,
190
+ result_commit_ref: resultCommitRef,
191
+ filesystem_entry_count: manifest.length,
192
+ filesystem_digest: sha256(manifest),
193
+ });
194
+ }
195
+
196
+ function resolveExactCommit(projectDir, commitRef, code, label) {
197
+ if (!/^[a-f0-9]{40,64}$/.test(commitRef || '')) {
198
+ fail(code, `read-only RAIL workspace requires a full lowercase ${label} commit id`);
199
+ }
200
+ const resolved = git(projectDir, ['rev-parse', '--verify', `${commitRef}^{commit}`], { encoding: 'utf8' }).trim();
201
+ if (resolved !== commitRef) fail(code, `read-only RAIL workspace resolved a different ${label} commit`);
202
+ return resolved;
203
+ }
204
+
205
+ function materializeReviewDelta({ projectDir, baseCommitRef, resultCommitRef, subjectDir }) {
206
+ const patch = git(projectDir, [
207
+ 'diff', '--no-ext-diff', '--binary', '--full-index', baseCommitRef, resultCommitRef,
208
+ ], { encoding: null });
209
+ // git archive intentionally respects export-ignore. The immutable patch is
210
+ // materialized beside the archive so a reviewer can still inspect every
211
+ // committed change, including generated metadata omitted from the archive.
212
+ writeFileSync(join(subjectDir, 'CHATI_RAIL_REVIEW.diff'), patch, { mode: 0o400 });
213
+ }
214
+
215
+ function openPinnedSubjectDirectory(subjectDir) {
216
+ let descriptor;
217
+ try {
218
+ const before = lstatSync(subjectDir);
219
+ if (!before.isDirectory() || before.isSymbolicLink()) {
220
+ fail('RAIL_READ_ONLY_WORKSPACE_UNSAFE', 'review subject directory must be a real directory');
221
+ }
222
+ descriptor = openSync(subjectDir, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
223
+ const opened = fstatSync(descriptor);
224
+ if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino) {
225
+ fail('RAIL_READ_ONLY_WORKSPACE_UNSAFE', 'review subject directory changed before archive extraction');
226
+ }
227
+ return Object.freeze({ descriptor, identity: { dev: opened.dev, ino: opened.ino } });
228
+ } catch (error) {
229
+ if (descriptor !== undefined) closeSync(descriptor);
230
+ if (error?.code?.startsWith?.('RAIL_')) throw error;
231
+ return fail('RAIL_READ_ONLY_WORKSPACE_UNSAFE', 'review subject directory cannot be opened without following links', { cause: error.message });
232
+ }
233
+ }
234
+
235
+ function extractArchiveIntoPinnedSubject(archivePath, subjectDir) {
236
+ const pinned = openPinnedSubjectDirectory(subjectDir);
237
+ try {
238
+ if (process.platform === 'linux') {
239
+ // The child receives the pinned directory as fd 3, so tar never resolves
240
+ // the mutable subject pathname after the security check.
241
+ execFileSync('tar', ['-xf', archivePath, '-C', '/proc/self/fd/3', '--no-same-owner', '--no-same-permissions'], {
242
+ env: { ...process.env, TAR_OPTIONS: '' },
243
+ stdio: ['ignore', 'ignore', 'pipe', pinned.descriptor],
244
+ });
245
+ return;
246
+ }
247
+ const current = lstatSync(subjectDir);
248
+ if (!current.isDirectory() || current.isSymbolicLink()
249
+ || current.dev !== pinned.identity.dev || current.ino !== pinned.identity.ino) {
250
+ fail('RAIL_READ_ONLY_WORKSPACE_UNSAFE', 'review subject directory changed before archive extraction');
251
+ }
252
+ execFileSync('tar', ['-xf', archivePath, '-C', subjectDir, '--no-same-owner', '--no-same-permissions'], {
253
+ env: { ...process.env, TAR_OPTIONS: '' }, stdio: 'ignore',
254
+ });
255
+ } finally {
256
+ closeSync(pinned.descriptor);
257
+ }
258
+ }
259
+
260
+ /** Materialize exactly one immutable Git commit without linking its .git dir. */
261
+ export function createRailReadOnlyCommitWorkspace({ projectDir, baseCommitRef, resultCommitRef } = {}) {
262
+ const canonicalProject = realpathSync(resolve(projectDir));
263
+ const resolvedBase = resolveExactCommit(canonicalProject, baseCommitRef, 'RAIL_REVIEW_BASE_COMMIT_INVALID', 'base');
264
+ const resolvedCommit = resolveExactCommit(canonicalProject, resultCommitRef, 'RAIL_REVIEW_RESULT_COMMIT_INVALID', 'result');
265
+ const root = mkdtempSync(join(tmpdir(), 'chati-rail-review-subject-'));
266
+ const rootStat = lstatSync(root);
267
+ const rootIdentity = {
268
+ dev: rootStat.dev,
269
+ ino: rootStat.ino,
270
+ uid: rootStat.uid,
271
+ gid: rootStat.gid,
272
+ };
273
+ const subjectDir = join(root, 'subject');
274
+ const archivePath = join(root, 'subject.tar');
275
+ try {
276
+ mkdirSync(subjectDir, { mode: 0o700 });
277
+ runReadOnlyWorkspaceTestHook('afterSubjectDirectoryCreate', { root, subjectDir });
278
+ git(canonicalProject, ['archive', '--format=tar', '--output', archivePath, resultCommitRef], { stdio: 'ignore' });
279
+ extractArchiveIntoPinnedSubject(archivePath, subjectDir);
280
+ rmSync(archivePath);
281
+ materializeReviewDelta({
282
+ projectDir: canonicalProject, baseCommitRef: resolvedBase, resultCommitRef: resolvedCommit, subjectDir,
283
+ });
284
+ const baseline = captureCommitSubject(subjectDir, resolvedCommit, resolvedBase);
285
+ return Object.freeze({
286
+ schema_version: 1,
287
+ root,
288
+ root_identity: Object.freeze(rootIdentity),
289
+ subject_dir: subjectDir,
290
+ canonical_project_dir: canonicalProject,
291
+ base_commit_ref: resolvedBase,
292
+ result_commit_ref: resultCommitRef,
293
+ baseline,
294
+ });
295
+ } catch (error) {
296
+ try { rmSync(root, { recursive: true, force: true }); } catch { /* best-effort cleanup of our private temp */ }
297
+ if (error?.code?.startsWith?.('RAIL_')) throw error;
298
+ return fail('RAIL_READ_ONLY_WORKSPACE_UNAVAILABLE', 'cannot materialize the immutable review commit', { cause: error.message });
299
+ }
300
+ }
301
+
302
+ export function assertRailReadOnlyCommitWorkspaceUnchanged({ workspace, label } = {}) {
303
+ const after = captureCommitSubject(workspace.subject_dir, workspace.result_commit_ref, workspace.base_commit_ref);
304
+ if (sha256(after) !== sha256(workspace.baseline)) {
305
+ fail('RAIL_READ_ONLY_WORKSPACE_MUTATED', `${label || 'RAIL read-only action'} changed the immutable commit workspace`, {
306
+ before: workspace.baseline, after,
307
+ });
308
+ }
309
+ return after;
310
+ }
311
+
312
+ export function cleanupRailReadOnlyCommitWorkspace(workspace) {
313
+ if (!workspace?.root || !workspace?.root_identity) return false;
314
+ let current;
315
+ try { current = lstatSync(workspace.root); }
316
+ catch (error) { if (error?.code === 'ENOENT') return true; throw error; }
317
+ if (!current.isDirectory() || current.isSymbolicLink()
318
+ || current.dev !== workspace.root_identity.dev || current.ino !== workspace.root_identity.ino
319
+ || current.uid !== workspace.root_identity.uid || current.gid !== workspace.root_identity.gid) {
320
+ fail('RAIL_READ_ONLY_WORKSPACE_CLEANUP_UNSAFE', 'immutable review workspace root changed before cleanup');
321
+ }
322
+ rmSync(workspace.root, { recursive: true });
323
+ return true;
324
+ }