@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. package/templates/roadmap.md +13 -0
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # outcome-report - the CLI over scripts/lib/outcome_report.rb (intent 339, G6).
6
+ # Prints the generated outcome.md by default; --write puts it on disk through
7
+ # AtomicWrite. `end-intent` calls the library directly (never shells out to
8
+ # this), so this command is for a human or an agent checking the generated
9
+ # report before or instead of a close.
10
+ #
11
+ # Usage:
12
+ # outcome-report <intent_dir> [--write] [--disposition delivered|abandoned]
13
+ #
14
+ # Exit codes:
15
+ # 0 - printed (or written) successfully
16
+ # 2 - usage error: no path given, an unknown flag, or the path is not an
17
+ # intent directory
18
+ # 3 - the report model could not be built (a malformed or absent graph.md)
19
+
20
+ require_relative "lib/outcome_report"
21
+ require_relative "lib/intent_screen"
22
+ require_relative "lib/outcome_guard"
23
+
24
+ def usage_abort(message)
25
+ warn "outcome-report: #{message}"
26
+ exit 2
27
+ end
28
+
29
+ args = ARGV.dup
30
+ write_flag = false
31
+ disposition = "delivered"
32
+ positional = []
33
+
34
+ while (arg = args.shift)
35
+ case arg
36
+ when "--write"
37
+ write_flag = true
38
+ when "--disposition"
39
+ disposition = args.shift or usage_abort("--disposition needs a value")
40
+ else
41
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
42
+ positional << arg
43
+ end
44
+ end
45
+
46
+ target = positional.first
47
+ usage_abort("usage: outcome-report <intent_dir> [--write] [--disposition delivered|abandoned]") unless target
48
+ # Row v1f.9 (N3): refuse an unknown --disposition here rather than write a
49
+ # record OutcomeGuard then refuses. OutcomeGuard::DISPOSITIONS is already
50
+ # loaded in-process, so this is one comparison, not a duplicated list.
51
+ unless OutcomeGuard::DISPOSITIONS.include?(disposition)
52
+ usage_abort("--disposition must be one of #{OutcomeGuard::DISPOSITIONS.join('|')}, got #{disposition.inspect}")
53
+ end
54
+
55
+ intent_dir = File.expand_path(target)
56
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
57
+
58
+ model = OutcomeReport.model(intent_dir)
59
+ unless model[:ok]
60
+ warn "outcome-report: the report model could not be built: #{model[:errors].join('; ')}"
61
+ exit 3
62
+ end
63
+
64
+ if write_flag
65
+ OutcomeReport.write(intent_dir, disposition: disposition)
66
+ puts File.join(intent_dir, "outcome.md")
67
+ else
68
+ outcome_path = File.join(intent_dir, "outcome.md")
69
+ existing = File.exist?(outcome_path) ? File.read(outcome_path) : nil
70
+ puts OutcomeReport.render(model, disposition: disposition, existing: existing,
71
+ findings: OutcomeReport.findings(intent_dir), intent_dir: intent_dir)
72
+ end
73
+
74
+ exit 0
@@ -70,6 +70,7 @@ until ARGV.empty?
70
70
  when "--model" then opts[:model] = ARGV.shift
71
71
  when "--thread" then opts[:thread] = ARGV.shift
72
72
  when "--mode" then opts[:mode] = ARGV.shift
73
+ when "--allow-inline" then opts[:allow_inline] = true
73
74
  when "--status" then opts[:status] = ARGV.shift
74
75
  else
75
76
  warn "unknown flag #{flag}"
@@ -154,7 +155,8 @@ end
154
155
  case verb
155
156
  when "arm"
156
157
  result = Arm.arm(intent_dir: dir, session: key, mode: opts[:mode] || "auto", home: home,
157
- harness: harness, agent: opts[:agent], model: opts[:model], thread: opts[:thread])
158
+ harness: harness, agent: opts[:agent], model: opts[:model], thread: opts[:thread],
159
+ allow_inline: opts[:allow_inline] || false)
158
160
  case result[:status]
159
161
  when :acquired, :owned
160
162
  wt = result[:worktree] || {}
@@ -178,6 +180,11 @@ when "arm"
178
180
  when :corrupt
179
181
  warn "plastic-lock: delivery.lock for intent #{intent_id} is unreadable; #{doctor_hint('fix the lock', hint_harness)}"
180
182
  exit 1
183
+ when :inline_refused
184
+ warn "plastic-lock: refusing to arm intent #{intent_id} from a conversation session " \
185
+ "(owner rule 2026-08-31: no inline intent delivery). Dispatch the delivery team " \
186
+ "instead (plastic-auto), or pass --allow-inline with explicit owner approval."
187
+ exit 1
181
188
  end
182
189
  when "status"
183
190
  lock = Lock.read(dir)
@@ -14,9 +14,9 @@ require_relative "lib/agent_models"
14
14
  DEFAULTS = {
15
15
  "version" => 3,
16
16
  "stale_threshold_days" => 3,
17
- # Absolute token counts for a 1M window, 35 and 50 percent (intent 312, 296 D38).
18
- "context_offer_tokens" => 350_000,
19
- "context_insist_tokens" => 500_000,
17
+ # Absolute token counts for a 1M window, 15 and 25 percent (intent 355, n5, D7).
18
+ "context_offer_tokens" => 150_000,
19
+ "context_insist_tokens" => 250_000,
20
20
  "execution_mode" => "subagent-driven",
21
21
  "hash_length" => 6,
22
22
  "hash_algorithm" => "sha256-base36",
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # ready-set - the CLI over ReadySet (intent 336, G3). No computed field this
6
+ # intent adds ships without a script that reads it (327 D18, C19): prints the
7
+ # ready order, the blockers for every unready node, the batches, and every
8
+ # maximal-length critical path; emits JSON for a caller like the runner (G7).
9
+ #
10
+ # Usage:
11
+ # ready-set <intent_dir> [--json] [--ranker finish-first|critical-path]
12
+ #
13
+ # Exit codes (matching scripts/validate-work-graph's shape):
14
+ # 0 - printed (or emitted as JSON)
15
+ # 1 - the graph is invalid (missing, cyclic, or a malformed node file)
16
+ # 2 - usage: not an intent directory, or an unknown --ranker value
17
+
18
+ require "json"
19
+ require_relative "lib/ready_set"
20
+ require_relative "lib/savepoint"
21
+
22
+ module ReadySetCli
23
+ module_function
24
+
25
+ RANKERS = {
26
+ "finish-first" => -> { ReadySet::FinishFirstRanker.new },
27
+ "critical-path" => -> { ReadySet::CriticalPathRanker.new },
28
+ }.freeze
29
+
30
+ def usage
31
+ warn "Usage: ready-set <intent_dir> [--json] [--ranker #{RANKERS.keys.join('|')}]"
32
+ end
33
+
34
+ def intent_directory?(dir)
35
+ dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
36
+ end
37
+
38
+ def main(argv)
39
+ args = argv.dup
40
+ json = !!args.delete("--json")
41
+
42
+ ranker_name = nil
43
+ if (i = args.index("--ranker"))
44
+ ranker_name = args[i + 1]
45
+ args.slice!(i, 2)
46
+ end
47
+
48
+ intent_dir_arg = args.shift
49
+ unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
50
+ warn "ready-set: #{intent_dir_arg.inspect} is not an intent directory"
51
+ usage
52
+ return 2
53
+ end
54
+ intent_dir = File.expand_path(intent_dir_arg)
55
+
56
+ ranker = nil
57
+ if ranker_name
58
+ factory = RANKERS[ranker_name]
59
+ unless factory
60
+ warn "ready-set: unknown ranker #{ranker_name.inspect} (use #{RANKERS.keys.join(', ')})"
61
+ return 2
62
+ end
63
+ ranker = factory.call
64
+ end
65
+
66
+ analysis = ReadySet.analyze(intent_dir, ranker: ranker)
67
+ unless analysis[:ok]
68
+ warn "ready-set: #{analysis[:errors].join('; ')}"
69
+ return 1
70
+ end
71
+
72
+ if json
73
+ puts JSON.pretty_generate(json_payload(analysis))
74
+ else
75
+ print_report(analysis)
76
+ end
77
+ 0
78
+ end
79
+
80
+ def json_payload(analysis)
81
+ unready = analysis[:nodes].reject { |_, v| v[:ready] }
82
+ {
83
+ "ready" => analysis[:ranked_ready].map { |r| r[:id] },
84
+ "ranker" => analysis[:ranker_name],
85
+ "batches" => analysis[:batches],
86
+ "critical_path" => analysis[:critical_path],
87
+ "paths" => analysis[:paths],
88
+ "hops" => analysis[:hops],
89
+ "max_paths_total" => analysis[:max_paths_total],
90
+ "blockers" => unready.transform_values { |v| v[:blockers] },
91
+ "errors" => analysis[:errors],
92
+ }
93
+ end
94
+
95
+ def print_report(analysis)
96
+ unready = analysis[:nodes].reject { |_, v| v[:ready] }
97
+
98
+ puts "Ranker: #{analysis[:ranker_name]}"
99
+ puts
100
+ puts "Ready (#{analysis[:ranked_ready].length}):"
101
+ if analysis[:ranked_ready].empty?
102
+ puts " (none)"
103
+ else
104
+ analysis[:ranked_ready].each { |r| puts " #{r[:id]}" }
105
+ end
106
+ puts
107
+ puts "Blockers:"
108
+ if unready.empty?
109
+ puts " (none)"
110
+ else
111
+ unready.each do |id, view|
112
+ puts " #{id}:"
113
+ view[:blockers].each { |b| puts " - #{b}" }
114
+ end
115
+ end
116
+ puts
117
+ puts "Batches:"
118
+ analysis[:batches].each_with_index { |batch, i| puts " #{i + 1}: #{batch.join(', ')}" }
119
+ puts
120
+ puts "Critical path (#{analysis[:hops]} hops): #{(analysis[:critical_path] || []).join(' > ')}"
121
+ puts "Every maximal-length path (#{analysis[:max_paths_total]} total, #{analysis[:paths].length} shown):"
122
+ analysis[:paths].each { |p| puts " #{p.join(' > ')}" }
123
+ end
124
+ end
125
+
126
+ exit(ReadySetCli.main(ARGV)) if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # release-check - deterministic CLI over ReleaseGuard (intent 347).
6
+ #
7
+ # Guards a tagged publish before the workflow ever reaches `npm publish`:
8
+ # the pushed tag must equal `v` plus the version in package.json, the three
9
+ # repo version files must agree, and the runner's npm must meet the 11.5.1
10
+ # floor npm requires for OIDC trusted publishing. Writes the derived
11
+ # dist-tag and version to $GITHUB_OUTPUT, so the alpha/beta/latest rule has
12
+ # exactly one implementation, ReleaseGuard.dist_tag, shared with the test
13
+ # suite.
14
+ #
15
+ # Usage:
16
+ # release-check --tag <tag> --npm-version <version>
17
+ # [--github-output <path>] [--root <dir>]
18
+ #
19
+ # Exit codes: 0 (clean), 1 (violations; reported on stderr, one per line,
20
+ # each naming the specific thing that is wrong), 2 (usage).
21
+
22
+ require "json"
23
+
24
+ require_relative "lib/release_guard"
25
+
26
+ NPM_FLOOR = [11, 5, 1].freeze
27
+ KNOWN_FLAGS = %w[--tag --npm-version --github-output --root].freeze
28
+
29
+ def usage
30
+ warn "usage: release-check --tag <tag> --npm-version <version> [--github-output <path>] [--root <dir>]"
31
+ exit 2
32
+ end
33
+
34
+ def parse_args(argv)
35
+ opts = {}
36
+ i = 0
37
+ while i < argv.length
38
+ flag = argv[i]
39
+ usage unless KNOWN_FLAGS.include?(flag)
40
+
41
+ value = argv[i + 1]
42
+ usage if value.nil?
43
+
44
+ opts[flag] = value
45
+ i += 2
46
+ end
47
+ opts
48
+ end
49
+
50
+ def read_version(path)
51
+ JSON.parse(File.read(path))["version"]
52
+ rescue Errno::ENOENT, JSON::ParserError
53
+ nil
54
+ end
55
+
56
+ opts = parse_args(ARGV)
57
+ usage unless opts["--tag"] && opts["--npm-version"]
58
+
59
+ root = opts["--root"] ? File.expand_path(opts["--root"]) : File.expand_path("..", __dir__)
60
+ package_json_path = File.join(root, "package.json")
61
+ plugin_json_path = File.join(root, ".claude-plugin", "plugin.json")
62
+ marketplace_json_path = File.join(root, ".claude-plugin", "marketplace.json")
63
+
64
+ violations = []
65
+
66
+ canonical_version = read_version(package_json_path)
67
+ violations << "could not read a version from #{package_json_path}" if canonical_version.nil?
68
+
69
+ tag = opts["--tag"].sub(%r{\Arefs/tags/}, "")
70
+ dist_tag = nil
71
+
72
+ if canonical_version
73
+ expected_tag = "v#{canonical_version}"
74
+ if tag != expected_tag
75
+ violations << "tag #{tag} does not match package.json version #{canonical_version} (expected #{expected_tag})"
76
+ end
77
+
78
+ dist_tag = ReleaseGuard.dist_tag(canonical_version)
79
+ stable = dist_tag == "latest"
80
+
81
+ result = ReleaseGuard.check(
82
+ package_json: package_json_path,
83
+ plugin_json: plugin_json_path,
84
+ marketplace_json: marketplace_json_path,
85
+ stable: stable
86
+ )
87
+
88
+ unless result.ok?
89
+ result.mismatches.each do |file|
90
+ violations << "version file #{file} does not agree with package.json (#{canonical_version})"
91
+ end
92
+ if stable && result.prerelease_suffix
93
+ violations << "stable cut #{canonical_version} carries the pre-release suffix -#{result.prerelease_suffix}"
94
+ end
95
+ end
96
+ end
97
+
98
+ npm_version = opts["--npm-version"].to_s.strip
99
+ if npm_version.empty? || !npm_version.match?(/\A\d+(\.\d+)*\z/)
100
+ violations << "npm --version reported #{opts["--npm-version"].inspect}, which is empty or not numeric"
101
+ elsif (npm_version.split(".").map(&:to_i) <=> NPM_FLOOR) < 0
102
+ violations << "npm #{npm_version} is below the 11.5.1 floor OIDC trusted publishing requires"
103
+ end
104
+
105
+ if violations.empty?
106
+ if opts["--github-output"]
107
+ File.open(opts["--github-output"], "a") do |f|
108
+ f.puts "version=#{canonical_version}"
109
+ f.puts "dist_tag=#{dist_tag}"
110
+ end
111
+ end
112
+ puts "OK: tag #{tag}, version #{canonical_version}, dist-tag #{dist_tag}"
113
+ exit 0
114
+ end
115
+
116
+ warn "VIOLATIONS:"
117
+ violations.each { |v| warn "- #{v}" }
118
+ exit 1
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # report-screen - the pre-delivery plan (intent 331b), the four delivery
6
+ # reports: mid-delivery state, post-delivery delivered, and delay (intent
7
+ # 317), plus session, the whole of one session's delivered work followed by
8
+ # the roster (intent 330). Each fills from the record via
9
+ # scripts/lib/report_screen.rb; no number here is written by eye.
10
+ #
11
+ # Usage:
12
+ # report-screen plan <intent_dir> [--ansi]
13
+ # report-screen state <intent_dir> [--changed "<text>"] [--ansi]
14
+ # report-screen state --all <store_root> [--changed "<text>"] [--ansi]
15
+ # report-screen delivered <intent_dir> [--ansi] [--repo <dir>]
16
+ # report-screen delay <intent_dir> [--ansi]
17
+ # report-screen session <tier_root> [--session <id>] [--since <iso>]
18
+ # [--ledger-root <dir>] [--ansi]
19
+ # report-screen roadmap <roadmap.md> plan|state|delivered [--ansi] [--store-root <dir>]
20
+ # report-screen archive <store_root> [--ansi]
21
+ #
22
+ # --ansi delegates to ScreenPaint (intent 317a, D1), the parser/re-layouter
23
+ # in the shared TUI core. Selection is by capability, never by harness:
24
+ # NO_COLOR forces plain, a non-TTY stdout stays plain unless
25
+ # PLASTIC_FORCE_COLOR=1 (the test seam), and an unparseable screen falls
26
+ # open to the plain text untouched.
27
+ #
28
+ # Exit codes:
29
+ # 0 - the screen is on stdout
30
+ # 2 - usage error, or the path/store is not what the verb expects (one line
31
+ # on stderr, stdout empty)
32
+
33
+ require "yaml"
34
+ require "shellwords"
35
+ require_relative "lib/report_screen"
36
+ require_relative "lib/intent_screen"
37
+ require_relative "lib/screen_paint"
38
+ require_relative "lib/session_ledger"
39
+
40
+ # Intent 331a (D6/R8): every caller-added screen kind file registers itself
41
+ # on load (ScreenPaint.register), so this glob is the ONLY wiring a new kind
42
+ # needs - no edit here, no edit to screen_paint.rb. Sorted for a
43
+ # deterministic load order; tolerates the directory being absent or empty
44
+ # (Dir.glob answers [] either way), which is this repo's own state today.
45
+ Dir.glob(File.join(__dir__, "lib", "screens", "*.rb")).sort.each { |f| require_relative f }
46
+
47
+ def usage_abort(message)
48
+ warn "report-screen: #{message}"
49
+ exit 2
50
+ end
51
+
52
+ # The one place allowed to shell out (D2/D20): resolves the tag that CONTAINS
53
+ # the intent's merge commit, in the project's own repository, so the version
54
+ # on the delivered screen is the release that merge shipped in. The
55
+ # repository comes from --repo, else from projects.yml beside the store (the
56
+ # installed layout, <home>/projects/<slug>/store/<id>), else the repository
57
+ # this script lives in (the in-repo layout, `include_self: true`) - unless
58
+ # `include_self: false` (the branch reader, D23), in which case a global
59
+ # intent's repo never falls back to the script's own repository, since that
60
+ # is not the intent's repository at all. No merge sha, no repository, or no
61
+ # containing tag all answer nil, and the screen says "not recorded" rather
62
+ # than guessing from HEAD (D14). The pure module never reads git.
63
+ def resolve_repo(intent_dir, explicit_repo, script_dir, include_self: true)
64
+ candidates = []
65
+ candidates << File.expand_path(explicit_repo) if explicit_repo
66
+ if (m = intent_dir.match(%r{\A(.*)/projects/([^/]+)/store/[^/]+\z}))
67
+ home, slug = m[1], m[2]
68
+ projects_yml = File.join(home, "projects.yml")
69
+ if File.exist?(projects_yml)
70
+ data = begin
71
+ YAML.safe_load(File.read(projects_yml))
72
+ rescue StandardError
73
+ nil
74
+ end
75
+ entry = data.is_a?(Hash) && data["projects"].is_a?(Hash) ? data["projects"][slug] : nil
76
+ path = entry.is_a?(Hash) ? entry["path"].to_s : ""
77
+ candidates << File.expand_path(path) unless path.empty?
78
+ end
79
+ end
80
+ candidates << File.expand_path("..", script_dir) if include_self
81
+ candidates.find { |c| File.exist?(File.join(c, ".git")) }
82
+ end
83
+
84
+ def git_tag_reader(explicit_repo:, script_dir:)
85
+ lambda do |intent_dir|
86
+ sha = ReportScreen.merge_sha(intent_dir)
87
+ return nil unless sha
88
+ repo = resolve_repo(intent_dir, explicit_repo, script_dir)
89
+ return nil unless repo
90
+ tags = `git -C #{repo.shellescape} tag --contains #{sha.shellescape} 2>/dev/null`
91
+ versions = tags.lines.map(&:strip).reject(&:empty?).filter_map do |tag|
92
+ v = tag.sub(/\Av/, "")
93
+ Gem::Version.correct?(v) ? [Gem::Version.new(v), tag] : nil
94
+ end
95
+ versions.min_by(&:first)&.last
96
+ end
97
+ end
98
+
99
+ # D9/D23: the injected branch reader. flow_base (a pure YAML read, never
100
+ # git) wins when the intent's project names a flow base; otherwise the
101
+ # repository's remote HEAD, else whichever of main/master exists. Resolves
102
+ # the repo with `include_self: false` (D23): a global-store intent must
103
+ # never fall back to the script's OWN repository (~/.plastic itself, on
104
+ # "main" in the installed layout) and print a confident, wrong branch.
105
+ def git_branch_reader(explicit_repo:, script_dir:)
106
+ lambda do |intent_dir|
107
+ flow = ReportScreen.flow_base(intent_dir)
108
+ next flow if flow && !flow.to_s.empty?
109
+
110
+ repo = resolve_repo(intent_dir, explicit_repo, script_dir, include_self: false)
111
+ next nil unless repo
112
+
113
+ branch = `git -C #{repo.shellescape} symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null`.strip
114
+ branch = branch.sub(%r{\Aorigin/}, "")
115
+ next branch unless branch.empty?
116
+
117
+ %w[main master].find do |b|
118
+ system("git", "-C", repo, "rev-parse", "--verify", "--quiet", b, out: File::NULL, err: File::NULL)
119
+ end
120
+ end
121
+ end
122
+
123
+ args = ARGV.dup
124
+ verb = args.shift
125
+ changed = nil
126
+ ansi = false
127
+ template_path = nil
128
+ repo_flag = nil
129
+ session_flag = nil
130
+ since_flag = nil
131
+ ledger_root_flag = nil
132
+ store_root_flag = nil
133
+ positional = []
134
+
135
+ while (arg = args.shift)
136
+ case arg
137
+ when "--changed"
138
+ usage_abort("--changed needs a value") if args.empty?
139
+ changed = args.shift
140
+ when "--ansi"
141
+ ansi = true
142
+ when "--all"
143
+ positional << "--all"
144
+ when "--repo"
145
+ repo_flag = args.shift or usage_abort("--repo needs a path")
146
+ when "--template"
147
+ template_path = args.shift or usage_abort("--template needs a path")
148
+ when "--session"
149
+ session_flag = args.shift or usage_abort("--session needs a value")
150
+ when "--since"
151
+ since_flag = args.shift or usage_abort("--since needs a value")
152
+ when "--ledger-root"
153
+ ledger_root_flag = args.shift or usage_abort("--ledger-root needs a path")
154
+ when "--store-root"
155
+ store_root_flag = args.shift or usage_abort("--store-root needs a path")
156
+ else
157
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--") && arg != "--all"
158
+ positional << arg
159
+ end
160
+ end
161
+
162
+ usage_abort("usage: report-screen plan|state|delivered|delay|session <intent_dir> [--changed \"<text>\"] [--ansi]") unless verb
163
+
164
+ all_mode = positional.delete("--all") ? true : false
165
+ target = positional.first
166
+
167
+ ansi_enabled = ansi && ENV["NO_COLOR"].to_s.empty? &&
168
+ ($stdout.tty? || ENV["PLASTIC_FORCE_COLOR"] == "1")
169
+
170
+ def paint(text, ansi_enabled, _renderer_path = nil)
171
+ return text unless ansi_enabled
172
+ ScreenPaint.paint(text, color: true) || text
173
+ end
174
+
175
+ case verb
176
+ when "plan"
177
+ usage_abort("usage: report-screen plan <intent_dir> [--ansi]") unless target
178
+ intent_dir = File.expand_path(target)
179
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
180
+ store_root = File.expand_path("../..", intent_dir)
181
+ template_path ||= File.expand_path("../templates/report-plan.md", __dir__)
182
+ usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
183
+ out = ReportScreen.render_plan(intent_dir: intent_dir, store_root: store_root,
184
+ template: File.read(template_path))
185
+ $stdout.write paint(out, ansi_enabled)
186
+ when "state"
187
+ if all_mode
188
+ usage_abort("usage: report-screen state --all <store_root>") unless target
189
+ store_root = File.expand_path(target)
190
+ usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
191
+ out = ReportScreen.render_roster(store_root, changed: changed)
192
+ $stdout.write paint(out, ansi_enabled)
193
+ else
194
+ usage_abort("usage: report-screen state <intent_dir> [--changed \"<text>\"]") unless target
195
+ intent_dir = File.expand_path(target)
196
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
197
+ store_root = File.expand_path("../..", intent_dir)
198
+ template_path ||= File.expand_path("../templates/report-state.md", __dir__)
199
+ usage_abort("template not found at #{template_path}") unless File.exist?(template_path)
200
+ out = ReportScreen.render_state(intent_dir: intent_dir, store_root: store_root, changed: changed,
201
+ template: File.read(template_path))
202
+ $stdout.write paint(out, ansi_enabled)
203
+ end
204
+ when "delivered"
205
+ usage_abort("usage: report-screen delivered <intent_dir>") unless target
206
+ intent_dir = File.expand_path(target)
207
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
208
+ out = ReportScreen.render_delivered(intent_dir: intent_dir,
209
+ tag_reader: git_tag_reader(explicit_repo: repo_flag, script_dir: __dir__),
210
+ branch_reader: git_branch_reader(explicit_repo: repo_flag, script_dir: __dir__))
211
+ $stdout.write paint(out, ansi_enabled)
212
+ when "delay"
213
+ usage_abort("usage: report-screen delay <intent_dir>") unless target
214
+ intent_dir = File.expand_path(target)
215
+ usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
216
+ out = ReportScreen.render_delay(intent_dir: intent_dir)
217
+ $stdout.write paint(out, ansi_enabled)
218
+ when "session"
219
+ usage_abort("usage: report-screen session <tier_root> [--session <id>] [--since <iso>] [--ledger-root <dir>] [--ansi]") unless target
220
+ store_root = File.expand_path(target)
221
+ usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
222
+
223
+ now = Time.now
224
+ session_id = session_flag || ENV[SessionLedger::SESSION_ID_ENV_KEY]
225
+ ledger_root = ledger_root_flag ? File.expand_path(ledger_root_flag) : ReportScreen.default_ledger_root(store_root)
226
+
227
+ if since_flag
228
+ begin
229
+ Time.parse(since_flag)
230
+ rescue ArgumentError
231
+ usage_abort("--since needs an ISO timestamp, got #{since_flag.inspect}")
232
+ end
233
+ end
234
+
235
+ dirs, skipped = ReportScreen.session_delivered_dirs(ledger_root: ledger_root, tier_root: store_root,
236
+ session: session_id, since: since_flag, now: now)
237
+ # D17: the widened window is announced whether the id was missing or simply
238
+ # matched nothing. A resumed background job carries a session id that is not
239
+ # the one on the ledger lines, and that case must not answer silently. An
240
+ # explicit --since is the caller naming the window, so it needs no note.
241
+ note =
242
+ if since_flag
243
+ nil
244
+ elsif session_id.nil? || session_id.to_s.strip.empty?
245
+ ReportScreen.window_note(ReportScreen.fallback_day(ledger_root, SessionLedger.day_id(now)),
246
+ "no session id given")
247
+ elsif !ReportScreen.session_tagged?(ledger_root: ledger_root, session: session_id, now: now)
248
+ ReportScreen.window_note(ReportScreen.fallback_day(ledger_root, SessionLedger.day_id(now)),
249
+ "this session has no line in the day ledger")
250
+ end
251
+
252
+ out = ReportScreen.render_session(
253
+ dirs: dirs, skipped: skipped, store_root: store_root,
254
+ tag_reader: git_tag_reader(explicit_repo: nil, script_dir: __dir__),
255
+ branch_reader: git_branch_reader(explicit_repo: nil, script_dir: __dir__),
256
+ note: note, now: now, painter: ->(text) { paint(text, ansi_enabled) }
257
+ )
258
+ $stdout.write out
259
+ when "roadmap"
260
+ usage_abort("usage: report-screen roadmap <roadmap.md> plan|state|delivered [--ansi] [--store-root <dir>]") if positional.empty?
261
+ roadmap_path = File.expand_path(positional[0])
262
+ usage_abort("#{roadmap_path} does not exist") unless File.exist?(roadmap_path)
263
+ sub_verb = positional[1]
264
+ usage_abort("usage: report-screen roadmap <roadmap.md> plan|state|delivered") unless sub_verb
265
+ unless %w[plan state delivered].include?(sub_verb)
266
+ usage_abort("unknown sub-verb #{sub_verb.inspect} (use plan|state|delivered)")
267
+ end
268
+ roadmap_store_root = store_root_flag ? File.expand_path(store_root_flag) : nil
269
+ out = ReportScreen.render_roadmap(path: roadmap_path, verb: sub_verb, store_root: roadmap_store_root)
270
+ $stdout.write paint(out, ansi_enabled)
271
+ when "archive"
272
+ usage_abort("usage: report-screen archive <store_root>") unless target
273
+ store_root = File.expand_path(target)
274
+ usage_abort("#{store_root} is not a store (no INDEX.md)") unless File.exist?(File.join(store_root, "INDEX.md"))
275
+ out = ReportScreen.render_archive(store_root)
276
+ $stdout.write paint(out, ansi_enabled)
277
+ else
278
+ usage_abort("unknown verb #{verb.inspect} (use plan|state|delivered|delay|session|roadmap|archive)")
279
+ end
280
+
281
+ exit 0