@zalom/plastic 1.1.1 → 1.1.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.
package/PLASTIC.md CHANGED
@@ -19,7 +19,7 @@ store/
19
19
  plan.md # optional - implementation plan (How deliverable)
20
20
  checklist.md # optional - execution registry (How deliverable)
21
21
  outcome.md # optional - detailed result (Exec deliverable)
22
- actions/ # optional - individual work items
22
+ actions/ # optional - real action files (How deliverable, at least one required)
23
23
  resources/ # optional - research, references, screenshots, diagrams
24
24
  savepoint.md # optional - deterministic cycle-step ledger (auto-written)
25
25
  revisions.md # optional - append-only structural-maintenance audit trail
@@ -95,7 +95,7 @@ tags: [plastic, architecture]
95
95
  |-------|---------|-------------|--------|
96
96
  | **What** | `## Intent` | `{ID}--{slug}.md` | `plastic-intent-creating` |
97
97
  | **Why** | `## Context` + Decisions | `spec.md` | `plastic-intent-brainstorming` |
98
- | **How** | Planning | `plan.md` + `actions/` + `checklist.md` | `plastic-intent-planning` |
98
+ | **How** | Planning | `plan.md` + `actions/ACTION_N.md` (at least one) + `checklist.md` | `plastic-intent-planning` |
99
99
  | **Exec** | Execution | `outcome.md` | `plastic-intent-executing` |
100
100
 
101
101
  `## Insights` is the append-only log of durable discoveries captured throughout ALL stages.
@@ -133,8 +133,9 @@ same-structure invariant holds: same file set, stage order, gates, and savepoint
133
133
  every tier and in both modes.
134
134
 
135
135
  S/M collapse the topology (one thinker agent writes spec.md then plan.md plus
136
- checklist.md in one context; actions/ only for L; a sonnet executor implements). L keeps
137
- the full team.
136
+ checklist.md plus at least one real action file in one context, consolidated into a single
137
+ actions/ACTION_1.md at S/M and one file per task at L; a sonnet executor implements). L
138
+ keeps the full team.
138
139
 
139
140
  Never cut at any tier: the independent reviewer, outcome.md as truth of delivery, the
140
141
  delivery lock, worktree isolation, intent creation via skill, INDEX as status truth, the
@@ -425,8 +426,8 @@ purge. See `docs/internals.md` for depth.
425
426
  | Transition | Trigger | Gate |
426
427
  |---|---|---|
427
428
  | What → Why | `spec.md` written | (none) |
428
- | Why → How | `plan.md` + `actions/` + `checklist.md` | `spec.md` must exist |
429
- | How → Exec | Checklist has items | Plan triplet must exist |
429
+ | Why → How | `plan.md` + `actions/ACTION_N.md` (at least one) + `checklist.md` | `spec.md` must exist |
430
+ | How → Exec | Checklist has items | plan.md, checklist.md, and at least one real actions/ACTION_N.md must exist |
430
431
  | Exec → Done | `outcome.md` written | All checklist items checked |
431
432
 
432
433
  Hard blocking: hooks exit code 2 on gate failure.
@@ -530,7 +531,7 @@ each station.
530
531
  | Start (board) | none (a procedure, not a stage) | `plastic-lock fix` self-heals stale, corrupt, or legacy state; arm acquires `delivery.lock` (O_EXCL, session-keyed), provisions the code worktree, writes the bridge cache | lock-gate denies any write into an active intent dir without this intent's lock; every deny names the resolving command | savepoint confirms the boarding station |
531
532
  | What (create) | `<id>--<slug>.md`, born complete | no lock yet; no bridge | create-gate validates the proposed intent content (Write, Edit, and MCP edits) | savepoint `What` line; intent listed in INDEX `## Active` |
532
533
  | Why | `spec.md` | owner writes refresh the lease (lock file mtime heartbeat) | gate-check requires the intent file with `## Intent` before spec.md; lock-gate admits only the owner or a delegate | savepoint `Why started`, `Why spec.md created` |
533
- | How | `plan.md`, `actions/`, `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md plus checklist.md exist | gate-check requires spec.md before plan.md, and plan.md plus actions/ before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
534
+ | How | `plan.md`, `actions/ACTION_N.md` (at least one), `checklist.md` | heartbeat on writes; the code gate stays closed until plan.md, checklist.md, and a real action file all exist | gate-check requires spec.md before plan.md, and plan.md plus a real actions/ACTION_N.md before checklist.md | savepoint `How started`, `How plan.md created`, `How checklist.md created`, `Exec started` |
534
535
  | Exec | code on the intent branch, checklist checked off | heartbeat; code edits confined to the provisioned worktree; delegates write under the owner's lock; bash, interpreter, and MCP writes gated the same way | code-gate, worktree-gate, bash-gate, lock-gate | checklist boxes; savepoint milestones |
535
536
  | End (done) | mandatory `outcome.md` (`disposition: delivered\|abandoned`), INDEX moves to Completed or Abandoned | ordered End tail: verify, merge and remove worktrees, disarm clears `delivery.lock`, then the bridge is purge-eligible, and the QMD reindex runs LAST (after purge) | gate-check blocks outcome.md while checklist items are unchecked | savepoint `Done delivered` (or `abandoned`); takeover audits, if any, remain in savepoint.md |
536
537
  | Maintenance (any stage) | `revisions.md` move-and-record entries | future `maintenance.lock` (short TTL), mutually exclusive with `delivery.lock` in either direction; 108 ships the schema seam only, the implementation follows intent 93 in a chained intent | acquisition refuses while the other lock type is fresh; a terminal intent with no lock held is read-only | dated, rule-tagged `revisions.md` entry; savepoint untouched |
@@ -31,8 +31,9 @@ is honored as written.
31
31
  4. **Sequence the team** — dispatch specialists per the chosen topology with a constructed
32
32
  context bundle:
33
33
  - S/M: ONE thinker agent, one boot, two stations — it writes spec.md, then plan.md +
34
- checklist.md, in a single context. Sections may be one line each; plan.md carries the
35
- checklist rationale inline; `actions/` files appear only for L. S may skip the QMD
34
+ checklist.md + at least one real action file, in a single context. Sections may be one
35
+ line each; the thinker writes one consolidated `actions/ACTION_1.md` (never an empty
36
+ `actions/`), while L writes one `actions/ACTION_N.md` per task. S may skip the QMD
36
37
  discovery deposit when chain and sources are both empty. A sonnet executor implements.
37
38
  - L: today's full team, one specialist per stage (brainstorming, spec-specialist, planner,
38
39
  executor), each in a fresh context.
@@ -19,11 +19,11 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
19
19
 
20
20
  ## How You Work
21
21
 
22
- 1. Receive (input handoff): `plan.md` and `checklist.md` from the planner (plus
23
- `ACTION_N.md` files inside `actions/` at L; at S/M the `actions/` directory exists but
24
- stays empty, so read the tier fork from the inline plan-as-checklist in plan.md
25
- instead). For S/M intents you run on the sonnet default, the collapsed topology's
26
- implementer; behavior is otherwise unchanged.
22
+ 1. Receive (input handoff): `plan.md`, `checklist.md`, and at least one real `ACTION_N.md`
23
+ inside `actions/` from the planner (S/M hand you one consolidated `actions/ACTION_1.md`;
24
+ L hands you one `actions/ACTION_N.md` per task). Execute the action files in order. For
25
+ S/M intents you run on the sonnet default, the collapsed topology's implementer; behavior
26
+ is otherwise unchanged.
27
27
  2. Work one action at a time, preferring safe, non-destructive routes
28
28
  3. Edit project code (the gate is open now that plan and checklist exist)
29
29
  4. Run the full suite, iterate to zero failures and zero errors
@@ -14,14 +14,17 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
14
14
 
15
15
  1. **Decompose the spec** — break the approach into ordered, independent actions
16
16
  2. **Write the plan** — produce `plan.md` with numbered tasks and verification
17
- 3. **Write self-contained actions, tier-forked**:
18
- - S/M: `plan.md` carries the checklist rationale INLINE; do NOT write
19
- `actions/ACTION_N.md` files. Sections may be one line each.
20
- - L: keep today's shape one self-contained `actions/ACTION_N.md` per task.
17
+ 3. **Write at least one real action file at every tier, tier-forked**:
18
+ - S/M: write ONE self-contained `actions/ACTION_1.md` that consolidates the whole
19
+ ordered delivery (the steps plus the exact changes). You MAY split into a few files
20
+ when that reads cleaner, but one real action file is the floor.
21
+ - L: one self-contained `actions/ACTION_N.md` per task. A `.gitkeep` never counts as an
22
+ action, and an empty `actions/` fails the How gate.
21
23
  4. **Write the checklist** — `checklist.md` as the execution registry covering every action.
22
- `checklist.md` exists at every tier: the file set does not change by tier, only depth
23
- (inline vs action files) and agent topology do. plan.md + checklist.md are the two files
24
- that open the code gate at every tier.
24
+ `checklist.md` and at least one real action file exist at every tier: the file set does not
25
+ change by tier, only action DEPTH (one consolidated action for S/M vs one-per-task for L)
26
+ and agent topology do. plan.md, checklist.md, AND a real `actions/ACTION_N.md` are what open
27
+ the code gate at every tier.
25
28
 
26
29
  ## How You Work
27
30
 
@@ -38,7 +41,7 @@ END your turn with a structured completion report as your final message, per the
38
41
  - The ordered actions, one line each: what the action does and how it is verified
39
42
  - Decomposition rationale: why this order, and why the actions are independent
40
43
  - Checklist coverage: the item count, and that every action plus suite-green is covered
41
- - Which tier shape was produced: inline plan-as-checklist (S/M) or full `actions/` (L)
44
+ - Which tier shape was produced: one consolidated `actions/ACTION_1.md` (S/M) or one `actions/ACTION_N.md` per task (L)
42
45
 
43
46
  The plan is an argument; the orchestrator gates on whether that argument is sound before any code is written, so make the report make that case.
44
47
 
@@ -46,7 +49,8 @@ The plan is an argument; the orchestrator gates on whether that argument is soun
46
49
 
47
50
  - You are dispatched by the plastic-enforcer and your deliverable is gated before Exec begins
48
51
  - You write only intent-store files (`plan.md`, `actions/`, `checklist.md`); no project code
49
- - The code gate stays closed until `plan.md` and `checklist.md` exist, so produce both
52
+ - The code gate stays closed until `plan.md`, `checklist.md`, and at least one real `actions/ACTION_N.md` exist, so produce all three
50
53
  - Keep each action self-contained so the executor can run them one at a time
51
- - The `actions/` empty-directory git-tracking edge (present only within one same-session
52
- local delivery) is out of scope; do not add a `.gitkeep` or a guard for it here
54
+ - Write real `actions/ACTION_N.md` files, never a `.gitkeep`: keeping a freshly-scaffolded
55
+ empty `actions/` under git is `scripts/new-intent`'s job at intent birth, not the planner's,
56
+ and a `.gitkeep` never counts as an action
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -25,6 +25,7 @@ require_relative "lib/lock"
25
25
  require_relative "lib/bridge"
26
26
  require_relative "lib/agent_models"
27
27
  require_relative "lib/legacy_bookend_amnesty"
28
+ require_relative "lib/skill_lint"
28
29
 
29
30
  # Diagnostic engine, instantiable with an injected store/agent map so tests can
30
31
  # run it hermetically (no eval, no global-constant rewriting).
@@ -1805,6 +1806,48 @@ class Doctor
1805
1806
  )
1806
1807
  end
1807
1808
 
1809
+ # --- Check category: skill-lint (advisory only; intent 85b) ---
1810
+ #
1811
+ # Reports SkillLint's five structural checks over the skills/ tree as a
1812
+ # single ADVISORY finding. Always status: "pass", so it can never contribute
1813
+ # to doctor's warn/fail exit code (summarize buckets purely by status; the
1814
+ # lint verdict belongs to a future ship gate, intent 100, not to doctor).
1815
+ # Resolves the same repo skills/ directory `scripts/skill-lint` defaults to,
1816
+ # relative to this script's own location. Skills install flat and renamed
1817
+ # (plastic-<name>) under each agent's own skills/ dir, a different directory
1818
+ # shape SkillLint's frontmatter name-check does not target, so an installed
1819
+ # ~/.plastic/scripts/doctor.rb with no sibling skills/ tree has nothing in
1820
+ # scope here and reports a silent pass rather than guessing at a mismatched
1821
+ # layout.
1822
+ def check_skill_lint
1823
+ dir = File.expand_path("../skills", __dir__)
1824
+ unless File.directory?(dir)
1825
+ return [check(
1826
+ category: "skill_lint", name: "skill_lint", status: "pass",
1827
+ message: "No skills/ directory found at #{tilde(dir)} to lint (nothing in scope for this advisory check)"
1828
+ )]
1829
+ end
1830
+
1831
+ result = SkillLint.new(skills_dir: dir).run
1832
+
1833
+ if result.ok?
1834
+ [check(
1835
+ category: "skill_lint", name: "skill_lint", status: "pass",
1836
+ message: "skill-lint: clean (0 violations across the skills/ tree)"
1837
+ )]
1838
+ else
1839
+ by_check = result.violations.group_by { |v| v[:check] }.transform_values(&:size)
1840
+ counts = by_check.map { |check_id, n| "#{check_id}: #{n}" }.join(", ")
1841
+ details = result.violations.map { |v| "#{v[:check]} #{v[:skill]} #{v[:file]}:#{v[:line]}: #{v[:message]}" }
1842
+ [check(
1843
+ category: "skill_lint", name: "skill_lint", status: "pass",
1844
+ message: "skill-lint: #{result.violations.size} advisory violation(s) found (#{counts}); " \
1845
+ "informational only, never affects doctor's exit code",
1846
+ details: details
1847
+ )]
1848
+ end
1849
+ end
1850
+
1808
1851
  # --- Run all checks ---
1809
1852
 
1810
1853
  def run_checks(agent_key)
@@ -1817,6 +1860,7 @@ class Doctor
1817
1860
  all_checks += check_deprecations
1818
1861
  all_checks += check_qmd
1819
1862
  all_checks += check_done_signals
1863
+ all_checks += check_skill_lint
1820
1864
 
1821
1865
  summarize(all_checks, agent_key)
1822
1866
  end
@@ -395,10 +395,24 @@ module Bridge
395
395
  File.exist?(path)
396
396
  end
397
397
 
398
+ # True iff actions/ holds AT LEAST ONE real action file: a non-empty *.md whose
399
+ # first line is not the placeholder sentinel. A `.gitkeep` (no .md extension)
400
+ # never counts, an empty *.md never counts, and a sentinel-only *.md never
401
+ # counts. Pure and side-effect-free so the gate stays unit-testable. Fail-open:
402
+ # a missing actions/ dir globs to nothing and returns false (the gate then
403
+ # reports it needs a real action file); it never raises.
404
+ def self.has_real_action?(intent_dir)
405
+ Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
406
+ File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
407
+ end
408
+ rescue StandardError
409
+ false
410
+ end
411
+
398
412
  def self.derive_stage(intent_dir)
399
413
  return "done" if stage_file_present?("#{intent_dir}/outcome.md")
400
414
  if stage_file_present?("#{intent_dir}/plan.md") &&
401
- File.directory?("#{intent_dir}/actions") &&
415
+ has_real_action?(intent_dir) &&
402
416
  stage_file_present?("#{intent_dir}/checklist.md")
403
417
  return "exec"
404
418
  end
@@ -414,7 +428,7 @@ module Bridge
414
428
  ["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
415
429
  files << f if stage_file_present?("#{intent_dir}/#{f}")
416
430
  end
417
- files << "actions/" if File.directory?("#{intent_dir}/actions")
431
+ files << "actions/" if has_real_action?(intent_dir)
418
432
  files
419
433
  end
420
434
 
@@ -779,8 +793,13 @@ module Bridge
779
793
  return "Cannot start How — Why is incomplete (spec.md missing)"
780
794
  end
781
795
  when "checklist.md"
782
- unless stage_file_present?("#{intent_dir}/plan.md") && File.directory?("#{intent_dir}/actions")
783
- return "Cannot complete How — plan.md or actions/ missing"
796
+ unless stage_file_present?("#{intent_dir}/plan.md")
797
+ return "Cannot complete How — plan.md missing"
798
+ end
799
+ unless has_real_action?(intent_dir)
800
+ return "Cannot complete How — actions/ has no real action file (only .gitkeep or empty). " \
801
+ "The planner must write at least one actions/ACTION_N.md before checklist.md. " \
802
+ "See skills/intent-planning."
784
803
  end
785
804
  when "outcome.md"
786
805
  checklist = "#{intent_dir}/checklist.md"
@@ -1017,11 +1036,14 @@ module Bridge
1017
1036
  return nil unless store && dir
1018
1037
  intent_dir_abs = File.expand_path("#{store}/#{dir}")
1019
1038
 
1020
- # "How reached" = the plan triplet exists. Gate by artifact presence, not the
1021
- # stage label (derive_stage returns "how" as soon as spec.md exists, before any
1022
- # plan). Code edits stay blocked until plan.md + checklist.md are both present.
1039
+ # "How reached" = plan.md + checklist.md are both present AND actions/ holds at
1040
+ # least one real action file. Gate by artifact presence, not the stage label
1041
+ # (derive_stage returns "how" as soon as spec.md exists, before any plan). Code
1042
+ # edits stay blocked until the planner has written a real action file, so an
1043
+ # empty or .gitkeep-only actions/ never opens the code gate.
1023
1044
  reached_how = stage_file_present?("#{intent_dir_abs}/plan.md") &&
1024
- stage_file_present?("#{intent_dir_abs}/checklist.md")
1045
+ stage_file_present?("#{intent_dir_abs}/checklist.md") &&
1046
+ has_real_action?(intent_dir_abs)
1025
1047
  return nil if reached_how
1026
1048
 
1027
1049
  file_abs = File.expand_path(file_path.to_s)
@@ -1030,9 +1052,9 @@ module Bridge
1030
1052
  return nil if file_abs == intent_dir_abs || file_abs.start_with?("#{intent_dir_abs}/")
1031
1053
 
1032
1054
  id = intent_info["id"]
1033
- "intent #{id} has not reached How — write plan.md + checklist.md before " \
1034
- "editing project code. Run plastic-auto or plastic-intent-planning first. " \
1035
- "(blocked edit: #{file_abs})"
1055
+ "intent #{id} has not reached How — write plan.md + checklist.md and at least " \
1056
+ "one real actions/ACTION_N.md before editing project code. Run plastic-auto or " \
1057
+ "plastic-intent-planning first. (blocked edit: #{file_abs})"
1036
1058
  end
1037
1059
 
1038
1060
  # --- Solo-mode detection (intent 128) ---------------------------------------
@@ -296,6 +296,8 @@ class InstallerCore
296
296
  "scripts/lib/legacy_bookend_amnesty.rb" => "scripts/lib/legacy_bookend_amnesty.rb",
297
297
  "scripts/doctor.rb" => "scripts/doctor.rb",
298
298
  "scripts/dashboard.rb" => "scripts/dashboard.rb",
299
+ "scripts/skill-lint" => "scripts/skill-lint",
300
+ "scripts/lib/skill_lint.rb" => "scripts/lib/skill_lint.rb",
299
301
  }
300
302
  end
301
303
 
@@ -0,0 +1,304 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+
6
+ # SkillLint: deterministic, dependency-injected engine that runs five
7
+ # structural checks over a directory of Agent Skills (intent 85b).
8
+ #
9
+ # Pure and DI: constructed with an injected `skills_dir`, performs no writes,
10
+ # no `eval`, and reads no ambient config beyond the injected directory. Mirrors
11
+ # the CLI-over-lib shape of `scripts/lib/intent_validator.rb`: the `skill-lint`
12
+ # CLI (ACTION_2) is a thin wrapper, `scripts/doctor.rb` (ACTION_4) consumes the
13
+ # same engine for an advisory finding, and `test/skill_lint_test.rb` (ACTION_3)
14
+ # proves every check red-and-green plus a no-skip-list live-tree guard.
15
+ #
16
+ # Each violation is a Hash:
17
+ # { check:, skill:, file:, line:, rule:, message: }
18
+ # `check` is one of the five check-ids below, `skill` is the skill directory
19
+ # basename, `file` is the offending path, `line` is an Integer where locatable
20
+ # else nil, `rule` cites the standard being enforced, `message` is an
21
+ # actionable fix instruction.
22
+ class SkillLint
23
+ # A binding keyword: a mention's paragraph carries an observable trigger
24
+ # condition when it names one of these (word-boundary, case-insensitive).
25
+ BINDING_KEYWORD_RE = /\b(when|if|before|after|while)\b/i
26
+
27
+ # A `to <verb>` or `for <noun-phrase>` purpose clause.
28
+ PURPOSE_RE = /\b(to|for)\s+\S/i
29
+
30
+ Result = Struct.new(:violations) do
31
+ def ok?
32
+ violations.empty?
33
+ end
34
+
35
+ # Slice violations down to one check-id, e.g. for a doctor finding or a
36
+ # red-proof assertion.
37
+ def violations_for(check_id)
38
+ violations.select { |v| v[:check] == check_id }
39
+ end
40
+ end
41
+
42
+ def initialize(skills_dir:)
43
+ @skills_dir = skills_dir
44
+ end
45
+
46
+ def run
47
+ violations = []
48
+
49
+ skill_md_paths.each do |skill_md|
50
+ skill_dir = File.dirname(skill_md)
51
+ content = File.read(skill_md)
52
+
53
+ violations.concat(check_body_budget(skill_dir, skill_md, content))
54
+ violations.concat(check_frontmatter_validity(skill_dir, skill_md, content))
55
+ violations.concat(check_bare_pointer(skill_dir, skill_md, content))
56
+ violations.concat(check_orphan_files(skill_dir))
57
+ violations.concat(check_references_depth(skill_dir))
58
+ end
59
+
60
+ Result.new(violations)
61
+ end
62
+
63
+ private
64
+
65
+ def skill_md_paths
66
+ Dir.glob(File.join(@skills_dir, "*", "SKILL.md")).sort
67
+ end
68
+
69
+ def skill_name(skill_dir)
70
+ File.basename(skill_dir)
71
+ end
72
+
73
+ def violation(check:, skill:, file:, line:, rule:, message:)
74
+ { check: check, skill: skill, file: file, line: line, rule: rule, message: message }
75
+ end
76
+
77
+ # Frontmatter split uses the shared convention (scripts/lib/intent_validator.rb):
78
+ # `content.split("---", 3)`; parts[1] is the frontmatter text, parts[2] is the
79
+ # body. Also computes the body's starting line number (0-based line count of
80
+ # everything before the body) so callers can report absolute file line
81
+ # numbers, not just body-relative ones.
82
+ def frontmatter_and_body(content)
83
+ parts = content.split("---", 3)
84
+ return { frontmatter: nil, body: content, body_offset_lines: 0 } if parts.length < 3
85
+
86
+ prefix_len = parts[0].length + 3 + parts[1].length + 3
87
+ prefix = content[0...prefix_len]
88
+ { frontmatter: parts[1], body: parts[2], body_offset_lines: prefix.count("\n") }
89
+ end
90
+
91
+ # --- 1. body-budget ---
92
+
93
+ def check_body_budget(skill_dir, skill_md, content)
94
+ violations = []
95
+ body = frontmatter_and_body(content)[:body]
96
+ name = skill_name(skill_dir)
97
+
98
+ line_count = body.lines.count
99
+ if line_count >= 500
100
+ violations << violation(
101
+ check: "body-budget", skill: name, file: skill_md, line: nil,
102
+ rule: "body under 500 lines",
103
+ message: "SKILL.md body is #{line_count} lines; move detail into references/*.md to get under 500"
104
+ )
105
+ end
106
+
107
+ tokens = (body.split(/\s+/).reject(&:empty?).length * 1.3).round
108
+ if tokens >= 5000
109
+ violations << violation(
110
+ check: "body-budget", skill: name, file: skill_md, line: nil,
111
+ rule: "body under about 5000 tokens (word-based estimate)",
112
+ message: "SKILL.md body is about #{tokens} tokens (word-based estimate); move detail into references/*.md to get under 5000"
113
+ )
114
+ end
115
+
116
+ violations
117
+ end
118
+
119
+ # --- 2. frontmatter-validity ---
120
+
121
+ def check_frontmatter_validity(skill_dir, skill_md, content)
122
+ violations = []
123
+ name = skill_name(skill_dir)
124
+ frontmatter_text = frontmatter_and_body(content)[:frontmatter]
125
+
126
+ begin
127
+ fm = YAML.safe_load(frontmatter_text.to_s)
128
+ rescue Psych::SyntaxError
129
+ violations << violation(
130
+ check: "frontmatter-validity", skill: name, file: skill_md, line: nil,
131
+ rule: "frontmatter must survive strict YAML.safe_load",
132
+ message: "SKILL.md frontmatter fails YAML.safe_load; quote scalar values that contain an unquoted \": \" sequence"
133
+ )
134
+ return violations
135
+ end
136
+ fm = {} unless fm.is_a?(Hash)
137
+
138
+ expected_name = "plastic-#{name}"
139
+ if fm["name"] != expected_name
140
+ violations << violation(
141
+ check: "frontmatter-validity", skill: name, file: skill_md, line: nil,
142
+ rule: "name: must equal plastic-<directory>",
143
+ message: "frontmatter name: is #{fm["name"].inspect}; expected #{expected_name.inspect}"
144
+ )
145
+ end
146
+
147
+ user_invocable = fm["user-invocable"]
148
+ unless user_invocable == true || user_invocable == false
149
+ violations << violation(
150
+ check: "frontmatter-validity", skill: name, file: skill_md, line: nil,
151
+ rule: "user-invocable: must be present and boolean",
152
+ message: "frontmatter user-invocable: is #{user_invocable.inspect}; must be present and true or false"
153
+ )
154
+ end
155
+
156
+ violations
157
+ end
158
+
159
+ # --- 3. bare-pointer ---
160
+
161
+ def check_bare_pointer(skill_dir, skill_md, content)
162
+ violations = []
163
+ ref_files = Dir.glob(File.join(skill_dir, "references", "*.md")).sort
164
+ return violations if ref_files.empty?
165
+
166
+ parsed = frontmatter_and_body(content)
167
+ body_lines = parsed[:body].lines
168
+ offset = parsed[:body_offset_lines]
169
+ blocks = paragraph_blocks(body_lines)
170
+ name = skill_name(skill_dir)
171
+
172
+ ref_files.each do |ref_file|
173
+ base = File.basename(ref_file)
174
+ mention_indices = (0...body_lines.length).select { |i| body_lines[i].include?(base) }
175
+ next if mention_indices.empty? # zero mentions is an orphan (check 4), not a bare pointer
176
+
177
+ bound = mention_indices.any? { |i| bound_mention?(body_lines[i], i, blocks, base) }
178
+ next if bound
179
+
180
+ first = mention_indices.first
181
+ violations << violation(
182
+ check: "bare-pointer", skill: name, file: skill_md, line: offset + first + 1,
183
+ rule: "every reference link must bind to an observable trigger condition, never a bare pointer",
184
+ message: "#{base} is only ever a bare pointer; add a when/if/before/after/while clause or a " \
185
+ "to/for purpose so the trigger is observable"
186
+ )
187
+ end
188
+
189
+ violations
190
+ end
191
+
192
+ def bound_mention?(line, index, blocks, base)
193
+ stripped = line.strip
194
+ return table_row_bound?(stripped, base) if stripped.start_with?("|")
195
+
196
+ block = blocks.find { |b| index.between?(b[:start], b[:end]) }
197
+ block_text = block ? block[:text] : line
198
+
199
+ # Narrow to the unit(s) that actually mention the reference, so an
200
+ # unrelated unit elsewhere in the same paragraph cannot launder a
201
+ # genuinely bare pointer through an incidental "to"/"for" (an ordinary
202
+ # preposition, not a binding purpose clause). Split on BOTH sentence
203
+ # boundaries (.!?) AND list-item starts, so a bullet list (which has no
204
+ # terminal punctuation between items) does not collapse into one unit
205
+ # whose bound siblings launder a bare bullet -- the trailing "References"
206
+ # list pattern (most bullets bound, one forgotten) this linter exists to
207
+ # catch. Keep only units that mention `base`. Falls back to the whole
208
+ # block when no unit boundary contains the mention (e.g. a mid-sentence
209
+ # line wrap with no terminal punctuation nearby), so the legitimate
210
+ # multi-line-wrapped case still binds.
211
+ #
212
+ # Known conservative limitation (documented, not closed): a bare mention
213
+ # with NO unit boundary of its own (no leading list marker, no preceding
214
+ # terminal punctuation) immediately followed by an unrelated sentence
215
+ # carrying "to"/"for" can still borrow that neighbor's binding via the
216
+ # empty-fallback path. Closing this structurally risks re-breaking the
217
+ # legitimate wrapped-sentence case (which also relies on the fallback),
218
+ # so it stays open; see intent 85b insights.
219
+ units = block_text.split(/(?<=[.!?])\s+|\n(?=\s*(?:[-*+]|\d+[.)])\s)/)
220
+ mentioning = units.select { |s| s.include?(base) }
221
+ scoped_text = mentioning.empty? ? block_text : mentioning.join(" ")
222
+
223
+ scoped_text.match?(BINDING_KEYWORD_RE) || scoped_text.match?(PURPOSE_RE)
224
+ end
225
+
226
+ # A table row is bound when some OTHER cell (not the one carrying the
227
+ # reference path) has visible text (the trigger-condition column).
228
+ def table_row_bound?(stripped_line, base)
229
+ cells = stripped_line.split("|").map(&:strip).reject(&:empty?)
230
+ other_cells = cells.reject { |c| c.include?(base) }
231
+ !other_cells.empty?
232
+ end
233
+
234
+ # Group body lines into blank-line-delimited paragraph blocks so a mention
235
+ # that trails a wrapped sentence (the binding keyword on the line above) is
236
+ # still evaluated against its full paragraph, not just its own physical line.
237
+ def paragraph_blocks(lines)
238
+ blocks = []
239
+ start = nil
240
+ lines.each_with_index do |line, i|
241
+ if line.strip.empty?
242
+ blocks << { start: start, end: i - 1, text: lines[start..(i - 1)].join } if start
243
+ start = nil
244
+ else
245
+ start ||= i
246
+ end
247
+ end
248
+ blocks << { start: start, end: lines.length - 1, text: lines[start..-1].join } if start
249
+ blocks
250
+ end
251
+
252
+ # --- 4. orphan-files ---
253
+
254
+ def check_orphan_files(skill_dir)
255
+ violations = []
256
+ ref_files = Dir.glob(File.join(skill_dir, "references", "*.md")).sort
257
+ return violations if ref_files.empty?
258
+
259
+ other_files = Dir.glob(File.join(skill_dir, "**", "*")).select { |p| File.file?(p) }
260
+ name = skill_name(skill_dir)
261
+
262
+ ref_files.each do |ref_file|
263
+ base = File.basename(ref_file)
264
+ mentioned = other_files.any? do |other|
265
+ next false if other == ref_file
266
+
267
+ File.read(other).include?(base)
268
+ end
269
+ next if mentioned
270
+
271
+ violations << violation(
272
+ check: "orphan-files", skill: name, file: ref_file, line: nil,
273
+ rule: "every references/ file must be routed from the skill",
274
+ message: "#{base} is never mentioned anywhere in the skill directory; route it from SKILL.md or delete it"
275
+ )
276
+ end
277
+
278
+ violations
279
+ end
280
+
281
+ # --- 5. references-depth ---
282
+
283
+ def check_references_depth(skill_dir)
284
+ violations = []
285
+ refs_root = File.join(skill_dir, "references")
286
+ return violations unless File.directory?(refs_root)
287
+
288
+ name = skill_name(skill_dir)
289
+ Dir.glob(File.join(refs_root, "**", "*")).each do |path|
290
+ next unless File.file?(path)
291
+
292
+ rel = path.sub("#{refs_root}/", "")
293
+ next unless rel.include?("/")
294
+
295
+ violations << violation(
296
+ check: "references-depth", skill: name, file: path, line: nil,
297
+ rule: "references stay one level deep",
298
+ message: "#{rel} is nested under references/; move it to a flat references/*.md file"
299
+ )
300
+ end
301
+
302
+ violations
303
+ end
304
+ end
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # skill-lint: deterministic CLI over SkillLint (intent 85b).
6
+ #
7
+ # Runs the five structural skill checks (body-budget, frontmatter-validity,
8
+ # bare-pointer, orphan-files, references-depth) over a directory of Agent
9
+ # Skills and reports every violation. Mirrors `scripts/validate-intent`'s
10
+ # CLI-over-lib shape and exit-code contract.
11
+ #
12
+ # Usage:
13
+ # skill-lint [--skills-dir <path>]
14
+ #
15
+ # Exit codes: 0 (clean), 1 (violations found; reported on stderr), 2 (usage).
16
+ # --skills-dir defaults to the repo skills/ directory next to this script.
17
+
18
+ require_relative "lib/skill_lint"
19
+
20
+ def resolve_skills_dir(args)
21
+ if (i = args.index("--skills-dir"))
22
+ args[i + 1]
23
+ else
24
+ File.expand_path("../skills", __dir__)
25
+ end
26
+ end
27
+
28
+ if ARGV.any? { |a| a.start_with?("--") && a != "--skills-dir" }
29
+ warn "usage: skill-lint [--skills-dir <path>]"
30
+ exit 2
31
+ end
32
+
33
+ if ARGV.include?("--skills-dir") && ARGV[ARGV.index("--skills-dir") + 1].nil?
34
+ warn "usage: skill-lint [--skills-dir <path>]"
35
+ exit 2
36
+ end
37
+
38
+ dir = File.expand_path(resolve_skills_dir(ARGV))
39
+ result = SkillLint.new(skills_dir: dir).run
40
+
41
+ if result.ok?
42
+ puts "OK: #{dir}"
43
+ exit 0
44
+ end
45
+
46
+ warn "VIOLATIONS: #{dir}"
47
+ result.violations.each do |v|
48
+ warn "#{v[:check]} #{v[:skill]} #{v[:file]}:#{v[:line].nil? ? "-" : v[:line]} #{v[:message]}"
49
+ end
50
+ exit 1
@@ -70,8 +70,10 @@ and artifact depth to that size. Extended walkthrough: `references/tiers.md`.
70
70
  every tier and in both modes. A three-line spec.md is still a spec.md, in the same
71
71
  place, under the same gate.
72
72
  3. **Per-tier topology.** S/M: one thinker agent, one boot, two stations, sonnet
73
- executor, `actions/` skipped; S may also skip the QMD discovery deposit when chain and
74
- sources are both empty. L: today's full team (`## Team Spin-Up` below).
73
+ executor; the thinker writes at least one real action file (one consolidated
74
+ `actions/ACTION_1.md`), never an empty `actions/`. S may also skip the QMD discovery
75
+ deposit when chain and sources are both empty. L: today's full team (`## Team Spin-Up`
76
+ below), one `actions/ACTION_N.md` per task.
75
77
  4. **Never-cut list**, any tier or mode: the independent reviewer (separate agent, fresh
76
78
  context, never the maker), `outcome.md` as truth of delivery, the delivery lock,
77
79
  worktree isolation, intent creation via skill, INDEX as status truth, the QMD reindex
@@ -203,14 +205,15 @@ Then proceed to How.
203
205
 
204
206
  ## How Phase
205
207
 
206
- This is the L-tier shape (see `## Tiers` above); S/M skip step 3 and fold the checklist
207
- rationale into plan.md inline. The `actions/` directory itself is scaffolded empty at
208
- intent birth and persists at every tier; only writing `ACTION_N.md` files into it is L
209
- only (S/M leave the directory empty).
208
+ Every tier runs all four steps below (see `## Tiers` above). The `actions/` directory is
209
+ scaffolded (with a `.gitkeep`) at intent birth; the planner then writes at least one REAL
210
+ `ACTION_N.md` into it at every tier. The tier only changes step 3's granularity: S/M write
211
+ one consolidated `actions/ACTION_1.md`, L writes one `actions/ACTION_N.md` per task. A
212
+ `.gitkeep`-only or empty `actions/` fails the How gate.
210
213
 
211
214
  1. If `superpowers:writing-plans` is available as a skill, delegate plan creation to it. Tell it the plan saves to the active intent's directory (not `docs/superpowers/plans/`).
212
215
  2. Otherwise, write `plan.md` directly - implementation plan with numbered tasks
213
- 3. Write `ACTION_N.md` files into the existing `actions/` directory (one per task, self-contained) - L only
216
+ 3. Write at least one real `ACTION_N.md` into the existing `actions/` directory, self-contained (S/M: one consolidated `ACTION_1.md`; L: one per task)
214
217
  4. Write `checklist.md` - execution registry with checkboxes covering all actions
215
218
  5. Notify user (How briefing): brief per `references/human-report-contract.md`
216
219
  (State: the plan shape, task count and what it builds; Risk: the riskiest task or
@@ -30,9 +30,10 @@ the savepoint ledger.
30
30
  One thinker agent boots ONCE and stays in a single context for two stations:
31
31
 
32
32
  1. Station 1 — writes `spec.md` (collapsed sections allowed, one line each is valid).
33
- 2. Station 2 — writes `plan.md` + `checklist.md` in the SAME context (no reboot). plan.md
34
- carries the checklist rationale inline instead of separate `actions/ACTION_N.md` files.
35
- `actions/` is not created for S/M.
33
+ 2. Station 2 — writes `plan.md` + `checklist.md` + at least one real action file in the
34
+ SAME context (no reboot). At S/M the thinker consolidates the whole delivery into one
35
+ `actions/ACTION_1.md` (rather than one file per task); `actions/` is populated at every
36
+ tier, and a `.gitkeep`-only or empty `actions/` fails the How gate.
36
37
 
37
38
  Then a sonnet executor (a fresh dispatch, this is the one topology split that always
38
39
  happens) implements from plan.md + checklist.md, checks off items, appends `## Insights`,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: plastic-intent-planning
3
- description: "Write implementation plans from a spec. Produces plan.md, checklist.md, and (L tier only) actions/ in the active intent directory."
3
+ description: "Write implementation plans from a spec. Produces plan.md, checklist.md, and at least one real actions/ACTION_N.md (every tier) in the active intent directory."
4
4
  user-invocable: true
5
5
  ---
6
6
 
@@ -31,12 +31,12 @@ If the spec covers multiple independent subsystems, it should have been broken i
31
31
 
32
32
  ## Tier shapes
33
33
 
34
- Read the spec's stamped `Tier:` line (written by intent-speccing) and pick the plan shape it calls for:
34
+ Read the spec's stamped `Tier:` line (written by intent-speccing) and pick the action shape it calls for. Every tier produces at least one REAL action file in `actions/`; the tier only changes how many:
35
35
 
36
- - **S or M (default):** an inline plan-as-checklist. Tasks live directly in `plan.md` with rationale inline; `checklist.md` mirrors the task list. Do NOT create any `actions/` files. Leave the `actions/` directory empty (or absent).
36
+ - **S or M (default):** write ONE consolidated `actions/ACTION_1.md` that carries the whole ordered delivery (the steps plus the exact changes). `plan.md` still holds the overall map and `checklist.md` still mirrors the task list. You may split into a few action files when that reads cleaner, but one real action file is the floor.
37
37
  - **L (many independent tasks, dispatched in parallel):** self-contained `actions/ACTION_N.md`, one per task, each readable without the plan (see `references/plan-format.md`).
38
38
 
39
- Default to inline for S and M. Reserve `actions/` for L, where tasks are independent enough to hand to separate subagents in parallel. When in doubt, prefer inline; creating unnecessary `actions/` files is a plan failure at S/M tier.
39
+ A `.gitkeep` never counts as an action, and an empty `actions/` fails the How gate at every tier. At S/M, keep it to a single consolidated action file rather than one-per-task; over-splitting a small intent is the S/M failure mode, an empty `actions/` is the tier-wide one.
40
40
 
41
41
  ## File Structure
42
42
 
@@ -97,11 +97,11 @@ If you find issues, fix them inline. No need to re-review, just fix and move on.
97
97
  ## Plastic Artifacts
98
98
 
99
99
  After writing `plan.md`, create `checklist.md` (execution registry following the
100
- FORM: `## In Progress`, `## Completed`, `## Session Log`) in the intent directory.
101
- For L tier only, also create `actions/ACTION_N.md` (one self-contained file per
102
- task, in an `actions/` directory inside the intent directory). For S/M tier, do
103
- not create `actions/` files at all (see Tier shapes above). For the exact format
104
- of both, read `references/plan-format.md`.
100
+ FORM: `## In Progress`, `## Completed`, `## Session Log`) and at least one real
101
+ `actions/ACTION_N.md` (self-contained, in an `actions/` directory inside the intent
102
+ directory). At S/M write one consolidated `actions/ACTION_1.md`; at L write one
103
+ `actions/ACTION_N.md` per task (see Tier shapes above). For the exact format of
104
+ both, read `references/plan-format.md`.
105
105
 
106
106
  ## Owner-decision hard-gate items
107
107
 
@@ -118,12 +118,12 @@ When collecting owner rulings for `[ORCHESTRATOR]` hard-gate items, read
118
118
  ## Gate position
119
119
 
120
120
  - **Before:** `spec.md` exists.
121
- - **Produces:** `plan.md` and `checklist.md` (plus `actions/` for L tier only).
121
+ - **Produces:** `plan.md`, `checklist.md`, and at least one real `actions/ACTION_N.md` (every tier; one consolidated file at S/M, one per task at L).
122
122
  - **Next:** /plastic-intent-executing.
123
123
 
124
124
  ## Git Commit
125
125
 
126
- After writing all artifacts (plan.md, checklist.md, actions/ for L tier), commit to the store:
126
+ After writing all artifacts (plan.md, checklist.md, and the actions/ACTION_N.md files), commit to the store:
127
127
 
128
128
  ```bash
129
129
  cd {store_root} && git add . && git commit -m "docs: plan for intent {id}: {name}"
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "skill_name": "plastic-intent-planning",
3
- "notes": "Intent 164. New evals.json for the writing-plans skill: tier-shape enforcement (S/M inline vs L actions/) and the checklist FORM plus [ORCHESTRATOR] hard-gate item convention. All assertions are pending a real observed run per the plastic-evaluating-skills convention.",
3
+ "notes": "Intent 164, updated by 133a. Evals for the writing-plans skill: action-file enforcement at every tier (S/M consolidate the delivery into one real actions/ACTION_1.md, L writes one actions/ACTION_N.md per task) and the checklist FORM plus [ORCHESTRATOR] hard-gate item convention. All assertions are pending a real observed run per the plastic-evaluating-skills convention.",
4
4
  "evals": [
5
5
  {
6
6
  "id": 1,
7
7
  "scope": "behavior",
8
8
  "set": "train",
9
9
  "prompt": "spec.md is stamped Tier: M and describes a single subsystem change touching four files across two tasks. Write the plan.",
10
- "expected_output": "Produces an inline plan-as-checklist: all tasks with rationale live directly in plan.md, and checklist.md mirrors the task list. No actions/ACTION_N.md files are created; the actions/ directory stays empty or absent.",
10
+ "expected_output": "Produces plan.md plus checklist.md plus at least one real actions/ACTION_N.md. At M the planner consolidates the whole delivery into a single actions/ACTION_1.md (it may split into a few files if that reads cleaner); the actions/ directory is never left empty or .gitkeep-only. checklist.md mirrors plan.md's task list.",
11
11
  "files": [],
12
12
  "assertions": [
13
13
  {
14
14
  "type": "code",
15
- "check": "no files created under actions/ for this intent; checklist.md exists and mirrors plan.md's task list",
15
+ "check": "at least one real (non-empty, non-.gitkeep) file exists under actions/ for this intent; checklist.md exists and mirrors plan.md's task list",
16
16
  "result": "expect-pass"
17
17
  }
18
18
  ]
@@ -22,12 +22,12 @@
22
22
  "scope": "behavior",
23
23
  "set": "train",
24
24
  "prompt": "spec.md is stamped Tier: S and describes a single-file config change. Write the plan.",
25
- "expected_output": "Produces an inline plan-as-checklist. No actions/ files are created, matching the S/M default.",
25
+ "expected_output": "Produces plan.md plus checklist.md plus one consolidated actions/ACTION_1.md carrying the whole ordered delivery (the steps plus the exact changes). The actions/ directory is never left empty or .gitkeep-only, matching the every-tier action-file rule.",
26
26
  "files": [],
27
27
  "assertions": [
28
28
  {
29
29
  "type": "code",
30
- "check": "no files created under actions/ for this intent",
30
+ "check": "at least one real (non-empty, non-.gitkeep) actions/ACTION_N.md exists under actions/ for this intent; no empty or .gitkeep-only actions/ dir remains",
31
31
  "result": "expect-pass"
32
32
  }
33
33
  ]
@@ -61,6 +61,21 @@
61
61
  "result": "expect-pass"
62
62
  }
63
63
  ]
64
+ },
65
+ {
66
+ "id": 5,
67
+ "scope": "behavior",
68
+ "set": "validation",
69
+ "prompt": "spec.md is stamped Tier: S. A prior draft left actions/ with only a .gitkeep. Write the plan and its actions.",
70
+ "expected_output": "Writes at least one real actions/ACTION_1.md (non-empty, not the placeholder sentinel, not a .gitkeep) that consolidates the whole S-tier delivery. Leaving actions/ empty or .gitkeep-only is a plan failure that would block the How gate; the planner never does this at any tier.",
71
+ "files": [],
72
+ "assertions": [
73
+ {
74
+ "type": "code",
75
+ "check": "actions/ contains at least one real action file (non-empty *.md, first line not the placeholder sentinel); no .gitkeep-only or empty actions/ dir remains",
76
+ "result": "expect-pass"
77
+ }
78
+ ]
64
79
  }
65
80
  ]
66
81
  }
@@ -28,8 +28,9 @@ plan.md so the shape matches on the first pass.
28
28
 
29
29
  **Intent:** {id}: {name}
30
30
 
31
- **Tier:** S|M|L, copied from the spec's stamped `Tier:` line. S and M plans are
32
- inline plan-as-checklist (no `actions/`); L plans use `actions/ACTION_N.md`.
31
+ **Tier:** S|M|L, copied from the spec's stamped `Tier:` line. Every tier produces at
32
+ least one real `actions/ACTION_N.md`; S and M consolidate the delivery into one action
33
+ file, L uses one `actions/ACTION_N.md` per task.
33
34
 
34
35
  ---
35
36
  ```
@@ -112,10 +113,13 @@ of tier (S, M, or L):
112
113
 
113
114
  ## actions/ACTION_N.md format
114
115
 
115
- L tier only. S and M plans skip this file type entirely; their tasks live
116
- inline in `plan.md` (see the parent SKILL.md's `## Tier shapes`).
116
+ Every tier writes at least one real action file here (see the parent SKILL.md's
117
+ `## Tier shapes`): S and M consolidate the whole delivery into a single
118
+ `actions/ACTION_1.md`, L writes one file per task.
117
119
 
118
- One file per task. Each action is self-contained: a subagent can execute it without reading the plan.
120
+ Each action is self-contained: a subagent (or a solo executor) can execute it without
121
+ reading the plan. It carries the ordered steps to deliver the task plus the exact changes
122
+ to make (for code, the anchor or `file:line` and the old/new text).
119
123
 
120
124
  ```markdown
121
125
  # Action {N}: {task title}
@@ -71,10 +71,11 @@ an answer given in station 3.
71
71
 
72
72
  Type `/plastic-intent-planning`.
73
73
 
74
- Artifact: `plan.md` and `checklist.md`. At the S tier used here, `actions/` stays empty and
75
- the task rationale lives inline in `plan.md` instead of separate action files; only the L
76
- tier (many independent tasks, dispatched in parallel) gets one `actions/ACTION_N.md` file
77
- per task. `checklist.md` follows a fixed form: tasks start under `## In Progress`, move to
74
+ Artifact: `plan.md`, `checklist.md`, and at least one real `actions/ACTION_N.md`. At the S
75
+ tier used here, the planner consolidates the whole delivery into a single
76
+ `actions/ACTION_1.md` (the ordered steps plus the exact changes); the L tier (many
77
+ independent tasks, dispatched in parallel) instead gets one `actions/ACTION_N.md` file per
78
+ task. `checklist.md` follows a fixed form: tasks start under `## In Progress`, move to
78
79
  `## Completed` as they land, and a `## Session Log` table records what happened each session.
79
80
  A task that depends on an owner decision landing first (a destructive step, a structural
80
81
  ruling) gets an `[ORCHESTRATOR]` prefix and blocks every other item until that decision is