@zalom/plastic 1.0.0-beta.8 → 1.0.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 (116) hide show
  1. package/PLASTIC-reference.md +133 -0
  2. package/PLASTIC.md +407 -84
  3. package/README.md +99 -82
  4. package/agents/plastic-brainstorming.md +12 -9
  5. package/agents/plastic-enforcer.md +52 -12
  6. package/agents/plastic-executor.md +10 -10
  7. package/agents/plastic-future-intent-researcher.md +4 -8
  8. package/agents/plastic-intent-curator.md +12 -14
  9. package/agents/plastic-intent-discovery.md +35 -0
  10. package/agents/plastic-planner.md +15 -10
  11. package/agents/plastic-spec-specialist.md +15 -10
  12. package/bin/plastic.js +7 -3
  13. package/hooks/code-gate +10 -1
  14. package/hooks/hooks.json +28 -3
  15. package/hooks/lock-gate +21 -0
  16. package/hooks/retrieval-gate +10 -0
  17. package/hooks/savepoint-pre +10 -0
  18. package/hooks/statusline +48 -6
  19. package/package.json +2 -1
  20. package/scripts/agent-report +21 -0
  21. package/scripts/dashboard.rb +98 -23
  22. package/scripts/doctor.rb +404 -5
  23. package/scripts/hook-auto-arm +1 -1
  24. package/scripts/hook-bash-gate +22 -4
  25. package/scripts/hook-code-gate +11 -6
  26. package/scripts/hook-continue +17 -0
  27. package/scripts/hook-create-gate +45 -9
  28. package/scripts/hook-gate-check +24 -23
  29. package/scripts/hook-lock-gate +83 -0
  30. package/scripts/hook-retrieval-gate +148 -0
  31. package/scripts/hook-savepoint-pre +32 -0
  32. package/scripts/hook-session-start +1 -1
  33. package/scripts/insight-append +51 -0
  34. package/scripts/install.rb +44 -7
  35. package/scripts/lib/agent_models.rb +43 -0
  36. package/scripts/lib/bridge.rb +924 -84
  37. package/scripts/lib/dashboard_banner.rb +42 -0
  38. package/scripts/lib/frontmatter_writer.rb +130 -0
  39. package/scripts/lib/graph_rebuild.rb +328 -0
  40. package/scripts/lib/hook_registry.rb +95 -0
  41. package/scripts/lib/insights.rb +86 -0
  42. package/scripts/lib/installer_core.rb +104 -60
  43. package/scripts/lib/link_suggestions.rb +319 -0
  44. package/scripts/lib/links_projection.rb +160 -0
  45. package/scripts/lib/links_section.rb +207 -0
  46. package/scripts/lib/lock.rb +375 -0
  47. package/scripts/lib/power_tools.rb +22 -21
  48. package/scripts/lib/preflight.rb +79 -0
  49. package/scripts/lib/qmd_sync.rb +15 -0
  50. package/scripts/lib/retrieval_gate.rb +211 -0
  51. package/scripts/lib/worktree.rb +384 -0
  52. package/scripts/link-suggest +213 -0
  53. package/scripts/new-intent +138 -29
  54. package/scripts/plastic-lock +164 -0
  55. package/scripts/project-links +287 -0
  56. package/scripts/read-config +4 -0
  57. package/scripts/rebuild-graph +244 -0
  58. package/scripts/spawn-preamble +11 -3
  59. package/scripts/update.rb +16 -7
  60. package/skills/auto/SKILL.md +126 -19
  61. package/skills/auto/references/agent-architecture.md +7 -4
  62. package/skills/auto/references/agent-report-contract.md +36 -1
  63. package/skills/auto/references/end-tail.md +56 -0
  64. package/skills/auto/references/human-report-contract.md +55 -0
  65. package/skills/auto/references/tiers.md +77 -0
  66. package/skills/brainstorming/SKILL.md +7 -34
  67. package/skills/brainstorming/references/design-principles.md +49 -0
  68. package/skills/continuing/SKILL.md +26 -7
  69. package/skills/creating-intent/SKILL.md +13 -28
  70. package/skills/creating-intent/references/lifecycle.md +9 -4
  71. package/skills/creating-project/SKILL.md +11 -74
  72. package/skills/creating-project/references/project-scaffolding.md +97 -0
  73. package/skills/creating-skills/SKILL.md +65 -0
  74. package/skills/creating-skills/evals/evals.json +108 -0
  75. package/skills/creating-skills/references/agents.md +168 -0
  76. package/skills/creating-skills/references/evals.md +41 -0
  77. package/skills/creating-skills/references/hooks.md +248 -0
  78. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  79. package/skills/creating-skills/references/scripts.md +166 -0
  80. package/skills/creating-skills/references/skills.md +165 -0
  81. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  82. package/skills/dashboard/SKILL.md +13 -11
  83. package/skills/dashboard/references/classification.md +22 -0
  84. package/skills/doctor/SKILL.md +10 -7
  85. package/skills/executing-plan/SKILL.md +4 -4
  86. package/skills/humanizer/SKILL.md +39 -0
  87. package/skills/humanizer/evals/evals.json +70 -0
  88. package/skills/humanizer/references/always-on-snippet.md +9 -0
  89. package/skills/humanizer/references/examples.md +48 -0
  90. package/skills/install/SKILL.md +75 -84
  91. package/skills/intent-curator/SKILL.md +5 -1
  92. package/skills/intent-discovery/SKILL.md +47 -0
  93. package/skills/intent-starting/SKILL.md +130 -0
  94. package/skills/intent-starting/evals/evals.json +117 -0
  95. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  96. package/skills/linking-intents/SKILL.md +48 -12
  97. package/skills/linking-intents/references/zettelkasten.md +7 -0
  98. package/skills/lock/SKILL.md +41 -0
  99. package/skills/managing-index/SKILL.md +6 -0
  100. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  101. package/skills/releasing/SKILL.md +44 -29
  102. package/skills/releasing/references/promotion-and-tagging.md +60 -0
  103. package/skills/roadmap/SKILL.md +51 -0
  104. package/skills/roadmap/references/file-format.md +91 -0
  105. package/skills/roadmap/references/operations.md +78 -0
  106. package/skills/uninstall/SKILL.md +29 -11
  107. package/skills/update/SKILL.md +34 -23
  108. package/skills/versions/SKILL.md +27 -12
  109. package/skills/writing-plans/SKILL.md +10 -88
  110. package/skills/writing-plans/references/plan-format.md +102 -0
  111. package/templates/config.yml +8 -0
  112. package/templates/outcome.md +3 -0
  113. package/templates/revisions.md +58 -0
  114. package/templates/roadmap.md +30 -0
  115. package/skills/writing-instructions/SKILL.md +0 -159
  116. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
@@ -0,0 +1,211 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "bridge"
5
+
6
+ # RetrievalGate — the single, pure decision for Lever 2 of intent 84, redesigned
7
+ # operation-based in intent 89a.
8
+ #
9
+ # Given an agent tool call (Bash/Read/Grep/Glob) and injected capability signals,
10
+ # it decides whether to BLOCK the call (returning a redirect-to-QMD reason String)
11
+ # or ALLOW it (returning nil). All capability/freshness signals are injected by the
12
+ # caller (the hook); this module shells out to nothing, reads no globals, and runs
13
+ # no binaries. Mirrors bridge.rb's decision-fn convention (reason String to block,
14
+ # nil to allow).
15
+ #
16
+ # Operation-based policy (intent 89, ## Redesign):
17
+ # - The gate distinguishes DISCOVERY (content search) from READING a known target.
18
+ # - Only CONTENT SEARCH over store markdown is hard-gated -> QMD.
19
+ # - Reading a known target (Read, cat/head/tail) and structural discovery (Glob,
20
+ # find, ls) are ALWAYS allowed, including over the store.
21
+ # - Code navigation is a soft prompt MANDATE (PowerTools / UserPromptSubmit), not a
22
+ # hard gate here. Content grep over code is allowed (Serena cannot grep strings).
23
+ #
24
+ # Content-search vectors (the only ones that can be gated):
25
+ # - the Grep tool (its `path` search root)
26
+ # - bash `grep`/`rg`/`ag` (their path args; the first bareword is the PATTERN)
27
+ #
28
+ # QMD enforcement is BINARY (no advisory tier):
29
+ # - store-md content search: QMD detected+fresh -> BLOCK; detected+stale -> fire
30
+ # reindex, ALLOW this turn; absent/broken -> ALLOW (the hook warns on broken).
31
+ #
32
+ # Bypass: a TRAILING `# qmd-ok` shell comment on a Bash command (not a substring; a
33
+ # quoted/echoed occurrence does not bypass). It is the auditable seam for "I tried
34
+ # discovery and it did not serve me" (empty, low, or wrongly-scored results).
35
+ #
36
+ # Scope: only the agent's own tool calls. Ruby `File.read` inside scripts is invisible
37
+ # to a PreToolUse hook and is explicitly out of scope (no exemptions).
38
+ module RetrievalGate
39
+ module_function
40
+
41
+ # A `# qmd-ok` token that is a real TRAILING shell comment, after stripping a
42
+ # trailing newline. The token must be preceded by whitespace (or start the
43
+ # command) and run to end-of-string. `echo "# qmd-ok"` does NOT match: the token
44
+ # there is followed by a closing quote, not end-of-string.
45
+ BYPASS_RE = /(?:\A|\s)#\s*qmd-ok\s*\z/.freeze
46
+
47
+ # Bash utilities that perform CONTENT SEARCH (scan file CONTENT for a pattern).
48
+ # These are the only bash read-vectors that can be gated; readers (cat/head/tail)
49
+ # and structural tools (find/ls) are never gated.
50
+ CONTENT_SEARCH_UTILS = %w[grep rg ag].freeze
51
+
52
+ # Decide. Returns nil to ALLOW, or a reason String to BLOCK.
53
+ # capabilities: { qmd:, qmd_fresh: } (booleans).
54
+ # reindex: no-arg callable fired once when a QMD-class target is STALE.
55
+ # When bypassed, returns nil and (if given) yields :bypass to the optional block
56
+ # so the caller can log it.
57
+ def decision(tool_name:, tool_input:, plastic_home:, cwd:,
58
+ capabilities:, reindex: -> {})
59
+ targets = extract_targets(tool_name, tool_input, cwd: cwd)
60
+ return nil if targets.empty?
61
+
62
+ if bypass?(tool_name, tool_input)
63
+ yield(:bypass) if block_given?
64
+ return nil
65
+ end
66
+
67
+ stale_seen = false
68
+ targets.each do |path|
69
+ next unless classify(path, plastic_home: plastic_home) == :qmd
70
+
71
+ if capabilities[:qmd] && capabilities[:qmd_fresh]
72
+ return qmd_reason(path)
73
+ elsif capabilities[:qmd] # present but stale
74
+ stale_seen = true
75
+ end
76
+ # absent/broken -> allow this target
77
+ end
78
+
79
+ reindex.call if stale_seen
80
+ nil
81
+ end
82
+
83
+ # --- classification ---
84
+
85
+ # Operation-based: the store tree is the only gated class (content search whose
86
+ # target is at/under a store routes to QMD). Everything else is allowed.
87
+ def classify(path, plastic_home:)
88
+ return :allow if path.nil? || path.empty?
89
+ store_path?(path, plastic_home: plastic_home) ? :qmd : :allow
90
+ end
91
+
92
+ # A path AT or UNDER the global store or a project store. We gate the whole store
93
+ # tree (not just `*.md`) because a content search root is usually a directory:
94
+ # grepping the store scans its markdown, which is exactly what QMD should serve.
95
+ def store_path?(path, plastic_home:)
96
+ abs = absolutize(path)
97
+ home = File.expand_path(plastic_home)
98
+ global = File.join(home, "store")
99
+ return true if abs == global || abs.start_with?("#{global}/")
100
+
101
+ projects = File.join(home, "projects")
102
+ return false unless abs.start_with?("#{projects}/")
103
+ tail = abs[(projects.length + 1)..].to_s.split(File::SEPARATOR)
104
+ tail.length >= 2 && tail[1] == "store"
105
+ end
106
+
107
+ def absolutize(path)
108
+ File.absolute_path?(path) ? path : File.expand_path(path)
109
+ end
110
+
111
+ # --- bypass ---
112
+
113
+ # Only Bash commands carry a trailing `# qmd-ok` comment. The token must be a real
114
+ # trailing comment (BYPASS_RE), so a quoted/echoed occurrence does not bypass.
115
+ def bypass?(tool_name, tool_input)
116
+ return false unless tool_name.to_s == "Bash"
117
+ cmd = tool_input.is_a?(Hash) ? tool_input["command"].to_s : ""
118
+ BYPASS_RE.match?(cmd.chomp)
119
+ end
120
+
121
+ # --- target extraction ---
122
+
123
+ # Paths a CONTENT-SEARCH operation scans. Reads (Read, cat/head/tail) and
124
+ # structural discovery (Glob, find, ls) are NOT content search -> no targets ->
125
+ # always allowed. Only the Grep tool and bash grep/rg/ag can be gated. Read
126
+ # vectors only (this is a READ gate); write vectors are bridge.rb's job.
127
+ def extract_targets(tool_name, tool_input, cwd:)
128
+ input = tool_input.is_a?(Hash) ? tool_input : {}
129
+ case tool_name.to_s
130
+ when "Grep"
131
+ # The search root is the target; the query text is not a path.
132
+ [input["path"]].compact.reject { |s| s.to_s.empty? }
133
+ when "Bash"
134
+ bash_search_targets(input["command"].to_s)
135
+ else
136
+ # Read, Glob, and every other tool: read / structural op -> never gated.
137
+ []
138
+ end
139
+ end
140
+
141
+ # CONTENT-SEARCH path args across a compound command. Conservative: missing an
142
+ # exotic form is fine; never flag /dev/null or pure pipes.
143
+ def bash_search_targets(command)
144
+ return [] unless command.is_a?(String) && !command.empty?
145
+ targets = []
146
+ command.split(/[;\n]|&&|\|\||\|/).each do |segment|
147
+ targets.concat(segment_search_targets(segment))
148
+ end
149
+ targets.reject { |t| t.nil? || t.empty? || dev_path?(t) }.uniq
150
+ end
151
+
152
+ def segment_search_targets(segment)
153
+ tokens = tokenize(segment)
154
+ return [] if tokens.empty?
155
+
156
+ # Skip leading env-style assignments (FOO=bar cmd ...).
157
+ idx = 0
158
+ idx += 1 while tokens[idx] && tokens[idx].include?("=") && tokens[idx] !~ /\A-/
159
+ util = File.basename(tokens[idx].to_s)
160
+ return [] unless CONTENT_SEARCH_UTILS.include?(util)
161
+
162
+ args = tokens[(idx + 1)..] || []
163
+ path_args_for(args)
164
+ end
165
+
166
+ # Collect path-shaped arguments for a content-search util. Flags are skipped; the
167
+ # first non-flag bareword is the PATTERN, not a path.
168
+ def path_args_for(args)
169
+ paths = []
170
+ pattern_consumed = false
171
+ args.each do |a|
172
+ next if a.start_with?("-")
173
+ unless pattern_consumed
174
+ pattern_consumed = true
175
+ next
176
+ end
177
+ paths << a
178
+ end
179
+ paths
180
+ end
181
+
182
+ # Minimal tokenizer: split on whitespace, strip surrounding matching quotes off
183
+ # each token. Good enough for the conservative read-vector parse.
184
+ def tokenize(segment)
185
+ segment.to_s.strip.split(/\s+/).map { |t| strip_quotes(t) }
186
+ end
187
+
188
+ def strip_quotes(token)
189
+ if (token.start_with?('"') && token.end_with?('"')) ||
190
+ (token.start_with?("'") && token.end_with?("'"))
191
+ token[1..-2].to_s
192
+ else
193
+ token
194
+ end
195
+ end
196
+
197
+ def dev_path?(path)
198
+ path == "/dev/null" || path.start_with?("/dev/")
199
+ end
200
+
201
+ # --- reasons ---
202
+
203
+ def qmd_reason(path)
204
+ "retrieval gate: search the store via QMD, not a raw content scan. Reading a " \
205
+ "known file and listing/globbing the store are fine; only CONTENT SEARCH over " \
206
+ "store markdown routes through QMD. Use `qmd search`/`qmd query` over the " \
207
+ "`plastic-*` collections (or `scripts/qmd-sync search`) instead of scanning " \
208
+ "#{path}. If QMD's results do not answer your need (your reading of the " \
209
+ "snippets, not their score), append a trailing `# qmd-ok` to a Bash command."
210
+ end
211
+ end
@@ -0,0 +1,384 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "yaml"
6
+ require "socket"
7
+ require "time"
8
+ require_relative "lock"
9
+
10
+ # Worktree -- Plastic-supplied git worktree isolation and the delivery lock
11
+ # (intent 73c / 73c1).
12
+ #
13
+ # The harness `EnterWorktree` tool assumes cwd IS the repo root, which is false
14
+ # for Plastic (cwd is often the parent of the repo subdir). When the mismatch
15
+ # occurs the tool silently degrades to a plain feature branch on the shared
16
+ # checkout, so parallel intent deliveries are NOT isolated. This module makes
17
+ # isolation deterministic and cwd-independent: Plastic resolves the repo from
18
+ # projects.yml and runs `git -C <repo> worktree add`, so the cwd-not-root bug
19
+ # dies by construction (decision D6).
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}
24
+ #
25
+ # The durable delivery.lock file in the intent dir is the single-owner
26
+ # delivery lock (intent 108): session-keyed, lease-based, explicit takeover.
27
+ #
28
+ # Pure and dependency-injected: every git call goes through an injected
29
+ # `ShellRunner`, so unit tests are hermetic (no real git; inject a fake runner).
30
+ # No eval, no global/ENV config injection.
31
+ module Worktree
32
+ module_function
33
+
34
+ # --- ShellRunner (DI seam) -------------------------------------------------
35
+
36
+ # The default runner shells out to real `git`. Tests inject a fake with the
37
+ # same `run(*args)` contract so no real git runs in unit tests.
38
+ class ShellRunner
39
+ Result = Struct.new(:status, :stdout, :stderr) do
40
+ def success?
41
+ status.zero?
42
+ end
43
+ end
44
+
45
+ def run(*args)
46
+ require "open3"
47
+ out, err, status = Open3.capture3("git", *args.map(&:to_s))
48
+ Result.new(status.exitstatus.to_i, out, err)
49
+ end
50
+ end
51
+
52
+ # --- pure helpers ----------------------------------------------------------
53
+
54
+ def blank?(value)
55
+ value.nil? || value.to_s.strip.empty?
56
+ end
57
+
58
+ # The `{id}--{slug}` identity shared by both worktrees and both branches.
59
+ def dir_name(intent_id, intent_slug)
60
+ "#{intent_id}--#{intent_slug}"
61
+ end
62
+
63
+ # Pure, deterministic. Returns the four paths/branches. No git calls.
64
+ # `repo_path` is resolved from projects.yml when nil; when it cannot be
65
+ # resolved the code worktree path/branch are nil (a global-store-only intent).
66
+ def paths(slug:, intent_id:, intent_slug:, home: Dir.home, repo_path: nil)
67
+ name = dir_name(intent_id, intent_slug)
68
+ repo = repo_path || repo_for(slug, home: home)
69
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
70
+
71
+ code_path = repo ? File.join(File.expand_path(repo), ".claude", "worktrees", name) : nil
72
+ store_path = File.join(plastic_home, ".worktrees", name)
73
+
74
+ {
75
+ "code" => code_path,
76
+ "code_branch" => code_path ? "plastic/#{name}" : nil,
77
+ "store" => store_path,
78
+ "store_branch" => "plastic-store/#{name}",
79
+ }
80
+ end
81
+
82
+ # Absolute repo path for a project slug from `~/.plastic/projects.yml`, or nil.
83
+ # Reuses the qmd_sync safe-loader pattern: any failure yields nil.
84
+ def repo_for(slug, home: Dir.home)
85
+ return nil if blank?(slug)
86
+ projects = load_projects(home)
87
+ info = projects[slug.to_s]
88
+ path = info.is_a?(Hash) ? info["path"] : nil
89
+ return nil if blank?(path)
90
+ File.expand_path(path)
91
+ end
92
+
93
+ # --- provisioning ----------------------------------------------------------
94
+
95
+ # Resolve the slug from the bridge's intent.store, create code + store
96
+ # worktrees (idempotent: reuse an existing worktree path, do not error), write
97
+ # the `worktree` block plus `provisioned: true` onto bridge_data, return it.
98
+ #
99
+ # Fails open with a stderr log when the repo is non-git or unresolvable:
100
+ # sets `provisioned: false` and leaves `code: null`. All git ops use
101
+ # `git -C <resolved path>` -- never cwd (decision D6).
102
+ def provision(bridge_data, home: Dir.home, runner: ShellRunner.new)
103
+ return bridge_data unless bridge_data.is_a?(Hash)
104
+ intent = bridge_data["intent"] || {}
105
+ intent_id = intent["id"].to_s
106
+ store = intent["store"].to_s
107
+ intent_slug = slug_from_dir(intent["dir"]) || slug_from_dir(store)
108
+
109
+ slug = slug_for_store(store, home: home)
110
+ p = paths(slug: slug, intent_id: intent_id, intent_slug: intent_slug, home: home)
111
+
112
+ block = {
113
+ "code" => nil,
114
+ "code_branch" => nil,
115
+ "store" => p["store"],
116
+ "store_branch" => p["store_branch"],
117
+ "provisioned" => false,
118
+ }
119
+
120
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
121
+
122
+ # Gitignore safety (intent 73c3): the store worktrees live under the store git
123
+ # repo, so without ignoring `.worktrees/` a `git add -A` sweeps their gitlinks
124
+ # into the store commit. Ensure both ignore entries before any worktree add.
125
+ ensure_gitignored(plastic_home, ".worktrees/", runner: runner)
126
+
127
+ # The durable lock files live inside intent dirs under the store git repo
128
+ # (intent 108, D2): transient state, never committed.
129
+ ensure_gitignored(plastic_home, "*.lock", runner: runner)
130
+
131
+ # Store worktree: created against the plastic home git repo. Fail-open if the
132
+ # store repo is not a git repo (a fresh global store may be ungit'd).
133
+ store_ok = add_worktree(runner, repo: plastic_home,
134
+ worktree: p["store"], branch: p["store_branch"],
135
+ label: "store")
136
+
137
+ # Code worktree: MANDATORY for project intents. Fail-open when the repo is
138
+ # unresolvable or non-git -- that is the global-store-only / non-git case.
139
+ repo = repo_for(slug, home: home)
140
+ code_ok = false
141
+ if repo && git_repo?(runner, repo)
142
+ ensure_gitignored(repo, ".claude/worktrees/", runner: runner)
143
+ code_ok = add_worktree(runner, repo: repo,
144
+ worktree: p["code"], branch: p["code_branch"],
145
+ label: "code")
146
+ if code_ok
147
+ block["code"] = p["code"]
148
+ block["code_branch"] = p["code_branch"]
149
+ end
150
+ else
151
+ warn "plastic: worktree provision fail-open -- repo for slug #{slug.inspect} " \
152
+ "is unresolvable or not a git repo; code worktree skipped"
153
+ end
154
+
155
+ block["store"] = store_ok ? p["store"] : nil
156
+ block["store_branch"] = store_ok ? p["store_branch"] : nil
157
+
158
+ # provisioned is true only when the MANDATORY code worktree exists. The gate
159
+ # fails open on provisioned: false (non-git / global-only).
160
+ block["provisioned"] = code_ok
161
+
162
+ bridge_data["worktree"] = block
163
+ bridge_data
164
+ end
165
+
166
+ # Remove both worktrees (then `git worktree prune`), clear the worktree block.
167
+ # No-op when nothing was provisioned. CLEANUP (73c3) layers the merge-vs-remove
168
+ # policy on top via `finish`; this is the plain remove. Pass `remove: false` to
169
+ # clear the block WITHOUT touching git (so `finish` can merge first, then call
170
+ # release to drop the worktrees once the code branch is integrated).
171
+ def release(bridge_data, home: Dir.home, runner: ShellRunner.new, remove: true)
172
+ return bridge_data unless bridge_data.is_a?(Hash)
173
+ block = bridge_data["worktree"]
174
+ return bridge_data unless block.is_a?(Hash)
175
+
176
+ if remove
177
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
178
+ slug = slug_for_store(bridge_data.dig("intent", "store").to_s, home: home)
179
+ repo = repo_for(slug, home: home)
180
+
181
+ remove_worktree(runner, repo: repo, worktree: block["code"]) if repo && block["code"]
182
+ remove_worktree(runner, repo: plastic_home, worktree: block["store"]) if block["store"]
183
+
184
+ prune(runner, repo: repo) if repo
185
+ prune(runner, repo: plastic_home)
186
+ end
187
+
188
+ bridge_data.delete("worktree")
189
+ bridge_data
190
+ end
191
+
192
+ # --- cleanup policy (merge-vs-remove) -------------------------------------
193
+
194
+ # Finish an intent's delivery by tearing down its worktrees, optionally merging
195
+ # the code branch back first (intent 73c3). The merge-vs-remove decision is the
196
+ # one piece of policy on top of the plain `release`:
197
+ #
198
+ # merge: true -> the releasing path. Merge the intent's code branch
199
+ # (`plastic/{id}--{slug}`) into the repo's default branch
200
+ # BEFORE removing the worktrees, so the work is integrated and
201
+ # not lost when the worktree disappears. Then `release`.
202
+ # merge: false -> the disarm / abandon path. Just `release` (plain remove);
203
+ # the branch survives and can be reclaimed.
204
+ #
205
+ # Fail-open and idempotent throughout: a missing block, missing branch, or any
206
+ # git failure never raises and never blocks teardown. All git ops use
207
+ # `git -C <path>`, never cwd (decision D6). No-op when nothing was provisioned.
208
+ def finish(bridge_data, home: Dir.home, runner: ShellRunner.new, merge: false)
209
+ return bridge_data unless bridge_data.is_a?(Hash)
210
+ block = bridge_data["worktree"]
211
+ return bridge_data unless block.is_a?(Hash)
212
+
213
+ if merge
214
+ slug = slug_for_store(bridge_data.dig("intent", "store").to_s, home: home)
215
+ repo = repo_for(slug, home: home)
216
+ branch = block["code_branch"]
217
+ merge_branch(runner, repo: repo, branch: branch) if repo && !blank?(branch)
218
+ end
219
+
220
+ release(bridge_data, home: home, runner: runner, remove: true)
221
+ end
222
+
223
+ # Merge `branch` into the repo's default branch from the main checkout. The
224
+ # worktree the branch is checked out in stays put; we merge in the repo dir
225
+ # itself (its own current branch is the integration target). Idempotent: a
226
+ # no-op merge ("Already up to date") still succeeds. Fail-open: a conflicting
227
+ # or otherwise failing merge is aborted and logged, never raised, so teardown
228
+ # still proceeds (CLEANUP must not strand a worktree).
229
+ def merge_branch(runner, repo:, branch:)
230
+ return false if blank?(repo) || blank?(branch)
231
+ target = current_branch(runner, repo: repo)
232
+ return false if blank?(target) || target == branch
233
+
234
+ res = runner.run("-C", repo, "merge", "--no-ff", "--no-edit", branch)
235
+ return true if res.success?
236
+
237
+ # Leave the integration branch clean: abort a half-applied/conflicted merge.
238
+ runner.run("-C", repo, "merge", "--abort")
239
+ warn "plastic: worktree finish could not merge #{branch.inspect} into " \
240
+ "#{target.inspect}: #{res.stderr.to_s.strip}; removing worktree without merge"
241
+ false
242
+ end
243
+
244
+ # The repo's current branch (the integration target), or nil when detached /
245
+ # unresolvable.
246
+ def current_branch(runner, repo:)
247
+ return nil if blank?(repo)
248
+ res = runner.run("-C", repo, "rev-parse", "--abbrev-ref", "HEAD")
249
+ return nil unless res.success?
250
+ name = res.stdout.to_s.strip
251
+ (name.empty? || name == "HEAD") ? nil : name
252
+ end
253
+
254
+ # --- gitignore safety ------------------------------------------------------
255
+
256
+ # Ensure `entry` is present in `<repo>/.gitignore`, appending it once if absent
257
+ # (idempotent). Without this, the store worktrees that live UNDER the store git
258
+ # repo (~/.plastic/.worktrees/) get swept into the store commit by a `git add
259
+ # -A`, polluting the index with worktree gitlinks (observed during 73c1
260
+ # integration). Provisioning and cleanup both call this so the repos' indexes
261
+ # stay clean. Best-effort and non-raising: any failure is logged, never raised.
262
+ def ensure_gitignored(repo, entry, runner: ShellRunner.new)
263
+ return false if blank?(repo) || blank?(entry) || !Dir.exist?(repo)
264
+ gitignore = File.join(File.expand_path(repo), ".gitignore")
265
+ want = entry.to_s.strip
266
+
267
+ existing = File.exist?(gitignore) ? File.read(gitignore) : ""
268
+ present = existing.each_line.any? { |line| line.strip == want }
269
+ return true if present
270
+
271
+ File.open(gitignore, "a") do |io|
272
+ io.write("\n") unless existing.empty? || existing.end_with?("\n")
273
+ io.write("#{want}\n")
274
+ end
275
+ true
276
+ rescue StandardError => e
277
+ warn "plastic: ensure_gitignored(#{entry.inspect}) failed for #{repo.inspect}: #{e.message}"
278
+ false
279
+ end
280
+
281
+ # --- lock ------------------------------------------------------------------
282
+
283
+ # True iff ANOTHER session's delivery.lock is FRESH on this intent's dir
284
+ # (intent 108, D2): the durable lock file decides; /tmp bridges are not
285
+ # consulted and no pid is probed. current_session being the owner or a
286
+ # delegate does not count as "other". A stale lock does not hold (explicit
287
+ # takeover reclaims it).
288
+ def lock_held_by_other?(intent_id:, store:, current_session:, home: Dir.home,
289
+ ttl: Lock::TTL_SECONDS, now: Time.now)
290
+ return false if blank?(store)
291
+ dir = Dir.glob(File.join(File.expand_path(store), "#{intent_id}--*")).first
292
+ return false unless dir
293
+ data = Lock.read(dir)
294
+ return false unless data
295
+ return false if Lock.authorized?(data, current_session)
296
+ Lock.fresh?(dir, ttl: ttl, now: now)
297
+ rescue StandardError
298
+ false
299
+ end
300
+
301
+ # --- git operations (all use -C, never cwd) --------------------------------
302
+
303
+ # Idempotent worktree add. If `worktree` already exists on disk, treat as
304
+ # reuse (success, no git call). Otherwise `git -C <repo> worktree add <wt>
305
+ # -b <branch>`; if the branch already exists, retry without -b (reattach).
306
+ def add_worktree(runner, repo:, worktree:, branch:, label:)
307
+ return false if blank?(repo) || blank?(worktree)
308
+ return true if Dir.exist?(worktree) # idempotent reuse
309
+
310
+ res = runner.run("-C", repo, "worktree", "add", worktree, "-b", branch)
311
+ return true if res.success?
312
+
313
+ # Branch may already exist (a prior provision that was pruned but kept the
314
+ # branch). Retry attaching the existing branch.
315
+ res2 = runner.run("-C", repo, "worktree", "add", worktree, branch)
316
+ return true if res2.success?
317
+
318
+ warn "plastic: worktree add (#{label}) failed: #{res.stderr.to_s.strip}"
319
+ false
320
+ end
321
+
322
+ def remove_worktree(runner, repo:, worktree:)
323
+ return false if blank?(repo) || blank?(worktree)
324
+ res = runner.run("-C", repo, "worktree", "remove", worktree)
325
+ unless res.success?
326
+ # Force-remove tolerates dirty/locked worktrees; CLEANUP owns merge policy.
327
+ res = runner.run("-C", repo, "worktree", "remove", "--force", worktree)
328
+ end
329
+ res.success?
330
+ end
331
+
332
+ def prune(runner, repo:)
333
+ return false if blank?(repo)
334
+ runner.run("-C", repo, "worktree", "prune").success?
335
+ end
336
+
337
+ # True iff `repo` is a git work tree (idempotent, no mutation).
338
+ def git_repo?(runner, repo)
339
+ return false if blank?(repo) || !Dir.exist?(repo)
340
+ res = runner.run("-C", repo, "rev-parse", "--is-inside-work-tree")
341
+ res.success? && res.stdout.to_s.strip == "true"
342
+ end
343
+
344
+ # --- internals (projects.yml resolution, mirrors qmd_sync) -----------------
345
+
346
+ def load_projects(home)
347
+ path = File.join(File.expand_path(home), ".plastic", "projects.yml")
348
+ return {} unless File.exist?(path)
349
+ data = begin
350
+ YAML.safe_load(File.read(path)) || {}
351
+ rescue StandardError
352
+ {}
353
+ end
354
+ projects = data.is_a?(Hash) ? data["projects"] : nil
355
+ projects.is_a?(Hash) ? projects : {}
356
+ end
357
+
358
+ # Resolve a project slug from a store directory. A project's tactical store
359
+ # lives at <plastic_home>/projects/<slug>/store; the global store yields nil
360
+ # (no project repo). Mirrors qmd_sync's slug_for_store fallback.
361
+ def slug_for_store(store_dir, home: Dir.home)
362
+ return nil if blank?(store_dir)
363
+ plastic_home = File.expand_path(File.join(home, ".plastic"))
364
+ store_dir = File.expand_path(store_dir)
365
+ return nil if store_dir == File.join(plastic_home, "store")
366
+
367
+ parts = store_dir.split(File::SEPARATOR)
368
+ idx = parts.rindex("projects")
369
+ return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
370
+ nil
371
+ end
372
+
373
+ # Best-effort slug for the worktree dir-name from an intent dir/store path:
374
+ # the basename `{id}--{slug}` -> the `{slug}` portion (split on the first
375
+ # `--`). Used only for naming.
376
+ def slug_from_dir(dir)
377
+ return nil if blank?(dir)
378
+ base = File.basename(dir.to_s)
379
+ idx = base.index("--")
380
+ return nil unless idx
381
+ base[(idx + 2)..]
382
+ end
383
+
384
+ end