filegrc 0.3.3 → 0.4.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
@@ -6,7 +6,6 @@ import { buildAgentGuide, findResourceReferences, listResourceTypes, scaffoldRes
6
6
  import { assessAuditPreparation, prepareAuditWorkspace } from "./audit-preparation.js";
7
7
  import { buildWorkspace } from "./build.js";
8
8
  import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet.js";
9
- import { ensureEvidenceTestDrafts, planEvidenceTestDrafts } from "./evidence-tests.js";
10
9
  import {
11
10
  addEvidenceAttachment,
12
11
  createResource,
@@ -15,6 +14,8 @@ import {
15
14
  updateResource
16
15
  } from "./files.js";
17
16
  import { generateModelDocumentation } from "./model-docs.js";
17
+ import { migrateModel, planModelMigration } from "./model-migration.js";
18
+ import { normalizeResourceMutation } from "./mutation.js";
18
19
  import {
19
20
  completeObligationAction,
20
21
  completeObligationEvent,
@@ -23,9 +24,10 @@ import {
23
24
  planObligations
24
25
  } from "./obligations.js";
25
26
  import { relativeToWorkspace, resolveDataPath } from "./paths.js";
26
- import { buildAgentProgramPath, policyEventName } from "./program-path.js";
27
- import { assessProgramReadiness } from "./program-readiness.js";
27
+ import { buildAgentProgramPath } from "./program-path.js";
28
+ import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
28
29
  import { markdownEntries } from "./resource-markdown.js";
30
+ import { effectiveResourceStatus } from "./resource-status.js";
29
31
  import { searchResources } from "./search.js";
30
32
  import { serveWorkspace } from "./server.js";
31
33
  import { planWorkspaceSetup, setupWorkspace, summarizeSetupResult } from "./setup.js";
@@ -36,6 +38,7 @@ import { validateWorkspace } from "./validate.js";
36
38
  import { loadWorkspace } from "./workspace.js";
37
39
 
38
40
  const BOOLEAN_FLAGS = new Set([
41
+ "allow-non-authoritative-writes",
39
42
  "check-docs",
40
43
  "complete",
41
44
  "current",
@@ -63,7 +66,8 @@ export async function runCli(argv = process.argv.slice(2)) {
63
66
  if (command === "serve") {
64
67
  const result = await serveWorkspace(positionals[0] ?? root, {
65
68
  host: flags.host ?? process.env.FILEGRC_HOST,
66
- port: flags.port ?? process.env.FILEGRC_PORT
69
+ port: flags.port ?? process.env.FILEGRC_PORT,
70
+ allowNonAuthoritativeWrites: flags["allow-non-authoritative-writes"] === true
67
71
  });
68
72
  const stopped = new Promise((resolvePromise) => {
69
73
  const stop = () => {
@@ -87,7 +91,7 @@ export async function runCli(argv = process.argv.slice(2)) {
87
91
  ...(flags.boundary !== undefined ? { boundary: flags.boundary } : {}),
88
92
  ...(flags.owner !== undefined ? { ownerId: flags.owner } : {}),
89
93
  ...(flags.criticality !== undefined ? { criticality: flags.criticality } : {}),
90
- ...(flags.classification !== undefined ? { dataClassification: flags.classification } : {}),
94
+ ...(flags.classification !== undefined ? { classificationId: flags.classification } : {}),
91
95
  ...(flags["internet-exposed"] !== undefined ? { internetExposed: flags["internet-exposed"] } : {}),
92
96
  ...(flags["program-goal"] !== undefined ? { programGoal: flags["program-goal"] } : {}),
93
97
  ...(flags.draft ? { draft: true } : {})
@@ -99,7 +103,7 @@ export async function runCli(argv = process.argv.slice(2)) {
99
103
  if (flags.json) console.log(JSON.stringify(output, null, 2));
100
104
  else if (flags.preview) {
101
105
  console.log(`Setup preview: ${result.changes.system} system ${result.system.id}; update workspace target to ${result.target.assuranceGoal}.`);
102
- console.log("No controls will be linked and no evidence drafts will be created.");
106
+ console.log("No controls will be linked and no evidence records will be created.");
103
107
  }
104
108
  else {
105
109
  console.log(`${result.draft ? "Saved draft scope" : "Completed initial setup"} for ${result.system.title}.`);
@@ -142,6 +146,35 @@ export async function runCli(argv = process.argv.slice(2)) {
142
146
  else console.log(source);
143
147
  return;
144
148
  }
149
+ if (command === "migrate") {
150
+ const targetModel = String(flags["to-model"] || "");
151
+ if (targetModel !== "2") throw new Error("Pass --to-model 2.");
152
+ const options = {
153
+ jobTitle: flags["job-title"],
154
+ startsOn: flags["starts-on"]
155
+ };
156
+ const plan = await planModelMigration(root, options);
157
+ 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.");
159
+ }
160
+ const result = flags.preview
161
+ ? plan
162
+ : plan.sourceModelVersion !== plan.targetModelVersion
163
+ ? await migrateModel(root, options)
164
+ : { ...plan, applied: false };
165
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
166
+ else if (result.sourceModelVersion === result.targetModelVersion) {
167
+ console.log(`Workspace already uses model v${result.targetModelVersion}.`);
168
+ } else if (flags.preview) {
169
+ console.log(`Model migration preview: create ${result.summary.create}; update ${result.summary.update}.`);
170
+ console.log(result.ready
171
+ ? "Ready to apply. Rerun with --yes."
172
+ : `Needs review: ${result.missing.length} missing values, ${result.conflicts.length} conflicts, ${result.manualActions.length} manual actions.`);
173
+ } else {
174
+ console.log(`Migrated workspace from model v${result.sourceModelVersion} to v${result.targetModelVersion}.`);
175
+ }
176
+ return result;
177
+ }
145
178
  if (command === "describe") {
146
179
  const loaded = await loadWorkspace(root);
147
180
  const type = positionals[0];
@@ -193,11 +226,18 @@ export async function runCli(argv = process.argv.slice(2)) {
193
226
  const loaded = await loadWorkspace(root);
194
227
  const type = positionals[0];
195
228
  if (type && !loaded.model.resources[type]) throw new Error(`Unknown resource type "${type}".`);
229
+ const asOf = currentCalendarDate(loaded.workspace.timezone);
196
230
  const records = loaded.resources
197
231
  .filter((record) => !type || record.type === type)
198
- .sort((left, right) => `${left.type}:${left.title}:${left.id}`.localeCompare(`${right.type}:${right.title}:${right.id}`));
232
+ .sort((left, right) => `${left.type}:${left.title}:${left.id}`.localeCompare(`${right.type}:${right.title}:${right.id}`))
233
+ .map((record) => {
234
+ const effectiveStatus = effectiveResourceStatus(record, asOf);
235
+ return effectiveStatus && effectiveStatus !== record.status
236
+ ? { ...record, effectiveStatus }
237
+ : record;
238
+ });
199
239
  if (flags.json) console.log(JSON.stringify(records, null, 2));
200
- else for (const record of records) console.log(`${record.id}\t${record.type}\t${record.status ?? ""}\t${record.title}`);
240
+ else for (const record of records) console.log(`${record.id}\t${record.type}\t${record.effectiveStatus ?? record.status ?? ""}\t${record.title}`);
201
241
  return records;
202
242
  }
203
243
  if (command === "search") {
@@ -215,7 +255,8 @@ export async function runCli(argv = process.argv.slice(2)) {
215
255
  from: flags.from,
216
256
  through: flags.through,
217
257
  now: flags.now,
218
- includeComplete: Boolean(flags.complete)
258
+ includeComplete: Boolean(flags.complete),
259
+ model: loaded.model
219
260
  });
220
261
  if (flags.json) console.log(JSON.stringify(result, null, 2));
221
262
  else {
@@ -234,7 +275,7 @@ export async function runCli(argv = process.argv.slice(2)) {
234
275
  if (result.triggers.length) {
235
276
  console.log("\nPolicy Events:");
236
277
  for (const trigger of result.triggers) {
237
- console.log(`${trigger.programStatus.toUpperCase()}\t${policyEventName(trigger.eventType)} (${trigger.eventType})\t${trigger.steps.length} Work Queue ${trigger.steps.length === 1 ? "task" : "tasks"}`);
278
+ console.log(`${trigger.programStatus.toUpperCase()}\t${trigger.title} (${trigger.eventType})\t${trigger.steps.length} Work Queue ${trigger.steps.length === 1 ? "task" : "tasks"}`);
238
279
  for (const step of trigger.steps) {
239
280
  const owners = step.ownerIds.length ? step.ownerIds.join(",") : "unassigned";
240
281
  const proof = step.completionResourceTypes.length ? step.completionResourceTypes.join("|") : "not specified";
@@ -260,7 +301,14 @@ export async function runCli(argv = process.argv.slice(2)) {
260
301
  }
261
302
  else {
262
303
  console.log(`${result.status.toUpperCase()}: ${result.progress.complete} of ${result.progress.total} program items complete`);
263
- console.log(`${result.target.label}${result.target.candidatePeriodStart ? `, candidate period starts ${result.target.candidatePeriodStart}` : ""}`);
304
+ console.log(
305
+ `${result.target.label}`
306
+ + (result.target.candidateCoverage?.kind === "range"
307
+ ? `, candidate period starts ${result.target.candidateCoverage.startsOn}`
308
+ : result.target.candidateCoverage?.kind === "as-of"
309
+ ? `, candidate as-of date ${result.target.candidateCoverage.on}`
310
+ : "")
311
+ );
264
312
  for (const stage of result.stages) {
265
313
  console.log(`\n${stage.title}`);
266
314
  for (const item of stage.items) console.log(`${item.status.toUpperCase()}\t${item.title}\t${item.message}`);
@@ -272,33 +320,25 @@ export async function runCli(argv = process.argv.slice(2)) {
272
320
  if (flags["require-ready"] && !result.evidenceReady) process.exitCode = 2;
273
321
  return output;
274
322
  }
275
- if (command === "evidence-test-drafts") {
276
- if (flags.preview) {
277
- const loaded = await loadWorkspace(root);
278
- const plan = planEvidenceTestDrafts(loaded);
279
- const result = {
280
- schemaVersion: 1,
281
- preview: true,
282
- total: plan.length,
283
- create: plan.filter(({ existing }) => !existing).map((item) => ({
284
- familyId: item.familyId,
285
- title: item.title,
286
- testEvidenceKind: item.testEvidenceKind,
287
- controlIds: item.controlIds
288
- })),
289
- existing: plan.filter(({ existing }) => existing).map(({ existing }) => ({
290
- id: existing.id,
291
- title: existing.title,
292
- status: existing.status
293
- }))
294
- };
295
- if (flags.json) console.log(JSON.stringify(result, null, 2));
296
- else console.log(`Evidence draft preview: create ${result.create.length}; preserve ${result.existing.length}.`);
297
- return result;
298
- }
299
- const result = await ensureEvidenceTestDrafts(root);
323
+ if (command === "evidence-map") {
324
+ const loaded = await loadWorkspace(root);
325
+ const result = await assessEvidenceMap(loaded, { asOf: flags["as-of"] });
300
326
  if (flags.json) console.log(JSON.stringify(result, null, 2));
301
- else console.log(`Created ${result.created.length} External Evidence test ${result.created.length === 1 ? "draft" : "drafts"}; ${result.total} required families are represented.`);
327
+ else {
328
+ console.log(`${result.status.toUpperCase()}: ${result.counts.complete} mapped, ${result.counts.action} need action`);
329
+ for (const item of result.items) {
330
+ console.log(`${item.status.toUpperCase()}\t${item.title}\t${item.message}`);
331
+ if (item.status !== "action") continue;
332
+ if (item.sourceKinds?.length) console.log(` Source role: ${item.sourceKinds.join(" or ")}`);
333
+ for (const source of item.sourceSystemChecks || []) {
334
+ const missing = Object.entries(source.checks)
335
+ .filter(([, passed]) => !passed)
336
+ .map(([name]) => evidenceSourceCheckName(name));
337
+ if (missing.length) console.log(` ${source.sourceSystemId}: ${missing.join(", ")}`);
338
+ }
339
+ if (item.commands?.length) console.log(` Next: ${item.commands[0]}`);
340
+ }
341
+ }
302
342
  return result;
303
343
  }
304
344
  if (command === "audit-readiness") {
@@ -337,7 +377,10 @@ export async function runCli(argv = process.argv.slice(2)) {
337
377
  else {
338
378
  console.log(`Work added to the Work Queue: ${result.actions.length} ${result.actions.length === 1 ? "task" : "tasks"} created for ${result.event.title}.`);
339
379
  console.log(`Event: obligation-event/${result.event.id}`);
340
- for (const action of result.actions) console.log(`Task: action-item/${action.id}\t${action.title}\t${action.dueWindowEndAt || action.dueWindowEnd || action.overdueAt || action.overdueOn}`);
380
+ for (const action of result.actions) {
381
+ const deadline = action.completionWindow?.dueAt || action.completionWindow?.dueOn;
382
+ console.log(`Task: action-item/${action.id}\t${action.title}\t${deadline}`);
383
+ }
341
384
  }
342
385
  return result;
343
386
  }
@@ -418,7 +461,7 @@ export async function runCli(argv = process.argv.slice(2)) {
418
461
  obligationId,
419
462
  record: mutation.record,
420
463
  content: mutation.content,
421
- expectedRevision: flags["expected-revision"]
464
+ expectedRevision: requireExpectedRevision(flags, `obligation/${obligationId}`)
422
465
  });
423
466
  if (flags.json) console.log(JSON.stringify(result, null, 2));
424
467
  else console.log(`Created ${result.created.type}/${result.created.id} and linked it to obligation/${obligationId}`);
@@ -432,7 +475,7 @@ export async function runCli(argv = process.argv.slice(2)) {
432
475
  completedOn: flags["completed-on"],
433
476
  record: mutation.record,
434
477
  content: mutation.content,
435
- expectedRevision: flags["expected-revision"]
478
+ expectedRevision: requireExpectedRevision(flags, `action-item/${actionItemId}`)
436
479
  });
437
480
  if (flags.json) console.log(JSON.stringify(result, null, 2));
438
481
  else console.log(`Created ${result.created.type}/${result.created.id}, linked it to action-item/${actionItemId}, and marked the action done.`);
@@ -444,7 +487,7 @@ export async function runCli(argv = process.argv.slice(2)) {
444
487
  const result = await completeObligationEvent(root, {
445
488
  eventId,
446
489
  completedOn: flags["completed-on"],
447
- expectedRevision: flags["expected-revision"]
490
+ expectedRevision: requireExpectedRevision(flags, `obligation-event/${eventId}`)
448
491
  });
449
492
  if (flags.json) console.log(JSON.stringify({ record: result.record }, null, 2));
450
493
  else console.log(`Marked obligation-event/${eventId} complete.`);
@@ -452,11 +495,12 @@ export async function runCli(argv = process.argv.slice(2)) {
452
495
  }
453
496
  if (command === "update") {
454
497
  const [type, id, file] = positionals;
455
- const mutation = await readMutation(file);
498
+ const mutation = await readMutation(file, { requireRevision: true });
456
499
  const result = await updateResource(root, type, id, mutation.record, {
457
500
  content: mutation.content,
458
501
  expectedRevision: mutation.revision,
459
- expectedContentRevisions: mutation.contentRevisions
502
+ expectedContentRevisions: mutation.contentRevisions,
503
+ requireExpectedContentRevisions: true
460
504
  });
461
505
  if (flags.json) console.log(JSON.stringify({ record: result.record }, null, 2));
462
506
  else console.log(`Updated ${result.record.type}/${result.record.id}`);
@@ -507,7 +551,7 @@ export async function runCli(argv = process.argv.slice(2)) {
507
551
  if (!evidenceId || !sourcePath) throw new Error("An evidence ID and source file are required.");
508
552
  const result = await addEvidenceAttachment(root, evidenceId, sourcePath, {
509
553
  name: flags.name,
510
- expectedRevision: flags["expected-revision"]
554
+ expectedRevision: requireExpectedRevision(flags, `evidence/${evidenceId}`)
511
555
  });
512
556
  const output = {
513
557
  evidenceId,
@@ -523,7 +567,7 @@ export async function runCli(argv = process.argv.slice(2)) {
523
567
  if (!evidenceId || !attachment) throw new Error("An evidence ID and attachment name are required.");
524
568
  if (!flags.yes) throw new Error("Pass --yes to confirm attachment removal.");
525
569
  const result = await removeEvidenceAttachment(root, evidenceId, attachment, {
526
- expectedRevision: flags["expected-revision"]
570
+ expectedRevision: requireExpectedRevision(flags, `evidence/${evidenceId}`)
527
571
  });
528
572
  const output = {
529
573
  evidenceId,
@@ -537,7 +581,9 @@ export async function runCli(argv = process.argv.slice(2)) {
537
581
  if (command === "delete") {
538
582
  const [type, id] = positionals;
539
583
  if (!flags.yes) throw new Error("Pass --yes to confirm deletion. Preserve historical records unless this is a mistake or uncommitted draft.");
540
- await deleteResource(root, type, id, { expectedRevision: flags["expected-revision"] });
584
+ await deleteResource(root, type, id, {
585
+ expectedRevision: requireExpectedRevision(flags, `${type}/${id}`)
586
+ });
541
587
  console.log(`Deleted ${type}/${id}`);
542
588
  return;
543
589
  }
@@ -565,37 +611,18 @@ function parseArgs(args) {
565
611
  return { positionals, flags };
566
612
  }
567
613
 
568
- async function readMutation(path) {
614
+ async function readMutation(path, options = {}) {
569
615
  if (!path) throw new Error("A JSON file path or - is required.");
570
616
  const source = path === "-" ? await readStdin() : await readFile(resolve(path), "utf8");
571
- const parsed = JSON.parse(source);
572
- if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
573
- throw new Error("A resource record or { record, content } mutation object is required.");
574
- }
575
- if (!Object.hasOwn(parsed, "record")) {
576
- return { record: parsed, content: undefined, revision: undefined, contentRevisions: undefined };
577
- }
578
- if (!parsed.record || Array.isArray(parsed.record) || typeof parsed.record !== "object") {
579
- throw new Error("Mutation record must be a JSON object.");
580
- }
581
- if (parsed.content !== undefined && (Array.isArray(parsed.content) || typeof parsed.content !== "object" || parsed.content === null)) {
582
- throw new Error("Mutation content must be an object keyed by Markdown slot.");
583
- }
584
- if (parsed.revision !== undefined && typeof parsed.revision !== "string") {
585
- throw new Error("Mutation revision must be a string.");
586
- }
587
- if (
588
- parsed.contentRevisions !== undefined
589
- && (Array.isArray(parsed.contentRevisions) || typeof parsed.contentRevisions !== "object" || parsed.contentRevisions === null)
590
- ) {
591
- throw new Error("Mutation contentRevisions must be an object keyed by data-relative Markdown path.");
617
+ return normalizeResourceMutation(JSON.parse(source), options);
618
+ }
619
+
620
+ function requireExpectedRevision(flags, target) {
621
+ const revision = flags["expected-revision"];
622
+ if (typeof revision !== "string" || revision.length === 0) {
623
+ throw new Error(`--expected-revision is required when changing ${target}. Reload the resource and try again.`);
592
624
  }
593
- return {
594
- record: parsed.record,
595
- content: parsed.content,
596
- revision: parsed.revision,
597
- contentRevisions: parsed.contentRevisions
598
- };
625
+ return revision;
599
626
  }
600
627
 
601
628
  async function readSetupPayload(path) {
@@ -641,11 +668,11 @@ async function completeInteractiveSetup(root, payload) {
641
668
  activePeople[0].id
642
669
  );
643
670
  result.criticality ||= await askChoice("Criticality", ["low", "medium", "high", "critical"], "high");
644
- result.dataClassification ||= classifications.length
671
+ result.classificationId ||= classifications.length
645
672
  ? await askChoice(
646
673
  "Data classification",
647
674
  classifications,
648
- classifications.includes("Confidential") ? "Confidential" : classifications[0]
675
+ classifications.includes("confidential") ? "confidential" : classifications[0]
649
676
  )
650
677
  : await askRequired("Data classification");
651
678
  if (result.internetExposed === undefined) {
@@ -683,11 +710,12 @@ function printHelp() {
683
710
  console.log(`filegrc - Git-native GRC workspace
684
711
 
685
712
  Usage:
686
- filegrc serve [root] [--host 127.0.0.1] [--port 8787]
713
+ filegrc serve [root] [--host 127.0.0.1] [--port 8787] [--allow-non-authoritative-writes]
687
714
  filegrc setup [setup.json|-] [setup options] [--draft] [--preview] [--summary] [--json]
688
715
  filegrc build [root] [--output .filegrc/site]
689
716
  filegrc validate [root] [--json]
690
717
  filegrc model [--json|--write-docs|--check-docs]
718
+ filegrc migrate --to-model 2 [--preview] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
691
719
  filegrc describe <resource-type>
692
720
  filegrc types [--json]
693
721
  filegrc guide [resource-type] [--id resource-id] [--json]
@@ -697,22 +725,22 @@ Usage:
697
725
  filegrc search <query> [--type resource-type] [--json]
698
726
  filegrc obligations [--as-of YYYY-MM-DD] [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--now RFC3339] [--complete] [--json]
699
727
  filegrc program-readiness [--as-of YYYY-MM-DD] [--require-ready] [--summary] [--json]
700
- filegrc evidence-test-drafts [--preview] [--json]
728
+ filegrc evidence-map [--as-of YYYY-MM-DD] [--json]
701
729
  filegrc audit-readiness [audit-id] [--require-ready] [--json]
702
730
  filegrc prepare-audit <audit-id> [--json]
703
731
  filegrc trigger <event-type> (--occurred-on YYYY-MM-DD | --occurred-at RFC3339) [--subject resource-id[,resource-id]] [--title text] [--json]
704
732
  filegrc evidence-packet [--audit audit-id] [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--output .filegrc/path] [--preview] [--require-ready] [--json]
705
733
  filegrc get [resource-type] <id> [--mutation]
706
734
  filegrc references <id> [--json]
707
- filegrc create <record-or-mutation.json|-> [--json]
708
- filegrc complete <obligation-id> <completion-record.json|-> [--expected-revision hash] [--json]
709
- filegrc complete-action <action-item-id> <completion-record.json|-> --completed-on YYYY-MM-DD [--expected-revision hash] [--json]
710
- filegrc complete-event <obligation-event-id> --completed-on YYYY-MM-DD [--expected-revision hash] [--json]
711
- filegrc update <resource-type> <id> <record-or-mutation.json|-> [--json]
735
+ 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]
738
+ filegrc complete-event <obligation-event-id> --completed-on YYYY-MM-DD --expected-revision hash [--json]
739
+ filegrc update <resource-type> <id> <mutation.json|-> [--json]
712
740
  filegrc content <resource-type> <id> [slot] [--write markdown-file|-] [--expected-revision hash] [--json]
713
- filegrc attach <evidence-id> <source-file> [--name file-name] [--expected-revision hash] [--json]
714
- filegrc detach <evidence-id> <attachment-name> --yes [--expected-revision hash] [--json]
715
- filegrc delete <resource-type> <id> --yes [--expected-revision hash]
741
+ filegrc attach <evidence-id> <source-file> --expected-revision hash [--name file-name] [--json]
742
+ filegrc detach <evidence-id> <attachment-name> --yes --expected-revision hash [--json]
743
+ filegrc delete <resource-type> <id> --yes --expected-revision hash
716
744
 
717
745
  All commands accept --root <workspace>. Writes never create Git commits.`);
718
746
  }
@@ -720,12 +748,15 @@ All commands accept --root <workspace>. Writes never create Git commits.`);
720
748
  function printCommandHelp(command) {
721
749
  if (command === "serve") {
722
750
  console.log(`Usage:
723
- filegrc serve [root] [--host address] [--port number]
751
+ filegrc serve [root] [--host address] [--port number] [--allow-non-authoritative-writes]
724
752
 
725
753
  Options:
726
754
  --host <address> Bind address. Defaults to FILEGRC_HOST or 127.0.0.1.
727
755
  --port <number> Port. Defaults to FILEGRC_PORT or 8787. Use 0 for an available port.
728
756
  --root <path> Workspace path when no positional root is given.
757
+ --allow-non-authoritative-writes
758
+ Allow local browser writes from a task checkout. This explicit
759
+ development override never commits or pushes.
729
760
  --help Show this help without starting the server.
730
761
 
731
762
  Safety:
@@ -746,7 +777,7 @@ Options:
746
777
  --boundary <description> boundary
747
778
  --owner <person-id> ownerId
748
779
  --criticality <level> low, medium, high, or critical
749
- --classification <name> dataClassification
780
+ --classification <id> classificationId
750
781
  --internet-exposed <bool> true or false
751
782
  --program-goal <goal> none, readiness, type-1, or type-2
752
783
  --draft Save the service boundary as planned
@@ -757,14 +788,37 @@ Options:
757
788
  --help Show this help`);
758
789
  return;
759
790
  }
791
+ if (command === "migrate") {
792
+ console.log(`Usage:
793
+ filegrc migrate --to-model 2 [options]
794
+
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.
800
+
801
+ Options:
802
+ --to-model <version> Required target model; currently 2
803
+ --preview Show the complete atomic record plan without writing
804
+ --job-title <title> Actual job title for the former Policy Owner seed person
805
+ --starts-on <date> Effective date of a new Policy Owner Appointment
806
+ --yes Apply the reviewed migration
807
+ --json Print the plan or result as JSON
808
+ --root <path> Workspace path
809
+ --help Show this help
810
+
811
+ Start with:
812
+ npx filegrc migrate --to-model 2 --preview --json`);
813
+ return;
814
+ }
760
815
  if (command === "program-readiness") {
761
816
  console.log(`Usage:
762
817
  filegrc program-readiness [options]
763
818
 
764
819
  Report whether management has defined scope, activated policies, implemented
765
- controls, configured authoritative evidence sources, and verified test collection
766
- for external evidence without a dedicated Step 5 record. No audit ID or CPA firm
767
- is required.
820
+ controls, and mapped every selected control to a configured authoritative evidence
821
+ source. No audit ID or CPA firm is required.
768
822
 
769
823
  Options:
770
824
  --as-of <date> Evaluate effective dates and obligations on YYYY-MM-DD
@@ -779,14 +833,14 @@ Options:
779
833
  console.log(`Usage:
780
834
  filegrc program-path [audit-id] [options]
781
835
 
782
- Show the same six-step SOC 2 lifecycle used by the renderer. Each step includes
836
+ Show the same five-step SOC 2 lifecycle used by the renderer. Each step includes
783
837
  its exact page instructions, Use and Policy Basis context, resource commands,
784
- and current readiness state. Pass an audit ID to include Step 6 status.
838
+ and current readiness state. Pass an audit ID to include Step 5 status.
785
839
 
786
840
  Options:
787
- --audit <id> Audit record to use for Step 6
841
+ --audit <id> Audit record to use for Step 5
788
842
  --as-of <date> Evaluate readiness on YYYY-MM-DD
789
- --summary Print compact status and the first action for all six steps
843
+ --summary Print compact status and the first action for all five steps
790
844
  --next Print only the current step and its first action
791
845
  --current Print the full guide for the current step only
792
846
  --json Print the selected path view as JSON
@@ -794,18 +848,18 @@ Options:
794
848
  --help Show this help`);
795
849
  return;
796
850
  }
797
- if (command === "evidence-test-drafts") {
851
+ if (command === "evidence-map") {
798
852
  console.log(`Usage:
799
- filegrc evidence-test-drafts [options]
853
+ filegrc evidence-map [options]
800
854
 
801
- Preview or create missing draft External Evidence records for collection that
802
- does not already have a dedicated Step 5 operating record. Existing tests are
803
- preserved. Run after confirming applicable controls and source systems.
855
+ Inspect the evidence-source checks included in Control implementation. Each item
856
+ reports the required source roles, linked Controls, authoritative source Systems,
857
+ per-record checks, and exact edit commands. This diagnostic is read-only.
804
858
 
805
859
  Options:
806
- --preview Report proposed drafts without creating them
807
- --json Print created and existing records as JSON
808
- --root <path> Workspace path
860
+ --as-of <date> Evaluate the map on YYYY-MM-DD
861
+ --json Print the map as JSON
862
+ --root <path> Workspace path
809
863
  --help Show this help`);
810
864
  return;
811
865
  }
@@ -821,6 +875,7 @@ function agentOverview(model) {
821
875
  build: "filegrc build [root]",
822
876
  validate: "filegrc validate [root] --json",
823
877
  model: "filegrc model --json",
878
+ migrate: "filegrc migrate --to-model 2 --preview --json",
824
879
  describe: "filegrc describe <resource-type>",
825
880
  types: "filegrc types --json",
826
881
  guide: "filegrc guide [resource-type] --json",
@@ -830,18 +885,18 @@ function agentOverview(model) {
830
885
  search: "filegrc search <query> --json",
831
886
  obligations: "filegrc obligations --json",
832
887
  programReadiness: "filegrc program-readiness --json",
833
- evidenceTestDrafts: "filegrc evidence-test-drafts --preview --json",
888
+ evidenceMap: "filegrc evidence-map --json",
834
889
  auditReadiness: "filegrc audit-readiness <audit-id> --json",
835
890
  prepareAudit: "filegrc prepare-audit <audit-id>",
836
891
  trigger: "filegrc trigger <event-type> <date-or-time-and-subject-flags>",
837
892
  evidencePacket: "filegrc evidence-packet --audit <audit-id> --preview --json",
838
893
  get: "filegrc get <resource-id> [--mutation]",
839
894
  references: "filegrc references <resource-id> --json",
840
- create: "filegrc create <record-or-mutation.json>",
895
+ create: "filegrc create <mutation.json>",
841
896
  complete: "filegrc complete <obligation-id> <completion-mutation.json>",
842
897
  completeAction: "filegrc complete-action <action-item-id> <completion-mutation.json> --completed-on <date>",
843
898
  completeEvent: "filegrc complete-event <obligation-event-id> --completed-on <date>",
844
- update: "filegrc update <resource-type> <id> <record-or-mutation.json>",
899
+ update: "filegrc update <resource-type> <id> <mutation.json>",
845
900
  content: "filegrc content <resource-type> <id> [slot] [--write <markdown-file|->]",
846
901
  attach: "filegrc attach <evidence-id> <source-file> [--name <file-name>]",
847
902
  detach: "filegrc detach <evidence-id> <attachment-name> --yes",
@@ -917,6 +972,8 @@ function printAgentGuide(result) {
917
972
  }
918
973
  console.log("\nWorkflow:");
919
974
  result.workflow.forEach((step, index) => console.log(`${index + 1}. ${step}`));
975
+ console.log("\nCompletion checks:");
976
+ result.completionChecks.forEach((check) => console.log(`- ${check}`));
920
977
  }
921
978
 
922
979
  function buildProgramPathResult(model, readiness, auditReadiness) {
@@ -1064,6 +1121,15 @@ function shellArgument(value) {
1064
1121
  : `'${text.replaceAll("'", "'\\''")}'`;
1065
1122
  }
1066
1123
 
1124
+ function evidenceSourceCheckName(name) {
1125
+ return ({
1126
+ active: "activate source",
1127
+ sourceRole: "add source role",
1128
+ accessOwners: "add access owner",
1129
+ retrievalInstructions: "add retrieval instructions"
1130
+ })[name] || name;
1131
+ }
1132
+
1067
1133
  function printProgramPathOutput(result, flags) {
1068
1134
  if (flags.summary) {
1069
1135
  console.log(`Current: Step ${result.currentStep.number}, ${result.currentStep.title}`);
@@ -1133,13 +1199,10 @@ function summarizeProgramReadiness(result) {
1133
1199
  }
1134
1200
 
1135
1201
  function eventWindowText(window) {
1136
- if (Number.isInteger(window?.endOffsetHours)) {
1137
- return window.endOffsetHours === 0 ? "due at event time" : `due within ${window.endOffsetHours} hours`;
1138
- }
1139
- if (Number.isInteger(window?.endOffsetDays)) {
1140
- return window.endOffsetDays === 0 ? "due on event date" : `due within ${window.endOffsetDays} days`;
1141
- }
1142
- return "due within 30 days";
1202
+ if (!Number.isInteger(window?.dueAfter)) return "deadline not configured";
1203
+ const unit = window.precision === "timestamp" ? "hour" : "day";
1204
+ if (window.dueAfter === 0) return window.precision === "timestamp" ? "due at event time" : "due on event date";
1205
+ return `due within ${window.dueAfter} ${unit}${window.dueAfter === 1 ? "" : "s"}`;
1143
1206
  }
1144
1207
 
1145
1208
  function formatGuideField(field) {
@@ -0,0 +1,50 @@
1
+ export function coverageBounds(coverage) {
2
+ if (coverage?.kind === "as-of" && typeof coverage.on === "string") {
3
+ return { start: coverage.on, end: coverage.on };
4
+ }
5
+ if (
6
+ coverage?.kind === "range"
7
+ && typeof coverage.startsOn === "string"
8
+ && typeof coverage.endsOn === "string"
9
+ ) {
10
+ return { start: coverage.startsOn, end: coverage.endsOn };
11
+ }
12
+ return { start: null, end: null };
13
+ }
14
+
15
+ export function coverageStart(coverage) {
16
+ return coverageBounds(coverage).start;
17
+ }
18
+
19
+ export function coverageEnd(coverage) {
20
+ return coverageBounds(coverage).end;
21
+ }
22
+
23
+ export function coverageMatches(coverage, start, end = start) {
24
+ const bounds = coverageBounds(coverage);
25
+ return bounds.start === start && bounds.end === end;
26
+ }
27
+
28
+ export function coverageOverlaps(coverage, start, end = start) {
29
+ const bounds = coverageBounds(coverage);
30
+ return Boolean(bounds.start && bounds.end && bounds.start <= end && bounds.end >= start);
31
+ }
32
+
33
+ export function coverageContains(coverage, date) {
34
+ return coverageOverlaps(coverage, date, date);
35
+ }
36
+
37
+ export function coverageLabel(coverage) {
38
+ const bounds = coverageBounds(coverage);
39
+ if (!bounds.start || !bounds.end) return "";
40
+ return bounds.start === bounds.end ? bounds.start : `${bounds.start} through ${bounds.end}`;
41
+ }
42
+
43
+ export function legacyCoverage(record, options = {}) {
44
+ const asOf = options.asOfFields?.map((field) => record[field]).find(Boolean);
45
+ if (asOf) return { kind: "as-of", on: asOf };
46
+ const start = options.startFields?.map((field) => record[field]).find(Boolean);
47
+ const end = options.endFields?.map((field) => record[field]).find(Boolean);
48
+ if (start && end) return { kind: "range", startsOn: start, endsOn: end };
49
+ return null;
50
+ }