phronesis 1.0.0 → 1.1.0

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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/act.js +62 -1
  3. package/src/adapter.js +366 -61
  4. package/src/cli.js +527 -32
  5. package/src/compile.js +141 -10
  6. package/src/doctor.js +63 -2
  7. package/src/hook.js +312 -0
  8. package/src/hooks-refresh.js +1363 -0
  9. package/src/hooks.js +48 -33
  10. package/src/init.js +37 -4
  11. package/src/launcher.js +2105 -0
  12. package/src/layout.js +51 -7
  13. package/src/profile.js +169 -0
  14. package/src/prompts.js +88 -0
  15. package/src/skills-bump.js +88 -20
  16. package/src/skills.js +103 -10
  17. package/templates/codex/INDEX.md +4 -2
  18. package/templates/codex/seed/ai-practice/the-workspace-is-part-of-the-thinking.md +96 -0
  19. package/templates/codex-surface/README.md +15 -16
  20. package/templates/codex-surface/hooks/_resolve.sh +25 -24
  21. package/templates/codex-surface/hooks/recall-guard.sh +5 -30
  22. package/templates/codex-surface/hooks/session-start.sh +5 -18
  23. package/templates/codex-surface/hooks/session-sweep-precompact.sh +5 -20
  24. package/templates/codex-surface/hooks/session-sweep-subagent.sh +5 -23
  25. package/templates/codex-surface/hooks/session-sweep.sh +5 -19
  26. package/templates/launcher/phronesis +95 -0
  27. package/templates/phronesis-hooks/compile-active-context.sh +12 -38
  28. package/templates/phronesis-hooks/recall-guard.sh +12 -103
  29. package/templates/phronesis-hooks/session-start.sh +10 -88
  30. package/templates/phronesis-hooks/session-sweep.sh +16 -139
  31. package/templates/phronesis-hooks/skill-lifecycle.sh +12 -37
  32. package/templates/skills/lint/SKILL.md +7 -2
  33. package/templates/skills/onboard/SKILL.md +47 -9
  34. package/templates/skills/onboard/evals/rubric.md +5 -3
  35. package/templates/skills/prd-draft/SKILL.md +34 -26
  36. package/templates/skills/prd-draft/evals/rubric.md +1 -1
  37. package/vendor/core/src/action-registry.js +6 -3
  38. package/vendor/core/src/actions.js +120 -16
  39. package/vendor/core/src/index.js +3 -0
  40. package/vendor/core/src/lint.js +22 -5
  41. package/vendor/core/src/skill-lifecycle.js +67 -7
@@ -80,9 +80,9 @@ function seedRefFromPath(path) {
80
80
 
81
81
  function orphanDetail(scope) {
82
82
  if (!scope.all_domains) {
83
- return 'domain compiled object has no inbound links from the scoped graph';
83
+ return 'domain compiled object has no typed link in either direction to another object in the scoped graph (links to raw sources do not count as connectivity)';
84
84
  }
85
- return 'domain compiled object has no inbound links that resolve within their source domain; bare targets from other domains do not count as cross-domain inbound links';
85
+ return 'domain compiled object has no typed link in either direction to another object that resolves within its source domain; links to raw sources and bare targets from other domains do not count as connectivity';
86
86
  }
87
87
 
88
88
  function lintRecords(records, scope) {
@@ -204,11 +204,28 @@ export async function runWorkspaceLint({
204
204
  const findings = [];
205
205
  const adoptions = adoptedSeedMap(records);
206
206
 
207
- // 1. Orphans: domain compiled objects with no inbound object edge.
207
+ // 1. Orphans: an in-scope domain object with NO typed edge in either direction to another
208
+ // in-scope object (core-0061 D1 — direction-agnostic). The engine's own action types write
209
+ // hub-and-spoke (`log_decision` links `enables → projects/*` outbound), so inbound-only
210
+ // counting flags every spoke and reports a maximally healthy young workspace as ~90%
211
+ // orphans — pure noise. Connectivity counts only edges whose OTHER endpoint is an in-scope
212
+ // node: `index.has(edge.to)` for outbound drops raw-source + unresolved forward targets
213
+ // (never nodes); every edge's `from` is an in-scope node by construction. Self-edges never
214
+ // count. Cross-domain scoping (D3) is structural: an edge's `to` resolves within its source
215
+ // domain, so it can only land on a node when that domain is itself in scope.
208
216
  for (const record of records) {
209
217
  if (record.ctx.layer !== 'domain') continue;
210
- const inbound = (index.inEdges.get(record.rel) ?? []).filter((edge) => edge.from !== record.rel);
211
- if (inbound.length === 0) {
218
+ // Connectivity counts edges to/from another in-scope object that is *earned mesh* — a
219
+ // domain compiled object or ADOPTED codex (codex/personal). A quarantined codex/seed node
220
+ // is discoverability, not connectivity (D1 / change 0053): a domain object linked only to
221
+ // an unadopted seed, or pointed at only by one, is still an orphan. `seedRefFromPath` is
222
+ // non-null exactly for codex/seed/ paths, so it excludes seed endpoints in both directions.
223
+ const outbound = index.outEdges.get(record.rel) ?? [];
224
+ const inbound = index.inEdges.get(record.rel) ?? [];
225
+ const connected =
226
+ outbound.some((edge) => edge.to !== record.rel && index.has(edge.to) && seedRefFromPath(edge.to) === null) ||
227
+ inbound.some((edge) => edge.from !== record.rel && seedRefFromPath(edge.from) === null);
228
+ if (!connected) {
212
229
  findings.push({
213
230
  surface: 'orphan',
214
231
  kind: 'orphan',
@@ -15,8 +15,13 @@ import { appendAuditEntry } from './hook-executor.js';
15
15
  import { matchFrontmatterBlock } from './workspace-scan.js';
16
16
  import { resolveInstall } from './install-root.js';
17
17
  import { advanceSchedulesForSkill } from './schedule.js';
18
+ import { RE_SLUG } from './contract.js';
18
19
 
19
20
  const RECORD_VERSION = 1;
21
+ // The DEFAULT surface for a minted lifecycle event. Claude Code was the sole minter until change
22
+ // 0056; the producer now takes a `surface` parameter (default this value) so a first-party in-process
23
+ // surface — Craft Phronesis Desktop — mints `skill.invoked`/`skill.completed` truthfully with its own
24
+ // `surface: 'desktop'`. Every existing Claude Code caller omits the parameter and is unchanged.
20
25
  const SURFACE = 'claude-code';
21
26
  const ACTOR = 'agent';
22
27
  const DEDUPE_WINDOW_MS = 30_000;
@@ -195,6 +200,34 @@ async function resolveSkillMetadata({ installRoot, hookInput }) {
195
200
  return null;
196
201
  }
197
202
 
203
+ // Surface-neutral skill metadata for a FIRST-PARTY model-invocation (change 0056). Unlike a Claude
204
+ // Code hook (which carries a `PreToolUse`+`Skill` or slash envelope), an in-process surface's
205
+ // `invoke_skill(name)` already holds the skill name. The descriptor is `{ name, version?, mode?,
206
+ // start_path? }`; the version resolves from the install registry / SKILL.md when omitted, exactly as
207
+ // the hook path resolves it — and, mirroring the hook path, an unregistered/unknown skill returns
208
+ // null (the lifecycle stream only starts for skills the workspace actually knows about). The name is
209
+ // slug-validated before it reaches a `skills/{name}/SKILL.md` read (path-traversal fail-closed).
210
+ async function resolveExplicitSkill({ installRoot, skill }) {
211
+ if (!isPlainObject(skill)) return null;
212
+ const name = nonEmptyString(skill.name);
213
+ if (!name || !RE_SLUG.test(name)) return null;
214
+ // The skill MUST exist in the workspace (registry entry or a readable SKILL.md) before a lifecycle is
215
+ // minted (Codex R3 F5): a caller-supplied `version` for an unknown skill would fabricate a
216
+ // skill.invoked/completed pair for a skill that cannot run. Prove existence FIRST, then a caller
217
+ // version may pin it; otherwise fall to the resolved one.
218
+ const existingVersion =
219
+ (await registrySkillVersion(installRoot, name)) || (await fileSkillVersion(installRoot, name));
220
+ if (!existingVersion) return null;
221
+ const version = nonEmptyString(skill.version) || existingVersion;
222
+ return {
223
+ skill_name: name,
224
+ version,
225
+ mode: nonEmptyString(skill.mode) || 'suggested',
226
+ start_path: nonEmptyString(skill.start_path) || 'model',
227
+ start_event: 'model-invocation',
228
+ };
229
+ }
230
+
198
231
  function mintInvocationId() {
199
232
  return `inv_${randomUUID()}`;
200
233
  }
@@ -229,6 +262,7 @@ export async function completeActiveSkillInvocation({
229
262
  session,
230
263
  hookInput,
231
264
  reason = 'stop',
265
+ surface = SURFACE,
232
266
  now = new Date(),
233
267
  } = {}) {
234
268
  const record = await readActiveInvocation({ installRoot, session });
@@ -255,7 +289,7 @@ export async function completeActiveSkillInvocation({
255
289
  type: 'skill.completed',
256
290
  session_id: session,
257
291
  actor: ACTOR,
258
- surface: SURFACE,
292
+ surface,
259
293
  payload: {
260
294
  skill_name: record.skill_name,
261
295
  action_types_committed: count,
@@ -348,11 +382,25 @@ function shouldDedupeStart(prior, meta, now) {
348
382
  return Math.abs(now.getTime() - started) <= DEDUPE_WINDOW_MS;
349
383
  }
350
384
 
351
- export async function beginSkillInvocation({ installRoot, hookInput, now = new Date() } = {}) {
352
- const session = hookSession(hookInput);
385
+ export async function beginSkillInvocation({
386
+ installRoot,
387
+ hookInput,
388
+ skill,
389
+ session: sessionArg,
390
+ surface = SURFACE,
391
+ pinDeviation,
392
+ resolvedModel,
393
+ now = new Date(),
394
+ } = {}) {
395
+ // Session: a Claude hook carries it in `hookInput.session_id`; a first-party surface passes it
396
+ // explicitly (`session`), since its `skill` descriptor is not a hook envelope (change 0056).
397
+ const session = hookSession(hookInput) || nonEmptyString(sessionArg);
353
398
  if (!session) return { started: false, reason: 'missing-session' };
354
399
 
355
- const meta = await resolveSkillMetadata({ installRoot, hookInput });
400
+ // A `skill` descriptor is the first-party model-invocation path; `hookInput` is the Claude path.
401
+ const meta = skill
402
+ ? await resolveExplicitSkill({ installRoot, skill })
403
+ : await resolveSkillMetadata({ installRoot, hookInput });
356
404
  if (!meta) return { started: false, reason: 'not-a-skill-start', session };
357
405
 
358
406
  const prior = await readActiveInvocation({ installRoot, session });
@@ -365,6 +413,7 @@ export async function beginSkillInvocation({ installRoot, hookInput, now = new D
365
413
  installRoot,
366
414
  session,
367
415
  reason: 'next-start',
416
+ surface,
368
417
  now,
369
418
  });
370
419
  }
@@ -377,7 +426,7 @@ export async function beginSkillInvocation({ installRoot, hookInput, now = new D
377
426
  version: meta.version,
378
427
  mode: meta.mode,
379
428
  started_at: now.toISOString(),
380
- start_event: hookInput.hook_event_name,
429
+ start_event: hookInput?.hook_event_name || meta.start_event || 'model-invocation',
381
430
  start_path: meta.start_path,
382
431
  ...(meta.tool_use_id ? { tool_use_id: meta.tool_use_id } : {}),
383
432
  ...(meta.command_name ? { command_name: meta.command_name } : {}),
@@ -388,12 +437,17 @@ export async function beginSkillInvocation({ installRoot, hookInput, now = new D
388
437
  type: 'skill.invoked',
389
438
  session_id: session,
390
439
  actor: ACTOR,
391
- surface: SURFACE,
440
+ surface,
392
441
  payload: {
393
442
  skill_name: record.skill_name,
394
443
  version: record.version,
395
444
  mode: record.mode,
396
445
  invocation_id: record.invocation_id,
446
+ // Pin-deviation (change 0051 / CR3): an additive optional field a ROUTING surface (the desktop)
447
+ // stamps when it substitutes a tier-equivalent for the skill's pinned model. Shape:
448
+ // { pinned_model, substituted_model, provider, tier }. No new event type; skill.invoked carries it.
449
+ ...(isPlainObject(pinDeviation) ? { pin_deviation: pinDeviation } : {}),
450
+ ...(nonEmptyString(resolvedModel) ? { resolved_model: resolvedModel } : {}),
397
451
  },
398
452
  now,
399
453
  });
@@ -405,18 +459,24 @@ export async function beginSkillInvocation({ installRoot, hookInput, now = new D
405
459
  export async function abandonActiveSkillInvocation({
406
460
  installRoot,
407
461
  hookInput,
462
+ session: sessionArg,
408
463
  reason = 'StopFailure',
464
+ surface = SURFACE,
409
465
  now = new Date(),
410
466
  } = {}) {
411
- const session = hookSession(hookInput);
467
+ const session = hookSession(hookInput) || nonEmptyString(sessionArg);
412
468
  if (!session) return { abandoned: false, reason: 'missing-session' };
413
469
  const record = await readActiveInvocation({ installRoot, session });
414
470
  if (!record) return { abandoned: false, reason: 'no-active-record', session };
415
471
  await clearActiveInvocation({ installRoot, session });
472
+ // Honest gap (StopFailure-equivalent): clear the active record and audit the abandonment; NEVER
473
+ // mint a synthetic skill.completed to fake a clean close (emission honesty). The desktop calls this
474
+ // on an errored/cancelled turn with surface: 'desktop'.
416
475
  await auditLifecycle(installRoot, {
417
476
  session_id: session,
418
477
  invocation_id: record.invocation_id,
419
478
  skill_name: record.skill_name,
479
+ surface,
420
480
  event_type: hookInput?.hook_event_name || reason,
421
481
  outcome: 'skill-lifecycle-abandoned',
422
482
  reason: nonEmptyString(hookInput?.error_type) || nonEmptyString(hookInput?.error) || reason,