@jc_stack/ez-agents 0.1.0-beta.12 → 0.1.0-beta.18

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 (132) hide show
  1. package/.dockerignore +4 -0
  2. package/.env.example +16 -1
  3. package/AGENTS.md +16 -4
  4. package/CHANGELOG.md +71 -0
  5. package/CONTRIBUTING.md +37 -4
  6. package/README.md +114 -9
  7. package/SECURITY.md +7 -1
  8. package/bin/ezenciel-agents-schedule +2 -0
  9. package/bin/ezenciel-agents-schedule.mjs +16 -0
  10. package/bin/ezenciel-agents-task +2 -0
  11. package/bin/ezenciel-agents-task.mjs +16 -0
  12. package/compose.yaml +10 -2
  13. package/docker/recovery.ts +2 -2
  14. package/docker/run.ts +2 -2
  15. package/docs/architecture/ai-selection.md +8 -0
  16. package/docs/architecture/authority-boundaries.md +137 -12
  17. package/docs/architecture/event-sources.md +12 -7
  18. package/docs/architecture/telegram-intake.md +1 -1
  19. package/docs/channel-backend.md +36 -0
  20. package/docs/docker-runtime.md +35 -0
  21. package/docs/host-service.md +19 -0
  22. package/docs/local-qa.md +45 -0
  23. package/docs/pagerduty.md +42 -0
  24. package/docs/plugin-catalog.md +71 -0
  25. package/docs/plugin-contributions.md +12 -0
  26. package/docs/plugins.md +61 -1
  27. package/docs/releasing.md +20 -9
  28. package/docs/repair.md +41 -0
  29. package/docs/scheduling.md +153 -0
  30. package/docs/selective-monitoring.md +114 -0
  31. package/docs/setup.md +46 -0
  32. package/docs/standalone-cli.md +62 -0
  33. package/docs/trusted-publishing.md +140 -0
  34. package/docs/upgrades.md +24 -4
  35. package/package.json +12 -4
  36. package/scripts/generate-publish-caller.mjs +60 -0
  37. package/scripts/smoke-busy-reply.ts +58 -0
  38. package/scripts/smoke-scheduler.ts +90 -0
  39. package/scripts/stage-qa.mjs +42 -0
  40. package/scripts/trusted-beta.mjs +289 -0
  41. package/src/agent-guidance.ts +5 -0
  42. package/src/ai-cli.ts +2 -1
  43. package/src/ai.ts +15 -5
  44. package/src/channel-backend.ts +46 -0
  45. package/src/client-defaults.ts +29 -13
  46. package/src/codex-session.ts +98 -0
  47. package/src/config.ts +35 -2
  48. package/src/control-state.ts +24 -7
  49. package/src/desktop-bridge.ts +37 -12
  50. package/src/event-sources.ts +2 -1
  51. package/src/execution-authority.ts +25 -0
  52. package/src/executor.ts +97 -21
  53. package/src/failure.ts +32 -0
  54. package/src/host-executor.ts +48 -19
  55. package/src/identity.ts +8 -3
  56. package/src/inbox.ts +11 -3
  57. package/src/index.ts +315 -91
  58. package/src/install-tools.mjs +2 -2
  59. package/src/menu.ts +6 -4
  60. package/src/model-policy.ts +15 -0
  61. package/src/owner.ts +3 -3
  62. package/src/pagerduty.ts +109 -0
  63. package/src/plugins/exposure.mjs +13 -0
  64. package/src/plugins/manager.mjs +74 -20
  65. package/src/plugins/shared.mjs +76 -0
  66. package/src/process-tree.ts +33 -0
  67. package/src/repair-policy.ts +13 -0
  68. package/src/reply-context.ts +67 -0
  69. package/src/reply-executor.ts +54 -0
  70. package/src/reply-mcp.ts +23 -0
  71. package/src/runs.ts +63 -19
  72. package/src/schedule-cli.ts +98 -0
  73. package/src/schedule-time.ts +85 -0
  74. package/src/scheduler.ts +130 -0
  75. package/src/setup.ts +2 -1
  76. package/src/software-status.ts +5 -5
  77. package/src/source-cli.ts +1 -1
  78. package/src/task-cli.ts +16 -0
  79. package/src/task-executor.ts +65 -0
  80. package/src/task-mcp.ts +36 -0
  81. package/src/task-rpc.ts +45 -0
  82. package/src/task-workspace.ts +22 -0
  83. package/src/tasks.ts +210 -0
  84. package/src/telegram-source.ts +94 -0
  85. package/src/updates/artifact.mjs +16 -0
  86. package/src/updates/binding.mjs +4 -1
  87. package/src/updates/control.mjs +4 -4
  88. package/src/updates/runtime.mjs +3 -1
  89. package/src/updates/status.mjs +7 -1
  90. package/templates/agent/AGENTS.md +10 -2
  91. package/templates/agent/TOOLS.md +60 -1
  92. package/templates/agent-guidance.md +13 -0
  93. package/templates/failure-review.md +9 -0
  94. package/templates/maintainer-purpose.md +15 -0
  95. package/templates/standalone-tools.md +20 -0
  96. package/templates/updates.md +2 -2
  97. package/test/agent-guidance.test.ts +110 -0
  98. package/test/ai-cli.test.ts +7 -6
  99. package/test/ai.test.ts +41 -0
  100. package/test/busy-reply-relay.test.ts +41 -0
  101. package/test/channel-backend.test.ts +100 -0
  102. package/test/client-defaults.test.ts +37 -5
  103. package/test/codex-context.test.ts +39 -1
  104. package/test/codex-session.test.ts +51 -0
  105. package/test/config.test.ts +31 -2
  106. package/test/desktop-bridge.test.ts +19 -0
  107. package/test/event-sources.test.ts +47 -11
  108. package/test/execution-authority.test.ts +42 -0
  109. package/test/executor.test.ts +53 -2
  110. package/test/failure.test.ts +250 -0
  111. package/test/group-owner.test.ts +36 -0
  112. package/test/helpers/owner-run.ts +13 -0
  113. package/test/host-executor.test.ts +47 -10
  114. package/test/intake-relay.test.ts +141 -4
  115. package/test/local-qa.test.mjs +38 -0
  116. package/test/model-policy.test.ts +61 -0
  117. package/test/pagerduty.test.ts +104 -0
  118. package/test/plugin-manager.test.mjs +73 -3
  119. package/test/relay.test.ts +2 -2
  120. package/test/repair-policy.test.ts +23 -0
  121. package/test/reply.test.ts +131 -0
  122. package/test/schedule-cli.test.ts +55 -0
  123. package/test/scheduler-host.test.ts +55 -0
  124. package/test/scheduler-relay.test.ts +67 -0
  125. package/test/scheduler.test.ts +104 -0
  126. package/test/shared-services.test.mjs +98 -0
  127. package/test/software-status.test.ts +5 -5
  128. package/test/task-native.test.ts +87 -0
  129. package/test/tasks.test.ts +187 -0
  130. package/test/telegram-source.test.ts +75 -0
  131. package/test/trusted-beta.test.mjs +224 -0
  132. package/test/updates.test.mjs +35 -3
package/docs/upgrades.md CHANGED
@@ -2,8 +2,9 @@
2
2
 
3
3
  Available in this beta. Earlier main upgrade/rollback VM QA passed; final-release
4
4
  fresh-host/reboot and live plugin upgrade acceptance remain pending. npm
5
- publication is not required to test this feature. Stable releases are the default
6
- automatic channel. The existing owner may select beta or manual policy per target.
5
+ publication is not required to test this feature. The beta channel is the default
6
+ automatic channel for core and plugins without a saved policy. Existing explicit
7
+ stable or manual policies are preserved. The owner may select either per target.
7
8
  The main target and installed plugins version independently.
8
9
 
9
10
  The agent owns release review, policy decisions and communication. The host
@@ -109,8 +110,8 @@ live. A healthy container alone does not prove a Telegram or plugin reply.
109
110
 
110
111
  ```sh
111
112
  ez updates check
112
- ez updates policy main # defaults: automatic, stable
113
- ez updates policy whatsapp beta # only under owner authorization
113
+ ez updates policy main # defaults: automatic, beta
114
+ ez updates policy whatsapp stable # opt into stable-only updates
114
115
  ez updates policy main manual # disable unattended upgrades
115
116
  ez updates prepare main --version 0.1.0-beta.4
116
117
  # Or a local candidate, independently of npm:
@@ -191,3 +192,22 @@ changes also need supervisor restart and requesting-process-exit tests. Run
191
192
  `node docker/upgrade-smoke.mjs` with a local `EZ_WHATSAPP_SOURCE` containing the
192
193
  WhatsApp fixture. The smoke uses synthetic transport only. VM installation,
193
194
  agent-led upgrades, restart and real account acceptance remain separate QA gates.
195
+
196
+ Beta policy discovers the newer of npm latest and the legacy beta tag. Stable-only
197
+ policy selects non-deprecated stable versions, even when latest is a prerelease.
198
+ No eligible version is reported as available:null with newer:false. Older installed
199
+ updaters need an exact-version core update to adopt this discovery behavior.
200
+
201
+ ## Shared agent guidance
202
+
203
+ Ez includes `templates/agent-guidance.md` from the running package in every
204
+ owner-worker prompt, including resumed CLI and desktop conversations and scheduled
205
+ owner work. After the runtime upgrades, the next turn receives the new guidance.
206
+ An already running turn keeps its original prompt. Restricted contact tasks and
207
+ reply-only workers retain their separate, bounded instructions.
208
+
209
+ Keep general operating defaults in this package-owned file. Keep agent purpose,
210
+ preferences and local conventions in the workspace's `AGENTS.md`, `SOUL.md`,
211
+ `USER.md` and memory files; upgrades preserve them. Shared guidance does not
212
+ grant permissions, and explicit owner instructions take precedence over its
213
+ defaults within existing execution permissions.
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@jc_stack/ez-agents",
3
- "version": "0.1.0-beta.12",
3
+ "version": "0.1.0-beta.18",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "A minimal Telegram-to-CLI executor relay.",
6
+ "description": "A lightweight foundation for persistent business AI assistants using existing AI harnesses, workspaces and plugins.",
7
7
  "packageManager": "pnpm@10.30.3",
8
8
  "bin": {
9
+ "ezenciel-agents-schedule": "bin/ezenciel-agents-schedule.mjs",
9
10
  "ezenciel-agents-install": "bin/ezenciel-agents-install",
10
11
  "ezenciel-agents": "bin/ezenciel-agents.mjs",
11
12
  "ezenciel-agents-owner": "bin/ezenciel-agents-owner.mjs",
@@ -13,6 +14,7 @@
13
14
  "ezenciel-agents-react": "bin/ezenciel-agents-react.mjs",
14
15
  "ezenciel-agents-approval": "bin/ezenciel-agents-approval.mjs",
15
16
  "ezenciel-agents-setup": "bin/ezenciel-agents-setup.mjs",
17
+ "ezenciel-agents-task": "bin/ezenciel-agents-task.mjs",
16
18
  "ezenciel-agents-source": "bin/ezenciel-agents-source.mjs",
17
19
  "ezenciel-agents-docker": "bin/ezenciel-agents-docker",
18
20
  "ezenciel-agents-create": "bin/ezenciel-agents-create",
@@ -36,18 +38,23 @@
36
38
  "docker",
37
39
  "compose.whatsapp.yaml",
38
40
  "scripts/smoke.ts",
41
+ "scripts/smoke-scheduler.ts",
39
42
  "LICENSE",
40
43
  "CHANGELOG.md",
41
44
  "THIRD_PARTY_NOTICES.md",
42
45
  "scripts/release-check.mjs",
46
+ "scripts/stage-qa.mjs",
43
47
  "test",
44
48
  "tsconfig.json",
45
49
  "scripts/assert-local-registry.mjs",
46
- ".dockerignore"
50
+ ".dockerignore",
51
+ "scripts/trusted-beta.mjs",
52
+ "scripts/generate-publish-caller.mjs",
53
+ "scripts/smoke-busy-reply.ts"
47
54
  ],
48
55
  "publishConfig": {
49
56
  "access": "public",
50
- "tag": "beta"
57
+ "tag": "latest"
51
58
  },
52
59
  "scripts": {
53
60
  "setup": "tsx --env-file-if-exists=.env src/setup.ts",
@@ -59,6 +66,7 @@
59
66
  "build": "tsc --noEmit",
60
67
  "test": "tsx --test test/*.test.ts test/*.test.mjs",
61
68
  "verify": "pnpm test && pnpm build",
69
+ "smoke:scheduler": "tsx scripts/smoke-scheduler.ts",
62
70
  "smoke": "./bin/ezenciel-agents-docker run --rm --no-deps relay smoke",
63
71
  "prepublishOnly": "npm run verify",
64
72
  "registry:up": "docker compose -f registry/docker-compose.yml up -d",
@@ -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,90 @@
1
+ // Opt-in token-consuming probe. Real executor and relay handlers; synthetic Telegram provider.
2
+ // Run: node --import tsx scripts/smoke-scheduler.ts [seconds=1860] [cli=codex]
3
+ import { mkdtemp, writeFile, readFile, readdir } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { createRelay } from '../src/index.js'
7
+ import { ControlStore } from '../src/control-state.js'
8
+ import { Scheduler } from '../src/scheduler.js'
9
+ import { RunStore } from '../src/runs.js'
10
+ import { initialPreset } from '../src/ai.js'
11
+ import { initializeWorkspace } from '../src/workspace.js'
12
+ import type { Update } from 'grammy/types'
13
+
14
+ const duration=Number(process.argv[2] || 1860),cli=process.argv[3] || 'codex'
15
+ const nativeGoal=process.argv[4]==='goal'
16
+ if(!Number.isSafeInteger(duration) || duration<30)throw new Error('Duration must be at least 30 seconds')
17
+ const root=await mkdtemp(join(tmpdir(),'ez-scheduler-smoke-')),workspace=join(root,'agent'),controlDir=join(root,'control')
18
+ await initializeWorkspace(workspace)
19
+ const control=new ControlStore(controlDir,1000),scheduler=new Scheduler(controlDir),runs=new RunStore(controlDir)
20
+ await control.requestPairing(101,101);await control.approveOwner(101)
21
+ const owner=(await control.status()).owner!,execution=await control.captureChoice(initialPreset(cli))
22
+ const replies:{at:number;text:string}[]=[]
23
+ const relay=createRelay({workspace,controlDir,pairingTtlMs:1000,executorTimeoutMs:0,executorCli:cli,telegramBotToken:'fixture'})
24
+ relay.bot.botInfo={id:999,is_bot:true,first_name:'Fixture',username:'fixture_bot'} as typeof relay.bot.botInfo
25
+ relay.bot.api.config.use(async(_previous,method,payload)=>{
26
+ if(method==='sendMessage') {const reply={at:Date.now(),text:(payload as {text:string}).text};replies.push(reply);console.log(JSON.stringify({reply}))}
27
+ return {ok:true,result:{message_id:replies.length}} as never
28
+ })
29
+ const due=Date.now()+2000
30
+ const text=nativeGoal
31
+ ? `/goal Native multi-turn continuation QA: create phase1.txt containing ONE and phase2.txt containing TWO, then finished.txt containing DONE and deliver BACKGROUND_DONE. In the FIRST turn write only phase1.txt and END with final response FIRST_TURN_DONE, leaving this native goal active. Do not write phase2 or finished.txt or send BACKGROUND_DONE in the first turn. On a subsequent native automatic continuation, run a terminal sleep for ${duration} seconds and wait for it, write phase2.txt and finished.txt, read and verify all three files, send BACKGROUND_DONE through ezenciel-agents-message, verify delivery, and mark this goal complete. No extra schedule, user prompt or custom continuation loop. Keep progress.md updated.`
32
+ : `This is an authorized synthetic test. In your task directory write progress.md with WAITING. Run a terminal sleep for ${duration} seconds and wait for that command to finish. Then write finished.txt containing DONE and use ezenciel-agents-message --text 'BACKGROUND_DONE'. Do not reschedule or finish early. No external services are needed.`
33
+ await scheduler.save({id:'sleep',name:'Long-running synthetic QA',text,trigger:{at:new Date(due).toISOString()},enabled:true,owner,execution})
34
+ let draining=false
35
+ const tick=setInterval(()=>{if(!draining){draining=true;void relay.drainSources().then(()=>relay.drainOutbox()).catch(console.error).finally(()=>{draining=false})}},250)
36
+ const until=async(check:()=>Promise<boolean>,seconds:number)=>{
37
+ const deadline=Date.now()+seconds*1000
38
+ while(!await check()){if(Date.now()>deadline)throw new Error('Probe timed out');await new Promise(r=>setTimeout(r,250))}
39
+ }
40
+ console.log(JSON.stringify({root,duration,cli}))
41
+ try{
42
+ await until(async()=>(await runs.list()).some(r=>r.scheduled && r.status==='running'),60)
43
+ const [background]=(await runs.list()).filter(r=>r.scheduled)
44
+ const message:Update={update_id:10,message:{message_id:10,date:0,text:"What is 17 times 19? Reply with the number using ezenciel-agents-message. This is a local test with a synthetic delivery provider.",from:{id:101,is_bot:false,first_name:'Fixture'},chat:{id:101,type:'private',first_name:'Fixture'}}}
45
+ const askedAt=Date.now();await relay.bot.handleUpdate(message);await relay.drainInbox(true)
46
+ await until(async()=>replies.some(r=>/323/.test(r.text)),180)
47
+ if((await runs.get(background.id))?.status!=='running')throw new Error('Background stopped before chat reply')
48
+ console.log(JSON.stringify({chatReplyMs:Date.now()-askedAt,backgroundState:'running'}))
49
+ if(duration>360){
50
+ await until(async()=>Date.now()-askedAt>360000,duration)
51
+ const followup={...message,update_id:11,message:{...message.message!,message_id:11,text:'Check ezenciel-agents-schedule runs. If the background task is actually running, send BACKGROUND_STATUS_RUNNING followed by its exact run ID. Otherwise report the problem.'}} as Update
52
+ const before=replies.length;await relay.bot.handleUpdate(followup);await relay.drainInbox(true)
53
+ await until(async()=>replies.slice(before).some(r=>r.text.includes('BACKGROUND_STATUS_RUNNING') && r.text.includes(background.id)),180)
54
+ if((await runs.get(background.id))?.status!=='running')throw new Error('Background stopped at six-minute check')
55
+ console.log(JSON.stringify({sixMinuteCheck:'passed',backgroundState:'running'}))
56
+ }
57
+ await until(async()=> (await runs.get(background.id))?.status!=='running',duration+180)
58
+ await relay.drainOutbox()
59
+ const status=(await runs.get(background.id))?.status
60
+ if(status!=='completed' || replies.filter(r=>r.text.includes('BACKGROUND_DONE')).length!==1)throw new Error('Missing completed run or exactly one delivered result')
61
+ const finished=await readFile(join(workspace,'work/tasks',background.id,'finished.txt'),'utf8')
62
+ if(finished.trim()!=='DONE')throw new Error('Missing finished.txt artifact')
63
+ if(nativeGoal){
64
+ for(const [file,expected] of [['phase1.txt','ONE'],['phase2.txt','TWO']])
65
+ if((await readFile(join(workspace,'work/tasks',background.id,file),'utf8')).trim()!==expected)throw new Error(`Missing native goal artifact: ${file}`)
66
+ }
67
+ const record=(await runs.get(background.id))!
68
+ if(Date.parse(record.endedAt!)-Date.parse(record.startedAt!) < duration*1000)throw new Error('Worker completed before requested duration')
69
+ let goalEvidence:unknown
70
+ if(nativeGoal){
71
+ const home=join(controlDir,'cli/codex/tasks',background.id),sessionId=record.nativeSessionId
72
+ if(!sessionId)throw new Error('Missing native session ID')
73
+ const files=await readdir(join(home,'sessions'),{recursive:true})
74
+ const file=files.find(f=>f.endsWith(`${sessionId}.jsonl`))
75
+ if(!file)throw new Error('Missing native transcript')
76
+ const events=(await readFile(join(home,'sessions',file),'utf8')).trim().split('\n').map(line=>JSON.parse(line)).filter(e=>e.type==='event_msg')
77
+ const turns=events.filter(e=>e.payload.type==='task_complete')
78
+ if(turns.length<2 || !turns[0].payload.last_agent_message?.includes('FIRST_TURN_DONE'))throw new Error('Native multi-turn continuation was not verified')
79
+ // Read Codex-owned evidence only; Ez never creates or manages this database.
80
+ const {DatabaseSync}=await import('node:sqlite'),db=new DatabaseSync(join(home,'goals_1.sqlite'),{readOnly:true})
81
+ try{
82
+ const goal=db.prepare('SELECT status FROM thread_goals WHERE thread_id = ?').get(sessionId)
83
+ if(goal?.status!=='complete')throw new Error('Native goal did not complete')
84
+ goalEvidence={status:goal.status,completedTurns:turns.map(e=>e.payload.turn_id)}
85
+ }finally{db.close()}
86
+ }
87
+ const evidence={duration,cli,nativeGoal,goalEvidence,status,replies,run:await runs.get(background.id)}
88
+ await writeFile(join(root,'evidence.json'),JSON.stringify(evidence,null,2),{mode:0o600})
89
+ console.log(JSON.stringify({passed:true,evidence:join(root,'evidence.json')}))
90
+ }finally{clearInterval(tick);while(draining)await new Promise(r=>setTimeout(r,50));await relay.stop()}
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Developer packaging only. The existing agent-owned updater installs the result.
3
+ import * as fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import {tmpdir} from 'node:os';
6
+ import {execFileSync} from 'node:child_process';
7
+ import {parseArgs} from 'node:util';
8
+ import {digest, extract, version, newer} from '../src/updates/artifact.mjs';
9
+
10
+ const {values:v}=parseArgs({options:Object.fromEntries(['source','catalog','label','version','flow'].map(k=>[k,{type:'string'}]))});
11
+ if(!['source','catalog','label','version','flow'].every(k=>v[k]))throw Error('Required: --source CHECKOUT --catalog DIRECTORY --label beta-12 --version 0.1.0-beta.12.qa.1 --flow QA.md');
12
+ if(!/^beta-[1-9]\d*$/.test(v.label))throw Error('Label must be beta-N');
13
+ version(v.version);
14
+ if(!/^\d+\.\d+\.\d+-beta\.\d+\.qa\.[1-9]\d*$/.test(v.version))throw Error('Use a distinct private version: X.Y.Z-beta.N.qa.BUILD');
15
+ const source=await fs.realpath(v.source),catalog=path.resolve(v.catalog);
16
+ const run=(cmd,args,cwd=source)=>execFileSync(cmd,args,{cwd,encoding:'utf8',stdio:['ignore','pipe','pipe']}).trim();
17
+ if(run('git',['status','--porcelain']))throw Error('Commit the reviewed source before staging QA');
18
+ const commit=run('git',['rev-parse','HEAD']),flow=await fs.readFile(v.flow,'utf8');
19
+ if(!flow.trim())throw Error('A feature QA flow is required');
20
+ await fs.mkdir(catalog,{recursive:true,mode:0o700});
21
+ const destination=path.join(catalog,v.label);
22
+ if(await fs.lstat(destination).then(()=>true,err=>{if(err.code==='ENOENT')return false;throw err;}))throw Error('QA label already exists; never replace a build. Choose the next beta label.');
23
+ const temp=await fs.mkdtemp(path.join(tmpdir(),'ez-stage-qa-'));
24
+ const staged=await fs.mkdtemp(path.join(catalog,'.staging-'));
25
+ try {
26
+ const pack=(cwd,out)=>JSON.parse(run('npm',['pack','--ignore-scripts','--json','--pack-destination',out],cwd))[0];
27
+ const original=pack(source,temp);
28
+ await extract(await fs.readFile(path.join(temp,original.filename)),path.join(temp,'source'));
29
+ const root=path.join(temp,'source'),pkg=JSON.parse(await fs.readFile(path.join(root,'package.json'),'utf8'));
30
+ if(pkg.ezRelease?.kind!=='main')throw Error('This staging command accepts the main package only');
31
+ if(!newer(v.version,pkg.version))throw Error('Private QA version must be newer than source package version');
32
+ pkg.version=v.version;pkg.ezQa={label:v.label,commit,private:true};
33
+ await fs.writeFile(path.join(root,'package.json'),JSON.stringify(pkg,null,2)+'\n');
34
+ const packed=pack(root,staged),artifact=await fs.readFile(path.join(staged,packed.filename));
35
+ const receipt={label:v.label,version:v.version,package:pkg.name,commit,sha256:digest(artifact),file:packed.filename,private:true};
36
+ await fs.writeFile(path.join(staged,'manifest.json'),JSON.stringify(receipt,null,2)+'\n',{mode:0o600});
37
+ await fs.writeFile(path.join(staged,'QA.md'),flow,{mode:0o600});
38
+ await fs.chmod(path.join(staged,packed.filename),0o600);
39
+ // The published directory is nonempty, so competing staging cannot replace it.
40
+ await fs.rename(staged,destination);
41
+ console.log(JSON.stringify({...receipt,directory:destination},null,2));
42
+ } finally {await fs.rm(temp,{recursive:true,force:true});await fs.rm(staged,{recursive:true,force:true});}