@zalom/plastic 1.0.0-beta.2 → 1.0.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PLASTIC.md +131 -7
- package/agents/plastic-brainstorming.md +9 -1
- package/agents/plastic-enforcer.md +1 -1
- package/agents/plastic-executor.md +11 -1
- package/agents/plastic-intent-curator.md +7 -5
- package/agents/plastic-planner.md +11 -1
- package/agents/plastic-spec-specialist.md +9 -1
- package/hooks/hooks.json +20 -0
- package/hooks/retrieval-gate +10 -0
- package/hooks/savepoint-pre +10 -0
- package/hooks/statusline +150 -41
- package/package.json +1 -1
- package/scripts/agent-report +163 -0
- package/scripts/doctor.rb +172 -0
- package/scripts/hook-auto-arm +1 -1
- package/scripts/hook-bash-gate +2 -2
- package/scripts/hook-code-gate +11 -6
- package/scripts/hook-create-gate +2 -2
- package/scripts/hook-gate-check +14 -23
- package/scripts/hook-retrieval-gate +136 -0
- package/scripts/hook-savepoint-pre +32 -0
- package/scripts/hook-session-start +1 -1
- package/scripts/insight-append +51 -0
- package/scripts/lib/bridge.rb +374 -34
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/insights.rb +86 -0
- package/scripts/lib/installer_core.rb +23 -0
- package/scripts/lib/link_suggestions.rb +322 -0
- package/scripts/lib/links_projection.rb +160 -0
- package/scripts/lib/links_section.rb +207 -0
- package/scripts/lib/power_tools.rb +76 -0
- package/scripts/lib/qmd_hook.rb +38 -25
- package/scripts/lib/qmd_sync.rb +36 -0
- package/scripts/lib/retrieval_gate.rb +211 -0
- package/scripts/lib/worktree.rb +409 -0
- package/scripts/link-suggest +211 -0
- package/scripts/new-intent +138 -29
- package/scripts/project-links +287 -0
- package/scripts/qmd-sync +50 -3
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +26 -1
- package/skills/auto/SKILL.md +58 -11
- package/skills/auto/evals/evals.json +48 -0
- package/skills/auto/references/agent-architecture.md +27 -4
- package/skills/auto/references/agent-report-contract.md +121 -0
- package/skills/brainstorming/SKILL.md +1 -0
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/continuing/SKILL.md +30 -8
- package/skills/continuing/evals/evals.json +9 -0
- package/skills/creating-intent/SKILL.md +16 -2
- package/skills/creating-intent/evals/evals.json +16 -0
- package/skills/creating-intent/references/lifecycle.md +9 -4
- 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 +5 -0
- package/skills/dashboard/evals/evals.json +22 -0
- package/skills/executing-plan/SKILL.md +2 -2
- 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/intent-curator/SKILL.md +6 -1
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/linking-intents/SKILL.md +54 -12
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +7 -0
- package/skills/managing-index/SKILL.md +8 -0
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +6 -1
- package/skills/releasing/SKILL.md +32 -0
- package/skills/research/SKILL.md +8 -0
- package/skills/research/evals/evals.json +22 -0
- package/skills/writing-instructions/SKILL.md +0 -159
- package/skills/writing-instructions/references/agentskills-spec.md +0 -135
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# agent-report - deterministic filesystem-derived completion report (intent 74).
|
|
6
|
+
#
|
|
7
|
+
# The auto-mode report contract is decision-shaped (the spawn preamble's
|
|
8
|
+
# REPORT_CONTRACT plus the role prompts ask every dispatched agent to END with a
|
|
9
|
+
# structured completion report). Because child-agent honor is best-effort across
|
|
10
|
+
# harnesses (Tier B/C, docs/reference/harness-adapters.md), the contract is never a
|
|
11
|
+
# hard block. This script is the always-a-report fallback: when a dispatched agent
|
|
12
|
+
# returns no usable report, the enforcer runs it to synthesize one from the intent
|
|
13
|
+
# directory, so the handoff account always exists.
|
|
14
|
+
#
|
|
15
|
+
# Like scripts/spawn-preamble, it is a PURE function of the intent dir: no network,
|
|
16
|
+
# no randomness, no wall-clock reads. Two runs over the same on-disk state produce
|
|
17
|
+
# byte-identical output.
|
|
18
|
+
#
|
|
19
|
+
# Usage:
|
|
20
|
+
# agent-report <intent_dir> [--role ROLE]
|
|
21
|
+
#
|
|
22
|
+
# Exit codes: 0 (report emitted), 2 (usage).
|
|
23
|
+
|
|
24
|
+
require_relative "lib/bridge"
|
|
25
|
+
|
|
26
|
+
def parse_args(argv)
|
|
27
|
+
role = nil
|
|
28
|
+
positional = []
|
|
29
|
+
i = 0
|
|
30
|
+
while i < argv.length
|
|
31
|
+
case argv[i]
|
|
32
|
+
when "--role"
|
|
33
|
+
role = argv[i + 1]
|
|
34
|
+
i += 2
|
|
35
|
+
else
|
|
36
|
+
positional << argv[i]
|
|
37
|
+
i += 1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
[positional.first, role]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Read the intent file frontmatter via the same path spawn-preamble uses, so the
|
|
44
|
+
# id/intent we report match what the rest of Plastic sees. Never raises.
|
|
45
|
+
def frontmatter_for(intent_dir)
|
|
46
|
+
ifile = Bridge.intent_file(intent_dir)
|
|
47
|
+
return {} unless File.exist?(ifile)
|
|
48
|
+
|
|
49
|
+
content = File.read(ifile)
|
|
50
|
+
return {} unless content.start_with?("---")
|
|
51
|
+
|
|
52
|
+
parts = content.split("---", 3)
|
|
53
|
+
return {} if parts.length < 3
|
|
54
|
+
|
|
55
|
+
require "yaml"
|
|
56
|
+
require "date"
|
|
57
|
+
require "time"
|
|
58
|
+
YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
|
|
59
|
+
rescue StandardError
|
|
60
|
+
{}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Current stage: prefer the last non-empty savepoint line (the ledger encodes the
|
|
64
|
+
# furthest-reached milestone), else the file-derived stage. Same logic as
|
|
65
|
+
# spawn-preamble's current_stage.
|
|
66
|
+
STAGE_LABELS = {
|
|
67
|
+
"what" => "What", "why" => "Why", "how" => "How",
|
|
68
|
+
"exec" => "Exec", "done" => "Done"
|
|
69
|
+
}.freeze
|
|
70
|
+
|
|
71
|
+
def current_stage(intent_dir)
|
|
72
|
+
ledger = File.join(intent_dir, Bridge::SAVEPOINT_FILE)
|
|
73
|
+
if File.exist?(ledger)
|
|
74
|
+
last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
|
|
75
|
+
return last if last
|
|
76
|
+
end
|
|
77
|
+
STAGE_LABELS.fetch(Bridge.derive_stage(intent_dir), Bridge.derive_stage(intent_dir))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# checklist.md checked/total: count GFM task-list items. Returns "n/a" when there
|
|
81
|
+
# is no checklist (a checklist is only meaningful from How onward).
|
|
82
|
+
def checklist_progress(intent_dir)
|
|
83
|
+
path = File.join(intent_dir, "checklist.md")
|
|
84
|
+
return "n/a" unless Bridge.stage_file_present?(path)
|
|
85
|
+
|
|
86
|
+
lines = File.readlines(path)
|
|
87
|
+
total = lines.count { |l| l =~ /^\s*- \[[ xX]\]/ }
|
|
88
|
+
checked = lines.count { |l| l =~ /^\s*- \[[xX]\]/ }
|
|
89
|
+
return "n/a" if total.zero?
|
|
90
|
+
|
|
91
|
+
"#{checked}/#{total}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The outcome line: first content line under outcome.md's "## Summary" (or the first
|
|
95
|
+
# non-heading content line), else a placeholder. Pure read of the intent dir.
|
|
96
|
+
def outcome_line(intent_dir)
|
|
97
|
+
path = File.join(intent_dir, "outcome.md")
|
|
98
|
+
return "(no outcome yet)" unless Bridge.stage_file_present?(path)
|
|
99
|
+
|
|
100
|
+
lines = File.readlines(path).map(&:rstrip)
|
|
101
|
+
start = lines.index { |l| l.strip.downcase == "## summary" }
|
|
102
|
+
scan = start ? lines[(start + 1)..] : lines
|
|
103
|
+
(scan || []).each do |l|
|
|
104
|
+
s = l.strip
|
|
105
|
+
next if s.empty? || s.start_with?("#") || s.start_with?("<!--")
|
|
106
|
+
return s
|
|
107
|
+
end
|
|
108
|
+
"(no outcome yet)"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The insights line: the LAST line of the intent file's `## Insights` section
|
|
112
|
+
# (the newest nugget, since entries are append-only newest-at-bottom), else
|
|
113
|
+
# `(none)`. Pure read of the intent dir, no clock.
|
|
114
|
+
def insights_line(intent_dir)
|
|
115
|
+
ifile = Bridge.intent_file(intent_dir)
|
|
116
|
+
return "(none)" unless File.exist?(ifile)
|
|
117
|
+
|
|
118
|
+
lines = File.read(ifile).split("\n", -1)
|
|
119
|
+
idx = lines.index { |l| l.strip == "## Insights" }
|
|
120
|
+
return "(none)" if idx.nil?
|
|
121
|
+
|
|
122
|
+
last = nil
|
|
123
|
+
(idx + 1).upto(lines.length - 1) do |i|
|
|
124
|
+
break if lines[i].start_with?("## ")
|
|
125
|
+
|
|
126
|
+
last = lines[i] unless lines[i].strip.empty?
|
|
127
|
+
end
|
|
128
|
+
last || "(none)"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
intent_dir_arg, role = parse_args(ARGV)
|
|
132
|
+
|
|
133
|
+
if intent_dir_arg.nil? || intent_dir_arg.empty?
|
|
134
|
+
warn "usage: agent-report <intent_dir> [--role ROLE]"
|
|
135
|
+
exit 2
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
intent_dir = File.expand_path(intent_dir_arg)
|
|
139
|
+
|
|
140
|
+
fm = frontmatter_for(intent_dir)
|
|
141
|
+
id = fm["id"].to_s.strip
|
|
142
|
+
intent_name = fm["intent"].to_s.strip
|
|
143
|
+
id = "(unknown)" if id.empty?
|
|
144
|
+
intent_name = "(unknown)" if intent_name.empty?
|
|
145
|
+
|
|
146
|
+
stage = current_stage(intent_dir)
|
|
147
|
+
role_label = (role && !role.empty?) ? role : stage
|
|
148
|
+
artifacts = Bridge.has_files(intent_dir)
|
|
149
|
+
artifacts_str = artifacts.empty? ? "(none)" : artifacts.join(", ")
|
|
150
|
+
|
|
151
|
+
lines = []
|
|
152
|
+
lines << "=== Plastic agent report (synthesized) ==="
|
|
153
|
+
lines << "Intent: #{id} - #{intent_name}"
|
|
154
|
+
lines << "Stage: #{stage}"
|
|
155
|
+
lines << "Role: #{role_label}"
|
|
156
|
+
lines << "Status: synthesized (filesystem-derived; no agent-authored report)"
|
|
157
|
+
lines << "Artifacts present: #{artifacts_str}"
|
|
158
|
+
lines << "Checklist: #{checklist_progress(intent_dir)}"
|
|
159
|
+
lines << "Outcome: #{outcome_line(intent_dir)}"
|
|
160
|
+
lines << "Insights: #{insights_line(intent_dir)}"
|
|
161
|
+
lines << "=== end report ==="
|
|
162
|
+
|
|
163
|
+
puts lines.join("\n")
|
package/scripts/doctor.rb
CHANGED
|
@@ -17,6 +17,9 @@ 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"
|
|
20
23
|
|
|
21
24
|
# Diagnostic engine, instantiable with an injected store/agent map so tests can
|
|
22
25
|
# run it hermetically (no eval, no global-constant rewriting).
|
|
@@ -480,9 +483,178 @@ class Doctor
|
|
|
480
483
|
# flagged: validate_graph does not compute it.
|
|
481
484
|
checks.concat(graph_invariant_checks(intent_dirs))
|
|
482
485
|
|
|
486
|
+
# cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
|
|
487
|
+
# `store:id` ref against the FULL store family via the relocation map
|
|
488
|
+
# (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
|
|
489
|
+
# Resolution always spans all stores even under `--store` scoping; only the
|
|
490
|
+
# REPORTED findings are filtered to refs originating in the scoped store(s).
|
|
491
|
+
checks << cross_store_resolution_check(scopes: scopes)
|
|
492
|
+
|
|
493
|
+
# graph_links_projection — the `## Links` section of every intent must EQUAL its
|
|
494
|
+
# canonical I5 frontmatter projection (intent 72), in BOTH set membership AND
|
|
495
|
+
# ordering (sources first, then chain). Recomputes the projection from each
|
|
496
|
+
# intent's sources/chain + the on-disk basenames using the SAME resolver the
|
|
497
|
+
# scripts/project-links tool uses, so the two can never diverge. Resolution
|
|
498
|
+
# spans all stores; only the REPORTED findings are filtered to the scoped store.
|
|
499
|
+
checks << links_projection_check(scopes: scopes)
|
|
500
|
+
|
|
483
501
|
checks
|
|
484
502
|
end
|
|
485
503
|
|
|
504
|
+
# Build the cross-store node maps (basename + label per store) + relocation map
|
|
505
|
+
# from ALL stores, then for every intent compute its canonical `## Links`
|
|
506
|
+
# projection and flag any whose ACTUAL `## Links` section differs (membership or
|
|
507
|
+
# ordering drift), or whose projection raises UnresolvedRef. `scopes` (nil = full
|
|
508
|
+
# run) filters only the REPORTED findings by origin scope.
|
|
509
|
+
def links_projection_check(scopes: nil)
|
|
510
|
+
all_dirs = all_intent_dirs
|
|
511
|
+
|
|
512
|
+
store_index = Hash.new { |h, k| h[k] = [] }
|
|
513
|
+
node_index = Hash.new { |h, k| h[k] = {} }
|
|
514
|
+
intents = [] # { scope:, id:, sources:, chain:, path: }
|
|
515
|
+
|
|
516
|
+
all_dirs.each do |d|
|
|
517
|
+
md = File.join(d[:path], "#{d[:name]}.md")
|
|
518
|
+
next unless File.exist?(md)
|
|
519
|
+
|
|
520
|
+
fm = parse_frontmatter(md)
|
|
521
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
522
|
+
|
|
523
|
+
id = fm["id"].to_s
|
|
524
|
+
store_index[d[:scope]] << id
|
|
525
|
+
node_index[d[:scope]][id] = { basename: d[:name], label: fm["intent"].to_s.strip }
|
|
526
|
+
intents << {
|
|
527
|
+
scope: d[:scope], id: id, path: md,
|
|
528
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
529
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
530
|
+
}
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
|
|
534
|
+
|
|
535
|
+
findings = []
|
|
536
|
+
intents.each do |node|
|
|
537
|
+
next if scopes && !scopes.include?(node[:scope])
|
|
538
|
+
|
|
539
|
+
resolve = ->(ref) do
|
|
540
|
+
LinksProjection.resolve_ref_projection(
|
|
541
|
+
ref, referer_store: node[:scope],
|
|
542
|
+
relocation_map: relocation_map, store_index: store_index, node_index: node_index
|
|
543
|
+
)
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
begin
|
|
547
|
+
expected = LinksProjection.section(sources: node[:sources], chain: node[:chain], resolve: resolve)
|
|
548
|
+
actual = actual_links_section(node[:path])
|
|
549
|
+
rescue LinksProjection::UnresolvedRef => e
|
|
550
|
+
findings << "#{node[:id]} ## Links projection failed: #{e.message}"
|
|
551
|
+
next
|
|
552
|
+
rescue LinksSection::AmbiguousLinks => e
|
|
553
|
+
findings << "#{node[:id]} ## Links ambiguous: #{e.message}"
|
|
554
|
+
next
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
next if actual == expected
|
|
558
|
+
|
|
559
|
+
findings << "#{node[:id]} ## Links does not match its frontmatter projection (membership/ordering drift)"
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
graph_finding_check(
|
|
563
|
+
"graph_links_projection", findings,
|
|
564
|
+
"Every intent's ## Links equals its frontmatter projection (membership and ordering)",
|
|
565
|
+
"Run scripts/project-links to regenerate the canonical ## Links sections"
|
|
566
|
+
)
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# Extract a file's ACTUAL REAL `## Links` section text (FENCE-AWARE), normalized
|
|
570
|
+
# to the canonical block shape the projection emits. Delegates to the shared
|
|
571
|
+
# LinksSection.extract_section so the doctor check and the project-links tool
|
|
572
|
+
# agree on the section location and never match a `## Links` heading inside an
|
|
573
|
+
# example code fence. Returns "" when the section is absent (which differs from
|
|
574
|
+
# any real projection, so a missing section is a finding).
|
|
575
|
+
def actual_links_section(path)
|
|
576
|
+
LinksSection.extract_section(IntentValidator.body_of(File.read(path)))
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Build the relocation map + cross-store store_index from ALL stores, then for
|
|
580
|
+
# every intent's cross-store `sources`/`chain` ref resolve it and flag:
|
|
581
|
+
# - DEAD: the target resolves nowhere
|
|
582
|
+
# - RELOCATED-STALE: the ref points at an old location the relocation log has
|
|
583
|
+
# moved (the resolved location differs from the literal ref), e.g. the
|
|
584
|
+
# `global:24` id-reuse hazard that direct resolution would silently accept.
|
|
585
|
+
# `scopes` (nil = full run) filters only the REPORTED findings by origin scope.
|
|
586
|
+
def cross_store_resolution_check(scopes: nil)
|
|
587
|
+
all_dirs = all_intent_dirs
|
|
588
|
+
|
|
589
|
+
# Per-scope node maps + store_index over the WHOLE family.
|
|
590
|
+
nodes_by_scope = Hash.new { |h, k| h[k] = {} }
|
|
591
|
+
store_index = Hash.new { |h, k| h[k] = [] }
|
|
592
|
+
all_dirs.each do |d|
|
|
593
|
+
md = File.join(d[:path], "#{d[:name]}.md")
|
|
594
|
+
next unless File.exist?(md)
|
|
595
|
+
|
|
596
|
+
fm = parse_frontmatter(md)
|
|
597
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
598
|
+
|
|
599
|
+
id = fm["id"].to_s
|
|
600
|
+
store_index[d[:scope]] << id
|
|
601
|
+
nodes_by_scope[d[:scope]][id] = {
|
|
602
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
603
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
604
|
+
}
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
relocation_map = GraphRebuild.build_relocation_map(cross_store_index_texts)
|
|
608
|
+
|
|
609
|
+
findings = []
|
|
610
|
+
nodes_by_scope.each do |scope, nodes|
|
|
611
|
+
next if scopes && !scopes.include?(scope)
|
|
612
|
+
|
|
613
|
+
nodes.each do |id, edges|
|
|
614
|
+
%i[sources chain].each do |field|
|
|
615
|
+
edges[field].each do |ref|
|
|
616
|
+
next unless ref.include?(":") # only cross-store refs are resolved here
|
|
617
|
+
|
|
618
|
+
res = GraphRebuild.resolve_ref(ref, referer_store: scope,
|
|
619
|
+
relocation_map: relocation_map,
|
|
620
|
+
store_index: store_index)
|
|
621
|
+
case res[:status]
|
|
622
|
+
when :dead
|
|
623
|
+
findings << "#{id}.#{field} cross-store ref #{ref} resolves to no intent (dead)"
|
|
624
|
+
when :same_store
|
|
625
|
+
findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now same-store #{res[:id]})"
|
|
626
|
+
when :cross_store
|
|
627
|
+
findings << "#{id}.#{field} cross-store ref #{ref} is relocated-stale (now #{res[:ref]})" if res[:ref] != ref
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
graph_finding_check(
|
|
635
|
+
"graph_cross_store_resolution", findings,
|
|
636
|
+
"Every cross-store sources/chain ref resolves to a live, current intent",
|
|
637
|
+
"Run scripts/rebuild-graph to repoint/collapse/drop stale cross-store refs"
|
|
638
|
+
)
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# { store_key => INDEX.md text } for every store (global + all projects), for the
|
|
642
|
+
# relocation-map builder. Reads INDEX.md one level above each store dir.
|
|
643
|
+
def cross_store_index_texts
|
|
644
|
+
texts = {}
|
|
645
|
+
global_index = File.join(plastic_home, "INDEX.md")
|
|
646
|
+
texts["global"] = File.read(global_index) if File.exist?(global_index)
|
|
647
|
+
|
|
648
|
+
projects_root = File.join(plastic_home, "projects")
|
|
649
|
+
if File.directory?(projects_root)
|
|
650
|
+
Dir.children(projects_root).each do |project|
|
|
651
|
+
idx = File.join(projects_root, project, "INDEX.md")
|
|
652
|
+
texts["project:#{project}"] = File.read(idx) if File.exist?(idx)
|
|
653
|
+
end
|
|
654
|
+
end
|
|
655
|
+
texts
|
|
656
|
+
end
|
|
657
|
+
|
|
486
658
|
# Build a per-scope `nodes` map and surface IntentValidator.validate_graph
|
|
487
659
|
# findings as warn-level checks. Scope-aware (the caller already filtered
|
|
488
660
|
# `intent_dirs` by scope), so a `global` id is not falsely flagged as a dangler
|
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,9 +28,9 @@ 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 ->
|
|
31
|
+
# --- Load bridge (shared resolution; stdin session_id -> CLAUDE_CODE_SESSION_ID -> /tmp scan) ---
|
|
32
32
|
session = payload["session_id"]
|
|
33
|
-
session = ENV["
|
|
33
|
+
session = ENV["CLAUDE_CODE_SESSION_ID"] if session.nil? || session.to_s.empty?
|
|
34
34
|
bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
|
|
35
35
|
exit 0 unless bridge_data
|
|
36
36
|
|
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-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
|
#
|
package/scripts/hook-gate-check
CHANGED
|
@@ -13,7 +13,7 @@ require_relative "lib/intent_validator"
|
|
|
13
13
|
file_path = ARGV[0]
|
|
14
14
|
exit 0 unless file_path && !file_path.empty?
|
|
15
15
|
|
|
16
|
-
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["
|
|
16
|
+
session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_CODE_SESSION_ID"]
|
|
17
17
|
|
|
18
18
|
file_path_abs = File.expand_path(file_path)
|
|
19
19
|
|
|
@@ -24,6 +24,12 @@ intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
|
|
|
24
24
|
if intent_dir_abs
|
|
25
25
|
begin
|
|
26
26
|
Bridge.append_savepoint(intent_dir_abs, file_path_abs)
|
|
27
|
+
# When checklist.md lands, How ends and Exec begins: emit the `Exec started`
|
|
28
|
+
# companion in the same event (intent 81). Guard on a real (non-placeholder)
|
|
29
|
+
# checklist so a scaffold sentinel does not trip it.
|
|
30
|
+
if File.basename(file_path_abs) == "checklist.md" && Bridge.stage_file_present?(file_path_abs)
|
|
31
|
+
Bridge.append_exec_started(intent_dir_abs)
|
|
32
|
+
end
|
|
27
33
|
rescue StandardError
|
|
28
34
|
# ignore — rebuildable from disk
|
|
29
35
|
end
|
|
@@ -112,32 +118,17 @@ if is_stage_file || is_action_file
|
|
|
112
118
|
|
|
113
119
|
Bridge.write(session, bridge_data)
|
|
114
120
|
|
|
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
|
|
121
|
+
# Build transition context — ONE concise sentence (intent 84, Lever 1),
|
|
122
|
+
# preserving the `Next: ...` hint. Formatting is pure in Bridge.gate_narration.
|
|
123
|
+
context = Bridge.gate_narration(
|
|
124
|
+
old_stage: old_stage, new_stage: new_stage,
|
|
125
|
+
basename: basename, new_missing: new_missing
|
|
126
|
+
)
|
|
136
127
|
|
|
137
128
|
payload = {
|
|
138
129
|
"hookSpecificOutput" => {
|
|
139
130
|
"hookEventName" => "PostToolUse",
|
|
140
|
-
"additionalContext" =>
|
|
131
|
+
"additionalContext" => context
|
|
141
132
|
}
|
|
142
133
|
}
|
|
143
134
|
puts JSON.generate(payload)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# PreToolUse retrieval gate (intent 84, Lever 2; operation-based redesign 89a).
|
|
6
|
+
# Reads the tool call (JSON on stdin: tool_name + tool_input), computes
|
|
7
|
+
# capabilities (QMD detect + freshness), delegates the decision to RetrievalGate,
|
|
8
|
+
# and enforces:
|
|
9
|
+
# ALLOW = exit 0 ; BLOCK = exit 2 with reason on stderr (shown to the agent).
|
|
10
|
+
# Fail-open: any parse error, timeout, or unexpected exception exits 0. On the
|
|
11
|
+
# STALE QMD path RetrievalGate fires QmdSync.reindex_async (NEVER synchronous).
|
|
12
|
+
# Binds subagents (PreToolUse hooks apply to subagent tool calls too).
|
|
13
|
+
#
|
|
14
|
+
# Only CONTENT SEARCH over store markdown is gated; reads and structural ops are
|
|
15
|
+
# allowed. Code navigation is a soft prompt mandate (UserPromptSubmit power-tools),
|
|
16
|
+
# not enforced here, so this hook no longer detects Serena.
|
|
17
|
+
#
|
|
18
|
+
# Scope: only the agent's own Bash/Read/Grep/Glob calls. Ruby `File.read` inside
|
|
19
|
+
# scripts is invisible to a PreToolUse hook and is out of scope (no exemptions).
|
|
20
|
+
#
|
|
21
|
+
# ARGV[0] is plastic_home (passed by the launcher), like hook-qmd-search.
|
|
22
|
+
|
|
23
|
+
require "json"
|
|
24
|
+
require "timeout"
|
|
25
|
+
require_relative "lib/retrieval_gate"
|
|
26
|
+
require_relative "lib/qmd_sync"
|
|
27
|
+
|
|
28
|
+
module RetrievalGateHook
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Pure-ish core: capabilities and reindex are injected so this is unit-testable
|
|
32
|
+
# with no real qmd. Returns [exit_code, stderr_string].
|
|
33
|
+
# stdin: raw PreToolUse JSON
|
|
34
|
+
# capabilities: { qmd:, qmd_fresh: }
|
|
35
|
+
# reindex: callable fired on the STALE path
|
|
36
|
+
def run(stdin:, plastic_home:, cwd:, capabilities:, reindex: -> {})
|
|
37
|
+
payload = parse(stdin)
|
|
38
|
+
return [0, nil] unless payload
|
|
39
|
+
|
|
40
|
+
tool_name = payload["tool_name"].to_s
|
|
41
|
+
tool_input = payload["tool_input"]
|
|
42
|
+
tool_input = {} unless tool_input.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
bypassed = false
|
|
45
|
+
reason = RetrievalGate.decision(
|
|
46
|
+
tool_name: tool_name, tool_input: tool_input,
|
|
47
|
+
plastic_home: plastic_home, cwd: cwd,
|
|
48
|
+
capabilities: capabilities, reindex: reindex
|
|
49
|
+
) { |_sig| bypassed = true }
|
|
50
|
+
|
|
51
|
+
if reason
|
|
52
|
+
[2, "PLASTIC GATE — #{reason}"]
|
|
53
|
+
elsif bypassed
|
|
54
|
+
[0, "PLASTIC GATE — bypassed via # qmd-ok"]
|
|
55
|
+
else
|
|
56
|
+
[0, nil]
|
|
57
|
+
end
|
|
58
|
+
rescue StandardError
|
|
59
|
+
[0, nil] # fail-open
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse(raw)
|
|
63
|
+
data = JSON.parse(raw.to_s)
|
|
64
|
+
data.is_a?(Hash) ? data : nil
|
|
65
|
+
rescue StandardError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Detect real capabilities for the live executable. Probes are injected so the
|
|
70
|
+
# three-tier QMD failure model is unit-testable:
|
|
71
|
+
# - absent : QMD not on PATH -> allow, no warn.
|
|
72
|
+
# - broken : QMD present but freshness probe -> tier-b: WARN once, then treat
|
|
73
|
+
# raises/times out as absent this turn (allow).
|
|
74
|
+
# - fresh : probe returns truthy -> gate is live.
|
|
75
|
+
# A slow `qmd status` cannot stall a tool call: a 2s Timeout bounds the probe.
|
|
76
|
+
def detect_capabilities(cwd:,
|
|
77
|
+
detect: -> { QmdSync.detect },
|
|
78
|
+
fresh: -> { QmdSync.fresh? },
|
|
79
|
+
warn: ->(m) { $stderr.puts(m) })
|
|
80
|
+
qmd = detect.call
|
|
81
|
+
qmd_fresh = false
|
|
82
|
+
if qmd
|
|
83
|
+
begin
|
|
84
|
+
qmd_fresh = Timeout.timeout(2) { fresh.call }
|
|
85
|
+
rescue StandardError
|
|
86
|
+
# Tier-b: QMD is present but its freshness probe broke/stalled. Distinct
|
|
87
|
+
# from QMD being absent — warn so a degraded QMD is visible, then fail open
|
|
88
|
+
# (allow this turn, no reindex).
|
|
89
|
+
warn.call("PLASTIC GATE — QMD is present but its freshness probe failed; " \
|
|
90
|
+
"allowing this turn without routing search to QMD (check qmd).")
|
|
91
|
+
qmd = false
|
|
92
|
+
qmd_fresh = false
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
{ qmd: qmd, qmd_fresh: qmd_fresh }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Best-effort reindex callable for the STALE path. Resolves the collection from
|
|
99
|
+
# cwd (project + global) and fires the async reindexer for each; never raises.
|
|
100
|
+
def reindex_for(cwd:, plastic_home:)
|
|
101
|
+
lambda do
|
|
102
|
+
begin
|
|
103
|
+
cols = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
|
|
104
|
+
cols.each { |c| QmdSync.reindex_async(collection: c) }
|
|
105
|
+
rescue StandardError
|
|
106
|
+
# non-fatal; the read is already allowed this turn
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# --- executable entrypoint ---
|
|
113
|
+
if $PROGRAM_NAME == __FILE__
|
|
114
|
+
raw = begin
|
|
115
|
+
$stdin.read
|
|
116
|
+
rescue StandardError
|
|
117
|
+
""
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
|
|
121
|
+
cwd = Dir.pwd
|
|
122
|
+
|
|
123
|
+
code, err = begin
|
|
124
|
+
caps = RetrievalGateHook.detect_capabilities(cwd: cwd)
|
|
125
|
+
RetrievalGateHook.run(
|
|
126
|
+
stdin: raw, plastic_home: plastic_home, cwd: cwd,
|
|
127
|
+
capabilities: caps,
|
|
128
|
+
reindex: RetrievalGateHook.reindex_for(cwd: cwd, plastic_home: plastic_home)
|
|
129
|
+
)
|
|
130
|
+
rescue StandardError
|
|
131
|
+
[0, nil] # fail-open at the outermost boundary too
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
$stderr.puts(err) if err && !err.empty?
|
|
135
|
+
exit code
|
|
136
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
# Usage: hook-savepoint-pre <file_path>
|
|
5
|
+
#
|
|
6
|
+
# PreToolUse savepoint trigger (intent 81). When a stage-opening lifecycle file
|
|
7
|
+
# (spec.md => Why, plan.md => How) is ABOUT to be written into an intent dir,
|
|
8
|
+
# append the pre-stage `started` ledger line so the ledger records "this stage
|
|
9
|
+
# was entered" before its artifact lands.
|
|
10
|
+
#
|
|
11
|
+
# Like the PostToolUse decoupled savepoint write (intent 52), it is derived from
|
|
12
|
+
# the file path alone: no bridge, no session, fires headless. It NEVER blocks a
|
|
13
|
+
# write: any non-match or failure exits 0. The append is idempotent and only
|
|
14
|
+
# fires while the stage is genuinely starting (the artifact is not yet a real,
|
|
15
|
+
# non-placeholder file), so re-edits add nothing.
|
|
16
|
+
|
|
17
|
+
require_relative "lib/bridge"
|
|
18
|
+
|
|
19
|
+
file_path = ARGV[0]
|
|
20
|
+
exit 0 if file_path.nil? || file_path.empty?
|
|
21
|
+
|
|
22
|
+
file_path_abs = File.expand_path(file_path)
|
|
23
|
+
intent_dir = Bridge.intent_dir_for(file_path_abs)
|
|
24
|
+
exit 0 unless intent_dir
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
Bridge.append_started_savepoint(intent_dir, file_path_abs)
|
|
28
|
+
rescue StandardError
|
|
29
|
+
# best-effort; the ledger is rebuildable and the post line still lands
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
exit 0
|
|
@@ -48,7 +48,7 @@ bridge_data = nil
|
|
|
48
48
|
if active.length == 1 && active.first =~ /store\/([\w-]+)\//
|
|
49
49
|
dir_name = $1
|
|
50
50
|
intent_dir = "#{store_root}/store/#{dir_name}"
|
|
51
|
-
session = ENV["
|
|
51
|
+
session = ENV["CLAUDE_CODE_SESSION_ID"] || Process.pid.to_s
|
|
52
52
|
intent_id = dir_name.split("--").first
|
|
53
53
|
intent_name = active.first[/\[([^\]]+)\]/, 1] || "unknown"
|
|
54
54
|
bridge_data = Bridge.derive(session, intent_id: intent_id, intent_dir: intent_dir, store: store_root, name: intent_name)
|