@zalom/plastic 1.2.0 → 1.4.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 (63) hide show
  1. package/PLASTIC-reference.md +8 -6
  2. package/PLASTIC.md +68 -6
  3. package/README.md +5 -0
  4. package/agents/plastic-advisor.md +56 -0
  5. package/agents/plastic-enforcer.md +9 -1
  6. package/agents/plastic-faux-advisor.md +174 -0
  7. package/agents/plastic-future-intent-researcher.md +1 -0
  8. package/hooks/hooks.json +5 -0
  9. package/hooks/links-gate +3 -0
  10. package/hooks/statusline +1 -0
  11. package/package.json +1 -1
  12. package/scripts/doctor.rb +164 -58
  13. package/scripts/end-intent +347 -43
  14. package/scripts/hook-links-gate +74 -0
  15. package/scripts/install.rb +8 -0
  16. package/scripts/lib/agent_models.rb +36 -9
  17. package/scripts/lib/bridge.rb +29 -1
  18. package/scripts/lib/config_asks.rb +110 -0
  19. package/scripts/lib/graph_rebuild.rb +30 -6
  20. package/scripts/lib/hook_registry.rb +2 -1
  21. package/scripts/lib/installer_core.rb +130 -23
  22. package/scripts/lib/intent_validator.rb +38 -10
  23. package/scripts/lib/links_gate.rb +140 -0
  24. package/scripts/lib/links_projection.rb +71 -12
  25. package/scripts/lib/power_tools.rb +57 -14
  26. package/scripts/lib/project_validator.rb +113 -0
  27. package/scripts/lib/qmd_hook.rb +12 -8
  28. package/scripts/lib/restore_intent_v1.rb +154 -0
  29. package/scripts/lib/roadmap_queue.rb +1 -1
  30. package/scripts/lib/roadmap_savepoint.rb +38 -10
  31. package/scripts/lib/store_discovery.rb +77 -0
  32. package/scripts/lib/store_provisioning.rb +21 -12
  33. package/scripts/new-intent +10 -12
  34. package/scripts/project-links +132 -35
  35. package/scripts/provision-project-store +18 -5
  36. package/scripts/read-config +1 -0
  37. package/scripts/rebuild-graph +42 -17
  38. package/scripts/restore-intent-v1 +288 -0
  39. package/scripts/roadmap-next +9 -2
  40. package/scripts/roadmap-savepoint +9 -1
  41. package/scripts/update.rb +50 -1
  42. package/scripts/validate-intent +3 -1
  43. package/scripts/validate-project +53 -0
  44. package/scripts/write-config +105 -0
  45. package/skills/agent-advisor/SKILL.md +92 -0
  46. package/skills/agent-advisor/references/advisor-protocol.md +245 -0
  47. package/skills/auto/SKILL.md +26 -12
  48. package/skills/auto/references/end-tail.md +27 -13
  49. package/skills/install/SKILL.md +30 -2
  50. package/skills/intent-creating/SKILL.md +5 -0
  51. package/skills/intent-ending/SKILL.md +49 -36
  52. package/skills/project-creating/SKILL.md +29 -1
  53. package/skills/releasing/SKILL.md +37 -19
  54. package/skills/roadmap/SKILL.md +9 -7
  55. package/skills/roadmap/references/file-format.md +14 -10
  56. package/skills/roadmap/references/operations.md +22 -18
  57. package/skills/roadmap-continuing/SKILL.md +5 -5
  58. package/skills/roadmap-continuing/evals/evals.json +3 -3
  59. package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
  60. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
  61. package/skills/update/SKILL.md +34 -4
  62. package/templates/config.yml +31 -6
  63. package/templates/roadmap.md +8 -8
@@ -2,63 +2,111 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # end-intent (intent 161) - the mechanical core of the single owned Done
6
- # procedure (D2 steps 1-4). Pure with respect to worktrees, locks, and QMD:
7
- # those stay in the plastic-intent-ending skill body (steps 0 and 5-7), never
8
- # duplicated here. Pairs new-intent's shape: one callable script instead of
9
- # raw one-liners repeated in skill prose (the failure mode this intent fixes).
5
+ # end-intent (intent 161, extended by intent 188) - the mechanical core of the
6
+ # single owned Done procedure. Steps 1-4 (outcome/INDEX/savepoint/commit) plus,
7
+ # since intent 188, step 5 (disarm: worktree release + delivery-lock clear).
8
+ # QMD reindex (step 6) and the human report (step 7) stay in the
9
+ # plastic-intent-ending skill body: intent 161's boundary moves by exactly one
10
+ # step (D2), not further.
10
11
  #
11
12
  # Usage:
12
13
  # end-intent --store <store_path> --id <intent_id> --disposition delivered|abandoned \
13
14
  # [--index <path>] [--outcome-summary <text>] [--index-note <text>] \
15
+ # [--session <session_id>] [--discard-worktree-changes] \
14
16
  # [--no-commit] [--dry-run]
15
17
  #
16
18
  # Steps performed (in order), all idempotent:
19
+ # 0. Pre-flight lock guard (intent 188, D4): resolve the calling session
20
+ # (--session, else CLAUDE_CODE_SESSION_ID, else the existing lock's own
21
+ # recorded owner when non-blank, else no-op) and check it against
22
+ # delivery.lock. A FRESH foreign lock refuses the whole run (exit 4),
23
+ # authoring nothing. A STALE foreign lock is reclaimed via Lock.takeover
24
+ # (audited to savepoint.md), then the run proceeds as the new owner. A
25
+ # lock file that exists but will not parse (corrupt) is arbitrated by
26
+ # freshness too (the mtime heartbeat is still valid even when the JSON
27
+ # content is not): fresh refuses (exit 4), stale is taken over. A lock
28
+ # file that exists while no session identity resolves from ANY source
29
+ # also refuses (exit 4) rather than ever reaching Lock.takeover with a
30
+ # blank session, which would delete the lock file and then crash.
17
31
  # 1. Guard outcome.md (exit 2, authors nothing, on any failure), then stamp
18
32
  # the intent file's `## Outcome` section with --outcome-summary if given.
19
33
  # 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
20
34
  # (delivered) or `## Abandoned` (abandoned), dated today, appending
21
- # --index-note (if given) after the date so the terminal entry stays
22
- # the store's existing rich Completed/Abandoned shape rather than
23
- # thinning to bare id+date.
35
+ # --index-note (if given). Accepts a real em dash OR a plain hyphen as
36
+ # the id/title separator on READ (shared matcher, Bridge.index_entry_match);
37
+ # always EMITS the real em dash on write. An id that resolves to neither
38
+ # `## Active` nor the terminal section is a loud failure (exit 1); an id
39
+ # already correctly in the terminal section is a quiet, idempotent success.
24
40
  # 3. Append the savepoint `Done` bookend (Bridge.append_terminal_savepoint).
25
41
  # 4. Commit the store repo, unless --no-commit.
42
+ # 5. Disarm (intent 188, D2/D16): check the code worktree (resolved from the
43
+ # bridge) for uncommitted changes first (exit 5 if dirty, unless
44
+ # --discard-worktree-changes); call the disarm seam (default
45
+ # Bridge.disarm_auto); verify the durable lock file is actually gone
46
+ # afterward. The /tmp bridge is only a cache (AGENTS.md): when no bridge
47
+ # resolves at all (a wiped /tmp, a resumed job under a new session id),
48
+ # the durable lock file is released directly instead, LOUDLY (a warning
49
+ # that a worktree may be orphaned), rather than stranding a committed,
50
+ # terminal intent still holding its lock.
26
51
  #
27
- # Exit codes: 0 (ok), 1 (usage/resolution failure), 2 (outcome.md guard refused).
52
+ # Exit codes:
53
+ # 0 ok - the intent is closed and no delivery.lock remains.
54
+ # 1 usage/resolution failure, OR an id that resolves to neither ## Active
55
+ # nor the terminal section (D11). Deliberately double duty; see plan.md.
56
+ # 2 the outcome.md guard refused; authors nothing.
57
+ # 3 steps 1-4 already committed, but the delivery lock genuinely could not
58
+ # be cleared (not a bridge cache miss; see step 5 above).
59
+ # 4 pre-flight refusal: a FRESH foreign lock is held by a live,
60
+ # non-delegate session; a FRESH lock that will not parse (corrupt); or a
61
+ # lock file with no resolvable session identity at all. Authors nothing.
62
+ # 5 the code worktree is dirty, OR its cleanliness could not be proven
63
+ # (git status itself failed: corrupt index, git missing, not a repo);
64
+ # refused before removal; pass --discard-worktree-changes to override
65
+ # deliberately.
28
66
 
29
67
  require "fileutils"
30
68
  require "date"
31
69
  require "open3"
32
70
  require_relative "lib/bridge"
71
+ require_relative "lib/lock"
33
72
  require_relative "lib/intent_validator"
34
73
 
35
74
  DISPOSITIONS = %w[delivered abandoned].freeze
36
75
 
37
- # The store-line separator this script must reproduce is a real em dash
38
- # (U+2014), the existing INDEX.md convention (store files are exempt from the
39
- # no-em-dash shipped-file rule). Built from the codepoint, not a literal byte
40
- # in this source file, so the script's own source stays em-dash free.
76
+ # The store-line separator this script must reproduce ON WRITE is a real em
77
+ # dash (U+2014), the existing INDEX.md convention (store files are exempt from
78
+ # the no-em-dash shipped-file rule). Built from the codepoint, not a literal
79
+ # byte in this source file, so the script's own source stays em-dash free.
80
+ # READING accepts a plain hyphen too, via the shared Bridge.index_entry_match
81
+ # (intent 188, D9/D12): see move_index_to_terminal below.
41
82
  EM_DASH = "\u2014"
42
83
 
84
+ # Raised internally when the INDEX id resolves to neither `## Active` nor the
85
+ # terminal section (D11): the caller (main) rescues this and exits 1.
86
+ class UnresolvedIndexEntry < StandardError; end
87
+
43
88
  # --- Explicit flag parsing (no eval, no global injection) -------------------
44
89
 
45
90
  def parse_args(argv)
46
91
  opts = {
47
92
  store: nil, id: nil, disposition: nil, index: nil,
48
93
  outcome_summary: nil, index_note: nil, no_commit: false, dry_run: false,
94
+ session: nil, discard_worktree_changes: false,
49
95
  }
50
96
  i = 0
51
97
  while i < argv.length
52
98
  arg = argv[i]
53
99
  case arg
54
- when "--store" then opts[:store] = argv[i += 1]
55
- when "--id" then opts[:id] = argv[i += 1]
56
- when "--disposition" then opts[:disposition] = argv[i += 1]
57
- when "--index" then opts[:index] = argv[i += 1]
58
- when "--outcome-summary" then opts[:outcome_summary] = argv[i += 1]
59
- when "--index-note" then opts[:index_note] = argv[i += 1]
60
- when "--no-commit" then opts[:no_commit] = true
61
- when "--dry-run" then opts[:dry_run] = true
100
+ when "--store" then opts[:store] = argv[i += 1]
101
+ when "--id" then opts[:id] = argv[i += 1]
102
+ when "--disposition" then opts[:disposition] = argv[i += 1]
103
+ when "--index" then opts[:index] = argv[i += 1]
104
+ when "--outcome-summary" then opts[:outcome_summary] = argv[i += 1]
105
+ when "--index-note" then opts[:index_note] = argv[i += 1]
106
+ when "--session" then opts[:session] = argv[i += 1]
107
+ when "--discard-worktree-changes" then opts[:discard_worktree_changes] = true
108
+ when "--no-commit" then opts[:no_commit] = true
109
+ when "--dry-run" then opts[:dry_run] = true
62
110
  else
63
111
  usage_abort("unknown argument #{arg.inspect}")
64
112
  end
@@ -70,7 +118,8 @@ end
70
118
  def usage
71
119
  "usage: end-intent --store <store_path> --id <intent_id> " \
72
120
  "--disposition delivered|abandoned [--index <path>] " \
73
- "[--outcome-summary <text>] [--index-note <text>] [--no-commit] [--dry-run]"
121
+ "[--outcome-summary <text>] [--index-note <text>] [--session <session_id>] " \
122
+ "[--discard-worktree-changes] [--no-commit] [--dry-run]"
74
123
  end
75
124
 
76
125
  def usage_abort(message)
@@ -140,9 +189,7 @@ def stamp_outcome_summary(content, summary)
140
189
  (lines[0..start] + replacement + lines[stop..]).join
141
190
  end
142
191
 
143
- # --- INDEX.md terminal move (D2 step 2) -------------------------------------
144
-
145
- ACTIVE_LINE_RE = /\A- \[(\S+)\s+#{Regexp.escape(EM_DASH)}\s+(.*?)\]\(([^)]+)\)/.freeze
192
+ # --- INDEX.md terminal move (D2 step 2, hardened by intent 188 D9/D11/D12) --
146
193
 
147
194
  def index_target_heading(disposition)
148
195
  disposition == "delivered" ? "## Completed" : "## Abandoned"
@@ -156,8 +203,10 @@ def section_stop(lines, heading_idx)
156
203
  stop
157
204
  end
158
205
 
206
+ # Shared matcher (Bridge.index_entry_match, intent 188 D12): accepts a real em
207
+ # dash OR a plain hyphen as the id/title separator on READ.
159
208
  def active_line_id(line)
160
- m = line.match(ACTIVE_LINE_RE)
209
+ m = Bridge.index_entry_match(line)
161
210
  m && m[1]
162
211
  end
163
212
 
@@ -203,28 +252,40 @@ def insert_terminal_entry(lines, target_start, new_entry)
203
252
  end
204
253
 
205
254
  # Move the intent's `## Active` line into the terminal section. Returns the
206
- # new INDEX.md content, or the original content unchanged when there is
207
- # nothing to do (idempotent: a second run finds no Active entry and the
208
- # terminal section already carries it, so it is a clean no-op). `index_note`
209
- # (D2 "rich entry", review F3), when given, is appended verbatim after the
210
- # date with a single leading space: content authoring (mode/tier, what
211
- # shipped, suite result, and so on) stays with the caller, this only places
212
- # the text where the store's existing Completed/Abandoned lines carry it.
255
+ # new INDEX.md content on success. Raises UnresolvedIndexEntry (D11) when the
256
+ # id resolves to NEITHER `## Active` NOR the terminal section (including the
257
+ # degenerate case where one of the two headings is missing from INDEX.md
258
+ # entirely); returns the content UNCHANGED (a quiet, idempotent success) when
259
+ # the id is already correctly present in the terminal section, whether or not
260
+ # a stray duplicate also still sits under `## Active` (that duplicate is
261
+ # cleaned up too, when found, matching the pre-188 idempotent-cleanup
262
+ # behavior for the ordinary already-moved re-run case).
213
263
  def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
214
264
  target_heading = index_target_heading(disposition)
215
265
  lines = content.lines
216
266
 
217
- active_start = lines.index { |l| l.rstrip == "## Active" }
218
267
  target_start = lines.index { |l| l.rstrip == target_heading }
219
- return content if active_start.nil? || target_start.nil?
268
+ active_start = lines.index { |l| l.rstrip == "## Active" }
269
+ already_in_target = target_start && section_contains_id?(lines, target_start, id)
220
270
 
221
- already_in_target = section_contains_id?(lines, target_start, id)
222
- active_stop = section_stop(lines, active_start)
223
- entry_idx = (active_start + 1...active_stop).find { |i| active_line_id(lines[i]) == id }
271
+ entry_idx = nil
272
+ if active_start
273
+ active_stop = section_stop(lines, active_start)
274
+ entry_idx = (active_start + 1...active_stop).find { |i| active_line_id(lines[i]) == id }
275
+ end
224
276
 
225
- return content if entry_idx.nil? # already moved (or never present): no-op either way
277
+ if entry_idx.nil?
278
+ return content if already_in_target # true idempotency (D11): quiet success
279
+ raise UnresolvedIndexEntry,
280
+ "intent #{id} could not be resolved: not found under ## Active, and not already " \
281
+ "present in #{target_heading} (check INDEX.md for a malformed or missing entry)"
282
+ end
226
283
 
227
- m = lines[entry_idx].match(ACTIVE_LINE_RE)
284
+ if target_start.nil?
285
+ raise UnresolvedIndexEntry, "INDEX.md has no #{target_heading} heading; cannot complete the move"
286
+ end
287
+
288
+ m = Bridge.index_entry_match(lines[entry_idx])
228
289
  title, link = m[2], m[3]
229
290
  note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
230
291
  new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
@@ -236,6 +297,23 @@ def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
236
297
  lines.join
237
298
  end
238
299
 
300
+ # Post-move duplicate detector (SHOULD-FIX 4, post-review hardening). If
301
+ # `## Active` somehow held two entries for the same id, `move_index_to_terminal`
302
+ # only ever removes the FIRST match (`entry_idx` is a single index, found once);
303
+ # a duplicate would otherwise stay under `## Active` forever, exit 0, with no
304
+ # signal anywhere, which is exactly the silent tail this intent exists to kill.
305
+ # Not a failure (the requested move DID succeed): warn only, never fail.
306
+ def warn_if_active_duplicate_remains(content, id)
307
+ lines = content.lines
308
+ active_start = lines.index { |l| l.rstrip == "## Active" }
309
+ return unless active_start
310
+ return unless section_contains_id?(lines, active_start, id)
311
+
312
+ warn "end-intent: intent #{id} still has an entry under ## Active after the move " \
313
+ "(a duplicate ## Active line for this id); INDEX.md needs manual cleanup. Check " \
314
+ "INDEX.md directly, or run /plastic-doctor."
315
+ end
316
+
239
317
  # --- store auto-commit (D2 step 4) ------------------------------------------
240
318
 
241
319
  def git_toplevel(dir)
@@ -262,6 +340,170 @@ def store_commit(store, id, disposition)
262
340
  status.success?
263
341
  end
264
342
 
343
+ # --- pre-flight lock guard (D3/D4, intent 188) ------------------------------
344
+
345
+ # Session resolution order (D3): explicit --session, else CLAUDE_CODE_SESSION_ID,
346
+ # else (only when a delivery.lock exists) that lock's own recorded owner, else
347
+ # (no lock at all, or the recorded owner is itself blank/missing) nil. Returning
348
+ # a blank STRING (rather than nil) here is what crashed end-intent post-review:
349
+ # `Lock.takeover(session: "")` deletes the lock file and THEN raises from
350
+ # `Lock.acquire`'s `blank?(session)` guard, uncaught, destroying mutual
351
+ # exclusion with a raw backtrace. nil is the only safe "could not resolve"
352
+ # signal; main refuses explicitly on it rather than ever reaching takeover.
353
+ def resolve_end_session(explicit, intent_dir)
354
+ return explicit.to_s.strip unless Bridge.blank?(explicit)
355
+ env = ENV["CLAUDE_CODE_SESSION_ID"]
356
+ return env.to_s.strip unless Bridge.blank?(env)
357
+ lock = Lock.read(intent_dir)
358
+ return nil unless lock
359
+ owner = lock["owner_session"].to_s
360
+ Bridge.blank?(owner) ? nil : owner
361
+ end
362
+
363
+ # Read-only pre-flight verdict (D4). Returns [:proceed, lock_or_nil],
364
+ # [:refuse, lock] (a FRESH foreign lock: exit 4, authors nothing),
365
+ # [:refuse_corrupt, nil] (a FRESH lock file that will not parse: exit 4,
366
+ # authors nothing; see below), or [:takeover, lock_or_nil] (a STALE foreign
367
+ # lock, or a STALE corrupt lock: reclaim via Lock.takeover, then proceed as
368
+ # the new owner). Never mutates anything itself, so it is safe to call under
369
+ # --dry-run.
370
+ #
371
+ # `Lock.read` returns nil for BOTH "no lock file" and "lock file exists but is
372
+ # corrupt/unparseable" (post-review hardening, intent 188): treating both as
373
+ # :proceed would let a corrupt-but-FRESH foreign lock (a live owner mid
374
+ # partial write, or genuine corruption) skip arbitration entirely, so steps
375
+ # 1-4 would author real writes while another session may still hold this
376
+ # intent. The lock file's mtime IS the heartbeat (Lock.fresh?) and stays
377
+ # readable even when the JSON content is garbage, so a corrupt lock is
378
+ # arbitrated by freshness exactly like a valid one: fresh refuses (we cannot
379
+ # tell whether it is safe to touch), stale is ours to reclaim automatically
380
+ # (no human repair, per the standing fail-open locking rule).
381
+ def preflight_lock_verdict(intent_dir, session)
382
+ return [:proceed, nil] unless File.exist?(Lock.path(intent_dir))
383
+
384
+ lock = Lock.read(intent_dir)
385
+ if lock.nil?
386
+ return [:refuse_corrupt, nil] if Lock.fresh?(intent_dir)
387
+ return [:takeover, nil]
388
+ end
389
+
390
+ return [:proceed, lock] if Lock.authorized?(lock, session)
391
+ return [:refuse, lock] if Lock.fresh?(intent_dir)
392
+ [:takeover, lock]
393
+ end
394
+
395
+ # --- step 5: disarm (D2/D16, intent 188) ------------------------------------
396
+ #
397
+ # Injected seams (D2): bridge_reader defaults to the real Bridge.read (so the
398
+ # dirty-worktree check can resolve the code worktree path without disarming
399
+ # first), disarm defaults to the real Bridge.disarm_auto. Tests may inject
400
+ # fakes for in-process unit coverage of this function; the subprocess-driven
401
+ # end-to-end tests exercise the real defaults against a hermetic tmp home.
402
+ #
403
+ # Returns :ok, :dirty (exit 5), or :lock_remains (exit 3). A blank session
404
+ # (D3's "no lock at all" fallback case) is a clean no-op: :ok, nothing touched.
405
+ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
406
+ bridge_reader: ->(sess, iid) { Bridge.read(sess, intent_id: iid) },
407
+ disarm: ->(sess, iid) { Bridge.disarm_auto(sess, intent_id: iid) })
408
+ return :ok if Bridge.blank?(session)
409
+
410
+ data = bridge_reader.call(session, id)
411
+ worktree_code = data.is_a?(Hash) ? data.dig("worktree", "code") : nil
412
+
413
+ if worktree_code && Dir.exist?(worktree_code)
414
+ begin
415
+ out, err, status = Open3.capture3("git", "-C", worktree_code, "status", "--porcelain")
416
+ rescue StandardError => e
417
+ out, err, status = nil, e.message, nil
418
+ end
419
+
420
+ # Fail CLOSED (intent 188, post-review hardening). A failed or impossible
421
+ # `git status` (corrupt per-worktree index, git missing from PATH, not a
422
+ # repo) means we cannot PROVE the worktree is clean, and the removal below
423
+ # force-removes on a plain `git worktree remove` failure, which destroys
424
+ # uncommitted work. Never force-remove an unproven worktree: the guard
425
+ # exists to protect uncommitted code, so an inconclusive check must refuse,
426
+ # not silently proceed as if it were clean.
427
+ if status.nil? || !status.success?
428
+ unless discard_worktree_changes
429
+ warn "end-intent: could not inspect the code worktree at #{worktree_code} " \
430
+ "(git status failed: #{err.to_s.strip}); refusing to remove it, because " \
431
+ "removal would force-discard any uncommitted work. Pass " \
432
+ "--discard-worktree-changes to remove it anyway, or repair the worktree."
433
+ return :dirty
434
+ end
435
+ elsif !out.strip.empty? && !discard_worktree_changes
436
+ warn "end-intent: code worktree #{worktree_code} has uncommitted changes; refusing " \
437
+ "to remove it. Pass --discard-worktree-changes to force the close deliberately, " \
438
+ "or commit/stash the changes and re-run."
439
+ return :dirty
440
+ end
441
+ end
442
+
443
+ disarm.call(session, id)
444
+
445
+ # The /tmp bridge is only a cache; the durable lock file is the truth
446
+ # (AGENTS.md). `disarm_auto` no-ops entirely when no bridge resolves (a wiped
447
+ # /tmp, a resumed job under a new session id), which would otherwise strand a
448
+ # committed, terminal intent still holding its lock: the exact stalled
449
+ # completion this intent forbids. Pre-flight has already established that this
450
+ # session is the owner, a delegate, or the post-takeover owner, so clearing
451
+ # the durable lock directly IS safe, PROVIDED the lock actually present here
452
+ # is still ours to clear.
453
+ #
454
+ # TOCTOU fix (post-review hardening, intent 188): the first version of this
455
+ # fallback read the lock's OWN recorded owner and handed it straight back to
456
+ # Lock.release, which makes the ownership check vacuous (comparing a value to
457
+ # itself is always true) and deletes WHATEVER lock is present, including a
458
+ # fresh lock a DIFFERENT, live session acquired during this close's window
459
+ # (no lock existed at pre-flight, so we proceeded; someone else then armed
460
+ # one before we reached this point). That silently broke mutual exclusion.
461
+ # `Lock.authorized?` (owner or a registered delegate), not a raw equality
462
+ # check against the lock's own field, is what actually proves the lock
463
+ # present here is ours; a lock we are not authorized for was acquired by
464
+ # someone else mid-close, and must never be deleted; the verification below
465
+ # then correctly reports :lock_remains (exit 3) rather than silently
466
+ # steamrolling a foreign lock.
467
+ if File.exist?(Lock.path(intent_dir))
468
+ begin
469
+ current = Lock.read(intent_dir)
470
+ if current && Lock.authorized?(current, session)
471
+ # Ours (owner or delegate). Release as the recorded owner, which is
472
+ # what Lock.release expects; `authorized?` is what actually proved
473
+ # the right to do so.
474
+ Lock.release(intent_dir, session: current["owner_session"])
475
+ elsif current
476
+ # A lock we are NOT authorized for is present at step 5, though
477
+ # pre-flight cleared us. Another session acquired it during this
478
+ # close (TOCTOU). Never delete another session's lock: refuse and
479
+ # let the caller sort it out.
480
+ warn "end-intent: the delivery lock at #{Lock.path(intent_dir)} is now held by " \
481
+ "#{current['owner_session'].inspect}, which acquired it during this close; " \
482
+ "refusing to delete another session's lock. Run /plastic-doctor to check the " \
483
+ "lock status."
484
+ end
485
+ # current.nil? here means the lock is corrupt/unparseable; nothing to
486
+ # authorize against, so nothing is attempted, and the verification
487
+ # below reports :lock_remains honestly.
488
+ rescue StandardError => e
489
+ warn "end-intent: direct lock release raised: #{e.message}"
490
+ end
491
+ end
492
+
493
+ unless File.exist?(Lock.path(intent_dir))
494
+ if data.nil?
495
+ warn "end-intent: no bridge resolved for session #{session.inspect} (intent #{id}); " \
496
+ "the delivery lock was cleared directly from disk, but any worktree this intent " \
497
+ "provisioned was NOT removed. Run /plastic-doctor to check for an orphaned worktree."
498
+ end
499
+ return :ok
500
+ end
501
+
502
+ warn "end-intent: the delivery lock at #{Lock.path(intent_dir)} is still present after " \
503
+ "disarm and a direct release attempt. Run /plastic-doctor to check the lock status."
504
+ :lock_remains
505
+ end
506
+
265
507
  # --- main --------------------------------------------------------------------
266
508
 
267
509
  def main(argv)
@@ -281,6 +523,39 @@ def main(argv)
281
523
  index_path = opts[:index] ? expand(opts[:index]) : File.join(File.dirname(store), "INDEX.md")
282
524
  today = Date.today.iso8601
283
525
 
526
+ # --- pre-flight lock guard (D4): before ANY of steps 1-4 write anything ---
527
+ session = resolve_end_session(opts[:session], intent_dir)
528
+
529
+ # A lock file exists but no session identity resolved from ANY source
530
+ # (post-review hardening, intent 188): proceeding would either crash inside
531
+ # Lock.takeover (blank session reaches Lock.acquire's blank? guard AFTER the
532
+ # existing lock file was already deleted) or silently mis-arbitrate. Refuse
533
+ # rather than guess; nothing has been written yet.
534
+ if Bridge.blank?(session) && File.exist?(Lock.path(intent_dir))
535
+ warn "end-intent: a delivery lock exists at #{Lock.path(intent_dir)} but no session " \
536
+ "identity could be resolved (--session, CLAUDE_CODE_SESSION_ID, and the lock's " \
537
+ "own recorded owner are all blank); refusing rather than guessing. Pass " \
538
+ "--session explicitly, or run /plastic-doctor check the lock status."
539
+ exit 4
540
+ end
541
+
542
+ verdict, lock_data = preflight_lock_verdict(intent_dir, session)
543
+
544
+ if verdict == :refuse
545
+ warn "end-intent: delivery lock for #{id} is held by a live session " \
546
+ "(#{lock_data['owner_session']}); refusing to close. Run /plastic-doctor check " \
547
+ "the lock status"
548
+ exit 4
549
+ end
550
+
551
+ if verdict == :refuse_corrupt
552
+ warn "end-intent: the delivery lock at #{Lock.path(intent_dir)} exists and is fresh, " \
553
+ "but its content will not parse (corrupt); refusing to close since another " \
554
+ "session may be actively heartbeating it. Run /plastic-doctor check the lock " \
555
+ "status, or plastic-lock fix once it is confirmed safe."
556
+ exit 4
557
+ end
558
+
284
559
  reason = outcome_guard_reason(intent_dir, disposition)
285
560
  if reason
286
561
  warn "end-intent: #{reason}"
@@ -294,9 +569,23 @@ def main(argv)
294
569
  puts " would append index note: #{opts[:index_note].inspect}" if opts[:index_note]
295
570
  puts " would append savepoint bookend: Done #{disposition}"
296
571
  puts(opts[:no_commit] ? " would skip store commit (--no-commit)" : " would commit the store repo")
572
+ if verdict == :takeover
573
+ owner_desc = lock_data ? lock_data["owner_session"] : "corrupt/unreadable"
574
+ puts " would reclaim the stale delivery lock (owner #{owner_desc}) via takeover"
575
+ end
576
+ puts " would disarm (release the code worktree, clear the delivery lock)"
297
577
  exit 0
298
578
  end
299
579
 
580
+ if verdict == :takeover
581
+ takeover_status, = Lock.takeover(intent_dir, session: session)
582
+ unless takeover_status == :taken
583
+ warn "end-intent: could not take over the stale delivery lock for #{id} " \
584
+ "(status: #{takeover_status}); run /plastic-doctor fix the lock"
585
+ exit 4
586
+ end
587
+ end
588
+
300
589
  # 1b. Intent-file `## Outcome` summary stamp (no-op unless given).
301
590
  if opts[:outcome_summary]
302
591
  intent_file = Bridge.intent_file(intent_dir)
@@ -310,9 +599,17 @@ def main(argv)
310
599
  # 2. INDEX.md terminal move.
311
600
  if File.exist?(index_path)
312
601
  original_index = File.read(index_path)
313
- updated_index = move_index_to_terminal(original_index, id, disposition, today: today,
314
- index_note: opts[:index_note])
315
- File.write(index_path, updated_index) if updated_index != original_index
602
+ begin
603
+ updated_index = move_index_to_terminal(original_index, id, disposition, today: today,
604
+ index_note: opts[:index_note])
605
+ if updated_index != original_index
606
+ File.write(index_path, updated_index)
607
+ warn_if_active_duplicate_remains(updated_index, id)
608
+ end
609
+ rescue UnresolvedIndexEntry => e
610
+ warn "end-intent: #{e.message}"
611
+ exit 1
612
+ end
316
613
  else
317
614
  warn "end-intent: INDEX.md not found at #{index_path}, skipping the terminal move"
318
615
  end
@@ -323,6 +620,13 @@ def main(argv)
323
620
  # 4. Store auto-commit, unless --no-commit.
324
621
  store_commit(store, id, disposition) unless opts[:no_commit]
325
622
 
623
+ # 5. Disarm (D2/D16): dirty-worktree guard, disarm seam, lock verification.
624
+ result = run_disarm(intent_dir, id, session, discard_worktree_changes: opts[:discard_worktree_changes])
625
+ case result
626
+ when :dirty then exit 5
627
+ when :lock_remains then exit 3
628
+ end
629
+
326
630
  puts intent_dir
327
631
  exit 0
328
632
  end
@@ -0,0 +1,74 @@
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
+ require "json"
18
+ require_relative "lib/links_gate"
19
+
20
+ begin
21
+ raw = begin
22
+ $stdin.read
23
+ rescue StandardError
24
+ nil
25
+ end
26
+ exit 0 if raw.nil? || raw.strip.empty?
27
+
28
+ payload = begin
29
+ JSON.parse(raw)
30
+ rescue StandardError
31
+ nil
32
+ end
33
+ exit 0 unless payload.is_a?(Hash)
34
+
35
+ path = payload.dig("tool_input", "file_path") || payload.dig("tool_params", "file_path")
36
+ exit 0 if path.to_s.strip.empty?
37
+
38
+ abs = File.expand_path(path)
39
+ exit 0 unless LinksGate.intent_file?(abs)
40
+
41
+ input = payload["tool_input"] || payload["tool_params"] || {}
42
+ content = input["content"]
43
+ old_string = input["old_string"]
44
+
45
+ before_content = File.exist?(abs) ? File.read(abs) : ""
46
+
47
+ after_content =
48
+ if !content.nil?
49
+ content
50
+ elsif !old_string.nil?
51
+ exit 0 unless before_content.include?(old_string) # the Edit tool itself will fail; nothing to judge
52
+ input["replace_all"] ? before_content.gsub(old_string, input["new_string"].to_s)
53
+ : before_content.sub(old_string, input["new_string"].to_s)
54
+ else
55
+ exit 0 # pathless mutation (no visible proposal); cannot judge, allow
56
+ end
57
+
58
+ plastic_home = ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
59
+
60
+ reason = LinksGate.decision(file_path: abs, before_content: before_content,
61
+ after_content: after_content, plastic_home: plastic_home)
62
+ exit 0 unless reason
63
+
64
+ $stderr.puts reason
65
+ exit 2
66
+ rescue => e
67
+ # Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
68
+ # here so a gate bug (an unreadable UNRELATED store dir, a resolver crash, or
69
+ # anything else) never bricks editing; mirrors hook-lock-gate's own top-level
70
+ # rescue (ruling: the gate must fail open, never closed, on a computation it
71
+ # cannot complete).
72
+ $stderr.puts "plastic links-gate error: #{e.message}"
73
+ exit 0
74
+ end
@@ -57,6 +57,7 @@ class Install < InstallerCore
57
57
 
58
58
  distribute(mode)
59
59
  bootstrap if fresh
60
+ apply_config_flags(argv)
60
61
 
61
62
  results = selected.map { |key| install_for_agent(key, force, argv: argv, input: input, reinstall: reinstall) }
62
63
 
@@ -158,6 +159,13 @@ class Install < InstallerCore
158
159
  --statusline VALUE keep or plastic. If an existing statusline is found, this
159
160
  skips the interactive prompt. Interactive sessions ask by
160
161
  default; non-interactive sessions default to keep.
162
+ --no-advisor Skip installing both advisor agents and the agent-advisor
163
+ skill (advisor.enabled: false)
164
+ --advisor VALUE Which advisor agent is the default: an agent name, or the
165
+ shorthand "real" (plastic-advisor) or "faux"
166
+ (plastic-faux-advisor). Writes advisor.claude.default. Left
167
+ unset, the agent-advisor skill falls back to
168
+ plastic-faux-advisor at consult time.
161
169
  -h, --help Show this help
162
170
 
163
171
  Notes: