@zalom/plastic 2.0.0-alpha.9 → 2.0.1

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 (322) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +20 -15
  4. package/agents/plastic-executor.md +15 -4
  5. package/agents/plastic-node-research.md +30 -0
  6. package/agents/plastic-node-verify.md +28 -0
  7. package/agents/plastic-node-work.md +33 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/assets/plastic-logo.svg +1 -0
  11. package/bin/crap +4 -0
  12. package/bin/lib/context_budget.rb +43 -6
  13. package/bin/lib/skill_census.rb +839 -0
  14. package/bin/plastic +6 -0
  15. package/bin/plastic-skill-census +114 -0
  16. package/bin/test +24 -4
  17. package/bin/verify-change +345 -0
  18. package/config_asks.yml +4 -4
  19. package/deprecations.yml +1 -1
  20. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  21. package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
  22. package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
  23. package/docs/help/human-report-contract.md +152 -0
  24. package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
  25. package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +11 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
  28. package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
  29. package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
  30. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  31. package/hooks/call-budget +4 -0
  32. package/hooks/hooks.json +24 -0
  33. package/hooks/message-display +55 -2
  34. package/hooks/session-start +5 -1
  35. package/hooks/statusline +32 -27
  36. package/hooks/stop +5 -0
  37. package/package.json +5 -3
  38. package/scripts/append-ledger +2 -1
  39. package/scripts/dashboard.rb +267 -16
  40. package/scripts/day-summary +2 -1
  41. package/scripts/doctor.rb +680 -39
  42. package/scripts/end-intent +153 -30
  43. package/scripts/exec-worktree +5 -5
  44. package/scripts/file-session-intent +2 -1
  45. package/scripts/graph-measure +249 -0
  46. package/scripts/hook-call-budget +222 -0
  47. package/scripts/hook-capture +24 -123
  48. package/scripts/hook-close +2 -1
  49. package/scripts/hook-message-display +7 -0
  50. package/scripts/hook-record +24 -16
  51. package/scripts/hook-savepoint +27 -3
  52. package/scripts/hook-session-start +359 -321
  53. package/scripts/hook-stop +58 -0
  54. package/scripts/index-projection +74 -0
  55. package/scripts/insight-append +17 -4
  56. package/scripts/install.rb +9 -7
  57. package/scripts/lib/action_graph_shim.rb +279 -0
  58. package/scripts/lib/active_delivery.rb +105 -0
  59. package/scripts/lib/agent_models.rb +63 -25
  60. package/scripts/lib/arm.rb +48 -83
  61. package/scripts/lib/atomic_write.rb +31 -0
  62. package/scripts/lib/backup.rb +65 -0
  63. package/scripts/lib/cli/command.rb +92 -0
  64. package/scripts/lib/cli/commands/auto.rb +18 -0
  65. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  66. package/scripts/lib/cli/commands/auto_lock.rb +99 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +31 -0
  69. package/scripts/lib/cli/commands/backup.rb +43 -0
  70. package/scripts/lib/cli/commands/checkout.rb +25 -0
  71. package/scripts/lib/cli/commands/continue.rb +66 -0
  72. package/scripts/lib/cli/commands/doctor.rb +81 -0
  73. package/scripts/lib/cli/commands/feedback.rb +40 -0
  74. package/scripts/lib/cli/commands/help.rb +69 -0
  75. package/scripts/lib/cli/commands/hook.rb +32 -0
  76. package/scripts/lib/cli/commands/index.rb +23 -0
  77. package/scripts/lib/cli/commands/install.rb +21 -0
  78. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  79. package/scripts/lib/cli/commands/intent.rb +19 -0
  80. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  81. package/scripts/lib/cli/commands/intent_command.rb +53 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +62 -0
  83. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  84. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  85. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  86. package/scripts/lib/cli/commands/intent_show.rb +32 -0
  87. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  88. package/scripts/lib/cli/commands/intent_step.rb +71 -0
  89. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  90. package/scripts/lib/cli/commands/migrate.rb +16 -0
  91. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  92. package/scripts/lib/cli/commands/next.rb +54 -0
  93. package/scripts/lib/cli/commands/project.rb +19 -0
  94. package/scripts/lib/cli/commands/project_links.rb +42 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +72 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +33 -0
  99. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  100. package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
  101. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  102. package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
  103. package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
  105. package/scripts/lib/cli/commands/rollback.rb +20 -0
  106. package/scripts/lib/cli/commands/search.rb +60 -0
  107. package/scripts/lib/cli/commands/session.rb +18 -0
  108. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  109. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  110. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  111. package/scripts/lib/cli/commands/status.rb +68 -0
  112. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  113. package/scripts/lib/cli/commands/sync.rb +46 -0
  114. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  115. package/scripts/lib/cli/commands/update.rb +20 -0
  116. package/scripts/lib/cli/commands/version.rb +53 -0
  117. package/scripts/lib/cli/frontier.rb +86 -0
  118. package/scripts/lib/cli/intent_progress.rb +36 -0
  119. package/scripts/lib/cli/legacy.rb +80 -0
  120. package/scripts/lib/cli/output.rb +137 -0
  121. package/scripts/lib/cli/scope.rb +134 -0
  122. package/scripts/lib/cli/table.rb +65 -0
  123. package/scripts/lib/cli.rb +94 -0
  124. package/scripts/lib/codex_adapter.rb +198 -0
  125. package/scripts/lib/compact_instructions.rb +13 -5
  126. package/scripts/lib/core_integrity.rb +71 -0
  127. package/scripts/lib/dashboard_screen.rb +40 -0
  128. package/scripts/lib/data_boundary.rb +132 -0
  129. package/scripts/lib/day_summary.rb +23 -14
  130. package/scripts/lib/doctor_core.rb +114 -38
  131. package/scripts/lib/doctor_session_ledger.rb +5 -53
  132. package/scripts/lib/engine_permissions.rb +88 -0
  133. package/scripts/lib/exec_worktree.rb +24 -21
  134. package/scripts/lib/feedback_report.rb +1 -1
  135. package/scripts/lib/graph_edges.rb +137 -0
  136. package/scripts/lib/graph_file.rb +246 -0
  137. package/scripts/lib/graph_measure.rb +645 -0
  138. package/scripts/lib/graph_measure_budget.rb +409 -0
  139. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  140. package/scripts/lib/graph_measure_models.rb +413 -0
  141. package/scripts/lib/graph_measure_report.rb +532 -0
  142. package/scripts/lib/graph_tree.rb +98 -0
  143. package/scripts/lib/guarded_append.rb +155 -0
  144. package/scripts/lib/handoff.rb +40 -13
  145. package/scripts/lib/harness_adapter.rb +184 -0
  146. package/scripts/lib/hook_registry.rb +27 -3
  147. package/scripts/lib/hook_replay.rb +229 -0
  148. package/scripts/lib/index_entry.rb +62 -0
  149. package/scripts/lib/index_projection.rb +201 -0
  150. package/scripts/lib/insights.rb +1 -1
  151. package/scripts/lib/installer_core.rb +531 -71
  152. package/scripts/lib/intent_screen.rb +4 -4
  153. package/scripts/lib/intent_screen_ansi.rb +73 -12
  154. package/scripts/lib/intent_validator.rb +2 -2
  155. package/scripts/lib/lock.rb +10 -11
  156. package/scripts/lib/message_display.rb +350 -54
  157. package/scripts/lib/meter_watch.rb +185 -0
  158. package/scripts/lib/node_file.rb +234 -0
  159. package/scripts/lib/node_ids.rb +99 -0
  160. package/scripts/lib/node_input.rb +913 -0
  161. package/scripts/lib/node_input_compatibility.rb +62 -0
  162. package/scripts/lib/node_ledger.rb +386 -0
  163. package/scripts/lib/node_progress.rb +153 -0
  164. package/scripts/lib/node_return.rb +204 -0
  165. package/scripts/lib/node_worktree.rb +337 -0
  166. package/scripts/lib/outcome_report.rb +440 -0
  167. package/scripts/lib/preflight.rb +4 -6
  168. package/scripts/lib/project_config.rb +46 -0
  169. package/scripts/lib/project_validator.rb +3 -2
  170. package/scripts/lib/qmd_sync.rb +8 -7
  171. package/scripts/lib/ready_set.rb +462 -0
  172. package/scripts/lib/reference_archive.rb +45 -0
  173. package/scripts/lib/release_guard.rb +18 -0
  174. package/scripts/lib/report_screen.rb +1371 -47
  175. package/scripts/lib/rlm/corpus.rb +13 -0
  176. package/scripts/lib/rlm/probe.rb +29 -0
  177. package/scripts/lib/rlm/query.rb +22 -0
  178. package/scripts/lib/roadmap_graph.rb +210 -0
  179. package/scripts/lib/roadmap_migration.rb +95 -0
  180. package/scripts/lib/roadmap_queue.rb +158 -8
  181. package/scripts/lib/roadmap_render.rb +150 -0
  182. package/scripts/lib/roadmap_savepoint.rb +64 -14
  183. package/scripts/lib/runner_absorb.rb +703 -0
  184. package/scripts/lib/runner_answer.rb +206 -0
  185. package/scripts/lib/runner_core.rb +194 -0
  186. package/scripts/lib/runner_dispatch.rb +525 -0
  187. package/scripts/lib/runner_policy.rb +191 -0
  188. package/scripts/lib/runner_proposals.rb +275 -0
  189. package/scripts/lib/runner_rewind.rb +201 -0
  190. package/scripts/lib/runner_sweep.rb +231 -0
  191. package/scripts/lib/runner_until_empty.rb +252 -0
  192. package/scripts/lib/runner_watch.rb +389 -0
  193. package/scripts/lib/savepoint.rb +141 -19
  194. package/scripts/lib/scaffold_intent.rb +6 -3
  195. package/scripts/lib/screen_paint.rb +365 -28
  196. package/scripts/lib/screens/dashboard.rb +20 -0
  197. package/scripts/lib/screens/plan.rb +18 -0
  198. package/scripts/lib/screens/roadmap.rb +15 -0
  199. package/scripts/lib/search_index.rb +55 -0
  200. package/scripts/lib/session_close.rb +30 -28
  201. package/scripts/lib/session_git.rb +25 -18
  202. package/scripts/lib/session_ledger.rb +48 -4
  203. package/scripts/lib/session_usage.rb +190 -0
  204. package/scripts/lib/sqlite.rb +22 -0
  205. package/scripts/lib/stop_gate.rb +95 -0
  206. package/scripts/lib/store_discovery.rb +7 -6
  207. package/scripts/lib/store_layout.rb +54 -0
  208. package/scripts/lib/store_provisioning.rb +2 -1
  209. package/scripts/lib/store_sync.rb +85 -0
  210. package/scripts/lib/stores_move.rb +93 -0
  211. package/scripts/lib/verify_intent.rb +36 -8
  212. package/scripts/lib/version_number.rb +48 -0
  213. package/scripts/lib/work_graph.rb +59 -0
  214. package/scripts/lib/work_graph_validator.rb +201 -0
  215. package/scripts/lib/worktree.rb +27 -32
  216. package/scripts/lib/worktree_sweep.rb +6 -5
  217. package/scripts/link-suggest +2 -1
  218. package/scripts/meter-watch +57 -0
  219. package/scripts/migrate-to-global +1 -1
  220. package/scripts/new-intent +4 -13
  221. package/scripts/node-input +92 -0
  222. package/scripts/node-run +225 -0
  223. package/scripts/node-transition +291 -0
  224. package/scripts/outcome-report +74 -0
  225. package/scripts/plastic-lock +42 -33
  226. package/scripts/promote-session-item +3 -2
  227. package/scripts/read-config +53 -9
  228. package/scripts/ready-set +126 -0
  229. package/scripts/release-check +123 -0
  230. package/scripts/report-screen +177 -16
  231. package/scripts/roadmap-graph +119 -0
  232. package/scripts/roadmap-savepoint +7 -0
  233. package/scripts/runner +581 -0
  234. package/scripts/savepoint-note +11 -9
  235. package/scripts/session-commit +2 -1
  236. package/scripts/session-usage +56 -0
  237. package/scripts/skill-lint +115 -6
  238. package/scripts/spawn-preamble +2 -2
  239. package/scripts/update.rb +25 -4
  240. package/scripts/validate-work-graph +39 -0
  241. package/scripts/verify-intent +3 -2
  242. package/scripts/write-handoff +2 -1
  243. package/templates/agents.md +7 -7
  244. package/templates/config.yml +16 -9
  245. package/templates/dashboard-screen.md +22 -0
  246. package/templates/display-fixture.md +21 -0
  247. package/templates/graph.md +16 -0
  248. package/templates/index.md +1 -1
  249. package/templates/intent-screen.md +1 -1
  250. package/templates/node-decision.md +11 -0
  251. package/templates/node-research.md +13 -0
  252. package/templates/node-verify.md +13 -0
  253. package/templates/node-work.md +22 -0
  254. package/templates/outcome.md +8 -3
  255. package/templates/project.yml +1 -1
  256. package/templates/render.css +10 -0
  257. package/templates/report-plan.md +15 -0
  258. package/templates/report-roadmap-delivered.md +10 -0
  259. package/templates/report-roadmap-plan.md +9 -0
  260. package/templates/report-roadmap-state.md +9 -0
  261. package/templates/report-state.md +1 -1
  262. package/templates/roadmap.md +13 -0
  263. package/bin/plastic.js +0 -70
  264. package/scripts/lib/bridge.rb +0 -116
  265. package/skills/agent-advisor/SKILL.md +0 -92
  266. package/skills/auto/SKILL.md +0 -296
  267. package/skills/auto/evals/evals.json +0 -255
  268. package/skills/auto/references/end-tail.md +0 -66
  269. package/skills/auto/references/human-report-contract.md +0 -78
  270. package/skills/conventions/SKILL.md +0 -29
  271. package/skills/dashboard/SKILL.md +0 -169
  272. package/skills/dashboard/evals/evals.json +0 -38
  273. package/skills/dashboard/references/classification.md +0 -22
  274. package/skills/dashboard/templates/dashboard-global.md +0 -20
  275. package/skills/dashboard/templates/dashboard-project.md +0 -19
  276. package/skills/direct/SKILL.md +0 -66
  277. package/skills/direct/references/request-signals.md +0 -59
  278. package/skills/doctor/SKILL.md +0 -299
  279. package/skills/doctor/report.md +0 -102
  280. package/skills/feedback/SKILL.md +0 -98
  281. package/skills/feedback/references/transport-and-privacy.md +0 -65
  282. package/skills/feedback/report.md +0 -36
  283. package/skills/install/SKILL.md +0 -217
  284. package/skills/intent-continuing/SKILL.md +0 -154
  285. package/skills/intent-continuing/references/board-fill.md +0 -43
  286. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  287. package/skills/intent-continuing/references/context-management.md +0 -28
  288. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  289. package/skills/intent-creating/SKILL.md +0 -164
  290. package/skills/intent-creating/evals/evals.json +0 -72
  291. package/skills/intent-creating/references/lifecycle.md +0 -81
  292. package/skills/intent-creating/references/wikilinks.md +0 -8
  293. package/skills/intent-ending/SKILL.md +0 -176
  294. package/skills/intent-ending/evals/evals.json +0 -74
  295. package/skills/intent-executing/SKILL.md +0 -170
  296. package/skills/intent-executing/evals/evals.json +0 -66
  297. package/skills/intent-executing/implementer-prompt.md +0 -42
  298. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  299. package/skills/intent-speccing/SKILL.md +0 -130
  300. package/skills/intent-speccing/evals/evals.json +0 -126
  301. package/skills/intent-speccing/references/design-principles.md +0 -44
  302. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  303. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  304. package/skills/project-creating/SKILL.md +0 -162
  305. package/skills/project-creating/references/hubs-projects.md +0 -55
  306. package/skills/project-creating/references/project-scaffolding.md +0 -97
  307. package/skills/releasing/SKILL.md +0 -337
  308. package/skills/releasing/references/deprecations.md +0 -60
  309. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  310. package/skills/releasing/references/release-lines.md +0 -105
  311. package/skills/roadmap/SKILL.md +0 -64
  312. package/skills/roadmap/references/file-format.md +0 -124
  313. package/skills/roadmap/references/operations.md +0 -112
  314. package/skills/rollback/SKILL.md +0 -91
  315. package/skills/tutorial/SKILL.md +0 -65
  316. package/skills/tutorial/evals/evals.json +0 -186
  317. package/skills/uninstall/SKILL.md +0 -75
  318. package/skills/update/SKILL.md +0 -126
  319. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  320. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  321. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  322. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -17,8 +17,9 @@ module SessionClose
17
17
  NOOP_REASONS = %w[clear resume].freeze
18
18
 
19
19
  # The default hand-off writer (intent 311, spec D6): renders this session's
20
- # share of the pointer day into handoff--<session>.md before the tmp dir
21
- # goes. The hook script builds it with the shipped templates dir.
20
+ # share of its session day (SessionLedger.session_day, graph.md D7) into
21
+ # handoff--<session>.md before the tmp dir goes. The hook script builds it
22
+ # with the shipped templates dir.
22
23
  def default_handoff(templates)
23
24
  lambda do |store, day, session|
24
25
  Handoff.write(store: store, day: day, session: session, trigger: "close", templates: templates)
@@ -45,26 +46,33 @@ module SessionClose
45
46
  session = SessionLedger.short_session_id(payload.is_a?(Hash) ? payload["session_id"] : nil, nil)
46
47
  return report if session.empty?
47
48
 
48
- pointer_day = safely { read_pointer_day(store, session) } || today
49
+ days = safely { SessionLedger.session_days(store, session, today: today) } || []
50
+ days = [today] if days.empty?
51
+ session_day = days.last
49
52
  project = "global"
50
53
 
54
+ # Intent 344 (G11, D13): a session that crossed midnight carries pending
55
+ # lines on more than one day ledger; every one of them drops, not only
56
+ # the newest, so a close never leaves an earlier day's lines pending.
51
57
  report[:dropped] = safely do
52
- checklist = SessionLedger.checklist_path(store, pointer_day)
53
- count = SessionLedger.flip_all(checklist, from: :pending, to: :dropped, session: session)
54
- if count.positive?
55
- SessionLedger.append_line(SessionLedger.savepoint_path(store, pointer_day),
56
- SessionLedger.savepoint_line("Note", session, project,
57
- "dropped #{count} pending lines at close", now: now))
58
+ days.sum do |day|
59
+ checklist = SessionLedger.checklist_path(store, day)
60
+ count = SessionLedger.flip_all(checklist, from: :pending, to: :dropped, session: session)
61
+ if count.positive?
62
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, day),
63
+ SessionLedger.savepoint_line("Note", session, project,
64
+ "dropped #{count} pending lines at close", now: now))
65
+ end
66
+ count
58
67
  end
59
- count
60
68
  end || 0
61
69
 
62
70
  # The hand-off (intent 311, spec D6) is written after the drop, so it
63
- # reflects it, and before the tmp dir goes, since the pointer lives there.
64
- # An intent pointer falls back to today, the same as the drop above.
71
+ # reflects it, and before the tmp dir goes. A session with no line in the
72
+ # day ledger's window falls back to today, the same as the drop above.
65
73
  if handoff
66
74
  report[:handoff] = safely do
67
- handoff.call(store, pointer_day, session)
75
+ handoff.call(store, session_day, session)
68
76
  true
69
77
  end || false
70
78
  end
@@ -75,27 +83,21 @@ module SessionClose
75
83
  true
76
84
  end || false
77
85
 
78
- if pointer_day < today
86
+ # One carry spawns per day earlier than today, oldest first; a single
87
+ # earlier day keeps reporting its own arg array (not a one-element
88
+ # list) so an existing caller reading report[:spawned] as one array is
89
+ # unaffected.
90
+ earlier_days = days.select { |day| day < today }
91
+ unless earlier_days.empty?
79
92
  report[:spawned] = safely do
80
- args = ["--day", pointer_day, "--carry-to", today, "--store", store]
81
- spawner.call(args)
82
- args
93
+ arg_lists = earlier_days.map { |day| ["--day", day, "--carry-to", today, "--store", store] }
94
+ arg_lists.each { |args| spawner.call(args) }
95
+ arg_lists.length == 1 ? arg_lists.first : arg_lists
83
96
  end
84
97
  end
85
98
  report
86
99
  end
87
100
 
88
- # The pointer names the intent this session records into; a day id means
89
- # the day ledger. Anything else (an intent id) means an auto team owns the
90
- # record and the close touches no day ledger.
91
- def read_pointer_day(store, session)
92
- path = SessionLedger.pointer_path(store, session)
93
- return nil unless File.exist?(path)
94
-
95
- value = File.read(path).strip
96
- SessionLedger.valid_day_id?(value) ? value : nil
97
- end
98
-
99
101
  def safely
100
102
  yield
101
103
  rescue StandardError
@@ -1,10 +1,12 @@
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"
7
8
  require_relative "session_ledger"
9
+ require_relative "active_delivery"
8
10
 
9
11
  # SessionGit - the branch model and per-repo flow settings behind
10
12
  # `scripts/session-commit` (intent 300), which is how a checklist item
@@ -159,7 +161,7 @@ module SessionGit
159
161
  def read_project_flow(slug, plastic_home)
160
162
  return nil if slug == "global"
161
163
 
162
- project_yml = File.join(plastic_home, "projects", slug, "project.yml")
164
+ project_yml = File.join(Plastic::StoreLayout.project_root(plastic_home, slug), "project.yml")
163
165
  return nil unless File.exist?(project_yml)
164
166
 
165
167
  data = begin
@@ -185,24 +187,29 @@ module SessionGit
185
187
  text.to_s.downcase.scan(/[a-z0-9]+/).first(max_words).join("-")
186
188
  end
187
189
 
188
- # The intent id named by the session's pointer file when `ticket_source`
189
- # is `intent_id`, else the day id (spec D4). Per intent 298 spec D6, the
190
- # pointer at `.tmp/<session>/current` holds exactly one line: either
191
- # today's day id (the session records into the day ledger) or an intent
192
- # id (an auto team owns the record). A day id in the pointer is therefore
193
- # not an intent name, and falls back to the day id here too, which is the
194
- # same value either way.
195
- def resolve_ticket(day:, store:, session:, ticket_source:)
190
+ # The delivering intent's id when `ticket_source` is `intent_id`, else the
191
+ # day id (344 n2, D4-D7, D12). `resolver` is injected so tests need no
192
+ # real ~/.plastic; it defaults to ActiveDelivery.resolve_by_short_session
193
+ # against the store's own project roots.
194
+ def resolve_ticket(day:, store:, session:, ticket_source:, resolver: nil)
196
195
  return day.to_s unless ticket_source.to_s == "intent_id"
197
196
 
198
- pointer = SessionLedger.pointer_path(store, session)
199
- return day.to_s unless File.exist?(pointer)
197
+ resolver ||= lambda { |short_session|
198
+ ActiveDelivery.resolve_by_short_session(
199
+ global_store: store,
200
+ project_roots: ActiveDelivery.project_roots(Plastic::StoreLayout.locate(store).first),
201
+ short_session: short_session
202
+ )
203
+ }
200
204
 
201
- content = File.read(pointer).to_s.strip
202
- return day.to_s if content.empty?
203
- return day.to_s if SessionLedger.valid_day_id?(content)
205
+ dir = begin
206
+ resolver.call(session)
207
+ rescue StandardError
208
+ nil
209
+ end
210
+ return day.to_s if dir.to_s.empty?
204
211
 
205
- content
212
+ File.basename(dir).split("--", 2).first
206
213
  end
207
214
 
208
215
  # --- commit message ------------------------------------------------------------
@@ -245,7 +252,7 @@ module SessionGit
245
252
  # The branch HEAD points to, even on an unborn branch (review R3: a fresh
246
253
  # `git init`, zero commits). `git rev-parse --abbrev-ref HEAD` FAILS on an
247
254
  # unborn branch (there is no commit for it to resolve yet), which the old
248
- # implementation misread as "cannot determine a branch" and folded into
255
+ # implementation misread as "cannot determine a branch" and merged into
249
256
  # the detached-HEAD case. `git symbolic-ref --quiet --short HEAD` succeeds
250
257
  # on both a normal AND an unborn branch (HEAD is a symbolic ref to
251
258
  # `refs/heads/<name>` in both cases) and only fails when HEAD is
@@ -327,7 +334,7 @@ module SessionGit
327
334
  # a detached HEAD. Both modes now share exactly one check of each.
328
335
  def commit!(cwd:, summary:, day:, session:, plastic_home:, store: nil,
329
336
  runner: Worktree::ShellRunner.new, gh_runner: GhRunner.new)
330
- effective_store = store || File.join(plastic_home, "store")
337
+ effective_store = store || Plastic::StoreLayout.global_store(plastic_home)
331
338
  repo = resolve_repo(cwd, runner: runner)
332
339
  return note("no repo") if repo.nil?
333
340
 
@@ -354,7 +361,7 @@ module SessionGit
354
361
 
355
362
  # An unknown mode/workspace value, or workspace: worktree's degradation
356
363
  # to checkout, is itself a degradation (spec D2, BLOCKER 3 ruling), so
357
- # it always turns the outcome into a Note, folding both facts into the
364
+ # it always turns the outcome into a Note, merging both facts into the
358
365
  # single savepoint line spec D7 allows.
359
366
  Result.new(message: "#{flow_notes.join('; ')}; #{result.message}", event: "Note")
360
367
  end
@@ -29,6 +29,10 @@ module SessionLedger
29
29
 
30
30
  SESSIONS_DIR = ".sessions"
31
31
  TMP_DIR = ".tmp"
32
+ # Named here, not in a CLI, so a caller can reference the key without the
33
+ # literal harness-branded env var name appearing in its own source (a few
34
+ # scripts are guarded against naming a harness at all).
35
+ SESSION_ID_ENV_KEY = "CLAUDE_CODE_SESSION_ID"
32
36
  DAY_ID = /\A\d{8}\z/
33
37
  EVENTS = %w[Item Done Note]
34
38
  STATES = { pending: "~", open: " ", done: "x", moved: ">", dropped: "-", promoted: "^" }.freeze
@@ -150,14 +154,54 @@ module SessionLedger
150
154
  File.join(tmp_root(store), session_id)
151
155
  end
152
156
 
153
- def pointer_path(store, session_id)
154
- File.join(session_tmp_dir(store, session_id), "current")
155
- end
156
-
157
157
  def heartbeat_path(store, session_id)
158
158
  File.join(session_tmp_dir(store, session_id), "heartbeat")
159
159
  end
160
160
 
161
+ # --- Session day (spec D7 replacement for the retired pointer) ------------
162
+
163
+ # Every day, ascending, no later than `today` and no earlier than `today`
164
+ # minus `window_days`, whose checklist carries at least one line tagged to
165
+ # `session` (any state). Each day's checklist is read inside its own
166
+ # rescue, so one unreadable or missing checklist skips that day only and
167
+ # never gives up on a later one. Returns an empty array when no day in the
168
+ # window carries the session's line, and on any error reading the store
169
+ # (never raises).
170
+ def session_days(store, session, today:, window_days: 7)
171
+ root = sessions_root(store)
172
+ return [] unless File.directory?(root)
173
+
174
+ today_date = Date.strptime(today, "%Y%m%d")
175
+ floor_date = today_date - window_days
176
+
177
+ days = Dir.children(root).select { |name| valid_day_id?(name) }.select do |name|
178
+ date = Date.strptime(name, "%Y%m%d")
179
+ date >= floor_date && date <= today_date
180
+ end.sort
181
+
182
+ days.select do |day|
183
+ begin
184
+ read_locked(checklist_path(store, day)).each_line.any? do |line|
185
+ parsed = parse_checklist_line(line)
186
+ parsed && parsed[:session] == session
187
+ end
188
+ rescue StandardError
189
+ false
190
+ end
191
+ end
192
+ rescue StandardError
193
+ []
194
+ end
195
+
196
+ # The newest day in `session_days`, the day this session's close and
197
+ # hand-off write belong to (intent 344, G11, D13: a session that crosses
198
+ # midnight carries lines on more than one day, and the newest is the one
199
+ # still open). Falls back to `today` when no day in the window carries the
200
+ # session's line.
201
+ def session_day(store, session, today:, window_days: 7)
202
+ session_days(store, session, today: today, window_days: window_days).last || today
203
+ end
204
+
161
205
  # Create `.tmp/` plus a `.gitignore` holding exactly `*`, if that file does
162
206
  # not already exist. The store is a local git repo that auto-commits
163
207
  # `add -A`, so without this ignore file every heartbeat write would enter
@@ -0,0 +1,190 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "time"
6
+
7
+ # SessionUsage (intent 355, D10): reads the harness transcripts modified since
8
+ # a cutoff, dedupes assistant records by message id (the harness logs one
9
+ # record per content block), and reports per session the model, the calls in
10
+ # the window, the boot and last context, the median step, the steps over 5k,
11
+ # and the cache read. Context is input plus cache read plus cache write.
12
+ # Broken records are counted and named, never averaged over. The transcripts
13
+ # root and the rate-limit cache path are injected; nothing reads ENV.
14
+ class SessionUsage
15
+ WINDOW = 5 * 3600
16
+ BIG_STEP = 5_000
17
+ LABEL_WIDTH = 70
18
+ NO_PROMPT = "(no prompt)"
19
+ SYNTHETIC_MODEL = "<synthetic>"
20
+ CONTEXT_FIELDS = %w[input_tokens cache_read_input_tokens cache_creation_input_tokens].freeze
21
+ TRANSCRIPT_GLOBS = [File.join("*", "*.jsonl"), File.join("*", "*", "subagents", "*.jsonl")].freeze
22
+ HEADERS = ["session", "model", "calls", "boot", "last", "median step", "big steps", "cache read", "broken", "prompt"].freeze
23
+
24
+ def initialize(transcripts_root:, rate_limits_path:, now: Time.now)
25
+ @transcripts_root = transcripts_root
26
+ @rate_limits_path = rate_limits_path
27
+ @now = now
28
+ end
29
+
30
+ def cutoff(since: nil)
31
+ return [since, "since"] if since
32
+
33
+ reset = reset_time
34
+ return [reset - WINDOW, "rate-limit reset"] if reset && reset > @now
35
+
36
+ [@now - WINDOW, "last 5 hours"]
37
+ end
38
+
39
+ def report(since: nil)
40
+ at, source = cutoff(since: since)
41
+ header = { "status" => "ok", "root" => @transcripts_root, "cutoff" => at.getutc.iso8601, "cutoff_source" => source }
42
+ return header.merge("status" => "unavailable") unless File.directory?(@transcripts_root)
43
+
44
+ broken = []
45
+ sessions = transcripts(at).filter_map { |path| summarize(path, at, broken) }
46
+ header.merge("sessions" => sessions.sort_by { |s| -s["cache_read"] }, "broken" => broken)
47
+ end
48
+
49
+ def self.render_text(report)
50
+ return "Session usage: unavailable (no transcripts directory at #{report['root']})\n" if report["status"] == "unavailable"
51
+
52
+ out = +"Session usage since #{report['cutoff']} (#{report['cutoff_source']})\n\n"
53
+ out << (report["sessions"].empty? ? "(no sessions)\n" : table(report["sessions"]))
54
+ return out if report["broken"].empty?
55
+
56
+ out << "\nBroken records (#{report['broken'].size}):\n"
57
+ report["broken"].each { |b| out << " #{b['file']}:#{b['line']} #{b['error']}\n" }
58
+ out
59
+ end
60
+
61
+ def self.table(sessions)
62
+ rows = sessions.map do |s|
63
+ boot = tokens(s["boot"]) + (s["started_before_cutoff"] ? "*" : "")
64
+ [s["id"], s["model"].to_s, s["calls"].to_s, boot, tokens(s["last"]), tokens(s["median_step"]),
65
+ s["big_steps"].to_s, tokens(s["cache_read"]), s["broken"].to_s, s["label"]]
66
+ end
67
+ widths = HEADERS.each_index.map { |i| ([HEADERS] + rows).map { |r| r[i].length }.max }
68
+ lines = ([HEADERS] + rows).map { |r| r.each_with_index.map { |cell, i| cell.ljust(widths[i]) }.join(" ").rstrip }
69
+ note = sessions.any? { |s| s["started_before_cutoff"] } ? "\n* boot predates the cutoff\n" : ""
70
+ lines.join("\n") + "\n" + note
71
+ end
72
+
73
+ def self.tokens(count)
74
+ return "-" if count.nil?
75
+
76
+ count.abs >= 1_000 ? format("%.1fk", count / 1_000.0) : count.to_s
77
+ end
78
+
79
+ private_class_method :table, :tokens
80
+
81
+ private
82
+
83
+ def reset_time
84
+ return unless File.file?(@rate_limits_path)
85
+
86
+ value = JSON.parse(File.read(@rate_limits_path))["resets_at"].to_s
87
+ return if value.empty?
88
+
89
+ value.match?(/\A\d+\z/) ? Time.at(value.to_i).utc : Time.iso8601(value)
90
+ rescue JSON::ParserError, ArgumentError, TypeError
91
+ nil
92
+ end
93
+
94
+ def transcripts(cutoff)
95
+ TRANSCRIPT_GLOBS.flat_map { |glob| Dir.glob(File.join(@transcripts_root, glob)) }
96
+ .select { |path| File.mtime(path) >= cutoff }
97
+ .sort
98
+ end
99
+
100
+ def summarize(path, cutoff, broken)
101
+ calls = {}
102
+ label = nil
103
+ torn = 0
104
+ File.foreach(path).with_index(1) do |line, number|
105
+ next if line.strip.empty?
106
+
107
+ record = JSON.parse(line)
108
+ raise TypeError, "record is not a JSON object" unless record.is_a?(Hash)
109
+
110
+ label ||= prompt_line(record)
111
+ call = call_from(record)
112
+ next unless call
113
+
114
+ calls[call[:id]] = calls.key?(call[:id]) ? call.merge(at: calls[call[:id]][:at]) : call
115
+ rescue JSON::ParserError
116
+ torn += 1
117
+ broken << { "file" => path, "line" => number, "error" => "unparsable JSON" }
118
+ rescue ArgumentError, TypeError => e
119
+ torn += 1
120
+ broken << { "file" => path, "line" => number, "error" => e.message }
121
+ end
122
+ row(path, calls.values, cutoff, label, torn)
123
+ end
124
+
125
+ def row(path, calls, cutoff, label, torn)
126
+ window = calls.each_index.select { |i| calls[i][:at] >= cutoff }
127
+ return if window.empty? && torn.zero?
128
+
129
+ steps = window.filter_map { |i| calls[i][:context] - calls[i - 1][:context] if i.positive? }
130
+ {
131
+ "id" => File.basename(path, ".jsonl"),
132
+ "file" => path,
133
+ "label" => label || NO_PROMPT,
134
+ "model" => calls.last && calls.last[:model],
135
+ "calls" => window.size,
136
+ "boot" => calls.first && calls.first[:context],
137
+ "last" => calls.last && calls.last[:context],
138
+ "median_step" => median(steps),
139
+ "big_steps" => steps.count { |step| step > BIG_STEP },
140
+ "cache_read" => window.sum { |i| calls[i][:cache_read] },
141
+ "broken" => torn,
142
+ "started_before_cutoff" => !calls.empty? && calls.first[:at] < cutoff,
143
+ }
144
+ end
145
+
146
+ def call_from(record)
147
+ return unless record["type"] == "assistant"
148
+
149
+ message = record["message"]
150
+ raise TypeError, "assistant record without a message" unless message.is_a?(Hash)
151
+ return if message["model"] == SYNTHETIC_MODEL
152
+
153
+ usage = message["usage"]
154
+ raise ArgumentError, "assistant record without message id or usage" unless message["id"] && usage.is_a?(Hash)
155
+
156
+ {
157
+ id: message["id"],
158
+ model: message["model"],
159
+ at: Time.iso8601(record["timestamp"].to_s),
160
+ context: CONTEXT_FIELDS.sum { |field| usage[field].to_i },
161
+ cache_read: usage["cache_read_input_tokens"].to_i,
162
+ }
163
+ end
164
+
165
+ def prompt_line(record)
166
+ return unless record["type"] == "user" && !record["isMeta"]
167
+
168
+ text = prompt_text(record["message"].is_a?(Hash) ? record["message"]["content"] : nil)
169
+ return if text.nil?
170
+
171
+ first = text.lines.map(&:strip).find { |line| !line.empty? && !line.start_with?("<") }
172
+ first && first[0, LABEL_WIDTH]
173
+ end
174
+
175
+ def prompt_text(content)
176
+ return content if content.is_a?(String)
177
+ return unless content.is_a?(Array)
178
+
179
+ block = content.find { |b| b.is_a?(Hash) && b["type"] == "text" }
180
+ block && block["text"]
181
+ end
182
+
183
+ def median(values)
184
+ return if values.empty?
185
+
186
+ sorted = values.sort
187
+ middle = sorted.size / 2
188
+ sorted.size.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
189
+ end
190
+ end
@@ -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
@@ -0,0 +1,95 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "active_delivery"
6
+ require_relative "lock"
7
+ require_relative "ready_set"
8
+
9
+ # StopGate (intent 340b, G7c, n4, D5/D7/D8/D9): may this session stop? Blocks
10
+ # only when four conditions hold together: the payload's stop_hook_active is
11
+ # true, runner.stop_hook parses as a real boolean true, this session holds
12
+ # the delivering intent's delivery.lock live and in auto mode, and the last
13
+ # runner step left work a new turn can actually move (some node is ready to
14
+ # dispatch right now - a graph whose only non-terminal node is already
15
+ # running, or that has no ready node at all, permits, because blocking that
16
+ # would trip Claude Code's own eight-block ceiling for nothing).
17
+ #
18
+ # Fail-open is the design, not a fallback: a missing store, a torn lock, an
19
+ # unreadable graph, a raised exception, a payload that is not JSON - every
20
+ # one of those permits. #decide never raises.
21
+ module StopGate
22
+ module_function
23
+
24
+ # decide(payload:, config_stop_hook:, session:, global_store:, project_roots:, ttl:, now:) ->
25
+ # {block: false} or {block: true, reason: "..."}.
26
+ #
27
+ # `payload` is the hook input: already a Hash, or the raw JSON text (row
28
+ # 4.19 - a payload that is not JSON permits, never raises). `config_stop_hook`
29
+ # is the raw string read-config returns for runner.stop_hook: parsed as a
30
+ # boolean here, never trusted as one.
31
+ def decide(payload:, config_stop_hook:, session:, global_store:, project_roots:,
32
+ ttl: Lock::TTL_SECONDS, now: Time.now, caps: nil)
33
+ payload = parse_payload(payload)
34
+
35
+ return permit unless payload["stop_hook_active"] == true
36
+ return permit unless flag_true?(config_stop_hook)
37
+
38
+ intent_dir = ActiveDelivery.resolve(global_store: global_store, project_roots: project_roots,
39
+ session: session, ttl: ttl, now: now)
40
+ return permit unless intent_dir
41
+
42
+ lock = Lock.read(intent_dir)
43
+ return permit unless lock
44
+ return permit unless lock["run_mode"].to_s == "auto"
45
+
46
+ return permit unless work_movable?(intent_dir, caps: caps)
47
+
48
+ block(intent_dir)
49
+ rescue StandardError
50
+ permit
51
+ end
52
+
53
+ # Accepts a Hash as-is, parses a String as JSON, and falls back to an
54
+ # empty Hash for anything else or anything that fails to parse - a
55
+ # malformed payload must never raise (row 4.19).
56
+ def parse_payload(payload)
57
+ return payload if payload.is_a?(Hash)
58
+ return {} unless payload.is_a?(String)
59
+
60
+ parsed = JSON.parse(payload)
61
+ parsed.is_a?(Hash) ? parsed : {}
62
+ rescue JSON::ParserError
63
+ {}
64
+ end
65
+
66
+ # A config value counts as on only when it parses as the literal boolean
67
+ # true (row 4.4): read-config emits strings, and the string "false" is
68
+ # truthy in Ruby, so testing the raw value would arm the hook on its own
69
+ # shipped default. Anything else, including an absent key (row 4.5), is off.
70
+ def flag_true?(value)
71
+ value.to_s.strip.downcase == "true"
72
+ end
73
+
74
+ # Some node is ready to dispatch right now. Never raises across this
75
+ # boundary (ReadySet.analyze already never does); an unreadable or absent
76
+ # graph, or an intent with no declared nodes, reads as ok: false or an
77
+ # empty ready set either way, and both permit.
78
+ def work_movable?(intent_dir, caps:)
79
+ analysis = caps ? ReadySet.analyze(intent_dir, caps: caps) : ReadySet.analyze(intent_dir)
80
+ analysis[:ok] && analysis[:ranked_ready].any?
81
+ end
82
+
83
+ def permit
84
+ { block: false }
85
+ end
86
+
87
+ # Names the next instruction (row 4.17): a session that is blocked and
88
+ # told nothing stops again immediately.
89
+ def block(intent_dir)
90
+ intent_id = File.basename(intent_dir.to_s)
91
+ reason = "Plastic: intent #{intent_id} still has ready work. Run `runner step` for it " \
92
+ "and dispatch what it prints before stopping."
93
+ { block: true, reason: reason }
94
+ end
95
+ 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,
@@ -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