@zalom/plastic 1.7.1 → 1.9.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.md +75 -535
- package/README.md +3 -2
- package/agents/plastic-intent-curator.md +2 -2
- package/deprecations.yml +10 -2
- package/hooks/{links-gate → edit-gates} +1 -1
- package/hooks/hooks.json +4 -44
- package/hooks/power-tools +8 -0
- package/package.json +1 -2
- package/scripts/codex-hook +17 -22
- package/scripts/doctor.rb +688 -186
- package/scripts/end-intent +65 -22
- package/scripts/hook-code-gate +13 -28
- package/scripts/hook-create-gate +10 -57
- package/scripts/hook-edit-gates +58 -0
- package/scripts/hook-links-gate +8 -31
- package/scripts/hook-lock-gate +13 -58
- package/scripts/hook-power-tools +38 -0
- package/scripts/hook-savepoint-pre +8 -11
- package/scripts/lib/bridge.rb +2 -2
- package/scripts/lib/edit_gates.rb +342 -0
- package/scripts/lib/hook_registry.rb +61 -29
- package/scripts/lib/installer_core.rb +100 -18
- package/scripts/lib/outcome_guard.rb +38 -0
- package/scripts/lib/qmd_hook.rb +21 -41
- package/scripts/lib/qmd_sync.rb +0 -15
- package/scripts/lib/revisions_writer.rb +1 -1
- package/scripts/maintenance-run +56 -2
- package/scripts/restore-intent-v1 +16 -11
- package/skills/auto/SKILL.md +15 -4
- package/skills/auto/evals/evals.json +2 -2
- package/skills/conventions/SKILL.md +31 -0
- package/skills/conventions/references/completion-and-done.md +40 -0
- package/skills/conventions/references/gates-and-enforcement.md +23 -0
- package/skills/conventions/references/knowledge-graph.md +47 -0
- package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
- package/skills/conventions/references/locks-and-worktrees.md +113 -0
- package/skills/conventions/references/maintenance-and-revisions.md +170 -0
- package/skills/conventions/references/roadmaps.md +44 -0
- package/skills/conventions/references/tiers-and-dispatch.md +134 -0
- package/skills/doctor/SKILL.md +59 -19
- package/skills/intent-brainstorming/SKILL.md +4 -0
- package/skills/intent-continuing/SKILL.md +4 -0
- package/skills/intent-creating/SKILL.md +7 -2
- package/skills/intent-ending/SKILL.md +20 -6
- package/skills/intent-executing/SKILL.md +10 -0
- package/skills/intent-linking/SKILL.md +4 -0
- package/skills/intent-locking/SKILL.md +4 -0
- package/skills/intent-planning/SKILL.md +7 -0
- package/skills/intent-savepoint/SKILL.md +4 -0
- package/skills/intent-speccing/SKILL.md +4 -0
- package/skills/intent-starting/SKILL.md +10 -0
- package/skills/project-creating/references/project-scaffolding.md +2 -2
- package/skills/roadmap/SKILL.md +4 -0
- package/skills/roadmap-continuing/SKILL.md +4 -0
- package/skills/skill-creating/SKILL.md +3 -0
- package/skills/skill-creating/references/defaults-first.md +23 -0
- package/skills/skill-creating/references/hooks.md +4 -1
- package/skills/store-curating/SKILL.md +8 -0
- package/skills/store-indexing/SKILL.md +8 -0
- package/skills/tutorial/references/track-2-auto.md +2 -3
- package/templates/agents.md +8 -0
- package/PLASTIC-reference.md +0 -138
- package/hooks/code-gate +0 -27
- package/hooks/create-gate +0 -3
- package/hooks/lock-gate +0 -21
- package/hooks/qmd-search +0 -8
- package/hooks/retrieval-gate +0 -10
- package/hooks/savepoint-pre +0 -10
- package/scripts/hook-qmd-search +0 -44
- package/scripts/hook-retrieval-gate +0 -148
- package/scripts/lib/legacy_bookend_amnesty.rb +0 -35
- package/scripts/lib/retrieval_gate.rb +0 -211
|
@@ -32,17 +32,20 @@ class InstallerCore
|
|
|
32
32
|
# Regex matching exactly one managed section (BEGIN line .. END line), non-greedy.
|
|
33
33
|
CODEX_SECTION_RE = /^<!-- BEGIN PLASTIC INTEGRATION.*?-->\n.*?\n<!-- END PLASTIC INTEGRATION -->\n?/m
|
|
34
34
|
|
|
35
|
-
# Curated essentials plus a pointer to ~/.plastic/PLASTIC.md
|
|
36
|
-
# ~/.codex/AGENTS.md. Not a slice of PLASTIC.md
|
|
37
|
-
#
|
|
35
|
+
# Curated essentials plus a pointer to ~/.plastic/PLASTIC.md and the plastic-conventions
|
|
36
|
+
# skill, injected into ~/.codex/AGENTS.md. Not a slice of PLASTIC.md itself: AGENTS.md is
|
|
37
|
+
# a shared file Codex merges from multiple sources, so this block stays a small,
|
|
38
|
+
# hand-curated pointer rather than embedding the core wholesale, and it never drifts
|
|
39
|
+
# because it only ever points, never duplicates.
|
|
38
40
|
CODEX_AGENTS_MD_BODY = <<~MD.freeze
|
|
39
41
|
Plastic is installed for this agent. Plastic is intent-driven state management: all
|
|
40
42
|
work flows through an intent, moved through What, Why, How, then Exec. Do not jump
|
|
41
43
|
straight to code.
|
|
42
44
|
|
|
43
45
|
Standing rules:
|
|
44
|
-
-
|
|
45
|
-
|
|
46
|
+
- Core conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly. For
|
|
47
|
+
depth, read a chapter from ~/.agents/skills/plastic-conventions/references/ on demand.
|
|
48
|
+
Both are generated and overwritten on Plastic updates, so never edit them.
|
|
46
49
|
- Operational procedures are installed as skills under ~/.agents/skills/ (each
|
|
47
50
|
plastic-<name>/SKILL.md). Invoke one explicitly as $plastic-<name> (for example
|
|
48
51
|
$plastic-doctor), or let Codex pick one implicitly by matching its description.
|
|
@@ -232,6 +235,13 @@ class InstallerCore
|
|
|
232
235
|
def distribute(mode)
|
|
233
236
|
puts " \u{1f4e6} #{mode == :update ? "Updating" : "Installing"} core files to #{plastic_home}"
|
|
234
237
|
|
|
238
|
+
manifest_path = File.join(plastic_home, "manifest.json")
|
|
239
|
+
# Capture the prior manifest before the copy loop touches anything, so a
|
|
240
|
+
# file dropped from core_files (renamed/removed) can be pruned below
|
|
241
|
+
# instead of orphaning forever. Mirrors install_for_agent's prune (D14
|
|
242
|
+
# Why-gate correction: this global path never had it).
|
|
243
|
+
old_files = manifest_files(manifest_path)
|
|
244
|
+
|
|
235
245
|
FileUtils.mkdir_p(plastic_home)
|
|
236
246
|
FileUtils.mkdir_p(File.join(plastic_home, "scripts", "lib"))
|
|
237
247
|
FileUtils.mkdir_p(File.join(plastic_home, "templates"))
|
|
@@ -252,9 +262,11 @@ class InstallerCore
|
|
|
252
262
|
global_files = core_files.values.map { |d| File.join(plastic_home, d) }
|
|
253
263
|
global_files << File.join(plastic_home, "VERSION")
|
|
254
264
|
global_files = global_files.select { |p| File.exist?(p) }
|
|
255
|
-
write_manifest(global_files,
|
|
265
|
+
write_manifest(global_files, manifest_path)
|
|
266
|
+
|
|
267
|
+
pruned = prune_removed_files(old_files - global_files, root: plastic_home)
|
|
256
268
|
|
|
257
|
-
puts " \u{2705} Core files synced (v#{version})"
|
|
269
|
+
puts " \u{2705} Core files synced (v#{version})#{pruned.positive? ? ", #{pruned} stale file(s) pruned" : ""}"
|
|
258
270
|
end
|
|
259
271
|
|
|
260
272
|
# Templates ship in full: every file under templates/ in the repo must reach
|
|
@@ -282,7 +294,6 @@ class InstallerCore
|
|
|
282
294
|
def hand_registered_files
|
|
283
295
|
{
|
|
284
296
|
"PLASTIC.md" => "PLASTIC.md",
|
|
285
|
-
"PLASTIC-reference.md" => "PLASTIC-reference.md",
|
|
286
297
|
"deprecations.yml" => "deprecations.yml",
|
|
287
298
|
"config_asks.yml" => "config_asks.yml",
|
|
288
299
|
"scripts/folgezettel-id" => "scripts/folgezettel-id",
|
|
@@ -294,7 +305,7 @@ class InstallerCore
|
|
|
294
305
|
"scripts/hook-future-intent-check" => "scripts/hook-future-intent-check",
|
|
295
306
|
"scripts/hook-gate-check" => "scripts/hook-gate-check",
|
|
296
307
|
"scripts/hook-savepoint-pre" => "scripts/hook-savepoint-pre",
|
|
297
|
-
"scripts/hook-
|
|
308
|
+
"scripts/hook-power-tools" => "scripts/hook-power-tools",
|
|
298
309
|
"scripts/lib/qmd_hook.rb" => "scripts/lib/qmd_hook.rb",
|
|
299
310
|
"scripts/lib/power_tools.rb" => "scripts/lib/power_tools.rb",
|
|
300
311
|
"scripts/lib/agent_models.rb" => "scripts/lib/agent_models.rb",
|
|
@@ -303,8 +314,6 @@ class InstallerCore
|
|
|
303
314
|
"scripts/hook-code-gate" => "scripts/hook-code-gate",
|
|
304
315
|
"scripts/hook-lock-gate" => "scripts/hook-lock-gate",
|
|
305
316
|
"scripts/hook-bash-gate" => "scripts/hook-bash-gate",
|
|
306
|
-
"scripts/hook-retrieval-gate" => "scripts/hook-retrieval-gate",
|
|
307
|
-
"scripts/lib/retrieval_gate.rb" => "scripts/lib/retrieval_gate.rb",
|
|
308
317
|
"scripts/hook-auto-arm" => "scripts/hook-auto-arm",
|
|
309
318
|
"scripts/lib/bridge.rb" => "scripts/lib/bridge.rb",
|
|
310
319
|
"scripts/lib/lock.rb" => "scripts/lib/lock.rb",
|
|
@@ -345,6 +354,8 @@ class InstallerCore
|
|
|
345
354
|
"scripts/hook-create-gate" => "scripts/hook-create-gate",
|
|
346
355
|
"scripts/hook-links-gate" => "scripts/hook-links-gate",
|
|
347
356
|
"scripts/lib/links_gate.rb" => "scripts/lib/links_gate.rb",
|
|
357
|
+
"scripts/lib/edit_gates.rb" => "scripts/lib/edit_gates.rb",
|
|
358
|
+
"scripts/hook-edit-gates" => "scripts/hook-edit-gates",
|
|
348
359
|
"scripts/lib/apply_patch_envelope.rb" => "scripts/lib/apply_patch_envelope.rb",
|
|
349
360
|
"scripts/codex-hook" => "scripts/codex-hook",
|
|
350
361
|
"scripts/spawn-preamble" => "scripts/spawn-preamble",
|
|
@@ -358,7 +369,7 @@ class InstallerCore
|
|
|
358
369
|
"scripts/update.rb" => "scripts/update.rb",
|
|
359
370
|
"scripts/uninstall.rb" => "scripts/uninstall.rb",
|
|
360
371
|
"scripts/rollback.rb" => "scripts/rollback.rb",
|
|
361
|
-
"scripts/lib/
|
|
372
|
+
"scripts/lib/outcome_guard.rb" => "scripts/lib/outcome_guard.rb",
|
|
362
373
|
"scripts/doctor.rb" => "scripts/doctor.rb",
|
|
363
374
|
"scripts/dashboard.rb" => "scripts/dashboard.rb",
|
|
364
375
|
"scripts/skill-lint" => "scripts/skill-lint",
|
|
@@ -403,10 +414,11 @@ class InstallerCore
|
|
|
403
414
|
MD
|
|
404
415
|
|
|
405
416
|
write_if_missing(File.join(plastic_home, "AGENTS.md"), <<~MD)
|
|
406
|
-
# Plastic
|
|
417
|
+
# Plastic: Agent Instructions
|
|
407
418
|
|
|
408
|
-
Read `PLASTIC.md` in this directory
|
|
409
|
-
Follow it exactly. Never modify it
|
|
419
|
+
Read `PLASTIC.md` in this directory for the core conventions; deeper doctrine lives
|
|
420
|
+
in the `plastic-conventions` skill's chapters. Follow it exactly. Never modify it:
|
|
421
|
+
it is overwritten on plugin updates.
|
|
410
422
|
|
|
411
423
|
This file (`AGENTS.md`) is where project-specific rules live.
|
|
412
424
|
|
|
@@ -508,7 +520,8 @@ class InstallerCore
|
|
|
508
520
|
end
|
|
509
521
|
|
|
510
522
|
new_files = manifest_files(manifest_path_for(key, config))
|
|
511
|
-
pruned = prune_removed_files(old_files - new_files
|
|
523
|
+
pruned = prune_removed_files(old_files - new_files,
|
|
524
|
+
root: [config[:dir], config[:home_dir], *shared_fragment_prune_roots])
|
|
512
525
|
result[:pruned] = pruned if pruned.positive?
|
|
513
526
|
|
|
514
527
|
# The legacy manifest is fully superseded once the new one is written; delete it so
|
|
@@ -588,7 +601,8 @@ class InstallerCore
|
|
|
588
601
|
restore_agent(config)
|
|
589
602
|
result = { agent: config[:name], success: false, reason: "verify failed - restored prior snapshot" }
|
|
590
603
|
else
|
|
591
|
-
prune_removed_files(manifest_files(manifest_path_for(key, config))
|
|
604
|
+
prune_removed_files(manifest_files(manifest_path_for(key, config)),
|
|
605
|
+
root: [config[:dir], config[:home_dir], *shared_fragment_prune_roots])
|
|
592
606
|
result = { agent: config[:name], success: false, reason: "verify failed - partial install pruned" }
|
|
593
607
|
end
|
|
594
608
|
end
|
|
@@ -599,10 +613,30 @@ class InstallerCore
|
|
|
599
613
|
|
|
600
614
|
# Delete tracked files present in the old manifest but absent from the new one,
|
|
601
615
|
# then remove any now-empty skill directories they lived in.
|
|
602
|
-
|
|
616
|
+
#
|
|
617
|
+
# `root:` is a mandatory containment boundary (intent 223 F5): every manifest path
|
|
618
|
+
# is read back from JSON written by write_manifest and could, on a hand-edited or
|
|
619
|
+
# otherwise corrupt manifest, name a path outside the install this call is pruning
|
|
620
|
+
# (for example `distribute` prunes against `~/.plastic/`, `install_for_agent` prunes
|
|
621
|
+
# against one agent's own directories). Accepts a single path or an array (an agent
|
|
622
|
+
# with a two-root schema, like Codex's `dir` plus `home_dir`, tracks files under
|
|
623
|
+
# both, so a single boundary would wrongly reject legitimate prunes under the root
|
|
624
|
+
# not passed). A candidate that resolves outside every given root is skipped and
|
|
625
|
+
# never deleted, so a stray manifest entry can never delete a path outside the
|
|
626
|
+
# target home.
|
|
627
|
+
def prune_removed_files(stale_files, root:)
|
|
628
|
+
expanded_roots = Array(root).compact.map { |r| File.expand_path(r) }
|
|
603
629
|
removed = 0
|
|
604
630
|
dirs = []
|
|
605
631
|
stale_files.each do |f|
|
|
632
|
+
if path_contained?(f, store_roots)
|
|
633
|
+
warn " \u{26a0}\u{fe0f} Refused to prune #{f}: inside the intent store"
|
|
634
|
+
next
|
|
635
|
+
end
|
|
636
|
+
unless path_contained?(f, expanded_roots)
|
|
637
|
+
warn " \u{26a0}\u{fe0f} Skipped prune of #{f}: outside #{expanded_roots.join(", ")}"
|
|
638
|
+
next
|
|
639
|
+
end
|
|
606
640
|
if File.exist?(f)
|
|
607
641
|
File.delete(f)
|
|
608
642
|
removed += 1
|
|
@@ -615,6 +649,54 @@ class InstallerCore
|
|
|
615
649
|
removed
|
|
616
650
|
end
|
|
617
651
|
|
|
652
|
+
# True when `path`, once expanded, is one of `roots` itself or lives underneath
|
|
653
|
+
# one of them. String-prefix containment guarded by a trailing separator so a
|
|
654
|
+
# sibling directory that merely shares a prefix (`/home/x-evil` vs root `/home/x`)
|
|
655
|
+
# never counts as contained.
|
|
656
|
+
def path_contained?(path, roots)
|
|
657
|
+
expanded = File.expand_path(path)
|
|
658
|
+
Array(roots).any? { |root| expanded == root || expanded.start_with?(root + File::SEPARATOR) }
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Intent 223 (post-delivery hardening): the user's entire intent history lives under
|
|
662
|
+
# `plastic_home/store` (the global store) and `plastic_home/projects` (every project
|
|
663
|
+
# store). `distribute` legitimately prunes with `root: plastic_home`, which CONTAINS
|
|
664
|
+
# both directories, so the ordinary `root:` containment check in `prune_removed_files`
|
|
665
|
+
# admits a store path instead of refusing it. Today that branch is unreachable (the
|
|
666
|
+
# pruned set is always `old_files - global_files`, both derived from `core_files` plus
|
|
667
|
+
# `VERSION`, so no store path can ever appear there), so this is defense in depth for
|
|
668
|
+
# an unreachable case, not a fix for a live bug. It exists because the standing rule is
|
|
669
|
+
# that NO path ever deletes the global or project intent stores, and the cost of being
|
|
670
|
+
# wrong (irreversible loss of the user's whole intent history) warrants a guard that
|
|
671
|
+
# fails milder than the bug it prevents, independent of what any caller's `root:` is or
|
|
672
|
+
# what a hand-edited manifest claims.
|
|
673
|
+
def store_roots
|
|
674
|
+
[File.join(plastic_home, "store"), File.join(plastic_home, "projects")].map { |p| File.expand_path(p) }
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# `install_skills_flat` relocates any top-level underscore-prefixed markdown
|
|
678
|
+
# fragment (today: `_active-intent-gate.md`, `_decision-tables.md`) out of the
|
|
679
|
+
# per-agent skills tree and into `plastic_home` directly, then lists them in
|
|
680
|
+
# `installed`, so they are manifest-tracked and eligible for prune. But
|
|
681
|
+
# `install_for_agent`'s prune roots are `[config[:dir], config[:home_dir]]`,
|
|
682
|
+
# which do not cover `plastic_home`, so those two files could never actually be
|
|
683
|
+
# pruned (every attempt printed "Skipped prune of ... outside ..." to stderr).
|
|
684
|
+
#
|
|
685
|
+
# Fix scope (intent 223 N3): add each shared fragment's exact FILE path as its
|
|
686
|
+
# own containment root, never `plastic_home` itself as a directory root.
|
|
687
|
+
# `path_contained?` only matches a candidate that equals a root exactly or
|
|
688
|
+
# lives under `root + separator`; a root that is itself a file path can never
|
|
689
|
+
# have children, so listing these exact files as roots permits pruning ONLY
|
|
690
|
+
# those files, never anything else under `plastic_home`. This keeps
|
|
691
|
+
# `~/.plastic/store` and `~/.plastic/projects` (the user's intent stores, never
|
|
692
|
+
# prunable by standing rule) outside every prune root.
|
|
693
|
+
def shared_fragment_prune_roots
|
|
694
|
+
skills_source = File.join(package_root, "skills")
|
|
695
|
+
return [] unless File.directory?(skills_source)
|
|
696
|
+
Dir.children(skills_source).select { |e| e.start_with?("_") && e.end_with?(".md") }
|
|
697
|
+
.map { |e| File.join(plastic_home, e) }
|
|
698
|
+
end
|
|
699
|
+
|
|
618
700
|
def install_claude(config, force, argv: [], input: $stdin, reinstall: false)
|
|
619
701
|
hooks_dir = File.join(config[:dir], "hooks")
|
|
620
702
|
skills_root = File.join(config[:dir], "skills")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "bridge"
|
|
5
|
+
require_relative "intent_validator"
|
|
6
|
+
|
|
7
|
+
# OutcomeGuard - the single definition of "is outcome.md real and disposition-matched for
|
|
8
|
+
# THIS close" (intent 222). Extracted verbatim from scripts/end-intent's own
|
|
9
|
+
# outcome_guard_reason (intent 161/188) so end-intent's existing exit-2 path and doctor's new
|
|
10
|
+
# per-intent check (intent_lifecycle_artifacts) can never independently drift on what counts
|
|
11
|
+
# as a valid outcome.md. Pure, read-only: authors nothing regardless of outcome.
|
|
12
|
+
module OutcomeGuard
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
DISPOSITIONS = %w[delivered abandoned].freeze
|
|
16
|
+
|
|
17
|
+
# Returns nil when the guard PASSES, or a reason String to refuse with. Refuses a missing
|
|
18
|
+
# file, a still-placeholder file (first line is the sentinel), or a disposition that does
|
|
19
|
+
# not exactly equal the requested one.
|
|
20
|
+
def reason(intent_dir, disposition)
|
|
21
|
+
path = File.join(intent_dir, "outcome.md")
|
|
22
|
+
return "outcome.md is missing at #{path}" unless File.exist?(path)
|
|
23
|
+
|
|
24
|
+
content = File.read(path)
|
|
25
|
+
first_line = content.each_line.first.to_s.chomp
|
|
26
|
+
if first_line == Bridge::PLACEHOLDER_SENTINEL
|
|
27
|
+
return "outcome.md is still the scaffold placeholder (first line is the sentinel)"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
fm = IntentValidator.parse_frontmatter_text(content)
|
|
31
|
+
actual = fm.is_a?(Hash) ? fm["disposition"] : nil
|
|
32
|
+
unless DISPOSITIONS.include?(actual) && actual == disposition
|
|
33
|
+
return "outcome.md frontmatter disposition is #{actual.inspect}, expected #{disposition.inspect}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
package/scripts/lib/qmd_hook.rb
CHANGED
|
@@ -5,22 +5,28 @@ require_relative "qmd_sync"
|
|
|
5
5
|
require_relative "power_tools"
|
|
6
6
|
|
|
7
7
|
# QmdHook - decision logic for the power-tools UserPromptSubmit hook (intents 66,
|
|
8
|
-
# 66b). Pure and dependency-injected: returns the additionalContext
|
|
9
|
-
# emit, or nil to emit nothing. The executable hook wires real deps and
|
|
10
|
-
# this is unit-tested with
|
|
8
|
+
# 66b, 187, 246). Pure and dependency-injected: returns the additionalContext
|
|
9
|
+
# string to emit, or nil to emit nothing. The executable hook wires real deps and
|
|
10
|
+
# prints; this is unit-tested with fake detectors (no real qmd, no network, no
|
|
11
|
+
# subprocess at all).
|
|
11
12
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# It emits exactly one thing: the PowerTools mandate, one recommendation line per
|
|
14
|
+
# present tool (qmd for finding intents, Enola-first for code navigation, falling
|
|
15
|
+
# back to Serena; intent 187 added the enola_detector alongside the pre-existing
|
|
16
|
+
# serena_detector).
|
|
17
|
+
#
|
|
18
|
+
# Intent 246 removed the scored qmd hit injection this hook used to prepend.
|
|
19
|
+
# Intent 225 measured that injection at 0.24 intent-level recall@3 against a plain
|
|
20
|
+
# ripgrep control at 0.18, while agent-driven `qmd query` scored 0.71. The failure
|
|
21
|
+
# was recall, not latency, so caching and async were both rejected. QmdSync.search
|
|
22
|
+
# is untouched and still backs the `scripts/qmd-sync search` CLI verb. All three
|
|
23
|
+
# detectors are PATH and marker-file walks with no subprocess, which is why what is
|
|
24
|
+
# left costs about a tenth of a second.
|
|
16
25
|
module QmdHook
|
|
17
26
|
module_function
|
|
18
27
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def run(prompt:, cwd:, plastic_home:, runner: QmdSync.default_runner,
|
|
22
|
-
detector: QmdSync.method(:detect), limit: 3, min_score: 0.5,
|
|
23
|
-
serena_detector: nil, enola_detector: nil)
|
|
28
|
+
def run(cwd:, detector: QmdSync.method(:detect), serena_detector: nil,
|
|
29
|
+
enola_detector: nil)
|
|
24
30
|
serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
|
|
25
31
|
enola_detector ||= -> { PowerTools.enola?(cwd: cwd) }
|
|
26
32
|
qmd_present = !!detector.call
|
|
@@ -28,34 +34,8 @@ module QmdHook
|
|
|
28
34
|
enola_present = !!enola_detector.call
|
|
29
35
|
return nil unless qmd_present || serena_present || enola_present
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# absent). The mandate itself is always-on for whichever tools are present.
|
|
35
|
-
search_ok = qmd_present && p.length >= MIN_PROMPT_LENGTH && p.downcase != "continue"
|
|
36
|
-
|
|
37
|
-
parts = []
|
|
38
|
-
if search_ok
|
|
39
|
-
collections = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
|
|
40
|
-
hits = QmdSync.search(p, collections: collections, limit: limit,
|
|
41
|
-
min_score: min_score, runner: runner, detector: detector)
|
|
42
|
-
if hits.any?
|
|
43
|
-
parts << "Related / prior Plastic intents (qmd BM25, includes completed) - " \
|
|
44
|
-
"check before treating this as new work:"
|
|
45
|
-
hits.each do |h|
|
|
46
|
-
loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
|
|
47
|
-
pct = (h[:score] * 100).round
|
|
48
|
-
parts << "- [#{pct}%] #{loc} - #{h[:title]}"
|
|
49
|
-
end
|
|
50
|
-
parts << ""
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
mandate = PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
|
|
55
|
-
serena_detector: -> { serena_present },
|
|
56
|
-
enola_detector: -> { enola_present })
|
|
57
|
-
parts << mandate if mandate
|
|
58
|
-
return nil if parts.empty?
|
|
59
|
-
parts.join("\n")
|
|
37
|
+
PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
|
|
38
|
+
serena_detector: -> { serena_present },
|
|
39
|
+
enola_detector: -> { enola_present })
|
|
60
40
|
end
|
|
61
41
|
end
|
package/scripts/lib/qmd_sync.rb
CHANGED
|
@@ -134,21 +134,6 @@ module QmdSync
|
|
|
134
134
|
pid
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
-
# True when the QMD index has no pending (unembedded) documents. Binary
|
|
138
|
-
# freshness signal for the retrieval gate (intent 84, Lever 2). `qmd status` is
|
|
139
|
-
# plain text (no --json); it prints a line like "Pending: N need embedding".
|
|
140
|
-
# No pending line found -> treat as fresh (conservative: a parse miss must not
|
|
141
|
-
# block reads). Runner failure -> false (cannot confirm freshness). The caller
|
|
142
|
-
# gates on `detect` first, so absence is handled upstream; this only answers
|
|
143
|
-
# "is the present index fresh?". Pure via the injected runner.
|
|
144
|
-
def self.fresh?(runner: default_runner)
|
|
145
|
-
out, ok = runner.call(["status"])
|
|
146
|
-
return false unless ok && out
|
|
147
|
-
m = out[/^\s*Pending:\s*(\d+)\b/i, 1]
|
|
148
|
-
pending = m ? m.to_i : 0
|
|
149
|
-
pending.zero?
|
|
150
|
-
end
|
|
151
|
-
|
|
152
137
|
# Read-only status used by doctor and the session-start report line.
|
|
153
138
|
# Returns a structured hash; never mutates the index.
|
|
154
139
|
def status(plastic_home:, runner: default_runner, detector: method(:detect))
|
|
@@ -15,7 +15,7 @@ module RevisionsWriter
|
|
|
15
15
|
module_function
|
|
16
16
|
|
|
17
17
|
# PURE. Renders one `## Revision vN - TIMESTAMP` entry in the documented shape
|
|
18
|
-
# (
|
|
18
|
+
# (plastic-conventions > references/maintenance-and-revisions.md; templates/revisions.md).
|
|
19
19
|
# `n` is the next revision number (caller resolves it via next_revision_number, or a caller
|
|
20
20
|
# that already knows it, e.g. a batch writer amortizing one file read across many entries).
|
|
21
21
|
# `why` is the one-sentence reason ending in "[rule: <tag>]" (tag is appended here if the
|
package/scripts/maintenance-run
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
# maintenance-run --tool project-links --intent <id> [--store <key>] [--plastic-home PATH] [--apply]
|
|
15
15
|
# maintenance-run --tool rebuild-graph [--plastic-home PATH] [--apply]
|
|
16
16
|
# maintenance-run --tool restore-intent-v1 <id> --at <ref> [--plastic-home PATH] [--apply] [--skip-links]
|
|
17
|
+
# maintenance-run --tool rebuild-savepoint --intent <id> [--store <key>] [--plastic-home PATH] [--apply]
|
|
17
18
|
#
|
|
18
19
|
# project-links here is ALWAYS single-intent: --intent is required. A store-wide
|
|
19
20
|
# project-links sweep is the rare, owner-approved batch exception (D2) and is run directly
|
|
@@ -35,6 +36,8 @@ require "time"
|
|
|
35
36
|
require_relative "lib/store_discovery"
|
|
36
37
|
require_relative "lib/lock"
|
|
37
38
|
require_relative "lib/maintenance_git"
|
|
39
|
+
require_relative "lib/bridge"
|
|
40
|
+
require_relative "lib/revisions_writer"
|
|
38
41
|
|
|
39
42
|
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
40
43
|
|
|
@@ -217,9 +220,58 @@ def run_restore_intent_v1(home, id, at, store, apply, skip_links)
|
|
|
217
220
|
report_result(result)
|
|
218
221
|
end
|
|
219
222
|
|
|
223
|
+
def run_rebuild_savepoint(home, intent, store, apply)
|
|
224
|
+
abort_loud("--tool rebuild-savepoint requires --intent <id>") unless intent
|
|
225
|
+
|
|
226
|
+
discovery = StoreDiscovery.discover(home)
|
|
227
|
+
dir = resolve_dir_for_id(discovery, intent, store: store)
|
|
228
|
+
abort_loud("intent #{intent} not found under #{home}#{store ? " (--store #{store})" : ""}") unless dir
|
|
229
|
+
check_not_fresh!(dir, intent)
|
|
230
|
+
|
|
231
|
+
outcome_path = File.join(dir, "outcome.md")
|
|
232
|
+
unless Bridge.stage_file_present?(outcome_path)
|
|
233
|
+
abort_loud("intent #{intent}'s outcome.md is missing or a placeholder; the 124a recipe " \
|
|
234
|
+
"requires a real disposition to echo, and 219 D6 forbids ever inventing one", 1)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
frontmatter = File.read(outcome_path)[/\A---\n(.*?)\n---/m, 1].to_s
|
|
238
|
+
disposition = frontmatter[/^disposition:\s*(\S+)/, 1]
|
|
239
|
+
abort_loud("intent #{intent}'s outcome.md has no disposition: frontmatter field", 1) unless disposition
|
|
240
|
+
|
|
241
|
+
unless apply
|
|
242
|
+
puts "maintenance-run: DRY RUN, would reconstruct savepoint.md for #{intent} " \
|
|
243
|
+
"(Bridge.rebuild_savepoint + Done #{disposition} echo)"
|
|
244
|
+
exit 0
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
begin
|
|
248
|
+
result = MaintenanceGit.run_scoped(
|
|
249
|
+
repo_dir: home, branch_name: "maintenance/rebuild-savepoint-#{intent}-#{stamp}",
|
|
250
|
+
commit_message: "chore: maintenance - rebuild-savepoint #{intent}"
|
|
251
|
+
) do
|
|
252
|
+
Bridge.rebuild_savepoint(dir)
|
|
253
|
+
Bridge.append_terminal_savepoint(dir, disposition)
|
|
254
|
+
RevisionsWriter.append!(
|
|
255
|
+
dir,
|
|
256
|
+
why: "reconstruct missing operational savepoint.md (generic operational-gap predicate)",
|
|
257
|
+
rule: "savepoint-operational-reconstruction",
|
|
258
|
+
prior_location: "#{intent}/savepoint.md",
|
|
259
|
+
change: "savepoint.md rebuilt from disk (Bridge.rebuild_savepoint) plus Done " \
|
|
260
|
+
"#{disposition} echo appended (Bridge.append_terminal_savepoint); disposition " \
|
|
261
|
+
"read from outcome.md's own frontmatter, never invented"
|
|
262
|
+
)
|
|
263
|
+
end
|
|
264
|
+
rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
|
|
265
|
+
abort_loud(e.message, 4)
|
|
266
|
+
rescue RuntimeError => e
|
|
267
|
+
abort_loud(e.message, 3)
|
|
268
|
+
end
|
|
269
|
+
report_result(result)
|
|
270
|
+
end
|
|
271
|
+
|
|
220
272
|
def main(argv)
|
|
221
273
|
opts = parse_argv(argv)
|
|
222
|
-
abort_loud("--tool is required (project-links|rebuild-graph|restore-intent-v1)") unless opts[:tool]
|
|
274
|
+
abort_loud("--tool is required (project-links|rebuild-graph|restore-intent-v1|rebuild-savepoint)") unless opts[:tool]
|
|
223
275
|
|
|
224
276
|
case opts[:tool]
|
|
225
277
|
when "project-links"
|
|
@@ -227,9 +279,11 @@ def main(argv)
|
|
|
227
279
|
when "rebuild-graph" then run_rebuild_graph(opts[:plastic_home], opts[:apply])
|
|
228
280
|
when "restore-intent-v1"
|
|
229
281
|
run_restore_intent_v1(opts[:plastic_home], opts[:id], opts[:at], opts[:store], opts[:apply], opts[:skip_links])
|
|
282
|
+
when "rebuild-savepoint"
|
|
283
|
+
run_rebuild_savepoint(opts[:plastic_home], opts[:intent], opts[:store], opts[:apply])
|
|
230
284
|
else
|
|
231
285
|
abort_loud("unknown --tool #{opts[:tool].inspect} " \
|
|
232
|
-
"(expected project-links|rebuild-graph|restore-intent-v1)")
|
|
286
|
+
"(expected project-links|rebuild-graph|restore-intent-v1|rebuild-savepoint)")
|
|
233
287
|
end
|
|
234
288
|
end
|
|
235
289
|
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
# while preserving its frontmatter GRAPH (sources/chain) as a target-resolved
|
|
7
7
|
# union of the v1 snapshot and the current snapshot (intent 193). This is the
|
|
8
8
|
# ONLY sanctioned way to restore a completed intent to v1; a hand-run whole-file
|
|
9
|
-
# `git checkout`/revert is forbidden (see
|
|
10
|
-
# because it cannot tell prose from graph
|
|
11
|
-
# backlinks written after v1 (the 124/131 incident this tool
|
|
9
|
+
# `git checkout`/revert is forbidden (see plastic-conventions > references/maintenance-and-revisions.md,
|
|
10
|
+
# WORK vs MAINTENANCE and its Restore-to-v1 paragraph), because it cannot tell prose from graph
|
|
11
|
+
# metadata and silently destroys backlinks written after v1 (the 124/131 incident this tool
|
|
12
|
+
# exists to prevent).
|
|
12
13
|
#
|
|
13
14
|
# Usage:
|
|
14
15
|
# restore-intent-v1 <intent-id> --at <git-ref> [--plastic-home PATH] [--apply] \
|
|
@@ -19,12 +20,14 @@
|
|
|
19
20
|
# class of tool already destroyed live store data once. --apply is required to
|
|
20
21
|
# write.
|
|
21
22
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
# lock
|
|
26
|
-
#
|
|
27
|
-
#
|
|
23
|
+
# There is exactly one lock in Plastic, `delivery.lock`, and MAINTENANCE (this tool included)
|
|
24
|
+
# never acquires or checks it: WORK vs MAINTENANCE (plastic-conventions >
|
|
25
|
+
# references/maintenance-and-revisions.md) holds the corrected doctrine after intent 112's
|
|
26
|
+
# proposed second "maintenance.lock" was abandoned before merge and never shipped. This tool does
|
|
27
|
+
# NOT acquire, check, or manage `delivery.lock` itself (fail-open doctrine, intent 111: lock
|
|
28
|
+
# management is the orchestrator's job, never built into a CLI as a trap); it only prints a
|
|
29
|
+
# one-line reminder on --apply that the target intent must be terminal, which the caller is
|
|
30
|
+
# responsible for confirming before running with --apply.
|
|
28
31
|
#
|
|
29
32
|
# LINKS BLAST RADIUS: an applied restore reprojects `## Links` via
|
|
30
33
|
# scripts/project-links, which is a STORE-WIDE operation with no per-intent
|
|
@@ -142,8 +145,10 @@ class RestoreIntentV1CLI
|
|
|
142
145
|
File.write(md_path, new_md) if md_changed
|
|
143
146
|
other_files.each { |f, content| File.write(File.join(dir, f), content) }
|
|
144
147
|
|
|
145
|
-
puts "Reminder: restore-to-v1
|
|
146
|
-
"
|
|
148
|
+
puts "Reminder: restore-to-v1 is MAINTENANCE, not WORK (plastic-conventions > " \
|
|
149
|
+
"references/maintenance-and-revisions.md, WORK vs MAINTENANCE). There is no " \
|
|
150
|
+
"maintenance lock to hold; confirm the target intent is terminal (Completed or " \
|
|
151
|
+
"Abandoned) with no fresh delivery.lock before this --apply."
|
|
147
152
|
|
|
148
153
|
handle_links_reprojection(base)
|
|
149
154
|
append_revision(dir, base, graph, files: prose_changes,
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -85,6 +85,10 @@ and artifact depth to that size. Extended walkthrough: `references/tiers.md`.
|
|
|
85
85
|
5. **Tier record.** `Tier: S|M|L` at the top of spec.md. Convention-only: read by the
|
|
86
86
|
orchestrator, never validated by any gate or by doctor.
|
|
87
87
|
|
|
88
|
+
Read `../plastic-conventions/references/tiers-and-dispatch.md` for tier sizing, agent-model
|
|
89
|
+
config, advisor routing, and the auto-mode human report contract behind the sizing above. This
|
|
90
|
+
path resolves relative to this skill's own installed directory.
|
|
91
|
+
|
|
88
92
|
## Arm the Lifecycle Gate (do this FIRST)
|
|
89
93
|
|
|
90
94
|
Immediately after selecting the intent - before any other work - arm auto mode. This
|
|
@@ -110,6 +114,9 @@ implies for later tool calls) read `references/end-tail.md`.
|
|
|
110
114
|
intent directory / `~/.plastic/`) until `plan.md` AND `checklist.md` exist for the intent.
|
|
111
115
|
Honor the cycle: What → Why (spec.md) → How (plan.md + actions/ + checklist.md) → Exec.
|
|
112
116
|
|
|
117
|
+
Read `../plastic-conventions/references/locks-and-worktrees.md` for delivery isolation: the
|
|
118
|
+
single-owner lock, claims, worktrees, solo mode, and the station ledger behind the arming above.
|
|
119
|
+
|
|
113
120
|
## Flags
|
|
114
121
|
|
|
115
122
|
- `--skip-permissions` - bypass hard stops on destructive actions on existing projects. Full trust mode. Default: off.
|
|
@@ -303,6 +310,9 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
303
310
|
|
|
304
311
|
## Completion
|
|
305
312
|
|
|
313
|
+
Read `../plastic-conventions/references/completion-and-done.md` for what "intent done" means and
|
|
314
|
+
the End-stage tail the steps below walk through.
|
|
315
|
+
|
|
306
316
|
1. Verify all checklist items are checked
|
|
307
317
|
2. Write `outcome.md` with detailed results, from `${CLAUDE_PLUGIN_ROOT}/templates/outcome.md`.
|
|
308
318
|
Set the frontmatter `disposition: delivered` (this is the delivered terminal). `outcome.md`
|
|
@@ -343,10 +353,11 @@ During initial project creation, all decisions are non-destructive by definition
|
|
|
343
353
|
non-zero exit needs attention before moving on: 4 means a live foreign session holds the
|
|
344
354
|
lock (back off), 5 means the code worktree is dirty (commit/stash first, or pass
|
|
345
355
|
`--discard-worktree-changes` deliberately), 3 means disarm ran but the lock is still
|
|
346
|
-
present (run `/plastic-doctor check the lock status`)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
356
|
+
present (run `/plastic-doctor check the lock status`), 6 means the structure gate refused
|
|
357
|
+
(see the named reason on stderr; fix via the owning tool named above, then re-run). Only
|
|
358
|
+
Step 6 (QMD reindex, async, last) remains a separate action after this call succeeds.
|
|
359
|
+
Never leave an orphaned worktree; run `git worktree prune` on a stale reference. If any of
|
|
360
|
+
this ever needs to change, change `plastic-intent-ending`, not this skill.
|
|
350
361
|
7. Notify user (Done briefing): brief per `references/human-report-contract.md`
|
|
351
362
|
(State: the delivered impact; Risk: residual risk; Call: the decision left to you, merge,
|
|
352
363
|
release, or accept). See `outcome.md` for details.
|
|
@@ -208,13 +208,13 @@
|
|
|
208
208
|
"scope": "behavior",
|
|
209
209
|
"set": "validation",
|
|
210
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).
|
|
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). No search hits are injected: intent 246 removed the per-prompt qmd hit search, so the hook emits the mandate and nothing else.",
|
|
212
212
|
"files": [],
|
|
213
213
|
"assertions": [
|
|
214
214
|
{
|
|
215
215
|
"type": "code",
|
|
216
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",
|
|
217
|
+
"observed": "power_tools_test.rb + qmd_hook_test.rb assert MUST wording; serena line gated on the serena detector; qmd_hook_test.rb test_no_retrieval_hits_are_ever_injected asserts no hit header for any tool combination",
|
|
218
218
|
"result": "pass"
|
|
219
219
|
}
|
|
220
220
|
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plastic-conventions
|
|
3
|
+
description: >
|
|
4
|
+
Chapters of Plastic doctrine used by more than one skill: the knowledge graph,
|
|
5
|
+
lifecycle and savepoints, tiers and dispatch, gates and enforcement, locks and
|
|
6
|
+
worktrees, completion, maintenance, and roadmaps. Read a chapter when its trigger
|
|
7
|
+
applies to the work in front of you.
|
|
8
|
+
user-invocable: false
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Plastic Conventions
|
|
12
|
+
|
|
13
|
+
The chapters below hold Plastic doctrine that more than one skill needs. Core always-on
|
|
14
|
+
conventions stay in `~/.plastic/PLASTIC.md`, which is injected at session start. Read a chapter
|
|
15
|
+
when the trigger in the second column applies to the work in front of you.
|
|
16
|
+
|
|
17
|
+
| Chapter | Read it when |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `references/knowledge-graph.md` | when creating, linking, curating, or indexing intents and you need the sources-vs-chain doctrine, the tiers of influence, the `## Links` projection, or branch-vs-root directory semantics |
|
|
20
|
+
| `references/lifecycle-and-savepoints.md` | when running a lifecycle stage or a savepoint and you need the subagent report-home contract for how an insight reaches the intent |
|
|
21
|
+
| `references/tiers-and-dispatch.md` | when sizing an intent, choosing agent models, routing to the advisor, or writing an auto-mode human report |
|
|
22
|
+
| `references/gates-and-enforcement.md` | when a transition gate blocks you, or before using an audited escape, for the gate mechanics and the logging contract |
|
|
23
|
+
| `references/locks-and-worktrees.md` | before taking or releasing a delivery lock, and when working with claims, worktrees, solo mode, or the station ledger |
|
|
24
|
+
| `references/completion-and-done.md` | when ending an intent, for what "intent done" means and the End-stage tail |
|
|
25
|
+
| `references/maintenance-and-revisions.md` | before any structural maintenance edit, for WORK vs MAINTENANCE, the `revisions.md` move-and-record contract, the violation-tag catalog, and the context-economy measurement buckets |
|
|
26
|
+
| `references/roadmaps.md` | when creating, ordering, closing, or consuming a roadmap, for the file format and the status-mirror rule |
|
|
27
|
+
|
|
28
|
+
Other skills read these chapters directly at
|
|
29
|
+
`../plastic-conventions/references/<chapter>.md`, resolved relative to their own installed
|
|
30
|
+
directory. All three harnesses install skills flat into one shared skills root through the same
|
|
31
|
+
`install_skills_flat` call, so that path is harness-independent.
|