@zalom/plastic 2.0.0-alpha.27 → 2.0.0-alpha.29

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 (207) hide show
  1. package/PLASTIC.md +13 -139
  2. package/README.md +345 -133
  3. package/agents/plastic-enforcer.md +9 -10
  4. package/agents/plastic-executor.md +1 -1
  5. package/bin/crap +4 -0
  6. package/bin/lib/context_budget.rb +35 -1
  7. package/bin/lib/skill_census.rb +839 -0
  8. package/bin/plastic +6 -0
  9. package/bin/plastic-skill-census +114 -0
  10. package/bin/verify-change +345 -0
  11. package/deprecations.yml +1 -1
  12. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +4 -7
  13. package/{skills/auto/references → docs/help}/agent-architecture.md +7 -7
  14. package/{skills/conventions/references → docs/help}/completion-and-done.md +1 -1
  15. package/{skills/auto/references → docs/help}/human-report-contract.md +17 -19
  16. package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
  17. package/{skills/tutorial/references → docs/help}/track-1-guided.md +8 -8
  18. package/{skills/tutorial/references → docs/help}/track-2-auto.md +5 -5
  19. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  20. package/package.json +3 -2
  21. package/scripts/append-ledger +2 -1
  22. package/scripts/dashboard.rb +10 -9
  23. package/scripts/day-summary +2 -1
  24. package/scripts/doctor.rb +48 -42
  25. package/scripts/end-intent +2 -8
  26. package/scripts/file-session-intent +2 -1
  27. package/scripts/hook-capture +4 -3
  28. package/scripts/hook-close +2 -1
  29. package/scripts/hook-record +3 -2
  30. package/scripts/hook-savepoint +3 -2
  31. package/scripts/hook-session-start +5 -4
  32. package/scripts/hook-stop +2 -1
  33. package/scripts/insight-append +1 -2
  34. package/scripts/install.rb +3 -1
  35. package/scripts/lib/active_delivery.rb +1 -1
  36. package/scripts/lib/arm.rb +2 -1
  37. package/scripts/lib/backup.rb +65 -0
  38. package/scripts/lib/cli/command.rb +85 -0
  39. package/scripts/lib/cli/commands/auto.rb +18 -0
  40. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  41. package/scripts/lib/cli/commands/auto_lock.rb +60 -0
  42. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  43. package/scripts/lib/cli/commands/auto_take.rb +25 -0
  44. package/scripts/lib/cli/commands/backup.rb +43 -0
  45. package/scripts/lib/cli/commands/checkout.rb +25 -0
  46. package/scripts/lib/cli/commands/continue.rb +66 -0
  47. package/scripts/lib/cli/commands/doctor.rb +20 -0
  48. package/scripts/lib/cli/commands/feedback.rb +40 -0
  49. package/scripts/lib/cli/commands/help.rb +69 -0
  50. package/scripts/lib/cli/commands/hook.rb +32 -0
  51. package/scripts/lib/cli/commands/index.rb +23 -0
  52. package/scripts/lib/cli/commands/install.rb +21 -0
  53. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  54. package/scripts/lib/cli/commands/intent.rb +19 -0
  55. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  56. package/scripts/lib/cli/commands/intent_command.rb +53 -0
  57. package/scripts/lib/cli/commands/intent_end.rb +61 -0
  58. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  59. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  60. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  61. package/scripts/lib/cli/commands/intent_show.rb +25 -0
  62. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  63. package/scripts/lib/cli/commands/intent_step.rb +43 -0
  64. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  65. package/scripts/lib/cli/commands/migrate.rb +16 -0
  66. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  67. package/scripts/lib/cli/commands/next.rb +49 -0
  68. package/scripts/lib/cli/commands/project.rb +19 -0
  69. package/scripts/lib/cli/commands/project_links.rb +42 -0
  70. package/scripts/lib/cli/commands/project_list.rb +20 -0
  71. package/scripts/lib/cli/commands/project_new.rb +72 -0
  72. package/scripts/lib/cli/commands/query.rb +31 -0
  73. package/scripts/lib/cli/commands/render.rb +25 -0
  74. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  75. package/scripts/lib/cli/commands/roadmap_check.rb +44 -0
  76. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  77. package/scripts/lib/cli/commands/roadmap_next.rb +34 -0
  78. package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
  79. package/scripts/lib/cli/commands/rollback.rb +20 -0
  80. package/scripts/lib/cli/commands/search.rb +60 -0
  81. package/scripts/lib/cli/commands/session.rb +18 -0
  82. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  83. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  84. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  85. package/scripts/lib/cli/commands/status.rb +68 -0
  86. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  87. package/scripts/lib/cli/commands/sync.rb +46 -0
  88. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  89. package/scripts/lib/cli/commands/update.rb +20 -0
  90. package/scripts/lib/cli/commands/version.rb +53 -0
  91. package/scripts/lib/cli/frontier.rb +84 -0
  92. package/scripts/lib/cli/legacy.rb +50 -0
  93. package/scripts/lib/cli/output.rb +102 -0
  94. package/scripts/lib/cli/scope.rb +127 -0
  95. package/scripts/lib/cli/table.rb +64 -0
  96. package/scripts/lib/cli.rb +94 -0
  97. package/scripts/lib/compact_instructions.rb +8 -0
  98. package/scripts/lib/day_summary.rb +4 -3
  99. package/scripts/lib/doctor_core.rb +7 -32
  100. package/scripts/lib/doctor_session_ledger.rb +2 -1
  101. package/scripts/lib/feedback_report.rb +1 -1
  102. package/scripts/lib/graph_measure_models.rb +3 -1
  103. package/scripts/lib/index_entry.rb +9 -0
  104. package/scripts/lib/installer_core.rb +73 -19
  105. package/scripts/lib/intent_screen.rb +3 -3
  106. package/scripts/lib/lock.rb +2 -2
  107. package/scripts/lib/node_input.rb +3 -2
  108. package/scripts/lib/preflight.rb +4 -6
  109. package/scripts/lib/project_config.rb +2 -1
  110. package/scripts/lib/project_validator.rb +3 -2
  111. package/scripts/lib/qmd_sync.rb +8 -7
  112. package/scripts/lib/reference_archive.rb +45 -0
  113. package/scripts/lib/release_guard.rb +2 -0
  114. package/scripts/lib/report_screen.rb +4 -3
  115. package/scripts/lib/rlm/corpus.rb +13 -0
  116. package/scripts/lib/rlm/probe.rb +29 -0
  117. package/scripts/lib/rlm/query.rb +22 -0
  118. package/scripts/lib/roadmap_queue.rb +2 -2
  119. package/scripts/lib/roadmap_savepoint.rb +1 -1
  120. package/scripts/lib/runner_absorb.rb +3 -2
  121. package/scripts/lib/search_index.rb +55 -0
  122. package/scripts/lib/session_git.rb +4 -3
  123. package/scripts/lib/sqlite.rb +22 -0
  124. package/scripts/lib/store_discovery.rb +7 -6
  125. package/scripts/lib/store_layout.rb +54 -0
  126. package/scripts/lib/store_provisioning.rb +2 -1
  127. package/scripts/lib/store_sync.rb +85 -0
  128. package/scripts/lib/stores_move.rb +93 -0
  129. package/scripts/lib/verify_intent.rb +2 -7
  130. package/scripts/lib/version_number.rb +48 -0
  131. package/scripts/lib/work_graph.rb +59 -0
  132. package/scripts/lib/worktree.rb +3 -8
  133. package/scripts/lib/worktree_sweep.rb +3 -2
  134. package/scripts/link-suggest +2 -1
  135. package/scripts/migrate-to-global +1 -1
  136. package/scripts/new-intent +3 -12
  137. package/scripts/plastic-lock +3 -2
  138. package/scripts/promote-session-item +3 -2
  139. package/scripts/release-check +10 -5
  140. package/scripts/report-screen +1 -1
  141. package/scripts/session-commit +2 -1
  142. package/scripts/spawn-preamble +2 -2
  143. package/scripts/update.rb +25 -4
  144. package/scripts/write-handoff +2 -1
  145. package/templates/agents.md +6 -6
  146. package/templates/render.css +10 -0
  147. package/bin/plastic.js +0 -70
  148. package/skills/agent-advisor/SKILL.md +0 -84
  149. package/skills/auto/SKILL.md +0 -297
  150. package/skills/auto/evals/evals.json +0 -255
  151. package/skills/auto/references/end-tail.md +0 -64
  152. package/skills/conventions/SKILL.md +0 -29
  153. package/skills/dashboard/SKILL.md +0 -180
  154. package/skills/dashboard/evals/evals.json +0 -38
  155. package/skills/dashboard/references/classification.md +0 -22
  156. package/skills/dashboard/templates/dashboard-global.md +0 -20
  157. package/skills/dashboard/templates/dashboard-project.md +0 -19
  158. package/skills/direct/SKILL.md +0 -66
  159. package/skills/direct/references/request-signals.md +0 -59
  160. package/skills/doctor/SKILL.md +0 -305
  161. package/skills/doctor/report.md +0 -102
  162. package/skills/feedback/SKILL.md +0 -98
  163. package/skills/feedback/references/transport-and-privacy.md +0 -65
  164. package/skills/feedback/report.md +0 -36
  165. package/skills/install/SKILL.md +0 -215
  166. package/skills/intent-continuing/SKILL.md +0 -156
  167. package/skills/intent-continuing/references/board-fill.md +0 -52
  168. package/skills/intent-continuing/references/boarding-matrix.md +0 -35
  169. package/skills/intent-continuing/references/context-management.md +0 -28
  170. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  171. package/skills/intent-creating/SKILL.md +0 -89
  172. package/skills/intent-creating/evals/evals.json +0 -72
  173. package/skills/intent-creating/references/lifecycle.md +0 -81
  174. package/skills/intent-creating/references/wikilinks.md +0 -8
  175. package/skills/intent-ending/SKILL.md +0 -182
  176. package/skills/intent-ending/evals/evals.json +0 -74
  177. package/skills/intent-executing/SKILL.md +0 -87
  178. package/skills/intent-executing/evals/evals.json +0 -66
  179. package/skills/intent-executing/implementer-prompt.md +0 -47
  180. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  181. package/skills/intent-speccing/SKILL.md +0 -136
  182. package/skills/intent-speccing/evals/evals.json +0 -126
  183. package/skills/intent-speccing/references/design-principles.md +0 -44
  184. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  185. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  186. package/skills/project-creating/SKILL.md +0 -162
  187. package/skills/project-creating/references/hubs-projects.md +0 -55
  188. package/skills/project-creating/references/project-scaffolding.md +0 -97
  189. package/skills/releasing/SKILL.md +0 -376
  190. package/skills/releasing/references/deprecations.md +0 -60
  191. package/skills/releasing/references/promotion-and-tagging.md +0 -70
  192. package/skills/releasing/references/release-lines.md +0 -105
  193. package/skills/roadmap/SKILL.md +0 -90
  194. package/skills/roadmap/references/file-format.md +0 -134
  195. package/skills/roadmap/references/operations.md +0 -112
  196. package/skills/rollback/SKILL.md +0 -91
  197. package/skills/tutorial/SKILL.md +0 -66
  198. package/skills/tutorial/evals/evals.json +0 -186
  199. package/skills/uninstall/SKILL.md +0 -75
  200. package/skills/update/SKILL.md +0 -126
  201. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  202. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  203. /package/{skills/conventions/references → docs/help}/knowledge-graph.md +0 -0
  204. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  205. /package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +0 -0
  206. /package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +0 -0
  207. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command"
4
+ require_relative "../legacy"
5
+
6
+ # `plastic session handoff` - writes this session's hand-off into the day
7
+ # ledger (`write-handoff --trigger tick`, run through Legacy).
8
+ module Plastic
9
+ class CLI
10
+ module Commands
11
+ class SessionHandoff < Command
12
+ USAGE_LINE = "plastic session handoff [--json]"
13
+
14
+ def initialize(argv, runner: nil, **streams)
15
+ super(argv, **streams)
16
+ @runner = runner
17
+ end
18
+
19
+ def call
20
+ status = legacy.run("write-handoff", "--trigger", "tick")
21
+ raise Failure, "write-handoff exited #{status}" unless status.zero?
22
+
23
+ @output.next_step("plastic session summary", because: "the summary is what a fresh handoff feeds")
24
+ end
25
+
26
+ private
27
+
28
+ def legacy
29
+ @legacy ||= Legacy.new(env: @env, runner: @runner)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command"
4
+ require_relative "../legacy"
5
+
6
+ # `plastic session summary` - the block SessionStart injects at boot: open
7
+ # items, the last five done, live auto intents, other active sessions
8
+ # (`day-summary`, run through Legacy).
9
+ module Plastic
10
+ class CLI
11
+ module Commands
12
+ class SessionSummary < Command
13
+ USAGE_LINE = "plastic session summary [--json]"
14
+
15
+ def initialize(argv, runner: nil, **streams)
16
+ super(argv, **streams)
17
+ @runner = runner
18
+ end
19
+
20
+ def call
21
+ status = legacy.run("day-summary")
22
+ raise Failure, "day-summary exited #{status}" unless status.zero?
23
+
24
+ @output.next_step("plastic status", because: "the board is where the summary's open items are worked from")
25
+ end
26
+
27
+ private
28
+
29
+ def legacy
30
+ @legacy ||= Legacy.new(env: @env, runner: @runner)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command"
4
+
5
+ # `plastic status` - every store this machine holds, the intents each one has
6
+ # open, and the one store worth continuing.
7
+ #
8
+ # The store to continue is chosen by a fixed rule, so two runs never disagree:
9
+ # the store whose repository holds the working directory, else the store with
10
+ # the most active intents, and on a tie the first in discovery order, which puts
11
+ # the global store ahead of the projects and the projects in name order.
12
+ module Plastic
13
+ class CLI
14
+ module Commands
15
+ class Status < Command
16
+ USAGE_LINE = "plastic status [--json]"
17
+
18
+ def call
19
+ counted.each { |slug, ids| @output.row(slug, summary(ids)) }
20
+ command, because = decision
21
+ @output.next_step(command, because: because)
22
+ end
23
+
24
+ private
25
+
26
+ def counted
27
+ @counted ||= scope.stores.to_h do |store|
28
+ slug = store[:slug]
29
+ [slug, Scope.new(env: @env, home: @home, slug: slug, directory: @directory).active_ids]
30
+ end
31
+ end
32
+
33
+ def summary(ids)
34
+ return "0 active" if ids.empty?
35
+
36
+ "#{ids.length} active #{ids.join(", ")}"
37
+ end
38
+
39
+ def decision
40
+ here = scope.directory_project
41
+ return [continue_command(here), "the working directory is inside #{here}"] if busy?(here)
42
+
43
+ chosen = busiest
44
+ return ["plastic help", "no store has active work"] unless chosen
45
+
46
+ [continue_command(chosen), "#{chosen} holds the most active work, #{intents(chosen)}"]
47
+ end
48
+
49
+ def busy?(slug)
50
+ slug && !counted.fetch(slug, []).empty?
51
+ end
52
+
53
+ def busiest
54
+ counted.reject { |_slug, ids| ids.empty? }.max_by { |_slug, ids| ids.length }&.first
55
+ end
56
+
57
+ def intents(slug)
58
+ count = counted.fetch(slug).length
59
+ (count == 1) ? "1 intent" : "#{count} intents"
60
+ end
61
+
62
+ def continue_command(slug)
63
+ (slug == Scope::GLOBAL) ? "plastic continue" : "plastic continue --project #{slug}"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command"
4
+ require_relative "../table"
5
+
6
+ # Plastic::CLI::Commands::SubcommandList (intent 372, family 3) - the shared
7
+ # base every bare group command (`plastic intent`, `plastic project`,
8
+ # `plastic roadmap`) runs on: list the table rows under its prefix, or a
9
+ # usage error naming that same list for a word that matches none of them.
10
+ module Plastic
11
+ class CLI
12
+ module Commands
13
+ class SubcommandList < Command
14
+ def call
15
+ unless arguments.empty?
16
+ raise Usage, "no subcommand named #{arguments.first.inspect}. Try: #{subcommand_names.join(", ")}"
17
+ end
18
+
19
+ @output.row("usage", self.class::USAGE_LINE)
20
+ subcommands.each { |name, (_file, _const, summary)| @output.row(name, summary) }
21
+ @output.next_step(self.class::NEXT, because: self.class::BECAUSE)
22
+ end
23
+
24
+ private
25
+
26
+ def subcommands
27
+ TABLE.select { |name, _| name.start_with?("#{self.class::PREFIX} ") }.sort.to_h
28
+ end
29
+
30
+ def subcommand_names
31
+ subcommands.keys
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../command"
4
+ require_relative "../../store_sync"
5
+ require_relative "../../work_graph"
6
+
7
+ module Plastic
8
+ class CLI
9
+ module Commands
10
+ class Sync < Command
11
+ USAGE_LINE = "plastic sync [--dry-run] [--json]"
12
+
13
+ def call
14
+ home = scope.plastic_home
15
+ plan = File.exist?(SearchIndex.path(home)) ? StoreSync.plan(home) : [["build", SearchIndex::NAME]]
16
+ refuse(home, plan.filter_map { |action, path| path if action == "conflict" })
17
+ plan.group_by(&:first).each { |action, pairs| @output.row(action, pairs.map(&:last)) }
18
+ @output.row("result", "nothing to do") if plan.empty?
19
+ apply(home, plan) unless options[:dry_run]
20
+ @output.next_step("plastic search TERMS", because: "a changed file is read into its row, a changed row is written out, and both changed is yours to settle")
21
+ rescue Sqlite::Error => e
22
+ raise Failure, e.message
23
+ end
24
+
25
+ private
26
+
27
+ def switches(parser)
28
+ parser.on("--dry-run") { options[:dry_run] = true }
29
+ end
30
+
31
+ def refuse(home, conflicts)
32
+ return if conflicts.empty?
33
+
34
+ diffs = conflicts.map { |path| "#{path}\n#{StoreSync.diff(home, path)}" }
35
+ raise Refusal, "the file and the row both changed; nothing was written\n#{diffs.join("\n")}"
36
+ end
37
+
38
+ def apply(home, plan)
39
+ (plan == [["build", SearchIndex::NAME]]) ? SearchIndex.build(home) : StoreSync.apply(home, plan)
40
+ WorkGraph.build(home)
41
+ ReferenceArchive.build(home)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "installer_verb"
4
+
5
+ # `plastic uninstall` - removes Plastic from this machine's agents and leaves
6
+ # the store alone. The work is still scripts/uninstall.rb, run through Legacy.
7
+ module Plastic
8
+ class CLI
9
+ module Commands
10
+ class Uninstall < InstallerVerb
11
+ USAGE_LINE = "plastic uninstall [--claude] [--codex] [--hermes] [--all]"
12
+ FLAGS = %w[--claude --codex --hermes --all].freeze
13
+
14
+ SCRIPT = "uninstall.rb"
15
+ AFTER = "none"
16
+ BECAUSE = "Plastic is removed from this machine's agents"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "installer_verb"
4
+
5
+ # `plastic update` - moves this machine to the next version on its channel. The
6
+ # work is still scripts/update.rb, run through Legacy.
7
+ module Plastic
8
+ class CLI
9
+ module Commands
10
+ class Update < InstallerVerb
11
+ USAGE_LINE = "plastic update [--claude] [--codex] [--hermes] [--all] [--latest] [--beta] [--alpha] [--yes] [--reinstall] [--full-doctor]"
12
+ FLAGS = %w[--claude --codex --hermes --all --latest --beta --alpha --yes --reinstall --full-doctor].freeze
13
+
14
+ SCRIPT = "update.rb"
15
+ AFTER = "plastic version"
16
+ BECAUSE = "the update is done, and restarting the session picks up the new conventions"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../command"
5
+
6
+ # `plastic version` - the version of the package this command came from, and the
7
+ # file it was read from. The VERSION file wins, because an installed copy
8
+ # carries one; the package manifest answers in a checkout, which has none.
9
+ module Plastic
10
+ class CLI
11
+ module Commands
12
+ class Version < Command
13
+ USAGE_LINE = "plastic version [--json]"
14
+
15
+ SOURCES = ["VERSION", "package.json"].freeze
16
+
17
+ def call
18
+ path, number = found
19
+ raise Failure, "no VERSION file and no package.json under #{package_root}" unless number
20
+
21
+ @output.row("version", number)
22
+ @output.row("source", path)
23
+ @output.next_step("plastic status", because: "the command line works, so read the work next")
24
+ end
25
+
26
+ def found
27
+ SOURCES.each do |name|
28
+ path = File.join(package_root, name)
29
+ number = read(path)
30
+ return [path, number] if number
31
+ end
32
+ [nil, nil]
33
+ end
34
+
35
+ private
36
+
37
+ def read(path)
38
+ return nil unless File.file?(path)
39
+ return File.read(path).strip unless path.end_with?(".json")
40
+
41
+ number = JSON.parse(File.read(path))["version"]
42
+ number&.to_s&.strip
43
+ rescue JSON::ParserError
44
+ nil
45
+ end
46
+
47
+ def package_root
48
+ @package_root ||= @env["PLASTIC_PACKAGE_ROOT"] || File.expand_path("../../../..", __dir__)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../roadmap_queue"
4
+
5
+ # Plastic::CLI::Frontier (intent 363) - the one reader of "what comes next" in a
6
+ # scope. `plastic next` prints it as a line and `plastic continue` prints it
7
+ # inside a project block, so the rule that picks the work is stated once rather
8
+ # than twice.
9
+ #
10
+ # It reads RoadmapQueue's queue mode: the liveliest roadmap, its frontier batch,
11
+ # the entries that can be dispatched, the entries in flight and the blocked
12
+ # ones. With no roadmap at all it answers the "none" state rather than raising,
13
+ # and a roadmap with no grouping heading raises through to the command, which
14
+ # fails loudly instead of parsing it as zero entries.
15
+ module Plastic
16
+ class CLI
17
+ class Frontier
18
+ NONE = "none"
19
+
20
+ def initialize(scope)
21
+ @scope = scope
22
+ end
23
+
24
+ def state
25
+ payload["state"]
26
+ end
27
+
28
+ def roadmap
29
+ payload["roadmap"]
30
+ end
31
+
32
+ def heading
33
+ payload["frontier_wave"]
34
+ end
35
+
36
+ def dispatchable_ids
37
+ ids(payload["dispatchable_queue"])
38
+ end
39
+
40
+ def in_flight_ids
41
+ ids(payload["in_flight"])
42
+ end
43
+
44
+ def blocked_ids
45
+ ids(payload["blocked"])
46
+ end
47
+
48
+ def id
49
+ (dispatchable_ids + in_flight_ids).first
50
+ end
51
+
52
+ def summary
53
+ return NONE unless id
54
+
55
+ "#{id} in #{heading}"
56
+ end
57
+
58
+ def because
59
+ return "#{@scope.slug} has no roadmap" if state == NONE
60
+ return "every entry on #{roadmap} is delivered" if id.nil?
61
+
62
+ "#{id} is first on the frontier of #{roadmap}"
63
+ end
64
+
65
+ def next_step
66
+ return "plastic status" unless id
67
+
68
+ plan = @scope.intent_dir(id)
69
+ plan ? "read #{File.join(plan, "plan.md")}" : "plastic status"
70
+ end
71
+
72
+ private
73
+
74
+ def payload
75
+ @payload ||= RoadmapQueue.new(roadmaps_dir: @scope.roadmaps_dir,
76
+ index_path: @scope.index_path).queue
77
+ end
78
+
79
+ def ids(entries)
80
+ Array(entries).map { |entry| entry["id"] }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "rbconfig"
5
+ require_relative "command"
6
+
7
+ # Plastic::CLI::Legacy (intent 363) - runs a script that has not moved into a
8
+ # module yet as a child process, and passes its output straight through. It is
9
+ # scaffolding: when the last family is migrated it is deleted, and the commands
10
+ # call their modules in the same process instead.
11
+ #
12
+ # A child that exits 3 refused, and the refusal travels as a Refusal so the
13
+ # agent is told to stop and ask rather than to retry with a flag. Every other
14
+ # status is handed back to the caller unchanged.
15
+ module Plastic
16
+ class CLI
17
+ class Legacy
18
+ DEFAULT_RUNNER = lambda do |path, arguments|
19
+ system(RbConfig.ruby, path, *arguments)
20
+ exit_code($CHILD_STATUS)
21
+ end
22
+
23
+ def self.exit_code(status)
24
+ return Command::OK if status.success?
25
+
26
+ status.exitstatus || Command::FAILED
27
+ end
28
+
29
+ def initialize(env:, runner: nil)
30
+ @env = env
31
+ @runner = runner || DEFAULT_RUNNER
32
+ end
33
+
34
+ def package_root
35
+ @package_root ||= @env["PLASTIC_PACKAGE_ROOT"] || File.expand_path("../../..", __dir__)
36
+ end
37
+
38
+ def script_path(script)
39
+ File.join(package_root, "scripts", script)
40
+ end
41
+
42
+ def run(script, *arguments)
43
+ status = @runner.call(script_path(script), arguments)
44
+ raise Command::Refusal, "#{script} needs the owner" if status == Command::REFUSED
45
+
46
+ status
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ # Plastic::CLI::Output (intent 363) - one place where a command's answer is
6
+ # printed. A result is rows of label and value; every command ends with a
7
+ # `next:` line and the `because:` line that gives the rule behind it, which is
8
+ # where the step order the skills used to carry now lives. `--json` prints the
9
+ # same three things with stable keys.
10
+ #
11
+ # Results go to the output stream and diagnostics to the error stream, so a
12
+ # caller can pipe one without the other. Nothing here colours its output.
13
+ module Plastic
14
+ class CLI
15
+ class Output
16
+ LABEL_GAP = 2
17
+
18
+ def initialize(out:, err:)
19
+ @out = out
20
+ @err = err
21
+ @rows = []
22
+ @next_step = nil
23
+ @because = nil
24
+ @flushed = false
25
+ end
26
+
27
+ def row(label, value)
28
+ @rows << [label, value]
29
+ self
30
+ end
31
+
32
+ def next_step(command, because:)
33
+ @next_step = command
34
+ @because = because
35
+ self
36
+ end
37
+
38
+ def flush(json: false)
39
+ return self if @flushed
40
+
41
+ @flushed = true
42
+ json ? print_json : print_text
43
+ self
44
+ end
45
+
46
+ def raw(text)
47
+ @out.puts text
48
+ self
49
+ end
50
+
51
+ def usage(message, banner)
52
+ @err.puts "plastic: #{message}"
53
+ @err.puts banner
54
+ self
55
+ end
56
+
57
+ def refused(message)
58
+ @err.puts "plastic: refused, #{message}"
59
+ @err.puts "This step belongs to the owner. Stop and ask; do not retry with a flag."
60
+ self
61
+ end
62
+
63
+ def failed(message)
64
+ @err.puts "plastic: #{message}"
65
+ self
66
+ end
67
+
68
+ private
69
+
70
+ def print_json
71
+ @out.puts JSON.pretty_generate("result" => @rows.to_h, "next" => @next_step,
72
+ "because" => @because)
73
+ end
74
+
75
+ def print_text
76
+ printed = print_rows
77
+ return unless @next_step
78
+
79
+ @out.puts if printed
80
+ @out.puts "next: #{@next_step}"
81
+ @out.puts "because: #{@because}"
82
+ end
83
+
84
+ def print_rows
85
+ width = label_width
86
+ printed = false
87
+ @rows.each do |label, value|
88
+ Array(value).each_with_index do |item, index|
89
+ @out.puts "#{(index.zero? ? label : "").ljust(width)}#{item}"
90
+ printed = true
91
+ end
92
+ end
93
+ printed
94
+ end
95
+
96
+ def label_width
97
+ widths = @rows.reject { |_label, value| Array(value).empty? }.map { |label, _value| label.length }
98
+ widths.empty? ? 0 : widths.max + LABEL_GAP
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../store_layout"
4
+ require_relative "../index_entry"
5
+ require_relative "../store_discovery"
6
+
7
+ # Plastic::CLI::Scope (intent 363) - which store a command works on. Six skills
8
+ # used to restate this rule in prose; it is stated here once.
9
+ #
10
+ # A named project wins. With no name, the project whose repository holds the
11
+ # working directory wins, longest path first, so a repository nested inside
12
+ # another resolves to the inner one. With neither, the global store answers.
13
+ #
14
+ # Every path comes from the injected environment and home directory, so a test
15
+ # points at a temporary store and never at the real ~/.plastic.
16
+ module Plastic
17
+ class CLI
18
+ class Scope
19
+ UnknownProject = Class.new(StandardError)
20
+
21
+ GLOBAL = "global"
22
+
23
+ def initialize(env:, home:, slug: nil, directory: Dir.pwd)
24
+ @env = env
25
+ @home = home
26
+ @requested = slug
27
+ @directory = directory
28
+ end
29
+
30
+ def plastic_home
31
+ @plastic_home ||= @env["PLASTIC_HOME"] || File.join(@home, ".plastic")
32
+ end
33
+
34
+ def stores
35
+ @stores ||= StoreDiscovery.discover(plastic_home)[:stores]
36
+ end
37
+
38
+ def slug
39
+ @slug ||= resolve_slug
40
+ end
41
+
42
+ def root
43
+ @root ||= found&.fetch(:root) || plastic_home
44
+ end
45
+
46
+ def store
47
+ File.join(root, "store")
48
+ end
49
+
50
+ def index_path
51
+ File.join(root, "INDEX.md")
52
+ end
53
+
54
+ def roadmaps_dir
55
+ File.join(root, "roadmaps")
56
+ end
57
+
58
+ def active_entries
59
+ return [] unless File.exist?(index_path)
60
+
61
+ section(File.read(index_path)).filter_map do |line|
62
+ match = IndexEntry.match(line)
63
+ match && [match[1], match[2]]
64
+ end
65
+ end
66
+
67
+ def active_ids
68
+ active_entries.map(&:first)
69
+ end
70
+
71
+ def intent_dir(id)
72
+ Dir.glob(File.join(store, "#{id}--*")).min
73
+ end
74
+
75
+ def directory_project
76
+ @directory_project = project_for_directory unless defined?(@directory_project)
77
+ @directory_project
78
+ end
79
+
80
+ def known_slugs
81
+ stores.map { |store| store[:slug] }.sort
82
+ end
83
+
84
+ private
85
+
86
+ def found
87
+ stores.find { |store| store[:slug] == slug }
88
+ end
89
+
90
+ def resolve_slug
91
+ return requested_slug if @requested
92
+
93
+ directory_project || GLOBAL
94
+ end
95
+
96
+ def requested_slug
97
+ return @requested if known_slugs.include?(@requested)
98
+
99
+ raise UnknownProject,
100
+ "no project named #{@requested.inspect}; this machine has #{known_slugs.join(", ")}"
101
+ end
102
+
103
+ def project_for_directory
104
+ repositories
105
+ .select { |_slug, path| inside?(path) }
106
+ .max_by { |_slug, path| path.length }
107
+ &.first
108
+ end
109
+
110
+ def inside?(path)
111
+ @directory == path || @directory.start_with?("#{path}#{File::SEPARATOR}")
112
+ end
113
+
114
+ def repositories
115
+ StoreProvisioning.load_projects(plastic_home).filter_map do |slug, info|
116
+ path = info.is_a?(Hash) ? info["path"].to_s : ""
117
+ [slug, path] unless path.empty?
118
+ end
119
+ end
120
+
121
+ def section(text)
122
+ body = text.match(/^##\s+Active\s*$(.*?)(?=^##\s|\z)/m)
123
+ body ? body[1].lines.map(&:strip) : []
124
+ end
125
+ end
126
+ end
127
+ end