@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.
- package/PLASTIC-reference.md +2 -0
- package/PLASTIC.md +160 -42
- package/agents/plastic-intent-curator.md +10 -2
- package/package.json +1 -1
- package/scripts/codex-hook +122 -8
- package/scripts/dashboard.rb +323 -71
- package/scripts/doctor.rb +271 -15
- package/scripts/end-intent +32 -7
- package/scripts/hook-lock-gate +8 -3
- package/scripts/install.rb +51 -6
- package/scripts/lib/bridge.rb +79 -30
- package/scripts/lib/hook_registry.rb +44 -2
- package/scripts/lib/installer_core.rb +45 -6
- package/scripts/lib/lock.rb +186 -11
- package/scripts/lib/maintenance_git.rb +94 -0
- package/scripts/lib/revisions_writer.rb +69 -0
- package/scripts/lib/worktree.rb +14 -32
- package/scripts/lib/worktree_sweep.rb +129 -0
- package/scripts/maintenance-run +236 -0
- package/scripts/plastic-lock +76 -9
- package/scripts/project-links +127 -24
- package/scripts/rebuild-graph +37 -3
- package/scripts/restore-intent-v1 +37 -3
- package/scripts/sweep-store-worktrees +53 -0
- package/skills/auto/SKILL.md +29 -6
- package/skills/auto/references/agent-architecture.md +7 -0
- package/skills/auto/references/end-tail.md +8 -6
- package/skills/dashboard/SKILL.md +48 -25
- package/skills/dashboard/evals/evals.json +4 -4
- package/skills/dashboard/templates/dashboard-global.md +3 -5
- package/skills/dashboard/templates/dashboard-project.md +6 -18
- package/skills/doctor/SKILL.md +6 -0
- package/skills/intent-locking/SKILL.md +20 -2
- package/skills/intent-starting/SKILL.md +6 -4
- package/skills/project-continuing/SKILL.md +10 -0
- package/skills/project-continuing/evals/evals.json +3 -3
- package/skills/project-continuing/references/board-fill.md +13 -11
- package/skills/releasing/SKILL.md +3 -3
- package/skills/store-curating/SKILL.md +9 -0
- package/skills/store-curating/evals/evals.json +16 -0
- package/skills/tutorial/SKILL.md +4 -4
- package/skills/tutorial/references/track-1-guided.md +2 -1
- package/skills/tutorial/references/track-2-auto.md +2 -1
- 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
|
package/scripts/lib/worktree.rb
CHANGED
|
@@ -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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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__
|