patchwarden 0.6.4 → 1.1.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 (212) hide show
  1. package/README.en.md +18 -18
  2. package/README.md +20 -20
  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 +15 -2
  8. package/dist/doctor.js +32 -1
  9. package/dist/goal/acceptanceEngine.d.ts +62 -0
  10. package/dist/goal/acceptanceEngine.js +103 -0
  11. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  12. package/dist/goal/acceptanceTemplate.js +104 -0
  13. package/dist/goal/goalGraph.d.ts +58 -0
  14. package/dist/goal/goalGraph.js +189 -0
  15. package/dist/goal/goalProgress.d.ts +81 -0
  16. package/dist/goal/goalProgress.js +216 -0
  17. package/dist/goal/goalStatus.d.ts +60 -0
  18. package/dist/goal/goalStatus.js +137 -0
  19. package/dist/goal/goalStore.d.ts +79 -0
  20. package/dist/goal/goalStore.js +211 -0
  21. package/dist/goal/handoffExport.d.ts +34 -0
  22. package/dist/goal/handoffExport.js +183 -0
  23. package/dist/goal/subgoalSync.d.ts +40 -0
  24. package/dist/goal/subgoalSync.js +72 -0
  25. package/dist/goal/worktreeManager.d.ts +96 -0
  26. package/dist/goal/worktreeManager.js +292 -0
  27. package/dist/logging.d.ts +44 -0
  28. package/dist/logging.js +68 -0
  29. package/dist/release/releaseGate.d.ts +99 -0
  30. package/dist/release/releaseGate.js +475 -0
  31. package/dist/runner/postTaskCleanup.d.ts +13 -0
  32. package/dist/runner/postTaskCleanup.js +147 -0
  33. package/dist/runner/runTask.js +50 -10
  34. package/dist/security/discoveryTokenStore.d.ts +62 -0
  35. package/dist/security/discoveryTokenStore.js +100 -0
  36. package/dist/security/toolInvocationGuard.d.ts +35 -0
  37. package/dist/security/toolInvocationGuard.js +153 -0
  38. package/dist/smoke-test.js +18 -10
  39. package/dist/taskRuntime.d.ts +17 -0
  40. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  41. package/dist/test/unit/acceptance-engine.test.js +228 -0
  42. package/dist/test/unit/agent-router.test.d.ts +1 -0
  43. package/dist/test/unit/agent-router.test.js +287 -0
  44. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  45. package/dist/test/unit/audit-checks.test.js +350 -0
  46. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  47. package/dist/test/unit/diagnose-task.test.js +457 -0
  48. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  49. package/dist/test/unit/discovery-token-store.test.js +139 -0
  50. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  51. package/dist/test/unit/goal-graph.test.js +298 -0
  52. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  53. package/dist/test/unit/goal-progress.test.js +381 -0
  54. package/dist/test/unit/goal-status.test.d.ts +1 -0
  55. package/dist/test/unit/goal-status.test.js +215 -0
  56. package/dist/test/unit/goal-store.test.d.ts +1 -0
  57. package/dist/test/unit/goal-store.test.js +253 -0
  58. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  59. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  60. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  61. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  62. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  63. package/dist/test/unit/handoff-export.test.js +263 -0
  64. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  65. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  66. package/dist/test/unit/logging.test.js +127 -5
  67. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  68. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  69. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  70. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  71. package/dist/test/unit/release-gate.test.d.ts +1 -0
  72. package/dist/test/unit/release-gate.test.js +242 -0
  73. package/dist/test/unit/safe-views.test.d.ts +1 -0
  74. package/dist/test/unit/safe-views.test.js +171 -0
  75. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  76. package/dist/test/unit/schema-drift-check.test.js +175 -0
  77. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  78. package/dist/test/unit/subgoal-sync.test.js +183 -0
  79. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  80. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  81. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  82. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  83. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  84. package/dist/test/unit/toolSearch.test.js +571 -0
  85. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  86. package/dist/test/unit/worktree-manager.test.js +176 -0
  87. package/dist/tools/auditTask.d.ts +103 -1
  88. package/dist/tools/auditTask.js +461 -8
  89. package/dist/tools/cancelTask.js +1 -1
  90. package/dist/tools/checkReleaseGate.d.ts +21 -0
  91. package/dist/tools/checkReleaseGate.js +22 -0
  92. package/dist/tools/createTask.d.ts +18 -2
  93. package/dist/tools/createTask.js +29 -1
  94. package/dist/tools/diagnoseTask.d.ts +45 -0
  95. package/dist/tools/diagnoseTask.js +347 -0
  96. package/dist/tools/discardWorktree.d.ts +24 -0
  97. package/dist/tools/discardWorktree.js +27 -0
  98. package/dist/tools/discoverTools.d.ts +11 -0
  99. package/dist/tools/discoverTools.js +39 -0
  100. package/dist/tools/explainTool.d.ts +11 -0
  101. package/dist/tools/explainTool.js +21 -0
  102. package/dist/tools/getTaskSummary.js +2 -1
  103. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  104. package/dist/tools/goalSubgoalTask.js +110 -0
  105. package/dist/tools/healthCheck.d.ts +1 -0
  106. package/dist/tools/healthCheck.js +5 -1
  107. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  108. package/dist/tools/invokeDiscoveredTool.js +112 -0
  109. package/dist/tools/listTasks.d.ts +3 -1
  110. package/dist/tools/listTasks.js +14 -1
  111. package/dist/tools/mergeWorktree.d.ts +24 -0
  112. package/dist/tools/mergeWorktree.js +27 -0
  113. package/dist/tools/reconcileTasks.d.ts +41 -0
  114. package/dist/tools/reconcileTasks.js +352 -0
  115. package/dist/tools/registry.js +550 -0
  116. package/dist/tools/safeStatus.d.ts +31 -1
  117. package/dist/tools/safeStatus.js +43 -1
  118. package/dist/tools/safeViews.d.ts +256 -0
  119. package/dist/tools/safeViews.js +250 -0
  120. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  121. package/dist/tools/schemaDriftCheck.js +80 -0
  122. package/dist/tools/toolCatalog.d.ts +4 -3
  123. package/dist/tools/toolCatalog.js +33 -11
  124. package/dist/tools/toolRegistry.d.ts +61 -0
  125. package/dist/tools/toolRegistry.js +724 -0
  126. package/dist/tools/toolSearch.d.ts +110 -0
  127. package/dist/tools/toolSearch.js +331 -0
  128. package/dist/tools/toolUsageStats.d.ts +41 -0
  129. package/dist/tools/toolUsageStats.js +116 -0
  130. package/dist/tools/waitForTask.js +1 -0
  131. package/dist/version.d.ts +2 -2
  132. package/dist/version.js +2 -2
  133. package/dist/watcherStatus.d.ts +29 -0
  134. package/dist/watcherStatus.js +92 -1
  135. package/package.json +3 -2
  136. package/scripts/checks/control-smoke.js +2 -2
  137. package/scripts/checks/doctor-smoke.js +1 -0
  138. package/scripts/checks/lifecycle-smoke.js +13 -9
  139. package/scripts/checks/mcp-manifest-check.js +53 -6
  140. package/scripts/checks/mcp-smoke.js +36 -9
  141. package/scripts/checks/watcher-supervisor-smoke.js +2 -2
  142. package/src/agents/agentRouter.ts +149 -0
  143. package/src/config.ts +9 -3
  144. package/src/controlCenter.ts +17 -3
  145. package/src/doctor.ts +36 -1
  146. package/src/goal/acceptanceEngine.ts +160 -0
  147. package/src/goal/acceptanceTemplate.ts +121 -0
  148. package/src/goal/goalGraph.ts +225 -0
  149. package/src/goal/goalProgress.ts +301 -0
  150. package/src/goal/goalStatus.ts +234 -0
  151. package/src/goal/goalStore.ts +306 -0
  152. package/src/goal/handoffExport.ts +211 -0
  153. package/src/goal/subgoalSync.ts +82 -0
  154. package/src/goal/worktreeManager.ts +404 -0
  155. package/src/logging.ts +91 -0
  156. package/src/release/releaseGate.ts +567 -0
  157. package/src/runner/postTaskCleanup.ts +154 -0
  158. package/src/runner/runTask.ts +49 -10
  159. package/src/security/discoveryTokenStore.ts +157 -0
  160. package/src/security/toolInvocationGuard.ts +251 -0
  161. package/src/smoke-test.ts +15 -7
  162. package/src/taskRuntime.ts +17 -0
  163. package/src/test/unit/acceptance-engine.test.ts +261 -0
  164. package/src/test/unit/agent-router.test.ts +342 -0
  165. package/src/test/unit/audit-checks.test.ts +567 -0
  166. package/src/test/unit/diagnose-task.test.ts +544 -0
  167. package/src/test/unit/discovery-token-store.test.ts +181 -0
  168. package/src/test/unit/goal-graph.test.ts +347 -0
  169. package/src/test/unit/goal-progress.test.ts +538 -0
  170. package/src/test/unit/goal-status.test.ts +270 -0
  171. package/src/test/unit/goal-store.test.ts +318 -0
  172. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  173. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  174. package/src/test/unit/handoff-export.test.ts +295 -0
  175. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  176. package/src/test/unit/logging.test.ts +177 -5
  177. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  178. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  179. package/src/test/unit/release-gate.test.ts +314 -0
  180. package/src/test/unit/safe-views.test.ts +184 -0
  181. package/src/test/unit/schema-drift-check.test.ts +258 -0
  182. package/src/test/unit/subgoal-sync.test.ts +236 -0
  183. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  184. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  185. package/src/test/unit/toolSearch.test.ts +756 -0
  186. package/src/test/unit/worktree-manager.test.ts +247 -0
  187. package/src/tools/auditTask.ts +831 -402
  188. package/src/tools/cancelTask.ts +1 -1
  189. package/src/tools/checkReleaseGate.ts +45 -0
  190. package/src/tools/createTask.ts +64 -6
  191. package/src/tools/diagnoseTask.ts +460 -0
  192. package/src/tools/discardWorktree.ts +42 -0
  193. package/src/tools/discoverTools.ts +51 -0
  194. package/src/tools/explainTool.ts +26 -0
  195. package/src/tools/getTaskSummary.ts +2 -1
  196. package/src/tools/goalSubgoalTask.ts +170 -0
  197. package/src/tools/healthCheck.ts +4 -1
  198. package/src/tools/invokeDiscoveredTool.ts +163 -0
  199. package/src/tools/listTasks.ts +16 -2
  200. package/src/tools/mergeWorktree.ts +42 -0
  201. package/src/tools/reconcileTasks.ts +464 -0
  202. package/src/tools/registry.ts +618 -1
  203. package/src/tools/safeStatus.ts +73 -2
  204. package/src/tools/safeViews.ts +271 -0
  205. package/src/tools/schemaDriftCheck.ts +120 -0
  206. package/src/tools/toolCatalog.ts +34 -11
  207. package/src/tools/toolRegistry.ts +786 -0
  208. package/src/tools/toolSearch.ts +464 -0
  209. package/src/tools/toolUsageStats.ts +151 -0
  210. package/src/tools/waitForTask.ts +1 -0
  211. package/src/version.ts +2 -2
  212. package/src/watcherStatus.ts +97 -1
@@ -3,13 +3,26 @@ import { resolve, join } from "node:path";
3
3
  import { getTasksDir, getConfig, type PatchWardenConfig } from "../config.js";
4
4
  import { guardReadPath } from "../security/pathGuard.js";
5
5
  import { guardSensitivePath } from "../security/sensitiveGuard.js";
6
- import type { TaskStatus, TaskPhase } from "./createTask.js";
6
+ import type { TaskStatus, TaskPhase, AcceptanceStatus } from "./createTask.js";
7
7
  import { readTaskRuntime } from "../taskRuntime.js";
8
8
  import { readWatcherStatus } from "../watcherStatus.js";
9
+ import { diagnoseTask, type DiagnosisType, type DiagnosisConfidence } from "./diagnoseTask.js";
9
10
 
10
11
  export interface SafeStatusOutput {
11
12
  task_id: string;
12
13
  status: TaskStatus | "not_found";
14
+ /**
15
+ * v0.7.0: For done_by_agent or accepted tasks, legacy_status echoes "done"
16
+ * so older UI/clients that do not understand those statuses still see a familiar value.
17
+ * Null for all other statuses.
18
+ */
19
+ legacy_status: "done" | null;
20
+ /**
21
+ * v0.7.0: Acceptance status — only meaningful for done_by_agent.
22
+ * - "pending": done_by_agent reached but not yet audited/accepted
23
+ * - null: status has no acceptance semantics
24
+ */
25
+ acceptance_status: AcceptanceStatus;
13
26
  phase: TaskPhase | null;
14
27
  created_at: string | null;
15
28
  started_at: string | null;
@@ -22,6 +35,20 @@ export interface SafeStatusOutput {
22
35
  watcher_state: string | null;
23
36
  error_code: string | null;
24
37
  error_summary: string | null;
38
+ /**
39
+ * v0.7.0: Seconds since the last heartbeat. Null when heartbeat is missing
40
+ * or unparseable. Used by callers to detect stale running tasks.
41
+ */
42
+ stale_seconds: number | null;
43
+ /**
44
+ * v0.7.0: Lightweight diagnosis snapshot derived from the same multi-signal
45
+ * logic as diagnose_task. Only the type/confidence are surfaced here;
46
+ * call diagnose_task for the full evidence and safe_actions.
47
+ *
48
+ * This field is null when status is already terminal (no diagnosis needed)
49
+ * or when diagnosis itself fails (do not block safe_status).
50
+ */
51
+ diagnosis: { type: DiagnosisType; confidence: DiagnosisConfidence } | null;
25
52
  }
26
53
 
27
54
  export function safeStatus(taskId: string, config?: PatchWardenConfig): SafeStatusOutput {
@@ -37,6 +64,8 @@ export function safeStatus(taskId: string, config?: PatchWardenConfig): SafeStat
37
64
  return {
38
65
  task_id: taskId,
39
66
  status: "not_found",
67
+ legacy_status: null,
68
+ acceptance_status: null,
40
69
  phase: null,
41
70
  created_at: null,
42
71
  started_at: null,
@@ -49,6 +78,8 @@ export function safeStatus(taskId: string, config?: PatchWardenConfig): SafeStat
49
78
  watcher_state: null,
50
79
  error_code: null,
51
80
  error_summary: null,
81
+ stale_seconds: null,
82
+ diagnosis: null,
52
83
  };
53
84
  }
54
85
 
@@ -77,9 +108,47 @@ export function safeStatus(taskId: string, config?: PatchWardenConfig): SafeStat
77
108
  errorSummary = `Task ended with status: ${status.status}`;
78
109
  }
79
110
 
111
+ // v0.7.2: derive legacy_status and acceptance_status
112
+ const statusStr = (status.status as TaskStatus) || "not_found";
113
+ const legacyStatus: "done" | null = (statusStr === "done_by_agent" || statusStr === "accepted") ? "done" : null;
114
+ const VALID_ACCEPTANCE: AcceptanceStatus[] = ["pending", "accepted", "rejected", "needs_fix", "blocked"];
115
+ const acceptanceStatus: AcceptanceStatus = statusStr === "done_by_agent"
116
+ ? (typeof status.acceptance_status === "string" && VALID_ACCEPTANCE.includes(status.acceptance_status as AcceptanceStatus)
117
+ ? (status.acceptance_status as AcceptanceStatus)
118
+ : "pending") // default for done_by_agent when field missing
119
+ : null;
120
+
121
+ // v0.7.0: compute stale_seconds from the most recent heartbeat
122
+ const heartbeatStr =
123
+ (typeof runtime.last_heartbeat_at === "string" && runtime.last_heartbeat_at) ||
124
+ (typeof status.last_heartbeat_at === "string" && status.last_heartbeat_at) ||
125
+ (typeof status.updated_at === "string" && status.updated_at) ||
126
+ null;
127
+ let staleSeconds: number | null = null;
128
+ if (heartbeatStr) {
129
+ const heartbeatMs = Date.parse(heartbeatStr);
130
+ if (Number.isFinite(heartbeatMs)) {
131
+ staleSeconds = Math.max(0, Math.round((Date.now() - heartbeatMs) / 1000));
132
+ }
133
+ }
134
+
135
+ // v0.7.0: lightweight diagnosis snapshot — only for non-terminal tasks.
136
+ // Wrap in try/catch so a diagnosis failure never breaks safe_status.
137
+ let diagnosis: { type: DiagnosisType; confidence: DiagnosisConfidence } | null = null;
138
+ if (statusStr === "running" || statusStr === "collecting_artifacts" || statusStr === "pending") {
139
+ try {
140
+ const result = diagnoseTask({ task_id: taskId }, cfg);
141
+ diagnosis = { type: result.diagnosis, confidence: result.confidence };
142
+ } catch {
143
+ diagnosis = null;
144
+ }
145
+ }
146
+
80
147
  return {
81
148
  task_id: taskId,
82
- status: (status.status as TaskStatus) || "not_found",
149
+ status: statusStr,
150
+ legacy_status: legacyStatus,
151
+ acceptance_status: acceptanceStatus,
83
152
  phase,
84
153
  created_at: typeof status.created_at === "string" ? status.created_at : null,
85
154
  started_at: typeof status.started_at === "string" ? status.started_at : null,
@@ -92,5 +161,7 @@ export function safeStatus(taskId: string, config?: PatchWardenConfig): SafeStat
92
161
  watcher_state: watcher.status,
93
162
  error_code: errorCode,
94
163
  error_summary: errorSummary,
164
+ stale_seconds: staleSeconds,
165
+ diagnosis,
95
166
  };
96
167
  }
@@ -0,0 +1,271 @@
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
+ started_at: run.started_at,
212
+ finished_at: run.finished_at,
213
+ }));
214
+ }
215
+
216
+ function limitClassified(value: ClassifiedChange[] | undefined, maxItems: number) {
217
+ return (value || []).slice(0, maxItems).map((entry) => ({
218
+ path: entry.path,
219
+ change: entry.change,
220
+ kind: entry.kind,
221
+ tracked: entry.tracked,
222
+ ignored: entry.ignored,
223
+ reason: truncate(entry.reason, 160),
224
+ }));
225
+ }
226
+
227
+ function getTaskDir(taskId: string) {
228
+ const config = getConfig();
229
+ const taskDir = resolve(getTasksDir(config), taskId);
230
+ guardReadPath(join(taskDir, "status.json"), config.workspaceRoot, config.tasksDir);
231
+ return { config, taskDir };
232
+ }
233
+
234
+ function readJson(path: string, config: ReturnType<typeof getConfig>): Record<string, any> {
235
+ guardReadPath(path, config.workspaceRoot, config.tasksDir);
236
+ if (!existsSync(path)) return {};
237
+ try {
238
+ const raw = readFileSync(path, "utf-8").replace(/^\uFEFF/, "");
239
+ return asRecord(JSON.parse(raw));
240
+ } catch {
241
+ return {};
242
+ }
243
+ }
244
+
245
+ function normalizeMaxItems(value: number | undefined): number {
246
+ if (value === undefined) return 8;
247
+ if (!Number.isInteger(value) || value < 1 || value > 50) {
248
+ throw new Error("max_items must be an integer from 1 to 50.");
249
+ }
250
+ return value;
251
+ }
252
+
253
+ function asArray(value: unknown): any[] {
254
+ return Array.isArray(value) ? value : [];
255
+ }
256
+
257
+ function asRecord(value: unknown): Record<string, any> {
258
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
259
+ }
260
+
261
+ function limitStrings(values: string[], maxItems: number): string[] {
262
+ return values.slice(0, maxItems).map((value) => truncate(value, 240));
263
+ }
264
+
265
+ function truncate(value: string, maxChars: number): string {
266
+ return value.length <= maxChars ? value : `${value.slice(0, maxChars)}...`;
267
+ }
268
+
269
+ function redact<T>(value: T): T {
270
+ return redactSensitiveValue(value).value as T;
271
+ }
@@ -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
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../version.js";
3
3
 
4
- export type ToolProfile = "full" | "chatgpt_core" | "chatgpt_direct";
4
+ export type ToolProfile = "full" | "chatgpt_core" | "chatgpt_direct" | "chatgpt_search";
5
5
 
6
6
  export interface CatalogTool {
7
7
  name: string;
@@ -36,6 +36,10 @@ export const CHATGPT_CORE_TOOL_NAMES = [
36
36
  "cancel_task",
37
37
  "audit_task",
38
38
  "safe_status",
39
+ "safe_result",
40
+ "safe_audit",
41
+ "safe_test_summary",
42
+ "safe_diff_summary",
39
43
  ] as const;
40
44
 
41
45
  export const CHATGPT_DIRECT_TOOL_NAMES = [
@@ -48,15 +52,26 @@ export const CHATGPT_DIRECT_TOOL_NAMES = [
48
52
  "run_verification",
49
53
  "finalize_direct_session",
50
54
  "audit_session",
55
+ "safe_direct_summary",
56
+ "safe_finalize_direct_session",
57
+ "safe_audit_direct_session",
51
58
  "sync_file",
52
59
  ] as const;
53
60
 
61
+ export const CHATGPT_SEARCH_TOOL_NAMES = [
62
+ "health_check",
63
+ "discover_tools",
64
+ "explain_tool",
65
+ "invoke_discovered_tool",
66
+ "safe_status",
67
+ ] as const;
68
+
54
69
  let lastSnapshot: ToolCatalogSnapshot | null = null;
55
70
 
56
71
  export function resolveToolProfile(configProfile?: string): ToolProfile {
57
72
  const raw = (process.env.PATCHWARDEN_TOOL_PROFILE || configProfile || "full").trim();
58
- if (raw !== "full" && raw !== "chatgpt_core" && raw !== "chatgpt_direct") {
59
- throw new Error(`Invalid tool profile "${raw}". Expected "full", "chatgpt_core", or "chatgpt_direct".`);
73
+ if (raw !== "full" && raw !== "chatgpt_core" && raw !== "chatgpt_direct" && raw !== "chatgpt_search") {
74
+ throw new Error(`Invalid tool profile "${raw}". Expected "full", "chatgpt_core", "chatgpt_direct", or "chatgpt_search".`);
60
75
  }
61
76
  return raw;
62
77
  }
@@ -73,17 +88,25 @@ export function selectToolsForProfile<T extends CatalogTool>(tools: T[], profile
73
88
  });
74
89
  }
75
90
 
76
- // chatgpt_direct
77
- if (!enableDirectProfile) {
78
- // Degraded mode: only expose health_check for diagnostics
79
- const tool = byName.get("health_check");
80
- if (!tool) throw new Error(`chatgpt_direct degraded mode requires missing tool "health_check".`);
81
- return [tool];
91
+ if (profile === "chatgpt_direct") {
92
+ if (!enableDirectProfile) {
93
+ // Degraded mode: only expose health_check for diagnostics
94
+ const tool = byName.get("health_check");
95
+ if (!tool) throw new Error(`chatgpt_direct degraded mode requires missing tool "health_check".`);
96
+ return [tool];
97
+ }
98
+
99
+ return CHATGPT_DIRECT_TOOL_NAMES.map((name) => {
100
+ const tool = byName.get(name);
101
+ if (!tool) throw new Error(`chatgpt_direct tool profile requires missing tool "${name}".`);
102
+ return tool;
103
+ });
82
104
  }
83
105
 
84
- return CHATGPT_DIRECT_TOOL_NAMES.map((name) => {
106
+ // chatgpt_search: compact profile, 5 tools all required, no degraded mode
107
+ return CHATGPT_SEARCH_TOOL_NAMES.map((name) => {
85
108
  const tool = byName.get(name);
86
- if (!tool) throw new Error(`chatgpt_direct tool profile requires missing tool "${name}".`);
109
+ if (!tool) throw new Error(`chatgpt_search tool profile requires missing tool "${name}".`);
87
110
  return tool;
88
111
  });
89
112
  }