@forwardimpact/libwiki 0.3.0 → 0.3.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.
Files changed (49) hide show
  1. package/README.md +30 -29
  2. package/package.json +1 -1
  3. package/src/active-claims.js +5 -5
  4. package/src/agent-roster.js +2 -2
  5. package/src/audit/admission.js +14 -11
  6. package/src/audit/conflict-markers-rule.js +9 -9
  7. package/src/audit/grammar.js +21 -18
  8. package/src/audit/rule-builders.js +20 -19
  9. package/src/audit/rules.js +30 -27
  10. package/src/audit/scopes.js +34 -33
  11. package/src/audit/status-row.js +14 -15
  12. package/src/block-renderer.js +5 -4
  13. package/src/boot.js +10 -8
  14. package/src/budget-gate.js +39 -36
  15. package/src/budget.js +3 -3
  16. package/src/cli-definition.js +19 -16
  17. package/src/commands/audit.js +3 -3
  18. package/src/commands/boot.js +1 -1
  19. package/src/commands/claim.js +44 -38
  20. package/src/commands/curate.js +34 -31
  21. package/src/commands/fix.js +68 -64
  22. package/src/commands/inbox.js +1 -1
  23. package/src/commands/init.js +12 -7
  24. package/src/commands/ledger.js +11 -11
  25. package/src/commands/log.js +19 -17
  26. package/src/commands/memo.js +4 -1
  27. package/src/commands/product-mix.js +16 -15
  28. package/src/commands/refresh.js +25 -22
  29. package/src/commands/rotate.js +9 -8
  30. package/src/commands/sync.js +26 -17
  31. package/src/conflict-markers.js +21 -21
  32. package/src/constants.js +37 -33
  33. package/src/gitattributes.js +10 -9
  34. package/src/integrity.js +29 -27
  35. package/src/issue-list-renderer.js +24 -16
  36. package/src/lane-files.js +11 -10
  37. package/src/ledger/anchor.js +6 -6
  38. package/src/ledger/projection.js +35 -32
  39. package/src/ledger/reader.js +4 -4
  40. package/src/marker-scanner.js +3 -2
  41. package/src/sanitize.js +12 -11
  42. package/src/secret-gate.js +41 -40
  43. package/src/status.js +12 -11
  44. package/src/storyboard-skeleton.js +20 -18
  45. package/src/util/agent-flag.js +8 -8
  46. package/src/util/clock.js +1 -1
  47. package/src/util/wiki-dir.js +7 -7
  48. package/src/weekly-log.js +115 -101
  49. package/src/wiki-sync.js +393 -361
package/src/sanitize.js CHANGED
@@ -3,9 +3,10 @@ const ELLIPSIS = "…";
3
3
  const ZERO_WIDTH_SPACE = "\u200b";
4
4
 
5
5
  // Replace every newline, control character, or whitespace code point with a
6
- // single space, then collapse runs. Done by code-point inspection rather than a
7
- // character-class range so no literal hyphen is ever folded into a range and
8
- // hyphenated identifiers ("staff-engineer", "dick-olsson") survive intact.
6
+ // single space. Then collapse runs. This function inspects each code point and
7
+ // does not use a character-class range. So it never folds a literal hyphen
8
+ // into a range, and hyphenated identifiers ("staff-engineer", "dick-olsson")
9
+ // survive intact.
9
10
  function flattenWhitespace(input) {
10
11
  let out = "";
11
12
  for (const ch of input) {
@@ -19,11 +20,11 @@ function flattenWhitespace(input) {
19
20
 
20
21
  /**
21
22
  * Neutralize an anyone-editable issue-tracker field before it crosses into a
22
- * boot-readable wiki surface. Flattens newlines / control characters /
23
- * whitespace to single spaces (a multi-line value is what would let a field
24
- * inject a heading or block marker and move section boundaries), escapes a
25
- * leading protocol sigil ("[" or "<") so "[ask#N]" / "<tag>" / HTML-comment
26
- * lookalikes render inert, and length-caps the result.
23
+ * boot-readable wiki surface. Flattens newlines, control characters, and
24
+ * whitespace to single spaces. A multi-line value would let a field inject a
25
+ * heading or block marker and move section boundaries. Escapes a leading
26
+ * protocol sigil ("[" or "<") so "[ask#N]", "<tag>", and HTML-comment
27
+ * lookalikes render inert. Length-caps the result.
27
28
  * @param {string|null|undefined} value
28
29
  * @param {number} [maxLen]
29
30
  * @returns {string}
@@ -38,9 +39,9 @@ export function sanitizeCrossingField(value, maxLen = FIELD_CAP) {
38
39
 
39
40
  /**
40
41
  * Sanitize a materialized item title. Beyond {@link sanitizeCrossingField}, it
41
- * defuses the literal author-suffix token " (by " by inserting a zero-width
42
- * space after "(by", so a title can never be mistaken for the trailing
43
- * "(by <author>)" provenance suffix when the line is parsed back at boot.
42
+ * defuses the literal author-suffix token " (by ". It inserts a zero-width
43
+ * space after "(by". A title then never looks like the trailing
44
+ * "(by <author>)" provenance suffix when the boot parse reads the line back.
44
45
  * @param {string|null|undefined} value
45
46
  * @param {number} [maxLen]
46
47
  * @returns {string}
@@ -1,32 +1,33 @@
1
1
  /**
2
- * Fail-closed secret gate for the wiki push path. Runs gitleaks over the
3
- * commit range a push introduces and reports a clean / finding /
4
- * scanner-absent verdict. The wiki has no destination-side secret control (a
5
- * GitHub Wiki repo runs no Actions and is excluded from GitHub
6
- * secret-scanning), so this is the only place a content backstop can live.
2
+ * Fail-closed secret gate for the wiki push path. The gate runs gitleaks over
3
+ * the commit range a push introduces. It reports a clean, finding, or
4
+ * scanner-absent verdict. The wiki has no destination-side secret control. A
5
+ * GitHub Wiki repo runs no Actions, and GitHub secret-scanning excludes it. So
6
+ * this is the only place a content backstop can live.
7
7
  *
8
- * The module never throws on a scanner result: a missing or erroring scanner
9
- * resolves to `scanner-absent` so the caller fails closed rather than treating
10
- * an error as clean. Findings carry only a location (`file:line:rule`) — never
11
- * the matched secret value, so an audit record built from them cannot itself
12
- * leak.
8
+ * The module never throws on a scanner result. A missing scanner resolves to
9
+ * `scanner-absent`. A scanner that errors resolves the same way. The caller
10
+ * then fails closed and never reports an error as clean. Findings carry only a
11
+ * location (`file:line:rule`). They never carry the matched secret value, so an
12
+ * audit record built from them cannot itself leak.
13
13
  */
14
14
 
15
15
  import { isoTimestamp } from "@forwardimpact/libutil";
16
16
  import { createLogger } from "@forwardimpact/libtelemetry";
17
17
 
18
- /** The gitleaks binary name resolved on PATH; provisioning is an operator concern (see wiki-operations guide). */
18
+ /** The gitleaks binary name resolved on PATH. An operator provisions it (see the wiki-operations guide). */
19
19
  const GITLEAKS = "gitleaks";
20
20
 
21
21
  /**
22
- * Scan the commit range a push introduces for secrets, fail closed.
22
+ * Scan the commit range a push introduces for secrets. Fail closed.
23
23
  *
24
- * Probes `gitleaks version` first; an unresolvable binary short-circuits to
25
- * `scanner-absent`. Then runs `gitleaks detect` over `range` expressed as
26
- * `git log` options, reading the JSON report from stdout. Exit codes follow
27
- * gitleaks' documented contract: `0` clean, `1` leaks found, any other
28
- * non-zero an invocation error (treated as `scanner-absent` — fail closed, an
29
- * error is never reported as clean).
24
+ * The scan probes `gitleaks version` first. An unresolvable binary
25
+ * short-circuits to `scanner-absent`. The scan then runs `gitleaks detect` over
26
+ * `range` expressed as `git log` options, and reads the JSON report from
27
+ * stdout. Exit codes follow gitleaks' documented contract: `0` clean, `1` leaks
28
+ * found, any other non-zero an invocation error. The scan treats an invocation
29
+ * error as `scanner-absent` and fails closed. It never reports an error as
30
+ * clean.
30
31
  *
31
32
  * @param {object} args
32
33
  * @param {import('@forwardimpact/libutil/runtime').Runtime} args.runtime - Provides `subprocess.run`.
@@ -60,16 +61,16 @@ export async function scanPushWindow({ runtime, wikiDir, range }) {
60
61
  if (scan.exitCode === 1) {
61
62
  return { status: "finding", findings: parseFindings(scan.stdout) };
62
63
  }
63
- // Any other non-zero is an invocation/usage error, not a leak verdict:
64
- // fail closed rather than risk reporting a broken scan as clean.
64
+ // Any other non-zero is an invocation or usage error. It is not a leak
65
+ // verdict. Fail closed. Do not report a broken scan as clean.
65
66
  return { status: "scanner-absent" };
66
67
  }
67
68
 
68
69
  /**
69
- * Parse a gitleaks JSON report into location-only findings. Reads only the
70
- * file, line, and rule of each entry — never the matched secret value — so a
71
- * record built from the result is secret-free by construction. A malformed or
72
- * empty report yields an empty list.
70
+ * Parse a gitleaks JSON report into location-only findings. The parser reads
71
+ * only the file, line, and rule of each entry. It never reads the matched
72
+ * secret value, so a record built from the result is secret-free by
73
+ * construction. A malformed or empty report yields an empty list.
73
74
  *
74
75
  * @param {string} stdout - The gitleaks JSON report.
75
76
  * @returns {Array<{file: string, line: number, rule: string}>}
@@ -93,10 +94,10 @@ function parseFindings(stdout) {
93
94
  * Append one secret-free line to the wiki tree's `secret-overrides.log` and
94
95
  * stage it (path-scoped) so it lands in the same push as the overridden
95
96
  * content. The line records the override as a durable, inspectable audit
96
- * trail: an ISO timestamp, the asserted operator identity (`git config
97
- * user.email` — attribution of intent, NOT an authenticated identity), the
98
- * override class, the reason, and for a finding its location. It never carries
99
- * a matched secret value.
97
+ * trail. It holds an ISO timestamp, the asserted operator identity (`git
98
+ * config user.email`), the override class, the reason, and for a finding its
99
+ * location. That identity asserts intent. It is NOT an authenticated identity.
100
+ * The line never carries a matched secret value.
100
101
  *
101
102
  * @param {object} args
102
103
  * @param {import('@forwardimpact/libutil/runtime').Runtime} args.runtime - Provides `fs` and `clock`.
@@ -123,8 +124,8 @@ export async function appendOverrideRecord({
123
124
  "unspecified"
124
125
  : "scanner-absent";
125
126
  const ts = isoTimestamp(runtime.clock.now());
126
- // Tab-separated, single line; the reason is collapsed so the record stays
127
- // one inspectable row per override.
127
+ // Tab-separated, single line. The replace call collapses the reason so the
128
+ // record stays one inspectable row per override.
128
129
  const line = `${ts}\t${email}\t${klass}\t${reason.replace(/\s+/g, " ").trim()}\t${where}\n`;
129
130
  const logPath = `${wikiDir}/${OVERRIDE_LOG}`;
130
131
  await runtime.fs.appendFile(logPath, line);
@@ -140,12 +141,12 @@ export async function appendOverrideRecord({
140
141
  export const OVERRIDE_LOG = "secret-overrides.log";
141
142
 
142
143
  /**
143
- * Translate a `commitAndPush` security refusal into a command envelope,
144
- * logging the cause and its break-glass procedure at error level (always
145
- * surfaced, regardless of LOG_LEVEL). Returns `null` for any non-refusal
146
- * result (clean / pushed / network "saved locally"), so a caller can fall
147
- * through to its normal success handling. Shared by every command surface so
148
- * the refusal message and exit code live in one place.
144
+ * Translate a `commitAndPush` security refusal into a command envelope. Log the
145
+ * cause and its break-glass procedure at error level. The logger always
146
+ * surfaces them, regardless of LOG_LEVEL. Returns `null` for any
147
+ * non-refusal result (clean, pushed, or network "saved locally"), so a caller
148
+ * can fall through to its normal success path. Every command surface shares
149
+ * this function, so the refusal message and exit code live in one place.
149
150
  *
150
151
  * @param {object} runtime - The runtime bag (the logger writes to `proc.stderr`).
151
152
  * @param {{reason?: string, findings?: Array<{file: string, line: number, rule: string}>}} result - A `commitAndPush` result.
@@ -158,8 +159,8 @@ export function refusalEnvelope(runtime, result) {
158
159
  .join(", ");
159
160
  createLogger("wiki", runtime).error(
160
161
  "push",
161
- `push blocked: secret detected in wiki content${where ? ` (${where})` : ""}; ` +
162
- "the push was not attempted. After confirming a false positive, set " +
162
+ `push blocked: secret detected in wiki content${where ? ` (${where})` : ""}. ` +
163
+ "The push did not run. Confirm a false positive first. Then set " +
163
164
  "FIT_WIKI_SECRET_OVERRIDE to a reason to override (audited).",
164
165
  );
165
166
  return { ok: false, code: 1 };
@@ -167,8 +168,8 @@ export function refusalEnvelope(runtime, result) {
167
168
  if (result.reason === "scanner-unavailable") {
168
169
  createLogger("wiki", runtime).error(
169
170
  "push",
170
- "push blocked: the secret scanner (gitleaks) is unavailable; the push " +
171
- "was not attempted. Install gitleaks, or set FIT_WIKI_SCANNER_ABSENT_OK " +
171
+ "push blocked: the secret scanner (gitleaks) is unavailable. The push " +
172
+ "did not run. Install gitleaks, or set FIT_WIKI_SCANNER_ABSENT_OK " +
172
173
  "to a reason to override (audited).",
173
174
  );
174
175
  return { ok: false, code: 1 };
package/src/status.js CHANGED
@@ -1,20 +1,21 @@
1
1
  // STATUS.md rows come in two kinds. A spec row's id is four digits with an
2
- // optional `/<unit>` suffix denoting a per-migration-unit sub-row of a master
3
- // spec (`1370/libutil`, …); the master `NNNN` row advances only when every
4
- // sub-row reads `plan implemented`. An experiment row's id is `exp:<issue>`
5
- // and the row carries four tab cells — `exp:<issue><TAB><state><TAB><pin>
6
- // <TAB><plan-ref>` — keying the merge-gate approval path for a spec-less
7
- // experiment PR. The `exp:` namespace cannot match the spec id's `^\d{4}`
8
- // anchor, so the two kinds never collide for any issue-number width.
2
+ // optional `/<unit>` suffix. The suffix denotes a per-migration-unit sub-row
3
+ // of a master spec (`1370/libutil`, …). The master `NNNN` row advances only
4
+ // when every sub-row reads `plan implemented`. An experiment row's id is
5
+ // `exp:<issue>`. That row carries four tab cells:
6
+ // `exp:<issue><TAB><state><TAB><pin><TAB><plan-ref>`. The cells key the
7
+ // merge-gate approval path for a spec-less experiment PR. The `exp:` namespace
8
+ // cannot match the spec id's `^\d{4}` anchor, so the two kinds never collide
9
+ // for any issue-number width.
9
10
 
10
11
  /** Matches a status-row id: a four-digit spec id (optional `/<unit>`) or `exp:<issue>`. */
11
12
  export const STATUS_ID_REGEX = /^(\d{4}(\/[a-z0-9-]+)?|exp:\d+)$/;
12
13
 
13
14
  /**
14
- * Classify a status-row id into its kind and parts. Experiment rows are
15
- * identified by an `exp:` id together with a four-cell row; the optional
16
- * `cells` array supplies that count (a bare `exp:` id without four cells is
17
- * not a valid row and yields null).
15
+ * Classify a status-row id into its kind and parts. An `exp:` id together with
16
+ * a four-cell row identifies an experiment row. The optional `cells` array
17
+ * supplies that count. A bare `exp:` id without four cells is not a valid row
18
+ * and yields null.
18
19
  * @param {string} id - The id field (cell 0) of a STATUS.md row.
19
20
  * @param {string[]} [cells] - The full tab-separated cells of the row, when
20
21
  * available. Required to classify an experiment row.
@@ -1,16 +1,17 @@
1
1
  /**
2
- * Storyboard skeleton — the minimal, valid storyboard file `gemba-wiki refresh`
3
- * writes when the current-month board does not yet exist. It carries only the
4
- * structural surface libwiki owns: the five Toyota Kata sections and the
5
- * generic `obstacles`/`experiments` issue-list marker blocks that refresh
6
- * renders from tracker state.
2
+ * The storyboard skeleton is the minimal, valid storyboard file that
3
+ * `gemba-wiki refresh` writes when the current-month board does not yet exist.
4
+ * It carries only the structural surface libwiki owns: the five Toyota Kata
5
+ * sections and the generic `obstacles`/`experiments` issue-list marker blocks.
6
+ * Refresh renders those blocks from tracker state.
7
7
  *
8
- * Deliberately *not* here: the per-agent `#### {metric}` XmR blocks. Their
9
- * agent→metric grouping is per-installation curation libwiki cannot infer, so a
10
- * participant seeds each missing marker pair (see the kata-session skill) and a
11
- * later refresh renders it. Section budgets and authoring prose ("write the
12
- * challenge here") stay in the skill's `storyboard-template.md`, the L4
13
- * authoring layer — this skeleton is content-free scaffolding.
8
+ * This skeleton deliberately omits the per-agent `#### {metric}` XmR blocks.
9
+ * Each installation curates which metric belongs to which agent, so libwiki
10
+ * cannot infer the pairs. A participant seeds each missing marker pair (see the
11
+ * kata-session skill). A later refresh renders it. Section budgets and prose
12
+ * for authors ("write the challenge here") stay in the skill's
13
+ * `storyboard-template.md`, the L4 authoring layer. The skeleton itself carries
14
+ * no content.
14
15
  */
15
16
 
16
17
  const MONTH_NAMES = [
@@ -36,9 +37,9 @@ function isLeapYear(year) {
36
37
  }
37
38
 
38
39
  /**
39
- * Last calendar day of the month containing `todayIso` (ISO `YYYY-MM-DD`).
40
- * Pure integer/calendar math — no `Date`, so the module stays free of ambient
41
- * time deps.
40
+ * Return the last calendar day of the month that holds `todayIso` (ISO
41
+ * `YYYY-MM-DD`). The function uses pure integer and calendar math. It uses no
42
+ * `Date`, so the module stays free of ambient time deps.
42
43
  */
43
44
  function endOfMonthIso(todayIso) {
44
45
  const [year, month] = todayIso.split("-").map(Number);
@@ -48,10 +49,11 @@ function endOfMonthIso(todayIso) {
48
49
  }
49
50
 
50
51
  /**
51
- * Render the minimal storyboard skeleton for the month containing `todayIso`.
52
- * Pure — takes the day as an ISO string and returns markdown. The heading reads
53
- * `# Storyboard — {YYYY} {Month}`; the marker blocks match the syntax the
54
- * scanner (`marker-scanner.js`) and renderer (`commands/refresh.js`) expect.
52
+ * Render the minimal storyboard skeleton for the month that holds `todayIso`.
53
+ * The function is pure. It takes the day as an ISO string and returns
54
+ * markdown. The heading reads `# Storyboard — {YYYY} {Month}`. The marker
55
+ * blocks match the syntax the scanner (`marker-scanner.js`) and renderer
56
+ * (`commands/refresh.js`) expect.
55
57
  *
56
58
  * @param {string} todayIso - ISO date string (`YYYY-MM-DD`).
57
59
  * @returns {string} The skeleton markdown, newline-terminated.
@@ -1,16 +1,16 @@
1
1
  /**
2
- * Resolve the required agent flag from frozen CLI options. Pure — reads no
3
- * filesystem and no environment, so it runs before any state change. Returns
4
- * `{ ok: true, agent }` when the flag is present, or
5
- * `{ ok: false, code: 2, error }` when it is missing, where `error` names the
6
- * missing flag and shows a corrected example invocation. The error never
7
- * mentions an environment variable: `libwiki` carries no ambient agent
2
+ * Resolve the required agent flag from frozen CLI options. The function is
3
+ * pure. It reads no filesystem and no environment, so it runs before any state
4
+ * change. It returns `{ ok: true, agent }` when the flag is present. It returns
5
+ * `{ ok: false, code: 2, error }` when the flag is missing. The `error` names
6
+ * the missing flag and shows a corrected example invocation. The error never
7
+ * mentions an environment variable. `libwiki` carries no ambient agent
8
8
  * identity, so there is no fallback to offer.
9
9
  *
10
10
  * @param {Record<string, unknown>} options - The frozen `ctx.options`.
11
11
  * @param {{ command: string, flag?: string, example: string }} spec
12
- * `command` names the failing subcommand; `flag` is the option key prefix
13
- * (`--agent` by default, `--from` for `memo`); `example` is a correct
12
+ * `command` names the subcommand that failed. `flag` is the option key prefix
13
+ * (`--agent` by default, `--from` for `memo`). `example` is a correct
14
14
  * invocation shown verbatim in the error.
15
15
  * @returns {{ ok: true, agent: string } | { ok: false, code: 2, error: string }}
16
16
  */
package/src/util/clock.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { isoDate } from "@forwardimpact/libutil";
2
2
 
3
3
  /**
4
- * Today's ISO calendar date (`YYYY-MM-DD`) read from the injected clock.
4
+ * Return today's ISO calendar date (`YYYY-MM-DD`) from the injected clock.
5
5
  * Commands that previously inlined `new Date().toISOString().slice(0, 10)` (or
6
6
  * libwiki's `io.today()`) call this instead so the wall-clock read flows
7
7
  * through `runtime.clock`.
@@ -1,9 +1,9 @@
1
1
  import path from "node:path";
2
2
 
3
3
  /**
4
- * Find the project root by upward `package.json` discovery from the current
5
- * working directory, using the injected `runtime.finder` (the one canonical
6
- * Finder, constructed only inside libutil).
4
+ * Find the project root. The search looks upward for a `package.json` from the
5
+ * current working directory. It uses the injected `runtime.finder` (the one
6
+ * canonical Finder, which libutil alone constructs).
7
7
  * @param {import('@forwardimpact/libutil/runtime').Runtime} runtime
8
8
  * @returns {string}
9
9
  */
@@ -12,9 +12,9 @@ export function resolveProjectRoot(runtime) {
12
12
  }
13
13
 
14
14
  /**
15
- * Resolve the wiki root, preserving the pre-1370 order: the `--wiki-root`
16
- * option when given, else `<projectRoot>/wiki`. The finder is consulted only
17
- * when no explicit `--wiki-root` is supplied.
15
+ * Resolve the wiki root and keep the pre-1370 order: the `--wiki-root` option
16
+ * when the caller gives one, else `<projectRoot>/wiki`. The function consults
17
+ * the finder only when the caller supplies no explicit `--wiki-root`.
18
18
  * @param {import('@forwardimpact/libutil/runtime').Runtime} runtime
19
19
  * @param {Record<string, unknown>} [options] - Parsed CLI options (`ctx.options`).
20
20
  * @returns {string}
@@ -26,7 +26,7 @@ export function resolveWikiRoot(runtime, options = {}) {
26
26
  /**
27
27
  * Report whether the resolved wiki root exists on disk. Commands that read or
28
28
  * sync an existing wiki use this to degrade gracefully (warn and exit 0) when
29
- * the tree was never bootstrapped — e.g. a fresh worktree where
29
+ * nobody bootstrapped the tree. One example is a fresh worktree where
30
30
  * `scripts/bootstrap.sh` did not run.
31
31
  * @param {import('@forwardimpact/libutil/runtime').Runtime} runtime
32
32
  * @param {string} wikiDir - The resolved wiki root.