@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.24
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.md +6 -5
- package/agents/plastic-enforcer.md +6 -3
- package/agents/plastic-executor.md +4 -0
- package/agents/plastic-node-research.md +28 -0
- package/agents/plastic-node-verify.md +27 -0
- package/agents/plastic-node-work.md +32 -0
- package/bin/lib/context_budget.rb +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/statusline +28 -0
- package/hooks/stop +5 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +22 -19
- package/scripts/exec-worktree +5 -5
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +16 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +26 -3
- package/scripts/hook-session-start +49 -10
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/graph_edges.rb +4 -4
- package/scripts/lib/graph_file.rb +4 -4
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +409 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +411 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +2 -2
- package/scripts/lib/handoff.rb +40 -13
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +13 -1
- package/scripts/lib/hook_replay.rb +23 -5
- package/scripts/lib/index_entry.rb +53 -0
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +228 -9
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +8 -9
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +11 -2
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +2 -2
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.rb +1 -1
- package/scripts/lib/report_screen.rb +10 -6
- package/scripts/lib/roadmap_graph.rb +1 -1
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_render.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +36 -10
- package/scripts/lib/runner_dispatch.rb +66 -51
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +9 -10
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +22 -16
- package/scripts/lib/session_ledger.rb +44 -4
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/verify_intent.rb +2 -2
- package/scripts/lib/work_graph_validator.rb +6 -6
- package/scripts/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/new-intent +1 -1
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +224 -0
- package/scripts/plastic-lock +33 -32
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +8 -8
- package/skills/auto/references/end-tail.md +9 -11
- package/skills/conventions/references/completion-and-done.md +9 -10
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +4 -4
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +7 -1
- package/skills/releasing/SKILL.md +2 -2
- package/skills/releasing/references/promotion-and-tagging.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/references/track-2-auto.md +1 -1
- package/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- package/scripts/lib/bridge.rb +0 -116
|
@@ -12,6 +12,7 @@ require "time"
|
|
|
12
12
|
require_relative "session_ledger"
|
|
13
13
|
require_relative "handoff"
|
|
14
14
|
require_relative "lock"
|
|
15
|
+
require_relative "active_delivery"
|
|
15
16
|
require_relative "report_screen"
|
|
16
17
|
|
|
17
18
|
module DaySummary
|
|
@@ -43,7 +44,7 @@ module DaySummary
|
|
|
43
44
|
open: open_items(store, day),
|
|
44
45
|
done: last_done(store, day),
|
|
45
46
|
live: live_intents(home, now: now),
|
|
46
|
-
others: active_sessions(store, session, now: now, ttl: heartbeat_ttl),
|
|
47
|
+
others: active_sessions(store, session, now: now, ttl: heartbeat_ttl, home: home),
|
|
47
48
|
}
|
|
48
49
|
hidden = Hash.new(0)
|
|
49
50
|
cap!(lists, hidden, :open, OPEN_CAP, keep: :newest)
|
|
@@ -142,7 +143,7 @@ module DaySummary
|
|
|
142
143
|
"(no savepoint yet)"
|
|
143
144
|
end
|
|
144
145
|
|
|
145
|
-
def active_sessions(store, session, now:, ttl:)
|
|
146
|
+
def active_sessions(store, session, now:, ttl:, home: nil)
|
|
146
147
|
tmp_root = SessionLedger.tmp_root(store)
|
|
147
148
|
return [] unless File.directory?(tmp_root)
|
|
148
149
|
|
|
@@ -155,7 +156,7 @@ module DaySummary
|
|
|
155
156
|
age = heartbeat_age(dir, now)
|
|
156
157
|
next if age.nil? || age > ttl
|
|
157
158
|
|
|
158
|
-
"- #{sid} (#{(age / 60).floor}m ago, on #{
|
|
159
|
+
"- #{sid} (#{(age / 60).floor}m ago, on #{delivering_label(store, home, sid, now)})"
|
|
159
160
|
end
|
|
160
161
|
rescue SystemCallError
|
|
161
162
|
[]
|
|
@@ -178,14 +179,21 @@ module DaySummary
|
|
|
178
179
|
nil
|
|
179
180
|
end
|
|
180
181
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
182
|
+
# The delivering intent's id, or "day ledger" (344 n2, D4-D7): the
|
|
183
|
+
# basename before "--" of ActiveDelivery.resolve_by_short_session's
|
|
184
|
+
# match, else the session records into today's day ledger.
|
|
185
|
+
def delivering_label(store, home, sid, now)
|
|
186
|
+
return "day ledger" if home.to_s.empty?
|
|
187
|
+
|
|
188
|
+
dir = ActiveDelivery.resolve_by_short_session(
|
|
189
|
+
global_store: store,
|
|
190
|
+
project_roots: ActiveDelivery.project_roots(home),
|
|
191
|
+
short_session: sid,
|
|
192
|
+
now: now
|
|
193
|
+
)
|
|
194
|
+
dir ? File.basename(dir).split("--", 2).first : "day ledger"
|
|
195
|
+
rescue StandardError
|
|
196
|
+
"day ledger"
|
|
189
197
|
end
|
|
190
198
|
|
|
191
199
|
# --- rendering, pure -------------------------------------------------------------
|
|
@@ -27,11 +27,16 @@ class Doctor
|
|
|
27
27
|
"hermes" => { name: "Hermes", dir: File.join(Dir.home, ".hermes") },
|
|
28
28
|
}.freeze
|
|
29
29
|
|
|
30
|
-
# The Claude events hooks_registered expects in settings.json: the
|
|
30
|
+
# The Claude events hooks_registered expects in settings.json: the eight-event map of
|
|
31
31
|
# cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301;
|
|
32
|
-
# intent 316a added MessageDisplay, registered for message-display, Claude only
|
|
32
|
+
# intent 316a added MessageDisplay, registered for message-display, Claude only; intent
|
|
33
|
+
# 355 added PreToolUse, registered for call-budget, Claude only; intent 340b added Stop,
|
|
34
|
+
# registered for stop, Claude only). Stop's registration is static (D5/D7) even though
|
|
35
|
+
# its runtime arm defaults off (D9, runner.stop_hook), so an install missing it is
|
|
36
|
+
# exactly as broken as one missing PreCompact, and this list keeps hooks_registered and
|
|
37
|
+
# hooks_match_registry agreeing on that (row 4.32).
|
|
33
38
|
CLAUDE_HOOK_EVENTS = %w[SessionStart PreToolUse PreCompact PostToolUse UserPromptSubmit SessionEnd
|
|
34
|
-
MessageDisplay].freeze
|
|
39
|
+
MessageDisplay Stop].freeze
|
|
35
40
|
|
|
36
41
|
# Launchers the installer places in the agent's hooks dir that are NOT hooks
|
|
37
42
|
# (intent 204): plastic-statusline is the settings["statusLine"] command, wired
|
|
@@ -15,21 +15,12 @@ require_relative "session_ledger"
|
|
|
15
15
|
# heartbeat is only refreshed on prompts and edits, so an old heartbeat is evidence, not
|
|
16
16
|
# proof, that the session is gone. Fail-open: an unreadable directory is skipped, never
|
|
17
17
|
# raised. Depends on the including class for `plastic_home` and `check`.
|
|
18
|
+
#
|
|
19
|
+
# Intent 344 (G11, D10): the third check this category used to carry,
|
|
20
|
+
# `no_pointer_session_tmp`, is retired along with the per-session state it read.
|
|
18
21
|
module DoctorSessionLedger
|
|
19
22
|
ORPHAN_TTL_SECONDS = 24 * 60 * 60
|
|
20
23
|
|
|
21
|
-
# Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
|
|
22
|
-
# DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
|
|
23
|
-
# session start never ran at all (a `-p` print session, a resumed
|
|
24
|
-
# background job, an unregistered SessionStart hook), not a session that
|
|
25
|
-
# ran and then never closed. No choice of session id ever creates a
|
|
26
|
-
# pointer for that class, so it would otherwise stay invisible for the
|
|
27
|
-
# full 24 hours (or forever, once hook-capture/hook-record stop creating
|
|
28
|
-
# it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
|
|
29
|
-
# between its own start and its first pointer write is normal and must not
|
|
30
|
-
# be flagged, but that window is seconds, not hours.
|
|
31
|
-
NO_POINTER_TTL_SECONDS = 5 * 60
|
|
32
|
-
|
|
33
24
|
# Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
|
|
34
25
|
# else that file's mtime, else the directory's mtime.
|
|
35
26
|
def heartbeat_age(dir, now)
|
|
@@ -49,7 +40,6 @@ module DoctorSessionLedger
|
|
|
49
40
|
|
|
50
41
|
store_dir = File.join(plastic_home, "store")
|
|
51
42
|
orphans = orphaned_session_dirs(store_dir, now)
|
|
52
|
-
no_pointer = no_pointer_session_dirs(store_dir, now)
|
|
53
43
|
shape = day_ledger_shape_problems(store_dir)
|
|
54
44
|
|
|
55
45
|
checks = []
|
|
@@ -67,20 +57,6 @@ module DoctorSessionLedger
|
|
|
67
57
|
"session is gone: a live session rewrites its heartbeat on every " \
|
|
68
58
|
"prompt and edit, so only a listed directory may be removed, by hand.")
|
|
69
59
|
end
|
|
70
|
-
checks << if no_pointer.empty?
|
|
71
|
-
check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
|
|
72
|
-
message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
|
|
73
|
-
"longer than #{NO_POINTER_TTL_SECONDS} seconds")
|
|
74
|
-
else
|
|
75
|
-
check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
|
|
76
|
-
message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
|
|
77
|
-
"with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
|
|
78
|
-
"(session start never ran for this session)",
|
|
79
|
-
details: no_pointer, fixable: true,
|
|
80
|
-
fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
|
|
81
|
-
"session never started: no `current` pointer means session start never " \
|
|
82
|
-
"ran for it, so this is not a live session missing a checklist entry.")
|
|
83
|
-
end
|
|
84
60
|
checks << if shape.empty?
|
|
85
61
|
check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
|
|
86
62
|
message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
|
|
@@ -113,31 +89,6 @@ module DoctorSessionLedger
|
|
|
113
89
|
[] # unreadable .tmp/: nothing to report, never a crash
|
|
114
90
|
end
|
|
115
91
|
|
|
116
|
-
# Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
|
|
117
|
-
# NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
|
|
118
|
-
# that one is age-only and blind to whether a pointer exists at all, so a
|
|
119
|
-
# young no-pointer dir (a session between its start and its first pointer
|
|
120
|
-
# write) must not appear here even though it may well appear there once it
|
|
121
|
-
# ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
|
|
122
|
-
# and a dir can legitimately show up in neither, either, or both.
|
|
123
|
-
def no_pointer_session_dirs(store_dir, now)
|
|
124
|
-
tmp_root = SessionLedger.tmp_root(store_dir)
|
|
125
|
-
return [] unless File.directory?(tmp_root)
|
|
126
|
-
|
|
127
|
-
Dir.children(tmp_root).sort.filter_map do |name|
|
|
128
|
-
dir = File.join(tmp_root, name)
|
|
129
|
-
next unless File.directory?(dir)
|
|
130
|
-
next if File.exist?(File.join(dir, "current"))
|
|
131
|
-
|
|
132
|
-
age = heartbeat_age(dir, now)
|
|
133
|
-
next if age <= NO_POINTER_TTL_SECONDS
|
|
134
|
-
|
|
135
|
-
"global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
|
|
136
|
-
end
|
|
137
|
-
rescue SystemCallError
|
|
138
|
-
[] # unreadable .tmp/: nothing to report, never a crash
|
|
139
|
-
end
|
|
140
|
-
|
|
141
92
|
def day_ledger_shape_problems(store_dir)
|
|
142
93
|
root = SessionLedger.sessions_root(store_dir)
|
|
143
94
|
return [] unless File.directory?(root)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# EnginePermissions: the engine deny rule (intent 340b, G7c, n3). A permissions.deny
|
|
5
|
+
# block merged into settings.json at install so the engine directories are not
|
|
6
|
+
# editable by a dispatched agent (C25's self-preservation half).
|
|
7
|
+
#
|
|
8
|
+
# Claude Code accepts a Write(...) path rule and never consults it: Edit(...) is the
|
|
9
|
+
# rule that covers Write, MultiEdit and NotebookEdit. Every entry below names Edit,
|
|
10
|
+
# never Write, and every entry ends in /** because a bare directory path matches the
|
|
11
|
+
# directory itself, not the files under it.
|
|
12
|
+
#
|
|
13
|
+
# This is a second ownership mechanism, deliberately not a reuse of HookRegistry's
|
|
14
|
+
# merge, which knows its own entries by a plastic- launcher basename inside a command
|
|
15
|
+
# string. A deny entry is a bare string with no marker in it, so ownership here is
|
|
16
|
+
# exact-string membership in ENTRIES. The consequence is deliberate: an entry the
|
|
17
|
+
# owner has edited no longer matches the frozen string, so it is the owner's from
|
|
18
|
+
# that point on - the merge appends Plastic's own alongside it, and removal leaves
|
|
19
|
+
# the edited one in place untouched.
|
|
20
|
+
#
|
|
21
|
+
# Pure functions only: no file I/O here. InstallerCore#merge_engine_permissions and
|
|
22
|
+
# #remove_engine_permissions own the read-modify-write against settings.json,
|
|
23
|
+
# mirroring the split HookRegistry keeps from merge_claude_hooks.
|
|
24
|
+
module EnginePermissions
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
ENTRIES = [
|
|
28
|
+
"Edit(~/.plastic/scripts/**)",
|
|
29
|
+
"Edit(~/.plastic/skills/**)",
|
|
30
|
+
"Edit(~/.plastic/hooks/**)",
|
|
31
|
+
"Edit(~/.plastic/templates/**)",
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
# Returns a new settings hash with ENTRIES merged into permissions.deny, appended
|
|
35
|
+
# (never duplicated) alongside whatever is already there. Survives a permissions
|
|
36
|
+
# or deny value that is not the shape expected (row 3.12): a non-Hash permissions
|
|
37
|
+
# value or a non-Array deny value is replaced rather than raised on. Never
|
|
38
|
+
# mutates the argument.
|
|
39
|
+
def merge_into(settings)
|
|
40
|
+
settings = settings.is_a?(Hash) ? settings.dup : {}
|
|
41
|
+
|
|
42
|
+
permissions = settings["permissions"]
|
|
43
|
+
permissions = permissions.is_a?(Hash) ? permissions.dup : {}
|
|
44
|
+
|
|
45
|
+
deny = permissions["deny"]
|
|
46
|
+
deny = deny.is_a?(Array) ? deny.dup : []
|
|
47
|
+
|
|
48
|
+
ENTRIES.each { |entry| deny << entry unless deny.include?(entry) }
|
|
49
|
+
|
|
50
|
+
permissions["deny"] = deny
|
|
51
|
+
settings["permissions"] = permissions
|
|
52
|
+
settings
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns a new settings hash with exactly the ENTRIES strings removed from
|
|
56
|
+
# permissions.deny, leaving every other entry (the owner's own deny rules, and
|
|
57
|
+
# any Plastic entry the owner has since edited) untouched. Prunes the deny array
|
|
58
|
+
# once it is empty, and the whole permissions block once nothing else remains
|
|
59
|
+
# under it (row 3.10). A settings/permissions/deny shape that is not what is
|
|
60
|
+
# expected is left alone rather than raised on (row 3.12's uninstall side).
|
|
61
|
+
def remove_from(settings)
|
|
62
|
+
return settings unless settings.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
permissions = settings["permissions"]
|
|
65
|
+
return settings unless permissions.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
deny = permissions["deny"]
|
|
68
|
+
return settings unless deny.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
settings = settings.dup
|
|
71
|
+
permissions = permissions.dup
|
|
72
|
+
deny = deny.reject { |entry| ENTRIES.include?(entry) }
|
|
73
|
+
|
|
74
|
+
if deny.empty?
|
|
75
|
+
permissions.delete("deny")
|
|
76
|
+
else
|
|
77
|
+
permissions["deny"] = deny
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if permissions.empty?
|
|
81
|
+
settings.delete("permissions")
|
|
82
|
+
else
|
|
83
|
+
settings["permissions"] = permissions
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
settings
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "open3"
|
|
5
|
-
require_relative "bridge"
|
|
6
5
|
require_relative "arm"
|
|
7
6
|
require_relative "lock"
|
|
8
7
|
require_relative "worktree"
|
|
@@ -29,8 +28,8 @@ require_relative "scaffold_intent"
|
|
|
29
28
|
# fail-open, see the merge-verification note below), `finisher:` (defaults to
|
|
30
29
|
# `Worktree.method(:finish)`), `status_checker:` (defaults to a `git status --porcelain`
|
|
31
30
|
# lambda). Every seam exists so
|
|
32
|
-
# tests run with no real git, no real worktree removal, and no real
|
|
33
|
-
# tmpdir.
|
|
31
|
+
# tests run with no real git, no real worktree removal, and no real delivery-lock file
|
|
32
|
+
# outside a tmpdir.
|
|
34
33
|
#
|
|
35
34
|
# MERGE VERIFICATION (delivered disposition only). `Worktree.merge_branch` is deliberately
|
|
36
35
|
# fail-open: on a conflict it runs `git merge --abort`, warns, and returns false, and
|
|
@@ -39,7 +38,7 @@ require_relative "scaffold_intent"
|
|
|
39
38
|
# module never trusts the discarded return value; it asks git directly, AFTER finish, whether
|
|
40
39
|
# the intent's code branch is now an ancestor of the repo's current HEAD
|
|
41
40
|
# (`merged_into_current_branch?` below). `repo` and `branch` are captured BEFORE the finisher
|
|
42
|
-
# runs, because finish deletes the
|
|
41
|
+
# runs, because finish deletes the delivery's `worktree` block.
|
|
43
42
|
#
|
|
44
43
|
# DOES NOT RUN A TEST SUITE, in any form. `verify-intent` owns test execution; one concern
|
|
45
44
|
# per script (spec D8).
|
|
@@ -60,6 +59,10 @@ module ExecWorktree
|
|
|
60
59
|
# [stdout, stderr, status] triple Open3.capture3 returns (status responds to
|
|
61
60
|
# success?). A raised error (git missing, corrupt repo) is caught and reported as a
|
|
62
61
|
# nil status, so the caller can fail closed the same way a failed exit status would.
|
|
62
|
+
def blank?(value)
|
|
63
|
+
value.nil? || value.to_s.strip.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
63
66
|
def default_status_checker
|
|
64
67
|
lambda do |worktree_code|
|
|
65
68
|
Open3.capture3("git", "-C", worktree_code, "status", "--porcelain")
|
|
@@ -85,7 +88,7 @@ module ExecWorktree
|
|
|
85
88
|
# `<repo>/.claude/worktrees/<name>` (scripts/lib/worktree.rb:75), so walking up three
|
|
86
89
|
# directories recovers `<repo>` deterministically, with no I/O and no git.
|
|
87
90
|
def repo_from_worktree_code(worktree_code)
|
|
88
|
-
return nil if
|
|
91
|
+
return nil if blank?(worktree_code)
|
|
89
92
|
File.dirname(File.dirname(File.dirname(worktree_code.to_s)))
|
|
90
93
|
end
|
|
91
94
|
|
|
@@ -94,7 +97,7 @@ module ExecWorktree
|
|
|
94
97
|
# i.e. the merge actually landed. Fails CLOSED (not merged) on a blank repo/branch or a
|
|
95
98
|
# raising runner, so an inconclusive check is never reported as a success.
|
|
96
99
|
def merged_into_current_branch?(runner, repo:, branch:)
|
|
97
|
-
return false if
|
|
100
|
+
return false if blank?(repo) || blank?(branch)
|
|
98
101
|
runner.run("-C", repo, "merge-base", "--is-ancestor", branch, "HEAD").success?
|
|
99
102
|
rescue StandardError
|
|
100
103
|
false
|
|
@@ -106,12 +109,12 @@ module ExecWorktree
|
|
|
106
109
|
# The lock-owner fallback is correct here: this is a teardown step on an intent this
|
|
107
110
|
# session has been delivering.
|
|
108
111
|
def resolve_session(explicit, env_session, intent_dir)
|
|
109
|
-
return explicit.to_s.strip unless
|
|
110
|
-
return env_session.to_s.strip unless
|
|
112
|
+
return explicit.to_s.strip unless blank?(explicit)
|
|
113
|
+
return env_session.to_s.strip unless blank?(env_session)
|
|
111
114
|
lock = Lock.read(intent_dir)
|
|
112
115
|
return nil unless lock
|
|
113
116
|
owner = lock["owner_session"].to_s
|
|
114
|
-
|
|
117
|
+
blank?(owner) ? nil : owner
|
|
115
118
|
end
|
|
116
119
|
|
|
117
120
|
# --- result builders (the exit_code/stdout/stderr shape the thin CLIs share) --------
|
|
@@ -181,7 +184,7 @@ module ExecWorktree
|
|
|
181
184
|
merge_line =
|
|
182
185
|
if disposition != "delivered"
|
|
183
186
|
"not attempted (abandoned)"
|
|
184
|
-
elsif
|
|
187
|
+
elsif blank?(target_branch)
|
|
185
188
|
"merged (integration target branch could not be resolved)"
|
|
186
189
|
else
|
|
187
190
|
"merged into #{target_branch}"
|
|
@@ -202,10 +205,10 @@ module ExecWorktree
|
|
|
202
205
|
runner: Worktree::ShellRunner.new,
|
|
203
206
|
finisher: Worktree.method(:finish),
|
|
204
207
|
status_checker: default_status_checker)
|
|
205
|
-
return usage_result("--store is required") if
|
|
206
|
-
return usage_result("--id is required") if
|
|
207
|
-
return usage_result("--home is required") if
|
|
208
|
-
return usage_result("--disposition is required") if
|
|
208
|
+
return usage_result("--store is required") if blank?(store)
|
|
209
|
+
return usage_result("--id is required") if blank?(id)
|
|
210
|
+
return usage_result("--home is required") if blank?(home)
|
|
211
|
+
return usage_result("--disposition is required") if blank?(disposition)
|
|
209
212
|
unless DISPOSITIONS.include?(disposition)
|
|
210
213
|
return usage_result("--disposition must be one of #{DISPOSITIONS.join('|')} (got #{disposition.inspect})")
|
|
211
214
|
end
|
|
@@ -220,16 +223,16 @@ module ExecWorktree
|
|
|
220
223
|
|
|
221
224
|
# Step 0: session resolution + pre-flight refusal (D3-mirroring; see resolve_session).
|
|
222
225
|
key_session = resolve_session(session, env_session, intent_dir)
|
|
223
|
-
if
|
|
226
|
+
if blank?(key_session) && File.exist?(Lock.path(intent_dir))
|
|
224
227
|
return deny_result(EXIT_UNRESOLVED, no_session_message(intent_dir))
|
|
225
228
|
end
|
|
226
229
|
|
|
227
|
-
|
|
228
|
-
worktree_code =
|
|
229
|
-
return ok_result(nothing_provisioned_message(intent_dir)) if
|
|
230
|
+
delivery = Arm.delivery(intent_dir: intent_dir, home: normalized_home)
|
|
231
|
+
worktree_code = delivery.dig("worktree", "code")
|
|
232
|
+
return ok_result(nothing_provisioned_message(intent_dir)) if blank?(worktree_code)
|
|
230
233
|
|
|
231
|
-
branch =
|
|
232
|
-
# Captured now, before `finisher.call` below (Worktree.finish) deletes the
|
|
234
|
+
branch = delivery.dig("worktree", "code_branch")
|
|
235
|
+
# Captured now, before `finisher.call` below (Worktree.finish) deletes the delivery's
|
|
233
236
|
# `worktree` block, so the post-finish merge verification (BLOCKING 1/2) still has
|
|
234
237
|
# both `repo` and `branch` to work with.
|
|
235
238
|
repo = repo_from_worktree_code(worktree_code)
|
|
@@ -254,7 +257,7 @@ module ExecWorktree
|
|
|
254
257
|
# disposition only, whether the worktree directory still exists on disk (teardown
|
|
255
258
|
# incomplete); second, for a delivered disposition only, whether the branch actually
|
|
256
259
|
# merged (BLOCKING 1/2, see merged_into_current_branch? above).
|
|
257
|
-
finisher.call(
|
|
260
|
+
finisher.call(delivery, home: normalized_home, runner: runner,
|
|
258
261
|
merge: disposition == "delivered")
|
|
259
262
|
|
|
260
263
|
removed = !Dir.exist?(worktree_code)
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
# GraphEdges (intent 334, n1): the shared `needs` syntax and cycle-path check
|
|
5
5
|
# (327 D2r-D4r, D12r). Parses a "## Graph" section's edge lines into a node
|
|
6
6
|
# set and an edge map, the one parser an intent's graph.md and a roadmap's
|
|
7
|
-
# own ## Graph section both use unchanged (C1,
|
|
7
|
+
# own ## Graph section both use unchanged (C1, review A5): the same list-item
|
|
8
8
|
# grammar, the same root keyword, the same cycle walk. Deliberately loose
|
|
9
9
|
# about what an id looks like - a roadmap id is numeric ("334", "340a"), a
|
|
10
10
|
# node id carries a kind prefix ("n1") - the kind-prefix rule belongs to
|
|
11
|
-
# NodeFile and WorkGraphValidator, never here (D12r,
|
|
11
|
+
# NodeFile and WorkGraphValidator, never here (D12r, review A6).
|
|
12
12
|
#
|
|
13
13
|
# Grammar (D2r/D3r): a list item shaped "- <id> needs <target> [<target>
|
|
14
14
|
# ...]", ending at end of line. The single literal target "nothing" declares
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
# all, "nothing" alongside other targets, or a target token carrying
|
|
19
19
|
# sentence punctuation (a period, a colon, ...) rather than the loose
|
|
20
20
|
# id-token charset, is an error naming the line, never a silently
|
|
21
|
-
# mis-parsed edge or an invented node (
|
|
21
|
+
# mis-parsed edge or an invented node (review A7).
|
|
22
22
|
#
|
|
23
23
|
# Pure and side-effect-free; never raises across the boundary, matching the
|
|
24
24
|
# Result-hash convention every library in scripts/lib/ follows.
|
|
@@ -30,7 +30,7 @@ module GraphEdges
|
|
|
30
30
|
ROOT_TARGET = "nothing"
|
|
31
31
|
|
|
32
32
|
# {nodes:, edges:, errors:} - nodes is the declared ids together with every
|
|
33
|
-
# id any edge targets (
|
|
33
|
+
# id any edge targets (review A5), in first-seen order; edges maps a declared
|
|
34
34
|
# id to its target ids (empty for a root); errors names each malformed
|
|
35
35
|
# edge-shaped line. Prose lines contribute nothing and raise nothing.
|
|
36
36
|
def parse(section_text)
|
|
@@ -7,9 +7,9 @@ require_relative "atomic_write"
|
|
|
7
7
|
# GraphFile (intent 334, n2): the four graph.md sections (## Goal,
|
|
8
8
|
# ## Decisions, ## Graph, ## Status), the verify:-none directive, and the two
|
|
9
9
|
# writers - write_status and append_decision. Both writers refuse a cyclic
|
|
10
|
-
# graph and both go through AtomicWrite (
|
|
10
|
+
# graph and both go through AtomicWrite (review D19r). Fence-aware everywhere a
|
|
11
11
|
# heading is located, so a fenced example carrying a fake "## " line never
|
|
12
|
-
# splits or ends a real section (
|
|
12
|
+
# splits or ends a real section (review A8's sibling concern, applied to
|
|
13
13
|
# section boundaries rather than edge lines).
|
|
14
14
|
module GraphFile
|
|
15
15
|
module_function
|
|
@@ -19,8 +19,8 @@ module GraphFile
|
|
|
19
19
|
|
|
20
20
|
# {ok:, goal:, decisions:, graph:, status:, verify:, errors:}. `graph` is
|
|
21
21
|
# GraphEdges.parse's own Result hash, over the Graph section text with any
|
|
22
|
-
# verify:-none directive line stripped first (
|
|
23
|
-
# section, or one that declares no nodes, is an error naming which (
|
|
22
|
+
# verify:-none directive line stripped first (review A8). A missing ## Graph
|
|
23
|
+
# section, or one that declares no nodes, is an error naming which (review
|
|
24
24
|
# A10).
|
|
25
25
|
def parse(path)
|
|
26
26
|
return failure(["graph file not found: #{path}"]) unless File.exist?(path)
|