@jc_stack/ez-agents 0.1.0-beta.13 → 0.1.0-beta.19

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 (115) hide show
  1. package/.dockerignore +3 -0
  2. package/.env.example +20 -0
  3. package/AGENTS.md +12 -3
  4. package/CHANGELOG.md +63 -0
  5. package/CONTRIBUTING.md +62 -6
  6. package/Dockerfile +6 -0
  7. package/README.md +11 -2
  8. package/bin/ezenciel-agents-watch.mjs +8 -0
  9. package/compose.workforce-watch.yaml +33 -0
  10. package/compose.yaml +8 -1
  11. package/docker/run.ts +1 -1
  12. package/docs/architecture/ai-selection.md +15 -0
  13. package/docs/architecture/authority-boundaries.md +24 -1
  14. package/docs/architecture/telegram-intake.md +1 -1
  15. package/docs/docker-runtime.md +35 -0
  16. package/docs/host-service.md +19 -0
  17. package/docs/pagerduty.md +42 -0
  18. package/docs/plugin-catalog.md +28 -10
  19. package/docs/plugin-contributions.md +9 -0
  20. package/docs/plugins.md +46 -1
  21. package/docs/releasing.md +20 -9
  22. package/docs/repair.md +41 -0
  23. package/docs/responsive-channels.md +57 -0
  24. package/docs/scheduling.md +32 -4
  25. package/docs/selective-monitoring.md +12 -4
  26. package/docs/setup.md +43 -0
  27. package/docs/trusted-publishing.md +140 -0
  28. package/docs/upgrades.md +24 -4
  29. package/docs/workforce-watch.md +101 -0
  30. package/package.json +9 -4
  31. package/scripts/generate-publish-caller.mjs +60 -0
  32. package/scripts/smoke-busy-reply.ts +58 -0
  33. package/scripts/trusted-beta.mjs +289 -0
  34. package/src/agent-guidance.ts +9 -0
  35. package/src/ai-cli.ts +2 -1
  36. package/src/ai.ts +26 -8
  37. package/src/client-defaults.ts +29 -13
  38. package/src/codex-session.ts +4 -2
  39. package/src/config.ts +29 -1
  40. package/src/control-state.ts +26 -7
  41. package/src/desktop-bridge.ts +11 -2
  42. package/src/event-sources.ts +2 -1
  43. package/src/execution-authority.ts +2 -1
  44. package/src/executor.ts +34 -7
  45. package/src/failure.ts +32 -0
  46. package/src/host-executor-client.ts +7 -1
  47. package/src/host-executor.ts +22 -13
  48. package/src/identity.ts +8 -3
  49. package/src/inbox.ts +7 -3
  50. package/src/index.ts +260 -92
  51. package/src/install-tools.mjs +2 -2
  52. package/src/menu.ts +8 -6
  53. package/src/model-policy.ts +18 -0
  54. package/src/owner.ts +3 -3
  55. package/src/pagerduty.ts +109 -0
  56. package/src/plugins/manager.mjs +115 -8
  57. package/src/plugins/shared.mjs +76 -0
  58. package/src/repair-policy.ts +13 -0
  59. package/src/reply-context.ts +71 -0
  60. package/src/reply-executor.ts +55 -0
  61. package/src/reply-mcp.ts +23 -0
  62. package/src/runs.ts +14 -16
  63. package/src/schedule-cli.ts +36 -7
  64. package/src/scheduled-tasks.ts +33 -0
  65. package/src/scheduler.ts +22 -4
  66. package/src/setup.ts +3 -2
  67. package/src/software-status.ts +5 -5
  68. package/src/task-cli.ts +3 -3
  69. package/src/task-executor.ts +9 -6
  70. package/src/tasks.ts +35 -17
  71. package/src/telegram-source.ts +94 -0
  72. package/src/updates/artifact.mjs +16 -0
  73. package/src/updates/binding.mjs +3 -1
  74. package/src/updates/control.mjs +4 -4
  75. package/src/updates/runtime.mjs +5 -2
  76. package/src/workforce-watch-cli.ts +14 -0
  77. package/src/workforce-watch.ts +155 -0
  78. package/templates/agent/AGENTS.md +10 -2
  79. package/templates/agent/TOOLS.md +6 -0
  80. package/templates/agent-guidance.md +24 -0
  81. package/templates/chat-guidance.md +23 -0
  82. package/templates/failure-review.md +9 -0
  83. package/templates/maintainer-purpose.md +15 -0
  84. package/templates/updates.md +2 -2
  85. package/test/agent-guidance.test.ts +125 -0
  86. package/test/ai-cli.test.ts +7 -6
  87. package/test/ai.test.ts +81 -1
  88. package/test/busy-reply-relay.test.ts +41 -0
  89. package/test/client-defaults.test.ts +37 -5
  90. package/test/codex-context.test.ts +5 -2
  91. package/test/codex-session.test.ts +4 -2
  92. package/test/config.test.ts +29 -0
  93. package/test/event-sources.test.ts +4 -0
  94. package/test/executor.test.ts +11 -1
  95. package/test/failure.test.ts +256 -0
  96. package/test/group-owner.test.ts +36 -0
  97. package/test/host-executor.test.ts +54 -7
  98. package/test/intake-relay.test.ts +145 -4
  99. package/test/model-policy.test.ts +69 -0
  100. package/test/pagerduty.test.ts +104 -0
  101. package/test/plugin-manager.test.mjs +52 -2
  102. package/test/relay.test.ts +2 -2
  103. package/test/repair-policy.test.ts +23 -0
  104. package/test/reply.test.ts +153 -0
  105. package/test/runs.test.ts +7 -0
  106. package/test/schedule-cli.test.ts +10 -2
  107. package/test/scheduled-tasks.test.ts +43 -0
  108. package/test/shared-services.test.mjs +98 -0
  109. package/test/software-status.test.ts +5 -5
  110. package/test/task-native.test.ts +2 -2
  111. package/test/tasks.test.ts +14 -6
  112. package/test/telegram-source.test.ts +75 -0
  113. package/test/trusted-beta.test.mjs +224 -0
  114. package/test/updates.test.mjs +35 -3
  115. package/test/workforce-watch.test.ts +180 -0
@@ -0,0 +1,101 @@
1
+ # Workforce Watch
2
+
3
+ Workforce Watch is a separate, small Docker service for missed agent check-ins
4
+ and explicit terminal failures. It is not an agent, scheduler, log store or
5
+ remote-execution channel. It retains only the five latest compact activity
6
+ records supplied by each enrolled worker.
7
+
8
+ Run it in a failure domain separate from the workers it watches. In particular,
9
+ it must not rely on the Stocks VM to report a Stocks VM outage, and a separate
10
+ host is required to detect a Mac-wide outage.
11
+
12
+ ## Start the monitor
13
+
14
+ Set private monitor-only values, never agent-workspace or source-control values:
15
+
16
+ ```text
17
+ EZ_WATCH_ENROLL_TOKEN=<private fleet enrollment secret>
18
+ EZ_WATCH_PAGERDUTY_SECRET_FILE=/absolute/path/to/a-0600-workforce-pagerduty-key
19
+ TELEGRAM_BOT_TOKEN=<alert bot token>
20
+ EZ_WATCH_TELEGRAM_CHAT_ID=<owner chat id>
21
+ ```
22
+
23
+ PagerDuty is the fleet incident owner. Workforce Watch sends a `trigger` for
24
+ each missed check-in or terminal failure, then a `resolve` with the stable
25
+ deduplication key `ez:workforce:<worker-id>` after sustained recovery. Use one
26
+ approved Events API v2 routing key for the fleet; it may be the existing
27
+ PagerDuty integration when that is the desired escalation policy. Never put it
28
+ in a worker's environment. The secret file contains only that key, is mode
29
+ `0600`, and is mounted as `/run/secrets/workforce_watch_pagerduty`; it is never
30
+ a Compose or container environment value. Telegram is optional, supplementary
31
+ owner visibility. PagerDuty is required: a Watch deployment without its
32
+ routing-key secret fails closed rather than silently running without paging.
33
+
34
+ The owner may explicitly authorize the CTO bot token temporarily. A dedicated
35
+ alert bot remains preferable because it preserves an independent delivery identity.
36
+
37
+ ```sh
38
+ docker compose -f compose.workforce-watch.yaml up -d --build
39
+ curl http://127.0.0.1:9919/healthz
40
+ ```
41
+
42
+ The default bind is loopback. Put private TLS networking or a private reverse
43
+ proxy in front of it before remote workers use it; do not expose enrollment or
44
+ check-in traffic publicly.
45
+
46
+ ## Enroll and check in
47
+
48
+ The worker selects its monitor through its configured URL. It submits the fleet
49
+ enrollment secret once; the response contains the worker-specific secret. Store
50
+ that response only in the worker's private service environment and discard it
51
+ from shell history and logs.
52
+
53
+ ```sh
54
+ curl --fail-with-body -X POST "$EZ_WATCH_URL/v1/enroll" \
55
+ -H "Authorization: Bearer $EZ_WATCH_ENROLL_TOKEN" \
56
+ -H 'content-type: application/json' \
57
+ --data '{"workerId":"stocks-production","checkInSeconds":300,"graceSeconds":180,"severity":"critical"}'
58
+ ```
59
+
60
+ The supervisor, not the LLM, posts check-ins using the returned worker secret:
61
+
62
+ ```sh
63
+ curl --fail-with-body -X POST "$EZ_WATCH_URL/v1/workers/stocks-production/check-in" \
64
+ -H "Authorization: Bearer $EZ_WATCH_WORKER_TOKEN" \
65
+ -H 'content-type: application/json' \
66
+ --data '{"status":"ok","activity":"strategy receipt delivered","runId":"daily-strategy-2026-09-11"}'
67
+ ```
68
+
69
+ An unrecoverable condition sends `{"status":"failed","terminal":true,...}`.
70
+ Optional `activity`, `error`, `runId`, and `logsHint` fields are size-limited and
71
+ appear in an alert. They must be public-safe operational context, never stdout,
72
+ prompts, holdings, credentials, or provider payloads.
73
+
74
+ ## Inspect
75
+
76
+ The enrollment token reads redacted state; worker secrets never appear:
77
+
78
+ ```sh
79
+ curl --fail-with-body "$EZ_WATCH_URL/v1/workers" \
80
+ -H "Authorization: Bearer $EZ_WATCH_ENROLL_TOKEN"
81
+ ```
82
+
83
+ For host replacement or a suspected worker-secret exposure, rotate that worker's
84
+ secret with the enrollment token. The old secret stops working immediately:
85
+
86
+ ```sh
87
+ curl --fail-with-body -X POST "$EZ_WATCH_URL/v1/workers/stocks-production/rotate" \
88
+ -H "Authorization: Bearer $EZ_WATCH_ENROLL_TOKEN"
89
+ ```
90
+
91
+ Store the returned worker secret in the replacement supervisor before stopping
92
+ the prior one.
93
+
94
+ The service triggers once for a missed deadline or terminal failure. It sends a
95
+ recovery notice only after two clean check-ins by default to prevent flapping.
96
+
97
+ If a worker fails again during a partially delivered recovery, channels that
98
+ already accepted recovery receive a fresh trigger. Channels still open retain
99
+ their delivery state, so they do not receive duplicate opening alerts. This
100
+ applies to terminal failures, failed check-ins, and missed check-ins, including
101
+ after a Watch restart.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.13",
3
+ "version": "0.1.0-beta.19",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
@@ -20,7 +20,8 @@
20
20
  "ezenciel-agents-create": "bin/ezenciel-agents-create",
21
21
  "ezenciel-agents-host": "bin/ezenciel-agents-host",
22
22
  "ezenciel-agents-tools": "bin/ezenciel-agents-tools.mjs",
23
- "ezenciel-agents-ai": "bin/ezenciel-agents-ai.mjs"
23
+ "ezenciel-agents-ai": "bin/ezenciel-agents-ai.mjs",
24
+ "ezenciel-agents-watch": "bin/ezenciel-agents-watch.mjs"
24
25
  },
25
26
  "files": [
26
27
  "default-plugins.json",
@@ -35,6 +36,7 @@
35
36
  "CONTRIBUTING.md",
36
37
  "Dockerfile",
37
38
  "compose.yaml",
39
+ "compose.workforce-watch.yaml",
38
40
  "docker",
39
41
  "compose.whatsapp.yaml",
40
42
  "scripts/smoke.ts",
@@ -47,11 +49,14 @@
47
49
  "test",
48
50
  "tsconfig.json",
49
51
  "scripts/assert-local-registry.mjs",
50
- ".dockerignore"
52
+ ".dockerignore",
53
+ "scripts/trusted-beta.mjs",
54
+ "scripts/generate-publish-caller.mjs",
55
+ "scripts/smoke-busy-reply.ts"
51
56
  ],
52
57
  "publishConfig": {
53
58
  "access": "public",
54
- "tag": "beta"
59
+ "tag": "latest"
55
60
  },
56
61
  "scripts": {
57
62
  "setup": "tsx --env-file-if-exists=.env src/setup.ts",
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+ // Print a reviewed caller. Does not write repositories or enroll npm trust.
3
+ import assert from 'node:assert/strict';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ export function generateCaller({ repository, packageName, publisherSha, checks }) {
7
+ assert.match(repository, /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, 'Invalid repository');
8
+ assert.match(packageName, /^@[a-z0-9_-]+\/[a-z0-9_.-]+$/, 'Expected scoped npm package');
9
+ assert.match(publisherSha, /^[a-f0-9]{40}$/, 'Pin the reviewed shared publisher to a full commit SHA');
10
+ assert(Array.isArray(checks) && checks.length > 0 && checks.every(c => typeof c === 'string' && /^[A-Za-z0-9 (),_.-]+$/.test(c)), 'Required CI check names are mandatory');
11
+ const core = repository === 'jdorado/ez-agents';
12
+ const reference = core ? './.github/workflows/npm-beta-shared.yml' : `jdorado/ez-agents/.github/workflows/npm-beta-shared.yml@${publisherSha}`;
13
+ return `# Generated by scripts/generate-publish-caller.mjs; review changes before merging.
14
+ name: Publish verified npm beta
15
+ on:
16
+ workflow_dispatch:
17
+ inputs:
18
+ release-id:
19
+ description: 'Numeric ID of the staged draft prerelease'
20
+ required: true
21
+ type: string
22
+ version:
23
+ description: 'Reviewed beta version staged as draft release vVERSION'
24
+ required: true
25
+ type: string
26
+ source-sha:
27
+ description: 'Full tested commit SHA; must be current main'
28
+ required: true
29
+ type: string
30
+ artifact-sha256:
31
+ description: 'SHA-256 of the exact independently verified candidate.tgz'
32
+ required: true
33
+ type: string
34
+ permissions: {}
35
+ jobs:
36
+ publish:
37
+ if: github.repository == '${repository}' && github.ref == 'refs/heads/main'
38
+ permissions:
39
+ contents: write
40
+ checks: read
41
+ actions: read
42
+ pull-requests: read
43
+ id-token: write
44
+ uses: ${reference}
45
+ with:
46
+ package: '${packageName}'
47
+ publisher-sha: ${core ? '${{ github.sha }}' : `'${publisherSha}'`}
48
+ required-checks: '${JSON.stringify(checks)}'
49
+ release-id: \${{ inputs.release-id }}
50
+ version: \${{ inputs.version }}
51
+ source-sha: \${{ inputs.source-sha }}
52
+ artifact-sha256: \${{ inputs.artifact-sha256 }}
53
+ `;
54
+ }
55
+
56
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
57
+ const [repository, packageName, publisherSha, checksJson, ...extra] = process.argv.slice(2);
58
+ if (!checksJson || extra.length) throw new Error('Usage: node scripts/generate-publish-caller.mjs OWNER/REPO @SCOPE/PACKAGE SHARED_COMMIT_SHA \'["required CI name"]\'');
59
+ process.stdout.write(generateCaller({ repository, packageName, publisherSha, checks: JSON.parse(checksJson) }));
60
+ }
@@ -0,0 +1,58 @@
1
+ // Real restricted Codex reply while a synthetic writer stays active. No Telegram network.
2
+ import { mkdtemp, mkdir, writeFile, symlink } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { spawn } from 'node:child_process'
6
+ import { createRelay } from '../src/index.js'
7
+ import { RunStore } from '../src/runs.js'
8
+ import { ControlStore } from '../src/control-state.js'
9
+ import { initialPreset } from '../src/ai.js'
10
+ import { startExecutorJob } from '../src/executor.js'
11
+ import { serveHostExecutor } from '../src/host-executor.js'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { initializeWorkspace } from '../src/workspace.js'
14
+ import type { Update } from 'grammy/types'
15
+ if (process.argv.includes('--host')) {
16
+ const root=process.argv[process.argv.indexOf('--host')+1], abort=new AbortController()
17
+ process.once('SIGTERM',()=>abort.abort())
18
+ await serveHostExecutor({cli:'codex',agents:[{name:'fixture',workspace:join(root,'mind'),controlDir:join(root,'control'),binDir:fileURLToPath(new URL('../bin',import.meta.url)),sharedWorkspace:join(root,'mind')}]},abort.signal,async(texts,options)=>{
19
+ if((await new RunStore(options.controlDir).get(options.runId))?.replyOnly)return startExecutorJob(texts,options)
20
+ const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['pipe','pipe','pipe']})
21
+ return {child,stdout:'',cleanup:async()=>{}}
22
+ })
23
+ process.exit(0)
24
+ }
25
+ const root=await mkdtemp(join(tmpdir(),'ez-busy-reply-')), workspace=join(root,'mind'), controlDir=join(root,'control')
26
+ await initializeWorkspace(workspace);await mkdir(controlDir,{recursive:true})
27
+ if(process.env.EZ_REPLY_QA_AUTH){await mkdir(join(controlDir,'cli','codex'),{recursive:true});await symlink(process.env.EZ_REPLY_QA_AUTH,join(controlDir,'cli','codex','auth.json'))}
28
+ const hostMode=process.argv.includes('--transport')
29
+ const hostEnvironment={...process.env};delete hostEnvironment.EZ_EXECUTOR_TRANSPORT
30
+ const host=hostMode?spawn(process.execPath,['--import',fileURLToPath(new URL('../node_modules/tsx/dist/loader.mjs',import.meta.url)),fileURLToPath(import.meta.url),'--host',root],{env:hostEnvironment,stdio:['ignore','inherit','inherit']}):undefined
31
+ if(hostMode)process.env.EZ_EXECUTOR_TRANSPORT='host'
32
+ const control=new ControlStore(controlDir,1000),runs=new RunStore(controlDir)
33
+ await control.requestPairing(101,101);await control.approveOwner(101)
34
+ let writer:any,replyEvents=''
35
+ const relay=createRelay({workspace,controlDir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:'codex',telegramBotToken:'fixture'},async(texts,options)=>{
36
+ if(hostMode)return startExecutorJob(texts,options)
37
+ const run=await runs.get(options.runId)
38
+ if(run?.replyOnly){const job=await startExecutorJob(texts,options);job.child.stdout?.on('data',c=>{replyEvents+=c});return job}
39
+ const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:['pipe','pipe','pipe']});writer=child
40
+ return {child,stdout:'',cleanup:async()=>{}}
41
+ })
42
+ relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as any
43
+ const replies:string[]=[]
44
+ relay.bot.api.config.use(async(_p,method,payload)=>{if(method==='sendMessage')replies.push((payload as any).text);return {ok:true,result:method==='sendMessage'?{message_id:replies.length,date:0,chat:{id:101,type:'private'},text:(payload as any).text}:true} as any})
45
+ const msg=(id:number,text:string):Update=>({update_id:id,message:{message_id:id,date:0,text,from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}})
46
+ try{
47
+ await relay.bot.handleUpdate(msg(1,'Long work'));await relay.drainInbox(true)
48
+ await relay.bot.handleUpdate(msg(2,'What is running? Also calculate 17 times 19. Use your available reply tools. Do not queue any work.'));await relay.drainInbox(true)
49
+ const started=Date.now()
50
+ while(!replies.length && Date.now()-started<120000){await relay.drainOutbox();await new Promise(r=>setTimeout(r,250))}
51
+ if(!replies.some(s=>s.includes('323')))throw new Error('No verified arithmetic reply: '+JSON.stringify(replies))
52
+ if((await runs.get('tg_1'))?.status!=='running')throw new Error('Writer stopped')
53
+ while((await runs.get('tg_2'))?.status==='running' && Date.now()-started<120000)await new Promise(r=>setTimeout(r,250))
54
+ if((await runs.get('tg_2'))?.status!=='completed')throw new Error('Reply did not finish successfully')
55
+ const reply=await runs.get('tg_2');if(!reply?.replyOnly)throw new Error('No restricted reply lane')
56
+ await writeFile(join(root,'evidence.json'),JSON.stringify({replyMs:Date.now()-started,replies,writerRunning:(await runs.get('tg_1'))?.status==='running',replyEvents},null,2))
57
+ console.log(JSON.stringify({root,replyMs:Date.now()-started,replies,writerRunning:true}))
58
+ }finally{await relay.stop();writer?.kill();host?.kill();delete process.env.EZ_EXECUTOR_TRANSPORT}
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ import { createHash } from 'node:crypto';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { mkdir, readFile, writeFile, mkdtemp, rm, rename } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { resolve, join } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { isDeepStrictEqual } from 'node:util';
9
+
10
+ const REGISTRY = 'https://registry.npmjs.org/';
11
+ const CORE = 'jdorado/ez-agents';
12
+ const MAX_ARTIFACT = 100 * 1024 * 1024;
13
+ const assert = (condition, message) => { if (!condition) throw new Error(message); };
14
+ export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
15
+
16
+ export function publishEnvironment(env) {
17
+ const allowed = ['PATH', 'HOME', 'TMPDIR', 'TEMP', 'TMP', 'LANG', 'LC_ALL', 'CI',
18
+ 'GITHUB_ACTIONS', 'GITHUB_WORKFLOW', 'GITHUB_WORKFLOW_REF', 'GITHUB_WORKFLOW_SHA',
19
+ 'GITHUB_REPOSITORY', 'GITHUB_REPOSITORY_ID', 'GITHUB_REPOSITORY_OWNER', 'GITHUB_REPOSITORY_OWNER_ID',
20
+ 'GITHUB_SERVER_URL', 'GITHUB_REF', 'GITHUB_REF_NAME', 'GITHUB_REF_TYPE', 'GITHUB_SHA',
21
+ 'GITHUB_RUN_ID', 'GITHUB_RUN_NUMBER', 'GITHUB_RUN_ATTEMPT', 'GITHUB_EVENT_NAME', 'GITHUB_JOB',
22
+ 'GITHUB_ACTOR', 'GITHUB_ACTOR_ID', 'RUNNER_ENVIRONMENT', 'RUNNER_OS', 'RUNNER_ARCH',
23
+ 'ACTIONS_ID_TOKEN_REQUEST_URL', 'ACTIONS_ID_TOKEN_REQUEST_TOKEN'];
24
+ return Object.fromEntries(allowed.filter(key => env[key] !== undefined).map(key => [key, env[key]]));
25
+ }
26
+
27
+ export function publishArguments(path, npmrc, globalNpmrc) {
28
+ return ['publish', path, '--fetch-retries=0', '--ignore-scripts', '--provenance', '--access', 'public', '--tag', 'latest', '--registry', REGISTRY, '--userconfig', npmrc, '--globalconfig', globalNpmrc];
29
+ }
30
+
31
+ export function identity(env) {
32
+ const value = { repository: env.RELEASE_REPOSITORY, package: env.RELEASE_PACKAGE,
33
+ version: env.RELEASE_VERSION, sourceSha: env.RELEASE_SOURCE_SHA,
34
+ sha256: env.RELEASE_SHA256, releaseId: Number(env.RELEASE_ID), requiredChecks: JSON.parse(env.RELEASE_REQUIRED_CHECKS || 'null') };
35
+ assert(/^[1-9]\d*$/.test(env.RELEASE_ID || '') && Number.isSafeInteger(value.releaseId), 'Invalid draft release ID');
36
+ assert(/^jdorado\/[A-Za-z0-9_.-]+$/.test(value.repository || ''), 'Invalid repository');
37
+ assert(/^@jc_stack\/[a-z0-9-]+$/.test(value.package || ''), 'Invalid package');
38
+ assert(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-beta\.(0|[1-9]\d*)$/.test(value.version || ''), 'Only immutable beta.N versions are allowed');
39
+ assert(/^[a-f0-9]{40}$/.test(value.sourceSha || ''), 'Invalid source SHA');
40
+ assert(/^[a-f0-9]{64}$/.test(value.sha256 || ''), 'Invalid artifact SHA256');
41
+ assert(Array.isArray(value.requiredChecks) && value.requiredChecks.length > 0 && value.requiredChecks.every(x => typeof x === 'string' && x.length > 0) && new Set(value.requiredChecks).size === value.requiredChecks.length, 'Required checks must be a nonempty unique list');
42
+ assert(env.GITHUB_REPOSITORY === value.repository, 'Caller repository mismatch');
43
+ assert(env.GITHUB_EVENT_NAME === 'workflow_dispatch' && env.GITHUB_REF === 'refs/heads/main', 'Must manually dispatch from main');
44
+ assert(env.GITHUB_SHA === value.sourceSha, 'Source must equal dispatched source');
45
+ return value;
46
+ }
47
+
48
+ export function validateManifest(manifest, expected) {
49
+ assert(manifest.name === expected.package && manifest.version === expected.version, 'Package identity mismatch');
50
+ assert(manifest.private !== true, 'Private packages cannot be published');
51
+ const repo = typeof manifest.repository === 'string' ? manifest.repository : manifest.repository?.url;
52
+ assert(repo === `git+https://github.com/${expected.repository}.git` || repo === `https://github.com/${expected.repository}.git` || repo === `https://github.com/${expected.repository}`, 'Package repository mismatch');
53
+ const config = manifest.publishConfig || {};
54
+ assert(Object.keys(config).every(key => ['access', 'tag', 'registry', 'provenance'].includes(key)), 'Unsupported publish configuration');
55
+ assert(config.access === undefined || config.access === 'public', 'Invalid publish access');
56
+ assert(config.tag === undefined || config.tag === 'latest', 'Invalid publish tag');
57
+ assert(config.registry === undefined || config.registry === REGISTRY || config.registry === REGISTRY.slice(0, -1), 'Invalid publish registry');
58
+ assert(config.provenance !== false, 'Provenance must not be disabled');
59
+ }
60
+
61
+ export function validateReceipt(receipt, expected) {
62
+ for (const key of ['repository', 'package', 'version', 'sourceSha', 'sha256']) {
63
+ assert(receipt[key] === expected[key], `Receipt ${key} mismatch`);
64
+ }
65
+ const base = `https://github.com/${expected.repository}/`;
66
+ assert(typeof receipt.independentReviewUrl === 'string' && receipt.independentReviewUrl.startsWith(base) && /^pull\/[1-9]\d*(?:#[A-Za-z0-9_-]+)?$/.test(receipt.independentReviewUrl.slice(base.length)), 'Missing independent review PR URL');
67
+ assert(Array.isArray(receipt.testEvidenceUrls) && receipt.testEvidenceUrls.length > 0 && receipt.testEvidenceUrls.every(url => typeof url === 'string' && url.startsWith(base) && /^(?:actions\/runs|pull|issues)\/[1-9]\d*(?:#[A-Za-z0-9_-]+)?$/.test(url.slice(base.length))), 'Missing test evidence URLs');
68
+ }
69
+
70
+ export function validateChecks(checks, expected) {
71
+ const selected = [];
72
+ for (const name of expected.requiredChecks) {
73
+ const runs = checks.filter(check => check.name === name && check.head_sha === expected.sourceSha && check.app?.id === 15368).sort((a, b) => b.id - a.id);
74
+ assert(runs.length > 0 && runs[0].status === 'completed' && runs[0].conclusion === 'success', `Required GitHub Actions check not successful: ${name}`);
75
+ selected.push(runs[0]);
76
+ }
77
+ return selected;
78
+ }
79
+
80
+ export function tarManifest(path) {
81
+ const options = { encoding: 'utf8', maxBuffer: 8 * 1024 * 1024, timeout: 30_000 };
82
+ const entries = execFileSync('tar', ['-tzf', path], options).trim().split('\n');
83
+ assert(entries.filter(name => name === 'package/package.json').length === 1, 'Tarball must have exactly one package/package.json');
84
+ assert(entries.every(name => name.startsWith('package/') && !name.split('/').includes('..') && !name.includes('\\')), 'Unsafe tarball path');
85
+ const details = execFileSync('tar', ['-tvzf', path], options).trim().split('\n');
86
+ assert(details.every(line => line.startsWith('-') || line.startsWith('d')), 'Tarball links or special files are forbidden');
87
+ return JSON.parse(execFileSync('tar', ['-xOf', path, 'package/package.json'], { ...options, maxBuffer: 1024 * 1024 }));
88
+ }
89
+
90
+ async function responseBytes(response, max = MAX_ARTIFACT) {
91
+ assert(response.ok, `HTTP ${response.status} while reading release evidence`);
92
+ assert(Number(response.headers.get('content-length') || 0) <= max, 'Response too large');
93
+ let size = 0; const chunks = [];
94
+ for await (const chunk of response.body) { size += chunk.length; assert(size <= max, 'Response too large'); chunks.push(chunk); }
95
+ return Buffer.concat(chunks);
96
+ }
97
+
98
+ export function githubClient(token, fetcher = fetch) {
99
+ return async (path, binary = false) => {
100
+ assert(path.startsWith('/repos/'), 'Invalid GitHub API path');
101
+ const response = await fetcher(`https://api.github.com${path}`, {
102
+ headers: { Accept: binary ? 'application/octet-stream' : 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}), 'X-GitHub-Api-Version': '2022-11-28' },
103
+ redirect: 'manual', signal: AbortSignal.timeout(30_000),
104
+ });
105
+ if (binary && [301, 302, 303, 307, 308].includes(response.status)) {
106
+ const location = new URL(response.headers.get('location'));
107
+ assert(location.protocol === 'https:' && (location.hostname === 'release-assets.githubusercontent.com' || location.hostname === 'objects.githubusercontent.com'), 'Unexpected asset redirect');
108
+ return responseBytes(await fetcher(location, { signal: AbortSignal.timeout(60_000), redirect: 'error' }));
109
+ }
110
+ const bytes = await responseBytes(response, binary ? MAX_ARTIFACT : 8 * 1024 * 1024);
111
+ return binary ? bytes : JSON.parse(bytes.toString());
112
+ };
113
+ }
114
+
115
+ export async function validateSource(expected, api) {
116
+ const repo = await api(`/repos/${expected.repository}`);
117
+ assert(repo.full_name === expected.repository && repo.private === false && repo.visibility === 'public' && repo.default_branch === 'main' && !repo.archived, 'Repository must be public and active on main');
118
+ const catalog = await api(`/repos/${CORE}/contents/docs/plugin-catalog.md?ref=main`);
119
+ const catalogText = Buffer.from(catalog.content, 'base64').toString('utf8');
120
+ const enrolled = catalogText.split('\n').some(line => line.startsWith('|') && line.includes(`](https://github.com/${expected.repository})`) && line.includes('`' + expected.package + '`'));
121
+ assert(expected.repository === CORE ? expected.package === '@jc_stack/ez-agents' : enrolled, 'Repository/package is not enrolled in public catalog');
122
+ const main = await api(`/repos/${expected.repository}/git/ref/heads/main`);
123
+ assert(main.object?.sha === expected.sourceSha, 'Source is no longer current main');
124
+ const manifest = await api(`/repos/${expected.repository}/contents/package.json?ref=${expected.sourceSha}`);
125
+ const sourceManifest = JSON.parse(Buffer.from(manifest.content, 'base64').toString('utf8'));
126
+ validateManifest(sourceManifest, expected);
127
+ let tag = (await api(`/repos/${expected.repository}/git/ref/tags/v${expected.version}`)).object;
128
+ for (let depth = 0; tag?.type === 'tag' && depth < 5; depth++) tag = (await api(`/repos/${expected.repository}/git/tags/${tag.sha}`)).object;
129
+ assert(tag?.type === 'commit' && tag.sha === expected.sourceSha, 'Release tag does not identify approved source');
130
+ // Select CI identity before considering outcomes: tag/PR runs at the same SHA
131
+ // must neither shadow main CI nor let a failed latest main run fall back.
132
+ const runs = [];
133
+ for (let page = 1; ; page++) {
134
+ assert(page <= 100, 'Too many workflow run pages');
135
+ const result = await api(`/repos/${expected.repository}/actions/workflows/ci.yml/runs?branch=main&event=push&head_sha=${expected.sourceSha}&per_page=100&page=${page}`);
136
+ assert(Array.isArray(result.workflow_runs), 'Invalid workflow run response');
137
+ runs.push(...result.workflow_runs.filter(run => run.head_sha === expected.sourceSha && run.event === 'push' && run.head_branch === 'main' && run.path === '.github/workflows/ci.yml'));
138
+ if (result.workflow_runs.length < 100) break;
139
+ }
140
+ const run = runs.sort((a, b) => b.id - a.id)[0];
141
+ assert(run && Number.isSafeInteger(run.id) && run.id > 0 && Number.isSafeInteger(run.run_attempt) && run.run_attempt > 0 && run.status === 'completed' && run.conclusion === 'success', 'Required check is not successful main push CI');
142
+ const jobs = [];
143
+ for (let page = 1; ; page++) {
144
+ assert(page <= 100, 'Too many job pages');
145
+ const result = await api(`/repos/${expected.repository}/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100&page=${page}`);
146
+ assert(Array.isArray(result.jobs), 'Invalid job response');
147
+ jobs.push(...result.jobs);
148
+ if (result.jobs.length < 100) break;
149
+ }
150
+ const checks = [];
151
+ for (const name of expected.requiredChecks) {
152
+ const matches = jobs.filter(job => job.name === name);
153
+ assert(matches.length === 1, `Missing or ambiguous required CI job: ${name}`);
154
+ const job = matches[0];
155
+ assert(job.run_id === run.id && job.run_attempt === run.run_attempt && job.head_sha === expected.sourceSha && job.status === 'completed' && job.conclusion === 'success', `Required CI job not successful: ${name}`);
156
+ const prefix = `https://api.github.com/repos/${expected.repository}/check-runs/`;
157
+ assert(typeof job.check_run_url === 'string' && job.check_run_url.startsWith(prefix) && /^[1-9]\d*$/.test(job.check_run_url.slice(prefix.length)), 'Invalid job check evidence URL');
158
+ const check = await api(job.check_run_url.slice('https://api.github.com'.length));
159
+ assert(check.name === name && check.details_url === `https://github.com/${expected.repository}/actions/runs/${run.id}/job/${job.id}`, 'Check does not identify the required CI job');
160
+ checks.push(check);
161
+ }
162
+ validateChecks(checks, expected);
163
+ return sourceManifest;
164
+ }
165
+
166
+ export async function registryState(expected, fetcher = fetch) {
167
+ const response = await fetcher(`${REGISTRY}${encodeURIComponent(expected.package)}`, { signal: AbortSignal.timeout(30_000), redirect: 'error', headers: { Accept: 'application/json' } });
168
+ assert(response.status !== 404, 'Package does not exist: first reviewed beta needs interactive registry-owner publication before trust enrollment');
169
+ const data = JSON.parse((await responseBytes(response, 32 * 1024 * 1024)).toString());
170
+ assert(data.name === expected.package && data.versions && data['dist-tags'], 'Invalid registry package metadata');
171
+ const published = data.versions[expected.version];
172
+ if (!published) return { exists: false, latest: data['dist-tags'].latest ?? null, beta: data['dist-tags'].beta ?? null };
173
+ assert(published.name === expected.package && published.version === expected.version, 'Registry version identity mismatch');
174
+ const url = new URL(published.dist?.tarball);
175
+ assert(url.origin === REGISTRY.slice(0, -1), 'Unexpected registry tarball host');
176
+ const bytes = await responseBytes(await fetcher(url, { signal: AbortSignal.timeout(60_000), redirect: 'error' }));
177
+ assert(sha256(bytes) === expected.sha256, 'Existing registry artifact differs; never overwrite');
178
+ return { exists: true, latest: data['dist-tags'].latest ?? null, beta: data['dist-tags'].beta ?? null };
179
+ }
180
+
181
+ export async function validate(output, env = process.env, api = githubClient(env.GH_TOKEN)) {
182
+ const expected = identity(env);
183
+ const sourceManifest = await validateSource(expected, api);
184
+ const release = await api(`/repos/${expected.repository}/releases/${expected.releaseId}`);
185
+ assert(release.id === expected.releaseId && release.draft === true && release.prerelease === true && release.tag_name === `v${expected.version}`, 'Candidate must be a draft prerelease');
186
+ const asset = name => {
187
+ const matches = (release.assets || []).filter(item => item.name === name && item.state === 'uploaded');
188
+ assert(matches.length === 1 && Number.isSafeInteger(matches[0].id), `Missing or ambiguous asset: ${name}`);
189
+ return matches[0];
190
+ };
191
+ const candidateAsset = asset('candidate.tgz');
192
+ const receiptAsset = asset('release-receipt.json');
193
+ const bytes = await api(`/repos/${expected.repository}/releases/assets/${candidateAsset.id}`, true);
194
+ assert(sha256(bytes) === expected.sha256, 'Candidate artifact SHA256 mismatch');
195
+ const receipt = JSON.parse((await api(`/repos/${expected.repository}/releases/assets/${receiptAsset.id}`, true)).toString());
196
+ validateReceipt(receipt, expected);
197
+ // Dispatch is the authorized maintainer's attestation to these review/test URLs.
198
+ // Their existence alone is not an independent review verdict.
199
+ const reviewPr = Number(new URL(receipt.independentReviewUrl).pathname.split('/')[4]);
200
+ const pr = await api(`/repos/${expected.repository}/pulls/${reviewPr}`);
201
+ assert(pr.merged === true && pr.base?.repo?.full_name === expected.repository && pr.base?.ref === 'main' && pr.merge_commit_sha === expected.sourceSha, 'Review PR must be merged as the exact release source');
202
+ await mkdir(output, { recursive: true });
203
+ const tarball = resolve(output, 'candidate.tgz');
204
+ await writeFile(tarball, bytes, { flag: 'wx' });
205
+ const packedManifest = tarManifest(tarball);
206
+ validateManifest(packedManifest, expected);
207
+ assert(isDeepStrictEqual(packedManifest, sourceManifest), 'Packed manifest differs from approved source manifest');
208
+ await writeFile(resolve(output, 'validated.json'), JSON.stringify({ schema: 1, expected, receipt, releaseId: release.id, candidateAssetId: candidateAsset.id }, null, 2) + '\n', { flag: 'wx' });
209
+ return { sourceSha: expected.sourceSha, sha256: expected.sha256, version: expected.version };
210
+ }
211
+
212
+ export async function publishOnce(expected, { readState, publishTarball, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), report = console.log, record = async () => {}, allowWrite = true }) {
213
+ const before = await readState();
214
+ await record({ phase: 'preflight', before });
215
+ if (before.exists) {
216
+ assert(before.latest === expected.version, 'Artifact exists but latest tag differs; reconcile without republishing');
217
+ return { status: 'already-published', version: expected.version, sha256: expected.sha256 };
218
+ }
219
+ assert(allowWrite, 'Rerun cannot repeat publication: reconcile registry state and create a fresh authorized dispatch if a new attempt is needed');
220
+ await record({ phase: 'write-started', before });
221
+ let writeError;
222
+ try { await publishTarball(); } catch (error) { writeError = error; }
223
+ let lastError;
224
+ for (let attempt = 0; attempt < 4; attempt++) {
225
+ if (attempt) await sleep(5000);
226
+ try {
227
+ const after = await readState();
228
+ await record({ phase: 'readback', before, after, publishCommandFailed: Boolean(writeError), attempt });
229
+ if (after.exists && after.latest === expected.version) {
230
+ if (writeError) report('Publish command was uncertain; registry readback verified exact artifact and latest tag.');
231
+ return { status: 'published', version: expected.version, sha256: expected.sha256 };
232
+ }
233
+ lastError = new Error('Exact artifact and latest tag not yet verified');
234
+ } catch (error) { lastError = error; await record({ phase: 'readback-error', before, error: error.message, attempt }); }
235
+ }
236
+ throw new Error(`Publication unresolved; do not repeat the write before registry reconciliation: ${lastError?.message || writeError?.message}`);
237
+ }
238
+
239
+ export async function publish(output, env = process.env) {
240
+ const expected = identity(env);
241
+ const bundle = JSON.parse(await readFile(resolve(output, 'validated.json'), 'utf8'));
242
+ assert(bundle.schema === 1 && JSON.stringify(bundle.expected) === JSON.stringify(expected), 'Validated bundle does not match dispatched identity');
243
+ validateReceipt(bundle.receipt, expected);
244
+ const path = resolve(output, 'candidate.tgz');
245
+ assert(sha256(await readFile(path)) === expected.sha256, 'Validated artifact changed');
246
+ const packedManifest = tarManifest(path);
247
+ validateManifest(packedManifest, expected);
248
+ const sourceManifest = await validateSource(expected, githubClient(env.GH_TOKEN));
249
+ assert(isDeepStrictEqual(packedManifest, sourceManifest), 'Packed manifest differs from approved source manifest');
250
+ assert(!env.NODE_AUTH_TOKEN && !env.NPM_TOKEN, 'Token-based npm publishing is forbidden');
251
+ const temporary = await mkdtemp(join(tmpdir(), 'trusted-beta-'));
252
+ try {
253
+ const npmrc = join(temporary, 'npmrc');
254
+ const globalNpmrc = join(temporary, 'global-npmrc');
255
+ await writeFile(npmrc, 'registry=https://registry.npmjs.org/\n');
256
+ await writeFile(globalNpmrc, '');
257
+ const events = [];
258
+ const record = async event => {
259
+ events.push({ at: new Date().toISOString(), ...event });
260
+ const receiptPath = resolve(output, 'publication-receipt.json');
261
+ await writeFile(`${receiptPath}.${process.pid}.tmp`, JSON.stringify({ schema: 1, expected, events }, null, 2) + '\n', { mode: 0o600 });
262
+ await rename(`${receiptPath}.${process.pid}.tmp`, receiptPath);
263
+ };
264
+ try {
265
+ const result = await publishOnce(expected, {
266
+ record,
267
+ allowWrite: env.GITHUB_RUN_ATTEMPT === '1',
268
+ readState: () => registryState(expected),
269
+ publishTarball: () => execFileSync('npm', publishArguments(path, npmrc, globalNpmrc), {
270
+ cwd: temporary, stdio: 'inherit', timeout: 180_000,
271
+ env: publishEnvironment(env),
272
+ }),
273
+ });
274
+ await record({ phase: 'complete', result });
275
+ return result;
276
+ } catch (error) {
277
+ await record({ phase: 'failed', error: error.message });
278
+ throw error;
279
+ }
280
+ } finally { await rm(temporary, { recursive: true, force: true }); }
281
+ }
282
+
283
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
284
+ try {
285
+ const [mode, output] = process.argv.slice(2);
286
+ assert(['validate', 'publish'].includes(mode) && output && process.argv.length === 4, 'Usage: trusted-beta.mjs validate|publish OUTPUT_DIR');
287
+ console.log(JSON.stringify(await (mode === 'validate' ? validate(output) : publish(output))));
288
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
289
+ }
@@ -0,0 +1,9 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ // Resolve against the installed package, never the agent's editable workspace.
4
+ export const agentGuidance = (): string =>
5
+ readFileSync(new URL('../templates/agent-guidance.md', import.meta.url), 'utf8').trim()
6
+
7
+ // Channel behavior is shared without exposing owner workspace guidance to contacts.
8
+ export const chatGuidance = (): string =>
9
+ readFileSync(new URL('../templates/chat-guidance.md', import.meta.url), 'utf8').trim()
package/src/ai-cli.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { parseArgs } from 'node:util'
2
2
  import { randomBytes } from 'node:crypto'
3
+ import { join } from 'node:path'
3
4
  import { readModels, validateSelection, type AiPreset } from './ai.js'
4
5
  import { ControlStore } from './control-state.js'
5
6
 
6
7
  const {values,positionals}=parseArgs({allowPositionals:true,options:{cli:{type:'string'},model:{type:'string'},effort:{type:'string'}}})
7
8
  if (!process.env.EZ_CONTROL_DIR) throw new Error('Use this agent’s bound control directory')
8
- const catalog=await readModels()
9
+ const catalog=await readModels(undefined,undefined,join(process.env.EZ_CONTROL_DIR,'cli','codex'))
9
10
  if(positionals[0]==='list')console.log(JSON.stringify(catalog))
10
11
  else if(positionals[0]==='select'){
11
12
  const preset:AiPreset={id:randomBytes(8).toString('hex'),name:[values.model||values.cli,values.effort].filter(Boolean).join(' · '),cli:values.cli||'',model:values.model,effort:values.effort}