@stravica-ai/digital-operator-pa 0.1.67 → 0.1.69

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 (29) hide show
  1. package/dist/engineering-pa-cli/docs/coordinator-hook.md +14 -1
  2. package/dist/engineering-pa-cli/src/agent/cli.mjs +18 -8
  3. package/dist/engineering-pa-cli/src/agent/probes.mjs +94 -0
  4. package/dist/engineering-pa-cli/src/pa/board-check.mjs +633 -0
  5. package/dist/engineering-pa-cli/src/pa/cli.mjs +44 -7
  6. package/dist/engineering-pa-cli/src/pa/dispatch-cli.mjs +655 -0
  7. package/dist/engineering-pa-cli/src/pa/repo-root.mjs +29 -10
  8. package/dist/engineering-pa-cli/src/pa/session.mjs +13 -16
  9. package/dist/engineering-pa-cli/src/pa/status-board.mjs +5 -1
  10. package/dist/engineering-pa-core/knowledge/delegation-dispatch/delegation-modes.md +1 -1
  11. package/dist/engineering-pa-core/knowledge/operator-interface/session-boot-and-end.md +4 -3
  12. package/dist/engineering-pa-core/knowledge/scaffold/agent-baseline.json +26 -1
  13. package/dist/engineering-pa-core/knowledge/scaffold/agent-baseline.schema.json +40 -1
  14. package/dist/engineering-pa-core/knowledge/work-management/spawn-log-lock.md +167 -0
  15. package/dist/engineering-pa-core/knowledge/work-management/spawn-log.md +34 -6
  16. package/dist/engineering-pa-core/knowledge/work-management/status-board.md +32 -2
  17. package/dist/engineering-pa-core/src/agent-baseline/manifest.mjs +2 -0
  18. package/dist/engineering-pa-core/src/dispatch/label.mjs +23 -0
  19. package/dist/engineering-pa-core/src/spawn-log/read.mjs +355 -0
  20. package/dist/engineering-pa-core/src/spawn-log/write.mjs +763 -0
  21. package/dist/engineering-pa-scaffold/src/generator.mjs +178 -9
  22. package/dist/engineering-pa-scaffold/templates/CLAUDE.md.template +4 -1
  23. package/dist/engineering-pa-workspace/README.md +31 -0
  24. package/dist/engineering-pa-workspace/app/delegations.html +21 -9
  25. package/dist/engineering-pa-workspace/app/home.html +1 -1
  26. package/dist/engineering-pa-workspace/app/notes.html +57 -2
  27. package/dist/engineering-pa-workspace/app/status-board.html +37 -0
  28. package/dist/engineering-pa-workspace/src/api.mjs +232 -61
  29. package/package.json +1 -1
@@ -156,6 +156,19 @@ quietly widen the policy.
156
156
  Wire the hook up in your OWN `~/.claude/settings.json` (per-machine operator
157
157
  territory — this package never writes to it):
158
158
 
159
+ The command is the launcher the scaffold writes into a new instance's
160
+ `.claude/settings.json` (copy it from that file): it walks up from
161
+ `$CLAUDE_PROJECT_DIR` to the nearest installed
162
+ `node_modules/@stravica-ai/digital-operator-pa/bin/pa-coordinator-hook` and
163
+ needs only `node` on the hook PATH.
164
+
165
+ This launcher, like the other PreToolUse gate, has no stdout latch. The
166
+ scaffold's SessionStart launchers do: once their bin has written a byte to
167
+ fd 1 they print nothing of their own. That latch counts only
168
+ `process.stdout.write` and `fs.writeSync` to fd 1, so a SessionStart bin that
169
+ writes with async `fs.write`, `fs.writev` or `fs.writeFileSync(1)` bypasses
170
+ it. No shipped bin writes that way.
171
+
159
172
  ```jsonc
160
173
  {
161
174
  "hooks": {
@@ -165,7 +178,7 @@ territory — this package never writes to it):
165
178
  "hooks": [
166
179
  {
167
180
  "type": "command",
168
- "command": "pa-coordinator-hook"
181
+ "command": "node -e 'var P=require(\"path\"),d=process.env.CLAUDE_PROJECT_DIR,q=\"node_modules/@stravica-ai/digital-operator-pa/bin/pa-coordinator-hook\",y=e=>x(e?.message||e),x=r=>process.exit(console.error(\"pa-coordinator-hook could not run: \"+r)|1);for(var c=P.resolve(d||x(\"no CLAUDE_PROJECT_DIR\")),b,n;!require(\"fs\").existsSync(b=P.join(c,q));c=n)if(c==(n=P.dirname(c)))x(\"no \"+q+\" above \"+d);process.argv.splice(1,0,b);import(require(\"url\").pathToFileURL(b)).catch(e=>process.listenerCount(\"uncaughtException\")?process.nextTick(()=>{throw e}):y(e))'"
169
182
  }
170
183
  ]
171
184
  }
@@ -18,7 +18,9 @@
18
18
  //
19
19
  // Cache: <repo-root>/.session/agent-audit.json.
20
20
  // Staleness: 60 minutes, override via operator/config/preferences.json:
21
- // agentAudit.cacheStalenessMinutes.
21
+ // agentAudit.cacheStalenessMinutes. A cache whose manifestVersion
22
+ // differs from the bundled manifest's (or that has none) is stale
23
+ // at any age, so a package update shows new capabilities at once.
22
24
  //
23
25
  // Performance target: cache-read <2s (session-boot budget), refresh <3s
24
26
  // on a real estate. All probes are Node-only synchronous, so the whole
@@ -52,7 +54,8 @@ Global flags:
52
54
 
53
55
  Cache file: <repo-root>/.session/agent-audit.json.
54
56
  Staleness threshold: 60 minutes (override via operator/config/preferences.json:
55
- agentAudit.cacheStalenessMinutes).
57
+ agentAudit.cacheStalenessMinutes). A cache written under another manifest
58
+ version is rebuilt at any age.
56
59
 
57
60
  Exit codes (tooling standard §6):
58
61
  0 ok | 1 usage | 2 unresolvable state | 2 --strict fail
@@ -129,11 +132,12 @@ async function runAudit({ flags, forceRefresh }) {
129
132
  err.exitCode = 2;
130
133
  throw err;
131
134
  }
135
+ const manifest = loadBundledManifestSync();
132
136
  const cached = forceRefresh ? null : readCache(cachePath);
133
- const stale = cached ? isStale(cached, staleness) : true;
137
+ const stale = cached ? isStale(cached, staleness, manifest.manifestVersion) : true;
134
138
  let audit;
135
139
  if (!cached || stale) {
136
- audit = performAudit({ repoRoot, paName });
140
+ audit = performAudit({ repoRoot, paName, manifest });
137
141
  writeCache(cachePath, audit);
138
142
  } else {
139
143
  audit = cached;
@@ -153,8 +157,8 @@ async function runAudit({ flags, forceRefresh }) {
153
157
  * Perform a fresh audit — pure orchestration around the manifest loader
154
158
  * and the audit runner. Exported for the boot-context hook and tests.
155
159
  */
156
- export function performAudit({ repoRoot, paName, now, hostname }) {
157
- const manifest = loadBundledManifestSync();
160
+ export function performAudit({ repoRoot, paName, now, hostname, manifest }) {
161
+ manifest = manifest ?? loadBundledManifestSync();
158
162
  const ctx = {
159
163
  repoRoot,
160
164
  paName,
@@ -192,11 +196,17 @@ function writeCache(path, cache) {
192
196
  }
193
197
  }
194
198
 
195
- function isStale(cache, stalenessMinutes) {
199
+ /**
200
+ * A cache is stale when it was written under another manifest version (or
201
+ * carries none), so an upgraded package never serves the old capability
202
+ * list, or when it is older than the staleness threshold.
203
+ */
204
+ export function isStale(cache, stalenessMinutes, manifestVersion, nowMs = Date.now()) {
205
+ if (cache?.manifestVersion === undefined || cache.manifestVersion !== manifestVersion) return true;
196
206
  if (!cache?.capturedAt) return true;
197
207
  const capturedMs = Date.parse(cache.capturedAt);
198
208
  if (!Number.isFinite(capturedMs)) return true;
199
- const ageMs = Date.now() - capturedMs;
209
+ const ageMs = nowMs - capturedMs;
200
210
  return ageMs > stalenessMinutes * 60 * 1000;
201
211
  }
202
212
 
@@ -33,6 +33,8 @@ export function runProbe(probe, ctx) {
33
33
  case 'knowledge-doc-exists': return probeKnowledgeDocExists(probe, ctx);
34
34
  case 'schedule-defined': return probeScheduleDefined(probe, ctx);
35
35
  case 'json-field-includes': return probeJsonFieldIncludes(probe, ctx);
36
+ case 'settings-hook-includes': return probeSettingsHookIncludes(probe, ctx);
37
+ case 'file-includes': return probeFileIncludes(probe, ctx);
36
38
  default:
37
39
  return {
38
40
  outcome: UNVERIFIED,
@@ -234,3 +236,95 @@ function probeJsonFieldIncludes(probe, ctx) {
234
236
  }
235
237
  return { outcome: PASS, detail: `${rel}: '${field}' includes ${JSON.stringify(value)}` };
236
238
  }
239
+
240
+ function probeSettingsHookIncludes(probe, ctx) {
241
+ // Reads every candidate that exists: `path` (the scaffold writes
242
+ // `.claude/settings.json` at the repo root) and each `altPaths` entry
243
+ // (an agent-folder settings file). A session may load only one of them,
244
+ // so the probe passes when any candidate holds the hook: some entry under
245
+ // `hooks.<event>` with a matcher containing `matcherIncludes` and a
246
+ // command hook whose command contains every `commandIncludes` string.
247
+ // A candidate that is not valid JSON (or cannot be read) does not stop
248
+ // the search. With no pass, the result is unverified-here when some
249
+ // candidate was invalid, else fail; the detail names each candidate read.
250
+ // No candidate at all fails.
251
+ const candidates = [...new Set([probe.path, ...(Array.isArray(probe.altPaths) ? probe.altPaths : [])]
252
+ .filter((p) => typeof p === 'string' && p.length > 0)
253
+ .map((p) => expandTemplate(p, ctx)))];
254
+ const present = candidates.filter((r) => existsSync(join(ctx.repoRoot, r)));
255
+ if (!present.length) {
256
+ return { outcome: FAIL, detail: `settings file missing: ${candidates.join(' or ')}` };
257
+ }
258
+ const misses = [];
259
+ let invalid = false;
260
+ for (const rel of present) {
261
+ const r = settingsFileHook(rel, probe, ctx);
262
+ if (r.pass) return { outcome: PASS, detail: r.detail };
263
+ if (r.invalid) invalid = true;
264
+ misses.push(r.detail);
265
+ }
266
+ return { outcome: invalid ? UNVERIFIED : FAIL, detail: misses.join('; ') };
267
+ }
268
+
269
+ /** One settings file: { pass, invalid, detail }. Never throws. */
270
+ function settingsFileHook(rel, probe, ctx) {
271
+ let text;
272
+ try {
273
+ text = readFileSync(join(ctx.repoRoot, rel), 'utf8');
274
+ } catch (err) {
275
+ return { pass: false, invalid: true, detail: `${rel} could not be read: ${err.message}` };
276
+ }
277
+ let doc;
278
+ try {
279
+ doc = JSON.parse(text);
280
+ } catch (err) {
281
+ return { pass: false, invalid: true, detail: `${rel} not valid JSON: ${err.message}` };
282
+ }
283
+ const event = probe.event;
284
+ const wants = Array.isArray(probe.commandIncludes) ? probe.commandIncludes : [];
285
+ const matcherWant = typeof probe.matcherIncludes === 'string' ? probe.matcherIncludes : null;
286
+ const entries = doc?.hooks?.[event];
287
+ if (!Array.isArray(entries)) {
288
+ return { pass: false, invalid: false, detail: `${rel}: no hooks.${event} entries` };
289
+ }
290
+ for (const entry of entries) {
291
+ if (!entry || typeof entry !== 'object') continue;
292
+ const matcher = typeof entry.matcher === 'string' ? entry.matcher : '';
293
+ if (matcherWant !== null && !matcher.includes(matcherWant)) continue;
294
+ for (const hook of Array.isArray(entry.hooks) ? entry.hooks : []) {
295
+ if (!hook || hook.type !== 'command' || typeof hook.command !== 'string') continue;
296
+ if (wants.every((w) => hook.command.includes(w))) {
297
+ return { pass: true, invalid: false, detail: `${rel}: hooks.${event} matcher ${JSON.stringify(matcher)} runs ${JSON.stringify(hook.command)}` };
298
+ }
299
+ }
300
+ }
301
+ const matcherText = matcherWant === null ? '' : ` with a matcher containing ${JSON.stringify(matcherWant)}`;
302
+ return {
303
+ pass: false,
304
+ invalid: false,
305
+ detail: `${rel}: no hooks.${event} entry${matcherText} runs a command containing ${JSON.stringify(wants)}`,
306
+ };
307
+ }
308
+
309
+ function probeFileIncludes(probe, ctx) {
310
+ const rel = expandTemplate(probe.path, ctx);
311
+ const abs = join(ctx.repoRoot, rel);
312
+ if (!existsSync(abs)) {
313
+ return { outcome: FAIL, detail: `missing: ${rel}` };
314
+ }
315
+ let text;
316
+ try {
317
+ if (!statSync(abs).isFile()) {
318
+ return { outcome: FAIL, detail: `path exists but is not a file: ${rel}` };
319
+ }
320
+ text = readFileSync(abs, 'utf8');
321
+ } catch (err) {
322
+ return { outcome: UNVERIFIED, detail: `read failed for ${rel}: ${err.message}` };
323
+ }
324
+ const wants = Array.isArray(probe.includes) ? probe.includes : [];
325
+ const missing = wants.filter((w) => !text.includes(w));
326
+ if (missing.length) {
327
+ return { outcome: FAIL, detail: `${rel} does not include ${JSON.stringify(missing)}` };
328
+ }
329
+ return { outcome: PASS, detail: `${rel} includes ${JSON.stringify(wants)}` };
330
+ }