@forwardimpact/libwiki 0.2.25 → 0.2.27
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/LICENSE +21 -201
- package/README.md +5 -3
- package/bin/fit-wiki.js +1 -1
- package/package.json +1 -1
- package/src/audit/admission.js +53 -0
- package/src/audit/conflict-markers-rule.js +24 -0
- package/src/audit/grammar.js +97 -0
- package/src/audit/rules.js +96 -5
- package/src/audit/scopes.js +121 -5
- package/src/audit/status-row.js +91 -6
- package/src/block-renderer.js +19 -5
- package/src/boot.js +39 -1
- package/src/cli-definition.js +17 -16
- package/src/commands/audit.js +6 -1
- package/src/commands/boot.js +7 -2
- package/src/commands/claim.js +138 -32
- package/src/commands/fix.js +13 -5
- package/src/commands/inbox.js +7 -8
- package/src/commands/init.js +6 -0
- package/src/commands/log.js +47 -25
- package/src/commands/memo.js +10 -9
- package/src/commands/refresh.js +1 -0
- package/src/commands/rotate.js +40 -19
- package/src/commands/sync.js +54 -6
- package/src/conflict-markers.js +78 -0
- package/src/constants.js +31 -1
- package/src/gitattributes.js +40 -0
- package/src/index.js +2 -1
- package/src/integrity.js +288 -0
- package/src/lane-files.js +62 -0
- package/src/marker-scanner.js +2 -0
- package/src/secret-gate.js +177 -0
- package/src/status.js +39 -12
- package/src/util/agent-flag.js +31 -0
- package/src/weekly-log.js +146 -37
- package/src/wiki-sync.js +528 -11
package/src/commands/sync.js
CHANGED
|
@@ -1,29 +1,53 @@
|
|
|
1
1
|
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
2
|
-
import {
|
|
2
|
+
import { refusalEnvelope } from "../secret-gate.js";
|
|
3
|
+
import { AncestryRefusal, WikiPullConflict } from "../wiki-sync.js";
|
|
4
|
+
import { sweepTier2, renderDetections } from "../integrity.js";
|
|
5
|
+
import { resolveWikiRoot } from "../util/wiki-dir.js";
|
|
3
6
|
|
|
4
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Commit all wiki changes and push them through the secret-gated push path.
|
|
9
|
+
* A detected secret or unavailable scanner fails the command closed and names
|
|
10
|
+
* the cause on stderr without attempting the push; a clean write pushes or
|
|
11
|
+
* reports nothing to push. The post-push tier-1 integrity detections surface in
|
|
12
|
+
* the output; they never gate the push.
|
|
13
|
+
*/
|
|
5
14
|
export async function runPushCommand(ctx) {
|
|
6
15
|
const { runtime, wikiSync } = ctx.deps;
|
|
7
16
|
await wikiSync.inheritIdentity();
|
|
8
17
|
|
|
9
|
-
|
|
18
|
+
let result;
|
|
19
|
+
try {
|
|
20
|
+
result = await wikiSync.commitAndPush("wiki: update from session");
|
|
21
|
+
} catch (err) {
|
|
22
|
+
if (err instanceof AncestryRefusal) {
|
|
23
|
+
runtime.proc.stderr.write(`${err.message}\n`);
|
|
24
|
+
return { ok: false, code: 1 };
|
|
25
|
+
}
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
// Fail the command closed on a secret-gate refusal (secret detected or scanner
|
|
29
|
+
// unavailable); a clean push falls through to the normal reporting below.
|
|
30
|
+
const refusal = refusalEnvelope(runtime, result);
|
|
31
|
+
if (refusal) return refusal;
|
|
10
32
|
if (result.pushed) {
|
|
11
33
|
runtime.proc.stdout.write("push: committed and pushed\n");
|
|
12
34
|
} else {
|
|
13
35
|
runtime.proc.stdout.write("push: nothing to push\n");
|
|
14
36
|
}
|
|
37
|
+
if (result.detections?.length) {
|
|
38
|
+
runtime.proc.stdout.write(renderDetections(result.detections));
|
|
39
|
+
}
|
|
15
40
|
return { ok: true };
|
|
16
41
|
}
|
|
17
42
|
|
|
18
|
-
/** Fetch and rebase the local wiki on origin/master; on rebase conflict, return a non-zero envelope with a message to resolve manually or push first. */
|
|
43
|
+
/** Fetch and rebase the local wiki on origin/master; on rebase conflict, return a non-zero envelope with a message to resolve manually or push first. After a clean pull, the tier-2 lane-record sweep surfaces any previous-session content absent at the fetched tip; it never gates the boot. */
|
|
19
44
|
export async function runPullCommand(ctx) {
|
|
20
|
-
const { runtime, wikiSync } = ctx.deps;
|
|
45
|
+
const { runtime, wikiSync, gitClient } = ctx.deps;
|
|
21
46
|
await wikiSync.inheritIdentity();
|
|
22
47
|
|
|
23
48
|
try {
|
|
24
49
|
await wikiSync.pull();
|
|
25
50
|
runtime.proc.stdout.write("pull: up to date\n");
|
|
26
|
-
return { ok: true };
|
|
27
51
|
} catch (err) {
|
|
28
52
|
if (err instanceof WikiPullConflict) {
|
|
29
53
|
createLogger("wiki", runtime).error(
|
|
@@ -34,4 +58,28 @@ export async function runPullCommand(ctx) {
|
|
|
34
58
|
}
|
|
35
59
|
throw err;
|
|
36
60
|
}
|
|
61
|
+
|
|
62
|
+
// Tier-2 sweep on the just-rebased tree. Detection-only: any failure degrades
|
|
63
|
+
// to no detections, never throws into the flow, never changes the exit code.
|
|
64
|
+
try {
|
|
65
|
+
const wikiDir = resolveWikiRoot(runtime, ctx.options);
|
|
66
|
+
// `--today` (ISO date) overrides the wall clock for deterministic tests;
|
|
67
|
+
// a malformed value falls back to the runtime clock rather than NaN.
|
|
68
|
+
const today = ctx.options.today
|
|
69
|
+
? Date.parse(ctx.options.today)
|
|
70
|
+
: Number.NaN;
|
|
71
|
+
const now = Number.isNaN(today) ? runtime.clock.now() : today;
|
|
72
|
+
const detections = await sweepTier2({
|
|
73
|
+
runtime,
|
|
74
|
+
gitClient,
|
|
75
|
+
wikiDir,
|
|
76
|
+
agent: ctx.options.agent,
|
|
77
|
+
now,
|
|
78
|
+
});
|
|
79
|
+
if (detections.length)
|
|
80
|
+
runtime.proc.stdout.write(renderDetections(detections));
|
|
81
|
+
} catch {
|
|
82
|
+
// Detection-only instrument: a sweep error never gates the boot flow.
|
|
83
|
+
}
|
|
84
|
+
return { ok: true };
|
|
37
85
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Structural detector for unresolved git conflict markers, shared by the wiki
|
|
2
|
+
// audit's `conflict.markers` rule (audit/conflict-markers-rule.js) and the
|
|
3
|
+
// WikiSync pre-push guard (wiki-sync.js). One home so the two layers cannot
|
|
4
|
+
// drift on what counts as a marker.
|
|
5
|
+
//
|
|
6
|
+
// Detection is line-anchored and structural, not a naive grep, so it does not
|
|
7
|
+
// fire on markers legitimately quoted in prose:
|
|
8
|
+
//
|
|
9
|
+
// - Open (`<<<<<<<`) and close (`>>>>>>>`) marker lines fire UNCONDITIONALLY,
|
|
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); a
|
|
12
|
+
// complete-in-file-block matcher would miss both, so each marker form stands
|
|
13
|
+
// alone. The single-space-or-end-of-line guard after the 7-char run admits
|
|
14
|
+
// the stash-pop label forms (`<<<<<<< Updated upstream`,
|
|
15
|
+
// `>>>>>>> Stashed changes`) and the branch/sha label forms while rejecting
|
|
16
|
+
// longer `<`/`>` runs.
|
|
17
|
+
// - The separator (`=======`) fires ONLY while a conflict block is open in the
|
|
18
|
+
// same file (block-conditioned). A lone separator with no open above it is
|
|
19
|
+
// indistinguishable from a setext-heading underline and is a deliberate
|
|
20
|
+
// accepted non-detection.
|
|
21
|
+
// - In a `fenceExempt` (prose) surface, occurrences inside a fenced code block
|
|
22
|
+
// are suppressed — a fence quotes content. Markers quoted in a backtick code
|
|
23
|
+
// SPAN are handled by the column-1 anchor itself: a span sits mid-line, so
|
|
24
|
+
// `^` never matches. STATUS.md and non-markdown push targets pass
|
|
25
|
+
// `fenceExempt:false`: their fenced rows are data, where a marker is never
|
|
26
|
+
// legitimate, so fence state never suppresses.
|
|
27
|
+
|
|
28
|
+
const OPEN_RE = /^<{7}( |$)/;
|
|
29
|
+
const CLOSE_RE = /^>{7}( |$)/;
|
|
30
|
+
const SEPARATOR_RE = /^={7}\s*$/;
|
|
31
|
+
// A fenced-code delimiter: three or more backticks or tildes, up to three
|
|
32
|
+
// leading spaces of indentation (CommonMark). The info string after the run is
|
|
33
|
+
// ignored — a delimiter line is never itself a marker.
|
|
34
|
+
const FENCE_RE = /^\s{0,3}(`{3,}|~{3,})/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Scan `text` for unresolved git conflict markers.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} text - The file (or diff-added) content to scan.
|
|
40
|
+
* @param {object} [options]
|
|
41
|
+
* @param {boolean} [options.fenceExempt=true] - When true, suppress markers
|
|
42
|
+
* inside fenced code blocks (prose surfaces). When false, fence state never
|
|
43
|
+
* suppresses (STATUS.md, non-markdown targets).
|
|
44
|
+
* @returns {Array<{lineNo: number, kind: "open"|"separator"|"close"}>}
|
|
45
|
+
* One entry per detected marker line, in document order.
|
|
46
|
+
*/
|
|
47
|
+
export function scanConflictMarkers(text, { fenceExempt = true } = {}) {
|
|
48
|
+
const lines = text.split("\n");
|
|
49
|
+
const hits = [];
|
|
50
|
+
let insideFence = false;
|
|
51
|
+
let openDepth = 0;
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
// Strip a trailing CR so a CRLF checkout is matched identically to LF — a
|
|
54
|
+
// bare marker (`<<<<<<<\r`) would otherwise escape the `( |$)` anchor and
|
|
55
|
+
// a CRLF corruption block would publish undetected.
|
|
56
|
+
const line = lines[i].replace(/\r$/, "");
|
|
57
|
+
if (FENCE_RE.test(line)) {
|
|
58
|
+
insideFence = !insideFence;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (fenceExempt && insideFence) continue;
|
|
62
|
+
const kind = classify(line, openDepth);
|
|
63
|
+
if (!kind) continue;
|
|
64
|
+
hits.push({ lineNo: i + 1, kind });
|
|
65
|
+
if (kind === "open") openDepth++;
|
|
66
|
+
else if (kind === "close" && openDepth > 0) openDepth--;
|
|
67
|
+
}
|
|
68
|
+
return hits;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Classify a single line as a marker kind, or null. The separator is
|
|
72
|
+
// block-conditioned: it only counts while a conflict block is open.
|
|
73
|
+
function classify(line, openDepth) {
|
|
74
|
+
if (OPEN_RE.test(line)) return "open";
|
|
75
|
+
if (CLOSE_RE.test(line)) return "close";
|
|
76
|
+
if (openDepth > 0 && SEPARATOR_RE.test(line)) return "separator";
|
|
77
|
+
return null;
|
|
78
|
+
}
|
package/src/constants.js
CHANGED
|
@@ -3,6 +3,21 @@ export const INBOX_HEADING = "## Message Inbox";
|
|
|
3
3
|
export const BROADCAST_TARGET = "all";
|
|
4
4
|
|
|
5
5
|
export const MEMORY_FILE = "MEMORY.md";
|
|
6
|
+
|
|
7
|
+
// Row-structured singleton surfaces under the sync-merge discipline: when a
|
|
8
|
+
// landing on one of these is contended, the resolution re-runs the row
|
|
9
|
+
// operation against the fresh remote tip (rebase the operation, not the
|
|
10
|
+
// lines), never a textual merge. Founding member: MEMORY.md (the Active Claims
|
|
11
|
+
// table). STATUS.md phase rows join as their own re-apply operations land.
|
|
12
|
+
// Metrics CSV appends take the complementary union-merge path below.
|
|
13
|
+
export const SINGLETON_PATHS = new Set([MEMORY_FILE]);
|
|
14
|
+
|
|
15
|
+
// The tracked `.gitattributes` declaration that makes concurrent appends to
|
|
16
|
+
// metrics CSVs union-merge (keep both sides' rows) on every publish path,
|
|
17
|
+
// instead of conflicting or side-picking. Carried by the wiki repo itself so
|
|
18
|
+
// it governs every clone.
|
|
19
|
+
export const GITATTRIBUTES_FILE = ".gitattributes";
|
|
20
|
+
export const METRICS_CSV_MERGE_ATTRIBUTE = "metrics/**/*.csv merge=union";
|
|
6
21
|
export const ACTIVE_CLAIMS_HEADING = "## Active Claims";
|
|
7
22
|
export const ACTIVE_CLAIMS_TABLE_HEADER =
|
|
8
23
|
"| agent | target | branch | pr | claimed_at | expires_at |";
|
|
@@ -53,12 +68,27 @@ export const WEEKLY_LOG_NAME_RE = /^([a-z][a-z-]*)-(\d{4})-W(\d{2})\.md$/;
|
|
|
53
68
|
export const WEEKLY_LOG_PART_NAME_RE =
|
|
54
69
|
/^([a-z][a-z-]*)-(\d{4})-W(\d{2})-part\d+\.md$/;
|
|
55
70
|
|
|
71
|
+
// Day-section seam: `## YYYY-MM-DD` at line start, a trailing suffix tolerated
|
|
72
|
+
// (e.g. `## 2026-05-19 (third activation)`). One home so the rotation
|
|
73
|
+
// seam-finder (weekly-log.js), the `log` command's last-entry probe
|
|
74
|
+
// (commands/log.js), and the audit's heading-grammar-drift rule (audit/rules.js)
|
|
75
|
+
// cannot disagree on what a conforming entry heading is. Source has no flags;
|
|
76
|
+
// call sites add `g`/`m` as needed via `new RegExp(WEEKLY_LOG_SEAM_RE.source, …)`.
|
|
77
|
+
export const WEEKLY_LOG_SEAM_RE = /^## (\d{4}-\d{2}-\d{2})/;
|
|
78
|
+
|
|
79
|
+
// Tier-2 integrity sweep idle-gap: lane-authored commits separated by more
|
|
80
|
+
// than this delimit sessions in the wiki history. 30 minutes.
|
|
81
|
+
export const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
82
|
+
|
|
56
83
|
// Storyboard marker syntax. An open or close marker tolerates optional trailing
|
|
57
84
|
// text after the tag (typically an inline "Do not edit. Generated from fit-wiki
|
|
58
85
|
// refresh." notice). One home so the marker scanner (marker-scanner.js) and the
|
|
59
86
|
// audit's balance check (audit/rules.js) cannot drift on the syntax.
|
|
87
|
+
// Capture groups: 1 metric, 2 csvPath, 3 optional prior-read anchor date. The
|
|
88
|
+
// `prior=YYYY-MM-DD` token sits before the trailing-text group so the "Do not
|
|
89
|
+
// edit" notice is still tolerated and does not swallow the anchor.
|
|
60
90
|
export const XMR_OPEN_RE =
|
|
61
|
-
/^<!--\s*xmr:([^:\s]+):(\S+)(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
91
|
+
/^<!--\s*xmr:([^:\s]+):(\S+)(?:\s+prior=(\d{4}-\d{2}-\d{2}))?(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
62
92
|
export const XMR_CLOSE_RE = /^<!--\s*\/xmr(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
63
93
|
export const ISSUE_OPEN_RE =
|
|
64
94
|
/^<!--\s*(obstacles|experiments):(open|closed)(?::(\d+d))?(?:\s+[^>]*?)?\s*-->\s*$/;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
GITATTRIBUTES_FILE,
|
|
4
|
+
METRICS_CSV_MERGE_ATTRIBUTE,
|
|
5
|
+
} from "./constants.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Ensure the wiki's tracked `.gitattributes` declares the metrics-CSV union
|
|
9
|
+
* merge attribute (`metrics/**\/*.csv merge=union`), idempotently.
|
|
10
|
+
*
|
|
11
|
+
* Present-and-correct means no write: if the exact attribute line already
|
|
12
|
+
* appears in the file, the file is left byte-unchanged and `{ changed: false }`
|
|
13
|
+
* is returned. Otherwise the line is appended (preserving any existing
|
|
14
|
+
* `.gitattributes` content) or the file is created with just that line, and
|
|
15
|
+
* `{ changed: true }` is returned.
|
|
16
|
+
*
|
|
17
|
+
* The single union declaration governs every clone because it is a tracked
|
|
18
|
+
* worktree file; per-clone config (`core.attributesFile`, `.git/info/attributes`)
|
|
19
|
+
* would not propagate to the sibling sessions that cause the loss.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} wikiDir - The wiki clone directory.
|
|
22
|
+
* @param {import('node:fs')} fsSync - Synchronous filesystem surface (`runtime.fsSync`).
|
|
23
|
+
* @returns {{ changed: boolean }}
|
|
24
|
+
*/
|
|
25
|
+
export function ensureMetricsCsvMergeAttribute(wikiDir, fsSync) {
|
|
26
|
+
const filePath = path.join(wikiDir, GITATTRIBUTES_FILE);
|
|
27
|
+
if (fsSync.existsSync(filePath)) {
|
|
28
|
+
const text = fsSync.readFileSync(filePath, "utf-8");
|
|
29
|
+
const present = text
|
|
30
|
+
.split("\n")
|
|
31
|
+
.some((line) => line.trim() === METRICS_CSV_MERGE_ATTRIBUTE);
|
|
32
|
+
if (present) return { changed: false };
|
|
33
|
+
// Append the line, keeping existing content and ending with a newline.
|
|
34
|
+
const base = text.endsWith("\n") || text === "" ? text : `${text}\n`;
|
|
35
|
+
fsSync.writeFileSync(filePath, `${base}${METRICS_CSV_MERGE_ATTRIBUTE}\n`);
|
|
36
|
+
return { changed: true };
|
|
37
|
+
}
|
|
38
|
+
fsSync.writeFileSync(filePath, `${METRICS_CSV_MERGE_ATTRIBUTE}\n`);
|
|
39
|
+
return { changed: true };
|
|
40
|
+
}
|
package/src/index.js
CHANGED
|
@@ -18,9 +18,10 @@ export {
|
|
|
18
18
|
SUMMARY_WORD_BUDGET,
|
|
19
19
|
} from "./constants.js";
|
|
20
20
|
export { scanMarkers } from "./marker-scanner.js";
|
|
21
|
+
export { scanConflictMarkers } from "./conflict-markers.js";
|
|
21
22
|
export { renderBlock } from "./block-renderer.js";
|
|
22
23
|
export { renderIssueList } from "./issue-list-renderer.js";
|
|
23
|
-
export { WikiSync, WikiPullConflict } from "./wiki-sync.js";
|
|
24
|
+
export { WikiSync, WikiPullConflict, WikiSyncRefusal } from "./wiki-sync.js";
|
|
24
25
|
export { listSkills } from "./skill-roster.js";
|
|
25
26
|
export {
|
|
26
27
|
parseClaims,
|
package/src/integrity.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { isoTimestamp } from "@forwardimpact/libutil";
|
|
2
|
+
import { SESSION_GAP_MS } from "./constants.js";
|
|
3
|
+
import { isLaneFile, enumerateLaneFiles } from "./lane-files.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a content line for content-keyed presence: strip a trailing CR and
|
|
7
|
+
* trailing whitespace. Blank lines normalize to "" and are dropped by callers.
|
|
8
|
+
* @param {string} line
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function normLine(line) {
|
|
12
|
+
return line.replace(/\r$/, "").replace(/\s+$/, "");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse `git diff --unified=0` text into per-file change records, attributing
|
|
17
|
+
* each `+`/`-` line to the file named by the most recent `+++ b/<path>` header.
|
|
18
|
+
* The `+++`/`---`/`@@` framing lines are not content. `/dev/null` targets
|
|
19
|
+
* (pure deletions) are kept as the home for their removed lines.
|
|
20
|
+
* @param {string} diffText
|
|
21
|
+
* @returns {Array<{home: string, added: string[], removed: string[]}>}
|
|
22
|
+
*/
|
|
23
|
+
export function parseDiff(diffText) {
|
|
24
|
+
const byHome = new Map();
|
|
25
|
+
let home = null;
|
|
26
|
+
for (const raw of diffText.split("\n")) {
|
|
27
|
+
if (raw.startsWith("+++ ")) {
|
|
28
|
+
home = stripDiffTarget(raw.slice(4));
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (home === null || isDiffFraming(raw)) continue;
|
|
32
|
+
const rec = byHome.get(home) ?? { home, added: [], removed: [] };
|
|
33
|
+
if (raw.startsWith("+")) rec.added.push(raw.slice(1));
|
|
34
|
+
else if (raw.startsWith("-")) rec.removed.push(raw.slice(1));
|
|
35
|
+
else continue;
|
|
36
|
+
byHome.set(home, rec);
|
|
37
|
+
}
|
|
38
|
+
return [...byHome.values()];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether a diff line is framing (`---`, `@@`, `diff`, `index`) rather than content. */
|
|
42
|
+
function isDiffFraming(raw) {
|
|
43
|
+
return (
|
|
44
|
+
raw.startsWith("--- ") ||
|
|
45
|
+
raw.startsWith("@@") ||
|
|
46
|
+
raw.startsWith("diff ") ||
|
|
47
|
+
raw.startsWith("index ")
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Strip a diff target's `a/`/`b/` prefix; `/dev/null` stays as-is. */
|
|
52
|
+
function stripDiffTarget(target) {
|
|
53
|
+
const t = target.trim();
|
|
54
|
+
if (t === "/dev/null") return t;
|
|
55
|
+
return t.replace(/^[ab]\//, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compose a window of change records (oldest→newest) into the surviving
|
|
60
|
+
* addition assertions, then return those absent from the tip. Content-keyed on
|
|
61
|
+
* `norm(line)`: a later own-lane deletion of an earlier-added line cancels its
|
|
62
|
+
* assertion (additions-only, own-deletions cancel). A surviving assertion is
|
|
63
|
+
* present iff its normalized line appears anywhere in `tipText`.
|
|
64
|
+
*
|
|
65
|
+
* @param {Array<{home: string, added: string[], removed: string[]}>} changes
|
|
66
|
+
* Window changes, oldest→newest.
|
|
67
|
+
* @param {string} tipText - Concatenated text of the tip's in-scope files.
|
|
68
|
+
* @param {(line: string) => string} norm - Line normalizer.
|
|
69
|
+
* @returns {Array<{contentId: string, pushHome: string}>} Absent assertions.
|
|
70
|
+
*/
|
|
71
|
+
export function findAbsent(changes, tipText, norm) {
|
|
72
|
+
const asserted = composeAssertions(changes, norm);
|
|
73
|
+
const present = normalizedKeySet(tipText, norm);
|
|
74
|
+
const absent = [];
|
|
75
|
+
for (const [key, a] of asserted) {
|
|
76
|
+
if (!present.has(key)) absent.push(a);
|
|
77
|
+
}
|
|
78
|
+
return absent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Compose window changes into surviving additions; a later removal cancels its key. */
|
|
82
|
+
function composeAssertions(changes, norm) {
|
|
83
|
+
const asserted = new Map(); // norm(line) -> { contentId, pushHome }
|
|
84
|
+
for (const change of changes) {
|
|
85
|
+
for (const line of change.added) {
|
|
86
|
+
const key = norm(line);
|
|
87
|
+
if (key !== "" && !asserted.has(key)) {
|
|
88
|
+
asserted.set(key, { contentId: key, pushHome: change.home });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const line of change.removed) {
|
|
92
|
+
const key = norm(line);
|
|
93
|
+
if (key !== "") asserted.delete(key);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return asserted;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The set of non-blank normalized lines in `text`. */
|
|
100
|
+
function normalizedKeySet(text, norm) {
|
|
101
|
+
const set = new Set();
|
|
102
|
+
for (const line of text.split("\n")) {
|
|
103
|
+
const key = norm(line);
|
|
104
|
+
if (key !== "") set.add(key);
|
|
105
|
+
}
|
|
106
|
+
return set;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 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 has
|
|
112
|
+
* pushed, so the most recent contiguous run is the previous session. Vacuous
|
|
113
|
+
* only for empty history; the degenerate (content-unresolvable) case is raised
|
|
114
|
+
* by {@link sweepTier2}, not here.
|
|
115
|
+
*
|
|
116
|
+
* @param {Array<{sha: string, when: number}>} commits - Newest first.
|
|
117
|
+
* @param {number} gapMs - Idle-gap threshold (ms).
|
|
118
|
+
* @returns {{kind: "vacuous"} | {kind: "window", commits: Array<{sha: string, when: number}>}}
|
|
119
|
+
*/
|
|
120
|
+
export function previousSessionWindow(commits, gapMs) {
|
|
121
|
+
if (commits.length === 0) return { kind: "vacuous" };
|
|
122
|
+
const tipRun = [commits[0]];
|
|
123
|
+
for (let i = 1; i < commits.length; i++) {
|
|
124
|
+
// commits are newest-first; `when` is seconds, gap threshold is ms.
|
|
125
|
+
const gap = (commits[i - 1].when - commits[i].when) * 1000;
|
|
126
|
+
if (gap > gapMs) break;
|
|
127
|
+
tipRun.push(commits[i]);
|
|
128
|
+
}
|
|
129
|
+
return { kind: "window", commits: tipRun };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build a detection record. `detectedAt` is the binding wall-clock stamp (ISO);
|
|
134
|
+
* an exposure figure derived from commit timestamps carries the labeled
|
|
135
|
+
* `commit-timestamp` fallback basis.
|
|
136
|
+
*
|
|
137
|
+
* @param {object} d
|
|
138
|
+
* @param {1|2} d.tier
|
|
139
|
+
* @param {string} d.contentId - The absent content's identity (normalized line).
|
|
140
|
+
* @param {string} d.pushHome - The content's push-time home path.
|
|
141
|
+
* @param {number|Date} d.now - Wall-clock (e.g. `runtime.clock.now()` ms).
|
|
142
|
+
* @param {number} [d.exposureSeconds] - Commit-timestamp-derived exposure.
|
|
143
|
+
* @returns {object}
|
|
144
|
+
*/
|
|
145
|
+
export function makeDetection({
|
|
146
|
+
tier,
|
|
147
|
+
contentId,
|
|
148
|
+
pushHome,
|
|
149
|
+
now,
|
|
150
|
+
exposureSeconds,
|
|
151
|
+
}) {
|
|
152
|
+
const detection = {
|
|
153
|
+
tier,
|
|
154
|
+
contentId,
|
|
155
|
+
pushHome,
|
|
156
|
+
detectedAt: isoTimestamp(now),
|
|
157
|
+
};
|
|
158
|
+
if (exposureSeconds != null) {
|
|
159
|
+
detection.exposure = {
|
|
160
|
+
seconds: exposureSeconds,
|
|
161
|
+
basis: "commit-timestamp",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return detection;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Render detections to flow output text. Empty input renders the empty string
|
|
169
|
+
* (clean-path silence). One line per detection naming tier, push-time home, and
|
|
170
|
+
* content identity; exposure (when present) is labeled with its fallback basis.
|
|
171
|
+
* @param {object[]} detections
|
|
172
|
+
* @returns {string}
|
|
173
|
+
*/
|
|
174
|
+
export function renderDetections(detections) {
|
|
175
|
+
if (detections.length === 0) return "";
|
|
176
|
+
return (
|
|
177
|
+
detections
|
|
178
|
+
.map((d) => {
|
|
179
|
+
const exposure = d.exposure
|
|
180
|
+
? ` exposure=${d.exposure.seconds}s (basis: ${d.exposure.basis})`
|
|
181
|
+
: "";
|
|
182
|
+
return `integrity[tier ${d.tier}]: absent content in ${d.pushHome} — "${d.contentId}" detected ${d.detectedAt}${exposure}`;
|
|
183
|
+
})
|
|
184
|
+
.join("\n") + "\n"
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Tier-2 boot sweep: verify the lane's previous-session push set is still
|
|
190
|
+
* content-present at the fetched (rebased) origin tip, returning detections for
|
|
191
|
+
* any absence. Reads git and lane files from `wikiDir` (the rebased tree).
|
|
192
|
+
* Never writes, never throws into the flow (the caller wraps it).
|
|
193
|
+
*
|
|
194
|
+
* @param {object} ctx
|
|
195
|
+
* @param {import('@forwardimpact/libutil/runtime').Runtime} ctx.runtime
|
|
196
|
+
* @param {import('@forwardimpact/libutil').GitClient} ctx.gitClient
|
|
197
|
+
* @param {string} ctx.wikiDir - The wiki clone dir (git cwd and fs read-root).
|
|
198
|
+
* @param {string} ctx.agent - Lane agent id.
|
|
199
|
+
* @param {number} ctx.now - Wall-clock (ms).
|
|
200
|
+
* @returns {Promise<object[]>} Detections (possibly empty).
|
|
201
|
+
*/
|
|
202
|
+
export async function sweepTier2({ runtime, gitClient, wikiDir, agent, now }) {
|
|
203
|
+
const email = await gitClient.configGet("user.email", { cwd: wikiDir });
|
|
204
|
+
if (!email) {
|
|
205
|
+
// Lane identity unresolvable — never a silent vacuous pass.
|
|
206
|
+
return [
|
|
207
|
+
makeDetection({
|
|
208
|
+
tier: 2,
|
|
209
|
+
contentId: "<unresolvable: no author identity>",
|
|
210
|
+
pushHome: "-",
|
|
211
|
+
now,
|
|
212
|
+
}),
|
|
213
|
+
];
|
|
214
|
+
}
|
|
215
|
+
const commits = await gitClient.logByAuthor(email, {
|
|
216
|
+
cwd: wikiDir,
|
|
217
|
+
ref: "HEAD",
|
|
218
|
+
});
|
|
219
|
+
const window = previousSessionWindow(commits, SESSION_GAP_MS);
|
|
220
|
+
if (window.kind === "vacuous") return [];
|
|
221
|
+
|
|
222
|
+
const detections = [];
|
|
223
|
+
const changes = [];
|
|
224
|
+
// Oldest→newest so own-deletion cancellation composes in commit order.
|
|
225
|
+
const ordered = [...window.commits].reverse();
|
|
226
|
+
for (const commit of ordered) {
|
|
227
|
+
const diff = await gitClient.diffRange(`${commit.sha}~1 ${commit.sha}`, {
|
|
228
|
+
cwd: wikiDir,
|
|
229
|
+
});
|
|
230
|
+
if (diff === null) {
|
|
231
|
+
detections.push(
|
|
232
|
+
makeDetection({
|
|
233
|
+
tier: 2,
|
|
234
|
+
contentId: `<unresolvable content: ${commit.sha}>`,
|
|
235
|
+
pushHome: "-",
|
|
236
|
+
now,
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
for (const rec of parseDiff(diff)) {
|
|
242
|
+
if (isLaneFile(rec.home, agent))
|
|
243
|
+
changes.push({ ...rec, when: commit.when });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const tipText = enumerateLaneFiles(wikiDir, agent, runtime.fsSync)
|
|
248
|
+
.map((rel) => readFileOrEmpty(runtime.fsSync, wikiDir, rel))
|
|
249
|
+
.join("\n");
|
|
250
|
+
|
|
251
|
+
for (const absent of findAbsent(changes, tipText, normLine)) {
|
|
252
|
+
// Exposure runs from the LAST window commit that added this exact line
|
|
253
|
+
// (its most recent assertion at origin), not merely a same-home commit.
|
|
254
|
+
const when = lastAssertionTime(changes, absent.contentId);
|
|
255
|
+
const exposureSeconds =
|
|
256
|
+
when != null ? Math.round(now / 1000 - when) : undefined;
|
|
257
|
+
detections.push(
|
|
258
|
+
makeDetection({
|
|
259
|
+
tier: 2,
|
|
260
|
+
contentId: absent.contentId,
|
|
261
|
+
pushHome: absent.pushHome,
|
|
262
|
+
now,
|
|
263
|
+
exposureSeconds,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return detections;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The `when` of the latest window change whose normalized additions include `contentId`. */
|
|
271
|
+
function lastAssertionTime(changes, contentId) {
|
|
272
|
+
let when;
|
|
273
|
+
for (const change of changes) {
|
|
274
|
+
if (change.added.some((line) => normLine(line) === contentId)) {
|
|
275
|
+
when = change.when;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return when;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readFileOrEmpty(fsSync, wikiDir, rel) {
|
|
282
|
+
const abs = `${wikiDir}/${rel}`;
|
|
283
|
+
try {
|
|
284
|
+
return fsSync.readFileSync(abs, "utf-8");
|
|
285
|
+
} catch {
|
|
286
|
+
return "";
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { WEEKLY_LOG_NAME_RE, WEEKLY_LOG_PART_NAME_RE } from "./constants.js";
|
|
3
|
+
|
|
4
|
+
const METRICS_CSV_RE = /^metrics\/[^/]+\/\d{4}\.csv$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether a wiki-root-relative path is one of the lane's own files: the
|
|
8
|
+
* agent's summary (`<agent>.md`), a weekly log or sealed part
|
|
9
|
+
* (`<agent>-YYYY-Www.md`, `<agent>-YYYY-Www-partN.md`, matched on the captured
|
|
10
|
+
* agent token), or a metrics CSV (`metrics/<skill>/<year>.csv`). Metrics CSVs
|
|
11
|
+
* match by path for every agent; lane ownership of a metrics CSV is enforced by
|
|
12
|
+
* the tier-2 sweep's author filter at the commit level, not here.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} relPath - Path relative to the wiki root (POSIX or native).
|
|
15
|
+
* @param {string} agent - Agent profile id (e.g. "staff-engineer").
|
|
16
|
+
* @returns {boolean}
|
|
17
|
+
*/
|
|
18
|
+
export function isLaneFile(relPath, agent) {
|
|
19
|
+
const rel = relPath.replace(/\\/g, "/");
|
|
20
|
+
const base = path.posix.basename(rel);
|
|
21
|
+
if (base === `${agent}.md`) return true;
|
|
22
|
+
for (const re of [WEEKLY_LOG_NAME_RE, WEEKLY_LOG_PART_NAME_RE]) {
|
|
23
|
+
const m = base.match(re);
|
|
24
|
+
if (m && m[1] === agent) return true;
|
|
25
|
+
}
|
|
26
|
+
return METRICS_CSV_RE.test(rel);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Enumerate the lane's own files present under `wikiRoot`: matching top-level
|
|
31
|
+
* summary and weekly-log files, plus every `metrics/<skill>/<year>.csv`.
|
|
32
|
+
* Returns wiki-root-relative POSIX paths.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} wikiRoot
|
|
35
|
+
* @param {string} agent
|
|
36
|
+
* @param {object} fsSync - Sync filesystem surface (`runtime.fsSync`).
|
|
37
|
+
* @returns {string[]} Relative paths, in directory-read order.
|
|
38
|
+
*/
|
|
39
|
+
export function enumerateLaneFiles(wikiRoot, agent, fsSync) {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const entry of fsSync.readdirSync(wikiRoot)) {
|
|
42
|
+
if (entry !== "metrics" && isLaneFile(entry, agent)) out.push(entry);
|
|
43
|
+
}
|
|
44
|
+
out.push(...enumerateMetricsCsvs(wikiRoot, agent, fsSync));
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Wiki-root-relative `metrics/<skill>/<year>.csv` paths matching the lane. */
|
|
49
|
+
function enumerateMetricsCsvs(wikiRoot, agent, fsSync) {
|
|
50
|
+
const metricsDir = path.join(wikiRoot, "metrics");
|
|
51
|
+
if (!fsSync.existsSync(metricsDir)) return [];
|
|
52
|
+
const found = [];
|
|
53
|
+
for (const skill of fsSync.readdirSync(metricsDir)) {
|
|
54
|
+
const skillDir = path.join(metricsDir, skill);
|
|
55
|
+
if (!fsSync.statSync(skillDir).isDirectory()) continue;
|
|
56
|
+
for (const file of fsSync.readdirSync(skillDir)) {
|
|
57
|
+
const rel = `metrics/${skill}/${file}`;
|
|
58
|
+
if (isLaneFile(rel, agent)) found.push(rel);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return found;
|
|
62
|
+
}
|
package/src/marker-scanner.js
CHANGED
|
@@ -20,6 +20,7 @@ function tryOpen(line, i) {
|
|
|
20
20
|
kind: "xmr",
|
|
21
21
|
metric: xmrMatch[1],
|
|
22
22
|
csvPath: xmrMatch[2],
|
|
23
|
+
priorReadAnchor: xmrMatch[3] || null,
|
|
23
24
|
openLine: i,
|
|
24
25
|
};
|
|
25
26
|
}
|
|
@@ -42,6 +43,7 @@ function closePair(open, i) {
|
|
|
42
43
|
kind: "xmr",
|
|
43
44
|
metric: open.metric,
|
|
44
45
|
csvPath: open.csvPath,
|
|
46
|
+
priorReadAnchor: open.priorReadAnchor,
|
|
45
47
|
openLine: open.openLine,
|
|
46
48
|
closeLine: i,
|
|
47
49
|
};
|