@zalom/plastic 2.0.0-alpha.26 → 2.0.0-alpha.28

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 (224) hide show
  1. package/PLASTIC.md +13 -139
  2. package/README.md +337 -133
  3. package/agents/plastic-enforcer.md +12 -12
  4. package/agents/plastic-executor.md +2 -1
  5. package/agents/plastic-node-research.md +4 -2
  6. package/agents/plastic-node-verify.md +1 -0
  7. package/agents/plastic-node-work.md +1 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +6 -8
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/bin/crap +4 -0
  11. package/bin/lib/context_budget.rb +35 -1
  12. package/bin/lib/skill_census.rb +839 -0
  13. package/bin/plastic +6 -0
  14. package/bin/plastic-skill-census +114 -0
  15. package/bin/verify-change +345 -0
  16. package/config_asks.yml +4 -4
  17. package/deprecations.yml +1 -1
  18. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  19. package/{skills/auto/references → docs/help}/agent-architecture.md +7 -7
  20. package/{skills/conventions/references → docs/help}/completion-and-done.md +1 -1
  21. package/{skills/auto/references → docs/help}/human-report-contract.md +17 -19
  22. package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
  23. package/{skills/tutorial/references → docs/help}/track-1-guided.md +8 -8
  24. package/{skills/tutorial/references → docs/help}/track-2-auto.md +5 -5
  25. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  26. package/package.json +3 -2
  27. package/scripts/append-ledger +2 -1
  28. package/scripts/dashboard.rb +10 -9
  29. package/scripts/day-summary +2 -1
  30. package/scripts/doctor.rb +57 -49
  31. package/scripts/end-intent +2 -8
  32. package/scripts/file-session-intent +2 -1
  33. package/scripts/hook-capture +4 -3
  34. package/scripts/hook-close +2 -1
  35. package/scripts/hook-record +3 -2
  36. package/scripts/hook-savepoint +3 -2
  37. package/scripts/hook-session-start +5 -4
  38. package/scripts/hook-stop +2 -1
  39. package/scripts/insight-append +1 -2
  40. package/scripts/install.rb +7 -5
  41. package/scripts/lib/action_graph_shim.rb +3 -1
  42. package/scripts/lib/active_delivery.rb +1 -1
  43. package/scripts/lib/agent_models.rb +44 -26
  44. package/scripts/lib/arm.rb +2 -1
  45. package/scripts/lib/backup.rb +63 -0
  46. package/scripts/lib/cli/command.rb +85 -0
  47. package/scripts/lib/cli/commands/auto.rb +18 -0
  48. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  49. package/scripts/lib/cli/commands/auto_lock.rb +60 -0
  50. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  51. package/scripts/lib/cli/commands/auto_take.rb +25 -0
  52. package/scripts/lib/cli/commands/backup.rb +43 -0
  53. package/scripts/lib/cli/commands/checkout.rb +25 -0
  54. package/scripts/lib/cli/commands/continue.rb +66 -0
  55. package/scripts/lib/cli/commands/doctor.rb +20 -0
  56. package/scripts/lib/cli/commands/feedback.rb +40 -0
  57. package/scripts/lib/cli/commands/help.rb +69 -0
  58. package/scripts/lib/cli/commands/hook.rb +32 -0
  59. package/scripts/lib/cli/commands/index.rb +23 -0
  60. package/scripts/lib/cli/commands/install.rb +21 -0
  61. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  62. package/scripts/lib/cli/commands/intent.rb +19 -0
  63. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  64. package/scripts/lib/cli/commands/intent_command.rb +53 -0
  65. package/scripts/lib/cli/commands/intent_end.rb +61 -0
  66. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  67. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  68. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  69. package/scripts/lib/cli/commands/intent_show.rb +25 -0
  70. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  71. package/scripts/lib/cli/commands/intent_step.rb +43 -0
  72. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  73. package/scripts/lib/cli/commands/migrate.rb +16 -0
  74. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  75. package/scripts/lib/cli/commands/next.rb +49 -0
  76. package/scripts/lib/cli/commands/project.rb +19 -0
  77. package/scripts/lib/cli/commands/project_links.rb +42 -0
  78. package/scripts/lib/cli/commands/project_list.rb +20 -0
  79. package/scripts/lib/cli/commands/project_new.rb +72 -0
  80. package/scripts/lib/cli/commands/query.rb +31 -0
  81. package/scripts/lib/cli/commands/render.rb +25 -0
  82. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  83. package/scripts/lib/cli/commands/roadmap_check.rb +44 -0
  84. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  85. package/scripts/lib/cli/commands/roadmap_next.rb +34 -0
  86. package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
  87. package/scripts/lib/cli/commands/rollback.rb +20 -0
  88. package/scripts/lib/cli/commands/search.rb +60 -0
  89. package/scripts/lib/cli/commands/session.rb +18 -0
  90. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  91. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  92. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  93. package/scripts/lib/cli/commands/status.rb +68 -0
  94. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  95. package/scripts/lib/cli/commands/sync.rb +46 -0
  96. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  97. package/scripts/lib/cli/commands/update.rb +20 -0
  98. package/scripts/lib/cli/commands/version.rb +53 -0
  99. package/scripts/lib/cli/frontier.rb +84 -0
  100. package/scripts/lib/cli/legacy.rb +50 -0
  101. package/scripts/lib/cli/output.rb +102 -0
  102. package/scripts/lib/cli/scope.rb +127 -0
  103. package/scripts/lib/cli/table.rb +64 -0
  104. package/scripts/lib/cli.rb +94 -0
  105. package/scripts/lib/codex_adapter.rb +5 -4
  106. package/scripts/lib/compact_instructions.rb +8 -0
  107. package/scripts/lib/day_summary.rb +4 -3
  108. package/scripts/lib/doctor_core.rb +7 -32
  109. package/scripts/lib/doctor_session_ledger.rb +2 -1
  110. package/scripts/lib/feedback_report.rb +1 -1
  111. package/scripts/lib/graph_measure_models.rb +4 -2
  112. package/scripts/lib/index_entry.rb +9 -0
  113. package/scripts/lib/installer_core.rb +175 -56
  114. package/scripts/lib/intent_screen.rb +3 -3
  115. package/scripts/lib/lock.rb +2 -2
  116. package/scripts/lib/node_file.rb +22 -2
  117. package/scripts/lib/node_input.rb +11 -6
  118. package/scripts/lib/node_return.rb +8 -3
  119. package/scripts/lib/preflight.rb +2 -4
  120. package/scripts/lib/project_config.rb +2 -1
  121. package/scripts/lib/project_validator.rb +3 -2
  122. package/scripts/lib/qmd_sync.rb +8 -7
  123. package/scripts/lib/reference_archive.rb +45 -0
  124. package/scripts/lib/release_guard.rb +2 -0
  125. package/scripts/lib/report_screen.rb +4 -3
  126. package/scripts/lib/rlm/corpus.rb +13 -0
  127. package/scripts/lib/rlm/probe.rb +29 -0
  128. package/scripts/lib/rlm/query.rb +22 -0
  129. package/scripts/lib/roadmap_queue.rb +2 -2
  130. package/scripts/lib/roadmap_savepoint.rb +1 -1
  131. package/scripts/lib/runner_absorb.rb +59 -2
  132. package/scripts/lib/runner_dispatch.rb +12 -8
  133. package/scripts/lib/runner_policy.rb +28 -10
  134. package/scripts/lib/search_index.rb +55 -0
  135. package/scripts/lib/session_git.rb +4 -3
  136. package/scripts/lib/sqlite.rb +22 -0
  137. package/scripts/lib/store_discovery.rb +7 -6
  138. package/scripts/lib/store_layout.rb +54 -0
  139. package/scripts/lib/store_provisioning.rb +2 -1
  140. package/scripts/lib/store_sync.rb +85 -0
  141. package/scripts/lib/stores_move.rb +93 -0
  142. package/scripts/lib/verify_intent.rb +2 -7
  143. package/scripts/lib/version_number.rb +48 -0
  144. package/scripts/lib/work_graph.rb +59 -0
  145. package/scripts/lib/worktree.rb +3 -8
  146. package/scripts/lib/worktree_sweep.rb +3 -2
  147. package/scripts/link-suggest +2 -1
  148. package/scripts/migrate-to-global +1 -1
  149. package/scripts/new-intent +3 -12
  150. package/scripts/node-run +2 -1
  151. package/scripts/plastic-lock +3 -2
  152. package/scripts/promote-session-item +3 -2
  153. package/scripts/read-config +44 -6
  154. package/scripts/release-check +10 -5
  155. package/scripts/report-screen +1 -1
  156. package/scripts/session-commit +2 -1
  157. package/scripts/spawn-preamble +2 -2
  158. package/scripts/update.rb +25 -4
  159. package/scripts/write-handoff +2 -1
  160. package/templates/agents.md +6 -6
  161. package/templates/config.yml +13 -6
  162. package/templates/node-research.md +4 -2
  163. package/templates/render.css +10 -0
  164. package/bin/plastic.js +0 -70
  165. package/skills/agent-advisor/SKILL.md +0 -92
  166. package/skills/auto/SKILL.md +0 -297
  167. package/skills/auto/evals/evals.json +0 -255
  168. package/skills/auto/references/end-tail.md +0 -64
  169. package/skills/conventions/SKILL.md +0 -29
  170. package/skills/dashboard/SKILL.md +0 -180
  171. package/skills/dashboard/evals/evals.json +0 -38
  172. package/skills/dashboard/references/classification.md +0 -22
  173. package/skills/dashboard/templates/dashboard-global.md +0 -20
  174. package/skills/dashboard/templates/dashboard-project.md +0 -19
  175. package/skills/direct/SKILL.md +0 -66
  176. package/skills/direct/references/request-signals.md +0 -59
  177. package/skills/doctor/SKILL.md +0 -305
  178. package/skills/doctor/report.md +0 -102
  179. package/skills/feedback/SKILL.md +0 -98
  180. package/skills/feedback/references/transport-and-privacy.md +0 -65
  181. package/skills/feedback/report.md +0 -36
  182. package/skills/install/SKILL.md +0 -217
  183. package/skills/intent-continuing/SKILL.md +0 -156
  184. package/skills/intent-continuing/references/board-fill.md +0 -52
  185. package/skills/intent-continuing/references/boarding-matrix.md +0 -35
  186. package/skills/intent-continuing/references/context-management.md +0 -28
  187. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  188. package/skills/intent-creating/SKILL.md +0 -89
  189. package/skills/intent-creating/evals/evals.json +0 -72
  190. package/skills/intent-creating/references/lifecycle.md +0 -81
  191. package/skills/intent-creating/references/wikilinks.md +0 -8
  192. package/skills/intent-ending/SKILL.md +0 -182
  193. package/skills/intent-ending/evals/evals.json +0 -74
  194. package/skills/intent-executing/SKILL.md +0 -87
  195. package/skills/intent-executing/evals/evals.json +0 -66
  196. package/skills/intent-executing/implementer-prompt.md +0 -47
  197. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  198. package/skills/intent-speccing/SKILL.md +0 -136
  199. package/skills/intent-speccing/evals/evals.json +0 -126
  200. package/skills/intent-speccing/references/design-principles.md +0 -44
  201. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  202. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  203. package/skills/project-creating/SKILL.md +0 -162
  204. package/skills/project-creating/references/hubs-projects.md +0 -55
  205. package/skills/project-creating/references/project-scaffolding.md +0 -97
  206. package/skills/releasing/SKILL.md +0 -376
  207. package/skills/releasing/references/deprecations.md +0 -60
  208. package/skills/releasing/references/promotion-and-tagging.md +0 -70
  209. package/skills/releasing/references/release-lines.md +0 -105
  210. package/skills/roadmap/SKILL.md +0 -90
  211. package/skills/roadmap/references/file-format.md +0 -134
  212. package/skills/roadmap/references/operations.md +0 -112
  213. package/skills/rollback/SKILL.md +0 -91
  214. package/skills/tutorial/SKILL.md +0 -66
  215. package/skills/tutorial/evals/evals.json +0 -186
  216. package/skills/uninstall/SKILL.md +0 -75
  217. package/skills/update/SKILL.md +0 -126
  218. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  219. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  220. /package/{skills/conventions/references → docs/help}/knowledge-graph.md +0 -0
  221. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  222. /package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +0 -0
  223. /package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +0 -0
  224. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plastic
4
+ module StoreLayout
5
+ GLOBAL = "global"
6
+ PARENTS = %w[stores projects].freeze
7
+
8
+ module_function
9
+
10
+ def moved?(home)
11
+ File.directory?(File.join(home, "stores"))
12
+ end
13
+
14
+ def global_root(home)
15
+ moved?(home) ? File.join(home, "stores", GLOBAL) : home
16
+ end
17
+
18
+ def global_store(home)
19
+ File.join(global_root(home), "store")
20
+ end
21
+
22
+ def projects_root(home)
23
+ File.join(home, moved?(home) ? "stores" : "projects")
24
+ end
25
+
26
+ def project_root(home, slug)
27
+ File.join(projects_root(home), slug)
28
+ end
29
+
30
+ def root(home, slug)
31
+ (slug == GLOBAL) ? global_root(home) : project_root(home, slug)
32
+ end
33
+
34
+ def project_slugs(home)
35
+ parent = projects_root(home)
36
+ return [] unless File.directory?(parent)
37
+
38
+ Dir.children(parent).reject { |entry| entry.start_with?(".") || entry == GLOBAL }.sort
39
+ end
40
+
41
+ def locate(store)
42
+ root = File.dirname(File.expand_path(store))
43
+ parent = File.dirname(root)
44
+ return [root, GLOBAL] unless PARENTS.include?(File.basename(parent))
45
+
46
+ [File.dirname(parent), File.basename(root)]
47
+ end
48
+
49
+ def home_and_scope(store)
50
+ home, slug = locate(store)
51
+ [home, (slug == GLOBAL) ? GLOBAL : "project:#{slug}"]
52
+ end
53
+ end
54
+ end
@@ -1,6 +1,7 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "store_layout"
4
5
  require "fileutils"
5
6
  require "yaml"
6
7
 
@@ -63,7 +64,7 @@ module StoreProvisioning
63
64
  }
64
65
  end
65
66
 
66
- project_dir = File.join(plastic_home, "projects", slug)
67
+ project_dir = Plastic::StoreLayout.project_root(plastic_home, slug)
67
68
  store_dir = File.join(project_dir, "store")
68
69
  FileUtils.mkdir_p(store_dir)
69
70
 
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+ require_relative "search_index"
6
+ require_relative "reference_archive"
7
+ require_relative "work_graph"
8
+
9
+ module Plastic
10
+ module StoreSync
11
+ INTENT_PATH = %r{\A(?:(?:projects|stores)/([^/]+)/)?store/([^/]+?)--}
12
+ ROW_HASH = "lower(hex(sha3(sqlar_uncompress(data, sz), 256)))"
13
+
14
+ def self.action(state)
15
+ file, row, recorded = state.values_at("file", "row", "hash")
16
+ return "write" if file.nil?
17
+ return if file == row
18
+ return "read" if row == recorded
19
+ return "write" if file == recorded
20
+
21
+ "conflict"
22
+ end
23
+
24
+ def self.plan(home)
25
+ known = states(home)
26
+ fresh = SearchIndex.files(home) - known.map { |state| state["path"] }
27
+ plan = known.map { |state| [action(state), state["path"]] } + fresh.map { |file| ["read", file] }
28
+ plan.select(&:first).sort_by(&:last)
29
+ end
30
+
31
+ def self.states(home)
32
+ Sqlite.call(SearchIndex.path(home), <<~SQL, readonly: true)
33
+ SELECT path, lower(hex(sha3(readfile(#{Sqlite.quote(home)} || '/' || path), 256))) AS file, #{ROW_HASH} AS row, hash
34
+ FROM doc;
35
+ SQL
36
+ end
37
+
38
+ def self.apply(home, plan)
39
+ statements = plan.map { |action, path| send(action, home, path) }
40
+ statements << "INSERT INTO ft(ft) VALUES('rebuild');" if plan.any? { |action, _path| action == "read" }
41
+ Sqlite.call(SearchIndex.path(home), ["BEGIN;", *statements, "COMMIT;"].join("\n"))
42
+ end
43
+
44
+ def self.read(home, path)
45
+ "DELETE FROM doc WHERE path = #{Sqlite.quote(path)};\n#{SearchIndex.insert(home, path)}"
46
+ end
47
+
48
+ def self.write(home, path)
49
+ where = "WHERE path = #{Sqlite.quote(path)}"
50
+ "#{restore(home, "doc", "path")} #{where};\nUPDATE doc SET hash = #{ROW_HASH} #{where};"
51
+ end
52
+
53
+ def self.restore(home, table, column)
54
+ "SELECT writefile(#{Sqlite.quote(home)} || '/' || #{column}, sqlar_uncompress(data, sz)) FROM #{table}"
55
+ end
56
+
57
+ def self.diff(home, path)
58
+ body = Sqlite.call(SearchIndex.path(home), <<~SQL, readonly: true).first["body"]
59
+ SELECT CAST(sqlar_uncompress(data, sz) AS TEXT) AS body FROM doc WHERE path = #{Sqlite.quote(path)};
60
+ SQL
61
+ Tempfile.create("plastic-row") do |row|
62
+ row.write(body)
63
+ row.flush
64
+ Open3.capture2("diff", "-u", "-L", "row", "-L", "file", row.path, File.join(home, path)).first
65
+ end
66
+ end
67
+
68
+ def self.orphans(home)
69
+ paths = Sqlite.call(SearchIndex.path(home), "SELECT path FROM doc;", readonly: true) +
70
+ Sqlite.call(ReferenceArchive.path(home), "SELECT name AS path FROM sqlar;", readonly: true)
71
+ keys = paths.filter_map { |row| row["path"].match(INTENT_PATH) }.map { |found| "#{found[1] || "global"}:#{found[2]}" }
72
+ (keys.uniq - WorkGraph.known(home)).sort
73
+ end
74
+
75
+ def self.checkout(home)
76
+ {SearchIndex.path(home) => %w[doc path], ReferenceArchive.path(home) => %w[sqlar name]}.flat_map do |database, (table, column)|
77
+ missing = Sqlite.call(database, "SELECT #{column} AS path FROM #{table};", readonly: true)
78
+ .map { |row| row["path"] }.reject { |path| File.exist?(File.join(home, path)) }
79
+ names = missing.map { |path| Sqlite.quote(path) }.join(", ")
80
+ Sqlite.call(database, "#{restore(home, table, column)} WHERE #{column} IN (#{names});", readonly: true)
81
+ missing
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "store_layout"
5
+ require_relative "sqlite"
6
+ require_relative "lock"
7
+
8
+ module Plastic
9
+ class StoresMove
10
+ Refused = Class.new(StandardError)
11
+
12
+ GLOBAL_ENTRIES = %w[store INDEX.md roadmaps].freeze
13
+ PATH_COLUMNS = {"knowledge_graph.db" => %w[doc path], "references.db" => %w[sqlar name]}.freeze
14
+ LOCKS = "{store,projects/*/store}/*/delivery.lock"
15
+
16
+ def initialize(home, qmd_index:, dry_run: false)
17
+ @home = home
18
+ @qmd_index = qmd_index
19
+ @dry_run = dry_run
20
+ end
21
+
22
+ def call
23
+ refuse
24
+ plan = [["copy", copy]] + moves.map { |from, to| ["move", "#{from} -> #{to}"] } + rewrites.map { |file| ["rewrite", file] }
25
+ run unless @dry_run
26
+ plan
27
+ end
28
+
29
+ def copy
30
+ "#{@home}-before-stores-move"
31
+ end
32
+
33
+ private
34
+
35
+ def refuse
36
+ raise Refused, "#{File.join(@home, "stores")}/ exists, so this home has moved already" if StoreLayout.moved?(@home)
37
+ raise Refused, "a delivery lock is held: #{held.join(", ")}" unless held.empty?
38
+ raise Refused, "#{copy} exists; remove it or move it away first" if File.exist?(copy)
39
+ end
40
+
41
+ def held
42
+ Dir.glob(LOCKS, base: @home).select { |lock| Time.now - File.mtime(File.join(@home, lock)) < Lock::TTL_SECONDS }
43
+ end
44
+
45
+ def moves
46
+ @moves ||= GLOBAL_ENTRIES.select { |name| File.exist?(File.join(@home, name)) }.map { |name| [name, "stores/global/#{name}"] } +
47
+ project_entries.map { |entry| ["projects/#{entry}", "stores/#{entry}"] }
48
+ end
49
+
50
+ def project_entries
51
+ Dir.exist?(projects) ? Dir.children(projects).sort : []
52
+ end
53
+
54
+ def projects
55
+ File.join(@home, "projects")
56
+ end
57
+
58
+ def rewrites
59
+ [File.join(@home, "config.yml"), @qmd_index, *PATH_COLUMNS.keys.map { |name| File.join(@home, name) }].select { |file| File.file?(file) }
60
+ end
61
+
62
+ def run
63
+ files = rewrites
64
+ FileUtils.cp_r(@home, copy, preserve: true)
65
+ moves.each { |from, to| move(from, to) }
66
+ FileUtils.rmdir(projects) if Dir.exist?(projects)
67
+ files.each { |file| PATH_COLUMNS.key?(File.basename(file)) ? rewrite_rows(file) : rewrite_text(file) }
68
+ end
69
+
70
+ def move(from, to)
71
+ FileUtils.mkdir_p(File.dirname(File.join(@home, to)))
72
+ FileUtils.mv(File.join(@home, from), File.join(@home, to))
73
+ end
74
+
75
+ def rewrite_text(file)
76
+ FileUtils.cp(file, "#{file}.before-stores-move", preserve: true)
77
+ File.write(file, [@home, "~/.plastic"].inject(File.read(file)) { |text, base| relocate(text, base) })
78
+ end
79
+
80
+ def relocate(text, base)
81
+ text.gsub(%r{#{Regexp.escape(base)}/(store|roadmaps|INDEX\.md)\b}) { "#{base}/stores/global/#{Regexp.last_match(1)}" }
82
+ .gsub(%r{#{Regexp.escape(base)}/projects\b}) { "#{base}/stores" }
83
+ end
84
+
85
+ def rewrite_rows(file)
86
+ table, column = PATH_COLUMNS.fetch(File.basename(file))
87
+ Sqlite.call(file, <<~SQL)
88
+ UPDATE #{table} SET #{column} = 'stores/' || substr(#{column}, 10) WHERE #{column} LIKE 'projects/%';
89
+ UPDATE #{table} SET #{column} = 'stores/global/' || #{column} WHERE #{column} LIKE 'store/%';
90
+ SQL
91
+ end
92
+ end
93
+ end
@@ -1,6 +1,7 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "store_layout"
4
5
  require_relative "worktree"
5
6
  require_relative "scaffold_intent"
6
7
 
@@ -96,13 +97,7 @@ module VerifyIntent
96
97
  # shapes are told apart by whether store's grandparent is literally named "projects" (the
97
98
  # StoreDiscovery convention), never by hardcoding ".plastic" as a name.
98
99
  def resolve_plastic_home_and_scope(store)
99
- parent = File.dirname(store)
100
- grandparent = File.dirname(parent)
101
- if File.basename(grandparent) == "projects"
102
- [File.dirname(grandparent), "project:#{File.basename(parent)}"]
103
- else
104
- [parent, "global"]
105
- end
100
+ Plastic::StoreLayout.home_and_scope(store)
106
101
  end
107
102
 
108
103
  # --- check 1: doctor, scoped to the intent --------------------------------------
@@ -0,0 +1,48 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # VersionNumber - dotted version comparison without RubyGems (intent 363).
5
+ #
6
+ # The launcher starts as `ruby --disable-gems`, which saves about 26 of the 40
7
+ # milliseconds a plain Ruby process costs, so no file a command loads may reach
8
+ # for Gem::Version. Parses the leading run of dotted integers, ignoring a
9
+ # leading "v" and anything after the digits, and compares two of them segment by
10
+ # segment with the shorter one zero-padded. A string with no leading integer
11
+ # parses to nil, so a caller answers "not found" instead of raising.
12
+ #
13
+ # It deliberately does not order pre-release suffixes: 2.0.0-alpha.27 and 2.0.0
14
+ # compare equal here. A caller that needs RubyGems' pre-release order, such as
15
+ # the release-tag reader in scripts/report-screen, keeps Gem::Version.
16
+ class VersionNumber
17
+ include Comparable
18
+
19
+ LEADING_DIGITS = /\A(\d+(?:\.\d+)*)/
20
+
21
+ attr_reader :segments
22
+
23
+ def self.parse(value)
24
+ match = LEADING_DIGITS.match(value.to_s.strip.sub(/\Av/, ""))
25
+ match && new(match[1].split(".").map(&:to_i))
26
+ end
27
+
28
+ def initialize(segments)
29
+ @segments = segments
30
+ end
31
+
32
+ def <=>(other)
33
+ return nil unless other.is_a?(VersionNumber)
34
+
35
+ width = [segments.length, other.segments.length].max
36
+ padded(width) <=> other.padded(width)
37
+ end
38
+
39
+ def to_s
40
+ segments.join(".")
41
+ end
42
+
43
+ protected
44
+
45
+ def padded(width)
46
+ segments + Array.new(width - segments.length, 0)
47
+ end
48
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "sqlite"
5
+ require_relative "index_entry"
6
+ require_relative "store_discovery"
7
+
8
+ module Plastic
9
+ module WorkGraph
10
+ NAME = "work_graph.db"
11
+ SCHEMA = <<~SQL
12
+ CREATE TABLE intent(id TEXT, store TEXT, slug TEXT, status TEXT, title TEXT);
13
+ CREATE TABLE ledger(intent_id TEXT, store TEXT, at TEXT, stage TEXT, text TEXT);
14
+ SQL
15
+ STATUSES = %w[active future completed abandoned].freeze
16
+ LEDGER_LINE = /\A(\S+)\s{2,}(\S+)\s{2,}(.+)\z/
17
+
18
+ def self.path(home)
19
+ File.join(home, NAME)
20
+ end
21
+
22
+ def self.build(home)
23
+ draft = "#{path(home)}.tmp"
24
+ FileUtils.rm_f(draft)
25
+ rows = StoreDiscovery.discover(home)[:stores].flat_map { |store| intents(store) + ledger(store) }
26
+ Sqlite.call(draft, [SCHEMA, "BEGIN;", *rows, "COMMIT;"].join("\n"))
27
+ File.rename(draft, path(home))
28
+ end
29
+
30
+ def self.intents(store)
31
+ return [] unless File.exist?(store[:index])
32
+
33
+ status = nil
34
+ File.readlines(store[:index], chomp: true, encoding: "UTF-8").filter_map do |line|
35
+ status = line.delete_prefix("## ").downcase if line.start_with?("## ")
36
+ entry = STATUSES.include?(status) && IndexEntry.match(line)
37
+ entry && insert("intent", entry[1], store[:slug], File.basename(File.dirname(entry[3])).split("--", 2).last, status, entry[2])
38
+ end
39
+ end
40
+
41
+ def self.ledger(store)
42
+ Dir.glob("*/savepoint.md", base: store[:store]).sort.flat_map do |file|
43
+ id = file.split("--").first
44
+ File.readlines(File.join(store[:store], file), chomp: true, encoding: "UTF-8").filter_map do |line|
45
+ entry = line.match(LEDGER_LINE)
46
+ entry && insert("ledger", id, store[:slug], entry[1], entry[2], entry[3])
47
+ end
48
+ end
49
+ end
50
+
51
+ def self.insert(table, *values)
52
+ "INSERT INTO #{table} VALUES(#{values.map { |value| Sqlite.quote(value.to_s) }.join(", ")});"
53
+ end
54
+
55
+ def self.known(home)
56
+ Sqlite.call(path(home), "SELECT store, id FROM intent;", readonly: true).map { |row| "#{row["store"]}:#{row["id"]}" }
57
+ end
58
+ end
59
+ end
@@ -1,6 +1,7 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "store_layout"
4
5
  require "json"
5
6
  require "yaml"
6
7
  require "socket"
@@ -377,14 +378,8 @@ module Worktree
377
378
  # (no project repo). Mirrors qmd_sync's slug_for_store fallback.
378
379
  def slug_for_store(store_dir, home: Dir.home)
379
380
  return nil if blank?(store_dir)
380
- plastic_home = File.expand_path(File.join(home, ".plastic"))
381
- store_dir = File.expand_path(store_dir)
382
- return nil if store_dir == File.join(plastic_home, "store")
383
-
384
- parts = store_dir.split(File::SEPARATOR)
385
- idx = parts.rindex("projects")
386
- return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
387
- nil
381
+ slug = Plastic::StoreLayout.locate(store_dir).last
382
+ (slug == Plastic::StoreLayout::GLOBAL) ? nil : slug
388
383
  end
389
384
 
390
385
  # Best-effort slug for the worktree dir-name from an intent dir/store path:
@@ -1,6 +1,7 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+ require_relative "store_layout"
4
5
  require_relative "worktree"
5
6
  require_relative "index_entry"
6
7
 
@@ -55,8 +56,8 @@ module WorktreeSweep
55
56
  # Matching on the full name, not just the numeric id, sidesteps id
56
57
  # collisions across projects (ids are only unique WITHIN one store).
57
58
  def resolve_intent_dir(plastic_home, name)
58
- candidates = [File.join(plastic_home, "store", name)] +
59
- Dir.glob(File.join(plastic_home, "projects", "*", "store", name))
59
+ candidates = [File.join(Plastic::StoreLayout.global_store(plastic_home), name)] +
60
+ Dir.glob(File.join(Plastic::StoreLayout.projects_root(plastic_home), "*", "store", name))
60
61
  candidates.find { |d| Dir.exist?(d) }
61
62
  end
62
63
 
@@ -26,6 +26,7 @@
26
26
  # nothing. --edge is explicit and required (sources is decided by origin, never
27
27
  # inferred); --rating is required when --edge is chain.
28
28
 
29
+ require_relative "lib/store_layout"
29
30
  require_relative "lib/link_suggestions"
30
31
 
31
32
  module LinkSuggestCLI
@@ -69,7 +70,7 @@ module LinkSuggestCLI
69
70
  i += 1
70
71
  end
71
72
  end
72
- opts[:store_dir] ||= File.join(opts[:home], "store")
73
+ opts[:store_dir] ||= Plastic::StoreLayout.global_store(opts[:home])
73
74
  opts
74
75
  end
75
76
 
@@ -17,7 +17,7 @@ GLOBAL_ROOT="$HOME/.plastic"
17
17
  LOCAL_PLASTIC="$PROJECT_PATH/.plastic"
18
18
 
19
19
  if [ ! -d "$GLOBAL_ROOT" ]; then
20
- echo "Error: Global Plastic not installed. Run /plastic-install first." >&2
20
+ echo "Error: Global Plastic not installed. Run 'plastic install' first." >&2
21
21
  exit 1
22
22
  fi
23
23
 
@@ -35,6 +35,7 @@
35
35
  # exactly one line, `created <dir>` or `joined <dir>`; exit 0 either way,
36
36
  # exit 1 on bad input or failed self-validation.
37
37
 
38
+ require_relative "lib/store_layout"
38
39
  require "fileutils"
39
40
  require "date"
40
41
  require_relative "lib/savepoint"
@@ -167,17 +168,7 @@ end
167
168
  # [nil, "global"] only if the layout is unrecognized, in which case Links
168
169
  # projection falls back to single-store resolution rooted at this store.
169
170
  def store_context(store)
170
- store = File.expand_path(store)
171
- parent = File.dirname(store) # `<home>` or `<home>/projects/<slug>`
172
- if File.basename(store) == "store" && File.basename(File.dirname(parent)) == "projects"
173
- slug = File.basename(parent)
174
- home = File.dirname(File.dirname(parent)) # strip projects/<slug>
175
- [home, "project:#{slug}"]
176
- elsif File.basename(store) == "store"
177
- [parent, "global"] # `<home>/store`
178
- else
179
- [parent, "global"]
180
- end
171
+ Plastic::StoreLayout.home_and_scope(store)
181
172
  end
182
173
 
183
174
  # The in-scope stores under `plastic_home`, each { key:, store: }. Delegates to
@@ -283,7 +274,7 @@ def run_tmp(opts)
283
274
  abort "new-intent: --sources is not valid with --tmp" unless opts[:sources].empty?
284
275
  abort "new-intent: --tags is not valid with --tmp" unless opts[:tags].empty?
285
276
 
286
- store = opts[:store] ? expand(opts[:store]) : expand(File.join(ENV.fetch("PLASTIC_HOME", "~/.plastic"), "store"))
277
+ store = opts[:store] ? expand(opts[:store]) : Plastic::StoreLayout.global_store(expand(ENV.fetch("PLASTIC_HOME", "~/.plastic")))
287
278
  templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
288
279
  abort "new-intent: templates dir not found: #{templates}" unless Dir.exist?(templates)
289
280
 
package/scripts/node-run CHANGED
@@ -201,7 +201,8 @@ module NodeRunCLI
201
201
  message_path = "#{out_path}.msg"
202
202
 
203
203
  timeout_seconds = timeout_flag ? timeout_flag.to_i : CodexAdapter.timeout_seconds(kind)
204
- codex_argv = CodexAdapter.build_argv(kind: kind, worktree: worktree, output_last_message: message_path)
204
+ codex_argv = CodexAdapter.build_argv(kind: kind, worktree: worktree, output_last_message: message_path,
205
+ model: fields["model"], effort: fields["effort"])
205
206
 
206
207
  result = CodexAdapter.execute(codex_argv, stdin_data: input_bytes, timeout_seconds: timeout_seconds,
207
208
  output_last_message_path: message_path)
@@ -38,6 +38,7 @@
38
38
  # file; that path was retired in 2.0 (intent 307) and 344 G11.
39
39
  # Exit 0 on success/report; exit 1 when the verb is blocked.
40
40
 
41
+ require_relative "lib/store_layout"
41
42
  require "json"
42
43
  require "yaml"
43
44
  require_relative "lib/lock"
@@ -96,7 +97,7 @@ if dir.nil? || dir.strip.empty?
96
97
  exit 1
97
98
  end
98
99
  roots = ActiveDelivery.project_roots(plastic_home)
99
- global_store = File.join(plastic_home, "store")
100
+ global_store = Plastic::StoreLayout.global_store(plastic_home)
100
101
  dir = ActiveDelivery.resolve(global_store: global_store, project_roots: roots, session: session)
101
102
  if dir.nil?
102
103
  candidates = ActiveDelivery.candidate_intent_dirs(global_store: global_store, project_roots: roots).uniq
@@ -188,7 +189,7 @@ when "arm"
188
189
  end
189
190
  when "status"
190
191
  lock = Lock.read(dir)
191
- resolved = ActiveDelivery.resolve(global_store: File.join(plastic_home, "store"),
192
+ resolved = ActiveDelivery.resolve(global_store: Plastic::StoreLayout.global_store(plastic_home),
192
193
  project_roots: ActiveDelivery.project_roots(plastic_home),
193
194
  session: key)
194
195
  delivering = !resolved.nil? && File.expand_path(resolved) == File.expand_path(dir)
@@ -21,6 +21,7 @@
21
21
  # Exit codes: 0 promoted (prints the intent dir last), 1 new-intent failed,
22
22
  # 2 usage error or nothing to promote.
23
23
 
24
+ require_relative "lib/store_layout"
24
25
  require "open3"
25
26
  require "yaml"
26
27
  require_relative "lib/session_ledger"
@@ -61,7 +62,7 @@ usage_abort("--day must be a real calendar date, YYYYMMDD") unless SessionLedger
61
62
  usage_abort("--match is required") if opts[:match].nil? || opts[:match].empty?
62
63
 
63
64
  plastic_home = File.expand_path(opts[:plastic_home] || ENV.fetch("PLASTIC_HOME", "~/.plastic"))
64
- store = opts[:store] || File.join(plastic_home, "store")
65
+ store = opts[:store] || Plastic::StoreLayout.global_store(plastic_home)
65
66
  templates = opts[:templates] || File.expand_path("../templates", __dir__)
66
67
  session = opts[:session] ? SessionLedger.short_session_id(opts[:session]) : nil
67
68
 
@@ -69,7 +70,7 @@ target_store = store
69
70
  if opts[:project]
70
71
  projects = StoreProvisioning.load_projects(plastic_home)
71
72
  usage_abort("--project #{opts[:project]} is not a registered project") unless projects.key?(opts[:project])
72
- target_store = File.join(plastic_home, "projects", opts[:project], "store")
73
+ target_store = File.join(Plastic::StoreLayout.project_root(plastic_home, opts[:project]), "store")
73
74
  end
74
75
 
75
76
  # --- pick the target line ---------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
- # Usage: read-config <key> [--default VALUE] [--project PATH]
2
+ # Usage: read-config <key> [--default VALUE] [--project PATH] [--harness claude|codex]
3
3
  # Resolves a config value: project -> global -> built-in defaults.
4
4
  # Nested keys use dot notation: agent.type, architect.style
5
5
  # Returns scalar values as strings, hash/array values as JSON.
@@ -46,6 +46,7 @@ def parse_args(argv)
46
46
  key = nil
47
47
  default_val = nil
48
48
  project_dir = nil
49
+ harness = nil
49
50
 
50
51
  i = 0
51
52
  while i < argv.length
@@ -58,13 +59,17 @@ def parse_args(argv)
58
59
  abort "Error: --project requires a value" unless argv[i + 1]
59
60
  project_dir = argv[i + 1]
60
61
  i += 2
62
+ when "--harness"
63
+ abort "Error: --harness requires a value" unless argv[i + 1]
64
+ harness = argv[i + 1]
65
+ i += 2
61
66
  else
62
67
  key = argv[i]
63
68
  i += 1
64
69
  end
65
70
  end
66
71
 
67
- [key, default_val, project_dir]
72
+ [key, default_val, project_dir, harness]
68
73
  end
69
74
 
70
75
  def load_yaml(path)
@@ -119,10 +124,10 @@ if ARGV.include?("--migrate")
119
124
  exit 0
120
125
  end
121
126
 
122
- key, explicit_default, project_dir = parse_args(ARGV)
127
+ key, explicit_default, project_dir, harness = parse_args(ARGV)
123
128
 
124
129
  if key.nil? || key.empty?
125
- $stderr.puts "Usage: read-config <key> [--default VALUE] [--project PATH]"
130
+ $stderr.puts "Usage: read-config <key> [--default VALUE] [--project PATH] [--harness claude|codex]"
126
131
  exit 1
127
132
  end
128
133
 
@@ -135,9 +140,42 @@ if project_dir
135
140
  project_config = load_yaml(project_config_path)
136
141
  end
137
142
 
138
- value = dig_key(project_config, key)
139
- value = dig_key(global_config, key) if value.nil?
143
+ def canonical_harness(value)
144
+ return nil if value.nil?
145
+ return "claude" if %w[claude claude-code].include?(value.to_s)
146
+ return "codex" if value.to_s == "codex"
147
+
148
+ abort "Error: --harness must be claude or codex"
149
+ end
150
+
151
+ def harness_agent_value(config, key, harness)
152
+ match = key.match(/\Aagents\.(models|efforts)\.([^.]+)\z/)
153
+ return nil unless match
154
+
155
+ section, agent = match.captures
156
+ if section == "models"
157
+ AgentModels.models_section(config, harness: harness)[agent]
158
+ else
159
+ AgentModels.efforts_section(config, harness)[agent]
160
+ end
161
+ end
162
+
163
+ harness = canonical_harness(harness)
164
+ if harness
165
+ value = harness_agent_value(project_config, key, harness)
166
+ value = harness_agent_value(global_config, key, harness) if value.nil?
167
+ else
168
+ value = dig_key(project_config, key)
169
+ value = dig_key(global_config, key) if value.nil?
170
+ end
140
171
  value = explicit_default if value.nil? && explicit_default
172
+ if value.nil? && harness && (match = key.match(/\Aagents\.(models|efforts)\.([^.]+)\z/))
173
+ value = if match[1] == "models"
174
+ AgentModels.shipped_model_for(match[2], harness: harness)
175
+ else
176
+ AgentModels::DEFAULT_EFFORT
177
+ end
178
+ end
141
179
  value = dig_key(DEFAULTS, key) if value.nil?
142
180
 
143
181
  puts format_value(value)