@zalom/plastic 1.0.0-alpha.9 → 1.0.0-beta.2

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 (99) hide show
  1. package/PLASTIC.md +163 -469
  2. package/README.md +95 -58
  3. package/agents/plastic-brainstorming.md +37 -0
  4. package/agents/plastic-enforcer.md +36 -0
  5. package/agents/plastic-executor.md +37 -0
  6. package/agents/{future-intent-researcher.md → plastic-future-intent-researcher.md} +1 -1
  7. package/agents/{intent-curator.md → plastic-intent-curator.md} +1 -1
  8. package/agents/plastic-planner.md +37 -0
  9. package/agents/plastic-spec-specialist.md +37 -0
  10. package/bin/plastic.js +57 -0
  11. package/bin/test +28 -0
  12. package/deprecations.yml +1 -10
  13. package/hooks/auto-arm +5 -0
  14. package/hooks/bash-gate +3 -0
  15. package/hooks/check-update +12 -8
  16. package/hooks/code-gate +12 -0
  17. package/hooks/create-gate +3 -0
  18. package/hooks/gate-check +3 -1
  19. package/hooks/hooks.json +52 -0
  20. package/hooks/qmd-search +8 -0
  21. package/package.json +2 -2
  22. package/scripts/dashboard.rb +687 -0
  23. package/scripts/doctor.rb +1054 -628
  24. package/scripts/hook-auto-arm +51 -0
  25. package/scripts/hook-bash-gate +41 -0
  26. package/scripts/hook-code-gate +27 -0
  27. package/scripts/hook-continue +15 -114
  28. package/scripts/hook-create-gate +59 -0
  29. package/scripts/hook-gate-check +47 -32
  30. package/scripts/hook-qmd-search +44 -0
  31. package/scripts/hook-session-start +106 -38
  32. package/scripts/install.rb +91 -529
  33. package/scripts/lib/boot_banner.rb +28 -0
  34. package/scripts/lib/bridge.rb +404 -19
  35. package/scripts/lib/installer_core.rb +807 -0
  36. package/scripts/lib/intent_validator.rb +235 -0
  37. package/scripts/lib/qmd_hook.rb +44 -0
  38. package/scripts/lib/qmd_sync.rb +209 -0
  39. package/scripts/lib/store_provisioning.rb +100 -0
  40. package/scripts/migrate-to-global +1 -1
  41. package/scripts/new-intent +226 -0
  42. package/scripts/provision-project-store +53 -0
  43. package/scripts/qmd-sync +92 -0
  44. package/scripts/select-update-target +93 -0
  45. package/scripts/spawn-preamble +121 -0
  46. package/scripts/uninstall.rb +53 -0
  47. package/scripts/update.rb +164 -0
  48. package/scripts/validate-intent +54 -0
  49. package/scripts/versions.rb +141 -0
  50. package/skills/_active-intent-gate.md +1 -1
  51. package/skills/add-project-store/SKILL.md +54 -0
  52. package/skills/auto/SKILL.md +77 -7
  53. package/skills/auto/evals/evals.json +207 -0
  54. package/skills/auto/references/agent-architecture.md +135 -0
  55. package/skills/brainstorming/SKILL.md +9 -9
  56. package/skills/brainstorming-grill-me/SKILL.md +6 -6
  57. package/skills/continuing/SKILL.md +92 -82
  58. package/skills/continuing/evals/evals.json +136 -0
  59. package/skills/continuing/references/context-management.md +32 -0
  60. package/skills/creating-intent/SKILL.md +74 -36
  61. package/skills/creating-intent/evals/evals.json +56 -0
  62. package/skills/creating-intent/references/lifecycle.md +76 -0
  63. package/skills/creating-intent/references/wikilinks.md +8 -0
  64. package/skills/creating-project/SKILL.md +40 -8
  65. package/skills/creating-project/references/hubs-projects.md +55 -0
  66. package/skills/dashboard/SKILL.md +121 -0
  67. package/skills/dashboard/templates/dashboard-global.md +31 -0
  68. package/skills/dashboard/templates/dashboard-project.md +40 -0
  69. package/skills/doctor/SKILL.md +51 -4
  70. package/skills/doctor/references/gates-stuck-detection.md +38 -0
  71. package/skills/doctor/report.md +4 -0
  72. package/skills/evaluating-skills/SKILL.md +140 -0
  73. package/skills/evaluating-skills/assets/eval-template.json +12 -0
  74. package/skills/evaluating-skills/evals/evals.json +75 -0
  75. package/skills/evaluating-skills/references/convention-checks.md +76 -0
  76. package/skills/evaluating-skills/references/eval-methodology.md +154 -0
  77. package/skills/executing-plan/SKILL.md +5 -3
  78. package/skills/install/SKILL.md +69 -8
  79. package/skills/intent-curator/SKILL.md +3 -3
  80. package/skills/linking-intents/SKILL.md +16 -7
  81. package/skills/linking-intents/references/zettelkasten.md +38 -0
  82. package/skills/managing-index/SKILL.md +5 -1
  83. package/skills/managing-index/references/zettelkasten-linking.md +1 -1
  84. package/skills/releasing/SKILL.md +80 -23
  85. package/skills/releasing/references/deprecations.md +60 -0
  86. package/skills/research/SKILL.md +2 -2
  87. package/skills/savepoint/SKILL.md +46 -37
  88. package/skills/savepoint/references/context-management.md +32 -0
  89. package/skills/uninstall/SKILL.md +39 -28
  90. package/skills/update/SKILL.md +41 -44
  91. package/skills/versions/SKILL.md +65 -0
  92. package/skills/writing-instructions/SKILL.md +159 -0
  93. package/skills/writing-instructions/references/agentskills-spec.md +135 -0
  94. package/skills/writing-plans/SKILL.md +5 -5
  95. package/templates/agents.md +7 -7
  96. package/templates/outcome.md +13 -0
  97. package/templates/savepoint.md +14 -13
  98. package/templates/spec.md +25 -0
  99. package/bin/install.js +0 -29
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-auto-arm <message>
6
+ # UserPromptSubmit arming (intent 27). Two jobs, both additive context — never blocks:
7
+ # 1. Detect auto-trigger phrases -> steer the agent to invoke plastic-auto.
8
+ # 2. If auto mode is armed and the active intent hasn't reached How -> remind the agent
9
+ # that project-code edits are gate-blocked until plan.md + checklist.md exist.
10
+ # Outputs UserPromptSubmit hookSpecificOutput JSON, or exits silently.
11
+
12
+ require "json"
13
+ require_relative "lib/bridge"
14
+
15
+ message = (ARGV[0] || "").downcase
16
+ parts = []
17
+
18
+ # --- 1. Auto-trigger steering ---
19
+ AUTO_TRIGGERS = ["take it from here", "deliver this", "/plastic-auto", "auto mode"].freeze
20
+ # bare "auto" only as a standalone word to avoid matching "automatic", "automation", etc.
21
+ triggered = AUTO_TRIGGERS.any? { |p| message.include?(p) } || message.match?(/\bauto\b/)
22
+ if triggered
23
+ parts << "Auto-mode request detected. Invoke the plastic-auto skill to deliver the " \
24
+ "active intent autonomously — it arms the lifecycle gate so project code " \
25
+ "cannot be edited before plan.md + checklist.md exist."
26
+ end
27
+
28
+ # --- 2. Standing gate reminder when armed and pre-How ---
29
+ bridge_data = Bridge.discover_bridge(session: ENV["CLAUDE_SESSION_ID"], cwd: Dir.pwd)
30
+ if bridge_data && bridge_data.dig("build", "auto") == true
31
+ intent = bridge_data["intent"] || {}
32
+ store = intent["store"]; dir = intent["dir"]
33
+ if store && dir
34
+ intent_dir = File.expand_path("#{store}/#{dir}")
35
+ reached_how = File.exist?("#{intent_dir}/plan.md") && File.exist?("#{intent_dir}/checklist.md")
36
+ unless reached_how
37
+ parts << "Auto mode armed on intent #{intent["id"]} — produce spec -> plan -> " \
38
+ "checklist before editing project code. Code edits are gate-blocked until then."
39
+ end
40
+ end
41
+ end
42
+
43
+ exit 0 if parts.empty?
44
+
45
+ payload = {
46
+ "hookSpecificOutput" => {
47
+ "hookEventName" => "UserPromptSubmit",
48
+ "additionalContext" => parts.join("\n")
49
+ }
50
+ }
51
+ puts JSON.generate(payload)
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # PreToolUse gate for Bash (intent 27a): close the Bash-edit bypass. When auto
6
+ # mode is armed and the active intent has not reached How (plan.md + checklist.md),
7
+ # block Bash commands that WRITE project code outside the store.
8
+ #
9
+ # Reads the Claude Code PreToolUse payload as JSON on STDIN:
10
+ # { "tool_input": { "command": "..." }, "cwd": "..." }
11
+ # Empty / unparseable / no command => exit 0 (allow). Conservatism is the prime
12
+ # directive: when a command's write intent is ambiguous, ALLOW.
13
+ #
14
+ # Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
15
+
16
+ require "json"
17
+ require_relative "lib/bridge"
18
+
19
+ raw = $stdin.read rescue nil
20
+ exit 0 if raw.nil? || raw.strip.empty?
21
+
22
+ payload = JSON.parse(raw) rescue nil
23
+ exit 0 unless payload.is_a?(Hash)
24
+
25
+ command = payload.dig("tool_input", "command") || payload.dig("tool_params", "command")
26
+ exit 0 if command.nil? || command.to_s.strip.empty?
27
+
28
+ cwd = payload["cwd"]
29
+ cwd = Dir.pwd if cwd.nil? || cwd.to_s.empty?
30
+
31
+ # --- Load bridge (shared resolution; stdin session_id -> env -> /tmp scan) ---
32
+ session = payload["session_id"]
33
+ session = ENV["CLAUDE_SESSION_ID"] if session.nil? || session.to_s.empty?
34
+ bridge_data = Bridge.discover_bridge(session: session, cwd: cwd)
35
+ exit 0 unless bridge_data
36
+
37
+ reason = Bridge.bash_gate_decision(bridge_data, command, cwd: cwd)
38
+ exit 0 unless reason
39
+
40
+ $stderr.puts "PLASTIC GATE — #{reason}"
41
+ exit 2
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # Usage: hook-code-gate <file_path>
6
+ # PreToolUse gate (intent 27): when auto mode is armed and the active intent has not
7
+ # reached How (plan.md + checklist.md), block edits to project code outside the store.
8
+ #
9
+ # Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
10
+ # No bridge / auto not armed / How reached / path under ~/.plastic or the intent dir = allow.
11
+
12
+ require_relative "lib/bridge"
13
+
14
+ file_path = ARGV[0]
15
+ exit 0 unless file_path && !file_path.empty?
16
+
17
+ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
18
+
19
+ # --- Load bridge (shared resolution; stdin session_id -> env -> /tmp scan) ---
20
+ bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
21
+ exit 0 unless bridge_data
22
+
23
+ reason = Bridge.code_gate_decision(bridge_data, file_path)
24
+ exit 0 unless reason
25
+
26
+ $stderr.puts "PLASTIC GATE — #{reason}"
27
+ exit 2
@@ -1,130 +1,31 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: UTF-8
3
3
  # Usage: hook-continue <index_path> <store_root> <mode>
4
- # Outputs hook JSON for the continue hook. Exits silently if nothing to show.
4
+ # Renders the dashboard `continue` cockpit as UserPromptSubmit additionalContext.
5
+ # Exits silently (no output) if the dashboard produces nothing — the bash caller
6
+ # then falls back to a minimal notice.
5
7
 
6
8
  require "json"
7
- require "date"
8
- require "yaml"
9
+ require "open3"
9
10
 
10
- index_path, store_root, mode = ARGV
11
- exit 0 unless index_path && store_root && mode
11
+ index_path, _store_root, _mode = ARGV
12
+ exit 0 unless index_path && File.exist?(index_path)
12
13
 
13
- # --- Parse INDEX.md ---
14
+ dashboard = File.expand_path("dashboard.rb", __dir__)
15
+ exit 0 unless File.exist?(dashboard)
14
16
 
15
- lines = File.readlines(index_path)
16
- active = []
17
- future = []
18
- section = nil
17
+ cockpit, _err, status = Open3.capture3("ruby", dashboard, "continue")
18
+ exit 0 unless status.success? && !cockpit.strip.empty?
19
19
 
20
- lines.each do |line|
21
- if line.start_with?("## Active")
22
- section = :active
23
- next
24
- elsif line.start_with?("## Future")
25
- section = :future
26
- next
27
- elsif line.start_with?("## ")
28
- section = nil
29
- next
30
- end
31
-
32
- next unless section && line.strip.start_with?("- [")
33
-
34
- if section == :active
35
- active << line.strip
36
- elsif section == :future
37
- future << line.strip
38
- end
39
- end
40
-
41
- # --- Detect stale future intents ---
42
-
43
- stale = []
44
- stale_days = 3
45
- config_path = "#{store_root}/config.yml"
46
- if File.exist?(config_path)
47
- config = YAML.safe_load(File.read(config_path)) rescue {}
48
- stale_days = config["stale_threshold_days"] || 3
49
- end
50
-
51
- future.each do |f|
52
- if f =~ /store\/([\w-]+)\//
53
- dir_name = $1
54
- intent_file = "#{store_root}/store/#{dir_name}/#{dir_name}.md"
55
- next unless File.exist?(intent_file)
56
-
57
- content = File.read(intent_file)
58
- if content =~ /^created:\s*['"]?(\d{4}-\d{2}-\d{2})/
59
- age = (Date.today - Date.parse($1)).to_i
60
- if age >= stale_days
61
- name = f[/\[([^\]]+)\]/, 1] || "unknown"
62
- stale << { name: name, age: age, entry: f }
63
- end
64
- end
65
- end
66
- end
67
-
68
- # --- Detect current project ---
69
-
70
- current_project = nil
71
- if mode == "global"
72
- projects_path = "#{store_root}/projects.yml"
73
- if File.exist?(projects_path)
74
- projects = YAML.safe_load(File.read(projects_path)) rescue {}
75
- cwd = Dir.pwd
76
- (projects["projects"] || {}).each do |slug, info|
77
- project_path = File.expand_path(info["path"])
78
- if cwd.start_with?(project_path)
79
- current_project = { "slug" => slug, "parent" => info["parent"], "path" => project_path }
80
- break
81
- end
82
- end
83
- end
84
- end
85
-
86
- # --- Build context ---
87
-
88
- parts = []
89
- parts << "PLASTIC CONTINUE — The user wants to resume work.\n"
90
-
91
- if mode == "global" && current_project
92
- parts << "Project: #{current_project["slug"]} (#{current_project["path"]})\n"
93
- parts << "Governing intent: #{current_project["parent"]}\n" if current_project["parent"]
94
- end
95
-
96
- if active.any?
97
- parts << "## Active Intents (work on these first)\n"
98
- active.each { |a| parts << a }
99
- parts << ""
100
- else
101
- parts << "## No Active Intents\n"
102
- end
103
-
104
- if future.any?
105
- parts << "## Future Intents (offer as next work)\n"
106
- future.each { |f| parts << f }
107
- parts << ""
108
- end
109
-
110
- if stale.any?
111
- parts << "## Stale Future Intents\n"
112
- parts << "These future intents have not been actioned. Ask the user what to do with each:\n"
113
- stale.each do |s|
114
- parts << "- #{s[:name]} (#{s[:age]} days old) — options: activate, abandon, or defer to agent (implement / research / ideate)"
115
- end
116
- parts << ""
117
- end
118
-
119
- parts << "Follow the plastic:continuing skill workflow.\n"
120
- parts << "1. If active intents exist: read their state and resume\n"
121
- parts << "2. If no active intents: present future intents as options\n"
122
- parts << "3. If stale intents exist: surface them and ask user to decide"
20
+ context = cockpit.rstrip +
21
+ "\n\nFollow the plastic-continuing skill workflow to resume: " \
22
+ "if active intents exist, read their state and resume; otherwise offer the " \
23
+ "Value×Effort matrix items above, surfacing any stale (Nd) / ⚑ triage intents."
123
24
 
124
25
  payload = {
125
26
  "hookSpecificOutput" => {
126
27
  "hookEventName" => "UserPromptSubmit",
127
- "additionalContext" => parts.join("\n")
28
+ "additionalContext" => context
128
29
  }
129
30
  }
130
31
  puts JSON.generate(payload)
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # PreToolUse create gate (intent 60b): validate the PROPOSED content of a Write
6
+ # to an intent file before it lands. Fires when the target path is an intent file
7
+ # inside its own equally-named directory (store/<id>--<slug>/<id>--<slug>.md). It
8
+ # validates the proposed content from the hook payload (tool_input.content), NOT
9
+ # the on-disk file (which does not exist yet at PreToolUse), using IntentValidator
10
+ # for born-complete frontmatter plus the sanctioned section structure.
11
+ #
12
+ # It depends ONLY on the stdin path + content, never on the auto-bridge or
13
+ # CLAUDE_SESSION_ID, so it runs unconditionally (headless / background sessions).
14
+ # It validates ONLY the intent file, never sentinel placeholder lifecycle files
15
+ # (spec.md/plan.md/etc.); the path matcher excludes them.
16
+ #
17
+ # Exit 0 = allow. Exit 2 = block (reason on stderr, shown to the agent).
18
+ #
19
+ # Reads the Claude Code PreToolUse payload as JSON on STDIN:
20
+ # { "tool_input": { "file_path": "...", "content": "..." } }
21
+ # Empty / unparseable / non-matching path => exit 0 (cannot judge, allow).
22
+ # Matching path but missing content => exit 2 (fail-safe: refuse to allow an
23
+ # unvalidated intent write).
24
+
25
+ require "json"
26
+ require_relative "lib/intent_validator"
27
+
28
+ raw = $stdin.read rescue nil
29
+ exit 0 if raw.nil? || raw.strip.empty?
30
+
31
+ payload = JSON.parse(raw) rescue nil
32
+ exit 0 unless payload.is_a?(Hash)
33
+
34
+ path = payload.dig("tool_input", "file_path") || payload.dig("tool_params", "file_path")
35
+ exit 0 if path.nil? || path.to_s.strip.empty?
36
+
37
+ # Precise path matcher: the target must be an intent file inside its own
38
+ # equally-named dir. Never matches sibling lifecycle files (spec.md, etc.) or
39
+ # unrelated store files.
40
+ abs = File.expand_path(path)
41
+ dir = File.dirname(abs)
42
+ is_intent_file = dir.match?(%r{/store/[^/]+--[^/]+\z}) &&
43
+ File.basename(abs) == "#{File.basename(dir)}.md"
44
+ exit 0 unless is_intent_file
45
+
46
+ content = payload.dig("tool_input", "content") || payload.dig("tool_params", "content")
47
+ if content.nil?
48
+ $stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)}: cannot read proposed content; " \
49
+ "refusing to allow an unvalidated intent write."
50
+ exit 2
51
+ end
52
+
53
+ result = IntentValidator.validate_content(content)
54
+ exit 0 if result[:ok]
55
+
56
+ $stderr.puts "PLASTIC CREATE GATE — #{File.basename(abs)} is not a valid intent:"
57
+ result[:errors].each { |e| $stderr.puts " #{e}" }
58
+ $stderr.puts "Create intents via new-intent / plastic-creating-intent; do not hand-author them."
59
+ exit 2
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # encoding: UTF-8
3
- # Usage: hook-gate-check <file_path>
3
+ # Usage: hook-gate-check <file_path> [session_id]
4
4
  # PostToolUse gate enforcement for Plastic intent lifecycle.
5
5
  # Checks if a written file triggers a stage-transition gate.
6
6
  # Exit 0 = allow (optionally with transition context)
@@ -8,36 +8,51 @@
8
8
 
9
9
  require "json"
10
10
  require_relative "lib/bridge"
11
+ require_relative "lib/intent_validator"
11
12
 
12
13
  file_path = ARGV[0]
13
14
  exit 0 unless file_path && !file_path.empty?
14
15
 
15
- # --- Find bridge file ---
16
+ session = (ARGV[1] unless ARGV[1].to_s.empty?) || ENV["CLAUDE_SESSION_ID"]
16
17
 
17
- session = ENV["CLAUDE_SESSION_ID"]
18
- bridge_path = nil
19
- bridge_data = nil
18
+ file_path_abs = File.expand_path(file_path)
20
19
 
21
- if session && !session.empty?
22
- bridge_path = Bridge.path(session)
23
- bridge_data = Bridge.read(session) if File.exist?(bridge_path)
20
+ # --- Decoupled savepoint ledger (intent 34, hardened in intent 52) ---
21
+ # The savepoint is derived purely from the file path's intent directory, BEFORE
22
+ # any bridge resolution. A missing bridge must never skip the savepoint.
23
+ intent_dir_abs = Bridge.intent_dir_for(file_path_abs)
24
+ if intent_dir_abs
25
+ begin
26
+ Bridge.append_savepoint(intent_dir_abs, file_path_abs)
27
+ rescue StandardError
28
+ # ignore — rebuildable from disk
29
+ end
24
30
  end
25
31
 
26
- # Fallback: scan /tmp for most recent plastic bridge file
27
- unless bridge_data
28
- candidates = Dir.glob("/tmp/plastic-*.json").reject { |f| f.end_with?(".tmp") }
29
- if candidates.any?
30
- bridge_path = candidates.max_by { |f| File.mtime(f) }
31
- raw = JSON.parse(File.read(bridge_path)) rescue nil
32
- if raw
33
- session = raw["session"]
34
- bridge_data = raw
35
- end
32
+ # --- Artifact-validity backstop (intent 4a1c1) ---
33
+ # When the written file IS the intent file itself (the `<id>--<slug>.md` directly
34
+ # inside `store/<id>--<slug>/`), run IntentValidator on it. This is headless-safe:
35
+ # it depends only on the file path and disk, never on a bridge or session. In the
36
+ # PostToolUse model the write has already happened, so we cannot prevent it; the
37
+ # loud non-zero exit + stderr is the rejection signal. NOT for spec.md/plan.md/
38
+ # checklist.md/outcome.md/savepoint.md — those are validated by the stage gates.
39
+ if intent_dir_abs && File.basename(file_path_abs) == File.basename(Bridge.intent_file(intent_dir_abs))
40
+ result = IntentValidator.validate(intent_dir_abs)
41
+ unless result[:ok]
42
+ warn "PLASTIC ARTIFACT INVALID — #{File.basename(file_path_abs)} is not born complete:"
43
+ result[:missing].each { |field| warn " missing required field: #{field}" }
44
+ result[:errors].each { |error| warn " #{error}" }
45
+ warn "Fix the frontmatter; the intent is not valid until every required field is present and sources/chain are well-formed."
46
+ exit 1
36
47
  end
37
48
  end
38
49
 
39
- # No bridge = no active intent = no enforcement
40
- exit 0 unless bridge_data && session
50
+ # --- Find bridge file ---
51
+ bridge_data = Bridge.discover_bridge(session: session, cwd: Dir.pwd)
52
+
53
+ # No bridge = no active intent = no stage enforcement. Savepoint already handled.
54
+ exit 0 unless bridge_data
55
+ session = bridge_data["session"]
41
56
 
42
57
  # --- Resolve intent directory ---
43
58
 
@@ -49,15 +64,14 @@ dir = intent_info["dir"]
49
64
  intent_dir = "#{store}/#{dir}"
50
65
 
51
66
  # Normalize both paths for comparison
52
- intent_dir_abs = File.expand_path(intent_dir)
53
- file_path_abs = File.expand_path(file_path)
67
+ bridge_intent_dir_abs = File.expand_path(intent_dir)
54
68
 
55
69
  # Not inside intent dir = not our business
56
- exit 0 unless file_path_abs.start_with?("#{intent_dir_abs}/")
70
+ exit 0 unless file_path_abs.start_with?("#{bridge_intent_dir_abs}/")
57
71
 
58
72
  # --- Determine if this is a stage-transition file ---
59
73
 
60
- relative = file_path_abs.sub("#{intent_dir_abs}/", "")
74
+ relative = file_path_abs.sub("#{bridge_intent_dir_abs}/", "")
61
75
  basename = File.basename(file_path)
62
76
 
63
77
  stage_files = %w[spec.md plan.md checklist.md outcome.md]
@@ -66,7 +80,7 @@ is_action_file = relative.start_with?("actions/")
66
80
 
67
81
  if is_stage_file || is_action_file
68
82
  # Run gate check
69
- error = Bridge.check_gate(intent_dir_abs, file_path_abs)
83
+ error = Bridge.check_gate(bridge_intent_dir_abs, file_path_abs)
70
84
 
71
85
  if error
72
86
  # Gate violation — block the write
@@ -83,11 +97,11 @@ if is_stage_file || is_action_file
83
97
 
84
98
  # Gate passed — update bridge with new stage
85
99
  old_stage = bridge_data["build"]["stage"]
86
- new_stage = Bridge.derive_stage(intent_dir_abs)
87
- new_missing = Bridge.missing_for_stage(new_stage) - Bridge.has_files(intent_dir_abs)
100
+ new_stage = Bridge.derive_stage(bridge_intent_dir_abs)
101
+ new_missing = Bridge.missing_for_stage(new_stage) - Bridge.has_files(bridge_intent_dir_abs)
88
102
 
89
103
  bridge_data["build"]["stage"] = new_stage
90
- bridge_data["build"]["has"] = Bridge.has_files(intent_dir_abs)
104
+ bridge_data["build"]["has"] = Bridge.has_files(bridge_intent_dir_abs)
91
105
  bridge_data["build"]["missing"] = new_missing
92
106
  bridge_data["build"]["gate_failures"] = 0
93
107
  bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
@@ -102,9 +116,9 @@ if is_stage_file || is_action_file
102
116
  stage_labels = { "what" => "What", "why" => "Why", "how" => "How", "exec" => "Exec", "done" => "Done" }
103
117
  next_hints = {
104
118
  "why" => "write spec.md",
105
- "how" => "Why complete. Invoke plastic:auto to deliver autonomously, or write plan.md manually.",
106
- "exec" => "How complete. Invoke plastic:auto or plastic:executing-plan to execute, or work through the checklist manually.",
107
- "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic:auto or do it manually."
119
+ "how" => "Why complete. Invoke plastic-auto to deliver autonomously, or write plan.md manually.",
120
+ "exec" => "How complete. Invoke plastic-auto or plastic-executing-plan to execute, or work through the checklist manually.",
121
+ "done" => "Exec complete. Intent must be completed now — write outcome.md, update INDEX.md, auto-commit. Use plastic-auto or do it manually."
108
122
  }
109
123
 
110
124
  context_parts = ["PLASTIC"]
@@ -129,7 +143,8 @@ if is_stage_file || is_action_file
129
143
  puts JSON.generate(payload)
130
144
  exit 0
131
145
  else
132
- # Regular file in intent dir — just update last_activity
146
+ # Regular file in intent dir — just update last_activity. The savepoint for
147
+ # the What milestone was already handled above by the decoupled ledger write.
133
148
  bridge_data["build"]["last_activity"] = Time.now.utc.iso8601
134
149
  Bridge.write(session, bridge_data)
135
150
  exit 0
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # qmd-first UserPromptSubmit hook (intent 66). Reads the prompt from stdin JSON,
6
+ # delegates the decision to QmdHook.run under a hard timeout, and prints
7
+ # hookSpecificOutput when there is something to inject/remind. Silent no-op (exit
8
+ # 0) when qmd is absent, the prompt is trivial, or anything goes wrong. ARGV[0]
9
+ # is the plastic_home (passed by the launcher); defaults to ~/.plastic.
10
+ require "json"
11
+ require "timeout"
12
+ require_relative "lib/qmd_hook"
13
+
14
+ raw = begin
15
+ STDIN.read
16
+ rescue StandardError
17
+ ""
18
+ end
19
+
20
+ prompt = begin
21
+ parsed = JSON.parse(raw)
22
+ parsed.is_a?(Hash) ? parsed["user_prompt"].to_s : ""
23
+ rescue StandardError
24
+ ""
25
+ end
26
+
27
+ plastic_home = (ARGV[0] && !ARGV[0].empty?) ? ARGV[0] : File.expand_path("~/.plastic")
28
+
29
+ context = begin
30
+ Timeout.timeout(2) do
31
+ QmdHook.run(prompt: prompt, cwd: Dir.pwd, plastic_home: plastic_home)
32
+ end
33
+ rescue Exception
34
+ nil
35
+ end
36
+
37
+ exit 0 if context.nil? || context.strip.empty?
38
+
39
+ puts JSON.generate(
40
+ "hookSpecificOutput" => {
41
+ "hookEventName" => "UserPromptSubmit",
42
+ "additionalContext" => context,
43
+ }
44
+ )