@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.
Files changed (82) hide show
  1. package/PLASTIC.md +131 -7
  2. package/agents/plastic-brainstorming.md +9 -1
  3. package/agents/plastic-enforcer.md +1 -1
  4. package/agents/plastic-executor.md +11 -1
  5. package/agents/plastic-intent-curator.md +7 -5
  6. package/agents/plastic-planner.md +11 -1
  7. package/agents/plastic-spec-specialist.md +9 -1
  8. package/hooks/hooks.json +20 -0
  9. package/hooks/retrieval-gate +10 -0
  10. package/hooks/savepoint-pre +10 -0
  11. package/hooks/statusline +150 -41
  12. package/package.json +1 -1
  13. package/scripts/agent-report +163 -0
  14. package/scripts/doctor.rb +172 -0
  15. package/scripts/hook-auto-arm +1 -1
  16. package/scripts/hook-bash-gate +2 -2
  17. package/scripts/hook-code-gate +11 -6
  18. package/scripts/hook-create-gate +2 -2
  19. package/scripts/hook-gate-check +14 -23
  20. package/scripts/hook-retrieval-gate +136 -0
  21. package/scripts/hook-savepoint-pre +32 -0
  22. package/scripts/hook-session-start +1 -1
  23. package/scripts/insight-append +51 -0
  24. package/scripts/lib/bridge.rb +374 -34
  25. package/scripts/lib/frontmatter_writer.rb +130 -0
  26. package/scripts/lib/graph_rebuild.rb +328 -0
  27. package/scripts/lib/insights.rb +86 -0
  28. package/scripts/lib/installer_core.rb +23 -0
  29. package/scripts/lib/link_suggestions.rb +322 -0
  30. package/scripts/lib/links_projection.rb +160 -0
  31. package/scripts/lib/links_section.rb +207 -0
  32. package/scripts/lib/power_tools.rb +76 -0
  33. package/scripts/lib/qmd_hook.rb +38 -25
  34. package/scripts/lib/qmd_sync.rb +36 -0
  35. package/scripts/lib/retrieval_gate.rb +211 -0
  36. package/scripts/lib/worktree.rb +409 -0
  37. package/scripts/link-suggest +211 -0
  38. package/scripts/new-intent +138 -29
  39. package/scripts/project-links +287 -0
  40. package/scripts/qmd-sync +50 -3
  41. package/scripts/rebuild-graph +244 -0
  42. package/scripts/spawn-preamble +26 -1
  43. package/skills/auto/SKILL.md +58 -11
  44. package/skills/auto/evals/evals.json +48 -0
  45. package/skills/auto/references/agent-architecture.md +27 -4
  46. package/skills/auto/references/agent-report-contract.md +121 -0
  47. package/skills/brainstorming/SKILL.md +1 -0
  48. package/skills/brainstorming/evals/evals.json +22 -0
  49. package/skills/continuing/SKILL.md +30 -8
  50. package/skills/continuing/evals/evals.json +9 -0
  51. package/skills/creating-intent/SKILL.md +16 -2
  52. package/skills/creating-intent/evals/evals.json +16 -0
  53. package/skills/creating-intent/references/lifecycle.md +9 -4
  54. package/skills/creating-skills/SKILL.md +65 -0
  55. package/skills/creating-skills/evals/evals.json +108 -0
  56. package/skills/creating-skills/references/agents.md +168 -0
  57. package/skills/creating-skills/references/evals.md +41 -0
  58. package/skills/creating-skills/references/hooks.md +248 -0
  59. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  60. package/skills/creating-skills/references/scripts.md +166 -0
  61. package/skills/creating-skills/references/skills.md +165 -0
  62. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  63. package/skills/dashboard/SKILL.md +5 -0
  64. package/skills/dashboard/evals/evals.json +22 -0
  65. package/skills/executing-plan/SKILL.md +2 -2
  66. package/skills/humanizer/SKILL.md +39 -0
  67. package/skills/humanizer/evals/evals.json +70 -0
  68. package/skills/humanizer/references/always-on-snippet.md +9 -0
  69. package/skills/humanizer/references/examples.md +48 -0
  70. package/skills/intent-curator/SKILL.md +6 -1
  71. package/skills/intent-curator/evals/evals.json +22 -0
  72. package/skills/linking-intents/SKILL.md +54 -12
  73. package/skills/linking-intents/evals/evals.json +22 -0
  74. package/skills/linking-intents/references/zettelkasten.md +7 -0
  75. package/skills/managing-index/SKILL.md +8 -0
  76. package/skills/managing-index/evals/evals.json +22 -0
  77. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  78. package/skills/releasing/SKILL.md +32 -0
  79. package/skills/research/SKILL.md +8 -0
  80. package/skills/research/evals/evals.json +22 -0
  81. package/skills/writing-instructions/SKILL.md +0 -159
  82. 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