@zalom/plastic 1.14.0 → 2.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/config_asks.yml +38 -0
  8. package/hooks/capture +4 -0
  9. package/hooks/check-update +12 -3
  10. package/hooks/close +5 -0
  11. package/hooks/hooks.json +9 -49
  12. package/hooks/record +4 -0
  13. package/package.json +3 -2
  14. package/scripts/agent-report +8 -9
  15. package/scripts/append-ledger +195 -0
  16. package/scripts/codex-hook +101 -95
  17. package/scripts/dashboard.rb +4 -4
  18. package/scripts/doctor.rb +106 -43
  19. package/scripts/end-intent +77 -76
  20. package/scripts/exec-worktree +11 -20
  21. package/scripts/file-session-intent +61 -0
  22. package/scripts/hook-capture +242 -0
  23. package/scripts/hook-close +34 -0
  24. package/scripts/hook-record +149 -0
  25. package/scripts/hook-session-start +95 -12
  26. package/scripts/install.rb +8 -6
  27. package/scripts/lib/agent_models.rb +1 -7
  28. package/scripts/lib/arm.rb +283 -0
  29. package/scripts/lib/backfill_intent.rb +316 -0
  30. package/scripts/lib/bridge.rb +24 -1597
  31. package/scripts/lib/doctor_core.rb +17 -88
  32. package/scripts/lib/doctor_session_ledger.rb +106 -0
  33. package/scripts/lib/exec_worktree.rb +14 -60
  34. package/scripts/lib/harness_text.rb +5 -4
  35. package/scripts/lib/hook_registry.rb +44 -107
  36. package/scripts/lib/insights.rb +3 -4
  37. package/scripts/lib/installer_core.rb +19 -30
  38. package/scripts/lib/lock.rb +6 -26
  39. package/scripts/lib/outcome_guard.rb +7 -5
  40. package/scripts/lib/project_validator.rb +30 -1
  41. package/scripts/lib/roadmap_queue.rb +3 -3
  42. package/scripts/lib/roadmap_savepoint.rb +1 -1
  43. package/scripts/lib/rule_catalog.rb +6 -3
  44. package/scripts/lib/savepoint.rb +320 -0
  45. package/scripts/lib/scaffold_intent.rb +13 -179
  46. package/scripts/lib/session_backfill.rb +184 -0
  47. package/scripts/lib/session_close.rb +84 -0
  48. package/scripts/lib/session_git.rb +469 -0
  49. package/scripts/lib/session_ledger.rb +497 -0
  50. package/scripts/lib/store_discovery.rb +1 -1
  51. package/scripts/lib/store_provisioning.rb +1 -1
  52. package/scripts/maintenance-run +7 -7
  53. package/scripts/new-intent +63 -4
  54. package/scripts/plastic-lock +94 -41
  55. package/scripts/promote-session-item +150 -0
  56. package/scripts/rebuild-graph +1 -1
  57. package/scripts/roadmap-next +2 -2
  58. package/scripts/rollback.rb +42 -3
  59. package/scripts/scaffold-intent +37 -63
  60. package/scripts/session-commit +116 -0
  61. package/scripts/spawn-preamble +5 -5
  62. package/scripts/update.rb +7 -3
  63. package/skills/agent-advisor/SKILL.md +3 -3
  64. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  65. package/skills/auto/SKILL.md +218 -317
  66. package/skills/auto/evals/evals.json +10 -10
  67. package/skills/auto/references/agent-architecture.md +102 -90
  68. package/skills/auto/references/agent-report-contract.md +4 -21
  69. package/skills/auto/references/end-tail.md +47 -53
  70. package/skills/auto/references/human-report-contract.md +8 -8
  71. package/skills/conventions/SKILL.md +1 -3
  72. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  73. package/skills/conventions/references/locks-and-worktrees.md +47 -55
  74. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  75. package/skills/dashboard/SKILL.md +1 -1
  76. package/skills/direct/SKILL.md +66 -0
  77. package/skills/direct/references/request-signals.md +59 -0
  78. package/skills/doctor/SKILL.md +58 -11
  79. package/skills/doctor/report.md +3 -1
  80. package/skills/install/SKILL.md +11 -9
  81. package/skills/intent-continuing/SKILL.md +131 -89
  82. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  83. package/skills/intent-continuing/references/context-management.md +2 -2
  84. package/skills/intent-creating/SKILL.md +2 -2
  85. package/skills/intent-creating/references/lifecycle.md +1 -1
  86. package/skills/intent-ending/SKILL.md +28 -37
  87. package/skills/intent-ending/evals/evals.json +4 -4
  88. package/skills/intent-executing/SKILL.md +12 -49
  89. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  90. package/skills/intent-speccing/SKILL.md +108 -51
  91. package/skills/intent-speccing/evals/evals.json +5 -5
  92. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  93. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  94. package/skills/project-creating/SKILL.md +1 -1
  95. package/skills/releasing/SKILL.md +7 -8
  96. package/skills/tutorial/SKILL.md +2 -2
  97. package/skills/tutorial/references/track-1-guided.md +16 -18
  98. package/skills/tutorial/references/track-2-auto.md +12 -13
  99. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  100. package/templates/agents.md +4 -7
  101. package/templates/project.yml +7 -0
  102. package/templates/roadmap.md +1 -1
  103. package/templates/savepoint.md +2 -2
  104. package/templates/session-intent.md +29 -0
  105. package/agents/plastic-brainstorming.md +0 -48
  106. package/agents/plastic-future-intent-researcher.md +0 -35
  107. package/agents/plastic-intent-curator.md +0 -47
  108. package/agents/plastic-intent-discovery.md +0 -43
  109. package/agents/plastic-planner.md +0 -56
  110. package/agents/plastic-spec-specialist.md +0 -53
  111. package/hooks/auto-arm +0 -5
  112. package/hooks/bash-gate +0 -3
  113. package/hooks/continue +0 -31
  114. package/hooks/edit-gates +0 -3
  115. package/hooks/future-intent-check +0 -25
  116. package/hooks/gate-check +0 -12
  117. package/hooks/power-tools +0 -8
  118. package/scripts/hook-auto-arm +0 -51
  119. package/scripts/hook-bash-gate +0 -78
  120. package/scripts/hook-code-gate +0 -38
  121. package/scripts/hook-continue +0 -48
  122. package/scripts/hook-create-gate +0 -51
  123. package/scripts/hook-edit-gates +0 -58
  124. package/scripts/hook-future-intent-check +0 -90
  125. package/scripts/hook-gate-check +0 -169
  126. package/scripts/hook-links-gate +0 -54
  127. package/scripts/hook-lock-gate +0 -47
  128. package/scripts/hook-power-tools +0 -38
  129. package/scripts/hook-savepoint-pre +0 -32
  130. package/scripts/lib/codex_edit_gates.rb +0 -138
  131. package/scripts/lib/edit_gates.rb +0 -398
  132. package/scripts/lib/links_gate.rb +0 -140
  133. package/scripts/lib/qmd_hook.rb +0 -41
  134. package/scripts/lib/spec_header.rb +0 -83
  135. package/scripts/lib/start_intent.rb +0 -296
  136. package/scripts/start-intent +0 -89
  137. package/skills/_active-intent-gate.md +0 -26
  138. package/skills/auto/references/tiers.md +0 -100
  139. package/skills/continuing/SKILL.md +0 -34
  140. package/skills/continuing/evals/evals.json +0 -91
  141. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  142. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  143. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  144. package/skills/intent-brainstorming/SKILL.md +0 -118
  145. package/skills/intent-brainstorming/evals/evals.json +0 -67
  146. package/skills/intent-continuing/evals/evals.json +0 -145
  147. package/skills/intent-discovering/SKILL.md +0 -56
  148. package/skills/intent-grilling/SKILL.md +0 -108
  149. package/skills/intent-linking/SKILL.md +0 -128
  150. package/skills/intent-linking/evals/evals.json +0 -22
  151. package/skills/intent-linking/references/zettelkasten.md +0 -45
  152. package/skills/intent-locking/SKILL.md +0 -64
  153. package/skills/intent-planning/SKILL.md +0 -145
  154. package/skills/intent-planning/evals/evals.json +0 -81
  155. package/skills/intent-planning/references/plan-format.md +0 -130
  156. package/skills/intent-researching/SKILL.md +0 -123
  157. package/skills/intent-researching/evals/evals.json +0 -22
  158. package/skills/intent-savepoint/SKILL.md +0 -83
  159. package/skills/intent-savepoint/references/context-management.md +0 -32
  160. package/skills/intent-starting/SKILL.md +0 -151
  161. package/skills/intent-starting/evals/evals.json +0 -117
  162. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  163. package/skills/project-continuing/SKILL.md +0 -119
  164. package/skills/project-continuing/evals/evals.json +0 -100
  165. package/skills/roadmap-continuing/SKILL.md +0 -89
  166. package/skills/roadmap-continuing/evals/evals.json +0 -82
  167. package/skills/skill-creating/SKILL.md +0 -75
  168. package/skills/skill-creating/evals/evals.json +0 -108
  169. package/skills/skill-creating/references/agents.md +0 -168
  170. package/skills/skill-creating/references/defaults-first.md +0 -23
  171. package/skills/skill-creating/references/evals.md +0 -41
  172. package/skills/skill-creating/references/hooks.md +0 -251
  173. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  174. package/skills/skill-creating/references/scripts.md +0 -166
  175. package/skills/skill-creating/references/skills.md +0 -169
  176. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  177. package/skills/skill-evaluating/SKILL.md +0 -141
  178. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  179. package/skills/skill-evaluating/evals/evals.json +0 -75
  180. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  181. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  182. package/skills/store-curating/SKILL.md +0 -64
  183. package/skills/store-curating/evals/evals.json +0 -38
  184. package/skills/store-indexing/SKILL.md +0 -93
  185. package/skills/store-indexing/evals/evals.json +0 -22
  186. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  187. package/skills/store-provisioning/SKILL.md +0 -55
  188. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  189. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  190. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -1,398 +0,0 @@
1
- # encoding: UTF-8
2
- # frozen_string_literal: true
3
-
4
- # EditGates (intent 244): the five Claude PreToolUse edit-path gates as plain
5
- # functions over one parsed payload. Three callers share these functions so the
6
- # harnesses cannot drift: scripts/hook-edit-gates (the merged Claude dispatcher),
7
- # scripts/lib/codex_edit_gates.rb (the merged Codex dispatcher, intent 251), and
8
- # the retained scripts/hook-code-gate / hook-lock-gate / hook-savepoint-pre /
9
- # hook-links-gate / hook-create-gate CLI wrappers, which no longer have a
10
- # production caller on either harness and remain as the per-gate isolation
11
- # surface for the hook test suite.
12
- #
13
- # A gate returns nil to allow, or a Deny. Two deny shapes coexist deliberately
14
- # (spec D-b): :stderr (stderr lines plus exit 2, code-gate / links-gate /
15
- # create-gate) and :json (stdout JSON plus exit 0, lock-gate, which reserves a
16
- # non-zero exit for hook-internal errors). Nothing here normalizes them.
17
-
18
- require "json"
19
- require "time"
20
- require_relative "hook_registry"
21
- require_relative "bridge"
22
- require_relative "lock"
23
- require_relative "links_gate"
24
- require_relative "intent_validator"
25
-
26
- module EditGates
27
- module_function
28
-
29
- ALLOW = nil
30
-
31
- Deny = Struct.new(:shape, :lines, :stdout, keyword_init: true)
32
-
33
- # Everything the five gates read out of one PreToolUse payload (spec D-g).
34
- # gate_path - code-gate and lock-gate: file_path || notebook_path ||
35
- # relative_path, absolutized against tool_input.project_root ||
36
- # payload.cwd when relative. Exactly what the old Bash shims
37
- # computed with their `ruby -rjson -e` one-liners.
38
- # file_path - savepoint-pre, links-gate, create-gate: tool_input.file_path
39
- # only, raw. Preserving this narrower extraction is what keeps
40
- # create-gate's dormant Serena gap byte-for-byte intact.
41
- # content - the RAW tool_input.content (nil when the key is absent, ""
42
- # when it is explicitly empty). Kept unmixed with new_string so
43
- # links-gate and create-gate's `!content.nil?` branch keeps its
44
- # exact original meaning; code-gate computes its own combined
45
- # content-or-new_string-or-"" value locally, mirroring what its
46
- # old Bash shim computed independently for ARGV[2].
47
- Context = Struct.new(:tool_name, :session, :gate_path, :file_path, :savepoint_path,
48
- :content, :old_string, :new_string, :replace_all, :harness,
49
- keyword_init: true) do
50
- # links-gate and create-gate branch on content.nil? (key absent) vs content
51
- # present (even ""). This is the raw field already, kept as a named reader
52
- # so both gates read intent, not a struct field, at the call site.
53
- def content_or_nil
54
- content
55
- end
56
- end
57
-
58
- # Per-key tool_input/tool_params fallback (post-review fix, intent 244): old
59
- # links-gate and create-gate read
60
- # `payload.dig("tool_input", key) || payload.dig("tool_params", key)`, each
61
- # key falling back independently, NOT the whole-object pick `input =
62
- # tool_input || tool_params` used for gate_path/content/etc below. This
63
- # matters for a payload shaped tool_input: {} plus
64
- # tool_params: { file_path: X }: the whole-object read stops at the (empty)
65
- # tool_input and never sees X; the per-key read still finds it. `prefer`
66
- # flips which object wins when BOTH carry the key, since savepoint-pre alone
67
- # read tool_params FIRST (see savepoint_path below).
68
- def per_key_field(payload, key, prefer: "tool_input")
69
- other = prefer == "tool_input" ? "tool_params" : "tool_input"
70
- a = payload[prefer]
71
- b = payload[other]
72
- (a.is_a?(Hash) ? a[key] : nil) || (b.is_a?(Hash) ? b[key] : nil)
73
- end
74
-
75
- def context_from(payload, env: ENV)
76
- input = payload["tool_input"] || payload["tool_params"] || {}
77
- input = {} unless input.is_a?(Hash)
78
-
79
- raw = input["file_path"] || input["notebook_path"] || input["relative_path"] || ""
80
- if !raw.empty? && !raw.start_with?("/")
81
- root = input["project_root"] || payload["cwd"] || ""
82
- raw = File.join(root, raw) unless root.empty?
83
- end
84
-
85
- session = payload["session_id"]
86
- session = env["CLAUDE_CODE_SESSION_ID"] if session.to_s.empty?
87
-
88
- Context.new(
89
- tool_name: payload["tool_name"],
90
- session: (session unless session.to_s.empty?),
91
- gate_path: raw,
92
- file_path: per_key_field(payload, "file_path", prefer: "tool_input"),
93
- # savepoint-pre's own historical precedence, inverted from links-gate /
94
- # create-gate above: old hooks/savepoint-pre read
95
- # `dig("tool_params","file_path") || dig("tool_input","file_path")`.
96
- savepoint_path: per_key_field(payload, "file_path", prefer: "tool_params"),
97
- content: input["content"],
98
- old_string: input["old_string"],
99
- new_string: input["new_string"],
100
- replace_all: input["replace_all"],
101
- harness: "claude",
102
- )
103
- end
104
-
105
- # --- savepoint-pre (intent 81): never denies; appends the `started` ledger line.
106
- def savepoint_pre(ctx)
107
- # ctx.savepoint_path carries context_from's tool_params-first read (fix 3);
108
- # falls back to ctx.file_path for the thin CLI wrapper (hook-savepoint-pre),
109
- # which only ever sets file_path directly and never populates savepoint_path.
110
- path = ctx.savepoint_path || ctx.file_path
111
- return ALLOW if path.nil? || path.empty?
112
-
113
- abs = File.expand_path(path)
114
- intent_dir = Bridge.intent_dir_for(abs)
115
- return ALLOW unless intent_dir
116
-
117
- begin
118
- Bridge.append_started_savepoint(intent_dir, abs)
119
- rescue StandardError
120
- # best-effort; the ledger is rebuildable and the post line still lands
121
- end
122
- ALLOW
123
- end
124
-
125
- # --- lock-gate (intent 96, 111): fail-CLOSED delivery lock plus the artifact claim.
126
- def lock_gate(ctx)
127
- file_path = ctx.gate_path
128
- return ALLOW if file_path.nil? || file_path.empty?
129
-
130
- session = ctx.session
131
- harness = ctx.harness
132
-
133
- bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
134
- reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session, harness: harness)
135
-
136
- unless reason
137
- begin
138
- dir = Bridge.intent_dir_for(file_path)
139
- Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
140
- rescue StandardError
141
- # ignore
142
- end
143
-
144
- begin
145
- dir = Bridge.intent_dir_for(file_path)
146
- artifact = File.basename(file_path) if dir
147
- if dir && artifact
148
- claim_reason = Claim.claim_gate_reason(dir, artifact, session: session, harness: harness)
149
- return deny_json(claim_reason) if claim_reason
150
-
151
- if Claim.fail_open?(dir, artifact)
152
- $stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
153
- "intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
154
- "proceeds (see /plastic-lock status)"
155
- end
156
-
157
- Claim.heartbeat(dir, artifact, session: session) rescue nil
158
- end
159
- rescue StandardError
160
- # ignore: a claim-gate bug must never block a write (fail open)
161
- end
162
-
163
- return ALLOW
164
- end
165
-
166
- deny_json(reason)
167
- end
168
-
169
- def deny_json(reason)
170
- Deny.new(shape: :json, stdout: JSON.generate(
171
- "hookSpecificOutput" => {
172
- "hookEventName" => "PreToolUse",
173
- "permissionDecision" => "deny",
174
- "permissionDecisionReason" => reason,
175
- }
176
- ))
177
- end
178
-
179
- # --- code-gate (intents 27, 73c2, 150): stage rule OR worktree rule, first match wins.
180
- def code_gate(ctx)
181
- file_path = ctx.gate_path
182
- return ALLOW if file_path.nil? || file_path.empty?
183
-
184
- session = ctx.session
185
- # Auditable escape (intent 150): mirrors the old Bash shim's independent
186
- # ti["content"] || ti["new_string"] || "" computation for ARGV[2]. Kept
187
- # local to code-gate; links-gate and create-gate never read it.
188
- new_content = ctx.content || ctx.new_string || ""
189
-
190
- if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
191
- log_escape(session, file_path)
192
- return ALLOW
193
- end
194
-
195
- bridge_data = Bridge.discover_bridge(session: session, cwd: file_path, edited_path: file_path)
196
- return ALLOW unless bridge_data
197
-
198
- reason = Bridge.code_gate_decision(bridge_data, file_path) ||
199
- Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
200
- return ALLOW unless reason
201
-
202
- Deny.new(shape: :stderr, lines: ["PLASTIC GATE — #{reason}"])
203
- end
204
-
205
- def log_escape(session, file_path)
206
- require "fileutils"
207
- log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
208
- FileUtils.mkdir_p(File.dirname(log))
209
- File.open(log, "a") do |io|
210
- io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{file_path}")
211
- end
212
- rescue StandardError
213
- # the escape still applies; logging is best-effort
214
- end
215
-
216
- # --- block log (intent 229): one TSV line per block, six tab-separated fields
217
- # in a fixed order. Mirrors log_escape above exactly: best-effort, wrapped in
218
- # rescue StandardError, so a logging failure can never change what a gate
219
- # decided and can never raise. `path` is the injected seam a test uses to
220
- # force the write to fail without eval, ENV, or a global.
221
- def block_log_path
222
- File.join(Dir.home, ".plastic", ".cache", "gate-blocks.log")
223
- end
224
-
225
- def log_block(gate:, session:, intent:, subject:, rule:, path: nil)
226
- require "fileutils"
227
- log = path || block_log_path
228
- FileUtils.mkdir_p(File.dirname(log))
229
- File.open(log, "a") do |io|
230
- io.puts([Time.now.utc.iso8601, gate, session.to_s, intent.to_s,
231
- collapse_ws(subject), collapse_ws(rule)].join("\t"))
232
- end
233
- rescue StandardError
234
- # the block still applies; logging is best-effort
235
- end
236
-
237
- def collapse_ws(value)
238
- value.to_s.gsub(/\s+/, " ").strip
239
- end
240
-
241
- # The reason text a Deny carries, per shape. Rescues to "" so a malformed
242
- # outcome costs one field, never the line and never the decision.
243
- def deny_reason(outcome)
244
- if outcome.shape == :json
245
- JSON.parse(outcome.stdout.to_s).dig("hookSpecificOutput", "permissionDecisionReason").to_s
246
- else
247
- Array(outcome.lines).join(" ")
248
- end
249
- rescue StandardError
250
- ""
251
- end
252
-
253
- # "" when the path is not inside an intent dir, keeping the column count fixed.
254
- def block_intent_id(path)
255
- dir = Bridge.intent_dir_for(path.to_s)
256
- dir ? Bridge.intent_id_from_dir(dir).to_s : ""
257
- rescue StandardError
258
- ""
259
- end
260
-
261
- # --- links-gate (intent 192): the write-time belt for the ## Links contract.
262
- def links_gate(ctx)
263
- path = ctx.file_path
264
- return ALLOW if path.to_s.strip.empty?
265
-
266
- abs = File.expand_path(path)
267
- return ALLOW unless LinksGate.intent_file?(abs)
268
-
269
- content = ctx.content_or_nil
270
- old_string = ctx.old_string
271
-
272
- before_content = File.exist?(abs) ? File.read(abs) : ""
273
-
274
- after_content =
275
- if !content.nil?
276
- content
277
- elsif !old_string.nil?
278
- return ALLOW unless before_content.include?(old_string)
279
- ctx.replace_all ? before_content.gsub(old_string, ctx.new_string.to_s)
280
- : before_content.sub(old_string, ctx.new_string.to_s)
281
- else
282
- return ALLOW # pathless mutation (no visible proposal); cannot judge, allow
283
- end
284
-
285
- plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
286
-
287
- reason = LinksGate.decision(file_path: abs, before_content: before_content,
288
- after_content: after_content, plastic_home: plastic_home)
289
- return ALLOW unless reason
290
-
291
- Deny.new(shape: :stderr, lines: [reason])
292
- end
293
-
294
- # --- create-gate (intents 60b, 108 D7): validate the PROPOSED intent file content.
295
- def create_gate(ctx)
296
- path = ctx.file_path
297
- return ALLOW if path.nil? || path.to_s.strip.empty?
298
-
299
- abs = File.expand_path(path)
300
- dir = File.dirname(abs)
301
- is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
302
- File.basename(abs) == "#{File.basename(dir)}.md"
303
- return ALLOW unless is_intent_file
304
-
305
- content = ctx.content_or_nil
306
- old_string = ctx.old_string
307
-
308
- result =
309
- if !content.nil?
310
- IntentValidator.validate_content(content)
311
- elsif !old_string.nil?
312
- unless File.exist?(abs)
313
- return Deny.new(shape: :stderr, lines: [
314
- "PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
315
- "create intents via new-intent / plastic-intent-creating.",
316
- ])
317
- end
318
- current = File.read(abs)
319
- return ALLOW unless current.include?(old_string)
320
- simulated = ctx.replace_all ? current.gsub(old_string, ctx.new_string.to_s)
321
- : current.sub(old_string, ctx.new_string.to_s)
322
- IntentValidator.validate_content(simulated)
323
- else
324
- unless File.exist?(abs)
325
- return Deny.new(shape: :stderr, lines: [
326
- "PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
327
- "content and no file exists; refusing to allow an unvalidated intent write.",
328
- ])
329
- end
330
- IntentValidator.validate_content(File.read(abs))
331
- end
332
-
333
- return ALLOW if result[:ok]
334
-
335
- lines = ["PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"]
336
- result[:errors].each { |e| lines << " #{e}" }
337
- lines << "Create intents via new-intent / plastic-intent-creating; do not hand-author them."
338
- Deny.new(shape: :stderr, lines: lines)
339
- end
340
-
341
- # Applicability (post-review fixes 1+2, intent 244): reproduces the Claude
342
- # harness's OWN matcher semantics, not the dispatcher's earlier string
343
- # equality. Two corrections:
344
- # - the harness matches an UNANCHORED REGEX (`"Write|Edit"` matches
345
- # "NotebookEdit" by substring), so applicability must too, or a gate
346
- # whose table entry never listed NotebookEdit explicitly stops firing on
347
- # it even though the old per-gate matcher group DID fire (a coverage
348
- # narrowing the byte-identical-behavior bar exists to catch);
349
- # - a missing/unrecognized tool_name means "the harness matched, but did
350
- # not name the tool", not "not our tool": the five old wrappers never
351
- # read tool_name at all, so they ALL ran on such a payload. Skipping
352
- # every gate here would be a coverage WEAKENING (the gate-weakening
353
- # direction is the dangerous one; see intent 203).
354
- def tool_applies?(tool_name, tools)
355
- return true if tool_name.to_s.empty?
356
- Regexp.new(tools.join("|")).match?(tool_name.to_s)
357
- end
358
-
359
- # --- the ordered evaluation (spec D-a, D-i) -------------------------------
360
- # `route` is injected so a test can supply a raising gate and prove isolation
361
- # without eval, an ENV seam, or a global. `gate_tools` is injected for the same
362
- # reason and defaults to the single source of truth.
363
- # Returns the process exit code.
364
- def dispatch(ctx:, route:, gate_tools: HookRegistry::GATE_TOOLS, out: $stdout, err: $stderr,
365
- block_log: nil)
366
- gate_tools.each do |gate, tools|
367
- next unless tool_applies?(ctx.tool_name, tools)
368
-
369
- begin
370
- outcome = route.call(gate, ctx)
371
- next unless outcome
372
-
373
- # intent 229: one block-log line per deny, both shapes, one call site.
374
- # Every helper here rescues internally, so nothing new can reach this
375
- # method's rescue and turn a logging problem into a changed decision.
376
- subject = ctx.gate_path.to_s.empty? ? ctx.file_path.to_s : ctx.gate_path.to_s
377
- log_block(gate: gate, session: ctx.session, intent: block_intent_id(subject),
378
- subject: subject, rule: deny_reason(outcome), path: block_log)
379
-
380
- if outcome.shape == :json
381
- out.print outcome.stdout
382
- return 0
383
- else
384
- outcome.lines.each { |line| err.puts line }
385
- return 2
386
- end
387
- rescue StandardError => e
388
- # Fix 7: outcome.shape / out.print / err.puts now live INSIDE the same
389
- # rescue as route.call, so a malformed outcome or a stream write
390
- # failure (e.g. EPIPE) is isolated exactly like a raising gate, and
391
- # never aborts the gates still to come.
392
- err.puts "plastic #{gate} error: #{e.message}"
393
- next
394
- end
395
- end
396
- 0
397
- end
398
- end
@@ -1,140 +0,0 @@
1
- # encoding: UTF-8
2
- # frozen_string_literal: true
3
-
4
- require_relative "intent_validator"
5
- require_relative "links_section"
6
- require_relative "links_projection"
7
- require_relative "store_discovery"
8
- require_relative "graph_rebuild"
9
-
10
- # LinksGate, the write-time belt for the PLASTIC.md `## Links` contract
11
- # (intent 192). Pure decision logic plus a small store-scanning glue (mirrors
12
- # the glue ProjectLinks and Doctor each already carry independently for
13
- # themselves; the CALCULATION is shared via LinksSection/LinksProjection so
14
- # gate, projector, and doctor can never disagree by construction, even though
15
- # each IO shell still does its own discovery, exactly as project-links and
16
- # doctor already do today).
17
- #
18
- # #decision is the single entry point the PreToolUse hook
19
- # (scripts/hook-links-gate) calls: given a file path and its BEFORE/AFTER
20
- # content (BEFORE = on-disk, AFTER = the proposed Write/Edit result), it
21
- # returns nil (allow) or a deny message (String). It only ever judges the
22
- # REAL, fence-aware `## Links` section (LinksSection.extract_section); an
23
- # edit that leaves that section untouched is always allowed, cheaply, with no
24
- # store scan at all.
25
- module LinksGate
26
- module_function
27
-
28
- DENY_MESSAGE =
29
- "PLASTIC LINKS GATE - a ## Links line must come from the frontmatter " \
30
- "sources/chain graph, never be hand-typed. This edit changes the ## Links " \
31
- "section to something other than its frontmatter projection. Add the edge " \
32
- "to sources or chain in frontmatter instead, then run scripts/project-links " \
33
- "to regenerate ## Links.".freeze
34
-
35
- # True iff `path` is an intent file inside its own equally-named store
36
- # directory (store/<id>--<slug>/<id>--<slug>.md). Mirrors the create-gate
37
- # path matcher (intent 60b) so the two gates agree on what "an intent file" is.
38
- def intent_file?(path)
39
- return false if path.to_s.strip.empty?
40
-
41
- abs = File.expand_path(path)
42
- dir = File.dirname(abs)
43
- dir.match?(%r{/store/[^/]+--[^/]+\z}) && File.basename(abs) == "#{File.basename(dir)}.md"
44
- end
45
-
46
- # Decide whether to deny a Write/Edit whose proposed result is
47
- # `after_content` (the on-disk content before the edit is `before_content`,
48
- # "" when the file does not yet exist). Returns nil (allow, including every
49
- # "cannot judge" case) or DENY_MESSAGE.
50
- def decision(file_path:, before_content:, after_content:, plastic_home:)
51
- return nil unless intent_file?(file_path)
52
-
53
- before_links = safe_extract(before_content)
54
- after_links = safe_extract(after_content)
55
- return nil if before_links.nil? || after_links.nil? # ambiguous ## Links; cannot judge
56
- return nil if before_links == after_links # this edit does not touch ## Links at all
57
-
58
- fm = IntentValidator.parse_frontmatter_text(after_content.to_s)
59
- return nil unless fm.is_a?(Hash) # no parseable frontmatter; cannot judge
60
-
61
- referer_store = store_key_for(file_path, plastic_home)
62
- return nil unless referer_store # not under any discovered store; cannot judge
63
-
64
- ctx = build_context(plastic_home)
65
- resolve = ->(ref) do
66
- LinksProjection.resolve_ref_projection(
67
- ref, referer_store: referer_store, relocation_map: ctx[:relocation_map],
68
- store_index: ctx[:store_index], node_index: ctx[:node_index]
69
- )
70
- end
71
-
72
- expected =
73
- begin
74
- LinksProjection.section(sources: Array(fm["sources"]), chain: Array(fm["chain"]),
75
- resolve: resolve)
76
- rescue LinksProjection::UnresolvedRef
77
- return nil # a pre-existing dead frontmatter ref is a doctor finding, not this gate's job
78
- end
79
-
80
- after_links == expected ? nil : DENY_MESSAGE
81
- end
82
-
83
- # Fence-aware real-section extract, tolerant of an ambiguous file (returns
84
- # nil rather than raising, so #decision can fail open on it).
85
- def safe_extract(content)
86
- LinksSection.extract_section(IntentValidator.body_of(content.to_s))
87
- rescue LinksSection::AmbiguousLinks
88
- nil
89
- end
90
-
91
- # Which discovered store (by store_index/node_index key) `file_path` lives
92
- # under, or nil when it is not inside any store this plastic_home discovers.
93
- def store_key_for(file_path, plastic_home)
94
- abs = File.expand_path(file_path)
95
- store_dir = File.dirname(File.dirname(abs)) # .../<store>/<id>--<slug>/<file>.md
96
- StoreDiscovery.discover(plastic_home)[:stores]
97
- .find { |s| File.expand_path(s[:store]) == store_dir }
98
- &.fetch(:key)
99
- end
100
-
101
- # Build the store_index/node_index/relocation_map resolver context spanning
102
- # every discovered store, the same shape ProjectLinks#run and Doctor each
103
- # build for themselves. Re-scanned per call (no caching): this only runs on
104
- # the rare edit that actually changes ## Links, so the cost is paid where it
105
- # matters, not on every Edit/Write.
106
- def build_context(plastic_home)
107
- discovery = StoreDiscovery.discover(plastic_home)
108
- store_index = {}
109
- node_index = {}
110
- index_texts = {}
111
-
112
- discovery[:stores].each do |s|
113
- nodes = load_nodes(s[:store])
114
- store_index[s[:key]] = nodes.keys
115
- node_index[s[:key]] = nodes.transform_values { |v| { basename: v[:basename], label: v[:label] } }
116
- index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
117
- end
118
-
119
- { store_index: store_index, node_index: node_index,
120
- relocation_map: GraphRebuild.build_relocation_map(index_texts) }
121
- end
122
-
123
- # { id => { basename:, label: } } for one store directory.
124
- def load_nodes(store_dir)
125
- nodes = {}
126
- Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
127
- dir = File.join(store_dir, entry)
128
- next unless File.directory?(dir)
129
-
130
- md = File.join(dir, "#{entry}.md")
131
- next unless File.exist?(md)
132
-
133
- fm = IntentValidator.parse_frontmatter(md)
134
- next unless fm.is_a?(Hash) && fm["id"]
135
-
136
- nodes[fm["id"].to_s] = { basename: entry, label: fm["intent"].to_s.strip }
137
- end
138
- nodes
139
- end
140
- end
@@ -1,41 +0,0 @@
1
- # encoding: UTF-8
2
- # frozen_string_literal: true
3
-
4
- require_relative "qmd_sync"
5
- require_relative "power_tools"
6
-
7
- # QmdHook - decision logic for the power-tools UserPromptSubmit hook (intents 66,
8
- # 66b, 187, 246). Pure and dependency-injected: returns the additionalContext
9
- # string to emit, or nil to emit nothing. The executable hook wires real deps and
10
- # prints; this is unit-tested with fake detectors (no real qmd, no network, no
11
- # subprocess at all).
12
- #
13
- # It emits exactly one thing: the PowerTools mandate, one recommendation line per
14
- # present tool (qmd for finding intents, Enola-first for code navigation, falling
15
- # back to Serena; intent 187 added the enola_detector alongside the pre-existing
16
- # serena_detector).
17
- #
18
- # Intent 246 removed the scored qmd hit injection this hook used to prepend.
19
- # Intent 225 measured that injection at 0.24 intent-level recall@3 against a plain
20
- # ripgrep control at 0.18, while agent-driven `qmd query` scored 0.71. The failure
21
- # was recall, not latency, so caching and async were both rejected. QmdSync.search
22
- # is untouched and still backs the `scripts/qmd-sync search` CLI verb. All three
23
- # detectors are PATH and marker-file walks with no subprocess, which is why what is
24
- # left costs about a tenth of a second.
25
- module QmdHook
26
- module_function
27
-
28
- def run(cwd:, detector: QmdSync.method(:detect), serena_detector: nil,
29
- enola_detector: nil)
30
- serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
31
- enola_detector ||= -> { PowerTools.enola?(cwd: cwd) }
32
- qmd_present = !!detector.call
33
- serena_present = !!serena_detector.call
34
- enola_present = !!enola_detector.call
35
- return nil unless qmd_present || serena_present || enola_present
36
-
37
- PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
38
- serena_detector: -> { serena_present },
39
- enola_detector: -> { enola_present })
40
- end
41
- end
@@ -1,83 +0,0 @@
1
- # encoding: UTF-8
2
- # frozen_string_literal: true
3
-
4
- # SpecHeader - the single implementation that parses the `Tier:` and `Settled:` lines out
5
- # of a spec.md's header block (intent 213). No script, skill, or hook re-implements this
6
- # grammar anywhere else; Bridge.savepoint_tier delegates here instead of carrying its own
7
- # copy of the Tier regex.
8
- #
9
- # Grammar (both lines live above the `# ` level-1 heading):
10
- #
11
- # Tier: L
12
- # Settled: yes (design fixed by the 2026-07-18 Fable advisor verdict)
13
- #
14
- # `Tier:` is exactly one of S, M, L; anything else is unparseable and yields nil. An ABSENT
15
- # `Settled:` line means not settled, there is no `Settled: no` variant. The parenthesised
16
- # reason is REQUIRED for a line to count as settled: a bare `Settled: yes` with no reason
17
- # does NOT parse as settled. D2 calls Settled a ONE-WAY DOOR (leniency accepted now can
18
- # never be tightened later), so this stays strict from the start rather than being loosened
19
- # once and then needing a breaking change to fix.
20
- module SpecHeader
21
- module_function
22
-
23
- # Mirrors Bridge::PLACEHOLDER_SENTINEL (scripts/lib/bridge.rb:22). Not required in from
24
- # bridge.rb to avoid a require cycle: bridge.rb requires spec_header.rb, so spec_header.rb
25
- # must have zero require_relative dependencies of its own.
26
- PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
27
-
28
- HEADER_BLOCK_MAX_LINES = 10
29
-
30
- TIER_RE = /\ATier:\s*(S|M|L)\z/
31
- SETTLED_RE = /\ASettled:\s*yes\s*\((.*)\)\z/
32
-
33
- # PURE. Parse a spec.md's raw text. Returns a Hash with symbol keys, always the same
34
- # three keys: tier ("S"|"M"|"L"|nil), settled (true|false), settled_reason (String|nil).
35
- def parse(text)
36
- result = { tier: nil, settled: false, settled_reason: nil }
37
- return result if text.nil?
38
-
39
- lines = text.each_line.first(HEADER_BLOCK_MAX_LINES)
40
- lines.each do |raw|
41
- line = raw.chomp.strip
42
- next if line.empty?
43
- next if line == PLACEHOLDER_SENTINEL
44
- break if line.start_with?("# ")
45
-
46
- if (m = line.match(TIER_RE))
47
- result[:tier] = m[1]
48
- elsif (m = line.match(SETTLED_RE))
49
- result[:settled] = true
50
- result[:settled_reason] = m[1]
51
- end
52
- end
53
-
54
- result
55
- end
56
-
57
- # Read at most the header block off disk and parse it. Returns the same Hash shape as
58
- # `parse`, with every value nil/false when the path does not exist or cannot be read.
59
- def parse_file(path)
60
- lines = []
61
- File.open(path) do |f|
62
- HEADER_BLOCK_MAX_LINES.times do
63
- line = f.gets
64
- break if line.nil?
65
- lines << line
66
- end
67
- end
68
- parse(lines.join)
69
- rescue StandardError
70
- { tier: nil, settled: false, settled_reason: nil }
71
- end
72
-
73
- # Render the two header lines for a spec.md. `tier` is "S"|"M"|"L" or nil, `settled_reason`
74
- # is a String or nil. Returns a String ending in one newline. A nil tier renders the
75
- # placeholder `Tier: S|M|L`; a nil reason renders no Settled line at all (absent means not
76
- # settled).
77
- def render(tier: nil, settled_reason: nil)
78
- lines = []
79
- lines << "Tier: #{tier || 'S|M|L'}"
80
- lines << "Settled: yes (#{settled_reason})" if settled_reason
81
- "#{lines.join("\n")}\n"
82
- end
83
- end