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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
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.2",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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}]]")
@@ -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