@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,51 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # encoding: UTF-8
3
- # frozen_string_literal: true
4
-
5
- # PreToolUse create gate (intent 60b): validate the PROPOSED content of a Write
6
- # to an intent file before it lands. Fires when the target path is an intent file
7
- # inside its own equally-named directory (store/<id>--<slug>/<id>--<slug>.md). It
8
- # validates the proposed content from the hook payload (tool_input.content), NOT
9
- # the on-disk file (which does not exist yet at PreToolUse), using IntentValidator
10
- # for born-complete frontmatter plus the sanctioned section structure.
11
- #
12
- # It depends ONLY on the stdin path + content, never on the auto-bridge or any
13
- # session id, so it runs unconditionally (headless / background sessions).
14
- # It validates ONLY the intent file, never sentinel placeholder lifecycle files
15
- # (spec.md/plan.md/etc.); the path matcher excludes them.
16
- #
17
- # Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
18
- #
19
- # Reads the Claude Code PreToolUse payload as JSON on STDIN:
20
- # { "tool_input": { "file_path": "...", "content": "..." } }
21
- # Empty / unparseable / non-matching path => exit 0 (cannot judge, allow).
22
- # Three payload shapes on a matching path (intent 108, D7):
23
- # Write (content): validate the proposed content.
24
- # Edit (old_string): simulate the replacement on the on-disk file and
25
- # validate the RESULT; a missing file blocks.
26
- # Pathless MCP mutation: validate the CURRENT on-disk file (the PostToolUse
27
- # gate-check backstop validates the result); a
28
- # missing file blocks (fail-safe).
29
- #
30
- # Thin CLI wrapper (intent 244): the gate logic itself lives in
31
- # scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
32
- # (scripts/hook-edit-gates) and, since intent 251, the merged Codex dispatcher
33
- # (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
34
- # either harness anymore; it remains as the per-gate isolation surface for the
35
- # hook test suite. Deliberately WITHOUT a top-level rescue, matching this
36
- # script's behavior before the extraction: an internal exception here has
37
- # never been caught.
38
-
39
- require "json"
40
- require_relative "lib/edit_gates"
41
-
42
- raw = $stdin.read rescue nil
43
- exit 0 if raw.nil? || raw.strip.empty?
44
-
45
- payload = JSON.parse(raw) rescue nil
46
- exit 0 unless payload.is_a?(Hash)
47
-
48
- outcome = EditGates.create_gate(EditGates.context_from(payload))
49
- exit 0 unless outcome
50
- outcome.lines.each { |line| $stderr.puts line }
51
- exit 2
@@ -1,58 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # encoding: UTF-8
3
- # frozen_string_literal: true
4
-
5
- # The merged Claude PreToolUse edit-path dispatcher (intent 244). One registered
6
- # hook, one Ruby process, one stdin parse, five gates in-process, in the fixed
7
- # order savepoint-pre, lock-gate, code-gate, links-gate, create-gate (spec D-a).
8
- # The first deny ends evaluation and its own shape is reproduced exactly: stderr
9
- # plus exit 2 for code-gate/links-gate/create-gate, stdout permissionDecision
10
- # JSON plus exit 0 for lock-gate (spec D-b). savepoint-pre runs first so its
11
- # `started` ledger append stays unconditional, including on a call another gate
12
- # then blocks (spec D-c).
13
- #
14
- # Fail-open everywhere (spec D-i): each gate runs inside its own rescue in
15
- # EditGates.dispatch, and this file's top level rescues to exit 0, so a gate bug
16
- # never denies and never silences a sibling gate.
17
- #
18
- # LOAD-BEARING SHAPE: doctor's claude_hooks_implemented_check reads THIS FILE as
19
- # plain text and extracts the `when "<gate>"` labels of the `case gate` statement
20
- # below, because a script with top-level side effects can never be required to
21
- # introspect it (the same technique intent 200 used for scripts/codex-hook). If
22
- # the case statement is reshaped, update that check in scripts/doctor.rb in the
23
- # same change, or it will fail loudly by design.
24
-
25
- require "json"
26
- require_relative "lib/edit_gates"
27
-
28
- def route(gate, ctx)
29
- case gate
30
- when "savepoint-pre" then EditGates.savepoint_pre(ctx)
31
- when "lock-gate" then EditGates.lock_gate(ctx)
32
- when "code-gate" then EditGates.code_gate(ctx)
33
- when "links-gate" then EditGates.links_gate(ctx)
34
- when "create-gate" then EditGates.create_gate(ctx)
35
- end
36
- end
37
-
38
- begin
39
- raw = begin
40
- $stdin.read
41
- rescue StandardError
42
- nil
43
- end
44
- exit 0 if raw.nil? || raw.strip.empty?
45
-
46
- payload = begin
47
- JSON.parse(raw)
48
- rescue StandardError
49
- nil
50
- end
51
- exit 0 unless payload.is_a?(Hash)
52
-
53
- exit EditGates.dispatch(ctx: EditGates.context_from(payload), route: method(:route))
54
- rescue StandardError => e
55
- # Dispatcher-internal failure: never impersonate a deny (spec D-i).
56
- $stderr.puts "plastic edit-gates error: #{e.message}"
57
- exit 0
58
- end
@@ -1,90 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # encoding: UTF-8
3
- # Usage: hook-future-intent-check <store_root> <message>
4
- # Outputs hook JSON when the user's message matches future intent keywords.
5
- # Exits silently if no matches.
6
-
7
- require "json"
8
- require "yaml"
9
-
10
- store_root, message = ARGV
11
- exit 0 unless store_root && message
12
-
13
- index_path = "#{store_root}/INDEX.md"
14
- exit 0 unless File.exist?(index_path)
15
-
16
- # --- Find future intent dirs ---
17
-
18
- lines = File.readlines(index_path)
19
- future_dirs = []
20
- section = nil
21
-
22
- lines.each do |line|
23
- section = :future if line.start_with?("## Future")
24
- section = nil if line.start_with?("## ") && !line.start_with?("## Future")
25
- next unless section == :future && line.strip.start_with?("- [")
26
-
27
- if line =~ /store\/([\w-]+)\//
28
- future_dirs << $1
29
- end
30
- end
31
-
32
- exit 0 if future_dirs.empty?
33
-
34
- # --- Match keywords against message ---
35
-
36
- matches = []
37
- message_words = message.split(/\W+/).reject { |w| w.length < 4 }
38
-
39
- future_dirs.each do |dir|
40
- intent_path = "#{store_root}/store/#{dir}/#{dir}.md"
41
- next unless File.exist?(intent_path)
42
-
43
- content = File.read(intent_path)
44
-
45
- tags = []
46
- if content =~ /^tags:\s*\[([^\]]+)\]/
47
- tags = $1.split(",").map(&:strip).map(&:downcase)
48
- end
49
-
50
- intent_name = ""
51
- if content =~ /^intent:\s*["']?(.+?)["']?\s*$/
52
- intent_name = $1.downcase
53
- end
54
-
55
- intent_id = ""
56
- if content =~ /^id:\s*["']?([a-z0-9]+)["']?\s*$/
57
- intent_id = $1
58
- end
59
-
60
- keywords = tags + intent_name.split(/\W+/).reject { |w| w.length < 4 }
61
- keywords = keywords.map(&:downcase).uniq
62
-
63
- matched_keywords = keywords.select { |kw| message.include?(kw) }
64
- name_matches = message_words.select { |w| intent_name.include?(w) }
65
- matched_keywords = (matched_keywords + name_matches).uniq
66
-
67
- if matched_keywords.length >= 1
68
- index_line = lines.find { |l| l.include?(dir) }&.strip || "#{intent_id} — #{intent_name}"
69
- matches << { "index_line" => index_line, "keywords" => matched_keywords }
70
- end
71
- end
72
-
73
- exit 0 if matches.empty?
74
-
75
- # --- Build context ---
76
-
77
- parts = []
78
- parts << "PLASTIC — Future intents related to this message:\n"
79
- matches.each do |m|
80
- parts << "#{m["index_line"]} (matched: #{m["keywords"].join(", ")})"
81
- end
82
- parts << "\nConsider asking the user if they want to activate any of these, or note the connection."
83
-
84
- payload = {
85
- "hookSpecificOutput" => {
86
- "hookEventName" => "UserPromptSubmit",
87
- "additionalContext" => parts.join("\n")
88
- }
89
- }
90
- puts JSON.generate(payload)
@@ -1,169 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # encoding: UTF-8
3
- # Usage: hook-gate-check <file_path> [session_id]
4
- # PostToolUse gate enforcement for Plastic intent lifecycle.
5
- # Checks if a written file triggers a stage-transition gate.
6
- # Exit 0 = allow (optionally with transition context)
7
- # Exit 2 = block (gate violation)
8
-
9
- require "json"
10
- require_relative "lib/bridge"
11
- require_relative "lib/intent_validator"
12
- require_relative "lib/lock"
13
-
14
- file_path = ARGV[0]
15
- exit 0 unless file_path && !file_path.empty?
16
-
17
- session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
18
-
19
- file_path_abs = File.expand_path(file_path)
20
-
21
- # --- Decoupled savepoint ledger (intent 34, hardened in intent 52) ---
22
- # The savepoint is derived purely from the file path's intent directory, BEFORE
23
- # any bridge resolution. A missing bridge must never skip the savepoint.
24
- intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
25
- if intent_dir_abs
26
- begin
27
- Bridge.append_savepoint(intent_dir_abs, file_path_abs)
28
- # When checklist.md lands, How ends and Exec begins: emit the `Exec started`
29
- # companion in the same event (intent 81). Guard on a real (non-placeholder)
30
- # checklist so a scaffold sentinel does not trip it.
31
- if File.basename(file_path_abs) == "checklist.md" && Bridge.stage_file_present?(file_path_abs)
32
- Bridge.append_exec_started(intent_dir_abs)
33
- end
34
- rescue StandardError
35
- # ignore — rebuildable from disk
36
- end
37
- end
38
-
39
- # --- Artifact-validity backstop (intent 4a1c1) ---
40
- # When the written file IS the intent file itself (the `<id>--<slug>.md` directly
41
- # inside `store/<id>--<slug>/`), run IntentValidator on it. This is headless-safe:
42
- # it depends only on the file path and disk, never on a bridge or session. In the
43
- # PostToolUse model the write has already happened, so we cannot prevent it; the
44
- # loud non-zero exit + stderr is the rejection signal. NOT for spec.md/plan.md/
45
- # checklist.md/outcome.md/savepoint.md — those are validated by the stage gates.
46
- if intent_dir_abs && File.basename(file_path_abs) == File.basename(Bridge.intent_file(intent_dir_abs))
47
- result = IntentValidator.validate(intent_dir_abs)
48
- unless result[:ok]
49
- warn "PLASTIC ARTIFACT INVALID — #{File.basename(file_path_abs)} is not born complete:"
50
- result[:missing].each { |field| warn " missing required field: #{field}" }
51
- result[:errors].each { |error| warn " #{error}" }
52
- warn "Fix the frontmatter; the intent is not valid until every required field is present and sources/chain are well-formed."
53
- exit 1
54
- end
55
- end
56
-
57
- # --- Find bridge file ---
58
- bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
59
-
60
- # No bridge = no active intent = no stage enforcement. Savepoint already handled.
61
- exit 0 unless bridge_data
62
- session = bridge_data["session"]
63
-
64
- # --- Resolve intent directory ---
65
-
66
- intent_info = bridge_data["intent"]
67
- exit 0 unless intent_info
68
-
69
- store = intent_info["store"]
70
- dir = intent_info["dir"]
71
- intent_dir = "#{store}/#{dir}"
72
-
73
- # Normalize both paths for comparison
74
- bridge_intent_dir_abs = File.expand_path(intent_dir)
75
-
76
- # Lease heartbeat (intent 108, D1): every write by the session that owns (or
77
- # delegates under) the lock refreshes the delivery.lock mtime. Best-effort:
78
- # a heartbeat failure must never break the gate hook.
79
- begin
80
- Lock.heartbeat(bridge_intent_dir_abs, session: session)
81
- rescue StandardError
82
- # ignore
83
- end
84
-
85
- # Not inside intent dir = not our business
86
- exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
87
-
88
- # --- Determine if this is a stage-transition file ---
89
-
90
- relative = file_path_abs.sub("#{bridge_intent_dir_abs}/", "")
91
- basename = File.basename(file_path)
92
-
93
- stage_files = %w[spec.md plan.md checklist.md outcome.md]
94
- is_stage_file = stage_files.include?(basename)
95
- is_action_file = relative.start_with?("actions/")
96
-
97
- if is_stage_file || is_action_file
98
- # Run gate check
99
- error = Bridge.check_gate(bridge_intent_dir_abs, file_path_abs)
100
-
101
- if error
102
- # Block log (intent 229): same hand-rolled best-effort write as
103
- # hook-bash-gate's; this script keeps its current requires.
104
- begin
105
- require "fileutils"
106
- require "time"
107
- blocklog = File.join(Dir.home, ".plastic", ".cache", "gate-blocks.log")
108
- FileUtils.mkdir_p(File.dirname(blocklog))
109
- File.open(blocklog, "a") do |io|
110
- io.puts([Time.now.utc.iso8601, "gate-check", session.to_s,
111
- Bridge.intent_id_from_dir(bridge_intent_dir_abs).to_s,
112
- file_path_abs.to_s.gsub(/\s+/, " ").strip,
113
- error.to_s.gsub(/\s+/, " ").strip].join("\t"))
114
- end
115
- rescue StandardError
116
- # the block still applies; logging is best-effort
117
- end
118
-
119
- # Gate violation — block the write
120
- bridge_data["build"]["gate_failures"] = (bridge_data["build"]["gate_failures"] || 0) + 1
121
- Bridge.write(session, bridge_data)
122
-
123
- payload = {
124
- "decision" => "block",
125
- "reason" => "PLASTIC GATE — #{error}"
126
- }
127
- puts JSON.generate(payload)
128
- exit 2
129
- end
130
-
131
- # Gate passed — update bridge with new stage
132
- old_stage = bridge_data["build"]["stage"]
133
- new_stage = Bridge.derive_stage(bridge_intent_dir_abs)
134
- new_missing = Bridge.missing_for_stage(new_stage) - Bridge.has_files(bridge_intent_dir_abs)
135
-
136
- bridge_data["build"]["stage"] = new_stage
137
- bridge_data["build"]["has"] = Bridge.has_files(bridge_intent_dir_abs)
138
- bridge_data["build"]["missing"] = new_missing
139
- bridge_data["build"]["gate_failures"] = 0
140
- bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
141
-
142
- if old_stage != new_stage
143
- bridge_data["observe"]["last_transition"] = "#{old_stage} → #{new_stage}"
144
- end
145
-
146
- Bridge.write(session, bridge_data)
147
-
148
- # Build transition context — ONE concise sentence (intent 84, Lever 1),
149
- # preserving the `Next: ...` hint. Formatting is pure in Bridge.gate_narration.
150
- context = Bridge.gate_narration(
151
- old_stage: old_stage, new_stage: new_stage,
152
- basename: basename, new_missing: new_missing
153
- )
154
-
155
- payload = {
156
- "hookSpecificOutput" => {
157
- "hookEventName" => "PostToolUse",
158
- "additionalContext" => context
159
- }
160
- }
161
- puts JSON.generate(payload)
162
- exit 0
163
- else
164
- # Regular file in intent dir — just update last_activity. The savepoint for
165
- # the What milestone was already handled above by the decoupled ledger write.
166
- bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
167
- Bridge.write(session, bridge_data)
168
- exit 0
169
- end
@@ -1,54 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # encoding: UTF-8
3
- # frozen_string_literal: true
4
-
5
- # PreToolUse links gate (intent 192): denies an Edit/Write whose payload
6
- # changes a REAL ## Links section to anything other than exactly what
7
- # LinksProjection would emit for the file's own frontmatter. This is the
8
- # write-time belt for the PLASTIC.md ## Links contract; scripts/project-links
9
- # is the safe-repair path this gate always leaves open (it runs via Bash, a
10
- # tool this gate does not intercept).
11
- #
12
- # Reads the PreToolUse payload as JSON on STDIN:
13
- # { "tool_input": { "file_path":, "content": | "old_string"/"new_string"/"replace_all": } }
14
- # Exit 0 = allow (including every "cannot judge" case). Exit 2 = deny (message
15
- # on stderr).
16
- #
17
- # Thin CLI wrapper (intent 244): the gate logic itself lives in
18
- # scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
19
- # (scripts/hook-edit-gates) and, since intent 251, the merged Codex dispatcher
20
- # (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
21
- # either harness anymore; it remains as the per-gate isolation surface for the
22
- # hook test suite.
23
-
24
- require "json"
25
- require_relative "lib/edit_gates"
26
-
27
- begin
28
- raw = begin
29
- $stdin.read
30
- rescue StandardError
31
- nil
32
- end
33
- exit 0 if raw.nil? || raw.strip.empty?
34
-
35
- payload = begin
36
- JSON.parse(raw)
37
- rescue StandardError
38
- nil
39
- end
40
- exit 0 unless payload.is_a?(Hash)
41
-
42
- outcome = EditGates.links_gate(EditGates.context_from(payload))
43
- exit 0 unless outcome
44
- outcome.lines.each { |line| $stderr.puts line }
45
- exit 2
46
- rescue => e
47
- # Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
48
- # here so a gate bug (an unreadable UNRELATED store dir, a resolver crash, or
49
- # anything else) never bricks editing; mirrors hook-lock-gate's own top-level
50
- # rescue (ruling: the gate must fail open, never closed, on a computation it
51
- # cannot complete).
52
- $stderr.puts "plastic links-gate error: #{e.message}"
53
- exit 0
54
- end
@@ -1,47 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # encoding: UTF-8
3
- # frozen_string_literal: true
4
- #
5
- # Usage: hook-lock-gate <file_path> [session_id] [harness]
6
- # Fail-CLOSED PreToolUse gate (intent 96). Blocks a mutating write to an active
7
- # intent's lifecycle dir when THIS session holds no live lock. Project code is NOT
8
- # gated here (D2). Emits the PreToolUse JSON deny contract at exit 0; reserves
9
- # non-zero exit for hook-internal errors only. Matcher Write|Edit|NotebookEdit, so
10
- # reads never reach here.
11
- #
12
- # Thin CLI wrapper (intent 244): the gate logic itself lives in
13
- # scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
14
- # (scripts/hook-edit-gates) and, since intent 251, the merged Codex dispatcher
15
- # (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
16
- # either harness anymore; it remains as the per-gate isolation surface for the
17
- # hook test suite.
18
-
19
- require_relative "lib/edit_gates"
20
-
21
- begin
22
- file_path = ARGV[0]
23
- exit 0 unless file_path && !file_path.empty?
24
- session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
25
- # Usage: hook-lock-gate <file_path> [session_id] [harness]. Claude's own bash
26
- # shim never passes a third arg, so harness falls through to Lock's default
27
- # (claude, slash form). Since intent 251 the merged Codex library
28
- # (scripts/lib/codex_edit_gates.rb) sets harness: "codex" directly on the
29
- # Context it builds; no caller of this wrapper passes "codex" anymore.
30
- harness = (ARGV[2] unless ARGV[2].to_s.empty?) || "claude"
31
-
32
- ctx = EditGates::Context.new(
33
- tool_name: "Write", session: session, gate_path: file_path, file_path: file_path,
34
- harness: harness,
35
- )
36
-
37
- outcome = EditGates.lock_gate(ctx)
38
- exit 0 unless outcome
39
- print outcome.stdout
40
- exit 0
41
- rescue => e
42
- # Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
43
- # here so a gate bug never bricks editing; the fail-CLOSED intent is about MISSING
44
- # locks, not crashes.
45
- $stderr.puts "plastic lock-gate error: #{e.message}"
46
- exit 0
47
- end
@@ -1,38 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # encoding: UTF-8
3
- # frozen_string_literal: true
4
-
5
- # power-tools UserPromptSubmit hook (intents 66, 66b, 187, 246). Emits the
6
- # PowerTools mandate, one recommendation line per present tool, as
7
- # hookSpecificOutput. Silent no-op (exit 0) when no power tool is present or
8
- # anything goes wrong. It parses no prompt: the mandate depends on tool presence
9
- # alone, so there is nothing to gate on prompt triviality. Intent 246 removed the
10
- # qmd hit search this hook used to run first.
11
- require "json"
12
- require "timeout"
13
- require_relative "lib/qmd_hook"
14
-
15
- # Drain stdin so the caller's write never blocks or breaks on a closed pipe. The
16
- # payload is deliberately not parsed.
17
- begin
18
- STDIN.read
19
- rescue StandardError
20
- nil
21
- end
22
-
23
- context = begin
24
- Timeout.timeout(2) do
25
- QmdHook.run(cwd: Dir.pwd)
26
- end
27
- rescue Exception
28
- nil
29
- end
30
-
31
- exit 0 if context.nil? || context.strip.empty?
32
-
33
- puts JSON.generate(
34
- "hookSpecificOutput" => {
35
- "hookEventName" => "UserPromptSubmit",
36
- "additionalContext" => context,
37
- }
38
- )
@@ -1,32 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # encoding: UTF-8
3
- # frozen_string_literal: true
4
- # Usage: hook-savepoint-pre <file_path>
5
- #
6
- # PreToolUse savepoint trigger (intent 81). When a stage-opening lifecycle file
7
- # (spec.md => Why, plan.md => How) is ABOUT to be written into an intent dir,
8
- # append the pre-stage `started` ledger line so the ledger records "this stage
9
- # was entered" before its artifact lands.
10
- #
11
- # Like the PostToolUse decoupled savepoint write (intent 52), it is derived from
12
- # the file path alone: no bridge, no session, fires headless. It NEVER blocks a
13
- # write: any non-match or failure exits 0. The append is idempotent and only
14
- # fires while the stage is genuinely starting (the artifact is not yet a real,
15
- # non-placeholder file), so re-edits add nothing.
16
- #
17
- # Thin CLI wrapper (intent 244): the gate logic itself lives in
18
- # scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
19
- # (scripts/hook-edit-gates) and, since intent 251, the merged Codex dispatcher
20
- # (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
21
- # either harness anymore; it remains as the per-gate isolation surface for the
22
- # hook test suite.
23
-
24
- require_relative "lib/edit_gates"
25
-
26
- file_path = ARGV[0]
27
- exit 0 if file_path.nil? || file_path.empty?
28
-
29
- EditGates.savepoint_pre(
30
- EditGates::Context.new(tool_name: "Write", file_path: file_path, harness: "claude")
31
- )
32
- exit 0
@@ -1,138 +0,0 @@
1
- # encoding: UTF-8
2
- # frozen_string_literal: true
3
-
4
- # CodexEditGates (intent 251): the merged Codex apply_patch edit path. One
5
- # process, one stdin parse, one envelope parse, five gates in-process.
6
- #
7
- # Before this, ~/.codex/hooks.json registered five separate PreToolUse commands
8
- # on the apply_patch matcher and three of them spawned a further run_core child
9
- # per file operation: eight processes per edit, ten across a full edit cycle.
10
- # This is the Codex mirror of what intent 244 did for Claude (spec D-e of that
11
- # intent parked it as a successor).
12
- #
13
- # Every DECISION here comes from scripts/lib/edit_gates.rb, the same functions
14
- # scripts/hook-edit-gates drives for Claude. This file contributes exactly three
15
- # Codex-specific things and nothing else:
16
- # 1. the per-operation Context (the patch envelope carries a path plus ADDED
17
- # lines, not Claude's file_path plus content pair),
18
- # 2. the add-only rule for create-gate (Update/Delete/Move defer to the
19
- # PostToolUse gate-check backstop, intent 102 spec Decision 5), and
20
- # 3. the two-pass evaluation that keeps savepoint-pre's ledger append
21
- # unconditional across a multi-operation patch (intent 244 spec D-c).
22
- #
23
- # NO SUBPROCESS MAY EVER BE ADDED TO THIS FILE. That is the whole point of the
24
- # intent, and test/codex_edit_gates_test.rb asserts it against this source.
25
- require "stringio"
26
- require_relative "edit_gates"
27
- require_relative "hook_registry"
28
-
29
- module CodexEditGates
30
- module_function
31
-
32
- # Pass 1 and pass 2 of the two-pass evaluation (spec D3). Both slices preserve
33
- # HookRegistry::CODEX_GATE_TOOLS key order, which is Claude's evaluation order
34
- # (spec D5): savepoint-pre, lock-gate, code-gate, links-gate, create-gate.
35
- SAVEPOINT_TOOLS = HookRegistry::CODEX_GATE_TOOLS.select { |gate, _| gate == "savepoint-pre" }.freeze
36
- DENY_TOOLS = HookRegistry::CODEX_GATE_TOOLS.reject { |gate, _| gate == "savepoint-pre" }.freeze
37
-
38
- # One Context per file operation. gate_path and file_path both carry the
39
- # envelope's raw path, exactly as the five popened cores received it in ARGV[0]
40
- # before the merge. content is always a String (never nil), because
41
- # ApplyPatchEnvelope gives a Delete op a nil added_content and today's
42
- # dispatcher already called .to_s on it at every call site. harness is "codex"
43
- # so lock-gate and the claim gate render deny reasons in the $plastic-<name>
44
- # form (intent 201 D2).
45
- def context_for(payload, op, env: ENV)
46
- session = payload["session_id"]
47
- session = env["CLAUDE_CODE_SESSION_ID"] if session.to_s.empty?
48
-
49
- EditGates::Context.new(
50
- tool_name: payload["tool_name"],
51
- session: (session unless session.to_s.empty?),
52
- gate_path: op.path,
53
- file_path: op.path,
54
- savepoint_path: op.path,
55
- content: op.added_content.to_s,
56
- harness: "codex",
57
- )
58
- end
59
-
60
- # create-gate is a pre-write veto for ADD operations only (spec D2).
61
- # EditGates.create_gate has no such rule: fed a partial Update hunk as content
62
- # it would run IntentValidator on a fragment and FALSE-DENY a legitimate write.
63
- def create_gate(ctx, op)
64
- return EditGates::ALLOW unless op.op == :add
65
- EditGates.create_gate(ctx)
66
- end
67
-
68
- def route_for(op)
69
- lambda do |gate, ctx|
70
- case gate
71
- when "savepoint-pre" then EditGates.savepoint_pre(ctx)
72
- when "lock-gate" then EditGates.lock_gate(ctx)
73
- when "code-gate" then EditGates.code_gate(ctx)
74
- when "links-gate" then EditGates.links_gate(ctx)
75
- when "create-gate" then create_gate(ctx, op)
76
- end
77
- end
78
- end
79
-
80
- # True iff tool_name is a NON-EMPTY string that no CODEX_GATE_TOOLS entry
81
- # matches. An empty/missing tool_name already means "the harness matched but
82
- # did not name the tool" and every gate runs (EditGates.tool_applies?'s own
83
- # empty-name rule); this is the OTHER unmatched case, a non-empty tool_name
84
- # this dispatcher does not recognize. Checked once against "apply_patch",
85
- # since every CODEX_GATE_TOOLS value is the identical single-entry list.
86
- def unexpected_tool_name?(tool_name)
87
- return false if tool_name.to_s.empty?
88
- !EditGates.tool_applies?(tool_name, %w[apply_patch])
89
- end
90
-
91
- # Returns the process exit code. Two passes (spec D3):
92
- # pass 1 runs savepoint-pre over EVERY op. It never denies, and running it
93
- # before any deny check keeps its started ledger append unconditional, so
94
- # op B's ledger line still lands when op A is blocked.
95
- # pass 2 runs the four denying gates per op, first deny wins.
96
- # EditGates.dispatch returns 0 both for "allowed" and for lock-gate's JSON
97
- # deny, so pass 2 captures stdout per op: a non-empty buffer IS a deny, and it
98
- # is printed and evaluation stops. Without that capture a second op could emit
99
- # a second deny JSON.
100
- #
101
- # Codex only ever invokes this dispatcher from the apply_patch matcher, so by
102
- # the time we get here the harness HAS already matched, regardless of what
103
- # tool_name the payload itself carries. A non-empty, unrecognized tool_name
104
- # (a future Codex rename, or a malformed/adversarial payload) must therefore
105
- # never silently skip every gate: that is the coverage WEAKENING
106
- # EditGates.tool_applies?'s own empty-name rule exists to prevent, just
107
- # triggered from the opposite direction (an unrecognized name here, not a
108
- # missing one there). Loud and safe: warn once to err and run all five gates
109
- # exactly as if tool_name had been "apply_patch", rather than silent and
110
- # permissive.
111
- def dispatch(ops:, payload:, out: $stdout, err: $stderr)
112
- if unexpected_tool_name?(payload["tool_name"])
113
- err.puts "plastic codex edit-gates: unexpected tool_name #{payload["tool_name"].inspect}; " \
114
- "the apply_patch matcher already selected this dispatcher, so all five gates run anyway"
115
- payload = payload.merge("tool_name" => "apply_patch")
116
- end
117
-
118
- pairs = ops.map { |op| [op, context_for(payload, op)] }
119
-
120
- pairs.each do |op, ctx|
121
- EditGates.dispatch(ctx: ctx, route: route_for(op), gate_tools: SAVEPOINT_TOOLS,
122
- out: StringIO.new, err: err)
123
- end
124
-
125
- pairs.each do |op, ctx|
126
- buffer = StringIO.new
127
- code = EditGates.dispatch(ctx: ctx, route: route_for(op), gate_tools: DENY_TOOLS,
128
- out: buffer, err: err)
129
- return code unless code.zero?
130
- next if buffer.string.empty?
131
-
132
- out.print buffer.string
133
- return 0
134
- end
135
-
136
- 0
137
- end
138
- end