@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.23

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.
Files changed (67) hide show
  1. package/agents/plastic-enforcer.md +6 -3
  2. package/agents/plastic-executor.md +4 -0
  3. package/agents/plastic-node-research.md +28 -0
  4. package/agents/plastic-node-verify.md +27 -0
  5. package/agents/plastic-node-work.md +32 -0
  6. package/bin/lib/context_budget.rb +1 -1
  7. package/hooks/hooks.json +12 -0
  8. package/hooks/statusline +28 -0
  9. package/hooks/stop +5 -0
  10. package/package.json +1 -1
  11. package/scripts/doctor.rb +80 -6
  12. package/scripts/end-intent +3 -3
  13. package/scripts/graph-measure +249 -0
  14. package/scripts/hook-capture +1 -0
  15. package/scripts/hook-savepoint +24 -2
  16. package/scripts/hook-session-start +40 -0
  17. package/scripts/hook-stop +57 -0
  18. package/scripts/lib/active_delivery.rb +61 -0
  19. package/scripts/lib/agent_models.rb +10 -1
  20. package/scripts/lib/codex_adapter.rb +197 -0
  21. package/scripts/lib/doctor_core.rb +8 -3
  22. package/scripts/lib/engine_permissions.rb +88 -0
  23. package/scripts/lib/graph_edges.rb +4 -4
  24. package/scripts/lib/graph_file.rb +4 -4
  25. package/scripts/lib/graph_measure.rb +645 -0
  26. package/scripts/lib/graph_measure_budget.rb +408 -0
  27. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  28. package/scripts/lib/graph_measure_models.rb +411 -0
  29. package/scripts/lib/graph_measure_report.rb +532 -0
  30. package/scripts/lib/graph_tree.rb +2 -2
  31. package/scripts/lib/handoff.rb +36 -5
  32. package/scripts/lib/harness_adapter.rb +184 -0
  33. package/scripts/lib/hook_registry.rb +13 -1
  34. package/scripts/lib/hook_replay.rb +23 -5
  35. package/scripts/lib/index_projection.rb +1 -1
  36. package/scripts/lib/installer_core.rb +109 -3
  37. package/scripts/lib/intent_screen.rb +1 -1
  38. package/scripts/lib/intent_validator.rb +2 -2
  39. package/scripts/lib/meter_watch.rb +15 -9
  40. package/scripts/lib/node_file.rb +3 -3
  41. package/scripts/lib/node_ledger.rb +8 -1
  42. package/scripts/lib/node_progress.rb +153 -0
  43. package/scripts/lib/outcome_report.rb +1 -1
  44. package/scripts/lib/report_screen.rb +10 -6
  45. package/scripts/lib/roadmap_graph.rb +1 -1
  46. package/scripts/lib/roadmap_queue.rb +1 -1
  47. package/scripts/lib/roadmap_render.rb +1 -1
  48. package/scripts/lib/runner_absorb.rb +31 -5
  49. package/scripts/lib/runner_dispatch.rb +26 -11
  50. package/scripts/lib/runner_until_empty.rb +252 -0
  51. package/scripts/lib/runner_watch.rb +389 -0
  52. package/scripts/lib/savepoint.rb +3 -3
  53. package/scripts/lib/session_git.rb +2 -2
  54. package/scripts/lib/stop_gate.rb +95 -0
  55. package/scripts/lib/verify_intent.rb +2 -2
  56. package/scripts/lib/work_graph_validator.rb +6 -6
  57. package/scripts/new-intent +1 -1
  58. package/scripts/node-run +224 -0
  59. package/scripts/read-config +6 -0
  60. package/scripts/runner +203 -19
  61. package/scripts/verify-intent +1 -1
  62. package/skills/auto/SKILL.md +1 -1
  63. package/skills/conventions/references/knowledge-graph.md +9 -0
  64. package/skills/doctor/SKILL.md +3 -3
  65. package/skills/intent-creating/evals/evals.json +1 -1
  66. package/skills/intent-executing/SKILL.md +6 -0
  67. package/skills/tutorial/references/track-2-auto.md +1 -1
@@ -26,9 +26,12 @@ deliberately; the auto pipeline never dispatches them.
26
26
  2.0, intent 304): record the rulings, write `spec.md`, then `plan.md`, at least one real
27
27
  `actions/ACTION_N.md` carrying a failure-mode matrix (one row per operation: the failure and
28
28
  the test that catches it), and `checklist.md`. One consolidated `ACTION_1.md` by default,
29
- never an empty `actions/`.
29
+ never an empty `actions/`. Every name given to a node, file, or field comes from the concept
30
+ family it lives under, graph engineering, the Plastic concepts coined on top of it, and the
31
+ software and AI engineering concepts those rest on; a gap is a design finding to raise, not
32
+ a word to coin.
30
33
  3. **Have the plan reviewed before code** - dispatch one adversarial plan reviewer on
31
- `plastic-intent-executing`'s `plan-reviewer-prompt.md`; fold every finding into the spec, the
34
+ `plastic-intent-executing`'s `plan-reviewer-prompt.md`; merge every finding into the spec, the
32
35
  matrix, and the tests.
33
36
  4. **Dispatch one executor, tests first** - the executor commits the matrix's tests red, then
34
37
  builds, then drives the full suite green; you verify tick-versus-diff at the
@@ -50,7 +53,7 @@ dispatch call's model parameter, alongside the spawn-preamble live-state injecti
50
53
 
51
54
  1. Take the intent; record the rulings in `## Context` + `### Decisions`; write `spec.md`.
52
55
  2. Write `plan.md`, the action files with their matrix, and `checklist.md`; dispatch the plan
53
- reviewer; fold the review.
56
+ reviewer; merge the review findings.
54
57
  3. Dispatch the executor through `plastic-intent-executing` with the whole consolidated action
55
58
  pasted in; require the red commit before the code and a green suite after it. Sequential,
56
59
  one team per intent, on one branch when files are shared.
@@ -65,3 +65,7 @@ verification, checklist deltas, deviations, blockers, insights) plus the executo
65
65
  - Safe-by-default: rename instead of drop, additive migrations, backups before destructive steps
66
66
  - One action at a time; do not batch unrelated changes into one step
67
67
  - Do not claim done until the full suite is green; show the final summary
68
+ - Every test, file, and symbol you name comes from the same concept family: graph engineering,
69
+ the Plastic concepts coined on top of it, and the software and AI engineering concepts those
70
+ rest on; a name from outside that stack is refused, and a gap is a design finding to raise,
71
+ not a word to coin
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: plastic-node-research
3
+ description: |
4
+ Dispatched for a research node by Plastic's node-graph runner. Given the
5
+ packet path the dispatch line names, research the question it names by
6
+ reading the repository, and reply with the YAML return the dispatch line
7
+ names. Read-only: no Bash, no edit. Never spawned outside a runner
8
+ dispatch.
9
+ tools:
10
+ - Read
11
+ - Glob
12
+ - Grep
13
+ model: sonnet
14
+ ---
15
+
16
+ You are one node in Plastic's node-graph runner: one packet in, one YAML return out, no diff.
17
+
18
+ Read the packet the dispatch line names; it is your whole input. Research the question it names
19
+ by reading the repository, never by running it. This role carries no `Bash`, because Claude
20
+ Code's `tools:` field grants or withholds a whole tool name, never a command pattern, and a shell
21
+ is a write whatever command it runs. `Read`, `Glob`, and `Grep` are enough to read every file the
22
+ packet points at.
23
+
24
+ A research node produces no diff. Put what you found in your return's `findings`, never in a code
25
+ change.
26
+
27
+ End your turn with exactly one YAML document, the return the dispatch line names, nothing else
28
+ around it.
@@ -0,0 +1,27 @@
1
+ ---
2
+ name: plastic-node-verify
3
+ description: |
4
+ Dispatched for a verify node by Plastic's node-graph runner. Given the
5
+ packet path the dispatch line names, check the work it names by reading
6
+ the repository, and reply with the YAML return the dispatch line names.
7
+ Read-only: no Bash, no edit. Never spawned outside a runner dispatch.
8
+ tools:
9
+ - Read
10
+ - Glob
11
+ - Grep
12
+ model: opus
13
+ ---
14
+
15
+ You are one node in Plastic's node-graph runner: one packet in, one YAML return out, no diff.
16
+
17
+ Read the packet the dispatch line names; it is your whole input. Verify what it asks you to
18
+ verify by reading the repository, never by running it. This role carries no `Bash`, because
19
+ Claude Code's `tools:` field grants or withholds a whole tool name, never a command pattern, and
20
+ a shell is a write whatever command it runs. `Read`, `Glob`, and `Grep` are enough to read every
21
+ file the packet points at.
22
+
23
+ A verify node produces no diff. If verifying the work would require changing a file, that is not
24
+ this role's job: say so in your return instead of reaching for a tool that could make the change.
25
+
26
+ End your turn with exactly one YAML document, the return the dispatch line names, nothing else
27
+ around it.
@@ -0,0 +1,32 @@
1
+ ---
2
+ name: plastic-node-work
3
+ description: |
4
+ Dispatched for a work node by Plastic's node-graph runner. Given the packet
5
+ path the dispatch line names, make the node's code changes inside the node
6
+ worktree, commit them there, and reply with the YAML return the dispatch
7
+ line names. Never spawned outside a runner dispatch.
8
+ tools:
9
+ - Read
10
+ - Write
11
+ - Edit
12
+ - Bash
13
+ - Glob
14
+ - Grep
15
+ model: sonnet
16
+ ---
17
+
18
+ You are one node in Plastic's node-graph runner: one packet in, your files and your commit and
19
+ one YAML return out.
20
+
21
+ Read the packet the dispatch line names; it is your whole input. Make the changes it asks for,
22
+ inside the worktree it names, and commit them there. Touch no file outside the packet's `files:`
23
+ list, write to no other node's worktree, and write no ledger line yourself: the runner's own
24
+ gates read your commit and your return, never your word.
25
+
26
+ This `tools:` allowlist is a guardrail on tool names, not a sandbox on what `Bash` can do once
27
+ granted. A shell can read, write, and reach the network the same as any of the other tools here,
28
+ so `ruby -e File.write(...)` or a stray `curl` walks straight through it. The diff-scope check at
29
+ `done` is the real backstop for what your commit is allowed to touch, not this list.
30
+
31
+ End your turn with exactly one YAML document, the return the dispatch line names, nothing else
32
+ around it.
@@ -87,7 +87,7 @@ module ContextBudget
87
87
 
88
88
  # What the harness loads at boot: every skill's name and description VALUES,
89
89
  # YAML-parsed. Not the raw frontmatter (that would count the keys and the
90
- # operational fields), and not a line regex (that would truncate a folded
90
+ # operational fields), and not a line regex (that would truncate a flattened
91
91
  # description at its first line).
92
92
  def self.skill_catalog_bytes(repo:)
93
93
  skill_paths(repo: repo).sum do |path|
package/hooks/hooks.json CHANGED
@@ -88,6 +88,18 @@
88
88
  }
89
89
  ]
90
90
  }
91
+ ],
92
+ "Stop": [
93
+ {
94
+ "matcher": "",
95
+ "hooks": [
96
+ {
97
+ "type": "command",
98
+ "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" stop",
99
+ "statusMessage": ""
100
+ }
101
+ ]
102
+ }
91
103
  ]
92
104
  }
93
105
  }
package/hooks/statusline CHANGED
@@ -187,6 +187,7 @@ FIELDS=$(printf '%s' "$INPUT" | awk '
187
187
  wanted["context_window.current_usage.cache_read_input_tokens"] = 1
188
188
  wanted["cost.total_cost_usd"] = 1
189
189
  wanted["rate_limits.five_hour.used_percentage"] = 1
190
+ wanted["rate_limits.five_hour.resets_at"] = 1
190
191
  wanted["rate_limits.seven_day.used_percentage"] = 1
191
192
  }
192
193
  { buf = buf $0 " " }
@@ -209,6 +210,7 @@ CU_CC=""
209
210
  CU_CR=""
210
211
  COST_RAW=""
211
212
  M5_PCT=""
213
+ M5_RESETS=""
212
214
  M7_PCT=""
213
215
 
214
216
  while IFS=$'\t' read -r k v; do
@@ -224,6 +226,7 @@ while IFS=$'\t' read -r k v; do
224
226
  context_window.current_usage.cache_read_input_tokens) CU_CR="$v" ;;
225
227
  cost.total_cost_usd) COST_RAW="$v" ;;
226
228
  rate_limits.five_hour.used_percentage) M5_PCT="$v" ;;
229
+ rate_limits.five_hour.resets_at) M5_RESETS="$v" ;;
227
230
  rate_limits.seven_day.used_percentage) M7_PCT="$v" ;;
228
231
  esac
229
232
  done <<FIELDS_EOF
@@ -301,6 +304,31 @@ meter_seg() { # $1 = label, $2 = raw percentage
301
304
  M5=$(meter_seg "5h" "$M5_PCT")
302
305
  M7=$(meter_seg "7d" "$M7_PCT")
303
306
 
307
+ # --- Rate-limits cache for MeterWatch (graph.md D11) ---
308
+ # Written only when the payload carries a meter at all; an API-key session
309
+ # with no rate_limits section leaves the cache untouched. A present meter
310
+ # with no matching value writes null rather than dropping the key, so
311
+ # MeterWatch always sees the same three fields. Temp file in the cache
312
+ # directory, then mv, so MeterWatch never reads a half-written file.
313
+ if [ -n "$M5_PCT" ] || [ -n "$M7_PCT" ]; then
314
+ CACHE_DIR="$HOME/.plastic/.cache"
315
+ mkdir -p "$CACHE_DIR"
316
+ CACHE5=$(to_int "$M5_PCT")
317
+ CACHE7=$(to_int "$M7_PCT")
318
+ [ -z "$CACHE5" ] && CACHE5=null
319
+ [ -z "$CACHE7" ] && CACHE7=null
320
+ if [ -n "$M5_RESETS" ]; then
321
+ RESETS_JSON="\"$M5_RESETS\""
322
+ else
323
+ RESETS_JSON=null
324
+ fi
325
+ CACHE_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
326
+ CACHE_TMP="$CACHE_DIR/.rate-limits.json.tmp.$$"
327
+ printf '{"five_hour":%s,"seven_day":%s,"resets_at":%s,"at":"%s"}\n' \
328
+ "$CACHE5" "$CACHE7" "$RESETS_JSON" "$CACHE_AT" > "$CACHE_TMP"
329
+ mv "$CACHE_TMP" "$CACHE_DIR/rate-limits.json"
330
+ fi
331
+
304
332
  # --- Session cost (stdin cost.total_cost_usd); hidden when it renders as $0.00 ---
305
333
  COST_SEG=""
306
334
  if [ -n "$COST_RAW" ]; then
package/hooks/stop ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
4
+ echo "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-stop" "$HOME/.plastic"
5
+ exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.22",
3
+ "version": "2.0.0-alpha.23",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -29,6 +29,7 @@ require_relative "lib/lock"
29
29
  require_relative "lib/savepoint"
30
30
  require_relative "lib/node_ledger"
31
31
  require_relative "lib/ready_set"
32
+ require_relative "lib/graph_measure_models"
32
33
  require_relative "lib/index_projection"
33
34
  require_relative "lib/agent_models"
34
35
  require_relative "lib/outcome_guard"
@@ -138,7 +139,7 @@ class Doctor
138
139
  --intent ID Per-intent structure gate at intent-end (intent 222): one
139
140
  closing intent only, never a store sweep. Pair with
140
141
  --store <key> to disambiguate an id that collides across
141
- stores, and --disposition delivered|abandoned to fold in
142
+ stores, and --disposition delivered|abandoned to include
142
143
  the outcome.md disposition check. 3-state pass/warn/fail.
143
144
  -h, --help Show this help
144
145
 
@@ -631,6 +632,15 @@ class Doctor
631
632
  # ReadySet's own require chain is 65,648 bytes, far past that budget.
632
633
  checks.concat(node_graph_checks(intent_dirs))
633
634
 
635
+ # model_requalification (D42, intent 343 G10 n7): a recorded model= that no
636
+ # longer matches what RunnerPolicy.model_for resolves for its role today
637
+ # means every measurement taken under the old model measured a different
638
+ # system. Lives here for the same reason node_graph_checks does: pins to
639
+ # test/doctor_core_split_test.rb's exact-set boot-path assertion, never
640
+ # doctor_core.rb (D11) - GraphMeasureModels' own require chain (NodeLedger,
641
+ # NodeFile, RunnerPolicy, AgentModels) is far past that file's byte budget.
642
+ checks.concat(model_requalification_checks(scopes: scopes))
643
+
634
644
  # unpromoted_rules (intent 341, G8, C37): an Insights entry tagged
635
645
  # `rule:` (via `insight-append --rule`) is a promise the rule will make
636
646
  # it into project doctrine. Advisory only.
@@ -982,14 +992,14 @@ def check_done_signals(scopes: nil)
982
992
  # rebuild-savepoint for most gaps, or knowingly excluded for the ones 219 D6 forbids ever
983
993
  # repairing (no real outcome.md to echo a disposition from). Three branches (spec D4/D5):
984
994
  # a malformed exclusion file can never report pass (loud), a clean remaining gap set reports
985
- # pass with the exclusion count folded in, and a real remaining gap set stays warn, same as
986
- # before intent 274, with the same count folded in when exclusions applied.
995
+ # pass with the exclusion count included, and a real remaining gap set stays warn, same as
996
+ # before intent 274, with the same count included when exclusions applied.
987
997
  #
988
- # `dead_suffix` (intent 280) folds in a second, independent drift notice: exclusion rows that
998
+ # `dead_suffix` (intent 280) adds a second, independent drift notice: exclusion rows that
989
999
  # suppressed nothing this run. It is purely informational, exactly like `exclusion_suffix` - it
990
1000
  # never changes status on any of the three branches below, because a stale governance-record row
991
1001
  # is bookkeeping drift, not a store regression (219 D6 is untouched: no disposition is invented).
992
- # Per rule (intent 308): savepoint_operational and backfilled_complete each fold in only
1002
+ # Per rule (intent 308): savepoint_operational and backfilled_complete each include only
993
1003
  # their own exclusions and dead rows, so one check never carries the other's counts.
994
1004
  suffixes = lambda do |excluded_rows, dead|
995
1005
  ex = excluded_rows.empty? ? "" : " (#{excluded_rows.size} excluded via #{exclusion_paths.join(", ")})"
@@ -1040,7 +1050,7 @@ def check_done_signals(scopes: nil)
1040
1050
  # backfilled_complete (intent 308): spec.md, plan.md, or a real action file missing on a
1041
1051
  # terminal intent. Same three branches as savepoint_operational: a malformed exclusion
1042
1052
  # file is always loud, a clean set passes with its own exclusion and dead-row counts
1043
- # folded in, a real gap set warns with the backfill verb as the fix.
1053
+ # included, a real gap set warns with the backfill verb as the fix.
1044
1054
  backfill_hint = "Write the missing documents from the record via " \
1045
1055
  "`scaffold-intent backfill --store <store> --id <id> --disposition " \
1046
1056
  "<delivered|abandoned>` (never touches real content, one intent per invocation)."
@@ -1787,6 +1797,70 @@ end
1787
1797
  end
1788
1798
  end
1789
1799
 
1800
+ # --- Check: re-qualification on model change (D42, intent 343 G10 n7) ---
1801
+ #
1802
+ # When the model behind a role changes, every measurement GraphMeasureModels
1803
+ # took under the old model measured a different system, and the numbers
1804
+ # need re-qualifying before anyone acts on them (D42). This warns, never
1805
+ # fails (D10): a model change is routine, and a red doctor over it would
1806
+ # train people to stop reading the report. The comparison itself is
1807
+ # GraphMeasureModels' own (D16, via RunnerPolicy.model_for) - this method
1808
+ # never re-derives it, it only names the drift rows GraphMeasureModels
1809
+ # already computed.
1810
+ #
1811
+ # Runs once per discovered store (StoreDiscovery, shared with every other
1812
+ # store-scoped check here), never per intent directory: GraphMeasureModels
1813
+ # walks a whole store_dir itself in one pass. A store this process cannot
1814
+ # read - or any other exception the store walk raises - is skipped, never
1815
+ # fatal: one bad store must never take every other doctor check down with
1816
+ # it.
1817
+ #
1818
+ # NEW-4 (v2 review, D22): this rule used to pass `project_config: {}` on
1819
+ # purpose, reasoning there was no live project scope at doctor-run time -
1820
+ # false: each discovered store DOES have a real sibling `config.yml`
1821
+ # (`GraphMeasureModels.project_config_path`), the same file the `cohorts`
1822
+ # verb resolves. Loading it here means the doctor and `cohorts` can never
1823
+ # disagree about the same store's project-scope override again.
1824
+ def model_requalification_checks(scopes: nil)
1825
+ global_config = load_yaml_safe(File.join(plastic_home, "config.yml")) || {}
1826
+ findings = []
1827
+
1828
+ store_discovery[:stores].each do |s|
1829
+ next if scopes && !scopes.include?(s[:key])
1830
+
1831
+ project_config = load_yaml_safe(GraphMeasureModels.project_config_path(s[:store])) || {}
1832
+ record = begin
1833
+ GraphMeasureModels.read(s[:store], project_config: project_config, global_config: global_config)
1834
+ rescue StandardError
1835
+ next
1836
+ end
1837
+
1838
+ %i[executor advisor].each do |role|
1839
+ Array(record[:drift][role]).each do |row|
1840
+ findings << "#{s[:key]}/#{row[:intent]}/#{row[:node]} (#{role}): recorded model=#{row[:recorded]}, " \
1841
+ "current config resolves #{row[:expected]} for kind #{row[:kind]} - re-qualify any " \
1842
+ "measurement taken while model=#{row[:recorded]} was in effect"
1843
+ end
1844
+ end
1845
+ end
1846
+
1847
+ if findings.empty?
1848
+ [check(
1849
+ category: "conventions", name: "model_requalification", status: "pass",
1850
+ message: "No recorded model= differs from what config resolves today for its role"
1851
+ )]
1852
+ else
1853
+ [check(
1854
+ category: "conventions", name: "model_requalification", status: "warn",
1855
+ message: "#{findings.size} node(s) recorded a model that no longer matches the role's " \
1856
+ "config-resolved model",
1857
+ details: findings, fixable: false,
1858
+ fix_hint: "The old measurements were taken against a different model; re-qualify them under " \
1859
+ "the current model before acting on their numbers"
1860
+ )]
1861
+ end
1862
+ end
1863
+
1790
1864
  # --- Check: unpromoted rule: findings (intent 341, G8, C37) ---------------
1791
1865
  #
1792
1866
  # `insight-append --rule` tags an entry "... - rule: <text>". A tag is a
@@ -287,7 +287,7 @@ end
287
287
  # Abandoned closes and machine-backfilled outcomes (the backfill marker) are
288
288
  # exempt: the reader cannot expect labels the writer never had.
289
289
  #
290
- # Intent 334 (G1, fold A3; post-execution review, blocking 2): the legacy
290
+ # Intent 334 (G1, review A3; post-execution review, blocking 2): the legacy
291
291
  # "S3" label grammar applies only to actions/*.md headings, and the node id
292
292
  # grammar (kind prefix n/v/d/r plus digits, D1r) applies only to nodes/*.md
293
293
  # headings - never both over both directories. This repo's own house words
@@ -310,7 +310,7 @@ end
310
310
  # 339 S9 (D18): a node id counts as a label in an actions/*.md heading too,
311
311
  # but only when it stands alone as one of the heading's " - "-delimited
312
312
  # segments - this repo's own convention, "### S1 - n1 - Title" (this action
313
- # file's own headings, post-fold) or the bare "### n1 - Title". Never as a
313
+ # file's own headings, post-merge) or the bare "### n1 - Title". Never as a
314
314
  # free word inside ordinary prose: the comment above ACTION_ID_LABEL_RE
315
315
  # records exactly why intent 334 restricted the node-id grammar to
316
316
  # nodes/*.md headings in the first place, and that reasoning does not change
@@ -342,7 +342,7 @@ def hollow_report_reason(intent_dir, disposition)
342
342
  # a node id ("n1", "v2", "d3", "r4"). A terse legacy close with no labeled
343
343
  # matrix stays report-and-proceed (intent 334, G1, D10r).
344
344
  #
345
- # Every matching token per heading is kept, not just the first (fold A3):
345
+ # Every matching token per heading is kept, not just the first (review A3):
346
346
  # a legacy actions/ heading can carry BOTH an S-label and prose that reads
347
347
  # like a node id ("### The v2 rewrite (S3)"). Restricting the word-scan
348
348
  # node-id grammar to nodes/*.md headings (post-execution review, blocking
@@ -0,0 +1,249 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ require "json"
6
+ require "yaml"
7
+ require "date"
8
+ require_relative "lib/savepoint"
9
+
10
+ # graph-measure - the one read-only executable over the node ledger (intent
11
+ # 343, G10, n2). A subcommand table over one library, following
12
+ # scripts/runner's shape (327 spec): the public verbs are named up front even
13
+ # before every module lands, a flag outside a verb's own vocabulary is
14
+ # refused by name, and each verb's module is required LAZILY inside its own
15
+ # branch so a verb whose module has not landed yet reports plainly instead of
16
+ # taking `intent`, the verb an operator runs constantly, down with it.
17
+ #
18
+ # Usage:
19
+ # graph-measure <intent|budget|cohorts> <dir> [--format text|json]
20
+ #
21
+ # `intent` (n2) and `budget` (n4) take an INTENT directory. `cohorts` (n5,
22
+ # its model section; n6 adds the rates, the latency and the bar to the same
23
+ # verb) takes a STORE directory instead - the directory holding `id--slug/`
24
+ # intent folders for one project - and is validated against that shape, not
25
+ # `intent_directory?`.
26
+ #
27
+ # Exit codes:
28
+ # 0 - a report rendered (anomalies never change this: a ledger with torn
29
+ # lines still renders, lists them, and exits 0)
30
+ # 1 - the directory has no savepoint.md to measure at all, named
31
+ # 2 - usage: no args, an unknown subcommand, an unknown flag, or the path
32
+ # is not the right shape for its verb (an intent directory for
33
+ # `intent`/`budget`, a store directory for `cohorts`)
34
+ # 3 - the verb's module has not been delivered yet
35
+ module GraphMeasureCLI
36
+ module_function
37
+
38
+ VERBS = %w[intent budget cohorts].freeze
39
+ SAVEPOINT_FILE = "savepoint.md"
40
+
41
+ # verb -> [[ModuleName, lib_file], ...], required lazily inside the verb's
42
+ # own branch (row 2.6). "budget" and "cohorts" name libraries n4 and n5
43
+ # ship; requiring them today raises LoadError, caught below.
44
+ LAZY_MODULES = {
45
+ "intent" => [["GraphMeasure", "graph_measure"], ["GraphMeasureReport", "graph_measure_report"]],
46
+ "budget" => [["GraphMeasureBudget", "graph_measure_budget"]],
47
+ "cohorts" => [["GraphMeasureModels", "graph_measure_models"], ["GraphMeasureCohorts", "graph_measure_cohorts"]],
48
+ }.freeze
49
+
50
+ KNOWN_FLAGS = {
51
+ "intent" => %w[--format],
52
+ "budget" => %w[--format],
53
+ "cohorts" => %w[--format],
54
+ }.freeze
55
+
56
+ # "intent" and "budget" take one intent directory; "cohorts" takes the
57
+ # store directory that holds many of them (row 5.15).
58
+ DIR_KIND = { "intent" => :intent, "budget" => :intent, "cohorts" => :store }.freeze
59
+
60
+ def usage
61
+ warn "Usage: graph-measure <#{VERBS.join('|')}> <dir> [--format text|json]"
62
+ end
63
+
64
+ def intent_directory?(dir)
65
+ dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
66
+ end
67
+
68
+ def store_directory?(dir)
69
+ dir && File.directory?(dir)
70
+ end
71
+
72
+ def valid_dir?(verb, dir)
73
+ DIR_KIND.fetch(verb, :intent) == :store ? store_directory?(dir) : intent_directory?(dir)
74
+ end
75
+
76
+ def opt(args, name)
77
+ (i = args.index(name)) && args[i + 1]
78
+ end
79
+
80
+ def unrecognized_flag(verb, args)
81
+ allowed = KNOWN_FLAGS.fetch(verb, [])
82
+ args.find { |a| a.is_a?(String) && a.start_with?("--") && !allowed.include?(a) }
83
+ end
84
+
85
+ def main(argv)
86
+ args = argv.dup
87
+ verb = args.shift
88
+
89
+ unless verb
90
+ usage
91
+ return 2
92
+ end
93
+
94
+ unless VERBS.include?(verb)
95
+ warn "graph-measure: unknown subcommand #{verb.inspect}"
96
+ usage
97
+ return 2
98
+ end
99
+
100
+ dir_arg = args.shift
101
+ unless valid_dir?(verb, dir_arg && File.expand_path(dir_arg))
102
+ label = DIR_KIND.fetch(verb, :intent) == :store ? "not a store directory" : "not an intent directory"
103
+ warn "graph-measure: #{dir_arg.inspect} is #{label}"
104
+ usage
105
+ return 2
106
+ end
107
+ dir = File.expand_path(dir_arg)
108
+
109
+ bad_flag = unrecognized_flag(verb, args)
110
+ if bad_flag
111
+ warn "graph-measure: unknown flag #{bad_flag.inspect} for #{verb}"
112
+ usage
113
+ return 2
114
+ end
115
+
116
+ format = opt(args, "--format") || "text"
117
+ unless %w[text json].include?(format)
118
+ warn "graph-measure: unknown --format #{format.inspect} (use text or json)"
119
+ usage
120
+ return 2
121
+ end
122
+
123
+ run_lazy(verb, dir, format)
124
+ rescue StandardError => e
125
+ # Row 2.22: a malformed graph.md, a torn ledger, or invalid UTF-8 never
126
+ # raises out of GraphMeasure or GraphMeasureReport (both are built to
127
+ # resolve broken data to an anomaly, never an exception) - this is
128
+ # defence in depth so a defect in either never reaches the owner as a
129
+ # bare Ruby backtrace.
130
+ warn "graph-measure: internal error: #{e.message}"
131
+ 1
132
+ end
133
+
134
+ def run_lazy(verb, dir, format)
135
+ LAZY_MODULES.fetch(verb, []).each { |(_name, file)| require_relative(File.join("lib", file)) }
136
+ rescue LoadError
137
+ warn "graph-measure: #{verb} is not yet delivered (its module has not landed)"
138
+ 3
139
+ else
140
+ dispatch_lazy(verb, dir, format)
141
+ end
142
+
143
+ def dispatch_lazy(verb, dir, format)
144
+ case verb
145
+ when "intent"
146
+ run_intent(dir, format)
147
+ when "budget"
148
+ run_budget(dir, format)
149
+ when "cohorts"
150
+ run_cohorts(dir, format)
151
+ else
152
+ warn "graph-measure: #{verb}'s module loaded but no dispatcher is wired up yet"
153
+ 3
154
+ end
155
+ end
156
+
157
+ # M1: the boundary's own config resolution, never GraphMeasureModels' or
158
+ # GraphMeasureCohorts' (spec D2, both modules' own DI convention). Mirrors
159
+ # scripts/doctor.rb's own `PLASTIC_HOME` fallback and `load_yaml_safe`
160
+ # shape exactly, so a missing or malformed config.yml/project.yml resolves
161
+ # to `{}` (the shipped-default chain), never an exception.
162
+ def plastic_home
163
+ ENV.fetch("PLASTIC_HOME") { File.join(Dir.home, ".plastic") }
164
+ end
165
+
166
+ def load_yaml_safe(path)
167
+ return {} unless path && File.exist?(path)
168
+
169
+ YAML.safe_load(File.read(path), permitted_classes: [Date, Time]) || {}
170
+ rescue StandardError
171
+ {}
172
+ end
173
+
174
+ def missing_savepoint(dir)
175
+ savepoint_path = File.join(dir, SAVEPOINT_FILE)
176
+ return nil if File.exist?(savepoint_path)
177
+
178
+ warn "graph-measure: #{savepoint_path} does not exist, nothing to measure"
179
+ savepoint_path
180
+ end
181
+
182
+ def run_intent(dir, format)
183
+ # Row 2.5: checked directly, ahead of GraphMeasure.read, which resolves a
184
+ # missing file to an empty (not absent) record by design (D3) - the CLI
185
+ # boundary is the only place that can still tell "nothing to measure"
186
+ # apart from "measured, and it is empty".
187
+ return 1 if missing_savepoint(dir)
188
+
189
+ record = GraphMeasure.read(dir)
190
+ puts format == "json" ? GraphMeasureReport.render_json(record) : GraphMeasureReport.render_text(record)
191
+ 0
192
+ end
193
+
194
+ def run_budget(dir, format)
195
+ return 1 if missing_savepoint(dir)
196
+
197
+ record = GraphMeasureBudget.read(dir)
198
+ puts format == "json" ? GraphMeasureBudget.render_json(record) : GraphMeasureBudget.render_text(record)
199
+ 0
200
+ end
201
+
202
+ # Row 2.5's "missing savepoint.md" check does not apply here: `dir` is a
203
+ # STORE directory (spec D1's "the directory holding id--slug/ intent
204
+ # folders"), which never carries a savepoint.md of its own.
205
+ #
206
+ # n6 adds the rates, the latency and the bar to this same verb, sourced
207
+ # from `GraphMeasureCohorts`. Its bar section reuses the one
208
+ # `GraphMeasureModels` walk's `qualified` counts (passed in here) rather
209
+ # than triggering a second store walk for the same numbers.
210
+ #
211
+ # M1 (v1 review): this call used to pass neither `project_config:` nor
212
+ # `global_config:`, so the model comparison always resolved against
213
+ # RunnerPolicy's shipped defaults only, never the real project-then-
214
+ # global-then-default chain the CHANGELOG describes and
215
+ # `scripts/doctor.rb`'s own `model_requalification_checks` actually reads.
216
+ # Reproduced: with a `plastic-executor:` override present in global
217
+ # config, `GraphMeasureModels.read` reports real drift rows while this
218
+ # verb (unpatched) always reported zero. `GraphMeasure`'s own modules stay
219
+ # config-free by design (D2's DI convention: they never read disk or ENV
220
+ # themselves) - this executable is the boundary that is allowed to, the
221
+ # same role `scripts/doctor.rb` already plays for the same two config
222
+ # files.
223
+ #
224
+ # NEW-4 (v2 review, D22): the project-scope half named `<store>/../project.yml`,
225
+ # a file whose template carries no `agents:` key at all, so a real
226
+ # operator override at the file that DOES exist in the wild - the store's
227
+ # sibling `config.yml` - was silently ignored. The path now comes from
228
+ # `GraphMeasureModels.project_config_path`, the one function
229
+ # `scripts/doctor.rb`'s own rule calls too, so the two commands cannot
230
+ # resolve a different file for the same store.
231
+ def run_cohorts(dir, format)
232
+ global_config = load_yaml_safe(File.join(plastic_home, "config.yml"))
233
+ project_config = load_yaml_safe(GraphMeasureModels.project_config_path(dir))
234
+
235
+ models_record = GraphMeasureModels.read(dir, project_config: project_config, global_config: global_config)
236
+ cohorts_record = GraphMeasureCohorts.read(dir, qualified: models_record[:qualified],
237
+ project_config: project_config, global_config: global_config)
238
+ if format == "json"
239
+ combined = GraphMeasureModels.model(models_record).merge(GraphMeasureCohorts.model(cohorts_record))
240
+ puts JSON.generate(combined)
241
+ else
242
+ puts GraphMeasureModels.render_text(models_record)
243
+ puts GraphMeasureCohorts.render_text(cohorts_record)
244
+ end
245
+ 0
246
+ end
247
+ end
248
+
249
+ exit GraphMeasureCLI.main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -11,6 +11,7 @@
11
11
 
12
12
  require "json"
13
13
  require "open3"
14
+ require "time"
14
15
  require "fileutils"
15
16
  require_relative "lib/session_ledger"
16
17
  require_relative "lib/dashboard_banner"