@zalom/plastic 2.0.0-alpha.16 → 2.0.0-alpha.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.16",
3
+ "version": "2.0.0-alpha.18",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,12 +5,11 @@
5
5
  # Usage: hook-capture (reads the UserPromptSubmit stdin JSON payload)
6
6
  # Intent 298. One UserPromptSubmit process replacing hook-continue,
7
7
  # hook-future-intent-check, and hook-auto-arm: it appends a pending line to
8
- # the session day ledger, detects "continue" and "auto" prompts, and hints at
9
- # matching Future intents. Every job runs in its own rescue and the hook
10
- # always exits 0 (spec D2).
8
+ # the session day ledger and detects "continue" and "auto" prompts. Every job
9
+ # runs in its own rescue and the hook always exits 0 (spec D2). Intent 345
10
+ # (D7, 323) removed the per-prompt Future-intent hint step entirely.
11
11
 
12
12
  require "json"
13
- require "yaml"
14
13
  require "open3"
15
14
  require "fileutils"
16
15
  require_relative "lib/session_ledger"
@@ -42,87 +41,6 @@ templates = File.expand_path("../templates", __dir__)
42
41
  sid = SessionLedger.short_session_id(nil, session_id)
43
42
  today = SessionLedger.day_id
44
43
 
45
- # --- Extracted matching logic, shared by both the global and project store
46
- # passes of job (f) below (mirrors hook-future-intent-check verbatim). ---
47
- def future_intent_matches(store_root, message)
48
- index_path = File.join(store_root, "INDEX.md")
49
- return [] unless File.exist?(index_path)
50
-
51
- lines = File.readlines(index_path)
52
- future_dirs = []
53
- section = nil
54
-
55
- lines.each do |line|
56
- section = :future if line.start_with?("## Future")
57
- section = nil if line.start_with?("## ") && !line.start_with?("## Future")
58
- next unless section == :future && line.strip.start_with?("- [")
59
-
60
- future_dirs << $1 if line =~ /store\/([\w-]+)\//
61
- end
62
- return [] if future_dirs.empty?
63
-
64
- matches = []
65
- message_words = message.split(/\W+/).reject { |w| w.length < 4 }
66
-
67
- future_dirs.each do |dir|
68
- intent_path = File.join(store_root, "store", dir, "#{dir}.md")
69
- next unless File.exist?(intent_path)
70
-
71
- content = File.read(intent_path)
72
-
73
- tags = []
74
- tags = $1.split(",").map(&:strip).map(&:downcase) if content =~ /^tags:\s*\[([^\]]+)\]/
75
-
76
- intent_name = ""
77
- intent_name = $1.downcase if content =~ /^intent:\s*["']?(.+?)["']?\s*$/
78
-
79
- keywords = (tags + intent_name.split(/\W+/).reject { |w| w.length < 4 }).map(&:downcase).uniq
80
-
81
- matched_keywords = keywords.select { |kw| message.include?(kw) }
82
- name_matches = message_words.select { |w| intent_name.include?(w) }
83
- matched_keywords = (matched_keywords + name_matches).uniq
84
-
85
- next if matched_keywords.empty?
86
-
87
- index_line = lines.find { |l| l.include?(dir) }&.strip || "#{dir} - #{intent_name}"
88
- matches << { "index_line" => index_line, "keywords" => matched_keywords }
89
- end
90
- matches
91
- end
92
-
93
- # The store_root that carries the current project's own store, resolved from
94
- # cwd through projects.yml, mirroring SessionLedger.project_slug's matching
95
- # but returning nil (not "global") when nothing matches, since there is then
96
- # no distinct second store to hint against.
97
- def resolve_project_store_root(cwd, plastic_home)
98
- projects_path = File.join(plastic_home, "projects.yml")
99
- return nil unless File.exist?(projects_path)
100
-
101
- data = begin
102
- YAML.safe_load(File.read(projects_path))
103
- rescue StandardError
104
- nil
105
- end
106
- data = {} unless data.is_a?(Hash)
107
- projects = data["projects"].is_a?(Hash) ? data["projects"] : {}
108
- expanded_cwd = File.expand_path(cwd)
109
-
110
- matches = projects.filter_map do |slug, info|
111
- next unless info.is_a?(Hash) && slug.is_a?(String)
112
-
113
- path = info["path"]
114
- next unless path
115
-
116
- root = File.expand_path(path)
117
- next unless expanded_cwd == root || expanded_cwd.start_with?("#{root}#{File::SEPARATOR}")
118
-
119
- [root.length, slug]
120
- end
121
-
122
- best = matches.max_by { |(length, _slug)| length }
123
- best ? File.join(plastic_home, "projects", best[1]) : nil
124
- end
125
-
126
44
  def truncate(text, max)
127
45
  return text if text.length <= max
128
46
 
@@ -183,7 +101,7 @@ system_message = nil
183
101
 
184
102
  # --- (d) "continue" cockpit -------------------------------------------------
185
103
  begin
186
- if prompt.match?(/\bcontinue\b/i)
104
+ if prompt.to_s.strip.downcase == "continue"
187
105
  dashboard = File.expand_path("dashboard.rb", __dir__)
188
106
  if File.exist?(dashboard)
189
107
  cockpit, _err, status = Open3.capture3({ "RUBYOPT" => nil }, "ruby", dashboard, "continue")
@@ -224,25 +142,6 @@ rescue StandardError
224
142
  nil
225
143
  end
226
144
 
227
- # --- (f) Future-intent hint, global store then the project's own store -----
228
- begin
229
- message = prompt.to_s.downcase
230
- if message.strip.length >= 10 && message.strip != "continue"
231
- matches = future_intent_matches(plastic_home, message)
232
- project_root = resolve_project_store_root(cwd, plastic_home)
233
- matches += future_intent_matches(project_root, message) if project_root
234
-
235
- if matches.any?
236
- parts = ["PLASTIC - Future intents related to this message:\n"]
237
- matches.each { |m| parts << "#{m["index_line"]} (matched: #{m["keywords"].join(", ")})" }
238
- parts << "\nConsider asking the user if they want to activate any of these, or note the connection."
239
- context_parts << parts.join("\n")
240
- end
241
- end
242
- rescue StandardError
243
- nil
244
- end
245
-
246
145
  exit 0 if context_parts.empty?
247
146
 
248
147
  payload_out = {
@@ -577,30 +577,69 @@ module ReportScreen
577
577
  rows.compact
578
578
  end
579
579
 
580
- # Intent 331b (plan.md, "The one non-additive edit"): the standalone-token
581
- # rule, extracted so `action_file_for` (the plan screen's Action column)
582
- # calls the exact same rule as `matching_action_heading` and the two can
583
- # never drift on what counts as a match. `matching_action_heading`'s own
584
- # signature, return shape and behavior are unchanged (row P16).
585
- def self.heading_tokens(heading)
586
- heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
587
- end
588
-
589
- # Rows 25-27: D19 - the label must appear as a standalone token in an action
590
- # file heading (any level); the count is the matched section's table rows only.
591
- def self.matching_action_heading(intent_dir, label)
580
+ # Intent 331b (plan.md, "The one non-additive edit"): the standalone-token
581
+ # rule, extracted so `action_file_for` (the plan screen's Action column)
582
+ # calls the exact same rule as `matching_action_heading` and the two can
583
+ # never drift on what counts as a match.
584
+ def self.heading_tokens(heading)
585
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
586
+ end
587
+
588
+ # Rows 25-27: D19/D1r - the label must appear as a standalone token in an
589
+ # action file heading (any level), AND that heading must own at least one
590
+ # matrix data row - a heading that only names the label, with no table
591
+ # beneath it (or a table with a separator but no data row), is skipped and
592
+ # the walk keeps going. Lexicographic path order (D8), then file order.
593
+ #
594
+ # Merge note (322 into alpha, 2026-09-05): 322's table-owning rule and 331b's
595
+ # extracted `heading_tokens` are both kept. The token split now comes from the
596
+ # shared helper so `action_file_for` cannot drift from this walk, while the
597
+ # `table_rows(body).any?` guard stays the thing that decides the match.
598
+ def self.matching_action_heading(intent_dir, label)
599
+ Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
600
+ split_by_headings(File.read(path)).each do |heading, body|
601
+ next unless heading_tokens(heading).include?(label)
602
+ return [heading, body] if table_rows(body).any?
603
+ end
604
+ end
605
+ [nil, nil]
606
+ end
607
+
608
+ # D3r: the row-cell fallback, for the shape where the label never appears in
609
+ # a heading at all, only as the first cell of a matrix data row. Restricted
610
+ # to tables under a heading that names itself a matrix (/matrix/i) - never a
611
+ # step list or any other table - so it cannot answer for a record that has
612
+ # no matrix anywhere (the close-gate defeat the plan review measured).
613
+ # Emphasis (bold/italic/code) is stripped from the compared cell; the count
614
+ # sums matching rows across every matrix heading, in every action file.
615
+ def self.matching_matrix_rows(intent_dir, label)
616
+ count = 0
592
617
  Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
593
618
  split_by_headings(File.read(path)).each do |heading, body|
594
- return [heading, body] if heading_tokens(heading).include?(label)
619
+ next unless heading.to_s.match?(/matrix/i)
620
+ table_rows(body).each do |cells|
621
+ cell = cells[0].to_s.gsub(/[*_`]/, "").strip
622
+ count += 1 if cell == label
623
+ end
595
624
  end
596
625
  end
597
- [nil, nil]
626
+ count
598
627
  end
599
628
 
629
+ # D7: a label with no letter never resolves, on either path - it is a
630
+ # bullet-derived Delivered number (delivered_rows), never a label anyone
631
+ # wrote, and would otherwise fabricate proof from a numbered heading like
632
+ # "## 1. What this intent is" or from a numbered matrix row-cell column.
600
633
  def self.proven_by(intent_dir, label)
634
+ return NOT_RECORDED unless label.to_s.match?(/[A-Za-z]/)
635
+
601
636
  _heading, body = matching_action_heading(intent_dir, label)
602
- return NOT_RECORDED unless body
603
- n = table_rows(body).length
637
+ if body
638
+ n = table_rows(body).length
639
+ return n.positive? ? "#{n} test#{n == 1 ? '' : 's'}" : NOT_RECORDED
640
+ end
641
+
642
+ n = matching_matrix_rows(intent_dir, label)
604
643
  n.positive? ? "#{n} test#{n == 1 ? '' : 's'}" : NOT_RECORDED
605
644
  end
606
645
 
@@ -67,9 +67,10 @@ fill `## Summary`, `## Delivered`, `## Verification`, `## Follow-ups`. `## Deliv
67
67
  `| Row | What |` table: one row per thing delivered, in plain wording a reader
68
68
  recognizes, not a method name or an implementation summary (that detail
69
69
  belongs in `## Summary`). Each row's label must appear as a standalone token
70
- in an action-file heading (`### S1 - ...` proves row S1); that heading's
71
- matrix rows become the row's Proven-by cell on `report-screen delivered`'s
72
- post-delivery screen. `## Needs you` is the literal None or a
70
+ in an action-file heading that owns the matrix table (`### S1 - ...` with a
71
+ table beneath it proves row S1; a table-less heading naming the label is
72
+ skipped); that heading's matrix rows become the row's Proven-by cell on
73
+ `report-screen delivered`'s post-delivery screen. `## Needs you` is the literal None or a
73
74
  `| N | What | Why |` table. On abandon, `## Summary` states the abandonment reason and the trail (see Pivot
74
75
  below). A placeholder outcome.md is backfilled from the record instead, with the
75
76
  close's disposition and the `--outcome-summary` line as its summary. Also author
@@ -75,7 +75,7 @@ Apply the auto skill's risk rule to the executor's return and the diff: a matrix
75
75
 
76
76
  Whenever a review verdict returns - the plan review before code, or the post-execution review above - the lead appends a `Review` line: `ruby ~/.plastic/scripts/savepoint-note <intent_dir> --kind Review --text "<verdict, what changed>"` (intent 317, D17). This is the other half of what `report-screen delay` reads.
77
77
 
78
- **The D19 heading convention.** An action file's `## Delivered` row (in `outcome.md`) is proven by whichever `actions/ACTION_N.md` heading carries that row's label as a standalone token - `### Row A -` proves row A, `### S1 -` proves row S1. Write action-file section headings so the label they prove is unambiguous (never a substring another label could also match, like `A` inside `AB`); `report-screen delivered`'s Proven-by column renders `not recorded` when no heading matches.
78
+ **The D19 heading convention.** An action file's `## Delivered` row (in `outcome.md`) is proven by the first `actions/ACTION_N.md` heading that carries that row's label as a standalone token AND owns the matrix table (322 D1r) - `### Row A -` with a table beneath it proves row A, `### S1 -` proves row S1; a heading that only names the label, with no table under it, is skipped. Write action-file section headings so the label they prove is unambiguous (never a substring another label could also match, like `A` inside `AB`); `report-screen delivered`'s Proven-by column renders `not recorded` when no heading owns a matching table and no matrix row cell carries the label either.
79
79
 
80
80
  ### Step 4: Update Intent and Complete
81
81
  Capture observations in `## Insights`. When ALL checklist items are checked:
@@ -10,8 +10,11 @@ disposition: delivered|abandoned
10
10
  <!-- One row per thing delivered, in plain wording a reader recognizes, not
11
11
  an implementation summary; the technical detail belongs in ## Summary. Each
12
12
  row's label must appear as a standalone token in an actions/*.md heading
13
- (for example "### S1 - ..." proves row S1): that heading's matrix rows become
14
- the row's Proven-by cell on the delivered screen (intent 317 D19, 317a). -->
13
+ that owns the matrix table (for example "### S1 - ..." with a table beneath
14
+ it proves row S1); that heading's matrix rows become the row's Proven-by
15
+ cell on the delivered screen (intent 317 D19, 317a, 322 D1r). A label with no
16
+ owning heading falls back to a matrix row cell that carries it, when one
17
+ under a heading named "matrix" exists (322 D3r). -->
15
18
  | Row | What |
16
19
  | --- | --- |
17
20
  | S1 | ... |