@zalom/plastic 1.0.0-beta.1 → 1.0.0-beta.11
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 +21 -5
- package/agents/plastic-brainstorming.md +9 -1
- package/agents/plastic-executor.md +10 -0
- 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/statusline +150 -41
- package/package.json +1 -1
- package/scripts/agent-report +142 -0
- package/scripts/dashboard.rb +5 -3
- package/scripts/doctor.rb +243 -0
- package/scripts/lib/bridge.rb +72 -24
- package/scripts/lib/frontmatter_writer.rb +130 -0
- package/scripts/lib/graph_rebuild.rb +328 -0
- package/scripts/lib/installer_core.rb +5 -0
- package/scripts/lib/intent_validator.rb +79 -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 +21 -0
- package/scripts/new-intent +172 -22
- package/scripts/project-links +287 -0
- package/scripts/qmd-sync +50 -3
- package/scripts/rebuild-graph +244 -0
- package/scripts/spawn-preamble +18 -1
- package/skills/auto/SKILL.md +24 -9
- 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 +86 -0
- package/skills/brainstorming/SKILL.md +1 -0
- package/skills/brainstorming/evals/evals.json +22 -0
- package/skills/continuing/SKILL.md +8 -1
- package/skills/continuing/evals/evals.json +9 -0
- package/skills/creating-intent/SKILL.md +28 -8
- package/skills/creating-intent/evals/evals.json +72 -0
- package/skills/creating-intent/references/lifecycle.md +12 -4
- 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/intent-curator/SKILL.md +3 -1
- package/skills/intent-curator/evals/evals.json +22 -0
- package/skills/linking-intents/SKILL.md +17 -6
- package/skills/linking-intents/evals/evals.json +22 -0
- package/skills/linking-intents/references/zettelkasten.md +15 -3
- package/skills/managing-index/SKILL.md +6 -0
- package/skills/managing-index/evals/evals.json +22 -0
- package/skills/managing-index/references/zettelkasten-linking.md +7 -2
- package/skills/research/SKILL.md +8 -0
- package/skills/research/evals/evals.json +22 -0
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).
|
|
@@ -473,9 +476,249 @@ class Doctor
|
|
|
473
476
|
)
|
|
474
477
|
end
|
|
475
478
|
|
|
479
|
+
# graph_invariants — cross-intent I1/I3/I4 checks (intent 68). I1/I3/I4 are
|
|
480
|
+
# defined within a single store's id space (bare ids resolve within the same
|
|
481
|
+
# store), so build the `nodes` map per scope and run validate_graph per scope.
|
|
482
|
+
# I2 asymmetry (a relational chain entry with no reciprocal sources) is NEVER
|
|
483
|
+
# flagged: validate_graph does not compute it.
|
|
484
|
+
checks.concat(graph_invariant_checks(intent_dirs))
|
|
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
|
+
|
|
501
|
+
checks
|
|
502
|
+
end
|
|
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
|
+
|
|
658
|
+
# Build a per-scope `nodes` map and surface IntentValidator.validate_graph
|
|
659
|
+
# findings as warn-level checks. Scope-aware (the caller already filtered
|
|
660
|
+
# `intent_dirs` by scope), so a `global` id is not falsely flagged as a dangler
|
|
661
|
+
# when only a `project:` store is loaded, and vice versa.
|
|
662
|
+
def graph_invariant_checks(intent_dirs)
|
|
663
|
+
nodes_by_scope = Hash.new { |h, k| h[k] = {} }
|
|
664
|
+
intent_dirs.each do |d|
|
|
665
|
+
md_path = File.join(d[:path], "#{d[:name]}.md")
|
|
666
|
+
next unless File.exist?(md_path)
|
|
667
|
+
|
|
668
|
+
fm = parse_frontmatter(md_path)
|
|
669
|
+
next unless fm.is_a?(Hash) && fm["id"]
|
|
670
|
+
|
|
671
|
+
nodes_by_scope[d[:scope]][fm["id"].to_s] = {
|
|
672
|
+
sources: Array(fm["sources"]).map(&:to_s),
|
|
673
|
+
chain: Array(fm["chain"]).map(&:to_s),
|
|
674
|
+
}
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
i1 = []
|
|
678
|
+
i3 = []
|
|
679
|
+
i4 = []
|
|
680
|
+
nodes_by_scope.each_value do |nodes|
|
|
681
|
+
findings = IntentValidator.validate_graph(nodes)
|
|
682
|
+
i1.concat(findings[:i1])
|
|
683
|
+
i3.concat(findings[:i3])
|
|
684
|
+
i4.concat(findings[:i4])
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
checks = []
|
|
688
|
+
checks << graph_finding_check(
|
|
689
|
+
"graph_i1_reciprocity", i1,
|
|
690
|
+
"Every sources edge has its reciprocal chain entry (I1)",
|
|
691
|
+
"Run new-intent / the rebuild so each source intent's chain backlinks the child"
|
|
692
|
+
)
|
|
693
|
+
checks << graph_finding_check(
|
|
694
|
+
"graph_i3_disjoint", i3,
|
|
695
|
+
"No intent lists the same id in both sources and chain (I3)",
|
|
696
|
+
"Remove the overlapping id from either sources or chain"
|
|
697
|
+
)
|
|
698
|
+
checks << graph_finding_check(
|
|
699
|
+
"graph_i4_danglers", i4,
|
|
700
|
+
"Every sources/chain id resolves to a real intent (I4)",
|
|
701
|
+
"Fix or remove the dangling id reference"
|
|
702
|
+
)
|
|
476
703
|
checks
|
|
477
704
|
end
|
|
478
705
|
|
|
706
|
+
# One graph check: pass when `findings` is empty, otherwise warn (never fail, so
|
|
707
|
+
# an existing store does not turn red on a graph finding). I1/I4 are auto-fixable.
|
|
708
|
+
def graph_finding_check(name, findings, pass_message, fix_hint)
|
|
709
|
+
if findings.empty?
|
|
710
|
+
check(category: "conventions", name: name, status: "pass", message: pass_message)
|
|
711
|
+
else
|
|
712
|
+
check(
|
|
713
|
+
category: "conventions", name: name, status: "warn",
|
|
714
|
+
message: "#{findings.size} #{name} violation(s)",
|
|
715
|
+
details: findings,
|
|
716
|
+
fixable: name != "graph_i3_disjoint",
|
|
717
|
+
fix_hint: fix_hint
|
|
718
|
+
)
|
|
719
|
+
end
|
|
720
|
+
end
|
|
721
|
+
|
|
479
722
|
# --- Check category 3: Agent registration ---
|
|
480
723
|
|
|
481
724
|
def check_agent_registration(agent_key)
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -18,13 +18,15 @@ module Bridge
|
|
|
18
18
|
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
19
19
|
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
|
|
21
|
+
# Bridge cleanup is terminal-state, not age-based (intent 80). A bridge is dead
|
|
22
|
+
# weight ONLY once its intent is terminal (no longer in its store's INDEX.md
|
|
23
|
+
# `## Active` block); such bridges are purged. An Active intent's bridge is kept
|
|
24
|
+
# unconditionally, because while the intent is live the bridge is still load-
|
|
25
|
+
# bearing: it is the continuation signal (a parked or interrupted run resumes
|
|
26
|
+
# from it) and the anti-collision lock (it keys the per-session statusline so
|
|
27
|
+
# parallel sessions do not overwrite each other). An age window was the wrong
|
|
28
|
+
# axis: it left dead bridges resident for ~2 days AND could reap bridges of
|
|
29
|
+
# interrupted-but-still-active intents, which are exactly the ones to preserve.
|
|
28
30
|
|
|
29
31
|
def self.intent_file(intent_dir)
|
|
30
32
|
dir_name = File.basename(intent_dir)
|
|
@@ -58,11 +60,20 @@ module Bridge
|
|
|
58
60
|
end
|
|
59
61
|
|
|
60
62
|
# Resolve a bridge session: first non-empty of explicit, CLAUDE_SESSION_ID,
|
|
61
|
-
# then a derived key. Never returns nil/empty.
|
|
63
|
+
# CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
|
|
64
|
+
# Whitespace-only counts as empty.
|
|
65
|
+
#
|
|
66
|
+
# The CLAUDE_CODE_SESSION_ID fallback (intent 79) is additive: it only changes
|
|
67
|
+
# behavior when CLAUDE_SESSION_ID is blank but CLAUDE_CODE_SESSION_ID is set —
|
|
68
|
+
# the bg/headless case where the real session id lives in CLAUDE_CODE_SESSION_ID.
|
|
69
|
+
# Keying by the real id (instead of a derived hash) lets the statusline, which
|
|
70
|
+
# receives that same id on stdin, find the bridge by direct filename lookup.
|
|
62
71
|
def self.resolve_session(explicit, intent_id:, store:)
|
|
63
72
|
return explicit.to_s.strip unless blank?(explicit)
|
|
64
73
|
env = ENV["CLAUDE_SESSION_ID"]
|
|
65
74
|
return env.to_s.strip unless blank?(env)
|
|
75
|
+
code_env = ENV["CLAUDE_CODE_SESSION_ID"]
|
|
76
|
+
return code_env.to_s.strip unless blank?(code_env)
|
|
66
77
|
derive_key(store, intent_id)
|
|
67
78
|
end
|
|
68
79
|
|
|
@@ -122,25 +133,62 @@ module Bridge
|
|
|
122
133
|
pool.max_by { |c| c[:mtime] }&.fetch(:data)
|
|
123
134
|
end
|
|
124
135
|
|
|
125
|
-
# ---
|
|
126
|
-
|
|
127
|
-
#
|
|
128
|
-
#
|
|
129
|
-
#
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
136
|
+
# --- Terminal-state bridge purge (intent 80) -------------------------------
|
|
137
|
+
|
|
138
|
+
# True iff the intent is Active in its store's INDEX.md. An INDEX.md lives at
|
|
139
|
+
# the PARENT of the store/ dir the bridge records, so we resolve it from the
|
|
140
|
+
# bridge's intent.store. Non-raising: any failure (missing/unreadable INDEX,
|
|
141
|
+
# bad arg) returns false, which means "not active" so the caller treats the
|
|
142
|
+
# bridge as purgeable. `index_active_ids` is a pure-data test seam: when an
|
|
143
|
+
# Array of id strings is supplied, membership is checked against it directly
|
|
144
|
+
# with no file read.
|
|
145
|
+
def self.intent_active?(intent_id, store:, index_active_ids: nil)
|
|
146
|
+
target = intent_id.to_s
|
|
147
|
+
return index_active_ids.include?(target) if index_active_ids.is_a?(Array)
|
|
148
|
+
|
|
149
|
+
index = File.join(File.dirname(store.to_s), "INDEX.md")
|
|
150
|
+
return false unless File.exist?(index)
|
|
151
|
+
|
|
152
|
+
in_active = false
|
|
153
|
+
File.foreach(index) do |line|
|
|
154
|
+
stripped = line.chomp
|
|
155
|
+
if stripped == "## Active"
|
|
156
|
+
in_active = true
|
|
157
|
+
next
|
|
158
|
+
end
|
|
159
|
+
next unless in_active
|
|
160
|
+
break if stripped.start_with?("## ") # next section ends the Active block
|
|
161
|
+
m = stripped.match(/^- \[(\S+) +—/)
|
|
162
|
+
return true if m && m[1] == target
|
|
163
|
+
end
|
|
164
|
+
false
|
|
165
|
+
rescue StandardError
|
|
166
|
+
false
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Remove tmp/plastic-*.json bridge files whose intent is terminal, so
|
|
170
|
+
# discover_bridge's per-fire scan stays bounded. Best-effort and non-raising:
|
|
171
|
+
# returns the array of removed paths. A bridge is purged when it cannot be
|
|
172
|
+
# parsed, has no intent.id, has no intent.store, or its intent is not Active in
|
|
173
|
+
# its store's INDEX.md. An Active intent's bridge is kept (continuation signal +
|
|
174
|
+
# anti-collision lock), and the current session's own bridge is never purged
|
|
133
175
|
# (preserves the disarm_auto contract that it stays readable). Wired into
|
|
134
176
|
# arm_auto and disarm_auto so both manual and auto delivery keep the temp dir
|
|
135
|
-
# clean.
|
|
136
|
-
def self.
|
|
137
|
-
tmp: tmp_dir)
|
|
177
|
+
# clean at deterministic work boundaries.
|
|
178
|
+
def self.purge_done_bridges(session:, tmp: tmp_dir)
|
|
138
179
|
current = path(session, tmp: tmp)
|
|
139
180
|
removed = []
|
|
140
181
|
Dir.glob(File.join(tmp, "plastic-*.json")).each do |f|
|
|
141
182
|
next if f == current
|
|
142
183
|
begin
|
|
143
|
-
|
|
184
|
+
data = JSON.parse(File.read(f)) rescue nil
|
|
185
|
+
keep = false
|
|
186
|
+
if data
|
|
187
|
+
id = data.dig("intent", "id")
|
|
188
|
+
store = data.dig("intent", "store")
|
|
189
|
+
keep = !blank?(id) && !blank?(store) && intent_active?(id, store: store)
|
|
190
|
+
end
|
|
191
|
+
next if keep
|
|
144
192
|
File.delete(f)
|
|
145
193
|
removed << f
|
|
146
194
|
rescue Errno::ENOENT
|
|
@@ -152,7 +200,7 @@ module Bridge
|
|
|
152
200
|
end
|
|
153
201
|
removed
|
|
154
202
|
rescue => e
|
|
155
|
-
$stderr.puts "plastic:
|
|
203
|
+
$stderr.puts "plastic: purge_done_bridges failed: #{e.message}"
|
|
156
204
|
removed || []
|
|
157
205
|
end
|
|
158
206
|
|
|
@@ -386,13 +434,13 @@ module Bridge
|
|
|
386
434
|
# (mid-session intent creation). Re-derives intent state, then sets build.auto.
|
|
387
435
|
def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
|
|
388
436
|
key = resolve_session(session, intent_id: intent_id, store: store)
|
|
389
|
-
if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"])
|
|
437
|
+
if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"]) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
|
|
390
438
|
$stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
|
|
391
439
|
end
|
|
392
440
|
data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
|
|
393
441
|
data["build"]["auto"] = true
|
|
394
442
|
write(key, data)
|
|
395
|
-
|
|
443
|
+
purge_done_bridges(session: key)
|
|
396
444
|
data
|
|
397
445
|
end
|
|
398
446
|
|
|
@@ -403,7 +451,7 @@ module Bridge
|
|
|
403
451
|
data["build"] ||= {}
|
|
404
452
|
data["build"]["auto"] = false
|
|
405
453
|
write(session, data)
|
|
406
|
-
|
|
454
|
+
purge_done_bridges(session: session)
|
|
407
455
|
data
|
|
408
456
|
end
|
|
409
457
|
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# FrontmatterWriter — pure, minimal, style-preserving rewrite of the `sources:`
|
|
5
|
+
# and `chain:` arrays in an intent file's content string (intent 49).
|
|
6
|
+
#
|
|
7
|
+
# It rewrites ONLY those two arrays and leaves every other frontmatter line and
|
|
8
|
+
# the entire body byte-identical. It preserves each array's existing serialization
|
|
9
|
+
# style independently:
|
|
10
|
+
# - flow style: `sources: ["40", "1a"]` (or `[]`)
|
|
11
|
+
# - block style: a `sources:` line followed by ` - '1a'` item lines
|
|
12
|
+
# When the desired array equals the file's current value (same ids, same order),
|
|
13
|
+
# the content is returned UNCHANGED so a re-run produces no diff (idempotency).
|
|
14
|
+
#
|
|
15
|
+
# Pure: no file IO, no eval, no global/ENV state. The IO shell reads/writes files.
|
|
16
|
+
module FrontmatterWriter
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Rewrite `sources:`/`chain:` in `content`. `sources`/`chain` are the desired
|
|
20
|
+
# final arrays of id strings. Returns the new content (or the original when
|
|
21
|
+
# nothing changed). Only operates within the leading `---`...`---` frontmatter
|
|
22
|
+
# block; never touches the body.
|
|
23
|
+
def rewrite_arrays(content, sources:, chain:)
|
|
24
|
+
return content unless content.is_a?(String) && content.start_with?("---")
|
|
25
|
+
|
|
26
|
+
parts = content.split("---", 3)
|
|
27
|
+
return content if parts.length < 3
|
|
28
|
+
|
|
29
|
+
fm = parts[1]
|
|
30
|
+
body = parts[2]
|
|
31
|
+
|
|
32
|
+
fm = rewrite_one(fm, "sources", sources)
|
|
33
|
+
fm = rewrite_one(fm, "chain", chain)
|
|
34
|
+
|
|
35
|
+
"---#{fm}---#{body}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Rewrite a single `key:` array within the frontmatter text `fm`, preserving the
|
|
39
|
+
# key's existing flow-vs-block style. No-op when the key is absent or unchanged.
|
|
40
|
+
def rewrite_one(fm, key, desired)
|
|
41
|
+
lines = fm.lines
|
|
42
|
+
idx = lines.index { |l| l.match?(/\A#{Regexp.escape(key)}:\s/) || l.match?(/\A#{Regexp.escape(key)}:\s*\z/) }
|
|
43
|
+
return fm if idx.nil?
|
|
44
|
+
|
|
45
|
+
header = lines[idx]
|
|
46
|
+
if block_style?(lines, idx)
|
|
47
|
+
rewrite_block(lines, idx, key, desired)
|
|
48
|
+
else
|
|
49
|
+
rewrite_flow(lines, idx, header, key, desired)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The key is block style when its own line carries no inline value and the next
|
|
54
|
+
# non-blank line is a `-` list item.
|
|
55
|
+
def block_style?(lines, idx)
|
|
56
|
+
header = lines[idx]
|
|
57
|
+
inline = header.sub(/\A[^:]+:/, "").strip
|
|
58
|
+
return false unless inline.empty?
|
|
59
|
+
|
|
60
|
+
nxt = lines[idx + 1]
|
|
61
|
+
!nxt.nil? && nxt.match?(/\A\s*-\s/)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Flow style: replace the inline array on the header line, preserving indentation
|
|
65
|
+
# and any trailing newline. No-op when the current ids already match `desired`.
|
|
66
|
+
def rewrite_flow(lines, idx, header, key, desired)
|
|
67
|
+
current = parse_flow(header)
|
|
68
|
+
return lines.join if current == desired
|
|
69
|
+
|
|
70
|
+
newline = header.end_with?("\n") ? "\n" : ""
|
|
71
|
+
lines[idx] = "#{key}: #{render_flow(desired)}#{newline}"
|
|
72
|
+
lines.join
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Parse the inline flow array from a `key: [ ... ]` header line.
|
|
76
|
+
def parse_flow(header)
|
|
77
|
+
inline = header.sub(/\A[^:]+:/, "").strip
|
|
78
|
+
return [] if inline.empty? || inline == "[]"
|
|
79
|
+
|
|
80
|
+
inline = inline.sub(/\A\[/, "").sub(/\]\z/, "")
|
|
81
|
+
inline.split(",").map { |t| t.strip.gsub(/\A['"]|['"]\z/, "") }.reject(&:empty?)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_flow(ids)
|
|
85
|
+
return "[]" if ids.empty?
|
|
86
|
+
|
|
87
|
+
"[#{ids.map { |i| "\"#{i}\"" }.join(", ")}]"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Block style: replace the contiguous `-` item lines following the header. No-op
|
|
91
|
+
# when the current ids already match `desired`. Preserves the item indentation
|
|
92
|
+
# and quoting style sampled from the existing first item.
|
|
93
|
+
def rewrite_block(lines, idx, key, desired)
|
|
94
|
+
last = idx
|
|
95
|
+
item_lines = []
|
|
96
|
+
(idx + 1).upto(lines.length - 1) do |i|
|
|
97
|
+
break unless lines[i].match?(/\A\s*-\s/)
|
|
98
|
+
|
|
99
|
+
item_lines << lines[i]
|
|
100
|
+
last = i
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
current = item_lines.map { |l| l.sub(/\A\s*-\s*/, "").strip.gsub(/\A['"]|['"]\z/, "") }
|
|
104
|
+
return lines.join if current == desired
|
|
105
|
+
|
|
106
|
+
indent, quote = block_item_shape(item_lines.first)
|
|
107
|
+
rendered = desired.map { |id| "#{indent}- #{quote}#{id}#{quote}\n" }
|
|
108
|
+
|
|
109
|
+
# When desired is empty, collapse the block to an inline `key: []` to keep YAML
|
|
110
|
+
# valid (a bare `key:` with no items parses as nil, not an empty array).
|
|
111
|
+
rendered = ["#{key}: []\n"] if desired.empty? && rendered.empty?
|
|
112
|
+
|
|
113
|
+
if desired.empty?
|
|
114
|
+
new_lines = lines[0...idx] + rendered + lines[(last + 1)..]
|
|
115
|
+
else
|
|
116
|
+
new_lines = lines[0...idx] + [lines[idx]] + rendered + lines[(last + 1)..]
|
|
117
|
+
end
|
|
118
|
+
new_lines.join
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Sample indentation and quote char from an existing block item line.
|
|
122
|
+
def block_item_shape(sample)
|
|
123
|
+
return ["", "'"] if sample.nil?
|
|
124
|
+
|
|
125
|
+
indent = sample[/\A\s*/].to_s
|
|
126
|
+
value = sample.sub(/\A\s*-\s*/, "").strip
|
|
127
|
+
quote = value.start_with?('"') ? '"' : "'"
|
|
128
|
+
[indent, quote]
|
|
129
|
+
end
|
|
130
|
+
end
|