@zalom/plastic 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/PLASTIC.md +23 -8
  2. package/README.md +15 -4
  3. package/agents/plastic-enforcer.md +3 -2
  4. package/agents/plastic-intent-discovery.md +7 -0
  5. package/bin/plastic.js +17 -8
  6. package/hooks/auto-arm +2 -2
  7. package/hooks/bash-gate +1 -1
  8. package/hooks/check-update +1 -1
  9. package/hooks/continue +2 -2
  10. package/hooks/edit-gates +1 -1
  11. package/hooks/future-intent-check +2 -2
  12. package/hooks/gate-check +3 -3
  13. package/hooks/power-tools +1 -1
  14. package/hooks/session-start +1 -1
  15. package/package.json +1 -1
  16. package/scripts/codex-hook +50 -106
  17. package/scripts/doctor.rb +132 -1080
  18. package/scripts/exec-worktree +103 -0
  19. package/scripts/hash-intent +1 -1
  20. package/scripts/hook-bash-gate +19 -0
  21. package/scripts/hook-code-gate +4 -1
  22. package/scripts/hook-continue +2 -2
  23. package/scripts/hook-create-gate +6 -3
  24. package/scripts/hook-gate-check +17 -0
  25. package/scripts/hook-links-gate +4 -1
  26. package/scripts/hook-lock-gate +7 -3
  27. package/scripts/hook-savepoint-pre +4 -1
  28. package/scripts/hook-session-start +21 -15
  29. package/scripts/lib/apply_patch_envelope.rb +46 -13
  30. package/scripts/lib/bridge.rb +83 -15
  31. package/scripts/lib/codex_edit_gates.rb +138 -0
  32. package/scripts/lib/doctor_core.rb +1087 -0
  33. package/scripts/lib/edit_gates.rb +61 -5
  34. package/scripts/lib/exec_worktree.rb +325 -0
  35. package/scripts/lib/harness_text.rb +57 -0
  36. package/scripts/lib/hook_registry.rb +32 -28
  37. package/scripts/lib/installer_core.rb +67 -7
  38. package/scripts/lib/lock.rb +196 -47
  39. package/scripts/lib/ruby_probe.rb +60 -0
  40. package/scripts/lib/scaffold_intent.rb +392 -0
  41. package/scripts/lib/spec_header.rb +83 -0
  42. package/scripts/lib/start_intent.rb +296 -0
  43. package/scripts/lib/verify_intent.rb +262 -0
  44. package/scripts/lib/worktree.rb +15 -1
  45. package/scripts/link-suggest +1 -1
  46. package/scripts/maintenance-run +5 -5
  47. package/scripts/migrate-to-global +2 -2
  48. package/scripts/restore-intent-v1 +1 -1
  49. package/scripts/scaffold-intent +120 -0
  50. package/scripts/start-intent +89 -0
  51. package/scripts/verify-intent +73 -0
  52. package/skills/agent-advisor/SKILL.md +5 -5
  53. package/skills/auto/SKILL.md +42 -32
  54. package/skills/auto/references/agent-architecture.md +1 -1
  55. package/skills/auto/references/agent-report-contract.md +1 -1
  56. package/skills/auto/references/human-report-contract.md +22 -3
  57. package/skills/auto/references/tiers.md +24 -2
  58. package/skills/conventions/references/completion-and-done.md +3 -0
  59. package/skills/conventions/references/gates-and-enforcement.md +28 -12
  60. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  61. package/skills/conventions/references/tiers-and-dispatch.md +7 -6
  62. package/skills/dashboard/SKILL.md +1 -1
  63. package/skills/doctor/SKILL.md +6 -5
  64. package/skills/doctor/references/gates-stuck-detection.md +13 -8
  65. package/skills/doctor/report.md +1 -1
  66. package/skills/install/SKILL.md +1 -1
  67. package/skills/intent-brainstorming/SKILL.md +0 -2
  68. package/skills/intent-creating/SKILL.md +6 -6
  69. package/skills/intent-creating/references/lifecycle.md +1 -1
  70. package/skills/intent-discovering/SKILL.md +10 -3
  71. package/skills/intent-ending/SKILL.md +8 -7
  72. package/skills/intent-executing/SKILL.md +27 -19
  73. package/skills/intent-grilling/SKILL.md +5 -3
  74. package/skills/intent-planning/SKILL.md +7 -3
  75. package/skills/intent-researching/SKILL.md +0 -2
  76. package/skills/intent-starting/SKILL.md +10 -2
  77. package/skills/project-creating/SKILL.md +0 -2
  78. package/skills/project-creating/references/project-scaffolding.md +1 -1
  79. package/skills/releasing/SKILL.md +1 -1
  80. package/skills/releasing/references/promotion-and-tagging.md +14 -8
  81. package/skills/releasing/references/release-lines.md +1 -1
  82. package/skills/skill-creating/SKILL.md +5 -2
  83. package/skills/store-indexing/SKILL.md +8 -5
  84. package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
  85. package/skills/tutorial/references/track-1-guided.md +2 -2
  86. package/skills/tutorial/references/track-2-auto.md +9 -6
  87. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
  88. package/skills/uninstall/SKILL.md +6 -9
  89. package/templates/agents.md +12 -12
  90. package/templates/config.yml +6 -7
  91. package/templates/index.md +6 -3
  92. package/templates/spec.md +1 -1
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # exec-worktree (intent 213, group 2) - finish an intent's code worktree: an order
6
+ # precondition, then Worktree.finish (delivered merges + removes, abandoned removes only).
7
+ #
8
+ # Usage:
9
+ # exec-worktree --store <path> --id <intent_id> --home <plastic_home> \
10
+ # --disposition delivered|abandoned [--session <session_id>]
11
+ #
12
+ # --home accepts either spelling: the OS-home level (the PARENT of .plastic) or the
13
+ # .plastic directory itself. When the given path's basename is literally .plastic, its
14
+ # dirname is used instead. Worktree.finish's home: keyword always wants the OS-home
15
+ # level (Worktree.home_from_store, scripts/lib/worktree.rb:90); this script normalizes
16
+ # both spellings to that so either caller convention works.
17
+ #
18
+ # Steps performed:
19
+ # 0. Resolve inputs: the intent dir, normalize --home, resolve the calling session
20
+ # (--session, else CLAUDE_CODE_SESSION_ID, else the existing delivery.lock's own
21
+ # recorded owner_session when non-blank, else nil; unlike start-intent, the
22
+ # lock-owner fallback IS used here, matching end-intent's teardown resolution order).
23
+ # Load the bridge (Bridge.read); a nil bridge means there is nothing to finish (exit
24
+ # 0, loud: the worktree this intent provisioned, if any, was NOT removed). No
25
+ # worktree.code on the bridge means nothing was provisioned (exit 0).
26
+ # 1. The order precondition (delivered only): calls the EXISTING
27
+ # Bridge.code_gate_decision predicate exactly as it exists. On an auto bridge that
28
+ # has not reached How, this BLOCKS (exit 2). Bridge.code_gate_decision returns nil
29
+ # UNCONDITIONALLY on a guided bridge (build.auto != true), so on a guided bridge
30
+ # this precondition is ADVISORY ONLY: it always passes and the run proceeds. This
31
+ # script is NOT the enforcement point either way; the hook layer
32
+ # (scripts/lib/bridge.rb code_gate_decision, wired through scripts/lib/edit_gates.rb)
33
+ # remains the actual gate. See scripts/lib/exec_worktree.rb's module doc for why the
34
+ # auto-only condition is never relaxed and never duplicated here.
35
+ # 2. The dirty-worktree guard (delivered only): git status --porcelain on the code
36
+ # worktree. Not clean, or the check itself fails, fails CLOSED (exit 3), mirroring
37
+ # end-intent's dirty-worktree handling. abandoned skips this guard entirely: the
38
+ # branch survives and can be reclaimed, so a dirty worktree is not a reason to
39
+ # refuse.
40
+ # 3. Finish: Worktree.finish(bridge_data, home:, runner:, merge: disposition ==
41
+ # "delivered"). Worktree.finish is fail-open and never raises, so failure is
42
+ # detected by checking, for a delivered disposition only, whether the worktree
43
+ # directory still exists on disk afterward (release removes it on success).
44
+ #
45
+ # No test suite is run by this script, in any form. verify-intent owns test execution;
46
+ # one concern per script (spec D8).
47
+ #
48
+ # Exit codes:
49
+ # 0 finished (merged or removed per disposition), or nothing to finish (no bridge
50
+ # resolved, or no code worktree recorded on the bridge)
51
+ # 1 usage or path-resolution failure
52
+ # 2 the order precondition blocked a delivered disposition (auto bridge, How not
53
+ # reached)
54
+ # 3 the worktree is not clean, or the merge/removal did not complete (delivered only)
55
+ # 4 lock or session resolution failure
56
+
57
+ require_relative "lib/exec_worktree"
58
+
59
+ def parse_args(argv)
60
+ opts = { store: nil, id: nil, home: nil, disposition: nil, session: nil }
61
+ i = 0
62
+ while i < argv.length
63
+ arg = argv[i]
64
+ case arg
65
+ when "--store" then opts[:store] = argv[i += 1]
66
+ when "--id" then opts[:id] = argv[i += 1]
67
+ when "--home" then opts[:home] = argv[i += 1]
68
+ when "--disposition" then opts[:disposition] = argv[i += 1]
69
+ when "--session" then opts[:session] = argv[i += 1]
70
+ else
71
+ usage_abort("unknown argument #{arg.inspect}")
72
+ end
73
+ i += 1
74
+ end
75
+ opts
76
+ end
77
+
78
+ def usage
79
+ "usage: exec-worktree --store <store_path> --id <intent_id> --home <plastic_home> " \
80
+ "--disposition delivered|abandoned [--session <session_id>]"
81
+ end
82
+
83
+ def usage_abort(message)
84
+ warn "exec-worktree: #{message}"
85
+ warn usage
86
+ exit 1
87
+ end
88
+
89
+ def main(argv)
90
+ opts = parse_args(argv)
91
+
92
+ result = ExecWorktree.run(
93
+ store: opts[:store], id: opts[:id], home: opts[:home], disposition: opts[:disposition],
94
+ session: opts[:session], env_session: ENV["CLAUDE_CODE_SESSION_ID"]
95
+ )
96
+
97
+ Array(result[:stderr]).each { |line| warn line }
98
+ warn usage if result[:exit_code] == ExecWorktree::EXIT_USAGE
99
+ Array(result[:stdout]).each { |line| puts line }
100
+ exit result[:exit_code]
101
+ end
102
+
103
+ main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -13,7 +13,7 @@ fi
13
13
 
14
14
  # Try Ruby first (macOS/Linux default)
15
15
  if command -v ruby &>/dev/null; then
16
- ruby -r digest -e 'puts Digest::SHA256.hexdigest(ARGV[0]).to_i(16).to_s(36)[0,6]' "$INTENT_NAME"
16
+ env -u RUBYOPT ruby -r digest -e 'puts Digest::SHA256.hexdigest(ARGV[0]).to_i(16).to_s(36)[0,6]' "$INTENT_NAME"
17
17
  exit 0
18
18
  fi
19
19
 
@@ -55,5 +55,24 @@ bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
55
55
  reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd, session: session)
56
56
  exit 0 unless reason
57
57
 
58
+ # Block log (intent 229): one six-field TSV line, hand-rolled here exactly as
59
+ # the escape write above is, so this script keeps requiring only lib/bridge and
60
+ # pays no extra load on every Bash tool call. Best-effort: the block applies
61
+ # regardless.
62
+ begin
63
+ require "fileutils"
64
+ require "time"
65
+ blocklog = File.join(Dir.home, ".plastic", ".cache", "gate-blocks.log")
66
+ FileUtils.mkdir_p(File.dirname(blocklog))
67
+ intent_id = bridge_data.is_a?(Hash) ? bridge_data.dig("intent", "id").to_s : ""
68
+ File.open(blocklog, "a") do |io|
69
+ io.puts([Time.now.utc.iso8601, "bash-gate", session.to_s, intent_id,
70
+ command.to_s.gsub(/\s+/, " ").strip,
71
+ reason.to_s.gsub(/\s+/, " ").strip].join("\t"))
72
+ end
73
+ rescue StandardError
74
+ # the block still applies; logging is best-effort
75
+ end
76
+
58
77
  $stderr.puts "PLASTIC GATE — #{reason}"
59
78
  exit 2
@@ -15,7 +15,10 @@
15
15
  #
16
16
  # Thin CLI wrapper (intent 244): the gate logic itself lives in
17
17
  # scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
18
- # (scripts/hook-edit-gates) so the two harnesses cannot drift.
18
+ # (scripts/hook-edit-gates) and, since intent 251, the merged Codex dispatcher
19
+ # (scripts/lib/codex_edit_gates.rb). This wrapper has no production caller on
20
+ # either harness anymore; it remains as the per-gate isolation surface for the
21
+ # hook test suite.
19
22
 
20
23
  require_relative "lib/edit_gates"
21
24
 
@@ -15,7 +15,7 @@ exit 0 unless index_path && File.exist?(index_path)
15
15
  dashboard = File.expand_path("dashboard.rb", __dir__)
16
16
  exit 0 unless File.exist?(dashboard)
17
17
 
18
- cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
18
+ cockpit, _err, status = Open3.capture3({"RUBYOPT" => nil}, "ruby", dashboard, "continue")
19
19
  exit 0 unless status.success? && !cockpit.strip.empty?
20
20
 
21
21
  context = cockpit.rstrip +
@@ -36,7 +36,7 @@ payload = {
36
36
  # degrades silently, omitting systemMessage; this hook must never crash
37
37
  # UserPromptSubmit over a summary line.
38
38
  begin
39
- data_json, _data_err, data_status = Open3.capture3("ruby", dashboard, "continue", "--data")
39
+ data_json, _data_err, data_status = Open3.capture3({"RUBYOPT" => nil}, "ruby", dashboard, "continue", "--data")
40
40
  if data_status.success?
41
41
  banner = DashboardBanner.render(JSON.parse(data_json))
42
42
  payload["systemMessage"] = banner if banner
@@ -29,9 +29,12 @@
29
29
  #
30
30
  # Thin CLI wrapper (intent 244): the gate logic itself lives in
31
31
  # scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
32
- # (scripts/hook-edit-gates) so the two harnesses cannot drift. Deliberately
33
- # WITHOUT a top-level rescue, matching this script's behavior before the
34
- # extraction: an internal exception here has never been caught.
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.
35
38
 
36
39
  require "json"
37
40
  require_relative "lib/edit_gates"
@@ -99,6 +99,23 @@ if is_stage_file || is_action_file
99
99
  error = Bridge.check_gate(bridge_intent_dir_abs, file_path_abs)
100
100
 
101
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
+
102
119
  # Gate violation — block the write
103
120
  bridge_data["build"]["gate_failures"] = (bridge_data["build"]["gate_failures"] || 0) + 1
104
121
  Bridge.write(session, bridge_data)
@@ -16,7 +16,10 @@
16
16
  #
17
17
  # Thin CLI wrapper (intent 244): the gate logic itself lives in
18
18
  # scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
19
- # (scripts/hook-edit-gates) so the two harnesses cannot drift.
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.
20
23
 
21
24
  require "json"
22
25
  require_relative "lib/edit_gates"
@@ -11,7 +11,10 @@
11
11
  #
12
12
  # Thin CLI wrapper (intent 244): the gate logic itself lives in
13
13
  # scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
14
- # (scripts/hook-edit-gates) so the two harnesses cannot drift.
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.
15
18
 
16
19
  require_relative "lib/edit_gates"
17
20
 
@@ -21,8 +24,9 @@ begin
21
24
  session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
22
25
  # Usage: hook-lock-gate <file_path> [session_id] [harness]. Claude's own bash
23
26
  # shim never passes a third arg, so harness falls through to Lock's default
24
- # (claude, slash form); scripts/codex-hook is the only caller that passes
25
- # "codex" here (intent 201 D2).
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.
26
30
  harness = (ARGV[2] unless ARGV[2].to_s.empty?) || "claude"
27
31
 
28
32
  ctx = EditGates::Context.new(
@@ -16,7 +16,10 @@
16
16
  #
17
17
  # Thin CLI wrapper (intent 244): the gate logic itself lives in
18
18
  # scripts/lib/edit_gates.rb, shared with the merged Claude dispatcher
19
- # (scripts/hook-edit-gates) so the two harnesses cannot drift.
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.
20
23
 
21
24
  require_relative "lib/edit_gates"
22
25
 
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: UTF-8
3
- # Usage: hook-session-start <index_path> <store_root> <mode> [plugin_root]
3
+ # Usage: hook-session-start <index_path> <plastic_home> <mode> [plugin_root]
4
4
  # Outputs hook JSON for the session-start hook. Exits silently if nothing to show.
5
5
 
6
6
  require "json"
@@ -9,10 +9,16 @@ require "yaml"
9
9
  require_relative "lib/bridge"
10
10
  require_relative "lib/boot_banner"
11
11
  require_relative "lib/qmd_sync"
12
- require_relative "doctor"
12
+ require_relative "lib/doctor_core"
13
13
 
14
- index_path, store_root, mode, plugin_root = ARGV
15
- exit 0 unless index_path && store_root && mode
14
+ index_path, plastic_home, mode, plugin_root = ARGV
15
+ exit 0 unless index_path && plastic_home && mode
16
+
17
+ # Plastic home and the store are two different paths (intent 231). The shim passes
18
+ # home (~/.plastic) as argument 2; the store lives one level below it. Compose the
19
+ # store exactly once here, so no later line re-derives it and no path can gain a
20
+ # doubled store/ segment.
21
+ store_dir = File.join(plastic_home, "store")
16
22
 
17
23
  # --- Parse INDEX.md ---
18
24
 
@@ -47,11 +53,11 @@ end
47
53
  bridge_data = nil
48
54
  if active.length == 1 && active.first =~ /store\/([\w-]+)\//
49
55
  dir_name = $1
50
- intent_dir = "#{store_root}/store/#{dir_name}"
56
+ intent_dir = "#{store_dir}/#{dir_name}"
51
57
  session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
52
58
  intent_id = dir_name.split("--").first
53
59
  intent_name = active.first[/\[([^\]]+)\]/, 1] || "unknown"
54
- bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_root, name: intent_name)
60
+ bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_dir, name: intent_name)
55
61
  end
56
62
 
57
63
  # --- Detect stale future intents ---
@@ -68,7 +74,7 @@ stale_days = 3 if stale_days == 0
68
74
  future.each do |f|
69
75
  if f =~ /store\/([\w-]+)\//
70
76
  dir_name = $1
71
- intent_file = "#{store_root}/store/#{dir_name}/#{dir_name}.md"
77
+ intent_file = "#{store_dir}/#{dir_name}/#{dir_name}.md"
72
78
  next unless File.exist?(intent_file)
73
79
 
74
80
  content = File.read(intent_file)
@@ -88,7 +94,7 @@ current_project = nil
88
94
  project_active = []
89
95
  project_future = []
90
96
 
91
- projects_path = "#{store_root}/projects.yml"
97
+ projects_path = "#{plastic_home}/projects.yml"
92
98
  if File.exist?(projects_path)
93
99
  projects = YAML.safe_load(File.read(projects_path)) rescue {}
94
100
  cwd = Dir.pwd
@@ -96,7 +102,7 @@ if File.exist?(projects_path)
96
102
  project_path = File.expand_path(info["path"])
97
103
  if cwd.start_with?(project_path)
98
104
  current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
99
- project_index = "#{store_root}/projects/#{slug}/INDEX.md"
105
+ project_index = "#{plastic_home}/projects/#{slug}/INDEX.md"
100
106
  if File.exist?(project_index)
101
107
  p_section = nil
102
108
  File.readlines(project_index).each do |pline|
@@ -122,7 +128,7 @@ end
122
128
 
123
129
  # --- Load PLASTIC.md conventions ---
124
130
 
125
- plastic_md_path = "#{store_root}/PLASTIC.md"
131
+ plastic_md_path = "#{plastic_home}/PLASTIC.md"
126
132
  plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : nil
127
133
 
128
134
  # --- Load deprecations ---
@@ -130,7 +136,7 @@ plastic_md = File.exist?(plastic_md_path) ? File.read(plastic_md_path).strip : n
130
136
  dep_file = if plugin_root && !plugin_root.empty?
131
137
  "#{plugin_root}/deprecations.yml"
132
138
  else
133
- "#{store_root}/deprecations.yml"
139
+ "#{plastic_home}/deprecations.yml"
134
140
  end
135
141
 
136
142
  deprecations = []
@@ -147,7 +153,7 @@ rescue
147
153
  end
148
154
 
149
155
  current_version = nil
150
- version_file = "#{store_root}/VERSION"
156
+ version_file = "#{plastic_home}/VERSION"
151
157
  if File.exist?(version_file)
152
158
  current_version = File.read(version_file).strip
153
159
  elsif plugin_root && !plugin_root.empty?
@@ -167,7 +173,7 @@ end
167
173
  # --- Check for available updates (from previous session's check) ---
168
174
 
169
175
  update_notice = nil
170
- cache_file = "#{store_root}/.cache/update-check.json"
176
+ cache_file = "#{plastic_home}/.cache/update-check.json"
171
177
  if File.exist?(cache_file)
172
178
  cache = JSON.parse(File.read(cache_file)) rescue {}
173
179
  if cache["updateAvailable"]
@@ -181,7 +187,7 @@ end
181
187
  # session: any failure or exception degrades to a banner and we continue.
182
188
 
183
189
  core_health = begin
184
- Doctor.new(plastic_home: store_root).run_core_checks("claude")
190
+ Doctor.new(plastic_home: plastic_home).run_core_checks("claude")
185
191
  rescue
186
192
  nil
187
193
  end
@@ -212,7 +218,7 @@ parts << ""
212
218
  # slow/broken/missing qmd appends nothing and the hook continues cleanly.
213
219
  begin
214
220
  require "timeout"
215
- qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: store_root) }
221
+ qmd_status = Timeout.timeout(2) { QmdSync.status(plastic_home: plastic_home) }
216
222
  if qmd_status[:present]
217
223
  if qmd_status[:all_registered]
218
224
  parts << "QMD: #{qmd_status[:registered].size} Plastic collections indexed (search with the qmd skill)."
@@ -13,16 +13,27 @@
13
13
  # Part 4] the hook stdin schema, including `tool_input.command` as the carrier of
14
14
  # the apply_patch envelope text (consumed by scripts/codex-hook).
15
15
  #
16
- # Residual gap (in neither the guide nor 181, per Decision 14): the apply_patch
17
- # V4A envelope INNER grammar parsed below (`*** Begin/End Patch`, `*** Add/Update/
18
- # Delete File:`, `*** Move to:`, `+`/`-`/context lines) is not primary-sourced.
19
- # There is no live Codex to verify it against (the owner has none installed), so
20
- # this parser is built to the best-known public V4A shape and FAILS OPEN on
21
- # anything else. Returns [] and warns on any missing or unparseable envelope:
22
- # gates fail OPEN (orchestrator-locks fail-open rule). The PostToolUse gate-check
23
- # artifact backstop re-validates the intent file after the write, so a fail-open
24
- # create-gate is still netted. A real Codex run after delivery is the only future
25
- # check on this residual.
16
+ # Grammar provenance (intent 239, 2026-08-12). The V4A envelope grammar parsed below
17
+ # is primary-sourced: it is embedded verbatim in the native codex binary and was
18
+ # extracted from codex-cli 0.146.0 into test/fixtures/codex-v4a-grammar.txt, which
19
+ # carries the binary's path, size and SHA256 in its header.
20
+ # test/codex_v4a_grammar_test.rb checks this parser's markers against that grammar,
21
+ # and a live test re-extracts from an installed binary so the fixture cannot drift
22
+ # (it skips cleanly on a machine with no codex).
23
+ #
24
+ # Two deliberate differences from the real grammar, both in the safe direction:
25
+ # codex requires the envelope's first line to be "*** Begin Patch" and its last to be
26
+ # "*** End Patch", while this parser scans for the markers on any line of the
27
+ # payload, not only the first and last; and "*** Environment ID:", a real production
28
+ # of the grammar, is ignored here rather than parsed, because it names no file
29
+ # operation. Both markers still must form a WHOLE LINE (start-of-text or a
30
+ # preceding LF, and end-of-text or a following LF): per add_line: "+" /(.*)/ LF, a
31
+ # content line such as "+*** End Patch" is legal file content, not a terminator, so a
32
+ # bare substring search would misparse it as the real marker (see
33
+ # line_anchored_index below). Anything else unparseable FAILS OPEN: parse returns []
34
+ # and warns, so gates allow the write (orchestrator-locks fail-open rule), and the
35
+ # PostToolUse gate-check backstop re-validates the intent file after the write
36
+ # lands.
26
37
  module ApplyPatchEnvelope
27
38
  module_function
28
39
 
@@ -39,9 +50,11 @@ module ApplyPatchEnvelope
39
50
  # patch text by scanning for the Begin/End markers regardless of wrapping.
40
51
  def parse(command)
41
52
  text = command.is_a?(Array) ? command.join("\n") : command.to_s
42
- b = text.index(BEGIN_MARK)
43
- e = text.index(END_MARK)
44
- return warn_empty("no Begin/End Patch markers") if b.nil? || e.nil? || e < b
53
+ b = line_anchored_index(text, BEGIN_MARK, 0)
54
+ return warn_empty("no Begin/End Patch markers") if b.nil?
55
+
56
+ e = line_anchored_index(text, END_MARK, b + BEGIN_MARK.length)
57
+ return warn_empty("no Begin/End Patch markers") if e.nil?
45
58
 
46
59
  body = text[(b + BEGIN_MARK.length)...e]
47
60
  ops = []
@@ -73,4 +86,24 @@ module ApplyPatchEnvelope
73
86
  $stderr.puts "plastic apply_patch parse: #{reason}; gate fails open"
74
87
  []
75
88
  end
89
+
90
+ # Finds the first occurrence of `mark` starting at or after `from` that forms a
91
+ # WHOLE LINE: immediately preceded by start-of-text or a newline, and immediately
92
+ # followed by end-of-text or a newline. A bare `text.index(mark)` would also match
93
+ # `mark` sitting inside a longer line, such as the content line "+*** End Patch"
94
+ # (legal per add_line: "+" /(.*)/ LF), which is not a terminator.
95
+ def line_anchored_index(text, mark, from)
96
+ pos = from
97
+ loop do
98
+ idx = text.index(mark, pos)
99
+ return nil if idx.nil?
100
+
101
+ line_start = idx.zero? || text[idx - 1] == "\n"
102
+ after = idx + mark.length
103
+ line_end = after == text.length || text[after] == "\n"
104
+ return idx if line_start && line_end
105
+
106
+ pos = idx + 1
107
+ end
108
+ end
76
109
  end
@@ -9,6 +9,7 @@ require "digest"
9
9
  require "socket"
10
10
  require_relative "worktree"
11
11
  require_relative "lock"
12
+ require_relative "spec_header"
12
13
 
13
14
  module Bridge
14
15
  STAGES = %w[what why how exec done].freeze
@@ -662,13 +663,10 @@ module Bridge
662
663
  # matching the skill and agent contracts). Returns nil when spec.md is
663
664
  # absent, empty, or its first line does not match, so a missing/malformed
664
665
  # Tier line changes nothing about existing rebuild behavior.
666
+ # The grammar itself now lives in SpecHeader (scripts/lib/spec_header.rb, intent 213);
667
+ # this method is a thin read on top of it.
665
668
  def self.savepoint_tier(intent_dir)
666
- path = File.join(intent_dir, "spec.md")
667
- return nil unless File.exist?(path)
668
- first = File.open(path, &:gets)
669
- return nil if first.nil?
670
- m = first.chomp.strip.match(/\ATier:\s*(S|M|L)\z/)
671
- m && m[1]
669
+ SpecHeader.parse_file(File.join(intent_dir, "spec.md"))[:tier]
672
670
  end
673
671
 
674
672
  # Reconstruct the ledger from files on disk (timestamps from mtimes), in
@@ -767,12 +765,16 @@ module Bridge
767
765
  phantoms
768
766
  end
769
767
 
770
- def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
768
+ # Pure compute (intent 230): build the bridge state and write NOTHING. `derive`
769
+ # is the writing wrapper over this; `arm` and `repair_lock` use the pure form so
770
+ # the single bridge write happens only after the delivery lock and the worktree
771
+ # have both settled. Joins the pure `derive_stage` / `derive_key` family.
772
+ def self.derive_data(session, intent_id:, intent_dir:, store:, name:)
771
773
  stage = derive_stage(intent_dir)
772
774
  has = has_files(intent_dir)
773
775
  missing = missing_for_stage(stage, intent_dir) - has
774
776
 
775
- data = {
777
+ {
776
778
  "session" => session,
777
779
  "intent" => {
778
780
  "id" => intent_id,
@@ -816,7 +818,14 @@ module Bridge
816
818
  "delegates" => []
817
819
  }
818
820
  }
821
+ end
819
822
 
823
+ # Compute AND persist. Contract unchanged (intent 230 kept it deliberately):
824
+ # `scripts/hook-session-start` calls this and wants the immediate write, and
825
+ # test/bridge_worktree_derive_test.rb pins write-on-call.
826
+ def self.derive(session, intent_id:, intent_dir:, store:, name:, tmp: tmp_dir)
827
+ data = derive_data(session, intent_id: intent_id, intent_dir: intent_dir,
828
+ store: store, name: name)
820
829
  write(session, data, tmp: tmp)
821
830
  data
822
831
  end
@@ -881,6 +890,19 @@ module Bridge
881
890
 
882
891
  # --- Auto mode (intent 27) ---
883
892
 
893
+ # Intent 230: freshly composed state carries worktree.code = nil, so a failed
894
+ # Worktree.provision would have nothing to keep. Seed the block from the bridge
895
+ # already on disk when it names a code path, so provision's keep-rule (see
896
+ # Worktree.provision) can preserve it. Provision SUCCESS overwrites this with
897
+ # the freshly resolved (identical) pointer, so this only matters on failure.
898
+ def self.carry_prior_worktree(data, session, tmp: tmp_dir)
899
+ prior = read(session, intent_id: data.dig("intent", "id"), tmp: tmp)
900
+ block = prior && prior["worktree"]
901
+ data["worktree"] = block if block.is_a?(Hash) && !blank?(block["code"])
902
+ data
903
+ end
904
+ private_class_method :carry_prior_worktree
905
+
884
906
  # Shared arming spine (intent 96): resolve the session key, derive intent state,
885
907
  # set the caller-controlled auto flag, acquire the delivery lock, provision the
886
908
  # per-intent worktrees, persist, and purge terminal bridges. arm_auto (auto: true)
@@ -893,7 +915,10 @@ module Bridge
893
915
  if blank?(session) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
894
916
  $stderr.puts "plastic: no session id available; arming with derived bridge key #{key}"
895
917
  end
896
- data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
918
+ # Compute only (intent 230). Nothing reaches disk until the lock is ours and
919
+ # the worktree has settled; a LockHeldError below must leave the previous
920
+ # bridge exactly as it was.
921
+ data = derive_data(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
897
922
  data["build"]["auto"] = auto
898
923
 
899
924
  # Acquire the durable delivery lock (D1/D2): session-keyed, O_EXCL, in the
@@ -924,6 +949,8 @@ module Bridge
924
949
  "run #{skill_ref('plastic-doctor', harness: harness)} fix the lock"
925
950
  end
926
951
 
952
+ carry_prior_worktree(data, key)
953
+
927
954
  # Provision the per-intent worktrees (mandatory code worktree for project
928
955
  # intents; fail-open for non-git / global-only). Never let a provision error
929
956
  # break arming: the lock and auto flag still matter.
@@ -959,13 +986,24 @@ module Bridge
959
986
  # Degrade path for disarm_auto when no intent_id is given (intent 131): the
960
987
  # session's sole per-intent bridge when there is exactly one, else the
961
988
  # legacy single-key file. Keeps the common single-intent auto path working
962
- # without every caller having to name the intent id explicitly.
989
+ # without every caller having to name the intent id explicitly. With TWO or
990
+ # more per-intent bridges it refuses to guess (intent 233); see below.
963
991
  def self.sole_bridge_data(session, tmp: tmp_dir)
964
992
  matches = Dir.glob(File.join(tmp, "plastic-#{session}--*.json")).reject { |f| f.end_with?(".tmp") }
965
993
  if matches.length == 1
966
994
  data = (JSON.parse(File.read(matches.first)) rescue nil)
967
995
  return data if data
968
996
  end
997
+ # Refuse to guess among siblings (intent 233): with 2+ per-intent bridges
998
+ # the legacy single-key file below can carry EITHER sibling, so falling
999
+ # through would let a no-id disarm release the wrong intent's lock. A
1000
+ # disarm that does nothing is recoverable; one that unlocks a live
1001
+ # delivery is not. Zero matches keeps the legacy fallback (131 migration).
1002
+ if matches.length > 1
1003
+ $stderr.puts "plastic: session #{session} has #{matches.length} bridges; " \
1004
+ "disarm needs an explicit intent_id (refusing to guess)"
1005
+ return nil
1006
+ end
969
1007
  read(session, tmp: tmp)
970
1008
  end
971
1009
 
@@ -979,6 +1017,9 @@ module Bridge
979
1017
  # (one per concurrent intent), so disarm must target ONE of them. When
980
1018
  # intent_id is nil, degrades to the session's sole bridge (see
981
1019
  # sole_bridge_data) so the common single-intent path keeps working.
1020
+ #
1021
+ # The lock clear is conditional (intent 233): a failed release leaves the
1022
+ # cached lock fields alone so the orphan stays findable.
982
1023
  def self.disarm_auto(session, intent_id: nil)
983
1024
  data = blank?(intent_id) ? sole_bridge_data(session) : read(session, intent_id: intent_id)
984
1025
  return nil unless data
@@ -994,14 +1035,39 @@ module Bridge
994
1035
  $stderr.puts "plastic: worktree release raised, continuing: #{e.message}"
995
1036
  end
996
1037
 
1038
+ # Check what the release actually DID before touching the cache (intent
1039
+ # 233). Blanking the cache after a failed release orphans the durable
1040
+ # delivery.lock: the file stays on disk and nothing points at it any more.
1041
+ # Rescue mirrors the Worktree.release rescue above: warn, never raise,
1042
+ # never abort the rest of the tail (the guard fails milder than the bug).
997
1043
  dir = bridge_intent_dir(data)
1044
+ release_status = nil
998
1045
  if dir
999
1046
  owner = data.dig("lock", "owner_session")
1000
1047
  owner = session if blank?(owner)
1001
- Lock.release(dir, session: owner)
1048
+ begin
1049
+ release_status = Lock.release(dir, session: owner)
1050
+ rescue => e
1051
+ release_status = :raised
1052
+ $stderr.puts "plastic: delivery lock release raised for #{dir}, continuing: #{e.message}"
1053
+ end
1002
1054
  end
1003
- data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
1004
- "host" => nil, "type" => nil, "delegates" => [] }
1055
+
1056
+ # Success means "no lock left on disk": :released (we deleted it), :none
1057
+ # (there was none), and nil (no intent dir, so no release was attempted).
1058
+ # Only :not_owner and a raised release keep the cache pointing at the lock
1059
+ # so plastic-lock fix / reclaim / doctor can still find and repair it.
1060
+ if release_status.nil? || release_status == :released || release_status == :none
1061
+ data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
1062
+ "host" => nil, "type" => nil, "delegates" => [] }
1063
+ else
1064
+ $stderr.puts "plastic: delivery lock NOT released for #{dir} (#{release_status}); " \
1065
+ "bridge lock cache preserved for repair"
1066
+ data["lock"] = {} unless data["lock"].is_a?(Hash)
1067
+ end
1068
+ # Diagnostic only (D5): the primary contract stays "owner_session non-nil
1069
+ # after disarm means the lock was not released".
1070
+ data["lock"]["release_status"] = release_status.nil? ? nil : release_status.to_s
1005
1071
 
1006
1072
  write(session, data)
1007
1073
  purge_done_bridges(session: session)
@@ -1070,11 +1136,13 @@ module Bridge
1070
1136
  actions << "lock #{status}"
1071
1137
  end
1072
1138
 
1073
- data = derive(key, intent_id: intent_id, intent_dir: dir, store: store,
1074
- name: name, tmp: tmp)
1139
+ data = derive_data(key, intent_id: intent_id, intent_dir: dir, store: store,
1140
+ name: name)
1075
1141
  data["build"]["auto"] = auto
1076
1142
  data["lock"] = lock_cache(lock_data)
1077
1143
 
1144
+ carry_prior_worktree(data, key, tmp: tmp)
1145
+
1078
1146
  # Provision the per-intent worktrees so the rebuilt bridge carries
1079
1147
  # worktree.code (intent 136). Without it, cwd/edited-path selection has no
1080
1148
  # key: the repaired intent loses its own code gate and a concurrent sibling