@zalom/plastic 1.3.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.
- package/PLASTIC-reference.md +8 -6
- package/PLASTIC.md +24 -3
- package/hooks/hooks.json +5 -0
- package/hooks/links-gate +3 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +164 -58
- package/scripts/end-intent +347 -43
- package/scripts/hook-links-gate +74 -0
- package/scripts/lib/bridge.rb +29 -1
- package/scripts/lib/config_asks.rb +110 -0
- package/scripts/lib/graph_rebuild.rb +30 -6
- package/scripts/lib/hook_registry.rb +2 -1
- package/scripts/lib/installer_core.rb +30 -7
- package/scripts/lib/intent_validator.rb +38 -10
- package/scripts/lib/links_gate.rb +140 -0
- package/scripts/lib/links_projection.rb +71 -12
- package/scripts/lib/power_tools.rb +57 -14
- package/scripts/lib/project_validator.rb +113 -0
- package/scripts/lib/qmd_hook.rb +12 -8
- package/scripts/lib/restore_intent_v1.rb +154 -0
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +38 -10
- package/scripts/lib/store_discovery.rb +77 -0
- package/scripts/lib/store_provisioning.rb +21 -12
- package/scripts/new-intent +10 -12
- package/scripts/project-links +132 -35
- package/scripts/provision-project-store +18 -5
- package/scripts/read-config +1 -0
- package/scripts/rebuild-graph +42 -17
- package/scripts/restore-intent-v1 +288 -0
- package/scripts/roadmap-next +9 -2
- package/scripts/roadmap-savepoint +9 -1
- package/scripts/update.rb +50 -1
- package/scripts/validate-intent +3 -1
- package/scripts/validate-project +53 -0
- package/scripts/write-config +105 -0
- package/skills/auto/SKILL.md +16 -10
- package/skills/auto/references/end-tail.md +27 -13
- package/skills/install/SKILL.md +4 -4
- package/skills/intent-creating/SKILL.md +5 -0
- package/skills/intent-ending/SKILL.md +49 -36
- package/skills/project-creating/SKILL.md +29 -1
- package/skills/releasing/SKILL.md +37 -19
- package/skills/roadmap/SKILL.md +9 -7
- package/skills/roadmap/references/file-format.md +14 -10
- package/skills/roadmap/references/operations.md +22 -18
- package/skills/roadmap-continuing/SKILL.md +5 -5
- package/skills/roadmap-continuing/evals/evals.json +3 -3
- package/skills/roadmap-continuing/references/liveness-ranking.md +6 -5
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +10 -10
- package/skills/update/SKILL.md +30 -17
- package/templates/roadmap.md +8 -8
package/scripts/end-intent
CHANGED
|
@@ -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
|
|
6
|
-
# procedure
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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)
|
|
22
|
-
# the
|
|
23
|
-
#
|
|
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:
|
|
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
|
|
38
|
-
# (U+2014), the existing INDEX.md convention (store files are exempt from
|
|
39
|
-
# no-em-dash shipped-file rule). Built from the codepoint, not a literal
|
|
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"
|
|
55
|
-
when "--id"
|
|
56
|
-
when "--disposition"
|
|
57
|
-
when "--index"
|
|
58
|
-
when "--outcome-summary"
|
|
59
|
-
when "--index-note"
|
|
60
|
-
when "--
|
|
61
|
-
when "--
|
|
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>] [--
|
|
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 =
|
|
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
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -259,6 +259,34 @@ module Bridge
|
|
|
259
259
|
auto_pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
260
260
|
end
|
|
261
261
|
|
|
262
|
+
# --- Shared INDEX entry matcher (intent 188, D12/D13) -----------------------
|
|
263
|
+
#
|
|
264
|
+
# ONE definition site for the "- [ID <sep> Title](link)" shape both
|
|
265
|
+
# `intent_active?` (below) and `scripts/end-intent`'s own INDEX-move parser
|
|
266
|
+
# depend on, so the two regexes can never drift apart again (the em-dash-only
|
|
267
|
+
# bug was flagged and deferred at intents 96 and 169, then independently
|
|
268
|
+
# rediscovered as a SEPARATE end-intent defect at intent 188). Accepts a real
|
|
269
|
+
# em dash (U+2014) OR a plain hyphen as the id/title separator on READ.
|
|
270
|
+
# Hardening this widens intent_active?'s fail-open case: a hyphen-formatted
|
|
271
|
+
# `## Active` line used to read as not-active (lock gate failed open); it now
|
|
272
|
+
# reads as active (gate correctly blocks). Accepted as a bug fix (D13): no
|
|
273
|
+
# passing test relied on the old fail-open behavior. Every WRITE still emits
|
|
274
|
+
# the real em dash (D10); only what this matcher can PARSE has widened.
|
|
275
|
+
#
|
|
276
|
+
# The separator is built from the codepoint, not a literal byte in this
|
|
277
|
+
# source file, so this new code stays em-dash free (the shipped-file
|
|
278
|
+
# convention; store files like INDEX.md are the exempt surface this matcher
|
|
279
|
+
# READS, not where this constant lives). Matches the existing convention in
|
|
280
|
+
# scripts/end-intent.
|
|
281
|
+
EM_DASH = "\u2014".freeze
|
|
282
|
+
INDEX_ENTRY_RE = /\A- \[(\S+)\s+(?:#{Regexp.escape(EM_DASH)}|-)\s+(.*?)\]\(([^)]+)\)/.freeze
|
|
283
|
+
|
|
284
|
+
# Match `line` (already chomped) against the shared INDEX entry shape.
|
|
285
|
+
# Returns a MatchData (captures: 1 = id, 2 = title, 3 = link) or nil.
|
|
286
|
+
def self.index_entry_match(line)
|
|
287
|
+
line.to_s.match(INDEX_ENTRY_RE)
|
|
288
|
+
end
|
|
289
|
+
|
|
262
290
|
# --- Terminal-state bridge purge (intent 80) -------------------------------
|
|
263
291
|
|
|
264
292
|
# True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
|
|
@@ -284,7 +312,7 @@ module Bridge
|
|
|
284
312
|
end
|
|
285
313
|
next unless in_active
|
|
286
314
|
break if stripped.start_with?("## ") # next section ends the Active block
|
|
287
|
-
m = stripped
|
|
315
|
+
m = index_entry_match(stripped)
|
|
288
316
|
return true if m && m[1] == target
|
|
289
317
|
end
|
|
290
318
|
false
|