forma-arch 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,6 +8,9 @@ them.
8
8
  Forma is the companion to `arbiter`: arbiter governs the process, Forma shows the system and
9
9
  guarantees the picture matches reality.
10
10
 
11
+ **[▶ Live demo](https://LucaDominici.github.io/forma/)** — forma's own architecture, generated by
12
+ forma from this repo on every push to `main`.
13
+
11
14
  ## Why
12
15
 
13
16
  A hand-drawn architecture diagram is stale the moment code changes. Forma walks your source for the
@@ -29,10 +32,29 @@ npx forma-arch <command> # or: npm i -D forma-arch
29
32
  | `forma check` | Deterministic drift check — **fails if the model no longer matches the code** |
30
33
  | `forma doc` | Project the arc42 scaffold (`ARCHITECTURE.scaffold.md`), or `--attach <file>` to inject a governed block into an existing doc |
31
34
  | `forma serve` | Open the live explorer at `http://localhost:4173` |
35
+ | `forma verify` | Refresh status from live GitHub issues through your `gh` CLI — the **only** networked command |
36
+
37
+ **Box text comes from your docs.** `gen` fills each box with the module's docstring (Python `"""…"""`, JS/TS leading block), else the directory `README.md`, else a mapped arc42 section — so the explorer shows meaning, not a list of symbols. On a flat directory of many `foo_*` files it also synthesizes a **component** layer, described from its children's docs (`--no-cluster` to disable; `--cluster-min <n>` = leaves before a container is clustered, default 8; `--group-min <n>` = files sharing a prefix before they become a component, default 3).
38
+
39
+ **Programme state is curated, not guessed.** Code shows what exists, never how far along it is. Drop a `docs/architecture/c4-status.json` (`--status <path>` to move it) and `gen` decorates nodes by id with `status2`, `completion`, `statusWord`, `current`, `target`, `verify`, `issues` — never `func`, which belongs to the docs. `gen` validates the *form* (ids resolve, fields known, enums and issue numbers well-shaped) and never the prose; `forma check` fails if the overlay decorates a node the model no longer has.
40
+
41
+ ```json
42
+ { "nodes": { "engine": { "status2": "in-progress", "completion": 60, "statusWord": "v2 in progress",
43
+ "current": "Live on ACA: RAG + citations. Hardening this week.",
44
+ "target": "Multi-surface substrate with client-ready output.",
45
+ "verify": { "source": "ADR-040 on main" }, "issues": ["#534"] } } }
46
+ ```
47
+
48
+ **Curated state, verified against reality.** `forma verify` asks your `gh` CLI for the state of every issue the model references (`--gh-repo owner/repo`, or `meta.ghRepo` in the topology), marks the nodes whose issues are closed as done, and prefixes their `current` with dated evidence. It touches state, never structure, and re-running it never stacks the evidence. It is opt-in and separate on purpose: `gen` and `check` never open a socket. In the served viewer, **RE-VERIFY** re-reads the model without losing your level, layout or mode.
32
49
 
33
- **Box text comes from your docs.** `gen` fills each box with the module's docstring (Python `"""…"""`, JS/TS leading block), else the directory `README.md`, else a mapped arc42 section so the explorer shows meaning, not a list of symbols. On a flat directory of many `foo_*` files it also synthesizes a **component** layer (`--no-cluster` to disable).
50
+ **One source of truth.** `forma doc --attach docs/architecture/arc42.md` injects the generated diagrams/tables between `<!-- forma:begin -->` / `<!-- forma:end -->` markers in your existing doc; your prose lives outside them, and `forma check` fails if that block drifts. Attached files are recorded in `source.attachedDocs`, so the gate governs **every** doc you attach — not just the model's `docPath`; deleting the markers (or the file) from a registered doc fails the check rather than quietly un-governing it. That registry lives in `c4-model.json`, so **commit the model** a lost model takes the registry with it. Where a repo lacks docs, `forma gen --enrich` can fill the remaining box holes with an LLM — opt-in, cached, never on the deterministic gate:
34
51
 
35
- **One source of truth.** `forma doc --attach docs/architecture/arc42.md` injects the generated diagrams/tables between `<!-- forma:begin -->` / `<!-- forma:end -->` markers in your existing doc; your prose lives outside them, and `forma check` fails if that block drifts. Where a repo lacks docs, `forma gen --enrich` can fill the remaining box holes with an LLM (opt-in, cached, never on the deterministic gate; `--enricher anthropic|openai|ollama`).
52
+ | `--enricher` | Use it when | Network |
53
+ |---|---|---|
54
+ | `agent` | **An agent is driving forma.** Writes `enrich-plan.json` with the holes; the agent writes the sentences (reading the sources if it wants) and `gen --enrich-apply <file>` applies them with the same cache and provenance. | none |
55
+ | `anthropic` (default) | Headless / CI, with `ANTHROPIC_API_KEY`. | REST |
56
+ | `openai` | Same, with `OPENAI_API_KEY`. | REST |
57
+ | `ollama` | Sensitive repos: a local model, nothing leaves the machine. | localhost |
36
58
 
37
59
  ## How it fits together
38
60
 
@@ -59,9 +81,14 @@ The viewer is a live C4 map, not a static picture:
59
81
 
60
82
  - **Click any box** to read its explanation — what it does, current state, target, verification source — at *every* level, from context down to a leaf.
61
83
  - **Double-click a box** (or its `[+] DRILL`) to descend into it; **BACK**, the breadcrumb, or `ESC` climb back out.
62
- - **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the auto-arrangement.
63
- - **Hover an arrow** to reveal its relationship label.
64
- - **PRINT / EXPORT** to SVG or PNG for docs and slides.
84
+ - **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the arrangement (your curated hints if the topology has them, the automatic one otherwise). To keep a layout, drag it, pick **Export layout JSON**, and paste the result under `"layout"` in the topology — `gen` carries it into `meta.layout` and the viewer pins those boxes, auto-arranging everything else clear of them.
85
+ - **Arrow labels** are painted on the diagram while the level stays readable (≤14 arrows) and turn off above that; **LABELS** forces them on or off, and hovering an arrow always reveals its label.
86
+ - **PRINT / EXPORT** to SVG or PNG for docs and slides — exported arrows carry their labels.
87
+ - The breadcrumb names the C4 level you are on (`C4-L1 · CONTEXT` → `C4-L3 · COMPONENTS`) and,
88
+ when the level carries curated state, tallies it: a dot per status with its count plus mean
89
+ completion, so a level reads as a programme board and not just a diagram.
90
+ - **Every level shrink-wraps its own content** — a context with four boxes renders zoomed and
91
+ dense instead of adrift in a fixed canvas.
65
92
 
66
93
  ## Skins
67
94
 
package/bin/forma.mjs CHANGED
@@ -12,7 +12,7 @@ const LIB = join(HERE, '..', 'lib')
12
12
  const cmd = process.argv[2]
13
13
  const rest = process.argv.slice(3)
14
14
 
15
- const MAP = { init: 'init.mjs', gen: 'gen.mjs', check: 'check.mjs', doc: 'doc.mjs', serve: 'serve.mjs' }
15
+ const MAP = { init: 'init.mjs', gen: 'gen.mjs', check: 'check.mjs', doc: 'doc.mjs', serve: 'serve.mjs', verify: 'verify.mjs' }
16
16
 
17
17
  if (cmd === '-v' || cmd === '--version') {
18
18
  const pkg = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf-8'))
@@ -28,6 +28,7 @@ Usage: forma <command> [--repo <path>]
28
28
  check deterministic drift check — fails if the model no longer matches the code
29
29
  doc project the arc42 scaffold (ARCHITECTURE.scaffold.md) from the model
30
30
  serve open the live explorer at http://localhost:4173
31
+ verify refresh status from live GitHub issues via your gh CLI (the only networked command)
31
32
 
32
33
  The file contract is lib/schema/c4-model.schema.json. Enrichment (curate the topology, write the
33
34
  arc42 prose) is model-agnostic — any agent edits the same JSON/Markdown.`)
package/lib/check.mjs CHANGED
@@ -75,17 +75,39 @@ for (const p of topo.plannedLeaves || []) if (p.sourceMustContain) {
75
75
 
76
76
  // 6) attach-mode doc-block freshness — if docPath carries forma markers, the region between them
77
77
  // must equal a freshly rendered block. Opt-in: no markers → nothing runs. No LLM, no network.
78
- const docRel = model.source && model.source.docPath
79
- if (docRel && existsSync(rp(docRel))) {
78
+ // Governs docPath AND every doc registered by `forma doc --attach` (source.attachedDocs): a block
79
+ // injected into a file no gate looks at is the false-green this whole command exists to kill.
80
+ const src = model.source || {}
81
+ const registered = new Set(src.attachedDocs || []) // registry membership PROVES a block was injected
82
+ const governed = [...new Set([src.docPath, ...registered].filter(Boolean))]
83
+ const deregister = 'remove it from source.attachedDocs if the block is gone for good'
84
+ for (const docRel of governed) {
85
+ if (!existsSync(rp(docRel))) {
86
+ // docPath may simply never have been attached; a REGISTERED doc that vanished is drift.
87
+ if (registered.has(docRel)) fail(`DOC BLOCK: ${docRel} is registered in source.attachedDocs but the file is gone — ${deregister}.`)
88
+ continue
89
+ }
80
90
  const text = readFileSync(rp(docRel), 'utf-8')
81
91
  const inner = extractBetween(text)
82
92
  if (inner != null) {
83
93
  if (norm(inner) !== norm(renderBlock(model, { repo: REPO }))) fail(`DOC BLOCK DRIFT: ${docRel} forma block is stale — run \`forma doc --attach\`.`)
84
94
  } else if (text.includes('<!-- forma:begin') || text.includes('<!-- forma:end')) {
85
95
  fail(`DOC BLOCK: malformed forma markers in ${docRel} (begin/end mismatch).`)
96
+ } else if (registered.has(docRel)) {
97
+ // Both markers deleted: the generated block stopped being governed while its (now frozen)
98
+ // text keeps shipping to readers. Exactly the false green attachedDocs exists to prevent.
99
+ fail(`DOC BLOCK: ${docRel} is registered in source.attachedDocs but has no forma markers — re-run \`forma doc --attach\`, or ${deregister}.`)
86
100
  }
87
101
  }
88
102
 
103
+ // 7) the status overlay decorates nodes by id: an id that no longer resolves is drift (the node was
104
+ // renamed or deleted and the curated state was left behind). Form only — never the prose itself.
105
+ if (src.statusPath && existsSync(rp(src.statusPath))) {
106
+ let ov = null
107
+ try { ov = JSON.parse(readFileSync(rp(src.statusPath), 'utf-8')) } catch (e) { fail(`status overlay: ${src.statusPath} is not valid JSON: ${(e && e.message) || e}`) }
108
+ if (ov) for (const id of Object.keys(ov.nodes || {})) if (!byId.has(id)) fail(`status overlay: "${id}" is not a node in the model — ${src.statusPath} is stale.`)
109
+ }
110
+
89
111
  // SOFT: LLM-enriched prose whose inputs changed is only a reminder, never a gate failure (structure
90
112
  // is the gate; enrichment freshness is advisory). check never calls the LLM — it recomputes the hash.
91
113
  for (const n of model.nodes || []) {
package/lib/doc.mjs CHANGED
@@ -83,7 +83,18 @@ if (attachIdx > -1) {
83
83
  catch { console.error('[forma doc] --attach: malformed forma markers in ' + relative(REPO, target) + ' — fix by hand'); process.exit(1) }
84
84
  mkdirSync(join(target, '..'), { recursive: true })
85
85
  writeFileSync(target, out)
86
- console.log(`[forma doc] spliced generated block into ${relative(REPO, target)} — ${containers.length} container(s); prose outside the markers untouched.`)
86
+ const rel = relative(REPO, target)
87
+ console.log(`[forma doc] spliced generated block into ${rel} — ${containers.length} container(s); prose outside the markers untouched.`)
88
+ // Register the target so `forma check` governs THIS file too, not just source.docPath — an
89
+ // injected block nobody gates is exactly the false-green forma exists to kill. `gen` carries
90
+ // the registry forward across regens.
91
+ const known = m.source.attachedDocs || []
92
+ if (rel !== m.source.docPath && !known.includes(rel)) {
93
+ m.source.attachedDocs = [...known, rel].sort()
94
+ writeFileSync(MODEL, JSON.stringify(m, null, 2) + '\n')
95
+ console.log(`[forma doc] registered ${rel} in ${relative(REPO, MODEL)} — \`forma check\` now governs its block.`)
96
+ console.log('[forma doc] commit the model: the registry lives in it, and a lost model un-governs the doc.')
97
+ }
87
98
  process.exit(0)
88
99
  }
89
100
  writeFileSync(OUT, md)
package/lib/enrich.mjs CHANGED
Binary file
package/lib/gen.mjs CHANGED
@@ -8,23 +8,29 @@ import { execSync } from 'node:child_process'
8
8
  import { fileURLToPath } from 'node:url'
9
9
  import { containerOf, componentsFor } from './cluster.mjs'
10
10
  import { makeDescribeCtx, resolveDescription } from './describe.mjs'
11
- import { loadCache, mergeCache, enrich } from './enrich.mjs'
11
+ import { loadCache, mergeCache, enrich, agentPlan, applyFills } from './enrich.mjs'
12
12
 
13
13
  const HERE = dirname(fileURLToPath(import.meta.url))
14
14
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
15
15
  const REPO = arg('--repo', process.cwd())
16
16
  const TOPO = arg('--topology', join(REPO, 'docs/architecture/c4-topology.json'))
17
17
  const OUT = arg('--out', join(REPO, 'docs/architecture/c4-model.json'))
18
- const SCHEMA_VERSION = '1.2.0'
18
+ const SCHEMA_VERSION = '1.3.0'
19
19
  const CLUSTER = !process.argv.includes('--no-cluster') // §2: auto-cluster flat containers; --no-cluster to disable
20
20
  const ENRICH = process.argv.includes('--enrich') // §7: opt-in LLM prose for description holes
21
21
  const ENRICHER = arg('--enricher', 'anthropic')
22
22
  const ENRICH_MODEL = arg('--enrich-model', null)
23
- const CLUSTER_MIN = 8, GROUP_MIN = 3
23
+ const STATUS = arg('--status', join(REPO, 'docs/architecture/c4-status.json')) // curated programme state, optional
24
+ const STATUS_SET = process.argv.includes('--status')
25
+ const APPLY = arg('--enrich-apply', null) // prose written by the agent driving forma
24
26
 
25
27
  const topo = JSON.parse(readFileSync(TOPO, 'utf-8'))
26
28
  const rp = (p) => join(REPO, p)
27
29
  const fail = (m) => { console.error('[gen-c4] FAIL: ' + m); process.exit(1) }
30
+ // §2 thresholds, overridable. Guarded: a non-integer would silently disable clustering
31
+ // (`length >= NaN` is false for every group), so a bad value fails loud instead.
32
+ const posInt = (flag, dflt) => { const v = Number(arg(flag, dflt)); if (!Number.isInteger(v) || v < 1) fail(`${flag}: expected a positive integer, got "${arg(flag, dflt)}"`); return v }
33
+ const CLUSTER_MIN = posInt('--cluster-min', 8), GROUP_MIN = posInt('--group-min', 3)
28
34
 
29
35
  function walk(spec) {
30
36
  const dir = rp(spec.dir)
@@ -114,7 +120,9 @@ for (const n of nodes) {
114
120
  if (!n.category) n.category = n.kind === 'leaf' ? (par && par.category) || 'leaf' : n.kind
115
121
  if (!n.status2) n.status2 = n.status === 'planned' ? 'planned' : 'done'
116
122
  if (n.completion == null) n.completion = n.status === 'planned' ? 0 : 100
117
- if (!n.current) { const pth = (n.evidence || []).find((e) => e.type === 'path'); n.current = pth ? `Exists: ${pth.ref}` : (n.tech || '') }
123
+ // `current` is left EMPTY unless curated (topology or status overlay): the viewer falls back to
124
+ // `func`, which since §1a carries the module's real documentation. The old "Exists: <path>"
125
+ // filler restated the evidence path in the one field meant for programme facts.
118
126
  if (n.target == null) n.target = ''
119
127
  }
120
128
 
@@ -123,12 +131,74 @@ for (const n of nodes) {
123
131
  const dctx = makeDescribeCtx({ repo: REPO, byId, descriptions: topo.descriptions || {}, docPath: topo.docPath, containerOf })
124
132
  for (const n of nodes) { const r = resolveDescription(n, dctx); n.func = r.func; n.descSource = r.descSource }
125
133
 
134
+ // §1a-bis) a synthesized component has no doc of its own: before settling for "Groups related
135
+ // files under X", compose its box from its children's docs (first sentence of up to 3, ordered by
136
+ // name — deterministic). descSource stays 'fallback': this is a heuristic, and --enrich may still
137
+ // improve it. Separate pass so it does not depend on components being added after their leaves.
138
+ const firstSentence = (s) => (String(s).match(/^[^.!?]*[.!?]/) || [String(s)])[0].trim()
139
+ for (const n of nodes) {
140
+ if (n.kind !== 'component' || n.descSource !== 'fallback') continue
141
+ const kids = nodes.filter((k) => k.parent === n.id && ['curated', 'docstring', 'readme'].includes(k.descSource))
142
+ .sort((a, b) => (String(a.name) < String(b.name) ? -1 : String(a.name) > String(b.name) ? 1 : 0)).slice(0, 3)
143
+ const txt = kids.map((k) => firstSentence(k.func)).filter(Boolean).join(' ')
144
+ if (txt) n.func = txt.length > 200 ? txt.slice(0, 199) + '…' : txt
145
+ }
146
+
147
+ // §WP-A1) programme-status overlay: the one channel for curated STATE (what is true now, where it
148
+ // must land, what proves it). Code can't know it, so a human/agent writes it in c4-status.json and
149
+ // gen validates only its FORM — ids resolve, fields are known, enums/ranges hold. Never `func`:
150
+ // what a module DOES comes from its docs (§1a), what it's WORTH is programme state.
151
+ const STATUS_FIELDS = new Set(['status2', 'completion', 'statusWord', 'current', 'target', 'verify', 'issues'])
152
+ const STATUS2 = new Set(['done', 'in-progress', 'next', 'planned', 'problem'])
153
+ let statusPath = null
154
+ if (existsSync(STATUS) || STATUS_SET) {
155
+ if (!existsSync(STATUS)) fail(`--status: file missing: ${STATUS}`)
156
+ let ov
157
+ try { ov = JSON.parse(readFileSync(STATUS, 'utf-8')) } catch (e) { fail(`--status: ${STATUS} is not valid JSON: ${(e && e.message) || e}`) }
158
+ let decorated = 0
159
+ for (const [id, patch] of Object.entries(ov.nodes || {})) {
160
+ const target = byId.get(id)
161
+ if (!target) fail(`status overlay: unknown node id "${id}" — it is not in the model (stale overlay?)`)
162
+ for (const [k, v] of Object.entries(patch)) {
163
+ if (k.startsWith('$')) continue // $comment and friends
164
+ if (!STATUS_FIELDS.has(k)) fail(`status overlay: "${id}" has field "${k}"; allowed: ${[...STATUS_FIELDS].join(', ')}${k === 'func' ? ' (func comes from the docs, not the overlay)' : ''}`)
165
+ if (k === 'status2' && !STATUS2.has(v)) fail(`status overlay: "${id}" status2 "${v}" — expected one of ${[...STATUS2].join('|')}`)
166
+ if (k === 'completion' && !(Number.isInteger(v) && v >= 0 && v <= 100)) fail(`status overlay: "${id}" completion must be an integer 0-100, got ${JSON.stringify(v)}`)
167
+ if (k === 'issues') {
168
+ if (!Array.isArray(v)) fail(`status overlay: "${id}" issues must be an array`)
169
+ for (const is of v) if (!/^#?\d+$/.test(String(is))) fail(`status overlay: "${id}" issue ${JSON.stringify(is)} — expected "#123"`)
170
+ }
171
+ target[k] = v
172
+ }
173
+ decorated++
174
+ }
175
+ statusPath = relative(REPO, STATUS)
176
+ console.log(`[gen-c4] status overlay: ${decorated} node(s) decorated from ${statusPath}`)
177
+ }
178
+
126
179
  // §7) enrichment (prose only, never structure/gate). Cache-merge ALWAYS: preserve prior LLM prose
127
180
  // whose inputs are unchanged, so a plain regen doesn't discard it. --enrich additionally fills the
128
181
  // remaining holes over the network (best-effort — a failure keeps the deterministic fallback).
129
182
  const ectx = { repo: REPO, byId, containerOf }
130
- mergeCache(nodes, ectx, loadCache(OUT))
131
- if (ENRICH) {
183
+ mergeCache(nodes, loadCache(OUT))
184
+ // --enrich-apply: prose written by the agent that drives forma (see --enricher agent below).
185
+ // Independent of --enrich, and applied first — an explicit fill outranks anything generated.
186
+ if (APPLY) {
187
+ if (!existsSync(APPLY)) fail(`--enrich-apply: file missing: ${APPLY}`)
188
+ let fills
189
+ try { fills = JSON.parse(readFileSync(APPLY, 'utf-8')).fills } catch (e) { fail(`--enrich-apply: ${APPLY} is not valid JSON: ${(e && e.message) || e}`) }
190
+ if (!Array.isArray(fills)) fail(`--enrich-apply: ${APPLY} must contain {"fills":[{"id","func"}]}`)
191
+ try { console.log(`[gen-c4] --enrich-apply: ${applyFills(nodes, ectx, fills)} fill(s) applied`) }
192
+ catch (e) { fail(String((e && e.message) || e)) }
193
+ }
194
+ if (ENRICH && ENRICHER === 'agent') {
195
+ // No network: emit the work for the agent already in the session, keep the deterministic model.
196
+ const plan = agentPlan(nodes, ectx)
197
+ const planPath = join(dirname(OUT), 'enrich-plan.json')
198
+ writeFileSync(planPath, JSON.stringify({ entries: plan }, null, 2) + '\n')
199
+ console.log(`[gen-c4] --enricher agent: ${plan.length} hole(s) → ${relative(REPO, planPath)} — write the prose yourself, then:`)
200
+ console.log(`[gen-c4] forma gen --enrich-apply ${relative(REPO, join(dirname(OUT), 'enrich-fill.json'))}`)
201
+ } else if (ENRICH) {
132
202
  try { const r = await enrich(nodes, ectx, { provider: ENRICHER, model: ENRICH_MODEL }); console.log(`[gen-c4] --enrich (${ENRICHER}): filled ${r.filled}/${r.holes} description hole(s)`) }
133
203
  catch (e) { console.error('[gen-c4] --enrich skipped: ' + String((e && e.message) || e)) }
134
204
  }
@@ -173,11 +243,26 @@ const gitOpts = { cwd: REPO, stdio: ['ignore', 'pipe', 'ignore'] }
173
243
  try { commit = execSync('git rev-parse HEAD', gitOpts).toString().trim() } catch {}
174
244
  try { branch = execSync('git rev-parse --abbrev-ref HEAD', gitOpts).toString().trim() } catch {}
175
245
 
246
+ // §1b) carry the attach registry forward from the prior model — `forma doc --attach` writes it
247
+ // there, and without this a plain regen would drop it and un-govern the attached docs.
248
+ // The registry lives ONLY in the generated model, so that model has to be committed (or at least
249
+ // kept) for the gate to keep governing. If a prior model is there but unreadable, say so rather
250
+ // than silently ungoverning every attached doc.
251
+ let attachedDocs = []
252
+ if (existsSync(OUT)) {
253
+ try { attachedDocs = JSON.parse(readFileSync(OUT, 'utf-8')).source.attachedDocs || [] }
254
+ catch (e) { console.error(`[gen-c4] WARNING: ${OUT} is unreadable (${(e && e.message) || e}) — any source.attachedDocs registry in it is lost; re-run \`forma doc --attach\` for each attached doc.`) }
255
+ }
256
+
257
+ // generatedAt is the ONE per-run volatile field (a plain `gen` x2 must diff on nothing else).
258
+ // meta.verifiedAt is reserved for `forma verify` (network, opt-in) — gen never writes it.
176
259
  const model = {
177
260
  schemaVersion: SCHEMA_VERSION,
178
261
  generatedAt: new Date().toISOString(),
179
- source: { repo: topo.meta.repo, commit, branch, docPath: topo.docPath, generator: 'gen-c4-model@' + SCHEMA_VERSION },
180
- meta: { ...topo.meta, verifiedAt: new Date().toISOString(), verifyMethod: 'code+topology (gh re-verify optional)' },
262
+ source: { repo: topo.meta.repo, commit, branch, docPath: topo.docPath, generator: 'gen-c4-model@' + SCHEMA_VERSION, ...(attachedDocs.length ? { attachedDocs } : {}), ...(statusPath ? { statusPath } : {}) },
263
+ // topo.layout (optional curated coordinates, keyed by parent id) rides along verbatim: the
264
+ // viewer pins those nodes and auto-arranges the rest. `meta` is the model's free-form area.
265
+ meta: { ...topo.meta, ...(topo.layout ? { layout: topo.layout } : {}), verifyMethod: 'code+topology (gh re-verify optional)' },
181
266
  levels: topo.levels,
182
267
  nodes,
183
268
  edges: topo.edges.map((e) => ({ status: 'current', estatus: e.estatus || 'active', ...e })),
package/lib/init.mjs CHANGED
@@ -22,13 +22,16 @@ const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g
22
22
 
23
23
  if (existsSync(OUT) && !FORCE) { console.error(`[forma init] ${relative(REPO, OUT)} already exists — use --force to reseed.`); process.exit(1) }
24
24
 
25
- // 1) detect dominant language by extension count (skipping ignored dirs)
25
+ // 1) detect dominant language by extension count. Skips the SAME dirs the container pass skips
26
+ // (build junk AND data/fixture/doc dirs): counting files that can never become a container is how
27
+ // a repo full of Python fixtures gets detected as a Python repo and then finds no source dir.
26
28
  const counts = {}
27
29
  ;(function walk(dir, depth) {
28
30
  if (depth > 12) return
29
31
  let ents; try { ents = readdirSync(dir) } catch { return }
30
32
  for (const e of ents) {
31
33
  if (IGNORE.has(e) || e.startsWith('.')) continue
34
+ if (SKIP_DIRS.has(e.toLowerCase()) && !KEEP.has(e.toLowerCase())) continue
32
35
  const p = join(dir, e)
33
36
  let st; try { st = statSync(p) } catch { continue }
34
37
  if (st.isDirectory()) walk(p, depth + 1)
@@ -86,7 +89,7 @@ const skipped = []
86
89
  // loose files directly in root → an entry container
87
90
  if (hasSrc(root)) { nodes.push({ id: 'app', level: 'container', kind: 'container', parent: sysId, name: 'app', tech: lang, description: 'Entry point / loose top-level sources.' }); leafSources.push({ parent: 'app', dir: relative(REPO, root) || '.', match: matchRe }) }
88
91
 
89
- if (leafSources.length === 0) { console.error('[forma init] found a source root but no directory with source files directly in it. Add leafSources manually.'); process.exit(1) }
92
+ if (leafSources.length === 0) { console.error(`[forma init] detected ${lang} (*.${ext[0]}) as the dominant language, but no directory under ${relative(REPO, root) || '.'}/ holds *.${ext[0]} files directly. Pass --include <dir,...> if the sources live in a skipped dir, or add leafSources manually.`); process.exit(1) }
90
93
 
91
94
  const topo = {
92
95
  meta: { repo: basename(REPO), stack: lang, seededBy: 'forma init' },
@@ -45,6 +45,17 @@
45
45
  "type": "string",
46
46
  "description": "The arc42 architecture doc this model derives from."
47
47
  },
48
+ "statusPath": {
49
+ "type": "string",
50
+ "description": "Repo-relative curated programme-status overlay applied by `forma gen` (c4-status.json). The gate fails if it decorates a node id the model no longer has."
51
+ },
52
+ "attachedDocs": {
53
+ "type": "array",
54
+ "items": {
55
+ "type": "string"
56
+ },
57
+ "description": "Repo-relative docs carrying an injected forma block (written by `forma doc --attach`). The drift gate governs every one of them, not just docPath."
58
+ },
48
59
  "generator": {
49
60
  "type": "string"
50
61
  }
package/lib/verify.mjs ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ // forma verify — refresh programme status from LIVE GitHub issues, through the user's `gh` CLI.
3
+ // This is the ONLY command that touches the network, and it is opt-in: `gen` and `check` stay
4
+ // deterministic and offline forever (that is the product). It never touches structure — no nodes,
5
+ // no edges, no func — only the state fields of nodes that reference an issue.
6
+ // Usage: node verify.mjs [--repo <path>] [--model <path>] [--gh-repo <owner/repo>] [--gh-cmd <cmd>]
7
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs'
8
+ import { execFileSync } from 'node:child_process'
9
+ import { join } from 'node:path'
10
+
11
+ const arg = (f, d) => { const i = process.argv.indexOf(f); return i > -1 ? process.argv[i + 1] : d }
12
+ const REPO = arg('--repo', process.cwd())
13
+ const MODEL = arg('--model', join(REPO, 'docs/architecture/c4-model.json'))
14
+ const GH_CMD = arg('--gh-cmd', 'gh') // split on whitespace; the tests point it at a stub
15
+ const LIMIT = arg('--limit', '250')
16
+ const fail = (m) => { console.error('[forma verify] ' + m); process.exit(1) }
17
+
18
+ if (!existsSync(MODEL)) fail(`model missing: ${MODEL} — run \`forma gen\` first.`)
19
+ const model = JSON.parse(readFileSync(MODEL, 'utf-8'))
20
+ const GH_REPO = arg('--gh-repo', (model.meta && model.meta.ghRepo) || null)
21
+ if (!GH_REPO) fail('no target repo: pass --gh-repo <owner/repo>, or set meta.ghRepo in the topology.')
22
+
23
+ // Which nodes claim which issue — from issues[] and from verify.issue.
24
+ const num = (v) => { const m = /^#?(\d+)$/.exec(String(v)); return m ? Number(m[1]) : null }
25
+ const refs = new Map()
26
+ for (const n of model.nodes || []) {
27
+ const claimed = [...(n.issues || []), ...(n.verify && n.verify.issue != null ? [n.verify.issue] : [])]
28
+ for (const raw of claimed) {
29
+ const i = num(raw); if (!i) continue
30
+ if (!refs.has(i)) refs.set(i, [])
31
+ refs.get(i).push(n)
32
+ }
33
+ }
34
+ if (!refs.size) { console.log('[forma verify] no issue references in the model — nothing to verify (add issues[] via the status overlay).'); process.exit(0) }
35
+
36
+ // ONE call for every issue: state for the whole repo, then match locally.
37
+ const parts = String(GH_CMD).split(/\s+/).filter(Boolean)
38
+ let raw
39
+ try {
40
+ raw = execFileSync(parts[0], [...parts.slice(1), 'issue', 'list', '--repo', GH_REPO, '--state', 'all', '--limit', String(LIMIT), '--json', 'number,state'],
41
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] })
42
+ } catch (e) {
43
+ const why = (e && e.code === 'ENOENT') ? `\`${parts[0]}\` not found — install the GitHub CLI or pass --gh-cmd` : String((e && (e.stderr || e.message)) || e).trim()
44
+ console.error(`[forma verify] ${why}`)
45
+ fail('model left untouched.')
46
+ }
47
+ let issues
48
+ try { issues = JSON.parse(raw) } catch { fail(`unexpected output from \`${parts[0]}\` (wanted JSON): ${String(raw).slice(0, 160)}`) }
49
+ if (!Array.isArray(issues)) fail('unexpected output: expected a JSON array of {number,state}.')
50
+
51
+ const ts = new Date().toISOString()
52
+ const seen = new Set()
53
+ let closed = 0, decorated = 0
54
+ for (const it of issues) {
55
+ const n = Number(it.number); seen.add(n)
56
+ if (String(it.state || '').toUpperCase() !== 'CLOSED') continue
57
+ const nodes = refs.get(n); if (!nodes) continue
58
+ closed++
59
+ for (const node of nodes) {
60
+ node.status2 = 'done'; node.completion = 100
61
+ // The badge shows statusWord when there is one, so a curated "NEXT"/"50%" would survive the
62
+ // node turning green — a box claiming both at once. Marking done owns the badge too.
63
+ if (node.statusWord) node.statusWord = '100%'
64
+ const mark = `(#${n} CLOSED` // re-running must not stack prefixes
65
+ if (!String(node.current || '').includes(mark)) {
66
+ node.current = `Closed with evidence ${mark}, gh ${ts}). ${String(node.current || '').trim()}`.trim()
67
+ decorated++
68
+ }
69
+ }
70
+ }
71
+ const unknown = [...refs.keys()].filter((n) => !seen.has(n))
72
+
73
+ model.meta = model.meta || {}
74
+ model.meta.verifiedAt = ts // the ONE field only verify writes — gen must stay single-volatile (R2)
75
+ model.meta.verifyMethod = 'gh live'
76
+ writeFileSync(MODEL, JSON.stringify(model, null, 2) + '\n')
77
+ console.log(`[forma verify] ${GH_REPO}: ${refs.size} referenced issue(s), ${closed} closed → ${decorated} node(s) marked done`)
78
+ if (unknown.length) console.log(`[forma verify] not returned by gh (closed long ago, wrong repo, or beyond --limit ${LIMIT}): ${unknown.map((n) => '#' + n).join(', ')}`)
79
+ console.log(`[forma verify] fact base stamped ${ts} — structure untouched, \`forma check\` unaffected.`)
@@ -29,6 +29,9 @@ button.on{background:rgba(62,231,255,.18);color:var(--cyan);border-color:var(--c
29
29
  #back[disabled]{opacity:.28;cursor:default;border-color:var(--border);color:var(--ter)}
30
30
  select#skin option{background:#08202e;color:var(--nm)}
31
31
  #crumb{font-size:11px;color:var(--sec);margin:8px 0}
32
+ #crumb .agg{float:right;color:var(--ter2);letter-spacing:.05em}
33
+ #crumb .agg i{display:inline-block;width:7px;height:7px;border-radius:50%;margin:0 3px 0 8px;vertical-align:baseline}
34
+ #crumb .agg b{color:var(--nm)}
32
35
  #crumb b{color:var(--cyan)}
33
36
  #crumb a{color:#8fd8ec;cursor:pointer}
34
37
  #crumb a:hover{text-decoration:underline}
@@ -64,6 +67,8 @@ svg{width:100%;height:100%;display:block}
64
67
  .edge.plan{stroke:var(--edgeplan);stroke-dasharray:3 6;opacity:.6;marker-end:url(#arp)}
65
68
  .edge.hot{stroke:var(--cyan);stroke-width:2.6;opacity:1}
66
69
  .edgehit{stroke:transparent;stroke-width:14;fill:none;cursor:help}
70
+ .elbl{font-size:8.2px;fill:var(--elbl);letter-spacing:.04em;pointer-events:none;
71
+ paint-order:stroke;stroke:var(--stagebg);stroke-width:3px;stroke-linejoin:round}
67
72
  @keyframes dash{to{stroke-dashoffset:-24}}
68
73
  .nd.pulse rect{animation:pul 2.4s ease-in-out infinite}
69
74
  @keyframes pul{0%,100%{filter:drop-shadow(0 0 8px rgba(62,231,255,.5))}50%{filter:drop-shadow(0 0 18px rgba(62,231,255,.95))}}
@@ -132,12 +137,14 @@ html[data-skin="blueprint"] button.on{text-shadow:none}
132
137
  <button id="bnow" class="on" type="button"></button>
133
138
  <button id="btgt" type="button"></button>
134
139
  <button id="brf" type="button"></button>
140
+ <button id="blbl" type="button"></button>
135
141
  <button id="brl" type="button"></button>
136
142
  <span class="menuwrap">
137
143
  <button id="pexp" type="button" aria-haspopup="true" aria-expanded="false"></button>
138
144
  <span id="pmenu" role="menu">
139
145
  <button id="pexpSvg" type="button" role="menuitem"></button>
140
146
  <button id="pexpPng" type="button" role="menuitem"></button>
147
+ <button id="pexpLayout" type="button" role="menuitem"></button>
141
148
  <button id="pexpPrint" type="button" role="menuitem"></button>
142
149
  </span>
143
150
  </span>
@@ -165,17 +172,18 @@ var STRINGS={
165
172
  en:{
166
173
  subhelp:"click = explain (any level) · double-click or [+] = drill in · drag to move · hover an arrow = label · ESC/BACK",
167
174
  back:"← BACK", now:"CURRENT STATE", tgt:"TARGET STATE", reverify:"RE-VERIFY",
175
+ labels:"LABELS", labelsTitle:"arrow labels (auto-off above 14 arrows)",
168
176
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"re-arrange",
169
- printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", print:"Print",
170
- legDone:"DONE", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
177
+ printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
178
+ legDone:"DONE (proven)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
171
179
  legHint:"— animated arrow = active flow · dashed = to build",
172
180
  crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
173
181
  lvlNames:{context:"Context",container:"Container",component:"Components",leaf:"Leaves"},
174
182
  detClose:"[x] close", detFunc:"What it does", detNow:"Current state (facts)",
175
183
  detTgt:"Target state (project)", detSrc:"Verification source", detIssue:"Issue",
176
184
  detTarget:"TARGET",
177
- rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match",
178
- stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology",
185
+ rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match", aggProgress:"progress",
186
+ stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology", staticSrc:"(static source)",
179
187
  errEmpty:"Model unreachable or empty.",
180
188
  errNotFound:"Model not found (./c4-model.json). Open via `forma serve` or inject window.__C4_MODEL__.",
181
189
  errUnavail:"Model unavailable."
@@ -183,17 +191,18 @@ var STRINGS={
183
191
  it:{
184
192
  subhelp:"click = spiega (ogni livello) · doppio-click o [+] = entra · trascina per spostare · hover su una freccia = etichetta · ESC/BACK",
185
193
  back:"← BACK", now:"STATO ATTUALE", tgt:"STATO FINITO", reverify:"RE-VERIFICA",
194
+ labels:"ETICHETTE", labelsTitle:"etichette sugli archi (spente sopra i 14 archi)",
186
195
  resetLayout:"RESET LAYOUT", resetLayoutTitle:"ridisponi",
187
- printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", print:"Stampa",
188
- legDone:"FATTO", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
196
+ printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
197
+ legDone:"FATTO (provato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
189
198
  legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
190
199
  crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
191
200
  lvlNames:{context:"Contesto",container:"Container",component:"Componenti",leaf:"Foglie"},
192
201
  detClose:"[x] chiudi", detFunc:"Cosa fa", detNow:"Stato attuale (fatti)",
193
202
  detTgt:"Stato finito (progetto)", detSrc:"Fonte verifica", detIssue:"Issue",
194
203
  detTarget:"TARGET",
195
- rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato",
196
- stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology",
204
+ rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato", aggProgress:"avanzamento",
205
+ stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology", staticSrc:"(fonte statica)",
197
206
  errEmpty:"Modello non raggiungibile o vuoto.",
198
207
  errNotFound:"Modello non trovato (./c4-model.json). Apri via `forma serve` o inietta window.__C4_MODEL__.",
199
208
  errUnavail:"Modello non disponibile."
@@ -207,13 +216,19 @@ function pickLang(model){
207
216
  return STRINGS[cand]?cand:"en";
208
217
  }
209
218
  var CATALOG_THRESHOLD=24; // group of same-category siblings above this collapses to one catalogue node
219
+ var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed labels would knot the view
220
+ var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
210
221
  var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
211
222
  var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob"};
212
- var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={};
223
+ var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
224
+ var VBOX={}; // last fitted viewBox per level — frozen while dragging so the canvas does not rescale
213
225
  function $(i){return document.getElementById(i)}
214
226
  function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/"/g,"&quot;")}
215
227
  function setSkin(s){var k=SKIN_IDS.indexOf(s)>-1?s:"holo";document.documentElement.dataset.skin=k;var el=$("skin");if(el)el.value=k;}
216
- function keyOf(){return stack.join(">")||"root";}
228
+ // Layout key = the id of the level's parent ("root" at the top). Stable and documentable: it is
229
+ // exactly the key used by meta.layout hints and by the exported layout JSON.
230
+ function keyOf(){return stack[stack.length-1]||"root";}
231
+ function hintsFor(k){return (M&&M.meta&&M.meta.layout&&M.meta.layout[k])||null;}
217
232
  function childrenOf(pid){var o=[];for(var i=0;i<M.nodes.length;i++){var n=M.nodes[i];if((pid==null&&!n.parent)||n.parent===pid)o.push(n);}return o;}
218
233
  // FEATURE #4: collapse large homogeneous catalogs.
219
234
  // Given real children, returns render items where any group of >CATALOG_THRESHOLD
@@ -260,8 +275,25 @@ function nameOf(id){var n=node(id);return n?n.name:id;}
260
275
  function srcOf(n){if(n.verify){if(typeof n.verify==="string")return n.verify;if(n.verify.source)return n.verify.source;if(n.verify.ref)return n.verify.ref;if(n.verify.issue)return "#"+n.verify.issue;}
261
276
  var ev=(n.evidence||[]).filter(function(e){return e.type==="doc"||e.type==="path";})[0];return ev?ev.ref:((M.meta&&M.meta.verifyMethod)||"");}
262
277
 
263
- function seedLayout(kids){
264
- var NW=228,NH=106,pos={},i,hub=null;
278
+ var NW=228,NH=118; // NH fits 3 description lines without hitting the [+] DRILL
279
+ // Curated coordinates from meta.layout win; everything else keeps the automatic arrangement,
280
+ // placed BELOW the pinned block so a partial hint set can never overlap generated nodes.
281
+ function seedLayout(kids,hints){
282
+ var pinned=[],free=[],i;
283
+ for(i=0;i<kids.length;i++)(hints&&hints[kids[i].id]?pinned:free).push(kids[i]);
284
+ if(!pinned.length)return autoLayout(kids);
285
+ var pos={},maxX=0,maxY=0,h;
286
+ for(i=0;i<pinned.length;i++){var q=hints[pinned[i].id];
287
+ h={x:+q.x||0,y:+q.y||0,w:+q.w||NW,h:+q.h||NH};pos[pinned[i].id]=h;
288
+ maxX=Math.max(maxX,h.x+h.w);maxY=Math.max(maxY,h.y+h.h);}
289
+ var W=Math.max(1020,maxX+64),H=Math.max(560,maxY+58);
290
+ if(free.length){var sub=autoLayout(free),oy=maxY+58;
291
+ for(i=0;i<free.length;i++){var p=sub.pos[free[i].id];pos[free[i].id]={x:p.x,y:oy+p.y,w:p.w,h:p.h};}
292
+ W=Math.max(W,sub.W);H=Math.max(H,oy+sub.H);}
293
+ return {W:W,H:H,pos:pos};
294
+ }
295
+ function autoLayout(kids){
296
+ var pos={},i,hub=null;
265
297
  for(i=0;i<kids.length;i++)if(kids[i].kind==="system"){hub=kids[i];break;}
266
298
  if(hub&&kids.length<=10){
267
299
  var W=1080,H=660,cx=W/2,cy=H/2;
@@ -280,9 +312,9 @@ function seedLayout(kids){
280
312
  pos[kids[i].id]={x:offx+c*(NW+gx),y:py+r*(NH+gy),w:NW,h:NH};}
281
313
  return {W:W,H:H,pos:pos};
282
314
  }
283
- function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids);
315
+ function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids,hintsFor(k));
284
316
  // make sure every current kid has a slot (model may have changed)
285
- var lay=POS[k];for(var i=0;i<kids.length;i++)if(!lay.pos[kids[i].id]){var s=seedLayout(kids);POS[k]=s;return s;}return lay;}
317
+ var lay=POS[k];for(var i=0;i<kids.length;i++)if(!lay.pos[kids[i].id]){var s=seedLayout(kids,hintsFor(k));POS[k]=s;return s;}return lay;}
286
318
 
287
319
  function draw(animate){
288
320
  if(!M)return;
@@ -297,14 +329,45 @@ function draw(animate){
297
329
  function mapId(id){return memberMap[id]||id;}
298
330
  var edges=(M.edges||[]).map(function(e){return {from:mapId(e.from),to:mapId(e.to),label:e.label,estatus:e.estatus};})
299
331
  .filter(function(e){return idset[e.from]&&idset[e.to]&&e.from!==e.to;});
300
- var s='<svg viewBox="0 0 '+lay.W+' '+lay.H+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
332
+ // fixed arrow labels: on by default while the level stays readable, else hover-only (the tooltip
333
+ // is always there). A level with 40 arrows would be unreadable with every label painted at once.
334
+ var showLbl=(LABELS===null)?(edges.length<=LABEL_AUTO_MAX):LABELS;
335
+ var blbl=$("blbl");if(blbl)blbl.className=showLbl?"on":"";
336
+ // geometry pre-pass: edge paths first, so the viewBox can shrink-wrap nodes AND label anchors
337
+ var geo=[],i;
338
+ for(i=0;i<edges.length;i++){
339
+ var a=pos[edges[i].from],b=pos[edges[i].to];if(!a||!b)continue;
340
+ geo.push({e:edges[i],i:i,g:edgePath(a,b)});
341
+ }
342
+ // Fit-to-content viewBox: a sparse level renders zoomed and dense instead of lost in a fixed
343
+ // 1020px canvas — the hand-made status boards read well precisely because they waste no space.
344
+ // Minimums stop a single box from becoming comically large; extra room is split evenly so the
345
+ // content stays centred inside the enforced window.
346
+ var bx1=Infinity,by1=Infinity,bx2=-Infinity,by2=-Infinity,j,pp;
347
+ for(j=0;j<kids.length;j++){pp=pos[kids[j].id];if(!pp)continue;
348
+ if(pp.x<bx1)bx1=pp.x;if(pp.y<by1)by1=pp.y;if(pp.x+pp.w>bx2)bx2=pp.x+pp.w;if(pp.y+pp.h>by2)by2=pp.y+pp.h;}
349
+ for(j=0;j<geo.length;j++){var gm=geo[j].g;
350
+ if(gm.mx-14<bx1)bx1=gm.mx-14;if(gm.my-18<by1)by1=gm.my-18;
351
+ if(gm.mx+14>bx2)bx2=gm.mx+14;if(gm.my+6>by2)by2=gm.my+6;}
352
+ if(bx2<bx1){bx1=0;by1=0;bx2=lay.W;by2=lay.H;}
353
+ var VPAD=56,MINW=880,MINH=460;
354
+ var cw=bx2-bx1+2*VPAD,ch=by2-by1+2*VPAD;
355
+ var vw=Math.max(MINW,cw),vh=Math.max(MINH,ch);
356
+ var vx=bx1-VPAD-(vw-cw)/2,vy=by1-VPAD-(vh-ch)/2;
357
+ // …but hold it still while a box is being dragged: draw() runs on every mousemove, so a live
358
+ // refit would rescale the whole canvas under the pointer. The drop refits (see mouseup).
359
+ var vbk=keyOf();
360
+ if(drag&&VBOX[vbk]){vx=VBOX[vbk][0];vy=VBOX[vbk][1];vw=VBOX[vbk][2];vh=VBOX[vbk][3];}
361
+ else VBOX[vbk]=[vx,vy,vw,vh];
362
+ var s='<svg viewBox="'+vx.toFixed(1)+' '+vy.toFixed(1)+' '+vw.toFixed(1)+' '+vh.toFixed(1)+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
301
363
  +'<defs><marker id="ar" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edge")+'"/></marker>'
302
364
  +'<marker id="arp" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edgeplan")+'"/></marker></defs>';
303
- for(var i=0;i<edges.length;i++){
304
- var a=pos[edges[i].from],b=pos[edges[i].to];if(!a||!b)continue;
305
- var d=edgePath(a,b),cl=(edges[i].estatus==="to-build"?"plan":"flow");
306
- s+='<path class="edge '+cl+'" data-e="'+i+'" d="'+d+'"/>';
307
- s+='<path class="edgehit" data-e="'+i+'" data-label="'+esc(edges[i].label||"")+'" d="'+d+'"/>';
365
+ for(j=0;j<geo.length;j++){
366
+ var e=geo[j].g,ed=geo[j].e,cl=(ed.estatus==="to-build"?"plan":"flow");
367
+ s+='<path class="edge '+cl+'" data-e="'+geo[j].i+'" d="'+e.d+'"/>';
368
+ s+='<path class="edgehit" data-e="'+geo[j].i+'" data-label="'+esc(ed.label||"")+'" d="'+e.d+'"/>';
369
+ if(showLbl&&ed.label){var lb=String(ed.label);if(lb.length>28)lb=lb.slice(0,27)+"";
370
+ s+='<text class="elbl" x="'+e.mx+'" y="'+(e.my-4)+'" text-anchor="middle">'+esc(lb)+'</text>';}
308
371
  }
309
372
  for(var j=0;j<kids.length;j++){
310
373
  var n=kids[j],p=pos[n.id];
@@ -313,18 +376,21 @@ function draw(animate){
313
376
  var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
314
377
  var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||n.tech||""));
315
378
  var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":(n.tech||""))));
316
- var tmax=Math.floor((p.w-70)/6.7),tt=n.name;if(String(tt).length>tmax)tt=String(tt).slice(0,tmax-1)+"…";
379
+ // the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
380
+ // instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
381
+ var tmax=Math.floor((p.w-34-String(badge).length*5.4)/6.7),tt=n.name;
382
+ if(String(tt).length>tmax)tt=String(tt).slice(0,Math.max(1,tmax-1))+"…";
317
383
  s+='<g class="'+cls+'" data-id="'+esc(n.id)+'">'
318
384
  +'<rect x="'+p.x+'" y="'+p.y+'" width="'+p.w+'" height="'+p.h+'" rx="10"/>'
319
385
  +'<text class="kk" x="'+(p.x+13)+'" y="'+(p.y+16)+'">'+esc(n.category||n.kind||"")+'</text>'
320
386
  +'<text class="tt" x="'+(p.x+13)+'" y="'+(p.y+33)+'">'+esc(tt)+'</text>'
321
387
  +'<text class="pp" x="'+(p.x+p.w-13)+'" y="'+(p.y+16)+'" text-anchor="end">'+esc(badge)+'</text>';
322
- var words=String(desc).split(" "),line="",ln=0,cpl=Math.floor((p.w-26)/5.4),MAXL=2;
323
- for(var k=0;k<words.length&&ln<MAXL;k++){
324
- if((line+words[k]).length>cpl){s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+ln*12.5)+'">'+esc(line.trim())+'</text>';line=words[k]+" ";ln++;}
325
- else line+=words[k]+" ";}
326
- if(ln<MAXL&&line)s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+ln*12.5)+'">'+esc(line.length>cpl?line.slice(0,cpl-1)+"…":line.trim())+'</text>';
327
- else if(ln===MAXL&&line.trim())s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+ln*12.5)+'">…</text>';
388
+ // Description lines: how many fit comes from the geometry (text starts at y+51, 12.5px apart,
389
+ // and must clear the footer control at h-25), so a curated layout hint with short boxes cannot
390
+ // print through [+] DRILL. wrapDesc is pure and clamps every line to the box width.
391
+ var floorY=p.h-((dr||isCat)?25:6),MAXL=Math.min(4,Math.floor((floorY-51)/12.5)+1);
392
+ var dlines=wrapDesc(desc,Math.floor((p.w-26)/5.4),MAXL);
393
+ for(var q=0;q<dlines.length;q++)s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+q*12.5)+'">'+esc(dlines[q])+'</text>';
328
394
  if(dr)s+='<rect class="drillhit" data-drill="1" x="'+(p.x+p.w-96)+'" y="'+(p.y+p.h-25)+'" width="88" height="20" rx="5"/>'
329
395
  +'<text class="plus" x="'+(p.x+p.w-13)+'" y="'+(p.y+p.h-9)+'" text-anchor="end">[+] DRILL</text>';
330
396
  else if(isCat)s+='<text class="plus" x="'+(p.x+p.w-13)+'" y="'+(p.y+p.h-9)+'" text-anchor="end">[≡] '+esc(String(n.count))+'</text>';
@@ -337,18 +403,58 @@ function draw(animate){
337
403
  st2.classList.toggle("anim",animate!==false);
338
404
  // breadcrumb
339
405
  var chain=[],c=pid;while(c){var nn=node(c);chain.unshift(nn);c=nn?nn.parent:null;}
340
- var lvl=pid?levelName(kids[0]&&kids[0].level):STR.lvlContext;
341
- var cb=STR.crumbLevel+" · "+lvl.toUpperCase()+" &nbsp;&nbsp; <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
406
+ var lvlKey=pid?(kids[0]&&kids[0].level):"context";
407
+ var lvl=pid?levelName(lvlKey):STR.lvlContext;
408
+ // C4 notation: number the level by its position in the model's own `levels` list (L1 = context).
409
+ var lvlIdx=((M.levels||[]).indexOf(lvlKey));
410
+ var cb=(lvlIdx>-1?STR.crumbLevel+"-L"+(lvlIdx+1):STR.crumbLevel)+" · "+lvl.toUpperCase()+" &nbsp;&nbsp; <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
342
411
  for(var w=0;w<chain.length;w++)cb+=" &rsaquo; "+(w===chain.length-1?"<b>"+esc(chain[w].name)+"</b>":"<a data-d='"+w+"'>"+esc(chain[w].name)+"</a>");
412
+ // Per-level programme tally — the status-board essence in one glance: the status mix of THIS
413
+ // view plus mean completion. Counts use the real children (a collapsed catalogue still counts
414
+ // its members), so the tally never changes when the catalogue threshold does.
415
+ var cnt={},tot=0,sum=0,nc=0,st3;
416
+ for(j=0;j<realKids.length;j++){
417
+ st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"done");
418
+ cnt[st3]=(cnt[st3]||0)+1;tot++;
419
+ if(realKids[j].completion!=null){sum+=(+realKids[j].completion||0);nc++;}
420
+ }
421
+ if(tot){
422
+ var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d"},ord=["done","prog","next","plan","prob"],agg="";
423
+ for(j=0;j<ord.length;j++)if(cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+cnt[ord[j]];
424
+ if(nc)agg+=' &nbsp;'+esc(STR.aggProgress)+' <b>'+Math.round(sum/nc)+'%</b>';
425
+ cb='<span class="agg">'+agg+'</span>'+cb;
426
+ }
343
427
  $("crumb").innerHTML=cb;
344
428
  $("back").disabled=stack.length<2;
345
429
  $("detail").style.display="none";
346
430
  }
431
+ // Greedy word wrap into at most `max` lines of `cpl` characters. Every returned line is clamped to
432
+ // cpl — an unbreakable token (a long class name, a URL) would otherwise paint outside the box, and
433
+ // there is no clip-path on the node. The last line carries the ellipsis when text was dropped,
434
+ // rather than spending a whole line on it (on a short curated box that was half the visible text).
435
+ function wrapDesc(desc,cpl,max){
436
+ if(max<1||cpl<2)return [];
437
+ var words=String(desc==null?"":desc).split(" "),all=[],line="",i;
438
+ for(i=0;i<words.length;i++){if(line&&(line+words[i]).length>cpl){all.push(line.trim());line="";}line+=words[i]+" ";}
439
+ if(line.trim())all.push(line.trim());
440
+ var out=all.slice(0,max),dropped=all.length>out.length;
441
+ if(dropped&&out.length)out[out.length-1]=out[out.length-1]+"…";
442
+ for(i=0;i<out.length;i++)if(out[i].length>cpl)out[i]=out[i].slice(0,cpl-1)+"…";
443
+ return out;
444
+ }
445
+ // Returns the path plus the point ON the curve at t=0.5 — the label anchor. (The quadratic's
446
+ // control point sits at twice the bow, so anchoring a label there would float it off the arrow.)
447
+ // Endpoints are trimmed at the true RECTANGLE border (not a radial guess): with wide boxes the
448
+ // radial trim landed inside the box on shallow angles, dragging the label anchor under the node.
347
449
  function edgePath(a,b){
348
450
  var ax=a.x+a.w/2,ay=a.y+a.h/2,bx=b.x+b.w/2,by=b.y+b.h/2,dx=bx-ax,dy=by-ay,L=Math.sqrt(dx*dx+dy*dy)||1;
349
- var x1=ax+(a.w/2+10)*dx/L,y1=ay+(a.h/2+10)*dy/L,x2=bx-(b.w/2+12)*dx/L,y2=by-(b.h/2+12)*dy/L;
350
- var mx=(x1+x2)/2+(y2-y1)*.12,my=(y1+y2)/2-(x2-x1)*.12;
351
- return "M"+x1+","+y1+" Q"+mx+","+my+" "+x2+","+y2;
451
+ var ux=dx/L,uy=dy/L;
452
+ var ta=Math.min(ux?(a.w/2)/Math.abs(ux):Infinity,uy?(a.h/2)/Math.abs(uy):Infinity)+8;
453
+ var tb=Math.min(ux?(b.w/2)/Math.abs(ux):Infinity,uy?(b.h/2)/Math.abs(uy):Infinity)+11;
454
+ if(ta+tb>L-4){ta=(a.w/2+10);tb=(b.w/2+12);} // boxes touch/overlap: fall back to the radial trim
455
+ var x1=ax+ta*ux,y1=ay+ta*uy,x2=bx-tb*ux,y2=by-tb*uy;
456
+ var cx=(x1+x2)/2+(y2-y1)*.12,cy=(y1+y2)/2-(x2-x1)*.12;
457
+ return {d:"M"+x1+","+y1+" Q"+cx+","+cy+" "+x2+","+y2, mx:(x1+2*cx+x2)/4, my:(y1+2*cy+y2)/4};
352
458
  }
353
459
  function levelName(l){return (STR.lvlNames&&STR.lvlNames[l])||l||"—";}
354
460
  function cssv(v){try{return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||"#2b6e8a";}catch(e){return "#2b6e8a";}}
@@ -416,7 +522,9 @@ function wire(){
416
522
  pos[drag.id].y=clamp(p.y-drag.oy,4,lay.H-pos[drag.id].h-4);
417
523
  drag.moved=true;draw(false);
418
524
  });
419
- document.addEventListener("mouseup",function(){if(drag){if(drag.moved)justDragged=true;drag=null;}});
525
+ document.addEventListener("mouseup",function(){if(!drag)return;var m=drag.moved;drag=null;
526
+ // refit after the drop, deferred so the click that follows mouseup still lands on the live DOM
527
+ if(m){justDragged=true;setTimeout(function(){draw(false);},0);}});
420
528
  // edge label on hover (invisible fat hit-path)
421
529
  stage.addEventListener("mouseover",function(ev){
422
530
  if(ev.target.classList&&ev.target.classList.contains("edgehit")){
@@ -456,7 +564,15 @@ function wire(){
456
564
  document.addEventListener("keydown",function(e){if(e.key==="Escape"&&stack.length>1){stack.pop();draw(true);}});
457
565
  $("bnow").addEventListener("click",function(){mode="now";$("bnow").className="on";$("btgt").className="";draw(false);});
458
566
  $("btgt").addEventListener("click",function(){mode="target";$("btgt").className="on";$("bnow").className="";draw(false);});
459
- $("brf").addEventListener("click",function(){var m=(M&&M.meta)||{};$("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M&&M.source&&M.source.commit)||"—")+" · "+STR.stampMethod+" "+(m.verifyMethod||STR.stampVerify);});
567
+ $("brf").addEventListener("click",function(){
568
+ // Served model ⇒ actually re-read it (a `forma verify` run in the terminal shows up here without
569
+ // losing the level, the layout or the mode). Injected/offline ⇒ re-stamp, and say why.
570
+ if(!FETCHED){stamp(STR.staticSrc);return;}
571
+ fetch("./c4-model.json?ts="+(new Date()).getTime()).then(function(r){return r.json();})
572
+ .then(function(m){if(m&&m.nodes&&m.nodes.length){M=m;stamp("");draw(false);}else stamp(STR.staticSrc);})
573
+ .catch(function(){stamp(STR.staticSrc);});
574
+ });
575
+ $("blbl").addEventListener("click",function(){LABELS=!($("blbl").className==="on");draw(false);});
460
576
  $("brl").addEventListener("click",function(){delete POS[keyOf()];draw(true);});
461
577
  $("skin").innerHTML=SKINS.map(function(s){return '<option value="'+s[0]+'">'+s[1]+'</option>';}).join("");
462
578
  $("skin").addEventListener("change",function(e){setSkin(e.target.value);});
@@ -467,7 +583,9 @@ function applyStrings(){
467
583
  function set(id,txt){var e=$(id);if(e)e.textContent=txt;}
468
584
  set("subhelp",STR.subhelp);set("back",STR.back);set("bnow",STR.now);set("btgt",STR.tgt);
469
585
  set("brf",STR.reverify);set("brl",STR.resetLayout);var brl=$("brl");if(brl)brl.title=STR.resetLayoutTitle;
470
- set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);set("pexpPrint",STR.print);
586
+ set("blbl",STR.labels);var bl=$("blbl");if(bl)bl.title=STR.labelsTitle;
587
+ set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);
588
+ set("pexpLayout",STR.exportLayout);set("pexpPrint",STR.print);
471
589
  set("lg-done",STR.legDone);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
472
590
  set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-hint",STR.legHint);
473
591
  }
@@ -486,7 +604,7 @@ function collectSvgCss(){
486
604
  if(!rules)continue;
487
605
  for(j=0;j<rules.length;j++){var r=rules[j],t=r.cssText||"";
488
606
  // keep rules that target svg-internal classes/elements + the color vars on :root/html
489
- if(/\.(nd|edge|edgehit|s-done|s-prog|s-next|s-plan|s-prob|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
607
+ if(/\.(nd|edge|edgehit|elbl|s-done|s-prog|s-next|s-plan|s-prob|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
490
608
  ||/(^|[\s,])svg\b/.test(t)||/:root|html\[data-skin/.test(t)){
491
609
  // drop animations for a static export (keepdefs stable)
492
610
  out.push(t);
@@ -512,14 +630,14 @@ function serializeSvg(){
512
630
  clone.setAttribute("xmlns","http://www.w3.org/2000/svg");
513
631
  clone.setAttribute("xmlns:xlink","http://www.w3.org/1999/xlink");
514
632
  var vb=(svg.getAttribute("viewBox")||"0 0 1080 660").split(/\s+/);
515
- var w=+vb[2]||1080,h=+vb[3]||660;
633
+ var vbx=+vb[0]||0,vby=+vb[1]||0,w=+vb[2]||1080,h=+vb[3]||660;
516
634
  clone.setAttribute("width",w);clone.setAttribute("height",h);
517
635
  // Inline the resolved variables on the root so var(--x) resolves standalone,
518
636
  // independent of the skin-selector chain that won't exist in the exported file.
519
637
  var vars=resolvedVars();if(vars)clone.setAttribute("style",vars);
520
638
  // solid dark/light background so the standalone file isn't transparent
521
639
  var bg=cssv("--stagebg")||"#050d14";
522
- var rect='<rect x="0" y="0" width="'+w+'" height="'+h+'" fill="'+bg+'"/>';
640
+ var rect='<rect x="'+vbx+'" y="'+vby+'" width="'+w+'" height="'+h+'" fill="'+bg+'"/>';
523
641
  var style="<style>"+collectSvgCss()+"</style>";
524
642
  var inner=clone.innerHTML;
525
643
  var attrs='';for(var a=0;a<clone.attributes.length;a++){var at=clone.attributes[a];attrs+=' '+at.name+'="'+String(at.value).replace(/"/g,"&quot;")+'"';}
@@ -531,6 +649,17 @@ function triggerDownload(blob,filename){
531
649
  a.href=url;a.download=filename;document.body.appendChild(a);a.click();
532
650
  document.body.removeChild(a);setTimeout(function(){URL.revokeObjectURL(url);},4000);
533
651
  }
652
+ // Every level you have arranged, in the exact shape meta.layout expects — drag the boxes, export,
653
+ // paste into the topology's "layout" and `gen` bakes it in. No storage: the repo is the memory.
654
+ function exportLayout(){
655
+ var out={},k,id,p;
656
+ for(k in POS){if(!POS.hasOwnProperty(k))continue;
657
+ var lvl={},any=false;
658
+ for(id in POS[k].pos){if(!POS[k].pos.hasOwnProperty(id))continue;
659
+ p=POS[k].pos[id];lvl[id]={x:Math.round(p.x),y:Math.round(p.y),w:Math.round(p.w),h:Math.round(p.h)};any=true;}
660
+ if(any)out[k]=lvl;}
661
+ triggerDownload(new Blob([JSON.stringify({layout:out},null,2)+"\n"],{type:"application/json"}),exportBaseName()+"-layout.json");
662
+ }
534
663
  function exportSvg(){
535
664
  var s=serializeSvg();if(!s)return;
536
665
  triggerDownload(new Blob([s.xml],{type:"image/svg+xml;charset=utf-8"}),exportBaseName()+".svg");
@@ -564,23 +693,34 @@ function wireExport(){
564
693
  document.addEventListener("keydown",function(e){if(e.key==="Escape")close();});
565
694
  var sv=$("pexpSvg");if(sv)sv.addEventListener("click",function(){close();exportSvg();});
566
695
  var pn=$("pexpPng");if(pn)pn.addEventListener("click",function(){close();exportPng();});
696
+ var lay=$("pexpLayout");if(lay)lay.addEventListener("click",function(){close();exportLayout();});
567
697
  var pr=$("pexpPrint");if(pr)pr.addEventListener("click",function(){close();window.print();});
568
698
  }
569
699
  function moveTip(ev){var st=$("stage").getBoundingClientRect(),t=$("etip");
570
700
  var x=ev.clientX-st.left+12,y=ev.clientY-st.top+12;
571
701
  t.style.left=Math.min(x,st.width-t.offsetWidth-6)+"px";t.style.top=Math.min(y,st.height-t.offsetHeight-6)+"px";}
572
702
 
703
+ // Fact-base line. `suffix` marks a RE-VERIFY that could not re-read the model (injected/offline).
704
+ function stamp(suffix){
705
+ var m=(M&&M.meta)||{};
706
+ $("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M&&M.generatedAt)||(M&&M.source&&M.source.commit)||"—")
707
+ +" · "+STR.stampMethod+" "+(m.verifyMethod||STR.stampVerify)+(suffix?" "+suffix:"");
708
+ }
573
709
  function boot(model){
574
710
  M=model;
575
711
  LANG=pickLang(M);STR=STRINGS[LANG];
576
712
  try{document.documentElement.setAttribute("lang",LANG);}catch(e){}
577
713
  applyStrings();
578
714
  if(!M||!M.nodes||!M.nodes.length){$("err").style.display="block";$("err").textContent=STR.errEmpty;return;}
579
- if(M.source&&M.source.repo)$("title").textContent="Forma · "+M.source.repo;
580
- var m=M.meta||{};$("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M.source&&M.source.commit)||"—")+" · "+(m.verifyMethod||STR.stampVerify);
581
- setSkin((new URLSearchParams(location.search).get("skin"))||window.__C4_SKIN__||(m.skin)||"holo");
715
+ var ttl=(M.meta&&M.meta.title)||((M.source&&M.source.repo)?"Forma · "+M.source.repo:null);
716
+ if(ttl){$("title").textContent=ttl;try{document.title=ttl;}catch(e){}}
717
+ stamp("");
718
+ setSkin((new URLSearchParams(location.search).get("skin"))||window.__C4_SKIN__||(M.meta&&M.meta.skin)||"holo");
582
719
  stack=[null];mode="now";POS={};
583
720
  draw(true);
721
+ // Minimal hook for embedders (the Cowork artifact re-verifies through its own MCP bridge and
722
+ // needs to push the result back in without forking the viewer).
723
+ window.__C4_API__={model:function(){return M;},redraw:function(){draw(false);},stamp:function(t){$("stamp").textContent=t;}};
584
724
  }
585
725
  function start(){
586
726
  // Pick language from URL/global up-front so header + errors render even if the model never loads.
@@ -589,6 +729,7 @@ function start(){
589
729
  applyStrings();
590
730
  wire();
591
731
  if(window.__C4_MODEL__){boot(window.__C4_MODEL__);return;}
732
+ FETCHED=true; // the model has a live URL ⇒ RE-VERIFY can re-read it instead of only re-stamping
592
733
  try{fetch("./c4-model.json").then(function(r){return r.json();}).then(boot).catch(function(){
593
734
  $("err").style.display="block";$("err").textContent=STR.errNotFound;});}
594
735
  catch(e){$("err").style.display="block";$("err").textContent=STR.errUnavail;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forma-arch",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Forma — present your architecture instead of slides. An interactive, stack-agnostic C4 explorer (context → container → component → leaf) generated from your code and kept true to it by a deterministic drift check.",
5
5
  "keywords": [
6
6
  "architecture",