@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,213 @@
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 in frontmatter plus a
9
+ # dated rating/reason line in the intent's `## Insights` section, and flags drift. It
10
+ # never writes a `## Links` line and 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 the intent's
46
+ ## Insights section, and flags drift. It never writes a ## Links line and
47
+ never deletes.
48
+
49
+ --edge is explicit and required (sources is decided by origin, never inferred).
50
+ --rating is required when --edge is chain. Without --confirm, nothing is written.
51
+ TXT
52
+
53
+ def parse(argv)
54
+ opts = { home: DEFAULT_HOME, store_dir: nil, subject: nil, help: false,
55
+ record: nil, edge: nil, rating: nil, reason: nil, confirm: false }
56
+ i = 0
57
+ while i < argv.length
58
+ case argv[i]
59
+ when "--help", "-h" then opts[:help] = true; i += 1
60
+ when "--plastic-home" then opts[:home] = argv[i + 1]; i += 2
61
+ when "--store-dir" then opts[:store_dir] = argv[i + 1]; i += 2
62
+ when "--record" then opts[:record] = argv[i + 1]; i += 2
63
+ when "--edge" then opts[:edge] = (argv[i + 1] || "").to_sym; i += 2
64
+ when "--rating" then opts[:rating] = argv[i + 1]; i += 2
65
+ when "--reason" then opts[:reason] = argv[i + 1]; i += 2
66
+ when "--confirm" then opts[:confirm] = true; i += 1
67
+ else
68
+ opts[:subject] ||= argv[i]
69
+ i += 1
70
+ end
71
+ end
72
+ opts[:store_dir] ||= File.join(opts[:home], "store")
73
+ opts
74
+ end
75
+
76
+ # Print the discovery candidates with their Intent + Context evidence, plus drift.
77
+ def report(tool, subject, out: $stdout)
78
+ nodes = tool.load_nodes
79
+ unless nodes.key?(subject)
80
+ out.puts "link-suggest: no intent #{subject.inspect} in #{tool.store_dir}"
81
+ return 1
82
+ end
83
+
84
+ cands = tool.gather(subject, nodes: nodes)
85
+ out.puts "Candidates for #{subject} (discovery only; judge influence by reading context):"
86
+ if cands.empty?
87
+ out.puts " (none)"
88
+ else
89
+ cands.each { |c| print_candidate(c, out) }
90
+ end
91
+
92
+ flaws = tool.drift(subject, nodes: nodes)
93
+ out.puts "Drift for #{subject}:"
94
+ if flaws.empty?
95
+ out.puts " (none)"
96
+ else
97
+ flaws.each { |f| out.puts " [drift] #{f.detail}" }
98
+ end
99
+ 0
100
+ end
101
+
102
+ def print_candidate(cand, out)
103
+ out.puts " - #{cand.id} #{cand.label}"
104
+ out.puts " Intent: #{excerpt(cand.intent)}" unless cand.intent.empty?
105
+ out.puts " Context: #{excerpt(cand.context)}" unless cand.context.empty?
106
+ end
107
+
108
+ # A one-paragraph excerpt of a section, for scannable evidence.
109
+ def excerpt(text, limit: 280)
110
+ flat = text.to_s.gsub(/\s+/, " ").strip
111
+ flat.length > limit ? "#{flat[0, limit]}..." : flat
112
+ end
113
+
114
+ def do_record(tool, opts, out: $stdout)
115
+ if opts[:edge].nil? || !%i[sources chain].include?(opts[:edge])
116
+ out.puts "link-suggest: --record requires --edge sources|chain (explicit, never inferred)."
117
+ return 1
118
+ end
119
+ if opts[:edge] == :chain && (opts[:rating].nil? || !LinkSuggestions::RATINGS.include?(opts[:rating]))
120
+ out.puts "link-suggest: --rating high|medium|low is required for a chain edge."
121
+ return 1
122
+ end
123
+
124
+ unless opts[:confirm]
125
+ out.puts "Would record #{opts[:edge]} edge #{opts[:subject]} -> #{opts[:record]} " \
126
+ "(rating #{opts[:rating] || "-"}, reason: #{opts[:reason] || "-"}). " \
127
+ "Re-run with --confirm to write. Nothing written."
128
+ return 0
129
+ end
130
+
131
+ wrote = tool.record_edge(opts[:subject], opts[:record],
132
+ edge: opts[:edge], rating: opts[:rating],
133
+ reason: opts[:reason], confirm: true)
134
+ if wrote
135
+ out.puts "Recorded #{opts[:edge]} edge #{opts[:subject]} -> #{opts[:record]} " \
136
+ "and appended a rating/reason line to the intent's ## Insights. " \
137
+ "Reproject ## Links with scripts/project-links."
138
+ return 0
139
+ end
140
+ out.puts "No edge written (edge may already exist, or intent missing)."
141
+ 0
142
+ end
143
+
144
+ def run(argv, out: $stdout)
145
+ opts = parse(argv)
146
+ if opts[:help]
147
+ out.puts USAGE
148
+ return 0
149
+ end
150
+ unless opts[:subject]
151
+ out.puts USAGE
152
+ return 1
153
+ end
154
+
155
+ tool = LinkSuggestions.new(store_dir: opts[:store_dir], finder: build_finder(opts[:store_dir]))
156
+
157
+ return do_record(tool, opts, out: out) if opts[:record]
158
+
159
+ report(tool, opts[:subject], out: out)
160
+ end
161
+
162
+ # The real candidate-finder, wired here (not in the lib): use QMD when available,
163
+ # else fall back to the lib's cheap family/tag/adjacent net. Discovery only.
164
+ def build_finder(store_dir)
165
+ qmd = qmd_finder(store_dir)
166
+ qmd || LinkSuggestions::FamilyTagFinder.new
167
+ end
168
+
169
+ # A QMD-backed finder when the `qmd` (or qmd-sync) CLI is on PATH. It seeds discovery
170
+ # from the subject's intent line, unions the hits with the cheap fallback net, and
171
+ # returns ids present in the store. Any failure falls back silently. Discovery only,
172
+ # never a grade.
173
+ def qmd_finder(_store_dir)
174
+ return nil unless qmd_available?
175
+
176
+ fallback = LinkSuggestions::FamilyTagFinder.new
177
+ lambda do |subject_id, nodes|
178
+ base = fallback.call(subject_id, nodes)
179
+ subject = nodes[subject_id]
180
+ return base unless subject
181
+
182
+ hits = qmd_search_ids(subject[:label], nodes)
183
+ (base + hits).uniq.reject { |id| id == subject_id }
184
+ end
185
+ end
186
+
187
+ def qmd_available?
188
+ %w[qmd qmd-sync].any? { |c| system("command -v #{c} >/dev/null 2>&1") }
189
+ rescue StandardError
190
+ false
191
+ end
192
+
193
+ # Best-effort: ask qmd-sync for related text and map any id-shaped tokens back to
194
+ # store ids. Never raises; returns [] on any trouble.
195
+ def qmd_search_ids(query, nodes)
196
+ return [] if query.to_s.strip.empty?
197
+
198
+ out = `ruby #{File.join(Dir.home, ".plastic", "scripts", "qmd-sync")} search #{shell_quote(query)} 2>/dev/null`
199
+ return [] if out.nil? || out.empty?
200
+
201
+ out.scan(/\b([0-9]+[a-z0-9]*)\b/).flatten.uniq.select { |id| nodes.key?(id) }
202
+ rescue StandardError
203
+ []
204
+ end
205
+
206
+ def shell_quote(str)
207
+ "'#{str.to_s.gsub("'", "'\\\\''")}'"
208
+ end
209
+ end
210
+
211
+ if $PROGRAM_NAME == __FILE__
212
+ exit LinkSuggestCLI.run(ARGV)
213
+ end
@@ -26,6 +26,9 @@ require "fileutils"
26
26
  require "date"
27
27
  require_relative "lib/bridge"
28
28
  require_relative "lib/intent_validator"
29
+ require_relative "lib/graph_rebuild"
30
+ require_relative "lib/links_projection"
31
+ require_relative "lib/links_section"
29
32
 
30
33
  # --- Explicit flag parsing (no eval, no global injection) ------------------
31
34
 
@@ -106,25 +109,119 @@ def add_to_chain(file_path, new_id)
106
109
  File.write(file_path, ["", new_fm, parts[2]].join("---"))
107
110
  end
108
111
 
109
- # Append a wikilink line under the file's `## Links` section, idempotently.
110
- def append_link(file_path, link_line)
111
- return unless File.exist?(file_path)
112
- content = File.read(file_path)
113
- return if content.include?(link_line)
112
+ # Derive [plastic_home, referer_store_key] from a store directory path so the
113
+ # cross-store resolver (shared with project-links and the doctor check) can be
114
+ # built. A global store is `<home>/store` (key "global"); a project store is
115
+ # `<home>/projects/<slug>/store` (key "project:<slug>"). Returns
116
+ # [nil, "global"] only if the layout is unrecognized, in which case Links
117
+ # projection falls back to single-store resolution rooted at this store.
118
+ def store_context(store)
119
+ store = File.expand_path(store)
120
+ parent = File.dirname(store) # `<home>` or `<home>/projects/<slug>`
121
+ if File.basename(store) == "store" && File.basename(File.dirname(parent)) == "projects"
122
+ slug = File.basename(parent)
123
+ home = File.dirname(File.dirname(parent)) # strip projects/<slug>
124
+ [home, "project:#{slug}"]
125
+ elsif File.basename(store) == "store"
126
+ [parent, "global"] # `<home>/store`
127
+ else
128
+ [parent, "global"]
129
+ end
130
+ end
131
+
132
+ # The in-scope stores under `plastic_home`, each { key:, store: }. Mirrors
133
+ # project-links/RebuildGraph#stores so cross-store resolution spans the family.
134
+ def family_stores(plastic_home)
135
+ list = []
136
+ global_store = File.join(plastic_home, "store")
137
+ list << { key: "global", store: global_store } if File.directory?(global_store)
138
+ %w[plastic knowdb].each do |slug|
139
+ store = File.join(plastic_home, "projects", slug, "store")
140
+ list << { key: "project:#{slug}", store: store } if File.directory?(store)
141
+ end
142
+ list
143
+ end
114
144
 
115
- lines = content.lines
116
- idx = lines.index { |l| l.strip == "## Links" }
117
- return unless idx
145
+ # Build the cross-store maps the LinksProjection resolver needs:
146
+ # store_index => { store_key => [bare ids] }
147
+ # node_index => { store_key => { id => { basename:, label: } } }
148
+ # relocation_map => from GraphRebuild.build_relocation_map over every INDEX.md
149
+ # `fallback_store` is the store the new intent lives in; it is always included so
150
+ # resolution works even for a brand-new store with no INDEX.md yet.
151
+ def build_cross_store_maps(plastic_home, fallback_store_key, fallback_store_dir)
152
+ stores = family_stores(plastic_home)
153
+ # Ensure the fallback store is represented even if family discovery missed it.
154
+ unless stores.any? { |s| s[:key] == fallback_store_key }
155
+ stores << { key: fallback_store_key, store: fallback_store_dir }
156
+ end
157
+
158
+ store_index = Hash.new { |h, k| h[k] = [] }
159
+ node_index = Hash.new { |h, k| h[k] = {} }
160
+ index_texts = {}
161
+
162
+ stores.each do |s|
163
+ next unless File.directory?(s[:store])
164
+
165
+ Dir.children(s[:store]).reject { |e| e.start_with?(".") }.sort.each do |entry|
166
+ dir = File.join(s[:store], entry)
167
+ next unless File.directory?(dir)
118
168
 
119
- insert_at = lines.length
120
- ((idx + 1)...lines.length).each do |j|
121
- if lines[j].strip.start_with?("## ")
122
- insert_at = j
123
- break
169
+ md = File.join(dir, "#{entry}.md")
170
+ next unless File.exist?(md)
171
+
172
+ fm = IntentValidator.parse_frontmatter(md)
173
+ next unless fm.is_a?(Hash) && fm["id"]
174
+
175
+ id = fm["id"].to_s
176
+ store_index[s[:key]] << id
177
+ node_index[s[:key]][id] = { basename: entry, label: fm["intent"].to_s.strip }
124
178
  end
179
+
180
+ idx = File.join(File.dirname(s[:store]), "INDEX.md")
181
+ index_texts[s[:key]] = File.read(idx) if File.exist?(idx)
125
182
  end
126
- lines.insert(insert_at, "#{link_line}\n")
127
- File.write(file_path, lines.join)
183
+
184
+ relocation_map = GraphRebuild.build_relocation_map(index_texts)
185
+ { store_index: store_index, node_index: node_index, relocation_map: relocation_map }
186
+ end
187
+
188
+ # Re-project ONE intent file's `## Links` as the canonical I5 projection of its
189
+ # OWN frontmatter sources+chain, using the shared cross-store resolver. Writes the
190
+ # file only if the section changed (idempotent). Born-canonical: a fresh root with
191
+ # no edges gets the empty-state comment; the fence-aware rewriter touches only the
192
+ # real `## Links` section. Returns true on success, false when the intent could
193
+ # not be read or a ref was unresolvable (left unwritten, never a guessed link).
194
+ def project_links_for(file_path, referer_store_key, maps)
195
+ return false unless File.exist?(file_path)
196
+
197
+ content = File.read(file_path)
198
+ fm = IntentValidator.parse_frontmatter_text(content)
199
+ return false unless fm.is_a?(Hash)
200
+
201
+ resolve = lambda do |ref|
202
+ LinksProjection.resolve_ref_projection(
203
+ ref,
204
+ referer_store: referer_store_key,
205
+ relocation_map: maps[:relocation_map],
206
+ store_index: maps[:store_index],
207
+ node_index: maps[:node_index]
208
+ )
209
+ end
210
+
211
+ begin
212
+ section_text = LinksProjection.section(
213
+ sources: Array(fm["sources"]).map(&:to_s),
214
+ chain: Array(fm["chain"]).map(&:to_s),
215
+ resolve: resolve
216
+ )
217
+ updated = LinksSection.rewrite(content, section_text)
218
+ rescue LinksProjection::UnresolvedRef, LinksSection::AmbiguousLinks => e
219
+ warn "new-intent: could not project ## Links for #{File.basename(file_path)}: #{e.message}"
220
+ return false
221
+ end
222
+
223
+ File.write(file_path, updated) if updated != content
224
+ true
128
225
  end
129
226
 
130
227
  def main(argv)
@@ -176,8 +273,9 @@ def main(argv)
176
273
  # 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
177
274
  # `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
178
275
  # `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
179
- # folds in `--parent`). Scope boundary: 68 fixes ONLY the frontmatter `chain`
180
- # backlink; the `## Links` wikilink projection for the `--sources` path is intent 72.
276
+ # folds in `--parent`). Collect the touched source files so their `## Links` can be
277
+ # re-projected once the chain edges are on disk.
278
+ source_files = []
181
279
  sources.each do |src_id|
182
280
  next if src_id.nil? || src_id.empty?
183
281
 
@@ -186,19 +284,22 @@ def main(argv)
186
284
 
187
285
  src_file = File.join(src_dir, "#{File.basename(src_dir)}.md")
188
286
  add_to_chain(src_file, id)
287
+ source_files << src_file
189
288
  end
190
289
 
191
- # 4b. Reciprocal `## Links` wikilinks: forward link to parent + back-reference in
192
- # the parent (kept parent-only; the `--sources` path's wikilink is intent 72's scope).
193
- if opts[:parent] && !opts[:parent].empty?
194
- parent_id = opts[:parent]
195
- append_link(intent_file, "- [[#{parent_id}]]")
196
- parent_dir = Dir.glob(File.join(store, "#{parent_id}--*")).find { |d| File.directory?(d) }
197
- if parent_dir
198
- parent_file = File.join(parent_dir, "#{File.basename(parent_dir)}.md")
199
- append_link(parent_file, "- [[#{id}]]")
200
- end
201
- end
290
+ # 4b. Canonical `## Links` projection (intent 72): born-canonical, drift PREVENTED
291
+ # at the source. Build the cross-store resolver maps AFTER the chain backlinks are
292
+ # written, then project the NEW intent's Links from its own frontmatter (sources at
293
+ # birth, chain empty) and RE-project each source/parent's Links (it just gained the
294
+ # new id in its chain). The fence-aware rewriter and the resolved-target dedup are
295
+ # the same ones project-links and the doctor check use, so a freshly created intent
296
+ # and its sources both PASS graph_links_projection. A no-source root gets the
297
+ # canonical empty-state comment.
298
+ plastic_home, referer_store_key = store_context(store)
299
+ maps = build_cross_store_maps(plastic_home, referer_store_key, store)
300
+
301
+ project_links_for(intent_file, referer_store_key, maps)
302
+ source_files.uniq.each { |sf| project_links_for(sf, referer_store_key, maps) }
202
303
 
203
304
  # 5. Sentinel placeholders for each lifecycle file. The sentinel is the FIRST
204
305
  # line; the rendered template body follows so the file is a usable starting
@@ -210,7 +311,15 @@ def main(argv)
210
311
  File.write(File.join(intent_dir, name), "#{Bridge::PLACEHOLDER_SENTINEL}\n#{body}")
211
312
  end
212
313
 
213
- # 6. Self-validate (frontmatter + sanctioned sections).
314
+ # 6. Stamp the born savepoint line (intent 81). The first ledger line is the
315
+ # `What` bookend, written deterministically at creation rather than relying on
316
+ # a PostToolUse gate firing on the intent-file write (which is missed in some
317
+ # sessions / harnesses). The intent file is never a sentinel placeholder, so
318
+ # append_savepoint records `What {id}--{slug}.md`. Idempotent: a later gate
319
+ # fire adds nothing.
320
+ Bridge.append_savepoint(intent_dir, intent_file)
321
+
322
+ # 7. Self-validate (frontmatter + sanctioned sections).
214
323
  result = IntentValidator.validate(intent_dir)
215
324
  unless result[:ok]
216
325
  warn "new-intent: scaffolded intent is NOT born complete:"
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+ #
5
+ # plastic-lock: inspect and repair the durable delivery lock (intent 108, D5),
6
+ # and take/free per-artifact claim tokens (intent 111 D1/D5).
7
+ #
8
+ # Usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim>
9
+ # [--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]
10
+ #
11
+ # Verbs:
12
+ # status report the lock file, the bridge cache, their agreement,
13
+ # and any live per-artifact claims
14
+ # fix idempotent repair: rebuild lock + bridge from disk truth for
15
+ # the current session; never touches a fresh foreign lock
16
+ # release owner clears the lock (End tail / abandoning a boarding)
17
+ # reclaim explicit takeover of a stale lock, audited in savepoint.md
18
+ # delegate owner registers a subagent session under the lock (D4)
19
+ # claim acquire the named --artifact's claim token (intent 111);
20
+ # rejected (exit 1) while a fresh claim is held by anyone,
21
+ # including this same session; takes over a stale claim
22
+ # release-claim free the named --artifact's claim token
23
+ #
24
+ # Without --intent-dir the intent is resolved from this session's bridge.
25
+ # Exit 0 on success/report; exit 1 when the verb is blocked (held elsewhere).
26
+
27
+ require "json"
28
+ require_relative "lib/bridge"
29
+ require_relative "lib/lock"
30
+
31
+ def usage!
32
+ warn "usage: plastic-lock <status|fix|release|reclaim|delegate|claim|release-claim> " \
33
+ "[--intent-dir DIR] [--session SID] [--delegate SID] [--artifact NAME]"
34
+ exit 1
35
+ end
36
+
37
+ verb = ARGV.shift
38
+ usage! unless %w[status fix release reclaim delegate claim release-claim].include?(verb)
39
+
40
+ opts = {}
41
+ until ARGV.empty?
42
+ case (flag = ARGV.shift)
43
+ when "--intent-dir" then opts[:dir] = ARGV.shift
44
+ when "--session" then opts[:session] = ARGV.shift
45
+ when "--delegate" then opts[:delegate] = ARGV.shift
46
+ when "--artifact" then opts[:artifact] = ARGV.shift
47
+ else
48
+ warn "unknown flag #{flag}"
49
+ usage!
50
+ end
51
+ end
52
+
53
+ session = opts[:session]
54
+ session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.strip.empty?
55
+
56
+ dir = opts[:dir]
57
+ if dir.nil? || dir.strip.empty?
58
+ bridge = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
59
+ dir = Bridge.bridge_intent_dir(bridge)
60
+ end
61
+ if dir.nil?
62
+ warn "plastic-lock: no intent resolved; pass --intent-dir <intent dir>"
63
+ exit 1
64
+ end
65
+ dir = File.expand_path(dir)
66
+
67
+ intent_id = Bridge.intent_id_from_dir(dir)
68
+ store = File.dirname(dir)
69
+ name = File.basename(dir)
70
+ key = Bridge.resolve_session(session, intent_id: intent_id, store: store)
71
+
72
+ case verb
73
+ when "status"
74
+ lock = Lock.read(dir)
75
+ bridge = Bridge.read(key, intent_id: intent_id)
76
+ report = {
77
+ "intent_dir" => dir,
78
+ "session" => key,
79
+ "lock" => lock,
80
+ "lock_fresh" => Lock.fresh?(dir),
81
+ "lock_corrupt" => Lock.corrupt?(dir),
82
+ "bridge_present" => !bridge.nil?,
83
+ "agreement" => (lock && bridge) ?
84
+ (lock["owner_session"] == bridge.dig("lock", "owner_session")) : nil,
85
+ "claims" => Claim.claims_status(dir),
86
+ }
87
+ puts JSON.pretty_generate(report)
88
+ when "fix"
89
+ report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
90
+ store: store, name: name)
91
+ puts JSON.pretty_generate(report)
92
+ unless report["status"] == "repaired"
93
+ warn "plastic-lock: #{report['status']} by #{report['owner']}" \
94
+ "#{report['hint'] ? " (#{report['hint']})" : ''}"
95
+ exit 1
96
+ end
97
+ when "release"
98
+ result = Lock.release(dir, session: key)
99
+ if result == :not_owner
100
+ warn "plastic-lock: not the owner; run plastic-lock status"
101
+ exit 1
102
+ end
103
+ data = Bridge.read(key, intent_id: intent_id)
104
+ if data
105
+ data["lock"] = { "owner_session" => nil, "acquired_at" => nil,
106
+ "host" => nil, "type" => nil, "delegates" => [] }
107
+ Bridge.write(key, data)
108
+ end
109
+ puts "released (#{result})"
110
+ when "reclaim"
111
+ status, lock_data = Lock.takeover(dir, session: key)
112
+ if status == :fresh
113
+ warn "plastic-lock: lock is FRESH and held by #{lock_data['owner_session']}; " \
114
+ "back off (no silent reclaim)"
115
+ exit 1
116
+ end
117
+ report = Bridge.repair_lock(key, intent_id: intent_id, intent_dir: dir,
118
+ store: store, name: name)
119
+ puts JSON.pretty_generate(report)
120
+ when "delegate"
121
+ usage! if opts[:delegate].nil?
122
+ ok = Lock.add_delegate(dir, delegate: opts[:delegate], session: key)
123
+ unless ok
124
+ warn "plastic-lock: only the lock owner may delegate; run plastic-lock status"
125
+ exit 1
126
+ end
127
+ puts "delegated #{opts[:delegate]} under #{key}"
128
+ when "claim"
129
+ usage! if opts[:artifact].nil?
130
+ status, data = Claim.acquire_claim(dir, opts[:artifact], session: key,
131
+ delegate: opts[:delegate])
132
+ case status
133
+ when :acquired
134
+ puts "claimed #{opts[:artifact]} under #{key}"
135
+ when :held
136
+ warn "plastic-lock: #{opts[:artifact]} is claimed by " \
137
+ "#{data['owner_session']} since #{data['acquired_at']}; back off"
138
+ exit 1
139
+ when :stale
140
+ old = data && data["owner_session"]
141
+ Claim.release_claim(dir, opts[:artifact], session: key, force: true)
142
+ status2, = Claim.acquire_claim(dir, opts[:artifact], session: key,
143
+ delegate: opts[:delegate])
144
+ if status2 == :acquired
145
+ warn "plastic-lock: took over stale claim on #{opts[:artifact]} from #{old}"
146
+ puts "claimed #{opts[:artifact]} under #{key}"
147
+ else
148
+ warn "plastic-lock: could not take over #{opts[:artifact]} (#{status2})"
149
+ exit 1
150
+ end
151
+ when :corrupt
152
+ Claim.release_claim(dir, opts[:artifact], session: key, force: true)
153
+ Claim.acquire_claim(dir, opts[:artifact], session: key, delegate: opts[:delegate])
154
+ puts "claimed #{opts[:artifact]} under #{key} (repaired corrupt claim)"
155
+ end
156
+ when "release-claim"
157
+ usage! if opts[:artifact].nil?
158
+ result = Claim.release_claim(dir, opts[:artifact], session: key)
159
+ if result == :not_owner
160
+ warn "plastic-lock: not the claim owner; run plastic-lock status"
161
+ exit 1
162
+ end
163
+ puts "released-claim #{opts[:artifact]} (#{result})"
164
+ end