filegrc 0.3.3 → 0.3.4

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/README.md CHANGED
@@ -34,7 +34,7 @@ npx filegrc prepare-audit audit-id
34
34
  npx filegrc evidence-packet --start 2026-01-01 --end 2026-06-30 --audit audit-id
35
35
  ```
36
36
 
37
- `filegrc serve --help` prints bind, port, environment, and safety options without starting the server. The editable server defaults to `127.0.0.1:8787`; set `FILEGRC_HOST`, `FILEGRC_PORT`, or the matching flags when needed.
37
+ `filegrc serve --help` prints bind, port, environment, and safety options without starting the server. The editable server defaults to `127.0.0.1:8787`; set `FILEGRC_HOST`, `FILEGRC_PORT`, or the matching flags when needed. In trunk mode, browser saves synchronize, commit, and push from the authoritative branch. Use `--allow-non-authoritative-writes` for local development in a task checkout; the override never commits or pushes.
38
38
 
39
39
  `filegrc setup` provides the headless equivalent of browser onboarding. Run it without arguments for guided terminal setup, or pass all initial service-boundary fields and a management program goal as flags or a JSON payload. Add `--preview` to validate and inspect the planned service and workspace writes without saving. Add `--summary --json` for compact agent output. Selecting Type 1 or Type 2 updates the workspace goal and selected systems. Setup does not select framework records, link controls, create evidence, or create an audit record.
40
40
 
@@ -54,7 +54,7 @@ Use `filegrc attach <evidence-id> <source-file>` to copy a fixed evidence file u
54
54
 
55
55
  Use `filegrc detach <evidence-id> <attachment-name> --yes` for explicit removal. Evidence records with linked local attachments cannot be deleted.
56
56
 
57
- The package requires Node.js 20 or newer. It uses Git for authors, commit timestamps, messages, diffs, and revisions. Browser commits are explicit. Without a remote they remain local; with a remote the browser commits and pushes together. Browser pulls use rebase. Agents and terminal users use Git directly; the filegrc CLI does not wrap pull, commit, or push.
57
+ The package requires Node.js 20 or newer. It uses Git for authors, commit timestamps, messages, diffs, and revisions. New workspaces use trunk mode, which fetches and fast-forwards before each browser mutation, validates and commits the saved change, then pushes. Existing settings without `repositoryMode` keep manual browser Git behavior. Agents and terminal users use Git directly; the filegrc CLI does not wrap pull, commit, or push.
58
58
 
59
59
  The editable server has no authentication and binds to loopback by default. Put it behind trusted authentication before exposing it on a network, or publish the read-only static build.
60
60
 
package/model/v1.json CHANGED
@@ -104,17 +104,20 @@
104
104
  "pluralTitle": "Renderer settings",
105
105
  "group": "repository",
106
106
  "singleton": "renderer.json",
107
- "description": "Optional local interface settings, including onboarding visibility and manually completed Step pages. Renderer settings are not required for SOC 2 and do not change compliance records.",
107
+ "description": "Optional local interface and browser repository settings, including onboarding visibility, manually completed Step pages, and authoritative-branch synchronization. Renderer settings are not required for SOC 2 and do not change compliance records.",
108
108
  "guidance": {
109
- "policyBasis": "Renderer settings are a filegrc convenience, not a SOC 2 requirement, control, audit record, or substitute for evidence.",
110
- "cadence": "Change it when the team wants to rerun or suppress an optional renderer workflow, then review and commit the resulting diff."
109
+ "policyBasis": "Renderer settings are a filegrc convenience, not a SOC 2 requirement, control, audit record, or substitute for evidence. Record lifecycle status represents approval; Git branches do not.",
110
+ "cadence": "Change it when the team wants to rerun or suppress an optional renderer workflow or change repository synchronization. Existing workspaces without repositoryMode keep manual browser Git behavior."
111
111
  },
112
112
  "required": ["showOnboarding"],
113
113
  "fields": {
114
114
  "showOnboarding": {"type":"boolean","label":"Show onboarding"},
115
- "completedStagePageIds": {"type":"array","items":"string","label":"Manually completed program pages"}
115
+ "completedStagePageIds": {"type":"array","items":"string","label":"Manually completed program pages"},
116
+ "repositoryMode": {"type":"enum","values":["trunk","manual"],"label":"Repository mode"},
117
+ "authoritativeBranch": {"type":"string","format":"git-name","label":"Authoritative branch (defaults to main)"},
118
+ "repositoryRemote": {"type":"string","format":"git-name","label":"Repository remote (defaults to origin)"}
116
119
  },
117
- "listFields": ["title","showOnboarding","completedStagePageIds"]
120
+ "listFields": ["title","showOnboarding","repositoryMode","authoritativeBranch","repositoryRemote","completedStagePageIds"]
118
121
  },
119
122
  "person": {
120
123
  "title": "Person",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "filegrc",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Zero-dependency Git-native GRC engine",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli.js CHANGED
@@ -36,6 +36,7 @@ import { validateWorkspace } from "./validate.js";
36
36
  import { loadWorkspace } from "./workspace.js";
37
37
 
38
38
  const BOOLEAN_FLAGS = new Set([
39
+ "allow-non-authoritative-writes",
39
40
  "check-docs",
40
41
  "complete",
41
42
  "current",
@@ -63,7 +64,8 @@ export async function runCli(argv = process.argv.slice(2)) {
63
64
  if (command === "serve") {
64
65
  const result = await serveWorkspace(positionals[0] ?? root, {
65
66
  host: flags.host ?? process.env.FILEGRC_HOST,
66
- port: flags.port ?? process.env.FILEGRC_PORT
67
+ port: flags.port ?? process.env.FILEGRC_PORT,
68
+ allowNonAuthoritativeWrites: flags["allow-non-authoritative-writes"] === true
67
69
  });
68
70
  const stopped = new Promise((resolvePromise) => {
69
71
  const stop = () => {
@@ -683,7 +685,7 @@ function printHelp() {
683
685
  console.log(`filegrc - Git-native GRC workspace
684
686
 
685
687
  Usage:
686
- filegrc serve [root] [--host 127.0.0.1] [--port 8787]
688
+ filegrc serve [root] [--host 127.0.0.1] [--port 8787] [--allow-non-authoritative-writes]
687
689
  filegrc setup [setup.json|-] [setup options] [--draft] [--preview] [--summary] [--json]
688
690
  filegrc build [root] [--output .filegrc/site]
689
691
  filegrc validate [root] [--json]
@@ -720,12 +722,15 @@ All commands accept --root <workspace>. Writes never create Git commits.`);
720
722
  function printCommandHelp(command) {
721
723
  if (command === "serve") {
722
724
  console.log(`Usage:
723
- filegrc serve [root] [--host address] [--port number]
725
+ filegrc serve [root] [--host address] [--port number] [--allow-non-authoritative-writes]
724
726
 
725
727
  Options:
726
728
  --host <address> Bind address. Defaults to FILEGRC_HOST or 127.0.0.1.
727
729
  --port <number> Port. Defaults to FILEGRC_PORT or 8787. Use 0 for an available port.
728
730
  --root <path> Workspace path when no positional root is given.
731
+ --allow-non-authoritative-writes
732
+ Allow local browser writes from a task checkout. This explicit
733
+ development override never commits or pushes.
729
734
  --help Show this help without starting the server.
730
735
 
731
736
  Safety:
@@ -0,0 +1,16 @@
1
+ export function isSafeGitName(value) {
2
+ const segments = value.split("/");
3
+ return Boolean(value)
4
+ && value !== "@"
5
+ && value !== "HEAD"
6
+ && !value.startsWith("-")
7
+ && !value.includes("..")
8
+ && !value.includes("@{")
9
+ && !/[\s~^:?*[\]\\\u0000-\u001f\u007f]/.test(value)
10
+ && segments.every((segment) => (
11
+ segment
12
+ && !segment.startsWith(".")
13
+ && !segment.endsWith(".")
14
+ && !segment.endsWith(".lock")
15
+ ));
16
+ }
package/src/git.js CHANGED
@@ -1,7 +1,14 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { rm } from "node:fs/promises";
4
+ import { relative, resolve, sep } from "node:path";
5
+ import { isSafeGitName } from "./git-name.js";
2
6
  import { serializeWorkspaceMutation } from "./mutation.js";
3
7
  import { resolveWorkspaceRoot } from "./paths.js";
4
8
  import { validateWorkspace } from "./validate.js";
9
+ import { loadWorkspace } from "./workspace.js";
10
+
11
+ const lastSuccessfulSynchronizations = new Map();
5
12
 
6
13
  export function getGitSummary(input = process.cwd()) {
7
14
  const root = resolveWorkspaceRoot(input);
@@ -120,6 +127,175 @@ export async function pushWorkspace(input = process.cwd()) {
120
127
  return serializeWorkspaceMutation(input, pushWorkspaceUnlocked);
121
128
  }
122
129
 
130
+ export async function getBrowserRepositoryState(input = process.cwd(), options = {}) {
131
+ const root = resolveWorkspaceRoot(input);
132
+ const config = await getRepositoryConfig(root);
133
+ const gitSummary = getGitSummary(root);
134
+ if (config.mode !== "trunk") {
135
+ return {
136
+ mode: "manual",
137
+ authoritativeBranch: config.authoritativeBranch,
138
+ remote: config.remote,
139
+ developmentOverride: false,
140
+ status: "manual",
141
+ label: "Manual Git",
142
+ writesAllowed: !options.readOnly,
143
+ currentCommit: gitSummary.commit,
144
+ upstreamCommit: null,
145
+ ahead: null,
146
+ behind: null,
147
+ pendingCommits: [],
148
+ pendingCommitsFilegrcOnly: null,
149
+ lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
150
+ message: "Browser writes stay local until a user commits and synchronizes them."
151
+ };
152
+ }
153
+
154
+ const details = inspectTrunkRepository(root, config, gitSummary);
155
+ const developmentOverride = options.allowNonAuthoritativeWrites === true;
156
+ if (developmentOverride) {
157
+ return {
158
+ ...details,
159
+ developmentOverride: true,
160
+ writesAllowed: !options.readOnly,
161
+ status: "not-synced",
162
+ label: "Not synced",
163
+ message: "Development override is active. Browser writes stay local and FileGRC will not commit or push them."
164
+ };
165
+ }
166
+ return {
167
+ ...details,
168
+ developmentOverride: false,
169
+ writesAllowed: !options.readOnly && details.writesAllowed
170
+ };
171
+ }
172
+
173
+ export async function runBrowserMutation(input, options, task) {
174
+ return serializeWorkspaceMutation(input, async (root) => {
175
+ const config = await getRepositoryConfig(root);
176
+ if (config.mode !== "trunk" || options?.allowNonAuthoritativeWrites === true) {
177
+ return task(root);
178
+ }
179
+ return runTrunkMutationUnlocked(root, config, options, task);
180
+ });
181
+ }
182
+
183
+ export async function retryBrowserSync(input = process.cwd(), options = {}) {
184
+ return serializeWorkspaceMutation(input, async (root) => {
185
+ const config = await getRepositoryConfig(root);
186
+ if (config.mode !== "trunk") throw new Error("Retry sync is available only in trunk repository mode.");
187
+ if (options.allowNonAuthoritativeWrites === true) {
188
+ throw new Error("Retry sync is disabled while the development write override is active.");
189
+ }
190
+ const before = requireTrunkPreconditions(root, config, { allowAhead: true });
191
+ fetchConfiguredRemote(root, config.remote);
192
+ const synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
193
+ if (synchronized.behind > 0 && synchronized.ahead > 0) {
194
+ throw new Error("The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.");
195
+ }
196
+ if (synchronized.behind > 0) {
197
+ fastForwardConfiguredBranch(root, synchronized.upstream);
198
+ }
199
+ const ready = inspectTrunkRepository(root, config, getGitSummary(root));
200
+ if (ready.ahead > 0 && !ready.pendingCommitsFilegrcOnly) {
201
+ throw new Error("At least one commit ahead of upstream changes files outside this FileGRC workspace. FileGRC will not push it. Reconcile the repository with Git.");
202
+ }
203
+ if (ready.ahead > 0) pushConfiguredBranch(root, config);
204
+ const after = inspectTrunkRepository(root, config, getGitSummary(root));
205
+ if (after.ahead !== 0 || after.behind !== 0) {
206
+ throw new Error("The authoritative branch is still not synchronized. Reload the repository state before trying again.");
207
+ }
208
+ const synchronizedAt = new Date().toISOString();
209
+ lastSuccessfulSynchronizations.set(root, synchronizedAt);
210
+ return {
211
+ commit: after.currentCommit,
212
+ shortCommit: after.currentCommit?.slice(0, 8) ?? null,
213
+ branch: config.authoritativeBranch,
214
+ upstream: after.upstream,
215
+ synchronizedAt,
216
+ retriedCommits: before.ahead ?? 0
217
+ };
218
+ });
219
+ }
220
+
221
+ async function runTrunkMutationUnlocked(root, config, options, task) {
222
+ requireTrunkPreconditions(root, config);
223
+ fetchConfiguredRemote(root, config.remote);
224
+ let synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
225
+ if (synchronized.ahead > 0 && synchronized.behind > 0) {
226
+ throw new Error("The authoritative branch has diverged from its upstream. FileGRC will not merge or rebase it. Reconcile the repository with Git, then reload.");
227
+ }
228
+ if (synchronized.ahead > 0) {
229
+ throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
230
+ }
231
+ if (synchronized.behind > 0) {
232
+ fastForwardConfiguredBranch(root, synchronized.upstream);
233
+ synchronized = inspectTrunkRepository(root, config, getGitSummary(root));
234
+ }
235
+ if (synchronized.ahead !== 0 || synchronized.behind !== 0) {
236
+ throw new Error("The authoritative branch is not synchronized with its upstream. Reload after reconciling the repository with Git.");
237
+ }
238
+
239
+ let result;
240
+ let subject;
241
+ try {
242
+ result = await task(root);
243
+ subject = generatedCommitMessage(typeof options?.message === "function" ? options.message(result) : options?.message);
244
+ const validation = await validateWorkspace(root);
245
+ if (!validation.ok) {
246
+ throw new Error(`The workspace has ${validation.counts.errors} validation ${validation.counts.errors === 1 ? "error" : "errors"}. The browser change was rolled back.`);
247
+ }
248
+ assertNoOutsideWorktreeChanges(root);
249
+ } catch (error) {
250
+ try {
251
+ await rollbackWorkspaceChanges(root);
252
+ } catch (rollbackError) {
253
+ throw new Error(`${error.message} FileGRC could not roll back the workspace change. ${rollbackError.message} Later browser mutations are blocked until the Git worktree is reconciled.`);
254
+ }
255
+ throw error;
256
+ }
257
+
258
+ if (!getGitSummary(root).changes.length) {
259
+ throw new Error("The browser action did not change any FileGRC workspace files.");
260
+ }
261
+ if (!tryGit(root, ["config", "user.name"]) || !tryGit(root, ["config", "user.email"])) {
262
+ throw new Error("Configure git user.name and git user.email before browser changes can be committed. The saved files remain uncommitted and later browser changes are blocked.");
263
+ }
264
+ gitForWrite(root, ["add", "--all", "--", "."], "stage the FileGRC workspace change");
265
+ assertNoOutsideWorktreeChanges(root, false);
266
+ assertOnlyWorkspaceFilesStaged(root);
267
+ try {
268
+ gitForWrite(root, ["commit", "-m", subject, "--", "."], "create the FileGRC browser commit");
269
+ } catch (error) {
270
+ throw new Error(`${error.message} The saved files remain in the Git worktree and later browser changes are blocked.`);
271
+ }
272
+
273
+ const committed = getGitSummary(root);
274
+ let pushError = null;
275
+ try {
276
+ pushConfiguredBranch(root, config);
277
+ } catch (error) {
278
+ pushError = `${error.message} The local FileGRC commit was retained. Use Retry sync after the remote is available.`;
279
+ }
280
+ const after = inspectTrunkRepository(root, config, getGitSummary(root));
281
+ let synchronizedAt = null;
282
+ if (!pushError && after.ahead === 0 && after.behind === 0) {
283
+ synchronizedAt = new Date().toISOString();
284
+ lastSuccessfulSynchronizations.set(root, synchronizedAt);
285
+ }
286
+ return {
287
+ ...result,
288
+ synchronization: {
289
+ status: pushError ? "not-synced" : "synced",
290
+ commit: committed.commit,
291
+ shortCommit: committed.shortCommit,
292
+ upstream: after.upstream,
293
+ synchronizedAt,
294
+ pushError
295
+ }
296
+ };
297
+ }
298
+
123
299
  async function commitWorkspaceUnlocked(root, message) {
124
300
  const subject = String(message ?? "").trim();
125
301
  if (!subject || subject.length > 200 || /[\u0000-\u001f\u007f]/.test(subject)) {
@@ -233,6 +409,299 @@ function syncReadySummary(root, action) {
233
409
  return summary;
234
410
  }
235
411
 
412
+ async function getRepositoryConfig(root) {
413
+ const loaded = await loadWorkspace(root);
414
+ const renderer = loaded.resources.find(({ type, id }) => type === "renderer-settings" && id === "renderer-settings");
415
+ const mode = renderer?.repositoryMode === "trunk" ? "trunk" : "manual";
416
+ const authoritativeBranch = cleanGitName(renderer?.authoritativeBranch, "main");
417
+ const remote = cleanGitName(renderer?.repositoryRemote, "origin");
418
+ return {
419
+ mode,
420
+ authoritativeBranch,
421
+ remote,
422
+ configurationError: !isSafeGitName(authoritativeBranch)
423
+ ? "The configured authoritative branch is not a safe Git branch name. Update renderer settings before using browser writes."
424
+ : !isSafeGitName(remote)
425
+ ? "The configured repository remote is not a safe Git remote name. Update renderer settings before using browser writes."
426
+ : null
427
+ };
428
+ }
429
+
430
+ function inspectTrunkRepository(root, config, summary = getGitSummary(root)) {
431
+ const base = {
432
+ mode: "trunk",
433
+ authoritativeBranch: config.authoritativeBranch,
434
+ remote: config.remote,
435
+ currentCommit: summary.commit,
436
+ upstreamCommit: null,
437
+ upstream: summary.upstream,
438
+ ahead: null,
439
+ behind: null,
440
+ pendingCommits: [],
441
+ pendingCommitsFilegrcOnly: null,
442
+ lastSuccessfulSynchronization: lastSuccessfulSynchronizations.get(root) ?? null,
443
+ wholeWorktreeClean: summary.available ? wholeWorktreeClean(root) : null,
444
+ operationInProgress: summary.available ? repositoryOperation(root) : null,
445
+ writesAllowed: false
446
+ };
447
+ if (config.configurationError) {
448
+ return {
449
+ ...base,
450
+ status: "git-setup-required",
451
+ label: "Git setup required",
452
+ message: config.configurationError
453
+ };
454
+ }
455
+ if (!summary.available) {
456
+ return {
457
+ ...base,
458
+ status: "git-setup-required",
459
+ label: "Git setup required",
460
+ message: "Git is unavailable. Install Git and open this workspace from its authoritative repository checkout."
461
+ };
462
+ }
463
+ if (summary.branch !== config.authoritativeBranch) {
464
+ return {
465
+ ...base,
466
+ status: "read-only-checkout",
467
+ label: "Read-only checkout",
468
+ message: "This checkout is not the authoritative FileGRC branch. You can review the program here, but browser changes are disabled. Run FileGRC from the main checkout or use the explicit development override."
469
+ };
470
+ }
471
+ if (!summary.remotes.includes(config.remote)) {
472
+ return {
473
+ ...base,
474
+ status: "git-setup-required",
475
+ label: "Git setup required",
476
+ message: `The configured Git remote "${config.remote}" does not exist. Add it and configure the authoritative branch upstream before using browser writes.`
477
+ };
478
+ }
479
+ const expectedUpstream = `${config.remote}/${config.authoritativeBranch}`;
480
+ if (summary.upstream !== expectedUpstream) {
481
+ return {
482
+ ...base,
483
+ status: "git-setup-required",
484
+ label: "Git setup required",
485
+ message: `The authoritative branch must track ${expectedUpstream}. Configure that upstream with Git before using browser writes.`
486
+ };
487
+ }
488
+ const upstreamCommit = tryGit(root, ["rev-parse", expectedUpstream]) || null;
489
+ const counts = upstreamCommit ? aheadBehind(root, expectedUpstream) : { ahead: null, behind: null };
490
+ const pendingCommits = counts.ahead > 0 ? commitsAhead(root, expectedUpstream) : [];
491
+ const pendingCommitsFilegrcOnly = counts.ahead > 0 ? commitsOnlyTouchWorkspace(root, expectedUpstream) : true;
492
+ const details = {
493
+ ...base,
494
+ upstreamCommit,
495
+ ahead: counts.ahead,
496
+ behind: counts.behind,
497
+ pendingCommits,
498
+ pendingCommitsFilegrcOnly
499
+ };
500
+ if (base.operationInProgress) {
501
+ return {
502
+ ...details,
503
+ status: "not-synced",
504
+ label: "Not synced",
505
+ message: `A Git ${base.operationInProgress} is in progress. Finish or abort it with Git before using browser writes.`
506
+ };
507
+ }
508
+ if (!base.wholeWorktreeClean) {
509
+ return {
510
+ ...details,
511
+ status: "not-synced",
512
+ label: "Not synced",
513
+ message: "The Git worktree has uncommitted changes. Commit, discard, or move them with Git before using browser writes."
514
+ };
515
+ }
516
+ if (counts.ahead === null || counts.behind === null) {
517
+ return {
518
+ ...details,
519
+ status: "git-setup-required",
520
+ label: "Git setup required",
521
+ message: `The upstream ${expectedUpstream} is unavailable locally. Fetch ${config.remote} with Git, then reload.`
522
+ };
523
+ }
524
+ if (counts.ahead > 0 || counts.behind > 0) {
525
+ const external = counts.ahead > 0 && !pendingCommitsFilegrcOnly;
526
+ return {
527
+ ...details,
528
+ status: "not-synced",
529
+ label: "Not synced",
530
+ message: external
531
+ ? "A commit ahead of upstream changes files outside this FileGRC workspace. Reconcile it with Git. FileGRC will not push it."
532
+ : counts.ahead > 0 && counts.behind > 0
533
+ ? "The authoritative branch has diverged from upstream. Reconcile it with Git. FileGRC will not merge or rebase it."
534
+ : counts.ahead > 0
535
+ ? "FileGRC-only commits are waiting to be pushed. Use Retry sync."
536
+ : "The authoritative branch is behind upstream. The next browser mutation will fast-forward before writing.",
537
+ writesAllowed: counts.ahead === 0 && counts.behind > 0,
538
+ retrySafe: counts.ahead > 0 && counts.behind === 0 && pendingCommitsFilegrcOnly
539
+ };
540
+ }
541
+ return {
542
+ ...details,
543
+ status: "synced",
544
+ label: "Synced",
545
+ message: `The authoritative branch is synchronized with ${expectedUpstream}.`,
546
+ writesAllowed: true,
547
+ retrySafe: false
548
+ };
549
+ }
550
+
551
+ function requireTrunkPreconditions(root, config, options = {}) {
552
+ const summary = getGitSummary(root);
553
+ const state = inspectTrunkRepository(root, config, summary);
554
+ if (config.configurationError) throw new Error(state.message);
555
+ if (!summary.available) throw new Error(state.message);
556
+ if (summary.branch !== config.authoritativeBranch) throw new Error(state.message);
557
+ if (!summary.remotes.includes(config.remote)) throw new Error(state.message);
558
+ if (summary.upstream !== `${config.remote}/${config.authoritativeBranch}`) throw new Error(state.message);
559
+ if (state.operationInProgress) throw new Error(state.message);
560
+ if (!state.wholeWorktreeClean) throw new Error(state.message);
561
+ if (!options.allowAhead && state.ahead > 0) {
562
+ throw new Error("The authoritative branch has local commits waiting to be pushed. Use Retry sync before making another browser change.");
563
+ }
564
+ return state;
565
+ }
566
+
567
+ function fetchConfiguredRemote(root, remote) {
568
+ gitForWrite(root, ["fetch", "--prune", "--", remote], `fetch ${remote}`);
569
+ }
570
+
571
+ function fastForwardConfiguredBranch(root, upstream) {
572
+ gitForWrite(root, ["merge", "--ff-only", "--", upstream], `fast-forward from ${upstream}`);
573
+ }
574
+
575
+ function pushConfiguredBranch(root, config) {
576
+ gitForWrite(
577
+ root,
578
+ ["push", "--porcelain", "--", config.remote, `HEAD:refs/heads/${config.authoritativeBranch}`],
579
+ `push ${config.authoritativeBranch} to ${config.remote}`
580
+ );
581
+ }
582
+
583
+ function wholeWorktreeClean(root) {
584
+ return git(root, ["status", "--porcelain=v1"]) === "";
585
+ }
586
+
587
+ function repositoryOperation(root) {
588
+ for (const [name, gitPath] of [
589
+ ["merge", "MERGE_HEAD"],
590
+ ["rebase", "rebase-merge"],
591
+ ["rebase", "rebase-apply"],
592
+ ["cherry-pick", "CHERRY_PICK_HEAD"]
593
+ ]) {
594
+ const path = tryGit(root, ["rev-parse", "--git-path", gitPath]);
595
+ if (path && existsSync(resolve(root, path))) return name;
596
+ }
597
+ return null;
598
+ }
599
+
600
+ function aheadBehind(root, upstream) {
601
+ const output = tryGit(root, ["rev-list", "--left-right", "--count", `HEAD...${upstream}`]);
602
+ const [ahead, behind] = output.split(/\s+/).map(Number);
603
+ return Number.isInteger(ahead) && Number.isInteger(behind)
604
+ ? { ahead, behind }
605
+ : { ahead: null, behind: null };
606
+ }
607
+
608
+ function commitsAhead(root, upstream) {
609
+ return lines(tryGit(root, ["log", "--format=%H%x1f%s", `${upstream}..HEAD`])).map((line) => {
610
+ const [commit, subject] = line.split("\x1f");
611
+ return { commit, shortCommit: commit.slice(0, 8), subject };
612
+ });
613
+ }
614
+
615
+ function commitsOnlyTouchWorkspace(root, upstream) {
616
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
617
+ const prefix = relative(topLevel, root).split(sep).join("/");
618
+ const commits = lines(tryGit(root, ["rev-list", `${upstream}..HEAD`]));
619
+ return commits.every((commit) => {
620
+ const paths = nulFields(tryGitRaw(topLevel, [
621
+ "diff-tree",
622
+ "--no-commit-id",
623
+ "--name-only",
624
+ "-z",
625
+ "-r",
626
+ "--root",
627
+ commit
628
+ ]));
629
+ return paths.length > 0 && paths.every((path) => pathInsideWorkspace(path, prefix));
630
+ });
631
+ }
632
+
633
+ function assertNoOutsideWorktreeChanges(root, rollbackExpected = true) {
634
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
635
+ const prefix = relative(topLevel, root).split(sep).join("/");
636
+ const paths = statusPaths(topLevel);
637
+ if (paths.some((path) => !pathInsideWorkspace(path, prefix))) {
638
+ throw new Error(rollbackExpected
639
+ ? "Files outside this FileGRC workspace changed while the browser action was running. The FileGRC change was rolled back; reconcile the other Git work first."
640
+ : "Files outside this FileGRC workspace changed while the browser action was being staged. The saved FileGRC files remain uncommitted and later browser mutations are blocked; reconcile the Git worktree.");
641
+ }
642
+ }
643
+
644
+ function assertOnlyWorkspaceFilesStaged(root) {
645
+ const topLevel = git(root, ["rev-parse", "--show-toplevel"]);
646
+ const prefix = relative(topLevel, root).split(sep).join("/");
647
+ const staged = nulFields(tryGitRaw(topLevel, [
648
+ "diff",
649
+ "--cached",
650
+ "--name-only",
651
+ "-z",
652
+ "--diff-filter=ACDMRTUXB"
653
+ ]));
654
+ if (!staged.length) throw new Error("The browser action did not stage any FileGRC workspace files.");
655
+ if (staged.some((path) => !pathInsideWorkspace(path, prefix))) {
656
+ throw new Error("Git has staged files outside this FileGRC workspace. FileGRC will not create a browser commit until those files are unstaged.");
657
+ }
658
+ }
659
+
660
+ async function rollbackWorkspaceChanges(root) {
661
+ gitForWrite(root, ["restore", "--staged", "--worktree", "--source=HEAD", "--", "."], "roll back the FileGRC workspace change");
662
+ const untracked = nulFields(tryGitRaw(root, ["ls-files", "-z", "--others", "--exclude-standard", "--", "."]));
663
+ for (const path of untracked) {
664
+ const absolute = resolve(root, path);
665
+ if (absolute === root || !absolute.startsWith(`${root}${sep}`)) continue;
666
+ await rm(absolute, { force: true });
667
+ }
668
+ }
669
+
670
+ function statusPaths(topLevel) {
671
+ const output = tryGitRaw(topLevel, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
672
+ if (!output) return [];
673
+ const fields = nulFields(output);
674
+ const paths = [];
675
+ for (let index = 0; index < fields.length; index += 1) {
676
+ const field = fields[index];
677
+ if (!/^[ MADRCU?!]{2} /.test(field)) {
678
+ paths.push(field);
679
+ continue;
680
+ }
681
+ const status = field.slice(0, 2);
682
+ paths.push(field.slice(3));
683
+ if (/[RC]/.test(status) && fields[index + 1] !== undefined) paths.push(fields[++index]);
684
+ }
685
+ return paths;
686
+ }
687
+
688
+ function pathInsideWorkspace(path, prefix) {
689
+ return !prefix || path === prefix || path.startsWith(`${prefix}/`);
690
+ }
691
+
692
+ function generatedCommitMessage(value) {
693
+ const subject = String(value ?? "")
694
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
695
+ .replace(/\s+/g, " ")
696
+ .trim();
697
+ return (subject || "Update FileGRC workspace").slice(0, 200);
698
+ }
699
+
700
+ function cleanGitName(value, fallback) {
701
+ const normalized = String(value ?? fallback).trim();
702
+ return normalized || fallback;
703
+ }
704
+
236
705
  function parseLogLine(line) {
237
706
  if (!line) return null;
238
707
  const [commit, timestamp, author, subject] = line.split("\x1f");
@@ -261,6 +730,28 @@ function tryGit(cwd, args) {
261
730
  }
262
731
  }
263
732
 
733
+ function tryGitRaw(cwd, args) {
734
+ try {
735
+ return gitRaw(cwd, args);
736
+ } catch {
737
+ return "";
738
+ }
739
+ }
740
+
741
+ function gitRaw(cwd, args) {
742
+ return execFileSync("git", args, {
743
+ cwd,
744
+ encoding: "utf8",
745
+ stdio: ["ignore", "pipe", "ignore"],
746
+ timeout: 10_000,
747
+ maxBuffer: 20_000_000
748
+ });
749
+ }
750
+
751
+ function nulFields(source) {
752
+ return source ? source.split("\0").filter(Boolean) : [];
753
+ }
754
+
264
755
  function gitForWrite(cwd, args, action = "create the commit") {
265
756
  try {
266
757
  return execFileSync("git", args, {
@@ -276,11 +767,18 @@ function gitForWrite(cwd, args, action = "create the commit") {
276
767
  }
277
768
  }).trim();
278
769
  } catch (error) {
279
- const message = error.stderr?.trim() || error.stdout?.trim() || error.message;
770
+ const message = sanitizeGitErrorMessage(error.stderr?.trim() || error.stdout?.trim() || error.message);
280
771
  throw new Error(`Git could not ${action}. ${message}`);
281
772
  }
282
773
  }
283
774
 
775
+ export function sanitizeGitErrorMessage(value) {
776
+ return String(value || "Git returned no error detail.")
777
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/\s@]+@/gi, "$1[redacted]@")
778
+ .replace(/([?&](?:access[_-]?token|auth|key|password|secret|token)=)[^&\s]+/gi, "$1[redacted]")
779
+ .replace(/\b(authorization:\s*)(?:basic|bearer)\s+\S+/gi, "$1[redacted]");
780
+ }
781
+
284
782
  function tryGitForWrite(cwd, args) {
285
783
  try {
286
784
  gitForWrite(cwd, args);
package/src/index.js CHANGED
@@ -18,11 +18,14 @@ export {
18
18
  export {
19
19
  commitAndPushWorkspace,
20
20
  commitWorkspace,
21
+ getBrowserRepositoryState,
21
22
  getFileHistory,
22
23
  getGitSummary,
23
24
  getWorkspaceHistories,
24
25
  pullWorkspace,
25
- pushWorkspace
26
+ pushWorkspace,
27
+ retryBrowserSync,
28
+ runBrowserMutation
26
29
  } from "./git.js";
27
30
  export { generateModelDocumentation } from "./model-docs.js";
28
31
  export { renderMarkdown } from "./markdown.js";
package/src/mutation.js CHANGED
@@ -1,11 +1,14 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { resolveWorkspaceRoot } from "./paths.js";
2
3
 
3
4
  const mutationQueues = new Map();
5
+ const activeMutation = new AsyncLocalStorage();
4
6
 
5
7
  export function serializeWorkspaceMutation(input, task) {
6
8
  const root = resolveWorkspaceRoot(input);
9
+ if (activeMutation.getStore() === root) return task(root);
7
10
  const previous = mutationQueues.get(root) ?? Promise.resolve();
8
- const run = previous.catch(() => {}).then(() => task(root));
11
+ const run = previous.catch(() => {}).then(() => activeMutation.run(root, () => task(root)));
9
12
  let tracked;
10
13
  tracked = run.finally(() => {
11
14
  if (mutationQueues.get(root) === tracked) mutationQueues.delete(root);
package/src/server.js CHANGED
@@ -7,7 +7,15 @@ import { generateEvidencePacket, prepareEvidencePacket } from "./evidence-packet
7
7
  import { ensureEvidenceTestDrafts } from "./evidence-tests.js";
8
8
  import { FAVICON_PNG, LOGO_MARK_PNG } from "./favicon.js";
9
9
  import { createResource, deleteResource, updateContent, updateResource } from "./files.js";
10
- import { commitAndPushWorkspace, getFileHistory, pullWorkspace, pushWorkspace } from "./git.js";
10
+ import {
11
+ commitAndPushWorkspace,
12
+ getBrowserRepositoryState,
13
+ getFileHistory,
14
+ pullWorkspace,
15
+ pushWorkspace,
16
+ retryBrowserSync,
17
+ runBrowserMutation
18
+ } from "./git.js";
11
19
  import { completeObligationOccurrence, createObligationEvent, planObligations } from "./obligations.js";
12
20
  import { isWithin, relativeToWorkspace, resolveWorkspacePath } from "./paths.js";
13
21
  import { createAppState } from "./state.js";
@@ -26,7 +34,9 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
26
34
  return json(response, 403, { error: "Cross-origin writes are not allowed." });
27
35
  }
28
36
  if (request.method === "GET" && url.pathname === "/api/state") {
29
- return json(response, 200, await createAppState(input));
37
+ return json(response, 200, await createAppState(input, {
38
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
39
+ }));
30
40
  }
31
41
  if (request.method === "GET" && url.pathname === "/api/history") {
32
42
  const path = url.searchParams.get("path");
@@ -44,17 +54,22 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
44
54
  }));
45
55
  }
46
56
  if (request.method === "POST" && url.pathname === "/api/obligation-events") {
47
- return json(response, 201, await createObligationEvent(input, await readJson(request)));
57
+ const payload = await readJson(request);
58
+ return json(response, 201, await browserMutation(input, options, {
59
+ message: (result) => `Create policy event: ${result.event?.title || payload.title || payload.eventType}`
60
+ }, () => createObligationEvent(input, payload)));
48
61
  }
49
62
  if (request.method === "POST" && url.pathname === "/api/obligation-completions") {
50
63
  const payload = await readJson(request);
51
64
  if (!safeSegment(payload.obligationId)) return json(response, 400, { error: "A safe obligation ID is required." });
52
- const result = await completeObligationOccurrence(input, {
65
+ const result = await browserMutation(input, options, {
66
+ message: () => `Complete ${resourceTypeLabel(payload.record?.type)}: ${payload.record?.title || payload.obligationId}`
67
+ }, () => completeObligationOccurrence(input, {
53
68
  obligationId: payload.obligationId,
54
69
  record: payload.record,
55
70
  content: payload.content,
56
71
  expectedRevision: payload.revision
57
- });
72
+ }));
58
73
  return json(response, 201, result);
59
74
  }
60
75
  if (request.method === "GET" && url.pathname === "/api/evidence-packet") {
@@ -82,34 +97,54 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
82
97
  });
83
98
  }
84
99
  if (request.method === "POST" && url.pathname === "/api/audit-preparation") {
85
- return json(response, 201, await prepareAuditWorkspace(input, await readJson(request)));
100
+ const payload = await readJson(request);
101
+ return json(response, 201, await browserMutation(input, options, {
102
+ message: () => `Prepare audit: ${payload.auditId || "engagement"}`
103
+ }, () => prepareAuditWorkspace(input, payload)));
86
104
  }
87
105
  if (request.method === "POST" && url.pathname === "/api/evidence-test-drafts") {
88
- return json(response, 201, await ensureEvidenceTestDrafts(input));
106
+ return json(response, 201, await browserMutation(input, options, {
107
+ message: "Create evidence collection test drafts"
108
+ }, () => ensureEvidenceTestDrafts(input)));
89
109
  }
90
110
  if (request.method === "POST" && url.pathname === "/api/setup") {
91
- return json(response, 200, await setupWorkspace(input, await readJson(request)));
111
+ const payload = await readJson(request);
112
+ return json(response, 200, await browserMutation(input, options, {
113
+ message: (result) => `${payload.draft === true ? "Save onboarding draft" : "Complete onboarding"} for ${result.workspace.organizationName}`
114
+ }, () => setupWorkspace(input, payload)));
92
115
  }
93
116
  if (request.method === "POST" && url.pathname === "/api/resources") {
94
117
  const payload = await readJson(request);
95
118
  const record = payload.record ?? payload;
96
- const result = await createResource(input, record, { content: payload.record ? payload.content : undefined });
97
- return json(response, 201, { record: result.record });
119
+ const result = await browserMutation(input, options, {
120
+ message: () => `Create ${resourceTypeLabel(record.type)}: ${record.title || record.id}`
121
+ }, () => createResource(input, record, { content: payload.record ? payload.content : undefined }));
122
+ return json(response, 201, { record: result.record, synchronization: result.synchronization });
98
123
  }
99
124
  if (request.method === "POST" && url.pathname === "/api/commit") {
125
+ await requireManualBrowserGit(input, options);
100
126
  const payload = await readJson(request);
101
127
  return json(response, 201, await commitAndPushWorkspace(input, payload.message));
102
128
  }
103
129
  if (request.method === "POST" && url.pathname === "/api/git/pull") {
130
+ await requireManualBrowserGit(input, options);
104
131
  return json(response, 200, await pullWorkspace(input));
105
132
  }
106
133
  if (request.method === "POST" && url.pathname === "/api/git/push") {
134
+ await requireManualBrowserGit(input, options);
107
135
  return json(response, 200, await pushWorkspace(input));
108
136
  }
137
+ if (request.method === "POST" && url.pathname === "/api/git/retry-sync") {
138
+ return json(response, 200, await retryBrowserSync(input, {
139
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
140
+ }));
141
+ }
109
142
  if (request.method === "PUT" && url.pathname === "/api/content") {
110
143
  const payload = await readJson(request);
111
- const result = await updateContent(input, payload.path, payload.source, { expectedRevision: payload.revision });
112
- return json(response, 200, { path: result.dataRelativePath });
144
+ const result = await browserMutation(input, options, {
145
+ message: () => `Update content: ${payload.path}`
146
+ }, () => updateContent(input, payload.path, payload.source, { expectedRevision: payload.revision }));
147
+ return json(response, 200, { path: result.dataRelativePath, synchronization: result.synchronization });
113
148
  }
114
149
  const match = /^\/api\/resource\/([^/]+)\/([^/]+)$/.exec(url.pathname);
115
150
  if (match) {
@@ -124,16 +159,26 @@ export function createFilegrcServer(input = process.cwd(), options = {}) {
124
159
  if (request.method === "PUT") {
125
160
  const payload = await readJson(request);
126
161
  const record = payload.record ?? payload;
127
- const result = await updateResource(input, type, id, record, {
162
+ const result = await browserMutation(input, options, {
163
+ message: () => `Update ${resourceTypeLabel(type)}: ${record.title || id}`
164
+ }, () => updateResource(input, type, id, record, {
128
165
  content: payload.record ? payload.content : undefined,
129
166
  expectedRevision: payload.revision,
130
167
  expectedContentRevisions: payload.contentRevisions
131
- });
132
- return json(response, 200, { record: result.record });
168
+ }));
169
+ return json(response, 200, { record: result.record, synchronization: result.synchronization });
133
170
  }
134
171
  if (request.method === "DELETE") {
135
- const result = await deleteResource(input, type, id, { expectedRevision: url.searchParams.get("revision") });
136
- return json(response, 200, { deleted: true, type, id, deletedContent: result.deletedContent });
172
+ const result = await browserMutation(input, options, {
173
+ message: () => `Delete ${resourceTypeLabel(type)}: ${id}`
174
+ }, () => deleteResource(input, type, id, { expectedRevision: url.searchParams.get("revision") }));
175
+ return json(response, 200, {
176
+ deleted: true,
177
+ type,
178
+ id,
179
+ deletedContent: result.deletedContent,
180
+ synchronization: result.synchronization
181
+ });
137
182
  }
138
183
  }
139
184
  if (request.method === "GET" && url.pathname === "/favicon.png") return text(response, 200, FAVICON_PNG, "image/png");
@@ -185,7 +230,10 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
185
230
  }
186
231
  const loaded = await loadWorkspace(input);
187
232
  getResourceDefinition(loaded.model, "workspace");
188
- const server = createFilegrcServer(loaded.root, { allowedHosts: [host] });
233
+ const server = createFilegrcServer(loaded.root, {
234
+ allowedHosts: [host],
235
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
236
+ });
189
237
  await new Promise((resolve, reject) => {
190
238
  server.once("error", reject);
191
239
  server.listen(port, host, resolve);
@@ -198,6 +246,26 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
198
246
  };
199
247
  }
200
248
 
249
+ function browserMutation(input, options, mutationOptions, task) {
250
+ return runBrowserMutation(input, {
251
+ ...mutationOptions,
252
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
253
+ }, task);
254
+ }
255
+
256
+ async function requireManualBrowserGit(input, options) {
257
+ const repository = await getBrowserRepositoryState(input, {
258
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
259
+ });
260
+ if (repository.mode === "trunk") {
261
+ throw new Error("Browser commit, pull, and push controls are disabled in trunk mode. Saved changes synchronize automatically.");
262
+ }
263
+ }
264
+
265
+ function resourceTypeLabel(value) {
266
+ return String(value || "record").replaceAll("-", " ");
267
+ }
268
+
201
269
  async function readJson(request) {
202
270
  const chunks = [];
203
271
  let size = 0;
@@ -296,7 +364,7 @@ function statusFor(error) {
296
364
  if (/exceeds 2 MB/i.test(error.message)) return 413;
297
365
  if (/changed after you opened|source changed|revision changed/i.test(error.message)) return 409;
298
366
  if (/already exists|target file already exists/i.test(error.message)) return 409;
299
- if (/Git could not (?:pull|push)|upstream branch|multiple remotes|no Git remote|check out a branch|before trying to (?:pull|push)/i.test(error.message)) return 409;
367
+ if (/Git could not|upstream branch|multiple remotes|no Git remote|configured repository remote|safe Git name|check out a branch|before trying to (?:pull|push)|authoritative branch|not synchronized|not synced|diverged|waiting to be pushed|Retry sync|outside this FileGRC workspace|worktree has uncommitted changes|development write override|browser commit, pull, and push/i.test(error.message)) return 409;
300
368
  if (/not found|ENOENT/i.test(error.message)) return 404;
301
369
  if (/invalid|required|unsafe|match|workspace|singleton|commit message|no changes|git history|git user|unknown resource type|must use|must be|content path|data path|path leaves|valid .*date|not found|no active obligations|end date|through date|already exists|EEXIST/i.test(error.message)) return 400;
302
370
  return 500;
package/src/state.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { assessAuditPreparation } from "./audit-preparation.js";
4
- import { getGitSummary, getWorkspaceHistories } from "./git.js";
4
+ import { getBrowserRepositoryState, getGitSummary, getWorkspaceHistories } from "./git.js";
5
5
  import { renderMarkdown } from "./markdown.js";
6
6
  import { planObligations } from "./obligations.js";
7
7
  import { resolveDataPath } from "./paths.js";
@@ -42,6 +42,10 @@ export async function createAppState(input = process.cwd(), options = {}) {
42
42
 
43
43
  const git = getGitSummary(loaded.root);
44
44
  delete git.root;
45
+ const repository = await getBrowserRepositoryState(loaded.root, {
46
+ readOnly: options.readOnly,
47
+ allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites
48
+ });
45
49
  const workspace = loaded.workspace ?? {
46
50
  schemaVersion: 1,
47
51
  dataModelVersion: loaded.model.modelVersion,
@@ -70,7 +74,8 @@ export async function createAppState(input = process.cwd(), options = {}) {
70
74
  ));
71
75
  return {
72
76
  generatedAt,
73
- readOnly: Boolean(options.readOnly),
77
+ readOnly: Boolean(options.readOnly || (repository.mode === "trunk" && !repository.writesAllowed)),
78
+ repository,
74
79
  workspace,
75
80
  model: loaded.model,
76
81
  resources: entries,
package/src/validate.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { stat } from "node:fs/promises";
2
2
  import { getResourceDefinition } from "../model/index.js";
3
+ import { isSafeGitName } from "./git-name.js";
3
4
  import { isCanonicalDataPath, resolveDataPath } from "./paths.js";
4
5
  import { parseCalendarDate, validCalendarRecurrence } from "./recurrence.js";
5
6
  import { obligationIsRunning } from "./program-lifecycle.js";
@@ -476,6 +477,7 @@ function validateValue(name, value, field, model, path, diagnostics) {
476
477
  }
477
478
  if (field.format === "email" && !EMAIL_PATTERN.test(value)) fail("must be an email address.");
478
479
  if (field.format === "timezone" && !isTimezone(value)) fail("must be an IANA time zone.");
480
+ if (field.format === "git-name" && !isSafeGitName(value)) fail("must be a safe Git name.");
479
481
  }
480
482
 
481
483
  function validateNumericRange(value, field, fail) {
package/src/web.js CHANGED
@@ -217,7 +217,19 @@ function topbar(route) {
217
217
  : route.name === "audit-packet"
218
218
  ? "Audit Readiness"
219
219
  : state.model.resources[route.type]?.pluralTitle || "filegrc";
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>';
220
+ const repositoryLabel = state.repository?.mode === "trunk"
221
+ ? state.repository.label
222
+ : state.git.available ? ((state.git.branch || "detached") + " · " + state.git.shortCommit) : "Git unavailable";
223
+ const repositoryTone = state.repository?.mode === "trunk"
224
+ ? repositoryStatusTone(state.repository.status)
225
+ : state.git.clean ? "good" : "warn";
226
+ 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 ' + repositoryTone + '"></span>' + esc(repositoryLabel) + '</a></div>';
227
+ }
228
+
229
+ function repositoryStatusTone(status) {
230
+ if (status === "synced") return "good";
231
+ if (status === "syncing") return "neutral";
232
+ return "warn";
221
233
  }
222
234
 
223
235
  function renderHome(main) {
@@ -1132,6 +1144,7 @@ function renderOrganization(main) {
1132
1144
  }
1133
1145
 
1134
1146
  function renderRepository(main) {
1147
+ if (state.repository?.mode === "trunk") return renderTrunkRepository(main);
1135
1148
  const settings = rendererSettingsEntry();
1136
1149
  const settingsLink = settings ? '<a class="button" href="#/resource/renderer-settings/' + encodeURIComponent(settings.record.id) + '">Renderer settings</a>' : "";
1137
1150
  const onboardingButton = settings && !state.readOnly ? '<button class="button" type="button" id="start-onboarding">Run onboarding</button>' : "";
@@ -1177,12 +1190,41 @@ function renderRepository(main) {
1177
1190
  main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
1178
1191
  }
1179
1192
 
1193
+ function renderTrunkRepository(main) {
1194
+ const repository = state.repository;
1195
+ const settings = rendererSettingsEntry();
1196
+ const settingsLink = settings ? '<a class="button" href="#/resource/renderer-settings/' + encodeURIComponent(settings.record.id) + '">Renderer settings</a>' : "";
1197
+ const onboardingButton = settings && repository.writesAllowed
1198
+ ? '<button class="button" type="button" id="start-onboarding">Run onboarding</button>'
1199
+ : "";
1200
+ const retryButton = repository.retrySafe
1201
+ ? '<button class="button primary" type="button" data-git-action="retry-sync">Retry sync</button>'
1202
+ : "";
1203
+ const pending = repository.pendingCommitsFilegrcOnly === false
1204
+ ? empty("Ahead commits include files outside this FileGRC workspace. Reconcile them with Git.")
1205
+ : repository.pendingCommits?.length
1206
+ ? '<ul class="changes">' + repository.pendingCommits.map((commit) => '<li><code>' + esc(commit.shortCommit) + '</code> ' + esc(commit.subject) + '</li>').join("") + '</ul>'
1207
+ : empty("No FileGRC commits are waiting to be pushed.");
1208
+ const lastSync = repository.lastSuccessfulSynchronization
1209
+ ? formatLocalDateTime(repository.lastSuccessfulSynchronization)
1210
+ : "No successful sync recorded by this server";
1211
+ const override = repository.developmentOverride
1212
+ ? '<div class="repository-override"><span class="status-dot warn"></span><div><strong>Development write override active</strong><p>Browser writes stay local. FileGRC will not fetch, commit, or push while this server uses <code>--allow-non-authoritative-writes</code>.</p></div></div>'
1213
+ : "";
1214
+ const validationBody = state.validation.diagnostics.length
1215
+ ? '<div class="diagnostics">' + state.validation.diagnostics.map((item) => '<div><span class="badge ' + item.severity + '">' + esc(properCase(item.severity)) + '</span><code>' + esc(item.path) + '</code><p>' + esc(item.message) + '</p></div>').join("") + '</div>'
1216
+ : empty("No validation problems.");
1217
+ main.innerHTML = '<div class="page"><div class="page-intro"><div><p class="kicker">Audit trail</p><h2>Repository State</h2><p>Browser saves use one authoritative branch. Record status represents draft, proposal, approval, and retirement; Git branches do not.</p><p class="repository-sync-status" role="status" aria-live="polite"></p></div><div class="page-actions">' + retryButton + onboardingButton + settingsLink + '<a class="button" href="#/resource/workspace/workspace">Workspace settings</a></div></div>' + override + '<section class="panel repository-state-banner"><span class="status-dot ' + repositoryStatusTone(repository.status) + '"></span><div><p class="kicker">Repository status</p><h3>' + esc(repository.label) + '</h3><p>' + esc(repository.message) + '</p></div></section><div class="dashboard-grid"><section class="panel"><div class="panel-head"><h3>Configured Repository</h3></div><dl class="metadata"><div><dt>Branch</dt><dd>' + esc(repository.authoritativeBranch) + '</dd></div><div><dt>Remote</dt><dd>' + esc(repository.remote) + '</dd></div><div><dt>Checkout</dt><dd>' + esc(state.git.branch || (state.git.available ? "Detached HEAD" : "Unavailable")) + '</dd></div><div><dt>Upstream</dt><dd>' + esc(repository.upstream || "Not configured") + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Synchronization</h3></div><dl class="metadata"><div><dt>Current commit</dt><dd><code>' + esc(repository.currentCommit || "Unavailable") + '</code></dd></div><div><dt>Upstream commit</dt><dd><code>' + esc(repository.upstreamCommit || "Unavailable") + '</code></dd></div><div><dt>Ahead</dt><dd>' + esc(repository.ahead ?? "Unknown") + '</dd></div><div><dt>Behind</dt><dd>' + esc(repository.behind ?? "Unknown") + '</dd></div><div><dt>Last sync</dt><dd>' + esc(lastSync) + '</dd></div></dl></section><section class="panel"><div class="panel-head"><h3>Safety Checks</h3></div><dl class="metadata"><div><dt>Whole worktree</dt><dd>' + (repository.wholeWorktreeClean === null ? "Unavailable" : repository.wholeWorktreeClean ? "Clean" : "Has changes") + '</dd></div><div><dt>Git operation</dt><dd>' + esc(repository.operationInProgress || "None") + '</dd></div><div><dt>Pending scope</dt><dd>' + (repository.pendingCommitsFilegrcOnly === false ? "Includes external files" : repository.pendingCommits?.length ? "FileGRC only" : "None") + '</dd></div></dl></section><section class="panel span-2"><div class="panel-head"><h3>Pending FileGRC-only Commits</h3></div>' + pending + '</section><section class="panel span-2"><div class="panel-head"><h3>Validation</h3><span class="badge ' + (state.validation.ok ? "good" : "bad") + '">' + (state.validation.ok ? "Passing" : "Needs attention") + '</span></div>' + validationBody + '</section></div></div>';
1218
+ main.querySelectorAll("[data-git-action]").forEach((button) => button.addEventListener("click", () => runRepositoryGitAction(button.dataset.gitAction)));
1219
+ main.querySelector("#start-onboarding")?.addEventListener("click", requestOnboarding);
1220
+ }
1221
+
1180
1222
  async function runRepositoryGitAction(action) {
1181
1223
  const buttons = [...document.querySelectorAll("[data-git-action]")];
1182
1224
  const disabled = buttons.map((button) => button.disabled);
1183
1225
  const active = buttons.find((button) => button.dataset.gitAction === action);
1184
1226
  const status = document.querySelector(".repository-sync-status");
1185
- const label = action === "pull" ? "Pulling…" : "Pushing…";
1227
+ const label = action === "pull" ? "Pulling…" : action === "retry-sync" ? "Syncing…" : "Pushing…";
1186
1228
  if (status) {
1187
1229
  status.textContent = "";
1188
1230
  status.classList.remove("error");
@@ -1200,10 +1242,12 @@ async function runRepositoryGitAction(action) {
1200
1242
  ? result.updated
1201
1243
  ? "Pulled " + result.upstream + " with rebase at " + result.shortCommit + "."
1202
1244
  : result.branch + " is current with " + result.upstream + "."
1203
- : "Pushed " + result.shortCommit + " to " + result.upstream + ".";
1245
+ : action === "retry-sync"
1246
+ ? "Synchronized " + result.shortCommit + " with " + result.upstream + "."
1247
+ : "Pushed " + result.shortCommit + " to " + result.upstream + ".";
1204
1248
  } catch (cause) {
1205
1249
  buttons.forEach((button, index) => { button.disabled = disabled[index]; });
1206
- if (active) active.textContent = action === "pull" ? "Pull with rebase" : "Push";
1250
+ if (active) active.textContent = action === "pull" ? "Pull with rebase" : action === "retry-sync" ? "Retry sync" : "Push";
1207
1251
  if (status) {
1208
1252
  status.textContent = cause.message;
1209
1253
  status.classList.add("error");
@@ -1413,8 +1457,9 @@ function onboardingSteps() {
1413
1457
  points: [
1414
1458
  "Use the UI, an editor, the CLI, or an agent; every path changes the same files.",
1415
1459
  "JSON holds structured records. Markdown holds policies, plans, minutes, and other long-form work.",
1416
- "Repository pulls with rebase, then commits and pushes reviewed changes together.",
1417
- "Agents and terminal users run git pull --rebase, git commit, and git push directly.",
1460
+ "In trunk mode, each browser save fast-forwards, validates, creates one focused commit, and pushes it.",
1461
+ "Record status represents approval. Draft, proposed, approved, and retired records all stay on the authoritative branch.",
1462
+ "Agents and terminal users continue to manage Git explicitly.",
1418
1463
  "The dashboard derives program status from the current repository state."
1419
1464
  ]
1420
1465
  };
@@ -1562,14 +1607,12 @@ function onboardingSetupForm() {
1562
1607
  currentSystem ? "Updates system " + currentSystem.title + "." : "Creates a new in-scope system.",
1563
1608
  "Records a management program goal without creating an audit engagement."
1564
1609
  ].filter(Boolean).join(" ");
1565
- const gitStatus = state.git.available && !state.git.branch
1566
- ? '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Detached HEAD detected</strong><small>Setup changes can be saved, but browser commits, pulls, and pushes are disabled until you check out a branch.</small></span></div>'
1567
- : state.git.available && state.git.remotes?.length
1568
- ? '<div class="onboarding-git-status"><span class="status-dot good"></span><span><strong>Git repository and remote detected</strong><small>Setup changes will appear in the workspace diff. Browser commits push to the configured remote.</small></span></div>'
1569
- : state.git.available
1570
- ? '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git remote needed</strong><small>Saving and local commits still work. Add a remote before the browser can push.</small></span></div>'
1571
- : '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git setup needed</strong><small>Saving still works. Run <code>git init</code> at the workspace root before your first compliance commit.</small></span></div>';
1572
- return '<p class="onboarding-body">' + esc(onboardingSteps().at(-1).body) + '</p>' + gitStatus + '<form id="onboarding-setup" class="onboarding-form"><label class="wide"><span>Service name</span><input name="serviceName" required maxlength="200" value="' + esc(onboardingDraft.serviceName) + '" placeholder="Customer-facing application"></label><label class="wide"><span>Scope description</span><textarea name="scope" required maxlength="2000" placeholder="What the service does and which production boundary is in scope">' + esc(onboardingDraft.scope) + '</textarea></label><label><span>Accountable owner</span><select name="ownerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === onboardingDraft.ownerId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Business criticality</span><select name="criticality" required>' + ["low", "medium", "high", "critical"].map((value) => '<option value="' + value + '" ' + (value === onboardingDraft.criticality ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Highest data classification</span><select name="dataClassification" required>' + classifications.map((value) => '<option value="' + esc(value) + '" ' + (value === onboardingDraft.dataClassification ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Internet exposed</span><select name="internetExposed" required><option value="true" ' + (onboardingDraft.internetExposed === "true" ? "selected" : "") + '>Yes</option><option value="false" ' + (onboardingDraft.internetExposed === "false" ? "selected" : "") + '>No</option></select></label><label class="wide"><span>Program goal</span><select name="programGoal" required><option value="none" ' + (onboardingDraft.programGoal === "none" ? "selected" : "") + '>No Assurance Goal Yet</option><option value="readiness" ' + (onboardingDraft.programGoal === "readiness" ? "selected" : "") + '>Program Readiness</option><option value="type-1" ' + (onboardingDraft.programGoal === "type-1" ? "selected" : "") + '>SOC 2 Type 1</option><option value="type-2" ' + (onboardingDraft.programGoal === "type-2" ? "selected" : "") + '>SOC 2 Type 2</option></select><small>This records management intent only. It does not create an engagement or establish the formal report period.</small></label></form><p class="onboarding-write-note">' + esc(existing) + ' Save draft marks the service Planned and In scope. It is selected for scope review, but it is not approved or active. Saving writes JSON files but does not commit them. Complete the remaining Step 1 pages next.</p>';
1610
+ const gitStatus = state.repository?.mode === "trunk"
1611
+ ? '<div class="onboarding-git-status ' + (state.repository.status === "synced" ? "" : "warning") + '"><span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span><span><strong>' + esc(state.repository.label) + '</strong><small>' + esc(state.repository.status === "synced" ? "Completing onboarding will save its related workspace, system, and renderer changes in one commit and push it." : state.repository.message) + '</small></span></div>'
1612
+ : state.git.available && state.git.branch
1613
+ ? '<div class="onboarding-git-status"><span class="status-dot good"></span><span><strong>Manual repository mode</strong><small>Setup changes will stay local until you commit and synchronize them.</small></span></div>'
1614
+ : '<div class="onboarding-git-status warning"><span class="status-dot warn"></span><span><strong>Git setup needed</strong><small>Manual-mode writes still work, but Git history is unavailable until the repository is configured.</small></span></div>';
1615
+ return '<p class="onboarding-body">' + esc(onboardingSteps().at(-1).body) + '</p>' + gitStatus + '<form id="onboarding-setup" class="onboarding-form"><label class="wide"><span>Service name</span><input name="serviceName" required maxlength="200" value="' + esc(onboardingDraft.serviceName) + '" placeholder="Customer-facing application"></label><label class="wide"><span>Scope description</span><textarea name="scope" required maxlength="2000" placeholder="What the service does and which production boundary is in scope">' + esc(onboardingDraft.scope) + '</textarea></label><label><span>Accountable owner</span><select name="ownerId" required><option value="">Select</option>' + people.map(({ record }) => '<option value="' + esc(record.id) + '" ' + (record.id === onboardingDraft.ownerId ? "selected" : "") + '>' + esc(record.title) + '</option>').join("") + '</select></label><label><span>Business criticality</span><select name="criticality" required>' + ["low", "medium", "high", "critical"].map((value) => '<option value="' + value + '" ' + (value === onboardingDraft.criticality ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Highest data classification</span><select name="dataClassification" required>' + classifications.map((value) => '<option value="' + esc(value) + '" ' + (value === onboardingDraft.dataClassification ? "selected" : "") + '>' + esc(properCase(value)) + '</option>').join("") + '</select></label><label><span>Internet exposed</span><select name="internetExposed" required><option value="true" ' + (onboardingDraft.internetExposed === "true" ? "selected" : "") + '>Yes</option><option value="false" ' + (onboardingDraft.internetExposed === "false" ? "selected" : "") + '>No</option></select></label><label class="wide"><span>Program goal</span><select name="programGoal" required><option value="none" ' + (onboardingDraft.programGoal === "none" ? "selected" : "") + '>No Assurance Goal Yet</option><option value="readiness" ' + (onboardingDraft.programGoal === "readiness" ? "selected" : "") + '>Program Readiness</option><option value="type-1" ' + (onboardingDraft.programGoal === "type-1" ? "selected" : "") + '>SOC 2 Type 1</option><option value="type-2" ' + (onboardingDraft.programGoal === "type-2" ? "selected" : "") + '>SOC 2 Type 2</option></select><small>This records management intent only. It does not create an engagement or establish the formal report period.</small></label></form><p class="onboarding-write-note">' + esc(existing) + ' Save draft marks the service Planned and In scope. It is selected for scope review, but it is not approved or active. ' + (state.repository?.mode === "trunk" ? "The browser saves and synchronizes the related files together." : "Manual mode leaves the files for you to commit.") + ' Complete the remaining Step 1 pages next.</p>';
1573
1616
  }
1574
1617
 
1575
1618
  function captureOnboardingForm() {
@@ -2562,10 +2605,35 @@ async function responseMessage(response) {
2562
2605
  try { return JSON.parse(source).error || source; } catch { return source; }
2563
2606
  }
2564
2607
  async function localFetch(url, options) {
2608
+ const method = String(options?.method || "GET").toUpperCase();
2609
+ const synchronizing = state?.repository?.mode === "trunk"
2610
+ && ["POST", "PUT", "DELETE"].includes(method)
2611
+ && url !== "/api/evidence-packet";
2612
+ const chip = synchronizing ? document.querySelector(".repo-chip") : null;
2613
+ const previousChip = chip?.innerHTML;
2614
+ let repositoryRefreshed = false;
2615
+ if (chip) chip.innerHTML = '<span class="status-dot neutral"></span>Syncing';
2565
2616
  try {
2566
- return await fetch(url, options);
2617
+ const response = await fetch(url, options);
2618
+ if (synchronizing && !response.ok) {
2619
+ try {
2620
+ const stateResponse = await fetch("/api/state");
2621
+ if (stateResponse.ok) {
2622
+ state = await stateResponse.json();
2623
+ if (chip?.isConnected) {
2624
+ chip.innerHTML = '<span class="status-dot ' + repositoryStatusTone(state.repository.status) + '"></span>' + esc(state.repository.label);
2625
+ repositoryRefreshed = true;
2626
+ }
2627
+ }
2628
+ } catch {
2629
+ // The original response contains the useful mutation error.
2630
+ }
2631
+ }
2632
+ return response;
2567
2633
  } catch {
2568
2634
  throw new Error("The filegrc server is unavailable. Restart npm run serve, or pnpm dev in the monorepo, and try again.");
2635
+ } finally {
2636
+ if (!repositoryRefreshed && chip?.isConnected && previousChip) chip.innerHTML = previousChip;
2569
2637
  }
2570
2638
  }
2571
2639
  async function fetchJson(url, options) { const response = await localFetch(url, options); if (!response.ok) throw new Error(await responseMessage(response)); return response.json(); }
@@ -2598,7 +2666,7 @@ html,body{height:100%;overflow:hidden}.shell{grid-template-columns:248px minmax(
2598
2666
  .readiness-map{margin:14px 0;background:var(--panel);border:1px solid var(--line);border-radius:11px;padding:20px 22px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.readiness-map-head{display:grid;grid-template-columns:minmax(220px,1fr) minmax(320px,420px);gap:28px;align-items:center;margin-bottom:17px}.readiness-map-head h3{font-size:18px;margin:5px 0 0}.readiness-progress-summary{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 14px;align-items:center}.readiness-progress-summary>div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:3px 12px;align-items:baseline}.readiness-progress-summary>div>span{color:var(--muted);font-size:9.6px;font-weight:700;text-transform:uppercase;letter-spacing:.08em}.readiness-progress-summary>div>strong{font-size:9.6px;font-weight:700;line-height:1.2}.readiness-progress-summary .progress,.readiness-progress-summary small{grid-column:1/-1}.readiness-progress-summary small{color:var(--muted);font-size:9.6px}.readiness-progress-summary>.button{white-space:nowrap}.readiness-flow{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:7px}.readiness-flow a{display:grid;grid-template-columns:23px minmax(0,1fr);column-gap:8px;align-content:start;min-width:0;padding:11px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft);text-decoration:none}.readiness-flow a:hover{border-color:var(--accent-light);background:var(--accent-soft)}.readiness-flow a>span{grid-row:1/4;display:grid;place-items:center;width:23px;height:23px;border-radius:50%;background:var(--primary-gradient);color:#fff;font-size:9.6px;font-weight:800}.readiness-flow strong{font-size:12px;line-height:1.25}.readiness-flow small{grid-column:2;color:var(--muted);font-size:9.6px;line-height:1.4;margin-top:3px}.readiness-state{grid-column:2;justify-self:start;margin-top:8px;padding:3px 6px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:8.4px;line-height:1.2}.readiness-state.good{background:#dcefe4;color:#125733}.readiness-state.warn{background:#f6e8c9;color:#79500f}.readiness-state.bad{background:#f7dfdc;color:#873027}.audit-engagement{display:grid;grid-template-columns:minmax(210px,1fr) minmax(260px,1.25fr) auto;gap:20px;align-items:center;padding:14px 15px;border-radius:8px;background:var(--surface-soft)}.audit-engagement strong{font-size:13.2px}.audit-engagement p,.audit-engagement li{color:var(--muted);font-size:10.8px;line-height:1.5}.audit-engagement p{margin:5px 0 0}.audit-engagement ul{margin:0;padding-left:18px}.audit-engagement .button{white-space:nowrap;text-decoration:none}.resource-directory{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.resource-directory>section{min-width:0;padding:12px;border-radius:8px;background:var(--surface-soft)}.resource-directory h4{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.resource-directory a{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-top:1px solid var(--line);font-size:10.8px;text-decoration:none}.resource-directory a:first-of-type{border-top:0}.resource-directory a:hover span{color:var(--accent)}.resource-directory a strong{color:var(--muted);font-size:9.6px}.record-prose{max-width:790px}.record-prose section{padding:0 0 20px}.record-prose section+section{padding-top:20px;border-top:1px solid var(--line)}.record-prose h3{margin:0 0 7px;color:var(--muted);font-size:10.8px;text-transform:uppercase;letter-spacing:.08em}.record-prose p{margin:0;font-size:16.8px;line-height:1.65;white-space:pre-wrap}.connections-panel .panel-head>span{display:grid;place-items:center;min-width:22px;height:22px;border-radius:99px;background:var(--surface-muted);color:var(--muted);font-size:9.6px}.connections{display:grid}.connections a{display:block;padding:9px 0;border-top:1px solid var(--line);text-decoration:none}.connections a:first-child{padding-top:0;border-top:0}.connections strong,.connections small{display:block}.connections strong{font-size:12px}.connections small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.4}.connections a:hover strong{color:var(--accent)}.connections-more{margin:9px 0 0;color:var(--muted);font-size:9.6px;line-height:1.4}.external-source{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;color:var(--accent);text-decoration:none}.external-source span,.external-source strong,.external-source small{display:block}.external-source strong{font-size:12px;line-height:1.35}.external-source small{margin-top:3px;color:var(--muted);font-size:9.6px;line-height:1.35;overflow-wrap:anywhere}.external-source b{font-size:13.2px}.external-source:hover strong{text-decoration:underline}
2599
2667
  .page-title-line{display:flex;align-items:center;gap:8px}.guide-trigger{display:grid;place-items:center;width:24px;height:24px;flex:0 0 auto;padding:0;border:1px solid var(--line);border-radius:50%;background:var(--panel);color:var(--muted);cursor:pointer}.guide-trigger svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round}.guide-trigger:hover{border-color:var(--accent-light);color:var(--accent)}.guide-trigger:focus-visible{outline:2px solid var(--focus);outline-offset:2px}.page-guide{display:grid;grid-template-columns:1.05fr 1.25fr 1fr;gap:0;margin:0;background:var(--panel);border:1px solid var(--line);border-radius:10px;box-shadow:0 2px 8px rgba(21,40,33,.025)}.resource-guide-popover{position:fixed;z-index:40;overflow:auto;box-shadow:0 18px 50px rgba(0,0,24,.24)}.resource-guide-popover[hidden]{display:none}.page-guide>div{padding:14px 16px;border-left:1px solid var(--line);min-width:0}.page-guide>div:first-child{border-left:0}.page-guide>div>span{display:block;color:var(--accent);text-transform:uppercase;letter-spacing:.09em;font-size:9.6px;font-weight:780;margin-bottom:6px}.page-guide p{color:var(--muted);font-size:12px;line-height:1.5;margin:0}.guide-links{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px}.guide-links a{color:var(--accent);background:var(--accent-soft);border-radius:99px;padding:4px 7px;text-decoration:none;font-size:9.6px;font-weight:700}
2600
2668
  .operation-tracking{display:grid;gap:2px;min-width:0;text-decoration:none}.operation-tracking strong,.operation-tracking small{display:block;overflow-wrap:anywhere}.operation-tracking small{color:var(--muted);line-height:1.35}.operation-tracking.running strong{color:#176143}.operation-tracking.waiting strong,.operation-tracking.mixed strong{color:var(--amber)}.operation-tracking.paused strong{color:var(--red)}a.operation-tracking:hover strong{text-decoration:underline}
2601
- .page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
2669
+ .page-actions{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap}.repository-sync-status{min-height:18px;margin:7px 0 0;color:var(--muted);font-size:13.2px}.repository-sync-status.error{color:var(--red)}.repository-state-banner,.repository-override{display:flex;align-items:flex-start;gap:13px;margin-bottom:14px}.repository-state-banner>.status-dot,.repository-override>.status-dot{margin-top:5px}.repository-state-banner h3{margin:3px 0 5px}.repository-state-banner p:last-child,.repository-override p{margin:0;color:var(--muted);line-height:1.5}.repository-override{padding:14px 17px;border:1px solid #e9c888;border-radius:9px;background:#fff8e8;font-size:13.2px}.repository-override strong{display:block;margin-bottom:4px}.onboarding-dialog{width:min(470px,calc(100vw - 30px));max-height:calc(100vh - 32px);margin:0;border:1px solid var(--line);border-radius:13px;padding:0;background:var(--panel);color:var(--ink);box-shadow:0 28px 90px rgba(0,0,24,.38);overflow:hidden}.onboarding-dialog[open]{display:flex;flex-direction:column}.onboarding-dialog::backdrop{background:transparent;backdrop-filter:none}.onboarding-shade{position:fixed;inset:0;z-index:60;pointer-events:none}.onboarding-shade span{position:absolute;background:rgba(0,0,24,.58)}.onboarding-progress{display:grid;flex:0 0 auto;grid-template-columns:repeat(var(--onboarding-step-count),1fr);gap:5px;padding:18px 24px 0}.onboarding-progress span{height:3px;border-radius:3px;background:var(--surface-muted)}.onboarding-progress span.active{background:var(--accent-light)}.onboarding-scroll{min-height:0;overflow-y:auto}.onboarding-head{padding:22px 25px 0}.onboarding-head h2{font-family:Georgia,serif;font-size:30px;font-weight:500;letter-spacing:-.015em;margin:8px 0 0}.onboarding-body{color:var(--muted);font-size:14.4px;line-height:1.6;margin:13px 25px 0}.onboarding-body+.onboarding-body{margin-top:8px}.onboarding-points{display:grid;gap:9px;margin:18px 25px 4px;padding-left:19px}.onboarding-points li{font-size:13.2px;line-height:1.5;padding-left:3px}.onboarding-sections{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin:16px 25px 4px}.onboarding-sections section{padding:13px;border:1px solid var(--line);border-radius:8px;background:var(--surface-soft)}.onboarding-sections strong{font-size:13.2px}.onboarding-sections p{margin:6px 0 0;color:var(--muted);font-size:12px;line-height:1.5}.onboarding-actions{flex:0 0 auto;padding:12px 25px 23px;border-top:1px solid var(--line);background:var(--panel)}.onboarding-skip{margin-right:auto;color:var(--muted);text-transform:none;letter-spacing:0;font-size:13.2px}.onboarding-form{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:18px 25px 0}.onboarding-form label{display:block;min-width:0}.onboarding-form label.wide{grid-column:1/-1}.onboarding-form label>span{display:block;color:var(--ink);font-size:12px;font-weight:720;margin-bottom:6px}.onboarding-form input,.onboarding-form select,.onboarding-form textarea{width:100%;min-height:40px;border:1px solid var(--line);border-radius:7px;background:var(--field);color:var(--ink);padding:9px 10px;font-size:14.4px}.onboarding-form textarea{min-height:78px;resize:vertical}.onboarding-form small{display:block;color:var(--muted);font-size:10.8px;line-height:1.45;margin-top:5px}.onboarding-write-note{color:var(--muted);font-size:10.8px;line-height:1.5;margin:12px 25px 16px}.onboarding-scroll>.dialog-error{margin:8px 25px 0}.onboarding-focus{outline:4px solid var(--accent-light)!important;outline-offset:5px;scroll-margin-top:102px}
2602
2670
  .page-intro,.detail-head{align-items:center;margin-bottom:12px}.actions{align-items:center}.detail-head>div:first-child{min-width:0}.detail-head h2{margin:7px 0}.detail-head .header-breadcrumbs{margin:0;font-size:10.8px;line-height:normal;min-height:11px;align-items:center}.header-breadcrumbs span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60ch}
2603
2671
  @media(max-width:1200px){.readiness-flow{grid-template-columns:repeat(3,minmax(0,1fr))}.audit-engagement{grid-template-columns:1fr 1fr}.audit-engagement .button{grid-column:1/-1;justify-self:start}}
2604
2672
  @media(max-width:1100px){.search{display:none}.topbar-status{margin-left:auto}.metrics{grid-template-columns:repeat(2,1fr)}.dashboard-grid,.organization-grid{grid-template-columns:repeat(2,1fr)}.catalog{grid-template-columns:repeat(3,1fr)}.span-2{grid-column:span 2}.resource-directory{grid-template-columns:repeat(2,minmax(0,1fr))}}