@khoavu882/doflow 2.0.0-beta.3 → 2.0.0-beta.5

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 (236) hide show
  1. package/bin/doflow.js +2 -2
  2. package/core/.antigravity-plugin/plugin.json +0 -1
  3. package/core/.claude-plugin/plugin.json +1 -1
  4. package/core/.codex-plugin/plugin.json +1 -1
  5. package/core/.plugin/plugin.json +1 -1
  6. package/core/harnesses/claude/hooks/mcp-tool-guard.sh +7 -17
  7. package/core/harnesses/claude/hooks/pre-bash-guard.sh +7 -17
  8. package/core/harnesses/claude/hooks/pre-implement-gate.sh +7 -18
  9. package/core/harnesses/claude/hooks/session-start.sh +5 -3
  10. package/core/harnesses/claude/hooks/stop-check.sh +5 -3
  11. package/core/harnesses/claude/hooks/subagent-audit.sh +5 -5
  12. package/core/harnesses/claude/hooks/user-prompt-submit.sh +5 -3
  13. package/core/harnesses/codex/hooks/mcp-tool-guard.sh +7 -15
  14. package/core/harnesses/codex/hooks/pre-bash-guard.sh +7 -15
  15. package/core/harnesses/codex/hooks/pre-implement-gate.sh +7 -16
  16. package/core/harnesses/codex/hooks/session-start.sh +5 -3
  17. package/core/harnesses/codex/hooks/stop-check.sh +6 -5
  18. package/core/harnesses/codex/hooks/subagent-audit.sh +17 -6
  19. package/core/harnesses/codex/hooks/user-prompt-submit.sh +5 -4
  20. package/core/harnesses/gemini/hooks/mcp-tool-guard.sh +7 -18
  21. package/core/harnesses/gemini/hooks/pre-bash-guard.sh +7 -18
  22. package/core/harnesses/gemini/hooks/pre-implement-gate.sh +7 -19
  23. package/core/harnesses/gemini/hooks/session-start.sh +5 -3
  24. package/core/harnesses/kiro/hooks/mcp-tool-guard.sh +20 -5
  25. package/core/harnesses/kiro/hooks/pre-bash-guard.sh +20 -5
  26. package/core/harnesses/kiro/hooks/pre-implement-gate.sh +20 -5
  27. package/core/harnesses/kiro/hooks/session-start.sh +11 -5
  28. package/core/harnesses/kiro/hooks/stop-check.sh +21 -30
  29. package/core/harnesses/pi/extensions/doflow-guard.js +159 -0
  30. package/core/harnesses/shared/hooks/ENVELOPE_MIGRATION.md +27 -11
  31. package/core/harnesses/shared/hooks/adapters/antigravity.js +14 -57
  32. package/core/harnesses/shared/hooks/bridge/bridge.sh +184 -0
  33. package/core/harnesses/shared/hooks/bridge/envelope.jq +60 -0
  34. package/core/harnesses/shared/hooks/bridge/respond.sh +62 -0
  35. package/core/harnesses/shared/hooks/policies/capture-failure.sh +4 -2
  36. package/core/harnesses/shared/hooks/policies/contract.sh +75 -0
  37. package/core/harnesses/shared/hooks/policies/deny-json.sh +39 -4
  38. package/core/harnesses/shared/hooks/policies/lib.sh +4 -2
  39. package/core/harnesses/shared/hooks/policies/mcp-tool-guard.sh +46 -34
  40. package/core/harnesses/shared/hooks/policies/pre-bash-guard.sh +75 -25
  41. package/core/harnesses/shared/hooks/policies/pre-implementation-gate.sh +67 -73
  42. package/core/harnesses/shared/hooks/policies/session-context.sh +37 -7
  43. package/core/harnesses/shared/hooks/policies/stop-check.sh +42 -15
  44. package/core/harnesses/shared/hooks/policies/subagent-audit.sh +35 -5
  45. package/core/harnesses/shared/hooks/policies/user-prompt-submit.sh +40 -7
  46. package/core/harnesses/shared/hooks/stream-hook-runner.js +69 -181
  47. package/core/registry/assets.json +19 -0
  48. package/core/registry/contracts.json +2 -2
  49. package/core/registry/harnesses.json +196 -10
  50. package/core/registry/lifecycle.json +2626 -7
  51. package/core/registry/storage.json +110 -83
  52. package/core/registry/verification.json +1 -1
  53. package/core/shared/scripts/doflow/bash/render-puml.sh +2 -2
  54. package/core/shared/scripts/doflow/bin/doflow-run +10 -6
  55. package/core/shared/skills/do-design/SKILL.md +6 -6
  56. package/core/shared/skills/do-execute-plan/SKILL.md +11 -10
  57. package/core/shared/skills/do-execute-plan/references/readiness_gate.md +2 -0
  58. package/core/shared/skills/do-execute-plan/references/scaffold.md +2 -2
  59. package/core/shared/skills/do-implement/SKILL.md +3 -2
  60. package/core/shared/skills/do-plan/SKILL.md +7 -8
  61. package/package.json +4 -2
  62. package/src/adapters/antigravity/{index.ts → index.js} +141 -105
  63. package/src/adapters/claude/{index.ts → index.js} +63 -49
  64. package/src/adapters/claude/{mcp.ts → mcp.js} +16 -16
  65. package/src/adapters/codex/{agents.ts → agents.js} +24 -24
  66. package/src/adapters/codex/{config.ts → config.js} +49 -49
  67. package/src/adapters/codex/{hooks.ts → hooks.js} +62 -27
  68. package/src/adapters/codex/{index.ts → index.js} +148 -105
  69. package/src/adapters/codex/{mcp.ts → mcp.js} +38 -38
  70. package/src/adapters/codex/{native-projection.ts → native-projection.js} +5 -5
  71. package/src/adapters/copilot/{index.ts → index.js} +42 -42
  72. package/src/adapters/{copy-tree.ts → copy-tree.js} +74 -74
  73. package/src/adapters/gemini/{hooks.ts → hooks.js} +78 -37
  74. package/src/adapters/gemini/{index.ts → index.js} +96 -57
  75. package/src/adapters/{hook-commands.ts → hook-commands.js} +8 -8
  76. package/src/adapters/{index.ts → index.js} +13 -13
  77. package/src/adapters/kiro/{index.ts → index.js} +49 -35
  78. package/src/adapters/{mcp-entries.ts → mcp-entries.js} +55 -55
  79. package/src/adapters/opencode/{index.ts → index.js} +52 -52
  80. package/src/adapters/pi/hooks.js +119 -0
  81. package/src/adapters/pi/{index.ts → index.js} +64 -39
  82. package/src/adapters/pi/{json-members.ts → json-members.js} +23 -23
  83. package/src/adapters/pi/{mcp.ts → mcp.js} +70 -70
  84. package/src/adapters/{resolve-source.ts → resolve-source.js} +1 -1
  85. package/src/cli/commands/{install.ts → install.js} +25 -25
  86. package/src/cli/commands/{list-backups.ts → list-backups.js} +4 -4
  87. package/src/cli/commands/{reconcile.ts → reconcile.js} +19 -19
  88. package/src/cli/commands/{remove.ts → remove.js} +10 -10
  89. package/src/cli/commands/{rollback.ts → rollback.js} +17 -17
  90. package/src/cli/commands/{self-update.ts → self-update.js} +3 -3
  91. package/src/cli/commands/{status.ts → status.js} +24 -24
  92. package/src/cli/commands/{tools.ts → tools.js} +16 -16
  93. package/src/cli/commands/{update.ts → update.js} +20 -20
  94. package/src/cli/{index.ts → index.js} +43 -31
  95. package/src/cli/{runtime-commands.ts → runtime-commands.js} +97 -58
  96. package/src/cli/{shared.ts → shared.js} +40 -40
  97. package/src/helper/{bash-helper.ts → bash-helper.js} +7 -7
  98. package/src/helper/{git.ts → git.js} +2 -2
  99. package/src/helper/{guardrails.ts → guardrails.js} +10 -10
  100. package/src/helper/{harness-paths.ts → harness-paths.js} +24 -24
  101. package/src/helper/{marker-merge.ts → marker-merge.js} +4 -4
  102. package/src/helper/{prompt.ts → prompt.js} +5 -5
  103. package/src/helper/{repo-root.ts → repo-root.js} +2 -2
  104. package/src/helper/{settings-merge.ts → settings-merge.js} +27 -27
  105. package/src/helper/{settings-scope.ts → settings-scope.js} +3 -3
  106. package/src/helper/{toml.ts → toml.js} +48 -48
  107. package/src/install/{backup-set.ts → backup-set.js} +16 -16
  108. package/src/install/{backup.ts → backup.js} +65 -65
  109. package/src/install/{context.ts → context.js} +15 -15
  110. package/src/install/{manifest.ts → manifest.js} +27 -27
  111. package/src/install/{mcp.ts → mcp.js} +30 -30
  112. package/src/install/{paths.ts → paths.js} +15 -15
  113. package/src/install/{targets.ts → targets.js} +10 -10
  114. package/src/install/{tool-lifecycle.ts → tool-lifecycle.js} +20 -20
  115. package/src/lifecycle/{bash-availability.ts → bash-availability.js} +1 -1
  116. package/src/lifecycle/coverage.js +57 -0
  117. package/src/lifecycle/hook-step.js +61 -0
  118. package/src/lifecycle/{index.ts → index.js} +160 -146
  119. package/src/lifecycle/{mcp-index.ts → mcp-index.js} +1 -1
  120. package/src/lifecycle/{policies.ts → policies.js} +11 -11
  121. package/src/lifecycle/{view.ts → view.js} +28 -28
  122. package/src/registry/{index.ts → index.js} +195 -66
  123. package/src/release/build-package.js +91 -0
  124. package/src/release/changelog-section.js +70 -0
  125. package/src/release/{sync-plugin-versions.ts → sync-plugin-versions.js} +4 -4
  126. package/src/runtime/advice/{cli.ts → cli.js} +31 -31
  127. package/src/runtime/advice/{client.ts → client.js} +44 -44
  128. package/src/runtime/advice/{config.ts → config.js} +16 -16
  129. package/src/runtime/advice/{doctor.ts → doctor.js} +32 -32
  130. package/src/runtime/advice/{endpoint-guard.ts → endpoint-guard.js} +20 -20
  131. package/src/runtime/advice/{kinds.ts → kinds.js} +16 -16
  132. package/src/runtime/advice/providers/{ollama-systemone.ts → ollama-systemone.js} +21 -21
  133. package/src/runtime/advice/{record.ts → record.js} +19 -19
  134. package/src/runtime/advice/{registry.ts → registry.js} +16 -16
  135. package/src/runtime/advice/{strength.ts → strength.js} +7 -7
  136. package/src/runtime/{brief-tools.ts → brief-tools.js} +50 -50
  137. package/src/runtime/{c4-project.ts → c4-project.js} +27 -27
  138. package/src/runtime/candidate.js +505 -0
  139. package/src/runtime/{capability-router.ts → capability-router.js} +39 -39
  140. package/src/runtime/{checkouts.ts → checkouts.js} +21 -21
  141. package/src/runtime/{claims.ts → claims.js} +44 -35
  142. package/src/runtime/{cli-result.ts → cli-result.js} +15 -8
  143. package/src/runtime/{cli.ts → cli.js} +59 -292
  144. package/src/runtime/{command-detect.ts → command-detect.js} +47 -47
  145. package/src/runtime/context/budget.js +203 -0
  146. package/src/runtime/context/compose.js +1367 -0
  147. package/src/runtime/context/delivery-store.js +102 -0
  148. package/src/runtime/context/mandatory.js +594 -0
  149. package/src/runtime/context/pointer.js +142 -0
  150. package/src/runtime/context-pack.js +1277 -0
  151. package/src/runtime/{decision-register.ts → decision-register.js} +67 -67
  152. package/src/runtime/evidence-fields.js +39 -0
  153. package/src/runtime/{evidence-ledger.ts → evidence-ledger.js} +32 -25
  154. package/src/runtime/failure/{capture.ts → capture.js} +29 -29
  155. package/src/runtime/failure/{classifier.ts → classifier.js} +4 -4
  156. package/src/runtime/failure/{cli.ts → cli.js} +30 -30
  157. package/src/runtime/failure/{home.ts → home.js} +6 -6
  158. package/src/runtime/failure/{store.ts → store.js} +32 -32
  159. package/src/runtime/{feature-resolve.ts → feature-resolve.js} +6 -6
  160. package/src/runtime/{freshness.ts → freshness.js} +59 -22
  161. package/src/runtime/{health.ts → health.js} +72 -72
  162. package/src/runtime/{history-compactor.ts → history-compactor.js} +25 -25
  163. package/src/runtime/{implementation-gate.ts → implementation-gate.js} +30 -30
  164. package/src/runtime/inventory/{coverage.ts → coverage.js} +10 -10
  165. package/src/runtime/inventory/{identity.ts → identity.js} +2 -2
  166. package/src/runtime/inventory/{index.ts → index.js} +36 -36
  167. package/src/runtime/inventory/{precedence.ts → precedence.js} +2 -2
  168. package/src/runtime/inventory/{read-scopes.ts → read-scopes.js} +9 -9
  169. package/src/runtime/inventory/{siblings.ts → siblings.js} +15 -15
  170. package/src/runtime/knowledge/{chunker.ts → chunker.js} +12 -12
  171. package/src/runtime/knowledge/{hybrid.ts → hybrid.js} +17 -17
  172. package/src/runtime/knowledge/{index-store.ts → index-store.js} +23 -23
  173. package/src/runtime/knowledge/{retrieval.ts → retrieval.js} +27 -27
  174. package/src/runtime/{leak-scan.ts → leak-scan.js} +12 -12
  175. package/src/runtime/lifecycle/{cli.ts → cli.js} +32 -32
  176. package/src/runtime/lifecycle/{event-store.ts → event-store.js} +48 -48
  177. package/src/runtime/lifecycle/{fold.ts → fold.js} +41 -41
  178. package/src/runtime/lifecycle/{followup.ts → followup.js} +54 -54
  179. package/src/runtime/lifecycle/{goal.ts → goal.js} +50 -50
  180. package/src/runtime/lifecycle/{intent-writer.ts → intent-writer.js} +5 -5
  181. package/src/runtime/lifecycle/{overview.ts → overview.js} +47 -47
  182. package/src/runtime/lifecycle/{release.ts → release.js} +29 -29
  183. package/src/runtime/lifecycle/{report-store.ts → report-store.js} +25 -25
  184. package/src/runtime/lifecycle/{retention.ts → retention.js} +37 -37
  185. package/src/runtime/lifecycle/{root.ts → root.js} +9 -9
  186. package/src/runtime/lifecycle/{status.ts → status.js} +64 -64
  187. package/src/runtime/lifecycle/{store-upkeep.ts → store-upkeep.js} +46 -46
  188. package/src/runtime/{locator-resolve.ts → locator-resolve.js} +7 -7
  189. package/src/runtime/{mask.ts → mask.js} +22 -22
  190. package/src/runtime/{mcp-consultations.ts → mcp-consultations.js} +54 -54
  191. package/src/runtime/{mcp-reach.ts → mcp-reach.js} +24 -24
  192. package/src/runtime/{model-router.ts → model-router.js} +20 -20
  193. package/src/runtime/{outcome.ts → outcome.js} +27 -27
  194. package/src/runtime/{reach.ts → reach.js} +21 -21
  195. package/src/runtime/{readiness-record.ts → readiness-record.js} +12 -12
  196. package/src/runtime/{readiness.ts → readiness.js} +113 -43
  197. package/src/runtime/{recovery.ts → recovery.js} +15 -15
  198. package/src/runtime/{research-request.ts → research-request.js} +34 -34
  199. package/src/runtime/{retrieval-plan.ts → retrieval-plan.js} +453 -104
  200. package/src/runtime/retrieval-record.js +223 -0
  201. package/src/runtime/scaffold/{artifacts.ts → artifacts.js} +22 -19
  202. package/src/runtime/scaffold/{fingerprint.ts → fingerprint.js} +3 -3
  203. package/src/runtime/scaffold/{generate.ts → generate.js} +109 -109
  204. package/src/runtime/scaffold/{languages.ts → languages.js} +34 -34
  205. package/src/runtime/source-version.js +289 -0
  206. package/src/runtime/storage/{cli.ts → cli.js} +9 -9
  207. package/src/runtime/storage/{engine.ts → engine.js} +37 -37
  208. package/src/runtime/storage/{registry.ts → registry.js} +19 -19
  209. package/src/runtime/storage/{roots.ts → roots.js} +8 -8
  210. package/src/runtime/storage/{rules.ts → rules.js} +26 -26
  211. package/src/runtime/storage/{upkeep.ts → upkeep.js} +35 -35
  212. package/src/runtime/storage/{work-status.ts → work-status.js} +39 -39
  213. package/src/runtime/{task-attempts.ts → task-attempts.js} +62 -62
  214. package/src/runtime/{task-classifier.ts → task-classifier.js} +34 -34
  215. package/src/runtime/{task-scope.ts → task-scope.js} +40 -15
  216. package/src/runtime/{task-state.ts → task-state.js} +86 -21
  217. package/src/runtime/trace/{indicators.ts → indicators.js} +22 -22
  218. package/src/runtime/trace/{ledger.ts → ledger.js} +42 -42
  219. package/src/runtime/trace/{render.ts → render.js} +7 -7
  220. package/src/runtime/trace/{views.ts → views.js} +40 -40
  221. package/src/runtime/verification/{contract-runner.ts → contract-runner.js} +23 -23
  222. package/src/runtime/verification/{engine.ts → engine.js} +143 -143
  223. package/src/runtime/verification/{registry.ts → registry.js} +6 -6
  224. package/src/runtime/verification/{scope-bound.ts → scope-bound.js} +50 -50
  225. package/src/runtime/verification/{test-selection.ts → test-selection.js} +28 -28
  226. package/src/runtime/{workflow-engine.ts → workflow-engine.js} +50 -50
  227. package/src/runtime/{workflow-orchestrator.ts → workflow-orchestrator.js} +91 -91
  228. package/src/runtime/{worktree.ts → worktree.js} +31 -31
  229. package/src/state/{index.ts → index.js} +26 -26
  230. package/src/state/{lockfile.ts → lockfile.js} +20 -20
  231. package/src/state/{run-lock.ts → run-lock.js} +44 -44
  232. package/core/.antigravity-plugin/hooks.json +0 -39
  233. package/core/harnesses/shared/hooks/adapters/arg-fields.js +0 -19
  234. package/core/harnesses/shared/hooks/adapters/gemini.js +0 -114
  235. package/core/harnesses/shared/hooks/hooks.json +0 -47
  236. package/src/runtime/context-pack.ts +0 -492
@@ -14,8 +14,9 @@
14
14
  // if a third harness with the same shape is ever added.
15
15
  const fs = require('node:fs');
16
16
  const path = require('node:path');
17
- const { verifyHookCommands } = require('../hook-commands.ts');
18
- const { mergeHooks } = require('../../helper/settings-merge.ts');
17
+ const crypto = require('node:crypto');
18
+ const { verifyHookCommands } = require('../hook-commands.js');
19
+ const { mergeHooks } = require('../../helper/settings-merge.js');
19
20
 
20
21
  const SUPPORTED_EVENTS = new Set([
21
22
  'SessionStart', 'SessionEnd', 'BeforeAgent', 'AfterAgent', 'BeforeModel', 'AfterModel',
@@ -27,7 +28,7 @@ const SUPPORTED_EVENTS = new Set([
27
28
  // Stop, SubagentStart/Stop, and ConfigChange have no correct Gemini equivalent and are
28
29
  // intentionally omitted rather than approximated onto BeforeAgent/AfterAgent (design.md §7/§8 —
29
30
  // no false parity).
30
- const CLAUDE_TO_GEMINI_EVENT: Record<string, string> = {
31
+ const CLAUDE_TO_GEMINI_EVENT = {
31
32
  SessionStart: 'SessionStart',
32
33
  SessionEnd: 'SessionEnd',
33
34
  PreToolUse: 'BeforeTool',
@@ -35,10 +36,10 @@ const CLAUDE_TO_GEMINI_EVENT: Record<string, string> = {
35
36
  PreCompact: 'PreCompress',
36
37
  };
37
38
 
38
- function validationError(errors: string[], message: string): void { errors.push(message); }
39
+ function validationError(errors , message ) { errors.push(message); }
39
40
 
40
- function validateHooksConfig(config: any): { ok: boolean, errors: string[], events: string[] } {
41
- const errors: string[] = [];
41
+ function validateHooksConfig(config ) {
42
+ const errors = [];
42
43
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
43
44
  return { ok: false, errors: ['Hook configuration must be an object'], events: [] };
44
45
  }
@@ -54,7 +55,7 @@ function validateHooksConfig(config: any): { ok: boolean, errors: string[], even
54
55
  validationError(errors, `Hook event '${event}' requires a non-empty array of matcher groups`);
55
56
  continue;
56
57
  }
57
- groups.forEach((group: any, groupIndex: number) => {
58
+ groups.forEach((group , groupIndex ) => {
58
59
  if (!group || typeof group !== 'object' || Array.isArray(group)) {
59
60
  validationError(errors, `Hook event '${event}' group ${groupIndex} must be an object`);
60
61
  return;
@@ -66,7 +67,7 @@ function validateHooksConfig(config: any): { ok: boolean, errors: string[], even
66
67
  validationError(errors, `Hook event '${event}' group ${groupIndex} requires a non-empty hooks array`);
67
68
  return;
68
69
  }
69
- group.hooks.forEach((handler: any, handlerIndex: number) => {
70
+ group.hooks.forEach((handler , handlerIndex ) => {
70
71
  const label = `Hook event '${event}' group ${groupIndex} handler ${handlerIndex}`;
71
72
  if (!handler || typeof handler !== 'object' || Array.isArray(handler)) {
72
73
  validationError(errors, `${label} must be an object`); return;
@@ -85,9 +86,9 @@ function validateHooksConfig(config: any): { ok: boolean, errors: string[], even
85
86
  }
86
87
 
87
88
  /** Maps Claude guardrail intent, never fabricating a Gemini equivalent for unavailable events. */
88
- function classifyClaudeGuardrails(claudeHooks: any = {}): { supported: any[], unsupported: any[] } {
89
- const supported: any[] = [];
90
- const unsupported: any[] = [];
89
+ function classifyClaudeGuardrails(claudeHooks = {}) {
90
+ const supported = [];
91
+ const unsupported = [];
91
92
  for (const [event, groups] of Object.entries(claudeHooks || {})) {
92
93
  const targetEvent = CLAUDE_TO_GEMINI_EVENT[event] || null;
93
94
  const item = { sourceEvent: event, targetEvent, groups: Array.isArray(groups) ? groups.length : 0 };
@@ -103,58 +104,98 @@ function classifyClaudeGuardrails(claudeHooks: any = {}): { supported: any[], un
103
104
  * exists but fails to parse is a hard error, not silently treated as empty (this path merges
104
105
  * into content DoFlow does not own — a malformed-file work-around here risks losing it).
105
106
  */
106
- function planGeminiHooks({ config, sourceFile, sourceHooksDir, settingsFile, destinationHooksDir: declaredHooksDir, trusted = false, fsImpl = fs }: any = {}): any {
107
+ /** Every regular, non-JSON file in the shipped scripts directory: what deployGeminiHooks copies. */
108
+ function shippedScripts(scriptsDir , fsImpl = fs) {
109
+ if (!scriptsDir || !fsImpl.existsSync(scriptsDir)) return [];
110
+ return fsImpl.readdirSync(scriptsDir)
111
+ .filter((name ) => !name.endsWith('.json') && fsImpl.statSync(path.join(scriptsDir, name)).isFile());
112
+ }
113
+
114
+ /** DoFlow's record of a deployed hook script: the sha256 of its bytes. */
115
+ function scriptFingerprint(bytes ) { return crypto.createHash('sha256').update(bytes).digest('hex'); }
116
+
117
+ /** The scripts beside the config that differ from the shipped source (or are absent), by name, and
118
+ * those among them a ledger row says were edited since DoFlow wrote them. A script with no row
119
+ * (an install from before scripts were recorded) is DoFlow's to refresh: the config deploy always
120
+ * overwrote it. `rows` are this harness's script rows: { target, fingerprint }. */
121
+ function planHookScripts({ commands, sourceHooksDir, destinationHooksDir, rows = [], force = false, fsImpl = fs } ) {
122
+ const sources = new Map (commands.checks.map((check ) => [check.script, check.file] ));
123
+ for (const name of shippedScripts(sourceHooksDir, fsImpl)) if (!sources.has(name)) sources.set(name, path.join(sourceHooksDir, name));
124
+ const stale = [];
125
+ const edited = [];
126
+ for (const [name, source] of sources) {
127
+ const target = path.join(destinationHooksDir, name);
128
+ if (fsImpl.existsSync(target)) {
129
+ const current = fsImpl.readFileSync(target);
130
+ if (current.equals(fsImpl.readFileSync(source))) continue;
131
+ const row = rows.find((item ) => path.resolve(item.target) === path.resolve(target));
132
+ if (row && row.fingerprint !== scriptFingerprint(current) && !force) edited.push(name);
133
+ }
134
+ stale.push(name);
135
+ }
136
+ return { names: [...sources.keys()], stale, edited };
137
+ }
138
+
139
+ function planGeminiHooks({ config, sourceFile, sourceHooksDir, settingsFile, destinationHooksDir: declaredHooksDir, trusted = false, scriptRows = [], force = false, fsImpl = fs } = {}) {
107
140
  let desired = config;
108
141
  try {
109
142
  if (!desired && sourceFile) desired = JSON.parse(fsImpl.readFileSync(sourceFile, 'utf8'));
110
- } catch (error: any) {
143
+ } catch (error ) {
111
144
  return { ok: false, status: 'malformed', changes: [], errors: [`Could not parse ${sourceFile}: ${error.message}`] };
112
145
  }
113
146
  if (!settingsFile) return { ok: false, status: 'invalid-request', changes: [], errors: ['settingsFile is required'] };
114
147
  const schema = validateHooksConfig(desired);
115
148
  const commands = verifyHookCommands(desired || {}, { scriptsDir: sourceHooksDir, trusted, fsImpl });
116
- const errors = [...schema.errors, ...commands.checks.filter((check: any) => !check.ok).map((check: any) => `${check.event}: ${check.reason}${check.script ? ` (${check.script})` : ''}`)];
149
+ const errors = [...schema.errors, ...commands.checks.filter((check ) => !check.ok).map((check ) => `${check.event}: ${check.reason}${check.script ? ` (${check.script})` : ''}`)];
117
150
 
118
- let existing: any = {};
151
+ let existing = {};
119
152
  if (fsImpl.existsSync(settingsFile)) {
120
153
  try { existing = JSON.parse(fsImpl.readFileSync(settingsFile, 'utf8')); }
121
- catch (error: any) { return { ok: false, status: 'malformed', changes: [], errors: [`Refusing to touch malformed ${settingsFile}: ${error.message}`] }; }
154
+ catch (error ) { return { ok: false, status: 'malformed', changes: [], errors: [`Refusing to touch malformed ${settingsFile}: ${error.message}`] }; }
122
155
  }
123
156
  if (errors.length) return { ok: false, status: 'invalid-request', settingsFile, existing, changes: [], errors, commands };
124
157
 
125
158
  const mergedHooks = existing.hooks ? mergeHooks(existing.hooks, desired.hooks) : desired.hooks;
126
159
  const merged = { ...existing, hooks: mergedHooks };
127
- const changed = JSON.stringify(existing.hooks) !== JSON.stringify(mergedHooks);
160
+ const configChanged = JSON.stringify(existing.hooks) !== JSON.stringify(mergedHooks);
128
161
  // Preferred: the declared hooks directory (harnesses.json "paths".hooksDirectory); the derived
129
162
  // sibling-of-settings fallback stays for direct callers passing raw inputs, and resolves to the
130
163
  // same location.
131
164
  const destinationHooksDir = declaredHooksDir || path.join(path.dirname(settingsFile), 'hooks');
165
+ // The scripts are deployed with the hooks key but are not part of it: a script whose bytes differ
166
+ // from the source is redeployed even when the key is already right.
167
+ const scripts = planHookScripts({ commands, sourceHooksDir, destinationHooksDir, rows: scriptRows, force, fsImpl });
168
+ if (scripts.edited.length) {
169
+ return { ok: false, status: 'conflict', settingsFile, existing, changes: [], commands,
170
+ errors: scripts.edited.map((name ) => `Gemini hook script '${name}' was modified outside DoFlow`) };
171
+ }
172
+ const scriptsOnly = !configChanged && scripts.stale.length > 0;
173
+ const changed = configChanged || scriptsOnly;
132
174
  // The scripts deployGeminiHooks copies beside settings.json, so a backup of this change holds them too.
133
- const shipped = sourceHooksDir && fsImpl.existsSync(sourceHooksDir)
134
- ? fsImpl.readdirSync(sourceHooksDir).filter((name: string) => !name.endsWith('.json') && fsImpl.statSync(path.join(sourceHooksDir, name)).isFile())
135
- : [];
136
175
  const companionTargets = [...new Set([
137
- ...commands.checks.map((check: any) => path.join(destinationHooksDir, check.script)),
138
- ...shipped.map((name: string) => path.join(destinationHooksDir, name)),
176
+ ...commands.checks.map((check ) => path.join(destinationHooksDir, check.script)),
177
+ ...shippedScripts(sourceHooksDir, fsImpl).map((name ) => path.join(destinationHooksDir, name)),
139
178
  ])].sort();
140
- return { ok: true, status: changed ? 'change' : 'unchanged', settingsFile, existing, merged,
179
+ return { ok: true, status: changed ? 'change' : 'unchanged', settingsFile, existing, merged, scriptsOnly, scriptNames: scripts.names,
141
180
  changes: changed ? [{ type: fsImpl.existsSync(settingsFile) ? 'update' : 'create', file: settingsFile, key: 'hooks' }] : [],
142
181
  errors: [], commands, trust: commands.trust, scriptsDir: sourceHooksDir, destinationHooksDir, companionTargets };
143
182
  }
144
183
 
145
- function deployGeminiHooks(plan: any, { dryRun = false, fsImpl = fs }: { dryRun?: boolean, fsImpl?: any } = {}): any {
184
+ function deployGeminiHooks(plan , { dryRun = false, fsImpl = fs } = {}) {
146
185
  if (!plan.ok) return { ...plan, applied: false };
147
186
  if (dryRun || plan.status === 'unchanged') return { ...plan, applied: false };
148
- fsImpl.mkdirSync(path.dirname(plan.settingsFile), { recursive: true });
149
- const content = `${JSON.stringify(plan.merged, null, 2)}\n`;
150
- const temporary = path.join(path.dirname(plan.settingsFile), `.${path.basename(plan.settingsFile)}.${process.pid}.${Date.now()}.tmp`);
151
- try {
152
- fsImpl.writeFileSync(temporary, content, { flag: 'wx' });
153
- fsImpl.renameSync(temporary, plan.settingsFile);
154
- } finally {
155
- if (fsImpl.existsSync(temporary)) fsImpl.unlinkSync(temporary);
187
+ if (!plan.scriptsOnly) {
188
+ fsImpl.mkdirSync(path.dirname(plan.settingsFile), { recursive: true });
189
+ const content = `${JSON.stringify(plan.merged, null, 2)}\n`;
190
+ const temporary = path.join(path.dirname(plan.settingsFile), `.${path.basename(plan.settingsFile)}.${process.pid}.${Date.now()}.tmp`);
191
+ try {
192
+ fsImpl.writeFileSync(temporary, content, { flag: 'wx' });
193
+ fsImpl.renameSync(temporary, plan.settingsFile);
194
+ } finally {
195
+ if (fsImpl.existsSync(temporary)) fsImpl.unlinkSync(temporary);
196
+ }
156
197
  }
157
- const deployed = new Set<string>();
198
+ const deployed = new Set ();
158
199
  for (const check of plan.commands.checks) {
159
200
  const target = path.join(plan.destinationHooksDir, check.script);
160
201
  fsImpl.mkdirSync(path.dirname(target), { recursive: true });
@@ -182,18 +223,18 @@ function deployGeminiHooks(plan: any, { dryRun = false, fsImpl = fs }: { dryRun?
182
223
  * cross-check): if the key isn't present, there's nothing to do; otherwise propose removing only
183
224
  * that key, leaving every other key in the shared file untouched (FR-002).
184
225
  */
185
- function planRemoveGeminiHooks({ settingsFile, fsImpl = fs }: { settingsFile?: string, fsImpl?: any } = {}): any {
226
+ function planRemoveGeminiHooks({ settingsFile, fsImpl = fs } = {}) {
186
227
  if (!settingsFile) return { ok: false, status: 'invalid-request', changes: [], errors: ['settingsFile is required'] };
187
228
  if (!fsImpl.existsSync(settingsFile)) return { ok: true, status: 'unchanged', settingsFile, changes: [] };
188
229
  let existing;
189
230
  try { existing = JSON.parse(fsImpl.readFileSync(settingsFile, 'utf8')); }
190
- catch (error: any) { return { ok: false, status: 'malformed', changes: [], errors: [`Could not parse ${settingsFile}: ${error.message}`] }; }
231
+ catch (error ) { return { ok: false, status: 'malformed', changes: [], errors: [`Could not parse ${settingsFile}: ${error.message}`] }; }
191
232
  if (existing.hooks === undefined) return { ok: true, status: 'unchanged', settingsFile, changes: [] };
192
233
  const { hooks: _dropped, ...merged } = existing;
193
234
  return { ok: true, status: 'change', settingsFile, merged, changes: [{ type: 'update', file: settingsFile, key: 'hooks', operation: 'remove' }] };
194
235
  }
195
236
 
196
- function deployRemoveGeminiHooks(plan: any, { dryRun = false, fsImpl = fs }: { dryRun?: boolean, fsImpl?: any } = {}): any {
237
+ function deployRemoveGeminiHooks(plan , { dryRun = false, fsImpl = fs } = {}) {
197
238
  if (!plan.ok || plan.status !== 'change' || dryRun) return { ...plan, applied: false };
198
239
  fsImpl.mkdirSync(path.dirname(plan.settingsFile), { recursive: true });
199
240
  const content = `${JSON.stringify(plan.merged, null, 2)}\n`;
@@ -207,4 +248,4 @@ function deployRemoveGeminiHooks(plan: any, { dryRun = false, fsImpl = fs }: { d
207
248
  return { ...plan, applied: true };
208
249
  }
209
250
 
210
- module.exports = { SUPPORTED_EVENTS, validateHooksConfig, classifyClaudeGuardrails, verifyHookCommands, planGeminiHooks, deployGeminiHooks, planRemoveGeminiHooks, deployRemoveGeminiHooks };
251
+ module.exports = { SUPPORTED_EVENTS, validateHooksConfig, classifyClaudeGuardrails, verifyHookCommands, planGeminiHooks, deployGeminiHooks, scriptFingerprint, shippedScripts, planRemoveGeminiHooks, deployRemoveGeminiHooks };
@@ -7,9 +7,9 @@
7
7
  // settings/MCP/extensions as first-class native-surface results for the lifecycle UI.
8
8
  const fs = require('node:fs');
9
9
  const path = require('node:path');
10
- const { planTree, applyTree, removeTree, verifyTree, copyTreeAssets, copyTreeDestDir, ledgerFileResources, ledgerSiblingFingerprints, siblingReplacedNotices, fingerprint, readJson, sourceDirFor, resolveTransform } = require('../copy-tree.ts');
11
- const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harness-paths.ts');
12
- const { planGeminiHooks, deployGeminiHooks, planRemoveGeminiHooks, deployRemoveGeminiHooks } = require('./hooks.ts');
10
+ const { planTree, applyTree, removeTree, verifyTree, copyTreeAssets, copyTreeDestDir, ledgerFileResources, ledgerSiblingFingerprints, siblingReplacedNotices, fingerprint, readJson, sourceDirFor, resolveTransform } = require('../copy-tree.js');
11
+ const { declaredHarnessPaths, resolveHarnessPaths } = require('../../helper/harness-paths.js');
12
+ const { planGeminiHooks, deployGeminiHooks, planRemoveGeminiHooks, deployRemoveGeminiHooks, scriptFingerprint } = require('./hooks.js');
13
13
 
14
14
  const MARKER_START = '<!-- doflow:start -->';
15
15
  const MARKER_END = '<!-- doflow:end -->';
@@ -22,12 +22,12 @@ const MARKER_END = '<!-- doflow:end -->';
22
22
  const DEFAULTS_FILE = path.resolve(__dirname, '../../../core/harnesses/gemini/settings/adapter-defaults.json');
23
23
  const HARNESS = 'gemini';
24
24
 
25
- function defaults({ fsImpl = fs }: any = {}) { return JSON.parse(fsImpl.readFileSync(DEFAULTS_FILE, 'utf8')); }
25
+ function defaults({ fsImpl = fs } = {}) { return JSON.parse(fsImpl.readFileSync(DEFAULTS_FILE, 'utf8')); }
26
26
 
27
27
  /** Pure string helper (no path facts): replace only the span between DoFlow's markers in an
28
28
  * existing GEMINI.md body. An existing file with no DoFlow section is refused rather than appended
29
29
  * to — GEMINI.md is shared with the user and other tools. */
30
- function managedInstruction(existing: any, rendered: any) {
30
+ function managedInstruction(existing , rendered ) {
31
31
  if (existing === null) return { ok: true, operation: 'create', content: rendered };
32
32
  const start = existing.indexOf(MARKER_START);
33
33
  const end = existing.indexOf(MARKER_END);
@@ -38,7 +38,7 @@ function managedInstruction(existing: any, rendered: any) {
38
38
  }
39
39
 
40
40
  /** Server names in one settings read, or null when the file is absent or unreadable. */
41
- function settingsServerNames(read: any) {
41
+ function settingsServerNames(read ) {
42
42
  if (!read.exists || read.error || !read.value || typeof read.value !== 'object') return null;
43
43
  const servers = read.value.mcpServers;
44
44
  return Object.keys(servers && typeof servers === 'object' ? servers : {});
@@ -50,7 +50,7 @@ function settingsServerNames(read: any) {
50
50
  * neither can be read. Gemini also merges system settings and extension servers, which DoFlow does
51
51
  * not read, so the view is never complete (`mcpComplete: false`) and absence is never proof.
52
52
  */
53
- function configuredGeminiMcp({ scope, settings, homeDir, fsImpl }: any) {
53
+ function configuredGeminiMcp({ scope, settings, homeDir, fsImpl } ) {
54
54
  const own = settingsServerNames(settings);
55
55
  const user = scope === 'project' && homeDir ? settingsServerNames(readJson(path.join(homeDir, '.gemini', 'settings.json'), { fsImpl })) : null;
56
56
  const mcpConfigured = own || user ? [...new Set([...(own || []), ...(user || [])])].sort() : null;
@@ -66,13 +66,13 @@ function configuredGeminiMcp({ scope, settings, homeDir, fsImpl }: any) {
66
66
  * historical shape.
67
67
  */
68
68
  function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] } = {}) {
69
- function nativePaths({ scope, scopeRoot, homeDir, fsImpl = fs }: any) {
69
+ function nativePaths({ scope, scopeRoot, homeDir, fsImpl = fs } ) {
70
70
  const resolved = resolveHarnessPaths(declaredPaths, { scope, scopeRoot, homeDir });
71
71
  // Non-path native fact carried alongside the resolved surfaces (see DEFAULTS_FILE residue note).
72
72
  return { ...resolved, extensionMode: defaults({ fsImpl }).extensionInstall };
73
73
  }
74
74
 
75
- function discover({ scope, scopeRoot, context = {}, fsImpl = fs }: any) {
75
+ function discover({ scope, scopeRoot, context = {}, fsImpl = fs } ) {
76
76
  const paths = nativePaths({ scope, scopeRoot, homeDir: context.homeDir, fsImpl });
77
77
  const instruction = fsImpl.existsSync(paths.instruction) ? fsImpl.readFileSync(paths.instruction, 'utf8') : null;
78
78
  // Gemini takes no servers from DoFlow's MCP catalog.
@@ -81,7 +81,7 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
81
81
  return { paths, instruction, settings, mcpOwned: [], mcpConfigured, mcpComplete };
82
82
  }
83
83
 
84
- function render({ content = '' }: any = {}) {
84
+ function render({ content = '' } = {}) {
85
85
  const body = String(content).trimEnd();
86
86
  return `${MARKER_START}\n${body}\n${MARKER_END}\n`;
87
87
  }
@@ -99,7 +99,7 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
99
99
  * (instruction vs. the guidance tree), not on any single declared path — expressible only by
100
100
  * joining and diffing, which is computation, not declaration.
101
101
  */
102
- function geminiDestDir(paths: any, asset: any) {
102
+ function geminiDestDir(paths , asset ) {
103
103
  if (asset.nativeDir && asset.nativeDir.startsWith('../')) {
104
104
  const dest = path.join(paths.root, asset.nativeDir.slice(3));
105
105
  const rel = path.relative(paths.root, dest);
@@ -111,8 +111,8 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
111
111
  return copyTreeDestDir(paths.configDir, asset);
112
112
  }
113
113
 
114
- function rewriteGuidanceImport(content: any, assets: any, paths: any) {
115
- const guidance = assets.find((item: any) => item.id === 'guidance.context-layer');
114
+ function rewriteGuidanceImport(content , assets , paths ) {
115
+ const guidance = assets.find((item ) => item.id === 'guidance.context-layer');
116
116
  if (!guidance?.nativeDir || !paths?.instruction || !paths?.configDir) return content;
117
117
  const guidanceDir = geminiDestDir(paths, guidance);
118
118
  const rel = path.relative(path.dirname(paths.instruction), guidanceDir).split(path.sep).join('/');
@@ -123,22 +123,22 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
123
123
  return content.replace(/@\S*?(DOFLOW_CORE\.md)/g, `@${prefix}/$1`);
124
124
  }
125
125
 
126
- function instructionContent({ assets, context, paths, fsImpl = fs }: any) {
126
+ function instructionContent({ assets, context, paths, fsImpl = fs } ) {
127
127
  if (context.instructionContent !== undefined) return String(context.instructionContent);
128
- const asset = assets.find((item: any) => item.id === 'guidance.core');
128
+ const asset = assets.find((item ) => item.id === 'guidance.core');
129
129
  if (!asset || !context.repoRoot) return null;
130
130
  const source = path.resolve(context.repoRoot, asset.source);
131
131
  if (!fsImpl.existsSync(source)) return null;
132
132
  return rewriteGuidanceImport(fsImpl.readFileSync(source, 'utf8'), assets, paths);
133
133
  }
134
134
 
135
- function policyStatuses(policies: any[] = []) {
136
- return policies.map((policy: any) => ({ id: policy.id, status: 'unavailable', fallback: 'guidance', reason: 'Gemini policy automation is not rendered by this adapter' }));
135
+ function policyStatuses(policies = []) {
136
+ return policies.map((policy ) => ({ id: policy.id, status: 'unavailable', fallback: 'guidance', reason: 'Gemini policy automation is not rendered by this adapter' }));
137
137
  }
138
138
 
139
139
  // ---- copy-tree assets (rules, skills, agents, modes, references) ----
140
140
 
141
- function planCopyTreeAssets({ assets, scope, scopeRoot, context, ledger, removing, fsImpl = fs }: any) {
141
+ function planCopyTreeAssets({ assets, scope, scopeRoot, context, ledger, removing, fsImpl = fs } ) {
142
142
  const paths = nativePaths({ scope, scopeRoot, homeDir: context.homeDir, fsImpl });
143
143
  const changes = [];
144
144
  const conflicts = [];
@@ -156,10 +156,10 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
156
156
  // Found by the guard written for the six adapters that forwarded nothing.
157
157
  force: !removing && context?.force === true });
158
158
  treeResults.push(result);
159
- conflicts.push(...result.conflicts.map((reason: any) => `${asset.id}: ${reason}`));
159
+ conflicts.push(...result.conflicts.map((reason ) => `${asset.id}: ${reason}`));
160
160
  // A hand-edited file at a path DoFlow no longer writes (the agents folder layout) is the
161
161
  // user's: it stays, only its ownership row is released.
162
- notices.push(...result.kept.map((item: any) => `kept hand-edited ${path.relative(paths.root, item.target)}; DoFlow no longer manages it`));
162
+ notices.push(...result.kept.map((item ) => `kept hand-edited ${path.relative(paths.root, item.target)}; DoFlow no longer manages it`));
163
163
  for (const change of result.changes) {
164
164
  changes.push({
165
165
  assetId: asset.id, target: change.target, source: change.source, operation: change.operation,
@@ -175,7 +175,7 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
175
175
  return { changes, conflicts, notices: [...siblingReplacedNotices(treeResults), ...notices] };
176
176
  }
177
177
 
178
- function applyCopyTreeAssets(changes: any, { fsImpl = fs }: any = {}) {
178
+ function applyCopyTreeAssets(changes , { fsImpl = fs } = {}) {
179
179
  let applied = 0;
180
180
  const grouped = new Map();
181
181
  for (const change of changes) {
@@ -190,13 +190,13 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
190
190
  return applied;
191
191
  }
192
192
 
193
- function removeCopyTreeAssets(changes: any, { fsImpl = fs }: any = {}) {
194
- const treeChanges = changes.filter((change: any) => change.projection?.renderer === 'copy-tree' && change.operation === 'remove' && !change.retained)
195
- .map((change: any) => ({ relPath: change.identity, target: change.target, operation: 'remove', fingerprint: change.fingerprint, pruneGuard: change.pruneGuard }));
193
+ function removeCopyTreeAssets(changes , { fsImpl = fs } = {}) {
194
+ const treeChanges = changes.filter((change ) => change.projection?.renderer === 'copy-tree' && change.operation === 'remove' && !change.retained)
195
+ .map((change ) => ({ relPath: change.identity, target: change.target, operation: 'remove', fingerprint: change.fingerprint, pruneGuard: change.pruneGuard }));
196
196
  return removeTree({ changes: treeChanges, fsImpl }).removed;
197
197
  }
198
198
 
199
- function verifyCopyTreeAssets({ assets, scope, scopeRoot, context, fsImpl = fs }: any) {
199
+ function verifyCopyTreeAssets({ assets, scope, scopeRoot, context, fsImpl = fs } ) {
200
200
  const paths = nativePaths({ scope, scopeRoot, homeDir: context.homeDir, fsImpl });
201
201
  const statuses = [];
202
202
  const resources = [];
@@ -205,7 +205,7 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
205
205
  const destDir = geminiDestDir(paths, asset);
206
206
  const sourceDir = sourceDirFor(asset, context, fsImpl, 'Gemini');
207
207
  const result = verifyTree({ sourceDir, destDir, fsImpl, layout: asset.layout, transform: asset.transform, plannedWrites: context?.plannedWrites });
208
- conflicts.push(...result.conflicts.map((reason: any) => `${asset.id}: ${reason}`));
208
+ conflicts.push(...result.conflicts.map((reason ) => `${asset.id}: ${reason}`));
209
209
  for (const resource of result.resources) {
210
210
  resources.push({
211
211
  assetId: asset.id, target: resource.target, ownershipIdentity: `doflow:gemini:copy-tree:${asset.id}:${resource.relPath}`,
@@ -225,21 +225,36 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
225
225
  // integration) — the lifecycle layer's normalizeChange() requires every change's assetId to
226
226
  // match a real registered asset, so this piggybacks on guidance.core's id the same way Codex's
227
227
  // assetIdFor(options, 'hooks') does, rather than inventing an unregistered 'hooks' id.
228
- function hooksAssetId(assets: any) {
229
- return assets.find((asset: any) => asset.id === 'guidance.core')?.id ?? assets[0]?.id;
228
+ function hooksAssetId(assets ) {
229
+ return assets.find((asset ) => asset.id === 'guidance.core')?.id ?? assets[0]?.id;
230
+ }
231
+
232
+ /** This scope's ledger rows for the hook scripts deployed beside settings.json. */
233
+ function scriptRows(ledger , scope ) {
234
+ return (ledger?.resources || []).filter((row ) => row?.harness === 'gemini' && row.kind === 'hooks-script' && (!row.scope || row.scope === scope));
230
235
  }
231
236
 
232
237
  /** Plan the hooks pseudo-component's single change (or none), mirroring the {changes, conflicts}
233
238
  * shape planCopyTreeAssets already returns so plan() can fold it in the same way. The hook
234
239
  * scripts directory beside settings.json is declared (paths.hooksDirectory); the merge/remove
235
- * planning itself lives in ./hooks.ts. */
236
- function planHooksChange({ removing, found, context, assets, fsImpl }: any) {
240
+ * planning itself lives in ./hooks.ts. A removal also releases the rows of the hook scripts. */
241
+ function planHooksChange({ removing, found, context, assets, ledger, scope, fsImpl } ) {
242
+ const component = planHooksComponentChange({ removing, found, context, assets, ledger, scope, fsImpl });
243
+ if (!removing) return component;
244
+ const releases = scriptRows(ledger, scope).map((row ) => ({ assetId: row.assetId, target: row.target, operation: 'remove',
245
+ ownershipIdentity: row.ownershipIdentity, kind: row.kind, identity: row.identity, projection: row.projection,
246
+ retained: true, retainedFor: [] }));
247
+ return { ...component, changes: [...component.changes, ...releases] };
248
+ }
249
+
250
+ function planHooksComponentChange({ removing, found, context, assets, ledger, scope, fsImpl } ) {
237
251
  if (found.settings.error) return { changes: [], conflicts: [] };
238
252
  const hooksPlan = removing
239
253
  ? planRemoveGeminiHooks({ settingsFile: found.paths.settings, fsImpl })
240
254
  : (context.geminiHooksSourceFile
241
255
  ? planGeminiHooks({ sourceFile: context.geminiHooksSourceFile, sourceHooksDir: context.geminiHooksSourceDir,
242
- settingsFile: found.paths.settings, destinationHooksDir: found.paths.hooksDirectory, trusted: context.hooksTrusted, fsImpl })
256
+ settingsFile: found.paths.settings, destinationHooksDir: found.paths.hooksDirectory, trusted: context.hooksTrusted,
257
+ scriptRows: scriptRows(ledger, scope), force: context.force === true, fsImpl })
243
258
  : null);
244
259
  if (hooksPlan && !hooksPlan.ok) return { changes: [], conflicts: hooksPlan.errors || [] };
245
260
  if (hooksPlan?.status !== 'change') return { changes: [], conflicts: [] };
@@ -248,7 +263,7 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
248
263
  companionTargets: removing ? [] : (hooksPlan.companionTargets ?? []) }], conflicts: [] };
249
264
  }
250
265
 
251
- function plan({ scope, scopeRoot, assets = [], mcp = [], discovery, context = {}, ledger, fsImpl = fs }: any) {
266
+ function plan({ scope, scopeRoot, assets = [], mcp = [], discovery, context = {}, ledger, fsImpl = fs } ) {
252
267
  const found = discovery || discover({ scope, scopeRoot, context, fsImpl });
253
268
  const changes = []; const conflicts = [];
254
269
  const removing = context.operation === 'remove';
@@ -256,7 +271,7 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
256
271
  // Removal only ever targets a section this adapter itself owns — a foreign or absent
257
272
  // GEMINI.md yields no change, never a forced deletion of user content.
258
273
  const owns = found.instruction !== null && found.instruction.includes(MARKER_START) && found.instruction.includes(MARKER_END);
259
- if (assets.some((asset: any) => asset.id === 'guidance.core') && owns) {
274
+ if (assets.some((asset ) => asset.id === 'guidance.core') && owns) {
260
275
  changes.push({ assetId: 'guidance.core', target: found.paths.instruction, operation: 'remove', ownershipIdentity: 'gemini:instructions', projection: { renderer: 'gemini-instructions' } });
261
276
  }
262
277
  } else {
@@ -270,21 +285,21 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
270
285
  const copyTree = planCopyTreeAssets({ assets, scope, scopeRoot, context, ledger, removing, fsImpl });
271
286
  changes.push(...copyTree.changes); conflicts.push(...copyTree.conflicts);
272
287
  if (found.settings.error) conflicts.push(found.settings.error);
273
- const hooks = planHooksChange({ removing, found, context, assets, fsImpl });
288
+ const hooks = planHooksChange({ removing, found, context, assets, ledger, scope, fsImpl });
274
289
  changes.push(...hooks.changes); conflicts.push(...hooks.conflicts);
275
290
  return {
276
291
  changes, conflicts, prerequisites: [], notices: copyTree.notices,
277
292
  surfaces: {
278
293
  instructions: { status: 'supported', target: found.paths.instruction },
279
294
  settings: { status: found.settings.error ? 'invalid' : 'supported', target: found.paths.settings },
280
- mcp: { status: found.settings.error ? 'blocked' : 'supported', target: found.paths.settings, selected: mcp.map((item: any) => item.id) },
295
+ mcp: { status: found.settings.error ? 'blocked' : 'supported', target: found.paths.settings, selected: mcp.map((item ) => item.id) },
281
296
  extensions: { status: 'different', mode: found.paths.extensionMode, action: 'use Gemini CLI extension management' },
282
297
  policies: policyStatuses(context.policies),
283
298
  },
284
299
  };
285
300
  }
286
301
 
287
- function atomicWrite(file: any, content: any, { fsImpl = fs }: any = {}) {
302
+ function atomicWrite(file , content , { fsImpl = fs } = {}) {
288
303
  fsImpl.mkdirSync(path.dirname(file), { recursive: true });
289
304
  const temp = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
290
305
  try {
@@ -294,9 +309,9 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
294
309
  if (fsImpl.existsSync(temp)) fsImpl.rmSync(temp, { force: true });
295
310
  }
296
311
  }
297
- function apply({ changes = [], fsImpl = fs }: any) {
312
+ function apply({ changes = [], fsImpl = fs } ) {
298
313
  for (const change of changes) if (change.content !== undefined) atomicWrite(change.target, change.content, { fsImpl });
299
- const hooksChange = changes.find((change: any) => change.nativeComponent === 'hooks');
314
+ const hooksChange = changes.find((change ) => change.nativeComponent === 'hooks');
300
315
  if (hooksChange?.nativePlan) deployGeminiHooks(hooksChange.nativePlan, { fsImpl });
301
316
  applyCopyTreeAssets(changes, { fsImpl });
302
317
  // An update that relocates an asset (the agents folder layout -> flat files) carries the old
@@ -307,7 +322,7 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
307
322
  /** Strip only the DoFlow-managed span, exactly like the Claude adapter's removeManagedSection —
308
323
  * a file with user content before/after the managed section must keep that content. Only delete
309
324
  * the file outright once nothing but the managed section (and its separator) remains. */
310
- function removeManagedSection(file: any, { fsImpl = fs }: any = {}) {
325
+ function removeManagedSection(file , { fsImpl = fs } = {}) {
311
326
  if (!fsImpl.existsSync(file)) return false;
312
327
  const existing = fsImpl.readFileSync(file, 'utf8');
313
328
  const start = existing.indexOf(MARKER_START);
@@ -324,38 +339,62 @@ function createGeminiAdapter({ declaredPaths = declaredHarnessPaths()[HARNESS] }
324
339
  else fsImpl.writeFileSync(file, next);
325
340
  return true;
326
341
  }
327
- function remove({ changes = [], fsImpl = fs }: any) {
342
+ function remove({ changes = [], fsImpl = fs } ) {
328
343
  for (const change of changes) if (change.target && change.projection?.renderer === 'gemini-instructions') removeManagedSection(change.target, { fsImpl });
329
- const hooksChange = changes.find((change: any) => change.nativeComponent === 'hooks');
344
+ const hooksChange = changes.find((change ) => change.nativeComponent === 'hooks');
330
345
  if (hooksChange?.nativePlan) deployRemoveGeminiHooks(hooksChange.nativePlan, { fsImpl });
331
346
  removeCopyTreeAssets(changes, { fsImpl });
332
347
  }
333
348
 
334
- function verify({ scope, scopeRoot, assets = [], context = {}, fsImpl = fs }: any) {
349
+ /** Whether the hooks pseudo-component is on disk as the source declares it, with the resource that
350
+ * records it when so. Null status when the run carries no hooks source or settings.json is unreadable. */
351
+ function verifyHooksComponent({ found, assets, context, fsImpl } ) {
352
+ if (!context.geminiHooksSourceFile || found.settings.error) return { hooksStatus: null, resources: [], conflicts: [] };
353
+ const hooksPlan = planGeminiHooks({ sourceFile: context.geminiHooksSourceFile, sourceHooksDir: context.geminiHooksSourceDir,
354
+ settingsFile: found.paths.settings, destinationHooksDir: found.paths.hooksDirectory, trusted: context.hooksTrusted, fsImpl });
355
+ const hooksStatus = hooksPlan.ok && hooksPlan.status === 'unchanged' ? 'managed' : (hooksPlan.ok ? 'missing' : 'conflict');
356
+ const resources = hooksStatus === 'managed' ? [{ assetId: hooksAssetId(assets), target: hooksPlan.settingsFile, ownershipIdentity: 'gemini:hooks',
357
+ fingerprint: fingerprint(hooksPlan.merged.hooks), sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'gemini-hooks' } },
358
+ ...hooksPlan.scriptNames.map((identity ) => {
359
+ const target = path.join(hooksPlan.destinationHooksDir, identity);
360
+ return { assetId: hooksAssetId(assets), target, ownershipIdentity: `gemini:hooks-script:${identity}`, kind: 'hooks-script', identity,
361
+ fingerprint: scriptFingerprint(fs.readFileSync(target)), sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'gemini-hooks' } };
362
+ })] : [];
363
+ return { hooksStatus, resources, conflicts: hooksPlan.ok ? [] : (hooksPlan.errors || []) };
364
+ }
365
+
366
+ /** The hook front doors (IC-037): the hooks pseudo-component alone. */
367
+ function planHookDoors({ scope, scopeRoot, assets = [], discovery, context = {}, ledger, fsImpl = fs } ) {
368
+ const found = discovery || discover({ scope, scopeRoot, context, fsImpl });
369
+ const hooks = planHooksChange({ removing: false, found, context, assets, ledger, scope, fsImpl });
370
+ return { changes: hooks.changes, conflicts: [...(found.settings.error ? [found.settings.error] : []), ...hooks.conflicts], prerequisites: [], notices: [] };
371
+ }
372
+
373
+ function verifyHookDoors({ scope, scopeRoot, assets = [], context = {}, fsImpl = fs } ) {
374
+ const found = discover({ scope, scopeRoot, context, fsImpl });
375
+ const hooks = verifyHooksComponent({ found, assets, context, fsImpl });
376
+ const conflicts = [...(found.settings.error ? [found.settings.error] : []), ...hooks.conflicts];
377
+ return { ok: conflicts.length === 0 && hooks.hooksStatus !== 'conflict', resources: hooks.resources, statuses: { hooks: hooks.hooksStatus }, conflicts };
378
+ }
379
+
380
+ function verify({ scope, scopeRoot, assets = [], context = {}, fsImpl = fs } ) {
335
381
  const found = discover({ scope, scopeRoot, context, fsImpl });
336
382
  const resources = [];
337
- if (assets.some((asset: any) => asset.id === 'guidance.core') && found.instruction?.includes(MARKER_START) && found.instruction.includes(MARKER_END)) {
383
+ if (assets.some((asset ) => asset.id === 'guidance.core') && found.instruction?.includes(MARKER_START) && found.instruction.includes(MARKER_END)) {
338
384
  resources.push({ assetId: 'guidance.core', target: found.paths.instruction, ownershipIdentity: 'gemini:instructions', fingerprint: fingerprint(found.instruction), sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'gemini-instructions' } });
339
385
  }
340
386
  const copyTree = verifyCopyTreeAssets({ assets, scope, scopeRoot, context, fsImpl });
341
387
  resources.push(...copyTree.resources);
342
- let hooksStatus = null; const conflicts = [...(found.settings.error ? [found.settings.error] : []), ...copyTree.conflicts];
343
- if (context.geminiHooksSourceFile && !found.settings.error) {
344
- const hooksPlan = planGeminiHooks({ sourceFile: context.geminiHooksSourceFile, sourceHooksDir: context.geminiHooksSourceDir,
345
- settingsFile: found.paths.settings, destinationHooksDir: found.paths.hooksDirectory, trusted: context.hooksTrusted, fsImpl });
346
- hooksStatus = hooksPlan.ok && hooksPlan.status === 'unchanged' ? 'managed' : (hooksPlan.ok ? 'missing' : 'conflict');
347
- if (!hooksPlan.ok) conflicts.push(...(hooksPlan.errors || []));
348
- if (hooksStatus === 'managed') {
349
- resources.push({ assetId: hooksAssetId(assets), target: hooksPlan.settingsFile, ownershipIdentity: 'gemini:hooks',
350
- fingerprint: fingerprint(hooksPlan.merged.hooks), sourceVersion: context.sourceVersion ?? 'unknown', projection: { renderer: 'gemini-hooks' } });
351
- }
352
- }
388
+ const hooks = verifyHooksComponent({ found, assets, context, fsImpl });
389
+ resources.push(...hooks.resources);
390
+ const hooksStatus = hooks.hooksStatus;
391
+ const conflicts = [...(found.settings.error ? [found.settings.error] : []), ...copyTree.conflicts, ...hooks.conflicts];
353
392
  return { ok: !found.settings.error && copyTree.conflicts.length === 0 && hooksStatus !== 'conflict', resources,
354
393
  statuses: { settings: found.settings.error ? 'invalid' : 'supported', extensions: 'different', policies: policyStatuses(context.policies), copyTree: copyTree.statuses, hooks: hooksStatus },
355
394
  conflicts };
356
395
  }
357
396
 
358
- return { nativePaths, discover, render, plan, apply, remove, verify };
397
+ return { nativePaths, discover, render, plan, apply, remove, verify, planHookDoors, verifyHookDoors };
359
398
  }
360
399
 
361
400
  const singleton = createGeminiAdapter();
@@ -370,7 +409,7 @@ const singleton = createGeminiAdapter();
370
409
  * @param {Object} target a lifecycle status target ({adapterInput, discovery})
371
410
  * @returns {*} the plan's trust, or null
372
411
  */
373
- function hookTrustFor(target: any) {
412
+ function hookTrustFor(target ) {
374
413
  const context = target.adapterInput?.context || {};
375
414
  const settingsFile = target.discovery?.paths?.settings;
376
415
  if (!context.geminiHooksSourceFile || !settingsFile || target.discovery?.settings?.error) return null;
@@ -385,6 +424,6 @@ module.exports = {
385
424
  MARKER_START, MARKER_END,
386
425
  nativePaths: singleton.nativePaths, discover: singleton.discover, render: singleton.render,
387
426
  managedInstruction, plan: singleton.plan, apply: singleton.apply, remove: singleton.remove,
388
- verify: singleton.verify, planGeminiHooks, hookTrustFor,
427
+ verify: singleton.verify, planHookDoors: singleton.planHookDoors, verifyHookDoors: singleton.verifyHookDoors, planGeminiHooks, hookTrustFor,
389
428
  createGeminiAdapter,
390
429
  };
@@ -11,16 +11,16 @@
11
11
  const fs = require('node:fs');
12
12
  const path = require('node:path');
13
13
 
14
- function commandScriptNames(command: string): string[] {
14
+ function commandScriptNames(command ) {
15
15
  // DoFlow's wrappers are shell scripts selected from a project or CODEX_HOME hooks directory.
16
16
  // Extracting the basename works for both literal and command-substitution based paths.
17
17
  return [...new Set((command.match(/[A-Za-z0-9][A-Za-z0-9_-]*\.sh\b/g) || []))];
18
18
  }
19
19
 
20
- function hookHandlers(config: any): { event: string, handler: any }[] {
21
- return Object.entries(config.hooks || {}).flatMap(([event, groups]: [string, any]) =>
22
- (Array.isArray(groups) ? groups : []).flatMap((group: any) =>
23
- (Array.isArray(group?.hooks) ? group.hooks : []).map((handler: any) => ({ event, handler }))));
20
+ function hookHandlers(config ) {
21
+ return Object.entries(config.hooks || {}).flatMap(([event, groups] ) =>
22
+ (Array.isArray(groups) ? groups : []).flatMap((group ) =>
23
+ (Array.isArray(group?.hooks) ? group.hooks : []).map((handler ) => ({ event, handler }))));
24
24
  }
25
25
 
26
26
  // On `trusted`: both harnesses gate hook execution behind their own trust prompt — Codex through
@@ -36,8 +36,8 @@ function hookHandlers(config: any): { event: string, handler: any }[] {
36
36
  // exec-bit half of the check is POSIX-only; existence is checked everywhere.
37
37
  const EXEC_BITS_EXIST = process.platform !== 'win32';
38
38
 
39
- function verifyHookCommands(config: any, { scriptsDir, trusted = false, fsImpl = fs }: { scriptsDir?: string, trusted?: boolean, fsImpl?: any } = {}): any {
40
- const checks: any[] = [];
39
+ function verifyHookCommands(config , { scriptsDir, trusted = false, fsImpl = fs } = {}) {
40
+ const checks = [];
41
41
  for (const { event, handler } of hookHandlers(config)) {
42
42
  if (!handler || handler.type !== 'command' || typeof handler.command !== 'string' || !handler.command.trim()) continue;
43
43
  const names = commandScriptNames(handler.command);
@@ -54,7 +54,7 @@ function verifyHookCommands(config: any, { scriptsDir, trusted = false, fsImpl =
54
54
  requiresTrust: true, trusted });
55
55
  }
56
56
  }
57
- return { ok: checks.every((check: any) => check.ok), checks, trust: { required: checks.length > 0, trusted: Boolean(trusted), status: trusted ? 'trusted' : 'review-required' } };
57
+ return { ok: checks.every((check ) => check.ok), checks, trust: { required: checks.length > 0, trusted: Boolean(trusted), status: trusted ? 'trusted' : 'review-required' } };
58
58
  }
59
59
 
60
60
  module.exports = { commandScriptNames, hookHandlers, verifyHookCommands };