@skrr-ai/cli 0.1.23 → 0.1.25

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 (85) hide show
  1. package/bin/run.js +54 -1
  2. package/dist/base-command.d.ts +4 -4
  3. package/dist/base-command.js +81 -6
  4. package/dist/commands/harnesses/leases/list.js +1 -1
  5. package/dist/commands/harnesses/products.js +1 -0
  6. package/dist/commands/machines/dedicated/create.d.ts +18 -0
  7. package/dist/commands/machines/dedicated/create.js +90 -0
  8. package/dist/commands/machines/dedicated/destroy.d.ts +14 -0
  9. package/dist/commands/machines/dedicated/destroy.js +56 -0
  10. package/dist/commands/machines/dedicated/grow.d.ts +13 -0
  11. package/dist/commands/machines/dedicated/grow.js +46 -0
  12. package/dist/commands/machines/dedicated/health-check.d.ts +12 -0
  13. package/dist/commands/machines/dedicated/health-check.js +42 -0
  14. package/dist/commands/machines/dedicated/incident-close.d.ts +12 -0
  15. package/dist/commands/machines/dedicated/incident-close.js +42 -0
  16. package/dist/commands/machines/dedicated/index.d.ts +6 -0
  17. package/dist/commands/machines/dedicated/index.js +39 -0
  18. package/dist/commands/machines/dedicated/list.d.ts +11 -0
  19. package/dist/commands/machines/dedicated/list.js +58 -0
  20. package/dist/commands/machines/dedicated/restart.d.ts +12 -0
  21. package/dist/commands/machines/dedicated/restart.js +42 -0
  22. package/dist/commands/machines/dedicated/show.d.ts +12 -0
  23. package/dist/commands/machines/dedicated/show.js +58 -0
  24. package/dist/commands/machines/dedicated/snapshot.d.ts +12 -0
  25. package/dist/commands/machines/dedicated/snapshot.js +42 -0
  26. package/dist/commands/machines/dedicated/start.d.ts +12 -0
  27. package/dist/commands/machines/dedicated/start.js +42 -0
  28. package/dist/commands/machines/dedicated/stop.d.ts +12 -0
  29. package/dist/commands/machines/dedicated/stop.js +42 -0
  30. package/dist/commands/machines/hosted/index.js +4 -1
  31. package/dist/commands/spaces/auto-executor/preview.d.ts +8 -0
  32. package/dist/commands/spaces/auto-executor/preview.js +41 -0
  33. package/dist/commands/spaces/task-rollups.d.ts +14 -0
  34. package/dist/commands/spaces/task-rollups.js +66 -0
  35. package/dist/commands/spaces/update.js +16 -12
  36. package/dist/commands/spaces/work-sync/reconcile.d.ts +38 -0
  37. package/dist/commands/spaces/work-sync/reconcile.js +90 -0
  38. package/dist/commands/spaces/work-sync/telemetry.d.ts +29 -0
  39. package/dist/commands/spaces/work-sync/telemetry.js +118 -0
  40. package/dist/commands/tasks/activity.js +5 -1
  41. package/dist/commands/tasks/assign.js +1 -1
  42. package/dist/commands/tasks/complete.d.ts +5 -1
  43. package/dist/commands/tasks/complete.js +17 -3
  44. package/dist/commands/tasks/create.d.ts +1 -0
  45. package/dist/commands/tasks/create.js +17 -4
  46. package/dist/commands/tasks/deliverable/add.js +22 -4
  47. package/dist/commands/tasks/deliverable/list.js +23 -2
  48. package/dist/commands/tasks/events/append.js +31 -3
  49. package/dist/commands/tasks/expectations/assess.d.ts +1 -1
  50. package/dist/commands/tasks/expectations/assess.js +33 -7
  51. package/dist/commands/tasks/expectations.js +110 -10
  52. package/dist/commands/tasks/output.d.ts +0 -1
  53. package/dist/commands/tasks/output.js +21 -1
  54. package/dist/commands/tasks/report.js +3 -1
  55. package/dist/commands/tasks/result/show.js +20 -2
  56. package/dist/commands/tasks/resume/save.js +11 -1
  57. package/dist/commands/tasks/self-schedule.js +7 -4
  58. package/dist/commands/tasks/show.js +19 -0
  59. package/dist/commands/tasks/timeline.js +9 -1
  60. package/dist/commands/tasks/updates/add.js +23 -2
  61. package/dist/lib/api-fetch.js +9 -1
  62. package/dist/lib/assignee-resolver.d.ts +7 -6
  63. package/dist/lib/daemonBroker.d.ts +20 -0
  64. package/dist/lib/daemonBroker.js +58 -17
  65. package/dist/lib/dedicated-machines.d.ts +66 -0
  66. package/dist/lib/dedicated-machines.js +161 -0
  67. package/dist/lib/harnesses.d.ts +5 -0
  68. package/dist/lib/harnesses.js +5 -2
  69. package/dist/lib/idempotency-scope.d.ts +10 -0
  70. package/dist/lib/idempotency-scope.js +16 -0
  71. package/dist/lib/node-adapter.d.ts +1 -1
  72. package/dist/lib/node-adapter.js +12 -10
  73. package/dist/lib/task-legacy.d.ts +24 -0
  74. package/dist/lib/task-legacy.js +48 -0
  75. package/dist/lib/tasks.d.ts +32 -0
  76. package/dist/lib/tasks.js +47 -1
  77. package/dist/lib/triggers.js +7 -0
  78. package/dist/lib/work-sync.d.ts +105 -0
  79. package/dist/lib/work-sync.js +29 -0
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +71 -21
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +71 -21
  82. package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
  83. package/dist/node_modules/@skrr-ai/data-provider/index.js +2141 -2061
  84. package/oclif.manifest.json +15661 -14585
  85. package/package.json +1 -1
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const core_1 = require("@oclif/core");
4
4
  const data_provider_1 = require("@skrr-ai/data-provider");
5
+ const data_provider_2 = require("@skrr-ai/data-provider");
5
6
  const base_command_1 = require("../../base-command");
6
7
  /**
7
8
  * `skrr tasks expectations <task>` — what this task owes, and how far each got.
@@ -23,16 +24,58 @@ const STATE_LABEL = {
23
24
  owed: 'not yet',
24
25
  unassessed: 'needs assessment',
25
26
  };
27
+ /**
28
+ * The evidence gap, on the rows where a reader can act on it.
29
+ *
30
+ * `unverified` and `underway` BOTH mean "something was produced and pinned to
31
+ * this Expectation, and it is not enough yet" — they differ only in whether the
32
+ * thing has been observed as available. Printing the gap on the first and
33
+ * nothing on the second is what made the careful path unreadable: an operator
34
+ * who pinned a deliverable saw `on its way` and had no way to learn that the
35
+ * bar was `external_receipt` and their own report could never clear it.
36
+ *
37
+ * It stays off the rows where it is noise: a met or waived row has nothing
38
+ * outstanding, and an `owed`/`unassessed` row has no deliverable to compare
39
+ * against, so naming a required authority there describes a hypothetical.
40
+ */
41
+ const PINNED_STATES = new Set(['unverified', 'underway']);
42
+ function evidenceGap(expectation) {
43
+ /*
44
+ * A `met` row settled BELOW its declared bar says so.
45
+ *
46
+ * `@merged-to-main` asks for `external_receipt` and is satisfiable by a person
47
+ * typing "I checked git log". That is allowed on purpose — refusing it is the
48
+ * deadlock the ladder exists to prevent — but every surface then read as though
49
+ * a receipt had arrived: the row said `done`, the rollup said `met`, and the
50
+ * recorded gap was visible only to someone who dumped the assessment rows and
51
+ * already knew the field existed.
52
+ */
53
+ if (expectation.evidenceShortfall) {
54
+ const { provided, required } = expectation.evidenceShortfall;
55
+ return ` (vouched at ${provided}, asked for ${required})`;
56
+ }
57
+ if (!PINNED_STATES.has(expectation.state))
58
+ return '';
59
+ if (!expectation.evidenceLevel || !expectation.requiredEvidence)
60
+ return '';
61
+ if (expectation.evidenceLevel === expectation.requiredEvidence)
62
+ return '';
63
+ return ` (has ${expectation.evidenceLevel}, needs ${expectation.requiredEvidence})`;
64
+ }
26
65
  function line(expectation) {
27
66
  const label = STATE_LABEL[expectation.state] || expectation.state;
28
67
  const from = expectation.source === 'space' ? ' [space default]' : '';
29
- // Only shown when the work exists and the evidence is short — stating the
30
- // required authority on every row would be noise on the rows where it is
31
- // already satisfied or not yet relevant.
32
- const evidence = expectation.state === 'unverified'
33
- ? ` (has ${expectation.evidenceLevel}, needs ${expectation.requiredEvidence})`
34
- : '';
35
- return ` ${expectation.id.padEnd(18)} ${label.padEnd(18)} ${expectation.text}${evidence}${from}`;
68
+ /*
69
+ * `sentence` before `text`.
70
+ *
71
+ * The server resolves `sentence` through `describeExpectation`, which is what
72
+ * appends a preset's destination — so `@wiki-page-published:Engineering/Deploy`
73
+ * and the same preset at `Ops/Oncall` are distinguishable. Printing `text`
74
+ * rendered the preset's fixed copy and dropped the one part that says WHICH
75
+ * thing is owed. The fallback is for a row that predates the resolved field.
76
+ */
77
+ const sentence = expectation.sentence || expectation.text;
78
+ return ` ${expectation.id.padEnd(18)} ${label.padEnd(18)} ${sentence}${evidenceGap(expectation)}${from}`;
36
79
  }
37
80
  class TasksExpectations extends base_command_1.BaseCommand {
38
81
  static description = 'Show what must be true before the Task can close and the evidence-backed assessment state';
@@ -79,12 +122,36 @@ class TasksExpectations extends base_command_1.BaseCommand {
79
122
  this.log(line(expectation));
80
123
  }
81
124
  const gating = acceptance.expectations.filter((e) => e.gating);
82
- const met = gating.filter((e) => e.met).length;
125
+ // `met: true` is the GATE's answer — a waiver unblocks closure exactly as a
126
+ // pass does — but it is the wrong REPORT. Counting them together printed
127
+ // "2 of 2 met" for a Task where one expectation was dropped, which is the
128
+ // one thing a reader checking this line needs to know.
129
+ const waived = gating.filter((e) => e.state === 'waived').length;
130
+ const met = gating.filter((e) => e.met).length - waived;
83
131
  this.log('');
84
- this.log(`${met} of ${gating.length} met · overall: ${acceptance.state}`);
132
+ this.log(`${met} of ${gating.length} met${waived > 0 ? ` · ${waived} waived` : ''} · overall: ${acceptance.state}`);
85
133
  // Space defaults are copied into the Task at creation. `source=space`
86
134
  // remains useful provenance, but these are Task-owned Expectations now and
87
135
  // are assessed exactly like explicitly declared ones.
136
+ /*
137
+ * A typed check nothing evaluates behaves exactly like `manual`.
138
+ *
139
+ * `EXPECTATION_CHECK_METHODS` declares six methods and the judge evaluates
140
+ * one: a `deliverable` check is compared against the ledger, and
141
+ * `task_state`, `goal_state`, `metric` and `llm_review` return `unassessed`
142
+ * forever. Verified against production — a `metric` check naming measureId,
143
+ * operator and target stores fine and is never looked at. The row read
144
+ * identically to a hand-written sentence, so somebody who chose
145
+ * `metric: cycle_time lt 2` had no way to learn the platform was not
146
+ * measuring it (OSK-8488).
147
+ */
148
+ const unevaluated = acceptance.expectations.filter((e) => (0, data_provider_2.declaresUnevaluatedCheck)(e));
149
+ if (unevaluated.length > 0) {
150
+ const methods = [...new Set(unevaluated.map((e) => e.check?.method).filter(Boolean))].join(', ');
151
+ this.log(`${unevaluated.length} declare${unevaluated.length === 1 ? 's' : ''} a typed check nothing ` +
152
+ `evaluates yet (${methods}) — a person settles ${unevaluated.length === 1 ? 'it' : 'them'} ` +
153
+ 'exactly as if the check said `manual`.');
154
+ }
88
155
  const settleable = acceptance.expectations.filter((e) => e.state === 'unassessed').length;
89
156
  if (settleable > 0) {
90
157
  this.log(`${settleable} awaiting assessment — use \`${this.config.bin} tasks expectations assess\`.`);
@@ -98,7 +165,40 @@ class TasksExpectations extends base_command_1.BaseCommand {
98
165
  `\`${this.config.bin} tasks deliverable confirm\`, or lower the bar with ` +
99
166
  `\`${this.config.bin} tasks trust\`.`);
100
167
  }
101
- if (gating.length > met) {
168
+ // `underway` had no trailer at all, which is why pinning a deliverable read
169
+ // as no progress: the row said `on its way` and nothing named the lever.
170
+ // A Deliverable is recorded `local` by default, so this is the state the
171
+ // ORDINARY `tasks deliverable add` lands in — the common case, not an edge.
172
+ const underway = acceptance.expectations.filter((e) => e.state === 'underway').length;
173
+ if (underway > 0) {
174
+ this.log(`${underway} recorded but not yet observed as available — say it arrived with ` +
175
+ `\`${this.config.bin} tasks deliverable confirm\`, or re-record it with ` +
176
+ `\`--status available\`.`);
177
+ }
178
+ // Counted separately from the unmet ones: these no longer block anything, and
179
+ // the reader's question about them is not "what do I do" but "how good is this
180
+ // record". Saying it once beats repeating the rungs on every row.
181
+ const shortfalls = acceptance.expectations.filter((e) => e.evidenceShortfall).length;
182
+ if (shortfalls > 0) {
183
+ this.log(`${shortfalls} settled below the evidence ${shortfalls === 1 ? 'bar it' : 'bars they'} ` +
184
+ `declared — allowed, and recorded as such on the assessment.`);
185
+ }
186
+ // Said once, for the whole task: `external_receipt` is the one bar no CLI
187
+ // command can clear. The only receipt producer in the platform is PR
188
+ // integration, so an operator staring at a row that needs one would
189
+ // otherwise keep looking for the command that supplies it.
190
+ const receiptBarred = acceptance.expectations.filter((e) => !e.met && e.requiredEvidence === 'external_receipt').length;
191
+ if (receiptBarred > 0) {
192
+ this.log(`${receiptBarred} ask${receiptBarred === 1 ? 's' : ''} for an external provider receipt, ` +
193
+ `which only an integration can supply — settle by hand with ` +
194
+ `\`${this.config.bin} tasks expectations assess\` (the gap is recorded), or lower the ` +
195
+ `bar with \`${this.config.bin} tasks trust\`.`);
196
+ }
197
+ // Settled, not "met" — a waiver settles an Expectation as surely as a pass,
198
+ // and splitting the two in the count above must not make this trailer fire
199
+ // on a Task where nothing is actually outstanding.
200
+ const settled = gating.filter((e) => e.met).length;
201
+ if (gating.length > settled) {
102
202
  this.log('');
103
203
  this.log('The current Result cannot be accepted while these Expectations remain unmet.');
104
204
  }
@@ -1,5 +1,4 @@
1
1
  import { BaseCommand } from '../../base-command';
2
- /** Compatibility command name. The authoritative object is Result, not output. */
3
2
  export default class TasksOutput extends BaseCommand {
4
3
  static hidden: boolean;
5
4
  static description: string;
@@ -7,6 +7,19 @@ const task_resolver_1 = require("../../lib/task-resolver");
7
7
  const web_url_1 = require("../../lib/web-url");
8
8
  const task_transcript_1 = require("../../lib/task-transcript");
9
9
  /** Compatibility command name. The authoritative object is Result, not output. */
10
+ /**
11
+ * "awaiting review" is only true while a review can still be recorded.
12
+ *
13
+ * A closed Task refuses one outright (`TASK_REOPEN_REQUIRED`), so printing
14
+ * "awaiting" described a state that could never be left — and the reader has no
15
+ * way to tell that from a review that is genuinely pending.
16
+ */
17
+ function reviewLine(review, task) {
18
+ if (review?.decision)
19
+ return review.decision;
20
+ const terminal = ['completed', 'cancelled'].includes(String(task?.statusType ?? ''));
21
+ return terminal ? 'none recorded — the Task is closed' : 'awaiting review';
22
+ }
10
23
  class TasksOutput extends base_command_1.BaseCommand {
11
24
  static hidden = true;
12
25
  static description = 'Show the current Result, Review, Deliverables, and final Run reply';
@@ -93,10 +106,17 @@ class TasksOutput extends base_command_1.BaseCommand {
93
106
  if (result.conclusion) {
94
107
  this.log(` conclusion (${result.conclusion.type}): ${JSON.stringify(result.conclusion.value)}`);
95
108
  }
96
- this.log(`Review: ${review?.decision || 'awaiting review'}`);
109
+ this.log(`Review: ${reviewLine(review, task)}`);
97
110
  if (review?.rationale)
98
111
  this.log(` ${review.rationale}`);
99
112
  }
113
+ else if (task.specVersion === undefined) {
114
+ // A Task predating the canonical model has no TaskResult row and never
115
+ // will. "none submitted" reads as "the work was never reported", which
116
+ // for a completed legacy Task is the opposite of the truth.
117
+ this.log('Result: not recorded under the canonical model (this Task predates it)');
118
+ this.log(' its closeout is in `skrr tasks comments list` / `tasks transcript`');
119
+ }
100
120
  else {
101
121
  this.log('Result: none submitted');
102
122
  }
@@ -14,7 +14,9 @@ const web_url_1 = require("../../lib/web-url");
14
14
  */
15
15
  class TasksReport extends base_command_1.BaseCommand {
16
16
  static hidden = true;
17
- static description = 'Submit a versioned Result with findings, evidence, and next actions';
17
+ static description = 'DEPRECATED compatibility spelling for `tasks result submit`. Every call submits a ' +
18
+ 'versioned Result; `--kind` is ignored. Use `tasks result submit` (submit only) or ' +
19
+ '`tasks complete` (submit and close).';
18
20
  static examples = [
19
21
  '<%= config.bin %> tasks report <task-id> --outcome "Adopt the event ledger" --finding "The task page omits work-event details" --evidence "Task detail reads only legacy activity" --next "Add a Result section"',
20
22
  '<%= config.bin %> tasks report <task-id> --kind handoff --outcome "Ready for review" --finding "All expectations are met" --deliverable "file:src/task.ts" --json',
@@ -4,6 +4,7 @@ const core_1 = require("@oclif/core");
4
4
  const data_provider_1 = require("@skrr-ai/data-provider");
5
5
  const base_command_1 = require("../../../base-command");
6
6
  const task_resolver_1 = require("../../../lib/task-resolver");
7
+ const task_legacy_1 = require("../../../lib/task-legacy");
7
8
  class TasksResultShow extends base_command_1.BaseCommand {
8
9
  static description = 'Show the current Task Result and its exact Review';
9
10
  static args = {
@@ -29,12 +30,29 @@ class TasksResultShow extends base_command_1.BaseCommand {
29
30
  return this.log(JSON.stringify(result, null, 2));
30
31
  const current = result.results.find((row) => row.id === result.currentResultId);
31
32
  if (!current)
32
- return this.log('No Result submitted.');
33
+ return this.log(await (0, task_legacy_1.emptyCanonicalView)(task.id, 'Result'));
33
34
  const review = result.reviews.find((row) => row.resultId === current.id);
34
35
  this.log(`Result v${current.version}\n${current.summary}`);
35
36
  if (current.conclusion)
36
37
  this.log(`Conclusion (${current.conclusion.type}): ${JSON.stringify(current.conclusion.value)}`);
37
- this.log(`Review: ${review ? review.decision : 'awaiting review'}`);
38
+ // Only "awaiting" while a review can still be recorded. A closed Task
39
+ // refuses one outright (`TASK_REOPEN_REQUIRED`), so the old wording named a
40
+ // state nothing could ever leave — and a reader could not tell it from a
41
+ // review that is genuinely pending. The extra read happens only on the
42
+ // no-review path, where the distinction is the whole point.
43
+ let reviewLine = 'awaiting review';
44
+ if (review) {
45
+ reviewLine = review.decision;
46
+ }
47
+ else {
48
+ const full = (await data_provider_1.dataService
49
+ .getTask(task.id, { enrich: false })
50
+ .catch(() => undefined));
51
+ if (['completed', 'cancelled'].includes(String(full?.statusType ?? ''))) {
52
+ reviewLine = 'none recorded — the Task is closed';
53
+ }
54
+ }
55
+ this.log(`Review: ${reviewLine}`);
38
56
  if (flags.history && result.results.length > 1) {
39
57
  this.log(`Superseded versions: ${result.results
40
58
  .slice(1)
@@ -15,7 +15,17 @@ class TasksResumeSave extends base_command_1.BaseCommand {
15
15
  static flags = {
16
16
  task: core_1.Flags.string({ description: 'Task id; overrides runtime context' }),
17
17
  'run-id': core_1.Flags.string({ required: true, description: 'Canonical Task Run' }),
18
- snapshot: core_1.Flags.string({ required: true, description: 'Resume Point JSON snapshot' }),
18
+ snapshot: core_1.Flags.string({
19
+ required: true,
20
+ description: 'Resume Point JSON snapshot. ' +
21
+ 'Every field is optional. `completed`, `nextActions` and `currentFocus` accept either a ' +
22
+ 'plain sentence or {summary,sourceRefs} — the object form is canonical and a bare string ' +
23
+ 'is lifted into it, so the same sentence you put in an Update’s nextActions works here ' +
24
+ 'unchanged. Shape: {completed:[], currentFocus, nextActions:[], ' +
25
+ 'openQuestions:[{question,sourceRef?}], relevantUpdateIds:[], evidenceRefs:[], ' +
26
+ 'deliverableRefs:[], childHeads:[{runId,resumePointId,status}], workspaceStateRef}. ' +
27
+ 'Never put a credential, token or lease in a snapshot — the server refuses one.',
28
+ }),
19
29
  'spec-version': core_1.Flags.integer({ description: 'Task specification version' }),
20
30
  'idempotency-key': core_1.Flags.string({ description: 'Stable retry key' }),
21
31
  json: core_1.Flags.boolean({ description: 'Output JSON' }),
@@ -31,10 +31,13 @@ class TasksSelfSchedule extends base_command_1.BaseCommand {
31
31
  at: core_1.Flags.string({ description: 'Absolute return time (ISO-8601 UTC)' }),
32
32
  in: core_1.Flags.string({ description: 'Relative return time such as 90m, 2h, 3d' }),
33
33
  note: core_1.Flags.string({
34
- // Was "handed to the agent as the return prompt", which is not true:
35
- // `task_execute` builds the run's prompt from the Task, so this string is
36
- // recorded and never read by the run. See `TaskScheduler/selfSchedule.js`.
37
- description: 'Why you are coming back recorded on the return for you to read back',
34
+ // This DOES reach the run now. It is read by `buildTaskExecutionPrompt`,
35
+ // the one place every dispatch path assembles a task prompt, and rendered
36
+ // as `### Why You Parked This Task` alongside the instant it was parked
37
+ // for. For a while it was recorded and never delivered, and the help said
38
+ // so; both halves are fixed rather than only the sentence.
39
+ description: 'Why you are coming back. Delivered to the agent on the return as its own ' +
40
+ 'prompt section, and readable any time with --show',
38
41
  }),
39
42
  'max-per-day': core_1.Flags.integer({
40
43
  description: 'Cap on self-initiated returns per day (1-24, default 4)',
@@ -262,6 +262,25 @@ class TasksShow extends base_command_1.BaseCommand {
262
262
  this.log(`Created: ${formatTime(t.createdAt)}`);
263
263
  if (t.updatedAt)
264
264
  this.log(`Updated: ${formatTime(t.updatedAt)}`);
265
+ /**
266
+ * Say which Task model this row belongs to, on the one surface everybody
267
+ * reads first.
268
+ *
269
+ * Canonical Task Model v1 deliberately migrated nothing, so a Task created
270
+ * before it is a second class of object with visibly different behaviour —
271
+ * no Updates, no Result, no Activity, no timeline. The only marker was
272
+ * `specVersion` being ABSENT under `--json`, and absence-as-a-marker is not
273
+ * something a reader can notice: you have to already know the field exists
274
+ * to observe that it does not. So `tasks show` said nothing, and the user
275
+ * found out by watching four other commands report emptiness about a Task
276
+ * with years of history.
277
+ *
278
+ * Printed only for the legacy class. A canonical Task needs no line saying
279
+ * it is ordinary.
280
+ */
281
+ if (t.specVersion === undefined || t.specVersion === null) {
282
+ this.log('Model: predates the canonical Task model — no Result, Updates, Activity or timeline');
283
+ }
265
284
  if (t.description) {
266
285
  this.log('');
267
286
  this.log('Description:');
@@ -5,6 +5,7 @@ const core_1 = require("@oclif/core");
5
5
  const data_provider_1 = require("@skrr-ai/data-provider");
6
6
  const base_command_1 = require("../../base-command");
7
7
  const format_1 = require("../../lib/format");
8
+ const task_legacy_1 = require("../../lib/task-legacy");
8
9
  /**
9
10
  * `<action> (<k=v ...>)`, or just the preview when there is nothing to add.
10
11
  *
@@ -49,7 +50,14 @@ class TasksTimeline extends base_command_1.BaseCommand {
49
50
  this.log(JSON.stringify(result, null, 2));
50
51
  return;
51
52
  }
52
- (0, format_1.renderTable)((result.data ?? []).map((item) => ({
53
+ const rows = result.data ?? [];
54
+ if (rows.length === 0) {
55
+ // `renderTable([])` prints NOTHING — no header, no empty state, exit 0 —
56
+ // which is indistinguishable from a broken command. Say what happened.
57
+ this.log(await (0, task_legacy_1.emptyCanonicalView)(args.id, 'timeline entries'));
58
+ return;
59
+ }
60
+ (0, format_1.renderTable)(rows.map((item) => ({
53
61
  time: String(item.occurredAt ?? '-')
54
62
  .replace('T', ' ')
55
63
  .slice(0, 19),
@@ -21,6 +21,9 @@ class TasksUpdatesAdd extends base_command_1.BaseCommand {
21
21
  static examples = [
22
22
  '<%= config.bin %> tasks updates add <task> --type progress --summary "Migration review complete" --data \'{"completedSinceLast":["Checked migrations 001-014"],"nextActions":["Review authorization"]}\'',
23
23
  '<%= config.bin %> tasks updates add <task> --type decision --summary "Use a resource object" --data \'{"decision":"Use WorkResource","rationale":"Identity must survive URL changes"}\'',
24
+ '<%= config.bin %> tasks updates add <task> --type finding --summary "Cross-tenant leak" --data \'{"finding":"Space ACL is not applied on the list path","impact":"Any member can read another space","severity":"high","evidenceRefs":[]}\'',
25
+ '<%= config.bin %> tasks updates add <task> --type blocker --summary "Needs a prod credential" --data \'{"cause":"No Atlas API key in this runtime","requiredAction":"Provision a read-only key","nextOwner":{"kind":"human","userId":"<user-id>"}}\'',
26
+ '<%= config.bin %> tasks updates add <task> --type verification --summary "Repro is gone" --data \'{"claim":"The 409 no longer reproduces","result":"passed","evidenceRefs":[]}\'',
24
27
  ];
25
28
  static args = {
26
29
  task: core_1.Args.string({ required: false, ignoreStdin: true, description: 'Task id or ref' }),
@@ -33,9 +36,27 @@ class TasksUpdatesAdd extends base_command_1.BaseCommand {
33
36
  description: 'What changed in the shared understanding of the Task',
34
37
  }),
35
38
  summary: core_1.Flags.string({ required: true, description: 'One sentence saying what became true' }),
36
- data: core_1.Flags.string({ required: true, description: 'Kind-specific JSON payload' }),
39
+ data: core_1.Flags.string({
40
+ required: true,
41
+ description: 'Kind-specific JSON payload. The server enforces one shape per --type, and the array ' +
42
+ 'fields are required even when empty. ' +
43
+ 'progress {completedSinceLast:[],nextActions:[],currentFocus?} · ' +
44
+ 'finding {finding,evidenceRefs:[],impact?,severity?} · ' +
45
+ 'decision {decision,rationale,alternativesConsidered?} · ' +
46
+ 'blocker {cause,requiredAction,nextOwner} · ' +
47
+ 'verification {claim,result:passed|failed|inconclusive,evidenceRefs:[]}. ' +
48
+ 'nextOwner is a principal object like {"kind":"human","userId":"..."}, not a name. ' +
49
+ "Note --reference sets the Update's own references, NOT data.evidenceRefs.",
50
+ }),
37
51
  resume: core_1.Flags.string({
38
- description: 'JSON recovery snapshot; requires --run-id and is committed with the Update',
52
+ description: 'JSON recovery snapshot; requires --run-id and is committed with the Update. ' +
53
+ 'Every field is optional. `completed`, `nextActions` and `currentFocus` accept either a ' +
54
+ 'plain sentence or {summary,sourceRefs} — the object form is canonical and a bare string ' +
55
+ 'is lifted into it, so the same sentence you put in an Update’s nextActions works here ' +
56
+ 'unchanged. Shape: {completed:[], currentFocus, nextActions:[], ' +
57
+ 'openQuestions:[{question,sourceRef?}], relevantUpdateIds:[], evidenceRefs:[], ' +
58
+ 'deliverableRefs:[], childHeads:[{runId,resumePointId,status}], workspaceStateRef}. ' +
59
+ 'Never put a credential, token or lease in a snapshot — the server refuses one.',
39
60
  }),
40
61
  'run-id': core_1.Flags.string({ description: 'Canonical Task Run that performed this work' }),
41
62
  'spec-version': core_1.Flags.integer({
@@ -37,6 +37,8 @@ const config_1 = require("./config");
37
37
  // (b) tests that mock './auth-storage' get a single mock target.
38
38
  const auth_storage_1 = require("./auth-storage");
39
39
  const credential_resolver_1 = require("./credential-resolver");
40
+ const data_provider_1 = require("@skrr-ai/data-provider");
41
+ const idempotency_scope_1 = require("./idempotency-scope");
40
42
  const node_adapter_1 = require("./node-adapter");
41
43
  const refresh_1 = require("./refresh");
42
44
  const delegated_cli_1 = require("./delegated-cli");
@@ -95,6 +97,12 @@ async function apiFetch(pathOrUrl, opts = {}) {
95
97
  if (opts.body !== undefined && method !== 'GET') {
96
98
  body = JSON.stringify(opts.body);
97
99
  }
100
+ // Same rule as every `dataService` write (`@skrr-ai/data-provider`
101
+ // idempotency), chosen once so the 401 retries below resend the same key.
102
+ (0, idempotency_scope_1.bindAutonomousIdempotencyScope)();
103
+ const callerHeaders = method === 'GET'
104
+ ? (opts.headers ?? {})
105
+ : (0, data_provider_1.withIdempotencyKey)(opts.headers, { method, url: pathOrUrl, body: opts.body });
98
106
  // A trusted Agent process cannot smuggle an explicit human credential into
99
107
  // this escape hatch. The daemon-issued session token is the sole principal.
100
108
  //
@@ -146,7 +154,7 @@ async function apiFetch(pathOrUrl, opts = {}) {
146
154
  // Caller headers are applied BEFORE the auth header below is read, and
147
155
  // deliberately cannot clobber Authorization / Content-Type: an idempotency
148
156
  // key is additive metadata, not a way to re-auth the request.
149
- for (const [k, v] of Object.entries(opts.headers ?? {})) {
157
+ for (const [k, v] of Object.entries(callerHeaders)) {
150
158
  const lower = k.toLowerCase();
151
159
  if (lower === 'authorization' || lower === 'content-type')
152
160
  continue;
@@ -9,12 +9,13 @@
9
9
  * reference into exactly one member of the task's space, or an actionable
10
10
  * failure that names the candidates.
11
11
  *
12
- * Scoping to the space is not just ergonomics. The server rejects assignees
13
- * that are not space members (`SpacePermissionService.validateAssigneeSpaceMembership`
14
- * → `ASSIGNEE_NOT_IN_SPACE`), so resolving against the space membership list
15
- * means a name that resolves is a name that will be accepted we never build
16
- * a request the server is guaranteed to refuse, and we cannot accidentally
17
- * leak a directory-wide user search into a space the caller can't see.
12
+ * Scoping to the space is not just ergonomics. The server accepts space members
13
+ * and, beyond them, only the caller's own agent when it is shared with no one
14
+ * else (`AgentTaskConsent` otherwise `ASSIGNEE_NOT_IN_SPACE`), so a name that
15
+ * resolves against the membership list is a name that will be accepted, and we
16
+ * cannot accidentally leak a directory-wide user search into a space the caller
17
+ * can't see. An owner-delegated agent is not a member and so is not resolvable
18
+ * by name here; it is passed by raw id.
18
19
  */
19
20
  export type MemberKind = 'user' | 'agent';
20
21
  export type SpaceMember = {
@@ -139,6 +139,8 @@ export interface DaemonBrokerOptions {
139
139
  profile?: string;
140
140
  /** Override the bootstrap file path entirely (tests / unusual layouts). */
141
141
  bootstrapPathOverride?: string;
142
+ /** Test seam: the ordered candidate list, when one path is not enough. */
143
+ candidatePathsOverride?: readonly string[];
142
144
  /** Override total request timeout. Tests use a short value. */
143
145
  timeoutMs?: number;
144
146
  }
@@ -159,6 +161,24 @@ export declare function resolveProfileBootstrapPaths(primary: string, entryNames
159
161
  * deterministic when more than one matching daemon is running.
160
162
  */
161
163
  export declare function resolveBootstrapCandidatePaths(profile?: string): string[];
164
+ /**
165
+ * A Dedicated Runtime guest's workload hand-off descriptor.
166
+ *
167
+ * On that product the daemon runs as `skrr-runtime` and everything the user runs
168
+ * — terminals, Bash tool calls, engines — as `skrr-workload`, so the island
169
+ * bootstrap above sits in a home this process cannot read. The daemon publishes
170
+ * this second descriptor to the shared workload group instead
171
+ * (`daemon/src/dedicated-cli-handoff.ts`). Its secret is accepted by the
172
+ * cli-handoff route and nothing else, so it is a hand-off candidate ONLY: it is
173
+ * deliberately absent from `resolveBootstrapCandidatePaths`, which message-intent
174
+ * submission also reads and which needs the island secret.
175
+ *
176
+ * Fixed and root-controlled (the daemon unit's RuntimeDirectory), so nothing the
177
+ * user configures can point the broker somewhere else.
178
+ */
179
+ export declare const DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = "/run/skrr-dedicated-runtime/cli-handoff/descriptor.json";
180
+ /** Where the cli-handoff broker looks, in order. */
181
+ export declare function resolveCliHandoffCandidatePaths(profile?: string): string[];
162
182
  /**
163
183
  * Resolve the first live daemon bootstrap for a profile. Message-intent
164
184
  * submission reuses the same authenticated loopback discovery as the login
@@ -71,10 +71,12 @@ var __importStar = (this && this.__importStar) || (function () {
71
71
  };
72
72
  })();
73
73
  Object.defineProperty(exports, "__esModule", { value: true });
74
+ exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = void 0;
74
75
  exports.normalizeBaseUrl = normalizeBaseUrl;
75
76
  exports.resolveBootstrapPath = resolveBootstrapPath;
76
77
  exports.resolveProfileBootstrapPaths = resolveProfileBootstrapPaths;
77
78
  exports.resolveBootstrapCandidatePaths = resolveBootstrapCandidatePaths;
79
+ exports.resolveCliHandoffCandidatePaths = resolveCliHandoffCandidatePaths;
78
80
  exports.findLiveDaemonBootstrap = findLiveDaemonBootstrap;
79
81
  exports.readBootstrap = readBootstrap;
80
82
  exports.attemptDaemonBrokerLogin = attemptDaemonBrokerLogin;
@@ -164,6 +166,33 @@ function resolveBootstrapCandidatePaths(profile = 'default') {
164
166
  return process.platform === 'darwin' ? [primary] : [primary, legacyBootstrapPath()];
165
167
  }
166
168
  }
169
+ /**
170
+ * A Dedicated Runtime guest's workload hand-off descriptor.
171
+ *
172
+ * On that product the daemon runs as `skrr-runtime` and everything the user runs
173
+ * — terminals, Bash tool calls, engines — as `skrr-workload`, so the island
174
+ * bootstrap above sits in a home this process cannot read. The daemon publishes
175
+ * this second descriptor to the shared workload group instead
176
+ * (`daemon/src/dedicated-cli-handoff.ts`). Its secret is accepted by the
177
+ * cli-handoff route and nothing else, so it is a hand-off candidate ONLY: it is
178
+ * deliberately absent from `resolveBootstrapCandidatePaths`, which message-intent
179
+ * submission also reads and which needs the island secret.
180
+ *
181
+ * Fixed and root-controlled (the daemon unit's RuntimeDirectory), so nothing the
182
+ * user configures can point the broker somewhere else.
183
+ */
184
+ exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = '/run/skrr-dedicated-runtime/cli-handoff/descriptor.json';
185
+ /** Where the cli-handoff broker looks, in order. */
186
+ function resolveCliHandoffCandidatePaths(profile = 'default') {
187
+ const candidates = resolveBootstrapCandidatePaths(profile);
188
+ // Any profile: a guest runs one daemon, and the auto-broker resolves the
189
+ // default profile regardless of `--profile`, so a narrower condition here would
190
+ // only describe behaviour production never reaches.
191
+ if (process.platform === 'linux') {
192
+ candidates.push(exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR);
193
+ }
194
+ return candidates;
195
+ }
167
196
  /**
168
197
  * Pre-Phase-K bootstrap path (non-darwin). Read-only fallback consulted
169
198
  * when the canonical path is absent — covers a freshly-upgraded daemon
@@ -247,13 +276,15 @@ function readBootstrap(filePath) {
247
276
  */
248
277
  async function attemptDaemonBrokerLogin(opts) {
249
278
  const profile = opts.profile ?? 'default';
250
- const candidates = opts.bootstrapPathOverride
251
- ? [opts.bootstrapPathOverride]
252
- : resolveBootstrapCandidatePaths(profile);
253
- let bootstrap = null;
254
- let usedPath = null;
255
- let unboundBootstrap = null;
256
- let unboundPath = null;
279
+ const candidates = opts.candidatePathsOverride
280
+ ? [...opts.candidatePathsOverride]
281
+ : opts.bootstrapPathOverride
282
+ ? [opts.bootstrapPathOverride]
283
+ : resolveCliHandoffCandidatePaths(profile);
284
+ // Bound candidates (their serverUrl matches this CLI) in order, then unbound
285
+ // ones. Every usable candidate is kept, not just the first: see below.
286
+ const usable = [];
287
+ const unbound = [];
257
288
  let mismatchedBootstrap = null;
258
289
  for (const candidate of candidates) {
259
290
  const candidateBootstrap = readBootstrap(candidate);
@@ -269,19 +300,13 @@ async function attemptDaemonBrokerLogin(opts) {
269
300
  }
270
301
  if (typeof candidateBootstrap.serverUrl !== 'string' ||
271
302
  candidateBootstrap.serverUrl.length === 0) {
272
- unboundBootstrap ??= candidateBootstrap;
273
- unboundPath ??= candidate;
303
+ unbound.push(candidateBootstrap);
274
304
  continue;
275
305
  }
276
- bootstrap = candidateBootstrap;
277
- usedPath = candidate;
278
- break;
279
- }
280
- if (!bootstrap && unboundBootstrap && unboundPath) {
281
- bootstrap = unboundBootstrap;
282
- usedPath = unboundPath;
306
+ usable.push(candidateBootstrap);
283
307
  }
284
- if (!bootstrap || !usedPath) {
308
+ usable.push(...unbound);
309
+ if (usable.length === 0) {
285
310
  if (mismatchedBootstrap?.serverUrl) {
286
311
  return {
287
312
  ok: false,
@@ -296,6 +321,22 @@ async function attemptDaemonBrokerLogin(opts) {
296
321
  detail: `No daemon bootstrap file found (looked in ${candidates.join(', ')})`,
297
322
  };
298
323
  }
324
+ // A candidate nothing is listening behind moves on to the next one. Liveness is
325
+ // only a pid check, and EPERM counts as alive — on a Dedicated Runtime guest
326
+ // nearly every pid belongs to another uid, so a stale island file in the
327
+ // workload's own home (restored dotfiles, say) passes it and used to shadow the
328
+ // daemon's live hand-off descriptor forever, with `skrr` reporting "Not signed
329
+ // in". Only a refused connection falls through: any answer, or a timeout, is a
330
+ // daemon that is really there, and its outcome stands.
331
+ let outcome = null;
332
+ for (const bootstrap of usable) {
333
+ outcome = await brokerThrough(bootstrap, opts);
334
+ if (outcome.ok || outcome.reason !== 'network')
335
+ return outcome;
336
+ }
337
+ return outcome;
338
+ }
339
+ async function brokerThrough(bootstrap, opts) {
299
340
  const url = `http://${bootstrap.host}:${bootstrap.port}/v1/auth/cli-handoff`;
300
341
  const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
301
342
  const controller = new AbortController();