@rig-c/create 1.0.4 → 1.2.0

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 (185) hide show
  1. package/README.md +5 -5
  2. package/docs/AGENT-REFERENCE.md +62 -0
  3. package/docs/CHECKS.md +45 -9
  4. package/docs/INTERVIEW.md +19 -7
  5. package/docs/LIVE-REVIEW.md +4 -4
  6. package/package.json +3 -2
  7. package/src/agent-guide.ts +7 -4
  8. package/src/check.ts +7 -4
  9. package/src/choice-writers.ts +3 -14
  10. package/src/cli.ts +1 -0
  11. package/src/feedback.ts +3 -10
  12. package/src/fix-ledger.ts +1 -1
  13. package/src/job-acts.ts +4 -0
  14. package/src/job-host-state.ts +154 -0
  15. package/src/job-host.ts +37 -14
  16. package/src/job-outbox.ts +5 -3
  17. package/src/job-publication.ts +17 -13
  18. package/src/job-runtime.ts +17 -4
  19. package/src/next.ts +21 -13
  20. package/src/parts-review.ts +4 -11
  21. package/src/reopen.ts +72 -9
  22. package/src/review-act.ts +49 -14
  23. package/src/review-package.ts +3 -1
  24. package/src/rig-version.ts +219 -8
  25. package/src/scene-writers.ts +5 -3
  26. package/src/scene.ts +14 -4
  27. package/src/signable.ts +25 -3
  28. package/src/signoff-checkpoint-act.ts +2 -16
  29. package/src/signoff.ts +3 -15
  30. package/src/waypoints.ts +20 -9
  31. package/templates/agent-guide.md +2 -24
  32. package/templates/collection.json +10 -2
  33. package/templates/example-chain/AGENT-REFERENCE.md +62 -0
  34. package/templates/example-chain/AGENTS.md +4 -26
  35. package/templates/example-chain/README.md +1 -1
  36. package/templates/example-chain/chain.json +385 -319
  37. package/templates/example-chain/checkpoints/0a-interview-options/AGENT-REFERENCE.md +62 -0
  38. package/templates/example-chain/checkpoints/0a-interview-options/AGENTS.md +4 -26
  39. package/templates/example-chain/checkpoints/0a-interview-options/project.json +22 -5
  40. package/templates/example-chain/checkpoints/0a-interview-options.json +7 -3
  41. package/templates/example-chain/checkpoints/0b-plan-pending/AGENT-REFERENCE.md +62 -0
  42. package/templates/example-chain/checkpoints/0b-plan-pending/AGENTS.md +4 -26
  43. package/templates/example-chain/checkpoints/0b-plan-pending/project.json +22 -5
  44. package/templates/example-chain/checkpoints/0b-plan-pending.json +7 -3
  45. package/templates/example-chain/checkpoints/1-parts-verdict/AGENT-REFERENCE.md +62 -0
  46. package/templates/example-chain/checkpoints/1-parts-verdict/AGENTS.md +4 -26
  47. package/templates/example-chain/checkpoints/1-parts-verdict/derived/drafts/chain/out/check/build/run.json +2 -2
  48. package/templates/example-chain/checkpoints/1-parts-verdict/derived/drafts/chain/out/run.json +2 -2
  49. package/templates/example-chain/checkpoints/1-parts-verdict/project.json +24 -7
  50. package/templates/example-chain/checkpoints/1-parts-verdict.json +9 -5
  51. package/templates/example-chain/checkpoints/2-rigging-answers/AGENT-REFERENCE.md +62 -0
  52. package/templates/example-chain/checkpoints/2-rigging-answers/AGENTS.md +4 -26
  53. package/templates/example-chain/checkpoints/2-rigging-answers/derived/drafts/chain/out/check/build/run.json +2 -2
  54. package/templates/example-chain/checkpoints/2-rigging-answers/derived/drafts/chain/out/run.json +2 -2
  55. package/templates/example-chain/checkpoints/2-rigging-answers/project.json +24 -7
  56. package/templates/example-chain/checkpoints/2-rigging-answers.json +21 -5
  57. package/templates/example-chain/checkpoints/3-rig-review/AGENT-REFERENCE.md +62 -0
  58. package/templates/example-chain/checkpoints/3-rig-review/AGENTS.md +4 -26
  59. package/templates/example-chain/checkpoints/3-rig-review/derived/drafts/chain/out/check/build/run.json +2 -2
  60. package/templates/example-chain/checkpoints/3-rig-review/derived/drafts/chain/out/run.json +2 -2
  61. package/templates/example-chain/checkpoints/3-rig-review/derived/review/v1/out/check/build/run.json +2 -2
  62. package/templates/example-chain/checkpoints/3-rig-review/derived/review/v1/out/rig/run.json +2 -2
  63. package/templates/example-chain/checkpoints/3-rig-review/evidence/interview/motion.json +1 -1
  64. package/templates/example-chain/checkpoints/3-rig-review/evidence/interview/turn.json +108 -0
  65. package/templates/example-chain/checkpoints/3-rig-review/outputs/v1/out/check/build/run.json +2 -2
  66. package/templates/example-chain/checkpoints/3-rig-review/outputs/v1/out/rig/run.json +2 -2
  67. package/templates/example-chain/checkpoints/3-rig-review/project.json +77 -28
  68. package/templates/example-chain/checkpoints/3-rig-review.json +19 -11
  69. package/templates/example-chain/checkpoints/4-direction-post/AGENT-REFERENCE.md +62 -0
  70. package/templates/example-chain/checkpoints/4-direction-post/AGENTS.md +4 -26
  71. package/templates/example-chain/checkpoints/4-direction-post/derived/drafts/chain/out/check/build/run.json +2 -2
  72. package/templates/example-chain/checkpoints/4-direction-post/derived/drafts/chain/out/run.json +2 -2
  73. package/templates/example-chain/checkpoints/4-direction-post/derived/review/v1/out/check/build/run.json +2 -2
  74. package/templates/example-chain/checkpoints/4-direction-post/derived/review/v1/out/rig/run.json +2 -2
  75. package/templates/example-chain/checkpoints/4-direction-post/evidence/interview/camera_depth.json +2 -2
  76. package/templates/example-chain/checkpoints/4-direction-post/evidence/interview/motion.json +1 -1
  77. package/templates/example-chain/checkpoints/4-direction-post/evidence/interview/post_composition.json +2 -2
  78. package/templates/example-chain/checkpoints/4-direction-post/evidence/interview/post_timing.json +2 -2
  79. package/templates/example-chain/checkpoints/4-direction-post/evidence/interview/turn.json +108 -0
  80. package/templates/example-chain/checkpoints/4-direction-post/outputs/v1/out/check/build/run.json +2 -2
  81. package/templates/example-chain/checkpoints/4-direction-post/outputs/v1/out/rig/run.json +2 -2
  82. package/templates/example-chain/checkpoints/4-direction-post/project.json +106 -57
  83. package/templates/example-chain/checkpoints/4-direction-post.json +22 -14
  84. package/templates/example-chain/derived/drafts/chain/out/check/build/run.json +2 -2
  85. package/templates/example-chain/derived/drafts/chain/out/run.json +2 -2
  86. package/templates/example-chain/derived/review/v1/out/check/build/run.json +2 -2
  87. package/templates/example-chain/derived/review/v1/out/rig/run.json +2 -2
  88. package/templates/example-chain/evidence/captions.json +1 -1
  89. package/templates/example-chain/evidence/direction/v2.json +1 -1
  90. package/templates/example-chain/evidence/direction-input.json +1 -1
  91. package/templates/example-chain/evidence/interview/camera_depth.json +2 -2
  92. package/templates/example-chain/evidence/interview/motion.json +1 -1
  93. package/templates/example-chain/evidence/interview/post_composition.json +2 -2
  94. package/templates/example-chain/evidence/interview/post_timing.json +2 -2
  95. package/templates/example-chain/evidence/interview/turn.json +108 -0
  96. package/templates/example-chain/evidence/scene/s1.json +2 -2
  97. package/templates/example-chain/outputs/v1/out/check/build/run.json +2 -2
  98. package/templates/example-chain/outputs/v1/out/rig/run.json +2 -2
  99. package/templates/example-chain/project.json +127 -78
  100. package/templates/example-direction/derived/review/v1/out/check/build/run.json +2 -2
  101. package/templates/example-draft/derived/drafts/lantern/out/check/build/run.json +2 -2
  102. package/templates/example-draft/derived/drafts/lantern/out/run.json +2 -2
  103. package/templates/example-draft/derived/review/v1/out/check/build/run.json +2 -2
  104. package/templates/example-draft/derived/review/v1/out/rig/run.json +2 -2
  105. package/templates/example-draft/derived/review/v2/out/check/build/run.json +2 -2
  106. package/templates/example-draft/derived/review/v2/out/rig/run.json +2 -2
  107. package/templates/example-draft/evidence/direction/v4.json +3 -3
  108. package/templates/example-draft/evidence/direction-input-v4.json +3 -3
  109. package/templates/example-draft/evidence/parts-review.json +1 -1
  110. package/templates/example-draft/evidence/review-reads/{52673b635b9cbc92da1a9e3d4c023a249bbacf79cd21b59724f03f18f8caf09b.json → 8866887a00012b5d5944b7525a478aea70931935bff35ddfcb186ffc515ef5af.json} +2 -2
  111. package/templates/example-draft/evidence/scene/s1.json +3 -3
  112. package/templates/example-draft/evidence/scene-citations.json +1 -1
  113. package/templates/example-draft/out/check/build/run.json +2 -2
  114. package/templates/example-draft/out/run.json +2 -2
  115. package/templates/example-draft/outputs/v1/out/check/build/run.json +2 -2
  116. package/templates/example-draft/outputs/v1/out/rig/run.json +2 -2
  117. package/templates/example-draft/outputs/v2/out/check/build/run.json +2 -2
  118. package/templates/example-draft/outputs/v2/out/rig/run.json +2 -2
  119. package/templates/example-interview-choice.json +113 -68
  120. package/templates/example-interview-options/constraint.json +2 -2
  121. package/templates/example-interview-options/example-assemble.json +2 -2
  122. package/templates/example-interview-options/example-bones.json +5 -5
  123. package/templates/example-interview-options/example-camera-depth.json +3 -3
  124. package/templates/example-interview-options/example-constraints.json +5 -5
  125. package/templates/example-interview-options/example-meshes.json +5 -5
  126. package/templates/example-interview-options/example-motion.json +5 -5
  127. package/templates/example-interview-options/example-picks.json +2 -2
  128. package/templates/example-interview-options/example-post-composition.json +3 -3
  129. package/templates/example-interview-options/example-post-timing.json +3 -3
  130. package/templates/example-interview-options/example-reference.json +2 -2
  131. package/templates/example-interview-options/example-rig-type.json +2 -2
  132. package/templates/example-interview-options/example-seethrough.json +2 -2
  133. package/templates/example-interview-options/example-turn.json +108 -0
  134. package/templates/example-interview-options/fullscreen.json +2 -2
  135. package/templates/example-interview-options/other-explicit.json +2 -2
  136. package/templates/example-interview-options/other-pending.json +2 -2
  137. package/templates/example-interview-options/other-picks.json +2 -2
  138. package/templates/example-invariants/invariants.json +223 -164
  139. package/templates/example-project.json +301 -249
  140. package/templates/example-remesh/AGENT-REFERENCE.md +62 -0
  141. package/templates/example-remesh/AGENTS.md +4 -26
  142. package/templates/example-remesh/derived/drafts/remesh/out/check/build/run.json +2 -2
  143. package/templates/example-remesh/derived/drafts/remesh/out/run.json +2 -2
  144. package/templates/example-remesh/derived/review/v1/out/check/build/run.json +2 -2
  145. package/templates/example-remesh/derived/review/v1/out/rig/run.json +2 -2
  146. package/templates/example-remesh/derived/review/v2/out/check/build/run.json +2 -2
  147. package/templates/example-remesh/derived/review/v2/out/rig/run.json +2 -2
  148. package/templates/example-remesh/derived/review/v3/out/check/build/run.json +2 -2
  149. package/templates/example-remesh/derived/review/v3/out/rig/run.json +2 -2
  150. package/templates/example-remesh/evidence/captions.json +1 -1
  151. package/templates/example-remesh/evidence/direction/v4.json +1 -1
  152. package/templates/example-remesh/evidence/direction/v5.json +1 -1
  153. package/templates/example-remesh/evidence/direction-input-v4.json +1 -1
  154. package/templates/example-remesh/evidence/direction-input-v5.json +1 -1
  155. package/templates/example-remesh/evidence/interview/assemble.json +1 -1
  156. package/templates/example-remesh/evidence/interview/bones.json +4 -4
  157. package/templates/example-remesh/evidence/interview/camera_depth.json +2 -2
  158. package/templates/example-remesh/evidence/interview/constraints.json +4 -4
  159. package/templates/example-remesh/evidence/interview/meshes.json +4 -4
  160. package/templates/example-remesh/evidence/interview/motion.json +4 -4
  161. package/templates/example-remesh/evidence/interview/post_composition.json +2 -2
  162. package/templates/example-remesh/evidence/interview/post_timing.json +2 -2
  163. package/templates/example-remesh/evidence/interview/reference.json +1 -1
  164. package/templates/example-remesh/evidence/interview/rig_type.json +1 -1
  165. package/templates/example-remesh/evidence/interview/seethrough.json +1 -1
  166. package/templates/example-remesh/evidence/interview/turn.json +108 -0
  167. package/templates/example-remesh/evidence/parts-review.json +1 -1
  168. package/templates/example-remesh/evidence/review-reads/{afe2522be4087f7f2fa7e97746d5f8574b0aedb474245ce2dbce0455e662f041.json → 99ade3eb19d70bd5f727138b3b4876ce86f1e1c22a59b516639bf85593996c32.json} +2 -2
  169. package/templates/example-remesh/evidence/scene/s1.json +2 -2
  170. package/templates/example-remesh/evidence/v1/registration/at-source.json +2 -2
  171. package/templates/example-remesh/evidence/v1/registration/rigc-run-1.json +2 -2
  172. package/templates/example-remesh/evidence/v2/registration/at-source.json +2 -2
  173. package/templates/example-remesh/evidence/v2/registration/rigc-run-1.json +2 -2
  174. package/templates/example-remesh/evidence/v3/registration/at-source.json +2 -2
  175. package/templates/example-remesh/evidence/v3/registration/rigc-run-1.json +2 -2
  176. package/templates/example-remesh/outputs/v1/out/check/build/run.json +2 -2
  177. package/templates/example-remesh/outputs/v1/out/rig/run.json +2 -2
  178. package/templates/example-remesh/outputs/v2/out/check/build/run.json +2 -2
  179. package/templates/example-remesh/outputs/v2/out/rig/run.json +2 -2
  180. package/templates/example-remesh/outputs/v3/out/check/build/run.json +2 -2
  181. package/templates/example-remesh/outputs/v3/out/rig/run.json +2 -2
  182. package/templates/example-remesh/project.json +279 -230
  183. package/templates/example-reopened/project.json +341 -289
  184. package/templates/example-split/captions.json +1 -1
  185. package/templates/project.json +10 -2
@@ -0,0 +1,154 @@
1
+ /** Retire only a replaced project's demonstrably stopped managed host. Never edit Review's store. */
2
+ import { Database, constants as sqlite } from 'bun:sqlite';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { lstatSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, rmdirSync, mkdirSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { connect } from 'node:net';
7
+ import { jobHash, jobHostStateRoot, pendingPublications, privateJson } from './job-outbox.ts';
8
+ import { Refusal, refuse } from './refusal.ts';
9
+
10
+ interface StoredRoot { path: string; dev: number; ino: number }
11
+ interface ActiveJob { jobId: string; rootKey: string; root: StoredRoot; fingerprint: string }
12
+ interface Host { jobId: string; fingerprint: string; pid: number; origin: string }
13
+ const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
14
+
15
+ export function replacedRootRefusal(root: string, recorded?: { pid: number; origin: string }): never {
16
+ refuse('JOB_ROOT_REPLACED', `project.json: the retained Review host belongs to a replaced project directory at ${root}; stop the original job open host in its terminal${recorded ? ` (recorded PID ${recorded.pid}, ${recorded.origin})` : ''}, then retry rig-create job open or job reconcile. Recovery requires its recorded PID to be absent, its recorded port to refuse connections, no startup claim and no pending publication; restore missing or unsafe host metadata before retrying`);
17
+ }
18
+
19
+ function absent(path: string): boolean {
20
+ try { lstatSync(path); return false; }
21
+ catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true; throw error; }
22
+ }
23
+
24
+ function owned(path: string, directory: boolean): void {
25
+ const info = lstatSync(path);
26
+ if (info.isSymbolicLink() || !(directory ? info.isDirectory() : info.isFile())
27
+ || (info.mode & 0o077) || !directory && info.nlink !== 1
28
+ || process.getuid && info.uid !== process.getuid() || realpathSync(path) !== path) throw Error();
29
+ }
30
+
31
+ function dead(pid: number): boolean {
32
+ try { process.kill(pid, 0); return false; }
33
+ catch (error) { return (error as NodeJS.ErrnoException).code === 'ESRCH'; }
34
+ }
35
+
36
+ /** A timeout, reset or any other network error is uncertainty, never proof of a stopped listener. */
37
+ export function hostPortRefuses(port: number): Promise<boolean> {
38
+ return new Promise(resolve => {
39
+ const socket = connect({ host: '127.0.0.1', port });
40
+ const finish = (refused: boolean) => { socket.destroy(); resolve(refused); };
41
+ socket.setTimeout(500, () => finish(false));
42
+ socket.once('connect', () => finish(false));
43
+ socket.once('error', error => finish((error as NodeJS.ErrnoException).code === 'ECONNREFUSED'));
44
+ });
45
+ }
46
+
47
+ /** Caller holds acquireJobLock(root) through inspection, rename and host startup. */
48
+ export async function recoverReplacedJobHostState(
49
+ root: string,
50
+ now?: number,
51
+ probe: (port: number) => Promise<boolean> = hostPortRefuses,
52
+ ): Promise<string | undefined> {
53
+ const state = jobHostStateRoot(root), host = join(state, 'host'), file = join(host, 'jobs.sqlite');
54
+ if (absent(file)) return;
55
+ let claim: string | undefined, archived: string | undefined;
56
+ let claimIdentity: { dev: number; ino: number } | undefined;
57
+ let recorded: Host | undefined;
58
+ const nonce = randomUUID();
59
+ try {
60
+ // The only private store fields consumed are the active job's identity and root identity.
61
+ owned(state, true); owned(host, true); owned(file, false);
62
+ if (['-journal', '-wal', '-shm'].some(suffix => !absent(file + suffix))) throw Error();
63
+ const current = lstatSync(root);
64
+ const rootIdentity = { path: realpathSync(root), dev: current.dev, ino: current.ino };
65
+ const db = new Database(file, sqlite.SQLITE_OPEN_READONLY | sqlite.SQLITE_OPEN_NOFOLLOW);
66
+ let jobs: ActiveJob[];
67
+ try {
68
+ jobs = db.query<{ job_id: string; root_key: string; document: string }, []>(
69
+ 'SELECT job_id, root_key, document FROM jobs WHERE active=1',
70
+ ).all().map(row => {
71
+ const data = JSON.parse(row.document) as { root: StoredRoot; fingerprint: string };
72
+ return { jobId: row.job_id, rootKey: row.root_key, root: data.root, fingerprint: data.fingerprint };
73
+ });
74
+ } finally { db.close(); }
75
+ if (!jobs.length) return;
76
+ for (const job of jobs) {
77
+ if (!job.root || job.root.path !== rootIdentity.path || !Number.isSafeInteger(job.root.dev)
78
+ || job.root.dev < 0 || !Number.isSafeInteger(job.root.ino) || job.root.ino < 0) throw Error();
79
+ }
80
+ if (jobs.every(job => job.root.dev === current.dev && job.root.ino === current.ino)) return;
81
+ // Renaming the whole managed directory must not strand a different active incarnation.
82
+ if (jobs.length !== 1 || pendingPublications(root).length) throw Error();
83
+ const job = jobs[0]!;
84
+ if (!UUID.test(job.jobId) || job.fingerprint !== 'rig-create/live-review/1'
85
+ || job.rootKey !== jobHash(JSON.stringify([rootIdentity.path, job.fingerprint]))) throw Error();
86
+ const control = join(host, 'job-ui'); owned(control, true);
87
+ const claimNames = () => readdirSync(control).filter(name => name.endsWith('.claim') || name.endsWith('.claiming'));
88
+ if (claimNames().length) throw Error();
89
+ // Use Review's existing startup-claim protocol, so an overlapping listener cannot adopt the old job.
90
+ const candidate = join(control, `job-${job.jobId}.${nonce}.claiming`);
91
+ mkdirSync(candidate, { mode: 0o700 }); claim = candidate;
92
+ const installed = lstatSync(candidate); claimIdentity = { dev: installed.dev, ino: installed.ino };
93
+ writeFileSync(join(candidate, `owner.${nonce}.json`), JSON.stringify({ pid: process.pid, nonce }), { mode: 0o600, flag: 'wx' });
94
+ const target = join(control, `job-${job.jobId}.claim`);
95
+ if (!absent(target)) throw Error();
96
+ renameSync(candidate, target); claim = target;
97
+ const snapshot = (): { seal: string; hosts: Host[] } => {
98
+ owned(state, true); owned(host, true); owned(file, false); owned(control, true);
99
+ const currentRoot = lstatSync(root);
100
+ if (currentRoot.dev !== rootIdentity.dev || currentRoot.ino !== rootIdentity.ino || realpathSync(root) !== root) throw Error();
101
+ if (['-journal', '-wal', '-shm'].some(suffix => !absent(file + suffix))) throw Error();
102
+ const claims = claimNames();
103
+ if (claims.length !== 1 || join(control, claims[0]!) !== claim) throw Error();
104
+ const names = readdirSync(control).filter(name => name.endsWith('.json')).sort();
105
+ if (!names.includes(`${job.jobId}.json`)) throw Error();
106
+ const records = names.map(name => {
107
+ const path = join(control, name); owned(path, false);
108
+ const value = privateJson(path) as Host & { schema: string };
109
+ if (value.schema !== 'rig-review-job-host/1' || !UUID.test(value.jobId) || name !== `${value.jobId}.json`
110
+ || value.fingerprint !== job.fingerprint || !Number.isSafeInteger(value.pid) || value.pid <= 0
111
+ || !/^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/.test(value.origin)
112
+ || Number(new URL(value.origin).port || 80) > 65535) throw Error();
113
+ return { path, value };
114
+ });
115
+ owned(claim!, true); owned(join(claim!, `owner.${nonce}.json`), false);
116
+ const paths = [root, state, host, control, file, claim!, join(claim!, `owner.${nonce}.json`), ...records.map(record => record.path)];
117
+ const seal = jobHash(JSON.stringify(paths.map(path => {
118
+ const info = lstatSync(path);
119
+ return [path, info.dev, info.ino, info.isFile() ? jobHash(readFileSync(path)) : null];
120
+ })));
121
+ return { seal, hosts: records.map(record => record.value) };
122
+ };
123
+ const before = snapshot();
124
+ recorded = before.hosts.find(record => record.jobId === job.jobId);
125
+ for (const record of before.hosts) {
126
+ if (!dead(record.pid) || !await probe(Number(new URL(record.origin).port || 80))) throw Error();
127
+ }
128
+ // No await between final identity/claim/PID recheck and rename. Keep all original state bytes.
129
+ if (snapshot().seal !== before.seal || before.hosts.some(record => !dead(record.pid))) throw Error();
130
+ const destination = `${state}.replaced-${new Date(now ?? current.mtimeMs).toISOString().replace(/[:.]/g, '-')}-${nonce}`;
131
+ if (!absent(destination)) throw Error();
132
+ renameSync(state, destination); archived = destination;
133
+ return destination;
134
+ } catch (error) {
135
+ if (error instanceof Refusal && error.code !== 'JOB_ROOT_REPLACED') throw error;
136
+ replacedRootRefusal(root, recorded);
137
+ } finally {
138
+ if (claim) {
139
+ const actual = archived ? join(archived, 'host', 'job-ui', claim.slice(claim.lastIndexOf('/') + 1)) : claim;
140
+ // This marker was created here; never remove a pre-existing Review claim or state directory.
141
+ try {
142
+ const info = lstatSync(actual);
143
+ if (!claimIdentity || !info.isDirectory() || info.isSymbolicLink() || info.dev !== claimIdentity.dev || info.ino !== claimIdentity.ino) throw Error();
144
+ const owner = join(actual, `owner.${nonce}.json`);
145
+ if (!absent(owner)) {
146
+ if (readFileSync(owner, 'utf8') !== JSON.stringify({ pid: process.pid, nonce })) throw Error();
147
+ rmSync(owner);
148
+ }
149
+ // rmdir refuses a competing owner's nonempty replacement.
150
+ rmdirSync(actual);
151
+ } catch { replacedRootRefusal(root, recorded); }
152
+ }
153
+ }
154
+ }
package/src/job-host.ts CHANGED
@@ -2,17 +2,19 @@ import { randomUUID } from 'node:crypto';
2
2
  import { readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { isDeepStrictEqual } from 'node:util';
5
- import { startJobHost, type JobHostAttachFailure, type JobHostOptions, type JobHostReuseBinding, type JobOwnerHost, type JobReviewerBinding, type JobSelector, type JobSubject, type JobEventV3, type ActV3 } from '@rig-c/review/job-host';
5
+ import { startJobHost, type JobHostAttachFailure, type JobHostOptions, type JobHostReuseBinding, type JobOwnerHost, type JobReviewerBinding, type JobSelector, type JobEventV3, type ActV3 } from '@rig-c/review/job-host';
6
6
  import { asArray, checkProject, collectItems, loadCollection, isObject, type Finding } from './check.ts';
7
7
  import { baselineOf, newFindings } from './refusal-baseline.ts';
8
8
  import { Refusal, refuse } from './refusal.ts';
9
9
  import { liveManifestPath, draftPublicationContext, currentJobIdentity, partsEnumerator, prepareDraftManifest, prepareLiveManifest, publicationFile, publicationSelector, readPublished, readRecordPublished } from './job-publication.ts';
10
- import { acknowledgePublication, acquireJobLock, assertJobIdentity, enqueuePublication, ensureJobPrivateIgnores, failPublication, JOB_BINDING_FILE, jobHash, jobHostStateRoot, pendingPublications, privateDirectory, publicationVersionHash, readAcknowledgedPublication, readWriterBinding, reconcilePublication, writePrivateJson, isRecordResult, type PublicationResult, type JobIdentity, type PendingPublication, type WriterBinding } from './job-outbox.ts';
10
+ import { acknowledgePublication, acquireJobLock, assertJobIdentity, enqueuePublication, ensureJobPrivateIgnores, failPublication, JOB_BINDING_FILE, jobHash, jobHostStateRoot, pendingPublications, privateDirectory, publicationVersionHash, readAcknowledgedPublication, readWriterBinding, reconcilePublication, writePrivateJson, isRecordResult, type PublicationResult, type JobIdentity, type PendingPublication, type WriterBinding, type PublicationSubject } from './job-outbox.ts';
11
11
  import { draftPublicationResult } from './job-writer.ts';
12
12
  import { attachCreateJobForClose } from './job-close.ts';
13
13
  import { pendingJobActs, type PendingJobAct } from './job-acts.ts';
14
14
  import { checkReviewPackages } from './review-package.ts';
15
+ import { reviewVerdictSignable } from './signable.ts';
15
16
  import type { JsonObject } from './templates.ts';
17
+ import { recoverReplacedJobHostState, replacedRootRefusal } from './job-host-state.ts';
16
18
 
17
19
  function installBinding(root: string, binding: WriterBinding): void {
18
20
  ensureJobPrivateIgnores(root);
@@ -79,10 +81,23 @@ export async function openCreateJob(
79
81
  if (existing) assertJobIdentity(existing.identity, identity);
80
82
  privateDirectory(identity.stateDir);
81
83
  let writerGrant: WriterGrant | undefined;
82
- const host = await startHost({
84
+ const start = () => startHost({
83
85
  ...options(identity, true, undefined, grant => { writerGrant = grant; }),
84
86
  ...(existing ? { port: Number(new URL(existing.origin).port) } : {}),
85
87
  });
88
+ let host: JobOwnerHost;
89
+ try {
90
+ host = await start();
91
+ } catch (error) {
92
+ if (typeof error !== 'object' || error === null || (error as { code?: unknown }).code !== 'JOB_ROOT_REPLACED') throw error;
93
+ if (identity.stateDir !== join(jobHostStateRoot(root), 'host') || !await recoverReplacedJobHostState(root, now)) replacedRootRefusal(root);
94
+ privateDirectory(identity.stateDir);
95
+ try { host = await start(); }
96
+ catch (retryError) {
97
+ if (typeof retryError === 'object' && retryError !== null && (retryError as { code?: unknown }).code === 'JOB_ROOT_REPLACED') replacedRootRefusal(root);
98
+ throw retryError;
99
+ }
100
+ }
86
101
  const file = join(root, 'project.json');
87
102
  const temporary = join(root, '.rig-create-job-record.tmp');
88
103
  try {
@@ -174,7 +189,7 @@ export async function publishPending(
174
189
  const currentHash = jobHash(bytes);
175
190
  if (currentHash !== entry.recordHash) { entry.recordHash = currentHash; delete entry.generation; }
176
191
  const project = JSON.parse(bytes.toString('utf8'));
177
- let subjects: readonly JobSubject[];
192
+ let subjects: readonly PublicationSubject[];
178
193
  if (isRecordResult(entry.result)) {
179
194
  subjects = readRecordPublished({ root: entry.root, record: { path: 'project.json', sha256: entry.recordHash }, result: entry.result, phase: 'inventory' }).subjects;
180
195
  } else {
@@ -216,7 +231,7 @@ export async function publishPending(
216
231
  }
217
232
 
218
233
  /** One declared subject after the ask step: asked now, asked before on this publication, or refused by Review. */
219
- export type AskOutcome = { readonly subject: JobSubject; readonly status: 'asked' | 'already' } | { readonly subject: JobSubject; readonly status: 'refused'; readonly code: string };
234
+ export type AskOutcome = { readonly subject: PublicationSubject; readonly status: 'asked' | 'already' } | { readonly subject: PublicationSubject; readonly status: 'refused'; readonly code: string };
220
235
 
221
236
  type AgentReply = { ok?: boolean; error?: { code?: string }; value?: { events?: unknown; status?: unknown; cursor?: unknown } };
222
237
 
@@ -251,10 +266,10 @@ async function* jobEvents(host: JobOwnerHost, strict = false): AsyncGenerator<Jo
251
266
  }
252
267
 
253
268
  /** The subjects already asked on this publication, from the same event reader as job acts. */
254
- async function askedSubjects(host: JobOwnerHost, publicationId: string): Promise<JobSubject[]> {
255
- const asked: JobSubject[] = [];
269
+ async function askedSubjects(host: JobOwnerHost, publicationId: string): Promise<PublicationSubject[]> {
270
+ const asked: PublicationSubject[] = [];
256
271
  for await (const event of jobEvents(host)) {
257
- if (event.type === 'pending' && event.ask.publicationId === publicationId) asked.push(event.ask.subject as JobSubject);
272
+ if (event.type === 'pending' && event.ask.publicationId === publicationId) asked.push(event.ask.subject as PublicationSubject);
258
273
  }
259
274
  return asked;
260
275
  }
@@ -282,7 +297,7 @@ export async function listCreateJobActs(
282
297
  * publication, pending or answered, is not asked again, so a re-open never shows a second
283
298
  * control. Asks stay limited to the declared list (`REVIEW_ACT_SUBJECT_UNDECLARED`).
284
299
  */
285
- export async function askDeclaredSubjects(host: JobOwnerHost, publicationId: string, subjects: readonly JobSubject[]): Promise<AskOutcome[]> {
300
+ export async function askDeclaredSubjects(host: JobOwnerHost, publicationId: string, subjects: readonly PublicationSubject[]): Promise<AskOutcome[]> {
286
301
  const before = await askedSubjects(host, publicationId);
287
302
  const outcomes: AskOutcome[] = [];
288
303
  for (const subject of subjects) {
@@ -298,7 +313,7 @@ export async function askDeclaredSubjects(host: JobOwnerHost, publicationId: str
298
313
  return outcomes;
299
314
  }
300
315
 
301
- export function subjectLabel(subject: JobSubject): string {
316
+ export function subjectLabel(subject: PublicationSubject): string {
302
317
  switch (subject.kind) {
303
318
  case 'parts-verdict': return `parts-verdict ${subject.draftId}`;
304
319
  case 'checkpoint': return `checkpoint ${subject.checkpointId}`;
@@ -325,11 +340,16 @@ export function refuseAskOutcomes(outcomes: readonly AskOutcome[]): void {
325
340
  refuse('JOB_ASK_REFUSED', `project.json: the publication is acknowledged but review refused the ask for ${named}; restore the published record bytes and run rig-create job open to ask again`);
326
341
  }
327
342
 
328
- export async function reconcileCreateJob(root: string, publish: (entry: PendingPublication) => Promise<unknown> = publishPending): Promise<number> {
343
+ export async function reconcileCreateJob(root: string, publish: (entry: PendingPublication) => Promise<unknown> = publishPending, now?: number): Promise<number> {
344
+ root = realpathSync(root);
329
345
  const release = acquireJobLock(root)!;
330
346
  let outcomes: AskOutcome[];
331
347
  let count: number;
332
- try { ({ count, outcomes } = await reconcileLocked(root, publish)); }
348
+ try {
349
+ const binding = readWriterBinding(root);
350
+ if (!binding || binding.identity.stateDir === join(jobHostStateRoot(root), 'host')) await recoverReplacedJobHostState(root, now);
351
+ ({ count, outcomes } = await reconcileLocked(root, publish));
352
+ }
333
353
  finally { release(); }
334
354
  refuseAskOutcomes(outcomes);
335
355
  return count;
@@ -482,13 +502,16 @@ export async function openDraftPublication(root: string, host: JobOwnerHost, now
482
502
  ? headSelector
483
503
  : undefined);
484
504
  const warnings: Finding[] = [];
505
+ const project = JSON.parse(bytes.toString('utf8')) as JsonObject;
485
506
  const draftContext = targetSelector?.kind === 'draft'
486
- ? draftPublicationContext(root, JSON.parse(bytes.toString('utf8')), targetSelector.id, warnings)
507
+ ? draftPublicationContext(root, project, targetSelector.id, warnings)
487
508
  : undefined;
488
509
  // Re-publish an older post head that still declares a sign-off or checkpoint:
489
510
  // either pending ask would prevent the legacy direction panel from rendering.
490
511
  const currentSubjects = draftContext?.subjects ?? (targetSelector?.kind === 'post'
491
- ? [{ kind: 'review-verdict' as const, stage: 'direction-review' as const }]
512
+ ? (reviewVerdictSignable(project, targetSelector.version, 'direction-review')
513
+ ? [{ kind: 'review-verdict' as const, stage: 'direction-review' as const }]
514
+ : [])
492
515
  : undefined);
493
516
  const reuseHead = Boolean(head && headSelector && source?.projectHash === jobHash(bytes) && (
494
517
  targetSelector === undefined || isDeepStrictEqual(headSelector, targetSelector)
package/src/job-outbox.ts CHANGED
@@ -4,7 +4,9 @@ import { existsSync, JOB_PRIVATE_DIRECTORY, lstatSync, readFileSync, readdirSync
4
4
  import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { tmpdir } from 'node:os';
6
6
  import { isDeepStrictEqual } from 'node:util';
7
- import type { JobPublishRequest, JobReviewerBinding, JobSubject, RecordResult } from '@rig-c/review/job-host';
7
+ import type { JobPublishRequest, JobReviewerBinding, JobSubject, RecordSubject, RecordResult } from '@rig-c/review/job-host';
8
+
9
+ export type PublicationSubject = JobSubject | RecordSubject;
8
10
  import { refuse } from './refusal.ts';
9
11
 
10
12
  export const JOB_BINDING_FILE = '.rig-create-review-binding.json';
@@ -342,7 +344,7 @@ export interface AcknowledgedPublication {
342
344
  result: PendingPublication['result'];
343
345
  versionHash: string;
344
346
  /** The subjects the published context declared. Receipts written before they were retained omit it. */
345
- subjects?: readonly JobSubject[];
347
+ subjects?: readonly PublicationSubject[];
346
348
  }
347
349
 
348
350
  function acknowledgedPath(root: string, jobId: string, publicationId: string): string {
@@ -350,7 +352,7 @@ function acknowledgedPath(root: string, jobId: string, publicationId: string): s
350
352
  }
351
353
 
352
354
  /** Persist the version identity before the pending publication is removed. */
353
- export function acknowledgePublication(entry: PendingPublication, publicationId: string, generation: number, requestId: string, subjects?: readonly JobSubject[]): void {
355
+ export function acknowledgePublication(entry: PendingPublication, publicationId: string, generation: number, requestId: string, subjects?: readonly PublicationSubject[]): void {
354
356
  if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(publicationId) || !Number.isSafeInteger(generation) || generation < 0 || !entry.versionHash) {
355
357
  refuse('JOB_OUTBOX_INVALID', 'project.json: publication acknowledgment lacks its identity; reconcile the original pending publication');
356
358
  }
@@ -1,13 +1,13 @@
1
1
  import { checkInputs } from './inputs.ts';
2
2
  import { pngDimensions } from './scene.ts';
3
- import { checkpointRequired } from './plan-history.ts';
3
+ import { hasPartsReceipt } from './fix-ledger.ts';
4
4
  import { renameSync, rmSync, writeFileSync } from 'node:fs';
5
5
  import { existsSync, lstatSync, readFileSync, realpathSync } from './check-reads.ts';
6
6
  import { dirname, join, relative, resolve } from 'node:path';
7
7
  import { isDeepStrictEqual } from 'node:util';
8
8
  import { partsFileReferences } from '@rig-c/parts/src/parts.ts';
9
9
  import partsPackage from '@rig-c/parts/package.json';
10
- import { JOB_ACT_SCHEMA, JOB_API_SCHEMA, type JobPublicationReader, type JobPublishedContext, type JobSelector, type JobRecordSignoffStage } from '@rig-c/review/job-host';
10
+ import { JOB_ACT_SCHEMA, JOB_API_SCHEMA, type JobPublicationReader, type JobPublishedContext, type JobSelector, type JobRecordSignoffStage, type RecordPlanSubject } from '@rig-c/review/job-host';
11
11
  import { stepHash } from './step-hash.ts';
12
12
  import { checkpointEvidenceContext, type CheckpointEvidence } from './checkpoint-evidence.ts';
13
13
  import { locateParts, PARTS_PACKAGE } from './parts-sections.ts';
@@ -24,10 +24,10 @@ import {
24
24
  import { optionContextFiles, optionFile, validateContext } from './choices.ts';
25
25
  import { hasStageOrder, interviewStageOfGuide, stageOfStep, stepCoveredByApproval } from './stages.ts';
26
26
  import { selectAnswerHashes } from './signoff-answers.ts';
27
- import { assertInterviewRecordSignable, packageSignSubjects, stageSignable } from './signable.ts';
28
- import { collectReopens, liveSignoff } from './reopen.ts';
27
+ import { assertInterviewRecordSignable, checkpointSignable, packageSignSubjects, reviewVerdictSignable, stageSignable } from './signable.ts';
28
+ import { collectReopens, directionReviewClosed, liveSignoff } from './reopen.ts';
29
29
  import { acceptedReviewVerdict } from './review.ts';
30
- import { requireRecordDirectionTarget } from './record-direction-target.ts';
30
+ import { recordDirectionTarget, requireRecordDirectionTarget } from './record-direction-target.ts';
31
31
  import { latestPartsApproval, riggingContext } from './structure.ts';
32
32
  import { jobHash, pendingPublications, isRecordResult, type JobIdentity } from './job-outbox.ts';
33
33
  import { Refusal, refuse } from './refusal.ts';
@@ -57,8 +57,6 @@ type RecordContextBase = {
57
57
  readonly files: JobPublishedContext['files'];
58
58
  };
59
59
  type RecordSignoffStage = JobRecordSignoffStage;
60
- type RecordPlanSubject = { readonly kind: 'checkpoint'; readonly checkpointId: string }
61
- | { readonly kind: 'signoff'; readonly stage: RecordSignoffStage };
62
60
  export type RecordOptionContext = RecordContextBase & {
63
61
  readonly plan: null;
64
62
  readonly subjects: readonly [{ readonly kind: 'interview-picks'; readonly questionId: string; readonly setId: string }];
@@ -285,7 +283,9 @@ export function readRecordPublished({ root, record, result, phase, parts: sealed
285
283
  label = `option-set ${result.questionId} / ${result.setId}`;
286
284
  } else if (result.kind === 'plan') {
287
285
  if (!plan) throw Error('the requested plan is absent');
288
- if (stage === 'direction-review') {
286
+ const reopenDirection = stage === 'direction-review' && directionReviewClosed(project)
287
+ && recordDirectionTarget(project, now) === null;
288
+ if (stage === 'direction-review' && !reopenDirection) {
289
289
  const reopens = collectReopens(project, []);
290
290
  const post = asArray(project.versions).filter(isObject).filter(version => version.kind === 'post'
291
291
  && liveSignoff(reopens, 'post', Date.parse(String(version.at)))).at(-1);
@@ -297,7 +297,7 @@ export function readRecordPublished({ root, record, result, phase, parts: sealed
297
297
  requireRecordDirectionTarget(project, now);
298
298
  }
299
299
  const covered = steps.filter(step => stepCoveredByApproval(project, step, stage));
300
- const signable = stage !== null && ['interview', 'plan-approved', 'rigging-interview', 'post-interview', 'direction-review'].includes(stage)
300
+ const signable = !reopenDirection && stage !== null && ['interview', 'plan-approved', 'rigging-interview', 'post-interview', 'direction-review'].includes(stage)
301
301
  && (stage !== 'interview' || interviewSignoff) && stageSignable(project, stage);
302
302
  if (signable && stage === 'direction-review' && steps.length === 0) throw Error('direction-review record sign-off needs displayed plan steps');
303
303
  const emptyPost = stage === 'post-interview' && !steps.some(step => step.phase === 'post') && postNotPassed({ items });
@@ -311,9 +311,10 @@ export function readRecordPublished({ root, record, result, phase, parts: sealed
311
311
  }
312
312
  subjects = steps.filter(step => {
313
313
  if (step.status !== 'at-checkpoint' || (step.phase !== 'rig' && step.phase !== 'post') || stageOfStep(step.phase, String(step.id)) !== stage) return false;
314
- return checkpointRequired(project, step, now);
314
+ return checkpointSignable(project, stage, String(step.id), now);
315
315
  }).map(step => ({ kind: 'checkpoint' as const, checkpointId: String(step.id) }));
316
316
  if (signable) subjects = [...subjects, { kind: 'signoff', stage: stage as RecordSignoffStage }];
317
+ if (reopenDirection) subjects = [{ kind: 'reopen', stage: 'direction-review' }];
317
318
  label = `plan / ${stage}`;
318
319
  } else throw Error('unknown record result');
319
320
  // Detect record changes during closure reads as well as changes before entry.
@@ -513,9 +514,12 @@ export const readPublished: JobPublicationReader = async ({ root, record, result
513
514
  }
514
515
  const plan = project.plan as JsonObject | undefined;
515
516
  const steps = Array.isArray(plan?.steps) ? plan.steps as JsonObject[] : [];
516
- const subjects: JobPublishedContext['subjects'] = [{ kind: 'review-verdict', stage: selector.kind === 'post' ? 'direction-review' : 'rig-review' },
517
+ const reviewStage = selector.kind === 'post' ? 'direction-review' as const : 'rig-review' as const;
518
+ const subjects: JobPublishedContext['subjects'] = [
519
+ ...(reviewVerdictSignable(project, selector.version, reviewStage) ? [{ kind: 'review-verdict' as const, stage: reviewStage }] : []),
517
520
  // The legacy direction panel accepts only the verdict on a post publication.
518
- ...(selector.kind === 'post' ? [] : packageSignSubjects(project, effectiveStage(root, project), selector, steps.length))];
521
+ ...(selector.kind === 'post' ? [] : packageSignSubjects(project, effectiveStage(root, project), selector, steps.length)),
522
+ ];
519
523
  return {
520
524
  selector, plan: { steps: steps.map(step => ({ id: String(step.id), hash: stepHash(step) })) },
521
525
  parts: { enumeratorVersion: partsEnumerator.version, manifest: { path: manifestPath, sha256: jobHash(manifest) }, images },
@@ -576,7 +580,7 @@ export function draftPublishedContext(root: string, project: JsonObject, id: str
576
580
  const plan = project.plan as JsonObject | undefined;
577
581
  return { selector: { kind: 'draft', id }, plan: plan ? { steps: (plan.steps as JsonObject[] ?? []).map(step => ({ id: String(step.id), hash: stepHash(step) })) } : null,
578
582
  parts: { enumeratorVersion: partsEnumerator.version, manifest: { path: manifestPath, sha256: jobHash(manifest) }, images },
579
- subjects: [{ kind: 'parts-verdict', draftId: id }],
583
+ subjects: hasPartsReceipt(project, id) ? [] : [{ kind: 'parts-verdict', draftId: id }],
580
584
  partIds: (JSON.parse(manifest.toString('utf8')).parts as { name: string }[]).map(part => part.name), files };
581
585
  }
582
586
 
@@ -5,6 +5,16 @@ import { askOutcomeLines, assertRequestedDraft, assertRequestedRig, closeCreateJ
5
5
  import { failPublication, pendingPublications, reconcilePublication } from './job-outbox.ts';
6
6
  import { jobActsText } from './job-acts.ts';
7
7
  import { Refusal, refuse } from './refusal.ts';
8
+ import { replacedRootRefusal } from './job-host-state.ts';
9
+
10
+ /** Preserve this known producer refusal without printing an uncontrolled producer message. */
11
+ export function publicationFailure(error: unknown, root: string): Refusal | undefined {
12
+ if (error instanceof Refusal) return error;
13
+ if (typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'JOB_ROOT_REPLACED') {
14
+ try { replacedRootRefusal(root); } catch (failure) { return failure as Refusal; }
15
+ }
16
+ return undefined;
17
+ }
8
18
 
9
19
  async function run(): Promise<void> {
10
20
  const input = JSON.parse(readFileSync(0, 'utf8')) as { root: string; id?: string; reviewer?: string; role?: 'owner' | 'delegate'; draft?: string; rig?: string; question?: string; plan?: boolean; now: number; json?: boolean };
@@ -16,7 +26,8 @@ async function run(): Promise<void> {
16
26
  let outcomes: AskOutcome[] = [];
17
27
  try { await reconcilePublication(root, entry, async item => { outcomes = await publishPending(item); }); }
18
28
  catch (error) {
19
- const failure = error instanceof Refusal ? { code: error.code, message: error.message }
29
+ const named = publicationFailure(error, root);
30
+ const failure = named ? { code: named.code, message: named.message }
20
31
  : { code: 'JOB_PUBLISH_PENDING', message: 'project.json: host publication failed; restore the bound host before rig-create job reconcile' };
21
32
  failPublication(root, entry, failure);
22
33
  throw new Refusal('JOB_PUBLISH_PENDING', 'project.json: committed publication is pending; run rig-create job reconcile');
@@ -31,7 +42,7 @@ async function run(): Promise<void> {
31
42
  console.log(input.json ? JSON.stringify(result) : jobActsText(result));
32
43
  break;
33
44
  }
34
- case 'reconcile': console.log(`job: reconciled ${await reconcileCreateJob(root)} publication(s)`); break;
45
+ case 'reconcile': console.log(`job: reconciled ${await reconcileCreateJob(root, undefined, input.now)} publication(s)`); break;
35
46
  case 'close': await closeCreateJob(root); console.log('job: closed'); break;
36
47
  case 'open': {
37
48
  if (!input.reviewer || !input.role) refuse('JOB_BINDING_INVALID', 'project.json: job open needs --reviewer and --role owner|delegate');
@@ -64,7 +75,8 @@ async function run(): Promise<void> {
64
75
  host.stop();
65
76
  throw error;
66
77
  }
67
- console.error(error instanceof Refusal ? `${error.code}: ${error.message}` : 'JOB_PUBLISH_PENDING: project.json: draft publication failed; run rig-create job reconcile');
78
+ const named = publicationFailure(error, root);
79
+ console.error(named ? `${named.code}: ${named.message}` : 'JOB_PUBLISH_PENDING: project.json: draft publication failed; run rig-create job reconcile');
68
80
  process.exitCode = 1;
69
81
  }
70
82
  if (host.reused || stopping) {
@@ -93,7 +105,8 @@ if (import.meta.main) {
93
105
  try { await run(); }
94
106
  catch (error) {
95
107
  // Unexpected errors may embed producer input. Only controlled refusals leave this process.
96
- console.error(error instanceof Refusal ? `${error.code}: ${error.message}` : 'JOB_PUBLISH_PENDING: project.json: publication operation failed; run rig-create job reconcile');
108
+ const named = publicationFailure(error, 'the requested project directory');
109
+ console.error(named ? `${named.code}: ${named.message}` : 'JOB_PUBLISH_PENDING: project.json: publication operation failed; run rig-create job reconcile');
97
110
  process.exitCode = 1;
98
111
  }
99
112
  }
package/src/next.ts CHANGED
@@ -32,7 +32,7 @@ import { pausedFrom } from './pause.ts';
32
32
  import { carriedRefusals, findingKey, newFindings } from './refusal-baseline.ts';
33
33
  import { refuse, USAGE_EXIT } from './refusal.ts';
34
34
  import { reopenLapseWarnings } from './reopen-lapses.ts';
35
- import { collectReopens, liveSignoff } from './reopen.ts';
35
+ import { collectReopens, directionReviewClosed, liveSignoff } from './reopen.ts';
36
36
  import { acceptedReviewVerdict } from './review.ts';
37
37
  import {
38
38
  advanceOrder,
@@ -54,6 +54,7 @@ import {
54
54
  } from './stages.ts';
55
55
  import type { Json, JsonObject } from './templates.ts';
56
56
  import { sceneCurrentAt, sceneRig, SceneError } from './scene.ts';
57
+ import { depthPlanMoves } from './rig-version.ts';
57
58
  import { resolveAgentCitationSpec } from './scene-authority.ts';
58
59
 
59
60
  export const NEXT_SCHEMA = 'rig-create/next@1';
@@ -72,7 +73,7 @@ export interface NextRefusal {
72
73
  }
73
74
 
74
75
  export interface NextReviewAct {
75
- kind: 'parts-verdict' | 'interview-picks' | 'review-verdict' | 'signoff' | 'signoff-checkpoint';
76
+ kind: 'parts-verdict' | 'interview-picks' | 'review-verdict' | 'signoff' | 'signoff-checkpoint' | 'reopen';
76
77
  stage: string;
77
78
  draft_id?: string;
78
79
  question_id?: string;
@@ -209,7 +210,10 @@ function reviewCommands(dir: string, project: JsonObject, move: NextMove): NextM
209
210
  if (move.role !== 'human' || move.unanswered_by_record || move.field === 'pauses' || !act) return [move];
210
211
  let selector: string;
211
212
  let writer: string;
212
- if (act.kind === 'interview-picks' && act.question_id) {
213
+ if (act.kind === 'reopen') {
214
+ selector = '--plan';
215
+ writer = `reopen ${act.stage}`;
216
+ } else if (act.kind === 'interview-picks' && act.question_id) {
213
217
  selector = `--question ${shellWord(act.question_id)}`;
214
218
  writer = `interview-answer ${shellWord(act.question_id)}`;
215
219
  } else if (act.kind === 'signoff' && ['interview', 'plan-approved', 'rigging-interview', 'post-interview', 'direction-review'].includes(act.stage)) {
@@ -691,18 +695,12 @@ export function nextProject(projectDirArg: string, now: number = Date.now()): Ne
691
695
  if (kind === 'post') {
692
696
  // Match the direction writer's authoring gate, including earlier
693
697
  // accepted verdicts that a later bounce does not itself reopen.
694
- const reopens = collectReopens(project, []);
695
- const closed = collectSignoffs(project, []).some(s => s.stage === 'direction-review'
696
- && s.checkpoint === null && liveSignoff(reopens, s.stage, s.at))
697
- || versions.some(v => v.kind === 'post' && asArray(v.verdicts).filter(isObject).some(d =>
698
- d.review === 'direction-review' && d.verdict === 'accepted'
699
- && liveSignoff(reopens, 'direction-review', Date.parse(String(d.at)))));
700
- if (closed) {
698
+ if (directionReviewClosed(project)) {
701
699
  const target = 'reopens[stage=direction-review]';
702
700
  addAwait(target);
703
701
  moves.push({ role: 'human', field: 'reopens', awaits: target,
704
- command: `rig-create reopen direction-review ${shellWord(dir)} --by '<human>' --verbatim-file '<absolute-human-words-file>' --source '<reopen-act-id>'`,
705
- act: 'Until create#493, an agent may type this human reopen only from Review host raw export act@4: save its unchanged nonempty value.words byte-for-byte to an absolute verbatim-file path and name actId with --source. Empty/missing words: STOP; the agent never supplies words. Do not use job fetch-act (it rejects act@4). Create cannot ingest Review reopen acts yet.' });
702
+ command: null, review_act: { kind: 'reopen', stage: 'direction-review' },
703
+ act: 'reopen direction-review in Review, then ingest the exported reopen act before writing the replacement post' });
706
704
  continue;
707
705
  }
708
706
  }
@@ -716,7 +714,7 @@ export function nextProject(projectDirArg: string, now: number = Date.now()): Ne
716
714
  command: `rig-create ${writer} ${shellWord(dir)} ${inputs.replace(/<[^>]+>/g, shellWord)} ${refs.map(r => `--answers ${r.version}:${r.verdict}`).join(' ')}`,
717
715
  act: `build the next ${kind} version with ${writer}, recording answers_feedback ${JSON.stringify(refs)}` });
718
716
  }
719
- if (moves.length > 0) return { schema: NEXT_SCHEMA, project_id: String(project.name ?? ''), stage, effective_stage: effectiveStage, accepted: false, refusals, ...warningFields, awaits: awaitsList, moves };
717
+ if (moves.length > 0) return { schema: NEXT_SCHEMA, project_id: String(project.name ?? ''), stage, effective_stage: effectiveStage, accepted: false, refusals, ...warningFields, awaits: awaitsList, moves: moves.flatMap(move => reviewCommands(dir, project, move)) };
720
718
  }
721
719
  const recordedLapseTargets = new Set<string>();
722
720
  const steps = asArray(isObject(project.plan) ? project.plan.steps : undefined).filter(isObject);
@@ -873,6 +871,12 @@ export function nextProject(projectDirArg: string, now: number = Date.now()): Ne
873
871
  const splitOrder = hasStageOrder(project);
874
872
  const drafts = asArray(project.drafts).filter(isObject);
875
873
  const versions = asArray(project.versions).filter(isObject);
874
+ if (effectiveStage === 'rigging-interview' && depthPlanMoves(project, collection).length > 0
875
+ && !steps.some(s => s.id === 'turn' && s.phase === 'rig')) {
876
+ addAwait('plan.steps[turn]');
877
+ moves.push({ role: 'agent', field: 'plan.steps[turn]', awaits: 'plan.steps[turn]', command: null,
878
+ act: 'Draft plan add turn --phase rig --rests-on turn before the rig steps, record the chosen depth route and reason in its text, with a bar that reads the producer depth reports; approve it at rigging-interview.' });
879
+ }
876
880
 
877
881
  if (stage === PAUSED_STAGE) {
878
882
  const resumeTarget = pausedFrom(project);
@@ -1235,6 +1239,10 @@ export function nextProject(projectDirArg: string, now: number = Date.now()): Ne
1235
1239
  }
1236
1240
  }
1237
1241
  } else if (effectiveStage === 'rigging' || effectiveStage === 'rig-review') {
1242
+ if (effectiveStage === 'rigging') for (const move of depthPlanMoves(project, collection)) {
1243
+ addAwait(move.awaits);
1244
+ moves.push(move);
1245
+ }
1238
1246
  const rigSteps = steps.filter(
1239
1247
  s => (s.phase === 'rig' || s.phase === 'post') && stageOfStep(s.phase, String(s.id)) === 'rigging',
1240
1248
  );
@@ -3,12 +3,11 @@ import { assertPartsCliArgs, assertPartsReadDraft, decodePartsRead, partsReadFin
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
5
5
  import { dirname, join, resolve } from 'node:path';
6
- import { fileURLToPath } from 'node:url';
7
6
  import { readProject } from './feedback.ts';
8
7
  import { readPartsReview, ReviewReadError, storeReviewRead, type ReviewReadRunner } from './review-read.ts';
9
- import { acquireBoundJobWrite, jobRuntimeExecArgv } from './job-writer.ts';
8
+ import { acquireBoundJobWrite } from './job-writer.ts';
10
9
  import { acquireJobLock, jobHash, readAcknowledgedPublication, readWriterBinding } from './job-outbox.ts';
11
- import { decodeReviewAct, reviewActFindings, reviewActTransport, reviewActVerbatim } from './review-act.ts';
10
+ import { decodeReviewAct, fetchReviewActBytes, reviewActFindings, reviewActTransport, reviewActVerbatim } from './review-act.ts';
12
11
  import { assertPartsActSource, partsActDraftHash, partsActFindings, partsActReceipt } from './parts-act.ts';
13
12
  import { newRefusals } from './refusal-baseline.ts';
14
13
  import { refuse, USAGE_EXIT } from './refusal.ts';
@@ -58,14 +57,8 @@ export function partsReviewCommand(args: string[], now: number, hooks: PartsRevi
58
57
  let committed = false;
59
58
  const temporary = join(root, `.parts-review-${randomUUID()}.tmp`);
60
59
  try {
61
- const transport = { ...reviewActTransport(root, actId!), kind: 'parts-verdict' };
62
- const child = Bun.spawnSync([process.execPath, ...jobRuntimeExecArgv(), fileURLToPath(new URL('./review-act-runtime.ts', import.meta.url))], {
63
- stdin: Buffer.from(JSON.stringify(transport)), stdout: 'pipe', stderr: 'pipe',
64
- });
65
- let response: { ok: boolean; bytes: string; code: string; message: string };
66
- try { response = JSON.parse(child.stdout.toString()); }
67
- catch { return refuse('REVIEW_ACT_FETCH_REFUSED', 'project.json.review_acts: fetch adapter failed; restore the live Review job'); }
68
- if (!response.ok || child.exitCode) refuse(response.code, response.message);
60
+ const transport = { ...reviewActTransport(root, actId!), kind: 'parts-verdict' as const };
61
+ const response = { bytes: fetchReviewActBytes(transport) };
69
62
  const act = decodeReviewAct(response.bytes, 'parts-verdict');
70
63
  if (Date.parse(act.at) > now) refuse('REVIEW_ACT_INVALID', 'project.json.review_acts: act is dated in the future; restore the clock before recording');
71
64
  const { file } = readProject(root);