@zalom/plastic 1.0.0-beta.3 → 1.0.0-beta.30

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 (102) hide show
  1. package/PLASTIC.md +293 -7
  2. package/README.md +10 -0
  3. package/agents/plastic-brainstorming.md +2 -2
  4. package/agents/plastic-enforcer.md +17 -2
  5. package/agents/plastic-executor.md +3 -3
  6. package/agents/plastic-future-intent-researcher.md +2 -1
  7. package/agents/plastic-intent-curator.md +15 -7
  8. package/agents/plastic-intent-discovery.md +37 -0
  9. package/agents/plastic-planner.md +2 -2
  10. package/agents/plastic-spec-specialist.md +2 -2
  11. package/hooks/code-gate +10 -1
  12. package/hooks/hooks.json +28 -3
  13. package/hooks/lock-gate +21 -0
  14. package/hooks/retrieval-gate +10 -0
  15. package/hooks/savepoint-pre +10 -0
  16. package/hooks/statusline +150 -41
  17. package/package.json +1 -1
  18. package/scripts/agent-report +21 -0
  19. package/scripts/doctor.rb +404 -5
  20. package/scripts/hook-auto-arm +1 -1
  21. package/scripts/hook-bash-gate +22 -4
  22. package/scripts/hook-code-gate +11 -6
  23. package/scripts/hook-create-gate +45 -9
  24. package/scripts/hook-gate-check +24 -23
  25. package/scripts/hook-lock-gate +83 -0
  26. package/scripts/hook-retrieval-gate +148 -0
  27. package/scripts/hook-savepoint-pre +32 -0
  28. package/scripts/hook-session-start +1 -1
  29. package/scripts/insight-append +51 -0
  30. package/scripts/lib/agent_models.rb +43 -0
  31. package/scripts/lib/bridge.rb +625 -47
  32. package/scripts/lib/frontmatter_writer.rb +130 -0
  33. package/scripts/lib/graph_rebuild.rb +328 -0
  34. package/scripts/lib/hook_registry.rb +95 -0
  35. package/scripts/lib/insights.rb +86 -0
  36. package/scripts/lib/installer_core.rb +64 -54
  37. package/scripts/lib/link_suggestions.rb +319 -0
  38. package/scripts/lib/links_projection.rb +160 -0
  39. package/scripts/lib/links_section.rb +207 -0
  40. package/scripts/lib/lock.rb +375 -0
  41. package/scripts/lib/power_tools.rb +75 -0
  42. package/scripts/lib/qmd_hook.rb +38 -25
  43. package/scripts/lib/qmd_sync.rb +36 -0
  44. package/scripts/lib/retrieval_gate.rb +211 -0
  45. package/scripts/lib/worktree.rb +384 -0
  46. package/scripts/link-suggest +213 -0
  47. package/scripts/new-intent +138 -29
  48. package/scripts/plastic-lock +164 -0
  49. package/scripts/project-links +287 -0
  50. package/scripts/qmd-sync +50 -3
  51. package/scripts/read-config +4 -0
  52. package/scripts/rebuild-graph +244 -0
  53. package/scripts/spawn-preamble +11 -3
  54. package/skills/auto/SKILL.md +98 -16
  55. package/skills/auto/evals/evals.json +48 -0
  56. package/skills/auto/references/agent-architecture.md +7 -4
  57. package/skills/auto/references/agent-report-contract.md +36 -1
  58. package/skills/brainstorming/SKILL.md +1 -0
  59. package/skills/brainstorming/evals/evals.json +22 -0
  60. package/skills/continuing/SKILL.md +30 -8
  61. package/skills/continuing/evals/evals.json +9 -0
  62. package/skills/creating-intent/SKILL.md +16 -2
  63. package/skills/creating-intent/evals/evals.json +16 -0
  64. package/skills/creating-intent/references/lifecycle.md +9 -4
  65. package/skills/creating-skills/SKILL.md +65 -0
  66. package/skills/creating-skills/evals/evals.json +108 -0
  67. package/skills/creating-skills/references/agents.md +168 -0
  68. package/skills/creating-skills/references/evals.md +41 -0
  69. package/skills/creating-skills/references/hooks.md +248 -0
  70. package/skills/creating-skills/references/progressive-disclosure.md +176 -0
  71. package/skills/creating-skills/references/scripts.md +166 -0
  72. package/skills/creating-skills/references/skills.md +165 -0
  73. package/skills/creating-skills/scripts/scaffold.rb +313 -0
  74. package/skills/dashboard/SKILL.md +5 -0
  75. package/skills/dashboard/evals/evals.json +22 -0
  76. package/skills/doctor/SKILL.md +4 -1
  77. package/skills/executing-plan/SKILL.md +4 -4
  78. package/skills/humanizer/SKILL.md +39 -0
  79. package/skills/humanizer/evals/evals.json +70 -0
  80. package/skills/humanizer/references/always-on-snippet.md +9 -0
  81. package/skills/humanizer/references/examples.md +48 -0
  82. package/skills/intent-curator/SKILL.md +7 -1
  83. package/skills/intent-curator/evals/evals.json +22 -0
  84. package/skills/intent-discovery/SKILL.md +46 -0
  85. package/skills/intent-starting/SKILL.md +127 -0
  86. package/skills/intent-starting/evals/evals.json +117 -0
  87. package/skills/intent-starting/references/boarding-matrix.md +35 -0
  88. package/skills/linking-intents/SKILL.md +54 -12
  89. package/skills/linking-intents/evals/evals.json +22 -0
  90. package/skills/linking-intents/references/zettelkasten.md +7 -0
  91. package/skills/lock/SKILL.md +41 -0
  92. package/skills/managing-index/SKILL.md +12 -0
  93. package/skills/managing-index/evals/evals.json +22 -0
  94. package/skills/managing-index/references/zettelkasten-linking.md +6 -1
  95. package/skills/releasing/SKILL.md +32 -0
  96. package/skills/research/SKILL.md +8 -0
  97. package/skills/research/evals/evals.json +22 -0
  98. package/templates/config.yml +8 -0
  99. package/templates/outcome.md +3 -0
  100. package/templates/revisions.md +58 -0
  101. package/skills/writing-instructions/SKILL.md +0 -159
  102. package/skills/writing-instructions/references/agentskills-spec.md +0 -135
package/scripts/qmd-sync CHANGED
@@ -13,7 +13,10 @@
13
13
  # qmd-sync register --store <dir> # register one store as a collection
14
14
  # qmd-sync register --all # register the global store + all projects
15
15
  # qmd-sync reindex --store <dir> # update + embed that store's collection
16
+ # qmd-sync reindex --store <dir> --async # same, detached/non-blocking
16
17
  # qmd-sync status [--format json] # read-only status
18
+ # qmd-sync search "<terms>" [--store <dir>] [--limit N] [--min-score F]
19
+ # # ranked store search; scope by --store or CWD
17
20
  #
18
21
  # --home <path> overrides the Plastic home (default: ~/.plastic).
19
22
 
@@ -71,8 +74,13 @@ when "register"
71
74
  when "reindex"
72
75
  dir = opt(ARGV, "--store") or (warn("reindex: pass --store <dir>"); exit 2)
73
76
  collection = QmdSync.collection_name(dir, plastic_home: home)
74
- res = QmdSync.reindex(collection: collection)
75
- puts "reindexed #{collection} (#{res[:ok] ? "ok" : "warn"})"
77
+ if ARGV.include?("--async")
78
+ QmdSync.reindex_async(collection: collection)
79
+ puts "reindex (async) #{collection} (started)"
80
+ else
81
+ res = QmdSync.reindex(collection: collection)
82
+ puts "reindexed #{collection} (#{res[:ok] ? "ok" : "warn"})"
83
+ end
76
84
  exit 0
77
85
 
78
86
  when "status"
@@ -86,7 +94,46 @@ when "status"
86
94
  end
87
95
  exit 0
88
96
 
97
+ when "search"
98
+ # The query is the first bareword that is NOT the value of a known value-taking
99
+ # flag. Walk ARGV, skipping each such flag and the token right after it, then
100
+ # take the first remaining token that does not start with "--".
101
+ value_flags = %w[--store --limit --min-score --home]
102
+ query = nil
103
+ i = 0
104
+ while i < ARGV.length
105
+ tok = ARGV[i]
106
+ if value_flags.include?(tok)
107
+ i += 2
108
+ next
109
+ end
110
+ unless tok.start_with?("--")
111
+ query = tok
112
+ break
113
+ end
114
+ i += 1
115
+ end
116
+ collections =
117
+ if (dir = opt(ARGV, "--store"))
118
+ [QmdSync.collection_name(dir, plastic_home: home), "plastic-global"].uniq
119
+ else
120
+ QmdSync.collections_for_cwd(Dir.pwd, plastic_home: home)
121
+ end
122
+ limit = (opt(ARGV, "--limit") || "5").to_i
123
+ min_score = (opt(ARGV, "--min-score") || "0.5").to_f
124
+ hits = QmdSync.search(query, collections: collections, limit: limit, min_score: min_score)
125
+ if hits.empty?
126
+ puts "no qmd hits"
127
+ else
128
+ hits.each do |h|
129
+ pct = (h[:score] * 100).round
130
+ path = h[:file].to_s.sub(%r{\Aqmd://}, "")
131
+ puts "[#{pct}%] #{path} - #{h[:title]}"
132
+ end
133
+ end
134
+ exit 0
135
+
89
136
  else
90
- warn "qmd-sync: unknown verb #{verb.inspect}. Use detect|register|reindex|status."
137
+ warn "qmd-sync: unknown verb #{verb.inspect}. Use detect|register|reindex|status|search."
91
138
  exit 2
92
139
  end
@@ -9,6 +9,7 @@
9
9
 
10
10
  require "yaml"
11
11
  require "json"
12
+ require_relative "lib/agent_models"
12
13
 
13
14
  DEFAULTS = {
14
15
  "version" => 3,
@@ -23,6 +24,9 @@ DEFAULTS = {
23
24
  "type" => "claude-code",
24
25
  "parallel_mode" => "linear"
25
26
  },
27
+ "agents" => {
28
+ "models" => AgentModels::TIER_DEFAULTS
29
+ },
26
30
  "architect" => {
27
31
  "style" => nil
28
32
  }
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # rebuild-graph — repair the store-wide sources/chain frontmatter graph across the
6
+ # global, plastic, and knowdb stores (intent 49). Deterministic, idempotent, and
7
+ # one-directional (intent 68 I-invariants): dedupe, I3 (formative edge wins), I1
8
+ # in-store backlinks, I2 preserved; cross-store refs resolved via a multi-hop
9
+ # relocation map (relocation wins over coincidental id reuse). Emits a
10
+ # before/after audit, then writes minimal style-preserving frontmatter.
11
+ #
12
+ # Usage:
13
+ # rebuild-graph [--plastic-home PATH] [--dry-run] [--audit-path PATH]
14
+ #
15
+ # Pure-Ruby (no bash). The pure logic lives in lib/graph_rebuild.rb and
16
+ # lib/frontmatter_writer.rb; this shell does only discovery, IO, and reporting.
17
+ # Never pushes ~/.plastic (no git ops here).
18
+
19
+ require "yaml"
20
+ require "date"
21
+ require "time"
22
+ require "fileutils"
23
+
24
+ require_relative "lib/graph_rebuild"
25
+ require_relative "lib/frontmatter_writer"
26
+ require_relative "lib/intent_validator"
27
+
28
+ class RebuildGraph
29
+ DEFAULT_HOME = File.join(Dir.home, ".plastic")
30
+
31
+ # The 49 intent dir audit destination (relative to plastic_home).
32
+ DEFAULT_AUDIT_REL =
33
+ "projects/plastic/store/49--store-wide-double-link-symmetry/resources/audit--graph-rebuild.md"
34
+
35
+ KIND_LABELS = {
36
+ dedupe: "Dedupes",
37
+ i3: "I3 resolutions (kept in sources, dropped from chain)",
38
+ repoint: "Cross-store repoints",
39
+ collapse: "Cross-store collapses (to bare same-store id)",
40
+ drop: "Dropped dead refs",
41
+ i1_backlink: "I1 backlinks added",
42
+ }.freeze
43
+
44
+ KIND_ORDER = %i[dedupe i3 repoint collapse drop i1_backlink].freeze
45
+
46
+ def initialize(plastic_home: DEFAULT_HOME, dry_run: false, audit_path: nil)
47
+ @plastic_home = plastic_home
48
+ @dry_run = dry_run
49
+
50
+ # A dry run must NOT stomp the canonical audit (the spec/checklist tell humans
51
+ # to run --dry-run to review the plan). When no explicit --audit-path is given,
52
+ # a dry run writes to a distinct `.dry-run.md` sibling, leaving the canonical
53
+ # real-run audit untouched. An explicit --audit-path is always honored verbatim
54
+ # (it is the caller's responsibility, and tests inject it).
55
+ canonical = File.join(plastic_home, DEFAULT_AUDIT_REL)
56
+ @audit_path =
57
+ if audit_path
58
+ audit_path
59
+ elsif dry_run
60
+ canonical.sub(/\.md\z/, ".dry-run.md")
61
+ else
62
+ canonical
63
+ end
64
+ end
65
+
66
+ attr_reader :plastic_home, :dry_run, :audit_path
67
+
68
+ # The three in-scope stores, each as { key:, root:, store:, index: }.
69
+ # `root` is the dir holding INDEX.md; `store` is the intents dir.
70
+ def stores
71
+ list = []
72
+ global_store = File.join(plastic_home, "store")
73
+ list << { key: "global", root: plastic_home, store: global_store,
74
+ index: File.join(plastic_home, "INDEX.md") } if File.directory?(global_store)
75
+
76
+ %w[plastic knowdb].each do |slug|
77
+ root = File.join(plastic_home, "projects", slug)
78
+ store = File.join(root, "store")
79
+ next unless File.directory?(store)
80
+
81
+ list << { key: "project:#{slug}", root: root, store: store,
82
+ index: File.join(root, "INDEX.md") }
83
+ end
84
+ list
85
+ end
86
+
87
+ # { id => { sources:, chain:, path: } } for one store.
88
+ def load_nodes(store_dir)
89
+ nodes = {}
90
+ Dir.children(store_dir).reject { |e| e.start_with?(".") }.sort.each do |entry|
91
+ dir = File.join(store_dir, entry)
92
+ next unless File.directory?(dir)
93
+
94
+ md = File.join(dir, "#{entry}.md")
95
+ next unless File.exist?(md)
96
+
97
+ fm = IntentValidator.parse_frontmatter(md)
98
+ next unless fm.is_a?(Hash) && fm["id"]
99
+
100
+ nodes[fm["id"].to_s] = {
101
+ sources: Array(fm["sources"]).map(&:to_s),
102
+ chain: Array(fm["chain"]).map(&:to_s),
103
+ path: md,
104
+ }
105
+ end
106
+ nodes
107
+ end
108
+
109
+ def run
110
+ store_list = stores
111
+ nodes_by_store = {}
112
+ index_texts = {}
113
+ store_index = {}
114
+
115
+ store_list.each do |s|
116
+ nodes_by_store[s[:key]] = load_nodes(s[:store])
117
+ index_texts[s[:key]] = File.exist?(s[:index]) ? File.read(s[:index]) : ""
118
+ store_index[s[:key]] = nodes_by_store[s[:key]].keys
119
+ end
120
+
121
+ relocation_map = GraphRebuild.build_relocation_map(index_texts)
122
+
123
+ results = {}
124
+ store_list.each do |s|
125
+ key = s[:key]
126
+ input = nodes_by_store[key].transform_values { |v| { sources: v[:sources], chain: v[:chain] } }
127
+ results[key] = GraphRebuild.rebuild_store(
128
+ input,
129
+ referer_store: key,
130
+ relocation_map: relocation_map,
131
+ store_index: store_index
132
+ )
133
+ end
134
+
135
+ write_back(store_list, nodes_by_store, results) unless dry_run
136
+ emit_audit(store_list, nodes_by_store, results)
137
+
138
+ results
139
+ end
140
+
141
+ # Write changed frontmatter back via the minimal style-preserving writer.
142
+ def write_back(store_list, nodes_by_store, results)
143
+ store_list.each do |s|
144
+ key = s[:key]
145
+ new_nodes = results[key][:nodes]
146
+ nodes_by_store[key].each do |id, original|
147
+ rebuilt = new_nodes[id]
148
+ next if rebuilt.nil?
149
+ next if rebuilt[:sources] == original[:sources] && rebuilt[:chain] == original[:chain]
150
+
151
+ content = File.read(original[:path])
152
+ updated = FrontmatterWriter.rewrite_arrays(content,
153
+ sources: rebuilt[:sources],
154
+ chain: rebuilt[:chain])
155
+ File.write(original[:path], updated) if updated != content
156
+ end
157
+ end
158
+ end
159
+
160
+ # Render the audit and write it (always, even in dry-run, so the human reviews
161
+ # the dry-run plan). Returns the rendered string.
162
+ def emit_audit(store_list, _nodes_by_store, results)
163
+ text = render_audit(store_list, results)
164
+ FileUtils.mkdir_p(File.dirname(audit_path))
165
+ File.write(audit_path, text)
166
+ text
167
+ end
168
+
169
+ # PURE-ish formatter (string from results). Per-store, grouped by kind.
170
+ def render_audit(store_list, results)
171
+ total = store_list.sum { |s| results[s[:key]][:changes].size }
172
+ lines = []
173
+ lines << "# Audit: store-wide sources/chain graph rebuild (intent 49)"
174
+ lines << ""
175
+ lines << "Generated: #{Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}#{dry_run ? " (DRY RUN)" : ""}"
176
+ lines << ""
177
+ lines << "Total changes across all stores: #{total}"
178
+ lines << ""
179
+
180
+ store_list.each do |s|
181
+ key = s[:key]
182
+ changes = results[key][:changes]
183
+ lines << "## #{key}"
184
+ lines << ""
185
+ if changes.empty?
186
+ lines << "No changes."
187
+ lines << ""
188
+ next
189
+ end
190
+
191
+ KIND_ORDER.each do |kind|
192
+ group = changes.select { |c| c[:kind] == kind }
193
+ next if group.empty?
194
+
195
+ lines << "### #{KIND_LABELS[kind]} (#{group.size})"
196
+ group.each { |c| lines << "- #{format_change(c)}" }
197
+ lines << ""
198
+ end
199
+ end
200
+
201
+ lines.join("\n") + "\n"
202
+ end
203
+
204
+ def format_change(c)
205
+ case c[:kind]
206
+ when :dedupe
207
+ "#{c[:intent]}: sources #{c[:before][:sources].inspect} → #{c[:after][:sources].inspect}, " \
208
+ "chain #{c[:before][:chain].inspect} → #{c[:after][:chain].inspect}"
209
+ when :i3
210
+ "#{c[:intent]}: #{c[:before]} kept in sources, dropped from chain"
211
+ when :repoint
212
+ "#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (relocated cross-store)"
213
+ when :collapse
214
+ "#{c[:intent]}.#{c[:field]}: #{c[:before]} → #{c[:after]} (collapsed to bare same-store id)"
215
+ when :drop
216
+ "#{c[:intent]}.#{c[:field]}: #{c[:before]} dropped (resolves nowhere)"
217
+ when :i1_backlink
218
+ "#{c[:intent]}.chain += #{c[:backlink]} (formative backlink)"
219
+ else
220
+ c.inspect
221
+ end
222
+ end
223
+ end
224
+
225
+ if $PROGRAM_NAME == __FILE__
226
+ home = RebuildGraph::DEFAULT_HOME
227
+ dry = false
228
+ audit = nil
229
+ i = 0
230
+ while i < ARGV.length
231
+ case ARGV[i]
232
+ when "--plastic-home" then home = ARGV[i + 1]; i += 2
233
+ when "--dry-run" then dry = true; i += 1
234
+ when "--audit-path" then audit = ARGV[i + 1]; i += 2
235
+ else i += 1
236
+ end
237
+ end
238
+
239
+ tool = RebuildGraph.new(plastic_home: home, dry_run: dry, audit_path: audit)
240
+ results = tool.run
241
+ total = results.values.sum { |r| r[:changes].size }
242
+ puts "rebuild-graph #{dry ? "DRY RUN" : "applied"}: #{total} change(s) across #{results.size} store(s)."
243
+ puts "Audit: #{tool.audit_path}"
244
+ end
@@ -41,10 +41,18 @@ REPORT_CONTRACT =
41
41
  "FINAL MESSAGE (your return value), not a side-channel file. Do not go idle or " \
42
42
  "finish silently. The report carries a common envelope: role, intent id, stage, " \
43
43
  "status (delivered or blocked), artifacts written, verification or tests run, " \
44
- "checklist deltas, deviations from spec, and blockers or handoff notes; plus a " \
44
+ "checklist deltas, deviations from spec, blockers or handoff notes, and an " \
45
+ "insights field carrying 0..N durable nuggets (what I discovered worth keeping, " \
46
+ "the most interesting residue of this turn; none if there were none); plus a " \
45
47
  "role-specific payload that fulfils your place in the What, Why, How, Exec cycle " \
46
- "(for example the planner explains the plan back to the orchestrator). See " \
47
- "skills/auto/references/agent-report-contract.md for the per-role format."
48
+ "(for example the planner explains the plan back to the orchestrator). Populate " \
49
+ "the insights field even when you cannot write the intent file yourself: a " \
50
+ "background or dispatched agent carries each nugget home in the report and the " \
51
+ "orchestrator persists it via scripts/insight-append, so an insight never " \
52
+ "depends on the discovering session's file-write access. Keep the report " \
53
+ "prose-stripped: the envelope and payload only, no greeting, no preamble, " \
54
+ "no end-recap, no restating of the task; reasoning stays in the thinking channel. " \
55
+ "See skills/auto/references/agent-report-contract.md for the per-role format."
48
56
 
49
57
  def parse_args(argv)
50
58
  role = nil
@@ -10,6 +10,12 @@ description: >-
10
10
 
11
11
  Announce: "Taking over intent [ID] — [name] for autonomous delivery."
12
12
 
13
+ **Advisory (not a gate).** At auto-mode start, recommend once that the user run this
14
+ orchestrating main session on the best available thinking model (Fable, Opus, or whatever
15
+ supersedes them) for the sharpest gating and synthesis. This is advice only: it changes no
16
+ behavior and blocks nothing if ignored. It concerns the human's MAIN session; dispatched
17
+ subagents keep their pinned tier and never resolve to Fable.
18
+
13
19
  ## Precondition
14
20
 
15
21
  An active intent MUST exist in INDEX.md. If none exists, refuse: "No active intent found. Create one first with /plastic-creating-intent."
@@ -28,6 +34,13 @@ Work `dispatchable_queue` in `rank` order (these are `defer`/`research` disposit
28
34
  safe to deliver autonomously). Leave `human_only` and `next_big_thing` for the user — those
29
35
  are `drive`/`triage` items the human should lead. See the `plastic-dashboard` skill.
30
36
 
37
+ QMD-first (when available): when the user describes the work to deliver rather than naming an
38
+ intent, before scanning the store with grep/Read run
39
+ `ruby ~/.plastic/scripts/qmd-sync search "<terms>"` to surface candidate, prior, or duplicate
40
+ intents, then open the authoritative intent file for the hit you take over. The command is a no-op
41
+ when QMD is absent, so fall back to the existing INDEX.md / file scan. (This is discovery; the
42
+ reindex step under Completion is separate.)
43
+
31
44
  ## Arm the Lifecycle Gate (do this FIRST)
32
45
 
33
46
  Immediately after selecting the intent — before any other work — arm auto mode. This
@@ -36,13 +49,23 @@ edited before the plan exists (the gate applies to YOU, the orchestrator):
36
49
 
37
50
  ```bash
38
51
  ruby -r ~/.plastic/scripts/lib/bridge -e \
39
- 'Bridge.arm_auto(ENV["CLAUDE_SESSION_ID"], intent_id: "<ID>", intent_dir: "<STORE>/<dir>", store: "<STORE>", name: "<name>")'
52
+ 'Bridge.arm_auto(ENV["CLAUDE_CODE_SESSION_ID"], intent_id: "<ID>", intent_dir: "<STORE>/<dir>", store: "<STORE>", name: "<name>")'
40
53
  ```
41
54
 
42
55
  Replace `<ID>`, `<STORE>` (e.g. `~/.plastic/projects/<slug>/store` or `~/.plastic/store`),
43
- `<dir>` (the `ID--slug` directory), and `<name>`. If `CLAUDE_SESSION_ID` is unset, `arm_auto`
44
- falls back to a deterministic derived bridge key (a hash of the store and intent id), so the
45
- gate still engages; arming prints a one-line notice to stderr in that case.
56
+ `<dir>` (the `ID--slug` directory), and `<name>`. The first argument is the session id you
57
+ want the bridge keyed by: pass the hook stdin `session_id` when you have it, otherwise
58
+ `ENV["CLAUDE_CODE_SESSION_ID"]`, otherwise `nil`. `arm_auto` calls `resolve_session`, which
59
+ picks the first non-empty of: the explicit id you pass -> `CLAUDE_CODE_SESSION_ID` -> a
60
+ deterministic derived key (a hash of the store and intent id).
61
+ It never returns nil, so the gate engages even when every session env var is empty; the call
62
+ never needs a non-empty session env var to function. Arming prints a one-line notice to
63
+ stderr when it falls through to the derived key.
64
+
65
+ Arming acquires the durable `delivery.lock` in the intent dir, keyed by that resolved
66
+ session. Ownership is session-keyed, not process-keyed, so the arm one-liner exiting
67
+ immediately is fine by construction: the lock stays yours for every later tool call in this
68
+ session. A failed arm raises with a message naming the resolving `plastic-lock` verb.
46
69
 
47
70
  **Hard rule for the rest of this run:** do NOT edit project code (anything outside the
48
71
  intent directory / `~/.plastic/`) until `plan.md` AND `checklist.md` exist for the intent.
@@ -68,17 +91,46 @@ Dispatch rule: sequential, one specialist per stage on one branch (the deliverab
68
91
 
69
92
  Spawn preamble (live-state injection): before dispatching any specialist, run `scripts/spawn-preamble <intent_dir> --role <role>` and PREPEND its output to that specialist's prompt. The preamble is a deterministic, filesystem-only snapshot of the active intent (id, intent line, current stage) plus the honoring instruction, so every spawned agent boots with accurate live state instead of guessing. This is the authoritative L2 mechanism for harnesses whose sub-agents do not inherit a top-level session event (see `docs/reference/harness-adapters.md`).
70
93
 
94
+ Dispatch-time model contract (belt-and-braces): alongside the preamble, resolve each specialist's model through the config chain (`read-config agents.models.<basename> --project <repo>`: project override, then global, then the shipped tier default) and pass it explicitly at dispatch. Never rely on the dispatched role's frontmatter alone; a resolved subagent model is never Fable.
95
+
71
96
  Completion report (require-then-synthesize): every dispatched specialist MUST end with a structured completion report as its final message. The preamble's `REPORT_CONTRACT` injects this and the role prompts carry the per-role format (see `references/agent-report-contract.md`). Because child-agent honor is best-effort across harnesses, this is decision-shaping, not a hard block. When a specialist returns no usable report (it went idle, emitted only a bare ping, or its message was lost to a mid-run interjection), run `scripts/agent-report <intent_dir> --role <role>` to synthesize a deterministic filesystem-derived report so the handoff account always exists. Use the agent-authored report when present, the synthesized one otherwise.
72
97
 
73
98
  Final-gate review: dispatch an independent reviewer subagent at the final gate only, not as a standing role.
74
99
 
75
- Headless manual gate: when running headless or in the background, enforce gates manually and do not rely on hooks, because `CLAUDE_SESSION_ID` may be unset (this ties to the arm-gate fallback above).
100
+ ### Delegation (subagents writing under the owner's lock)
101
+
102
+ The enforcer's session owns the delivery lock. Per-stage specialists run in
103
+ their own sessions and would be denied by the lock gate, so register each one
104
+ as a delegate before (or when) it needs to write into the intent dir:
105
+
106
+ 1. Instruct each spawned specialist to report its session id
107
+ (`CLAUDE_CODE_SESSION_ID`) in its first message.
108
+ 2. As the lock owner, run:
109
+ `ruby ~/.plastic/scripts/plastic-lock delegate --delegate <specialist-session-id>`
110
+ 3. If a specialist hits a lock-gate deny, the deny message names this exact
111
+ command; run it and have the specialist retry.
112
+
113
+ Only the owner can delegate. Delegates cannot re-delegate or release.
114
+
115
+ Headless manual gate: when running headless or in the background, still enforce gates manually rather than relying on hooks alone. The PostToolUse gate hook reads `session_id` from hook stdin, and the savepoint ledger write is decoupled from the bridge (derived from the file path, so it fires even with no session id) - these do NOT no-op. What can degrade is the bridge-keyed stage enforcement: if no session id reaches the bridge and no matching bridge is discovered, the stage-gate enforcement step exits without acting, so verify state yourself. The bridge still resolves arming via `CLAUDE_CODE_SESSION_ID` or the derived-key fallback (see the arm-gate note above).
76
116
 
77
117
  Solo fallback: if the harness has no subagent dispatch, fall back to a single agent walking the full What, Why, How, Exec cycle yourself. This preserves current behavior.
78
118
 
79
119
  ## Stage-Aware Entry
80
120
 
81
- Read the active intent's directory. Determine current lifecycle stage from filesystem state:
121
+ Read the active intent's `savepoint.md` FIRST (intent 81): the last line classifies the stage,
122
+ and you then verify only that line's artifact before entering. Fall back to the filesystem probe
123
+ below only when the ledger is missing (then rebuild it with `Bridge.rebuild_savepoint`).
124
+
125
+ | Ledger last line | Enter |
126
+ |---|---|
127
+ | `What {id}--{slug}.md` (born) or no spec | Start / complete Why (write spec.md) |
128
+ | `Why spec.md created` | Enter How |
129
+ | `How plan.md created` / `How checklist.md created` / `Exec started` | Enter Exec (verify plan + checklist) |
130
+ | `Exec outcome.md created` | Exec done; complete the intent |
131
+ | `Done delivered|abandoned` | Terminal; do not resume |
132
+
133
+ Filesystem fallback (ledger missing only):
82
134
 
83
135
  | Check (in order) | Stage |
84
136
  |---|---|
@@ -165,7 +217,10 @@ During initial project creation, all decisions are non-destructive by definition
165
217
  ## Completion
166
218
 
167
219
  1. Verify all checklist items are checked
168
- 2. Write `outcome.md` with detailed results
220
+ 2. Write `outcome.md` with detailed results, from `${CLAUDE_PLUGIN_ROOT}/templates/outcome.md`.
221
+ Set the frontmatter `disposition: delivered` (this is the delivered terminal). `outcome.md`
222
+ is mandatory at every terminal and self-declares its disposition (see the canonical done-marker
223
+ and End tail in PLASTIC.md `## Delivery Isolation and the Single-Owner Lock`).
169
224
  3. Write `## Outcome` summary in the intent file (1-2 sentences)
170
225
  4. **Release (if configured)**
171
226
  1. Detect project — match CWD against paths in `~/.plastic/projects.yml` to find the project slug. If no match, skip to step 5 (default commit-only behavior).
@@ -184,20 +239,47 @@ During initial project creation, all decisions are non-destructive by definition
184
239
  5. Review `## Insights` for observations that should spawn future intents. If any:
185
240
  - Create them (using `plastic-creating-intent` conventions)
186
241
  - Update `chain` in the current intent's frontmatter
187
- 6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date)
242
+ 6. Move intent from `## Active` to `## Completed` in INDEX.md (with today's date). As the
243
+ closing act of the transfer, stamp the terminal ledger bookend (intent 81) so the savepoint's
244
+ last line records delivery:
245
+ ```bash
246
+ ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.append_terminal_savepoint("<intent_dir>", "delivered")'
247
+ ```
248
+ (Use `"abandoned"` instead when the intent is being moved to `## Abandoned`.) Idempotent.
188
249
  7. Auto-commit: `cd <store-root> && git add . && git commit -m "feat: deliver intent <ID> — <name>"`
189
- 8. Refresh the QMD search index for this store (optional, no-op when QMD is absent):
250
+ 8. Disarm the lifecycle gate (auto delivery is finished):
190
251
  ```bash
191
- ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root>
252
+ ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_CODE_SESSION_ID"])'
192
253
  ```
193
- Delivery is the lifecycle event that keeps the search index fresh. `<store-root>` is the
194
- store that holds this intent (the global store or the project store).
195
- 9. Disarm the lifecycle gate (auto delivery is finished):
254
+ Disarm runs the ordered End tail: it releases the worktrees first, then clears the
255
+ intent's `delivery.lock` (and the bridge's lock cache), and only then is the bridge
256
+ purge-eligible. Disarming also purges stale bridge files from the temp directory
257
+ automatically (it keeps the current bridge, any live run, and any bridge whose intent
258
+ still holds a delivery lock), so no manual `/tmp` cleanup is needed.
259
+
260
+ **Worktree cleanup (mandatory, intent 73c3).** Disarming performs the worktree release:
261
+ `disarm_auto` calls `Worktree.release`, which removes both per-intent worktrees (the code
262
+ worktree under `<repo>/.claude/worktrees/{id}--{slug}` and the paired store worktree under
263
+ `<plastic_home>/.worktrees/{id}--{slug}`), prunes both repos, and clears the worktree block
264
+ from the bridge. This is the plain remove path: the disarm route does NOT merge, so use it
265
+ only when no release merges the branch (the branch survives and can be reclaimed).
266
+
267
+ When the work is being shipped through a release, do NOT rely on this plain remove. The
268
+ release path (step 4 above, via `plastic-releasing`) is responsible for merging the intent's
269
+ code branch (`plastic/{id}--{slug}`) back to the repo's default branch BEFORE the worktree is
270
+ removed, so the integrated work is not lost. It does this with `Worktree.finish(bridge_data,
271
+ merge: true)` (merge-then-remove). Never leave an orphaned worktree, and run `git worktree
272
+ prune` if you hit a stale reference.
273
+ 9. QMD reindex LAST (canonical End tail). AFTER disarm has released the worktrees, cleared the
274
+ `delivery.lock`, and purged the bridge, refresh the QMD search index for this store (no-op when
275
+ QMD is absent). It runs in the background so it never blocks the turn:
196
276
  ```bash
197
- ruby -r ~/.plastic/scripts/lib/bridge -e 'Bridge.disarm_auto(ENV["CLAUDE_SESSION_ID"])'
277
+ ruby ~/.plastic/scripts/qmd-sync reindex --store <store-root> --async
198
278
  ```
199
- Disarming also purges stale bridge files from the temp directory automatically (it keeps the
200
- current bridge and any live run), so no manual `/tmp` cleanup is needed.
279
+ Completion is the lifecycle event that keeps the search index fresh. `<store-root>` is the store
280
+ that holds this intent (the global store or the project store). The reindex is the LAST End-tail
281
+ step, run after purge, so the index never references a bridge or lock that is about to disappear
282
+ (see PLASTIC.md `## Delivery Isolation and the Single-Owner Lock`).
201
283
  10. Notify user: "Intent [ID] — [name] delivered. [1-2 sentence summary]. See outcome.md for details."
202
284
 
203
285
  ## Error Handling
@@ -202,6 +202,54 @@
202
202
  "result": "pass"
203
203
  }
204
204
  ]
205
+ },
206
+ {
207
+ "id": 12,
208
+ "scope": "behavior",
209
+ "set": "validation",
210
+ "prompt": "A power-tool is present (qmd on PATH, or a .serena marker / serena on PATH). A substantive prompt arrives in auto mode.",
211
+ "expected_output": "The UserPromptSubmit power-tools hook appends a MANDATORY obligation per present tool: a MUST-use-QMD line when qmd is present (to check for an existing or related intent before treating work as new), and a MUST-use-Serena line when serena is present (symbolic tools before grep/Read). QMD hits are still injected when above threshold.",
212
+ "files": [],
213
+ "assertions": [
214
+ {
215
+ "type": "code",
216
+ "check": "PowerTools.mandate returns MUST/MANDATORY lines for each present tool; QmdHook.run appends the mandate",
217
+ "observed": "power_tools_test.rb + qmd_hook_test.rb assert MUST wording; serena line gated on the serena detector",
218
+ "result": "pass"
219
+ }
220
+ ]
221
+ },
222
+ {
223
+ "id": 13,
224
+ "scope": "behavior",
225
+ "set": "validation",
226
+ "prompt": "Neither qmd nor serena is present (no qmd on PATH, no .serena marker, no serena on PATH). A substantive prompt arrives.",
227
+ "expected_output": "Detect-then-degrade: the hook emits nothing (silent no-op, exit 0). No mandate text appears. Nothing is required to install.",
228
+ "files": [],
229
+ "assertions": [
230
+ {
231
+ "type": "code",
232
+ "check": "PowerTools.mandate returns nil and QmdHook.run returns nil when neither tool is present",
233
+ "observed": "power_tools_test.rb test_mandate_neither_is_nil + qmd_hook_test.rb test_nil_when_neither_tool_present",
234
+ "result": "pass"
235
+ }
236
+ ]
237
+ },
238
+ {
239
+ "id": 14,
240
+ "scope": "behavior",
241
+ "set": "validation",
242
+ "prompt": "QMD is present. In auto mode the user says: deliver the work on the uploader retry policy (no intent id given).",
243
+ "expected_output": "Before scanning the store with grep/Read to find the matching intent, runs `ruby ~/.plastic/scripts/qmd-sync search \"uploader retry policy\"` to surface the candidate intent, then opens the authoritative intent file for the hit it takes over. This discovery step is distinct from the completion-time reindex step. No-op fallback to INDEX.md / file scan when QMD is absent.",
244
+ "files": [],
245
+ "assertions": [
246
+ {
247
+ "type": "human",
248
+ "check": "qmd-sync search is run before grep/Read during discovery; authoritative file opened for the hit; reindex step stays separate",
249
+ "observed": "SKILL.md (or agent file) carries the QMD-first step: run qmd-sync search before grep/Read, then open the authoritative file; no-op fallback when QMD is absent",
250
+ "result": "pass"
251
+ }
252
+ ]
205
253
  }
206
254
  ]
207
255
  }
@@ -90,10 +90,13 @@ permanent sixth role, it exists only for the final review.
90
90
 
91
91
  ### Headless Manual Gate
92
92
 
93
- When running headless or in the background, the enforcer enforces gates manually and
94
- does not rely on hooks, because `CLAUDE_SESSION_ID` may be unset in those runs (the
95
- gate-check and savepoint hooks no-op without it). The enforcer arms via the bridge's
96
- derived-key fallback and verifies state itself.
93
+ When running headless or in the background, the enforcer enforces gates manually rather
94
+ than relying on hooks alone. The savepoint ledger and PostToolUse gate hook still fire
95
+ (the gate hook reads `session_id` from stdin; the savepoint write is path-derived and
96
+ bridge-independent), so they do not blanket no-op. Only the bridge-keyed stage-enforcement
97
+ step degrades when no session id reaches the bridge and no bridge is discovered. The
98
+ enforcer arms via `CLAUDE_CODE_SESSION_ID` or the bridge's derived-key fallback and
99
+ verifies state itself.
97
100
 
98
101
  ### Delegation
99
102