rig-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
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
  }
package/src/review-act.ts CHANGED
@@ -3,6 +3,8 @@ import { PARTS_CLI_RECEIPT } from './parts-verdict-record.ts';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { readFileSync, realpathSync } from './check-reads.ts';
5
5
  import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { jobRuntimeExecArgv } from './job-writer.ts';
6
8
  import { isDeepStrictEqual } from 'node:util';
7
9
  import { JOB_COMMENTS_ACT_SCHEMA, parseCommentsAct, parseRecordAct, projectComments,
8
10
  type JobAct, type JobCommentPartsAnchor, type JobCommentsAct, type JobModelSourceBinding, type JobRigStill, type RecordAct } from '@rig-c/review/job-host';
@@ -28,6 +30,9 @@ export type PartsIssue = {
28
30
  };
29
31
  /** Review's JobAct now includes comments; legacy adapters keep their original source guarantees. */
30
32
  type LegacyJobAct = Exclude<JobAct, { schema: typeof JOB_COMMENTS_ACT_SCHEMA }>;
33
+ export type ReopenAct = Extract<RecordAct, { kind: 'reopen' }>;
34
+ export type ReviewActKind = 'review-verdict' | 'parts-verdict' | 'interview-picks' | 'checkpoint' | 'signoff' | 'reopen';
35
+ export type DecodedReviewAct = ReviewVerdictAct | PartsVerdictAct | InterviewPicksAct | CheckpointAct | SignoffAct | ReopenAct;
31
36
  export type InterviewPicksAct = Extract<LegacyJobAct | RecordAct, { kind: 'interview-picks' }>;
32
37
  export type PartsVerdictActV2 = Extract<LegacyJobAct, { kind: 'parts-verdict' }>;
33
38
  export type PartsVerdictActV5 = Omit<PartsVerdictActV2, 'schema' | 'value'> & {
@@ -88,7 +93,7 @@ function failure(code: string, detail: string): never {
88
93
  }
89
94
 
90
95
  /** The shared decoder validates grammar; Create separately admits its existing source adapters. */
91
- function decodeCommentsReviewAct(raw: JsonObject, kind: 'review-verdict' | 'parts-verdict' | 'interview-picks' | 'checkpoint' | 'signoff'): ReviewVerdictAct | PartsVerdictAct | CheckpointAct | SignoffAct {
96
+ function decodeCommentsReviewAct(raw: JsonObject, kind: ReviewActKind): ReviewVerdictAct | PartsVerdictAct | CheckpointAct | SignoffAct {
92
97
  if (kind === 'interview-picks' || raw.kind === 'interview-picks') failure('REVIEW_ACT_KIND_MISMATCH', 'comments acts do not carry interview answers');
93
98
  let act: JobCommentsAct;
94
99
  try { act = parseCommentsAct(raw); }
@@ -151,18 +156,22 @@ export function decodeReviewAct(bytes: string, kind: 'interview-picks'): Intervi
151
156
  export function decodeReviewAct(bytes: string, kind: 'parts-verdict'): PartsVerdictAct;
152
157
  export function decodeReviewAct(bytes: string, kind: 'checkpoint'): CheckpointAct;
153
158
  export function decodeReviewAct(bytes: string, kind: 'signoff'): SignoffAct;
154
- export function decodeReviewAct(bytes: string, kind: 'review-verdict' | 'parts-verdict' | 'interview-picks' | 'checkpoint' | 'signoff'): ReviewVerdictAct | PartsVerdictAct | InterviewPicksAct | CheckpointAct | SignoffAct;
155
- export function decodeReviewAct(bytes: string, kind: 'review-verdict' | 'parts-verdict' | 'interview-picks' | 'checkpoint' | 'signoff' = 'review-verdict'): ReviewVerdictAct | PartsVerdictAct | InterviewPicksAct | CheckpointAct | SignoffAct {
159
+ export function decodeReviewAct(bytes: string, kind: 'reopen'): ReopenAct;
160
+ export function decodeReviewAct(bytes: string, kind: ReviewActKind): DecodedReviewAct;
161
+ export function decodeReviewAct(bytes: string, kind: ReviewActKind = 'review-verdict'): DecodedReviewAct {
156
162
  let act: JsonObject;
157
163
  try { const parsed: unknown = JSON.parse(bytes); if (!object(parsed)) throw Error(); act = parsed; }
158
164
  catch { return failure('REVIEW_ACT_INVALID', 'act is not a JSON object'); }
165
+ if (kind === 'reopen' && act.schema !== 'rig-review/act@4') failure('REVIEW_ACT_SCHEMA_UNSUPPORTED', 'reopen requires a record act@4');
166
+ if (act.schema === 'rig-review/act@4' && act.kind === 'reopen' && kind !== 'reopen') {
167
+ failure('REVIEW_ACT_KIND_MISMATCH', 'record reopen acts require the reopen writer');
168
+ }
159
169
  if (act.schema === JOB_COMMENTS_ACT_SCHEMA) return decodeCommentsReviewAct(act, kind);
160
- if (act.schema === 'rig-review/act@4' && act.kind === 'reopen' && kind !== 'checkpoint') failure('REVIEW_ACT_SCHEMA_UNSUPPORTED', 'record reopen acts have no Create writer mapping');
161
- if (['checkpoint', 'interview-picks', 'signoff'].includes(kind) && act.schema === 'rig-review/act@4') {
170
+ if (['checkpoint', 'interview-picks', 'signoff', 'reopen'].includes(kind) && act.schema === 'rig-review/act@4') {
162
171
  try {
163
172
  const recordAct = parseRecordAct(act);
164
173
  if (recordAct.kind !== kind) return failure('REVIEW_ACT_KIND_MISMATCH', 'record act kind differs from requested kind');
165
- return recordAct as CheckpointAct | InterviewPicksAct | SignoffAct;
174
+ return recordAct as CheckpointAct | InterviewPicksAct | SignoffAct | ReopenAct;
166
175
  } catch { return failure('REVIEW_ACT_INVALID', 'record act or its sealed source is malformed'); }
167
176
  }
168
177
  if (act.schema !== 'rig-review/act@2' && !(kind === 'review-verdict' && act.schema === 'rig-review/act@3')
@@ -292,7 +301,7 @@ export function decodeReviewAct(bytes: string, kind: 'review-verdict' | 'parts-v
292
301
  return act as unknown as ReviewVerdictAct;
293
302
  }
294
303
 
295
- export function decodeReviewResponse(text: string, jobId: string, actId: string, requestId: string, kind: 'review-verdict' | 'parts-verdict' | 'interview-picks' | 'checkpoint' | 'signoff' = 'review-verdict'): { act: ReviewVerdictAct | PartsVerdictAct | InterviewPicksAct | CheckpointAct | SignoffAct; bytes: string } {
304
+ export function decodeReviewResponse(text: string, jobId: string, actId: string, requestId: string, kind: ReviewActKind = 'review-verdict'): { act: DecodedReviewAct; bytes: string } {
296
305
  let response: JsonObject;
297
306
  try { const parsed: unknown = JSON.parse(text); if (!object(parsed)) throw Error(); response = parsed; }
298
307
  catch { return failure('REVIEW_ACT_INVALID', 'Review response is not JSON'); }
@@ -312,7 +321,23 @@ export interface ReviewActTransport {
312
321
  jobId: string;
313
322
  actId: string;
314
323
  fetchCapability: string;
315
- kind?: 'parts-verdict' | 'interview-picks' | 'checkpoint' | 'signoff';
324
+ kind?: Exclude<ReviewActKind, 'review-verdict'>;
325
+ }
326
+
327
+ /** Synchronous writers use the same authenticated fetch/decoder through the private runtime. */
328
+ export function fetchReviewActRuntime(transport: ReviewActTransport): { bytes: string; stillBytes?: string } {
329
+ const child = Bun.spawnSync([process.execPath, ...jobRuntimeExecArgv(), fileURLToPath(new URL('./review-act-runtime.ts', import.meta.url))], {
330
+ stdin: Buffer.from(JSON.stringify(transport)), stdout: 'pipe', stderr: 'pipe',
331
+ });
332
+ let response: { ok: boolean; bytes: string; stillBytes?: string; code: string; message: string };
333
+ try { response = JSON.parse(child.stdout.toString()); }
334
+ catch { return failure('REVIEW_ACT_FETCH_REFUSED', 'fetch adapter failed'); }
335
+ if (!response.ok || child.exitCode !== 0) refuse(response.code, response.message);
336
+ return { bytes: response.bytes, stillBytes: response.stillBytes };
337
+ }
338
+
339
+ export function fetchReviewActBytes(transport: ReviewActTransport): string {
340
+ return fetchReviewActRuntime(transport).bytes;
316
341
  }
317
342
 
318
343
  /** Only the route and fetch grant leave the binding reader. Never attach a host. */
@@ -329,7 +354,7 @@ export function reviewActTransport(root: string, actId: string): ReviewActTransp
329
354
  return { origin: origin.origin, jobId: binding.jobId, actId, fetchCapability: binding.fetchCapability };
330
355
  }
331
356
 
332
- export async function fetchReviewAct(transport: ReviewActTransport, fetchAdapter: typeof fetch = fetch): Promise<{ act: ReviewVerdictAct | PartsVerdictAct | InterviewPicksAct | CheckpointAct | SignoffAct; bytes: string; stillBytes?: string }> {
357
+ export async function fetchReviewAct(transport: ReviewActTransport, fetchAdapter: typeof fetch = fetch): Promise<{ act: DecodedReviewAct; bytes: string; stillBytes?: string }> {
333
358
  const requestId = randomUUID();
334
359
  const request = JSON.stringify({ schema: 'rig-review/job-api@2', verb: 'fetch-act', jobId: transport.jobId, actId: transport.actId, requestId });
335
360
  async function read(accept: string): Promise<Response> {
@@ -342,7 +367,7 @@ export async function fetchReviewAct(transport: ReviewActTransport, fetchAdapter
342
367
  return response;
343
368
  } catch { return failure('REVIEW_ACT_FETCH_REFUSED', 'live Review fetch failed'); }
344
369
  }
345
- if (transport.kind === 'parts-verdict' || transport.kind === 'interview-picks' || transport.kind === 'checkpoint' || transport.kind === 'signoff') return decodeReviewResponse(await (await read('application/json')).text(), transport.jobId, transport.actId, requestId, transport.kind);
370
+ if (transport.kind === 'parts-verdict' || transport.kind === 'interview-picks' || transport.kind === 'checkpoint' || transport.kind === 'signoff' || transport.kind === 'reopen') return decodeReviewResponse(await (await read('application/json')).text(), transport.jobId, transport.actId, requestId, transport.kind);
346
371
  const bytes = await (await read('application/vnd.rig-review.act+json')).text();
347
372
  const act = decodeReviewAct(bytes);
348
373
  if (act.jobId !== transport.jobId || act.actId !== transport.actId) failure('REVIEW_ACT_INVALID', 'fetched act identity differs from the request');
@@ -459,12 +484,12 @@ export function reviewActSignoff(act: SignoffAct): JsonObject {
459
484
  * acknowledged receipt keeps the subjects create published; a receipt that predates
460
485
  * them, or none (a clone), leaves only Review's own ask binding to vouch for it.
461
486
  */
462
- export function assertActSubjectDeclared(root: string, act: SignoffAct | CheckpointAct | InterviewPicksAct, path: string): void {
487
+ export function assertActSubjectDeclared(root: string, act: SignoffAct | CheckpointAct | InterviewPicksAct | ReopenAct, path: string): void {
463
488
  const receipt = readAcknowledgedPublication(root, act.jobId, act.publicationId);
464
489
  if (receipt?.subjects === undefined) return;
465
- const subject = act.kind === 'interview-picks' ? { kind: 'interview-picks', questionId: act.value.questionId, setId: act.source.optionSet.id } : act.kind === 'signoff' ? { kind: 'signoff', stage: act.value.stage } : { kind: 'checkpoint', checkpointId: act.value.checkpointId };
490
+ const subject = act.kind === 'interview-picks' ? { kind: 'interview-picks', questionId: act.value.questionId, setId: act.source.optionSet.id } : act.kind === 'signoff' || act.kind === 'reopen' ? { kind: act.kind, stage: act.value.stage } : { kind: 'checkpoint', checkpointId: act.value.checkpointId };
466
491
  if (receipt.subjects.some(declared => isDeepStrictEqual(declared, subject))) return;
467
- const named = act.kind === 'interview-picks' ? `answers ${act.value.questionId}` : act.kind === 'signoff' ? `signs ${act.value.stage}` : `passes checkpoint ${act.value.checkpointId}`;
492
+ const named = act.kind === 'interview-picks' ? `answers ${act.value.questionId}` : act.kind === 'signoff' || act.kind === 'reopen' ? `${act.kind} ${act.value.stage}` : `passes checkpoint ${act.value.checkpointId}`;
468
493
  refuse('REVIEW_ACT_SUBJECT_UNDECLARED', `${path}: act ${act.jobId}/${act.actId} ${named}, which its publication ${act.publicationId} did not declare; ask for it in Review on a publication made while the record can sign it`);
469
494
  }
470
495
 
@@ -512,7 +537,7 @@ export function reviewActFindings(project: JsonObject): { code: string; path: st
512
537
  try {
513
538
  if (!object(entry) || entry.schema !== 'rig-create/review-act@1' || typeof entry.bytes !== 'string'
514
539
  || !sha(entry.sha256) || jobHash(entry.bytes) !== entry.sha256 || !sha(entry.version_hash)) failure('REVIEW_ACT_LEDGER_INVALID', 'receipt shape or bytes hash is invalid');
515
- const act = decodeReviewAct(entry.bytes, entry.kind === 'parts-verdict' ? 'parts-verdict' : entry.kind === 'interview-picks' ? 'interview-picks' : entry.kind === 'checkpoint' ? 'checkpoint' : entry.kind === 'signoff' ? 'signoff' : 'review-verdict');
540
+ const act = decodeReviewAct(entry.bytes, entry.kind === 'parts-verdict' ? 'parts-verdict' : entry.kind === 'interview-picks' ? 'interview-picks' : entry.kind === 'checkpoint' ? 'checkpoint' : entry.kind === 'signoff' ? 'signoff' : entry.kind === 'reopen' ? 'reopen' : 'review-verdict');
516
541
  if (entry.jobId !== act.jobId || entry.actId !== act.actId) failure('REVIEW_ACT_LEDGER_INVALID', 'receipt identity differs from its act');
517
542
  const key = JSON.stringify([act.jobId, act.actId]);
518
543
  if (seen.has(key)) failure('REVIEW_ACT_REPLAY_CONFLICT', 'duplicate act receipt');
@@ -537,6 +562,16 @@ export function reviewActFindings(project: JsonObject): { code: string; path: st
537
562
  }
538
563
  return;
539
564
  }
565
+ if (act.kind === 'reopen') {
566
+ const recorded = Number.isSafeInteger(entry.reopen) && Number(entry.reopen) >= 0 ? asArray(project.reopens)[Number(entry.reopen)] : null;
567
+ if (!object(recorded) || entry.version_hash !== jobHash(JSON.stringify(recorded))
568
+ || recorded.stage !== act.value.stage || recorded.by !== act.authority.reviewer.id
569
+ || recorded.at !== act.at || recorded.verbatim !== act.value.words
570
+ || recorded.source !== `review act ${act.jobId}/${act.actId}`) {
571
+ failure('REVIEW_ACT_LEDGER_INVALID', 'recorded reopen differs from the immutable act');
572
+ }
573
+ return;
574
+ }
540
575
  if (act.kind === 'checkpoint') {
541
576
  const signoffs = Array.isArray(project.signoffs) ? project.signoffs : [];
542
577
  const signoff = Number.isSafeInteger(entry.signoff) && Number(entry.signoff) >= 0 ? signoffs[Number(entry.signoff)] : undefined;
@@ -13,6 +13,7 @@ import { asArray, isObject, readJsonObject, type Ctx } from './check.ts';
13
13
  import { draftFileUnavailable, draftVersionFindings } from './drafts.ts';
14
14
  import { Refusal, refuse, USAGE_EXIT } from './refusal.ts';
15
15
  import { assertRigVersionDate, rigVersionDateFinding } from './rig-version-date.ts';
16
+ import { registeredDepthPartsMatch } from './rig-version.ts';
16
17
  import { latestPartsApproval, retainedJson } from './structure.ts';
17
18
  import type { Json, JsonObject } from './templates.ts';
18
19
 
@@ -50,7 +51,8 @@ function checkBuildIdentity(ctx: Pick<Ctx, 'dir' | 'project' | 'refusals' | 'war
50
51
  const files = asArray(parts.files).filter(isObject);
51
52
  const manifest = files.find(file => file.path === `${parts.path}/parts.json`);
52
53
  const packaged = asArray(record.files).find(f => isObject(f) && f.path === 'out/parts.json');
53
- if (isObject(manifest) && typeof manifest.sha256 === 'string' && isObject(packaged) && typeof packaged.sha256 === 'string' && manifest.sha256 !== packaged.sha256) {
54
+ if (isObject(manifest) && typeof manifest.sha256 === 'string' && isObject(packaged) && typeof packaged.sha256 === 'string' && manifest.sha256 !== packaged.sha256
55
+ && !registeredDepthPartsMatch(ctx.dir, version, manifest.sha256, packaged.sha256)) {
54
56
  mismatch('REVIEW_PACKAGE_PARTS_MISMATCH', 'files[out/parts.json]', manifest.sha256, packaged.sha256);
55
57
  }
56
58
  if (!isObject(manifest) || typeof manifest.path !== 'string') return;