filegrc 0.4.0 → 0.5.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.
package/src/cli.js CHANGED
@@ -4,6 +4,17 @@ import { createInterface } from "node:readline/promises";
4
4
  import { loadModel } from "../model/index.js";
5
5
  import { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldResourceMutation } from "./agent.js";
6
6
  import { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
7
+ import { createNextAuditCycle, planNextAuditCycle } from "./audit-transition.js";
8
+ import {
9
+ applyApplicabilityReview,
10
+ planApplicabilityReview,
11
+ scaffoldApplicabilityReview
12
+ } from "./batch-review.js";
13
+ import {
14
+ applyCollectionReview,
15
+ planCollectionReview,
16
+ scaffoldCollectionReview
17
+ } from "./collection-review.js";
7
18
  import { buildWorkspace } from "./build.js";
8
19
  import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
9
20
  import {
@@ -21,11 +32,18 @@ import {
21
32
  completeObligationEvent,
22
33
  completeObligationOccurrence,
23
34
  createObligationEvent,
24
- planObligations
35
+ planObligations,
36
+ scaffoldObligationCompletion
25
37
  } from "./obligations.js";
38
+ import {
39
+ planExternalReviewerGovernance,
40
+ scaffoldExternalReviewerGovernance,
41
+ setupExternalReviewerGovernance
42
+ } from "./external-reviewer.js";
26
43
  import { relativeToWorkspace, resolveDataPath } from "./paths.js";
27
44
  import { buildAgentProgramPath } from "./program-path.js";
28
45
  import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
46
+ import { applyReconciliation, planReconciliation } from "./reconciliation.js";
29
47
  import { markdownEntries } from "./resource-markdown.js";
30
48
  import { effectiveResourceStatus } from "./resource-status.js";
31
49
  import { searchResources } from "./search.js";
@@ -36,9 +54,16 @@ import { createAppState } from "./state.js";
36
54
  import { currentCalendarDate } from "./time.js";
37
55
  import { validateWorkspace } from "./validate.js";
38
56
  import { loadWorkspace } from "./workspace.js";
57
+ import {
58
+ assessWorkflow,
59
+ buildWorkflowDelta,
60
+ previewWorkflowMutation,
61
+ workflowForResource
62
+ } from "./workflow.js";
39
63
 
40
64
  const BOOLEAN_FLAGS = new Set([
41
65
  "allow-non-authoritative-writes",
66
+ "apply",
42
67
  "check-docs",
43
68
  "complete",
44
69
  "current",
@@ -49,7 +74,10 @@ const BOOLEAN_FLAGS = new Set([
49
74
  "next",
50
75
  "preview",
51
76
  "require-ready",
77
+ "require-healthy",
78
+ "scaffold",
52
79
  "summary",
80
+ "workflow",
53
81
  "write-docs",
54
82
  "yes"
55
83
  ]);
@@ -98,7 +126,7 @@ export async function runCli(argv = process.argv.slice(2)) {
98
126
  });
99
127
  const result = flags.preview
100
128
  ? await planWorkspaceSetup(root, setupInput)
101
- : await setupWorkspace(root, setupInput);
129
+ : await withWorkflowDelta(root, () => setupWorkspace(root, setupInput));
102
130
  const output = flags.summary && !flags.preview ? summarizeSetupResult(result) : result;
103
131
  if (flags.json) console.log(JSON.stringify(output, null, 2));
104
132
  else if (flags.preview) {
@@ -148,14 +176,15 @@ export async function runCli(argv = process.argv.slice(2)) {
148
176
  }
149
177
  if (command === "migrate") {
150
178
  const targetModel = String(flags["to-model"] || "");
151
- if (targetModel !== "2") throw new Error("Pass --to-model 2.");
179
+ if (!["2", "3"].includes(targetModel)) throw new Error("Pass --to-model 2 or --to-model 3.");
152
180
  const options = {
153
181
  jobTitle: flags["job-title"],
154
- startsOn: flags["starts-on"]
182
+ startsOn: flags["starts-on"],
183
+ targetModelVersion: targetModel
155
184
  };
156
185
  const plan = await planModelMigration(root, options);
157
186
  if (!flags.preview && plan.sourceModelVersion !== plan.targetModelVersion && !flags.yes) {
158
- throw new Error("Review migrate --to-model 2 --preview --json, then pass --yes to apply the migration.");
187
+ throw new Error(`Review migrate --to-model ${targetModel} --preview --json, then pass --yes to apply the migration.`);
159
188
  }
160
189
  const result = flags.preview
161
190
  ? plan
@@ -215,6 +244,71 @@ export async function runCli(argv = process.argv.slice(2)) {
215
244
  else printProgramPathOutput(output, flags);
216
245
  return output;
217
246
  }
247
+ if (command === "workflow") {
248
+ const result = await assessWorkflow(root, {
249
+ auditId: positionals[0] || flags.audit,
250
+ asOf: flags["as-of"],
251
+ through: flags.through,
252
+ includeComplete: Boolean(flags.complete)
253
+ });
254
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
255
+ else printWorkflow(result);
256
+ if (flags["require-ready"] && result.assessments.evidenceReadiness.status !== "complete") {
257
+ process.exitCode = 2;
258
+ }
259
+ return result;
260
+ }
261
+ if (command === "period-health") {
262
+ const coverage = flags.start || flags.end
263
+ ? { kind: "range", startsOn: flags.start, endsOn: flags.end }
264
+ : undefined;
265
+ const result = await assessWorkflow(root, {
266
+ auditId: positionals[0] || flags.audit,
267
+ asOf: flags["as-of"],
268
+ through: flags.end || flags.through,
269
+ coverage
270
+ });
271
+ const output = {
272
+ contractVersion: result.contractVersion,
273
+ dataModelVersion: result.dataModelVersion,
274
+ evaluatedAt: result.evaluatedAt,
275
+ input: result.input,
276
+ assessment: result.assessments.periodHealth,
277
+ findings: result.findings.filter(({ assessment }) => assessment === "period-health"),
278
+ workItems: result.workItems.filter((item) => (
279
+ ["overdue", "due", "scheduled", "blocked"].includes(item.state)
280
+ )),
281
+ recommended: result.recommended
282
+ };
283
+ if (flags.json) console.log(JSON.stringify(output, null, 2));
284
+ else {
285
+ console.log(`${output.assessment.status.toUpperCase()}: ${output.assessment.message}`);
286
+ for (const finding of output.findings) {
287
+ console.log(`${finding.state.toUpperCase()}\t${finding.title}\t${finding.message}`);
288
+ }
289
+ }
290
+ if (flags["require-healthy"] && output.assessment.status !== "complete") process.exitCode = 2;
291
+ return output;
292
+ }
293
+ if (command === "milestone-check") {
294
+ const loaded = await loadWorkspace(root);
295
+ const result = await assessWorkflow(loaded, { asOf: flags["as-of"] });
296
+ const target = loaded.workspace?.assuranceGoal === "none"
297
+ ? "structuralValidity"
298
+ : loaded.workspace?.candidateCoverage
299
+ ? "periodHealth"
300
+ : "evidenceReadiness";
301
+ const output = {
302
+ milestone: target,
303
+ assessment: result.assessments[target],
304
+ findingKeys: result.assessments[target].findingKeys,
305
+ evaluatedAt: result.evaluatedAt
306
+ };
307
+ if (flags.json) console.log(JSON.stringify(output, null, 2));
308
+ else console.log(`${target}: ${output.assessment.status.toUpperCase()} · ${output.assessment.message}`);
309
+ if (output.assessment.status !== "complete") process.exitCode = 2;
310
+ return output;
311
+ }
218
312
  if (command === "scaffold") {
219
313
  const loaded = await loadWorkspace(root);
220
314
  const type = positionals[0];
@@ -236,9 +330,15 @@ export async function runCli(argv = process.argv.slice(2)) {
236
330
  ? { ...record, effectiveStatus }
237
331
  : record;
238
332
  });
239
- if (flags.json) console.log(JSON.stringify(records, null, 2));
333
+ const output = flags.workflow
334
+ ? {
335
+ records,
336
+ workflow: await assessWorkflow(loaded, { asOf })
337
+ }
338
+ : records;
339
+ if (flags.json) console.log(JSON.stringify(output, null, 2));
240
340
  else for (const record of records) console.log(`${record.id}\t${record.type}\t${record.effectiveStatus ?? record.status ?? ""}\t${record.title}`);
241
- return records;
341
+ return output;
242
342
  }
243
343
  if (command === "search") {
244
344
  const loaded = await loadWorkspace(root);
@@ -260,7 +360,7 @@ export async function runCli(argv = process.argv.slice(2)) {
260
360
  });
261
361
  if (flags.json) console.log(JSON.stringify(result, null, 2));
262
362
  else {
263
- console.log(`${result.counts.overdue} overdue, ${result.counts.due} due, ${result.counts.upcoming} upcoming, ${result.counts.proposed} starter proposals`);
363
+ console.log(`${result.counts.overdue} overdue, ${result.counts.blocked} blocked, ${result.counts.due} due, ${result.counts.upcoming} upcoming, ${result.counts.proposed} starter proposals`);
264
364
  for (const item of result.items) {
265
365
  const deadline = item.dueWindowEndAt || item.dueWindowEnd;
266
366
  if (!deadline) throw new Error(`Planned work "${item.title}" is missing a deadline.`);
@@ -269,7 +369,12 @@ export async function runCli(argv = process.argv.slice(2)) {
269
369
  item.dueWindowStartAt || item.dueWindowStart,
270
370
  deadline,
271
371
  item.title,
272
- item.actionItemId || item.obligationId
372
+ item.actionItemId || item.obligationId,
373
+ item.actionItemId
374
+ ? item.status === "blocked"
375
+ ? `filegrc get ${item.actionItemId} --mutation`
376
+ : `filegrc complete-action ${item.actionItemId} --scaffold --completed-on YYYY-MM-DD`
377
+ : `filegrc complete ${item.obligationId} --scaffold --window-start ${item.dueWindowStart} --completed-on YYYY-MM-DD`
273
378
  ].join("\t"));
274
379
  }
275
380
  if (result.triggers.length) {
@@ -360,19 +465,124 @@ export async function runCli(argv = process.argv.slice(2)) {
360
465
  if (command === "prepare-audit") {
361
466
  const auditId = positionals[0] || flags.audit;
362
467
  if (!auditId) throw new Error("An audit ID is required.");
363
- const result = await prepareAuditWorkspace(root, { auditId });
468
+ const result = await withWorkflowDelta(root, () => prepareAuditWorkspace(root, { auditId }));
364
469
  if (flags.json) console.log(JSON.stringify(result, null, 2));
365
470
  else console.log(`Prepared ${result.auditId}: linked ${result.linkedDocumentIds.length} management documents and created ${result.createdPopulationIds.length} population records.`);
366
471
  return result;
367
472
  }
473
+ if (command === "reconcile") {
474
+ const result = flags.apply
475
+ ? await withWorkflowDelta(root, () => applyReconciliation(root, {
476
+ candidateId: flags.candidate,
477
+ transitionFingerprint: flags.candidate,
478
+ occurredOn: flags["occurred-on"],
479
+ occurredAt: flags["occurred-at"],
480
+ riskLevel: flags["risk-level"],
481
+ title: flags.title,
482
+ confirmed: flags.yes === true
483
+ }))
484
+ : await planReconciliation(root);
485
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
486
+ else if (flags.apply) {
487
+ console.log(`Reconciled ${result.candidate.eventType}: created ${result.event.id} and ${result.actions.length} linked tasks.`);
488
+ } else if (!result.candidates.length) {
489
+ console.log("No direct-file transitions need confirmation.");
490
+ } else {
491
+ for (const candidate of result.candidates) {
492
+ console.log(`${candidate.id}\t${candidate.eventType}\t${candidate.subject.title}\t${candidate.message}`);
493
+ console.log(` ${candidate.action.command}`);
494
+ }
495
+ }
496
+ return result;
497
+ }
498
+ if (command === "external-reviewer-setup") {
499
+ if (flags.scaffold) {
500
+ const result = await scaffoldExternalReviewerGovernance(root);
501
+ console.log(JSON.stringify(result, null, 2));
502
+ return result;
503
+ }
504
+ const payload = await readSetupPayload(positionals[0]);
505
+ const options = {
506
+ ...payload,
507
+ confirmed: flags.yes === true
508
+ };
509
+ const result = flags.preview
510
+ ? await planExternalReviewerGovernance(root, options)
511
+ : await withWorkflowDelta(root, () => setupExternalReviewerGovernance(root, options));
512
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
513
+ else if (flags.preview) {
514
+ console.log(`External reviewer governance preview: ${result.changes.create.length} records to create and ${result.changes.update.length} to update.`);
515
+ } else {
516
+ console.log(`Assigned external reviewer ${result.reviewerId} through ${result.appointmentIds.length} active Appointments.`);
517
+ }
518
+ return result;
519
+ }
520
+ if (command === "next-audit-cycle") {
521
+ const payload = await readSetupPayload(positionals[1]);
522
+ const options = {
523
+ ...payload,
524
+ priorAuditId: positionals[0] || flags.audit || payload.priorAuditId,
525
+ startsOn: flags.start || payload.startsOn,
526
+ endsOn: flags.end || payload.endsOn,
527
+ confirmed: flags.yes === true
528
+ };
529
+ const result = flags.preview
530
+ ? await planNextAuditCycle(root, options)
531
+ : await withWorkflowDelta(root, () => createNextAuditCycle(root, options));
532
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
533
+ else if (flags.preview) {
534
+ console.log(`${result.operation} preview: ${result.audit.title}, ${result.audit.coverage.startsOn} through ${result.audit.coverage.endsOn}.`);
535
+ } else {
536
+ console.log(`Created ${result.audit.id}. Review carried-forward scope and period continuity before fieldwork.`);
537
+ }
538
+ return result;
539
+ }
540
+ if (command === "review-applicability") {
541
+ if (flags.scaffold) {
542
+ const result = await scaffoldApplicabilityReview(root, { type: flags.type });
543
+ console.log(JSON.stringify(result, null, 2));
544
+ return result;
545
+ }
546
+ const payload = await readSetupPayload(positionals[0]);
547
+ const options = { ...payload, confirmed: flags.yes === true };
548
+ const result = flags.preview
549
+ ? await planApplicabilityReview(root, options)
550
+ : await withWorkflowDelta(root, () => applyApplicabilityReview(root, options));
551
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
552
+ else if (flags.preview) console.log(`Applicability preview: ${result.reviewedIds.length} decisions.`);
553
+ else console.log(`Recorded ${result.reviewedIds.length} reviewed applicability decisions.`);
554
+ return result;
555
+ }
556
+ if (command === "review-collection") {
557
+ const resourceType = positionals[0] || flags.type;
558
+ if (flags.scaffold) {
559
+ const result = await scaffoldCollectionReview(root, { resourceType });
560
+ console.log(JSON.stringify(result, null, 2));
561
+ return result;
562
+ }
563
+ const payload = await readSetupPayload(positionals[1]);
564
+ const options = {
565
+ ...payload,
566
+ resourceType: resourceType || payload.resourceType,
567
+ confirmed: flags.yes === true
568
+ };
569
+ const result = flags.preview
570
+ ? await planCollectionReview(root, options)
571
+ : await withWorkflowDelta(root, () => applyCollectionReview(root, options));
572
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
573
+ else if (flags.preview) console.log(`Collection review preview: ${result.assessment.configuration.title}.`);
574
+ else console.log(`Confirmed ${result.assessment.configuration.title}.`);
575
+ return result;
576
+ }
368
577
  if (command === "trigger") {
369
- const result = await createObligationEvent(root, {
578
+ const result = await withWorkflowDelta(root, () => createObligationEvent(root, {
370
579
  eventType: positionals[0],
371
580
  occurredOn: flags["occurred-on"],
372
581
  occurredAt: flags["occurred-at"],
582
+ riskLevel: flags["risk-level"],
373
583
  subjectResourceIds: String(flags.subject || "").split(",").map((value) => value.trim()).filter(Boolean),
374
584
  title: flags.title
375
- });
585
+ }));
376
586
  if (flags.json) console.log(JSON.stringify(result, null, 2));
377
587
  else {
378
588
  console.log(`Work added to the Work Queue: ${result.actions.length} ${result.actions.length === 1 ? "task" : "tasks"} created for ${result.event.title}.`);
@@ -432,8 +642,14 @@ export async function runCli(argv = process.argv.slice(2)) {
432
642
  console.log(JSON.stringify(mutation, null, 2));
433
643
  return mutation;
434
644
  }
435
- console.log(JSON.stringify(record, null, 2));
436
- return record;
645
+ const output = flags.workflow
646
+ ? {
647
+ record,
648
+ workflow: workflowForResource(await assessWorkflow(loaded), record.type, record.id)
649
+ }
650
+ : record;
651
+ console.log(JSON.stringify(output, null, 2));
652
+ return output;
437
653
  }
438
654
  if (command === "references") {
439
655
  const loaded = await loadWorkspace(root);
@@ -447,36 +663,65 @@ export async function runCli(argv = process.argv.slice(2)) {
447
663
  }
448
664
  return result;
449
665
  }
666
+ if (command === "preview-mutation") {
667
+ const payload = await readSetupPayload(positionals[0]);
668
+ const result = await previewWorkflowMutation(root, payload);
669
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
670
+ else {
671
+ console.log(`${result.operation.toUpperCase()} preview for ${result.target.type}/${result.target.id}`);
672
+ console.log(`${result.workflowDelta.findings.added.length} findings added, ${result.workflowDelta.findings.removed.length} resolved, ${result.workflowDelta.findings.changed.length} changed.`);
673
+ if (result.workflow.recommended) console.log(`Next: ${result.workflow.recommended.title}`);
674
+ console.log("No workspace files were changed.");
675
+ }
676
+ return result;
677
+ }
450
678
  if (command === "create") {
451
679
  const mutation = await readMutation(positionals[0]);
452
- const result = await createResource(root, mutation.record, { content: mutation.content });
453
- if (flags.json) console.log(JSON.stringify({ record: result.record }, null, 2));
680
+ const result = await withWorkflowDelta(root, () => createResource(root, mutation.record, { content: mutation.content }));
681
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
454
682
  else console.log(`Created ${result.record.type}/${result.record.id}`);
455
683
  return result;
456
684
  }
457
685
  if (command === "complete") {
458
686
  const [obligationId, file] = positionals;
687
+ if (flags.scaffold) {
688
+ const result = await scaffoldObligationCompletion(root, {
689
+ obligationId,
690
+ windowStart: flags["window-start"],
691
+ completedOn: flags["completed-on"]
692
+ });
693
+ console.log(JSON.stringify(result, null, 2));
694
+ return result;
695
+ }
459
696
  const mutation = await readMutation(file);
460
- const result = await completeObligationOccurrence(root, {
697
+ const result = await withWorkflowDelta(root, () => completeObligationOccurrence(root, {
461
698
  obligationId,
462
699
  record: mutation.record,
463
700
  content: mutation.content,
464
- expectedRevision: requireExpectedRevision(flags, `obligation/${obligationId}`)
465
- });
701
+ expectedRevision: expectedRevision(flags, mutation, `obligation/${obligationId}`)
702
+ }));
466
703
  if (flags.json) console.log(JSON.stringify(result, null, 2));
467
704
  else console.log(`Created ${result.created.type}/${result.created.id} and linked it to obligation/${obligationId}`);
468
705
  return result;
469
706
  }
470
707
  if (command === "complete-action") {
471
708
  const [actionItemId, file] = positionals;
709
+ if (flags.scaffold) {
710
+ const result = await scaffoldObligationCompletion(root, {
711
+ actionItemId,
712
+ completedOn: flags["completed-on"]
713
+ });
714
+ console.log(JSON.stringify(result, null, 2));
715
+ return result;
716
+ }
472
717
  const mutation = await readMutation(file);
473
- const result = await completeObligationAction(root, {
718
+ const result = await withWorkflowDelta(root, () => completeObligationAction(root, {
474
719
  actionItemId,
475
720
  completedOn: flags["completed-on"],
476
721
  record: mutation.record,
477
722
  content: mutation.content,
478
- expectedRevision: requireExpectedRevision(flags, `action-item/${actionItemId}`)
479
- });
723
+ expectedRevision: expectedRevision(flags, mutation, `action-item/${actionItemId}`)
724
+ }));
480
725
  if (flags.json) console.log(JSON.stringify(result, null, 2));
481
726
  else console.log(`Created ${result.created.type}/${result.created.id}, linked it to action-item/${actionItemId}, and marked the action done.`);
482
727
  return result;
@@ -484,25 +729,25 @@ export async function runCli(argv = process.argv.slice(2)) {
484
729
  if (command === "complete-event") {
485
730
  const eventId = positionals[0];
486
731
  if (!eventId) throw new Error("A Policy Event ID is required.");
487
- const result = await completeObligationEvent(root, {
732
+ const result = await withWorkflowDelta(root, () => completeObligationEvent(root, {
488
733
  eventId,
489
734
  completedOn: flags["completed-on"],
490
735
  expectedRevision: requireExpectedRevision(flags, `obligation-event/${eventId}`)
491
- });
492
- if (flags.json) console.log(JSON.stringify({ record: result.record }, null, 2));
736
+ }));
737
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
493
738
  else console.log(`Marked obligation-event/${eventId} complete.`);
494
739
  return result;
495
740
  }
496
741
  if (command === "update") {
497
742
  const [type, id, file] = positionals;
498
743
  const mutation = await readMutation(file, { requireRevision: true });
499
- const result = await updateResource(root, type, id, mutation.record, {
744
+ const result = await withWorkflowDelta(root, () => updateResource(root, type, id, mutation.record, {
500
745
  content: mutation.content,
501
746
  expectedRevision: mutation.revision,
502
747
  expectedContentRevisions: mutation.contentRevisions,
503
748
  requireExpectedContentRevisions: true
504
- });
505
- if (flags.json) console.log(JSON.stringify({ record: result.record }, null, 2));
749
+ }));
750
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
506
751
  else console.log(`Updated ${result.record.type}/${result.record.id}`);
507
752
  return result;
508
753
  }
@@ -522,14 +767,21 @@ export async function runCli(argv = process.argv.slice(2)) {
522
767
  const state = await createAppState(root);
523
768
  const stateEntry = state.resources.find((item) => item.record.type === type && item.record.id === id);
524
769
  const existingContentRevision = stateEntry.content?.[slot.name]?.revision;
525
- await updateResource(root, type, id, record, {
770
+ const mutationResult = await withWorkflowDelta(root, () => updateResource(root, type, id, record, {
526
771
  content: { [slot.name]: source },
527
772
  expectedRevision: stateEntry.revision,
528
773
  expectedContentRevisions: existingContentRevision
529
774
  ? { [slot.path]: flags["expected-revision"] ?? existingContentRevision }
530
775
  : undefined
531
- });
532
- const result = { type, id, slot: slot.name, path: `data/${slot.path}`, written: true };
776
+ }));
777
+ const result = {
778
+ type,
779
+ id,
780
+ slot: slot.name,
781
+ path: `data/${slot.path}`,
782
+ written: true,
783
+ workflowDelta: mutationResult.workflowDelta
784
+ };
533
785
  if (flags.json) console.log(JSON.stringify(result, null, 2));
534
786
  else console.log(`Updated ${result.path}`);
535
787
  return result;
@@ -549,14 +801,15 @@ export async function runCli(argv = process.argv.slice(2)) {
549
801
  if (command === "attach") {
550
802
  const [evidenceId, sourcePath] = positionals;
551
803
  if (!evidenceId || !sourcePath) throw new Error("An evidence ID and source file are required.");
552
- const result = await addEvidenceAttachment(root, evidenceId, sourcePath, {
804
+ const result = await withWorkflowDelta(root, () => addEvidenceAttachment(root, evidenceId, sourcePath, {
553
805
  name: flags.name,
554
806
  expectedRevision: requireExpectedRevision(flags, `evidence/${evidenceId}`)
555
- });
807
+ }));
556
808
  const output = {
557
809
  evidenceId,
558
810
  path: `data/${result.dataRelativePath}`,
559
- filePaths: result.record.filePaths
811
+ filePaths: result.record.filePaths,
812
+ workflowDelta: result.workflowDelta
560
813
  };
561
814
  if (flags.json) console.log(JSON.stringify(output, null, 2));
562
815
  else console.log(`Attached ${output.path} to evidence/${evidenceId}`);
@@ -566,13 +819,14 @@ export async function runCli(argv = process.argv.slice(2)) {
566
819
  const [evidenceId, attachment] = positionals;
567
820
  if (!evidenceId || !attachment) throw new Error("An evidence ID and attachment name are required.");
568
821
  if (!flags.yes) throw new Error("Pass --yes to confirm attachment removal.");
569
- const result = await removeEvidenceAttachment(root, evidenceId, attachment, {
822
+ const result = await withWorkflowDelta(root, () => removeEvidenceAttachment(root, evidenceId, attachment, {
570
823
  expectedRevision: requireExpectedRevision(flags, `evidence/${evidenceId}`)
571
- });
824
+ }));
572
825
  const output = {
573
826
  evidenceId,
574
827
  removed: `data/${result.dataRelativePath}`,
575
- filePaths: result.record.filePaths ?? []
828
+ filePaths: result.record.filePaths ?? [],
829
+ workflowDelta: result.workflowDelta
576
830
  };
577
831
  if (flags.json) console.log(JSON.stringify(output, null, 2));
578
832
  else console.log(`Detached and removed ${output.removed} from evidence/${evidenceId}`);
@@ -581,11 +835,13 @@ export async function runCli(argv = process.argv.slice(2)) {
581
835
  if (command === "delete") {
582
836
  const [type, id] = positionals;
583
837
  if (!flags.yes) throw new Error("Pass --yes to confirm deletion. Preserve historical records unless this is a mistake or uncommitted draft.");
584
- await deleteResource(root, type, id, {
838
+ const result = await withWorkflowDelta(root, () => deleteResource(root, type, id, {
585
839
  expectedRevision: requireExpectedRevision(flags, `${type}/${id}`)
586
- });
587
- console.log(`Deleted ${type}/${id}`);
588
- return;
840
+ }));
841
+ const output = { deleted: true, type, id, workflowDelta: result.workflowDelta };
842
+ if (flags.json) console.log(JSON.stringify(output, null, 2));
843
+ else console.log(`Deleted ${type}/${id}`);
844
+ return output;
589
845
  }
590
846
  throw new Error(`Unknown command "${command}". Run filegrc help.`);
591
847
  }
@@ -611,6 +867,16 @@ function parseArgs(args) {
611
867
  return { positionals, flags };
612
868
  }
613
869
 
870
+ async function withWorkflowDelta(root, task) {
871
+ const before = await assessWorkflow(root);
872
+ const result = await task();
873
+ const after = await assessWorkflow(root);
874
+ return {
875
+ ...result,
876
+ workflowDelta: buildWorkflowDelta(before, after)
877
+ };
878
+ }
879
+
614
880
  async function readMutation(path, options = {}) {
615
881
  if (!path) throw new Error("A JSON file path or - is required.");
616
882
  const source = path === "-" ? await readStdin() : await readFile(resolve(path), "utf8");
@@ -625,6 +891,14 @@ function requireExpectedRevision(flags, target) {
625
891
  return revision;
626
892
  }
627
893
 
894
+ function expectedRevision(flags, mutation, target) {
895
+ const revision = flags["expected-revision"] || mutation.revision;
896
+ if (typeof revision !== "string" || revision.length === 0) {
897
+ throw new Error(`A mutation revision or --expected-revision is required when changing ${target}. Reload the resource and try again.`);
898
+ }
899
+ return revision;
900
+ }
901
+
628
902
  async function readSetupPayload(path) {
629
903
  if (!path) return {};
630
904
  const source = path === "-" ? await readStdin() : await readFile(resolve(path), "utf8");
@@ -715,26 +989,38 @@ Usage:
715
989
  filegrc build [root] [--output .filegrc/site]
716
990
  filegrc validate [root] [--json]
717
991
  filegrc model [--json|--write-docs|--check-docs]
718
- filegrc migrate --to-model 2 [--preview] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
992
+ filegrc migrate --to-model <2|3> [--preview] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
719
993
  filegrc describe <resource-type>
720
994
  filegrc types [--json]
721
995
  filegrc guide [resource-type] [--id resource-id] [--json]
722
996
  filegrc program-path [audit-id] [--as-of YYYY-MM-DD] [--summary|--next|--current] [--json]
997
+ filegrc workflow [audit-id] [--as-of YYYY-MM-DD] [--through YYYY-MM-DD] [--complete] [--require-ready] [--json]
998
+ filegrc period-health [audit-id] [--start YYYY-MM-DD --end YYYY-MM-DD] [--as-of YYYY-MM-DD] [--require-healthy] [--json]
999
+ filegrc milestone-check [--as-of YYYY-MM-DD] [--json]
723
1000
  filegrc scaffold <resource-type> --title text [--id resource-id]
724
- filegrc list [resource-type] [--json]
1001
+ filegrc list [resource-type] [--workflow] [--json]
725
1002
  filegrc search <query> [--type resource-type] [--json]
726
1003
  filegrc obligations [--as-of YYYY-MM-DD] [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--now RFC3339] [--complete] [--json]
727
1004
  filegrc program-readiness [--as-of YYYY-MM-DD] [--require-ready] [--summary] [--json]
728
1005
  filegrc evidence-map [--as-of YYYY-MM-DD] [--json]
729
1006
  filegrc audit-readiness [audit-id] [--require-ready] [--json]
730
1007
  filegrc prepare-audit <audit-id> [--json]
731
- filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--subject resource-id[,resource-id]] [--title text] [--json]
1008
+ filegrc reconcile [--preview|--apply --candidate fingerprint (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) --yes] [--risk-level normal|high] [--json]
1009
+ filegrc external-reviewer-setup --scaffold
1010
+ filegrc external-reviewer-setup <reviewer.json|-> [--preview|--yes] [--json]
1011
+ filegrc next-audit-cycle <prior-audit-id> [cycle.json|-] --start YYYY-MM-DD --end YYYY-MM-DD [--preview|--yes] [--json]
1012
+ filegrc review-applicability [--scaffold --type requirement|control|commitment|complementary-control] [decisions.json|-] [--preview|--yes] [--json]
1013
+ filegrc review-collection <resource-type> [--scaffold | review.json|-] [--preview|--yes] [--json]
1014
+ filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--risk-level normal|high] [--subject resource-id[,resource-id]] [--title text] [--json]
732
1015
  filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
733
1016
  filegrc get [resource-type] <id> [--mutation]
734
1017
  filegrc references <id> [--json]
1018
+ filegrc preview-mutation <preview.json|-> [--json]
735
1019
  filegrc create <mutation.json|-> [--json]
736
- filegrc complete <obligation-id> <completion-record.json|-> --expected-revision hash [--json]
737
- filegrc complete-action <action-item-id> <completion-record.json|-> --completed-on YYYY-MM-DD --expected-revision hash [--json]
1020
+ filegrc complete <obligation-id> --scaffold --window-start YYYY-MM-DD [--completed-on YYYY-MM-DD]
1021
+ filegrc complete <obligation-id> <completion-record.json|-> [--expected-revision hash] [--json]
1022
+ filegrc complete-action <action-item-id> --scaffold [--completed-on YYYY-MM-DD]
1023
+ filegrc complete-action <action-item-id> <completion-record.json|-> --completed-on YYYY-MM-DD [--expected-revision hash] [--json]
738
1024
  filegrc complete-event <obligation-event-id> --completed-on YYYY-MM-DD --expected-revision hash [--json]
739
1025
  filegrc update <resource-type> <id> <mutation.json|-> [--json]
740
1026
  filegrc content <resource-type> <id> [slot] [--write markdown-file|-] [--expected-revision hash] [--json]
@@ -777,7 +1063,7 @@ Options:
777
1063
  --boundary <description> boundary
778
1064
  --owner <person-id> ownerId
779
1065
  --criticality <level> low, medium, high, or critical
780
- --classification <id> classificationId
1066
+ --classification <level> public, internal, confidential, or restricted
781
1067
  --internet-exposed <bool> true or false
782
1068
  --program-goal <goal> none, readiness, type-1, or type-2
783
1069
  --draft Save the service boundary as planned
@@ -790,16 +1076,15 @@ Options:
790
1076
  }
791
1077
  if (command === "migrate") {
792
1078
  console.log(`Usage:
793
- filegrc migrate --to-model 2 [options]
1079
+ filegrc migrate --to-model <2|3> [options]
794
1080
 
795
- Upgrade a model v1 workspace to model v2. The migration moves reverse
796
- relationships to their authoritative records, converts the former Policy Owner
797
- seed role into a dated Appointment, makes repository behavior explicit, rewrites
798
- the moved program-page ID, removes obsolete collection-test fields and per-record
799
- schemaVersion keys, and changes dataModelVersion last. It writes no Git commit.
1081
+ Upgrade a workspace through an explicit, reviewable model boundary. Model v1
1082
+ workspaces migrate to v2 first. Model v2 workspaces migrate to v3 with planned
1083
+ core Appointments, removal of obsolete manual page state, classified review work,
1084
+ and dataModelVersion changed last. The command writes no Git commit.
800
1085
 
801
1086
  Options:
802
- --to-model <version> Required target model; currently 2
1087
+ --to-model <version> Required target model; 2 for legacy v1 migration, 3 for the active model
803
1088
  --preview Show the complete atomic record plan without writing
804
1089
  --job-title <title> Actual job title for the former Policy Owner seed person
805
1090
  --starts-on <date> Effective date of a new Policy Owner Appointment
@@ -809,7 +1094,7 @@ Options:
809
1094
  --help Show this help
810
1095
 
811
1096
  Start with:
812
- npx filegrc migrate --to-model 2 --preview --json`);
1097
+ npx filegrc migrate --to-model 3 --preview --json`);
813
1098
  return;
814
1099
  }
815
1100
  if (command === "program-readiness") {
@@ -829,6 +1114,25 @@ Options:
829
1114
  --help Show this help`);
830
1115
  return;
831
1116
  }
1117
+ if (command === "workflow") {
1118
+ console.log(`Usage:
1119
+ filegrc workflow [audit-id] [options]
1120
+
1121
+ Return the shared assessment envelope used by browser, HTTP, CLI, static, and
1122
+ agent workflows. Results include named assessments, normalized findings,
1123
+ deterministic Work Items, and one recommended next action.
1124
+
1125
+ Options:
1126
+ --audit <id> Limit audit assessments to one engagement
1127
+ --as-of <date> Evaluate on YYYY-MM-DD
1128
+ --through <date> Include scheduled work through YYYY-MM-DD
1129
+ --complete Include completed Work Items
1130
+ --require-ready Exit with code 2 unless Evidence Readiness passes
1131
+ --json Print the versioned result envelope
1132
+ --root <path> Workspace path
1133
+ --help Show this help`);
1134
+ return;
1135
+ }
832
1136
  if (command === "program-path") {
833
1137
  console.log(`Usage:
834
1138
  filegrc program-path [audit-id] [options]
@@ -875,11 +1179,14 @@ function agentOverview(model) {
875
1179
  build: "filegrc build [root]",
876
1180
  validate: "filegrc validate [root] --json",
877
1181
  model: "filegrc model --json",
878
- migrate: "filegrc migrate --to-model 2 --preview --json",
1182
+ migrate: "filegrc migrate --to-model 3 --preview --json",
879
1183
  describe: "filegrc describe <resource-type>",
880
1184
  types: "filegrc types --json",
881
1185
  guide: "filegrc guide [resource-type] --json",
882
1186
  programPath: "filegrc program-path [audit-id] --next --json",
1187
+ workflow: "filegrc workflow [audit-id] --json",
1188
+ periodHealth: "filegrc period-health [audit-id] --require-healthy --json",
1189
+ milestoneCheck: "filegrc milestone-check --json",
883
1190
  scaffold: "filegrc scaffold <resource-type> --title <name>",
884
1191
  list: "filegrc list [resource-type] --json",
885
1192
  search: "filegrc search <query> --json",
@@ -888,10 +1195,16 @@ function agentOverview(model) {
888
1195
  evidenceMap: "filegrc evidence-map --json",
889
1196
  auditReadiness: "filegrc audit-readiness <audit-id> --json",
890
1197
  prepareAudit: "filegrc prepare-audit <audit-id>",
1198
+ reconcile: "filegrc reconcile --preview --json",
1199
+ externalReviewerSetup: "filegrc external-reviewer-setup [--scaffold | <reviewer.json|-> --preview] --json",
1200
+ nextAuditCycle: "filegrc next-audit-cycle <prior-audit-id> --start <date> --end <date> --preview --json",
1201
+ reviewApplicability: "filegrc review-applicability <decisions.json|-> --preview --json",
1202
+ reviewCollection: "filegrc review-collection <resource-type> [--scaffold | <review.json|-> --preview] --json",
891
1203
  trigger: "filegrc trigger <event-type> <date-or-time-and-subject-flags>",
892
1204
  evidencePacket: "filegrc evidence-packet --audit <audit-id> --preview --json",
893
1205
  get: "filegrc get <resource-id> [--mutation]",
894
1206
  references: "filegrc references <resource-id> --json",
1207
+ previewMutation: "filegrc preview-mutation <preview.json> --json",
895
1208
  create: "filegrc create <mutation.json>",
896
1209
  complete: "filegrc complete <obligation-id> <completion-mutation.json>",
897
1210
  completeAction: "filegrc complete-action <action-item-id> <completion-mutation.json> --completed-on <date>",
@@ -934,6 +1247,17 @@ function printAgentGuide(result) {
934
1247
  console.log(`Policy basis: ${result.policyBasis}`);
935
1248
  console.log(`Timing: ${result.cadence}`);
936
1249
  console.log(`JSON: ${result.location}`);
1250
+ if (result.reviewRequirements.collectionReview) {
1251
+ const review = result.reviewRequirements.collectionReview;
1252
+ console.log(`\nCollection review: ${review.title} (${review.status}, ${review.recordCount} ${review.recordCount === 1 ? "record" : "records"})`);
1253
+ console.log(review.description);
1254
+ for (const point of review.reviewPoints) console.log(`- ${point}`);
1255
+ console.log(`Action: ${review.command}`);
1256
+ }
1257
+ if (result.reviewRequirements.recordReviewPoints.length) {
1258
+ console.log("\nWhen reviewing each record:");
1259
+ for (const point of result.reviewRequirements.recordReviewPoints) console.log(`- ${point}`);
1260
+ }
937
1261
  console.log("\nRequired fields:");
938
1262
  for (const field of result.requiredAtCreation) console.log(formatGuideField(field));
939
1263
  if (result.conditionalRequirements.length) {
@@ -980,11 +1304,28 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
980
1304
  const readinessById = new Map(readiness.stages.map((stage) => [stage.id, stage]));
981
1305
  const stages = buildAgentProgramPath(model).map((stage) => {
982
1306
  if (stage.id === "audit") {
1307
+ const auditAction = auditReadiness?.firstAction || (
1308
+ auditReadiness?.status === "not-started"
1309
+ ? {
1310
+ id: "create-audit",
1311
+ status: "action",
1312
+ title: "Create the planned CPA engagement",
1313
+ message: "Create a planned Audit from the current management scope, then replace the remaining scaffold values with the CPA firm and firm-agreed scope and dates.",
1314
+ resourceType: "audit",
1315
+ commands: [
1316
+ "npx filegrc guide audit --json",
1317
+ "npx filegrc scaffold audit --title \"YEAR SOC 2 TYPE\" > audit-mutation.json",
1318
+ "npx filegrc create audit-mutation.json --json",
1319
+ "npx filegrc prepare-audit AUDIT_ID --json"
1320
+ ]
1321
+ }
1322
+ : null
1323
+ );
983
1324
  return {
984
1325
  ...stage,
985
1326
  status: auditReadiness?.status || "not-started",
986
1327
  counts: auditReadiness?.counts || null,
987
- nextActions: auditReadiness?.firstAction ? [auditReadiness.firstAction] : []
1328
+ nextActions: auditAction ? [auditAction] : []
988
1329
  };
989
1330
  }
990
1331
  const readinessId = stage.id === "run" ? "operation" : stage.id;
@@ -1002,6 +1343,7 @@ function buildProgramPathResult(model, readiness, auditReadiness) {
1002
1343
  const currentStep = stages.find((stage) => !["complete", "operating", "management-ready"].includes(stage.status)) || stages.at(-1);
1003
1344
  return {
1004
1345
  schemaVersion: 1,
1346
+ dataModelVersion: String(model.modelVersion),
1005
1347
  asOf: readiness.asOf,
1006
1348
  currentStep: { id: currentStep.id, number: currentStep.number, title: currentStep.title },
1007
1349
  evidenceReady: readiness.evidenceReady,
@@ -1018,14 +1360,13 @@ function printProgramPath(result) {
1018
1360
  console.log(stage.summary);
1019
1361
  for (const page of stage.pages) {
1020
1362
  console.log(`${page.order ? `Step ${page.order}` : "Operating area"} · ${page.title} (${page.type || `utility:${page.utility}`})`);
1021
- console.log(` Instructions: ${page.instructions}`);
1022
- console.log(` Use: ${page.use}`);
1023
- console.log(` Policy basis: ${page.policyBasis}`);
1363
+ console.log(` ${page.summary}`);
1364
+ if (page.guide) console.log(` Details: ${page.guide}`);
1024
1365
  }
1025
1366
  if (stage.operatingRecords?.length) {
1026
1367
  console.log("Operating record guides:");
1027
1368
  for (const record of stage.operatingRecords) {
1028
- console.log(` ${record.type}\t${record.instructions}\t${record.guide}`);
1369
+ console.log(` ${record.type}\t${record.summary}\t${record.guide}`);
1029
1370
  }
1030
1371
  }
1031
1372
  console.log("Commands:");
@@ -1049,6 +1390,7 @@ function selectProgramPathOutput(result, flags) {
1049
1390
  function summarizeProgramPath(result) {
1050
1391
  return {
1051
1392
  schemaVersion: result.schemaVersion,
1393
+ dataModelVersion: result.dataModelVersion,
1052
1394
  asOf: result.asOf,
1053
1395
  currentStep: result.currentStep,
1054
1396
  evidenceReady: result.evidenceReady,
@@ -1069,6 +1411,7 @@ function nextProgramPath(result) {
1069
1411
  const nextAction = stage?.nextActions[0];
1070
1412
  return {
1071
1413
  schemaVersion: result.schemaVersion,
1414
+ dataModelVersion: result.dataModelVersion,
1072
1415
  asOf: result.asOf,
1073
1416
  currentStep: result.currentStep,
1074
1417
  evidenceReady: result.evidenceReady,
@@ -1151,6 +1494,20 @@ function printProgramPathOutput(result, flags) {
1151
1494
  printProgramPath(result);
1152
1495
  }
1153
1496
 
1497
+ function printWorkflow(result) {
1498
+ console.log(`Workflow contract v${result.contractVersion}, model v${result.dataModelVersion}`);
1499
+ for (const [name, assessment] of Object.entries(result.assessments)) {
1500
+ console.log(`${String(assessment.status).toUpperCase()}\t${name}\t${assessment.message}`);
1501
+ }
1502
+ console.log(`\n${result.counts.findings.ready || 0} ready findings, ${result.counts.workItems.overdue || 0} overdue Work Items`);
1503
+ if (result.recommended) {
1504
+ console.log(`Next: ${result.recommended.title}`);
1505
+ if (result.recommended.message) console.log(` ${result.recommended.message}`);
1506
+ const command = result.recommended.nextAction?.command || result.recommended.actions?.[0]?.command;
1507
+ if (command) console.log(` ${command}`);
1508
+ }
1509
+ }
1510
+
1154
1511
  function summarizeProgramReadiness(result) {
1155
1512
  const ownership = result.stages
1156
1513
  .flatMap((stage) => stage.items)