@zalom/plastic 1.8.0 → 1.10.0
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.
- package/PLASTIC.md +94 -556
- package/README.md +18 -6
- package/agents/plastic-enforcer.md +3 -2
- package/agents/plastic-intent-curator.md +2 -2
- package/agents/plastic-intent-discovery.md +7 -0
- package/bin/plastic.js +17 -8
- package/deprecations.yml +10 -2
- package/hooks/auto-arm +2 -2
- package/hooks/bash-gate +1 -1
- package/hooks/check-update +1 -1
- package/hooks/continue +2 -2
- package/hooks/edit-gates +3 -0
- package/hooks/future-intent-check +2 -2
- package/hooks/gate-check +3 -3
- package/hooks/hooks.json +4 -44
- package/hooks/power-tools +8 -0
- package/hooks/session-start +1 -1
- package/package.json +1 -2
- package/scripts/codex-hook +57 -118
- package/scripts/doctor.rb +146 -1012
- package/scripts/exec-worktree +103 -0
- package/scripts/hash-intent +1 -1
- package/scripts/hook-bash-gate +19 -0
- package/scripts/hook-code-gate +16 -28
- package/scripts/hook-continue +2 -2
- package/scripts/hook-create-gate +13 -57
- package/scripts/hook-edit-gates +58 -0
- package/scripts/hook-gate-check +17 -0
- package/scripts/hook-links-gate +11 -31
- package/scripts/hook-lock-gate +19 -60
- package/scripts/hook-power-tools +38 -0
- package/scripts/hook-savepoint-pre +11 -11
- package/scripts/hook-session-start +21 -15
- package/scripts/lib/apply_patch_envelope.rb +46 -13
- package/scripts/lib/bridge.rb +85 -17
- package/scripts/lib/codex_edit_gates.rb +138 -0
- package/scripts/lib/doctor_core.rb +1087 -0
- package/scripts/lib/edit_gates.rb +398 -0
- package/scripts/lib/exec_worktree.rb +325 -0
- package/scripts/lib/harness_text.rb +57 -0
- package/scripts/lib/hook_registry.rb +64 -28
- package/scripts/lib/installer_core.rb +166 -24
- package/scripts/lib/lock.rb +196 -47
- package/scripts/lib/qmd_hook.rb +21 -41
- package/scripts/lib/qmd_sync.rb +0 -15
- package/scripts/lib/revisions_writer.rb +1 -1
- package/scripts/lib/ruby_probe.rb +60 -0
- package/scripts/lib/scaffold_intent.rb +392 -0
- package/scripts/lib/spec_header.rb +83 -0
- package/scripts/lib/start_intent.rb +296 -0
- package/scripts/lib/verify_intent.rb +262 -0
- package/scripts/lib/worktree.rb +15 -1
- package/scripts/link-suggest +1 -1
- package/scripts/maintenance-run +5 -5
- package/scripts/migrate-to-global +2 -2
- package/scripts/restore-intent-v1 +17 -12
- package/scripts/scaffold-intent +120 -0
- package/scripts/start-intent +89 -0
- package/scripts/verify-intent +73 -0
- package/skills/agent-advisor/SKILL.md +5 -5
- package/skills/auto/SKILL.md +52 -32
- package/skills/auto/evals/evals.json +2 -2
- package/skills/auto/references/agent-architecture.md +1 -1
- package/skills/auto/references/agent-report-contract.md +1 -1
- package/skills/auto/references/human-report-contract.md +22 -3
- package/skills/auto/references/tiers.md +24 -2
- package/skills/conventions/SKILL.md +31 -0
- package/skills/conventions/references/completion-and-done.md +43 -0
- package/skills/conventions/references/gates-and-enforcement.md +39 -0
- package/skills/conventions/references/knowledge-graph.md +47 -0
- package/skills/conventions/references/lifecycle-and-savepoints.md +11 -0
- package/skills/conventions/references/locks-and-worktrees.md +113 -0
- package/skills/conventions/references/maintenance-and-revisions.md +170 -0
- package/skills/conventions/references/roadmaps.md +44 -0
- package/skills/conventions/references/tiers-and-dispatch.md +135 -0
- package/skills/dashboard/SKILL.md +1 -1
- package/skills/doctor/SKILL.md +15 -6
- package/skills/doctor/references/gates-stuck-detection.md +13 -8
- package/skills/doctor/report.md +1 -1
- package/skills/install/SKILL.md +1 -1
- package/skills/intent-brainstorming/SKILL.md +4 -2
- package/skills/intent-continuing/SKILL.md +4 -0
- package/skills/intent-creating/SKILL.md +13 -8
- package/skills/intent-creating/references/lifecycle.md +1 -1
- package/skills/intent-discovering/SKILL.md +10 -3
- package/skills/intent-ending/SKILL.md +12 -7
- package/skills/intent-executing/SKILL.md +37 -19
- package/skills/intent-grilling/SKILL.md +5 -3
- package/skills/intent-linking/SKILL.md +4 -0
- package/skills/intent-locking/SKILL.md +4 -0
- package/skills/intent-planning/SKILL.md +14 -3
- package/skills/intent-researching/SKILL.md +0 -2
- package/skills/intent-savepoint/SKILL.md +4 -0
- package/skills/intent-speccing/SKILL.md +4 -0
- package/skills/intent-starting/SKILL.md +20 -2
- package/skills/project-creating/SKILL.md +0 -2
- package/skills/project-creating/references/project-scaffolding.md +3 -3
- package/skills/releasing/SKILL.md +1 -1
- package/skills/releasing/references/promotion-and-tagging.md +14 -8
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +4 -0
- package/skills/roadmap-continuing/SKILL.md +4 -0
- package/skills/skill-creating/SKILL.md +8 -2
- package/skills/skill-creating/references/defaults-first.md +23 -0
- package/skills/skill-creating/references/hooks.md +4 -1
- package/skills/store-curating/SKILL.md +8 -0
- package/skills/store-indexing/SKILL.md +16 -5
- package/skills/store-indexing/references/zettelkasten-linking.md +1 -1
- package/skills/tutorial/references/track-1-guided.md +2 -2
- package/skills/tutorial/references/track-2-auto.md +10 -8
- package/skills/tutorial/references/track-3-projects-and-roadmaps.md +1 -1
- package/skills/uninstall/SKILL.md +6 -9
- package/templates/agents.md +12 -12
- package/templates/config.yml +6 -7
- package/templates/index.md +6 -3
- package/templates/spec.md +1 -1
- package/PLASTIC-reference.md +0 -138
- package/hooks/code-gate +0 -27
- package/hooks/create-gate +0 -3
- package/hooks/links-gate +0 -3
- package/hooks/lock-gate +0 -21
- package/hooks/qmd-search +0 -8
- package/hooks/retrieval-gate +0 -10
- package/hooks/savepoint-pre +0 -10
- package/scripts/hook-qmd-search +0 -44
- package/scripts/hook-retrieval-gate +0 -148
- package/scripts/lib/retrieval_gate.rb +0 -211
package/scripts/lib/qmd_hook.rb
CHANGED
|
@@ -5,22 +5,28 @@ require_relative "qmd_sync"
|
|
|
5
5
|
require_relative "power_tools"
|
|
6
6
|
|
|
7
7
|
# QmdHook - decision logic for the power-tools UserPromptSubmit hook (intents 66,
|
|
8
|
-
# 66b). Pure and dependency-injected: returns the additionalContext
|
|
9
|
-
# emit, or nil to emit nothing. The executable hook wires real deps and
|
|
10
|
-
# this is unit-tested with
|
|
8
|
+
# 66b, 187, 246). Pure and dependency-injected: returns the additionalContext
|
|
9
|
+
# string to emit, or nil to emit nothing. The executable hook wires real deps and
|
|
10
|
+
# prints; this is unit-tested with fake detectors (no real qmd, no network, no
|
|
11
|
+
# subprocess at all).
|
|
11
12
|
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
13
|
+
# It emits exactly one thing: the PowerTools mandate, one recommendation line per
|
|
14
|
+
# present tool (qmd for finding intents, Enola-first for code navigation, falling
|
|
15
|
+
# back to Serena; intent 187 added the enola_detector alongside the pre-existing
|
|
16
|
+
# serena_detector).
|
|
17
|
+
#
|
|
18
|
+
# Intent 246 removed the scored qmd hit injection this hook used to prepend.
|
|
19
|
+
# Intent 225 measured that injection at 0.24 intent-level recall@3 against a plain
|
|
20
|
+
# ripgrep control at 0.18, while agent-driven `qmd query` scored 0.71. The failure
|
|
21
|
+
# was recall, not latency, so caching and async were both rejected. QmdSync.search
|
|
22
|
+
# is untouched and still backs the `scripts/qmd-sync search` CLI verb. All three
|
|
23
|
+
# detectors are PATH and marker-file walks with no subprocess, which is why what is
|
|
24
|
+
# left costs about a tenth of a second.
|
|
16
25
|
module QmdHook
|
|
17
26
|
module_function
|
|
18
27
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
def run(prompt:, cwd:, plastic_home:, runner: QmdSync.default_runner,
|
|
22
|
-
detector: QmdSync.method(:detect), limit: 3, min_score: 0.5,
|
|
23
|
-
serena_detector: nil, enola_detector: nil)
|
|
28
|
+
def run(cwd:, detector: QmdSync.method(:detect), serena_detector: nil,
|
|
29
|
+
enola_detector: nil)
|
|
24
30
|
serena_detector ||= -> { PowerTools.serena?(cwd: cwd) }
|
|
25
31
|
enola_detector ||= -> { PowerTools.enola?(cwd: cwd) }
|
|
26
32
|
qmd_present = !!detector.call
|
|
@@ -28,34 +34,8 @@ module QmdHook
|
|
|
28
34
|
enola_present = !!enola_detector.call
|
|
29
35
|
return nil unless qmd_present || serena_present || enola_present
|
|
30
36
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# absent). The mandate itself is always-on for whichever tools are present.
|
|
35
|
-
search_ok = qmd_present && p.length >= MIN_PROMPT_LENGTH && p.downcase != "continue"
|
|
36
|
-
|
|
37
|
-
parts = []
|
|
38
|
-
if search_ok
|
|
39
|
-
collections = QmdSync.collections_for_cwd(cwd, plastic_home: plastic_home)
|
|
40
|
-
hits = QmdSync.search(p, collections: collections, limit: limit,
|
|
41
|
-
min_score: min_score, runner: runner, detector: detector)
|
|
42
|
-
if hits.any?
|
|
43
|
-
parts << "Related / prior Plastic intents (qmd BM25, includes completed) - " \
|
|
44
|
-
"check before treating this as new work:"
|
|
45
|
-
hits.each do |h|
|
|
46
|
-
loc = h[:file].to_s.sub(%r{\Aqmd://}, "")
|
|
47
|
-
pct = (h[:score] * 100).round
|
|
48
|
-
parts << "- [#{pct}%] #{loc} - #{h[:title]}"
|
|
49
|
-
end
|
|
50
|
-
parts << ""
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
mandate = PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
|
|
55
|
-
serena_detector: -> { serena_present },
|
|
56
|
-
enola_detector: -> { enola_present })
|
|
57
|
-
parts << mandate if mandate
|
|
58
|
-
return nil if parts.empty?
|
|
59
|
-
parts.join("\n")
|
|
37
|
+
PowerTools.mandate(cwd: cwd, qmd_detector: -> { qmd_present },
|
|
38
|
+
serena_detector: -> { serena_present },
|
|
39
|
+
enola_detector: -> { enola_present })
|
|
60
40
|
end
|
|
61
41
|
end
|
package/scripts/lib/qmd_sync.rb
CHANGED
|
@@ -134,21 +134,6 @@ module QmdSync
|
|
|
134
134
|
pid
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
-
# True when the QMD index has no pending (unembedded) documents. Binary
|
|
138
|
-
# freshness signal for the retrieval gate (intent 84, Lever 2). `qmd status` is
|
|
139
|
-
# plain text (no --json); it prints a line like "Pending: N need embedding".
|
|
140
|
-
# No pending line found -> treat as fresh (conservative: a parse miss must not
|
|
141
|
-
# block reads). Runner failure -> false (cannot confirm freshness). The caller
|
|
142
|
-
# gates on `detect` first, so absence is handled upstream; this only answers
|
|
143
|
-
# "is the present index fresh?". Pure via the injected runner.
|
|
144
|
-
def self.fresh?(runner: default_runner)
|
|
145
|
-
out, ok = runner.call(["status"])
|
|
146
|
-
return false unless ok && out
|
|
147
|
-
m = out[/^\s*Pending:\s*(\d+)\b/i, 1]
|
|
148
|
-
pending = m ? m.to_i : 0
|
|
149
|
-
pending.zero?
|
|
150
|
-
end
|
|
151
|
-
|
|
152
137
|
# Read-only status used by doctor and the session-start report line.
|
|
153
138
|
# Returns a structured hash; never mutates the index.
|
|
154
139
|
def status(plastic_home:, runner: default_runner, detector: method(:detect))
|
|
@@ -15,7 +15,7 @@ module RevisionsWriter
|
|
|
15
15
|
module_function
|
|
16
16
|
|
|
17
17
|
# PURE. Renders one `## Revision vN - TIMESTAMP` entry in the documented shape
|
|
18
|
-
# (
|
|
18
|
+
# (plastic-conventions > references/maintenance-and-revisions.md; templates/revisions.md).
|
|
19
19
|
# `n` is the next revision number (caller resolves it via next_revision_number, or a caller
|
|
20
20
|
# that already knows it, e.g. a batch writer amortizing one file read across many entries).
|
|
21
21
|
# `why` is the one-sentence reason ending in "[rule: <tag>]" (tag is appended here if the
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Which ruby would a spawned Plastic hook actually get, and what version is it?
|
|
5
|
+
# Intent 235, D6. Pure and dependency injected: the capture seam is a lambda, so
|
|
6
|
+
# tests never spawn a process and never touch ENV.
|
|
7
|
+
#
|
|
8
|
+
# A hook is launched by the agent application, not by a login shell, so a version
|
|
9
|
+
# manager that activates on shell prompt render (mise, rbenv, asdf) may never reach
|
|
10
|
+
# it and bare `ruby` can still resolve to the system interpreter. Doctor reports
|
|
11
|
+
# that. It never repairs it.
|
|
12
|
+
#
|
|
13
|
+
# Mechanism, and why it is honest:
|
|
14
|
+
# - The command word is the bare name "ruby", so the operating system resolves it
|
|
15
|
+
# on the inherited PATH exactly the way it does for a spawned bash launcher. We
|
|
16
|
+
# do not read PATH ourselves and we do not reimplement the search.
|
|
17
|
+
# - The resolved interpreter answers for itself: RUBY_VERSION is its own version,
|
|
18
|
+
# RbConfig.ruby is its own absolute path. One spawn, no guessing.
|
|
19
|
+
# - RUBYOPT is cleared, matching what every Plastic launcher now does. That makes
|
|
20
|
+
# this the honest simulation of the post-fix world, and it stops the probe from
|
|
21
|
+
# crashing on the exact machine that most needs the report (an old ruby plus a
|
|
22
|
+
# shell that exports RUBYOPT=--yjit).
|
|
23
|
+
module RubyProbe
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# A hash passed as the first argument MERGES onto the inherited environment. It
|
|
27
|
+
# clears nothing unless the key is present with a nil or empty value, so the nil
|
|
28
|
+
# here is load bearing.
|
|
29
|
+
CLEARED_ENV = { "RUBYOPT" => nil }.freeze
|
|
30
|
+
|
|
31
|
+
PROBE_ARGS = ["-rrbconfig", "-e", "puts RUBY_VERSION; puts RbConfig.ruby"].freeze
|
|
32
|
+
|
|
33
|
+
def default_capture
|
|
34
|
+
lambda do |env, command, *args|
|
|
35
|
+
require "open3"
|
|
36
|
+
out, _err, status = Open3.capture3(env, command, *args)
|
|
37
|
+
[out, status.success?]
|
|
38
|
+
rescue Errno::ENOENT
|
|
39
|
+
["", false] # no ruby on PATH: undetectable, fail open
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# => { found: true, version: "3.3.5", path: "/opt/ruby/bin/ruby" }
|
|
44
|
+
# => { found: false, version: nil, path: nil } on any trouble at all.
|
|
45
|
+
def resolve(capture: default_capture)
|
|
46
|
+
out, ok = capture.call(CLEARED_ENV, "ruby", *PROBE_ARGS)
|
|
47
|
+
return not_found unless ok
|
|
48
|
+
|
|
49
|
+
version, path = out.to_s.lines.map(&:strip).reject(&:empty?)
|
|
50
|
+
return not_found if version.nil? || version.empty?
|
|
51
|
+
|
|
52
|
+
{ found: true, version: version, path: path }
|
|
53
|
+
rescue StandardError
|
|
54
|
+
not_found
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def not_found
|
|
58
|
+
{ found: false, version: nil, path: nil }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "worktree"
|
|
6
|
+
require_relative "bridge"
|
|
7
|
+
require_relative "spec_header"
|
|
8
|
+
require_relative "intent_validator"
|
|
9
|
+
|
|
10
|
+
# ScaffoldIntent - all logic for `scripts/scaffold-intent` (intent 213). Three
|
|
11
|
+
# subcommands (spec|checklist|outcome), each writes one lifecycle artifact by copying or
|
|
12
|
+
# mechanically deriving it from an already-committed source: the intent file's
|
|
13
|
+
# `### Decisions` list, spec.md's `## Acceptance Criteria` list, or a `git diff --stat`
|
|
14
|
+
# plus an optional supplied test-summary file. No subcommand interprets or invents prose;
|
|
15
|
+
# a field this module cannot derive mechanically is left as the template's own stub text
|
|
16
|
+
# instead of being guessed at.
|
|
17
|
+
#
|
|
18
|
+
# `scaffold-intent` does not scaffold `actions/` in any form (intent 133a, D11): actions
|
|
19
|
+
# require judgment and stay entirely with the planner. No method here creates the
|
|
20
|
+
# directory, writes a `.gitkeep`, or writes a sentinel-only `ACTION_*.md`.
|
|
21
|
+
#
|
|
22
|
+
# Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or
|
|
23
|
+
# `ENV` directly (only via the ambient `Dir.home` default, matching Bridge/Worktree
|
|
24
|
+
# convention). A git seam is injected as `runner:`, defaulting to
|
|
25
|
+
# `Worktree::ShellRunner.new`, so tests drive this in process with a fake runner and
|
|
26
|
+
# never touch real git. Every method returns a value; `scripts/scaffold-intent` maps the
|
|
27
|
+
# returned result to an exit code.
|
|
28
|
+
module ScaffoldIntent
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
SPEC_SECTIONS = [
|
|
32
|
+
"## Problem", "## Goals", "## Non-Goals", "## Approach",
|
|
33
|
+
"## Alternatives Considered", "## Decisions",
|
|
34
|
+
"## Acceptance Criteria", "## Open Questions",
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
SETTLED_PLACEHOLDER_COMMENT =
|
|
38
|
+
"<!-- Settled: yes (<reason>) optional, add this line only when the design is settled -->\n"
|
|
39
|
+
|
|
40
|
+
# --- path resolution (pure) --------------------------------------------------
|
|
41
|
+
|
|
42
|
+
def expand(path)
|
|
43
|
+
File.expand_path(path.to_s.sub(/\A~/, Dir.home))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolve the single "<store>/<id>--*" directory. Returns [dir, nil] on success, or
|
|
47
|
+
# [nil, message] on a usage failure (no match, or more than one match).
|
|
48
|
+
def resolve_intent_dir(store, id)
|
|
49
|
+
matches = Dir.glob(File.join(store, "#{id}--*")).select { |d| File.directory?(d) }
|
|
50
|
+
return [nil, "no intent directory matches #{id}--* under #{store}"] if matches.empty?
|
|
51
|
+
if matches.length > 1
|
|
52
|
+
return [nil, "ambiguous id #{id.inspect}: #{matches.length} matching directories under #{store}"]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
[matches.first, nil]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The templates dir, resolved the same way from this file's own directory
|
|
59
|
+
# (scripts/lib) as `scripts/scaffold-intent` resolves it from its own directory
|
|
60
|
+
# (scripts/): two levels up. A repo checkout has templates/ at the repo root; an
|
|
61
|
+
# installed copy has it at <plastic_home>/templates, which is also two levels up
|
|
62
|
+
# from <plastic_home>/scripts/lib. The explicit `<plastic_home>/templates` fallback
|
|
63
|
+
# covers the case where that computed path does not exist. Returns nil when neither
|
|
64
|
+
# exists.
|
|
65
|
+
def resolve_templates_dir(home: Dir.home)
|
|
66
|
+
primary = File.expand_path("../../templates", __dir__)
|
|
67
|
+
return primary if Dir.exist?(primary)
|
|
68
|
+
|
|
69
|
+
fallback = File.expand_path(File.join(home, ".plastic", "templates"))
|
|
70
|
+
return fallback if Dir.exist?(fallback)
|
|
71
|
+
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def templates_missing_message(home: Dir.home)
|
|
76
|
+
primary = File.expand_path("../../templates", __dir__)
|
|
77
|
+
fallback = File.expand_path(File.join(home, ".plastic", "templates"))
|
|
78
|
+
"no templates directory found; tried #{primary} and #{fallback}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# --- result builders (pure) ---------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def ok_result(path)
|
|
84
|
+
{ status: :ok, code: 0, message: nil, path: path }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def refuse_result(path)
|
|
88
|
+
{ status: :refused, code: 2, path: path,
|
|
89
|
+
message: "#{path} already has real content; pass --force to overwrite it deliberately" }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def error_result(message)
|
|
93
|
+
{ status: :error, code: 3, message: message, path: nil }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# True iff `target` exists, carries real (non-sentinel) content, and `force` was not
|
|
97
|
+
# passed: the caller must refuse to write and leave the file untouched.
|
|
98
|
+
def refuse_without_force?(target, force)
|
|
99
|
+
File.exist?(target) && Bridge.stage_file_present?(target) && !force
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# --- generic section helpers (pure) --------------------------------------------
|
|
103
|
+
|
|
104
|
+
# Split `text` into { "## Heading" => body_text } by top-level `## ` headings (never
|
|
105
|
+
# `### `). Each body runs from the line after its heading to the line before the next
|
|
106
|
+
# `## ` heading (or EOF), copied verbatim including any trailing blank line.
|
|
107
|
+
def sections_from(text)
|
|
108
|
+
sections = {}
|
|
109
|
+
current = nil
|
|
110
|
+
buf = []
|
|
111
|
+
text.each_line do |line|
|
|
112
|
+
if line.start_with?("## ")
|
|
113
|
+
sections[current] = buf.join if current
|
|
114
|
+
current = line.rstrip
|
|
115
|
+
buf = []
|
|
116
|
+
elsif current
|
|
117
|
+
buf << line
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
sections[current] = buf.join if current
|
|
121
|
+
sections
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Replace the body of `heading` in `text` with `new_body_lines` (an Array of String
|
|
125
|
+
# fragments), leaving every other line untouched. A no-op (returns `text` unchanged)
|
|
126
|
+
# when `heading` is not found.
|
|
127
|
+
def replace_section_body(text, heading, new_body_lines)
|
|
128
|
+
lines = text.lines
|
|
129
|
+
idx = lines.index { |l| l.rstrip == heading }
|
|
130
|
+
return text if idx.nil?
|
|
131
|
+
|
|
132
|
+
stop = idx + 1
|
|
133
|
+
stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
|
|
134
|
+
(lines[0..idx] + new_body_lines + lines[stop..]).join
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# --- `### Decisions` extraction from the intent file (pure) --------------------
|
|
138
|
+
|
|
139
|
+
# Byte-for-byte body of the first `### Decisions` heading in `intent_file_content`.
|
|
140
|
+
# Returns [body, nil] on success, or [nil, message] when the heading is absent or its
|
|
141
|
+
# body has no non-blank line.
|
|
142
|
+
def extract_decisions(intent_file_content)
|
|
143
|
+
lines = intent_file_content.lines
|
|
144
|
+
idx = lines.index { |l| l.rstrip == "### Decisions" }
|
|
145
|
+
return [nil, "the intent file has no ### Decisions list to copy; the Why stage is not finished"] if idx.nil?
|
|
146
|
+
|
|
147
|
+
stop = idx + 1
|
|
148
|
+
stop += 1 while stop < lines.length && !(lines[stop].start_with?("## ") || lines[stop].start_with?("### "))
|
|
149
|
+
body_lines = lines[(idx + 1)...stop]
|
|
150
|
+
body_lines = strip_blank_edges(body_lines)
|
|
151
|
+
|
|
152
|
+
if body_lines.empty?
|
|
153
|
+
return [nil, "the intent file's ### Decisions list has no content to copy; the Why stage is not finished"]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
[body_lines.join, nil]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# --- `## Acceptance Criteria` extraction from spec.md (pure) --------------------
|
|
160
|
+
|
|
161
|
+
# Byte-for-byte body of `## Acceptance Criteria` in `spec_content`. Returns
|
|
162
|
+
# [body, nil] on success, or [nil, message] when the heading is absent or its body
|
|
163
|
+
# holds no `- [ ]` line.
|
|
164
|
+
def extract_acceptance_criteria(spec_content)
|
|
165
|
+
lines = spec_content.lines
|
|
166
|
+
idx = lines.index { |l| l.rstrip == "## Acceptance Criteria" }
|
|
167
|
+
return [nil, "spec.md has no ## Acceptance Criteria section to copy"] if idx.nil?
|
|
168
|
+
|
|
169
|
+
stop = idx + 1
|
|
170
|
+
stop += 1 while stop < lines.length && !lines[stop].start_with?("## ")
|
|
171
|
+
body_lines = strip_blank_edges(lines[(idx + 1)...stop])
|
|
172
|
+
|
|
173
|
+
unless body_lines.any? { |l| l =~ /^\s*- \[ \]/ }
|
|
174
|
+
return [nil, "spec.md's ## Acceptance Criteria has no checklist items (no line matching '- [ ]')"]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
[body_lines.join, nil]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def strip_blank_edges(lines)
|
|
181
|
+
lines = lines.drop_while { |l| l.strip.empty? }
|
|
182
|
+
lines.reverse.drop_while { |l| l.strip.empty? }.reverse
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# --- spec subcommand ------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def scaffold_spec(intent_dir:, force:, templates_dir: nil, home: Dir.home)
|
|
188
|
+
target = File.join(intent_dir, "spec.md")
|
|
189
|
+
return refuse_result(target) if refuse_without_force?(target, force)
|
|
190
|
+
|
|
191
|
+
intent_file = Bridge.intent_file(intent_dir)
|
|
192
|
+
return error_result("the intent file is missing at #{intent_file}") unless File.exist?(intent_file)
|
|
193
|
+
|
|
194
|
+
intent_content = File.read(intent_file)
|
|
195
|
+
fm = IntentValidator.parse_frontmatter_text(intent_content)
|
|
196
|
+
intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
|
|
197
|
+
if Bridge.blank?(intent_name)
|
|
198
|
+
return error_result("the intent file at #{intent_file} has no frontmatter intent name")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
decisions_body, decisions_err = extract_decisions(intent_content)
|
|
202
|
+
return error_result(decisions_err) if decisions_body.nil?
|
|
203
|
+
|
|
204
|
+
tdir = templates_dir || resolve_templates_dir(home: home)
|
|
205
|
+
return error_result(templates_missing_message(home: home)) if tdir.nil?
|
|
206
|
+
|
|
207
|
+
spec_template_path = File.join(tdir, "spec.md")
|
|
208
|
+
return error_result("spec.md template not found at #{spec_template_path}") unless File.exist?(spec_template_path)
|
|
209
|
+
|
|
210
|
+
written = build_spec_content(intent_name: intent_name, decisions_body: decisions_body,
|
|
211
|
+
template_text: File.read(spec_template_path))
|
|
212
|
+
|
|
213
|
+
FileUtils.mkdir_p(intent_dir)
|
|
214
|
+
File.write(target, written)
|
|
215
|
+
ok_result(target)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def build_spec_content(intent_name:, decisions_body:, template_text:)
|
|
219
|
+
template_sections = sections_from(template_text)
|
|
220
|
+
|
|
221
|
+
out = []
|
|
222
|
+
out << SpecHeader.render(tier: nil, settled_reason: nil)
|
|
223
|
+
out << SETTLED_PLACEHOLDER_COMMENT
|
|
224
|
+
out << "\n"
|
|
225
|
+
out << "# Spec: #{intent_name}\n"
|
|
226
|
+
out << "\n"
|
|
227
|
+
|
|
228
|
+
SPEC_SECTIONS.each do |heading|
|
|
229
|
+
out << "#{heading}\n"
|
|
230
|
+
if heading == "## Decisions"
|
|
231
|
+
out << decisions_body
|
|
232
|
+
out << "\n"
|
|
233
|
+
else
|
|
234
|
+
out << (template_sections[heading] || "")
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
out.join
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# --- checklist subcommand --------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def scaffold_checklist(intent_dir:, force:, templates_dir: nil, home: Dir.home)
|
|
244
|
+
target = File.join(intent_dir, "checklist.md")
|
|
245
|
+
return refuse_result(target) if refuse_without_force?(target, force)
|
|
246
|
+
|
|
247
|
+
spec_path = File.join(intent_dir, "spec.md")
|
|
248
|
+
unless File.exist?(spec_path) && Bridge.stage_file_present?(spec_path)
|
|
249
|
+
return error_result("spec.md is missing or still the scaffold placeholder at #{spec_path}")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
ac_body, ac_err = extract_acceptance_criteria(File.read(spec_path))
|
|
253
|
+
return error_result(ac_err) if ac_body.nil?
|
|
254
|
+
|
|
255
|
+
intent_file = Bridge.intent_file(intent_dir)
|
|
256
|
+
fm = IntentValidator.parse_frontmatter(intent_file)
|
|
257
|
+
intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
|
|
258
|
+
if Bridge.blank?(intent_name)
|
|
259
|
+
return error_result("the intent file at #{intent_file} has no frontmatter intent name")
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
tdir = templates_dir || resolve_templates_dir(home: home)
|
|
263
|
+
return error_result(templates_missing_message(home: home)) if tdir.nil?
|
|
264
|
+
|
|
265
|
+
checklist_template_path = File.join(tdir, "checklist.md")
|
|
266
|
+
unless File.exist?(checklist_template_path)
|
|
267
|
+
return error_result("checklist.md template not found at #{checklist_template_path}")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
template_text = File.read(checklist_template_path).sub("{{INTENT_NAME}}", intent_name)
|
|
271
|
+
written = replace_section_body(template_text, "## In Progress", [ac_body, "\n"])
|
|
272
|
+
|
|
273
|
+
FileUtils.mkdir_p(intent_dir)
|
|
274
|
+
File.write(target, written)
|
|
275
|
+
ok_result(target)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# --- repo / base-branch resolution (shared with ACTION_3) -----------------------
|
|
279
|
+
|
|
280
|
+
# The provisioned code worktree for this intent when it exists on disk, else the git
|
|
281
|
+
# toplevel of the current working directory. Returns nil when neither resolves.
|
|
282
|
+
def resolve_repo_dir(store:, id:, intent_dir:, home: Dir.home, runner: Worktree::ShellRunner.new)
|
|
283
|
+
wt_home = Worktree.home_from_store(store) || home
|
|
284
|
+
slug = Worktree.slug_for_store(store, home: wt_home)
|
|
285
|
+
intent_slug = File.basename(intent_dir).split("--", 2).last
|
|
286
|
+
paths = Worktree.paths(slug: slug, intent_id: id, intent_slug: intent_slug, home: wt_home)
|
|
287
|
+
code = paths["code"]
|
|
288
|
+
return code if code && Dir.exist?(code)
|
|
289
|
+
|
|
290
|
+
res = runner.run("-C", Dir.pwd, "rev-parse", "--show-toplevel")
|
|
291
|
+
return nil unless res.success?
|
|
292
|
+
|
|
293
|
+
top = res.stdout.to_s.strip
|
|
294
|
+
top.empty? ? nil : top
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Standard git base-branch detection, first success wins: origin/HEAD, then `main`,
|
|
298
|
+
# then `master`. Returns nil when none resolve.
|
|
299
|
+
def detect_base_branch(repo, runner: Worktree::ShellRunner.new)
|
|
300
|
+
res = runner.run("-C", repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
|
|
301
|
+
if res.success?
|
|
302
|
+
ref = res.stdout.to_s.strip
|
|
303
|
+
return ref.sub(%r{\Aorigin/}, "") unless ref.empty?
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
return "main" if runner.run("-C", repo, "rev-parse", "--verify", "--quiet", "main").success?
|
|
307
|
+
return "master" if runner.run("-C", repo, "rev-parse", "--verify", "--quiet", "master").success?
|
|
308
|
+
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# [stdout, nil] on success, [nil, stderr] on failure. Three-dot range so the diff is
|
|
313
|
+
# against the merge base, not the tip of the base branch.
|
|
314
|
+
def diffstat(repo, base, runner: Worktree::ShellRunner.new)
|
|
315
|
+
res = runner.run("-C", repo, "diff", "--stat", "#{base}...HEAD")
|
|
316
|
+
return [nil, res.stderr.to_s.strip] unless res.success?
|
|
317
|
+
|
|
318
|
+
[res.stdout.to_s, nil]
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# --- outcome subcommand -----------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def scaffold_outcome(intent_dir:, force:, store:, id:, test_summary: nil,
|
|
324
|
+
home: Dir.home, runner: Worktree::ShellRunner.new, templates_dir: nil)
|
|
325
|
+
target = File.join(intent_dir, "outcome.md")
|
|
326
|
+
return refuse_result(target) if refuse_without_force?(target, force)
|
|
327
|
+
|
|
328
|
+
intent_file = Bridge.intent_file(intent_dir)
|
|
329
|
+
return error_result("the intent file is missing at #{intent_file}") unless File.exist?(intent_file)
|
|
330
|
+
|
|
331
|
+
fm = IntentValidator.parse_frontmatter(intent_file)
|
|
332
|
+
intent_name = fm.is_a?(Hash) ? fm["intent"] : nil
|
|
333
|
+
if Bridge.blank?(intent_name)
|
|
334
|
+
return error_result("the intent file at #{intent_file} has no frontmatter intent name")
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
tdir = templates_dir || resolve_templates_dir(home: home)
|
|
338
|
+
return error_result(templates_missing_message(home: home)) if tdir.nil?
|
|
339
|
+
|
|
340
|
+
outcome_template_path = File.join(tdir, "outcome.md")
|
|
341
|
+
unless File.exist?(outcome_template_path)
|
|
342
|
+
return error_result("outcome.md template not found at #{outcome_template_path}")
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
verification_body = build_verification_body(store: store, id: id, intent_dir: intent_dir,
|
|
346
|
+
home: home, runner: runner, test_summary: test_summary)
|
|
347
|
+
|
|
348
|
+
content = File.read(outcome_template_path).sub("# Outcome: <intent name>", "# Outcome: #{intent_name}")
|
|
349
|
+
written = replace_section_body(content, "## Verification", [verification_body, "\n"])
|
|
350
|
+
|
|
351
|
+
FileUtils.mkdir_p(intent_dir)
|
|
352
|
+
File.write(target, written)
|
|
353
|
+
ok_result(target)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def build_verification_body(store:, id:, intent_dir:, home:, runner:, test_summary:)
|
|
357
|
+
repo = resolve_repo_dir(store: store, id: id, intent_dir: intent_dir, home: home, runner: runner)
|
|
358
|
+
|
|
359
|
+
out = []
|
|
360
|
+
if repo.nil?
|
|
361
|
+
out << "Diffstat unavailable: no repo could be resolved for this intent\n"
|
|
362
|
+
else
|
|
363
|
+
base = detect_base_branch(repo, runner: runner)
|
|
364
|
+
if base.nil?
|
|
365
|
+
out << "Diffstat unavailable: no base branch could be detected (no origin/HEAD, main, or master)\n"
|
|
366
|
+
else
|
|
367
|
+
stat, err = diffstat(repo, base, runner: runner)
|
|
368
|
+
if stat.nil?
|
|
369
|
+
out << "Diffstat unavailable: #{err}\n"
|
|
370
|
+
else
|
|
371
|
+
out << "Diffstat against #{base}:\n"
|
|
372
|
+
out << "```\n"
|
|
373
|
+
out << stat
|
|
374
|
+
out << "\n" unless stat.end_with?("\n")
|
|
375
|
+
out << "```\n"
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
unless Bridge.blank?(test_summary)
|
|
381
|
+
out << "\n"
|
|
382
|
+
out << "Test summary from #{test_summary}:\n"
|
|
383
|
+
out << "```\n"
|
|
384
|
+
content = File.read(test_summary)
|
|
385
|
+
out << content
|
|
386
|
+
out << "\n" unless content.end_with?("\n")
|
|
387
|
+
out << "```\n"
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
out.join
|
|
391
|
+
end
|
|
392
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# SpecHeader - the single implementation that parses the `Tier:` and `Settled:` lines out
|
|
5
|
+
# of a spec.md's header block (intent 213). No script, skill, or hook re-implements this
|
|
6
|
+
# grammar anywhere else; Bridge.savepoint_tier delegates here instead of carrying its own
|
|
7
|
+
# copy of the Tier regex.
|
|
8
|
+
#
|
|
9
|
+
# Grammar (both lines live above the `# ` level-1 heading):
|
|
10
|
+
#
|
|
11
|
+
# Tier: L
|
|
12
|
+
# Settled: yes (design fixed by the 2026-07-18 Fable advisor verdict)
|
|
13
|
+
#
|
|
14
|
+
# `Tier:` is exactly one of S, M, L; anything else is unparseable and yields nil. An ABSENT
|
|
15
|
+
# `Settled:` line means not settled, there is no `Settled: no` variant. The parenthesised
|
|
16
|
+
# reason is REQUIRED for a line to count as settled: a bare `Settled: yes` with no reason
|
|
17
|
+
# does NOT parse as settled. D2 calls Settled a ONE-WAY DOOR (leniency accepted now can
|
|
18
|
+
# never be tightened later), so this stays strict from the start rather than being loosened
|
|
19
|
+
# once and then needing a breaking change to fix.
|
|
20
|
+
module SpecHeader
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Mirrors Bridge::PLACEHOLDER_SENTINEL (scripts/lib/bridge.rb:22). Not required in from
|
|
24
|
+
# bridge.rb to avoid a require cycle: bridge.rb requires spec_header.rb, so spec_header.rb
|
|
25
|
+
# must have zero require_relative dependencies of its own.
|
|
26
|
+
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
27
|
+
|
|
28
|
+
HEADER_BLOCK_MAX_LINES = 10
|
|
29
|
+
|
|
30
|
+
TIER_RE = /\ATier:\s*(S|M|L)\z/
|
|
31
|
+
SETTLED_RE = /\ASettled:\s*yes\s*\((.*)\)\z/
|
|
32
|
+
|
|
33
|
+
# PURE. Parse a spec.md's raw text. Returns a Hash with symbol keys, always the same
|
|
34
|
+
# three keys: tier ("S"|"M"|"L"|nil), settled (true|false), settled_reason (String|nil).
|
|
35
|
+
def parse(text)
|
|
36
|
+
result = { tier: nil, settled: false, settled_reason: nil }
|
|
37
|
+
return result if text.nil?
|
|
38
|
+
|
|
39
|
+
lines = text.each_line.first(HEADER_BLOCK_MAX_LINES)
|
|
40
|
+
lines.each do |raw|
|
|
41
|
+
line = raw.chomp.strip
|
|
42
|
+
next if line.empty?
|
|
43
|
+
next if line == PLACEHOLDER_SENTINEL
|
|
44
|
+
break if line.start_with?("# ")
|
|
45
|
+
|
|
46
|
+
if (m = line.match(TIER_RE))
|
|
47
|
+
result[:tier] = m[1]
|
|
48
|
+
elsif (m = line.match(SETTLED_RE))
|
|
49
|
+
result[:settled] = true
|
|
50
|
+
result[:settled_reason] = m[1]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Read at most the header block off disk and parse it. Returns the same Hash shape as
|
|
58
|
+
# `parse`, with every value nil/false when the path does not exist or cannot be read.
|
|
59
|
+
def parse_file(path)
|
|
60
|
+
lines = []
|
|
61
|
+
File.open(path) do |f|
|
|
62
|
+
HEADER_BLOCK_MAX_LINES.times do
|
|
63
|
+
line = f.gets
|
|
64
|
+
break if line.nil?
|
|
65
|
+
lines << line
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
parse(lines.join)
|
|
69
|
+
rescue StandardError
|
|
70
|
+
{ tier: nil, settled: false, settled_reason: nil }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Render the two header lines for a spec.md. `tier` is "S"|"M"|"L" or nil, `settled_reason`
|
|
74
|
+
# is a String or nil. Returns a String ending in one newline. A nil tier renders the
|
|
75
|
+
# placeholder `Tier: S|M|L`; a nil reason renders no Settled line at all (absent means not
|
|
76
|
+
# settled).
|
|
77
|
+
def render(tier: nil, settled_reason: nil)
|
|
78
|
+
lines = []
|
|
79
|
+
lines << "Tier: #{tier || 'S|M|L'}"
|
|
80
|
+
lines << "Settled: yes (#{settled_reason})" if settled_reason
|
|
81
|
+
"#{lines.join("\n")}\n"
|
|
82
|
+
end
|
|
83
|
+
end
|