novahiz 0.9.5 → 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 +76 -0
- package/catalog/rules.json +10 -5
- package/dist/autodocs.js +16 -0
- package/dist/commands/autodocs.js +24 -2
- package/dist/commands/doctor.js +59 -6
- package/dist/commands/init.js +53 -3
- package/dist/commands/second-memory.js +117 -0
- package/dist/gate.js +9 -0
- package/install/install.mjs +8 -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 +51 -7
- package/src/commands/init.ts +53 -3
- package/src/commands/second-memory.ts +135 -0
- package/src/gate.ts +11 -0
- package/src/spec.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,82 @@ 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
|
+
|
|
33
|
+
## [0.9.6] - 2026-10-15
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **`PRODUCT.md` and `DESIGN.md`** - the product context and the visual-system
|
|
38
|
+
record `impeccable` asks for, both written from sources in the repository
|
|
39
|
+
(`README.md`, `docs/CONSTITUTION.md`, `src/render.ts`) with anything they do
|
|
40
|
+
not settle marked "not determined" instead of guessed. `novahiz doctor` runs
|
|
41
|
+
from a project now finds both instead of two `fail` rows; when doctor runs
|
|
42
|
+
from the Novahiz home both rows are skipped by design
|
|
43
|
+
(`impeccableChecks` returns early when `cwd === home`).
|
|
44
|
+
|
|
45
|
+
- **`when.pathExcludes`** - a rule-level *negative* selector, the counterpart of
|
|
46
|
+
`pathGlobs`. A rule carrying it never applies to a matching path, whatever the
|
|
47
|
+
other selectors say. With `match: "any"` a prompt category alone fired on any
|
|
48
|
+
file, so `design-ui` demanded the frontend design skills on unit tests, and
|
|
49
|
+
inclusion-only selectors could not express "never on tests" (`**/*.tsx` also
|
|
50
|
+
matches `tests/Foo.test.tsx`). Applied to the five design-scoped rules
|
|
51
|
+
(R11-accessibility, R13-design-craft, R14-impeccable, R16-stitch-fidelity,
|
|
52
|
+
R17-wireframe), which now skip `**/tests/**`, `**/__tests__/**`,
|
|
53
|
+
`**/*.test.*` and `**/*.spec.*`. R9-code-review and R10-security are
|
|
54
|
+
untouched — a test is still code — and R15-token-economy keeps its own
|
|
55
|
+
`**/tests/token-economy.test.ts` target.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- **The installer states its own limit** - `install.mjs` now prints that
|
|
60
|
+
`deps --install` is an install-only pass: packs already present are left
|
|
61
|
+
untouched, and `/novahiz-skills-update` (`deps --install --update`) is what
|
|
62
|
+
refreshes them. Installation and update stay separate commands by choice — a
|
|
63
|
+
reinstall must not overwrite local skill edits — but the choice was previously
|
|
64
|
+
only written down in `docs/PROVIDERS.md`, not shown to the person installing.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- **`doctor` no longer advises a command that cannot help** - the "Referenced
|
|
69
|
+
skills" row counted every absent non-shipped skill as a pack skill and
|
|
70
|
+
answered "run /novahiz-skills-update, or novahiz deps --install". Measured on
|
|
71
|
+
the 0.9.5 install: the 7 skills it named are `eas-app-stores`, `eas-hosting`,
|
|
72
|
+
`eas-observe`, `eas-simulator`, `eas-update`, `eas-update-insights`,
|
|
73
|
+
`eas-workflows`, referenced as `defaultSkills` by the `test` and `devops`
|
|
74
|
+
categories, while `catalog/providers.json` ships four skill packs only
|
|
75
|
+
(`flutter-skills`, `dart-skills`, `expo-skills`, `impeccable`) and the expo
|
|
76
|
+
command names 20 `expo-*` skills, no `eas-*`. `deps --install` accordingly
|
|
77
|
+
returned `install: []` and exited 0 - nothing to install. The row now reads
|
|
78
|
+
each pack's `install` argv (`--skill` list, or `--skill *` resolved to the
|
|
79
|
+
repo's leading segment, so `dart-lang/skills` covers `dart-*`), and skills no
|
|
80
|
+
pack can deliver are named as such instead of being sent after a command that
|
|
81
|
+
installs nothing. New `tests/doctor-referenced-skills.test.ts` pins the
|
|
82
|
+
contract - eight cases, none existed before.
|
|
83
|
+
|
|
8
84
|
## [0.9.5] - 2026-10-15
|
|
9
85
|
|
|
10
86
|
> 0.9.4 was prepared on 2026-10-14 but never tagged nor published to npm
|
package/catalog/rules.json
CHANGED
|
@@ -94,7 +94,8 @@
|
|
|
94
94
|
"description": "Load novahiz-wcag-audit on design-ui work and on audit prompts, where accessibility is in scope.",
|
|
95
95
|
"when": {
|
|
96
96
|
"match": "any",
|
|
97
|
-
"promptCategories": ["design-ui", "audit"]
|
|
97
|
+
"promptCategories": ["design-ui", "audit"],
|
|
98
|
+
"pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
|
|
98
99
|
},
|
|
99
100
|
"require": ["novahiz-wcag-audit"]
|
|
100
101
|
},
|
|
@@ -121,7 +122,8 @@
|
|
|
121
122
|
"**/*.html",
|
|
122
123
|
"**/*.htm"
|
|
123
124
|
],
|
|
124
|
-
"minChange": 200
|
|
125
|
+
"minChange": 200,
|
|
126
|
+
"pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
|
|
125
127
|
},
|
|
126
128
|
"require": ["novahiz-humanizer", "ui-slop-remover", "ui-craft-rules"]
|
|
127
129
|
},
|
|
@@ -139,7 +141,8 @@
|
|
|
139
141
|
"**/*.html",
|
|
140
142
|
"**/*.htm"
|
|
141
143
|
],
|
|
142
|
-
"minChange": 200
|
|
144
|
+
"minChange": 200,
|
|
145
|
+
"pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
|
|
143
146
|
},
|
|
144
147
|
"require": ["impeccable"]
|
|
145
148
|
},
|
|
@@ -172,7 +175,8 @@
|
|
|
172
175
|
"**/*.scss",
|
|
173
176
|
"**/*.html"
|
|
174
177
|
],
|
|
175
|
-
"projectGlobs": ["stitch/**"]
|
|
178
|
+
"projectGlobs": ["stitch/**"],
|
|
179
|
+
"pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
|
|
176
180
|
},
|
|
177
181
|
"require": ["novahiz-stitch-fidelity"]
|
|
178
182
|
},
|
|
@@ -192,7 +196,8 @@
|
|
|
192
196
|
"**/components/**"
|
|
193
197
|
],
|
|
194
198
|
"tiers": ["lite", "full"],
|
|
195
|
-
"minChange": 800
|
|
199
|
+
"minChange": 800,
|
|
200
|
+
"pathExcludes": ["**/tests/**", "**/__tests__/**", "**/*.test.*", "**/*.spec.*"]
|
|
196
201
|
},
|
|
197
202
|
"require": ["novahiz-wireframe"]
|
|
198
203
|
},
|
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
|
@@ -460,19 +460,72 @@ export function referencedSkillsCheck(spec, indexSkills, packageRootDir) {
|
|
|
460
460
|
}
|
|
461
461
|
}
|
|
462
462
|
const absentShipped = absent.filter((id) => shipped.has(id));
|
|
463
|
-
const absentPack = absent.
|
|
463
|
+
const absentPack = absent.filter((id) => !shipped.has(id));
|
|
464
|
+
// Only promise the update command when a pack can actually deliver what is
|
|
465
|
+
// missing: see packDelivery below.
|
|
466
|
+
const { exact, prefixes } = packDelivery(spec);
|
|
467
|
+
const deliverable = (id) => exact.has(id) || prefixes.some((p) => id.startsWith(p));
|
|
468
|
+
const undeliverable = absentPack.filter((id) => !deliverable(id));
|
|
469
|
+
const present = referenced.length - absent.length;
|
|
470
|
+
let detail;
|
|
471
|
+
if (absent.length === 0)
|
|
472
|
+
detail = `${present} present`;
|
|
473
|
+
else if (absentShipped.length > 0)
|
|
474
|
+
detail = `missing from index: ${absent.join(", ")}`;
|
|
475
|
+
else {
|
|
476
|
+
detail = `${present} present, ${absentPack.length} pack skills not installed`;
|
|
477
|
+
// A skill the catalog references but no pack ships can never be installed —
|
|
478
|
+
// say so instead of sending the reader after a command that returns nothing.
|
|
479
|
+
detail +=
|
|
480
|
+
undeliverable.length > 0
|
|
481
|
+
? `; ${undeliverable.join(", ")} referenced by no pack (deps cannot install them)`
|
|
482
|
+
: " (run /novahiz-skills-update, or novahiz deps --install)";
|
|
483
|
+
}
|
|
464
484
|
return {
|
|
465
485
|
id: "referenced",
|
|
466
486
|
label: "Referenced skills",
|
|
467
487
|
ok: absentShipped.length === 0,
|
|
468
|
-
detail
|
|
469
|
-
? `${referenced.length} present`
|
|
470
|
-
: absentShipped.length > 0
|
|
471
|
-
? `missing from index: ${absent.join(", ")}`
|
|
472
|
-
: `${referenced.length - absent.length} present, ${absentPack} pack skills not installed (run /novahiz-skills-update, or novahiz deps --install)`,
|
|
488
|
+
detail,
|
|
473
489
|
blocking: absentShipped.length > 0
|
|
474
490
|
};
|
|
475
491
|
}
|
|
492
|
+
/** Skills a provider pack could really install, read off each pack's `install`
|
|
493
|
+
* argv: an explicit `--skill a b c` names them, and `--skill *` delivers the
|
|
494
|
+
* whole repo, whose ids start with the repo's leading segment with any hyphen
|
|
495
|
+
* suffix dropped (`flutter/skills` -> `flutter-`, `dart-lang/skills` ->
|
|
496
|
+
* `dart-`). Measured 2026-10-15: the 7 skills doctor reported as "pack skills
|
|
497
|
+
* not installed" are `eas-*`, referenced as `defaultSkills` by the `test` and
|
|
498
|
+
* `devops` categories while no provider ships them, so
|
|
499
|
+
* "run /novahiz-skills-update, or novahiz deps --install" could not have
|
|
500
|
+
* installed a single one. Advice no command can honour is a defect. */
|
|
501
|
+
function packDelivery(spec) {
|
|
502
|
+
const exact = new Set();
|
|
503
|
+
const prefixes = [];
|
|
504
|
+
const disabled = new Set(spec.config.providers.disabled);
|
|
505
|
+
for (const provider of spec.providers) {
|
|
506
|
+
if (disabled.has(provider.id))
|
|
507
|
+
continue;
|
|
508
|
+
const argv = provider.install;
|
|
509
|
+
if (!Array.isArray(argv) || argv.length === 0)
|
|
510
|
+
continue;
|
|
511
|
+
const at = argv.indexOf("--skill");
|
|
512
|
+
if (at === -1)
|
|
513
|
+
continue;
|
|
514
|
+
const args = argv.slice(at + 1).filter((arg) => !arg.startsWith("-"));
|
|
515
|
+
if (args.length === 0)
|
|
516
|
+
continue;
|
|
517
|
+
if (args.includes("*")) {
|
|
518
|
+
const repo = String(argv.find((arg) => arg.includes("/")) ?? "");
|
|
519
|
+
const head = repo.split("/")[0].split("-")[0];
|
|
520
|
+
if (head.length > 0)
|
|
521
|
+
prefixes.push(`${head}-`);
|
|
522
|
+
}
|
|
523
|
+
for (const id of args)
|
|
524
|
+
if (id !== "*")
|
|
525
|
+
exact.add(id);
|
|
526
|
+
}
|
|
527
|
+
return { exact, prefixes };
|
|
528
|
+
}
|
|
476
529
|
/** Impeccable design-context checks: PRODUCT.md and DESIGN.md resolution
|
|
477
530
|
* (root -> .agents/context -> docs/). Rows appear only when the impeccable
|
|
478
531
|
* 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/dist/gate.js
CHANGED
|
@@ -301,6 +301,15 @@ function selectorMatches(rule, classification, path, categories, pathless = fals
|
|
|
301
301
|
checks.push(when.promptCategories.some((category) => categories.includes(category)));
|
|
302
302
|
}
|
|
303
303
|
else {
|
|
304
|
+
// E1: pathExcludes is the negative counterpart of pathGlobs. A rule that
|
|
305
|
+
// carries it never applies to an excluded path, whatever the other
|
|
306
|
+
// selectors say — the only way to express "never on tests" when match is
|
|
307
|
+
// "any", where a prompt category alone would otherwise fire on any file.
|
|
308
|
+
if (when.pathExcludes &&
|
|
309
|
+
when.pathExcludes.length > 0 &&
|
|
310
|
+
when.pathExcludes.some((glob) => globToRegExp(glob).test(path))) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
304
313
|
if (when.fileClasses && when.fileClasses.length > 0)
|
|
305
314
|
checks.push(when.fileClasses.includes(classification));
|
|
306
315
|
if (when.pathGlobs && when.pathGlobs.length > 0)
|
package/install/install.mjs
CHANGED
|
@@ -459,6 +459,14 @@ async function main() {
|
|
|
459
459
|
});
|
|
460
460
|
if (result.stdout) ui.raw(result.stdout);
|
|
461
461
|
if (result.status !== 0 && result.stderr) process.stderr.write(result.stderr);
|
|
462
|
+
// Arbitrage assumé: installation et mise à jour restent deux commandes.
|
|
463
|
+
// `deps --install` est idempotent — un pack déjà présent n'est jamais
|
|
464
|
+
// rafraîchi ici, sinon une réinstallation écraserait les retouches
|
|
465
|
+
// locales. C'est documenté dans docs/PROVIDERS.md; on le dit aussi à
|
|
466
|
+
// l'écran plutôt que de laisser le paquet sembler à jour sans l'être.
|
|
467
|
+
if (autoInstall && result.status === 0) {
|
|
468
|
+
note("Installs only: skill packs already present are left untouched. Refresh them with /novahiz-skills-update (deps --install --update).");
|
|
469
|
+
}
|
|
462
470
|
}
|
|
463
471
|
}
|
|
464
472
|
|
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
|
@@ -459,21 +459,65 @@ export function referencedSkillsCheck(
|
|
|
459
459
|
}
|
|
460
460
|
}
|
|
461
461
|
const absentShipped = absent.filter((id) => shipped.has(id));
|
|
462
|
-
const absentPack = absent.
|
|
462
|
+
const absentPack = absent.filter((id) => !shipped.has(id));
|
|
463
|
+
// Only promise the update command when a pack can actually deliver what is
|
|
464
|
+
// missing: see packDelivery below.
|
|
465
|
+
const { exact, prefixes } = packDelivery(spec);
|
|
466
|
+
const deliverable = (id: string): boolean => exact.has(id) || prefixes.some((p) => id.startsWith(p));
|
|
467
|
+
const undeliverable = absentPack.filter((id) => !deliverable(id));
|
|
468
|
+
const present = referenced.length - absent.length;
|
|
469
|
+
let detail: string;
|
|
470
|
+
if (absent.length === 0) detail = `${present} present`;
|
|
471
|
+
else if (absentShipped.length > 0) detail = `missing from index: ${absent.join(", ")}`;
|
|
472
|
+
else {
|
|
473
|
+
detail = `${present} present, ${absentPack.length} pack skills not installed`;
|
|
474
|
+
// A skill the catalog references but no pack ships can never be installed —
|
|
475
|
+
// say so instead of sending the reader after a command that returns nothing.
|
|
476
|
+
detail +=
|
|
477
|
+
undeliverable.length > 0
|
|
478
|
+
? `; ${undeliverable.join(", ")} referenced by no pack (deps cannot install them)`
|
|
479
|
+
: " (run /novahiz-skills-update, or novahiz deps --install)";
|
|
480
|
+
}
|
|
463
481
|
return {
|
|
464
482
|
id: "referenced",
|
|
465
483
|
label: "Referenced skills",
|
|
466
484
|
ok: absentShipped.length === 0,
|
|
467
|
-
detail
|
|
468
|
-
absent.length === 0
|
|
469
|
-
? `${referenced.length} present`
|
|
470
|
-
: absentShipped.length > 0
|
|
471
|
-
? `missing from index: ${absent.join(", ")}`
|
|
472
|
-
: `${referenced.length - absent.length} present, ${absentPack} pack skills not installed (run /novahiz-skills-update, or novahiz deps --install)`,
|
|
485
|
+
detail,
|
|
473
486
|
blocking: absentShipped.length > 0
|
|
474
487
|
};
|
|
475
488
|
}
|
|
476
489
|
|
|
490
|
+
/** Skills a provider pack could really install, read off each pack's `install`
|
|
491
|
+
* argv: an explicit `--skill a b c` names them, and `--skill *` delivers the
|
|
492
|
+
* whole repo, whose ids start with the repo's leading segment with any hyphen
|
|
493
|
+
* suffix dropped (`flutter/skills` -> `flutter-`, `dart-lang/skills` ->
|
|
494
|
+
* `dart-`). Measured 2026-10-15: the 7 skills doctor reported as "pack skills
|
|
495
|
+
* not installed" are `eas-*`, referenced as `defaultSkills` by the `test` and
|
|
496
|
+
* `devops` categories while no provider ships them, so
|
|
497
|
+
* "run /novahiz-skills-update, or novahiz deps --install" could not have
|
|
498
|
+
* installed a single one. Advice no command can honour is a defect. */
|
|
499
|
+
function packDelivery(spec: ReturnType<typeof loadSpec>): { exact: Set<string>; prefixes: string[] } {
|
|
500
|
+
const exact = new Set<string>();
|
|
501
|
+
const prefixes: string[] = [];
|
|
502
|
+
const disabled = new Set(spec.config.providers.disabled);
|
|
503
|
+
for (const provider of spec.providers) {
|
|
504
|
+
if (disabled.has(provider.id)) continue;
|
|
505
|
+
const argv = provider.install;
|
|
506
|
+
if (!Array.isArray(argv) || argv.length === 0) continue;
|
|
507
|
+
const at = argv.indexOf("--skill");
|
|
508
|
+
if (at === -1) continue;
|
|
509
|
+
const args = argv.slice(at + 1).filter((arg) => !arg.startsWith("-"));
|
|
510
|
+
if (args.length === 0) continue;
|
|
511
|
+
if (args.includes("*")) {
|
|
512
|
+
const repo = String(argv.find((arg) => arg.includes("/")) ?? "");
|
|
513
|
+
const head = repo.split("/")[0].split("-")[0];
|
|
514
|
+
if (head.length > 0) prefixes.push(`${head}-`);
|
|
515
|
+
}
|
|
516
|
+
for (const id of args) if (id !== "*") exact.add(id);
|
|
517
|
+
}
|
|
518
|
+
return { exact, prefixes };
|
|
519
|
+
}
|
|
520
|
+
|
|
477
521
|
/** Impeccable design-context checks: PRODUCT.md and DESIGN.md resolution
|
|
478
522
|
* (root -> .agents/context -> docs/). Rows appear only when the impeccable
|
|
479
523
|
* 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();
|
package/src/gate.ts
CHANGED
|
@@ -291,6 +291,17 @@ function selectorMatches(rule: Rule, classification: FileClass, path: string, ca
|
|
|
291
291
|
if (!when.promptCategories || when.promptCategories.length === 0) return false;
|
|
292
292
|
checks.push(when.promptCategories.some((category) => categories.includes(category)));
|
|
293
293
|
} else {
|
|
294
|
+
// E1: pathExcludes is the negative counterpart of pathGlobs. A rule that
|
|
295
|
+
// carries it never applies to an excluded path, whatever the other
|
|
296
|
+
// selectors say — the only way to express "never on tests" when match is
|
|
297
|
+
// "any", where a prompt category alone would otherwise fire on any file.
|
|
298
|
+
if (
|
|
299
|
+
when.pathExcludes &&
|
|
300
|
+
when.pathExcludes.length > 0 &&
|
|
301
|
+
when.pathExcludes.some((glob) => globToRegExp(glob).test(path))
|
|
302
|
+
) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
294
305
|
if (when.fileClasses && when.fileClasses.length > 0) checks.push(when.fileClasses.includes(classification));
|
|
295
306
|
if (when.pathGlobs && when.pathGlobs.length > 0) checks.push(when.pathGlobs.some((glob) => globToRegExp(glob).test(path)));
|
|
296
307
|
if (when.promptCategories && when.promptCategories.length > 0) {
|
package/src/spec.ts
CHANGED
|
@@ -35,6 +35,12 @@ type RuleWhen = {
|
|
|
35
35
|
match?: "any" | "all";
|
|
36
36
|
fileClasses?: string[];
|
|
37
37
|
pathGlobs?: string[];
|
|
38
|
+
/** Negative counterpart of pathGlobs: the rule never applies to a path
|
|
39
|
+
* matching one of these globs, whatever the other selectors say. This is the
|
|
40
|
+
* only way to express "never on tests" when match is "any", where a prompt
|
|
41
|
+
* category on its own would otherwise fire on any file, unit tests included.
|
|
42
|
+
* Omitted = no exclusion, exactly as before. */
|
|
43
|
+
pathExcludes?: string[];
|
|
38
44
|
/** Project-scoped selector: the rule applies only when at least one file under
|
|
39
45
|
* the project root (nearest ancestor of the edited file holding .git,
|
|
40
46
|
* package.json or pubspec.yaml) matches one of these globs, written from that
|