@rig-c/create 1.2.1 → 1.3.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 (30) hide show
  1. package/docs/CHECKS.md +8 -1
  2. package/docs/INTERVIEW.md +20 -0
  3. package/package.json +1 -1
  4. package/src/check.ts +10 -0
  5. package/src/choice-writers.ts +6 -1
  6. package/src/cli.ts +3 -2
  7. package/src/job-close.ts +6 -2
  8. package/src/job-host.ts +1 -1
  9. package/src/job-outbox.ts +12 -0
  10. package/src/job-publication.ts +22 -4
  11. package/src/job-runtime.ts +2 -2
  12. package/src/next.ts +19 -0
  13. package/src/owner-chat.ts +67 -0
  14. package/src/parts-sections.ts +7 -4
  15. package/src/signoff.ts +10 -3
  16. package/templates/agent-guide.md +1 -1
  17. package/templates/example-chain/AGENTS.md +1 -1
  18. package/templates/example-chain/checkpoints/0a-interview-options/AGENTS.md +1 -1
  19. package/templates/example-chain/checkpoints/0a-interview-options.json +1 -1
  20. package/templates/example-chain/checkpoints/0b-plan-pending/AGENTS.md +1 -1
  21. package/templates/example-chain/checkpoints/0b-plan-pending.json +1 -1
  22. package/templates/example-chain/checkpoints/1-parts-verdict/AGENTS.md +1 -1
  23. package/templates/example-chain/checkpoints/1-parts-verdict.json +1 -1
  24. package/templates/example-chain/checkpoints/2-rigging-answers/AGENTS.md +1 -1
  25. package/templates/example-chain/checkpoints/2-rigging-answers.json +1 -1
  26. package/templates/example-chain/checkpoints/3-rig-review/AGENTS.md +1 -1
  27. package/templates/example-chain/checkpoints/3-rig-review.json +1 -1
  28. package/templates/example-chain/checkpoints/4-direction-post/AGENTS.md +1 -1
  29. package/templates/example-chain/checkpoints/4-direction-post.json +1 -1
  30. package/templates/example-remesh/AGENTS.md +1 -1
package/docs/CHECKS.md CHANGED
@@ -24,7 +24,7 @@ requires Bun 1.4 or later. See [Live review](LIVE-REVIEW.md).
24
24
 
25
25
  | Code | Finding and remedy |
26
26
  | --- | --- |
27
- | `JOB_PUBLISH_PENDING` | A committed `project.json` result awaits publication. Restore its bound host and sources, then run `job reconcile` before another participating write or close. |
27
+ | `JOB_PUBLISH_PENDING` | A committed `project.json` result awaits publication. Restore its bound host and sources, then run `job reconcile` before another participating write or close. To abandon failed publications explicitly, `job close --retire-refused` archives their complete receipts under the private `retired-publications/` directory and closes without acknowledging them; unfailed pending publications still block close. |
28
28
  | `JOB_ROOT_REPLACED` | `project.json`: the managed Review state belongs to a replaced project directory, or its ownership/liveness cannot be established safely. Stop the original `job open` host in its terminal, then retry `job open` or `job reconcile`. With no pending publication or startup claim, both commands preserve the old state in a dated `.replaced-…` sibling only when its recorded PID is absent and its recorded loopback port refuses connections. Missing/unsafe metadata, an occupied port or uncertain liveness refuses without retiring state; restore the retained metadata or original directory before retrying. |
29
29
  | `JOB_HOST_ATTACH_REFUSED` | Strict reuse-only attach to the bound live host was refused. The pending receipt names the host `reason` and `phase` (including `incarnation-missing` on `record` or `probe`). Reopen or restore the bound live host before reconciling. |
30
30
  | `JOB_HOST_REUSE_MISMATCH` | The private binding's host tuple or `listenerIncarnation` (`jobId`, `origin`, `listenerIncarnation`, `root`, `stateDir`, `fingerprint`, `reviewer.id`, `reviewer.role`, `readerId`, `bindingId`, `partsEnumeratorId`, `partsEnumeratorVersion`, `apiSchema`, `actSchema`, `port`) differs. The pending receipt names the `field` and both `expected` and `actual` values. Restore the original host configuration before reconciling. |
@@ -1717,3 +1717,10 @@ checks remain in force; no source measurement is recomputed.
1717
1717
  new placement. Re-signing or adopting the split order on a later date alone
1718
1718
  does not retrofit it into historical interviews. Missing `turn` on an adopted
1719
1719
  interview is the existing `WAYPOINT_MISSING` refusal.
1720
+
1721
+ ### Relayed owner chat
1722
+
1723
+ | Code | Meaning and remedy |
1724
+ | --- | --- |
1725
+ | `OWNER_CHAT_INVALID` | The named project.json answer/signoff lacks exact nonblank owner words, a valid explicit ISO said_at, relay identity, or the relayed source, or mixes chat and Review flags. Supply `--owner-chat`, `--said-at`, `--relayed-by` and owner `--by` together; keep at as the write time and omit act ids. |
1726
+ | `OWNER_CHAT_GATE_VISUAL` | The named project.json stage signoff has no accepted live verdict for the current draft, rig or post. Get the verdict first in the review tool with `job open --draft <id>` or `job open --rig <n>`, ingest it, then relay the signoff. |
package/docs/INTERVIEW.md CHANGED
@@ -903,3 +903,23 @@ screens a corpus reproduction away. A new report needs a fresh shown set
903
903
  and explicit choice; the old choice and set remain history. Choices are
904
904
  local, never inherited from a collection. They may be recorded only in
905
905
  parts, but later signatures do not make a pending optional choice invalid.
906
+
907
+ ### Relayed owner chat
908
+
909
+ All interview questions may use `interview-answer --owner-chat <words file>
910
+ --said-at <ISO date> --relayed-by <operator> --by <owner>` alongside the
911
+ existing `--set`, `--sha256` and explicit `--pick` flags. Shared context images
912
+ are background and do not change this route. The writer retains generated
913
+ pick text in `verbatim`, exact UTF-8 chat in `owner_words`, the owner's original
914
+ `said_at`, the relay's `relayed_by`, and source `owner chat, relayed`.
915
+ `at` remains the write time; no review act id is created. Empty or whitespace
916
+ words, missing clocks or relay identity, and agent owners are refused.
917
+ If options ever carry their own images, those sets leave the chat path;
918
+ that requires a new Review contract outside this change.
919
+
920
+ Stage signoffs use the same flags, retaining the exact words in `verbatim`.
921
+ Parts-review, rig-review and direction-review first require the current
922
+ accepted live verdict from the review tool; changes-requested does not count.
923
+ Get that verdict with `job open --draft <id>` (parts) or `job open --rig <n>`
924
+ (rig/post), then ingest it through the existing verdict writer. Verdict writers
925
+ have no owner-chat route. Review remains available for picks and signoffs.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/create",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Create a rig project: its folder, decision record, repository and pinned rig-c packages",
6
6
  "license": "MIT",
package/src/check.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ownerChatFinding } from './owner-chat.ts';
1
2
  import { packageUpgradeFindings } from './package-upgrades.ts';
2
3
  import { checkPublicationGenerations } from './job-publication.ts';
3
4
  import { collectBarAmendments, effectiveBar } from './plan-bar-amendments.ts';
@@ -1001,6 +1002,15 @@ function checkProjectOnce(projectDirArg: string, candidate: JsonObject | undefin
1001
1002
  if (!hasStageOrder(project)) warnings.push(STAGE_ORDER_LEGACY_FINDING);
1002
1003
  checkInputs(ctx);
1003
1004
  checkChoices(ctx);
1005
+ const checkChat = (value: Json | undefined, path: string): void => {
1006
+ if (Array.isArray(value)) value.forEach((entry, index) => checkChat(entry, `${path}[${index}]`));
1007
+ else if (isObject(value)) {
1008
+ const finding = ownerChatFinding(value, path);
1009
+ if (finding) ctx.refusals.push(finding);
1010
+ for (const [key, entry] of Object.entries(value)) checkChat(entry, `${path}.${key}`);
1011
+ }
1012
+ };
1013
+ checkChat(project, 'project.json');
1004
1014
  if (!options.recordPublication) checkCorrections(ctx, options.now);
1005
1015
  if (!options.recordPublication) checkScenes(ctx);
1006
1016
  if (!options.recordPublication) checkDirections(ctx, options.now);
@@ -1,3 +1,4 @@
1
+ import { OWNER_CHAT_FLAGS, readOwnerChat } from './owner-chat.ts';
1
2
  import { overrideFindings } from './overrides.ts';
2
3
  import { intakeItem, intakeWarnings, INTAKE_QUESTION } from './intake.ts';
3
4
  /** Public record writers; validation precedes every project write. */
@@ -20,6 +21,7 @@ interview-options --adopt [<dir>]
20
21
  interview-answer <question> [<dir>] --from-review <actId> [--why-file <file>]
21
22
  interview-answer <question> [<dir>] --set <path> --sha256 <shown sha> --by <human>
22
23
  [--pick <ask>=<option>...] [--other <ask>=<file> --other-fragment <ask>=<JSON file>]...
24
+ [--owner-chat <words file> --said-at <ISO date> --relayed-by <who>]
23
25
  [--note-file <file>] [--source <location>] [--why-file <file> for a member override]
24
26
  interview-decision <question> [<dir>] --by <agent> --reasoning-file <file>
25
27
  --fragment <ask>=<JSON file>...
@@ -139,8 +141,10 @@ export interface InterviewAnswerHooks {
139
141
  }
140
142
  export function interviewAnswerCommand(args: string[], now: number, hooks: InterviewAnswerHooks = {}): string { return run(() => {
141
143
  if (args.includes('--picks-file')) refuse('INTERVIEW_PICKS_FILE_REMOVED', 'project.json: --picks-file was removed; use interview-answer <question> --from-review <actId>', USAGE_EXIT);
142
- const opts = parse(args,['--set','--sha256','--by','--note-file','--source','--from-review','--why-file'],['--pick','--other','--other-fragment']);
144
+ const opts = parse(args,['--set','--sha256','--by','--note-file','--source','--from-review','--why-file',...OWNER_CHAT_FLAGS],['--pick','--other','--other-fragment']);
143
145
  if (opts.positional.length > 2 || (!opts.one['--from-review'] && (!opts.one['--set'] || !opts.one['--sha256'] || !opts.one['--by']))) usage('question and --from-review, or --set/--sha256/--by required');
146
+ if (opts.one['--owner-chat'] !== undefined && opts.one['--by']?.toLowerCase() === 'agent') refuse('INTERVIEW_CHOICE_INVALID', 'project.json.interview: --by must name the owner; relay owner words without claiming agent authority');
147
+ const chat = readOwnerChat(opts.one, opts.one['--by'] ?? '', now, 'project.json.interview');
144
148
  const dir = resolve(opts.positional[1] ?? process.cwd());
145
149
  const release = acquireBoundJobWrite(dir);
146
150
  try {
@@ -208,6 +212,7 @@ export function interviewAnswerCommand(args: string[], now: number, hooks: Inter
208
212
  else if (opts.one['--note-file']) choice.note = readText(opts.one['--note-file']);
209
213
  const result = selection(set,choice);
210
214
  const answer: JsonObject = {delegated:result.delegated,by:payload?.by ?? opts.one['--by'],at:payload?.at ?? new Date(now).toISOString(),verbatim:choiceVerbatim(picks,choice.note),source:opts.one['--source'] ?? 'review questionnaire',choice,asks:result.asks};
215
+ if (chat) { answer.owner_words = chat.owner_words!; answer.said_at = chat.said_at!; answer.relayed_by = chat.relayed_by!; answer.source = chat.source!; }
211
216
  if (result.value !== undefined) answer.value = result.value;
212
217
  if (result.autos.length) answer.agent_decision = {covers:result.autos.join(',')};
213
218
  if (isObject(item.raw) && typeof item.raw.basis === 'string') answer.basis = item.raw.basis === 'inherited' ? (item.state.state === 'silent' ? 'own' : 'overridden') : item.raw.basis;
package/src/cli.ts CHANGED
@@ -51,7 +51,7 @@ Usage:
51
51
  rig-create republish <rig> [<dir>] --reason <words>
52
52
  rig-create job open [<dir>] --reviewer <id> --role owner|delegate [--draft <id> | --rig <n> | --question <question> | --plan]
53
53
  rig-create job acts [<dir>] [--json] list finalized acts and their ingest commands
54
- rig-create job reconcile|close [<dir>]
54
+ rig-create job reconcile|close [<dir>] [--retire-refused (close only)]
55
55
  ${INPUT_CLI} rig-create init <dir> [--no-install] create a project folder, its repository and pinned packages
56
56
  rig-create init <dir> --painting <file> --origin <words> [--projects-root <dir>] [--same-artwork-reason <file>]
57
57
  also register the painting; refuse a second project for one artwork
@@ -310,6 +310,7 @@ export function main(argv: string[], now: number): number {
310
310
  if (!value || value.startsWith('-') || options[arg]) refuse('JOB_BINDING_INVALID', 'project.json: job open requires one value for each reviewer option', USAGE_EXIT);
311
311
  options[arg] = value;
312
312
  } else if (action === 'open' && arg === '--plan' && !options[arg]) options[arg] = 'true';
313
+ else if (action === 'close' && arg === '--retire-refused' && !options[arg]) options[arg] = 'true';
313
314
  else if (action === 'acts' && arg === '--json' && !options[arg]) options[arg] = 'true';
314
315
  else if (arg.startsWith('-')) refuse('JOB_BINDING_INVALID', 'project.json: unsupported job option; see help', USAGE_EXIT);
315
316
  else positional.push(arg);
@@ -320,7 +321,7 @@ export function main(argv: string[], now: number): number {
320
321
  if (['--draft', '--rig', '--question', '--plan'].filter(flag => options[flag] !== undefined).length > 1) {
321
322
  refuse('JOB_BINDING_INVALID', 'project.json: job open takes one of --draft, --rig, --question or --plan; choose one recorded result', USAGE_EXIT);
322
323
  }
323
- const stdin = Buffer.from(JSON.stringify({ root: positional[0] ?? process.cwd(), reviewer: options['--reviewer'], role: options['--role'], draft: options['--draft'], rig: options['--rig'], question: options['--question'], plan: options['--plan'] === 'true', json: options['--json'] === 'true', now }));
324
+ const stdin = Buffer.from(JSON.stringify({ root: positional[0] ?? process.cwd(), reviewer: options['--reviewer'], role: options['--role'], draft: options['--draft'], rig: options['--rig'], question: options['--question'], plan: options['--plan'] === 'true', json: options['--json'] === 'true', retireRefused: options['--retire-refused'] === 'true', now }));
324
325
  const runtime = join(import.meta.dir, 'job-runtime.ts');
325
326
  if (action === 'open') {
326
327
  const child = Bun.spawn(jobRuntimeCommand(action, runtime), {
package/src/job-close.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  acquireJobLock,
14
14
  JOB_BINDING_FILE,
15
15
  pendingPublications,
16
+ retireRefusedPublications,
16
17
  readWriterBinding,
17
18
  type JobIdentity,
18
19
  type WriterBinding,
@@ -181,12 +182,14 @@ export async function attachCreateJobForClose(
181
182
  export async function closeCreateJob(
182
183
  root: string,
183
184
  startHost: (options: JobHostOptions) => Promise<JobOwnerHost> = startJobHost,
185
+ retireRefused = false,
184
186
  ): Promise<void> {
185
187
  root = realpathSync(root);
186
188
  const release = acquireJobLock(root)!;
187
189
  try {
188
- if (pendingPublications(root).length) {
189
- refuse('JOB_PUBLISH_PENDING', 'project.json: a committed publication remains pending; run rig-create job reconcile before closing');
190
+ const pending = pendingPublications(root);
191
+ if (pending.length && (!retireRefused || pending.some(entry => !entry.failure))) {
192
+ refuse('JOB_PUBLISH_PENDING', 'project.json: a committed publication remains pending; run rig-create job reconcile, or job close --retire-refused to archive failed publications and close without publishing them');
190
193
  }
191
194
  const binding = readWriterBinding(root);
192
195
  if (!binding) refuse('JOB_BINDING_INVALID', 'project.json: no writer-owned job binding; open the live job first');
@@ -216,6 +219,7 @@ export async function closeCreateJob(
216
219
  if (!current || !isDeepStrictEqual(current, binding)) {
217
220
  refuse('JOB_BINDING_INVALID', 'project.json: private live-job binding changed during close; restore the writer-owned binding');
218
221
  }
222
+ if (retireRefused) retireRefusedPublications(root, pending);
219
223
  rmSync(join(root, JOB_BINDING_FILE));
220
224
  } finally {
221
225
  host.stop();
package/src/job-host.ts CHANGED
@@ -369,7 +369,7 @@ async function reconcileLocked(root: string, publish: (entry: PendingPublication
369
369
  const failure = error instanceof Refusal ? { code: error.code, message: error.message }
370
370
  : { code: 'JOB_PUBLISH_PENDING', message: 'project.json: host publication failed; restore the live host before rig-create job reconcile' };
371
371
  failPublication(root, entry, failure);
372
- refuse('JOB_PUBLISH_PENDING', `project.json: record ${entry.recordHash} remains pending (${failure.code}: ${failure.message}); run rig-create job reconcile`);
372
+ refuse('JOB_PUBLISH_PENDING', `project.json: record ${entry.recordHash} remains pending (${failure.code}: ${failure.message}); run rig-create job reconcile after restoring the sources, or job close --retire-refused to archive failed publications and close without publishing them`);
373
373
  }
374
374
  }
375
375
  return { count: entries.length, outcomes };
package/src/job-outbox.ts CHANGED
@@ -302,6 +302,18 @@ export function failPublication(root: string, entry: PendingPublication, failure
302
302
  writePrivateJson(pendingPath(root, entry.id), { ...entry, failure });
303
303
  }
304
304
 
305
+ /** Explicit close recovery retains the complete failed receipt; it never acknowledges publication. */
306
+ export function retireRefusedPublications(root: string, entries: PendingPublication[]): void {
307
+ for (const entry of entries) {
308
+ if (!entry.failure) refuse('JOB_PUBLISH_PENDING', 'project.json: an unfailed publication cannot be retired; run rig-create job reconcile first');
309
+ const directory = join(jobPrivateRoot(root), 'retired-publications');
310
+ privateDirectory(directory);
311
+ writePrivateJson(join(directory, `${entry.id}.json`), entry);
312
+ rmSync(pendingPath(root, entry.id));
313
+ syncDirectory(join(jobPrivateRoot(root), 'outbox'));
314
+ }
315
+ }
316
+
305
317
  export async function reconcilePublication(root: string, entry: PendingPublication,
306
318
  publish: (entry: PendingPublication) => Promise<unknown>): Promise<void> {
307
319
  if (currentVersionHash(root, entry) !== entry.versionHash) {
@@ -328,8 +328,13 @@ export function readRecordPublished({ root, record, result, phase, parts: sealed
328
328
  return { label, parts, inputs, files, ...evidence,
329
329
  plan: { steps: steps.map(step => ({ id: String(step.id), hash: stepHash(step) })) }, subjects };
330
330
  } catch (error) {
331
- if (error instanceof Refusal && (phase === 'retained' || ['JOB_RECORD_SUBJECT_INVALID', 'JOB_RECORD_COLLECTION_EXTERNAL', 'JOB_RECORD_PARTS_LOCK_MISSING', 'JOB_DIRECTION_VERDICT_REQUIRED', 'REVIEW_ACT_RECORD_TARGET_INVALID', 'REVIEW_ACT_TARGET_MISMATCH', 'INTERVIEW_SIGNOFF_NOT_CLEAN', 'INTERVIEW_SIGNOFF_PLAN_REQUIRED'].includes(error.code))) throw error;
332
- refuse('JOB_RECORD_INCOMPLETE', `project.json: ${error instanceof Error ? error.message : String(error)}; restore the requested record and retained evidence, resolve check refusals and reconcile pending publications before publishing`);
331
+ const failure = error instanceof Refusal && (phase === 'retained' || ['JOB_RECORD_SUBJECT_INVALID', 'JOB_RECORD_COLLECTION_EXTERNAL', 'JOB_RECORD_PARTS_LOCK_MISSING', 'JOB_DIRECTION_VERDICT_REQUIRED', 'REVIEW_ACT_RECORD_TARGET_INVALID', 'REVIEW_ACT_TARGET_MISMATCH', 'INTERVIEW_SIGNOFF_NOT_CLEAN', 'INTERVIEW_SIGNOFF_PLAN_REQUIRED'].includes(error.code))
332
+ ? error
333
+ : new Refusal('JOB_RECORD_INCOMPLETE', `project.json: ${error instanceof Error ? error.message : String(error)}; restore the requested record and retained evidence, resolve check refusals and reconcile pending publications before publishing`);
334
+ // Review returns only a safe producer token. Its retained root is private and may
335
+ // be removed on refusal, so report here in the host terminal before it is lost.
336
+ if (phase === 'retained') console.error(`${failure.code}: ${failure.message}`);
337
+ throw failure;
333
338
  }
334
339
  }
335
340
 
@@ -608,8 +613,21 @@ export function prepareDraftManifest(root: string, project: JsonObject, id: stri
608
613
  if (existsSync(join(root, path))) {
609
614
  const existing = publicationFile(root, path);
610
615
  if (!existing.equals(bytes)) {
611
- if (!existing.equals(legacyPanelsBytes) && !existing.equals(legacyBytes)) {
612
- refuse('JOB_PUBLICATION_INVALID', `drafts[${id}]: retained publication manifest differs; restore it before reconciling`);
616
+ let orphaned = false;
617
+ try {
618
+ const retained: unknown = JSON.parse(existing.toString('utf8'));
619
+ if (isObject(retained as Json)) {
620
+ const prior = retained as JsonObject;
621
+ const revisions = asArray(project.drafts).filter(isObject).map(item => jobHash(`${JSON.stringify(item, null, 2)}\n`));
622
+ orphaned = prior.schema === 'rig-review/1' && prior.id === `create-draft-${id}`
623
+ && typeof prior.revision === 'string' && /^[a-f0-9]{64}$/.test(prior.revision)
624
+ && !revisions.includes(prior.revision)
625
+ && !pendingPublications(root).some(entry => !isRecordResult(entry.result)
626
+ && entry.result.path === path && entry.versionHash === prior.revision);
627
+ }
628
+ } catch { /* An unreadable manifest is never evidence of an orphaned revision. */ }
629
+ if (!existing.equals(legacyPanelsBytes) && !existing.equals(legacyBytes) && !orphaned) {
630
+ refuse('JOB_PUBLICATION_INVALID', `drafts[${id}]: retained publication manifest differs; restore it before reconciling, or run rig-create job close --retire-refused to retire a refused publication`);
613
631
  }
614
632
  const target = join(root, path);
615
633
  const tmp = `${target}.${process.pid}-${crypto.randomUUID()}.tmp`;
@@ -17,7 +17,7 @@ export function publicationFailure(error: unknown, root: string): Refusal | unde
17
17
  }
18
18
 
19
19
  async function run(): Promise<void> {
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 };
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; retireRefused?: boolean; json?: boolean };
21
21
  const root = resolve(input.root);
22
22
  switch (process.argv[2]) {
23
23
  case 'publish': {
@@ -43,7 +43,7 @@ async function run(): Promise<void> {
43
43
  break;
44
44
  }
45
45
  case 'reconcile': console.log(`job: reconciled ${await reconcileCreateJob(root, undefined, input.now)} publication(s)`); break;
46
- case 'close': await closeCreateJob(root); console.log('job: closed'); break;
46
+ case 'close': await closeCreateJob(root, undefined, input.retireRefused); console.log('job: closed'); break;
47
47
  case 'open': {
48
48
  if (!input.reviewer || !input.role) refuse('JOB_BINDING_INVALID', 'project.json: job open needs --reviewer and --role owner|delegate');
49
49
  requestedRecordResult(root, input.question, input.plan, input.now);
package/src/next.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { optionFile } from './choices.ts';
2
+ import { ownerChatSignoffAllowed } from './owner-chat.ts';
1
3
  import { checkpointEnabled, checkpointRequired } from './plan-history.ts';
2
4
  import { createHash } from 'node:crypto';
3
5
  import { existsSync, readFileSync } from './check-reads.ts';
@@ -238,7 +240,24 @@ function reviewCommands(dir: string, project: JsonObject, move: NextMove): NextM
238
240
  role: 'agent', field: move.field, awaits: move.awaits, command, act: description,
239
241
  });
240
242
  const root = shellWord(dir);
243
+ const chatMoves: NextMove[] = [];
244
+ const chatFlags = "--by '<owner>' --owner-chat '<words file>' --said-at '<ISO date>' --relayed-by '<operator>'";
245
+ if (act.kind === 'signoff' && ownerChatSignoffAllowed(dir, project, act.stage, act.draft_id, act.version_id?.slice(1))) {
246
+ const version = act.stage === 'rig-review' || act.stage === 'direction-review' ? ` --version ${shellWord(act.version_id?.slice(1) ?? '<current-version>')}` : '';
247
+ chatMoves.push({...move, command:`rig-create ${writer} ${root}${version} ${chatFlags}`, act:`${move.act}; or relay the owner chat signoff with exact words, owner date and relay identity`, review_act:undefined});
248
+ } else if (act.kind === 'interview-picks' && act.question_id) {
249
+ const item = collectItems(project, {collection:loadCollection(dir)}).find(item => item.id === act.question_id);
250
+ const binding = item?.record.pending_options;
251
+ if (isObject(binding) && typeof binding.path === 'string' && typeof binding.sha256 === 'string') {
252
+ try {
253
+ const {set} = optionFile(dir,binding);
254
+ const picks = asArray(set.asks).filter(isObject).map(ask => ` --pick ${shellWord(`${String(ask.id)}=<option-id>`)}`).join('');
255
+ chatMoves.push({...move, command:`rig-create ${writer} ${root} --set ${shellWord(binding.path)} --sha256 ${shellWord(binding.sha256)} ${chatFlags}${picks}`, act:`${move.act}; or relay the owner chat answer and explicit option ids from the displayed set`, review_act:undefined});
256
+ } catch { /* The check's named option-file refusal supplies the repair route. */ }
257
+ }
258
+ }
241
259
  return [
260
+ ...chatMoves,
242
261
  agent(`rig-create job open ${root} ${selector} --reviewer '<reviewer>' --role owner`, 'host this Review page; keep the host running in a separate terminal until job close'),
243
262
  move,
244
263
  agent(`rig-create job acts ${root} --json`, `after the person submits in Review, list exported acts and use the matching actId below${act.stage === 'parts-review' ? '; run its reported preconditions before ingestion (parts-review signoff includes structure-snapshot)' : ''}`),
@@ -0,0 +1,67 @@
1
+ /** Relayed words retain their own clock; at remains the writer's clock. */
2
+ import { asArray, isObject, type Finding } from './check.ts';
3
+ import { latestPartsReceipt, latestPartsVerdictFinding, recordedPartsVerdict } from './parts-verdict-record.ts';
4
+ import { decodeReviewAct } from './review-act.ts';
5
+ import { readVerbatim } from './record-input.ts';
6
+ import { acceptedReviewVerdict, readVersions } from './review.ts';
7
+ import { collectReopens, liveSignoff } from './reopen.ts';
8
+ import { refuse, USAGE_EXIT } from './refusal.ts';
9
+ import type { JsonObject } from './templates.ts';
10
+
11
+ export const OWNER_CHAT_SOURCE = 'owner chat, relayed';
12
+ export const OWNER_CHAT_FLAGS = ['--owner-chat', '--said-at', '--relayed-by'];
13
+ const nonblank = (value: unknown): value is string => typeof value === 'string' && value.trim() !== '';
14
+
15
+ export function ownerChatFinding(record: JsonObject, path: string): Finding | undefined {
16
+ if (record.source !== OWNER_CHAT_SOURCE && record.said_at === undefined && record.relayed_by === undefined && record.owner_words === undefined) return;
17
+ const said = record.said_at;
18
+ if (record.source !== OWNER_CHAT_SOURCE || !nonblank(record.owner_words ?? record.verbatim)
19
+ || !nonblank(record.by) || record.by.toLowerCase() === 'agent' || !nonblank(record.relayed_by)
20
+ || typeof said !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(said)
21
+ || !Number.isFinite(Date.parse(said)) || !Number.isFinite(Date.parse(String(record.at)))
22
+ || Date.parse(said) > Date.parse(String(record.at)) || record.actId !== undefined || record.act_id !== undefined) {
23
+ return {code:'OWNER_CHAT_INVALID',path,message:`${path}: relayed owner chat needs exact nonblank words, owner identity, ISO said_at, relayed_by and a separate write at without an act id; record the owner's words with --owner-chat --said-at --relayed-by`};
24
+ }
25
+ }
26
+
27
+ export function readOwnerChat(opts: Record<string,string>, by: string, now: number, path: string): JsonObject | undefined {
28
+ if (!OWNER_CHAT_FLAGS.some(flag => opts[flag] !== undefined)) return;
29
+ if (OWNER_CHAT_FLAGS.some(flag => !nonblank(opts[flag]))) refuse('OWNER_CHAT_INVALID', `${path}: --owner-chat, --said-at and --relayed-by are required together; supply the words file and explicit owner clock and relay`, USAGE_EXIT);
30
+ if (opts['--from-review'] !== undefined || opts['--verbatim-file'] !== undefined || opts['--source'] !== undefined) refuse('OWNER_CHAT_INVALID', `${path}: owner chat excludes --from-review, --verbatim-file and --source; retain the relayed source`, USAGE_EXIT);
31
+ const record: JsonObject = {by, at:new Date(now).toISOString(), owner_words:readVerbatim(opts['--owner-chat']!),
32
+ said_at:opts['--said-at']!,relayed_by:opts['--relayed-by']!,source:OWNER_CHAT_SOURCE};
33
+ const finding = ownerChatFinding(record,path);
34
+ if (finding) refuse(finding.code,finding.message);
35
+ return record;
36
+ }
37
+
38
+ /** Select the current subject as next does, then require an accepted verdict still live at this review stage. */
39
+ export function ownerChatSignoffAllowed(dir: string, project: JsonObject, stage: string, draft?: string, version?: string): boolean {
40
+ const reopens = collectReopens(project,[]);
41
+ if (stage === 'parts-review') {
42
+ const current = asArray(project.drafts).filter(isObject).at(-1);
43
+ if (!current || typeof current.id !== 'string' || (draft !== undefined && draft !== current.id)) return false;
44
+ const receipt = latestPartsReceipt(project,current.id);
45
+ if (!receipt || latestPartsVerdictFinding(project,current.id,'signoffs.draft_id')) return false;
46
+ try {
47
+ const at = receipt.schema === 'rig-create/review-act@1' ? decodeReviewAct(String(receipt.bytes),'parts-verdict').at : receipt.at;
48
+ if (!Number.isFinite(Date.parse(String(at))) || !liveSignoff(reopens,'parts-review',Date.parse(String(at)))) return false;
49
+ recordedPartsVerdict(dir,project,current.id); return true;
50
+ } catch { return false; }
51
+ }
52
+ if (stage !== 'rig-review' && stage !== 'direction-review') return true;
53
+ const phase = stage === 'rig-review' ? 'rigging' : 'post';
54
+ const current = asArray(project.versions).filter(isObject).filter(v => v.kind === (stage === 'rig-review' ? 'rig' : 'post')
55
+ && (stage === 'rig-review' || liveSignoff(reopens,phase,Date.parse(String(v.at))))).at(-1);
56
+ if (!current || (version !== undefined && String(current.n) !== version)) return false;
57
+ if (stage === 'rig-review' && !readVersions(project).rigs.some(rig => rig.n === current.n && rig.live)) return false;
58
+ const index = acceptedReviewVerdict(current,stage);
59
+ const verdict = asArray(current.verdicts)[index];
60
+ return isObject(verdict) && liveSignoff(reopens,stage,Date.parse(String(verdict.at)));
61
+ }
62
+
63
+ export function assertOwnerChatSignoff(dir: string, project: JsonObject, stage: string, draft?: string, version?: string): void {
64
+ if (ownerChatSignoffAllowed(dir,project,stage,draft,version)) return;
65
+ const route = stage === 'parts-review' ? '--draft <current-draft>' : '--rig <current-version>';
66
+ refuse('OWNER_CHAT_GATE_VISUAL', `project.json.signoffs.${stage}: an accepted live verdict for the current subject is required; get the verdict in the review tool first with rig-create job open ${route}, ingest it, then relay the owner's signoff`);
67
+ }
@@ -46,8 +46,13 @@ export class PartsUnreadable extends Error {
46
46
  }
47
47
  }
48
48
 
49
- /** The create repository's own install, used when a project has none (running the check in this repo, or before `bun install` in a project). */
50
- export const OWN_NODE_MODULES = join(import.meta.dir, '..', 'node_modules');
49
+ const load = createRequire(import.meta.url);
50
+
51
+ /** Create's runtime-resolved dependency install, used when a project has none (running the check in this repo, or before `bun install` in a project). */
52
+ const checkoutNodeModules = join(import.meta.dir, '..', 'node_modules');
53
+ export const OWN_NODE_MODULES = existsSync(join(checkoutNodeModules, PARTS_PACKAGE, 'package.json'))
54
+ ? checkoutNodeModules
55
+ : dirname(dirname(dirname(load.resolve(`${PARTS_PACKAGE}/package.json`))));
51
56
 
52
57
  /**
53
58
  * Where the installed package is looked for, in order: the project's own
@@ -67,8 +72,6 @@ export function locateParts(projectDir: string): string | null {
67
72
  return null;
68
73
  }
69
74
 
70
- const load = createRequire(import.meta.url);
71
-
72
75
  function isSections(v: unknown): v is Sections {
73
76
  return Array.isArray(v) && v.length > 0 && v.every((s) => typeof s === 'string');
74
77
  }
package/src/signoff.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { OWNER_CHAT_FLAGS, readOwnerChat, assertOwnerChatSignoff } from './owner-chat.ts';
1
2
  import { latestPartsVerdictFinding, recordedPartsVerdict } from './parts-verdict-record.ts';
2
3
  import { selectAnswerHashes } from './signoff-answers.ts';
3
4
  import { selectSignoffTarget } from './signoff-target.ts';
@@ -34,6 +35,9 @@ export const SIGNOFF_HELP = `signoff:
34
35
  (excludes --by, --verbatim-file and --steps on both routes)
35
36
  --by <who> the person signing (agent is refused)
36
37
  --verbatim-file <file> UTF-8 words unchanged, or - for stdin (default: stdin)
38
+ --owner-chat <file> relay exact owner words (excludes --verbatim-file / --from-review)
39
+ --said-at <ISO date> explicit owner clock; at remains the write time
40
+ --relayed-by <who> operator relaying the owner chat
37
41
  --steps <id,id,...> narrow parts plan, rigging-interview rig steps, or post steps
38
42
  --checkpoint <id> sign an enabled checkpoint in this execution stage
39
43
  --from-review <actId> with --checkpoint: record the passed Review checkpoint act (no --by or --verbatim-file)
@@ -75,7 +79,7 @@ export function signoffCommand(args: string[], now: number, hooks: SignoffHooks
75
79
  i++;
76
80
  } else if (arg.startsWith('--component=') || arg.startsWith('--diff-report=') || arg.startsWith('--triage-file=')) {
77
81
  hasDiffFlag = true;
78
- } else if (['--version', '--draft', '--structure-file', '--by', '--verbatim-file', '--steps', '--checkpoint', '--from-review'].includes(arg)) {
82
+ } else if (['--version', '--draft', '--structure-file', '--by', '--verbatim-file', '--steps', '--checkpoint', '--from-review', ...OWNER_CHAT_FLAGS].includes(arg)) {
79
83
  const value = args[++i];
80
84
  if (arg in opts || value === undefined || value === '') refuse('SIGNOFF_OPTION_INVALID', `${arg} needs one non-empty value`, USAGE_EXIT);
81
85
  opts[arg] = value;
@@ -94,7 +98,7 @@ export function signoffCommand(args: string[], now: number, hooks: SignoffHooks
94
98
  }
95
99
  const actId = opts['--from-review'];
96
100
  if (actId !== undefined) {
97
- const mixed = ['--by', '--verbatim-file', '--steps'].filter(flag => opts[flag] !== undefined);
101
+ const mixed = ['--by', '--verbatim-file', '--steps', ...OWNER_CHAT_FLAGS].filter(flag => opts[flag] !== undefined);
98
102
  if (mixed.length > 0) refuse('SIGNOFF_OPTION_INVALID', `project.json.review_acts: --from-review excludes ${mixed.join(', ')}; the person's identity, words and covered steps come from the review act`, USAGE_EXIT);
99
103
  }
100
104
  if (actId === undefined) return recordSignoff(stage, positional, opts, json, now, hooks);
@@ -114,7 +118,9 @@ function recordSignoff(stage: string, positional: string[], opts: Record<string,
114
118
  if (opts['--by'] === undefined && actId === undefined) refuse('SIGNOFF_OPTION_INVALID', 'signoff needs --by', USAGE_EXIT);
115
119
  if (by.toLowerCase() === 'agent') refuse('SIGNOFF_NOT_HUMAN', `stage ${stage}: signed by agent`);
116
120
  const dir = resolve(positional[1] ?? process.cwd());
121
+ const chat = readOwnerChat(opts, by, now, 'project.json.signoffs');
117
122
  const { file, project } = readProject(dir);
123
+ if (chat) assertOwnerChatSignoff(dir, project, stage, opts['--draft'], opts['--version']);
118
124
  const original = actId === undefined ? undefined : readFileSync(file);
119
125
  let ingest: CheckpointIngest | undefined;
120
126
  // Recorded checkpoint receipts replay before current eligibility, through the same immutable-byte validator.
@@ -224,7 +230,7 @@ function recordSignoff(stage: string, positional: string[], opts: Record<string,
224
230
  : checkpoint.state === 'delegated' && checkpoint.decided && isObject(checkpoint.answer.agent_decision) ? checkpoint.answer.agent_decision.value : undefined;
225
231
  if (typeof value !== 'boolean') refuse('SIGNOFF_CHECKPOINT_INVALID', `${String(step.id)}: checkpoint needs a boolean user answer or a decided boolean delegation`);
226
232
  }
227
- const verbatim = review !== undefined ? String(review.human.verbatim) : ingest ? ingest.verbatim : readVerbatim(opts['--verbatim-file'] ?? '-');
233
+ const verbatim = review !== undefined ? String(review.human.verbatim) : ingest ? ingest.verbatim : chat ? String(chat.owner_words) : readVerbatim(opts['--verbatim-file'] ?? '-');
228
234
  const at = review !== undefined ? String(review.human.at) : ingest ? ingest.at : new Date(now).toISOString();
229
235
  const hashes: JsonObject = Object.create(null) as JsonObject;
230
236
  for (const step of covered) {
@@ -234,6 +240,7 @@ function recordSignoff(stage: string, positional: string[], opts: Record<string,
234
240
  hashes[step.id as string] = stepHash(step);
235
241
  }
236
242
  const signoff: JsonObject = { stage, checkpoint: checkpointId ?? null, by, at, verbatim };
243
+ if (chat) { signoff.source = chat.source!; signoff.said_at = chat.said_at!; signoff.relayed_by = chat.relayed_by!; }
237
244
  if (review !== undefined) signoff.source = review.human.source!;
238
245
  if (evidence !== undefined) signoff.evidence = evidence;
239
246
  if (phase !== undefined) {
@@ -4,7 +4,7 @@ One painting; one rig and its takes.
4
4
 
5
5
  ## Start here
6
6
 
7
- 1. Read `project.json`. Human acts go through Review; ingest exports. Never type human answers/signatures.
7
+ 1. Read `project.json`. Verdicts go through Review; interview picks and signoffs may relay owner words via `--owner-chat --said-at --relayed-by`. Never invent human words.
8
8
  2. Run `rig-create next` to derive the next moves and whose they are.
9
9
  3. Resume per {{RESUME_LINK}}.
10
10
 
@@ -4,7 +4,7 @@ One painting; one rig and its takes.
4
4
 
5
5
  ## Start here
6
6
 
7
- 1. Read `project.json`. Human acts go through Review; ingest exports. Never type human answers/signatures.
7
+ 1. Read `project.json`. Verdicts go through Review; interview picks and signoffs may relay owner words via `--owner-chat --said-at --relayed-by`. Never invent human words.
8
8
  2. Run `rig-create next` to derive the next moves and whose they are.
9
9
  3. Resume per [Resume](https://github.com/rig-c/create/blob/main/docs/PROJECT.md#how-an-agent-resumes-a-project).
10
10
 
@@ -4,7 +4,7 @@ One painting; one rig and its takes.
4
4
 
5
5
  ## Start here
6
6
 
7
- 1. Read `project.json`. Human acts go through Review; ingest exports. Never type human answers/signatures.
7
+ 1. Read `project.json`. Verdicts go through Review; interview picks and signoffs may relay owner words via `--owner-chat --said-at --relayed-by`. Never invent human words.
8
8
  2. Run `rig-create next` to derive the next moves and whose they are.
9
9
  3. Resume per [Resume](https://github.com/rig-c/create/blob/main/docs/PROJECT.md#how-an-agent-resumes-a-project).
10
10
 
@@ -89,7 +89,7 @@
89
89
  },
90
90
  {
91
91
  "path": "AGENTS.md",
92
- "sha256": "e37e5793e9233ff8c685076f42e002fbf614e460a56dedb3e56acead59a824f2"
92
+ "sha256": "143b357c86ac9573219f0e04cdb94170508ee116d91011a70fc7922f806b4ff7"
93
93
  },
94
94
  {
95
95
  "path": "CLAUDE.md",
@@ -4,7 +4,7 @@ One painting; one rig and its takes.
4
4
 
5
5
  ## Start here
6
6
 
7
- 1. Read `project.json`. Human acts go through Review; ingest exports. Never type human answers/signatures.
7
+ 1. Read `project.json`. Verdicts go through Review; interview picks and signoffs may relay owner words via `--owner-chat --said-at --relayed-by`. Never invent human words.
8
8
  2. Run `rig-create next` to derive the next moves and whose they are.
9
9
  3. Resume per [Resume](https://github.com/rig-c/create/blob/main/docs/PROJECT.md#how-an-agent-resumes-a-project).
10
10
 
@@ -31,7 +31,7 @@
31
31
  },
32
32
  {
33
33
  "path": "AGENTS.md",
34
- "sha256": "e37e5793e9233ff8c685076f42e002fbf614e460a56dedb3e56acead59a824f2"
34
+ "sha256": "143b357c86ac9573219f0e04cdb94170508ee116d91011a70fc7922f806b4ff7"
35
35
  },
36
36
  {
37
37
  "path": "CLAUDE.md",
@@ -4,7 +4,7 @@ One painting; one rig and its takes.
4
4
 
5
5
  ## Start here
6
6
 
7
- 1. Read `project.json`. Human acts go through Review; ingest exports. Never type human answers/signatures.
7
+ 1. Read `project.json`. Verdicts go through Review; interview picks and signoffs may relay owner words via `--owner-chat --said-at --relayed-by`. Never invent human words.
8
8
  2. Run `rig-create next` to derive the next moves and whose they are.
9
9
  3. Resume per [Resume](https://github.com/rig-c/create/blob/main/docs/PROJECT.md#how-an-agent-resumes-a-project).
10
10
 
@@ -46,7 +46,7 @@
46
46
  },
47
47
  {
48
48
  "path": "AGENTS.md",
49
- "sha256": "e37e5793e9233ff8c685076f42e002fbf614e460a56dedb3e56acead59a824f2"
49
+ "sha256": "143b357c86ac9573219f0e04cdb94170508ee116d91011a70fc7922f806b4ff7"
50
50
  },
51
51
  {
52
52
  "path": "CLAUDE.md",
@@ -4,7 +4,7 @@ One painting; one rig and its takes.
4
4
 
5
5
  ## Start here
6
6
 
7
- 1. Read `project.json`. Human acts go through Review; ingest exports. Never type human answers/signatures.
7
+ 1. Read `project.json`. Verdicts go through Review; interview picks and signoffs may relay owner words via `--owner-chat --said-at --relayed-by`. Never invent human words.
8
8
  2. Run `rig-create next` to derive the next moves and whose they are.
9
9
  3. Resume per [Resume](https://github.com/rig-c/create/blob/main/docs/PROJECT.md#how-an-agent-resumes-a-project).
10
10
 
@@ -88,7 +88,7 @@
88
88
  },
89
89
  {
90
90
  "path": "AGENTS.md",
91
- "sha256": "e37e5793e9233ff8c685076f42e002fbf614e460a56dedb3e56acead59a824f2"
91
+ "sha256": "143b357c86ac9573219f0e04cdb94170508ee116d91011a70fc7922f806b4ff7"
92
92
  },
93
93
  {
94
94
  "path": "CLAUDE.md",
@@ -4,7 +4,7 @@ One painting; one rig and its takes.
4
4
 
5
5
  ## Start here
6
6
 
7
- 1. Read `project.json`. Human acts go through Review; ingest exports. Never type human answers/signatures.
7
+ 1. Read `project.json`. Verdicts go through Review; interview picks and signoffs may relay owner words via `--owner-chat --said-at --relayed-by`. Never invent human words.
8
8
  2. Run `rig-create next` to derive the next moves and whose they are.
9
9
  3. Resume per [Resume](https://github.com/rig-c/create/blob/main/docs/PROJECT.md#how-an-agent-resumes-a-project).
10
10
 
@@ -34,7 +34,7 @@
34
34
  },
35
35
  {
36
36
  "path": "AGENTS.md",
37
- "sha256": "e37e5793e9233ff8c685076f42e002fbf614e460a56dedb3e56acead59a824f2"
37
+ "sha256": "143b357c86ac9573219f0e04cdb94170508ee116d91011a70fc7922f806b4ff7"
38
38
  },
39
39
  {
40
40
  "path": "CLAUDE.md",
@@ -4,7 +4,7 @@ One painting; one rig and its takes.
4
4
 
5
5
  ## Start here
6
6
 
7
- 1. Read `project.json`. Human acts go through Review; ingest exports. Never type human answers/signatures.
7
+ 1. Read `project.json`. Verdicts go through Review; interview picks and signoffs may relay owner words via `--owner-chat --said-at --relayed-by`. Never invent human words.
8
8
  2. Run `rig-create next` to derive the next moves and whose they are.
9
9
  3. Resume per [Resume](https://github.com/rig-c/create/blob/main/docs/PROJECT.md#how-an-agent-resumes-a-project).
10
10
 
@@ -54,7 +54,7 @@
54
54
  },
55
55
  {
56
56
  "path": "AGENTS.md",
57
- "sha256": "e37e5793e9233ff8c685076f42e002fbf614e460a56dedb3e56acead59a824f2"
57
+ "sha256": "143b357c86ac9573219f0e04cdb94170508ee116d91011a70fc7922f806b4ff7"
58
58
  },
59
59
  {
60
60
  "path": "CLAUDE.md",
@@ -4,7 +4,7 @@ One painting; one rig and its takes.
4
4
 
5
5
  ## Start here
6
6
 
7
- 1. Read `project.json`. Human acts go through Review; ingest exports. Never type human answers/signatures.
7
+ 1. Read `project.json`. Verdicts go through Review; interview picks and signoffs may relay owner words via `--owner-chat --said-at --relayed-by`. Never invent human words.
8
8
  2. Run `rig-create next` to derive the next moves and whose they are.
9
9
  3. Resume per [Resume](https://github.com/rig-c/create/blob/main/docs/PROJECT.md#how-an-agent-resumes-a-project).
10
10