@nurix/nustack 0.23.14 → 0.23.16
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/commands/obligationList.js +75 -0
- package/dist/commands/recordAliases.js +8 -0
- package/dist/commands/records.js +59 -36
- package/dist/commands/session.js +222 -0
- package/dist/lib/harness/hookCheck.js +8 -1
- package/dist/lib/local_store/paths.js +12 -0
- package/dist/lib/records/refusalLog.js +2 -0
- package/dist/lib/session-engine/claudeAdapter.js +1 -1
- package/dist/lib/session-engine/supervisor.js +27 -2
- package/dist/lib/sessions/brief.js +102 -0
- package/dist/lib/sessions/client.js +25 -0
- package/dist/lib/sessions/hookInput.js +38 -0
- package/dist/lib/sessions/memoryShip.js +89 -0
- package/dist/lib/sessions/mountFacts.js +42 -0
- package/dist/lib/sessions/outsideSessions.js +1 -0
- package/dist/lib/sessions/sessionEvents.js +203 -0
- package/dist/lib/sessions/sessionKey.js +16 -0
- package/dist/program.js +3 -1
- package/dist/records.schema.json +430 -0
- package/dist/vendor/harness/rules/bookkeeping.js +44 -0
- package/dist/vendor/harness/rules/changeRules.js +8 -1
- package/dist/vendor/harness/rules/index.js +10 -2
- package/dist/vendor/harness/rules/planKey.js +49 -0
- package/package.json +3 -3
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { obligationsCachePath } from "../lib/local_store/paths.js";
|
|
2
|
+
import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
|
|
3
|
+
import { resolveProjectId } from "../lib/project_context.js";
|
|
4
|
+
import { getFromDoor, readCache, writeCache } from "../lib/sessions/brief.js";
|
|
5
|
+
const MAX_PAGES = 100;
|
|
6
|
+
const PAGE_TIMEOUT_MS = 10_000;
|
|
7
|
+
function lineOf(item) {
|
|
8
|
+
if (typeof item.naturalKey !== "string")
|
|
9
|
+
return null;
|
|
10
|
+
const payload = (item.payload ?? {});
|
|
11
|
+
return {
|
|
12
|
+
naturalKey: item.naturalKey,
|
|
13
|
+
state: typeof payload.state === "string" ? payload.state : "unknown",
|
|
14
|
+
verdict: typeof payload.outcome?.verdict === "string" ? payload.outcome.verdict : null,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
async function fetchAll(root, globals, projectId, plan) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
let cursor = null;
|
|
20
|
+
for (let page = 0; page < MAX_PAGES; page += 1) {
|
|
21
|
+
const query = `/records?kind=obligation&planAnchor=${encodeURIComponent(plan)}&limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
|
|
22
|
+
const answered = await getFromDoor(root, globals, projectId, query, PAGE_TIMEOUT_MS);
|
|
23
|
+
const items = answered?.data?.items;
|
|
24
|
+
if (!answered || !Array.isArray(items))
|
|
25
|
+
return null;
|
|
26
|
+
for (const item of items) {
|
|
27
|
+
const line = lineOf(item);
|
|
28
|
+
if (line)
|
|
29
|
+
lines.push(line);
|
|
30
|
+
}
|
|
31
|
+
cursor = answered.meta?.nextCursor ?? null;
|
|
32
|
+
if (!cursor)
|
|
33
|
+
return lines;
|
|
34
|
+
}
|
|
35
|
+
return lines;
|
|
36
|
+
}
|
|
37
|
+
export async function runObligationList(options, globals) {
|
|
38
|
+
const root = process.cwd();
|
|
39
|
+
const project = await resolveProjectId(root, options.project ? { project: options.project } : {});
|
|
40
|
+
if (!project.ok) {
|
|
41
|
+
if (options.json)
|
|
42
|
+
printJsonError("obligation list", { code: "NO_PROJECT", message: project.message });
|
|
43
|
+
else {
|
|
44
|
+
process.stderr.write(`obligation list: ${project.message}\n`);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const cacheFile = obligationsCachePath(project.projectId, options.plan);
|
|
50
|
+
const fetched = await fetchAll(root, globals, project.projectId, options.plan);
|
|
51
|
+
let obligations;
|
|
52
|
+
let source;
|
|
53
|
+
let fetchedAt = null;
|
|
54
|
+
if (fetched) {
|
|
55
|
+
writeCache(cacheFile, fetched);
|
|
56
|
+
obligations = fetched;
|
|
57
|
+
source = "server";
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const cached = readCache(cacheFile);
|
|
61
|
+
obligations = cached?.value ?? [];
|
|
62
|
+
fetchedAt = cached?.fetchedAt ?? null;
|
|
63
|
+
source = "cache";
|
|
64
|
+
}
|
|
65
|
+
if (options.json) {
|
|
66
|
+
printJsonSuccess("obligation list", { plan: options.plan, projectId: project.projectId, source, fetchedAt, obligations });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (source === "cache")
|
|
70
|
+
process.stderr.write(`obligation list: offline; the cached list${fetchedAt ? ` from ${fetchedAt}` : " is empty"}\n`);
|
|
71
|
+
for (const line of obligations)
|
|
72
|
+
process.stdout.write(`${line.naturalKey} ${line.state}${line.verdict ? ` ${line.verdict}` : ""}\n`);
|
|
73
|
+
if (obligations.length === 0)
|
|
74
|
+
process.stdout.write(`obligation list: no obligations under ${options.plan}\n`);
|
|
75
|
+
}
|
|
@@ -3,6 +3,7 @@ import { openLocalStore } from "../lib/local_store/database.js";
|
|
|
3
3
|
import { idempotencyKeyOf, splitObligationKey } from "../lib/records/naturalKey.js";
|
|
4
4
|
import { findRecordByKey } from "../lib/records/recordsTable.js";
|
|
5
5
|
import { emitRows, refuseEmit, resolveCaller } from "./records.js";
|
|
6
|
+
import { runObligationList } from "./obligationList.js";
|
|
6
7
|
export const RECORD_ALIAS_GROUPS = ["obligation", "backlog", "changelog", "journal"];
|
|
7
8
|
const VERDICTS_NEEDING_REASON = new Set(["owes_nothing", "blocked", "dismissed"]);
|
|
8
9
|
const PLAN_ANCHOR = /^\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*$/;
|
|
@@ -152,6 +153,13 @@ export function registerRecordAliases(program, getGlobalOptions) {
|
|
|
152
153
|
.requiredOption("--key <naturalKey>", "the obligation's natural key, <origin>#<type>:<subject>")
|
|
153
154
|
.requiredOption("--outcome <verdict>", "updated, satisfied, fixed, owes_nothing, blocked or dismissed")
|
|
154
155
|
.option("--reason <text>", "why; required for owes_nothing, blocked and dismissed")).action(async (options) => runObligationDischarge(options, getGlobalOptions()));
|
|
156
|
+
obligation
|
|
157
|
+
.command("list")
|
|
158
|
+
.description("List a plan's obligations as NuStack holds them (the cached list when offline)")
|
|
159
|
+
.requiredOption("--plan <anchor>", "the plan anchor")
|
|
160
|
+
.option("--project <id>", "the Project, when the folder carries no binding marker")
|
|
161
|
+
.option("--json", "emit the versioned machine envelope")
|
|
162
|
+
.action(async (options) => runObligationList(options, getGlobalOptions()));
|
|
155
163
|
withCommon(program
|
|
156
164
|
.command("backlog")
|
|
157
165
|
.description("Log backlog items (harness records)")
|
package/dist/commands/records.js
CHANGED
|
@@ -11,6 +11,7 @@ import { DEVICE_TOKEN_VARIABLE, DRAIN_CHILD_VARIABLE, SESSION_ID_VARIABLE, isFla
|
|
|
11
11
|
import { UNKNOWN_KIND_MESSAGE, assembleRecord, parseRowsText, translateRow, } from "../lib/records/recordInput.js";
|
|
12
12
|
import { AgentOwnedError, OBLIGATION_AGENT_OWNED, countRecords, hasDueRecord, listRecords, releaseUnboundRows, writeRecords, } from "../lib/records/recordsTable.js";
|
|
13
13
|
import { logRefusals } from "../lib/records/refusalLog.js";
|
|
14
|
+
import { drainSessionLegs } from "../lib/sessions/sessionEvents.js";
|
|
14
15
|
async function readStdin() {
|
|
15
16
|
if (process.stdin.isTTY)
|
|
16
17
|
return "";
|
|
@@ -35,23 +36,25 @@ export async function resolveCaller(request) {
|
|
|
35
36
|
},
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
+
async function logRefusal(request, gate, message, globals, caller) {
|
|
39
40
|
const who = caller ?? (await resolveCaller(request).catch(() => null));
|
|
40
|
-
if (who)
|
|
41
|
+
if (!who)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
const store = await openLocalStore();
|
|
41
45
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (logRefusals(store, [{ gate, verdict: "block", target: request.target, reason: message }], who.refusalContext) > 0) {
|
|
45
|
-
startDetachedDrain({ nustackUrl: globals.nustackUrl });
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
finally {
|
|
49
|
-
store.close();
|
|
46
|
+
if (logRefusals(store, [{ gate, verdict: "block", target: request.target, reason: message }], who.refusalContext) > 0) {
|
|
47
|
+
startDetachedDrain({ nustackUrl: globals.nustackUrl });
|
|
50
48
|
}
|
|
51
49
|
}
|
|
52
|
-
|
|
50
|
+
finally {
|
|
51
|
+
store.close();
|
|
53
52
|
}
|
|
54
53
|
}
|
|
54
|
+
catch {
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function printRefusal(request, code, message) {
|
|
55
58
|
if (request.json)
|
|
56
59
|
printJsonError(request.command, { code, message });
|
|
57
60
|
else {
|
|
@@ -59,9 +62,16 @@ export async function refuseEmit(request, code, gate, message, globals, caller)
|
|
|
59
62
|
process.exitCode = 1;
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
|
-
export async function
|
|
65
|
+
export async function refuseEmit(request, code, gate, message, globals, caller) {
|
|
66
|
+
await logRefusal(request, gate, message, globals, caller);
|
|
67
|
+
printRefusal(request, code, message);
|
|
68
|
+
}
|
|
69
|
+
export async function writeRecordsLocally(request, globals) {
|
|
63
70
|
const caller = await resolveCaller(request);
|
|
64
|
-
const refuse = (code, gate, message) =>
|
|
71
|
+
const refuse = async (code, gate, message) => {
|
|
72
|
+
await logRefusal(request, gate, message, globals, caller);
|
|
73
|
+
return { ok: false, code, message };
|
|
74
|
+
};
|
|
65
75
|
if (!isEmitKind(request.kind))
|
|
66
76
|
return refuse("VALIDATION_FAILED", "records-unknown-kind", UNKNOWN_KIND_MESSAGE);
|
|
67
77
|
const kind = request.kind;
|
|
@@ -110,37 +120,44 @@ export async function emitRows(request, globals) {
|
|
|
110
120
|
owned = error;
|
|
111
121
|
written = [];
|
|
112
122
|
}
|
|
113
|
-
if (owned)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
+
if (!owned) {
|
|
124
|
+
if (caller.projectId !== null)
|
|
125
|
+
releaseUnboundRows(store, caller.root);
|
|
126
|
+
logRefusals(store, flags.map((flag) => ({
|
|
127
|
+
gate: "secret-scan",
|
|
128
|
+
verdict: "flag",
|
|
129
|
+
target: request.target,
|
|
130
|
+
reason: `redacted a ${flag.patternId} match at ${flag.path}`,
|
|
131
|
+
})), caller.refusalContext);
|
|
132
|
+
}
|
|
123
133
|
}
|
|
124
134
|
finally {
|
|
125
135
|
store.close();
|
|
126
|
-
if (owned)
|
|
127
|
-
await refuse(OBLIGATION_AGENT_OWNED, "agent-owned-obligation", owned.message);
|
|
128
136
|
}
|
|
129
137
|
if (owned)
|
|
130
|
-
return;
|
|
138
|
+
return refuse(OBLIGATION_AGENT_OWNED, "agent-owned-obligation", owned.message);
|
|
131
139
|
if (flags.length > 0 || written.some((row) => row.syncState === "pending"))
|
|
132
140
|
startDetachedDrain({ nustackUrl: globals.nustackUrl });
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
data: {
|
|
144
|
+
written: written.length,
|
|
145
|
+
keys: written.map((row) => row.idempotencyKey),
|
|
146
|
+
local: written.filter((row) => row.syncState === "local").length,
|
|
147
|
+
flags,
|
|
148
|
+
projectId: caller.projectId,
|
|
149
|
+
},
|
|
139
150
|
};
|
|
151
|
+
}
|
|
152
|
+
export async function emitRows(request, globals) {
|
|
153
|
+
const outcome = await writeRecordsLocally(request, globals);
|
|
154
|
+
if (!outcome.ok)
|
|
155
|
+
return printRefusal(request, outcome.code, outcome.message);
|
|
156
|
+
const data = outcome.data;
|
|
140
157
|
if (request.json)
|
|
141
158
|
printJsonSuccess(request.command, data);
|
|
142
159
|
else {
|
|
143
|
-
for (const flag of flags)
|
|
160
|
+
for (const flag of data.flags)
|
|
144
161
|
process.stderr.write(`${request.command}: redacted a ${flag.patternId} match at ${flag.path}\n`);
|
|
145
162
|
process.stdout.write(`${request.command}: wrote ${data.written} record(s) locally; sending in the background\n`);
|
|
146
163
|
}
|
|
@@ -159,12 +176,18 @@ export async function runRecordsEmit(options, globals) {
|
|
|
159
176
|
}, globals);
|
|
160
177
|
}
|
|
161
178
|
async function drainAs(store, globals, isChild) {
|
|
162
|
-
if (!isChild)
|
|
163
|
-
|
|
179
|
+
if (!isChild) {
|
|
180
|
+
const drained = await drainRecords(store, globals, { isForeground: true });
|
|
181
|
+
await drainSessionLegs(store, globals);
|
|
182
|
+
return drained;
|
|
183
|
+
}
|
|
164
184
|
if (!acquireDrainLock())
|
|
165
185
|
return null;
|
|
166
186
|
try {
|
|
167
|
-
|
|
187
|
+
const drained = await drainRecords(store, globals, { onPass: () => refreshDrainLock() });
|
|
188
|
+
refreshDrainLock();
|
|
189
|
+
await drainSessionLegs(store, globals);
|
|
190
|
+
return drained;
|
|
168
191
|
}
|
|
169
192
|
finally {
|
|
170
193
|
releaseDrainLock();
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { appendFileSync } from "node:fs";
|
|
2
|
+
import { openLocalStore } from "../lib/local_store/database.js";
|
|
3
|
+
import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
|
|
4
|
+
import { resolveProjectId } from "../lib/project_context.js";
|
|
5
|
+
import { startDetachedDrain } from "../lib/records/drain.js";
|
|
6
|
+
import { SESSION_ID_VARIABLE, readVariable } from "../lib/records/environment.js";
|
|
7
|
+
import { countRefusals } from "../lib/records/refusalLog.js";
|
|
8
|
+
import { scanPayload } from "../lib/records/secretScan.js";
|
|
9
|
+
import { readBrief, renderBrief, renderHookOutput } from "../lib/sessions/brief.js";
|
|
10
|
+
import { parseHookInput, readAllStdin, resolveModel } from "../lib/sessions/hookInput.js";
|
|
11
|
+
import { shipChangedMemory } from "../lib/sessions/memoryShip.js";
|
|
12
|
+
import { mountFacts } from "../lib/sessions/mountFacts.js";
|
|
13
|
+
import { isSessionRuntime, sessionRowIdOf } from "../lib/sessions/sessionKey.js";
|
|
14
|
+
import { readSpool, spoolEvent } from "../lib/sessions/sessionEvents.js";
|
|
15
|
+
import { writeRecordsLocally } from "./records.js";
|
|
16
|
+
async function hookContext(runtimeFlag, raw) {
|
|
17
|
+
if (!isSessionRuntime(runtimeFlag))
|
|
18
|
+
return null;
|
|
19
|
+
const projectDir = readVariable("CLAUDE_PROJECT_DIR");
|
|
20
|
+
if (runtimeFlag === "claude_code" && !projectDir)
|
|
21
|
+
return null;
|
|
22
|
+
const input = parseHookInput(raw);
|
|
23
|
+
const engineRowId = readVariable(SESSION_ID_VARIABLE);
|
|
24
|
+
if (!engineRowId && !input.sessionId)
|
|
25
|
+
return null;
|
|
26
|
+
const root = projectDir ?? input.cwd ?? process.cwd();
|
|
27
|
+
const project = await resolveProjectId(root, {});
|
|
28
|
+
if (!project.ok)
|
|
29
|
+
return null;
|
|
30
|
+
return {
|
|
31
|
+
input,
|
|
32
|
+
runtime: runtimeFlag,
|
|
33
|
+
root,
|
|
34
|
+
projectId: project.projectId,
|
|
35
|
+
rowId: engineRowId ?? sessionRowIdOf(runtimeFlag, input.sessionId),
|
|
36
|
+
isEngineSession: engineRowId !== null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const identityOf = (context) => ({
|
|
40
|
+
rowId: context.rowId,
|
|
41
|
+
projectId: context.projectId,
|
|
42
|
+
root: context.root,
|
|
43
|
+
runtime: context.runtime,
|
|
44
|
+
runtimeSessionId: context.input.sessionId,
|
|
45
|
+
isEngineSession: context.isEngineSession,
|
|
46
|
+
});
|
|
47
|
+
async function refusalCountOf(projectId) {
|
|
48
|
+
try {
|
|
49
|
+
const store = await openLocalStore();
|
|
50
|
+
try {
|
|
51
|
+
return countRefusals(store, { projectId });
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
store.close();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async function runBriefHook(options, globals) {
|
|
62
|
+
const context = await hookContext(options.runtime, await readAllStdin());
|
|
63
|
+
if (!context)
|
|
64
|
+
return;
|
|
65
|
+
const facts = await mountFacts(context.root, context.runtime);
|
|
66
|
+
const model = resolveModel(context.input);
|
|
67
|
+
const event = {
|
|
68
|
+
sessionId: context.rowId,
|
|
69
|
+
...(context.input.sessionId ? { runtimeSessionId: context.input.sessionId } : {}),
|
|
70
|
+
phase: "start",
|
|
71
|
+
occurredAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
72
|
+
extension: {
|
|
73
|
+
runtime: context.runtime,
|
|
74
|
+
...(model ? { model } : {}),
|
|
75
|
+
...(facts.harnessSemver ? { harnessSemver: facts.harnessSemver } : {}),
|
|
76
|
+
...(facts.harnessDigest ? { harnessDigest: facts.harnessDigest } : {}),
|
|
77
|
+
...(facts.configDigest ? { configDigest: facts.configDigest } : {}),
|
|
78
|
+
},
|
|
79
|
+
isEngineSession: context.isEngineSession,
|
|
80
|
+
};
|
|
81
|
+
spoolEvent(identityOf(context), event);
|
|
82
|
+
const envFile = readVariable("CLAUDE_ENV_FILE");
|
|
83
|
+
if (envFile) {
|
|
84
|
+
try {
|
|
85
|
+
appendFileSync(envFile, `export ${SESSION_ID_VARIABLE}=${context.rowId}\n`);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
startDetachedDrain({ nustackUrl: globals.nustackUrl });
|
|
91
|
+
const brief = await readBrief(context.root, globals, context.projectId, context.rowId, await refusalCountOf(context.projectId));
|
|
92
|
+
process.stdout.write(`${renderHookOutput(brief)}\n`);
|
|
93
|
+
}
|
|
94
|
+
export async function runSessionBrief(options, globals) {
|
|
95
|
+
if (options.hook) {
|
|
96
|
+
try {
|
|
97
|
+
await runBriefHook(options, globals);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
}
|
|
101
|
+
process.exitCode = 0;
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const root = process.cwd();
|
|
105
|
+
const project = await resolveProjectId(root, options.project ? { project: options.project } : {});
|
|
106
|
+
if (!project.ok) {
|
|
107
|
+
if (options.json)
|
|
108
|
+
printJsonError("session brief", { code: "NO_PROJECT", message: project.message });
|
|
109
|
+
else {
|
|
110
|
+
process.stderr.write(`session brief: ${project.message}\n`);
|
|
111
|
+
process.exitCode = 1;
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const sessionId = options.session ?? readVariable(SESSION_ID_VARIABLE);
|
|
116
|
+
const brief = await readBrief(root, globals, project.projectId, sessionId, await refusalCountOf(project.projectId));
|
|
117
|
+
if (options.json)
|
|
118
|
+
printJsonSuccess("session brief", brief);
|
|
119
|
+
else
|
|
120
|
+
process.stdout.write(`${renderBrief(brief)}\n`);
|
|
121
|
+
}
|
|
122
|
+
async function handoverOf(flag) {
|
|
123
|
+
if (flag === undefined)
|
|
124
|
+
return null;
|
|
125
|
+
const raw = (flag === "-" ? await readAllStdin() : flag).trim();
|
|
126
|
+
if (!raw)
|
|
127
|
+
return null;
|
|
128
|
+
const scanned = scanPayload({ handover: raw });
|
|
129
|
+
return { text: scanned.payload.handover.slice(0, 20_000), flags: scanned.flags.length };
|
|
130
|
+
}
|
|
131
|
+
async function runEndHook(options, globals) {
|
|
132
|
+
const context = await hookContext(options.runtime, await readAllStdin());
|
|
133
|
+
if (!context)
|
|
134
|
+
return;
|
|
135
|
+
if (context.input.hookEventName === "SessionEnd") {
|
|
136
|
+
const event = {
|
|
137
|
+
sessionId: context.rowId,
|
|
138
|
+
...(context.input.sessionId ? { runtimeSessionId: context.input.sessionId } : {}),
|
|
139
|
+
phase: "end",
|
|
140
|
+
occurredAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
141
|
+
extension: { runtime: context.runtime },
|
|
142
|
+
isEngineSession: context.isEngineSession,
|
|
143
|
+
};
|
|
144
|
+
spoolEvent(identityOf(context), event, { isClosePending: true });
|
|
145
|
+
}
|
|
146
|
+
if (context.runtime === "claude_code") {
|
|
147
|
+
await shipChangedMemory({ repositoryRoot: context.root, projectId: context.projectId, sessionId: context.rowId }, globals, writeRecordsLocally);
|
|
148
|
+
}
|
|
149
|
+
startDetachedDrain({ nustackUrl: globals.nustackUrl });
|
|
150
|
+
}
|
|
151
|
+
export async function runSessionEnd(options, globals) {
|
|
152
|
+
if (options.hook) {
|
|
153
|
+
try {
|
|
154
|
+
await runEndHook(options, globals);
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
}
|
|
158
|
+
process.exitCode = 0;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const refuse = (code, message) => {
|
|
162
|
+
if (options.json)
|
|
163
|
+
printJsonError("session end", { code, message });
|
|
164
|
+
else {
|
|
165
|
+
process.stderr.write(`session end: ${message}\n`);
|
|
166
|
+
process.exitCode = 1;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const handover = await handoverOf(options.handover);
|
|
170
|
+
if (!handover)
|
|
171
|
+
return refuse("VALIDATION_FAILED", "a handover is required: --handover <text>, or --handover - to read it from stdin");
|
|
172
|
+
const rowId = options.session ?? readVariable(SESSION_ID_VARIABLE);
|
|
173
|
+
if (!rowId)
|
|
174
|
+
return refuse("NO_SESSION", `no session: run it inside a session (${SESSION_ID_VARIABLE}) or pass --session <id>`);
|
|
175
|
+
const held = readSpool(rowId);
|
|
176
|
+
const runtime = held?.runtime ?? (isSessionRuntime(options.runtime) ? options.runtime : "claude_code");
|
|
177
|
+
let projectId = held?.projectId ?? null;
|
|
178
|
+
const root = held?.root ?? process.cwd();
|
|
179
|
+
if (!projectId) {
|
|
180
|
+
const project = await resolveProjectId(root, options.project ? { project: options.project } : {});
|
|
181
|
+
if (!project.ok)
|
|
182
|
+
return refuse("NO_PROJECT", project.message);
|
|
183
|
+
projectId = project.projectId;
|
|
184
|
+
}
|
|
185
|
+
spoolEvent({ rowId, projectId, root, runtime, runtimeSessionId: held?.runtimeSessionId ?? null, isEngineSession: held?.isEngineSession ?? false }, {
|
|
186
|
+
sessionId: rowId,
|
|
187
|
+
phase: "end",
|
|
188
|
+
occurredAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
189
|
+
extension: { runtime, handover: handover.text },
|
|
190
|
+
});
|
|
191
|
+
startDetachedDrain({ nustackUrl: globals.nustackUrl });
|
|
192
|
+
const data = { sessionId: rowId, projectId, redacted: handover.flags };
|
|
193
|
+
if (options.json)
|
|
194
|
+
printJsonSuccess("session end", data);
|
|
195
|
+
else {
|
|
196
|
+
if (handover.flags > 0)
|
|
197
|
+
process.stderr.write(`session end: redacted ${handover.flags} secret match(es) in the handover\n`);
|
|
198
|
+
process.stdout.write("session end: handover recorded; sending in the background\n");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function registerSessionCommands(program, getGlobalOptions) {
|
|
202
|
+
const session = program.command("session").description("What a session opens with and what it leaves the next one");
|
|
203
|
+
session
|
|
204
|
+
.command("brief")
|
|
205
|
+
.description("The Project's previous handover and open obligations")
|
|
206
|
+
.option("--hook", "SessionStart hook mode: read the hook JSON on stdin, print the brief as context, always exit 0")
|
|
207
|
+
.option("--runtime <runtime>", "the runtime reporting: claude_code, codex, opencode, copilot_cli or nustack (required with --hook)")
|
|
208
|
+
.option("--project <id>", "the Project, when the folder carries no binding marker")
|
|
209
|
+
.option("--session <id>", `the asking session, left out of the previous handover (default: $${SESSION_ID_VARIABLE})`)
|
|
210
|
+
.option("--json", "emit the versioned machine envelope")
|
|
211
|
+
.action(async (options) => runSessionBrief(options, getGlobalOptions()));
|
|
212
|
+
session
|
|
213
|
+
.command("end")
|
|
214
|
+
.description("Leave a handover for the next session; in hook mode, ship memory and close the session")
|
|
215
|
+
.option("--handover <text>", "the handover; - reads it from stdin")
|
|
216
|
+
.option("--hook", "Stop / SessionEnd hook mode: read the hook JSON on stdin, always exit 0")
|
|
217
|
+
.option("--runtime <runtime>", "the runtime reporting (required with --hook)")
|
|
218
|
+
.option("--session <id>", `the session row (default: $${SESSION_ID_VARIABLE})`)
|
|
219
|
+
.option("--project <id>", "the Project, when the folder carries no binding marker")
|
|
220
|
+
.option("--json", "emit the versioned machine envelope")
|
|
221
|
+
.action(async (options) => runSessionEnd(options, getGlobalOptions()));
|
|
222
|
+
}
|
|
@@ -19,7 +19,14 @@ export const DEADLINE_GATE = "hook.deadline";
|
|
|
19
19
|
const EVENT_NAMESPACE = "3f0c9a52-6d1e-4b7a-9c2f-8e5d4a1b7c60";
|
|
20
20
|
const RECORDS_STORE_VERSION = 9;
|
|
21
21
|
const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
22
|
-
export const COMMIT_RULES = new Set([
|
|
22
|
+
export const COMMIT_RULES = new Set([
|
|
23
|
+
"changelog",
|
|
24
|
+
"commit-gate",
|
|
25
|
+
"changelog-presence",
|
|
26
|
+
"harness-checks",
|
|
27
|
+
"bookkeeping",
|
|
28
|
+
"bookkeeping-clean",
|
|
29
|
+
]);
|
|
23
30
|
class CannotRun extends Error {
|
|
24
31
|
}
|
|
25
32
|
export function uuidV5(name, namespace = EVENT_NAMESPACE) {
|
|
@@ -19,6 +19,18 @@ export function desktopSettingsPath() {
|
|
|
19
19
|
export function cacheDir() {
|
|
20
20
|
return path.join(nustackHomeDir(), "cache");
|
|
21
21
|
}
|
|
22
|
+
export function sessionEventsDir() {
|
|
23
|
+
return path.join(nustackHomeDir(), "session-events");
|
|
24
|
+
}
|
|
25
|
+
export function briefCachePath(projectId) {
|
|
26
|
+
return path.join(cacheDir(), "brief", `${projectId}.json`);
|
|
27
|
+
}
|
|
28
|
+
export function obligationsCachePath(projectId, planAnchor) {
|
|
29
|
+
return path.join(cacheDir(), "obligations", projectId, `${planAnchor}.json`);
|
|
30
|
+
}
|
|
31
|
+
export function memoryManifestPath(projectId) {
|
|
32
|
+
return path.join(cacheDir(), "memory-shipped", `${projectId}.json`);
|
|
33
|
+
}
|
|
22
34
|
export function recoveryDir() {
|
|
23
35
|
return path.join(nustackHomeDir(), "recovery");
|
|
24
36
|
}
|
|
@@ -72,7 +72,7 @@ export class ClaudeSession {
|
|
|
72
72
|
}
|
|
73
73
|
if (!this.sessionIdSeen && frame.type === "system" && frame.subtype === "init" && typeof frame.session_id === "string") {
|
|
74
74
|
this.sessionIdSeen = true;
|
|
75
|
-
this.options.onSessionId?.(frame.session_id);
|
|
75
|
+
this.options.onSessionId?.(frame.session_id, typeof frame.model === "string" && frame.model ? frame.model : undefined);
|
|
76
76
|
}
|
|
77
77
|
this.options.onEvent(frame);
|
|
78
78
|
}
|
|
@@ -4,6 +4,8 @@ import { ClaudeSession } from "./claudeAdapter.js";
|
|
|
4
4
|
import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
|
|
5
5
|
import { inspectSession, reclaimSession } from "./prune.js";
|
|
6
6
|
import { sessionRegistryPath, readSessionRegistry, updateSessionRegistry } from "./registry.js";
|
|
7
|
+
import { SESSION_ID_VARIABLE } from "../records/environment.js";
|
|
8
|
+
import { mountFacts } from "../sessions/mountFacts.js";
|
|
7
9
|
import { createSessionWorktree } from "./worktree.js";
|
|
8
10
|
import { readProjectContract } from "../project_contract.js";
|
|
9
11
|
function defaultProbeClaude(env) {
|
|
@@ -164,11 +166,18 @@ export class SessionSupervisor {
|
|
|
164
166
|
};
|
|
165
167
|
if (record.projectId)
|
|
166
168
|
await this.mountInto(sessionId, worktree.worktreePath, record.projectId);
|
|
169
|
+
const facts = record.projectId ? await mountFacts(worktree.worktreePath, "claude_code") : null;
|
|
170
|
+
const extension = {
|
|
171
|
+
runtime: "claude_code",
|
|
172
|
+
...(facts?.harnessSemver ? { harnessSemver: facts.harnessSemver } : {}),
|
|
173
|
+
...(facts?.harnessDigest ? { harnessDigest: facts.harnessDigest } : {}),
|
|
174
|
+
...(facts?.configDigest ? { configDigest: facts.configDigest } : {}),
|
|
175
|
+
};
|
|
167
176
|
const adapterOptions = {
|
|
168
177
|
cwd: worktree.worktreePath,
|
|
169
178
|
permissionMode,
|
|
170
179
|
resumeSessionId,
|
|
171
|
-
env: this.env,
|
|
180
|
+
env: { ...this.env, [SESSION_ID_VARIABLE]: sessionId },
|
|
172
181
|
onEvent: (event) => {
|
|
173
182
|
this.emit({ v: FRAME_VERSION, type: "session.event", sessionId, event });
|
|
174
183
|
if (typeof event === "object" && event !== null && event.type === "result") {
|
|
@@ -188,12 +197,27 @@ export class SessionSupervisor {
|
|
|
188
197
|
});
|
|
189
198
|
},
|
|
190
199
|
onProtocolError: (code, message) => this.error(sessionId, code, message),
|
|
191
|
-
onSessionId: (vendorSessionId) => {
|
|
200
|
+
onSessionId: (vendorSessionId, model) => {
|
|
192
201
|
record.providerSessionId = vendorSessionId;
|
|
193
202
|
void this.updateRegistry((sessions) => sessions.map((row) => (row.sessionId === sessionId ? { ...row, providerSessionId: vendorSessionId } : row)));
|
|
194
203
|
const client = this.options.sessions;
|
|
195
204
|
if (client && record.projectId) {
|
|
196
205
|
void client.setVendorSessionId(record.projectId, sessionId, vendorSessionId).catch(() => undefined);
|
|
206
|
+
void client
|
|
207
|
+
.postSessionEvents?.(record.projectId, {
|
|
208
|
+
events: [
|
|
209
|
+
{
|
|
210
|
+
sessionId,
|
|
211
|
+
runtimeSessionId: vendorSessionId,
|
|
212
|
+
phase: "start",
|
|
213
|
+
occurredAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
214
|
+
extension: { ...extension, ...(model ? { model } : {}) },
|
|
215
|
+
isEngineSession: true,
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
labelRows: [],
|
|
219
|
+
})
|
|
220
|
+
.catch(() => undefined);
|
|
197
221
|
}
|
|
198
222
|
},
|
|
199
223
|
onExit: (code) => {
|
|
@@ -227,6 +251,7 @@ export class SessionSupervisor {
|
|
|
227
251
|
vendor: "claude_code",
|
|
228
252
|
renderer: "claude_transcript",
|
|
229
253
|
status: "created",
|
|
254
|
+
...extension,
|
|
230
255
|
});
|
|
231
256
|
}
|
|
232
257
|
catch {
|