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

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 (103) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +1 -1
  13. package/scripts/doctor.rb +80 -6
  14. package/scripts/end-intent +22 -19
  15. package/scripts/exec-worktree +5 -5
  16. package/scripts/graph-measure +249 -0
  17. package/scripts/hook-call-budget +6 -6
  18. package/scripts/hook-capture +16 -15
  19. package/scripts/hook-record +21 -14
  20. package/scripts/hook-savepoint +26 -3
  21. package/scripts/hook-session-start +49 -10
  22. package/scripts/hook-stop +57 -0
  23. package/scripts/lib/active_delivery.rb +105 -0
  24. package/scripts/lib/agent_models.rb +10 -1
  25. package/scripts/lib/arm.rb +41 -102
  26. package/scripts/lib/codex_adapter.rb +197 -0
  27. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  28. package/scripts/lib/day_summary.rb +19 -11
  29. package/scripts/lib/doctor_core.rb +8 -3
  30. package/scripts/lib/doctor_session_ledger.rb +3 -52
  31. package/scripts/lib/engine_permissions.rb +88 -0
  32. package/scripts/lib/exec_worktree.rb +24 -21
  33. package/scripts/lib/graph_edges.rb +4 -4
  34. package/scripts/lib/graph_file.rb +4 -4
  35. package/scripts/lib/graph_measure.rb +645 -0
  36. package/scripts/lib/graph_measure_budget.rb +409 -0
  37. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  38. package/scripts/lib/graph_measure_models.rb +411 -0
  39. package/scripts/lib/graph_measure_report.rb +532 -0
  40. package/scripts/lib/graph_tree.rb +2 -2
  41. package/scripts/lib/handoff.rb +40 -13
  42. package/scripts/lib/harness_adapter.rb +184 -0
  43. package/scripts/lib/hook_registry.rb +13 -1
  44. package/scripts/lib/hook_replay.rb +23 -5
  45. package/scripts/lib/index_entry.rb +53 -0
  46. package/scripts/lib/index_projection.rb +1 -1
  47. package/scripts/lib/insights.rb +1 -1
  48. package/scripts/lib/installer_core.rb +228 -9
  49. package/scripts/lib/intent_screen.rb +1 -1
  50. package/scripts/lib/intent_validator.rb +2 -2
  51. package/scripts/lib/lock.rb +8 -9
  52. package/scripts/lib/meter_watch.rb +15 -9
  53. package/scripts/lib/node_file.rb +3 -3
  54. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  55. package/scripts/lib/node_input_compatibility.rb +62 -0
  56. package/scripts/lib/node_ledger.rb +11 -2
  57. package/scripts/lib/node_progress.rb +153 -0
  58. package/scripts/lib/outcome_report.rb +2 -2
  59. package/scripts/lib/project_config.rb +45 -0
  60. package/scripts/lib/ready_set.rb +1 -1
  61. package/scripts/lib/report_screen.rb +10 -6
  62. package/scripts/lib/roadmap_graph.rb +1 -1
  63. package/scripts/lib/roadmap_queue.rb +1 -1
  64. package/scripts/lib/roadmap_render.rb +1 -1
  65. package/scripts/lib/roadmap_savepoint.rb +1 -1
  66. package/scripts/lib/runner_absorb.rb +36 -10
  67. package/scripts/lib/runner_dispatch.rb +66 -51
  68. package/scripts/lib/runner_sweep.rb +4 -4
  69. package/scripts/lib/runner_until_empty.rb +252 -0
  70. package/scripts/lib/runner_watch.rb +389 -0
  71. package/scripts/lib/savepoint.rb +9 -10
  72. package/scripts/lib/scaffold_intent.rb +6 -3
  73. package/scripts/lib/session_close.rb +30 -28
  74. package/scripts/lib/session_git.rb +22 -16
  75. package/scripts/lib/session_ledger.rb +44 -4
  76. package/scripts/lib/stop_gate.rb +95 -0
  77. package/scripts/lib/verify_intent.rb +2 -2
  78. package/scripts/lib/work_graph_validator.rb +6 -6
  79. package/scripts/lib/worktree.rb +24 -24
  80. package/scripts/lib/worktree_sweep.rb +3 -3
  81. package/scripts/new-intent +1 -1
  82. package/scripts/{node-packet → node-input} +15 -15
  83. package/scripts/node-run +224 -0
  84. package/scripts/plastic-lock +33 -32
  85. package/scripts/read-config +6 -0
  86. package/scripts/runner +203 -19
  87. package/scripts/verify-intent +1 -1
  88. package/skills/auto/SKILL.md +8 -8
  89. package/skills/auto/references/end-tail.md +9 -11
  90. package/skills/conventions/references/completion-and-done.md +9 -10
  91. package/skills/conventions/references/knowledge-graph.md +9 -0
  92. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  93. package/skills/direct/SKILL.md +2 -2
  94. package/skills/doctor/SKILL.md +4 -4
  95. package/skills/intent-creating/evals/evals.json +1 -1
  96. package/skills/intent-executing/SKILL.md +7 -1
  97. package/skills/releasing/SKILL.md +2 -2
  98. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  99. package/skills/releasing/references/release-lines.md +1 -1
  100. package/skills/tutorial/references/track-2-auto.md +1 -1
  101. package/templates/index.md +1 -1
  102. package/templates/project.yml +1 -1
  103. package/scripts/lib/bridge.rb +0 -116
package/PLASTIC.md CHANGED
@@ -83,14 +83,15 @@ runs the flow. A roadmap is an ordered, delivery-side collection; `plastic-roadm
83
83
  config.yml, projects.yml # Preferences; slug -> path
84
84
  store/ID--slug/ # Strategic intents
85
85
  store/.sessions/<YYYYMMDD>/ # Day ledger: <YYYYMMDD>.md, checklist.md, savepoint.md
86
- store/.tmp/<session>/ # current (the pointer), heartbeat; git-ignored
86
+ store/.tmp/<session>/ # heartbeat; git-ignored
87
87
  ~/.plastic/projects/{slug}/ # Project store: INDEX.md, AGENTS.md, roadmaps/, store/
88
88
  ```
89
89
 
90
- `<session>` is the first eight characters of the session id. The pointer holds a day id (the
91
- day ledger takes the record) or an intent id (an auto team owns the record); session start
92
- writes today's day id. The SessionStart hook picks the project store by matching the working
93
- directory against `projects.yml`; no Plastic files land in project code.
90
+ `<session>` is the first eight characters of the session id. Session start writes the heartbeat
91
+ file, proof only that the session started. The delivery lock (`delivery.lock` in the intent
92
+ directory) names the session delivering an intent, as its owner or a delegate; with no lock a
93
+ session records into its day ledger. SessionStart matches the working directory against
94
+ `projects.yml` to pick the project store.
94
95
 
95
96
  Naming: intents are `ID--three-to-five-words`, the file matching the directory
96
97
  (`1a1--slug/1a1--slug.md`); next id: `ruby ~/.plastic/scripts/folgezettel-id <parent_id>
@@ -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
+ node input 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 node input in, one YAML return out, no diff.
17
+
18
+ Read the node input 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
+ node input 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
+ node input 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 node input in, one YAML return out, no diff.
16
+
17
+ Read the node input 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 node input 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 node input
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 node input in, your files and your commit and
19
+ one YAML return out.
20
+
21
+ Read the node input 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 node input'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.24",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -100,7 +100,7 @@ end
100
100
  # instead, with no end-of-line requirement, so trailing note prose is irrelevant. The
101
101
  # separator there may be a real em dash (U+2014, INDEX.md's normal on-write convention,
102
102
  # built from the codepoint so this source line stays em-dash free) or a plain hyphen
103
- # (end-intent's own Bridge.index_entry_match accepts either on read). Because regex
103
+ # (end-intent's own IndexEntry.match accepts either on read). Because regex
104
104
  # alternation is leftmost-first, this only ever matches the date immediately after the
105
105
  # link. It never continues scanning into the note prose, so a second date mentioned
106
106
  # later in a note's free text cannot be mistaken for the completion date. The id class
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
@@ -37,7 +37,7 @@
37
37
  # 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
38
38
  # (delivered) or `## Abandoned` (abandoned), dated today, appending
39
39
  # --index-note (if given). Accepts a real em dash OR a plain hyphen as
40
- # the id/title separator on READ (shared matcher, Bridge.index_entry_match);
40
+ # the id/title separator on READ (shared matcher, IndexEntry.match);
41
41
  # always EMITS the real em dash on write. An id that resolves to neither
42
42
  # `## Active` nor the terminal section is a loud failure (exit 1); an id
43
43
  # already correctly in the terminal section is a quiet, idempotent success.
@@ -46,10 +46,9 @@
46
46
  # 5. Disarm (intent 188, D2/D16; intent 307): check the code worktree (derived
47
47
  # from projects.yml and the intent id, Arm.worktree_block) for uncommitted
48
48
  # changes first (exit 5 if dirty, unless --discard-worktree-changes); call the
49
- # disarm seam (default Arm.disarm: worktree released, lock cleared, the session
50
- # pointer back on the day ledger); verify the durable lock file is actually gone
51
- # afterward, and release it directly when a foreign-keyed disarm left it (the
52
- # /tmp bridge this step once read was removed in 2.0, intent 307).
49
+ # disarm seam (default Arm.disarm: worktree released, lock cleared); verify
50
+ # the durable lock file is actually gone afterward, and release it directly
51
+ # when a foreign-keyed disarm left it.
53
52
  #
54
53
  # Exit codes:
55
54
  # 0 ok - the intent is closed and no delivery.lock remains.
@@ -57,7 +56,7 @@
57
56
  # nor the terminal section (D11). Deliberately double duty; see plan.md.
58
57
  # 2 retired in 2.0 (intent 308): the outcome.md guard reports and proceeds.
59
58
  # 3 steps 1-4 already committed, but the delivery lock genuinely could not
60
- # be cleared (not a bridge cache miss; see step 5 above).
59
+ # be cleared (see step 5 above).
61
60
  # 4 pre-flight refusal: a FRESH foreign lock is held by a live,
62
61
  # non-delegate session; a FRESH lock that will not parse (corrupt); or a
63
62
  # lock file with no resolvable session identity at all. Authors nothing.
@@ -73,8 +72,8 @@ require "fileutils"
73
72
  require "date"
74
73
  require "open3"
75
74
  require "pathname"
76
- require_relative "lib/bridge"
77
75
  require_relative "lib/arm"
76
+ require_relative "lib/index_entry"
78
77
  require_relative "lib/savepoint"
79
78
  require_relative "lib/lock"
80
79
  require_relative "lib/intent_validator"
@@ -89,7 +88,7 @@ DISPOSITIONS = %w[delivered abandoned].freeze
89
88
  # dash (U+2014), the existing INDEX.md convention (store files are exempt from
90
89
  # the no-em-dash shipped-file rule). Built from the codepoint, not a literal
91
90
  # byte in this source file, so the script's own source stays em-dash free.
92
- # READING accepts a plain hyphen too, via the shared Bridge.index_entry_match
91
+ # READING accepts a plain hyphen too, via the shared IndexEntry.match
93
92
  # (intent 188, D9/D12): see move_index_to_terminal below.
94
93
  EM_DASH = "\u2014"
95
94
 
@@ -99,6 +98,10 @@ class UnresolvedIndexEntry < StandardError; end
99
98
 
100
99
  # --- Explicit flag parsing (no eval, no global injection) -------------------
101
100
 
101
+ def blank?(value)
102
+ value.nil? || value.to_s.strip.empty?
103
+ end
104
+
102
105
  def parse_args(argv)
103
106
  opts = {
104
107
  store: nil, id: nil, disposition: nil, index: nil,
@@ -287,7 +290,7 @@ end
287
290
  # Abandoned closes and machine-backfilled outcomes (the backfill marker) are
288
291
  # exempt: the reader cannot expect labels the writer never had.
289
292
  #
290
- # Intent 334 (G1, fold A3; post-execution review, blocking 2): the legacy
293
+ # Intent 334 (G1, review A3; post-execution review, blocking 2): the legacy
291
294
  # "S3" label grammar applies only to actions/*.md headings, and the node id
292
295
  # grammar (kind prefix n/v/d/r plus digits, D1r) applies only to nodes/*.md
293
296
  # headings - never both over both directories. This repo's own house words
@@ -310,7 +313,7 @@ end
310
313
  # 339 S9 (D18): a node id counts as a label in an actions/*.md heading too,
311
314
  # but only when it stands alone as one of the heading's " - "-delimited
312
315
  # 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
316
+ # file's own headings, post-merge) or the bare "### n1 - Title". Never as a
314
317
  # free word inside ordinary prose: the comment above ACTION_ID_LABEL_RE
315
318
  # records exactly why intent 334 restricted the node-id grammar to
316
319
  # nodes/*.md headings in the first place, and that reasoning does not change
@@ -342,7 +345,7 @@ def hollow_report_reason(intent_dir, disposition)
342
345
  # a node id ("n1", "v2", "d3", "r4"). A terse legacy close with no labeled
343
346
  # matrix stays report-and-proceed (intent 334, G1, D10r).
344
347
  #
345
- # Every matching token per heading is kept, not just the first (fold A3):
348
+ # Every matching token per heading is kept, not just the first (review A3):
346
349
  # a legacy actions/ heading can carry BOTH an S-label and prose that reads
347
350
  # like a node id ("### The v2 rewrite (S3)"). Restricting the word-scan
348
351
  # node-id grammar to nodes/*.md headings (post-execution review, blocking
@@ -438,10 +441,10 @@ def section_stop(lines, heading_idx)
438
441
  stop
439
442
  end
440
443
 
441
- # Shared matcher (Bridge.index_entry_match, intent 188 D12): accepts a real em
444
+ # Shared matcher (IndexEntry.match, intent 188 D12): accepts a real em
442
445
  # dash OR a plain hyphen as the id/title separator on READ.
443
446
  def active_line_id(line)
444
- m = Bridge.index_entry_match(line)
447
+ m = IndexEntry.match(line)
445
448
  m && m[1]
446
449
  end
447
450
 
@@ -520,7 +523,7 @@ def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
520
523
  raise UnresolvedIndexEntry, "INDEX.md has no #{target_heading} heading; cannot complete the move"
521
524
  end
522
525
 
523
- m = Bridge.index_entry_match(lines[entry_idx])
526
+ m = IndexEntry.match(lines[entry_idx])
524
527
  title, link = m[2], m[3]
525
528
  note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
526
529
  new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
@@ -609,13 +612,13 @@ end
609
612
  # exclusion with a raw backtrace. nil is the only safe "could not resolve"
610
613
  # signal; main refuses explicitly on it rather than ever reaching takeover.
611
614
  def resolve_end_session(explicit, intent_dir)
612
- return explicit.to_s.strip unless Bridge.blank?(explicit)
615
+ return explicit.to_s.strip unless blank?(explicit)
613
616
  env = ENV["CLAUDE_CODE_SESSION_ID"]
614
- return env.to_s.strip unless Bridge.blank?(env)
617
+ return env.to_s.strip unless blank?(env)
615
618
  lock = Lock.read(intent_dir)
616
619
  return nil unless lock
617
620
  owner = lock["owner_session"].to_s
618
- Bridge.blank?(owner) ? nil : owner
621
+ blank?(owner) ? nil : owner
619
622
  end
620
623
 
621
624
  # Read-only pre-flight verdict (D4). Returns [:proceed, lock_or_nil],
@@ -663,7 +666,7 @@ end
663
666
  def run_disarm(intent_dir, id, session, discard_worktree_changes:,
664
667
  worktree_reader: ->(dir) { Arm.worktree_block(intent_dir: dir) },
665
668
  disarm: ->(sess, dir) { Arm.disarm(intent_dir: dir, session: sess) })
666
- return :ok if Bridge.blank?(session)
669
+ return :ok if blank?(session)
667
670
 
668
671
  block = worktree_reader.call(intent_dir)
669
672
  worktree_code = block.is_a?(Hash) ? block["code"] : nil
@@ -788,7 +791,7 @@ def main(argv)
788
791
  # Lock.takeover (blank session reaches Lock.acquire's blank? guard AFTER the
789
792
  # existing lock file was already deleted) or silently mis-arbitrate. Refuse
790
793
  # rather than guess; nothing has been written yet.
791
- if Bridge.blank?(session) && File.exist?(Lock.path(intent_dir))
794
+ if blank?(session) && File.exist?(Lock.path(intent_dir))
792
795
  warn "end-intent: a delivery lock exists at #{Lock.path(intent_dir)} but no session " \
793
796
  "identity could be resolved (--session, CLAUDE_CODE_SESSION_ID, and the lock's " \
794
797
  "own recorded owner are all blank); refusing rather than guessing. Pass " \
@@ -21,15 +21,15 @@
21
21
  # (--session, else CLAUDE_CODE_SESSION_ID, else the existing delivery.lock's own
22
22
  # recorded owner_session when non-blank, else nil; the lock-owner fallback IS
23
23
  # used here, matching end-intent's teardown resolution order).
24
- # Derive the worktree block from projects.yml and the intent id (Arm.worktree_block;
25
- # the /tmp bridge this once read was removed in 2.0, intent 307). No code worktree on
24
+ # Derive the worktree block from projects.yml and the intent id (Arm.worktree_block).
25
+ # No code worktree on
26
26
  # disk means nothing was provisioned (exit 0).
27
27
  # 1. The dirty-worktree guard (delivered only): git status --porcelain on the code
28
28
  # worktree. Not clean, or the check itself fails, fails CLOSED (exit 3), mirroring
29
29
  # end-intent's dirty-worktree handling. abandoned skips this guard entirely: the
30
30
  # branch survives and can be reclaimed, so a dirty worktree is not a reason to
31
31
  # refuse.
32
- # 2. Finish: Worktree.finish(bridge_data, home:, runner:, merge: disposition ==
32
+ # 2. Finish: Worktree.finish(delivery, home:, runner:, merge: disposition ==
33
33
  # "delivered"). Worktree.finish is fail-open and never raises, so failure is
34
34
  # detected by checking, for a delivered disposition only, whether the worktree
35
35
  # directory still exists on disk afterward (release removes it on success).
@@ -38,8 +38,8 @@
38
38
  # one concern per script (spec D8).
39
39
  #
40
40
  # Exit codes:
41
- # 0 finished (merged or removed per disposition), or nothing to finish (no bridge
42
- # resolved, or no code worktree recorded on the bridge)
41
+ # 0 finished (merged or removed per disposition), or nothing to finish (no delivery
42
+ # resolved, or no code worktree recorded on it)
43
43
  # 1 usage or path-resolution failure
44
44
  # 2 retired in 2.0 (intent 302): was the order precondition; never emitted, never reused
45
45
  # 3 the worktree is not clean, or the merge/removal did not complete (delivered only)