@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
@@ -0,0 +1,440 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "date"
6
+ require_relative "graph_file"
7
+ require_relative "node_file"
8
+ require_relative "node_ledger"
9
+ require_relative "atomic_write"
10
+ require_relative "report_screen"
11
+
12
+ # OutcomeReport (intent 339, G6): the report model over graph.md, nodes/, and
13
+ # the node ledger (n1), the generator and its command (n2), the plan-versus-
14
+ # delivered diff and the transitive stale walk (n3), and findings, read and
15
+ # capped (n4). Every rendered cell traces to graph.md, a node file, or a
16
+ # ledger line; an absent source renders as a named reason, never a guess
17
+ # (spec D1). Pure: no clock, no environment, every path an explicit argument.
18
+ # Edges come from GraphFile/GraphEdges only - never the older node-ledger
19
+ # reader intent 336 deletes in this same batch (spec D16).
20
+ module OutcomeReport
21
+ module_function
22
+
23
+ # One past NodeFile::KIND_PREFIX ("n" => "work", ...), so a declared node
24
+ # with no node file on disk (matrix 1.8) still gets a kind, inferred from
25
+ # its id's own prefix rather than left nil.
26
+ KIND_BY_PREFIX = NodeFile::KIND_PREFIX.invert.freeze
27
+
28
+ TITLE_LINE_RE = /\A#\s+\S+\s*-\s*(.+)\z/.freeze
29
+
30
+ # {ok:, errors:, goal:, edges:, nodes:, entries:}. `edges` maps a declared
31
+ # node id to the ids it needs (GraphEdges' own shape). `nodes` maps every id
32
+ # this intent has ever mentioned - declared in graph.md, filed under
33
+ # nodes/, or named in the ledger - to {declared:, file_present:, kind:,
34
+ # title:, state:, fields:, retries:}. `entries` is the raw ledger read
35
+ # (NodeLedger.entries' own shape), carried through so #graph_diff and
36
+ # #stale_nodes can walk file order without a second read. Never raises
37
+ # across its boundary (matrix 1.1, 1.6).
38
+ # `node_file_parser:` (row v1f.4, N4) is the injectable seam: tests drive a
39
+ # raising stub through it to prove this method's OWN rescue, rather than
40
+ # pinning one already owned by NodeFile.parse or NodeLedger.entries a layer
41
+ # down.
42
+ def model(intent_dir, node_file_parser: NodeFile.method(:parse))
43
+ graph_path = File.join(intent_dir, "graph.md")
44
+ unless File.exist?(graph_path)
45
+ return { ok: false, errors: ["no graph.md at #{graph_path}"], goal: nil, edges: {}, nodes: {}, entries: [] }
46
+ end
47
+
48
+ parsed = GraphFile.parse(graph_path)
49
+ edges = (parsed[:graph] && parsed[:graph][:edges]) || {}
50
+ declared_ids = (parsed[:graph] && parsed[:graph][:nodes]) || []
51
+
52
+ ledger_path = File.join(intent_dir, "savepoint.md")
53
+ entries = NodeLedger.entries(ledger_path)
54
+ ledger_ids = entries.map { |e| e[:subject] }.uniq
55
+ node_files = node_files_by_id(intent_dir, node_file_parser: node_file_parser)
56
+
57
+ all_ids = (declared_ids + ledger_ids + node_files.keys).uniq
58
+ nodes = all_ids.each_with_object({}) do |id, memo|
59
+ memo[id] = node_entry(id, declared_ids, entries, node_files[id])
60
+ end
61
+
62
+ { ok: parsed[:ok], errors: parsed[:errors] || [], goal: parsed[:goal], edges: edges, nodes: nodes,
63
+ entries: entries }
64
+ rescue StandardError => e
65
+ { ok: false, errors: ["outcome report model crashed: #{e.message}"], goal: nil, edges: {}, nodes: {}, entries: [] }
66
+ end
67
+
68
+ # Row v1f.12 (N11): every nodes/*.md file is parsed exactly once here,
69
+ # id -> {path:, parsed:}, so `model` is linear in file count rather than
70
+ # quadratic (the old `node_file_path_for` re-globbed and re-parsed every
71
+ # file once per node id).
72
+ def node_files_by_id(intent_dir, node_file_parser: NodeFile.method(:parse))
73
+ Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort.each_with_object({}) do |path, memo|
74
+ parsed = node_file_parser.call(path)
75
+ memo[parsed[:node]] = { path: path, parsed: parsed } if parsed[:node]
76
+ end
77
+ end
78
+
79
+ def node_entry(id, declared_ids, entries, file_entry)
80
+ file_path = file_entry && file_entry[:path]
81
+ parsed = file_entry && file_entry[:parsed]
82
+ kind = (parsed && parsed[:kind]) || KIND_BY_PREFIX[id.to_s[0]]
83
+ title = (parsed && title_from_body(parsed[:body])) || id.to_s
84
+
85
+ subject_entries = entries.select { |e| e[:subject] == id }
86
+ non_torn = subject_entries.reject { |e| e[:torn] }
87
+ state = non_torn.empty? ? "planned" : NodeLedger.resolved_state(non_torn.last[:state])
88
+ done_entry = non_torn.select { |e| e[:state] == "done" }.last
89
+ fields = done_entry ? done_entry[:fields] : {}
90
+ # Row v1f.14: carried alongside `fields`, never merged into it - a
91
+ # `failed_verification` node has no `done` entry, so `fields` stays {}
92
+ # (row 1.4 must stay true). The failure branch of
93
+ # verification_line_for reads its reason from here instead.
94
+ failure_entry = non_torn.select { |e| e[:state] == "failed_verification" }.last
95
+ failure_fields = failure_entry ? failure_entry[:fields] : {}
96
+ retries = non_torn.count { |e| e[:state] == "failed_verification" }
97
+
98
+ {
99
+ declared: declared_ids.include?(id),
100
+ file_present: !file_path.nil?,
101
+ kind: kind,
102
+ title: title,
103
+ state: state,
104
+ fields: fields,
105
+ failure_fields: failure_fields,
106
+ retries: retries,
107
+ }
108
+ end
109
+
110
+ def title_from_body(body)
111
+ return nil unless body
112
+
113
+ line = body.to_s.each_line.find { |l| l.start_with?("#") }
114
+ return nil unless line
115
+
116
+ m = line.strip.match(TITLE_LINE_RE)
117
+ m && m[1].strip
118
+ end
119
+
120
+ # --- n2: the generator -------------------------------------------------------
121
+
122
+ PLACEHOLDER_SUMMARY = "(what was delivered)"
123
+ NON_EVIDENCE_FIELD_KEYS = %w[holder expires input model gates commit verdict reason question by expired suite].freeze
124
+
125
+ # D14: a pipe in any generated table cell (or bullet line) is replaced with a
126
+ # forward slash, never escaped - ReportScreen.table_rows splits on the bare
127
+ # character with no escape awareness.
128
+ def sanitize_cell(text)
129
+ text.to_s.gsub("|", "/")
130
+ end
131
+
132
+ # Numeric-aware id sort ("n2" before "n10"), for deterministic output order
133
+ # regardless of Hash insertion order.
134
+ def sort_ids(ids)
135
+ ids.sort_by do |id|
136
+ m = id.to_s.match(/\A([A-Za-z]+)(\d+)\z/)
137
+ m ? [m[1], m[2].to_i] : [id.to_s, 0]
138
+ end
139
+ end
140
+
141
+ def section_body(text, heading)
142
+ return nil unless text
143
+
144
+ text.split(/^#{Regexp.escape(heading)}\s*$/, 2)[1]&.split(/^## /, 2)&.first
145
+ end
146
+
147
+ def existing_frontmatter(text)
148
+ return {} unless text && text.start_with?("---")
149
+
150
+ parts = text.split("---", 3)
151
+ return {} if parts.length < 3
152
+
153
+ (YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}).each_with_object({}) do |(k, v), memo|
154
+ memo[k.to_s] = v
155
+ end
156
+ rescue StandardError
157
+ {}
158
+ end
159
+
160
+ # An authored block, trimmed, or nil when the section is absent, blank, or
161
+ # still the literal "None"/placeholder scaffold text (spec D2).
162
+ def preserved_block(existing_text, heading, placeholder: nil)
163
+ body = section_body(existing_text, heading)
164
+ return nil if body.nil?
165
+
166
+ trimmed = body.gsub(/<!--.*?-->/m, "").strip
167
+ return nil if trimmed.empty? || trimmed == "None"
168
+ return nil if placeholder && trimmed == placeholder
169
+
170
+ body.strip
171
+ end
172
+
173
+ def default_summary(model)
174
+ work = model[:nodes].select { |_, n| n[:kind] == "work" }
175
+ done = work.select { |_, n| n[:state] == "done" }
176
+ "#{done.length} of #{work.length} work node#{work.length == 1 ? '' : 's'} delivered."
177
+ end
178
+
179
+ # Row v1f.11 (N10): a whole sentence, not the first LINE of the goal - a
180
+ # goal that wraps across source lines (this intent's own graph.md does)
181
+ # must not be cut mid-sentence at the wrap. Wrapped lines within the first
182
+ # paragraph are joined with spaces, then cut at the first sentence-ending
183
+ # punctuation; a paragraph with none is used whole.
184
+ def title_text(model)
185
+ goal = model[:goal].to_s.strip
186
+ return "generated report" if goal.empty?
187
+
188
+ paragraph = goal.split(/\n[ \t]*\n/, 2).first.to_s
189
+ joined = paragraph.each_line.map(&:strip).join(" ")
190
+ sentence = joined[/\A.*?[.!?](?=\s|\z)/]
191
+ (sentence || joined).strip
192
+ end
193
+
194
+ # D19 (S10): a delivered row is labelled by the S-label of the action
195
+ # heading that owns that node's own failure-mode matrix, falling back to
196
+ # the bare node id (D3's grammar) when no heading carries one. This keeps
197
+ # a generated record close-able under an already-installed core that only
198
+ # collects S-labels off action headings - the same core that, before this,
199
+ # found S-labels on the headings and node ids on the rows and refused with
200
+ # no intersection between the two (ruling D19's probe).
201
+ ACTION_S_LABEL_RE = /\AS\d+\z/.freeze
202
+
203
+ # Resolves through the exact same walk `ReportScreen.proven_by` reads
204
+ # against (`matching_action_heading` + `heading_tokens`), so the label this
205
+ # emits can never disagree with what a reader's Proven-by cell resolves.
206
+ # `intent_dir` is nil for an in-memory model with nothing on disk (rows
207
+ # 2.4/2.5) - that, like a node id absent from every heading, falls back to
208
+ # the node id unchanged.
209
+ def delivered_row_label(id, intent_dir)
210
+ return id.to_s unless intent_dir
211
+
212
+ heading, body = ReportScreen.matching_action_heading(intent_dir, id.to_s)
213
+ return id.to_s unless heading && body
214
+
215
+ s_label = ReportScreen.heading_tokens(heading).find { |t| t.match?(ACTION_S_LABEL_RE) }
216
+ s_label || id.to_s
217
+ end
218
+
219
+ def render_delivered_section(model, intent_dir: nil)
220
+ rows = model[:nodes].select { |_, n| n[:kind] == "work" && n[:state] == "done" }
221
+ lines = ["## Delivered", "| Row | What |", "| --- | --- |"]
222
+ sort_ids(rows.keys).each do |id|
223
+ lines << "| #{delivered_row_label(id, intent_dir)} | #{sanitize_cell(rows[id][:title])} |"
224
+ end
225
+ "#{lines.join("\n")}\n"
226
+ end
227
+
228
+ def verification_line_for(id, node)
229
+ fields = node[:fields] || {}
230
+ if node[:state] == "failed_verification"
231
+ reason = (node[:failure_fields] || {})["reason"] || "reason not recorded"
232
+ return "- #{id}: verification failed (#{sanitize_cell(reason)})"
233
+ end
234
+
235
+ parts = []
236
+ parts << "tests committed red before `#{sanitize_cell(fields['commit'])}`" if fields["commit"]
237
+ parts << "gates=#{sanitize_cell(fields['gates'])}" if fields["gates"]
238
+ parts << "verdict=#{sanitize_cell(fields['verdict'])}" if fields["verdict"]
239
+ extra = fields.reject { |k, _| NON_EVIDENCE_FIELD_KEYS.include?(k) }
240
+ extra.each { |k, v| parts << "#{k}=#{sanitize_cell(v)}" }
241
+ parts << "retried #{node[:retries]}x" if node[:retries].to_i.positive?
242
+ return nil if parts.empty?
243
+
244
+ "- #{id}: #{parts.join(', ')}"
245
+ end
246
+
247
+ def render_verification_section(model)
248
+ candidates = model[:nodes].select { |_, n| %w[work verify].include?(n[:kind]) }
249
+ lines = sort_ids(candidates.keys).filter_map { |id| verification_line_for(id, candidates[id]) }
250
+
251
+ suite = candidates.values.filter_map { |n| n[:fields] && n[:fields]["suite"] }.first
252
+ lines << "- suite: #{suite}" if suite
253
+
254
+ lines = ["- no work or verify node has recorded evidence yet"] if lines.empty?
255
+ "## Verification\n#{lines.join("\n")}\n"
256
+ end
257
+
258
+ def render_needs_you_default(model)
259
+ needs = model[:nodes].select { |_, n| n[:state] == "needs_decision" }
260
+ return "None" if needs.empty?
261
+
262
+ lines = ["| N | Need | Reason |", "| --- | --- | --- |"]
263
+ sort_ids(needs.keys).each_with_index do |id, i|
264
+ reason = (needs[id][:fields] && needs[id][:fields]["question"]) || "not recorded"
265
+ lines << "| N#{i + 1} | #{sanitize_cell(id)} | #{sanitize_cell(reason)} |"
266
+ end
267
+ lines.join("\n")
268
+ end
269
+
270
+ # Row v1f.3 (B3): serialized with to_yaml, not `"#{k}: #{v}"`
271
+ # interpolation - a preserved value carrying a colon, a newline, or a
272
+ # leading/trailing space corrupted the whole frontmatter block under the
273
+ # old naive join, taking `disposition` down with it. Key order is
274
+ # Hash#to_yaml's own (insertion order), so the caller's last-writer-wins
275
+ # reassignment of `disposition` still lands at its original position.
276
+ def frontmatter_lines(fm)
277
+ fm.to_yaml.sub(/\A---\n/, "").rstrip.split("\n")
278
+ end
279
+
280
+ # {ok:, errors:, goal:, edges:, nodes:} in, the whole `outcome.md` text out.
281
+ # Preserved verbatim when authored (spec D2): every frontmatter key except
282
+ # `disposition`, `## Summary`, `## Needs you`, `## Follow-ups`. Regenerated
283
+ # every time: `## Delivered`, `## Verification` (and, once n3/n4 wire them
284
+ # in, `## Graph diff` and `## Findings`).
285
+ def render(model, disposition:, existing: nil, findings: [], intent_dir: nil)
286
+ fm = existing_frontmatter(existing)
287
+ fm["disposition"] = disposition
288
+ fm_lines = ["---"] + frontmatter_lines(fm) + ["---"]
289
+
290
+ summary = preserved_block(existing, "## Summary", placeholder: PLACEHOLDER_SUMMARY) || default_summary(model)
291
+ needs_you = preserved_block(existing, "## Needs you") || render_needs_you_default(model)
292
+ follow_ups = preserved_block(existing, "## Follow-ups") || "None"
293
+
294
+ lines = fm_lines.dup
295
+ lines << "# Outcome: #{title_text(model)}"
296
+ lines << ""
297
+ lines << "## Summary"
298
+ lines << summary
299
+ lines << ""
300
+ lines << render_delivered_section(model, intent_dir: intent_dir)
301
+ lines << render_verification_section(model)
302
+ lines << graph_diff(model)
303
+ lines << render_findings_section(findings) unless findings.nil? || findings.empty?
304
+ lines << "## Needs you"
305
+ lines << needs_you
306
+ lines << ""
307
+ lines << "## Follow-ups"
308
+ lines << follow_ups
309
+ "#{lines.join("\n")}\n"
310
+ end
311
+
312
+ # --- n3: plan versus delivered, and stale -----------------------------------
313
+
314
+ # A done node's transitive `needs` closure (spec D5), by ledger LINE
315
+ # POSITION, never timestamp - the ledger's own status rule is last line per
316
+ # subject in file order, so a flag computed from anything else could
317
+ # disagree with the state printed beside it. `stale_fn:` (spec D6) lets
318
+ # intent 336's ready-set module supply its own computation without an edit
319
+ # here. Cycle-guarded: a node already on the current walk's path is never
320
+ # re-entered.
321
+ def stale_nodes(entries:, edges:, stale_fn: nil)
322
+ return stale_fn.call(entries: entries, edges: edges) if stale_fn
323
+
324
+ non_torn = entries.reject { |e| e[:torn] }
325
+ last_state = {}
326
+ last_index = {}
327
+ done_index = {}
328
+ non_torn.each_with_index do |e, i|
329
+ last_state[e[:subject]] = e[:state]
330
+ last_index[e[:subject]] = i
331
+ done_index[e[:subject]] = i if e[:state] == "done"
332
+ end
333
+
334
+ edges.keys.select do |id|
335
+ done_index.key?(id) && stale_via_closure?(id, edges, last_state, last_index, done_index[id], [])
336
+ end
337
+ end
338
+
339
+ def stale_via_closure?(id, edges, last_state, last_index, own_done_idx, visiting)
340
+ return false if visiting.include?(id)
341
+
342
+ visiting = visiting + [id]
343
+ (edges[id] || []).any? do |dep|
344
+ (last_state[dep] == "superseded" && last_index[dep] && last_index[dep] > own_done_idx) ||
345
+ stale_via_closure?(dep, edges, last_state, last_index, own_done_idx, visiting)
346
+ end
347
+ end
348
+
349
+ # The plan-versus-delivered divergence: a planned node not done, a node the
350
+ # ledger knows that the graph never declared, a node that took a retry, a
351
+ # stale node (spec D4). One line saying so when nothing diverged.
352
+ def graph_diff(model)
353
+ lines = []
354
+
355
+ declared = model[:nodes].select { |_, n| n[:declared] }
356
+ sort_ids(declared.keys).each do |id|
357
+ lines << "#{id} is #{declared[id][:state]}, not done" unless declared[id][:state] == "done"
358
+ end
359
+
360
+ undeclared = model[:nodes].reject { |_, n| n[:declared] }
361
+ sort_ids(undeclared.keys).each { |id| lines << "#{id} was not declared in graph.md" }
362
+
363
+ retried = model[:nodes].select { |_, n| n[:retries].to_i.positive? }
364
+ sort_ids(retried.keys).each do |id|
365
+ n = retried[id]
366
+ lines << "#{id} took #{n[:retries]} #{n[:retries] == 1 ? 'retry' : 'retries'}"
367
+ end
368
+
369
+ stale = stale_nodes(entries: model[:entries] || [], edges: model[:edges] || {})
370
+ sort_ids(stale).each { |id| lines << "#{id} is stale: a dependency was superseded after it finished" }
371
+
372
+ return "## Graph diff\nDelivered matches the plan.\n" if lines.empty?
373
+
374
+ "## Graph diff\n#{lines.map { |l| "- #{l}" }.join("\n")}\n"
375
+ end
376
+
377
+ # --- n4: findings, read and capped ------------------------------------------
378
+
379
+ # Two lines at the screens' own 115-column limit (spec D15). A named
380
+ # constant so the number is never embedded into a regex.
381
+ FINDING_CAP = 200
382
+
383
+ # `### Findings` under the intent record's own `## Insights` section (spec
384
+ # D7). G7 writes those lines one Insight entry at a time; nothing writes one
385
+ # today, so this reads a fixture shape until then. [] when the record, the
386
+ # `## Insights` section, or the `### Findings` subsection is absent -
387
+ # never raises, never treats the whole Insights section as one finding.
388
+ def findings(intent_dir)
389
+ path = File.join(intent_dir, "#{File.basename(intent_dir)}.md")
390
+ return [] unless File.exist?(path)
391
+
392
+ text = File.read(path)
393
+ insights = text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
394
+ body = insights.split(/^### Findings\s*$/, 2)[1]
395
+ return [] if body.nil?
396
+
397
+ body = body.split(/^#+\s/, 2)[0]
398
+ finding_bullet_rows(body).map { |f| cap_finding(sanitize_cell(f)) }
399
+ end
400
+
401
+ # Same continuation-line rule as ReportScreen.bullet_rows: a "- " line plus
402
+ # its wrapped continuation, ending at a blank line or the next heading.
403
+ def finding_bullet_rows(section)
404
+ rows = []
405
+ section.to_s.each_line do |line|
406
+ stripped = line.strip
407
+ if line.lstrip.start_with?("- ")
408
+ rows << line.lstrip.sub(/\A-\s*/, "").strip
409
+ elsif stripped.empty? || line.start_with?("#")
410
+ rows << nil unless rows.empty? || rows.last.nil?
411
+ elsif !rows.empty? && !rows.last.nil?
412
+ rows[rows.length - 1] = "#{rows.last} #{stripped}"
413
+ end
414
+ end
415
+ rows.compact
416
+ end
417
+
418
+ def cap_finding(text)
419
+ return text if text.length <= FINDING_CAP
420
+
421
+ "#{text[0...(FINDING_CAP - 3)].rstrip}..."
422
+ end
423
+
424
+ def render_findings_section(findings)
425
+ return "" if findings.nil? || findings.empty?
426
+
427
+ lines = ["## Findings", "| Finding |", "| --- |"]
428
+ findings.each { |f| lines << "| #{f} |" }
429
+ "#{lines.join("\n")}\n"
430
+ end
431
+
432
+ def write(intent_dir, disposition:, renamer: File.method(:rename))
433
+ outcome_path = File.join(intent_dir, "outcome.md")
434
+ existing = File.exist?(outcome_path) ? File.read(outcome_path) : nil
435
+ m = model(intent_dir)
436
+ text = render(m, disposition: disposition, existing: existing, findings: findings(intent_dir), intent_dir: intent_dir)
437
+ AtomicWrite.write(outcome_path, text, renamer: renamer)
438
+ text
439
+ end
440
+ end
@@ -1,7 +1,7 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
- require "rubygems"
4
+ require_relative "version_number"
5
5
 
6
6
  # Pure, dependency-injected pre-flight checks for Plastic's runtime dependencies
7
7
  # (intent 38). Takes injected probes (ruby version, node version, git presence,
@@ -14,9 +14,9 @@ require "rubygems"
14
14
  module Preflight
15
15
  module_function
16
16
 
17
- RUBY_FLOOR = "3.0.0"
17
+ RUBY_FLOOR = "4.0.0"
18
18
  NODE_FLOOR = 18
19
- RUBY_PIN = "3.3"
19
+ RUBY_PIN = "4.0"
20
20
 
21
21
  def check(ruby_version:, node_version:, git_present:, mise_present:)
22
22
  messages = []
@@ -63,9 +63,7 @@ module Preflight
63
63
  end
64
64
 
65
65
  def safe_version(str)
66
- Gem::Version.new(str.to_s)
67
- rescue ArgumentError
68
- nil
66
+ VersionNumber.parse(str)
69
67
  end
70
68
 
71
69
  def strip_leading_v(str)
@@ -0,0 +1,46 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "store_layout"
5
+ require "yaml"
6
+
7
+ # ProjectConfig - reads a project's project.yml merged over the shipped defaults
8
+ # (moved off the retired shared-helpers module by intent 344, D2). A missing or
9
+ # malformed project.yml never raises: read returns the defaults with a warning
10
+ # on stderr instead.
11
+ module ProjectConfig
12
+ module_function
13
+
14
+ DEFAULTS = {
15
+ "governing_docs" => ["AGENTS.md"],
16
+ "release" => {
17
+ "on_complete" => "commit",
18
+ },
19
+ }.freeze
20
+
21
+ def read(slug)
22
+ path = File.join(Plastic::StoreLayout.project_root(File.join(Dir.home, ".plastic"), slug), "project.yml")
23
+ config = if File.exist?(path)
24
+ YAML.safe_load(File.read(path)) || {}
25
+ else
26
+ {}
27
+ end
28
+
29
+ deep_merge(DEFAULTS, config)
30
+ rescue => e
31
+ $stderr.puts "Warning: failed to read project config for #{slug}: #{e.message}"
32
+ DEFAULTS.dup
33
+ end
34
+
35
+ def deep_merge(base, overlay)
36
+ result = base.dup
37
+ overlay.each do |key, value|
38
+ if value.is_a?(Hash) && result[key].is_a?(Hash)
39
+ result[key] = deep_merge(result[key], value)
40
+ else
41
+ result[key] = value
42
+ end
43
+ end
44
+ result
45
+ end
46
+ 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
 
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