faf-cli 7.12.1 → 7.13.1

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.
@@ -0,0 +1,8 @@
1
+ /** True if dir is the user's home directory or the filesystem root — compared
2
+ * by identity (device + inode), not by spelling. */
3
+ export declare function isNonProjectRoot(dir?: string): boolean;
4
+ /**
5
+ * Exit 1 with a clear cd instruction if cwd cannot be a project root.
6
+ * Call at the top of commands that create or interview project.faf.
7
+ */
8
+ export declare function assertProjectCwd(dir?: string, command?: string): void;
@@ -0,0 +1,147 @@
1
+ /**
2
+ * FAF DNA — the lifecycle of AI context. The "first heartbeat".
3
+ *
4
+ * Every project.faf gets a `.faf-dna` lineage record (separate file, NOT
5
+ * embedded in the .faf — so it's compatible with the clean dialect):
6
+ * - Birth Certificate: the honest first score (even 0%) — the "before" picture
7
+ * - Growth Record: version history as the score improves
8
+ * - Journey: the one-line story, e.g. "22% → 85% → 99% ← 92%"
9
+ *
10
+ * Birth DNA = the raw slot-based score at init. The growth from Birth DNA to
11
+ * the current score is the demonstrated value of FAF.
12
+ *
13
+ * Restored 2026-05-21 — silently dropped in the v6.0 clean-architecture rewrite
14
+ * (it was never knowingly removed). Ported from v5 (sync, v6-native, load-compatible).
15
+ *
16
+ * Other shapes. `.faf-dna` is committed lineage, and other tools have written
17
+ * it in their own shape (claude-faf-mcp's faf_dna wrote `milestones` at the top
18
+ * level and no `versions` or `growth`). Reading never throws on such a file: it
19
+ * reads what it can, and missing parts are derived from what is there. Writing
20
+ * is different: recordGrowth rewrites the file only when faf can prove it wrote
21
+ * every byte — the file is in faf's shape AND its text is exactly faf's own
22
+ * serialisation of what it holds (`JSON.stringify(data, null, 2)` and a final
23
+ * newline), and nothing faf would replace carries a note of the user's. Hand
24
+ * formatting, CRLF, reordered keys, a repeated key or a number JSON cannot hold
25
+ * exactly (a 20-digit id) would all be lost in a rewrite, so such a file — like
26
+ * one in another shape — is read and left exactly as it is, and
27
+ * `readOnlyReason()` says why in one line. `faf init --force` starts a fresh
28
+ * lineage when you ask it to.
29
+ */
30
+ export interface BirthCertificate {
31
+ born: string;
32
+ birthDNA: number;
33
+ birthDNASource: 'init' | 'legacy';
34
+ projectDNA: string;
35
+ certificate: string;
36
+ }
37
+ export interface VersionEntry {
38
+ version: string;
39
+ timestamp: string;
40
+ score: number;
41
+ changes: string[];
42
+ growth: number;
43
+ }
44
+ /** Journey markers only — not score tiers.
45
+ * Tiers live solely in tiers.ts (Trophy 100 · Gold 99 · Silver 95 · Bronze 85 · …).
46
+ * There is no championship / elite / perfect milestone. */
47
+ export interface Milestone {
48
+ type: 'birth' | 'doubled' | 'peak' | 'current';
49
+ score: number;
50
+ date: string;
51
+ version: string;
52
+ label: string;
53
+ emoji: string;
54
+ }
55
+ export interface FafDNA {
56
+ birthCertificate: BirthCertificate;
57
+ versions: VersionEntry[];
58
+ current: {
59
+ version: string;
60
+ score: number;
61
+ lastSync: string;
62
+ };
63
+ growth: {
64
+ totalGrowth: number;
65
+ daysActive: number;
66
+ milestones: Milestone[];
67
+ };
68
+ lastModified: string;
69
+ format: 'faf-dna-v1';
70
+ }
71
+ /**
72
+ * The `.faf-dna` lineage of one project: birth, growth and the journey line.
73
+ * This is the API `faf init` (birth), `faf auto` / `faf refresh` (recordGrowth)
74
+ * and `faf dna` (getJourney, getBirthDNADisplay, getLog) use.
75
+ *
76
+ * - `birth(score)` starts a new lineage and writes it, replacing any
77
+ * `.faf-dna` there (check `exists()` first unless a fresh start is meant —
78
+ * `faf init` births only when there is none, or with `--force`).
79
+ * - `recordGrowth(score, changes)` adds a version when the score changed.
80
+ * It adds only to a file faf can prove it wrote (see the file header);
81
+ * for any other file it writes nothing and returns null, and
82
+ * `readOnlyReason()` says why.
83
+ * - Reads never throw: a missing, unreadable, non-UTF-8 or non-JSON file,
84
+ * or one with no usable birth certificate, reads as null / '' / [].
85
+ *
86
+ * Every write is atomic and stays inside the project: a `.faf-dna` link that
87
+ * leads out of the project, dangles or leads to a file with another name is
88
+ * refused (and reads as absent). A write is refused, too, when the file
89
+ * changed on disk after this manager read it (another `faf` process recorded
90
+ * growth meanwhile, say): SafePathError `changed`, nothing written.
91
+ */
92
+ export declare class FafDNAManager {
93
+ private readonly projectPath;
94
+ private readonly dnaPath;
95
+ private dna;
96
+ /** True when the loaded file is one faf wrote (so it may be added to). */
97
+ private own;
98
+ /** The text read from `.faf-dna` (or last written), for the write's check. */
99
+ private text;
100
+ /** Why the file is read only, when it is; null when faf may add to it. */
101
+ private reason;
102
+ /** True once this manager found no `.faf-dna` (exists / load / birth) and
103
+ * has not written one since: a birth then refuses a file that appeared
104
+ * meanwhile instead of writing over it. */
105
+ private sawNoFile;
106
+ constructor(projectPath: string);
107
+ exists(): boolean;
108
+ /** Birth — the first heartbeat. Writes the birth certificate with the honest first score.
109
+ * When this manager found no `.faf-dna` (now, or at an earlier exists() /
110
+ * load()), a file that appeared since is refused (SafePathError `changed`)
111
+ * rather than written over; over a `.faf-dna` that is there, birth starts a
112
+ * fresh lineage (`faf init --force`). */
113
+ birth(birthDNA: number): FafDNA;
114
+ /** True when `.faf-dna` exists and is one faf wrote — in faf's shape, and
115
+ * exactly faf's own text — so recordGrowth may add to it. Any other file is
116
+ * read, not written. */
117
+ isFafShape(): boolean;
118
+ /** Why faf leaves this `.faf-dna` as it is, in one plain line — or null when
119
+ * there is none, or recordGrowth may add to it. */
120
+ readOnlyReason(): string | null;
121
+ /** Record growth — a new score on the journey. Returns null when there is no
122
+ * DNA yet, or when the file is in another tool's shape (nothing is written). */
123
+ recordGrowth(newScore: number, changes: string[]): FafDNA | null;
124
+ /** The one-line journey: e.g. "22% → 85% → 99% ← 92%". */
125
+ getJourney(): string;
126
+ getBirthDNADisplay(): {
127
+ current: number;
128
+ birthDNA: number;
129
+ growth: number;
130
+ born: string;
131
+ } | null;
132
+ /** Complete version history, newest last. */
133
+ getLog(): string[];
134
+ /** Load `.faf-dna`. Never throws: missing, unreadable, not UTF-8, not JSON, a
135
+ * refused link, or no usable birth certificate → null. A file faf did not
136
+ * write (another shape, or not exactly faf's text) loads as a readable view
137
+ * (see the file header). */
138
+ load(): FafDNA | null;
139
+ /** The file's text (strict UTF-8) and its JSON, or null with the reason noted. */
140
+ private read;
141
+ private save;
142
+ private generateProjectDNA;
143
+ private generateCertificate;
144
+ private incrementVersion;
145
+ private daysSince;
146
+ private updateMilestones;
147
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Where .faf data came from: the file readFaf read it from (its real path) and
3
+ * the text it read. writeFaf checks a write of that data against it — when the
4
+ * file changed on disk after the read, the write is refused instead of being
5
+ * made over the newer edit. Kept apart from interop/faf.ts so the pure parts
6
+ * of detection (updateExistingFaf) can carry it without importing a module
7
+ * that runs git.
8
+ */
9
+ /** The file a data object was read from, and the text read. */
10
+ export interface FafSource {
11
+ real: string;
12
+ text: string;
13
+ }
14
+ /** The source recorded for `data`, if any. */
15
+ export declare function fafSourceOf(data: unknown): FafSource | undefined;
16
+ /** Record that `data` holds what `source.text` (read from `source.real`) says. */
17
+ export declare function setFafSource(data: object, source: FafSource): void;
18
+ /** Let `to` — data made from `from` (a copy with empty slots filled, say) —
19
+ * carry the file `from` was read from. Returns `to`. */
20
+ export declare function carryFafSource<T extends object>(from: unknown, to: T): T;
21
+ /** Mark `data` as a fill of the file it was read from — what `faf auto`
22
+ * (updateExistingFaf) makes: detected values in the file's empty slots.
23
+ * writeFaf leaves a node with an anchor that an alias reads as written in
24
+ * such data, since filling it would change every alias. Returns `data`. */
25
+ export declare function markAsFill<T extends object>(data: T): T;
26
+ /** True when `data` was marked with {@link markAsFill}. */
27
+ export declare function isFill(data: unknown): boolean;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Text-preserving JSON edits: change the value of a key, or add a key that is
3
+ * missing, and keep every other byte of the file — key order, spacing, a
4
+ * 20-digit integer, an array written on one line. faf uses it for the one
5
+ * JSON file it shares with its owner, a registry `server.json`, where faf owns
6
+ * only the identity keys (`faf server-card`, `faf cards --target registry`).
7
+ *
8
+ * A patch is a plain object. For each key: a plain-object value is a folder to
9
+ * go into (the file's value must be an object too); any other value replaces
10
+ * the value's text when it differs, or is added when the key is missing.
11
+ * Nothing is ever deleted, and `undefined` is skipped. When the file cannot be
12
+ * edited that way — not valid JSON, not an object, a key it repeats on the way,
13
+ * a value faf would have to replace to go into it, or an object or array in the
14
+ * file where faf sets a plain value — a JsonEditError says why in one line and
15
+ * nothing is changed.
16
+ *
17
+ * Two more edits keep the same promise: {@link upsertJsonRows} updates or
18
+ * appends faf's own rows in an array (the `faf cards` catalog), and
19
+ * {@link removeJsonKey} takes out one key faf added (its render hash).
20
+ */
21
+ /** Why a JSON text cannot be edited in place (one line). */
22
+ export declare class JsonEditError extends Error {
23
+ constructor(message: string);
24
+ }
25
+ /** Where to add a missing key: after the first of these keys the object has
26
+ * (else after its last key). Keyed by the dotted path of the object ('' is the root). */
27
+ export type InsertAfter = Record<string, Record<string, readonly string[]>>;
28
+ /**
29
+ * Apply `patch` to the JSON `text`, changing only the value text of the keys
30
+ * it names and adding the keys it has that the text lacks (see the file
31
+ * header). Returns the new text and whether it changed. Throws a
32
+ * JsonEditError, having changed nothing, when the text cannot be edited that
33
+ * way. `after` says where a missing key goes (default: after the object's last key).
34
+ */
35
+ export declare function editJsonText(text: string, patch: Record<string, unknown>, after?: InsertAfter): {
36
+ text: string;
37
+ changed: boolean;
38
+ };
39
+ /**
40
+ * Update or add rows in the array at the root key `key`, changing nothing
41
+ * else: a row whose `id` value is exactly a row's in the file is updated in
42
+ * place — only the values of its `update` keys change (or are added) — and any
43
+ * other row is appended after the file's last item, laid out like the items
44
+ * around it (the array, or the key, is added when missing). Every item faf
45
+ * does not update stays byte for byte. Never matched by anything but `id`.
46
+ * Throws a JsonEditError, having changed nothing, when the text cannot be
47
+ * edited that way: not a JSON object, `key` not an array or there more than
48
+ * once, a row's id there more than once, or a value faf sets that is an object
49
+ * or array in the file.
50
+ */
51
+ export declare function upsertJsonRows(text: string, key: string, rows: readonly Record<string, unknown>[], opts: {
52
+ id: string;
53
+ update: readonly string[];
54
+ }): {
55
+ text: string;
56
+ changed: boolean;
57
+ };
58
+ /**
59
+ * The value at `path` in the JSON object `text` (each step a key of an
60
+ * object): `count` 1 with its parsed `value`, 0 when a step is missing or not
61
+ * an object, more than 1 when a key on the way is repeated. Throws a
62
+ * JsonEditError when `text` is not a JSON object.
63
+ */
64
+ export declare function locateJsonKey(text: string, path: readonly string[]): {
65
+ count: number;
66
+ value?: unknown;
67
+ };
68
+ /**
69
+ * `text` with the key at `path` taken out — the reverse of {@link editJsonText}
70
+ * adding it (see {@link removal}); every other byte stays. With
71
+ * `dropEmptyParent`, when that key is its object's only key, the object's own
72
+ * key is taken out instead. Returns null when the key is not there exactly
73
+ * once; throws a JsonEditError when `text` is not a JSON object.
74
+ */
75
+ export declare function removeJsonKey(text: string, path: readonly string[], opts?: {
76
+ dropEmptyParent?: boolean;
77
+ }): string | null;
@@ -0,0 +1,55 @@
1
+ /** The `_meta` key a JSON file faf renders carries its render hash under. */
2
+ export declare const RENDER_KEY = "one.faf/render";
3
+ /** `sha256:<hex>` of `text` (UTF-8). */
4
+ export declare function renderHash(text: string): string;
5
+ /** How faf records its hash in a file: an HTML meta line, or a JSON `_meta` key. */
6
+ export type RenderFormat = 'html' | 'json';
7
+ /**
8
+ * Whether the bytes of a whole file faf renders are exactly what faf wrote:
9
+ * `match` — they carry faf's render hash, the hash is that of the rest of the
10
+ * file, and the file is laid out exactly as faf writes it; `mismatch` — they
11
+ * carry a render hash that no longer fits, or (project.html) a render line
12
+ * that is not exactly faf's form (the file was edited since faf wrote it);
13
+ * `none` — they carry no render hash (a file from before 7.13, or one faf
14
+ * never wrote). The bytes are taken as they are: see {@link writeRendered}
15
+ * for a file git checked out with CRLF line ends.
16
+ */
17
+ export declare function renderOwnership(bytes: Uint8Array, format: RenderFormat): 'match' | 'mismatch' | 'none';
18
+ /** Options for {@link writeRendered}. */
19
+ export interface RenderedWriteOptions {
20
+ /** The project folder the write must stay inside. */
21
+ root: string;
22
+ /** How the hash is recorded: `html` (project.html) or `json` (the cards, server.json, a snapshot). */
23
+ format: RenderFormat;
24
+ /** True when the bytes carry faf's older mark — a file faf wrote before render hashes. */
25
+ hasMark: (bytes: Buffer) => boolean;
26
+ /** That mark in words, for the refusal of a file without it. */
27
+ mark: string;
28
+ /** Replace the file whatever it holds — the explicit overwrite (`--force`). */
29
+ force?: boolean;
30
+ }
31
+ /** What {@link writeRendered} did. */
32
+ export type RenderedResult = 'created' | 'updated' | 'unchanged';
33
+ /**
34
+ * Write the whole file faf renders at `path`: `render` (faf's text, without a
35
+ * render hash) with faf's render hash added. The link rules and the atomic,
36
+ * compare-before-rename write are safe-write's. A file already there is
37
+ * replaced only when {@link renderOwnership} says `match`; with a `mismatch`,
38
+ * or with no hash, it is refused (SafePathError `not-owned`) and left byte for
39
+ * byte:
40
+ * - "<file> was edited since faf wrote it — faf left it unchanged. Use --force to replace it."
41
+ * - with faf's older mark (written before 7.13): exactly faf's `render` →
42
+ * nothing to change (`unchanged`); otherwise "<file> has no faf render hash
43
+ * (written before 7.13), so faf cannot tell whether it was edited — faf
44
+ * left it unchanged. Use --force once to replace it; after that faf
45
+ * recognises its own output."
46
+ * - without the mark: "<file> has no <mark>, so faf did not write it — faf left it unchanged."
47
+ * `force` replaces it anyway. A file already holding exactly the new bytes is
48
+ * not written again. A file whose every line ends CRLF (git's
49
+ * `core.autocrlf`) is judged with those line ends read as LF, and is written
50
+ * back with CRLF line ends.
51
+ */
52
+ export declare function writeRendered(path: string, render: string, opts: RenderedWriteOptions): {
53
+ path: string;
54
+ result: RenderedResult;
55
+ };
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Safe file access — the one primitive every faf writer, and every read of
3
+ * project context, goes through. It is also the only module in faf that calls
4
+ * a filesystem write, rename, delete, mkdir or chmod API (tests/write-guard
5
+ * enforces that for all of src/).
6
+ *
7
+ * Rule 1 — stay inside the project. Before faf touches a path it resolves it on
8
+ * disk (realpath: every link followed). If that leads outside the project
9
+ * folder, faf refuses. A dangling link is refused — faf never creates a file at
10
+ * the end of a link. Anything whose real path is inside a `.git` folder is
11
+ * refused, always — with two narrow exceptions: faf's own git hook
12
+ * (`allowGitHooks`: one file directly inside the repo's hooks folder), and
13
+ * the repo's own git config file (`allowGitConfig`: the file `config`
14
+ * directly in the folder git names for it), where `faf diff
15
+ * --uninstall-driver` removes the section faf's install wrote.
16
+ *
17
+ * Rule 2 — a link leads to the same kind of file. faf follows a link only to a
18
+ * file with the same name (CLAUDE.md → docs/CLAUDE.md), or from one AI context
19
+ * file to another (CLAUDE.md → AGENTS.md; the set is FAF_CONTEXT_FILES). The
20
+ * link itself survives: faf writes the file it points at. `CLAUDE.md →
21
+ * README.md` or `project.html → package.json` is refused — faf would be
22
+ * writing a file it was never asked to write. A read of project context
23
+ * through a link must land on a .faf or .fafm file instead, so `project.faf →
24
+ * .env` is refused even though .env is in the project — or, for a read of an
25
+ * AI context file, on another AI context file (`faf recover` reads CLAUDE.md →
26
+ * AGENTS.md), the rule the writers use.
27
+ *
28
+ * Rule 3 — never leave a half-written file. A write goes to a temp file in the
29
+ * same folder, is flushed to disk (fsync), then renamed over the original in
30
+ * one step, keeping the original's permissions. If any step fails the temp
31
+ * file is removed and the original is exactly as it was: "not written;
32
+ * original kept". A plain writeFileSync truncates first, so a full disk, a
33
+ * quota or a killed process used to leave the user's file cut short. With
34
+ * `expect` (the bytes the caller read), the file is read again just before the
35
+ * rename and the write is refused if it changed in the meantime. Its mode is
36
+ * checked too: a file made read-only, or given other permissions, while faf
37
+ * was writing is not replaced.
38
+ *
39
+ * Rule 4 — text faf edits is UTF-8. readUtf8 decodes strictly: a UTF-16 file or
40
+ * any bytes that are not UTF-8 are refused, never turned into U+FFFD and
41
+ * written back.
42
+ *
43
+ * Rule 5 — a whole file faf renders replaces a file already there only when
44
+ * faf can prove it wrote it: project.html, the cards, `faf server-card --out`,
45
+ * a `faf taf --output` snapshot and a `faf decompile --output` file must be
46
+ * byte for byte what faf last wrote (their render hash, render-hash.ts); a
47
+ * `.fafb` must carry the FAFB header (see {@link safeReplaceOwned}). Anything
48
+ * else is refused unless the caller passes `force` (the CLI's `--force`).
49
+ *
50
+ * Rule 6 — detection reads stay inside the project. The files faf reads to fill
51
+ * slots and render context (README.md, package.json, pyproject.toml,
52
+ * Cargo.toml, go.mod, …) go through {@link repoFile}: resolved on disk, every
53
+ * link followed. A file whose real path, or the folder it sits in, leaves the
54
+ * project folder, runs through `.git`, or is a dangling link is absent to
55
+ * detection: it does not exist, and reading it gives nothing. A link that
56
+ * stays inside the project is followed (README.md → docs/README.md).
57
+ */
58
+ import { type Dirent, type Stats } from 'fs';
59
+ /** Why a path was refused. */
60
+ export type SafePathReason = 'outside' | 'dangling' | 'not-a-file' | 'not-faf' | 'other-file' | 'git' | 'not-utf8' | 'changed' | 'not-owned' | 'not-yaml' | 'unplaceable';
61
+ /**
62
+ * A path faf will not read or write, or a file faf will not change. Nothing
63
+ * was written; the file on disk is exactly as it was.
64
+ * - `outside`, `dangling`, `not-a-file`, `not-faf`, `other-file`, `git`: the
65
+ * path is refused (see {@link resolveInside}).
66
+ * - `not-utf8`: the file is not UTF-8 (see {@link readUtf8}).
67
+ * - `changed`: the file changed on disk after faf read it — its bytes, or its
68
+ * mode (see the `expect` option of {@link safeWriteFile}).
69
+ * - `not-owned`: a whole file faf renders is already there and faf cannot
70
+ * prove it wrote every byte of it — it has no faf mark, or it was edited
71
+ * since faf wrote it, or it is from before faf recorded a render hash (see
72
+ * {@link safeReplaceOwned} and render-hash.ts).
73
+ * - `not-yaml`: a .faf faf reads or edits is not valid YAML: "<file> is not
74
+ * valid YAML (<reason>, line N) — faf left it unchanged"; or it parses to
75
+ * a scalar or a list, not a mapping; or faf's scoring kernel cannot read
76
+ * it (`faf score`, `faf compile`, `faf refresh`).
77
+ * - `unplaceable`: faf could not place its managed block where its next run
78
+ * finds it again, so it wrote nothing (see inject.ts).
79
+ *
80
+ * `onWrite` is true when faf refused at the write itself — it may have read
81
+ * the file before (a `.faf` read through a link, say) — and false when it
82
+ * refused before reading or writing anything.
83
+ */
84
+ export declare class SafePathError extends Error {
85
+ readonly reason: SafePathReason;
86
+ /** The path as the caller named it (absolute). */
87
+ readonly path: string;
88
+ /** True when the refusal came at the write (faf may have read the file first). */
89
+ readonly onWrite: boolean;
90
+ constructor(reason: SafePathReason, path: string, message: string, opts?: {
91
+ onWrite?: boolean;
92
+ cause?: unknown;
93
+ });
94
+ }
95
+ export interface ResolveInsideOptions {
96
+ /** Resolving for a read of project context: a link must end at a `.faf` or
97
+ * `.fafm` file — or, when the name is an AI context file (CLAUDE.md), at
98
+ * another AI context file (CLAUDE.md → AGENTS.md), the rule the writers
99
+ * use. A plain file is read under the name the caller gave. */
100
+ read?: boolean;
101
+ /**
102
+ * Resolving faf's own git hook (`faf hooks`) — one of the two exceptions
103
+ * to the `.git` rule. `dir` must be the repo's hooks folder as git names it
104
+ * (`git rev-parse --git-path hooks`, a folder named `hooks`), and `name` a
105
+ * hook file sitting directly in it (`pre-commit`). Only that file may be
106
+ * inside `.git`; nothing below or beside it. Links are still checked: a
107
+ * link must stay inside the hooks folder's real folder and end at a file of
108
+ * the same name.
109
+ */
110
+ allowGitHooks?: boolean;
111
+ /**
112
+ * Resolving the repo's own git config file (`faf diff --uninstall-driver`)
113
+ * — the other exception to the `.git` rule. `dir` must be the folder git
114
+ * names for it (the folder of `git rev-parse --git-path config`), and `name`
115
+ * the file `config` directly in it. Only that file may be inside `.git`;
116
+ * nothing below or beside it. A link must stay inside that folder and end
117
+ * at a file named `config`.
118
+ */
119
+ allowGitConfig?: boolean;
120
+ }
121
+ export interface SafeWriteOptions {
122
+ /** The project folder the write must stay inside. Default: the file's own folder. */
123
+ root?: string;
124
+ /**
125
+ * The bytes the caller read from the file. Just before the rename the file
126
+ * is read again; when it no longer equals `expect`, the temp file is removed
127
+ * and a SafePathError (`changed`) is thrown: "<file> changed on disk while
128
+ * faf was writing — not written; original kept". A string is compared as its
129
+ * UTF-8 bytes. `null` means the caller found no file there: if one has
130
+ * appeared, the write is refused the same way (a missing file stays
131
+ * missing). Omit it to write without the byte check (the mode check below
132
+ * still runs).
133
+ */
134
+ expect?: string | Uint8Array | null;
135
+ /** Write faf's own git hook: see {@link ResolveInsideOptions.allowGitHooks}. `root` is the hooks folder. */
136
+ allowGitHooks?: boolean;
137
+ /** Write the repo's own git config file: see {@link ResolveInsideOptions.allowGitConfig}. `root` is its folder. */
138
+ allowGitConfig?: boolean;
139
+ /** Permission bits for the written file. Default: the original's (a new
140
+ * file gets the default create mode). */
141
+ mode?: number;
142
+ }
143
+ /**
144
+ * The files faf's block injector writes — one managed block, every other byte
145
+ * of the file kept. Each injector writer (writeClaudeMd, writeAgentsMd,
146
+ * writeGeminiMd, writeCursorrules, writeCopilotInstructions, writeMemoryMd,
147
+ * writeLlmsTxt, writeClaudeMemory) takes its file name from this table, so
148
+ * the set is exactly the files those writers target. A link from one of these
149
+ * names to another (CLAUDE.md → AGENTS.md) may be followed.
150
+ */
151
+ export declare const FAF_CONTEXT_FILES: Readonly<{
152
+ readonly claude: "CLAUDE.md";
153
+ readonly agents: "AGENTS.md";
154
+ readonly gemini: "GEMINI.md";
155
+ readonly cursorrules: ".cursorrules";
156
+ readonly copilot: "copilot-instructions.md";
157
+ readonly memory: "MEMORY.md";
158
+ readonly llms: "llms.txt";
159
+ }>;
160
+ /**
161
+ * Resolve `name` inside the project folder `dir` and return the real path to
162
+ * read or write — or throw a SafePathError.
163
+ *
164
+ * - the folder the file sits in must resolve to `dir` or below it
165
+ * - a path whose real form runs through `.git` → refused, always (the two
166
+ * exceptions: `allowGitHooks`, a hook file directly in the hooks folder,
167
+ * and `allowGitConfig`, the file `config` directly in its git folder)
168
+ * - a file that does not exist yet → its path inside the project
169
+ * - a regular file → its path (spelled as on disk)
170
+ * - a link → the file it points at, when that exists, is a regular file, is
171
+ * inside the project and out of `.git`, and has the link's own name (or
172
+ * both names are AI context files: CLAUDE.md → AGENTS.md). With `read`, the
173
+ * file must be a .faf/.fafm file instead (`project.faf → config/team.faf`),
174
+ * or — for an AI context file — another AI context file.
175
+ * - anything else (a link out, a dangling link, a link to a file with
176
+ * another name, a folder, a device) → refused
177
+ *
178
+ * `name` may be relative to `dir` or absolute. `dir` and the folder `name` sits
179
+ * in must exist (their ENOENT is thrown as is).
180
+ */
181
+ export declare function resolveInside(dir: string, name: string, opts?: ResolveInsideOptions): string;
182
+ /**
183
+ * Read a text file faf may write back — strictly as UTF-8. A UTF-8 BOM is kept
184
+ * (U+FEFF at the start of the text, as a plain read gives it). A UTF-16 BOM
185
+ * (FF FE or FE FF) or any byte sequence that is not UTF-8 is refused with a
186
+ * SafePathError (`not-utf8`): "<file> is not UTF-8 — faf left it unchanged". A
187
+ * lenient read would turn those bytes into U+FFFD, and the next write would
188
+ * lose them. Other read errors (ENOENT, EACCES, …) are thrown as they are.
189
+ * `path` is read as given: resolve it with {@link resolveInside} first.
190
+ */
191
+ export declare function readUtf8(path: string): string;
192
+ /** The bytes at `path`, or null when nothing is there (only ENOENT). */
193
+ export declare function readBytesIfPresent(path: string): Buffer | null;
194
+ /**
195
+ * Where detection may read `rel` in the project folder `dir` — its real path —
196
+ * or null when detection treats it as absent (Rule 6). `rel` names a file or a
197
+ * folder, relative to `dir` (as `join(dir, rel)`). Every link on the way is
198
+ * followed, the folders included; the result is null when:
199
+ * - nothing is there, or a link on the way dangles or loops
200
+ * - the real path leaves `dir` (README.md → ~/.aws/credentials, or a folder
201
+ * on the way that is a link out)
202
+ * - the real path runs through `.git` (README.md → .git/config)
203
+ * A link that stays inside `dir` is followed. `dir` is the folder detection
204
+ * was handed: the project, or a folder below it that detection reads on its
205
+ * own (a subfolder's manifest), so a link may not leave that folder either.
206
+ */
207
+ export declare function repoFile(dir: string, rel: string): string | null;
208
+ /**
209
+ * Run `scan` with `root` as the project folder for every detection read under
210
+ * it: a read in a subfolder (web/package.json) may then follow a link to
211
+ * anywhere inside `root` (../shared/web-package.json), not only inside that
212
+ * subfolder. A link out of `root` is still absent. Subfolder scans use this.
213
+ */
214
+ export declare function withRepoRoot<T>(root: string, scan: () => T): T;
215
+ /** True when `rel` (a file or a folder) is in the project folder `dir` for
216
+ * detection: see {@link repoFile}. */
217
+ export declare function repoExists(dir: string, rel: string): boolean;
218
+ /** The text of the file `rel` in the project folder `dir` (UTF-8, read the way
219
+ * detection reads it), or null when detection treats it as absent (see
220
+ * {@link repoFile}), it is not a regular file, or it cannot be read. */
221
+ export declare function readRepoFile(dir: string, rel: string): string | null;
222
+ /** What `rel` is (its real path's stats) in the project folder `dir`, or null
223
+ * when detection treats it as absent (see {@link repoFile}). */
224
+ export declare function statRepoFile(dir: string, rel: string): Stats | null;
225
+ /**
226
+ * The entries of the folder `rel` (default: `dir` itself) in the project
227
+ * folder `dir`, or null when detection treats the folder as absent (see
228
+ * {@link repoFile}) or it cannot be read. A link among them that leaves `dir`,
229
+ * dangles, or leads into `.git` is left out: detection does not see it. Each
230
+ * entry says what it is itself (a link is a link, as readdirSync gives it).
231
+ */
232
+ export declare function readRepoDir(dir: string, rel?: string): Dirent[] | null;
233
+ /**
234
+ * A write that failed partway — a full disk, a quota, a read-only file, a
235
+ * killed rename — with the file on disk exactly as it was: "<file>: not
236
+ * written; original kept (<why>)" ("not written" alone when there was no file).
237
+ * `cause` is the error underneath. The CLI prints it as one line.
238
+ */
239
+ export declare class NotWrittenError extends Error {
240
+ /** The file that was not written. */
241
+ readonly path: string;
242
+ constructor(path: string, message: string, opts?: {
243
+ cause?: unknown;
244
+ });
245
+ }
246
+ /**
247
+ * Write a file inside a project, safely: resolved with {@link resolveInside}
248
+ * (a link that leaves the project, dangles, or leads to a file with another
249
+ * name, and anything in `.git`, is refused), then replaced atomically (temp
250
+ * file in the same folder, fsync, rename; the original's permissions — and,
251
+ * where the OS allows, its owner — kept). On any failure the original is
252
+ * untouched and the Error says "not written; original kept". With `expect`,
253
+ * a file that changed after the caller read it is not replaced either
254
+ * (SafePathError `changed`); nor is one whose mode changed, or that became
255
+ * read-only, while faf was writing. Returns the real path written — the
256
+ * link's target when `path` is an in-project link.
257
+ */
258
+ export declare function safeWriteFile(path: string, content: string | Uint8Array, opts?: SafeWriteOptions): string;
259
+ /** Options for {@link safeReplaceOwned}. */
260
+ export interface ReplaceOwnedOptions {
261
+ /** The project folder the write must stay inside. Default: the file's own folder. */
262
+ root?: string;
263
+ /** True when the bytes already at the path carry faf's own mark (faf wrote them). */
264
+ owns: (existing: Buffer) => boolean;
265
+ /** faf's mark in words, for the refusal: "the `_meta[\"one.faf/context\"]` block". */
266
+ mark: string;
267
+ /** Replace a file without the mark anyway — the explicit overwrite (`--force`). */
268
+ force?: boolean;
269
+ /** The bytes the caller read there earlier (`null`: no file then). When
270
+ * given, the file must still hold them, else SafePathError `changed`. */
271
+ expect?: string | Uint8Array | null;
272
+ }
273
+ /**
274
+ * Write a whole file faf renders (project.html, a Server Card, an A2A card, a
275
+ * `.fafb`), replacing a file already at `path` only when faf can prove it wrote
276
+ * it: its bytes carry faf's own mark (`owns`). A file without the mark is
277
+ * refused — SafePathError `not-owned`: "<file> has no <mark>, so faf did not
278
+ * write it — faf left it unchanged." — and stays byte for byte, unless `force`
279
+ * asks to replace it. The link rules and the atomic write are
280
+ * {@link safeWriteFile}'s, and the write is refused if the file changed on
281
+ * disk after faf read it. Returns the real path written.
282
+ */
283
+ export declare function safeReplaceOwned(path: string, content: string | Uint8Array, opts: ReplaceOwnedOptions): string;
284
+ /**
285
+ * Create the folder `dir` inside the project folder `root`, with any missing
286
+ * folders between them, and return its real path. `root` itself is the
287
+ * caller's own folder: it is created as named when missing. Below it, faf
288
+ * never creates a folder through a link: an existing folder on the way that
289
+ * is a link leading out of `root` (or a dangling link) is refused, as is any
290
+ * part inside `.git` and anything on the way that is not a folder — so
291
+ * `.github → ~/elsewhere` cannot make faf create `~/elsewhere/workflows`.
292
+ */
293
+ export declare function makeDirInside(root: string, dir?: string): string;
294
+ /**
295
+ * Remove a file faf wrote — only when it still holds exactly `expect` (the
296
+ * bytes faf wrote or read there) and is a regular file inside `root` (default:
297
+ * its own folder), out of `.git`. A link is never removed (faf did not make
298
+ * it), and a file whose bytes changed is left as it is (SafePathError
299
+ * `changed`). Returns false when nothing was there.
300
+ */
301
+ export declare function safeUnlink(path: string, opts: {
302
+ root?: string;
303
+ expect: string | Uint8Array;
304
+ }): boolean;
305
+ /** The marker file makeTempDir writes into every temp folder it makes, and
306
+ * its exact content: `faf clear` removes only a folder that carries it. */
307
+ export declare const TEMP_MARKER = ".faf-temp";
308
+ /** Make a new temp folder for faf's own use — `<os temp>/<prefix>XXXXXX`, a
309
+ * fresh name no one else can have made (mkdtemp) — write faf's marker file
310
+ * into it ({@link TEMP_MARKER}), and return its real path. Anything faf puts
311
+ * there (a clone, say) goes in a subfolder, beside the marker. */
312
+ export declare function makeTempDir(prefix: string): string;
313
+ /** Remove a temp folder {@link makeTempDir} made in this process, with what is
314
+ * in it. Any other folder is refused (an Error; nothing removed). */
315
+ export declare function removeTempDir(dir: string): void;
316
+ /**
317
+ * Remove faf's own temp folders left behind by earlier runs (`faf clear`):
318
+ * only folders faf made — entries of the OS temp folder named exactly as
319
+ * mkdtemp names them (`prefix` plus six letters or digits), that are real
320
+ * folders (a link is left alone), belong to this user and carry the marker
321
+ * file {@link makeTempDir} writes into each one. A folder of yours that only
322
+ * starts with the prefix (`faf-git-my-notes`) stays. Returns how many were
323
+ * removed. A folder that cannot be read or removed is skipped.
324
+ */
325
+ export declare function removeStaleTempDirs(prefix: string): number;
@@ -1,4 +1,8 @@
1
1
  import type { KernelScoreResult, ScoreResult } from './types.js';
2
+ /** A score as text for display: `85%`, or `unknown (—)` when the result says
3
+ * the score is not known (`unknown: true` — an About Repo with no
4
+ * source_score). Never `-1%`. */
5
+ export declare function scoreText(result: Pick<ScoreResult, 'score' | 'unknown'>): string;
2
6
  /** Convert kernel result into enriched ScoreResult */
3
7
  export declare function enrichScore(kernel: KernelScoreResult): ScoreResult;
4
8
  /**
@@ -8,8 +12,9 @@ export declare function enrichScore(kernel: KernelScoreResult): ScoreResult;
8
12
  * (`about.represents`). The scorer reads `about.source_score` and emits
9
13
  * that directly — no slot scoring, no kernel call.
10
14
  *
11
- * Optional: `about.source_score: <number>` — without it, score is -1
12
- * (renders as "—" honest unknown).
15
+ * Optional: `about.source_score: <number>` — without it the score is not
16
+ * known: the result carries `unknown: true` (with score -1 and a White tier
17
+ * as placeholders). Render that as "unknown" (—), never as "-1/100" or "-1%".
13
18
  *
14
19
  * Doctrine: memory/private-source-public-about-pattern.md.
15
20
  *