@ran-sh/dsh-crew 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.claude-plugin/marketplace.json +16 -16
  2. package/.claude-plugin/plugin.json +14 -14
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +126 -126
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +116 -116
  19. package/agents/ds-flash.md +24 -24
  20. package/agents/ds-pro.md +24 -24
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +23 -23
  23. package/bin/dsh-crew.mjs +16 -16
  24. package/codex/agents/ds-flash.toml +34 -34
  25. package/codex/agents/ds-pro.toml +34 -34
  26. package/codex/agents/ds-reviewer.toml +32 -32
  27. package/codex/agents/ds-worker.toml +32 -32
  28. package/codex/prompts/dsh-config.md +19 -19
  29. package/codex/prompts/dsh-status.md +5 -5
  30. package/commands/config.md +23 -23
  31. package/commands/off.md +5 -5
  32. package/commands/on.md +5 -5
  33. package/commands/status.md +9 -9
  34. package/cordis.patch.yml +4 -4
  35. package/docs/gpt-relay-extension.md +103 -103
  36. package/docs/installation.md +138 -138
  37. package/docs/job-contracts.md +103 -103
  38. package/docs/readiness-matrix.md +85 -85
  39. package/docs/ui-surfaces.md +107 -107
  40. package/official-web-bridge/cordis.patch.yml +4 -4
  41. package/official-web-bridge/entry.mjs +1 -1
  42. package/official-web-bridge/overlay-entry.mjs +59 -59
  43. package/official-web-bridge/package.json +25 -25
  44. package/package.json +3 -2
  45. package/scripts/build-client.mjs +49 -49
  46. package/scripts/live-crew-smoke.mjs +39 -39
  47. package/scripts/live-policy-matrix.mjs +177 -177
  48. package/scripts/policy-probe.mjs +101 -101
  49. package/scripts/remove-legacy-official-bridge.ps1 +89 -89
  50. package/scripts/setup.mjs +393 -393
  51. package/scripts/smoke-real.mjs +110 -110
  52. package/scripts/smoke.mjs +78 -78
  53. package/scripts/verify-crew-ui-polish.mjs +145 -145
  54. package/scripts/verify-history-ui.mjs +97 -97
  55. package/scripts/verify-installer-fix.mjs +26 -26
  56. package/scripts/verify-npm-install.mjs +311 -311
  57. package/scripts/verify-official-bridge-e2e.mjs +192 -192
  58. package/src/adaptive-routing.mjs +260 -260
  59. package/src/client/activation-summary.tsx +64 -64
  60. package/src/client/collapsible-sections.mjs +55 -55
  61. package/src/client/history-panel.tsx +108 -108
  62. package/src/client/host-readiness.mjs +71 -71
  63. package/src/client/index.tsx +1711 -1711
  64. package/src/client/model-callability-view.mjs +17 -17
  65. package/src/client/panel-chrome.tsx +40 -40
  66. package/src/client/quick-entry.tsx +10 -10
  67. package/src/client/quick-panel.tsx +275 -275
  68. package/src/client/readiness-envelope.mjs +34 -34
  69. package/src/client/surface-detection.mjs +43 -43
  70. package/src/config-readiness.mjs +226 -226
  71. package/src/credential-reference.mjs +38 -38
  72. package/src/delivery.mjs +205 -205
  73. package/src/dsh-cli-runtime.mjs +1021 -1021
  74. package/src/dsh-cohort.mjs +20 -20
  75. package/src/extension-contract.mjs +104 -104
  76. package/src/failure-classification.mjs +201 -201
  77. package/src/history/admission-gate.mjs +67 -67
  78. package/src/history/archive-store.mjs +272 -272
  79. package/src/history/cleanup-plan.mjs +89 -89
  80. package/src/history/http.mjs +32 -32
  81. package/src/history/operation.mjs +86 -86
  82. package/src/history/runner-detach.mjs +34 -34
  83. package/src/history/runner.mjs +52 -52
  84. package/src/history/runtime.mjs +36 -36
  85. package/src/history/service.mjs +177 -177
  86. package/src/history/state.mjs +27 -27
  87. package/src/hub/entry.mjs +104 -104
  88. package/src/hub/index.mjs +2694 -2694
  89. package/src/hub-client.mjs +154 -154
  90. package/src/hub-compatibility.mjs +42 -42
  91. package/src/i18n.mjs +19 -19
  92. package/src/information-flow.mjs +67 -67
  93. package/src/install/cli.mjs +28 -28
  94. package/src/install/install-legacy.mjs +711 -711
  95. package/src/install/install.mjs +483 -483
  96. package/src/install/npx-lifecycle.mjs +3456 -3456
  97. package/src/install/official-frontend-assets.mjs +78 -78
  98. package/src/install/official-web.mjs +95 -95
  99. package/src/install/payload-content.mjs +88 -88
  100. package/src/install/windows-startup.mjs +236 -236
  101. package/src/install/windows-supervisor-adapter.mjs +443 -443
  102. package/src/install/windows-supervisor-lifecycle.mjs +782 -782
  103. package/src/install/zcode.mjs +397 -397
  104. package/src/job-contracts.mjs +255 -255
  105. package/src/local-request-guard.mjs +60 -60
  106. package/src/mcp-runtime.mjs +340 -340
  107. package/src/model-callability-contract.mjs +79 -79
  108. package/src/model-catalog.mjs +180 -180
  109. package/src/model-routing.mjs +586 -586
  110. package/src/model-schedule.mjs +207 -207
  111. package/src/official-web-bridge.mjs +447 -447
  112. package/src/policy.mjs +235 -235
  113. package/src/provider-delete-adapters.mjs +1934 -1934
  114. package/src/provider-health.mjs +130 -130
  115. package/src/provider-inventory.mjs +182 -182
  116. package/src/provider-layer-migration-adapters.mjs +759 -759
  117. package/src/provider-layer-migration.mjs +198 -198
  118. package/src/provider-lifecycle-state.mjs +103 -103
  119. package/src/provider-lifecycle.mjs +252 -252
  120. package/src/provider-profile-store.mjs +390 -390
  121. package/src/provider-settings-store.mjs +633 -633
  122. package/src/provider-store-lock.mjs +67 -67
  123. package/src/readiness-matrix.mjs +181 -181
  124. package/src/removable-waiter.mjs +29 -29
  125. package/src/role-profiles.mjs +107 -107
  126. package/src/runtime-controls.mjs +84 -84
  127. package/src/runtime-identity-contract.mjs +34 -34
  128. package/src/runtime-identity.mjs +235 -235
  129. package/src/runtime-readiness-snapshot.mjs +285 -285
  130. package/src/server.mjs +581 -581
  131. package/src/session-origins.mjs +60 -60
  132. package/src/standalone-sdk.mjs +23 -23
  133. package/src/status-shard.mjs +63 -63
  134. package/src/structured-error-code.mjs +38 -38
  135. package/src/supervisor/restart-request.mjs +256 -256
  136. package/src/workflow-runtime.mjs +739 -739
  137. package/src/workflow.mjs +155 -155
  138. package/src/workspace-audit.mjs +231 -231
  139. package/src/workspace-context.mjs +146 -146
  140. package/src/workspace-isolation.mjs +463 -455
  141. package/src/workspace-readiness.mjs +32 -32
  142. package/statusline/statusline.sh +14 -14
  143. package/statusline/worker-segment.sh +35 -35
  144. package/windows/start-dsh-crew.cmd +57 -57
  145. package/windows/start-dsh-crew.ps1 +1302 -1302
  146. package/windows/supervisor-control.ps1 +467 -467
  147. package/worker.cordis.yml +67 -67
  148. package/zcode/agents/ds-reviewer.md +31 -31
  149. package/zcode/agents/ds-worker.md +31 -31
  150. package/zcode/commands/dsh-config.md +17 -17
  151. package/zcode/commands/dsh-status.md +5 -5
@@ -1,231 +1,231 @@
1
- // Read-only workspace auditing for the auditable-delivery flow. Captures a
2
- // before/after snapshot of a git working tree (status / stat / name-status)
3
- // plus a bounded, redacted patch, so an orchestrator can review exactly what a
4
- // worker changed before accepting the result.
5
- //
6
- // Discipline: strictly read-only. Only git porcelain reads are issued — never
7
- // reset / stash / clean / checkout, and nothing here writes to disk. A
8
- // non-git directory degrades to { kind: 'no-git' } instead of failing, and a
9
- // pre-dirty workspace is flagged (dirtyBaseline) rather than hidden.
10
- //
11
- // The default `git` runner is replaceable with an injected runner in tests:
12
- // it receives (argsArray, { cwd }) and resolves { code, stdout, stderr }.
13
-
14
- import { execFile } from 'node:child_process';
15
- import { existsSync } from 'node:fs';
16
- import { win32 } from 'node:path';
17
- import { promisify } from 'node:util';
18
-
19
- const execFileAsync = promisify(execFile);
20
-
21
- export const DIFF_LIMIT = 64 * 1024;
22
- export const NOT_A_GIT_REPOSITORY = 'NOT_A_GIT_REPOSITORY';
23
- export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
24
- export const GIT_TIMEOUT = 'GIT_TIMEOUT';
25
- export const GIT_TIMEOUT_MS = 8000;
26
-
27
- const SENSITIVE_SUFFIXES = ['.pem', '.key'];
28
- const SENSITIVE_PREFIXES = ['credentials', 'secret'];
29
- const ENV_BASENAME = '.env';
30
-
31
- /**
32
- * True when a path looks like a credential/secret file whose patch content
33
- * must be redacted: .env / .env.*, credentials*, secrets*, *.pem, *.key.
34
- * The file's name-status entry stays visible — only the patch is hidden.
35
- */
36
- export function isSensitivePath(relPath) {
37
- const segments = String(relPath ?? '').replace(/\\/g, '/').split('/');
38
- return segments.some((raw) => {
39
- const seg = raw.toLowerCase();
40
- if (seg === ENV_BASENAME || seg.startsWith(`${ENV_BASENAME}.`)) return true;
41
- if (SENSITIVE_PREFIXES.some((p) => seg.startsWith(p))) return true;
42
- return SENSITIVE_SUFFIXES.some((s) => seg.endsWith(s));
43
- });
44
- }
45
-
46
- let resolvedGitExecutable = 'git';
47
-
48
- export async function resolveWindowsGit({ exec = execFileAsync, env = process.env, exists = existsSync } = {}) {
49
- try {
50
- const located = await exec('where.exe', ['git'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
51
- const first = String(located.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
52
- if (first) return first;
53
- } catch {}
54
- // Always compose candidate paths with Windows semantics. This helper is
55
- // unit-tested on non-Windows hosts too, and host-native path.join() would
56
- // otherwise turn C:\\... inputs into mixed/invalid paths on POSIX.
57
- const candidates = [
58
- env.ProgramFiles && win32.join(env.ProgramFiles, 'Git', 'cmd', 'git.exe'),
59
- env.ProgramFiles && win32.join(env.ProgramFiles, 'Git', 'bin', 'git.exe'),
60
- env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, 'Programs', 'Git', 'cmd', 'git.exe'),
61
- ].filter(Boolean);
62
- return candidates.find((candidate) => exists(candidate));
63
- }
64
-
65
- async function defaultRunner(args, { cwd }) {
66
- let out;
67
- try {
68
- out = await execFileAsync(resolvedGitExecutable, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
69
- } catch (error) {
70
- const missing = error?.code === 'ENOENT' || /spawn git ENOENT/i.test(error?.message ?? '');
71
- if (!missing || process.platform !== 'win32' || resolvedGitExecutable !== 'git') throw error;
72
- const fallback = await resolveWindowsGit();
73
- if (!fallback) throw error;
74
- resolvedGitExecutable = fallback;
75
- out = await execFileAsync(resolvedGitExecutable, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
76
- }
77
- return { code: 0, stdout: out.stdout ?? '', stderr: out.stderr ?? '' };
78
- }
79
-
80
- /** Run one git read; normalize failure into a no-git reason, never throws. */
81
- async function runGit(runner, args, opts) {
82
- try {
83
- const r = await runner(args, opts);
84
- const stderr = r.stderr ?? '';
85
- if (/not a git repository/i.test(stderr)) {
86
- return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: stderr.trim() };
87
- }
88
- return { ok: true, code: r.code ?? 0, stdout: r.stdout ?? '', stderr };
89
- } catch (err) {
90
- const msg = err?.message ?? String(err);
91
- if (err?.code === 'ETIMEDOUT' || err?.killed === true || /timed out|timeout/i.test(msg)) return { ok: false, reason: GIT_TIMEOUT, error: 'git audit timed out' };
92
- if (/ENOENT|spawn git/i.test(msg)) return { ok: false, reason: GIT_NOT_FOUND, error: msg };
93
- return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: msg };
94
- }
95
- }
96
-
97
- function splitFirstTab(line) {
98
- const i = line.indexOf('\t');
99
- return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
100
- }
101
-
102
- /**
103
- * Parse `git diff --name-status` + `git status --porcelain` into structured
104
- * change lists used by the patch builder. Renames/copies fold onto their
105
- * destination path (the diff shows them there); untracked `??` rows are listed
106
- * separately because git does not diff them read-only.
107
- */
108
- export function parseChanges(nameStatus, statusPorcelain) {
109
- const modified = [];
110
- const deleted = [];
111
- const renamed = [];
112
- for (const line of String(nameStatus ?? '').split('\n')) {
113
- const trimmed = line.trim();
114
- if (!trimmed) continue;
115
- const [code, rest] = splitFirstTab(trimmed);
116
- const paths = rest.split('\t').filter(Boolean);
117
- if (paths.length === 0) continue;
118
- const x = code[0] ?? '';
119
- const y = code[1] ?? '';
120
- if (y === 'D' || x === 'D') { deleted.push(paths[paths.length - 1]); continue; }
121
- if (x === 'R' || x === 'C') { renamed.push(paths[0]); modified.push(paths[paths.length - 1]); continue; }
122
- modified.push(paths[paths.length - 1]);
123
- }
124
- const untracked = String(statusPorcelain ?? '').split('\n')
125
- .map((l) => l.trim())
126
- .filter((l) => l.startsWith('??'))
127
- .map((l) => l.slice(2).trim())
128
- .filter(Boolean);
129
- return {
130
- modified: [...new Set(modified)],
131
- deleted: [...new Set(deleted)],
132
- renamed: [...new Set(renamed)],
133
- untracked: [...new Set(untracked)],
134
- };
135
- }
136
-
137
- /**
138
- * Read-only pre-run snapshot of a workspace. Returns a git snapshot
139
- * { kind:'git', status, nameStatus, stat, dirty, changes } or a degraded
140
- * { kind:'no-git', reason, error } for non-repos / missing git. Never throws.
141
- */
142
- export async function captureWorkspaceBaseline({ cwd, git } = {}) {
143
- const runner = git ?? defaultRunner;
144
- if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
145
- const [status, nameStatus, stat] = await Promise.all([
146
- runGit(runner, ['status', '--porcelain'], { cwd }),
147
- runGit(runner, ['diff', '--name-status'], { cwd }),
148
- runGit(runner, ['diff', '--stat'], { cwd }),
149
- ]);
150
- for (const r of [status, nameStatus, stat]) {
151
- if (!r.ok) return { kind: 'no-git', reason: r.reason, error: r.error };
152
- }
153
- const changes = parseChanges(nameStatus.stdout, status.stdout);
154
- return {
155
- kind: 'git',
156
- status: status.stdout,
157
- nameStatus: nameStatus.stdout,
158
- stat: stat.stdout,
159
- dirty: changes.modified.length + changes.deleted.length + changes.renamed.length + changes.untracked.length > 0,
160
- changes,
161
- };
162
- }
163
-
164
- /** Build the bounded, redacted patch for the changed (tracked) paths. */
165
- async function buildPatch(runner, { cwd, changes, limit }) {
166
- const { modified, deleted, untracked } = changes;
167
- const nonSensitive = [...new Set([...modified, ...deleted])].filter((p) => !isSensitivePath(p));
168
- const sensitive = [...new Set([...modified, ...deleted])].filter((p) => isSensitivePath(p));
169
- const redacted = [];
170
- let patch = '';
171
- if (nonSensitive.length > 0) {
172
- const r = await runGit(runner, ['diff', '--binary', '--', ...nonSensitive], { cwd });
173
- if (!r.ok) return { failed: true, reason: r.reason, error: r.error };
174
- patch += r.stdout;
175
- }
176
- for (const p of sensitive) {
177
- redacted.push(p);
178
- patch += `diff --git a/${p} b/${p}\n[REDACTED SENSITIVE FILE]\n`;
179
- }
180
- for (const p of untracked) {
181
- if (isSensitivePath(p)) {
182
- redacted.push(p);
183
- patch += `[REDACTED SENSITIVE FILE: ${p} (untracked)]\n`;
184
- } else {
185
- patch += `[UNTRACKED FILE: ${p} (content not diffed read-only)]\n`;
186
- }
187
- }
188
- let truncated = false;
189
- if (Buffer.byteLength(patch, 'utf8') > limit) {
190
- patch = Buffer.from(patch, 'utf8').subarray(0, limit).toString('utf8');
191
- truncated = true;
192
- }
193
- return { failed: false, patch, truncated, redacted };
194
- }
195
-
196
- /**
197
- * Read-only post-run snapshot: the after-state (status / stat / name-status)
198
- * plus the bounded, redacted patch of everything the worker changed.
199
- * `dirtyBaseline` records whether the workspace was already dirty before the
200
- * worker started (the diff may then include pre-existing changes). Never
201
- * throws; degrades to { kind:'no-git' } like captureWorkspaceBaseline.
202
- */
203
- export async function captureWorkspaceDiff({ cwd, baseline, git, limit = DIFF_LIMIT } = {}) {
204
- const runner = git ?? defaultRunner;
205
- if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
206
- if (!baseline || baseline.kind !== 'git') {
207
- return { kind: 'no-git', reason: baseline?.reason ?? NOT_A_GIT_REPOSITORY, error: baseline?.error };
208
- }
209
- const [status, nameStatus, stat] = await Promise.all([
210
- runGit(runner, ['status', '--porcelain'], { cwd }),
211
- runGit(runner, ['diff', '--name-status'], { cwd }),
212
- runGit(runner, ['diff', '--stat'], { cwd }),
213
- ]);
214
- for (const r of [status, nameStatus, stat]) {
215
- if (!r.ok) return { kind: 'no-git', reason: r.reason, error: r.error };
216
- }
217
- const changes = parseChanges(nameStatus.stdout, status.stdout);
218
- const patch = await buildPatch(runner, { cwd, changes, limit });
219
- if (patch.failed) return { kind: 'no-git', reason: patch.reason, error: patch.error };
220
- return {
221
- kind: 'git',
222
- status: status.stdout,
223
- nameStatus: nameStatus.stdout,
224
- stat: stat.stdout,
225
- patch: patch.patch,
226
- truncated: patch.truncated,
227
- redacted: patch.redacted,
228
- dirtyBaseline: baseline.dirty === true,
229
- changes,
230
- };
231
- }
1
+ // Read-only workspace auditing for the auditable-delivery flow. Captures a
2
+ // before/after snapshot of a git working tree (status / stat / name-status)
3
+ // plus a bounded, redacted patch, so an orchestrator can review exactly what a
4
+ // worker changed before accepting the result.
5
+ //
6
+ // Discipline: strictly read-only. Only git porcelain reads are issued — never
7
+ // reset / stash / clean / checkout, and nothing here writes to disk. A
8
+ // non-git directory degrades to { kind: 'no-git' } instead of failing, and a
9
+ // pre-dirty workspace is flagged (dirtyBaseline) rather than hidden.
10
+ //
11
+ // The default `git` runner is replaceable with an injected runner in tests:
12
+ // it receives (argsArray, { cwd }) and resolves { code, stdout, stderr }.
13
+
14
+ import { execFile } from 'node:child_process';
15
+ import { existsSync } from 'node:fs';
16
+ import { win32 } from 'node:path';
17
+ import { promisify } from 'node:util';
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ export const DIFF_LIMIT = 64 * 1024;
22
+ export const NOT_A_GIT_REPOSITORY = 'NOT_A_GIT_REPOSITORY';
23
+ export const GIT_NOT_FOUND = 'GIT_NOT_FOUND';
24
+ export const GIT_TIMEOUT = 'GIT_TIMEOUT';
25
+ export const GIT_TIMEOUT_MS = 8000;
26
+
27
+ const SENSITIVE_SUFFIXES = ['.pem', '.key'];
28
+ const SENSITIVE_PREFIXES = ['credentials', 'secret'];
29
+ const ENV_BASENAME = '.env';
30
+
31
+ /**
32
+ * True when a path looks like a credential/secret file whose patch content
33
+ * must be redacted: .env / .env.*, credentials*, secrets*, *.pem, *.key.
34
+ * The file's name-status entry stays visible — only the patch is hidden.
35
+ */
36
+ export function isSensitivePath(relPath) {
37
+ const segments = String(relPath ?? '').replace(/\\/g, '/').split('/');
38
+ return segments.some((raw) => {
39
+ const seg = raw.toLowerCase();
40
+ if (seg === ENV_BASENAME || seg.startsWith(`${ENV_BASENAME}.`)) return true;
41
+ if (SENSITIVE_PREFIXES.some((p) => seg.startsWith(p))) return true;
42
+ return SENSITIVE_SUFFIXES.some((s) => seg.endsWith(s));
43
+ });
44
+ }
45
+
46
+ let resolvedGitExecutable = 'git';
47
+
48
+ export async function resolveWindowsGit({ exec = execFileAsync, env = process.env, exists = existsSync } = {}) {
49
+ try {
50
+ const located = await exec('where.exe', ['git'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
51
+ const first = String(located.stdout ?? '').split(/\r?\n/).map((line) => line.trim()).find(Boolean);
52
+ if (first) return first;
53
+ } catch {}
54
+ // Always compose candidate paths with Windows semantics. This helper is
55
+ // unit-tested on non-Windows hosts too, and host-native path.join() would
56
+ // otherwise turn C:\\... inputs into mixed/invalid paths on POSIX.
57
+ const candidates = [
58
+ env.ProgramFiles && win32.join(env.ProgramFiles, 'Git', 'cmd', 'git.exe'),
59
+ env.ProgramFiles && win32.join(env.ProgramFiles, 'Git', 'bin', 'git.exe'),
60
+ env.LOCALAPPDATA && win32.join(env.LOCALAPPDATA, 'Programs', 'Git', 'cmd', 'git.exe'),
61
+ ].filter(Boolean);
62
+ return candidates.find((candidate) => exists(candidate));
63
+ }
64
+
65
+ async function defaultRunner(args, { cwd }) {
66
+ let out;
67
+ try {
68
+ out = await execFileAsync(resolvedGitExecutable, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
69
+ } catch (error) {
70
+ const missing = error?.code === 'ENOENT' || /spawn git ENOENT/i.test(error?.message ?? '');
71
+ if (!missing || process.platform !== 'win32' || resolvedGitExecutable !== 'git') throw error;
72
+ const fallback = await resolveWindowsGit();
73
+ if (!fallback) throw error;
74
+ resolvedGitExecutable = fallback;
75
+ out = await execFileAsync(resolvedGitExecutable, args, { cwd, encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: GIT_TIMEOUT_MS });
76
+ }
77
+ return { code: 0, stdout: out.stdout ?? '', stderr: out.stderr ?? '' };
78
+ }
79
+
80
+ /** Run one git read; normalize failure into a no-git reason, never throws. */
81
+ async function runGit(runner, args, opts) {
82
+ try {
83
+ const r = await runner(args, opts);
84
+ const stderr = r.stderr ?? '';
85
+ if (/not a git repository/i.test(stderr)) {
86
+ return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: stderr.trim() };
87
+ }
88
+ return { ok: true, code: r.code ?? 0, stdout: r.stdout ?? '', stderr };
89
+ } catch (err) {
90
+ const msg = err?.message ?? String(err);
91
+ if (err?.code === 'ETIMEDOUT' || err?.killed === true || /timed out|timeout/i.test(msg)) return { ok: false, reason: GIT_TIMEOUT, error: 'git audit timed out' };
92
+ if (/ENOENT|spawn git/i.test(msg)) return { ok: false, reason: GIT_NOT_FOUND, error: msg };
93
+ return { ok: false, reason: NOT_A_GIT_REPOSITORY, error: msg };
94
+ }
95
+ }
96
+
97
+ function splitFirstTab(line) {
98
+ const i = line.indexOf('\t');
99
+ return i === -1 ? [line, ''] : [line.slice(0, i), line.slice(i + 1)];
100
+ }
101
+
102
+ /**
103
+ * Parse `git diff --name-status` + `git status --porcelain` into structured
104
+ * change lists used by the patch builder. Renames/copies fold onto their
105
+ * destination path (the diff shows them there); untracked `??` rows are listed
106
+ * separately because git does not diff them read-only.
107
+ */
108
+ export function parseChanges(nameStatus, statusPorcelain) {
109
+ const modified = [];
110
+ const deleted = [];
111
+ const renamed = [];
112
+ for (const line of String(nameStatus ?? '').split('\n')) {
113
+ const trimmed = line.trim();
114
+ if (!trimmed) continue;
115
+ const [code, rest] = splitFirstTab(trimmed);
116
+ const paths = rest.split('\t').filter(Boolean);
117
+ if (paths.length === 0) continue;
118
+ const x = code[0] ?? '';
119
+ const y = code[1] ?? '';
120
+ if (y === 'D' || x === 'D') { deleted.push(paths[paths.length - 1]); continue; }
121
+ if (x === 'R' || x === 'C') { renamed.push(paths[0]); modified.push(paths[paths.length - 1]); continue; }
122
+ modified.push(paths[paths.length - 1]);
123
+ }
124
+ const untracked = String(statusPorcelain ?? '').split('\n')
125
+ .map((l) => l.trim())
126
+ .filter((l) => l.startsWith('??'))
127
+ .map((l) => l.slice(2).trim())
128
+ .filter(Boolean);
129
+ return {
130
+ modified: [...new Set(modified)],
131
+ deleted: [...new Set(deleted)],
132
+ renamed: [...new Set(renamed)],
133
+ untracked: [...new Set(untracked)],
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Read-only pre-run snapshot of a workspace. Returns a git snapshot
139
+ * { kind:'git', status, nameStatus, stat, dirty, changes } or a degraded
140
+ * { kind:'no-git', reason, error } for non-repos / missing git. Never throws.
141
+ */
142
+ export async function captureWorkspaceBaseline({ cwd, git } = {}) {
143
+ const runner = git ?? defaultRunner;
144
+ if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
145
+ const [status, nameStatus, stat] = await Promise.all([
146
+ runGit(runner, ['status', '--porcelain'], { cwd }),
147
+ runGit(runner, ['diff', '--name-status'], { cwd }),
148
+ runGit(runner, ['diff', '--stat'], { cwd }),
149
+ ]);
150
+ for (const r of [status, nameStatus, stat]) {
151
+ if (!r.ok) return { kind: 'no-git', reason: r.reason, error: r.error };
152
+ }
153
+ const changes = parseChanges(nameStatus.stdout, status.stdout);
154
+ return {
155
+ kind: 'git',
156
+ status: status.stdout,
157
+ nameStatus: nameStatus.stdout,
158
+ stat: stat.stdout,
159
+ dirty: changes.modified.length + changes.deleted.length + changes.renamed.length + changes.untracked.length > 0,
160
+ changes,
161
+ };
162
+ }
163
+
164
+ /** Build the bounded, redacted patch for the changed (tracked) paths. */
165
+ async function buildPatch(runner, { cwd, changes, limit }) {
166
+ const { modified, deleted, untracked } = changes;
167
+ const nonSensitive = [...new Set([...modified, ...deleted])].filter((p) => !isSensitivePath(p));
168
+ const sensitive = [...new Set([...modified, ...deleted])].filter((p) => isSensitivePath(p));
169
+ const redacted = [];
170
+ let patch = '';
171
+ if (nonSensitive.length > 0) {
172
+ const r = await runGit(runner, ['diff', '--binary', '--', ...nonSensitive], { cwd });
173
+ if (!r.ok) return { failed: true, reason: r.reason, error: r.error };
174
+ patch += r.stdout;
175
+ }
176
+ for (const p of sensitive) {
177
+ redacted.push(p);
178
+ patch += `diff --git a/${p} b/${p}\n[REDACTED SENSITIVE FILE]\n`;
179
+ }
180
+ for (const p of untracked) {
181
+ if (isSensitivePath(p)) {
182
+ redacted.push(p);
183
+ patch += `[REDACTED SENSITIVE FILE: ${p} (untracked)]\n`;
184
+ } else {
185
+ patch += `[UNTRACKED FILE: ${p} (content not diffed read-only)]\n`;
186
+ }
187
+ }
188
+ let truncated = false;
189
+ if (Buffer.byteLength(patch, 'utf8') > limit) {
190
+ patch = Buffer.from(patch, 'utf8').subarray(0, limit).toString('utf8');
191
+ truncated = true;
192
+ }
193
+ return { failed: false, patch, truncated, redacted };
194
+ }
195
+
196
+ /**
197
+ * Read-only post-run snapshot: the after-state (status / stat / name-status)
198
+ * plus the bounded, redacted patch of everything the worker changed.
199
+ * `dirtyBaseline` records whether the workspace was already dirty before the
200
+ * worker started (the diff may then include pre-existing changes). Never
201
+ * throws; degrades to { kind:'no-git' } like captureWorkspaceBaseline.
202
+ */
203
+ export async function captureWorkspaceDiff({ cwd, baseline, git, limit = DIFF_LIMIT } = {}) {
204
+ const runner = git ?? defaultRunner;
205
+ if (!cwd) return { kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace cwd is required' };
206
+ if (!baseline || baseline.kind !== 'git') {
207
+ return { kind: 'no-git', reason: baseline?.reason ?? NOT_A_GIT_REPOSITORY, error: baseline?.error };
208
+ }
209
+ const [status, nameStatus, stat] = await Promise.all([
210
+ runGit(runner, ['status', '--porcelain'], { cwd }),
211
+ runGit(runner, ['diff', '--name-status'], { cwd }),
212
+ runGit(runner, ['diff', '--stat'], { cwd }),
213
+ ]);
214
+ for (const r of [status, nameStatus, stat]) {
215
+ if (!r.ok) return { kind: 'no-git', reason: r.reason, error: r.error };
216
+ }
217
+ const changes = parseChanges(nameStatus.stdout, status.stdout);
218
+ const patch = await buildPatch(runner, { cwd, changes, limit });
219
+ if (patch.failed) return { kind: 'no-git', reason: patch.reason, error: patch.error };
220
+ return {
221
+ kind: 'git',
222
+ status: status.stdout,
223
+ nameStatus: nameStatus.stdout,
224
+ stat: stat.stdout,
225
+ patch: patch.patch,
226
+ truncated: patch.truncated,
227
+ redacted: patch.redacted,
228
+ dirtyBaseline: baseline.dirty === true,
229
+ changes,
230
+ };
231
+ }