@1aboveio/skills 0.12.1 → 0.14.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.
@@ -0,0 +1,172 @@
1
+ const deepFreeze = (value) => {
2
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
3
+ for (const child of Object.values(value)) deepFreeze(child);
4
+ Object.freeze(value);
5
+ }
6
+ return value;
7
+ };
8
+
9
+ export const WORKFLOW_OWNED_MEMBERS = deepFreeze([
10
+ 'e2e-test',
11
+ 'ensure-coverage',
12
+ 'implement-and-pr',
13
+ 'resolve-issues',
14
+ 'resolve-release',
15
+ 'review-pr',
16
+ 'smoke',
17
+ 'engineering-runtime',
18
+ ]);
19
+
20
+ export const HARNESS_RUNTIME_DEPENDENCY_MEMBERS = deepFreeze([
21
+ 'harness-runtime',
22
+ ]);
23
+
24
+ export const MATT_DEPENDENCY_MEMBERS = deepFreeze([
25
+ 'batch-grill-me',
26
+ 'code-review',
27
+ 'codebase-design',
28
+ 'diagnosing-bugs',
29
+ 'domain-modeling',
30
+ 'grill-me',
31
+ 'grill-with-docs',
32
+ 'grilling',
33
+ 'handoff',
34
+ 'improve-codebase-architecture',
35
+ 'prototype',
36
+ 'research',
37
+ 'resolving-merge-conflicts',
38
+ 'setup-matt-pocock-skills',
39
+ 'tdd',
40
+ 'to-spec',
41
+ 'to-tickets',
42
+ 'triage',
43
+ 'wayfinder',
44
+ ]);
45
+
46
+ // Per-invoked-skill closure, generated from SKILL.md dependencies plus cross-member
47
+ // runtime imports. Order follows the coherence member census so the same tree always
48
+ // produces the same identity. A generated file remains part of its owner's content
49
+ // digest and is also named explicitly so a scoped verdict reports every identity used.
50
+ export const WORKFLOW_TRUSTED_INVOCATION_POLICIES = deepFreeze([
51
+ { installName: 'tdd', policy: 'manual-only' },
52
+ ]);
53
+
54
+ export const WORKFLOW_TRUSTED_DEPENDENCY_CLOSURES = deepFreeze([
55
+ {
56
+ invokedSkill: 'resolve-issues',
57
+ members: [
58
+ 'tdd',
59
+ 'harness-runtime',
60
+ 'e2e-test',
61
+ 'ensure-coverage',
62
+ 'implement-and-pr',
63
+ 'resolve-issues',
64
+ 'review-pr',
65
+ 'smoke',
66
+ 'engineering-runtime',
67
+ ],
68
+ generatedIdentities: [
69
+ { member: 'resolve-issues', path: 'generated/workflow-repair-policy.json' },
70
+ ],
71
+ },
72
+ {
73
+ invokedSkill: 'resolve-release',
74
+ members: [
75
+ 'harness-runtime',
76
+ 'e2e-test',
77
+ 'ensure-coverage',
78
+ 'resolve-issues',
79
+ 'resolve-release',
80
+ 'review-pr',
81
+ 'smoke',
82
+ 'engineering-runtime',
83
+ ],
84
+ generatedIdentities: [
85
+ { member: 'resolve-issues', path: 'generated/workflow-repair-policy.json' },
86
+ ],
87
+ },
88
+ ]);
89
+
90
+ // First-party content is distributed as an exact public npm release, so its identity is the package
91
+ // and version rather than a private repository at a moving branch. The private checkout remains a
92
+ // render target of the same manifest for local development, but it is not what an installed Workflow
93
+ // is verified against: `revision: 'main'` is scoped to that target and never reaches coherence.
94
+ // External sources keep their immutable reviewed revision — the migration is first-party only.
95
+ export const WORKFLOW_TRUSTED_SOURCES = deepFreeze({
96
+ firstParty: {
97
+ id: 'first-party',
98
+ type: 'first-party',
99
+ package: '@1aboveio/skills',
100
+ version: '0.14.0',
101
+ },
102
+ matt: {
103
+ id: 'matt-pocock',
104
+ type: 'external',
105
+ repository: 'https://github.com/mattpocock/skills',
106
+ revision: 'ed37663cc5fbef691ddfecd080dff42f7e7e350d',
107
+ reviewedTree: '04b0fcb78e3de7c58744fcba2528354cc64ab988',
108
+ },
109
+ });
110
+
111
+ const nativeSkillsPrefix = 'npx skills@1.5.22 add';
112
+ const nativeSkillsTarget = '--global --agent universal claude-code --skill';
113
+ const invocationPolicyCommand = 'node "$HOME/.agents/skills/engineering-runtime/scripts/invocation-policy.mjs" --skill tdd --policy manual-only --from-selection';
114
+ const externalArchiveLimits = 'SKILLS_DOWNLOAD_MAX_BYTES=33554432 SKILLS_EXTRACT_MAX_FILES=4096 SKILLS_EXTRACT_MAX_BYTES=67108864';
115
+ const mattMembersArgument = MATT_DEPENDENCY_MEMBERS.join(' ');
116
+ const firstPartyMembers = deepFreeze([
117
+ ...HARNESS_RUNTIME_DEPENDENCY_MEMBERS,
118
+ ...WORKFLOW_OWNED_MEMBERS,
119
+ ]);
120
+ const publicPackageRelease = `${WORKFLOW_TRUSTED_SOURCES.firstParty.package}@${WORKFLOW_TRUSTED_SOURCES.firstParty.version}`;
121
+
122
+ // The public package/group flow, which expands the same dependency-first recipe the steps below
123
+ // document and delegates every write to the pinned native CLI. `--yes` is deliberately absent: repair
124
+ // runs against an installation that is already missing, modified or mixed, which is exactly when the
125
+ // native overwrite summary and its consent prompt are the point. The generated recipes withhold the
126
+ // flag for the same reason, and repair guidance must not be the one place that pre-approves a
127
+ // destructive overwrite.
128
+ export const WORKFLOW_TRUSTED_REPAIR_RECIPE = deepFreeze({
129
+ id: 'engineering-workflow-dependency-first',
130
+ generatedFrom: 'skills/distribution/generated/recipes.json',
131
+ groupId: 'engineering-workflow',
132
+ lifecycle: 'install',
133
+ renderTarget: 'public-npm',
134
+ stopOnFailure: true,
135
+ steps: [
136
+ {
137
+ sourceId: 'matt-pocock',
138
+ sourceType: 'external',
139
+ repository: WORKFLOW_TRUSTED_SOURCES.matt.repository,
140
+ revision: WORKFLOW_TRUSTED_SOURCES.matt.revision,
141
+ installPath: null,
142
+ members: MATT_DEPENDENCY_MEMBERS,
143
+ invocationPolicies: WORKFLOW_TRUSTED_INVOCATION_POLICIES,
144
+ commands: [{
145
+ transport: 'https',
146
+ command: `${externalArchiveLimits} ${nativeSkillsPrefix} https://codeload.github.com/mattpocock/skills/tar.gz/${WORKFLOW_TRUSTED_SOURCES.matt.revision} ${nativeSkillsTarget} ${mattMembersArgument} --yes && ${invocationPolicyCommand}`,
147
+ }],
148
+ onFailure: {
149
+ action: 'stop',
150
+ message: 'Source matt-pocock fetch failed; stop this recipe before running another source.',
151
+ },
152
+ },
153
+ {
154
+ sourceId: 'first-party',
155
+ sourceType: 'first-party',
156
+ package: WORKFLOW_TRUSTED_SOURCES.firstParty.package,
157
+ version: WORKFLOW_TRUSTED_SOURCES.firstParty.version,
158
+ installPath: null,
159
+ members: firstPartyMembers,
160
+ commands: [
161
+ {
162
+ transport: 'npm',
163
+ command: `npx ${publicPackageRelease} install --group engineering-workflow --yes`,
164
+ },
165
+ ],
166
+ onFailure: {
167
+ action: 'stop',
168
+ message: 'Source first-party fetch failed; stop this recipe before running another source.',
169
+ },
170
+ },
171
+ ],
172
+ });
@@ -125,6 +125,13 @@ A watcher must retain history for the PR it is watching.
125
125
  - `WAITING`: membership was observed, then one readable absence — re-read suspicion, not terminal.
126
126
  - `STALLED`: no observable queue/admission state has changed for the configured stall bound. Terminal hand-back, not a merge failure. The bound is **phase-aware**: pre-admission (never observed membership this process) uses the shorter `--admission-stall-minutes` (default 3) and reports `reason: "admission-stall"`; after membership has been observed, the full `--stall-minutes` (default 10) applies and reports `reason: "stall-bound"`. Same terminal outcome and exit code either way — only wall time and `reason` change. A dead admission path (forgotten enqueue, stale Mergify status, unreadable queue API) must not burn the post-entry bound. An `admission-stall` while Mergify is visibly waiting on a green-or-pending admission check is often a **too-short window**, not a broken enqueue — relaunch with the CI-gated 15m pair above rather than treating 60m as the next step.
127
127
 
128
+ Before queue membership, a completed failing non-Mergify GitHub check settles
129
+ immediately as `STALLED` with `reason: "admission-checks-failed"` and a
130
+ `failedChecks` summary. This is not `DEQUEUED`: the PR was never admitted.
131
+ Mergify's own queue/protection checks are excluded, pending/neutral/skipped
132
+ checks keep watching, and after membership Mergify remains the lifecycle
133
+ authority for failed batches and dequeue routing.
134
+
128
135
  The critical invariant: after a watcher has **observed** queue entry in this process (`everActive`), it must never keep reporting `queued:false` as a routine healthy snapshot. If the PR did not merge, loss of queue membership is a queue exit. Seeding `seenQueued` via `--seen-queued` before the first successful membership poll must not invent that observation.
129
136
 
130
137
  ## Re-read Rule
@@ -104,6 +104,19 @@ export function classifyObservation({
104
104
  if (mayCountLoss) {
105
105
  return { terminal: false, outcome: 'WAITING', reason: 'queue-membership-missing-reread', active, seenQueued: true, everActive: ever }
106
106
  }
107
+ if (Array.isArray(pr.failedChecks) && pr.failedChecks.length > 0) {
108
+ return {
109
+ terminal: true,
110
+ outcome: 'STALLED',
111
+ reason: 'admission-checks-failed',
112
+ detail: 'PR admission failed before active Mergify queue membership was observed.',
113
+ failedChecks: pr.failedChecks,
114
+ active,
115
+ seenQueued: seenQueued === true,
116
+ everActive: false,
117
+ preAdmission: true,
118
+ }
119
+ }
107
120
  // Seeded seenQueued with explicit everActive=false: still pre-admission for THIS process.
108
121
  // Keep seenQueued so phases/callers retain the resume hint, but do not start the
109
122
  // lost-membership counter until membership is observed.
@@ -136,10 +149,16 @@ export function nextPhases(phases = [], outcome) {
136
149
 
137
150
  export function observationKey({ pr = {}, queue = {} } = {}) {
138
151
  const labels = Array.isArray(pr.labels) ? [...pr.labels].sort().join(',') : ''
152
+ const checks = [...(pr.failedChecks || []), ...(pr.pendingChecks || [])]
153
+ .map((check) => `${check.name}:${check.status}`)
154
+ .sort()
155
+ .join(',')
139
156
  return JSON.stringify([
140
157
  pr.state ?? null,
141
158
  pr.mergedAt ?? null,
159
+ pr.headRefOid ?? null,
142
160
  labels,
161
+ checks,
143
162
  queue.queued ?? null,
144
163
  queue.queueState ?? queue.state ?? null,
145
164
  queue.position ?? null,
@@ -283,6 +302,28 @@ function parseJson(text) {
283
302
  try { return JSON.parse(text) } catch { return null }
284
303
  }
285
304
 
305
+ const MERGIFY_CHECKS = new Set(['Mergify Merge Protections', 'Mergify Merge Queue'])
306
+ const FAILED_CHECK_STATES = new Set(['ACTION_REQUIRED', 'CANCELLED', 'ERROR', 'FAILURE', 'STARTUP_FAILURE', 'TIMED_OUT'])
307
+ const PENDING_CHECK_STATES = new Set(['EXPECTED', 'IN_PROGRESS', 'PENDING', 'QUEUED', 'REQUESTED', 'WAITING'])
308
+
309
+ export function summarizeStatusChecks(rollup) {
310
+ const failedChecks = []
311
+ const pendingChecks = []
312
+ for (const check of Array.isArray(rollup) ? rollup : []) {
313
+ const name = check?.name || check?.context || 'unnamed-check'
314
+ if (MERGIFY_CHECKS.has(name)) continue
315
+ const status = String(check?.conclusion || check?.state || check?.status || 'UNKNOWN').toUpperCase()
316
+ const summary = {
317
+ name,
318
+ status,
319
+ detailsUrl: check?.detailsUrl || check?.targetUrl || null,
320
+ }
321
+ if (FAILED_CHECK_STATES.has(status)) failedChecks.push(summary)
322
+ else if (PENDING_CHECK_STATES.has(status)) pendingChecks.push(summary)
323
+ }
324
+ return { failedChecks, pendingChecks }
325
+ }
326
+
286
327
  // Issue #629: the one piece of history a single `labels` snapshot cannot carry — WHEN a
287
328
  // `dequeued` label was applied. `gh pr view --json` has no `timelineItems` field in the CLI
288
329
  // version this was built against (`gh pr view --json timelineItems` errors "Unknown JSON
@@ -380,18 +421,21 @@ function prObservation({ repo, pr, intervalSeconds, stallMinutes, admissionStall
380
421
  // `comments` costs nothing extra to request in this same call and carries the one signal
381
422
  // `dequeuedAtFromComments` needs — no additional subprocess read, so the READS_PER_POLL
382
423
  // budget below is unaffected by issue #629's fix.
383
- const result = run('gh', ['pr', 'view', String(pr), '--repo', repo, '--json', 'state,mergedAt,mergeCommit,labels,url,comments'], { timeout: readTimeoutForArgs({ intervalSeconds, stallMinutes, admissionStallMinutes, maxMinutes, everActive }) })
424
+ const result = run('gh', ['pr', 'view', String(pr), '--repo', repo, '--json', 'state,mergedAt,mergeCommit,headRefOid,labels,url,comments,statusCheckRollup'], { timeout: readTimeoutForArgs({ intervalSeconds, stallMinutes, admissionStallMinutes, maxMinutes, everActive }) })
384
425
  if (!result.ok) return { readable: false, error: result.stderr || result.stdout }
385
426
  const data = parseJson(result.stdout)
386
427
  if (!data) return { readable: false, error: 'gh pr view returned invalid JSON' }
428
+ const checks = summarizeStatusChecks(data.statusCheckRollup)
387
429
  return {
388
430
  readable: true,
389
431
  state: data.state || null,
390
432
  mergedAt: data.mergedAt || null,
391
433
  mergeCommitOid: data.mergeCommit?.oid || null,
434
+ headRefOid: data.headRefOid || null,
392
435
  labels: (data.labels || []).map((label) => label.name).filter(Boolean),
393
436
  url: data.url || null,
394
437
  dequeuedAtMs: dequeuedAtFromComments(data.comments),
438
+ ...checks,
395
439
  }
396
440
  }
397
441
 
@@ -501,6 +545,8 @@ The script observes only; it never queues, dequeues, merges, or records state.
501
545
 
502
546
  Enqueue first. On a repo with manual delivery (no auto_merge_conditions, autoqueue: false)
503
547
  an unqueued PR never enters the queue on its own, so this watch can only ever end STALLED.
548
+ Before admission, a completed failing non-Mergify GitHub check ends the watch as STALLED
549
+ with reason admission-checks-failed; after admission, Mergify owns failure/dequeue routing.
504
550
 
505
551
  Options:
506
552
  --interval-seconds <n> Poll interval, default 90
@@ -595,6 +641,7 @@ export async function runCli(argv) {
595
641
  })
596
642
  if (verdict.everActive === true) everActive = true
597
643
  if (verdict.seenQueued === true) seenQueued = true
644
+ if (verdict.preAdmission === true) phases = nextPhases(phases, 'NOT_YET_QUEUED')
598
645
  phases = nextPhases(phases, verdict.outcome)
599
646
  const payload = {
600
647
  outcome: verdict.outcome,
@@ -605,10 +652,21 @@ export async function runCli(argv) {
605
652
  seenQueued,
606
653
  everActive,
607
654
  phases,
655
+ failedChecks: verdict.failedChecks || pr.failedChecks || [],
608
656
  missingActiveReads,
609
657
  observations,
610
658
  elapsedMs: Date.now() - startedMs,
611
- pr: { state: pr.state || null, mergedAt: pr.mergedAt || null, labels: pr.labels || [], url: pr.url || null, readable: pr.readable !== false, dequeuedAtMs: pr.dequeuedAtMs ?? null },
659
+ pr: {
660
+ state: pr.state || null,
661
+ mergedAt: pr.mergedAt || null,
662
+ headRefOid: pr.headRefOid || null,
663
+ labels: pr.labels || [],
664
+ url: pr.url || null,
665
+ readable: pr.readable !== false,
666
+ dequeuedAtMs: pr.dequeuedAtMs ?? null,
667
+ failedChecks: pr.failedChecks || [],
668
+ pendingChecks: pr.pendingChecks || [],
669
+ },
612
670
  queue: { queued: queue.queued === true, queueState: queue.queueState || null, position: queue.position ?? null, readable: queue.readable !== false },
613
671
  }
614
672
 
@@ -294,7 +294,8 @@
294
294
  "reviewedTree": "04b0fcb78e3de7c58744fcba2528354cc64ab988"
295
295
  },
296
296
  "contentDigest": "81eca2a5b53a63f481c0849be7a663a8cd43d5cf53f32b644ec0a2f50cf91aa2",
297
- "digestExcludes": []
297
+ "digestExcludes": [],
298
+ "invocationPolicy": "manual-only"
298
299
  },
299
300
  {
300
301
  "installName": "to-spec",
@@ -360,7 +361,7 @@
360
361
  "id": "first-party",
361
362
  "type": "first-party",
362
363
  "package": "@1aboveio/skills",
363
- "version": "0.12.1"
364
+ "version": "0.14.0"
364
365
  },
365
366
  "contentDigest": "eff6c7b5931bce5b2265a619bccddd371a89df6ce7bc2edc74f11d00060a71dd",
366
367
  "digestExcludes": []
@@ -373,7 +374,7 @@
373
374
  "id": "first-party",
374
375
  "type": "first-party",
375
376
  "package": "@1aboveio/skills",
376
- "version": "0.12.1"
377
+ "version": "0.14.0"
377
378
  },
378
379
  "contentDigest": "08230dc57a53d6526692b50138038abbd80abce1a067e01c52ad6acc182caf7e",
379
380
  "digestExcludes": []
@@ -386,7 +387,7 @@
386
387
  "id": "first-party",
387
388
  "type": "first-party",
388
389
  "package": "@1aboveio/skills",
389
- "version": "0.12.1"
390
+ "version": "0.14.0"
390
391
  },
391
392
  "contentDigest": "9eea7bfba348ddaea1b934c9a7fabdd9b101df8a146e1c93e947da4d335f1d98",
392
393
  "digestExcludes": []
@@ -399,7 +400,7 @@
399
400
  "id": "first-party",
400
401
  "type": "first-party",
401
402
  "package": "@1aboveio/skills",
402
- "version": "0.12.1"
403
+ "version": "0.14.0"
403
404
  },
404
405
  "contentDigest": "58b0556228a9271cf9e33727a05fc08a4fdfd29512ec0936b169f0a55d60e6ef",
405
406
  "digestExcludes": []
@@ -412,9 +413,9 @@
412
413
  "id": "first-party",
413
414
  "type": "first-party",
414
415
  "package": "@1aboveio/skills",
415
- "version": "0.12.1"
416
+ "version": "0.14.0"
416
417
  },
417
- "contentDigest": "c8e731b2441e03b2a7d2999f9888427f842f41b16f40c43d4d0965faad2eb925",
418
+ "contentDigest": "9eac84bee618e57564eb702d198484b6e78f8d27e3db621d087f0dd2dc048b58",
418
419
  "digestExcludes": []
419
420
  },
420
421
  {
@@ -425,7 +426,7 @@
425
426
  "id": "first-party",
426
427
  "type": "first-party",
427
428
  "package": "@1aboveio/skills",
428
- "version": "0.12.1"
429
+ "version": "0.14.0"
429
430
  },
430
431
  "contentDigest": "b5af4dccf703362d4f41cac4fdff48305f652a00338d85975a2e5c35ec6bc61c",
431
432
  "digestExcludes": []
@@ -438,7 +439,7 @@
438
439
  "id": "first-party",
439
440
  "type": "first-party",
440
441
  "package": "@1aboveio/skills",
441
- "version": "0.12.1"
442
+ "version": "0.14.0"
442
443
  },
443
444
  "contentDigest": "f048fd00c69f2dc666fc7a3096cfeee6dfba73933bfb69602f3f0e26159798cc",
444
445
  "digestExcludes": []
@@ -451,7 +452,7 @@
451
452
  "id": "first-party",
452
453
  "type": "first-party",
453
454
  "package": "@1aboveio/skills",
454
- "version": "0.12.1"
455
+ "version": "0.14.0"
455
456
  },
456
457
  "contentDigest": "90a7c4e1ad6da1e632ea2c5259e4967ffebb84353adccbca8dfc5d6bc50b60c1",
457
458
  "digestExcludes": []
@@ -464,15 +465,15 @@
464
465
  "id": "first-party",
465
466
  "type": "first-party",
466
467
  "package": "@1aboveio/skills",
467
- "version": "0.12.1"
468
+ "version": "0.14.0"
468
469
  },
469
- "contentDigest": "d53c76bf1d2118c59b443c5757d7d4c3bcf489fd8e345b68b44127de9a085e5b",
470
+ "contentDigest": "0d71ce70f3180bba021005e056f028ef5a2018eb187078ab8c691a5a000a924e",
470
471
  "digestExcludes": [
471
472
  "coherence/workflow.json"
472
473
  ]
473
474
  }
474
475
  ],
475
- "releaseIdentity": "785896ddc88471f058dcd79a651bb2558630706c874e4222562cc828df6aae91",
476
+ "releaseIdentity": "5c171520b6818f86f6b88cd60e5e4e0168dd6f89256690925c02758105c6c8a5",
476
477
  "lifecycleAuthority": "native-skills-cli",
477
478
  "repairRecipe": {
478
479
  "id": "engineering-workflow-dependency-first",
@@ -509,10 +510,16 @@
509
510
  "triage",
510
511
  "wayfinder"
511
512
  ],
513
+ "invocationPolicies": [
514
+ {
515
+ "installName": "tdd",
516
+ "policy": "manual-only"
517
+ }
518
+ ],
512
519
  "commands": [
513
520
  {
514
521
  "transport": "https",
515
- "command": "SKILLS_DOWNLOAD_MAX_BYTES=33554432 SKILLS_EXTRACT_MAX_FILES=4096 SKILLS_EXTRACT_MAX_BYTES=67108864 npx skills@1.5.22 add https://codeload.github.com/mattpocock/skills/tar.gz/ed37663cc5fbef691ddfecd080dff42f7e7e350d --global --agent universal claude-code --skill batch-grill-me code-review codebase-design diagnosing-bugs domain-modeling grill-me grill-with-docs grilling handoff improve-codebase-architecture prototype research resolving-merge-conflicts setup-matt-pocock-skills tdd to-spec to-tickets triage wayfinder"
522
+ "command": "SKILLS_DOWNLOAD_MAX_BYTES=33554432 SKILLS_EXTRACT_MAX_FILES=4096 SKILLS_EXTRACT_MAX_BYTES=67108864 npx skills@1.5.22 add https://codeload.github.com/mattpocock/skills/tar.gz/ed37663cc5fbef691ddfecd080dff42f7e7e350d --global --agent universal claude-code --skill batch-grill-me code-review codebase-design diagnosing-bugs domain-modeling grill-me grill-with-docs grilling handoff improve-codebase-architecture prototype research resolving-merge-conflicts setup-matt-pocock-skills tdd to-spec to-tickets triage wayfinder --yes && node \"$HOME/.agents/skills/engineering-runtime/scripts/invocation-policy.mjs\" --skill tdd --policy manual-only --from-selection"
516
523
  }
517
524
  ],
518
525
  "onFailure": {
@@ -524,7 +531,7 @@
524
531
  "sourceId": "first-party",
525
532
  "sourceType": "first-party",
526
533
  "package": "@1aboveio/skills",
527
- "version": "0.12.1",
534
+ "version": "0.14.0",
528
535
  "installPath": null,
529
536
  "members": [
530
537
  "harness-runtime",
@@ -540,7 +547,7 @@
540
547
  "commands": [
541
548
  {
542
549
  "transport": "npm",
543
- "command": "npx @1aboveio/skills@0.12.1 install --group engineering-workflow"
550
+ "command": "npx @1aboveio/skills@0.14.0 install --group engineering-workflow --yes"
544
551
  }
545
552
  ],
546
553
  "onFailure": {
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+
7
+ import { isMainModule } from './main-module.mjs';
8
+
9
+ export const MANUAL_ONLY = 'manual-only';
10
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
11
+
12
+ function frontmatterBounds(contents) {
13
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(contents);
14
+ if (!match) throw new Error('SKILL.md must begin with YAML frontmatter');
15
+ return { body: match[1], end: match[0].length };
16
+ }
17
+
18
+ function manualOnlySkill(contents) {
19
+ const frontmatter = frontmatterBounds(contents);
20
+ const declarations = frontmatter.body.match(/^disable-model-invocation\s*:\s*.*$/gm) ?? [];
21
+ if (declarations.length > 1) throw new Error('SKILL.md repeats disable-model-invocation');
22
+
23
+ const body = declarations.length === 1
24
+ ? frontmatter.body.replace(/^disable-model-invocation\s*:\s*.*$/m, 'disable-model-invocation: true')
25
+ : `${frontmatter.body}\ndisable-model-invocation: true`;
26
+ return `---\n${body}\n---\n${contents.slice(frontmatter.end)}`;
27
+ }
28
+
29
+ function manualOnlyOpenAi(contents) {
30
+ const policyHeaders = contents.match(/^policy\s*:\s*$/gm) ?? [];
31
+ if (policyHeaders.length > 1) throw new Error('agents/openai.yaml repeats policy');
32
+ if (policyHeaders.length === 0) {
33
+ const prefix = contents.length === 0 || contents.endsWith('\n') ? contents : `${contents}\n`;
34
+ return `${prefix}policy:\n allow_implicit_invocation: false\n`;
35
+ }
36
+
37
+ const lines = contents.split('\n');
38
+ const policyIndex = lines.findIndex((line) => /^policy\s*:\s*$/.test(line));
39
+ let policyEnd = lines.length;
40
+ for (let index = policyIndex + 1; index < lines.length; index += 1) {
41
+ if (/^[^\s#][^:]*\s*:/.test(lines[index])) {
42
+ policyEnd = index;
43
+ break;
44
+ }
45
+ }
46
+ const declarations = lines
47
+ .slice(policyIndex + 1, policyEnd)
48
+ .filter((line) => /^\s+allow_implicit_invocation\s*:/.test(line));
49
+ if (declarations.length > 1) throw new Error('agents/openai.yaml repeats policy.allow_implicit_invocation');
50
+ if (declarations.length === 1) {
51
+ return lines.map((line, index) => (
52
+ index > policyIndex && index < policyEnd && /^\s+allow_implicit_invocation\s*:/.test(line)
53
+ ? ' allow_implicit_invocation: false'
54
+ : line
55
+ )).join('\n');
56
+ }
57
+ lines.splice(policyIndex + 1, 0, ' allow_implicit_invocation: false');
58
+ return lines.join('\n');
59
+ }
60
+
61
+ export function manualOnlyPolicyFindings(skillDirectory) {
62
+ const findings = [];
63
+ const skillPath = join(skillDirectory, 'SKILL.md');
64
+ const openaiPath = join(skillDirectory, 'agents', 'openai.yaml');
65
+ if (!existsSync(skillPath)) return ['SKILL.md is missing'];
66
+ const skill = readFileSync(skillPath, 'utf8');
67
+ const declarations = frontmatterBounds(skill).body.match(/^disable-model-invocation\s*:\s*true\s*$/gm) ?? [];
68
+ if (declarations.length !== 1) findings.push('SKILL.md must declare disable-model-invocation: true exactly once');
69
+ if (!existsSync(openaiPath)) {
70
+ findings.push('agents/openai.yaml is missing');
71
+ } else {
72
+ const openai = readFileSync(openaiPath, 'utf8');
73
+ const policies = openai.match(/^policy\s*:\s*$/gm) ?? [];
74
+ const implicit = openai.match(/^\s+allow_implicit_invocation\s*:\s*false\s*$/gm) ?? [];
75
+ if (policies.length !== 1 || implicit.length !== 1) {
76
+ findings.push('agents/openai.yaml must declare policy.allow_implicit_invocation: false exactly once');
77
+ }
78
+ }
79
+ return findings;
80
+ }
81
+
82
+ // Restore the reviewed upstream bytes for source-integrity hashing. The installed tree must first
83
+ // pass manualOnlyPolicyFindings; these exact trailing additions are the only intentional divergence.
84
+ export function reviewedSourceInjections(skillDirectory, policy) {
85
+ if (policy !== MANUAL_ONLY) return [];
86
+ const findings = manualOnlyPolicyFindings(skillDirectory);
87
+ if (findings.length > 0) throw new Error(findings.join('; '));
88
+ const skill = readFileSync(join(skillDirectory, 'SKILL.md'), 'utf8')
89
+ .replace(/^disable-model-invocation: true\n/m, '');
90
+ const openai = readFileSync(join(skillDirectory, 'agents', 'openai.yaml'), 'utf8')
91
+ .replace(/policy:\n allow_implicit_invocation: false\n?$/, '');
92
+ return [
93
+ { relativePath: 'SKILL.md', contents: skill },
94
+ { relativePath: 'agents/openai.yaml', contents: openai },
95
+ ];
96
+ }
97
+
98
+ export function clearInvocationPolicy({ installName, policy, skillsRoot = join(homedir(), '.agents', 'skills') }) {
99
+ if (!SKILL_NAME.test(installName ?? '')) throw new Error(`invalid installed skill name ${JSON.stringify(installName)}`);
100
+ if (policy !== MANUAL_ONLY) throw new Error(`unsupported invocation policy ${JSON.stringify(policy)}`);
101
+ const skillDirectory = join(skillsRoot, installName);
102
+ const skillPath = join(skillDirectory, 'SKILL.md');
103
+ const openaiPath = join(skillDirectory, 'agents', 'openai.yaml');
104
+ if (!existsSync(skillPath) || !existsSync(openaiPath)) throw new Error(`installed skill is incomplete: ${skillDirectory}`);
105
+ const skill = readFileSync(skillPath, 'utf8');
106
+ const openai = readFileSync(openaiPath, 'utf8');
107
+ const hasSkillPolicy = /^disable-model-invocation\s*:/m.test(frontmatterBounds(skill).body);
108
+ const hasOpenAiPolicy = /^policy\s*:\s*$/m.test(openai) || /^\s+allow_implicit_invocation\s*:/m.test(openai);
109
+ if (!hasSkillPolicy && !hasOpenAiPolicy) {
110
+ return { installName, policy: 'implicit', skillDirectory, changed: false };
111
+ }
112
+ const injected = Object.fromEntries(reviewedSourceInjections(skillDirectory, policy)
113
+ .map((entry) => [entry.relativePath, entry.contents]));
114
+ writeFileSync(skillPath, injected['SKILL.md']);
115
+ writeFileSync(openaiPath, injected['agents/openai.yaml']);
116
+ return { installName, policy: 'implicit', skillDirectory, changed: true };
117
+ }
118
+
119
+ export function applyInvocationPolicy({ installName, policy, skillsRoot = join(homedir(), '.agents', 'skills') }) {
120
+ if (!SKILL_NAME.test(installName ?? '')) throw new Error(`invalid installed skill name ${JSON.stringify(installName)}`);
121
+ if (policy !== MANUAL_ONLY) throw new Error(`unsupported invocation policy ${JSON.stringify(policy)}`);
122
+ const skillDirectory = join(skillsRoot, installName);
123
+ const skillPath = join(skillDirectory, 'SKILL.md');
124
+ if (!existsSync(skillPath)) throw new Error(`installed skill is missing: ${skillPath}`);
125
+ const openaiPath = join(skillDirectory, 'agents', 'openai.yaml');
126
+
127
+ const skillBefore = readFileSync(skillPath, 'utf8');
128
+ const skillAfter = manualOnlySkill(skillBefore);
129
+ if (skillAfter !== skillBefore) writeFileSync(skillPath, skillAfter);
130
+
131
+ mkdirSync(dirname(openaiPath), { recursive: true });
132
+ const openaiBefore = existsSync(openaiPath) ? readFileSync(openaiPath, 'utf8') : '';
133
+ const openaiAfter = manualOnlyOpenAi(openaiBefore);
134
+ if (openaiAfter !== openaiBefore) writeFileSync(openaiPath, openaiAfter);
135
+
136
+ const findings = manualOnlyPolicyFindings(skillDirectory);
137
+ if (findings.length > 0) throw new Error(findings.join('; '));
138
+ return { installName, policy, skillDirectory, changed: skillAfter !== skillBefore || openaiAfter !== openaiBefore };
139
+ }
140
+
141
+ function selectionEnablesTdd(env = process.env) {
142
+ const stateHome = env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state');
143
+ const path = join(stateHome, '1aboveio-skills', 'selection.json');
144
+ if (!existsSync(path)) return true;
145
+ try {
146
+ const state = JSON.parse(readFileSync(path, 'utf8'));
147
+ if (state.tddImplicitInvocation === undefined) return true;
148
+ if (typeof state.tddImplicitInvocation !== 'boolean') {
149
+ throw new Error('tddImplicitInvocation must be a boolean');
150
+ }
151
+ return state.tddImplicitInvocation;
152
+ } catch (error) {
153
+ throw new Error(`cannot read TDD invocation selection at ${path}: ${error.message}`);
154
+ }
155
+ }
156
+
157
+ export function reconcileInvocationPolicy(options) {
158
+ return selectionEnablesTdd(options.env)
159
+ ? clearInvocationPolicy(options)
160
+ : applyInvocationPolicy(options);
161
+ }
162
+
163
+ function parseArgs(argv) {
164
+ const parsed = { installName: null, policy: null, skillsRoot: undefined, fromSelection: false };
165
+ for (let index = 0; index < argv.length; index += 1) {
166
+ const arg = argv[index];
167
+ if (arg === '--skill') parsed.installName = argv[++index];
168
+ else if (arg === '--policy') parsed.policy = argv[++index];
169
+ else if (arg === '--skills-root') parsed.skillsRoot = argv[++index];
170
+ else if (arg === '--from-selection') parsed.fromSelection = true;
171
+ else throw new Error(`unknown argument ${arg}`);
172
+ }
173
+ return parsed;
174
+ }
175
+
176
+ if (isMainModule(import.meta.url)) {
177
+ try {
178
+ const parsed = parseArgs(process.argv.slice(2));
179
+ const result = parsed.fromSelection
180
+ ? reconcileInvocationPolicy(parsed)
181
+ : applyInvocationPolicy(parsed);
182
+ process.stdout.write(`${JSON.stringify(result)}\n`);
183
+ } catch (error) {
184
+ process.stderr.write(`invocation-policy: ${error.message}\n`);
185
+ process.exitCode = 1;
186
+ }
187
+ }