@zalom/plastic 2.0.0-alpha.9 → 2.0.2
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 +21 -16
- 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 +8 -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/docs/help/completion-and-done.md +53 -0
- 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 +5 -4
- 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 +378 -56
- 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 +74 -91
- 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 +111 -0
- package/scripts/lib/cli/commands/auto_report.rb +50 -0
- package/scripts/lib/cli/commands/auto_take.rb +68 -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 +58 -0
- package/scripts/lib/cli/commands/intent_end.rb +75 -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 +79 -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 +46 -0
- package/scripts/lib/cli/commands/project_list.rb +20 -0
- package/scripts/lib/cli/commands/project_new.rb +95 -0
- package/scripts/lib/cli/commands/query.rb +31 -0
- package/scripts/lib/cli/commands/render.rb +35 -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 +77 -0
- package/scripts/lib/cli/commands/roadmap_show.rb +65 -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 +55 -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 +235 -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 +543 -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 +921 -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 +170 -12
- 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 +526 -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 +67 -19
- 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/untouched_scaffold.rb +51 -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 +65 -60
- package/scripts/project-links +6 -21
- 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 +125 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/rollback.rb +5 -1
- 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 +31 -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/conventions/references/completion-and-done.md +0 -43
- 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,62 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# NodeInputCompatibility (intent 338a, G6, n1, D10/D11): the one seam that
|
|
5
|
+
# lets a `running` line written before the node-input rename keep reading
|
|
6
|
+
# correctly. It is the ONLY shipped file allowed to name the retired field,
|
|
7
|
+
# and it names it exactly once, as LEGACY_FIELD below.
|
|
8
|
+
#
|
|
9
|
+
# It maps, never rewrites: the raw ledger line on disk is never touched.
|
|
10
|
+
# `NodeLedger.parse_transition_line` calls #fields on every parsed field hash
|
|
11
|
+
# before returning it, so torn detection, attempt counting, node-run,
|
|
12
|
+
# graph-measure and outcome-report all see `input` whether the line was
|
|
13
|
+
# written by an old dispatch or a new one.
|
|
14
|
+
#
|
|
15
|
+
# No project requires (spec D10): a plain module over a plain hash, so
|
|
16
|
+
# `node_ledger.rb` never gains a require cycle through this file.
|
|
17
|
+
module NodeInputCompatibility
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The retired field name, named once. No other shipped file names it.
|
|
21
|
+
LEGACY_FIELD = "packet"
|
|
22
|
+
|
|
23
|
+
# The retired directory and suffix (338a n3, D8), named once beside
|
|
24
|
+
# LEGACY_FIELD. No other shipped file spells either literal; a caller
|
|
25
|
+
# that needs the legacy input location builds it from these constants.
|
|
26
|
+
LEGACY_DIRECTORY = "packets"
|
|
27
|
+
LEGACY_SUFFIX = ".packet"
|
|
28
|
+
|
|
29
|
+
# A copy of `fields` where LEGACY_FIELD maps to "input" when "input" is
|
|
30
|
+
# absent or blank. "input" wins when both are present (D11): the newer,
|
|
31
|
+
# authoritative name is never overwritten by the retired one. LEGACY_FIELD
|
|
32
|
+
# itself is dropped from the copy either way, so a caller that renders
|
|
33
|
+
# every field it does not recognize (outcome-report's evidence line) never
|
|
34
|
+
# sees the retired key leak through as a second, unrecognized field.
|
|
35
|
+
def fields(fields)
|
|
36
|
+
source = fields || {}
|
|
37
|
+
return source.dup unless source.key?(LEGACY_FIELD)
|
|
38
|
+
|
|
39
|
+
mapped = source.dup
|
|
40
|
+
unless present?(mapped["input"])
|
|
41
|
+
mapped["input"] = mapped[LEGACY_FIELD]
|
|
42
|
+
end
|
|
43
|
+
mapped.delete(LEGACY_FIELD)
|
|
44
|
+
mapped
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# 338a n3 (D8): the new path when it exists, the legacy directory and
|
|
48
|
+
# suffix when only that file exists, and the new path when neither does
|
|
49
|
+
# (a fresh attempt names its own soon-to-be-written destination).
|
|
50
|
+
def input_path(intent_dir:, node:, attempt:)
|
|
51
|
+
new_path = File.join(intent_dir, "attempts", "#{node}--a#{attempt}.input")
|
|
52
|
+
return new_path if File.exist?(new_path)
|
|
53
|
+
|
|
54
|
+
legacy_path = File.join(intent_dir, LEGACY_DIRECTORY, "#{node}--a#{attempt}#{LEGACY_SUFFIX}")
|
|
55
|
+
File.exist?(legacy_path) ? legacy_path : new_path
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def present?(value)
|
|
59
|
+
!(value.nil? || value.to_s.strip.empty?)
|
|
60
|
+
end
|
|
61
|
+
private_class_method :present?
|
|
62
|
+
end
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "strscan"
|
|
5
|
+
require_relative "savepoint"
|
|
6
|
+
require_relative "guarded_append"
|
|
7
|
+
require_relative "node_input_compatibility"
|
|
8
|
+
|
|
9
|
+
# NodeLedger - the node and intent transition line (intent 335, G2). Owns the
|
|
10
|
+
# line's byte-exact format, the closed state vocabulary, the required fields per
|
|
11
|
+
# state, the parser, torn-line and unattributed-line detection, status as the
|
|
12
|
+
# last line per subject in file order, and the guarded transition append that
|
|
13
|
+
# skips dedup entirely (spec D11).
|
|
14
|
+
#
|
|
15
|
+
# Per spec D17 the subject vocabulary (`Savepoint::INTENT_SUBJECT`,
|
|
16
|
+
# `Savepoint::NODE_SUBJECT_RE`, `Savepoint.transition_candidate?`) lives on
|
|
17
|
+
# Savepoint, not here: this file requires savepoint.rb and reuses them, the
|
|
18
|
+
# dependency running one way only (test/savepoint_split_test.rb:57 pins
|
|
19
|
+
# savepoint.rb to loading no other project file).
|
|
20
|
+
#
|
|
21
|
+
# Pure and dependency-injected: every path and clock is an argument, `guard:` is
|
|
22
|
+
# an injectable seam for the transition append, and this module reads no
|
|
23
|
+
# environment variable.
|
|
24
|
+
module NodeLedger
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Ten states, closed (spec D1). "ready" is computed and never written.
|
|
28
|
+
STATES = %w[
|
|
29
|
+
planned running done failed_verification needs_decision blocked deferred
|
|
30
|
+
superseded abandoned reclaimed
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
# A written state's RESOLVED state for the ready function 336 builds on top of
|
|
34
|
+
# this ledger (spec D2): every state resolves to itself except `reclaimed`,
|
|
35
|
+
# which resolves to `planned` ("a reclaimed node is planned again").
|
|
36
|
+
RESOLUTION = { "reclaimed" => "planned" }.freeze
|
|
37
|
+
|
|
38
|
+
# Required fields per state (spec D4), refused by #append_transition and read
|
|
39
|
+
# as torn by #torn? when absent. `done`'s evidence requirement (gates= plus at
|
|
40
|
+
# least one of commit=/verdict=) is asymmetric, so it is not representable as
|
|
41
|
+
# a flat list; DONE_EVIDENCE_FIELDS below carries the "at least one of" half.
|
|
42
|
+
REQUIRED_FIELDS = {
|
|
43
|
+
"planned" => [],
|
|
44
|
+
"running" => %w[holder expires input model],
|
|
45
|
+
"done" => %w[gates],
|
|
46
|
+
"failed_verification" => %w[gates reason],
|
|
47
|
+
"needs_decision" => %w[question],
|
|
48
|
+
"blocked" => %w[reason],
|
|
49
|
+
"deferred" => %w[reason],
|
|
50
|
+
"superseded" => %w[by],
|
|
51
|
+
"abandoned" => %w[reason],
|
|
52
|
+
"reclaimed" => %w[holder expired],
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# `done` requires gates= plus at least one of these (spec D7). `model=` is
|
|
56
|
+
# deliberately absent from every required-fields list except `running` (spec
|
|
57
|
+
# D6) and is accepted, never required, everywhere else - no separate table
|
|
58
|
+
# entry is needed for that: an unlisted field is never required.
|
|
59
|
+
DONE_EVIDENCE_FIELDS = %w[commit verdict].freeze
|
|
60
|
+
|
|
61
|
+
# Canonical field render order (spec D8's "stable declared order", matrix
|
|
62
|
+
# 2.6): every key named in REQUIRED_FIELDS or DONE_EVIDENCE_FIELDS, plus
|
|
63
|
+
# `model` (accepted on any state). A field outside this list still renders,
|
|
64
|
+
# sorted after these, so an unrecognized key is never silently dropped.
|
|
65
|
+
#
|
|
66
|
+
# `harness` (intent 340b, G7c, n1, D21) sits right after `model`: both are
|
|
67
|
+
# dispatch-time metadata `running` writes and every terminal transition
|
|
68
|
+
# carries forward, accepted on every state and required by none - the same
|
|
69
|
+
# treatment `suite=`, `hop=` and `core_drift=` already get as extras, given
|
|
70
|
+
# a stable declared position instead of falling to the alphabetical extras
|
|
71
|
+
# tail (matrix row 1.17).
|
|
72
|
+
FIELD_ORDER = %w[holder expires input model harness gates commit verdict reason question by expired].freeze
|
|
73
|
+
|
|
74
|
+
SEPARATOR = " "
|
|
75
|
+
|
|
76
|
+
# The field key character class, shared by the emitter (#render_fields, below) and
|
|
77
|
+
# the parser (FIELD_TOKEN_RE, further down): a key outside this class is a key the
|
|
78
|
+
# parser can never read back as a pair, so it must never be emitted (post-execution
|
|
79
|
+
# review row 7.7). One source of truth for the char class keeps the two families
|
|
80
|
+
# from drifting the way they did before this row's fix.
|
|
81
|
+
FIELD_KEY_CHARS = "[a-z_]+"
|
|
82
|
+
FIELD_KEY_RE = /\A#{FIELD_KEY_CHARS}\z/.freeze
|
|
83
|
+
|
|
84
|
+
# timestamp<SEP>subject<SEP>state field=value... (comment). Two-space
|
|
85
|
+
# separators keep the line a three-field line under both `split(/\s{2,}/)`
|
|
86
|
+
# and every reader's own `SAVEPOINT_RE` (spec "Line shape"). Raises
|
|
87
|
+
# ArgumentError, writing nothing, for an unknown state or a missing required
|
|
88
|
+
# field (D4/D9): the caller of #append_transition relies on this check
|
|
89
|
+
# running BEFORE the file is ever touched.
|
|
90
|
+
def transition_line(subject:, state:, fields: {}, comment: nil, now: Time.now)
|
|
91
|
+
state = state.to_s
|
|
92
|
+
raise ArgumentError, "unknown transition state: #{state.inspect}" unless STATES.include?(state)
|
|
93
|
+
|
|
94
|
+
missing = missing_fields(state, fields)
|
|
95
|
+
raise ArgumentError, "state #{state} requires #{missing.join(', ')}" if missing.any?
|
|
96
|
+
|
|
97
|
+
timestamp = now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
98
|
+
rest = ([state] + render_fields(fields)).join(" ")
|
|
99
|
+
rest = "#{rest} (#{normalize_value(comment)})" if comment && !comment.to_s.strip.empty?
|
|
100
|
+
"#{timestamp}#{SEPARATOR}#{subject}#{SEPARATOR}#{rest}\n"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Collapse every run of two-or-more WHITESPACE characters in `text` to one
|
|
104
|
+
# (spec "Line shape": "collapses every whitespace run inside a field value
|
|
105
|
+
# to a single space"), so a caller-supplied value or comment can never
|
|
106
|
+
# reintroduce a two-or-more run the line's own separators rely on being
|
|
107
|
+
# unique to field boundaries. A tab or a newline is refused outright (raises
|
|
108
|
+
# ArgumentError) rather than collapsed: silently eating either would hide a
|
|
109
|
+
# value that could otherwise tear a ledger line. Post-execution review row
|
|
110
|
+
# 7.6: a bare `/ {2,}/` collapsed only literal spaces, so a carriage return,
|
|
111
|
+
# vertical tab or form feed beside a space still reached field 3 as a
|
|
112
|
+
# two-whitespace run that `split(/\s{2,}/)` reads as four fields while
|
|
113
|
+
# SAVEPOINT_RE reads as three - `\s{2,}` is what the acceptance criterion
|
|
114
|
+
# ("no run of two or more whitespace characters ever reaches field 3")
|
|
115
|
+
# actually requires.
|
|
116
|
+
def normalize_value(text)
|
|
117
|
+
value = text.to_s
|
|
118
|
+
raise ArgumentError, "value must not contain a tab or a newline: #{value.inspect}" if value =~ /[\t\n]/
|
|
119
|
+
|
|
120
|
+
value.gsub(/\s{2,}/, " ")
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# A required-field D4 check independent of vocabulary validity: given a state
|
|
124
|
+
# (assumed valid) and a fields hash, the list of required field names still
|
|
125
|
+
# missing. `done`'s "at least one of commit=/verdict=" half renders as the
|
|
126
|
+
# literal string "commit or verdict" when both are absent.
|
|
127
|
+
def missing_fields(state, fields)
|
|
128
|
+
normalized = stringify_keys(fields)
|
|
129
|
+
required = REQUIRED_FIELDS.fetch(state.to_s, [])
|
|
130
|
+
missing = required.reject { |key| present?(normalized[key]) }
|
|
131
|
+
if state.to_s == "done" && DONE_EVIDENCE_FIELDS.none? { |key| present?(normalized[key]) }
|
|
132
|
+
missing += ["commit or verdict"]
|
|
133
|
+
end
|
|
134
|
+
missing
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def present?(value)
|
|
138
|
+
!(value.nil? || value.to_s.strip.empty?)
|
|
139
|
+
end
|
|
140
|
+
private_class_method :present?
|
|
141
|
+
|
|
142
|
+
def stringify_keys(fields)
|
|
143
|
+
(fields || {}).each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
144
|
+
end
|
|
145
|
+
private_class_method :stringify_keys
|
|
146
|
+
|
|
147
|
+
def render_fields(fields)
|
|
148
|
+
normalized = stringify_keys(fields)
|
|
149
|
+
unreadable = normalized.keys.reject { |key| key.match?(FIELD_KEY_RE) }
|
|
150
|
+
if unreadable.any?
|
|
151
|
+
raise ArgumentError, "field key(s) the parser cannot read back: #{unreadable.join(', ')}"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
known = FIELD_ORDER.select { |key| normalized.key?(key) }
|
|
155
|
+
extra = (normalized.keys - FIELD_ORDER).sort
|
|
156
|
+
(known + extra).map { |key| "#{key}=#{render_value(normalized[key])}" }
|
|
157
|
+
end
|
|
158
|
+
private_class_method :render_fields
|
|
159
|
+
|
|
160
|
+
def render_value(value)
|
|
161
|
+
text = normalize_value(value)
|
|
162
|
+
return text if bare_safe?(text)
|
|
163
|
+
|
|
164
|
+
escaped = text.gsub("\\") { "\\\\" }.gsub('"') { "\\\"" }
|
|
165
|
+
"\"#{escaped}\""
|
|
166
|
+
end
|
|
167
|
+
private_class_method :render_value
|
|
168
|
+
|
|
169
|
+
def bare_safe?(text)
|
|
170
|
+
!text.empty? && !text.match?(/[\s"\\]/)
|
|
171
|
+
end
|
|
172
|
+
private_class_method :bare_safe?
|
|
173
|
+
|
|
174
|
+
# --- Parsing ---------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
# timestamp<2sp>subject<2sp>rest, mirroring intent_screen.rb's SAVEPOINT_RE
|
|
177
|
+
# (the same three-field contract every reader in the tree already assumes).
|
|
178
|
+
TRANSITION_LINE_RE = /\A(\S+)#{SEPARATOR}(\S+)#{SEPARATOR}(.+?)\s*\z/.freeze
|
|
179
|
+
private_constant :TRANSITION_LINE_RE
|
|
180
|
+
|
|
181
|
+
FIELD_TOKEN_RE = /(#{FIELD_KEY_CHARS})=("(?:[^"\\]|\\.)*"|[^\s"]+)/.freeze
|
|
182
|
+
private_constant :FIELD_TOKEN_RE
|
|
183
|
+
|
|
184
|
+
# Parse one raw ledger line into {timestamp:, subject:, state:, fields:,
|
|
185
|
+
# comment:, raw:}, or nil when it does not even split into the three fields
|
|
186
|
+
# every transition line has. `#scrub`s the line first (mirrors
|
|
187
|
+
# SessionLedger.parse_checklist_line) so a stray non-UTF-8 byte anywhere in
|
|
188
|
+
# the ledger never raises out of the parser and takes down every reader
|
|
189
|
+
# (matrix 2.44); it only ever affects that one line's parsed text.
|
|
190
|
+
def parse_transition_line(line)
|
|
191
|
+
raw = line.to_s.chomp.scrub
|
|
192
|
+
m = TRANSITION_LINE_RE.match(raw)
|
|
193
|
+
return nil unless m
|
|
194
|
+
|
|
195
|
+
timestamp, subject, rest = m.captures
|
|
196
|
+
state, remainder = rest.split(/\s+/, 2)
|
|
197
|
+
fields, comment = scan_fields(remainder.to_s)
|
|
198
|
+
fields = NodeInputCompatibility.fields(fields)
|
|
199
|
+
{ timestamp: timestamp, subject: subject, state: state, fields: fields, comment: comment, raw: raw }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Scan `rest` (everything after the state token) for `key=value` pairs, quote-
|
|
203
|
+
# and-escape aware, greedily from the start; the first token that is not a
|
|
204
|
+
# pair, and everything after it, is the trailing comment (spec D8), with one
|
|
205
|
+
# layer of wrapping parens stripped when present (the shape #transition_line
|
|
206
|
+
# itself always emits). Returns [fields_hash, comment_or_nil].
|
|
207
|
+
def scan_fields(rest)
|
|
208
|
+
scanner = StringScanner.new(rest)
|
|
209
|
+
fields = {}
|
|
210
|
+
loop do
|
|
211
|
+
scanner.skip(/\s+/)
|
|
212
|
+
break if scanner.eos?
|
|
213
|
+
|
|
214
|
+
start_pos = scanner.pos
|
|
215
|
+
if scanner.scan(FIELD_TOKEN_RE)
|
|
216
|
+
fields[scanner[1]] = unquote(scanner[2])
|
|
217
|
+
else
|
|
218
|
+
scanner.pos = start_pos
|
|
219
|
+
break
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
tail = scanner.rest.to_s.strip
|
|
223
|
+
comment = tail.empty? ? nil : tail.sub(/\A\((.*)\)\z/m, '\1')
|
|
224
|
+
[fields, comment]
|
|
225
|
+
end
|
|
226
|
+
private_class_method :scan_fields
|
|
227
|
+
|
|
228
|
+
def unquote(raw)
|
|
229
|
+
return raw unless raw.start_with?('"')
|
|
230
|
+
|
|
231
|
+
raw[1..-2].gsub(/\\(.)/) { Regexp.last_match(1) }
|
|
232
|
+
end
|
|
233
|
+
private_class_method :unquote
|
|
234
|
+
|
|
235
|
+
# A transition line is torn (spec D9) when it does not parse into the three
|
|
236
|
+
# fields at all, when its state token is outside STATES, or when its state is
|
|
237
|
+
# in STATES but a field REQUIRED_FIELDS demands is missing. The second half
|
|
238
|
+
# matters more than the first: a crash-truncated write usually keeps a valid
|
|
239
|
+
# state token and loses the tail (`n1 running holder=auto-ce5`), which is
|
|
240
|
+
# the realistic torn line, not a truncated state token.
|
|
241
|
+
def torn?(line)
|
|
242
|
+
parsed = parse_transition_line(line)
|
|
243
|
+
return true unless parsed
|
|
244
|
+
return true unless parsed[:state] && STATES.include?(parsed[:state])
|
|
245
|
+
|
|
246
|
+
missing_fields(parsed[:state], parsed[:fields]).any?
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def torn_reason(parsed)
|
|
250
|
+
return "does not parse as timestamp subject state ..." unless parsed && parsed[:state]
|
|
251
|
+
return "unknown state #{parsed[:state].inspect}" unless STATES.include?(parsed[:state])
|
|
252
|
+
|
|
253
|
+
"missing required field(s): #{missing_fields(parsed[:state], parsed[:fields]).join(', ')}"
|
|
254
|
+
end
|
|
255
|
+
private_class_method :torn_reason
|
|
256
|
+
|
|
257
|
+
# A transition line is attributed (spec D10) iff it carries a non-blank
|
|
258
|
+
# `holder=` field. Unattributed and torn are independent: a line can be
|
|
259
|
+
# unattributed and otherwise well formed at the same time.
|
|
260
|
+
def attributed?(parsed)
|
|
261
|
+
return false unless parsed
|
|
262
|
+
|
|
263
|
+
present?((parsed[:fields] || {})["holder"])
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def resolved_state(state)
|
|
267
|
+
RESOLUTION.fetch(state.to_s, state.to_s)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- Reading the ledger ------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
# Every transition candidate line in `path`, in file order, each annotated
|
|
273
|
+
# with its parse, torn-ness, and attribution. Non-candidate lines (a stage
|
|
274
|
+
# line, a Lock takeover line) are excluded entirely: they are never a
|
|
275
|
+
# transition and are never reported as torn by this module (spec Acceptance
|
|
276
|
+
# Criteria; Savepoint's own phantom detector covers the stage family).
|
|
277
|
+
# `#scrub`s the whole file up front (matrix 2.44) so one bad byte anywhere
|
|
278
|
+
# never raises out of this reader.
|
|
279
|
+
def entries(path)
|
|
280
|
+
return [] unless path && File.exist?(path)
|
|
281
|
+
|
|
282
|
+
entries_from_content(File.read(path))
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Same as #entries, but over an in-memory string rather than a path (post-
|
|
286
|
+
# execution review row 7.1/7.2): the readiness decision `node-transition`
|
|
287
|
+
# makes for `running` must be evaluated against the exact content
|
|
288
|
+
# GuardedAppend read under its lock hold, never a re-read of the path (which
|
|
289
|
+
# could observe a different file than the one the guard is holding closed
|
|
290
|
+
# against other writers, and reintroduces the check-then-append gap this row
|
|
291
|
+
# exists to close).
|
|
292
|
+
def entries_from_content(content)
|
|
293
|
+
content.to_s.scrub.each_line.filter_map do |raw|
|
|
294
|
+
line = raw.chomp
|
|
295
|
+
next nil if line.strip.empty?
|
|
296
|
+
next nil unless Savepoint.transition_candidate?(line)
|
|
297
|
+
|
|
298
|
+
parsed = parse_transition_line(line)
|
|
299
|
+
torn = torn?(line)
|
|
300
|
+
{
|
|
301
|
+
raw: line,
|
|
302
|
+
timestamp: parsed && parsed[:timestamp],
|
|
303
|
+
subject: parsed ? parsed[:subject] : line.split(/\s{2,}/)[1],
|
|
304
|
+
state: parsed && parsed[:state],
|
|
305
|
+
fields: parsed ? parsed[:fields] : {},
|
|
306
|
+
comment: parsed && parsed[:comment],
|
|
307
|
+
torn: torn,
|
|
308
|
+
attributed: parsed ? attributed?(parsed) : false,
|
|
309
|
+
}
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Status per subject: the last NON-TORN line for that subject in FILE order
|
|
314
|
+
# (never by timestamp, spec Acceptance Criteria), resolved (spec D2). An
|
|
315
|
+
# unattributed-but-well-formed line still counts for status (D10: status
|
|
316
|
+
# shows it; only the readiness check ignores it).
|
|
317
|
+
def status(path)
|
|
318
|
+
status_from_content(path && File.exist?(path) ? File.read(path) : "")
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Content-based counterpart to #status (see #entries_from_content).
|
|
322
|
+
def status_from_content(content)
|
|
323
|
+
entries_from_content(content).each_with_object({}) do |entry, memo|
|
|
324
|
+
next if entry[:torn]
|
|
325
|
+
|
|
326
|
+
memo[entry[:subject]] = resolved_state(entry[:state])
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# A subject with no line at all reads as "planned" (spec D1's implicit
|
|
331
|
+
# starting state), never raises.
|
|
332
|
+
def status_for(path, subject)
|
|
333
|
+
status(path).fetch(subject.to_s, "planned")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Content-based counterpart to #status_for (see #entries_from_content).
|
|
337
|
+
def status_for_content(content, subject)
|
|
338
|
+
status_from_content(content).fetch(subject.to_s, "planned")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# The last (file-order) non-torn `running` entry for `subject`, or nil. Used
|
|
342
|
+
# by the reclaim precondition (S3) to find the `expires=` this subject's live
|
|
343
|
+
# dispatch carries.
|
|
344
|
+
def last_running(path, subject)
|
|
345
|
+
entries(path).select { |e| !e[:torn] && e[:subject] == subject.to_s && e[:state] == "running" }.last
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Torn and unattributed lines, each paired with a reason (spec C2/C5's
|
|
349
|
+
# reader): [{line:, reason:}, ...]. A clean or absent ledger returns []. A
|
|
350
|
+
# torn line is reported once, as torn; a well-formed unattributed line is
|
|
351
|
+
# reported once, as unattributed - the two categories never conflate.
|
|
352
|
+
def anomalies(path)
|
|
353
|
+
entries(path).filter_map do |entry|
|
|
354
|
+
if entry[:torn]
|
|
355
|
+
{ line: entry[:raw], reason: "torn: #{torn_reason(parse_transition_line(entry[:raw]))}" }
|
|
356
|
+
elsif !entry[:attributed]
|
|
357
|
+
{ line: entry[:raw], reason: "unattributed (no holder=)" }
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# --- Writing -----------------------------------------------------------------
|
|
363
|
+
|
|
364
|
+
# Refuse an unknown state or a missing required field BEFORE touching the
|
|
365
|
+
# file (spec C20), then append through `guard` (default GuardedAppend,
|
|
366
|
+
# strict), skipping dedup entirely (spec D11: transition lines never consult
|
|
367
|
+
# savepoint_recorded_pairs). Returns whatever `guard.call` returns
|
|
368
|
+
# (:written or :refused); propagates GuardedAppend::Unavailable rather than
|
|
369
|
+
# swallowing it (a caller must never believe a line landed when it did not).
|
|
370
|
+
#
|
|
371
|
+
# `precondition:` (post-execution review rows 7.1-7.3) is an optional
|
|
372
|
+
# callable evaluated INSIDE the guard's lock hold, against the exact
|
|
373
|
+
# `content` the guard just read - never a value captured before the call.
|
|
374
|
+
# When it returns falsy, the block returns nil (GuardedAppend's own
|
|
375
|
+
# refusal contract) and nothing is written; the caller sees :refused,
|
|
376
|
+
# distinguishable from :written, and must not believe the line landed. This
|
|
377
|
+
# is what makes "is the subject still ready" and "append running" atomic
|
|
378
|
+
# against a second writer: a check followed by a separate append never is.
|
|
379
|
+
def append_transition(path, subject:, state:, fields: {}, comment: nil, now: Time.now, guard: GuardedAppend,
|
|
380
|
+
precondition: nil)
|
|
381
|
+
line = transition_line(subject: subject, state: state, fields: fields, comment: comment, now: now)
|
|
382
|
+
guard.call(path, strict: true) do |content|
|
|
383
|
+
precondition && !precondition.call(content) ? nil : line
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_file"
|
|
5
|
+
require_relative "graph_edges"
|
|
6
|
+
require_relative "node_ledger"
|
|
7
|
+
require_relative "intent_screen"
|
|
8
|
+
|
|
9
|
+
# NodeProgress (intent 337a, n1): the graph-era progress reader every report
|
|
10
|
+
# screen's node count calls instead of counting checklist.md items. A
|
|
11
|
+
# graph-era intent (graph.md AND nodes/ both present, D4) counts declared
|
|
12
|
+
# nodes - GraphFile.parse's ## Graph section, via GraphEdges - against
|
|
13
|
+
# NodeLedger.status's last-non-torn-line-per-subject state; a checklist-era
|
|
14
|
+
# intent gets nil, so its screen keeps counting checklist.md exactly as
|
|
15
|
+
# before. No second parser: every number here comes from GraphFile,
|
|
16
|
+
# GraphEdges or NodeLedger, never a hand-rolled scan.
|
|
17
|
+
#
|
|
18
|
+
# Pure: an explicit intent_dir and store_root: in, a fields hash or nil out;
|
|
19
|
+
# no ENV, no Dir.pwd, no raise across the boundary (a malformed record
|
|
20
|
+
# answers nil or zeroed fields, matrix row 1.16).
|
|
21
|
+
module NodeProgress
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Same keys IntentScreen.progress_fields returns, plus "progress.unit".
|
|
25
|
+
# nil for a pre-graph intent (D4), so the caller keeps counting
|
|
26
|
+
# checklist.md.
|
|
27
|
+
def fields(intent_dir, store_root: nil)
|
|
28
|
+
return nil unless graph_era?(intent_dir)
|
|
29
|
+
|
|
30
|
+
nodes = declared_nodes(File.join(intent_dir, "graph.md"))
|
|
31
|
+
return nil if nodes.nil? || nodes.empty?
|
|
32
|
+
|
|
33
|
+
status = NodeLedger.status(File.join(intent_dir, "savepoint.md"))
|
|
34
|
+
superseded = nodes.count { |n| status[n] == "superseded" }
|
|
35
|
+
abandoned = nodes.count { |n| status[n] == "abandoned" }
|
|
36
|
+
remaining = nodes.reject { |n| status[n] == "superseded" || status[n] == "abandoned" }
|
|
37
|
+
done = remaining.count { |n| status[n] == "done" }
|
|
38
|
+
running = remaining.count { |n| status[n] == "running" }
|
|
39
|
+
total = remaining.length
|
|
40
|
+
|
|
41
|
+
inferred = done.zero? && total.positive? && delivered?(intent_dir, store_root)
|
|
42
|
+
done = total if inferred
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
"progress.bar" => bar_for(done, total),
|
|
46
|
+
"progress.done" => done.to_s,
|
|
47
|
+
"progress.total" => total.to_s,
|
|
48
|
+
"progress.note" => note_for(done, total, running, inferred, superseded, abandoned),
|
|
49
|
+
"progress.unit" => "nodes",
|
|
50
|
+
}
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# D4: graph-era means graph.md AND nodes/ both exist.
|
|
56
|
+
def graph_era?(intent_dir)
|
|
57
|
+
File.exist?(File.join(intent_dir, "graph.md")) && Dir.exist?(File.join(intent_dir, "nodes"))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Declared node ids from the ## Graph section (GraphFile/GraphEdges), never
|
|
61
|
+
# from Dir over nodes/. nil for a missing ## Graph section or one that
|
|
62
|
+
# declares no nodes (matrix row 1.16).
|
|
63
|
+
def declared_nodes(graph_md_path)
|
|
64
|
+
parsed = GraphFile.parse(graph_md_path)
|
|
65
|
+
graph = parsed && parsed[:graph]
|
|
66
|
+
return nil unless graph
|
|
67
|
+
|
|
68
|
+
ids = graph[:nodes]
|
|
69
|
+
ids.nil? || ids.empty? ? nil : ids
|
|
70
|
+
end
|
|
71
|
+
private_class_method :declared_nodes
|
|
72
|
+
|
|
73
|
+
def bar_for(done, total)
|
|
74
|
+
width = IntentScreen::BAR_WIDTH
|
|
75
|
+
on = total.zero? ? 0 : (done * width) / total
|
|
76
|
+
(IntentScreen::ON * on) + (IntentScreen::OFF * (width - on))
|
|
77
|
+
end
|
|
78
|
+
private_class_method :bar_for
|
|
79
|
+
|
|
80
|
+
# D8's four exact note strings, plus a superseded/abandoned suffix (n6, B1):
|
|
81
|
+
# a node whose resolved state is superseded or abandoned leaves the total
|
|
82
|
+
# and the numerator, and the note names it, superseded first, appended as
|
|
83
|
+
# ", K superseded" and/or ", J abandoned". Unchanged when there are none of
|
|
84
|
+
# either. The inferred case never carries a suffix: it counts the already-
|
|
85
|
+
# reduced total, so the note stays exactly "inferred: delivered before the
|
|
86
|
+
# node ledger".
|
|
87
|
+
def note_for(done, total, running, inferred, superseded, abandoned)
|
|
88
|
+
return "inferred: delivered before the node ledger" if inferred
|
|
89
|
+
|
|
90
|
+
base =
|
|
91
|
+
if total.zero?
|
|
92
|
+
"no nodes counted"
|
|
93
|
+
else
|
|
94
|
+
open = total - done
|
|
95
|
+
if open.zero?
|
|
96
|
+
"all nodes done"
|
|
97
|
+
elsif running.positive?
|
|
98
|
+
"#{open} nodes open, #{running} running"
|
|
99
|
+
else
|
|
100
|
+
"#{open} nodes open"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
suffix = []
|
|
105
|
+
suffix << "#{superseded} superseded" if superseded.positive?
|
|
106
|
+
suffix << "#{abandoned} abandoned" if abandoned.positive?
|
|
107
|
+
suffix.empty? ? base : "#{base}, #{suffix.join(', ')}"
|
|
108
|
+
end
|
|
109
|
+
private_class_method :note_for
|
|
110
|
+
|
|
111
|
+
# Delivered, proven twice (the inferred fallback's precondition):
|
|
112
|
+
# outcome.md on disk plus either an INDEX ## Completed line (store_root:
|
|
113
|
+
# given) or a Done savepoint line (store_root: nil, the roadmap-entries
|
|
114
|
+
# path that pays for no INDEX read per entry).
|
|
115
|
+
def delivered?(intent_dir, store_root)
|
|
116
|
+
return false unless File.exist?(File.join(intent_dir, "outcome.md"))
|
|
117
|
+
|
|
118
|
+
store_root ? index_completed?(store_root, intent_id(intent_dir)) : done_savepoint_line?(intent_dir)
|
|
119
|
+
end
|
|
120
|
+
private_class_method :delivered?
|
|
121
|
+
|
|
122
|
+
def intent_id(intent_dir)
|
|
123
|
+
File.basename(intent_dir).split("--", 2).first
|
|
124
|
+
end
|
|
125
|
+
private_class_method :intent_id
|
|
126
|
+
|
|
127
|
+
def index_completed?(store_root, id)
|
|
128
|
+
path = File.join(store_root, "INDEX.md")
|
|
129
|
+
return false unless File.exist?(path)
|
|
130
|
+
|
|
131
|
+
content = File.read(path).scrub
|
|
132
|
+
section = content[/^## Completed\n(.*?)(?=^## |\z)/m, 1]
|
|
133
|
+
return false unless section
|
|
134
|
+
|
|
135
|
+
section.each_line.any? { |line| line.strip.match?(/\A-\s*\[#{Regexp.escape(id)}(?=[\s\]])/) }
|
|
136
|
+
end
|
|
137
|
+
private_class_method :index_completed?
|
|
138
|
+
|
|
139
|
+
# Only a Done line whose text begins with "delivered" proves delivered (n6,
|
|
140
|
+
# B3): end-intent writes "Done delivered" or "Done abandoned"
|
|
141
|
+
# (scripts/end-intent, Savepoint.append_terminal_savepoint), and only the
|
|
142
|
+
# first disposition is delivery.
|
|
143
|
+
def done_savepoint_line?(intent_dir)
|
|
144
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
145
|
+
return false unless File.exist?(path)
|
|
146
|
+
|
|
147
|
+
File.read(path).scrub.each_line.any? do |line|
|
|
148
|
+
m = line.strip.match(IntentScreen::SAVEPOINT_RE)
|
|
149
|
+
m && m[2] == "Done" && m[3].to_s.start_with?("delivered")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
private_class_method :done_savepoint_line?
|
|
153
|
+
end
|