@zalom/plastic 2.0.0-alpha.9 → 2.0.1
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/PLASTIC.md +13 -136
- package/README.md +357 -133
- package/agents/plastic-enforcer.md +20 -15
- package/agents/plastic-executor.md +15 -4
- package/agents/plastic-node-research.md +30 -0
- package/agents/plastic-node-verify.md +28 -0
- package/agents/plastic-node-work.md +33 -0
- package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
- package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
- package/assets/plastic-logo.svg +1 -0
- package/bin/crap +4 -0
- package/bin/lib/context_budget.rb +43 -6
- package/bin/lib/skill_census.rb +839 -0
- package/bin/plastic +6 -0
- package/bin/plastic-skill-census +114 -0
- package/bin/test +24 -4
- package/bin/verify-change +345 -0
- package/config_asks.yml +4 -4
- package/deprecations.yml +1 -1
- package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
- package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
- package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
- package/docs/help/human-report-contract.md +152 -0
- package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
- package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +11 -13
- package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
- package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
- package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
- package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
- package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +55 -2
- package/hooks/session-start +5 -1
- package/hooks/statusline +32 -27
- package/hooks/stop +5 -0
- package/package.json +5 -3
- package/scripts/append-ledger +2 -1
- package/scripts/dashboard.rb +267 -16
- package/scripts/day-summary +2 -1
- package/scripts/doctor.rb +680 -39
- package/scripts/end-intent +153 -30
- package/scripts/exec-worktree +5 -5
- package/scripts/file-session-intent +2 -1
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +24 -123
- package/scripts/hook-close +2 -1
- package/scripts/hook-message-display +7 -0
- package/scripts/hook-record +24 -16
- package/scripts/hook-savepoint +27 -3
- package/scripts/hook-session-start +359 -321
- package/scripts/hook-stop +58 -0
- package/scripts/index-projection +74 -0
- package/scripts/insight-append +17 -4
- package/scripts/install.rb +9 -7
- package/scripts/lib/action_graph_shim.rb +279 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +63 -25
- package/scripts/lib/arm.rb +48 -83
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/backup.rb +65 -0
- package/scripts/lib/cli/command.rb +92 -0
- package/scripts/lib/cli/commands/auto.rb +18 -0
- package/scripts/lib/cli/commands/auto_brief.rb +44 -0
- package/scripts/lib/cli/commands/auto_lock.rb +99 -0
- package/scripts/lib/cli/commands/auto_report.rb +50 -0
- package/scripts/lib/cli/commands/auto_take.rb +31 -0
- package/scripts/lib/cli/commands/backup.rb +43 -0
- package/scripts/lib/cli/commands/checkout.rb +25 -0
- package/scripts/lib/cli/commands/continue.rb +66 -0
- package/scripts/lib/cli/commands/doctor.rb +81 -0
- package/scripts/lib/cli/commands/feedback.rb +40 -0
- package/scripts/lib/cli/commands/help.rb +69 -0
- package/scripts/lib/cli/commands/hook.rb +32 -0
- package/scripts/lib/cli/commands/index.rb +23 -0
- package/scripts/lib/cli/commands/install.rb +21 -0
- package/scripts/lib/cli/commands/installer_verb.rb +37 -0
- package/scripts/lib/cli/commands/intent.rb +19 -0
- package/scripts/lib/cli/commands/intent_answer.rb +37 -0
- package/scripts/lib/cli/commands/intent_command.rb +53 -0
- package/scripts/lib/cli/commands/intent_end.rb +62 -0
- package/scripts/lib/cli/commands/intent_new.rb +62 -0
- package/scripts/lib/cli/commands/intent_note.rb +43 -0
- package/scripts/lib/cli/commands/intent_rule.rb +36 -0
- package/scripts/lib/cli/commands/intent_show.rb +32 -0
- package/scripts/lib/cli/commands/intent_spec.rb +44 -0
- package/scripts/lib/cli/commands/intent_step.rb +71 -0
- package/scripts/lib/cli/commands/intent_verify.rb +26 -0
- package/scripts/lib/cli/commands/migrate.rb +16 -0
- package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
- package/scripts/lib/cli/commands/next.rb +54 -0
- package/scripts/lib/cli/commands/project.rb +19 -0
- package/scripts/lib/cli/commands/project_links.rb +42 -0
- package/scripts/lib/cli/commands/project_list.rb +20 -0
- package/scripts/lib/cli/commands/project_new.rb +72 -0
- package/scripts/lib/cli/commands/query.rb +31 -0
- package/scripts/lib/cli/commands/render.rb +33 -0
- package/scripts/lib/cli/commands/roadmap.rb +19 -0
- package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
- package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
- package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
- package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
- package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
- package/scripts/lib/cli/commands/rollback.rb +20 -0
- package/scripts/lib/cli/commands/search.rb +60 -0
- package/scripts/lib/cli/commands/session.rb +18 -0
- package/scripts/lib/cli/commands/session_commit.rb +42 -0
- package/scripts/lib/cli/commands/session_handoff.rb +34 -0
- package/scripts/lib/cli/commands/session_summary.rb +35 -0
- package/scripts/lib/cli/commands/status.rb +68 -0
- package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
- package/scripts/lib/cli/commands/sync.rb +46 -0
- package/scripts/lib/cli/commands/uninstall.rb +20 -0
- package/scripts/lib/cli/commands/update.rb +20 -0
- package/scripts/lib/cli/commands/version.rb +53 -0
- package/scripts/lib/cli/frontier.rb +86 -0
- package/scripts/lib/cli/intent_progress.rb +36 -0
- package/scripts/lib/cli/legacy.rb +80 -0
- package/scripts/lib/cli/output.rb +137 -0
- package/scripts/lib/cli/scope.rb +134 -0
- package/scripts/lib/cli/table.rb +65 -0
- package/scripts/lib/cli.rb +94 -0
- package/scripts/lib/codex_adapter.rb +198 -0
- package/scripts/lib/compact_instructions.rb +13 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/data_boundary.rb +132 -0
- package/scripts/lib/day_summary.rb +23 -14
- package/scripts/lib/doctor_core.rb +114 -38
- package/scripts/lib/doctor_session_ledger.rb +5 -53
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/feedback_report.rb +1 -1
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +409 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +413 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/handoff.rb +40 -13
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +27 -3
- package/scripts/lib/hook_replay.rb +229 -0
- package/scripts/lib/index_entry.rb +62 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +531 -71
- package/scripts/lib/intent_screen.rb +4 -4
- package/scripts/lib/intent_screen_ansi.rb +73 -12
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +10 -11
- package/scripts/lib/message_display.rb +350 -54
- package/scripts/lib/meter_watch.rb +185 -0
- package/scripts/lib/node_file.rb +234 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_input.rb +913 -0
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +386 -0
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/node_return.rb +204 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/preflight.rb +4 -6
- package/scripts/lib/project_config.rb +46 -0
- package/scripts/lib/project_validator.rb +3 -2
- package/scripts/lib/qmd_sync.rb +8 -7
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/reference_archive.rb +45 -0
- package/scripts/lib/release_guard.rb +18 -0
- package/scripts/lib/report_screen.rb +1371 -47
- package/scripts/lib/rlm/corpus.rb +13 -0
- package/scripts/lib/rlm/probe.rb +29 -0
- package/scripts/lib/rlm/query.rb +22 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +158 -8
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +64 -14
- package/scripts/lib/runner_absorb.rb +703 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +525 -0
- package/scripts/lib/runner_policy.rb +191 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +141 -19
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/screen_paint.rb +365 -28
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/search_index.rb +55 -0
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +25 -18
- package/scripts/lib/session_ledger.rb +48 -4
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/sqlite.rb +22 -0
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/store_discovery.rb +7 -6
- package/scripts/lib/store_layout.rb +54 -0
- package/scripts/lib/store_provisioning.rb +2 -1
- package/scripts/lib/store_sync.rb +85 -0
- package/scripts/lib/stores_move.rb +93 -0
- package/scripts/lib/verify_intent.rb +36 -8
- package/scripts/lib/version_number.rb +48 -0
- package/scripts/lib/work_graph.rb +59 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/lib/worktree.rb +27 -32
- package/scripts/lib/worktree_sweep.rb +6 -5
- package/scripts/link-suggest +2 -1
- package/scripts/meter-watch +57 -0
- package/scripts/migrate-to-global +1 -1
- package/scripts/new-intent +4 -13
- package/scripts/node-input +92 -0
- package/scripts/node-run +225 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +42 -33
- package/scripts/promote-session-item +3 -2
- package/scripts/read-config +53 -9
- package/scripts/ready-set +126 -0
- package/scripts/release-check +123 -0
- package/scripts/report-screen +177 -16
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +581 -0
- package/scripts/savepoint-note +11 -9
- package/scripts/session-commit +2 -1
- package/scripts/session-usage +56 -0
- package/scripts/skill-lint +115 -6
- package/scripts/spawn-preamble +2 -2
- package/scripts/update.rb +25 -4
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +3 -2
- package/scripts/write-handoff +2 -1
- package/templates/agents.md +7 -7
- package/templates/config.yml +16 -9
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/index.md +1 -1
- package/templates/intent-screen.md +1 -1
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +13 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +8 -3
- package/templates/project.yml +1 -1
- package/templates/render.css +10 -0
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +1 -1
- package/templates/roadmap.md +13 -0
- package/bin/plastic.js +0 -70
- package/scripts/lib/bridge.rb +0 -116
- package/skills/agent-advisor/SKILL.md +0 -92
- package/skills/auto/SKILL.md +0 -296
- package/skills/auto/evals/evals.json +0 -255
- package/skills/auto/references/end-tail.md +0 -66
- package/skills/auto/references/human-report-contract.md +0 -78
- package/skills/conventions/SKILL.md +0 -29
- package/skills/dashboard/SKILL.md +0 -169
- package/skills/dashboard/evals/evals.json +0 -38
- package/skills/dashboard/references/classification.md +0 -22
- package/skills/dashboard/templates/dashboard-global.md +0 -20
- package/skills/dashboard/templates/dashboard-project.md +0 -19
- package/skills/direct/SKILL.md +0 -66
- package/skills/direct/references/request-signals.md +0 -59
- package/skills/doctor/SKILL.md +0 -299
- package/skills/doctor/report.md +0 -102
- package/skills/feedback/SKILL.md +0 -98
- package/skills/feedback/references/transport-and-privacy.md +0 -65
- package/skills/feedback/report.md +0 -36
- package/skills/install/SKILL.md +0 -217
- package/skills/intent-continuing/SKILL.md +0 -154
- package/skills/intent-continuing/references/board-fill.md +0 -43
- package/skills/intent-continuing/references/boarding-matrix.md +0 -34
- package/skills/intent-continuing/references/context-management.md +0 -28
- package/skills/intent-continuing/references/liveness-ranking.md +0 -57
- package/skills/intent-creating/SKILL.md +0 -164
- package/skills/intent-creating/evals/evals.json +0 -72
- package/skills/intent-creating/references/lifecycle.md +0 -81
- package/skills/intent-creating/references/wikilinks.md +0 -8
- package/skills/intent-ending/SKILL.md +0 -176
- package/skills/intent-ending/evals/evals.json +0 -74
- package/skills/intent-executing/SKILL.md +0 -170
- package/skills/intent-executing/evals/evals.json +0 -66
- package/skills/intent-executing/implementer-prompt.md +0 -42
- package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
- package/skills/intent-speccing/SKILL.md +0 -130
- package/skills/intent-speccing/evals/evals.json +0 -126
- package/skills/intent-speccing/references/design-principles.md +0 -44
- package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
- package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
- package/skills/project-creating/SKILL.md +0 -162
- package/skills/project-creating/references/hubs-projects.md +0 -55
- package/skills/project-creating/references/project-scaffolding.md +0 -97
- package/skills/releasing/SKILL.md +0 -337
- package/skills/releasing/references/deprecations.md +0 -60
- package/skills/releasing/references/promotion-and-tagging.md +0 -66
- package/skills/releasing/references/release-lines.md +0 -105
- package/skills/roadmap/SKILL.md +0 -64
- package/skills/roadmap/references/file-format.md +0 -124
- package/skills/roadmap/references/operations.md +0 -112
- package/skills/rollback/SKILL.md +0 -91
- package/skills/tutorial/SKILL.md +0 -65
- package/skills/tutorial/evals/evals.json +0 -186
- package/skills/uninstall/SKILL.md +0 -75
- package/skills/update/SKILL.md +0 -126
- /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
- /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
- /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
- /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "ready_set"
|
|
5
|
+
require_relative "node_worktree"
|
|
6
|
+
require_relative "agent_models"
|
|
7
|
+
|
|
8
|
+
# RunnerPolicy (intent 340, G7, n5): the kind table. Four facts per kind -
|
|
9
|
+
# which model it runs on, whether it gets a worktree, its SOFT retry cap, and
|
|
10
|
+
# its diff rule - looked up by one fallback rule: an unknown or nil kind gets
|
|
11
|
+
# `work`'s row, the widest of the four, never no policy at all (matrix 5.17,
|
|
12
|
+
# mirroring ReadySet's own `caps.fetch(kind) { caps["work"] }` fallback).
|
|
13
|
+
#
|
|
14
|
+
# This is deliberately a SECOND, SOFTER cap than ReadySet::DEFAULT_CAPS
|
|
15
|
+
# (327 D22): that cap is the transition layer's hard backstop, counted as
|
|
16
|
+
# `running` lines since the last terminal line, and `node-transition` is
|
|
17
|
+
# never changed to accept an override of it. RunnerPolicy's cap is counted
|
|
18
|
+
# from `failed_verification` lines alone (ReadySet.failed_verification_count,
|
|
19
|
+
# matrix 5.12) - a node reclaimed after a crash, with no failed_verification
|
|
20
|
+
# line at all, never trips this cap, exactly as 327 D12 specifies: "work runs
|
|
21
|
+
# ... retry cap 2 ... verify ... retry cap 1 ... research ... retry cap 1".
|
|
22
|
+
#
|
|
23
|
+
# Pure and dependency-injected: `model_for` takes `config:` (an already
|
|
24
|
+
# loaded config hash, AgentModels' own `agents.models` shape), never reads
|
|
25
|
+
# ENV or a real config.yml itself - the caller (RunnerDispatch) owns loading
|
|
26
|
+
# real config, this module only resolves values out of what it is handed.
|
|
27
|
+
module RunnerPolicy
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# D31 (327): "One advisor: the smartest model available, or the model set
|
|
31
|
+
# in user config." Never the cheap tier - that is the one thing D31 rules
|
|
32
|
+
# out for a verify node (matrix 5.14).
|
|
33
|
+
#
|
|
34
|
+
# D19 (327): "knows nothing about either harness." The executor default
|
|
35
|
+
# resolves through AgentModels::TIER_DEFAULTS, the one place
|
|
36
|
+
# `plastic-executor`'s shipped tier is already declared (post-execution
|
|
37
|
+
# review minor 4), rather than a second, independently-drifting literal
|
|
38
|
+
# here. `plastic-primary-advisor` carries no lifecycle-stage entry in
|
|
39
|
+
# TIER_DEFAULTS at all (it is a consultation agent, never auto-dispatched
|
|
40
|
+
# - see agent_models.rb's own docstring), so its default stays the one
|
|
41
|
+
# literal this table cannot source from anywhere else.
|
|
42
|
+
DEFAULT_EXECUTOR_MODEL = AgentModels::TIER_DEFAULTS.fetch("plastic-executor")
|
|
43
|
+
DEFAULT_ADVISOR_MODEL = "opus"
|
|
44
|
+
|
|
45
|
+
EXECUTOR_CONFIG_KEY = "plastic-executor"
|
|
46
|
+
ADVISOR_CONFIG_KEY = "plastic-primary-advisor"
|
|
47
|
+
|
|
48
|
+
# {model_role:, worktree:, retry_cap:, diff_rule:, lease_minutes:} per kind
|
|
49
|
+
# (327 D12). `decision` carries no retry cap or lease: it is never
|
|
50
|
+
# dispatched (RunnerDispatch stops the loop on one instead), so nothing
|
|
51
|
+
# here ever needs to answer "how long is a decision node's lease".
|
|
52
|
+
KIND_TABLE = {
|
|
53
|
+
"work" => { model_role: :executor, worktree: true, retry_cap: 2, diff_rule: :inside_files,
|
|
54
|
+
lease_minutes: 180 },
|
|
55
|
+
"verify" => { model_role: :advisor, worktree: false, retry_cap: 1, diff_rule: :none,
|
|
56
|
+
lease_minutes: 30 },
|
|
57
|
+
"research" => { model_role: :executor, worktree: false, retry_cap: 1, diff_rule: :none,
|
|
58
|
+
lease_minutes: 60 },
|
|
59
|
+
"decision" => { model_role: nil, worktree: false, retry_cap: nil, diff_rule: :none,
|
|
60
|
+
lease_minutes: nil },
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# matrix 5.17: an unknown, nil, or blank kind gets `work`'s whole row.
|
|
64
|
+
def policy_for(kind)
|
|
65
|
+
KIND_TABLE.fetch(kind.to_s, KIND_TABLE["work"])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# matrix 5.13/5.14/5.15: work and research resolve the executor model,
|
|
69
|
+
# verify resolves the advisor model, and a config with no override falls
|
|
70
|
+
# back to the shipped default rather than an empty string (`running`
|
|
71
|
+
# requires a non-blank `model=`).
|
|
72
|
+
def model_for(kind, config: {}, harness: "claude-code")
|
|
73
|
+
alias_or_id = policy_for(kind)[:model_role] == :advisor ? advisor_model(config: config, harness: harness) :
|
|
74
|
+
executor_model(config: config, harness: harness)
|
|
75
|
+
codex_harness?(harness) ? (AgentModels.codex_model_for(alias_or_id) || alias_or_id) : alias_or_id
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def executor_model(config: {}, harness: "claude-code")
|
|
79
|
+
resolve_model(config, EXECUTOR_CONFIG_KEY, DEFAULT_EXECUTOR_MODEL, harness)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def advisor_model(config: {}, harness: "claude-code")
|
|
83
|
+
resolve_model(config, ADVISOR_CONFIG_KEY, DEFAULT_ADVISOR_MODEL, harness)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def effort_for(kind, config: {}, harness: "claude-code")
|
|
87
|
+
key = policy_for(kind)[:model_role] == :advisor ? ADVISOR_CONFIG_KEY : EXECUTOR_CONFIG_KEY
|
|
88
|
+
value = AgentModels.efforts_section(config, harness_name(harness))[key]
|
|
89
|
+
present?(value) ? value : AgentModels::DEFAULT_EFFORT
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def resolve_model(config, key, shipped_default, harness)
|
|
93
|
+
value = AgentModels.models_section(config, harness: harness_name(harness))[key]
|
|
94
|
+
present?(value) ? value : shipped_default
|
|
95
|
+
end
|
|
96
|
+
private_class_method :resolve_model
|
|
97
|
+
|
|
98
|
+
def harness_name(harness)
|
|
99
|
+
codex_harness?(harness) ? "codex" : "claude"
|
|
100
|
+
end
|
|
101
|
+
private_class_method :harness_name
|
|
102
|
+
|
|
103
|
+
def codex_harness?(harness)
|
|
104
|
+
harness.to_s == "codex"
|
|
105
|
+
end
|
|
106
|
+
private_class_method :codex_harness?
|
|
107
|
+
|
|
108
|
+
def present?(value)
|
|
109
|
+
!(value.nil? || value.to_s.strip.empty?)
|
|
110
|
+
end
|
|
111
|
+
private_class_method :present?
|
|
112
|
+
|
|
113
|
+
# matrix 5.16/5.17: only `work` gets a worktree; an unknown kind falls back
|
|
114
|
+
# to `work`'s own row like every other field here. `KIND_TABLE["work"]` is
|
|
115
|
+
# kept equal to `NodeWorktree::WORKTREE_KINDS` by the assertion below, so
|
|
116
|
+
# the two lists cannot silently drift apart.
|
|
117
|
+
raise "RunnerPolicy/NodeWorktree kind lists have drifted" unless KIND_TABLE.select { |_, v| v[:worktree] }.keys ==
|
|
118
|
+
NodeWorktree::WORKTREE_KINDS
|
|
119
|
+
|
|
120
|
+
def worktree?(kind)
|
|
121
|
+
!!policy_for(kind)[:worktree]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def diff_rule(kind)
|
|
125
|
+
policy_for(kind)[:diff_rule]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# matrix 5.12: the SOFT cap, or nil for a kind that carries none (decision).
|
|
129
|
+
def retry_cap(kind)
|
|
130
|
+
policy_for(kind)[:retry_cap]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The soft-cap count itself: every non-torn `failed_verification` line for
|
|
134
|
+
# `node`, delegated to ReadySet (matrix 5.12's "counts from
|
|
135
|
+
# failed_verification lines", never from attempts).
|
|
136
|
+
def retry_count(entries, node)
|
|
137
|
+
ReadySet.failed_verification_count(entries, node.to_s)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# true only when a real cap exists AND the count has reached it (a nil cap,
|
|
141
|
+
# e.g. `decision`, never trips).
|
|
142
|
+
def at_retry_cap?(entries, node, kind)
|
|
143
|
+
cap = retry_cap(kind)
|
|
144
|
+
return false if cap.nil?
|
|
145
|
+
|
|
146
|
+
retry_count(entries, node) >= cap
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# matrix 5.21: `expires=` on `running` comes from the kind's own lease
|
|
150
|
+
# length, never one shared constant - a long `work` build must not be
|
|
151
|
+
# reclaimed under a still-working executor the way a quick `verify` pass
|
|
152
|
+
# would be.
|
|
153
|
+
def lease_minutes(kind)
|
|
154
|
+
policy_for(kind)[:lease_minutes] || policy_for("work")[:lease_minutes]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def lease_expires(kind, now: Time.now)
|
|
158
|
+
(now + (lease_minutes(kind) * 60)).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# --- call budget (intent 355, n2) -------------------------------------------
|
|
162
|
+
#
|
|
163
|
+
# D2: a cap on tool calls per attempt, enforced by a PreToolUse hook that
|
|
164
|
+
# counts tool calls in the session transcript. Shipped per kind (matrix
|
|
165
|
+
# 2.1); `decision` carries one too even though it is never dispatched
|
|
166
|
+
# (327 D12 leaves it out of every lease table for the same reason), so
|
|
167
|
+
# `call_cap` never has to special-case an unknown kind here any more than
|
|
168
|
+
# `retry_cap` does.
|
|
169
|
+
CALL_CAP_TABLE = { "work" => 60, "verify" => 40, "research" => 40, "decision" => 10 }.freeze
|
|
170
|
+
|
|
171
|
+
# matrix 5.17's own fallback rule, one call: an unknown or nil kind reads
|
|
172
|
+
# `work`'s cap, never a fourth, undeclared number.
|
|
173
|
+
def call_cap(kind, config: {})
|
|
174
|
+
override = call_caps_section(config)[kind.to_s]
|
|
175
|
+
present?(override) ? override.to_i : CALL_CAP_TABLE.fetch(kind.to_s, CALL_CAP_TABLE["work"])
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# matrix 2.2: `runner.call_caps.<kind>` in the project config overrides the
|
|
179
|
+
# shipped cap, the same nested-Hash shape AgentModels.models_section reads
|
|
180
|
+
# `agents.models` from - one more caller of the pattern, not a new one.
|
|
181
|
+
def call_caps_section(config)
|
|
182
|
+
return {} unless config.is_a?(Hash)
|
|
183
|
+
|
|
184
|
+
runner = config["runner"]
|
|
185
|
+
return {} unless runner.is_a?(Hash)
|
|
186
|
+
|
|
187
|
+
section = runner["call_caps"]
|
|
188
|
+
section.is_a?(Hash) ? section : {}
|
|
189
|
+
end
|
|
190
|
+
private_class_method :call_caps_section
|
|
191
|
+
end
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "node_file"
|
|
6
|
+
require_relative "node_ids"
|
|
7
|
+
require_relative "graph_edges"
|
|
8
|
+
require_relative "graph_file"
|
|
9
|
+
require_relative "atomic_write"
|
|
10
|
+
require_relative "ready_set"
|
|
11
|
+
require_relative "node_ledger"
|
|
12
|
+
require_relative "savepoint"
|
|
13
|
+
require_relative "work_graph_validator"
|
|
14
|
+
|
|
15
|
+
# RunnerProposals (intent 340, G7, n6): accepts or refuses what an executor
|
|
16
|
+
# proposed in its return (327 D15, D28, D30). The runner mints every node id;
|
|
17
|
+
# an executor never sees or picks one. A proposed node is scaffolded from its
|
|
18
|
+
# kind's template, its id substituted everywhere the template hard-codes its
|
|
19
|
+
# own placeholder (`n1`, `v1`, `d1`, `r1` - the frontmatter key, the H1, and
|
|
20
|
+
# the failure-mode matrix heading), and appended to graph.md's ## Graph
|
|
21
|
+
# section as a fresh, undispatched line - no ledger line at all, which reads
|
|
22
|
+
# as "planned" by construction. A proposed edge is accepted only when both
|
|
23
|
+
# endpoints exist (among the already-declared nodes or a node this very call
|
|
24
|
+
# is minting), its head is not `running`, and the resulting graph stays
|
|
25
|
+
# acyclic (D30); a refusal writes one ledger comment naming which of the
|
|
26
|
+
# three failed.
|
|
27
|
+
#
|
|
28
|
+
# One call, one all-or-nothing write (D15's "nothing is partially written"):
|
|
29
|
+
# every proposed node and edge is validated BEFORE anything touches disk. A
|
|
30
|
+
# single rejected edge refuses the WHOLE call, leaving graph.md and nodes/
|
|
31
|
+
# byte-identical to how #accept found them, and burning no minted id (an id
|
|
32
|
+
# is only ever consumed once a node FILE actually exists; NodeIds.taken never
|
|
33
|
+
# sees one this call abandoned).
|
|
34
|
+
#
|
|
35
|
+
# Pure and dependency-injected: the full validator re-run (327 D17's "a mid-
|
|
36
|
+
# run scaffold is checked, not merely accepted"), the templates directory,
|
|
37
|
+
# the clock and the rename call are all injectable keyword arguments with
|
|
38
|
+
# real defaults, so a test never touches a real ~/.plastic install.
|
|
39
|
+
module RunnerProposals
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
KIND_TEMPLATES = {
|
|
43
|
+
"work" => "node-work.md",
|
|
44
|
+
"verify" => "node-verify.md",
|
|
45
|
+
"decision" => "node-decision.md",
|
|
46
|
+
"research" => "node-research.md",
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
REVIEW_FIX_CAP = 2
|
|
50
|
+
|
|
51
|
+
# accept(context, proposer:, proposed_nodes:, proposed_edges:, now:,
|
|
52
|
+
# validator:, templates_dir:, renamer:) -> {ok:, minted:, validator:,
|
|
53
|
+
# errors:}. `proposer` names the node whose return carried these proposals
|
|
54
|
+
# - it is used only to attribute the refusal comment; the runner itself
|
|
55
|
+
# decides everything about the proposal's fate.
|
|
56
|
+
def accept(context, proposer:, proposed_nodes: [], proposed_edges: [], now: Time.now,
|
|
57
|
+
validator: WorkGraphValidator.method(:validate), templates_dir: nil,
|
|
58
|
+
renamer: File.method(:rename))
|
|
59
|
+
intent_dir = context.intent_dir
|
|
60
|
+
graph_path = File.join(intent_dir, "graph.md")
|
|
61
|
+
templates_root = templates_dir || File.join(context.plastic_home.to_s, "templates")
|
|
62
|
+
|
|
63
|
+
# v2 NEW-5/row 11.8: `ReadySet.load_graph` documents itself as never
|
|
64
|
+
# raising, but that guarantee is only as good as the parser underneath
|
|
65
|
+
# it - a `graph.md` carrying invalid UTF-8 bytes raises `ArgumentError`
|
|
66
|
+
# out of `GraphFile`'s own fence-line scan before `load_graph` ever gets
|
|
67
|
+
# a chance to catch it. M4 made this call reachable straight from
|
|
68
|
+
# `RunnerAbsorb#absorb`, so a `--return` carrying a proposal against a
|
|
69
|
+
# malformed graph used to die with a raw stack trace instead of a plain
|
|
70
|
+
# refusal.
|
|
71
|
+
loaded = safe_load_graph(intent_dir)
|
|
72
|
+
return refuse(intent_dir, proposer, "graph.md is unreadable: #{loaded[:errors].join('; ')}", now) if loaded[:unreadable]
|
|
73
|
+
|
|
74
|
+
declared = loaded[:edges].keys
|
|
75
|
+
status_map = NodeLedger.status_from_content(read_savepoint(intent_dir))
|
|
76
|
+
|
|
77
|
+
mint_pool = NodeIds.taken(intent_dir).dup
|
|
78
|
+
node_specs = []
|
|
79
|
+
|
|
80
|
+
Array(proposed_nodes).each do |raw|
|
|
81
|
+
spec = stringify(raw)
|
|
82
|
+
kind = spec["kind"].to_s
|
|
83
|
+
template_name = KIND_TEMPLATES[kind]
|
|
84
|
+
unless template_name
|
|
85
|
+
return refuse(intent_dir, proposer, "proposed node kind #{kind.inspect} has no template", now)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
template_path = File.join(templates_root, template_name)
|
|
89
|
+
unless File.exist?(template_path)
|
|
90
|
+
return refuse(intent_dir, proposer, "missing template for kind #{kind.inspect} at #{template_path}", now)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
id = NodeFile.mint_id(kind, mint_pool)
|
|
94
|
+
mint_pool << id
|
|
95
|
+
node_specs << {
|
|
96
|
+
id: id, kind: kind, template_path: template_path,
|
|
97
|
+
needs: Array(spec["needs"]).map(&:to_s),
|
|
98
|
+
files: spec["files"].nil? ? nil : Array(spec["files"]),
|
|
99
|
+
budget: spec["budget"],
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
trial_edges = loaded[:edges].dup
|
|
104
|
+
node_specs.each { |s| trial_edges[s[:id]] = s[:needs] }
|
|
105
|
+
known_ids = declared + node_specs.map { |s| s[:id] }
|
|
106
|
+
|
|
107
|
+
edge_specs = []
|
|
108
|
+
Array(proposed_edges).each do |raw|
|
|
109
|
+
e = stringify(raw)
|
|
110
|
+
from = e["from"].to_s
|
|
111
|
+
to = e["to"].to_s
|
|
112
|
+
|
|
113
|
+
unless known_ids.include?(from) && known_ids.include?(to)
|
|
114
|
+
return refuse(intent_dir, proposer,
|
|
115
|
+
"edge #{from}->#{to} refused (endpoint_unknown): both ends must be a declared or proposed node", now)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if status_map.fetch(from, "planned") == "running"
|
|
119
|
+
return refuse(intent_dir, proposer,
|
|
120
|
+
"edge #{from}->#{to} refused (head_running): #{from} is currently running", now)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
trial = trial_edges.dup
|
|
124
|
+
trial[from] = (trial[from] || []) + [to]
|
|
125
|
+
if GraphEdges.cycle(trial)
|
|
126
|
+
return refuse(intent_dir, proposer,
|
|
127
|
+
"edge #{from}->#{to} refused (would_cycle): would make the graph cyclic", now)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
trial_edges = trial
|
|
131
|
+
edge_specs << { from: from, to: to }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
trial_nodes = loaded[:nodes].merge(node_specs.to_h { |s| [s[:id], { kind: s[:kind] }] })
|
|
135
|
+
existing_fixes = review_fix_count(loaded[:edges], loaded[:nodes])
|
|
136
|
+
trial_fixes = review_fix_count(trial_edges, trial_nodes)
|
|
137
|
+
if trial_fixes > [existing_fixes, REVIEW_FIX_CAP].max
|
|
138
|
+
return refuse(intent_dir, proposer,
|
|
139
|
+
"proposal refused (review_fix_cap): would make #{trial_fixes} review fixes, the cap is #{REVIEW_FIX_CAP}", now)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
node_specs.each { |s| scaffold_node_file(intent_dir, s) }
|
|
143
|
+
if node_specs.any? || edge_specs.any?
|
|
144
|
+
append_to_graph(graph_path, node_specs: node_specs, edge_specs: edge_specs, renamer: renamer)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# v2 NEW-5/row 11.8: the accept itself already landed on disk by this
|
|
148
|
+
# point (327 D17's "a mid-run scaffold is checked" runs strictly AFTER
|
|
149
|
+
# the write) - a raising validator must report a broken verdict, never
|
|
150
|
+
# turn an already-successful accept into an uncaught exception.
|
|
151
|
+
{ ok: true, minted: node_specs.map { |s| s[:id] }, validator: safe_validate(validator, intent_dir), errors: [] }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# --- review fixes (355 D4) -------------------------------------------------
|
|
155
|
+
|
|
156
|
+
# review_fix_count(edges, nodes) -> how many work nodes GraphEdges.review_fixes
|
|
157
|
+
# finds, a node's kind read from its declaration and, when that is missing,
|
|
158
|
+
# from its id's NodeFile::KIND_PREFIX (343 D6).
|
|
159
|
+
def review_fix_count(edges, nodes)
|
|
160
|
+
ids = (edges.keys + edges.values.flatten).uniq
|
|
161
|
+
kinds = ids.to_h { |id| [id, (nodes[id] || {})[:kind] || NodeFile::KIND_PREFIX.key(id.to_s[/\A[a-z]+/])] }
|
|
162
|
+
GraphEdges.review_fixes(edges, kinds).length
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# --- node scaffolding --------------------------------------------------
|
|
166
|
+
|
|
167
|
+
# A template's own placeholder id ("n1", "v1", ...) is replaced only as a
|
|
168
|
+
# whole token, so minting past 9 (a proposed "n10") never mangles a
|
|
169
|
+
# coincidental substring match.
|
|
170
|
+
def substitute_id(text, old_id, new_id)
|
|
171
|
+
text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def scaffold_node_file(intent_dir, spec)
|
|
175
|
+
parsed_template = NodeFile.parse(spec[:template_path])
|
|
176
|
+
old_id = parsed_template[:node]
|
|
177
|
+
raw = File.read(spec[:template_path])
|
|
178
|
+
parts = raw.split("---", 3)
|
|
179
|
+
body = substitute_id(parts[2].to_s, old_id, spec[:id])
|
|
180
|
+
|
|
181
|
+
files = spec[:files].nil? ? parsed_template[:files] : spec[:files]
|
|
182
|
+
budget = spec[:budget].nil? ? parsed_template[:budget] : spec[:budget]
|
|
183
|
+
|
|
184
|
+
frontmatter = "---\nnode: #{spec[:id]}\nkind: #{spec[:kind]}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
|
|
185
|
+
path = File.join(intent_dir, "nodes", "#{spec[:id]}.md")
|
|
186
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
187
|
+
File.write(path, "#{frontmatter}#{body}")
|
|
188
|
+
end
|
|
189
|
+
private_class_method :scaffold_node_file
|
|
190
|
+
|
|
191
|
+
# --- graph.md editing ----------------------------------------------------
|
|
192
|
+
|
|
193
|
+
# Appends one "- <id> needs <targets>" line per minted node, then applies
|
|
194
|
+
# each accepted edge onto the line (existing or just-appended) whose id
|
|
195
|
+
# matches its `from`, through GraphFile's own public section helpers
|
|
196
|
+
# (section_bounds, section_body, replace_or_append_section - none of them
|
|
197
|
+
# private_class_method'd) so this stays a plain text edit, never a second
|
|
198
|
+
# bespoke parser.
|
|
199
|
+
def append_to_graph(graph_path, node_specs:, edge_specs:, renamer:)
|
|
200
|
+
content = File.read(graph_path)
|
|
201
|
+
bounds = GraphFile.section_bounds(content, "## Graph")
|
|
202
|
+
return unless bounds
|
|
203
|
+
|
|
204
|
+
body = GraphFile.section_body(content, "## Graph").to_s
|
|
205
|
+
lines = body.split("\n")
|
|
206
|
+
|
|
207
|
+
node_specs.each do |s|
|
|
208
|
+
rendered = s[:needs].empty? ? "nothing" : s[:needs].join(" ")
|
|
209
|
+
lines << "- #{s[:id]} needs #{rendered}"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
edge_specs.each do |e|
|
|
213
|
+
idx = lines.index { |l| l.strip.match?(/\A-\s*#{Regexp.escape(e[:from])}\s+needs\b/) }
|
|
214
|
+
next unless idx
|
|
215
|
+
|
|
216
|
+
stripped = lines[idx].strip
|
|
217
|
+
m = stripped.match(/\A-\s*(\S+)\s+needs\s+(.*)\z/)
|
|
218
|
+
targets = m[2].to_s.strip.split(/\s+/)
|
|
219
|
+
targets = [] if targets == ["nothing"]
|
|
220
|
+
targets << e[:to] unless targets.include?(e[:to])
|
|
221
|
+
lines[idx] = "- #{e[:from]} needs #{targets.empty? ? 'nothing' : targets.join(' ')}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
new_body = lines.join("\n")
|
|
225
|
+
new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
|
|
226
|
+
AtomicWrite.write(graph_path, new_content, renamer: renamer)
|
|
227
|
+
end
|
|
228
|
+
private_class_method :append_to_graph
|
|
229
|
+
|
|
230
|
+
# --- refusal -------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
# One ledger comment (a plain milestone-style line through Savepoint's own
|
|
233
|
+
# public append primitive, never a state transition - a refused proposal
|
|
234
|
+
# changes nothing about the proposer's own status) naming which of the
|
|
235
|
+
# three checks failed. Dedup'd by (stage, text) like every other such line,
|
|
236
|
+
# so an identical refusal repeated verbatim across retries writes once.
|
|
237
|
+
def refuse(intent_dir, proposer, reason, now)
|
|
238
|
+
Savepoint.append_savepoint_line(intent_dir, "Proposal", "#{proposer}: #{reason}", now)
|
|
239
|
+
{ ok: false, minted: [], validator: nil, errors: [reason] }
|
|
240
|
+
end
|
|
241
|
+
private_class_method :refuse
|
|
242
|
+
|
|
243
|
+
# --- guarded re-entries into graph.md (v2 NEW-5/row 11.8) -------------------
|
|
244
|
+
|
|
245
|
+
# `unreadable: true` marks the ONE case a raise actually happened -
|
|
246
|
+
# distinct from `ReadySet.load_graph`'s own ordinary `ok: false` (a
|
|
247
|
+
# cyclic graph, a missing node file, ...), which callers here already
|
|
248
|
+
# tolerate and must keep tolerating unchanged.
|
|
249
|
+
def safe_load_graph(intent_dir)
|
|
250
|
+
ReadySet.load_graph(intent_dir)
|
|
251
|
+
rescue StandardError => e
|
|
252
|
+
{ ok: false, edges: {}, nodes: {}, errors: ["graph.md could not be read: #{e.message}"], unreadable: true }
|
|
253
|
+
end
|
|
254
|
+
private_class_method :safe_load_graph
|
|
255
|
+
|
|
256
|
+
def safe_validate(validator, intent_dir)
|
|
257
|
+
validator.call(intent_dir)
|
|
258
|
+
rescue StandardError => e
|
|
259
|
+
{ ok: false, missing: [], errors: ["graph.md could not be validated: #{e.message}"] }
|
|
260
|
+
end
|
|
261
|
+
private_class_method :safe_validate
|
|
262
|
+
|
|
263
|
+
# --- internals -------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
def stringify(hash)
|
|
266
|
+
(hash || {}).each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
267
|
+
end
|
|
268
|
+
private_class_method :stringify
|
|
269
|
+
|
|
270
|
+
def read_savepoint(intent_dir)
|
|
271
|
+
path = File.join(intent_dir.to_s, "savepoint.md")
|
|
272
|
+
File.exist?(path) ? File.read(path) : ""
|
|
273
|
+
end
|
|
274
|
+
private_class_method :read_savepoint
|
|
275
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "node_ledger"
|
|
6
|
+
require_relative "ready_set"
|
|
7
|
+
require_relative "node_file"
|
|
8
|
+
require_relative "node_ids"
|
|
9
|
+
require_relative "graph_file"
|
|
10
|
+
require_relative "atomic_write"
|
|
11
|
+
require_relative "runner_core"
|
|
12
|
+
require_relative "worktree"
|
|
13
|
+
require_relative "node_worktree"
|
|
14
|
+
|
|
15
|
+
# RunnerRewind (intent 340, G7, n6): resets the intent branch to a node's own
|
|
16
|
+
# recorded commit, marks every downstream node superseded (its evidence no
|
|
17
|
+
# longer stands once the code it was built on is gone), and respins the
|
|
18
|
+
# rewound node itself the same way RunnerAnswer's hard-cap path does - never
|
|
19
|
+
# a plain `planned` line, because `failed_verification_count` is counted per
|
|
20
|
+
# subject id over the WHOLE ledger and never resets; only a brand new id
|
|
21
|
+
# starts clean.
|
|
22
|
+
#
|
|
23
|
+
# Internal, confirm-gated (327 D15's rewind clause): `confirm:` must be
|
|
24
|
+
# explicitly true, and the whole intent must be quiescent (no `running` node
|
|
25
|
+
# anywhere) before a branch reset is safe to make - resetting the branch
|
|
26
|
+
# under a live executor's own worktree would move the base it is building on
|
|
27
|
+
# out from under it.
|
|
28
|
+
#
|
|
29
|
+
# Pure and dependency-injected: every git call goes through an injected
|
|
30
|
+
# `runner:` (default Worktree::ShellRunner), always `-C <path>`, never cwd;
|
|
31
|
+
# the clock and the rename call are injectable the same way every other
|
|
32
|
+
# runner_* module here already is.
|
|
33
|
+
module RunnerRewind
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def rewind(context, node:, confirm:, now: Time.now, ledger: NodeLedger,
|
|
37
|
+
runner: Worktree::ShellRunner.new, renamer: File.method(:rename),
|
|
38
|
+
worktree: NodeWorktree)
|
|
39
|
+
node = node.to_s
|
|
40
|
+
return refusal("confirm_required") unless confirm
|
|
41
|
+
|
|
42
|
+
intent_dir = context.intent_dir
|
|
43
|
+
# M9: a second, unguarded re-parse of graph.md - RunnerCore.context's
|
|
44
|
+
# own first read is already safe, but this fresh read is not.
|
|
45
|
+
loaded = RunnerCore.safe_load_graph(intent_dir)
|
|
46
|
+
return refusal("invalid_graph", detail: Array(loaded[:errors]).join("; ")) unless loaded[:ok]
|
|
47
|
+
|
|
48
|
+
edges = loaded[:edges]
|
|
49
|
+
nodes_decl = loaded[:nodes]
|
|
50
|
+
before_content = read_savepoint(intent_dir)
|
|
51
|
+
entries = NodeLedger.entries_from_content(before_content)
|
|
52
|
+
status_map = NodeLedger.status_from_content(before_content)
|
|
53
|
+
|
|
54
|
+
return refusal("a_node_is_running") if status_map.value?("running")
|
|
55
|
+
|
|
56
|
+
commit = last_commit(entries, node)
|
|
57
|
+
return refusal("no_recorded_commit") if commit.nil? || commit.to_s.strip.empty?
|
|
58
|
+
|
|
59
|
+
return refusal("no_intent_worktree") if blank?(context.worktree)
|
|
60
|
+
|
|
61
|
+
reset = runner.run("-C", context.worktree, "reset", "--hard", commit)
|
|
62
|
+
return refusal("git_reset_failed", detail: reset.stderr.to_s.strip) unless reset.success?
|
|
63
|
+
|
|
64
|
+
downstream = downstream_of(node, edges)
|
|
65
|
+
downstream.each do |d|
|
|
66
|
+
ledger.append_transition(savepoint_path(intent_dir), subject: d, state: "superseded",
|
|
67
|
+
fields: { by: node }, now: now)
|
|
68
|
+
# M5/D7: a rewind-superseded node's evidence no longer stands once the
|
|
69
|
+
# code it was built on is gone - its worktree releases here too.
|
|
70
|
+
worktree.release(context, node: d, state: "superseded", runner: runner)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
succ_id = respin(intent_dir, node, nodes_decl, edges, ledger: ledger, now: now, renamer: renamer,
|
|
74
|
+
context: context, worktree: worktree, runner: runner)
|
|
75
|
+
|
|
76
|
+
RunnerCore.render_status(context)
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
ok: true, reset_to: commit, superseded: downstream, respun_to: succ_id, errors: [],
|
|
80
|
+
newly_ready: newly_ready(intent_dir, before_content: before_content, before_nodes: nodes_decl,
|
|
81
|
+
before_edges: edges),
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- downstream: everything that, directly or transitively, needs the
|
|
86
|
+
# rewound node - the mirror of ReadySet.critical_paths' own successors map.
|
|
87
|
+
|
|
88
|
+
def downstream_of(node, edges)
|
|
89
|
+
successors = Hash.new { |h, k| h[k] = [] }
|
|
90
|
+
edges.each { |id, targets| (targets || []).each { |t| successors[t] << id } }
|
|
91
|
+
|
|
92
|
+
seen = []
|
|
93
|
+
stack = successors[node].dup
|
|
94
|
+
until stack.empty?
|
|
95
|
+
n = stack.pop
|
|
96
|
+
next if seen.include?(n)
|
|
97
|
+
|
|
98
|
+
seen << n
|
|
99
|
+
stack.concat(successors[n] || [])
|
|
100
|
+
end
|
|
101
|
+
seen.sort
|
|
102
|
+
end
|
|
103
|
+
private_class_method :downstream_of
|
|
104
|
+
|
|
105
|
+
def last_commit(entries, node)
|
|
106
|
+
last = entries.select { |e| !e[:torn] && e[:subject] == node && e[:state] == "done" }.last
|
|
107
|
+
last && (last[:fields] || {})["commit"]
|
|
108
|
+
end
|
|
109
|
+
private_class_method :last_commit
|
|
110
|
+
|
|
111
|
+
# --- the respin (327 D22, same shape as RunnerAnswer's hard-cap path) ------
|
|
112
|
+
|
|
113
|
+
def respin(intent_dir, node, nodes_decl, edges, ledger:, now:, renamer:,
|
|
114
|
+
context:, worktree: NodeWorktree, runner: Worktree::ShellRunner.new)
|
|
115
|
+
decl = nodes_decl[node] || {}
|
|
116
|
+
kind = decl[:kind]
|
|
117
|
+
node_path = ReadySet.find_node_path(intent_dir, node)
|
|
118
|
+
parsed = NodeFile.parse(node_path)
|
|
119
|
+
|
|
120
|
+
mint_pool = NodeIds.taken(intent_dir).dup
|
|
121
|
+
succ_id = NodeFile.mint_id(kind, mint_pool)
|
|
122
|
+
|
|
123
|
+
raw = File.read(node_path)
|
|
124
|
+
parts = raw.split("---", 3)
|
|
125
|
+
body = substitute_id(parts[2].to_s, node, succ_id)
|
|
126
|
+
files = parsed[:files] || decl[:files] || []
|
|
127
|
+
budget = parsed[:budget]
|
|
128
|
+
|
|
129
|
+
frontmatter = "---\nnode: #{succ_id}\nkind: #{kind}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
|
|
130
|
+
succ_path = File.join(intent_dir, "nodes", "#{succ_id}.md")
|
|
131
|
+
FileUtils.mkdir_p(File.dirname(succ_path))
|
|
132
|
+
File.write(succ_path, "#{frontmatter}#{body}")
|
|
133
|
+
|
|
134
|
+
append_graph_line(File.join(intent_dir, "graph.md"), succ_id, edges[node] || [], renamer: renamer)
|
|
135
|
+
|
|
136
|
+
ledger.append_transition(savepoint_path(intent_dir), subject: node, state: "superseded",
|
|
137
|
+
fields: { by: succ_id }, now: now)
|
|
138
|
+
|
|
139
|
+
# M5/D7: the rewound node's own worktree releases too, same as the
|
|
140
|
+
# downstream nodes above.
|
|
141
|
+
worktree.release(context, node: node, state: "superseded", runner: runner)
|
|
142
|
+
|
|
143
|
+
succ_id
|
|
144
|
+
end
|
|
145
|
+
private_class_method :respin
|
|
146
|
+
|
|
147
|
+
def append_graph_line(graph_path, id, needs, renamer:)
|
|
148
|
+
content = File.read(graph_path)
|
|
149
|
+
body = GraphFile.section_body(content, "## Graph").to_s
|
|
150
|
+
lines = body.split("\n")
|
|
151
|
+
rendered = needs.empty? ? "nothing" : needs.join(" ")
|
|
152
|
+
lines << "- #{id} needs #{rendered}"
|
|
153
|
+
new_body = lines.join("\n")
|
|
154
|
+
new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
|
|
155
|
+
AtomicWrite.write(graph_path, new_content, renamer: renamer)
|
|
156
|
+
end
|
|
157
|
+
private_class_method :append_graph_line
|
|
158
|
+
|
|
159
|
+
def substitute_id(text, old_id, new_id)
|
|
160
|
+
text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
|
|
161
|
+
end
|
|
162
|
+
private_class_method :substitute_id
|
|
163
|
+
|
|
164
|
+
def newly_ready(intent_dir, before_content:, before_nodes:, before_edges:)
|
|
165
|
+
after = RunnerCore.safe_load_graph(intent_dir)
|
|
166
|
+
return [] unless after[:ok]
|
|
167
|
+
|
|
168
|
+
after_content = read_savepoint(intent_dir)
|
|
169
|
+
|
|
170
|
+
after[:nodes].keys.select do |id|
|
|
171
|
+
was = before_nodes.key?(id) &&
|
|
172
|
+
ReadySet.ready?(content: before_content, subject: id, graph: { edges: before_edges },
|
|
173
|
+
nodes: before_nodes)[:ready]
|
|
174
|
+
now_ready = ReadySet.ready?(content: after_content, subject: id, graph: { edges: after[:edges] },
|
|
175
|
+
nodes: after[:nodes])[:ready]
|
|
176
|
+
now_ready && !was
|
|
177
|
+
end.sort
|
|
178
|
+
end
|
|
179
|
+
private_class_method :newly_ready
|
|
180
|
+
|
|
181
|
+
def refusal(reason, detail: nil)
|
|
182
|
+
{ ok: false, reason: reason, detail: detail, reset_to: nil, superseded: [], respun_to: nil, newly_ready: [] }
|
|
183
|
+
end
|
|
184
|
+
private_class_method :refusal
|
|
185
|
+
|
|
186
|
+
def blank?(value)
|
|
187
|
+
value.nil? || value.to_s.strip.empty?
|
|
188
|
+
end
|
|
189
|
+
private_class_method :blank?
|
|
190
|
+
|
|
191
|
+
def read_savepoint(intent_dir)
|
|
192
|
+
path = savepoint_path(intent_dir)
|
|
193
|
+
File.exist?(path) ? File.read(path) : ""
|
|
194
|
+
end
|
|
195
|
+
private_class_method :read_savepoint
|
|
196
|
+
|
|
197
|
+
def savepoint_path(intent_dir)
|
|
198
|
+
File.join(intent_dir.to_s, "savepoint.md")
|
|
199
|
+
end
|
|
200
|
+
private_class_method :savepoint_path
|
|
201
|
+
end
|