etymd 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # etymd
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - a58d3bd: Fleet mode — the truth guard across your repositories (design record `docs/design/004-fleet-truth-guard.md`; registry + fleet `--json` schemas EXPERIMENTAL through 0.2.x).
8
+
9
+ - New `state-freshness` truth lens: state/decisions artifacts dated by git committer dates only (never mtime); staleness is relative, so a dormant repo's old state is current; state char budget against the ~10k session-hook truncation; marker-gated decisions format checks (`Scope:`, duplicate/out-of-order `D-NNN` ids, past `Revisit:` dates as due review debt); ADR conventions (`docs/adr/`, `docs/decisions/`, `NNNN-*.md`) recognized natively.
10
+ - New `etymd fleet` command family. The sweep runs a read-only audit per registered repo (`--manifest` required unless the cwd holds `registry.json` — no env var, no global pointer) and renders one line per project with a delta against `last.fleet.json`; detail only for new or risk findings. `fleet check` validates the manifest pair alone (dangling mappings, duplicate names, privacy leaks, machine paths). `fleet dismiss`/`fleet accept` resolve a project's finding from any cwd.
11
+ - The manifest loader (`src/core/fleet.ts`) resolves both the fleet registry pair (`registry.json` + gitignored `registry.local.json`) and the legacy corpus pair (`sources.json` + `sources.local.json`); corp entries are opaque aliases resolved only through the local file, and every resolution failure is disclosed, never silently skipped.
12
+ - Persistence invariants, pinned by tests: the sweep never creates `.etymd` anywhere; `--persist-ledgers` only persists into personal repos that already opted in; corp worktrees take zero writes under every flag combination — corp findings persist (and stay dismissible) at `<manifestDir>/corp/<name>/.etymd/`; zero corp-resolved content under the manifest repo's tracked paths.
13
+ - Fleet-scope wall findings (lens id `fleet-manifest`): corp contract files inside a corp worktree, unregistered corp-remote checkouts under the fleet root, tracked `/Users/` paths in the manifest's own repo, private needles inside `trust: "public-repo"` entries, corp-host commit emails on personal entries.
14
+ - Fork-aware freshness: entries with `upstream` are dated on fork-authored commits only (`HEAD --not --remotes=<upstream>`), with a disclosed fallback when the remote is absent.
15
+
3
16
  ## 0.1.0 — 2026-07-31
4
17
 
5
18
  First public release.
@@ -22,7 +35,7 @@ rationale are recorded in `docs/design/003-truth-guard-pivot.md`.)
22
35
  absent because nobody looked, not because it was fixed. The ledger holds those entries open
23
36
  (`lastSeen` untouched) and the diff reports them as held rather than folding them into
24
37
  "N resolved".
25
- - **`init` baselines the repo it leaves behind.** It used to approve the scan taken *before* its
38
+ - **`init` baselines the repo it leaves behind.** It used to approve the scan taken _before_ its
26
39
  own scaffold, so the first baseline recorded AGENTS.md and the hooks as absent — and deleting
27
40
  them later never registered as drift, which is the baseline's whole job. It now re-scans after
28
41
  writing.
package/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # etymd
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/etymd.svg)](https://www.npmjs.com/package/etymd)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/triartleet/etymd/ci.yml?branch=main&label=CI)](https://github.com/triartleet/etymd/actions/workflows/ci.yml)
5
+
3
6
  **Keep your agent instructions true.**
4
7
 
5
8
  Your `AGENTS.md` is the interface between your team and every coding agent — and it rots silently.
@@ -45,8 +48,7 @@ npx etymd audit # verify every instruction claim against the repo
45
48
  npx etymd audit --fail-on risk # the CI gate
46
49
  ```
47
50
 
48
- Requires Node ≥ 18.17. **Status: pre-publish** — run from a checkout (`npm run build &&
49
- node dist/cli.js …`) until the first npm release.
51
+ Requires Node ≥ 18.17. On npm since v0.1.0 — `npx etymd` just works.
50
52
 
51
53
  ## What it checks
52
54
 
@@ -72,17 +74,38 @@ installed but unwired). `allow_failure` jobs count as advisory, never as gates.
72
74
  `alwaysApply` Cursor rules count), flagging files worth extracting into on-demand skills. Context
73
75
  is the dominant cost of the loop; a lean contract is a correctness feature.
74
76
 
77
+ **`state-freshness`** — the layer that claims "this describes now" (`PROJECT_CONTEXT.md`,
78
+ `DECISIONS.md`, ADR dirs), judged by git committer dates only, never mtime. Staleness is
79
+ _relative_ — a state doc is stale only when the repo moved past it, so a dormant repo's old
80
+ state is current; a tracked file with uncommitted edits is treated fresh-now (the refresh is
81
+ already on disk) and disclosed. Decisions records get format checks (`Scope:` presence, a
82
+ `Revisit:` date that, once past, becomes a finding) — opt in by adding the literal marker
83
+ `<!-- decisions-format: 1 -->` anywhere in the file; forward-only, never retroactive. Duplicate
84
+ or out-of-order `D-NNN` ids are flagged with a rename action even without the marker — an
85
+ append race is a defect in the file's own convention, not a format opinion.
86
+
87
+ **`fleet-manifest`** (via `etymd fleet`) — one truth guard across every repo you registered:
88
+ per-repo audits plus checks on the fleet manifest itself and on the placement wall between
89
+ personal and employer repos. See [the fleet manifest](#the-fleet-manifest-experimental) below.
90
+
75
91
  ## Commands
76
92
 
77
- | Command | What it does |
78
- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
79
- | `etymd audit` | Verify every claim; ranked findings (risk → gap → polish) + ledger diff. `--lens`, `--truth`, `--json`, `--no-ledger`, `--fail-on <tier>`. |
80
- | `etymd init` | Onboard: approve the committed baseline; scaffold a minimal AGENTS.md **only if missing**. Never overwrites. |
81
- | `etymd doctor` | Alias for `audit --truth`. |
82
- | `etymd context` | The economy view: per-file always-loaded footprint + extraction candidates. |
83
- | `etymd gates` | Install local git-hook gates (pre-commit / pre-push) built from your own check scripts. |
84
- | `etymd scan` | The deterministic reckoning behind everything. `--json`. |
85
- | `etymd brief` | A grounded briefing your in-repo agent completes to author the semantic layer. |
93
+ | Command | What it does |
94
+ | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
95
+ | `etymd audit` | Verify every claim; ranked findings (risk → gap → polish) + ledger diff. `--lens`, `--truth`, `--json`, `--no-ledger`, `--fail-on <tier>`. |
96
+ | `etymd init` | Onboard: approve the committed baseline; scaffold a minimal AGENTS.md **only if missing**. Never overwrites. |
97
+ | `etymd doctor` | Alias for `audit --truth`. |
98
+ | `etymd context` | The economy view: per-file always-loaded footprint + extraction candidates. |
99
+ | `etymd gates` | Install local git-hook gates (pre-commit / pre-push) built from your own check scripts. |
100
+ | `etymd scan` | The deterministic reckoning behind everything. `--json`. |
101
+ | `etymd brief` | A grounded briefing your in-repo agent completes to author the semantic layer. |
102
+ | `etymd approve` | Refresh the committed baseline non-interactively after intentional structural changes. |
103
+ | `etymd ledger` | The findings memory: every tracked finding with status and history. |
104
+ | `etymd dismiss` | `dismiss <id> --reason <text>` — a dismissed finding never resurfaces without regressing. |
105
+ | `etymd accept` | `accept <id>` — record a finding as accepted reality; visible in the ledger, out of the report. |
106
+ | `etymd fleet` | Sweep every project in a fleet manifest: read-only per-repo audits + manifest/wall checks. `--manifest`, `--only`, `--profile`, `--truth`, `--persist-ledgers`, `--json`, `--fail-on`. |
107
+ | `etymd fleet check` | Validate the manifest pair alone (no lenses): dangling mappings, duplicate names, privacy leaks, machine paths. Non-zero exit on any finding. |
108
+ | `etymd fleet dismiss` / `accept` | `<name> <id>` — resolve a project's finding from any cwd; corp findings persist beside the manifest, never in the corp worktree. |
86
109
 
87
110
  `--cwd <dir>` targets another directory. Read-only probing of any repo leaves **zero trace**
88
111
  (`audit --no-ledger` writes nothing).
@@ -126,6 +149,81 @@ Narrowing an audit can hide findings, so etymd never lets it happen quietly: **e
126
149
  is counted and named in the lens disclosures**, and a config that fails to parse is reported as a
127
150
  disclosure rather than silently falling back to defaults.
128
151
 
152
+ ## The fleet manifest (EXPERIMENTAL)
153
+
154
+ `etymd fleet` extends the one objective across every repository you work in — your fleet of
155
+ **repositories**, not a fleet of agents. The manifest, `registry.json`, is itself an
156
+ agent-context file: claims about your fleet (what exists, where, under which profile). It rots
157
+ like any AGENTS.md does, and `etymd fleet` keeps it true. Design record:
158
+ [`docs/design/004-fleet-truth-guard.md`](docs/design/004-fleet-truth-guard.md). Both the
159
+ registry schema and the fleet `--json` schema are **experimental through 0.2.x**.
160
+
161
+ Two files beside each other — the split is the privacy model:
162
+
163
+ `registry.json` (tracked; safe to publish by construction):
164
+
165
+ ```jsonc
166
+ {
167
+ "registryVersion": 1,
168
+ "root": "~/projects", // ~ expands on the consumer side — never a machine home
169
+ "projects": [
170
+ { "name": "web-app", "kind": "repo", "profile": "personal", "path": "web-app" },
171
+ {
172
+ "name": "notes",
173
+ "kind": "docs",
174
+ "profile": "personal",
175
+ "path": "notes",
176
+ "staleAfterDays": 45, // per-entry freshness window
177
+ "contract": { "state": "STATUS.md" }, // native conventions register, never migrate
178
+ },
179
+ {
180
+ "name": "my-fork",
181
+ "kind": "tool",
182
+ "profile": "personal",
183
+ "path": "my-fork",
184
+ "upstream": "origin", // freshness measured on fork-authored commits only
185
+ "trust": "public-repo", // hygiene needles apply (see below)
186
+ },
187
+ // Corp entries: opaque alias, private, NO path — real dirs live only in the local file.
188
+ { "name": "c-one", "kind": "repo", "profile": "corp", "private": true, "staleAfterDays": 45 },
189
+ ],
190
+ }
191
+ ```
192
+
193
+ `registry.local.json` (gitignored; this machine's facts — each one an identifier you don't ship):
194
+
195
+ ```jsonc
196
+ {
197
+ "machineProfile": "corp", // "personal" resolves corp entries disclosed-absent
198
+ "root": "~/projects", // optional per-machine root override
199
+ "dirs": { "c-one": "~/projects/real-corp-dir" },
200
+ "labels": { "c-one": "real-corp-dir" },
201
+ "corpHosts": ["git.example-corp.com"],
202
+ }
203
+ ```
204
+
205
+ How the sweep behaves:
206
+
207
+ - **Read-only by default, everywhere.** The sweep never creates `.etymd` anywhere.
208
+ `--persist-ledgers` persists only into personal repos that already opted in, and a **corp
209
+ worktree is never written** — regardless of flags, even if a stray `.etymd` exists inside it
210
+ (pinned by test). Corp findings stay dismissible: their ledger lives at
211
+ `<manifest-dir>/corp/<name>/.etymd/`, beside the manifest.
212
+ - **Deltas.** Each sweep compares against `last.fleet.json` stored beside the manifest and
213
+ renders `Δ +new −resolved` per project. Add `*.fleet.json` to the manifest repo's
214
+ `.gitignore` — sweep output is local-only and never tracked.
215
+ - **Wall checks.** Corp contract files found inside a corp worktree, unregistered checkouts
216
+ under the fleet root whose remotes match `corpHosts`, tracked `/Users/` paths in the manifest
217
+ repo, private needles (labels, dir names, hosts) inside `trust: "public-repo"` entries, and
218
+ corp-host commit emails on personal entries — each a risk finding; each check that cannot run
219
+ is disclosed.
220
+ - **No global pointer.** `--manifest` is required unless the cwd holds `registry.json` — there
221
+ is deliberately no env var and no home-directory pointer.
222
+
223
+ Interop note: if a repo's Prettier (or similar formatter) checks JSON, add `.etymd` to its
224
+ `.prettierignore` — etymd writes its own JSON style, and a format gate fighting the ledger is
225
+ noise (this repo does exactly that).
226
+
129
227
  ## Programmatic use
130
228
 
131
229
  ```ts
@@ -157,7 +255,8 @@ Without it — on a fresh clone or in CI — those suites skip cleanly and the r
157
255
  ## Design record & roadmap
158
256
 
159
257
  [`docs/design/`](docs/design/) — 001 founding · 002 foundation re-lock · **003 the truth-guard
160
- pivot** (the current identity; includes the state-of-the-field investigation it rests on).
258
+ pivot** (the current identity; includes the state-of-the-field investigation it rests on) ·
259
+ **004 fleet mode** (the truth guard across your repositories).
161
260
  [`ROADMAP.md`](ROADMAP.md) — what's now / next / later, the pre-publish checklist, and the
162
261
  accepted heuristic trade-offs.
163
262
 
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { scanProject, PACK_VERSION } from './chunk-TCWKIMCU.js';
3
- import { VERSION } from './chunk-U33XLT5I.js';
4
- import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-SMM2TW35.js';
5
- import { section, theme, print, renderBaselineDrift, glyph } from './chunk-WVYKYPKS.js';
2
+ import { scanProject, PACK_VERSION } from './chunk-WNMA2FYM.js';
3
+ import { VERSION } from './chunk-JQHGV74L.js';
4
+ import { readBaseline, summarizeBaselineDrift, isDriftEmpty, writeBaseline, deriveProfile } from './chunk-DJ2DDFDK.js';
5
+ import { section, theme, print, renderBaselineDrift, glyph } from './chunk-ASKJFMSX.js';
6
6
 
7
7
  // src/commands/approve.ts
8
8
  async function run(opts) {
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ export { run } from './chunk-3WOORFOW.js';
3
+ import './chunk-XGH2DTW4.js';
4
+ import './chunk-XW5XQN23.js';
5
+ import './chunk-3LBIBVX3.js';
6
+ import './chunk-WNMA2FYM.js';
7
+ import './chunk-JQHGV74L.js';
8
+ import './chunk-DJ2DDFDK.js';
9
+ import './chunk-ASKJFMSX.js';
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { scanProject } from './chunk-TCWKIMCU.js';
3
- import './chunk-U33XLT5I.js';
4
- import { ETYMD_DIR } from './chunk-SMM2TW35.js';
5
- import { print, theme } from './chunk-WVYKYPKS.js';
2
+ import { scanProject } from './chunk-WNMA2FYM.js';
3
+ import './chunk-JQHGV74L.js';
4
+ import { ETYMD_DIR } from './chunk-DJ2DDFDK.js';
5
+ import { print, theme } from './chunk-ASKJFMSX.js';
6
6
  import { promises } from 'node:fs';
7
7
  import path from 'node:path';
8
8
 
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { ETYMD_DIR } from './chunk-SMM2TW35.js';
3
- import { pathExists, readText, wordCount, approxTokens } from './chunk-WVYKYPKS.js';
2
+ import { ETYMD_DIR } from './chunk-DJ2DDFDK.js';
3
+ import { pathExists, readText, wordCount, approxTokens } from './chunk-ASKJFMSX.js';
4
4
  import path2 from 'node:path';
5
5
  import { promises } from 'node:fs';
6
6
 
7
7
  var CONFIG_FILE = path2.join(ETYMD_DIR, "config.json");
8
8
  var DEFAULT_CONFIG = {
9
9
  instructions: { include: [], exclude: [] },
10
- context: { perFileWords: 4e3, totalWords: 8e3 }
10
+ context: { perFileWords: 4e3, totalWords: 8e3 },
11
+ state: { staleAfterDays: 30, maxChars: 9500 }
11
12
  };
12
13
  function configPath(root) {
13
14
  return path2.join(root, CONFIG_FILE);
@@ -51,6 +52,7 @@ async function readConfig(root) {
51
52
  const obj = parsed;
52
53
  const instructions = obj.instructions ?? {};
53
54
  const context = obj.context ?? {};
55
+ const state = obj.state ?? {};
54
56
  return {
55
57
  present: true,
56
58
  problems,
@@ -62,12 +64,17 @@ async function readConfig(root) {
62
64
  context: {
63
65
  perFileWords: readBudget(context.perFileWords, "context.perFileWords", problems) ?? DEFAULT_CONFIG.context.perFileWords,
64
66
  totalWords: readBudget(context.totalWords, "context.totalWords", problems) ?? DEFAULT_CONFIG.context.totalWords
67
+ },
68
+ state: {
69
+ staleAfterDays: readBudget(state.staleAfterDays, "state.staleAfterDays", problems) ?? DEFAULT_CONFIG.state.staleAfterDays,
70
+ maxChars: readBudget(state.maxChars, "state.maxChars", problems) ?? DEFAULT_CONFIG.state.maxChars
65
71
  }
66
72
  }
67
73
  };
68
74
  }
69
75
  var ALWAYS_LOADED = [
70
76
  { path: "AGENTS.md", role: "operating contract" },
77
+ { path: "PROJECT_CONTEXT.md", role: "ground-truth state" },
71
78
  { path: "CLAUDE.md", role: "Claude Code pointer" },
72
79
  { path: "GEMINI.md", role: "Gemini pointer" },
73
80
  { path: ".github/copilot-instructions.md", role: "Copilot instructions" },
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ import { parseFailOnTier, runAudit, meetsFailOn } from './chunk-XGH2DTW4.js';
3
+ import { print, section, theme, renderLensCoverage, renderFindings, renderLedgerDiff } from './chunk-ASKJFMSX.js';
4
+
5
+ // src/commands/audit.ts
6
+ async function run(opts) {
7
+ const failOn = opts.failOn === void 0 ? void 0 : parseFailOnTier(opts.failOn);
8
+ const result = await runAudit(opts.cwd, {
9
+ kind: opts.truth ? "truth" : void 0,
10
+ lensIds: opts.lens ? [opts.lens] : void 0,
11
+ persistLedger: !opts.noLedger
12
+ });
13
+ if (failOn && meetsFailOn(result.findings, failOn)) {
14
+ process.exitCode = 1;
15
+ }
16
+ if (opts.json) {
17
+ print(
18
+ JSON.stringify(
19
+ {
20
+ profile: result.profile,
21
+ baseline: result.baseline ? { packVersion: result.baseline.packVersion, approvedAt: result.baseline.approvedAt } : null,
22
+ reports: result.reports.map(({ findings: _findings, ...r }) => r),
23
+ findings: result.findings,
24
+ diff: {
25
+ new: result.diff.new.map((f) => f.id),
26
+ stillOpen: result.diff.stillOpen.map((f) => f.id),
27
+ regressed: result.diff.regressed.map((f) => f.id),
28
+ resolved: result.diff.resolved.map((e) => e.id),
29
+ dismissed: result.diff.dismissed.map((f) => f.id),
30
+ accepted: result.diff.accepted.map((f) => f.id)
31
+ }
32
+ },
33
+ null,
34
+ 2
35
+ )
36
+ );
37
+ return;
38
+ }
39
+ section(
40
+ `${opts.truth ? "Doctor \u2014 is this still true?" : "Audit"} ${theme.dim(`\xB7 ${result.facts.name} \xB7 ${result.profile} profile`)}`
41
+ );
42
+ if (!result.baseline && !opts.truth) {
43
+ print(
44
+ ` ${theme.dim("no committed baseline \u2014 run `etymd init` to approve one; drift checks are limited")}`
45
+ );
46
+ }
47
+ renderLensCoverage(result.reports);
48
+ section(`Findings ${theme.dim("(ranked: risk \u2192 gap \u2192 polish, cheapest first)")}`);
49
+ renderFindings(result.findings);
50
+ renderLedgerDiff(result.diff);
51
+ const top = result.findings[0];
52
+ if (top) {
53
+ print();
54
+ print(
55
+ ` ${theme.dim("next:")} ${theme.heading(top.claim)}${top.action ? theme.dim(` \u2014 ${top.action}`) : ""}`
56
+ );
57
+ }
58
+ }
59
+
60
+ export { run };
@@ -280,6 +280,33 @@ function renderLedgerDiff(diff) {
280
280
  print(` ${theme.dim("since last audit:")} ${parts.join(theme.dim(" \xB7 "))}`);
281
281
  }
282
282
  }
283
+ function renderFleetRows(rows) {
284
+ if (!rows.length) {
285
+ print(` ${theme.dim("no entries matched \u2014 nothing swept")}`);
286
+ return;
287
+ }
288
+ const nameWidth = maxWidth(rows.map((r) => r.name));
289
+ const ageWidth = maxWidth(rows.map((r) => r.age));
290
+ const countStr = (c) => c.risk + c.gap + c.polish === 0 ? theme.ok("clean") : [
291
+ c.risk ? theme.bad(`${c.risk} risk`) : "",
292
+ c.gap ? theme.warn(`${c.gap} gap`) : "",
293
+ c.polish ? theme.dim(`${c.polish} polish`) : ""
294
+ ].filter(Boolean).join(theme.dim(" \xB7 "));
295
+ const countsWidth = maxWidth(rows.filter((r) => !r.note).map((r) => countStr(r.counts)));
296
+ for (const r of rows) {
297
+ if (r.note) {
298
+ print(` ${pad(theme.info(r.name), nameWidth)} ${glyph.bad} ${theme.dim(r.note)}`);
299
+ continue;
300
+ }
301
+ print(
302
+ ` ${pad(theme.info(r.name), nameWidth)} ${pad(theme.dim(r.age), ageWidth)} ${pad(countStr(r.counts), countsWidth)} ${theme.dim(r.delta)}`
303
+ );
304
+ }
305
+ }
306
+ function renderFleetNotes(problems, disclosures) {
307
+ for (const p of problems) print(` ${glyph.bad} ${theme.bad(p)}`);
308
+ for (const d of disclosures) print(` ${theme.dim(`\u25E6 ${d}`)}`);
309
+ }
283
310
  var STATUS_ORDER = ["regressed", "open", "accepted", "dismissed", "done"];
284
311
  var STATUS_LABEL = {
285
312
  regressed: theme.bad,
@@ -329,4 +356,4 @@ function renderLedger(ledger) {
329
356
  }
330
357
  }
331
358
 
332
- export { approxTokens, git, glyph, isCiEnvironment, isDirectory, matchesAnyGlob, normalizeRelPath, pathExists, print, readJson, readText, renderBaselineDrift, renderContext, renderFacts, renderFindings, renderLedger, renderLedgerDiff, renderLensCoverage, renderPlan, section, theme, wordCount };
359
+ export { approxTokens, git, glyph, isCiEnvironment, isDirectory, matchesAnyGlob, normalizeRelPath, pathExists, print, readJson, readText, renderBaselineDrift, renderContext, renderFacts, renderFindings, renderFleetNotes, renderFleetRows, renderLedger, renderLedgerDiff, renderLensCoverage, renderPlan, section, theme, wordCount };
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readLedger, resolveEntry, writeLedger } from './chunk-N64ZTBCW.js';
3
- import { print, renderLedger, glyph, theme } from './chunk-WVYKYPKS.js';
2
+ import { readLedger, resolveEntry, writeLedger } from './chunk-XW5XQN23.js';
3
+ import { print, renderLedger, glyph, theme } from './chunk-ASKJFMSX.js';
4
4
 
5
5
  // src/commands/ledger.ts
6
6
  async function list(opts) {
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { readJson } from './chunk-WVYKYPKS.js';
2
+ import { readJson } from './chunk-ASKJFMSX.js';
3
3
  import { promises } from 'node:fs';
4
4
  import path from 'node:path';
5
5
 
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // package.json
3
3
  var package_default = {
4
- version: "0.1.0"};
4
+ version: "0.2.0"};
5
5
 
6
6
  // src/version.ts
7
7
  var VERSION = package_default.version;
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { VERSION } from './chunk-U33XLT5I.js';
3
- import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-WVYKYPKS.js';
2
+ import { VERSION } from './chunk-JQHGV74L.js';
3
+ import { normalizeRelPath, isDirectory, readJson, git, matchesAnyGlob, pathExists, readText } from './chunk-ASKJFMSX.js';
4
4
  import path from 'node:path';
5
5
  import { promises } from 'node:fs';
6
6
 
@@ -334,6 +334,19 @@ var ARTIFACT_SPECS = [
334
334
  path: "PROJECT_CONTEXT.md",
335
335
  kind: "state"
336
336
  },
337
+ {
338
+ id: "decisions",
339
+ label: "DECISIONS.md (decision record)",
340
+ path: "DECISIONS.md",
341
+ kind: "decisions"
342
+ },
343
+ { id: "adr-dir", label: "ADR directory (docs/adr)", path: "docs/adr", kind: "decisions" },
344
+ {
345
+ id: "decisions-dir",
346
+ label: "ADR directory (docs/decisions)",
347
+ path: "docs/decisions",
348
+ kind: "decisions"
349
+ },
337
350
  { id: "claude", label: "CLAUDE.md (Claude Code pointer)", path: "CLAUDE.md", kind: "adapter" },
338
351
  {
339
352
  id: "copilot",
@@ -379,12 +392,25 @@ var ARTIFACT_SPECS = [
379
392
  }
380
393
  ];
381
394
  async function detectArtifacts(root) {
382
- return Promise.all(
395
+ const artifacts = await Promise.all(
383
396
  ARTIFACT_SPECS.map(async (spec) => ({
384
397
  ...spec,
385
398
  exists: await pathExists(path.join(root, spec.path))
386
399
  }))
387
400
  );
401
+ let adrFiles = false;
402
+ try {
403
+ adrFiles = (await promises.readdir(path.join(root, "docs"))).some((f) => /^\d{4}-.+\.md$/.test(f));
404
+ } catch {
405
+ }
406
+ artifacts.push({
407
+ id: "adr-files",
408
+ label: "ADR files (docs/NNNN-*.md)",
409
+ path: "docs",
410
+ kind: "decisions",
411
+ exists: adrFiles
412
+ });
413
+ return artifacts;
388
414
  }
389
415
  async function walkTree(root) {
390
416
  const state = { budget: 2e4, truncated: false };
@@ -449,7 +475,50 @@ function inferTicketKey(subjects) {
449
475
  }
450
476
  return bestCount >= 3 ? best : void 0;
451
477
  }
452
- async function scanProject(root) {
478
+ async function collectFreshness(abs, isRepo, artifacts, upstreamRemote) {
479
+ const dated = artifacts.filter((a) => a.exists && (a.kind === "state" || a.kind === "decisions"));
480
+ const allUnverifiable = (reason) => ({
481
+ artifacts: [],
482
+ unverifiable: dated.map((a) => ({ path: a.path, reason }))
483
+ });
484
+ if (!isRepo) return allUnverifiable("not a git repository \u2014 no committer dates to read");
485
+ if (await git(abs, ["rev-parse", "--is-shallow-repository"]) === "true") {
486
+ return allUnverifiable("shallow clone \u2014 history is incomplete, committer dates would lie");
487
+ }
488
+ const repoLastCommit = upstreamRemote ? await git(abs, ["log", "-1", "--format=%cI", "HEAD", "--not", `--remotes=${upstreamRemote}`]) : await git(abs, ["log", "-1", "--format=%cI"]);
489
+ if (!repoLastCommit) {
490
+ if (upstreamRemote) {
491
+ return allUnverifiable(
492
+ `no fork-authored commits \u2014 every commit is reachable from \`${upstreamRemote}\`, the fork has not moved`
493
+ );
494
+ }
495
+ return allUnverifiable("repository has no commits yet");
496
+ }
497
+ const dirtyRaw = await git(abs, ["diff", "--name-only", "HEAD"]);
498
+ const dirtyPaths = dirtyRaw ? dirtyRaw.split("\n").filter(Boolean) : [];
499
+ const isDirty = (p) => dirtyPaths.some((d) => d === p || d.startsWith(`${p}/`));
500
+ const facts = { repoLastCommit, artifacts: [], unverifiable: [] };
501
+ await Promise.all(
502
+ dated.map(async (a) => {
503
+ const lastCommit = await git(abs, ["log", "-1", "--format=%cI", "--", a.path]);
504
+ if (!lastCommit) {
505
+ facts.unverifiable.push({ path: a.path, reason: "untracked \u2014 never committed" });
506
+ return;
507
+ }
508
+ facts.artifacts.push({
509
+ artifactId: a.id,
510
+ path: a.path,
511
+ lastCommit,
512
+ commitsSince: Date.parse(repoLastCommit) > Date.parse(lastCommit),
513
+ ...isDirty(a.path) ? { dirty: true } : {}
514
+ });
515
+ })
516
+ );
517
+ facts.artifacts.sort((x, y) => x.path.localeCompare(y.path));
518
+ facts.unverifiable.sort((x, y) => x.path.localeCompare(y.path));
519
+ return facts;
520
+ }
521
+ async function scanProject(root, opts = {}) {
453
522
  const abs = path.resolve(root);
454
523
  const rootPkg = await readJson(path.join(abs, "package.json"));
455
524
  const [packageManager, workspace, ci, artifacts, tree, isRepo] = await Promise.all([
@@ -469,6 +538,7 @@ async function scanProject(root) {
469
538
  ]) : [null, null, null, null, null];
470
539
  const hooksPath = hooksPathRaw ?? void 0;
471
540
  const hooks = await detectHooks(abs, hooksPath, rootPkg);
541
+ const freshness = await collectFreshness(abs, isRepo, artifacts, opts.upstreamRemote);
472
542
  const packages = workspace.packageGlobs.length ? await listWorkspacePackages(abs, workspace.packageGlobs) : [];
473
543
  const recentAuthors = authorsRaw ? new Set(authorsRaw.split("\n").filter(Boolean)).size : void 0;
474
544
  return {
@@ -495,6 +565,7 @@ async function scanProject(root) {
495
565
  ci,
496
566
  hooks,
497
567
  artifacts,
568
+ freshness,
498
569
  tree
499
570
  };
500
571
  }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { PACK_VERSION } from './chunk-TCWKIMCU.js';
3
- import { pathExists, readText } from './chunk-WVYKYPKS.js';
2
+ import { PACK_VERSION } from './chunk-WNMA2FYM.js';
3
+ import { pathExists, readText } from './chunk-ASKJFMSX.js';
4
4
  import { promises } from 'node:fs';
5
5
  import path from 'node:path';
6
6