filegrc 0.5.1 → 0.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/agent.js CHANGED
@@ -2,6 +2,7 @@ import { createResourceId } from "./id.js";
2
2
  import { markdownEntries } from "./resource-markdown.js";
3
3
  import { RESOURCE_INSTRUCTIONS, resourceProgramContext } from "./program-path.js";
4
4
  import { assessCollectionReview } from "./collection-review.js";
5
+ import { resolveProgram } from "./program.js";
5
6
 
6
7
  const STARTING_STATUS_ORDER = [
7
8
  "draft",
@@ -29,7 +30,7 @@ export function listResourceTypes(model) {
29
30
  export function buildAgentGuide(loaded, type, options = {}) {
30
31
  const definition = loaded.model.resources[type];
31
32
  if (!definition) throw new Error(`Unknown resource type "${type}".`);
32
- const collectionReview = assessCollectionReview(loaded, type);
33
+ const collectionReview = assessCollectionReview(loaded, type, { programId: options.programId });
33
34
  const fields = { ...loaded.model.commonFields, ...definition.fields };
34
35
  const required = new Set([
35
36
  ...Object.entries(loaded.model.commonFields)
@@ -115,12 +116,18 @@ export function buildAgentGuide(loaded, type, options = {}) {
115
116
  recommendedMarkdown.length
116
117
  ? "Keep model fields in JSON and use the recommended Markdown companion for the detailed work, decisions, results, exceptions, and follow-up that apply to this record."
117
118
  : "Keep the current facts and lifecycle state in JSON. Add optional Record Markdown only when the model fields cannot explain the record clearly.",
119
+ ...(type === "program" && String(loaded.model.modelVersion) === "4"
120
+ ? ["Review Requirement applicability with npx filegrc review-applicability --type requirement --scaffold, then preview and apply the reviewed decisions as one validated batch."]
121
+ : []),
118
122
  "Run npx filegrc validate, review the full Git diff, and commit the JSON, Markdown, and attachments together with a message that explains why the record changed."
119
123
  ],
120
124
  completionChecks: [
121
125
  "Required and status-dependent fields are complete, and the lifecycle status matches the facts.",
122
126
  "Every relationship resolves to the intended existing record.",
123
127
  "Dates describe the business event in the workspace time zone, not the file edit time.",
128
+ ...(type === "program" && String(loaded.model.modelVersion) === "4"
129
+ ? ["Every selected Requirement has an applicable or not-applicable decision reviewed against the current Program scope."]
130
+ : []),
124
131
  ...(recommendedMarkdown.length
125
132
  ? ["Required or recommended Markdown explains the work, decisions, results, exceptions, and follow-up that apply."]
126
133
  : ["The structured fields state the current fact clearly; optional Record Markdown is added only when needed."]),
@@ -172,7 +179,7 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) {
172
179
  record[name] = scaffoldValue(name, field, loaded.model);
173
180
  }
174
181
  }
175
- applyModelScaffoldDefaults(record, loaded);
182
+ applyModelScaffoldDefaults(record, loaded, options);
176
183
 
177
184
  const slots = markdownEntries(loaded.model, record).filter((slot) => (
178
185
  slot.required
@@ -189,7 +196,7 @@ export function scaffoldResourceMutation(loaded, type, title, options = {}) {
189
196
  };
190
197
  }
191
198
 
192
- function applyModelScaffoldDefaults(record, loaded) {
199
+ function applyModelScaffoldDefaults(record, loaded, options = {}) {
193
200
  if (record.type === "appointment") {
194
201
  const normalizedTitle = record.title.toLowerCase();
195
202
  const match = Object.entries(loaded.model.appointmentTemplates || {}).find(([kind, template]) => (
@@ -203,14 +210,18 @@ function applyModelScaffoldDefaults(record, loaded) {
203
210
  return;
204
211
  }
205
212
  if (record.type === "audit") {
213
+ const program = resolveProgram(loaded, options.programId);
206
214
  const kind = {
207
215
  "soc-2-type-1": "soc-2-type-1",
208
216
  "soc-2-type-2": "soc-2-type-2"
209
- }[loaded.workspace?.assuranceGoal];
217
+ }[program?.assuranceGoal];
210
218
  if (kind) record.auditKind = kind;
211
219
  for (const field of ["frameworkIds", "systemIds", "requirementIds", "controlIds"]) {
212
- if (loaded.workspace?.[field]?.length) record[field] = [...loaded.workspace[field]];
220
+ if (field === "requirementIds" && String(loaded.model.modelVersion) === "4") {
221
+ record[field] = (program.requirementApplicability || []).filter(({ decision }) => decision === "applicable").map(({ requirementId }) => requirementId);
222
+ } else if (program?.[field]?.length) record[field] = [...program[field]];
213
223
  }
224
+ if (program.type === "program") record.programId = program.id;
214
225
  const programOwner = loaded.resources.find((candidate) => (
215
226
  candidate.type === "appointment"
216
227
  && candidate.appointmentKind === "program-lead"
@@ -280,6 +291,7 @@ function allowedValues(model, field) {
280
291
 
281
292
  function scaffoldValue(name, field = {}, model) {
282
293
  if (field.const !== undefined) return field.const;
294
+ if (field.default !== undefined) return structuredClone(field.default);
283
295
  if (name === "status" && field.values) {
284
296
  return STARTING_STATUS_ORDER.find((value) => field.values.includes(value)) ?? field.values[0] ?? null;
285
297
  }
@@ -55,7 +55,8 @@ export async function assessAuditPreparation(input, options = {}) {
55
55
  if (options.auditId && !audit) throw new Error(`Audit "${options.auditId}" was not found.`);
56
56
 
57
57
  const programReadiness = options.programReadiness || await assessProgramReadiness(loaded, {
58
- generatedAt: options.generatedAt
58
+ generatedAt: options.generatedAt,
59
+ programId: audit?.programId
59
60
  });
60
61
  const stages = [
61
62
  programFoundationStage(programReadiness, loaded.workspace),
@@ -157,7 +158,8 @@ export async function prepareAuditWorkspace(input, options = {}) {
157
158
  const selectedControls = (audit.controlIds || [])
158
159
  .map((id) => loaded.resources.find((record) => record.id === id))
159
160
  .filter(Boolean);
160
- const sourceSystems = loaded.resources.filter((record) => record.type === "system");
161
+ const v4 = String(loaded.model.modelVersion) === "4";
162
+ const sourceSystems = loaded.resources.filter((record) => record.type === (v4 ? "component" : "system"));
161
163
  const populations = (audit.auditKind === "soc-2-type-2" ? model.populationTemplates || [] : [])
162
164
  .filter((template) => !existingKinds.has(template.kind))
163
165
  .map((template) => {
@@ -183,7 +185,7 @@ export async function prepareAuditWorkspace(input, options = {}) {
183
185
  coverage: structuredClone(audit.coverage),
184
186
  ownerIds: [...audit.ownerIds],
185
187
  ...(controlIds.length ? { controlIds } : {}),
186
- ...(matchingSources.length === 1 ? { sourceSystemId: matchingSources[0].id } : {}),
188
+ ...(matchingSources.length === 1 ? { [v4 ? "sourceComponentId" : "sourceSystemId"]: matchingSources[0].id } : {}),
187
189
  reconciliationSummary: `Authoritative source to confirm: ${template.sourcePrompt}. ${template.timing || ""}`.trim()
188
190
  };
189
191
  });
@@ -608,14 +610,16 @@ function evidenceStage(audit, records, byId, model) {
608
610
  item(
609
611
  "external-evidence",
610
612
  externalControls.length && controlsWithExternalEvidence.length === externalControls.length ? "complete" : externalControls.length ? "action" : "info",
611
- "Review External Evidence",
613
+ "Review Evidence Artifacts",
612
614
  externalControls.length
613
- ? `${controlsWithExternalEvidence.length} of ${externalControls.length} selected controls that rely on external systems have verified External Evidence for the formal period. Confirm the source System, date or period, control links, collector, verifier, and retained artifact or approved external reference.`
614
- : "No selected controls require a separate External Evidence record.",
615
+ ? `${controlsWithExternalEvidence.length} of ${externalControls.length} selected controls that rely on external Components have verified Evidence Artifacts for the formal period. Confirm the source Component, date or period, control links, collector, verifier, and retained artifact or approved external reference.`
616
+ : "No selected controls require a separate Evidence Artifact.",
615
617
  externalEvidence[0] || { type: "evidence" }
616
618
  )
617
619
  ];
618
- const systems = records.filter((record) => record.type === "system" && record.status === "active");
620
+ const v4 = String(model.modelVersion) === "4";
621
+ const systems = records.filter((record) => record.type === (v4 ? "component" : "system") && record.status === "active");
622
+ const sourceId = (record) => v4 ? record.sourceComponentId : record.sourceSystemId;
619
623
  for (const source of model.evidenceSourceFamilies || []) {
620
624
  const relevantControls = controls.filter((control) => (source.controlCodes || []).includes(control.code));
621
625
  if (!relevantControls.length) {
@@ -652,7 +656,7 @@ function evidenceStage(audit, records, byId, model) {
652
656
  (system.evidenceSourceKinds || []).some((kind) => (source.sourceKinds || []).includes(kind))
653
657
  ));
654
658
  const coveredControls = relevantControls.filter((control) => externalEvidence.some((record) => (
655
- sourceSystems.some((system) => system.id === record.sourceSystemId)
659
+ sourceSystems.some((system) => system.id === sourceId(record))
656
660
  && controlIdsForRecord(record, byId).has(control.id)
657
661
  )));
658
662
  const status = sourceSystems.length && coveredControls.length === relevantControls.length ? "complete" : "action";
@@ -666,12 +670,12 @@ function evidenceStage(audit, records, byId, model) {
666
670
  status,
667
671
  source.title,
668
672
  message,
669
- externalEvidence.find((record) => sourceSystems.some((system) => system.id === record.sourceSystemId))
673
+ externalEvidence.find((record) => sourceSystems.some((system) => system.id === sourceId(record)))
670
674
  || sourceSystems[0]
671
- || { type: "system" }
675
+ || { type: v4 ? "component" : "system" }
672
676
  ));
673
677
  }
674
- return stage("evidence", "Audit Evidence", "Review both evidence paths: dated filegrc operating records and verified External Evidence from authoritative systems. filegrc includes both in the audit packet.", items);
678
+ return stage("evidence", "Audit Evidence", `Review both evidence paths: dated filegrc operating records and verified ${v4 ? "Evidence Artifacts from authoritative Components" : "External Evidence from authoritative systems"}. filegrc includes both in the audit packet.`, items);
675
679
  }
676
680
 
677
681
  function populationsStage(audit, records, byId, model) {
@@ -748,8 +752,8 @@ function populationResult(population, audit, byId) {
748
752
  && evidence.type === "evidence"
749
753
  && evidence.artifactKind === "population-export"
750
754
  && evidence.status === "verified"
751
- && population.sourceSystemId
752
- && evidence.sourceSystemId === population.sourceSystemId
755
+ && (population.sourceComponentId || population.sourceSystemId)
756
+ && (evidence.sourceComponentId || evidence.sourceSystemId) === (population.sourceComponentId || population.sourceSystemId)
753
757
  && coverageMatches(
754
758
  evidence.coverage,
755
759
  coverageStart(audit.coverage),
@@ -4,8 +4,8 @@ import { loadWorkspace } from "./workspace.js";
4
4
 
5
5
  export async function planNextAuditCycle(input = process.cwd(), options = {}) {
6
6
  const loaded = await loadWorkspace(input);
7
- if (String(loaded.model.modelVersion) !== "3") {
8
- throw new Error("Audit-cycle carry-forward requires a model v3 workspace.");
7
+ if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
8
+ throw new Error("Audit-cycle carry-forward requires a model v3 or v4 workspace.");
9
9
  }
10
10
  const prior = loaded.resources.find((record) => (
11
11
  record.type === "audit" && record.id === options.priorAuditId
@@ -32,8 +32,11 @@ export async function planNextAuditCycle(input = process.cwd(), options = {}) {
32
32
  requirementIds: [...(prior.requirementIds || [])],
33
33
  controlIds: [...(prior.controlIds || [])],
34
34
  complementaryControlIds: [...(prior.complementaryControlIds || [])],
35
- subserviceVendorIds: [...(prior.subserviceVendorIds || [])],
36
- ...(prior.subserviceMethod ? { subserviceMethod: prior.subserviceMethod } : {}),
35
+ ...(prior.programId ? { programId: prior.programId } : {}),
36
+ ...(prior.subserviceTreatments ? { subserviceTreatments: structuredClone(prior.subserviceTreatments) } : {
37
+ subserviceVendorIds: [...(prior.subserviceVendorIds || [])],
38
+ ...(prior.subserviceMethod ? { subserviceMethod: prior.subserviceMethod } : {})
39
+ }),
37
40
  ...(prior.complementaryControlsConclusion
38
41
  ? { complementaryControlsConclusion: prior.complementaryControlsConclusion }
39
42
  : {}),
@@ -1,6 +1,7 @@
1
1
  import { applyResourceBatch } from "./files.js";
2
2
  import { getGitSummary } from "./git.js";
3
3
  import { loadWorkspace } from "./workspace.js";
4
+ import { resolveProgram } from "./program.js";
4
5
 
5
6
  const REVIEWABLE_TYPES = new Set([
6
7
  "requirement",
@@ -11,17 +12,23 @@ const REVIEWABLE_TYPES = new Set([
11
12
 
12
13
  export async function scaffoldApplicabilityReview(input = process.cwd(), options = {}) {
13
14
  const loaded = await loadWorkspace(input);
14
- if (String(loaded.model.modelVersion) !== "3") {
15
- throw new Error("Batch applicability review requires a model v3 workspace.");
15
+ if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
16
+ throw new Error("Batch applicability review requires a model v3 or v4 workspace.");
16
17
  }
17
18
  const requestedType = options.type ? String(options.type) : null;
18
19
  if (requestedType && !REVIEWABLE_TYPES.has(requestedType)) {
19
20
  throw new Error(`Applicability review type must be one of ${[...REVIEWABLE_TYPES].join(", ")}.`);
20
21
  }
22
+ const program = resolveProgram(loaded, options.programId);
23
+ const reviewedRequirementIds = new Set((program.requirementApplicability || [])
24
+ .filter(({ decision }) => ["applicable", "not-applicable"].includes(decision))
25
+ .map(({ requirementId }) => requirementId));
21
26
  const records = loaded.resources.filter((record) => (
22
27
  REVIEWABLE_TYPES.has(record.type)
23
28
  && (!requestedType || record.type === requestedType)
24
- && !record.applicabilityReview
29
+ && (record.type === "requirement" && String(loaded.model.modelVersion) === "4"
30
+ ? !reviewedRequirementIds.has(record.id)
31
+ : !record.applicabilityReview)
25
32
  && !["retired", "superseded"].includes(record.status)
26
33
  ));
27
34
  return {
@@ -39,14 +46,16 @@ export async function scaffoldApplicabilityReview(input = process.cwd(), options
39
46
 
40
47
  export async function planApplicabilityReview(input = process.cwd(), options = {}) {
41
48
  const loaded = await loadWorkspace(input);
42
- if (String(loaded.model.modelVersion) !== "3") {
43
- throw new Error("Batch applicability review requires a model v3 workspace.");
49
+ if (!["3", "4"].includes(String(loaded.model.modelVersion))) {
50
+ throw new Error("Batch applicability review requires a model v3 or v4 workspace.");
44
51
  }
45
52
  if (!Array.isArray(options.decisions) || !options.decisions.length) {
46
53
  throw new Error("Applicability review needs at least one decision.");
47
54
  }
48
55
  const byId = new Map(loaded.resources.map((record) => [record.id, record]));
49
- const update = options.decisions.map((decision) => {
56
+ const program = resolveProgram(loaded, options.programId);
57
+ const v4RequirementDecisions = [];
58
+ const update = options.decisions.flatMap((decision) => {
50
59
  const record = byId.get(decision.id);
51
60
  if (!record || !REVIEWABLE_TYPES.has(record.type)) {
52
61
  throw new Error(`Resource "${decision.id}" is not an applicability-review record.`);
@@ -81,16 +90,30 @@ export async function planApplicabilityReview(input = process.cwd(), options = {
81
90
  if (!["applicable", "not-applicable"].includes(result)) {
82
91
  throw new Error(`Requirement "${record.id}" must be applicable or not-applicable.`);
83
92
  }
93
+ if (String(loaded.model.modelVersion) === "4") {
94
+ v4RequirementDecisions.push({ requirementId: record.id, decision: result, rationale, reviewedByIds, reviewedOn, scopeRevision });
95
+ return [];
96
+ }
84
97
  next.applicability = result;
85
98
  next.applicabilityRationale = rationale;
86
99
  }
87
100
  if (record.type === "control" && result === "not-applicable") next.status = "not-applicable";
88
101
  if (record.type === "control" && result === "applicable" && record.status === "not-applicable") next.status = "planned";
89
- return next;
102
+ return [next];
90
103
  });
104
+ if (v4RequirementDecisions.length) {
105
+ const replaced = new Set(v4RequirementDecisions.map(({ requirementId }) => requirementId));
106
+ update.push({
107
+ ...program,
108
+ requirementApplicability: [
109
+ ...(program.requirementApplicability || []).filter(({ requirementId }) => !replaced.has(requirementId)),
110
+ ...v4RequirementDecisions
111
+ ]
112
+ });
113
+ }
91
114
  return {
92
115
  operation: "applicability-review",
93
- reviewedIds: update.map(({ id }) => id),
116
+ reviewedIds: options.decisions.map(({ id }) => id),
94
117
  changes: {
95
118
  update,
96
119
  expectedRevisions: options.expectedRevisions || {},
package/src/cli.js CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  import { relativeToWorkspace, resolveDataPath } from "./paths.js";
44
44
  import { buildAgentProgramPath } from "./program-path.js";
45
45
  import { assessEvidenceMap, assessProgramReadiness } from "./program-readiness.js";
46
+ import { resolveProgram } from "./program.js";
46
47
  import { applyReconciliation, planReconciliation } from "./reconciliation.js";
47
48
  import { markdownEntries } from "./resource-markdown.js";
48
49
  import { effectiveResourceStatus } from "./resource-status.js";
@@ -95,6 +96,7 @@ export async function runCli(argv = process.argv.slice(2)) {
95
96
  const result = await serveWorkspace(positionals[0] ?? root, {
96
97
  host: flags.host ?? process.env.FILEGRC_HOST,
97
98
  port: flags.port ?? process.env.FILEGRC_PORT,
99
+ fallbackToAvailablePort: true,
98
100
  allowNonAuthoritativeWrites: flags["allow-non-authoritative-writes"] === true
99
101
  });
100
102
  const stopped = new Promise((resolvePromise) => {
@@ -106,6 +108,9 @@ export async function runCli(argv = process.argv.slice(2)) {
106
108
  process.once("SIGINT", stop);
107
109
  process.once("SIGTERM", stop);
108
110
  });
111
+ if (result.usedFallbackPort) {
112
+ console.log(`Port ${result.requestedPort} is already in use. Using ${result.address.port} instead.`);
113
+ }
109
114
  console.log(`filegrc workspace: ${result.url}`);
110
115
  console.log(`Data: ${result.root}/data`);
111
116
  printGithubStarMessage();
@@ -130,7 +135,7 @@ export async function runCli(argv = process.argv.slice(2)) {
130
135
  const output = flags.summary && !flags.preview ? summarizeSetupResult(result) : result;
131
136
  if (flags.json) console.log(JSON.stringify(output, null, 2));
132
137
  else if (flags.preview) {
133
- console.log(`Setup preview: ${result.changes.system} system ${result.system.id}; update workspace target to ${result.target.assuranceGoal}.`);
138
+ console.log(`Setup preview: ${result.changes.system} System ${result.system.id}; update the assurance target to ${result.target.assuranceGoal}.`);
134
139
  console.log("No controls will be linked and no evidence records will be created.");
135
140
  }
136
141
  else {
@@ -139,8 +144,8 @@ export async function runCli(argv = process.argv.slice(2)) {
139
144
  if (result.draft) {
140
145
  console.log("Planned and in scope means selected for scope review, not approved or active.");
141
146
  }
142
- console.log(`Target: ${result.workspace.assuranceGoal}`);
143
- console.log("Next: finish Step 1 by confirming people, criteria, commitments, vendors, and in-scope systems. Run npx filegrc program-path --next --json.");
147
+ console.log(`Target: ${(result.program || result.workspace).assuranceGoal}`);
148
+ console.log("Next: finish Step 1 by confirming people, criteria, commitments, bounded Systems, Components, and Vendors. Run npx filegrc program-path --next --json.");
144
149
  }
145
150
  return output;
146
151
  }
@@ -176,11 +181,15 @@ export async function runCli(argv = process.argv.slice(2)) {
176
181
  }
177
182
  if (command === "migrate") {
178
183
  const targetModel = String(flags["to-model"] || "");
179
- if (!["2", "3"].includes(targetModel)) throw new Error("Pass --to-model 2 or --to-model 3.");
184
+ if (!["2", "3", "4"].includes(targetModel)) throw new Error("Pass --to-model 2, --to-model 3, or --to-model 4.");
185
+ const systemDecisions = flags.decisions
186
+ ? JSON.parse(await readFile(resolve(String(flags.decisions)), "utf8"))
187
+ : undefined;
180
188
  const options = {
181
189
  jobTitle: flags["job-title"],
182
190
  startsOn: flags["starts-on"],
183
- targetModelVersion: targetModel
191
+ targetModelVersion: targetModel,
192
+ systemDecisions: systemDecisions?.systemDecisions || systemDecisions
184
193
  };
185
194
  const plan = await planModelMigration(root, options);
186
195
  if (!flags.preview && plan.sourceModelVersion !== plan.targetModelVersion && !flags.yes) {
@@ -228,15 +237,17 @@ export async function runCli(argv = process.argv.slice(2)) {
228
237
  else printAgentOverview(result);
229
238
  return result;
230
239
  }
231
- const result = buildAgentGuide(loaded, type, { id: flags.id });
240
+ const result = buildAgentGuide(loaded, type, { id: flags.id, programId: flags.program });
232
241
  if (flags.json) console.log(JSON.stringify(result, null, 2));
233
242
  else printAgentGuide(result);
234
243
  return result;
235
244
  }
236
245
  if (command === "program-path") {
237
246
  const loaded = await loadWorkspace(root);
238
- const readiness = await assessProgramReadiness(loaded, { asOf: flags["as-of"] });
239
247
  const auditId = positionals[0] || flags.audit;
248
+ const audit = auditId ? loaded.resources.find(({ id, type }) => id === auditId && type === "audit") : null;
249
+ const programId = flags.program || audit?.programId;
250
+ const readiness = await assessProgramReadiness(loaded, { asOf: flags["as-of"], programId });
240
251
  const auditReadiness = auditId ? await assessAuditPreparation(loaded, { auditId }) : null;
241
252
  const result = buildProgramPathResult(loaded.model, readiness, auditReadiness);
242
253
  const output = selectProgramPathOutput(result, flags);
@@ -247,6 +258,7 @@ export async function runCli(argv = process.argv.slice(2)) {
247
258
  if (command === "workflow") {
248
259
  const result = await assessWorkflow(root, {
249
260
  auditId: positionals[0] || flags.audit,
261
+ programId: flags.program,
250
262
  asOf: flags["as-of"],
251
263
  through: flags.through,
252
264
  includeComplete: Boolean(flags.complete)
@@ -264,6 +276,7 @@ export async function runCli(argv = process.argv.slice(2)) {
264
276
  : undefined;
265
277
  const result = await assessWorkflow(root, {
266
278
  auditId: positionals[0] || flags.audit,
279
+ programId: flags.program,
267
280
  asOf: flags["as-of"],
268
281
  through: flags.end || flags.through,
269
282
  coverage
@@ -292,10 +305,11 @@ export async function runCli(argv = process.argv.slice(2)) {
292
305
  }
293
306
  if (command === "milestone-check") {
294
307
  const loaded = await loadWorkspace(root);
295
- const result = await assessWorkflow(loaded, { asOf: flags["as-of"] });
296
- const target = loaded.workspace?.assuranceGoal === "none"
308
+ const result = await assessWorkflow(loaded, { asOf: flags["as-of"], programId: flags.program });
309
+ const program = resolveProgram(loaded, flags.program);
310
+ const target = program?.assuranceGoal === "none"
297
311
  ? "structuralValidity"
298
- : loaded.workspace?.candidateCoverage
312
+ : program?.candidateCoverage
299
313
  ? "periodHealth"
300
314
  : "evidenceReadiness";
301
315
  const output = {
@@ -312,7 +326,7 @@ export async function runCli(argv = process.argv.slice(2)) {
312
326
  if (command === "scaffold") {
313
327
  const loaded = await loadWorkspace(root);
314
328
  const type = positionals[0];
315
- const result = scaffoldResourceMutation(loaded, type, flags.title, { id: flags.id });
329
+ const result = scaffoldResourceMutation(loaded, type, flags.title, { id: flags.id, programId: flags.program });
316
330
  console.log(JSON.stringify(result, null, 2));
317
331
  return result;
318
332
  }
@@ -333,7 +347,7 @@ export async function runCli(argv = process.argv.slice(2)) {
333
347
  const output = flags.workflow
334
348
  ? {
335
349
  records,
336
- workflow: await assessWorkflow(loaded, { asOf })
350
+ workflow: await assessWorkflow(loaded, { asOf, programId: flags.program })
337
351
  }
338
352
  : records;
339
353
  if (flags.json) console.log(JSON.stringify(output, null, 2));
@@ -394,7 +408,7 @@ export async function runCli(argv = process.argv.slice(2)) {
394
408
  }
395
409
  if (command === "program-readiness") {
396
410
  const loaded = await loadWorkspace(root);
397
- const result = await assessProgramReadiness(loaded, { asOf: flags["as-of"] });
411
+ const result = await assessProgramReadiness(loaded, { asOf: flags["as-of"], programId: flags.program });
398
412
  const output = flags.summary ? summarizeProgramReadiness(result) : result;
399
413
  if (flags.json) console.log(JSON.stringify(output, null, 2));
400
414
  else if (flags.summary) {
@@ -427,7 +441,7 @@ export async function runCli(argv = process.argv.slice(2)) {
427
441
  }
428
442
  if (command === "evidence-map") {
429
443
  const loaded = await loadWorkspace(root);
430
- const result = await assessEvidenceMap(loaded, { asOf: flags["as-of"] });
444
+ const result = await assessEvidenceMap(loaded, { asOf: flags["as-of"], programId: flags.program });
431
445
  if (flags.json) console.log(JSON.stringify(result, null, 2));
432
446
  else {
433
447
  console.log(`${result.status.toUpperCase()}: ${result.counts.complete} mapped, ${result.counts.action} need action`);
@@ -435,11 +449,11 @@ export async function runCli(argv = process.argv.slice(2)) {
435
449
  console.log(`${item.status.toUpperCase()}\t${item.title}\t${item.message}`);
436
450
  if (item.status !== "action") continue;
437
451
  if (item.sourceKinds?.length) console.log(` Source role: ${item.sourceKinds.join(" or ")}`);
438
- for (const source of item.sourceSystemChecks || []) {
452
+ for (const source of item.sourceComponentChecks || item.sourceSystemChecks || []) {
439
453
  const missing = Object.entries(source.checks)
440
454
  .filter(([, passed]) => !passed)
441
455
  .map(([name]) => evidenceSourceCheckName(name));
442
- if (missing.length) console.log(` ${source.sourceSystemId}: ${missing.join(", ")}`);
456
+ if (missing.length) console.log(` ${source.sourceComponentId || source.sourceSystemId}: ${missing.join(", ")}`);
443
457
  }
444
458
  if (item.commands?.length) console.log(` Next: ${item.commands[0]}`);
445
459
  }
@@ -989,15 +1003,15 @@ Usage:
989
1003
  filegrc build [root] [--output .filegrc/site]
990
1004
  filegrc validate [root] [--json]
991
1005
  filegrc model [--json|--write-docs|--check-docs]
992
- filegrc migrate --to-model <2|3> [--preview] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
1006
+ filegrc migrate --to-model <2|3|4> [--preview] [--decisions path] [--job-title text] [--starts-on YYYY-MM-DD] [--yes] [--json]
993
1007
  filegrc describe <resource-type>
994
1008
  filegrc types [--json]
995
- filegrc guide [resource-type] [--id resource-id] [--json]
1009
+ filegrc guide [resource-type] [--id resource-id] [--program program-id] [--json]
996
1010
  filegrc program-path [audit-id] [--as-of YYYY-MM-DD] [--summary|--next|--current] [--json]
997
1011
  filegrc workflow [audit-id] [--as-of YYYY-MM-DD] [--through YYYY-MM-DD] [--complete] [--require-ready] [--json]
998
1012
  filegrc period-health [audit-id] [--start YYYY-MM-DD --end YYYY-MM-DD] [--as-of YYYY-MM-DD] [--require-healthy] [--json]
999
1013
  filegrc milestone-check [--as-of YYYY-MM-DD] [--json]
1000
- filegrc scaffold <resource-type> --title text [--id resource-id]
1014
+ filegrc scaffold <resource-type> --title text [--id resource-id] [--program program-id]
1001
1015
  filegrc list [resource-type] [--workflow] [--json]
1002
1016
  filegrc search <query> [--type resource-type] [--json]
1003
1017
  filegrc obligations [--as-of YYYY-MM-DD] [--from YYYY-MM-DD] [--through YYYY-MM-DD] [--now RFC3339] [--complete] [--json]
@@ -1038,7 +1052,8 @@ function printCommandHelp(command) {
1038
1052
 
1039
1053
  Options:
1040
1054
  --host <address> Bind address. Defaults to FILEGRC_HOST or 127.0.0.1.
1041
- --port <number> Port. Defaults to FILEGRC_PORT or 8787. Use 0 for an available port.
1055
+ --port <number> Preferred port. Defaults to FILEGRC_PORT or 8787. If occupied,
1056
+ the server uses an available port. Use 0 to choose one directly.
1042
1057
  --root <path> Workspace path when no positional root is given.
1043
1058
  --allow-non-authoritative-writes
1044
1059
  Allow local browser writes from a task checkout. This explicit
@@ -1076,15 +1091,19 @@ Options:
1076
1091
  }
1077
1092
  if (command === "migrate") {
1078
1093
  console.log(`Usage:
1079
- filegrc migrate --to-model <2|3> [options]
1094
+ filegrc migrate --to-model <2|3|4> [options]
1080
1095
 
1081
1096
  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
1097
+ workspaces migrate to v2 first. Model v2 workspaces migrate to v3, then v4. v4 separates
1098
+ the repository Workspace, management Program, bounded Systems, operational Components,
1099
+ specific Assets, Vendors, normalized information, and Evidence Artifacts. v3 migration
1100
+ previews may require a decisions JSON file for ambiguous old Systems. v3 still creates planned
1083
1101
  core Appointments, removal of obsolete manual page state, classified review work,
1084
1102
  and dataModelVersion changed last. The command writes no Git commit.
1085
1103
 
1086
1104
  Options:
1087
- --to-model <version> Required target model; 2 for legacy v1 migration, 3 for the active model
1105
+ --to-model <version> Required target model; migrations must run in order through 2, 3, and 4
1106
+ --decisions <path> v4 JSON object keyed by old System ID with system/component decisions
1088
1107
  --preview Show the complete atomic record plan without writing
1089
1108
  --job-title <title> Actual job title for the former Policy Owner seed person
1090
1109
  --starts-on <date> Effective date of a new Policy Owner Appointment
@@ -1094,7 +1113,7 @@ Options:
1094
1113
  --help Show this help
1095
1114
 
1096
1115
  Start with:
1097
- npx filegrc migrate --to-model 3 --preview --json`);
1116
+ npx filegrc migrate --to-model 4 --preview --json`);
1098
1117
  return;
1099
1118
  }
1100
1119
  if (command === "program-readiness") {
@@ -1106,6 +1125,7 @@ controls, and mapped every selected control to a configured authoritative eviden
1106
1125
  source. No audit ID or CPA firm is required.
1107
1126
 
1108
1127
  Options:
1128
+ --program <id> Program to assess when more than one active Program exists
1109
1129
  --as-of <date> Evaluate effective dates and obligations on YYYY-MM-DD
1110
1130
  --require-ready Exit with code 2 unless the Evidence Ready gate passes
1111
1131
  --summary Omit item details and print stage counts and next actions
@@ -1124,6 +1144,7 @@ deterministic Work Items, and one recommended next action.
1124
1144
 
1125
1145
  Options:
1126
1146
  --audit <id> Limit audit assessments to one engagement
1147
+ --program <id> Program to assess when no Audit supplies the Program
1127
1148
  --as-of <date> Evaluate on YYYY-MM-DD
1128
1149
  --through <date> Include scheduled work through YYYY-MM-DD
1129
1150
  --complete Include completed Work Items
@@ -1143,6 +1164,7 @@ and current readiness state. Pass an audit ID to include Step 5 status.
1143
1164
 
1144
1165
  Options:
1145
1166
  --audit <id> Audit record to use for Step 5
1167
+ --program <id> Program to assess when no Audit supplies the Program
1146
1168
  --as-of <date> Evaluate readiness on YYYY-MM-DD
1147
1169
  --summary Print compact status and the first action for all five steps
1148
1170
  --next Print only the current step and its first action
@@ -1157,10 +1179,11 @@ Options:
1157
1179
  filegrc evidence-map [options]
1158
1180
 
1159
1181
  Inspect the evidence-source checks included in Control implementation. Each item
1160
- reports the required source roles, linked Controls, authoritative source Systems,
1182
+ reports the required source roles, linked Controls, authoritative source Components,
1161
1183
  per-record checks, and exact edit commands. This diagnostic is read-only.
1162
1184
 
1163
1185
  Options:
1186
+ --program <id> Program to assess when more than one active Program exists
1164
1187
  --as-of <date> Evaluate the map on YYYY-MM-DD
1165
1188
  --json Print the map as JSON
1166
1189
  --root <path> Workspace path
@@ -1179,7 +1202,7 @@ function agentOverview(model) {
1179
1202
  build: "filegrc build [root]",
1180
1203
  validate: "filegrc validate [root] --json",
1181
1204
  model: "filegrc model --json",
1182
- migrate: "filegrc migrate --to-model 3 --preview --json",
1205
+ migrate: "filegrc migrate --to-model 4 --preview --json",
1183
1206
  describe: "filegrc describe <resource-type>",
1184
1207
  types: "filegrc types --json",
1185
1208
  guide: "filegrc guide [resource-type] --json",