patchwarden 0.6.4 → 1.5.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 (254) hide show
  1. package/README.en.md +54 -20
  2. package/README.md +48 -21
  3. package/dist/agents/agentRouter.d.ts +40 -0
  4. package/dist/agents/agentRouter.js +95 -0
  5. package/dist/config.d.ts +1 -1
  6. package/dist/config.js +6 -2
  7. package/dist/controlCenter.js +212 -3
  8. package/dist/direct/directSessionStore.d.ts +2 -0
  9. package/dist/direct/directVerification.js +7 -0
  10. package/dist/doctor.js +32 -1
  11. package/dist/goal/acceptanceEngine.d.ts +62 -0
  12. package/dist/goal/acceptanceEngine.js +103 -0
  13. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  14. package/dist/goal/acceptanceTemplate.js +104 -0
  15. package/dist/goal/goalGraph.d.ts +58 -0
  16. package/dist/goal/goalGraph.js +189 -0
  17. package/dist/goal/goalProgress.d.ts +81 -0
  18. package/dist/goal/goalProgress.js +216 -0
  19. package/dist/goal/goalStatus.d.ts +60 -0
  20. package/dist/goal/goalStatus.js +137 -0
  21. package/dist/goal/goalStore.d.ts +79 -0
  22. package/dist/goal/goalStore.js +211 -0
  23. package/dist/goal/handoffExport.d.ts +34 -0
  24. package/dist/goal/handoffExport.js +183 -0
  25. package/dist/goal/subgoalSync.d.ts +40 -0
  26. package/dist/goal/subgoalSync.js +72 -0
  27. package/dist/goal/worktreeManager.d.ts +96 -0
  28. package/dist/goal/worktreeManager.js +292 -0
  29. package/dist/logging.d.ts +44 -0
  30. package/dist/logging.js +68 -0
  31. package/dist/policy/projectPolicy.d.ts +55 -0
  32. package/dist/policy/projectPolicy.js +286 -0
  33. package/dist/release/releaseGate.d.ts +99 -0
  34. package/dist/release/releaseGate.js +475 -0
  35. package/dist/runner/postTaskCleanup.d.ts +13 -0
  36. package/dist/runner/postTaskCleanup.js +147 -0
  37. package/dist/runner/runTask.js +50 -10
  38. package/dist/security/discoveryTokenStore.d.ts +62 -0
  39. package/dist/security/discoveryTokenStore.js +100 -0
  40. package/dist/security/toolInvocationGuard.d.ts +35 -0
  41. package/dist/security/toolInvocationGuard.js +153 -0
  42. package/dist/smoke-test.js +18 -10
  43. package/dist/taskRuntime.d.ts +17 -0
  44. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  45. package/dist/test/unit/acceptance-engine.test.js +228 -0
  46. package/dist/test/unit/agent-router.test.d.ts +1 -0
  47. package/dist/test/unit/agent-router.test.js +287 -0
  48. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  49. package/dist/test/unit/audit-checks.test.js +350 -0
  50. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  51. package/dist/test/unit/diagnose-task.test.js +457 -0
  52. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  53. package/dist/test/unit/discovery-token-store.test.js +139 -0
  54. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  55. package/dist/test/unit/evidence-pack.test.js +130 -0
  56. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  57. package/dist/test/unit/goal-graph.test.js +298 -0
  58. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  59. package/dist/test/unit/goal-progress.test.js +381 -0
  60. package/dist/test/unit/goal-status.test.d.ts +1 -0
  61. package/dist/test/unit/goal-status.test.js +215 -0
  62. package/dist/test/unit/goal-store.test.d.ts +1 -0
  63. package/dist/test/unit/goal-store.test.js +253 -0
  64. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  65. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  66. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  67. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  68. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  69. package/dist/test/unit/handoff-export.test.js +263 -0
  70. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  71. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  72. package/dist/test/unit/logging.test.js +127 -5
  73. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  74. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  75. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  76. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  77. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  78. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  79. package/dist/test/unit/release-gate.test.d.ts +1 -0
  80. package/dist/test/unit/release-gate.test.js +242 -0
  81. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  82. package/dist/test/unit/run-task-loop.test.js +380 -0
  83. package/dist/test/unit/safe-views.test.d.ts +1 -0
  84. package/dist/test/unit/safe-views.test.js +171 -0
  85. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  86. package/dist/test/unit/schema-drift-check.test.js +176 -0
  87. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  88. package/dist/test/unit/subgoal-sync.test.js +183 -0
  89. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  90. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  91. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  92. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  93. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  94. package/dist/test/unit/toolSearch.test.js +571 -0
  95. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  96. package/dist/test/unit/worktree-manager.test.js +176 -0
  97. package/dist/tools/auditTask.d.ts +103 -1
  98. package/dist/tools/auditTask.js +461 -8
  99. package/dist/tools/cancelTask.js +1 -1
  100. package/dist/tools/checkReleaseGate.d.ts +21 -0
  101. package/dist/tools/checkReleaseGate.js +22 -0
  102. package/dist/tools/createTask.d.ts +18 -2
  103. package/dist/tools/createTask.js +29 -1
  104. package/dist/tools/diagnoseTask.d.ts +45 -0
  105. package/dist/tools/diagnoseTask.js +347 -0
  106. package/dist/tools/discardWorktree.d.ts +24 -0
  107. package/dist/tools/discardWorktree.js +27 -0
  108. package/dist/tools/discoverTools.d.ts +11 -0
  109. package/dist/tools/discoverTools.js +39 -0
  110. package/dist/tools/evidencePack.d.ts +39 -0
  111. package/dist/tools/evidencePack.js +168 -0
  112. package/dist/tools/explainTool.d.ts +11 -0
  113. package/dist/tools/explainTool.js +21 -0
  114. package/dist/tools/getTaskSummary.js +2 -1
  115. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  116. package/dist/tools/goalSubgoalTask.js +110 -0
  117. package/dist/tools/healthCheck.d.ts +1 -0
  118. package/dist/tools/healthCheck.js +5 -1
  119. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  120. package/dist/tools/invokeDiscoveredTool.js +112 -0
  121. package/dist/tools/listTasks.d.ts +3 -1
  122. package/dist/tools/listTasks.js +14 -1
  123. package/dist/tools/mergeWorktree.d.ts +24 -0
  124. package/dist/tools/mergeWorktree.js +27 -0
  125. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  126. package/dist/tools/recommendAgentForTask.js +56 -0
  127. package/dist/tools/reconcileTasks.d.ts +41 -0
  128. package/dist/tools/reconcileTasks.js +352 -0
  129. package/dist/tools/registry.js +926 -2
  130. package/dist/tools/releaseMode.d.ts +50 -0
  131. package/dist/tools/releaseMode.js +370 -0
  132. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  133. package/dist/tools/runDirectVerificationBundle.js +64 -0
  134. package/dist/tools/runTaskLoop.d.ts +57 -0
  135. package/dist/tools/runTaskLoop.js +417 -0
  136. package/dist/tools/runVerification.d.ts +4 -0
  137. package/dist/tools/runVerification.js +4 -0
  138. package/dist/tools/safeStatus.d.ts +31 -1
  139. package/dist/tools/safeStatus.js +43 -1
  140. package/dist/tools/safeViews.d.ts +262 -0
  141. package/dist/tools/safeViews.js +252 -0
  142. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  143. package/dist/tools/schemaDriftCheck.js +80 -0
  144. package/dist/tools/taskLineage.d.ts +91 -0
  145. package/dist/tools/taskLineage.js +175 -0
  146. package/dist/tools/toolCatalog.d.ts +4 -3
  147. package/dist/tools/toolCatalog.js +39 -11
  148. package/dist/tools/toolRegistry.d.ts +61 -0
  149. package/dist/tools/toolRegistry.js +834 -0
  150. package/dist/tools/toolSearch.d.ts +110 -0
  151. package/dist/tools/toolSearch.js +331 -0
  152. package/dist/tools/toolUsageStats.d.ts +41 -0
  153. package/dist/tools/toolUsageStats.js +116 -0
  154. package/dist/tools/waitForTask.js +1 -0
  155. package/dist/version.d.ts +2 -2
  156. package/dist/version.js +2 -2
  157. package/dist/watcherStatus.d.ts +29 -0
  158. package/dist/watcherStatus.js +92 -1
  159. package/docs/chatgpt-usage.md +31 -0
  160. package/docs/control-center/README.md +9 -0
  161. package/package.json +3 -2
  162. package/scripts/checks/control-center-smoke.js +87 -0
  163. package/scripts/checks/control-smoke.js +2 -2
  164. package/scripts/checks/doctor-smoke.js +1 -0
  165. package/scripts/checks/lifecycle-smoke.js +13 -9
  166. package/scripts/checks/mcp-manifest-check.js +65 -6
  167. package/scripts/checks/mcp-smoke.js +60 -9
  168. package/scripts/checks/watcher-supervisor-smoke.js +2 -2
  169. package/src/agents/agentRouter.ts +149 -0
  170. package/src/config.ts +9 -3
  171. package/src/controlCenter.ts +215 -4
  172. package/src/direct/directSessionStore.ts +2 -0
  173. package/src/direct/directVerification.ts +7 -0
  174. package/src/doctor.ts +36 -1
  175. package/src/goal/acceptanceEngine.ts +160 -0
  176. package/src/goal/acceptanceTemplate.ts +121 -0
  177. package/src/goal/goalGraph.ts +225 -0
  178. package/src/goal/goalProgress.ts +301 -0
  179. package/src/goal/goalStatus.ts +234 -0
  180. package/src/goal/goalStore.ts +306 -0
  181. package/src/goal/handoffExport.ts +211 -0
  182. package/src/goal/subgoalSync.ts +82 -0
  183. package/src/goal/worktreeManager.ts +404 -0
  184. package/src/logging.ts +91 -0
  185. package/src/policy/projectPolicy.ts +344 -0
  186. package/src/release/releaseGate.ts +567 -0
  187. package/src/runner/postTaskCleanup.ts +154 -0
  188. package/src/runner/runTask.ts +49 -10
  189. package/src/security/discoveryTokenStore.ts +157 -0
  190. package/src/security/toolInvocationGuard.ts +251 -0
  191. package/src/smoke-test.ts +15 -7
  192. package/src/taskRuntime.ts +17 -0
  193. package/src/test/unit/acceptance-engine.test.ts +261 -0
  194. package/src/test/unit/agent-router.test.ts +342 -0
  195. package/src/test/unit/audit-checks.test.ts +567 -0
  196. package/src/test/unit/diagnose-task.test.ts +544 -0
  197. package/src/test/unit/discovery-token-store.test.ts +181 -0
  198. package/src/test/unit/evidence-pack.test.ts +142 -0
  199. package/src/test/unit/goal-graph.test.ts +347 -0
  200. package/src/test/unit/goal-progress.test.ts +538 -0
  201. package/src/test/unit/goal-status.test.ts +270 -0
  202. package/src/test/unit/goal-store.test.ts +318 -0
  203. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  204. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  205. package/src/test/unit/handoff-export.test.ts +295 -0
  206. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  207. package/src/test/unit/logging.test.ts +177 -5
  208. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  209. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  210. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  211. package/src/test/unit/release-gate.test.ts +314 -0
  212. package/src/test/unit/run-task-loop.test.ts +425 -0
  213. package/src/test/unit/safe-views.test.ts +184 -0
  214. package/src/test/unit/schema-drift-check.test.ts +260 -0
  215. package/src/test/unit/subgoal-sync.test.ts +236 -0
  216. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  217. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  218. package/src/test/unit/toolSearch.test.ts +756 -0
  219. package/src/test/unit/worktree-manager.test.ts +247 -0
  220. package/src/tools/auditTask.ts +831 -402
  221. package/src/tools/cancelTask.ts +1 -1
  222. package/src/tools/checkReleaseGate.ts +45 -0
  223. package/src/tools/createTask.ts +64 -6
  224. package/src/tools/diagnoseTask.ts +460 -0
  225. package/src/tools/discardWorktree.ts +42 -0
  226. package/src/tools/discoverTools.ts +51 -0
  227. package/src/tools/evidencePack.ts +205 -0
  228. package/src/tools/explainTool.ts +26 -0
  229. package/src/tools/getTaskSummary.ts +2 -1
  230. package/src/tools/goalSubgoalTask.ts +170 -0
  231. package/src/tools/healthCheck.ts +4 -1
  232. package/src/tools/invokeDiscoveredTool.ts +163 -0
  233. package/src/tools/listTasks.ts +16 -2
  234. package/src/tools/listWorkspace.ts +71 -71
  235. package/src/tools/mergeWorktree.ts +42 -0
  236. package/src/tools/recommendAgentForTask.ts +79 -0
  237. package/src/tools/reconcileTasks.ts +464 -0
  238. package/src/tools/registry.ts +1093 -73
  239. package/src/tools/releaseMode.ts +450 -0
  240. package/src/tools/runDirectVerificationBundle.ts +98 -0
  241. package/src/tools/runTaskLoop.ts +526 -0
  242. package/src/tools/runVerification.ts +8 -0
  243. package/src/tools/safeStatus.ts +73 -2
  244. package/src/tools/safeViews.ts +273 -0
  245. package/src/tools/schemaDriftCheck.ts +120 -0
  246. package/src/tools/taskLineage.ts +300 -0
  247. package/src/tools/toolCatalog.ts +40 -11
  248. package/src/tools/toolRegistry.ts +896 -0
  249. package/src/tools/toolSearch.ts +464 -0
  250. package/src/tools/toolUsageStats.ts +151 -0
  251. package/src/tools/waitForTask.ts +1 -0
  252. package/src/version.ts +2 -2
  253. package/src/watcherStatus.ts +97 -1
  254. package/ui/pages/dashboard.html +143 -2
@@ -0,0 +1,273 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { getConfig, getTasksDir } from "../config.js";
4
+ import { auditSession } from "./auditSession.js";
5
+ import { auditTask, type AuditTaskOutput } from "./auditTask.js";
6
+ import { finalizeDirectSession } from "./finalizeDirectSession.js";
7
+ import { getTaskSummary } from "./getTaskSummary.js";
8
+ import { guardReadPath } from "../security/pathGuard.js";
9
+ import { redactSensitiveValue } from "../security/contentRedaction.js";
10
+ import { readDirectSession, type DirectSessionRecord, type DirectSessionVerificationRun } from "../direct/directSessionStore.js";
11
+ import type { ChangedFile, ClassifiedChange } from "../runner/changeCapture.js";
12
+
13
+ export interface SafeViewOptions {
14
+ max_items?: number;
15
+ }
16
+
17
+ export function safeResult(taskId: string, options: SafeViewOptions = {}) {
18
+ const maxItems = normalizeMaxItems(options.max_items);
19
+ const summary = getTaskSummary(taskId, { view: "compact", max_items: maxItems });
20
+ return redact({
21
+ task_id: summary.task_id,
22
+ status: summary.status,
23
+ terminal: summary.terminal,
24
+ acceptance_status: summary.acceptance_status,
25
+ phase: summary.phase,
26
+ repo_path: summary.repo_path,
27
+ changed_files_total: summary.changed_files_total,
28
+ out_of_scope_changes_total: summary.out_of_scope_changes_total,
29
+ artifact_hygiene_counts: summary.artifact_hygiene.counts,
30
+ release_artifacts_count: summary.release_artifacts_count,
31
+ artifact_status: summary.artifact_status,
32
+ verification: summary.verification_summary,
33
+ warnings: limitStrings(summary.warnings, maxItems),
34
+ errors: limitStrings(summary.errors, maxItems),
35
+ failure_reason: summary.failure_reason,
36
+ failed_command: summary.failed_command,
37
+ next_action: summary.suggested_next_action,
38
+ execution_blocked: summary.execution_blocked,
39
+ pending_reason: summary.pending_reason,
40
+ redacted: summary.redacted,
41
+ redaction_categories: summary.redaction_categories,
42
+ });
43
+ }
44
+
45
+ export function safeTestSummary(taskId: string) {
46
+ const { taskDir, config } = getTaskDir(taskId);
47
+ const verify = readJson(join(taskDir, "verify.json"), config);
48
+ const summary = getTaskSummary(taskId, { view: "compact", max_items: 8 });
49
+ const commands = asArray(verify.commands).map((entry: any) => ({
50
+ command: String(entry.command || ""),
51
+ status: String(entry.status || "unknown"),
52
+ exit_code: entry.exit_code ?? null,
53
+ duration_ms: Number(entry.duration_ms || 0),
54
+ }));
55
+ return redact({
56
+ task_id: taskId,
57
+ status: String(verify.status || summary.verification_summary.status || "not_available"),
58
+ command_count: commands.length,
59
+ passed_commands: commands.filter((entry) => entry.status === "passed").length,
60
+ failed_commands: commands.filter((entry) => ["failed", "timed_out", "canceled"].includes(entry.status)).length,
61
+ skipped_commands: commands.filter((entry) => entry.status === "skipped").length,
62
+ commands,
63
+ headline: summary.verification_summary.headline,
64
+ });
65
+ }
66
+
67
+ export function safeDiffSummary(taskId: string, options: SafeViewOptions = {}) {
68
+ const maxItems = normalizeMaxItems(options.max_items);
69
+ const { taskDir, config } = getTaskDir(taskId);
70
+ const changes = readJson(join(taskDir, "changed-files.json"), config);
71
+ const changedFiles = asArray(changes.changed_files) as ChangedFile[];
72
+ const hygiene = asRecord(changes.artifact_hygiene);
73
+ return redact({
74
+ task_id: taskId,
75
+ changed_files_total: changedFiles.length,
76
+ additions: Number(changes.additions || 0),
77
+ deletions: Number(changes.deletions || 0),
78
+ diff_available: Boolean(changes.diff_available),
79
+ diff_truncated: Boolean(changes.diff_truncated),
80
+ patch_mode: changes.patch_mode || null,
81
+ files: changedFiles.slice(0, maxItems).map((file) => ({
82
+ path: file.path,
83
+ old_path: file.old_path || null,
84
+ change: file.change,
85
+ kind: file.kind,
86
+ tracked: Boolean(file.tracked),
87
+ ignored: Boolean(file.ignored),
88
+ })),
89
+ artifact_hygiene_counts: asRecord(hygiene.counts),
90
+ large_diff_omitted: true,
91
+ truncated: changedFiles.length > maxItems,
92
+ });
93
+ }
94
+
95
+ export function safeAudit(taskId: string, options: SafeViewOptions = {}) {
96
+ const maxItems = normalizeMaxItems(options.max_items);
97
+ return auditToSafe(auditTask(taskId) as AuditTaskOutput, maxItems);
98
+ }
99
+
100
+ export function safeDirectSummary(sessionId: string, options: SafeViewOptions = {}) {
101
+ const maxItems = normalizeMaxItems(options.max_items);
102
+ const session = readDirectSession(sessionId);
103
+ return directSessionToSafe(session, maxItems, "summary");
104
+ }
105
+
106
+ export function safeFinalizeDirectSession(sessionId: string, options: SafeViewOptions = {}) {
107
+ const maxItems = normalizeMaxItems(options.max_items);
108
+ const finalized = finalizeDirectSession({ session_id: sessionId });
109
+ const session = readDirectSession(sessionId);
110
+ return redact({
111
+ ...directSessionToSafe(session, maxItems, "finalize"),
112
+ finalized: finalized.finalized,
113
+ next_action: finalized.next_action,
114
+ });
115
+ }
116
+
117
+ export function safeAuditDirectSession(sessionId: string, options: SafeViewOptions = {}) {
118
+ const maxItems = normalizeMaxItems(options.max_items);
119
+ const audit = auditSession({ session_id: sessionId });
120
+ return redact({
121
+ session_id: audit.session_id,
122
+ decision: audit.decision,
123
+ reason_codes: audit.reason_codes.slice(0, maxItems),
124
+ blocking_findings: limitStrings(audit.blocking_findings, maxItems),
125
+ warnings: limitStrings(audit.warnings, maxItems),
126
+ evidence: {
127
+ changed_files_total: audit.evidence.changed_files_total,
128
+ verification_runs: summarizeVerificationRuns(audit.evidence.verification_runs),
129
+ diff_available: Boolean(audit.evidence.diff_path),
130
+ summary_available: Boolean(audit.evidence.summary_path),
131
+ audit_available: Boolean(audit.evidence.audit_path),
132
+ },
133
+ next_action: audit.next_action,
134
+ });
135
+ }
136
+
137
+ function auditToSafe(audit: AuditTaskOutput, maxItems: number) {
138
+ const checks = audit.checks.map((check) => ({
139
+ name: check.name,
140
+ result: check.result,
141
+ detail: truncate(check.detail, 240),
142
+ }));
143
+ return redact({
144
+ task_id: audit.task_id,
145
+ verdict: audit.verdict,
146
+ acceptance: {
147
+ verdict: audit.acceptance.verdict,
148
+ status: audit.acceptance.status,
149
+ reason: truncate(audit.acceptance.reason, 240),
150
+ fail_checks: audit.acceptance.fail_checks.slice(0, maxItems).map((check) => check.name),
151
+ warn_checks: audit.acceptance.warn_checks.slice(0, maxItems).map((check) => check.name),
152
+ next_suggested_task: truncate(audit.acceptance.next_suggested_task, 240),
153
+ },
154
+ check_counts: {
155
+ pass: checks.filter((check) => check.result === "pass").length,
156
+ warn: checks.filter((check) => check.result === "warn").length,
157
+ fail: checks.filter((check) => check.result === "fail").length,
158
+ },
159
+ checks: checks.slice(0, maxItems),
160
+ fail_checks: checks.filter((check) => check.result === "fail").slice(0, maxItems),
161
+ warn_checks: checks.filter((check) => check.result === "warn").slice(0, maxItems),
162
+ possible_false_positives: audit.possible_false_positives.slice(0, maxItems).map((item) => ({
163
+ check: item.check,
164
+ reason: truncate(item.reason, 240),
165
+ })),
166
+ manual_verification_required: audit.manual_verification_required,
167
+ manual_verification_items: limitStrings(audit.manual_verification_items, maxItems),
168
+ recommended_next_actions: limitStrings(audit.recommended_next_actions, maxItems),
169
+ });
170
+ }
171
+
172
+ function directSessionToSafe(session: DirectSessionRecord, maxItems: number, view: "summary" | "finalize") {
173
+ const artifacts = session.change_artifacts;
174
+ const changedFiles = artifacts?.changed_files || [];
175
+ return redact({
176
+ view,
177
+ session_id: session.session_id,
178
+ title: session.title || "",
179
+ repo_path: session.repo_path,
180
+ created_at: session.created_at,
181
+ expires_at: session.expires_at,
182
+ finalized: session.finalized,
183
+ finalized_at: session.finalized_at,
184
+ audited: session.audited,
185
+ changed_files_total: changedFiles.length,
186
+ files: changedFiles.slice(0, maxItems).map((file) => ({
187
+ path: file.path,
188
+ old_path: file.old_path || null,
189
+ change: file.change,
190
+ kind: file.kind,
191
+ tracked: Boolean(file.tracked),
192
+ ignored: Boolean(file.ignored),
193
+ })),
194
+ artifact_hygiene_counts: artifacts?.artifact_hygiene.counts || {},
195
+ source_changes: limitClassified(artifacts?.artifact_hygiene.source_changes, maxItems),
196
+ tracked_build_artifacts: limitClassified(artifacts?.artifact_hygiene.tracked_build_artifacts, maxItems),
197
+ runtime_generated_files: limitClassified(artifacts?.artifact_hygiene.runtime_generated_files, maxItems),
198
+ suspicious_changes: limitClassified(artifacts?.artifact_hygiene.suspicious_changes, maxItems),
199
+ verification: summarizeVerificationRuns(session.verification_runs),
200
+ large_diff_omitted: true,
201
+ truncated: changedFiles.length > maxItems,
202
+ });
203
+ }
204
+
205
+ function summarizeVerificationRuns(runs: DirectSessionVerificationRun[]) {
206
+ return runs.map((run) => ({
207
+ command: run.command,
208
+ exit_code: run.exit_code,
209
+ passed: run.passed,
210
+ timed_out: run.timed_out,
211
+ redacted: Boolean(run.redacted),
212
+ redaction_categories: run.redaction_categories || [],
213
+ started_at: run.started_at,
214
+ finished_at: run.finished_at,
215
+ }));
216
+ }
217
+
218
+ function limitClassified(value: ClassifiedChange[] | undefined, maxItems: number) {
219
+ return (value || []).slice(0, maxItems).map((entry) => ({
220
+ path: entry.path,
221
+ change: entry.change,
222
+ kind: entry.kind,
223
+ tracked: entry.tracked,
224
+ ignored: entry.ignored,
225
+ reason: truncate(entry.reason, 160),
226
+ }));
227
+ }
228
+
229
+ function getTaskDir(taskId: string) {
230
+ const config = getConfig();
231
+ const taskDir = resolve(getTasksDir(config), taskId);
232
+ guardReadPath(join(taskDir, "status.json"), config.workspaceRoot, config.tasksDir);
233
+ return { config, taskDir };
234
+ }
235
+
236
+ function readJson(path: string, config: ReturnType<typeof getConfig>): Record<string, any> {
237
+ guardReadPath(path, config.workspaceRoot, config.tasksDir);
238
+ if (!existsSync(path)) return {};
239
+ try {
240
+ const raw = readFileSync(path, "utf-8").replace(/^\uFEFF/, "");
241
+ return asRecord(JSON.parse(raw));
242
+ } catch {
243
+ return {};
244
+ }
245
+ }
246
+
247
+ function normalizeMaxItems(value: number | undefined): number {
248
+ if (value === undefined) return 8;
249
+ if (!Number.isInteger(value) || value < 1 || value > 50) {
250
+ throw new Error("max_items must be an integer from 1 to 50.");
251
+ }
252
+ return value;
253
+ }
254
+
255
+ function asArray(value: unknown): any[] {
256
+ return Array.isArray(value) ? value : [];
257
+ }
258
+
259
+ function asRecord(value: unknown): Record<string, any> {
260
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
261
+ }
262
+
263
+ function limitStrings(values: string[], maxItems: number): string[] {
264
+ return values.slice(0, maxItems).map((value) => truncate(value, 240));
265
+ }
266
+
267
+ function truncate(value: string, maxChars: number): string {
268
+ return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`;
269
+ }
270
+
271
+ function redact<T>(value: T): T {
272
+ return redactSensitiveValue(value).value as T;
273
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * v0.9.0: Schema 漂移检测模块。
3
+ *
4
+ * 检测 registry 元数据与实际工具 schema 是否漂移,
5
+ * 并聚合为可被 doctor 自检消费的 SchemaDriftResult。
6
+ *
7
+ * 本模块只做检测和报告,不做运行时阻断。
8
+ */
9
+
10
+ import { computeSchemaDigest } from "./toolRegistry.js";
11
+ import type { PatchWardenToolMeta } from "./toolRegistry.js";
12
+ import { CHATGPT_CORE_TOOL_NAMES } from "./toolCatalog.js";
13
+
14
+ // ── 结果类型 ──────────────────────────────────────────────────────
15
+
16
+ export interface SchemaDriftResult {
17
+ /** true 表示无 drift,false 表示有 drift */
18
+ ok: boolean;
19
+ /** drift 描述列表(每个 drift 一条警告消息) */
20
+ warnings: string[];
21
+ }
22
+
23
+ /** toolDefs Map 的值类型:至少包含 inputSchema。 */
24
+ export interface ToolDefLike {
25
+ inputSchema: unknown;
26
+ }
27
+
28
+ /** chatgpt_core profile 的预期工具数(来自 CHATGPT_CORE_TOOL_NAMES)。 */
29
+ const EXPECTED_CORE_COUNT = CHATGPT_CORE_TOOL_NAMES.length;
30
+
31
+ // ── 检查 1: registry schema digest 一致性 ─────────────────────────
32
+
33
+ /**
34
+ * 检查 registry 中记录的 inputSchemaDigest 与实际 toolDef 的 inputSchema digest 是否一致。
35
+ * toolDefs 中找不到的工具会被跳过(不报 drift)。
36
+ */
37
+ export function checkRegistrySchemaDigest(
38
+ registry: PatchWardenToolMeta[],
39
+ toolDefs: Map<string, ToolDefLike>,
40
+ ): SchemaDriftResult {
41
+ const warnings: string[] = [];
42
+ for (const meta of registry) {
43
+ const toolDef = toolDefs.get(meta.name);
44
+ if (!toolDef) continue; // toolDefs 中找不到该工具时跳过(不报 drift)
45
+ const actualDigest = computeSchemaDigest(toolDef.inputSchema);
46
+ if (actualDigest !== meta.inputSchemaDigest) {
47
+ warnings.push(
48
+ `Schema drift for tool '${meta.name}': registry digest ${meta.inputSchemaDigest} != actual digest ${actualDigest}`,
49
+ );
50
+ }
51
+ }
52
+ return { ok: warnings.length === 0, warnings };
53
+ }
54
+
55
+ // ── 检查 2: chatgpt_core manifest 稳定性 ──────────────────────────
56
+
57
+ /**
58
+ * 校验 chatgpt_core profile 的工具 manifest 是否稳定。
59
+ *
60
+ * toolCatalog.ts 未导出独立的 manifest hash 计算函数(stableJson 为私有),
61
+ * 因此采用 fallback 方案:校验 CHATGPT_CORE_TOOL_NAMES 中的工具名
62
+ * 全部存在于 toolDefs 中(集合比较,不比较顺序——Map 迭代顺序取决于构造
63
+ * 方式,不一定与 chatgpt_core profile 顺序一致)。
64
+ *
65
+ * 真正的顺序稳定性由 CHATGPT_CORE_TOOL_NAMES 常量本身保证(顺序固定),
66
+ * 如果常量被修改,这里的名称集合比较也会检测到变化。
67
+ */
68
+ export function checkChatgptCoreManifestStable(
69
+ toolDefs: Map<string, ToolDefLike>,
70
+ ): SchemaDriftResult {
71
+ const warnings: string[] = [];
72
+ const expected = [...CHATGPT_CORE_TOOL_NAMES];
73
+ // 检查 CHATGPT_CORE_TOOL_NAMES 中的每个工具是否都存在于 toolDefs
74
+ const missing = expected.filter((name) => !toolDefs.has(name));
75
+ if (missing.length > 0) {
76
+ warnings.push(
77
+ `chatgpt_core manifest has missing tools: ${missing.join(", ")} (expected ${EXPECTED_CORE_COUNT}, got ${EXPECTED_CORE_COUNT - missing.length})`,
78
+ );
79
+ }
80
+ return { ok: warnings.length === 0, warnings };
81
+ }
82
+
83
+ // ── 检查 3: chatgpt_core profile 工具数未变 ───────────────────────
84
+
85
+ /**
86
+ * 校验 chatgpt_core profile 的工具数仍与 CHATGPT_CORE_TOOL_NAMES 一致。
87
+ * 用于检测是否有新工具被意外追加到 chatgpt_core profile。
88
+ */
89
+ export function checkNewToolsProfileAppend(
90
+ registry: PatchWardenToolMeta[],
91
+ ): SchemaDriftResult {
92
+ const warnings: string[] = [];
93
+ const coreCount = registry.filter((t) => t.profiles.includes("chatgpt_core")).length;
94
+ if (coreCount !== EXPECTED_CORE_COUNT) {
95
+ warnings.push(
96
+ `chatgpt_core profile tool count changed: expected ${EXPECTED_CORE_COUNT}, got ${coreCount}`,
97
+ );
98
+ }
99
+ return { ok: warnings.length === 0, warnings };
100
+ }
101
+
102
+ // ── 聚合检查 ──────────────────────────────────────────────────────
103
+
104
+ /**
105
+ * 聚合执行所有 schema drift 检查,合并 warnings。
106
+ * ok = warnings.length === 0
107
+ */
108
+ export function runAllSchemaDriftChecks(
109
+ registry: PatchWardenToolMeta[],
110
+ toolDefs: Map<string, ToolDefLike>,
111
+ ): SchemaDriftResult {
112
+ const warnings: string[] = [];
113
+ const r1 = checkRegistrySchemaDigest(registry, toolDefs);
114
+ warnings.push(...r1.warnings);
115
+ const r2 = checkChatgptCoreManifestStable(toolDefs);
116
+ warnings.push(...r2.warnings);
117
+ const r3 = checkNewToolsProfileAppend(registry);
118
+ warnings.push(...r3.warnings);
119
+ return { ok: warnings.length === 0, warnings };
120
+ }
@@ -0,0 +1,300 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { getConfig } from "../config.js";
5
+ import { guardReadPath } from "../security/pathGuard.js";
6
+ import { redactSensitiveValue } from "../security/contentRedaction.js";
7
+ import { PatchWardenError } from "../errors.js";
8
+
9
+ export type TaskLoopStopReason =
10
+ | "success"
11
+ | "max_iterations_reached"
12
+ | "verification_failed"
13
+ | "high_risk_blocked"
14
+ | "user_confirmation_required"
15
+ | "agent_timeout"
16
+ | "policy_blocked"
17
+ | "watcher_blocked"
18
+ | "direct_profile_disabled"
19
+ | "direct_verification_failed"
20
+ | "direct_audit_failed";
21
+
22
+ export interface TaskLineageDirectSession {
23
+ session_id: string;
24
+ status?: "passed" | "failed" | "skipped";
25
+ command_count?: number;
26
+ passed_commands?: number;
27
+ failed_commands?: number;
28
+ timed_out_commands?: number;
29
+ audit_decision?: "pass" | "warn" | "fail" | "not_run";
30
+ changed_files_total?: number;
31
+ next_action?: string;
32
+ }
33
+
34
+ export interface TaskLineageWorktree {
35
+ isolation_mode: "current_repo" | "worktree";
36
+ worktree_id?: string;
37
+ worktree_path?: string;
38
+ branch?: string;
39
+ requested_base_branch?: string;
40
+ cleanup: "keep" | "archive" | "delete_ignored_only";
41
+ status: "not_used" | "active" | "failed";
42
+ next_action: string;
43
+ }
44
+
45
+ export interface TaskLineageAgentRouting {
46
+ requested_agent: string | null;
47
+ selected_agent: string;
48
+ reason: string;
49
+ fallback: boolean;
50
+ }
51
+
52
+ export interface TaskLineageRound {
53
+ iteration: number;
54
+ task_id: string;
55
+ role: "main" | "fix_tests" | "cleanup";
56
+ status: string;
57
+ terminal: boolean;
58
+ verification_status: string;
59
+ audit_verdict: string;
60
+ fail_checks: string[];
61
+ warn_checks: string[];
62
+ next_action: string;
63
+ }
64
+
65
+ export interface TaskLineageRecord {
66
+ lineage_id: string;
67
+ goal: string;
68
+ repo_path: string;
69
+ created_at: string;
70
+ updated_at: string;
71
+ final_status: "accepted" | "needs_fix" | "blocked" | "failed";
72
+ stop_reason: TaskLoopStopReason;
73
+ next_action: string;
74
+ main_task: string | null;
75
+ fix_tasks: string[];
76
+ cleanup_tasks: string[];
77
+ direct_sessions: Array<string | TaskLineageDirectSession>;
78
+ rounds: TaskLineageRound[];
79
+ warnings: string[];
80
+ errors: string[];
81
+ worktree?: TaskLineageWorktree;
82
+ agent_routing?: TaskLineageAgentRouting;
83
+ }
84
+
85
+ export interface SafeTaskLineage {
86
+ lineage_id: string;
87
+ goal: string;
88
+ repo_path: string;
89
+ created_at: string;
90
+ updated_at: string;
91
+ final_status: TaskLineageRecord["final_status"];
92
+ stop_reason: TaskLoopStopReason;
93
+ next_action: string;
94
+ tasks: {
95
+ main: string | null;
96
+ fix: string[];
97
+ cleanup: string[];
98
+ direct_sessions: TaskLineageDirectSession[];
99
+ };
100
+ worktree: TaskLineageWorktree;
101
+ agent_routing: TaskLineageAgentRouting | null;
102
+ verification: {
103
+ latest_status: string;
104
+ passed: boolean;
105
+ };
106
+ rounds: TaskLineageRound[];
107
+ warnings: string[];
108
+ errors: string[];
109
+ truncated: boolean;
110
+ }
111
+
112
+ export function createLineageId(now = new Date()): string {
113
+ const stamp = now.toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
114
+ return `lineage_${stamp}_${randomBytes(4).toString("hex")}`;
115
+ }
116
+
117
+ export function writeTaskLineage(record: TaskLineageRecord): SafeTaskLineage {
118
+ const config = getConfig();
119
+ const lineageDir = resolve(config.workspaceRoot, ".patchwarden", "lineages", record.lineage_id);
120
+ mkdirSync(lineageDir, { recursive: true });
121
+ const safeRecord = redactSensitiveValue(record).value as TaskLineageRecord;
122
+ writeFileSync(join(lineageDir, "lineage.json"), JSON.stringify(safeRecord, null, 2) + "\n", "utf-8");
123
+ writeFileSync(join(lineageDir, "SUMMARY.md"), buildSummaryMarkdown(safeRecord), "utf-8");
124
+ return toSafeTaskLineage(safeRecord);
125
+ }
126
+
127
+ export function getTaskLineage(lineageId: string, options: { max_items?: number } = {}): SafeTaskLineage {
128
+ const maxItems = normalizeMaxItems(options.max_items);
129
+ if (!/^[A-Za-z0-9_-]+$/.test(lineageId)) {
130
+ throw new PatchWardenError(
131
+ "invalid_lineage_id",
132
+ "lineage_id may contain only letters, numbers, underscores, and hyphens.",
133
+ "Pass a lineage_id returned by run_task_loop.",
134
+ true,
135
+ { lineage_id: lineageId }
136
+ );
137
+ }
138
+ const config = getConfig();
139
+ const lineageFile = resolve(config.workspaceRoot, ".patchwarden", "lineages", lineageId, "lineage.json");
140
+ guardReadPath(lineageFile, config.workspaceRoot, ".patchwarden/lineages");
141
+ if (!existsSync(lineageFile)) {
142
+ throw new PatchWardenError(
143
+ "lineage_not_found",
144
+ `Task lineage not found: "${lineageId}".`,
145
+ "Pass a lineage_id returned by run_task_loop.",
146
+ true,
147
+ { lineage_id: lineageId }
148
+ );
149
+ }
150
+ const raw = readFileSync(lineageFile, "utf-8").replace(/^\uFEFF/, "");
151
+ const record = JSON.parse(raw) as TaskLineageRecord;
152
+ return toSafeTaskLineage(redactSensitiveValue(record).value as TaskLineageRecord, maxItems);
153
+ }
154
+
155
+ export function toSafeTaskLineage(record: TaskLineageRecord, maxItems = 8): SafeTaskLineage {
156
+ const rounds = record.rounds.slice(0, maxItems);
157
+ const latest = record.rounds[record.rounds.length - 1];
158
+ const directSessions = normalizeDirectSessions(record.direct_sessions);
159
+ return {
160
+ lineage_id: record.lineage_id,
161
+ goal: record.goal,
162
+ repo_path: record.repo_path,
163
+ created_at: record.created_at,
164
+ updated_at: record.updated_at,
165
+ final_status: record.final_status,
166
+ stop_reason: record.stop_reason,
167
+ next_action: record.next_action,
168
+ tasks: {
169
+ main: record.main_task,
170
+ fix: record.fix_tasks.slice(0, maxItems),
171
+ cleanup: record.cleanup_tasks.slice(0, maxItems),
172
+ direct_sessions: directSessions.slice(0, maxItems),
173
+ },
174
+ worktree: normalizeWorktree(record.worktree),
175
+ agent_routing: record.agent_routing ? {
176
+ requested_agent: record.agent_routing.requested_agent,
177
+ selected_agent: truncate(String(record.agent_routing.selected_agent), 120),
178
+ reason: truncate(String(record.agent_routing.reason), 240),
179
+ fallback: Boolean(record.agent_routing.fallback),
180
+ } : null,
181
+ verification: {
182
+ latest_status: latest?.verification_status || "not_available",
183
+ passed: latest?.verification_status === "passed",
184
+ },
185
+ rounds,
186
+ warnings: record.warnings.slice(0, maxItems).map((value) => truncate(value, 240)),
187
+ errors: record.errors.slice(0, maxItems).map((value) => truncate(value, 240)),
188
+ truncated:
189
+ record.rounds.length > maxItems ||
190
+ record.fix_tasks.length > maxItems ||
191
+ record.cleanup_tasks.length > maxItems ||
192
+ directSessions.length > maxItems ||
193
+ record.warnings.length > maxItems ||
194
+ record.errors.length > maxItems,
195
+ };
196
+ }
197
+
198
+ function buildSummaryMarkdown(record: TaskLineageRecord): string {
199
+ const rounds = record.rounds.map((round) =>
200
+ `- ${round.role} ${round.task_id}: ${round.status}, verification=${round.verification_status}, audit=${round.audit_verdict}`
201
+ );
202
+ return [
203
+ "# PatchWarden Task Lineage",
204
+ "",
205
+ `- Lineage: ${record.lineage_id}`,
206
+ `- Goal: ${record.goal}`,
207
+ `- Repo: ${record.repo_path}`,
208
+ `- Final status: ${record.final_status}`,
209
+ `- Stop reason: ${record.stop_reason}`,
210
+ `- Next action: ${record.next_action}`,
211
+ `- Isolation: ${normalizeWorktree(record.worktree).isolation_mode}`,
212
+ `- Worktree: ${formatWorktree(record.worktree)}`,
213
+ `- Agent routing: ${formatAgentRouting(record.agent_routing)}`,
214
+ "",
215
+ "## Tasks",
216
+ `- Main: ${record.main_task || "none"}`,
217
+ `- Fix tasks: ${record.fix_tasks.length > 0 ? record.fix_tasks.join(", ") : "none"}`,
218
+ `- Cleanup tasks: ${record.cleanup_tasks.length > 0 ? record.cleanup_tasks.join(", ") : "none"}`,
219
+ `- Direct sessions: ${formatDirectSessions(record.direct_sessions)}`,
220
+ "",
221
+ "## Rounds",
222
+ ...(rounds.length > 0 ? rounds : ["- None."]),
223
+ "",
224
+ ].join("\n");
225
+ }
226
+
227
+ function normalizeWorktree(value: TaskLineageWorktree | undefined): TaskLineageWorktree {
228
+ if (!value) {
229
+ return {
230
+ isolation_mode: "current_repo",
231
+ cleanup: "keep",
232
+ status: "not_used",
233
+ next_action: "none",
234
+ };
235
+ }
236
+ return {
237
+ isolation_mode: value.isolation_mode === "worktree" ? "worktree" : "current_repo",
238
+ worktree_id: value.worktree_id ? truncate(String(value.worktree_id), 120) : undefined,
239
+ worktree_path: value.worktree_path ? truncate(String(value.worktree_path), 260) : undefined,
240
+ branch: value.branch ? truncate(String(value.branch), 160) : undefined,
241
+ requested_base_branch: value.requested_base_branch ? truncate(String(value.requested_base_branch), 160) : undefined,
242
+ cleanup: value.cleanup,
243
+ status: value.status,
244
+ next_action: truncate(String(value.next_action || "review_worktree"), 240),
245
+ };
246
+ }
247
+
248
+ function formatWorktree(value: TaskLineageWorktree | undefined): string {
249
+ const worktree = normalizeWorktree(value);
250
+ if (worktree.isolation_mode !== "worktree") return "not used";
251
+ const id = worktree.worktree_id || "unknown";
252
+ const status = worktree.status || "unknown";
253
+ const branch = worktree.branch ? ` branch=${worktree.branch}` : "";
254
+ return `${id} status=${status}${branch}`;
255
+ }
256
+
257
+ function formatAgentRouting(value: TaskLineageAgentRouting | undefined): string {
258
+ if (!value) return "not recorded";
259
+ const requested = value.requested_agent ? ` requested=${value.requested_agent}` : "";
260
+ return `${value.selected_agent}${requested} reason=${truncate(value.reason, 160)}`;
261
+ }
262
+
263
+ function normalizeDirectSessions(value: Array<string | TaskLineageDirectSession>): TaskLineageDirectSession[] {
264
+ return value.map((entry) => {
265
+ if (typeof entry === "string") return { session_id: entry };
266
+ return {
267
+ session_id: String(entry.session_id || ""),
268
+ status: entry.status,
269
+ command_count: entry.command_count,
270
+ passed_commands: entry.passed_commands,
271
+ failed_commands: entry.failed_commands,
272
+ timed_out_commands: entry.timed_out_commands,
273
+ audit_decision: entry.audit_decision,
274
+ changed_files_total: entry.changed_files_total,
275
+ next_action: entry.next_action ? truncate(String(entry.next_action), 240) : undefined,
276
+ };
277
+ }).filter((entry) => entry.session_id !== "");
278
+ }
279
+
280
+ function formatDirectSessions(value: Array<string | TaskLineageDirectSession>): string {
281
+ const sessions = normalizeDirectSessions(value);
282
+ if (sessions.length === 0) return "none";
283
+ return sessions.map((entry) => {
284
+ const status = entry.status ? ` status=${entry.status}` : "";
285
+ const audit = entry.audit_decision ? ` audit=${entry.audit_decision}` : "";
286
+ return `${entry.session_id}${status}${audit}`;
287
+ }).join(", ");
288
+ }
289
+
290
+ function normalizeMaxItems(value: number | undefined): number {
291
+ if (value === undefined) return 8;
292
+ if (!Number.isInteger(value) || value < 1 || value > 50) {
293
+ throw new Error("max_items must be an integer from 1 to 50.");
294
+ }
295
+ return value;
296
+ }
297
+
298
+ function truncate(value: string, maxChars: number): string {
299
+ return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`;
300
+ }