@intutic/cli 2.5.0 → 2.5.1

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.
Files changed (113) hide show
  1. package/README.md +27 -16
  2. package/dist/cli.js +24 -11
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/attenuate.d.ts +9 -8
  5. package/dist/commands/attenuate.d.ts.map +1 -1
  6. package/dist/commands/attenuate.js +18 -11
  7. package/dist/commands/attenuate.js.map +1 -1
  8. package/dist/commands/budget.d.ts.map +1 -1
  9. package/dist/commands/budget.js +3 -6
  10. package/dist/commands/budget.js.map +1 -1
  11. package/dist/commands/chaos.js +3 -2
  12. package/dist/commands/chaos.js.map +1 -1
  13. package/dist/commands/connect.d.ts +7 -0
  14. package/dist/commands/connect.d.ts.map +1 -1
  15. package/dist/commands/connect.js +79 -31
  16. package/dist/commands/connect.js.map +1 -1
  17. package/dist/commands/disconnect.d.ts +11 -1
  18. package/dist/commands/disconnect.d.ts.map +1 -1
  19. package/dist/commands/disconnect.js +18 -3
  20. package/dist/commands/disconnect.js.map +1 -1
  21. package/dist/commands/doctor.d.ts +7 -0
  22. package/dist/commands/doctor.d.ts.map +1 -1
  23. package/dist/commands/doctor.js +21 -17
  24. package/dist/commands/doctor.js.map +1 -1
  25. package/dist/commands/enforce.d.ts +14 -1
  26. package/dist/commands/enforce.d.ts.map +1 -1
  27. package/dist/commands/enforce.js +21 -3
  28. package/dist/commands/enforce.js.map +1 -1
  29. package/dist/commands/gateway.d.ts.map +1 -1
  30. package/dist/commands/gateway.js +3 -3
  31. package/dist/commands/gateway.js.map +1 -1
  32. package/dist/commands/guardrails.d.ts.map +1 -1
  33. package/dist/commands/guardrails.js +10 -8
  34. package/dist/commands/guardrails.js.map +1 -1
  35. package/dist/commands/integrity.d.ts.map +1 -1
  36. package/dist/commands/integrity.js +4 -1
  37. package/dist/commands/integrity.js.map +1 -1
  38. package/dist/commands/judge.d.ts.map +1 -1
  39. package/dist/commands/judge.js +6 -9
  40. package/dist/commands/judge.js.map +1 -1
  41. package/dist/commands/login.js +4 -4
  42. package/dist/commands/login.js.map +1 -1
  43. package/dist/commands/policy.d.ts +16 -0
  44. package/dist/commands/policy.d.ts.map +1 -1
  45. package/dist/commands/policy.js +86 -7
  46. package/dist/commands/policy.js.map +1 -1
  47. package/dist/commands/siem.d.ts.map +1 -1
  48. package/dist/commands/siem.js.map +1 -1
  49. package/dist/commands/skill.d.ts +52 -1
  50. package/dist/commands/skill.d.ts.map +1 -1
  51. package/dist/commands/skill.js +147 -40
  52. package/dist/commands/skill.js.map +1 -1
  53. package/dist/commands/sops.d.ts +61 -50
  54. package/dist/commands/sops.d.ts.map +1 -1
  55. package/dist/commands/sops.js +425 -166
  56. package/dist/commands/sops.js.map +1 -1
  57. package/dist/commands/start.d.ts.map +1 -1
  58. package/dist/commands/start.js +2 -1
  59. package/dist/commands/start.js.map +1 -1
  60. package/dist/commands/status.d.ts.map +1 -1
  61. package/dist/commands/status.js +2 -1
  62. package/dist/commands/status.js.map +1 -1
  63. package/dist/commands/traces.d.ts.map +1 -1
  64. package/dist/commands/traces.js +4 -3
  65. package/dist/commands/traces.js.map +1 -1
  66. package/dist/config/paths.d.ts +15 -9
  67. package/dist/config/paths.d.ts.map +1 -1
  68. package/dist/config/paths.js +24 -10
  69. package/dist/config/paths.js.map +1 -1
  70. package/dist/harness/ag2.d.ts +4 -2
  71. package/dist/harness/ag2.d.ts.map +1 -1
  72. package/dist/harness/ag2.js +4 -2
  73. package/dist/harness/ag2.js.map +1 -1
  74. package/dist/harness/autogen.d.ts +10 -11
  75. package/dist/harness/autogen.d.ts.map +1 -1
  76. package/dist/harness/autogen.js +14 -15
  77. package/dist/harness/autogen.js.map +1 -1
  78. package/dist/harness/detector.d.ts.map +1 -1
  79. package/dist/harness/detector.js +5 -2
  80. package/dist/harness/detector.js.map +1 -1
  81. package/dist/harness/windsurf.d.ts.map +1 -1
  82. package/dist/harness/windsurf.js +4 -2
  83. package/dist/harness/windsurf.js.map +1 -1
  84. package/dist/lib/api.d.ts +7 -3
  85. package/dist/lib/api.d.ts.map +1 -1
  86. package/dist/lib/caTrust.d.ts +43 -1
  87. package/dist/lib/caTrust.d.ts.map +1 -1
  88. package/dist/lib/caTrust.js +109 -9
  89. package/dist/lib/caTrust.js.map +1 -1
  90. package/dist/lib/ciscoScanner.d.ts +9 -4
  91. package/dist/lib/ciscoScanner.d.ts.map +1 -1
  92. package/dist/lib/ciscoScanner.js +42 -8
  93. package/dist/lib/ciscoScanner.js.map +1 -1
  94. package/dist/lib/decisionsLogRefresh.d.ts.map +1 -1
  95. package/dist/lib/decisionsLogRefresh.js +6 -2
  96. package/dist/lib/decisionsLogRefresh.js.map +1 -1
  97. package/dist/lib/enforcementState.d.ts +7 -0
  98. package/dist/lib/enforcementState.d.ts.map +1 -1
  99. package/dist/lib/enforcementState.js +9 -0
  100. package/dist/lib/enforcementState.js.map +1 -1
  101. package/dist/lib/mdmManifest.d.ts.map +1 -1
  102. package/dist/lib/mdmManifest.js +33 -3
  103. package/dist/lib/mdmManifest.js.map +1 -1
  104. package/dist/lib/proxyBinary.d.ts +11 -0
  105. package/dist/lib/proxyBinary.d.ts.map +1 -1
  106. package/dist/lib/proxyBinary.js +16 -0
  107. package/dist/lib/proxyBinary.js.map +1 -1
  108. package/dist/lib/sopFrontMatter.d.ts +35 -0
  109. package/dist/lib/sopFrontMatter.d.ts.map +1 -1
  110. package/dist/lib/sopFrontMatter.js +60 -0
  111. package/dist/lib/sopFrontMatter.js.map +1 -1
  112. package/package.json +5 -5
  113. package/resources/sandbox/firecracker-launch.sh +8 -7
@@ -1,158 +1,390 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import { createHash } from 'node:crypto';
3
- import { join } from 'node:path';
3
+ import { basename, dirname, join } from 'node:path';
4
4
  import { log } from '../lib/logger.js';
5
5
  import { NOT_AUTHENTICATED } from '../lib/authMessages.js';
6
6
  import { loadCredentials, loadConfig } from '../config/store.js';
7
7
  import { resolveControlPlaneUrl } from '../config/paths.js';
8
8
  import { createApiClient } from '../lib/api.js';
9
- import { parseSopFile, titleFromFileName, renderSopFile, slugifyTitle } from '../lib/sopFrontMatter.js';
9
+ import { parseSopFile, titleFromFileName, renderSopFile, slugifyTitle, setFrontMatter, withSyncRecord, } from '../lib/sopFrontMatter.js';
10
10
  async function getClient(dev) {
11
11
  const creds = await loadCredentials();
12
12
  if (!creds) {
13
13
  log.error(NOT_AUTHENTICATED);
14
14
  process.exit(1);
15
15
  }
16
- const config = loadConfig();
17
- const devMode = dev || config?.devMode || process.env.INTUTIC_DEV === '1';
18
- const controlPlaneUrl = resolveControlPlaneUrl(devMode);
16
+ const controlPlaneUrl = resolveControlPlaneUrl(dev);
19
17
  return createApiClient(controlPlaneUrl, creds.apiKey);
20
18
  }
19
+ /**
20
+ * What `sops push <name>` reads: the folder `.intutic/sops/<name>/` when
21
+ * there is one, else the single file `.intutic/sops/<name>.md` (or `<name>`
22
+ * given with its `.md`). The file form is the layout `sops pull` writes and
23
+ * the proxy enforces, so a pulled, edited SOP pushes back by its file name.
24
+ */
25
+ export async function resolvePushTarget(sopsRoot, name) {
26
+ const folder = join(sopsRoot, name);
27
+ const isDir = await fs.stat(folder).then((st) => st.isDirectory(), () => false);
28
+ if (isDir) {
29
+ let files;
30
+ try {
31
+ files = (await fs.readdir(folder)).filter((e) => e.endsWith('.md')).sort();
32
+ }
33
+ catch (err) {
34
+ return { error: `Error reading SOP files: ${err instanceof Error ? err.message : String(err)}` };
35
+ }
36
+ if (files.length === 0)
37
+ return { error: `No markdown (.md) files found in local SOP folder: ${folder}` };
38
+ return { dir: folder, files };
39
+ }
40
+ const fileName = name.endsWith('.md') ? name : `${name}.md`;
41
+ const file = join(sopsRoot, fileName);
42
+ const isFile = await fs.stat(file).then((st) => st.isFile(), () => false);
43
+ if (isFile)
44
+ return { dir: dirname(file), files: [basename(file)] };
45
+ return { error: `No SOP folder ${folder}/ or file ${file} found.` };
46
+ }
21
47
  /**
22
48
  * `intutic sops push <name>` — one control-plane SOP per file under
23
- * `.intutic/sops/<name>/*.md`, each carrying that file's own declared
24
- * `title:`/`risk_tier:`/`version:` front matter rather than the folder's
25
- * name and two hardcoded constants.
49
+ * `.intutic/sops/<name>/*.md` (or the single file `.intutic/sops/<name>.md`),
50
+ * each carrying that file's own declared `title:`/`risk_tier:`/`version:`
51
+ * front matter.
26
52
  *
27
- * Previously concatenated every file in the folder into one blob under one
28
- * title derived from the folder name, and always sent `risk_tier: 'LOW'`,
29
- * `version: '1.0.0'` regardless of what the files declared — a folder of
30
- * five SOPs at five different risk tiers pushed as one LOW-tier SOP whose
31
- * title named none of them. Per-file push is what the DB plane can actually
32
- * represent faithfully: `sop_registry` has no concept of "a folder of SOPs,"
33
- * only individual rows.
53
+ * A file that records the SOP it was synced with (`intutic_id:`, written by
54
+ * `sops pull` and by a previous push) updates that SOP in place through
55
+ * `PUT /api/v1/sops/:id`, the same edit path as the dashboard: a DRAFT is
56
+ * edited in place, and an approved SOP gets a new DRAFT version while the
57
+ * approved one keeps enforcing until the draft is promoted. The recorded
58
+ * `intutic_revision:` goes along as `version_counter`, so an edit made on the
59
+ * server since the file was synced is refused (409) instead of overwritten;
60
+ * `--force` sends the server's current counter instead. A file with no record
61
+ * creates a new SOP. Either way, a successful push writes the SOP's id and
62
+ * counter back into the file's front matter (a forked DRAFT's id, when the
63
+ * edit forked one), keeping every other line of the file.
34
64
  *
35
- * Front matter is stripped before the body is sent as `markdownContent` —
36
- * `formatOrgSopConstraints` in `routes/judge.ts` interpolates that field
37
- * verbatim into the judge's system prompt, and a `---\nrisk_tier: HIGH\n---`
38
- * block in the middle of it is noise no judge prompt should carry.
39
- * Declarative enforcement keys (`deny_tools:`, `requires_before:`, ...) have
40
- * no `sop_registry` column, so they are lost from the DB-plane row exactly
41
- * as they always were — this fixes what already had somewhere to go
42
- * (title, risk tier, version), not what does not.
65
+ * Front matter is stripped before the body is sent as `markdown_content` —
66
+ * the judge reads that field verbatim. Declarative enforcement keys
67
+ * (`deny_tools:`, `requires_before:`, ...) have no `sop_registry` column, so
68
+ * they stay file-plane only.
43
69
  */
44
70
  export async function runSopsPush(name, opts) {
45
71
  log.header(`Intutic — Push SOP: ${name}`);
46
72
  const config = loadConfig();
47
73
  const workspaceRoot = config?.workspaceRoot ?? process.cwd();
48
- const sopsDir = join(workspaceRoot, '.intutic', 'sops', name);
49
- try {
50
- const stat = await fs.stat(sopsDir);
51
- if (!stat.isDirectory()) {
52
- log.error(`Path is not a directory: ${sopsDir}`);
53
- process.exit(1);
54
- }
74
+ const sopsRoot = join(workspaceRoot, '.intutic', 'sops');
75
+ const target = await resolvePushTarget(sopsRoot, name);
76
+ if ('error' in target) {
77
+ log.error(target.error);
78
+ process.exit(1);
55
79
  }
56
- catch {
57
- log.error(`Local SOP folder not found: ${sopsDir}`);
80
+ const { dir: sopsDir, files: mdFiles } = target;
81
+ const client = await getClient(opts.dev);
82
+ const ctx = { client, opts, successorsOf: null };
83
+ const counts = { created: 0, updated: 0, forked: 0, 'up-to-date': 0, skipped: 0, failed: 0 };
84
+ for (const file of mdFiles) {
85
+ const outcome = opts.org
86
+ ? await pushOrgFile(client, sopsDir, file)
87
+ : await pushFile(ctx, join(sopsDir, file), file);
88
+ counts[outcome] += 1;
89
+ }
90
+ log.info(`${mdFiles.length} file(s) from ${sopsDir}: ${counts.created} created, ${counts.updated} updated, ` +
91
+ `${counts.forked} new DRAFT version(s), ${counts['up-to-date']} up to date, ${counts.failed} failed` +
92
+ (counts.skipped > 0 ? `, ${counts.skipped} skipped` : '') +
93
+ '.');
94
+ if (counts.failed > 0)
58
95
  process.exit(1);
96
+ }
97
+ const enc = encodeURIComponent;
98
+ function errMessage(err) {
99
+ return err instanceof Error ? err.message : String(err);
100
+ }
101
+ /** `--org` pushes a mandatory org-wide floor: create-only, as before. */
102
+ async function pushOrgFile(client, sopsDir, file) {
103
+ const parsed = parseSopFile(await fs.readFile(join(sopsDir, file), 'utf-8'));
104
+ if (!parsed.body) {
105
+ log.warn(`Skipping ${file}: empty after stripping front matter.`);
106
+ return 'skipped';
59
107
  }
60
- let mdFiles;
108
+ const title = parsed.title ?? titleFromFileName(file);
109
+ // The server derives orgId from the caller's own workspace; this CLI never
110
+ // resolves or supplies one.
61
111
  try {
62
- mdFiles = (await fs.readdir(sopsDir)).filter((e) => e.endsWith('.md')).sort();
63
- if (mdFiles.length === 0) {
64
- log.error(`No markdown (.md) files found in local SOP folder: ${sopsDir}`);
65
- process.exit(1);
112
+ const res = await client.post('/api/v1/workspace/org-sops', {
113
+ title,
114
+ markdown_content: parsed.body,
115
+ ...(parsed.riskTier ? { risk_tier: parsed.riskTier } : {}),
116
+ });
117
+ if (res && res.orgSopId) {
118
+ log.info(`${file} → org-wide floor "${title}"`);
119
+ log.field('Org SOP ID', res.orgSopId);
120
+ return 'created';
66
121
  }
122
+ log.error(`${file}: failed to create org SOP on the control plane.`);
67
123
  }
68
124
  catch (err) {
69
- log.error(`Error reading SOP files: ${err instanceof Error ? err.message : String(err)}`);
70
- process.exit(1);
125
+ log.error(`${file}: failed to push org SOP: ${errMessage(err)}`);
71
126
  }
72
- const client = await getClient(opts.dev);
73
- let failures = 0;
74
- for (const file of mdFiles) {
75
- const raw = await fs.readFile(join(sopsDir, file), 'utf-8');
76
- const parsed = parseSopFile(raw);
77
- if (!parsed.body) {
78
- log.warn(`Skipping ${file}: empty after stripping front matter.`);
79
- continue;
127
+ return 'failed';
128
+ }
129
+ async function pushFile(ctx, filePath, file) {
130
+ const raw = await fs.readFile(filePath, 'utf-8');
131
+ const parsed = parseSopFile(raw);
132
+ if (!parsed.body) {
133
+ log.warn(`Skipping ${file}: empty after stripping front matter.`);
134
+ return 'skipped';
135
+ }
136
+ const title = parsed.title ?? titleFromFileName(file);
137
+ if (parsed.intuticId) {
138
+ const id = parsed.intuticId;
139
+ let current;
140
+ try {
141
+ current = await ctx.client.getWithStatus(`/api/v1/sops/${enc(id)}`);
80
142
  }
81
- const title = parsed.title ?? titleFromFileName(file);
82
- // --org pushes a mandatory org-wide floor (migration 139) instead of a
83
- // workspace SOP -- the server derives orgId from the caller's own
84
- // workspace; this CLI never resolves or supplies one.
85
- if (opts.org) {
86
- try {
87
- const res = await client.post('/api/v1/workspace/org-sops', {
88
- title,
89
- markdown_content: parsed.body,
90
- ...(parsed.riskTier ? { risk_tier: parsed.riskTier } : {}),
91
- });
92
- if (res && res.orgSopId) {
93
- log.info(`${file} → org-wide floor "${title}"`);
94
- log.field('Org SOP ID', res.orgSopId);
95
- }
96
- else {
97
- log.error(`${file}: failed to create org SOP on the control plane.`);
98
- failures += 1;
99
- }
100
- }
101
- catch (err) {
102
- log.error(`${file}: failed to push org SOP: ${err instanceof Error ? err.message : String(err)}`);
103
- failures += 1;
104
- }
105
- continue;
143
+ catch (err) {
144
+ log.error(`${file}: could not read SOP ${id}: ${errMessage(err)}`);
145
+ return 'failed';
106
146
  }
107
- try {
108
- const res = await client.post('/api/v1/sops', {
109
- title,
110
- markdown_content: parsed.body,
111
- // Declared, not guessed: an unstated risk_tier lets the DB's own
112
- // default (MEDIUM) apply rather than this CLI silently choosing
113
- // LOW for every SOP that never mentioned one.
114
- risk_tier: parsed.riskTier ?? 'MEDIUM',
115
- // No front-matter equivalent exists for complexity — nothing in
116
- // packages/proxy/src/sops.rs's front matter declares it.
117
- complexity_tier: 'TIER_0',
118
- ...(parsed.version ? { version: parsed.version } : {}),
119
- });
120
- if (res && res.sopId) {
121
- log.info(`${file} → workspace SOP "${title}"`);
122
- log.field('SOP ID', res.sopId);
123
- }
124
- else {
125
- log.error(`${file}: failed to create SOP on the control plane.`);
126
- failures += 1;
147
+ if (current.status === 404) {
148
+ if (!ctx.opts.create) {
149
+ log.error(`${file}: SOP ${id} (its intutic_id) is no longer on the control plane. ` +
150
+ 'Re-run with --create to create it again as a new SOP.');
151
+ return 'failed';
127
152
  }
153
+ log.info(`${file}: SOP ${id} is gone; creating it again (--create).`);
128
154
  }
129
- catch (err) {
130
- log.error(`${file}: failed to push SOP: ${err instanceof Error ? err.message : String(err)}`);
131
- failures += 1;
155
+ else if (current.status !== 200 || !current.body.sopId) {
156
+ log.error(`${file}: could not read SOP ${id} (${current.status}): ${current.body.message ?? current.body.error ?? 'no detail'}`);
157
+ return 'failed';
158
+ }
159
+ else {
160
+ return updateFile(ctx, filePath, file, raw, parsed, title, current.body);
132
161
  }
133
162
  }
134
- log.info(`Pushed ${mdFiles.length - failures}/${mdFiles.length} file(s) from ${sopsDir}.`);
135
- if (failures > 0)
136
- process.exit(1);
163
+ return createFile(ctx, filePath, file, raw, parsed, title);
164
+ }
165
+ async function createFile(ctx, filePath, file, raw, parsed, title) {
166
+ let sopId;
167
+ try {
168
+ // The route answers with the created SOP itself; it sends no `ok`.
169
+ const res = await ctx.client.post('/api/v1/sops', {
170
+ title,
171
+ markdown_content: parsed.body,
172
+ // Declared, not guessed: an unstated risk_tier gets the DB's own
173
+ // default (MEDIUM) rather than this CLI choosing LOW.
174
+ risk_tier: parsed.riskTier ?? 'MEDIUM',
175
+ // No front-matter equivalent exists for complexity.
176
+ complexity_tier: 'TIER_0',
177
+ ...(parsed.version ? { version: parsed.version } : {}),
178
+ });
179
+ sopId = res?.sopId;
180
+ }
181
+ catch (err) {
182
+ log.error(`${file}: failed to push SOP: ${errMessage(err)}`);
183
+ return 'failed';
184
+ }
185
+ if (!sopId) {
186
+ log.error(`${file}: failed to create SOP on the control plane.`);
187
+ return 'failed';
188
+ }
189
+ log.info(`${file} → new workspace SOP "${title}"`);
190
+ log.field('SOP ID', sopId);
191
+ // The create response carries no version counter; read it back so the next
192
+ // push has its precondition. A new SOP starts at 1.
193
+ let revision = 1;
194
+ try {
195
+ const created = await ctx.client.getWithStatus(`/api/v1/sops/${enc(sopId)}`);
196
+ if (created.status === 200 && typeof created.body.versionCounter === 'number')
197
+ revision = created.body.versionCounter;
198
+ }
199
+ catch {
200
+ // Keep 1: the SOP was just created, so nothing has edited it yet.
201
+ }
202
+ await recordSync(filePath, file, raw, { intuticId: sopId, revision });
203
+ return 'created';
204
+ }
205
+ async function updateFile(ctx, filePath, file, raw, parsed, title, current) {
206
+ const id = current.sopId;
207
+ const serverRevision = current.versionCounter;
208
+ const sameContent = current.title === title &&
209
+ (current.markdownContent ?? '').trim() === parsed.body &&
210
+ (parsed.riskTier === null || parsed.riskTier === current.riskTier) &&
211
+ (parsed.version === null || parsed.version === current.version);
212
+ if (sameContent) {
213
+ log.info(`${file}: up to date with "${title}" (${id}).`);
214
+ if (typeof serverRevision === 'number' && serverRevision !== parsed.revision) {
215
+ await recordSync(filePath, file, raw, { intuticId: id, revision: serverRevision });
216
+ }
217
+ return 'up-to-date';
218
+ }
219
+ if (!ctx.opts.force && (parsed.revision === null || parsed.revision !== serverRevision)) {
220
+ log.error(`${file}: "${title}" changed on the server since you pulled it ` +
221
+ `(revision ${serverRevision ?? '?'} there, ${parsed.revision ?? 'none'} recorded here). ` +
222
+ 'Run `intutic sops pull` to fetch it, or push with --force to overwrite it.');
223
+ return 'failed';
224
+ }
225
+ // Editing an approved SOP forks a new DRAFT version. If one already exists,
226
+ // a second fork would leave two competing drafts of one guideline.
227
+ if (current.lifecycleState && current.lifecycleState !== 'DRAFT' && !ctx.opts.force) {
228
+ const successors = (await successorsOf(ctx)).get(id) ?? [];
229
+ if (successors.length > 0) {
230
+ log.error(`${file}: "${title}" already has a newer DRAFT version on the server (${successors.join(', ')}). ` +
231
+ 'Edit that draft instead (`intutic sops pull` writes it to its own file), or push with --force to fork another.');
232
+ return 'failed';
233
+ }
234
+ }
235
+ const body = {
236
+ title,
237
+ markdown_content: parsed.body,
238
+ ...(parsed.riskTier && parsed.riskTier !== current.riskTier ? { risk_tier: parsed.riskTier } : {}),
239
+ // Only a version the file changed: an unchanged one would pin a forked
240
+ // draft to the approved version's number instead of the server's bump.
241
+ ...(parsed.version && parsed.version !== current.version ? { version: parsed.version } : {}),
242
+ ...(typeof serverRevision === 'number'
243
+ ? { version_counter: ctx.opts.force ? serverRevision : parsed.revision }
244
+ : {}),
245
+ };
246
+ let res;
247
+ try {
248
+ res = await ctx.client.putWithStatus(`/api/v1/sops/${enc(id)}`, body);
249
+ }
250
+ catch (err) {
251
+ log.error(`${file}: failed to update SOP ${id}: ${errMessage(err)}`);
252
+ return 'failed';
253
+ }
254
+ if (res.status === 409) {
255
+ log.error(`${file}: "${title}" changed on the server since you pulled it (${res.body.error ?? 'version conflict'}). ` +
256
+ 'Run `intutic sops pull` to fetch it, or push with --force to overwrite it.');
257
+ return 'failed';
258
+ }
259
+ if (res.status === 403) {
260
+ // WEAKEN_REQUIRES_OWNER (the edit loosens the guideline) or the role check.
261
+ log.error(`${file}: the control plane refused the edit: ${res.body.message ?? res.body.error ?? 'forbidden'}`);
262
+ return 'failed';
263
+ }
264
+ if (res.status === 404) {
265
+ log.error(`${file}: SOP ${id} is no longer on the control plane. Re-run with --create to create it again as a new SOP.`);
266
+ return 'failed';
267
+ }
268
+ const sop = res.body.sop;
269
+ if (res.status !== 200 || !sop?.sopId) {
270
+ log.error(`${file}: update failed (${res.status}): ${res.body.message ?? res.body.error ?? 'no detail'}`);
271
+ return 'failed';
272
+ }
273
+ const forked = sop.sopId !== id;
274
+ const revision = typeof sop.versionCounter === 'number' ? sop.versionCounter : (serverRevision ?? 0) + 1;
275
+ const extra = {};
276
+ if (parsed.version !== null && sop.version && sop.version !== parsed.version)
277
+ extra.version = sop.version;
278
+ await recordSync(filePath, file, raw, { intuticId: sop.sopId, revision }, extra);
279
+ if (forked) {
280
+ log.info(`${file} → new DRAFT version of "${title}"`);
281
+ log.field('SOP ID', sop.sopId);
282
+ log.dim(` ${id} (${current.lifecycleState}) keeps enforcing until the draft is promoted.`);
283
+ return 'forked';
284
+ }
285
+ log.info(`${file} → updated "${title}" in place`);
286
+ log.field('SOP ID', sop.sopId);
287
+ return 'updated';
288
+ }
289
+ /** Write the sync record into the file, keeping everything else; a failure is reported, not fatal — the server write already happened. */
290
+ async function recordSync(filePath, file, raw, record, extra = {}) {
291
+ try {
292
+ await fs.writeFile(filePath, withSyncRecord(raw, record, extra), 'utf-8');
293
+ }
294
+ catch (err) {
295
+ log.warn(`${file}: pushed, but could not record intutic_id ${record.intuticId} in the file: ${errMessage(err)}`);
296
+ }
297
+ }
298
+ async function successorsOf(ctx) {
299
+ if (ctx.successorsOf)
300
+ return ctx.successorsOf;
301
+ const map = new Map();
302
+ try {
303
+ for (const item of await listAllSops(ctx.client)) {
304
+ if (!item.priorVersionId)
305
+ continue;
306
+ map.set(item.priorVersionId, [...(map.get(item.priorVersionId) ?? []), item.sopId]);
307
+ }
308
+ }
309
+ catch {
310
+ // Without the listing the check cannot run; the server's own
311
+ // precondition still applies to the PUT.
312
+ }
313
+ ctx.successorsOf = map;
314
+ return map;
315
+ }
316
+ /** The route's own cap on `limit`. */
317
+ const SOP_PAGE_SIZE = 100;
318
+ /**
319
+ * Every SOP in the workspace. `GET /api/v1/sops` pages by `page` and `limit`
320
+ * (at most 100) and reports `total`, so one request stopped at the first 100.
321
+ */
322
+ async function listAllSops(client) {
323
+ const all = [];
324
+ for (let page = 1;; page++) {
325
+ const res = await client.get(`/api/v1/sops?page=${page}&limit=${SOP_PAGE_SIZE}`);
326
+ const items = res.items ?? [];
327
+ // A control plane that ignores `page` would hand back page 1 forever.
328
+ if (items.length > 0 && all.some((s) => s.sopId === items[0].sopId))
329
+ return all;
330
+ all.push(...items);
331
+ if (items.length < SOP_PAGE_SIZE)
332
+ return all;
333
+ if (typeof res.total === 'number' && all.length >= res.total)
334
+ return all;
335
+ }
336
+ }
337
+ /**
338
+ * Every SOP file under `.intutic/sops/`: the flat `*.md` files `sops pull`
339
+ * writes and the `<name>/*.md` folder layout `sops push <name>` also reads.
340
+ * `guardrail-*.md` are `intutic guardrails pull`'s and have no SOP behind
341
+ * them. Empty when the directory does not exist.
342
+ */
343
+ async function readLocalSops(sopsDir) {
344
+ let entries;
345
+ try {
346
+ entries = await fs.readdir(sopsDir, { withFileTypes: true });
347
+ }
348
+ catch {
349
+ return [];
350
+ }
351
+ const out = [];
352
+ const add = async (path, label) => {
353
+ const raw = await fs.readFile(path, 'utf-8').catch(() => null);
354
+ if (raw !== null)
355
+ out.push({ path, label, raw, parsed: parseSopFile(raw) });
356
+ };
357
+ for (const e of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
358
+ if (e.isFile() && e.name.endsWith('.md') && !e.name.startsWith('guardrail-')) {
359
+ await add(join(sopsDir, e.name), e.name);
360
+ }
361
+ else if (e.isDirectory()) {
362
+ const inner = await fs.readdir(join(sopsDir, e.name)).catch(() => []);
363
+ for (const f of inner.filter((n) => n.endsWith('.md')).sort())
364
+ await add(join(sopsDir, e.name, f), `${e.name}/${f}`);
365
+ }
366
+ }
367
+ return out;
137
368
  }
138
369
  /**
139
- * `intutic sops pull` — the round trip `push` never had: every workspace SOP
140
- * on the control plane, written to `.intutic/sops/<slug-of-title>.md` with
141
- * front matter reconstructed from the row (`title:`, `risk_tier:`,
142
- * `version:`).
370
+ * `intutic sops pull` — every workspace SOP on the control plane, written
371
+ * with front matter reconstructed from the row (`title:`, `risk_tier:`,
372
+ * `version:`) plus the sync record `sops push` reads: `intutic_id:` (the
373
+ * SOP) and `intutic_revision:` (its `version_counter`).
374
+ *
375
+ * A SOP lands in the local file that already records its id — flat or in a
376
+ * `<name>/` folder — and otherwise in `.intutic/sops/<slug-of-title>.md`. An
377
+ * existing file keeps its other front-matter lines (the enforcing keys the
378
+ * proxy reads); only the synced keys and the body are replaced. An approved
379
+ * version is skipped when a local file already tracks its DRAFT successor,
380
+ * so a push that forked a draft does not come back as a second file.
143
381
  *
144
382
  * Refuses to overwrite a file it did not itself write most recently, unless
145
383
  * `--force` is passed. The check is a recorded hash, not a live diff: every
146
- * file this command writes carries `content_hash: sha256(body)` in its own
147
- * front matter (see `renderSopFile`), and a later pull compares that
148
- * recorded value against the CURRENT local body's hash — not against the
149
- * control plane's. A mismatch means a human edited the file since the last
150
- * pull; a match means nothing local changed, so pulling fresh content from
151
- * the plane (which may itself have changed) is safe. A file with no
152
- * recorded hash at all — hand-authored, or only ever pushed — is treated as
153
- * unverifiable and always requires `--force`, since overwriting a
154
- * hand-written SOP the first time this command sees it would be the
155
- * expensive direction to get wrong.
384
+ * file this command writes carries `content_hash: sha256(body)`, and a later
385
+ * pull compares that against the CURRENT local body's hash. A mismatch means
386
+ * a human edited the file since; a file with no recorded hash at all —
387
+ * hand-authored — is treated as unverifiable and always requires `--force`.
156
388
  */
157
389
  export async function runSopsPull(opts) {
158
390
  log.header('Intutic — Pull SOPs');
@@ -162,8 +394,7 @@ export async function runSopsPull(opts) {
162
394
  const client = await getClient(opts.dev);
163
395
  let items;
164
396
  try {
165
- const res = await client.get('/api/v1/sops?limit=100');
166
- items = res.items ?? [];
397
+ items = await listAllSops(client);
167
398
  }
168
399
  catch (err) {
169
400
  log.error(`Failed to list SOPs: ${err instanceof Error ? err.message : String(err)}`);
@@ -174,10 +405,27 @@ export async function runSopsPull(opts) {
174
405
  return;
175
406
  }
176
407
  await fs.mkdir(sopsDir, { recursive: true });
408
+ const local = await readLocalSops(sopsDir);
409
+ const fileById = new Map();
410
+ for (const f of local)
411
+ if (f.parsed.intuticId)
412
+ fileById.set(f.parsed.intuticId, f);
413
+ // An approved version whose DRAFT successor a local file tracks.
414
+ const supersededBy = new Map();
415
+ for (const item of items) {
416
+ const tracked = fileById.get(item.sopId);
417
+ if (item.priorVersionId && tracked)
418
+ supersededBy.set(item.priorVersionId, tracked.label);
419
+ }
177
420
  let written = 0;
178
421
  let conflicts = 0;
179
422
  const seenSlugs = new Set();
180
423
  for (const item of items) {
424
+ const tracked = fileById.get(item.sopId);
425
+ if (!tracked && supersededBy.has(item.sopId)) {
426
+ log.dim(` ${item.title} (${item.sopId}): skipped, ${supersededBy.get(item.sopId)} tracks its newer version.`);
427
+ continue;
428
+ }
181
429
  let detail;
182
430
  try {
183
431
  detail = await client.get(`/api/v1/sops/${item.sopId}`);
@@ -190,20 +438,23 @@ export async function runSopsPull(opts) {
190
438
  log.warn(`${item.title}: no content on the control plane, skipping.`);
191
439
  continue;
192
440
  }
193
- // Two SOPs with the same title collide on the same file. Rather than
194
- // silently overwriting one with the other, the second gets its sopId
195
- // suffixed — a visible, if ugly, signal beats a quiet data loss.
196
- let slug = slugifyTitle(detail.title);
197
- if (seenSlugs.has(slug))
198
- slug = `${slug}-${detail.sopId.slice(-6)}`;
199
- seenSlugs.add(slug);
200
- const filePath = join(sopsDir, `${slug}.md`);
201
- const rendered = renderSopFile({
202
- title: detail.title,
203
- riskTier: detail.riskTier,
204
- version: detail.version,
205
- body: detail.markdownContent.trim(),
206
- });
441
+ let filePath;
442
+ if (tracked) {
443
+ filePath = tracked.path;
444
+ }
445
+ else {
446
+ // Two SOPs with the same title collide on the same file, as does a file
447
+ // that records a different SOP. The second gets its sopId suffixed — a
448
+ // visible, if ugly, signal beats a quiet data loss.
449
+ let slug = slugifyTitle(detail.title);
450
+ const claimed = (s) => seenSlugs.has(s) || local.some((f) => f.path === join(sopsDir, `${s}.md`) && f.parsed.intuticId !== null && f.parsed.intuticId !== detail.sopId);
451
+ if (claimed(slug))
452
+ slug = `${slug}-${detail.sopId.slice(-6)}`;
453
+ seenSlugs.add(slug);
454
+ filePath = join(sopsDir, `${slug}.md`);
455
+ }
456
+ const body = detail.markdownContent.trim();
457
+ const record = { intuticId: detail.sopId, revision: detail.versionCounter ?? item.versionCounter ?? 1 };
207
458
  const existing = await fs.readFile(filePath, 'utf-8').catch(() => null);
208
459
  if (existing !== null && !opts.force) {
209
460
  const parsedExisting = parseSopFile(existing);
@@ -215,6 +466,16 @@ export async function runSopsPull(opts) {
215
466
  continue;
216
467
  }
217
468
  }
469
+ const rendered = existing === null
470
+ ? renderSopFile({ title: detail.title, riskTier: detail.riskTier, version: detail.version, body, record })
471
+ : setFrontMatter(existing, {
472
+ title: detail.title,
473
+ risk_tier: detail.riskTier,
474
+ version: detail.version,
475
+ content_hash: createHash('sha256').update(body, 'utf8').digest('hex'),
476
+ intutic_id: record.intuticId,
477
+ intutic_revision: String(record.revision),
478
+ }, body);
218
479
  await fs.writeFile(filePath, rendered, 'utf-8');
219
480
  written += 1;
220
481
  log.info(`${filePath} ← "${detail.title}"`);
@@ -225,52 +486,47 @@ export async function runSopsPull(opts) {
225
486
  }
226
487
  }
227
488
  /**
228
- * `intutic sops status` — drift between `.intutic/sops/*.md` and the
229
- * control plane, read-only against the file system (the comparison itself
230
- * never writes a local file).
489
+ * `intutic sops status` — drift between the local SOP files (flat and
490
+ * `<name>/` folders) and the control plane, read-only against the file
491
+ * system.
231
492
  *
232
- * Matched by title, the same heuristic `pull` uses to name files: neither
233
- * plane has a stable identifier the other side carries (a pushed file has
234
- * no `sopId` until after the fact; a filename is not a title). A file
235
- * `pull` has never touched carries no `content_hash:` marker, so this
236
- * cannot tell "hand-authored, never pushed" apart from "hand-authored,
237
- * edited after an old pull" — both report `diverged` rather than guessing.
493
+ * A file that records its SOP (`intutic_id:`) is compared with that SOP; a
494
+ * file without one is matched by title, the heuristic `pull` used before
495
+ * files carried ids. `intutic_revision:` tells whether the server moved since
496
+ * the file was synced, so a file edited on both sides reports `diverged`
497
+ * rather than `local-ahead`. A file with no `content_hash:` marker cannot be
498
+ * told apart as "never pulled" or "edited after an old pull", so it reports
499
+ * `diverged` when its content differs.
238
500
  *
239
- * The comparison itself is computed entirely client-side, same as always.
240
- * What's new: once computed, every matched (non-`push-only`) result is
241
- * reported to `POST /api/v1/sops/git-drift-report`, best-effort — a live
242
- * sync-daemon reconciliation loop is still not attempted here (still "a
243
- * second project", per `apps/docs/guide/gitops-sops.md`), but a human or CI
244
- * job running this command on a schedule now leaves the compliance probe
245
- * (`sop_git_drift`) something to read instead of nothing. Report failures
246
- * never fail the command — the read-only status output above is already
247
- * complete and correct on its own.
501
+ * Every matched (non-`push-only`) result is reported to
502
+ * `POST /api/v1/sops/git-drift-report`, best-effort, so the compliance probe
503
+ * (`sop_git_drift`) has something to read. Report failures never fail the
504
+ * command.
248
505
  */
249
506
  export async function runSopsStatus(opts) {
250
507
  log.header('Intutic — SOP Drift Status');
251
508
  const config = loadConfig();
252
509
  const workspaceRoot = config?.workspaceRoot ?? process.cwd();
253
510
  const sopsDir = join(workspaceRoot, '.intutic', 'sops');
254
- let localFiles;
255
511
  try {
256
- // `guardrail-*.md` are written by `intutic guardrails pull` and have no
257
- // control-plane SOP to drift against; listing them here would report every
258
- // one as push-only.
259
- localFiles = (await fs.readdir(sopsDir)).filter((f) => f.endsWith('.md') && !f.startsWith('guardrail-')).sort();
512
+ await fs.access(sopsDir);
260
513
  }
261
514
  catch {
262
515
  log.info(`No local SOPs directory at ${sopsDir}.`);
263
516
  return;
264
517
  }
518
+ const localFiles = await readLocalSops(sopsDir);
265
519
  if (localFiles.length === 0) {
266
520
  log.info(`No .md files in ${sopsDir}.`);
267
521
  return;
268
522
  }
269
523
  const client = await getClient(opts.dev);
524
+ let remoteById;
270
525
  let remoteByTitle;
271
526
  try {
272
- const res = await client.get('/api/v1/sops?limit=100');
273
- remoteByTitle = new Map((res.items ?? []).map((r) => [r.title, { sopId: r.sopId, contentHash: r.contentHash }]));
527
+ const items = await listAllSops(client);
528
+ remoteById = new Map(items.map((r) => [r.sopId, r]));
529
+ remoteByTitle = new Map(items.map((r) => [r.title, r]));
274
530
  }
275
531
  catch (err) {
276
532
  log.error(`Failed to fetch workspace SOPs: ${err instanceof Error ? err.message : String(err)}`);
@@ -278,34 +534,35 @@ export async function runSopsStatus(opts) {
278
534
  }
279
535
  const counts = { 'in-sync': 0, 'local-ahead': 0, 'remote-ahead': 0, diverged: 0, 'push-only': 0 };
280
536
  const driftReport = [];
281
- for (const file of localFiles) {
282
- const raw = await fs.readFile(join(sopsDir, file), 'utf-8');
283
- const parsed = parseSopFile(raw);
284
- const title = parsed.title ?? titleFromFileName(file);
537
+ for (const { label: file, parsed } of localFiles) {
538
+ const title = parsed.title ?? titleFromFileName(file.slice(file.lastIndexOf('/') + 1));
285
539
  const localHash = createHash('sha256').update(parsed.body, 'utf8').digest('hex');
286
- const remote = remoteByTitle.get(title);
540
+ const remote = parsed.intuticId ? remoteById.get(parsed.intuticId) : remoteByTitle.get(title);
287
541
  if (!remote) {
288
542
  counts['push-only'] += 1;
289
- log.field(file, `push-only — no SOP titled "${title}" on the control plane yet`);
543
+ log.field(file, parsed.intuticId
544
+ ? `push-only — its SOP ${parsed.intuticId} is not active on the control plane (deleted, or replaced by a promoted version); \`sops push --create\` creates it again`
545
+ : `push-only — no SOP titled "${title}" on the control plane yet`);
290
546
  continue;
291
547
  }
292
548
  const remoteHash = remote.contentHash;
549
+ const remoteMoved = parsed.revision !== null && typeof remote.versionCounter === 'number' && remote.versionCounter !== parsed.revision;
293
550
  let status;
294
551
  if (remoteHash && localHash === remoteHash) {
295
552
  status = 'in-sync';
296
553
  }
297
554
  else if (parsed.contentHash === localHash) {
298
- // Unedited since the last pull, but the local hash no longer matches
299
- // the control plane — the control plane moved, this file did not.
555
+ // Unedited since the last sync, but no longer what the control plane
556
+ // holds — the control plane moved, this file did not.
300
557
  status = 'remote-ahead';
301
558
  }
302
559
  else if (parsed.contentHash !== null) {
303
- // Had a recorded pull hash, and the file no longer matches it — a
304
- // human edited it since.
305
- status = 'local-ahead';
560
+ // Edited since the last sync. If the server's revision moved too, both
561
+ // sides changed.
562
+ status = remoteMoved ? 'diverged' : 'local-ahead';
306
563
  }
307
564
  else {
308
- // No pull marker at all, and it does not match the control plane
565
+ // No sync marker at all, and it does not match the control plane
309
566
  // either: cannot tell "never pulled" apart from "edited since a very
310
567
  // old pull" from local information alone.
311
568
  status = 'diverged';
@@ -316,7 +573,9 @@ export async function runSopsStatus(opts) {
316
573
  'in-sync': 'in sync',
317
574
  'local-ahead': 'locally modified — `sops push` to publish',
318
575
  'remote-ahead': 'control plane has newer content — `sops pull` to fetch',
319
- diverged: 'diverges from the control plane, and was never (verifiably) pulled',
576
+ diverged: remoteMoved
577
+ ? 'changed both here and on the control plane — `sops pull --force` or `sops push --force` picks one'
578
+ : 'diverges from the control plane, and was never (verifiably) pulled',
320
579
  'push-only': '',
321
580
  }[status];
322
581
  log.field(file, `${status} — ${label}`);