@nurix/nustack 0.23.14 → 0.23.15
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/local_store/paths.js +12 -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/package.json +2 -2
|
@@ -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,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 {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ensureAccessToken } from "../auth_grant.js";
|
|
4
|
+
import { briefCachePath } from "../local_store/paths.js";
|
|
5
|
+
import { assertCredentialOrigin } from "../nustack_client.js";
|
|
6
|
+
import { resolveDoor } from "../records/door.js";
|
|
7
|
+
export const BRIEF_FETCH_TIMEOUT_MS = 3_000;
|
|
8
|
+
export const BRIEF_CONTEXT_CAP = 4_000;
|
|
9
|
+
export const BRIEF_CONTEXT_OBLIGATIONS = 20;
|
|
10
|
+
export const HANDOVER_LINE = "Before you finish, leave a handover for the next session: `nustack session end --handover -` with the note on stdin.";
|
|
11
|
+
const EMPTY_BRIEF = { previousHandover: null, openObligations: { total: 0, items: [] }, generatedAt: "" };
|
|
12
|
+
export function readCache(file) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
15
|
+
return typeof parsed.fetchedAt === "string" && parsed.value !== undefined ? { fetchedAt: parsed.fetchedAt, value: parsed.value } : null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function writeCache(file, value) {
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
24
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
25
|
+
writeFileSync(temporary, `${JSON.stringify({ fetchedAt: new Date().toISOString(), value })}\n`, { mode: 0o600 });
|
|
26
|
+
renameSync(temporary, file);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function getFromDoor(root, globals, projectId, pathAndQuery, timeoutMs) {
|
|
32
|
+
try {
|
|
33
|
+
const door = await resolveDoor(root, globals, projectId);
|
|
34
|
+
if (door.kind !== "door")
|
|
35
|
+
return null;
|
|
36
|
+
await assertCredentialOrigin(door.doorUrl);
|
|
37
|
+
const auth = await ensureAccessToken();
|
|
38
|
+
if (!auth)
|
|
39
|
+
return null;
|
|
40
|
+
const response = await fetch(`${door.doorUrl}/api/v2/projects/${encodeURIComponent(projectId)}${pathAndQuery}`, {
|
|
41
|
+
headers: { authorization: `Bearer ${auth.accessToken}` },
|
|
42
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
43
|
+
});
|
|
44
|
+
if (!response.ok)
|
|
45
|
+
return null;
|
|
46
|
+
const body = (await response.json());
|
|
47
|
+
return body.data === undefined ? null : { data: body.data, meta: body.meta ?? null };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function readBrief(root, globals, projectId, sessionId, refusalCount) {
|
|
54
|
+
const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
|
|
55
|
+
const answered = await getFromDoor(root, globals, projectId, `/sessions/brief${query}`, BRIEF_FETCH_TIMEOUT_MS);
|
|
56
|
+
const cacheFile = briefCachePath(projectId);
|
|
57
|
+
if (answered && answered.data && typeof answered.data === "object") {
|
|
58
|
+
const brief = answered.data;
|
|
59
|
+
writeCache(cacheFile, brief);
|
|
60
|
+
return { ...brief, refusalCount, offline: null, source: "server" };
|
|
61
|
+
}
|
|
62
|
+
const cached = readCache(cacheFile);
|
|
63
|
+
const since = cached?.fetchedAt ?? new Date().toISOString();
|
|
64
|
+
return {
|
|
65
|
+
...(cached?.value ?? EMPTY_BRIEF),
|
|
66
|
+
refusalCount,
|
|
67
|
+
offline: { since, durationMs: Math.max(0, Date.now() - Date.parse(since)) },
|
|
68
|
+
source: "cache",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function obligationLine(item) {
|
|
72
|
+
const claim = typeof item.payload.claim === "string" ? ` — ${item.payload.claim}` : "";
|
|
73
|
+
return `- ${item.naturalKey}${claim}`;
|
|
74
|
+
}
|
|
75
|
+
export function renderBrief(brief, options = {}) {
|
|
76
|
+
const lines = ["NuStack session brief"];
|
|
77
|
+
if (brief.offline)
|
|
78
|
+
lines.push(`(offline since ${brief.offline.since}; this is the cached brief)`);
|
|
79
|
+
if (brief.previousHandover) {
|
|
80
|
+
const runtime = brief.previousHandover.runtime ? ` (${brief.previousHandover.runtime.replace(/_/g, " ")})` : "";
|
|
81
|
+
lines.push("", `Previous handover${runtime}, ${brief.previousHandover.createdAt}:`, brief.previousHandover.handover);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
lines.push("", "No previous handover.");
|
|
85
|
+
}
|
|
86
|
+
const items = brief.openObligations.items.slice(0, options.obligationLimit ?? brief.openObligations.items.length);
|
|
87
|
+
lines.push("", `Open obligations: ${brief.openObligations.total}`);
|
|
88
|
+
for (const item of items)
|
|
89
|
+
lines.push(obligationLine(item));
|
|
90
|
+
if (brief.openObligations.total > items.length)
|
|
91
|
+
lines.push(`… and ${brief.openObligations.total - items.length} more (nustack obligation list --plan <anchor>)`);
|
|
92
|
+
if (brief.refusalCount > 0)
|
|
93
|
+
lines.push("", `Refused record writes on this device: ${brief.refusalCount} (nustack records list).`);
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
export function renderHookOutput(brief) {
|
|
97
|
+
const tail = `\n\n${HANDOVER_LINE}`;
|
|
98
|
+
let body = renderBrief(brief, { obligationLimit: BRIEF_CONTEXT_OBLIGATIONS });
|
|
99
|
+
if (body.length + tail.length > BRIEF_CONTEXT_CAP)
|
|
100
|
+
body = `${body.slice(0, BRIEF_CONTEXT_CAP - tail.length - 1)}…`;
|
|
101
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: `${body}${tail}` } });
|
|
102
|
+
}
|