@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
@@ -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 "yaml"
5
6
 
6
7
  # ProjectValidator - the single source of truth for "is a project spawn
@@ -10,7 +11,7 @@ require "yaml"
10
11
  # missing the pieces a real project needs: project.yml, a root AGENTS.md.
11
12
  # The intent-26 spawn shipped exactly that shape and was caught only by a
12
13
  # much later, pull-only plastic-doctor sweep. This module lets
13
- # plastic-project-creating verify a spawn BEFORE announcing it as done,
14
+ # `plastic project new` verify a spawn BEFORE announcing it as done,
14
15
  # mirroring how scripts/new-intent already runs IntentValidator before
15
16
  # announcing a new intent (validate-intent).
16
17
  #
@@ -52,7 +53,7 @@ module ProjectValidator
52
53
  errors << "registered project directory does not exist: #{project_path}"
53
54
  end
54
55
 
55
- project_dir = File.join(plastic_home, "projects", slug)
56
+ project_dir = Plastic::StoreLayout.project_root(plastic_home, slug)
56
57
 
57
58
  # Invariant 3: project.yml exists AND parses as YAML.
58
59
  project_yml_path = File.join(project_dir, "project.yml")
@@ -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 "yaml"
5
6
  require "json"
6
7
 
@@ -50,7 +51,7 @@ module QmdSync
50
51
  # to the directory's parent name when no registry match exists.
51
52
  def collection_name(store_dir, plastic_home:)
52
53
  store_dir = File.expand_path(store_dir)
53
- global_store = File.expand_path(File.join(plastic_home, "store"))
54
+ global_store = File.expand_path(Plastic::StoreLayout.global_store(plastic_home))
54
55
  return "plastic-global" if store_dir == global_store
55
56
 
56
57
  if File.basename(store_dir) == "roadmaps"
@@ -69,7 +70,7 @@ module QmdSync
69
70
  def enumerate_stores(plastic_home:)
70
71
  stores = [{
71
72
  collection: "plastic-global",
72
- dir: File.expand_path(File.join(plastic_home, "store")),
73
+ dir: File.expand_path(Plastic::StoreLayout.global_store(plastic_home)),
73
74
  }]
74
75
 
75
76
  projects = load_projects(plastic_home)
@@ -80,7 +81,7 @@ module QmdSync
80
81
  # Project stores live under ~/.plastic/projects/<slug>/store as the mirror;
81
82
  # registry `path` is the project code dir, so the tactical store is the
82
83
  # plastic_home projects mirror.
83
- mirror_store = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
84
+ mirror_store = File.expand_path(File.join(Plastic::StoreLayout.project_root(plastic_home, slug.to_s), "store"))
84
85
  dir = Dir.exist?(mirror_store) ? mirror_store : project_store
85
86
  stores << { collection: "plastic-#{slug}", dir: dir }
86
87
  end
@@ -245,13 +246,13 @@ module QmdSync
245
246
  path = info.is_a?(Hash) ? info["path"] : nil
246
247
  next unless path
247
248
  project_root = File.expand_path(path)
248
- mirror = File.expand_path(File.join(plastic_home, "projects", slug.to_s, "store"))
249
+ mirror = File.expand_path(File.join(Plastic::StoreLayout.project_root(plastic_home, slug.to_s), "store"))
249
250
  return slug.to_s if store_dir == File.join(project_root, "store") || store_dir == mirror
250
251
  end
251
252
  # Fallback: <...>/projects/<slug>/store -> slug, else parent dir name.
252
- parts = store_dir.split(File::SEPARATOR)
253
- idx = parts.rindex("projects")
254
- return parts[idx + 1] if idx && parts[idx + 1] && parts[idx + 2] == "store"
253
+ slug = Plastic::StoreLayout.locate(store_dir).last
254
+ return slug unless slug == Plastic::StoreLayout::GLOBAL
255
+
255
256
  File.basename(File.dirname(store_dir))
256
257
  end
257
258
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "sqlite"
5
+
6
+ module Plastic
7
+ module ReferenceArchive
8
+ NAME = "references.db"
9
+ SCHEMA = <<~SQL
10
+ CREATE TABLE IF NOT EXISTS sqlar(name TEXT PRIMARY KEY, mode INT, mtime INT, sz INT, data BLOB, intent_id TEXT, sha256 TEXT);
11
+ SQL
12
+ INTENT_ID = %r{/([^/-]+)--[^/]+/}
13
+ SKIPPED = %r{(\A|/)(\.git|\.tmp)/|\.DS_Store\z|\.lock\z}
14
+
15
+ def self.path(home)
16
+ File.join(home, NAME)
17
+ end
18
+
19
+ def self.files(home)
20
+ Dir.glob("{store,projects,stores}/**/*", File::FNM_DOTMATCH, base: home)
21
+ .select { |file| File.file?(File.join(home, file)) && File.extname(file) != ".md" && !file.match?(SKIPPED) }.sort
22
+ end
23
+
24
+ def self.build(home)
25
+ known = Sqlite.call(path(home), "#{SCHEMA}SELECT name, mtime, sz FROM sqlar;").to_h { |row| [row["name"], row.values_at("mtime", "sz")] }
26
+ changed = files(home).reject { |file| known[file] == stamp(File.join(home, file)) }
27
+ Sqlite.call(path(home), ["BEGIN;", *changed.map { |file| insert(home, file) }, "COMMIT;"].join("\n"))
28
+ changed.size
29
+ end
30
+
31
+ def self.stamp(source)
32
+ stat = File.stat(source)
33
+ [stat.mtime.to_i, stat.size]
34
+ end
35
+
36
+ def self.insert(home, file)
37
+ source = File.join(home, file)
38
+ stat = File.stat(source)
39
+ id = file[INTENT_ID, 1]
40
+ read = "readfile(#{Sqlite.quote(source)})"
41
+ "INSERT OR REPLACE INTO sqlar VALUES(#{Sqlite.quote(file)}, #{stat.mode}, #{stat.mtime.to_i}, length(#{read}), " \
42
+ "sqlar_compress(#{read}), #{id ? Sqlite.quote(id) : "NULL"}, #{Sqlite.quote(Digest::SHA256.file(source).hexdigest)});"
43
+ end
44
+ end
45
+ end
@@ -52,6 +52,8 @@ module ReleaseGuard
52
52
  # to the stable channel. Pure function, no ENV reads, shared by
53
53
  # scripts/release-check and the test suite so the channel rule has exactly
54
54
  # one implementation (intent 347).
55
+ CHANNELS = {"alpha" => "alpha", "beta" => "beta", "main" => "latest"}.freeze
56
+
55
57
  def self.dist_tag(version)
56
58
  suffix = version[/-(.+)\z/, 1]
57
59
  return "latest" if suffix.nil?
@@ -8,6 +8,7 @@
8
8
  # out. Dependency injection for anything reaching outside the fixture: the
9
9
  # clock is passed as `now:`, git tag reading as `tag_reader:`, and the ANSI
10
10
  # renderer path as `renderer_path:` (D2).
11
+ require_relative "store_layout"
11
12
  require "time"
12
13
  require "json"
13
14
  require "date"
@@ -805,14 +806,14 @@ def self.matching_action_heading(intent_dir, label)
805
806
  # (<home>/projects/<slug>/store/<id--slug>); nil otherwise (a global-store
806
807
  # intent, a project with no `flow:` key, or malformed YAML). Pure: no git,
807
808
  # no shell-out, just the one file this intent's own layout already reads.
808
- PROJECT_LAYOUT_RE = %r{\A(.*)/projects/([^/]+)/store/[^/]+\z}.freeze
809
+ PROJECT_LAYOUT_RE = %r{\A(.*)/(?:projects|stores)/([^/]+)/store/[^/]+\z}.freeze
809
810
 
810
811
  def self.flow_base(intent_dir)
811
812
  m = intent_dir.to_s.match(PROJECT_LAYOUT_RE)
812
813
  return nil unless m
813
814
 
814
815
  home, slug = m[1], m[2]
815
- path = File.join(home, "projects", slug, "project.yml")
816
+ path = File.join(Plastic::StoreLayout.project_root(home, slug), "project.yml")
816
817
  return nil unless File.exist?(path)
817
818
 
818
819
  require "yaml"
@@ -1480,7 +1481,7 @@ def self.matching_action_heading(intent_dir, label)
1480
1481
 
1481
1482
  # D5: "global" is <home> itself; any other slug is <home>/projects/<slug>.
1482
1483
  def self.store_for_slug(home, slug)
1483
- slug == "global" ? home : File.join(home, "projects", slug)
1484
+ slug == "global" ? home : Plastic::StoreLayout.project_root(home, slug)
1484
1485
  end
1485
1486
 
1486
1487
  # D4: the newest valid day directory that is not in the future, when
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RLM
4
+ class Corpus
5
+ def initialize(search)
6
+ @search = search
7
+ end
8
+
9
+ def call(terms, limit)
10
+ @search.call(terms, limit)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "corpus"
4
+
5
+ module RLM
6
+ class Probe
7
+ STOP_WORDS = %w[a an and are as at be by do does for from how in is it of on or that the this to was what when where which who why with].freeze
8
+ DEFAULT_LIMIT = 10
9
+
10
+ def initialize(corpus, limit: DEFAULT_LIMIT)
11
+ @corpus = corpus
12
+ @limit = limit
13
+ end
14
+
15
+ def call(question)
16
+ hits = terms(question).flat_map { |term| @corpus.call([term], @limit) }
17
+ hits.group_by { |row| row["id"] }.values
18
+ .map { |rows| rows.first.merge("score" => rows.size) }
19
+ .sort_by.with_index { |row, index| [-row["score"], index] }
20
+ .first(@limit)
21
+ end
22
+
23
+ private
24
+
25
+ def terms(question)
26
+ question.downcase.scan(/[[:alnum:]]+/).uniq - STOP_WORDS
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "probe"
4
+
5
+ module RLM
6
+ class Query
7
+ DEFAULT_LIMIT = 3
8
+
9
+ def initialize(probe, judge: nil, limit: DEFAULT_LIMIT)
10
+ @probe = probe
11
+ @judge = judge
12
+ @limit = limit
13
+ end
14
+
15
+ def call(question)
16
+ rows = @probe.call(question)
17
+ return rows unless @judge && rows.size > @limit
18
+
19
+ @judge.call(question, rows)
20
+ end
21
+ end
22
+ end
@@ -411,8 +411,8 @@ class RoadmapQueue
411
411
  # --- scope + payload -----------------------------------------------------------
412
412
 
413
413
  def scope_label
414
- m = @roadmaps_dir.to_s.match(%r{/projects/([^/]+)/roadmaps/?\z})
415
- m ? "project:#{m[1]}" : "global"
414
+ m = @roadmaps_dir.to_s.match(%r{/(?:projects|stores)/([^/]+)/roadmaps/?\z})
415
+ (m && m[1] != "global") ? "project:#{m[1]}" : "global"
416
416
  end
417
417
 
418
418
  def payload(mode:, state:, roadmap:, frontier_wave:, dispatchable:, in_flight:, blocked:, tie:, tie_candidates:)
@@ -224,7 +224,7 @@ module RoadmapSavepoint
224
224
 
225
225
  # The tier root is the parent of `roadmaps/` (a live roadmap's grandparent, or, for one
226
226
  # already moved to `roadmaps/archived/`, its great-grandparent); INDEX.md is that root's
227
- # sibling file, matching the layout `plastic-roadmap`'s file-format doc defines.
227
+ # sibling file, matching the layout docs/help/roadmaps.md defines.
228
228
  def index_path_for(roadmap_path)
229
229
  dir = File.dirname(roadmap_path)
230
230
  dir = File.dirname(dir) if File.basename(dir) == "archived"
@@ -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 "yaml"
5
6
  require "date"
6
7
  require "time"
@@ -59,7 +60,7 @@ module RunnerAbsorb
59
60
  # to plain strings), this cap is purely about line length.
60
61
  FINDING_CAP = 200
61
62
 
62
- PROJECT_LAYOUT_RE = %r{\A(.*)/projects/([^/]+)/store/[^/]+\z}.freeze
63
+ PROJECT_LAYOUT_RE = %r{\A(.*)/(?:projects|stores)/([^/]+)/store/[^/]+\z}.freeze
63
64
 
64
65
  # absorb(context, node:, return_path:) -> a result hash describing the one
65
66
  # transition it wrote (or attempted to write). Never raises across its own
@@ -100,6 +101,7 @@ module RunnerAbsorb
100
101
  node_decl = ((context.graph || {})[:nodes] || {})[node] || {}
101
102
  kind = node_decl[:kind]
102
103
  declared_files = normalize_files(node_decl[:files])
104
+ report_path = declared_report_path(intent_dir, node)
103
105
 
104
106
  # v2 NEW-2: a `graph.md` that failed to parse, or a node whose kind
105
107
  # cannot be resolved from it, refuses right here - before ANY check
@@ -143,6 +145,11 @@ module RunnerAbsorb
143
145
  return fail_check(savepoint_path, context, node, "node_mismatch", checks_ran, holder, extra_fields, now, ledger)
144
146
  end
145
147
 
148
+ report_reason = report_contract_violation(kind, report_path, parsed)
149
+ if report_reason
150
+ return fail_check(savepoint_path, context, node, report_reason, checks_ran, holder, extra_fields, now, ledger)
151
+ end
152
+
146
153
  append_findings(intent_dir, node, parsed.findings, now: now)
147
154
 
148
155
  proposal_result = nil
@@ -267,6 +274,14 @@ module RunnerAbsorb
267
274
  end
268
275
 
269
276
  commit_value = merge_commit || parsed.commit
277
+ if report_path
278
+ report_result = persist_report(intent_dir, report_path, parsed.report)
279
+ unless report_result[:ok]
280
+ return finish.call(fail_check(savepoint_path, context, node, report_result[:reason], gates.split("+") + ["report"],
281
+ holder, extra_fields, now, ledger))
282
+ end
283
+ gates = "#{gates}+report"
284
+ end
270
285
  fields = { gates: gates, commit: commit_value, holder: holder, suite: suite_value }.merge(extra_fields)
271
286
  result = write_transition(savepoint_path, context, node, "done", fields, now: now, ledger: ledger)
272
287
 
@@ -420,6 +435,48 @@ module RunnerAbsorb
420
435
  end
421
436
  private_class_method :normalize_scope_path
422
437
 
438
+ # --- research report -------------------------------------------------------
439
+
440
+ def declared_report_path(intent_dir, node)
441
+ path = ReadySet.find_node_path(intent_dir, node)
442
+ return nil unless path
443
+
444
+ parsed = NodeFile.parse(path)
445
+ parsed[:ok] ? parsed[:report] : nil
446
+ end
447
+ private_class_method :declared_report_path
448
+
449
+ def report_contract_violation(kind, declared, parsed)
450
+ return "report_not_declared" if parsed.report && declared.nil?
451
+ return nil unless parsed.status == "done"
452
+ return "report_missing" if declared && parsed.report.to_s.strip.empty?
453
+ return "report_not_research" if parsed.report && kind.to_s != "research"
454
+
455
+ nil
456
+ end
457
+ private_class_method :report_contract_violation
458
+
459
+ def persist_report(intent_dir, relative_path, content)
460
+ resources_root = File.expand_path(File.join(intent_dir, "resources"))
461
+ target = File.expand_path(File.join(intent_dir, relative_path.to_s))
462
+ unless target.start_with?("#{resources_root}/") && target.end_with?(".md")
463
+ return { ok: false, reason: "report_path_invalid" }
464
+ end
465
+
466
+ if File.exist?(target)
467
+ return { ok: true, path: target } if File.binread(target) == content.to_s
468
+
469
+ return { ok: false, reason: "report_conflict" }
470
+ end
471
+
472
+ FileUtils.mkdir_p(File.dirname(target))
473
+ AtomicWrite.write(target, content.to_s)
474
+ { ok: true, path: target }
475
+ rescue StandardError
476
+ { ok: false, reason: "report_write_failed" }
477
+ end
478
+ private_class_method :persist_report
479
+
423
480
  # --- named tests -----------------------------------------------------------
424
481
 
425
482
  # Row 4.19/minor 1: read the node's own failure-mode matrix table, take
@@ -619,7 +676,7 @@ module RunnerAbsorb
619
676
  return nil unless m
620
677
 
621
678
  home, slug = m[1], m[2]
622
- path = File.join(home, "projects", slug, "project.yml")
679
+ path = File.join(Plastic::StoreLayout.project_root(home, slug), "project.yml")
623
680
  return nil unless File.exist?(path)
624
681
 
625
682
  data = begin
@@ -37,7 +37,7 @@ module RunnerDispatch
37
37
  RETURN_CONTRACT = <<~TEXT.freeze
38
38
  RETURN CONTRACT: reply with exactly one YAML document as your final
39
39
  message, nothing else around it. Keys: node, status, commit, summary,
40
- findings, proposed_nodes, proposed_edges, question, reason. status is one
40
+ findings, report, proposed_nodes, proposed_edges, question, reason. status is one
41
41
  of done, failed_verification, needs_decision, blocked. done requires
42
42
  commit; needs_decision requires question; failed_verification and
43
43
  blocked require reason. Anything that does not parse under this closed
@@ -47,7 +47,7 @@ module RunnerDispatch
47
47
  HARD_CAP_RE = /\Ais at its dispatch cap \((\d+)\/(\d+)\)\z/.freeze
48
48
 
49
49
  # D8 (355, n6): the agent every dispatched, non-decision node names - a
50
- # role, never a harness (matrix 6.5), and never `plastic-advisor`, which
50
+ # role, never a harness (matrix 6.5), and never a consultation advisor, which
51
51
  # stays a deliberate, never-auto-dispatched consultation agent.
52
52
  SPAWN_AGENT = "plastic-executor"
53
53
 
@@ -56,8 +56,8 @@ module RunnerDispatch
56
56
  # (NodeInput.test_command_block, n4), and the call cap (n2) - fenced so a
57
57
  # session pastes it straight into the Agent tool (327 D42: the runner
58
58
  # itself never spawns).
59
- def spawn_block(model:, input:, test_command:, call_cap:, agent: SPAWN_AGENT)
60
- lines = ["agent: #{agent}", "model: #{model}", "input: #{input}", test_command,
59
+ def spawn_block(model:, input:, test_command:, call_cap:, effort: AgentModels::DEFAULT_EFFORT, agent: SPAWN_AGENT)
60
+ lines = ["agent: #{agent}", "model: #{model}", "effort: #{effort}", "input: #{input}", test_command,
61
61
  NodeInput.call_cap_sentence(call_cap)]
62
62
  (["```"] + lines + ["```"]).join("\n")
63
63
  end
@@ -220,7 +220,8 @@ module RunnerDispatch
220
220
  savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
221
221
 
222
222
  holder = context.session
223
- model = RunnerPolicy.model_for(kind, config: config)
223
+ model = RunnerPolicy.model_for(kind, config: config, harness: harness)
224
+ effort = RunnerPolicy.effort_for(kind, config: config, harness: harness)
224
225
  expires = RunnerPolicy.lease_expires(kind, now: now)
225
226
  calls_cap = RunnerPolicy.call_cap(kind, config: config)
226
227
 
@@ -267,7 +268,7 @@ module RunnerDispatch
267
268
  # Row 1.19/1.20/D21: harness= rides alongside model= on every `running`
268
269
  # line, resolved once by the caller through HarnessAdapter and threaded
269
270
  # straight through here - never re-resolved, never a literal.
270
- fields = { holder: holder, expires: expires, input: build_result[:sha], model: model, harness: harness,
271
+ fields = { holder: holder, expires: expires, input: build_result[:sha], model: model, effort: effort, harness: harness,
271
272
  calls: calls_cap }
272
273
 
273
274
  result = begin
@@ -287,11 +288,13 @@ module RunnerDispatch
287
288
  end
288
289
 
289
290
  test_command = NodeInput.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
290
- spawn = spawn_block(model: model, input: build_result[:path], test_command: test_command, call_cap: calls_cap)
291
+ spawn = spawn_block(model: model, effort: effort, input: build_result[:path], test_command: test_command,
292
+ call_cap: calls_cap)
291
293
 
292
294
  {
293
295
  ok: true,
294
- entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, worktree: provisioned[:path],
296
+ entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, effort: effort,
297
+ worktree: provisioned[:path],
295
298
  input: build_result[:path], spawn: spawn },
296
299
  }
297
300
  end
@@ -507,6 +510,7 @@ module RunnerDispatch
507
510
  "return_contract" => RETURN_CONTRACT,
508
511
  "dispatch" => dispatched.map do |d|
509
512
  { "node" => d[:node], "kind" => d[:kind], "role" => d[:role], "model" => d[:model],
513
+ "effort" => d[:effort],
510
514
  "worktree" => d[:worktree], "input" => d[:input] }
511
515
  end,
512
516
  "spawn" => dispatched.map { |d| d[:spawn] }
@@ -35,7 +35,7 @@ module RunnerPolicy
35
35
  # resolves through AgentModels::TIER_DEFAULTS, the one place
36
36
  # `plastic-executor`'s shipped tier is already declared (post-execution
37
37
  # review minor 4), rather than a second, independently-drifting literal
38
- # here. `plastic-advisor` carries no lifecycle-stage entry in
38
+ # here. `plastic-primary-advisor` carries no lifecycle-stage entry in
39
39
  # TIER_DEFAULTS at all (it is a consultation agent, never auto-dispatched
40
40
  # - see agent_models.rb's own docstring), so its default stays the one
41
41
  # literal this table cannot source from anywhere else.
@@ -43,7 +43,7 @@ module RunnerPolicy
43
43
  DEFAULT_ADVISOR_MODEL = "opus"
44
44
 
45
45
  EXECUTOR_CONFIG_KEY = "plastic-executor"
46
- ADVISOR_CONFIG_KEY = "plastic-advisor"
46
+ ADVISOR_CONFIG_KEY = "plastic-primary-advisor"
47
47
 
48
48
  # {model_role:, worktree:, retry_cap:, diff_rule:, lease_minutes:} per kind
49
49
  # (327 D12). `decision` carries no retry cap or lease: it is never
@@ -69,24 +69,42 @@ module RunnerPolicy
69
69
  # verify resolves the advisor model, and a config with no override falls
70
70
  # back to the shipped default rather than an empty string (`running`
71
71
  # requires a non-blank `model=`).
72
- def model_for(kind, config: {})
73
- policy_for(kind)[:model_role] == :advisor ? advisor_model(config: config) : executor_model(config: config)
72
+ def model_for(kind, config: {}, harness: "claude-code")
73
+ alias_or_id = policy_for(kind)[:model_role] == :advisor ? advisor_model(config: config, harness: harness) :
74
+ executor_model(config: config, harness: harness)
75
+ codex_harness?(harness) ? (AgentModels.codex_model_for(alias_or_id) || alias_or_id) : alias_or_id
74
76
  end
75
77
 
76
- def executor_model(config: {})
77
- resolve_model(config, EXECUTOR_CONFIG_KEY, DEFAULT_EXECUTOR_MODEL)
78
+ def executor_model(config: {}, harness: "claude-code")
79
+ resolve_model(config, EXECUTOR_CONFIG_KEY, DEFAULT_EXECUTOR_MODEL, harness)
78
80
  end
79
81
 
80
- def advisor_model(config: {})
81
- resolve_model(config, ADVISOR_CONFIG_KEY, DEFAULT_ADVISOR_MODEL)
82
+ def advisor_model(config: {}, harness: "claude-code")
83
+ resolve_model(config, ADVISOR_CONFIG_KEY, DEFAULT_ADVISOR_MODEL, harness)
82
84
  end
83
85
 
84
- def resolve_model(config, key, shipped_default)
85
- value = AgentModels.models_section(config)[key]
86
+ def effort_for(kind, config: {}, harness: "claude-code")
87
+ key = policy_for(kind)[:model_role] == :advisor ? ADVISOR_CONFIG_KEY : EXECUTOR_CONFIG_KEY
88
+ value = AgentModels.efforts_section(config, harness_name(harness))[key]
89
+ present?(value) ? value : AgentModels::DEFAULT_EFFORT
90
+ end
91
+
92
+ def resolve_model(config, key, shipped_default, harness)
93
+ value = AgentModels.models_section(config, harness: harness_name(harness))[key]
86
94
  present?(value) ? value : shipped_default
87
95
  end
88
96
  private_class_method :resolve_model
89
97
 
98
+ def harness_name(harness)
99
+ codex_harness?(harness) ? "codex" : "claude"
100
+ end
101
+ private_class_method :harness_name
102
+
103
+ def codex_harness?(harness)
104
+ harness.to_s == "codex"
105
+ end
106
+ private_class_method :codex_harness?
107
+
90
108
  def present?(value)
91
109
  !(value.nil? || value.to_s.strip.empty?)
92
110
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "sqlite"
5
+
6
+ module Plastic
7
+ module SearchIndex
8
+ NAME = "knowledge_graph.db"
9
+ SCHEMA = <<~SQL
10
+ CREATE TABLE doc(id INTEGER PRIMARY KEY, path TEXT, sz INT, data BLOB, hash TEXT);
11
+ CREATE VIEW doc_v AS SELECT id AS rowid, CAST(sqlar_uncompress(data, sz) AS TEXT) AS body FROM doc;
12
+ CREATE VIRTUAL TABLE ft USING fts5(body, content='doc_v', content_rowid='rowid');
13
+ SQL
14
+ FINISH = "INSERT INTO ft(ft) VALUES('rebuild'); INSERT INTO ft(ft) VALUES('optimize'); VACUUM;"
15
+
16
+ def self.path(home)
17
+ File.join(home, NAME)
18
+ end
19
+
20
+ def self.files(home)
21
+ Dir.glob("{store,projects,stores}/**/*.md", File::FNM_DOTMATCH, base: home).reject { |file| file.include?("/.git/") }.sort
22
+ end
23
+
24
+ def self.build(home)
25
+ files = files(home)
26
+ draft = "#{path(home)}.tmp"
27
+ FileUtils.rm_f(draft)
28
+ Sqlite.call(draft, [SCHEMA, "BEGIN;", *files.map { |file| insert(home, file) }, "COMMIT;", FINISH].join("\n"))
29
+ File.rename(draft, path(home))
30
+ files.size
31
+ ensure
32
+ FileUtils.rm_f(draft.to_s)
33
+ end
34
+
35
+ def self.insert(home, file)
36
+ source = quote(File.join(home, file))
37
+ "INSERT INTO doc(path, sz, data, hash) VALUES(#{quote(file)}, length(readfile(#{source})), " \
38
+ "sqlar_compress(readfile(#{source})), lower(hex(sha3(readfile(#{source}), 256))));"
39
+ end
40
+
41
+ def self.search(home, terms, limit:, prefix: "")
42
+ phrases = terms.map { |term| %("#{term.delete('"')}") }.join(" ")
43
+ Sqlite.call(path(home), <<~SQL, readonly: true)
44
+ SELECT doc.id AS id, doc.path AS path, snippet(ft, 0, '[', ']', ' ... ', 12) AS excerpt
45
+ FROM ft JOIN doc ON doc.id = ft.rowid
46
+ WHERE ft MATCH #{quote(phrases)} AND doc.path LIKE #{quote("#{prefix}%")}
47
+ ORDER BY rank LIMIT #{Integer(limit)};
48
+ SQL
49
+ end
50
+
51
+ def self.quote(text)
52
+ Sqlite.quote(text)
53
+ end
54
+ end
55
+ 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 "yaml"
5
6
  require_relative "worktree"
6
7
  require_relative "scaffold_intent"
@@ -160,7 +161,7 @@ module SessionGit
160
161
  def read_project_flow(slug, plastic_home)
161
162
  return nil if slug == "global"
162
163
 
163
- project_yml = File.join(plastic_home, "projects", slug, "project.yml")
164
+ project_yml = File.join(Plastic::StoreLayout.project_root(plastic_home, slug), "project.yml")
164
165
  return nil unless File.exist?(project_yml)
165
166
 
166
167
  data = begin
@@ -196,7 +197,7 @@ module SessionGit
196
197
  resolver ||= lambda { |short_session|
197
198
  ActiveDelivery.resolve_by_short_session(
198
199
  global_store: store,
199
- project_roots: ActiveDelivery.project_roots(File.dirname(store)),
200
+ project_roots: ActiveDelivery.project_roots(Plastic::StoreLayout.locate(store).first),
200
201
  short_session: short_session
201
202
  )
202
203
  }
@@ -333,7 +334,7 @@ module SessionGit
333
334
  # a detached HEAD. Both modes now share exactly one check of each.
334
335
  def commit!(cwd:, summary:, day:, session:, plastic_home:, store: nil,
335
336
  runner: Worktree::ShellRunner.new, gh_runner: GhRunner.new)
336
- effective_store = store || File.join(plastic_home, "store")
337
+ effective_store = store || Plastic::StoreLayout.global_store(plastic_home)
337
338
  repo = resolve_repo(cwd, runner: runner)
338
339
  return note("no repo") if repo.nil?
339
340
 
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+
6
+ module Plastic
7
+ module Sqlite
8
+ Error = Class.new(StandardError)
9
+
10
+ def self.quote(text)
11
+ "'#{text.gsub("'", "''")}'"
12
+ end
13
+
14
+ def self.call(path, sql, readonly: false)
15
+ flags = readonly ? ["-readonly"] : []
16
+ out, err, status = Open3.capture3("sqlite3", "-json", *flags, path, stdin_data: sql)
17
+ raise Error, err.strip unless status.success?
18
+
19
+ out.strip.empty? ? [] : JSON.parse(out)
20
+ end
21
+ end
22
+ 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 "store_provisioning"
5
6
 
6
7
  # StoreDiscovery: the single source of truth for "what stores exist" (intent 189).
@@ -39,20 +40,20 @@ module StoreDiscovery
39
40
  stores = []
40
41
  missing = []
41
42
 
42
- global_store = File.join(plastic_home, "store")
43
+ global_store = Plastic::StoreLayout.global_store(plastic_home)
43
44
  if File.directory?(global_store)
44
- stores << { key: "global", slug: "global", root: plastic_home,
45
- store: global_store, index: File.join(plastic_home, "INDEX.md") }
45
+ global_root = Plastic::StoreLayout.global_root(plastic_home)
46
+ stores << { key: "global", slug: "global", root: global_root,
47
+ store: global_store, index: File.join(global_root, "INDEX.md") }
46
48
  end
47
49
 
48
50
  registered = StoreProvisioning.load_projects(plastic_home) # { slug => info }, {} on error/absence
49
- projects_root = File.join(plastic_home, "projects")
50
- on_disk = File.directory?(projects_root) ? Dir.children(projects_root).reject { |e| e.start_with?(".") } : []
51
+ on_disk = Plastic::StoreLayout.project_slugs(plastic_home)
51
52
 
52
53
  all_slugs = (registered.keys + on_disk).uniq.sort
53
54
 
54
55
  all_slugs.each do |slug|
55
- root = File.join(projects_root, slug)
56
+ root = Plastic::StoreLayout.project_root(plastic_home, slug)
56
57
  store_dir = File.join(root, "store")
57
58
  if File.directory?(store_dir)
58
59
  stores << { key: "project:#{slug}", slug: slug, root: root,