@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.
- package/PLASTIC-reference.md +133 -0
- package/PLASTIC.md +407 -84
- package/README.md +99 -82
- package/agents/plastic-brainstorming.md +12 -9
- package/agents/plastic-enforcer.md +52 -12
- package/agents/plastic-executor.md +10 -10
- package/agents/plastic-future-intent-researcher.md +4 -8
- package/agents/plastic-intent-curator.md +12 -14
- package/agents/plastic-intent-discovery.md +35 -0
- package/agents/plastic-planner.md +15 -10
- package/agents/plastic-spec-specialist.md +15 -10
- package/bin/plastic.js +7 -3
- package/hooks/code-gate +10 -1
- package/hooks/hooks.json +28 -3
- package/hooks/lock-gate +21 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/hooks/statusline +48 -6
- package/package.json +2 -1
- package/scripts/agent-report +21 -0
- package/scripts/dashboard.rb +98 -23
- package/scripts/doctor.rb +404 -5
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +22 -4
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-continue +17 -0
- package/scripts/hook-create-gate +45 -9
- package/scripts/hook-gate-check +24 -23
- package/scripts/hook-lock-gate +83 -0
- package/scripts/hook-retrieval-gate +148 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/install.rb +44 -7
- package/scripts/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +924 -84
- package/scripts/lib/dashboard_banner.rb +42 -0
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +104 -60
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/lock.rb +375 -0
- package/scripts/lib/power_tools.rb +22 -21
- package/scripts/lib/preflight.rb +79 -0
- package/scripts/lib/qmd_sync.rb +15 -0
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +384 -0
- package/scripts/link-suggest +213 -0
- package/scripts/new-intent +138 -29
- package/scripts/plastic-lock +164 -0
- package/scripts/project-links +287 -0
- package/scripts/read-config +4 -0
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +11 -3
- package/scripts/update.rb +16 -7
- package/skills/auto/SKILL.md +126 -19
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/auto/references/end-tail.md +56 -0
- package/skills/auto/references/human-report-contract.md +55 -0
- package/skills/auto/references/tiers.md +77 -0
- package/skills/brainstorming/SKILL.md +7 -34
- package/skills/brainstorming/references/design-principles.md +49 -0
- package/skills/continuing/SKILL.md +26 -7
- package/skills/creating-intent/SKILL.md +13 -28
- package/skills/creating-intent/references/lifecycle.md +9 -4
- package/skills/creating-project/SKILL.md +11 -74
- package/skills/creating-project/references/project-scaffolding.md +97 -0
- 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 +13 -11
- package/skills/dashboard/references/classification.md +22 -0
- package/skills/doctor/SKILL.md +10 -7
- package/skills/executing-plan/SKILL.md +4 -4
- 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/install/SKILL.md +75 -84
- package/skills/intent-curator/SKILL.md +5 -1
- package/skills/intent-discovery/SKILL.md +47 -0
- package/skills/intent-starting/SKILL.md +130 -0
- package/skills/intent-starting/evals/evals.json +117 -0
- package/skills/intent-starting/references/boarding-matrix.md +35 -0
- package/skills/linking-intents/SKILL.md +48 -12
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- package/skills/releasing/SKILL.md +44 -29
- package/skills/releasing/references/promotion-and-tagging.md +60 -0
- package/skills/roadmap/SKILL.md +51 -0
- package/skills/roadmap/references/file-format.md +91 -0
- package/skills/roadmap/references/operations.md +78 -0
- package/skills/uninstall/SKILL.md +29 -11
- package/skills/update/SKILL.md +34 -23
- package/skills/versions/SKILL.md +27 -12
- package/skills/writing-plans/SKILL.md +10 -88
- package/skills/writing-plans/references/plan-format.md +102 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -0
- package/templates/roadmap.md +30 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- 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
|
package/scripts/new-intent
CHANGED
|
@@ -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
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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`).
|
|
180
|
-
#
|
|
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.
|
|
192
|
-
# the
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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.
|
|
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
|