@davesheffer/hunch 1.39.0 → 1.39.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/index.js +193 -65
- package/dist/cli/integrations.js +10 -0
- package/dist/client/state.d.ts +2 -1
- package/dist/client/state.js +1 -0
- package/dist/core/agenthook.d.ts +14 -0
- package/dist/core/agenthook.js +55 -8
- package/dist/core/capturetoken.d.ts +30 -3
- package/dist/core/capturetoken.js +29 -3
- package/dist/core/changeProof.js +5 -1
- package/dist/core/checkreport.d.ts +7 -0
- package/dist/core/checkreport.js +20 -3
- package/dist/core/compare.js +3 -2
- package/dist/core/correction.d.ts +10 -4
- package/dist/core/correction.js +7 -4
- package/dist/core/countersign.d.ts +28 -0
- package/dist/core/countersign.js +50 -0
- package/dist/core/reviewqueue.js +6 -1
- package/dist/core/spawnCommand.js +41 -9
- package/dist/core/stateHttp.d.ts +1 -1
- package/dist/core/stateHttp.js +3 -1
- package/dist/core/taskReportEvidence.js +31 -11
- package/dist/core/topics.js +1 -1
- package/dist/core/types.d.ts +1 -0
- package/dist/core/workspace.d.ts +23 -1
- package/dist/core/workspace.js +31 -7
- package/dist/extractors/diff.d.ts +34 -0
- package/dist/extractors/diff.js +147 -5
- package/dist/extractors/git.d.ts +40 -11
- package/dist/extractors/git.js +147 -43
- package/dist/extractors/workspaces.d.ts +10 -0
- package/dist/extractors/workspaces.js +92 -15
- package/dist/integrations/gitignore.d.ts +27 -2
- package/dist/integrations/gitignore.js +103 -17
- package/dist/integrations/hooks.d.ts +61 -7
- package/dist/integrations/hooks.js +330 -43
- package/dist/integrations/scaffold.js +1 -1
- package/dist/integrations/workspaceLedger.d.ts +23 -3
- package/dist/integrations/workspaceLedger.js +114 -8
- package/dist/mcp/server.js +115 -56
- package/dist/serve/app.d.ts +4 -0
- package/dist/serve/app.js +56 -36
- package/dist/store/hunchStore.d.ts +4 -2
- package/dist/store/hunchStore.js +23 -6
- package/dist/store/stateBinding.js +111 -42
- package/dist/wiki/wiki.d.ts +7 -0
- package/dist/wiki/wiki.js +19 -8
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/serve/app.js
CHANGED
|
@@ -93,6 +93,7 @@ function parseScopeParam(value) {
|
|
|
93
93
|
}
|
|
94
94
|
export function createServeApp(config, opts = {}) {
|
|
95
95
|
const version = opts.version ?? HUNCH_VERSION;
|
|
96
|
+
const writeLockTimeoutMs = opts.writeLockTimeoutMs;
|
|
96
97
|
const configFile = config.file;
|
|
97
98
|
const authStateDir = opts.authStateDir ?? (configFile ? resolve(configFile + ".auth") : undefined);
|
|
98
99
|
const stores = new Map();
|
|
@@ -125,19 +126,26 @@ export function createServeApp(config, opts = {}) {
|
|
|
125
126
|
};
|
|
126
127
|
const problemBody = (p) => ({ type: `${PROBLEM_TYPE}${p.code}`, title: p.code, status: p.status, detail: p.message, ...p.extra });
|
|
127
128
|
const sendProblem = (res, p) => { send(res, p.status, problemBody(p), "application/problem+json"); };
|
|
128
|
-
|
|
129
|
+
const log = opts.log ?? ((line) => { process.stderr.write(`${line}\n`); });
|
|
130
|
+
/** Anything thrown → problem. Shared by REST (problem+json) and MCP (tool error results).
|
|
131
|
+
* A contract refusal (4xx) explains itself to the caller. A server-side failure (5xx) does
|
|
132
|
+
* not: its message can name lock paths, PIDs, host names or store paths, so the caller gets
|
|
133
|
+
* a generic detail and the specifics go to the server log only. */
|
|
129
134
|
const problemOf = (error) => {
|
|
130
135
|
if (error instanceof HttpProblem)
|
|
131
136
|
return error;
|
|
132
137
|
if (error instanceof StateRefusal)
|
|
133
138
|
return problem(REFUSAL_STATUS[error.code], error.code, error.message, error.conflict ? { conflict: error.conflict } : {});
|
|
134
|
-
if (error instanceof WriteLockTimeout)
|
|
135
|
-
|
|
139
|
+
if (error instanceof WriteLockTimeout) {
|
|
140
|
+
log(`hunch serve: 503 write-lock-timeout: ${error.message}`);
|
|
141
|
+
return problem(503, "write-lock-timeout", "the partition is busy with another write; retry shortly", { "retry-after": 1 });
|
|
142
|
+
}
|
|
136
143
|
if (error && typeof error === "object" && error.name === "ZodError") {
|
|
137
144
|
const issues = (error.issues ?? []).map((i) => `${i.path.join(".") || "request"}: ${i.message}`);
|
|
138
145
|
return problem(400, "malformed", `request is malformed: ${issues.join("; ")}`, { issues });
|
|
139
146
|
}
|
|
140
|
-
|
|
147
|
+
log(`hunch serve: 500 internal: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
|
|
148
|
+
return problem(500, "internal", "internal server error");
|
|
141
149
|
};
|
|
142
150
|
/** One authenticated state verb. The REST routes and the MCP tools both call this, so every
|
|
143
151
|
* rule — grants, the write lock, flushes, refusals — is the same whichever transport carried it. */
|
|
@@ -191,20 +199,53 @@ export function createServeApp(config, opts = {}) {
|
|
|
191
199
|
if (route === "write" || route === "capture" || route === "capture-batch") {
|
|
192
200
|
const { hunchDir } = stateHomeFor(store, scope);
|
|
193
201
|
const opts = { ...accessOptions, flush };
|
|
202
|
+
const lockOptions = { timeoutMs: writeLockTimeoutMs };
|
|
194
203
|
if (route === "write") {
|
|
195
|
-
const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, opts));
|
|
204
|
+
const result = await withWriteLock(hunchDir, () => writeState(store, { schema: STATE_WRITE_VERSION, principal, ...body }, opts), lockOptions);
|
|
196
205
|
return { status: result.outcome === "created" ? 201 : 200, payload: result };
|
|
197
206
|
}
|
|
198
207
|
if (route === "capture") {
|
|
199
|
-
const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, opts));
|
|
208
|
+
const result = await withWriteLock(hunchDir, () => captureState(store, { schema: STATE_CAPTURE_VERSION, principal, ...body }, opts), lockOptions);
|
|
200
209
|
return { status: result.outcome === "created" ? 201 : 200, payload: result };
|
|
201
210
|
}
|
|
202
|
-
return { status: 200, payload: await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, opts)) };
|
|
211
|
+
return { status: 200, payload: await withWriteLock(hunchDir, () => captureBatchState(store, { schema: STATE_CAPTURE_BATCH_VERSION, principal, ...body }, opts), lockOptions) };
|
|
203
212
|
}
|
|
204
213
|
if (route === "subscribe")
|
|
205
214
|
return { status: 200, payload: subscribeState(store, { schema: STATE_SUBSCRIBE_VERSION, principal, ...body }, accessOptions) };
|
|
206
215
|
return { status: 200, payload: recordsState(store, { schema: STATE_RECORDS_VERSION, principal, ...body }, accessOptions) };
|
|
207
216
|
};
|
|
217
|
+
/** The bearer/DPoP credential → the principal. Every authenticated route, and health when a
|
|
218
|
+
* credential is presented, goes through this one check. */
|
|
219
|
+
const authenticate = async (req, res, url, activeConfig) => {
|
|
220
|
+
const authorization = /^(Bearer|DPoP) ([^\s]+)$/i.exec(req.headers.authorization ?? '');
|
|
221
|
+
const credential = resolveCredential(activeConfig, authorization?.[2]);
|
|
222
|
+
if (!credential)
|
|
223
|
+
throw problem(401, 'unauthorized', 'valid credentials are required');
|
|
224
|
+
const countHeader = (name) => req.rawHeaders.filter((header, index) => index % 2 === 0 && header.toLowerCase() === name).length;
|
|
225
|
+
if (countHeader('authorization') !== 1 || countHeader('dpop') > 1)
|
|
226
|
+
throw problem(401, 'invalid_dpop_proof', 'ambiguous authentication headers');
|
|
227
|
+
if (credential.proof_key) {
|
|
228
|
+
if (authorization[1].toLowerCase() !== 'dpop')
|
|
229
|
+
throw problem(401, 'invalid_dpop_proof', 'this credential requires DPoP proof; bearer fallback is disabled');
|
|
230
|
+
if (!activeConfig.public_origin || !authStateDir)
|
|
231
|
+
throw problem(503, 'proof-state-unavailable', 'key-bound authentication requires a public origin and persistent proof state');
|
|
232
|
+
try {
|
|
233
|
+
await verifyStateProof({ proof: typeof req.headers.dpop === 'string' ? req.headers.dpop : undefined, key: credential.proof_key, method: req.method ?? '', url: activeConfig.public_origin + url.pathname, token: authorization[2], stateDir: authStateDir });
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (error instanceof StateProofError) {
|
|
237
|
+
res.setHeader('WWW-Authenticate', `DPoP error="${error.code}", algs="EdDSA"`);
|
|
238
|
+
if (error.nonce)
|
|
239
|
+
res.setHeader('DPoP-Nonce', error.nonce);
|
|
240
|
+
throw problem(401, error.code, error.message);
|
|
241
|
+
}
|
|
242
|
+
throw problem(503, 'proof-state-unavailable', 'proof replay state is unavailable; authentication is refused');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (authorization[1].toLowerCase() !== 'bearer' || req.headers.dpop !== undefined)
|
|
246
|
+
throw problem(401, 'invalid_dpop_proof', 'credential is not bound to a proof key');
|
|
247
|
+
return { id: credential.id, kind: credential.kind, grants: credential.grants, ...(credential.display ? { display: credential.display } : {}) };
|
|
248
|
+
};
|
|
208
249
|
const server = createServer(async (req, res) => {
|
|
209
250
|
try {
|
|
210
251
|
let activeConfig;
|
|
@@ -222,36 +263,15 @@ export function createServeApp(config, opts = {}) {
|
|
|
222
263
|
return res.end(asset[0]);
|
|
223
264
|
}
|
|
224
265
|
if (url.pathname === "/nuryel/v1/health" && req.method === "GET") {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
if (countHeader('authorization') !== 1 || countHeader('dpop') > 1)
|
|
233
|
-
throw problem(401, 'invalid_dpop_proof', 'ambiguous authentication headers');
|
|
234
|
-
if (credential.proof_key) {
|
|
235
|
-
if (authorization[1].toLowerCase() !== 'dpop')
|
|
236
|
-
throw problem(401, 'invalid_dpop_proof', 'this credential requires DPoP proof; bearer fallback is disabled');
|
|
237
|
-
if (!activeConfig.public_origin || !authStateDir)
|
|
238
|
-
throw problem(503, 'proof-state-unavailable', 'key-bound authentication requires a public origin and persistent proof state');
|
|
239
|
-
try {
|
|
240
|
-
await verifyStateProof({ proof: typeof req.headers.dpop === 'string' ? req.headers.dpop : undefined, key: credential.proof_key, method: req.method ?? '', url: activeConfig.public_origin + url.pathname, token: authorization[2], stateDir: authStateDir });
|
|
241
|
-
}
|
|
242
|
-
catch (error) {
|
|
243
|
-
if (error instanceof StateProofError) {
|
|
244
|
-
res.setHeader('WWW-Authenticate', `DPoP error="${error.code}", algs="EdDSA"`);
|
|
245
|
-
if (error.nonce)
|
|
246
|
-
res.setHeader('DPoP-Nonce', error.nonce);
|
|
247
|
-
throw problem(401, error.code, error.message);
|
|
248
|
-
}
|
|
249
|
-
throw problem(503, 'proof-state-unavailable', 'proof replay state is unavailable; authentication is refused');
|
|
250
|
-
}
|
|
266
|
+
// Liveness is public (a load balancer or proxy probe holds no token). Which
|
|
267
|
+
// partitions this server hosts is not: their ids name people and organizations.
|
|
268
|
+
const liveness = { ok: true, version, protocol: "nuryel.state/1" };
|
|
269
|
+
if (req.headers.authorization === undefined && req.headers.dpop === undefined)
|
|
270
|
+
return send(res, 200, liveness);
|
|
271
|
+
await authenticate(req, res, url, activeConfig);
|
|
272
|
+
return send(res, 200, { ...liveness, partitions: activeConfig.partitions.map((p) => scopePath(p.scope)) });
|
|
251
273
|
}
|
|
252
|
-
|
|
253
|
-
throw problem(401, 'invalid_dpop_proof', 'credential is not bound to a proof key');
|
|
254
|
-
const principal = { id: credential.id, kind: credential.kind, grants: credential.grants, ...(credential.display ? { display: credential.display } : {}) };
|
|
274
|
+
const principal = await authenticate(req, res, url, activeConfig);
|
|
255
275
|
if (url.pathname === "/nuryel/v1/capabilities" && req.method === "GET") {
|
|
256
276
|
const scope = parseScopeParam(url.searchParams.get("scope"));
|
|
257
277
|
const { status, payload } = await dispatch("capabilities", principal, scope ? { scope } : {}, activeConfig);
|
|
@@ -5,7 +5,7 @@ import { type Embedder } from "./embedder.js";
|
|
|
5
5
|
import { JsonStore } from "./jsonStore.js";
|
|
6
6
|
import { type RankingContext, type RankingQuery, type RankingWeights, type SlotOptions, type TaskSelection } from "../core/taskRanking.js";
|
|
7
7
|
import { type VetoTier } from "../core/strictgate.js";
|
|
8
|
-
import { type DiffAnalysis } from "../extractors/diff.js";
|
|
8
|
+
import { type DiffAnalysis, type DiffStatus } from "../extractors/diff.js";
|
|
9
9
|
import type { CheckReport, CausalWhy, ImpactReport } from "../core/checkreport.js";
|
|
10
10
|
import { type ReviewedLandscapeSelection } from "../core/landscapeDelivery.js";
|
|
11
11
|
import { type StateSlice } from "../core/stateDelivery.js";
|
|
@@ -384,7 +384,7 @@ export declare class HunchStore {
|
|
|
384
384
|
/** PR impact (read-only, ADVISORY — never gates): the dependency + memory surface
|
|
385
385
|
* of a change. Composes the SAME primitives as buildCheckReport (blast radius,
|
|
386
386
|
* scope-matched constraints, why) so impact and gating can never disagree. */
|
|
387
|
-
prImpact(files: string[], diff: string): ImpactReport;
|
|
387
|
+
prImpact(files: string[], diff: string, diffStatus?: DiffStatus): ImpactReport;
|
|
388
388
|
/** Structure view (hunch_structure / hunch structure): serve the indexed shape of
|
|
389
389
|
* the repo so an agent ORIENTS from the graph instead of running grep/glob rounds.
|
|
390
390
|
* Resolution: no target -> repo map; a directory -> its files+symbols; a file ->
|
|
@@ -446,6 +446,8 @@ export declare class HunchStore {
|
|
|
446
446
|
strict: boolean;
|
|
447
447
|
lastChange?: (f: string) => string;
|
|
448
448
|
publicOnly?: boolean;
|
|
449
|
+
/** Completeness of `diff` as produced for a gate (a GateDiff fits). */
|
|
450
|
+
diffStatus?: DiffStatus;
|
|
449
451
|
}): CheckReport;
|
|
450
452
|
/** Sprawl/"this already exists" guard (ADVISORY, never blocks). A symbol the diff
|
|
451
453
|
* ADDS whose name already exists in the indexed graph in a file NOT touched by the
|
package/dist/store/hunchStore.js
CHANGED
|
@@ -28,7 +28,7 @@ import { currentForTopic, isInForce } from "../core/topics.js";
|
|
|
28
28
|
import { edgeId } from "../core/ids.js";
|
|
29
29
|
import { isStrictBlocker, isVetoBlocker } from "../core/strictgate.js";
|
|
30
30
|
import { effectiveForbids, matchForbids } from "../core/constraintmatch.js";
|
|
31
|
-
import { analyzeDiff } from "../extractors/diff.js";
|
|
31
|
+
import { analyzeDiff, diffContentGaps } from "../extractors/diff.js";
|
|
32
32
|
import { selectReviewedLandscape, } from "../core/landscapeDelivery.js";
|
|
33
33
|
import { STATE_KINDS, STATE_SLICE_CAPS, compareStateHits, isStateKind, stateLiveness, stateObservedAt, stateSearchDoc, stateSubject, } from "../core/stateDelivery.js";
|
|
34
34
|
/** Git cannot resolve repository identity from a cwd that does not exist yet.
|
|
@@ -1332,7 +1332,7 @@ export class HunchStore {
|
|
|
1332
1332
|
/** PR impact (read-only, ADVISORY — never gates): the dependency + memory surface
|
|
1333
1333
|
* of a change. Composes the SAME primitives as buildCheckReport (blast radius,
|
|
1334
1334
|
* scope-matched constraints, why) so impact and gating can never disagree. */
|
|
1335
|
-
prImpact(files, diff) {
|
|
1335
|
+
prImpact(files, diff, diffStatus) {
|
|
1336
1336
|
const changed = new Set(files.map(toPosixTarget));
|
|
1337
1337
|
const blast = new Map();
|
|
1338
1338
|
for (const f of changed) {
|
|
@@ -1344,7 +1344,7 @@ export class HunchStore {
|
|
|
1344
1344
|
blast.set(b.file, b);
|
|
1345
1345
|
}
|
|
1346
1346
|
}
|
|
1347
|
-
const report = this.buildCheckReport([...changed], diff, { strict: false });
|
|
1347
|
+
const report = this.buildCheckReport([...changed], diff, { strict: false, diffStatus });
|
|
1348
1348
|
const decisions = new Map();
|
|
1349
1349
|
for (const f of changed) {
|
|
1350
1350
|
for (const d of this.why(f).decisions)
|
|
@@ -1639,6 +1639,7 @@ export class HunchStore {
|
|
|
1639
1639
|
});
|
|
1640
1640
|
const directReport = [];
|
|
1641
1641
|
const addedDepSet = new Set(an.addedDeps);
|
|
1642
|
+
const missingContent = diffContentGaps(diff, opts.diffStatus);
|
|
1642
1643
|
for (const { c, files: fs } of direct.values()) {
|
|
1643
1644
|
const forbids = effectiveForbids(c);
|
|
1644
1645
|
if (forbids) {
|
|
@@ -1646,11 +1647,27 @@ export class HunchStore {
|
|
|
1646
1647
|
// dep imported / symbol added / pattern matched in scoped code) — not by bare
|
|
1647
1648
|
// scope-touch. A commit that touches the scope but doesn't trip it COMPLIES → drop it
|
|
1648
1649
|
// (no noise). A real hit blocks WITHOUT the staleness gate: content is verified per
|
|
1649
|
-
// commit, so file churn can't retract the teeth (dec_e0a36efbf5).
|
|
1650
|
-
//
|
|
1650
|
+
// commit, so file churn can't retract the teeth (dec_e0a36efbf5). A COMPLETE diff with
|
|
1651
|
+
// no added lines for a file proves nothing was added there ⇒ clean.
|
|
1651
1652
|
const scopedAdded = fs.flatMap((f) => an.addedLinesByFile.get(f) ?? []);
|
|
1652
|
-
if (!matchForbids(forbids, addedDepSet, scopedAdded))
|
|
1653
|
+
if (!matchForbids(forbids, addedDepSet, scopedAdded)) {
|
|
1654
|
+
// …but when the diff is truncated, git could not produce it, or a file's content
|
|
1655
|
+
// could not be read, "no added lines" is not evidence of compliance. A blocking
|
|
1656
|
+
// rule over such a file is UNEVALUABLE and fails closed under the same strict gate
|
|
1657
|
+
// a proven hit would face (dec_20db57c576). Advisory/warning rules stay quiet.
|
|
1658
|
+
const unseen = missingContent ? fs.filter((f) => missingContent.missing(f)) : [];
|
|
1659
|
+
if (c.severity !== "blocking" || !unseen.length)
|
|
1660
|
+
continue;
|
|
1661
|
+
const strictBlocks = isStrictBlocker(c, false);
|
|
1662
|
+
directReport.push({
|
|
1663
|
+
id: c.id, severity: c.severity, statement: c.statement, rationale: c.rationale ?? "",
|
|
1664
|
+
files: fs, strictBlocks,
|
|
1665
|
+
downgrade: strictBlocks ? undefined : "low-confidence",
|
|
1666
|
+
unevaluable: { reason: missingContent.reason(unseen), files: unseen },
|
|
1667
|
+
why: this.causalChain(c.id),
|
|
1668
|
+
});
|
|
1653
1669
|
continue;
|
|
1670
|
+
}
|
|
1654
1671
|
const strictBlocks = isStrictBlocker(c, false);
|
|
1655
1672
|
directReport.push({
|
|
1656
1673
|
id: c.id, severity: c.severity ?? "advisory", statement: c.statement, rationale: c.rationale ?? "",
|
|
@@ -26,6 +26,7 @@ import { writeFileAtomic } from "../core/io.js";
|
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
import { z } from "zod";
|
|
28
28
|
import { appendChanges, latestSeqFor, readLedger } from "./changeLedger.js";
|
|
29
|
+
import { STATE_ONLY_FACETS } from "./replay.js";
|
|
29
30
|
import { hunchPaths } from "../core/paths.js";
|
|
30
31
|
import { decisionId } from "../core/ids.js";
|
|
31
32
|
import { ENTITY_KINDS, SCHEMAS } from "../core/types.js";
|
|
@@ -274,15 +275,18 @@ export function readState(store, input, options = {}) {
|
|
|
274
275
|
}
|
|
275
276
|
if (facets.has("derived"))
|
|
276
277
|
for (const d of store.recs("derived")) {
|
|
278
|
+
// Match the subject BEFORE admit, as every other facet does: admit names an ungranted
|
|
279
|
+
// partition in denied_scopes, and only a record that matches may name its partition.
|
|
280
|
+
// A linked observation must sit in the request's (granted) partition, so it never names one.
|
|
281
|
+
const direct = isSubject(d.subject) || d.id === subject;
|
|
282
|
+
const linked = scopePath(recordScope(d, repo)) === scopePath(request.scope) && linkedObservations.get(d.id)?.has(stateHash(d));
|
|
283
|
+
if (!direct && !linked)
|
|
284
|
+
continue;
|
|
277
285
|
const scope = admit("derived", d);
|
|
278
286
|
if (!scope)
|
|
279
287
|
continue;
|
|
280
288
|
if (request.observed_page && scopePath(scope) !== scopePath(request.scope))
|
|
281
289
|
continue;
|
|
282
|
-
const direct = isSubject(d.subject) || d.id === subject;
|
|
283
|
-
const linked = scopePath(scope) === scopePath(request.scope) && linkedObservations.get(d.id)?.has(stateHash(d));
|
|
284
|
-
if (!direct && !linked)
|
|
285
|
-
continue;
|
|
286
290
|
if (!direct) {
|
|
287
291
|
if (d.state === "unknown" && d.valid_to == null)
|
|
288
292
|
observations.push(d);
|
|
@@ -467,6 +471,16 @@ function subjectOf(facet, record) {
|
|
|
467
471
|
default: return undefined;
|
|
468
472
|
}
|
|
469
473
|
}
|
|
474
|
+
/** The published `ChangeEvent.subject` bound. Record subjects can be longer (a receipt's
|
|
475
|
+
* `object_type:object_key`, an entity id, a relationship endpoint, a decision topic); the record
|
|
476
|
+
* limits are never lowered and the event limit is never raised (dec_a9bfb5dd8d). */
|
|
477
|
+
const EVENT_SUBJECT_MAX = 512;
|
|
478
|
+
/** The subject an EVENT carries: the record's subject when it fits the event limit, otherwise
|
|
479
|
+
* omitted (`subject` is optional). A subscriber still matches the event by `record_id`. */
|
|
480
|
+
function eventSubjectOf(facet, record) {
|
|
481
|
+
const subject = subjectOf(facet, record);
|
|
482
|
+
return subject !== undefined && subject.length <= EVENT_SUBJECT_MAX ? subject : undefined;
|
|
483
|
+
}
|
|
470
484
|
/** Which facet a record id belongs to, from its prefix; `null` for a kind-qualified entity id
|
|
471
485
|
* or an unknown shape (those are looked up across every facet). */
|
|
472
486
|
function facetOfId(id) {
|
|
@@ -607,6 +621,29 @@ function closeWindow(store, facet, incumbentId, byId, at, isPrivate) {
|
|
|
607
621
|
store.putCapture(facet, closed, isPrivate);
|
|
608
622
|
return true;
|
|
609
623
|
}
|
|
624
|
+
/** Validate pending change events against the published event schema without writing anything,
|
|
625
|
+
* so a write whose event could not be appended is refused before its record lands (#283). */
|
|
626
|
+
function assertEventsValid(ledger, scope, at, changes) {
|
|
627
|
+
changes.forEach((change, i) => {
|
|
628
|
+
const parsed = ChangeEventSchema.safeParse({ schema: STATE_SUBSCRIBE_VERSION, seq: ledger.head_seq + i + 1, at, scope, ...change });
|
|
629
|
+
if (!parsed.success)
|
|
630
|
+
throw new StateRefusal("malformed", `change event for ${change.record_id} is malformed: ${parsed.error.issues.map((x) => `${x.path.join(".") || "event"}: ${x.message}`).join("; ")}`);
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
/** The event a replayed write owes the ledger (#282): a state-only record on file that the ledger
|
|
634
|
+
* has never seen — no event names it and no idempotency entry references it (compaction drops
|
|
635
|
+
* events but keeps the table, so an entry means "known") — was written without its event, by a
|
|
636
|
+
* crash between put and append or a refusal after the put. The retry appends it with the hash on
|
|
637
|
+
* file. A record the ledger already knows gets no new event. */
|
|
638
|
+
function missingEventFor(ledger, facet, onFile, principal) {
|
|
639
|
+
if (!STATE_ONLY_FACETS.includes(facet))
|
|
640
|
+
return [];
|
|
641
|
+
const id = String(onFile.id);
|
|
642
|
+
if (latestSeqFor(ledger, id) > 0 || Object.values(ledger.idempotency).some((e) => e.record_id === id))
|
|
643
|
+
return [];
|
|
644
|
+
const invalidates = facet === "receipts" && Array.isArray(onFile.invalidates) ? onFile.invalidates : [];
|
|
645
|
+
return [{ ...(onFile.visibility ? { visibility: onFile.visibility } : {}), facet, record_id: id, record_hash: stateHash(onFile), change: "created", subject: eventSubjectOf(facet, onFile), invalidates, cause: { kind: "write", principal } }];
|
|
646
|
+
}
|
|
610
647
|
/** Normalize + validate the record for its facet; enforce the identity rule (an id, when
|
|
611
648
|
* given, must be the one the record's facts derive). Returns the canonical record. */
|
|
612
649
|
function normalizeRecord(facet, scope, raw, principal) {
|
|
@@ -691,7 +728,17 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
691
728
|
catch (e) {
|
|
692
729
|
throw new StateRefusal(/grants/.test(e.message) ? "outside-grants" : "malformed", e.message);
|
|
693
730
|
}
|
|
731
|
+
const repo = partitionOf(store);
|
|
732
|
+
// Legacy kinds carry no partition scope: whatever home they land in, every reader (and the
|
|
733
|
+
// repository's edit gate) treats them as the store's OWN partition. Written under any other
|
|
734
|
+
// scope they would silently become that partition's records, so they are refused instead.
|
|
735
|
+
if (LEGACY_FACETS.has(request.facet) && scopePath(request.scope) !== scopePath(repo)) {
|
|
736
|
+
throw new StateRefusal("unsupported", `${request.facet} records belong to this store's own partition ${scopePath(repo)}; they cannot be written under ${scopePath(request.scope)} — use a partition-scoped facet (conventions, receipts, commitments, derived, entities, relationships) there`);
|
|
737
|
+
}
|
|
694
738
|
const { home, hunchDir, isPrivate } = stateHomeFor(store, request.scope);
|
|
739
|
+
/** Several partitions share one overlay home: a record counts for this write only when it is
|
|
740
|
+
* in the write's own partition. */
|
|
741
|
+
const inScope = (r) => scopePath(recordScope(r, repo)) === scopePath(request.scope);
|
|
695
742
|
const now = (opts.now ?? (() => new Date()))().toISOString();
|
|
696
743
|
const facet = request.facet;
|
|
697
744
|
const getHere = (id) => facet === "derived" || facet === "receipts" || facet === "commitments"
|
|
@@ -701,9 +748,40 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
701
748
|
throw new StateRefusal("unsupported", `facet ${facet} is not a store kind`);
|
|
702
749
|
const record = normalizeRecord(facet, request.scope, request.record, request.principal);
|
|
703
750
|
const deny = () => { throw new StateRefusal('outside-grants', 'record unavailable or operation not permitted'); };
|
|
751
|
+
const id = record.id;
|
|
752
|
+
/** The normalized PAYLOAD hash: what idempotency recognizes on a re-send. */
|
|
753
|
+
const hash = stateHash(record);
|
|
754
|
+
const ledger = opts.ledgerCache?.ledger ?? readLedger(hunchDir, request.scope);
|
|
755
|
+
if (opts.ledgerCache)
|
|
756
|
+
opts.ledgerCache.ledger = ledger;
|
|
757
|
+
const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
|
|
758
|
+
/** The result reports the record ON FILE and its hash — the store may enrich a record on put
|
|
759
|
+
* (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
|
|
760
|
+
* on this record must hold the hash a reader will verify, never a pre-store one. */
|
|
761
|
+
const result = (outcome, conflict = null, rid = id) => {
|
|
762
|
+
const onFile = getHere(rid) ?? record;
|
|
763
|
+
return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
|
|
764
|
+
};
|
|
765
|
+
// Idempotency, exact replay FIRST: the same key with the same payload for the same record is
|
|
766
|
+
// the retry of a request that already succeeded, and returns what it wrote — before any check
|
|
767
|
+
// that depends on state changed since (an entity now claiming the subject, a visibility or link
|
|
768
|
+
// change). Otherwise a writer whose response was lost is refused, re-derives, and duplicates
|
|
769
|
+
// the record it already holds (#284). A differing payload under the key falls through to every
|
|
770
|
+
// check and the idempotency refusal below.
|
|
771
|
+
const seen = ledger.idempotency[request.idempotency_key];
|
|
772
|
+
if (seen && seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash)) {
|
|
773
|
+
const prior = findRecord(store, seen.record_id)?.record;
|
|
774
|
+
if (prior && !access.canRead(prior))
|
|
775
|
+
deny();
|
|
776
|
+
return result("replayed");
|
|
777
|
+
}
|
|
704
778
|
const incumbent = getHere(String(record.id));
|
|
705
779
|
if (incumbent && !access.canWrite(incumbent))
|
|
706
780
|
deny();
|
|
781
|
+
// An id not derived from its scope (an entity, a relationship) can collide with another
|
|
782
|
+
// partition's record in a shared home: that record is never rewritten from this partition.
|
|
783
|
+
if (incumbent && !inScope(incumbent))
|
|
784
|
+
throw new StateRefusal("conflict", `${id} is on record in ${scopePath(recordScope(incumbent, repo))}, not ${scopePath(request.scope)}: a write never moves or rewrites another partition's record`, { incumbent_id: id, reason: "record id held by another partition" });
|
|
707
785
|
const previousVisibility = incumbent?.visibility;
|
|
708
786
|
const nextVisibility = record.visibility;
|
|
709
787
|
if (nextVisibility && home === 'private')
|
|
@@ -713,6 +791,10 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
713
791
|
if (supersededRecord) {
|
|
714
792
|
if (!access.canWrite(supersededRecord))
|
|
715
793
|
deny();
|
|
794
|
+
// Supersession stays inside one partition: closing another partition's record from here would
|
|
795
|
+
// land its `superseded` event in THIS ledger, invisible to that partition's subscribers.
|
|
796
|
+
if (!inScope(supersededRecord))
|
|
797
|
+
throw new StateRefusal("conflict", `supersedes ${request.supersedes} is on record in ${scopePath(recordScope(supersededRecord, repo))}, not ${scopePath(request.scope)}: a write supersedes only a record in its own partition`, { incumbent_id: String(supersededRecord.id), reason: "supersede target in another partition" });
|
|
716
798
|
if (stateHash(supersededVisibility ?? null) !== stateHash(nextVisibility ?? null)) {
|
|
717
799
|
if (supersededVisibility ? supersededVisibility.owner !== request.principal.id : request.principal.kind !== 'human')
|
|
718
800
|
deny();
|
|
@@ -761,30 +843,12 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
761
843
|
replayLink = prior;
|
|
762
844
|
}
|
|
763
845
|
}
|
|
764
|
-
const id = record.id;
|
|
765
846
|
assertExternalIdentity(store, request.principal, request.scope, facet, record);
|
|
766
|
-
|
|
767
|
-
const hash = stateHash(record);
|
|
768
|
-
const ledger = opts.ledgerCache?.ledger ?? readLedger(hunchDir, request.scope);
|
|
769
|
-
if (opts.ledgerCache)
|
|
770
|
-
opts.ledgerCache.ledger = ledger;
|
|
771
|
-
const durability = () => opts.flush?.(isPrivate, `nuryel: write ${id}`) ?? "local";
|
|
772
|
-
/** The result reports the record ON FILE and its hash — the store may enrich a record on put
|
|
773
|
-
* (a private-mode decision gains `valid_from`), and a writer that goes on to rest a receipt
|
|
774
|
-
* on this record must hold the hash a reader will verify, never a pre-store one. */
|
|
775
|
-
const result = (outcome, conflict = null, rid = id) => {
|
|
776
|
-
const onFile = getHere(rid) ?? record;
|
|
777
|
-
return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: rid, record_hash: stateHash(onFile), durability: durability(), outcome, conflict, record: onFile });
|
|
778
|
-
};
|
|
779
|
-
// Idempotency: the same key replays the original; the same key with a different payload
|
|
780
|
-
// is a refusal, never a second record.
|
|
781
|
-
const seen = ledger.idempotency[request.idempotency_key];
|
|
847
|
+
// Idempotency: the same key with a different payload is a refusal, never a second record.
|
|
782
848
|
if (seen) {
|
|
783
849
|
const prior = findRecord(store, seen.record_id)?.record;
|
|
784
850
|
if (prior && !access.canRead(prior))
|
|
785
851
|
deny();
|
|
786
|
-
if (seen.record_id === id && (seen.record_hash === hash || seen.payload_hash === hash))
|
|
787
|
-
return result("replayed");
|
|
788
852
|
// Say WHAT differs and what to do: a stable key with a varying payload (a timestamp, new
|
|
789
853
|
// wording) is the trap every writer falls into once; the refusal must teach the way out.
|
|
790
854
|
const stored = store.getRec(facet, seen.record_id);
|
|
@@ -799,7 +863,7 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
799
863
|
throw new StateRefusal('malformed', 'reviewer must be the initiating principal');
|
|
800
864
|
}
|
|
801
865
|
if (existing && stateHash(existing) === hash) {
|
|
802
|
-
appendChanges(hunchDir, request.scope,
|
|
866
|
+
appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, existing, request.principal.id), { key: request.idempotency_key, entry: { record_id: id, record_hash: hash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
|
|
803
867
|
return result("replayed");
|
|
804
868
|
}
|
|
805
869
|
if (existing && request.expected_version !== null) {
|
|
@@ -811,7 +875,7 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
811
875
|
}
|
|
812
876
|
if (replayLink) {
|
|
813
877
|
const recordHash = stateHash(replayLink);
|
|
814
|
-
appendChanges(hunchDir, request.scope,
|
|
878
|
+
appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, replayLink, request.principal.id), { key: request.idempotency_key, entry: { record_id: replayLink.id, record_hash: recordHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
|
|
815
879
|
return WriteResultSchema.parse({ schema: STATE_WRITE_VERSION, record_id: replayLink.id, record_hash: recordHash, record: replayLink, outcome: "replayed", conflict: null, durability: "local" });
|
|
816
880
|
}
|
|
817
881
|
// human-correction-outranks-agent-writes: what a human confirmed, an agent does not rewrite.
|
|
@@ -841,7 +905,7 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
841
905
|
};
|
|
842
906
|
const verdict = guard(existing, "overwrite");
|
|
843
907
|
if (verdict === "replay") {
|
|
844
|
-
appendChanges(hunchDir, request.scope,
|
|
908
|
+
appendChanges(hunchDir, request.scope, missingEventFor(ledger, facet, existing, request.principal.id), { key: request.idempotency_key, entry: { record_id: id, record_hash: stateHash(existing), payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
|
|
845
909
|
return result("replayed");
|
|
846
910
|
}
|
|
847
911
|
if (verdict === "keep-provenance")
|
|
@@ -878,7 +942,7 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
878
942
|
}
|
|
879
943
|
}
|
|
880
944
|
}
|
|
881
|
-
if (supersedes && !store.recsInHome(facet, home).some((r) => r.id === supersedes)) {
|
|
945
|
+
if (supersedes && !store.recsInHome(facet, home).some((r) => r.id === supersedes && inScope(r))) {
|
|
882
946
|
throw new StateRefusal("conflict", `supersedes ${supersedes} is not a ${facet} record in this partition`, { incumbent_id: supersedes, reason: "supersede target absent" });
|
|
883
947
|
}
|
|
884
948
|
if (supersedes === id)
|
|
@@ -892,7 +956,7 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
892
956
|
if (incumbent && "valid_to" in incumbent && incumbent.valid_to !== null) {
|
|
893
957
|
const subject = subjectOf(facet, incumbent);
|
|
894
958
|
const open = store.recsInHome(facet, home)
|
|
895
|
-
.filter((r) => subjectOf(facet, r) === subject && r.valid_to === null)
|
|
959
|
+
.filter((r) => inScope(r) && subjectOf(facet, r) === subject && r.valid_to === null)
|
|
896
960
|
.map((r) => r.id).sort();
|
|
897
961
|
if (!open.includes(id)) {
|
|
898
962
|
const current = open.length ? `the current ${facet} record for ${subject ?? "that subject"} is ${open.join(", ")}` : `no ${facet} record for ${subject ?? "that subject"} is open now`;
|
|
@@ -911,7 +975,7 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
911
975
|
const d = record;
|
|
912
976
|
const incumbent = store.recsInHome("derived", home).find((r) => {
|
|
913
977
|
const x = r;
|
|
914
|
-
return x.id !== id && x.id !== supersedes && x.subject === d.subject && x.transform_version === d.transform_version && x.state === "current" && x.valid_to === null;
|
|
978
|
+
return x.id !== id && x.id !== supersedes && inScope(x) && x.subject === d.subject && x.transform_version === d.transform_version && x.state === "current" && x.valid_to === null;
|
|
915
979
|
});
|
|
916
980
|
if (incumbent) {
|
|
917
981
|
throw new StateRefusal("conflict", `${d.subject} already has a current ${d.transform_version} statement ${incumbent.id}; pass supersedes: "${incumbent.id}" to replace it, or write that identity to update it`, { incumbent_id: incumbent.id, reason: "one-current-derived-per-subject-transform" });
|
|
@@ -922,6 +986,21 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
922
986
|
if (facet === "receipts")
|
|
923
987
|
assertRestsOn(store, request.principal, request.scope, record.rests_on ?? []);
|
|
924
988
|
const closedBy = facet === "commitments" ? assertClosedBy(store, request.principal, record) : null;
|
|
989
|
+
// Build and validate every change event BEFORE anything is written (#283): a refusal must never
|
|
990
|
+
// leave a record on file without its event. Only the hashes are filled in after the write (the
|
|
991
|
+
// store may enrich a record on put), and a sha256 always satisfies the event schema.
|
|
992
|
+
const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
|
|
993
|
+
// A current derived statement written back as stale is an INVALIDATION, not an update: the
|
|
994
|
+
// ledger says so, and names the external pointer that moved when the writer gives one.
|
|
995
|
+
const invalidated = facet === "derived" && !!existing && (existing.state === "current" || existing.state === "unknown") && record.state === "stale";
|
|
996
|
+
const invalidates = facet === "receipts" ? record.invalidates : [];
|
|
997
|
+
// An entity leaving service is a `retired` change (a merge names the survivor in the record).
|
|
998
|
+
const retired = (facet === "entities" || facet === "relationships") && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
|
|
999
|
+
const subject = eventSubjectOf(facet, record);
|
|
1000
|
+
const supersededPrior = supersedes ? store.getRec(facet, supersedes) : undefined;
|
|
1001
|
+
const supersededChange = (old) => ({ ...(old.visibility ? { visibility: old.visibility } : {}), facet, record_id: String(old.id), record_hash: stateHash(old), change: "superseded", subject: eventSubjectOf(facet, old), invalidates: [], cause });
|
|
1002
|
+
const ownChange = (recordHash) => ({ ...(nextVisibility ? { visibility: nextVisibility } : {}), facet, record_id: id, record_hash: recordHash, change: invalidated ? "invalidated" : retired ? "retired" : existing ? "updated" : "created", subject, invalidates: invalidated && subject ? [subject] : invalidates, cause });
|
|
1003
|
+
assertEventsValid(ledger, request.scope, now, [...(supersededPrior ? [supersededChange(supersededPrior)] : []), ownChange(hash)]);
|
|
925
1004
|
if (nextVisibility) {
|
|
926
1005
|
// Publish the fail-closed old-reader gate BEFORE protected bytes. An interrupted
|
|
927
1006
|
// write can leave a gate without a record, never a record without the gate.
|
|
@@ -932,22 +1011,12 @@ function writeStateAuthorized(store, input, opts, access) {
|
|
|
932
1011
|
/** What is on file now — the hash every event, ref and result carries. */
|
|
933
1012
|
const onFileHash = stateHash(getHere(id) ?? record);
|
|
934
1013
|
const changes = [];
|
|
935
|
-
const cause = closedBy ? { kind: "receipt", receipt_id: closedBy } : request.cause ?? { kind: "write", principal: request.principal.id };
|
|
936
|
-
// A current derived statement written back as stale is an INVALIDATION, not an update: the
|
|
937
|
-
// ledger says so, and names the external pointer that moved when the writer gives one.
|
|
938
|
-
const invalidated = facet === "derived" && !!existing && (existing.state === "current" || existing.state === "unknown") && record.state === "stale";
|
|
939
|
-
const invalidates = facet === "receipts" ? record.invalidates : [];
|
|
940
|
-
// An entity leaving service is a `retired` change (a merge names the survivor in the record).
|
|
941
|
-
const retired = (facet === "entities" || facet === "relationships") && record.lifecycle === "retired" && (!existing || existing.lifecycle !== "retired");
|
|
942
|
-
const subject = subjectOf(facet, record);
|
|
943
1014
|
if (supersedes) {
|
|
944
1015
|
const closed = closeWindow(store, facet, supersedes, id, now, isPrivate);
|
|
945
|
-
if (closed)
|
|
946
|
-
|
|
947
|
-
changes.push({ ...("visibility" in old && old.visibility ? { visibility: old.visibility } : {}), facet, record_id: supersedes, record_hash: stateHash(old), change: "superseded", subject: subjectOf(facet, old), invalidates: [], cause });
|
|
948
|
-
}
|
|
1016
|
+
if (closed)
|
|
1017
|
+
changes.push(supersededChange(store.getRec(facet, supersedes)));
|
|
949
1018
|
}
|
|
950
|
-
changes.push(
|
|
1019
|
+
changes.push(ownChange(onFileHash));
|
|
951
1020
|
appendChanges(hunchDir, request.scope, changes, { key: request.idempotency_key, entry: { record_id: id, record_hash: onFileHash, payload_hash: hash, facet } }, now, opts.ledgerCache?.ledger);
|
|
952
1021
|
if (!opts.deferReindex)
|
|
953
1022
|
store.reindex();
|
package/dist/wiki/wiki.d.ts
CHANGED
|
@@ -146,7 +146,14 @@ export interface NowItem {
|
|
|
146
146
|
date: string;
|
|
147
147
|
/** decision text for recent items; CONTEXT (the why-it's-planned) for roadmap items. */
|
|
148
148
|
note: string;
|
|
149
|
+
/** Roadmap only: true when the item is agent testimony no human has confirmed yet.
|
|
150
|
+
* Omitted (never false) otherwise, so pages rendered before this field stay fresh. */
|
|
151
|
+
unconfirmed?: true;
|
|
149
152
|
}
|
|
153
|
+
/** Marker for an unconfirmed roadmap item, naming the human countersign command. */
|
|
154
|
+
export declare function unconfirmedRoadmapMarker(item: Pick<NowItem, "id" | "unconfirmed">, opts?: {
|
|
155
|
+
private?: boolean;
|
|
156
|
+
}): string;
|
|
150
157
|
/** The hot view's inputs: last `recentLimit` decisions by date (any status — a
|
|
151
158
|
* supersession IS activity), and every live PROPOSED decision (the roadmap:
|
|
152
159
|
* record intent as a proposed decision; accepting or superseding it removes it
|
package/dist/wiki/wiki.js
CHANGED
|
@@ -33,6 +33,7 @@ import { writeFileAtomic } from "../core/io.js";
|
|
|
33
33
|
import { compareCodeUnits } from "../core/canonicalOrder.js";
|
|
34
34
|
import { hunchPaths, toPosixTarget } from "../core/paths.js";
|
|
35
35
|
import { isLive } from "../core/topics.js";
|
|
36
|
+
import { confirmCommand, isAgentTestimony } from "../core/countersign.js";
|
|
36
37
|
import { scanRepoDocs } from "../core/docscan.js";
|
|
37
38
|
import { adoptedSlug, adoptionHash, renderAdoptedDoc } from "./adopt.js";
|
|
38
39
|
import { assembleGraphData, renderGraphPage } from "./graph.js";
|
|
@@ -396,6 +397,10 @@ const NOW_ID = "_now";
|
|
|
396
397
|
const GRAPH_ID = "_graph";
|
|
397
398
|
/** Manifest component-id prefix for adopted (wiki-managed) doc copies. */
|
|
398
399
|
const ADOPTED_PREFIX = "doc:";
|
|
400
|
+
/** Marker for an unconfirmed roadmap item, naming the human countersign command. */
|
|
401
|
+
export function unconfirmedRoadmapMarker(item, opts = {}) {
|
|
402
|
+
return item.unconfirmed ? `⚠ unconfirmed agent testimony — confirm: ${confirmCommand(item.id, opts)}` : "";
|
|
403
|
+
}
|
|
399
404
|
/** The hot view's inputs: last `recentLimit` decisions by date (any status — a
|
|
400
405
|
* supersession IS activity), and every live PROPOSED decision (the roadmap:
|
|
401
406
|
* record intent as a proposed decision; accepting or superseding it removes it
|
|
@@ -405,15 +410,21 @@ export function nowData(decisions, recentLimit = 10) {
|
|
|
405
410
|
const byDateDesc = (a, b) => compareCodeUnits(b.valid_from ?? b.date, a.valid_from ?? a.date) || compareCodeUnits(a.id, b.id);
|
|
406
411
|
const recent = [...decisions].sort(byDateDesc).slice(0, recentLimit)
|
|
407
412
|
.map((d) => ({ id: d.id, topic: d.topic, title: d.title, status: d.status, date: (d.valid_from ?? d.date).slice(0, 10), note: clip1(d.decision) }));
|
|
408
|
-
// Roadmap = INTENT
|
|
409
|
-
//
|
|
410
|
-
//
|
|
413
|
+
// Roadmap = deliberately recorded INTENT: human-vouched, or recorded by an agent
|
|
414
|
+
// (hunch_record_decision, e.g. at the end of /capture) and not yet confirmed. The latter
|
|
415
|
+
// is shown MARKED with the human confirm command — never hidden, and never routed to
|
|
416
|
+
// `adopt-drafts` (which would accept it). Auto-synthesized drafts are also status
|
|
417
|
+
// "proposed", but they describe work already done and belong to the review queue
|
|
418
|
+
// (`hunch review`) — surfacing them here would bury real plans.
|
|
411
419
|
const live = decisions.filter((d) => d.status === "proposed" && !d.superseded_by && !d.valid_to);
|
|
412
|
-
const
|
|
413
|
-
const roadmap =
|
|
420
|
+
const intent = live.filter((d) => d.provenance.source.includes("human_confirmed") || isAgentTestimony(d.provenance.source));
|
|
421
|
+
const roadmap = intent
|
|
414
422
|
.sort(byDateDesc)
|
|
415
|
-
.map((d) => ({
|
|
416
|
-
|
|
423
|
+
.map((d) => ({
|
|
424
|
+
id: d.id, topic: d.topic, title: d.title, status: d.status, date: (d.valid_from ?? d.date).slice(0, 10), note: clip1(d.context || d.decision),
|
|
425
|
+
...(isAgentTestimony(d.provenance.source) ? { unconfirmed: true } : {}),
|
|
426
|
+
}));
|
|
427
|
+
return { recent, roadmap, pendingReview: live.length - intent.length };
|
|
417
428
|
}
|
|
418
429
|
/** The hot file — a DERIVED view like every other page: what just happened
|
|
419
430
|
* (last N decisions) and what's next (live proposed decisions). No topic pins
|
|
@@ -436,7 +447,7 @@ export function renderNowPage(recent, roadmap, home, pendingReview = 0) {
|
|
|
436
447
|
if (!roadmap.length)
|
|
437
448
|
L.push("_Empty. Record what's next as a PROPOSED decision (`/capture`, status: proposed) and it appears here._", "");
|
|
438
449
|
for (const r of roadmap)
|
|
439
|
-
L.push(`- **${r.title}** — ${r.note || "(no context)"} _(${r.id}${r.topic ? `, topic \`${r.topic}\`` : ""}, since ${r.date})_`);
|
|
450
|
+
L.push(`- **${r.title}** — ${r.note || "(no context)"} _(${r.id}${r.topic ? `, topic \`${r.topic}\`` : ""}, since ${r.date})_${r.unconfirmed ? ` — ${unconfirmedRoadmapMarker(r, { private: home.kind === "private" })}` : ""}`);
|
|
440
451
|
if (roadmap.length)
|
|
441
452
|
L.push("");
|
|
442
453
|
if (pendingReview > 0)
|
package/package.json
CHANGED
package/server.json
CHANGED
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
9
|
"websiteUrl": "https://www.hunchmemory.com",
|
|
10
|
-
"version": "1.39.
|
|
10
|
+
"version": "1.39.2",
|
|
11
11
|
"packages": [
|
|
12
12
|
{
|
|
13
13
|
"registryType": "npm",
|
|
14
14
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
15
15
|
"identifier": "@davesheffer/hunch",
|
|
16
|
-
"version": "1.39.
|
|
16
|
+
"version": "1.39.2",
|
|
17
17
|
"runtimeHint": "npx",
|
|
18
18
|
"packageArguments": [
|
|
19
19
|
{
|