@zalom/plastic 1.0.0-beta.1 → 1.0.0-beta.3

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
@@ -35,15 +35,21 @@ Identity and knowledge graph only. Nothing operational.
35
35
  ---
36
36
  id: "4a1"
37
37
  intent: "Short description of the desire"
38
- sources: ["4a"] # backward links what influenced this
39
- chain: ["4a1a"] # forward links — what this spawned
38
+ sources: ["4a"] # direct ascendants: intents this was created from
39
+ chain: ["4a1a"] # forward: what this spawned and related successors
40
40
  created: 2026-05-29
41
41
  author: human # human | agent-name
42
42
  tags: [plastic, architecture]
43
43
  ---
44
44
  ```
45
45
 
46
- - `sources` + `chain` form the double-linked knowledge graph (Folgezettel)
46
+ - `sources` (formative, must-load, acyclic) and `chain` (forward + relational, lighter,
47
+ may cycle) form the directed knowledge graph. Reciprocity is one-directional: every
48
+ `sources` edge has a reciprocal `chain` entry (I1), but `chain` may carry relational
49
+ entries with no reciprocal `sources` (I2), so the graph is not strictly symmetric.
50
+ - Context contract: load `sources` strongly (they are what the intent was built from);
51
+ traverse `chain` lightly for discovery. See
52
+ docs/concepts/how-plastic-sources-and-chains-intents.md for the full model.
47
53
  - IDs use Luhmann's alternating convention: `1` → `1a` → `1a1` → `1a1a`
48
54
  - Multiple branches increment: `1a`, `1b`, `1c`
49
55
 
@@ -155,8 +161,11 @@ Format: `ID--three-to-five-words` (all stores).
155
161
 
156
162
  - **Branch (`14a`, `14b`)** — a sub-task, refinement, or direct continuation of the
157
163
  parent. It cannot stand on its own; it only makes sense as part of the parent's work.
158
- - **Root (`15`, `16`)** an independent thought, even if inspired by another intent.
159
- Record provenance with `sources: ["14"]`, not by branching.
164
+ - **Root (`15`, `16`)**: an independent thought, even if inspired by another intent.
165
+ Reserve `sources` for true created-from provenance (intents this was built out of). An
166
+ independent intent merely related to or inspired by another carries NO `sources`; record
167
+ the relation on the PREDECESSOR's `chain` (and mirror it as a `[[id]]` wikilink in
168
+ `## Links`).
160
169
  - **Rule of thumb:** if the intent could exist without its parent, it's a root.
161
170
 
162
171
  ## INDEX.md
@@ -27,7 +27,15 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
27
27
  2. Read the intent file, its `## Links`, and related code or docs
28
28
  3. Research with the adaptive budget the enforcer set (simple 2-3, medium 5-8, complex 10-15 steps)
29
29
  4. Produce (output handoff): an enriched `## Context` plus `### Decisions` with rationale
30
- 5. Log autonomous calls in `## Insights` with the `(autonomous)` marker, then report for gating
30
+ 5. Log autonomous calls in `## Insights` with the `(autonomous)` marker, then report for gating (see `## Completion Report`)
31
+
32
+ ## Completion Report
33
+
34
+ END your turn with a structured completion report as your final message, per the spawn preamble's `REPORT_CONTRACT` and `skills/auto/references/agent-report-contract.md`. Do not finish silently. Carry the common envelope (role, intent id, stage, status, artifacts written, verification, checklist deltas, deviations, blockers) plus the brainstorming payload:
35
+
36
+ - Decisions recorded in `### Decisions`, each with its one-line rationale
37
+ - Context enriched: what was researched and the key findings
38
+ - Open questions resolved, and any deliberately left for the spec
31
39
 
32
40
  ## Constraints
33
41
 
@@ -28,6 +28,16 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
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
30
30
  5. Produce (output handoff): the code changes, a checked-off `checklist.md`, and `## Insights`
31
+ 6. Report for gating (see `## Completion Report`); the enforcer reviews the work at the final gate
32
+
33
+ ## Completion Report
34
+
35
+ END your turn with a structured completion report as your final message, per the spawn preamble's `REPORT_CONTRACT` and `skills/auto/references/agent-report-contract.md`. Do not finish silently. Carry the common envelope (role, intent id, stage, status, artifacts written, verification, checklist deltas, deviations, blockers) plus the executor payload:
36
+
37
+ - Actions implemented this turn, mapped to checklist items checked off (checked / total)
38
+ - A summary of the code changed (files and the shape of the change)
39
+ - Test result: the full-suite command and its pass / fail counts
40
+ - Insights appended, with the `(autonomous)` marker
31
41
 
32
42
  ## Constraints
33
43
 
@@ -27,7 +27,17 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
27
27
  2. Read `spec.md` and the plan/checklist templates; match their FORM
28
28
  3. Write `plan.md`, the `actions/` directory, and `checklist.md` into the intent directory
29
29
  4. Produce (output handoff): `plan.md` plus `actions/` plus `checklist.md`
30
- 5. Report for gating; the enforcer verifies plan and checklist exist before Exec is allowed
30
+ 5. Report for gating (see `## Completion Report`); the enforcer verifies plan and checklist exist before Exec is allowed
31
+
32
+ ## Completion Report
33
+
34
+ END your turn with a structured completion report as your final message, per the spawn preamble's `REPORT_CONTRACT` and `skills/auto/references/agent-report-contract.md`. Do not finish silently. Carry the common envelope (role, intent id, stage, status, artifacts written, verification, checklist deltas, deviations, blockers) plus the planner payload, which EXPLAINS THE PLAN BACK TO THE ORCHESTRATOR:
35
+
36
+ - The ordered actions, one line each: what the action does and how it is verified
37
+ - Decomposition rationale: why this order, and why the actions are independent
38
+ - Checklist coverage: the item count, and that every action plus suite-green is covered
39
+
40
+ 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.
31
41
 
32
42
  ## Constraints
33
43
 
@@ -27,7 +27,15 @@ When dispatched in auto mode you receive the standard Plastic spawn preamble (fr
27
27
  2. Read the spec template (`templates/spec.md`) and match its FORM exactly
28
28
  3. Write `spec.md` into the intent directory, resolving the decisions into a coherent approach
29
29
  4. Produce (output handoff): a complete `spec.md`
30
- 5. Report for gating; the enforcer checks the spec against its exit criteria before How begins
30
+ 5. Report for gating (see `## Completion Report`); the enforcer checks the spec against its exit criteria before How begins
31
+
32
+ ## Completion Report
33
+
34
+ END your turn with a structured completion report as your final message, per the spawn preamble's `REPORT_CONTRACT` and `skills/auto/references/agent-report-contract.md`. Do not finish silently. Carry the common envelope (role, intent id, stage, status, artifacts written, verification, checklist deltas, deviations, blockers) plus the spec payload:
35
+
36
+ - The spec sections produced (Problem, Goals, Non-Goals, Approach, Decisions, Acceptance Criteria)
37
+ - How the recorded decisions resolved into the chosen approach
38
+ - The acceptance-criteria count, so the planner knows the surface to cover
31
39
 
32
40
  ## Constraints
33
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.3",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # agent-report - deterministic filesystem-derived completion report (intent 74).
6
+ #
7
+ # The auto-mode report contract is decision-shaped (the spawn preamble's
8
+ # REPORT_CONTRACT plus the role prompts ask every dispatched agent to END with a
9
+ # structured completion report). Because child-agent honor is best-effort across
10
+ # harnesses (Tier B/C, docs/reference/harness-adapters.md), the contract is never a
11
+ # hard block. This script is the always-a-report fallback: when a dispatched agent
12
+ # returns no usable report, the enforcer runs it to synthesize one from the intent
13
+ # directory, so the handoff account always exists.
14
+ #
15
+ # Like scripts/spawn-preamble, it is a PURE function of the intent dir: no network,
16
+ # no randomness, no wall-clock reads. Two runs over the same on-disk state produce
17
+ # byte-identical output.
18
+ #
19
+ # Usage:
20
+ # agent-report <intent_dir> [--role ROLE]
21
+ #
22
+ # Exit codes: 0 (report emitted), 2 (usage).
23
+
24
+ require_relative "lib/bridge"
25
+
26
+ def parse_args(argv)
27
+ role = nil
28
+ positional = []
29
+ i = 0
30
+ while i < argv.length
31
+ case argv[i]
32
+ when "--role"
33
+ role = argv[i + 1]
34
+ i += 2
35
+ else
36
+ positional << argv[i]
37
+ i += 1
38
+ end
39
+ end
40
+ [positional.first, role]
41
+ end
42
+
43
+ # Read the intent file frontmatter via the same path spawn-preamble uses, so the
44
+ # id/intent we report match what the rest of Plastic sees. Never raises.
45
+ def frontmatter_for(intent_dir)
46
+ ifile = Bridge.intent_file(intent_dir)
47
+ return {} unless File.exist?(ifile)
48
+
49
+ content = File.read(ifile)
50
+ return {} unless content.start_with?("---")
51
+
52
+ parts = content.split("---", 3)
53
+ return {} if parts.length < 3
54
+
55
+ require "yaml"
56
+ require "date"
57
+ require "time"
58
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}
59
+ rescue StandardError
60
+ {}
61
+ end
62
+
63
+ # Current stage: prefer the last non-empty savepoint line (the ledger encodes the
64
+ # furthest-reached milestone), else the file-derived stage. Same logic as
65
+ # spawn-preamble's current_stage.
66
+ STAGE_LABELS = {
67
+ "what" => "What", "why" => "Why", "how" => "How",
68
+ "exec" => "Exec", "done" => "Done"
69
+ }.freeze
70
+
71
+ def current_stage(intent_dir)
72
+ ledger = File.join(intent_dir, Bridge::SAVEPOINT_FILE)
73
+ if File.exist?(ledger)
74
+ last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
75
+ return last if last
76
+ end
77
+ STAGE_LABELS.fetch(Bridge.derive_stage(intent_dir), Bridge.derive_stage(intent_dir))
78
+ end
79
+
80
+ # checklist.md checked/total: count GFM task-list items. Returns "n/a" when there
81
+ # is no checklist (a checklist is only meaningful from How onward).
82
+ def checklist_progress(intent_dir)
83
+ path = File.join(intent_dir, "checklist.md")
84
+ return "n/a" unless Bridge.stage_file_present?(path)
85
+
86
+ lines = File.readlines(path)
87
+ total = lines.count { |l| l =~ /^\s*- \[[ xX]\]/ }
88
+ checked = lines.count { |l| l =~ /^\s*- \[[xX]\]/ }
89
+ return "n/a" if total.zero?
90
+
91
+ "#{checked}/#{total}"
92
+ end
93
+
94
+ # The outcome line: first content line under outcome.md's "## Summary" (or the first
95
+ # non-heading content line), else a placeholder. Pure read of the intent dir.
96
+ def outcome_line(intent_dir)
97
+ path = File.join(intent_dir, "outcome.md")
98
+ return "(no outcome yet)" unless Bridge.stage_file_present?(path)
99
+
100
+ lines = File.readlines(path).map(&:rstrip)
101
+ start = lines.index { |l| l.strip.downcase == "## summary" }
102
+ scan = start ? lines[(start + 1)..] : lines
103
+ (scan || []).each do |l|
104
+ s = l.strip
105
+ next if s.empty? || s.start_with?("#") || s.start_with?("<!--")
106
+ return s
107
+ end
108
+ "(no outcome yet)"
109
+ end
110
+
111
+ intent_dir_arg, role = parse_args(ARGV)
112
+
113
+ if intent_dir_arg.nil? || intent_dir_arg.empty?
114
+ warn "usage: agent-report <intent_dir> [--role ROLE]"
115
+ exit 2
116
+ end
117
+
118
+ intent_dir = File.expand_path(intent_dir_arg)
119
+
120
+ fm = frontmatter_for(intent_dir)
121
+ id = fm["id"].to_s.strip
122
+ intent_name = fm["intent"].to_s.strip
123
+ id = "(unknown)" if id.empty?
124
+ intent_name = "(unknown)" if intent_name.empty?
125
+
126
+ stage = current_stage(intent_dir)
127
+ role_label = (role && !role.empty?) ? role : stage
128
+ artifacts = Bridge.has_files(intent_dir)
129
+ artifacts_str = artifacts.empty? ? "(none)" : artifacts.join(", ")
130
+
131
+ lines = []
132
+ lines << "=== Plastic agent report (synthesized) ==="
133
+ lines << "Intent: #{id} - #{intent_name}"
134
+ lines << "Stage: #{stage}"
135
+ lines << "Role: #{role_label}"
136
+ lines << "Status: synthesized (filesystem-derived; no agent-authored report)"
137
+ lines << "Artifacts present: #{artifacts_str}"
138
+ lines << "Checklist: #{checklist_progress(intent_dir)}"
139
+ lines << "Outcome: #{outcome_line(intent_dir)}"
140
+ lines << "=== end report ==="
141
+
142
+ puts lines.join("\n")
@@ -223,15 +223,17 @@ def effort_of(rec, type)
223
223
  end
224
224
 
225
225
  # Value -> :high | :low (explicit frontmatter field wins).
226
- # High is deliberately rare: an explicit stamp, or a human-authored root idea that has
227
- # already spawned follow-on work (chain non-empty) — i.e. a strategic theme the user owns.
226
+ # High is deliberately rare: an explicit stamp, a human-authored root idea, or an intent
227
+ # that has SPAWNED follow-on work, i.e. a strategic theme the user owns. "Has spawned work"
228
+ # means a reciprocal (I1) edge: another intent lists this one in its `sources`, captured by
229
+ # `referenced`. A purely relational `chain` entry (D2, no reciprocal `sources`) does NOT
230
+ # count as spawned, so bare `chain` membership is not a high-value signal (intent 68).
228
231
  def value_of(rec, referenced = {})
229
232
  case rec[:value_field]
230
233
  when "high" then return :high
231
234
  when "low" then return :low
232
235
  end
233
236
  return :high if rec[:author] == "human" && root_intent?(rec[:id])
234
- return :high unless rec[:chain].empty?
235
237
  return :high if referenced[[rec[:scope], rec[:id]]]
236
238
  :low
237
239
  end
package/scripts/doctor.rb CHANGED
@@ -473,9 +473,80 @@ class Doctor
473
473
  )
474
474
  end
475
475
 
476
+ # graph_invariants — cross-intent I1/I3/I4 checks (intent 68). I1/I3/I4 are
477
+ # defined within a single store's id space (bare ids resolve within the same
478
+ # store), so build the `nodes` map per scope and run validate_graph per scope.
479
+ # I2 asymmetry (a relational chain entry with no reciprocal sources) is NEVER
480
+ # flagged: validate_graph does not compute it.
481
+ checks.concat(graph_invariant_checks(intent_dirs))
482
+
483
+ checks
484
+ end
485
+
486
+ # Build a per-scope `nodes` map and surface IntentValidator.validate_graph
487
+ # findings as warn-level checks. Scope-aware (the caller already filtered
488
+ # `intent_dirs` by scope), so a `global` id is not falsely flagged as a dangler
489
+ # when only a `project:` store is loaded, and vice versa.
490
+ def graph_invariant_checks(intent_dirs)
491
+ nodes_by_scope = Hash.new { |h, k| h[k] = {} }
492
+ intent_dirs.each do |d|
493
+ md_path = File.join(d[:path], "#{d[:name]}.md")
494
+ next unless File.exist?(md_path)
495
+
496
+ fm = parse_frontmatter(md_path)
497
+ next unless fm.is_a?(Hash) && fm["id"]
498
+
499
+ nodes_by_scope[d[:scope]][fm["id"].to_s] = {
500
+ sources: Array(fm["sources"]).map(&:to_s),
501
+ chain: Array(fm["chain"]).map(&:to_s),
502
+ }
503
+ end
504
+
505
+ i1 = []
506
+ i3 = []
507
+ i4 = []
508
+ nodes_by_scope.each_value do |nodes|
509
+ findings = IntentValidator.validate_graph(nodes)
510
+ i1.concat(findings[:i1])
511
+ i3.concat(findings[:i3])
512
+ i4.concat(findings[:i4])
513
+ end
514
+
515
+ checks = []
516
+ checks << graph_finding_check(
517
+ "graph_i1_reciprocity", i1,
518
+ "Every sources edge has its reciprocal chain entry (I1)",
519
+ "Run new-intent / the rebuild so each source intent's chain backlinks the child"
520
+ )
521
+ checks << graph_finding_check(
522
+ "graph_i3_disjoint", i3,
523
+ "No intent lists the same id in both sources and chain (I3)",
524
+ "Remove the overlapping id from either sources or chain"
525
+ )
526
+ checks << graph_finding_check(
527
+ "graph_i4_danglers", i4,
528
+ "Every sources/chain id resolves to a real intent (I4)",
529
+ "Fix or remove the dangling id reference"
530
+ )
476
531
  checks
477
532
  end
478
533
 
534
+ # One graph check: pass when `findings` is empty, otherwise warn (never fail, so
535
+ # an existing store does not turn red on a graph finding). I1/I4 are auto-fixable.
536
+ def graph_finding_check(name, findings, pass_message, fix_hint)
537
+ if findings.empty?
538
+ check(category: "conventions", name: name, status: "pass", message: pass_message)
539
+ else
540
+ check(
541
+ category: "conventions", name: name, status: "warn",
542
+ message: "#{findings.size} #{name} violation(s)",
543
+ details: findings,
544
+ fixable: name != "graph_i3_disjoint",
545
+ fix_hint: fix_hint
546
+ )
547
+ end
548
+ end
549
+
479
550
  # --- Check category 3: Agent registration ---
480
551
 
481
552
  def check_agent_registration(agent_key)
@@ -153,4 +153,83 @@ module IntentValidator
153
153
  content = File.exist?(md_path) ? File.read(md_path) : nil
154
154
  validate_content(content)
155
155
  end
156
+
157
+ # PURE: cross-intent graph-shape invariants (intent 68). These need visibility
158
+ # over the whole intent set, so they live apart from the single-file born-complete
159
+ # helpers above (which must not drift). No file IO: the caller builds `nodes`.
160
+ #
161
+ # `nodes` is a Hash { id(String) => { sources: [ids], chain: [ids] } } for every
162
+ # intent in ONE store's id space. Returns { i1: [...], i3: [...], i4: [...] },
163
+ # each an array of human-readable finding strings.
164
+ #
165
+ # I2 (no false symmetry) is INTENTIONALLY not computed: a relational `chain` entry
166
+ # with no reciprocal `sources` is valid and must never be flagged.
167
+ def validate_graph(nodes)
168
+ nodes = normalize_nodes(nodes)
169
+ { i1: graph_i1(nodes), i3: graph_i3(nodes), i4: graph_i4(nodes) }
170
+ end
171
+
172
+ # Coerce node arrays to deduped String id lists; tolerate missing keys.
173
+ def normalize_nodes(nodes)
174
+ return {} unless nodes.is_a?(Hash)
175
+
176
+ nodes.each_with_object({}) do |(id, edges), acc|
177
+ edges = {} unless edges.is_a?(Hash)
178
+ acc[id.to_s] = {
179
+ sources: Array(edges[:sources] || edges["sources"]).map(&:to_s).uniq,
180
+ chain: Array(edges[:chain] || edges["chain"]).map(&:to_s).uniq,
181
+ }
182
+ end
183
+ end
184
+
185
+ # An id is a cross-store reference (out of this store's scope) when it carries a
186
+ # `<store>:` prefix, mirroring how `valid_id?` accepts the prefix. Such refs are
187
+ # resolved outside this node set, so they are never danglers here.
188
+ def cross_store_ref?(id)
189
+ id.to_s.include?(":")
190
+ end
191
+
192
+ # I1 (formative reciprocity): for every B and every `s` in B.sources that resolves
193
+ # in this store, B must appear in s.chain. A `s` that does not resolve is an I4
194
+ # dangler, not an I1 violation, so it is skipped here.
195
+ def graph_i1(nodes)
196
+ findings = []
197
+ nodes.each do |b_id, edges|
198
+ edges[:sources].each do |s|
199
+ next if cross_store_ref?(s)
200
+ next unless nodes.key?(s)
201
+
202
+ findings << "#{b_id}.sources lists #{s} but #{s}.chain is missing #{b_id}" unless nodes[s][:chain].include?(b_id)
203
+ end
204
+ end
205
+ findings
206
+ end
207
+
208
+ # I3 (per-node disjoint): X.sources and X.chain must not overlap.
209
+ def graph_i3(nodes)
210
+ findings = []
211
+ nodes.each do |x_id, edges|
212
+ (edges[:sources] & edges[:chain]).each do |overlap|
213
+ findings << "#{x_id} lists #{overlap} in BOTH sources and chain"
214
+ end
215
+ end
216
+ findings
217
+ end
218
+
219
+ # I4 (no danglers): every bare (same-store) id in any sources/chain must resolve
220
+ # to a node. Cross-store `<store>:<id>` refs resolve elsewhere and are not flagged.
221
+ def graph_i4(nodes)
222
+ findings = []
223
+ nodes.each do |id, edges|
224
+ %i[sources chain].each do |field|
225
+ edges[field].each do |ref|
226
+ next if cross_store_ref?(ref)
227
+ next if nodes.key?(ref)
228
+
229
+ findings << "#{id}.#{field} references #{ref} which resolves to no intent"
230
+ end
231
+ end
232
+ end
233
+ findings
234
+ end
156
235
  end
@@ -73,6 +73,39 @@ def render_tokens(text, tokens)
73
73
  tokens.reduce(text) { |acc, (k, v)| acc.gsub("{{#{k}}}", v.to_s) }
74
74
  end
75
75
 
76
+ # Add an id to a source intent's frontmatter `chain` array, idempotently (I1
77
+ # reciprocity: `child in parent.sources` => `parent.chain` gains `child`). A
78
+ # targeted edit of the `chain:` line only; the body (including `## Links`) is
79
+ # preserved byte-for-byte and no other frontmatter key is touched, so the file
80
+ # stays born-complete. Renders the array in flow style (`["a", "b"]`) to match
81
+ # templates/intent.md. No-op when the id is already present.
82
+ def add_to_chain(file_path, new_id)
83
+ return unless File.exist?(file_path)
84
+ content = File.read(file_path)
85
+ return unless content.start_with?("---")
86
+
87
+ parts = content.split("---", 3)
88
+ return unless parts.length >= 3
89
+
90
+ fm = parts[1]
91
+ chain_line = fm.lines.find { |l| l.match?(/\A\s*chain\s*:/) }
92
+ return unless chain_line
93
+
94
+ existing = fm.match(/\bchain\s*:\s*\[(.*?)\]/m)
95
+ ids =
96
+ if existing
97
+ existing[1].scan(/"([^"]*)"|'([^']*)'/).flatten.compact
98
+ else
99
+ []
100
+ end
101
+ return if ids.include?(new_id)
102
+
103
+ ids << new_id
104
+ rendered = "chain: [#{ids.map { |i| "\"#{i}\"" }.join(", ")}]"
105
+ new_fm = fm.sub(/^\s*chain\s*:.*$/, rendered)
106
+ File.write(file_path, ["", new_fm, parts[2]].join("---"))
107
+ end
108
+
76
109
  # Append a wikilink line under the file's `## Links` section, idempotently.
77
110
  def append_link(file_path, link_line)
78
111
  return unless File.exist?(file_path)
@@ -140,7 +173,23 @@ def main(argv)
140
173
  intent_file = File.join(intent_dir, "#{id}--#{slug}.md")
141
174
  File.write(intent_file, intent_body)
142
175
 
143
- # 4. Reciprocal links: forward link to parent + back-reference in the parent.
176
+ # 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
177
+ # `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
178
+ # `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
179
+ # folds in `--parent`). Scope boundary: 68 fixes ONLY the frontmatter `chain`
180
+ # backlink; the `## Links` wikilink projection for the `--sources` path is intent 72.
181
+ sources.each do |src_id|
182
+ next if src_id.nil? || src_id.empty?
183
+
184
+ src_dir = Dir.glob(File.join(store, "#{src_id}--*")).find { |d| File.directory?(d) }
185
+ next unless src_dir
186
+
187
+ src_file = File.join(src_dir, "#{File.basename(src_dir)}.md")
188
+ add_to_chain(src_file, id)
189
+ end
190
+
191
+ # 4b. Reciprocal `## Links` wikilinks: forward link to parent + back-reference in
192
+ # the parent (kept parent-only; the `--sources` path's wikilink is intent 72's scope).
144
193
  if opts[:parent] && !opts[:parent].empty?
145
194
  parent_id = opts[:parent]
146
195
  append_link(intent_file, "- [[#{parent_id}]]")
@@ -29,7 +29,22 @@ require_relative "lib/bridge"
29
29
  HONOR_INSTRUCTION =
30
30
  "You are operating inside Plastic. Use it as your operating scaffold. " \
31
31
  "Emit VALID lifecycle artifacts; do not hallucinate intents or stages. " \
32
- "Your output is a deliverable, not a message."
32
+ "Your primary output is valid lifecycle artifacts; you close with a structured report about them."
33
+
34
+ # Verbatim completion-report contract (intent 74). Kept as one constant so the
35
+ # contract doc (skills/auto/references/agent-report-contract.md), the role prompts,
36
+ # and the test assert against the exact same string. SINGLE SOURCE OF TRUTH for the
37
+ # report wording: the work output is lifecycle artifacts, the final message is a
38
+ # structured report about them. Both are required and they do not contradict.
39
+ REPORT_CONTRACT =
40
+ "Before you finish, END your turn with a structured completion report as your " \
41
+ "FINAL MESSAGE (your return value), not a side-channel file. Do not go idle or " \
42
+ "finish silently. The report carries a common envelope: role, intent id, stage, " \
43
+ "status (delivered or blocked), artifacts written, verification or tests run, " \
44
+ "checklist deltas, deviations from spec, and blockers or handoff notes; plus a " \
45
+ "role-specific payload that fulfils your place in the What, Why, How, Exec cycle " \
46
+ "(for example the planner explains the plan back to the orchestrator). See " \
47
+ "skills/auto/references/agent-report-contract.md for the per-role format."
33
48
 
34
49
  def parse_args(argv)
35
50
  role = nil
@@ -116,6 +131,8 @@ lines << "Current stage: #{stage}"
116
131
  lines << "Cycle step / role: #{cycle}"
117
132
  lines << ""
118
133
  lines << HONOR_INSTRUCTION
134
+ lines << ""
135
+ lines << REPORT_CONTRACT
119
136
  lines << "=== end preamble ==="
120
137
 
121
138
  puts lines.join("\n")
@@ -68,6 +68,8 @@ Dispatch rule: sequential, one specialist per stage on one branch (the deliverab
68
68
 
69
69
  Spawn preamble (live-state injection): before dispatching any specialist, run `scripts/spawn-preamble <intent_dir> --role <role>` and PREPEND its output to that specialist's prompt. The preamble is a deterministic, filesystem-only snapshot of the active intent (id, intent line, current stage) plus the honoring instruction, so every spawned agent boots with accurate live state instead of guessing. This is the authoritative L2 mechanism for harnesses whose sub-agents do not inherit a top-level session event (see `docs/reference/harness-adapters.md`).
70
70
 
71
+ Completion report (require-then-synthesize): every dispatched specialist MUST end with a structured completion report as its final message. The preamble's `REPORT_CONTRACT` injects this and the role prompts carry the per-role format (see `references/agent-report-contract.md`). Because child-agent honor is best-effort across harnesses, this is decision-shaping, not a hard block. When a specialist returns no usable report (it went idle, emitted only a bare ping, or its message was lost to a mid-run interjection), run `scripts/agent-report <intent_dir> --role <role>` to synthesize a deterministic filesystem-derived report so the handoff account always exists. Use the agent-authored report when present, the synthesized one otherwise.
72
+
71
73
  Final-gate review: dispatch an independent reviewer subagent at the final gate only, not as a standing role.
72
74
 
73
75
  Headless manual gate: when running headless or in the background, enforce gates manually and do not rely on hooks, because `CLAUDE_SESSION_ID` may be unset (this ties to the arm-gate fallback above).
@@ -61,6 +61,26 @@ and not hallucinate intents or stages. This is the standard L2 live-state mechan
61
61
  for harnesses whose spawned sub-agents do not inherit the top-level session event. See
62
62
  `docs/reference/harness-adapters.md` for how it slots into the per-harness contract.
63
63
 
64
+ ### Completion Reports
65
+
66
+ Every dispatched specialist ends its turn with a structured completion report as its final
67
+ message (its return value), so the agent that did the work is the one that accounts for it. The
68
+ report carries a common envelope plus a role-specific payload that fulfils the agent's place in
69
+ the cycle; the planner explains the plan back to the orchestrator, the executor reports what was
70
+ built and the test result, and so on. The format lives in `references/agent-report-contract.md`,
71
+ and the verbatim instruction is injected once via the spawn preamble's `REPORT_CONTRACT`
72
+ constant, which the role prompts reproduce.
73
+
74
+ Enforcement is require-report then synthesize-fallback. The preamble and prompts make the report
75
+ mandatory (decision-shaping), but child-agent honor is best-effort across harnesses (Tier B/C),
76
+ so it is never a hard block. When a specialist returns no usable report, the enforcer runs
77
+ `scripts/agent-report <intent_dir> --role <role>`, a pure function of the intent dir (no network,
78
+ clock, or randomness, mirroring `spawn-preamble`) that emits a filesystem-derived report from the
79
+ savepoint, the artifacts present, the checklist checked/total, and the outcome line. A handoff
80
+ account therefore always exists: agent-authored when present, deterministically reconstructed
81
+ otherwise. This structures the finish notification only; in-flight observations stay in
82
+ `## Insights`, no progress chatter is added.
83
+
64
84
  ### Gate Ownership
65
85
 
66
86
  The enforcer arms and verifies the lifecycle gate, then gates every stage transition.
@@ -0,0 +1,86 @@
1
+ # Agent Completion Report Contract
2
+
3
+ Every agent dispatched by the auto-mode enforcer MUST end its turn with a structured
4
+ completion report. This doc defines that report: one common envelope plus a per-role payload.
5
+ It is the format the `REPORT_CONTRACT` constant in `scripts/spawn-preamble` points at, the
6
+ role prompts (`agents/plastic-*.md`) reproduce, and the deterministic fallback
7
+ (`scripts/agent-report`) approximates. Keep all four in agreement; the constant in
8
+ `scripts/spawn-preamble` is the single source of truth for the injected wording.
9
+
10
+ ## Purpose
11
+
12
+ The report is the agent's FINAL MESSAGE (its return value), not a side-channel file. Every
13
+ harness hands a spawned agent's final text back to the dispatcher, so the final message is the
14
+ one carrier that works everywhere (decision D1). The report structures the FINISH notification
15
+ only. In-flight observations still go in `## Insights`; the report does not add progress chatter
16
+ (decision D5). An agent that finishes correct artifacts but goes idle without a report has not
17
+ completed its handoff: the agent that did the work is the cheapest, most accurate source of the
18
+ account.
19
+
20
+ ## Common envelope
21
+
22
+ Every role report, whatever the stage, carries these fields:
23
+
24
+ - **Role**: which specialist produced this (brainstorming, spec, planner, executor, reviewer).
25
+ - **Intent id and stage**: the active intent id and the cycle stage just completed.
26
+ - **Status**: `delivered` or `blocked`.
27
+ - **Artifacts written**: the files produced or changed (store paths, and project paths for the
28
+ executor).
29
+ - **Verification / tests run**: the command run and its result, or `n/a` for stages that write
30
+ no code.
31
+ - **Checklist deltas**: which checklist items this turn checked off (executor), or `n/a`.
32
+ - **Deviations from spec**: anything done differently from the spec or plan, and why, or `none`.
33
+ - **Blockers / handoff notes**: what the next stage must watch for, or `none`.
34
+
35
+ ## Per-role payload
36
+
37
+ Each role appends a payload that fulfils its place in the What, Why, How, Exec cycle (decision
38
+ D2). The payload is what makes the report useful to the orchestrator beyond the envelope.
39
+
40
+ ### brainstorming (Why exploration)
41
+ - Decisions recorded in `### Decisions`, each with its one-line rationale.
42
+ - Context enriched: what was researched and the key findings.
43
+ - Open questions resolved, and any deliberately left for the spec.
44
+
45
+ ### spec-specialist (Why to How boundary)
46
+ - Spec sections produced (Problem, Goals, Non-Goals, Approach, Decisions, Acceptance Criteria).
47
+ - How the recorded decisions resolved into the chosen approach.
48
+ - Acceptance-criteria count, so the planner knows the surface to cover.
49
+
50
+ ### planner (How): worked exemplar
51
+ The planner report EXPLAINS THE PLAN BACK TO THE ORCHESTRATOR. It carries:
52
+ - The ordered actions, one line each: what the action does and how it is verified.
53
+ - Decomposition rationale: why this order, and why the actions are independent.
54
+ - Checklist coverage: item count and that every action plus suite-green is covered.
55
+ This is the exemplar because the plan is an argument, and the orchestrator gates on whether that
56
+ argument is sound before any code is written.
57
+
58
+ ### executor (Exec)
59
+ - Actions implemented this turn, mapped to checklist items checked off (checked / total).
60
+ - A summary of the code changed (files and the shape of the change).
61
+ - Test result: the full-suite command and its pass / fail counts.
62
+ - Insights appended, with the `(autonomous)` marker.
63
+
64
+ ### final reviewer (final gate)
65
+ - Verdict: `pass` or `blockers found`.
66
+ - Each acceptance criterion checked, with the evidence that confirms or refutes it.
67
+ - Gaps or risks found, ranked, with a recommended disposition.
68
+
69
+ ## Fallback: always a report
70
+
71
+ Decision-shaping (the preamble plus these prompts) makes the report mandatory, but child-agent
72
+ honor is best-effort across harnesses (Tier B/C in `docs/reference/harness-adapters.md`), so the
73
+ contract is never a hard block (decision D3). When a dispatched agent returns no usable report
74
+ (it went idle, emitted only a bare ping, or its message was lost to a mid-run interjection), the
75
+ enforcer synthesizes one:
76
+
77
+ ```
78
+ scripts/agent-report <intent_dir> --role <role>
79
+ ```
80
+
81
+ `scripts/agent-report` is a pure function of the intent directory (no network, clock, or
82
+ randomness, mirroring `scripts/spawn-preamble`): it reads the current stage from the savepoint
83
+ ledger, the lifecycle artifacts present, the checklist checked / total, and the `## Outcome`
84
+ line, and emits a filesystem-derived report labelled `synthesized`. So a handoff account always
85
+ exists: authored by the agent when possible, reconstructed deterministically when not. This
86
+ formalizes the by-hand reconstruction the orchestrator did while delivering intent 68.
@@ -30,7 +30,7 @@ description: Use when new work begins, the user expresses a new goal, says "new
30
30
 
31
31
  When creating a tactical intent in a project store:
32
32
  - Read the project's `AGENTS.md` for project context and decisions
33
- - Link back to the project's governing intent (from `projects.yml` `parent` field) via `sources`
33
+ - Link back to the project's governing intent (from `projects.yml` `parent` field) via `sources` (the project genuinely is formed from its founding intent, a true formative edge, reciprocated on the founding intent's `chain`)
34
34
  - Add `[[global:<parent_ID>]]` backlink in `## Links`
35
35
  - The intent's Folgezettel ID is scoped to the project store (run `folgezettel-id` against the project's store at `~/.plastic/projects/{slug}/store/`)
36
36
 
@@ -48,10 +48,17 @@ Having a "parent" in mind does NOT automatically mean branch. Choose by meaning:
48
48
 
49
49
  - **Branch (`14a`, `14b`)**: a sub-task, refinement, or direct continuation. It only
50
50
  makes sense as part of the parent's work. Pass `--parent <parent_id>`.
51
- - **Root (`15`, `16`)**: an independent thought, even if inspired by another intent.
52
- Capture the inspiration in `--sources`, not in the id. Omit `--parent`.
53
- - **Rule of thumb:** if the intent could exist without its parent, make it a root and
54
- set `--sources`. Only branch when it genuinely cannot stand alone.
51
+ - **Root (`15`, `16`)**: an independent thought. Two cases, decided by ORIGIN:
52
+ - **Created from another intent** (it emerged from that intent's lifecycle): make it a
53
+ root and set `--sources <ascendant_id>`. `sources` is reserved for true created-from /
54
+ direct-ascendant provenance (D1).
55
+ - **Merely related to / inspired by another intent** (it did NOT come out of that
56
+ intent's lifecycle): carry NO `--sources`. Record the relation on the PREDECESSOR's
57
+ `chain` instead, and mirror it as a `[[id]]` wikilink in `## Links` (the
58
+ related-but-not-spawned rule).
59
+ - **Rule of thumb:** if the intent could exist without its parent, make it a root; only set
60
+ `--sources` when it was genuinely created from / emerged from that intent's lifecycle.
61
+ Topic similarity alone is not a `sources` edge.
55
62
 
56
63
  ### 3. Determine Intent Properties
57
64
 
@@ -59,11 +66,16 @@ Ask or infer from context:
59
66
  - **intent**: one-line description
60
67
  - **slug**: short hyphenated handle for the directory name
61
68
  - **author**: `human` | `claude-code` | other agent name
62
- - **sources**: Folgezettel ids that influenced this intent (e.g., `4a1`). For a
63
- project intent, include the governing intent's id.
69
+ - **sources**: the direct ascendant(s) this intent was created from / emerged from the
70
+ lifecycle of (formation, not topic similarity), e.g., `4a1`. For a project intent,
71
+ include the governing intent's id. A branch's structural parent is ALSO recorded in
72
+ `sources` (the ID carries it for the human/paper tree, `sources` carries it for
73
+ software), which `new-intent` does automatically (see `new-intent:126`).
64
74
  - **tags**: freeform list (use `project-<name>` for project membership)
65
75
 
66
- `chain` starts empty and is populated later when this intent spawns others.
76
+ `chain` carries what this intent spawns AND related-but-not-spawned successors it leads to;
77
+ it starts empty and is populated later. See
78
+ `docs/concepts/how-plastic-sources-and-chains-intents.md` for the full model.
67
79
  Place the intent in `## Active` or `## Future` in INDEX.md (status is
68
80
  convention-derived, not a frontmatter field).
69
81
 
@@ -0,0 +1,56 @@
1
+ {
2
+ "skill_name": "plastic-creating-intent",
3
+ "notes": "Intent 68. Scope: output-quality for the sources-vs-chain construction rules (D1/D2). Asserts the related-but-not-spawned case produces NO sources plus a predecessor chain link and a ## Links mirror, contrasted with the created-from case (true ascendant -> --sources set, reciprocal chain). The machine-checkable half lives in test/new_intent_test.rb (test_sources_path_gets_child_in_chain_frontmatter); this file documents the agent-facing scenario for skill evaluation and is NOT run by bin/test.",
4
+ "evals": [
5
+ {
6
+ "id": 1,
7
+ "scope": "behavior",
8
+ "set": "train",
9
+ "prompt": "Create an intent for adding a retry policy to the uploader. It's related to intent 41 (the upload pipeline work) but it's independent: it did not come out of intent 41's lifecycle.",
10
+ "expected_output": "A new root intent is created with EMPTY sources (it was not created from 41). The relation is recorded on the PREDECESSOR: intent 41 gains the new intent's id in its frontmatter chain, and a [[<new-id>]] wikilink is added to intent 41's ## Links. The new intent is NOT given 41 in --sources (the related-but-not-spawned rule). No false symmetry: 41 keeps the new id on chain with no reciprocal sources.",
11
+ "files": [],
12
+ "assertions": [
13
+ {
14
+ "type": "code",
15
+ "check": "new intent sources is empty",
16
+ "observed": "sources: []",
17
+ "result": "pass"
18
+ },
19
+ {
20
+ "type": "code",
21
+ "check": "predecessor 41 chain includes new id",
22
+ "observed": "41.chain includes <new-id>",
23
+ "result": "pass"
24
+ },
25
+ {
26
+ "type": "code",
27
+ "check": "predecessor 41 ## Links has [[<new-id>]] mirror",
28
+ "observed": "[[<new-id>]] present in 41 ## Links",
29
+ "result": "pass"
30
+ }
31
+ ]
32
+ },
33
+ {
34
+ "id": 2,
35
+ "scope": "behavior",
36
+ "set": "validation",
37
+ "prompt": "Create an intent that is the direct continuation of intent 41: it emerged from intent 41's lifecycle and could not exist without it.",
38
+ "expected_output": "Because the new intent was genuinely CREATED FROM 41 (D1), it carries 41 in --sources (or branches from 41, which folds 41 into sources via the redundant-explicit rule). The reciprocal I1 backlink lands: intent 41's frontmatter chain gains the new intent's id. This is the created-from case, contrasted with the related-but-not-spawned case in eval 1.",
39
+ "files": [],
40
+ "assertions": [
41
+ {
42
+ "type": "code",
43
+ "check": "new intent sources includes 41",
44
+ "observed": "sources includes 41",
45
+ "result": "pass"
46
+ },
47
+ {
48
+ "type": "code",
49
+ "check": "predecessor 41 chain includes new id (I1 reciprocity)",
50
+ "observed": "41.chain includes <new-id>",
51
+ "result": "pass"
52
+ }
53
+ ]
54
+ }
55
+ ]
56
+ }
@@ -36,13 +36,16 @@ Never modified, only appended.
36
36
 
37
37
  Tracks: stage transitions, decisions, shifts, blocks, cancellations, material for future intents.
38
38
  This is how execution is tracked. When this intent completes, Insights
39
- is where to look for what comes next. New intents spawned from Insights
40
- appear in the `chain` field.
39
+ is where to look for what comes next. New intents spawned from this one,
40
+ plus related-but-not-spawned successors it leads to, appear in the `chain`
41
+ field.
41
42
 
42
43
  ## `## Links`
43
44
 
44
- Wikilinks for Obsidian graph navigation. Human-facing counterpart to the
45
- frontmatter knowledge graph.
45
+ The human-readable projection of the local knowledge graph: all `sources`
46
+ first (top, named), then all `chain` (named), as `[[id]]` wikilinks plus a
47
+ short label. Counterpart to the frontmatter `sources` / `chain` edges, for
48
+ Obsidian graph navigation.
46
49
 
47
50
  ## Conventions — Filesystem as Schema
48
51
 
@@ -21,13 +21,13 @@ Explicit wikilinks in the `## Links` section. Bidirectional — add to both inte
21
21
  ```
22
22
 
23
23
  ### 2. Sources (Backward)
24
- The `sources` array in frontmatter. What influenced this intent backward links to parent/prior work:
24
+ The `sources` array in frontmatter. The direct ascendant(s) this intent was created from / emerged from the lifecycle of (formation, not topic similarity), backward links to the work it was built out of:
25
25
  ```yaml
26
26
  sources: ["1a", "1a2"]
27
27
  ```
28
28
 
29
29
  ### 3. Chain (Forward)
30
- The `chain` array in frontmatter. What this intent spawned forward links to children/follow-on work:
30
+ The `chain` array in frontmatter. What this intent spawned AND related-but-not-spawned successors it leads to, forward links to children, follow-on, and related work:
31
31
  ```yaml
32
32
  chain: ["1b1", "1b2"]
33
33
  ```
@@ -55,16 +55,21 @@ done
55
55
 
56
56
  ### 2. Choose Connection Type
57
57
  Ask the user which type of connection:
58
- - **source** "this was influenced by that" (add to `sources[]`, update `chain[]` on the target)
59
- - **cross-reference** "these are related" (add wikilink in `## Links` of both intents)
58
+ - **source**: "this was CREATED FROM that" (D1). The reciprocal update is one-directional (I1): add the ascendant id to this intent's `sources[]` AND add this intent's id to the ascendant's `chain[]`. A merely-related (not-created-from) connection is NOT a source: record it on the predecessor's `chain[]` only, plus a `## Links` wikilink, with NO `sources` (the related-but-not-spawned rule).
59
+ - **cross-reference**: "these are related" (add wikilink in `## Links` of both intents)
60
60
 
61
61
  ### 3. Apply Connection
62
62
 
63
- **For sources:**
64
- Update frontmatter arrays on both intents:
63
+ **For sources (a true created-from edge only):**
64
+ Update frontmatter arrays on both intents (I1, two-sided):
65
65
  - Add the parent's ID to the child's `sources` array
66
66
  - Add the child's ID to the parent's `chain` array
67
67
 
68
+ For the merely-related case, only the predecessor's `chain` (and both sides' `## Links`)
69
+ get the link, never `sources`. `chain` is NOT strictly the reverse of `sources` (I2):
70
+ relational `chain` entries are valid and must never be "corrected" by adding a reciprocal
71
+ `sources`.
72
+
68
73
  **For cross-references:**
69
74
  Add a wikilink in the `## Links` section of **both** intents (bidirectional).
70
75
 
@@ -20,9 +20,14 @@ IDs encode lineage using Luhmann's alternating convention:
20
20
 
21
21
  ## Knowledge Graph
22
22
 
23
- `sources` + `chain` form the double-linked knowledge graph:
24
- - `sources` = what fed into this intent (parents, inspirations, prerequisites)
25
- - `chain` = what this intent produced (children, follow-ups, spin-offs)
23
+ `sources` and `chain` form the directed knowledge graph:
24
+ - `sources` = the direct ascendant(s) this intent was created from / emerged from the
25
+ lifecycle of (formation, not topic similarity); a DAG (acyclic), strong must-load context.
26
+ - `chain` = forward continuations AND related-but-not-spawned successors it leads to; a
27
+ directed graph that may cycle, lighter contributory context.
28
+ - Reciprocity is one-directional: every `sources` edge has a reciprocal `chain` entry (I1),
29
+ but `chain` may carry relational entries with no reciprocal `sources` (I2), so the graph is
30
+ NOT strictly double-linked.
26
31
 
27
32
  ## Dual-Mode
28
33
 
@@ -9,7 +9,7 @@
9
9
  ## Three Connection Types (Ranked)
10
10
 
11
11
  1. **Direct links** (strongest) — wikilinks in `## Links` section
12
- 2. **Sources/Chain** (knowledge graph) `sources` array (backward), `chain` array (forward) in frontmatter
12
+ 2. **Sources/Chain** (knowledge graph): `sources` = direct ascendants this was created from (formation, acyclic, must-load); `chain` = forward continuations and related successors (may cycle, lighter context). See `docs/concepts/how-plastic-sources-and-chains-intents.md` for the full model.
13
13
  3. **Tags** (weakest) — shared tags, `project-<name>` for project membership
14
14
 
15
15
  ## When to Create a Cluster