@zalom/plastic 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PLASTIC.md CHANGED
@@ -113,9 +113,9 @@ entries stay append-only, newest at the bottom; the prefix only stamps each line
113
113
  which stage, and who.
114
114
 
115
115
  The blessed write path is the `insight-append` helper
116
- (`scripts/insight-append <intent_dir> <text> --stage S --author A`), which formats the prefix,
117
- validates it, and appends at the bottom. Hand-editing `## Insights` is an escape hatch; the
118
- helper is the default so the format cannot drift.
116
+ (`scripts/insight-append <intent_dir> <text> --stage S --author A`), which ships with every
117
+ install and update, formats the prefix, validates it, and appends at the bottom. Hand-editing
118
+ `## Insights` is an escape hatch; the helper is the default so the format cannot drift.
119
119
 
120
120
  Background sessions and dispatched sub-agents do not write the insight themselves. They carry
121
121
  each nugget home in the completion report's `insights:` field, and the orchestrator (or any
@@ -191,6 +191,13 @@ adapter layer that maps Plastic's hooks and model aliases onto each supported ag
191
191
  (Claude, Codex, Hermes) is the cross-harness portability layer; see
192
192
  docs/reference/harness-adapters.md for the adapter contract.
193
193
 
194
+ **Spawn preamble (intent 152).** `scripts/spawn-preamble` emits a live-state block purely from
195
+ filesystem state: the active intent, stage, role/cycle-step, the honor instruction, and the
196
+ report contract. When the intent's code worktree is resolvable and exists on disk, it also
197
+ appends the worktree's absolute path plus a verbatim instruction to `cd` there directly, for
198
+ harnesses whose `EnterWorktree` cannot discover a nested repo from a non-repo launch directory.
199
+ Output is byte-identical when no worktree resolves.
200
+
194
201
  **Orchestrator advisory.** At auto-mode start, the orchestrator recommends once that the user
195
202
  run the main session on the best available thinking model (Fable, Opus, or whatever supersedes
196
203
  them). This is advisory only: it changes no behavior and blocks nothing if ignored, and it
@@ -343,7 +350,7 @@ ALL work flows through intents.
343
350
  3. On completion, capture observations in `## Insights`.
344
351
  4. When done, write `outcome.md` + `## Outcome` summary. Update INDEX.md.
345
352
  5. Researches are intents. No separate folder.
346
- 6. Intents are created only via `plastic-creating-intent`. Never hand-author an intent file. The skill self-verifies the written intent with `scripts/validate-intent` before announcing or committing, so every intent is born complete.
353
+ 6. Intents are created only via `plastic-creating-intent`. Never hand-author an intent file. The skill self-verifies the written intent with `scripts/validate-intent` before announcing or committing, so every intent is born complete. `--intent` text is escaped for double quotes and backslashes before it lands in frontmatter, so free-form text is safe to pass as-is, and a reciprocal `chain:` append preserves the target intent's existing flow- or block-style entries.
347
354
 
348
355
  ## House Style (self-check)
349
356
 
@@ -407,7 +414,9 @@ Each gate guards one thing. All are hard except the retrieval gate:
407
414
  `>` redirect, a `ruby -e` or `python -c` write), so the same rules apply whether an edit goes
408
415
  through the Write tool or a shell. A trailing `# plastic-ok` comment is an auditable escape that
409
416
  lets a deliberate command through, and every use is logged to
410
- `~/.plastic/.cache/gate-escapes.log`.
417
+ `~/.plastic/.cache/gate-escapes.log`. The code gate (Write and Edit) carries the identical
418
+ audited `# plastic-ok` escape, logged to the same file. The escape does not extend to
419
+ `NotebookEdit` or MCP structural edits: they are still gated, just without an escape hatch.
411
420
  - **retrieval-gate** is advisory only (see the Retrieval Gate section): it hints at QMD and never
412
421
  blocks a read or search.
413
422
 
package/README.md CHANGED
@@ -102,14 +102,13 @@ Plastic delivers all work through successive stages ->
102
102
 
103
103
  ## Compatibility and ownership
104
104
 
105
- - Native installers for Claude, Codex, Hermes, and all supported targets.
105
+ - Native installers for Claude (Codex, Hermes, and and others coming soon).
106
106
  - Plain Markdown plus Git. The work stays in files you own.
107
107
  - Personal stores by default.
108
108
  - Guided delivery with a human at every gate, or autonomous delivery when you
109
109
  ask for it.
110
110
 
111
- Plastic needs Ruby (already on macOS and Linux) and Node.js 18 or later. Swap
112
- `--claude` for `--codex`, `--hermes`, or `--all` to match your agent. Bun
111
+ Plastic needs Ruby (already on macOS and Linux) and Node.js 18 or later. Bun
113
112
  users can run `bunx` in place of `npx`; Bun is never required.
114
113
 
115
114
  ## Built with Plastic
package/hooks/code-gate CHANGED
@@ -17,5 +17,11 @@ fi
17
17
 
18
18
  SESSION_ID=$(echo "$INPUT" | ruby -rjson -e 'data = JSON.parse(STDIN.read); puts data.dig("session_id") || ""' 2>/dev/null)
19
19
 
20
+ NEW_CONTENT=$(echo "$INPUT" | ruby -rjson -e '
21
+ data = JSON.parse(STDIN.read)
22
+ ti = data["tool_input"] || data["tool_params"] || {}
23
+ print ti["content"] || ti["new_string"] || ""
24
+ ' 2>/dev/null)
25
+
20
26
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
21
- ruby "$SCRIPT_DIR/../scripts/hook-code-gate" "$FILE_PATH" "$SESSION_ID"
27
+ ruby "$SCRIPT_DIR/../scripts/hook-code-gate" "$FILE_PATH" "$SESSION_ID" "$NEW_CONTENT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,8 +20,26 @@ exit 0 unless file_path && !file_path.empty?
20
20
 
21
21
  session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
22
22
 
23
+ # Auditable escape (intent 150, mirrors the Bash gate's intent 108 D7): a trailing
24
+ # `# plastic-ok` in the new Write/Edit content allows the edit and logs it, so a
25
+ # deliberately sanctioned code edit no longer needs the Bash-funneling workaround.
26
+ new_content = ARGV[2]
27
+ if new_content && Bridge::PLASTIC_OK_RE.match?(new_content.chomp)
28
+ begin
29
+ require "fileutils"
30
+ log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
31
+ FileUtils.mkdir_p(File.dirname(log))
32
+ File.open(log, "a") do |io|
33
+ io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{file_path}")
34
+ end
35
+ rescue StandardError
36
+ # the escape still applies; logging is best-effort
37
+ end
38
+ exit 0
39
+ end
40
+
23
41
  # --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
24
- bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
42
+ bridge_data = Bridge.discover_bridge(session: session, cwd: file_path)
25
43
  exit 0 unless bridge_data
26
44
 
27
45
  reason = Bridge.code_gate_decision(bridge_data, file_path) ||
@@ -254,6 +254,7 @@ class InstallerCore
254
254
  "scripts/lib/hook_registry.rb" => "scripts/lib/hook_registry.rb",
255
255
  "scripts/agent-report" => "scripts/agent-report",
256
256
  "scripts/lib/insights.rb" => "scripts/lib/insights.rb",
257
+ "scripts/insight-append" => "scripts/insight-append",
257
258
  "scripts/lib/worktree.rb" => "scripts/lib/worktree.rb",
258
259
  "scripts/lib/boot_banner.rb" => "scripts/lib/boot_banner.rb",
259
260
  "scripts/lib/dashboard_banner.rb" => "scripts/lib/dashboard_banner.rb",
@@ -42,8 +42,10 @@ module QmdSync
42
42
  end
43
43
 
44
44
  # Collection name for a store directory.
45
- # global store (<plastic_home>/store) -> "plastic-global"
46
- # project store (<.../projects/<slug>/store) -> "plastic-<slug>"
45
+ # global store (<plastic_home>/store) -> "plastic-global"
46
+ # project store (<.../projects/<slug>/store) -> "plastic-<slug>"
47
+ # roadmaps dir (sibling of either store above) -> the sibling store's
48
+ # collection name, suffixed "-roadmaps"
47
49
  # Slug is resolved from projects.yml by matching the project path; falls back
48
50
  # to the directory's parent name when no registry match exists.
49
51
  def collection_name(store_dir, plastic_home:)
@@ -51,12 +53,19 @@ module QmdSync
51
53
  global_store = File.expand_path(File.join(plastic_home, "store"))
52
54
  return "plastic-global" if store_dir == global_store
53
55
 
56
+ if File.basename(store_dir) == "roadmaps"
57
+ sibling_store = File.join(File.dirname(store_dir), "store")
58
+ return "#{collection_name(sibling_store, plastic_home: plastic_home)}-roadmaps"
59
+ end
60
+
54
61
  slug = slug_for_store(store_dir, plastic_home: plastic_home)
55
62
  "plastic-#{slug}"
56
63
  end
57
64
 
58
65
  # Every store Plastic knows about: the global store plus each registered
59
- # project store. Returns [{collection:, dir:}, ...].
66
+ # project store, plus a roadmap-companion entry per tier when that tier's
67
+ # sibling `roadmaps/` directory exists on disk (intent 135). Returns
68
+ # [{collection:, dir:}, ...].
60
69
  def enumerate_stores(plastic_home:)
61
70
  stores = [{
62
71
  collection: "plastic-global",
@@ -75,7 +84,12 @@ module QmdSync
75
84
  dir = Dir.exist?(mirror_store) ? mirror_store : project_store
76
85
  stores << { collection: "plastic-#{slug}", dir: dir }
77
86
  end
78
- stores
87
+
88
+ stores.flat_map do |s|
89
+ roadmaps_dir = File.join(File.dirname(s[:dir]), "roadmaps")
90
+ next [s] unless Dir.exist?(roadmaps_dir)
91
+ [s, { collection: "#{s[:collection]}-roadmaps", dir: roadmaps_dir }]
92
+ end
79
93
  end
80
94
 
81
95
  # Register a store directory as a collection. Idempotent: re-running is safe.
@@ -185,7 +199,12 @@ module QmdSync
185
199
  # project's collection plus plastic-global, or just plastic-global when the
186
200
  # CWD is not inside any registered project. Project match = CWD equals the
187
201
  # registered path or is nested under it.
188
- def collections_for_cwd(cwd, plastic_home:)
202
+ #
203
+ # Each collection's roadmap companion (intent 135) is appended right after it
204
+ # when actually registered (checked via the injected runner's `collection
205
+ # list`), because `qmd search -c <unregistered-collection>` hard-fails the
206
+ # whole call, so an unregistered companion must never be added blindly.
207
+ def collections_for_cwd(cwd, plastic_home:, runner: default_runner, detector: method(:detect))
189
208
  cwd = File.expand_path(cwd)
190
209
  projects = load_projects(plastic_home)
191
210
  slug, = projects.find do |_s, info|
@@ -194,7 +213,15 @@ module QmdSync
194
213
  root = File.expand_path(path)
195
214
  cwd == root || cwd.start_with?(root + File::SEPARATOR)
196
215
  end
197
- slug ? ["plastic-#{slug}", "plastic-global"] : ["plastic-global"]
216
+
217
+ base = slug ? ["plastic-#{slug}", "plastic-global"] : ["plastic-global"]
218
+ return base unless detector.call
219
+
220
+ registered = list_collections(runner)
221
+ base.flat_map do |c|
222
+ roadmap = "#{c}-roadmaps"
223
+ registered.include?(roadmap) ? [c, roadmap] : [c]
224
+ end
198
225
  end
199
226
 
200
227
  # --- internals ---
@@ -73,15 +73,28 @@ def default_templates_dir
73
73
  end
74
74
 
75
75
  def render_tokens(text, tokens)
76
- tokens.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}", v.to_s) }
76
+ # Block-form gsub: a String replacement argument reinterprets backslash
77
+ # sequences (so a doubled backslash from escape_double_quoted_scalar
78
+ # collapses back to one); the block form substitutes the value literally.
79
+ tokens.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}") { v.to_s } }
80
+ end
81
+
82
+ # Escape the characters that would break a double-quoted YAML scalar: backslash
83
+ # first, so a real backslash in the input isn't re-escaped by the quote step,
84
+ # then the double quote itself. Used only for the frontmatter-bound INTENT
85
+ # token; the body's DESCRIPTION copy stays raw (markdown prose, not a YAML
86
+ # scalar) so escaping it would corrupt what the user actually typed.
87
+ def escape_double_quoted_scalar(value)
88
+ value.to_s.gsub("\\") { "\\\\" }.gsub('"') { '\"' }
77
89
  end
78
90
 
79
91
  # Add an id to a source intent's frontmatter `chain` array, idempotently (I1
80
92
  # reciprocity: `child in parent.sources` => `parent.chain` gains `child`). A
81
- # targeted edit of the `chain:` line only; the body (including `## Links`) is
93
+ # targeted edit that preserves every existing entry and appends the new id in
94
+ # the SAME on-disk YAML style (flow `chain: [...]` or block `chain:` + indented
95
+ # `- "id"` lines) the target already uses; the body (including `## Links`) is
82
96
  # preserved byte-for-byte and no other frontmatter key is touched, so the file
83
- # stays born-complete. Renders the array in flow style (`["a", "b"]`) to match
84
- # templates/intent.md. No-op when the id is already present.
97
+ # stays born-complete. No-op when the id is already present.
85
98
  def add_to_chain(file_path, new_id)
86
99
  return unless File.exist?(file_path)
87
100
  content = File.read(file_path)
@@ -91,21 +104,41 @@ def add_to_chain(file_path, new_id)
91
104
  return unless parts.length >= 3
92
105
 
93
106
  fm = parts[1]
94
- chain_line = fm.lines.find { |l| l.match?(/\A\s*chain\s*:/) }
95
- return unless chain_line
107
+ lines = fm.lines
108
+ chain_idx = lines.index { |l| l.match?(/\A\s*chain\s*:/) }
109
+ return unless chain_idx
110
+
111
+ flow_match = lines[chain_idx].match(/\A\s*chain\s*:\s*\[(.*?)\]\s*\z/m)
96
112
 
97
- existing = fm.match(/\bchain\s*:\s*\[(.*?)\]/m)
98
- ids =
99
- if existing
100
- existing[1].scan(/"([^"]*)"|'([^']*)'/).flatten.compact
113
+ if flow_match
114
+ ids = flow_match[1].scan(/"([^"]*)"|'([^']*)'/).flatten.compact
115
+ return if ids.include?(new_id)
116
+
117
+ ids << new_id
118
+ lines[chain_idx] = "chain: [#{ids.map { |i| "\"#{i}\"" }.join(", ")}]\n"
119
+ else
120
+ entry_re = /\A(\s*-\s*)(?:"([^"]*)"|'([^']*)'|(\S+))\s*\z/
121
+ entry_lines = []
122
+ j = chain_idx + 1
123
+ while j < lines.length && lines[j].match?(entry_re)
124
+ entry_lines << lines[j]
125
+ j += 1
126
+ end
127
+
128
+ ids = entry_lines.map { |l| (m = l.match(entry_re)) && (m[2] || m[3] || m[4]) }
129
+ return if ids.include?(new_id)
130
+
131
+ if entry_lines.empty?
132
+ prefix, quote = " - ", "\""
101
133
  else
102
- []
134
+ m = entry_lines.last.match(entry_re)
135
+ prefix = m[1]
136
+ quote = m[2] ? "\"" : (m[3] ? "'" : "")
103
137
  end
104
- return if ids.include?(new_id)
138
+ lines.insert(chain_idx + 1 + entry_lines.length, "#{prefix}#{quote}#{new_id}#{quote}\n")
139
+ end
105
140
 
106
- ids << new_id
107
- rendered = "chain: [#{ids.map { |i| "\"#{i}\"" }.join(", ")}]"
108
- new_fm = fm.sub(/^\s*chain\s*:.*$/, rendered)
141
+ new_fm = lines.join
109
142
  File.write(file_path, ["", new_fm, parts[2]].join("---"))
110
143
  end
111
144
 
@@ -260,7 +293,7 @@ def main(argv)
260
293
  intent_template = File.read(File.join(templates, "intent.md"))
261
294
  intent_body = render_tokens(intent_template, {
262
295
  "ID" => id,
263
- "INTENT" => opts[:intent],
296
+ "INTENT" => escape_double_quoted_scalar(opts[:intent]),
264
297
  "SOURCES" => sources_str,
265
298
  "DATE" => Date.today.iso8601,
266
299
  "AUTHOR" => opts[:author],
@@ -23,6 +23,7 @@
23
23
  # Exit codes: 0 (preamble emitted), 2 (usage).
24
24
 
25
25
  require_relative "lib/bridge"
26
+ require_relative "lib/worktree"
26
27
 
27
28
  # Verbatim honoring instruction. Kept as one constant so the contract doc and the
28
29
  # test assert against the exact same string.
@@ -31,6 +32,16 @@ HONOR_INSTRUCTION =
31
32
  "Emit VALID lifecycle artifacts; do not hallucinate intents or stages. " \
32
33
  "Your primary output is valid lifecycle artifacts; you close with a structured report about them."
33
34
 
35
+ # Verbatim cd-fallback instruction (intent 152). The harness's own EnterWorktree
36
+ # tool assumes cwd IS the repo root, which fails when a dispatched agent's
37
+ # session launches from a directory that is not itself a git repo (the normal
38
+ # shape for a background job): it cannot discover the nested project repo
39
+ # underneath it. Kept as one constant so the doc and the test assert against
40
+ # the exact same string.
41
+ WORKTREE_FALLBACK_INSTRUCTION =
42
+ "If EnterWorktree fails to discover the nested repo from a non-repo launch " \
43
+ "directory, cd directly to the code worktree path above before editing project code."
44
+
34
45
  # Verbatim completion-report contract (intent 74). Kept as one constant so the
35
46
  # contract doc (skills/auto/references/agent-report-contract.md), the role prompts,
36
47
  # and the test assert against the exact same string. SINGLE SOURCE OF TRUTH for the
@@ -123,7 +134,8 @@ end
123
134
  intent_dir = File.expand_path(intent_dir_arg)
124
135
 
125
136
  fm = frontmatter_for(intent_dir)
126
- id = fm["id"].to_s.strip
137
+ raw_id = fm["id"].to_s.strip
138
+ id = raw_id
127
139
  intent_name = fm["intent"].to_s.strip
128
140
  id = "(unknown)" if id.empty?
129
141
  intent_name = "(unknown)" if intent_name.empty?
@@ -131,12 +143,24 @@ intent_name = "(unknown)" if intent_name.empty?
131
143
  stage = current_stage(intent_dir)
132
144
  cycle = role || step || stage
133
145
 
146
+ # Already-provisioned code worktree path (intent 152), resolved purely from
147
+ # filesystem/YAML state (no git shell-out): see Worktree.paths's own doc
148
+ # comment. Fails open (no lines added) when unresolvable or not on disk.
149
+ store_root = File.dirname(intent_dir)
150
+ project_slug = Worktree.slug_for_store(store_root)
151
+ intent_slug = Worktree.slug_from_dir(intent_dir)
152
+ code_path = Worktree.paths(slug: project_slug, intent_id: raw_id, intent_slug: intent_slug)["code"]
153
+
134
154
  lines = []
135
155
  lines << "=== Plastic spawn preamble ==="
136
156
  lines << "Store: #{intent_dir}"
137
157
  lines << "Active intent: #{id} - #{intent_name}"
138
158
  lines << "Current stage: #{stage}"
139
159
  lines << "Cycle step / role: #{cycle}"
160
+ if code_path && Dir.exist?(code_path)
161
+ lines << "Code worktree: #{code_path}"
162
+ lines << WORKTREE_FALLBACK_INSTRUCTION
163
+ end
140
164
  lines << ""
141
165
  lines << HONOR_INSTRUCTION
142
166
  lines << ""
@@ -105,7 +105,7 @@ Roster (one role per cycle stage):
105
105
 
106
106
  Dispatch rule: sequential, one specialist per stage on one branch (the deliverables share files). Gate each deliverable against the stage's exit criteria before handing off. The How and Exec phases below default to Plastic's native dispatch (`plastic-executing-plan`) and delegate to the superpowers skills only when they are available or the user asks; do not restate the phase mechanics here.
107
107
 
108
- 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`).
108
+ 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, and the provisioned code worktree path when one exists on disk) 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`).
109
109
 
110
110
  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.
111
111
 
@@ -20,6 +20,8 @@ next" in under a minute, just from this one file.
20
20
  status mirroring that intent's current `INDEX.md` status.
21
21
  6. Append the first `## Log` line, a short `YYYY-MM-DD HH:MM UTC`-prefixed plain-language note
22
22
  that the roadmap was created.
23
+ 7. Refresh the QMD index for this roadmap (no-op when QMD is absent), in the background so it
24
+ never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <roadmaps-dir> --async`.
23
25
 
24
26
  ## Add / reorder entries
25
27
 
@@ -32,6 +34,8 @@ next" in under a minute, just from this one file.
32
34
  entry is eligible to run.
33
35
  - After any add/reorder, append a `## Log` line describing the change (e.g.
34
36
  `- <YYYY-MM-DD HH:MM UTC> added 132 to wave 2`).
37
+ - Refresh the QMD index for this roadmap (no-op when QMD is absent), in the background so it
38
+ never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <roadmaps-dir> --async`.
35
39
 
36
40
  ## Sync status mirror
37
41
 
@@ -45,6 +49,8 @@ next" in under a minute, just from this one file.
45
49
  one-line EM-to-CTO entry described in `file-format.md` (date, what shipped and its impact in
46
50
  plain language, then a link to that intent's `outcome.md`). For other transitions, write a
47
51
  short dated plain-language line (no codenames, no jargon).
52
+ 5. Refresh the QMD index for this roadmap (no-op when QMD is absent), in the background so it
53
+ never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <roadmaps-dir> --async`.
48
54
 
49
55
  ## Append a log line
50
56
 
@@ -54,6 +60,8 @@ next" in under a minute, just from this one file.
54
60
  A delivery event follows the EM-to-CTO one-line shape with an `outcome.md` link (see
55
61
  `file-format.md`); bookkeeping events (created, an intent added to a wave, a wave completed, a
56
62
  roadmap closed) are short dated plain-language lines.
63
+ - Refresh the QMD index for this roadmap (no-op when QMD is absent), in the background so it
64
+ never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <roadmaps-dir> --async`.
57
65
 
58
66
  ## Read / consume
59
67
 
@@ -76,3 +84,5 @@ next" in under a minute, just from this one file.
76
84
  lists only live (open or in-flight) roadmaps.
77
85
  4. Append the final `## Log` line before or as part of the move:
78
86
  `- <YYYY-MM-DD HH:MM UTC> roadmap closed`.
87
+ 5. Refresh the QMD index for this roadmap (no-op when QMD is absent), in the background so it
88
+ never blocks: `ruby ~/.plastic/scripts/qmd-sync reindex --store <roadmaps-dir> --async`.