@zalom/plastic 1.14.1 → 2.0.0-alpha.10

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 (213) hide show
  1. package/PLASTIC.md +101 -294
  2. package/README.md +5 -6
  3. package/agents/plastic-advisor.md +18 -17
  4. package/agents/plastic-enforcer.md +57 -69
  5. package/agents/plastic-executor.md +34 -20
  6. package/agents/plastic-faux-advisor.md +18 -18
  7. package/bin/lib/context_budget.rb +453 -0
  8. package/bin/plastic-bench +78 -0
  9. package/config_asks.yml +38 -0
  10. package/hooks/capture +4 -0
  11. package/hooks/close +5 -0
  12. package/hooks/hooks.json +14 -42
  13. package/hooks/message-display +81 -0
  14. package/hooks/record +4 -0
  15. package/hooks/savepoint +5 -5
  16. package/package.json +3 -2
  17. package/scripts/agent-report +15 -10
  18. package/scripts/append-ledger +208 -0
  19. package/scripts/codex-hook +78 -90
  20. package/scripts/dashboard.rb +43 -14
  21. package/scripts/day-summary +53 -0
  22. package/scripts/doctor.rb +269 -43
  23. package/scripts/end-intent +170 -76
  24. package/scripts/exec-worktree +11 -20
  25. package/scripts/file-session-intent +61 -0
  26. package/scripts/hook-capture +255 -0
  27. package/scripts/hook-close +36 -0
  28. package/scripts/hook-message-display +74 -0
  29. package/scripts/hook-record +157 -0
  30. package/scripts/hook-savepoint +45 -0
  31. package/scripts/hook-session-start +128 -12
  32. package/scripts/install.rb +8 -6
  33. package/scripts/intent-screen +77 -0
  34. package/scripts/lib/agent_models.rb +1 -7
  35. package/scripts/lib/arm.rb +308 -0
  36. package/scripts/lib/backfill_intent.rb +316 -0
  37. package/scripts/lib/bridge.rb +24 -1597
  38. package/scripts/lib/compact_instructions.rb +56 -0
  39. package/scripts/lib/day_summary.rb +211 -0
  40. package/scripts/lib/doctor_core.rb +66 -88
  41. package/scripts/lib/doctor_session_ledger.rb +158 -0
  42. package/scripts/lib/exec_worktree.rb +14 -60
  43. package/scripts/lib/handoff.rb +184 -0
  44. package/scripts/lib/harness_text.rb +5 -4
  45. package/scripts/lib/hook_registry.rb +57 -106
  46. package/scripts/lib/insights.rb +3 -4
  47. package/scripts/lib/installer_core.rb +136 -41
  48. package/scripts/lib/intent_screen.rb +309 -0
  49. package/scripts/lib/intent_screen_ansi.rb +262 -0
  50. package/scripts/lib/lock.rb +6 -26
  51. package/scripts/lib/message_display.rb +290 -0
  52. package/scripts/lib/outcome_guard.rb +7 -5
  53. package/scripts/lib/project_validator.rb +30 -1
  54. package/scripts/lib/report_screen.rb +648 -0
  55. package/scripts/lib/roadmap_queue.rb +3 -3
  56. package/scripts/lib/roadmap_savepoint.rb +1 -1
  57. package/scripts/lib/rule_catalog.rb +6 -3
  58. package/scripts/lib/savepoint.rb +334 -0
  59. package/scripts/lib/scaffold_intent.rb +13 -179
  60. package/scripts/lib/screen_paint.rb +276 -0
  61. package/scripts/lib/session_backfill.rb +184 -0
  62. package/scripts/lib/session_close.rb +104 -0
  63. package/scripts/lib/session_git.rb +500 -0
  64. package/scripts/lib/session_ledger.rb +621 -0
  65. package/scripts/lib/store_discovery.rb +1 -1
  66. package/scripts/lib/store_provisioning.rb +1 -1
  67. package/scripts/maintenance-run +7 -7
  68. package/scripts/new-intent +63 -4
  69. package/scripts/plastic-lock +101 -41
  70. package/scripts/promote-session-item +150 -0
  71. package/scripts/read-config +3 -0
  72. package/scripts/rebuild-graph +1 -1
  73. package/scripts/report-screen +120 -0
  74. package/scripts/roadmap-next +2 -2
  75. package/scripts/rollback.rb +48 -3
  76. package/scripts/savepoint-note +67 -0
  77. package/scripts/scaffold-intent +37 -63
  78. package/scripts/session-commit +116 -0
  79. package/scripts/spawn-preamble +14 -7
  80. package/scripts/update.rb +7 -3
  81. package/scripts/write-handoff +60 -0
  82. package/skills/agent-advisor/SKILL.md +3 -3
  83. package/skills/agent-advisor/references/advisor-protocol.md +20 -20
  84. package/skills/auto/SKILL.md +223 -317
  85. package/skills/auto/evals/evals.json +10 -10
  86. package/skills/auto/references/agent-architecture.md +102 -90
  87. package/skills/auto/references/agent-report-contract.md +4 -21
  88. package/skills/auto/references/end-tail.md +47 -53
  89. package/skills/auto/references/human-report-contract.md +62 -56
  90. package/skills/conventions/SKILL.md +1 -3
  91. package/skills/conventions/references/lifecycle-and-savepoints.md +35 -1
  92. package/skills/conventions/references/locks-and-worktrees.md +62 -58
  93. package/skills/conventions/references/maintenance-and-revisions.md +12 -12
  94. package/skills/dashboard/SKILL.md +1 -1
  95. package/skills/direct/SKILL.md +66 -0
  96. package/skills/direct/references/request-signals.md +59 -0
  97. package/skills/doctor/SKILL.md +58 -11
  98. package/skills/doctor/report.md +3 -1
  99. package/skills/install/SKILL.md +11 -9
  100. package/skills/intent-continuing/SKILL.md +142 -91
  101. package/skills/intent-continuing/references/boarding-matrix.md +34 -0
  102. package/skills/intent-continuing/references/context-management.md +3 -3
  103. package/skills/intent-creating/SKILL.md +2 -2
  104. package/skills/intent-creating/references/lifecycle.md +1 -1
  105. package/skills/intent-ending/SKILL.md +34 -37
  106. package/skills/intent-ending/evals/evals.json +4 -4
  107. package/skills/intent-executing/SKILL.md +15 -46
  108. package/skills/intent-executing/plan-reviewer-prompt.md +39 -0
  109. package/skills/intent-speccing/SKILL.md +108 -51
  110. package/skills/intent-speccing/evals/evals.json +5 -5
  111. package/skills/intent-speccing/references/per-section-fill-rules.md +4 -4
  112. package/skills/intent-speccing/references/self-verify-checklist.md +1 -2
  113. package/skills/project-creating/SKILL.md +1 -1
  114. package/skills/releasing/SKILL.md +7 -8
  115. package/skills/tutorial/SKILL.md +2 -2
  116. package/skills/tutorial/references/track-1-guided.md +16 -18
  117. package/skills/tutorial/references/track-2-auto.md +12 -13
  118. package/skills/tutorial/references/track-3-projects-and-roadmaps.md +2 -2
  119. package/templates/agents.md +4 -7
  120. package/templates/config.yml +5 -0
  121. package/templates/intent-screen.md +17 -0
  122. package/templates/outcome.md +14 -1
  123. package/templates/project.yml +7 -0
  124. package/templates/report-state.md +11 -0
  125. package/templates/roadmap.md +1 -1
  126. package/templates/savepoint.md +2 -2
  127. package/templates/session-intent.md +29 -0
  128. package/agents/plastic-brainstorming.md +0 -48
  129. package/agents/plastic-future-intent-researcher.md +0 -35
  130. package/agents/plastic-intent-curator.md +0 -47
  131. package/agents/plastic-intent-discovery.md +0 -43
  132. package/agents/plastic-planner.md +0 -56
  133. package/agents/plastic-spec-specialist.md +0 -53
  134. package/hooks/auto-arm +0 -5
  135. package/hooks/bash-gate +0 -3
  136. package/hooks/continue +0 -31
  137. package/hooks/edit-gates +0 -3
  138. package/hooks/future-intent-check +0 -25
  139. package/hooks/gate-check +0 -12
  140. package/hooks/power-tools +0 -8
  141. package/scripts/hook-auto-arm +0 -51
  142. package/scripts/hook-bash-gate +0 -78
  143. package/scripts/hook-code-gate +0 -38
  144. package/scripts/hook-continue +0 -48
  145. package/scripts/hook-create-gate +0 -51
  146. package/scripts/hook-edit-gates +0 -58
  147. package/scripts/hook-future-intent-check +0 -90
  148. package/scripts/hook-gate-check +0 -169
  149. package/scripts/hook-links-gate +0 -54
  150. package/scripts/hook-lock-gate +0 -47
  151. package/scripts/hook-power-tools +0 -38
  152. package/scripts/hook-savepoint-pre +0 -32
  153. package/scripts/lib/codex_edit_gates.rb +0 -138
  154. package/scripts/lib/edit_gates.rb +0 -398
  155. package/scripts/lib/links_gate.rb +0 -140
  156. package/scripts/lib/qmd_hook.rb +0 -41
  157. package/scripts/lib/spec_header.rb +0 -83
  158. package/scripts/lib/start_intent.rb +0 -296
  159. package/scripts/start-intent +0 -89
  160. package/skills/_active-intent-gate.md +0 -26
  161. package/skills/auto/references/tiers.md +0 -100
  162. package/skills/continuing/SKILL.md +0 -34
  163. package/skills/continuing/evals/evals.json +0 -91
  164. package/skills/conventions/references/gates-and-enforcement.md +0 -53
  165. package/skills/conventions/references/tiers-and-dispatch.md +0 -135
  166. package/skills/doctor/references/gates-stuck-detection.md +0 -43
  167. package/skills/intent-brainstorming/SKILL.md +0 -118
  168. package/skills/intent-brainstorming/evals/evals.json +0 -67
  169. package/skills/intent-continuing/evals/evals.json +0 -145
  170. package/skills/intent-discovering/SKILL.md +0 -56
  171. package/skills/intent-grilling/SKILL.md +0 -108
  172. package/skills/intent-linking/SKILL.md +0 -128
  173. package/skills/intent-linking/evals/evals.json +0 -22
  174. package/skills/intent-linking/references/zettelkasten.md +0 -45
  175. package/skills/intent-locking/SKILL.md +0 -64
  176. package/skills/intent-planning/SKILL.md +0 -145
  177. package/skills/intent-planning/evals/evals.json +0 -81
  178. package/skills/intent-planning/references/plan-format.md +0 -130
  179. package/skills/intent-researching/SKILL.md +0 -123
  180. package/skills/intent-researching/evals/evals.json +0 -22
  181. package/skills/intent-savepoint/SKILL.md +0 -83
  182. package/skills/intent-savepoint/references/context-management.md +0 -32
  183. package/skills/intent-starting/SKILL.md +0 -151
  184. package/skills/intent-starting/evals/evals.json +0 -117
  185. package/skills/intent-starting/references/boarding-matrix.md +0 -35
  186. package/skills/project-continuing/SKILL.md +0 -119
  187. package/skills/project-continuing/evals/evals.json +0 -100
  188. package/skills/roadmap-continuing/SKILL.md +0 -89
  189. package/skills/roadmap-continuing/evals/evals.json +0 -82
  190. package/skills/skill-creating/SKILL.md +0 -75
  191. package/skills/skill-creating/evals/evals.json +0 -108
  192. package/skills/skill-creating/references/agents.md +0 -168
  193. package/skills/skill-creating/references/defaults-first.md +0 -23
  194. package/skills/skill-creating/references/evals.md +0 -41
  195. package/skills/skill-creating/references/hooks.md +0 -251
  196. package/skills/skill-creating/references/progressive-disclosure.md +0 -176
  197. package/skills/skill-creating/references/scripts.md +0 -166
  198. package/skills/skill-creating/references/skills.md +0 -169
  199. package/skills/skill-creating/scripts/scaffold.rb +0 -313
  200. package/skills/skill-evaluating/SKILL.md +0 -141
  201. package/skills/skill-evaluating/assets/eval-template.json +0 -12
  202. package/skills/skill-evaluating/evals/evals.json +0 -75
  203. package/skills/skill-evaluating/references/convention-checks.md +0 -76
  204. package/skills/skill-evaluating/references/eval-methodology.md +0 -154
  205. package/skills/store-curating/SKILL.md +0 -64
  206. package/skills/store-curating/evals/evals.json +0 -38
  207. package/skills/store-indexing/SKILL.md +0 -93
  208. package/skills/store-indexing/evals/evals.json +0 -22
  209. package/skills/store-indexing/references/zettelkasten-linking.md +0 -32
  210. package/skills/store-provisioning/SKILL.md +0 -55
  211. /package/skills/{project-continuing → intent-continuing}/references/board-fill.md +0 -0
  212. /package/skills/{roadmap-continuing → intent-continuing}/references/liveness-ranking.md +0 -0
  213. /package/skills/{intent-brainstorming → intent-speccing}/references/design-principles.md +0 -0
@@ -0,0 +1,81 @@
1
+ #!/bin/bash
2
+ # hooks/message-display (intent 316a, O6, round 3 concurrency fix): the
3
+ # MessageDisplay launcher. Fires on every streamed chunk of every assistant
4
+ # message (D11), so the common case — an ordinary chunk of an ordinary
5
+ # message — must decide with shell builtins alone and fork nothing. Only a
6
+ # candidate message hands off to Ruby (scripts/hook-message-display), which
7
+ # is the one place allowed to do real work.
8
+ #
9
+ # No command substitution, no backticks, no sed/jq/cat: case, [, parameter
10
+ # expansion and printf are all builtins. Deliberately does NOT copy hooks/
11
+ # capture's SCRIPT_DIR-via-subshell pattern (cd into dirname of $0, inside a
12
+ # command substitution, then pwd) — that forks a subshell on every single
13
+ # invocation, which is exactly the cost this hook cannot carry.
14
+ #
15
+ # A live run under a real pty found Claude Code fires these chunk processes
16
+ # CONCURRENTLY: a chunk with index > 0 can arrive, and be judged here,
17
+ # before chunk 0 ever runs. The OLD hand-off test — "does a buffer already
18
+ # exist for this message" — answered no in that race and silently dropped
19
+ # the chunk before Ruby ever saw it, no matter what MessageDisplay's own
20
+ # (correct) polling logic would have done. So a later chunk is now also
21
+ # handed off when its OWN delta looks like it could be part of a screen
22
+ # (leading "|" or "**Steps**", or blank — the same cheap test Ruby itself
23
+ # uses to decide whether a wait is worth paying for), and the final chunk is
24
+ # ALWAYS handed off, whatever it looks like, since it is the one that must
25
+ # not race. Ruby is the one place that actually waits (bounded, injectable
26
+ # for tests); this script only ever decides once, fast, and never sleeps.
27
+ #
28
+ # Claude adapter: Claude Code only; the core is harness-agnostic.
29
+ IFS= read -r -d '' INPUT # returns 1 at EOF: do NOT set -e
30
+ case $INPUT in *'"message_id"'*) ;; *) exit 0 ;; esac
31
+ rest=${INPUT#*\"message_id\"}; rest=${rest#*\"}; mid=${rest%%\"*}
32
+ rest=${INPUT#*\"session_id\"}; rest=${rest#*\"}; sid=${rest%%\"*}
33
+
34
+ SCRIPT_DIR=${0%/*}
35
+ TMP_ROOT=${PLASTIC_TMP:-${TMPDIR:-/tmp}}
36
+ export PLASTIC_TMP="$TMP_ROOT"
37
+ MSGDIR="$TMP_ROOT/plastic-message-display/$sid/$mid"
38
+
39
+ is_index_zero=0
40
+ case $INPUT in *'"index":0'*|*'"index": 0'*) is_index_zero=1 ;; esac
41
+
42
+ is_final=0
43
+ case $INPUT in *'"final":true'*|*'"final": true'*) is_final=1 ;; esac
44
+
45
+ handoff=0
46
+ if [ "$is_index_zero" = 1 ]; then
47
+ # Chunk 0 decides synchronously; a bare "#" first delta, in either JSON
48
+ # spacing, is the only shape that can possibly open a screen. Must be a
49
+ # single "#", not "##" — a real screen's own first delta can be as short
50
+ # as "## " — a "##" glob would filter out exactly the message this hook
51
+ # exists to recognize.
52
+ # 317a (B11): a screen can open with "## " (state, delivered), a bare "▶"
53
+ # (roster), or a bare "✔" (delay); the JSON may carry the glyph raw or
54
+ # \u-escaped depending on the encoder. All globs are literal bytes - case
55
+ # matching is byte-wise, so multibyte literals are safe under bash 3.2.
56
+ case $INPUT in
57
+ *'"delta":"#'*|*'"delta": "#'*) handoff=1 ;;
58
+ *'"delta":"▶'*|*'"delta": "▶'*) handoff=1 ;;
59
+ *'"delta":"✔'*|*'"delta": "✔'*) handoff=1 ;;
60
+ *'"delta":"▶'*|*'"delta": "▶'*) handoff=1 ;;
61
+ *'"delta":"✔'*|*'"delta": "✔'*) handoff=1 ;;
62
+ esac
63
+ else
64
+ # A later chunk: hand off when this message's directory already exists
65
+ # (chunk 0 already left a decision or a chunk file), when this chunk is
66
+ # final (it must always be checked, whatever it looks like), or when its
67
+ # own delta is shaped like part of a screen — a Markdown table row, the
68
+ # "**Steps**" heading, or a blank line, in either JSON spacing.
69
+ [ -d "$MSGDIR" ] && handoff=1
70
+ [ "$is_final" = 1 ] && handoff=1
71
+ case $INPUT in
72
+ *'"delta":"|'*|*'"delta": "|'*) handoff=1 ;;
73
+ *'"delta":"**'*|*'"delta": "**'*) handoff=1 ;;
74
+ *'"delta":""'*|*'"delta": ""'*) handoff=1 ;;
75
+ *'"delta":"\n"'*|*'"delta": "\n"'*) handoff=1 ;;
76
+ esac
77
+ fi
78
+
79
+ [ "$handoff" = 1 ] || exit 0
80
+
81
+ printf '%s' "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-message-display"
package/hooks/record ADDED
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
4
+ echo "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-record"
package/hooks/savepoint CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/bin/bash
2
- cat <<'HOOKJSON'
3
- {
4
- "systemMessage": "PLASTIC SAVEPOINT — Context is being compacted.\n\nYou MUST perform the savepoint procedure NOW:\n1. Read .plastic/INDEX.md to find active intent(s)\n2. For each active intent in .plastic/store/ID--slug/:\n a. Update checklist.md (check off completed items)\n b. Create/update savepoint.md (in-progress work, next steps, blockers, discoveries)\n c. Update intent.md Build/Observe sections with session work\n3. Update .plastic/INDEX.md active section\n4. Commit: git add .plastic/ && git commit -m 'chore: savepoint — [intent name]'\n5. Tell the user: 'Context is getting large. I have saved progress to the active intent. Please run /clear and say continue to resume.'\n\nDO NOT skip this. The user's work depends on it."
5
- }
6
- HOOKJSON
2
+ INPUT=""
3
+ [ -t 0 ] || INPUT=$(cat)
4
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
5
+ echo "$INPUT" | env -u RUBYOPT ruby "$SCRIPT_DIR/../scripts/hook-savepoint" "$HOME/.plastic"
6
+ exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.14.1",
3
+ "version": "2.0.0-alpha.10",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,6 +38,7 @@
38
38
  "agents/",
39
39
  "templates/",
40
40
  "PLASTIC.md",
41
- "deprecations.yml"
41
+ "deprecations.yml",
42
+ "config_asks.yml"
42
43
  ]
43
44
  }
@@ -21,7 +21,8 @@
21
21
  #
22
22
  # Exit codes: 0 (report emitted), 2 (usage).
23
23
 
24
- require_relative "lib/bridge"
24
+ require_relative "lib/savepoint"
25
+ require_relative "lib/intent_screen"
25
26
 
26
27
  def parse_args(argv)
27
28
  role = nil
@@ -43,7 +44,7 @@ end
43
44
  # Read the intent file frontmatter via the same path spawn-preamble uses, so the
44
45
  # id/intent we report match what the rest of Plastic sees. Never raises.
45
46
  def frontmatter_for(intent_dir)
46
- ifile = Bridge.intent_file(intent_dir)
47
+ ifile = Savepoint.intent_file(intent_dir)
47
48
  return {} unless File.exist?(ifile)
48
49
 
49
50
  content = File.read(ifile)
@@ -68,20 +69,24 @@ STAGE_LABELS = {
68
69
  "exec" => "Exec", "done" => "Done"
69
70
  }.freeze
70
71
 
72
+ # Intent 317, D6: name the last LIFECYCLE line, never a trailing Lock/Review/
73
+ # Commit line (same guard as spawn-preamble; plan review finding B1).
71
74
  def current_stage(intent_dir)
72
- ledger = File.join(intent_dir, Bridge::SAVEPOINT_FILE)
75
+ ledger = File.join(intent_dir, Savepoint::SAVEPOINT_FILE)
73
76
  if File.exist?(ledger)
74
- last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
75
- return last if last
77
+ lines = File.read(ledger).each_line.map(&:strip).reject(&:empty?)
78
+ lifecycle = lines.select { |l| IntentScreen.lifecycle_line?(l) }
79
+ return lifecycle.last if lifecycle.any?
80
+ return lines.last if lines.any?
76
81
  end
77
- STAGE_LABELS.fetch(Bridge.derive_stage(intent_dir), Bridge.derive_stage(intent_dir))
82
+ STAGE_LABELS.fetch(Savepoint.derive_stage(intent_dir), Savepoint.derive_stage(intent_dir))
78
83
  end
79
84
 
80
85
  # checklist.md checked/total: count GFM task-list items. Returns "n/a" when there
81
86
  # is no checklist (a checklist is only meaningful from How onward).
82
87
  def checklist_progress(intent_dir)
83
88
  path = File.join(intent_dir, "checklist.md")
84
- return "n/a" unless Bridge.stage_file_present?(path)
89
+ return "n/a" unless Savepoint.stage_file_present?(path)
85
90
 
86
91
  lines = File.readlines(path)
87
92
  total = lines.count { |l| l =~ /^\s*- \[[ xX]\]/ }
@@ -95,7 +100,7 @@ end
95
100
  # non-heading content line), else a placeholder. Pure read of the intent dir.
96
101
  def outcome_line(intent_dir)
97
102
  path = File.join(intent_dir, "outcome.md")
98
- return "(no outcome yet)" unless Bridge.stage_file_present?(path)
103
+ return "(no outcome yet)" unless Savepoint.stage_file_present?(path)
99
104
 
100
105
  lines = File.readlines(path).map(&:rstrip)
101
106
  start = lines.index { |l| l.strip.downcase == "## summary" }
@@ -112,7 +117,7 @@ end
112
117
  # (the newest nugget, since entries are append-only newest-at-bottom), else
113
118
  # `(none)`. Pure read of the intent dir, no clock.
114
119
  def insights_line(intent_dir)
115
- ifile = Bridge.intent_file(intent_dir)
120
+ ifile = Savepoint.intent_file(intent_dir)
116
121
  return "(none)" unless File.exist?(ifile)
117
122
 
118
123
  lines = File.read(ifile).split("\n", -1)
@@ -145,7 +150,7 @@ intent_name = "(unknown)" if intent_name.empty?
145
150
 
146
151
  stage = current_stage(intent_dir)
147
152
  role_label = (role && !role.empty?) ? role : stage
148
- artifacts = Bridge.has_files(intent_dir)
153
+ artifacts = Savepoint.has_files(intent_dir)
149
154
  artifacts_str = artifacts.empty? ? "(none)" : artifacts.join(", ")
150
155
 
151
156
  lines = []
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # append-ledger (intent 297) - the only writer of a session day ledger's
6
+ # shared files. Every verb either appends a line to checklist.md or
7
+ # savepoint.md, or flips one checklist line's state marker byte, always
8
+ # under an exclusive lock on the target file itself (spec D6, D7, D8). Every
9
+ # invocation scaffolds the day itself through SessionLedger.open_day (cheap
10
+ # and idempotent), so a capture that crosses midnight never fails and never
11
+ # needs a second process (spec D12).
12
+ #
13
+ # Usage:
14
+ # append-ledger pending <summary> [common flags]
15
+ # append-ledger item <summary> [common flags]
16
+ # append-ledger promote [--match <substring>] [--savepoint] [common flags]
17
+ # append-ledger tick [--match <substring>] [--savepoint] [common flags]
18
+ # append-ledger savepoint --event Item|Done|Note <summary> [common flags]
19
+ #
20
+ # Common flags:
21
+ # --store <dir> defaults to $PLASTIC_HOME/store ($PLASTIC_HOME defaults to ~/.plastic)
22
+ # --day <YYYYMMDD> defaults to SessionLedger.day_id (today, local wall clock)
23
+ # --session <id> defaults to $CLAUDE_CODE_SESSION_ID, then "local"
24
+ # --project <slug> defaults to the project_slug derived from --cwd
25
+ # --cwd <dir> defaults to Dir.pwd
26
+ # --templates <dir> defaults to the templates/ dir shipped next to this script
27
+ #
28
+ # Exit codes:
29
+ # 0 - done
30
+ # 2 - usage error, or promote/tick found nothing to address (no write happens)
31
+ # 3 - promote/tick could not take an exclusive lock (filesystem without flock support)
32
+
33
+ require_relative "lib/session_ledger"
34
+ require_relative "lib/handoff"
35
+
36
+ VERBS = %w[pending item promote tick savepoint].freeze
37
+
38
+ def usage_abort(message)
39
+ warn "append-ledger: #{message}"
40
+ exit 2
41
+ end
42
+
43
+ # The value token that follows a flag at argv[i]. Aborts with exit 2 rather
44
+ # than silently returning nil when the flag is the last token (a trailing
45
+ # `--store` with nothing after it must not fall back to a default as if the
46
+ # flag had never been given).
47
+ def next_value!(argv, i, flag)
48
+ usage_abort("#{flag} requires a value") if i + 1 >= argv.length
49
+ argv[i + 1]
50
+ end
51
+
52
+ def parse_args(argv)
53
+ opts = {
54
+ match: nil, savepoint: false, event: nil,
55
+ store: nil, day: nil, session: nil, project: nil, cwd: nil, templates: nil,
56
+ }
57
+ positional = []
58
+ i = 0
59
+ while i < argv.length
60
+ arg = argv[i]
61
+ case arg
62
+ when "--match" then opts[:match] = next_value!(argv, i, arg); i += 1
63
+ when "--event" then opts[:event] = next_value!(argv, i, arg); i += 1
64
+ when "--store" then opts[:store] = next_value!(argv, i, arg); i += 1
65
+ when "--day" then opts[:day] = next_value!(argv, i, arg); i += 1
66
+ when "--session" then opts[:session] = next_value!(argv, i, arg); i += 1
67
+ when "--project" then opts[:project] = next_value!(argv, i, arg); i += 1
68
+ when "--cwd" then opts[:cwd] = next_value!(argv, i, arg); i += 1
69
+ when "--templates" then opts[:templates] = next_value!(argv, i, arg); i += 1
70
+ when "--savepoint" then opts[:savepoint] = true
71
+ else
72
+ usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
73
+
74
+ positional << arg
75
+ end
76
+ i += 1
77
+ end
78
+
79
+ opts[:verb] = positional.shift
80
+ opts[:summary] = positional.shift
81
+ opts
82
+ end
83
+
84
+ def expand(path)
85
+ File.expand_path(path.to_s.sub(/\A~/, Dir.home))
86
+ end
87
+
88
+ # Sibling of this script's dir. Works in-repo (<repo>/scripts/append-ledger
89
+ # -> <repo>/templates) and installed (~/.plastic/scripts/append-ledger ->
90
+ # ~/.plastic/templates when present).
91
+ def default_templates_dir
92
+ File.expand_path("../templates", __dir__)
93
+ end
94
+
95
+ def sanitized_summary(raw)
96
+ usage_abort("a summary is required") if raw.nil?
97
+
98
+ clean = SessionLedger.sanitize_summary(raw)
99
+ usage_abort("summary is empty after sanitizing") if clean.empty?
100
+ clean
101
+ end
102
+
103
+ def append_checklist_line(state, store, day, session, project, summary)
104
+ clean = sanitized_summary(summary)
105
+ line = SessionLedger.checklist_line(state, session, project, clean)
106
+ path = SessionLedger.checklist_path(store, day)
107
+ SessionLedger.append_line(path, line, header: SessionLedger.checklist_header(day))
108
+ end
109
+
110
+ # set_state identifies the target line AND flips it inside one LOCK_EX hold,
111
+ # returning the flipped line's own summary (or nil when nothing matched).
112
+ # There is deliberately no separate identify-then-flip step here: a prior
113
+ # version located the target under its own released lock, then set_state
114
+ # re-located it under a fresh lock, and the two scans could disagree under
115
+ # concurrency, naming the wrong line in the --savepoint entry below. Using
116
+ # set_state's return value is what keeps identify-and-flip atomic.
117
+ def run_transition(from, to, event, store, day, session, project, opts)
118
+ path = SessionLedger.checklist_path(store, day)
119
+
120
+ target_summary =
121
+ begin
122
+ SessionLedger.set_state(path, from: from, to: to, session: session, match: opts[:match])
123
+ rescue SessionLedger::LockUnavailableError => e
124
+ warn "append-ledger: #{e.message}"
125
+ exit 3
126
+ end
127
+
128
+ if target_summary.nil?
129
+ warn "append-ledger: nothing to address"
130
+ exit 2
131
+ end
132
+
133
+ if opts[:savepoint]
134
+ line = SessionLedger.savepoint_line(event, session, project, target_summary, now: Time.now)
135
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
136
+ end
137
+
138
+ return unless to == :done
139
+
140
+ # The hand-off at every tick (intent 311, spec D4): derived and
141
+ # regenerable, so a failure here never fails the tick that was recorded.
142
+ begin
143
+ Handoff.write(store: store, day: day, session: session, trigger: "tick",
144
+ templates: opts[:templates] || default_templates_dir)
145
+ rescue StandardError
146
+ nil
147
+ end
148
+ end
149
+
150
+ def run_savepoint(store, day, session, project, opts)
151
+ unless SessionLedger::EVENTS.include?(opts[:event])
152
+ usage_abort("--event must be one of #{SessionLedger::EVENTS.join(", ")}, got #{opts[:event].inspect}")
153
+ end
154
+
155
+ clean = sanitized_summary(opts[:summary])
156
+ line = SessionLedger.savepoint_line(opts[:event], session, project, clean, now: Time.now)
157
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, day), line, header: nil)
158
+ end
159
+
160
+ def main(argv)
161
+ opts = parse_args(argv)
162
+ usage_abort("verb must be one of #{VERBS.join(", ")}, got #{opts[:verb].inspect}") unless VERBS.include?(opts[:verb])
163
+
164
+ plastic_home = expand(ENV.fetch("PLASTIC_HOME", "~/.plastic"))
165
+ store = opts[:store] ? expand(opts[:store]) : File.join(plastic_home, "store")
166
+ templates = opts[:templates] ? expand(opts[:templates]) : default_templates_dir
167
+ usage_abort("templates dir not found: #{templates}") unless Dir.exist?(templates)
168
+ opts[:templates] = templates
169
+ cwd = opts[:cwd] ? expand(opts[:cwd]) : Dir.pwd
170
+
171
+ day = opts[:day] || SessionLedger.day_id
172
+ unless SessionLedger.valid_day_id?(day)
173
+ usage_abort("--day must be eight digits parsing as a real date, got #{day.inspect}")
174
+ end
175
+
176
+ if opts[:project] && !SessionLedger::SLUG_RE.match?(opts[:project])
177
+ usage_abort("--project must match [a-z0-9-]+, got #{opts[:project].inspect}")
178
+ end
179
+
180
+ session = SessionLedger.short_session_id(opts[:session], ENV["CLAUDE_CODE_SESSION_ID"])
181
+ project = opts[:project] || SessionLedger.project_slug(cwd, plastic_home: plastic_home)
182
+
183
+ # Always call open_day: it is idempotent (File::CREAT | File::EXCL decides
184
+ # create vs join) and cheap, so this never depends on Dir.exist?(day_dir)
185
+ # as a proxy for "does the day file exist". A guard keyed on the DIRECTORY
186
+ # missed the case of a day dir that exists without its <day>.md (a crashed
187
+ # scaffold, or a template-read failure): that guard would see the
188
+ # directory, skip open_day forever, and every append would land in a day
189
+ # dir with no intent file at all.
190
+ SessionLedger.open_day(store: store, day: day, templates: templates, author: session)
191
+
192
+ case opts[:verb]
193
+ when "pending"
194
+ append_checklist_line(:pending, store, day, session, project, opts[:summary])
195
+ when "item"
196
+ append_checklist_line(:open, store, day, session, project, opts[:summary])
197
+ when "promote"
198
+ run_transition(:pending, :open, "Item", store, day, session, project, opts)
199
+ when "tick"
200
+ run_transition(:open, :done, "Done", store, day, session, project, opts)
201
+ when "savepoint"
202
+ run_savepoint(store, day, session, project, opts)
203
+ end
204
+
205
+ exit 0
206
+ end
207
+
208
+ main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -2,44 +2,41 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
  #
5
- # Usage: codex-hook <gate>
6
- # file-mutation gates: edit-gates | gate-check
7
- # live-state hooks (intent 199): session-start | check-update | continue |
8
- # future-intent-check | auto-arm | power-tools | savepoint
9
- # shell-tool gate (intent 203): bash-gate
5
+ # Usage: codex-hook <hook>
6
+ # file-mutation hook: record (PostToolUse on apply_patch)
7
+ # live-state hooks (intent 199, capture/record collapse by 298):
8
+ # session-start | check-update | capture | savepoint
9
+ # session-end hook (intent 309): close, handed off detached
10
10
  #
11
- # The Codex input adapter (intent 102, extended by 199, 203, and collapsed by
12
- # 251). Reads a Codex hook stdin payload once. Three shapes exist and the gate
13
- # name alone selects which path runs (Codex's own hooks.json registration
14
- # guarantees they never cross): the apply_patch path reads session_id at top
15
- # level and the apply_patch command in tool_input.command [guide Part 4],
16
- # parses the diff envelope once via ApplyPatchEnvelope, and runs all five gates
17
- # (savepoint-pre, lock-gate, code-gate, links-gate, create-gate) IN-PROCESS
18
- # through scripts/lib/codex_edit_gates.rb, which drives the same
19
- # scripts/lib/edit_gates.rb functions Claude's merged dispatcher drives
20
- # (mirroring intent 244). This replaced five registered PreToolUse commands and
21
- # three nested run_core children with one process. The live-state hooks carry
22
- # no tool_input at all (they are not tool calls); the dispatcher execs the SAME
23
- # launcher file Claude already runs for that hook (hooks/<name>), which is
24
- # already harness-agnostic (resolves ~/.plastic off $HOME, reads only the
25
- # common stdin fields the guide confirms Codex shares with Claude for these
26
- # three events), so the body is reused, not rewritten (D3). The shell-tool gate
27
- # (bash-gate) reports tool_name: "Bash" with the command in tool_input.command,
28
- # but MUST NOT be routed through ApplyPatchEnvelope.parse below: a plain shell
29
- # command has no *** Begin Patch/*** End Patch envelope, so it would parse to
30
- # an empty op list and hit this file's own `exit 0 if ops.empty?` line,
31
- # silently allowing every Bash call and reopening the exact hole intent 203
32
- # closes. So it execs the SAME scripts/hook-bash-gate file Claude already runs,
33
- # unmodified, the identical "drive the body, relay its output" pattern used for
34
- # the live-state hooks. The one adaptation shared by both the live-state and
35
- # shell-tool paths is threading the payload's session_id into
36
- # CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it,
37
- # plus a bounded timeout: Codex invokes hooks synchronously, so a launcher
38
- # that never releases this dispatcher's pipes would hang the session.
39
- # hooks/check-update used to leak them by backgrounding an npm network call
40
- # with the inherited descriptors (fixed at the root in intent 289 by
41
- # redirecting that subshell to /dev/null); the bound stays for any other
42
- # launcher that misbehaves, and capture_launcher keeps the timeout path silent.
11
+ # The Codex input adapter (intent 102, extended by 199, collapsed by 251, cut to
12
+ # the record hook by 302). Reads a Codex hook stdin payload once. Two shapes
13
+ # exist and the hook name alone selects which path runs (Codex's own hooks.json
14
+ # registration guarantees they never cross): the apply_patch path reads
15
+ # session_id at top level and the apply_patch command in tool_input.command
16
+ # [guide Part 4] and parses the diff envelope once via ApplyPatchEnvelope; the
17
+ # live-state hooks carry no tool_input at all (they are not tool calls), so the
18
+ # dispatcher execs the SAME launcher file Claude already runs for that hook
19
+ # (hooks/<name>), which is already harness-agnostic (resolves ~/.plastic off
20
+ # $HOME, reads only the common stdin fields the guide confirms Codex shares with
21
+ # Claude for these events), so the body is reused, not rewritten (D3). The one
22
+ # adaptation on the live-state path is threading the payload's session_id into
23
+ # CLAUDE_CODE_SESSION_ID, since Codex's own process env never carries it, plus a
24
+ # bounded timeout: Codex invokes hooks synchronously, so a launcher that never
25
+ # releases this dispatcher's pipes would hang the session. hooks/check-update
26
+ # used to leak them by backgrounding an npm network call with the inherited
27
+ # descriptors (fixed at the root in intent 289 by redirecting that subshell to
28
+ # /dev/null); the bound stays for any other launcher that misbehaves, and
29
+ # capture_launcher keeps the timeout path silent.
30
+ #
31
+ # The PreToolUse edit-path gates and the Bash-matcher shell gate were removed in
32
+ # 2.0 (intent 302), and the power-tools reminder in intent 309. An older
33
+ # ~/.codex/hooks.json that still names one of them reaches the fail-open `else`
34
+ # below: exit 0, no output, no decision.
35
+ #
36
+ # SessionEnd (intent 309): Codex kills a SessionEnd hook after 3 seconds, so `close`
37
+ # is never relayed synchronously. The dispatcher hands the payload to hooks/close in a
38
+ # detached child (own process group, stdin from a pipe, stdout and stderr to /dev/null)
39
+ # and exits at once; the close hook's own detached filer does the slow part.
43
40
  require "json"
44
41
  require "open3"
45
42
  require "rbconfig"
@@ -52,11 +49,11 @@ require "timeout"
52
49
  # caller that merges the dispatcher's own stdout and stderr.
53
50
  $stdout.sync = true
54
51
 
55
- STATE_HOOKS = %w[session-start check-update continue future-intent-check auto-arm power-tools savepoint].freeze
52
+ STATE_HOOKS = %w[session-start check-update capture savepoint close].freeze
56
53
  STATE_TIMEOUT = 5
57
- SHELL_HOOKS = %w[bash-gate].freeze
54
+ DETACHED_HOOKS = %w[close].freeze
58
55
 
59
- # One capture path for both relayed-launcher branches (intent 289). The 5 s bound covers a
56
+ # One capture path for the relayed-launcher branch (intent 289). The 5 s bound covers a
60
57
  # launcher that leaks THIS process's stdout and stderr into a longer-lived background child
61
58
  # (hooks/check-update did exactly that before 289): it does not bound a launcher that simply
62
59
  # never exits in the foreground, since Open3.popen3's ensure joins the child's wait thread on
@@ -80,6 +77,28 @@ def capture_launcher(env, *argv, raw:, cwd:)
80
77
  end
81
78
  end
82
79
 
80
+ # Hand a launcher its payload and return without waiting (intent 309). The child gets its
81
+ # own process group so the harness's teardown of this dispatcher never reaches it, reads
82
+ # the payload from a pipe this process fills without blocking and closes, and writes
83
+ # nowhere. Every failure (a missing or unexecutable launcher, a closed pipe) is rescued
84
+ # silently, so nothing ever reaches Codex's stderr from this path.
85
+ def detach_launcher(env, launcher, raw:, cwd:)
86
+ reader, writer = IO.pipe
87
+ pid = Process.spawn(env, launcher, in: reader, out: File::NULL, err: File::NULL,
88
+ chdir: cwd, pgroup: true)
89
+ reader.close
90
+ begin
91
+ writer.write_nonblock(raw) # a hook payload fits the pipe buffer; a larger one is truncated
92
+ rescue IO::WaitWritable, SystemCallError
93
+ nil
94
+ ensure
95
+ writer.close
96
+ end
97
+ Process.detach(pid)
98
+ rescue StandardError
99
+ nil # fail open: a close that cannot start is a close the next-day sweep files
100
+ end
101
+
83
102
  gate = ARGV[0].to_s
84
103
  raw = ($stdin.read rescue nil)
85
104
  exit 0 if raw.nil? || raw.strip.empty?
@@ -93,73 +112,42 @@ if STATE_HOOKS.include?(gate)
93
112
  cwd = payload["cwd"].to_s
94
113
  cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
95
114
  env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
115
+ if DETACHED_HOOKS.include?(gate)
116
+ detach_launcher(env.merge("RUBYOPT" => nil), launcher, raw: raw, cwd: cwd) if File.file?(launcher)
117
+ exit 0
118
+ end
96
119
  out, err, status = capture_launcher(env.merge("RUBYOPT" => nil), launcher, raw: raw, cwd: cwd)
97
120
  print out unless out.to_s.empty?
98
121
  warn err unless err.to_s.empty?
99
122
  exit(status ? status.exitstatus : 0)
100
123
  end
101
124
 
102
- if SHELL_HOOKS.include?(gate)
103
- # bash-gate (intent 203): a shell command has no apply_patch diff envelope, so
104
- # this branch execs the SAME scripts/hook-bash-gate file Claude already runs,
105
- # unmodified, and relays its exit code and stderr. It must never fall through
106
- # to ApplyPatchEnvelope.parse below (see header comment).
107
- cwd = payload["cwd"].to_s
108
- cwd = Dir.pwd if cwd.empty? || !Dir.exist?(cwd)
109
- env = { "CLAUDE_CODE_SESSION_ID" => (session.empty? ? nil : session) }
110
- script = File.join(__dir__, "hook-#{gate}")
111
- argv = [script]
112
- out, err, status = capture_launcher(env.merge("RUBYOPT" => nil), RbConfig.ruby, *argv, raw: raw, cwd: cwd)
113
- print out unless out.to_s.empty?
114
- warn err unless err.to_s.empty?
115
- exit(status ? status.exitstatus : 0)
116
- end
117
-
118
125
  require_relative "lib/apply_patch_envelope"
119
126
 
120
127
  command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
121
- exit 0 if command.nil? # fail-open: no tool_input at all (unrecognized gate name or non-tool-call payload)
128
+ exit 0 if command.nil? # fail-open: no tool_input at all (unrecognized hook name or non-tool-call payload)
122
129
  ops = ApplyPatchEnvelope.parse(command)
123
- exit 0 if ops.empty? # fail-open: nothing parseable to gate
130
+ exit 0 if ops.empty? # fail-open: nothing parseable to record
124
131
 
125
132
  CORES = __dir__ # ~/.plastic/scripts
126
- def run_core(name, *argv)
127
- out = IO.popen([{ "RUBYOPT" => nil }, RbConfig.ruby, File.join(CORES, name), *argv], "r", err: [:child, :out], &:read)
128
- [out, $?.exitstatus]
129
- end
130
133
 
131
134
  case gate
132
- when "edit-gates"
133
- # The ONE registered PreToolUse command on the apply_patch matcher (intent
134
- # 251). Five gates, in-process, in Claude's evaluation order, first deny wins,
135
- # each deny shape reproduced exactly: stderr plus exit 2 for code-gate,
136
- # links-gate and create-gate; stdout permissionDecision JSON plus exit 0 for
137
- # lock-gate. Fail-open at both levels, per gate inside EditGates.dispatch and
138
- # again at this file's own top level.
139
- begin
140
- require_relative "lib/codex_edit_gates"
141
- exit CodexEditGates.dispatch(ops: ops, payload: payload)
142
- rescue StandardError, ScriptError => e
143
- # Dispatcher-internal failure: never impersonate a deny. ScriptError (a
144
- # sibling of StandardError, not a subclass) covers LoadError: a future
145
- # manifest gap (a lib file required here but not distributed, exactly the
146
- # bug the full suite caught during this intent's own delivery) must fail
147
- # open with a gate decision, never exit 1 with no decision at all.
148
- $stderr.puts "plastic codex edit-gates error: #{e.message}"
149
- exit 0
150
- end
151
-
152
- when "gate-check"
153
- last_allow = nil
135
+ when "record"
136
+ # hook-record (intent 298) reads one stdin JSON payload; it takes no ARGV. One
137
+ # apply_patch call can bundle several file operations, so this synthesizes one
138
+ # Claude-shaped PostToolUse payload per op and pipes it to hook-record in-process,
139
+ # mirroring the "drive the body" pattern used for the live-state hooks. record
140
+ # never blocks and never prints anything (D3), so there is nothing to relay: every
141
+ # op's failure is independently rescued and the dispatcher always exits 0.
142
+ cwd_val = payload["cwd"].to_s
154
143
  ops.each do |o|
155
- out, code = run_core("hook-gate-check", o.path, session)
156
- if code == 2
157
- print out # {"decision":"block",...}
158
- exit 2
144
+ synth = JSON.generate("session_id" => session, "tool_input" => { "file_path" => o.path }, "cwd" => cwd_val)
145
+ begin
146
+ Open3.capture3({ "RUBYOPT" => nil }, RbConfig.ruby, File.join(CORES, "hook-record"), stdin_data: synth)
147
+ rescue StandardError
148
+ nil
159
149
  end
160
- last_allow = out unless out.to_s.strip.empty?
161
150
  end
162
- print last_allow if last_allow
163
151
  exit 0
164
152
 
165
153
  else