novahiz 0.9.6 → 0.9.7

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/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.7] - 2026-10-15
9
+
10
+ ### Added
11
+
12
+ - **Project docs mirrored into the vault** - `novahiz init` now fills the
13
+ `<Domain>/<Branch>/docs/<slug>/` folder it creates instead of leaving it
14
+ empty: a new `Obsidian project docs` step copies `ARCHITECTURE.md`,
15
+ `CONVENTIONS.md`, `DECISIONS.md` and `STANDARDS.md` verbatim from
16
+ `novahiz-docs/`, and `novahiz autodocs --flush` re-mirrors them on every
17
+ flush, reported as a `Vault` row. The folder previously appeared holding only
18
+ a `_MOC.md` - the `docs` leaf of the catalogue existed and nothing ever wrote
19
+ into it. The copy is a **mirror, never the source**: every note reflects a
20
+ file that still lives in the project, which is what makes an overwriting flush
21
+ safe instead of a second history to maintain. Guards, none silent: no
22
+ `vault.json` binding, a binding outside the catalogue, or a target folder
23
+ `project-init` never created all skip with advice and invent nothing (contract
24
+ 1); a doc file absent from the project is reported as `absent`, never
25
+ manufactured; a dry-run writes neither note nor log entry. Each real write
26
+ appends one `- HH:MM -` line under the day's heading of `log.md` through
27
+ `appendLogLine` - contract 8's "log the operation" was an instruction for the
28
+ agent and not code, so it only happened when someone remembered it. The
29
+ project name now resolves through a single `readProjectName` helper shared by
30
+ `init` and `flush`, so the seed and the refresh cannot slugify to different
31
+ folders.
32
+
8
33
  ## [0.9.6] - 2026-10-15
9
34
 
10
35
  ### Added
package/dist/autodocs.js CHANGED
@@ -56,6 +56,22 @@ export function configPath(cwd) {
56
56
  export function statePath(cwd) {
57
57
  return join(projectDir(cwd), STATE_NAME);
58
58
  }
59
+ /** Nom du projet côté module pur, sans dépendre de `commands/init.ts` (qui
60
+ * importe déjà ce fichier : importer `detectProject` créerait un cycle).
61
+ * package.json `name`, sinon le dernier segment du cwd — la même chute que
62
+ * `detectProject`, volontairement unique : l'init et le flush doivent produire
63
+ * le même slug, sans quoi la copie vault partirait dans un autre dossier. */
64
+ export function readProjectName(cwd) {
65
+ try {
66
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
67
+ if (typeof pkg.name === "string" && pkg.name.trim().length > 0)
68
+ return pkg.name;
69
+ }
70
+ catch {
71
+ // name falls back to the folder below, exactly as detectProject does
72
+ }
73
+ return cwd.split(/[\\/]/).filter(Boolean).pop() ?? "project";
74
+ }
59
75
  function ensureDir(path) {
60
76
  if (!existsSync(path))
61
77
  mkdirSync(path, { recursive: true });
@@ -1,8 +1,9 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { autoDocsEnabled, clearDirty, dateStamp, isMajorPath, markDirty, readProjectConfig, readState, upsertChangesSection, writeProjectConfig } from "../autodocs.js";
3
+ import { autoDocsEnabled, clearDirty, dateStamp, isMajorPath, markDirty, readProjectConfig, readProjectName, readState, upsertChangesSection, writeProjectConfig } from "../autodocs.js";
4
4
  import { emit, flagOn } from "./context.js";
5
5
  import { memoryRoot, writeEntry } from "../memory.js";
6
+ import { syncProjectDocsToVault } from "./second-memory.js";
6
7
  import * as ui from "../render.js";
7
8
  const MAX_DOC_PATHS = 12;
8
9
  const ARCHITECTURE = "novahiz-docs/ARCHITECTURE.md";
@@ -26,6 +27,7 @@ function skipResult(reason, paths = []) {
26
27
  docsPath: null,
27
28
  docsUpdated: false,
28
29
  memorySlot: null,
30
+ vaultDocs: null,
29
31
  paths,
30
32
  pending: state.pending.length,
31
33
  lastSync: state.lastSync
@@ -76,6 +78,20 @@ export function flushAutoDocs(cwd) {
76
78
  const paths = state.pending;
77
79
  const docs = flushDocs(cwd, paths);
78
80
  const memorySlot = flushMemory(cwd, paths);
81
+ // Volet « miroir » : la doc fraîchement écrite est répercutée dans
82
+ // <Domaine>/<Branche>/docs/<slug>/. Volontairement encapsulé plutôt que
83
+ // laissé dans le try du flush : docs et slot sont déjà écrits à ce point,
84
+ // et faire échouer le flush ici laisserait un état sale et un slot
85
+ // dupliqué à la prochaine exécution. Le vault est un miroir dérivé, la
86
+ // source de vérité reste le projet — il n'a pas le droit de le casser.
87
+ let vaultTarget = null;
88
+ try {
89
+ const vault = syncProjectDocsToVault(readProjectName(cwd), join(cwd, "novahiz-docs"), true);
90
+ vaultTarget = vault.target.length > 0 && vault.copied.length > 0 ? vault.target : null;
91
+ }
92
+ catch {
93
+ vaultTarget = null;
94
+ }
79
95
  const next = clearDirty(cwd);
80
96
  return {
81
97
  status: "flushed",
@@ -83,6 +99,7 @@ export function flushAutoDocs(cwd) {
83
99
  docsPath: docs.path,
84
100
  docsUpdated: docs.updated,
85
101
  memorySlot,
102
+ vaultDocs: vaultTarget,
86
103
  paths,
87
104
  pending: next.pending.length,
88
105
  lastSync: next.lastSync
@@ -95,6 +112,7 @@ export function flushAutoDocs(cwd) {
95
112
  docsPath: null,
96
113
  docsUpdated: false,
97
114
  memorySlot: null,
115
+ vaultDocs: null,
98
116
  paths: state.pending,
99
117
  pending: state.pending.length,
100
118
  lastSync: state.lastSync
@@ -116,6 +134,7 @@ function markFromArgs(cwd, mark, positionals) {
116
134
  docsPath: null,
117
135
  docsUpdated: false,
118
136
  memorySlot: null,
137
+ vaultDocs: null,
119
138
  paths: [],
120
139
  pending: readState(cwd).pending.length,
121
140
  lastSync: readState(cwd).lastSync
@@ -140,6 +159,7 @@ function markFromArgs(cwd, mark, positionals) {
140
159
  docsPath: null,
141
160
  docsUpdated: false,
142
161
  memorySlot: null,
162
+ vaultDocs: null,
143
163
  paths: rejected,
144
164
  pending: state.pending.length,
145
165
  lastSync: state.lastSync
@@ -153,6 +173,7 @@ function markFromArgs(cwd, mark, positionals) {
153
173
  docsPath: null,
154
174
  docsUpdated: false,
155
175
  memorySlot: null,
176
+ vaultDocs: null,
156
177
  paths: accepted,
157
178
  pending: state.pending.length,
158
179
  lastSync: state.lastSync
@@ -204,7 +225,8 @@ export function commandAutodocs(parsed) {
204
225
  ["Action", outcome.status],
205
226
  ["Reason", outcome.reason],
206
227
  ["Docs", outcome.docsPath ? `${outcome.docsPath}${outcome.docsUpdated ? " updated" : " unchanged"}` : "n/a"],
207
- ["Memory", outcome.memorySlot ?? "n/a"]
228
+ ["Memory", outcome.memorySlot ?? "n/a"],
229
+ ["Vault", outcome.vaultDocs ?? "n/a"]
208
230
  ]));
209
231
  lines.push("", outcome.status === "failed" ? ui.style("red", outcome.reason) : ui.style("green", outcome.reason));
210
232
  }
@@ -5,8 +5,9 @@ import { loadSpec, NovahizHome } from "../spec.js";
5
5
  import { loadInstalledSkills } from "../catalog.js";
6
6
  import { findContextFile } from "../impeccable.js";
7
7
  import { ensureMemoryRoot, memoryRoot, writeEntry } from "../memory.js";
8
+ import { readProjectName } from "../autodocs.js";
8
9
  import { ensureProjectAutoDocsConfig } from "./autodocs.js";
9
- import { projectInit } from "./second-memory.js";
10
+ import { projectInit, syncProjectDocsToVault } from "./second-memory.js";
10
11
  import * as ui from "../render.js";
11
12
  const DOC_FILES = ["ARCHITECTURE.md", "CONVENTIONS.md", "DECISIONS.md", "STANDARDS.md"];
12
13
  const CLEANUP_PATTERNS = [
@@ -56,7 +57,7 @@ function detectProject(cwd) {
56
57
  if (existsSync(join(cwd, "README.md")))
57
58
  markers.push("README.md");
58
59
  if (!name) {
59
- name = cwd.split(/[\\/]/).filter(Boolean).pop() ?? "project";
60
+ name = readProjectName(cwd);
60
61
  }
61
62
  return { kind, name, version, markers };
62
63
  }
@@ -297,6 +298,53 @@ function obsidianProject(dryRun, project) {
297
298
  detail: created > 0 ? `${where}: ${created} folder(s) + binding` : `${where}: already initialized`
298
299
  };
299
300
  }
301
+ /** Étape de suite : la place existe-t-elle, on la remplit.
302
+ * Recopie `<projet>/novahiz-docs/` dans le `<docs>/<slug>/` du vault. Elle
303
+ * hérite du verdict d'`obsidianProject` au lieu d'en dériver un second : si le
304
+ * dossier projet n'a pas pu être posé, il n'y a aucune cible légitime et cette
305
+ * étape se tait plutôt que d'en tenter une. Sans vault, sans binding ou sans
306
+ * dossier cible, elle saute avec le conseil exact ; aucun dossier n'est
307
+ * inventé (contrainte 1) et aucune copie ne part dans le vide. */
308
+ function obsidianDocs(cwd, dryRun, project, folder) {
309
+ const label = "Obsidian project docs";
310
+ if (folder.status !== "created" && folder.status !== "skipped") {
311
+ return { id: "obsidian-docs", label, status: "skipped", detail: `project folder not ready — see "${folder.label}"` };
312
+ }
313
+ const name = project.name.trim();
314
+ if (name.length === 0) {
315
+ return { id: "obsidian-docs", label, status: "skipped", detail: "no project name detected (package.json)" };
316
+ }
317
+ let result;
318
+ try {
319
+ result = syncProjectDocsToVault(name, join(cwd, "novahiz-docs"), !dryRun);
320
+ }
321
+ catch (error) {
322
+ return { id: "obsidian-docs", label, status: "failed", detail: String(error).slice(0, 160) };
323
+ }
324
+ const where = result.target.length > 0 ? result.target : `${result.domain}/${result.branch}/${result.slug}`;
325
+ if (result.warnings.length > 0 && result.copied.length === 0) {
326
+ return {
327
+ id: "obsidian-docs",
328
+ label,
329
+ status: "skipped",
330
+ detail: `${where}: ${result.warnings.join("; ").slice(0, 160)}`,
331
+ advice: result.target.length > 0 ? undefined : `Link it: novahiz second-memory project-init --name <project> --branch Domain/Branch --apply`
332
+ };
333
+ }
334
+ const counts = `${result.copied.length} file(s)${result.absent.length > 0 ? `, ${result.absent.length} absent` : ""}`;
335
+ if (dryRun) {
336
+ return { id: "obsidian-docs", label, status: "dry-run", detail: `${where}: ${counts} would be copied` };
337
+ }
338
+ if (result.warnings.length > 0) {
339
+ return { id: "obsidian-docs", label, status: "failed", detail: `${where}: ${result.warnings.join("; ").slice(0, 160)}` };
340
+ }
341
+ return {
342
+ id: "obsidian-docs",
343
+ label,
344
+ status: result.copied.length > 0 ? "created" : "skipped",
345
+ detail: result.copied.length > 0 ? `${where}: ${counts} mirrored` : `${where}: nothing to copy`
346
+ };
347
+ }
300
348
  export function commandInit(parsed) {
301
349
  const cwd = process.cwd();
302
350
  const home = NovahizHome();
@@ -336,7 +384,9 @@ export function commandInit(parsed) {
336
384
  });
337
385
  }
338
386
  steps.push(impeccableContext(home, cwd, dryRun));
339
- steps.push(obsidianProject(dryRun, project));
387
+ const folder = obsidianProject(dryRun, project);
388
+ steps.push(folder);
389
+ steps.push(obsidianDocs(cwd, dryRun, project, folder));
340
390
  }
341
391
  let cleanup = [];
342
392
  let cleanupApplied = 0;
@@ -2159,6 +2159,123 @@ export function projectInit(name, branchFlag, apply) {
2159
2159
  }
2160
2160
  return result;
2161
2161
  }
2162
+ // --- project docs sync ---------------------------------------------------------
2163
+ /** Les quatre fichiers que `novahiz init` pose dans `<projet>/novahiz-docs/` et
2164
+ * que la copie vault reflète. Liste fermée, même ordre canonique que le
2165
+ * template : on ne recopie pas n'importe quel fichier trouvé dans le dossier. */
2166
+ export const PROJECT_DOC_FILES = ["ARCHITECTURE.md", "CONVENTIONS.md", "DECISIONS.md", "STANDARDS.md"];
2167
+ /** Append-only, une entrée par jour : `- HH:MM - message` sous `## YYYY-MM-DD`.
2168
+ * Même forme que l'entrée posée à l'initialisation du vault (L1036), pour que
2169
+ * le journal reste lisible comme un seul flux. Une entrée est posée uniquement
2170
+ * en mode apply : la trace d'un dry-run est un non-événement. */
2171
+ function appendLogLine(vault, message) {
2172
+ const now = new Date();
2173
+ const date = now.toISOString().slice(0, 10);
2174
+ const time = now.toTimeString().slice(0, 5);
2175
+ const entry = `- ${time} - ${message}`;
2176
+ const path = join(vault, LOG_FILE);
2177
+ if (!existsSync(path)) {
2178
+ writeFileSync(path, `# Second Memory Log\n\nAppend-only. Never delete entries.\n\n## ${date}\n${entry}\n`, "utf8");
2179
+ return;
2180
+ }
2181
+ const body = readFileSync(path, "utf8").replace(/\s*$/, "");
2182
+ // log.md est chronologique : la section du jour, si elle existe, est la dernière.
2183
+ const marker = "## " + date;
2184
+ const lastHeading = body.slice(body.lastIndexOf("\n## ") + 1);
2185
+ const todayIsLast = lastHeading.startsWith(marker);
2186
+ const head = todayIsLast ? body + "\n" : body + "\n\n" + marker + "\n";
2187
+ writeFileSync(path, head + entry + "\n", "utf8");
2188
+ }
2189
+ /** Copie la documentation d'un projet vers `<Domaine>/<Branche>/docs/<slug>/`.
2190
+ *
2191
+ * Volet « écriture automatique dans le vault » de `novahiz init` et de
2192
+ * `autodocs flush`. La copie est **un miroir, jamais la source** : chaque note
2193
+ * reflète un fichier qui existe toujours dans `<projet>/novahiz-docs/`, donc un
2194
+ * écrasement ne détruit rien qu'on ne puisse régénérer — c'est ce qui rend
2195
+ * l'absence de backup défendable ici, contrairement à une note écrite à la main.
2196
+ *
2197
+ * Garde-fous, tous explicites et non silencieux :
2198
+ * - aucun binding → on ne devine pas de branche, on renvoie le conseil ;
2199
+ * - branche absente du catalogue → le vault a bougé, on ne crée rien ;
2200
+ * - dossier cible absent → `project-init` crée la chaîne, on ne l'invente pas
2201
+ * (contrainte 1) ;
2202
+ * - fichier absent du projet → reporté dans `absent`, pas d'étublage.
2203
+ *
2204
+ * En dry-run (`apply: false`), rien n'est écrit — ni note, ni entrée de log ;
2205
+ * `copied` liste alors ce qui *serait* copié. */
2206
+ export function syncProjectDocsToVault(name, docsDir, apply) {
2207
+ const trimmed = (name ?? "").trim();
2208
+ const slug = projectFolder(trimmed, "");
2209
+ const result = { apply, project: trimmed, slug, domain: "", branch: "", target: "", copied: [], absent: [], warnings: [] };
2210
+ if (slug.length === 0) {
2211
+ result.warnings.push("empty project name after slugify — pass a real project name");
2212
+ return result;
2213
+ }
2214
+ const vault = vaultPath();
2215
+ if (!existsSync(vault)) {
2216
+ result.warnings.push("vault not found — run `novahiz second-memory init` first");
2217
+ return result;
2218
+ }
2219
+ if (!existsSync(docsDir)) {
2220
+ result.warnings.push(`no project docs at ${docsDir} — run \`novahiz init\` to create them`);
2221
+ return result;
2222
+ }
2223
+ const binding = readBinding(slug);
2224
+ if (!binding) {
2225
+ result.warnings.push(`no vault binding for "${slug}" — run \`novahiz second-memory project-init --name ${slug} --branch Domain/Branch\``);
2226
+ return result;
2227
+ }
2228
+ const domainNode = findDomain(binding.domain);
2229
+ const branchNode = domainNode?.children?.find((node) => node.name === binding.branch) ?? null;
2230
+ if (!domainNode || !branchNode) {
2231
+ result.warnings.push(`binding points to an unknown branch ${binding.domain}/${binding.branch} — re-run project-init`);
2232
+ return result;
2233
+ }
2234
+ result.domain = domainNode.name;
2235
+ result.branch = branchNode.name;
2236
+ const leaf = (branchNode.children ?? []).find((node) => node.kind === "docs");
2237
+ if (!leaf) {
2238
+ result.warnings.push(`branch ${domainNode.name}/${branchNode.name} carries no docs leaf`);
2239
+ return result;
2240
+ }
2241
+ const target = `${domainNode.name}/${branchNode.name}/${leaf.name}/${slug}`;
2242
+ if (!existsSync(join(vault, target))) {
2243
+ result.warnings.push(`target folder missing: ${target} — run \`novahiz second-memory project-init --name ${slug}\``);
2244
+ return result;
2245
+ }
2246
+ result.target = target;
2247
+ for (const file of PROJECT_DOC_FILES) {
2248
+ const source = join(docsDir, file);
2249
+ let content;
2250
+ try {
2251
+ content = readFileSync(source, "utf8");
2252
+ }
2253
+ catch {
2254
+ result.absent.push(file);
2255
+ continue;
2256
+ }
2257
+ if (!apply) {
2258
+ result.copied.push(file);
2259
+ continue;
2260
+ }
2261
+ try {
2262
+ writeFileSync(join(vault, target, file), content, "utf8");
2263
+ result.copied.push(file);
2264
+ }
2265
+ catch (error) {
2266
+ result.warnings.push(`${file}: ${String(error).slice(0, 200)}`);
2267
+ }
2268
+ }
2269
+ if (apply && result.copied.length > 0) {
2270
+ try {
2271
+ appendLogLine(vault, `project docs synced: ${target} (${result.copied.length} file(s))`);
2272
+ }
2273
+ catch (error) {
2274
+ result.warnings.push(`log: ${String(error).slice(0, 160)}`);
2275
+ }
2276
+ }
2277
+ return result;
2278
+ }
2162
2279
  function secondMemoryProjectInit(parsed) {
2163
2280
  const apply = flagOn(parsed, "apply");
2164
2281
  const name = String(parsed.flags.name ?? "").trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "novahiz",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "Enforce AI agent skills before every file edit: prompt classifier, skill catalog, and pre-edit gate.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -5,7 +5,9 @@ description: |
5
5
  FIXED, predefined arborescence: root INDEX.md, a _MOC.md per folder, 11 domains with
6
6
  predefined branches and memory/docs/journal/decisions leaves. `second-memory project-init`
7
7
  gives a new project its docs/journal/decisions folders (never memory: it stays local) and
8
- binds it to a branch; other folders appear on first write, never speculatively.
8
+ binds it to a branch; `novahiz init` seeds that docs folder and `autodocs --flush`
9
+ re-mirrors the project's four novahiz-docs files into it. Other folders appear on first
10
+ write, never speculatively.
9
11
  `second-memory doctor` audits and repairs the vault, installs the 10 mandatory plugins.
10
12
  Use when the user writes to, organizes, audits, repairs or syncs the second-memory vault,
11
13
  or needs past context: search the vault first (`novahiz second-memory search "<terms>"`)
@@ -29,11 +31,16 @@ is predefined, and every write lands in a predefined spot.
29
31
 
30
32
  ## The contract (non-negotiable)
31
33
 
32
- 1. **Fixed arborescence** — content lives only inside the tree below. Nothing is ever
33
- written at the vault root, and no top-level folder is invented.
34
+ 1. **Fixed arborescence** — content lives only inside the tree below. No note is ever
35
+ written at the vault root: only the three system files live there (`INDEX.md`,
36
+ `STRUCTURE.md`, `log.md`). No top-level folder is invented.
34
37
  2. **`INDEX.md` is the single entry point** — categories → index, subcategories →
35
- category, subfolders → parent. It links exactly the active domains, nothing else.
36
- 3. **Every folder owns a `_MOC.md`** — it indexes its children; children link back to it.
38
+ category, subfolders → parent. Its `## Categories` section carries exactly the active
39
+ domains — never more, never fewer. The `## System` section below it is fixed and lists
40
+ the system folders; the audit reads `## Categories` only.
41
+ 3. **Every content folder owns a `_MOC.md`** — it indexes its children; children link back
42
+ to it. The four system folders (`Inbox/`, `Archive/`, `Templates/`, `Excalidraw/`) sit
43
+ outside the graph: they carry no `_MOC.md`, and `doctor` never asks for one.
37
44
  4. **Auto-create** — a write never fails on a missing folder: the whole path is created
38
45
  (folder → `_MOC.md` → parent link → `INDEX.md` link) before the note is written.
39
46
  5. **A domain activates whole** — the first write that routes to a domain materializes its
@@ -79,6 +86,11 @@ second-memory/
79
86
 
80
87
  Full per-domain detail (MOC titles, leaves, what belongs where): `references/arborescence.md`.
81
88
 
89
+ The four system folders sit outside the graph: they carry no `_MOC.md` and `doctor` never
90
+ audits them. Routing reaches only `Inbox/`, as the parking spot for a note with no domain
91
+ signal; `Archive/`, `Templates/` and `Excalidraw/` are never routing targets — the last one
92
+ holds the plugin's own drawing data.
93
+
82
94
  Leaves mean:
83
95
 
84
96
  - **`memory/`** — evergreen personal notes, split into one subfolder per project
@@ -122,6 +134,19 @@ memory stays local. Dry-run is the default; `novahiz init` runs the same logic a
122
134
  `Obsidian project folder` step. Nothing else creates folders speculatively: without a
123
135
  write, a folder does not exist.
124
136
 
137
+ `docs/<project>/` is not left empty: `novahiz init` fills it in an `Obsidian project docs`
138
+ step, and `novahiz autodocs --flush` re-mirrors on every flush. Copied verbatim from the
139
+ project's `novahiz-docs/`: `ARCHITECTURE.md`, `CONVENTIONS.md`, `DECISIONS.md`,
140
+ `STANDARDS.md`. Those notes are **a mirror, never the source** — each one reflects a file
141
+ that still lives in the project, which is why the flush overwrites in place and takes no
142
+ backup: the content is regenerated, not preserved. Two consequences worth stating. A hand
143
+ edit to a mirrored note is lost on the next flush — edit the project file, not the copy.
144
+ And the seed refuses to guess: no `vault.json` binding, a binding whose branch has left
145
+ the catalogue, a branch carrying no `docs` leaf, or a folder `project-init` never created
146
+ each skip with its advice printed, and a document missing from the project is reported
147
+ absent rather than manufactured. Every real write appends one `- HH:MM -` line to
148
+ `log.md` under the day's heading; a dry-run leaves no trace at all.
149
+
125
150
  ## INDEX.md format
126
151
 
127
152
  ```markdown
@@ -142,6 +167,7 @@ Personal knowledge vault. Start here.
142
167
  - [[Inbox]]
143
168
  - [[Archive]]
144
169
  - [[Templates]]
170
+ - [[Excalidraw]]
145
171
  ```
146
172
 
147
173
  `## Categories` is machine-managed: exactly the active domains, in catalog order.
@@ -198,6 +224,7 @@ Novahiz home. Dry-run is the default on every mutating subcommand.
198
224
  | `project-init` | give a project its folders (`docs/journal/decisions/<project>/`) and bind it to a branch — dry-run default |
199
225
  | `sync` | retire the old memory mirror: list/archive the vault notes that were slot copies (memory stays local) |
200
226
  | `status` | note counts, categories, open issues, resolved memory root |
227
+ | `search` | full-text search across the vault notes (`second-memory search "<terms>"`) |
201
228
 
202
229
  `doctor` checks, in order: `vault`, `skeleton`, `arborescence`, `outside-tree folders`,
203
230
  `links`, `plugins`, `memory`. It exits **1 while issues remain** and 0 when the vault is
@@ -277,5 +304,8 @@ the `log.md` format.
277
304
  - **Memory**: project memory stays local — `memory_search` / `memory_get` read the slots,
278
305
  `memory_write` appends them; nothing is mirrored into the vault. A legacy note carrying
279
306
  `novahiz_slot_id` is a mirror copy; `sync --apply` archives it (§3).
280
- - **Docs**: vault notes can cite novahiz-docs as `novahiz-docs/<library>@<version>`.
307
+ - **Docs**: unlike memory, project *documentation* is mirrored — `novahiz init` seeds
308
+ `<Domain>/<Branch>/docs/<slug>/` from the project's `novahiz-docs/`, and
309
+ `novahiz autodocs --flush` refreshes it (a `Vault` row reports the target). Vault notes
310
+ can also cite novahiz-docs as `novahiz-docs/<library>@<version>`.
281
311
  - **Ledger**: structural changes to the vault are logged in the novahiz ledger (task todos).
package/src/autodocs.ts CHANGED
@@ -74,6 +74,21 @@ export function statePath(cwd: string): string {
74
74
  return join(projectDir(cwd), STATE_NAME);
75
75
  }
76
76
 
77
+ /** Nom du projet côté module pur, sans dépendre de `commands/init.ts` (qui
78
+ * importe déjà ce fichier : importer `detectProject` créerait un cycle).
79
+ * package.json `name`, sinon le dernier segment du cwd — la même chute que
80
+ * `detectProject`, volontairement unique : l'init et le flush doivent produire
81
+ * le même slug, sans quoi la copie vault partirait dans un autre dossier. */
82
+ export function readProjectName(cwd: string): string {
83
+ try {
84
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { name?: unknown };
85
+ if (typeof pkg.name === "string" && pkg.name.trim().length > 0) return pkg.name;
86
+ } catch {
87
+ // name falls back to the folder below, exactly as detectProject does
88
+ }
89
+ return cwd.split(/[\\/]/).filter(Boolean).pop() ?? "project";
90
+ }
91
+
77
92
  function ensureDir(path: string): void {
78
93
  if (!existsSync(path)) mkdirSync(path, { recursive: true });
79
94
  }
@@ -7,12 +7,14 @@ import {
7
7
  isMajorPath,
8
8
  markDirty,
9
9
  readProjectConfig,
10
+ readProjectName,
10
11
  readState,
11
12
  upsertChangesSection,
12
13
  writeProjectConfig
13
14
  } from "../autodocs.ts";
14
15
  import { emit, flagOn, type Parsed } from "./context.ts";
15
16
  import { memoryRoot, writeEntry } from "../memory.ts";
17
+ import { syncProjectDocsToVault } from "./second-memory.ts";
16
18
  import * as ui from "../render.ts";
17
19
 
18
20
  const MAX_DOC_PATHS = 12;
@@ -26,6 +28,9 @@ export type FlushOutcome = {
26
28
  docsPath: string | null;
27
29
  docsUpdated: boolean;
28
30
  memorySlot: string | null;
31
+ /** Rel de la copie vault du projet, ou null quand rien n'a été répercuté :
32
+ * aucun binding, dossier cible absent, ou aucun fichier à recopier. */
33
+ vaultDocs: string | null;
29
34
  paths: string[];
30
35
  pending: number;
31
36
  lastSync: string | null;
@@ -50,6 +55,7 @@ function skipResult(reason: string, paths: string[] = []): FlushOutcome {
50
55
  docsPath: null,
51
56
  docsUpdated: false,
52
57
  memorySlot: null,
58
+ vaultDocs: null,
53
59
  paths,
54
60
  pending: state.pending.length,
55
61
  lastSync: state.lastSync
@@ -98,6 +104,19 @@ export function flushAutoDocs(cwd: string): FlushOutcome {
98
104
  const paths = state.pending;
99
105
  const docs = flushDocs(cwd, paths);
100
106
  const memorySlot = flushMemory(cwd, paths);
107
+ // Volet « miroir » : la doc fraîchement écrite est répercutée dans
108
+ // <Domaine>/<Branche>/docs/<slug>/. Volontairement encapsulé plutôt que
109
+ // laissé dans le try du flush : docs et slot sont déjà écrits à ce point,
110
+ // et faire échouer le flush ici laisserait un état sale et un slot
111
+ // dupliqué à la prochaine exécution. Le vault est un miroir dérivé, la
112
+ // source de vérité reste le projet — il n'a pas le droit de le casser.
113
+ let vaultTarget: string | null = null;
114
+ try {
115
+ const vault = syncProjectDocsToVault(readProjectName(cwd), join(cwd, "novahiz-docs"), true);
116
+ vaultTarget = vault.target.length > 0 && vault.copied.length > 0 ? vault.target : null;
117
+ } catch {
118
+ vaultTarget = null;
119
+ }
101
120
  const next = clearDirty(cwd);
102
121
  return {
103
122
  status: "flushed",
@@ -105,6 +124,7 @@ export function flushAutoDocs(cwd: string): FlushOutcome {
105
124
  docsPath: docs.path,
106
125
  docsUpdated: docs.updated,
107
126
  memorySlot,
127
+ vaultDocs: vaultTarget,
108
128
  paths,
109
129
  pending: next.pending.length,
110
130
  lastSync: next.lastSync
@@ -116,6 +136,7 @@ export function flushAutoDocs(cwd: string): FlushOutcome {
116
136
  docsPath: null,
117
137
  docsUpdated: false,
118
138
  memorySlot: null,
139
+ vaultDocs: null,
119
140
  paths: state.pending,
120
141
  pending: state.pending.length,
121
142
  lastSync: state.lastSync
@@ -139,6 +160,7 @@ function markFromArgs(cwd: string, mark: string | boolean, positionals: string[]
139
160
  docsPath: null,
140
161
  docsUpdated: false,
141
162
  memorySlot: null,
163
+ vaultDocs: null,
142
164
  paths: [],
143
165
  pending: readState(cwd).pending.length,
144
166
  lastSync: readState(cwd).lastSync
@@ -162,6 +184,7 @@ function markFromArgs(cwd: string, mark: string | boolean, positionals: string[]
162
184
  docsPath: null,
163
185
  docsUpdated: false,
164
186
  memorySlot: null,
187
+ vaultDocs: null,
165
188
  paths: rejected,
166
189
  pending: state.pending.length,
167
190
  lastSync: state.lastSync
@@ -176,6 +199,7 @@ function markFromArgs(cwd: string, mark: string | boolean, positionals: string[]
176
199
  docsPath: null,
177
200
  docsUpdated: false,
178
201
  memorySlot: null,
202
+ vaultDocs: null,
179
203
  paths: accepted,
180
204
  pending: state.pending.length,
181
205
  lastSync: state.lastSync
@@ -233,7 +257,8 @@ export function commandAutodocs(parsed: Parsed): void {
233
257
  ["Action", outcome.status],
234
258
  ["Reason", outcome.reason],
235
259
  ["Docs", outcome.docsPath ? `${outcome.docsPath}${outcome.docsUpdated ? " updated" : " unchanged"}` : "n/a"],
236
- ["Memory", outcome.memorySlot ?? "n/a"]
260
+ ["Memory", outcome.memorySlot ?? "n/a"],
261
+ ["Vault", outcome.vaultDocs ?? "n/a"]
237
262
  ])
238
263
  );
239
264
  lines.push(
@@ -5,8 +5,9 @@ import { loadSpec, NovahizHome } from "../spec.ts";
5
5
  import { loadInstalledSkills } from "../catalog.ts";
6
6
  import { findContextFile } from "../impeccable.ts";
7
7
  import { ensureMemoryRoot, memoryRoot, writeEntry } from "../memory.ts";
8
+ import { readProjectName } from "../autodocs.ts";
8
9
  import { ensureProjectAutoDocsConfig } from "./autodocs.ts";
9
- import { projectInit } from "./second-memory.ts";
10
+ import { projectInit, syncProjectDocsToVault } from "./second-memory.ts";
10
11
  import * as ui from "../render.ts";
11
12
 
12
13
  type StepStatus = "created" | "skipped" | "dry-run" | "failed";
@@ -78,7 +79,7 @@ function detectProject(cwd: string): ProjectInfo {
78
79
  if (existsSync(join(cwd, "README.md"))) markers.push("README.md");
79
80
 
80
81
  if (!name) {
81
- name = cwd.split(/[\\/]/).filter(Boolean).pop() ?? "project";
82
+ name = readProjectName(cwd);
82
83
  }
83
84
  return { kind, name, version, markers };
84
85
  }
@@ -320,6 +321,53 @@ function obsidianProject(dryRun: boolean, project: ProjectInfo): InitStep {
320
321
  };
321
322
  }
322
323
 
324
+ /** Étape de suite : la place existe-t-elle, on la remplit.
325
+ * Recopie `<projet>/novahiz-docs/` dans le `<docs>/<slug>/` du vault. Elle
326
+ * hérite du verdict d'`obsidianProject` au lieu d'en dériver un second : si le
327
+ * dossier projet n'a pas pu être posé, il n'y a aucune cible légitime et cette
328
+ * étape se tait plutôt que d'en tenter une. Sans vault, sans binding ou sans
329
+ * dossier cible, elle saute avec le conseil exact ; aucun dossier n'est
330
+ * inventé (contrainte 1) et aucune copie ne part dans le vide. */
331
+ function obsidianDocs(cwd: string, dryRun: boolean, project: ProjectInfo, folder: InitStep): InitStep {
332
+ const label = "Obsidian project docs";
333
+ if (folder.status !== "created" && folder.status !== "skipped") {
334
+ return { id: "obsidian-docs", label, status: "skipped", detail: `project folder not ready — see "${folder.label}"` };
335
+ }
336
+ const name = project.name.trim();
337
+ if (name.length === 0) {
338
+ return { id: "obsidian-docs", label, status: "skipped", detail: "no project name detected (package.json)" };
339
+ }
340
+ let result;
341
+ try {
342
+ result = syncProjectDocsToVault(name, join(cwd, "novahiz-docs"), !dryRun);
343
+ } catch (error) {
344
+ return { id: "obsidian-docs", label, status: "failed", detail: String(error).slice(0, 160) };
345
+ }
346
+ const where = result.target.length > 0 ? result.target : `${result.domain}/${result.branch}/${result.slug}`;
347
+ if (result.warnings.length > 0 && result.copied.length === 0) {
348
+ return {
349
+ id: "obsidian-docs",
350
+ label,
351
+ status: "skipped",
352
+ detail: `${where}: ${result.warnings.join("; ").slice(0, 160)}`,
353
+ advice: result.target.length > 0 ? undefined : `Link it: novahiz second-memory project-init --name <project> --branch Domain/Branch --apply`
354
+ };
355
+ }
356
+ const counts = `${result.copied.length} file(s)${result.absent.length > 0 ? `, ${result.absent.length} absent` : ""}`;
357
+ if (dryRun) {
358
+ return { id: "obsidian-docs", label, status: "dry-run", detail: `${where}: ${counts} would be copied` };
359
+ }
360
+ if (result.warnings.length > 0) {
361
+ return { id: "obsidian-docs", label, status: "failed", detail: `${where}: ${result.warnings.join("; ").slice(0, 160)}` };
362
+ }
363
+ return {
364
+ id: "obsidian-docs",
365
+ label,
366
+ status: result.copied.length > 0 ? "created" : "skipped",
367
+ detail: result.copied.length > 0 ? `${where}: ${counts} mirrored` : `${where}: nothing to copy`
368
+ };
369
+ }
370
+
323
371
  export function commandInit(parsed: Parsed): void {
324
372
  const cwd = process.cwd();
325
373
  const home = NovahizHome();
@@ -362,7 +410,9 @@ export function commandInit(parsed: Parsed): void {
362
410
  });
363
411
  }
364
412
  steps.push(impeccableContext(home, cwd, dryRun));
365
- steps.push(obsidianProject(dryRun, project));
413
+ const folder = obsidianProject(dryRun, project);
414
+ steps.push(folder);
415
+ steps.push(obsidianDocs(cwd, dryRun, project, folder));
366
416
  }
367
417
 
368
418
  let cleanup: CleanupCandidate[] = [];
@@ -2260,6 +2260,141 @@ export function projectInit(name: string, branchFlag: string | undefined, apply:
2260
2260
  return result;
2261
2261
  }
2262
2262
 
2263
+ // --- project docs sync ---------------------------------------------------------
2264
+
2265
+ /** Les quatre fichiers que `novahiz init` pose dans `<projet>/novahiz-docs/` et
2266
+ * que la copie vault reflète. Liste fermée, même ordre canonique que le
2267
+ * template : on ne recopie pas n'importe quel fichier trouvé dans le dossier. */
2268
+ export const PROJECT_DOC_FILES = ["ARCHITECTURE.md", "CONVENTIONS.md", "DECISIONS.md", "STANDARDS.md"] as const;
2269
+
2270
+ export type DocsSyncResult = {
2271
+ apply: boolean;
2272
+ project: string;
2273
+ slug: string;
2274
+ /** Rel `<Domaine>/<Branche>/docs/<slug>` ; chaîne vide tant que la cible
2275
+ * n'est pas confirmée sur le disque. */
2276
+ target: string;
2277
+ /** Confiés uniquement quand la cible est établie : un domaine dont la branche
2278
+ * manque n'est pas un chemin valide, et son nom reste dans `warnings`. */
2279
+ domain: string;
2280
+ branch: string;
2281
+ copied: string[];
2282
+ absent: string[];
2283
+ warnings: string[];
2284
+ };
2285
+
2286
+ /** Append-only, une entrée par jour : `- HH:MM - message` sous `## YYYY-MM-DD`.
2287
+ * Même forme que l'entrée posée à l'initialisation du vault (L1036), pour que
2288
+ * le journal reste lisible comme un seul flux. Une entrée est posée uniquement
2289
+ * en mode apply : la trace d'un dry-run est un non-événement. */
2290
+ function appendLogLine(vault: string, message: string): void {
2291
+ const now = new Date();
2292
+ const date = now.toISOString().slice(0, 10);
2293
+ const time = now.toTimeString().slice(0, 5);
2294
+ const entry = `- ${time} - ${message}`;
2295
+ const path = join(vault, LOG_FILE);
2296
+ if (!existsSync(path)) {
2297
+ writeFileSync(path, `# Second Memory Log\n\nAppend-only. Never delete entries.\n\n## ${date}\n${entry}\n`, "utf8");
2298
+ return;
2299
+ }
2300
+ const body = readFileSync(path, "utf8").replace(/\s*$/, "");
2301
+ // log.md est chronologique : la section du jour, si elle existe, est la dernière.
2302
+ const marker = "## " + date;
2303
+ const lastHeading = body.slice(body.lastIndexOf("\n## ") + 1);
2304
+ const todayIsLast = lastHeading.startsWith(marker);
2305
+ const head = todayIsLast ? body + "\n" : body + "\n\n" + marker + "\n";
2306
+ writeFileSync(path, head + entry + "\n", "utf8");
2307
+ }
2308
+
2309
+ /** Copie la documentation d'un projet vers `<Domaine>/<Branche>/docs/<slug>/`.
2310
+ *
2311
+ * Volet « écriture automatique dans le vault » de `novahiz init` et de
2312
+ * `autodocs flush`. La copie est **un miroir, jamais la source** : chaque note
2313
+ * reflète un fichier qui existe toujours dans `<projet>/novahiz-docs/`, donc un
2314
+ * écrasement ne détruit rien qu'on ne puisse régénérer — c'est ce qui rend
2315
+ * l'absence de backup défendable ici, contrairement à une note écrite à la main.
2316
+ *
2317
+ * Garde-fous, tous explicites et non silencieux :
2318
+ * - aucun binding → on ne devine pas de branche, on renvoie le conseil ;
2319
+ * - branche absente du catalogue → le vault a bougé, on ne crée rien ;
2320
+ * - dossier cible absent → `project-init` crée la chaîne, on ne l'invente pas
2321
+ * (contrainte 1) ;
2322
+ * - fichier absent du projet → reporté dans `absent`, pas d'étublage.
2323
+ *
2324
+ * En dry-run (`apply: false`), rien n'est écrit — ni note, ni entrée de log ;
2325
+ * `copied` liste alors ce qui *serait* copié. */
2326
+ export function syncProjectDocsToVault(name: string, docsDir: string, apply: boolean): DocsSyncResult {
2327
+ const trimmed = (name ?? "").trim();
2328
+ const slug = projectFolder(trimmed, "");
2329
+ const result: DocsSyncResult = { apply, project: trimmed, slug, domain: "", branch: "", target: "", copied: [], absent: [], warnings: [] };
2330
+ if (slug.length === 0) {
2331
+ result.warnings.push("empty project name after slugify — pass a real project name");
2332
+ return result;
2333
+ }
2334
+ const vault = vaultPath();
2335
+ if (!existsSync(vault)) {
2336
+ result.warnings.push("vault not found — run `novahiz second-memory init` first");
2337
+ return result;
2338
+ }
2339
+ if (!existsSync(docsDir)) {
2340
+ result.warnings.push(`no project docs at ${docsDir} — run \`novahiz init\` to create them`);
2341
+ return result;
2342
+ }
2343
+ const binding = readBinding(slug);
2344
+ if (!binding) {
2345
+ result.warnings.push(`no vault binding for "${slug}" — run \`novahiz second-memory project-init --name ${slug} --branch Domain/Branch\``);
2346
+ return result;
2347
+ }
2348
+ const domainNode = findDomain(binding.domain);
2349
+ const branchNode = domainNode?.children?.find((node) => node.name === binding.branch) ?? null;
2350
+ if (!domainNode || !branchNode) {
2351
+ result.warnings.push(`binding points to an unknown branch ${binding.domain}/${binding.branch} — re-run project-init`);
2352
+ return result;
2353
+ }
2354
+ result.domain = domainNode.name;
2355
+ result.branch = branchNode.name;
2356
+ const leaf = (branchNode.children ?? []).find((node) => node.kind === "docs");
2357
+ if (!leaf) {
2358
+ result.warnings.push(`branch ${domainNode.name}/${branchNode.name} carries no docs leaf`);
2359
+ return result;
2360
+ }
2361
+ const target = `${domainNode.name}/${branchNode.name}/${leaf.name}/${slug}`;
2362
+ if (!existsSync(join(vault, target))) {
2363
+ result.warnings.push(`target folder missing: ${target} — run \`novahiz second-memory project-init --name ${slug}\``);
2364
+ return result;
2365
+ }
2366
+ result.target = target;
2367
+
2368
+ for (const file of PROJECT_DOC_FILES) {
2369
+ const source = join(docsDir, file);
2370
+ let content: string;
2371
+ try {
2372
+ content = readFileSync(source, "utf8");
2373
+ } catch {
2374
+ result.absent.push(file);
2375
+ continue;
2376
+ }
2377
+ if (!apply) {
2378
+ result.copied.push(file);
2379
+ continue;
2380
+ }
2381
+ try {
2382
+ writeFileSync(join(vault, target, file), content, "utf8");
2383
+ result.copied.push(file);
2384
+ } catch (error) {
2385
+ result.warnings.push(`${file}: ${String(error).slice(0, 200)}`);
2386
+ }
2387
+ }
2388
+ if (apply && result.copied.length > 0) {
2389
+ try {
2390
+ appendLogLine(vault, `project docs synced: ${target} (${result.copied.length} file(s))`);
2391
+ } catch (error) {
2392
+ result.warnings.push(`log: ${String(error).slice(0, 160)}`);
2393
+ }
2394
+ }
2395
+ return result;
2396
+ }
2397
+
2263
2398
  function secondMemoryProjectInit(parsed: Parsed): void {
2264
2399
  const apply = flagOn(parsed, "apply");
2265
2400
  const name = String(parsed.flags.name ?? "").trim();