@zalom/plastic 1.0.0-beta.9 → 1.0.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-reference.md +133 -0
- package/PLASTIC.md +398 -82
- package/README.md +99 -82
- package/agents/plastic-brainstorming.md +12 -9
- package/agents/plastic-enforcer.md +52 -12
- package/agents/plastic-executor.md +10 -10
- package/agents/plastic-future-intent-researcher.md +4 -8
- package/agents/plastic-intent-curator.md +12 -14
- package/agents/plastic-intent-discovery.md +35 -0
- package/agents/plastic-planner.md +15 -10
- package/agents/plastic-spec-specialist.md +15 -10
- package/bin/plastic.js +7 -3
- package/hooks/code-gate +10 -1
- package/hooks/hooks.json +28 -3
- package/hooks/lock-gate +21 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/hooks/statusline +48 -6
- package/package.json +2 -1
- package/scripts/agent-report +21 -0
- package/scripts/dashboard.rb +98 -23
- package/scripts/doctor.rb +232 -5
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +22 -4
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-continue +17 -0
- package/scripts/hook-create-gate +45 -9
- package/scripts/hook-gate-check +24 -23
- package/scripts/hook-lock-gate +83 -0
- package/scripts/hook-retrieval-gate +148 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/install.rb +44 -7
- package/scripts/lib/agent_models.rb +43 -0
- package/scripts/lib/bridge.rb +924 -84
- package/scripts/lib/dashboard_banner.rb +42 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +100 -60
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/lock.rb +375 -0
- package/scripts/lib/power_tools.rb +22 -21
- package/scripts/lib/preflight.rb +79 -0
- package/scripts/lib/qmd_sync.rb +15 -0
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +384 -0
- package/scripts/link-suggest +213 -0
- package/scripts/new-intent +9 -1
- package/scripts/plastic-lock +164 -0
- package/scripts/read-config +4 -0
- package/scripts/spawn-preamble +11 -3
- package/scripts/update.rb +16 -7
- package/skills/auto/SKILL.md +126 -19
- package/skills/auto/references/agent-architecture.md +7 -4
- package/skills/auto/references/agent-report-contract.md +36 -1
- package/skills/auto/references/end-tail.md +56 -0
- package/skills/auto/references/human-report-contract.md +55 -0
- package/skills/auto/references/tiers.md +77 -0
- package/skills/brainstorming/SKILL.md +7 -34
- package/skills/brainstorming/references/design-principles.md +49 -0
- package/skills/continuing/SKILL.md +26 -7
- package/skills/creating-intent/SKILL.md +13 -28
- package/skills/creating-project/SKILL.md +11 -74
- package/skills/creating-project/references/project-scaffolding.md +97 -0
- package/skills/creating-skills/SKILL.md +65 -0
- package/skills/creating-skills/evals/evals.json +108 -0
- package/skills/creating-skills/references/agents.md +168 -0
- package/skills/creating-skills/references/evals.md +41 -0
- package/skills/creating-skills/references/hooks.md +248 -0
- package/skills/creating-skills/references/progressive-disclosure.md +176 -0
- package/skills/creating-skills/references/scripts.md +166 -0
- package/skills/creating-skills/references/skills.md +165 -0
- package/skills/creating-skills/scripts/scaffold.rb +313 -0
- package/skills/dashboard/SKILL.md +13 -11
- package/skills/dashboard/references/classification.md +22 -0
- package/skills/doctor/SKILL.md +10 -7
- package/skills/executing-plan/SKILL.md +4 -4
- package/skills/humanizer/SKILL.md +39 -0
- package/skills/humanizer/evals/evals.json +70 -0
- package/skills/humanizer/references/always-on-snippet.md +9 -0
- package/skills/humanizer/references/examples.md +48 -0
- package/skills/install/SKILL.md +75 -84
- package/skills/intent-curator/SKILL.md +5 -1
- package/skills/intent-discovery/SKILL.md +47 -0
- package/skills/intent-starting/SKILL.md +130 -0
- package/skills/intent-starting/evals/evals.json +117 -0
- package/skills/intent-starting/references/boarding-matrix.md +35 -0
- package/skills/linking-intents/SKILL.md +48 -12
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/releasing/SKILL.md +44 -29
- package/skills/releasing/references/promotion-and-tagging.md +60 -0
- package/skills/roadmap/SKILL.md +51 -0
- package/skills/roadmap/references/file-format.md +91 -0
- package/skills/roadmap/references/operations.md +78 -0
- package/skills/uninstall/SKILL.md +29 -11
- package/skills/update/SKILL.md +34 -23
- package/skills/versions/SKILL.md +27 -12
- package/skills/writing-plans/SKILL.md +10 -88
- package/skills/writing-plans/references/plan-format.md +102 -0
- package/templates/config.yml +8 -0
- package/templates/outcome.md +3 -0
- package/templates/revisions.md +58 -0
- package/templates/roadmap.md +30 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- package/skills/writing-instructions/references/agentskills-spec.md +0 -135
package/scripts/doctor.rb
CHANGED
|
@@ -20,6 +20,9 @@ require_relative "lib/intent_validator"
|
|
|
20
20
|
require_relative "lib/graph_rebuild"
|
|
21
21
|
require_relative "lib/links_projection"
|
|
22
22
|
require_relative "lib/links_section"
|
|
23
|
+
require_relative "lib/hook_registry"
|
|
24
|
+
require_relative "lib/lock"
|
|
25
|
+
require_relative "lib/bridge"
|
|
23
26
|
|
|
24
27
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
25
28
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -472,7 +475,10 @@ class Doctor
|
|
|
472
475
|
category: "conventions", name: "section_structure", status: "warn",
|
|
473
476
|
message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
|
|
474
477
|
details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
|
|
475
|
-
fixable:
|
|
478
|
+
fixable: true,
|
|
479
|
+
fix_hint: "Dispatch plastic-intent-curator to relocate each unsanctioned section into the " \
|
|
480
|
+
"intent's revisions.md via move-and-record (a missing required section is restored " \
|
|
481
|
+
"or reprojected instead); see PLASTIC.md > Structural maintenance and revisions.md"
|
|
476
482
|
)
|
|
477
483
|
end
|
|
478
484
|
|
|
@@ -501,6 +507,184 @@ class Doctor
|
|
|
501
507
|
checks
|
|
502
508
|
end
|
|
503
509
|
|
|
510
|
+
# --- Check: the three done-signals agree + stalled completion (intent 93) ---
|
|
511
|
+
#
|
|
512
|
+
# "Done" is one law with three signals that MUST agree: INDEX
|
|
513
|
+
# `## Completed`/`## Abandoned` is the single canonical terminal marker;
|
|
514
|
+
# `outcome.md` is the "deliverable exists" signal (real, non-placeholder, with a
|
|
515
|
+
# `disposition:` header); the savepoint `Done` line is the audit echo. INDEX
|
|
516
|
+
# wins on conflict. This check flags any disagreement, and separately surfaces a
|
|
517
|
+
# "stalled completion": an intent that is terminal in INDEX but whose End tail
|
|
518
|
+
# never released its `delivery.lock` (the post-done window never closed). Read
|
|
519
|
+
# only, dependency-light: it uses INDEX parsing, file presence, the placeholder
|
|
520
|
+
# sentinel, and `Lock.fresh?` — no new lock, no 111 lock-liveness surface.
|
|
521
|
+
|
|
522
|
+
# For one store's INDEX.md, map each referenced intent directory name to the
|
|
523
|
+
# `## ...` section(s) it appears under: { "<id>--<slug>" => ["Active", ...] }.
|
|
524
|
+
def index_sections_by_dir(index_path)
|
|
525
|
+
sections = Hash.new { |h, k| h[k] = [] }
|
|
526
|
+
return sections unless File.exist?(index_path)
|
|
527
|
+
|
|
528
|
+
current = nil
|
|
529
|
+
File.foreach(index_path) do |line|
|
|
530
|
+
if (m = line.match(/^##\s+(.+?)\s*$/))
|
|
531
|
+
current = m[1]
|
|
532
|
+
next
|
|
533
|
+
end
|
|
534
|
+
next unless current
|
|
535
|
+
|
|
536
|
+
line.scan(%r{store/([\w][\w.-]*?)(?:/|\))}) do |(dirname)|
|
|
537
|
+
sections[dirname] << current unless sections[dirname].include?(current)
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
sections
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
# Stores to reconcile: the global store plus each project store, filtered by
|
|
544
|
+
# `scopes` (nil = all) exactly like check_conventions.
|
|
545
|
+
def done_signal_stores(scopes)
|
|
546
|
+
stores = []
|
|
547
|
+
|
|
548
|
+
if scopes.nil? || scopes.include?("global")
|
|
549
|
+
stores << {
|
|
550
|
+
scope: "global",
|
|
551
|
+
index: File.join(plastic_home, "INDEX.md"),
|
|
552
|
+
store_dir: File.join(plastic_home, "store"),
|
|
553
|
+
}
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
projects_root = File.join(plastic_home, "projects")
|
|
557
|
+
if File.directory?(projects_root)
|
|
558
|
+
Dir.children(projects_root).sort.each do |project|
|
|
559
|
+
scope = "project:#{project}"
|
|
560
|
+
next unless scopes.nil? || scopes.include?(scope)
|
|
561
|
+
|
|
562
|
+
store_dir = File.join(projects_root, project, "store")
|
|
563
|
+
next unless File.directory?(store_dir)
|
|
564
|
+
|
|
565
|
+
stores << {
|
|
566
|
+
scope: scope,
|
|
567
|
+
index: File.join(projects_root, project, "INDEX.md"),
|
|
568
|
+
store_dir: store_dir,
|
|
569
|
+
}
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
stores
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def check_done_signals(scopes: nil)
|
|
577
|
+
conflicts = [] # canonical INDEX-wins disagreement (fail)
|
|
578
|
+
gaps = [] # terminal completeness gaps on immutable/legacy history (warn)
|
|
579
|
+
stalled = [] # terminal but End tail unfinished (warn)
|
|
580
|
+
|
|
581
|
+
done_signal_stores(scopes).each do |store|
|
|
582
|
+
index_sections_by_dir(store[:index]).each do |dirname, in_sections|
|
|
583
|
+
dir = File.join(store[:store_dir], dirname)
|
|
584
|
+
next unless File.directory?(dir)
|
|
585
|
+
|
|
586
|
+
terminal = (in_sections & ["Completed", "Abandoned"]).any?
|
|
587
|
+
active = in_sections.include?("Active") && !terminal
|
|
588
|
+
outcome = File.join(dir, "outcome.md")
|
|
589
|
+
outcome_real = Bridge.stage_file_present?(outcome)
|
|
590
|
+
label = "#{store[:scope]} store/#{dirname}"
|
|
591
|
+
|
|
592
|
+
# HARD conflict: the deliverable exists but INDEX still says Active. This
|
|
593
|
+
# is the one true INDEX-wins disagreement, so it stays a fail.
|
|
594
|
+
if active && outcome_real
|
|
595
|
+
conflicts << "#{label}: outcome.md is real but the intent is still under ## Active " \
|
|
596
|
+
"(INDEX is canonical — move it to its terminal section or revert outcome.md)"
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Completeness gap (warn, not fail): a terminal intent whose outcome.md is
|
|
600
|
+
# missing/placeholder. Legacy terminal intents predate this convention and
|
|
601
|
+
# are immutable, so this is advisory, never breakage.
|
|
602
|
+
if terminal && !outcome_real
|
|
603
|
+
state = File.exist?(outcome) ? "still a placeholder" : "missing"
|
|
604
|
+
gaps << "#{label}: terminal in INDEX but outcome.md is #{state} " \
|
|
605
|
+
"(author outcome.md with the disposition header)"
|
|
606
|
+
end
|
|
607
|
+
|
|
608
|
+
next unless terminal
|
|
609
|
+
|
|
610
|
+
# Audit echo (weakest signal, D1): the savepoint should carry a
|
|
611
|
+
# `Done delivered|abandoned` line. Missing on legacy history -> advisory gap.
|
|
612
|
+
savepoint = File.join(dir, "savepoint.md")
|
|
613
|
+
if File.exist?(savepoint) && File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
|
|
614
|
+
gaps << "#{label}: terminal in INDEX but savepoint.md has no " \
|
|
615
|
+
"`Done delivered|abandoned` line (audit echo missing)"
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Stalled completion: the End tail never released the delivery lock, so the
|
|
619
|
+
# post-done window `[INDEX terminal -> Lock.release]` never closed.
|
|
620
|
+
if File.exist?(Lock.path(dir))
|
|
621
|
+
note = Lock.fresh?(dir) ? "delivery.lock still present (post-done window not closed)"
|
|
622
|
+
: "delivery.lock is present and STALE"
|
|
623
|
+
stalled << "#{label}: #{note} — the End tail did not finish"
|
|
624
|
+
end
|
|
625
|
+
end
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
checks = []
|
|
629
|
+
|
|
630
|
+
# signals_agree: only the canonical INDEX-wins conflict is a hard fail, so
|
|
631
|
+
# `doctor` never goes red on immutable legacy terminal intents.
|
|
632
|
+
if conflicts.empty?
|
|
633
|
+
checks << check(
|
|
634
|
+
category: "done_signals", name: "signals_agree", status: "pass",
|
|
635
|
+
message: "No done-signal conflicts (no intent has a real outcome.md while still Active)"
|
|
636
|
+
)
|
|
637
|
+
else
|
|
638
|
+
checks << check(
|
|
639
|
+
category: "done_signals", name: "signals_agree", status: "fail",
|
|
640
|
+
message: "#{conflicts.size} done-signal conflict#{conflicts.size == 1 ? "" : "s"} " \
|
|
641
|
+
"(INDEX ## Completed/## Abandoned is canonical; a real outcome.md must not stay Active)",
|
|
642
|
+
details: conflicts, fixable: true,
|
|
643
|
+
fix_hint: "Reconcile to INDEX (canonical): move the intent to its terminal section, or revert " \
|
|
644
|
+
"outcome.md to a placeholder. Deliverable-exists but still-Active is the one done-signal " \
|
|
645
|
+
"state that must never persist."
|
|
646
|
+
)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# signals_complete: terminal completeness gaps are advisory (warn), because
|
|
650
|
+
# completed intents are immutable and legacy history predates this convention.
|
|
651
|
+
if gaps.empty?
|
|
652
|
+
checks << check(
|
|
653
|
+
category: "done_signals", name: "signals_complete", status: "pass",
|
|
654
|
+
message: "Every terminal intent carries a real outcome.md and a Done savepoint echo"
|
|
655
|
+
)
|
|
656
|
+
else
|
|
657
|
+
checks << check(
|
|
658
|
+
category: "done_signals", name: "signals_complete", status: "warn",
|
|
659
|
+
message: "#{gaps.size} terminal completeness gap#{gaps.size == 1 ? "" : "s"} " \
|
|
660
|
+
"(outcome.md or the savepoint Done echo is missing; advisory on immutable history)",
|
|
661
|
+
details: gaps, fixable: true,
|
|
662
|
+
fix_hint: "For live terminals, author a real outcome.md with the `disposition:` header via the " \
|
|
663
|
+
"completion/abandon path (plastic-auto or plastic-intent-curator) and stamp the terminal " \
|
|
664
|
+
"savepoint. Legacy terminal intents are immutable, so pre-convention gaps stay advisory."
|
|
665
|
+
)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
if stalled.empty?
|
|
669
|
+
checks << check(
|
|
670
|
+
category: "done_signals", name: "stalled_completion", status: "pass",
|
|
671
|
+
message: "No stalled completions (every terminal intent released its delivery lock)"
|
|
672
|
+
)
|
|
673
|
+
else
|
|
674
|
+
checks << check(
|
|
675
|
+
category: "done_signals", name: "stalled_completion", status: "warn",
|
|
676
|
+
message: "#{stalled.size} stalled completion#{stalled.size == 1 ? "" : "s"} " \
|
|
677
|
+
"(terminal in INDEX but the End tail did not finish)",
|
|
678
|
+
details: stalled, fixable: true,
|
|
679
|
+
fix_hint: "Finish the End tail via stale-lock reclaim: `plastic-lock reclaim`, then complete the " \
|
|
680
|
+
"tail (Worktree.release -> Lock.release -> purge -> QMD reindex last). This FINISHES a " \
|
|
681
|
+
"completion; it is NOT a reactivation of a done intent."
|
|
682
|
+
)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
checks
|
|
686
|
+
end
|
|
687
|
+
|
|
504
688
|
# Build the cross-store node maps (basename + label per store) + relocation map
|
|
505
689
|
# from ALL stores, then for every intent compute its canonical `## Links`
|
|
506
690
|
# projection and flag any whose ACTUAL `## Links` section differs (membership or
|
|
@@ -698,7 +882,9 @@ class Doctor
|
|
|
698
882
|
checks << graph_finding_check(
|
|
699
883
|
"graph_i4_danglers", i4,
|
|
700
884
|
"Every sources/chain id resolves to a real intent (I4)",
|
|
701
|
-
"
|
|
885
|
+
"Dispatch plastic-intent-curator to record the dangling sources/chain edge as a " \
|
|
886
|
+
"broken-source/broken-chain move-and-record entry in the intent's revisions.md (see " \
|
|
887
|
+
"PLASTIC.md > Structural maintenance and revisions.md), or restore the missing intent"
|
|
702
888
|
)
|
|
703
889
|
checks
|
|
704
890
|
end
|
|
@@ -822,6 +1008,45 @@ class Doctor
|
|
|
822
1008
|
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
823
1009
|
)
|
|
824
1010
|
end
|
|
1011
|
+
|
|
1012
|
+
# hooks_match_registry (intent 108, D7): the live settings must carry
|
|
1013
|
+
# EXACTLY the registrations HookRegistry defines; any drift (a missing
|
|
1014
|
+
# gate, a stray plastic hook, a stale matcher) is how bash-gate shipped
|
|
1015
|
+
# dead once already.
|
|
1016
|
+
expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
|
|
1017
|
+
diffs = []
|
|
1018
|
+
expected.each do |event, group|
|
|
1019
|
+
groups = group.is_a?(Array) ? group : [group]
|
|
1020
|
+
live = settings.dig("hooks", event) || []
|
|
1021
|
+
groups.each do |g|
|
|
1022
|
+
matches = live.select { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
|
|
1023
|
+
wanted = g["hooks"].map { |h| h["command"] }
|
|
1024
|
+
got = matches.flat_map { |m| Array(m["hooks"]).map { |h| h["command"] } }
|
|
1025
|
+
missing = wanted - got
|
|
1026
|
+
diffs << "#{event}[#{g['matcher']}] missing: #{missing.join(', ')}" unless missing.empty?
|
|
1027
|
+
end
|
|
1028
|
+
end
|
|
1029
|
+
live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
|
|
1030
|
+
Array(groups).flat_map do |g|
|
|
1031
|
+
next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
|
|
1032
|
+
g["hooks"].map { |h| h["command"].to_s }.select { |c| c.include?("plastic-") }
|
|
1033
|
+
.map { |c| "#{event}: #{c}" }
|
|
1034
|
+
end
|
|
1035
|
+
end
|
|
1036
|
+
expected_cmds = expected.values.flat_map { |g| g.is_a?(Array) ? g : [g] }
|
|
1037
|
+
.flat_map { |g| g["hooks"].map { |h| h["command"] } }
|
|
1038
|
+
strays = live_plastic.reject { |lp| expected_cmds.any? { |c| lp.end_with?(c) } }
|
|
1039
|
+
diffs.concat(strays.map { |s| "stray: #{s}" })
|
|
1040
|
+
|
|
1041
|
+
checks << if diffs.empty?
|
|
1042
|
+
check(category: "agent_registration", name: "hooks_match_registry",
|
|
1043
|
+
status: "pass", message: "settings.json hooks match HookRegistry")
|
|
1044
|
+
else
|
|
1045
|
+
check(category: "agent_registration", name: "hooks_match_registry",
|
|
1046
|
+
status: "fail", message: "#{diffs.size} hook registration(s) diverge from HookRegistry",
|
|
1047
|
+
details: diffs, fixable: true,
|
|
1048
|
+
fix_hint: "Re-run the installer merge: npx @zalom/plastic update (or ruby ~/.plastic/scripts/install.rb)")
|
|
1049
|
+
end
|
|
825
1050
|
end
|
|
826
1051
|
|
|
827
1052
|
# skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
|
|
@@ -1412,6 +1637,7 @@ class Doctor
|
|
|
1412
1637
|
all_checks += check_project_stores
|
|
1413
1638
|
all_checks += check_deprecations
|
|
1414
1639
|
all_checks += check_qmd
|
|
1640
|
+
all_checks += check_done_signals
|
|
1415
1641
|
|
|
1416
1642
|
summarize(all_checks, agent_key)
|
|
1417
1643
|
end
|
|
@@ -1438,9 +1664,9 @@ class Doctor
|
|
|
1438
1664
|
all_checks =
|
|
1439
1665
|
case store
|
|
1440
1666
|
when :all
|
|
1441
|
-
check_global_store + check_project_stores + check_conventions
|
|
1667
|
+
check_global_store + check_project_stores + check_conventions + check_done_signals
|
|
1442
1668
|
when :global
|
|
1443
|
-
check_global_store + check_conventions(scopes: ["global"])
|
|
1669
|
+
check_global_store + check_conventions(scopes: ["global"]) + check_done_signals(scopes: ["global"])
|
|
1444
1670
|
else
|
|
1445
1671
|
all_checks_for_project_slug(store)
|
|
1446
1672
|
end
|
|
@@ -1463,7 +1689,8 @@ class Doctor
|
|
|
1463
1689
|
end
|
|
1464
1690
|
|
|
1465
1691
|
check_project_store(slug, projects[slug]) +
|
|
1466
|
-
check_conventions(scopes: ["project:#{slug}"])
|
|
1692
|
+
check_conventions(scopes: ["project:#{slug}"]) +
|
|
1693
|
+
check_done_signals(scopes: ["project:#{slug}"])
|
|
1467
1694
|
end
|
|
1468
1695
|
|
|
1469
1696
|
# Roll a list of checks up into the standard result envelope.
|
package/scripts/hook-auto-arm
CHANGED
|
@@ -26,7 +26,7 @@ if triggered
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# --- 2. Standing gate reminder when armed and pre-How ---
|
|
29
|
-
bridge_data = Bridge.discover_bridge(session: ENV["
|
|
29
|
+
bridge_data = Bridge.discover_bridge(session: ENV["CLAUDE_CODE_SESSION_ID"], cwd: Dir.pwd)
|
|
30
30
|
if bridge_data && bridge_data.dig("build", "auto") == true
|
|
31
31
|
intent = bridge_data["intent"] || {}
|
|
32
32
|
store = intent["store"]; dir = intent["dir"]
|
package/scripts/hook-bash-gate
CHANGED
|
@@ -28,13 +28,31 @@ exit 0 if command.nil? || command.to_s.strip.empty?
|
|
|
28
28
|
cwd = payload["cwd"]
|
|
29
29
|
cwd = Dir.pwd if cwd.nil? || cwd.to_s.empty?
|
|
30
30
|
|
|
31
|
-
# --- Load bridge (shared resolution; stdin session_id -> env -> /tmp scan) ---
|
|
32
31
|
session = payload["session_id"]
|
|
33
|
-
session = ENV["
|
|
32
|
+
session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.to_s.empty?
|
|
33
|
+
|
|
34
|
+
# Auditable escape (intent 108, D7): a trailing `# plastic-ok` allows the
|
|
35
|
+
# command and logs it, so sanctioned writes are visible, not silent.
|
|
36
|
+
if Bridge.bash_escape?(command)
|
|
37
|
+
begin
|
|
38
|
+
require "fileutils"
|
|
39
|
+
log = File.join(Dir.home, ".plastic", ".cache", "gate-escapes.log")
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(log))
|
|
41
|
+
File.open(log, "a") do |io|
|
|
42
|
+
io.puts("#{Time.now.utc.iso8601}\t#{session}\t#{command.gsub(/\s+/, ' ').strip}")
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError
|
|
45
|
+
# the escape still applies; logging is best-effort
|
|
46
|
+
end
|
|
47
|
+
exit 0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
51
|
+
# A nil bridge does NOT short-circuit (intent 108): the lock gate decides from
|
|
52
|
+
# the durable delivery.lock file, so it must run even without a bridge cache.
|
|
34
53
|
bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
|
|
35
|
-
exit 0 unless bridge_data
|
|
36
54
|
|
|
37
|
-
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
|
|
55
|
+
reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd, session: session)
|
|
38
56
|
exit 0 unless reason
|
|
39
57
|
|
|
40
58
|
$stderr.puts "PLASTIC GATE — #{reason}"
|
package/scripts/hook-code-gate
CHANGED
|
@@ -3,24 +3,29 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# Usage: hook-code-gate <file_path>
|
|
6
|
-
# PreToolUse gate
|
|
7
|
-
#
|
|
6
|
+
# PreToolUse gate. Composes two independent block rules; EITHER blocks the edit:
|
|
7
|
+
# - Stage rule (intent 27): when auto mode is armed and the active intent has not
|
|
8
|
+
# reached How (plan.md + checklist.md), block edits to project code outside the store.
|
|
9
|
+
# - Worktree isolation rule (intent 73c2): when the intent has a provisioned code
|
|
10
|
+
# worktree, block project-code edits outside it; and block edits to another
|
|
11
|
+
# intent's store dir whose delivery lock is held by a live non-owner session.
|
|
8
12
|
#
|
|
9
13
|
# Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
|
|
10
|
-
# No bridge
|
|
14
|
+
# No bridge = allow. Each rule fails open on its own conditions (see bridge.rb).
|
|
11
15
|
|
|
12
16
|
require_relative "lib/bridge"
|
|
13
17
|
|
|
14
18
|
file_path = ARGV[0]
|
|
15
19
|
exit 0 unless file_path && !file_path.empty?
|
|
16
20
|
|
|
17
|
-
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["
|
|
21
|
+
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
18
22
|
|
|
19
|
-
# --- Load bridge (shared resolution; stdin session_id ->
|
|
23
|
+
# --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
20
24
|
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
|
|
21
25
|
exit 0 unless bridge_data
|
|
22
26
|
|
|
23
|
-
reason = Bridge.code_gate_decision(bridge_data, file_path)
|
|
27
|
+
reason = Bridge.code_gate_decision(bridge_data, file_path) ||
|
|
28
|
+
Bridge.worktree_gate_decision(bridge_data, file_path, current_session: session)
|
|
24
29
|
exit 0 unless reason
|
|
25
30
|
|
|
26
31
|
$stderr.puts "PLASTIC GATE — #{reason}"
|
package/scripts/hook-continue
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
require "json"
|
|
9
9
|
require "open3"
|
|
10
|
+
require_relative "lib/dashboard_banner"
|
|
10
11
|
|
|
11
12
|
index_path, _store_root, _mode = ARGV
|
|
12
13
|
exit 0 unless index_path && File.exist?(index_path)
|
|
@@ -28,4 +29,20 @@ payload = {
|
|
|
28
29
|
"additionalContext" => context
|
|
29
30
|
}
|
|
30
31
|
}
|
|
32
|
+
|
|
33
|
+
# Hook-owned systemMessage floor (intent 125, Task 6): a hard fallback summary
|
|
34
|
+
# independent of the agent's reply, mirroring hook-session-start's BootBanner
|
|
35
|
+
# pattern. Best-effort only — any failure here (subprocess, JSON, renderer)
|
|
36
|
+
# degrades silently, omitting systemMessage; this hook must never crash
|
|
37
|
+
# UserPromptSubmit over a summary line.
|
|
38
|
+
begin
|
|
39
|
+
data_json, _data_err, data_status = Open3.capture3("ruby", dashboard, "continue", "--data")
|
|
40
|
+
if data_status.success?
|
|
41
|
+
banner = DashboardBanner.render(JSON.parse(data_json))
|
|
42
|
+
payload["systemMessage"] = banner if banner
|
|
43
|
+
end
|
|
44
|
+
rescue StandardError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
31
48
|
puts JSON.generate(payload)
|
package/scripts/hook-create-gate
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
# the on-disk file (which does not exist yet at PreToolUse), using IntentValidator
|
|
10
10
|
# for born-complete frontmatter plus the sanctioned section structure.
|
|
11
11
|
#
|
|
12
|
-
# It depends ONLY on the stdin path + content, never on the auto-bridge or
|
|
13
|
-
#
|
|
12
|
+
# It depends ONLY on the stdin path + content, never on the auto-bridge or any
|
|
13
|
+
# session id, so it runs unconditionally (headless / background sessions).
|
|
14
14
|
# It validates ONLY the intent file, never sentinel placeholder lifecycle files
|
|
15
15
|
# (spec.md/plan.md/etc.); the path matcher excludes them.
|
|
16
16
|
#
|
|
@@ -19,8 +19,13 @@
|
|
|
19
19
|
# Reads the Claude Code PreToolUse payload as JSON on STDIN:
|
|
20
20
|
# { "tool_input": { "file_path": "...", "content": "..." } }
|
|
21
21
|
# Empty / unparseable / non-matching path => exit 0 (cannot judge, allow).
|
|
22
|
-
#
|
|
23
|
-
#
|
|
22
|
+
# Three payload shapes on a matching path (intent 108, D7):
|
|
23
|
+
# Write (content): validate the proposed content.
|
|
24
|
+
# Edit (old_string): simulate the replacement on the on-disk file and
|
|
25
|
+
# validate the RESULT; a missing file blocks.
|
|
26
|
+
# Pathless MCP mutation: validate the CURRENT on-disk file (the PostToolUse
|
|
27
|
+
# gate-check backstop validates the result); a
|
|
28
|
+
# missing file blocks (fail-safe).
|
|
24
29
|
|
|
25
30
|
require "json"
|
|
26
31
|
require_relative "lib/intent_validator"
|
|
@@ -43,14 +48,45 @@ is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
|
|
|
43
48
|
File.basename(abs) == "#{File.basename(dir)}.md"
|
|
44
49
|
exit 0 unless is_intent_file
|
|
45
50
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
input = payload["tool_input"] || payload["tool_params"] || {}
|
|
52
|
+
content = input["content"]
|
|
53
|
+
old_string = input["old_string"]
|
|
54
|
+
|
|
55
|
+
def block!(lines)
|
|
56
|
+
Array(lines).each { |l| $stderr.puts l }
|
|
50
57
|
exit 2
|
|
51
58
|
end
|
|
52
59
|
|
|
53
|
-
result =
|
|
60
|
+
result =
|
|
61
|
+
if !content.nil?
|
|
62
|
+
# Write: validate the proposed content (intent 60b, unchanged).
|
|
63
|
+
IntentValidator.validate_content(content)
|
|
64
|
+
elsif !old_string.nil?
|
|
65
|
+
# Edit (intent 108, D7): simulate the replacement on the on-disk file and
|
|
66
|
+
# validate the RESULT, so an Edit can no longer sneak an intent file past
|
|
67
|
+
# the validator.
|
|
68
|
+
unless File.exist?(abs)
|
|
69
|
+
block!("PLASTIC CREATE GATE — #{File.basename(abs)} does not exist; " \
|
|
70
|
+
"create intents via new-intent / plastic-creating-intent.")
|
|
71
|
+
end
|
|
72
|
+
current = File.read(abs)
|
|
73
|
+
unless current.include?(old_string)
|
|
74
|
+
exit 0 # the Edit tool itself will fail; nothing lands, nothing to judge
|
|
75
|
+
end
|
|
76
|
+
simulated = input["replace_all"] ? current.gsub(old_string, input["new_string"].to_s)
|
|
77
|
+
: current.sub(old_string, input["new_string"].to_s)
|
|
78
|
+
IntentValidator.validate_content(simulated)
|
|
79
|
+
else
|
|
80
|
+
# Pathless mutation (MCP symbolic edits): the proposal is not visible at
|
|
81
|
+
# PreToolUse. Judge the CURRENT file: valid -> allow (the PostToolUse
|
|
82
|
+
# gate-check backstop validates the result), invalid/missing -> block.
|
|
83
|
+
unless File.exist?(abs)
|
|
84
|
+
block!("PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed " \
|
|
85
|
+
"content and no file exists; refusing to allow an unvalidated intent write.")
|
|
86
|
+
end
|
|
87
|
+
IntentValidator.validate_content(File.read(abs))
|
|
88
|
+
end
|
|
89
|
+
|
|
54
90
|
exit 0 if result[:ok]
|
|
55
91
|
|
|
56
92
|
$stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
|
package/scripts/hook-gate-check
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
require "json"
|
|
10
10
|
require_relative "lib/bridge"
|
|
11
11
|
require_relative "lib/intent_validator"
|
|
12
|
+
require_relative "lib/lock"
|
|
12
13
|
|
|
13
14
|
file_path = ARGV[0]
|
|
14
15
|
exit 0 unless file_path && !file_path.empty?
|
|
15
16
|
|
|
16
|
-
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["
|
|
17
|
+
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
17
18
|
|
|
18
19
|
file_path_abs = File.expand_path(file_path)
|
|
19
20
|
|
|
@@ -24,6 +25,12 @@ intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
|
|
|
24
25
|
if intent_dir_abs
|
|
25
26
|
begin
|
|
26
27
|
Bridge.append_savepoint(intent_dir_abs, file_path_abs)
|
|
28
|
+
# When checklist.md lands, How ends and Exec begins: emit the `Exec started`
|
|
29
|
+
# companion in the same event (intent 81). Guard on a real (non-placeholder)
|
|
30
|
+
# checklist so a scaffold sentinel does not trip it.
|
|
31
|
+
if File.basename(file_path_abs) == "checklist.md" && Bridge.stage_file_present?(file_path_abs)
|
|
32
|
+
Bridge.append_exec_started(intent_dir_abs)
|
|
33
|
+
end
|
|
27
34
|
rescue StandardError
|
|
28
35
|
# ignore — rebuildable from disk
|
|
29
36
|
end
|
|
@@ -66,6 +73,15 @@ intent_dir = "#{store}/#{dir}"
|
|
|
66
73
|
# Normalize both paths for comparison
|
|
67
74
|
bridge_intent_dir_abs = File.expand_path(intent_dir)
|
|
68
75
|
|
|
76
|
+
# Lease heartbeat (intent 108, D1): every write by the session that owns (or
|
|
77
|
+
# delegates under) the lock refreshes the delivery.lock mtime. Best-effort:
|
|
78
|
+
# a heartbeat failure must never break the gate hook.
|
|
79
|
+
begin
|
|
80
|
+
Lock.heartbeat(bridge_intent_dir_abs, session: session)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
# ignore
|
|
83
|
+
end
|
|
84
|
+
|
|
69
85
|
# Not inside intent dir = not our business
|
|
70
86
|
exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
|
|
71
87
|
|
|
@@ -112,32 +128,17 @@ if is_stage_file || is_action_file
|
|
|
112
128
|
|
|
113
129
|
Bridge.write(session, bridge_data)
|
|
114
130
|
|
|
115
|
-
# Build transition context
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
"done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
context_parts = ["PLASTIC"]
|
|
125
|
-
if old_stage != new_stage
|
|
126
|
-
context_parts[0] = "PLASTIC — Stage transition: #{stage_labels[old_stage] || old_stage} → #{stage_labels[new_stage] || new_stage}."
|
|
127
|
-
else
|
|
128
|
-
context_parts[0] = "PLASTIC — #{basename} written (stage: #{stage_labels[new_stage] || new_stage})."
|
|
129
|
-
end
|
|
130
|
-
context_parts << "#{basename} written." if old_stage != new_stage
|
|
131
|
-
if new_missing.any?
|
|
132
|
-
context_parts << "Next: #{new_missing.join(", ")}"
|
|
133
|
-
elsif next_hints[new_stage]
|
|
134
|
-
context_parts << "Next: #{next_hints[new_stage]}"
|
|
135
|
-
end
|
|
131
|
+
# Build transition context — ONE concise sentence (intent 84, Lever 1),
|
|
132
|
+
# preserving the `Next: ...` hint. Formatting is pure in Bridge.gate_narration.
|
|
133
|
+
context = Bridge.gate_narration(
|
|
134
|
+
old_stage: old_stage, new_stage: new_stage,
|
|
135
|
+
basename: basename, new_missing: new_missing
|
|
136
|
+
)
|
|
136
137
|
|
|
137
138
|
payload = {
|
|
138
139
|
"hookSpecificOutput" => {
|
|
139
140
|
"hookEventName" => "PostToolUse",
|
|
140
|
-
"additionalContext" =>
|
|
141
|
+
"additionalContext" => context
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
puts JSON.generate(payload)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
#
|
|
5
|
+
# Usage: hook-lock-gate <file_path> [session_id]
|
|
6
|
+
# Fail-CLOSED PreToolUse gate (intent 96). Blocks a mutating write to an active
|
|
7
|
+
# intent's lifecycle dir when THIS session holds no live lock. Project code is NOT
|
|
8
|
+
# gated here (D2). Emits the PreToolUse JSON deny contract at exit 0; reserves
|
|
9
|
+
# non-zero exit for hook-internal errors only. Matcher Write|Edit|NotebookEdit, so
|
|
10
|
+
# reads never reach here.
|
|
11
|
+
require "json"
|
|
12
|
+
require_relative "lib/bridge"
|
|
13
|
+
require_relative "lib/lock"
|
|
14
|
+
|
|
15
|
+
begin
|
|
16
|
+
file_path = ARGV[0]
|
|
17
|
+
exit 0 unless file_path && !file_path.empty?
|
|
18
|
+
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
19
|
+
|
|
20
|
+
bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd) # nil = no bridge cache
|
|
21
|
+
reason = Bridge.lock_gate_decision(bridge_data, file_path, session: session)
|
|
22
|
+
unless reason
|
|
23
|
+
# Allow path: refresh the lease for the session that holds this target's
|
|
24
|
+
# lock (owner or delegate). Best-effort, never blocks.
|
|
25
|
+
begin
|
|
26
|
+
dir = Bridge.intent_dir_for(file_path)
|
|
27
|
+
Lock.heartbeat(dir, session: session) if dir && !session.to_s.empty?
|
|
28
|
+
rescue StandardError
|
|
29
|
+
# ignore
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Second, independent gate (intent 111 D7): the artifact-grain claim.
|
|
33
|
+
# Composes UNDER the delivery lock above; only reached once the delivery
|
|
34
|
+
# lock already allows. Dormant (nil) unless a claim file exists for this
|
|
35
|
+
# artifact, so AC7 (existing single-owner flows) is unaffected.
|
|
36
|
+
begin
|
|
37
|
+
dir = Bridge.intent_dir_for(file_path)
|
|
38
|
+
artifact = File.basename(file_path) if dir
|
|
39
|
+
if dir && artifact
|
|
40
|
+
claim_reason = Claim.claim_gate_reason(dir, artifact, session: session)
|
|
41
|
+
if claim_reason
|
|
42
|
+
print JSON.generate(
|
|
43
|
+
"hookSpecificOutput" => {
|
|
44
|
+
"hookEventName" => "PreToolUse",
|
|
45
|
+
"permissionDecision" => "deny",
|
|
46
|
+
"permissionDecisionReason" => claim_reason,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
exit 0
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Fail-open surface (AC3): allow, but make the yielded/stale claim visible.
|
|
53
|
+
if Claim.fail_open?(dir, artifact)
|
|
54
|
+
$stderr.puts "plastic claim gate: unresolvable claim on #{artifact} in " \
|
|
55
|
+
"intent #{Bridge.intent_id_from_dir(dir)} yielded; write " \
|
|
56
|
+
"proceeds (see /plastic-lock status)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Heartbeat the session's own claim on the allow path (best-effort).
|
|
60
|
+
Claim.heartbeat(dir, artifact, session: session) rescue nil
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError
|
|
63
|
+
# ignore: a claim-gate bug must never block a write (fail open)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
exit 0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
print JSON.generate(
|
|
70
|
+
"hookSpecificOutput" => {
|
|
71
|
+
"hookEventName" => "PreToolUse",
|
|
72
|
+
"permissionDecision" => "deny",
|
|
73
|
+
"permissionDecisionReason" => reason # "run /plastic-intent-starting to lock and begin"
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
exit 0
|
|
77
|
+
rescue => e
|
|
78
|
+
# Hook-internal failure only: do NOT impersonate a user-deny. Log and fail open
|
|
79
|
+
# here so a gate bug never bricks editing; the fail-CLOSED intent is about MISSING
|
|
80
|
+
# locks, not crashes.
|
|
81
|
+
$stderr.puts "plastic lock-gate error: #{e.message}"
|
|
82
|
+
exit 0
|
|
83
|
+
end
|