filegrc 0.2.0 → 0.3.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/src/web.js CHANGED
@@ -21,7 +21,7 @@ export function renderIndex(state = null) {
21
21
  <meta charset="utf-8">
22
22
  <meta name="viewport" content="width=device-width,initial-scale=1">
23
23
  <meta name="color-scheme" content="light dark">
24
- <title>FileGRC</title>
24
+ <title>filegrc</title>
25
25
  <link rel="icon" type="image/png" href="./favicon.png">
26
26
  <link rel="stylesheet" href="./filegrc.css">
27
27
  </head>
@@ -52,7 +52,7 @@ const READINESS_STAGES = SHARED_PROGRAM_STAGES.map((stage) => ({
52
52
  }));
53
53
  const STAGE_PAGE_SUMMARIES = ${JSON.stringify({
54
54
  ...RESOURCE_INSTRUCTIONS,
55
- "utility:audit-packet": "Review FileGRC Evidence and External Evidence for the formal period, complete engagement preparation, and build the indexed audit packet."
55
+ "utility:audit-packet": "Review filegrc Evidence and External Evidence for the formal period, complete engagement preparation, and build the indexed audit packet."
56
56
  })};
57
57
  const POLICY_EVENT_NAMES = ${JSON.stringify(POLICY_EVENT_NAMES)};
58
58
  const STAGE_PAGE_ID_ALIASES = {
@@ -174,7 +174,7 @@ function buildNavigation(route) {
174
174
  const organizationCurrent = route.name === "organization" || route.name === "repository" || ["workspace", "renderer-settings"].includes(route.type);
175
175
  const organizationName = state.workspace.organizationName || "Organization";
176
176
  const initial = organizationName.trim().charAt(0).toUpperCase() || "O";
177
- return '<aside class="sidebar" id="sidebar-navigation"><button class="nav-close" type="button" aria-label="Close navigation">×</button><a href="#/" class="brand"' + (route.name === "home" ? ' aria-current="page"' : "") + '><img class="mark" src="./favicon.png" alt="" width="39" height="39"><span><strong>FileGRC</strong><small>SOC 2 workspace</small></span></a><nav class="sidebar-nav">' + stages + '</nav><div class="sidebar-footer"><a class="organization-nav ' + (organizationCurrent ? "current" : "") + '" href="#/organization"><span class="organization-mark">' + esc(initial) + '</span><span><strong>' + esc(organizationName) + '</strong><small>Organization</small></span><span class="organization-arrow">›</span></a></div></aside><button class="nav-scrim" type="button" aria-label="Close navigation"></button>';
177
+ return '<aside class="sidebar" id="sidebar-navigation"><button class="nav-close" type="button" aria-label="Close navigation">×</button><a href="#/" class="brand"' + (route.name === "home" ? ' aria-current="page"' : "") + '><img class="mark" src="./favicon.png" alt="" width="39" height="39"><span><strong>filegrc</strong><small>SOC 2 workspace</small></span></a><nav class="sidebar-nav">' + stages + '</nav><div class="sidebar-footer"><a class="organization-nav ' + (organizationCurrent ? "current" : "") + '" href="#/organization"><span class="organization-mark">' + esc(initial) + '</span><span><strong>' + esc(organizationName) + '</strong><small>Organization</small></span><span class="organization-arrow">›</span></a></div></aside><button class="nav-scrim" type="button" aria-label="Close navigation"></button>';
178
178
  }
179
179
 
180
180
  function readinessStageForRoute(route) {
@@ -216,7 +216,7 @@ function topbar(route) {
216
216
  ? "Work Queue"
217
217
  : route.name === "audit-packet"
218
218
  ? "Audit Readiness"
219
- : state.model.resources[route.type]?.pluralTitle || "FileGRC";
219
+ : state.model.resources[route.type]?.pluralTitle || "filegrc";
220
220
  return '<button class="mobile-nav" type="button" aria-label="Open navigation" aria-controls="sidebar-navigation" aria-expanded="false">☰</button><div><small class="eyebrow">' + esc(state.workspace.organizationName) + '</small><h1>' + esc(titleCase(title)) + '</h1></div><label class="search"><span aria-hidden="true">⌕</span><input id="global-search" type="search" placeholder="Search records" aria-label="Search records"><kbd>/</kbd></label><div class="topbar-status"><a class="validation-chip" href="#/repository"><span class="status-dot ' + (state.validation.ok ? "good" : "bad") + '"></span>' + (state.validation.ok ? "Data valid" : state.validation.counts.errors + " validation errors") + '</a><a class="repo-chip" href="#/repository"><span class="status-dot ' + (state.git.clean ? "good" : "warn") + '"></span>' + esc(state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable") + '</a></div>';
221
221
  }
222
222
 
@@ -227,21 +227,40 @@ function renderHome(main) {
227
227
  const setupPending = rendererSettingsEntry()?.record.showOnboarding === true;
228
228
  const acceptedEventTriggers = state.obligations.triggers.filter(({ programStatus }) => programStatus !== "proposed");
229
229
  const openObligations = state.obligations.items.filter((item) => item.status !== "complete");
230
+ const previewObligations = distinctObligationPreviews(openObligations, 3);
230
231
  const obligationHeading = openObligations.some((item) => item.status !== "proposed") ? "Due Windows" : "Starter Proposals";
231
- const setupBanner = setupPending
232
- ? '<section class="setup-banner"><div><p class="kicker">Setup incomplete</p><h3>Define the initial service boundary</h3><p>Record the management program goal and the systems that should enter policy and control review.</p></div><ol><li>Describe the service boundary.</li><li>Choose the program goal.</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>'
233
- : "";
232
+ const setupBanner = setupPending ? initialSetupBanner() : "";
234
233
  const auditPanel = program.evidenceReady
235
234
  ? '<section class="panel audit-panel"><div class="panel-head"><div><p class="kicker">Optional next phase</p><h3>' + esc(activeFirm ? titleCase(activeAudit.record.title) : "Target: " + program.target.label) + '</h3></div>' + (activeAudit ? '<a href="#/resource/audit/' + encodeURIComponent(activeAudit.record.id) + '">Open audit</a>' : '<a href="#/resources/audit">Engagements</a>') + '</div>' +
236
235
  (activeAudit ? auditProgress(activeAudit.record) + auditEngagementPrompt(activeAudit.record) : auditEngagementPrompt()) + '</section>'
237
236
  : "";
238
237
  main.innerHTML = '<div class="page home-page"><section class="hero overview-hero"><div><p class="kicker">Current program state</p><h2>' + esc(titleCase(state.workspace.title)) + '</h2><p>' + esc(state.workspace.description || "Governance, risk, controls, evidence, and audit work maintained as plain files in Git.") + '</p></div></section>' + setupBanner + readinessOverview() +
239
- '<div class="overview-grid"><section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>' + obligationHeading + '</h3></div><a href="#/stage/run">Open board</a></div>' + obligationPreview(openObligations.slice(0, 3)) + '</section>' +
238
+ '<div class="overview-grid"><section class="panel obligation-panel"><div class="panel-head"><div><p class="kicker">Policy obligations</p><h3>' + obligationHeading + '</h3></div><a href="#/stage/run">Open board</a></div>' + obligationPreview(previewObligations) + '</section>' +
240
239
  '<section class="panel event-reminder-panel"><div class="panel-head"><div><p class="kicker">Event reminders</p><h3>Did Something Change?</h3></div><a href="#/stage/run?section=events">' + (acceptedEventTriggers.length ? "Trigger work" : "Review proposals") + '</a></div>' + eventReminderPreview(state.obligations.triggers.slice(0, 4)) + '</section>' +
241
240
  auditPanel + '</div></div>';
242
241
  main.querySelector("#resume-setup")?.addEventListener("click", requestOnboarding);
243
242
  }
244
243
 
244
+ function initialSetupBanner() {
245
+ const system = resourcesOfType("system").find(({ record }) => record.inScope && record.status !== "retired")?.record;
246
+ if (!system) {
247
+ return '<section class="setup-banner"><div><p class="kicker">Setup incomplete</p><h3>Define the initial service boundary</h3><p>Record the management program goal and the systems that should enter policy and control review.</p></div><ol><li>Describe the service boundary.</li><li>Choose the program goal.</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>';
248
+ }
249
+ const goal = programGoalFromKind(state.workspace.assuranceGoal);
250
+ const goalLabels = {
251
+ readiness: "Program Readiness",
252
+ "type-1": "SOC 2 Type 1",
253
+ "type-2": "SOC 2 Type 2"
254
+ };
255
+ const goalStep = goal === "none"
256
+ ? "Choose the program goal."
257
+ : "Confirm the saved program goal: " + goalLabels[goal] + ".";
258
+ const completion = system.status === "planned"
259
+ ? "Complete setup to activate the planned service and continue to Step 1."
260
+ : "Complete setup to close onboarding and continue to Step 1.";
261
+ return '<section class="setup-banner"><div><p class="kicker">Setup draft saved</p><h3>Review and complete initial setup</h3><p>' + esc(system.title) + ' already has a saved service boundary.</p></div><ol><li>Review the saved service boundary.</li><li>' + esc(goalStep) + '</li><li>' + esc(completion) + '</li><li><button class="text-button" type="button" id="resume-setup">Resume setup</button></li></ol></section>';
262
+ }
263
+
245
264
  function readinessOverview() {
246
265
  const progress = programPathProgress();
247
266
  const nextHref = nextProgramStageHref();
@@ -249,7 +268,7 @@ function readinessOverview() {
249
268
  const stage = READINESS_STAGES.find((candidate) => candidate.id === id);
250
269
  const current = stageProgress(stage);
251
270
  const remaining = current.total - current.complete;
252
- const status = !remaining ? "Complete" : current.complete ? remaining + " remaining" : current.total + " to confirm";
271
+ const status = !remaining ? "Review complete" : current.complete ? remaining + " pages to review" : "Review not marked";
253
272
  return [stage.title, body, href, status, !remaining ? "good" : current.complete ? "warn" : "neutral"];
254
273
  };
255
274
  const stages = [
@@ -257,10 +276,10 @@ function readinessOverview() {
257
276
  programStage("policies", "Tailor the policy set, obtain independent management approval, and establish effective dates.", "#/stage/policies"),
258
277
  programStage("controls", "Finish the internal control set with actual procedures, owners, scope, and evidence sources, then record any complementary controls.", "#/stage/controls"),
259
278
  programStage("evidence", "For each control family, finish the source-system instructions and verify a real test export or capture.", "#/stage/evidence"),
260
- programStage("run", "Begin the candidate period, maintain risk assessments, work the FileGRC queue, run the remaining controls, and retain dated evidence.", "#/stage/run"),
279
+ programStage("run", "Begin the candidate period, maintain risk assessments, work the filegrc queue, run the remaining controls, and retain dated evidence.", "#/stage/run"),
261
280
  programStage("audit", "Engage the CPA firm, confirm the formal period, complete fieldwork, and generate the final evidence packet.", "#/stage/audit")
262
281
  ];
263
- return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>Prepare, Operate, Then Audit</h3></div><div class="readiness-progress-summary"><div><span>Program Progress</span><strong>' + progress.percent + '%</strong><div class="progress"><span style="width:' + progress.percent + '%"></span></div><small>' + esc(progress.complete + " of " + progress.total + " program milestones complete") + '</small></div><a class="button primary" href="' + nextHref + '">Continue</a></div></div><div class="readiness-flow">' + stages.map(([title, body, href, status, tone], index) => '<a href="' + href + '"><span>' + (index + 1) + '</span><strong>' + esc(title) + '</strong><small>' + esc(body) + '</small><b class="readiness-state ' + esc(tone) + '">' + esc(status) + '</b></a>').join("") + '</div></section>';
282
+ return '<section class="readiness-map"><div class="readiness-map-head"><div><p class="kicker">SOC 2 program path</p><h3>Prepare, Operate, Then Audit</h3></div><div class="readiness-progress-summary"><div><span>Page Review Progress</span><strong>' + progress.percent + '%</strong><div class="progress"><span style="width:' + progress.percent + '%"></span></div><small>' + esc(progress.complete + " of " + progress.total + " program pages marked reviewed") + '</small></div><a class="button primary" href="' + nextHref + '">Continue</a></div></div><div class="readiness-flow">' + stages.map(([title, body, href, status, tone], index) => '<a href="' + href + '"><span>' + (index + 1) + '</span><strong>' + esc(title) + '</strong><small>' + esc(body) + '</small><b class="readiness-state ' + esc(tone) + '">' + esc(status) + '</b></a>').join("") + '</div></section>';
264
283
  }
265
284
 
266
285
  function nextProgramStageHref() {
@@ -391,12 +410,12 @@ function programPathProgress() {
391
410
  }
392
411
 
393
412
  function progressFromCounts(complete, total, noun) {
394
- if (!total) return { percent: 0, complete: 0, total: 0, status: "Not started", tone: "neutral", detail: "No " + pluralize(noun, 2) + " are configured yet." };
413
+ if (!total) return { percent: 0, complete: 0, total: 0, status: "Nothing to review", tone: "neutral", detail: "No " + pluralize(noun, 2) + " are configured yet." };
395
414
  const percent = Math.round((complete / total) * 100);
396
- const detail = complete + " of " + total + " " + pluralize(noun, total) + " marked complete.";
397
- if (complete === total) return { percent: 100, complete, total, status: "Complete", tone: "good", detail };
398
- if (!complete) return { percent: 0, complete, total, status: "Not started", tone: "warn", detail };
399
- return { percent, complete, total, status: "In progress", tone: "warn", detail };
415
+ const detail = complete + " of " + total + " " + pluralize(noun, total) + " marked reviewed. This tracks page review, while readiness checks the records themselves.";
416
+ if (complete === total) return { percent: 100, complete, total, status: "Review complete", tone: "good", detail };
417
+ if (!complete) return { percent: 0, complete, total, status: "No pages marked", tone: "warn", detail };
418
+ return { percent, complete, total, status: "Review in progress", tone: "warn", detail };
400
419
  }
401
420
 
402
421
  function stageProgressCard(progress) {
@@ -411,7 +430,7 @@ function sectionDestinations(section) {
411
430
  destinations.push({ type, kind: "Record page", label: titleCase(definition.pluralTitle), href: "#/resources/" + encodeURIComponent(type), description: definition.description });
412
431
  }
413
432
  if (section.utility === "obligation-board") destinations.push({ utility: section.utility, kind: "Working page", label: "Work Queue", href: "#/stage/run", description: "Complete recurring work, Policy Event tasks, and assigned follow-up with its due windows and linked proof." });
414
- if (section.utility === "audit-packet") destinations.push({ utility: section.utility, kind: "Working page", label: "Audit Evidence & Packet", href: "#/audit-packet", description: "Review FileGRC and External Evidence, prepare fieldwork, and build the indexed packet." });
433
+ if (section.utility === "audit-packet") destinations.push({ utility: section.utility, kind: "Working page", label: "Audit Evidence & Packet", href: "#/audit-packet", description: "Review filegrc and External Evidence, prepare fieldwork, and build the indexed packet." });
415
434
  return destinations;
416
435
  }
417
436
 
@@ -468,7 +487,7 @@ function renderObligations(main, params = new URLSearchParams()) {
468
487
  main.innerHTML = '<div class="page obligation-board-page stage-overview-page"><nav class="breadcrumbs"><a href="#/">Overview</a><span>/</span><span>' + esc(stage.title) + '</span></nav>' +
469
488
  '<section class="stage-overview-hero"><div><p class="kicker">Step ' + esc(stage.number) + ' of 6</p><h2>' + esc(stage.title) + '</h2><p>' + esc(stage.summary) + '</p></div>' + stageProgressCard(stageProgress(stage)) + '</section>' +
470
489
  feedback +
471
- '<section class="workflow-section event-reminders"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>Trigger the matching workflow when an event occurs. FileGRC adds every required action to the Work Queue with its owner and deadline.</p></div></div><div class="policy-event-list">' + (triggers || empty("No event-driven obligations are configured.")) + '</div></section>' +
490
+ '<section class="workflow-section event-reminders"><div class="section-head"><div><p class="kicker">Changes that create work</p><h2>Policy Events</h2><p>Trigger the matching workflow when an event occurs. filegrc adds every required action to the Work Queue with its owner and deadline.</p></div></div><div class="policy-event-list">' + (triggers || empty("No event-driven obligations are configured.")) + '</div></section>' +
472
491
  '<section class="workflow-section work-queue-section"><div class="section-head"><div><p class="kicker">Recurring, event, and assigned work</p><h2>Work Queue</h2><p>This board schedules work linked to ' + scheduledControls + ' of ' + controls.length + ' controls and includes ' + assignedFollowUp + ' open ' + pluralize("Action Item", assignedFollowUp) + '. Triggered Policy Event actions appear here as individual tasks in Upcoming, Due, or Overdue. Other controls operate continuously or per transaction in their source systems and are documented through evidence records. Starter work remains a proposal until its governing policies are effective and at least one linked control is implemented.</p></div><div class="page-actions">' + (!state.readOnly ? '<button class="button" type="button" data-new-action-item>New task</button>' : "") + '<a class="button" href="#/resources/obligation">Edit schedules</a></div></div>' +
473
492
  '<div class="obligation-board">' + sections + '</div>' +
474
493
  '</section></div>';
@@ -510,7 +529,7 @@ function policyEventTrigger(trigger, index) {
510
529
  const proposed = trigger.programStatus === "proposed";
511
530
  const unavailable = state.readOnly || proposed;
512
531
  const availability = proposed
513
- ? "Available after its governing policies are effective and at least one linked control is implemented."
532
+ ? "Available after its governing policies are effective, at least one linked control is implemented, and every task has a current owner."
514
533
  : state.readOnly
515
534
  ? "Open this workspace in writable mode to trigger the workflow."
516
535
  : trigger.steps.length + " " + pluralize("task", trigger.steps.length) + " will be added to the Work Queue.";
@@ -525,7 +544,9 @@ function obligationCard(item, collapsed = false) {
525
544
  const type = item.actionItemId ? "action-item" : "obligation";
526
545
  const id = item.actionItemId || item.obligationId;
527
546
  const completion = !item.actionItemId ? obligationCompletionPlan(item) : null;
528
- const action = !state.readOnly && !["upcoming", "proposed"].includes(item.status) && completion
547
+ const canAct = completion?.blocked === "Assign current owner"
548
+ || !["upcoming", "proposed"].includes(item.status);
549
+ const action = !state.readOnly && canAct && completion
529
550
  ? completion.blocked
530
551
  ? '<a class="obligation-action blocked" href="' + completion.href + '">' + esc(completion.blocked) + '</a>'
531
552
  : '<button class="obligation-action" type="button" data-record-obligation="' + esc(item.key) + '">Record work</button>'
@@ -536,14 +557,17 @@ function obligationCard(item, collapsed = false) {
536
557
 
537
558
  function obligationCompletionPlan(item) {
538
559
  const type = OBLIGATION_COMPLETION_TYPES[item.activityType] || "evidence";
560
+ if (!currentPeopleForParties(item.ownerIds || []).length) {
561
+ return { type, blocked: "Assign current owner", href: "#/resource/obligation/" + encodeURIComponent(item.obligationId) };
562
+ }
539
563
  if (["access-review", "backup-test"].includes(type) && !resourcesOfType("system").some(({ record }) => record.inScope && record.status !== "retired")) {
540
564
  return { type, blocked: "Add system first", href: "#/resources/system?new=1" };
541
565
  }
542
566
  if (type === "vendor-review" && !resourcesOfType("vendor").some(({ record }) => record.status !== "terminated")) {
543
567
  return { type, blocked: "Add vendor first", href: "#/resources/vendor?new=1" };
544
568
  }
545
- if (type === "meeting" && !resourcesOfType("team").length) {
546
- return { type, blocked: "Add team first", href: "#/resources/team?new=1" };
569
+ if (type === "meeting" && !completionTeam(item)) {
570
+ return { type, blocked: "Finish team and chair", href: "#/resources/team" };
547
571
  }
548
572
  return { type };
549
573
  }
@@ -567,15 +591,14 @@ function openObligationCompletion(item) {
567
591
  function obligationCompletionSeed(type, item, obligation) {
568
592
  const date = currentDate();
569
593
  const timestamp = new Date().toISOString();
570
- const people = resourcesOfType("person").filter(({ record }) => record.status === "active").map(({ record }) => record.id);
571
- const ownerIds = (item.ownerIds || []).filter((id) => people.includes(id));
572
- const responsiblePeople = ownerIds.length ? ownerIds : people.slice(0, 1);
594
+ const responsiblePeople = currentPeopleForParties(item.ownerIds || []);
573
595
  const inScopeSystems = resourcesOfType("system").filter(({ record }) => record.inScope && record.status !== "retired").map(({ record }) => record.id);
574
596
  const activeVendors = resourcesOfType("vendor").filter(({ record }) => record.status !== "terminated").map(({ record }) => record.id);
575
597
  const title = item.title + " · " + formatCalendarDate(item.dueWindowStart);
576
598
  const common = { title };
577
599
  if (type === "meeting") {
578
- return { ...common, status: "complete", teamId: resourcesOfType("team")[0]?.record.id, chairIds: responsiblePeople, scheduledOn: date };
600
+ const team = completionTeam(item);
601
+ return { ...common, status: "complete", teamId: team.id, chairIds: currentPeopleForParties(team.chairIds || []), scheduledOn: date };
579
602
  }
580
603
  if (type === "policy-review") {
581
604
  return { ...common, status: "complete", scopeResourceIds: obligation.scopeResourceIds || [], reviewerIds: responsiblePeople, reviewedOn: date, outcome: "passed", changesRequired: false, periodStart: item.dueWindowStart, periodEnd: item.dueWindowEnd };
@@ -678,10 +701,10 @@ function openObligationEventDialog(trigger) {
678
701
  function renderAuditPacket(main, params = new URLSearchParams()) {
679
702
  const audits = resourcesOfType("audit");
680
703
  const evidence = resourcesOfType("evidence");
681
- const fileGRCRecordTypes = new Set((state.model.evidenceSourceFamilies || [])
704
+ const filegrcRecordTypes = new Set((state.model.evidenceSourceFamilies || [])
682
705
  .filter((family) => family.collectionTestRequired === false)
683
706
  .flatMap((family) => family.operationRecordTypes || []));
684
- const fileGRCRecords = state.resources.filter(({ record }) => fileGRCRecordTypes.has(record.type));
707
+ const filegrcRecords = state.resources.filter(({ record }) => filegrcRecordTypes.has(record.type));
685
708
  const requestedAudit = params.get("auditId");
686
709
  const selected = audits.find(({ record }) => record.id === requestedAudit)?.record || audits.find(({ record }) => record.status !== "complete")?.record || null;
687
710
  const today = currentDate();
@@ -693,15 +716,15 @@ function renderAuditPacket(main, params = new URLSearchParams()) {
693
716
  const preflight = [
694
717
  ["Repository", state.git.available ? state.git.clean ? "Clean revision" : state.git.changes.length + " uncommitted" : "Git unavailable", "#/repository", state.git.clean ? "good" : "warn"],
695
718
  ["Engagement", selected ? selected.title : "No audit record", "#/resources/audit", selected ? "good" : "warn"],
696
- ["FileGRC Evidence", fileGRCRecords.length + " operating " + pluralize("record", fileGRCRecords.length), "#/stage/run", "neutral"],
719
+ ["filegrc Evidence", filegrcRecords.length + " operating " + pluralize("record", filegrcRecords.length), "#/stage/run", "neutral"],
697
720
  ["External Evidence", evidence.length + " " + pluralize("record", evidence.length), "#/resources/evidence", evidence.length ? "good" : "neutral"],
698
721
  ["Policy work", state.obligations.counts.overdue ? state.obligations.counts.overdue + " overdue" : state.obligations.counts.due ? state.obligations.counts.due + " due" : state.obligations.counts.proposed ? state.obligations.counts.proposed + " proposals" : "No work due", "#/stage/run", state.obligations.counts.overdue ? "bad" : state.obligations.counts.due ? "warn" : state.obligations.counts.proposed ? "neutral" : "good"]
699
722
  ];
700
- const evidencePaths = '<section class="panel audit-evidence-paths"><div class="panel-head"><div><p class="kicker">Evidence workflow</p><h3>Review both evidence paths</h3><p>Use the formal audit date or period. Each selected control may need one or both paths.</p></div></div><div class="audit-evidence-path-grid"><a href="#/stage/run"><span class="step-label">FileGRC Evidence</span><h4>Review operating records</h4><p>Complete the applicable Step 5 records, link them to their Controls, and record results in their fields or Markdown. Link any external artifact needed to support the result. The packet includes the records, Markdown, Git history, and linked artifacts.</p></a><a href="#/resources/evidence"><span class="step-label">External Evidence</span><h4>Review imported or referenced proof</h4><p>Verify the source System, audit date or period, Control links, collector, verifier, and fixed attachment or approved external reference. The packet includes the records, retained files, delivery index, and checksums.</p></a></div></section>';
723
+ const evidencePaths = '<section class="panel audit-evidence-paths"><div class="panel-head"><div><p class="kicker">Evidence workflow</p><h3>Review both evidence paths</h3><p>Use the formal audit date or period. Each selected control may need one or both paths.</p></div></div><div class="audit-evidence-path-grid"><a href="#/stage/run"><span class="step-label">filegrc Evidence</span><h4>Review operating records</h4><p>Complete the applicable Step 5 records, link them to their Controls, and record results in their fields or Markdown. Link any external artifact needed to support the result. The packet includes the records, Markdown, Git history, and linked artifacts.</p></a><a href="#/resources/evidence"><span class="step-label">External Evidence</span><h4>Review imported or referenced proof</h4><p>Verify the source System, audit date or period, Control links, collector, verifier, and fixed attachment or approved external reference. The packet includes the records, retained files, delivery index, and checksums.</p></a></div></section>';
701
724
  const dateFields = typeOne
702
725
  ? '<label><span>As-of date</span><input type="date" name="start" required value="' + esc(start) + '"></label>'
703
726
  : '<label><span>Period start</span><input type="date" name="start" required value="' + esc(start) + '"></label><label><span>Period end</span><input type="date" name="end" required value="' + esc(end) + '"></label>';
704
- main.innerHTML = '<div class="page audit-packet-page"><div class="page-intro"><div><p class="kicker">Audit evidence and packet</p><h2>Prepare Fieldwork</h2><p>Confirm the firm-agreed date or period, review FileGRC Evidence and External Evidence, complete management documents and Type 2 populations, then build the indexed packet. FileGRC compiles both evidence paths with their links, control matrix, history, attachments, indexes, and checksums.</p></div></div><section class="packet-preflight" aria-label="Packet readiness">' + preflight.map(([label, value, href, tone]) => '<a href="' + href + '"><span class="status-dot ' + tone + '"></span><span><small>' + esc(label) + '</small><strong>' + esc(value) + '</strong></span></a>').join("") + '</section>' + evidencePaths + renderAuditPreparation(preparation) + '<section class="panel packet-builder"><div class="panel-head"><div><p class="kicker">Evidence delivery</p><h3>' + (typeOne ? "Build the As-of Packet" : "Build the Period Packet") + '</h3></div></div><form id="packet-form">' + dateFields + '<label><span>Audit <small>required for delivery</small></span><select name="auditId"><option value="">Draft Without Audit Scope</option>' + audits.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === selected?.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><button class="button primary" type="submit" ' + (state.readOnly ? "disabled" : "") + '>' + (draft ? "Generate draft" : "Generate packet") + '</button></form><p class="packet-note">' + (state.readOnly ? "Packet generation requires the local writable renderer or the CLI." : draft ? "Drafts expose coverage gaps now. Commit a clean revision and select an audit record before delivery." : "The packet is derived under .filegrc/ and bound to the selected audit and current Git revision. FileGRC checks preparation and integrity; the engagement team determines evidence sufficiency.") + '</p><div class="dialog-error" role="alert"></div></section><div id="packet-results"></div></div>';
727
+ main.innerHTML = '<div class="page audit-packet-page"><div class="page-intro"><div><p class="kicker">Audit evidence and packet</p><h2>Prepare Fieldwork</h2><p>Confirm the firm-agreed date or period, review filegrc Evidence and External Evidence, complete management documents and Type 2 populations, then build the indexed packet. filegrc compiles both evidence paths with their links, control matrix, history, attachments, indexes, and checksums.</p></div></div><section class="packet-preflight" aria-label="Packet readiness">' + preflight.map(([label, value, href, tone]) => '<a href="' + href + '"><span class="status-dot ' + tone + '"></span><span><small>' + esc(label) + '</small><strong>' + esc(value) + '</strong></span></a>').join("") + '</section>' + evidencePaths + renderAuditPreparation(preparation) + '<section class="panel packet-builder"><div class="panel-head"><div><p class="kicker">Evidence delivery</p><h3>' + (typeOne ? "Build the As-of Packet" : "Build the Period Packet") + '</h3></div></div><form id="packet-form">' + dateFields + '<label><span>Audit <small>required for delivery</small></span><select name="auditId"><option value="">Draft Without Audit Scope</option>' + audits.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === selected?.id ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><button class="button primary" type="submit" ' + (state.readOnly ? "disabled" : "") + '>' + (draft ? "Generate draft" : "Generate packet") + '</button></form><p class="packet-note">' + (state.readOnly ? "Packet generation requires the local writable renderer or the CLI." : draft ? "Drafts expose coverage gaps now. Commit a clean revision and select an audit record before delivery." : "The packet is derived under .filegrc/ and bound to the selected audit and current Git revision. filegrc checks preparation and integrity; the engagement team determines evidence sufficiency.") + '</p><div class="dialog-error" role="alert"></div></section><div id="packet-results"></div></div>';
705
728
  main.querySelector('select[name="auditId"]').addEventListener("change", (event) => {
706
729
  const next = event.currentTarget.value;
707
730
  location.hash = "#/audit-packet" + (next ? "?auditId=" + encodeURIComponent(next) : "");
@@ -796,18 +819,31 @@ function renderPacketResults(container, result) {
796
819
  const packet = result.packet;
797
820
  const ready = packet.readiness.status === "delivery-ready";
798
821
  container.innerHTML = '<section class="metrics packet-metrics">' +
799
- metric("FileGRC Evidence", packet.summary.fileGRCRecords, packet.summary.records + " total packet records", "neutral") +
822
+ metric("filegrc Evidence", packet.summary.filegrcRecords, packet.summary.records + " total packet records", "neutral") +
800
823
  metric("Obligations", packet.summary.obligationOccurrences, packet.summary.eventRuns + " event workflows", "neutral") +
801
824
  metric("External Evidence", packet.summary.evidence, packet.summary.policies + " policies · " + packet.summary.controls + " controls", "neutral") +
802
825
  metric("Review items", packet.summary.gaps, packet.summary.errors + " errors · " + packet.summary.warnings + " warnings", packet.summary.errors ? "bad" : packet.summary.warnings ? "warn" : "good") +
803
- '</section><section class="panel packet-output"><div class="panel-head"><div><p class="kicker">' + (ready ? "FileGRC management checks passed" : "Draft packet") + '</p><h3>' + esc(result.output) + '</h3></div>' + (result.packetUrl ? '<a class="button primary" href="' + esc(result.packetUrl) + '" target="_blank" rel="noreferrer">Open index</a>' : "") + '</div><p>The directory contains ' + result.files.length + ' files. ' + (ready ? "Verify the checksums, reconcile external deliveries, and let the engagement team confirm evidence sufficiency." : "Do not deliver it until every error is resolved and each warning has been reviewed.") + '</p></section>' +
804
- '<div class="dashboard-grid"><section class="panel span-2"><div class="panel-head"><h3>Coverage Gaps and Warnings</h3></div>' + (packet.gaps.length ? '<div class="packet-gaps">' + packet.gaps.map((gap) => '<div><span class="badge ' + (gap.severity === "error" ? "bad" : "warn") + '">' + esc(properCase(gap.severity)) + '</span><p>' + esc(gap.message) + '</p></div>').join("") + '</div>' : empty("No packet gaps were detected.")) + '</section><section class="panel"><div class="panel-head"><h3>Included FileGRC Evidence</h3></div>' + (packet.fileGRCRecords.length ? '<div class="packet-list">' + packet.fileGRCRecords.slice(0, 12).map((item) => '<a href="#/resource/' + encodeURIComponent(item.type) + '/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.type)) + ' · ' + esc(item.primaryDate) + '</small></a>').join("") + '</div>' : empty("No FileGRC Evidence matched.")) + '</section><section class="panel"><div class="panel-head"><h3>Included External Evidence</h3></div>' + (packet.evidence.length ? '<div class="packet-list">' + packet.evidence.slice(0, 12).map((item) => '<a href="#/resource/evidence/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.status)) + ' · ' + esc(properCase(item.evidenceKind)) + '</small></a>').join("") + '</div>' : empty("No External Evidence matched.")) + '</section></div>';
826
+ '</section><section class="panel packet-output"><div class="panel-head"><div><p class="kicker">' + (ready ? "filegrc management checks passed" : "Draft packet") + '</p><h3>' + esc(result.output) + '</h3></div>' + (result.packetUrl ? '<a class="button primary" href="' + esc(result.packetUrl) + '" target="_blank" rel="noreferrer">Open index</a>' : "") + '</div><p>The directory contains ' + result.files.length + ' files. ' + (ready ? "Verify the checksums, reconcile external deliveries, and let the engagement team confirm evidence sufficiency." : "Do not deliver it until every error is resolved and each warning has been reviewed.") + '</p></section>' +
827
+ '<div class="dashboard-grid"><section class="panel span-2"><div class="panel-head"><h3>Coverage Gaps and Warnings</h3></div>' + (packet.gaps.length ? '<div class="packet-gaps">' + packet.gaps.map((gap) => '<div><span class="badge ' + (gap.severity === "error" ? "bad" : "warn") + '">' + esc(properCase(gap.severity)) + '</span><p>' + esc(gap.message) + '</p></div>').join("") + '</div>' : empty("No packet gaps were detected.")) + '</section><section class="panel"><div class="panel-head"><h3>Included filegrc Evidence</h3></div>' + (packet.filegrcRecords.length ? '<div class="packet-list">' + packet.filegrcRecords.slice(0, 12).map((item) => '<a href="#/resource/' + encodeURIComponent(item.type) + '/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.type)) + ' · ' + esc(item.primaryDate) + '</small></a>').join("") + '</div>' : empty("No filegrc Evidence matched.")) + '</section><section class="panel"><div class="panel-head"><h3>Included External Evidence</h3></div>' + (packet.evidence.length ? '<div class="packet-list">' + packet.evidence.slice(0, 12).map((item) => '<a href="#/resource/evidence/' + encodeURIComponent(item.id) + '"><strong>' + esc(item.title) + '</strong><small>' + esc(properCase(item.status)) + ' · ' + esc(properCase(item.evidenceKind)) + '</small></a>').join("") + '</div>' : empty("No External Evidence matched.")) + '</section></div>';
805
828
  }
806
829
 
807
830
  function obligationPreview(items) {
808
831
  return items.length ? '<div class="obligation-preview">' + items.map((item) => '<a href="#/stage/run"><span class="status-dot ' + (item.status === "overdue" ? "bad" : item.status === "due" ? "warn" : "neutral") + '"></span><span><strong>' + esc(item.title) + '</strong><small>' + esc(timingText(item)) + '</small></span></a>').join("") + '</div>' : empty("No open obligations.");
809
832
  }
810
833
 
834
+ function distinctObligationPreviews(items, limit) {
835
+ const seen = new Set();
836
+ const result = [];
837
+ for (const item of items) {
838
+ const source = item.obligationId || item.actionItemId || item.key;
839
+ if (seen.has(source)) continue;
840
+ seen.add(source);
841
+ result.push(item);
842
+ if (result.length === limit) break;
843
+ }
844
+ return result;
845
+ }
846
+
811
847
  function eventReminderPreview(triggers) {
812
848
  return triggers.length ? '<div class="event-reminder-preview">' + triggers.map((trigger) => {
813
849
  const proposed = trigger.programStatus === "proposed";
@@ -1182,7 +1218,7 @@ function openCommitDialog() {
1182
1218
  const dialog = document.createElement("dialog");
1183
1219
  dialog.className = "commit-dialog";
1184
1220
  dialog.setAttribute("aria-labelledby", "commit-dialog-title");
1185
- dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git audit trail</p><h2 id="commit-dialog-title">' + (hasRemote ? "Commit and Push Workspace Changes" : "Commit Workspace Changes") + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + (hasRemote ? "Commit every change under this FileGRC workspace, then push the commit to its Git remote." : "Commit every change under this FileGRC workspace. Add a Git remote later when you are ready to sync it.") + ' Use a message that explains why the compliance records changed.</p><label><span>Commit message</span><input name="message" required maxlength="200" placeholder="Record quarterly access review"></label><div class="commit-files">' + state.git.changes.map((change) => '<code>' + esc(change) + '</code>').join("") + '</div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-commit="cancel">Cancel</button><button type="submit" class="button primary">' + actionLabel + '</button></div></form>';
1221
+ dialog.innerHTML = '<form><div class="dialog-head"><div><p class="kicker">Git audit trail</p><h2 id="commit-dialog-title">' + (hasRemote ? "Commit and Push Workspace Changes" : "Commit Workspace Changes") + '</h2></div><button type="button" class="icon-button" aria-label="Close">×</button></div><p>' + (hasRemote ? "Commit every change under this filegrc workspace, then push the commit to its Git remote." : "Commit every change under this filegrc workspace. Add a Git remote later when you are ready to sync it.") + ' Use a message that explains why the compliance records changed.</p><label><span>Commit message</span><input name="message" required maxlength="200" placeholder="Record quarterly access review"></label><div class="commit-files">' + state.git.changes.map((change) => '<code>' + esc(change) + '</code>').join("") + '</div><div class="dialog-error" role="alert"></div><div class="dialog-actions"><button type="button" class="button" data-commit="cancel">Cancel</button><button type="submit" class="button primary">' + actionLabel + '</button></div></form>';
1186
1222
  document.body.append(dialog);
1187
1223
  dialog.showModal();
1188
1224
  dialog.querySelector(".icon-button").addEventListener("click", () => dialog.close());
@@ -1402,7 +1438,7 @@ function onboardingSteps() {
1402
1438
  points: [
1403
1439
  "Quarterly means any date in that cycle is valid unless the policy sets a narrower window.",
1404
1440
  "Link a dated completion record and its evidence to satisfy one occurrence.",
1405
- "The UI and FileGRC CLI use the same calculation."
1441
+ "The UI and filegrc CLI use the same calculation."
1406
1442
  ]
1407
1443
  };
1408
1444
  const events = {
@@ -1414,7 +1450,7 @@ function onboardingSteps() {
1414
1450
  "The checklist stays open until every action is done and has the requested completion record or evidence.",
1415
1451
  "Hour-based rules keep the event time and exact cutoff; day-based rules keep the policy date range.",
1416
1452
  "Every action has a policy-based cutoff or a reasonable default deadline for the event.",
1417
- "Agents start the identical workflow with the FileGRC CLI."
1453
+ "Agents start the identical workflow with the filegrc CLI."
1418
1454
  ]
1419
1455
  };
1420
1456
  const reportTypes = {
@@ -1435,7 +1471,7 @@ function onboardingSteps() {
1435
1471
  body: "A CPA evaluates whether the controls operated consistently throughout an agreed review period, often six months. Dated evidence must cover that period."
1436
1472
  }
1437
1473
  ],
1438
- afterSections: "Most evidence comes from production, identity, monitoring, and business systems. FileGRC records where it comes from and how it was collected."
1474
+ afterSections: "Most evidence comes from production, identity, monitoring, and business systems. filegrc records where it comes from and how it was collected."
1439
1475
  };
1440
1476
  const audit = {
1441
1477
  target: ".audit-panel",
@@ -1452,7 +1488,7 @@ function onboardingSteps() {
1452
1488
  target: null,
1453
1489
  kicker: "Initial scope",
1454
1490
  title: "Describe the service you plan to audit",
1455
- body: "This records the management goal and creates the first in-scope system. It does not create an audit engagement. Next, finish Step 1 by confirming the starter people and oversight team, criteria, commitments, vendors, and systems."
1491
+ body: "This records the management goal and creates the first in-scope system. It does not create an audit engagement. Next, finish Step 1 by adding the real reviewers and operators, finishing the oversight team, and confirming the criteria, commitments, vendors, and systems."
1456
1492
  };
1457
1493
  return [
1458
1494
  files,
@@ -2307,6 +2343,28 @@ function formatCadence(value) {
2307
2343
  return value.mode ? humanize(value.mode) : "";
2308
2344
  }
2309
2345
  function resourcesOfType(type) { return state.resources.filter(({ record }) => record.type === type); }
2346
+ function currentPeopleForParties(ids = [], seen = new Set()) {
2347
+ const people = [];
2348
+ for (const id of ids) {
2349
+ if (seen.has(id)) continue;
2350
+ seen.add(id);
2351
+ const party = state.resources.find(({ record }) => record.id === id)?.record;
2352
+ if (party?.type === "person" && ["active", "external"].includes(party.status)) people.push(party.id);
2353
+ if (party?.type === "team" && party.status === "active") {
2354
+ people.push(...currentPeopleForParties([...(party.memberIds || []), ...(party.chairIds || [])], seen));
2355
+ }
2356
+ }
2357
+ return [...new Set(people)];
2358
+ }
2359
+ function completionTeam(item) {
2360
+ const ownerIds = new Set(item.ownerIds || []);
2361
+ const teams = resourcesOfType("team").map(({ record }) => record);
2362
+ const ownedTeams = teams.filter((record) => ownerIds.has(record.id));
2363
+ return (ownedTeams.length ? ownedTeams : teams).find((record) => (
2364
+ record.status === "active"
2365
+ && currentPeopleForParties(record.chairIds || []).length
2366
+ )) || null;
2367
+ }
2310
2368
  function searchText(record) {
2311
2369
  const definition = state.model.resources[record.type];
2312
2370
  if (!definition) return "";
@@ -2507,7 +2565,7 @@ async function localFetch(url, options) {
2507
2565
  try {
2508
2566
  return await fetch(url, options);
2509
2567
  } catch {
2510
- throw new Error("The FileGRC server is unavailable. Restart npm run serve, or pnpm dev in the monorepo, and try again.");
2568
+ throw new Error("The filegrc server is unavailable. Restart npm run serve, or pnpm dev in the monorepo, and try again.");
2511
2569
  }
2512
2570
  }
2513
2571
  async function fetchJson(url, options) { const response = await localFetch(url, options); if (!response.ok) throw new Error(await responseMessage(response)); return response.json(); }