rig-create 1.0.4 → 1.1.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 (184) 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/package.json +2 -1
  6. package/src/agent-guide.ts +7 -4
  7. package/src/check.ts +7 -4
  8. package/src/choice-writers.ts +3 -14
  9. package/src/cli.ts +1 -0
  10. package/src/feedback.ts +3 -10
  11. package/src/fix-ledger.ts +1 -1
  12. package/src/job-acts.ts +4 -0
  13. package/src/job-host-state.ts +154 -0
  14. package/src/job-host.ts +37 -14
  15. package/src/job-outbox.ts +5 -3
  16. package/src/job-publication.ts +17 -13
  17. package/src/job-runtime.ts +17 -4
  18. package/src/next.ts +21 -13
  19. package/src/parts-review.ts +4 -11
  20. package/src/reopen.ts +72 -9
  21. package/src/review-act.ts +49 -14
  22. package/src/review-package.ts +3 -1
  23. package/src/rig-version.ts +219 -8
  24. package/src/scene-writers.ts +5 -3
  25. package/src/scene.ts +14 -4
  26. package/src/signable.ts +25 -3
  27. package/src/signoff-checkpoint-act.ts +2 -16
  28. package/src/signoff.ts +3 -15
  29. package/src/waypoints.ts +20 -9
  30. package/templates/agent-guide.md +2 -24
  31. package/templates/collection.json +10 -2
  32. package/templates/example-chain/AGENT-REFERENCE.md +62 -0
  33. package/templates/example-chain/AGENTS.md +4 -26
  34. package/templates/example-chain/README.md +1 -1
  35. package/templates/example-chain/chain.json +385 -319
  36. package/templates/example-chain/checkpoints/0a-interview-options/AGENT-REFERENCE.md +62 -0
  37. package/templates/example-chain/checkpoints/0a-interview-options/AGENTS.md +4 -26
  38. package/templates/example-chain/checkpoints/0a-interview-options/project.json +22 -5
  39. package/templates/example-chain/checkpoints/0a-interview-options.json +7 -3
  40. package/templates/example-chain/checkpoints/0b-plan-pending/AGENT-REFERENCE.md +62 -0
  41. package/templates/example-chain/checkpoints/0b-plan-pending/AGENTS.md +4 -26
  42. package/templates/example-chain/checkpoints/0b-plan-pending/project.json +22 -5
  43. package/templates/example-chain/checkpoints/0b-plan-pending.json +7 -3
  44. package/templates/example-chain/checkpoints/1-parts-verdict/AGENT-REFERENCE.md +62 -0
  45. package/templates/example-chain/checkpoints/1-parts-verdict/AGENTS.md +4 -26
  46. package/templates/example-chain/checkpoints/1-parts-verdict/derived/drafts/chain/out/check/build/run.json +2 -2
  47. package/templates/example-chain/checkpoints/1-parts-verdict/derived/drafts/chain/out/run.json +2 -2
  48. package/templates/example-chain/checkpoints/1-parts-verdict/project.json +24 -7
  49. package/templates/example-chain/checkpoints/1-parts-verdict.json +9 -5
  50. package/templates/example-chain/checkpoints/2-rigging-answers/AGENT-REFERENCE.md +62 -0
  51. package/templates/example-chain/checkpoints/2-rigging-answers/AGENTS.md +4 -26
  52. package/templates/example-chain/checkpoints/2-rigging-answers/derived/drafts/chain/out/check/build/run.json +2 -2
  53. package/templates/example-chain/checkpoints/2-rigging-answers/derived/drafts/chain/out/run.json +2 -2
  54. package/templates/example-chain/checkpoints/2-rigging-answers/project.json +24 -7
  55. package/templates/example-chain/checkpoints/2-rigging-answers.json +21 -5
  56. package/templates/example-chain/checkpoints/3-rig-review/AGENT-REFERENCE.md +62 -0
  57. package/templates/example-chain/checkpoints/3-rig-review/AGENTS.md +4 -26
  58. package/templates/example-chain/checkpoints/3-rig-review/derived/drafts/chain/out/check/build/run.json +2 -2
  59. package/templates/example-chain/checkpoints/3-rig-review/derived/drafts/chain/out/run.json +2 -2
  60. package/templates/example-chain/checkpoints/3-rig-review/derived/review/v1/out/check/build/run.json +2 -2
  61. package/templates/example-chain/checkpoints/3-rig-review/derived/review/v1/out/rig/run.json +2 -2
  62. package/templates/example-chain/checkpoints/3-rig-review/evidence/interview/motion.json +1 -1
  63. package/templates/example-chain/checkpoints/3-rig-review/evidence/interview/turn.json +108 -0
  64. package/templates/example-chain/checkpoints/3-rig-review/outputs/v1/out/check/build/run.json +2 -2
  65. package/templates/example-chain/checkpoints/3-rig-review/outputs/v1/out/rig/run.json +2 -2
  66. package/templates/example-chain/checkpoints/3-rig-review/project.json +77 -28
  67. package/templates/example-chain/checkpoints/3-rig-review.json +19 -11
  68. package/templates/example-chain/checkpoints/4-direction-post/AGENT-REFERENCE.md +62 -0
  69. package/templates/example-chain/checkpoints/4-direction-post/AGENTS.md +4 -26
  70. package/templates/example-chain/checkpoints/4-direction-post/derived/drafts/chain/out/check/build/run.json +2 -2
  71. package/templates/example-chain/checkpoints/4-direction-post/derived/drafts/chain/out/run.json +2 -2
  72. package/templates/example-chain/checkpoints/4-direction-post/derived/review/v1/out/check/build/run.json +2 -2
  73. package/templates/example-chain/checkpoints/4-direction-post/derived/review/v1/out/rig/run.json +2 -2
  74. package/templates/example-chain/checkpoints/4-direction-post/evidence/interview/camera_depth.json +2 -2
  75. package/templates/example-chain/checkpoints/4-direction-post/evidence/interview/motion.json +1 -1
  76. package/templates/example-chain/checkpoints/4-direction-post/evidence/interview/post_composition.json +2 -2
  77. package/templates/example-chain/checkpoints/4-direction-post/evidence/interview/post_timing.json +2 -2
  78. package/templates/example-chain/checkpoints/4-direction-post/evidence/interview/turn.json +108 -0
  79. package/templates/example-chain/checkpoints/4-direction-post/outputs/v1/out/check/build/run.json +2 -2
  80. package/templates/example-chain/checkpoints/4-direction-post/outputs/v1/out/rig/run.json +2 -2
  81. package/templates/example-chain/checkpoints/4-direction-post/project.json +106 -57
  82. package/templates/example-chain/checkpoints/4-direction-post.json +22 -14
  83. package/templates/example-chain/derived/drafts/chain/out/check/build/run.json +2 -2
  84. package/templates/example-chain/derived/drafts/chain/out/run.json +2 -2
  85. package/templates/example-chain/derived/review/v1/out/check/build/run.json +2 -2
  86. package/templates/example-chain/derived/review/v1/out/rig/run.json +2 -2
  87. package/templates/example-chain/evidence/captions.json +1 -1
  88. package/templates/example-chain/evidence/direction/v2.json +1 -1
  89. package/templates/example-chain/evidence/direction-input.json +1 -1
  90. package/templates/example-chain/evidence/interview/camera_depth.json +2 -2
  91. package/templates/example-chain/evidence/interview/motion.json +1 -1
  92. package/templates/example-chain/evidence/interview/post_composition.json +2 -2
  93. package/templates/example-chain/evidence/interview/post_timing.json +2 -2
  94. package/templates/example-chain/evidence/interview/turn.json +108 -0
  95. package/templates/example-chain/evidence/scene/s1.json +2 -2
  96. package/templates/example-chain/outputs/v1/out/check/build/run.json +2 -2
  97. package/templates/example-chain/outputs/v1/out/rig/run.json +2 -2
  98. package/templates/example-chain/project.json +127 -78
  99. package/templates/example-direction/derived/review/v1/out/check/build/run.json +2 -2
  100. package/templates/example-draft/derived/drafts/lantern/out/check/build/run.json +2 -2
  101. package/templates/example-draft/derived/drafts/lantern/out/run.json +2 -2
  102. package/templates/example-draft/derived/review/v1/out/check/build/run.json +2 -2
  103. package/templates/example-draft/derived/review/v1/out/rig/run.json +2 -2
  104. package/templates/example-draft/derived/review/v2/out/check/build/run.json +2 -2
  105. package/templates/example-draft/derived/review/v2/out/rig/run.json +2 -2
  106. package/templates/example-draft/evidence/direction/v4.json +3 -3
  107. package/templates/example-draft/evidence/direction-input-v4.json +3 -3
  108. package/templates/example-draft/evidence/parts-review.json +1 -1
  109. package/templates/example-draft/evidence/review-reads/{52673b635b9cbc92da1a9e3d4c023a249bbacf79cd21b59724f03f18f8caf09b.json → 8866887a00012b5d5944b7525a478aea70931935bff35ddfcb186ffc515ef5af.json} +2 -2
  110. package/templates/example-draft/evidence/scene/s1.json +3 -3
  111. package/templates/example-draft/evidence/scene-citations.json +1 -1
  112. package/templates/example-draft/out/check/build/run.json +2 -2
  113. package/templates/example-draft/out/run.json +2 -2
  114. package/templates/example-draft/outputs/v1/out/check/build/run.json +2 -2
  115. package/templates/example-draft/outputs/v1/out/rig/run.json +2 -2
  116. package/templates/example-draft/outputs/v2/out/check/build/run.json +2 -2
  117. package/templates/example-draft/outputs/v2/out/rig/run.json +2 -2
  118. package/templates/example-interview-choice.json +113 -68
  119. package/templates/example-interview-options/constraint.json +2 -2
  120. package/templates/example-interview-options/example-assemble.json +2 -2
  121. package/templates/example-interview-options/example-bones.json +5 -5
  122. package/templates/example-interview-options/example-camera-depth.json +3 -3
  123. package/templates/example-interview-options/example-constraints.json +5 -5
  124. package/templates/example-interview-options/example-meshes.json +5 -5
  125. package/templates/example-interview-options/example-motion.json +5 -5
  126. package/templates/example-interview-options/example-picks.json +2 -2
  127. package/templates/example-interview-options/example-post-composition.json +3 -3
  128. package/templates/example-interview-options/example-post-timing.json +3 -3
  129. package/templates/example-interview-options/example-reference.json +2 -2
  130. package/templates/example-interview-options/example-rig-type.json +2 -2
  131. package/templates/example-interview-options/example-seethrough.json +2 -2
  132. package/templates/example-interview-options/example-turn.json +108 -0
  133. package/templates/example-interview-options/fullscreen.json +2 -2
  134. package/templates/example-interview-options/other-explicit.json +2 -2
  135. package/templates/example-interview-options/other-pending.json +2 -2
  136. package/templates/example-interview-options/other-picks.json +2 -2
  137. package/templates/example-invariants/invariants.json +223 -164
  138. package/templates/example-project.json +301 -249
  139. package/templates/example-remesh/AGENT-REFERENCE.md +62 -0
  140. package/templates/example-remesh/AGENTS.md +4 -26
  141. package/templates/example-remesh/derived/drafts/remesh/out/check/build/run.json +2 -2
  142. package/templates/example-remesh/derived/drafts/remesh/out/run.json +2 -2
  143. package/templates/example-remesh/derived/review/v1/out/check/build/run.json +2 -2
  144. package/templates/example-remesh/derived/review/v1/out/rig/run.json +2 -2
  145. package/templates/example-remesh/derived/review/v2/out/check/build/run.json +2 -2
  146. package/templates/example-remesh/derived/review/v2/out/rig/run.json +2 -2
  147. package/templates/example-remesh/derived/review/v3/out/check/build/run.json +2 -2
  148. package/templates/example-remesh/derived/review/v3/out/rig/run.json +2 -2
  149. package/templates/example-remesh/evidence/captions.json +1 -1
  150. package/templates/example-remesh/evidence/direction/v4.json +1 -1
  151. package/templates/example-remesh/evidence/direction/v5.json +1 -1
  152. package/templates/example-remesh/evidence/direction-input-v4.json +1 -1
  153. package/templates/example-remesh/evidence/direction-input-v5.json +1 -1
  154. package/templates/example-remesh/evidence/interview/assemble.json +1 -1
  155. package/templates/example-remesh/evidence/interview/bones.json +4 -4
  156. package/templates/example-remesh/evidence/interview/camera_depth.json +2 -2
  157. package/templates/example-remesh/evidence/interview/constraints.json +4 -4
  158. package/templates/example-remesh/evidence/interview/meshes.json +4 -4
  159. package/templates/example-remesh/evidence/interview/motion.json +4 -4
  160. package/templates/example-remesh/evidence/interview/post_composition.json +2 -2
  161. package/templates/example-remesh/evidence/interview/post_timing.json +2 -2
  162. package/templates/example-remesh/evidence/interview/reference.json +1 -1
  163. package/templates/example-remesh/evidence/interview/rig_type.json +1 -1
  164. package/templates/example-remesh/evidence/interview/seethrough.json +1 -1
  165. package/templates/example-remesh/evidence/interview/turn.json +108 -0
  166. package/templates/example-remesh/evidence/parts-review.json +1 -1
  167. package/templates/example-remesh/evidence/review-reads/{afe2522be4087f7f2fa7e97746d5f8574b0aedb474245ce2dbce0455e662f041.json → 99ade3eb19d70bd5f727138b3b4876ce86f1e1c22a59b516639bf85593996c32.json} +2 -2
  168. package/templates/example-remesh/evidence/scene/s1.json +2 -2
  169. package/templates/example-remesh/evidence/v1/registration/at-source.json +2 -2
  170. package/templates/example-remesh/evidence/v1/registration/rigc-run-1.json +2 -2
  171. package/templates/example-remesh/evidence/v2/registration/at-source.json +2 -2
  172. package/templates/example-remesh/evidence/v2/registration/rigc-run-1.json +2 -2
  173. package/templates/example-remesh/evidence/v3/registration/at-source.json +2 -2
  174. package/templates/example-remesh/evidence/v3/registration/rigc-run-1.json +2 -2
  175. package/templates/example-remesh/outputs/v1/out/check/build/run.json +2 -2
  176. package/templates/example-remesh/outputs/v1/out/rig/run.json +2 -2
  177. package/templates/example-remesh/outputs/v2/out/check/build/run.json +2 -2
  178. package/templates/example-remesh/outputs/v2/out/rig/run.json +2 -2
  179. package/templates/example-remesh/outputs/v3/out/check/build/run.json +2 -2
  180. package/templates/example-remesh/outputs/v3/out/rig/run.json +2 -2
  181. package/templates/example-remesh/project.json +279 -230
  182. package/templates/example-reopened/project.json +341 -289
  183. package/templates/example-split/captions.json +1 -1
  184. package/templates/project.json +10 -2
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);
package/src/reopen.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import { writeFileSync } from 'node:fs';
2
+ import { readFileSync } from './check-reads.ts';
3
+ import { isDeepStrictEqual } from 'node:util';
2
4
  import { resolve } from 'node:path';
3
5
 
4
- import { asArray, asString, isObject, type Finding } from './check.ts';
6
+ import { asArray, asString, collectSignoffs, isObject, type Finding } from './check.ts';
5
7
  import { collectAmendments } from './amend.ts';
6
8
  import { readProject } from './feedback.ts';
9
+ import { jobHash } from './job-outbox.ts';
10
+ import { acquireBoundJobWrite } from './job-writer.ts';
11
+ import { assertActSubjectDeclared, decodeReviewAct, fetchReviewActBytes, reviewActFindings, reviewActTransport, type ReviewActTransport, type ReopenAct } from './review-act.ts';
12
+ import { stepHash } from './step-hash.ts';
7
13
  import { readVerbatim } from './record-input.ts';
8
14
  import { refuse, USAGE_EXIT } from './refusal.ts';
9
15
  import { newRefusals } from './refusal-baseline.ts';
@@ -77,6 +83,16 @@ function reachedIndex(project: JsonObject, at: number, fromStage: string): numbe
77
83
  return reached;
78
84
  }
79
85
 
86
+ /** An acceptance remains authoring authority until an explicit reopen supersedes it. */
87
+ export function directionReviewClosed(project: JsonObject): boolean {
88
+ const reopens = collectReopens(project, []);
89
+ return collectSignoffs(project, []).some(signoff => signoff.stage === 'direction-review'
90
+ && signoff.checkpoint === null && liveSignoff(reopens, signoff.stage, signoff.at))
91
+ || asArray(project.versions).filter(isObject).some(version => version.kind === 'post'
92
+ && asArray(version.verdicts).filter(isObject).some(verdict => verdict.review === 'direction-review'
93
+ && verdict.verdict === 'accepted' && liveSignoff(reopens, 'direction-review', Date.parse(String(verdict.at)))));
94
+ }
95
+
80
96
  /** Each reopen invalidates its suffix; a later, narrower reopen cannot resurrect an earlier suffix. */
81
97
  export function reopenTime(reopens: readonly Reopen[], stage: string): number {
82
98
  return Math.max(-Infinity, ...reopens.filter(r => stageIndex(r.stage) <= stageIndex(stage)).map(r => r.at));
@@ -94,38 +110,77 @@ export function liveSignoff(reopens: readonly Reopen[], stage: string, at: numbe
94
110
 
95
111
  export const REOPEN_HELP = `reopen:
96
112
  <stage> [<project dir>] record a human backward move and set project.stage
113
+ --from-review <actId> ingest the exported Review reopen (excludes --by, --verbatim-file and --source)
97
114
  --by <who> human signer (agent is refused)
98
115
  --verbatim-file <file> unchanged UTF-8 words; relative to the current working directory, not the project; or - for stdin (default)
99
116
  --source <text> optional origin of the words, separate from verbatim
100
117
  --json print the reopen record
101
118
  The clock supplies at; sign-offs and versions remain history.`;
102
119
 
103
- export function reopenCommand(args: string[], now: number): { stdout: string; exit: number } {
120
+ export function reopenCommand(args: string[], now: number, hooks: { fetch?: (transport: ReviewActTransport) => { bytes: string } } = {}): { stdout: string; exit: number } {
104
121
  const opts: Record<string, string> = {};
105
122
  const positional: string[] = [];
106
123
  let json = false;
107
124
  for (let i = 0; i < args.length; i++) {
108
125
  const arg = args[i] as string;
109
126
  if (arg === '--json') json = true;
110
- else if (['--by', '--verbatim-file', '--source'].includes(arg)) {
127
+ else if (['--by', '--verbatim-file', '--source', '--from-review'].includes(arg)) {
111
128
  const value = args[++i];
112
129
  if (arg in opts || value === undefined || value === '') refuse('REOPEN_OPTION_INVALID', `reopens: ${arg} needs one non-empty value; supply it once`, USAGE_EXIT);
113
130
  opts[arg] = value;
114
131
  } else if (arg.startsWith('-')) refuse('UNKNOWN_OPTION', `reopens: unsupported ${arg}; see reopen help`, USAGE_EXIT);
115
132
  else positional.push(arg);
116
133
  }
117
- if (positional.length < 1 || positional.length > 2 || opts['--by'] === undefined) refuse('REOPEN_OPTION_INVALID', 'reopens: supply a stage, --by and one optional project directory', USAGE_EXIT);
134
+ const actId = opts['--from-review'];
135
+ if (positional.length < 1 || positional.length > 2 || actId === undefined && opts['--by'] === undefined) refuse('REOPEN_OPTION_INVALID', 'reopens: supply a stage, --by or --from-review, and one optional project directory', USAGE_EXIT);
136
+ if (actId !== undefined && ['--by', '--verbatim-file', '--source'].some(flag => opts[flag] !== undefined)) {
137
+ refuse('REOPEN_OPTION_INVALID', 'project.json.reopens: --from-review excludes --by, --verbatim-file and --source; use the exported human values', USAGE_EXIT);
138
+ }
118
139
  const dir = resolve(positional[1] ?? process.cwd());
140
+ const release = actId === undefined ? () => {} : acquireBoundJobWrite(dir);
141
+ try { return recordReopen(dir, positional[0]!, opts, json, now, hooks); }
142
+ finally { release(); }
143
+ }
144
+
145
+ function recordReopen(dir: string, stage: string, opts: Record<string, string>, json: boolean, now: number,
146
+ hooks: { fetch?: (transport: ReviewActTransport) => { bytes: string } }): { stdout: string; exit: number } {
119
147
  const { file, project } = readProject(dir);
148
+ const original = readFileSync(file);
149
+ let review: { act: ReopenAct; bytes: string } | undefined;
150
+ const result = (entry: JsonObject): { stdout: string; exit: number } => ({ stdout: json ? JSON.stringify(entry, null, 2) : `reopen: ${String(entry.stage)} by ${String(entry.by)} at ${String(entry.at)}`, exit: 0 });
151
+ const actId = opts['--from-review'];
152
+ if (actId !== undefined) {
153
+ const invalid = reviewActFindings(project)[0];
154
+ if (invalid) refuse(invalid.code, invalid.message);
155
+ const transport: ReviewActTransport = { ...reviewActTransport(dir, actId), kind: 'reopen' };
156
+ const bytes = hooks.fetch ? hooks.fetch(transport).bytes : fetchReviewActBytes(transport);
157
+ const act = decodeReviewAct(bytes, 'reopen');
158
+ if (act.jobId !== transport.jobId || act.actId !== actId || Date.parse(act.at) > now) {
159
+ refuse('REVIEW_ACT_INVALID', 'project.json.reopens: fetched act identity or date differs from the request; request the correct reopen act');
160
+ }
161
+ if (act.value.stage !== stage) refuse('REVIEW_ACT_KIND_MISMATCH', `project.json.reopens: act reopens ${act.value.stage}, not ${stage}; invoke reopen for the exported stage`);
162
+ const existing = asArray(project.review_acts).filter(isObject).find(entry => entry.jobId === act.jobId && entry.actId === actId);
163
+ if (existing) {
164
+ if (existing.kind !== 'reopen' || existing.bytes !== bytes) refuse('REVIEW_ACT_REPLAY_CONFLICT', 'project.json.review_acts: immutable reopen act changed; restore the original receipt');
165
+ return result(asArray(project.reopens)[Number(existing.reopen)] as JsonObject);
166
+ }
167
+ if (act.source.projectHash !== jobHash(original) || !isDeepStrictEqual(act.source.plan.steps,
168
+ asArray(isObject(project.plan) ? project.plan.steps : undefined).map(raw => ({ id: isObject(raw) ? raw.id : null, hash: isObject(raw) ? stepHash(raw) : null })))) {
169
+ refuse('REVIEW_ACT_SOURCE_STALE', 'project.json.reopens: displayed record or plan changed; request a reopen on the current record');
170
+ }
171
+ assertActSubjectDeclared(dir, act, 'project.json.reopens');
172
+ review = { act, bytes };
173
+ }
120
174
  const before = structuredClone(project);
121
175
  const steps = asArray(isObject(project.plan) ? project.plan.steps : undefined);
122
176
  if (steps.some(s => !isObject(s) || typeof s.id !== 'string' || (s.phase !== 'rig' && s.phase !== 'post'))) {
123
177
  refuse('REOPEN_MALFORMED', 'plan.steps: each step needs id and phase; restore the plan before reopening');
124
178
  }
125
179
  const affected = steps.filter((s): s is JsonObject => isObject(s)
126
- && stageIndex(stageOfStep(s.phase as 'rig' | 'post', s.id as string)) >= stageIndex(positional[0] as string));
127
- const entry: JsonObject = { from_stage: project.stage ?? null, steps_before: affected.map(s => ({ id: s.id as string, ...(s.status === undefined ? {} : { status: s.status }) })), stage: positional[0] as string, by: opts['--by'], at: new Date(now).toISOString(), verbatim: '' };
128
- if (opts['--source'] !== undefined) entry.source = opts['--source'];
180
+ && stageIndex(stageOfStep(s.phase as 'rig' | 'post', s.id as string)) >= stageIndex(stage));
181
+ const entry: JsonObject = { from_stage: project.stage ?? null, steps_before: affected.map(s => ({ id: s.id as string, ...(s.status === undefined ? {} : { status: s.status }) })), stage, by: review?.act.authority.reviewer.id ?? opts['--by']!, at: review?.act.at ?? new Date(now).toISOString(), verbatim: '' };
182
+ if (review) entry.source = `review act ${review.act.jobId}/${review.act.actId}`;
183
+ else if (opts['--source'] !== undefined) entry.source = opts['--source'];
129
184
  const refusals: Finding[] = [];
130
185
  // Validate against the original reached stage before moving it backwards.
131
186
  const candidate = { ...project, reopens: [...asArray(project.reopens), entry] };
@@ -133,12 +188,20 @@ export function reopenCommand(args: string[], now: number): { stdout: string; ex
133
188
  collectReopens(candidate, refusals);
134
189
  const first = refusals[0];
135
190
  if (first !== undefined) refuse(first.code, first.message);
136
- entry.verbatim = readVerbatim(opts['--verbatim-file'] ?? '-');
191
+ entry.verbatim = review ? review.act.value.words : readVerbatim(opts['--verbatim-file'] ?? '-');
137
192
  for (const step of affected) step.status = 'pending';
138
193
  project.reopens = candidate.reopens;
139
194
  project.stage = entry.stage;
195
+ if (review) {
196
+ project.review_acts = [...asArray(project.review_acts), { schema: 'rig-create/review-act@1', kind: 'reopen',
197
+ jobId: review.act.jobId, actId: review.act.actId, reopen: asArray(project.reopens).length - 1,
198
+ bytes: review.bytes, sha256: jobHash(review.bytes), version_hash: jobHash(JSON.stringify(entry)) }];
199
+ const invalid = reviewActFindings(project)[0];
200
+ if (invalid) refuse(invalid.code, invalid.message);
201
+ }
140
202
  const rejected = newRefusals(dir, before, project, { now })[0];
141
203
  if (rejected !== undefined) refuse(rejected.code, rejected.message);
204
+ if (review && !readFileSync(file).equals(original)) refuse('REVIEW_ACT_SOURCE_STALE', 'project.json.reopens: record changed during fetch; retry on the current record');
142
205
  writeFileSync(file, `${JSON.stringify(project, null, 2)}\n`);
143
- return { stdout: json ? JSON.stringify(entry, null, 2) : `reopen: ${String(entry.stage)} by ${String(entry.by)} at ${String(entry.at)}`, exit: 0 };
206
+ return result(entry);
144
207
  }