@zalom/plastic 1.0.0-beta.2 → 1.0.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +131 -7
- package/agents/plastic-brainstorming.md +9 -1
- package/agents/plastic-enforcer.md +1 -1
- package/agents/plastic-executor.md +11 -1
- package/agents/plastic-intent-curator.md +7 -5
- package/agents/plastic-planner.md +11 -1
- package/agents/plastic-spec-specialist.md +9 -1
- package/hooks/hooks.json +20 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/hooks/statusline +150 -41
- package/package.json +1 -1
- package/scripts/agent-report +163 -0
- package/scripts/doctor.rb +172 -0
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +2 -2
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-create-gate +2 -2
- package/scripts/hook-gate-check +14 -23
- package/scripts/hook-retrieval-gate +136 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/lib/bridge.rb +374 -34
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +23 -0
- package/scripts/lib/link_suggestions.rb +322 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/power_tools.rb +76 -0
- package/scripts/lib/qmd_hook.rb +38 -25
- package/scripts/lib/qmd_sync.rb +36 -0
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +409 -0
- package/scripts/link-suggest +211 -0
- package/scripts/new-intent +138 -29
- package/scripts/project-links +287 -0
- package/scripts/qmd-sync +50 -3
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +26 -1
- package/skills/auto/SKILL.md +58 -11
- package/skills/auto/evals/evals.json +48 -0
- package/skills/auto/references/agent-architecture.md +27 -4
- package/skills/auto/references/agent-report-contract.md +121 -0
- package/skills/brainstorming/SKILL.md +1 -0
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/continuing/SKILL.md +30 -8
- package/skills/continuing/evals/evals.json +9 -0
- package/skills/creating-intent/SKILL.md +16 -2
- package/skills/creating-intent/evals/evals.json +16 -0
- package/skills/creating-intent/references/lifecycle.md +9 -4
- package/skills/creating-skills/SKILL.md +65 -0
- package/skills/creating-skills/evals/evals.json +108 -0
- package/skills/creating-skills/references/agents.md +168 -0
- package/skills/creating-skills/references/evals.md +41 -0
- package/skills/creating-skills/references/hooks.md +248 -0
- package/skills/creating-skills/references/progressive-disclosure.md +176 -0
- package/skills/creating-skills/references/scripts.md +166 -0
- package/skills/creating-skills/references/skills.md +165 -0
- package/skills/creating-skills/scripts/scaffold.rb +313 -0
- package/skills/dashboard/SKILL.md +5 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/executing-plan/SKILL.md +2 -2
- package/skills/humanizer/SKILL.md +39 -0
- package/skills/humanizer/evals/evals.json +70 -0
- package/skills/humanizer/references/always-on-snippet.md +9 -0
- package/skills/humanizer/references/examples.md +48 -0
- package/skills/intent-curator/SKILL.md +6 -1
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/linking-intents/SKILL.md +54 -12
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/managing-index/SKILL.md +8 -0
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- package/skills/releasing/SKILL.md +32 -0
- package/skills/research/SKILL.md +8 -0
- package/skills/research/evals/evals.json +22 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- package/skills/writing-instructions/references/agentskills-spec.md +0 -135
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "yaml"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
# Worktree -- Plastic-supplied git worktree isolation and the delivery lock
|
|
10
|
+
# (intent 73c / 73c1).
|
|
11
|
+
#
|
|
12
|
+
# The harness `EnterWorktree` tool assumes cwd IS the repo root, which is false
|
|
13
|
+
# for Plastic (cwd is often the parent of the repo subdir). When the mismatch
|
|
14
|
+
# occurs the tool silently degrades to a plain feature branch on the shared
|
|
15
|
+
# checkout, so parallel intent deliveries are NOT isolated. This module makes
|
|
16
|
+
# isolation deterministic and cwd-independent: Plastic resolves the repo from
|
|
17
|
+
# projects.yml and runs `git -C <repo> worktree add`, so the cwd-not-root bug
|
|
18
|
+
# dies by construction (decision D6).
|
|
19
|
+
#
|
|
20
|
+
# Two worktrees per project intent, both named `{id}--{slug}` (decision D2):
|
|
21
|
+
# code worktree <repo>/.claude/worktrees/{id}--{slug} branch plastic/{id}--{slug}
|
|
22
|
+
# store worktree <plastic_home>/.worktrees/{id}--{slug} branch plastic-store/{id}--{slug}
|
|
23
|
+
#
|
|
24
|
+
# The bridge file doubles as the delivery lock (decision D3): single-owner,
|
|
25
|
+
# stale-lock reclaim via pid liveness.
|
|
26
|
+
#
|
|
27
|
+
# Pure and dependency-injected: every git call goes through an injected
|
|
28
|
+
# `ShellRunner`, so unit tests are hermetic (no real git; inject a fake runner).
|
|
29
|
+
# No eval, no global/ENV config injection.
|
|
30
|
+
module Worktree
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# --- ShellRunner (DI seam) -------------------------------------------------
|
|
34
|
+
|
|
35
|
+
# The default runner shells out to real `git`. Tests inject a fake with the
|
|
36
|
+
# same `run(*args)` contract so no real git runs in unit tests.
|
|
37
|
+
class ShellRunner
|
|
38
|
+
Result = Struct.new(:status, :stdout, :stderr) do
|
|
39
|
+
def success?
|
|
40
|
+
status.zero?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run(*args)
|
|
45
|
+
require "open3"
|
|
46
|
+
out, err, status = Open3.capture3("git", *args.map(&:to_s))
|
|
47
|
+
Result.new(status.exitstatus.to_i, out, err)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# --- pure helpers ----------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def blank?(value)
|
|
54
|
+
value.nil? || value.to_s.strip.empty?
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The `{id}--{slug}` identity shared by both worktrees and both branches.
|
|
58
|
+
def dir_name(intent_id, intent_slug)
|
|
59
|
+
"#{intent_id}--#{intent_slug}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Pure, deterministic. Returns the four paths/branches. No git calls.
|
|
63
|
+
# `repo_path` is resolved from projects.yml when nil; when it cannot be
|
|
64
|
+
# resolved the code worktree path/branch are nil (a global-store-only intent).
|
|
65
|
+
def paths(slug:, intent_id:, intent_slug:, home: Dir.home, repo_path: nil)
|
|
66
|
+
name = dir_name(intent_id, intent_slug)
|
|
67
|
+
repo = repo_path || repo_for(slug, home: home)
|
|
68
|
+
plastic_home = File.expand_path(File.join(home, ".plastic"))
|
|
69
|
+
|
|
70
|
+
code_path = repo ? File.join(File.expand_path(repo), ".claude", "worktrees", name) : nil
|
|
71
|
+
store_path = File.join(plastic_home, ".worktrees", name)
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
"code" => code_path,
|
|
75
|
+
"code_branch" => code_path ? "plastic/#{name}" : nil,
|
|
76
|
+
"store" => store_path,
|
|
77
|
+
"store_branch" => "plastic-store/#{name}",
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Absolute repo path for a project slug from `~/.plastic/projects.yml`, or nil.
|
|
82
|
+
# Reuses the qmd_sync safe-loader pattern: any failure yields nil.
|
|
83
|
+
def repo_for(slug, home: Dir.home)
|
|
84
|
+
return nil if blank?(slug)
|
|
85
|
+
projects = load_projects(home)
|
|
86
|
+
info = projects[slug.to_s]
|
|
87
|
+
path = info.is_a?(Hash) ? info["path"] : nil
|
|
88
|
+
return nil if blank?(path)
|
|
89
|
+
File.expand_path(path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# --- provisioning ----------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
# Resolve the slug from the bridge's intent.store, create code + store
|
|
95
|
+
# worktrees (idempotent: reuse an existing worktree path, do not error), write
|
|
96
|
+
# the `worktree` block plus `provisioned: true` onto bridge_data, return it.
|
|
97
|
+
#
|
|
98
|
+
# Fails open with a stderr log when the repo is non-git or unresolvable:
|
|
99
|
+
# sets `provisioned: false` and leaves `code: null`. All git ops use
|
|
100
|
+
# `git -C <resolved path>` -- never cwd (decision D6).
|
|
101
|
+
def provision(bridge_data, home: Dir.home, runner: ShellRunner.new)
|
|
102
|
+
return bridge_data unless bridge_data.is_a?(Hash)
|
|
103
|
+
intent = bridge_data["intent"] || {}
|
|
104
|
+
intent_id = intent["id"].to_s
|
|
105
|
+
store = intent["store"].to_s
|
|
106
|
+
intent_slug = slug_from_dir(intent["dir"]) || slug_from_dir(store)
|
|
107
|
+
|
|
108
|
+
slug = slug_for_store(store, home: home)
|
|
109
|
+
p = paths(slug: slug, intent_id: intent_id, intent_slug: intent_slug, home: home)
|
|
110
|
+
|
|
111
|
+
block = {
|
|
112
|
+
"code" => nil,
|
|
113
|
+
"code_branch" => nil,
|
|
114
|
+
"store" => p["store"],
|
|
115
|
+
"store_branch" => p["store_branch"],
|
|
116
|
+
"provisioned" => false,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
plastic_home = File.expand_path(File.join(home, ".plastic"))
|
|
120
|
+
|
|
121
|
+
# Gitignore safety (intent 73c3): the store worktrees live under the store git
|
|
122
|
+
# repo, so without ignoring `.worktrees/` a `git add -A` sweeps their gitlinks
|
|
123
|
+
# into the store commit. Ensure both ignore entries before any worktree add.
|
|
124
|
+
ensure_gitignored(plastic_home, ".worktrees/", runner: runner)
|
|
125
|
+
|
|
126
|
+
# Store worktree: created against the plastic home git repo. Fail-open if the
|
|
127
|
+
# store repo is not a git repo (a fresh global store may be ungit'd).
|
|
128
|
+
store_ok = add_worktree(runner, repo: plastic_home,
|
|
129
|
+
worktree: p["store"], branch: p["store_branch"],
|
|
130
|
+
label: "store")
|
|
131
|
+
|
|
132
|
+
# Code worktree: MANDATORY for project intents. Fail-open when the repo is
|
|
133
|
+
# unresolvable or non-git -- that is the global-store-only / non-git case.
|
|
134
|
+
repo = repo_for(slug, home: home)
|
|
135
|
+
code_ok = false
|
|
136
|
+
if repo && git_repo?(runner, repo)
|
|
137
|
+
ensure_gitignored(repo, ".claude/worktrees/", runner: runner)
|
|
138
|
+
code_ok = add_worktree(runner, repo: repo,
|
|
139
|
+
worktree: p["code"], branch: p["code_branch"],
|
|
140
|
+
label: "code")
|
|
141
|
+
if code_ok
|
|
142
|
+
block["code"] = p["code"]
|
|
143
|
+
block["code_branch"] = p["code_branch"]
|
|
144
|
+
end
|
|
145
|
+
else
|
|
146
|
+
warn "plastic: worktree provision fail-open -- repo for slug #{slug.inspect} " \
|
|
147
|
+
"is unresolvable or not a git repo; code worktree skipped"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
block["store"] = store_ok ? p["store"] : nil
|
|
151
|
+
block["store_branch"] = store_ok ? p["store_branch"] : nil
|
|
152
|
+
|
|
153
|
+
# provisioned is true only when the MANDATORY code worktree exists. The gate
|
|
154
|
+
# fails open on provisioned: false (non-git / global-only).
|
|
155
|
+
block["provisioned"] = code_ok
|
|
156
|
+
|
|
157
|
+
bridge_data["worktree"] = block
|
|
158
|
+
bridge_data
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Remove both worktrees (then `git worktree prune`), clear the worktree block.
|
|
162
|
+
# No-op when nothing was provisioned. CLEANUP (73c3) layers the merge-vs-remove
|
|
163
|
+
# policy on top via `finish`; this is the plain remove. Pass `remove: false` to
|
|
164
|
+
# clear the block WITHOUT touching git (so `finish` can merge first, then call
|
|
165
|
+
# release to drop the worktrees once the code branch is integrated).
|
|
166
|
+
def release(bridge_data, home: Dir.home, runner: ShellRunner.new, remove: true)
|
|
167
|
+
return bridge_data unless bridge_data.is_a?(Hash)
|
|
168
|
+
block = bridge_data["worktree"]
|
|
169
|
+
return bridge_data unless block.is_a?(Hash)
|
|
170
|
+
|
|
171
|
+
if remove
|
|
172
|
+
plastic_home = File.expand_path(File.join(home, ".plastic"))
|
|
173
|
+
slug = slug_for_store(bridge_data.dig("intent", "store").to_s, home: home)
|
|
174
|
+
repo = repo_for(slug, home: home)
|
|
175
|
+
|
|
176
|
+
remove_worktree(runner, repo: repo, worktree: block["code"]) if repo && block["code"]
|
|
177
|
+
remove_worktree(runner, repo: plastic_home, worktree: block["store"]) if block["store"]
|
|
178
|
+
|
|
179
|
+
prune(runner, repo: repo) if repo
|
|
180
|
+
prune(runner, repo: plastic_home)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
bridge_data.delete("worktree")
|
|
184
|
+
bridge_data
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# --- cleanup policy (merge-vs-remove) -------------------------------------
|
|
188
|
+
|
|
189
|
+
# Finish an intent's delivery by tearing down its worktrees, optionally merging
|
|
190
|
+
# the code branch back first (intent 73c3). The merge-vs-remove decision is the
|
|
191
|
+
# one piece of policy on top of the plain `release`:
|
|
192
|
+
#
|
|
193
|
+
# merge: true -> the releasing path. Merge the intent's code branch
|
|
194
|
+
# (`plastic/{id}--{slug}`) into the repo's default branch
|
|
195
|
+
# BEFORE removing the worktrees, so the work is integrated and
|
|
196
|
+
# not lost when the worktree disappears. Then `release`.
|
|
197
|
+
# merge: false -> the disarm / abandon path. Just `release` (plain remove);
|
|
198
|
+
# the branch survives and can be reclaimed.
|
|
199
|
+
#
|
|
200
|
+
# Fail-open and idempotent throughout: a missing block, missing branch, or any
|
|
201
|
+
# git failure never raises and never blocks teardown. All git ops use
|
|
202
|
+
# `git -C <path>`, never cwd (decision D6). No-op when nothing was provisioned.
|
|
203
|
+
def finish(bridge_data, home: Dir.home, runner: ShellRunner.new, merge: false)
|
|
204
|
+
return bridge_data unless bridge_data.is_a?(Hash)
|
|
205
|
+
block = bridge_data["worktree"]
|
|
206
|
+
return bridge_data unless block.is_a?(Hash)
|
|
207
|
+
|
|
208
|
+
if merge
|
|
209
|
+
slug = slug_for_store(bridge_data.dig("intent", "store").to_s, home: home)
|
|
210
|
+
repo = repo_for(slug, home: home)
|
|
211
|
+
branch = block["code_branch"]
|
|
212
|
+
merge_branch(runner, repo: repo, branch: branch) if repo && !blank?(branch)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
release(bridge_data, home: home, runner: runner, remove: true)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Merge `branch` into the repo's default branch from the main checkout. The
|
|
219
|
+
# worktree the branch is checked out in stays put; we merge in the repo dir
|
|
220
|
+
# itself (its own current branch is the integration target). Idempotent: a
|
|
221
|
+
# no-op merge ("Already up to date") still succeeds. Fail-open: a conflicting
|
|
222
|
+
# or otherwise failing merge is aborted and logged, never raised, so teardown
|
|
223
|
+
# still proceeds (CLEANUP must not strand a worktree).
|
|
224
|
+
def merge_branch(runner, repo:, branch:)
|
|
225
|
+
return false if blank?(repo) || blank?(branch)
|
|
226
|
+
target = current_branch(runner, repo: repo)
|
|
227
|
+
return false if blank?(target) || target == branch
|
|
228
|
+
|
|
229
|
+
res = runner.run("-C", repo, "merge", "--no-ff", "--no-edit", branch)
|
|
230
|
+
return true if res.success?
|
|
231
|
+
|
|
232
|
+
# Leave the integration branch clean: abort a half-applied/conflicted merge.
|
|
233
|
+
runner.run("-C", repo, "merge", "--abort")
|
|
234
|
+
warn "plastic: worktree finish could not merge #{branch.inspect} into " \
|
|
235
|
+
"#{target.inspect}: #{res.stderr.to_s.strip}; removing worktree without merge"
|
|
236
|
+
false
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# The repo's current branch (the integration target), or nil when detached /
|
|
240
|
+
# unresolvable.
|
|
241
|
+
def current_branch(runner, repo:)
|
|
242
|
+
return nil if blank?(repo)
|
|
243
|
+
res = runner.run("-C", repo, "rev-parse", "--abbrev-ref", "HEAD")
|
|
244
|
+
return nil unless res.success?
|
|
245
|
+
name = res.stdout.to_s.strip
|
|
246
|
+
(name.empty? || name == "HEAD") ? nil : name
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# --- gitignore safety ------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
# Ensure `entry` is present in `<repo>/.gitignore`, appending it once if absent
|
|
252
|
+
# (idempotent). Without this, the store worktrees that live UNDER the store git
|
|
253
|
+
# repo (~/.plastic/.worktrees/) get swept into the store commit by a `git add
|
|
254
|
+
# -A`, polluting the index with worktree gitlinks (observed during 73c1
|
|
255
|
+
# integration). Provisioning and cleanup both call this so the repos' indexes
|
|
256
|
+
# stay clean. Best-effort and non-raising: any failure is logged, never raised.
|
|
257
|
+
def ensure_gitignored(repo, entry, runner: ShellRunner.new)
|
|
258
|
+
return false if blank?(repo) || blank?(entry) || !Dir.exist?(repo)
|
|
259
|
+
gitignore = File.join(File.expand_path(repo), ".gitignore")
|
|
260
|
+
want = entry.to_s.strip
|
|
261
|
+
|
|
262
|
+
existing = File.exist?(gitignore) ? File.read(gitignore) : ""
|
|
263
|
+
present = existing.each_line.any? { |line| line.strip == want }
|
|
264
|
+
return true if present
|
|
265
|
+
|
|
266
|
+
File.open(gitignore, "a") do |io|
|
|
267
|
+
io.write("\n") unless existing.empty? || existing.end_with?("\n")
|
|
268
|
+
io.write("#{want}\n")
|
|
269
|
+
end
|
|
270
|
+
true
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
warn "plastic: ensure_gitignored(#{entry.inspect}) failed for #{repo.inspect}: #{e.message}"
|
|
273
|
+
false
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# --- lock ------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
# pid liveness: signal 0 probes without sending. Any error (no such process,
|
|
279
|
+
# not ours) means not live.
|
|
280
|
+
def session_live?(pid)
|
|
281
|
+
n = Integer(pid) rescue nil
|
|
282
|
+
return false if n.nil? || n <= 0
|
|
283
|
+
Process.kill(0, n)
|
|
284
|
+
true
|
|
285
|
+
rescue StandardError
|
|
286
|
+
false
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# True iff ANOTHER bridge for this intent has a LIVE owner pid that is not
|
|
290
|
+
# current_session. Scans /tmp/plastic-*.json (or `tmp`). The current session's
|
|
291
|
+
# own bridge never counts as "other". A dead owner does not hold the lock
|
|
292
|
+
# (stale-lock reclaim).
|
|
293
|
+
def lock_held_by_other?(intent_id:, store:, current_session:, home: Dir.home, tmp: nil)
|
|
294
|
+
tmp ||= default_tmp
|
|
295
|
+
id = intent_id.to_s
|
|
296
|
+
st = File.expand_path(store.to_s) unless blank?(store)
|
|
297
|
+
|
|
298
|
+
Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
|
|
299
|
+
next if f.end_with?(".tmp")
|
|
300
|
+
data = (JSON.parse(File.read(f)) rescue nil)
|
|
301
|
+
next unless data.is_a?(Hash)
|
|
302
|
+
|
|
303
|
+
intent = data["intent"] || {}
|
|
304
|
+
next unless intent["id"].to_s == id
|
|
305
|
+
unless st.nil?
|
|
306
|
+
bstore = intent["store"].to_s
|
|
307
|
+
next unless bstore.empty? || File.expand_path(bstore) == st
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
session = data["session"].to_s
|
|
311
|
+
next if !blank?(current_session) && session == current_session.to_s
|
|
312
|
+
|
|
313
|
+
lock = data["lock"] || {}
|
|
314
|
+
owner_pid = lock["pid"]
|
|
315
|
+
return true if session_live?(owner_pid)
|
|
316
|
+
end
|
|
317
|
+
false
|
|
318
|
+
rescue StandardError
|
|
319
|
+
false
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# --- git operations (all use -C, never cwd) --------------------------------
|
|
323
|
+
|
|
324
|
+
# Idempotent worktree add. If `worktree` already exists on disk, treat as
|
|
325
|
+
# reuse (success, no git call). Otherwise `git -C <repo> worktree add <wt>
|
|
326
|
+
# -b <branch>`; if the branch already exists, retry without -b (reattach).
|
|
327
|
+
def add_worktree(runner, repo:, worktree:, branch:, label:)
|
|
328
|
+
return false if blank?(repo) || blank?(worktree)
|
|
329
|
+
return true if Dir.exist?(worktree) # idempotent reuse
|
|
330
|
+
|
|
331
|
+
res = runner.run("-C", repo, "worktree", "add", worktree, "-b", branch)
|
|
332
|
+
return true if res.success?
|
|
333
|
+
|
|
334
|
+
# Branch may already exist (a prior provision that was pruned but kept the
|
|
335
|
+
# branch). Retry attaching the existing branch.
|
|
336
|
+
res2 = runner.run("-C", repo, "worktree", "add", worktree, branch)
|
|
337
|
+
return true if res2.success?
|
|
338
|
+
|
|
339
|
+
warn "plastic: worktree add (#{label}) failed: #{res.stderr.to_s.strip}"
|
|
340
|
+
false
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def remove_worktree(runner, repo:, worktree:)
|
|
344
|
+
return false if blank?(repo) || blank?(worktree)
|
|
345
|
+
res = runner.run("-C", repo, "worktree", "remove", worktree)
|
|
346
|
+
unless res.success?
|
|
347
|
+
# Force-remove tolerates dirty/locked worktrees; CLEANUP owns merge policy.
|
|
348
|
+
res = runner.run("-C", repo, "worktree", "remove", "--force", worktree)
|
|
349
|
+
end
|
|
350
|
+
res.success?
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def prune(runner, repo:)
|
|
354
|
+
return false if blank?(repo)
|
|
355
|
+
runner.run("-C", repo, "worktree", "prune").success?
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# True iff `repo` is a git work tree (idempotent, no mutation).
|
|
359
|
+
def git_repo?(runner, repo)
|
|
360
|
+
return false if blank?(repo) || !Dir.exist?(repo)
|
|
361
|
+
res = runner.run("-C", repo, "rev-parse", "--is-inside-work-tree")
|
|
362
|
+
res.success? && res.stdout.to_s.strip == "true"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# --- internals (projects.yml resolution, mirrors qmd_sync) -----------------
|
|
366
|
+
|
|
367
|
+
def load_projects(home)
|
|
368
|
+
path = File.join(File.expand_path(home), ".plastic", "projects.yml")
|
|
369
|
+
return {} unless File.exist?(path)
|
|
370
|
+
data = begin
|
|
371
|
+
YAML.safe_load(File.read(path)) || {}
|
|
372
|
+
rescue StandardError
|
|
373
|
+
{}
|
|
374
|
+
end
|
|
375
|
+
projects = data.is_a?(Hash) ? data["projects"] : nil
|
|
376
|
+
projects.is_a?(Hash) ? projects : {}
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Resolve a project slug from a store directory. A project's tactical store
|
|
380
|
+
# lives at <plastic_home>/projects/<slug>/store; the global store yields nil
|
|
381
|
+
# (no project repo). Mirrors qmd_sync's slug_for_store fallback.
|
|
382
|
+
def slug_for_store(store_dir, home: Dir.home)
|
|
383
|
+
return nil if blank?(store_dir)
|
|
384
|
+
plastic_home = File.expand_path(File.join(home, ".plastic"))
|
|
385
|
+
store_dir = File.expand_path(store_dir)
|
|
386
|
+
return nil if store_dir == File.join(plastic_home, "store")
|
|
387
|
+
|
|
388
|
+
parts = store_dir.split(File::SEPARATOR)
|
|
389
|
+
idx = parts.rindex("projects")
|
|
390
|
+
return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Best-effort slug for the worktree dir-name from an intent dir/store path:
|
|
395
|
+
# the basename `{id}--{slug}` -> the `{slug}` portion (split on the first
|
|
396
|
+
# `--`). Used only for naming.
|
|
397
|
+
def slug_from_dir(dir)
|
|
398
|
+
return nil if blank?(dir)
|
|
399
|
+
base = File.basename(dir.to_s)
|
|
400
|
+
idx = base.index("--")
|
|
401
|
+
return nil unless idx
|
|
402
|
+
base[(idx + 2)..]
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def default_tmp
|
|
406
|
+
t = ENV["PLASTIC_TMP"]
|
|
407
|
+
(t.nil? || t.strip.empty?) ? "/tmp" : t
|
|
408
|
+
end
|
|
409
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# link-suggest - support links decided by CONTEXT INFLUENCE (intent 91, D7). Links are
|
|
6
|
+
# NOT graded by a script: whether one intent's context influenced another is a
|
|
7
|
+
# judgement an agent makes by reading the candidate's Intent + Context. This tool
|
|
8
|
+
# gathers candidates with that evidence, records a CONFIRMED edge plus its rating and
|
|
9
|
+
# reason in `link-decisions.md`, and flags drift. It never writes a `## Links` line and
|
|
10
|
+
# never deletes.
|
|
11
|
+
#
|
|
12
|
+
# `## Links` is a DERIVED view of `sources`/`chain` (Convention over Configuration).
|
|
13
|
+
# Tiers, by context influence:
|
|
14
|
+
# sources - the foundational context that shaped this intent's creation.
|
|
15
|
+
# chain - the context that materially helps DELIVER this intent. HIGH bar.
|
|
16
|
+
# tags - loose theme grouping for search. Not a link.
|
|
17
|
+
#
|
|
18
|
+
# Usage:
|
|
19
|
+
# link-suggest <subject_id> [--store-dir PATH] [--plastic-home PATH]
|
|
20
|
+
# link-suggest <subject_id> --record <target_id> --edge <sources|chain>
|
|
21
|
+
# --rating <high|medium|low> --reason "..." --confirm
|
|
22
|
+
# link-suggest --help
|
|
23
|
+
#
|
|
24
|
+
# Default (no --record) prints candidates WITH their Intent+Context plus any drift, and
|
|
25
|
+
# writes nothing. --record without --confirm prints what would happen and writes
|
|
26
|
+
# nothing. --edge is explicit and required (sources is decided by origin, never
|
|
27
|
+
# inferred); --rating is required when --edge is chain.
|
|
28
|
+
|
|
29
|
+
require_relative "lib/link_suggestions"
|
|
30
|
+
|
|
31
|
+
module LinkSuggestCLI
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
35
|
+
|
|
36
|
+
USAGE = <<~TXT
|
|
37
|
+
Usage:
|
|
38
|
+
link-suggest <subject_id> [--store-dir PATH] [--plastic-home PATH]
|
|
39
|
+
link-suggest <subject_id> --record <target_id> --edge <sources|chain> \\
|
|
40
|
+
--rating <high|medium|low> --reason "..." --confirm
|
|
41
|
+
link-suggest --help
|
|
42
|
+
|
|
43
|
+
Links are decided by context influence, judged by an agent reading each
|
|
44
|
+
candidate's Intent + Context. This tool only gathers candidates with that
|
|
45
|
+
evidence, records a confirmed edge plus its rating/reason in link-decisions.md,
|
|
46
|
+
and flags drift. It never writes a ## Links line and never deletes.
|
|
47
|
+
|
|
48
|
+
--edge is explicit and required (sources is decided by origin, never inferred).
|
|
49
|
+
--rating is required when --edge is chain. Without --confirm, nothing is written.
|
|
50
|
+
TXT
|
|
51
|
+
|
|
52
|
+
def parse(argv)
|
|
53
|
+
opts = { home: DEFAULT_HOME, store_dir: nil, subject: nil, help: false,
|
|
54
|
+
record: nil, edge: nil, rating: nil, reason: nil, confirm: false }
|
|
55
|
+
i = 0
|
|
56
|
+
while i < argv.length
|
|
57
|
+
case argv[i]
|
|
58
|
+
when "--help", "-h" then opts[:help] = true; i += 1
|
|
59
|
+
when "--plastic-home" then opts[:home] = argv[i + 1]; i += 2
|
|
60
|
+
when "--store-dir" then opts[:store_dir] = argv[i + 1]; i += 2
|
|
61
|
+
when "--record" then opts[:record] = argv[i + 1]; i += 2
|
|
62
|
+
when "--edge" then opts[:edge] = (argv[i + 1] || "").to_sym; i += 2
|
|
63
|
+
when "--rating" then opts[:rating] = argv[i + 1]; i += 2
|
|
64
|
+
when "--reason" then opts[:reason] = argv[i + 1]; i += 2
|
|
65
|
+
when "--confirm" then opts[:confirm] = true; i += 1
|
|
66
|
+
else
|
|
67
|
+
opts[:subject] ||= argv[i]
|
|
68
|
+
i += 1
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
opts[:store_dir] ||= File.join(opts[:home], "store")
|
|
72
|
+
opts
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Print the discovery candidates with their Intent + Context evidence, plus drift.
|
|
76
|
+
def report(tool, subject, out: $stdout)
|
|
77
|
+
nodes = tool.load_nodes
|
|
78
|
+
unless nodes.key?(subject)
|
|
79
|
+
out.puts "link-suggest: no intent #{subject.inspect} in #{tool.store_dir}"
|
|
80
|
+
return 1
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
cands = tool.gather(subject, nodes: nodes)
|
|
84
|
+
out.puts "Candidates for #{subject} (discovery only; judge influence by reading context):"
|
|
85
|
+
if cands.empty?
|
|
86
|
+
out.puts " (none)"
|
|
87
|
+
else
|
|
88
|
+
cands.each { |c| print_candidate(c, out) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
flaws = tool.drift(subject, nodes: nodes)
|
|
92
|
+
out.puts "Drift for #{subject}:"
|
|
93
|
+
if flaws.empty?
|
|
94
|
+
out.puts " (none)"
|
|
95
|
+
else
|
|
96
|
+
flaws.each { |f| out.puts " [drift] #{f.detail}" }
|
|
97
|
+
end
|
|
98
|
+
0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def print_candidate(cand, out)
|
|
102
|
+
out.puts " - #{cand.id} #{cand.label}"
|
|
103
|
+
out.puts " Intent: #{excerpt(cand.intent)}" unless cand.intent.empty?
|
|
104
|
+
out.puts " Context: #{excerpt(cand.context)}" unless cand.context.empty?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# A one-paragraph excerpt of a section, for scannable evidence.
|
|
108
|
+
def excerpt(text, limit: 280)
|
|
109
|
+
flat = text.to_s.gsub(/\s+/, " ").strip
|
|
110
|
+
flat.length > limit ? "#{flat[0, limit]}..." : flat
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def do_record(tool, opts, out: $stdout)
|
|
114
|
+
if opts[:edge].nil? || !%i[sources chain].include?(opts[:edge])
|
|
115
|
+
out.puts "link-suggest: --record requires --edge sources|chain (explicit, never inferred)."
|
|
116
|
+
return 1
|
|
117
|
+
end
|
|
118
|
+
if opts[:edge] == :chain && (opts[:rating].nil? || !LinkSuggestions::RATINGS.include?(opts[:rating]))
|
|
119
|
+
out.puts "link-suggest: --rating high|medium|low is required for a chain edge."
|
|
120
|
+
return 1
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
unless opts[:confirm]
|
|
124
|
+
out.puts "Would record #{opts[:edge]} edge #{opts[:subject]} -> #{opts[:record]} " \
|
|
125
|
+
"(rating #{opts[:rating] || "-"}, reason: #{opts[:reason] || "-"}). " \
|
|
126
|
+
"Re-run with --confirm to write. Nothing written."
|
|
127
|
+
return 0
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
wrote = tool.record_edge(opts[:subject], opts[:record],
|
|
131
|
+
edge: opts[:edge], rating: opts[:rating],
|
|
132
|
+
reason: opts[:reason], confirm: true)
|
|
133
|
+
if wrote
|
|
134
|
+
out.puts "Recorded #{opts[:edge]} edge #{opts[:subject]} -> #{opts[:record]} " \
|
|
135
|
+
"and appended to link-decisions.md. Reproject with scripts/project-links."
|
|
136
|
+
return 0
|
|
137
|
+
end
|
|
138
|
+
out.puts "No edge written (edge may already exist, or intent missing)."
|
|
139
|
+
0
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def run(argv, out: $stdout)
|
|
143
|
+
opts = parse(argv)
|
|
144
|
+
if opts[:help]
|
|
145
|
+
out.puts USAGE
|
|
146
|
+
return 0
|
|
147
|
+
end
|
|
148
|
+
unless opts[:subject]
|
|
149
|
+
out.puts USAGE
|
|
150
|
+
return 1
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
tool = LinkSuggestions.new(store_dir: opts[:store_dir], finder: build_finder(opts[:store_dir]))
|
|
154
|
+
|
|
155
|
+
return do_record(tool, opts, out: out) if opts[:record]
|
|
156
|
+
|
|
157
|
+
report(tool, opts[:subject], out: out)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The real candidate-finder, wired here (not in the lib): use QMD when available,
|
|
161
|
+
# else fall back to the lib's cheap family/tag/adjacent net. Discovery only.
|
|
162
|
+
def build_finder(store_dir)
|
|
163
|
+
qmd = qmd_finder(store_dir)
|
|
164
|
+
qmd || LinkSuggestions::FamilyTagFinder.new
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A QMD-backed finder when the `qmd` (or qmd-sync) CLI is on PATH. It seeds discovery
|
|
168
|
+
# from the subject's intent line, unions the hits with the cheap fallback net, and
|
|
169
|
+
# returns ids present in the store. Any failure falls back silently. Discovery only,
|
|
170
|
+
# never a grade.
|
|
171
|
+
def qmd_finder(_store_dir)
|
|
172
|
+
return nil unless qmd_available?
|
|
173
|
+
|
|
174
|
+
fallback = LinkSuggestions::FamilyTagFinder.new
|
|
175
|
+
lambda do |subject_id, nodes|
|
|
176
|
+
base = fallback.call(subject_id, nodes)
|
|
177
|
+
subject = nodes[subject_id]
|
|
178
|
+
return base unless subject
|
|
179
|
+
|
|
180
|
+
hits = qmd_search_ids(subject[:label], nodes)
|
|
181
|
+
(base + hits).uniq.reject { |id| id == subject_id }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def qmd_available?
|
|
186
|
+
%w[qmd qmd-sync].any? { |c| system("command -v #{c} >/dev/null 2>&1") }
|
|
187
|
+
rescue StandardError
|
|
188
|
+
false
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Best-effort: ask qmd-sync for related text and map any id-shaped tokens back to
|
|
192
|
+
# store ids. Never raises; returns [] on any trouble.
|
|
193
|
+
def qmd_search_ids(query, nodes)
|
|
194
|
+
return [] if query.to_s.strip.empty?
|
|
195
|
+
|
|
196
|
+
out = `ruby #{File.join(Dir.home, ".plastic", "scripts", "qmd-sync")} search #{shell_quote(query)} 2>/dev/null`
|
|
197
|
+
return [] if out.nil? || out.empty?
|
|
198
|
+
|
|
199
|
+
out.scan(/\b([0-9]+[a-z0-9]*)\b/).flatten.uniq.select { |id| nodes.key?(id) }
|
|
200
|
+
rescue StandardError
|
|
201
|
+
[]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def shell_quote(str)
|
|
205
|
+
"'#{str.to_s.gsub("'", "'\\\\''")}'"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
if $PROGRAM_NAME == __FILE__
|
|
210
|
+
exit LinkSuggestCLI.run(ARGV)
|
|
211
|
+
end
|