@zalom/plastic 1.8.0 → 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.
Files changed (67) hide show
  1. package/PLASTIC.md +75 -552
  2. package/README.md +3 -2
  3. package/agents/plastic-intent-curator.md +2 -2
  4. package/deprecations.yml +10 -2
  5. package/hooks/{links-gate → edit-gates} +1 -1
  6. package/hooks/hooks.json +4 -44
  7. package/hooks/power-tools +8 -0
  8. package/package.json +1 -2
  9. package/scripts/codex-hook +17 -22
  10. package/scripts/doctor.rb +85 -3
  11. package/scripts/hook-code-gate +13 -28
  12. package/scripts/hook-create-gate +10 -57
  13. package/scripts/hook-edit-gates +58 -0
  14. package/scripts/hook-links-gate +8 -31
  15. package/scripts/hook-lock-gate +13 -58
  16. package/scripts/hook-power-tools +38 -0
  17. package/scripts/hook-savepoint-pre +8 -11
  18. package/scripts/lib/bridge.rb +2 -2
  19. package/scripts/lib/edit_gates.rb +342 -0
  20. package/scripts/lib/hook_registry.rb +61 -29
  21. package/scripts/lib/installer_core.rb +99 -17
  22. package/scripts/lib/qmd_hook.rb +21 -41
  23. package/scripts/lib/qmd_sync.rb +0 -15
  24. package/scripts/lib/revisions_writer.rb +1 -1
  25. package/scripts/restore-intent-v1 +16 -11
  26. package/skills/auto/SKILL.md +10 -0
  27. package/skills/auto/evals/evals.json +2 -2
  28. package/skills/conventions/SKILL.md +31 -0
  29. package/skills/conventions/references/completion-and-done.md +40 -0
  30. package/skills/conventions/references/gates-and-enforcement.md +23 -0
  31. package/skills/conventions/references/knowledge-graph.md +47 -0
  32. package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
  33. package/skills/conventions/references/locks-and-worktrees.md +113 -0
  34. package/skills/conventions/references/maintenance-and-revisions.md +170 -0
  35. package/skills/conventions/references/roadmaps.md +44 -0
  36. package/skills/conventions/references/tiers-and-dispatch.md +134 -0
  37. package/skills/doctor/SKILL.md +9 -1
  38. package/skills/intent-brainstorming/SKILL.md +4 -0
  39. package/skills/intent-continuing/SKILL.md +4 -0
  40. package/skills/intent-creating/SKILL.md +7 -2
  41. package/skills/intent-ending/SKILL.md +4 -0
  42. package/skills/intent-executing/SKILL.md +10 -0
  43. package/skills/intent-linking/SKILL.md +4 -0
  44. package/skills/intent-locking/SKILL.md +4 -0
  45. package/skills/intent-planning/SKILL.md +7 -0
  46. package/skills/intent-savepoint/SKILL.md +4 -0
  47. package/skills/intent-speccing/SKILL.md +4 -0
  48. package/skills/intent-starting/SKILL.md +10 -0
  49. package/skills/project-creating/references/project-scaffolding.md +2 -2
  50. package/skills/roadmap/SKILL.md +4 -0
  51. package/skills/roadmap-continuing/SKILL.md +4 -0
  52. package/skills/skill-creating/SKILL.md +3 -0
  53. package/skills/skill-creating/references/defaults-first.md +23 -0
  54. package/skills/skill-creating/references/hooks.md +4 -1
  55. package/skills/store-curating/SKILL.md +8 -0
  56. package/skills/store-indexing/SKILL.md +8 -0
  57. package/skills/tutorial/references/track-2-auto.md +2 -3
  58. package/PLASTIC-reference.md +0 -138
  59. package/hooks/code-gate +0 -27
  60. package/hooks/create-gate +0 -3
  61. package/hooks/lock-gate +0 -21
  62. package/hooks/qmd-search +0 -8
  63. package/hooks/retrieval-gate +0 -10
  64. package/hooks/savepoint-pre +0 -10
  65. package/scripts/hook-qmd-search +0 -44
  66. package/scripts/hook-retrieval-gate +0 -148
  67. 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, injected into
36
- # ~/.codex/AGENTS.md. Not a slice of PLASTIC.md (which is already over the 32 KiB
37
- # AGENTS.md merge cap on its own), so it never drifts and carries no maintenance fork.
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
- - The full conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly.
45
- It is generated and overwritten on Plastic updates, so never edit it.
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, File.join(plastic_home, "manifest.json"))
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-qmd-search" => "scripts/hook-qmd-search",
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",
@@ -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 \u{2014} Agent Instructions
417
+ # Plastic: Agent Instructions
407
418
 
408
- Read `PLASTIC.md` in this directory. It contains all Plastic conventions.
409
- Follow it exactly. Never modify it \u{2014} it is overwritten on plugin updates.
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
- def prune_removed_files(stale_files)
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")
@@ -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 string to
9
- # emit, or nil to emit nothing. The executable hook wires real deps and prints;
10
- # this is unit-tested with a fake runner/detector (no real qmd, no network).
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
- # When qmd is present it still injects scored qmd hits (intent 66), then appends
13
- # the PowerTools mandate (a recommendation per present tool: qmd for finding
14
- # intents, Enola-first for code navigation, falling back to Serena; intent 187
15
- # added the enola_detector alongside the pre-existing serena_detector).
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
- MIN_PROMPT_LENGTH = 10
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
- p = prompt.to_s.strip
32
- # The hit SEARCH is the only expensive step and the only one gated by prompt
33
- # triviality: skip it for short or bare-"continue" prompts (and when qmd is
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
@@ -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
- # (PLASTIC-reference.md > Structural maintenance and revisions.md; templates/revisions.md).
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
@@ -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 PLASTIC.md > Terminal immutability),
10
- # because it cannot tell prose from graph metadata and silently destroys
11
- # backlinks written after v1 (the 124/131 incident this tool exists to prevent).
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
- # Restore-to-v1 runs under the MAINTENANCE lock (PLASTIC.md > Terminal
23
- # immutability: the maintenance lock covers sanctioned structural move-and-record
24
- # edits after completion). This tool does NOT acquire, check, or manage that
25
- # lock itself (fail-open doctrine, intent 111: lock management is the
26
- # orchestrator's job, never built into a CLI as a trap); it only prints a
27
- # one-line reminder on --apply.
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 runs under the maintenance lock (PLASTIC.md > Terminal " \
146
- "immutability). Confirm the maintenance lock is held before this --apply."
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,
@@ -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`
@@ -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). QMD hits are still injected when above threshold.",
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.
@@ -0,0 +1,40 @@
1
+ # Completion and Done
2
+
3
+ This chapter holds what "intent done" means and the End-stage tail.
4
+
5
+ #### What "intent done" means (intent 93)
6
+
7
+ Done is one law with three signals, and they must agree. INDEX `## Completed` /
8
+ `## Abandoned` is the single canonical terminal marker: it is the store-wide ledger a fresh
9
+ session reads first, so it wins on any conflict. `outcome.md` is the "deliverable exists"
10
+ signal, and the savepoint `Done delivered|abandoned` line is the audit echo. All three must
11
+ agree; when they disagree, INDEX is authoritative and `doctor` flags the mismatch (the
12
+ `done_signals` check: `outcome.md` real but still under `## Active`, or terminal without a
13
+ real `outcome.md`, or a terminal intent whose savepoint carries no `Done` line).
14
+
15
+ `outcome.md` is mandatory at every terminal transition, delivered and abandoned alike. It
16
+ self-declares its disposition through a `disposition: delivered|abandoned` frontmatter
17
+ header. The delivered path authors it with the result; the abandoned path authors it with
18
+ the abandonment reason and no longer leaves the scaffolded placeholder sentinel in place.
19
+
20
+ The canonical End tail runs in this order, and the QMD reindex is always LAST, after the
21
+ purge: `outcome.md -> INDEX terminal -> savepoint Done -> commit -> disarm (Worktree.release
22
+ -> Lock.release -> purge) -> QMD reindex`. Running the reindex last keeps the index from
23
+ ever referencing a bridge or lock that disarm is about to remove.
24
+
25
+ `scripts/end-intent` performs this order's disarm step (verify the code worktree is clean,
26
+ then merge/remove worktrees, then clear the lock) as its own step 5, mechanically, since
27
+ intent 188: a session no longer needs a separate one-liner for it, and the script's own
28
+ exit code (0) is the single fact a caller needs that the intent is closed AND its delivery
29
+ lock is gone. A pre-flight lock guard runs before anything is written (refuses a live
30
+ foreign session, reclaims a stale one with an audit line), and a dirty code worktree
31
+ refuses before removal rather than force-discarding uncommitted changes.
32
+
33
+ The post-done access window is lock-bounded: `[INDEX terminal -> Lock.release]`. Through it
34
+ the completing session keeps full read and write access to the terminal directory and no
35
+ purge can fire (108's lock-held keep-guard keeps the bridge while `delivery.lock` exists).
36
+ Once the lock is released the window closes: the bridge becomes purge-eligible and the
37
+ directory is frozen. A crash mid-tail is recovered by stale-lock reclaim plus finishing the
38
+ tail; `doctor` surfaces this as a "stalled completion" (terminal in INDEX but the lock is
39
+ still present or stale). Finishing the tail is FINISHING a completion, never a reactivation:
40
+ a done intent is never moved back to `## Active`.
@@ -0,0 +1,23 @@
1
+ # Gates and Enforcement
2
+
3
+ This chapter holds the escape-and-logging depth for each transition gate.
4
+
5
+ #### The gates by name
6
+
7
+ Each gate guards one thing. All are hard:
8
+
9
+ - **create-gate** validates the proposed intent file at What write-time (Write, Edit, and MCP
10
+ edits), so a malformed or incomplete intent never lands.
11
+ - **gate-check** enforces lifecycle stage order (spec.md before plan.md, the plan triplet before
12
+ the checklist, all checklist items before outcome.md).
13
+ - **lock-gate** arbitrates ownership and claims: it admits only the intent's lock owner or a
14
+ registered delegate to write into an active intent directory, and every deny names the
15
+ resolving `plastic-lock` command.
16
+ - **bash-gate** intercepts a write attempted through a bash or interpreter one-liner (a heredoc, a
17
+ `>` redirect, a `ruby -e` or `python -c` write), so the same rules apply whether an edit goes
18
+ through the Write tool or a shell. A trailing `# plastic-ok` comment is an auditable escape that
19
+ lets a deliberate command through, and every use is logged to
20
+ `~/.plastic/.cache/gate-escapes.log`. The code gate (Write and Edit) carries the identical
21
+ audited `# plastic-ok` escape, logged to the same file. The escape does not extend to
22
+ `NotebookEdit` or MCP structural edits: they are still gated, just without an escape hatch.
23
+
@@ -0,0 +1,47 @@
1
+ # Knowledge Graph
2
+
3
+ This chapter holds the linking doctrine from Frontmatter and the branch-vs-root directory semantics from Directory Naming.
4
+
5
+ - `sources` (formative, must-load, acyclic) and `chain` (forward + relational, lighter,
6
+ may cycle) form the directed knowledge graph. Reciprocity is one-directional: every
7
+ `sources` edge has a reciprocal `chain` entry (I1), but `chain` may carry relational
8
+ entries with no reciprocal `sources` (I2), so the graph is not strictly symmetric.
9
+
10
+ - `## Links` (I5) is the human-readable projection of the graph. It mirrors the
11
+ frontmatter exactly: every entry is `- [[id--slug|<target's full intent: text>]]`, a
12
+ clickable `id--slug` wikilink target with the target intent's full `intent:` text as the
13
+ label (cross-store targets render `- [[store:id--slug|<target's full intent: text>]]`).
14
+ Ordering is mandatory: all `sources` first (top), then all `chain`, frontmatter order
15
+ preserved within each group. Sources never appear at the end. No source/chain tags, no
16
+ sub-grouping. An intent with empty `sources` and `chain` carries the empty-state comment.
17
+ - `## Links` is a DERIVED view, not a place to author links (Convention over Configuration).
18
+ It equals the projection of `sources` (first) then `chain`. Never hand-write or hand-edit a
19
+ `## Links` line, and never auto-delete one. The edge lives in the frontmatter graph; the
20
+ section is regenerated from it (doctor `graph_links_projection` enforces this identity). To
21
+ add a link, add the frontmatter edge, then reproject.
22
+
23
+ - Links are decided by CONTEXT INFLUENCE, not by shared files, shared symbols, or a topic
24
+ similarity score. The question is whether one intent's context actually informed another.
25
+ Three tiers:
26
+ - **sources:** the foundational context that shaped this intent's creation (a split, an idea
27
+ born during development, a merge). Earns an edge.
28
+ - **chain:** the context that materially helps DELIVER this intent. This is a HIGH bar: only
29
+ the genuinely delivery-moving intents, not everything in the same area. Earns an edge,
30
+ reflected in `## Links`.
31
+ - **tags:** a loose theme grouping for search. NOT a link. A shared tag is a door INTO the
32
+ store (filtered discovery), not a pathway BETWEEN two notes.
33
+ Judging influence is an agent's call, made by reading the candidate's Intent and Context. A
34
+ script cannot grade it, so `scripts/link-suggest` only gathers candidates with that evidence,
35
+ records a confirmed edge with a rating and reason, and flags drift.
36
+
37
+ **Branch vs root: the semantic decision.** The numbering is mechanics; choosing
38
+ *whether* to branch is meaning:
39
+
40
+ - **Branch (`14a`, `14b`):** a sub-task, refinement, or direct continuation of the
41
+ parent. It cannot stand on its own; it only makes sense as part of the parent's work.
42
+ - **Root (`15`, `16`)**: an independent thought, even if inspired by another intent.
43
+ Reserve `sources` for true created-from provenance (intents this was built out of). An
44
+ independent intent merely related to or inspired by another carries NO `sources`; record
45
+ the relation on the PREDECESSOR's `chain` (and mirror it as a
46
+ `[[id--slug|<target's full intent: text>]]` wikilink in `## Links`).
47
+ - **Rule of thumb:** if the intent could exist without its parent, it's a root.
@@ -0,0 +1,11 @@
1
+ # Lifecycle and Savepoints
2
+
3
+ This chapter holds the subagent report-home depth for how an insight reaches the intent when the writer cannot write the file itself.
4
+
5
+ Background sessions and dispatched sub-agents do not write the insight themselves. They carry
6
+ each nugget home in the completion report's `insights:` field, and the orchestrator (or any
7
+ agent that can write the file) persists it via the helper. A session that cannot write the
8
+ intent file still returns its report, so the insight survives.
9
+
10
+ For the stage table (What/Why/How/Exec, deliverable, owning skill), see PLASTIC.md's Lifecycle
11
+ Stages section; each named skill's own `references/` holds that stage's own depth.