@forwardimpact/libwiki 0.3.0 → 0.3.2
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/README.md +53 -33
- package/package.json +1 -1
- package/src/active-claims.js +5 -5
- package/src/agent-roster.js +2 -2
- package/src/audit/admission.js +14 -11
- package/src/audit/conflict-markers-rule.js +9 -9
- package/src/audit/grammar.js +21 -18
- package/src/audit/rule-builders.js +20 -19
- package/src/audit/rules.js +30 -27
- package/src/audit/scopes.js +34 -33
- package/src/audit/status-row.js +14 -15
- package/src/block-renderer.js +5 -4
- package/src/boot.js +10 -8
- package/src/budget-gate.js +39 -36
- package/src/budget.js +3 -3
- package/src/cli-definition.js +23 -20
- package/src/commands/audit.js +3 -3
- package/src/commands/boot.js +1 -1
- package/src/commands/claim.js +44 -38
- package/src/commands/curate.js +34 -31
- package/src/commands/fix.js +68 -64
- package/src/commands/inbox.js +1 -1
- package/src/commands/init.js +12 -7
- package/src/commands/ledger.js +11 -11
- package/src/commands/log.js +19 -17
- package/src/commands/memo.js +4 -1
- package/src/commands/product-mix.js +16 -15
- package/src/commands/refresh.js +25 -22
- package/src/commands/rotate.js +9 -8
- package/src/commands/sync.js +26 -17
- package/src/conflict-markers.js +21 -21
- package/src/constants.js +37 -33
- package/src/gitattributes.js +10 -9
- package/src/integrity.js +29 -27
- package/src/issue-list-renderer.js +24 -16
- package/src/lane-files.js +11 -10
- package/src/ledger/anchor.js +6 -6
- package/src/ledger/projection.js +35 -32
- package/src/ledger/reader.js +4 -4
- package/src/marker-scanner.js +3 -2
- package/src/sanitize.js +12 -11
- package/src/secret-gate.js +41 -40
- package/src/status.js +12 -11
- package/src/storyboard-skeleton.js +20 -18
- package/src/util/agent-flag.js +8 -8
- package/src/util/clock.js +1 -1
- package/src/util/wiki-dir.js +7 -7
- package/src/weekly-log.js +115 -101
- package/src/wiki-sync.js +393 -361
package/src/commands/rotate.js
CHANGED
|
@@ -6,8 +6,8 @@ import { resolveWikiRoot } from "../util/wiki-dir.js";
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Rotate the current weekly log to a sealed part file. Refuses an under-budget
|
|
9
|
-
* target (exit 2) unless `--force
|
|
10
|
-
* no-op even under `--force
|
|
9
|
+
* target (exit 2) unless you pass `--force`. The header-only floor stays a
|
|
10
|
+
* zero-exit no-op even under `--force`. A missing target exits 2.
|
|
11
11
|
*/
|
|
12
12
|
export function runRotateCommand(ctx) {
|
|
13
13
|
const { runtime } = ctx.deps;
|
|
@@ -21,8 +21,8 @@ export function runRotateCommand(ctx) {
|
|
|
21
21
|
const agent = resolved.agent;
|
|
22
22
|
const wikiRoot = resolveWikiRoot(runtime, options);
|
|
23
23
|
const today = options.today || currentDayIso(runtime);
|
|
24
|
-
// Name the resolved target before any seal
|
|
25
|
-
// current week
|
|
24
|
+
// Name the resolved target before any seal. The file follows from agent plus
|
|
25
|
+
// current week. No audit finding decides it.
|
|
26
26
|
runtime.proc.stdout.write(
|
|
27
27
|
`target → ${weeklyLogPath(wikiRoot, agent, today)}\n`,
|
|
28
28
|
);
|
|
@@ -43,8 +43,8 @@ export function runRotateCommand(ctx) {
|
|
|
43
43
|
}
|
|
44
44
|
switch (result.status) {
|
|
45
45
|
case "noop":
|
|
46
|
-
// The header-only floor is a benign no-op
|
|
47
|
-
// target fails closed
|
|
46
|
+
// The header-only floor is a benign no-op. An under-budget or missing
|
|
47
|
+
// target fails closed. A stale or typo'd invocation cannot pass silently.
|
|
48
48
|
if (result.reason === "floor") {
|
|
49
49
|
runtime.proc.stdout.write(`no rotation needed for ${agent}\n`);
|
|
50
50
|
return { ok: true };
|
|
@@ -79,14 +79,15 @@ export function runRotateCommand(ctx) {
|
|
|
79
79
|
`section ${section} alone exceeds the budget ` +
|
|
80
80
|
`(${lines} lines, ${words} words) and has no finer seam to split ` +
|
|
81
81
|
`at: ${residuePath}\n` +
|
|
82
|
-
`recover it by hand
|
|
82
|
+
`recover it by hand. Shorten the section ` +
|
|
83
83
|
`(see the memory protocol's manual-recovery convention)`,
|
|
84
84
|
);
|
|
85
85
|
return { ok: false, code: 1 };
|
|
86
86
|
}
|
|
87
87
|
default:
|
|
88
88
|
// Defensive: the tagged union is exhaustive above, so this is
|
|
89
|
-
// unreachable
|
|
89
|
+
// unreachable. It stays so a future status cannot fall through to no
|
|
90
|
+
// return.
|
|
90
91
|
return { ok: true };
|
|
91
92
|
}
|
|
92
93
|
}
|
package/src/commands/sync.js
CHANGED
|
@@ -10,17 +10,17 @@ import { resolveWikiRoot } from "../util/wiki-dir.js";
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Commit all wiki changes and push them through the secret-gated push path.
|
|
13
|
-
* A detected secret or unavailable scanner fails the command closed
|
|
14
|
-
* the cause on stderr
|
|
15
|
-
* reports nothing to push. The post-push tier-1 integrity detections surface
|
|
16
|
-
* the output
|
|
13
|
+
* A detected secret or unavailable scanner fails the command closed. It names
|
|
14
|
+
* the cause on stderr and never tries the push. A clean write pushes, or it
|
|
15
|
+
* reports nothing to push. The post-push tier-1 integrity detections surface
|
|
16
|
+
* in the output. They never gate the push.
|
|
17
17
|
*/
|
|
18
18
|
export async function runPushCommand(ctx) {
|
|
19
19
|
const { runtime, wikiSync } = ctx.deps;
|
|
20
20
|
await wikiSync.inheritIdentity();
|
|
21
21
|
|
|
22
|
-
// A caller that knows its narrower write-set passes `--paths` (repeatable)
|
|
23
|
-
//
|
|
22
|
+
// A caller that knows its narrower write-set passes `--paths` (repeatable).
|
|
23
|
+
// The bare session-close invocation passes none. It lands the session's own
|
|
24
24
|
// dirty set under per-session checkout isolation.
|
|
25
25
|
const paths = ctx.options?.paths?.length ? ctx.options.paths : undefined;
|
|
26
26
|
|
|
@@ -28,17 +28,19 @@ export async function runPushCommand(ctx) {
|
|
|
28
28
|
try {
|
|
29
29
|
result = await wikiSync.commitAndPush("wiki: update from session", paths);
|
|
30
30
|
} catch (err) {
|
|
31
|
-
// Honest CLI contract (the honest-CLI contract): non-zero on any non-land
|
|
32
|
-
// failure, and on the ancestry guard's refusal (the ancestry guard).
|
|
33
|
-
// recipe maps this to a stop-blocking exit
|
|
31
|
+
// Honest CLI contract (the honest-CLI contract): non-zero on any non-land
|
|
32
|
+
// push failure, and on the ancestry guard's refusal (the ancestry guard).
|
|
33
|
+
// The Stop-hook recipe maps this to a stop-blocking exit. CI steps see a
|
|
34
|
+
// loud failure.
|
|
34
35
|
if (err instanceof WikiPushFailure || err instanceof AncestryRefusal) {
|
|
35
36
|
runtime.proc.stderr.write(`${err.message}\n`);
|
|
36
37
|
return { ok: false, code: 1 };
|
|
37
38
|
}
|
|
38
39
|
throw err;
|
|
39
40
|
}
|
|
40
|
-
// Fail the command closed on a secret-gate refusal (secret detected or
|
|
41
|
-
// unavailable)
|
|
41
|
+
// Fail the command closed on a secret-gate refusal (secret detected or
|
|
42
|
+
// scanner unavailable). A clean push falls through to the normal report
|
|
43
|
+
// below.
|
|
42
44
|
const refusal = refusalEnvelope(runtime, result);
|
|
43
45
|
if (refusal) return refusal;
|
|
44
46
|
if (result.landed) {
|
|
@@ -52,7 +54,13 @@ export async function runPushCommand(ctx) {
|
|
|
52
54
|
return { ok: true };
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
/**
|
|
57
|
+
/**
|
|
58
|
+
* Fetch and rebase the local wiki on origin/master. On a rebase conflict,
|
|
59
|
+
* return a non-zero envelope. Its message tells the caller to resolve the
|
|
60
|
+
* conflict by hand or to push first. After a clean pull, the tier-2
|
|
61
|
+
* lane-record sweep surfaces any previous-session content absent at the
|
|
62
|
+
* fetched tip. The sweep never gates the boot.
|
|
63
|
+
*/
|
|
56
64
|
export async function runPullCommand(ctx) {
|
|
57
65
|
const { runtime, wikiSync, gitClient } = ctx.deps;
|
|
58
66
|
await wikiSync.inheritIdentity();
|
|
@@ -64,19 +72,20 @@ export async function runPullCommand(ctx) {
|
|
|
64
72
|
if (err instanceof WikiPullConflict) {
|
|
65
73
|
createLogger("wiki", runtime).error(
|
|
66
74
|
"pull",
|
|
67
|
-
"rebase conflict
|
|
75
|
+
"rebase conflict from local divergence. Resolve it by hand or push first",
|
|
68
76
|
);
|
|
69
77
|
return { ok: false, code: 1 };
|
|
70
78
|
}
|
|
71
79
|
throw err;
|
|
72
80
|
}
|
|
73
81
|
|
|
74
|
-
// Tier-2 sweep on the just-rebased tree.
|
|
75
|
-
// to no detections
|
|
82
|
+
// Tier-2 sweep on the just-rebased tree. This detects only. Any failure
|
|
83
|
+
// degrades to no detections. It never throws into the flow. It never changes
|
|
84
|
+
// the exit code.
|
|
76
85
|
try {
|
|
77
86
|
const wikiDir = resolveWikiRoot(runtime, ctx.options);
|
|
78
|
-
// `--today` (ISO date) overrides the wall clock for deterministic tests
|
|
79
|
-
//
|
|
87
|
+
// `--today` (ISO date) overrides the wall clock for deterministic tests.
|
|
88
|
+
// A malformed value falls back to the runtime clock instead of NaN.
|
|
80
89
|
const today = ctx.options.today
|
|
81
90
|
? Date.parse(ctx.options.today)
|
|
82
91
|
: Number.NaN;
|
package/src/conflict-markers.js
CHANGED
|
@@ -1,36 +1,36 @@
|
|
|
1
|
-
// Structural detector for unresolved git conflict markers
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Structural detector for unresolved git conflict markers. The wiki audit's
|
|
2
|
+
// `conflict.markers` rule (audit/conflict-markers-rule.js) and the WikiSync
|
|
3
|
+
// pre-push guard (wiki-sync.js) both use it. One home so the two layers cannot
|
|
4
4
|
// drift on what counts as a marker.
|
|
5
5
|
//
|
|
6
|
-
// Detection is line-anchored and structural
|
|
7
|
-
// fire on markers legitimately quoted in prose:
|
|
6
|
+
// Detection is line-anchored and structural. It is not a naive grep, so it
|
|
7
|
+
// does not fire on markers legitimately quoted in prose:
|
|
8
8
|
//
|
|
9
9
|
// - Open (`<<<<<<<`) and close (`>>>>>>>`) marker lines fire UNCONDITIONALLY,
|
|
10
10
|
// per file. A seal rotation can sever one conflict block across two sealed
|
|
11
|
-
// files (the open in one, the separator + close in the other)
|
|
12
|
-
// complete
|
|
11
|
+
// files (the open in one, the separator + close in the other). A matcher for
|
|
12
|
+
// a complete in-file block would miss both, so each marker form stands
|
|
13
13
|
// alone. The single-space-or-end-of-line guard after the 7-char run admits
|
|
14
14
|
// the stash-pop label forms (`<<<<<<< Updated upstream`,
|
|
15
|
-
// `>>>>>>> Stashed changes`) and the branch/sha label forms
|
|
15
|
+
// `>>>>>>> Stashed changes`) and the branch/sha label forms. It rejects
|
|
16
16
|
// longer `<`/`>` runs.
|
|
17
17
|
// - The separator (`=======`) fires ONLY while a conflict block is open in the
|
|
18
18
|
// same file (block-conditioned). A lone separator with no open above it is
|
|
19
|
-
// indistinguishable from a setext-heading underline
|
|
19
|
+
// indistinguishable from a setext-heading underline. It is a deliberate
|
|
20
20
|
// accepted non-detection.
|
|
21
|
-
// - In a `fenceExempt` (prose) surface,
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// `^` never matches. STATUS.md and non-markdown push targets
|
|
25
|
-
// `fenceExempt:false
|
|
26
|
-
// legitimate, so fence state never suppresses.
|
|
21
|
+
// - In a `fenceExempt` (prose) surface, a fenced code block suppresses the
|
|
22
|
+
// occurrences inside it, because a fence quotes content. The column-1 anchor
|
|
23
|
+
// itself handles a marker quoted in a backtick code SPAN. A span sits
|
|
24
|
+
// mid-line, so `^` never matches. STATUS.md and non-markdown push targets
|
|
25
|
+
// pass `fenceExempt:false`. Their fenced rows are data, where a marker is
|
|
26
|
+
// never legitimate, so fence state never suppresses.
|
|
27
27
|
|
|
28
28
|
const OPEN_RE = /^<{7}( |$)/;
|
|
29
29
|
const CLOSE_RE = /^>{7}( |$)/;
|
|
30
30
|
const SEPARATOR_RE = /^={7}\s*$/;
|
|
31
31
|
// A fenced-code delimiter: three or more backticks or tildes, up to three
|
|
32
|
-
// leading spaces of indentation (CommonMark). The
|
|
33
|
-
//
|
|
32
|
+
// leading spaces of indentation (CommonMark). The scanner ignores the info
|
|
33
|
+
// string after the run. A delimiter line is never itself a marker.
|
|
34
34
|
const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
35
35
|
|
|
36
36
|
/**
|
|
@@ -50,9 +50,9 @@ export function scanConflictMarkers(text, { fenceExempt = true } = {}) {
|
|
|
50
50
|
let insideFence = false;
|
|
51
51
|
let openDepth = 0;
|
|
52
52
|
for (let i = 0; i < lines.length; i++) {
|
|
53
|
-
// Strip a trailing CR so a CRLF checkout
|
|
54
|
-
// bare marker (`<<<<<<<\r`) would otherwise escape the
|
|
55
|
-
// a CRLF corruption block would publish undetected.
|
|
53
|
+
// Strip a trailing CR so this matches a CRLF checkout and an LF checkout
|
|
54
|
+
// the same way. A bare marker (`<<<<<<<\r`) would otherwise escape the
|
|
55
|
+
// `( |$)` anchor, and a CRLF corruption block would publish undetected.
|
|
56
56
|
const line = lines[i].replace(/\r$/, "");
|
|
57
57
|
if (FENCE_RE.test(line)) {
|
|
58
58
|
insideFence = !insideFence;
|
|
@@ -69,7 +69,7 @@ export function scanConflictMarkers(text, { fenceExempt = true } = {}) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
// Classify a single line as a marker kind, or null. The separator is
|
|
72
|
-
// block-conditioned
|
|
72
|
+
// block-conditioned. It counts only while a conflict block is open.
|
|
73
73
|
function classify(line, openDepth) {
|
|
74
74
|
if (OPEN_RE.test(line)) return "open";
|
|
75
75
|
if (CLOSE_RE.test(line)) return "close";
|
package/src/constants.js
CHANGED
|
@@ -4,18 +4,19 @@ export const BROADCAST_TARGET = "all";
|
|
|
4
4
|
|
|
5
5
|
export const MEMORY_FILE = "MEMORY.md";
|
|
6
6
|
|
|
7
|
-
// Row-structured singleton surfaces under the sync-merge discipline
|
|
7
|
+
// Row-structured singleton surfaces under the sync-merge discipline. When a
|
|
8
8
|
// landing on one of these is contended, the resolution re-runs the row
|
|
9
|
-
// operation against the fresh remote tip
|
|
10
|
-
// lines
|
|
11
|
-
// table). STATUS.md phase rows join as their own
|
|
12
|
-
// Metrics CSV appends take the complementary
|
|
9
|
+
// operation against the fresh remote tip. It rebases the operation. It does
|
|
10
|
+
// not rebase the lines, and it never does a textual merge. Founding member:
|
|
11
|
+
// MEMORY.md (the Active Claims table). STATUS.md phase rows join as their own
|
|
12
|
+
// re-apply operations land. Metrics CSV appends take the complementary
|
|
13
|
+
// union-merge path below.
|
|
13
14
|
export const SINGLETON_PATHS = new Set([MEMORY_FILE]);
|
|
14
15
|
|
|
15
16
|
// The tracked `.gitattributes` declaration that makes concurrent appends to
|
|
16
|
-
// metrics CSVs union-merge (keep both sides' rows) on every publish path
|
|
17
|
-
//
|
|
18
|
-
// it governs every clone.
|
|
17
|
+
// metrics CSVs union-merge (keep both sides' rows) on every publish path. The
|
|
18
|
+
// appends then never conflict, and nothing picks one side. The wiki repo
|
|
19
|
+
// carries the declaration itself, so it governs every clone.
|
|
19
20
|
export const GITATTRIBUTES_FILE = ".gitattributes";
|
|
20
21
|
export const METRICS_CSV_MERGE_ATTRIBUTE = "metrics/**/*.csv merge=union";
|
|
21
22
|
export const ACTIVE_CLAIMS_HEADING = "## Active Claims";
|
|
@@ -25,9 +26,9 @@ export const ACTIVE_CLAIMS_TABLE_SEPARATOR =
|
|
|
25
26
|
"| --- | --- | --- | --- | --- | --- |";
|
|
26
27
|
|
|
27
28
|
// Match a rendered pipe-table row (header or separator) line-anchored and
|
|
28
|
-
// whitespace-tolerant between cells.
|
|
29
|
-
// literal
|
|
30
|
-
// (audit/rules.js)
|
|
29
|
+
// whitespace-tolerant between cells. The matcher comes from the rendered
|
|
30
|
+
// literal, so the claims parser (active-claims.js) and the audit
|
|
31
|
+
// (audit/rules.js) cannot drift on the column set. One literal, one matcher.
|
|
31
32
|
function pipeRowRe(literal, flags) {
|
|
32
33
|
const cells = literal
|
|
33
34
|
.split("|")
|
|
@@ -51,8 +52,8 @@ export const DECISION_HEADING = "### Decision";
|
|
|
51
52
|
// Unified budgets for the audited surfaces (summary, weekly-log, storyboard,
|
|
52
53
|
// memory). They keep per-surface rule pairs so the limits can diverge as the
|
|
53
54
|
// context-tax model says one surface should be looser or tighter. MEMORY.md is
|
|
54
|
-
// the tightest
|
|
55
|
-
//
|
|
55
|
+
// the tightest. Every boot reads it, and it holds settled cross-cutting state
|
|
56
|
+
// and no history. So its budget keeps the on-boot read cheap.
|
|
56
57
|
export const SUMMARY_LINE_BUDGET = 496;
|
|
57
58
|
export const SUMMARY_WORD_BUDGET = 2048;
|
|
58
59
|
export const WEEKLY_LOG_LINE_BUDGET = 496;
|
|
@@ -70,25 +71,27 @@ export const WEEKLY_LOG_NAME_RE = /^([a-z][a-z-]*)-(\d{4})-W(\d{2})\.md$/;
|
|
|
70
71
|
export const WEEKLY_LOG_PART_NAME_RE =
|
|
71
72
|
/^([a-z][a-z-]*)-(\d{4})-W(\d{2})-part\d+\.md$/;
|
|
72
73
|
|
|
73
|
-
// Day-section seam: `## YYYY-MM-DD` at line start
|
|
74
|
-
// (e.g. `## 2026-05-19 (third activation)`). One home so the
|
|
75
|
-
// seam-finder (weekly-log.js), the `log` command's last-entry probe
|
|
76
|
-
// (commands/log.js), and the audit's heading-grammar-drift rule
|
|
77
|
-
// cannot disagree on what a conforming entry heading is.
|
|
78
|
-
//
|
|
74
|
+
// Day-section seam: `## YYYY-MM-DD` at line start. The matcher tolerates a
|
|
75
|
+
// trailing suffix (e.g. `## 2026-05-19 (third activation)`). One home so the
|
|
76
|
+
// rotation seam-finder (weekly-log.js), the `log` command's last-entry probe
|
|
77
|
+
// (commands/log.js), and the audit's heading-grammar-drift rule
|
|
78
|
+
// (audit/rules.js) cannot disagree on what a conforming entry heading is. The
|
|
79
|
+
// source has no flags. Call sites add `g`/`m` as needed with
|
|
80
|
+
// `new RegExp(WEEKLY_LOG_SEAM_RE.source, …)`.
|
|
79
81
|
export const WEEKLY_LOG_SEAM_RE = /^## (\d{4}-\d{2}-\d{2})/;
|
|
80
82
|
|
|
81
|
-
//
|
|
82
|
-
// than this delimit sessions in the wiki history. 30 minutes.
|
|
83
|
+
// Idle-gap for the tier-2 integrity sweep. Lane-authored commits with a gap
|
|
84
|
+
// larger than this value delimit sessions in the wiki history. 30 minutes.
|
|
83
85
|
export const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
84
86
|
|
|
85
87
|
// Carry-surface filename and H1 convention: `<agent>-carries.md` with an H1
|
|
86
|
-
// `# <agent> — Carries`. The name capture group is the agent prefix
|
|
87
|
-
//
|
|
88
|
-
// with the `**Carry-clearance:**` marker
|
|
89
|
-
// `wiki/release-engineer.md § Message Inbox`,
|
|
90
|
-
// migration relocates
|
|
91
|
-
// (audit/scopes.js) and rules (audit/rules.js) cannot drift
|
|
88
|
+
// `# <agent> — Carries`. The name capture group is the agent prefix, which the
|
|
89
|
+
// H1↔filename agreement rule uses. A Carry entry names its clearance trigger
|
|
90
|
+
// with the `**Carry-clearance:**` marker. That is the existing live convention
|
|
91
|
+
// in `wiki/release-engineer.md § Message Inbox`, kept verbatim so the
|
|
92
|
+
// migration relocates the entry and does not re-mark it. One home so the
|
|
93
|
+
// audit's classifier (audit/scopes.js) and rules (audit/rules.js) cannot drift
|
|
94
|
+
// on the syntax.
|
|
92
95
|
export const CARRY_SURFACE_NAME_RE = /^(.+)-carries\.md$/;
|
|
93
96
|
export const CARRY_SURFACE_H1_RE = /^# (.+) — Carries$/;
|
|
94
97
|
export const CARRY_CLEARANCE_MARKER_RE = /\*\*Carry-clearance:\*\*/;
|
|
@@ -98,8 +101,9 @@ export const CARRY_CLEARANCE_MARKER_RE = /\*\*Carry-clearance:\*\*/;
|
|
|
98
101
|
// refresh." notice). One home so the marker scanner (marker-scanner.js) and the
|
|
99
102
|
// audit's balance check (audit/rules.js) cannot drift on the syntax.
|
|
100
103
|
// Capture groups: 1 metric, 2 csvPath, 3 optional prior-read anchor date. The
|
|
101
|
-
// `prior=YYYY-MM-DD` token sits before the trailing-text group so the
|
|
102
|
-
// edit" notice
|
|
104
|
+
// `prior=YYYY-MM-DD` token sits before the trailing-text group, so the scanner
|
|
105
|
+
// still tolerates the "Do not edit" notice and the notice does not swallow the
|
|
106
|
+
// anchor.
|
|
103
107
|
export const XMR_OPEN_RE =
|
|
104
108
|
/^<!--\s*xmr:([^:\s]+):(\S+)(?:\s+prior=(\d{4}-\d{2}-\d{2}))?(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
105
109
|
export const XMR_CLOSE_RE = /^<!--\s*\/xmr(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
@@ -108,9 +112,9 @@ export const ISSUE_OPEN_RE =
|
|
|
108
112
|
export const ISSUE_CLOSE_RE =
|
|
109
113
|
/^<!--\s*\/(obstacles|experiments)(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
110
114
|
|
|
111
|
-
// Materialized per-agent experiments surface.
|
|
112
|
-
//
|
|
113
|
-
// last-successful-sync stamp, and
|
|
115
|
+
// Materialized per-agent experiments surface. This is a distinct marker kind
|
|
116
|
+
// from `experiments:open`. It carries attributed, sanitized items plus a
|
|
117
|
+
// last-successful-sync stamp, and `gemba-wiki boot` reads it offline. One home
|
|
114
118
|
// so the scanner (marker-scanner.js), the refresh renderer (commands/refresh.js),
|
|
115
119
|
// the boot parser (boot.js), and the audit balance check (audit/rules.js) cannot
|
|
116
120
|
// drift on the syntax.
|
|
@@ -121,7 +125,7 @@ export const AGENT_EXPERIMENTS_CLOSE_RE =
|
|
|
121
125
|
export const LAST_SYNC_RE =
|
|
122
126
|
/^<!--\s*last-successful-sync:\s*(\d{4}-\d{2}-\d{2})\s*-->\s*$/;
|
|
123
127
|
// Attributed item line: `- #<n> [<agent>] <title> (by <author>)`. The author
|
|
124
|
-
// suffix is mandatory and anchored at end
|
|
128
|
+
// suffix is mandatory and anchored at end. The title group is greedy, which is
|
|
125
129
|
// unambiguous because sanitizeTitle defuses any embedded ` (by ` token.
|
|
126
130
|
export const AGENT_EXPERIMENT_ITEM_RE =
|
|
127
131
|
/^- #(\d+) \[([a-z][a-z-]*)\] (.*) \(by (.+)\)$/;
|
package/src/gitattributes.js
CHANGED
|
@@ -5,18 +5,19 @@ import {
|
|
|
5
5
|
} from "./constants.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
8
|
+
* Make sure the wiki's tracked `.gitattributes` declares the metrics-CSV union
|
|
9
9
|
* merge attribute (`metrics/**\/*.csv merge=union`), idempotently.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* appears in the file, the
|
|
13
|
-
*
|
|
14
|
-
* `.gitattributes` content
|
|
15
|
-
* `{ changed: true }
|
|
11
|
+
* A present and correct line means no write. If the exact attribute line
|
|
12
|
+
* already appears in the file, the function leaves the file byte-unchanged and
|
|
13
|
+
* returns `{ changed: false }`. Otherwise it appends the line and keeps any
|
|
14
|
+
* existing `.gitattributes` content, or it creates the file with only that
|
|
15
|
+
* line. It then returns `{ changed: true }`.
|
|
16
16
|
*
|
|
17
17
|
* The single union declaration governs every clone because it is a tracked
|
|
18
|
-
* worktree file
|
|
19
|
-
* would not propagate to the sibling sessions that
|
|
18
|
+
* worktree file. Per-clone config (`core.attributesFile`,
|
|
19
|
+
* `.git/info/attributes`) would not propagate to the sibling sessions that
|
|
20
|
+
* cause the loss.
|
|
20
21
|
*
|
|
21
22
|
* @param {string} wikiDir - The wiki clone directory.
|
|
22
23
|
* @param {import('node:fs')} fsSync - Synchronous filesystem surface (`runtime.fsSync`).
|
|
@@ -30,7 +31,7 @@ export function ensureMetricsCsvMergeAttribute(wikiDir, fsSync) {
|
|
|
30
31
|
.split("\n")
|
|
31
32
|
.some((line) => line.trim() === METRICS_CSV_MERGE_ATTRIBUTE);
|
|
32
33
|
if (present) return { changed: false };
|
|
33
|
-
// Append the line
|
|
34
|
+
// Append the line. Keep existing content and end with a newline.
|
|
34
35
|
const base = text.endsWith("\n") || text === "" ? text : `${text}\n`;
|
|
35
36
|
fsSync.writeFileSync(filePath, `${base}${METRICS_CSV_MERGE_ATTRIBUTE}\n`);
|
|
36
37
|
return { changed: true };
|
package/src/integrity.js
CHANGED
|
@@ -3,8 +3,8 @@ import { SESSION_GAP_MS } from "./constants.js";
|
|
|
3
3
|
import { isLaneFile, enumerateLaneFiles } from "./lane-files.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Normalize a content line for content-keyed presence
|
|
7
|
-
* trailing whitespace.
|
|
6
|
+
* Normalize a content line for content-keyed presence. Strip a trailing CR and
|
|
7
|
+
* trailing whitespace. A blank line normalizes to "". Callers drop it.
|
|
8
8
|
* @param {string} line
|
|
9
9
|
* @returns {string}
|
|
10
10
|
*/
|
|
@@ -13,10 +13,10 @@ export function normLine(line) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Parse `git diff --unified=0` text into per-file change records
|
|
16
|
+
* Parse `git diff --unified=0` text into per-file change records. Attribute
|
|
17
17
|
* each `+`/`-` line to the file named by the most recent `+++ b/<path>` header.
|
|
18
|
-
* The `+++`/`---`/`@@` framing lines are not content.
|
|
19
|
-
* (pure deletions)
|
|
18
|
+
* The `+++`/`---`/`@@` framing lines are not content. The parser keeps
|
|
19
|
+
* `/dev/null` targets (pure deletions) as the home for their removed lines.
|
|
20
20
|
* @param {string} diffText
|
|
21
21
|
* @returns {Array<{home: string, added: string[], removed: string[]}>}
|
|
22
22
|
*/
|
|
@@ -38,7 +38,7 @@ export function parseDiff(diffText) {
|
|
|
38
38
|
return [...byHome.values()];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/** Whether a diff line is framing (`---`, `@@`, `diff`, `index`)
|
|
41
|
+
/** Whether a diff line is framing (`---`, `@@`, `diff`, `index`) and carries no content. */
|
|
42
42
|
function isDiffFraming(raw) {
|
|
43
43
|
return (
|
|
44
44
|
raw.startsWith("--- ") ||
|
|
@@ -48,7 +48,7 @@ function isDiffFraming(raw) {
|
|
|
48
48
|
);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/** Strip a diff target's `a/`/`b/` prefix
|
|
51
|
+
/** Strip a diff target's `a/`/`b/` prefix. `/dev/null` stays as-is. */
|
|
52
52
|
function stripDiffTarget(target) {
|
|
53
53
|
const t = target.trim();
|
|
54
54
|
if (t === "/dev/null") return t;
|
|
@@ -57,10 +57,11 @@ function stripDiffTarget(target) {
|
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
59
|
* Compose a window of change records (oldest→newest) into the surviving
|
|
60
|
-
* addition assertions
|
|
61
|
-
* `norm(line)
|
|
62
|
-
* assertion (additions-only, own-deletions
|
|
63
|
-
* present iff its normalized line appears
|
|
60
|
+
* addition assertions. Then return those absent from the tip. The composition
|
|
61
|
+
* is content-keyed on `norm(line)`. A later own-lane deletion of an
|
|
62
|
+
* earlier-added line cancels its assertion (additions-only, own-deletions
|
|
63
|
+
* cancel). A surviving assertion is present iff its normalized line appears
|
|
64
|
+
* anywhere in `tipText`.
|
|
64
65
|
*
|
|
65
66
|
* @param {Array<{home: string, added: string[], removed: string[]}>} changes
|
|
66
67
|
* Window changes, oldest→newest.
|
|
@@ -78,7 +79,7 @@ export function findAbsent(changes, tipText, norm) {
|
|
|
78
79
|
return absent;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
/** Compose window changes into surviving additions
|
|
82
|
+
/** Compose window changes into surviving additions. A later removal cancels its key. */
|
|
82
83
|
function composeAssertions(changes, norm) {
|
|
83
84
|
const asserted = new Map(); // norm(line) -> { contentId, pushHome }
|
|
84
85
|
for (const change of changes) {
|
|
@@ -108,10 +109,10 @@ function normalizedKeySet(text, norm) {
|
|
|
108
109
|
|
|
109
110
|
/**
|
|
110
111
|
* Resolve the previous-session push set from lane-authored commits (newest
|
|
111
|
-
* first) by idle-gap. Tier 2 runs at boot before the current session
|
|
112
|
-
*
|
|
113
|
-
* only for empty history
|
|
114
|
-
*
|
|
112
|
+
* first) by idle-gap. Tier 2 runs at boot before the current session pushes,
|
|
113
|
+
* so the most recent contiguous run is the previous session. The result is
|
|
114
|
+
* vacuous only for empty history. {@link sweepTier2} raises the degenerate
|
|
115
|
+
* (content-unresolvable) case. This function does not raise it.
|
|
115
116
|
*
|
|
116
117
|
* @param {Array<{sha: string, when: number}>} commits - Newest first.
|
|
117
118
|
* @param {number} gapMs - Idle-gap threshold (ms).
|
|
@@ -121,7 +122,7 @@ export function previousSessionWindow(commits, gapMs) {
|
|
|
121
122
|
if (commits.length === 0) return { kind: "vacuous" };
|
|
122
123
|
const tipRun = [commits[0]];
|
|
123
124
|
for (let i = 1; i < commits.length; i++) {
|
|
124
|
-
// commits are newest-first
|
|
125
|
+
// commits are newest-first. `when` is seconds. The gap threshold is ms.
|
|
125
126
|
const gap = (commits[i - 1].when - commits[i].when) * 1000;
|
|
126
127
|
if (gap > gapMs) break;
|
|
127
128
|
tipRun.push(commits[i]);
|
|
@@ -130,8 +131,8 @@ export function previousSessionWindow(commits, gapMs) {
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
/**
|
|
133
|
-
* Build a detection record. `detectedAt` is the binding wall-clock stamp
|
|
134
|
-
*
|
|
134
|
+
* Build a detection record. `detectedAt` is the binding wall-clock stamp
|
|
135
|
+
* (ISO). An exposure figure derived from commit timestamps carries the labeled
|
|
135
136
|
* `commit-timestamp` fallback basis.
|
|
136
137
|
*
|
|
137
138
|
* @param {object} d
|
|
@@ -166,8 +167,9 @@ export function makeDetection({
|
|
|
166
167
|
|
|
167
168
|
/**
|
|
168
169
|
* Render detections to flow output text. Empty input renders the empty string
|
|
169
|
-
* (clean-path silence).
|
|
170
|
-
* content identity
|
|
170
|
+
* (clean-path silence). Each detection gets one line that names the tier, the
|
|
171
|
+
* push-time home, and the content identity. The line labels exposure with its
|
|
172
|
+
* fallback basis when exposure is present.
|
|
171
173
|
* @param {object[]} detections
|
|
172
174
|
* @returns {string}
|
|
173
175
|
*/
|
|
@@ -186,10 +188,10 @@ export function renderDetections(detections) {
|
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
/**
|
|
189
|
-
* Tier-2 boot sweep
|
|
190
|
-
* content-present at the fetched (rebased) origin tip
|
|
191
|
+
* Tier-2 boot sweep. Verify the lane's previous-session push set is still
|
|
192
|
+
* content-present at the fetched (rebased) origin tip. Return detections for
|
|
191
193
|
* any absence. Reads git and lane files from `wikiDir` (the rebased tree).
|
|
192
|
-
* Never writes
|
|
194
|
+
* Never writes. Never throws into the flow (the caller wraps it).
|
|
193
195
|
*
|
|
194
196
|
* @param {object} ctx
|
|
195
197
|
* @param {import('@forwardimpact/libutil/runtime').Runtime} ctx.runtime
|
|
@@ -202,7 +204,7 @@ export function renderDetections(detections) {
|
|
|
202
204
|
export async function sweepTier2({ runtime, gitClient, wikiDir, agent, now }) {
|
|
203
205
|
const email = await gitClient.configGet("user.email", { cwd: wikiDir });
|
|
204
206
|
if (!email) {
|
|
205
|
-
// Lane identity unresolvable
|
|
207
|
+
// Lane identity unresolvable. Never a silent vacuous pass.
|
|
206
208
|
return [
|
|
207
209
|
makeDetection({
|
|
208
210
|
tier: 2,
|
|
@@ -221,7 +223,7 @@ export async function sweepTier2({ runtime, gitClient, wikiDir, agent, now }) {
|
|
|
221
223
|
|
|
222
224
|
const detections = [];
|
|
223
225
|
const changes = [];
|
|
224
|
-
// Oldest→newest so own-
|
|
226
|
+
// Oldest→newest so own-deletions cancel in commit order.
|
|
225
227
|
const ordered = [...window.commits].reverse();
|
|
226
228
|
for (const commit of ordered) {
|
|
227
229
|
const diff = await gitClient.diffRange(`${commit.sha}~1 ${commit.sha}`, {
|
|
@@ -250,7 +252,7 @@ export async function sweepTier2({ runtime, gitClient, wikiDir, agent, now }) {
|
|
|
250
252
|
|
|
251
253
|
for (const absent of findAbsent(changes, tipText, normLine)) {
|
|
252
254
|
// Exposure runs from the LAST window commit that added this exact line
|
|
253
|
-
// (its most recent assertion at origin)
|
|
255
|
+
// (its most recent assertion at origin). A same-home commit does not count.
|
|
254
256
|
const when = lastAssertionTime(changes, absent.contentId);
|
|
255
257
|
const exposureSeconds =
|
|
256
258
|
when != null ? Math.round(now / 1000 - when) : undefined;
|
|
@@ -3,10 +3,11 @@ import { createLogger } from "@forwardimpact/libtelemetry";
|
|
|
3
3
|
import { sanitizeCrossingField, sanitizeTitle } from "./sanitize.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* (non-zero exit or unparseable JSON).
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* `renderAgentExperiments` throws this when the tracker query for the
|
|
7
|
+
* agent-experiments materialization fails (non-zero exit or unparseable JSON).
|
|
8
|
+
* This error differs from a `[]` return. It lets the refresh command keep the
|
|
9
|
+
* previously materialized block when the tracker is briefly unavailable. The
|
|
10
|
+
* command does not wipe the routing surface.
|
|
10
11
|
*/
|
|
11
12
|
export class TrackerQueryError extends Error {
|
|
12
13
|
/** @param {string} reason */
|
|
@@ -18,7 +19,12 @@ export class TrackerQueryError extends Error {
|
|
|
18
19
|
|
|
19
20
|
const AGENT_LABEL_RE = /^agent:([a-z][a-z-]*)$/;
|
|
20
21
|
|
|
21
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Parse `owner/repo` from a git origin URL. Tolerates http(s), ssh, and
|
|
24
|
+
* proxy-rewritten URLs (e.g. `http://host/git/owner/repo`). It strips `.git`.
|
|
25
|
+
* Then it takes the last two path segments. Returns null when it finds nothing
|
|
26
|
+
* parseable.
|
|
27
|
+
*/
|
|
22
28
|
export function parseRepoSlug(originUrl) {
|
|
23
29
|
if (!originUrl) return null;
|
|
24
30
|
const stripped = originUrl.trim().replace(/\.git$/, "");
|
|
@@ -29,11 +35,12 @@ export function parseRepoSlug(originUrl) {
|
|
|
29
35
|
|
|
30
36
|
/**
|
|
31
37
|
* Render an issue-list block for an obstacles/experiments marker. Returns
|
|
32
|
-
* markdown lines. `cwd`
|
|
33
|
-
* resolves the correct origin
|
|
34
|
-
*
|
|
35
|
-
* is the resolved GH token (e.g.
|
|
36
|
-
* through `runtime.subprocess
|
|
38
|
+
* markdown lines. Set `cwd` to the parent monorepo's project root so `gh`
|
|
39
|
+
* resolves the correct origin. `repo` is an explicit `owner/name` slug. Use it
|
|
40
|
+
* when `gh` cannot parse the origin remote (e.g. sandbox proxy URLs). `token`
|
|
41
|
+
* is the resolved GH token (e.g. through `Config.ghToken()`). The `gh` command
|
|
42
|
+
* runs through `runtime.subprocess`. Stderr warnings run through
|
|
43
|
+
* `runtime.proc`.
|
|
37
44
|
*
|
|
38
45
|
* @param {object} options
|
|
39
46
|
* @param {string} options.topic
|
|
@@ -107,12 +114,13 @@ export async function renderIssueList({
|
|
|
107
114
|
}
|
|
108
115
|
|
|
109
116
|
/**
|
|
110
|
-
* Render the attributed per-agent experiments surface. Fetches open
|
|
111
|
-
*
|
|
112
|
-
* `agent:{name}` label
|
|
113
|
-
* `- #<number> [<agent>] <title> (by <author>)`.
|
|
114
|
-
* Throws {@link TrackerQueryError} on tracker failure so the
|
|
115
|
-
* previously materialized block
|
|
117
|
+
* Render the attributed per-agent experiments surface. Fetches open issues
|
|
118
|
+
* labeled `experiment`. Keeps only the issues that also carry an
|
|
119
|
+
* `agent:{name}` label. Emits one sanitized, body-free line per issue:
|
|
120
|
+
* `- #<number> [<agent>] <title> (by <author>)`. This function never reads
|
|
121
|
+
* issue bodies. Throws {@link TrackerQueryError} on tracker failure so the
|
|
122
|
+
* caller can keep the previously materialized block. Never returns `[]` on
|
|
123
|
+
* failure.
|
|
116
124
|
*
|
|
117
125
|
* @param {object} options
|
|
118
126
|
* @param {string} options.cwd
|