@forwardimpact/libwiki 0.2.24 → 0.2.26
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 +22 -2
- package/package.json +2 -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 +108 -3
- 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 +146 -35
- package/src/commands/fix.js +16 -6
- package/src/commands/inbox.js +15 -11
- package/src/commands/init.js +12 -5
- package/src/commands/log.js +57 -29
- package/src/commands/memo.js +14 -11
- package/src/commands/refresh.js +8 -4
- package/src/commands/rotate.js +46 -22
- package/src/commands/sync.js +58 -8
- 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/issue-list-renderer.js +7 -4
- package/src/lane-files.js +62 -0
- package/src/marker-scanner.js +2 -0
- package/src/secret-gate.js +177 -0
- package/src/util/agent-flag.js +31 -0
- package/src/util/wiki-dir.js +13 -0
- package/src/weekly-log.js +146 -37
- package/src/wiki-sync.js +528 -11
package/src/commands/claim.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { addDays } from "@forwardimpact/libutil";
|
|
3
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
3
4
|
import {
|
|
4
5
|
appendClaim,
|
|
5
6
|
removeClaim,
|
|
@@ -7,7 +8,25 @@ import {
|
|
|
7
8
|
filterExpired,
|
|
8
9
|
} from "../active-claims.js";
|
|
9
10
|
import { currentDayIso } from "../util/clock.js";
|
|
11
|
+
import { requireAgentFlag } from "../util/agent-flag.js";
|
|
10
12
|
import { resolveWikiRoot } from "../util/wiki-dir.js";
|
|
13
|
+
import { refusalEnvelope } from "../secret-gate.js";
|
|
14
|
+
import { AncestryRefusal } from "../wiki-sync.js";
|
|
15
|
+
|
|
16
|
+
/** Non-zero envelope returned when the ancestry guard refused publication. */
|
|
17
|
+
const NOT_PUBLISHED = {
|
|
18
|
+
ok: false,
|
|
19
|
+
code: 1,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Build the not-published refusal message for the given guard refusal. */
|
|
23
|
+
function notPublishedMessage(err) {
|
|
24
|
+
return (
|
|
25
|
+
`${err.message}\n` +
|
|
26
|
+
"The row was written to MEMORY.md but is NOT published — it remains an " +
|
|
27
|
+
"uncommitted working-tree change.\n"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
11
30
|
|
|
12
31
|
function readMemory(runtime, memPath) {
|
|
13
32
|
if (!runtime.fsSync.existsSync(memPath)) return "";
|
|
@@ -18,17 +37,72 @@ function memoryPath(runtime, options) {
|
|
|
18
37
|
return path.join(resolveWikiRoot(runtime, options), "MEMORY.md");
|
|
19
38
|
}
|
|
20
39
|
|
|
21
|
-
|
|
22
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Push the claim/release MEMORY.md change through the secret-gated push path
|
|
42
|
+
* and translate the result into a command envelope. A secret block fails the
|
|
43
|
+
* command closed (`{ ok: false, code: 1 }`) and names the finding or the
|
|
44
|
+
* scanner absence on stderr — distinct from a *network* push failure, which
|
|
45
|
+
* still degrades to "saved locally" and succeeds (`{ ok: true }`).
|
|
46
|
+
*
|
|
47
|
+
* The `reapply` closure re-derives this row against the fresh tip if the
|
|
48
|
+
* landing contends, so a parallel writer's row is never erased. An
|
|
49
|
+
* ancestry-guard refusal ({@link AncestryRefusal}) is rethrown so the caller's
|
|
50
|
+
* `pushRowOrRefuse` can map it to the not-published non-zero envelope; every
|
|
51
|
+
* other throw is a network/credential failure that degrades to "saved locally".
|
|
52
|
+
*
|
|
53
|
+
* @param {object} wikiSync - The WikiSync collaborator (may be absent in tests).
|
|
54
|
+
* @param {object} runtime - The runtime bag (for stdout/stderr).
|
|
55
|
+
* @param {string} message - The commit message.
|
|
56
|
+
* @param {(freshText: string) => string | null} [reapply] - Re-derives the row
|
|
57
|
+
* against the fresh tip when the landing contends.
|
|
58
|
+
* @returns {Promise<{ok: boolean, code?: number}>}
|
|
59
|
+
*/
|
|
60
|
+
async function pushWiki(wikiSync, runtime, message, reapply) {
|
|
61
|
+
if (!wikiSync) return { ok: true };
|
|
62
|
+
let result;
|
|
23
63
|
try {
|
|
24
64
|
await wikiSync.inheritIdentity();
|
|
25
65
|
// claim/release contract is a 1-line MEMORY.md change; the pathspec keeps
|
|
26
|
-
// foreign uncommitted files from parallel writers out of the commit.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
66
|
+
// foreign uncommitted files from parallel writers out of the commit. The
|
|
67
|
+
// `reapply` closure re-derives this row against the fresh tip if the landing
|
|
68
|
+
// contends, so a parallel writer's row is never erased.
|
|
69
|
+
result = await wikiSync.commitAndPush(message, ["MEMORY.md"], { reapply });
|
|
70
|
+
} catch (err) {
|
|
71
|
+
// An ancestry-guard refusal pierces the saved-locally degradation: it must
|
|
72
|
+
// reach a non-zero exit so the session stops rather than scroll past. Every
|
|
73
|
+
// other failure is a network/credential failure: preserve fire-and-forget
|
|
74
|
+
// "saved locally" — the change is on disk and the command still succeeds.
|
|
75
|
+
if (err instanceof AncestryRefusal) throw err;
|
|
76
|
+
createLogger("wiki", runtime).warn(
|
|
77
|
+
"claim",
|
|
78
|
+
`push failed (saved locally): ${err.message}`,
|
|
79
|
+
);
|
|
80
|
+
return { ok: true };
|
|
81
|
+
}
|
|
82
|
+
const refusal = refusalEnvelope(runtime, result);
|
|
83
|
+
if (refusal) return refusal;
|
|
84
|
+
if (result.pushed) runtime.proc.stdout.write("push: committed and pushed\n");
|
|
85
|
+
return { ok: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Push a written claim/release row, mapping an ancestry-guard refusal to the
|
|
90
|
+
* not-published non-zero envelope and any other outcome to `{ ok: true }`. The
|
|
91
|
+
* row is already written to MEMORY.md; on refusal it stays as an uncommitted
|
|
92
|
+
* working-tree change. The `reapply` closure re-derives the same row against
|
|
93
|
+
* the fresh tip when the landing contends.
|
|
94
|
+
*/
|
|
95
|
+
async function pushRowOrRefuse(wikiSync, runtime, message, reapply) {
|
|
96
|
+
try {
|
|
97
|
+
// Propagate pushWiki's envelope so a secret-gate refusal ({ ok: false })
|
|
98
|
+
// fails the command closed; a clean push returns { ok: true }.
|
|
99
|
+
return await pushWiki(wikiSync, runtime, message, reapply);
|
|
30
100
|
} catch (err) {
|
|
31
|
-
|
|
101
|
+
if (err instanceof AncestryRefusal) {
|
|
102
|
+
runtime.proc.stderr.write(notPublishedMessage(err));
|
|
103
|
+
return NOT_PUBLISHED;
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
32
106
|
}
|
|
33
107
|
}
|
|
34
108
|
|
|
@@ -36,14 +110,13 @@ async function pushWiki(wikiSync, runtime, message) {
|
|
|
36
110
|
export async function runClaimCommand(ctx) {
|
|
37
111
|
const { runtime, wikiSync } = ctx.deps;
|
|
38
112
|
const options = ctx.options;
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
113
|
+
const resolved = requireAgentFlag(options, {
|
|
114
|
+
command: "claim",
|
|
115
|
+
example:
|
|
116
|
+
"fit-wiki claim --agent staff-engineer --target spec-NNNN --branch claude/...",
|
|
117
|
+
});
|
|
118
|
+
if (!resolved.ok) return resolved;
|
|
119
|
+
const agent = resolved.agent;
|
|
47
120
|
if (!options.target || !options.branch) {
|
|
48
121
|
return {
|
|
49
122
|
ok: false,
|
|
@@ -55,24 +128,35 @@ export async function runClaimCommand(ctx) {
|
|
|
55
128
|
const expires = options["expires-at"] || addDays(today, 7);
|
|
56
129
|
const memPath = memoryPath(runtime, options);
|
|
57
130
|
const text = readMemory(runtime, memPath);
|
|
58
|
-
const
|
|
131
|
+
const claim = {
|
|
59
132
|
agent,
|
|
60
133
|
target: options.target,
|
|
61
134
|
branch: options.branch,
|
|
62
135
|
pr: options.pr || null,
|
|
63
136
|
claimed_at: today,
|
|
64
137
|
expires_at: expires,
|
|
65
|
-
}
|
|
138
|
+
};
|
|
139
|
+
const result = appendClaim(text, claim);
|
|
66
140
|
if (!result.inserted) {
|
|
67
|
-
runtime.
|
|
68
|
-
|
|
141
|
+
createLogger("wiki", runtime).warn(
|
|
142
|
+
"claim",
|
|
143
|
+
`claim already exists for ${agent}/${options.target}`,
|
|
69
144
|
);
|
|
70
145
|
return { ok: false, code: 2 };
|
|
71
146
|
}
|
|
72
147
|
runtime.fsSync.writeFileSync(memPath, result.text);
|
|
73
148
|
runtime.proc.stdout.write(`claimed ${options.target} (expires ${expires})\n`);
|
|
74
|
-
|
|
75
|
-
|
|
149
|
+
// Re-apply the same append against the fresh tip if the landing contends.
|
|
150
|
+
const reapply = (fresh) => {
|
|
151
|
+
const r = appendClaim(fresh, claim);
|
|
152
|
+
return r.inserted ? r.text : null;
|
|
153
|
+
};
|
|
154
|
+
return pushRowOrRefuse(
|
|
155
|
+
wikiSync,
|
|
156
|
+
runtime,
|
|
157
|
+
`wiki: claim ${options.target}`,
|
|
158
|
+
reapply,
|
|
159
|
+
);
|
|
76
160
|
}
|
|
77
161
|
|
|
78
162
|
/** Remove a claim row. `--expired` cleans every row past expires_at. */
|
|
@@ -97,18 +181,35 @@ export async function runReleaseCommand(ctx) {
|
|
|
97
181
|
}
|
|
98
182
|
runtime.fsSync.writeFileSync(memPath, current);
|
|
99
183
|
runtime.proc.stdout.write(`released ${count} expired claim(s)\n`);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
184
|
+
// Re-derive expiry against the fresh tip so a renewal landed since the stale
|
|
185
|
+
// read survives; only still-expired rows are removed.
|
|
186
|
+
const reapply = (fresh) => {
|
|
187
|
+
const freshExpired = filterExpired(parseClaims(fresh), today).expired;
|
|
188
|
+
let next = fresh;
|
|
189
|
+
let anyRemoved = false;
|
|
190
|
+
for (const c of freshExpired) {
|
|
191
|
+
const r = removeClaim(next, { agent: c.agent, target: c.target });
|
|
192
|
+
if (r.removed) {
|
|
193
|
+
next = r.text;
|
|
194
|
+
anyRemoved = true;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return anyRemoved ? next : null;
|
|
110
198
|
};
|
|
199
|
+
return pushRowOrRefuse(
|
|
200
|
+
wikiSync,
|
|
201
|
+
runtime,
|
|
202
|
+
"wiki: release expired claims",
|
|
203
|
+
reapply,
|
|
204
|
+
);
|
|
111
205
|
}
|
|
206
|
+
|
|
207
|
+
const resolved = requireAgentFlag(options, {
|
|
208
|
+
command: "release",
|
|
209
|
+
example: "fit-wiki release --agent staff-engineer --target spec-NNNN",
|
|
210
|
+
});
|
|
211
|
+
if (!resolved.ok) return resolved;
|
|
212
|
+
const agent = resolved.agent;
|
|
112
213
|
if (!options.target) {
|
|
113
214
|
return {
|
|
114
215
|
ok: false,
|
|
@@ -122,9 +223,19 @@ export async function runReleaseCommand(ctx) {
|
|
|
122
223
|
runtime.proc.stdout.write(
|
|
123
224
|
`no matching claim for ${agent}/${options.target}\n`,
|
|
124
225
|
);
|
|
125
|
-
|
|
126
|
-
runtime.proc.stdout.write(`released ${options.target}\n`);
|
|
127
|
-
await pushWiki(wikiSync, runtime, `wiki: release ${options.target}`);
|
|
226
|
+
return { ok: true };
|
|
128
227
|
}
|
|
129
|
-
|
|
228
|
+
runtime.proc.stdout.write(`released ${options.target}\n`);
|
|
229
|
+
// Re-apply the same removal against the fresh tip if the landing contends;
|
|
230
|
+
// re-removing an absent row is a no-op, so a re-release never resurrects it.
|
|
231
|
+
const reapply = (fresh) => {
|
|
232
|
+
const r = removeClaim(fresh, { agent, target: options.target });
|
|
233
|
+
return r.removed ? r.text : null;
|
|
234
|
+
};
|
|
235
|
+
return pushRowOrRefuse(
|
|
236
|
+
wikiSync,
|
|
237
|
+
runtime,
|
|
238
|
+
`wiki: release ${options.target}`,
|
|
239
|
+
reapply,
|
|
240
|
+
);
|
|
130
241
|
}
|
package/src/commands/fix.js
CHANGED
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import { currentDayIso } from "../util/clock.js";
|
|
17
17
|
import { resolveProjectRoot } from "../util/wiki-dir.js";
|
|
18
18
|
import { FAST_MODEL } from "@forwardimpact/libutil/models";
|
|
19
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
19
20
|
|
|
20
21
|
// Pipeline: audit → deterministic rotation (the one fix needing a file seal the
|
|
21
22
|
// agent can't do) → re-audit → Haiku agent on the prose-judgment residual →
|
|
@@ -37,14 +38,18 @@ function classOf(finding) {
|
|
|
37
38
|
* Handing the agent the full contract for the files it edits — not just the
|
|
38
39
|
* failing rules — stops it fixing one finding by breaking another (dropping
|
|
39
40
|
* the `**Last run**:` line, appending a section after `## Open Blockers`, …).
|
|
41
|
+
*
|
|
42
|
+
* Only static-string hints are listed: a function hint is a per-finding
|
|
43
|
+
* resolved command (a `rotate` remediation the agent never performs), not a
|
|
44
|
+
* file invariant, so listing it would leak its source text into the prompt.
|
|
40
45
|
*/
|
|
41
46
|
function invariantContract(findings) {
|
|
42
47
|
const scopes = new Set(
|
|
43
48
|
findings.map((f) => RULES.find((r) => r.id === f.id)?.scope),
|
|
44
49
|
);
|
|
45
|
-
return RULES.filter(
|
|
46
|
-
(r) =>
|
|
47
|
-
);
|
|
50
|
+
return RULES.filter(
|
|
51
|
+
(r) => scopes.has(r.scope) && typeof r.hint === "string",
|
|
52
|
+
).map((r) => `- ${r.id} — ${r.hint}`);
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
/**
|
|
@@ -105,7 +110,7 @@ function sealMainLog(agent, { wikiRoot, today, projectRoot, fs, out, err }) {
|
|
|
105
110
|
wikiRoot,
|
|
106
111
|
agent,
|
|
107
112
|
today,
|
|
108
|
-
0,
|
|
113
|
+
{ lines: 0, words: 0 },
|
|
109
114
|
{ force: true },
|
|
110
115
|
fs,
|
|
111
116
|
);
|
|
@@ -276,11 +281,16 @@ export async function runFixCommand(ctx) {
|
|
|
276
281
|
const wikiRoot = ctx.options["wiki-root"] || path.join(projectRoot, "wiki");
|
|
277
282
|
const today = ctx.options.today || currentDayIso(runtime);
|
|
278
283
|
const out = (s) => runtime.proc.stdout.write(s);
|
|
279
|
-
const
|
|
284
|
+
const logger = createLogger("wiki", runtime);
|
|
285
|
+
const err = (s) => logger.warn("fix", String(s).replace(/\n+$/, ""));
|
|
280
286
|
|
|
281
287
|
// The agent's edits change the result, so re-read and re-audit each round.
|
|
282
288
|
const audit = () =>
|
|
283
|
-
runRules(
|
|
289
|
+
runRules(
|
|
290
|
+
RULES,
|
|
291
|
+
buildContext({ wikiRoot, today, fs, subprocess: runtime.subprocess }),
|
|
292
|
+
{ resolveScope },
|
|
293
|
+
);
|
|
284
294
|
// The agent only ever gets prose-judgment (`agent`-class) findings. A
|
|
285
295
|
// `rotate` finding that survived the pre-pass (e.g. a prior-week log) is
|
|
286
296
|
// unfixable by the agent — and trimming append-only history to satisfy a
|
package/src/commands/inbox.js
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
2
3
|
import {
|
|
3
4
|
MEMO_INBOX_MARKER,
|
|
4
5
|
PRIORITY_INDEX_HEADING,
|
|
5
6
|
PRIORITY_INDEX_TABLE_HEADER,
|
|
6
7
|
} from "../constants.js";
|
|
7
8
|
import { currentDayIso } from "../util/clock.js";
|
|
9
|
+
import { requireAgentFlag } from "../util/agent-flag.js";
|
|
8
10
|
import { resolveWikiRoot } from "../util/wiki-dir.js";
|
|
9
11
|
|
|
10
12
|
function paths(runtime, options) {
|
|
11
13
|
const wikiRoot = resolveWikiRoot(runtime, options);
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
14
|
+
const resolved = requireAgentFlag(options, {
|
|
15
|
+
command: "inbox",
|
|
16
|
+
example: "fit-wiki inbox list --agent staff-engineer",
|
|
17
|
+
});
|
|
18
|
+
if (!resolved.ok) return { error: resolved };
|
|
19
|
+
const agent = resolved.agent;
|
|
19
20
|
return {
|
|
20
21
|
summaryPath: path.join(wikiRoot, `${agent}.md`),
|
|
21
22
|
memoryPath: path.join(wikiRoot, "MEMORY.md"),
|
|
@@ -67,13 +68,13 @@ function ackOrDropCmd(runtime, options) {
|
|
|
67
68
|
const { summaryPath } = p;
|
|
68
69
|
const idx = Number.parseInt(options.index ?? "", 10);
|
|
69
70
|
if (!Number.isInteger(idx) || idx < 0) {
|
|
70
|
-
runtime.
|
|
71
|
+
createLogger("wiki", runtime).warn("inbox", "inbox requires --index <n>");
|
|
71
72
|
return { ok: false, code: 2 };
|
|
72
73
|
}
|
|
73
74
|
const text = runtime.fsSync.readFileSync(summaryPath, "utf-8");
|
|
74
75
|
const { lines, bulletIdxs } = readInboxBullets(text);
|
|
75
76
|
if (idx >= bulletIdxs.length) {
|
|
76
|
-
runtime.
|
|
77
|
+
createLogger("wiki", runtime).warn("inbox", `no bullet at index ${idx}`);
|
|
77
78
|
return { ok: false, code: 2 };
|
|
78
79
|
}
|
|
79
80
|
removeBulletAt(lines, bulletIdxs[idx]);
|
|
@@ -134,13 +135,16 @@ function promoteCmd(runtime, options) {
|
|
|
134
135
|
const { summaryPath, memoryPath, agent } = p;
|
|
135
136
|
const idx = Number.parseInt(options.index ?? "", 10);
|
|
136
137
|
if (!Number.isInteger(idx) || idx < 0) {
|
|
137
|
-
|
|
138
|
+
createLogger("wiki", runtime).warn(
|
|
139
|
+
"inbox",
|
|
140
|
+
"inbox promote requires --index <n>",
|
|
141
|
+
);
|
|
138
142
|
return { ok: false, code: 2 };
|
|
139
143
|
}
|
|
140
144
|
const text = runtime.fsSync.readFileSync(summaryPath, "utf-8");
|
|
141
145
|
const { lines, bullets, bulletIdxs } = readInboxBullets(text);
|
|
142
146
|
if (idx >= bullets.length) {
|
|
143
|
-
runtime.
|
|
147
|
+
createLogger("wiki", runtime).warn("inbox", `no bullet at index ${idx}`);
|
|
144
148
|
return { ok: false, code: 2 };
|
|
145
149
|
}
|
|
146
150
|
const bulletText = bullets[idx].replace(/^[-*]\s+/, "");
|
package/src/commands/init.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
3
|
+
import { ensureMetricsCsvMergeAttribute } from "../gitattributes.js";
|
|
2
4
|
import { listSkills } from "../skill-roster.js";
|
|
3
5
|
import { resolveProjectRoot, resolveWikiRoot } from "../util/wiki-dir.js";
|
|
4
6
|
import {
|
|
@@ -53,19 +55,19 @@ function scaffoldActiveClaims(runtime, memoryPath) {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
async function maybeCloneWiki(wikiSync, gitClient, projectRoot, runtime) {
|
|
58
|
+
const logger = createLogger("wiki", runtime);
|
|
56
59
|
const wikiUrl = await deriveWikiUrl(gitClient, projectRoot, runtime.proc.env);
|
|
57
60
|
if (!wikiUrl) {
|
|
58
|
-
|
|
59
|
-
"init: could not determine wiki URL from origin remote\n",
|
|
60
|
-
);
|
|
61
|
+
logger.warn("init", "could not determine wiki URL from origin remote");
|
|
61
62
|
return;
|
|
62
63
|
}
|
|
63
64
|
const cloneResult = await wikiSync.ensureCloned(wikiUrl);
|
|
64
65
|
if (cloneResult.cloned) {
|
|
65
66
|
await wikiSync.inheritIdentity();
|
|
66
67
|
} else {
|
|
67
|
-
|
|
68
|
-
"init
|
|
68
|
+
logger.warn(
|
|
69
|
+
"init",
|
|
70
|
+
"could not clone wiki, continuing with local-only steps",
|
|
69
71
|
);
|
|
70
72
|
}
|
|
71
73
|
}
|
|
@@ -99,6 +101,11 @@ export async function runInitCommand(ctx) {
|
|
|
99
101
|
`init: scaffolded ${ACTIVE_CLAIMS_HEADING} in ${memoryPath}\n`,
|
|
100
102
|
);
|
|
101
103
|
}
|
|
104
|
+
if (ensureMetricsCsvMergeAttribute(wikiDir, runtime.fsSync).changed) {
|
|
105
|
+
runtime.proc.stdout.write(
|
|
106
|
+
"init: declared metrics-CSV union merge in .gitattributes\n",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
102
109
|
}
|
|
103
110
|
|
|
104
111
|
runtime.proc.stdout.write(`init: wiki ready at ${wikiDir}\n`);
|
package/src/commands/log.js
CHANGED
|
@@ -1,29 +1,36 @@
|
|
|
1
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
1
2
|
import {
|
|
2
3
|
weeklyLogPath,
|
|
3
4
|
rotateIfOverBudget,
|
|
4
5
|
appendEntry,
|
|
5
6
|
} from "../weekly-log.js";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
DECISION_HEADING,
|
|
9
|
+
WEEKLY_LOG_LINE_BUDGET,
|
|
10
|
+
WEEKLY_LOG_SEAM_RE,
|
|
11
|
+
WEEKLY_LOG_WORD_BUDGET,
|
|
12
|
+
} from "../constants.js";
|
|
13
|
+
import { countLines, countWords } from "../budget.js";
|
|
7
14
|
import { currentDayIso } from "../util/clock.js";
|
|
15
|
+
import { requireAgentFlag } from "../util/agent-flag.js";
|
|
8
16
|
import { resolveWikiRoot } from "../util/wiki-dir.js";
|
|
9
17
|
|
|
10
18
|
function commonContext(runtime, options) {
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
19
|
+
const resolved = requireAgentFlag(options, {
|
|
20
|
+
command: "log",
|
|
21
|
+
example:
|
|
22
|
+
'fit-wiki log decision --agent staff-engineer --chosen "..." --rationale "..."',
|
|
23
|
+
});
|
|
24
|
+
if (!resolved.ok) return { error: resolved };
|
|
18
25
|
const wikiRoot = resolveWikiRoot(runtime, options);
|
|
19
26
|
const today = options.today || currentDayIso(runtime);
|
|
20
|
-
return { agent, wikiRoot, today };
|
|
27
|
+
return { agent: resolved.agent, wikiRoot, today };
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
function lastDateHeading(text) {
|
|
24
31
|
// Match `## YYYY-MM-DD` at the start of a line, optionally followed by
|
|
25
32
|
// suffix text (e.g. `## 2026-05-19 (third activation)`).
|
|
26
|
-
const re =
|
|
33
|
+
const re = new RegExp(WEEKLY_LOG_SEAM_RE.source, "gm");
|
|
27
34
|
let last = null;
|
|
28
35
|
let match;
|
|
29
36
|
while ((match = re.exec(text)) !== null) last = match[1];
|
|
@@ -38,28 +45,50 @@ function lastDateHeading(text) {
|
|
|
38
45
|
* thrown fs error is reported and swallowed: the writer rolled back, so the
|
|
39
46
|
* (intact) current file still receives the new entry.
|
|
40
47
|
*/
|
|
41
|
-
function rotateBeforeAppend(wikiRoot, agent, today,
|
|
48
|
+
function rotateBeforeAppend(wikiRoot, agent, today, body, runtime) {
|
|
42
49
|
try {
|
|
50
|
+
const delta = { lines: countLines(body), words: countWords(body) };
|
|
43
51
|
const res = rotateIfOverBudget(
|
|
44
52
|
wikiRoot,
|
|
45
53
|
agent,
|
|
46
54
|
today,
|
|
47
|
-
|
|
55
|
+
delta,
|
|
48
56
|
{},
|
|
49
57
|
runtime.fsSync,
|
|
50
58
|
);
|
|
51
59
|
if (res.status === "incomplete") {
|
|
52
|
-
runtime.
|
|
53
|
-
|
|
60
|
+
createLogger("wiki", runtime).warn(
|
|
61
|
+
"log",
|
|
62
|
+
`day-section ${res.residue.section} alone exceeds the budget ` +
|
|
54
63
|
`(${res.residue.lines} lines, ${res.residue.words} words); ` +
|
|
55
|
-
`sealed as ${res.residue.path} for manual recovery
|
|
64
|
+
`sealed as ${res.residue.path} for manual recovery`,
|
|
56
65
|
);
|
|
57
66
|
}
|
|
58
67
|
} catch (e) {
|
|
59
|
-
runtime.
|
|
68
|
+
createLogger("wiki", runtime).warn("log", `rotation failed: ${e.message}`);
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Report the just-written weekly log's budget state — value, cap, and remaining
|
|
74
|
+
* headroom for both the line and word budget — so a writer sees the ceiling
|
|
75
|
+
* before composing the next entry rather than discovering it via a red gate.
|
|
76
|
+
* Runs after every successful append, on the rotated and non-rotated path alike.
|
|
77
|
+
*/
|
|
78
|
+
function reportBudget(target, runtime) {
|
|
79
|
+
const text = runtime.fsSync.existsSync(target)
|
|
80
|
+
? runtime.fsSync.readFileSync(target, "utf-8")
|
|
81
|
+
: "";
|
|
82
|
+
const lines = countLines(text);
|
|
83
|
+
const words = countWords(text);
|
|
84
|
+
runtime.proc.stdout.write(
|
|
85
|
+
`weekly log budget: ${lines}/${WEEKLY_LOG_LINE_BUDGET} lines ` +
|
|
86
|
+
`(${WEEKLY_LOG_LINE_BUDGET - lines} remaining), ` +
|
|
87
|
+
`${words}/${WEEKLY_LOG_WORD_BUDGET} words ` +
|
|
88
|
+
`(${WEEKLY_LOG_WORD_BUDGET - words} remaining)\n`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
function runDecision(runtime, options) {
|
|
64
93
|
const ctx = commonContext(runtime, options);
|
|
65
94
|
if (ctx.error) return ctx.error;
|
|
@@ -82,11 +111,11 @@ function runDecision(runtime, options) {
|
|
|
82
111
|
`**Rationale:** ${rationale}`,
|
|
83
112
|
"",
|
|
84
113
|
].join("\n");
|
|
85
|
-
|
|
86
|
-
rotateBeforeAppend(wikiRoot, agent, today, lineCount, runtime);
|
|
114
|
+
rotateBeforeAppend(wikiRoot, agent, today, body, runtime);
|
|
87
115
|
const target = weeklyLogPath(wikiRoot, agent, today);
|
|
88
116
|
appendEntry(target, body, agent, today, runtime.fsSync);
|
|
89
117
|
runtime.proc.stdout.write(`logged decision to ${target}\n`);
|
|
118
|
+
reportBudget(target, runtime);
|
|
90
119
|
return { ok: true };
|
|
91
120
|
}
|
|
92
121
|
|
|
@@ -95,19 +124,17 @@ function runNote(runtime, options) {
|
|
|
95
124
|
if (ctx.error) return ctx.error;
|
|
96
125
|
const { agent, wikiRoot, today } = ctx;
|
|
97
126
|
if (!options.field || !options.body) {
|
|
98
|
-
|
|
127
|
+
createLogger("wiki", runtime).warn(
|
|
128
|
+
"log",
|
|
129
|
+
"log note requires --field and --body",
|
|
130
|
+
);
|
|
99
131
|
return { ok: false, code: 2 };
|
|
100
132
|
}
|
|
101
133
|
const fieldBlock = `### ${options.field}\n\n${options.body}\n`;
|
|
102
|
-
// Conservative
|
|
134
|
+
// Conservative budget: assume we'll prepend a date heading. Rotate on the
|
|
135
|
+
// larger `withHeading` body so the word/line projection never under-counts.
|
|
103
136
|
const withHeading = `## ${today}\n\n${fieldBlock}`;
|
|
104
|
-
rotateBeforeAppend(
|
|
105
|
-
wikiRoot,
|
|
106
|
-
agent,
|
|
107
|
-
today,
|
|
108
|
-
withHeading.split("\n").length,
|
|
109
|
-
runtime,
|
|
110
|
-
);
|
|
137
|
+
rotateBeforeAppend(wikiRoot, agent, today, withHeading, runtime);
|
|
111
138
|
const target = weeklyLogPath(wikiRoot, agent, today);
|
|
112
139
|
// Append under the open entry if the file's last `## YYYY-MM-DD` is today;
|
|
113
140
|
// otherwise open a new entry by prepending a date heading.
|
|
@@ -117,6 +144,7 @@ function runNote(runtime, options) {
|
|
|
117
144
|
const body = lastDateHeading(existing) === today ? fieldBlock : withHeading;
|
|
118
145
|
appendEntry(target, body, agent, today, runtime.fsSync);
|
|
119
146
|
runtime.proc.stdout.write(`logged note to ${target}\n`);
|
|
147
|
+
reportBudget(target, runtime);
|
|
120
148
|
return { ok: true };
|
|
121
149
|
}
|
|
122
150
|
|
|
@@ -125,11 +153,11 @@ function runDone(runtime, options) {
|
|
|
125
153
|
if (ctx.error) return ctx.error;
|
|
126
154
|
const { agent, wikiRoot, today } = ctx;
|
|
127
155
|
const body = `### Closed\n\nRun closed ${today}.\n`;
|
|
128
|
-
|
|
129
|
-
rotateBeforeAppend(wikiRoot, agent, today, lineCount, runtime);
|
|
156
|
+
rotateBeforeAppend(wikiRoot, agent, today, body, runtime);
|
|
130
157
|
const target = weeklyLogPath(wikiRoot, agent, today);
|
|
131
158
|
appendEntry(target, body, agent, today, runtime.fsSync);
|
|
132
159
|
runtime.proc.stdout.write(`closed entry in ${target}\n`);
|
|
160
|
+
reportBudget(target, runtime);
|
|
133
161
|
return { ok: true };
|
|
134
162
|
}
|
|
135
163
|
|
package/src/commands/memo.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
2
3
|
import { writeMemo } from "../memo-writer.js";
|
|
3
4
|
import { listAgents } from "../agent-roster.js";
|
|
4
5
|
import { BROADCAST_TARGET } from "../constants.js";
|
|
5
6
|
import { currentDayIso } from "../util/clock.js";
|
|
7
|
+
import { requireAgentFlag } from "../util/agent-flag.js";
|
|
6
8
|
import { resolveProjectRoot } from "../util/wiki-dir.js";
|
|
7
9
|
|
|
8
10
|
function writeAndCheck(runtime, summaryPath, sender, message, today) {
|
|
@@ -11,8 +13,9 @@ function writeAndCheck(runtime, summaryPath, sender, message, today) {
|
|
|
11
13
|
runtime.fsSync,
|
|
12
14
|
);
|
|
13
15
|
if (!result.written) {
|
|
14
|
-
runtime.
|
|
15
|
-
|
|
16
|
+
createLogger("wiki", runtime).warn(
|
|
17
|
+
"memo",
|
|
18
|
+
`summary lacks memo:inbox marker: ${result.path}`,
|
|
16
19
|
);
|
|
17
20
|
return { ok: false, code: 2 };
|
|
18
21
|
}
|
|
@@ -61,19 +64,19 @@ function writeBroadcast(
|
|
|
61
64
|
return { ok: true };
|
|
62
65
|
}
|
|
63
66
|
|
|
64
|
-
/** Write a memo to a target agent's summary file (or broadcast to all except the sender); sender is --from
|
|
67
|
+
/** Write a memo to a target agent's summary file (or broadcast to all except the sender); the sender is the required --from flag. */
|
|
65
68
|
export function runMemoCommand(ctx) {
|
|
66
69
|
const { runtime } = ctx.deps;
|
|
67
70
|
const options = ctx.options;
|
|
68
|
-
const
|
|
71
|
+
const resolved = requireAgentFlag(options, {
|
|
72
|
+
command: "memo",
|
|
73
|
+
flag: "--from",
|
|
74
|
+
example:
|
|
75
|
+
'fit-wiki memo --from staff-engineer --to security-engineer --message "..."',
|
|
76
|
+
});
|
|
77
|
+
if (!resolved.ok) return resolved;
|
|
78
|
+
const sender = resolved.agent;
|
|
69
79
|
|
|
70
|
-
if (!sender) {
|
|
71
|
-
return {
|
|
72
|
-
ok: false,
|
|
73
|
-
code: 2,
|
|
74
|
-
error: "memo requires --from <sender> or LIBEVAL_AGENT_PROFILE env var",
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
80
|
if (!options.to) {
|
|
78
81
|
return { ok: false, code: 2, error: "memo requires --to <target|all>" };
|
|
79
82
|
}
|