@zalom/plastic 1.0.0 → 1.0.1
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/README.md +2 -3
- package/hooks/code-gate +7 -1
- package/package.json +1 -1
- package/scripts/hook-code-gate +19 -1
- package/scripts/lib/installer_core.rb +1 -0
- package/scripts/lib/qmd_sync.rb +33 -6
- package/scripts/new-intent +49 -16
- package/scripts/spawn-preamble +25 -1
- package/skills/auto/SKILL.md +1 -1
- package/skills/roadmap/references/operations.md +10 -0
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
|
|
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.
|
|
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
package/scripts/hook-code-gate
CHANGED
|
@@ -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:
|
|
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",
|
package/scripts/lib/qmd_sync.rb
CHANGED
|
@@ -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)
|
|
46
|
-
# project store (<.../projects/<slug>/store)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ---
|
package/scripts/new-intent
CHANGED
|
@@ -73,15 +73,28 @@ def default_templates_dir
|
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def render_tokens(text, tokens)
|
|
76
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
if
|
|
100
|
-
|
|
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
|
-
|
|
138
|
+
lines.insert(chain_idx + 1 + entry_lines.length, "#{prefix}#{quote}#{new_id}#{quote}\n")
|
|
139
|
+
end
|
|
105
140
|
|
|
106
|
-
|
|
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],
|
package/scripts/spawn-preamble
CHANGED
|
@@ -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
|
-
|
|
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 << ""
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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`.
|