@zalom/plastic 1.9.0 → 1.11.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 (96) hide show
  1. package/PLASTIC.md +23 -8
  2. package/README.md +16 -5
  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 +25 -8
  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 +10 -9
  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
  93. package/skills/humanizer/SKILL.md +0 -40
  94. package/skills/humanizer/evals/evals.json +0 -70
  95. package/skills/humanizer/references/always-on-snippet.md +0 -9
  96. package/skills/humanizer/references/examples.md +0 -48
@@ -0,0 +1,296 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "bridge"
5
+ require_relative "lock"
6
+ require_relative "intent_validator"
7
+ require_relative "spec_header"
8
+ require_relative "outcome_guard"
9
+ require_relative "scaffold_intent"
10
+
11
+ # StartIntent - all logic for `scripts/start-intent` (intent 213, group 2). Boards a
12
+ # session onto an intent: arm the delivery lock, then print a read-only resume-station
13
+ # report. This is the highest-risk surface in this action set because it composes lock
14
+ # arbitration; the rule for that surface is compose the existing primitives, add nothing
15
+ # (spec D6).
16
+ #
17
+ # NEVER REIMPLEMENTS LOCK ARBITRATION. Arming is `Bridge.arm_auto` or `Bridge.arm_guided`
18
+ # and nothing else acquires anything: no new arming logic, no retry loop, no takeover path.
19
+ # This module NEVER calls the Lock module's release or takeover operations. That unguarded
20
+ # read-modify-write gap is parked intent 254's territory; start-intent reports and refuses,
21
+ # it never repairs a lock. Read-only lock inspection (`Lock.path`, `Lock.read`,
22
+ # `Lock.fresh?`, `Lock.authorized?`) is expected and used below.
23
+ #
24
+ # DETERMINISM CRITERION: every value this module prints is read from already-committed
25
+ # files or from `Bridge.arm_*`'s own return value. No model inference, no free-text
26
+ # generation.
27
+ #
28
+ # Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or `ENV`
29
+ # directly. Seams: `armer:` (a lambda dispatching on mode to the two real Bridge methods),
30
+ # plus session/harness/thread values passed in as arguments rather than read from ENV here.
31
+ # Reading ENV for harness detection happens in the CLI (`scripts/start-intent`) and is
32
+ # passed down.
33
+ module StartIntent
34
+ module_function
35
+
36
+ EXIT_OK = 0
37
+ EXIT_USAGE = 1
38
+ EXIT_HELD = 3
39
+ EXIT_UNRESOLVED = 4
40
+
41
+ # --- Step 0: resolve inputs -------------------------------------------------------
42
+
43
+ # `name:` for the arm call, deterministically: the intent file frontmatter's `intent:`
44
+ # field, or the directory basename when the frontmatter is unreadable or blank. Never
45
+ # invents a name.
46
+ def resolve_name(intent_dir)
47
+ fm = IntentValidator.parse_frontmatter(Bridge.intent_file(intent_dir))
48
+ name = fm.is_a?(Hash) ? fm["intent"] : nil
49
+ Bridge.blank?(name) ? File.basename(intent_dir) : name
50
+ end
51
+
52
+ # Session resolution order: explicit --session, else CLAUDE_CODE_SESSION_ID (passed in
53
+ # as env_session, never read from ENV here), else nil. Deliberately NO fallback to the
54
+ # lock's own recorded owner (unlike end-intent's resolve_end_session): arming as an
55
+ # owner you cannot prove you are is wrong.
56
+ def resolve_session(explicit, env_session)
57
+ return explicit.to_s.strip unless Bridge.blank?(explicit)
58
+ return env_session.to_s.strip unless Bridge.blank?(env_session)
59
+ nil
60
+ end
61
+
62
+ # Harness/thread detection, mirroring skills/auto/SKILL.md's arm-the-lifecycle-gate
63
+ # snippet exactly: a non-empty CODEX_THREAD_ID means codex (thread = that id); else a
64
+ # non-empty CLAUDE_CODE_SESSION_ID means claude (thread = nil); else both nil. Pure:
65
+ # takes the raw ENV strings as arguments, never reads ENV itself.
66
+ def resolve_harness(codex_thread_id, claude_session_id)
67
+ codex = codex_thread_id.to_s.strip
68
+ claude = claude_session_id.to_s.strip
69
+ return { harness: "codex", thread: codex } unless codex.empty?
70
+ return { harness: "claude", thread: nil } unless claude.empty?
71
+ { harness: nil, thread: nil }
72
+ end
73
+
74
+ # --- Step 1: pre-flight lock read (read-only, before arming) ---------------------
75
+ #
76
+ # Mirrors the READ-ONLY half of scripts/end-intent's preflight_lock_verdict, minus its
77
+ # takeover branch (start-intent never takes over). Returns [verdict, lock_or_nil]:
78
+ # :proceed no lock, or this session already owns/delegates it
79
+ # :corrupt a lock file exists but Lock.read cannot parse it (exit 4, unconditional;
80
+ # unlike end-intent, freshness is never consulted for a corrupt lock,
81
+ # because there is no repair path here to reach for a stale one)
82
+ # :no_session a lock file exists and no session identity resolved from any source
83
+ # (exit 4)
84
+ # :held a FRESH foreign lock (exit 4... exit 3, before ever calling the armer)
85
+ # :stale a STALE foreign lock: fall through to arming, which arbitrates it (and
86
+ # raises Bridge::LockHeldError with the reclaim hint) itself
87
+ def preflight_verdict(intent_dir, session)
88
+ return [:proceed, nil] unless File.exist?(Lock.path(intent_dir))
89
+
90
+ lock = Lock.read(intent_dir)
91
+ return [:corrupt, nil] if lock.nil?
92
+ return [:no_session, lock] if Bridge.blank?(session)
93
+ return [:proceed, lock] if Lock.authorized?(lock, session)
94
+ return [:held, lock] if Lock.fresh?(intent_dir)
95
+ [:stale, lock]
96
+ end
97
+
98
+ def corrupt_message(intent_dir)
99
+ "start-intent: the delivery lock at #{Lock.path(intent_dir)} exists but its content " \
100
+ "will not parse (corrupt); refusing to arm since another session may still be " \
101
+ "heartbeating it. Run /plastic-doctor check the lock status, or plastic-lock fix " \
102
+ "once it is confirmed safe."
103
+ end
104
+
105
+ def no_session_message(intent_dir)
106
+ "start-intent: a delivery lock exists at #{Lock.path(intent_dir)} but no session " \
107
+ "identity could be resolved (--session or CLAUDE_CODE_SESSION_ID are both blank); " \
108
+ "refusing rather than guessing. Pass --session explicitly."
109
+ end
110
+
111
+ def held_message(intent_dir, lock, harness:)
112
+ owner = lock.is_a?(Hash) ? lock["owner_session"] : nil
113
+ "start-intent: delivery lock for #{File.basename(intent_dir)} is held by a live " \
114
+ "session (#{owner}); run #{Bridge.skill_ref('plastic-doctor', harness: harness)} " \
115
+ "check the lock status"
116
+ end
117
+
118
+ # --- Step 2: arm -------------------------------------------------------------------
119
+
120
+ # Default arm seam: dispatches on mode to the two real Bridge methods. Nothing else
121
+ # acquires anything.
122
+ def default_armer
123
+ lambda do |mode, session:, intent_id:, intent_dir:, store:, name:, harness:, agent:,
124
+ model:, thread:|
125
+ case mode
126
+ when "auto"
127
+ Bridge.arm_auto(session, intent_id: intent_id, intent_dir: intent_dir, store: store,
128
+ name: name, harness: harness, agent: agent, model: model, thread: thread)
129
+ when "guided"
130
+ Bridge.arm_guided(session, intent_id: intent_id, intent_dir: intent_dir, store: store,
131
+ name: name, harness: harness, agent: agent, model: model, thread: thread)
132
+ end
133
+ end
134
+ end
135
+
136
+ # --- Step 3: the resume-station report (read-only, after arming) -----------------
137
+
138
+ # Count-only helper for the printed "actions/: <n> real action file(s)" line. The
139
+ # STATION gate (whether How is delivered) is decided exclusively by
140
+ # Bridge.has_real_action?, called separately in build_report; this exists only to render
141
+ # a number and must never be used for any pass/fail decision.
142
+ def real_action_count(intent_dir)
143
+ Dir.glob("#{intent_dir}/actions/*.md").count do |f|
144
+ File.file?(f) && File.size(f) > 0 && Bridge.stage_file_present?(f)
145
+ end
146
+ rescue StandardError
147
+ 0
148
+ end
149
+
150
+ # Classify outcome.md into one of three states, per the spec's outcome.md nuance:
151
+ # `scaffold-intent outcome` writes a real (non-sentinel) outcome.md while leaving the
152
+ # frontmatter disposition at the template placeholder `delivered|abandoned`, so a naive
153
+ # sentinel check would misreport that as Done. Returns [state, reason]:
154
+ # "not_started" the sentinel is still present (or the file is absent), reason nil
155
+ # "authored" sentinel gone AND OutcomeGuard.reason(intent_dir, disposition) is nil
156
+ # for EITHER "delivered" or "abandoned" (BLOCKING 3 fix: a fully-authored
157
+ # abandoned outcome.md is authored too; checking "delivered" only used to
158
+ # misreport it as "scaffolded, not authored" and point a resuming session
159
+ # back into Exec)
160
+ # "scaffolded" sentinel gone AND OutcomeGuard.reason(...) returns a reason string for
161
+ # BOTH dispositions
162
+ # OutcomeGuard.reason is the single existing definition of "is outcome.md real for this
163
+ # close"; this never re-derives that definition.
164
+ def classify_outcome(intent_dir)
165
+ outcome_path = File.join(intent_dir, "outcome.md")
166
+ return ["not_started", nil] unless Bridge.stage_file_present?(outcome_path)
167
+
168
+ delivered_reason = OutcomeGuard.reason(intent_dir, "delivered")
169
+ return ["authored", nil] if delivered_reason.nil?
170
+
171
+ abandoned_reason = OutcomeGuard.reason(intent_dir, "abandoned")
172
+ return ["authored", nil] if abandoned_reason.nil?
173
+
174
+ # Neither disposition's guard passed: report the "delivered" reason, since it is the
175
+ # more common close path and OutcomeGuard.reason's message already names whatever
176
+ # disposition is actually recorded in the frontmatter (or that the file is missing or
177
+ # still the scaffold placeholder), so it stays informative either way.
178
+ ["scaffolded", delivered_reason]
179
+ end
180
+
181
+ def settled_text(header)
182
+ return "no" unless header[:settled]
183
+ header[:settled_reason] ? "yes (#{header[:settled_reason]})" : "yes"
184
+ end
185
+
186
+ # Build the resume-station report. Every value comes from already-committed files
187
+ # (Bridge.stage_file_present?, Bridge.has_real_action?, SpecHeader, OutcomeGuard) or from
188
+ # `data`, the Hash Bridge.arm_* itself returned. No model inference, no free text beyond
189
+ # the fixed shape below.
190
+ def build_report(intent_dir:, mode:, data:)
191
+ spec_path = File.join(intent_dir, "spec.md")
192
+ plan_path = File.join(intent_dir, "plan.md")
193
+ checklist_path = File.join(intent_dir, "checklist.md")
194
+
195
+ spec_delivered = Bridge.stage_file_present?(spec_path)
196
+ plan_delivered = Bridge.stage_file_present?(plan_path)
197
+ checklist_delivered = Bridge.stage_file_present?(checklist_path)
198
+ has_action = Bridge.has_real_action?(intent_dir)
199
+ how_triple = plan_delivered && checklist_delivered && has_action
200
+
201
+ outcome_state, outcome_reason = classify_outcome(intent_dir)
202
+
203
+ resume_at =
204
+ if !spec_delivered
205
+ "Why"
206
+ elsif !how_triple
207
+ "How"
208
+ elsif outcome_state != "authored"
209
+ "Exec"
210
+ else
211
+ "Done"
212
+ end
213
+
214
+ header = SpecHeader.parse_file(spec_path)
215
+ tier = header[:tier] || "unknown"
216
+
217
+ worktree_code = data.dig("worktree", "code")
218
+ worktree_line = Bridge.blank?(worktree_code) ? "not provisioned" : worktree_code
219
+
220
+ outcome_line =
221
+ case outcome_state
222
+ when "authored" then "authored"
223
+ when "scaffolded" then "scaffolded, not authored (#{outcome_reason})"
224
+ else "not started"
225
+ end
226
+
227
+ lines = []
228
+ lines << "start-intent: #{File.basename(intent_dir)}"
229
+ lines << " mode: #{mode}"
230
+ lines << " session: #{data['session']}"
231
+ lines << " lock: held by #{data.dig('lock', 'owner_session')} (ours)"
232
+ lines << " worktree: #{worktree_line}"
233
+ lines << " tier: #{tier}"
234
+ lines << " settled: #{settled_text(header)}"
235
+ lines << " spec.md: #{spec_delivered ? 'delivered' : 'not started'}"
236
+ lines << " plan.md: #{plan_delivered ? 'delivered' : 'not started'}"
237
+ lines << " checklist.md: #{checklist_delivered ? 'delivered' : 'not started'}"
238
+ lines << " actions/: #{real_action_count(intent_dir)} real action file(s)"
239
+ lines << " outcome.md: #{outcome_line}"
240
+ lines << " resume at: #{resume_at}"
241
+ lines.join("\n")
242
+ end
243
+
244
+ # --- entry point --------------------------------------------------------------------
245
+
246
+ def usage_result(message)
247
+ { exit_code: EXIT_USAGE, stderr: [message], stdout: [] }
248
+ end
249
+
250
+ def deny_result(code, message)
251
+ { exit_code: code, stderr: [message], stdout: [] }
252
+ end
253
+
254
+ def run(store:, id:, mode:, session:, env_session:, harness:, thread:, armer: default_armer)
255
+ return usage_result("--store is required") if Bridge.blank?(store)
256
+ return usage_result("--id is required") if Bridge.blank?(id)
257
+ return usage_result("--mode is required") if Bridge.blank?(mode)
258
+ unless %w[auto guided].include?(mode)
259
+ return usage_result("--mode must be one of auto, guided (got #{mode.inspect})")
260
+ end
261
+
262
+ store_abs = ScaffoldIntent.expand(store)
263
+ return usage_result("store dir does not exist: #{store_abs}") unless Dir.exist?(store_abs)
264
+
265
+ intent_dir, resolve_err = ScaffoldIntent.resolve_intent_dir(store_abs, id)
266
+ return usage_result(resolve_err) if intent_dir.nil?
267
+
268
+ name = resolve_name(intent_dir)
269
+ key_session = resolve_session(session, env_session)
270
+
271
+ verdict, lock = preflight_verdict(intent_dir, key_session)
272
+ case verdict
273
+ when :corrupt
274
+ return deny_result(EXIT_UNRESOLVED, corrupt_message(intent_dir))
275
+ when :no_session
276
+ return deny_result(EXIT_UNRESOLVED, no_session_message(intent_dir))
277
+ when :held
278
+ return deny_result(EXIT_HELD, held_message(intent_dir, lock, harness: harness))
279
+ end
280
+ # :proceed and :stale both fall through to arming; Bridge.arm_* arbitrates :stale
281
+ # itself (and raises Bridge::LockHeldError with the reclaim hint) via its own
282
+ # up-to-date Lock.acquire call.
283
+
284
+ data =
285
+ begin
286
+ armer.call(mode, session: key_session, intent_id: id, intent_dir: intent_dir,
287
+ store: store_abs, name: name, harness: harness, agent: nil, model: nil,
288
+ thread: thread)
289
+ rescue Bridge::LockHeldError => e
290
+ return deny_result(EXIT_HELD, e.message)
291
+ end
292
+
293
+ report = build_report(intent_dir: intent_dir, mode: mode, data: data)
294
+ { exit_code: EXIT_OK, stderr: [], stdout: [report] }
295
+ end
296
+ end
@@ -0,0 +1,262 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "worktree"
5
+ require_relative "scaffold_intent"
6
+
7
+ # VerifyIntent - all logic for `scripts/verify-intent` (intent 213). Bundles the checks that
8
+ # need no project-specific knowledge into one verdict: the per-intent doctor scan
9
+ # (`Doctor#run_intent_check`), a net-new added-line em-dash diff guard (the first standing
10
+ # implementation of this check; the only prior automated em-dash check,
11
+ # `test/skill_command_lint_test.rb`, asserts against two FIXED file sets and cannot scan a
12
+ # diff), a diffstat, and an optional caller-supplied `--suite` command folded into the same
13
+ # verdict.
14
+ #
15
+ # Repo resolution and base-branch detection are NOT re-implemented here: `resolve_repo_dir`,
16
+ # `detect_base_branch`, and `diffstat` are `ScaffoldIntent`'s own shared git seam helpers
17
+ # (built for `scripts/scaffold-intent`, intent 213 ACTION_2); this module calls them so
18
+ # there is exactly one implementation of each in the repo.
19
+ #
20
+ # Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV`. The public
21
+ # entry point (`run`) returns a verdict hash; `scripts/verify-intent` maps it to stdout lines
22
+ # plus an exit code. Seams: `git_runner:` (defaults to `Worktree::ShellRunner.new`),
23
+ # `doctor:` (a lambda, defaults to a real `Doctor#run_intent_check` call), `suite_runner:` (a
24
+ # lambda, defaults to a real subprocess with `RUBYOPT` cleared).
25
+ module VerifyIntent
26
+ module_function
27
+
28
+ # Built from its codepoint, never typed as a literal byte, so this file's own source stays
29
+ # free of the character it is written to detect (matching scripts/end-intent's own
30
+ # EM_DASH constant, built the same way).
31
+ EM_DASH = "\u2014"
32
+
33
+ EXIT_OK = 0
34
+ EXIT_USAGE = 1
35
+ EXIT_DOCTOR = 2
36
+ EXIT_EMDASH = 3
37
+ EXIT_SUITE = 4
38
+
39
+ # --- entry point ---------------------------------------------------------------
40
+
41
+ def run(store:, id:, base: nil, suite: nil, home: Dir.home,
42
+ git_runner: Worktree::ShellRunner.new, doctor: default_doctor, suite_runner: default_suite_runner)
43
+ return usage_result("--store is required") if Worktree.blank?(store)
44
+ return usage_result("--id is required") if Worktree.blank?(id)
45
+
46
+ intent_dir, resolve_err = ScaffoldIntent.resolve_intent_dir(store, id)
47
+ return usage_result(resolve_err) if intent_dir.nil?
48
+
49
+ gate_home, scope = resolve_plastic_home_and_scope(store)
50
+
51
+ lines = []
52
+ checks = {}
53
+ codes = []
54
+
55
+ doctor_lines, doctor_code, doctor_check = run_doctor_check(home: gate_home, scope: scope, id: id, doctor: doctor)
56
+ lines.concat(doctor_lines)
57
+ checks[:doctor] = doctor_check
58
+ codes << doctor_code if doctor_code
59
+
60
+ repo = ScaffoldIntent.resolve_repo_dir(store: store, id: id, intent_dir: intent_dir, home: home, runner: git_runner)
61
+ base_ref = base
62
+ base_ref ||= repo ? ScaffoldIntent.detect_base_branch(repo, runner: git_runner) : nil
63
+
64
+ emdash_lines, emdash_code, emdash_check = run_emdash_check(repo: repo, base: base_ref, git_runner: git_runner)
65
+ lines.concat(emdash_lines)
66
+ checks[:emdash] = emdash_check
67
+ codes << emdash_code if emdash_code
68
+
69
+ diffstat_lines, diffstat_check = run_diffstat_check(repo: repo, base: base_ref, git_runner: git_runner)
70
+ lines.concat(diffstat_lines)
71
+ checks[:diffstat] = diffstat_check
72
+
73
+ if Worktree.blank?(suite)
74
+ checks[:suite] = { status: "skipped" }
75
+ else
76
+ suite_lines, suite_code, suite_check = run_suite_check(suite: suite, repo: repo, suite_runner: suite_runner)
77
+ lines.concat(suite_lines)
78
+ checks[:suite] = suite_check
79
+ codes << suite_code if suite_code
80
+ end
81
+
82
+ { exit_code: codes.compact.min || EXIT_OK, lines: lines, checks: checks }
83
+ end
84
+
85
+ def usage_result(message)
86
+ { exit_code: EXIT_USAGE, lines: ["verify-intent: #{message}"], checks: {} }
87
+ end
88
+
89
+ # --- store/scope resolution (mirrors scripts/end-intent:161-170) ---------------
90
+
91
+ # `store` is `<plastic_home>/store` or `<plastic_home>/projects/<slug>/store`. The two
92
+ # shapes are told apart by whether store's grandparent is literally named "projects" (the
93
+ # StoreDiscovery convention), never by hardcoding ".plastic" as a name.
94
+ def resolve_plastic_home_and_scope(store)
95
+ parent = File.dirname(store)
96
+ grandparent = File.dirname(parent)
97
+ if File.basename(grandparent) == "projects"
98
+ [File.dirname(grandparent), "project:#{File.basename(parent)}"]
99
+ else
100
+ [parent, "global"]
101
+ end
102
+ end
103
+
104
+ # --- check 1: doctor, scoped to the intent --------------------------------------
105
+
106
+ def default_doctor
107
+ lambda do |home:, scope:, id:|
108
+ require_relative "../doctor"
109
+ Doctor.new(plastic_home: home, runner: Doctor.default_runner).run_intent_check(id, store: scope)
110
+ end
111
+ end
112
+
113
+ # Fail-open on any crash (a broken diagnostic must never wedge a verify): the check is
114
+ # skipped, not failed.
115
+ def run_doctor_check(home:, scope:, id:, doctor:)
116
+ verdict = doctor.call(home: home, scope: scope, id: id)
117
+ case verdict[:status]
118
+ when "fail"
119
+ failing = Array(verdict[:checks]).select { |c| c[:status] == "fail" }
120
+ lines = failing.map { |c| "doctor: FAIL #{c[:name]}: #{c[:message]}" }
121
+ lines = ["doctor: FAIL"] if lines.empty?
122
+ [lines, EXIT_DOCTOR, { status: "fail", verdict: verdict }]
123
+ when "warn"
124
+ warning = Array(verdict[:checks]).select { |c| c[:status] == "warn" }
125
+ lines = warning.map { |c| "doctor: WARN #{c[:name]}: #{c[:message]}" }
126
+ lines = ["doctor: WARN"] if lines.empty?
127
+ [lines, nil, { status: "warn", verdict: verdict }]
128
+ else
129
+ [["doctor: pass"], nil, { status: "pass", verdict: verdict }]
130
+ end
131
+ rescue StandardError => e
132
+ [["doctor crashed (#{e.message}); proceeding without it"], nil, { status: "crashed", error: e.message }]
133
+ end
134
+
135
+ # --- check 2: the added-line em-dash diff guard (net-new, D9) -------------------
136
+
137
+ # Store and intent files are exempt from the no-em-dash rule (AGENTS.md states this
138
+ # explicitly), and INDEX.md is REQUIRED to carry a real em dash on write
139
+ # (scripts/end-intent:82-88). No per-file allow-list beyond these three shapes.
140
+ def exempt_path?(path)
141
+ return true if path.nil?
142
+ return true if path.start_with?("store/")
143
+ return true if path.split("/").include?(".plastic")
144
+ return true if path == "INDEX.md" || path.end_with?("/INDEX.md")
145
+ return true if path.start_with?("test/fixtures/")
146
+
147
+ false
148
+ end
149
+
150
+ HUNK_HEADER_RE = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
151
+
152
+ # Scan ONLY the added lines of a unified diff (never a removed line, never a context
153
+ # line, never the `+++ ` file header line itself). An added line starts with exactly one
154
+ # `+` and is not the file header; per the spec this is decided with
155
+ # `l.start_with?("+") && !l.start_with?("+++")`. Tracks the current file from the most
156
+ # recent `+++ b/<path>` header and the current new-file line number from the most recent
157
+ # `@@ -a,b +c,d @@` hunk header, incrementing on every context or added line (never on a
158
+ # removed line, which does not exist in the new file).
159
+ def em_dash_violations(diff_text)
160
+ violations = []
161
+ current_file = nil
162
+ current_line = nil
163
+ exempt = false
164
+
165
+ diff_text.to_s.each_line do |line|
166
+ if line.start_with?("+++")
167
+ current_file = line.sub(/\A\+\+\+\s*/, "").strip.sub(%r{\Ab/}, "")
168
+ exempt = exempt_path?(current_file)
169
+ next
170
+ end
171
+
172
+ if (m = line.match(HUNK_HEADER_RE))
173
+ current_line = m[1].to_i
174
+ next
175
+ end
176
+
177
+ if line.start_with?("+")
178
+ text = line[1..].to_s.chomp
179
+ violations << { file: current_file, line: current_line, text: text } if !exempt && text.include?(EM_DASH)
180
+ current_line += 1 if current_line
181
+ elsif line.start_with?("-")
182
+ # A removed line does not exist in the new file: never scanned, never counted.
183
+ elsif line.start_with?(" ")
184
+ current_line += 1 if current_line
185
+ end
186
+ end
187
+
188
+ violations
189
+ end
190
+
191
+ def run_emdash_check(repo:, base:, git_runner:)
192
+ if repo.nil?
193
+ return [["em-dash guard skipped: no repo could be resolved for this intent"], nil,
194
+ { status: "skipped", reason: "no repo" }]
195
+ end
196
+ if base.nil?
197
+ return [["em-dash guard skipped: no base branch could be detected (no origin/HEAD, main, or master)"], nil,
198
+ { status: "skipped", reason: "no base" }]
199
+ end
200
+
201
+ res = git_runner.run("-C", repo, "diff", "#{base}...HEAD")
202
+ unless res.success?
203
+ reason = res.stderr.to_s.strip
204
+ return [["em-dash guard skipped: #{reason}"], nil, { status: "skipped", reason: reason }]
205
+ end
206
+
207
+ violations = em_dash_violations(res.stdout)
208
+ if violations.empty?
209
+ [["em-dash guard: pass (0 violations)"], nil, { status: "pass", violations: [] }]
210
+ else
211
+ lines = ["em-dash guard: #{violations.size} violation(s) found"]
212
+ lines.concat(violations.map { |v| "#{v[:file]}:#{v[:line]}: #{v[:text]}" })
213
+ [lines, EXIT_EMDASH, { status: "fail", violations: violations }]
214
+ end
215
+ end
216
+
217
+ # --- check 3: the diffstat -------------------------------------------------------
218
+
219
+ def run_diffstat_check(repo:, base:, git_runner:)
220
+ if repo.nil?
221
+ return [["diffstat unavailable: no repo could be resolved for this intent"], { status: "skipped" }]
222
+ end
223
+ if base.nil?
224
+ return [["diffstat unavailable: no base branch could be detected (no origin/HEAD, main, or master)"],
225
+ { status: "skipped" }]
226
+ end
227
+
228
+ stat, err = ScaffoldIntent.diffstat(repo, base, runner: git_runner)
229
+ if stat.nil?
230
+ [["diffstat unavailable: #{err}"], { status: "skipped" }]
231
+ else
232
+ lines = ["diffstat against #{base}:"]
233
+ lines.concat(stat.each_line.map(&:chomp))
234
+ [lines, { status: "pass", stat: stat }]
235
+ end
236
+ end
237
+
238
+ # --- check 4: the optional suite --------------------------------------------------
239
+
240
+ # The supplied command is very likely a ruby command, so it is spawned with RUBYOPT
241
+ # cleared, matching the pattern at scripts/maintenance-run:133.
242
+ def default_suite_runner
243
+ lambda do |command, dir|
244
+ require "open3"
245
+ out, status = Open3.capture2e({ "RUBYOPT" => nil }, command, chdir: dir || Dir.pwd)
246
+ [out, status.exitstatus]
247
+ end
248
+ end
249
+
250
+ def run_suite_check(suite:, repo:, suite_runner:)
251
+ output, status = suite_runner.call(suite, repo)
252
+ lines = ["suite: #{suite}"]
253
+ lines.concat(output.to_s.each_line.map(&:chomp))
254
+ lines << "suite: exit #{status}"
255
+
256
+ if status.to_i.zero?
257
+ [lines, nil, { status: "pass", exit_status: status }]
258
+ else
259
+ [lines, EXIT_SUITE, { status: "fail", exit_status: status }]
260
+ end
261
+ end
262
+ end
@@ -166,7 +166,21 @@ module Worktree
166
166
  # fails open on provisioned: false (non-git / global-only).
167
167
  block["provisioned"] = code_ok
168
168
 
169
- bridge_data["worktree"] = block
169
+ # Intent 230: only OVERWRITE the worktree block when the code worktree was
170
+ # actually added. On failure, keep a pointer the caller already had rather
171
+ # than erasing it -- clearing it is what sent code edits into the shared
172
+ # checkout. A pointer whose directory is gone is worse than none, so it is
173
+ # replaced by the fresh unprovisioned block; the guard must not fail harder
174
+ # than the bug it guards.
175
+ if code_ok
176
+ bridge_data["worktree"] = block
177
+ else
178
+ existing = bridge_data["worktree"]
179
+ keep = existing.is_a?(Hash) && !blank?(existing["code"]) &&
180
+ Dir.exist?(existing["code"].to_s)
181
+ bridge_data["worktree"] = block unless keep
182
+ end
183
+
170
184
  bridge_data
171
185
  end
172
186
 
@@ -195,7 +195,7 @@ module LinkSuggestCLI
195
195
  def qmd_search_ids(query, nodes)
196
196
  return [] if query.to_s.strip.empty?
197
197
 
198
- out = `ruby #{File.join(Dir.home, ".plastic", "scripts", "qmd-sync")} search #{shell_quote(query)} 2>/dev/null`
198
+ out = `env -u RUBYOPT ruby #{File.join(Dir.home, ".plastic", "scripts", "qmd-sync")} search #{shell_quote(query)} 2>/dev/null`
199
199
  return [] if out.nil? || out.empty?
200
200
 
201
201
  out.scan(/\b([0-9]+[a-z0-9]*)\b/).flatten.uniq.select { |id| nodes.key?(id) }
@@ -130,7 +130,7 @@ def run_project_links(home, intent, store, apply)
130
130
  base_args += ["--store", store] if store
131
131
 
132
132
  unless apply
133
- system(RbConfig.ruby, tool_path, *base_args, "--dry-run")
133
+ system({"RUBYOPT" => nil}, RbConfig.ruby, tool_path, *base_args, "--dry-run")
134
134
  exit($?.exitstatus)
135
135
  end
136
136
 
@@ -139,7 +139,7 @@ def run_project_links(home, intent, store, apply)
139
139
  repo_dir: home, branch_name: "maintenance/project-links-#{intent}-#{stamp}",
140
140
  commit_message: "chore: maintenance - project-links --intent #{intent}"
141
141
  ) do
142
- ok = system(RbConfig.ruby, tool_path, *base_args)
142
+ ok = system({"RUBYOPT" => nil}, RbConfig.ruby, tool_path, *base_args)
143
143
  raise "project-links failed for #{intent}" unless ok
144
144
  end
145
145
  rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
@@ -171,7 +171,7 @@ def run_rebuild_graph(home, apply)
171
171
  repo_dir: home, branch_name: "maintenance/rebuild-graph-#{stamp}",
172
172
  commit_message: "chore: maintenance - rebuild-graph"
173
173
  ) do
174
- ok = system(RbConfig.ruby, tool_path, "--plastic-home", home)
174
+ ok = system({"RUBYOPT" => nil}, RbConfig.ruby, tool_path, "--plastic-home", home)
175
175
  raise "rebuild-graph failed" unless ok
176
176
  end
177
177
  rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
@@ -200,7 +200,7 @@ def run_restore_intent_v1(home, id, at, store, apply, skip_links)
200
200
  args << "--skip-links" if skip_links
201
201
 
202
202
  unless apply
203
- system(RbConfig.ruby, *args)
203
+ system({"RUBYOPT" => nil}, RbConfig.ruby, *args)
204
204
  exit($?.exitstatus)
205
205
  end
206
206
 
@@ -209,7 +209,7 @@ def run_restore_intent_v1(home, id, at, store, apply, skip_links)
209
209
  repo_dir: home, branch_name: "maintenance/restore-intent-v1-#{id}-#{stamp}",
210
210
  commit_message: "chore: maintenance - restore-intent-v1 #{id}"
211
211
  ) do
212
- ok = system(RbConfig.ruby, *args, "--apply")
212
+ ok = system({"RUBYOPT" => nil}, RbConfig.ruby, *args, "--apply")
213
213
  raise "restore-intent-v1 failed for #{id}" unless ok
214
214
  end
215
215
  rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
@@ -44,7 +44,7 @@ fi
44
44
 
45
45
  # Rewrite relative links to wikilinks
46
46
  echo " Rewriting links to [[ID]] format..."
47
- ruby <<'RUBY'
47
+ env -u RUBYOPT ruby <<'RUBY'
48
48
  # Find intent files (named {ID}.md, the primary file in each intent directory)
49
49
  Dir.glob(File.expand_path("~/.plastic/store/*/")).each do |dir|
50
50
  id = File.basename(dir).split("--").first
@@ -68,7 +68,7 @@ RUBY
68
68
  # Register project in projects.yml
69
69
  echo " Registering project..."
70
70
  REMOTE=$(cd "$PROJECT_PATH" && git remote get-url origin 2>/dev/null || echo "null")
71
- ruby -r yaml <<RUBY
71
+ env -u RUBYOPT ruby -r yaml <<RUBY
72
72
  path = File.expand_path("$GLOBAL_ROOT/projects.yml")
73
73
  data = YAML.safe_load(File.read(path)) rescue {}
74
74
  data["projects"] ||= {}
@@ -271,7 +271,7 @@ class RestoreIntentV1CLI
271
271
 
272
272
  def reproject_links
273
273
  project_links = File.expand_path("project-links", __dir__)
274
- system(RbConfig.ruby, project_links, "--plastic-home", plastic_home)
274
+ system({"RUBYOPT" => nil}, RbConfig.ruby, project_links, "--plastic-home", plastic_home)
275
275
  return if $?.success?
276
276
 
277
277
  warn "restore-intent-v1: ## Links may now be stale (project-links reprojection failed). " \