@aiwg/cli 2026.8.25 → 2026.8.27

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/bin/aiwg.mjs CHANGED
@@ -203,13 +203,6 @@ function maybeHandleFastHelp(args) {
203
203
  return true;
204
204
  }
205
205
 
206
- if (maybeHandleFastVersion(process.argv.slice(2))) {
207
- process.exit(0);
208
- }
209
- if (maybeHandleFastHelp(process.argv.slice(2))) {
210
- process.exit(0);
211
- }
212
-
213
206
  // Preflight: verify dist/ is built before any of the dynamic imports below
214
207
  // try to resolve files that don't exist. Without this, a missing/incomplete
215
208
  // dist/ surfaces as a raw `Cannot find module '.../dist/src/.../*.mjs'` from
@@ -240,6 +233,31 @@ function maybeWarnUnbuiltDist() {
240
233
  }
241
234
  maybeWarnUnbuiltDist();
242
235
 
236
+ // Display a cached notice and schedule its refresh before every eligible CLI
237
+ // path, including fast help/version, channel recovery, and later preflight
238
+ // failures. This local-only bootstrap never waits on the registry and failures
239
+ // are deliberately ignored so update advice cannot change command behavior.
240
+ async function runUpdateNotifierBootstrap() {
241
+ try {
242
+ const notifierPath = path.join(packageRoot, 'dist', 'src', 'update', 'notifier.mjs');
243
+ const notifier = await import(pathToFileURL(notifierPath).href);
244
+ const activePackageRoot = notifier.resolveActivePackageRoot(packageRoot);
245
+ notifier.maybePrintNotice(activePackageRoot);
246
+ notifier.scheduleBackgroundCheck(activePackageRoot);
247
+ } catch {
248
+ // Best effort: installation/router diagnostics retain authority.
249
+ }
250
+ }
251
+
252
+ await runUpdateNotifierBootstrap();
253
+
254
+ if (maybeHandleFastVersion(process.argv.slice(2))) {
255
+ process.exit(0);
256
+ }
257
+ if (maybeHandleFastHelp(process.argv.slice(2))) {
258
+ process.exit(0);
259
+ }
260
+
243
261
  // Mint or inherit an invocation ID before anything else loads. Child processes
244
262
  // spawned by handlers (detached update-notifier, aiwg exec, etc.) inherit the
245
263
  // parent's ID via `AIWG_INVOCATION_ID` so their JSONL log records correlate
@@ -398,15 +416,6 @@ async function main() {
398
416
  // tag every record with it.
399
417
  await applyVerbosityFromArgs(args, routerPath);
400
418
 
401
- // Update notifier: print any pending notice from the previous run's
402
- // background check, then schedule the next background check. Both are
403
- // non-blocking — the current command never waits on the network.
404
- // Honors NO_UPDATE_NOTIFIER, CI=*, and non-TTY stderr.
405
- const notifierPath = path.join(activePackageRoot, 'dist', 'src', 'update', 'notifier.mjs');
406
- const { scheduleBackgroundCheck, maybePrintNotice } = await import(pathToFileURL(notifierPath).href);
407
- maybePrintNotice(activePackageRoot);
408
- scheduleBackgroundCheck(activePackageRoot);
409
-
410
419
  // Top-level cancellation controller. SIGINT / SIGTERM flip it, long-running
411
420
  // handlers plumb ctx.signal through fetches and loops so Ctrl-C cancels
412
421
  // in-flight work cleanly instead of leaving orphaned sockets and children.
@@ -9,6 +9,7 @@ export { run } from '../cli/router.js';
9
9
  export * from '../resources/index.js';
10
10
  export * from '../sessions/index.js';
11
11
  export * from '../memory/index.js';
12
+ export * from '../storage/index.js';
12
13
  export * from '../security/threat-assessment-config.js';
13
14
  export * from '../security/artifact-verifier.js';
14
15
  export * from '../security/artifact-attestation.js';
@@ -9,6 +9,7 @@ export { run } from '../cli/router.js';
9
9
  export * from '../resources/index.js';
10
10
  export * from '../sessions/index.js';
11
11
  export * from '../memory/index.js';
12
+ export * from '../storage/index.js';
12
13
  export * from '../security/threat-assessment-config.js';
13
14
  export * from '../security/artifact-verifier.js';
14
15
  export * from '../security/artifact-attestation.js';
@@ -11,6 +11,7 @@
11
11
  * @source @src/artifacts/graph-backend.ts
12
12
  * @tests @test/unit/artifacts/graphology-backend.test.ts
13
13
  */
14
+ import { compareGraphIds, pageGraphIds } from '../graph-backend.js';
14
15
  import { normalizeEdges } from '../types.js';
15
16
  import { loadFeaturePackage } from '../../features/runtime.js';
16
17
  /**
@@ -73,7 +74,15 @@ export class GraphologyBackend {
73
74
  return this.graph.getNodeAttributes(id);
74
75
  }
75
76
  nodes() {
76
- return this.graph.nodes();
77
+ return this.graph.nodes().sort(compareGraphIds);
78
+ }
79
+ queryNodes(filters) {
80
+ return this.graph.nodes()
81
+ .filter((id) => Object.entries(filters).every(([key, value]) => this.graph.getNodeAttribute(id, key) === value))
82
+ .sort(compareGraphIds);
83
+ }
84
+ pageNodes(limit, after) {
85
+ return pageGraphIds(this.graph.nodes(), limit, after);
77
86
  }
78
87
  // --- Traversal ---
79
88
  neighbors(nodeId, direction, edgeType) {
@@ -98,7 +107,7 @@ export class GraphologyBackend {
98
107
  // Add the "other" node
99
108
  results.add(src === nodeId ? tgt : src);
100
109
  }
101
- return [...results];
110
+ return [...results].sort(compareGraphIds);
102
111
  }
103
112
  // --- Set operations ---
104
113
  intersection(setA, setB) {
@@ -9,6 +9,7 @@
9
9
  * @source @src/artifacts/graph-backend.ts
10
10
  * @tests @test/unit/artifacts/graph-backend.test.ts
11
11
  */
12
+ import { compareGraphIds, pageGraphIds } from '../graph-backend.js';
12
13
  import { normalizeEdges } from '../types.js';
13
14
  /**
14
15
  * JSON-backed graph using plain objects and JS Set operations.
@@ -52,7 +53,16 @@ export class JsonGraphBackend {
52
53
  return this.graph.get(id)?.attrs;
53
54
  }
54
55
  nodes() {
55
- return [...this.graph.keys()];
56
+ return [...this.graph.keys()].sort(compareGraphIds);
57
+ }
58
+ queryNodes(filters) {
59
+ return [...this.graph]
60
+ .filter(([, node]) => Object.entries(filters).every(([key, value]) => node.attrs[key] === value))
61
+ .map(([id]) => id)
62
+ .sort(compareGraphIds);
63
+ }
64
+ pageNodes(limit, after) {
65
+ return pageGraphIds([...this.graph.keys()], limit, after);
56
66
  }
57
67
  // --- Traversal ---
58
68
  neighbors(nodeId, direction, edgeType) {
@@ -74,7 +84,7 @@ export class JsonGraphBackend {
74
84
  }
75
85
  }
76
86
  }
77
- return [...results];
87
+ return [...results].sort(compareGraphIds);
78
88
  }
79
89
  // --- Set operations ---
80
90
  intersection(setA, setB) {
@@ -13,6 +13,7 @@
13
13
  * @source @src/artifacts/graph-backend.ts
14
14
  * @tests @test/unit/artifacts/sqlite-backend.test.ts
15
15
  */
16
+ import { compareGraphIds, pageGraphIds } from '../graph-backend.js';
16
17
  import { normalizeEdges } from '../types.js';
17
18
  import { requireFeaturePackage } from '../../features/runtime.js';
18
19
  const SCHEMA_VERSION = 1;
@@ -131,23 +132,35 @@ export class SqliteGraphBackend {
131
132
  return JSON.parse(row.attrs);
132
133
  }
133
134
  nodes() {
134
- return this.db.prepare('SELECT id FROM nodes').all().map((r) => r.id);
135
+ return this.db.prepare('SELECT id FROM nodes ORDER BY id COLLATE BINARY').all()
136
+ .map((r) => r.id);
135
137
  }
136
138
  queryNodes(filters) {
137
139
  const clauses = [];
138
140
  const values = [];
139
141
  if (filters.type !== undefined) {
140
- clauses.push('type = ?');
141
- values.push(filters.type);
142
+ if (filters.type === null)
143
+ clauses.push('type IS NULL');
144
+ else {
145
+ clauses.push('type = ?');
146
+ values.push(filters.type);
147
+ }
142
148
  }
143
149
  if (filters.phase !== undefined) {
144
- clauses.push('phase = ?');
145
- values.push(filters.phase);
150
+ if (filters.phase === null)
151
+ clauses.push('phase IS NULL');
152
+ else {
153
+ clauses.push('phase = ?');
154
+ values.push(filters.phase);
155
+ }
146
156
  }
147
157
  const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
148
- return this.db.prepare(`SELECT id FROM nodes ${where} ORDER BY id`).all(...values)
158
+ return this.db.prepare(`SELECT id FROM nodes ${where} ORDER BY id COLLATE BINARY`).all(...values)
149
159
  .map((row) => row.id);
150
160
  }
161
+ pageNodes(limit, after) {
162
+ return pageGraphIds(this.nodes(), limit, after);
163
+ }
151
164
  // --- Traversal ---
152
165
  neighbors(nodeId, direction, edgeType) {
153
166
  const results = new Set();
@@ -171,7 +184,7 @@ export class SqliteGraphBackend {
171
184
  for (const row of rows)
172
185
  results.add(row.target);
173
186
  }
174
- return [...results];
187
+ return [...results].sort(compareGraphIds);
175
188
  }
176
189
  // --- Set operations (native SQL) ---
177
190
  intersection(setA, setB) {
@@ -10,6 +10,22 @@
10
10
  * @source @src/artifacts/types.ts
11
11
  * @tests @test/unit/artifacts/graph-backend.test.ts
12
12
  */
13
+ /** SQLite BINARY collation and local backends share this UTF-8 byte order. */
14
+ export function compareGraphIds(left, right) {
15
+ return Buffer.compare(Buffer.from(left, 'utf8'), Buffer.from(right, 'utf8'));
16
+ }
17
+ export function pageGraphIds(ids, limit, after) {
18
+ if (!Number.isInteger(limit) || limit < 1 || limit > 10_000) {
19
+ throw new Error('graph page limit must be an integer from 1 through 10000');
20
+ }
21
+ const ordered = [...ids].sort(compareGraphIds);
22
+ const eligible = after === undefined ? ordered : ordered.filter(id => compareGraphIds(id, after) > 0);
23
+ const nodes = eligible.slice(0, limit);
24
+ return {
25
+ nodes,
26
+ ...(eligible.length > limit && nodes.length ? { nextCursor: nodes[nodes.length - 1] } : {}),
27
+ };
28
+ }
13
29
  /**
14
30
  * Create a graph backend instance.
15
31
  *
@@ -42,6 +42,17 @@ export const FEATURE_CATALOG = [
42
42
  ],
43
43
  cost: '~5 MB — native compile via node-gyp',
44
44
  },
45
+ {
46
+ name: 'postgres',
47
+ description: 'Advanced direct PostgreSQL canonical-storage backend',
48
+ packages: ['pg'],
49
+ packageSpecs: { pg: '8.23.0' },
50
+ enables: [
51
+ 'aiwg.storage-backend/v1 direct PostgreSQL persistence',
52
+ 'transactional migration batches, snapshots, cursors, and tombstones',
53
+ ],
54
+ cost: '~1 MB — pure JavaScript PostgreSQL client',
55
+ },
45
56
  {
46
57
  name: 'pty',
47
58
  description: 'PTY bridge for daemon — pass-through interactive TUIs (Claude Code, Codex, etc.)',
@@ -43,8 +43,17 @@ export const STORAGE_BACKEND_MATRIX = {
43
43
  sqlite: descriptor('sqlite', 'supported', [...BASELINE, 'atomic-batch', 'consistent-snapshot', 'tombstones', 'idempotency-keys', 'filtered-query', 'cursor-pagination', 'backup', 'restore'], 'wal', 'single-host', 'serializable', 'regenerable-index'),
44
44
  'fortemi-core-static': descriptor('fortemi-core-static', 'supported', ['read', 'filtered-query', 'recursive-traversal', 'set-operations'], 'filesystem', 'single-host', 'snapshot', 'static-cache'),
45
45
  'fortemi-server': descriptor('fortemi-server', 'alpha', [...BASELINE, 'filtered-query', 'health', 'tls', 'tenant-isolation'], 'replicated', 'remote-service', 'none', 'remote-persistence'),
46
- 'postgres-direct': descriptor('postgres-direct', 'advanced', [], 'replicated', 'remote-service', 'none', 'canonical'),
47
- 'postgres-postgrest': descriptor('postgres-postgrest', 'advanced', [], 'replicated', 'remote-service', 'none', 'canonical'),
46
+ 'postgres-direct': descriptor('postgres-direct', 'advanced', [
47
+ ...BASELINE, 'atomic-batch', 'consistent-snapshot', 'change-cursor',
48
+ 'tombstones', 'idempotency-keys', 'recursive-traversal', 'set-operations',
49
+ 'filtered-query', 'cursor-pagination', 'health', 'readiness', 'telemetry',
50
+ 'tenant-isolation', 'tls',
51
+ ], 'replicated', 'remote-service', 'serializable', 'canonical'),
52
+ 'postgres-postgrest': descriptor('postgres-postgrest', 'advanced', [
53
+ ...BASELINE, 'atomic-batch', 'consistent-snapshot', 'change-cursor',
54
+ 'tombstones', 'idempotency-keys', 'filtered-query', 'cursor-pagination',
55
+ 'health', 'readiness', 'tenant-isolation', 'tls',
56
+ ], 'replicated', 'remote-service', 'serializable', 'canonical'),
48
57
  mysql: descriptor('mysql', 'deferred', [], 'replicated', 'remote-service', 'none', 'canonical'),
49
58
  };
50
59
  function descriptor(backend, maturity, capabilities, durability, availability, isolation, dataClass) {
@@ -0,0 +1,144 @@
1
+ export const POSTGRES_SCHEMA_VERSION = 1;
2
+ export const POSTGRES_SCHEMA_V1_SQL = `
3
+ CREATE TABLE IF NOT EXISTS aiwg_storage_schema (
4
+ singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
5
+ schema_version integer NOT NULL
6
+ );
7
+ INSERT INTO aiwg_storage_schema(singleton, schema_version)
8
+ VALUES (true, 1) ON CONFLICT (singleton) DO NOTHING;
9
+ CREATE TABLE IF NOT EXISTS aiwg_storage_records (
10
+ tenant text NOT NULL,
11
+ subsystem text NOT NULL,
12
+ path text NOT NULL,
13
+ source_revision text NOT NULL,
14
+ digest text NOT NULL,
15
+ value jsonb,
16
+ tombstone boolean NOT NULL DEFAULT false,
17
+ deleted_at timestamptz,
18
+ delete_reason text,
19
+ idempotency_key text NOT NULL,
20
+ change_seq bigserial NOT NULL,
21
+ updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
22
+ PRIMARY KEY (tenant, subsystem, path),
23
+ UNIQUE (tenant, subsystem, idempotency_key)
24
+ );
25
+ CREATE INDEX IF NOT EXISTS aiwg_storage_records_change
26
+ ON aiwg_storage_records(tenant, subsystem, change_seq, path);
27
+ CREATE TABLE IF NOT EXISTS aiwg_storage_batch_receipts (
28
+ tenant text NOT NULL,
29
+ subsystem text NOT NULL,
30
+ batch_id uuid NOT NULL,
31
+ payload_digest text NOT NULL,
32
+ high_water_mark bigint NOT NULL,
33
+ receipt jsonb NOT NULL,
34
+ committed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
35
+ PRIMARY KEY (tenant, subsystem, batch_id)
36
+ );
37
+ CREATE TABLE IF NOT EXISTS aiwg_storage_edges (
38
+ tenant text NOT NULL,
39
+ subsystem text NOT NULL,
40
+ source_path text NOT NULL,
41
+ target_path text NOT NULL,
42
+ edge_type text NOT NULL,
43
+ PRIMARY KEY (tenant, subsystem, source_path, target_path, edge_type)
44
+ );
45
+ CREATE INDEX IF NOT EXISTS aiwg_storage_edges_target
46
+ ON aiwg_storage_edges(tenant, subsystem, target_path, edge_type);
47
+ `;
48
+ const POSTGREST_FUNCTIONS = [
49
+ 'aiwg_reload_schema_v1()',
50
+ 'aiwg_health_v1(text,text)',
51
+ 'aiwg_changes_v1(text,text,bigint,integer)',
52
+ 'aiwg_snapshot_v1(text,text,integer)',
53
+ 'aiwg_query_records_v1(text,text,jsonb,text,integer,boolean)',
54
+ 'aiwg_get_record_v1(text,text,text)',
55
+ 'aiwg_commit_batch_v1(text,text,uuid,text,jsonb)',
56
+ 'aiwg_record_v1(aiwg_storage_records)',
57
+ ];
58
+ export async function inspectPostgresSchema(client) {
59
+ const exists = await client.query("SELECT to_regclass('public.aiwg_storage_schema') IS NOT NULL AS exists");
60
+ if (!exists.rows[0]?.exists)
61
+ return { version: 0, records: 0, receipts: 0, edges: 0 };
62
+ const result = await client.query(`
63
+ SELECT schema_version,
64
+ (SELECT count(*) FROM aiwg_storage_records) AS records,
65
+ (SELECT count(*) FROM aiwg_storage_batch_receipts) AS receipts,
66
+ (SELECT count(*) FROM aiwg_storage_edges) AS edges
67
+ FROM aiwg_storage_schema WHERE singleton=true`);
68
+ const row = result.rows[0];
69
+ if (Number(row?.schema_version) !== 1)
70
+ throw new Error(`unsupported PostgreSQL storage schema ${String(row?.schema_version)}`);
71
+ return { version: 1, records: Number(row.records), receipts: Number(row.receipts), edges: Number(row.edges) };
72
+ }
73
+ /** Upgrade an empty database to v1 under a dedicated migration-role connection. */
74
+ export async function upgradePostgresSchemaV1(client) {
75
+ await client.query('BEGIN');
76
+ try {
77
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended('aiwg-storage-schema',0))");
78
+ const before = await inspectPostgresSchema(client);
79
+ if (before.version === 1) {
80
+ await client.query('COMMIT');
81
+ return before;
82
+ }
83
+ await client.query(POSTGRES_SCHEMA_V1_SQL);
84
+ const after = await inspectPostgresSchema(client);
85
+ if (after.version !== 1)
86
+ throw new Error('PostgreSQL schema upgrade did not reach v1');
87
+ await client.query('COMMIT');
88
+ return after;
89
+ }
90
+ catch (error) {
91
+ try {
92
+ await client.query('ROLLBACK');
93
+ }
94
+ catch { /* preserve upgrade error */ }
95
+ throw error;
96
+ }
97
+ }
98
+ /**
99
+ * Roll v1 back to an empty schema. This is intentionally impossible without
100
+ * exact version and observed-count acknowledgements from a prior inspection.
101
+ */
102
+ export async function rollbackPostgresSchemaV1(client, approval) {
103
+ if (approval.expectedVersion !== 1 || approval.allowDataLoss !== true)
104
+ throw new Error('explicit v1 data-loss approval is required');
105
+ await client.query('BEGIN');
106
+ try {
107
+ await client.query("SELECT pg_advisory_xact_lock(hashtextextended('aiwg-storage-schema',0))");
108
+ const before = await inspectPostgresSchema(client);
109
+ const observed = { records: before.records, receipts: before.receipts, edges: before.edges };
110
+ if (before.version !== 1 || JSON.stringify(observed) !== JSON.stringify(approval.expectedCounts)) {
111
+ throw new Error('schema rollback approval does not match current version/counts');
112
+ }
113
+ for (const signature of POSTGREST_FUNCTIONS)
114
+ await client.query(`DROP FUNCTION IF EXISTS ${signature}`);
115
+ await client.query('DROP TABLE aiwg_storage_edges, aiwg_storage_batch_receipts, aiwg_storage_records, aiwg_storage_schema');
116
+ const after = await inspectPostgresSchema(client);
117
+ if (after.version !== 0)
118
+ throw new Error('PostgreSQL schema rollback did not reach v0');
119
+ await client.query('COMMIT');
120
+ return after;
121
+ }
122
+ catch (error) {
123
+ try {
124
+ await client.query('ROLLBACK');
125
+ }
126
+ catch { /* preserve rollback error */ }
127
+ throw error;
128
+ }
129
+ }
130
+ export function postgresLeastPrivilegeSql(runtimeRole) {
131
+ const role = quoteIdentifier(runtimeRole);
132
+ return `REVOKE ALL ON aiwg_storage_records, aiwg_storage_batch_receipts, aiwg_storage_edges FROM PUBLIC;
133
+ REVOKE ALL ON aiwg_storage_schema FROM PUBLIC;
134
+ GRANT USAGE ON SCHEMA public TO ${role};
135
+ GRANT SELECT, INSERT, UPDATE, DELETE ON aiwg_storage_records, aiwg_storage_batch_receipts, aiwg_storage_edges TO ${role};
136
+ GRANT SELECT ON aiwg_storage_schema TO ${role};
137
+ GRANT USAGE, SELECT ON SEQUENCE aiwg_storage_records_change_seq_seq TO ${role};`;
138
+ }
139
+ function quoteIdentifier(value) {
140
+ if (!value || value.length > 63 || /[\u0000-\u001f]/.test(value))
141
+ throw new Error('runtime role must be a printable PostgreSQL identifier up to 63 characters');
142
+ return `"${value.replaceAll('"', '""')}"`;
143
+ }
144
+ //# sourceMappingURL=postgres-schema.js.map