novahiz 0.9.6 → 0.9.8
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 +52 -0
- package/catalog/categories.json +2 -10
- package/catalog/overrides.json +0 -77
- package/dist/autodocs.js +16 -0
- package/dist/commands/autodocs.js +24 -2
- package/dist/commands/doctor.js +32 -15
- package/dist/commands/init.js +53 -3
- package/dist/commands/second-memory.js +117 -0
- package/package.json +1 -1
- package/skills/novahiz-second-memory/SKILL.md +36 -6
- package/src/autodocs.ts +15 -0
- package/src/commands/autodocs.ts +26 -1
- package/src/commands/doctor.ts +38 -14
- package/src/commands/init.ts +53 -3
- package/src/commands/second-memory.ts +135 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,58 @@ 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.8] - 2026-10-15
|
|
9
|
+
|
|
10
|
+
### Removed
|
|
11
|
+
|
|
12
|
+
- **The 7 `eas-*` skills from the catalogue** - `test` and `devops` listed them
|
|
13
|
+
as `defaultSkills` (`eas-app-stores`, `eas-hosting`, `eas-observe`,
|
|
14
|
+
`eas-simulator`, `eas-update`, `eas-update-insights`, `eas-workflows`) while
|
|
15
|
+
the expo pack deliberately excludes them: `providers.json` says so in the
|
|
16
|
+
provider's own purpose. No provider could install a single one, so `doctor`
|
|
17
|
+
named them and `novahiz deps --install` returned `install: []`. 0.9.5 made
|
|
18
|
+
that row tell the truth instead of blaming the reader; the references
|
|
19
|
+
themselves stayed, and the row reports without blocking, which is why nothing
|
|
20
|
+
had failed since. `referencedSkills` now spans 51 ids and none of them is
|
|
21
|
+
undeliverable, so `doctor` reports `51 present` with no caveat. The seven
|
|
22
|
+
inert `overrides.json` entries went with them; the `devops` keywords `eas`,
|
|
23
|
+
`eas build` and `eas update` stay, since they route a deployment question to
|
|
24
|
+
a roadmap that still applies.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **A test holding the catalogue to that invariant** -
|
|
29
|
+
`undeliverableReferences(spec, packageRootDir)` is exported from
|
|
30
|
+
`doctor.ts`, and `tests/doctor-referenced-skills.test.ts` runs it against the
|
|
31
|
+
shipped catalogue: it must return `[]`, no `defaultSkills` may carry an
|
|
32
|
+
`eas-*`, and a reintroduced one must be named. Checked by injection: putting
|
|
33
|
+
`eas-simulator` back into `test.defaultSkills` turns both assertions red.
|
|
34
|
+
|
|
35
|
+
## [0.9.7] - 2026-10-15
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **Project docs mirrored into the vault** - `novahiz init` now fills the
|
|
40
|
+
`<Domain>/<Branch>/docs/<slug>/` folder it creates instead of leaving it
|
|
41
|
+
empty: a new `Obsidian project docs` step copies `ARCHITECTURE.md`,
|
|
42
|
+
`CONVENTIONS.md`, `DECISIONS.md` and `STANDARDS.md` verbatim from
|
|
43
|
+
`novahiz-docs/`, and `novahiz autodocs --flush` re-mirrors them on every
|
|
44
|
+
flush, reported as a `Vault` row. The folder previously appeared holding only
|
|
45
|
+
a `_MOC.md` - the `docs` leaf of the catalogue existed and nothing ever wrote
|
|
46
|
+
into it. The copy is a **mirror, never the source**: every note reflects a
|
|
47
|
+
file that still lives in the project, which is what makes an overwriting flush
|
|
48
|
+
safe instead of a second history to maintain. Guards, none silent: no
|
|
49
|
+
`vault.json` binding, a binding outside the catalogue, or a target folder
|
|
50
|
+
`project-init` never created all skip with advice and invent nothing (contract
|
|
51
|
+
1); a doc file absent from the project is reported as `absent`, never
|
|
52
|
+
manufactured; a dry-run writes neither note nor log entry. Each real write
|
|
53
|
+
appends one `- HH:MM -` line under the day's heading of `log.md` through
|
|
54
|
+
`appendLogLine` - contract 8's "log the operation" was an instruction for the
|
|
55
|
+
agent and not code, so it only happened when someone remembered it. The
|
|
56
|
+
project name now resolves through a single `readProjectName` helper shared by
|
|
57
|
+
`init` and `flush`, so the seed and the refresh cannot slugify to different
|
|
58
|
+
folders.
|
|
59
|
+
|
|
8
60
|
## [0.9.6] - 2026-10-15
|
|
9
61
|
|
|
10
62
|
### Added
|
package/catalog/categories.json
CHANGED
|
@@ -554,8 +554,7 @@
|
|
|
554
554
|
"defaultSkills": [
|
|
555
555
|
"flutter-add-widget-test",
|
|
556
556
|
"dart-add-unit-test",
|
|
557
|
-
"expo-skill-eval"
|
|
558
|
-
"eas-simulator"
|
|
557
|
+
"expo-skill-eval"
|
|
559
558
|
],
|
|
560
559
|
"roadmap": {
|
|
561
560
|
"id": "testing",
|
|
@@ -1329,14 +1328,7 @@
|
|
|
1329
1328
|
"tag a version",
|
|
1330
1329
|
"changelog"
|
|
1331
1330
|
],
|
|
1332
|
-
"defaultSkills": [
|
|
1333
|
-
"eas-workflows",
|
|
1334
|
-
"eas-app-stores",
|
|
1335
|
-
"eas-hosting",
|
|
1336
|
-
"eas-observe",
|
|
1337
|
-
"eas-update",
|
|
1338
|
-
"eas-update-insights"
|
|
1339
|
-
],
|
|
1331
|
+
"defaultSkills": [],
|
|
1340
1332
|
"roadmap": {
|
|
1341
1333
|
"id": "devops",
|
|
1342
1334
|
"steps": [
|
package/catalog/overrides.json
CHANGED
|
@@ -634,83 +634,6 @@
|
|
|
634
634
|
"docs-writing"
|
|
635
635
|
]
|
|
636
636
|
},
|
|
637
|
-
"eas-app-stores": {
|
|
638
|
-
"power": 4,
|
|
639
|
-
"stars": null,
|
|
640
|
-
"tags": [
|
|
641
|
-
"eas",
|
|
642
|
-
"expo"
|
|
643
|
-
],
|
|
644
|
-
"categories": [
|
|
645
|
-
"expo"
|
|
646
|
-
]
|
|
647
|
-
},
|
|
648
|
-
"eas-hosting": {
|
|
649
|
-
"power": 4,
|
|
650
|
-
"stars": null,
|
|
651
|
-
"tags": [
|
|
652
|
-
"eas",
|
|
653
|
-
"expo"
|
|
654
|
-
],
|
|
655
|
-
"categories": [
|
|
656
|
-
"expo"
|
|
657
|
-
]
|
|
658
|
-
},
|
|
659
|
-
"eas-observe": {
|
|
660
|
-
"power": 4,
|
|
661
|
-
"stars": null,
|
|
662
|
-
"tags": [
|
|
663
|
-
"eas",
|
|
664
|
-
"expo"
|
|
665
|
-
],
|
|
666
|
-
"categories": [
|
|
667
|
-
"expo"
|
|
668
|
-
]
|
|
669
|
-
},
|
|
670
|
-
"eas-simulator": {
|
|
671
|
-
"power": 4,
|
|
672
|
-
"stars": null,
|
|
673
|
-
"tags": [
|
|
674
|
-
"eas",
|
|
675
|
-
"expo"
|
|
676
|
-
],
|
|
677
|
-
"categories": [
|
|
678
|
-
"expo"
|
|
679
|
-
]
|
|
680
|
-
},
|
|
681
|
-
"eas-update": {
|
|
682
|
-
"power": 4,
|
|
683
|
-
"stars": null,
|
|
684
|
-
"tags": [
|
|
685
|
-
"eas",
|
|
686
|
-
"expo"
|
|
687
|
-
],
|
|
688
|
-
"categories": [
|
|
689
|
-
"expo"
|
|
690
|
-
]
|
|
691
|
-
},
|
|
692
|
-
"eas-update-insights": {
|
|
693
|
-
"power": 4,
|
|
694
|
-
"stars": null,
|
|
695
|
-
"tags": [
|
|
696
|
-
"eas",
|
|
697
|
-
"expo"
|
|
698
|
-
],
|
|
699
|
-
"categories": [
|
|
700
|
-
"expo"
|
|
701
|
-
]
|
|
702
|
-
},
|
|
703
|
-
"eas-workflows": {
|
|
704
|
-
"power": 4,
|
|
705
|
-
"stars": null,
|
|
706
|
-
"tags": [
|
|
707
|
-
"eas",
|
|
708
|
-
"expo"
|
|
709
|
-
],
|
|
710
|
-
"categories": [
|
|
711
|
-
"expo"
|
|
712
|
-
]
|
|
713
|
-
},
|
|
714
637
|
"expo-animation": {
|
|
715
638
|
"power": 4,
|
|
716
639
|
"stars": null,
|
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
|
}
|
package/dist/commands/doctor.js
CHANGED
|
@@ -443,22 +443,15 @@ function referencedSkills(spec) {
|
|
|
443
443
|
* `indexSkills` is null when the index is unreadable — the index check already
|
|
444
444
|
* reports that, so the row stays quiet instead of double-failing.
|
|
445
445
|
* Only skills the package ships (packageRoot/skills) can make a fresh install
|
|
446
|
-
* broken. The remaining references
|
|
447
|
-
* impeccable
|
|
446
|
+
* broken. The remaining references, the dart/expo/flutter packs and
|
|
447
|
+
* impeccable, are optional delivery downloaded on request and are simply not
|
|
448
448
|
* present on a machine that never asked for them; the gate already degrades
|
|
449
449
|
* them to "not enforced". Blocking on them turned every healthy fresh install
|
|
450
450
|
* red (`missing from index: dart-...` right after novahiz-install --yes). */
|
|
451
451
|
export function referencedSkillsCheck(spec, indexSkills, packageRootDir) {
|
|
452
452
|
const referenced = referencedSkills(spec);
|
|
453
453
|
const absent = indexSkills ? referenced.filter((id) => !indexSkills.has(id)) : [];
|
|
454
|
-
const shipped =
|
|
455
|
-
const shippedDir = join(packageRootDir, "skills");
|
|
456
|
-
if (existsSync(shippedDir)) {
|
|
457
|
-
for (const entry of readdirSync(shippedDir, { withFileTypes: true })) {
|
|
458
|
-
if (entry.isDirectory())
|
|
459
|
-
shipped.add(entry.name);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
454
|
+
const shipped = shippedSkills(packageRootDir);
|
|
462
455
|
const absentShipped = absent.filter((id) => shipped.has(id));
|
|
463
456
|
const absentPack = absent.filter((id) => !shipped.has(id));
|
|
464
457
|
// Only promise the update command when a pack can actually deliver what is
|
|
@@ -493,11 +486,14 @@ export function referencedSkillsCheck(spec, indexSkills, packageRootDir) {
|
|
|
493
486
|
* argv: an explicit `--skill a b c` names them, and `--skill *` delivers the
|
|
494
487
|
* whole repo, whose ids start with the repo's leading segment with any hyphen
|
|
495
488
|
* suffix dropped (`flutter/skills` -> `flutter-`, `dart-lang/skills` ->
|
|
496
|
-
* `dart-`). Measured 2026-10-15: the 7 skills doctor
|
|
497
|
-
* not installed"
|
|
498
|
-
* `devops`
|
|
499
|
-
* "run /novahiz-skills-update, or novahiz deps --install"
|
|
500
|
-
* installed a single one. Advice no command can honour is a
|
|
489
|
+
* `dart-`). Measured 2026-10-15 on the 0.9.5 install: the 7 skills doctor
|
|
490
|
+
* reported as "pack skills not installed" were `eas-*`, referenced as
|
|
491
|
+
* `defaultSkills` by `test` and `devops` while the expo pack deliberately
|
|
492
|
+
* excludes them, so "run /novahiz-skills-update, or novahiz deps --install"
|
|
493
|
+
* could not have installed a single one. Advice no command can honour is a
|
|
494
|
+
* defect. Those references are gone from the catalog; the guard stays, since
|
|
495
|
+
* the same situation reappears whenever a pack is disabled or its `--skill`
|
|
496
|
+
* list stops naming an id the catalog still asks for. */
|
|
501
497
|
function packDelivery(spec) {
|
|
502
498
|
const exact = new Set();
|
|
503
499
|
const prefixes = [];
|
|
@@ -526,6 +522,27 @@ function packDelivery(spec) {
|
|
|
526
522
|
}
|
|
527
523
|
return { exact, prefixes };
|
|
528
524
|
}
|
|
525
|
+
/** Skill ids the package itself carries in `packageRootDir/skills`. */
|
|
526
|
+
function shippedSkills(packageRootDir) {
|
|
527
|
+
const shipped = new Set();
|
|
528
|
+
const shippedDir = join(packageRootDir, "skills");
|
|
529
|
+
if (existsSync(shippedDir)) {
|
|
530
|
+
for (const entry of readdirSync(shippedDir, { withFileTypes: true })) {
|
|
531
|
+
if (entry.isDirectory())
|
|
532
|
+
shipped.add(entry.name);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return shipped;
|
|
536
|
+
}
|
|
537
|
+
/** Referenced skill ids nothing in the system can supply: the package ships
|
|
538
|
+
* none of them and no enabled pack can install them. Exported so the catalog
|
|
539
|
+
* is held to this invariant in tests, since the row above reports it without
|
|
540
|
+
* blocking and therefore never failed on it. */
|
|
541
|
+
export function undeliverableReferences(spec, packageRootDir) {
|
|
542
|
+
const shipped = shippedSkills(packageRootDir);
|
|
543
|
+
const { exact, prefixes } = packDelivery(spec);
|
|
544
|
+
return referencedSkills(spec).filter((id) => !shipped.has(id) && !exact.has(id) && !prefixes.some((prefix) => id.startsWith(prefix)));
|
|
545
|
+
}
|
|
529
546
|
/** Impeccable design-context checks: PRODUCT.md and DESIGN.md resolution
|
|
530
547
|
* (root -> .agents/context -> docs/). Rows appear only when the impeccable
|
|
531
548
|
* skill is installed (opt-in) and are never blocking: a missing record is a
|
package/dist/commands/init.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
@@ -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;
|
|
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.
|
|
33
|
-
written at the vault root
|
|
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.
|
|
36
|
-
|
|
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**:
|
|
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
|
}
|
package/src/commands/autodocs.ts
CHANGED
|
@@ -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(
|
package/src/commands/doctor.ts
CHANGED
|
@@ -439,8 +439,8 @@ function referencedSkills(spec: ReturnType<typeof loadSpec>): string[] {
|
|
|
439
439
|
* `indexSkills` is null when the index is unreadable — the index check already
|
|
440
440
|
* reports that, so the row stays quiet instead of double-failing.
|
|
441
441
|
* Only skills the package ships (packageRoot/skills) can make a fresh install
|
|
442
|
-
* broken. The remaining references
|
|
443
|
-
* impeccable
|
|
442
|
+
* broken. The remaining references, the dart/expo/flutter packs and
|
|
443
|
+
* impeccable, are optional delivery downloaded on request and are simply not
|
|
444
444
|
* present on a machine that never asked for them; the gate already degrades
|
|
445
445
|
* them to "not enforced". Blocking on them turned every healthy fresh install
|
|
446
446
|
* red (`missing from index: dart-...` right after novahiz-install --yes). */
|
|
@@ -451,13 +451,7 @@ export function referencedSkillsCheck(
|
|
|
451
451
|
): DoctorCheck {
|
|
452
452
|
const referenced = referencedSkills(spec);
|
|
453
453
|
const absent = indexSkills ? referenced.filter((id) => !indexSkills.has(id)) : [];
|
|
454
|
-
const shipped =
|
|
455
|
-
const shippedDir = join(packageRootDir, "skills");
|
|
456
|
-
if (existsSync(shippedDir)) {
|
|
457
|
-
for (const entry of readdirSync(shippedDir, { withFileTypes: true })) {
|
|
458
|
-
if (entry.isDirectory()) shipped.add(entry.name);
|
|
459
|
-
}
|
|
460
|
-
}
|
|
454
|
+
const shipped = shippedSkills(packageRootDir);
|
|
461
455
|
const absentShipped = absent.filter((id) => shipped.has(id));
|
|
462
456
|
const absentPack = absent.filter((id) => !shipped.has(id));
|
|
463
457
|
// Only promise the update command when a pack can actually deliver what is
|
|
@@ -491,11 +485,14 @@ export function referencedSkillsCheck(
|
|
|
491
485
|
* argv: an explicit `--skill a b c` names them, and `--skill *` delivers the
|
|
492
486
|
* whole repo, whose ids start with the repo's leading segment with any hyphen
|
|
493
487
|
* suffix dropped (`flutter/skills` -> `flutter-`, `dart-lang/skills` ->
|
|
494
|
-
* `dart-`). Measured 2026-10-15: the 7 skills doctor
|
|
495
|
-
* not installed"
|
|
496
|
-
* `devops`
|
|
497
|
-
* "run /novahiz-skills-update, or novahiz deps --install"
|
|
498
|
-
* installed a single one. Advice no command can honour is a
|
|
488
|
+
* `dart-`). Measured 2026-10-15 on the 0.9.5 install: the 7 skills doctor
|
|
489
|
+
* reported as "pack skills not installed" were `eas-*`, referenced as
|
|
490
|
+
* `defaultSkills` by `test` and `devops` while the expo pack deliberately
|
|
491
|
+
* excludes them, so "run /novahiz-skills-update, or novahiz deps --install"
|
|
492
|
+
* could not have installed a single one. Advice no command can honour is a
|
|
493
|
+
* defect. Those references are gone from the catalog; the guard stays, since
|
|
494
|
+
* the same situation reappears whenever a pack is disabled or its `--skill`
|
|
495
|
+
* list stops naming an id the catalog still asks for. */
|
|
499
496
|
function packDelivery(spec: ReturnType<typeof loadSpec>): { exact: Set<string>; prefixes: string[] } {
|
|
500
497
|
const exact = new Set<string>();
|
|
501
498
|
const prefixes: string[] = [];
|
|
@@ -518,6 +515,33 @@ function packDelivery(spec: ReturnType<typeof loadSpec>): { exact: Set<string>;
|
|
|
518
515
|
return { exact, prefixes };
|
|
519
516
|
}
|
|
520
517
|
|
|
518
|
+
/** Skill ids the package itself carries in `packageRootDir/skills`. */
|
|
519
|
+
function shippedSkills(packageRootDir: string): Set<string> {
|
|
520
|
+
const shipped = new Set<string>();
|
|
521
|
+
const shippedDir = join(packageRootDir, "skills");
|
|
522
|
+
if (existsSync(shippedDir)) {
|
|
523
|
+
for (const entry of readdirSync(shippedDir, { withFileTypes: true })) {
|
|
524
|
+
if (entry.isDirectory()) shipped.add(entry.name);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return shipped;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** Referenced skill ids nothing in the system can supply: the package ships
|
|
531
|
+
* none of them and no enabled pack can install them. Exported so the catalog
|
|
532
|
+
* is held to this invariant in tests, since the row above reports it without
|
|
533
|
+
* blocking and therefore never failed on it. */
|
|
534
|
+
export function undeliverableReferences(
|
|
535
|
+
spec: ReturnType<typeof loadSpec>,
|
|
536
|
+
packageRootDir: string
|
|
537
|
+
): string[] {
|
|
538
|
+
const shipped = shippedSkills(packageRootDir);
|
|
539
|
+
const { exact, prefixes } = packDelivery(spec);
|
|
540
|
+
return referencedSkills(spec).filter(
|
|
541
|
+
(id) => !shipped.has(id) && !exact.has(id) && !prefixes.some((prefix) => id.startsWith(prefix))
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
521
545
|
/** Impeccable design-context checks: PRODUCT.md and DESIGN.md resolution
|
|
522
546
|
* (root -> .agents/context -> docs/). Rows appear only when the impeccable
|
|
523
547
|
* skill is installed (opt-in) and are never blocking: a missing record is a
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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();
|