@zalom/plastic 2.0.0-alpha.21 → 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 (86) hide show
  1. package/PLASTIC.md +20 -18
  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/doctor.rb +159 -10
  13. package/scripts/end-intent +3 -3
  14. package/scripts/graph-measure +249 -0
  15. package/scripts/hook-capture +1 -0
  16. package/scripts/hook-savepoint +24 -2
  17. package/scripts/hook-session-start +333 -321
  18. package/scripts/hook-stop +57 -0
  19. package/scripts/insight-append +18 -4
  20. package/scripts/lib/active_delivery.rb +61 -0
  21. package/scripts/lib/agent_models.rb +10 -1
  22. package/scripts/lib/codex_adapter.rb +197 -0
  23. package/scripts/lib/doctor_core.rb +8 -3
  24. package/scripts/lib/engine_permissions.rb +88 -0
  25. package/scripts/lib/graph_edges.rb +4 -4
  26. package/scripts/lib/graph_file.rb +4 -4
  27. package/scripts/lib/graph_measure.rb +645 -0
  28. package/scripts/lib/graph_measure_budget.rb +408 -0
  29. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  30. package/scripts/lib/graph_measure_models.rb +411 -0
  31. package/scripts/lib/graph_measure_report.rb +532 -0
  32. package/scripts/lib/graph_tree.rb +2 -2
  33. package/scripts/lib/handoff.rb +36 -5
  34. package/scripts/lib/harness_adapter.rb +184 -0
  35. package/scripts/lib/hook_registry.rb +13 -1
  36. package/scripts/lib/hook_replay.rb +23 -5
  37. package/scripts/lib/index_projection.rb +1 -1
  38. package/scripts/lib/installer_core.rb +112 -6
  39. package/scripts/lib/intent_screen.rb +1 -1
  40. package/scripts/lib/intent_validator.rb +2 -2
  41. package/scripts/lib/meter_watch.rb +15 -9
  42. package/scripts/lib/node_file.rb +3 -3
  43. package/scripts/lib/node_ledger.rb +8 -1
  44. package/scripts/lib/node_progress.rb +153 -0
  45. package/scripts/lib/outcome_report.rb +1 -1
  46. package/scripts/lib/report_screen.rb +10 -6
  47. package/scripts/lib/roadmap_graph.rb +1 -1
  48. package/scripts/lib/roadmap_queue.rb +1 -1
  49. package/scripts/lib/roadmap_render.rb +1 -1
  50. package/scripts/lib/runner_absorb.rb +31 -5
  51. package/scripts/lib/runner_dispatch.rb +26 -11
  52. package/scripts/lib/runner_until_empty.rb +252 -0
  53. package/scripts/lib/runner_watch.rb +389 -0
  54. package/scripts/lib/savepoint.rb +3 -3
  55. package/scripts/lib/session_git.rb +2 -2
  56. package/scripts/lib/stop_gate.rb +95 -0
  57. package/scripts/lib/verify_intent.rb +2 -2
  58. package/scripts/lib/work_graph_validator.rb +6 -6
  59. package/scripts/new-intent +1 -1
  60. package/scripts/node-run +224 -0
  61. package/scripts/read-config +6 -0
  62. package/scripts/runner +203 -19
  63. package/scripts/skill-lint +115 -6
  64. package/scripts/verify-intent +1 -1
  65. package/skills/auto/SKILL.md +54 -56
  66. package/skills/auto/references/agent-architecture.md +10 -8
  67. package/skills/auto/references/human-report-contract.md +1 -1
  68. package/skills/conventions/references/completion-and-done.md +7 -7
  69. package/skills/conventions/references/knowledge-graph.md +9 -0
  70. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  71. package/skills/conventions/references/maintenance-and-revisions.md +1 -1
  72. package/skills/doctor/SKILL.md +3 -3
  73. package/skills/doctor/report.md +1 -1
  74. package/skills/intent-continuing/references/boarding-matrix.md +2 -2
  75. package/skills/intent-creating/SKILL.md +58 -133
  76. package/skills/intent-creating/evals/evals.json +1 -1
  77. package/skills/intent-ending/SKILL.md +48 -56
  78. package/skills/intent-ending/evals/evals.json +1 -1
  79. package/skills/intent-executing/SKILL.md +43 -136
  80. package/skills/intent-speccing/SKILL.md +3 -0
  81. package/skills/releasing/SKILL.md +1 -1
  82. package/skills/releasing/references/release-lines.md +1 -1
  83. package/skills/tutorial/SKILL.md +2 -1
  84. package/skills/tutorial/references/track-1-guided.md +21 -40
  85. package/skills/tutorial/references/track-2-auto.md +3 -3
  86. package/templates/agents.md +2 -2
package/PLASTIC.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > **This file is maintained by Plastic.** It is overwritten on update; do not edit it. Project
4
4
  > rules go in `AGENTS.md`. Deeper doctrine lives in the six `plastic-conventions` chapters
5
5
  > (`references/<chapter>.md`: knowledge-graph, lifecycle-and-savepoints, locks-and-worktrees,
6
- > completion-and-done, maintenance-and-revisions, roadmaps), read on demand.
6
+ > completion-and-done, maintenance-and-revisions, roadmaps).
7
7
 
8
8
  ## Two Modes, Plus Auto
9
9
 
@@ -43,20 +43,20 @@ IDs follow Luhmann's alternating convention (`1`, `1a`, `1a1`, `1a1a`); siblings
43
43
 
44
44
  ## The Record: Stages as its Shape
45
45
 
46
- | Stage | Section | Deliverable | Skill |
47
- |---|---|---|---|
48
- | **What** | `## Intent` | `{ID}--{slug}.md` | `plastic-intent-creating` |
49
- | **Why** | `## Context` + Decisions | `spec.md` | `plastic-intent-speccing` (thinking mode) |
50
- | **How** | `checklist.md` + `actions/` | `plan.md` | `plastic-intent-speccing` writes the action files |
51
- | **Exec** | `## Outcome` | `outcome.md` | `plastic-intent-executing`; `plastic-intent-ending` closes |
46
+ | Stage | Section | Skill |
47
+ |---|---|---|
48
+ | **What** | `## Intent` | `plastic-intent-creating` |
49
+ | **Why** | `## Context` + Decisions | `plastic-intent-speccing` (optional) |
50
+ | **How** | `graph.md` + `nodes/` | `plastic-intent-speccing` |
51
+ | **Exec** | `## Outcome` | `plastic-intent-executing` drives the runner loop; `plastic-intent-ending` closes |
52
52
 
53
- Stages are the shape of the record, not checkpoints: nothing blocks a write. Only "how to
54
- execute" (a checklist item plus its action) must exist before work; the rest is backfilled at
55
- intent end from what was recorded while working.
53
+ Stages are the shape of the record, not checkpoints: nothing blocks a write. Work runs on
54
+ `graph.md` and `nodes/`, driven by `runner step` (dispatch), `runner status` (read the
55
+ ledger), and `runner answer` (close a `needs_decision` node). `spec.md` stays optional;
56
+ `plan.md` and `checklist.md` exist only for a legacy intent with no `graph.md`.
56
57
 
57
58
  Invoke a skill for your harness: Claude Code uses the slash form (`/plastic-intent-creating`);
58
- Codex CLI uses a dollar prefix instead (`$plastic-intent-creating`), and may also select a skill
59
- implicitly by matching its description.
59
+ Codex CLI uses a dollar prefix instead (`$plastic-intent-creating`), and may also select a skill implicitly by matching its description.
60
60
 
61
61
  `## Insights` is the append-only log of durable discoveries from every stage, newest at the
62
62
  bottom, never prepended, each entry prefixed `{utc-iso8601} · {stage} · {author}`. Write
@@ -71,8 +71,8 @@ An auto team is `plastic-enforcer` (the lead) plus `plastic-executor`; models li
71
71
  Twenty skills, each `plastic-<name>`, listed by your harness. `plastic-doctor` checks
72
72
  installation health (core, store, and full scopes). `plastic-feedback` turns a Plastic quirk,
73
73
  bug, or feature idea into a redacted local report and a prefilled GitHub issue URL; only the
74
- user submits it. If the user hits one, offer to invoke the plastic-feedback skill yourself
75
- instead of waiting to be asked. A release is a collection of intents; `plastic-releasing`
74
+ user submits it. If the user hits one, offer to invoke the plastic-feedback skill yourself.
75
+ A release is a collection of intents; `plastic-releasing`
76
76
  runs the flow. A roadmap is an ordered, delivery-side collection; `plastic-roadmap` owns it.
77
77
 
78
78
  ## State System
@@ -108,8 +108,10 @@ the store root, so the id allocator and every 1.x store walker skip them.
108
108
  2. Artifacts go in the intent directory, never in `docs/plans/` or `docs/specs/`: code goes in
109
109
  the project, everything else in the intent. Capture observations in `## Insights`; at the
110
110
  end write `outcome.md` and `## Outcome`, and update INDEX.md.
111
- 3. State is derived from what exists: `outcome.md` present means done, so never write it
112
- before the checklist is fully checked. Status lives on checklist items, not intents.
111
+ 3. State is derived from what exists: `outcome.md` present means the intent is closed, so
112
+ never write it before every node in `graph.md` reaches a terminal status (checklist.md
113
+ fully checked, for a legacy intent). Status lives on graph nodes or checklist items,
114
+ never on intents.
113
115
  4. The global store is never pushed (`~/.plastic/` holds sensitive data); agent-created repos
114
116
  are private by default (`gh repo create --private`).
115
117
  5. Intents are created only via `plastic-intent-creating`, never hand-authored. It
@@ -137,5 +139,5 @@ treating work as new. Enola, or Serena when Enola is absent: prefer its symbol r
137
139
  Locks and worktrees exist only for auto teams. One team develops an intent at a time: a
138
140
  session-keyed `delivery.lock` in the intent directory, alive while its mtime lease is fresh.
139
141
  Code edits happen in the intent's worktree (`<repo>/.claude/worktrees/{id}--{slug}`, branch
140
- `plastic/{id}--{slug}`). "Done" is three signals that agree: INDEX `## Completed` or
141
- `## Abandoned`, `outcome.md`, and the savepoint `Done` line; INDEX wins on conflict.
142
+ `plastic/{id}--{slug}`). A terminal intent is three signals that agree: INDEX
143
+ `## Completed`/`## Abandoned`, `outcome.md`, and the terminal savepoint line; INDEX wins.
@@ -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.21",
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,20 @@ 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
+
644
+ # unpromoted_rules (intent 341, G8, C37): an Insights entry tagged
645
+ # `rule:` (via `insight-append --rule`) is a promise the rule will make
646
+ # it into project doctrine. Advisory only.
647
+ checks.concat(unpromoted_rules_checks(intent_dirs, home: Dir.home))
648
+
634
649
  # cross_store_resolution — RESOLVES (not just shape-checks) every cross-store
635
650
  # `store:id` ref against the FULL store family via the relocation map
636
651
  # (relocation consulted first), closing the shape-only gap i1/i3/i4 leave open.
@@ -795,7 +810,11 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
795
810
  # written from the record by `scaffold-intent backfill`, so this is repairable and
796
811
  # reported as a fixable warn (backfilled_complete). Its exclusions go to their OWN
797
812
  # bucket so savepoint_operational's consumed/dead-row bookkeeping above never sees them.
798
- backfill_gaps = %w[spec.md plan.md].reject { |f| Savepoint.stage_file_present?(File.join(dir, f)) }
813
+ # A graph intent (real graph.md on disk) never carries spec.md or plan.md by design
814
+ # (D1, no ceremonies, intent 341): the graph IS the spec and the plan, so a missing one
815
+ # is never a gap here.
816
+ is_graph_intent = File.exist?(File.join(dir, "graph.md"))
817
+ backfill_gaps = is_graph_intent ? [] : %w[spec.md plan.md].reject { |f| Savepoint.stage_file_present?(File.join(dir, f)) }
799
818
  # Name whichever directory the intent actually used (post-execution
800
819
  # review, non-blocking 8), consistent with Savepoint.missing_for_stage:
801
820
  # a nodes/ directory on disk means the intent chose the node-graph
@@ -853,7 +872,7 @@ def check_done_signals(scopes: nil)
853
872
  projection = IndexProjection.analyze(store[:store_dir], index_path: store[:index])
854
873
  projection[:drift].each do |row|
855
874
  index_drift << "#{store[:scope]}: #{row[:id]} - INDEX says #{row[:index_status]}, " \
856
- "the ledger's last Done line says #{row[:ledger_status]}"
875
+ "the ledger's last line says #{row[:ledger_status]}"
857
876
  end
858
877
  end
859
878
 
@@ -973,14 +992,14 @@ def check_done_signals(scopes: nil)
973
992
  # rebuild-savepoint for most gaps, or knowingly excluded for the ones 219 D6 forbids ever
974
993
  # repairing (no real outcome.md to echo a disposition from). Three branches (spec D4/D5):
975
994
  # a malformed exclusion file can never report pass (loud), a clean remaining gap set reports
976
- # pass with the exclusion count folded in, and a real remaining gap set stays warn, same as
977
- # 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.
978
997
  #
979
- # `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
980
999
  # suppressed nothing this run. It is purely informational, exactly like `exclusion_suffix` - it
981
1000
  # never changes status on any of the three branches below, because a stale governance-record row
982
1001
  # is bookkeeping drift, not a store regression (219 D6 is untouched: no disposition is invented).
983
- # 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
984
1003
  # their own exclusions and dead rows, so one check never carries the other's counts.
985
1004
  suffixes = lambda do |excluded_rows, dead|
986
1005
  ex = excluded_rows.empty? ? "" : " (#{excluded_rows.size} excluded via #{exclusion_paths.join(", ")})"
@@ -1031,7 +1050,7 @@ def check_done_signals(scopes: nil)
1031
1050
  # backfilled_complete (intent 308): spec.md, plan.md, or a real action file missing on a
1032
1051
  # terminal intent. Same three branches as savepoint_operational: a malformed exclusion
1033
1052
  # file is always loud, a clean set passes with its own exclusion and dead-row counts
1034
- # 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.
1035
1054
  backfill_hint = "Write the missing documents from the record via " \
1036
1055
  "`scaffold-intent backfill --store <store> --id <id> --disposition " \
1037
1056
  "<delivered|abandoned>` (never touches real content, one intent per invocation)."
@@ -1097,7 +1116,7 @@ def check_done_signals(scopes: nil)
1097
1116
  fix_hint: "For a live (Active) intent, rebuild the ledger via " \
1098
1117
  "Savepoint.rebuild_savepoint. Terminal (Completed/Abandoned) intents are immutable: " \
1099
1118
  "a phantom there stays advisory unless an explicit human grant authorizes the " \
1100
- "124a manual Done-bookend repair."
1119
+ "124a manual terminal-bookend repair."
1101
1120
  )
1102
1121
  end
1103
1122
 
@@ -1294,9 +1313,13 @@ end
1294
1313
  end
1295
1314
 
1296
1315
  INTENT_END_LIFECYCLE_FILES = %w[spec.md plan.md checklist.md outcome.md].freeze
1316
+ # A graph intent (D1, no ceremonies, intent 341) never carries spec.md, plan.md, or
1317
+ # checklist.md by design; the graph IS the spec and the plan. outcome.md stays mandatory.
1318
+ GRAPH_INTENT_LIFECYCLE_FILES = %w[outcome.md].freeze
1297
1319
 
1298
1320
  def intent_lifecycle_artifacts_check(intent_dir, disposition)
1299
- missing = INTENT_END_LIFECYCLE_FILES.select { |f| !Savepoint.stage_file_present?(File.join(intent_dir, f)) }
1321
+ files = File.exist?(File.join(intent_dir, "graph.md")) ? GRAPH_INTENT_LIFECYCLE_FILES : INTENT_END_LIFECYCLE_FILES
1322
+ missing = files.select { |f| !Savepoint.stage_file_present?(File.join(intent_dir, f)) }
1300
1323
  unless Savepoint.stage_file_present?(Savepoint.intent_file(intent_dir))
1301
1324
  missing = [File.basename(Savepoint.intent_file(intent_dir))] + missing
1302
1325
  end
@@ -1774,6 +1797,132 @@ end
1774
1797
  end
1775
1798
  end
1776
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
+
1864
+ # --- Check: unpromoted rule: findings (intent 341, G8, C37) ---------------
1865
+ #
1866
+ # `insight-append --rule` tags an entry "... - rule: <text>". A tag is a
1867
+ # promise the rule will make it into project doctrine; until the exact
1868
+ # rule text shows up in some skills/conventions/references/*.md chapter,
1869
+ # it is only visible to a session that happens to read this one intent
1870
+ # file, and the next session repeats the mistake the rule names. Advisory
1871
+ # only (warn, never fail): a freshly tagged rule is not yet promoted by
1872
+ # design, and nothing here can auto-promote it (that is an editorial call,
1873
+ # not a mechanical one).
1874
+ RULE_ENTRY_RE = /—\s*rule:\s*(.+?)\s*\z/.freeze
1875
+
1876
+ def unpromoted_rules_checks(intent_dirs, package_root: PACKAGE_ROOT, home: nil)
1877
+ # The installed doctor runs from ~/.plastic/scripts, so PACKAGE_ROOT
1878
+ # (~/.plastic) has no skills/ directory: the conventions chapters install
1879
+ # to the agent home layout instead. `home` is caller-injected (never an
1880
+ # ENV read here) so this stays hermetic in tests; the real call site
1881
+ # passes the process's actual home directory.
1882
+ chapter_dirs = []
1883
+ if home
1884
+ chapter_dirs << File.join(home, ".claude", "skills", "plastic-conventions", "references")
1885
+ chapter_dirs << File.join(home, ".agents", "skills", "plastic-conventions", "references")
1886
+ end
1887
+ chapter_dirs << File.join(package_root, "skills", "conventions", "references")
1888
+
1889
+ chapters_text = chapter_dirs.select { |d| Dir.exist?(d) }
1890
+ .flat_map { |d| Dir.glob(File.join(d, "*.md")) }
1891
+ .map { |f| File.read(f) }
1892
+ .join("\n\n")
1893
+
1894
+ unpromoted = []
1895
+ intent_dirs.each do |d|
1896
+ md_path = File.join(d[:path], "#{d[:name]}.md")
1897
+ next unless File.exist?(md_path)
1898
+
1899
+ File.readlines(md_path).each do |line|
1900
+ m = line.chomp.match(RULE_ENTRY_RE)
1901
+ next unless m
1902
+
1903
+ rule_text = m[1].strip
1904
+ next if rule_text.empty?
1905
+
1906
+ unpromoted << "#{tilde(d[:path])}: #{rule_text}" unless chapters_text.include?(rule_text)
1907
+ end
1908
+ end
1909
+
1910
+ if unpromoted.empty?
1911
+ [check(
1912
+ category: "conventions", name: "unpromoted_rules", status: "pass",
1913
+ message: "Every tagged rule: finding is carried by a conventions chapter"
1914
+ )]
1915
+ else
1916
+ [check(
1917
+ category: "conventions", name: "unpromoted_rules", status: "warn",
1918
+ message: "#{unpromoted.size} tagged rule(s) not yet carried by any conventions chapter",
1919
+ details: unpromoted, fixable: false,
1920
+ fix_hint: "Promote the rule into the right skills/conventions/references/*.md chapter, " \
1921
+ "or drop the tag if the finding does not belong in doctrine"
1922
+ )]
1923
+ end
1924
+ end
1925
+
1777
1926
  # --- Check category 3: Agent registration ---
1778
1927
 
1779
1928
 
@@ -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