@zalom/plastic 1.0.0-beta.7 → 1.0.0-beta.9

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 CHANGED
@@ -50,6 +50,13 @@ tags: [plastic, architecture]
50
50
  - Context contract: load `sources` strongly (they are what the intent was built from);
51
51
  traverse `chain` lightly for discovery. See
52
52
  docs/concepts/how-plastic-sources-and-chains-intents.md for the full model.
53
+ - `## Links` (I5) is the human-readable projection of the graph. It mirrors the
54
+ frontmatter exactly: every entry is `- [[id--slug|<target's full intent: text>]]`, a
55
+ clickable `id--slug` wikilink target with the target intent's full `intent:` text as the
56
+ label (cross-store targets render `- [[store:id--slug|<target's full intent: text>]]`).
57
+ Ordering is mandatory: all `sources` first (top), then all `chain`, frontmatter order
58
+ preserved within each group. Sources never appear at the end. No source/chain tags, no
59
+ sub-grouping. An intent with empty `sources` and `chain` carries the empty-state comment.
53
60
  - IDs use Luhmann's alternating convention: `1` → `1a` → `1a1` → `1a1a`
54
61
  - Multiple branches increment: `1a`, `1b`, `1c`
55
62
 
@@ -164,8 +171,8 @@ Format: `ID--three-to-five-words` (all stores).
164
171
  - **Root (`15`, `16`)**: an independent thought, even if inspired by another intent.
165
172
  Reserve `sources` for true created-from provenance (intents this was built out of). An
166
173
  independent intent merely related to or inspired by another carries NO `sources`; record
167
- the relation on the PREDECESSOR's `chain` (and mirror it as a `[[id]]` wikilink in
168
- `## Links`).
174
+ the relation on the PREDECESSOR's `chain` (and mirror it as a
175
+ `[[id--slug|<target's full intent: text>]]` wikilink in `## Links`).
169
176
  - **Rule of thumb:** if the intent could exist without its parent, it's a root.
170
177
 
171
178
  ## INDEX.md
package/hooks/statusline CHANGED
@@ -84,9 +84,34 @@ else
84
84
  fi
85
85
  INDEX_DIR=$(dirname "$INDEX")
86
86
 
87
- # --- Most-recently-accessed active intent (mirror dashboard: last savepoint ts) ---
87
+ # --- Current-session work-unit: the live bridge wins over savepoint recency ---
88
+ # The statusline receives the real session_id on stdin; the session's bridge
89
+ # (plastic-{session_id}.json) is the authoritative "what THIS session is driving".
90
+ # This beats the shared, savepoint-recency heuristic so parallel sessions on one
91
+ # store no longer overwrite each other's line. grep/sed only - no jq, no ruby.
88
92
  WORK=""
89
- if [ -f "$INDEX" ]; then
93
+ SID=$(json_str "session_id")
94
+ if [ -n "$SID" ]; then
95
+ BRIDGE="${PLASTIC_TMP:-/tmp}/plastic-${SID}.json"
96
+ if [ -f "$BRIDGE" ]; then
97
+ # Bridge JSON is pretty-printed (one field per line). Scope extraction to the
98
+ # "intent" object so a sibling top-level "id"/"name" can never win (the intent
99
+ # object holds only string fields, so the first "}" closes it). grep/sed only.
100
+ INTENT_BLOCK=$(sed -n '/"intent"[[:space:]]*:[[:space:]]*{/,/}/p' "$BRIDGE" 2>/dev/null)
101
+ B_ID=$(printf '%s\n' "$INTENT_BLOCK" | grep -o '"id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
102
+ | sed 's/.*"id"[[:space:]]*:[[:space:]]*"//;s/"$//')
103
+ B_NAME=$(printf '%s\n' "$INTENT_BLOCK" | grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 \
104
+ | sed 's/.*"name"[[:space:]]*:[[:space:]]*"//;s/"$//')
105
+ if [ -n "$B_ID" ] && [ -n "$B_NAME" ]; then
106
+ EMDASH=$'\342\200\224'
107
+ WORK="${B_ID} ${EMDASH} ${B_NAME}"
108
+ fi
109
+ fi
110
+ fi
111
+
112
+ # --- Most-recently-accessed active intent (mirror dashboard: last savepoint ts) ---
113
+ # Fallback only: runs when no session bridge resolved a work-unit above.
114
+ if [ -z "$WORK" ] && [ -f "$INDEX" ]; then
90
115
  BEST_TS=""
91
116
  while IFS= read -r line; do
92
117
  [ -z "$line" ] && continue
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-beta.9",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
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
@@ -58,11 +58,20 @@ module Bridge
58
58
  end
59
59
 
60
60
  # Resolve a bridge session: first non-empty of explicit, CLAUDE_SESSION_ID,
61
- # then a derived key. Never returns nil/empty. Whitespace-only counts as empty.
61
+ # CLAUDE_CODE_SESSION_ID, then a derived key. Never returns nil/empty.
62
+ # Whitespace-only counts as empty.
63
+ #
64
+ # The CLAUDE_CODE_SESSION_ID fallback (intent 79) is additive: it only changes
65
+ # behavior when CLAUDE_SESSION_ID is blank but CLAUDE_CODE_SESSION_ID is set —
66
+ # the bg/headless case where the real session id lives in CLAUDE_CODE_SESSION_ID.
67
+ # Keying by the real id (instead of a derived hash) lets the statusline, which
68
+ # receives that same id on stdin, find the bridge by direct filename lookup.
62
69
  def self.resolve_session(explicit, intent_id:, store:)
63
70
  return explicit.to_s.strip unless blank?(explicit)
64
71
  env = ENV["CLAUDE_SESSION_ID"]
65
72
  return env.to_s.strip unless blank?(env)
73
+ code_env = ENV["CLAUDE_CODE_SESSION_ID"]
74
+ return code_env.to_s.strip unless blank?(code_env)
66
75
  derive_key(store, intent_id)
67
76
  end
68
77
 
@@ -386,7 +395,7 @@ module Bridge
386
395
  # (mid-session intent creation). Re-derives intent state, then sets build.auto.
387
396
  def self.arm_auto(session, intent_id:, intent_dir:, store:, name:)
388
397
  key = resolve_session(session, intent_id: intent_id, store: store)
389
- if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"])
398
+ if blank?(session) && blank?(ENV["CLAUDE_SESSION_ID"]) && blank?(ENV["CLAUDE_CODE_SESSION_ID"])
390
399
  $stderr.puts "plastic: no session id available; arming auto with derived bridge key #{key}"
391
400
  end
392
401
  data = derive(key, intent_id: intent_id, intent_dir: intent_dir, store: store, name: name)
@@ -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