@superjackfruitlabs/salaam 0.0.3 → 0.0.4
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/CHANGELOG.md +45 -0
- package/README.md +12 -11
- package/dist/src/acp-outcome.js +177 -17
- package/dist/src/acp.js +41 -7
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +27 -2
- package/dist/src/cli.js +87 -3
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +217 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +192 -6
- package/dist/src/house-extension.js +162 -57
- package/dist/src/local-coding.js +214 -18
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi.js +22 -1
- package/dist/src/protocol.js +5 -2
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +141 -6
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +19 -0
- package/dist/src/supervisor.js +177 -5
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/usage.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-outcome.ts +169 -19
- package/src/acp.ts +53 -7
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +27 -2
- package/src/cli.ts +109 -3
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +273 -0
- package/src/execution-evidence.ts +31 -1
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +244 -6
- package/src/house-extension.ts +174 -56
- package/src/local-coding.ts +270 -18
- package/src/local-edit-helper.ts +26 -0
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi.ts +23 -1
- package/src/protocol.ts +6 -1
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +222 -6
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +18 -0
- package/src/supervisor.ts +203 -4
- package/src/task-budget.ts +48 -3
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/tool-identity.ts +110 -0
- package/src/usage.ts +1 -1
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { Store } from './store.js';
|
|
2
|
+
import type { WorkTask } from './task-work.js';
|
|
3
|
+
import { digest, ServiceError } from './protocol.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Checkpoints of a station task, derived from its own recorded writes (item 11). Each completed
|
|
7
|
+
* step ends at a checkpoint: the tree after that step's last returned station patch. A file's
|
|
8
|
+
* content at a checkpoint is known only from the run's own patches; a file that existed before
|
|
9
|
+
* the run and was first written after the checkpoint has no recorded earlier bytes, so it is
|
|
10
|
+
* "unknown" there and cannot be restored. Read-only: nothing here changes the workspace.
|
|
11
|
+
*/
|
|
12
|
+
export interface StationCheckpoint {
|
|
13
|
+
id: string;
|
|
14
|
+
step_id: string;
|
|
15
|
+
step_key: string;
|
|
16
|
+
tree_digest: string;
|
|
17
|
+
at: number; // the last patch call rowid at the checkpoint (0: before any write)
|
|
18
|
+
files_changed: string[];
|
|
19
|
+
}
|
|
20
|
+
type Patch = {
|
|
21
|
+
rowid: number;
|
|
22
|
+
path: string;
|
|
23
|
+
content: string | null;
|
|
24
|
+
expected_hash: string | null;
|
|
25
|
+
before_tree: string;
|
|
26
|
+
tree: string;
|
|
27
|
+
sha256: string | null;
|
|
28
|
+
};
|
|
29
|
+
export type FileAtCheckpoint =
|
|
30
|
+
{ state: 'content'; content: string } | { state: 'absent' } | { state: 'unknown' };
|
|
31
|
+
/** A file as the workspace holds it now, read through the protected helper's closed verbs. */
|
|
32
|
+
export type FileNow =
|
|
33
|
+
| { state: 'content'; content: string; sha256: string }
|
|
34
|
+
| { state: 'absent' }
|
|
35
|
+
| { state: 'unreadable' };
|
|
36
|
+
/** The live workspace: its tree digest now, and each file's bytes now. */
|
|
37
|
+
export interface LiveTree {
|
|
38
|
+
tree: string;
|
|
39
|
+
file(path: string): Promise<FileNow>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class StationCheckpoints {
|
|
43
|
+
constructor(private readonly store: Store) {}
|
|
44
|
+
private patches(session: string): Patch[] {
|
|
45
|
+
return this.store.db
|
|
46
|
+
.prepare(
|
|
47
|
+
"SELECT rowid,args_json,result_json,attempt_id FROM station_calls WHERE session_id=? AND kind='patch' AND state='returned' ORDER BY rowid",
|
|
48
|
+
)
|
|
49
|
+
.all(session)
|
|
50
|
+
.map((r) => {
|
|
51
|
+
const args = JSON.parse(String(r.args_json)),
|
|
52
|
+
result = JSON.parse(String(r.result_json));
|
|
53
|
+
return {
|
|
54
|
+
rowid: Number(r.rowid),
|
|
55
|
+
path: String(args.path),
|
|
56
|
+
content: typeof args.content === 'string' ? args.content : null,
|
|
57
|
+
expected_hash: typeof args.expected_hash === 'string' ? args.expected_hash : null,
|
|
58
|
+
before_tree: String(result.before_tree_digest),
|
|
59
|
+
tree: String(result.tree_digest),
|
|
60
|
+
sha256: typeof result.sha256 === 'string' ? result.sha256 : null,
|
|
61
|
+
attempt: String(r.attempt_id),
|
|
62
|
+
} as Patch & { attempt: string };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
list(task: WorkTask): StationCheckpoint[] {
|
|
66
|
+
const patches = this.patches(task.session_id) as (Patch & { attempt: string })[];
|
|
67
|
+
const attemptsOf = (step: string) =>
|
|
68
|
+
new Set(
|
|
69
|
+
this.store.db
|
|
70
|
+
.prepare('SELECT attempt_id FROM task_step_attempts WHERE step_id=?')
|
|
71
|
+
.all(step)
|
|
72
|
+
.map((r) => String(r.attempt_id)),
|
|
73
|
+
);
|
|
74
|
+
const out: StationCheckpoint[] = [];
|
|
75
|
+
let at = 0,
|
|
76
|
+
tree = patches[0]?.before_tree ?? '';
|
|
77
|
+
for (const step of task.steps.filter((s) => s.state === 'completed')) {
|
|
78
|
+
const mine = patches.filter((p) => attemptsOf(step.id).has(p.attempt));
|
|
79
|
+
if (mine.length) {
|
|
80
|
+
at = Math.max(at, ...mine.map((p) => p.rowid));
|
|
81
|
+
tree = patches.find((p) => p.rowid === at)!.tree;
|
|
82
|
+
}
|
|
83
|
+
out.push({
|
|
84
|
+
id: digest({ task: task.id, plan_revision: task.plan_revision, step: step.id, at, tree }),
|
|
85
|
+
step_id: step.id,
|
|
86
|
+
step_key: step.key,
|
|
87
|
+
tree_digest: tree,
|
|
88
|
+
at,
|
|
89
|
+
files_changed: [...new Set(mine.map((p) => p.path))].sort(),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
get(task: WorkTask, id: string): StationCheckpoint {
|
|
95
|
+
const found = this.list(task).find((c) => c.id === id);
|
|
96
|
+
if (!found) throw new ServiceError('not_found', 'No such checkpoint for this task', 404);
|
|
97
|
+
return found;
|
|
98
|
+
}
|
|
99
|
+
/** A path's content at a checkpoint, from the run's own patches only. */
|
|
100
|
+
fileAt(task: WorkTask, at: number, path: string): FileAtCheckpoint {
|
|
101
|
+
const patches = this.patches(task.session_id).filter((p) => p.path === path);
|
|
102
|
+
const before = patches.filter((p) => p.rowid <= at).at(-1);
|
|
103
|
+
if (before)
|
|
104
|
+
return before.content === null
|
|
105
|
+
? { state: 'absent' }
|
|
106
|
+
: { state: 'content', content: before.content };
|
|
107
|
+
const first = patches.find((p) => p.rowid > at);
|
|
108
|
+
return first && first.expected_hash === null ? { state: 'absent' } : { state: 'unknown' };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* What restoring this checkpoint would do, against the LIVE workspace (never the run's own
|
|
112
|
+
* receipts alone): every path this run wrote is read as it is now, so a change made outside the
|
|
113
|
+
* run since (an edit, a deletion, a file put back) shows as what the restore would undo. The
|
|
114
|
+
* restore digest binds the live tree, so a restore refuses a workspace that moved after review.
|
|
115
|
+
* Files this run never wrote are left as they are; `drift` says when the tree moved outside it.
|
|
116
|
+
*/
|
|
117
|
+
async diff(task: WorkTask, id: string, live: LiveTree) {
|
|
118
|
+
const checkpoint = this.get(task, id);
|
|
119
|
+
const patches = this.patches(task.session_id);
|
|
120
|
+
const recorded = patches.at(-1)?.tree ?? checkpoint.tree_digest;
|
|
121
|
+
const paths = [...new Set(patches.map((p) => p.path))].sort();
|
|
122
|
+
const files = [];
|
|
123
|
+
for (const path of paths) {
|
|
124
|
+
const now = await live.file(path);
|
|
125
|
+
const then = this.fileAt(task, checkpoint.at, path);
|
|
126
|
+
const change =
|
|
127
|
+
now.state === 'unreadable' || then.state === 'unknown'
|
|
128
|
+
? ('not_restorable' as const)
|
|
129
|
+
: then.state === 'absent'
|
|
130
|
+
? now.state === 'absent'
|
|
131
|
+
? ('unchanged' as const)
|
|
132
|
+
: ('delete' as const)
|
|
133
|
+
: now.state === 'absent'
|
|
134
|
+
? ('bring_back' as const)
|
|
135
|
+
: now.content === then.content
|
|
136
|
+
? ('unchanged' as const)
|
|
137
|
+
: ('revert' as const);
|
|
138
|
+
files.push({
|
|
139
|
+
path,
|
|
140
|
+
current: now.state === 'content' ? now.content : null,
|
|
141
|
+
current_state: now.state,
|
|
142
|
+
at_checkpoint: then,
|
|
143
|
+
change,
|
|
144
|
+
expected_hash: now.state === 'content' ? now.sha256 : null,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const shown = files.filter((f) => f.change !== 'unchanged');
|
|
148
|
+
const restore_digest = digest({
|
|
149
|
+
task: task.id,
|
|
150
|
+
plan_revision: task.plan_revision,
|
|
151
|
+
checkpoint: checkpoint.id,
|
|
152
|
+
checkpoint_tree: checkpoint.tree_digest,
|
|
153
|
+
recorded_tree: recorded,
|
|
154
|
+
current_tree: live.tree,
|
|
155
|
+
files: shown.map((f) => ({
|
|
156
|
+
path: f.path,
|
|
157
|
+
change: f.change,
|
|
158
|
+
target: f.at_checkpoint,
|
|
159
|
+
expected: f.expected_hash,
|
|
160
|
+
})),
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
checkpoint,
|
|
164
|
+
current_tree: live.tree,
|
|
165
|
+
recorded_tree: recorded,
|
|
166
|
+
drift: live.tree !== recorded,
|
|
167
|
+
restore_digest,
|
|
168
|
+
files: shown.map(({ expected_hash: _, ...f }) => f),
|
|
169
|
+
writes: shown
|
|
170
|
+
.filter((f) => ['revert', 'delete', 'bring_back'].includes(f.change))
|
|
171
|
+
.map((f) => ({
|
|
172
|
+
path: f.path,
|
|
173
|
+
content: f.at_checkpoint.state === 'content' ? f.at_checkpoint.content : null,
|
|
174
|
+
expected_hash: f.expected_hash,
|
|
175
|
+
})),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/** Item 11b: exactly what a restore of this checkpoint writes, and the digest that binds it. */
|
|
179
|
+
async restorePlan(task: WorkTask, id: string, live: LiveTree) {
|
|
180
|
+
const { restore_digest, current_tree, writes, checkpoint } = await this.diff(task, id, live);
|
|
181
|
+
return { restore_digest, current_tree, writes, checkpoint };
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/station-config.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface StationConfig {
|
|
|
28
28
|
runtime_root: string;
|
|
29
29
|
runtime_fingerprint: string;
|
|
30
30
|
runtime_image_digest: string;
|
|
31
|
+
/** OS user the agent's local tools run as (salaam --tools-user). Optional. */
|
|
32
|
+
tools_user?: string;
|
|
31
33
|
}
|
|
32
34
|
const hashShape = /^[a-f0-9]{64}$/;
|
|
33
35
|
export const stationHash = (bytes: Uint8Array): string =>
|
|
@@ -149,9 +151,15 @@ export function readStationConfig(
|
|
|
149
151
|
): StationConfig {
|
|
150
152
|
try {
|
|
151
153
|
const v = object(JSON.parse(stationPrivateFile(path, 16384).toString('utf8')));
|
|
154
|
+
const keys = Object.keys(v)
|
|
155
|
+
.filter((k) => k !== 'tools_user')
|
|
156
|
+
.sort()
|
|
157
|
+
.join(',');
|
|
152
158
|
if (
|
|
153
|
-
|
|
159
|
+
keys !==
|
|
154
160
|
'files,kind,runtime_fingerprint,runtime_image_digest,runtime_root,systemctl,systemd_run,version,workspace,workspace_identity' ||
|
|
161
|
+
(Object.hasOwn(v, 'tools_user') &&
|
|
162
|
+
(typeof v.tools_user !== 'string' || !/^[a-z_][a-z0-9_-]{0,31}$/.test(v.tools_user))) ||
|
|
155
163
|
v.version !== 1 ||
|
|
156
164
|
v.kind !== 'linux-systemd-station-v1' ||
|
|
157
165
|
v.workspace !== workspace ||
|
|
@@ -174,6 +182,7 @@ export function readStationConfig(
|
|
|
174
182
|
runtime_root: v.runtime_root,
|
|
175
183
|
runtime_fingerprint: v.runtime_fingerprint,
|
|
176
184
|
runtime_image_digest: v.runtime_image_digest,
|
|
185
|
+
...(Object.hasOwn(v, 'tools_user') ? { tools_user: v.tools_user as string } : {}),
|
|
177
186
|
};
|
|
178
187
|
if (
|
|
179
188
|
workspace
|
package/src/station-files.ts
CHANGED
|
@@ -22,9 +22,14 @@ export function stationFileResult(
|
|
|
22
22
|
};
|
|
23
23
|
const fields = keys[String(request.action)];
|
|
24
24
|
if (!fields) bad();
|
|
25
|
+
// A new file's missing parent directories, created beneath the root, are part of its effect.
|
|
26
|
+
const extra =
|
|
27
|
+
request.action === 'patch' && 'created_directories' in v ? ['created_directories'] : [];
|
|
25
28
|
if (
|
|
26
29
|
Object.keys(v).sort().join(',') !==
|
|
27
|
-
['version', 'ok', 'tree_digest', 'files', 'root_identity', ...fields
|
|
30
|
+
['version', 'ok', 'tree_digest', 'files', 'root_identity', ...fields!, ...extra]
|
|
31
|
+
.sort()
|
|
32
|
+
.join(',') ||
|
|
28
33
|
v.version !== 1 ||
|
|
29
34
|
v.ok !== true ||
|
|
30
35
|
v.root_identity !== request.root_identity ||
|
|
@@ -47,6 +52,8 @@ export function stationFileResult(
|
|
|
47
52
|
)
|
|
48
53
|
bad();
|
|
49
54
|
}
|
|
55
|
+
if (extra.length && !createdParents(String(request.path), v.created_directories, request.content))
|
|
56
|
+
bad();
|
|
50
57
|
if (request.action === 'patch') {
|
|
51
58
|
if (
|
|
52
59
|
v.path !== request.path ||
|
|
@@ -102,3 +109,15 @@ export function stationFileResult(
|
|
|
102
109
|
}
|
|
103
110
|
return v;
|
|
104
111
|
}
|
|
112
|
+
|
|
113
|
+
/** The created directories must be a non-empty, in-order run of the path's own
|
|
114
|
+
* ancestors ending at its parent: each one beneath the last, nothing else. Only a
|
|
115
|
+
* new file creates them. */
|
|
116
|
+
function createdParents(path: string, dirs: unknown, content: unknown): boolean {
|
|
117
|
+
if (typeof content !== 'string' || !Array.isArray(dirs) || dirs.length === 0) return false;
|
|
118
|
+
const parts = path.split('/');
|
|
119
|
+
if (parts.length < 2 || parts.some((p) => p === '' || p === '.' || p === '..')) return false;
|
|
120
|
+
const ancestors = parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join('/'));
|
|
121
|
+
const tail = ancestors.slice(ancestors.length - dirs.length);
|
|
122
|
+
return dirs.length <= ancestors.length && dirs.every((d, i) => d === tail[i]);
|
|
123
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { requiredChecks } from './task-policy.js';
|
|
1
2
|
import type { Store } from './store.js';
|
|
2
3
|
import type { TaskWork, WorkTask } from './task-work.js';
|
|
3
4
|
import type { TaskVerification, CheckReceipt } from './task-verification.js';
|
|
@@ -163,7 +164,7 @@ export class StationTaskVerification {
|
|
|
163
164
|
private async dispatchOwned(id: string): Promise<void> {
|
|
164
165
|
const receipt = this.verification.get(id),
|
|
165
166
|
task = this.tasks.get(receipt.task_id);
|
|
166
|
-
const configured = task.
|
|
167
|
+
const configured = requiredChecks(task).find((c) => c.key === receipt.check_key);
|
|
167
168
|
if (!configured || configured.cwd !== '.') this.fail();
|
|
168
169
|
const binding = this.station.ready(task.session_id);
|
|
169
170
|
const validate = (inspection?: string) => {
|
|
@@ -265,6 +266,7 @@ export class StationTaskVerification {
|
|
|
265
266
|
objective: task.objective,
|
|
266
267
|
constraints: task.constraints,
|
|
267
268
|
acceptance: task.acceptance,
|
|
269
|
+
...(task.completion_gate ? { completion_gate: task.completion_gate } : {}),
|
|
268
270
|
deliverables: task.deliverables,
|
|
269
271
|
limits: task.limits,
|
|
270
272
|
steps: task.steps.map(({ key, title, instruction, depends_on, checks }) => ({
|
|
@@ -300,7 +302,7 @@ export class StationTaskVerification {
|
|
|
300
302
|
});
|
|
301
303
|
}
|
|
302
304
|
private matches(check: CheckReceipt, task: WorkTask, call: StationCall): void {
|
|
303
|
-
const configured = task.
|
|
305
|
+
const configured = requiredChecks(task).find((c) => c.key === check.check_key);
|
|
304
306
|
const args = JSON.parse(call.args_json);
|
|
305
307
|
if (
|
|
306
308
|
check.task_id !== task.id ||
|
|
@@ -526,7 +528,8 @@ export class StationTaskVerification {
|
|
|
526
528
|
result.root_identity !== this.station.config.workspace_identity
|
|
527
529
|
)
|
|
528
530
|
this.fail();
|
|
529
|
-
|
|
531
|
+
// The verified bytes travel with the digest; completion keeps them as an artifact.
|
|
532
|
+
deliverables.push({ path, sha256: result.sha256, content: result.content });
|
|
530
533
|
}
|
|
531
534
|
const final = await this.observe(
|
|
532
535
|
evidence[0]!.call_id,
|