@zalom/plastic 1.0.0-beta.8 → 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 +407 -84
- 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 +404 -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/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/hook_registry.rb +95 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +104 -60
- package/scripts/lib/link_suggestions.rb +319 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -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 +138 -29
- package/scripts/plastic-lock +164 -0
- package/scripts/project-links +287 -0
- package/scripts/read-config +4 -0
- package/scripts/rebuild-graph +244 -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-intent/references/lifecycle.md +9 -4
- 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/linking-intents/references/zettelkasten.md +7 -0
- package/skills/lock/SKILL.md +41 -0
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- 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
|
@@ -17,6 +17,12 @@ require "digest"
|
|
|
17
17
|
|
|
18
18
|
require_relative "lib/qmd_sync"
|
|
19
19
|
require_relative "lib/intent_validator"
|
|
20
|
+
require_relative "lib/graph_rebuild"
|
|
21
|
+
require_relative "lib/links_projection"
|
|
22
|
+
require_relative "lib/links_section"
|
|
23
|
+
require_relative "lib/hook_registry"
|
|
24
|
+
require_relative "lib/lock"
|
|
25
|
+
require_relative "lib/bridge"
|
|
20
26
|
|
|
21
27
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
22
28
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -469,7 +475,10 @@ class Doctor
|
|
|
469
475
|
category: "conventions", name: "section_structure", status: "warn",
|
|
470
476
|
message: "#{bad_sections.size} intent file(s) have non-sanctioned ## sections",
|
|
471
477
|
details: bad_sections.map { |b| "#{b[:dir]}: #{b[:issues].join(", ")}" },
|
|
472
|
-
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"
|
|
473
482
|
)
|
|
474
483
|
end
|
|
475
484
|
|
|
@@ -480,9 +489,356 @@ class Doctor
|
|
|
480
489
|
# flagged: validate_graph does not compute it.
|
|
481
490
|
checks.concat(graph_invariant_checks(intent_dirs))
|
|
482
491
|
|
|
492
|
+
# cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
|
|
493
|
+
# `store:id` ref against the FULL store family via the relocation map
|
|
494
|
+
# (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
|
|
495
|
+
# Resolution always spans all stores even under `--store` scoping; only the
|
|
496
|
+
# REPORTED findings are filtered to refs originating in the scoped store(s).
|
|
497
|
+
checks << cross_store_resolution_check(scopes: scopes)
|
|
498
|
+
|
|
499
|
+
# graph_links_projection — the `## Links` section of every intent must EQUAL its
|
|
500
|
+
# canonical I5 frontmatter projection (intent 72), in BOTH set membership AND
|
|
501
|
+
# ordering (sources first, then chain). Recomputes the projection from each
|
|
502
|
+
# intent's sources/chain + the on-disk basenames using the SAME resolver the
|
|
503
|
+
# scripts/project-links tool uses, so the two can never diverge. Resolution
|
|
504
|
+
# spans all stores; only the REPORTED findings are filtered to the scoped store.
|
|
505
|
+
checks << links_projection_check(scopes: scopes)
|
|
506
|
+
|
|
507
|
+
checks
|
|
508
|
+
end
|
|
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
|
+
|
|
483
685
|
checks
|
|
484
686
|
end
|
|
485
687
|
|
|
688
|
+
# Build the cross-store node maps (basename + label per store) + relocation map
|
|
689
|
+
# from ALL stores, then for every intent compute its canonical `## Links`
|
|
690
|
+
# projection and flag any whose ACTUAL `## Links` section differs (membership or
|
|
691
|
+
# ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
|
|
692
|
+
# run) filters only the REPORTED findings by origin scope.
|
|
693
|
+
def links_projection_check(scopes: nil)
|
|
694
|
+
all_dirs = all_intent_dirs
|
|
695
|
+
|
|
696
|
+
store_index = Hash.new { |h, k| h[k] = [] }
|
|
697
|
+
node_index = Hash.new { |h, k| h[k] = {} }
|
|
698
|
+
intents = [] # { scope:, id:, sources:, chain:, path: }
|
|
699
|
+
|
|
700
|
+
all_dirs.each do |d|
|
|
701
|
+
md = File.join(d[:path], "#{d[:name]}.md")
|
|
702
|
+
next unless File.exist?(md)
|
|
703
|
+
|
|
704
|
+
fm = parse_frontmatter(md)
|
|
705
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
706
|
+
|
|
707
|
+
id = fm["id"].to_s
|
|
708
|
+
store_index[d[:scope]] << id
|
|
709
|
+
node_index[d[:scope]][id] = { basename: d[:name], label: fm["intent"].to_s.strip }
|
|
710
|
+
intents << {
|
|
711
|
+
scope: d[:scope], id: id, path: md,
|
|
712
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
713
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
714
|
+
}
|
|
715
|
+
end
|
|
716
|
+
|
|
717
|
+
relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
|
|
718
|
+
|
|
719
|
+
findings = []
|
|
720
|
+
intents.each do |node|
|
|
721
|
+
next if scopes && !scopes.include?(node[:scope])
|
|
722
|
+
|
|
723
|
+
resolve = ->(ref) do
|
|
724
|
+
LinksProjection.resolve_ref_projection(
|
|
725
|
+
ref, referer_store: node[:scope],
|
|
726
|
+
relocation_map: relocation_map, store_index: store_index, node_index: node_index
|
|
727
|
+
)
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
begin
|
|
731
|
+
expected = LinksProjection.section(sources: node[:sources], chain: node[:chain], resolve: resolve)
|
|
732
|
+
actual = actual_links_section(node[:path])
|
|
733
|
+
rescue LinksProjection::UnresolvedRef => e
|
|
734
|
+
findings << "#{node[:id]} ## Links projection failed: #{e.message}"
|
|
735
|
+
next
|
|
736
|
+
rescue LinksSection::AmbiguousLinks => e
|
|
737
|
+
findings << "#{node[:id]} ## Links ambiguous: #{e.message}"
|
|
738
|
+
next
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
next if actual == expected
|
|
742
|
+
|
|
743
|
+
findings << "#{node[:id]} ## Links does not match its frontmatter projection (membership/ordering drift)"
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
graph_finding_check(
|
|
747
|
+
"graph_links_projection", findings,
|
|
748
|
+
"Every intent's ## Links equals its frontmatter projection (membership and ordering)",
|
|
749
|
+
"Run scripts/project-links to regenerate the canonical ## Links sections"
|
|
750
|
+
)
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
# Extract a file's ACTUAL REAL `## Links` section text (FENCE-AWARE), normalized
|
|
754
|
+
# to the canonical block shape the projection emits. Delegates to the shared
|
|
755
|
+
# LinksSection.extract_section so the doctor check and the project-links tool
|
|
756
|
+
# agree on the section location and never match a `## Links` heading inside an
|
|
757
|
+
# example code fence. Returns "" when the section is absent (which differs from
|
|
758
|
+
# any real projection, so a missing section is a finding).
|
|
759
|
+
def actual_links_section(path)
|
|
760
|
+
LinksSection.extract_section(IntentValidator.body_of(File.read(path)))
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# Build the relocation map + cross-store store_index from ALL stores, then for
|
|
764
|
+
# every intent's cross-store `sources`/`chain` ref resolve it and flag:
|
|
765
|
+
# - DEAD: the target resolves nowhere
|
|
766
|
+
# - RELOCATED-STALE: the ref points at an old location the relocation log has
|
|
767
|
+
# moved (the resolved location differs from the literal ref), e.g. the
|
|
768
|
+
# `global:24` id-reuse hazard that direct resolution would silently accept.
|
|
769
|
+
# `scopes` (nil = full run) filters only the REPORTED findings by origin scope.
|
|
770
|
+
def cross_store_resolution_check(scopes: nil)
|
|
771
|
+
all_dirs = all_intent_dirs
|
|
772
|
+
|
|
773
|
+
# Per-scope node maps + store_index over the WHOLE family.
|
|
774
|
+
nodes_by_scope = Hash.new { |h, k| h[k] = {} }
|
|
775
|
+
store_index = Hash.new { |h, k| h[k] = [] }
|
|
776
|
+
all_dirs.each do |d|
|
|
777
|
+
md = File.join(d[:path], "#{d[:name]}.md")
|
|
778
|
+
next unless File.exist?(md)
|
|
779
|
+
|
|
780
|
+
fm = parse_frontmatter(md)
|
|
781
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
782
|
+
|
|
783
|
+
id = fm["id"].to_s
|
|
784
|
+
store_index[d[:scope]] << id
|
|
785
|
+
nodes_by_scope[d[:scope]][id] = {
|
|
786
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
787
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
788
|
+
}
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
|
|
792
|
+
|
|
793
|
+
findings = []
|
|
794
|
+
nodes_by_scope.each do |scope, nodes|
|
|
795
|
+
next if scopes && !scopes.include?(scope)
|
|
796
|
+
|
|
797
|
+
nodes.each do |id, edges|
|
|
798
|
+
%i[sources chain].each do |field|
|
|
799
|
+
edges[field].each do |ref|
|
|
800
|
+
next unless ref.include?(":") # only cross-store refs are resolved here
|
|
801
|
+
|
|
802
|
+
res = GraphRebuild.resolve_ref(ref, referer_store: scope,
|
|
803
|
+
relocation_map: relocation_map,
|
|
804
|
+
store_index: store_index)
|
|
805
|
+
case res[:status]
|
|
806
|
+
when :dead
|
|
807
|
+
findings << "#{id}.#{field} cross-store ref #{ref} resolves to no intent (dead)"
|
|
808
|
+
when :same_store
|
|
809
|
+
findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now same-store #{res[:id]})"
|
|
810
|
+
when :cross_store
|
|
811
|
+
findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now #{res[:ref]})" if res[:ref] != ref
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
graph_finding_check(
|
|
819
|
+
"graph_cross_store_resolution", findings,
|
|
820
|
+
"Every cross-store sources/chain ref resolves to a live, current intent",
|
|
821
|
+
"Run scripts/rebuild-graph to repoint/collapse/drop stale cross-store refs"
|
|
822
|
+
)
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# { store_key => INDEX.md text } for every store (global + all projects), for the
|
|
826
|
+
# relocation-map builder. Reads INDEX.md one level above each store dir.
|
|
827
|
+
def cross_store_index_texts
|
|
828
|
+
texts = {}
|
|
829
|
+
global_index = File.join(plastic_home, "INDEX.md")
|
|
830
|
+
texts["global"] = File.read(global_index) if File.exist?(global_index)
|
|
831
|
+
|
|
832
|
+
projects_root = File.join(plastic_home, "projects")
|
|
833
|
+
if File.directory?(projects_root)
|
|
834
|
+
Dir.children(projects_root).each do |project|
|
|
835
|
+
idx = File.join(projects_root, project, "INDEX.md")
|
|
836
|
+
texts["project:#{project}"] = File.read(idx) if File.exist?(idx)
|
|
837
|
+
end
|
|
838
|
+
end
|
|
839
|
+
texts
|
|
840
|
+
end
|
|
841
|
+
|
|
486
842
|
# Build a per-scope `nodes` map and surface IntentValidator.validate_graph
|
|
487
843
|
# findings as warn-level checks. Scope-aware (the caller already filtered
|
|
488
844
|
# `intent_dirs` by scope), so a `global` id is not falsely flagged as a dangler
|
|
@@ -526,7 +882,9 @@ class Doctor
|
|
|
526
882
|
checks << graph_finding_check(
|
|
527
883
|
"graph_i4_danglers", i4,
|
|
528
884
|
"Every sources/chain id resolves to a real intent (I4)",
|
|
529
|
-
"
|
|
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"
|
|
530
888
|
)
|
|
531
889
|
checks
|
|
532
890
|
end
|
|
@@ -650,6 +1008,45 @@ class Doctor
|
|
|
650
1008
|
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
651
1009
|
)
|
|
652
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
|
|
653
1050
|
end
|
|
654
1051
|
|
|
655
1052
|
# skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
|
|
@@ -1240,6 +1637,7 @@ class Doctor
|
|
|
1240
1637
|
all_checks += check_project_stores
|
|
1241
1638
|
all_checks += check_deprecations
|
|
1242
1639
|
all_checks += check_qmd
|
|
1640
|
+
all_checks += check_done_signals
|
|
1243
1641
|
|
|
1244
1642
|
summarize(all_checks, agent_key)
|
|
1245
1643
|
end
|
|
@@ -1266,9 +1664,9 @@ class Doctor
|
|
|
1266
1664
|
all_checks =
|
|
1267
1665
|
case store
|
|
1268
1666
|
when :all
|
|
1269
|
-
check_global_store + check_project_stores + check_conventions
|
|
1667
|
+
check_global_store + check_project_stores + check_conventions + check_done_signals
|
|
1270
1668
|
when :global
|
|
1271
|
-
check_global_store + check_conventions(scopes: ["global"])
|
|
1669
|
+
check_global_store + check_conventions(scopes: ["global"]) + check_done_signals(scopes: ["global"])
|
|
1272
1670
|
else
|
|
1273
1671
|
all_checks_for_project_slug(store)
|
|
1274
1672
|
end
|
|
@@ -1291,7 +1689,8 @@ class Doctor
|
|
|
1291
1689
|
end
|
|
1292
1690
|
|
|
1293
1691
|
check_project_store(slug, projects[slug]) +
|
|
1294
|
-
check_conventions(scopes: ["project:#{slug}"])
|
|
1692
|
+
check_conventions(scopes: ["project:#{slug}"]) +
|
|
1693
|
+
check_done_signals(scopes: ["project:#{slug}"])
|
|
1295
1694
|
end
|
|
1296
1695
|
|
|
1297
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:"
|