patchwarden 0.4.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/PatchWarden.cmd +51 -0
  2. package/README.en.md +1022 -0
  3. package/README.md +897 -358
  4. package/dist/assessments/agentAssessor.d.ts +15 -0
  5. package/dist/assessments/agentAssessor.js +293 -0
  6. package/dist/assessments/assessmentStore.d.ts +133 -0
  7. package/dist/assessments/assessmentStore.js +238 -0
  8. package/dist/assessments/confirmCli.d.ts +9 -0
  9. package/dist/assessments/confirmCli.js +26 -0
  10. package/dist/config.d.ts +22 -1
  11. package/dist/config.js +156 -3
  12. package/dist/direct/directAudit.d.ts +23 -0
  13. package/dist/direct/directAudit.js +309 -0
  14. package/dist/direct/directGuards.d.ts +20 -0
  15. package/dist/direct/directGuards.js +137 -0
  16. package/dist/direct/directPatch.d.ts +17 -0
  17. package/dist/direct/directPatch.js +113 -0
  18. package/dist/direct/directSessionStore.d.ts +63 -0
  19. package/dist/direct/directSessionStore.js +192 -0
  20. package/dist/direct/directVerification.d.ts +12 -0
  21. package/dist/direct/directVerification.js +96 -0
  22. package/dist/doctor.js +73 -5
  23. package/dist/logging.d.ts +52 -0
  24. package/dist/logging.js +123 -0
  25. package/dist/runner/agentInvocation.d.ts +23 -0
  26. package/dist/runner/agentInvocation.js +106 -0
  27. package/dist/runner/changeCapture.d.ts +70 -0
  28. package/dist/runner/changeCapture.js +278 -6
  29. package/dist/runner/runTask.js +271 -65
  30. package/dist/runner/simpleProcess.d.ts +21 -0
  31. package/dist/runner/simpleProcess.js +206 -0
  32. package/dist/runner/watch.js +17 -2
  33. package/dist/security/commandGuard.d.ts +2 -1
  34. package/dist/security/commandGuard.js +25 -3
  35. package/dist/security/riskEngine.d.ts +27 -0
  36. package/dist/security/riskEngine.js +118 -0
  37. package/dist/security/runtimeGuard.d.ts +6 -0
  38. package/dist/security/runtimeGuard.js +28 -0
  39. package/dist/smoke-test.js +1353 -7
  40. package/dist/test/unit/android-doctor.test.d.ts +1 -0
  41. package/dist/test/unit/android-doctor.test.js +118 -0
  42. package/dist/test/unit/chinese-path.test.d.ts +1 -0
  43. package/dist/test/unit/chinese-path.test.js +91 -0
  44. package/dist/test/unit/command-guard.test.d.ts +1 -0
  45. package/dist/test/unit/command-guard.test.js +160 -0
  46. package/dist/test/unit/direct-guards.test.d.ts +1 -0
  47. package/dist/test/unit/direct-guards.test.js +213 -0
  48. package/dist/test/unit/logging.test.d.ts +1 -0
  49. package/dist/test/unit/logging.test.js +275 -0
  50. package/dist/test/unit/path-guard.test.d.ts +1 -0
  51. package/dist/test/unit/path-guard.test.js +109 -0
  52. package/dist/test/unit/safe-status.test.d.ts +1 -0
  53. package/dist/test/unit/safe-status.test.js +165 -0
  54. package/dist/test/unit/sensitive-guard.test.d.ts +1 -0
  55. package/dist/test/unit/sensitive-guard.test.js +104 -0
  56. package/dist/test/unit/sync-file.test.d.ts +1 -0
  57. package/dist/test/unit/sync-file.test.js +154 -0
  58. package/dist/test/unit/watcher-status.test.d.ts +1 -0
  59. package/dist/test/unit/watcher-status.test.js +169 -0
  60. package/dist/tools/androidDoctor.d.ts +38 -0
  61. package/dist/tools/androidDoctor.js +391 -0
  62. package/dist/tools/applyPatch.d.ts +16 -0
  63. package/dist/tools/applyPatch.js +41 -0
  64. package/dist/tools/auditSession.d.ts +5 -0
  65. package/dist/tools/auditSession.js +12 -0
  66. package/dist/tools/auditTask.d.ts +7 -0
  67. package/dist/tools/auditTask.js +105 -7
  68. package/dist/tools/createDirectSession.d.ts +14 -0
  69. package/dist/tools/createDirectSession.js +54 -0
  70. package/dist/tools/createTask.d.ts +48 -1
  71. package/dist/tools/createTask.js +298 -47
  72. package/dist/tools/finalizeDirectSession.d.ts +19 -0
  73. package/dist/tools/finalizeDirectSession.js +84 -0
  74. package/dist/tools/getTaskSummary.d.ts +50 -0
  75. package/dist/tools/getTaskSummary.js +79 -2
  76. package/dist/tools/healthCheck.d.ts +27 -12
  77. package/dist/tools/healthCheck.js +44 -6
  78. package/dist/tools/readWorkspaceFile.d.ts +7 -1
  79. package/dist/tools/readWorkspaceFile.js +48 -2
  80. package/dist/tools/registry.js +270 -19
  81. package/dist/tools/runVerification.d.ts +16 -0
  82. package/dist/tools/runVerification.js +32 -0
  83. package/dist/tools/safeStatus.d.ts +19 -0
  84. package/dist/tools/safeStatus.js +72 -0
  85. package/dist/tools/savePlan.d.ts +1 -0
  86. package/dist/tools/savePlan.js +38 -7
  87. package/dist/tools/searchWorkspace.d.ts +19 -0
  88. package/dist/tools/searchWorkspace.js +205 -0
  89. package/dist/tools/syncFile.d.ts +18 -0
  90. package/dist/tools/syncFile.js +65 -0
  91. package/dist/tools/taskOutputs.d.ts +2 -2
  92. package/dist/tools/taskTemplates.js +2 -1
  93. package/dist/tools/toolCatalog.d.ts +4 -3
  94. package/dist/tools/toolCatalog.js +34 -5
  95. package/dist/tools/waitForTask.d.ts +2 -2
  96. package/dist/tools/waitForTask.js +1 -1
  97. package/dist/version.d.ts +2 -2
  98. package/dist/version.js +2 -2
  99. package/dist/watcherStatus.d.ts +1 -0
  100. package/dist/watcherStatus.js +96 -4
  101. package/docs/chatgpt-usage.md +101 -0
  102. package/docs/performance-notes.md +55 -0
  103. package/docs/release-checklist.md +14 -0
  104. package/docs/release-v0.6.0.md +71 -0
  105. package/docs/release-v0.6.1.md +75 -0
  106. package/examples/config.example.json +5 -0
  107. package/examples/openai-tunnel/README.md +11 -4
  108. package/examples/openai-tunnel/chatgpt-test-prompt.md +18 -14
  109. package/examples/openai-tunnel/tunnel-client.example.yaml +8 -0
  110. package/package.json +13 -8
  111. package/scripts/brand-check.js +58 -12
  112. package/scripts/control-smoke.js +206 -0
  113. package/scripts/http-mcp-smoke.js +10 -2
  114. package/scripts/launchers/Check-PatchWarden-Health.cmd +6 -0
  115. package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +6 -0
  116. package/scripts/launchers/Restart-PatchWarden.cmd +6 -0
  117. package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +7 -0
  118. package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +7 -0
  119. package/scripts/lifecycle-smoke.js +402 -8
  120. package/scripts/manage-patchwarden.ps1 +639 -0
  121. package/scripts/mcp-manifest-check.js +123 -58
  122. package/scripts/mcp-smoke.js +160 -2
  123. package/scripts/pack-clean.js +160 -5
  124. package/scripts/package-manifest-check.js +78 -0
  125. package/scripts/patchwarden-mcp-direct.cmd +7 -0
  126. package/scripts/patchwarden-mcp-stdio.cmd +1 -1
  127. package/scripts/restart-patchwarden.ps1 +5 -4
  128. package/scripts/start-patchwarden-tunnel.ps1 +261 -30
  129. package/scripts/tunnel-supervisor-smoke.js +36 -4
  130. package/scripts/unit-tests.js +36 -0
  131. package/scripts/watcher-supervisor-smoke.js +10 -6
  132. package/src/assessments/agentAssessor.ts +324 -0
  133. package/src/assessments/assessmentStore.ts +426 -0
  134. package/src/assessments/confirmCli.ts +29 -0
  135. package/src/config.ts +178 -4
  136. package/src/direct/directAudit.ts +400 -0
  137. package/src/direct/directGuards.ts +279 -0
  138. package/src/direct/directPatch.ts +258 -0
  139. package/src/direct/directSessionStore.ts +345 -0
  140. package/src/direct/directVerification.ts +138 -0
  141. package/src/doctor.ts +103 -7
  142. package/src/logging.ts +152 -0
  143. package/src/runner/agentInvocation.ts +125 -0
  144. package/src/runner/changeCapture.ts +352 -6
  145. package/src/runner/runTask.ts +279 -63
  146. package/src/runner/simpleProcess.ts +223 -0
  147. package/src/runner/watch.ts +18 -2
  148. package/src/security/commandGuard.ts +46 -4
  149. package/src/security/riskEngine.ts +160 -0
  150. package/src/security/runtimeGuard.ts +41 -0
  151. package/src/smoke-test.ts +1291 -4
  152. package/src/test/unit/android-doctor.test.ts +158 -0
  153. package/src/test/unit/chinese-path.test.ts +106 -0
  154. package/src/test/unit/command-guard.test.ts +221 -0
  155. package/src/test/unit/direct-guards.test.ts +297 -0
  156. package/src/test/unit/logging.test.ts +325 -0
  157. package/src/test/unit/path-guard.test.ts +150 -0
  158. package/src/test/unit/safe-status.test.ts +187 -0
  159. package/src/test/unit/sensitive-guard.test.ts +124 -0
  160. package/src/test/unit/sync-file.test.ts +231 -0
  161. package/src/test/unit/watcher-status.test.ts +190 -0
  162. package/src/tools/androidDoctor.ts +424 -0
  163. package/src/tools/applyPatch.ts +86 -0
  164. package/src/tools/auditSession.ts +28 -0
  165. package/src/tools/auditTask.ts +111 -7
  166. package/src/tools/createDirectSession.ts +113 -0
  167. package/src/tools/createTask.ts +405 -55
  168. package/src/tools/finalizeDirectSession.ts +144 -0
  169. package/src/tools/getTaskSummary.ts +133 -2
  170. package/src/tools/healthCheck.ts +45 -6
  171. package/src/tools/readWorkspaceFile.ts +85 -2
  172. package/src/tools/registry.ts +305 -19
  173. package/src/tools/runVerification.ts +58 -0
  174. package/src/tools/safeStatus.ts +96 -0
  175. package/src/tools/savePlan.ts +57 -7
  176. package/src/tools/searchWorkspace.ts +275 -0
  177. package/src/tools/syncFile.ts +122 -0
  178. package/src/tools/taskTemplates.ts +2 -1
  179. package/src/tools/toolCatalog.ts +37 -6
  180. package/src/tools/waitForTask.ts +3 -3
  181. package/src/version.ts +2 -2
  182. package/src/watcherStatus.ts +101 -4
  183. package/tsconfig.json +18 -17
  184. package/Check-PatchWarden-Health.cmd +0 -6
  185. package/Reset-PatchWarden-Tunnel-Key.cmd +0 -6
  186. package/Restart-PatchWarden.cmd +0 -19
  187. package/Start-PatchWarden-Tunnel.cmd +0 -7
  188. package/docs/release-v0.3.0.md +0 -43
  189. package/docs/release-v0.4.0.md +0 -74
@@ -7,7 +7,7 @@ import {
7
7
  statSync,
8
8
  writeFileSync,
9
9
  } from "node:fs";
10
- import { join, relative, resolve } from "node:path";
10
+ import { join, relative, resolve, isAbsolute } from "node:path";
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { isSensitivePath } from "../security/sensitiveGuard.js";
13
13
 
@@ -19,6 +19,8 @@ const SKIP_DIRECTORIES = new Set([".git", ".patchwarden", "node_modules"]);
19
19
  export interface FileFingerprint {
20
20
  size: number;
21
21
  sha256: string;
22
+ tracked: boolean;
23
+ ignored: boolean;
22
24
  }
23
25
 
24
26
  export interface RepoSnapshot {
@@ -28,6 +30,7 @@ export interface RepoSnapshot {
28
30
  status: string;
29
31
  workspace_dirty: boolean;
30
32
  files: Record<string, FileFingerprint>;
33
+ dirty_paths: string[]; // paths that git status --porcelain reports as modified/added/deleted/untracked/renamed
31
34
  warnings: string[];
32
35
  }
33
36
 
@@ -37,6 +40,33 @@ export interface ChangedFile {
37
40
  old_path?: string;
38
41
  before_sha256: string | null;
39
42
  after_sha256: string | null;
43
+ tracked: boolean;
44
+ ignored: boolean;
45
+ kind: "source" | "build_artifact" | "runtime_generated";
46
+ }
47
+
48
+ export interface ClassifiedChange {
49
+ path: string;
50
+ change: ChangedFile["change"];
51
+ tracked: boolean;
52
+ ignored: boolean;
53
+ kind: ChangedFile["kind"];
54
+ reason: string;
55
+ }
56
+
57
+ export interface ArtifactHygiene {
58
+ counts: {
59
+ source_changes: number;
60
+ tracked_build_artifacts: number;
61
+ ignored_untracked_artifacts: number;
62
+ runtime_generated_files: number;
63
+ suspicious_changes: number;
64
+ };
65
+ source_changes: ClassifiedChange[];
66
+ tracked_build_artifacts: ClassifiedChange[];
67
+ ignored_untracked_artifacts: ClassifiedChange[];
68
+ runtime_generated_files: ClassifiedChange[];
69
+ suspicious_changes: ClassifiedChange[];
40
70
  }
41
71
 
42
72
  export interface ChangeArtifacts {
@@ -57,6 +87,7 @@ export interface ChangeArtifacts {
57
87
  workspace_dirty_after: boolean;
58
88
  patch_mode: "textual" | "no_changes" | "hash_only";
59
89
  unavailable_reason: string | null;
90
+ artifact_hygiene: ArtifactHygiene;
60
91
  }
61
92
 
62
93
  export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
@@ -65,11 +96,45 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
65
96
  let head: string | null = null;
66
97
  let status = "";
67
98
  let paths: string[] = [];
99
+ const trackedPaths = new Set<string>();
100
+ const ignoredPaths = new Set<string>();
68
101
 
102
+ const dirtyPaths = new Set<string>();
69
103
  if (isGit) {
70
104
  const headResult = runGit(repoPath, ["rev-parse", "HEAD"]);
71
105
  if (headResult.status === 0) head = headResult.stdout.trim() || null;
72
106
  status = runGit(repoPath, ["status", "--porcelain=v1", "-uall"]).stdout.trimEnd();
107
+ // Parse git status --porcelain to collect all dirty paths
108
+ for (const line of status.split("\n")) {
109
+ if (line.length < 4) continue;
110
+ const st = line.slice(0, 2); // XY status codes
111
+ const rawPath = line.slice(3);
112
+ // M=modified, A=added, D=deleted, ?=untracked, R=renamed, !=ignored
113
+ if (/[MAD\?R]/.test(st)) {
114
+ if (st.includes("R")) {
115
+ // Rename: rawPath is "oldname -> newname"
116
+ const parts = rawPath.split(" -> ");
117
+ if (parts.length === 2) {
118
+ dirtyPaths.add(normalizePath(parts[0]));
119
+ dirtyPaths.add(normalizePath(parts[1]));
120
+ } else {
121
+ dirtyPaths.add(normalizePath(rawPath));
122
+ }
123
+ } else {
124
+ dirtyPaths.add(normalizePath(rawPath));
125
+ }
126
+ }
127
+ }
128
+ const tracked = runGit(repoPath, ["ls-files", "-z"]);
129
+ if (tracked.status === 0) {
130
+ for (const path of tracked.stdout.split("\0").filter(Boolean)) trackedPaths.add(normalizePath(path));
131
+ }
132
+ const ignored = runGit(repoPath, ["ls-files", "-o", "-i", "--exclude-standard", "-z"]);
133
+ if (ignored.status === 0) {
134
+ for (const path of ignored.stdout.split("\0").filter(Boolean)) ignoredPaths.add(normalizePath(path));
135
+ } else {
136
+ warnings.push("git ignored-file discovery failed; ignored classification may be incomplete");
137
+ }
73
138
  const listed = runGit(repoPath, ["ls-files", "-co", "--exclude-standard", "-z"]);
74
139
  if (listed.status === 0) {
75
140
  paths = [...new Set([
@@ -92,7 +157,7 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
92
157
 
93
158
  const files: Record<string, FileFingerprint> = {};
94
159
  for (const inputPath of paths.sort()) {
95
- const normalized = inputPath.replace(/\\/g, "/");
160
+ const normalized = normalizePath(inputPath);
96
161
  if (!normalized || normalized.startsWith(".patchwarden/") || isSensitivePath(normalized)) continue;
97
162
  const absolutePath = resolve(repoPath, inputPath);
98
163
  try {
@@ -101,7 +166,12 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
101
166
  const sha256 = stat.size <= MAX_HASH_BYTES
102
167
  ? createHash("sha256").update(readFileSync(absolutePath)).digest("hex")
103
168
  : `large-file:${stat.size}:${Math.trunc(stat.mtimeMs)}`;
104
- files[normalized] = { size: stat.size, sha256 };
169
+ files[normalized] = {
170
+ size: stat.size,
171
+ sha256,
172
+ tracked: trackedPaths.has(normalized),
173
+ ignored: !trackedPaths.has(normalized) && ignoredPaths.has(normalized),
174
+ };
105
175
  } catch {
106
176
  warnings.push(`could not fingerprint: ${normalized}`);
107
177
  }
@@ -114,6 +184,7 @@ export function captureRepoSnapshot(repoPath: string): RepoSnapshot {
114
184
  status,
115
185
  workspace_dirty: status.trim().length > 0,
116
186
  files,
187
+ dirty_paths: [...dirtyPaths],
117
188
  warnings,
118
189
  };
119
190
  }
@@ -128,6 +199,7 @@ export function buildChangeArtifacts(
128
199
  after: RepoSnapshot
129
200
  ): ChangeArtifacts {
130
201
  const changedFiles = compareSnapshots(before, after);
202
+ const artifactHygiene = classifyArtifactHygiene(changedFiles);
131
203
  const sections: string[] = [];
132
204
  const scopedPaths = [...new Set(changedFiles.flatMap((file) => file.old_path ? [file.old_path, file.path] : [file.path]))];
133
205
 
@@ -179,6 +251,7 @@ export function buildChangeArtifacts(
179
251
  ? "Git could not produce a textual patch for the changed files; hash evidence remains available."
180
252
  : "Repository is not a Git worktree; only bounded hash evidence is available.")
181
253
  : null,
254
+ artifact_hygiene: artifactHygiene,
182
255
  };
183
256
  }
184
257
 
@@ -232,11 +305,11 @@ export function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): Cha
232
305
  const left = before.files[path];
233
306
  const right = after.files[path];
234
307
  if (!left && right) {
235
- changed.push({ path, change: "added", before_sha256: null, after_sha256: right.sha256 });
308
+ changed.push(classifyChangedFile(path, "added", null, right));
236
309
  } else if (left && !right) {
237
- changed.push({ path, change: "deleted", before_sha256: left.sha256, after_sha256: null });
310
+ changed.push(classifyChangedFile(path, "deleted", left, null));
238
311
  } else if (left.sha256 !== right.sha256) {
239
- changed.push({ path, change: "modified", before_sha256: left.sha256, after_sha256: right.sha256 });
312
+ changed.push(classifyChangedFile(path, "modified", left, right));
240
313
  }
241
314
  }
242
315
  const deletedByHash = new Map<string, ChangedFile[]>();
@@ -260,6 +333,9 @@ export function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): Cha
260
333
  change: "renamed",
261
334
  before_sha256: source.before_sha256,
262
335
  after_sha256: file.after_sha256,
336
+ tracked: file.tracked || source.tracked,
337
+ ignored: file.ignored,
338
+ kind: classifyPathKind(file.path),
263
339
  });
264
340
  }
265
341
 
@@ -267,6 +343,276 @@ export function compareSnapshots(before: RepoSnapshot, after: RepoSnapshot): Cha
267
343
  .sort((left, right) => left.path.localeCompare(right.path));
268
344
  }
269
345
 
346
+ export function emptyArtifactHygiene(): ArtifactHygiene {
347
+ return {
348
+ counts: {
349
+ source_changes: 0,
350
+ tracked_build_artifacts: 0,
351
+ ignored_untracked_artifacts: 0,
352
+ runtime_generated_files: 0,
353
+ suspicious_changes: 0,
354
+ },
355
+ source_changes: [],
356
+ tracked_build_artifacts: [],
357
+ ignored_untracked_artifacts: [],
358
+ runtime_generated_files: [],
359
+ suspicious_changes: [],
360
+ };
361
+ }
362
+
363
+ // ── Phase 4: External dirty file baseline ─────────────────────────
364
+
365
+ export interface ExternalDirtyFile {
366
+ path: string;
367
+ change: ChangedFile["change"];
368
+ before_sha256: string | null;
369
+ after_sha256: string | null;
370
+ }
371
+
372
+ /**
373
+ * Extract files that are dirty in the workspace but outside the target repo.
374
+ * Used to establish a baseline before task execution.
375
+ */
376
+ export function extractExternalDirtyFiles(
377
+ workspaceSnapshot: RepoSnapshot,
378
+ repoPath: string,
379
+ workspaceRoot: string
380
+ ): ExternalDirtyFile[] {
381
+ const dirtyFiles: ExternalDirtyFile[] = [];
382
+ const dirtyPathSet = new Set(workspaceSnapshot.dirty_paths);
383
+ for (const [path, fingerprint] of Object.entries(workspaceSnapshot.files)) {
384
+ const absolutePath = resolve(workspaceRoot, path);
385
+ const rel = relative(repoPath, absolutePath);
386
+ // If the path is outside repoPath (starts with .. or is absolute)
387
+ if (rel.startsWith("..") || isAbsolute(rel)) {
388
+ // A file is "external dirty" if:
389
+ // 1. Git reports it as dirty (modified/added/deleted/untracked) via dirty_paths, OR
390
+ // 2. It's not tracked by git (untracked file), OR
391
+ // 3. It's explicitly ignored
392
+ const isDirty = dirtyPathSet.has(path);
393
+ const isUntracked = !fingerprint.tracked;
394
+ const isIgnored = fingerprint.ignored;
395
+ if (isDirty || isUntracked || isIgnored) {
396
+ dirtyFiles.push({
397
+ path,
398
+ change: isDirty ? "modified" : "added",
399
+ before_sha256: fingerprint.sha256,
400
+ after_sha256: null,
401
+ });
402
+ }
403
+ }
404
+ }
405
+ return dirtyFiles;
406
+ }
407
+
408
+ /**
409
+ * Compare external dirty files between baseline and post-task snapshots.
410
+ * Returns files that are NEW (not present in baseline) or CHANGED
411
+ * (same path but different sha256, meaning the task modified them).
412
+ */
413
+ export function findNewExternalDirtyFiles(
414
+ baseline: ExternalDirtyFile[],
415
+ current: ExternalDirtyFile[]
416
+ ): ExternalDirtyFile[] {
417
+ const baselineMap = new Map(baseline.map((f) => [f.path, f]));
418
+ return current.filter((f) => {
419
+ const baselineFile = baselineMap.get(f.path);
420
+ if (!baselineFile) return true; // New path — definitely new
421
+ // Same path but content changed during task execution
422
+ if (baselineFile.before_sha256 !== f.before_sha256) return true;
423
+ return false;
424
+ });
425
+ }
426
+
427
+ // ── Phase 6: Artifact manifest ────────────────────────────────────
428
+
429
+ export interface ArtifactManifestEntry {
430
+ path: string;
431
+ type: string;
432
+ size: number;
433
+ sha256: string;
434
+ generated_by: string;
435
+ created_at: string;
436
+ }
437
+
438
+ export interface ArtifactManifest {
439
+ task_id: string | null;
440
+ generated_at: string;
441
+ artifacts: ArtifactManifestEntry[];
442
+ }
443
+
444
+ export function buildArtifactManifest(
445
+ changedFiles: ChangedFile[],
446
+ repoPath: string,
447
+ taskId?: string
448
+ ): ArtifactManifest {
449
+ const entries: ArtifactManifestEntry[] = [];
450
+ for (const file of changedFiles) {
451
+ if (file.kind !== "build_artifact") continue;
452
+ const absolutePath = resolve(repoPath, file.path);
453
+ let size = 0;
454
+ let sha256 = file.after_sha256 || "unknown";
455
+ try {
456
+ const stat = lstatSync(absolutePath);
457
+ if (stat.isFile()) {
458
+ size = stat.size;
459
+ if (size <= MAX_HASH_BYTES) {
460
+ sha256 = createHash("sha256").update(readFileSync(absolutePath)).digest("hex");
461
+ }
462
+ }
463
+ } catch {
464
+ // File may have been deleted
465
+ }
466
+ entries.push({
467
+ path: file.path,
468
+ type: classifyArtifactType(file.path),
469
+ size,
470
+ sha256,
471
+ generated_by: "task_execution",
472
+ created_at: new Date().toISOString(),
473
+ });
474
+ }
475
+ return {
476
+ task_id: taskId || null,
477
+ generated_at: new Date().toISOString(),
478
+ artifacts: entries,
479
+ };
480
+ }
481
+
482
+ function classifyArtifactType(path: string): string {
483
+ const normalized = normalizePath(path).toLowerCase();
484
+ const basename = normalized.split("/").pop() || "";
485
+ if (basename.endsWith(".exe")) return "windows_exe";
486
+ if (basename.endsWith(".apk")) return "android_apk";
487
+ if (basename.endsWith(".zip")) return "zip";
488
+ if (basename.endsWith(".asar")) return "asar";
489
+ if (basename.endsWith(".dll")) return "dll";
490
+ if (basename.endsWith(".pak")) return "pak";
491
+ return "release_directory_file";
492
+ }
493
+
494
+ // ── Phase 6: Changed file grouping ────────────────────────────────
495
+
496
+ export interface ChangedFileGroups {
497
+ source_changes: ChangedFile[];
498
+ docs_changes: ChangedFile[];
499
+ config_changes: ChangedFile[];
500
+ test_changes: ChangedFile[];
501
+ release_artifacts: ChangedFile[];
502
+ runtime_generated_files: ChangedFile[];
503
+ }
504
+
505
+ export function groupChangedFiles(changedFiles: ChangedFile[]): ChangedFileGroups {
506
+ const groups: ChangedFileGroups = {
507
+ source_changes: [],
508
+ docs_changes: [],
509
+ config_changes: [],
510
+ test_changes: [],
511
+ release_artifacts: [],
512
+ runtime_generated_files: [],
513
+ };
514
+ for (const file of changedFiles) {
515
+ const normalized = normalizePath(file.path).toLowerCase();
516
+ const parts = normalized.split("/");
517
+ const basename = parts[parts.length - 1] || "";
518
+ // Check for docs
519
+ if (parts.some((p) => p === "docs") || /\.(md|rst|txt)$/.test(basename)) {
520
+ groups.docs_changes.push(file);
521
+ continue;
522
+ }
523
+ // Check for config
524
+ if (basename === "package.json" || basename === "tsconfig.json" || basename === ".gitignore" ||
525
+ basename.startsWith(".config") || basename.endsWith(".config.js") || basename.endsWith(".config.ts")) {
526
+ groups.config_changes.push(file);
527
+ continue;
528
+ }
529
+ // Check for test files
530
+ if (basename.includes(".test.") || basename.includes(".spec.") || parts.some((p) => p === "test" || p === "tests" || p === "__tests__")) {
531
+ groups.test_changes.push(file);
532
+ continue;
533
+ }
534
+ // Check for build artifacts / release
535
+ if (file.kind === "build_artifact") {
536
+ groups.release_artifacts.push(file);
537
+ continue;
538
+ }
539
+ // Check for runtime generated
540
+ if (file.kind === "runtime_generated") {
541
+ groups.runtime_generated_files.push(file);
542
+ continue;
543
+ }
544
+ // Default: source changes
545
+ groups.source_changes.push(file);
546
+ }
547
+ return groups;
548
+ }
549
+
550
+ function classifyChangedFile(
551
+ path: string,
552
+ change: ChangedFile["change"],
553
+ before: FileFingerprint | null,
554
+ after: FileFingerprint | null
555
+ ): ChangedFile {
556
+ return {
557
+ path,
558
+ change,
559
+ before_sha256: before?.sha256 || null,
560
+ after_sha256: after?.sha256 || null,
561
+ tracked: Boolean(after?.tracked || before?.tracked),
562
+ ignored: Boolean(after?.ignored ?? before?.ignored),
563
+ kind: classifyPathKind(path),
564
+ };
565
+ }
566
+
567
+ function classifyArtifactHygiene(changes: ChangedFile[]): ArtifactHygiene {
568
+ const hygiene = emptyArtifactHygiene();
569
+ const entries = changes.map((change): ClassifiedChange => ({
570
+ path: change.path,
571
+ change: change.change,
572
+ tracked: change.tracked,
573
+ ignored: change.ignored,
574
+ kind: change.kind,
575
+ reason: classificationReason(change),
576
+ }));
577
+ hygiene.source_changes = entries.filter((entry) => entry.kind === "source" && !entry.ignored);
578
+ hygiene.tracked_build_artifacts = entries.filter((entry) => entry.kind === "build_artifact" && entry.tracked);
579
+ hygiene.ignored_untracked_artifacts = entries.filter((entry) => entry.ignored && !entry.tracked);
580
+ hygiene.runtime_generated_files = entries.filter((entry) => entry.kind === "runtime_generated");
581
+ hygiene.suspicious_changes = entries.filter((entry) =>
582
+ (entry.kind === "build_artifact" || entry.kind === "runtime_generated") && !entry.ignored
583
+ );
584
+ hygiene.counts = {
585
+ source_changes: hygiene.source_changes.length,
586
+ tracked_build_artifacts: hygiene.tracked_build_artifacts.length,
587
+ ignored_untracked_artifacts: hygiene.ignored_untracked_artifacts.length,
588
+ runtime_generated_files: hygiene.runtime_generated_files.length,
589
+ suspicious_changes: hygiene.suspicious_changes.length,
590
+ };
591
+ return hygiene;
592
+ }
593
+
594
+ function classifyPathKind(path: string): ChangedFile["kind"] {
595
+ const normalized = normalizePath(path).toLowerCase();
596
+ const parts = normalized.split("/");
597
+ const basename = parts[parts.length - 1] || "";
598
+ if (basename === "sync-store.json" || /\.(log|tmp|temp|pid)$/.test(basename)) return "runtime_generated";
599
+ if (parts.some((part) => ["dist", "release", "build", "out", "coverage", ".next"].includes(part))) return "build_artifact";
600
+ if (/\.(exe|dll|pak|bin|zip|tgz|tar\.gz)$/.test(basename)) return "build_artifact";
601
+ return "source";
602
+ }
603
+
604
+ function classificationReason(change: ChangedFile): string {
605
+ if (change.ignored) return "untracked path is ignored by repository Git rules";
606
+ if (change.kind === "build_artifact" && change.tracked) return "artifact-like path is tracked by Git and requires review";
607
+ if (change.kind === "build_artifact") return "artifact-like path is not ignored and requires review";
608
+ if (change.kind === "runtime_generated") return "runtime-generated path is not ignored and requires review";
609
+ return change.tracked ? "tracked source change" : "untracked source change";
610
+ }
611
+
612
+ function normalizePath(value: string): string {
613
+ return value.replace(/\\/g, "/");
614
+ }
615
+
270
616
  function walkWorkspace(root: string): string[] {
271
617
  const result: string[] = [];
272
618
  const visit = (directory: string) => {