@zalom/plastic 2.0.0-alpha.8 → 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 +23 -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 +62 -1
- 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 +238 -22
- 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 +19 -5
- 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 +30 -16
- 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 +532 -71
- package/scripts/lib/intent_screen.rb +9 -6
- 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 +377 -173
- 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 +1460 -74
- 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 +555 -0
- 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 +192 -30
- 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 +15 -2
- 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 -295
- 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 -173
- 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,389 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "time"
|
|
7
|
+
require "rbconfig"
|
|
8
|
+
require_relative "worktree"
|
|
9
|
+
require_relative "runner_core"
|
|
10
|
+
require_relative "runner_sweep"
|
|
11
|
+
require_relative "ready_set"
|
|
12
|
+
require_relative "node_ledger"
|
|
13
|
+
require_relative "savepoint"
|
|
14
|
+
require_relative "atomic_write"
|
|
15
|
+
require_relative "runner_until_empty"
|
|
16
|
+
require_relative "harness_adapter"
|
|
17
|
+
require_relative "meter_watch"
|
|
18
|
+
|
|
19
|
+
# RunnerWatch (intent 340a, G7b, n1): one tick over disk truth. The whole
|
|
20
|
+
# watch minus the CLI and the dispatch branch (340a n2). Composes the
|
|
21
|
+
# existing modules the way RunnerSweep and MeterWatch already do - the
|
|
22
|
+
# git runner, the clock and the sweep module are all injected, nothing
|
|
23
|
+
# reads ENV, nothing evals.
|
|
24
|
+
#
|
|
25
|
+
# Order, fixed by graph.md D2: take a non-blocking lock on
|
|
26
|
+
# `<intent_dir>/watch.lock` for the whole tick (a losing tick is `busy`,
|
|
27
|
+
# writes nothing); `RunnerSweep.abort_if_merging`; refuse the tick outright
|
|
28
|
+
# on a half-finished merge (row 1.3), writing nothing; `RunnerSweep.reclaim`
|
|
29
|
+
# (never `RunnerSweep.run`, D3: that method heartbeats the delivery lease,
|
|
30
|
+
# which would keep a dead lead's lock fresh forever); classify (D4);
|
|
31
|
+
# persist the snapshot and the record line only when `record:` holds.
|
|
32
|
+
module RunnerWatch
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
LOCK_FILENAME = "watch.lock"
|
|
36
|
+
STATE_FILENAME = "watch.state"
|
|
37
|
+
RECORD_FILENAME = "watch.record"
|
|
38
|
+
|
|
39
|
+
# Classes that never dispatch (D8): a finished delivery re-running its own
|
|
40
|
+
# graph on every tick is exactly the bug a timer must not have.
|
|
41
|
+
FINISHED_CLASSES = %w[closed done_unreported].freeze
|
|
42
|
+
|
|
43
|
+
# tick(context, record:, dispatch:, harness:, now:, runner:, sweep:,
|
|
44
|
+
# until_empty:) -> {class:, blockers:, ready:, reclaimed:, dispatched:,
|
|
45
|
+
# tick:, busy:}. `dispatch:` (327 Q6, D7, D8) only ever runs under an
|
|
46
|
+
# explicit ask - never inferred from the class - and only when the lock is
|
|
47
|
+
# held, the class is not finished, and the meter does not read `stop`.
|
|
48
|
+
# `until_empty:` is `RunnerUntilEmpty` by default; a test can inject a
|
|
49
|
+
# double so the whole dispatch path never spawns a real `node-run`
|
|
50
|
+
# subprocess. `harness=` and `meter=` on the record line read `-` only
|
|
51
|
+
# when `dispatch:` itself was never asked for (D5) - every other refusal
|
|
52
|
+
# (an unheld lock, a finished class, a stopped meter) still consults and
|
|
53
|
+
# records the meter, since the tick DID look, it just chose not to act.
|
|
54
|
+
def tick(context, record: true, dispatch: false, harness: nil, now: Time.now,
|
|
55
|
+
runner: Worktree::ShellRunner.new, sweep: RunnerSweep, until_empty: RunnerUntilEmpty)
|
|
56
|
+
intent_dir = context.intent_dir.to_s
|
|
57
|
+
lock_handle = acquire_lock(File.join(intent_dir, LOCK_FILENAME))
|
|
58
|
+
return busy_result unless lock_handle
|
|
59
|
+
|
|
60
|
+
begin
|
|
61
|
+
abort_result = sweep.abort_if_merging(context, runner: runner)
|
|
62
|
+
unless abort_result[:ok]
|
|
63
|
+
return {
|
|
64
|
+
class: "merge_in_progress", blockers: [abort_result[:error]], ready: [],
|
|
65
|
+
reclaimed: [], dispatched: [], tick: nil, busy: false,
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
reclaim_result = sweep.reclaim(context, runner: runner, skip: [], now: now)
|
|
70
|
+
reclaimed_ids = Array(reclaim_result[:reclaimed]).map { |r| r[:node] }
|
|
71
|
+
extended = !Array(reclaim_result[:extended]).empty?
|
|
72
|
+
|
|
73
|
+
view = classify(context, runner: runner, now: now, intent_dir: intent_dir, extended: extended)
|
|
74
|
+
|
|
75
|
+
lock_state = context.session ? "held" : "not_held"
|
|
76
|
+
harness_field = "-"
|
|
77
|
+
meter_state = "-"
|
|
78
|
+
dispatched_ids = []
|
|
79
|
+
|
|
80
|
+
# B1: the dispatch loop and the persist step share one inner
|
|
81
|
+
# begin/ensure so a raise mid `until_empty.run` (a launchd SIGTERM, a
|
|
82
|
+
# Process.spawn Errno) still lands the snapshot and the record line
|
|
83
|
+
# with whatever `dispatched_ids` was collected before the raise, and
|
|
84
|
+
# then the exception keeps propagating past this method (the outer
|
|
85
|
+
# `ensure` below still releases the tick lock).
|
|
86
|
+
begin
|
|
87
|
+
if dispatch
|
|
88
|
+
harness_field = blank?(harness) ? "-" : harness.to_s
|
|
89
|
+
meter_state = read_meter_state(context)
|
|
90
|
+
|
|
91
|
+
if context.session && !FINISHED_CLASSES.include?(view[:class]) && meter_state != "stop"
|
|
92
|
+
run_until_empty_dispatch(context, harness: harness, until_empty: until_empty,
|
|
93
|
+
dispatched_ids: dispatched_ids)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
ensure
|
|
97
|
+
if record
|
|
98
|
+
Worktree.ensure_gitignored(context.plastic_home, STATE_FILENAME, runner: runner)
|
|
99
|
+
write_snapshot(intent_dir, view[:snapshot])
|
|
100
|
+
append_record(
|
|
101
|
+
intent_dir, now: now, tick: view[:tick], klass: view[:class],
|
|
102
|
+
reclaimed: reclaimed_ids, ready: view[:ready], dispatched: dispatched_ids,
|
|
103
|
+
harness: harness_field, meter: meter_state, lock: lock_state
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
class: view[:class], blockers: view[:blockers], ready: view[:ready],
|
|
110
|
+
reclaimed: reclaimed_ids, dispatched: dispatched_ids, tick: view[:tick], busy: false,
|
|
111
|
+
}
|
|
112
|
+
ensure
|
|
113
|
+
release_lock(lock_handle)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# install_timer(context, home:, harness_key:, installer:) -> the written
|
|
118
|
+
# plist path. Graph.md D9: the Codex carrier is `runner watch
|
|
119
|
+
# --install-timer`, and it reuses MeterWatch's own writer through
|
|
120
|
+
# `label:`/`arguments:` rather than rendering plist XML here (row 3.5) - a
|
|
121
|
+
# second writer is exactly the drift D9 rules out. The label carries the
|
|
122
|
+
# intent id (row 3.4), so a second intent's timer never overwrites the
|
|
123
|
+
# first's job; `--dispatch --harness codex` rides the arguments only when
|
|
124
|
+
# `HarnessAdapter.unattended_start?` holds for the resolved harness (row
|
|
125
|
+
# 3.3), the same predicate `run_watch` itself already gates `--dispatch`
|
|
126
|
+
# on. `installer:` is `MeterWatch` by default so a test can inject a
|
|
127
|
+
# double that never touches a real home.
|
|
128
|
+
def install_timer(context, home:, harness_key:, installer: MeterWatch)
|
|
129
|
+
runner_path = File.expand_path(File.join(__dir__, "..", "runner"))
|
|
130
|
+
arguments = [RbConfig.ruby, runner_path, "watch", context.intent_dir.to_s]
|
|
131
|
+
arguments += ["--dispatch", "--harness", harness_key.to_s] if HarnessAdapter.unattended_start?(harness_key)
|
|
132
|
+
|
|
133
|
+
installer.install_timer(home: home, script_path: runner_path,
|
|
134
|
+
label: "com.plastic.delivery-watch.#{context.intent_id}", arguments: arguments)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# run_until_empty_dispatch(context, harness:, until_empty:) -> every node
|
|
138
|
+
# id the loop dispatched (C30). `until_empty.run` gets `step:` wrapped
|
|
139
|
+
# around `until_empty.step_once` so this method sees every turn's own
|
|
140
|
+
# `:dispatched` ids, the same shape RunnerDispatch.dispatch returns
|
|
141
|
+
# (graph.md D8); `until_empty.run` itself still owns spawning and waiting
|
|
142
|
+
# on the real `node-run` subprocesses (never duplicated here).
|
|
143
|
+
def run_until_empty_dispatch(context, harness:, until_empty:, dispatched_ids:)
|
|
144
|
+
wrapped_step = lambda do |ctx, harness:, returns:|
|
|
145
|
+
result = until_empty.step_once(ctx, harness: harness, returns: returns)
|
|
146
|
+
dispatched_ids.concat(Array(result[:dispatched]))
|
|
147
|
+
result
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
until_empty.run(context, harness: harness, step: wrapped_step)
|
|
151
|
+
end
|
|
152
|
+
private_class_method :run_until_empty_dispatch
|
|
153
|
+
|
|
154
|
+
# read_meter_state(context) -> "ok", "reduce", "stop", "resume", "stale"
|
|
155
|
+
# (whatever MeterWatch's own tick last wrote), or "unavailable" for a
|
|
156
|
+
# missing or unparseable file (D8) - read-only, at MeterWatch's own state
|
|
157
|
+
# path under `context.plastic_home`, never through a MeterWatch instance
|
|
158
|
+
# (that class computes a FRESH state from the rate-limit cache; this tick
|
|
159
|
+
# only ever reads what it already wrote).
|
|
160
|
+
def read_meter_state(context)
|
|
161
|
+
path = File.join(context.plastic_home.to_s, ".cache", "meter-state.json")
|
|
162
|
+
return "unavailable" unless File.file?(path)
|
|
163
|
+
|
|
164
|
+
state = JSON.parse(File.read(path))["state"]
|
|
165
|
+
blank?(state) ? "unavailable" : state.to_s
|
|
166
|
+
rescue StandardError
|
|
167
|
+
"unavailable"
|
|
168
|
+
end
|
|
169
|
+
private_class_method :read_meter_state
|
|
170
|
+
|
|
171
|
+
def blank?(value)
|
|
172
|
+
value.nil? || value.to_s.strip.empty?
|
|
173
|
+
end
|
|
174
|
+
private_class_method :blank?
|
|
175
|
+
|
|
176
|
+
# fingerprint(context, runner:) -> the SHA256 D4 defines: savepoint.md's
|
|
177
|
+
# current content plus the intent branch head, so a commit that lands no
|
|
178
|
+
# ledger line still counts as movement (row 1.13). Public so a caller (and
|
|
179
|
+
# this file's own tests) can compute the exact value a tick would compute
|
|
180
|
+
# without duplicating the hashing here.
|
|
181
|
+
def fingerprint(context, runner: Worktree::ShellRunner.new)
|
|
182
|
+
content = savepoint_content(context.intent_dir)
|
|
183
|
+
Digest::SHA256.hexdigest("#{content}\x1f#{branch_head_sha(context, runner)}")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# --- the lock ----------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
def acquire_lock(path)
|
|
189
|
+
handle = File.open(path, File::CREAT | File::RDWR, 0o644)
|
|
190
|
+
return handle if handle.flock(File::LOCK_EX | File::LOCK_NB)
|
|
191
|
+
|
|
192
|
+
handle.close
|
|
193
|
+
nil
|
|
194
|
+
rescue SystemCallError
|
|
195
|
+
nil
|
|
196
|
+
end
|
|
197
|
+
private_class_method :acquire_lock
|
|
198
|
+
|
|
199
|
+
def release_lock(handle)
|
|
200
|
+
return unless handle
|
|
201
|
+
|
|
202
|
+
handle.flock(File::LOCK_UN)
|
|
203
|
+
rescue SystemCallError
|
|
204
|
+
nil
|
|
205
|
+
ensure
|
|
206
|
+
handle&.close
|
|
207
|
+
end
|
|
208
|
+
private_class_method :release_lock
|
|
209
|
+
|
|
210
|
+
def busy_result
|
|
211
|
+
{ class: nil, blockers: [], ready: [], reclaimed: [], dispatched: [], tick: nil, busy: true }
|
|
212
|
+
end
|
|
213
|
+
private_class_method :busy_result
|
|
214
|
+
|
|
215
|
+
# --- classification (D4), first match wins ------------------------------------
|
|
216
|
+
|
|
217
|
+
def classify(context, runner:, now:, intent_dir:, extended: false)
|
|
218
|
+
content = savepoint_content(intent_dir)
|
|
219
|
+
recorded_pairs = Savepoint.savepoint_recorded_pairs(intent_dir)
|
|
220
|
+
previous = read_snapshot(intent_dir)
|
|
221
|
+
tick_number = (previous ? previous[:tick] : 0) + 1
|
|
222
|
+
|
|
223
|
+
if closed?(recorded_pairs)
|
|
224
|
+
return settle("closed", blockers: [], ready_ids: [], previous: previous, tick_number: tick_number,
|
|
225
|
+
quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
unless context.graph[:ok]
|
|
229
|
+
errors = Array(context.graph[:errors])
|
|
230
|
+
errors = ["graph.md could not be parsed"] if errors.empty?
|
|
231
|
+
return settle("stalled", blockers: errors, ready_ids: [], previous: previous, tick_number: tick_number,
|
|
232
|
+
quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if RunnerCore.complete?(context)
|
|
236
|
+
return settle("done_unreported", blockers: [], ready_ids: [], previous: previous, tick_number: tick_number,
|
|
237
|
+
quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
ready_ids = ReadySet.analyze(intent_dir, now: now)[:ranked_ready].map { |r| r[:id] }
|
|
241
|
+
running = any_running?(content)
|
|
242
|
+
|
|
243
|
+
if !running && ready_ids.empty?
|
|
244
|
+
return settle("stalled", blockers: blocked_reasons(context), ready_ids: ready_ids, previous: previous,
|
|
245
|
+
tick_number: tick_number, quiet_ticks: 0, content: content, context: context, runner: runner,
|
|
246
|
+
now: now)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
current_fingerprint = fingerprint(context, runner: runner)
|
|
250
|
+
|
|
251
|
+
# B2: a reclaim that extended a lease is evidence of live work (the
|
|
252
|
+
# sweep extends only when the node branch has commits newer than the
|
|
253
|
+
# expiry), so it counts as movement here, at the fingerprint
|
|
254
|
+
# comparison, after closed/malformed-graph/done_unreported/the
|
|
255
|
+
# nothing-running-nothing-ready stall have already returned above.
|
|
256
|
+
if extended || previous.nil? || previous[:fingerprint] != current_fingerprint
|
|
257
|
+
return settle("moving", blockers: [], ready_ids: ready_ids, previous: previous, tick_number: tick_number,
|
|
258
|
+
quiet_ticks: 0, content: content, context: context, runner: runner, now: now,
|
|
259
|
+
fingerprint: current_fingerprint)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
next_quiet_ticks = previous[:quiet_ticks] + 1
|
|
263
|
+
if previous[:quiet_ticks] >= 1 && !unexpired_running_lease?(content, now)
|
|
264
|
+
klass = "stalled"
|
|
265
|
+
blockers = ["no observed movement for #{next_quiet_ticks} consecutive ticks and no unexpired running lease"]
|
|
266
|
+
else
|
|
267
|
+
klass = "quiet"
|
|
268
|
+
blockers = []
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
settle(klass, blockers: blockers, ready_ids: ready_ids, previous: previous, tick_number: tick_number,
|
|
272
|
+
quiet_ticks: next_quiet_ticks, content: content, context: context, runner: runner, now: now,
|
|
273
|
+
fingerprint: current_fingerprint)
|
|
274
|
+
end
|
|
275
|
+
private_class_method :classify
|
|
276
|
+
|
|
277
|
+
# Assembles the return view plus the snapshot that will be persisted, when
|
|
278
|
+
# `record:` is honored by the caller. `fingerprint:` defaults to a fresh
|
|
279
|
+
# computation so every class - not only moving/quiet/stalled-by-quiet -
|
|
280
|
+
# persists a value later ticks can compare against.
|
|
281
|
+
def settle(klass, blockers:, ready_ids:, previous:, tick_number:, quiet_ticks:, content:, context:, runner:,
|
|
282
|
+
now:, fingerprint: nil)
|
|
283
|
+
fp = fingerprint || RunnerWatch.fingerprint(context, runner: runner)
|
|
284
|
+
{
|
|
285
|
+
class: klass, blockers: blockers, ready: ready_ids, tick: tick_number,
|
|
286
|
+
snapshot: { fingerprint: fp, quiet_ticks: quiet_ticks, tick: tick_number, at: now.utc.iso8601 },
|
|
287
|
+
}
|
|
288
|
+
end
|
|
289
|
+
private_class_method :settle
|
|
290
|
+
|
|
291
|
+
def closed?(recorded_pairs)
|
|
292
|
+
recorded_pairs.include?(["Done", "delivered"]) || recorded_pairs.include?(["Done", "abandoned"])
|
|
293
|
+
end
|
|
294
|
+
private_class_method :closed?
|
|
295
|
+
|
|
296
|
+
def blocked_reasons(context)
|
|
297
|
+
rows = RunnerCore.status(context)
|
|
298
|
+
rows.values.reject { |v| ReadySet::TERMINAL_STATES.include?(v[:state]) || v[:ready] }
|
|
299
|
+
.flat_map { |v| v[:blockers] }
|
|
300
|
+
.uniq
|
|
301
|
+
end
|
|
302
|
+
private_class_method :blocked_reasons
|
|
303
|
+
|
|
304
|
+
def any_running?(content)
|
|
305
|
+
NodeLedger.status_from_content(content).value?("running")
|
|
306
|
+
end
|
|
307
|
+
private_class_method :any_running?
|
|
308
|
+
|
|
309
|
+
def unexpired_running_lease?(content, now)
|
|
310
|
+
status_map = NodeLedger.status_from_content(content)
|
|
311
|
+
entries = NodeLedger.entries_from_content(content)
|
|
312
|
+
status_map.any? do |subject, state|
|
|
313
|
+
next false unless state == "running"
|
|
314
|
+
|
|
315
|
+
last = entries.select { |e| !e[:torn] && e[:subject] == subject && e[:state] == "running" }.last
|
|
316
|
+
next false unless last
|
|
317
|
+
|
|
318
|
+
at = parse_time((last[:fields] || {})["expires"])
|
|
319
|
+
at && now < at
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
private_class_method :unexpired_running_lease?
|
|
323
|
+
|
|
324
|
+
def branch_head_sha(context, runner)
|
|
325
|
+
worktree = context.worktree
|
|
326
|
+
return "" if worktree.nil? || worktree.to_s.strip.empty?
|
|
327
|
+
|
|
328
|
+
res = runner.run("-C", worktree, "rev-parse", "HEAD")
|
|
329
|
+
res.success? ? res.stdout.to_s.strip : ""
|
|
330
|
+
rescue StandardError
|
|
331
|
+
""
|
|
332
|
+
end
|
|
333
|
+
private_class_method :branch_head_sha
|
|
334
|
+
|
|
335
|
+
def parse_time(raw)
|
|
336
|
+
return nil if raw.nil? || raw.to_s.strip.empty?
|
|
337
|
+
|
|
338
|
+
Time.iso8601(raw.to_s)
|
|
339
|
+
rescue ArgumentError
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
private_class_method :parse_time
|
|
343
|
+
|
|
344
|
+
def savepoint_content(intent_dir)
|
|
345
|
+
path = File.join(intent_dir.to_s, "savepoint.md")
|
|
346
|
+
File.exist?(path) ? File.read(path) : ""
|
|
347
|
+
end
|
|
348
|
+
private_class_method :savepoint_content
|
|
349
|
+
|
|
350
|
+
# --- the snapshot (D5) ---------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
def read_snapshot(intent_dir)
|
|
353
|
+
path = File.join(intent_dir, STATE_FILENAME)
|
|
354
|
+
return nil unless File.file?(path)
|
|
355
|
+
|
|
356
|
+
data = JSON.parse(File.read(path))
|
|
357
|
+
{ fingerprint: data["fingerprint"], quiet_ticks: data["quiet_ticks"].to_i, tick: data["tick"].to_i,
|
|
358
|
+
at: data["at"] }
|
|
359
|
+
rescue StandardError
|
|
360
|
+
nil
|
|
361
|
+
end
|
|
362
|
+
private_class_method :read_snapshot
|
|
363
|
+
|
|
364
|
+
def write_snapshot(intent_dir, snapshot)
|
|
365
|
+
path = File.join(intent_dir, STATE_FILENAME)
|
|
366
|
+
data = {
|
|
367
|
+
"fingerprint" => snapshot[:fingerprint], "quiet_ticks" => snapshot[:quiet_ticks],
|
|
368
|
+
"tick" => snapshot[:tick], "at" => snapshot[:at],
|
|
369
|
+
}
|
|
370
|
+
AtomicWrite.write(path, JSON.generate(data))
|
|
371
|
+
end
|
|
372
|
+
private_class_method :write_snapshot
|
|
373
|
+
|
|
374
|
+
# --- the record (D5) -----------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
def append_record(intent_dir, now:, tick:, klass:, reclaimed:, ready:, dispatched:, harness: "-", meter: "-",
|
|
377
|
+
lock:)
|
|
378
|
+
line = "#{now.utc.iso8601} tick=#{tick} class=#{klass} reclaimed=#{list_or_dash(reclaimed)} " \
|
|
379
|
+
"ready=#{list_or_dash(ready)} dispatched=#{list_or_dash(dispatched)} harness=#{harness} " \
|
|
380
|
+
"meter=#{meter} lock=#{lock}\n"
|
|
381
|
+
File.open(File.join(intent_dir, RECORD_FILENAME), "a") { |f| f.write(line) }
|
|
382
|
+
end
|
|
383
|
+
private_class_method :append_record
|
|
384
|
+
|
|
385
|
+
def list_or_dash(list)
|
|
386
|
+
list.nil? || list.empty? ? "-" : list.join(",")
|
|
387
|
+
end
|
|
388
|
+
private_class_method :list_or_dash
|
|
389
|
+
end
|
package/scripts/lib/savepoint.rb
CHANGED
|
@@ -3,14 +3,13 @@
|
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
5
|
# Savepoint: the intent-directory savepoint ledger and the stage derivation it
|
|
6
|
-
# rests on (intent 303
|
|
6
|
+
# rests on (intent 303). Everything here is derived from
|
|
7
7
|
# the files on disk inside one intent directory: which lifecycle files are real
|
|
8
8
|
# (not the placeholder sentinel), which stage that makes, and the append-only
|
|
9
|
-
# savepoint.md ledger that records each milestone. No session, no
|
|
9
|
+
# savepoint.md ledger that records each milestone. No session, no lock, no
|
|
10
10
|
# lock: a caller that only wants to append a ledger line or ask "which stage is
|
|
11
|
-
# this intent at" loads this file and nothing else.
|
|
12
|
-
#
|
|
13
|
-
# requires the bridge.
|
|
11
|
+
# this intent at" loads this file and nothing else. `scripts/lib/arm.rb` (arm,
|
|
12
|
+
# disarm, lock repair) requires this file; this file never requires arm.rb.
|
|
14
13
|
require "fileutils"
|
|
15
14
|
require "time" # Time#iso8601 for the ledger timestamps
|
|
16
15
|
|
|
@@ -23,6 +22,36 @@ module Savepoint
|
|
|
23
22
|
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
24
23
|
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
25
24
|
|
|
25
|
+
# --- Node-graph transition subject vocabulary (intent 335, spec D17) -------
|
|
26
|
+
#
|
|
27
|
+
# Owned HERE, not on NodeLedger, because test/savepoint_split_test.rb:57 pins
|
|
28
|
+
# savepoint.rb to loading no other project file: NodeLedger requires this
|
|
29
|
+
# file and reuses these three names rather than duplicating them, so the
|
|
30
|
+
# dependency runs one way only. Intent 334 (G1) mints node ids and must
|
|
31
|
+
# agree with NODE_SUBJECT_RE: it is the single seam for the node id shape.
|
|
32
|
+
|
|
33
|
+
# The literal subject token for an intent-scope transition line ("Intent
|
|
34
|
+
# needs_decision question=..."), as opposed to a node-scope line.
|
|
35
|
+
INTENT_SUBJECT = "Intent"
|
|
36
|
+
|
|
37
|
+
# A node id: one or two lowercase letters (the node's kind prefix, e.g. "n"
|
|
38
|
+
# for work, "v" for verify) followed by digits.
|
|
39
|
+
NODE_SUBJECT_RE = /\A[a-z]{1,2}\d+\z/.freeze
|
|
40
|
+
|
|
41
|
+
# True iff a raw savepoint ledger line's subject (field 2, split on
|
|
42
|
+
# /\s{2,}/) is a transition candidate: the literal Intent token or a node
|
|
43
|
+
# id. A stage line ("How checklist.md created") or a Lock takeover audit
|
|
44
|
+
# line never matches, by construction (spec Acceptance Criteria: "no stage
|
|
45
|
+
# token this tree writes collides with Intent or with the node id
|
|
46
|
+
# pattern").
|
|
47
|
+
def self.transition_candidate?(line)
|
|
48
|
+
parts = line.to_s.split(/\s{2,}/)
|
|
49
|
+
return false unless parts.length >= 2
|
|
50
|
+
|
|
51
|
+
subject = parts[1]
|
|
52
|
+
subject == INTENT_SUBJECT || subject.match?(NODE_SUBJECT_RE)
|
|
53
|
+
end
|
|
54
|
+
|
|
26
55
|
def self.intent_file(intent_dir)
|
|
27
56
|
dir_name = File.basename(intent_dir)
|
|
28
57
|
"#{intent_dir}/#{dir_name}.md"
|
|
@@ -30,7 +59,7 @@ module Savepoint
|
|
|
30
59
|
|
|
31
60
|
# Walk up from file_path; return the first ancestor that looks like an intent
|
|
32
61
|
# directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
|
|
33
|
-
# target without needing
|
|
62
|
+
# target without needing the lock or arm machinery. The input is always a file inside the intent
|
|
34
63
|
# dir (never the dir itself), so the walk-up starts at its parent.
|
|
35
64
|
def self.intent_dir_for(file_path)
|
|
36
65
|
dir = File.expand_path(file_path)
|
|
@@ -57,22 +86,47 @@ module Savepoint
|
|
|
57
86
|
File.exist?(path)
|
|
58
87
|
end
|
|
59
88
|
|
|
60
|
-
# True iff actions/ holds AT LEAST ONE real
|
|
61
|
-
# first line
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# reports it needs a real action file); it never raises.
|
|
66
|
-
def self.has_real_action?(intent_dir)
|
|
67
|
-
Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
|
|
89
|
+
# True iff DIR_NAME (actions/ or nodes/) holds AT LEAST ONE real *.md file: non-empty,
|
|
90
|
+
# first line not the placeholder sentinel. A `.gitkeep` (no .md extension) never counts.
|
|
91
|
+
# Pure and side-effect-free; fail-open (a missing dir globs to nothing, never raises).
|
|
92
|
+
def self.has_real_files_in?(dir_name, intent_dir)
|
|
93
|
+
Dir.glob("#{intent_dir}/#{dir_name}/*.md").any? do |f|
|
|
68
94
|
File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
|
|
69
95
|
end
|
|
70
96
|
rescue StandardError
|
|
71
97
|
false
|
|
72
98
|
end
|
|
73
99
|
|
|
100
|
+
# True iff the intent has at least one real action file, whether delivered as
|
|
101
|
+
# legacy actions/*.md or as a node graph's nodes/*.md (intent 334, G1, D10r):
|
|
102
|
+
# an intent delivered as nodes is exactly as real as one delivered as
|
|
103
|
+
# actions, so doctor and the exec-stage gate never report a backfill gap on
|
|
104
|
+
# a fully delivered node-graph intent. Checks actions/ first (the common
|
|
105
|
+
# path today), falling through to nodes/ only when actions/ has nothing.
|
|
106
|
+
def self.has_real_action?(intent_dir)
|
|
107
|
+
has_real_files_in?("actions", intent_dir) || has_real_files_in?("nodes", intent_dir)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Intent 336 (G3, D13): a real graph.md is 327 D41's replacement for
|
|
113
|
+
# plan.md/checklist.md on the common path, so a node-shaped intent is
|
|
114
|
+
# judged on graph.md and nodes/ alone, whether or not spec.md is real - the
|
|
115
|
+
# graph IS the spec under D41. Checked using only this file's own
|
|
116
|
+
# primitives (stage_file_present?, has_real_files_in?), because
|
|
117
|
+
# test/savepoint_split_test.rb pins this file to loading no other project
|
|
118
|
+
# file and no YAML: GraphFile/NodeFile/ReadySet are never required here. An
|
|
119
|
+
# intent with no real graph.md (or a sentinel-placeholder one,
|
|
120
|
+
# stage_file_present? already reads that as absent) derives exactly what it
|
|
121
|
+
# derived before this intent.
|
|
74
122
|
def self.derive_stage(intent_dir)
|
|
75
123
|
return "done" if stage_file_present?("#{intent_dir}/outcome.md")
|
|
124
|
+
|
|
125
|
+
if stage_file_present?("#{intent_dir}/graph.md")
|
|
126
|
+
return "exec" if has_real_files_in?("nodes", intent_dir)
|
|
127
|
+
return "how"
|
|
128
|
+
end
|
|
129
|
+
|
|
76
130
|
if stage_file_present?("#{intent_dir}/plan.md") &&
|
|
77
131
|
has_real_action?(intent_dir) &&
|
|
78
132
|
stage_file_present?("#{intent_dir}/checklist.md")
|
|
@@ -87,19 +141,49 @@ module Savepoint
|
|
|
87
141
|
files = []
|
|
88
142
|
ifile = File.basename(intent_file(intent_dir))
|
|
89
143
|
files << ifile if File.exist?("#{intent_dir}/#{ifile}")
|
|
90
|
-
["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
144
|
+
["spec.md", "graph.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
91
145
|
files << f if stage_file_present?("#{intent_dir}/#{f}")
|
|
92
146
|
end
|
|
93
|
-
|
|
147
|
+
# Name the directory that actually exists (review B3): a nodes-only intent
|
|
148
|
+
# must never claim the literal "actions/" artifact it does not have.
|
|
149
|
+
# Checks actions/ first, matching D15r's read order.
|
|
150
|
+
if has_real_files_in?("actions", intent_dir)
|
|
151
|
+
files << "actions/"
|
|
152
|
+
elsif has_real_files_in?("nodes", intent_dir)
|
|
153
|
+
files << "nodes/"
|
|
154
|
+
end
|
|
94
155
|
files
|
|
95
156
|
end
|
|
96
157
|
|
|
97
158
|
def self.missing_for_stage(stage, intent_dir = nil)
|
|
98
159
|
ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
|
|
160
|
+
# A How-stage intent that already started a nodes/ directory is named
|
|
161
|
+
# accordingly, so the next-step hint never tells a node-graph intent to
|
|
162
|
+
# go make an actions/ directory it will never use (review B3). Mirrors
|
|
163
|
+
# has_real_files_in?'s actions-first order and its real-file requirement
|
|
164
|
+
# (post-execution review, non-blocking 4): an intent carrying real files
|
|
165
|
+
# in both directories, or a real actions/ file beside an empty or
|
|
166
|
+
# .gitkeep-only nodes/, is named actions/, never nodes/.
|
|
167
|
+
action_label = if intent_dir && has_real_files_in?("actions", intent_dir)
|
|
168
|
+
"actions/"
|
|
169
|
+
elsif intent_dir && has_real_files_in?("nodes", intent_dir)
|
|
170
|
+
"nodes/"
|
|
171
|
+
else
|
|
172
|
+
"actions/"
|
|
173
|
+
end
|
|
99
174
|
case stage
|
|
100
175
|
when "what" then [ifile]
|
|
101
176
|
when "why" then ["spec.md"]
|
|
102
|
-
when "how"
|
|
177
|
+
when "how"
|
|
178
|
+
# A node-shaped How intent (a real graph.md already) is named after its
|
|
179
|
+
# own two artifacts, never the three D41 removed from its path (review
|
|
180
|
+
# B3, extended by 336 D13): checked via stage_file_present? alone, the
|
|
181
|
+
# same primitive derive_stage itself uses.
|
|
182
|
+
if intent_dir && stage_file_present?("#{intent_dir}/graph.md")
|
|
183
|
+
["graph.md", "nodes/"]
|
|
184
|
+
else
|
|
185
|
+
["plan.md", action_label, "checklist.md"]
|
|
186
|
+
end
|
|
103
187
|
when "exec" then ["outcome.md"]
|
|
104
188
|
else []
|
|
105
189
|
end
|
|
@@ -227,6 +311,18 @@ module Savepoint
|
|
|
227
311
|
append_savepoint_line(intent_dir, "Commit", text, now)
|
|
228
312
|
end
|
|
229
313
|
|
|
314
|
+
# The day the Report kind (below) shipped. `doctor`'s intent_reports_printed_check reads
|
|
315
|
+
# this so it never re-litigates a ledger recorded before the kind existed (intent 331f, R6).
|
|
316
|
+
REPORT_KIND_SINCE = "2026-09-05"
|
|
317
|
+
|
|
318
|
+
# Append a `Report` line: one per report screen printed (intent 331f, D2/D3). Same primitive
|
|
319
|
+
# as append_review_savepoint/append_commit_savepoint above, so the line shape, dedup, and
|
|
320
|
+
# timestamp format never drift. `savepoint_milestone` maps FILENAMES only, so a Report line
|
|
321
|
+
# is never mistaken for a file-landing lifecycle line by construction.
|
|
322
|
+
def self.append_report_savepoint(intent_dir, text, now: Time.now)
|
|
323
|
+
append_savepoint_line(intent_dir, "Report", text, now)
|
|
324
|
+
end
|
|
325
|
+
|
|
230
326
|
TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
|
|
231
327
|
|
|
232
328
|
# Append the terminal bookend `Done delivered|abandoned`, written by the
|
|
@@ -246,6 +342,17 @@ module Savepoint
|
|
|
246
342
|
# A Plastic 1.x ledger may carry a `Tier <value>` line after the spec.md
|
|
247
343
|
# milestone (removed in 2.0, intent 304); a rebuild drops it, and the phantom
|
|
248
344
|
# detector ignores it, so a 1.x store reads clean.
|
|
345
|
+
#
|
|
346
|
+
# Every transition line (intent 335, spec D13) is preserved VERBATIM, in its
|
|
347
|
+
# original relative order, after the reconstructed stage skeleton. It is
|
|
348
|
+
# never dropped and never refused: a transition line's evidence fields
|
|
349
|
+
# (`holder=`, `expires=`, `gates=`, ...) have no file-mtime analog to
|
|
350
|
+
# reconstruct from, so refusing instead of preserving would make this method
|
|
351
|
+
# destroy the graph's only status on every intent that carries one. Relative
|
|
352
|
+
# order BETWEEN a stage line and a transition line is not preserved (safe:
|
|
353
|
+
# status is computed per subject, and the two families share no subject);
|
|
354
|
+
# relative order WITHIN the transition lines is preserved, which is what
|
|
355
|
+
# "last line per subject in file order" depends on.
|
|
249
356
|
def self.rebuild_savepoint(intent_dir)
|
|
250
357
|
ordered = [
|
|
251
358
|
File.basename(intent_file(intent_dir)),
|
|
@@ -259,13 +366,24 @@ module Savepoint
|
|
|
259
366
|
stamp = File.mtime(path).utc.iso8601
|
|
260
367
|
["#{stamp} #{stage} #{milestone}\n"]
|
|
261
368
|
end
|
|
262
|
-
|
|
369
|
+
|
|
370
|
+
savepoint_path = File.join(intent_dir, SAVEPOINT_FILE)
|
|
371
|
+
if File.exist?(savepoint_path)
|
|
372
|
+
# #scrub before scanning (post-execution review row 7.8), the same way
|
|
373
|
+
# NodeLedger.entries does (matrix 2.44): a stray non-UTF-8 byte anywhere
|
|
374
|
+
# in the ledger must not raise out of the one repair tool three doctor
|
|
375
|
+
# fix hints and maintenance-run --tool rebuild-savepoint point at.
|
|
376
|
+
transition_lines = File.read(savepoint_path).scrub.each_line.select { |raw| transition_candidate?(raw) }
|
|
377
|
+
lines += transition_lines.map { |raw| raw.end_with?("\n") ? raw : "#{raw}\n" }
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
File.write(savepoint_path, lines.join)
|
|
263
381
|
lines.length
|
|
264
382
|
end
|
|
265
383
|
|
|
266
384
|
# --- Phantom-line detection (intent 134) ------------------------------------
|
|
267
385
|
#
|
|
268
|
-
# A companion to the ledger, not a new writer: pure, disk-only, hermetic (no
|
|
386
|
+
# A companion to the ledger, not a new writer: pure, disk-only, hermetic (no lock or
|
|
269
387
|
# session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
|
|
270
388
|
# a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
|
|
271
389
|
# can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
|
|
@@ -308,6 +426,10 @@ module Savepoint
|
|
|
308
426
|
File.read(path).each_line do |raw|
|
|
309
427
|
line = raw.strip
|
|
310
428
|
next if line.empty?
|
|
429
|
+
# A transition line (intent 335) is never a stage phantom candidate: its
|
|
430
|
+
# own repeated-line semantics (dedup-free by design, spec D11) are
|
|
431
|
+
# NodeLedger's concern, not this detector's.
|
|
432
|
+
next if transition_candidate?(line)
|
|
311
433
|
parts = line.split(/\s{2,}/)
|
|
312
434
|
next if parts.length < 3
|
|
313
435
|
pair = [parts[1], parts[2]]
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
|
|
4
4
|
require "fileutils"
|
|
5
5
|
require_relative "worktree"
|
|
6
|
-
require_relative "bridge"
|
|
7
6
|
require_relative "savepoint"
|
|
8
7
|
require_relative "intent_validator"
|
|
9
8
|
|
|
@@ -21,7 +20,7 @@ require_relative "intent_validator"
|
|
|
21
20
|
# copy or a mechanical rendering of a committed artifact.
|
|
22
21
|
#
|
|
23
22
|
# Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or
|
|
24
|
-
# `ENV` directly (only via the ambient `Dir.home` default, matching
|
|
23
|
+
# `ENV` directly (only via the ambient `Dir.home` default, matching this module's own
|
|
25
24
|
# convention). A git seam is injected as `runner:`, defaulting to
|
|
26
25
|
# `Worktree::ShellRunner.new`, so tests drive this in process with a fake runner and
|
|
27
26
|
# never touch real git. Every method returns a value; `scripts/scaffold-intent` maps the
|
|
@@ -29,6 +28,10 @@ require_relative "intent_validator"
|
|
|
29
28
|
module ScaffoldIntent
|
|
30
29
|
module_function
|
|
31
30
|
|
|
31
|
+
def blank?(value)
|
|
32
|
+
value.nil? || value.to_s.strip.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
32
35
|
# --- path resolution (pure) --------------------------------------------------
|
|
33
36
|
|
|
34
37
|
def expand(path)
|
|
@@ -211,7 +214,7 @@ module ScaffoldIntent
|
|
|
211
214
|
end
|
|
212
215
|
end
|
|
213
216
|
|
|
214
|
-
unless
|
|
217
|
+
unless blank?(test_summary)
|
|
215
218
|
out << "\n"
|
|
216
219
|
out << "Test summary from #{test_summary}:\n"
|
|
217
220
|
out << "```\n"
|