@forwardimpact/libwiki 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -29
- 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 +19 -16
- 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/claim.js
CHANGED
|
@@ -24,9 +24,9 @@ const NOT_PUBLISHED = {
|
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
// Failure reasons that, on the claim/release surfaces, are an unsafe-state
|
|
27
|
-
// refusal (D7/D9 family) rather than a saved-locally success (D1)
|
|
28
|
-
// fires before the local write is publishable
|
|
29
|
-
// later whole-tree sweep
|
|
27
|
+
// refusal (D7/D9 family) rather than a saved-locally success (D1). The refusal
|
|
28
|
+
// fires before the local write is publishable. Or it leaves the tree unsafe
|
|
29
|
+
// for a later whole-tree sweep. So the surface must exit non-zero.
|
|
30
30
|
const UNSAFE_STATE_REASONS = new Set([
|
|
31
31
|
PUSH_REASONS.PRECONDITION,
|
|
32
32
|
PUSH_REASONS.RESIDUE_CONFLICT,
|
|
@@ -37,7 +37,7 @@ const UNSAFE_STATE_REASONS = new Set([
|
|
|
37
37
|
function notPublishedMessage(err) {
|
|
38
38
|
return (
|
|
39
39
|
`${err.message}\n` +
|
|
40
|
-
"The row
|
|
40
|
+
"The row is in MEMORY.md but is NOT published. It remains an " +
|
|
41
41
|
"uncommitted working-tree change.\n"
|
|
42
42
|
);
|
|
43
43
|
}
|
|
@@ -52,23 +52,25 @@ function memoryPath(runtime, options) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
* Push the claim/release MEMORY.md change
|
|
56
|
-
*
|
|
57
|
-
* discipline (the singleton merge discipline) and the secret/ancestry
|
|
58
|
-
*
|
|
55
|
+
* Push the claim/release MEMORY.md change. Translate the honest outcome (the
|
|
56
|
+
* honest-CLI contract) into a command envelope. Compose it with the singleton
|
|
57
|
+
* merge discipline (the singleton merge discipline) and the secret/ancestry
|
|
58
|
+
* guards:
|
|
59
|
+
* - landed (grounded or re-applied) ⇒ `{ ok: true }` with a printed success
|
|
60
|
+
* message;
|
|
59
61
|
* - `rejected`/`transport` ⇒ `{ ok: true }` with a saved-locally warning (the
|
|
60
|
-
* landed-locally row is complete
|
|
62
|
+
* landed-locally row is complete, and the session-end push is its retry);
|
|
61
63
|
* - `precondition`/`residue-conflict`/`conservation` ⇒ `{ ok: false, code: 1 }`
|
|
62
|
-
* (D7/D9 unsafe-state family
|
|
63
|
-
* left unsafe for a later whole-tree sweep);
|
|
64
|
+
* (the D7/D9 unsafe-state family, where the row is not published and the
|
|
65
|
+
* tree may be left unsafe for a later whole-tree sweep);
|
|
64
66
|
* - a secret-gate refusal ⇒ `{ ok: false, code: 1 }` ({@link refusalEnvelope});
|
|
65
|
-
* - an {@link AncestryRefusal}
|
|
66
|
-
* not-published non-zero envelope;
|
|
67
|
+
* - this function rethrows an {@link AncestryRefusal} so `pushRowOrRefuse`
|
|
68
|
+
* maps it to the not-published non-zero envelope;
|
|
67
69
|
* - any other thrown error is a network/credential failure that degrades to
|
|
68
70
|
* "saved locally" (`{ ok: true }`).
|
|
69
71
|
*
|
|
70
72
|
* The `reapply` closure re-derives this row against the fresh tip if the
|
|
71
|
-
* landing contends, so a parallel writer's row
|
|
73
|
+
* landing contends, so this command never erases a parallel writer's row.
|
|
72
74
|
*
|
|
73
75
|
* @param {object} wikiSync - The WikiSync collaborator (may be absent in tests).
|
|
74
76
|
* @param {object} runtime - The runtime bag (for stdout/stderr).
|
|
@@ -82,37 +84,38 @@ async function pushWiki(wikiSync, runtime, message, reapply) {
|
|
|
82
84
|
let result;
|
|
83
85
|
try {
|
|
84
86
|
await wikiSync.inheritIdentity();
|
|
85
|
-
// claim/release contract is a 1-line MEMORY.md change
|
|
86
|
-
// foreign uncommitted files from parallel writers out of the commit.
|
|
87
|
-
// `reapply` closure re-derives this row against the fresh tip if the
|
|
88
|
-
// contends (the singleton merge discipline), so
|
|
87
|
+
// The claim/release contract is a 1-line MEMORY.md change. The pathspec
|
|
88
|
+
// keeps foreign uncommitted files from parallel writers out of the commit.
|
|
89
|
+
// The `reapply` closure re-derives this row against the fresh tip if the
|
|
90
|
+
// landing contends (the singleton merge discipline), so this command never
|
|
91
|
+
// erases a parallel writer's row.
|
|
89
92
|
result = await wikiSync.commitAndPush(message, ["MEMORY.md"], { reapply });
|
|
90
93
|
} catch (err) {
|
|
91
|
-
// An ancestry-guard refusal pierces the saved-locally degradation
|
|
92
|
-
// so pushRowOrRefuse maps it to the not-published non-zero envelope.
|
|
94
|
+
// An ancestry-guard refusal pierces the saved-locally degradation. Rethrow
|
|
95
|
+
// it so pushRowOrRefuse maps it to the not-published non-zero envelope.
|
|
93
96
|
if (err instanceof AncestryRefusal) throw err;
|
|
94
97
|
if (err instanceof WikiPushFailure) {
|
|
95
|
-
// D7/D9 unsafe-state family
|
|
96
|
-
// left unsafe for a later sweep
|
|
98
|
+
// D7/D9 unsafe-state family. The row is not published, and the tree may
|
|
99
|
+
// be left unsafe for a later sweep. Fail the command closed (non-zero).
|
|
97
100
|
if (UNSAFE_STATE_REASONS.has(err.reason)) {
|
|
98
101
|
runtime.proc.stderr.write(`${err.message}\n`);
|
|
99
102
|
return { ok: false, code: 1 };
|
|
100
103
|
}
|
|
101
|
-
// rejected / transport: the local row landed
|
|
104
|
+
// rejected / transport: the local row landed. Warn and keep zero exit.
|
|
102
105
|
runtime.proc.stderr.write(
|
|
103
|
-
`saved locally
|
|
106
|
+
`saved locally, not yet visible to parallel sessions (${err.reason}): ${err.message}\n`,
|
|
104
107
|
);
|
|
105
108
|
return { ok: true };
|
|
106
109
|
}
|
|
107
|
-
// Any other failure: preserve fire-and-forget "saved locally"
|
|
108
|
-
// is on disk and the command still succeeds.
|
|
110
|
+
// Any other failure: preserve fire-and-forget "saved locally". The change
|
|
111
|
+
// is on disk, and the command still succeeds.
|
|
109
112
|
createLogger("wiki", runtime).warn(
|
|
110
113
|
"claim",
|
|
111
114
|
`push failed (saved locally): ${err.message}`,
|
|
112
115
|
);
|
|
113
116
|
return { ok: true };
|
|
114
117
|
}
|
|
115
|
-
// A secret-gate refusal fails the command closed
|
|
118
|
+
// A secret-gate refusal fails the command closed. A grounded-landed or a
|
|
116
119
|
// re-applied push reports success.
|
|
117
120
|
const refusal = refusalEnvelope(runtime, result);
|
|
118
121
|
if (refusal) return refusal;
|
|
@@ -123,16 +126,17 @@ async function pushWiki(wikiSync, runtime, message, reapply) {
|
|
|
123
126
|
}
|
|
124
127
|
|
|
125
128
|
/**
|
|
126
|
-
* Push a written claim/release row
|
|
127
|
-
* not-published non-zero envelope
|
|
128
|
-
* envelope. The row is already
|
|
129
|
+
* Push a written claim/release row. Map an ancestry-guard refusal to the
|
|
130
|
+
* not-published non-zero envelope. Map any other outcome to `pushWiki`'s
|
|
131
|
+
* envelope. The row is already in MEMORY.md. On a refusal it stays as an
|
|
129
132
|
* uncommitted working-tree change. The `reapply` closure re-derives the same
|
|
130
133
|
* row against the fresh tip when the landing contends.
|
|
131
134
|
*/
|
|
132
135
|
async function pushRowOrRefuse(wikiSync, runtime, message, reapply) {
|
|
133
136
|
try {
|
|
134
137
|
// Propagate pushWiki's envelope so a secret-gate or unsafe-state refusal
|
|
135
|
-
// ({ ok: false }) fails the command closed
|
|
138
|
+
// ({ ok: false }) fails the command closed. A clean push returns
|
|
139
|
+
// { ok: true }.
|
|
136
140
|
return await pushWiki(wikiSync, runtime, message, reapply);
|
|
137
141
|
} catch (err) {
|
|
138
142
|
if (err instanceof AncestryRefusal) {
|
|
@@ -143,7 +147,7 @@ async function pushRowOrRefuse(wikiSync, runtime, message, reapply) {
|
|
|
143
147
|
}
|
|
144
148
|
}
|
|
145
149
|
|
|
146
|
-
/** Insert a row into MEMORY.md `## Active Claims`.
|
|
150
|
+
/** Insert a row into MEMORY.md `## Active Claims`. It refuses if (agent, target) is already present. */
|
|
147
151
|
export async function runClaimCommand(ctx) {
|
|
148
152
|
const { runtime, wikiSync } = ctx.deps;
|
|
149
153
|
const options = ctx.options;
|
|
@@ -162,8 +166,9 @@ export async function runClaimCommand(ctx) {
|
|
|
162
166
|
};
|
|
163
167
|
}
|
|
164
168
|
const today = options.today || currentDayIso(runtime);
|
|
165
|
-
// Default expiry is claim+1 day
|
|
166
|
-
// this now" assertion
|
|
169
|
+
// Default expiry is claim+1 day. A claim is a short-lived "actively shipping
|
|
170
|
+
// this now" assertion. It is not a long lease. A run that outlives one day
|
|
171
|
+
// re-claims.
|
|
167
172
|
const expires = options["expires-at"] || addDays(today, 1);
|
|
168
173
|
const memPath = memoryPath(runtime, options);
|
|
169
174
|
const text = readMemory(runtime, memPath);
|
|
@@ -220,8 +225,8 @@ export async function runReleaseCommand(ctx) {
|
|
|
220
225
|
}
|
|
221
226
|
runtime.fsSync.writeFileSync(memPath, current);
|
|
222
227
|
runtime.proc.stdout.write(`released ${count} expired claim(s)\n`);
|
|
223
|
-
// Re-derive expiry against the fresh tip so a renewal landed since the
|
|
224
|
-
// read survives
|
|
228
|
+
// Re-derive expiry against the fresh tip so a renewal landed since the
|
|
229
|
+
// stale read survives. Remove only the rows that are still expired.
|
|
225
230
|
const reapply = (fresh) => {
|
|
226
231
|
const freshExpired = filterExpired(parseClaims(fresh), today).expired;
|
|
227
232
|
let next = fresh;
|
|
@@ -265,8 +270,9 @@ export async function runReleaseCommand(ctx) {
|
|
|
265
270
|
return { ok: true };
|
|
266
271
|
}
|
|
267
272
|
runtime.proc.stdout.write(`released ${options.target}\n`);
|
|
268
|
-
// Re-apply the same removal against the fresh tip if the landing contends
|
|
269
|
-
//
|
|
273
|
+
// Re-apply the same removal against the fresh tip if the landing contends.
|
|
274
|
+
// A second removal of an absent row is a no-op, so a re-release never
|
|
275
|
+
// resurrects it.
|
|
270
276
|
const reapply = (fresh) => {
|
|
271
277
|
const r = removeClaim(fresh, { agent, target: options.target });
|
|
272
278
|
return r.removed ? r.text : null;
|
package/src/commands/curate.js
CHANGED
|
@@ -7,8 +7,8 @@ import { resolveProjectRoot } from "../util/wiki-dir.js";
|
|
|
7
7
|
import { auditWiki } from "./audit.js";
|
|
8
8
|
|
|
9
9
|
// The routing contract: a single open issue, addressed to the technical-writer,
|
|
10
|
-
//
|
|
11
|
-
// dirty wiki appends to one issue
|
|
10
|
+
// that holds the audit findings. Every run matches the title verbatim, so a
|
|
11
|
+
// dirty wiki appends to one issue and does not open a new one each day.
|
|
12
12
|
const LABEL = {
|
|
13
13
|
name: "wiki-curation",
|
|
14
14
|
color: "BFD4F2",
|
|
@@ -19,13 +19,14 @@ const TITLE = "Wiki curation: shared-state audit findings";
|
|
|
19
19
|
// GitHub rejects an issue or comment body over 65536 characters. Keep the whole
|
|
20
20
|
// body under a margin below that so the preamble, JSON fence, and truncation
|
|
21
21
|
// notice always fit. When the findings overflow, the body carries the first N
|
|
22
|
-
// that fit plus a count
|
|
22
|
+
// that fit plus a count. `gemba-wiki audit` still reproduces the full list.
|
|
23
23
|
const MAX_BODY = 65000;
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Compose the issue body from the audit's JSON findings. The findings ride a
|
|
27
|
-
* fenced ```json block
|
|
28
|
-
* file)
|
|
27
|
+
* fenced ```json block. The caller passes the body to `gh` through
|
|
28
|
+
* `--body-file` (a temp file) and never through argv. So `gh` cannot misread
|
|
29
|
+
* untrusted finding text as a flag.
|
|
29
30
|
* @param {string} findingsJson
|
|
30
31
|
* @param {{shown?: number, total?: number}} [trunc]
|
|
31
32
|
* @returns {string}
|
|
@@ -34,12 +35,12 @@ function buildBody(findingsJson, { shown, total } = {}) {
|
|
|
34
35
|
const lines = [
|
|
35
36
|
"Scheduled `curate-wiki` audit found shared-wiki violations.",
|
|
36
37
|
"",
|
|
37
|
-
"Owner: **technical-writer
|
|
38
|
+
"Owner: **technical-writer**. Service these through the curation shift. The per-PR `wiki` gate no longer reads shared wiki state.",
|
|
38
39
|
"",
|
|
39
40
|
];
|
|
40
41
|
if (total != null && shown != null && shown < total) {
|
|
41
42
|
lines.push(
|
|
42
|
-
`Showing ${shown} of ${total} findings
|
|
43
|
+
`Showing ${shown} of ${total} findings. The body is truncated to fit GitHub's comment limit. Run \`gemba-wiki audit\` for the full list.`,
|
|
43
44
|
"",
|
|
44
45
|
);
|
|
45
46
|
}
|
|
@@ -49,9 +50,10 @@ function buildBody(findingsJson, { shown, total } = {}) {
|
|
|
49
50
|
|
|
50
51
|
/**
|
|
51
52
|
* Build the largest postable body for the audit findings. The full findings
|
|
52
|
-
* usually fit
|
|
53
|
-
* under GitHub's body limit
|
|
54
|
-
* down proportionally to the overflow, so it converges in a couple of
|
|
53
|
+
* usually fit. When they do not fit, keep the first N `fail` findings that
|
|
54
|
+
* stay under GitHub's body limit. Label the body as truncated. The shrink
|
|
55
|
+
* steps down proportionally to the overflow, so it converges in a couple of
|
|
56
|
+
* passes.
|
|
55
57
|
* @param {{level: string}[]} findings
|
|
56
58
|
* @returns {string}
|
|
57
59
|
*/
|
|
@@ -76,8 +78,8 @@ function fitBody(findings) {
|
|
|
76
78
|
|
|
77
79
|
// Resolve the monorepo's `owner/repo` slug the way refresh.js/product-mix.js
|
|
78
80
|
// do: an explicit FIT_GH_REPO override (sandbox proxy URLs), else the origin
|
|
79
|
-
// remote parsed
|
|
80
|
-
// own cwd resolution.
|
|
81
|
+
// remote parsed through the injected git client. Null lets `gh` fall back to
|
|
82
|
+
// its own cwd resolution.
|
|
81
83
|
async function deriveRepo(gitClient, cwd, env) {
|
|
82
84
|
if (env.FIT_GH_REPO) return env.FIT_GH_REPO;
|
|
83
85
|
if (!gitClient) return null;
|
|
@@ -88,7 +90,7 @@ async function deriveRepo(gitClient, cwd, env) {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
// A missing token is non-fatal
|
|
93
|
+
// A missing token is non-fatal. `gh` may still resolve ambient auth.
|
|
92
94
|
async function resolveToken() {
|
|
93
95
|
try {
|
|
94
96
|
return (await createScriptConfig("wiki")).ghToken();
|
|
@@ -98,8 +100,9 @@ async function resolveToken() {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/**
|
|
101
|
-
* Find the open `wiki-curation` issue by its verbatim title, or null.
|
|
102
|
-
* parse failure or empty result
|
|
103
|
+
* Find the open `wiki-curation` issue by its verbatim title, or null. This
|
|
104
|
+
* function treats any parse failure or empty result as "no issue" (create
|
|
105
|
+
* path).
|
|
103
106
|
* @param {import("@forwardimpact/libcli").InvocationContext["deps"]["runtime"]} runtime
|
|
104
107
|
* @param {string[]} repoArgs
|
|
105
108
|
* @param {{cwd: string, env: object}} opts
|
|
@@ -129,11 +132,11 @@ async function findOpenIssue(runtime, repoArgs, opts) {
|
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
/**
|
|
132
|
-
* Route the composed body to the single `wiki-curation` issue:
|
|
133
|
-
* label, find the open issue by title, then comment on it or create it.
|
|
134
|
-
* body goes through a temp file
|
|
135
|
-
*
|
|
136
|
-
*
|
|
135
|
+
* Route the composed body to the single `wiki-curation` issue: make sure the
|
|
136
|
+
* label exists, find the open issue by title, then comment on it or create it.
|
|
137
|
+
* The body goes through a temp file and never through argv. So `gh` cannot
|
|
138
|
+
* read untrusted finding text as a flag. On a `gh` failure this function logs
|
|
139
|
+
* the reason and returns `ok:false`, so the caller exits non-zero.
|
|
137
140
|
* @param {import("@forwardimpact/libcli").InvocationContext} ctx
|
|
138
141
|
* @param {string} body
|
|
139
142
|
* @param {ReturnType<typeof createLogger>} logger
|
|
@@ -150,8 +153,8 @@ async function routeFindings(ctx, body, logger) {
|
|
|
150
153
|
: runtime.proc.env;
|
|
151
154
|
const repoArgs = repo ? ["--repo", repo] : [];
|
|
152
155
|
|
|
153
|
-
//
|
|
154
|
-
//
|
|
156
|
+
// Make sure the label exists. A re-create on an existing label exits
|
|
157
|
+
// non-zero. That is expected, and the code ignores it.
|
|
155
158
|
await runtime.subprocess.run(
|
|
156
159
|
"gh",
|
|
157
160
|
[
|
|
@@ -169,8 +172,8 @@ async function routeFindings(ctx, body, logger) {
|
|
|
169
172
|
|
|
170
173
|
const number = await findOpenIssue(runtime, repoArgs, { cwd, env });
|
|
171
174
|
|
|
172
|
-
// Pass the body through a temp file
|
|
173
|
-
// to
|
|
175
|
+
// Pass the body through a temp file instead of argv. A temp file is robust
|
|
176
|
+
// to length, and `gh` cannot read finding text as a flag.
|
|
174
177
|
const tmp = runtime.proc.env.RUNNER_TEMP || runtime.proc.env.TMPDIR || "/tmp";
|
|
175
178
|
const bodyFile = path.join(tmp, "wiki-curation-body.md");
|
|
176
179
|
runtime.fsSync.writeFileSync(bodyFile, body);
|
|
@@ -208,12 +211,12 @@ async function routeFindings(ctx, body, logger) {
|
|
|
208
211
|
}
|
|
209
212
|
|
|
210
213
|
/**
|
|
211
|
-
* Audit the shared wiki
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
214
|
+
* Audit the shared wiki. When it is dirty, route the findings to the single
|
|
215
|
+
* `wiki-curation` issue (create or comment) addressed to the technical-writer.
|
|
216
|
+
* This is the SOLE home of the shared-wiki audit verdict. The per-PR `wiki`
|
|
217
|
+
* gate no longer reads live wiki state. A clean wiki routes nothing. The
|
|
218
|
+
* label/search/create-or-comment logic lives here and not in the workflow, so
|
|
219
|
+
* the curation step is one CLI call.
|
|
217
220
|
*
|
|
218
221
|
* @param {import("@forwardimpact/libcli").InvocationContext} ctx
|
|
219
222
|
* @returns {Promise<{ok: boolean}>}
|
|
@@ -224,7 +227,7 @@ export async function runCurateCommand(ctx) {
|
|
|
224
227
|
const { findings } = auditWiki(ctx);
|
|
225
228
|
|
|
226
229
|
if (!findings.some((f) => f.level === "fail")) {
|
|
227
|
-
runtime.proc.stdout.write("wiki audit clean
|
|
230
|
+
runtime.proc.stdout.write("wiki audit clean, no curation issue routed\n");
|
|
228
231
|
return { ok: true };
|
|
229
232
|
}
|
|
230
233
|
|
package/src/commands/fix.js
CHANGED
|
@@ -18,15 +18,15 @@ import { resolveProjectRoot } from "../util/wiki-dir.js";
|
|
|
18
18
|
import { FAST_MODEL } from "@forwardimpact/libutil/models";
|
|
19
19
|
import { createLogger } from "@forwardimpact/libtelemetry";
|
|
20
20
|
|
|
21
|
-
// Pipeline: audit → deterministic rotation (the one fix
|
|
22
|
-
// agent
|
|
23
|
-
// flag what neither should touch. MAX_ROUNDS still caps the agent loop
|
|
24
|
-
// unresolvable agent-class finding fails loudly
|
|
21
|
+
// Pipeline: audit → deterministic rotation (the one fix that needs a file seal
|
|
22
|
+
// the agent cannot do) → re-audit → Haiku agent on the prose-judgment residual
|
|
23
|
+
// → flag what neither should touch. MAX_ROUNDS still caps the agent loop. An
|
|
24
|
+
// unresolvable agent-class finding fails loudly and does not loop forever.
|
|
25
25
|
const MAX_ROUNDS = 3;
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* A finding's remediation class, from the declarative rule. Rules without a
|
|
29
|
-
* `remediation` field default to `"agent"
|
|
29
|
+
* `remediation` field default to `"agent"`. The Haiku agent handles all
|
|
30
30
|
* prose-judgment fixes (summary trims, section order, MEMORY.md prose).
|
|
31
31
|
*/
|
|
32
32
|
function classOf(finding) {
|
|
@@ -34,14 +34,16 @@ function classOf(finding) {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
* Every rule
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
37
|
+
* Every rule that governs a scope with an open finding, as `id — hint` lines.
|
|
38
|
+
* The agent gets the full contract for the files it edits. The contract covers
|
|
39
|
+
* more than the rules that fail. It stops the agent from breaking one
|
|
40
|
+
* invariant while it fixes another finding (a dropped `**Last run**:` line, a
|
|
41
|
+
* section appended after `## Open Blockers`, …).
|
|
41
42
|
*
|
|
42
|
-
*
|
|
43
|
-
* resolved command (a `rotate` remediation the agent never performs)
|
|
44
|
-
* file invariant, so
|
|
43
|
+
* This lists only static-string hints. A function hint is a per-finding
|
|
44
|
+
* resolved command (a `rotate` remediation the agent never performs). A
|
|
45
|
+
* function hint is not a file invariant, so an entry for it would leak its
|
|
46
|
+
* source text into the prompt.
|
|
45
47
|
*/
|
|
46
48
|
function invariantContract(findings) {
|
|
47
49
|
const scopes = new Set(
|
|
@@ -53,52 +55,53 @@ function invariantContract(findings) {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
|
-
* The opening task
|
|
57
|
-
* rule hints
|
|
58
|
-
* weekly-log files
|
|
59
|
-
* Write.
|
|
58
|
+
* The opening task. It carries the findings, the invariant contract, and the
|
|
59
|
+
* things the rule hints do not cover. It says where trimmed history goes (only
|
|
60
|
+
* existing weekly-log files, because rotation mints the new ones). It also
|
|
61
|
+
* says to prefer a single Write.
|
|
60
62
|
*/
|
|
61
63
|
function composeTask(findings, wikiRoot, projectRoot) {
|
|
62
64
|
return [
|
|
63
|
-
`Fix these wiki audit findings
|
|
65
|
+
`Fix these wiki audit findings. Edit files under ${wikiRoot}.`,
|
|
64
66
|
``,
|
|
65
67
|
emitFindingsText(findings, { cwd: projectRoot }),
|
|
66
68
|
``,
|
|
67
|
-
`All of these invariants must hold when you finish
|
|
68
|
-
`
|
|
69
|
+
`All of these invariants must hold when you finish. Never fix one finding`,
|
|
70
|
+
`and break another:`,
|
|
69
71
|
...invariantContract(findings),
|
|
70
72
|
``,
|
|
71
73
|
`Move history out of an over-budget summary into the agent's existing`,
|
|
72
|
-
`weekly-log file or its current part (wiki/<agent>-YYYY-Www[-partN].md)
|
|
73
|
-
`
|
|
74
|
-
`when part files
|
|
75
|
-
`trimmed narrative already exists in the weekly log, replace it in
|
|
76
|
-
`summary with a pointer to that file instead of copying it anywhere.`,
|
|
74
|
+
`weekly-log file or its current part (wiki/<agent>-YYYY-Www[-partN].md).`,
|
|
75
|
+
`Never write a new summary section, and never a new file. The rotation`,
|
|
76
|
+
`tool owns when it creates part files, so do not mint filenames yourself.`,
|
|
77
|
+
`If the trimmed narrative already exists in the weekly log, replace it in`,
|
|
78
|
+
`the summary with a pointer to that file instead of copying it anywhere.`,
|
|
77
79
|
`Prefer a single Write over many Edits.`,
|
|
78
80
|
].join("\n");
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
/** The resume task
|
|
83
|
+
/** The resume task. It carries the findings that survived the last edit. */
|
|
82
84
|
function composeFollowup(findings, projectRoot) {
|
|
83
85
|
return [
|
|
84
|
-
`The wiki still fails the audit.
|
|
86
|
+
`The wiki still fails the audit. The findings that remain:`,
|
|
85
87
|
``,
|
|
86
88
|
emitFindingsText(findings, { cwd: projectRoot }),
|
|
87
89
|
``,
|
|
88
|
-
`Fix every one
|
|
90
|
+
`Fix every one. Do not break any invariant listed earlier.`,
|
|
89
91
|
].join("\n");
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
/**
|
|
93
|
-
* Deterministic pre-pass
|
|
94
|
-
*
|
|
95
|
-
* subjects (keyed by path)
|
|
96
|
-
* word-over/line-under file.
|
|
95
|
+
* Deterministic pre-pass. It seals every over-budget current-week main log
|
|
96
|
+
* with `rotateIfOverBudget`. The agent name comes from the audit's own
|
|
97
|
+
* subjects (keyed by path), so nothing parses the filename. `force: true`
|
|
98
|
+
* rotates even a word-over/line-under file.
|
|
97
99
|
*
|
|
98
|
-
* `rotateIfOverBudget` always seals the agent's *current-week* log, so we
|
|
99
|
-
*
|
|
100
|
-
* untouched
|
|
101
|
-
*
|
|
100
|
+
* `rotateIfOverBudget` always seals the agent's *current-week* log, so we call
|
|
101
|
+
* it only when the finding IS that file. A prior-week over-budget main log
|
|
102
|
+
* stays untouched. A rotation would force-seal a healthy current-week log
|
|
103
|
+
* instead. The over-budget main survives the re-audit, and the run flags it
|
|
104
|
+
* for a human.
|
|
102
105
|
*/
|
|
103
106
|
/**
|
|
104
107
|
* Seal one over-budget current-week main log. A failed seal leaves the source
|
|
@@ -133,8 +136,8 @@ function rotateOverBudgetMainLogs(findings, deps) {
|
|
|
133
136
|
];
|
|
134
137
|
const agentByPath = new Map(subjects.map((s) => [s.path, s.agentPrefix]));
|
|
135
138
|
// A log over BOTH budgets yields two `rotate` findings with the same path
|
|
136
|
-
// (different rule ids)
|
|
137
|
-
// bisect the
|
|
139
|
+
// (different rule ids). Seal each path once. A second force call would
|
|
140
|
+
// bisect the newly written main file into a spurious near-empty part.
|
|
138
141
|
const sealed = new Set();
|
|
139
142
|
for (const f of findings) {
|
|
140
143
|
if (classOf(f) !== "rotate") continue;
|
|
@@ -147,9 +150,9 @@ function rotateOverBudgetMainLogs(findings, deps) {
|
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
/**
|
|
150
|
-
* Re-bisect one over-budget sealed part
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
+
* Re-bisect one over-budget sealed part. Log each new sibling slot it produces
|
|
154
|
+
* (the reused source slot is not a new file). A failed reseal leaves the
|
|
155
|
+
* source intact (the writer rolled back), so the re-audit re-flags it.
|
|
153
156
|
*/
|
|
154
157
|
function resealPart(partPath, { fs, projectRoot, out, err }) {
|
|
155
158
|
try {
|
|
@@ -171,15 +174,15 @@ function resealPart(partPath, { fs, projectRoot, out, err }) {
|
|
|
171
174
|
}
|
|
172
175
|
|
|
173
176
|
/**
|
|
174
|
-
* Deterministic pass for over-budget sealed parts
|
|
175
|
-
* day-section seams. A part with no splittable seam
|
|
176
|
-
* re-audit re-flags it for a human). Agent and week come from the part
|
|
177
|
-
* so no subject lookup
|
|
178
|
-
* findings `rotateOverBudgetMainLogs` handles.
|
|
177
|
+
* Deterministic pass for over-budget sealed parts. It re-bisects each part at
|
|
178
|
+
* its day-section seams. A part with no splittable seam stays byte-identical
|
|
179
|
+
* (the re-audit re-flags it for a human). Agent and week come from the part
|
|
180
|
+
* filename, so the pass needs no subject lookup. Part findings are disjoint
|
|
181
|
+
* from the main-log findings `rotateOverBudgetMainLogs` handles.
|
|
179
182
|
*/
|
|
180
183
|
function rebisectOverBudgetParts(findings, deps) {
|
|
181
|
-
// A part over both budgets yields two findings with the same path
|
|
182
|
-
// each path once.
|
|
184
|
+
// A part over both budgets yields two findings with the same path.
|
|
185
|
+
// Re-bisect each path once.
|
|
183
186
|
const done = new Set();
|
|
184
187
|
for (const f of findings) {
|
|
185
188
|
if (classOf(f) !== "rotate") continue;
|
|
@@ -190,7 +193,7 @@ function rebisectOverBudgetParts(findings, deps) {
|
|
|
190
193
|
}
|
|
191
194
|
}
|
|
192
195
|
|
|
193
|
-
/** Report findings that need human judgment
|
|
196
|
+
/** Report findings that need human judgment. Nothing auto-fixes them. */
|
|
194
197
|
function reportFlags(err, flagFindings, projectRoot) {
|
|
195
198
|
err(
|
|
196
199
|
`gemba-wiki fix: ${flagFindings.length} finding(s) need human judgment ` +
|
|
@@ -200,11 +203,11 @@ function reportFlags(err, flagFindings, projectRoot) {
|
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
/**
|
|
203
|
-
* Surface a round's agent error, if any. Returns true when
|
|
204
|
-
* missing sessionId means the process never started (e.g. the SDK refused
|
|
206
|
+
* Surface a round's agent error, if any. Returns true when the error is fatal.
|
|
207
|
+
* A missing sessionId means the process never started (e.g. the SDK refused
|
|
205
208
|
* bypass-permissions as root), so there is nothing to resume. A turn-limit or
|
|
206
|
-
* transient error keeps its session and
|
|
207
|
-
*
|
|
209
|
+
* transient error keeps its session and can make partial progress. The
|
|
210
|
+
* function notes it and does not treat it as fatal. The re-audit decides.
|
|
208
211
|
*/
|
|
209
212
|
function isFatalError(result, round, err) {
|
|
210
213
|
if (!result.error) return false;
|
|
@@ -237,10 +240,10 @@ async function buildFixRunner(ctx, projectRoot, runtime) {
|
|
|
237
240
|
}
|
|
238
241
|
|
|
239
242
|
/**
|
|
240
|
-
* Run the agent on the prose-judgment findings
|
|
241
|
-
* clean,
|
|
242
|
-
* the agent's self-report
|
|
243
|
-
* large for one round.
|
|
243
|
+
* Run the agent on the prose-judgment findings. Re-audit each round until the
|
|
244
|
+
* wiki is clean, until only flags remain, or until MAX_ROUNDS is exhausted.
|
|
245
|
+
* The audit gives the verdict. The agent's self-report does not. A resume
|
|
246
|
+
* extends the turn budget for a trim too large for one round.
|
|
244
247
|
*/
|
|
245
248
|
async function runAgentRounds(runner, agentFindings, deps) {
|
|
246
249
|
const { wikiRoot, projectRoot, audit, partition, out, err } = deps;
|
|
@@ -291,10 +294,10 @@ export async function runFixCommand(ctx) {
|
|
|
291
294
|
buildContext({ wikiRoot, today, fs, subprocess: runtime.subprocess }),
|
|
292
295
|
{ resolveScope },
|
|
293
296
|
);
|
|
294
|
-
// The agent only ever gets prose-judgment (`agent`-class) findings.
|
|
295
|
-
// `rotate` finding that survived the pre-pass (e.g. a
|
|
296
|
-
//
|
|
297
|
-
//
|
|
297
|
+
// The agent only ever gets prose-judgment (`agent`-class) findings. The
|
|
298
|
+
// agent cannot fix a `rotate` finding that survived the pre-pass (e.g. a
|
|
299
|
+
// prior-week log). A trim of append-only history to satisfy a budget would
|
|
300
|
+
// corrupt the history. So the finding joins the flag set for a human.
|
|
298
301
|
const partition = (found) => ({
|
|
299
302
|
agentFindings: found.filter((f) => classOf(f) === "agent"),
|
|
300
303
|
flagFindings: found.filter((f) => classOf(f) !== "agent"),
|
|
@@ -307,7 +310,7 @@ export async function runFixCommand(ctx) {
|
|
|
307
310
|
}
|
|
308
311
|
|
|
309
312
|
// Deterministic layer: seal over-budget main logs, then re-bisect over-budget
|
|
310
|
-
// sealed parts. Both
|
|
313
|
+
// sealed parts. Both preserve content, with no agent and no history rewrite.
|
|
311
314
|
if (findings.some((f) => classOf(f) === "rotate")) {
|
|
312
315
|
const rotateDeps = { wikiRoot, today, projectRoot, fs, out, err };
|
|
313
316
|
rotateOverBudgetMainLogs(findings, rotateDeps);
|
|
@@ -319,15 +322,16 @@ export async function runFixCommand(ctx) {
|
|
|
319
322
|
}
|
|
320
323
|
}
|
|
321
324
|
|
|
322
|
-
// Residual: agent-class goes to the writer
|
|
323
|
-
// rotate finding the deterministic pass could not handle)
|
|
325
|
+
// Residual: agent-class goes to the writer. Everything else needs a human
|
|
326
|
+
// (flag, plus any rotate finding the deterministic pass could not handle).
|
|
324
327
|
const { agentFindings, flagFindings } = partition(findings);
|
|
325
328
|
if (agentFindings.length === 0) {
|
|
326
329
|
reportFlags(err, flagFindings, projectRoot);
|
|
327
330
|
return { ok: false, code: 2 };
|
|
328
331
|
}
|
|
329
332
|
|
|
330
|
-
//
|
|
333
|
+
// Build the runner only now, so a rotation-only or flag-only run never
|
|
334
|
+
// spawns it.
|
|
331
335
|
const runner = await buildFixRunner(ctx, projectRoot, runtime);
|
|
332
336
|
return runAgentRounds(runner, agentFindings, {
|
|
333
337
|
wikiRoot,
|
package/src/commands/inbox.js
CHANGED
|
@@ -104,7 +104,7 @@ function appendPriorityRow(memoryText, { item, agents, owner, status, added }) {
|
|
|
104
104
|
];
|
|
105
105
|
return memoryText.replace(/\n*$/, "") + "\n" + block.join("\n");
|
|
106
106
|
}
|
|
107
|
-
// Find last data row.
|
|
107
|
+
// Find the last data row.
|
|
108
108
|
let sepIdx = -1;
|
|
109
109
|
for (let i = headingIdx + 1; i < lines.length; i++) {
|
|
110
110
|
if (/^\|\s*---/.test(lines[i])) {
|
package/src/commands/init.js
CHANGED
|
@@ -9,7 +9,12 @@ import {
|
|
|
9
9
|
ACTIVE_CLAIMS_TABLE_SEPARATOR,
|
|
10
10
|
} from "../constants.js";
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Resolve the wiki clone URL. The FIT_WIKI_URL env var is an explicit
|
|
14
|
+
* override. Use it in a sandboxed environment where a local proxy replaces
|
|
15
|
+
* `origin` and does not serve wiki repos. Without that override, the function
|
|
16
|
+
* appends `.wiki.git` to the parent repo's `origin` remote.
|
|
17
|
+
*/
|
|
13
18
|
export async function deriveWikiUrl(gitClient, parentDir, env) {
|
|
14
19
|
if (env.FIT_WIKI_URL) return env.FIT_WIKI_URL;
|
|
15
20
|
try {
|
|
@@ -31,7 +36,7 @@ function scaffoldActiveClaims(runtime, memoryPath) {
|
|
|
31
36
|
"",
|
|
32
37
|
ACTIVE_CLAIMS_HEADING,
|
|
33
38
|
"",
|
|
34
|
-
"In-flight work claimed by an agent. Row present = active
|
|
39
|
+
"In-flight work claimed by an agent. Row present = active. Row absent = settled.",
|
|
35
40
|
"Writers: `gemba-wiki claim`, `gemba-wiki release`. Reader: `gemba-wiki boot`.",
|
|
36
41
|
"",
|
|
37
42
|
ACTIVE_CLAIMS_TABLE_HEADER,
|
|
@@ -65,14 +70,14 @@ async function maybeCloneWiki(wikiSync, gitClient, projectRoot, runtime) {
|
|
|
65
70
|
if (cloneResult.cloned) {
|
|
66
71
|
await wikiSync.inheritIdentity();
|
|
67
72
|
} else {
|
|
68
|
-
logger.warn(
|
|
69
|
-
"init",
|
|
70
|
-
"could not clone wiki, continuing with local-only steps",
|
|
71
|
-
);
|
|
73
|
+
logger.warn("init", "could not clone wiki, so only the local steps run");
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
76
|
|
|
75
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Clone the wiki if it is absent. Scaffold Active Claims in MEMORY.md. Create
|
|
79
|
+
* a metric directory for each skill.
|
|
80
|
+
*/
|
|
76
81
|
export async function runInitCommand(ctx) {
|
|
77
82
|
const { runtime, wikiSync, gitClient } = ctx.deps;
|
|
78
83
|
const options = ctx.options;
|