@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.
- package/agents/plastic-enforcer.md +6 -3
- package/agents/plastic-executor.md +4 -0
- package/agents/plastic-node-research.md +28 -0
- package/agents/plastic-node-verify.md +27 -0
- package/agents/plastic-node-work.md +32 -0
- package/bin/lib/context_budget.rb +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/statusline +28 -0
- package/hooks/stop +5 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +3 -3
- package/scripts/graph-measure +249 -0
- package/scripts/hook-capture +1 -0
- package/scripts/hook-savepoint +24 -2
- package/scripts/hook-session-start +40 -0
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +61 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/graph_edges.rb +4 -4
- package/scripts/lib/graph_file.rb +4 -4
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +408 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +411 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +2 -2
- package/scripts/lib/handoff.rb +36 -5
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +13 -1
- package/scripts/lib/hook_replay.rb +23 -5
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/installer_core.rb +109 -3
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/node_ledger.rb +8 -1
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +1 -1
- package/scripts/lib/report_screen.rb +10 -6
- package/scripts/lib/roadmap_graph.rb +1 -1
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_render.rb +1 -1
- package/scripts/lib/runner_absorb.rb +31 -5
- package/scripts/lib/runner_dispatch.rb +26 -11
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +3 -3
- package/scripts/lib/session_git.rb +2 -2
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/verify_intent.rb +2 -2
- package/scripts/lib/work_graph_validator.rb +6 -6
- package/scripts/new-intent +1 -1
- package/scripts/node-run +224 -0
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +1 -1
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/doctor/SKILL.md +3 -3
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +6 -0
- 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`;
|
|
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;
|
|
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
|
|
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
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
package/package.json
CHANGED
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
|
|
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
|
|
986
|
-
# before intent 274, with the same count
|
|
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)
|
|
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
|
|
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
|
-
#
|
|
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
|
package/scripts/end-intent
CHANGED
|
@@ -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,
|
|
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-
|
|
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 (
|
|
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__
|