@zalom/plastic 1.4.0 → 1.5.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 (44) hide show
  1. package/PLASTIC-reference.md +2 -0
  2. package/PLASTIC.md +160 -42
  3. package/agents/plastic-intent-curator.md +10 -2
  4. package/package.json +1 -1
  5. package/scripts/codex-hook +122 -8
  6. package/scripts/dashboard.rb +323 -71
  7. package/scripts/doctor.rb +271 -15
  8. package/scripts/end-intent +32 -7
  9. package/scripts/hook-lock-gate +8 -3
  10. package/scripts/install.rb +51 -6
  11. package/scripts/lib/bridge.rb +79 -30
  12. package/scripts/lib/hook_registry.rb +44 -2
  13. package/scripts/lib/installer_core.rb +45 -6
  14. package/scripts/lib/lock.rb +186 -11
  15. package/scripts/lib/maintenance_git.rb +94 -0
  16. package/scripts/lib/revisions_writer.rb +69 -0
  17. package/scripts/lib/worktree.rb +14 -32
  18. package/scripts/lib/worktree_sweep.rb +129 -0
  19. package/scripts/maintenance-run +236 -0
  20. package/scripts/plastic-lock +76 -9
  21. package/scripts/project-links +127 -24
  22. package/scripts/rebuild-graph +37 -3
  23. package/scripts/restore-intent-v1 +37 -3
  24. package/scripts/sweep-store-worktrees +53 -0
  25. package/skills/auto/SKILL.md +29 -6
  26. package/skills/auto/references/agent-architecture.md +7 -0
  27. package/skills/auto/references/end-tail.md +8 -6
  28. package/skills/dashboard/SKILL.md +48 -25
  29. package/skills/dashboard/evals/evals.json +4 -4
  30. package/skills/dashboard/templates/dashboard-global.md +3 -5
  31. package/skills/dashboard/templates/dashboard-project.md +6 -18
  32. package/skills/doctor/SKILL.md +6 -0
  33. package/skills/intent-locking/SKILL.md +20 -2
  34. package/skills/intent-starting/SKILL.md +6 -4
  35. package/skills/project-continuing/SKILL.md +10 -0
  36. package/skills/project-continuing/evals/evals.json +3 -3
  37. package/skills/project-continuing/references/board-fill.md +13 -11
  38. package/skills/releasing/SKILL.md +3 -3
  39. package/skills/store-curating/SKILL.md +9 -0
  40. package/skills/store-curating/evals/evals.json +16 -0
  41. package/skills/tutorial/SKILL.md +4 -4
  42. package/skills/tutorial/references/track-1-guided.md +2 -1
  43. package/skills/tutorial/references/track-2-auto.md +2 -1
  44. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -1
@@ -0,0 +1,94 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "open3"
5
+
6
+ # MaintenanceGit - git isolation for a maintenance action (intent 197, D12/D13): a fresh
7
+ # branch off the CURRENT tip of `base`, the caller's block runs, only the paths the block
8
+ # ACTUALLY changed are staged (never `git add -A`), committed, and the branch is merged back
9
+ # to `base` as part of the SAME closed operation before this method returns. Nothing strands
10
+ # on an unmerged branch; nothing outside the block's own change is ever touched.
11
+ #
12
+ # Requires a CLEAN working tree before starting (see module doc above for why); refuses
13
+ # loudly rather than attempt to distinguish pre-existing dirt from the block's own changes.
14
+ # On any error inside the block, the working tree is hard-reset and returned to `base` before
15
+ # re-raising, which is SAFE only because the precondition already proved nothing else was
16
+ # dirty when the branch was created.
17
+ module MaintenanceGit
18
+ module_function
19
+
20
+ class NotAGitRepo < StandardError; end
21
+ class DirtyWorkingTree < StandardError; end
22
+
23
+ def git_toplevel(dir)
24
+ out, _err, status = Open3.capture3("git", "-C", dir, "rev-parse", "--show-toplevel")
25
+ return nil unless status.success?
26
+
27
+ top = out.strip
28
+ top.empty? ? nil : top
29
+ end
30
+
31
+ # Bare paths (status prefix stripped), relative to `root`. Empty array on a clean tree.
32
+ def porcelain_paths(root)
33
+ out, _err, status = Open3.capture3("git", "-C", root, "status", "--porcelain")
34
+ return [] unless status.success?
35
+
36
+ out.lines.map { |l| l[3..].to_s.strip }.reject(&:empty?)
37
+ end
38
+
39
+ # Runs `block` inside a fresh branch off `base`'s current tip. Returns
40
+ # { changed: [...], committed: bool, merged: bool, branch: name }.
41
+ def run_scoped(repo_dir:, branch_name:, commit_message:, base: "main")
42
+ root = git_toplevel(repo_dir)
43
+ raise NotAGitRepo, "#{repo_dir} is not inside a git repository" unless root
44
+
45
+ dirty = porcelain_paths(root)
46
+ unless dirty.empty?
47
+ raise DirtyWorkingTree,
48
+ "#{root} has #{dirty.size} uncommitted path(s) before maintenance started; " \
49
+ "commit or stash them first (never swept via git add -A): #{dirty.join(", ")}"
50
+ end
51
+
52
+ checkout!(root, base)
53
+ run_git!(root, "checkout", "--quiet", "-b", branch_name)
54
+
55
+ begin
56
+ yield
57
+ rescue StandardError
58
+ run_git!(root, "reset", "--hard", "--quiet")
59
+ run_git!(root, "clean", "-fd", "--quiet")
60
+ checkout!(root, base)
61
+ delete_branch(root, branch_name)
62
+ raise
63
+ end
64
+
65
+ changed = porcelain_paths(root)
66
+ if changed.empty?
67
+ checkout!(root, base)
68
+ delete_branch(root, branch_name)
69
+ return { changed: [], committed: false, merged: false, branch: branch_name }
70
+ end
71
+
72
+ run_git!(root, "add", "--", *changed)
73
+ run_git!(root, "-c", "user.name=Plastic", "-c", "user.email=plastic@localhost",
74
+ "commit", "--quiet", "-m", commit_message)
75
+ checkout!(root, base)
76
+ run_git!(root, "merge", "--quiet", "--no-ff", "-m", "Merge #{branch_name} into #{base}", branch_name)
77
+ delete_branch(root, branch_name)
78
+
79
+ { changed: changed, committed: true, merged: true, branch: branch_name }
80
+ end
81
+
82
+ def checkout!(root, ref)
83
+ run_git!(root, "checkout", "--quiet", ref)
84
+ end
85
+
86
+ def delete_branch(root, name)
87
+ Open3.capture3("git", "-C", root, "branch", "--quiet", "-D", name)
88
+ end
89
+
90
+ def run_git!(root, *args)
91
+ _out, err, status = Open3.capture3("git", "-C", root, *args)
92
+ raise "git #{args.join(" ")} failed in #{root}: #{err}" unless status.success?
93
+ end
94
+ end
@@ -0,0 +1,69 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # RevisionsWriter - the shared append-only revisions.md writer (intent 107's convention,
5
+ # generalized from restore_intent_v1.rb's proven pattern, intent 197). Every tool that
6
+ # performs structural maintenance on an intent (project-links, rebuild-graph,
7
+ # restore-intent-v1) must record it here: PLASTIC.md's `revisions.md` contract is that a
8
+ # structural change and its receipt are never separated. This module owns rendering ONE
9
+ # entry's text and appending it correctly; it does no git operations (that is
10
+ # lib/maintenance_git.rb's job) and never overwrites a prior entry.
11
+ #
12
+ # Pure where it can be (render_entry has no IO); the IO half (append!) is a thin,
13
+ # dependency-free file read/write, matching every other tool in scripts/lib.
14
+ module RevisionsWriter
15
+ module_function
16
+
17
+ # PURE. Renders one `## Revision vN - TIMESTAMP` entry in the documented shape
18
+ # (PLASTIC-reference.md > Structural maintenance and revisions.md; templates/revisions.md).
19
+ # `n` is the next revision number (caller resolves it via next_revision_number, or a caller
20
+ # that already knows it, e.g. a batch writer amortizing one file read across many entries).
21
+ # `why` is the one-sentence reason ending in "[rule: <tag>]" (tag is appended here if the
22
+ # caller passes a bare sentence plus `rule:`, so every caller cannot forget the tag).
23
+ # `prior_location` and `change` are free text (the "Change:" line for a metadata edit, or a
24
+ # multi-line indented "Content held:" block for a relocated section/file - callers building
25
+ # a Content-held entry should pass an already-indented `change` block).
26
+ def render_entry(n, why:, rule:, prior_location:, change:, timestamp: Time.now.utc)
27
+ ts = timestamp.strftime("%Y-%m-%d-%H:%M")
28
+ lines = []
29
+ lines << "## Revision v#{n} - #{ts}"
30
+ lines << "- Why: #{why.to_s.strip} [rule: #{rule}]"
31
+ lines << "- Prior location: #{prior_location}"
32
+ lines << "- Change: #{change}"
33
+ "#{lines.join("\n")}\n"
34
+ end
35
+
36
+ # PURE. Every existing "## Revision vN" number found in `existing_text` (empty array when
37
+ # none, i.e. the file does not exist yet or carries no entries). Mirrors
38
+ # scripts/restore-intent-v1's own `nums = existing.scan(/^## Revision v(\d+)/)` exactly, so
39
+ # the two writers can never disagree about numbering.
40
+ def revision_numbers(existing_text)
41
+ existing_text.to_s.scan(/^## Revision v(\d+)/).flatten.map(&:to_i)
42
+ end
43
+
44
+ def next_revision_number(existing_text)
45
+ (revision_numbers(existing_text).max || 0) + 1
46
+ end
47
+
48
+ # IO. Appends one entry to `<intent_dir>/revisions.md`, creating the file with its
49
+ # documented header (matching templates/revisions.md's "# revisions.md" title line) if it
50
+ # does not exist yet. NEVER overwrites or reorders a prior entry (append-only, intent 124's
51
+ # own v3-corrects-v2-by-appending precedent). Returns the revision number written.
52
+ #
53
+ # Raises RevisionsWriter::WriteFailed on any IO error (permission, disk full, read-only
54
+ # filesystem) so a caller can roll back a paired structural change rather than leave it
55
+ # unrecorded (D14's "or refuse"). Never swallows an error silently.
56
+ def append!(intent_dir, why:, rule:, prior_location:, change:, timestamp: Time.now.utc)
57
+ path = File.join(intent_dir, "revisions.md")
58
+ existing = File.exist?(path) ? File.read(path) : "# revisions.md\n\n"
59
+ n = next_revision_number(existing)
60
+ entry = render_entry(n, why: why, rule: rule, prior_location: prior_location,
61
+ change: change, timestamp: timestamp)
62
+ File.write(path, "#{existing.chomp}\n\n#{entry}")
63
+ n
64
+ rescue StandardError => e
65
+ raise WriteFailed, "could not append revisions.md at #{path}: #{e.message}"
66
+ end
67
+
68
+ class WriteFailed < StandardError; end
69
+ end
@@ -18,9 +18,11 @@ require_relative "lock"
18
18
  # projects.yml and runs `git -C <repo> worktree add`, so the cwd-not-root bug
19
19
  # dies by construction (decision D6).
20
20
  #
21
- # Two worktrees per project intent, both named `{id}--{slug}` (decision D2):
22
- # code worktree <repo>/.claude/worktrees/{id}--{slug} branch plastic/{id}--{slug}
23
- # store worktree <plastic_home>/.worktrees/{id}--{slug} branch plastic-store/{id}--{slug}
21
+ # One worktree per project intent (decision D2, retired to a single worktree by
22
+ # intent 178): the code worktree, <repo>/.claude/worktrees/{id}--{slug}, branch
23
+ # plastic/{id}--{slug}. Store-write safety for lifecycle-doc writes now comes
24
+ # from intent 197's branch-from-main plus scoped-commit mechanism instead of a
25
+ # second, dedicated worktree (see PLASTIC.md's worktree doctrine).
24
26
  #
25
27
  # The durable delivery.lock file in the intent dir is the single-owner
26
28
  # delivery lock (intent 108): session-keyed, lease-based, explicit takeover.
@@ -60,22 +62,21 @@ module Worktree
60
62
  "#{intent_id}--#{intent_slug}"
61
63
  end
62
64
 
63
- # Pure, deterministic. Returns the four paths/branches. No git calls.
65
+ # Pure, deterministic. Returns the code worktree's path/branch. No git calls.
64
66
  # `repo_path` is resolved from projects.yml when nil; when it cannot be
65
67
  # resolved the code worktree path/branch are nil (a global-store-only intent).
68
+ # Store-worktree provisioning was retired by intent 178: this used to also
69
+ # return a `store`/`store_branch` pair for a second worktree under
70
+ # `<plastic_home>/.worktrees/{id}--{slug}`; nothing provisions that anymore.
66
71
  def paths(slug:, intent_id:, intent_slug:, home: Dir.home, repo_path: nil)
67
72
  name = dir_name(intent_id, intent_slug)
68
73
  repo = repo_path || repo_for(slug, home: home)
69
- plastic_home = File.expand_path(File.join(home, ".plastic"))
70
74
 
71
75
  code_path = repo ? File.join(File.expand_path(repo), ".claude", "worktrees", name) : nil
72
- store_path = File.join(plastic_home, ".worktrees", name)
73
76
 
74
77
  {
75
78
  "code" => code_path,
76
79
  "code_branch" => code_path ? "plastic/#{name}" : nil,
77
- "store" => store_path,
78
- "store_branch" => "plastic-store/#{name}",
79
80
  }
80
81
  end
81
82
 
@@ -133,28 +134,16 @@ module Worktree
133
134
  block = {
134
135
  "code" => nil,
135
136
  "code_branch" => nil,
136
- "store" => p["store"],
137
- "store_branch" => p["store_branch"],
138
137
  "provisioned" => false,
139
138
  }
140
139
 
141
140
  plastic_home = File.expand_path(File.join(home, ".plastic"))
142
141
 
143
- # Gitignore safety (intent 73c3): the store worktrees live under the store git
144
- # repo, so without ignoring `.worktrees/` a `git add -A` sweeps their gitlinks
145
- # into the store commit. Ensure both ignore entries before any worktree add.
146
- ensure_gitignored(plastic_home, ".worktrees/", runner: runner)
147
-
148
142
  # The durable lock files live inside intent dirs under the store git repo
149
- # (intent 108, D2): transient state, never committed.
143
+ # (intent 108, D2): transient state, never committed. Unrelated to store
144
+ # worktrees (retired by intent 178); this stays regardless.
150
145
  ensure_gitignored(plastic_home, "*.lock", runner: runner)
151
146
 
152
- # Store worktree: created against the plastic home git repo. Fail-open if the
153
- # store repo is not a git repo (a fresh global store may be ungit'd).
154
- store_ok = add_worktree(runner, repo: plastic_home,
155
- worktree: p["store"], branch: p["store_branch"],
156
- label: "store")
157
-
158
147
  # Code worktree: MANDATORY for project intents. Fail-open when the repo is
159
148
  # unresolvable or non-git -- that is the global-store-only / non-git case.
160
149
  repo = repo_for(slug, home: home)
@@ -173,9 +162,6 @@ module Worktree
173
162
  "is unresolvable or not a git repo; code worktree skipped"
174
163
  end
175
164
 
176
- block["store"] = store_ok ? p["store"] : nil
177
- block["store_branch"] = store_ok ? p["store_branch"] : nil
178
-
179
165
  # provisioned is true only when the MANDATORY code worktree exists. The gate
180
166
  # fails open on provisioned: false (non-git / global-only).
181
167
  block["provisioned"] = code_ok
@@ -184,26 +170,22 @@ module Worktree
184
170
  bridge_data
185
171
  end
186
172
 
187
- # Remove both worktrees (then `git worktree prune`), clear the worktree block.
173
+ # Remove the worktree (then `git worktree prune`), clear the worktree block.
188
174
  # No-op when nothing was provisioned. CLEANUP (73c3) layers the merge-vs-remove
189
175
  # policy on top via `finish`; this is the plain remove. Pass `remove: false` to
190
176
  # clear the block WITHOUT touching git (so `finish` can merge first, then call
191
- # release to drop the worktrees once the code branch is integrated).
177
+ # release to drop the worktree once the code branch is integrated).
192
178
  def release(bridge_data, home: Dir.home, runner: ShellRunner.new, remove: true)
193
179
  return bridge_data unless bridge_data.is_a?(Hash)
194
180
  block = bridge_data["worktree"]
195
181
  return bridge_data unless block.is_a?(Hash)
196
182
 
197
183
  if remove
198
- plastic_home = File.expand_path(File.join(home, ".plastic"))
199
184
  slug = slug_for_store(bridge_data.dig("intent", "store").to_s, home: home)
200
185
  repo = repo_for(slug, home: home)
201
186
 
202
187
  remove_worktree(runner, repo: repo, worktree: block["code"]) if repo && block["code"]
203
- remove_worktree(runner, repo: plastic_home, worktree: block["store"]) if block["store"]
204
-
205
188
  prune(runner, repo: repo) if repo
206
- prune(runner, repo: plastic_home)
207
189
  end
208
190
 
209
191
  bridge_data.delete("worktree")
@@ -212,7 +194,7 @@ module Worktree
212
194
 
213
195
  # --- cleanup policy (merge-vs-remove) -------------------------------------
214
196
 
215
- # Finish an intent's delivery by tearing down its worktrees, optionally merging
197
+ # Finish an intent's delivery by tearing down its worktree, optionally merging
216
198
  # the code branch back first (intent 73c3). The merge-vs-remove decision is the
217
199
  # one piece of policy on top of the plain `release`:
218
200
  #
@@ -0,0 +1,129 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "worktree"
5
+ require_relative "bridge"
6
+
7
+ # WorktreeSweep -- the one-time orphan sweep for store worktrees retired by
8
+ # intent 178 (D2). Pure candidate classification plus a thin apply step, both
9
+ # dependency-injected (a ShellRunner, an explicit plastic_home) so the whole
10
+ # module is hermetically testable: no call here ever falls back to the real
11
+ # `Dir.home` (the intent 169 hermeticity lesson applies here too).
12
+ #
13
+ # A candidate is REMOVE iff its intent is terminal (Completed or Abandoned in
14
+ # SOME store's INDEX.md) AND its branch carries no commits ahead of that
15
+ # store's main. Any ambiguity -- no matching intent directory found in any
16
+ # store, an unreadable INDEX, or a branch that cannot be resolved -- SPARES
17
+ # the candidate rather than removing it. This module never shells out for
18
+ # `worktree remove`; that only happens in `apply!`, and only for candidates
19
+ # already marked REMOVE.
20
+ module WorktreeSweep
21
+ module_function
22
+
23
+ Candidate = Struct.new(:name, :dir, :branch, :intent_dir, :status, :ahead_count,
24
+ :decision, :reason, keyword_init: true)
25
+
26
+ TERMINAL_STATUSES = %w[Completed Abandoned].freeze
27
+
28
+ # All worktree dirs under `<plastic_home>/.worktrees/`, classified. `runner`
29
+ # is injected (default a real ShellRunner) so tests never shell out.
30
+ def candidates(plastic_home:, runner: Worktree::ShellRunner.new)
31
+ Dir.glob(File.join(plastic_home, ".worktrees", "*"))
32
+ .select { |d| File.directory?(d) }
33
+ .sort
34
+ .map { |dir| classify(dir, plastic_home: plastic_home, runner: runner) }
35
+ end
36
+
37
+ def classify(dir, plastic_home:, runner:)
38
+ name = File.basename(dir)
39
+ branch = "plastic-store/#{name}"
40
+ intent_dir = resolve_intent_dir(plastic_home, name)
41
+ status = intent_dir ? index_status(intent_dir, name) : nil
42
+ ahead = intent_dir ? branch_ahead_count(runner, plastic_home, branch) : nil
43
+
44
+ terminal = TERMINAL_STATUSES.include?(status)
45
+ ahead_or_unknown = ahead.nil? || ahead > 0
46
+ decision = (terminal && !ahead_or_unknown) ? :remove : :spare
47
+
48
+ Candidate.new(name: name, dir: dir, branch: branch, intent_dir: intent_dir,
49
+ status: status, ahead_count: ahead, decision: decision,
50
+ reason: reason_for(intent_dir, status, ahead, terminal))
51
+ end
52
+
53
+ # The FIRST real intent directory literally named `{id}--{slug}` (the exact
54
+ # worktree dir name) found under the global store or any project store.
55
+ # Matching on the full name, not just the numeric id, sidesteps id
56
+ # collisions across projects (ids are only unique WITHIN one store).
57
+ def resolve_intent_dir(plastic_home, name)
58
+ candidates = [File.join(plastic_home, "store", name)] +
59
+ Dir.glob(File.join(plastic_home, "projects", "*", "store", name))
60
+ candidates.find { |d| Dir.exist?(d) }
61
+ end
62
+
63
+ # The INDEX.md section heading (e.g. "Active", "Completed") that lists this
64
+ # intent dir, or nil if no INDEX.md entry links to it. Reuses
65
+ # Bridge.index_entry_match so this never drifts from the shared parser.
66
+ def index_status(intent_dir, name)
67
+ store = File.dirname(intent_dir)
68
+ index = File.join(File.dirname(store), "INDEX.md")
69
+ return nil unless File.exist?(index)
70
+
71
+ section = nil
72
+ File.foreach(index) do |line|
73
+ stripped = line.chomp
74
+ if stripped.start_with?("## ")
75
+ section = stripped.sub(/\A##\s*/, "").strip
76
+ next
77
+ end
78
+ m = Bridge.index_entry_match(stripped)
79
+ next unless m
80
+ return section if m[3].to_s.include?(name)
81
+ end
82
+ nil
83
+ end
84
+
85
+ # Commits reachable from `branch` but not from the repo's own current
86
+ # (default) branch, or nil if either cannot be resolved. nil is treated as
87
+ # "ahead or unknown" by `classify` (fail SPARE, never fail REMOVE).
88
+ def branch_ahead_count(runner, plastic_home, branch)
89
+ target = Worktree.current_branch(runner, repo: plastic_home)
90
+ return nil if target.nil? || target == branch
91
+ res = runner.run("-C", plastic_home, "rev-list", "--count", "#{target}..#{branch}")
92
+ return nil unless res.success?
93
+ res.stdout.to_s.strip.to_i
94
+ end
95
+
96
+ def reason_for(intent_dir, status, ahead, terminal)
97
+ return "no matching intent directory found in any store (orphaned/ambiguous reference)" unless intent_dir
98
+ return "ahead of main by #{ahead} commit(s); sparing to avoid stranding unmerged work" if ahead.nil? || ahead > 0
99
+ return "intent status is #{status.inspect}, not terminal; sparing" unless terminal
100
+ "terminal (#{status}) and not ahead of main"
101
+ end
102
+
103
+ # A stable, human-reviewable dry-run report. Every candidate is listed,
104
+ # remove and spare alike, with its full reasoning -- this is the artifact
105
+ # the owner reviews BEFORE anything is deleted (D2, AC5).
106
+ def dry_run_report(candidates, now: Time.now)
107
+ lines = ["Store-worktree sweep dry run -- #{now.utc.iso8601}", ""]
108
+ candidates.each do |c|
109
+ tag = c.decision == :remove ? "REMOVE" : "SPARE "
110
+ lines << "#{tag} #{c.name} status=#{c.status.inspect} ahead=#{c.ahead_count.inspect} #{c.reason}"
111
+ end
112
+ remove_n = candidates.count { |c| c.decision == :remove }
113
+ lines << ""
114
+ lines << "#{remove_n} of #{candidates.length} candidate(s) eligible for removal. " \
115
+ "Nothing has been removed yet; re-run with --apply after review."
116
+ lines.join("\n")
117
+ end
118
+
119
+ # Removes only :remove-decision candidates, via `git worktree remove` (falls
120
+ # back to --force only inside Worktree.remove_worktree's own existing
121
+ # retry), then `git worktree prune` once at the end. Returns the list of
122
+ # candidates actually removed. Never called for a :spare candidate.
123
+ def apply!(candidates, plastic_home:, runner: Worktree::ShellRunner.new)
124
+ removed = candidates.select { |c| c.decision == :remove }
125
+ removed.each { |c| Worktree.remove_worktree(runner, repo: plastic_home, worktree: c.dir) }
126
+ Worktree.prune(runner, repo: plastic_home) unless removed.empty?
127
+ removed
128
+ end
129
+ end
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # maintenance-run - the maintenance dispatch wrapper (intent 197). DETECTS (never acquires) a
6
+ # target intent's delivery lock (Lock.fresh?) and defers if fresh; otherwise runs the
7
+ # requested tool inside MaintenanceGit.run_scoped so the change and its revisions.md receipt
8
+ # land as ONE scoped, merged commit on the store's own main, never via `git add -A`.
9
+ #
10
+ # Dry-run by DEFAULT (mirrors restore-intent-v1's higher-blast-radius default, since this
11
+ # tool commits and merges on the shared store repo); --apply is required to write anything.
12
+ #
13
+ # Usage:
14
+ # maintenance-run --tool project-links --intent <id> [--store <key>] [--plastic-home PATH] [--apply]
15
+ # maintenance-run --tool rebuild-graph [--plastic-home PATH] [--apply]
16
+ # maintenance-run --tool restore-intent-v1 <id> --at <ref> [--plastic-home PATH] [--apply] [--skip-links]
17
+ #
18
+ # project-links here is ALWAYS single-intent: --intent is required. A store-wide
19
+ # project-links sweep is the rare, owner-approved batch exception (D2) and is run directly
20
+ # with the plain `project-links` tool, never through this wrapper.
21
+ #
22
+ # --store <key> (a StoreDiscovery key, e.g. "global" or "project:dealintell") disambiguates
23
+ # a bare id that exists in more than one store (real, live examples: ids 26 and 15 both
24
+ # collide across stores today) - both project-links --intent and restore-intent-v1's own id
25
+ # resolution abort loud naming every candidate when ambiguous and --store is not given;
26
+ # never silently pick the first match.
27
+ #
28
+ # Exit codes: 0 applied or clean no-op; 1 usage error; 2 deferred (a target holds a fresh
29
+ # delivery lock); 3 the underlying tool reported failure; 4 precondition failed (the store
30
+ # working tree was not clean, or is not a git repo at all).
31
+
32
+ require "open3"
33
+ require "time"
34
+
35
+ require_relative "lib/store_discovery"
36
+ require_relative "lib/lock"
37
+ require_relative "lib/maintenance_git"
38
+
39
+ DEFAULT_HOME = File.join(Dir.home, ".plastic")
40
+
41
+ # Resolves `id` to exactly one directory. `store:` (a StoreDiscovery key) short-circuits
42
+ # resolution to that one store. Without it, more than one matching store is an ambiguity
43
+ # this method itself aborts on (never silently pick the first match - the same class of bug
44
+ # ACTION_1 fixes inside project-links itself; this helper guards every OTHER caller of
45
+ # resolve_dir_for_id, i.e. rebuild-graph's touched-id lock scan and restore-intent-v1's id
46
+ # resolution, both of which route through this one function).
47
+ def resolve_dir_for_id(discovery, id, store: nil)
48
+ matches = []
49
+ discovery[:stores].each do |s|
50
+ next if store && s[:key] != store
51
+
52
+ Dir.children(s[:store]).reject { |e| e.start_with?(".") }.each do |entry|
53
+ full = File.join(s[:store], entry)
54
+ next unless File.directory?(full)
55
+
56
+ matches << [s[:key], full] if entry.split("--", 2).first == id.to_s
57
+ end
58
+ end
59
+
60
+ return nil if matches.empty?
61
+ if matches.length > 1
62
+ abort_loud("intent #{id.inspect} is ambiguous across stores " \
63
+ "(#{matches.map(&:first).join(", ")}); pass --store <key> to disambiguate")
64
+ end
65
+ matches.first.last
66
+ end
67
+
68
+ def abort_loud(msg, code = 1)
69
+ warn "maintenance-run: #{msg}"
70
+ exit code
71
+ end
72
+
73
+ def parse_argv(argv)
74
+ opts = { tool: nil, intent: nil, store: nil, plastic_home: DEFAULT_HOME, apply: false,
75
+ at: nil, skip_links: false, id: nil }
76
+ i = 0
77
+ while i < argv.length
78
+ case argv[i]
79
+ when "--tool" then opts[:tool] = argv[i += 1]
80
+ when "--intent" then opts[:intent] = argv[i += 1]
81
+ when "--store" then opts[:store] = argv[i += 1]
82
+ when "--plastic-home" then opts[:plastic_home] = argv[i += 1]
83
+ when "--apply" then opts[:apply] = true
84
+ when "--at" then opts[:at] = argv[i += 1]
85
+ when "--skip-links" then opts[:skip_links] = true
86
+ else
87
+ opts[:id] ||= argv[i] # positional id, restore-intent-v1 only
88
+ end
89
+ i += 1
90
+ end
91
+ opts
92
+ end
93
+
94
+ def check_not_fresh!(dir, id)
95
+ return unless dir && Lock.fresh?(dir)
96
+
97
+ abort_loud("deferred: intent #{id} holds a FRESH delivery lock; an active delivery is in " \
98
+ "progress. Maintenance never acquires a lock and never waits on one; re-run " \
99
+ "once the delivery finishes or the lock goes stale.", 2)
100
+ end
101
+
102
+ def stamp
103
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
104
+ end
105
+
106
+ def report_result(result)
107
+ if result[:committed]
108
+ puts "maintenance-run: applied and merged (#{result[:changed].size} path(s)): " \
109
+ "#{result[:changed].join(", ")}"
110
+ else
111
+ puts "maintenance-run: no change (already canonical)."
112
+ end
113
+ exit 0
114
+ end
115
+
116
+ def run_project_links(home, intent, store, apply)
117
+ abort_loud("--tool project-links requires --intent <id> (a store-wide sweep runs " \
118
+ "scripts/project-links directly, never through maintenance-run)") unless intent
119
+
120
+ discovery = StoreDiscovery.discover(home)
121
+ dir = resolve_dir_for_id(discovery, intent, store: store) # aborts loud itself on ambiguity
122
+ abort_loud("intent #{intent} not found under #{home}#{store ? " (--store #{store})" : ""}") unless dir
123
+ check_not_fresh!(dir, intent)
124
+
125
+ tool_path = File.expand_path("project-links", __dir__)
126
+ base_args = ["--plastic-home", home, "--intent", intent]
127
+ base_args += ["--store", store] if store
128
+
129
+ unless apply
130
+ system(RbConfig.ruby, tool_path, *base_args, "--dry-run")
131
+ exit($?.exitstatus)
132
+ end
133
+
134
+ begin
135
+ result = MaintenanceGit.run_scoped(
136
+ repo_dir: home, branch_name: "maintenance/project-links-#{intent}-#{stamp}",
137
+ commit_message: "chore: maintenance - project-links --intent #{intent}"
138
+ ) do
139
+ ok = system(RbConfig.ruby, tool_path, *base_args)
140
+ raise "project-links failed for #{intent}" unless ok
141
+ end
142
+ rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
143
+ abort_loud(e.message, 4)
144
+ rescue RuntimeError => e
145
+ abort_loud(e.message, 3)
146
+ end
147
+ report_result(result)
148
+ end
149
+
150
+ def run_rebuild_graph(home, apply)
151
+ tool_path = File.expand_path("rebuild-graph", __dir__)
152
+ load tool_path unless defined?(RebuildGraph) # matches test/*_test.rb's own load convention
153
+
154
+ discovery = StoreDiscovery.discover(home)
155
+ dry = RebuildGraph.new(plastic_home: home, dry_run: true)
156
+ dry_results = dry.run
157
+ touched_ids = dry_results.values.flat_map { |r| r[:changes].map { |c| c[:intent] } }.uniq
158
+
159
+ touched_ids.each { |id| check_not_fresh!(resolve_dir_for_id(discovery, id), id) }
160
+
161
+ unless apply
162
+ puts "maintenance-run: DRY RUN, #{touched_ids.size} intent(s) would change: #{touched_ids.join(", ")}"
163
+ exit 0
164
+ end
165
+
166
+ begin
167
+ result = MaintenanceGit.run_scoped(
168
+ repo_dir: home, branch_name: "maintenance/rebuild-graph-#{stamp}",
169
+ commit_message: "chore: maintenance - rebuild-graph"
170
+ ) do
171
+ ok = system(RbConfig.ruby, tool_path, "--plastic-home", home)
172
+ raise "rebuild-graph failed" unless ok
173
+ end
174
+ rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
175
+ abort_loud(e.message, 4)
176
+ rescue RuntimeError => e
177
+ abort_loud(e.message, 3)
178
+ end
179
+ report_result(result)
180
+ end
181
+
182
+ def run_restore_intent_v1(home, id, at, store, apply, skip_links)
183
+ abort_loud("restore-intent-v1 requires an intent id and --at <ref>") unless id && at
184
+
185
+ discovery = StoreDiscovery.discover(home)
186
+ # NOTE: restore-intent-v1's own CLI (scripts/restore-intent-v1:219-238, find_intent_dir)
187
+ # already aborts loud on a cross-store id collision; this resolve_dir_for_id call is only
188
+ # for the LOCK CHECK here (maintenance-run must know which one directory to check
189
+ # Lock.fresh? against). --store is honored the same way for consistency; if omitted and
190
+ # the id is ambiguous, this call aborts BEFORE restore-intent-v1 itself would have run.
191
+ dir = resolve_dir_for_id(discovery, id, store: store)
192
+ abort_loud("intent #{id} not found under #{home}#{store ? " (--store #{store})" : ""}") unless dir
193
+ check_not_fresh!(dir, id)
194
+
195
+ tool_path = File.expand_path("restore-intent-v1", __dir__)
196
+ args = [tool_path, id, "--at", at, "--plastic-home", home]
197
+ args << "--skip-links" if skip_links
198
+
199
+ unless apply
200
+ system(RbConfig.ruby, *args)
201
+ exit($?.exitstatus)
202
+ end
203
+
204
+ begin
205
+ result = MaintenanceGit.run_scoped(
206
+ repo_dir: home, branch_name: "maintenance/restore-intent-v1-#{id}-#{stamp}",
207
+ commit_message: "chore: maintenance - restore-intent-v1 #{id}"
208
+ ) do
209
+ ok = system(RbConfig.ruby, *args, "--apply")
210
+ raise "restore-intent-v1 failed for #{id}" unless ok
211
+ end
212
+ rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
213
+ abort_loud(e.message, 4)
214
+ rescue RuntimeError => e
215
+ abort_loud(e.message, 3)
216
+ end
217
+ report_result(result)
218
+ end
219
+
220
+ def main(argv)
221
+ opts = parse_argv(argv)
222
+ abort_loud("--tool is required (project-links|rebuild-graph|restore-intent-v1)") unless opts[:tool]
223
+
224
+ case opts[:tool]
225
+ when "project-links"
226
+ run_project_links(opts[:plastic_home], opts[:intent], opts[:store], opts[:apply])
227
+ when "rebuild-graph" then run_rebuild_graph(opts[:plastic_home], opts[:apply])
228
+ when "restore-intent-v1"
229
+ run_restore_intent_v1(opts[:plastic_home], opts[:id], opts[:at], opts[:store], opts[:apply], opts[:skip_links])
230
+ else
231
+ abort_loud("unknown --tool #{opts[:tool].inspect} " \
232
+ "(expected project-links|rebuild-graph|restore-intent-v1)")
233
+ end
234
+ end
235
+
236
+ main(ARGV) if $PROGRAM_NAME == __FILE__